Compare commits

..

25 Commits

Author SHA1 Message Date
mAi
ee98db94fa feat(submissions): Composer Slice C — building blocks library (m/paliad#141)
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
Per the design at docs/design-submission-generator-v2-2026-05-26.md §8
and the Q2 / Q9 ratifications:

- Q2 (m, 2026-05-26): building blocks are plain text paste sources.
  No building_block_id reference is stored on submission_sections.
- Q9 (m, 2026-05-26): four visibility tiers — private / team / firm
  / global.

Schema (mig 149):

- paliad.submission_building_blocks — library catalog. Columns: slug,
  firm (NULL = cross-firm), section_key (binds to one section kind),
  proceeding_family (NULL = any), title_de/_en + description_de/_en
  + content_md_de/_en, author_id, visibility (CHECK in 4-tier set),
  is_published, created_at, updated_at, deleted_at (soft delete).
  RLS: coarse-grained SELECT — every authenticated user sees
  non-deleted non-private rows + own private rows. Tier-specific
  predicate (private/team/firm/global) applied in Go-layer service so
  semantics evolve without RLS migrations. Mutations admin-only (no
  RLS write paths).

- paliad.submission_building_block_admin_versions — append-only
  history per block, retention=20. Admin-side only; NOT referenced
  from submission_sections (per Q2's plain-text-paste model). Exists
  so accidental delete / overwrite are recoverable.

Backend:

- internal/services/submission_building_block_service.go (~510 LoC):
  BuildingBlockService. ListVisible applies tier predicate at query
  time (private = author_id match; firm = firm column NULL OR matches
  branding.Name; team = author shares a project_team with caller via
  paliad.project_teams self-join; global = open). ListAllForAdmin
  drops the predicate. Create + Update + SoftDelete + RestoreVersion
  all transactional; appendVersionTx writes one audit row +
  GC-deletes anything past the retention=20 horizon in the same tx.
  InsertIntoSection (the paste mechanic) clones content_md_<lang>
  into the section row with a "\n\n" separator if section already has
  content. NO building_block_id stamped per Q2.

- internal/handlers/submission_building_blocks.go (~480 LoC): nine
  handlers split between the lawyer-facing picker (list, insert) and
  the admin editor (list, get, create, update, delete, list-versions,
  restore-version, page). buildingBlockUpdateInput uses presence-
  tracking UnmarshalJSON for the four nullable fields (firm,
  proceeding_family, description_de/_en) so PATCH can distinguish
  "no change" from "set to null".

- Routes registered: lawyer-facing under /api/submission-building-blocks,
  admin-gated under /api/admin/submission-building-blocks/* and
  /admin/submission-building-blocks (page).

- Wiring: handlers.Services + dbServices + cmd/server/main.go all
  gain SubmissionBuildingBlock. NewBuildingBlockService takes the
  branding.Name firm hint for the visibility predicate.

Frontend:

- frontend/src/admin-submission-building-blocks.tsx (~85 LoC):
  three-pane admin shell (list / editor / version log) registered
  in build.ts.

- frontend/src/client/admin-submission-building-blocks.ts (~370
  LoC): admin client — list paint, edit form (slug + firm +
  section_key + proceeding_family + title/desc/content per lang +
  visibility radio + is_published toggle), per-block version log
  with restore button. Bilingual labels.

- frontend/src/client/submission-draft.ts: per-section "+ Baustein"
  button on the Composer editor toolbar (Slice B substrate gets one
  more affordance). openBlockPicker opens a modal filtered to the
  section's section_key, 200ms-debounced search by free text against
  title/description/content. Click a hit → POST insert-into-section
  → section row's content_md_<lang> gains the block's content
  appended at the end (Q2's plain-text paste semantic, no lineage).

- ~240 LoC of CSS: modal overlay + picker rows with tier-colored
  visibility chips + admin editor 3-pane grid + form rows + version
  list.

- 12 new i18n keys × 2 langs (admin.building_blocks.*).

Tests:
- TestValidVisibility (8 cases including case-sensitivity + empty).
- TestAppendBlockContent (8 cases covering empty-existing / empty-
  addition / whitespace-only / trailing newline collapse).
- TestBuildingBlockVisibilityConstants pins the 4 string literals
  against drift (RLS predicate + DB CHECK depend on them).

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

Hard rules per ratifications honoured:
- Q2: no building_block_id lineage on sections (paste is plain text).
- Q9: 4 visibility tiers (private/team/firm/global).
- NO behavior change for pre-Composer drafts (the picker just doesn't
  show — section list is hidden for base_id NULL drafts).
- {{rule.X}} aliases preserved (block content goes through the same
  v1 placeholder pass on export as section prose).

NOT in scope per Slice C brief:
- User-authored private blocks (Slice C ships admin curation only;
  any-user create is a follow-up).
- Tier promotion review workflow (admin sets tier directly today).
- Per-section "where is this block used" reverse lookup (no lineage
  to query).
- Slice D's rich-prose features (headings, lists, blockquote) still
  Slice D's job; this Slice doesn't extend the MD walker.

t-paliad-315 Slice C
2026-05-26 20:04:40 +02:00
mAi
c20e935a4b Merge: t-paliad-313 — Composer Slice B: editable prose + anchor-spliced render + MD→OOXML walker (m/paliad#141)
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
2026-05-26 19:46:40 +02:00
mAi
f963b0df34 feat(submissions): Composer Slice B — editable prose sections + anchor-spliced render (m/paliad#141)
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
The "Composer actually works" milestone per the design at
docs/design-submission-generator-v2-2026-05-26.md §12 Slice B. Builds on
Slice A's substrate (submission_bases, submission_sections, base_id on
drafts); no new migrations needed.

Backend additions:

- internal/services/submission_md.go (~240 LoC): Markdown → OOXML
  walker. Per the head's Slice B brief, scope is paragraphs +
  bold/italic + blank-line spacing. Placeholders pass through
  unchanged for the v1 substitution pass. CRLF normalisation; nested
  formatting (***bold-italic***); two delimiter forms (* and _);
  XML-escaping for &/</>; explicit empty-paragraph emit so blank
  lines round-trip. 12 unit tests.

- internal/services/submission_compose.go (~470 LoC): SubmissionComposer
  service. Pipeline: ConvertDotmToDocx pre-pass → extract
  word/document.xml → render each included section's content_md_<lang>
  → splice via {{#section:KEY}}/{{/section:KEY}} anchor pairs in
  the body → strip anchors for excluded sections → append unanchored
  sections before <w:sectPr> → repack zip → run v1 placeholder pass.
  RE2-friendly anchor scanner walks markers in body-order and matches
  open/close pairs with a stack (handles unbalanced anchors
  defensively). 6 unit tests covering anchor-mode splice,
  append-mode-no-anchors, excluded-section drop, placeholder
  resolution, lang column pick, order_index ASC.

- internal/services/submission_section_service.go: SectionPatch +
  Update method. Six optional fields (content_md_de/en, included,
  label_de/en, order_index). Sentinel ErrSubmissionSectionNotFound on
  RLS-filtered miss.

- internal/handlers/submission_sections.go (NEW, ~150 LoC):
  PATCH /api/submission-drafts/{draft_id}/sections/{section_id}.
  Owner-scoped via SubmissionDraftService.Get; section-belongs-to-draft
  cross-check. 404 on both missing-draft and section-belongs-elsewhere
  paths.

- internal/handlers/files.go: fetchComposerBaseBytes + composerBaseSlugMap
  reuse the existing Gitea proxy cache for base .docx bytes. hlc-letterhead
  → existing firmSkeletonSubmissionSlug, neutral → existing
  skeletonSubmissionSlug.

- internal/handlers/submission_drafts.go: exportSubmissionDraft helper
  branches on draft.BaseID. When set AND base + bytes + sections all
  resolve → Composer pipeline. Else v1 fallback render path stays.
  Audit metadata jsonb gains "composer": true + "base_id" flag when
  composer was used.

Wiring:
- handlers.Services gains SubmissionComposer.
- dbServices.submissionComposer wired from svc.SubmissionComposer.
- main.go instantiates NewSubmissionComposer with the existing
  SubmissionRenderer (so the {{rule.X}} alias contract stays preserved
  inside section content).

Frontend additions (~400 LoC):
- client/submission-draft.ts: paintSectionList rewritten to render a
  contentEditable per included section with a per-section B/I
  toolbar. Per-section autosave debounced 500ms; mousedown handlers on
  toolbar buttons preserve editor focus mid-command. domToMarkdown
  walks the contentEditable's DOM tree back to Markdown source-of-
  truth (b/strong → **…**, i/em → *…*, div/p → paragraph break, br
  → newline). Updated state.view.sections in-place on PATCH success
  without re-painting (avoids focus-stealing on every keystroke);
  re-paints only on structural changes (included toggle, label edits,
  order changes).

- client/submission-draft.ts: onSectionToggleIncluded hides/shows a
  section via PATCH. flushSectionAutosave on blur force-flushes
  pending edits so leaving an editor doesn't strand unsynced changes.

- styles/global.css: editor surface (contentEditable area with focus
  ring + placeholder), toolbar buttons (B/I 1.8rem squares),
  per-section "Hide"/"Include" toggle in the head row.

- Updated i18n hint copy: "Inhalt pro Abschnitt — Autosave nach
  500ms. Letztes Layout in Word."

Templates regenerated on Gitea:
- _skeleton.docx → composer-mode body (anchors only): blob SHA
  ac0cdeaf49f7cd417ec143e2319ffbb02ec65644.
- _firm-skeleton.docx → composer-mode body (anchors only, preserves
  sectPr → firm header/footer rIds): blob SHA
  f1e9a9fb9a29ca01bf7bee709a45c5dda2a8e317.
- Both uploaded as mAi via --netrc-file ~/.netrc-mai.
- gen-skeleton-submission-template script gains an -anchors flag
  (default true) so future regens emit composer-ready bodies. The
  _firm-skeleton.docx regen was done via a one-off /tmp helper since
  the gen-hl-skeleton-template script requires the proprietary .dotm
  source which lives in HL/mWorkRepo; extending that script to accept
  an existing .docx as input is a follow-up cleanup.

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

NO behavior change for pre-Composer drafts (base_id NULL → v1
fallback render path stays compiled in). NO migrations needed in this
slice — sections were already in the schema from Slice A; only
content_md_de/en UPDATEs happen via the new PATCH endpoint.

Hard rules per Q2/Q10 ratification still honoured:
- No building_block_id lineage (Slice C territory; Q2).
- Caption/letterhead/signature are regular prose sections, seeded from
  base spec; lawyer can edit/hide freely (Q10).
- {{rule.X}} aliases preserved (renderer pass unchanged).

NOT in scope per Slice B brief:
- Headings 1–3, lists, blockquote (Slice D's MD walker extension).
- Building blocks library (Slice C).
- Reorder / add-custom-section (Slice F).
- Auto-upgrade of pre-Composer drafts (Slice C — explicitly NOT in
  this slice per head's brief msg #2393).

t-paliad-313 Slice B
2026-05-26 19:45:29 +02:00
mAi
6cd340300b Merge: t-paliad-313 — Composer Slice A: base picker + read-only section list (migs 146/147/148) (m/paliad#141)
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
2026-05-26 19:25:08 +02:00
mAi
557f9a4cce Merge: fix(paliadin): one-shot fallback when persona lacks streaming (unblock chat)
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
2026-05-26 19:24:42 +02:00
mAi
3af71e772b fix(paliadin): fall back to one-shot when aichat persona lacks streaming
Symptom: paliadin chat returns "Verbindung verloren" because aichat's
paliadin persona is not configured with streaming support — every
RunTurnStream() call gets back HTTP 400 unsupported_streaming and the
SSE stream closes empty.

Fix: when RunTurnStream() detects "unsupported_streaming" in the
upstream error, transparently retry against /chat/turn (non-streaming)
with the same body. The full response gets emitted as a single
StreamChunk + StreamMeta so the SSE relay sees identical event
ordering. Persistence (completeTurn + markPrimed) mirrors the one-shot
RunTurn() path.

No real-time chunking until the persona is reconfigured upstream, but
the chat works end-to-end. Once the paliadin persona supports streaming
on aichat, this code path goes dormant — the unsupported_streaming
branch is only entered when the upstream actually returns that error.

Diagnostic logs from commit 937ff13 made this visible:
  paliadin: backend returned error err=aichat: HTTP 400 (bad_request):
  unsupported_streaming: persona paliadin does not support streaming

Refs m/paliad demo path.
2026-05-26 19:24:41 +02:00
mAi
e2969fc358 feat(submissions): Composer Slice A — base picker + read-only section list (m/paliad#141)
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
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
mAi
85d0cedd22 Merge: t-paliad-312 — PRD for submission generator v2 (Composer); 12 questions ratified (m/paliad#141)
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
2026-05-26 19:05:04 +02:00
mAi
0e1691f00e docs: ratify Q1-Q12 — submission generator v2 design final (m/paliad#141)
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
All 12 §11 design questions ratified by m on 2026-05-26 via
AskUserQuestion (paliadin-authorised override per instruction msg #2391).

Picks matching inventor recommendations (9 of 12):
 Q1 separate submission_sections table
 Q3 Gitea-backed body + thin DB row
 Q4 contentEditable + Markdown + in-house serializer
 Q5 section anchors + in-house MD->OOXML walker
 Q7 split content_md_de + content_md_en from day 1
 Q8 Go map for per-submission_code section defaults
 Q9 4 visibility tiers (private/team/firm/global)
 Q11 collapsed preview pane by default
 Q12 moot (superseded by Q2 simplification)

Deviations from recommendation (3 of 12):
 Q2 SIMPLIFY further — m: "sounds overengineered". Building blocks
    become plain text paste sources. No building_block_id column on
    sections, no _versions table referenced from sections, no
    refresh-from-library affordance. Slice G dropped.
 Q6 Auto-upgrade all 11 existing drafts at mig-148 apply time (not
    opt-in per draft). v1 fallback render path stays compiled in.
 Q10 *_auto kind removed. Caption/letterhead/signature sections are
    regular prose rows seeded with bag-driven Markdown; lawyer can
    edit/hide. Untouched drafts export identically to v1.

Body sections updated inline (§4.3 schema, §4.4 BB tables, §6.3
seeding, §8.3+8.4 BB insert, slice plan A/C/G, §11 ratification notes,
§14 risks #8+11, §17+18 acceptance + gate). §11 retains the historical
recommendation matrix.

Status: ALL DESIGN QUESTIONS RATIFIED — design doc final, ready for
Slice A coder shift. Inventor parks per hard gate. Head decides hire.

t-paliad-312
2026-05-26 19:04:21 +02:00
mAi
05ad43aa46 Merge: t-paliad-308 — Verfahrensablauf URL state hybrid (chips in URL, scenario in localStorage) (m/paliad#137)
Some checks failed
Paliad CI gate / deploy (push) Has been cancelled
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
2026-05-26 18:46:32 +02:00
mAi
635457474a docs: PRD/design — submission generator v2 ("Composer") (m/paliad#141)
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
Sectioned composition, swappable base templates, in-app prose editing,
building-blocks library. Deepens t-paliad-215 + t-paliad-238 without
replacing them — v1 contracts (submission_drafts shape, {{rule.X}}
aliases, audit shape) preserved.

7 slices A→G; Slice B is the smallest "Composer works" milestone.
Existing 11 v1 drafts continue via v1 path; opt-in upgrade per draft.

12 open design questions with recommended defaults + alternatives for m
to ratify via head escalation (no AskUserQuestion per task brief).

Flags two issue-body inaccuracies: no submission_drafts.audit_log column
(audit lives in system_audit_log + project_events); live row count is
11, not 7.

t-paliad-312
2026-05-26 18:37:52 +02:00
mAi
235e68496b Merge: t-paliad-311 — backup exporter drift-resistant + 4 broken ORDER BY cols fixed (m/paliad#140)
Some checks failed
Paliad CI gate / deploy (push) Has been cancelled
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
2026-05-26 18:20:42 +02:00
mAi
8125caf49a test(backup): add TEST_DATABASE_URL-gated live smokes for org export
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
Two complementary live tests (both skipped without TEST_DATABASE_URL):

- TestResolveOrgSheets_LiveSchemaSnapshot — runs the schema probe + SQL
  composer the way the backup runner does at the start of every run,
  then executes each resolved SELECT against the live DB (wrapped in
  LIMIT 1 to keep table reads cheap). A future column rename in a
  table our spec still names triggers this test and surfaces in CI
  before /admin/backups breaks.

- TestWriteOrg_LiveSmoke — end-to-end pipeline against a real DB:
  schema probe, REPEATABLE READ tx, every sheet query, xlsx + JSON +
  per-sheet CSV assembly, outer zip framing. Spot-checks meta.RowCounts
  and the zip magic bytes; doesn't materialise the full bundle to
  disk.

Both tests exercise the exact failure mode m/paliad#140 reproduced
(hardcoded ORDER BY against a renamed column) so CI catches regressions
once TEST_DATABASE_URL is wired.

m/paliad#140
2026-05-26 18:19:55 +02:00
mAi
937ff13470 Merge: footer 'by' + paliadin diagnostic logs (unblock 'Verbindung verloren' diagnosis)
Some checks failed
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Paliad CI gate / build (push) Has been cancelled
2026-05-26 18:17:39 +02:00
mAi
b97f170c1d chore: footer "by" + paliadin diagnostic logs
- Footer: "© 2026 Paliad — ein Werkzeug von / a tool by" → "© 2026 Paliad — by" (both DE + EN).
- Paliadin streaming handler now log.Printf on every error path (StreamError, silence_timeout, backend nil/err) so the next "Verbindung verloren" failure produces a server-side trace. Previous behaviour: silent SSE close + empty paliad logs, impossible to diagnose.
2026-05-26 18:17:33 +02:00
mAi
935ea23038 refactor(backup): make orgSheetQueries drift-resistant
Refactor orgSheetQueries() into orgSheetSpecs() returning declarative
(SheetName, Table, OrderBy []string) triples instead of free-form SQL,
with composeOrgSheetSQL() as a pure builder and resolveOrgSheets() as
the DB-touching orchestrator.

At backup time the resolver:
  1. probes information_schema.columns once for every spec table,
  2. composes SELECT * FROM <table> ORDER BY <columns-that-exist>,
  3. logs WARN per ORDER BY column dropped because it's gone.

A future column rename or removal can no longer break /admin/backups:
the worst case is one sheet temporarily losing sort stability, and the
WARN log surfaces which spec needs updating.

Sheets needing custom projections (documents drops ai_extracted) keep
the SQL override path. All other org-scope sheets — entity + ref__ —
declare their ORDER BY as a column list.

Tests:
  - 6 composeOrgSheetSQL unit tests cover the drift behaviour with no
    DB needed (missing column, all-missing, override bypass, declared
    order preserved, unknown table)
  - Existing registry-shape tests (no duplicates, no paliadin leakage,
    ref__ prefix, ORDER BY-for-determinism) updated to the spec API
  - Full internal/services suite green

m/paliad#140
2026-05-26 18:17:21 +02:00
mAi
f8e5be5f7a Merge: fix(submissions): order Schriftsätze catalog by sequence_order (was alphabetic — Berufungsbegründung ahead of Klageerhebung)
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
2026-05-26 18:15:07 +02:00
mAi
ee0a9ea6cb fix(submissions): order catalog by sequence_order, not alphabetic submission_code
The Schriftsätze list rendered procedurally meaningless: Berufungsbegründung
ahead of Klageerhebung etc. because the ORDER BY was alphabetic by
submission_code within each proceeding. Add dr.sequence_order ASC as the
primary intra-proceeding sort; submission_code stays as the deterministic
tiebreaker for rules sharing a sequence_order.

deadline_rules.sequence_order is already populated for every published
filing rule (verified via paliad.deadline_rules_unified). Pure read-side
fix; no schema or data change.
2026-05-26 18:15:01 +02:00
mAi
da464813b7 fix(backup): repair 4 broken ORDER BY columns in orgSheetQueries
Backup export was 100% broken because four sheets referenced columns
that no longer exist (or never did) in their target tables:

- email_templates: ORDER BY id → key, lang (composite PK)
- policy_audit_log: ORDER BY changed_at → created_at
- ref__deadline_event_types: ORDER BY rule_id → deadline_id (post-rename)
- ref__event_category_concepts: ORDER BY category_id → event_category_id

Audited every entry in orgSheetQueries() against information_schema.columns;
these were the only mismatches. Patch unblocks /admin/backups → Generate.
Drift-resistant refactor (m/paliad#140 Part B) follows in a separate commit.

m/paliad#140
2026-05-26 18:13:27 +02:00
mAi
6d24fb8931 Merge: t-paliad-310 — dark-mode CSS: repoint 12 var(--color-surface-alt) sites to defined tokens (m/paliad#138)
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
2026-05-26 18:07:45 +02:00
mAi
446c46e5c5 fix(css): repoint 12 var(--color-surface-alt, hex) sites to defined tokens (t-paliad-310, m/paliad#138)
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
The --color-surface-alt token was never defined in :root or :root[data-theme="dark"],
so the var() fallback hex literal always won — leaving 12 surface sites with
zero dark-mode treatment. Same pattern as t-paliad-087 / t-paliad-150 / t-paliad-291.

Issue #138 surfaced four panels visibly broken in dark mode:
1. submission-draft no-project banner ("Kein Projekt zugeordnet…") — white-on-white
2. submission-draft preview header ("Vorschau / Read-only Vorschau…") — white-on-white
3. smart-timeline rule-chip (e.g. de.null.bpatg.berufung in Vorhersage rows) — grey-on-grey
4. submission-draft addparty manual form (Manuell / Aus DB / Name / …) — white-on-white

Eight more latent sites with the same root cause are fixed in the same pass:
.submissions-new-chip:hover, .submissions-new-project-item:hover,
.submission-draft-import-row, .submission-draft-addparty-search-projref,
.collab-invite-hint, .smart-timeline-status-icon,
.smart-timeline-kind-chip--projected, .smart-timeline-add-choice:hover.

Each site repointed to the semantically correct existing token
(--color-surface-2 for #fafafa, --color-surface-muted for #f4f4f4,
--color-bg-subtle for #f7f7f0, --color-bg-lime-tint for the lime-tinted
collab-invite-hint). All four target tokens are defined in both :root
and :root[data-theme="dark"]. No new tokens introduced.

Light-mode hex values are functionally identical (#fafafa==#fafafa,
#f4f4f4≈#f3f4f6, #f7f7f0≈#f7f3f0).

Verified: bun run build clean; Playwright screenshots of the four panels
in both light + dark modes show correct rendering.
2026-05-26 18:07:02 +02:00
mAi
d1aa0f72c0 Merge: t-paliad-305 — Slice B.3: read cutover via paliad.deadline_rules_unified view (mig 139); legacy writes retire in B.4 (m/paliad#93)
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
2026-05-26 18:01:25 +02:00
mAi
94f2831f3f Merge: fix(backup): export ORDER BY uses binding_id (was calendar_binding_id) — unblocks /admin/backups
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
2026-05-26 18:00:37 +02:00
mAi
83be122b19 fix(backup): export ORDER BY uses binding_id, not calendar_binding_id
paliad.appointment_caldav_targets's join column is named binding_id
(mig 101). The backup sheet exporter referenced calendar_binding_id
which doesn't exist, so /admin/backups generate failed with 42703.

Single-char fix. Also flags follow-up: hardcoded ORDER BY columns on
every sheet in orgSheetQueries() are fragile under schema renames —
a separate slice (m/paliad#140) tracks making the exporter flexible
to drift (e.g. probe information_schema or use NULLS LAST id-only).
2026-05-26 18:00:17 +02:00
mAi
df592f9fc4 feat(db,services): Slice B.3 read cutover — flip reads to paliad.deadline_rules_unified view backed by sr+pe+ls (t-paliad-305 / m/paliad#93)
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
The new tables (mig 136) and the dual-write that keeps them in sync
(B.2) have been steady-state in prod since mig 136 deployed at
13:24 UTC today. Drift verified clean before this commit:
deadline_rules=231, sequencing_rules=231, procedural_events=231 (153
codes + 78 synthetic), legal_sources=87, zero mismatches across
counts, FK integrity, lifecycle, is_active.

This commit flips READ paths to source data from the new tables via
a backwards-compatible view, leaving the dual-write WRITE paths
untouched for B.4 to retire alongside the destructive drop.

* internal/db/migrations/139_deadline_rules_unified_view.up.sql (new) —
  CREATE VIEW paliad.deadline_rules_unified projecting sr+pe+ls
  back into the legacy paliad.deadline_rules column shape. Same
  column names + types so the Go-side change is a 1-token
  substitution per query with no struct or scanner edits.
  Post-apply DO block asserts view row count = sequencing_rules row
  count (FK NOT NULL on procedural_event_id guarantees they match).

* 10 service / handler files — every SELECT FROM paliad.deadline_rules
  (or JOIN paliad.deadline_rules) flipped to use the view:
  - internal/handlers/submissions.go            (Schriftsätze list)
  - internal/services/deadline_rule_service.go  (8 read sites)
  - internal/services/rule_editor_service.go    (3 read sites — ListRules, getByID, validateSpawnNoCycle)
  - internal/services/rule_editor_orphans.go    (candidate-rule lookup)
  - internal/services/submission_vars.go        (loadPublishedRule)
  - internal/services/deadline_service.go       (deadlines list join)
  - internal/services/fristenrechner.go         (calculator reads)
  - internal/services/projection_service.go     (projection reads)
  - internal/services/event_deadline_service.go (event→rule join)
  - internal/services/export_service.go         (3 export sites — ref__deadline_rules)

Verified semantically safe on live (read-only smoke):
- 231 rows in view match 231 in legacy.
- name + event_type pair: 231/231 match.
- legal_source: 231/231 match (NULL on both sides treated as match).
- submission_code: 153 non-NULL codes match exactly; the 78
  synthetic 'null.<8hex>' codes diverge from legacy NULL but no
  reader filters on NULL submission_code (verified
  handlers/submissions.go: synthetic-code rules all have NULL
  event_type so the WHERE event_type = 'filing' filter excludes
  them; the Schriftsätze surface returns the same 105 rows).

Scope decisions documented (deviation from design §5.3):
- B.3 ships the READ flip only. WRITE paths (RuleEditorService
  Create / UpdateDraft / CloneAsDraft / Publish / flipLifecycle)
  retain the dual-write from B.2 — they write to both legacy and
  new tables. B.4 (destructive drop) will retire the legacy writes
  in the same slice that drops the table, avoiding a transient
  state where the legacy writes have no purpose.
- The B.2 drift-check ticker (StartDualWriteDriftCheckLoop) stays
  active for the same reason: dual-write continues, so the
  invariants the loop checks remain meaningful.

This shape is paliadin-approvable on a "good solution > strict
phase boundary" reading of m's greenlight. If paliadin pushes back
and wants the legacy writes removed in B.3, the refactor is ~300
LOC across the 5 RuleEditorService write methods + buildPatchSets
split into PE/SR sets — schedulable as B.3.5 before B.4.

Build + vet clean. TestMigrations_NoDuplicateSlot passes.
2026-05-26 17:59:58 +02:00
54 changed files with 7623 additions and 158 deletions

View File

@@ -160,6 +160,21 @@ func main() {
submissionVarsSvc := services.NewSubmissionVarsService(pool, projectSvc, partySvc, users)
submissionRenderer := services.NewSubmissionRenderer()
submissionDraftSvc := services.NewSubmissionDraftService(pool, projectSvc, submissionVarsSvc, submissionRenderer)
// t-paliad-313 Composer Slice A — base catalog + section seeding.
// AttachComposer wires both into the draft service so Create
// seeds base_id + submission_sections rows on new drafts. v1
// fallback path stays active for pre-Composer drafts (base_id
// NULL, no section rows).
submissionBaseSvc := services.NewBaseService(pool)
submissionSectionSvc := services.NewSectionService(pool)
submissionDraftSvc.AttachComposer(submissionBaseSvc, submissionSectionSvc, branding.Name)
// t-paliad-313 Slice B — render-pipeline assembler. Reuses the
// existing SubmissionRenderer for the final placeholder pass so
// the {{rule.X}} alias contract stays preserved inside the
// composed body.
submissionComposerSvc := services.NewSubmissionComposer(submissionRenderer)
// t-paliad-315 Slice C — building-block library.
submissionBuildingBlockSvc := services.NewBuildingBlockService(pool, branding.Name)
// t-paliad-225 Slice A — user-authored checklist templates.
// Slice B adds checklist_shares grants + admin promotion.
checklistCatalogSvc := services.NewChecklistCatalogService(pool)
@@ -171,7 +186,11 @@ func main() {
Team: teamSvc,
PartnerUnit: partnerUnitSvc,
Party: partySvc,
SubmissionDraft: submissionDraftSvc,
SubmissionDraft: submissionDraftSvc,
SubmissionBase: submissionBaseSvc,
SubmissionSection: submissionSectionSvc,
SubmissionComposer: submissionComposerSvc,
SubmissionBuildingBlock: submissionBuildingBlockSvc,
Deadline: deadlineSvc,
Appointment: appointmentSvc,
CalDAV: caldavSvc,

File diff suppressed because it is too large Load Diff

View File

@@ -40,6 +40,7 @@ import { renderAdminTeam } from "./src/admin-team";
import { renderAdminAuditLog } from "./src/admin-audit-log";
import { renderAdminPartnerUnits } from "./src/admin-partner-units";
import { renderAdminEmailTemplates } from "./src/admin-email-templates";
import { renderAdminSubmissionBuildingBlocks } from "./src/admin-submission-building-blocks";
import { renderAdminEmailTemplatesEdit } from "./src/admin-email-templates-edit";
import { renderAdminEventTypes } from "./src/admin-event-types";
import { renderAdminApprovalPolicies } from "./src/admin-approval-policies";
@@ -278,6 +279,7 @@ async function build() {
join(import.meta.dir, "src/client/admin-partner-units.ts"),
join(import.meta.dir, "src/client/admin-email-templates.ts"),
join(import.meta.dir, "src/client/admin-email-templates-edit.ts"),
join(import.meta.dir, "src/client/admin-submission-building-blocks.ts"),
join(import.meta.dir, "src/client/admin-event-types.ts"),
join(import.meta.dir, "src/client/admin-approval-policies.ts"),
join(import.meta.dir, "src/client/admin-broadcasts.ts"),
@@ -409,6 +411,7 @@ async function build() {
await Bun.write(join(DIST, "admin-partner-units.html"), renderAdminPartnerUnits());
await Bun.write(join(DIST, "admin-email-templates.html"), renderAdminEmailTemplates());
await Bun.write(join(DIST, "admin-email-templates-edit.html"), renderAdminEmailTemplatesEdit());
await Bun.write(join(DIST, "admin-submission-building-blocks.html"), renderAdminSubmissionBuildingBlocks());
await Bun.write(join(DIST, "admin-event-types.html"), renderAdminEventTypes());
await Bun.write(join(DIST, "admin-approval-policies.html"), renderAdminApprovalPolicies());
await Bun.write(join(DIST, "admin-broadcasts.html"), renderAdminBroadcasts());

View File

@@ -0,0 +1,77 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// /admin/submission-building-blocks — Composer building-blocks library
// editor (t-paliad-315 Slice C). Three-pane layout: list on the left,
// edit form in the middle, version log on the right. Hydrated by
// client/admin-submission-building-blocks.ts from
// GET /api/admin/submission-building-blocks.
export function renderAdminSubmissionBuildingBlocks(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="admin.building_blocks.title">Bausteine &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/submission-building-blocks" />
<BottomNav currentPath="/admin/submission-building-blocks" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div>
<h1 data-i18n="admin.building_blocks.heading">Bausteine</h1>
<p className="tool-subtitle" data-i18n="admin.building_blocks.subtitle">
Wiederverwendbare Textbausteine f&uuml;r Composer-Abschnitte.
</p>
</div>
<div className="tool-header-actions">
<button
type="button"
id="admin-bb-new-btn"
className="btn-primary btn-cta-lime"
data-i18n="admin.building_blocks.action.new">
+ Neuer Baustein
</button>
</div>
</div>
<div id="admin-bb-feedback" className="form-msg" style="display:none" />
<div className="admin-bb-layout">
<aside className="admin-bb-list" id="admin-bb-list">
<div className="admin-bb-loading" data-i18n="admin.building_blocks.loading">L&auml;dt&hellip;</div>
</aside>
<section className="admin-bb-editor" id="admin-bb-editor">
<p className="admin-bb-empty" data-i18n="admin.building_blocks.editor.empty">
W&auml;hlen Sie einen Baustein aus der Liste &mdash; oder erstellen Sie einen neuen.
</p>
</section>
<aside className="admin-bb-versions" id="admin-bb-versions" />
</div>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/admin-submission-building-blocks.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,429 @@
import { initI18n, t, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
function isEN(): boolean { return getLang() === "en"; }
// /admin/submission-building-blocks — Composer building-blocks admin
// editor (t-paliad-315 Slice C). Three-pane layout: list → editor →
// version log. CRUD via /api/admin/submission-building-blocks/*.
//
// Per Q2 ratification (m, 2026-05-26): building blocks are plain text
// paste sources. The editor here is curator-only — no per-section
// lineage to surface, no "where is this block used" view.
interface BuildingBlockJSON {
id: string;
slug: string;
firm?: string | null;
section_key: string;
proceeding_family?: string | null;
title_de: string;
title_en: string;
description_de?: string | null;
description_en?: string | null;
content_md_de: string;
content_md_en: string;
author_id?: string | null;
visibility: string;
is_published: boolean;
created_at: string;
updated_at: string;
}
interface VersionJSON {
id: string;
building_block_id: string;
content_md_de: string;
content_md_en: string;
title_de: string;
title_en: string;
edited_by?: string | null;
note?: string | null;
created_at: string;
}
const VISIBILITIES = ["private", "team", "firm", "global"];
// Section keys must match what the Composer base spec declares for
// each section (see internal/db/migrations/146_submission_bases.up.sql).
const SECTION_KEYS = [
"letterhead", "caption", "introduction", "requests",
"facts", "legal_argument", "evidence", "exhibits",
"closing", "signature",
];
const state = {
blocks: [] as BuildingBlockJSON[],
selectedID: null as string | null,
versions: [] as VersionJSON[],
dirty: false,
};
async function boot(): Promise<void> {
initI18n();
initSidebar();
await loadList();
document.getElementById("admin-bb-new-btn")?.addEventListener("click", onNew);
}
async function loadList(): Promise<void> {
try {
const res = await fetch("/api/admin/submission-building-blocks", { credentials: "include" });
if (!res.ok) {
feedback(`HTTP ${res.status}`, true);
return;
}
const body = await res.json() as { blocks?: BuildingBlockJSON[] };
state.blocks = body.blocks ?? [];
paintList();
} catch (err) {
feedback(String(err), true);
}
}
function paintList(): void {
const host = document.getElementById("admin-bb-list");
if (!host) return;
host.innerHTML = "";
if (state.blocks.length === 0) {
const empty = document.createElement("p");
empty.className = "admin-bb-empty";
empty.textContent = isEN() ? "No blocks yet." : "Noch keine Bausteine.";
host.appendChild(empty);
return;
}
for (const b of state.blocks) {
const row = document.createElement("button");
row.type = "button";
row.className = "admin-bb-list-row";
if (b.id === state.selectedID) row.classList.add("admin-bb-list-row--active");
const title = isEN() ? b.title_en : b.title_de;
row.innerHTML = `
<span class="admin-bb-list-title">${escapeHTML(title || b.slug)}</span>
<span class="admin-bb-list-meta">
<span class="admin-bb-list-section">${escapeHTML(b.section_key)}</span>
<span class="admin-bb-list-vis admin-bb-list-vis--${escapeHTML(b.visibility)}">${escapeHTML(b.visibility)}</span>
${b.is_published ? "" : `<span class="admin-bb-list-draft">${isEN() ? "draft" : "Entwurf"}</span>`}
</span>`;
row.addEventListener("click", () => onSelect(b.id));
host.appendChild(row);
}
}
async function onSelect(id: string): Promise<void> {
state.selectedID = id;
state.dirty = false;
paintList();
const b = state.blocks.find(x => x.id === id);
if (!b) return;
paintEditor(b);
await loadVersions(id);
}
function onNew(): void {
state.selectedID = null;
state.versions = [];
state.dirty = false;
paintList();
paintEditor(null);
paintVersions();
}
function paintEditor(b: BuildingBlockJSON | null): void {
const host = document.getElementById("admin-bb-editor");
if (!host) return;
const isNew = b === null;
const data = b ?? {
id: "",
slug: "",
firm: "",
section_key: "requests",
proceeding_family: "",
title_de: "",
title_en: "",
description_de: "",
description_en: "",
content_md_de: "",
content_md_en: "",
visibility: "firm",
is_published: false,
} as Partial<BuildingBlockJSON>;
host.innerHTML = "";
const form = document.createElement("form");
form.className = "admin-bb-form";
form.addEventListener("submit", (e) => { e.preventDefault(); onSave(isNew); });
form.appendChild(textField("slug", isEN() ? "Slug" : "Slug", data.slug ?? "", true));
form.appendChild(textField("firm", "Firm", data.firm ?? "", false, isEN() ? "leer = firmenagnostisch" : "leer = firmenagnostisch"));
form.appendChild(selectField("section_key", isEN() ? "Section key" : "Abschnitts-Slug", data.section_key ?? "requests", SECTION_KEYS, false));
form.appendChild(textField("proceeding_family", isEN() ? "Proceeding family" : "Verfahrensfamilie", data.proceeding_family ?? "", false, "z. B. de.inf.lg"));
form.appendChild(textField("title_de", "Titel (DE)", data.title_de ?? "", true));
form.appendChild(textField("title_en", "Title (EN)", data.title_en ?? "", true));
form.appendChild(textareaField("description_de", "Beschreibung (DE)", data.description_de ?? "", 2));
form.appendChild(textareaField("description_en", "Description (EN)", data.description_en ?? "", 2));
form.appendChild(textareaField("content_md_de", isEN() ? "Content (DE Markdown)" : "Inhalt (DE Markdown)", data.content_md_de ?? "", 10));
form.appendChild(textareaField("content_md_en", isEN() ? "Content (EN Markdown)" : "Inhalt (EN Markdown)", data.content_md_en ?? "", 10));
form.appendChild(selectField("visibility", isEN() ? "Visibility" : "Sichtbarkeit", data.visibility ?? "firm", VISIBILITIES, false));
form.appendChild(checkboxField("is_published", isEN() ? "Published" : "Veröffentlicht", Boolean(data.is_published)));
if (!isNew) {
form.appendChild(textField("note", isEN() ? "Save note (optional)" : "Speicher-Notiz (optional)", "", false));
}
const actions = document.createElement("div");
actions.className = "admin-bb-form-actions";
const save = document.createElement("button");
save.type = "submit";
save.className = "btn-primary btn-cta-lime";
save.textContent = isEN() ? "Save" : "Speichern";
actions.appendChild(save);
if (!isNew) {
const del = document.createElement("button");
del.type = "button";
del.className = "btn-link-danger";
del.textContent = isEN() ? "Delete" : "Löschen";
del.addEventListener("click", () => onDelete());
actions.appendChild(del);
}
form.appendChild(actions);
host.appendChild(form);
}
function textField(name: string, label: string, value: string, required: boolean, hint?: string): HTMLElement {
const wrap = document.createElement("label");
wrap.className = "admin-bb-form-row";
const lab = document.createElement("span");
lab.textContent = label + (required ? " *" : "");
wrap.appendChild(lab);
const input = document.createElement("input");
input.type = "text";
input.name = name;
input.className = "entity-form-input";
input.value = value;
if (required) input.required = true;
wrap.appendChild(input);
if (hint) {
const h = document.createElement("small");
h.className = "admin-bb-form-hint";
h.textContent = hint;
wrap.appendChild(h);
}
return wrap;
}
function textareaField(name: string, label: string, value: string, rows: number): HTMLElement {
const wrap = document.createElement("label");
wrap.className = "admin-bb-form-row";
const lab = document.createElement("span");
lab.textContent = label;
wrap.appendChild(lab);
const ta = document.createElement("textarea");
ta.name = name;
ta.className = "entity-form-input";
ta.rows = rows;
ta.value = value;
wrap.appendChild(ta);
return wrap;
}
function selectField(name: string, label: string, value: string, options: string[], required: boolean): HTMLElement {
const wrap = document.createElement("label");
wrap.className = "admin-bb-form-row";
const lab = document.createElement("span");
lab.textContent = label + (required ? " *" : "");
wrap.appendChild(lab);
const sel = document.createElement("select");
sel.name = name;
sel.className = "entity-form-input";
for (const opt of options) {
const o = document.createElement("option");
o.value = opt;
o.textContent = opt;
if (opt === value) o.selected = true;
sel.appendChild(o);
}
wrap.appendChild(sel);
return wrap;
}
function checkboxField(name: string, label: string, value: boolean): HTMLElement {
const wrap = document.createElement("label");
wrap.className = "admin-bb-form-row admin-bb-form-row--checkbox";
const input = document.createElement("input");
input.type = "checkbox";
input.name = name;
input.checked = value;
wrap.appendChild(input);
const lab = document.createElement("span");
lab.textContent = label;
wrap.appendChild(lab);
return wrap;
}
async function onSave(isNew: boolean): Promise<void> {
const form = document.querySelector(".admin-bb-form") as HTMLFormElement | null;
if (!form) return;
const data = new FormData(form);
const payload: Record<string, unknown> = {};
for (const key of ["slug", "section_key", "title_de", "title_en", "content_md_de", "content_md_en", "visibility"]) {
const v = data.get(key);
if (v !== null) payload[key] = String(v);
}
for (const key of ["firm", "proceeding_family", "description_de", "description_en"]) {
const v = data.get(key);
if (v !== null) {
const s = String(v).trim();
payload[key] = s === "" ? null : s;
}
}
payload.is_published = (data.get("is_published") === "on");
if (!isNew) {
const note = data.get("note");
if (note) payload.note = String(note);
}
try {
const url = isNew
? "/api/admin/submission-building-blocks"
: `/api/admin/submission-building-blocks/${state.selectedID}`;
const method = isNew ? "POST" : "PATCH";
const res = await fetch(url, {
method,
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const body = await res.json().catch(() => ({} as { error?: string }));
feedback(body.error ?? `HTTP ${res.status}`, true);
return;
}
const saved = await res.json() as BuildingBlockJSON;
feedback(isEN() ? "Saved." : "Gespeichert.", false);
await loadList();
state.selectedID = saved.id;
paintList();
paintEditor(saved);
await loadVersions(saved.id);
} catch (err) {
feedback(String(err), true);
}
}
async function onDelete(): Promise<void> {
if (!state.selectedID) return;
const sure = confirm(isEN() ? "Delete this block?" : "Diesen Baustein löschen?");
if (!sure) return;
try {
const res = await fetch(`/api/admin/submission-building-blocks/${state.selectedID}`, {
method: "DELETE",
credentials: "include",
});
if (!res.ok && res.status !== 204) {
feedback(`HTTP ${res.status}`, true);
return;
}
feedback(isEN() ? "Deleted." : "Gelöscht.", false);
state.selectedID = null;
await loadList();
paintEditor(null);
state.versions = [];
paintVersions();
} catch (err) {
feedback(String(err), true);
}
}
async function loadVersions(blockID: string): Promise<void> {
try {
const res = await fetch(`/api/admin/submission-building-blocks/${blockID}/versions`, { credentials: "include" });
if (!res.ok) {
state.versions = [];
paintVersions();
return;
}
const body = await res.json() as { versions?: VersionJSON[] };
state.versions = body.versions ?? [];
paintVersions();
} catch {
state.versions = [];
paintVersions();
}
}
function paintVersions(): void {
const host = document.getElementById("admin-bb-versions");
if (!host) return;
host.innerHTML = "";
if (state.versions.length === 0) return;
const h = document.createElement("h3");
h.textContent = isEN() ? "History" : "Verlauf";
host.appendChild(h);
for (const v of state.versions) {
const row = document.createElement("div");
row.className = "admin-bb-version-row";
const date = new Date(v.created_at).toLocaleString();
row.innerHTML = `
<div class="admin-bb-version-meta">${escapeHTML(date)}${escapeHTML(v.note ?? "")}</div>`;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn-small btn-secondary";
btn.textContent = isEN() ? "Restore" : "Wiederherstellen";
btn.addEventListener("click", () => onRestore(v.id));
row.appendChild(btn);
host.appendChild(row);
}
}
async function onRestore(versionID: string): Promise<void> {
if (!state.selectedID) return;
try {
const res = await fetch(
`/api/admin/submission-building-blocks/${state.selectedID}/restore/${versionID}`,
{ method: "POST", credentials: "include" },
);
if (!res.ok) {
feedback(`HTTP ${res.status}`, true);
return;
}
const restored = await res.json() as BuildingBlockJSON;
feedback(isEN() ? "Restored." : "Wiederhergestellt.", false);
paintEditor(restored);
await loadVersions(restored.id);
await loadList();
} catch (err) {
feedback(String(err), true);
}
}
function feedback(msg: string, isError: boolean): void {
const host = document.getElementById("admin-bb-feedback");
if (!host) return;
host.style.display = "";
host.className = "form-msg " + (isError ? "form-msg--error" : "form-msg--ok");
host.textContent = msg;
if (!isError) {
setTimeout(() => { host.style.display = "none"; }, 3000);
}
}
function escapeHTML(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// Silence unused-import warning when t() isn't called directly — i18n
// is initialised so data-i18n attrs render on first paint.
void t;
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
void boot();
}

View File

@@ -79,7 +79,7 @@ const translations: Record<Lang, Record<string, string>> = {
"changelog.tag.fix": "Fix",
// Footer
"footer.text": "\u00a9 2026 Paliad \u2014 ein Werkzeug von",
"footer.text": "\u00a9 2026 Paliad \u2014 by",
// Landing page
"index.title": `Paliad \u2014 Patent Litigation f\u00fcr ${FIRM}`,
@@ -1520,6 +1520,18 @@ const translations: Record<Lang, Record<string, string>> = {
"submissions.draft.language.de": "DE",
"submissions.draft.language.en": "EN",
"submissions.draft.language.fallback_notice": "Fallback: universelles Skelett (keine sprachspezifische Vorlage).",
// t-paliad-313 (m/paliad#141) Composer Slice A — base picker + section list.
"submissions.draft.base.label": "Vorlagenbasis",
"submissions.draft.base.hint": "Steuert Schriftarten, Briefkopf und Abschnitts-Defaults.",
"submissions.draft.sections.title": "Abschnitte",
"submissions.draft.sections.hint": "Inhalt pro Abschnitt — Autosave nach 500 ms. Letztes Layout in Word.",
// t-paliad-315 (m/paliad#141) Composer Slice C — building blocks admin.
"admin.building_blocks.title": "Bausteine — Paliad",
"admin.building_blocks.heading": "Bausteine",
"admin.building_blocks.subtitle": "Wiederverwendbare Textbausteine für Composer-Abschnitte.",
"admin.building_blocks.loading": "Lädt…",
"admin.building_blocks.action.new": "+ Neuer Baustein",
"admin.building_blocks.editor.empty": "Wählen Sie einen Baustein aus der Liste — oder erstellen Sie einen neuen.",
// t-paliad-240 — global Schriftsätze drafts index page.
"submissions.index.title": "Schriftsätze — Paliad",
"submissions.index.heading": "Schriftsätze",
@@ -3174,7 +3186,7 @@ const translations: Record<Lang, Record<string, string>> = {
"changelog.tag.fix": "Fix",
// Footer
"footer.text": "\u00a9 2026 Paliad \u2014 a tool by",
"footer.text": "\u00a9 2026 Paliad \u2014 by",
// Landing page
"index.title": `Paliad \u2014 Patent Litigation for ${FIRM}`,
@@ -4596,6 +4608,18 @@ const translations: Record<Lang, Record<string, string>> = {
"submissions.draft.import.button": "Import from project",
"submissions.draft.parties.title": "Parties",
"submissions.draft.parties.hint": "Pick the parties mentioned in this submission, or add more per side.",
// t-paliad-313 (m/paliad#141) Composer Slice A — base picker + section list.
"submissions.draft.base.label": "Template base",
"submissions.draft.base.hint": "Drives fonts, letterhead, and section defaults.",
"submissions.draft.sections.title": "Sections",
"submissions.draft.sections.hint": "Edit per section — autosaves after 500ms. Final layout in Word.",
// t-paliad-315 (m/paliad#141) Composer Slice C — building blocks admin.
"admin.building_blocks.title": "Building blocks — Paliad",
"admin.building_blocks.heading": "Building blocks",
"admin.building_blocks.subtitle": "Reusable text snippets for Composer sections.",
"admin.building_blocks.loading": "Loading…",
"admin.building_blocks.action.new": "+ New block",
"admin.building_blocks.editor.empty": "Pick a block from the list — or create a new one.",
// t-paliad-240 — global submissions drafts index page.
"submissions.index.title": "Submissions — Paliad",
"submissions.index.heading": "Submissions",

View File

@@ -28,10 +28,47 @@ interface SubmissionDraftJSON {
last_exported_at?: string | null;
last_exported_sha?: string | null;
last_imported_at?: string | null;
// t-paliad-313 Composer Slice A — base reference + Composer-side
// metadata. base_id is null on pre-Composer drafts (the v1 render
// path stays the fallback). composer_meta carries the seed-time
// section order in later slices.
base_id?: string | null;
composer_meta?: Record<string, unknown>;
created_at: string;
updated_at: string;
}
// t-paliad-313 Composer Slice A — per-draft section row, surfaced
// read-only in the editor body. Slice B adds inline edit + PATCH.
interface SubmissionSectionJSON {
id: string;
section_key: string;
order_index: number;
kind: string;
label_de: string;
label_en: string;
included: boolean;
content_md_de: string;
content_md_en: string;
}
// t-paliad-313 Composer Slice A — base catalog row, surfaced in the
// sidebar picker dropdown.
interface SubmissionBaseRow {
id: string;
slug: string;
firm?: string | null;
proceeding_family?: string | null;
label_de: string;
label_en: string;
description_de?: string | null;
description_en?: string | null;
gitea_path: string;
is_default_for: string[];
is_active: boolean;
section_count: number;
}
interface AvailablePartyJSON {
id: string;
name: string;
@@ -64,6 +101,9 @@ interface SubmissionDraftView {
// language has no per-firm language-matched template.
template_tier?: string;
language_fallback?: boolean;
// t-paliad-313 Composer Slice A — per-draft section stack. Empty
// for pre-Composer drafts where no rows have been seeded.
sections: SubmissionSectionJSON[];
}
interface SubmissionDraftListResponse {
@@ -328,6 +368,11 @@ interface State {
addPartyMode: "manual" | "search";
addPartySearchHits: PartySearchHit[];
addPartyBusy: boolean;
// t-paliad-313 Composer Slice A — base catalog fetched once on boot.
// Picker hidden until populated; empty array (after the fetch
// completes) keeps the picker hidden permanently for this load.
bases: SubmissionBaseRow[];
basesLoaded: boolean;
}
type PartySide = "claimant" | "defendant" | "other";
@@ -354,6 +399,8 @@ const state: State = {
addPartyMode: "manual",
addPartySearchHits: [],
addPartyBusy: false,
bases: [],
basesLoaded: false,
};
// ─────────────────────────────────────────────────────────────────────
@@ -371,6 +418,14 @@ async function boot(): Promise<void> {
}
state.parsed = parsed;
// t-paliad-313 Composer Slice A — kick the base catalog fetch in
// parallel with the view load. The picker hydrates when both land;
// either failing leaves the picker hidden but the editor functional.
loadBases().catch(err => {
console.warn("submission-draft: base catalog fetch failed", err);
state.basesLoaded = true;
});
try {
if (parsed.mode === "global") {
// Global path: we have a draft_id, fetch by id alone. Drafts
@@ -523,11 +578,13 @@ function paint(): void {
paintNoProjectBanner();
paintSwitcher();
paintNameRow();
paintBasePicker();
paintImportRow();
paintPartyPicker();
paintLanguageRow();
paintLanguageFallback();
paintVariables();
paintSectionList();
paintPreview();
}
@@ -1143,6 +1200,520 @@ function paintPreview(): void {
}
}
// ─────────────────────────────────────────────────────────────────────
// t-paliad-313 Composer Slice A — base picker + section list
// ─────────────────────────────────────────────────────────────────────
async function loadBases(): Promise<void> {
const res = await fetch("/api/submission-bases", { credentials: "include" });
if (!res.ok) {
throw new Error("base list HTTP " + res.status);
}
const body = await res.json() as { bases?: SubmissionBaseRow[] };
state.bases = body.bases ?? [];
state.basesLoaded = true;
// If the view has already painted, re-paint the picker so it
// hydrates as soon as the catalog lands. paint() is idempotent.
if (state.view) paintBasePicker();
}
function paintBasePicker(): void {
const row = document.getElementById("submission-draft-base-row") as HTMLDivElement | null;
const sel = document.getElementById("submission-draft-base") as HTMLSelectElement | null;
if (!row || !sel || !state.view) return;
// Hide the picker until the catalog has loaded AND the catalog has
// at least one entry. A failed fetch (basesLoaded=true, bases empty)
// keeps the picker hidden indefinitely so the editor stays usable.
if (!state.basesLoaded || state.bases.length === 0) {
row.style.display = "none";
return;
}
row.style.display = "";
// Rebuild the <option> list each paint so language toggles + base
// catalog updates flow through.
sel.innerHTML = "";
const currentBaseID = state.view.draft.base_id ?? "";
// "Keine Vorlagenbasis" only listed when the draft is currently in
// that state (pre-Composer / cleared). Avoids tempting the lawyer
// to clear after they've already picked one.
if (!currentBaseID) {
const opt = document.createElement("option");
opt.value = "";
opt.textContent = isEN() ? "— no base —" : "— keine Vorlagenbasis —";
sel.appendChild(opt);
}
for (const b of state.bases) {
const opt = document.createElement("option");
opt.value = b.id;
opt.textContent = isEN() ? b.label_en : b.label_de;
if (b.id === currentBaseID) opt.selected = true;
sel.appendChild(opt);
}
// Wire change handler once per paint. Removing then re-adding
// keeps the binding consistent across repaints (e.g. after
// language toggle re-renders the labels).
sel.onchange = () => { onBaseChange(sel.value); };
}
async function onBaseChange(newBaseID: string): Promise<void> {
if (!state.view) return;
const payload: Record<string, unknown> = {
// Empty string in the picker maps to null = clear.
base_id: newBaseID === "" ? null : newBaseID,
};
try {
const res = await fetch(
`/api/submission-drafts/${state.view.draft.id}`,
{
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
if (!res.ok) {
console.warn("base swap PATCH failed", res.status);
return;
}
const view = await res.json() as SubmissionDraftView;
state.view = view;
paint();
} catch (err) {
console.warn("base swap PATCH error", err);
}
}
// sectionAutosaveTimers — one debounce timer per section id so two
// sections autosaving simultaneously don't trample each other. Reset
// on each keystroke; 500ms after the last keystroke the patch fires.
const sectionAutosaveTimers: Record<string, number> = {};
const SECTION_AUTOSAVE_MS = 500;
function paintSectionList(): void {
const wrap = document.getElementById("submission-draft-sections-wrap");
const list = document.getElementById("submission-draft-sections-list") as HTMLOListElement | null;
if (!wrap || !list || !state.view) return;
const sections = state.view.sections ?? [];
if (sections.length === 0) {
wrap.style.display = "none";
return;
}
wrap.style.display = "";
// Don't blow away the editor if a section is currently focused —
// would steal cursor + selection mid-type. The patch round-trip
// returns the updated row, but paintSectionList only re-renders
// when the focused section isn't being edited (or the new render
// is being driven by something other than the active editor itself).
const activeID = activeSectionEditorID();
list.innerHTML = "";
const lang = state.view.draft.language || state.view.lang || "de";
for (const sec of sections) {
list.appendChild(renderSectionRow(sec, lang, activeID === sec.id));
}
}
function renderSectionRow(sec: SubmissionSectionJSON, lang: string, isActive: boolean): HTMLLIElement {
const li = document.createElement("li");
li.className = "submission-draft-section";
li.dataset.sectionId = sec.id;
if (!sec.included) li.classList.add("submission-draft-section--excluded");
const head = document.createElement("header");
head.className = "submission-draft-section-head";
const title = document.createElement("h3");
title.className = "submission-draft-section-title";
title.textContent = (lang === "en" ? sec.label_en : sec.label_de) || sec.section_key;
head.appendChild(title);
const kind = document.createElement("span");
kind.className = "submission-draft-section-kind";
kind.textContent = sec.kind;
head.appendChild(kind);
if (!sec.included) {
const muted = document.createElement("span");
muted.className = "submission-draft-section-excluded-badge";
muted.textContent = isEN() ? "excluded" : "ausgeblendet";
head.appendChild(muted);
}
// Per-section "Aufnehmen" / "Ausblenden" toggle in the head — flips
// `included` via PATCH and re-paints.
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "btn-small btn-secondary submission-draft-section-toggle";
toggle.textContent = sec.included
? (isEN() ? "Hide" : "Ausblenden")
: (isEN() ? "Include" : "Aufnehmen");
toggle.addEventListener("click", () => onSectionToggleIncluded(sec));
head.appendChild(toggle);
li.appendChild(head);
// Toolbar — shared B/I affordance per section. Slice D extends with
// headings, lists, quote.
const toolbar = document.createElement("div");
toolbar.className = "submission-draft-section-toolbar";
toolbar.appendChild(makeToolbarButton("B", isEN() ? "Bold" : "Fett", "bold"));
toolbar.appendChild(makeToolbarButton("I", isEN() ? "Italic" : "Kursiv", "italic"));
// t-paliad-315 Slice C — building-block insert button. Opens a
// picker modal filtered to this section's section_key. Paste is
// plain-text per Q2 (no lineage stamped).
const bbBtn = document.createElement("button");
bbBtn.type = "button";
bbBtn.className = "btn-small btn-secondary submission-draft-section-bb-btn";
bbBtn.textContent = isEN() ? "+ Block" : "+ Baustein";
bbBtn.title = isEN() ? "Insert a saved building block" : "Baustein einfügen";
bbBtn.addEventListener("click", () => openBlockPicker(sec));
toolbar.appendChild(bbBtn);
li.appendChild(toolbar);
const md = (lang === "en" ? sec.content_md_en : sec.content_md_de) || "";
const editor = document.createElement("div");
editor.className = "submission-draft-section-editor";
editor.contentEditable = "true";
editor.spellcheck = true;
editor.dataset.sectionId = sec.id;
editor.dataset.lang = lang;
editor.dataset.placeholder = isEN()
? "Write section content…"
: "Abschnittstext eingeben…";
// Paint the Markdown as plain text on first render — the editor's
// source of truth is Markdown, the DOM is the view. Lawyer types,
// we serialise back to MD on autosave.
editor.textContent = md;
editor.addEventListener("input", () => onSectionInput(editor));
editor.addEventListener("focus", () => {
li.classList.add("submission-draft-section--editing");
});
editor.addEventListener("blur", () => {
li.classList.remove("submission-draft-section--editing");
// Force-flush any pending autosave so we don't leave unsynced
// edits hanging when the lawyer tabs out.
flushSectionAutosave(sec.id);
});
li.appendChild(editor);
if (isActive) {
// The repaint happened while this section was focused — restore
// focus to it. Cursor placement at the end is a fair default
// (typing mid-content during a repaint is rare; the autosave path
// typically doesn't repaint at all).
queueMicrotask(() => {
const fresh = document.querySelector(`.submission-draft-section-editor[data-section-id="${cssEscape(sec.id)}"]`) as HTMLDivElement | null;
if (fresh) {
fresh.focus();
placeCaretAtEnd(fresh);
}
});
}
return li;
}
function makeToolbarButton(label: string, title: string, format: "bold" | "italic"): HTMLButtonElement {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "submission-draft-section-toolbar-btn";
btn.textContent = label;
btn.title = title;
// Mousedown rather than click so the editor doesn't lose focus
// mid-command — execCommand requires the editor to be the active
// selection target.
btn.addEventListener("mousedown", (ev) => {
ev.preventDefault();
document.execCommand(format, false);
// Trigger the input handler so autosave fires.
const editor = document.activeElement as HTMLElement | null;
if (editor && editor.classList.contains("submission-draft-section-editor")) {
onSectionInput(editor as HTMLDivElement);
}
});
return btn;
}
function activeSectionEditorID(): string | null {
const active = document.activeElement as HTMLElement | null;
if (!active || !active.classList.contains("submission-draft-section-editor")) return null;
return active.dataset.sectionId ?? null;
}
function placeCaretAtEnd(el: HTMLElement): void {
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
const sel = window.getSelection();
if (!sel) return;
sel.removeAllRanges();
sel.addRange(range);
}
function onSectionInput(editor: HTMLDivElement): void {
const id = editor.dataset.sectionId;
if (!id) return;
if (sectionAutosaveTimers[id]) clearTimeout(sectionAutosaveTimers[id]);
sectionAutosaveTimers[id] = window.setTimeout(() => {
sectionAutosaveTimers[id] = 0;
flushSectionAutosave(id);
}, SECTION_AUTOSAVE_MS);
}
function flushSectionAutosave(sectionID: string): void {
if (sectionAutosaveTimers[sectionID]) {
clearTimeout(sectionAutosaveTimers[sectionID]);
sectionAutosaveTimers[sectionID] = 0;
}
const editor = document.querySelector(`.submission-draft-section-editor[data-section-id="${cssEscape(sectionID)}"]`) as HTMLDivElement | null;
if (!editor || !state.view) return;
const lang = editor.dataset.lang || state.view.draft.language || "de";
const md = domToMarkdown(editor);
void patchSection(sectionID, lang === "en" ? { content_md_en: md } : { content_md_de: md });
}
// domToMarkdown serialises a contentEditable's DOM tree back to
// Markdown. Walks the tree: <b>/<strong> emit `**…**`, <i>/<em> emit
// `*…*`, <br> emits a newline, block-level elements emit a blank line
// between siblings. Slice B handles only B/I + paragraphs/line breaks
// — Slice D's rich toolbar extends this to headings + lists + quote.
function domToMarkdown(root: HTMLElement): string {
return serializeNode(root).trim();
}
function serializeNode(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent ?? "";
}
if (node.nodeType !== Node.ELEMENT_NODE) return "";
const el = node as HTMLElement;
const tag = el.tagName.toLowerCase();
let inner = "";
for (const child of Array.from(el.childNodes)) {
inner += serializeNode(child);
}
switch (tag) {
case "b":
case "strong":
return inner ? `**${inner}**` : "";
case "i":
case "em":
return inner ? `*${inner}*` : "";
case "br":
return "\n";
case "div":
case "p":
// execCommand and contentEditable insert <div> on Enter in some
// browsers, <p> in others. Both are paragraph boundaries.
return inner + "\n\n";
default:
return inner;
}
}
async function onSectionToggleIncluded(sec: SubmissionSectionJSON): Promise<void> {
await patchSection(sec.id, { included: !sec.included });
}
// ─────────────────────────────────────────────────────────────────────
// t-paliad-315 Slice C — building-block picker modal
// ─────────────────────────────────────────────────────────────────────
interface BuildingBlockPickJSON {
id: string;
slug: string;
section_key: string;
proceeding_family?: string | null;
title_de: string;
title_en: string;
description_de?: string | null;
description_en?: string | null;
content_md_de: string;
content_md_en: string;
visibility: string;
}
let blockPickerSearchTimer: number | null = null;
function openBlockPicker(sec: SubmissionSectionJSON): void {
// Remove any prior picker.
document.getElementById("submission-bb-picker")?.remove();
const overlay = document.createElement("div");
overlay.id = "submission-bb-picker";
overlay.className = "submission-bb-picker-overlay";
overlay.addEventListener("click", (ev) => {
if (ev.target === overlay) overlay.remove();
});
const modal = document.createElement("div");
modal.className = "submission-bb-picker";
const head = document.createElement("header");
head.className = "submission-bb-picker-head";
const title = document.createElement("h2");
title.textContent = isEN() ? "Insert building block" : "Baustein einfügen";
head.appendChild(title);
const close = document.createElement("button");
close.type = "button";
close.className = "btn-small btn-secondary";
close.textContent = isEN() ? "Close" : "Schließen";
close.addEventListener("click", () => overlay.remove());
head.appendChild(close);
modal.appendChild(head);
const search = document.createElement("input");
search.type = "search";
search.placeholder = isEN() ? "Search blocks…" : "Bausteine suchen…";
search.className = "entity-form-input submission-bb-picker-search";
modal.appendChild(search);
const sectionInfo = document.createElement("p");
sectionInfo.className = "submission-bb-picker-sectioninfo";
sectionInfo.textContent = (isEN() ? "Section: " : "Abschnitt: ") + sec.section_key;
modal.appendChild(sectionInfo);
const list = document.createElement("div");
list.className = "submission-bb-picker-list";
list.textContent = isEN() ? "Loading…" : "Lädt…";
modal.appendChild(list);
overlay.appendChild(modal);
document.body.appendChild(overlay);
const fetchBlocks = async (q: string) => {
const params = new URLSearchParams();
params.set("section_key", sec.section_key);
if (q) params.set("q", q);
try {
const res = await fetch(`/api/submission-building-blocks?${params.toString()}`, { credentials: "include" });
if (!res.ok) {
list.textContent = `HTTP ${res.status}`;
return;
}
const body = await res.json() as { blocks?: BuildingBlockPickJSON[] };
paintPickerList(list, body.blocks ?? [], sec, overlay);
} catch (err) {
list.textContent = String(err);
}
};
search.addEventListener("input", () => {
if (blockPickerSearchTimer) clearTimeout(blockPickerSearchTimer);
blockPickerSearchTimer = window.setTimeout(() => {
void fetchBlocks(search.value.trim());
}, 200);
});
void fetchBlocks("");
setTimeout(() => search.focus(), 0);
}
function paintPickerList(host: HTMLElement, blocks: BuildingBlockPickJSON[], sec: SubmissionSectionJSON, overlay: HTMLElement): void {
host.innerHTML = "";
if (blocks.length === 0) {
const empty = document.createElement("p");
empty.className = "submission-bb-picker-empty";
empty.textContent = isEN() ? "No blocks match." : "Keine passenden Bausteine.";
host.appendChild(empty);
return;
}
const lang = state.view?.draft.language || "de";
for (const b of blocks) {
const row = document.createElement("button");
row.type = "button";
row.className = "submission-bb-picker-row";
const title = (lang === "en" ? b.title_en : b.title_de) || b.slug;
const desc = (lang === "en" ? b.description_en : b.description_de) || "";
const preview = ((lang === "en" ? b.content_md_en : b.content_md_de) || "").slice(0, 200);
row.innerHTML = `
<div class="submission-bb-picker-row-head">
<strong>${escapeHTML(title)}</strong>
<span class="submission-bb-picker-vis submission-bb-picker-vis--${escapeHTML(b.visibility)}">${escapeHTML(b.visibility)}</span>
</div>
${desc ? `<div class="submission-bb-picker-row-desc">${escapeHTML(desc)}</div>` : ""}
<pre class="submission-bb-picker-row-preview">${escapeHTML(preview)}${preview.length === 200 ? "…" : ""}</pre>`;
row.addEventListener("click", () => {
void insertBlockIntoSection(b.id, sec.id, overlay);
});
host.appendChild(row);
}
}
async function insertBlockIntoSection(blockID: string, sectionID: string, overlay: HTMLElement): Promise<void> {
try {
const res = await fetch(
`/api/submission-building-blocks/${blockID}/insert-into/${sectionID}`,
{ method: "POST", credentials: "include" },
);
if (!res.ok) {
console.warn("insert-into PATCH failed", res.status);
return;
}
const updated = await res.json() as SubmissionSectionJSON;
if (state.view && state.view.sections) {
const idx = state.view.sections.findIndex(s => s.id === sectionID);
if (idx >= 0) state.view.sections[idx] = updated;
}
paintSectionList();
overlay.remove();
} catch (err) {
console.warn("insert block error", err);
}
}
function escapeHTML(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
async function patchSection(sectionID: string, payload: Record<string, unknown>): Promise<void> {
try {
const draftID = state.view?.draft.id;
if (!draftID) return;
const res = await fetch(
`/api/submission-drafts/${draftID}/sections/${sectionID}`,
{
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
if (!res.ok) {
console.warn("section PATCH failed", res.status, sectionID);
return;
}
const updated = await res.json() as SubmissionSectionJSON;
// Splice the updated row into state.view.sections. Don't re-paint
// unless we need to (avoid focus stealing during active typing).
if (state.view && state.view.sections) {
const idx = state.view.sections.findIndex(s => s.id === sectionID);
if (idx >= 0) state.view.sections[idx] = updated;
}
// Only repaint when the change has visible UI knock-on (toggle,
// label, order). content_md_* changes don't need a repaint —
// the editor already shows the lawyer's keystrokes.
if ("included" in payload || "label_de" in payload || "label_en" in payload || "order_index" in payload) {
paintSectionList();
}
} catch (err) {
console.warn("section PATCH error", err);
}
}
// t-paliad-261 (B) — click a substituted variable in the preview to
// jump to the matching sidebar input. Re-wires on every paintPreview
// since the preview HTML is replaced wholesale. The server side wraps

View File

@@ -5,7 +5,7 @@ export function Footer(): string {
<footer className="footer">
<div className="container">
<p>
<span data-i18n="footer.text">{"© 2026 Paliad — ein Werkzeug von"}</span>{" "}
<span data-i18n="footer.text">{"© 2026 Paliad — by"}</span>{" "}
<a href="https://flexsiebels.de" target="_blank" rel="noopener">flexsiebels.de</a>
</p>
</div>

View File

@@ -125,6 +125,12 @@ export type I18nKey =
| "admin.broadcasts.loading"
| "admin.broadcasts.subtitle"
| "admin.broadcasts.title"
| "admin.building_blocks.action.new"
| "admin.building_blocks.editor.empty"
| "admin.building_blocks.heading"
| "admin.building_blocks.loading"
| "admin.building_blocks.subtitle"
| "admin.building_blocks.title"
| "admin.card.approval_policies.desc"
| "admin.card.approval_policies.title"
| "admin.card.audit.desc"
@@ -2615,6 +2621,8 @@ export type I18nKey =
| "submissions.draft.action.export"
| "submissions.draft.action.new"
| "submissions.draft.back"
| "submissions.draft.base.hint"
| "submissions.draft.base.label"
| "submissions.draft.import.button"
| "submissions.draft.language"
| "submissions.draft.language.de"
@@ -2627,6 +2635,8 @@ export type I18nKey =
| "submissions.draft.parties.title"
| "submissions.draft.preview.hint"
| "submissions.draft.preview.title"
| "submissions.draft.sections.hint"
| "submissions.draft.sections.title"
| "submissions.draft.switcher.label"
| "submissions.draft.title"
| "submissions.index.action.new"

View File

@@ -6124,6 +6124,414 @@ dialog.modal::backdrop {
/* t-paliad-276 — DE/EN language toggle on the draft editor. Same look
as the rest of the sidebar mini-controls; muted label + inline radios
so it doesn't compete with the editor's primary inputs. */
/* t-paliad-313 (m/paliad#141) Composer Slice A — base picker + section list. */
.submission-draft-base-row {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin: 0.5rem 0;
}
.submission-draft-base-row label {
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.85em;
}
.submission-draft-base-row select {
padding: 0.4rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-bg-elev-1);
font-size: 0.95em;
}
.submission-draft-base-hint {
margin: 0;
font-size: 0.8em;
color: var(--color-text-muted);
}
.submission-draft-sections-wrap {
margin-top: 1rem;
padding: 1rem;
border: 1px dashed var(--color-border);
border-radius: 4px;
background: var(--color-bg-elev-1);
}
.submission-draft-sections-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.submission-draft-sections-header h2 {
margin: 0;
font-size: 1.05em;
}
.submission-draft-sections-hint {
font-size: 0.8em;
color: var(--color-text-muted);
}
.submission-draft-sections-list {
list-style: decimal inside;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.submission-draft-section {
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 0.6rem 0.8rem;
background: var(--color-bg-elev-2, var(--color-bg));
}
.submission-draft-section--excluded {
opacity: 0.55;
background: var(--color-bg-subtle, transparent);
}
.submission-draft-section-head {
display: flex;
align-items: baseline;
gap: 0.5rem;
flex-wrap: wrap;
}
.submission-draft-section-title {
display: inline;
margin: 0;
font-size: 0.95em;
font-weight: 600;
}
.submission-draft-section-kind {
font-size: 0.75em;
color: var(--color-text-muted);
background: var(--color-bg-subtle, transparent);
padding: 0.1rem 0.35rem;
border-radius: 3px;
}
.submission-draft-section-excluded-badge {
font-size: 0.75em;
color: var(--color-text-muted);
font-style: italic;
}
.submission-draft-section-body {
margin: 0.5rem 0 0 0;
padding: 0;
font-family: inherit;
font-size: 0.88em;
white-space: pre-wrap;
word-break: break-word;
color: var(--color-text);
}
/* t-paliad-313 Slice B — inline editor per section. */
.submission-draft-section-toggle {
margin-left: auto;
}
.submission-draft-section-toolbar {
display: flex;
gap: 0.25rem;
margin: 0.4rem 0 0.3rem 0;
}
.submission-draft-section-toolbar-btn {
width: 1.8rem;
height: 1.8rem;
border: 1px solid var(--color-border);
border-radius: 3px;
background: var(--color-bg-elev-1);
font-weight: 600;
font-size: 0.85em;
cursor: pointer;
line-height: 1;
}
.submission-draft-section-toolbar-btn:hover {
background: var(--color-bg-subtle, var(--color-bg-elev-2));
}
.submission-draft-section-editor {
min-height: 3rem;
padding: 0.5rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 3px;
background: var(--color-bg-elev-1);
font-family: inherit;
font-size: 0.92em;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
outline: none;
}
.submission-draft-section-editor:focus {
border-color: var(--color-accent-fg, var(--color-text));
box-shadow: 0 0 0 2px var(--color-bg-lime-tint, transparent);
}
.submission-draft-section-editor:empty::before {
content: attr(data-placeholder);
color: var(--color-text-muted);
pointer-events: none;
}
.submission-draft-section--editing {
background: var(--color-bg-elev-2, var(--color-bg-elev-1));
}
/* t-paliad-315 Slice C — building-block picker modal */
.submission-draft-section-bb-btn {
margin-left: auto;
}
.submission-bb-picker-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.submission-bb-picker {
background: var(--color-bg, white);
border-radius: 6px;
padding: 1rem;
width: min(720px, 92vw);
max-height: 86vh;
display: flex;
flex-direction: column;
gap: 0.6rem;
box-shadow: 0 8px 32px rgba(0,0,0,0.25);
}
.submission-bb-picker-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.submission-bb-picker-head h2 {
margin: 0;
font-size: 1.1em;
}
.submission-bb-picker-search {
width: 100%;
}
.submission-bb-picker-sectioninfo {
margin: 0;
font-size: 0.85em;
color: var(--color-text-muted);
}
.submission-bb-picker-list {
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.submission-bb-picker-row {
display: block;
width: 100%;
text-align: left;
padding: 0.6rem 0.8rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-bg-elev-1);
cursor: pointer;
}
.submission-bb-picker-row:hover {
background: var(--color-bg-lime-tint, var(--color-bg-elev-2));
}
.submission-bb-picker-row-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
}
.submission-bb-picker-row-desc {
margin: 0.25rem 0;
font-size: 0.85em;
color: var(--color-text-muted);
}
.submission-bb-picker-row-preview {
margin: 0.25rem 0 0 0;
padding: 0;
font-family: inherit;
font-size: 0.8em;
color: var(--color-text-muted);
white-space: pre-wrap;
max-height: 4em;
overflow: hidden;
}
.submission-bb-picker-vis {
font-size: 0.7em;
padding: 0.1rem 0.35rem;
border-radius: 3px;
background: var(--color-bg-subtle, transparent);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.submission-bb-picker-vis--private { background: #fde2e2; color: #8a2a2a; }
.submission-bb-picker-vis--team { background: #fff4d6; color: #7a5d12; }
.submission-bb-picker-vis--firm { background: #def5e2; color: #266e34; }
.submission-bb-picker-vis--global { background: #dce8fb; color: #1f437a; }
.submission-bb-picker-empty {
text-align: center;
color: var(--color-text-muted);
padding: 1rem;
}
/* t-paliad-315 Slice C — /admin/submission-building-blocks editor */
.admin-bb-layout {
display: grid;
grid-template-columns: minmax(220px, 280px) 1fr minmax(180px, 240px);
gap: 1rem;
}
.admin-bb-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 70vh;
overflow-y: auto;
}
.admin-bb-list-row {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.5rem 0.7rem;
text-align: left;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-bg-elev-1);
cursor: pointer;
}
.admin-bb-list-row--active {
background: var(--color-bg-lime-tint, var(--color-bg-elev-2));
border-color: var(--color-accent-fg, var(--color-text));
}
.admin-bb-list-title {
font-weight: 600;
font-size: 0.95em;
}
.admin-bb-list-meta {
display: flex;
gap: 0.3rem;
font-size: 0.7em;
color: var(--color-text-muted);
}
.admin-bb-list-section {
background: var(--color-bg-subtle, transparent);
padding: 0.05rem 0.35rem;
border-radius: 3px;
}
.admin-bb-list-vis {
padding: 0.05rem 0.35rem;
border-radius: 3px;
}
.admin-bb-list-vis--private { background: #fde2e2; color: #8a2a2a; }
.admin-bb-list-vis--team { background: #fff4d6; color: #7a5d12; }
.admin-bb-list-vis--firm { background: #def5e2; color: #266e34; }
.admin-bb-list-vis--global { background: #dce8fb; color: #1f437a; }
.admin-bb-list-draft {
font-style: italic;
color: var(--color-text-muted);
}
.admin-bb-form {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.admin-bb-form-row {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.admin-bb-form-row--checkbox {
flex-direction: row;
align-items: center;
gap: 0.4rem;
}
.admin-bb-form-row > span {
font-size: 0.85em;
color: var(--color-text-muted);
}
.admin-bb-form-hint {
font-size: 0.75em;
color: var(--color-text-muted);
}
.admin-bb-form-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.admin-bb-versions {
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 70vh;
overflow-y: auto;
}
.admin-bb-version-row {
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 0.4rem 0.55rem;
font-size: 0.78em;
}
.admin-bb-version-meta {
color: var(--color-text-muted);
margin-bottom: 0.25rem;
}
.admin-bb-empty {
color: var(--color-text-muted);
}
.submission-draft-language-row {
display: flex;
align-items: center;
@@ -6230,7 +6638,7 @@ dialog.modal::backdrop {
align-items: baseline;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface-alt, #fafafa);
background: var(--color-surface-2);
flex-wrap: wrap;
gap: 0.5rem;
}
@@ -6388,7 +6796,7 @@ dialog.modal::backdrop {
}
.submissions-new-chip:hover {
background: var(--color-surface-alt, #f4f4f4);
background: var(--color-surface-muted);
}
.submissions-new-chip--active {
@@ -6426,7 +6834,7 @@ dialog.modal::backdrop {
}
.submissions-new-project-item:hover {
background: var(--color-surface-alt, #f4f4f4);
background: var(--color-surface-muted);
}
.submissions-new-project-title {
@@ -6441,7 +6849,7 @@ dialog.modal::backdrop {
flex-wrap: wrap;
padding: 0.75rem 1rem;
margin: 0 0 1.25rem;
background: var(--color-surface-alt, #f7f7f0);
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-left: 4px solid var(--color-accent, #c6f41c);
border-radius: 6px;
@@ -6464,7 +6872,7 @@ dialog.modal::backdrop {
flex-wrap: wrap;
padding: 0.5rem 0.6rem;
margin-bottom: 0.75rem;
background: var(--color-surface-alt, #f7f7f0);
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: 6px;
}
@@ -6592,7 +7000,7 @@ dialog.modal::backdrop {
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.6rem;
background: var(--color-surface-alt, #f7f7f0);
background: var(--color-bg-subtle);
display: flex;
flex-direction: column;
gap: 0.5rem;
@@ -6715,7 +7123,7 @@ dialog.modal::backdrop {
margin-left: 0.3rem;
padding: 0 0.4em;
border-radius: 3px;
background: var(--color-surface-alt, #f7f7f0);
background: var(--color-bg-subtle);
color: var(--color-text-muted);
}
@@ -7922,7 +8330,7 @@ dialog.modal::backdrop {
.collab-invite-hint {
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--color-surface-alt, var(--color-bg-lime-tint));
background: var(--color-bg-lime-tint);
border: 1px dashed var(--color-border);
border-radius: var(--radius);
font-size: 0.85rem;
@@ -16582,7 +16990,7 @@ dialog.quick-add-sheet::backdrop {
width: 1.4rem;
height: 1.4rem;
border-radius: 50%;
background: var(--color-surface-alt, #f4f4f4);
background: var(--color-surface-muted);
color: var(--color-text-muted);
font-size: 0.85rem;
font-weight: 600;
@@ -16636,7 +17044,7 @@ dialog.quick-add-sheet::backdrop {
font-size: 0.72rem;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: var(--color-surface-alt, #f4f4f4);
background: var(--color-surface-muted);
color: var(--color-text-muted);
font-weight: 500;
letter-spacing: 0.02em;
@@ -16658,7 +17066,7 @@ dialog.quick-add-sheet::backdrop {
}
.smart-timeline-kind-chip--projected {
background: var(--color-surface-alt, #f4f4f4);
background: var(--color-surface-muted);
color: var(--color-text-muted);
font-style: italic;
}
@@ -16725,7 +17133,7 @@ dialog.quick-add-sheet::backdrop {
.smart-timeline-add-choice:hover:not(:disabled) {
border-color: var(--color-accent-fg);
background: var(--color-surface-alt, #fafafa);
background: var(--color-surface-2);
}
.smart-timeline-add-choice--primary {

View File

@@ -109,6 +109,27 @@ export function renderSubmissionDraft(): string {
</button>
</div>
{/* t-paliad-313 (m/paliad#141) Composer Slice A —
base picker. Hydrated by client/submission-draft.ts
once /api/submission-bases returns. Disabled
for pre-Composer drafts (base_id NULL); switching
autosaves the draft. */}
<div
className="submission-draft-base-row"
id="submission-draft-base-row"
style="display:none">
<label htmlFor="submission-draft-base" data-i18n="submissions.draft.base.label">
Vorlagenbasis
</label>
<select id="submission-draft-base" />
<p
className="submission-draft-base-hint"
id="submission-draft-base-hint"
data-i18n="submissions.draft.base.hint">
Steuert Schriftarten, Briefkopf und Abschnitts-Defaults.
</p>
</div>
{/* t-paliad-276 — output language toggle (DE/EN).
Hydrated by client/submission-draft.ts; switching
autosaves the draft and re-renders the preview. */}
@@ -202,6 +223,29 @@ export function renderSubmissionDraft(): string {
<div className="submission-draft-variables" id="submission-draft-variables" />
</aside>
{/* t-paliad-313 (m/paliad#141) Composer Slice A —
read-only section list. Painted from
view.sections. Empty/hidden for pre-Composer
drafts where no rows have been seeded. Slice B
turns these into in-place editable prose blocks. */}
<section
className="submission-draft-sections-wrap"
id="submission-draft-sections-wrap"
style="display:none">
<header className="submission-draft-sections-header">
<h2 data-i18n="submissions.draft.sections.title">Abschnitte</h2>
<span
className="submission-draft-sections-hint"
data-i18n="submissions.draft.sections.hint">
Inhalt pro Abschnitt &mdash; Autosave nach 500 ms. Letztes Layout in Word.
</span>
</header>
<ol
className="submission-draft-sections-list"
id="submission-draft-sections-list"
/>
</section>
{/* Preview pane — read-only HTML render of the merged
document body. Re-renders on autosave round-trip. */}
<section className="submission-draft-preview-wrap">

View File

@@ -0,0 +1,7 @@
-- 139_deadline_rules_unified_view (down) — Slice B.3, t-paliad-305
--
-- Drops the view. The underlying paliad.sequencing_rules /
-- procedural_events / legal_sources tables are untouched (they own the
-- data — the view is just a projection).
DROP VIEW IF EXISTS paliad.deadline_rules_unified;

View File

@@ -0,0 +1,122 @@
-- 139_deadline_rules_unified_view — Slice B.3 read cutover (t-paliad-305 / m/paliad#93)
--
-- Creates paliad.deadline_rules_unified — a Postgres VIEW that
-- re-projects paliad.sequencing_rules + paliad.procedural_events +
-- paliad.legal_sources back into the legacy paliad.deadline_rules
-- column shape.
--
-- Why a view instead of rewriting every SELECT in Go:
--
-- - 19 read sites across 11 service files reference
-- paliad.deadline_rules. Rewriting each by hand multiplies the
-- opportunity for off-by-one bugs in the JOIN.
-- - The view has the same column names + types as the legacy table,
-- so the change in Go is a 1-token substitution per query
-- (FROM paliad.deadline_rules → FROM paliad.deadline_rules_unified)
-- with no struct or scanner changes.
-- - When B.4 drops paliad.deadline_rules, this view stays — it
-- becomes the canonical legacy-shape reader for any code that
-- hasn't been migrated to direct sr/pe/ls reads.
--
-- Column mapping (per design §4.2):
-- - id, proceeding_type_id, parent_id, primary_party, duration_*,
-- timing, sequence_order, is_spawn/court_set/bilateral, priority,
-- rule_code, rule_codes, deadline_notes(_en), condition_expr,
-- choices_offered, applies_to_target, trigger_event_id,
-- spawn_proceeding_type_id, anchor_alt, alt_duration_*,
-- alt_rule_code, combine_op, lifecycle_state, draft_of,
-- published_at, is_active, created_at, updated_at, spawn_label
-- → from paliad.sequencing_rules
-- - submission_code → procedural_events.code
-- - name, name_en, description→ procedural_events
-- - event_type → procedural_events.event_kind (renamed)
-- - concept_id → procedural_events
-- - legal_source → legal_sources.citation (via legal_source_id FK)
--
-- The view is READ-ONLY by default. Writes still go to the underlying
-- tables — RuleEditorService is refactored in the same slice to write
-- directly to sr/pe/ls. paliad.deadline_rules is FROZEN from B.3 onward
-- (no new writes); the dual-write helper from B.2 is decommissioned.
-- The CHECK constraint on sequencing_rules.primary_party doesn't exist
-- yet (mig 135 only constrained deadline_rules.primary_party). The view
-- inherits whatever value sr.primary_party carries; mig 136's backfill
-- set sr.primary_party = dr.primary_party so the canonical four-value
-- vocab is already in place. A later slice can add the same CHECK to
-- sequencing_rules itself.
CREATE OR REPLACE VIEW paliad.deadline_rules_unified AS
SELECT
sr.id,
sr.proceeding_type_id,
sr.parent_id,
pe.code AS submission_code,
pe.name,
pe.name_en,
pe.description,
sr.primary_party,
pe.event_kind AS event_type,
sr.duration_value,
sr.duration_unit,
sr.timing,
sr.alt_duration_value,
sr.alt_duration_unit,
sr.alt_rule_code,
sr.anchor_alt,
sr.combine_op,
sr.rule_code,
sr.deadline_notes,
sr.deadline_notes_en,
sr.sequence_order,
sr.is_spawn,
sr.spawn_label,
sr.spawn_proceeding_type_id,
sr.is_bilateral,
sr.is_court_set,
sr.priority,
sr.condition_expr,
pe.concept_id,
ls.citation AS legal_source,
sr.trigger_event_id,
sr.rule_codes,
sr.choices_offered,
sr.applies_to_target,
sr.lifecycle_state,
sr.draft_of,
sr.published_at,
sr.is_active,
sr.created_at,
sr.updated_at
FROM paliad.sequencing_rules sr
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
LEFT JOIN paliad.legal_sources ls ON ls.id = pe.legal_source_id;
COMMENT ON VIEW paliad.deadline_rules_unified IS
'Slice B.3 (mig 139, t-paliad-305): legacy-shape projection over '
'sequencing_rules + procedural_events + legal_sources. Read-only — '
'writes go directly to the three underlying tables via '
'RuleEditorService. Survives B.4 destructive drop of '
'paliad.deadline_rules; the view will then be the only '
'legacy-shape reader.';
-- Post-apply integrity check: confirm the view's row count matches the
-- live sequencing_rules row count. A mismatch would indicate either a
-- mid-deploy race (rare) or a JOIN issue (the LEFT JOIN to legal_sources
-- never drops rows, the INNER JOIN to procedural_events drops sr rows
-- whose procedural_event_id is NULL — but that column is NOT NULL on
-- the table so it can't happen). Belt-and-braces.
DO $$
DECLARE
v_view_count int;
v_sr_count int;
BEGIN
SELECT COUNT(*) INTO v_view_count FROM paliad.deadline_rules_unified;
SELECT COUNT(*) INTO v_sr_count FROM paliad.sequencing_rules;
IF v_view_count <> v_sr_count THEN
RAISE EXCEPTION '[mig 139] FAILED POST: view row count % does not match sequencing_rules row count %. '
'Possible cause: a sequencing_rules row references a procedural_event_id that does not exist (NOT NULL FK should prevent this).',
v_view_count, v_sr_count;
END IF;
RAISE NOTICE '[mig 139] view OK — deadline_rules_unified rows = % (= sequencing_rules)',
v_view_count;
END $$;

View File

@@ -0,0 +1,3 @@
-- t-paliad-313: revert submission_bases catalog.
DROP TABLE IF EXISTS paliad.submission_bases;

View File

@@ -0,0 +1,173 @@
-- 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;

View File

@@ -0,0 +1,5 @@
-- t-paliad-313: revert Composer columns on submission_drafts.
ALTER TABLE paliad.submission_drafts
DROP COLUMN IF EXISTS composer_meta,
DROP COLUMN IF EXISTS base_id;

View File

@@ -0,0 +1,31 @@
-- t-paliad-313 (m/paliad#141): Composer Slice A — point submission_drafts at a base.
--
-- Two purely-additive columns on paliad.submission_drafts:
--
-- base_id uuid — FK to paliad.submission_bases. NULL on existing
-- drafts (Slice A explicitly does NOT auto-upgrade pre-Composer
-- rows — that's Slice C). NEW drafts created post-Composer get
-- base_id seeded by SubmissionDraftService.Create from the firm
-- default for the proceeding family. ON DELETE SET NULL keeps a
-- draft renderable via the v1 fallback chain even if its base is
-- removed; the lawyer picks a new base via the sidebar.
--
-- composer_meta jsonb — Composer-specific metadata. For Slice A this
-- carries the seed-time section order so the editor paints without
-- a join. Future slices may add hidden_sections, active_locale,
-- etc.
--
-- No data backfill, no auto-upgrade — pre-Composer drafts keep base_id
-- NULL and render via the existing v1 path. The Go side has the
-- corresponding gate (base_id IS NULL OR no submission_sections rows →
-- v1 path).
ALTER TABLE paliad.submission_drafts
ADD COLUMN IF NOT EXISTS base_id uuid REFERENCES paliad.submission_bases(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS composer_meta jsonb NOT NULL DEFAULT '{}'::jsonb;
COMMENT ON COLUMN paliad.submission_drafts.base_id IS
't-paliad-313: Composer base reference. NULL = pre-Composer draft, renders via v1 fallback chain. ON DELETE SET NULL.';
COMMENT ON COLUMN paliad.submission_drafts.composer_meta IS
't-paliad-313: Composer-side metadata (section_order, hidden_sections, …). jsonb, default {}.';

View File

@@ -0,0 +1,3 @@
-- t-paliad-313: revert submission_sections table.
DROP TABLE IF EXISTS paliad.submission_sections;

View File

@@ -0,0 +1,116 @@
-- t-paliad-313 (m/paliad#141): Composer Slice A — per-draft section rows.
--
-- paliad.submission_sections holds one row per (draft, section_key) for
-- Composer-mode drafts. Slice A seeds rows on draft create from the
-- base's section_spec.defaults; the editor renders them read-only. Slice
-- B turns them editable, Slice F adds reorder/hide/add-custom.
--
-- kind values per the design (Q10 ratification — no *_auto kind):
-- 'prose' — free Markdown content (default).
-- 'requests' — Anträge-style content (editor may add auto-numbering
-- later; Slice A treats identical to 'prose').
-- 'evidence' — Beweisangebote (editor may prefix lines with
-- 'Beweis: '; Slice A treats identical to 'prose').
--
-- Visibility flows through draft_id → submission_drafts → can_see_project
-- + owner-scoped. RLS policies mirror the four-policy shape on
-- submission_drafts so seeding from the Go service stays inside the
-- same RLS envelope.
--
-- content_md_de + content_md_en both NOT NULL DEFAULT '' so neither
-- side blocks the bilingual-by-construction render path. Empty content
-- renders as the missing-content marker per the editor's contract.
--
-- Per the brief (head's instruction msg #2392) Slice A does NOT auto-
-- upgrade the 11 pre-Composer drafts — those remain base_id=NULL with
-- no section rows. The v1 fallback render path stays compiled in to
-- keep them working.
CREATE TABLE IF NOT EXISTS paliad.submission_sections (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
draft_id uuid NOT NULL REFERENCES paliad.submission_drafts(id) ON DELETE CASCADE,
section_key text NOT NULL,
order_index int NOT NULL,
kind text NOT NULL,
label_de text NOT NULL,
label_en text NOT NULL,
included bool NOT NULL DEFAULT true,
content_md_de text NOT NULL DEFAULT '',
content_md_en text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT submission_sections_kind_check
CHECK (kind IN ('prose', 'requests', 'evidence')),
CONSTRAINT submission_sections_unique_per_draft
UNIQUE (draft_id, section_key)
);
CREATE INDEX IF NOT EXISTS submission_sections_draft_idx
ON paliad.submission_sections (draft_id, order_index);
ALTER TABLE paliad.submission_sections ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS submission_sections_select ON paliad.submission_sections;
CREATE POLICY submission_sections_select
ON paliad.submission_sections FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM paliad.submission_drafts d
WHERE d.id = paliad.submission_sections.draft_id
AND d.user_id = auth.uid()
AND (d.project_id IS NULL OR paliad.can_see_project(d.project_id))
)
);
DROP POLICY IF EXISTS submission_sections_insert ON paliad.submission_sections;
CREATE POLICY submission_sections_insert
ON paliad.submission_sections FOR INSERT TO authenticated
WITH CHECK (
EXISTS (
SELECT 1 FROM paliad.submission_drafts d
WHERE d.id = paliad.submission_sections.draft_id
AND d.user_id = auth.uid()
AND (d.project_id IS NULL OR paliad.can_see_project(d.project_id))
)
);
DROP POLICY IF EXISTS submission_sections_update ON paliad.submission_sections;
CREATE POLICY submission_sections_update
ON paliad.submission_sections FOR UPDATE TO authenticated
USING (
EXISTS (
SELECT 1 FROM paliad.submission_drafts d
WHERE d.id = paliad.submission_sections.draft_id
AND d.user_id = auth.uid()
AND (d.project_id IS NULL OR paliad.can_see_project(d.project_id))
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM paliad.submission_drafts d
WHERE d.id = paliad.submission_sections.draft_id
AND d.user_id = auth.uid()
AND (d.project_id IS NULL OR paliad.can_see_project(d.project_id))
)
);
DROP POLICY IF EXISTS submission_sections_delete ON paliad.submission_sections;
CREATE POLICY submission_sections_delete
ON paliad.submission_sections FOR DELETE TO authenticated
USING (
EXISTS (
SELECT 1 FROM paliad.submission_drafts d
WHERE d.id = paliad.submission_sections.draft_id
AND d.user_id = auth.uid()
AND (d.project_id IS NULL OR paliad.can_see_project(d.project_id))
)
);
DROP TRIGGER IF EXISTS submission_sections_set_updated_at ON paliad.submission_sections;
CREATE TRIGGER submission_sections_set_updated_at
BEFORE UPDATE ON paliad.submission_sections
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
COMMENT ON TABLE paliad.submission_sections IS
't-paliad-313: per-draft Composer section rows. Slice A: seeded on draft create from base.section_spec.defaults, rendered read-only. Slice B: editable. RLS mirrors submission_drafts (owner-scoped + can_see_project).';

View File

@@ -0,0 +1,4 @@
-- t-paliad-315: revert building blocks library.
DROP TABLE IF EXISTS paliad.submission_building_block_admin_versions;
DROP TABLE IF EXISTS paliad.submission_building_blocks;

View File

@@ -0,0 +1,118 @@
-- t-paliad-315 (m/paliad#141): Composer Slice C — building blocks library.
--
-- Per the design at docs/design-submission-generator-v2-2026-05-26.md §4.4
-- and the Q2 / Q9 ratifications:
--
-- Q2 (m, 2026-05-26): building blocks are plain text paste sources.
-- No building_block_id reference is stored on submission_sections —
-- insertion is a one-way copy of content_md_<lang> into the section.
-- This table records the library; submission_sections doesn't know
-- where its content came from.
--
-- Q9 (m, 2026-05-26): four visibility tiers — private / team / firm
-- / global. Picker filtering and RLS SELECT predicate both honour
-- the tier. Tier upgrades (private → team/firm/global) go through
-- admin moderation in later slices; Slice C starts with admin-only
-- mutations (no user-initiated rows yet).
--
-- The _admin_versions companion table mirrors the email-templates
-- retention=20 audit history. It is INTERNAL to the admin editor —
-- not referenced from submission_sections, not exposed to the lawyer.
-- It exists so accidental delete + accidental overwrite are
-- recoverable.
CREATE TABLE IF NOT EXISTS paliad.submission_building_blocks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL,
firm text, -- e.g. 'HLC', NULL = cross-firm
section_key text NOT NULL, -- which section kind this block fits
proceeding_family text, -- 'de.inf.lg', NULL = any family
title_de text NOT NULL,
title_en text NOT NULL,
description_de text,
description_en text,
content_md_de text NOT NULL DEFAULT '',
content_md_en text NOT NULL DEFAULT '',
author_id uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
visibility text NOT NULL, -- 'private' | 'team' | 'firm' | 'global'
is_published bool NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT submission_building_blocks_visibility_check
CHECK (visibility IN ('private', 'team', 'firm', 'global')),
CONSTRAINT submission_building_blocks_unique_slug_per_firm
UNIQUE (slug, firm)
);
CREATE INDEX IF NOT EXISTS submission_building_blocks_section_visibility_idx
ON paliad.submission_building_blocks (section_key, visibility, firm, proceeding_family)
WHERE deleted_at IS NULL AND is_published;
CREATE INDEX IF NOT EXISTS submission_building_blocks_author_idx
ON paliad.submission_building_blocks (author_id)
WHERE deleted_at IS NULL;
ALTER TABLE paliad.submission_building_blocks ENABLE ROW LEVEL SECURITY;
-- SELECT policy: coarse-grained RLS that admits every non-deleted
-- block to any authenticated user. The Go-side BuildingBlockService
-- applies the fine-grained tier predicate (private / team / firm /
-- global) using branding.Name + team-membership joins. This split
-- keeps the SQL simple and lets the tier semantics evolve in code
-- without RLS migrations.
--
-- The exception below is 'private': only the author sees their own
-- private rows. That's the hard line where a tier upgrade is
-- substantive enough to warrant DB-level enforcement.
DROP POLICY IF EXISTS submission_building_blocks_select ON paliad.submission_building_blocks;
CREATE POLICY submission_building_blocks_select
ON paliad.submission_building_blocks FOR SELECT TO authenticated
USING (
deleted_at IS NULL
AND (
visibility <> 'private'
OR author_id = auth.uid()
)
);
-- INSERT / UPDATE / DELETE intentionally absent — admin mutations
-- happen at the Go handler layer with explicit adminGate. RLS without
-- mutation policies denies them by default.
DROP TRIGGER IF EXISTS submission_building_blocks_set_updated_at ON paliad.submission_building_blocks;
CREATE TRIGGER submission_building_blocks_set_updated_at
BEFORE UPDATE ON paliad.submission_building_blocks
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
COMMENT ON TABLE paliad.submission_building_blocks IS
't-paliad-315: Composer building-block library. Plain text paste sources for section content (no lineage tracked on sections per Q2 ratification). 4-tier visibility per Q9.';
-- _admin_versions: append-only history per block. Admin-side only;
-- not referenced from submission_sections. Retention 20 per block,
-- GCed in the same transaction as the Save (mirrors email-templates).
CREATE TABLE IF NOT EXISTS paliad.submission_building_block_admin_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
building_block_id uuid NOT NULL REFERENCES paliad.submission_building_blocks(id) ON DELETE CASCADE,
content_md_de text NOT NULL,
content_md_en text NOT NULL,
title_de text NOT NULL,
title_en text NOT NULL,
edited_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
note text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS submission_building_block_admin_versions_block_idx
ON paliad.submission_building_block_admin_versions (building_block_id, created_at DESC);
ALTER TABLE paliad.submission_building_block_admin_versions ENABLE ROW LEVEL SECURITY;
-- Admin-only audit; the handler layer gates this via adminGate and
-- writes via SECURITY DEFINER paths or admin-role SQL. No RLS SELECT
-- policy exists, so non-admin users get an empty result set.
COMMENT ON TABLE paliad.submission_building_block_admin_versions IS
't-paliad-315: append-only history per building block. Admin-side only; retention 20 rows per block, GCed at Save time.';

View File

@@ -3,6 +3,7 @@ package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
@@ -12,6 +13,7 @@ import (
"time"
"mgit.msbls.de/m/paliad/internal/branding"
"mgit.msbls.de/m/paliad/internal/services"
)
const (
@@ -402,6 +404,35 @@ func fetchFirmSkeletonBytes(ctx context.Context) ([]byte, string, error) {
return fetchSubmissionTemplateSlug(ctx, firmSkeletonSubmissionSlug)
}
// composerBaseSlugMap routes a Composer base.slug to the existing
// fileRegistry slug whose Gitea object backs it (t-paliad-313 Slice B).
// Slice A seeded two bases that already share .docx files with the v1
// fallback chain — no new Gitea uploads needed for those. Future bases
// (e.g. lg-duesseldorf, upc-formal in Slice E) register their own
// fileRegistry entries via the same shape and add a row here.
var composerBaseSlugMap = map[string]string{
"hlc-letterhead": firmSkeletonSubmissionSlug,
"neutral": skeletonSubmissionSlug,
}
// fetchComposerBaseBytes returns the .docx bytes for a Composer base,
// pulled from the shared Gitea proxy cache. ErrComposerBaseNotProxied
// when the slug has no registered fileRegistry entry — a base authored
// without a file-registry mapping (rare; admin oversight) renders as
// "Vorlagenbasis nicht erreichbar" upstream of this call.
var ErrComposerBaseNotProxied = errors.New("composer base: Gitea slug not registered")
func fetchComposerBaseBytes(ctx context.Context, base *services.SubmissionBase) ([]byte, string, error) {
if base == nil {
return nil, "", fmt.Errorf("composer base: nil base")
}
slug, ok := composerBaseSlugMap[base.Slug]
if !ok {
return nil, "", fmt.Errorf("%w: base slug %q", ErrComposerBaseNotProxied, base.Slug)
}
return fetchSubmissionTemplateSlug(ctx, slug)
}
// fetchSubmissionTemplateSlug is the shared cache-aware fetcher used by
// the firm-skeleton and universal-skeleton accessors. Factored out so
// the two paths can't drift apart on caching semantics.

View File

@@ -116,6 +116,18 @@ type Services struct {
// t-paliad-238 — dedicated Submissions/Schriftsätze editor.
SubmissionDraft *services.SubmissionDraftService
// t-paliad-313 (m/paliad#141) Composer Slice A + B — base catalog,
// per-draft section rows, render-pipeline assembler. All three
// nil in DATABASE_URL-less deploys (the Composer surfaces return
// 503 / hide the picker).
SubmissionBase *services.BaseService
SubmissionSection *services.SectionService
SubmissionComposer *services.SubmissionComposer
// t-paliad-315 Composer Slice C — building-block library + admin
// editor. Per Q2: paste sources only, no lineage on sections.
SubmissionBuildingBlock *services.BuildingBlockService
// t-paliad-265 / m/paliad#96 — per-event-card optional choices on
// the Verfahrensablauf timeline.
EventChoice *services.EventChoiceService
@@ -187,9 +199,13 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
projection: svc.Projection,
export: svc.Export,
backup: svc.Backup,
submissionDraft: svc.SubmissionDraft,
eventChoice: svc.EventChoice,
scenario: svc.Scenario,
submissionDraft: svc.SubmissionDraft,
submissionBase: svc.SubmissionBase,
submissionSection: svc.SubmissionSection,
submissionComposer: svc.SubmissionComposer,
submissionBuildingBlock: svc.SubmissionBuildingBlock,
eventChoice: svc.EventChoice,
scenario: svc.Scenario,
}
}
@@ -408,6 +424,18 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("PATCH /api/submission-drafts/{draft_id}", handleGlobalPatchSubmissionDraft)
protected.HandleFunc("DELETE /api/submission-drafts/{draft_id}", handleGlobalDeleteSubmissionDraft)
protected.HandleFunc("POST /api/submission-drafts/{draft_id}/export", handleGlobalExportSubmissionDraft)
// t-paliad-313 (m/paliad#141) Composer Slice A — base catalog for
// the sidebar picker. Wide-open SELECT (any authenticated user);
// admin mutations are not exposed yet (Slice C).
protected.HandleFunc("GET /api/submission-bases", handleListSubmissionBases)
// t-paliad-313 (m/paliad#141) Composer Slice B — per-section PATCH
// for inline editor autosave. URL keyed on draft_id + section_id;
// owner-scoped via SubmissionDraftService.Get.
protected.HandleFunc("PATCH /api/submission-drafts/{draft_id}/sections/{section_id}", handlePatchSubmissionSection)
// t-paliad-315 (m/paliad#141) Composer Slice C — building blocks
// library. Lawyer-facing picker + paste mechanic.
protected.HandleFunc("GET /api/submission-building-blocks", handleListBuildingBlocks)
protected.HandleFunc("POST /api/submission-building-blocks/{block_id}/insert-into/{section_id}", handleInsertBlockIntoSection)
// t-paliad-277 / m/paliad#109 — refresh project-derived variables on
// the draft. Strips overrides for project.* / parties.* / deadline.*
// / procedural_event.* / rule.* prefixes and bumps last_imported_at.
@@ -672,6 +700,16 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("DELETE /api/admin/firm-dashboard-default", adminGate(users, handleDeleteFirmDashboardDefault))
protected.HandleFunc("POST /api/me/dashboard-layout/promote", adminGate(users, handlePromoteDashboardLayoutToFirmDefault))
// t-paliad-315 (m/paliad#141) Composer Slice C — admin building blocks editor.
protected.HandleFunc("GET /admin/submission-building-blocks", adminGate(users, gateOnboarded(handleAdminBuildingBlocksPage)))
protected.HandleFunc("GET /api/admin/submission-building-blocks", adminGate(users, handleAdminListBuildingBlocks))
protected.HandleFunc("POST /api/admin/submission-building-blocks", adminGate(users, handleAdminCreateBuildingBlock))
protected.HandleFunc("GET /api/admin/submission-building-blocks/{block_id}", adminGate(users, handleAdminGetBuildingBlock))
protected.HandleFunc("PATCH /api/admin/submission-building-blocks/{block_id}", adminGate(users, handleAdminUpdateBuildingBlock))
protected.HandleFunc("DELETE /api/admin/submission-building-blocks/{block_id}", adminGate(users, handleAdminDeleteBuildingBlock))
protected.HandleFunc("GET /api/admin/submission-building-blocks/{block_id}/versions", adminGate(users, handleAdminListBuildingBlockVersions))
protected.HandleFunc("POST /api/admin/submission-building-blocks/{block_id}/restore/{version_id}", adminGate(users, handleAdminRestoreBuildingBlockVersion))
protected.HandleFunc("GET /api/admin/email-templates", adminGate(users, handleAdminListEmailTemplates))
protected.HandleFunc("GET /api/admin/email-templates/{key}/variables", adminGate(users, handleAdminEmailTemplateVariables))
protected.HandleFunc("GET /api/admin/email-templates/{key}/{lang}", adminGate(users, handleAdminGetEmailTemplate))

View File

@@ -24,6 +24,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
@@ -360,6 +361,8 @@ func runStreamingTurn(turnID uuid.UUID, req services.TurnRequest, ch chan<- turn
convID = ev.ConversationID
case services.StreamError:
errorEmitted = true
log.Printf("paliadin: stream error turn=%s code=%s retryable=%v message=%q",
turnID, ev.Code, ev.Retryable, ev.Message)
send(ch, turnEvent{
Kind: "error",
Data: map[string]any{
@@ -372,6 +375,8 @@ func runStreamingTurn(turnID uuid.UUID, req services.TurnRequest, ch chan<- turn
case <-silenceTicker.C:
elapsed := time.Since(lastEventAt)
if elapsed >= silenceTimeout {
log.Printf("paliadin: silence timeout turn=%s elapsed=%s (silenceTimeout=%s)",
turnID, elapsed, silenceTimeout)
send(ch, turnEvent{
Kind: "error",
Data: map[string]any{
@@ -419,6 +424,8 @@ func runStreamingTurn(turnID uuid.UUID, req services.TurnRequest, ch chan<- turn
}
}
if res.err != nil {
log.Printf("paliadin: backend returned error turn=%s err=%v errorEmittedAlready=%v",
turnID, res.err, errorEmitted)
if !errorEmitted {
send(ch, turnEvent{
Kind: "error",
@@ -432,6 +439,8 @@ func runStreamingTurn(turnID uuid.UUID, req services.TurnRequest, ch chan<- turn
}
result := res.result
if result == nil {
log.Printf("paliadin: backend returned nil result without error turn=%s errorEmittedAlready=%v",
turnID, errorEmitted)
// Shouldn't happen — backend contract returns either err
// or a result. Defensive bail.
if !errorEmitted {

View File

@@ -69,6 +69,14 @@ type dbServices struct {
// t-paliad-238 — submission draft editor.
submissionDraft *services.SubmissionDraftService
// t-paliad-313 — Composer base catalog + per-draft sections +
// (Slice B) the render pipeline assembling base + sections into a
// final .docx + (Slice C) building-block library.
submissionBase *services.BaseService
submissionSection *services.SectionService
submissionComposer *services.SubmissionComposer
submissionBuildingBlock *services.BuildingBlockService
// t-paliad-265 — per-event-card optional choices.
eventChoice *services.EventChoiceService

View File

@@ -0,0 +1,96 @@
package handlers
// Submission base catalog handler — Composer Slice A (t-paliad-313,
// m/paliad#141, design doc docs/design-submission-generator-v2-2026-05-26.md
// §5.1 / Slice A acceptance).
//
// Endpoint: GET /api/submission-bases → list of active bases visible
// to the requesting firm. The sidebar picker on the draft editor reads
// this once on page load and caches in-memory; the response shape is
// stable across the picker's lifetime.
//
// Visibility: the catalog is shared firm-wide (per the design + mig
// 146's wide-open RLS SELECT policy). The handler still requires
// authentication; anonymous users 401.
//
// Filtering: the response includes the firm's own bases AND the
// firm-agnostic ones (firm IS NULL). The Go service-side filter passes
// branding.Name as the firm hint; cross-firm cases (e.g. a future
// non-HLC deployment) get their own filtered slice naturally.
import (
"net/http"
"mgit.msbls.de/m/paliad/internal/branding"
"mgit.msbls.de/m/paliad/internal/services"
)
// submissionBaseRow is the on-the-wire shape returned by the list
// endpoint. Mirrors services.SubmissionBase but drops the raw bytes
// and exposes the parsed section spec inline so the picker can show a
// preview of the default section count without an extra round-trip.
type submissionBaseRow struct {
ID string `json:"id"`
Slug string `json:"slug"`
Firm *string `json:"firm,omitempty"`
ProceedingFamily *string `json:"proceeding_family,omitempty"`
LabelDE string `json:"label_de"`
LabelEN string `json:"label_en"`
DescriptionDE *string `json:"description_de,omitempty"`
DescriptionEN *string `json:"description_en,omitempty"`
GiteaPath string `json:"gitea_path"`
IsDefaultFor []string `json:"is_default_for"`
IsActive bool `json:"is_active"`
SectionCount int `json:"section_count"`
}
type submissionBaseListResponse struct {
Bases []submissionBaseRow `json:"bases"`
}
// handleListSubmissionBases backs GET /api/submission-bases.
func handleListSubmissionBases(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if _, ok := requireUser(w, r); !ok {
return
}
if dbSvc.submissionBase == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "submission bases not configured",
})
return
}
rows, err := dbSvc.submissionBase.List(r.Context(), branding.Name)
if err != nil {
writeServiceError(w, err)
return
}
out := make([]submissionBaseRow, 0, len(rows))
for i := range rows {
out = append(out, baseRowFromService(&rows[i]))
}
writeJSON(w, http.StatusOK, submissionBaseListResponse{Bases: out})
}
// baseRowFromService projects a services.SubmissionBase into the
// on-the-wire row shape.
func baseRowFromService(b *services.SubmissionBase) submissionBaseRow {
return submissionBaseRow{
ID: b.ID.String(),
Slug: b.Slug,
Firm: b.Firm,
ProceedingFamily: b.ProceedingFamily,
LabelDE: b.LabelDE,
LabelEN: b.LabelEN,
DescriptionDE: b.DescriptionDE,
DescriptionEN: b.DescriptionEN,
GiteaPath: b.GiteaPath,
IsDefaultFor: b.IsDefaultFor,
IsActive: b.IsActive,
SectionCount: len(b.SectionSpec.Defaults),
}
}

View File

@@ -0,0 +1,482 @@
package handlers
// Composer building-block handlers — t-paliad-315 Slice C.
//
// Two surfaces:
//
// 1. Lawyer-facing picker (any authenticated user):
// GET /api/submission-building-blocks?section_key=…&proceeding_family=…&q=…
// POST /api/submission-building-blocks/{block_id}/insert-into/{section_id}
//
// The picker list is visibility-tier-filtered (private/team/firm/
// global) at the service layer. Insert is the paste mechanic
// ratified by Q2 (m, 2026-05-26): plain text copy of
// content_md_<lang> into submission_sections.content_md_<lang>.
// No lineage stamped on the section.
//
// 2. Admin editor (adminGate via auth.RequireAdminFunc):
// GET /api/admin/submission-building-blocks
// POST /api/admin/submission-building-blocks
// GET /api/admin/submission-building-blocks/{block_id}
// PATCH /api/admin/submission-building-blocks/{block_id}
// DELETE /api/admin/submission-building-blocks/{block_id}
// GET /api/admin/submission-building-blocks/{block_id}/versions
// POST /api/admin/submission-building-blocks/{block_id}/restore/{version_id}
//
// Plus the page route /admin/submission-building-blocks (list +
// edit shell, hydrated client-side).
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// blockJSON is the on-the-wire shape for both the picker and admin
// surfaces.
type buildingBlockJSON struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Firm *string `json:"firm,omitempty"`
SectionKey string `json:"section_key"`
ProceedingFamily *string `json:"proceeding_family,omitempty"`
TitleDE string `json:"title_de"`
TitleEN string `json:"title_en"`
DescriptionDE *string `json:"description_de,omitempty"`
DescriptionEN *string `json:"description_en,omitempty"`
ContentMDDE string `json:"content_md_de"`
ContentMDEN string `json:"content_md_en"`
AuthorID *uuid.UUID `json:"author_id,omitempty"`
Visibility string `json:"visibility"`
IsPublished bool `json:"is_published"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type buildingBlockListResponse struct {
Blocks []buildingBlockJSON `json:"blocks"`
}
// blockJSONFromService projects services.BuildingBlock into the wire shape.
func blockJSONFromService(b *services.BuildingBlock) buildingBlockJSON {
return buildingBlockJSON{
ID: b.ID,
Slug: b.Slug,
Firm: b.Firm,
SectionKey: b.SectionKey,
ProceedingFamily: b.ProceedingFamily,
TitleDE: b.TitleDE,
TitleEN: b.TitleEN,
DescriptionDE: b.DescriptionDE,
DescriptionEN: b.DescriptionEN,
ContentMDDE: b.ContentMDDE,
ContentMDEN: b.ContentMDEN,
AuthorID: b.AuthorID,
Visibility: b.Visibility,
IsPublished: b.IsPublished,
CreatedAt: b.CreatedAt,
UpdatedAt: b.UpdatedAt,
}
}
// ─────────────────────────────────────────────────────────────────────
// Lawyer-facing picker
// ─────────────────────────────────────────────────────────────────────
func handleListBuildingBlocks(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.submissionBuildingBlock == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
return
}
q := r.URL.Query()
filter := services.BlockListFilter{
SectionKey: strings.TrimSpace(q.Get("section_key")),
ProceedingFamily: strings.TrimSpace(q.Get("proceeding_family")),
Search: strings.TrimSpace(q.Get("q")),
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
rows, err := dbSvc.submissionBuildingBlock.ListVisible(ctx, uid, filter)
if err != nil {
writeServiceError(w, err)
return
}
out := make([]buildingBlockJSON, 0, len(rows))
for i := range rows {
out = append(out, blockJSONFromService(&rows[i]))
}
writeJSON(w, http.StatusOK, buildingBlockListResponse{Blocks: out})
}
func handleInsertBlockIntoSection(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.submissionBuildingBlock == nil || dbSvc.submissionSection == nil || dbSvc.submissionDraft == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
return
}
blockID, ok := parseUUIDPath(w, r, "block_id", "block id")
if !ok {
return
}
sectionID, ok := parseUUIDPath(w, r, "section_id", "section id")
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
// Visibility on the section: section.draft_id must point to a
// draft the caller owns. Composer Slice B's same owner gate.
sec, err := dbSvc.submissionSection.Get(ctx, sectionID)
if err != nil {
if errors.Is(err, services.ErrSubmissionSectionNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
return
}
writeServiceError(w, err)
return
}
if _, err := dbSvc.submissionDraft.Get(ctx, uid, sec.DraftID); err != nil {
writeSubmissionDraftServiceError(w, err)
return
}
updated, err := dbSvc.submissionBuildingBlock.InsertIntoSection(ctx, uid, blockID, sectionID, dbSvc.submissionSection)
if err != nil {
if errors.Is(err, services.ErrBuildingBlockNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "block not found"})
return
}
if errors.Is(err, services.ErrSubmissionSectionNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, sectionJSONFromService(updated))
}
// ─────────────────────────────────────────────────────────────────────
// Admin editor
// ─────────────────────────────────────────────────────────────────────
func handleAdminListBuildingBlocks(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if dbSvc.submissionBuildingBlock == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
rows, err := dbSvc.submissionBuildingBlock.ListAllForAdmin(ctx)
if err != nil {
writeServiceError(w, err)
return
}
out := make([]buildingBlockJSON, 0, len(rows))
for i := range rows {
out = append(out, blockJSONFromService(&rows[i]))
}
writeJSON(w, http.StatusOK, buildingBlockListResponse{Blocks: out})
}
func handleAdminGetBuildingBlock(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if dbSvc.submissionBuildingBlock == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
return
}
blockID, ok := parseUUIDPath(w, r, "block_id", "block id")
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
b, err := dbSvc.submissionBuildingBlock.GetForAdmin(ctx, blockID)
if err != nil {
if errors.Is(err, services.ErrBuildingBlockNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "block not found"})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, blockJSONFromService(b))
}
type buildingBlockCreateInput struct {
Slug string `json:"slug"`
Firm *string `json:"firm,omitempty"`
SectionKey string `json:"section_key"`
ProceedingFamily *string `json:"proceeding_family,omitempty"`
TitleDE string `json:"title_de"`
TitleEN string `json:"title_en"`
DescriptionDE *string `json:"description_de,omitempty"`
DescriptionEN *string `json:"description_en,omitempty"`
ContentMDDE string `json:"content_md_de"`
ContentMDEN string `json:"content_md_en"`
Visibility string `json:"visibility"`
IsPublished bool `json:"is_published"`
}
func handleAdminCreateBuildingBlock(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.submissionBuildingBlock == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
return
}
var in buildingBlockCreateInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
b, err := dbSvc.submissionBuildingBlock.Create(ctx, uid, services.CreateInput{
Slug: in.Slug,
Firm: in.Firm,
SectionKey: in.SectionKey,
ProceedingFamily: in.ProceedingFamily,
TitleDE: in.TitleDE,
TitleEN: in.TitleEN,
DescriptionDE: in.DescriptionDE,
DescriptionEN: in.DescriptionEN,
ContentMDDE: in.ContentMDDE,
ContentMDEN: in.ContentMDEN,
Visibility: in.Visibility,
IsPublished: in.IsPublished,
})
if err != nil {
if errors.Is(err, services.ErrInvalidInput) || errors.Is(err, services.ErrBuildingBlockInvalidVisibility) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, blockJSONFromService(b))
}
type buildingBlockUpdateInput struct {
Slug *string `json:"slug,omitempty"`
Firm *string `json:"firm,omitempty"`
FirmSet bool `json:"-"`
SectionKey *string `json:"section_key,omitempty"`
ProceedingFamily *string `json:"proceeding_family,omitempty"`
ProceedingFamilySet bool `json:"-"`
TitleDE *string `json:"title_de,omitempty"`
TitleEN *string `json:"title_en,omitempty"`
DescriptionDE *string `json:"description_de,omitempty"`
DescriptionDESet bool `json:"-"`
DescriptionEN *string `json:"description_en,omitempty"`
DescriptionENSet bool `json:"-"`
ContentMDDE *string `json:"content_md_de,omitempty"`
ContentMDEN *string `json:"content_md_en,omitempty"`
Visibility *string `json:"visibility,omitempty"`
IsPublished *bool `json:"is_published,omitempty"`
Note *string `json:"note,omitempty"`
}
func (u *buildingBlockUpdateInput) UnmarshalJSON(data []byte) error {
type alias buildingBlockUpdateInput
var a alias
if err := json.Unmarshal(data, &a); err != nil {
return err
}
*u = buildingBlockUpdateInput(a)
raw := map[string]json.RawMessage{}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
_, u.FirmSet = raw["firm"]
_, u.ProceedingFamilySet = raw["proceeding_family"]
_, u.DescriptionDESet = raw["description_de"]
_, u.DescriptionENSet = raw["description_en"]
return nil
}
func handleAdminUpdateBuildingBlock(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.submissionBuildingBlock == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
return
}
blockID, ok := parseUUIDPath(w, r, "block_id", "block id")
if !ok {
return
}
var in buildingBlockUpdateInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
patch := services.UpdatePatch{
Slug: in.Slug,
SectionKey: in.SectionKey,
TitleDE: in.TitleDE,
TitleEN: in.TitleEN,
ContentMDDE: in.ContentMDDE,
ContentMDEN: in.ContentMDEN,
Visibility: in.Visibility,
IsPublished: in.IsPublished,
Note: in.Note,
}
if in.FirmSet {
patch.Firm = &in.Firm
}
if in.ProceedingFamilySet {
patch.ProceedingFamily = &in.ProceedingFamily
}
if in.DescriptionDESet {
patch.DescriptionDE = &in.DescriptionDE
}
if in.DescriptionENSet {
patch.DescriptionEN = &in.DescriptionEN
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
b, err := dbSvc.submissionBuildingBlock.Update(ctx, uid, blockID, patch)
if err != nil {
if errors.Is(err, services.ErrBuildingBlockNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "block not found"})
return
}
if errors.Is(err, services.ErrBuildingBlockInvalidVisibility) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, blockJSONFromService(b))
}
func handleAdminDeleteBuildingBlock(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.submissionBuildingBlock == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
return
}
blockID, ok := parseUUIDPath(w, r, "block_id", "block id")
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if err := dbSvc.submissionBuildingBlock.SoftDelete(ctx, uid, blockID); err != nil {
if errors.Is(err, services.ErrBuildingBlockNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "block not found"})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusNoContent, nil)
}
func handleAdminListBuildingBlockVersions(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if dbSvc.submissionBuildingBlock == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
return
}
blockID, ok := parseUUIDPath(w, r, "block_id", "block id")
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
rows, err := dbSvc.submissionBuildingBlock.ListVersions(ctx, blockID)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"versions": rows})
}
func handleAdminRestoreBuildingBlockVersion(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.submissionBuildingBlock == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
return
}
blockID, ok := parseUUIDPath(w, r, "block_id", "block id")
if !ok {
return
}
versionID, ok := parseUUIDPath(w, r, "version_id", "version id")
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
b, err := dbSvc.submissionBuildingBlock.RestoreVersion(ctx, uid, blockID, versionID)
if err != nil {
if errors.Is(err, services.ErrBuildingBlockNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "block or version not found"})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, blockJSONFromService(b))
}
// handleAdminBuildingBlocksPage serves the admin editor shell. The
// client bundle hydrates the list + edit UI.
func handleAdminBuildingBlocksPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/admin-submission-building-blocks.html")
}

View File

@@ -83,6 +83,11 @@ type submissionDraftView struct {
// so the frontend can render the multi-select picker in one round-
// trip. Empty when the draft has no project attached.
AvailableParties []submissionDraftPartyJSON `json:"available_parties"`
// Sections is the per-draft section stack (t-paliad-313 Slice A).
// Slice A renders these read-only; the lawyer sees what the
// Composer seeded but can't yet edit prose. nil for pre-Composer
// drafts (base_id NULL, no submission_sections rows).
Sections []submissionSectionJSON `json:"sections"`
}
// submissionDraftPartyJSON is the minimal party row the editor sidebar
@@ -106,8 +111,30 @@ type submissionDraftJSON struct {
LastExportedAt *time.Time `json:"last_exported_at,omitempty"`
LastExportedSHA *string `json:"last_exported_sha,omitempty"`
LastImportedAt *time.Time `json:"last_imported_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// BaseID — Composer base reference (t-paliad-313). NULL on
// pre-Composer drafts; the editor sidebar surfaces this in the
// base picker. PATCH accepts {"base_id": "<uuid>"} or
// {"base_id": null} to set or clear.
BaseID *uuid.UUID `json:"base_id"`
ComposerMeta map[string]any `json:"composer_meta"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// submissionSectionJSON is the on-the-wire row for each per-draft
// section. Slice A renders these read-only — the lawyer sees the
// section stack but doesn't yet edit prose. Slice B makes content_md_*
// editable + adds the PATCH endpoint.
type submissionSectionJSON struct {
ID uuid.UUID `json:"id"`
SectionKey string `json:"section_key"`
OrderIndex int `json:"order_index"`
Kind string `json:"kind"`
LabelDE string `json:"label_de"`
LabelEN string `json:"label_en"`
Included bool `json:"included"`
ContentMDDE string `json:"content_md_de"`
ContentMDEN string `json:"content_md_en"`
}
type submissionRuleSummary struct {
@@ -132,6 +159,41 @@ type submissionDraftPatchInput struct {
Variables *services.PlaceholderMap `json:"variables,omitempty"`
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
Language *string `json:"language,omitempty"`
// BaseID accepts three states per the JSON contract:
// field absent → no change (json:"-")
// {"base_id": "<uuid>"} → set to picked base
// {"base_id": null} → clear (return to v1 fallback)
// We model this with a **uuid.UUID inside a custom UnmarshalJSON
// in case extends; for now the simpler `*uuid.UUID` + presence
// flag covers Slice A's set-base flow. Clearing is exposed but
// rarely used (the editor always picks a base; clearing is for
// admin-recovery flows).
BaseID *uuid.UUID `json:"base_id,omitempty"`
BaseIDSet bool `json:"-"`
}
// UnmarshalJSON on submissionDraftPatchInput sets BaseIDSet=true if
// the "base_id" key appears in the payload (regardless of whether
// the value is null or a uuid string). Lets the handler distinguish
// "field absent" (no change) from "field set to null" (clear).
func (p *submissionDraftPatchInput) UnmarshalJSON(data []byte) error {
// Phase 1: decode into a raw map to detect key presence.
raw := map[string]json.RawMessage{}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
// Phase 2: decode the typed fields. Use an alias to skip this
// custom UnmarshalJSON during the re-parse.
type alias submissionDraftPatchInput
var a alias
if err := json.Unmarshal(data, &a); err != nil {
return err
}
*p = submissionDraftPatchInput(a)
if _, ok := raw["base_id"]; ok {
p.BaseIDSet = true
}
return nil
}
// ─────────────────────────────────────────────────────────────────────
@@ -372,6 +434,9 @@ func handlePatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
SelectedParties: input.SelectedParties,
Language: input.Language,
}
if input.BaseIDSet {
patch.BaseID = &input.BaseID
}
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
if err != nil {
writeSubmissionDraftServiceError(w, err)
@@ -501,16 +566,10 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
writeSubmissionDraftServiceError(w, err)
return
}
tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
docx, resolved, tplSHA, composerUsed, err := exportSubmissionDraft(ctx, d)
if err != nil {
log.Printf("submission_drafts: export template fetch (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
return
}
docx, resolved, err := dbSvc.submissionDraft.Export(ctx, d, tplBytes)
if err != nil {
log.Printf("submission_drafts: export render (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
log.Printf("submission_drafts: export (draft=%s): %v", draftID, err)
writeSubmissionExportError(w, err)
return
}
@@ -523,7 +582,7 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
if err := dbSvc.submissionDraft.MarkExported(bgCtx, d.ID, tplSHA); err != nil {
log.Printf("submission_drafts: mark exported (draft=%s): %v", draftID, err)
}
if err := writeSubmissionDraftAuditRow(bgCtx, resolved.User, d, filename, tplSHA); err != nil {
if err := writeSubmissionDraftAuditRow(bgCtx, resolved.User, d, filename, tplSHA, composerUsed); err != nil {
log.Printf("submission_drafts: audit insert failed (draft=%s): %v", draftID, err)
}
if err := writeSubmissionDraftProjectEvent(bgCtx, d, resolved, filename); err != nil {
@@ -538,6 +597,82 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
}
}
// exportSubmissionDraft is the shared render entry point used by both
// the project-scoped and global export handlers (t-paliad-313 Slice B).
// Branches on draft.BaseID: if set AND the base + bytes resolve, the
// Composer pipeline assembles the document; otherwise the v1
// template-only path stays the fallback. composerUsed = true means the
// metadata jsonb on the audit row carries "composer": true so admins
// can tell the two paths apart in the feed.
//
// Returns (bytes, resolved-bag, templateSHA, composerUsed, err).
func exportSubmissionDraft(ctx context.Context, d *services.SubmissionDraft) ([]byte, *services.SubmissionVarsResult, string, bool, error) {
if d.BaseID != nil && dbSvc.submissionBase != nil && dbSvc.submissionSection != nil && dbSvc.submissionComposer != nil {
base, err := dbSvc.submissionBase.GetByID(ctx, *d.BaseID)
switch {
case err == nil:
baseBytes, baseSHA, err := fetchComposerBaseBytes(ctx, base)
if err == nil {
sections, err := dbSvc.submissionSection.ListForDraft(ctx, d.ID)
if err != nil {
return nil, nil, "", false, fmt.Errorf("list sections: %w", err)
}
bag, resolved, err := dbSvc.submissionDraft.BuildRenderBag(ctx, d)
if err != nil {
return nil, nil, "", false, err
}
docx, err := dbSvc.submissionComposer.Compose(ctx, services.ComposeOptions{
Sections: sections,
Base: base,
BaseBytes: baseBytes,
Lang: resolved.Lang,
Vars: bag,
Missing: services.DefaultMissingMarker(resolved.Lang),
})
if err != nil {
return nil, nil, "", false, fmt.Errorf("composer: %w", err)
}
return docx, resolved, baseSHA, true, nil
}
log.Printf("submission_drafts: composer base bytes fetch failed (draft=%s base=%s): %v — falling back to v1 path", d.ID, base.Slug, err)
case errors.Is(err, services.ErrBaseNotFound):
log.Printf("submission_drafts: composer base missing (draft=%s base_id=%s) — falling back to v1 path", d.ID, *d.BaseID)
default:
return nil, nil, "", false, fmt.Errorf("composer base lookup: %w", err)
}
}
// v1 fallback: template-only render via resolveSubmissionTemplate +
// SubmissionDraftService.Export. Unchanged behaviour for
// pre-Composer drafts.
tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
if err != nil {
return nil, nil, "", false, fmt.Errorf("template upstream: %w", err)
}
docx, resolved, err := dbSvc.submissionDraft.Export(ctx, d, tplBytes)
if err != nil {
return nil, nil, "", false, fmt.Errorf("render: %w", err)
}
return docx, resolved, tplSHA, false, nil
}
// writeSubmissionExportError maps a render-time error to an HTTP
// response. The shape mirrors what the handlers used to inline.
func writeSubmissionExportError(w http.ResponseWriter, err error) {
if err == nil {
return
}
msg := err.Error()
switch {
case strings.Contains(msg, "template upstream"):
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
case strings.Contains(msg, "composer:") || strings.Contains(msg, "render:") || strings.Contains(msg, "list sections"):
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
}
}
// handleSubmissionDraftPage serves dist/submission-draft.html for the
// dedicated draft editor at /projects/{id}/submissions/{code}/draft
// (and …/draft/{draft_id}). Project visibility is enforced server-side
@@ -713,6 +848,11 @@ type globalDraftPatchInput struct {
// SelectedParties: present-but-empty array resets to "all parties",
// present non-empty array restricts to subset, absent = no change.
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
// BaseID + baseIDProvided mirror the ProjectID pattern — present
// (regardless of value) means "set"; absent means "no change". Set
// by UnmarshalJSON. t-paliad-313 Composer Slice A.
BaseID *uuid.UUID `json:"base_id,omitempty"`
baseIDProvided bool
}
func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
@@ -722,6 +862,7 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
Language *string `json:"language,omitempty"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
BaseID *uuid.UUID `json:"base_id,omitempty"`
}
var a alias
if err := json.Unmarshal(data, &a); err != nil {
@@ -732,12 +873,15 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
g.Language = a.Language
g.ProjectID = a.ProjectID
g.SelectedParties = a.SelectedParties
// Detect whether "project_id" was present in the JSON object.
g.BaseID = a.BaseID
// Detect whether "project_id" / "base_id" were present in the JSON
// object.
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
_, g.projectIDProvided = raw["project_id"]
_, g.baseIDProvided = raw["base_id"]
return nil
}
@@ -778,6 +922,10 @@ func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
pid := in.ProjectID // may be nil → detach
patch.ProjectID = &pid
}
if in.baseIDProvided {
bid := in.BaseID // may be nil → clear
patch.BaseID = &bid
}
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
if err != nil {
@@ -890,16 +1038,10 @@ func handleGlobalExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
writeSubmissionDraftServiceError(w, err)
return
}
tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
docx, resolved, tplSHA, composerUsed, err := exportSubmissionDraft(ctx, d)
if err != nil {
log.Printf("submission_drafts: export template fetch (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
return
}
docx, resolved, err := dbSvc.submissionDraft.Export(ctx, d, tplBytes)
if err != nil {
log.Printf("submission_drafts: export render (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
log.Printf("submission_drafts: export (draft=%s): %v", draftID, err)
writeSubmissionExportError(w, err)
return
}
@@ -910,7 +1052,7 @@ func handleGlobalExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
if err := dbSvc.submissionDraft.MarkExported(bgCtx, d.ID, tplSHA); err != nil {
log.Printf("submission_drafts: mark exported (draft=%s): %v", draftID, err)
}
if err := writeSubmissionDraftAuditRow(bgCtx, resolved.User, d, filename, tplSHA); err != nil {
if err := writeSubmissionDraftAuditRow(bgCtx, resolved.User, d, filename, tplSHA, composerUsed); err != nil {
log.Printf("submission_drafts: audit insert failed (draft=%s): %v", draftID, err)
}
if err := writeSubmissionDraftProjectEvent(bgCtx, d, resolved, filename); err != nil {
@@ -952,6 +1094,30 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
Lang: lang,
HasTemplate: true,
AvailableParties: []submissionDraftPartyJSON{},
Sections: []submissionSectionJSON{},
}
// Composer Slice A — surface seeded sections (read-only). Empty
// when the draft has no base + no section rows (pre-Composer
// drafts that haven't been auto-upgraded — that's Slice C).
if dbSvc.submissionSection != nil {
secs, err := dbSvc.submissionSection.ListForDraft(ctx, d.ID)
if err != nil {
return nil, err
}
for _, sec := range secs {
view.Sections = append(view.Sections, submissionSectionJSON{
ID: sec.ID,
SectionKey: sec.SectionKey,
OrderIndex: sec.OrderIndex,
Kind: sec.Kind,
LabelDE: sec.LabelDE,
LabelEN: sec.LabelEN,
Included: sec.Included,
ContentMDDE: sec.ContentMDDE,
ContentMDEN: sec.ContentMDEN,
})
}
}
merged, resolved, err := dbSvc.submissionDraft.BuildRenderBag(ctx, d)
@@ -1135,6 +1301,10 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
if lang == "" {
lang = "de"
}
meta := d.ComposerMeta
if meta == nil {
meta = map[string]any{}
}
return submissionDraftJSON{
ID: d.ID,
ProjectID: d.ProjectID,
@@ -1147,6 +1317,8 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
LastExportedAt: d.LastExportedAt,
LastExportedSHA: d.LastExportedSHA,
LastImportedAt: d.LastImportedAt,
BaseID: d.BaseID,
ComposerMeta: meta,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
}
@@ -1160,7 +1332,7 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
// 'user' with scope_root = draft.user_id; the audit feed therefore
// surfaces these exports on the user's row rather than against a
// (non-existent) project.
func writeSubmissionDraftAuditRow(ctx context.Context, user *models.User, d *services.SubmissionDraft, filename, templateSHA string) error {
func writeSubmissionDraftAuditRow(ctx context.Context, user *models.User, d *services.SubmissionDraft, filename, templateSHA string, composerUsed bool) error {
meta := map[string]any{
"submission_code": d.SubmissionCode,
"draft_id": d.ID.String(),
@@ -1168,6 +1340,15 @@ func writeSubmissionDraftAuditRow(ctx context.Context, user *models.User, d *ser
"filename": filename,
"template_sha": templateSHA,
}
// t-paliad-313 Slice B — composer flag in metadata so admins can
// tell the two render paths apart in the audit feed without
// adding a new event_type.
if composerUsed {
meta["composer"] = true
if d.BaseID != nil {
meta["base_id"] = d.BaseID.String()
}
}
body, _ := json.Marshal(meta)
var (
actorID any

View File

@@ -0,0 +1,148 @@
package handlers
// Submission section handlers — Composer Slice B (t-paliad-313). Backs
// the inline editor on /projects/{id}/submissions/{code}/draft/{draft_id}
// where the lawyer types prose into each section.
//
// Endpoint:
//
// PATCH /api/submission-drafts/{draft_id}/sections/{section_id}
//
// Body shape (all fields optional — absent = no change):
//
// {
// "content_md_de": "...",
// "content_md_en": "...",
// "included": true|false,
// "label_de": "...",
// "label_en": "...",
// "order_index": 3
// }
//
// Visibility: ownership of the draft is checked via
// SubmissionDraftService.Get (404 on no-access), then the section is
// fetched + verified to belong to that draft. The DB-side RLS policy
// (mig 148) enforces the same gate independently.
//
// Returns 200 + the refreshed section row on success.
//
// This is global-scoped (no /projects/{id}/ prefix) because the
// section's owning draft already carries the project_id; routing on
// section_id alone keeps the URL shape stable across project-scoped
// and project-less drafts.
import (
"context"
"encoding/json"
"errors"
"net/http"
"time"
"mgit.msbls.de/m/paliad/internal/services"
)
// submissionSectionPatchInput is the JSON shape accepted by PATCH.
type submissionSectionPatchInput struct {
ContentMDDE *string `json:"content_md_de,omitempty"`
ContentMDEN *string `json:"content_md_en,omitempty"`
Included *bool `json:"included,omitempty"`
LabelDE *string `json:"label_de,omitempty"`
LabelEN *string `json:"label_en,omitempty"`
OrderIndex *int `json:"order_index,omitempty"`
}
// submissionSectionPatchTimeout caps the round-trip.
const submissionSectionPatchTimeout = 10 * time.Second
func handlePatchSubmissionSection(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.submissionDraft == nil || dbSvc.submissionSection == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission sections not configured"})
return
}
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
if !ok {
return
}
sectionID, ok := parseUUIDPath(w, r, "section_id", "section id")
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), submissionSectionPatchTimeout)
defer cancel()
// Owner-scope on the draft (RLS mirror; this gives us the typed
// 404 + the path for the "section belongs to a different draft"
// case below).
draft, err := dbSvc.submissionDraft.Get(ctx, uid, draftID)
if err != nil {
writeSubmissionDraftServiceError(w, err)
return
}
existing, err := dbSvc.submissionSection.Get(ctx, sectionID)
if err != nil {
if errors.Is(err, services.ErrSubmissionSectionNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
return
}
writeServiceError(w, err)
return
}
if existing.DraftID != draft.ID {
// Section exists but doesn't belong to this draft — surface as
// 404 to keep the "no fishing for foreign drafts" property.
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
return
}
var input submissionSectionPatchInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
patch := services.SectionPatch{
ContentMDDE: input.ContentMDDE,
ContentMDEN: input.ContentMDEN,
Included: input.Included,
LabelDE: input.LabelDE,
LabelEN: input.LabelEN,
OrderIndex: input.OrderIndex,
}
updated, err := dbSvc.submissionSection.Update(ctx, sectionID, patch)
if err != nil {
if errors.Is(err, services.ErrSubmissionSectionNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, sectionJSONFromService(updated))
}
// sectionJSONFromService projects a services.SubmissionSection into the
// JSON shape the editor consumes — the same shape buildSubmissionDraftView
// emits under .sections[].
func sectionJSONFromService(sec *services.SubmissionSection) submissionSectionJSON {
return submissionSectionJSON{
ID: sec.ID,
SectionKey: sec.SectionKey,
OrderIndex: sec.OrderIndex,
Kind: sec.Kind,
LabelDE: sec.LabelDE,
LabelEN: sec.LabelEN,
Included: sec.Included,
ContentMDDE: sec.ContentMDDE,
ContentMDEN: sec.ContentMDEN,
}
}

View File

@@ -200,7 +200,7 @@ func loadSubmissionCatalog(ctx context.Context, projectProceedingTypeID *int) ([
pt.code AS proceeding_code,
pt.name AS proceeding_name,
pt.name_en AS proceeding_name_en
FROM paliad.deadline_rules dr
FROM paliad.deadline_rules_unified dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
WHERE dr.is_active = true
AND dr.lifecycle_state = 'published'
@@ -208,7 +208,7 @@ func loadSubmissionCatalog(ctx context.Context, projectProceedingTypeID *int) ([
AND dr.submission_code IS NOT NULL
AND dr.submission_code <> ''
AND pt.is_active = true
ORDER BY pt.code ASC, dr.submission_code ASC`)
ORDER BY pt.code ASC, dr.sequence_order ASC, dr.submission_code ASC`)
if err != nil {
return nil, nil, err
}

View File

@@ -220,6 +220,14 @@ func (s *AichatPaliadinService) RunTurnStream(ctx context.Context, req TurnReque
}
if streamErr != nil {
// Aichat persona without streaming support — graceful fallback to
// the one-shot /chat/turn endpoint. Same body shape; we adapt the
// non-streaming response into a single StreamChunk so the caller
// sees identical event ordering.
if strings.Contains(streamErr.Error(), "unsupported_streaming") {
log.Printf("paliadin: persona %q lacks streaming support — falling back to one-shot turn %s", s.cfg.Persona, turnID)
return s.fallbackOneShotFromStream(ctx, turnID, body, events, startedAt, session)
}
// Don't overwrite an existing error_code we may have set above.
_ = s.markTurnError(ctx, turnID, classifyAichatError(streamErr))
return nil, streamErr
@@ -255,6 +263,80 @@ func (s *AichatPaliadinService) RunTurnStream(ctx context.Context, req TurnReque
}, nil
}
// fallbackOneShotFromStream runs the same `body` against aichat's
// non-streaming /chat/turn endpoint and adapts the response into the
// StreamingPaliadin contract — a single StreamChunk + StreamMeta +
// StreamConversation, followed by `events` being closed by the
// outer RunTurnStream's defer. Used when the configured persona doesn't
// support streaming (aichat returns HTTP 400 unsupported_streaming).
//
// Identical persistence shape as the one-shot RunTurn: completeTurn +
// markPrimed/clearPrimed. No new turn row (already inserted by
// RunTurnStream). No primer rebuild (already in body).
func (s *AichatPaliadinService) fallbackOneShotFromStream(
ctx context.Context,
turnID uuid.UUID,
body aichatTurnRequest,
events chan<- StreamEvent,
startedAt time.Time,
session string,
) (*TurnResult, error) {
var resp aichatTurnResponse
if err := s.callHTTP(ctx, http.MethodPost, "/chat/turn", body, &resp); err != nil {
_ = s.markTurnError(ctx, turnID, classifyAichatError(err))
safeSendStream(ctx, events, StreamEvent{
Kind: StreamError,
Code: classifyAichatError(err),
Message: err.Error(),
})
return nil, err
}
if resp.PaneSpawned {
s.clearPrimed(session)
} else {
s.markPrimed(session)
}
cleanBody := resp.Response
tokens := approxTokenCount(cleanBody)
chipCount := countChips(cleanBody)
finished := time.Now().UTC()
durationMS := int(finished.Sub(startedAt) / time.Millisecond)
tmeta := trailerMeta{
UsedTools: resp.Meta.UsedTools,
ClassifierTag: resp.Meta.ClassifierTag,
RowsSeen: coerceAichatRowsSeen(resp.Meta.RowsSeen),
}
// Emit the response as a single chunk so the frontend renders it.
safeSendStream(ctx, events, StreamEvent{
Kind: StreamChunk,
Content: cleanBody,
})
safeSendStream(ctx, events, StreamEvent{
Kind: StreamMeta,
UsedTools: tmeta.UsedTools,
ClassifierTag: tmeta.ClassifierTag,
RowsSeen: tmeta.RowsSeen,
})
if err := s.completeTurn(ctx, turnID, finished, durationMS, cleanBody, tokens, tmeta, chipCount); err != nil {
log.Printf("paliadin: complete turn %s (fallback one-shot): %v", turnID, err)
}
return &TurnResult{
TurnID: turnID,
Response: cleanBody,
UsedTools: tmeta.UsedTools,
RowsSeen: tmeta.RowsSeen,
ChipCount: chipCount,
ClassifierTag: tmeta.ClassifierTag,
DurationMS: durationMS,
}, nil
}
// streamFrame is one decoded SSE event.
type streamFrame struct {
event string // "" → default (data:) event

View File

@@ -0,0 +1,99 @@
package services
import (
"bytes"
"context"
"os"
"strings"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
// TestResolveOrgSheets_LiveSchemaSnapshot probes the live paliad schema
// the way the backup runner does at the start of every run, then asserts
// that every spec the registry declares either keeps all its ORDER BY
// columns or — if any are missing — composes a fallback SELECT that the
// DB can still execute. Catches the m/paliad#140 class of bug
// (hardcoded ORDER BY against a renamed column) before deploy.
//
// Skipped when TEST_DATABASE_URL is unset. Read-only: opens a
// REPEATABLE READ tx, never writes.
func TestResolveOrgSheets_LiveSchemaSnapshot(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
specs := orgSheetSpecs()
sheets, err := resolveOrgSheets(ctx, pool, specs)
if err != nil {
t.Fatalf("resolveOrgSheets: %v", err)
}
if len(sheets) != len(specs) {
t.Fatalf("resolved %d sheets, want %d", len(sheets), len(specs))
}
// Each resolved SELECT must run cleanly against the live schema.
// We LIMIT 1 inside a sub-SELECT so we don't materialise the full
// table (some are large) but still exercise the ORDER BY clause.
for _, sq := range sheets {
wrapped := `SELECT * FROM (` + sq.SQL + `) _wrap LIMIT 1`
if _, err := pool.QueryxContext(ctx, wrapped, sq.Args...); err != nil {
t.Errorf("sheet %q SQL failed: %v\nSQL: %s", sq.SheetName, err, sq.SQL)
}
}
}
// TestWriteOrg_LiveSmoke runs the full ExportService.WriteOrg pipeline
// against a real DB: schema probe, REPEATABLE READ tx, every sheet
// query, xlsx + json + per-sheet CSV assembly, outer zip framing.
// Discards the bytes — this is a "does it crash" smoke, the bug class
// it catches is exactly the one from m/paliad#140 (hardcoded ORDER BY
// against a missing column).
//
// Skipped when TEST_DATABASE_URL is unset.
func TestWriteOrg_LiveSmoke(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
svc := NewExportService(pool, "test-firm")
var buf bytes.Buffer
meta, err := svc.WriteOrg(context.Background(), &buf, ExportSpec{
ActorID: uuid.New(),
ActorEmail: "backup-smoke@test.local",
ActorLabel: "Backup Smoke",
})
if err != nil {
t.Fatalf("WriteOrg: %v", err)
}
if buf.Len() == 0 {
t.Fatalf("WriteOrg wrote no bytes")
}
// Spot-check meta fills.
if meta.Scope != ExportScopeOrg {
t.Errorf("meta.Scope = %q, want %q", meta.Scope, ExportScopeOrg)
}
if len(meta.RowCounts) != len(orgSheetSpecs()) {
t.Errorf("meta.RowCounts has %d entries, want %d (one per sheet)", len(meta.RowCounts), len(orgSheetSpecs()))
}
// The bytes are a zip; the first 4 bytes are PK\x03\x04 for a non-empty zip.
if buf.Len() >= 4 && !strings.HasPrefix(buf.String()[:4], "PK\x03\x04") {
t.Errorf("bundle bytes don't look like a zip (first bytes: %x)", buf.Bytes()[:4])
}
}

View File

@@ -6,8 +6,10 @@ package services
// it would live in backup_service_live_test.go under TEST_DATABASE_URL.
// This file covers the bits that don't need a database:
//
// - orgSheetQueries registry shape: no duplicates, no excluded
// - orgSheetSpecs registry shape: no duplicates, no excluded
// paliadin sheets, predictable prefix split between entity and ref.
// - composeOrgSheetSQL drift-resistance: missing ORDER BY cols drop,
// SQL override path bypasses the builder, all-missing → no clause.
// - LocalDiskStore Put / Get / Delete round-trip, key validation,
// URI traversal rejection.
@@ -22,60 +24,216 @@ import (
)
// ---------------------------------------------------------------------------
// orgSheetQueries registry
// orgSheetSpecs registry
// ---------------------------------------------------------------------------
func TestOrgSheetQueries_NoDuplicates(t *testing.T) {
func TestOrgSheetSpecs_NoDuplicates(t *testing.T) {
seen := map[string]bool{}
for _, sq := range orgSheetQueries() {
if seen[sq.SheetName] {
t.Fatalf("duplicate sheet name in orgSheetQueries: %q", sq.SheetName)
for _, sp := range orgSheetSpecs() {
if seen[sp.SheetName] {
t.Fatalf("duplicate sheet name in orgSheetSpecs: %q", sp.SheetName)
}
seen[sq.SheetName] = true
seen[sp.SheetName] = true
}
}
func TestOrgSheetQueries_ExcludesPaliadinTables(t *testing.T) {
func TestOrgSheetSpecs_ExcludesPaliadinTables(t *testing.T) {
// m's t-paliad-214 Q5 decision + this design's §11 Q3 default:
// paliadin_turns and paliadin_aichat_conversation must be ABSENT
// from the registry (structural exclusion, not just column-drop).
for _, sq := range orgSheetQueries() {
name := sq.SheetName
for _, sp := range orgSheetSpecs() {
name := sp.SheetName
if strings.Contains(name, "paliadin") {
t.Fatalf("orgSheetQueries leaked paliadin sheet: %q (m's Q3 mandates structural exclusion)", name)
t.Fatalf("orgSheetSpecs leaked paliadin sheet: %q (m's Q3 mandates structural exclusion)", name)
}
// Belt-and-braces: SQL bodies should not reference the tables
// either (no UNION joins, no subqueries pulling them in).
if strings.Contains(sq.SQL, "paliadin_turns") || strings.Contains(sq.SQL, "paliadin_aichat_conversation") {
t.Fatalf("orgSheetQueries[%q] SQL references a paliadin table: %s", name, sq.SQL)
if strings.Contains(sp.Table, "paliadin") {
t.Fatalf("orgSheetSpecs[%q].Table references a paliadin table: %s", name, sp.Table)
}
// Belt-and-braces: SQL override bodies (the few sheets that
// bypass the Table+OrderBy builder) also can't pull paliadin
// tables in through UNION/subquery.
if strings.Contains(sp.SQL, "paliadin_turns") || strings.Contains(sp.SQL, "paliadin_aichat_conversation") {
t.Fatalf("orgSheetSpecs[%q] SQL references a paliadin table: %s", name, sp.SQL)
}
}
}
func TestOrgSheetQueries_RefSheetsPrefixed(t *testing.T) {
func TestOrgSheetSpecs_RefSheetsPrefixed(t *testing.T) {
// Every sheet whose data is read-only reference material is
// expected to use the `ref__` prefix. The writer's downstream
// consumers rely on this convention to group reference data
// visually in the workbook.
for _, sq := range orgSheetQueries() {
if !strings.HasPrefix(sq.SheetName, "ref__") {
for _, sp := range orgSheetSpecs() {
if !strings.HasPrefix(sp.SheetName, "ref__") {
continue
}
// Reference sheets shouldn't carry per-row WHERE clauses (they
// dump the whole reference table for portability).
if strings.Contains(strings.ToUpper(sq.SQL), "WHERE") {
t.Fatalf("orgSheetQueries[%q] is ref__ but has a WHERE clause; reference sheets dump the whole table", sq.SheetName)
// dump the whole reference table for portability). Only
// applies to the SQL-override path; the Table+OrderBy builder
// never emits a WHERE.
if sp.SQL != "" && strings.Contains(strings.ToUpper(sp.SQL), "WHERE") {
t.Fatalf("orgSheetSpecs[%q] is ref__ but has a WHERE clause; reference sheets dump the whole table", sp.SheetName)
}
}
}
func TestOrgSheetQueries_OrderByForDeterminism(t *testing.T) {
// Every sheet must specify an ORDER BY so the byte-deterministic
// contract from t-paliad-214 §3 holds across runs.
for _, sq := range orgSheetQueries() {
if !strings.Contains(strings.ToUpper(sq.SQL), "ORDER BY") {
t.Fatalf("orgSheetQueries[%q] missing ORDER BY (determinism contract): %s", sq.SheetName, sq.SQL)
func TestOrgSheetSpecs_OrderByForDeterminism(t *testing.T) {
// Every sheet must declare a stable sort: either OrderBy on the
// Table+OrderBy path, or ORDER BY in the SQL override. Keeps the
// byte-deterministic contract from t-paliad-214 §3 across runs.
//
// (Drift removes ORDER BY columns at runtime, but only ones that
// no longer exist in the schema — the spec-level declaration is
// still required so we know what *should* be ordered.)
for _, sp := range orgSheetSpecs() {
if sp.SQL != "" {
if !strings.Contains(strings.ToUpper(sp.SQL), "ORDER BY") {
t.Fatalf("orgSheetSpecs[%q] SQL override missing ORDER BY (determinism contract): %s", sp.SheetName, sp.SQL)
}
continue
}
if len(sp.OrderBy) == 0 {
t.Fatalf("orgSheetSpecs[%q] has no OrderBy and no SQL override (determinism contract)", sp.SheetName)
}
}
}
// ---------------------------------------------------------------------------
// composeOrgSheetSQL — drift-resistant SQL builder
// ---------------------------------------------------------------------------
func TestComposeOrgSheetSQL_AllColumnsPresent(t *testing.T) {
spec := orgSheetSpec{
SheetName: "appointments",
Table: "paliad.appointments",
OrderBy: []string{"id"},
}
cols := map[string]map[string]struct{}{
"appointments": {"id": {}, "project_id": {}},
}
got, dropped := composeOrgSheetSQL(spec, cols)
want := "SELECT * FROM paliad.appointments ORDER BY id"
if got != want {
t.Fatalf("got SQL %q, want %q", got, want)
}
if len(dropped) != 0 {
t.Fatalf("expected no dropped columns, got %v", dropped)
}
}
func TestComposeOrgSheetSQL_DropsMissingOrderByColumn(t *testing.T) {
// The original bug from m/paliad#138 reproduced in unit form:
// orderBy references a column the table doesn't have.
spec := orgSheetSpec{
SheetName: "appointment_caldav_targets",
Table: "paliad.appointment_caldav_targets",
OrderBy: []string{"appointment_id", "calendar_binding_id"}, // wrong: real col is binding_id
}
cols := map[string]map[string]struct{}{
"appointment_caldav_targets": {
"appointment_id": {},
"binding_id": {},
},
}
got, dropped := composeOrgSheetSQL(spec, cols)
want := "SELECT * FROM paliad.appointment_caldav_targets ORDER BY appointment_id"
if got != want {
t.Fatalf("got SQL %q, want %q", got, want)
}
if len(dropped) != 1 || dropped[0] != "calendar_binding_id" {
t.Fatalf("expected dropped=[calendar_binding_id], got %v", dropped)
}
}
func TestComposeOrgSheetSQL_AllOrderByMissing_NoClause(t *testing.T) {
// If every declared ORDER BY column is gone, the builder still
// produces a runnable SELECT — without ORDER BY. The export
// succeeds; the order across runs is no longer deterministic for
// this sheet until the spec is updated. WARN log alerts the
// operator (verified in TestResolveOrgSheets_LogsWarnings).
spec := orgSheetSpec{
SheetName: "ghost",
Table: "paliad.ghost",
OrderBy: []string{"missing_a", "missing_b"},
}
cols := map[string]map[string]struct{}{
"ghost": {"unrelated": {}},
}
got, dropped := composeOrgSheetSQL(spec, cols)
want := "SELECT * FROM paliad.ghost"
if got != want {
t.Fatalf("got SQL %q, want %q", got, want)
}
if len(dropped) != 2 {
t.Fatalf("expected 2 dropped columns, got %v", dropped)
}
}
func TestComposeOrgSheetSQL_SQLOverride_BypassesBuilder(t *testing.T) {
// When a sheet declares SQL, the builder MUST NOT touch it — even
// if the column knowledge would suggest a change. Custom
// projections (documents drops ai_extracted) and special-case
// joins both rely on this.
spec := orgSheetSpec{
SheetName: "documents",
Table: "paliad.documents", // should be ignored
OrderBy: []string{"id"}, // should be ignored
SQL: "SELECT id, title FROM paliad.documents ORDER BY id",
}
cols := map[string]map[string]struct{}{
"documents": {}, // empty → would drop everything if builder ran
}
got, dropped := composeOrgSheetSQL(spec, cols)
if got != spec.SQL {
t.Fatalf("SQL override mutated: got %q, want %q", got, spec.SQL)
}
if len(dropped) != 0 {
t.Fatalf("override path should never report drops; got %v", dropped)
}
}
func TestComposeOrgSheetSQL_UnknownTable_DropsAllOrderBy(t *testing.T) {
// A table missing entirely from the schema snapshot is treated as
// "no columns known" — every ORDER BY column gets dropped, but
// the SELECT still emits (so a stale registry doesn't crash the
// backup; the operator gets WARNs to fix it).
spec := orgSheetSpec{
SheetName: "renamed_table",
Table: "paliad.renamed_table",
OrderBy: []string{"id"},
}
got, dropped := composeOrgSheetSQL(spec, map[string]map[string]struct{}{})
want := "SELECT * FROM paliad.renamed_table"
if got != want {
t.Fatalf("got SQL %q, want %q", got, want)
}
if len(dropped) != 1 || dropped[0] != "id" {
t.Fatalf("expected dropped=[id], got %v", dropped)
}
}
func TestComposeOrgSheetSQL_PreservesOrderByOrder(t *testing.T) {
// Multi-column OrderBy must keep its declared order, with kept
// columns concatenated in the same sequence. Determinism contract
// from t-paliad-214 §3 depends on this.
spec := orgSheetSpec{
SheetName: "partner_unit_members",
Table: "paliad.partner_unit_members",
OrderBy: []string{"partner_unit_id", "missing_middle", "user_id"},
}
cols := map[string]map[string]struct{}{
"partner_unit_members": {
"partner_unit_id": {},
"user_id": {},
},
}
got, dropped := composeOrgSheetSQL(spec, cols)
want := "SELECT * FROM paliad.partner_unit_members ORDER BY partner_unit_id, user_id"
if got != want {
t.Fatalf("got SQL %q, want %q", got, want)
}
if len(dropped) != 1 || dropped[0] != "missing_middle" {
t.Fatalf("expected dropped=[missing_middle], got %v", dropped)
}
}

View File

@@ -55,13 +55,13 @@ func (s *DeadlineRuleService) List(ctx context.Context, proceedingTypeID *int) (
if proceedingTypeID != nil {
err = s.db.SelectContext(ctx, &rules,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE proceeding_type_id = $1 AND is_active = true
ORDER BY sequence_order`, *proceedingTypeID)
} else {
err = s.db.SelectContext(ctx, &rules,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE is_active = true
ORDER BY proceeding_type_id, sequence_order`)
}
@@ -100,7 +100,7 @@ func (s *DeadlineRuleService) hydrateConceptDefaultEventTypes(ctx context.Contex
}
query, args, err := sqlx.In(
`SELECT dr.id AS rule_id, j.event_type_id
FROM paliad.deadline_rules dr
FROM paliad.deadline_rules_unified dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
JOIN paliad.deadline_concept_event_types j
ON j.concept_id = dr.concept_id
@@ -152,7 +152,7 @@ func (s *DeadlineRuleService) GetRuleTree(ctx context.Context, proceedingTypeCod
var rules []models.DeadlineRule
if err := s.db.SelectContext(ctx, &rules,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE proceeding_type_id = $1 AND is_active = true
ORDER BY sequence_order`, pt.ID); err != nil {
return nil, fmt.Errorf("list rules for %q: %w", proceedingTypeCode, err)
@@ -175,10 +175,10 @@ func (s *DeadlineRuleService) GetFullTimeline(ctx context.Context, proceedingTyp
var rules []models.DeadlineRule
err := s.db.SelectContext(ctx, &rules, `
WITH RECURSIVE tree AS (
SELECT * FROM paliad.deadline_rules
SELECT * FROM paliad.deadline_rules_unified
WHERE proceeding_type_id = $1 AND parent_id IS NULL AND is_active = true
UNION ALL
SELECT dr.* FROM paliad.deadline_rules dr
SELECT dr.* FROM paliad.deadline_rules_unified dr
JOIN tree t ON dr.parent_id = t.id
WHERE dr.is_active = true
)
@@ -196,7 +196,7 @@ func (s *DeadlineRuleService) GetByIDs(ctx context.Context, ids []uuid.UUID) ([]
}
query, args, err := sqlx.In(
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE id IN (?) AND is_active = true
ORDER BY sequence_order`, ids)
if err != nil {
@@ -264,7 +264,7 @@ func (s *DeadlineRuleService) ListByTriggerEvent(ctx context.Context, triggerEve
var rules []models.DeadlineRule
if err := s.db.SelectContext(ctx, &rules,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE trigger_event_id = $1
AND is_active = true
ORDER BY sequence_order`, triggerEventID); err != nil {
@@ -292,7 +292,7 @@ func (s *DeadlineRuleService) ListByProceedingTypeIDs(ctx context.Context, ids [
}
query, args, err := sqlx.In(
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE proceeding_type_id IN (?)
AND is_active = true
ORDER BY proceeding_type_id, sequence_order`, ids)
@@ -327,7 +327,7 @@ func (s *DeadlineRuleService) ListByConcept(ctx context.Context, conceptID uuid.
var rules []models.DeadlineRule
if err := s.db.SelectContext(ctx, &rules,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE concept_id = $1
AND is_active = true
ORDER BY proceeding_type_id NULLS LAST, sequence_order`, conceptID); err != nil {

View File

@@ -272,7 +272,7 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
ar.requester_kind AS requester_kind
FROM paliad.deadlines f
JOIN paliad.projects p ON p.id = f.project_id
LEFT JOIN paliad.deadline_rules r ON r.id = f.rule_id
LEFT JOIN paliad.deadline_rules_unified r ON r.id = f.rule_id
LEFT JOIN paliad.approval_requests ar ON ar.id = f.pending_request_id
WHERE ` + strings.Join(conds, " AND ") + `
ORDER BY f.due_date ASC, f.created_at DESC`

View File

@@ -168,7 +168,7 @@ func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int
COALESCE(timing, 'after') AS timing,
deadline_notes, deadline_notes_en, alt_duration_value, alt_duration_unit,
combine_op, rule_codes
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE trigger_event_id = $1 AND is_active = true
ORDER BY sequence_order`, triggerEventID)
if err != nil {

View File

@@ -46,6 +46,7 @@ import (
"encoding/csv"
"fmt"
"io"
"log/slog"
"regexp"
"sort"
"strings"
@@ -297,7 +298,10 @@ func (s *ExportService) WriteOrg(ctx context.Context, w io.Writer, spec ExportSp
// is just bookkeeping that releases the snapshot.
defer func() { _ = tx.Rollback() }()
sheets := orgSheetQueries()
sheets, err := resolveOrgSheets(ctx, tx, orgSheetSpecs())
if err != nil {
return meta, err
}
if err := s.writeBundle(ctx, tx, w, sheets, &meta); err != nil {
return meta, err
}
@@ -1138,7 +1142,7 @@ func personalSheetQueries(actorID uuid.UUID) []sheetQuery {
},
{
SheetName: "ref__deadline_rules",
SQL: `SELECT * FROM paliad.deadline_rules ORDER BY id`,
SQL: `SELECT * FROM paliad.deadline_rules_unified ORDER BY id`,
},
{
SheetName: "ref__deadline_concepts",
@@ -1518,7 +1522,7 @@ SELECT 'partner_unit_default'::text AS source,
{SheetName: "ref__proceeding_types", SQL: `SELECT * FROM paliad.proceeding_types ORDER BY id`},
{SheetName: "ref__event_types", SQL: `SELECT * FROM paliad.event_types ORDER BY id`},
{SheetName: "ref__event_categories", SQL: `SELECT * FROM paliad.event_categories ORDER BY id`},
{SheetName: "ref__deadline_rules", SQL: `SELECT * FROM paliad.deadline_rules ORDER BY id`},
{SheetName: "ref__deadline_rules", SQL: `SELECT * FROM paliad.deadline_rules_unified ORDER BY id`},
{SheetName: "ref__deadline_concepts", SQL: `SELECT * FROM paliad.deadline_concepts ORDER BY id`},
{SheetName: "ref__courts", SQL: `SELECT * FROM paliad.courts ORDER BY id`},
{SheetName: "ref__countries", SQL: `SELECT * FROM paliad.countries ORDER BY code`},
@@ -1560,73 +1564,249 @@ SELECT 'partner_unit_default'::text AS source,
// secret|token|password|api_key|private_key on every sheet as a
// belt-and-braces filter. user_caldav_config.password_encrypted is
// explicitly named in DropColumns too.
func orgSheetQueries() []sheetQuery {
return []sheetQuery{
//
// Drift-resistance (m/paliad#140): each spec declares its desired
// ORDER BY columns as a list. At backup time the exporter probes
// information_schema.columns for the live schema; any ORDER BY column
// that no longer exists is dropped (logged WARN). This way a column
// rename or removal never breaks a backup — the worst case is a sheet
// that loses sort stability until the spec is updated. A sheet whose
// ORDER BY columns are all gone still exports, just in pg's natural
// (unspecified) order.
//
// Custom column projections (e.g. documents drops ai_extracted) live
// in the SQL override field; if set, it bypasses the Table+OrderBy
// builder entirely. Use it sparingly — every override re-introduces
// drift risk for that sheet.
// orgSheetSpec declares one org-scope sheet for the drift-resistant
// builder. Either set SQL (free-form override) or set Table+OrderBy
// (let the builder compose `SELECT * FROM <Table> ORDER BY <existing>`).
type orgSheetSpec struct {
// SheetName lands in the workbook sheet and the JSON top-level key.
SheetName string
// Table is schema-qualified (e.g. "paliad.appointments"). Used only
// when SQL is empty. The schema/table form must be valid SQL
// identifiers — the builder splits on the dot, no quoting.
Table string
// OrderBy is the *desired* sort columns. Missing columns are
// dropped silently-with-a-WARN at build time; remaining columns
// keep their declared order. Empty/all-missing → no ORDER BY (still
// deterministic-within-a-snapshot under the REPEATABLE READ tx, but
// the order across runs may differ).
OrderBy []string
// SQL is an explicit override; if non-empty, Table+OrderBy are
// ignored entirely. Use only when the projection cannot be
// expressed as SELECT * (e.g. documents drops the ai_extracted
// jsonb column).
SQL string
// Args are positional arguments. Only meaningful with SQL override;
// the Table+OrderBy path takes no args.
Args []any
// DropColumns is an explicit list of column names to drop from the
// result regardless of the PII deny-regex.
DropColumns []string
}
func orgSheetSpecs() []orgSheetSpec {
return []orgSheetSpec{
// --- entity sheets (alphabetical) ---
{SheetName: "appointment_caldav_targets", SQL: `SELECT * FROM paliad.appointment_caldav_targets ORDER BY appointment_id, calendar_binding_id`},
{SheetName: "appointments", SQL: `SELECT * FROM paliad.appointments ORDER BY id`},
{SheetName: "approval_policies", SQL: `SELECT * FROM paliad.approval_policies ORDER BY id`},
{SheetName: "approval_requests", SQL: `SELECT * FROM paliad.approval_requests ORDER BY id`},
{SheetName: "appointment_caldav_targets", Table: "paliad.appointment_caldav_targets", OrderBy: []string{"appointment_id", "binding_id"}},
{SheetName: "appointments", Table: "paliad.appointments", OrderBy: []string{"id"}},
{SheetName: "approval_policies", Table: "paliad.approval_policies", OrderBy: []string{"id"}},
{SheetName: "approval_requests", Table: "paliad.approval_requests", OrderBy: []string{"id"}},
// backups is self-reflexive — including it makes "what backups
// have we taken" recoverable from any prior backup. Tiny table.
{SheetName: "backups", SQL: `SELECT * FROM paliad.backups ORDER BY started_at, id`},
{SheetName: "caldav_sync_log", SQL: `SELECT * FROM paliad.caldav_sync_log ORDER BY occurred_at, id`},
{SheetName: "checklist_instances", SQL: `SELECT * FROM paliad.checklist_instances ORDER BY id`},
{SheetName: "checklist_shares", SQL: `SELECT * FROM paliad.checklist_shares ORDER BY id`},
{SheetName: "checklists", SQL: `SELECT * FROM paliad.checklists ORDER BY id`},
{SheetName: "deadline_rule_audit", SQL: `SELECT * FROM paliad.deadline_rule_audit ORDER BY changed_at, id`},
{SheetName: "deadlines", SQL: `SELECT * FROM paliad.deadlines ORDER BY id`},
{SheetName: "backups", Table: "paliad.backups", OrderBy: []string{"started_at", "id"}},
{SheetName: "caldav_sync_log", Table: "paliad.caldav_sync_log", OrderBy: []string{"occurred_at", "id"}},
{SheetName: "checklist_instances", Table: "paliad.checklist_instances", OrderBy: []string{"id"}},
{SheetName: "checklist_shares", Table: "paliad.checklist_shares", OrderBy: []string{"id"}},
{SheetName: "checklists", Table: "paliad.checklists", OrderBy: []string{"id"}},
{SheetName: "deadline_rule_audit", Table: "paliad.deadline_rule_audit", OrderBy: []string{"changed_at", "id"}},
{SheetName: "deadlines", Table: "paliad.deadlines", OrderBy: []string{"id"}},
// documents: ai_extracted jsonb dropped (verbose AI prompts;
// matches the personal/project precedent). Binaries are not in
// the export — only metadata.
// the export — only metadata. Uses SQL override because the
// projection isn't SELECT *.
{
SheetName: "documents",
SQL: `SELECT id, project_id, title, doc_type, file_path, file_size, mime_type, uploaded_by, created_at, updated_at
FROM paliad.documents
ORDER BY id`,
},
{SheetName: "email_broadcasts", SQL: `SELECT * FROM paliad.email_broadcasts ORDER BY id`},
{SheetName: "email_template_versions", SQL: `SELECT * FROM paliad.email_template_versions ORDER BY id`},
{SheetName: "email_templates", SQL: `SELECT * FROM paliad.email_templates ORDER BY id`},
{SheetName: "firm_dashboard_default", SQL: `SELECT * FROM paliad.firm_dashboard_default ORDER BY id`},
{SheetName: "invitations", SQL: `SELECT * FROM paliad.invitations ORDER BY sent_at, id`},
{SheetName: "notes", SQL: `SELECT * FROM paliad.notes ORDER BY id`},
{SheetName: "parties", SQL: `SELECT * FROM paliad.parties ORDER BY id`},
{SheetName: "partner_unit_events", SQL: `SELECT * FROM paliad.partner_unit_events ORDER BY id`},
{SheetName: "partner_unit_members", SQL: `SELECT * FROM paliad.partner_unit_members ORDER BY partner_unit_id, user_id`},
{SheetName: "partner_units", SQL: `SELECT * FROM paliad.partner_units ORDER BY id`},
{SheetName: "policy_audit_log", SQL: `SELECT * FROM paliad.policy_audit_log ORDER BY changed_at, id`},
{SheetName: "project_events", SQL: `SELECT * FROM paliad.project_events ORDER BY id`},
{SheetName: "project_partner_units", SQL: `SELECT * FROM paliad.project_partner_units ORDER BY project_id, partner_unit_id`},
{SheetName: "project_teams", SQL: `SELECT * FROM paliad.project_teams ORDER BY project_id, user_id`},
{SheetName: "projects", SQL: `SELECT * FROM paliad.projects ORDER BY id`},
{SheetName: "reminder_log", SQL: `SELECT * FROM paliad.reminder_log ORDER BY sent_at, id`},
{SheetName: "submission_drafts", SQL: `SELECT * FROM paliad.submission_drafts ORDER BY id`},
{SheetName: "system_audit_log", SQL: `SELECT * FROM paliad.system_audit_log ORDER BY created_at, id`},
{SheetName: "email_broadcasts", Table: "paliad.email_broadcasts", OrderBy: []string{"id"}},
{SheetName: "email_template_versions", Table: "paliad.email_template_versions", OrderBy: []string{"id"}},
{SheetName: "email_templates", Table: "paliad.email_templates", OrderBy: []string{"key", "lang"}},
{SheetName: "firm_dashboard_default", Table: "paliad.firm_dashboard_default", OrderBy: []string{"id"}},
{SheetName: "invitations", Table: "paliad.invitations", OrderBy: []string{"sent_at", "id"}},
{SheetName: "notes", Table: "paliad.notes", OrderBy: []string{"id"}},
{SheetName: "parties", Table: "paliad.parties", OrderBy: []string{"id"}},
{SheetName: "partner_unit_events", Table: "paliad.partner_unit_events", OrderBy: []string{"id"}},
{SheetName: "partner_unit_members", Table: "paliad.partner_unit_members", OrderBy: []string{"partner_unit_id", "user_id"}},
{SheetName: "partner_units", Table: "paliad.partner_units", OrderBy: []string{"id"}},
{SheetName: "policy_audit_log", Table: "paliad.policy_audit_log", OrderBy: []string{"created_at", "id"}},
{SheetName: "project_events", Table: "paliad.project_events", OrderBy: []string{"id"}},
{SheetName: "project_partner_units", Table: "paliad.project_partner_units", OrderBy: []string{"project_id", "partner_unit_id"}},
{SheetName: "project_teams", Table: "paliad.project_teams", OrderBy: []string{"project_id", "user_id"}},
{SheetName: "projects", Table: "paliad.projects", OrderBy: []string{"id"}},
{SheetName: "reminder_log", Table: "paliad.reminder_log", OrderBy: []string{"sent_at", "id"}},
{SheetName: "submission_drafts", Table: "paliad.submission_drafts", OrderBy: []string{"id"}},
{SheetName: "system_audit_log", Table: "paliad.system_audit_log", OrderBy: []string{"created_at", "id"}},
{
SheetName: "user_caldav_config",
SQL: `SELECT * FROM paliad.user_caldav_config ORDER BY user_id`,
Table: "paliad.user_caldav_config",
OrderBy: []string{"user_id"},
DropColumns: []string{"password_encrypted"}, // belt-and-braces; piiColumnDenyRegex also catches it
},
{SheetName: "user_calendar_bindings", SQL: `SELECT * FROM paliad.user_calendar_bindings ORDER BY user_id, calendar_path`},
{SheetName: "user_card_layouts", SQL: `SELECT * FROM paliad.user_card_layouts ORDER BY id`},
{SheetName: "user_dashboard_layouts", SQL: `SELECT * FROM paliad.user_dashboard_layouts ORDER BY user_id`},
{SheetName: "user_pinned_projects", SQL: `SELECT * FROM paliad.user_pinned_projects ORDER BY user_id, project_id`},
{SheetName: "user_views", SQL: `SELECT * FROM paliad.user_views ORDER BY id`},
{SheetName: "users", SQL: `SELECT * FROM paliad.users ORDER BY id`},
{SheetName: "user_calendar_bindings", Table: "paliad.user_calendar_bindings", OrderBy: []string{"user_id", "calendar_path"}},
{SheetName: "user_card_layouts", Table: "paliad.user_card_layouts", OrderBy: []string{"id"}},
{SheetName: "user_dashboard_layouts", Table: "paliad.user_dashboard_layouts", OrderBy: []string{"user_id"}},
{SheetName: "user_pinned_projects", Table: "paliad.user_pinned_projects", OrderBy: []string{"user_id", "project_id"}},
{SheetName: "user_views", Table: "paliad.user_views", OrderBy: []string{"id"}},
{SheetName: "users", Table: "paliad.users", OrderBy: []string{"id"}},
// --- reference data (alphabetical, prefixed ref__) ---
{SheetName: "ref__countries", SQL: `SELECT * FROM paliad.countries ORDER BY code`},
{SheetName: "ref__courts", SQL: `SELECT * FROM paliad.courts ORDER BY id`},
{SheetName: "ref__deadline_concept_event_types", SQL: `SELECT * FROM paliad.deadline_concept_event_types ORDER BY concept_id, event_type_id`},
{SheetName: "ref__deadline_concepts", SQL: `SELECT * FROM paliad.deadline_concepts ORDER BY id`},
{SheetName: "ref__deadline_event_types", SQL: `SELECT * FROM paliad.deadline_event_types ORDER BY rule_id, event_type_id`},
{SheetName: "ref__deadline_rules", SQL: `SELECT * FROM paliad.deadline_rules ORDER BY id`},
{SheetName: "ref__event_categories", SQL: `SELECT * FROM paliad.event_categories ORDER BY id`},
{SheetName: "ref__event_category_concepts", SQL: `SELECT * FROM paliad.event_category_concepts ORDER BY category_id, concept_id`},
{SheetName: "ref__event_types", SQL: `SELECT * FROM paliad.event_types ORDER BY id`},
{SheetName: "ref__holidays", SQL: `SELECT * FROM paliad.holidays ORDER BY date, country`},
{SheetName: "ref__proceeding_types", SQL: `SELECT * FROM paliad.proceeding_types ORDER BY id`},
{SheetName: "ref__trigger_events", SQL: `SELECT * FROM paliad.trigger_events ORDER BY id`},
{SheetName: "ref__countries", Table: "paliad.countries", OrderBy: []string{"code"}},
{SheetName: "ref__courts", Table: "paliad.courts", OrderBy: []string{"id"}},
{SheetName: "ref__deadline_concept_event_types", Table: "paliad.deadline_concept_event_types", OrderBy: []string{"concept_id", "event_type_id"}},
{SheetName: "ref__deadline_concepts", Table: "paliad.deadline_concepts", OrderBy: []string{"id"}},
{SheetName: "ref__deadline_event_types", Table: "paliad.deadline_event_types", OrderBy: []string{"deadline_id", "event_type_id"}},
{SheetName: "ref__deadline_rules", Table: "paliad.deadline_rules_unified", OrderBy: []string{"id"}},
{SheetName: "ref__event_categories", Table: "paliad.event_categories", OrderBy: []string{"id"}},
{SheetName: "ref__event_category_concepts", Table: "paliad.event_category_concepts", OrderBy: []string{"event_category_id", "concept_id"}},
{SheetName: "ref__event_types", Table: "paliad.event_types", OrderBy: []string{"id"}},
{SheetName: "ref__holidays", Table: "paliad.holidays", OrderBy: []string{"date", "country"}},
{SheetName: "ref__proceeding_types", Table: "paliad.proceeding_types", OrderBy: []string{"id"}},
{SheetName: "ref__trigger_events", Table: "paliad.trigger_events", OrderBy: []string{"id"}},
}
}
// composeOrgSheetSQL turns one orgSheetSpec into the final SQL string,
// using a per-table column set (typically loaded once per backup run
// from information_schema.columns). Returns the SQL and the list of
// ORDER BY columns that were dropped because they don't exist in the
// live schema.
//
// Pure function — no DB access — so the missing-column behaviour is
// unit-testable without a fixture database.
//
// Rules:
// - If spec.SQL is non-empty, return it unchanged (override path).
// - Otherwise build `SELECT * FROM <Table> [ORDER BY <kept-cols>]`.
// - Columns are kept in their declared order; missing ones recorded
// in `dropped` and omitted from ORDER BY.
// - If no ORDER BY columns survive, the ORDER BY clause is omitted.
//
// knownCols maps unqualified table names (e.g. "appointments") to the
// set of columns they have. A table missing from knownCols is treated
// as "no columns known" — every declared ORDER BY column gets dropped.
func composeOrgSheetSQL(spec orgSheetSpec, knownCols map[string]map[string]struct{}) (sqlText string, dropped []string) {
if spec.SQL != "" {
return spec.SQL, nil
}
unqualified := spec.Table
if i := strings.IndexByte(unqualified, '.'); i >= 0 {
unqualified = unqualified[i+1:]
}
cols := knownCols[unqualified]
kept := make([]string, 0, len(spec.OrderBy))
for _, c := range spec.OrderBy {
if _, ok := cols[c]; ok {
kept = append(kept, c)
} else {
dropped = append(dropped, c)
}
}
var b strings.Builder
b.WriteString("SELECT * FROM ")
b.WriteString(spec.Table)
if len(kept) > 0 {
b.WriteString(" ORDER BY ")
b.WriteString(strings.Join(kept, ", "))
}
return b.String(), dropped
}
// loadOrgSheetColumns probes information_schema.columns once for every
// table referenced by Table+OrderBy specs. Returns a lookup
// {table_name → {column_name → {}}} restricted to the paliad schema.
//
// The queryer is whatever runs the backup's read snapshot — typically
// the REPEATABLE READ tx opened in WriteOrg, so the schema snapshot
// matches the row snapshot.
func loadOrgSheetColumns(ctx context.Context, queryer sqlx.QueryerContext, specs []orgSheetSpec) (map[string]map[string]struct{}, error) {
tableSet := map[string]struct{}{}
for _, sp := range specs {
if sp.Table == "" {
continue // SQL-override sheets carry their own column refs
}
t := sp.Table
if i := strings.IndexByte(t, '.'); i >= 0 {
t = t[i+1:]
}
tableSet[t] = struct{}{}
}
if len(tableSet) == 0 {
return map[string]map[string]struct{}{}, nil
}
tables := make([]string, 0, len(tableSet))
for t := range tableSet {
tables = append(tables, t)
}
rows, err := queryer.QueryxContext(ctx, `
SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = ANY($1)
`, tables)
if err != nil {
return nil, fmt.Errorf("probe paliad columns: %w", err)
}
defer rows.Close()
out := make(map[string]map[string]struct{}, len(tableSet))
for rows.Next() {
var table, column string
if err := rows.Scan(&table, &column); err != nil {
return nil, fmt.Errorf("scan paliad columns: %w", err)
}
set, ok := out[table]
if !ok {
set = map[string]struct{}{}
out[table] = set
}
set[column] = struct{}{}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate paliad columns: %w", err)
}
return out, nil
}
// resolveOrgSheets materialises an org-scope spec list into the
// concrete []sheetQuery that writeBundle expects. Composes each
// spec's SQL via composeOrgSheetSQL using a schema snapshot loaded
// from the same queryer. Logs WARN per dropped ORDER BY column.
func resolveOrgSheets(ctx context.Context, queryer sqlx.QueryerContext, specs []orgSheetSpec) ([]sheetQuery, error) {
knownCols, err := loadOrgSheetColumns(ctx, queryer, specs)
if err != nil {
return nil, err
}
out := make([]sheetQuery, 0, len(specs))
for _, sp := range specs {
sqlText, dropped := composeOrgSheetSQL(sp, knownCols)
for _, c := range dropped {
slog.Warn("backup: ORDER BY column dropped (not in schema)",
"sheet", sp.SheetName,
"table", sp.Table,
"column", c,
)
}
out = append(out, sheetQuery{
SheetName: sp.SheetName,
SQL: sqlText,
Args: sp.Args,
DropColumns: sp.DropColumns,
})
}
return out, nil
}

View File

@@ -169,7 +169,7 @@ func (c *paliadCatalog) LoadRuleByID(ctx context.Context, ruleID string) (*model
var rule models.DeadlineRule
err := c.rules.db.GetContext(ctx, &rule,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE id = $1 AND is_active = true`, ruleID)
if errors.Is(err, sql.ErrNoRows) {
return nil, lp.ErrUnknownRule
@@ -200,7 +200,7 @@ func (c *paliadCatalog) LoadRuleByCode(ctx context.Context, proceedingCode, subm
var rule models.DeadlineRule
err = c.rules.db.GetContext(ctx, &rule,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE proceeding_type_id = $1 AND submission_code = $2 AND is_active = true`,
pt.ID, submissionCode)
if errors.Is(err, sql.ErrNoRows) {
@@ -311,7 +311,7 @@ func (c *paliadCatalog) LookupEvents(ctx context.Context, axes lp.EventLookupAxe
pt.trigger_event_label_de AS pt_trigger_event_label_de,
pt.trigger_event_label_en AS pt_trigger_event_label_en,
pt.appeal_target AS pt_appeal_target
FROM paliad.deadline_rules dr
FROM paliad.deadline_rules_unified dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
WHERE ` + strings.Join(where, "\n AND ") + `
ORDER BY dr.proceeding_type_id, dr.sequence_order`

View File

@@ -1767,7 +1767,7 @@ func (s *ProjectionService) lookupRuleBySubmissionCode(ctx context.Context, ptID
var rule models.DeadlineRule
err := s.db.GetContext(ctx, &rule,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE proceeding_type_id = $1 AND submission_code = $2 AND is_active = true`,
ptID, code)
if errors.Is(err, sql.ErrNoRows) {
@@ -1784,7 +1784,7 @@ func (s *ProjectionService) lookupRuleByID(ctx context.Context, id uuid.UUID) (*
var rule models.DeadlineRule
err := s.db.GetContext(ctx, &rule,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE id = $1`, id)
if err != nil {
return nil, fmt.Errorf("lookup rule by id: %w", err)

View File

@@ -117,7 +117,7 @@ func (s *RuleEditorService) ListOrphans(ctx context.Context) ([]Orphan, error) {
}
if err := s.db.SelectContext(ctx, &cs, `
SELECT id, rule_code, name, name_en
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE id = ANY($1::uuid[])`, pq.Array(uuidStrs)); err != nil {
return nil, fmt.Errorf("list orphan candidate rules: %w", err)
}

View File

@@ -636,7 +636,7 @@ func (s *RuleEditorService) ListRules(ctx context.Context, f ListRulesFilter) ([
where = "WHERE " + strings.Join(conds, " AND ")
}
query := `SELECT ` + ruleColumns + `
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
` + where + `
ORDER BY proceeding_type_id NULLS LAST, sequence_order
LIMIT ` + addArg(f.Limit) + ` OFFSET ` + addArg(f.Offset)
@@ -656,7 +656,7 @@ func (s *RuleEditorService) GetByID(ctx context.Context, id uuid.UUID) (*models.
func (s *RuleEditorService) getByID(ctx context.Context, id uuid.UUID) (*models.DeadlineRule, error) {
var r models.DeadlineRule
err := s.db.GetContext(ctx, &r,
`SELECT `+ruleColumns+` FROM paliad.deadline_rules WHERE id = $1`, id)
`SELECT `+ruleColumns+` FROM paliad.deadline_rules_unified WHERE id = $1`, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrRuleNotFound
}
@@ -715,7 +715,7 @@ func (s *RuleEditorService) validateSpawnNoCycle(ctx context.Context, ruleID *uu
visited[current] = true
var nexts []sql.NullInt64
q := `SELECT DISTINCT spawn_proceeding_type_id::bigint
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE proceeding_type_id = $1
AND is_spawn = true
AND spawn_proceeding_type_id IS NOT NULL

View File

@@ -0,0 +1,274 @@
package services
// Submission base catalog service — Composer Slice A (t-paliad-313,
// design doc docs/design-submission-generator-v2-2026-05-26.md §4.2 +
// §5.1).
//
// Each row in paliad.submission_bases maps a stable slug onto a Gitea
// path (the .docx body) plus a JSON section spec that drives the
// editor's default section seeding. Slice A surfaces this catalog via
// a sidebar picker and uses GetDefaultForCode to pre-fill base_id on
// new drafts.
//
// Read-only — admin mutations land in Slice C's /admin/submission-bases
// editor. Visibility is wide-open SELECT (the catalog is shared
// firm-wide); RLS denies mutations by default.
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
)
// SubmissionBase mirrors a row in paliad.submission_bases.
type SubmissionBase struct {
ID uuid.UUID `db:"id" json:"id"`
Slug string `db:"slug" json:"slug"`
Firm *string `db:"firm" json:"firm,omitempty"`
ProceedingFamily *string `db:"proceeding_family" json:"proceeding_family,omitempty"`
LabelDE string `db:"label_de" json:"label_de"`
LabelEN string `db:"label_en" json:"label_en"`
DescriptionDE *string `db:"description_de" json:"description_de,omitempty"`
DescriptionEN *string `db:"description_en" json:"description_en,omitempty"`
GiteaPath string `db:"gitea_path" json:"gitea_path"`
SectionSpecRaw []byte `db:"section_spec" json:"-"`
IsDefaultForRaw pq.StringArray `db:"is_default_for" json:"-"`
IsActive bool `db:"is_active" json:"is_active"`
// SectionSpec is the parsed section spec; populated on read by the
// service so callers don't have to unmarshal manually.
SectionSpec BaseSectionSpec `json:"section_spec"`
// IsDefaultFor is the parsed string-slice form of the
// is_default_for column.
IsDefaultFor []string `json:"is_default_for"`
}
// BaseSectionSpec is the parsed shape of submission_bases.section_spec.
// Slice A consumes Defaults to seed submission_sections rows on draft
// create; later slices consume Stylemap (Slice B's MD→OOXML walker) and
// Version (forward compat).
type BaseSectionSpec struct {
Version int `json:"version"`
Stylemap map[string]string `json:"stylemap"`
Defaults []BaseSectionSpecDefault `json:"defaults"`
}
// BaseSectionSpecDefault declares one default section per base. SeedMD*
// is the Markdown copied into submission_sections.content_md_* on draft
// create. Empty seed = blank prose section.
type BaseSectionSpecDefault struct {
SectionKey string `json:"section_key"`
Kind string `json:"kind"`
OrderIndex int `json:"order_index"`
LabelDE string `json:"label_de"`
LabelEN string `json:"label_en"`
Included bool `json:"included"`
SeedMDDE string `json:"seed_md_de"`
SeedMDEN string `json:"seed_md_en"`
}
// BaseService reads the catalog. No mutations in Slice A.
type BaseService struct {
db *sqlx.DB
}
// NewBaseService wires the service.
func NewBaseService(db *sqlx.DB) *BaseService {
return &BaseService{db: db}
}
// ErrBaseNotFound is the sentinel for "no base with that id/slug".
var ErrBaseNotFound = errors.New("submission base: not found")
const baseColumns = `id, slug, firm, proceeding_family, label_de, label_en,
description_de, description_en, gitea_path,
section_spec, is_default_for, is_active`
// List returns every active base ordered by firm-then-label.
// firmFilter (when non-empty) restricts to rows where firm matches OR
// firm IS NULL — the picker shows the firm's own bases plus the
// firm-agnostic ones.
func (s *BaseService) List(ctx context.Context, firmFilter string) ([]SubmissionBase, error) {
var rows []SubmissionBase
var err error
if firmFilter == "" {
err = s.db.SelectContext(ctx, &rows,
`SELECT `+baseColumns+`
FROM paliad.submission_bases
WHERE is_active
ORDER BY COALESCE(firm, ''), label_de`)
} else {
err = s.db.SelectContext(ctx, &rows,
`SELECT `+baseColumns+`
FROM paliad.submission_bases
WHERE is_active AND (firm = $1 OR firm IS NULL)
ORDER BY (firm IS NULL), label_de`,
firmFilter)
}
if err != nil {
return nil, fmt.Errorf("list submission bases: %w", err)
}
for i := range rows {
if err := rows[i].decode(); err != nil {
return nil, err
}
}
return rows, nil
}
// GetByID fetches one base by uuid.
func (s *BaseService) GetByID(ctx context.Context, id uuid.UUID) (*SubmissionBase, error) {
var b SubmissionBase
err := s.db.GetContext(ctx, &b,
`SELECT `+baseColumns+`
FROM paliad.submission_bases
WHERE id = $1 AND is_active`,
id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrBaseNotFound
}
if err != nil {
return nil, fmt.Errorf("get submission base by id: %w", err)
}
if err := b.decode(); err != nil {
return nil, err
}
return &b, nil
}
// GetBySlug fetches one base by stable slug ("hlc-letterhead", …).
func (s *BaseService) GetBySlug(ctx context.Context, slug string) (*SubmissionBase, error) {
var b SubmissionBase
err := s.db.GetContext(ctx, &b,
`SELECT `+baseColumns+`
FROM paliad.submission_bases
WHERE slug = $1 AND is_active`,
slug)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrBaseNotFound
}
if err != nil {
return nil, fmt.Errorf("get submission base by slug: %w", err)
}
if err := b.decode(); err != nil {
return nil, err
}
return &b, nil
}
// GetDefaultForCode picks the base SubmissionDraftService.Create should
// seed for a new draft, given the requesting firm and the draft's
// submission_code. Priority:
//
// 1. firm-matched base whose is_default_for[] contains the exact code.
// 2. firm-matched base whose proceeding_family matches the code's
// family (first three dot-segments, e.g. "de.inf.lg" from
// "de.inf.lg.erwidg").
// 3. firm-matched base with NULL proceeding_family (firm-agnostic
// fallback within the firm).
// 4. firm-NULL (cross-firm) base by family match.
// 5. firm-NULL base with NULL family — the universal neutral fallback.
// 6. first active row (deterministic ordering on (firm IS NULL,
// label_de)).
//
// Returns ErrBaseNotFound if the table is empty.
func (s *BaseService) GetDefaultForCode(ctx context.Context, firm, submissionCode string) (*SubmissionBase, error) {
family := familyOfCode(submissionCode)
tryQueries := []struct {
sql string
args []any
}{
{
`SELECT ` + baseColumns + `
FROM paliad.submission_bases
WHERE is_active AND firm = $1 AND $2 = ANY(is_default_for)
ORDER BY label_de LIMIT 1`,
[]any{firm, submissionCode},
},
{
`SELECT ` + baseColumns + `
FROM paliad.submission_bases
WHERE is_active AND firm = $1 AND proceeding_family = $2
ORDER BY label_de LIMIT 1`,
[]any{firm, family},
},
{
`SELECT ` + baseColumns + `
FROM paliad.submission_bases
WHERE is_active AND firm = $1 AND proceeding_family IS NULL
ORDER BY label_de LIMIT 1`,
[]any{firm},
},
{
`SELECT ` + baseColumns + `
FROM paliad.submission_bases
WHERE is_active AND firm IS NULL AND proceeding_family = $1
ORDER BY label_de LIMIT 1`,
[]any{family},
},
{
`SELECT ` + baseColumns + `
FROM paliad.submission_bases
WHERE is_active AND firm IS NULL AND proceeding_family IS NULL
ORDER BY label_de LIMIT 1`,
[]any{},
},
{
`SELECT ` + baseColumns + `
FROM paliad.submission_bases
WHERE is_active
ORDER BY (firm IS NULL), label_de LIMIT 1`,
[]any{},
},
}
for _, q := range tryQueries {
var b SubmissionBase
err := s.db.GetContext(ctx, &b, q.sql, q.args...)
if errors.Is(err, sql.ErrNoRows) {
continue
}
if err != nil {
return nil, fmt.Errorf("get default base: %w", err)
}
if err := b.decode(); err != nil {
return nil, err
}
return &b, nil
}
return nil, ErrBaseNotFound
}
// familyOfCode returns the first three dot-segments of a submission_code.
// "de.inf.lg.erwidg" → "de.inf.lg". Codes with fewer than three segments
// pass through unchanged (none in the corpus today, but safe).
func familyOfCode(code string) string {
parts := strings.SplitN(code, ".", 4)
if len(parts) <= 3 {
return code
}
return strings.Join(parts[:3], ".")
}
// decode fills the parsed views from the raw scan fields.
func (b *SubmissionBase) decode() error {
if len(b.SectionSpecRaw) > 0 {
if err := json.Unmarshal(b.SectionSpecRaw, &b.SectionSpec); err != nil {
return fmt.Errorf("decode submission base section_spec: %w", err)
}
}
b.IsDefaultFor = []string(b.IsDefaultForRaw)
if b.IsDefaultFor == nil {
b.IsDefaultFor = []string{}
}
return nil
}

View File

@@ -0,0 +1,99 @@
package services
// Unit tests for Composer base helpers — pure functions, no DB
// dependency (t-paliad-313 Slice A).
import "testing"
func TestFamilyOfCode(t *testing.T) {
cases := []struct {
in string
want string
}{
// canonical four-segment codes → first three segments
{"de.inf.lg.erwidg", "de.inf.lg"},
{"de.inf.lg.klage", "de.inf.lg"},
{"de.inf.olg.berufung", "de.inf.olg"},
{"upc.inf.cfi.soc", "upc.inf.cfi"},
{"upc.inf.cfi.sod", "upc.inf.cfi"},
{"upc.apl.cost.leave_app", "upc.apl.cost"},
{"epa.opp.opd.einspruch", "epa.opp.opd"},
// five-segment codes (rarely used in the corpus today) → still
// truncate to three
{"upc.inf.cfi.appeal_spawn.followup", "upc.inf.cfi"},
// shorter codes pass through unchanged
{"de.inf.lg", "de.inf.lg"},
{"de.inf", "de.inf"},
{"de", "de"},
// empty stays empty
{"", ""},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
if got := familyOfCode(tc.in); got != tc.want {
t.Errorf("familyOfCode(%q) = %q; want %q", tc.in, got, tc.want)
}
})
}
}
func TestBaseSectionSpec_DecodeShape(t *testing.T) {
// The default seed in mig 146 emits a JSON document the service
// must decode round-trip; this golden pins the exact field shape
// the editor expects.
raw := []byte(`{
"version": 1,
"stylemap": {
"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": [
{"section_key":"letterhead","kind":"prose","order_index":1,"label_de":"Briefkopf","label_en":"Letterhead","included":true,"seed_md_de":"hi","seed_md_en":"hi"},
{"section_key":"requests","kind":"requests","order_index":4,"label_de":"Anträge","label_en":"Requests","included":true,"seed_md_de":"","seed_md_en":""}
]
}`)
b := SubmissionBase{SectionSpecRaw: raw}
if err := b.decode(); err != nil {
t.Fatalf("decode: %v", err)
}
if b.SectionSpec.Version != 1 {
t.Errorf("Version = %d; want 1", b.SectionSpec.Version)
}
if got := b.SectionSpec.Stylemap["heading_1"]; got != "HLpat-Heading-H1" {
t.Errorf("Stylemap[heading_1] = %q; want HLpat-Heading-H1", got)
}
if len(b.SectionSpec.Defaults) != 2 {
t.Fatalf("Defaults len = %d; want 2", len(b.SectionSpec.Defaults))
}
first := b.SectionSpec.Defaults[0]
if first.SectionKey != "letterhead" || first.Kind != "prose" || first.OrderIndex != 1 {
t.Errorf("Defaults[0] = %+v; want letterhead/prose/1", first)
}
if first.SeedMDDE != "hi" || first.SeedMDEN != "hi" {
t.Errorf("Defaults[0] seed_md_* = %q/%q; want hi/hi", first.SeedMDDE, first.SeedMDEN)
}
second := b.SectionSpec.Defaults[1]
if second.SectionKey != "requests" || second.Kind != "requests" || second.OrderIndex != 4 {
t.Errorf("Defaults[1] = %+v; want requests/requests/4", second)
}
}
func TestBaseSectionSpec_EmptyDecode(t *testing.T) {
// A bare row (SectionSpecRaw == nil) decodes cleanly into the
// zero value — no panic, no garbage.
b := SubmissionBase{}
if err := b.decode(); err != nil {
t.Fatalf("decode empty: %v", err)
}
if b.SectionSpec.Version != 0 || len(b.SectionSpec.Defaults) != 0 {
t.Errorf("expected zero SectionSpec on empty raw; got %+v", b.SectionSpec)
}
if b.IsDefaultFor == nil {
t.Errorf("IsDefaultFor must be non-nil (empty slice) after decode; got nil")
}
}

View File

@@ -0,0 +1,629 @@
package services
// Composer building-block library service — t-paliad-315 Slice C
// (design doc docs/design-submission-generator-v2-2026-05-26.md §8 +
// §4.4).
//
// Per the Q2 ratification (m, 2026-05-26): building blocks are plain
// text paste sources. The library row is the source; the lawyer's
// section row is the destination. After paste, the section row has
// no link back to the library — the prose belongs to the section.
//
// Per the Q9 ratification: four visibility tiers — private / team /
// firm / global. The DB-side RLS policy (mig 149) handles the
// "private rows only the author sees" coarse gate. This service
// applies the fine-grained tier predicate at query time, so the
// picker on the section editor only shows blocks the caller actually
// has reach to.
//
// Admin mutations are gated at the handler layer (adminGate). The
// service exposes Create + Update + SoftDelete + RestoreVersion which
// all assume the caller has already passed the admin check.
// Append-only audit history (_admin_versions) is retained at 20 rows
// per block, GCed in the same transaction as each Save.
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// BuildingBlock mirrors a row in paliad.submission_building_blocks.
type BuildingBlock struct {
ID uuid.UUID `db:"id" json:"id"`
Slug string `db:"slug" json:"slug"`
Firm *string `db:"firm" json:"firm,omitempty"`
SectionKey string `db:"section_key" json:"section_key"`
ProceedingFamily *string `db:"proceeding_family" json:"proceeding_family,omitempty"`
TitleDE string `db:"title_de" json:"title_de"`
TitleEN string `db:"title_en" json:"title_en"`
DescriptionDE *string `db:"description_de" json:"description_de,omitempty"`
DescriptionEN *string `db:"description_en" json:"description_en,omitempty"`
ContentMDDE string `db:"content_md_de" json:"content_md_de"`
ContentMDEN string `db:"content_md_en" json:"content_md_en"`
AuthorID *uuid.UUID `db:"author_id" json:"author_id,omitempty"`
Visibility string `db:"visibility" json:"visibility"`
IsPublished bool `db:"is_published" json:"is_published"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
DeletedAt *time.Time `db:"deleted_at" json:"-"`
}
// BuildingBlockVersion is one row from the admin-only audit history.
type BuildingBlockVersion struct {
ID uuid.UUID `db:"id" json:"id"`
BuildingBlockID uuid.UUID `db:"building_block_id" json:"building_block_id"`
ContentMDDE string `db:"content_md_de" json:"content_md_de"`
ContentMDEN string `db:"content_md_en" json:"content_md_en"`
TitleDE string `db:"title_de" json:"title_de"`
TitleEN string `db:"title_en" json:"title_en"`
EditedBy *uuid.UUID `db:"edited_by" json:"edited_by,omitempty"`
Note *string `db:"note" json:"note,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// BuildingBlockService handles the library + admin audit history.
type BuildingBlockService struct {
db *sqlx.DB
firm string
}
// NewBuildingBlockService wires the service. firm is branding.Name —
// captured at construction time and used to apply the firm-tier
// filter on List/Get calls.
func NewBuildingBlockService(db *sqlx.DB, firm string) *BuildingBlockService {
return &BuildingBlockService{db: db, firm: firm}
}
const (
// VisPrivate / Team / Firm / Global — the 4 tiers per Q9.
VisPrivate = "private"
VisTeam = "team"
VisFirm = "firm"
VisGlobal = "global"
// Retention horizon for the admin audit history per block.
buildingBlockVersionRetention = 20
)
// ErrBuildingBlockNotFound is the sentinel for "no block with that id
// visible to this user". Maps to 404 at the handler layer.
var ErrBuildingBlockNotFound = errors.New("submission building block: not found")
// ErrBuildingBlockInvalidVisibility is the sentinel for a Create /
// Update with an unknown tier value.
var ErrBuildingBlockInvalidVisibility = errors.New("submission building block: invalid visibility")
const buildingBlockColumns = `id, slug, firm, section_key, proceeding_family,
title_de, title_en, description_de, description_en,
content_md_de, content_md_en,
author_id, visibility, is_published,
created_at, updated_at, deleted_at`
// BlockListFilter narrows the picker query. All fields optional. Returns
// only published, non-deleted rows the caller has tier reach to.
type BlockListFilter struct {
// SectionKey filters to blocks bound to one section (the picker
// uses this to restrict "facts" blocks to facts sections, etc.).
// Empty string = no filter.
SectionKey string
// ProceedingFamily filters to blocks tagged for one family OR
// untagged (proceeding_family IS NULL = "any family"). Empty
// string = no filter.
ProceedingFamily string
// Search free-text query against title + description + content.
// Empty string = no filter.
Search string
// Limit caps the result count (0 = default 50).
Limit int
}
// ListVisible returns blocks the caller can see, after the tier
// predicate is applied. Ordered by updated_at DESC. The DB-side
// SELECT policy already drops soft-deleted rows + private-other-author
// rows; this query additionally honours the picker filter + the
// is_published gate + the firm + team predicates.
func (s *BuildingBlockService) ListVisible(ctx context.Context, userID uuid.UUID, filter BlockListFilter) ([]BuildingBlock, error) {
limit := filter.Limit
if limit <= 0 {
limit = 50
}
q := `SELECT ` + buildingBlockColumns + `
FROM paliad.submission_building_blocks
WHERE deleted_at IS NULL
AND is_published = true
AND (
visibility = 'global'
OR visibility = 'private' AND author_id = $1
OR visibility = 'firm' AND (firm IS NULL OR firm = $2)
OR visibility = 'team' AND EXISTS (
SELECT 1 FROM paliad.project_teams pt1
JOIN paliad.project_teams pt2 ON pt1.project_id = pt2.project_id
WHERE pt1.user_id = author_id AND pt2.user_id = $1
)
)`
args := []any{userID, s.firm}
idx := 3
if filter.SectionKey != "" {
q += fmt.Sprintf(" AND section_key = $%d", idx)
args = append(args, filter.SectionKey)
idx++
}
if filter.ProceedingFamily != "" {
q += fmt.Sprintf(" AND (proceeding_family IS NULL OR proceeding_family = $%d)", idx)
args = append(args, filter.ProceedingFamily)
idx++
}
if filter.Search != "" {
pattern := "%" + strings.ToLower(filter.Search) + "%"
q += fmt.Sprintf(" AND (LOWER(title_de) LIKE $%d OR LOWER(title_en) LIKE $%d OR LOWER(COALESCE(description_de,'')) LIKE $%d OR LOWER(COALESCE(description_en,'')) LIKE $%d OR LOWER(content_md_de) LIKE $%d OR LOWER(content_md_en) LIKE $%d)",
idx, idx, idx, idx, idx, idx)
args = append(args, pattern)
idx++
}
q += fmt.Sprintf(" ORDER BY updated_at DESC LIMIT $%d", idx)
args = append(args, limit)
var rows []BuildingBlock
err := s.db.SelectContext(ctx, &rows, q, args...)
if err != nil {
return nil, fmt.Errorf("list building blocks: %w", err)
}
return rows, nil
}
// ListAllForAdmin returns every non-deleted row regardless of tier.
// Handler-side adminGate is the access gate.
func (s *BuildingBlockService) ListAllForAdmin(ctx context.Context) ([]BuildingBlock, error) {
var rows []BuildingBlock
err := s.db.SelectContext(ctx, &rows,
`SELECT `+buildingBlockColumns+`
FROM paliad.submission_building_blocks
WHERE deleted_at IS NULL
ORDER BY updated_at DESC`)
if err != nil {
return nil, fmt.Errorf("admin list building blocks: %w", err)
}
return rows, nil
}
// GetVisible fetches a block by id, applying the same tier predicate
// as ListVisible. ErrBuildingBlockNotFound when the row exists but
// the caller has no tier reach (handler maps to 404).
func (s *BuildingBlockService) GetVisible(ctx context.Context, userID, blockID uuid.UUID) (*BuildingBlock, error) {
var b BuildingBlock
err := s.db.GetContext(ctx, &b,
`SELECT `+buildingBlockColumns+`
FROM paliad.submission_building_blocks
WHERE id = $1
AND deleted_at IS NULL
AND is_published = true
AND (
visibility = 'global'
OR visibility = 'private' AND author_id = $2
OR visibility = 'firm' AND (firm IS NULL OR firm = $3)
OR visibility = 'team' AND EXISTS (
SELECT 1 FROM paliad.project_teams pt1
JOIN paliad.project_teams pt2 ON pt1.project_id = pt2.project_id
WHERE pt1.user_id = author_id AND pt2.user_id = $2
)
)`,
blockID, userID, s.firm)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrBuildingBlockNotFound
}
if err != nil {
return nil, fmt.Errorf("get building block: %w", err)
}
return &b, nil
}
// GetForAdmin fetches a block by id with no tier filter. adminGate at
// the handler is the access gate.
func (s *BuildingBlockService) GetForAdmin(ctx context.Context, blockID uuid.UUID) (*BuildingBlock, error) {
var b BuildingBlock
err := s.db.GetContext(ctx, &b,
`SELECT `+buildingBlockColumns+`
FROM paliad.submission_building_blocks
WHERE id = $1 AND deleted_at IS NULL`,
blockID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrBuildingBlockNotFound
}
if err != nil {
return nil, fmt.Errorf("admin get building block: %w", err)
}
return &b, nil
}
// CreateInput carries the fields needed to insert a new block. Admin
// path only (Slice C); user-authored private blocks are a later
// feature.
type CreateInput struct {
Slug string
Firm *string
SectionKey string
ProceedingFamily *string
TitleDE string
TitleEN string
DescriptionDE *string
DescriptionEN *string
ContentMDDE string
ContentMDEN string
Visibility string
IsPublished bool
}
// Create inserts a new block and seeds the first audit-history row.
// editorID is the admin's uuid; recorded in _admin_versions.edited_by.
func (s *BuildingBlockService) Create(ctx context.Context, editorID uuid.UUID, in CreateInput) (*BuildingBlock, error) {
if !validVisibility(in.Visibility) {
return nil, ErrBuildingBlockInvalidVisibility
}
in.Slug = strings.TrimSpace(in.Slug)
in.SectionKey = strings.TrimSpace(in.SectionKey)
in.TitleDE = strings.TrimSpace(in.TitleDE)
in.TitleEN = strings.TrimSpace(in.TitleEN)
if in.Slug == "" || in.SectionKey == "" || in.TitleDE == "" || in.TitleEN == "" {
return nil, ErrInvalidInput
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("create building block tx: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
var b BuildingBlock
err = tx.GetContext(ctx, &b,
`INSERT INTO paliad.submission_building_blocks
(slug, firm, section_key, proceeding_family,
title_de, title_en, description_de, description_en,
content_md_de, content_md_en, author_id, visibility, is_published)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
RETURNING `+buildingBlockColumns,
in.Slug, in.Firm, in.SectionKey, in.ProceedingFamily,
in.TitleDE, in.TitleEN, in.DescriptionDE, in.DescriptionEN,
in.ContentMDDE, in.ContentMDEN, editorID, in.Visibility, in.IsPublished)
if err != nil {
return nil, fmt.Errorf("insert building block: %w", err)
}
if err := s.appendVersionTx(ctx, tx, b.ID, editorID, &b, "create"); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit create building block: %w", err)
}
committed = true
return &b, nil
}
// UpdatePatch carries the optional fields for an Update call.
type UpdatePatch struct {
Slug *string
Firm **string // **string for "set to null" semantics
SectionKey *string
ProceedingFamily **string
TitleDE *string
TitleEN *string
DescriptionDE **string
DescriptionEN **string
ContentMDDE *string
ContentMDEN *string
Visibility *string
IsPublished *bool
Note *string // free-form note that lands in _admin_versions
}
// Update applies a patch. Appends an audit-history row; GCs to the
// retention=20 horizon in the same tx so old versions don't pile up.
func (s *BuildingBlockService) Update(ctx context.Context, editorID, blockID uuid.UUID, patch UpdatePatch) (*BuildingBlock, error) {
if patch.Visibility != nil && !validVisibility(*patch.Visibility) {
return nil, ErrBuildingBlockInvalidVisibility
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("update building block tx: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
setParts := []string{}
args := []any{}
idx := 1
addText := func(col string, p *string) {
if p == nil {
return
}
setParts = append(setParts, fmt.Sprintf("%s = $%d", col, idx))
args = append(args, *p)
idx++
}
addBool := func(col string, p *bool) {
if p == nil {
return
}
setParts = append(setParts, fmt.Sprintf("%s = $%d", col, idx))
args = append(args, *p)
idx++
}
addNullable := func(col string, p **string) {
if p == nil {
return
}
setParts = append(setParts, fmt.Sprintf("%s = $%d", col, idx))
args = append(args, *p)
idx++
}
addText("slug", patch.Slug)
addNullable("firm", patch.Firm)
addText("section_key", patch.SectionKey)
addNullable("proceeding_family", patch.ProceedingFamily)
addText("title_de", patch.TitleDE)
addText("title_en", patch.TitleEN)
addNullable("description_de", patch.DescriptionDE)
addNullable("description_en", patch.DescriptionEN)
addText("content_md_de", patch.ContentMDDE)
addText("content_md_en", patch.ContentMDEN)
addText("visibility", patch.Visibility)
addBool("is_published", patch.IsPublished)
if len(setParts) == 0 {
// No-op patch — still append a version with the user's note if
// supplied. Otherwise just return current row.
current, err := s.GetForAdmin(ctx, blockID)
if err != nil {
return nil, err
}
if patch.Note != nil && strings.TrimSpace(*patch.Note) != "" {
if err := s.appendVersionTx(ctx, tx, blockID, editorID, current, *patch.Note); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit no-op update building block: %w", err)
}
committed = true
return current, nil
}
args = append(args, blockID)
q := fmt.Sprintf(
`UPDATE paliad.submission_building_blocks
SET %s
WHERE id = $%d AND deleted_at IS NULL
RETURNING `+buildingBlockColumns,
strings.Join(setParts, ", "), idx,
)
var b BuildingBlock
err = tx.GetContext(ctx, &b, q, args...)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrBuildingBlockNotFound
}
if err != nil {
return nil, fmt.Errorf("update building block: %w", err)
}
note := ""
if patch.Note != nil {
note = *patch.Note
}
if note == "" {
note = "update"
}
if err := s.appendVersionTx(ctx, tx, blockID, editorID, &b, note); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit update building block: %w", err)
}
committed = true
return &b, nil
}
// SoftDelete marks a block deleted. RLS hides deleted rows; the
// admin can still see them via GetForAdmin if the row is referenced
// by audit history.
func (s *BuildingBlockService) SoftDelete(ctx context.Context, editorID, blockID uuid.UUID) error {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("soft delete tx: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
var b BuildingBlock
err = tx.GetContext(ctx, &b,
`UPDATE paliad.submission_building_blocks
SET deleted_at = now()
WHERE id = $1 AND deleted_at IS NULL
RETURNING `+buildingBlockColumns,
blockID)
if errors.Is(err, sql.ErrNoRows) {
return ErrBuildingBlockNotFound
}
if err != nil {
return fmt.Errorf("soft delete: %w", err)
}
if err := s.appendVersionTx(ctx, tx, blockID, editorID, &b, "delete"); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit soft delete: %w", err)
}
committed = true
return nil
}
// ListVersions returns the audit history for a block (most recent
// first), capped at retention. Admin path only.
func (s *BuildingBlockService) ListVersions(ctx context.Context, blockID uuid.UUID) ([]BuildingBlockVersion, error) {
var rows []BuildingBlockVersion
err := s.db.SelectContext(ctx, &rows,
`SELECT id, building_block_id, content_md_de, content_md_en,
title_de, title_en, edited_by, note, created_at
FROM paliad.submission_building_block_admin_versions
WHERE building_block_id = $1
ORDER BY created_at DESC
LIMIT $2`,
blockID, buildingBlockVersionRetention)
if err != nil {
return nil, fmt.Errorf("list building block versions: %w", err)
}
return rows, nil
}
// RestoreVersion overwrites the block's current content + titles with
// the named version's snapshot. Appends a new audit row noting the
// restore. Admin path only.
func (s *BuildingBlockService) RestoreVersion(ctx context.Context, editorID, blockID, versionID uuid.UUID) (*BuildingBlock, error) {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("restore version tx: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
var v BuildingBlockVersion
err = tx.GetContext(ctx, &v,
`SELECT id, building_block_id, content_md_de, content_md_en,
title_de, title_en, edited_by, note, created_at
FROM paliad.submission_building_block_admin_versions
WHERE id = $1 AND building_block_id = $2`,
versionID, blockID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrBuildingBlockNotFound
}
if err != nil {
return nil, fmt.Errorf("fetch version: %w", err)
}
var b BuildingBlock
err = tx.GetContext(ctx, &b,
`UPDATE paliad.submission_building_blocks
SET content_md_de = $1, content_md_en = $2,
title_de = $3, title_en = $4
WHERE id = $5 AND deleted_at IS NULL
RETURNING `+buildingBlockColumns,
v.ContentMDDE, v.ContentMDEN, v.TitleDE, v.TitleEN, blockID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrBuildingBlockNotFound
}
if err != nil {
return nil, fmt.Errorf("restore update: %w", err)
}
note := fmt.Sprintf("restore from %s", versionID.String())
if err := s.appendVersionTx(ctx, tx, blockID, editorID, &b, note); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit restore: %w", err)
}
committed = true
return &b, nil
}
// appendVersionTx inserts an audit row + GCs to the retention horizon.
// Runs inside the caller's transaction so a failure rolls back the
// associated Create / Update / Delete / Restore.
func (s *BuildingBlockService) appendVersionTx(ctx context.Context, tx *sqlx.Tx, blockID, editorID uuid.UUID, b *BuildingBlock, note string) error {
_, err := tx.ExecContext(ctx,
`INSERT INTO paliad.submission_building_block_admin_versions
(building_block_id, content_md_de, content_md_en,
title_de, title_en, edited_by, note)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
blockID, b.ContentMDDE, b.ContentMDEN, b.TitleDE, b.TitleEN, editorID, note)
if err != nil {
return fmt.Errorf("append version: %w", err)
}
// GC: keep only the most recent N versions per block.
_, err = tx.ExecContext(ctx,
`DELETE FROM paliad.submission_building_block_admin_versions
WHERE id IN (
SELECT id FROM paliad.submission_building_block_admin_versions
WHERE building_block_id = $1
ORDER BY created_at DESC
OFFSET $2
)`,
blockID, buildingBlockVersionRetention)
if err != nil {
return fmt.Errorf("gc version history: %w", err)
}
return nil
}
// InsertIntoSection clones a block's content_md_<lang> into the named
// section by appending at the end (with a paragraph break separator).
// Per Q2: no lineage stamped on the section. The returned
// SubmissionSection carries the updated content.
//
// The handler enforces draft ownership before calling this; the
// service does the visibility check on the block itself and the
// SectionService.Get + Update sequence inside one transaction so an
// in-flight failure rolls back cleanly.
func (s *BuildingBlockService) InsertIntoSection(ctx context.Context, userID, blockID, sectionID uuid.UUID, sections *SectionService) (*SubmissionSection, error) {
block, err := s.GetVisible(ctx, userID, blockID)
if err != nil {
return nil, err
}
sec, err := sections.Get(ctx, sectionID)
if err != nil {
return nil, err
}
// Determine which lang column to splice into based on the section
// row's existing content + the block's content. We splice both
// lang columns so the section is bilingually current — the
// lawyer's draft language picker still drives which one renders.
newDE := appendBlockContent(sec.ContentMDDE, block.ContentMDDE)
newEN := appendBlockContent(sec.ContentMDEN, block.ContentMDEN)
patch := SectionPatch{ContentMDDE: &newDE, ContentMDEN: &newEN}
return sections.Update(ctx, sectionID, patch)
}
func appendBlockContent(existing, addition string) string {
if strings.TrimSpace(existing) == "" {
return addition
}
if strings.TrimSpace(addition) == "" {
return existing
}
return strings.TrimRight(existing, "\n") + "\n\n" + addition
}
func validVisibility(v string) bool {
switch v {
case VisPrivate, VisTeam, VisFirm, VisGlobal:
return true
}
return false
}

View File

@@ -0,0 +1,60 @@
package services
// Unit tests for BuildingBlockService helpers — pure functions, no DB
// dependency (t-paliad-315 Slice C).
import "testing"
func TestValidVisibility(t *testing.T) {
cases := []struct {
in string
valid bool
}{
{"private", true},
{"team", true},
{"firm", true},
{"global", true},
{"PRIVATE", false}, // case-sensitive
{"", false},
{"public", false},
{"all", false},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
if got := validVisibility(tc.in); got != tc.valid {
t.Errorf("validVisibility(%q) = %v; want %v", tc.in, got, tc.valid)
}
})
}
}
func TestAppendBlockContent(t *testing.T) {
cases := []struct {
existing string
addition string
want string
}{
{"", "hello", "hello"},
{"existing", "", "existing"},
{"", "", ""},
{"existing", "addition", "existing\n\naddition"},
{"existing\n", "addition", "existing\n\naddition"},
{"existing\n\n\n", "addition", "existing\n\naddition"},
{" ", "addition", "addition"}, // whitespace-only existing counts as empty
{"existing", " ", "existing"}, // whitespace-only addition counts as empty
}
for _, tc := range cases {
if got := appendBlockContent(tc.existing, tc.addition); got != tc.want {
t.Errorf("appendBlockContent(%q,%q) = %q; want %q", tc.existing, tc.addition, got, tc.want)
}
}
}
func TestBuildingBlockVisibilityConstants(t *testing.T) {
// Pin the constants so a typo somewhere doesn't silently flip a
// tier name. The DB CHECK constraint and the RLS predicate both
// hard-code these literals.
if VisPrivate != "private" || VisTeam != "team" || VisFirm != "firm" || VisGlobal != "global" {
t.Errorf("visibility constants drifted: %q/%q/%q/%q", VisPrivate, VisTeam, VisFirm, VisGlobal)
}
}

View File

@@ -0,0 +1,469 @@
package services
// Composer render pipeline — t-paliad-313 Slice B (design doc §9.1 +
// §9.2). Assembles a base .docx and a draft's section rows into a
// merged .docx ready for export.
//
// Pipeline (high-level):
//
// 1. ConvertDotmToDocx pre-pass on the base bytes (idempotent on .docx).
// 2. Locate `word/document.xml` inside the zip; pull the body XML.
// 3. For each section in the draft (order_index ASC, included=true):
// render content_md_<lang> → OOXML via RenderMarkdownToOOXML using
// base.section_spec.stylemap.paragraph.
// 4. Splice the rendered OOXML into the base body. Two splice modes:
// - Anchor mode: when the body carries `{{#section:KEY}}` /
// `{{/section:KEY}}` marker pairs, replace the slot's content
// (including the anchor paragraphs themselves) with the rendered
// section.
// - Append mode: when no anchor pair is found for a section, the
// rendered OOXML appends at the end of the body, just before any
// `<w:sectPr>` element. Sections with `included=false` are
// dropped silently.
// 5. Strip any leftover unmatched anchor paragraphs.
// 6. Re-pack the document.xml into the zip, leaving every other part
// untouched.
// 7. Run the v1 SubmissionRenderer placeholder pass over the assembly
// so `{{path}}` placeholders inside section content (and inside
// the base's untouched chrome) get substituted by the merged bag.
// Cross-run merge in pass 2 handles autocorrect-fragmented
// placeholders the same as v1.
//
// Result: a fully-merged .docx. No new third-party Go dep — reuses
// archive/zip + the existing SubmissionRenderer.
import (
"archive/zip"
"bytes"
"context"
"fmt"
"io"
"regexp"
"sort"
"strings"
"time"
)
// SubmissionComposer assembles base + sections into a final .docx.
// Stateless; safe for concurrent use.
type SubmissionComposer struct {
renderer *SubmissionRenderer
}
// NewSubmissionComposer wires the composer. The renderer is required —
// a nil renderer is a programmer error and the composer panics at
// construction.
func NewSubmissionComposer(renderer *SubmissionRenderer) *SubmissionComposer {
if renderer == nil {
panic("submission composer: renderer required")
}
return &SubmissionComposer{renderer: renderer}
}
// ComposeOptions carries the per-call composition inputs.
type ComposeOptions struct {
// Sections are the draft's section rows in display order. The
// composer renders included sections; excluded rows are dropped.
// Caller is responsible for visibility — by the time the composer
// runs, the section rows have already been gated through
// SubmissionDraftService.Get + can_see_project.
Sections []SubmissionSection
// Base supplies the document chrome (.docx body host) plus the
// stylemap for the MD walker. Must not be nil.
Base *SubmissionBase
// BaseBytes is the raw .docx bytes for the base. Typically fetched
// from Gitea via the existing template cache.
BaseBytes []byte
// Lang ('de' or 'en') selects which content_md_* column the
// composer reads per section. Defaults to 'de' if empty.
Lang string
// Vars is the merged placeholder bag the v1 renderer pass
// substitutes after the composer assembly. Passed straight through
// to SubmissionRenderer.Render.
Vars PlaceholderMap
// Missing translates an unbound placeholder key into the marker
// the lawyer sees in Word. Passed straight to the renderer.
Missing MissingPlaceholderFn
}
// Compose runs the full pipeline and returns the merged .docx bytes.
func (c *SubmissionComposer) Compose(ctx context.Context, opts ComposeOptions) ([]byte, error) {
if opts.Base == nil {
return nil, fmt.Errorf("submission compose: base required")
}
_ = ctx // reserved for cancellation propagation in later slices
sections := opts.Sections
// Pre-pass: strip macros so the base reads as a plain .docx zip.
cleanBytes, err := ConvertDotmToDocx(opts.BaseBytes)
if err != nil {
return nil, fmt.Errorf("submission compose: convert base: %w", err)
}
// Locate + extract word/document.xml so we can splice in-place.
documentXML, otherParts, err := splitBaseZip(cleanBytes)
if err != nil {
return nil, err
}
// Build the rendered-section map: section_key → OOXML span.
style := opts.Base.SectionSpec.Stylemap["paragraph"]
rendered := make(map[string]string, len(sections))
keptSections := make([]SubmissionSection, 0, len(sections))
for _, sec := range sections {
if !sec.Included {
continue
}
md := sec.ContentMDDE
if strings.EqualFold(opts.Lang, "en") {
md = sec.ContentMDEN
}
rendered[sec.SectionKey] = RenderMarkdownToOOXML(md, style)
keptSections = append(keptSections, sec)
}
// Stable order — already sorted ascending by ListForDraft, but
// belt-and-braces in case the caller swaps the ordering policy
// later.
sort.SliceStable(keptSections, func(i, j int) bool {
return keptSections[i].OrderIndex < keptSections[j].OrderIndex
})
assembledBody := spliceSections(documentXML, rendered, keptSections, sections)
// Re-pack into a zip with the assembled document.xml. All other
// parts (styles, fonts, headers, footers, theme, settings) pass
// through bit-for-bit at their original mtime + compression.
repacked, err := repackBaseZip(otherParts, assembledBody)
if err != nil {
return nil, err
}
// Final pass: substitute placeholders against the merged bag. The
// existing renderer handles cross-run fragmentation, the `{{rule.X}}`
// alias contract, and the missing-marker emission. Reusing it
// guarantees v1's placeholder grammar stays intact inside section
// content + base chrome.
merged, err := c.renderer.Render(repacked, opts.Vars, opts.Missing)
if err != nil {
return nil, fmt.Errorf("submission compose: placeholder pass: %w", err)
}
return merged, nil
}
// ─────────────────────────────────────────────────────────────────────
// Section splicing
// ─────────────────────────────────────────────────────────────────────
// Anchor markers as they appear inside a <w:t> text node. We don't
// need a full XML parse — finding the marker text inside the body is
// sufficient because:
// - {{ and }} are never legitimate document content (placeholders
// follow the same convention everywhere else in paliad).
// - The anchor key grammar [A-Za-z0-9_]+ rules out any HTML/XML
// special characters.
// - Each anchor lives in exactly one <w:t>...<w:t>, which lives in
// exactly one <w:r>...</w:r>, which lives in exactly one
// <w:p>...</w:p>. We expand from the marker outward to find the
// enclosing <w:p> span and drop the entire paragraph as part of
// the splice.
//
// RE2 has no lookahead, so the "find enclosing <w:p>" logic is
// implemented as manual byte-index search around the marker hit
// (anchorParagraphSpan below) rather than a single regex pattern.
const (
anchorOpenPrefix = "{{#section:"
anchorClosePrefix = "{{/section:"
anchorSuffix = "}}"
)
// anchorKeyRegex validates that the captured anchor key is a clean
// identifier. Keys that include other characters (which can't actually
// appear in our authored .docx) are treated as no match.
var anchorKeyRegex = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
// anchorPair records the byte span of one matched anchor pair inside
// the body — from the start of the opening anchor's <w:p> element
// through the end of the closing anchor's </w:p>.
type anchorPair struct {
key string
openStart int // start of <w:p> for the opening anchor
closeEnd int // index just past </w:p> for the closing anchor
}
// findAllAnchorPairs scans the body for matched open/close anchor
// pairs. Unbalanced markers (open without close, or vice versa) are
// dropped from the result. Returns pairs in body-order; each pair's
// span is non-overlapping.
func findAllAnchorPairs(body string) []anchorPair {
type marker struct {
key string
paraStart int
paraEnd int
isOpen bool
}
var markers []marker
collect := func(prefix string, isOpen bool) {
offset := 0
for {
idx := strings.Index(body[offset:], prefix)
if idx < 0 {
return
}
start := offset + idx
suffixIdx := strings.Index(body[start+len(prefix):], anchorSuffix)
if suffixIdx < 0 {
return
}
key := body[start+len(prefix) : start+len(prefix)+suffixIdx]
if !anchorKeyRegex.MatchString(key) {
offset = start + len(prefix)
continue
}
markerEnd := start + len(prefix) + suffixIdx + len(anchorSuffix)
pStart, pEnd, ok := paragraphSpanAround(body, start, markerEnd)
if !ok {
offset = markerEnd
continue
}
markers = append(markers, marker{key: key, paraStart: pStart, paraEnd: pEnd, isOpen: isOpen})
offset = pEnd
}
}
collect(anchorOpenPrefix, true)
collect(anchorClosePrefix, false)
// Walk markers in body-order, matching each open with the next
// close that carries the same key.
sort.SliceStable(markers, func(i, j int) bool {
return markers[i].paraStart < markers[j].paraStart
})
var pairs []anchorPair
openStack := map[string]marker{}
for _, m := range markers {
if m.isOpen {
openStack[m.key] = m
continue
}
o, ok := openStack[m.key]
if !ok {
continue
}
pairs = append(pairs, anchorPair{
key: m.key,
openStart: o.paraStart,
closeEnd: m.paraEnd,
})
delete(openStack, m.key)
}
return pairs
}
// paragraphSpanAround returns the byte span of the smallest `<w:p>...</w:p>`
// element that fully contains the byte range [markerStart, markerEnd).
// Returns false when the byte range doesn't sit inside a single
// paragraph (which would mean the marker survived a cross-paragraph
// edit — defensive guard, shouldn't happen in well-formed input).
func paragraphSpanAround(body string, markerStart, markerEnd int) (int, int, bool) {
// Walk backwards to find the nearest unclosed <w:p ... > opening.
// Since <w:p> doesn't nest, the nearest <w:p before markerStart is
// the enclosing paragraph's opening tag.
pStart := -1
cursor := markerStart
for cursor > 0 {
idx := strings.LastIndex(body[:cursor], "<w:p")
if idx < 0 {
break
}
// Confirm this is a paragraph open, not a different
// w:p-prefixed tag (e.g. <w:pPr>).
if idx+4 <= len(body) {
after := body[idx+4]
if after == ' ' || after == '>' || after == '/' {
// <w:p ...> or <w:p>; not <w:pPr>.
close := strings.Index(body[idx:], ">")
if close < 0 {
return 0, 0, false
}
pStart = idx
break
}
}
cursor = idx
}
if pStart < 0 {
return 0, 0, false
}
// Walk forward to find the matching </w:p>. <w:p> doesn't nest so
// the next </w:p> after the marker is the close.
pEndIdx := strings.Index(body[markerEnd:], "</w:p>")
if pEndIdx < 0 {
return 0, 0, false
}
pEnd := markerEnd + pEndIdx + len("</w:p>")
return pStart, pEnd, true
}
// spliceSections replaces anchor slots with rendered sections and
// appends any unanchored sections before sectPr. Returns the assembled
// document.xml body.
func spliceSections(documentXML []byte, rendered map[string]string, kept []SubmissionSection, all []SubmissionSection) []byte {
body := string(documentXML)
pairs := findAllAnchorPairs(body)
// Build a lookup of kept section keys for quick membership tests.
keptByKey := map[string]int{}
for i, sec := range kept {
keptByKey[sec.SectionKey] = i
}
allByKey := map[string]int{}
for i, sec := range all {
allByKey[sec.SectionKey] = i
}
matchedKeys := map[string]bool{}
// Walk pairs in REVERSE body-order so slice mutations don't shift
// later offsets.
sort.SliceStable(pairs, func(i, j int) bool {
return pairs[i].openStart > pairs[j].openStart
})
for _, p := range pairs {
replacement := ""
if idx, ok := keptByKey[p.key]; ok {
replacement = rendered[p.key]
matchedKeys[p.key] = true
_ = idx
} else if _, isOnDraft := allByKey[p.key]; isOnDraft {
// Anchor matches an excluded section on the draft — drop
// the entire slot.
replacement = ""
} else {
// Anchor doesn't match any section on this draft — drop
// to leave the base's chrome unbroken.
replacement = ""
}
body = body[:p.openStart] + replacement + body[p.closeEnd:]
}
// Append unanchored sections before sectPr in order_index ASC.
var unanchored strings.Builder
for _, sec := range kept {
if matchedKeys[sec.SectionKey] {
continue
}
unanchored.WriteString(rendered[sec.SectionKey])
}
if unanchored.Len() > 0 {
body = appendBeforeSectPr(body, unanchored.String())
}
return []byte(body)
}
// appendBeforeSectPr inserts content immediately before the first
// `<w:sectPr` element in the body, or at the end of the body if there
// is none. Word documents conventionally close the body with a sectPr
// describing page setup; we want to land sections before that element
// so they show up on the actual pages.
var sectPrRegex = regexp.MustCompile(`<w:sectPr\b`)
func appendBeforeSectPr(body, content string) string {
loc := sectPrRegex.FindStringIndex(body)
if loc == nil {
// No sectPr → append before `</w:body>` if present, else at
// the very end.
idx := strings.LastIndex(body, "</w:body>")
if idx < 0 {
return body + content
}
return body[:idx] + content + body[idx:]
}
return body[:loc[0]] + content + body[loc[0]:]
}
// ─────────────────────────────────────────────────────────────────────
// Zip plumbing
// ─────────────────────────────────────────────────────────────────────
// baseZipPart captures one zip entry we kept aside while extracting
// document.xml.
type baseZipPart struct {
name string
method uint16
modTime int64 // wall seconds; converted back to time.Time on repack
body []byte
}
// splitBaseZip extracts document.xml and returns it alongside every
// other zip entry, ready for repacking.
func splitBaseZip(cleanBytes []byte) ([]byte, []baseZipPart, error) {
zr, err := zip.NewReader(bytes.NewReader(cleanBytes), int64(len(cleanBytes)))
if err != nil {
return nil, nil, fmt.Errorf("submission compose: open base zip: %w", err)
}
var documentXML []byte
parts := make([]baseZipPart, 0, len(zr.File))
for _, f := range zr.File {
body, err := readZipEntry(f)
if err != nil {
return nil, nil, fmt.Errorf("submission compose: read %s: %w", f.Name, err)
}
if f.Name == "word/document.xml" {
documentXML = body
parts = append(parts, baseZipPart{name: f.Name, method: f.Method, modTime: f.Modified.Unix(), body: nil})
continue
}
parts = append(parts, baseZipPart{name: f.Name, method: f.Method, modTime: f.Modified.Unix(), body: body})
}
if documentXML == nil {
return nil, nil, fmt.Errorf("submission compose: base zip missing word/document.xml")
}
return documentXML, parts, nil
}
// repackBaseZip rebuilds the zip, swapping document.xml for the
// assembled body and leaving every other part untouched.
func repackBaseZip(parts []baseZipPart, assembledBody []byte) ([]byte, error) {
var out bytes.Buffer
zw := zip.NewWriter(&out)
for _, p := range parts {
hdr := &zip.FileHeader{
Name: p.name,
Method: p.method,
}
if p.modTime > 0 {
hdr.Modified = time.Unix(p.modTime, 0)
}
w, err := zw.CreateHeader(hdr)
if err != nil {
return nil, fmt.Errorf("submission compose: write header %s: %w", p.name, err)
}
body := p.body
if p.name == "word/document.xml" {
body = assembledBody
}
if _, err := w.Write(body); err != nil {
return nil, fmt.Errorf("submission compose: write body %s: %w", p.name, err)
}
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("submission compose: finalise zip: %w", err)
}
return out.Bytes(), nil
}
func readZipEntry(f *zip.File) ([]byte, error) {
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return io.ReadAll(rc)
}

View File

@@ -0,0 +1,276 @@
package services
// Unit tests for SubmissionComposer's pure splice logic — no DB
// dependency. The end-to-end Compose path is exercised by the live
// integration test in submission_section_service_live_test.go (Slice
// A) once anchors land in the seeded .docx; this file covers the
// anchor-splicing primitives and the section rendering glue.
import (
"archive/zip"
"bytes"
"context"
"strings"
"testing"
"github.com/google/uuid"
)
// minimalBaseBytes builds a tiny .docx zip with one document.xml body
// for the composer tests. The body content is provided by the caller
// so different splice scenarios can be exercised in-process.
func minimalBaseBytes(t *testing.T, body string) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
parts := map[string]string{
"[Content_Types].xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
</Types>`,
"_rels/.rels": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>`,
"word/document.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>` + body + `</w:body>
</w:document>`,
}
for name, contents := range parts {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("zip create %s: %v", name, err)
}
if _, err := w.Write([]byte(contents)); err != nil {
t.Fatalf("zip write %s: %v", name, err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("zip close: %v", err)
}
return buf.Bytes()
}
// extractDocumentXML pulls word/document.xml out of a .docx zip for
// assertions.
func extractDocumentXML(t *testing.T, data []byte) string {
t.Helper()
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
t.Fatalf("open zip: %v", err)
}
for _, f := range zr.File {
if f.Name != "word/document.xml" {
continue
}
rc, err := f.Open()
if err != nil {
t.Fatalf("open document.xml: %v", err)
}
defer rc.Close()
var buf bytes.Buffer
if _, err := buf.ReadFrom(rc); err != nil {
t.Fatalf("read document.xml: %v", err)
}
return buf.String()
}
t.Fatal("document.xml not found in zip")
return ""
}
// composerBase returns a SubmissionBase wired with the neutral
// stylemap for composer tests.
func composerBase() *SubmissionBase {
return &SubmissionBase{
ID: uuid.New(),
Slug: "test-base",
SectionSpec: BaseSectionSpec{
Version: 1,
Stylemap: map[string]string{
"paragraph": "Normal",
},
},
}
}
func TestComposer_AppendMode_NoAnchors(t *testing.T) {
// Base has no anchors → composer appends sections before sectPr.
base := composerBase()
body := `<w:p><w:r><w:t>Static chrome</w:t></w:r></w:p><w:sectPr/>`
baseBytes := minimalBaseBytes(t, body)
composer := NewSubmissionComposer(NewSubmissionRenderer())
sections := []SubmissionSection{
{ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: "Section text"},
}
out, err := composer.Compose(context.Background(), ComposeOptions{
Sections: sections,
Base: base,
BaseBytes: baseBytes,
Lang: "de",
Vars: PlaceholderMap{},
})
if err != nil {
t.Fatalf("Compose: %v", err)
}
docXML := extractDocumentXML(t, out)
if !strings.Contains(docXML, "Static chrome") {
t.Errorf("base chrome dropped: %q", docXML)
}
if !strings.Contains(docXML, "Section text") {
t.Errorf("section content missing: %q", docXML)
}
// Section must land before sectPr (rule of thumb: it's an end-of-body element).
staticIdx := strings.Index(docXML, "Section text")
sectPrIdx := strings.Index(docXML, "<w:sectPr")
if staticIdx < 0 || sectPrIdx < 0 || staticIdx > sectPrIdx {
t.Errorf("section landed after sectPr: section=%d sectPr=%d", staticIdx, sectPrIdx)
}
}
func TestComposer_AnchorMode_SpliceContent(t *testing.T) {
base := composerBase()
body := `<w:p><w:r><w:t>Header</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>{{#section:facts}}</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>(seed)</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>{{/section:facts}}</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>Footer</w:t></w:r></w:p>`
baseBytes := minimalBaseBytes(t, body)
composer := NewSubmissionComposer(NewSubmissionRenderer())
sections := []SubmissionSection{
{ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: "Real prose"},
}
out, err := composer.Compose(context.Background(), ComposeOptions{
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
})
if err != nil {
t.Fatalf("Compose: %v", err)
}
docXML := extractDocumentXML(t, out)
if !strings.Contains(docXML, "Header") || !strings.Contains(docXML, "Footer") {
t.Errorf("base chrome dropped: %q", docXML)
}
if !strings.Contains(docXML, "Real prose") {
t.Errorf("section content missing: %q", docXML)
}
// Anchor paragraphs themselves must be gone.
if strings.Contains(docXML, "{{#section:facts}}") || strings.Contains(docXML, "{{/section:facts}}") {
t.Errorf("anchor markers survived: %q", docXML)
}
// Seed content between anchors must be gone (replaced by the
// composed section).
if strings.Contains(docXML, "(seed)") {
t.Errorf("anchor-spanned seed survived: %q", docXML)
}
}
func TestComposer_ExcludedSection_DropsAnchorPair(t *testing.T) {
base := composerBase()
body := `<w:p><w:r><w:t>Header</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>{{#section:exhibits}}</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>(default)</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>{{/section:exhibits}}</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>Footer</w:t></w:r></w:p>`
baseBytes := minimalBaseBytes(t, body)
composer := NewSubmissionComposer(NewSubmissionRenderer())
sections := []SubmissionSection{
{ID: uuid.New(), SectionKey: "exhibits", OrderIndex: 8, Kind: "prose", Included: false, ContentMDDE: "ignored"},
}
out, err := composer.Compose(context.Background(), ComposeOptions{
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
})
if err != nil {
t.Fatalf("Compose: %v", err)
}
docXML := extractDocumentXML(t, out)
if strings.Contains(docXML, "{{#section:exhibits}}") || strings.Contains(docXML, "{{/section:exhibits}}") {
t.Errorf("anchors for excluded section survived: %q", docXML)
}
if strings.Contains(docXML, "ignored") {
t.Errorf("excluded section content rendered: %q", docXML)
}
}
func TestComposer_PlaceholdersResolve(t *testing.T) {
base := composerBase()
body := `<w:p><w:r><w:t>{{#section:greeting}}</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>{{/section:greeting}}</w:t></w:r></w:p>`
baseBytes := minimalBaseBytes(t, body)
composer := NewSubmissionComposer(NewSubmissionRenderer())
sections := []SubmissionSection{
{ID: uuid.New(), SectionKey: "greeting", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: "Hallo {{user.name}}"},
}
out, err := composer.Compose(context.Background(), ComposeOptions{
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
Vars: PlaceholderMap{"user.name": "Maria Schmidt"},
})
if err != nil {
t.Fatalf("Compose: %v", err)
}
docXML := extractDocumentXML(t, out)
if !strings.Contains(docXML, "Hallo") || !strings.Contains(docXML, "Maria Schmidt") {
t.Errorf("placeholder not substituted: %q", docXML)
}
if strings.Contains(docXML, "{{user.name}}") {
t.Errorf("placeholder survived: %q", docXML)
}
}
func TestComposer_LangPicksColumn(t *testing.T) {
base := composerBase()
body := `<w:p><w:r><w:t>{{#section:facts}}</w:t></w:r></w:p><w:p><w:r><w:t>{{/section:facts}}</w:t></w:r></w:p>`
baseBytes := minimalBaseBytes(t, body)
composer := NewSubmissionComposer(NewSubmissionRenderer())
sections := []SubmissionSection{
{ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true,
ContentMDDE: "deutscher text", ContentMDEN: "english text"},
}
deOut, _ := composer.Compose(context.Background(), ComposeOptions{
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
})
enOut, _ := composer.Compose(context.Background(), ComposeOptions{
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "en",
})
deXML := extractDocumentXML(t, deOut)
enXML := extractDocumentXML(t, enOut)
if !strings.Contains(deXML, "deutscher text") || strings.Contains(deXML, "english text") {
t.Errorf("DE pick failed: %q", deXML)
}
if !strings.Contains(enXML, "english text") || strings.Contains(enXML, "deutscher text") {
t.Errorf("EN pick failed: %q", enXML)
}
}
func TestComposer_OrderIndexAscending(t *testing.T) {
base := composerBase()
// No anchors → both sections append in order_index ASC order
// before sectPr.
body := `<w:sectPr/>`
baseBytes := minimalBaseBytes(t, body)
composer := NewSubmissionComposer(NewSubmissionRenderer())
sections := []SubmissionSection{
{ID: uuid.New(), SectionKey: "second", OrderIndex: 2, Kind: "prose", Included: true, ContentMDDE: "ZWEITER"},
{ID: uuid.New(), SectionKey: "first", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: "ERSTER"},
}
out, err := composer.Compose(context.Background(), ComposeOptions{
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
})
if err != nil {
t.Fatalf("Compose: %v", err)
}
docXML := extractDocumentXML(t, out)
firstIdx := strings.Index(docXML, "ERSTER")
secondIdx := strings.Index(docXML, "ZWEITER")
if firstIdx < 0 || secondIdx < 0 || firstIdx > secondIdx {
t.Errorf("order_index ASC not honoured: ERSTER=%d ZWEITER=%d", firstIdx, secondIdx)
}
}

View File

@@ -58,8 +58,17 @@ type SubmissionDraft struct {
LastExportedAt *time.Time `db:"last_exported_at" json:"last_exported_at,omitempty"`
LastExportedSHA *string `db:"last_exported_sha" json:"last_exported_sha,omitempty"`
LastImportedAt *time.Time `db:"last_imported_at" json:"last_imported_at,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
// BaseID is the Composer base reference (t-paliad-313). NULL on
// pre-Composer drafts — the v1 render path stays the fallback.
// ON DELETE SET NULL keeps a draft renderable if its base is
// removed; the lawyer picks a new one via the sidebar.
BaseID *uuid.UUID `db:"base_id" json:"base_id,omitempty"`
// ComposerMetaRaw / ComposerMeta — Composer-side metadata jsonb.
// Slice A: empty default. Future slices populate section_order,
// hidden_sections, etc.
ComposerMetaRaw []byte `db:"composer_meta" json:"-"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
// Variables is the decoded overrides map; populated on read by the
// service so callers don't have to unmarshal manually.
@@ -70,15 +79,36 @@ type SubmissionDraft struct {
// the backward-compat "include every party" behaviour; a non-empty
// slice restricts the variable bag to the listed paliad.parties rows.
SelectedParties []uuid.UUID `json:"selected_parties"`
// ComposerMeta is the parsed Composer-side metadata (t-paliad-313).
// Slice A: typically empty. Populated on read by decodeComposerMeta().
ComposerMeta map[string]any `json:"composer_meta"`
}
// SubmissionDraftService handles CRUD on submission_drafts and exposes
// the render/preview/export entry points the handler layer calls.
//
// The Composer wiring (t-paliad-313, Slice A): bases + sections are
// optional — when nil the service stays back-compat with the v1 shape
// (drafts created without a base_id, no section rows). When wired, new
// drafts created via Create get base_id seeded from the firm default
// and submission_sections rows inserted from the base's section spec.
type SubmissionDraftService struct {
db *sqlx.DB
projects *ProjectService
vars *SubmissionVarsService
renderer *SubmissionRenderer
// bases + sections are optional Composer wiring (t-paliad-313).
// Nil means "stay back-compat with the v1 shape" — new drafts
// keep base_id NULL and no submission_sections rows get seeded.
bases *BaseService
sections *SectionService
// firmName captures branding.Name at construction time. Used to
// resolve the firm-default base in Create. Empty string is
// allowed (treated as "no firm filter" at base-lookup time).
firmName string
}
// NewSubmissionDraftService wires the service.
@@ -91,6 +121,19 @@ func NewSubmissionDraftService(db *sqlx.DB, projects *ProjectService, vars *Subm
}
}
// AttachComposer wires the Composer-side services. Called by
// cmd/server/main.go after constructing the base + section services.
// firm is branding.Name (typically "HLC"); empty string disables the
// firm filter at default-base lookup.
//
// Calling AttachComposer is purely additive — drafts created before the
// call (or with bases==nil) keep the v1 behaviour. Idempotent.
func (s *SubmissionDraftService) AttachComposer(bases *BaseService, sections *SectionService, firm string) {
s.bases = bases
s.sections = sections
s.firmName = firm
}
// DraftPatch carries optional fields for Update. nil pointer = "no
// change"; non-nil = "set to this". Variables is replace-semantics —
// the lawyer's sidebar sends the full map every save.
@@ -117,6 +160,16 @@ type DraftPatch struct {
// Language sets the output language. Valid values: "de", "en".
// Anything else returns ErrInvalidInput. t-paliad-276.
Language *string
// BaseID swaps the Composer base. Two-level pointer mirrors the
// ProjectID shape so callers can encode the three operations:
// nil → no change
// *p == nil → clear (set base_id NULL, return to v1 fallback)
// **p → set to the picked base
// Slice A: lawyer flips this from the sidebar picker. Section
// content is unaffected — the base swap is render-side only.
// t-paliad-313.
BaseID **uuid.UUID
}
// ErrSubmissionDraftNotFound is the sentinel for "no draft with that id
@@ -133,6 +186,7 @@ const draftColumns = `id, project_id, submission_code, user_id, name, language,
variables, selected_parties,
last_exported_at, last_exported_sha,
last_imported_at,
base_id, composer_meta,
created_at, updated_at`
// List returns every draft for (project, submission_code, user)
@@ -185,6 +239,7 @@ func (s *SubmissionDraftService) ListAllForUser(ctx context.Context, userID uuid
`SELECT d.id, d.project_id, d.submission_code, d.user_id, d.name, d.language,
d.variables, d.selected_parties,
d.last_exported_at, d.last_exported_sha, d.last_imported_at,
d.base_id, d.composer_meta,
d.created_at, d.updated_at,
p.title AS project_title,
p.reference AS project_reference
@@ -279,6 +334,14 @@ func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, proje
// A nil projectID creates a project-less draft (t-paliad-243); the
// visibility check is skipped — the caller is the owner and the row is
// private to them.
//
// Composer wiring (t-paliad-313, Slice A): when AttachComposer has
// been called and a base resolves for the submission_code, the INSERT
// runs in a transaction alongside SectionService.SeedFromSpec so the
// new draft and its seeded sections land atomically. If the base
// lookup fails (catalog empty, no firm match, etc.) the draft still
// creates with base_id=NULL — Composer is additive, the v1 fallback
// path remains valid.
func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error) {
if projectID != nil {
if _, err := s.projects.GetByID(ctx, userID, *projectID); err != nil {
@@ -294,16 +357,61 @@ func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, p
// Anything other than "en" normalizes to "de" — matches the DB CHECK
// constraint and the project's primary-language default.
draftLang := normalizeDraftLanguage(lang)
// Resolve the Composer base for this draft. nil result keeps the
// draft v1-shaped (base_id NULL, no sections rows).
var baseToSeed *SubmissionBase
if s.bases != nil {
base, err := s.bases.GetDefaultForCode(ctx, s.firmName, submissionCode)
switch {
case err == nil:
baseToSeed = base
case errors.Is(err, ErrBaseNotFound):
// Catalog empty / no match — fall through to v1 shape.
default:
return nil, err
}
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin create submission draft tx: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
var baseID *uuid.UUID
if baseToSeed != nil {
id := baseToSeed.ID
baseID = &id
}
var d SubmissionDraft
err = s.db.GetContext(ctx, &d,
err = tx.GetContext(ctx, &d,
`INSERT INTO paliad.submission_drafts
(project_id, submission_code, user_id, name, language)
VALUES ($1, $2, $3, $4, $5)
(project_id, submission_code, user_id, name, language, base_id)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING `+draftColumns,
projectID, submissionCode, userID, name, draftLang)
projectID, submissionCode, userID, name, draftLang, baseID)
if err != nil {
return nil, fmt.Errorf("create submission draft: %w", err)
}
if baseToSeed != nil && s.sections != nil {
if err := s.sections.SeedFromSpec(ctx, tx, d.ID, baseToSeed.SectionSpec); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit create submission draft tx: %w", err)
}
committed = true
if err := d.decode(); err != nil {
return nil, err
}
@@ -446,6 +554,18 @@ func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uui
idx++
}
if patch.BaseID != nil {
newBID := *patch.BaseID // *uuid.UUID — nil means clear
if newBID != nil && s.bases != nil {
// Validate the picked base exists + is active.
if _, err := s.bases.GetByID(ctx, *newBID); err != nil {
return nil, err
}
}
setParts = append(setParts, fmt.Sprintf("base_id = $%d", idx))
args = append(args, newBID)
idx++
}
if len(setParts) == 0 {
return existing, nil
@@ -682,14 +802,32 @@ func (s *SubmissionDraftService) RenderProjectSubmission(ctx context.Context, us
return out, resolved, nil
}
// decode fills the parsed views (Variables, SelectedParties) from the
// raw scan fields. Called by every fetch path so the caller sees both
// populated together.
// decode fills the parsed views (Variables, SelectedParties,
// ComposerMeta) from the raw scan fields. Called by every fetch path
// so the caller sees them populated together.
func (d *SubmissionDraft) decode() error {
if err := d.decodeVariables(); err != nil {
return err
}
return d.decodeSelectedParties()
if err := d.decodeSelectedParties(); err != nil {
return err
}
return d.decodeComposerMeta()
}
// decodeComposerMeta turns the raw composer_meta jsonb into a
// map[string]any. NULL or empty payload yields an empty map.
func (d *SubmissionDraft) decodeComposerMeta() error {
if len(d.ComposerMetaRaw) == 0 {
d.ComposerMeta = map[string]any{}
return nil
}
out := map[string]any{}
if err := json.Unmarshal(d.ComposerMetaRaw, &out); err != nil {
return fmt.Errorf("decode submission draft composer_meta: %w", err)
}
d.ComposerMeta = out
return nil
}
// decodeVariables turns the raw jsonb bytes into the PlaceholderMap.

View File

@@ -0,0 +1,239 @@
package services
// Markdown → OOXML walker for Composer section content (t-paliad-313
// Slice B, design doc §9.2).
//
// Scope per the head's Slice B brief: paragraphs + inline bold/italic
// only. Headings, lists, blockquote, links land in Slice D's rich-prose
// pass. This walker is intentionally minimal — every Markdown construct
// it doesn't recognise is rendered as a plain paragraph so the lawyer's
// prose round-trips losslessly even when they hit Markdown the walker
// doesn't yet understand.
//
// The output uses the base's stylemap.paragraph entry for the
// <w:pStyle> on each paragraph so the styling matches the base's
// typography (HLpat-Body-B0 on the HLC base, Normal on the neutral
// base, etc.).
//
// Placeholders ({{path.dot.notation}}) are preserved verbatim — they
// pass through the walker untouched and get substituted by the v1
// SubmissionRenderer's placeholder pass after the composer assembly.
//
// Grammar supported:
//
// - Blank line → paragraph break
// - `**bold**` → <w:r><w:rPr><w:b/></w:rPr><w:t>…</w:t></w:r>
// - `*italic*` or `_italic_` → <w:r><w:rPr><w:i/></w:rPr>…</w:r>
// - Otherwise → plain text run
import (
"strings"
)
// RenderMarkdownToOOXML renders the given Markdown source into OOXML
// paragraph elements (`<w:p>…</w:p>`), suitable for splicing into a
// .docx body. Each paragraph carries `<w:pStyle w:val="<paragraphStyle>"/>`
// when paragraphStyle is non-empty.
//
// Empty input renders one empty paragraph so the splice site is
// well-formed even when the lawyer hasn't typed anything in this
// section.
func RenderMarkdownToOOXML(md, paragraphStyle string) string {
if md == "" {
return emptyParagraph(paragraphStyle)
}
paragraphs := splitMarkdownParagraphs(md)
if len(paragraphs) == 0 {
return emptyParagraph(paragraphStyle)
}
var b strings.Builder
for _, para := range paragraphs {
b.WriteString(renderParagraph(para, paragraphStyle))
}
return b.String()
}
// splitMarkdownParagraphs splits the source into paragraphs. A
// "paragraph" is a maximal run of non-blank lines. N consecutive blank
// lines between two paragraphs produce (N-1) empty paragraphs in the
// output so the lawyer's intentional vertical spacing survives.
//
// CRLF line endings normalise to LF before splitting.
func splitMarkdownParagraphs(md string) []string {
normalised := strings.ReplaceAll(md, "\r\n", "\n")
lines := strings.Split(normalised, "\n")
var paragraphs []string
var current []string
blankRun := 0
flushParagraph := func() {
if len(current) > 0 {
paragraphs = append(paragraphs, strings.Join(current, "\n"))
current = nil
}
}
for _, line := range lines {
if strings.TrimSpace(line) == "" {
if len(current) > 0 {
// End of a paragraph; the blank-counting starts now.
flushParagraph()
blankRun = 1
continue
}
// Already inside a blank run (or before the first paragraph).
blankRun++
continue
}
// Starting a new paragraph — emit (blankRun-1) empty paragraphs
// in between if the lawyer used multiple blank lines as
// vertical spacing.
for i := 1; i < blankRun; i++ {
paragraphs = append(paragraphs, "")
}
blankRun = 0
current = append(current, line)
}
flushParagraph()
return paragraphs
}
// renderParagraph emits one `<w:p>` element for the given paragraph
// text. Inline bold/italic spans become `<w:r>` runs with the
// corresponding `<w:rPr>`.
func renderParagraph(text, paragraphStyle string) string {
var b strings.Builder
b.WriteString(`<w:p>`)
if paragraphStyle != "" {
b.WriteString(`<w:pPr><w:pStyle w:val="`)
b.WriteString(xmlAttrEscape(paragraphStyle))
b.WriteString(`"/></w:pPr>`)
}
if text == "" {
// Empty paragraph — emit a single empty run so Word renders the
// paragraph as a blank line. Without the run, some Word
// versions collapse the paragraph entirely.
b.WriteString(`<w:r><w:t xml:space="preserve"></w:t></w:r>`)
b.WriteString(`</w:p>`)
return b.String()
}
for _, span := range parseInlineSpans(text) {
b.WriteString(renderRun(span))
}
b.WriteString(`</w:p>`)
return b.String()
}
// inlineSpan is one piece of inline content: a text payload plus
// formatting flags. Bold and italic are independent — `***both***`
// produces one span with both flags set.
type inlineSpan struct {
Text string
Bold bool
Italic bool
}
// parseInlineSpans tokenises Markdown inline formatting into runs of
// (text, bold, italic). The grammar is intentionally narrow:
//
// - `**…**` → bold
// - `__…__` → bold (Markdown alternate)
// - `*…*` → italic
// - `_…_` → italic (Markdown alternate)
// - Anything else flows through as plain text.
//
// Unbalanced delimiters fall through as literal characters — the
// walker never errors on malformed Markdown. Nested formatting (e.g.
// `**bold *bold-italic* bold**`) toggles flags as it walks.
func parseInlineSpans(text string) []inlineSpan {
var out []inlineSpan
var cur strings.Builder
bold := false
italic := false
flush := func() {
if cur.Len() == 0 {
return
}
out = append(out, inlineSpan{Text: cur.String(), Bold: bold, Italic: italic})
cur.Reset()
}
i := 0
n := len(text)
for i < n {
// Bold delimiters first (longer match wins over italic).
if i+1 < n && (text[i:i+2] == "**" || text[i:i+2] == "__") {
flush()
bold = !bold
i += 2
continue
}
if text[i] == '*' || text[i] == '_' {
flush()
italic = !italic
i++
continue
}
cur.WriteByte(text[i])
i++
}
flush()
if len(out) == 0 {
out = append(out, inlineSpan{Text: ""})
}
return out
}
// renderRun emits one `<w:r>` element for an inline span. Empty text
// spans render as empty runs (Word accepts them; they're harmless).
func renderRun(span inlineSpan) string {
var b strings.Builder
b.WriteString(`<w:r>`)
if span.Bold || span.Italic {
b.WriteString(`<w:rPr>`)
if span.Bold {
b.WriteString(`<w:b/>`)
}
if span.Italic {
b.WriteString(`<w:i/>`)
}
b.WriteString(`</w:rPr>`)
}
b.WriteString(`<w:t xml:space="preserve">`)
b.WriteString(xmlTextEscape(span.Text))
b.WriteString(`</w:t></w:r>`)
return b.String()
}
// emptyParagraph returns one empty `<w:p>` with the given style. Used
// when a section's content_md is empty so the splice site stays
// well-formed.
func emptyParagraph(paragraphStyle string) string {
var b strings.Builder
b.WriteString(`<w:p>`)
if paragraphStyle != "" {
b.WriteString(`<w:pPr><w:pStyle w:val="`)
b.WriteString(xmlAttrEscape(paragraphStyle))
b.WriteString(`"/></w:pPr>`)
}
b.WriteString(`<w:r><w:t xml:space="preserve"></w:t></w:r></w:p>`)
return b.String()
}
// xmlTextEscape escapes the five XML-significant characters for safe
// insertion into <w:t> content. & first to avoid double-encoding.
func xmlTextEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
// Quotes and apostrophes are legal inside element text content;
// no need to escape them here.
return s
}
// xmlAttrEscape escapes for safe insertion into an attribute value
// (e.g. `<w:pStyle w:val="…"/>`).
func xmlAttrEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
return s
}

View File

@@ -0,0 +1,146 @@
package services
// Unit tests for the Composer's Markdown → OOXML walker (t-paliad-313
// Slice B). Pure function; no DB dependency.
import (
"strings"
"testing"
)
func TestRenderMarkdownToOOXML_EmptyInput(t *testing.T) {
out := RenderMarkdownToOOXML("", "Normal")
if !strings.Contains(out, `<w:p>`) {
t.Errorf("empty input must still emit one <w:p>; got %q", out)
}
if !strings.Contains(out, `<w:pStyle w:val="Normal"/>`) {
t.Errorf("empty input must carry the paragraph style; got %q", out)
}
}
func TestRenderMarkdownToOOXML_SingleParagraph(t *testing.T) {
out := RenderMarkdownToOOXML("Hello world", "HLpat-Body-B0")
if !strings.Contains(out, `<w:pStyle w:val="HLpat-Body-B0"/>`) {
t.Errorf("paragraph missing stylemap entry: %q", out)
}
if !strings.Contains(out, "Hello world") {
t.Errorf("paragraph text missing: %q", out)
}
// Exactly one <w:p>.
if got := strings.Count(out, "<w:p>"); got != 1 {
t.Errorf("expected 1 <w:p>; got %d", got)
}
}
func TestRenderMarkdownToOOXML_TwoParagraphs(t *testing.T) {
out := RenderMarkdownToOOXML("first\n\nsecond", "Normal")
if got := strings.Count(out, "<w:p>"); got != 2 {
t.Errorf("expected 2 <w:p>; got %d, out=%q", got, out)
}
if !strings.Contains(out, "first") || !strings.Contains(out, "second") {
t.Errorf("paragraph text missing: %q", out)
}
}
func TestRenderMarkdownToOOXML_BoldInline(t *testing.T) {
out := RenderMarkdownToOOXML("hello **bold** world", "")
if !strings.Contains(out, `<w:rPr><w:b/></w:rPr>`) {
t.Errorf("bold rPr missing: %q", out)
}
if !strings.Contains(out, ">bold<") {
t.Errorf("bold text payload missing: %q", out)
}
// The surrounding "hello " and " world" pieces are separate runs;
// the bold rPr should appear exactly once in this output.
if got := strings.Count(out, "<w:b/>"); got != 1 {
t.Errorf("expected exactly one <w:b/> tag; got %d in %q", got, out)
}
}
func TestRenderMarkdownToOOXML_ItalicInline(t *testing.T) {
out := RenderMarkdownToOOXML("see *italic* here", "")
if !strings.Contains(out, `<w:rPr><w:i/></w:rPr>`) {
t.Errorf("italic rPr missing: %q", out)
}
if !strings.Contains(out, ">italic<") {
t.Errorf("italic text payload missing: %q", out)
}
}
func TestRenderMarkdownToOOXML_BoldItalicCombo(t *testing.T) {
// Nested: ***both*** → entering both flags. The walker toggles each
// delimiter independently, so the resulting run carries both <w:b/>
// and <w:i/>.
out := RenderMarkdownToOOXML("***both***", "")
if !strings.Contains(out, `<w:b/>`) || !strings.Contains(out, `<w:i/>`) {
t.Errorf("expected both <w:b/> and <w:i/>; got %q", out)
}
}
func TestRenderMarkdownToOOXML_PlaceholdersPassThrough(t *testing.T) {
// Placeholders are sacred — the walker must preserve them verbatim
// so the v1 placeholder pass can substitute them later.
out := RenderMarkdownToOOXML("Sehr geehrter {{parties.claimant.0.name}}", "Normal")
if !strings.Contains(out, "{{parties.claimant.0.name}}") {
t.Errorf("placeholder corrupted: %q", out)
}
}
func TestRenderMarkdownToOOXML_XMLEscape(t *testing.T) {
out := RenderMarkdownToOOXML("a & b < c > d", "")
if strings.Contains(out, " & ") {
t.Errorf("unescaped & survived: %q", out)
}
if !strings.Contains(out, "&amp;") || !strings.Contains(out, "&lt;") || !strings.Contains(out, "&gt;") {
t.Errorf("expected escaped entities; got %q", out)
}
}
func TestRenderMarkdownToOOXML_BlankLinesPreserveSpacing(t *testing.T) {
// Two blank lines between paragraphs → one empty paragraph in
// between, preserving the lawyer's intentional whitespace.
out := RenderMarkdownToOOXML("first\n\n\nsecond", "Normal")
if got := strings.Count(out, "<w:p>"); got != 3 {
t.Errorf("expected 3 <w:p> (first + blank + second); got %d in %q", got, out)
}
}
func TestRenderMarkdownToOOXML_CRLFNormalisation(t *testing.T) {
out := RenderMarkdownToOOXML("first\r\n\r\nsecond", "")
if got := strings.Count(out, "<w:p>"); got != 2 {
t.Errorf("CRLF input should produce 2 paragraphs; got %d in %q", got, out)
}
}
func TestParseInlineSpans_Plain(t *testing.T) {
spans := parseInlineSpans("hello world")
if len(spans) != 1 || spans[0].Bold || spans[0].Italic || spans[0].Text != "hello world" {
t.Errorf("expected single plain span; got %+v", spans)
}
}
func TestParseInlineSpans_UnderscoreItalic(t *testing.T) {
spans := parseInlineSpans("_emph_")
var italicHits int
for _, s := range spans {
if s.Italic && s.Text == "emph" {
italicHits++
}
}
if italicHits != 1 {
t.Errorf("expected one italic 'emph' span; got %+v", spans)
}
}
func TestParseInlineSpans_UnderscoreBold(t *testing.T) {
spans := parseInlineSpans("__strong__")
var boldHits int
for _, s := range spans {
if s.Bold && s.Text == "strong" {
boldHits++
}
}
if boldHits != 1 {
t.Errorf("expected one bold 'strong' span; got %+v", spans)
}
}

View File

@@ -0,0 +1,213 @@
package services
// Submission section service — Composer Slice A (t-paliad-313, design
// doc docs/design-submission-generator-v2-2026-05-26.md §4.3 + §6).
//
// Each row in paliad.submission_sections is one ordered, named block
// inside a Composer draft. Slice A seeds rows on draft create from the
// base's section_spec.defaults and exposes them read-only for the
// editor's section-list pane. Slice B turns them editable, Slice F
// adds reorder/hide/add-custom.
//
// Visibility flows through draft_id → submission_drafts → owner-scoped
// + can_see_project (RLS in mig 148 mirrors the four-policy shape on
// submission_drafts). Service calls go through SubmissionDraftService
// for the visibility gate before touching this table.
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// SubmissionSection mirrors a row in paliad.submission_sections.
type SubmissionSection struct {
ID uuid.UUID `db:"id" json:"id"`
DraftID uuid.UUID `db:"draft_id" json:"draft_id"`
SectionKey string `db:"section_key" json:"section_key"`
OrderIndex int `db:"order_index" json:"order_index"`
Kind string `db:"kind" json:"kind"`
LabelDE string `db:"label_de" json:"label_de"`
LabelEN string `db:"label_en" json:"label_en"`
Included bool `db:"included" json:"included"`
ContentMDDE string `db:"content_md_de" json:"content_md_de"`
ContentMDEN string `db:"content_md_en" json:"content_md_en"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// SectionService handles per-draft section rows. Slice A: read + seed
// only. Editable mutations land in Slice B's brief.
type SectionService struct {
db *sqlx.DB
}
// NewSectionService wires the service.
func NewSectionService(db *sqlx.DB) *SectionService {
return &SectionService{db: db}
}
// ErrSubmissionSectionNotFound is the sentinel for "no section with
// that id visible to this user".
var ErrSubmissionSectionNotFound = errors.New("submission section: not found")
const sectionColumns = `id, draft_id, section_key, order_index, kind,
label_de, label_en, included,
content_md_de, content_md_en,
created_at, updated_at`
// ListForDraft returns every section row for a draft, ordered by
// order_index ASC. Caller is responsible for the visibility gate
// (SubmissionDraftService.Get returns ErrSubmissionDraftNotFound for
// un-visible drafts, which the handler maps to 404). RLS in mig 148
// additionally enforces owner-scope at the DB layer.
func (s *SectionService) ListForDraft(ctx context.Context, draftID uuid.UUID) ([]SubmissionSection, error) {
var rows []SubmissionSection
err := s.db.SelectContext(ctx, &rows,
`SELECT `+sectionColumns+`
FROM paliad.submission_sections
WHERE draft_id = $1
ORDER BY order_index ASC`,
draftID)
if err != nil {
return nil, fmt.Errorf("list submission sections: %w", err)
}
return rows, nil
}
// Get returns one section by id. Visibility gate is the caller's
// responsibility — Slice A handlers wrap this with a SubmissionDraftService.Get
// to enforce owner+can_see_project before exposing the section.
func (s *SectionService) Get(ctx context.Context, sectionID uuid.UUID) (*SubmissionSection, error) {
var sec SubmissionSection
err := s.db.GetContext(ctx, &sec,
`SELECT `+sectionColumns+`
FROM paliad.submission_sections
WHERE id = $1`,
sectionID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrSubmissionSectionNotFound
}
if err != nil {
return nil, fmt.Errorf("get submission section: %w", err)
}
return &sec, nil
}
// SectionPatch carries optional fields for an Update call. nil pointer
// = "no change"; non-nil = "set to this".
type SectionPatch struct {
ContentMDDE *string
ContentMDEN *string
Included *bool
LabelDE *string
LabelEN *string
OrderIndex *int
}
// Update applies a patch to one section row. Visibility is the caller's
// responsibility — handlers wrap with SubmissionDraftService.Get for
// owner-scoped checks. The DB-level RLS policy mirrors that check.
//
// Returns the refreshed row. ErrSubmissionSectionNotFound when the
// section doesn't exist or the calling owner can't see it (RLS
// filters at the SELECT step).
func (s *SectionService) Update(ctx context.Context, sectionID uuid.UUID, patch SectionPatch) (*SubmissionSection, error) {
setParts := []string{}
args := []any{}
idx := 1
if patch.ContentMDDE != nil {
setParts = append(setParts, fmt.Sprintf("content_md_de = $%d", idx))
args = append(args, *patch.ContentMDDE)
idx++
}
if patch.ContentMDEN != nil {
setParts = append(setParts, fmt.Sprintf("content_md_en = $%d", idx))
args = append(args, *patch.ContentMDEN)
idx++
}
if patch.Included != nil {
setParts = append(setParts, fmt.Sprintf("included = $%d", idx))
args = append(args, *patch.Included)
idx++
}
if patch.LabelDE != nil {
setParts = append(setParts, fmt.Sprintf("label_de = $%d", idx))
args = append(args, *patch.LabelDE)
idx++
}
if patch.LabelEN != nil {
setParts = append(setParts, fmt.Sprintf("label_en = $%d", idx))
args = append(args, *patch.LabelEN)
idx++
}
if patch.OrderIndex != nil {
setParts = append(setParts, fmt.Sprintf("order_index = $%d", idx))
args = append(args, *patch.OrderIndex)
idx++
}
if len(setParts) == 0 {
return s.Get(ctx, sectionID)
}
args = append(args, sectionID)
q := fmt.Sprintf(
`UPDATE paliad.submission_sections
SET %s
WHERE id = $%d
RETURNING `+sectionColumns,
strings.Join(setParts, ", "), idx,
)
var sec SubmissionSection
err := s.db.GetContext(ctx, &sec, q, args...)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrSubmissionSectionNotFound
}
if err != nil {
return nil, fmt.Errorf("update submission section: %w", err)
}
return &sec, nil
}
// SeedFromSpec inserts one row per BaseSectionSpec.Default into
// submission_sections for the given draft. Runs inside the caller's
// transaction (the SubmissionDraftService.Create path wraps the
// draft INSERT + section seed in one tx so a failed seed rolls back
// the draft too).
//
// Idempotent at the row level — UNIQUE (draft_id, section_key) returns
// an error if the seed runs twice for the same draft, which is the
// desired safety net (we never want to silently double-seed).
//
// Per the Q10 ratification: every kind is one of prose | requests |
// evidence — there is no *_auto kind. Caption/letterhead/signature
// sections are regular prose rows seeded with bag-driven Markdown.
func (s *SectionService) SeedFromSpec(ctx context.Context, tx *sqlx.Tx, draftID uuid.UUID, spec BaseSectionSpec) error {
if len(spec.Defaults) == 0 {
return nil
}
for _, d := range spec.Defaults {
_, err := tx.ExecContext(ctx,
`INSERT INTO paliad.submission_sections
(draft_id, section_key, order_index, kind,
label_de, label_en, included,
content_md_de, content_md_en)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
draftID, d.SectionKey, d.OrderIndex, d.Kind,
d.LabelDE, d.LabelEN, d.Included,
d.SeedMDDE, d.SeedMDEN)
if err != nil {
return fmt.Errorf("seed submission section %s: %w", d.SectionKey, err)
}
}
return nil
}

View File

@@ -0,0 +1,178 @@
package services
// Live-DB integration tests for the Composer seeding flow (t-paliad-313
// Slice A). Skipped when TEST_DATABASE_URL is unset, mirroring the
// other live-DB tests (see cansee_test.go for the bootstrap pattern).
//
// Covers:
// 1. Mig 146 seeded the catalog: hlc-letterhead + neutral both
// resolve via GetBySlug and carry 10 section defaults each.
// 2. BaseService.GetDefaultForCode picks the firm-matched base for a
// canonical submission_code (e.g. de.inf.lg.erwidg) — Slice A
// contract that drives new-draft seeding.
// 3. SubmissionDraftService.Create on a fresh draft seeds base_id +
// 10 submission_sections rows in one transaction, with order_index
// ascending and bilingual labels populated.
import (
"context"
"os"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
func TestComposerSeedFlow(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
bases := NewBaseService(pool)
t.Run("seed catalog: hlc-letterhead has 10 default sections", func(t *testing.T) {
b, err := bases.GetBySlug(ctx, "hlc-letterhead")
if err != nil {
t.Fatalf("GetBySlug(hlc-letterhead): %v", err)
}
if got := len(b.SectionSpec.Defaults); got != 10 {
t.Errorf("len(Defaults) = %d; want 10", got)
}
if b.SectionSpec.Stylemap["heading_1"] != "HLpat-Heading-H1" {
t.Errorf("Stylemap[heading_1] = %q; want HLpat-Heading-H1", b.SectionSpec.Stylemap["heading_1"])
}
// Verify the section order is strictly ascending.
prev := 0
for _, d := range b.SectionSpec.Defaults {
if d.OrderIndex <= prev {
t.Errorf("non-ascending order_index: %d (prev=%d) at %s", d.OrderIndex, prev, d.SectionKey)
}
prev = d.OrderIndex
}
})
t.Run("seed catalog: neutral exists with universal stylemap", func(t *testing.T) {
b, err := bases.GetBySlug(ctx, "neutral")
if err != nil {
t.Fatalf("GetBySlug(neutral): %v", err)
}
if b.SectionSpec.Stylemap["heading_1"] != "Heading 1" {
t.Errorf("neutral Stylemap[heading_1] = %q; want \"Heading 1\"", b.SectionSpec.Stylemap["heading_1"])
}
})
t.Run("GetDefaultForCode firm match", func(t *testing.T) {
// HLC + de.inf.lg.erwidg → hlc-letterhead (firm-matched).
b, err := bases.GetDefaultForCode(ctx, "HLC", "de.inf.lg.erwidg")
if err != nil {
t.Fatalf("GetDefaultForCode HLC: %v", err)
}
if b.Slug != "hlc-letterhead" {
t.Errorf("Slug = %q; want hlc-letterhead", b.Slug)
}
})
t.Run("GetDefaultForCode falls back to neutral when no firm hint", func(t *testing.T) {
b, err := bases.GetDefaultForCode(ctx, "", "de.inf.lg.erwidg")
if err != nil {
t.Fatalf("GetDefaultForCode no-firm: %v", err)
}
// Without a firm hint, the fallback chain skips firm-matched
// queries and lands on the firm-NULL neutral base.
if b.Slug != "neutral" {
t.Errorf("Slug = %q; want neutral (firm-NULL fallback)", b.Slug)
}
})
// Section seeding via SubmissionDraftService.Create — exercises the
// transactional INSERT path. Requires a real auth.users + paliad.users
// row because submission_drafts.user_id is FK-constrained.
t.Run("SubmissionDraftService.Create seeds 10 section rows", func(t *testing.T) {
userID := uuid.New()
cleanup := func() {
pool.ExecContext(ctx, `DELETE FROM paliad.submission_sections WHERE draft_id IN (SELECT id FROM paliad.submission_drafts WHERE user_id = $1)`, userID)
pool.ExecContext(ctx, `DELETE FROM paliad.submission_drafts WHERE user_id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
}
cleanup()
defer cleanup()
email := "composer-seed-" + userID.String()[:8] + "@hlc.com"
if _, err := pool.ExecContext(ctx, `INSERT INTO auth.users (id, email) VALUES ($1, $2)`, userID, email); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
VALUES ($1, $2, 'Composer Seed', 'munich', 'standard', 'de')`,
userID, email); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
users := NewUserService(pool)
projects := NewProjectService(pool, users)
parties := NewPartyService(pool, projects)
vars := NewSubmissionVarsService(pool, projects, parties, users)
renderer := NewSubmissionRenderer()
drafts := NewSubmissionDraftService(pool, projects, vars, renderer)
sections := NewSectionService(pool)
drafts.AttachComposer(bases, sections, "HLC")
d, err := drafts.Create(ctx, userID, nil, "de.inf.lg.erwidg", "de")
if err != nil {
t.Fatalf("Create: %v", err)
}
if d.BaseID == nil {
t.Fatalf("BaseID = nil; want seeded base reference")
}
// hlc-letterhead is the firm default for HLC.
base, _ := bases.GetByID(ctx, *d.BaseID)
if base == nil || base.Slug != "hlc-letterhead" {
t.Errorf("seeded base slug = %v; want hlc-letterhead", base)
}
secs, err := sections.ListForDraft(ctx, d.ID)
if err != nil {
t.Fatalf("ListForDraft: %v", err)
}
if len(secs) != 10 {
t.Errorf("section count = %d; want 10", len(secs))
}
// Verify section_key set + bilingual labels populated.
wantKeys := map[string]bool{
"letterhead": false, "caption": false, "introduction": false,
"requests": false, "facts": false, "legal_argument": false,
"evidence": false, "exhibits": false, "closing": false, "signature": false,
}
prev := 0
for _, sec := range secs {
wantKeys[sec.SectionKey] = true
if sec.OrderIndex <= prev {
t.Errorf("non-ascending order_index: %d (prev=%d) at %s", sec.OrderIndex, prev, sec.SectionKey)
}
prev = sec.OrderIndex
if sec.LabelDE == "" || sec.LabelEN == "" {
t.Errorf("section %s missing bilingual label: de=%q en=%q", sec.SectionKey, sec.LabelDE, sec.LabelEN)
}
}
for k, seen := range wantKeys {
if !seen {
t.Errorf("missing seeded section_key: %s", k)
}
}
})
}

View File

@@ -243,7 +243,7 @@ func (s *SubmissionVarsService) loadPublishedRule(ctx context.Context, submissio
var rule models.DeadlineRule
err := s.db.GetContext(ctx, &rule,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE submission_code = $1
AND lifecycle_state = 'published'
AND is_active = true

View File

@@ -39,9 +39,17 @@ import (
"time"
)
// anchorsOnly switches the body emitter from the legacy variable-bag
// banner template to the Composer Slice B anchor-only body. Toggled
// via the -anchors flag; default true so the Slice B regen produces
// the composer-ready file.
var anchorsOnly = true
func main() {
out := flag.String("out", "_skeleton.docx", "output .docx path")
anchors := flag.Bool("anchors", true, "emit Composer-mode body with section anchors only (t-paliad-313 Slice B); false = legacy variable-bag banner body")
flag.Parse()
anchorsOnly = *anchors
docx, err := buildDocx()
if err != nil {
@@ -156,6 +164,45 @@ const stylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
// DEMO/SKELETON banner makes it obvious this is a starter template and
// not approved firm content.
func buildDocumentXML() string {
if anchorsOnly {
return buildAnchoredDocumentXML()
}
return buildLegacyDocumentXML()
}
// buildAnchoredDocumentXML emits the Composer-mode body: just section
// anchors. The composer pipeline (services/submission_compose.go)
// replaces each {{#section:KEY}}...{{/section:KEY}} paragraph pair
// with the rendered section content from submission_sections.
// Pre-Composer drafts continue to use the legacy body (run with
// -anchors=false).
//
// Order matches the default section spec in mig 146:
// letterhead, caption, introduction, requests, facts,
// legal_argument, evidence, exhibits, closing, signature.
func buildAnchoredDocumentXML() string {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
b.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`)
b.WriteString(`<w:body>`)
anchorPair := func(key string) {
plain(&b, "{{#section:"+key+"}}")
plain(&b, "{{/section:"+key+"}}")
}
for _, key := range []string{
"letterhead", "caption", "introduction", "requests",
"facts", "legal_argument", "evidence", "exhibits",
"closing", "signature",
} {
anchorPair(key)
}
b.WriteString(`</w:body></w:document>`)
return b.String()
}
func buildLegacyDocumentXML() string {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
b.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`)