Files
paliad/internal/services/checklist_catalog_service.go
mAi a4e2f3526d feat(checklists): t-paliad-225 Slice A backend — user-authored templates
m/paliad#61 Slice A. Introduces paliad.checklists (mig 114) as the
DB-backed companion to the static Go catalog. ChecklistCatalogService
unifies both sources at read time; ChecklistTemplateService handles
authoring CRUD + visibility toggle (private↔firm; Slice B opens
'shared' and 'global').

Schema (mig 114, idempotent):
- paliad.checklists (uuid, slug UNIQUE, owner_id FK, title/description
  /regime/court/reference/deadline/lang, body jsonb, visibility CHECK
  ('private','shared','firm','global'), promoted_at/_by, timestamps)
- paliad.can_see_checklist(uuid, uuid) STABLE SECURITY DEFINER —
  owner OR firm/global. Slice B extends with the explicit-share branch.
- RLS: select via can_see_checklist; insert owner=self; update/delete
  owner OR global_admin
- ALTER paliad.checklist_instances ADD COLUMN template_snapshot jsonb
  (snapshot semantics so per-Akte instances stay decoupled from
  subsequent template edits)

Services:
- ChecklistCatalogService — ListVisible, Find, SnapshotBody, IsStaticSlug.
  Reapplies visibility application-side (service-role bypasses RLS, per
  visibility.go pattern). Static-slug map computed once at boot for
  collision detection.
- ChecklistTemplateService — Create (auto-generates u-<slug>-<hex> with
  retry), Update (changed_fields[] in audit), SetVisibility, Delete,
  ListOwnedBy, GetBySlug. Owner-or-global_admin gate.
- SystemAuditLogService.WriteChecklistEvent — thin helper writing into
  paliad.system_audit_log with scope='org'.
- ChecklistInstanceService.Create now captures template_snapshot via
  the catalog; GetByID returns it inline so the frontend can render
  the captured body even after the upstream template is mutated.

Endpoints (all owner-gated where mutating):
- GET    /api/checklists                 — merged catalog (static + DB visible)
- GET    /api/checklists/{slug}          — single template; static-first lookup
- GET    /api/checklists/templates/mine  — caller's authored templates
- POST   /api/checklists/templates       — create
- PATCH  /api/checklists/templates/{slug}            — edit
- PATCH  /api/checklists/templates/{slug}/visibility — private↔firm
- DELETE /api/checklists/templates/{slug}            — delete
- GET    /checklists/new, /checklists/{slug}/edit    — author wizard pages

Tests: pure-helper unit tests cover slugifyTitle (umlaut → ae/oe/ue/ss
normalisation + clamp), regime/lang/visibility validation, body-shape
enforcement, static-slug detection, predicate shape, clamp.
2026-05-20 15:24:06 +02:00

253 lines
8.3 KiB
Go

