feat(checklists): t-paliad-225 Slice B backend — explicit sharing + admin promotion
m/paliad#61 Slice B backend. Implements the explicit-share path
(checklist_shares + visibility predicate extension) and the
global_admin-only promotion / demotion of authored templates to and
from the firm catalog.
Schema (mig 115, idempotent):
- paliad.checklist_shares (uuid id, checklist_id FK, polymorphic
recipient via xor-check: recipient_kind in {user, office,
partner_unit, project} with exactly one matching recipient_* column
populated; granted_by FK; granted_at)
- Hot-path lookup index + per-kind partial UNIQUE indexes prevent
duplicate grants
- RLS: SELECT owner OR self-recipient (user-kind) OR global_admin;
INSERT owner-only with granted_by=self; DELETE owner OR global_admin;
no UPDATE (revoke = DELETE)
- can_see_checklist CREATE OR REPLACE — adds 4 share branches; project-
share branch uses inline ltree walk over projects.path because
can_see_project reads auth.uid() (NULL on service-role connection,
same pattern as visibility.go)
- xor-check verified live: rejects kind='user' with recipient_office
set; accepts the matching kind/recipient pair
Services:
- ChecklistShareService — Grant (owner-only, validates recipient kind +
required FK target, friendly 409 on partial-unique-index conflict),
Revoke (owner or global_admin), ListGrants (owner or global_admin;
enriches recipient_label via LEFT JOINs)
- ChecklistPromotionService — Promote (global_admin → visibility=global
+ promoted_at/by + audit), Demote (global_admin → target visibility,
default 'firm', clears promoted_at/by; rejects demote of non-global
rows)
- ChecklistCatalogService.checklistVisibilityPredicate extended to
include all 5 share branches; service-role-friendly (no auth.uid())
- ChecklistTemplateService.normaliseSliceAVisibility now accepts
'shared' as an author-set value; 'global' stays admin-only
Endpoints:
- GET /api/checklists/templates/{slug}/shares — list grants (owner/admin)
- POST /api/checklists/templates/{slug}/shares — grant
- DELETE /api/checklists/shares/{id} — revoke
- POST /api/admin/checklists/{slug}/promote — promote to global
- POST /api/admin/checklists/{slug}/demote — demote (body.target default 'firm')
Audit (paliad.system_audit_log):
- checklist.shared — recipient_kind + recipient_id in metadata
- checklist.unshared — same shape, captured pre-DELETE
- checklist.promoted_global — prior_visibility + owner_id
- checklist.demoted — target_visibility
Tests: validateShareInput covers all 4 kinds (happy + missing-id);
predicate-shape test asserts all 6 visibility branches present;
pqUniqueViolation regex sniff; nullableString helper; SliceB visibility
opens 'shared' but keeps 'global' admin-only.
Hotfix-merge note: head shipped 794617c after Slice A — the
template-edit page route moved from /checklists/{slug}/edit to
/checklists/templates/{slug}/edit to disambiguate from
/checklists/instances/{id}. Slice B routes follow the safe
/<resource>/<noun>/{id} pattern (no new {slug}-then-verb endpoints).
This commit is contained in:
@@ -152,8 +152,10 @@ func main() {
|
||||
eventTypeSvc := services.NewEventTypeService(pool, users)
|
||||
deadlineSvc := services.NewDeadlineService(pool, projectSvc, eventTypeSvc)
|
||||
// t-paliad-225 Slice A — user-authored checklist templates.
|
||||
// Slice B adds checklist_shares grants + admin promotion.
|
||||
checklistCatalogSvc := services.NewChecklistCatalogService(pool)
|
||||
sysAuditSvc := services.NewSystemAuditLogService(pool)
|
||||
checklistTemplateSvc := services.NewChecklistTemplateService(pool, checklistCatalogSvc, sysAuditSvc, users)
|
||||
svcBundle = &handlers.Services{
|
||||
Project: projectSvc,
|
||||
Team: teamSvc,
|
||||
@@ -184,7 +186,9 @@ func main() {
|
||||
Note: services.NewNoteService(pool, projectSvc, appointmentSvc),
|
||||
ChecklistInst: services.NewChecklistInstanceService(pool, projectSvc, checklistCatalogSvc),
|
||||
ChecklistCatalog: checklistCatalogSvc,
|
||||
ChecklistTemplate: services.NewChecklistTemplateService(pool, checklistCatalogSvc, sysAuditSvc, users),
|
||||
ChecklistTemplate: checklistTemplateSvc,
|
||||
ChecklistShare: services.NewChecklistShareService(pool, checklistTemplateSvc, sysAuditSvc, users),
|
||||
ChecklistPromotion: services.NewChecklistPromotionService(pool, checklistTemplateSvc, sysAuditSvc, users),
|
||||
Mail: mailSvc,
|
||||
Invite: inviteSvc,
|
||||
Agenda: services.NewAgendaService(pool, users, eventTypeSvc),
|
||||
|
||||
26
internal/db/migrations/115_checklist_shares.down.sql
Normal file
26
internal/db/migrations/115_checklist_shares.down.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- Reverse of mig 115 — t-paliad-225 / m/paliad#61 Slice B.
|
||||
--
|
||||
-- Restore the owner+firm/global-only body of paliad.can_see_checklist
|
||||
-- (matches the mig 114 definition) so a rollback of Slice B leaves the
|
||||
-- function pointing at the Slice A behaviour.
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.can_see_checklist(_user_id uuid, _checklist_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'paliad', 'public'
|
||||
AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = _checklist_id AND c.owner_id = _user_id
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = _checklist_id AND c.visibility IN ('firm', 'global')
|
||||
);
|
||||
$$;
|
||||
|
||||
DROP POLICY IF EXISTS checklist_shares_delete ON paliad.checklist_shares;
|
||||
DROP POLICY IF EXISTS checklist_shares_insert ON paliad.checklist_shares;
|
||||
DROP POLICY IF EXISTS checklist_shares_select ON paliad.checklist_shares;
|
||||
|
||||
DROP TABLE IF EXISTS paliad.checklist_shares;
|
||||
211
internal/db/migrations/115_checklist_shares.up.sql
Normal file
211
internal/db/migrations/115_checklist_shares.up.sql
Normal file
@@ -0,0 +1,211 @@
|
||||
-- mig 115 — t-paliad-225 / m/paliad#61 Slice B — explicit sharing +
|
||||
-- admin-promotion plumbing for user-authored checklists.
|
||||
--
|
||||
-- Design: docs/design-user-checklists-2026-05-20.md §3.2 / §4.2 / §4.3
|
||||
-- / §4.5.
|
||||
--
|
||||
-- Introduces paliad.checklist_shares with the polymorphic recipient
|
||||
-- pattern (xor-check enforces exactly one recipient_* column populated
|
||||
-- per recipient_kind). Extends paliad.can_see_checklist with the
|
||||
-- explicit-share branches so the 'shared' visibility level actually
|
||||
-- gates anything.
|
||||
--
|
||||
-- Sections:
|
||||
-- 1. CREATE TABLE paliad.checklist_shares (+ indexes + RLS).
|
||||
-- 2. CREATE OR REPLACE paliad.can_see_checklist — adds 4 share
|
||||
-- branches (user / office / partner_unit / project).
|
||||
--
|
||||
-- Idempotent throughout.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. paliad.checklist_shares — explicit grants for a single checklist.
|
||||
--
|
||||
-- recipient_kind disambiguates which recipient_* column is populated.
|
||||
-- The XOR check makes the constraint structurally enforce "exactly one
|
||||
-- recipient_<kind> non-null per row". Per-kind UNIQUE partial indexes
|
||||
-- prevent duplicate grants per (checklist, recipient).
|
||||
--
|
||||
-- Slice A's checklists.visibility CHECK already includes 'shared' so no
|
||||
-- ALTER is needed here.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.checklist_shares (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
checklist_id uuid NOT NULL REFERENCES paliad.checklists(id) ON DELETE CASCADE,
|
||||
recipient_kind text NOT NULL
|
||||
CHECK (recipient_kind IN ('user', 'office', 'partner_unit', 'project')),
|
||||
recipient_user_id uuid REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
recipient_office text,
|
||||
recipient_partner_unit_id uuid REFERENCES paliad.partner_units(id) ON DELETE CASCADE,
|
||||
recipient_project_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
granted_by uuid NOT NULL REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
granted_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT checklist_shares_recipient_xor CHECK (
|
||||
(recipient_kind = 'user'
|
||||
AND recipient_user_id IS NOT NULL
|
||||
AND recipient_office IS NULL
|
||||
AND recipient_partner_unit_id IS NULL
|
||||
AND recipient_project_id IS NULL)
|
||||
OR (recipient_kind = 'office'
|
||||
AND recipient_office IS NOT NULL
|
||||
AND recipient_user_id IS NULL
|
||||
AND recipient_partner_unit_id IS NULL
|
||||
AND recipient_project_id IS NULL)
|
||||
OR (recipient_kind = 'partner_unit'
|
||||
AND recipient_partner_unit_id IS NOT NULL
|
||||
AND recipient_user_id IS NULL
|
||||
AND recipient_office IS NULL
|
||||
AND recipient_project_id IS NULL)
|
||||
OR (recipient_kind = 'project'
|
||||
AND recipient_project_id IS NOT NULL
|
||||
AND recipient_user_id IS NULL
|
||||
AND recipient_office IS NULL
|
||||
AND recipient_partner_unit_id IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
-- Hot-path lookup for the visibility predicate.
|
||||
CREATE INDEX IF NOT EXISTS checklist_shares_lookup_idx
|
||||
ON paliad.checklist_shares (checklist_id);
|
||||
|
||||
-- Uniqueness per recipient kind. Partial indexes so a NULL recipient_<other>
|
||||
-- doesn't collide with another row's NULL recipient_<other>.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_user_uniq
|
||||
ON paliad.checklist_shares (checklist_id, recipient_user_id)
|
||||
WHERE recipient_kind = 'user';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_office_uniq
|
||||
ON paliad.checklist_shares (checklist_id, recipient_office)
|
||||
WHERE recipient_kind = 'office';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_partner_unit_uniq
|
||||
ON paliad.checklist_shares (checklist_id, recipient_partner_unit_id)
|
||||
WHERE recipient_kind = 'partner_unit';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_project_uniq
|
||||
ON paliad.checklist_shares (checklist_id, recipient_project_id)
|
||||
WHERE recipient_kind = 'project';
|
||||
|
||||
COMMENT ON TABLE paliad.checklist_shares IS
|
||||
'Explicit grants for paliad.checklists. Polymorphic recipient '
|
||||
'(user/office/partner_unit/project) enforced by recipient_xor CHECK. '
|
||||
'Owner of the checklist grants and revokes; global_admin can revoke '
|
||||
'as well. Slice B (t-paliad-225) — see can_see_checklist body for '
|
||||
'the visibility branches that consume these rows.';
|
||||
|
||||
ALTER TABLE paliad.checklist_shares ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- SELECT: caller can see the row if they own the parent checklist OR
|
||||
-- they are the recipient (for user-kind grants — recipients shouldn't
|
||||
-- be surprised by who else can also see the checklist) OR global_admin.
|
||||
DROP POLICY IF EXISTS checklist_shares_select ON paliad.checklist_shares;
|
||||
CREATE POLICY checklist_shares_select
|
||||
ON paliad.checklist_shares FOR SELECT TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = checklist_id AND c.owner_id = auth.uid()
|
||||
)
|
||||
OR (recipient_kind = 'user' AND recipient_user_id = auth.uid())
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- INSERT: only the checklist owner can grant; granted_by must be self.
|
||||
DROP POLICY IF EXISTS checklist_shares_insert ON paliad.checklist_shares;
|
||||
CREATE POLICY checklist_shares_insert
|
||||
ON paliad.checklist_shares FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = checklist_id AND c.owner_id = auth.uid()
|
||||
)
|
||||
AND granted_by = auth.uid()
|
||||
);
|
||||
|
||||
-- DELETE: owner OR global_admin. No UPDATE policy — grants are
|
||||
-- immutable, revoke = DELETE + re-insert with the corrected recipient.
|
||||
DROP POLICY IF EXISTS checklist_shares_delete ON paliad.checklist_shares;
|
||||
CREATE POLICY checklist_shares_delete
|
||||
ON paliad.checklist_shares FOR DELETE TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = checklist_id AND c.owner_id = auth.uid()
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. paliad.can_see_checklist — extend with the 4 share branches.
|
||||
--
|
||||
-- Owner + firm/global branches stay as in mig 114. Share branches:
|
||||
-- - user — the row's recipient_user_id matches the caller
|
||||
-- - office — recipient_office matches caller's office OR is in
|
||||
-- their additional_offices array
|
||||
-- - partner_unit — caller is a member of the recipient partner_unit
|
||||
-- - project — caller can see the recipient project (reuses
|
||||
-- paliad.can_see_project, ltree-walked)
|
||||
--
|
||||
-- can_see_project reads auth.uid() through SECURITY DEFINER inheritance
|
||||
-- (same pattern effective_project_admin uses in mig 111).
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.can_see_checklist(_user_id uuid, _checklist_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'paliad', 'public'
|
||||
AS $$
|
||||
-- Owner
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = _checklist_id AND c.owner_id = _user_id
|
||||
)
|
||||
-- firm / global
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = _checklist_id AND c.visibility IN ('firm', 'global')
|
||||
)
|
||||
-- Explicit share: user
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.checklist_shares s
|
||||
WHERE s.checklist_id = _checklist_id
|
||||
AND s.recipient_kind = 'user'
|
||||
AND s.recipient_user_id = _user_id
|
||||
)
|
||||
-- Explicit share: office (caller's primary OR additional offices)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.checklist_shares s
|
||||
JOIN paliad.users u ON u.id = _user_id
|
||||
WHERE s.checklist_id = _checklist_id
|
||||
AND s.recipient_kind = 'office'
|
||||
AND (s.recipient_office = u.office
|
||||
OR s.recipient_office = ANY(u.additional_offices))
|
||||
)
|
||||
-- Explicit share: partner_unit (caller is a member)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.checklist_shares s
|
||||
JOIN paliad.partner_unit_members pum
|
||||
ON pum.partner_unit_id = s.recipient_partner_unit_id
|
||||
AND pum.user_id = _user_id
|
||||
WHERE s.checklist_id = _checklist_id
|
||||
AND s.recipient_kind = 'partner_unit'
|
||||
)
|
||||
-- Explicit share: project (caller can see the project via existing predicate)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.checklist_shares s
|
||||
WHERE s.checklist_id = _checklist_id
|
||||
AND s.recipient_kind = 'project'
|
||||
AND paliad.can_see_project(s.recipient_project_id)
|
||||
);
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.can_see_checklist(uuid, uuid) IS
|
||||
'True iff the user owns the checklist OR firm/global visibility OR '
|
||||
'an explicit share row matches the caller (by user / office / '
|
||||
'partner_unit / project ancestry).';
|
||||
131
internal/handlers/checklist_shares.go
Normal file
131
internal/handlers/checklist_shares.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// GET /api/checklists/templates/{slug}/shares — list grants (owner/admin).
|
||||
func handleListChecklistShares(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slug := r.PathValue("slug")
|
||||
rows, err := dbSvc.checklistShare.ListGrants(r.Context(), uid, slug)
|
||||
if err != nil {
|
||||
writeChecklistShareError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// POST /api/checklists/templates/{slug}/shares — grant a share.
|
||||
func handleGrantChecklistShare(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slug := r.PathValue("slug")
|
||||
var input services.ShareGrantInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
share, err := dbSvc.checklistShare.Grant(r.Context(), uid, slug, input)
|
||||
if err != nil {
|
||||
writeChecklistShareError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, share)
|
||||
}
|
||||
|
||||
// DELETE /api/checklists/shares/{id} — revoke a share by id.
|
||||
func handleRevokeChecklistShare(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
if err := dbSvc.checklistShare.Revoke(r.Context(), uid, id); err != nil {
|
||||
writeChecklistShareError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// POST /api/admin/checklists/{slug}/promote — global_admin only.
|
||||
func handlePromoteChecklist(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slug := r.PathValue("slug")
|
||||
if err := dbSvc.checklistPromotion.Promote(r.Context(), uid, slug); err != nil {
|
||||
writeChecklistShareError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// POST /api/admin/checklists/{slug}/demote — global_admin only.
|
||||
func handleDemoteChecklist(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slug := r.PathValue("slug")
|
||||
var body struct {
|
||||
Target string `json:"target"`
|
||||
}
|
||||
// Body is optional — Demote defaults to 'firm' when empty.
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if err := dbSvc.checklistPromotion.Demote(r.Context(), uid, slug, body.Target); err != nil {
|
||||
writeChecklistShareError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// writeChecklistShareError maps the share/promotion service errors.
|
||||
// Same as the templates handler: ErrInvalidInput → 400, ErrForbidden →
|
||||
// 403, ErrNotVisible → 404, fall through to writeServiceError.
|
||||
func writeChecklistShareError(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, services.ErrInvalidInput) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, services.ErrForbidden) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, services.ErrNotVisible) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "checklist not found"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
}
|
||||
@@ -70,9 +70,11 @@ type Services struct {
|
||||
EventType *services.EventTypeService
|
||||
Dashboard *services.DashboardService
|
||||
Note *services.NoteService
|
||||
ChecklistInst *services.ChecklistInstanceService
|
||||
ChecklistCatalog *services.ChecklistCatalogService
|
||||
ChecklistTemplate *services.ChecklistTemplateService
|
||||
ChecklistInst *services.ChecklistInstanceService
|
||||
ChecklistCatalog *services.ChecklistCatalogService
|
||||
ChecklistTemplate *services.ChecklistTemplateService
|
||||
ChecklistShare *services.ChecklistShareService
|
||||
ChecklistPromotion *services.ChecklistPromotionService
|
||||
Mail *services.MailService
|
||||
Invite *services.InviteService
|
||||
Agenda *services.AgendaService
|
||||
@@ -146,9 +148,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
eventType: svc.EventType,
|
||||
dashboard: svc.Dashboard,
|
||||
note: svc.Note,
|
||||
checklistInst: svc.ChecklistInst,
|
||||
checklistCatalog: svc.ChecklistCatalog,
|
||||
checklistTemplate: svc.ChecklistTemplate,
|
||||
checklistInst: svc.ChecklistInst,
|
||||
checklistCatalog: svc.ChecklistCatalog,
|
||||
checklistTemplate: svc.ChecklistTemplate,
|
||||
checklistShare: svc.ChecklistShare,
|
||||
checklistPromotion: svc.ChecklistPromotion,
|
||||
mail: svc.Mail,
|
||||
invite: svc.Invite,
|
||||
agenda: svc.Agenda,
|
||||
@@ -265,6 +269,12 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("PATCH /api/checklists/templates/{slug}", handleUpdateChecklistTemplate)
|
||||
protected.HandleFunc("PATCH /api/checklists/templates/{slug}/visibility", handleSetChecklistTemplateVisibility)
|
||||
protected.HandleFunc("DELETE /api/checklists/templates/{slug}", handleDeleteChecklistTemplate)
|
||||
// t-paliad-225 Slice B — explicit sharing + admin promotion.
|
||||
protected.HandleFunc("GET /api/checklists/templates/{slug}/shares", handleListChecklistShares)
|
||||
protected.HandleFunc("POST /api/checklists/templates/{slug}/shares", handleGrantChecklistShare)
|
||||
protected.HandleFunc("DELETE /api/checklists/shares/{id}", handleRevokeChecklistShare)
|
||||
protected.HandleFunc("POST /api/admin/checklists/{slug}/promote", handlePromoteChecklist)
|
||||
protected.HandleFunc("POST /api/admin/checklists/{slug}/demote", handleDemoteChecklist)
|
||||
protected.HandleFunc("GET /api/checklists/{slug}/instances", handleListChecklistInstancesForTemplate)
|
||||
protected.HandleFunc("POST /api/checklists/{slug}/instances", handleCreateChecklistInstance)
|
||||
protected.HandleFunc("GET /api/checklist-instances", handleListAllChecklistInstances)
|
||||
|
||||
@@ -38,9 +38,11 @@ type dbServices struct {
|
||||
eventType *services.EventTypeService
|
||||
dashboard *services.DashboardService
|
||||
note *services.NoteService
|
||||
checklistInst *services.ChecklistInstanceService
|
||||
checklistCatalog *services.ChecklistCatalogService
|
||||
checklistTemplate *services.ChecklistTemplateService
|
||||
checklistInst *services.ChecklistInstanceService
|
||||
checklistCatalog *services.ChecklistCatalogService
|
||||
checklistTemplate *services.ChecklistTemplateService
|
||||
checklistShare *services.ChecklistShareService
|
||||
checklistPromotion *services.ChecklistPromotionService
|
||||
mail *services.MailService
|
||||
invite *services.InviteService
|
||||
agenda *services.AgendaService
|
||||
|
||||
@@ -179,19 +179,68 @@ const authoredWithOwnerSelect = `SELECT c.id, c.slug, c.owner_id, c.title, c.des
|
||||
JOIN paliad.users u ON u.id = c.owner_id`
|
||||
|
||||
// checklistVisibilityPredicate mirrors paliad.can_see_checklist for the
|
||||
// service-role connection (which bypasses RLS). Slice A covers owner +
|
||||
// firm/global; Slice B will extend with the explicit-share path.
|
||||
// service-role connection (which bypasses RLS). Covers all 6 branches
|
||||
// from mig 115: owner + firm/global + global_admin + 4 share-recipient
|
||||
// kinds (user / office / partner_unit / project).
|
||||
//
|
||||
// Two positional args expected: ($userArg) the caller UUID. Reused
|
||||
// twice (owner-check + global_admin shortcut). Slice B will add a third
|
||||
// branch over paliad.checklist_shares.
|
||||
// One positional arg ($userArg) for the caller UUID. Reused several
|
||||
// times across the branches; that's fine — Postgres positional
|
||||
// placeholders evaluate the arg once per reference, no extra param
|
||||
// binding overhead.
|
||||
func checklistVisibilityPredicate(alias string, userArg int) string {
|
||||
return fmt.Sprintf(`(%s.owner_id = $%d
|
||||
OR %s.visibility IN ('firm', 'global')
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = $%d AND u.global_role = 'global_admin'
|
||||
))`, alias, userArg, alias, userArg)
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.checklist_shares s
|
||||
WHERE s.checklist_id = %s.id
|
||||
AND s.recipient_kind = 'user'
|
||||
AND s.recipient_user_id = $%d
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.checklist_shares s
|
||||
JOIN paliad.users u ON u.id = $%d
|
||||
WHERE s.checklist_id = %s.id
|
||||
AND s.recipient_kind = 'office'
|
||||
AND (s.recipient_office = u.office
|
||||
OR s.recipient_office = ANY(u.additional_offices))
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.checklist_shares s
|
||||
JOIN paliad.partner_unit_members pum
|
||||
ON pum.partner_unit_id = s.recipient_partner_unit_id
|
||||
AND pum.user_id = $%d
|
||||
WHERE s.checklist_id = %s.id
|
||||
AND s.recipient_kind = 'partner_unit'
|
||||
)
|
||||
OR EXISTS (
|
||||
-- Share-to-project resolution: inline ltree walk over
|
||||
-- paliad.projects.path because paliad.can_see_project
|
||||
-- reads auth.uid() which is NULL on the service-role
|
||||
-- connection (same pattern as visibility.go).
|
||||
SELECT 1
|
||||
FROM paliad.checklist_shares s
|
||||
JOIN paliad.projects p
|
||||
ON p.id = s.recipient_project_id
|
||||
JOIN paliad.project_teams pt
|
||||
ON pt.user_id = $%d
|
||||
AND pt.project_id = ANY(CAST(string_to_array(p.path, '.') AS uuid[]))
|
||||
WHERE s.checklist_id = %s.id
|
||||
AND s.recipient_kind = 'project'
|
||||
))`,
|
||||
alias, userArg, // owner
|
||||
alias, // firm/global visibility col
|
||||
userArg, // global_admin
|
||||
alias, userArg, // share: user
|
||||
userArg, alias, // share: office
|
||||
userArg, alias, // share: partner_unit
|
||||
userArg, alias, // share: project (ltree walk)
|
||||
)
|
||||
}
|
||||
|
||||
func (s *ChecklistCatalogService) fetchVisibleAuthored(ctx context.Context, userID uuid.UUID) ([]models.ChecklistWithOwner, error) {
|
||||
|
||||
153
internal/services/checklist_promotion_service.go
Normal file
153
internal/services/checklist_promotion_service.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// ChecklistPromotionService implements the global_admin-only promote /
|
||||
// demote flow for paliad.checklists. Promote flips visibility to
|
||||
// 'global' and stamps promoted_at / promoted_by; demote flips it back
|
||||
// to a caller-chosen target ('firm' default — preserves visibility for
|
||||
// already-instantiated users).
|
||||
type ChecklistPromotionService struct {
|
||||
db *sqlx.DB
|
||||
templates *ChecklistTemplateService
|
||||
audit *SystemAuditLogService
|
||||
users *UserService
|
||||
}
|
||||
|
||||
func NewChecklistPromotionService(db *sqlx.DB, templates *ChecklistTemplateService, audit *SystemAuditLogService, users *UserService) *ChecklistPromotionService {
|
||||
return &ChecklistPromotionService{db: db, templates: templates, audit: audit, users: users}
|
||||
}
|
||||
|
||||
// validDemoteTargets — narrowing the visibility back from 'global' is
|
||||
// only allowed to a state where the row is still meaningful. 'global'
|
||||
// would be a no-op; 'shared' would orphan existing instance owners who
|
||||
// already see it without a grant. Default is 'firm'.
|
||||
var validDemoteTargets = map[string]bool{"firm": true, "private": true}
|
||||
|
||||
// Promote flips an authored template to visibility='global'. Caller
|
||||
// must be global_admin. Emits 'checklist.promoted_global' audit event
|
||||
// with the prior visibility captured for the demote-undo path.
|
||||
func (s *ChecklistPromotionService) Promote(ctx context.Context, callerID uuid.UUID, slug string) error {
|
||||
if err := s.requireGlobalAdmin(ctx, callerID); err != nil {
|
||||
return err
|
||||
}
|
||||
row, err := s.templates.GetBySlug(ctx, callerID, slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if row.Visibility == "global" {
|
||||
return nil
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin promote tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.checklists
|
||||
SET visibility = 'global',
|
||||
promoted_at = $2,
|
||||
promoted_by = $3,
|
||||
updated_at = $2
|
||||
WHERE id = $1`, row.ID, time.Now().UTC(), callerID); err != nil {
|
||||
return fmt.Errorf("promote checklist: %w", err)
|
||||
}
|
||||
|
||||
actorEmail, _ := s.actorEmail(ctx, callerID)
|
||||
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
|
||||
EventType: "checklist.promoted_global",
|
||||
ActorID: callerID,
|
||||
ActorEmail: actorEmail,
|
||||
Metadata: map[string]any{
|
||||
"checklist_id": row.ID,
|
||||
"slug": slug,
|
||||
"owner_id": row.OwnerID,
|
||||
"prior_visibility": row.Visibility,
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// Demote narrows visibility from 'global' to target. target defaults to
|
||||
// 'firm' when empty. promoted_at / promoted_by are cleared.
|
||||
func (s *ChecklistPromotionService) Demote(ctx context.Context, callerID uuid.UUID, slug, target string) error {
|
||||
if err := s.requireGlobalAdmin(ctx, callerID); err != nil {
|
||||
return err
|
||||
}
|
||||
row, err := s.templates.GetBySlug(ctx, callerID, slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t := strings.ToLower(strings.TrimSpace(target))
|
||||
if t == "" {
|
||||
t = "firm"
|
||||
}
|
||||
if !validDemoteTargets[t] {
|
||||
return fmt.Errorf("%w: demote target must be firm | private, got %q", ErrInvalidInput, target)
|
||||
}
|
||||
if row.Visibility != "global" {
|
||||
return fmt.Errorf("%w: checklist is not currently promoted (visibility=%s)", ErrInvalidInput, row.Visibility)
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin demote tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.checklists
|
||||
SET visibility = $2,
|
||||
promoted_at = NULL,
|
||||
promoted_by = NULL,
|
||||
updated_at = now()
|
||||
WHERE id = $1`, row.ID, t); err != nil {
|
||||
return fmt.Errorf("demote checklist: %w", err)
|
||||
}
|
||||
|
||||
actorEmail, _ := s.actorEmail(ctx, callerID)
|
||||
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
|
||||
EventType: "checklist.demoted",
|
||||
ActorID: callerID,
|
||||
ActorEmail: actorEmail,
|
||||
Metadata: map[string]any{
|
||||
"checklist_id": row.ID,
|
||||
"slug": slug,
|
||||
"target_visibility": t,
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *ChecklistPromotionService) requireGlobalAdmin(ctx context.Context, callerID uuid.UUID) error {
|
||||
user, err := s.users.GetByID(ctx, callerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user == nil || user.GlobalRole != "global_admin" {
|
||||
return fmt.Errorf("%w: only global_admin can promote / demote checklists", ErrForbidden)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ChecklistPromotionService) actorEmail(ctx context.Context, userID uuid.UUID) (string, error) {
|
||||
u, err := s.users.GetByID(ctx, userID)
|
||||
if err != nil || u == nil {
|
||||
return "", err
|
||||
}
|
||||
return u.Email, nil
|
||||
}
|
||||
331
internal/services/checklist_share_service.go
Normal file
331
internal/services/checklist_share_service.go
Normal file
@@ -0,0 +1,331 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/offices"
|
||||
)
|
||||
|
||||
// ChecklistShareService is the write surface for paliad.checklist_shares
|
||||
// (mig 115). Owners grant; owner-or-global_admin revokes. ListGrants is
|
||||
// owner-only (returning all 4 recipient kinds) — recipients see "this
|
||||
// is shared with me" only implicitly via the visibility predicate.
|
||||
type ChecklistShareService struct {
|
||||
db *sqlx.DB
|
||||
templates *ChecklistTemplateService
|
||||
audit *SystemAuditLogService
|
||||
users *UserService
|
||||
}
|
||||
|
||||
func NewChecklistShareService(db *sqlx.DB, templates *ChecklistTemplateService, audit *SystemAuditLogService, users *UserService) *ChecklistShareService {
|
||||
return &ChecklistShareService{db: db, templates: templates, audit: audit, users: users}
|
||||
}
|
||||
|
||||
// ShareGrantInput is the POST body for granting a share. Exactly one
|
||||
// of the recipient_* fields must be set, matching recipient_kind.
|
||||
type ShareGrantInput struct {
|
||||
RecipientKind string `json:"recipient_kind"`
|
||||
UserID *uuid.UUID `json:"recipient_user_id,omitempty"`
|
||||
Office string `json:"recipient_office,omitempty"`
|
||||
PartnerUnitID *uuid.UUID `json:"recipient_partner_unit_id,omitempty"`
|
||||
ProjectID *uuid.UUID `json:"recipient_project_id,omitempty"`
|
||||
}
|
||||
|
||||
// Share is the row shape returned from list / grant calls.
|
||||
type Share struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ChecklistID uuid.UUID `db:"checklist_id" json:"checklist_id"`
|
||||
RecipientKind string `db:"recipient_kind" json:"recipient_kind"`
|
||||
RecipientUserID *uuid.UUID `db:"recipient_user_id" json:"recipient_user_id,omitempty"`
|
||||
RecipientOffice *string `db:"recipient_office" json:"recipient_office,omitempty"`
|
||||
RecipientPartnerUnitID *uuid.UUID `db:"recipient_partner_unit_id" json:"recipient_partner_unit_id,omitempty"`
|
||||
RecipientProjectID *uuid.UUID `db:"recipient_project_id" json:"recipient_project_id,omitempty"`
|
||||
GrantedBy uuid.UUID `db:"granted_by" json:"granted_by"`
|
||||
GrantedAt time.Time `db:"granted_at" json:"granted_at"`
|
||||
// Display-name enrichment for the recipient — owners want to see
|
||||
// "Sarah Schmidt" not just a UUID on the grants list.
|
||||
RecipientLabel string `db:"recipient_label" json:"recipient_label"`
|
||||
}
|
||||
|
||||
// Grant creates a new share row. Caller must own the parent checklist
|
||||
// (or be global_admin). Recipient validity (FK targets exist + kind
|
||||
// matches the populated recipient_* column) enforced before INSERT.
|
||||
func (s *ChecklistShareService) Grant(ctx context.Context, callerID uuid.UUID, slug string, input ShareGrantInput) (*Share, error) {
|
||||
row, err := s.templates.GetBySlug(ctx, callerID, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Ownership check — Grant is owner-only (global_admin can demote
|
||||
// global templates but doesn't author shares).
|
||||
if row.OwnerID != callerID {
|
||||
return nil, fmt.Errorf("%w: only the owner can grant shares", ErrForbidden)
|
||||
}
|
||||
|
||||
kind := strings.ToLower(strings.TrimSpace(input.RecipientKind))
|
||||
if err := validateShareInput(kind, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id := uuid.New()
|
||||
now := time.Now().UTC()
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin grant tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.checklist_shares
|
||||
(id, checklist_id, recipient_kind, recipient_user_id, recipient_office,
|
||||
recipient_partner_unit_id, recipient_project_id, granted_by, granted_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
id, row.ID, kind,
|
||||
input.UserID, nullableString(input.Office), input.PartnerUnitID, input.ProjectID,
|
||||
callerID, now,
|
||||
); err != nil {
|
||||
// Map the partial-unique-index conflict into a friendly 409.
|
||||
if pqUniqueViolation(err) {
|
||||
return nil, fmt.Errorf("%w: this recipient already has a grant on this checklist", ErrInvalidInput)
|
||||
}
|
||||
return nil, fmt.Errorf("insert checklist_share: %w", err)
|
||||
}
|
||||
|
||||
actorEmail, _ := s.actorEmail(ctx, callerID)
|
||||
meta := map[string]any{
|
||||
"checklist_id": row.ID,
|
||||
"slug": slug,
|
||||
"share_id": id,
|
||||
"recipient_kind": kind,
|
||||
}
|
||||
switch kind {
|
||||
case "user":
|
||||
meta["recipient_user_id"] = input.UserID
|
||||
case "office":
|
||||
meta["recipient_office"] = input.Office
|
||||
case "partner_unit":
|
||||
meta["recipient_partner_unit_id"] = input.PartnerUnitID
|
||||
case "project":
|
||||
meta["recipient_project_id"] = input.ProjectID
|
||||
}
|
||||
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
|
||||
EventType: "checklist.shared",
|
||||
ActorID: callerID,
|
||||
ActorEmail: actorEmail,
|
||||
Metadata: meta,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit grant: %w", err)
|
||||
}
|
||||
return s.getShareByID(ctx, callerID, id)
|
||||
}
|
||||
|
||||
// Revoke deletes a share row. Owner of the parent checklist OR
|
||||
// global_admin. Audited as 'checklist.unshared' with the recipient meta
|
||||
// captured pre-delete.
|
||||
func (s *ChecklistShareService) Revoke(ctx context.Context, callerID uuid.UUID, shareID uuid.UUID) error {
|
||||
share, err := s.getShareByID(ctx, callerID, shareID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Resolve owner of the parent checklist for the authorization gate.
|
||||
// templates.GetBySlug needs a slug we don't have; inline a minimal
|
||||
// owner lookup keyed on the share's checklist_id.
|
||||
var ownerID uuid.UUID
|
||||
if err := s.db.GetContext(ctx, &ownerID,
|
||||
`SELECT owner_id FROM paliad.checklists WHERE id = $1`, share.ChecklistID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotVisible
|
||||
}
|
||||
return fmt.Errorf("fetch checklist owner: %w", err)
|
||||
}
|
||||
if ownerID != callerID {
|
||||
user, err := s.users.GetByID(ctx, callerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user == nil || user.GlobalRole != "global_admin" {
|
||||
return fmt.Errorf("%w: only the owner or a global_admin can revoke a share", ErrForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin revoke tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM paliad.checklist_shares WHERE id = $1`, shareID); err != nil {
|
||||
return fmt.Errorf("delete checklist_share: %w", err)
|
||||
}
|
||||
|
||||
actorEmail, _ := s.actorEmail(ctx, callerID)
|
||||
meta := map[string]any{
|
||||
"checklist_id": share.ChecklistID,
|
||||
"share_id": share.ID,
|
||||
"recipient_kind": share.RecipientKind,
|
||||
}
|
||||
switch share.RecipientKind {
|
||||
case "user":
|
||||
meta["recipient_user_id"] = share.RecipientUserID
|
||||
case "office":
|
||||
meta["recipient_office"] = share.RecipientOffice
|
||||
case "partner_unit":
|
||||
meta["recipient_partner_unit_id"] = share.RecipientPartnerUnitID
|
||||
case "project":
|
||||
meta["recipient_project_id"] = share.RecipientProjectID
|
||||
}
|
||||
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
|
||||
EventType: "checklist.unshared",
|
||||
ActorID: callerID,
|
||||
ActorEmail: actorEmail,
|
||||
Metadata: meta,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ListGrants returns every share row for the checklist. Owner-only —
|
||||
// recipients only learn about shares affecting them implicitly via the
|
||||
// visibility predicate.
|
||||
func (s *ChecklistShareService) ListGrants(ctx context.Context, callerID uuid.UUID, slug string) ([]Share, error) {
|
||||
row, err := s.templates.GetBySlug(ctx, callerID, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row.OwnerID != callerID {
|
||||
user, err := s.users.GetByID(ctx, callerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil || user.GlobalRole != "global_admin" {
|
||||
return nil, fmt.Errorf("%w: only the owner or a global_admin can list shares", ErrForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
rows := []Share{}
|
||||
q := `SELECT s.id, s.checklist_id, s.recipient_kind, s.recipient_user_id,
|
||||
s.recipient_office, s.recipient_partner_unit_id, s.recipient_project_id,
|
||||
s.granted_by, s.granted_at,
|
||||
COALESCE(
|
||||
CASE s.recipient_kind
|
||||
WHEN 'user' THEN ru.display_name
|
||||
WHEN 'office' THEN s.recipient_office
|
||||
WHEN 'partner_unit' THEN pu.name
|
||||
WHEN 'project' THEN COALESCE(pr.reference, pr.title)
|
||||
END,
|
||||
''
|
||||
) AS recipient_label
|
||||
FROM paliad.checklist_shares s
|
||||
LEFT JOIN paliad.users ru ON ru.id = s.recipient_user_id
|
||||
LEFT JOIN paliad.partner_units pu ON pu.id = s.recipient_partner_unit_id
|
||||
LEFT JOIN paliad.projects pr ON pr.id = s.recipient_project_id
|
||||
WHERE s.checklist_id = $1
|
||||
ORDER BY s.granted_at DESC`
|
||||
if err := s.db.SelectContext(ctx, &rows, q, row.ID); err != nil {
|
||||
return nil, fmt.Errorf("list checklist_shares: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// --- internals ------------------------------------------------------------
|
||||
|
||||
func (s *ChecklistShareService) getShareByID(ctx context.Context, callerID uuid.UUID, shareID uuid.UUID) (*Share, error) {
|
||||
var row Share
|
||||
q := `SELECT s.id, s.checklist_id, s.recipient_kind, s.recipient_user_id,
|
||||
s.recipient_office, s.recipient_partner_unit_id, s.recipient_project_id,
|
||||
s.granted_by, s.granted_at,
|
||||
COALESCE(
|
||||
CASE s.recipient_kind
|
||||
WHEN 'user' THEN ru.display_name
|
||||
WHEN 'office' THEN s.recipient_office
|
||||
WHEN 'partner_unit' THEN pu.name
|
||||
WHEN 'project' THEN COALESCE(pr.reference, pr.title)
|
||||
END,
|
||||
''
|
||||
) AS recipient_label
|
||||
FROM paliad.checklist_shares s
|
||||
LEFT JOIN paliad.users ru ON ru.id = s.recipient_user_id
|
||||
LEFT JOIN paliad.partner_units pu ON pu.id = s.recipient_partner_unit_id
|
||||
LEFT JOIN paliad.projects pr ON pr.id = s.recipient_project_id
|
||||
WHERE s.id = $1`
|
||||
err := s.db.GetContext(ctx, &row, q, shareID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotVisible
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch checklist_share: %w", err)
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (s *ChecklistShareService) actorEmail(ctx context.Context, userID uuid.UUID) (string, error) {
|
||||
u, err := s.users.GetByID(ctx, userID)
|
||||
if err != nil || u == nil {
|
||||
return "", err
|
||||
}
|
||||
return u.Email, nil
|
||||
}
|
||||
|
||||
// --- pure helpers ---------------------------------------------------------
|
||||
|
||||
func validateShareInput(kind string, input ShareGrantInput) error {
|
||||
switch kind {
|
||||
case "user":
|
||||
if input.UserID == nil {
|
||||
return fmt.Errorf("%w: recipient_user_id required when recipient_kind=user", ErrInvalidInput)
|
||||
}
|
||||
case "office":
|
||||
off := strings.TrimSpace(input.Office)
|
||||
if off == "" {
|
||||
return fmt.Errorf("%w: recipient_office required when recipient_kind=office", ErrInvalidInput)
|
||||
}
|
||||
if !offices.IsValid(off) {
|
||||
return fmt.Errorf("%w: unknown office %q", ErrInvalidInput, off)
|
||||
}
|
||||
case "partner_unit":
|
||||
if input.PartnerUnitID == nil {
|
||||
return fmt.Errorf("%w: recipient_partner_unit_id required when recipient_kind=partner_unit", ErrInvalidInput)
|
||||
}
|
||||
case "project":
|
||||
if input.ProjectID == nil {
|
||||
return fmt.Errorf("%w: recipient_project_id required when recipient_kind=project", ErrInvalidInput)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("%w: recipient_kind must be user|office|partner_unit|project, got %q", ErrInvalidInput, kind)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nullableString(s string) any {
|
||||
t := strings.TrimSpace(s)
|
||||
if t == "" {
|
||||
return nil
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// pqUniqueViolation reports whether the error is a Postgres
|
||||
// unique_violation (SQLSTATE 23505). lib/pq exposes it via the .Code()
|
||||
// method; sqlx surfaces it untouched. We sniff via the error string to
|
||||
// avoid pulling in lib/pq's Error type here.
|
||||
func pqUniqueViolation(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "23505") || strings.Contains(msg, "duplicate key")
|
||||
}
|
||||
107
internal/services/checklist_share_service_test.go
Normal file
107
internal/services/checklist_share_service_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestValidateShareInput(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
puID := uuid.New()
|
||||
prID := uuid.New()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
kind string
|
||||
input ShareGrantInput
|
||||
wantErr bool
|
||||
}{
|
||||
{"user happy", "user", ShareGrantInput{RecipientKind: "user", UserID: &uid}, false},
|
||||
{"user missing id", "user", ShareGrantInput{RecipientKind: "user"}, true},
|
||||
{"office happy", "office", ShareGrantInput{RecipientKind: "office", Office: "munich"}, false},
|
||||
{"office unknown key", "office", ShareGrantInput{RecipientKind: "office", Office: "atlantis"}, true},
|
||||
{"office empty", "office", ShareGrantInput{RecipientKind: "office"}, true},
|
||||
{"partner_unit happy", "partner_unit", ShareGrantInput{RecipientKind: "partner_unit", PartnerUnitID: &puID}, false},
|
||||
{"partner_unit missing id", "partner_unit", ShareGrantInput{RecipientKind: "partner_unit"}, true},
|
||||
{"project happy", "project", ShareGrantInput{RecipientKind: "project", ProjectID: &prID}, false},
|
||||
{"project missing id", "project", ShareGrantInput{RecipientKind: "project"}, true},
|
||||
{"unknown kind", "bogus", ShareGrantInput{RecipientKind: "bogus"}, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
err := validateShareInput(c.kind, c.input)
|
||||
if c.wantErr && !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("%s: expected ErrInvalidInput, got %v", c.name, err)
|
||||
}
|
||||
if !c.wantErr && err != nil {
|
||||
t.Errorf("%s: unexpected error %v", c.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPredicateIncludesAllShareBranches(t *testing.T) {
|
||||
pred := checklistVisibilityPredicate("c", 1)
|
||||
wants := []string{
|
||||
"c.owner_id = $1",
|
||||
"c.visibility IN ('firm', 'global')",
|
||||
"u.global_role = 'global_admin'",
|
||||
"s.recipient_kind = 'user'",
|
||||
"s.recipient_kind = 'office'",
|
||||
"s.recipient_kind = 'partner_unit'",
|
||||
"s.recipient_kind = 'project'",
|
||||
"paliad.checklist_shares",
|
||||
"paliad.partner_unit_members",
|
||||
"paliad.projects",
|
||||
"paliad.project_teams",
|
||||
}
|
||||
for _, w := range wants {
|
||||
if !strings.Contains(pred, w) {
|
||||
t.Errorf("predicate missing %q in:\n%s", w, pred)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPqUniqueViolationDetection(t *testing.T) {
|
||||
cases := []struct {
|
||||
err string
|
||||
want bool
|
||||
}{
|
||||
{"pq: duplicate key value violates unique constraint \"checklist_shares_user_uniq\"", true},
|
||||
{"pq: 23505 something", true},
|
||||
{"some other error", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := pqUniqueViolation(errors.New(c.err))
|
||||
if got != c.want {
|
||||
t.Errorf("pqUniqueViolation(%q) = %v; want %v", c.err, got, c.want)
|
||||
}
|
||||
}
|
||||
if pqUniqueViolation(nil) {
|
||||
t.Error("nil err should not be a unique violation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNullableString(t *testing.T) {
|
||||
if got := nullableString(""); got != nil {
|
||||
t.Errorf("empty should map to nil, got %v", got)
|
||||
}
|
||||
if got := nullableString(" "); got != nil {
|
||||
t.Errorf("whitespace should map to nil, got %v", got)
|
||||
}
|
||||
if got := nullableString(" munich "); got != "munich" {
|
||||
t.Errorf("expected trimmed 'munich', got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormaliseSliceAVisibilityAcceptsShared(t *testing.T) {
|
||||
for _, v := range []string{"private", "firm", "shared"} {
|
||||
if _, err := normaliseSliceAVisibility(v); err != nil {
|
||||
t.Errorf("Slice-B visibility %q rejected: %v", v, err)
|
||||
}
|
||||
}
|
||||
if _, err := normaliseSliceAVisibility("global"); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("'global' should be rejected as author-set, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,10 @@ type UpdateTemplateInput struct {
|
||||
var (
|
||||
validRegimes = map[string]bool{"UPC": true, "DE": true, "EPA": true, "OTHER": true}
|
||||
validLangs = map[string]bool{"de": true, "en": true}
|
||||
validVisibilities = map[string]bool{"private": true, "firm": true}
|
||||
// Author-settable visibilities. 'shared' is implicit (set
|
||||
// automatically when the first checklist_shares row exists); 'global'
|
||||
// is admin-only via ChecklistPromotionService.
|
||||
validVisibilities = map[string]bool{"private": true, "firm": true, "shared": true}
|
||||
titleMaxLen = 200
|
||||
descriptionMaxLen = 2000
|
||||
freeTextMaxLen = 200
|
||||
@@ -515,7 +518,7 @@ func normaliseSliceAVisibility(v string) (string, error) {
|
||||
x = "private"
|
||||
}
|
||||
if !validVisibilities[x] {
|
||||
return "", fmt.Errorf("%w: visibility must be private | firm in Slice A, got %q", ErrInvalidInput, v)
|
||||
return "", fmt.Errorf("%w: visibility must be private | firm | shared, got %q (global is admin-only)", ErrInvalidInput, v)
|
||||
}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
@@ -48,12 +48,15 @@ func TestNormaliseLang(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNormaliseSliceAVisibility(t *testing.T) {
|
||||
for _, valid := range []string{"private", "firm", " ", ""} {
|
||||
// Slice B opened up 'shared' as a valid author-set visibility
|
||||
// (alongside 'private' and 'firm'). 'global' stays admin-only via
|
||||
// ChecklistPromotionService.
|
||||
for _, valid := range []string{"private", "firm", "shared", " ", ""} {
|
||||
if _, err := normaliseSliceAVisibility(valid); err != nil {
|
||||
t.Errorf("visibility(%q) errored: %v", valid, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"shared", "global", "public"} {
|
||||
for _, bad := range []string{"global", "public"} {
|
||||
if _, err := normaliseSliceAVisibility(bad); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("visibility(%q) expected ErrInvalidInput, got %v", bad, err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user