Files
paliad/internal/handlers/checklist_shares.go
mAi c3cd51eb85 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).
2026-05-20 15:38:30 +02:00

132 lines
3.4 KiB
Go

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