Files
paliad/internal/db/migrations/146_submission_bases.up.sql
mAi e2969fc358
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
feat(submissions): Composer Slice A — base picker + read-only section list (m/paliad#141)
The first slice of the Submission generator v2 ("Composer") per the
design at docs/design-submission-generator-v2-2026-05-26.md §12 Slice A.
Ships the base concept + per-draft section seeding end-to-end with NO
change to the .docx render path — v1 export still works exactly as
today.

Schema (mig 146/147/148):
- paliad.submission_bases — catalog table; one row per template base
  (slug, firm, proceeding_family, label_de/en, gitea_path, section_spec
  jsonb, is_default_for[]). RLS: wide-open SELECT for authenticated
  users, mutations admin-only (handler-enforced, no RLS write paths).
  Seeded with 2 rows: hlc-letterhead → _firm-skeleton.docx; neutral →
  _skeleton.docx. Each section_spec carries the 10-section default
  (letterhead, caption, introduction, requests, facts, legal_argument,
  evidence, exhibits, closing, signature) with bilingual labels +
  bag-driven seed Markdown for caption/letterhead/signature.
- paliad.submission_drafts gains base_id (FK SET NULL, optional) +
  composer_meta jsonb (default '{}'). Purely additive; pre-Composer
  drafts keep base_id NULL → v1 fallback render path stays active.
- paliad.submission_sections — per-draft section rows (draft_id,
  section_key, order_index, kind ∈ {prose,requests,evidence},
  label_de/en, included, content_md_de/en). RLS mirrors
  submission_drafts (owner-scoped + can_see_project, four policies).

Backend:
- BaseService (read-only Slice A): List + GetByID + GetBySlug +
  GetDefaultForCode (firm/family fallback chain).
- SectionService: ListForDraft + Get + SeedFromSpec (transactional
  multi-INSERT).
- SubmissionDraftService.AttachComposer wires both; Create resolves
  the firm default base and seeds base_id + section rows in one tx.
  Composer wiring is additive — when bases==nil the service stays
  v1-shaped.
- Update accepts BaseID **uuid.UUID (set / clear / no-change).
- submissionDraftView gains BaseID, ComposerMeta, Sections fields.
- Routes: GET /api/submission-bases (catalog list). PATCH endpoints
  on both project-scoped and global drafts accept "base_id".

Frontend:
- submission-draft.tsx: base picker dropdown above language toggle
  (hidden until catalog loads); section-list pane above the preview
  (hidden when no rows).
- client/submission-draft.ts: loadBases() parallel-fetches on boot;
  paintBasePicker rebuilds <option> list on every paint; onBaseChange
  PATCHes base_id and repaints; paintSectionList renders each section
  read-only (label + kind chip + excluded badge + Markdown body).
- Per the brief: NO auto-upgrade of existing 11 drafts (that's Slice C).
  Pre-Composer drafts get the picker (catalog still loads) but the
  section pane stays hidden until they pick a base on a new draft.

Tests:
- TestFamilyOfCode + TestBaseSectionSpec_DecodeShape + _EmptyDecode
  (pure unit, no DB).
- TestComposerSeedFlow (live, TEST_DATABASE_URL-gated): asserts mig 146
  seeded 10 default sections on both bases; GetDefaultForCode picks
  hlc-letterhead for HLC/de.inf.lg.erwidg; new draft via Create seeds
  base_id + 10 section rows in tx with ascending order_index and
  bilingual labels populated.

NO behavior change to .docx export — the v1 path stays sole render
path this slice. Composer's anchor-based assembly engine + MD→OOXML
walker land in Slice B.

Build hygiene: go build/vet/test -short clean; bun run build clean
(2900 i18n keys, data-i18n scan clean).

t-paliad-313
2026-05-26 19:23:40 +02:00

174 lines
12 KiB
SQL

-- t-paliad-313 (m/paliad#141): Composer Slice A — submission base catalog.
--
-- paliad.submission_bases is a thin pointer table — each row maps a
-- short, stable slug ("hlc-letterhead", "neutral", …) onto a Gitea path
-- that holds the actual .docx body, plus a JSON section-spec describing
-- the base's default section set, stylemap, and per-section seed
-- Markdown. The .docx in Gitea stays the source of truth for the
-- chrome, fonts, paragraph styles, and (in later slices) the
-- {{#section:KEY}} anchors. The DB row carries the listable metadata
-- the picker needs.
--
-- Visibility: every authenticated user SELECTs (the catalog is shared
-- firm-wide). Mutations are admin-only and enforced in Go at the
-- handler layer — RLS only gates reads.
--
-- Slice A seeds two rows:
-- 1. hlc-letterhead — points at the existing HLC firm skeleton
-- (_firm-skeleton.docx with HL Patents Style typography).
-- 2. neutral — points at the universal _skeleton.docx.
-- Specialist bases (lg-duesseldorf, upc-formal) land in Slice E with
-- their own .docx authoring task.
CREATE TABLE IF NOT EXISTS paliad.submission_bases (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
firm text,
proceeding_family text,
label_de text NOT NULL,
label_en text NOT NULL,
description_de text,
description_en text,
gitea_path text NOT NULL,
section_spec jsonb NOT NULL,
is_default_for text[] NOT NULL DEFAULT '{}'::text[],
is_active bool NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS submission_bases_firm_family_idx
ON paliad.submission_bases (firm, proceeding_family) WHERE is_active;
ALTER TABLE paliad.submission_bases ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS submission_bases_select ON paliad.submission_bases;
CREATE POLICY submission_bases_select
ON paliad.submission_bases FOR SELECT TO authenticated
USING (true);
-- INSERT / UPDATE / DELETE intentionally absent — admin-only mutations
-- happen via the handler layer with explicit role checks. No RLS path
-- for mutations means RLS denies them by default.
DROP TRIGGER IF EXISTS submission_bases_set_updated_at ON paliad.submission_bases;
CREATE TRIGGER submission_bases_set_updated_at
BEFORE UPDATE ON paliad.submission_bases
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
COMMENT ON TABLE paliad.submission_bases IS
't-paliad-313: Composer base catalog. One row per base template (HLC letterhead, neutral, …) pointing at a .docx in Gitea + a JSON section spec.';
-- Seed: HLC letterhead + neutral skeleton. The section_spec carries the
-- 10 default sections (letterhead, caption, introduction, requests,
-- facts, legal_argument, evidence, exhibits, closing, signature) with
-- their kinds, default order, and bilingual labels. seed_md_de /
-- seed_md_en are populated for the bag-driven sections (letterhead,
-- caption, signature); the remaining sections seed empty.
--
-- exhibits.included=false by default (lawyer opts in when an attachment
-- list applies). Every other section ships included=true.
INSERT INTO paliad.submission_bases
(slug, firm, proceeding_family, label_de, label_en, description_de, description_en, gitea_path, section_spec, is_default_for)
VALUES
('hlc-letterhead', 'HLC', NULL,
'HLC-Briefkopf', 'HLC letterhead',
'Mit HL Patents Style — Firmen-Header, Schriftarten, Absatzformaten.',
'With HL Patents Style — firm header, fonts, paragraph styles.',
'6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx',
jsonb_build_object(
'version', 1,
'stylemap', jsonb_build_object(
'paragraph', 'HLpat-Body-B0',
'heading_1', 'HLpat-Heading-H1',
'heading_2', 'HLpat-Heading-H2',
'heading_3', 'HLpat-Heading-H3',
'list_bullet', 'HLpat-Body-B0',
'list_numbered', 'HLpat-Body-B0',
'blockquote', 'HLpat-Body-B1'
),
'defaults', jsonb_build_array(
jsonb_build_object('section_key','letterhead', 'kind','prose', 'order_index', 1, 'label_de','Briefkopf', 'label_en','Letterhead',
'included',true,
'seed_md_de', E'Schriftsatz von {{firm.name}}\n\n{{user.display_name}}, {{user.office}}',
'seed_md_en', E'Submission by {{firm.name}}\n\n{{user.display_name}}, {{user.office}}'),
jsonb_build_object('section_key','caption', 'kind','prose', 'order_index', 2, 'label_de','Rubrum', 'label_en','Caption',
'included',true,
'seed_md_de', E'In der Sache\n\n**{{parties.claimant.0.name}}**\nvertreten durch {{parties.claimant.0.representative}}\n\n— Klägerin —\n\ngegen\n\n**{{parties.defendant.0.name}}**\nvertreten durch {{parties.defendant.0.representative}}\n\n— Beklagte —\n\nAktenzeichen: {{project.case_number}}\n{{project.court}}',
'seed_md_en', E'In the matter\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n\n— Defendant —\n\nCase number: {{project.case_number}}\n{{project.court}}'),
jsonb_build_object('section_key','introduction', 'kind','prose', 'order_index', 3, 'label_de','Einleitung', 'label_en','Introduction',
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','requests', 'kind','requests', 'order_index', 4, 'label_de','Anträge', 'label_en','Requests',
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','facts', 'kind','prose', 'order_index', 5, 'label_de','Sachverhalt', 'label_en','Facts',
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','legal_argument', 'kind','prose', 'order_index', 6, 'label_de','Rechtliche Würdigung', 'label_en','Legal argument',
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','evidence', 'kind','evidence', 'order_index', 7, 'label_de','Beweisangebote', 'label_en','Evidence offering',
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','exhibits', 'kind','prose', 'order_index', 8, 'label_de','Anlagen', 'label_en','Exhibits',
'included',false, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','closing', 'kind','prose', 'order_index', 9, 'label_de','Schlussformel', 'label_en','Closing',
'included',true,
'seed_md_de', E'Mit freundlichen Grüßen',
'seed_md_en', E'Yours sincerely,'),
jsonb_build_object('section_key','signature', 'kind','prose', 'order_index',10, 'label_de','Unterschrift', 'label_en','Signature',
'included',true,
'seed_md_de', E'{{user.display_name}}\n{{user.office}}',
'seed_md_en', E'{{user.display_name}}\n{{user.office}}')
)
),
'{}'::text[]
),
('neutral', NULL, NULL,
'Neutraler Schriftsatz', 'Neutral skeleton',
'Universelle Vorlage ohne firmenspezifisches Branding.',
'Universal template with no firm-specific branding.',
'6 - material/Templates/Word/Paliad/HLC/_skeleton.docx',
jsonb_build_object(
'version', 1,
'stylemap', jsonb_build_object(
'paragraph', 'Normal',
'heading_1', 'Heading 1',
'heading_2', 'Heading 2',
'heading_3', 'Heading 3',
'list_bullet', 'Normal',
'list_numbered', 'Normal',
'blockquote', 'Quote'
),
'defaults', jsonb_build_array(
jsonb_build_object('section_key','letterhead', 'kind','prose', 'order_index', 1, 'label_de','Briefkopf', 'label_en','Letterhead',
'included',true,
'seed_md_de', E'Schriftsatz von {{firm.name}}\n\n{{user.display_name}}',
'seed_md_en', E'Submission by {{firm.name}}\n\n{{user.display_name}}'),
jsonb_build_object('section_key','caption', 'kind','prose', 'order_index', 2, 'label_de','Rubrum', 'label_en','Caption',
'included',true,
'seed_md_de', E'In der Sache\n\n**{{parties.claimant.0.name}}**\n— Klägerin —\n\ngegen\n\n**{{parties.defendant.0.name}}**\n— Beklagte —\n\nAktenzeichen: {{project.case_number}}',
'seed_md_en', E'In the matter\n\n**{{parties.claimant.0.name}}**\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\n— Defendant —\n\nCase number: {{project.case_number}}'),
jsonb_build_object('section_key','introduction', 'kind','prose', 'order_index', 3, 'label_de','Einleitung', 'label_en','Introduction',
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','requests', 'kind','requests', 'order_index', 4, 'label_de','Anträge', 'label_en','Requests',
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','facts', 'kind','prose', 'order_index', 5, 'label_de','Sachverhalt', 'label_en','Facts',
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','legal_argument', 'kind','prose', 'order_index', 6, 'label_de','Rechtliche Würdigung', 'label_en','Legal argument',
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','evidence', 'kind','evidence', 'order_index', 7, 'label_de','Beweisangebote', 'label_en','Evidence offering',
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','exhibits', 'kind','prose', 'order_index', 8, 'label_de','Anlagen', 'label_en','Exhibits',
'included',false, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','closing', 'kind','prose', 'order_index', 9, 'label_de','Schlussformel', 'label_en','Closing',
'included',true,
'seed_md_de', E'Mit freundlichen Grüßen',
'seed_md_en', E'Yours sincerely,'),
jsonb_build_object('section_key','signature', 'kind','prose', 'order_index',10, 'label_de','Unterschrift', 'label_en','Signature',
'included',true,
'seed_md_de', E'{{user.display_name}}',
'seed_md_en', E'{{user.display_name}}')
)
),
'{}'::text[]
)
ON CONFLICT (slug) DO NOTHING;