Files
paliad/internal/services/checklist_catalog_service.go
mAi fffddcc71a feat(checklists): t-paliad-225 Slice C backend — template versioning + catalog Version
m/paliad#61 Slice C backend.

Schema (mig 116, idempotent):
- ALTER paliad.checklists ADD COLUMN version int NOT NULL DEFAULT 1.
  Pre-Slice-C rows default to 1 (the column was added with DEFAULT
  so the UPDATE clause is a no-op safety net).
- ALTER paliad.checklist_instances ADD COLUMN template_version int.
  NULL on existing rows — instance detail page leaves the "outdated"
  badge off when the snapshot version is unknown.

Services:
- ChecklistTemplateService.Update — version bumps on title/body
  changes (the meaningful edits that warrant notifying instance
  owners). Pure metadata tweaks (description/court/reference/deadline)
  update updated_at without bumping. Emits the new 'checklist.versioned'
  audit event with prior_version + new_version metadata.
- ChecklistInstanceService.Create — captures snapshot_version
  alongside the body snapshot.
- ChecklistCatalogService — CatalogEntry grew a Version field
  (1 for static; live column for authored). ListVisible / Find
  populate it.
- Models — Checklist.Version int; ChecklistInstance.TemplateVersion *int.
- /api/checklists/{slug} response now includes version so the
  instance detail page can compare against the snapshot.

Migration verified live via BEGIN..ROLLBACK against paliad.checklists
and paliad.checklist_instances.

Build hygiene: go build/vet/test ./internal/... + TestBootSmoke
./cmd/server/ all green.
2026-05-20 15:50:21 +02:00

310 lines
10 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"`
// Version of the underlying row. 1 for static templates (they
// re-version implicitly with the deploy that ships them); the live
// counter from paliad.checklists.version for authored rows.
Version int `json:"version"`
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",
Version: 1,
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,
Version: r.Version,
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",
Version: 1,
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,
Version: row.Version,
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.version, 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). Covers all 6 branches
// from mig 115: owner + firm/global + global_admin + 4 share-recipient
// kinds (user / office / partner_unit / project).
//
// 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'
)
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) {
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
}