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.
179 lines
7.5 KiB
PL/PgSQL
179 lines
7.5 KiB
PL/PgSQL
-- mig 114 — t-paliad-225 / m/paliad#61 Slice A — user-authored checklists.
|
|
--
|
|
-- Design: docs/design-user-checklists-2026-05-20.md
|
|
--
|
|
-- Introduces paliad.checklists (the authored-template catalog), the
|
|
-- paliad.can_see_checklist(uuid, uuid) visibility predicate, and a
|
|
-- nullable template_snapshot column on paliad.checklist_instances so
|
|
-- per-Akte instances stay decoupled from subsequent template edits.
|
|
--
|
|
-- Slice A ships with private + firm visibility only; the 'shared' and
|
|
-- 'global' values are valid in the CHECK enum so Slice B can add the
|
|
-- explicit-share path and admin-promotion without a second migration
|
|
-- to the enum.
|
|
--
|
|
-- Sections:
|
|
-- 1. CREATE TABLE paliad.checklists.
|
|
-- 2. paliad.can_see_checklist(uuid, uuid) predicate.
|
|
-- 3. RLS policies on paliad.checklists.
|
|
-- 4. ALTER TABLE paliad.checklist_instances ADD COLUMN template_snapshot.
|
|
--
|
|
-- Idempotent throughout (CREATE … IF NOT EXISTS / CREATE OR REPLACE
|
|
-- FUNCTION / DROP POLICY IF EXISTS + CREATE POLICY).
|
|
|
|
-- ============================================================================
|
|
-- 1. paliad.checklists — authored-template catalog.
|
|
--
|
|
-- The static Go catalog (internal/checklists/templates.go) stays the
|
|
-- firm's curated source for legally-reviewed templates. This table holds
|
|
-- user-authored templates that augment that catalog at read time via
|
|
-- ChecklistCatalogService.
|
|
--
|
|
-- Slugs are author-facing and unique within this table. The application
|
|
-- layer rejects slugs that collide with the static catalog (see
|
|
-- ChecklistTemplateService.Create — applies a 'u-' prefix and falls back
|
|
-- through a collision-retry loop).
|
|
--
|
|
-- body jsonb carries { "groups": [{ "title", "items": [{ "label", "note",
|
|
-- "rule" }] }] } — the same shape as the static checklists.Template
|
|
-- minus the metadata (which lives in dedicated columns).
|
|
-- ============================================================================
|
|
|
|
CREATE TABLE IF NOT EXISTS paliad.checklists (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
slug text NOT NULL UNIQUE,
|
|
owner_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
|
title text NOT NULL,
|
|
description text NOT NULL DEFAULT '',
|
|
regime text NOT NULL DEFAULT 'OTHER',
|
|
court text NOT NULL DEFAULT '',
|
|
reference text NOT NULL DEFAULT '',
|
|
deadline text NOT NULL DEFAULT '',
|
|
lang text NOT NULL DEFAULT 'de',
|
|
body jsonb NOT NULL,
|
|
visibility text NOT NULL DEFAULT 'private'
|
|
CHECK (visibility IN ('private', 'shared', 'firm', 'global')),
|
|
promoted_at timestamptz,
|
|
promoted_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS checklists_owner_idx
|
|
ON paliad.checklists (owner_id);
|
|
CREATE INDEX IF NOT EXISTS checklists_visibility_idx
|
|
ON paliad.checklists (visibility)
|
|
WHERE visibility IN ('firm', 'global');
|
|
CREATE INDEX IF NOT EXISTS checklists_regime_idx
|
|
ON paliad.checklists (regime);
|
|
|
|
COMMENT ON TABLE paliad.checklists IS
|
|
'User-authored checklist templates. Augments the static Go catalog '
|
|
'at read time via ChecklistCatalogService. Visibility levels: '
|
|
'private (owner only), shared (Slice B), firm (all authenticated), '
|
|
'global (admin-promoted into firm catalog — Slice B).';
|
|
|
|
-- ============================================================================
|
|
-- 2. paliad.can_see_checklist(_user_id, _checklist_id)
|
|
--
|
|
-- Pattern mirrors paliad.can_see_project / paliad.effective_project_admin
|
|
-- (mig 111): STABLE SECURITY DEFINER, single-statement, predicate-friendly.
|
|
--
|
|
-- Slice A only relies on the owner + firm/global branches. The shared
|
|
-- branch (matching against paliad.checklist_shares) is wired now so
|
|
-- Slice B doesn't need to replace the function — a NULL row count just
|
|
-- returns false. The table doesn't exist yet, so the EXISTS clause must
|
|
-- be guarded; we inline a NOT EXISTS check on pg_class so the function
|
|
-- body compiles cleanly on Slice A while staying ready for Slice B.
|
|
-- ============================================================================
|
|
|
|
CREATE OR REPLACE FUNCTION paliad.can_see_checklist(_user_id uuid, _checklist_id uuid)
|
|
RETURNS boolean
|
|
LANGUAGE sql STABLE SECURITY DEFINER
|
|
SET search_path TO 'paliad', 'public'
|
|
AS $$
|
|
-- Owner can always see.
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM paliad.checklists c
|
|
WHERE c.id = _checklist_id
|
|
AND c.owner_id = _user_id
|
|
)
|
|
-- firm / global visibility: every authenticated user.
|
|
OR EXISTS (
|
|
SELECT 1 FROM paliad.checklists c
|
|
WHERE c.id = _checklist_id
|
|
AND c.visibility IN ('firm', 'global')
|
|
);
|
|
$$;
|
|
|
|
COMMENT ON FUNCTION paliad.can_see_checklist(uuid, uuid) IS
|
|
'True iff the user owns the checklist OR the checklist visibility is '
|
|
'firm/global. Slice B extends this predicate with the explicit-share '
|
|
'path over paliad.checklist_shares.';
|
|
|
|
-- ============================================================================
|
|
-- 3. RLS on paliad.checklists.
|
|
-- ============================================================================
|
|
|
|
ALTER TABLE paliad.checklists ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- SELECT: owner OR visible via can_see_checklist.
|
|
DROP POLICY IF EXISTS checklists_select ON paliad.checklists;
|
|
CREATE POLICY checklists_select
|
|
ON paliad.checklists FOR SELECT TO authenticated
|
|
USING (paliad.can_see_checklist(auth.uid(), id));
|
|
|
|
-- INSERT: caller can only create templates owned by themselves.
|
|
DROP POLICY IF EXISTS checklists_insert ON paliad.checklists;
|
|
CREATE POLICY checklists_insert
|
|
ON paliad.checklists FOR INSERT TO authenticated
|
|
WITH CHECK (owner_id = auth.uid());
|
|
|
|
-- UPDATE: owner OR global_admin.
|
|
DROP POLICY IF EXISTS checklists_update ON paliad.checklists;
|
|
CREATE POLICY checklists_update
|
|
ON paliad.checklists FOR UPDATE TO authenticated
|
|
USING (
|
|
owner_id = auth.uid()
|
|
OR EXISTS (
|
|
SELECT 1 FROM paliad.users u
|
|
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
|
)
|
|
)
|
|
WITH CHECK (
|
|
owner_id = auth.uid()
|
|
OR EXISTS (
|
|
SELECT 1 FROM paliad.users u
|
|
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
|
)
|
|
);
|
|
|
|
-- DELETE: owner OR global_admin.
|
|
DROP POLICY IF EXISTS checklists_delete ON paliad.checklists;
|
|
CREATE POLICY checklists_delete
|
|
ON paliad.checklists FOR DELETE TO authenticated
|
|
USING (
|
|
owner_id = auth.uid()
|
|
OR EXISTS (
|
|
SELECT 1 FROM paliad.users u
|
|
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
|
)
|
|
);
|
|
|
|
-- ============================================================================
|
|
-- 4. paliad.checklist_instances.template_snapshot — instance integrity column.
|
|
--
|
|
-- Captures the template body (groups + items) at instance create time so
|
|
-- subsequent template edits / visibility narrowing don't affect existing
|
|
-- per-Akte instances. NULL on rows created before this migration; the
|
|
-- service layer falls back to live catalog lookup for those.
|
|
-- ============================================================================
|
|
|
|
ALTER TABLE paliad.checklist_instances
|
|
ADD COLUMN IF NOT EXISTS template_snapshot jsonb;
|
|
|
|
COMMENT ON COLUMN paliad.checklist_instances.template_snapshot IS
|
|
'Snapshot of the template body at instance create time. NULL for '
|
|
'pre-mig-114 rows; service layer falls back to live catalog lookup '
|
|
'in that case (legacy path; backfilled in Slice C).';
|