package services
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/checklists"
"mgit.msbls.de/m/paliad/internal/models"
)
// ChecklistCatalogService unifies the static Go template catalog
// (internal/checklists/templates.go) and the user-authored DB catalog
// (paliad.checklists, mig 114) into a single read facade.
//
// Slug uniqueness is enforced across both spaces at write time by
// ChecklistTemplateService (authored slugs get a 'u-' prefix and we
// reject collisions with static slugs). Catalog lookups prefer static
// templates on collision so a stray DB row never shadows curated
// content — see Find().
type ChecklistCatalogService struct {
db *sqlx.DB
staticSlugs map[string]bool
}
// NewChecklistCatalogService wires the service and pre-computes the
// static-slug set used for collision detection at write + read time.
func NewChecklistCatalogService(db *sqlx.DB) *ChecklistCatalogService {
set := make(map[string]bool, len(checklists.Templates))
for _, t := range checklists.Templates {
set[t.Slug] = true
}
return &ChecklistCatalogService{db: db, staticSlugs: set}
}
// CatalogEntry is one unified entry — either a static template or an
// authored DB row. Origin identifies the source so the UI can render
// provenance ("Erstellt von <author>" for authored, plain title for
// static).
type CatalogEntry struct {
Slug string `json:"slug"`
Origin string `json:"origin"` // "static" | "authored"
Visibility string `json:"visibility"`
OwnerID *uuid.UUID `json:"owner_id,omitempty"`
OwnerEmail string `json:"owner_email,omitempty"`
OwnerDisplayName string `json:"owner_display_name,omitempty"`
Template checklists.Template `json:"template"`
}
// IsStaticSlug reports whether the given slug names a curated static
// template. Called by ChecklistTemplateService.Create to reject author
// slugs that would shadow a curated entry.
func (s *ChecklistCatalogService) IsStaticSlug(slug string) bool {
return s.staticSlugs[slug]
}
// ListVisible returns every catalog entry the caller can see — every
// static template (always visible) plus every authored DB row that
// passes paliad.can_see_checklist via RLS.
//
// Ordering: static templates first in their definition order, then
// authored rows alphabetised by title.
func (s *ChecklistCatalogService) ListVisible(ctx context.Context, userID uuid.UUID) ([]CatalogEntry, error) {
out := make([]CatalogEntry, 0, len(checklists.Templates))
for _, t := range checklists.Templates {
out = append(out, CatalogEntry{
Slug: t.Slug,
Origin: "static",
Visibility: "static",
Template: t,
})
}
if s.db == nil {
return out, nil
}
rows, err := s.fetchVisibleAuthored(ctx, userID)
if err != nil {
return nil, err
}
sort.SliceStable(rows, func(i, j int) bool {
return strings.ToLower(rows[i].Title) < strings.ToLower(rows[j].Title)
})
for _, r := range rows {
// Skip the row if it collides with a static slug — static wins.
if s.staticSlugs[r.Slug] {
continue
}
tpl, err := s.rowToTemplate(r)
if err != nil {
return nil, err
}
ownerID := r.OwnerID
out = append(out, CatalogEntry{
Slug: r.Slug,
Origin: "authored",
Visibility: r.Visibility,
OwnerID: &ownerID,
OwnerEmail: r.OwnerEmail,
OwnerDisplayName: r.OwnerDisplayName,
Template: tpl,
})
}
return out, nil
}
// Find resolves a slug to a single catalog entry, applying visibility
// (RLS for authored rows; static always visible). Returns ErrNotVisible
// if the slug is unknown or the caller can't see the authored row.
func (s *ChecklistCatalogService) Find(ctx context.Context, userID uuid.UUID, slug string) (*CatalogEntry, error) {
if t, ok := checklists.Find(slug); ok {
return &CatalogEntry{
Slug: t.Slug,
Origin: "static",
Visibility: "static",
Template: t,
}, nil
}
if s.db == nil {
return nil, ErrNotVisible
}
row, err := s.fetchAuthoredBySlug(ctx, userID, slug)
if err != nil {
return nil, err
}
if row == nil {
return nil, ErrNotVisible
}
tpl, err := s.rowToTemplate(*row)
if err != nil {
return nil, err
}
ownerID := row.OwnerID
return &CatalogEntry{
Slug: row.Slug,
Origin: "authored",
Visibility: row.Visibility,
OwnerID: &ownerID,
OwnerEmail: row.OwnerEmail,
OwnerDisplayName: row.OwnerDisplayName,
Template: tpl,
}, nil
}
// SnapshotBody returns the template body as JSONB suitable for storing
// in paliad.checklist_instances.template_snapshot. For static templates
// we marshal the full Template struct; for authored rows we return the
// body column directly (it already has the right shape — groups[]).
func (s *ChecklistCatalogService) SnapshotBody(ctx context.Context, userID uuid.UUID, slug string) (json.RawMessage, error) {
entry, err := s.Find(ctx, userID, slug)
if err != nil {
return nil, err
}
body, err := json.Marshal(entry.Template)
if err != nil {
return nil, fmt.Errorf("snapshot marshal: %w", err)
}
return body, nil
}
// --- internals ------------------------------------------------------------
const authoredWithOwnerSelect = `SELECT c.id, c.slug, c.owner_id, c.title, c.description,
c.regime, c.court, c.reference, c.deadline, c.lang, c.body, c.visibility,
c.promoted_at, c.promoted_by, c.created_at, c.updated_at,
u.email AS owner_email,
u.display_name AS owner_display_name
FROM paliad.checklists c
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.
//
// 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.
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)
}
func (s *ChecklistCatalogService) fetchVisibleAuthored(ctx context.Context, userID uuid.UUID) ([]models.ChecklistWithOwner, error) {
rows := []models.ChecklistWithOwner{}
q := authoredWithOwnerSelect + `
WHERE ` + checklistVisibilityPredicate("c", 1) + `
ORDER BY c.title ASC`
if err := s.db.SelectContext(ctx, &rows, q, userID); err != nil {
return nil, fmt.Errorf("list authored checklists: %w", err)
}
return rows, nil
}
func (s *ChecklistCatalogService) fetchAuthoredBySlug(ctx context.Context, userID uuid.UUID, slug string) (*models.ChecklistWithOwner, error) {
var row models.ChecklistWithOwner
q := authoredWithOwnerSelect + `
WHERE c.slug = $2
AND ` + checklistVisibilityPredicate("c", 1)
err := s.db.GetContext(ctx, &row, q, userID, slug)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("fetch authored checklist: %w", err)
}
return &row, nil
}
func (s *ChecklistCatalogService) rowToTemplate(row models.ChecklistWithOwner) (checklists.Template, error) {
// body jsonb holds { "groups": [...] }. Unmarshal into a thin local
// shape because the full checklists.Template has DE/EN sibling
// fields the author only fills one side of.
var bodyShape struct {
Groups []checklists.Group `json:"groups"`
}
if err := json.Unmarshal(row.Body, &bodyShape); err != nil {
return checklists.Template{}, fmt.Errorf("unmarshal authored body for %s: %w", row.Slug, err)
}
t := checklists.Template{
Slug: row.Slug,
Regime: row.Regime,
Groups: bodyShape.Groups,
ReferenceDE: row.Reference,
ReferenceEN: row.Reference,
DeadlineDE: row.Deadline,
DeadlineEN: row.Deadline,
CourtDE: row.Court,
CourtEN: row.Court,
}
// Author picks one language per template — surface their title /
// description on both sides so the existing bilingual frontend
// renders without a special-case for authored entries.
t.TitleDE = row.Title
t.TitleEN = row.Title
t.DescriptionDE = row.Description
t.DescriptionEN = row.Description
return t, nil
}