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.
253 lines
8.3 KiB
Go
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
|
|
}
|