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

409 lines
13 KiB
Go

package services
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/models"
)
// ChecklistInstanceService reads and writes paliad.checklist_instances.
//
// Visibility mirrors paliad.appointments (project_id nullable):
// - project_id NULL → creator-only (personal instance)
// - project_id NOT NULL → parent Project's team-based gate
//
// Template resolution goes through ChecklistCatalogService so authored
// templates (paliad.checklists, mig 114) and static templates work
// interchangeably. Instance create captures a template_snapshot so
// subsequent template edits/deletes don't disturb existing instances.
type ChecklistInstanceService struct {
db *sqlx.DB
projects *ProjectService
catalog *ChecklistCatalogService
}
func NewChecklistInstanceService(db *sqlx.DB, projects *ProjectService, catalog *ChecklistCatalogService) *ChecklistInstanceService {
return &ChecklistInstanceService{db: db, projects: projects, catalog: catalog}
}
const checklistInstanceColumns = `ci.id, ci.template_slug, ci.name, ci.project_id, ci.state,
ci.created_by, ci.created_at, ci.updated_at, ci.template_snapshot`
const checklistInstanceWithProjectSelect = `SELECT ` + checklistInstanceColumns + `,
p.reference AS project_reference,
p.title AS project_title
FROM paliad.checklist_instances ci
LEFT JOIN paliad.projects p ON p.id = ci.project_id`
// CreateInstanceInput is the POST body for creating a new instance.
type CreateInstanceInput struct {
Name string `json:"name"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
}
// UpdateInstanceInput is the PATCH body. Any subset of fields may be set.
type UpdateInstanceInput struct {
Name *string `json:"name,omitempty"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
State map[string]bool `json:"state,omitempty"`
ClearProject bool `json:"clear_projekt,omitempty"`
}
// ListForTemplate returns every visible instance of a given template.
func (s *ChecklistInstanceService) ListForTemplate(ctx context.Context, userID uuid.UUID, slug string) ([]models.ChecklistInstanceWithProject, error) {
if _, err := s.catalog.Find(ctx, userID, slug); err != nil {
if errors.Is(err, ErrNotVisible) {
return nil, fmt.Errorf("%w: unknown template slug %q", ErrInvalidInput, slug)
}
return nil, err
}
user, err := s.projects.Users().GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return []models.ChecklistInstanceWithProject{}, nil
}
query := checklistInstanceWithProjectSelect + `
WHERE ci.template_slug = :slug
AND (
(ci.project_id IS NULL AND ci.created_by = :user_id)
OR (ci.project_id IS NOT NULL AND ` + visibilityPredicate("p") + `)
)
ORDER BY ci.updated_at DESC`
args := map[string]any{
"slug": slug,
"user_id": userID,
}
return s.listWithProject(ctx, query, args)
}
// ListForProject returns every visible instance attached to a Project.
func (s *ChecklistInstanceService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.ChecklistInstanceWithProject, error) {
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
return nil, err
}
query := checklistInstanceWithProjectSelect + `
WHERE ci.project_id = :project_id
ORDER BY ci.updated_at DESC`
return s.listWithProject(ctx, query, map[string]any{"project_id": projectID})
}
// ListAllVisible returns every checklist instance the user can see across
// all templates and projects. Personal instances (project_id NULL) are scoped
// to the creator; project-attached instances follow paliad.can_see_project.
// Ordered by created_at DESC so the most recently created instances surface
// first on the /checklists "Vorhandene Instanzen" tab.
func (s *ChecklistInstanceService) ListAllVisible(ctx context.Context, userID uuid.UUID) ([]models.ChecklistInstanceWithProject, error) {
user, err := s.projects.Users().GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return []models.ChecklistInstanceWithProject{}, nil
}
query := checklistInstanceWithProjectSelect + `
WHERE (
(ci.project_id IS NULL AND ci.created_by = :user_id)
OR (ci.project_id IS NOT NULL AND ` + visibilityPredicate("p") + `)
)
ORDER BY ci.created_at DESC`
return s.listWithProject(ctx, query, map[string]any{"user_id": userID})
}
// GetByID returns a single instance with visibility check applied.
func (s *ChecklistInstanceService) GetByID(ctx context.Context, userID, id uuid.UUID) (*models.ChecklistInstance, error) {
inst, err := s.getByIDUnchecked(ctx, id)
if err != nil {
return nil, err
}
if err := s.requireVisible(ctx, userID, inst); err != nil {
return nil, err
}
return inst, nil
}
// Create inserts a new instance. Captures a template_snapshot via the
// catalog so subsequent template edits/deletes don't disturb this row
// (t-paliad-225 Slice A).
func (s *ChecklistInstanceService) Create(ctx context.Context, userID uuid.UUID, slug string, input CreateInstanceInput) (*models.ChecklistInstance, error) {
if _, err := s.catalog.Find(ctx, userID, slug); err != nil {
if errors.Is(err, ErrNotVisible) {
return nil, fmt.Errorf("%w: unknown template slug %q", ErrInvalidInput, slug)
}
return nil, err
}
snapshot, err := s.catalog.SnapshotBody(ctx, userID, slug)
if err != nil {
return nil, fmt.Errorf("snapshot template body: %w", err)
}
name := strings.TrimSpace(input.Name)
if name == "" {
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
}
if len(name) > 200 {
return nil, fmt.Errorf("%w: name exceeds 200 characters", ErrInvalidInput)
}
if input.ProjectID != nil {
if _, err := s.projects.GetByID(ctx, userID, *input.ProjectID); err != nil {
return nil, err
}
}
id := uuid.New()
now := time.Now().UTC()
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.checklist_instances
(id, template_slug, name, project_id, state, template_snapshot,
created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, '{}'::jsonb, $5::jsonb, $6, $7, $7)`,
id, slug, name, input.ProjectID, string(snapshot), userID, now,
); err != nil {
return nil, fmt.Errorf("insert checklist_instance: %w", err)
}
if input.ProjectID != nil {
desc := fmt.Sprintf("Checkliste \u201E%s\u201C angelegt", name)
descPtr := &desc
if err := insertProjectEventWithMeta(ctx, tx, *input.ProjectID, userID,
"checklist_created", "Checklist created", descPtr,
map[string]any{"checklist_instance_id": id}); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit create instance: %w", err)
}
return s.GetByID(ctx, userID, id)
}
// Update applies a partial update (rename, re-link, state merge).
func (s *ChecklistInstanceService) Update(ctx context.Context, userID, id uuid.UUID, input UpdateInstanceInput) (*models.ChecklistInstance, error) {
current, err := s.GetByID(ctx, userID, id)
if err != nil {
return nil, err
}
sets := []string{}
args := []any{}
next := 1
appendSet := func(col string, val any) {
sets = append(sets, fmt.Sprintf("%s = $%d", col, next))
args = append(args, val)
next++
}
var renamedTo *string
if input.Name != nil {
n := strings.TrimSpace(*input.Name)
if n == "" {
return nil, fmt.Errorf("%w: name cannot be empty", ErrInvalidInput)
}
if len(n) > 200 {
return nil, fmt.Errorf("%w: name exceeds 200 characters", ErrInvalidInput)
}
appendSet("name", n)
renamedTo = &n
}
var relinkTo *uuid.UUID
var unlinking bool
if input.ClearProject {
appendSet("project_id", nil)
unlinking = true
} else if input.ProjectID != nil {
if _, err := s.projects.GetByID(ctx, userID, *input.ProjectID); err != nil {
return nil, err
}
appendSet("project_id", *input.ProjectID)
relinkTo = input.ProjectID
}
if len(input.State) > 0 {
patch, err := json.Marshal(input.State)
if err != nil {
return nil, fmt.Errorf("marshal state patch: %w", err)
}
sets = append(sets, fmt.Sprintf("state = state || $%d::jsonb", next))
args = append(args, string(patch))
next++
}
if len(sets) == 0 {
return current, nil
}
appendSet("updated_at", time.Now().UTC())
args = append(args, id)
query := fmt.Sprintf("UPDATE paliad.checklist_instances SET %s WHERE id = $%d",
strings.Join(sets, ", "), next)
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
return nil, fmt.Errorf("update checklist_instance: %w", err)
}
meta := map[string]any{"checklist_instance_id": id}
switch {
case renamedTo != nil && current.ProjectID != nil:
desc := fmt.Sprintf("Checkliste umbenannt: \u201E%s\u201C", *renamedTo)
descPtr := &desc
if err := insertProjectEventWithMeta(ctx, tx, *current.ProjectID, userID,
"checklist_renamed", "Checklist renamed", descPtr, meta); err != nil {
return nil, err
}
case unlinking && current.ProjectID != nil:
desc := fmt.Sprintf("Checkliste \u201E%s\u201C von Project getrennt", current.Name)
descPtr := &desc
if err := insertProjectEventWithMeta(ctx, tx, *current.ProjectID, userID,
"checklist_unlinked", "Checklist unlinked", descPtr, meta); err != nil {
return nil, err
}
case relinkTo != nil:
desc := fmt.Sprintf("Checkliste \u201E%s\u201C mit Project verknüpft", current.Name)
descPtr := &desc
if err := insertProjectEventWithMeta(ctx, tx, *relinkTo, userID,
"checklist_linked", "Checklist linked", descPtr, meta); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit update instance: %w", err)
}
return s.GetByID(ctx, userID, id)
}
// Reset clears all checkbox state on an instance.
func (s *ChecklistInstanceService) Reset(ctx context.Context, userID, id uuid.UUID) (*models.ChecklistInstance, error) {
current, err := s.GetByID(ctx, userID, id)
if err != nil {
return nil, err
}
now := time.Now().UTC()
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.checklist_instances
SET state = '{}'::jsonb, updated_at = $1
WHERE id = $2`, now, id); err != nil {
return nil, fmt.Errorf("reset instance: %w", err)
}
if current.ProjectID != nil {
desc := fmt.Sprintf("Checkliste \u201E%s\u201C zurückgesetzt", current.Name)
descPtr := &desc
if err := insertProjectEventWithMeta(ctx, tx, *current.ProjectID, userID,
"checklist_reset", "Checklist reset", descPtr,
map[string]any{"checklist_instance_id": id}); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit reset instance: %w", err)
}
return s.GetByID(ctx, userID, id)
}
// Delete removes an instance. Creator or partner/admin.
func (s *ChecklistInstanceService) Delete(ctx context.Context, userID, id uuid.UUID) error {
current, err := s.GetByID(ctx, userID, id)
if err != nil {
return err
}
if current.CreatedBy != userID {
user, err := s.projects.Users().GetByID(ctx, userID)
if err != nil {
return err
}
if user.GlobalRole != "global_admin" {
return fmt.Errorf("%w: only the creator or a partner/admin can delete a checklist instance", ErrForbidden)
}
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.checklist_instances WHERE id = $1`, id); err != nil {
return fmt.Errorf("delete instance: %w", err)
}
if current.ProjectID != nil {
desc := fmt.Sprintf("Checkliste \u201E%s\u201C gelöscht", current.Name)
descPtr := &desc
if err := insertProjectEvent(ctx, tx, *current.ProjectID, userID,
"checklist_deleted", "Checklist deleted", descPtr); err != nil {
return err
}
}
return tx.Commit()
}
// --- internals ------------------------------------------------------------
func (s *ChecklistInstanceService) listWithProject(ctx context.Context, query string, args map[string]any) ([]models.ChecklistInstanceWithProject, error) {
stmt, err := s.db.PrepareNamedContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("prepare list instances: %w", err)
}
defer stmt.Close()
rows := []models.ChecklistInstanceWithProject{}
if err := stmt.SelectContext(ctx, &rows, args); err != nil {
return nil, fmt.Errorf("list checklist_instances: %w", err)
}
return rows, nil
}
func (s *ChecklistInstanceService) getByIDUnchecked(ctx context.Context, id uuid.UUID) (*models.ChecklistInstance, error) {
var inst models.ChecklistInstance
err := s.db.GetContext(ctx, &inst,
`SELECT id, template_slug, name, project_id, state, created_by,
created_at, updated_at, template_snapshot
FROM paliad.checklist_instances WHERE id = $1`, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotVisible
}
if err != nil {
return nil, fmt.Errorf("fetch checklist_instance: %w", err)
}
return &inst, nil
}
func (s *ChecklistInstanceService) requireVisible(ctx context.Context, userID uuid.UUID, inst *models.ChecklistInstance) error {
if inst.ProjectID == nil {
if inst.CreatedBy != userID {
return ErrNotVisible
}
return nil
}
_, err := s.projects.GetByID(ctx, userID, *inst.ProjectID)
return err
}