-- 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).';