Files
paliad/internal/services/checklist_template_service.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

551 lines
16 KiB
Go

package services
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/checklists"
"mgit.msbls.de/m/paliad/internal/models"
)
// ChecklistTemplateService is the write surface for user-authored checklist
// templates (paliad.checklists, mig 114). Create / Update / Delete on
// owner-only paths; SetVisibility on private↔firm only (Slice A — Slice B
// adds 'shared' grants, Slice C adds 'global' via admin promotion).
type ChecklistTemplateService struct {
db *sqlx.DB
catalog *ChecklistCatalogService
audit *SystemAuditLogService
users *UserService
}
func NewChecklistTemplateService(db *sqlx.DB, catalog *ChecklistCatalogService, audit *SystemAuditLogService, users *UserService) *ChecklistTemplateService {
return &ChecklistTemplateService{db: db, catalog: catalog, audit: audit, users: users}
}
// CreateTemplateInput is the POST body for authoring a new template.
//
// Body carries the groups[] / items[] sub-tree as JSONB; the surrounding
// metadata (title, regime, etc.) lives on dedicated columns. The
// handler validates the body shape upstream.
type CreateTemplateInput struct {
Title string `json:"title"`
Description string `json:"description"`
Regime string `json:"regime"`
Court string `json:"court"`
Reference string `json:"reference"`
Deadline string `json:"deadline"`
Lang string `json:"lang"`
Body json.RawMessage `json:"body"`
Visibility string `json:"visibility"`
}
// UpdateTemplateInput patches the owner-editable fields. Any field left
// nil is unchanged.
type UpdateTemplateInput struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
Regime *string `json:"regime,omitempty"`
Court *string `json:"court,omitempty"`
Reference *string `json:"reference,omitempty"`
Deadline *string `json:"deadline,omitempty"`
Body *json.RawMessage `json:"body,omitempty"`
}
var (
validRegimes = map[string]bool{"UPC": true, "DE": true, "EPA": true, "OTHER": true}
validLangs = map[string]bool{"de": true, "en": 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
slugSafeChars = regexp.MustCompile(`[^a-z0-9-]+`)
)
// Create inserts a new authored template owned by userID. Returns the
// created row; emits a `checklist.authored` audit event.
func (s *ChecklistTemplateService) Create(ctx context.Context, userID uuid.UUID, input CreateTemplateInput) (*models.Checklist, error) {
title, err := requireNonEmptyTrimmed(input.Title, "title", titleMaxLen)
if err != nil {
return nil, err
}
regime, err := normaliseRegime(input.Regime)
if err != nil {
return nil, err
}
lang, err := normaliseLang(input.Lang)
if err != nil {
return nil, err
}
visibility, err := normaliseSliceAVisibility(input.Visibility)
if err != nil {
return nil, err
}
if err := validateBodyShape(input.Body); err != nil {
return nil, err
}
slug, err := s.generateSlug(ctx, title)
if err != nil {
return nil, err
}
now := time.Now().UTC()
id := uuid.New()
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin create tx: %w", err)
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx,
`INSERT INTO paliad.checklists
(id, slug, owner_id, title, description, regime, court, reference,
deadline, lang, body, visibility, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13, $13)`,
id, slug, userID, title,
clampFreeText(input.Description, descriptionMaxLen),
regime,
clampFreeText(input.Court, freeTextMaxLen),
clampFreeText(input.Reference, freeTextMaxLen),
clampFreeText(input.Deadline, freeTextMaxLen),
lang,
string(input.Body),
visibility,
now,
)
if err != nil {
return nil, fmt.Errorf("insert checklist: %w", err)
}
actorEmail, _ := s.actorEmail(ctx, userID)
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
EventType: "checklist.authored",
ActorID: userID,
ActorEmail: actorEmail,
Metadata: map[string]any{
"checklist_id": id,
"slug": slug,
"visibility": visibility,
},
}); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit create checklist: %w", err)
}
return s.GetBySlug(ctx, userID, slug)
}
// Update mutates an authored template. Owner-only; non-owner attempts
// return ErrForbidden. Emits `checklist.edited` with the names of the
// changed fields in metadata.changed_fields[].
func (s *ChecklistTemplateService) Update(ctx context.Context, userID uuid.UUID, slug string, input UpdateTemplateInput) (*models.Checklist, error) {
row, err := s.requireOwnerOrAdmin(ctx, userID, slug)
if err != nil {
return nil, err
}
sets := []string{}
args := []any{}
next := 1
changed := []string{}
appendSet := func(col string, val any) {
sets = append(sets, fmt.Sprintf("%s = $%d", col, next))
args = append(args, val)
next++
}
if input.Title != nil {
t, err := requireNonEmptyTrimmed(*input.Title, "title", titleMaxLen)
if err != nil {
return nil, err
}
appendSet("title", t)
changed = append(changed, "title")
}
if input.Description != nil {
appendSet("description", clampFreeText(*input.Description, descriptionMaxLen))
changed = append(changed, "description")
}
if input.Regime != nil {
r, err := normaliseRegime(*input.Regime)
if err != nil {
return nil, err
}
appendSet("regime", r)
changed = append(changed, "regime")
}
if input.Court != nil {
appendSet("court", clampFreeText(*input.Court, freeTextMaxLen))
changed = append(changed, "court")
}
if input.Reference != nil {
appendSet("reference", clampFreeText(*input.Reference, freeTextMaxLen))
changed = append(changed, "reference")
}
if input.Deadline != nil {
appendSet("deadline", clampFreeText(*input.Deadline, freeTextMaxLen))
changed = append(changed, "deadline")
}
if input.Body != nil {
if err := validateBodyShape(*input.Body); err != nil {
return nil, err
}
sets = append(sets, fmt.Sprintf("body = $%d::jsonb", next))
args = append(args, string(*input.Body))
next++
changed = append(changed, "body")
}
if len(sets) == 0 {
return row, nil
}
appendSet("updated_at", time.Now().UTC())
args = append(args, row.ID)
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin update tx: %w", err)
}
defer tx.Rollback()
q := fmt.Sprintf(`UPDATE paliad.checklists SET %s WHERE id = $%d`,
strings.Join(sets, ", "), next)
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
return nil, fmt.Errorf("update checklist: %w", err)
}
actorEmail, _ := s.actorEmail(ctx, userID)
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
EventType: "checklist.edited",
ActorID: userID,
ActorEmail: actorEmail,
Metadata: map[string]any{
"checklist_id": row.ID,
"slug": slug,
"changed_fields": changed,
},
}); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit update checklist: %w", err)
}
return s.GetBySlug(ctx, userID, slug)
}
// SetVisibility flips the visibility level. Slice A allows only the
// private ↔ firm transitions; Slice B opens 'shared' (requires share
// grants); Slice C opens 'global' via the admin promotion service.
func (s *ChecklistTemplateService) SetVisibility(ctx context.Context, userID uuid.UUID, slug string, visibility string) (*models.Checklist, error) {
row, err := s.requireOwnerOrAdmin(ctx, userID, slug)
if err != nil {
return nil, err
}
target, err := normaliseSliceAVisibility(visibility)
if err != nil {
return nil, err
}
if row.Visibility == target {
return row, nil
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin visibility tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.checklists
SET visibility = $2, updated_at = now()
WHERE id = $1`, row.ID, target); err != nil {
return nil, fmt.Errorf("update visibility: %w", err)
}
actorEmail, _ := s.actorEmail(ctx, userID)
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
EventType: "checklist.visibility_changed",
ActorID: userID,
ActorEmail: actorEmail,
Metadata: map[string]any{
"checklist_id": row.ID,
"slug": slug,
"from": row.Visibility,
"to": target,
},
}); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit visibility: %w", err)
}
return s.GetBySlug(ctx, userID, slug)
}
// Delete removes the authored template. Existing instances survive via
// template_snapshot; new instance creation against this slug fails.
func (s *ChecklistTemplateService) Delete(ctx context.Context, userID uuid.UUID, slug string) error {
row, err := s.requireOwnerOrAdmin(ctx, userID, slug)
if err != nil {
return err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin delete tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.checklists WHERE id = $1`, row.ID); err != nil {
return fmt.Errorf("delete checklist: %w", err)
}
actorEmail, _ := s.actorEmail(ctx, userID)
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
EventType: "checklist.deleted",
ActorID: userID,
ActorEmail: actorEmail,
Metadata: map[string]any{
"checklist_id": row.ID,
"slug": slug,
"was_visibility": row.Visibility,
},
}); err != nil {
return err
}
return tx.Commit()
}
// ListOwnedBy returns every authored template owned by the caller. Used
// by the 'Meine Vorlagen' tab on /checklists.
func (s *ChecklistTemplateService) ListOwnedBy(ctx context.Context, userID uuid.UUID) ([]models.Checklist, error) {
rows := []models.Checklist{}
if err := s.db.SelectContext(ctx, &rows,
`SELECT id, slug, owner_id, title, description, regime, court, reference,
deadline, lang, body, visibility, promoted_at, promoted_by,
created_at, updated_at
FROM paliad.checklists
WHERE owner_id = $1
ORDER BY updated_at DESC`, userID); err != nil {
return nil, fmt.Errorf("list owned checklists: %w", err)
}
return rows, nil
}
// GetBySlug returns one authored template by slug; applies visibility.
func (s *ChecklistTemplateService) GetBySlug(ctx context.Context, userID uuid.UUID, slug string) (*models.Checklist, error) {
var row models.Checklist
q := `SELECT id, slug, owner_id, title, description, regime, court, reference,
deadline, lang, body, visibility, promoted_at, promoted_by,
created_at, updated_at
FROM paliad.checklists
WHERE slug = $2
AND ` + checklistVisibilityPredicate("paliad.checklists", 1)
err := s.db.GetContext(ctx, &row, q, userID, slug)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotVisible
}
if err != nil {
return nil, fmt.Errorf("fetch checklist: %w", err)
}
return &row, nil
}
// --- internals ------------------------------------------------------------
// requireOwnerOrAdmin fetches the row and returns it iff caller is owner
// or global_admin. Other callers get ErrForbidden (template visible to
// many users, only some can mutate).
func (s *ChecklistTemplateService) requireOwnerOrAdmin(ctx context.Context, userID uuid.UUID, slug string) (*models.Checklist, error) {
row, err := s.GetBySlug(ctx, userID, slug)
if err != nil {
return nil, err
}
if row.OwnerID == userID {
return row, nil
}
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user != nil && user.GlobalRole == "global_admin" {
return row, nil
}
return nil, fmt.Errorf("%w: only the owner or a global_admin can modify this checklist", ErrForbidden)
}
func (s *ChecklistTemplateService) 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
}
// generateSlug builds a 'u-<title-slug>-<6hex>' slug. Three retries on
// collision (against authored table + static catalog). After three
// failures we fall back to a pure-random suffix so the create path
// never wedges.
func (s *ChecklistTemplateService) generateSlug(ctx context.Context, title string) (string, error) {
base := slugifyTitle(title)
if base == "" {
base = "checklist"
}
for attempt := 0; attempt < 3; attempt++ {
suffix, err := randomHex(3)
if err != nil {
return "", err
}
slug := "u-" + base + "-" + suffix
if len(slug) > 64 {
slug = slug[:64]
}
taken, err := s.slugTaken(ctx, slug)
if err != nil {
return "", err
}
if !taken {
return slug, nil
}
}
suffix, err := randomHex(6)
if err != nil {
return "", err
}
return "u-" + suffix, nil
}
func (s *ChecklistTemplateService) slugTaken(ctx context.Context, slug string) (bool, error) {
if s.catalog.IsStaticSlug(slug) {
return true, nil
}
var n int
if err := s.db.GetContext(ctx, &n,
`SELECT count(*) FROM paliad.checklists WHERE slug = $1`, slug); err != nil {
return false, fmt.Errorf("slug taken check: %w", err)
}
return n > 0, nil
}
// --- pure helpers ---------------------------------------------------------
func slugifyTitle(title string) string {
s := strings.ToLower(strings.TrimSpace(title))
s = strings.ReplaceAll(s, "ä", "ae")
s = strings.ReplaceAll(s, "ö", "oe")
s = strings.ReplaceAll(s, "ü", "ue")
s = strings.ReplaceAll(s, "ß", "ss")
s = slugSafeChars.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
if len(s) > 40 {
s = s[:40]
}
return strings.Trim(s, "-")
}
func randomHex(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("rand: %w", err)
}
return hex.EncodeToString(b), nil
}
func requireNonEmptyTrimmed(v, field string, max int) (string, error) {
t := strings.TrimSpace(v)
if t == "" {
return "", fmt.Errorf("%w: %s is required", ErrInvalidInput, field)
}
if len(t) > max {
return "", fmt.Errorf("%w: %s exceeds %d characters", ErrInvalidInput, field, max)
}
return t, nil
}
func clampFreeText(v string, max int) string {
v = strings.TrimSpace(v)
if len(v) > max {
v = v[:max]
}
return v
}
func normaliseRegime(v string) (string, error) {
r := strings.ToUpper(strings.TrimSpace(v))
if r == "" {
r = "OTHER"
}
if !validRegimes[r] {
return "", fmt.Errorf("%w: regime must be UPC | DE | EPA | OTHER, got %q", ErrInvalidInput, v)
}
return r, nil
}
func normaliseLang(v string) (string, error) {
l := strings.ToLower(strings.TrimSpace(v))
if l == "" {
l = "de"
}
if !validLangs[l] {
return "", fmt.Errorf("%w: lang must be de | en, got %q", ErrInvalidInput, v)
}
return l, nil
}
func normaliseSliceAVisibility(v string) (string, error) {
x := strings.ToLower(strings.TrimSpace(v))
if x == "" {
x = "private"
}
if !validVisibilities[x] {
return "", fmt.Errorf("%w: visibility must be private | firm | shared, got %q (global is admin-only)", ErrInvalidInput, v)
}
return x, nil
}
// validateBodyShape enforces { "groups": [...] } with at least one
// non-empty group and at least one non-empty item somewhere. Authored
// templates aren't useful without content.
func validateBodyShape(body json.RawMessage) error {
if len(body) == 0 {
return fmt.Errorf("%w: body is required", ErrInvalidInput)
}
var shape struct {
Groups []checklists.Group `json:"groups"`
}
if err := json.Unmarshal(body, &shape); err != nil {
return fmt.Errorf("%w: body must be {\"groups\":[...]} (%v)", ErrInvalidInput, err)
}
if len(shape.Groups) == 0 {
return fmt.Errorf("%w: body must contain at least one group", ErrInvalidInput)
}
totalItems := 0
for _, g := range shape.Groups {
totalItems += len(g.Items)
}
if totalItems == 0 {
return fmt.Errorf("%w: body must contain at least one item", ErrInvalidInput)
}
return nil
}