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:
mAi
2026-05-20 15:38:30 +02:00
parent 6b634207c2
commit c3cd51eb85
12 changed files with 1050 additions and 20 deletions

View File

@@ -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),

View 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;

View 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).';

View 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)
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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) {

View 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
}

View 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")
}

View 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)
}
}

View File

@@ -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
}

View File

@@ -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)
}