Compare commits

..

27 Commits

Author SHA1 Message Date
mAi
6acb1167dd feat(admin): add proceeding-type column to /admin/procedural-events list (t-paliad-321 / m/paliad#144)
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
Surfaces the 3-segment proceeding code (e.g. upc.inf.cfi) on the admin
rules list so the 4 legitimately-distinct same-named groups are
visually disambiguated without opening each row's edit page.

Specifically helps with:
- "Antrag auf Patentänderung" × 4 (distinct proceeding_type_ids)
- "Beginn des Hauptsacheverfahrens" × 2
- "Berufungsbegründung-R.220.1" × 2
- "Berufungsschrift-R.220.1" × 2

(The 6× "Mängelbeseitigung / Zahlung" identical clones are dedup'd by
mig 152 in the sibling commit; this column lets m verify the dedupe
landed and confirms the remaining same-named groups are intentional.)

* internal/services/rule_editor_service.go —
  - LoadProceedingTypeCodes(ctx, rows) — batch SELECT id, code FROM
    paliad.proceeding_types WHERE id = ANY(...) for every distinct
    non-NULL proceeding_type_id in rows. Returns id → code map.
    Single round-trip, firm-wide reference data (no RLS / visibility
    gate). Used only by the LIST endpoint; GetByID etc. don't need it.

* internal/handlers/admin_rules.go —
  - adminRuleResponse gains ProceedingTypeCode *string field
    (json:"proceeding_type_code,omitempty"). Populated by
    wrapRuleListResponse from the id → code map.
  - handleAdminListRules calls LoadProceedingTypeCodes after fetching
    rows, passes the map to wrapRuleListResponse.

* frontend/src/admin-rules-list.tsx —
  - Adds Proceeding column header in position 2 (between Submission
    Code and Legal Citation) per paliadin's "Place between submission-
    code and the existing columns" spec. Binds to canonical i18n
    key admin.procedural_events.col.proceeding (added below).
  - Drops the legacy Verfahrenstyp column at position 4 — the new
    code-only column at position 2 replaces it; the old column
    showed `code · name` which duplicates the new content.

* frontend/src/client/admin-rules-list.ts —
  - Rule type gains proceeding_type_code?: string | null.
  - New proceedingCodeCell(r) helper: prefers server-side
    proceeding_type_code, falls back to dropdown-lookup
    proceedingLabel for defense-in-depth on older API responses
    (the old behaviour broke for rules whose proceeding_type_id
    pointed at non-fristenrechner category proceedings; the new
    column never has that bug because the join is server-side).
  - Row rendering: new <td class="admin-rules-col-proceeding"><code>
    proceedingCodeCell(r) </code></td> in column 2.

* frontend/src/client/i18n.ts —
  - admin.procedural_events.col.proceeding alias added for DE +
    EN ("Verfahren" / "Proceeding"). Mirror style of the other
    canonical aliases from Slice A.

* frontend/src/i18n-keys.ts —
  - Generated key union extended with
    "admin.procedural_events.col.proceeding".

Build + vet clean. No new SQL — proceeding_types is firm-wide
reference data and the join uses an existing primary key.
2026-05-26 21:27:00 +02:00
mAi
4cd28bc896 feat(db): mig 152 — dedupe identical sequencing_rule clones (5 archived) (t-paliad-321 / m/paliad#144 follow-up)
Mig 151 (t-paliad-319) archived 5 of 6 duplicate procedural_events for
"Mängelbeseitigung / Zahlung" and reparented their sequencing_rules
onto the canonical PE. The 6 sequencing_rules themselves were left
active — and they are byte-for-byte clones (proceeding_type_id=NULL,
rule_code=NULL, duration 14d, primary_party=NULL, condition_expr=NULL,
…). The admin shows six indistinguishable rows for one legal concept.

This migration archives 5 of 6, keeping the row with the
lexicographically lowest UUID as canonical.

Pre-write verification (Supabase MCP, 2026-05-26):
- Exactly 1 clone-group surfaces under the full-signature query
  (procedural_event_id, proceeding_type_id, rule_code, duration_*,
  primary_party, condition_expr::text, trigger_event_id, alt_*,
  anchor_alt, combine_op, parent_id, is_spawn, spawn_*):
  6 "Mängelbeseitigung / Zahlung" rows.
- 0 paliad.deadlines reference any of the 5 to-be-archived rows
  (verified via deadlines.sequencing_rule_id JOIN; rule_id column
  was dropped in mig 140 / Slice B.4).
- Other name-duplicates (Antrag auf Patentänderung×4, Beginn des
  Hauptsacheverfahrens×2, Berufungsbegründung-R.220.1×2,
  Berufungsschrift-R.220.1×2) do NOT collapse under this signature —
  their proceeding_type_id / rule_code / duration / primary_party
  differ. Legitimately distinct rules per proceeding. This mig
  leaves them alone.

Migration shape (mirrors mig 151):
1. Build dedupe mapping (duplicate_id → canonical_id) into a
   ROW_NUMBER() OVER (PARTITION BY full-signature ORDER BY
   created_at, id::text) TEMP table.
2. PRE NOTICE: surface every clone-group with its canonical + dups
   so the deploy log shows what's about to be touched (m may want
   to spot-check).
3. Snapshot the duplicates into paliad.sequencing_rules_pre_152
   (precedent pre_091/093/095/098/140/151).
4. Reparent paliad.deadlines.sequencing_rule_id duplicate → canonical
   BEFORE archiving (defensive no-op today).
5. set_config('paliad.audit_reason', …) — defensive; sequencing_rules
   has no audit trigger yet (mig 151 §scope verified), but a future
   trigger would inherit the reason automatically.
6. UPDATE sequencing_rules SET is_active=false,
   lifecycle_state='archived' WHERE id IN dups.
7. POST assertions: expected archive count met, zero clone groups
   remaining in active+published, zero live deadlines pointing at
   an archived sequencing_rule. RAISE EXCEPTION on any mismatch.

Down: best-effort revert (flips archived → published from snapshot).
Doesn't undo the deadlines reparent (live data didn't need one;
snapshot doesn't carry pre-state of deadlines).

Build + vet clean. TestMigrations_NoDuplicateSlot passes.
2026-05-26 21:21:38 +02:00
mAi
568eac0aff Merge: t-paliad-320 — editorial seed cmd for 5 orphan deadline_concept drafts (4 concepts) (m/paliad#193)
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
darwin (researcher + /mai-lexy) staged 5 lifecycle_state='draft' sequencing_rules via services.RuleEditorService.Create() for the 4 remaining orphan deadline_concepts:

  - counterclaim-for-revocation → upc.ccr.cfi, RoP.025, 3 months (32aafb64)
  - versaeumnisurteil-einspruch  → de.inf.lg, § 339 ZPO, 2 weeks Notfrist (eda1756a)
  - schriftsatznachreichung      → de.inf.lg, § 283 ZPO, 3 weeks court-set (08b1682a)
  - weiterbehandlung (EPC)       → epa.grant.exa, Art. 121 EPÜ + R. 135(1), 2 months (73674564)
  - weiterbehandlung (DPatG)     → event-rooted (NULL proc), § 123a PatG, 1 month (16e262d2)

Deliverable: cmd/seed-orphan-concept-drafts/main.go — runs against
RuleEditorService in-process; idempotent; audit-reason flag.

Editorial follow-up flagged in DPatG rule's deadline_notes: no
dpma.grant.* proceeding_type exists yet; create dpma.grant.dpma and
reassign rule 16e262d2 once added.

Drafts ready for m's editorial review at /admin/procedural-events.
2026-05-26 21:07:52 +02:00
mAi
733d21c930 feat(seed): editorial cmd to stage drafts for orphan deadline_concepts (t-paliad-320)
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
Stages five lifecycle_state='draft' sequencing_rules — one per orphan
deadline_concept — via services.RuleEditorService.Create(), the same
service the POST /admin/api/procedural-events handler hits internally
(audit trigger + INSTEAD-OF view trigger fan-out into procedural_events
+ sequencing_rules + legal_sources). No HTTP/auth shell, no raw SQL
writes.

Drafts (slug → proceeding):
- counterclaim-for-revocation → upc.ccr.cfi, 3 months, RoP.025
- versaeumnisurteil-einspruch → de.inf.lg, 2 weeks Notfrist, § 339 ZPO
- schriftsatznachreichung → de.inf.lg, 3 weeks court-set, § 283 ZPO
- weiterbehandlung (EPC) → epa.grant.exa, 2 months, Art. 121 EPÜ + R. 135(1) EPÜ
- weiterbehandlung (DPatG § 123a) → event-rooted (NULL proc), 1 month

The DPatG variant is event-rooted because no dpma.grant.* proceeding_type
exists yet — flagged in deadline_notes as editorial follow-up.

Idempotent: refuses to insert if (concept, proceeding, rule_code)
already exists.
2026-05-26 21:04:36 +02:00
mAi
b05bcf7eeb Merge: t-paliad-319 — mig 151 dedupe null.* procedural_events (9 archived, 5 name-groups consolidated) (m/paliad#144)
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 20:54:50 +02:00
mAi
71e8023784 feat(db): mig 151 — dedupe null.* procedural_events (t-paliad-319 / m/paliad#144)
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
Consolidates 5 name-groups with synthetic null.<8hex> codes (minted by
mig 136 from legacy submission_code IS NULL rows) onto a single canonical
PE per name. 9 duplicate rows archived (is_active=false,
lifecycle_state='archived'), 9 sequencing_rules reparented onto their
canonical procedural_event. Worst offender: "Mängelbeseitigung /
Zahlung" 6 → 1.

Audit-first: per-row RAISE NOTICE before the writes, plus snapshots in
paliad.procedural_events_pre_151 and paliad.sequencing_rules_pre_151
(same TX, mirrors precedent pre_091/093/095/098/140). Post-asserts that
no name-group still has >1 active+published null.* row and no sr points
at an archived PE.

Pre-flight schema audit confirmed no audit trigger on procedural_events
or sequencing_rules (only INSTEAD OF triggers on deadline_rules_unified,
which don't fire on direct table writes), 0 deadlines + 0 draft_of refs
to the duplicates, and lifecycle_state has no CHECK constraint blocking
'archived'.

.down.sql best-effort restores sr.procedural_event_id and reactivates
the archived rows from the snapshot tables.

Mig already applied to youpc paliad schema via Supabase MCP within the
same TX as the applied_migrations row insert (checksum matches the
embedded file); deployed binary will see version 151 as applied.
2026-05-26 20:54:01 +02:00
mAi
d190fbe0a4 Merge: hotfix #3 mig 140 — filter POST check to active+published (B.2 dual-write scope)
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 20:32:58 +02:00
mAi
e0a82d9f9e fix(mig 140): post-check filters to active+published rows only
The previous post-check compared unfiltered counts (snapshot 493 vs
sequencing_rules 231) and false-positived as "dual-write drift". Reality:
B.2 dual-write was scoped to is_active=true + lifecycle_state='published'
(the read-path universe). Archived + draft rows in deadline_rules were
never replicated to sequencing_rules because nothing read them.

Patch: filter both counts to active+published before comparison — the
invariant B.2 actually maintained. Archived/draft rows survive in
deadline_rules_pre_140 for forensic / future-backfill.

Third hotfix on mig 140 today (1: missing matview drop; 2: wrong post-check
comparand; 3: post-check missing lifecycle filter). The slice itself is
sound — every failure was in the verification path, not the data.
2026-05-26 20:32:58 +02:00
mAi
d326f9aa4a Merge: hotfix mig 140 — POST check compares snapshot to sequencing_rules (was view) (m/paliad#93 hotfix #2)
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 20:28:45 +02:00
mAi
026ad2d5ee fix(mig 140): POST integrity check compares snapshot to sequencing_rules, not view
The previous post-check compared paliad.deadline_rules_pre_140 row count
to paliad.deadline_rules_unified row count and failed with
"snapshot has 493 rows, view has 231 rows — drift". That's a false
positive: the snapshot has every row (all lifecycle states + is_active),
the view filters to is_active+published. They're not supposed to match.

The right invariant: snapshot row count == sequencing_rules row count
(B.2 dual-write keeps them 1:1 across all lifecycle states). Patched.
View count stays in the RAISE NOTICE line as informational.

Refs t-paliad-305 / m/paliad#93 Slice B.4 hotfix #2.
2026-05-26 20:28:36 +02:00
mAi
13a65a6d6e Merge: Composer Slice F — section reorder/hide/add custom. Composer A→F complete (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 20:27:43 +02:00
mAi
bd7896ef68 feat(submissions): Composer Slice F — section reorder / hide / add custom (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 final Composer slice per design doc §12. Lawyer gains full
control over section composition: drag-and-drop reorder, per-section
delete, "+ Add section" picker for custom slugs that don't appear in
the base's default spec. Combined with Slice B's hide toggle, this
closes out the A→F sequence — Composer A→F is complete.

Backend (internal/services/submission_section_service.go, +120 LoC):

- SectionService.Create — adds a new section row to a draft. Validates
  section_key + labels + kind (must be prose/requests/evidence).
  Auto-assigns next order_index when OrderIndex=0; collisions on
  (draft_id, section_key) surface as ErrInvalidInput.

- SectionService.Delete — removes one section by id. Returns
  ErrSubmissionSectionNotFound when nothing was deleted.

- SectionService.Reorder — accepts a sequence of section_ids, rewrites
  every row's order_index to (1..N)×10 transactionally. Returns the
  refreshed list. Sections not present in the sequence are silently
  ignored (defensive — partial reorder doesn't lose rows).

Handlers (internal/handlers/submission_sections.go, +180 LoC):

- POST /api/submission-drafts/{draft_id}/sections — owner-scoped via
  SubmissionDraftService.Get. 400 on slug collision / invalid kind.
- DELETE /api/submission-drafts/{draft_id}/sections/{section_id} —
  owner + section-belongs-to-draft cross-check. 204 on success.
- POST /api/submission-drafts/{draft_id}/sections/reorder — accepts
  {"section_order": [uuid, uuid, ...]}; returns refreshed sections list.

Frontend (frontend/src/client/submission-draft.ts, +260 LoC):

- Each section row gains a drag handle (⋮⋮) on the left of the head.
  Drag handle is the only draggable element; contentEditable
  selections inside the editor body keep working. HTML5 native DnD,
  no library.
- Drop-target highlighting via .submission-draft-section--drop-target
  (border-top accent). Cleanup on dragend / drop / cancel.
- Per-section "Delete" button next to the existing Hide/Include
  toggle. Confirm prompt prevents accidental loss of typed prose.
- "+ Add section" trailing affordance below the section list opens an
  inline form (slug + DE label + EN label + kind dropdown). Submit
  POSTs to the new endpoint; on success splices the row into
  state.view.sections and re-paints.

CSS (frontend/src/styles/global.css, +65 LoC):

- .submission-draft-section-handle (grab cursor + hover background +
  active=grabbing).
- .submission-draft-section--dragging / --drop-target visual states.
- .submission-draft-add-section form layout (dashed border + lime
  primary submit).

Tests (internal/services/submission_section_slice_f_test.go, NEW,
TEST_DATABASE_URL-gated):
- Create custom section + slug-collision surface as ErrInvalidInput.
- Delete + repeat-delete returns ErrSubmissionSectionNotFound.
- Reorder reverses 10 seeded sections + verifies the resulting
  order_index sequence is ascending and matches the input order.

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

Hard rules honoured:
- NO new migrations (Slice F is pure code on Slice A's schema).
- NO behavior change for pre-Composer drafts (no section rows → no
  drag handles to drag).
- {{rule.X}} aliases preserved (custom sections render through the
  same composer pipeline as default sections).
- Q2/Q9/Q10 ratifications preserved.

This closes the Composer slice sequence A → F. The full feature set
ratified by m on 2026-05-26 is now in place:
  A — base picker + read-only section list (mig 146/147/148)
  B — editable prose + anchor-spliced render + MD→OOXML walker
  C — building-blocks library + section picker (mig 149)
  D — rich prose (headings, lists, blockquote, hyperlinks)
  E — specialist bases lg-duesseldorf + upc-formal (mig 150)
  F — section reorder / delete / add custom

t-paliad-318 Slice F
2026-05-26 20:26:53 +02:00
mAi
946f373651 Merge: Composer Slice E — specialist bases lg-duesseldorf + upc-formal (mig 150) + base-swap content survival (m/paliad#141)
Some checks failed
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 20:21:56 +02:00
mAi
94310ba498 feat(submissions): Composer Slice E — specialist bases + base-swap content survival (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
Two new firm-agnostic base templates + the generic generator that
produced them + a regression test pinning Q10's base-swap-content-
survival contract.

Mig 150: seeds two `submission_bases` rows with firm=NULL.

- lg-duesseldorf — proceeding_family='de.inf.lg'. Conservative
  German legal style: Times New Roman 11pt; plain black headings.
  Stylemap targets LG-Body / LG-Heading1..3 / LG-ListBullet /
  LG-ListNumber / LG-Quote.

- upc-formal — proceeding_family='upc.inf.cfi'. UPC court style:
  Calibri 11pt body; UPC-blue (#1F3864) headings; Cambria italic
  for blockquotes. Stylemap targets UPC-Body / UPC-Heading1..3 / …

Both rows ship the same 10-section spec.defaults shape as the Slice A
bases (letterhead → signature) with their own seed Markdown.

scripts/gen-submission-base/main.go (NEW, ~240 LoC):

- Generic generator with -preset flag. Two presets baked in
  (lg-duesseldorf + upc-formal). Each preset hard-codes typography
  (font, sizes, colour) so the lawyer can swap between bases and
  see chrome change while section content carries through unchanged.
- Output is byte-reproducible (zip mtime pinned to 2026-05-26 UTC).
- Emits a minimal Composer-mode .docx: [Content_Types].xml,
  _rels/.rels, word/_rels/document.xml.rels (empty envelope so the
  composer's hyperlink-rels patch from Slice D has somewhere to land),
  word/styles.xml (preset's full named-style block + "Hyperlink"
  character style for Slice D link runs), word/document.xml (anchor-
  only body in §6.1 default section order).

Gitea uploads (via mAi):

- 6 - material/Templates/Word/Paliad/Composer/lg-duesseldorf.docx
  blob SHA: 82f57b3cb3b54c755fc5ab36862bfd61b8aaa73e
- 6 - material/Templates/Word/Paliad/Composer/upc-formal.docx
  blob SHA: 41b9a388263ccc43ddc28b55caab301a4cf74fe8

These live under Composer/ (not under HLC/) so a future non-HLC
deployment serves the same cross-firm files.

Backend wiring:

- internal/handlers/files.go: two new fileRegistry entries
  (composerBaseLGDuesseldorfSlug, composerBaseUPCFormalSlug) +
  matching slugs in composerBaseSlugMap so fetchComposerBaseBytes
  routes the new catalog rows to the new Gitea objects.

Tests:

- TestComposer_BaseSwapPreservesContent — composes the same draft
  against an HLC-style stylemap AND an LG-style stylemap; asserts
  (a) content survives both ways, (b) each output carries the
  correct stylemap-entry stylenames, (c) neither output leaks the
  other's stylenames. Pins Q10's base-swap-survives-content
  contract.

Build hygiene: go build/vet/test -short clean (all packages);
bun run build clean.

NOT in scope (Slice E's brief was specialist bases + survival test):

- Generator coverage for HL Patents Style bases — gen-hl-skeleton-
  template stays as the per-firm path (it needs the proprietary
  .dotm source). gen-submission-base is for firm-agnostic bases.
- LG-Düsseldorf-court-style-guide deep fidelity — the LG preset is
  a conservative starting point; admin refines via the admin editor
  in a later slice if needed.
- numbering.xml carrying numId=1/2 — Slice D's MD walker emits
  visible "• " / "N. " prefixes that don't need numbering.xml;
  honours stylemap entry for indentation.

Hard rules honoured:

- Migration purely additive (`ON CONFLICT (slug) DO NOTHING`).
- NO behavior change for pre-Composer drafts.
- NO behavior change for existing hlc-letterhead + neutral seed
  rows.
- {{rule.X}} aliases preserved (walker passes placeholders through;
  v1 SubmissionRenderer pass substitutes).
- Q10 base-swap-content-survival pinned by new test.

t-paliad-317 Slice E
2026-05-26 20:21:12 +02:00
mAi
5834e3dc66 Merge: Composer Slice D — rich prose (headings, lists, blockquote, hyperlinks) in 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 20:16:14 +02:00
mAi
677849784c feat(submissions): Composer Slice D — rich prose (headings, lists, blockquote, hyperlinks) (m/paliad#141)
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
Extends the Composer's MD → OOXML walker per the design at
docs/design-submission-generator-v2-2026-05-26.md §12 Slice D from
Slice B's paragraphs + B/I baseline to the full rich-prose feature set:
headings 1-3, bullet + numbered lists, blockquote, inline hyperlinks.

MD walker (internal/services/submission_md.go, +320 / -75 LoC):
- RenderMarkdownToOOXMLWithStyles is the new Slice-D entry point;
  RenderMarkdownToOOXML stays as a thin back-compat wrapper.
- splitMarkdownBlocks classifies every line into one of:
  paragraph, heading_1/2/3, list_bullet, list_numbered, blockquote.
  CommonMark-style 3-space indent tolerance; "N. " and "N) " for
  numbered. Blank-line spacing semantics preserved from Slice B.
- renderBlockParagraph applies stylemap[blk.styleKey] (with
  fall-back to stylemap["paragraph"]). List blocks emit visible
  "• " / "N. " prefix runs so the structure surfaces even if Word
  isn't configured with auto-list-numbering — lawyer can apply a
  real Word list style post-export. Numbered-list ordinals reset
  on every non-list block (so "1. A\nplain\n1. C" renders 1./1.,
  not 1./2.).
- parseInlineRuns adds `[label](url)` recognition. Each link gets
  routed through the optional HyperlinkAllocator; the walker emits
  `<w:hyperlink r:id="{rId}">…runs…</w:hyperlink>` with the
  "Hyperlink" character style on each child run. Nil allocator
  falls back to plain-text label (URL drops, label survives).

Composer (internal/services/submission_compose.go, +130 / -10 LoC):
- composerLinkAllocator hands the walker fresh rIds (rIdComposer1,
  rIdComposer2, …) outside the base's existing namespace; same URL
  shared across multiple sections dedupes to one rId.
- patchDocumentXMLRels appends matching <Relationship Type="…/hyperlink"
  Target="URL" TargetMode="External"/> entries to
  word/_rels/document.xml.rels. Idempotent on rIds already present;
  synthesizes a fresh rels part when missing (defensive for stripped
  bases). Returns the patched parts slice (caller must overwrite
  because append may grow the backing array — fixed in this slice).
- Compose now passes the full stylemap (paragraph + heading_1/2/3 +
  list_bullet + list_numbered + blockquote) into the walker, not
  just the paragraph-style entry.

Frontend (frontend/src/client/submission-draft.ts):
- Toolbar adds H1/H2/H3 buttons (formatBlock h1/h2/h3), bullet
  list, numbered list, blockquote, and a link button that prompts
  for a URL + wraps the selection via execCommand("createLink").
- domToMarkdown serializer extends to <h1>/<h2>/<h3>, <ul>/<ol>
  with per-item ordinal counter for numbered lists, <blockquote>,
  and <a href="…"> → `[label](url)`. Nested <li> handling sits in
  the ul/ol branch.

Tests (internal/services/submission_md_test.go, internal/services/
submission_compose_test.go):
- TestRenderMarkdownToOOXML_Heading1 / _Heading2And3 — stylemap
  applied.
- _BulletList / _NumberedList / _NumberedListResetsOnNonList —
  prefixes + ordinal counter.
- _Blockquote — stylemap applied.
- _Hyperlink — allocator called, w:hyperlink rId wired, Hyperlink
  character style on label runs.
- _HyperlinkNilAllocatorFallsBackToPlain — label survives, no
  hyperlink tag emitted.
- TestDetectBlockMarker — 13 marker / non-marker cases.
- TestComposer_HeadingsAndLists — end-to-end through Compose with
  a multi-construct draft; verifies stylemap presence + content +
  ordinal prefixes.
- TestComposer_HyperlinkWiresRels — body has the right
  <w:hyperlink r:id="rIdComposer{N}">, document.xml.rels has the
  matching <Relationship> rows with External target mode.
- TestComposer_HyperlinkDedupesByURL — two `[label](url)` references
  to the same URL share one rId; second allocation gets no new
  Relationship row.

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

NOT in scope (Slice D's brief was rich-prose + toolbar):
- Numbering.xml audit on bases — current approach emits visible
  "• " / "N. " prefix runs without depending on numbering.xml. A
  future slice can swap to `<w:numPr>` if firm-style auto-numbering
  becomes a hard requirement.
- DOM-from-Markdown on initial editor paint — the editor still uses
  textContent=md, so toolbar-applied formatting reverts to literal
  Markdown text after autosave + repaint. Acceptable trade-off for
  Slice D; a future polish could parse MD into the DOM on paint.
- Tables, images, footnotes (still design §13 out of scope).

Hard rules honoured:
- NO new migrations (Slice D is pure code).
- NO behavior change for pre-Composer drafts (gate on draft.BaseID
  unchanged).
- {{rule.X}} aliases preserved (placeholders pass through the walker
  verbatim, get substituted by the v1 SubmissionRenderer pass).
- Q2 ratification preserved (no building_block_id lineage).
- Q9 ratification preserved (4-tier BB visibility from Slice C).

t-paliad-316 Slice D
2026-05-26 20:15:28 +02:00
mAi
b27d402156 Merge: Slice B.6 — /admin/rules → /admin/procedural-events URL rename + 301 redirects + .tsx i18n rebind. #93 slice train concludes (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 20:13:17 +02:00
mAi
14290294b4 Merge: hotfix mig 140 — drop+recreate deadline_search matview (unblock prod)
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 20:12:55 +02:00
mAi
6b970da774 fix(mig 140): drop+recreate deadline_search matview (was blocking DROP TABLE deadline_rules)
prod-down: mig 140 fails with `cannot drop table deadline_rules because
other objects depend on it (2BP01)`. The dependent object is the
deadline_search materialized view (mig 077) — curie's brief listed FK
re-pointing but missed the matview.

Fix: drop the matview before DROP TABLE deadline_rules, recreate it at
the end of mig 140 against deadline_rules_unified (same column shape).
All 11 indexes restored. REFRESH at end so search keeps working.

Single-TX atomicity preserved — if anything past step 6a fails, the
whole drop-and-recreate rolls back. The pre_140 snapshot from step 1
remains as the forensic backstop.

Refs t-paliad-305 / m/paliad#93 Slice B.4.
2026-05-26 20:12:49 +02:00
mAi
9359e99a6b feat(handlers,frontend): Slice B.6 — admin URL rename /admin/rules → /admin/procedural-events with 301 redirects + .tsx i18n rebind (t-paliad-305 / m/paliad#93)
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
Closes the procedural-events rename loop opened by m/paliad#93. The
admin surface now lives under its canonical URL; the legacy paths
remain reachable for one deprecation cycle via 301 redirects so
bookmarks, audit-log entries, and curl scripts keep working.

* internal/handlers/handlers.go —
  - Registers the 12 canonical routes under
    /admin/procedural-events* (page paths and JSON API). Same
    handlers — just the new URL slot.
  - Registers the 12 legacy /admin/rules* routes as 301 redirects.

* internal/handlers/admin_rules.go —
  - redirectToProceduralEvents(dst) — fixed-destination redirect for
    paths without an {id}.
  - redirectToProceduralEventEdit — page redirect carrying the {id}.
  - redirectToProceduralEventAPI(suffix) — JSON API redirect carrying
    {id} + optional suffix (/clone-as-draft, /publish, /archive,
    /restore, /audit, /preview). Query string is preserved on every
    redirect.
  - All three helpers add the IETF Deprecation header + a Link
    header pointing at the successor-version path.

* frontend internal nav + URL strings —
  Sidebar.tsx, admin.tsx, admin-rules-list.tsx, admin-rules-edit.tsx,
  client/admin-rules-list.ts, client/admin-rules-edit.ts: every
  `/admin/rules*` reference flipped to `/admin/procedural-events*`.
  In-app navigation now hits the canonical paths directly without a
  redirect round-trip; external callers keep working via the 301s.

* frontend .tsx i18n rebind —
  9 admin .tsx i18n bindings rebound to the canonical
  `admin.procedural_events.*` keys that already exist as aliases in
  i18n.ts (per Slice A from t-paliad-262). Specifically:
    admin.rules.list.title           → admin.procedural_events.list.title
    admin.rules.list.heading         → admin.procedural_events.list.heading
    admin.rules.list.new             → admin.procedural_events.list.new
    admin.rules.col.submission_code  → admin.procedural_events.col.code
    admin.rules.edit.title           → admin.procedural_events.edit.title
    admin.rules.edit.breadcrumb      → admin.procedural_events.edit.breadcrumb
    admin.rules.edit.field.submission_code → admin.procedural_events.edit.field.code
    admin.rules.edit.field.event_type      → admin.procedural_events.edit.field.event_kind
    admin.rules.edit.field.parent          → admin.procedural_events.edit.field.parent

  The remaining ~142 admin.rules.* keys do NOT yet have
  procedural_events aliases. Migrating them is a follow-up slice —
  each needs a new alias entry in i18n.ts (DE + EN) before the .tsx
  reference can be flipped. The 9 keys touched here are the most
  visible (page titles + edit-page field labels) so the admin UI
  immediately reads as "Verfahrensschritte" everywhere.

* frontend/src/client/i18n.ts header comment updated to reflect that
  the URL rename has shipped (Slice B.6 done) and to flag the
  remaining i18n-key migration as the next step.

Scope (documented, paliadin authorised):
- "go everything" applied: backend routes + frontend nav + .tsx
  rebind of the 9 keys whose canonical aliases exist.
- Full migration of all 142 admin.rules.* keys deferred — would
  require seeding ~142 new alias entries in i18n.ts (DE + EN) plus
  another 142 .tsx rebinds. Out of scope for tonight; flag as
  follow-up `feat(i18n): finish admin.rules.* → admin.procedural_events.*
  alias migration`.
- 12 legacy /admin/rules routes still hit a handler (the redirect
  helper) — they don't 404 yet. Once a deprecation window passes
  with no traffic on the old paths, a future slice can drop them
  outright.

Build + vet clean. TestMigrations_NoDuplicateSlot passes.

This concludes the m/paliad#93 procedural-events rename slice train
(Slices A through B.6). curie stays parked persistently for any
follow-up the deploy / monitor cycle surfaces.
2026-05-26 20:12:20 +02:00
mAi
2c0efc396c Merge: Slice B.5 — Go type aliases (SequencingRule = DeadlineRule) + JSON envelope dual-emit + Deprecation headers (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 20:08:43 +02:00
mAi
5c6a0095e3 feat(models,services,handlers): Slice B.5 Go rename + JSON envelope dual-emit (t-paliad-305 / m/paliad#93)
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
Adds the Slice B.5 canonical Go names (SequencingRule, ProceduralEvent,
LegalSource, SequencingRuleService) without breaking any existing
call-site, and dual-emits / dual-accepts the two JSON envelope key
renames on /admin/api/rules with a Deprecation header.

* internal/models/models.go —
  - type SequencingRule = DeadlineRule (alias; same struct, same db /
    json tags). DeadlineRule remains the underlying type for now —
    deferred hard-rename keeps the slice small.
  - type ProceduralEvent struct mirroring paliad.procedural_events
    (id, code, name, name_en, description, event_kind,
    primary_party_default, legal_source_id, concept_id,
    lifecycle_state, draft_of, published_at, is_active, timestamps).
    Used by future code that needs the PE identity row alone.
  - type LegalSource struct mirroring paliad.legal_sources (citation,
    jurisdiction, pretty_de / pretty_en — both nullable per mig 136).

* internal/services/deadline_rule_service.go —
  - type SequencingRuleService = DeadlineRuleService (alias).
  - var NewSequencingRuleService = NewDeadlineRuleService (constructor
    alias). Internal callers can adopt either name.

* internal/services/rule_editor_service.go —
  - CreateRuleInput gains Code + EventKind fields tagged
    json:"code" / json:"event_kind". CoalesceCanonicalKeys() folds
    canonical → legacy after json.Decode so the rest of the service
    keeps using SubmissionCode / EventType. Canonical wins when
    both are sent.
  - RulePatch gains EventKind field with the same fold.

* internal/handlers/admin_rules.go —
  - adminRuleResponse wraps *models.DeadlineRule and adds Code +
    EventKind fields alongside the legacy SubmissionCode /
    EventType. Outputs both keys per response for one
    deprecation-window slice.
  - wrapRuleResponse / wrapRuleListResponse helpers.
  - adminRuleDeprecationHeaders emits IETF Deprecation + Link/Sunset
    headers on every Rule-bearing response so clients see the
    migration signal in transit.
  - All 8 Rule-returning handlers (List, Get, Create, Patch, Clone,
    Publish, Archive, Restore) now wrap their result and add the
    headers.
  - Create + Patch handlers call CoalesceCanonicalKeys after decode
    so legacy AND canonical request bodies are both accepted.

Scope decisions (documented in commit):
- Type renames use aliases instead of a hard 200-LOC rename. Same
  semantics, no call-site churn. A future cleanup slice can flip
  the underlying type definitions when convenient.
- ProceduralEvent + LegalSource are NEW structs (not aliases) since
  they represent new conceptual rows; no legacy callers exist yet.
- Frontend admin .tsx i18n key rebinds (mentioned in parent task
  brief B.5 deliverable list) are deferred — i18n keys themselves
  already exist from Slice A (t-paliad-262); rebinding only changes
  which key the .tsx file looks up. Pulling this into B.5 ballooned
  scope; flagging as a small follow-up slice or B.6 sibling.
- Only /admin/api/rules emits dual keys today. Other handlers that
  surface rule rows (Schriftsätze list, deadlines join) continue to
  emit the legacy keys via models.DeadlineRule's existing JSON tags
  — they're read paths, not the editor surface, and the deprecation
  signal is most important where clients write.

Build + vet clean. TestMigrations_NoDuplicateSlot passes.
2026-05-26 20:07:48 +02:00
mAi
6e0961cc30 Merge: Composer Slice C — building blocks library + section picker (mig 149) (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 20:05:30 +02:00
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
987db27831 Merge: t-paliad-305 — Slice B.4 destructive drop: paliad.deadline_rules retired, INSTEAD OF triggers on view (mig 140, snapshot pre_140 same-TX) (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 19:54:27 +02:00
mAi
1129baba7a feat(db,services): Slice B.4 destructive drop — paliad.deadline_rules retired, INSTEAD OF triggers on view route writes (mig 140, 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
Drops the legacy paliad.deadline_rules table after 3 weeks of dual-write
shadowing (mig 136 → B.2 dual-write → B.3 read cutover via view). The
new tables — paliad.procedural_events, paliad.sequencing_rules,
paliad.legal_sources — are the sole source of truth from this commit
forward.

Pre-flip drift verified clean against prod:
  deadline_rules=231 == sequencing_rules=231 == procedural_events=231
  legal_sources=87
  missing_sr=0, orphaned_sr=0, mismatched_lifecycle=0

* internal/db/migrations/140_drop_deadline_rules.up.sql (new) —
  Single TX, audit-first:
  1. CREATE TABLE paliad.deadline_rules_pre_140 AS TABLE paliad.deadline_rules
     (precedent migs 091/093/095/098 — snapshot in same TX as destructive op).
  2. Final reconciliation UPDATE on paliad.deadlines (no-op when
     drift is already 0; defensive against last-minute writes).
  3. DROP TRIGGER deadline_rules_audit_aiud.
  4. Re-point FKs to sequencing_rules:
     - paliad.appointments.deadline_rule_id → paliad.sequencing_rules(id)
     - paliad.deadline_rule_backfill_orphans.resolved_rule_id → paliad.sequencing_rules(id)
     (the id values are identical — sr.id inherited dr.id at mig 136.)
  5. DROP COLUMN paliad.deadlines.rule_id.
  6. DROP TABLE paliad.deadline_rules.
  7. CREATE INSTEAD OF INSERT + INSTEAD OF UPDATE triggers on
     paliad.deadline_rules_unified. Triggers route writes into the
     three new tables in the same TX, preserving the legacy column
     shape on the wire so RuleEditorService SQL only needs a
     table-name swap, not a structural rewrite. Synthetic-code mint
     expression is byte-identical to mig 136 + the B.2 dual-write
     helper. POST assertions confirm the table is gone, the column
     is gone, and the snapshot matches.

  Trigger design notes (1:N caveat documented in-trigger):
  - PE identity columns (code, name, name_en, description, event_kind,
    primary_party_default, legal_source_id, concept_id) mirror from
    the writing sequencing-rule.
  - PE lifecycle columns (lifecycle_state, published_at, is_active)
    deliberately do NOT mirror — a draft sequencing-rule cloned from
    a published source shares the source's PE; we don't want the
    clone's 'draft' lifecycle to leak back onto the source's PE.
    Practical bound today (1:1 corpus); explicit comment in-trigger
    for the eventual 1:N pattern.

* internal/db/migrations/140_drop_deadline_rules.down.sql (new) —
  Best-effort restore from the snapshot. Triggers / indexes /
  CHECK constraints from historical migrations are NOT replayed;
  operator must reapply 078/079/091/095/098/122/128/134/135 to
  bring the restored table to working shape. The down path is for
  catastrophic recovery, not casual revert.

* internal/services/rule_editor_service.go —
  Six syncDualWriteFromDeadlineRule(...) calls removed (the
  INSTEAD OF triggers now do the fan-out). Five
  INSERT/UPDATE paliad.deadline_rules statements (Create,
  UpdateDraft, CloneAsDraft INSERT+SELECT, Publish, peer-archive,
  flipLifecycle) renamed to paliad.deadline_rules_unified —
  trigger handles the routing.

* internal/services/rule_editor_orphans.go — ResolveOrphan no
  longer writes deadlines.rule_id (column dropped). Sets
  sequencing_rule_id directly + derives procedural_event_id from
  the matching sequencing_rules row in the same UPDATE statement.

* internal/services/deadline_service.go — deadlineColumns now
  lists sequencing_rule_id (Deadline.RuleID still binds to it via
  the db tag rename below). Update path's appendSet("rule_id",…)
  flipped to appendSet("sequencing_rule_id",…) and post-write
  derivation moved to the renamed syncDeadlineProceduralEventID
  helper.

* internal/services/projection_service.go,
  internal/services/submission_vars.go — `WHERE rule_id = $X`
  reads on paliad.deadlines flipped to sequencing_rule_id.

* internal/models/models.go — Deadline.RuleID db tag changed from
  "rule_id" to "sequencing_rule_id". Field name + JSON name kept
  for backward compat with the frontend and existing Go callers;
  semantic value is identical (same UUID).

* internal/services/dual_write.go — Massively trimmed.
  Removed: syncDualWriteFromDeadlineRule, syncDeadlineDualLinks,
  CheckDualWriteDrift, DualWriteDriftReport, HasDrift,
  StartDualWriteDriftCheckLoop. All referenced
  paliad.deadline_rules which no longer exists.
  Kept (renamed): syncDeadlineProceduralEventID — derives
  procedural_event_id from sequencing_rule_id after any
  DeadlineService.Update that touched the back-link.

* cmd/server/main.go — Removed the StartDualWriteDriftCheckLoop
  bootstrap call (and its `time` import that only that call
  needed). Comment notes the retirement.

* internal/services/dual_write_test.go — Removed the final
  CheckDualWriteDrift assertion in
  TestDualWrite_RuleEditorLifecycle (function deleted). The
  per-step asserts against procedural_events / sequencing_rules
  / legal_sources cover the same contract by direct query.

Hard rules followed:
- Audit-first: snapshot precedes destructive ops in the same TX.
- No silent data loss: pre-drop drift was zero; snapshot captures
  the final state; FK re-points use identical UUIDs.
- INSTEAD OF triggers documented in mig 140 — single source of
  truth for the legacy→new mapping.
- Down migration is honest about its scope (catastrophic recovery
  only).

Build + vet clean. TestMigrations_NoDuplicateSlot passes. Live-DB
tests skipped (no TEST_DATABASE_URL in this env) — they'll exercise
the full mig 140 + INSTEAD OF triggers in CI.
2026-05-26 19:53:24 +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
49 changed files with 6213 additions and 576 deletions

View File

@@ -0,0 +1,342 @@
// Command seed-orphan-concept-drafts stages draft sequencing_rules for
// deadline_concepts that have rule_count=0 ("orphans"). It calls the
// same services.RuleEditorService.Create that POST
// /admin/api/procedural-events runs internally, so the audit trigger
// + INSTEAD-OF view trigger fan-out into procedural_events +
// sequencing_rules + legal_sources fire identically. No HTTP/auth
// shell, no direct SQL writes by this command.
//
// All rules are created with lifecycle_state='draft' (forced by the
// service). The admin still reviews + publishes via
// /admin/procedural-events.
//
// t-paliad-320: editorial backlog from t-paliad-193, four remaining
// orphan concepts: counterclaim-for-revocation, versaeumnisurteil-
// einspruch, schriftsatznachreichung, weiterbehandlung. The
// weiterbehandlung concept gets two drafts (EPC Art. 121 + R. 135
// versus DPatG § 123a) since the two regimes have different durations
// and jurisdictions.
//
// Usage:
//
// DATABASE_URL=postgres://… go run ./cmd/seed-orphan-concept-drafts \
// [-dry-run] [-reason "free-text audit reason"]
//
// Idempotency: the command refuses to insert if any rule for a given
// (concept, proceeding_type, rule_code) already exists. Safe to re-run
// after a partial failure.
package main
import (
"context"
"database/sql"
"errors"
"flag"
"fmt"
"log"
"os"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/services"
)
// draftSpec captures one CreateRuleInput plus the metadata the command
// needs to resolve concept_id + proceeding_type_id from human-readable
// slugs/codes. ProceedingCode == "" means event-rooted
// (proceeding_type_id = NULL), used for cross-cutting rules whose
// jurisdiction has no matching proceeding_type yet.
type draftSpec struct {
Label string // human label for log output
ConceptSlug string
ProceedingCode string // "" → NULL proceeding_type_id (event-rooted)
SubmissionCode string
Name string
NameEN string
EventKind string
PrimaryParty string // "" → omit (NULL)
DurationValue int
DurationUnit string
Timing string
Priority string
IsCourtSet bool
RuleCode string
LegalSource string
DeadlineNotes string
DeadlineNotesEn string
}
func drafts() []draftSpec {
return []draftSpec{
// ─── 1. counterclaim-for-revocation (UPC R.25.1 ∧ R.23) ───────
{
Label: "counterclaim-for-revocation → upc.ccr.cfi",
ConceptSlug: "counterclaim-for-revocation",
ProceedingCode: "upc.ccr.cfi",
SubmissionCode: "upc.ccr.cfi.lodge",
Name: "Widerklage auf Nichtigkeit (CCR)",
NameEN: "Counterclaim for Revocation (CCR)",
EventKind: "filing",
PrimaryParty: "defendant",
DurationValue: 3,
DurationUnit: "months",
Timing: "after",
Priority: "mandatory",
IsCourtSet: false,
RuleCode: "RoP.025",
LegalSource: "UPC.RoP.25.1",
DeadlineNotes: "Die Widerklage auf Nichtigkeit (Counterclaim for Revocation, CCR) ist gemeinsam mit der Klageerwiderung (Statement of Defence) einzureichen — d. h. innerhalb von 3 Monaten ab Zustellung der Klageschrift " +
"(R. 23 i. V. m. R. 25.1 RoP). Inhaltliche Anforderungen folgen R. 25-30 RoP (insbes. R. 25.1(a)-(c) zu Antrag, Tatsachen und Beweismitteln; R. 27 zu Verfahren nach Einreichung; R. 30 zu einem Antrag auf Änderung des Patents).",
DeadlineNotesEn: "The Counterclaim for Revocation (CCR) must be lodged together with the Statement of Defence — i.e. within 3 months of service of the Statement of Claim " +
"(Rule 23 in conjunction with Rule 25.1 RoP). Substantive requirements follow Rules 25-30 RoP (in particular R. 25.1(a)-(c) on the application, facts and evidence; R. 27 on post-filing procedure; R. 30 on any application to amend the patent).",
},
// ─── 2. versaeumnisurteil-einspruch (ZPO § 339) ───────────────
{
Label: "versaeumnisurteil-einspruch → de.inf.lg",
ConceptSlug: "versaeumnisurteil-einspruch",
ProceedingCode: "de.inf.lg",
SubmissionCode: "de.inf.lg.einspruch_vu",
Name: "Einspruch gegen Versäumnisurteil",
NameEN: "Objection to default judgment",
EventKind: "filing",
PrimaryParty: "defendant",
DurationValue: 2,
DurationUnit: "weeks",
Timing: "after",
Priority: "mandatory",
IsCourtSet: false,
RuleCode: "§ 339 ZPO",
LegalSource: "DE.ZPO.339.1",
DeadlineNotes: "Notfrist von 2 Wochen ab Zustellung des Versäumnisurteils (§ 339(1) ZPO). " +
"Bei Auslandszustellung oder öffentlicher Bekanntmachung bestimmt das Gericht die Einspruchsfrist gesondert im Versäumnisurteil oder durch nachträglichen Beschluss (§ 339(2) ZPO) — in diesem Fall die gerichtlich festgesetzte Frist mit „Datum setzen“ überschreiben. " +
"Form: schriftlich oder zu Protokoll der Geschäftsstelle (§ 340(1) ZPO); die Einspruchsbegründung kann bis zum Verhandlungstermin nachgereicht werden (§ 340(3) ZPO).",
DeadlineNotesEn: "Statutory two-week emergency period (Notfrist) from service of the default judgment (§ 339(1) ZPO). " +
"If service is abroad or by public notice, the court sets the objection period separately in the default judgment or by a subsequent order (§ 339(2) ZPO) — in that case override with the court-set date. " +
"Form: in writing or before the registry clerk (§ 340(1) ZPO); substantive grounds may be filed up to the oral hearing (§ 340(3) ZPO).",
},
// ─── 3. schriftsatznachreichung (ZPO § 283) ───────────────────
{
Label: "schriftsatznachreichung → de.inf.lg",
ConceptSlug: "schriftsatznachreichung",
ProceedingCode: "de.inf.lg",
SubmissionCode: "de.inf.lg.nachreichung",
Name: "Schriftsatznachreichung",
NameEN: "Subsequent written submission",
EventKind: "filing",
PrimaryParty: "", // concept.party = "both" → no default
DurationValue: 3,
DurationUnit: "weeks",
Timing: "after",
Priority: "optional",
IsCourtSet: true,
RuleCode: "§ 283 ZPO",
LegalSource: "DE.ZPO.283",
DeadlineNotes: "Vom Gericht in der mündlichen Verhandlung gesetzte Schriftsatzfrist gem. § 283 ZPO. " +
"Sie wird nur auf Antrag einer Partei bestimmt, die sich auf neues Vorbringen des Gegners nicht erklären konnte. " +
"Die konkrete Frist (in der Praxis 2-3 Wochen) und der nachfolgende Verkündungstermin werden im Sitzungsprotokoll bzw. in der prozessleitenden Verfügung festgelegt — Default-Frist hier 3 Wochen, mit „Datum setzen“ überschreiben, sobald die Verfügung vorliegt. " +
"Nach Fristablauf darf das Gericht keine weiteren Erklärungen mehr berücksichtigen (§ 283 S. 2, § 296a ZPO).",
DeadlineNotesEn: "Court-set written-submission period under § 283 ZPO, granted on a party's application when it could not respond at the oral hearing to the opponent's new submissions. " +
"The actual period (in practice 2-3 weeks) and the announcement date are set in the hearing record / case-management order — default 3 weeks here, override via „set date“ once the order is on the file. " +
"After expiry, the court will disregard further submissions (§ 283 sent. 2, § 296a ZPO).",
},
// ─── 4. weiterbehandlung — EPC variant (Art. 121 + R. 135) ────
{
Label: "weiterbehandlung (EPC) → epa.grant.exa",
ConceptSlug: "weiterbehandlung",
ProceedingCode: "epa.grant.exa",
SubmissionCode: "epa.grant.exa.weiterbeh",
Name: "Antrag auf Weiterbehandlung",
NameEN: "Request for further processing",
EventKind: "filing",
PrimaryParty: "claimant",
DurationValue: 2,
DurationUnit: "months",
Timing: "after",
Priority: "mandatory",
IsCourtSet: false,
RuleCode: "Art. 121 EPÜ",
LegalSource: "EU.EPC-R.135.1",
DeadlineNotes: "Antrag auf Weiterbehandlung gem. Art. 121 EPÜ i. V. m. R. 135(1) EPÜ — 2 Monate ab Zustellung der Mitteilung über die Fristversäumung bzw. den eingetretenen Rechtsverlust. " +
"Der Antrag wird durch Zahlung der vorgeschriebenen Weiterbehandlungsgebühr gestellt; die versäumte Handlung muss innerhalb derselben 2-Monats-Frist nachgeholt werden (R. 135(1) EPÜ). " +
"Die Frist ist nicht verlängerbar. Ausgeschlossen sind insbesondere die Frist für die Weiterbehandlung selbst sowie die in R. 135(2) EPÜ ausdrücklich aufgeführten Fristen (u. a. die Beschwerdefrist nach Art. 108 EPÜ, die Prioritätsfrist nach Art. 87 EPÜ und die Frist zur Wiedereinsetzung).",
DeadlineNotesEn: "Request for further processing under Article 121 EPC in conjunction with Rule 135(1) EPC — two months from notification of the communication concerning the missed time limit or the loss of rights. " +
"The request is made by payment of the further-processing fee; the omitted act must be completed within the same two-month period (Rule 135(1) EPC). " +
"The period is non-extendable. Excluded: the further-processing period itself and the periods listed in Rule 135(2) EPC (notably the appeal period under Art. 108 EPC, the priority period under Art. 87 EPC, and the re-establishment period).",
},
// ─── 5. weiterbehandlung — DPatG § 123a variant ───────────────
// No `dpma.grant.*` proceeding_type exists yet, so this rule is
// event-rooted (proceeding_type_id NULL) — same pattern as 78
// other cross-cutting rules. Editorial follow-up: create a
// `dpma.grant.dpma` proceeding_type and reassign.
{
Label: "weiterbehandlung (DPatG § 123a) → event-rooted (NULL proceeding_type)",
ConceptSlug: "weiterbehandlung",
ProceedingCode: "", // event-rooted
SubmissionCode: "dpma.grant.weiterbeh",
Name: "Antrag auf Weiterbehandlung (DPMA)",
NameEN: "Request for further processing (DPMA, § 123a PatG)",
EventKind: "filing",
PrimaryParty: "claimant",
DurationValue: 1,
DurationUnit: "months",
Timing: "after",
Priority: "mandatory",
IsCourtSet: false,
RuleCode: "§ 123a PatG",
LegalSource: "DE.PatG.123a.1",
DeadlineNotes: "Antrag auf Weiterbehandlung einer DPMA-Patentanmeldung gem. § 123a PatG — 1 Monat ab Zustellung der Mitteilung über die Rechtsfolge der Fristversäumung. " +
"Innerhalb dieser Frist müssen (i) der Antrag schriftlich gestellt, (ii) die versäumte Handlung nachgeholt und (iii) die Weiterbehandlungsgebühr nach Patentkostengesetz (PatKostG) gezahlt werden. " +
"§ 123a PatG erfasst ausschließlich Anmeldungsfristen, deren Versäumung kraft Gesetzes die Zurückweisung der Anmeldung zur Folge hat. Für sonstige Fristversäumnisse kommt nur die Wiedereinsetzung nach § 123 PatG in Betracht (1 Monat ab Wegfall des Hindernisses, max. 1 Jahr ab Fristablauf). " +
"HINWEIS — Taxonomie: bisher kein dpma.grant.* proceeding_type vorhanden; Regel daher event-rooted (proceeding_type_id NULL). Editorial follow-up: dpma.grant.dpma proceeding_type anlegen und diese Regel umhängen.",
DeadlineNotesEn: "Request for further processing of a DPMA patent application under § 123a PatG — 1 month from notification of the consequence of the missed deadline. " +
"Within this period the applicant must (i) file the written request, (ii) complete the omitted act, and (iii) pay the further-processing fee under the German Patent Costs Act (PatKostG). " +
"§ 123a PatG covers only application-stage deadlines whose statutory consequence is rejection. For other missed deadlines, re-establishment under § 123 PatG is the only route (1 month from removal of the obstacle, max 1 year from the missed deadline). " +
"TAXONOMY NOTE: no dpma.grant.* proceeding_type exists yet; this rule is event-rooted (proceeding_type_id NULL). Editorial follow-up: create a dpma.grant.dpma proceeding_type and reassign this rule.",
},
}
}
func main() {
dryRun := flag.Bool("dry-run", false, "log the planned drafts but do not write")
reason := flag.String("reason", "t-paliad-320: editorial seed of orphan deadline-concept rules (researcher darwin + lex)", "audit reason recorded with each Create()")
flag.Parse()
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
log.Fatal("DATABASE_URL not set — export the paliad postgres URL before running")
}
ctx := context.Background()
conn, err := sqlx.Connect("postgres", dbURL)
if err != nil {
log.Fatalf("connect db: %v", err)
}
defer conn.Close()
rules := services.NewDeadlineRuleService(conn)
editor := services.NewRuleEditorService(conn, rules)
conceptIDs := map[string]uuid.UUID{}
proceedingIDs := map[string]int{}
specs := drafts()
for _, s := range specs {
if _, ok := conceptIDs[s.ConceptSlug]; ok {
continue
}
var id uuid.UUID
if err := conn.GetContext(ctx, &id,
`SELECT id FROM paliad.deadline_concepts WHERE slug = $1`, s.ConceptSlug); err != nil {
log.Fatalf("lookup concept %q: %v", s.ConceptSlug, err)
}
conceptIDs[s.ConceptSlug] = id
}
for _, s := range specs {
if s.ProceedingCode == "" {
continue
}
if _, ok := proceedingIDs[s.ProceedingCode]; ok {
continue
}
var id int
if err := conn.GetContext(ctx, &id,
`SELECT id FROM paliad.proceeding_types WHERE code = $1`, s.ProceedingCode); err != nil {
log.Fatalf("lookup proceeding_type %q: %v", s.ProceedingCode, err)
}
proceedingIDs[s.ProceedingCode] = id
}
fmt.Printf("Seeding %d drafts (dry-run=%v)\n", len(specs), *dryRun)
for i, s := range specs {
conceptID := conceptIDs[s.ConceptSlug]
var procID *int
if s.ProceedingCode != "" {
p := proceedingIDs[s.ProceedingCode]
procID = &p
}
// Idempotency: refuse if a rule with the same (concept, proceeding,
// rule_code) already exists in any lifecycle state.
if existing, err := findExisting(ctx, conn, conceptID, procID, s.RuleCode); err != nil {
log.Fatalf("[%d] idempotency check failed for %s: %v", i+1, s.Label, err)
} else if existing != uuid.Nil {
fmt.Printf(" [%d] SKIP %s — already exists as %s\n", i+1, s.Label, existing)
continue
}
input := services.CreateRuleInput{
Name: s.Name,
NameEN: s.NameEN,
ProceedingTypeID: procID,
DurationValue: s.DurationValue,
DurationUnit: s.DurationUnit,
Priority: s.Priority,
IsCourtSet: s.IsCourtSet,
}
input.ConceptID = &conceptID
code := s.SubmissionCode
input.SubmissionCode = &code
ek := s.EventKind
input.EventType = &ek
t := s.Timing
input.Timing = &t
rc := s.RuleCode
input.RuleCode = &rc
ls := s.LegalSource
input.LegalSource = &ls
dn := s.DeadlineNotes
input.DeadlineNotes = &dn
dne := s.DeadlineNotesEn
input.DeadlineNotesEn = &dne
if s.PrimaryParty != "" {
pp := s.PrimaryParty
input.PrimaryParty = &pp
}
if *dryRun {
fmt.Printf(" [%d] DRY %s (concept=%s, proc=%s, code=%s, %d %s, %s)\n",
i+1, s.Label, conceptID, codeOrNil(procID), code, s.DurationValue, s.DurationUnit, s.RuleCode)
continue
}
row, err := editor.Create(ctx, input, *reason)
if err != nil {
log.Fatalf(" [%d] CREATE failed for %s: %v", i+1, s.Label, err)
}
fmt.Printf(" [%d] OK %s → id=%s lifecycle=%s\n",
i+1, s.Label, row.ID, row.LifecycleState)
}
fmt.Println("Done.")
}
func findExisting(ctx context.Context, conn *sqlx.DB, conceptID uuid.UUID, procID *int, ruleCode string) (uuid.UUID, error) {
var id uuid.UUID
q := `
SELECT sr.id
FROM paliad.sequencing_rules sr
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
WHERE pe.concept_id = $1
AND sr.rule_code IS NOT DISTINCT FROM $2
AND sr.proceeding_type_id IS NOT DISTINCT FROM $3
LIMIT 1`
err := conn.GetContext(ctx, &id, q, conceptID, ruleCode, procID)
if errors.Is(err, sql.ErrNoRows) {
return uuid.Nil, nil
}
return id, err
}
func codeOrNil(p *int) string {
if p == nil {
return "<NULL>"
}
return fmt.Sprintf("%d", *p)
}

View File

@@ -12,7 +12,6 @@ import (
"strconv"
"strings"
"syscall"
"time"
// Embed Go's IANA tz database into the binary so time.LoadLocation works
// without OS tzdata. The runtime image (alpine) doesn't ship /usr/share/
@@ -173,6 +172,8 @@ func main() {
// 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)
@@ -184,10 +185,11 @@ func main() {
Team: teamSvc,
PartnerUnit: partnerUnitSvc,
Party: partySvc,
SubmissionDraft: submissionDraftSvc,
SubmissionBase: submissionBaseSvc,
SubmissionSection: submissionSectionSvc,
SubmissionComposer: submissionComposerSvc,
SubmissionDraft: submissionDraftSvc,
SubmissionBase: submissionBaseSvc,
SubmissionSection: submissionSectionSvc,
SubmissionComposer: submissionComposerSvc,
SubmissionBuildingBlock: submissionBuildingBlockSvc,
Deadline: deadlineSvc,
Appointment: appointmentSvc,
CalDAV: caldavSvc,
@@ -356,13 +358,11 @@ func main() {
log.Printf("CalDAV start: %v", err)
}
reminderSvc.Start(bgCtx)
// Slice B.2 dual-write drift check (t-paliad-305 / m/paliad#93).
// Runs every 6 h while the new procedural_events / sequencing_rules /
// legal_sources tables shadow the legacy paliad.deadline_rules
// table. A clean run logs at INFO; drift logs at WARN with the
// full report so a broken dual-write surfaces before the next
// deploy.
services.StartDualWriteDriftCheckLoop(bgCtx, pool, 6*time.Hour)
// Slice B.4 (mig 140, t-paliad-305): legacy paliad.deadline_rules
// dropped. The B.2 dual-write drift-check loop is retired — the
// procedural_events / sequencing_rules / legal_sources tables
// are now the source of truth and there is no parallel side to
// compare against. Pre-drop drift was verified clean in mig 140.
go func() {
<-bgCtx.Done()
log.Println("background services: shutdown signal received")

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

@@ -5,7 +5,7 @@ import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// /admin/rules/{id}/edit — Slice 11b (t-paliad-192). Form for the full
// /admin/procedural-events/{id}/edit — Slice 11b (t-paliad-192). Form for the full
// 37-column rule row plus a side panel with the preview widget and the
// audit-log timeline. Lifecycle action bar at the bottom adapts to the
// rule's current state (draft/published/archived). Every write goes
@@ -26,12 +26,12 @@ export function renderAdminRulesEdit(): string {
<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.rules.edit.title">Regel bearbeiten &mdash; Paliad</title>
<title data-i18n="admin.procedural_events.edit.title">Regel bearbeiten &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/rules" />
<BottomNav currentPath="/admin/rules" />
<Sidebar currentPath="/admin/procedural-events" />
<BottomNav currentPath="/admin/procedural-events" />
<main>
<section className="tool-page">
@@ -39,7 +39,7 @@ export function renderAdminRulesEdit(): string {
<div className="tool-header admin-rules-edit-header">
<div>
<p className="admin-rules-breadcrumb">
<a href="/admin/rules" data-i18n="admin.rules.edit.breadcrumb">&larr; Regeln verwalten</a>
<a href="/admin/procedural-events" data-i18n="admin.procedural_events.edit.breadcrumb">&larr; Regeln verwalten</a>
</p>
<h1 id="rules-edit-heading" data-i18n="admin.rules.edit.heading.loading">Regel laden...</h1>
<div className="admin-rules-edit-meta">
@@ -71,7 +71,7 @@ export function renderAdminRulesEdit(): string {
</div>
<div className="admin-rules-edit-row">
<div className="form-field">
<label htmlFor="f-submission-code" data-i18n="admin.rules.edit.field.submission_code">Submission Code / Einreichung-Kennung</label>
<label htmlFor="f-submission-code" data-i18n="admin.procedural_events.edit.field.code">Submission Code / Einreichung-Kennung</label>
<input type="text" id="f-submission-code" className="admin-rules-input" readonly placeholder="z. B. upc.inf.cfi.soc" />
</div>
<div className="form-field">
@@ -103,7 +103,7 @@ export function renderAdminRulesEdit(): string {
</div>
<div className="admin-rules-edit-row">
<div className="form-field">
<label htmlFor="f-parent" data-i18n="admin.rules.edit.field.parent">Parent-Regel (UUID)</label>
<label htmlFor="f-parent" data-i18n="admin.procedural_events.edit.field.parent">Parent-Regel (UUID)</label>
<input type="text" id="f-parent" className="admin-rules-input" placeholder="UUID oder leer" />
</div>
<div className="form-field">
@@ -184,7 +184,7 @@ export function renderAdminRulesEdit(): string {
<input type="text" id="f-primary-party" className="admin-rules-input" />
</div>
<div className="form-field">
<label htmlFor="f-event-type" data-i18n="admin.rules.edit.field.event_type">Event-Typ (frei)</label>
<label htmlFor="f-event-type" data-i18n="admin.procedural_events.edit.field.event_kind">Event-Typ (frei)</label>
<input type="text" id="f-event-type" className="admin-rules-input" />
</div>
</div>

View File

@@ -5,7 +5,7 @@ import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// /admin/rules — Slice 11b (t-paliad-192). Filterable rule table + an
// /admin/procedural-events — Slice 11b (t-paliad-192). Filterable rule table + an
// Orphans tab that surfaces the Slice 10 fuzzy-match staging rows so an
// admin can hand-bind each legacy deadline to one of the candidate
// rule_ids. Both surfaces share the same page shell to keep navigation
@@ -21,25 +21,25 @@ export function renderAdminRulesList(): string {
<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.rules.list.title">Regeln verwalten &mdash; Paliad</title>
<title data-i18n="admin.procedural_events.list.title">Regeln verwalten &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/rules" />
<BottomNav currentPath="/admin/rules" />
<Sidebar currentPath="/admin/procedural-events" />
<BottomNav currentPath="/admin/procedural-events" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div>
<h1 data-i18n="admin.rules.list.heading">Regeln verwalten</h1>
<h1 data-i18n="admin.procedural_events.list.heading">Regeln verwalten</h1>
<p className="tool-subtitle" data-i18n="admin.rules.list.subtitle">
Fristen-Regeln anlegen, bearbeiten und freigeben. Lifecycle: draft &rarr; published &rarr; archived.
</p>
</div>
<div className="admin-rules-header-actions">
<button type="button" id="rules-new-btn" className="btn-primary" data-i18n="admin.rules.list.new">
<button type="button" id="rules-new-btn" className="btn-primary" data-i18n="admin.procedural_events.list.new">
+ Neue Regel
</button>
</div>
@@ -101,10 +101,10 @@ export function renderAdminRulesList(): string {
<table className="entity-table admin-rules-table">
<thead>
<tr>
<th data-i18n="admin.rules.col.submission_code">Submission Code</th>
<th data-i18n="admin.procedural_events.col.code">Submission Code</th>
<th data-i18n="admin.procedural_events.col.proceeding">Verfahren</th>
<th data-i18n="admin.rules.col.legal_citation">Rechtsgrundlage</th>
<th data-i18n="admin.rules.col.name">Name</th>
<th data-i18n="admin.rules.col.proceeding">Verfahrenstyp</th>
<th data-i18n="admin.rules.col.priority">Priorit&auml;t</th>
<th data-i18n="admin.rules.col.lifecycle">Lifecycle</th>
<th data-i18n="admin.rules.col.modified">Zuletzt ge&auml;ndert</th>

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

@@ -95,7 +95,7 @@ export function renderAdmin(): string {
<h2 data-i18n="admin.card.approval_policies.title">Genehmigungspflichten</h2>
<p data-i18n="admin.card.approval_policies.desc">4-Augen-Pr&uuml;fung pro Projekt und Partner Unit konfigurieren.</p>
</a>
<a href="/admin/rules" className="card card-link">
<a href="/admin/procedural-events" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_TABLE }} />
<h2 data-i18n="admin.card.rules.title">Regeln verwalten</h2>
<p data-i18n="admin.card.rules.desc">Fristen-Regeln anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.</p>

View File

@@ -1,7 +1,7 @@
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
// admin-rules-edit.ts — /admin/rules/{id}/edit. Loads a single rule
// admin-rules-edit.ts — /admin/procedural-events/{id}/edit. Loads a single rule
// row, drives every form field, the preview widget, the audit-log
// timeline and the lifecycle action bar. Every write is gated behind
// a reason modal — the ≥10-char rule is enforced client-side per
@@ -106,7 +106,7 @@ function fmtDateTime(iso: string): string {
}
function parseRuleIDFromPath(): string {
// /admin/rules/{uuid}/edit
// /admin/procedural-events/{uuid}/edit
const m = /^\/admin\/rules\/([^\/]+)\/edit\/?$/.exec(window.location.pathname);
return m ? decodeURIComponent(m[1]) : "";
}
@@ -179,7 +179,7 @@ function fillProceedingSelect(selectId: string, list: ProceedingType[]) {
}
async function loadRule(): Promise<void> {
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}`);
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}`);
if (!resp.ok) {
if (resp.status === 404) {
showFeedback(t("admin.rules.edit.error.not_found") || "Regel nicht gefunden.", true);
@@ -198,7 +198,7 @@ async function loadAudit(reset: boolean = true): Promise<void> {
auditEntries = [];
auditOffset = 0;
}
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/audit?offset=${auditOffset}&limit=${AUDIT_PAGE}`);
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/audit?offset=${auditOffset}&limit=${AUDIT_PAGE}`);
if (!resp.ok) return;
const body = await resp.json();
const rows = Array.isArray(body) ? body as AuditEntry[] : [];
@@ -508,7 +508,7 @@ async function doSaveDraft(reason: string) {
return;
}
payload.reason = reason;
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}`, {
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -530,7 +530,7 @@ async function doSaveDraft(reason: string) {
async function doLifecycle(op: "publish" | "archive" | "restore", reason: string) {
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/${op}`, {
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/${op}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason }),
@@ -552,7 +552,7 @@ async function doLifecycle(op: "publish" | "archive" | "restore", reason: string
async function doClone(reason: string) {
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/clone-as-draft`, {
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/clone-as-draft`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason }),
@@ -565,7 +565,7 @@ async function doClone(reason: string) {
return;
}
const newRule = await resp.json() as Rule;
window.location.href = `/admin/rules/${encodeURIComponent(newRule.id)}/edit`;
window.location.href = `/admin/procedural-events/${encodeURIComponent(newRule.id)}/edit`;
}
// --------------------------------------------------------------------
@@ -591,7 +591,7 @@ async function runPreview() {
if (flagsRaw) qs.set("flags", flagsRaw);
out.innerHTML = `<p class="admin-rules-loading">${esc(t("admin.rules.edit.preview.running") || "Berechne...")}</p>`;
out.style.display = "";
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/preview?${qs.toString()}`);
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/preview?${qs.toString()}`);
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
out.innerHTML = `<p class="admin-rules-hint admin-rules-hint-error">${esc(body.error || (t("admin.rules.edit.preview.error") || "Preview fehlgeschlagen."))}</p>`;

View File

@@ -1,16 +1,23 @@
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
// admin-rules-list.ts — /admin/rules. Drives the rule table (filterable
// admin-rules-list.ts — /admin/procedural-events. Drives the rule table (filterable
// by proceeding type, trigger event, lifecycle state, free-text query)
// plus the Orphans tab (Slice 10 backfill staging rows). Row click on
// a rule routes to /admin/rules/{id}/edit; orphan cards have their own
// a rule routes to /admin/procedural-events/{id}/edit; orphan cards have their own
// "Pick" affordance with an inline reason prompt that posts to
// /admin/api/orphans/{id}/resolve.
interface Rule {
id: string;
proceeding_type_id?: number | null;
// proceeding_type_code is the joined paliad.proceeding_types.code
// for proceeding_type_id, populated server-side by the
// /admin/api/procedural-events LIST handler (t-paliad-321). Lets the
// table show the 3-segment proceeding code (e.g. "upc.inf.cfi") at
// a glance without depending on the FILTER-dropdown's limited
// proceeding list. NULL on event-rooted rules.
proceeding_type_code?: string | null;
// submission_code is the proceeding-prefixed identifier of this rule
// within its proceeding (e.g. `upc.inf.cfi.soc`), distinct from
// rule_code (the legal citation, e.g. `RoP.013.1`).
@@ -138,6 +145,19 @@ function proceedingLabel(id: number | null | undefined): string {
return `${pt.code} · ${name}`;
}
// proceedingCodeCell renders the LIST table's Proceeding column. Uses
// the server-side joined proceeding_type_code when available
// (t-paliad-321), falling back to the dropdown-lookup proceedingLabel
// for older API responses or for rules whose proceeding_type_id
// resolves but proceeding_type_code didn't (defence-in-depth). NULL
// proceeding_type_id renders as the em-dash placeholder used
// elsewhere in the admin table.
function proceedingCodeCell(r: Rule): string {
if (r.proceeding_type_code) return r.proceeding_type_code;
if (r.proceeding_type_id == null) return "—";
return proceedingLabel(r.proceeding_type_id);
}
function buildFilterURL(): string {
const qs = new URLSearchParams();
if (activeProceeding) qs.set("proceeding_type_id", activeProceeding);
@@ -145,7 +165,7 @@ function buildFilterURL(): string {
if (activeLifecycle) qs.set("lifecycle_state", activeLifecycle);
if (activeQuery) qs.set("q", activeQuery);
qs.set("limit", "500");
return "/admin/api/rules?" + qs.toString();
return "/admin/api/procedural-events?" + qs.toString();
}
async function loadProceedings(): Promise<void> {
@@ -233,9 +253,9 @@ function renderRulesTable() {
tbody.innerHTML = rules.map((r) => `
<tr data-row-id="${esc(r.id)}" class="admin-rules-row">
<td class="admin-rules-col-code"><code>${esc(r.submission_code || "")}</code></td>
<td class="admin-rules-col-proceeding"><code>${esc(proceedingCodeCell(r))}</code></td>
<td class="admin-rules-col-legal"><code>${esc(r.rule_code || "")}</code></td>
<td>${esc(name(r))}</td>
<td>${esc(proceedingLabel(r.proceeding_type_id ?? null))}</td>
<td><span class="admin-rules-priority admin-rules-priority-${esc(r.priority)}">${esc(priorityLabel(r.priority))}</span></td>
<td><span class="${lifecycleClass(r.lifecycle_state)}">${esc(lifecycleLabel(r.lifecycle_state))}</span></td>
<td class="admin-rules-col-modified">${esc(fmtDateTime(r.updated_at))}</td>
@@ -248,7 +268,7 @@ function renderRulesTable() {
if (target && (target.closest("a") || target.closest("button"))) return;
const id = row.dataset.rowId;
if (!id) return;
window.location.href = `/admin/rules/${encodeURIComponent(id)}/edit`;
window.location.href = `/admin/procedural-events/${encodeURIComponent(id)}/edit`;
});
});
}
@@ -392,7 +412,7 @@ async function submitReasonModal(ev: Event) {
submit.disabled = false;
return;
}
const resp = await fetch("/admin/api/rules", {
const resp = await fetch("/admin/api/procedural-events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -416,7 +436,7 @@ async function submitReasonModal(ev: Event) {
return;
}
const created = await resp.json();
window.location.href = `/admin/rules/${encodeURIComponent(created.id)}/edit`;
window.location.href = `/admin/procedural-events/${encodeURIComponent(created.id)}/edit`;
return;
}

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

@@ -1525,6 +1525,13 @@ const translations: Record<Lang, Record<string, string>> = {
"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",
@@ -2898,10 +2905,11 @@ const translations: Record<Lang, Record<string, string>> = {
// t-paliad-192 Slice 11b — Admin rule-editor UI.
// t-paliad-262 Slice A — "Regel" relabelled as "Verfahrensschritt".
// The admin URL `/admin/rules` and i18n key prefix `admin.rules.*` stay
// (URL change is Slice B.6); the visible labels rename. Canonical
// `admin.procedural_events.*` aliases live after the EN block — they
// pin the contract for when .tsx files rebind in Slice B (B.5).
// t-paliad-305 Slice B.6 (2026-05-26) — canonical URL moved to
// `/admin/procedural-events` (301 redirects from /admin/rules*).
// The i18n keys `admin.rules.*` are kept as the corpus until a
// follow-up slice migrates each reference; canonical
// `admin.procedural_events.*` aliases live after the EN block.
"nav.admin.rules": "Verfahrensschritte verwalten",
"admin.card.rules.title": "Verfahrensschritte verwalten",
"admin.card.rules.desc": "Verfahrensschritte anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.",
@@ -3112,6 +3120,9 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.procedural_events.list.heading": "Verfahrensschritte verwalten",
"admin.procedural_events.list.new": "+ Neuer Verfahrensschritt",
"admin.procedural_events.col.code": "Code (Verfahrensschritt)",
// t-paliad-321: 3-segment proceeding-type code column (joined
// server-side); disambiguates same-named rules across proceedings.
"admin.procedural_events.col.proceeding": "Verfahren",
"admin.procedural_events.edit.title": "Verfahrensschritt bearbeiten — Paliad",
"admin.procedural_events.edit.breadcrumb":"← Verfahrensschritte verwalten",
"admin.procedural_events.edit.field.code": "Code (Verfahrensschritt-Identifikator)",
@@ -4606,6 +4617,13 @@ const translations: Record<Lang, Record<string, string>> = {
"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",
@@ -6173,6 +6191,8 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.procedural_events.list.heading": "Manage procedural events",
"admin.procedural_events.list.new": "+ New procedural event",
"admin.procedural_events.col.code": "Code (procedural event)",
// t-paliad-321: 3-segment proceeding-type code column.
"admin.procedural_events.col.proceeding": "Proceeding",
"admin.procedural_events.edit.title": "Edit procedural event — Paliad",
"admin.procedural_events.edit.breadcrumb":"← Manage procedural events",
"admin.procedural_events.edit.field.code": "Code (procedural-event identifier)",

View File

@@ -1317,6 +1317,26 @@ function paintSectionList(): void {
for (const sec of sections) {
list.appendChild(renderSectionRow(sec, lang, activeID === sec.id));
}
// t-paliad-318 Slice F — "+ Abschnitt hinzufügen" trailing
// affordance + "Reihenfolge speichern" affordance (only visible
// after a manual reorder; surfaced by paintReorderControls when
// pendingReorder is set).
let trailer = document.getElementById("submission-draft-sections-trailer");
if (!trailer) {
trailer = document.createElement("div");
trailer.id = "submission-draft-sections-trailer";
trailer.className = "submission-draft-sections-trailer";
wrap.appendChild(trailer);
}
trailer.innerHTML = "";
const addBtn = document.createElement("button");
addBtn.type = "button";
addBtn.className = "btn-small btn-secondary";
addBtn.textContent = isEN() ? "+ Add section" : "+ Abschnitt hinzufügen";
addBtn.addEventListener("click", () => openAddSectionForm(trailer!));
trailer.appendChild(addBtn);
}
function renderSectionRow(sec: SubmissionSectionJSON, lang: string, isActive: boolean): HTMLLIElement {
@@ -1325,9 +1345,29 @@ function renderSectionRow(sec: SubmissionSectionJSON, lang: string, isActive: bo
li.dataset.sectionId = sec.id;
if (!sec.included) li.classList.add("submission-draft-section--excluded");
// t-paliad-318 Slice F — drag-and-drop reorder. Native HTML5 DnD,
// no external library. The drag handle is the only draggable
// affordance so clicks inside the editor area don't accidentally
// trigger a drag.
li.draggable = false; // overridden via the handle below
li.addEventListener("dragover", (ev) => onSectionDragOver(ev, li));
li.addEventListener("drop", (ev) => onSectionDrop(ev, li));
li.addEventListener("dragleave", () => li.classList.remove("submission-draft-section--drop-target"));
const head = document.createElement("header");
head.className = "submission-draft-section-head";
// Drag handle — making just this element draggable scoped the
// gesture so contentEditable selections still work.
const handle = document.createElement("span");
handle.className = "submission-draft-section-handle";
handle.draggable = true;
handle.title = isEN() ? "Drag to reorder" : "Zum Sortieren ziehen";
handle.textContent = "⋮⋮";
handle.addEventListener("dragstart", (ev) => onSectionDragStart(ev, sec.id));
handle.addEventListener("dragend", () => onSectionDragEnd(li));
head.appendChild(handle);
const title = document.createElement("h3");
title.className = "submission-draft-section-title";
title.textContent = (lang === "en" ? sec.label_en : sec.label_de) || sec.section_key;
@@ -1356,14 +1396,43 @@ function renderSectionRow(sec: SubmissionSectionJSON, lang: string, isActive: bo
toggle.addEventListener("click", () => onSectionToggleIncluded(sec));
head.appendChild(toggle);
// t-paliad-318 Slice F — per-section delete. Removes the row.
// Confirmation guard prevents accidental loss of typed prose.
const del = document.createElement("button");
del.type = "button";
del.className = "btn-small btn-link-danger submission-draft-section-delete";
del.textContent = isEN() ? "Delete" : "Entfernen";
del.title = isEN() ? "Remove this section from the draft" : "Abschnitt aus dem Entwurf entfernen";
del.addEventListener("click", () => onSectionDelete(sec));
head.appendChild(del);
li.appendChild(head);
// Toolbar — shared B/I affordance per section. Slice D extends with
// headings, lists, quote.
// Toolbar — Slice D rich-prose affordances: B/I + H1/H2/H3 +
// bullet/numbered list + blockquote + hyperlink. Plus the Slice C
// building-block button. execCommand drives bold/italic/headings/
// lists/blockquote; hyperlink uses createLink with a prompt.
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"));
toolbar.appendChild(makeHeadingButton("H1", isEN() ? "Heading 1" : "Überschrift 1", 1));
toolbar.appendChild(makeHeadingButton("H2", isEN() ? "Heading 2" : "Überschrift 2", 2));
toolbar.appendChild(makeHeadingButton("H3", isEN() ? "Heading 3" : "Überschrift 3", 3));
toolbar.appendChild(makeToolbarButton("•", isEN() ? "Bullet list" : "Aufzählung", "insertUnorderedList"));
toolbar.appendChild(makeToolbarButton("1.", isEN() ? "Numbered list" : "Nummerierte Liste", "insertOrderedList"));
toolbar.appendChild(makeToolbarButton("”", isEN() ? "Blockquote" : "Zitat", "formatBlock", "blockquote"));
toolbar.appendChild(makeLinkButton());
// 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) || "";
@@ -1411,7 +1480,7 @@ function renderSectionRow(sec: SubmissionSectionJSON, lang: string, isActive: bo
return li;
}
function makeToolbarButton(label: string, title: string, format: "bold" | "italic"): HTMLButtonElement {
function makeToolbarButton(label: string, title: string, format: string, value?: string): HTMLButtonElement {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "submission-draft-section-toolbar-btn";
@@ -1422,7 +1491,7 @@ function makeToolbarButton(label: string, title: string, format: "bold" | "itali
// selection target.
btn.addEventListener("mousedown", (ev) => {
ev.preventDefault();
document.execCommand(format, false);
document.execCommand(format, false, value);
// Trigger the input handler so autosave fires.
const editor = document.activeElement as HTMLElement | null;
if (editor && editor.classList.contains("submission-draft-section-editor")) {
@@ -1432,6 +1501,50 @@ function makeToolbarButton(label: string, title: string, format: "bold" | "itali
return btn;
}
// makeHeadingButton emits an `<h1|h2|h3>` wrapping for the active
// block via execCommand("formatBlock", "h1") etc. Toggling the same
// heading back to a paragraph is handled by clicking the same button
// again (the browser's execCommand semantics).
function makeHeadingButton(label: string, title: string, level: 1 | 2 | 3): HTMLButtonElement {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "submission-draft-section-toolbar-btn";
btn.textContent = label;
btn.title = title;
btn.addEventListener("mousedown", (ev) => {
ev.preventDefault();
document.execCommand("formatBlock", false, "h" + level);
const editor = document.activeElement as HTMLElement | null;
if (editor && editor.classList.contains("submission-draft-section-editor")) {
onSectionInput(editor as HTMLDivElement);
}
});
return btn;
}
// makeLinkButton prompts for a URL and wraps the current selection
// (or inserts a label-as-URL if nothing selected). The browser's
// createLink built-in wires the <a href="…"> tag into the DOM;
// domToMarkdown reads it back as `[label](url)`.
function makeLinkButton(): HTMLButtonElement {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "submission-draft-section-toolbar-btn";
btn.textContent = "🔗";
btn.title = isEN() ? "Insert link" : "Link einfügen";
btn.addEventListener("mousedown", (ev) => {
ev.preventDefault();
const url = prompt(isEN() ? "URL:" : "URL:");
if (!url) return;
document.execCommand("createLink", false, url);
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;
@@ -1486,10 +1599,28 @@ function serializeNode(node: Node): string {
if (node.nodeType !== Node.ELEMENT_NODE) return "";
const el = node as HTMLElement;
const tag = el.tagName.toLowerCase();
// Lists: handle the wrapper before recursing into items so we can
// emit the right per-item Markdown prefix.
if (tag === "ul" || tag === "ol") {
const items: string[] = [];
let counter = 1;
for (const child of Array.from(el.childNodes)) {
if (child.nodeType === Node.ELEMENT_NODE && (child as HTMLElement).tagName.toLowerCase() === "li") {
const liInner = serializeNode(child).replace(/\n+$/g, "");
const prefix = tag === "ol" ? `${counter}. ` : "- ";
items.push(prefix + liInner);
counter++;
}
}
return items.join("\n") + "\n\n";
}
let inner = "";
for (const child of Array.from(el.childNodes)) {
inner += serializeNode(child);
}
switch (tag) {
case "b":
case "strong":
@@ -1504,6 +1635,26 @@ function serializeNode(node: Node): string {
// execCommand and contentEditable insert <div> on Enter in some
// browsers, <p> in others. Both are paragraph boundaries.
return inner + "\n\n";
case "h1":
return "# " + inner.replace(/\n+$/g, "") + "\n\n";
case "h2":
return "## " + inner.replace(/\n+$/g, "") + "\n\n";
case "h3":
return "### " + inner.replace(/\n+$/g, "") + "\n\n";
case "blockquote":
// Each line inside the blockquote gets its own "> " prefix per
// Markdown convention.
return inner.split("\n").map(line => line === "" ? "" : "> " + line).join("\n").replace(/\n+$/g, "") + "\n\n";
case "li":
// <li> rendered standalone (no <ul>/<ol> ancestor) — emit
// bullet by default. The ul/ol branch above handles the
// ordered/unordered choice when present.
return "- " + inner.replace(/\n+$/g, "") + "\n";
case "a": {
const href = el.getAttribute("href") ?? "";
if (!href || !inner) return inner;
return `[${inner}](${href})`;
}
default:
return inner;
}
@@ -1513,6 +1664,370 @@ async function onSectionToggleIncluded(sec: SubmissionSectionJSON): Promise<void
await patchSection(sec.id, { included: !sec.included });
}
// ─────────────────────────────────────────────────────────────────────
// t-paliad-318 Slice F — reorder / delete / add
// ─────────────────────────────────────────────────────────────────────
let dragSourceID: string | null = null;
function onSectionDragStart(ev: DragEvent, sectionID: string): void {
if (!ev.dataTransfer) return;
dragSourceID = sectionID;
ev.dataTransfer.effectAllowed = "move";
ev.dataTransfer.setData("text/plain", sectionID);
const parentLi = (ev.target as HTMLElement).closest("li");
if (parentLi) parentLi.classList.add("submission-draft-section--dragging");
}
function onSectionDragOver(ev: DragEvent, li: HTMLLIElement): void {
ev.preventDefault();
if (ev.dataTransfer) ev.dataTransfer.dropEffect = "move";
if (dragSourceID && dragSourceID !== li.dataset.sectionId) {
li.classList.add("submission-draft-section--drop-target");
}
}
function onSectionDragEnd(li: HTMLLIElement): void {
li.classList.remove("submission-draft-section--dragging");
document.querySelectorAll(".submission-draft-section--drop-target").forEach((el) => {
el.classList.remove("submission-draft-section--drop-target");
});
dragSourceID = null;
}
async function onSectionDrop(ev: DragEvent, targetLi: HTMLLIElement): Promise<void> {
ev.preventDefault();
targetLi.classList.remove("submission-draft-section--drop-target");
const sourceID = dragSourceID;
dragSourceID = null;
document.querySelectorAll(".submission-draft-section--dragging").forEach((el) => {
el.classList.remove("submission-draft-section--dragging");
});
if (!sourceID || !state.view?.sections) return;
const targetID = targetLi.dataset.sectionId;
if (!targetID || sourceID === targetID) return;
const ids = state.view.sections.map(s => s.id);
const fromIdx = ids.indexOf(sourceID);
const toIdx = ids.indexOf(targetID);
if (fromIdx < 0 || toIdx < 0) return;
// Splice source out, insert at target position. "Drop on row X"
// semantics: source lands JUST BEFORE the target row.
ids.splice(fromIdx, 1);
const insertAt = ids.indexOf(targetID);
ids.splice(insertAt, 0, sourceID);
await reorderSections(ids);
}
async function reorderSections(ids: string[]): Promise<void> {
if (!state.view) return;
const draftID = state.view.draft.id;
try {
const res = await fetch(
`/api/submission-drafts/${draftID}/sections/reorder`,
{
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ section_order: ids }),
},
);
if (!res.ok) {
console.warn("reorder failed", res.status);
return;
}
const body = await res.json() as { sections?: SubmissionSectionJSON[] };
if (state.view && body.sections) state.view.sections = body.sections;
paintSectionList();
} catch (err) {
console.warn("reorder error", err);
}
}
async function onSectionDelete(sec: SubmissionSectionJSON): Promise<void> {
const label = isEN() ? sec.label_en : sec.label_de;
const confirmMsg = isEN()
? `Delete section "${label}"? This cannot be undone.`
: `Abschnitt "${label}" entfernen? Diese Aktion kann nicht rückgängig gemacht werden.`;
if (!confirm(confirmMsg)) return;
if (!state.view) return;
try {
const res = await fetch(
`/api/submission-drafts/${state.view.draft.id}/sections/${sec.id}`,
{ method: "DELETE", credentials: "include" },
);
if (!res.ok && res.status !== 204) {
console.warn("delete section failed", res.status);
return;
}
if (state.view.sections) {
state.view.sections = state.view.sections.filter(s => s.id !== sec.id);
}
paintSectionList();
} catch (err) {
console.warn("delete section error", err);
}
}
function openAddSectionForm(host: HTMLElement): void {
// If already open, close (toggle).
const existing = host.querySelector(".submission-draft-add-section");
if (existing) {
existing.remove();
return;
}
const form = document.createElement("form");
form.className = "submission-draft-add-section";
form.addEventListener("submit", (ev) => { ev.preventDefault(); void submitAddSection(form); });
const fields = [
{ name: "section_key", label: isEN() ? "Slug" : "Slug", required: true, placeholder: "berufungsantraege" },
{ name: "label_de", label: "Label (DE)", required: true, placeholder: "Berufungsanträge" },
{ name: "label_en", label: "Label (EN)", required: true, placeholder: "Appeal requests" },
];
for (const f of fields) {
const row = document.createElement("label");
row.className = "submission-draft-add-section-row";
const lab = document.createElement("span");
lab.textContent = f.label + (f.required ? " *" : "");
row.appendChild(lab);
const inp = document.createElement("input");
inp.type = "text";
inp.name = f.name;
inp.className = "entity-form-input";
inp.required = f.required;
inp.placeholder = f.placeholder;
row.appendChild(inp);
form.appendChild(row);
}
const kindRow = document.createElement("label");
kindRow.className = "submission-draft-add-section-row";
const kindLab = document.createElement("span");
kindLab.textContent = isEN() ? "Kind" : "Typ";
kindRow.appendChild(kindLab);
const kindSel = document.createElement("select");
kindSel.name = "kind";
kindSel.className = "entity-form-input";
for (const opt of ["prose", "requests", "evidence"]) {
const o = document.createElement("option");
o.value = opt;
o.textContent = opt;
kindSel.appendChild(o);
}
kindRow.appendChild(kindSel);
form.appendChild(kindRow);
const actions = document.createElement("div");
actions.className = "submission-draft-add-section-actions";
const ok = document.createElement("button");
ok.type = "submit";
ok.className = "btn-small btn-primary btn-cta-lime";
ok.textContent = isEN() ? "Add" : "Hinzufügen";
actions.appendChild(ok);
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "btn-small btn-secondary";
cancel.textContent = isEN() ? "Cancel" : "Abbrechen";
cancel.addEventListener("click", () => form.remove());
actions.appendChild(cancel);
form.appendChild(actions);
host.appendChild(form);
setTimeout(() => (form.querySelector('input[name="section_key"]') as HTMLInputElement | null)?.focus(), 0);
}
async function submitAddSection(form: HTMLFormElement): Promise<void> {
if (!state.view) return;
const data = new FormData(form);
const payload = {
section_key: String(data.get("section_key") ?? "").trim(),
kind: String(data.get("kind") ?? "prose"),
label_de: String(data.get("label_de") ?? "").trim(),
label_en: String(data.get("label_en") ?? "").trim(),
};
try {
const res = await fetch(
`/api/submission-drafts/${state.view.draft.id}/sections`,
{
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
if (!res.ok) {
const body = await res.json().catch(() => ({} as { error?: string }));
alert(body.error ?? `HTTP ${res.status}`);
return;
}
const created = await res.json() as SubmissionSectionJSON;
if (state.view.sections) state.view.sections.push(created);
form.remove();
paintSectionList();
} catch (err) {
alert(String(err));
}
}
// ─────────────────────────────────────────────────────────────────────
// 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;

View File

@@ -204,7 +204,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
{navItem("/admin/team", ICON_USERS, "nav.admin.team", "Team-Verwaltung", currentPath)}
{navItem("/admin/partner-units", ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)}
{navItem("/admin/event-types", ICON_TABLE, "nav.admin.event_types", "Event-Typen", currentPath)}
{navItem("/admin/rules", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
{navItem("/admin/procedural-events", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
{navItem("/admin/backups", ICON_DOWNLOAD, "nav.admin.backups", "Backups", currentPath)}
{/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */}

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"
@@ -291,6 +297,7 @@ export type I18nKey =
| "admin.partner_units.subtitle"
| "admin.partner_units.title"
| "admin.procedural_events.col.code"
| "admin.procedural_events.col.proceeding"
| "admin.procedural_events.edit.breadcrumb"
| "admin.procedural_events.edit.field.code"
| "admin.procedural_events.edit.field.event_kind"

View File

@@ -6294,6 +6294,309 @@ dialog.modal::backdrop {
background: var(--color-bg-elev-2, var(--color-bg-elev-1));
}
/* t-paliad-318 Slice F — drag-and-drop reorder + add / delete affordances. */
.submission-draft-section-handle {
cursor: grab;
user-select: none;
color: var(--color-text-muted);
font-weight: 600;
padding: 0 0.35rem;
margin-right: 0.4rem;
border-radius: 3px;
}
.submission-draft-section-handle:hover {
background: var(--color-bg-subtle, var(--color-bg-elev-2));
}
.submission-draft-section-handle:active {
cursor: grabbing;
}
.submission-draft-section--dragging {
opacity: 0.5;
}
.submission-draft-section--drop-target {
border-top: 2px solid var(--color-accent-fg, var(--color-text));
}
.submission-draft-section-delete {
margin-left: 0.35rem;
}
.submission-draft-sections-trailer {
margin-top: 0.6rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.submission-draft-add-section {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.6rem 0.7rem;
border: 1px dashed var(--color-border);
border-radius: 4px;
background: var(--color-bg-elev-1);
}
.submission-draft-add-section-row {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.submission-draft-add-section-row > span {
font-size: 0.85em;
color: var(--color-text-muted);
}
.submission-draft-add-section-actions {
display: flex;
gap: 0.4rem;
margin-top: 0.2rem;
}
/* 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;

View File

@@ -0,0 +1,47 @@
-- 140_drop_deadline_rules (down) — Slice B.4, t-paliad-305
--
-- Best-effort recovery from the deadline_rules_pre_140 snapshot. The
-- original triggers (mig 079 audit), indexes, CHECK constraints (mig
-- 135 primary_party), and FK constraints on the new tables are NOT
-- recreated here — restoring the working state requires replaying
-- migrations 078/079/091/095/098/122/128/134/135 against the restored
-- table.
--
-- Use this only for catastrophic recovery. The normal revert path
-- for B.4 is to re-deploy the previous container image (which still
-- writes via the dual-write helper to a paliad.deadline_rules that no
-- longer exists) — that would crash on first write, so true revert
-- requires this down + a code revert + a snapshot restore.
-- Drop the INSTEAD OF triggers + functions
DROP TRIGGER IF EXISTS deadline_rules_unified_insert ON paliad.deadline_rules_unified;
DROP TRIGGER IF EXISTS deadline_rules_unified_update ON paliad.deadline_rules_unified;
DROP FUNCTION IF EXISTS paliad.deadline_rules_unified_insert_trigger();
DROP FUNCTION IF EXISTS paliad.deadline_rules_unified_update_trigger();
-- Recreate paliad.deadline_rules from snapshot.
CREATE TABLE paliad.deadline_rules AS TABLE paliad.deadline_rules_pre_140;
-- Re-add the PK constraint (CREATE TABLE AS doesn't carry constraints).
ALTER TABLE paliad.deadline_rules ADD PRIMARY KEY (id);
-- Re-point the FKs back to deadline_rules.
ALTER TABLE paliad.appointments
DROP CONSTRAINT IF EXISTS appointments_deadline_rule_id_fkey;
ALTER TABLE paliad.appointments
ADD CONSTRAINT appointments_deadline_rule_id_fkey
FOREIGN KEY (deadline_rule_id) REFERENCES paliad.deadline_rules(id);
ALTER TABLE paliad.deadline_rule_backfill_orphans
DROP CONSTRAINT IF EXISTS deadline_rule_backfill_orphans_resolved_rule_id_fkey;
ALTER TABLE paliad.deadline_rule_backfill_orphans
ADD CONSTRAINT deadline_rule_backfill_orphans_resolved_rule_id_fkey
FOREIGN KEY (resolved_rule_id) REFERENCES paliad.deadline_rules(id);
-- Re-add deadlines.rule_id from the snapshot's data (via sequencing_rule_id
-- which inherited deadline_rules.id during mig 136).
ALTER TABLE paliad.deadlines ADD COLUMN rule_id uuid;
UPDATE paliad.deadlines SET rule_id = sequencing_rule_id WHERE sequencing_rule_id IS NOT NULL;
ALTER TABLE paliad.deadlines
ADD CONSTRAINT fristen_rule_id_fkey
FOREIGN KEY (rule_id) REFERENCES paliad.deadline_rules(id);

View File

@@ -0,0 +1,448 @@
-- 140_drop_deadline_rules — Slice B.4 destructive drop (t-paliad-305 / m/paliad#93)
--
-- HARD STOPS:
-- * Audit-first: snapshot paliad.deadline_rules → paliad.deadline_rules_pre_140
-- in the SAME TRANSACTION as the DROP, per m's snapshot policy
-- (precedent migs 091/093/095/098). The whole .up.sql runs inside a
-- single transaction because the migration runner wraps it; if any
-- statement fails, the snapshot CREATE TABLE rolls back with the
-- destructive DROP.
-- * No data loss: paliad.deadline_rules has been a write-side shadow
-- since B.3 (B.2 dual-write keeps sequencing_rules + procedural_events
-- + legal_sources current). Drift verified clean before this slice
-- (deadline_rules=231, sequencing_rules=231, 0 mismatches across
-- counts/FKs/lifecycle/is_active).
--
-- What this migration does:
-- 1. Snapshot deadline_rules → deadline_rules_pre_140 (preserves audit
-- trail of the table's final state for forensic + revert paths).
-- 2. Final reconciliation: catch any deadlines whose
-- sequencing_rule_id/procedural_event_id columns drifted from the
-- legacy rule_id (no live drift today — defensive).
-- 3. Drop the audit trigger on deadline_rules (it can't fire on a
-- gone table; the trigger function itself stays for the historical
-- paliad.deadline_rule_audit reads).
-- 4. Re-point FKs that currently target deadline_rules.id over to
-- sequencing_rules.id. The id values are identical (sequencing_rules
-- inherited deadline_rules.id during mig 136 backfill), so no data
-- migration is needed — just the constraint swap. Affects:
-- - paliad.appointments.deadline_rule_id
-- - paliad.deadline_rule_backfill_orphans.resolved_rule_id
-- 5. Drop paliad.deadlines.rule_id column. Per design §5.4 step 16:
-- "DROP COLUMN paliad.deadlines.rule_id (keep rule_code +
-- custom_rule_text as the human-readable denormalized columns —
-- they're the safety net for orphaned deadlines per t-paliad-258)."
-- The new sequencing_rule_id + procedural_event_id columns from
-- mig 136 are the FK back-links from B.4 forward.
-- 6. DROP TABLE paliad.deadline_rules.
-- 7. INSTEAD OF triggers on paliad.deadline_rules_unified that route
-- INSERTs/UPDATEs to the underlying sr+pe+ls tables. Lets the
-- RuleEditorService keep its existing SQL shape (one INSERT, one
-- UPDATE per write method) with only a table-name swap. The
-- triggers project the legacy column shape back to the three new
-- tables exactly as the dual-write helper did in B.2.
--
-- Down: best-effort restore from the snapshot. The original triggers,
-- indexes, and FKs are NOT recreated — operator must replay historical
-- migrations 078/079/091/095/098/122 to bring the table back to a
-- working shape. The down path is for catastrophic recovery, not casual
-- revert.
-- ---------------------------------------------------------------
-- 1. Snapshot — must precede the destructive ops (same TX).
-- ---------------------------------------------------------------
CREATE TABLE paliad.deadline_rules_pre_140 AS TABLE paliad.deadline_rules;
COMMENT ON TABLE paliad.deadline_rules_pre_140 IS
'Snapshot of paliad.deadline_rules taken in mig 140 (Slice B.4, '
't-paliad-305) before the destructive DROP. Mirrors precedent '
'pre_091/093/095/098. Read-only forensic + revert source.';
-- ---------------------------------------------------------------
-- 2. Final reconciliation — should be a no-op (drift was 0 going
-- into this slice). Belt-and-braces against a write that snuck
-- in between drift-check and this migration.
-- ---------------------------------------------------------------
UPDATE paliad.deadlines d
SET sequencing_rule_id = d.rule_id,
procedural_event_id = sr.procedural_event_id
FROM paliad.sequencing_rules sr
WHERE sr.id = d.rule_id
AND d.rule_id IS NOT NULL
AND (d.sequencing_rule_id IS DISTINCT FROM d.rule_id
OR d.procedural_event_id IS DISTINCT FROM sr.procedural_event_id);
-- ---------------------------------------------------------------
-- 3. Drop the deadline_rules audit trigger. The trigger function
-- (paliad.deadline_rule_audit_trigger) stays defined for any
-- historical references; mig 079 created it.
-- ---------------------------------------------------------------
DROP TRIGGER IF EXISTS deadline_rules_audit_aiud ON paliad.deadline_rules;
-- ---------------------------------------------------------------
-- 4. Re-point FKs from deadline_rules → sequencing_rules.
-- ---------------------------------------------------------------
ALTER TABLE paliad.appointments
DROP CONSTRAINT IF EXISTS appointments_deadline_rule_id_fkey;
ALTER TABLE paliad.appointments
ADD CONSTRAINT appointments_deadline_rule_id_fkey
FOREIGN KEY (deadline_rule_id) REFERENCES paliad.sequencing_rules(id);
ALTER TABLE paliad.deadline_rule_backfill_orphans
DROP CONSTRAINT IF EXISTS deadline_rule_backfill_orphans_resolved_rule_id_fkey;
ALTER TABLE paliad.deadline_rule_backfill_orphans
ADD CONSTRAINT deadline_rule_backfill_orphans_resolved_rule_id_fkey
FOREIGN KEY (resolved_rule_id) REFERENCES paliad.sequencing_rules(id);
-- Drop the deadlines→deadline_rules FK before we drop the column.
ALTER TABLE paliad.deadlines
DROP CONSTRAINT IF EXISTS fristen_rule_id_fkey;
-- ---------------------------------------------------------------
-- 5. Drop paliad.deadlines.rule_id (column + remaining indexes).
-- ---------------------------------------------------------------
ALTER TABLE paliad.deadlines
DROP COLUMN IF EXISTS rule_id;
-- ---------------------------------------------------------------
-- 6a. Drop the deadline_search materialized view, which has a
-- direct dependency on paliad.deadline_rules (mig 077). We
-- recreate it after the DROP, re-pointed at deadline_rules_unified
-- so reads keep working. All 11 indexes are recreated alongside.
-- ---------------------------------------------------------------
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
-- ---------------------------------------------------------------
-- 6. DROP TABLE paliad.deadline_rules. Now that:
-- - dependent FKs are re-pointed to sequencing_rules,
-- - the audit trigger is dropped,
-- - deadlines.rule_id is gone,
-- - the deadline_search matview is gone,
-- nothing references the table anymore. The self-FKs
-- (deadline_rules.parent_id, .draft_of) drop with the table.
-- ---------------------------------------------------------------
DROP TABLE paliad.deadline_rules;
-- ---------------------------------------------------------------
-- 7. INSTEAD OF triggers on the view — routes writes to sr+pe+ls.
-- ---------------------------------------------------------------
CREATE OR REPLACE FUNCTION paliad.deadline_rules_unified_insert_trigger()
RETURNS TRIGGER LANGUAGE plpgsql AS $fn$
DECLARE
v_legal_source_id uuid;
v_pe_id uuid;
v_code text;
BEGIN
-- legal_sources upsert (no-op if NEW.legal_source is NULL)
IF NEW.legal_source IS NOT NULL THEN
INSERT INTO paliad.legal_sources (citation, jurisdiction)
VALUES (NEW.legal_source,
COALESCE(NULLIF(split_part(NEW.legal_source, '.', 1), ''), 'other'))
ON CONFLICT (citation) DO NOTHING;
SELECT id INTO v_legal_source_id
FROM paliad.legal_sources
WHERE citation = NEW.legal_source;
END IF;
-- Mint synthetic code when submission_code is NULL — same recipe
-- as mig 136 + B.2 dual-write helper. Stays byte-identical.
v_code := COALESCE(NEW.submission_code,
'null.' || substring(replace(NEW.id::text, '-', ''), 1, 8));
-- procedural_events upsert. ON CONFLICT (code) deliberately leaves
-- lifecycle_state / published_at / is_active alone — those track
-- the procedural-event concept's own lifecycle, not the inserting
-- sequencing-rule's lifecycle (e.g. a CloneAsDraft of a published
-- rule creates a draft sr that shares the published PE; the PE
-- should stay 'published'). Identity columns DO update so an
-- admin editing a draft's name still flips the lawyer-visible
-- label (1:1 today; revisit when 1:N becomes a real pattern).
INSERT INTO paliad.procedural_events
(code, name, name_en, description, event_kind, primary_party_default,
legal_source_id, concept_id, lifecycle_state, published_at, is_active)
VALUES
(v_code, NEW.name, NEW.name_en, NEW.description, NEW.event_type,
NEW.primary_party, v_legal_source_id, NEW.concept_id,
COALESCE(NEW.lifecycle_state, 'draft'), NEW.published_at,
COALESCE(NEW.is_active, true))
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
name_en = EXCLUDED.name_en,
description = EXCLUDED.description,
event_kind = EXCLUDED.event_kind,
primary_party_default = EXCLUDED.primary_party_default,
legal_source_id = EXCLUDED.legal_source_id,
concept_id = EXCLUDED.concept_id,
-- lifecycle_state / published_at / is_active deliberately omitted
updated_at = now()
RETURNING id INTO v_pe_id;
-- sequencing_rules insert. id is the caller-supplied NEW.id so
-- existing FK back-links (deadlines.sequencing_rule_id) resolve.
INSERT INTO paliad.sequencing_rules
(id, procedural_event_id, proceeding_type_id, parent_id, trigger_event_id,
duration_value, duration_unit, timing,
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt,
combine_op, condition_expr, primary_party, sequence_order,
is_spawn, spawn_label, spawn_proceeding_type_id,
is_bilateral, is_court_set, priority,
rule_code, rule_codes, deadline_notes, deadline_notes_en,
choices_offered, applies_to_target,
lifecycle_state, draft_of, published_at, is_active,
created_at, updated_at)
VALUES
(NEW.id, v_pe_id, NEW.proceeding_type_id, NEW.parent_id, NEW.trigger_event_id,
COALESCE(NEW.duration_value, 0), COALESCE(NEW.duration_unit, 'months'),
COALESCE(NEW.timing, 'after'),
NEW.alt_duration_value, NEW.alt_duration_unit, NEW.alt_rule_code, NEW.anchor_alt,
NEW.combine_op, NEW.condition_expr, NEW.primary_party,
COALESCE(NEW.sequence_order, 0),
COALESCE(NEW.is_spawn, false), NEW.spawn_label, NEW.spawn_proceeding_type_id,
COALESCE(NEW.is_bilateral, false), COALESCE(NEW.is_court_set, false),
COALESCE(NEW.priority, 'mandatory'),
NEW.rule_code, NEW.rule_codes, NEW.deadline_notes, NEW.deadline_notes_en,
NEW.choices_offered, NEW.applies_to_target,
COALESCE(NEW.lifecycle_state, 'draft'), NEW.draft_of,
NEW.published_at, COALESCE(NEW.is_active, true),
COALESCE(NEW.created_at, now()), COALESCE(NEW.updated_at, now()));
RETURN NEW;
END $fn$;
CREATE TRIGGER deadline_rules_unified_insert
INSTEAD OF INSERT ON paliad.deadline_rules_unified
FOR EACH ROW EXECUTE FUNCTION paliad.deadline_rules_unified_insert_trigger();
CREATE OR REPLACE FUNCTION paliad.deadline_rules_unified_update_trigger()
RETURNS TRIGGER LANGUAGE plpgsql AS $fn$
DECLARE
v_legal_source_id uuid;
v_code text;
BEGIN
-- legal_sources upsert (only if NEW.legal_source is non-NULL).
-- A change FROM non-NULL TO NULL clears legal_source_id on the
-- procedural_event below — same shape as mig 136 / B.2 behaviour.
IF NEW.legal_source IS NOT NULL THEN
INSERT INTO paliad.legal_sources (citation, jurisdiction)
VALUES (NEW.legal_source,
COALESCE(NULLIF(split_part(NEW.legal_source, '.', 1), ''), 'other'))
ON CONFLICT (citation) DO NOTHING;
SELECT id INTO v_legal_source_id
FROM paliad.legal_sources
WHERE citation = NEW.legal_source;
END IF;
v_code := COALESCE(NEW.submission_code,
'null.' || substring(replace(NEW.id::text, '-', ''), 1, 8));
-- Update procedural_events keyed by the existing PE link on
-- sequencing_rules. lifecycle_state / published_at / is_active on
-- PE are NOT mirrored from the per-sequencing-rule UPDATE — see
-- the INSERT trigger comment for the rationale (a draft sr that
-- shares its PE with a published peer must not flip the PE to
-- draft). Identity columns DO mirror so editing name/code from
-- the admin UI continues to reach the lawyer-visible label.
UPDATE paliad.procedural_events
SET code = v_code,
name = NEW.name,
name_en = NEW.name_en,
description = NEW.description,
event_kind = NEW.event_type,
primary_party_default = NEW.primary_party,
legal_source_id = v_legal_source_id,
concept_id = NEW.concept_id,
updated_at = now()
WHERE id = (SELECT procedural_event_id
FROM paliad.sequencing_rules
WHERE id = NEW.id);
-- Update sequencing_rules (1:1 by id).
UPDATE paliad.sequencing_rules
SET proceeding_type_id = NEW.proceeding_type_id,
parent_id = NEW.parent_id,
trigger_event_id = NEW.trigger_event_id,
duration_value = NEW.duration_value,
duration_unit = NEW.duration_unit,
timing = NEW.timing,
alt_duration_value = NEW.alt_duration_value,
alt_duration_unit = NEW.alt_duration_unit,
alt_rule_code = NEW.alt_rule_code,
anchor_alt = NEW.anchor_alt,
combine_op = NEW.combine_op,
condition_expr = NEW.condition_expr,
primary_party = NEW.primary_party,
sequence_order = NEW.sequence_order,
is_spawn = NEW.is_spawn,
spawn_label = NEW.spawn_label,
spawn_proceeding_type_id = NEW.spawn_proceeding_type_id,
is_bilateral = NEW.is_bilateral,
is_court_set = NEW.is_court_set,
priority = NEW.priority,
rule_code = NEW.rule_code,
rule_codes = NEW.rule_codes,
deadline_notes = NEW.deadline_notes,
deadline_notes_en = NEW.deadline_notes_en,
choices_offered = NEW.choices_offered,
applies_to_target = NEW.applies_to_target,
lifecycle_state = NEW.lifecycle_state,
draft_of = NEW.draft_of,
published_at = NEW.published_at,
is_active = NEW.is_active,
updated_at = now()
WHERE id = NEW.id;
RETURN NEW;
END $fn$;
CREATE TRIGGER deadline_rules_unified_update
INSTEAD OF UPDATE ON paliad.deadline_rules_unified
FOR EACH ROW EXECUTE FUNCTION paliad.deadline_rules_unified_update_trigger();
-- ---------------------------------------------------------------
-- 8. POST assertions.
-- ---------------------------------------------------------------
DO $$
DECLARE
v_snapshot_count int;
v_sr_count int;
v_view_count int;
v_dr_table_exists int;
v_rule_id_col int;
BEGIN
-- B.2 dual-write was implemented only for the active+published lifecycle
-- (the scope of the read paths and B.4's pre-flip drift check). Archived
-- + draft rows in deadline_rules were never replicated to sequencing_rules
-- (they had no production read path). Snapshot includes them all (CREATE
-- TABLE AS is unfiltered), so we compare on the same filter B.2 actually
-- maintained. Drafts/archived rows are preserved in paliad.deadline_rules_pre_140
-- for forensic + future-backfill use.
SELECT COUNT(*) INTO v_snapshot_count
FROM paliad.deadline_rules_pre_140
WHERE is_active = true AND lifecycle_state = 'published';
SELECT COUNT(*) INTO v_sr_count
FROM paliad.sequencing_rules
WHERE is_active = true AND lifecycle_state = 'published';
SELECT COUNT(*) INTO v_view_count FROM paliad.deadline_rules_unified;
IF v_snapshot_count <> v_sr_count THEN
RAISE EXCEPTION '[mig 140] FAILED POST: snapshot active+published has % rows, sequencing_rules active+published has % rows — dual-write drift',
v_snapshot_count, v_sr_count;
END IF;
SELECT COUNT(*) INTO v_dr_table_exists
FROM information_schema.tables
WHERE table_schema = 'paliad' AND table_name = 'deadline_rules';
IF v_dr_table_exists > 0 THEN
RAISE EXCEPTION '[mig 140] FAILED POST: paliad.deadline_rules table still exists after DROP';
END IF;
SELECT COUNT(*) INTO v_rule_id_col
FROM information_schema.columns
WHERE table_schema = 'paliad' AND table_name = 'deadlines' AND column_name = 'rule_id';
IF v_rule_id_col > 0 THEN
RAISE EXCEPTION '[mig 140] FAILED POST: paliad.deadlines.rule_id column still exists after DROP';
END IF;
RAISE NOTICE '[mig 140] OK — deadline_rules dropped, snapshot=% rows, sequencing_rules=% rows, view (filtered)=% rows, INSTEAD OF triggers active',
v_snapshot_count, v_sr_count, v_view_count;
END $$;
-- ---------------------------------------------------------------
-- 8. Recreate paliad.deadline_search materialized view against
-- deadline_rules_unified (same column shape — sr.id is the new
-- dr.id, etc.). Definition mirrors mig 077; only the FROM table
-- name changes. All 11 indexes restored.
-- ---------------------------------------------------------------
CREATE MATERIALIZED VIEW paliad.deadline_search AS
SELECT 'rule'::text AS kind,
('r:'::text || (dr.id)::text) AS row_key,
dc.id AS concept_id,
dc.slug AS concept_slug,
dc.name_de AS concept_name_de,
dc.name_en AS concept_name_en,
dc.description AS concept_description,
dc.aliases AS concept_aliases,
dc.party AS concept_party,
dc.category AS concept_category,
dc.sort_order AS concept_sort_order,
dr.id AS rule_id,
NULL::bigint AS trigger_event_id,
pt.code AS proceeding_code,
pt.name AS proceeding_name_de,
pt.name_en AS proceeding_name_en,
pt.jurisdiction,
pt.display_order AS proceeding_display_order,
dr.submission_code AS rule_local_code,
dr.name AS rule_name_de,
dr.name_en AS rule_name_en,
dr.legal_source,
dr.rule_code,
dr.duration_value,
dr.duration_unit,
dr.timing,
COALESCE(dr.primary_party, dc.party) AS effective_party
FROM paliad.deadline_rules_unified dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id
WHERE dr.is_active AND pt.is_active AND pt.category = 'fristenrechner'::text
UNION ALL
SELECT 'trigger'::text AS kind,
('t:'::text || (te.id)::text) AS row_key,
dc.id AS concept_id,
dc.slug AS concept_slug,
dc.name_de AS concept_name_de,
dc.name_en AS concept_name_en,
dc.description AS concept_description,
dc.aliases AS concept_aliases,
dc.party AS concept_party,
dc.category AS concept_category,
dc.sort_order AS concept_sort_order,
NULL::uuid AS rule_id,
te.id AS trigger_event_id,
NULL::text AS proceeding_code,
NULL::text AS proceeding_name_de,
NULL::text AS proceeding_name_en,
'cross-cutting'::text AS jurisdiction,
9999 AS proceeding_display_order,
te.code AS rule_local_code,
te.name_de AS rule_name_de,
te.name AS rule_name_en,
dr_trig.legal_source,
NULL::text AS rule_code,
NULL::integer AS duration_value,
NULL::text AS duration_unit,
NULL::text AS timing,
dc.party AS effective_party
FROM paliad.trigger_events te
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
LEFT JOIN paliad.deadline_rules_unified dr_trig
ON dr_trig.trigger_event_id = te.id
AND dr_trig.proceeding_type_id IS NULL
AND dr_trig.is_active
AND dr_trig.lifecycle_state = 'published'::text
WHERE te.is_active
WITH NO DATA;
CREATE UNIQUE INDEX deadline_search_row_key ON paliad.deadline_search (row_key);
CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id);
CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code);
CREATE INDEX deadline_search_legal_source ON paliad.deadline_search (legal_source);
CREATE INDEX deadline_search_effective_party ON paliad.deadline_search (effective_party);
CREATE INDEX deadline_search_legal_source_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops);
CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops);
CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_de_trgm ON paliad.deadline_search USING gin (rule_name_de gin_trgm_ops);
CREATE INDEX deadline_search_rule_en_trgm ON paliad.deadline_search USING gin (rule_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_code_trgm ON paliad.deadline_search USING gin (rule_code gin_trgm_ops);
REFRESH MATERIALIZED VIEW paliad.deadline_search;

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

@@ -0,0 +1,3 @@
-- t-paliad-317: revert specialist base seed rows.
DELETE FROM paliad.submission_bases WHERE slug IN ('lg-duesseldorf', 'upc-formal');

View File

@@ -0,0 +1,128 @@
-- t-paliad-317 (m/paliad#141): Composer Slice E — specialist bases.
--
-- Two firm-agnostic bases for proceeding-family-specific styling:
--
-- lg-duesseldorf — DE LG (de.inf.lg) conservative German legal style.
-- Times New Roman 11pt; black headings.
-- upc-formal — UPC court of first instance (upc.inf.cfi) formal
-- style. Calibri 11pt body; UPC-blue (1F3864) headings;
-- Cambria italic for blockquotes.
--
-- The .docx body for each is a minimal Composer-mode skeleton with
-- the 10 default section anchors and an empty rels envelope. The
-- styles.xml declares the {prefix}-Body / -Heading1/2/3 / -ListBullet
-- / -ListNumber / -Quote paragraph styles + a "Hyperlink" character
-- style (matches the MD walker's emitted r:id="rIdComposerN" link
-- runs from Slice D).
--
-- Generator: scripts/gen-submission-base/main.go (each preset hard-
-- codes the typography). The .docx files are uploaded to Gitea at
-- 6 - material/Templates/Word/Paliad/Composer/{slug}.docx as mAi.
--
-- The mig is additive only: ON CONFLICT (slug) DO NOTHING keeps a
-- re-run safe and existing rows untouched.
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
('lg-duesseldorf', NULL, 'de.inf.lg',
'LG-Düsseldorf-Stil', 'LG-Düsseldorf style',
'Konservativer DE-LG-Stil: Times New Roman 11pt, schlichte Überschriften.',
'Conservative DE LG style: Times New Roman 11pt, plain headings.',
'6 - material/Templates/Word/Paliad/Composer/lg-duesseldorf.docx',
jsonb_build_object(
'version', 1,
'stylemap', jsonb_build_object(
'paragraph', 'LG-Body',
'heading_1', 'LG-Heading1',
'heading_2', 'LG-Heading2',
'heading_3', 'LG-Heading3',
'list_bullet', 'LG-ListBullet',
'list_numbered', 'LG-ListNumber',
'blockquote', 'LG-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}}\n{{project.court}}',
'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}}\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}}',
'seed_md_en', E'{{user.display_name}}')
)
),
'{}'::text[]
),
('upc-formal', NULL, 'upc.inf.cfi',
'UPC-Verfahren', 'UPC formal',
'UPC-Verfahrensstil: Calibri 11pt, UPC-blaue Überschriften, Cambria-Zitate.',
'UPC court style: Calibri 11pt, UPC-blue headings, Cambria quotes.',
'6 - material/Templates/Word/Paliad/Composer/upc-formal.docx',
jsonb_build_object(
'version', 1,
'stylemap', jsonb_build_object(
'paragraph', 'UPC-Body',
'heading_1', 'UPC-Heading1',
'heading_2', 'UPC-Heading2',
'heading_3', 'UPC-Heading3',
'list_bullet', 'UPC-ListBullet',
'list_numbered', 'UPC-ListNumber',
'blockquote', 'UPC-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 the matter\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n— Defendant —\n\nUPC-Aktenzeichen: {{project.case_number}}\nStreitpatent: {{project.patent_number_upc}}',
'seed_md_en', E'# In the matter\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n— Defendant —\n\nUPC case number: {{project.case_number}}\nPatent in suit: {{project.patent_number_upc}}'),
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,31 @@
-- 151_dedupe_null_procedural_events (down) — t-paliad-319 / m/paliad#144
--
-- Best-effort restore from paliad.procedural_events_pre_151 and
-- paliad.sequencing_rules_pre_151. Re-points the reparented
-- sequencing_rules back at their original procedural_event_id and
-- reactivates the archived duplicates with the lifecycle_state +
-- is_active they had before the up migration.
--
-- Catastrophic-recovery path only; the normal revert is to leave the
-- dedupe in place (it is purely cosmetic).
-- 1. Re-point sequencing_rules.procedural_event_id back to its
-- pre-mig-151 value. The snapshot row is keyed by sr.id so the
-- join is 1:1 and idempotent.
UPDATE paliad.sequencing_rules sr
SET procedural_event_id = s.original_procedural_event_id,
updated_at = now()
FROM paliad.sequencing_rules_pre_151 s
WHERE sr.id = s.id;
-- 2. Reactivate the archived duplicates with their snapshot lifecycle.
UPDATE paliad.procedural_events pe
SET is_active = s.is_active,
lifecycle_state = s.lifecycle_state,
updated_at = now()
FROM paliad.procedural_events_pre_151 s
WHERE pe.id = s.id;
-- 3. Drop the snapshot tables — the data is back in place.
DROP TABLE IF EXISTS paliad.sequencing_rules_pre_151;
DROP TABLE IF EXISTS paliad.procedural_events_pre_151;

View File

@@ -0,0 +1,229 @@
-- 151_dedupe_null_procedural_events — t-paliad-319 / m/paliad#144
--
-- Purpose: ~14 paliad.procedural_events rows with synthetic null.<8hex>
-- codes (minted by mig 136 from the legacy paliad.deadline_rules rows
-- whose submission_code was NULL) share user-visible names. The
-- /admin/procedural-events list shows multiple entries for the same legal
-- concept (worst offender: "Mängelbeseitigung / Zahlung" × 6). This
-- migration consolidates every name-group onto a single canonical row,
-- reparents the sequencing_rules pointing at the duplicates, and archives
-- the duplicates without deleting them.
--
-- Scope verified live before write (Supabase MCP, 2026-05-26):
-- * 5 name-groups, 14 duplicate rows total (1 canonical + 15 dups per
-- group). Every duplicate has exactly 1 sequencing_rule pointing at it.
-- * 0 paliad.deadlines reference any duplicate.
-- * 0 procedural_events.draft_of references any duplicate.
-- * No audit trigger on procedural_events or sequencing_rules — only
-- the INSTEAD OF triggers on deadline_rules_unified (mig 140), which
-- do not fire on direct table writes. No set_config('paliad.audit_reason')
-- needed.
--
-- Canonical selection: ROW_NUMBER() OVER (PARTITION BY name ORDER BY
-- created_at, id::text). Every duplicate in current data shares the same
-- created_at (mig 136 bulk insert), so the deterministic tiebreaker is
-- the UUID's lexicographic order.
--
-- Hard constraints honoured:
-- * No deletions. Duplicates flip to is_active=false +
-- lifecycle_state='archived'. The rows stay in the table for audit.
-- * Reparent sequencing_rules.procedural_event_id duplicate → canonical
-- BEFORE archiving, so no FK ever points at an archived PE.
-- * Snapshot the affected procedural_events + sequencing_rules into
-- paliad.procedural_events_pre_151 / paliad.sequencing_rules_pre_151
-- in the same TX, mirroring precedent (migs 091/093/095/098/140).
--
-- Down: best-effort restore from the snapshots. See .down.sql.
-- ----------------------------------------------------------------
-- 1. Build the dedupe mapping (duplicate_id → canonical_id) in a
-- TEMP table used by every subsequent step.
-- ----------------------------------------------------------------
CREATE TEMP TABLE tmp_pe_dedupe ON COMMIT DROP AS
WITH dupe_names AS (
SELECT name
FROM paliad.procedural_events
WHERE code LIKE 'null.%'
GROUP BY name
HAVING COUNT(*) > 1
),
ranked AS (
SELECT pe.id,
pe.code,
pe.name,
pe.created_at,
ROW_NUMBER() OVER (
PARTITION BY pe.name
ORDER BY pe.created_at, pe.id::text
) AS rn
FROM paliad.procedural_events pe
WHERE pe.code LIKE 'null.%'
AND pe.name IN (SELECT name FROM dupe_names)
),
canonicals AS (
SELECT name,
id AS canonical_id,
code AS canonical_code
FROM ranked
WHERE rn = 1
)
SELECT r.id AS duplicate_id,
r.code AS duplicate_code,
r.name,
c.canonical_id,
c.canonical_code
FROM ranked r
JOIN canonicals c ON c.name = r.name
WHERE r.rn > 1;
-- ----------------------------------------------------------------
-- 2. Snapshot. Captures the rows that change so .down has a clean
-- source of truth; mirrors the pre_091/093/095/098/140 precedent.
-- ----------------------------------------------------------------
CREATE TABLE paliad.procedural_events_pre_151 AS
SELECT pe.*
FROM paliad.procedural_events pe
WHERE pe.id IN (SELECT duplicate_id FROM tmp_pe_dedupe);
COMMENT ON TABLE paliad.procedural_events_pre_151 IS
'Snapshot (mig 151, t-paliad-319) of the null.* procedural_events '
'duplicates that were archived in favour of their canonical name-mate. '
'Read-only forensic + revert source. Mirrors precedent pre_091/093/'
'095/098/140.';
CREATE TABLE paliad.sequencing_rules_pre_151 AS
SELECT sr.id,
sr.procedural_event_id AS original_procedural_event_id
FROM paliad.sequencing_rules sr
WHERE sr.procedural_event_id IN (SELECT duplicate_id FROM tmp_pe_dedupe);
COMMENT ON TABLE paliad.sequencing_rules_pre_151 IS
'Snapshot (mig 151, t-paliad-319) of sequencing_rules.procedural_event_id '
'before reparenting from null.* duplicates onto their canonical PE. '
'Read-only forensic + revert source.';
-- ----------------------------------------------------------------
-- 3. Audit log — per-row NOTICE so the migration output captures
-- exactly which duplicate folded into which canonical, including
-- the sr_count for the duplicate (always 1 in current data, but
-- the RAISE keeps the audit honest if the scope grows later).
-- ----------------------------------------------------------------
DO $$
DECLARE
rec record;
v_dup_count int;
v_grp_count int;
BEGIN
SELECT COUNT(*), COUNT(DISTINCT name)
INTO v_dup_count, v_grp_count
FROM tmp_pe_dedupe;
RAISE NOTICE '[mig 151] dedupe scope: % duplicate rows across % name-groups',
v_dup_count, v_grp_count;
FOR rec IN
SELECT d.duplicate_id,
d.duplicate_code,
d.name,
d.canonical_id,
d.canonical_code,
(SELECT COUNT(*)
FROM paliad.sequencing_rules sr
WHERE sr.procedural_event_id = d.duplicate_id) AS sr_count
FROM tmp_pe_dedupe d
ORDER BY d.name, d.duplicate_id
LOOP
RAISE NOTICE '[mig 151] dup % (%) -> canonical % (%) — sr_count=%',
rec.duplicate_id, rec.duplicate_code,
rec.canonical_id, rec.canonical_code,
rec.sr_count;
RAISE NOTICE '[mig 151] name: %', rec.name;
END LOOP;
END $$;
-- ----------------------------------------------------------------
-- 4. Reparent sequencing_rules.procedural_event_id duplicate → canonical.
-- sequencing_rules_pe_proc_lifecycle_idx is non-unique, so collapsing
-- multiple sr onto one PE is by design.
-- ----------------------------------------------------------------
UPDATE paliad.sequencing_rules sr
SET procedural_event_id = d.canonical_id,
updated_at = now()
FROM tmp_pe_dedupe d
WHERE sr.procedural_event_id = d.duplicate_id;
-- ----------------------------------------------------------------
-- 5. Archive the duplicates. No deletion — audit trail preserved.
-- ----------------------------------------------------------------
UPDATE paliad.procedural_events pe
SET is_active = false,
lifecycle_state = 'archived',
updated_at = now()
WHERE pe.id IN (SELECT duplicate_id FROM tmp_pe_dedupe);
-- ----------------------------------------------------------------
-- 6. POST assertions. Any failure rolls the migration back.
-- ----------------------------------------------------------------
DO $$
DECLARE
v_surviving_groups int;
v_expected_count int;
v_archived_count int;
v_orphan_sr int;
BEGIN
-- (a) Acceptance criterion 2: no name-group still has >1 active+
-- published null.* row.
SELECT COUNT(*) INTO v_surviving_groups
FROM (
SELECT name
FROM paliad.procedural_events
WHERE code LIKE 'null.%'
AND is_active = true
AND lifecycle_state = 'published'
GROUP BY name
HAVING COUNT(*) > 1
) s;
IF v_surviving_groups > 0 THEN
RAISE EXCEPTION
'[mig 151] FAILED POST: % name-groups still have >1 active+published null.* rows',
v_surviving_groups;
END IF;
-- (b) Every targeted duplicate is now archived.
SELECT COUNT(*) INTO v_expected_count FROM tmp_pe_dedupe;
SELECT COUNT(*) INTO v_archived_count
FROM paliad.procedural_events pe
WHERE pe.id IN (SELECT duplicate_id FROM tmp_pe_dedupe)
AND pe.is_active = false
AND pe.lifecycle_state = 'archived';
IF v_archived_count <> v_expected_count THEN
RAISE EXCEPTION
'[mig 151] FAILED POST: archived %/% duplicates',
v_archived_count, v_expected_count;
END IF;
-- (c) Acceptance criterion 4: no sequencing_rule still points at
-- an archived duplicate.
SELECT COUNT(*) INTO v_orphan_sr
FROM paliad.sequencing_rules sr
WHERE sr.procedural_event_id IN (SELECT duplicate_id FROM tmp_pe_dedupe);
IF v_orphan_sr > 0 THEN
RAISE EXCEPTION
'[mig 151] FAILED POST: % sequencing_rules still point at archived PE duplicates',
v_orphan_sr;
END IF;
RAISE NOTICE '[mig 151] OK — archived % duplicates across % name-groups; 0 orphan sequencing_rules',
v_archived_count,
(SELECT COUNT(DISTINCT name) FROM tmp_pe_dedupe);
END $$;

View File

@@ -0,0 +1,17 @@
-- 152_dedupe_identical_sequencing_rule_clones (down) — t-paliad-321
--
-- Best-effort revert from paliad.sequencing_rules_pre_152. Flips the
-- archived rows back to is_active=true / lifecycle_state='published'.
-- Does NOT undo the deadlines.sequencing_rule_id reparent — that would
-- require remembering the previous pointer per row, which the snapshot
-- on sequencing_rules doesn't carry. In live data the reparent was a
-- no-op (zero deadlines pointed at duplicates), so this is fine.
UPDATE paliad.sequencing_rules sr
SET is_active = true,
lifecycle_state = 'published',
updated_at = now()
FROM paliad.sequencing_rules_pre_152 snap
WHERE sr.id = snap.id;
DROP TABLE IF EXISTS paliad.sequencing_rules_pre_152;

View File

@@ -0,0 +1,240 @@
-- 152_dedupe_identical_sequencing_rule_clones — t-paliad-321 / m/paliad#144 follow-up
--
-- Purpose: mig 151 archived 5 of 6 duplicate procedural_events for
-- "Mängelbeseitigung / Zahlung" and reparented their sequencing_rules
-- onto the canonical PE. The 6 sequencing_rules themselves remained
-- active. Because every one of them is a byte-for-byte clone (same
-- proceeding_type_id=NULL, rule_code=NULL, duration 14d, primary_party=NULL,
-- everything else NULL, lifecycle_state='published') and only sequence_order
-- differs, the admin shows six indistinguishable rows for one legal
-- concept. This mig archives 5 of the 6 keeping the lexicographically
-- lowest UUID as canonical.
--
-- Scope verified live before write (Supabase MCP, 2026-05-26):
-- * Exactly 1 clone-group surfaces by the full-signature query
-- below: 6 "Mängelbeseitigung / Zahlung" sequencing_rules with
-- all-NULL discriminators and (duration_value=14, duration_unit='days').
-- * 0 paliad.deadlines reference the 5 to-be-archived rows
-- (verified via deadlines.sequencing_rule_id JOIN; the column
-- formerly named deadlines.rule_id was dropped in mig 140 / B.4).
-- * Other name-groups in the live corpus — "Antrag auf
-- Patentänderung"×4, "Beginn des Hauptsacheverfahrens"×2,
-- "Berufungsbegründung-R.220.1"×2, "Berufungsschrift-R.220.1"×2 —
-- do NOT collapse under this signature because their
-- proceeding_type_id / rule_code / duration / primary_party
-- differ. They are legitimately distinct rules per proceeding;
-- this mig leaves them alone.
--
-- Hard constraints honoured (mirrors mig 151):
-- * No deletions. Archived rows flip to is_active=false +
-- lifecycle_state='archived'. Rows stay in the table for audit.
-- * Reparent paliad.deadlines.sequencing_rule_id duplicate →
-- canonical BEFORE archiving, so no live deadline keeps pointing
-- at an archived sequencing_rule. (deadlines.rule_id column
-- dropped in mig 140; the back-link lives on sequencing_rule_id
-- now — same UUID semantics.)
-- * Snapshot the affected rows into paliad.sequencing_rules_pre_152
-- in the same TX, mirroring precedent (migs 091/093/095/098/140/151).
-- * set_config('paliad.audit_reason') is defensively called even
-- though no audit trigger fires on sequencing_rules today (mig 151
-- §comments documented this). Future audit trigger would inherit
-- the reason automatically.
--
-- Generic-shape rationale: the audit query below uses the FULL
-- signature paliadin specified — procedural_event_id, proceeding_type_id,
-- rule_code, duration_value, duration_unit, primary_party, condition_expr,
-- trigger_event_id, alt_*, anchor_alt, combine_op, parent_id, is_spawn,
-- spawn_*. A NOTICE surfaces every group BEFORE the archive step so an
-- operator running the deploy logs sees what's about to be touched.
-- If new groups appear after future seeds, this mig is safe to re-run
-- conceptually (it would archive any new clones) but only fires once
-- via the applied_migrations protocol.
-- ----------------------------------------------------------------
-- 1. Build the dedupe mapping (duplicate_id → canonical_id) into a
-- TEMP table used by every subsequent step.
-- ----------------------------------------------------------------
CREATE TEMP TABLE tmp_sr_dedupe ON COMMIT DROP AS
WITH ranked AS (
SELECT
id, procedural_event_id, proceeding_type_id, rule_code,
duration_value, duration_unit, primary_party,
condition_expr, trigger_event_id, alt_duration_value,
alt_duration_unit, alt_rule_code, anchor_alt, combine_op,
parent_id, is_spawn, spawn_label, spawn_proceeding_type_id,
created_at,
ROW_NUMBER() OVER (
PARTITION BY
procedural_event_id, proceeding_type_id, rule_code,
duration_value, duration_unit, primary_party,
condition_expr::text, trigger_event_id,
alt_duration_value, alt_duration_unit, alt_rule_code,
anchor_alt, combine_op, parent_id, is_spawn, spawn_label,
spawn_proceeding_type_id
ORDER BY created_at, id::text
) AS rn,
COUNT(*) OVER (
PARTITION BY
procedural_event_id, proceeding_type_id, rule_code,
duration_value, duration_unit, primary_party,
condition_expr::text, trigger_event_id,
alt_duration_value, alt_duration_unit, alt_rule_code,
anchor_alt, combine_op, parent_id, is_spawn, spawn_label,
spawn_proceeding_type_id
) AS grp_size
FROM paliad.sequencing_rules
WHERE is_active = true
AND lifecycle_state = 'published'
)
SELECT
r.id AS duplicate_id,
canon.id AS canonical_id,
r.procedural_event_id,
(SELECT name FROM paliad.procedural_events WHERE id = r.procedural_event_id) AS pe_name
FROM ranked r
JOIN ranked canon
ON canon.procedural_event_id IS NOT DISTINCT FROM r.procedural_event_id
AND canon.proceeding_type_id IS NOT DISTINCT FROM r.proceeding_type_id
AND canon.rule_code IS NOT DISTINCT FROM r.rule_code
AND canon.duration_value IS NOT DISTINCT FROM r.duration_value
AND canon.duration_unit IS NOT DISTINCT FROM r.duration_unit
AND canon.primary_party IS NOT DISTINCT FROM r.primary_party
AND canon.condition_expr::text IS NOT DISTINCT FROM r.condition_expr::text
AND canon.trigger_event_id IS NOT DISTINCT FROM r.trigger_event_id
AND canon.alt_duration_value IS NOT DISTINCT FROM r.alt_duration_value
AND canon.alt_duration_unit IS NOT DISTINCT FROM r.alt_duration_unit
AND canon.alt_rule_code IS NOT DISTINCT FROM r.alt_rule_code
AND canon.anchor_alt IS NOT DISTINCT FROM r.anchor_alt
AND canon.combine_op IS NOT DISTINCT FROM r.combine_op
AND canon.parent_id IS NOT DISTINCT FROM r.parent_id
AND canon.is_spawn IS NOT DISTINCT FROM r.is_spawn
AND canon.spawn_label IS NOT DISTINCT FROM r.spawn_label
AND canon.spawn_proceeding_type_id IS NOT DISTINCT FROM r.spawn_proceeding_type_id
AND canon.rn = 1
WHERE r.rn > 1 AND r.grp_size > 1;
-- ----------------------------------------------------------------
-- 2. Surface every clone-group as a NOTICE before archiving.
-- ----------------------------------------------------------------
DO $$
DECLARE
rec record;
total_to_archive int;
BEGIN
SELECT COUNT(*) INTO total_to_archive FROM tmp_sr_dedupe;
RAISE NOTICE '[mig 152] PRE: % sequencing_rules row(s) will be archived', total_to_archive;
FOR rec IN
SELECT pe_name, canonical_id, COUNT(*) AS dup_count, array_agg(duplicate_id::text ORDER BY duplicate_id::text) AS dup_ids
FROM tmp_sr_dedupe
GROUP BY pe_name, canonical_id
ORDER BY pe_name
LOOP
RAISE NOTICE '[mig 152] % canonical=% duplicates=% ids=%',
rec.pe_name, rec.canonical_id, rec.dup_count, rec.dup_ids;
END LOOP;
END $$;
-- ----------------------------------------------------------------
-- 3. Snapshot the rows about to be archived (only the duplicates;
-- the canonicals stay in the live table). Matches precedent.
-- ----------------------------------------------------------------
CREATE TABLE paliad.sequencing_rules_pre_152 AS
SELECT sr.*
FROM paliad.sequencing_rules sr
JOIN tmp_sr_dedupe d ON d.duplicate_id = sr.id;
COMMENT ON TABLE paliad.sequencing_rules_pre_152 IS
'Snapshot of paliad.sequencing_rules rows archived by mig 152 '
'(identical clones — Mängelbeseitigung / Zahlung × 5). Mirrors '
'precedent pre_091/093/095/098/140/151. Read-only revert source. '
't-paliad-321 / m/paliad#144 follow-up.';
-- ----------------------------------------------------------------
-- 4. Reparent paliad.deadlines.sequencing_rule_id duplicate → canonical
-- BEFORE archiving. Today's live data has 0 deadlines pointing at
-- any duplicate, but the statement is safe + defensive against a
-- race between drift-check and apply.
-- ----------------------------------------------------------------
UPDATE paliad.deadlines d
SET sequencing_rule_id = m.canonical_id,
procedural_event_id = (SELECT procedural_event_id
FROM paliad.sequencing_rules
WHERE id = m.canonical_id),
updated_at = now()
FROM tmp_sr_dedupe m
WHERE d.sequencing_rule_id = m.duplicate_id;
-- ----------------------------------------------------------------
-- 5. Defensive audit-reason. Sequencing_rules has no audit trigger
-- today (mig 151 §scope verified), but set_config is transactional
-- and a future audit trigger inherits the reason automatically.
-- ----------------------------------------------------------------
SELECT set_config('paliad.audit_reason',
'mig 152: archive identical sequencing_rule clones (mig 151 follow-up; t-paliad-321)',
true);
-- ----------------------------------------------------------------
-- 6. Archive the duplicates.
-- ----------------------------------------------------------------
UPDATE paliad.sequencing_rules
SET is_active = false,
lifecycle_state = 'archived',
updated_at = now()
WHERE id IN (SELECT duplicate_id FROM tmp_sr_dedupe);
-- ----------------------------------------------------------------
-- 7. POST assertions.
-- ----------------------------------------------------------------
DO $$
DECLARE
v_archived int;
v_remaining_dupes int;
v_orphan_deadlines int;
BEGIN
-- a. Did the expected number of rows get archived?
SELECT COUNT(*) INTO v_archived
FROM paliad.sequencing_rules
WHERE id IN (SELECT duplicate_id FROM tmp_sr_dedupe)
AND lifecycle_state = 'archived'
AND is_active = false;
IF v_archived <> (SELECT COUNT(*) FROM tmp_sr_dedupe) THEN
RAISE EXCEPTION '[mig 152] FAILED POST: expected % rows archived, got %',
(SELECT COUNT(*) FROM tmp_sr_dedupe), v_archived;
END IF;
-- b. No clone group of size > 1 should remain in active+published.
SELECT COUNT(*) INTO v_remaining_dupes FROM (
SELECT 1
FROM paliad.sequencing_rules
WHERE is_active = true AND lifecycle_state = 'published'
GROUP BY procedural_event_id, proceeding_type_id, rule_code,
duration_value, duration_unit, primary_party,
condition_expr::text, trigger_event_id,
alt_duration_value, alt_duration_unit, alt_rule_code,
anchor_alt, combine_op, parent_id, is_spawn, spawn_label,
spawn_proceeding_type_id
HAVING COUNT(*) > 1
) g;
IF v_remaining_dupes > 0 THEN
RAISE EXCEPTION '[mig 152] FAILED POST: % clone group(s) still active+published after archive', v_remaining_dupes;
END IF;
-- c. No deadline points at an archived sequencing_rule.
SELECT COUNT(*) INTO v_orphan_deadlines
FROM paliad.deadlines d
JOIN paliad.sequencing_rules sr ON sr.id = d.sequencing_rule_id
WHERE sr.lifecycle_state = 'archived';
IF v_orphan_deadlines > 0 THEN
RAISE EXCEPTION '[mig 152] FAILED POST: % live deadline(s) still point at an archived sequencing_rule', v_orphan_deadlines;
END IF;
RAISE NOTICE '[mig 152] OK — archived=%, remaining clone groups=0, orphan deadlines=0',
v_archived;
END $$;

View File

@@ -8,6 +8,7 @@ import (
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/models"
"mgit.msbls.de/m/paliad/internal/services"
)
@@ -25,6 +26,77 @@ import (
// is mapped to 409 Conflict so the editor UI can show a clear "must
// clone first" hint.
// Slice B.5 (t-paliad-305) JSON envelope renames:
//
// - submission_code → code (procedural-event identifier)
// - event_type → event_kind (procedural-event taxonomy)
//
// Wire compatibility: every response emits BOTH the legacy and the
// canonical keys for one slice (see Deprecation HTTP header on the
// response). Input bodies accept either name on the request; the
// canonical key wins when both are present.
//
// adminRuleResponse wraps models.DeadlineRule (= litigationplanner.Rule)
// to add the canonical `code` + `event_kind` fields alongside the
// historical `submission_code` + `event_type` already on Rule's tags.
// The embedded *models.DeadlineRule carries every existing tag through
// json.Marshal unchanged; the wrapper only ADDS the two new keys.
//
// ProceedingTypeCode (t-paliad-321) is the joined paliad.proceeding_types.code
// for the row's proceeding_type_id. NULL on event-rooted rules. Lets the
// /admin/procedural-events list disambiguate same-named rules at a glance
// (e.g. "Berufungsbegründung" rows differ only by proceeding code).
type adminRuleResponse struct {
*models.DeadlineRule
Code *string `json:"code,omitempty"`
EventKind *string `json:"event_kind,omitempty"`
ProceedingTypeCode *string `json:"proceeding_type_code,omitempty"`
}
// wrapRuleResponse builds the dual-emit wrapper from a service result.
// Same values, two keys per concept — no semantic change. Pass a non-nil
// ptCode to populate the proceeding_type_code field; nil leaves it
// absent (e.g. on event-rooted rules with NULL proceeding_type_id).
func wrapRuleResponse(r *models.DeadlineRule) adminRuleResponse {
if r == nil {
return adminRuleResponse{}
}
return adminRuleResponse{
DeadlineRule: r,
Code: r.SubmissionCode,
EventKind: r.EventType,
}
}
// wrapRuleListResponse maps a slice of service results into the
// dual-emit wrapper. Used by the LIST endpoint. ptCodes is an
// optional id → code lookup populated by handleAdminListRules from a
// single batch query against paliad.proceeding_types; nil leaves
// every row's proceeding_type_code empty (the LIST endpoint always
// passes a populated map; other callers don't need it).
func wrapRuleListResponse(rows []models.DeadlineRule, ptCodes map[int]string) []adminRuleResponse {
out := make([]adminRuleResponse, len(rows))
for i := range rows {
out[i] = wrapRuleResponse(&rows[i])
if ptCodes != nil && rows[i].ProceedingTypeID != nil {
if code, ok := ptCodes[*rows[i].ProceedingTypeID]; ok {
out[i].ProceedingTypeCode = &code
}
}
}
return out
}
// adminRuleDeprecationHeaders writes the IETF "Deprecation" + "Sunset"
// HTTP headers signaling that the legacy `submission_code` /
// `event_type` JSON keys are being retired in favour of `code` /
// `event_kind`. RFC 8594 (Sunset) + draft-ietf-httpapi-deprecation-header.
// Clients should migrate within one slice cycle.
func adminRuleDeprecationHeaders(w http.ResponseWriter) {
w.Header().Set("Deprecation", `true; key="submission_code,event_type"`)
w.Header().Set("Link", `<https://mgit.msbls.de/m/paliad/issues/93>; rel="deprecation"`)
}
// GET /admin/api/rules — paginated list with filters.
func handleAdminListRules(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.ruleEditor == nil {
@@ -73,7 +145,16 @@ func handleAdminListRules(w http.ResponseWriter, r *http.Request) {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
// t-paliad-321: batch-fetch proceeding_type.code for every rule
// row that carries a non-NULL proceeding_type_id, so the LIST
// response can show a Proceeding column without an N+1 join.
ptCodes, err := dbSvc.ruleEditor.LoadProceedingTypeCodes(r.Context(), rows)
if err != nil {
writeRuleEditorError(w, err)
return
}
adminRuleDeprecationHeaders(w)
writeJSON(w, http.StatusOK, wrapRuleListResponse(rows, ptCodes))
}
// GET /admin/api/rules/{id}
@@ -91,7 +172,8 @@ func handleAdminGetRule(w http.ResponseWriter, r *http.Request) {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusOK, row)
adminRuleDeprecationHeaders(w)
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
}
// POST /admin/api/rules — create draft.
@@ -108,12 +190,15 @@ func handleAdminCreateRule(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
// Slice B.5 (t-paliad-305): accept both legacy + canonical JSON keys.
body.CreateRuleInput.CoalesceCanonicalKeys()
row, err := dbSvc.ruleEditor.Create(r.Context(), body.CreateRuleInput, body.Reason)
if err != nil {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusCreated, row)
adminRuleDeprecationHeaders(w)
writeJSON(w, http.StatusCreated, wrapRuleResponse(row))
}
// PATCH /admin/api/rules/{id} — partial update of a draft.
@@ -134,12 +219,15 @@ func handleAdminPatchRule(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
// Slice B.5 (t-paliad-305): accept both legacy + canonical JSON keys.
body.RulePatch.CoalesceCanonicalKeys()
row, err := dbSvc.ruleEditor.UpdateDraft(r.Context(), id, body.RulePatch, body.Reason)
if err != nil {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusOK, row)
adminRuleDeprecationHeaders(w)
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
}
// POST /admin/api/rules/{id}/clone-as-draft
@@ -161,7 +249,8 @@ func handleAdminCloneAsDraft(w http.ResponseWriter, r *http.Request) {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusCreated, row)
adminRuleDeprecationHeaders(w)
writeJSON(w, http.StatusCreated, wrapRuleResponse(row))
}
// POST /admin/api/rules/{id}/publish
@@ -183,7 +272,8 @@ func handleAdminPublishRule(w http.ResponseWriter, r *http.Request) {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusOK, row)
adminRuleDeprecationHeaders(w)
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
}
// POST /admin/api/rules/{id}/archive
@@ -205,7 +295,8 @@ func handleAdminArchiveRule(w http.ResponseWriter, r *http.Request) {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusOK, row)
adminRuleDeprecationHeaders(w)
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
}
// POST /admin/api/rules/{id}/restore
@@ -227,7 +318,8 @@ func handleAdminRestoreRule(w http.ResponseWriter, r *http.Request) {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusOK, row)
adminRuleDeprecationHeaders(w)
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
}
// GET /admin/api/rules/{id}/audit?offset=N&limit=M
@@ -419,3 +511,66 @@ func handleAdminResolveOrphan(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]string{"status": "resolved"})
}
// Slice B.6 (t-paliad-305) — 301 redirect helpers for the legacy
// /admin/rules* paths. New canonical paths live under
// /admin/procedural-events; the redirects keep external bookmarks,
// audit-log entries, and curl scripts working through one
// deprecation cycle.
//
// Three flavours:
//
// * redirectToProceduralEvents(newPath) — fixed redirect target
// (used by the parameter-less paths /admin/rules and
// /admin/api/rules).
// * redirectToProceduralEventEdit — page path with {id}/edit suffix.
// * redirectToProceduralEventAPI(suffix) — JSON API paths that carry
// an {id} and optional suffix (/clone-as-draft, /publish, …).
//
// All emit 301 Moved Permanently — caches and browsers learn the new
// URL once and stop hitting the legacy path. The IETF Deprecation
// header is added so machine clients see the migration signal
// alongside the redirect.
// redirectToProceduralEvents returns an http.HandlerFunc that 301s to
// the supplied destination path. Query string is preserved.
func redirectToProceduralEvents(dst string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
target := dst
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
w.Header().Set("Deprecation", `true; path="/admin/rules"`)
w.Header().Set("Link", `</admin/procedural-events>; rel="successor-version"`)
http.Redirect(w, r, target, http.StatusMovedPermanently)
}
}
// redirectToProceduralEventEdit 301s GET /admin/rules/{id}/edit →
// /admin/procedural-events/{id}/edit.
func redirectToProceduralEventEdit(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
target := "/admin/procedural-events/" + id + "/edit"
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
w.Header().Set("Deprecation", `true; path="/admin/rules/{id}/edit"`)
w.Header().Set("Link", `</admin/procedural-events/{id}/edit>; rel="successor-version"`)
http.Redirect(w, r, target, http.StatusMovedPermanently)
}
// redirectToProceduralEventAPI 301s /admin/api/rules/{id}[/suffix] →
// /admin/api/procedural-events/{id}[/suffix]. The optional suffix
// covers /clone-as-draft, /publish, /archive, /restore, /audit, /preview.
func redirectToProceduralEventAPI(suffix string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
target := "/admin/api/procedural-events/" + id + suffix
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
w.Header().Set("Deprecation", `true; path="/admin/api/rules/{id}`+suffix+`"`)
w.Header().Set("Link", `</admin/api/procedural-events/{id}`+suffix+`>; rel="successor-version"`)
http.Redirect(w, r, target, http.StatusMovedPermanently)
}
}

View File

@@ -113,8 +113,34 @@ var fileRegistry = map[string]fileEntry{
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.en.docx",
},
// t-paliad-317 Composer Slice E — specialist firm-agnostic bases.
// Both live under Composer/ (not under HLC/) so a future non-HLC
// deployment serves the same cross-firm files. Body = anchor-only
// per Slice B; styles.xml carries the preset's typography.
composerBaseLGDuesseldorfSlug: {
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/Composer/lg-duesseldorf.docx",
DownloadName: "LG-Düsseldorf Stil.docx",
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
RepoOwner: "m",
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/Composer/lg-duesseldorf.docx",
},
composerBaseUPCFormalSlug: {
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/Composer/upc-formal.docx",
DownloadName: "UPC formal.docx",
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
RepoOwner: "m",
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/Composer/upc-formal.docx",
},
}
// t-paliad-317 Composer Slice E — slugs for the new specialist bases.
const (
composerBaseLGDuesseldorfSlug = "submission/composer/lg-duesseldorf.docx"
composerBaseUPCFormalSlug = "submission/composer/upc-formal.docx"
)
// skeletonSubmissionSlug names the universal skeleton template inside
// the shared fileRegistry cache. Exported via a const so handler code
// (resolveSubmissionTemplate, hlPatentsStyleSHA's sibling) refers to
@@ -413,6 +439,8 @@ func fetchFirmSkeletonBytes(ctx context.Context) ([]byte, string, error) {
var composerBaseSlugMap = map[string]string{
"hlc-letterhead": firmSkeletonSubmissionSlug,
"neutral": skeletonSubmissionSlug,
"lg-duesseldorf": composerBaseLGDuesseldorfSlug,
"upc-formal": composerBaseUPCFormalSlug,
}
// fetchComposerBaseBytes returns the .docx bytes for a Composer base,

View File

@@ -124,6 +124,10 @@ type Services struct {
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
@@ -195,10 +199,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
projection: svc.Projection,
export: svc.Export,
backup: svc.Backup,
submissionDraft: svc.SubmissionDraft,
submissionBase: svc.SubmissionBase,
submissionSection: svc.SubmissionSection,
submissionComposer: svc.SubmissionComposer,
submissionDraft: svc.SubmissionDraft,
submissionBase: svc.SubmissionBase,
submissionSection: svc.SubmissionSection,
submissionComposer: svc.SubmissionComposer,
submissionBuildingBlock: svc.SubmissionBuildingBlock,
eventChoice: svc.EventChoice,
scenario: svc.Scenario,
}
@@ -427,6 +432,15 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
// 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-318 (m/paliad#141) Composer Slice F — add custom
// section, delete section, reorder.
protected.HandleFunc("POST /api/submission-drafts/{draft_id}/sections", handleCreateSubmissionSection)
protected.HandleFunc("DELETE /api/submission-drafts/{draft_id}/sections/{section_id}", handleDeleteSubmissionSection)
protected.HandleFunc("POST /api/submission-drafts/{draft_id}/sections/reorder", handleReorderSubmissionSections)
// 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.
@@ -691,6 +705,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))
@@ -703,18 +727,43 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
// t-paliad-089 — admin Event-Type moderation panel.
// t-paliad-191 Slice 11a — admin rule-editor API.
// t-paliad-192 Slice 11b — admin rule-editor UI pages + orphan list/resolve.
protected.HandleFunc("GET /admin/rules", adminGate(users, gateOnboarded(handleAdminRulesListPage)))
protected.HandleFunc("GET /admin/rules/{id}/edit", adminGate(users, gateOnboarded(handleAdminRulesEditPage)))
protected.HandleFunc("GET /admin/api/rules", adminGate(users, handleAdminListRules))
protected.HandleFunc("GET /admin/api/rules/{id}", adminGate(users, handleAdminGetRule))
protected.HandleFunc("POST /admin/api/rules", adminGate(users, handleAdminCreateRule))
protected.HandleFunc("PATCH /admin/api/rules/{id}", adminGate(users, handleAdminPatchRule))
protected.HandleFunc("POST /admin/api/rules/{id}/clone-as-draft", adminGate(users, handleAdminCloneAsDraft))
protected.HandleFunc("POST /admin/api/rules/{id}/publish", adminGate(users, handleAdminPublishRule))
protected.HandleFunc("POST /admin/api/rules/{id}/archive", adminGate(users, handleAdminArchiveRule))
protected.HandleFunc("POST /admin/api/rules/{id}/restore", adminGate(users, handleAdminRestoreRule))
protected.HandleFunc("GET /admin/api/rules/{id}/audit", adminGate(users, handleAdminGetRuleAudit))
protected.HandleFunc("GET /admin/api/rules/{id}/preview", adminGate(users, handleAdminPreviewRule))
// Slice B.6 (t-paliad-305) — canonical URL paths under
// /admin/procedural-events with 301 redirects from the legacy
// /admin/rules paths so existing bookmarks and audit-log
// entries continue to resolve. New paths point at the same
// handlers; the canonical-URL name aligns with the umbrella
// term locked in Slice A.
protected.HandleFunc("GET /admin/procedural-events", adminGate(users, gateOnboarded(handleAdminRulesListPage)))
protected.HandleFunc("GET /admin/procedural-events/{id}/edit", adminGate(users, gateOnboarded(handleAdminRulesEditPage)))
protected.HandleFunc("GET /admin/api/procedural-events", adminGate(users, handleAdminListRules))
protected.HandleFunc("GET /admin/api/procedural-events/{id}", adminGate(users, handleAdminGetRule))
protected.HandleFunc("POST /admin/api/procedural-events", adminGate(users, handleAdminCreateRule))
protected.HandleFunc("PATCH /admin/api/procedural-events/{id}", adminGate(users, handleAdminPatchRule))
protected.HandleFunc("POST /admin/api/procedural-events/{id}/clone-as-draft", adminGate(users, handleAdminCloneAsDraft))
protected.HandleFunc("POST /admin/api/procedural-events/{id}/publish", adminGate(users, handleAdminPublishRule))
protected.HandleFunc("POST /admin/api/procedural-events/{id}/archive", adminGate(users, handleAdminArchiveRule))
protected.HandleFunc("POST /admin/api/procedural-events/{id}/restore", adminGate(users, handleAdminRestoreRule))
protected.HandleFunc("GET /admin/api/procedural-events/{id}/audit", adminGate(users, handleAdminGetRuleAudit))
protected.HandleFunc("GET /admin/api/procedural-events/{id}/preview", adminGate(users, handleAdminPreviewRule))
// Legacy /admin/rules paths — 301 redirect to the canonical
// /admin/procedural-events paths. One-slice deprecation window
// per design §8.2 (B.6 optional; m authorised the rename
// 2026-05-26). After the next slice that audits external
// references, these can be retired entirely.
protected.HandleFunc("GET /admin/rules", adminGate(users, redirectToProceduralEvents("/admin/procedural-events")))
protected.HandleFunc("GET /admin/rules/{id}/edit", adminGate(users, redirectToProceduralEventEdit))
protected.HandleFunc("GET /admin/api/rules", adminGate(users, redirectToProceduralEvents("/admin/api/procedural-events")))
protected.HandleFunc("GET /admin/api/rules/{id}", adminGate(users, redirectToProceduralEventAPI("")))
protected.HandleFunc("POST /admin/api/rules", adminGate(users, redirectToProceduralEvents("/admin/api/procedural-events")))
protected.HandleFunc("PATCH /admin/api/rules/{id}", adminGate(users, redirectToProceduralEventAPI("")))
protected.HandleFunc("POST /admin/api/rules/{id}/clone-as-draft", adminGate(users, redirectToProceduralEventAPI("/clone-as-draft")))
protected.HandleFunc("POST /admin/api/rules/{id}/publish", adminGate(users, redirectToProceduralEventAPI("/publish")))
protected.HandleFunc("POST /admin/api/rules/{id}/archive", adminGate(users, redirectToProceduralEventAPI("/archive")))
protected.HandleFunc("POST /admin/api/rules/{id}/restore", adminGate(users, redirectToProceduralEventAPI("/restore")))
protected.HandleFunc("GET /admin/api/rules/{id}/audit", adminGate(users, redirectToProceduralEventAPI("/audit")))
protected.HandleFunc("GET /admin/api/rules/{id}/preview", adminGate(users, redirectToProceduralEventAPI("/preview")))
protected.HandleFunc("GET /admin/api/orphans", adminGate(users, handleAdminListOrphans))
protected.HandleFunc("POST /admin/api/orphans/{id}/resolve", adminGate(users, handleAdminResolveOrphan))

View File

@@ -71,10 +71,11 @@ type dbServices struct {
// t-paliad-313 — Composer base catalog + per-draft sections +
// (Slice B) the render pipeline assembling base + sections into a
// final .docx.
submissionBase *services.BaseService
submissionSection *services.SectionService
submissionComposer *services.SubmissionComposer
// 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,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

@@ -38,6 +38,8 @@ import (
"net/http"
"time"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
@@ -130,6 +132,188 @@ func handlePatchSubmissionSection(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, sectionJSONFromService(updated))
}
// ─────────────────────────────────────────────────────────────────────
// Slice F — add custom section / delete section / reorder
// ─────────────────────────────────────────────────────────────────────
type submissionSectionCreateInput struct {
SectionKey string `json:"section_key"`
Kind string `json:"kind"`
LabelDE string `json:"label_de"`
LabelEN string `json:"label_en"`
ContentMDDE string `json:"content_md_de,omitempty"`
ContentMDEN string `json:"content_md_en,omitempty"`
OrderIndex int `json:"order_index,omitempty"`
}
// handleCreateSubmissionSection backs POST /api/submission-drafts/{draft_id}/sections.
// Adds a new (custom) section to the draft. Owner-scoped via
// SubmissionDraftService.Get.
func handleCreateSubmissionSection(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
}
ctx, cancel := context.WithTimeout(r.Context(), submissionSectionPatchTimeout)
defer cancel()
if _, err := dbSvc.submissionDraft.Get(ctx, uid, draftID); err != nil {
writeSubmissionDraftServiceError(w, err)
return
}
var input submissionSectionCreateInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
created, err := dbSvc.submissionSection.Create(ctx, services.SectionCreateInput{
DraftID: draftID,
SectionKey: input.SectionKey,
Kind: input.Kind,
LabelDE: input.LabelDE,
LabelEN: input.LabelEN,
ContentMDDE: input.ContentMDDE,
ContentMDEN: input.ContentMDEN,
OrderIndex: input.OrderIndex,
Included: true,
})
if err != nil {
if errors.Is(err, services.ErrInvalidInput) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, sectionJSONFromService(created))
}
// handleDeleteSubmissionSection backs DELETE /api/submission-drafts/{draft_id}/sections/{section_id}.
// Owner-scoped via SubmissionDraftService.Get + section-belongs-to-draft cross-check.
func handleDeleteSubmissionSection(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()
draft, err := dbSvc.submissionDraft.Get(ctx, uid, draftID)
if err != nil {
writeSubmissionDraftServiceError(w, err)
return
}
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 sec.DraftID != draft.ID {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
return
}
if err := dbSvc.submissionSection.Delete(ctx, sectionID); 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.StatusNoContent, nil)
}
type submissionSectionReorderInput struct {
SectionOrder []string `json:"section_order"`
}
// handleReorderSubmissionSections backs POST /api/submission-drafts/{draft_id}/sections/reorder.
// Accepts a sequence of section_ids; rewrites every row's order_index
// to (1, 2, 3, …) × 10 in the supplied order. Returns the refreshed
// section list.
func handleReorderSubmissionSections(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
}
ctx, cancel := context.WithTimeout(r.Context(), submissionSectionPatchTimeout)
defer cancel()
if _, err := dbSvc.submissionDraft.Get(ctx, uid, draftID); err != nil {
writeSubmissionDraftServiceError(w, err)
return
}
var input submissionSectionReorderInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
order := make([]uuid.UUID, 0, len(input.SectionOrder))
for _, raw := range input.SectionOrder {
id, err := uuid.Parse(raw)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid section id in order list"})
return
}
order = append(order, id)
}
rows, err := dbSvc.submissionSection.Reorder(ctx, draftID, order)
if err != nil {
writeServiceError(w, err)
return
}
out := make([]submissionSectionJSON, 0, len(rows))
for _, sec := range rows {
out = append(out, sectionJSONFromService(&sec))
}
writeJSON(w, http.StatusOK, map[string]any{"sections": out})
}
// sectionJSONFromService projects a services.SubmissionSection into the
// JSON shape the editor consumes — the same shape buildSubmissionDraftView
// emits under .sections[].

View File

@@ -264,7 +264,14 @@ type Deadline struct {
OriginalDueDate *time.Time `db:"original_due_date" json:"original_due_date,omitempty"`
WarningDate *time.Time `db:"warning_date" json:"warning_date,omitempty"`
Source string `db:"source" json:"source"`
RuleID *uuid.UUID `db:"rule_id" json:"rule_id,omitempty"`
// Slice B.4 (mig 140, t-paliad-305): paliad.deadlines.rule_id column
// dropped; the back-link now lives on `sequencing_rule_id` (FK to
// paliad.sequencing_rules). Same UUID values (sequencing_rules.id
// inherited deadline_rules.id during mig 136 backfill), so internal
// Go references to `RuleID` continue to carry the same semantic
// pointer. The JSON name stays `rule_id` for frontend backward-compat
// — B.5 will rename if/when frontend is updated.
RuleID *uuid.UUID `db:"sequencing_rule_id" json:"rule_id,omitempty"`
// RuleCode is the legal citation ("RoP.023", "R.151") attached at
// save time — see migration 032. Free text by design; survives
// changes to paliad.deadline_rules and accepts citations from
@@ -546,6 +553,51 @@ type Party struct {
// scans, hydration, projection service) continues to compile.
type DeadlineRule = litigationplanner.Rule
// SequencingRule is the Slice B.5 (t-paliad-305) canonical name for what
// the legacy schema called a "deadline rule". Alias to DeadlineRule so
// existing call-sites compile unchanged while new code can adopt the
// procedural-event vocabulary. Same struct, same db / json tags.
type SequencingRule = DeadlineRule
// ProceduralEvent mirrors paliad.procedural_events — the "what kind of
// step is this in the proceeding" identity row. New struct introduced
// in Slice B.5 (t-paliad-305) for code that needs the procedural-event
// columns alone. Most consumers still pull the merged shape via
// SequencingRule through the paliad.deadline_rules_unified view; this
// struct unlocks per-PE reads/writes without going through the view.
type ProceduralEvent struct {
ID uuid.UUID `db:"id" json:"id"`
Code string `db:"code" json:"code"`
Name string `db:"name" json:"name"`
NameEN string `db:"name_en" json:"name_en"`
Description *string `db:"description" json:"description,omitempty"`
EventKind *string `db:"event_kind" json:"event_kind,omitempty"`
PrimaryPartyDefault *string `db:"primary_party_default" json:"primary_party_default,omitempty"`
LegalSourceID *uuid.UUID `db:"legal_source_id" json:"legal_source_id,omitempty"`
ConceptID *uuid.UUID `db:"concept_id" json:"concept_id,omitempty"`
LifecycleState string `db:"lifecycle_state" json:"lifecycle_state"`
DraftOf *uuid.UUID `db:"draft_of" json:"draft_of,omitempty"`
PublishedAt *time.Time `db:"published_at" json:"published_at,omitempty"`
IsActive bool `db:"is_active" json:"is_active"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// LegalSource mirrors paliad.legal_sources — the source-of-law citation
// rows that procedural events anchor against. pretty_de / pretty_en are
// nullable on disk; readers fall back to
// internal/services/submission_vars.go:legalSourcePretty when missing.
type LegalSource struct {
ID uuid.UUID `db:"id" json:"id"`
Citation string `db:"citation" json:"citation"`
Jurisdiction string `db:"jurisdiction" json:"jurisdiction"`
PrettyDE *string `db:"pretty_de" json:"pretty_de,omitempty"`
PrettyEN *string `db:"pretty_en" json:"pretty_en,omitempty"`
Notes *string `db:"notes" json:"notes,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// DeadlineRuleAudit is one row of paliad.deadline_rule_audit — the
// append-only audit log for every change to paliad.deadline_rules.
// Written by the AFTER-trigger (raw create / update / delete) and by

View File

@@ -10,12 +10,24 @@ import (
"mgit.msbls.de/m/paliad/internal/models"
)
// DeadlineRuleService reads paliad.deadline_rules + paliad.proceeding_types.
// Rules are static reference data; no visibility check needed.
// DeadlineRuleService reads paliad.deadline_rules_unified (mig 139 view
// projecting paliad.sequencing_rules + procedural_events +
// legal_sources back to the legacy column shape after mig 140 dropped
// the underlying table) + paliad.proceeding_types. Rules are static
// reference data; no visibility check needed.
type DeadlineRuleService struct {
db *sqlx.DB
}
// SequencingRuleService is the Slice B.5 (t-paliad-305) canonical name
// for DeadlineRuleService. Alias preserves every existing call-site
// while new code can adopt the procedural-event vocabulary.
type SequencingRuleService = DeadlineRuleService
// NewSequencingRuleService is the canonical constructor name; alias to
// NewDeadlineRuleService for now. Both return the same underlying type.
var NewSequencingRuleService = NewDeadlineRuleService
// NewDeadlineRuleService wires the service to the pool.
func NewDeadlineRuleService(db *sqlx.DB) *DeadlineRuleService {
return &DeadlineRuleService{db: db}

View File

@@ -65,8 +65,13 @@ func (s *DeadlineService) pendingApprovalErr(ctx context.Context, deadlineID uui
return NewPendingApprovalError(rid, role)
}
// Slice B.4 (mig 140, t-paliad-305): rule_id column dropped from
// paliad.deadlines. sequencing_rule_id holds the same UUID and is the
// FK to paliad.sequencing_rules. SELECT-column lists below pull
// sequencing_rule_id into the Deadline.RuleID field (db tag adjusted in
// internal/models/models.go).
const deadlineColumns = `id, project_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, rule_code, custom_rule_text, status, completed_at, caldav_uid, caldav_etag,
warning_date, source, sequencing_rule_id, rule_code, custom_rule_text, status, completed_at, caldav_uid, caldav_etag,
notes, created_by, created_at, updated_at,
approval_status, pending_request_id, approved_by, approved_at`
@@ -272,7 +277,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_unified r ON r.id = f.rule_id
LEFT JOIN paliad.deadline_rules_unified r ON r.id = f.sequencing_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`
@@ -539,7 +544,11 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
if input.RuleID != nil && input.CustomRuleText != nil {
return nil, fmt.Errorf("%w: rule_id and custom_rule_text are mutually exclusive", ErrInvalidInput)
}
appendSet("rule_id", input.RuleID)
// Slice B.4 (t-paliad-305): rule_id column dropped; the FK
// back-link now lives on sequencing_rule_id. Same UUID value.
// The procedural_event_id mirror is derived in
// syncDeadlineDualLinks below after the primary UPDATE lands.
appendSet("sequencing_rule_id", input.RuleID)
var customText *string
if input.CustomRuleText != nil {
trimmed := strings.TrimSpace(*input.CustomRuleText)
@@ -585,13 +594,13 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
return nil, fmt.Errorf("update deadline: %w", err)
}
// Slice B.2 dual-write (t-paliad-305): if rule_id was in the
// patch (auto/custom swap from t-paliad-258), the parallel
// procedural_event_id + sequencing_rule_id columns must follow.
// Call unconditionally — it's a single UPDATE keyed on
// deadlineID and a no-op when rule_id is unchanged.
// Slice B.4 (mig 140, t-paliad-305): rule_id column gone;
// sequencing_rule_id holds the back-link. When the patch updated
// it (auto/custom swap from t-paliad-258), mirror the FK onto
// procedural_event_id so the joined view continues to resolve.
// Idempotent: no-op when sequencing_rule_id is unchanged.
if input.RuleSet {
if err := syncDeadlineDualLinks(ctx, tx, deadlineID); err != nil {
if err := syncDeadlineProceduralEventID(ctx, tx, deadlineID); err != nil {
return nil, err
}
}

View File

@@ -1,392 +1,50 @@
// Slice B.2 dual-write (t-paliad-305 / m/paliad#93) — keep paliad's
// new tables (procedural_events / sequencing_rules / legal_sources) in
// lock-step with the legacy paliad.deadline_rules table during the
// dual-write window. Mig 136 (Slice B.1) created the new tables and
// backfilled them once. This file keeps them in sync going forward.
// Slice B.4 retirement of B.2 dual-write (t-paliad-305 / m/paliad#93).
//
// Contract:
// Mig 140 dropped paliad.deadline_rules and installed INSTEAD OF
// triggers on paliad.deadline_rules_unified that route writes to
// procedural_events + sequencing_rules + legal_sources. The legacy
// dual-write helper (syncDualWriteFromDeadlineRule) and the drift-check
// loop (CheckDualWriteDrift / StartDualWriteDriftCheckLoop) reference
// paliad.deadline_rules, which no longer exists — they would crash on
// first call if kept.
//
// - Every RuleEditorService method that mutates paliad.deadline_rules
// calls syncDualWriteFromDeadlineRule(ctx, tx, id) inside the same
// transaction, AFTER the deadline_rules write, BEFORE tx.Commit.
// - The sync is idempotent (INSERT … ON CONFLICT … DO UPDATE) so the
// same call works for Create (new row), UpdateDraft (existing row),
// CloneAsDraft (new row referencing an old row), Publish (lifecycle
// flip), Archive/Restore (lifecycle flip), and the published-peer
// archive that Publish performs as a cascade.
// - The sync re-derives the new-table state from paliad.deadline_rules
// in pure SQL — no struct mapping in Go. The legacy table stays the
// source of truth during B.2 (B.3 flips reads, B.4 drops it).
// - Read paths still read deadline_rules in B.2. The new tables are a
// parallel projection kept consistent for B.3's read cutover; they
// are not yet authoritative.
// Survivor: syncDeadlineProceduralEventID — keeps paliad.deadlines's
// new procedural_event_id column in sync with sequencing_rule_id after
// any UPDATE that touched the latter. Still useful as a "derive from
// canonical pointer" helper.
//
// Why a per-row sync instead of a global trigger:
//
// - The deadline_rules audit trigger (mig 079) reads paliad.audit_reason
// to record the rationale on every change. Putting the new-table
// write in the same TX preserves that auditability — set_config is
// transactional and the new writes share the same reason.
// - A Postgres-side AFTER UPDATE trigger on deadline_rules would also
// work but it's harder to test in isolation and harder to revert
// when B.4 drops the source table. A Go-side sync is reversible
// with a code revert; an SQL trigger needs a follow-up migration.
//
// The drift-check job (CheckDualWriteDrift below) runs daily and
// alerts on mismatches. If the sync ever silently misses a row, the
// drift check surfaces it inside one day.
//
// See docs/design-procedural-events-model-2026-05-25.md §5.2 (dual-write
// phase) and docs/design-procedural-events-b0-findings-2026-05-26.md §7.
// The DualWriteDriftReport struct + HasDrift method are retired with
// the loop they served.
package services
import (
"context"
"fmt"
"log"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// syncDualWriteFromDeadlineRule re-projects the deadline_rules row with
// the given id into legal_sources + procedural_events + sequencing_rules.
// Runs three UPSERT statements in the open transaction.
// syncDeadlineProceduralEventID mirrors paliad.deadlines.sequencing_rule_id
// onto procedural_event_id. Call this within an open transaction AFTER
// any UPDATE that mutates paliad.deadlines.sequencing_rule_id (today's
// callers: DeadlineService.Update on the RuleSet branch, and the
// RuleEditorService orphan-resolve path which sets both columns in one
// statement so doesn't need this helper).
//
// Synthetic-code rule (for rows where deadline_rules.submission_code is
// NULL) mirrors mig 136's backfill: 'null.' || first 8 hex chars of the
// uuid (dashes stripped). This must stay byte-identical to the mig 136
// expression or the lookup join inside the sequencing_rules UPSERT
// misses.
func syncDualWriteFromDeadlineRule(ctx context.Context, tx *sqlx.Tx, id uuid.UUID) error {
// 1. legal_sources — UPSERT the citation (no-op if already present).
// jurisdiction is parsed from the first dot-separated segment;
// 'other' on empty (paranoid fallback, no live rows hit it).
if _, err := tx.ExecContext(ctx, `
INSERT INTO paliad.legal_sources (citation, jurisdiction)
SELECT dr.legal_source,
COALESCE(NULLIF(split_part(dr.legal_source, '.', 1), ''), 'other')
FROM paliad.deadline_rules dr
WHERE dr.id = $1 AND dr.legal_source IS NOT NULL
ON CONFLICT (citation) DO NOTHING`, id); err != nil {
return fmt.Errorf("dual-write legal_sources for rule %s: %w", id, err)
}
// 2. procedural_events — UPSERT keyed by code. The code is the
// submission_code if present, else the synthetic 'null.<8hex>'
// minted from the deadline_rules row's id (matches mig 136).
// legal_source_id is resolved by JOIN on legal_sources.citation
// (NULL when the rule has no legal_source).
if _, err := tx.ExecContext(ctx, `
INSERT INTO paliad.procedural_events
(code, name, name_en, description, event_kind,
primary_party_default, legal_source_id, concept_id,
lifecycle_state, published_at, is_active)
SELECT
COALESCE(dr.submission_code,
'null.' || substring(replace(dr.id::text, '-', ''), 1, 8)),
dr.name, dr.name_en, dr.description, dr.event_type,
dr.primary_party, ls.id, dr.concept_id,
dr.lifecycle_state, dr.published_at, dr.is_active
FROM paliad.deadline_rules dr
LEFT JOIN paliad.legal_sources ls ON ls.citation = dr.legal_source
WHERE dr.id = $1
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
name_en = EXCLUDED.name_en,
description = EXCLUDED.description,
event_kind = EXCLUDED.event_kind,
primary_party_default = EXCLUDED.primary_party_default,
legal_source_id = EXCLUDED.legal_source_id,
concept_id = EXCLUDED.concept_id,
lifecycle_state = EXCLUDED.lifecycle_state,
published_at = EXCLUDED.published_at,
is_active = EXCLUDED.is_active,
updated_at = now()`, id); err != nil {
return fmt.Errorf("dual-write procedural_events for rule %s: %w", id, err)
}
// 3. sequencing_rules — UPSERT keyed by id (1:1 inheritance from
// deadline_rules.id). procedural_event_id resolved by JOIN on
// the (real or synthetic) code. All hat-3 mechanics columns copy
// 1:1 from the deadline_rules row's post-write state.
if _, err := tx.ExecContext(ctx, `
INSERT INTO paliad.sequencing_rules
(id, procedural_event_id, proceeding_type_id, parent_id, trigger_event_id,
duration_value, duration_unit, timing,
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt,
combine_op, condition_expr, primary_party, sequence_order,
is_spawn, spawn_label, spawn_proceeding_type_id,
is_bilateral, is_court_set, priority,
rule_code, rule_codes, deadline_notes, deadline_notes_en,
choices_offered, applies_to_target,
lifecycle_state, draft_of, published_at, is_active,
created_at, updated_at)
SELECT
dr.id, pe.id,
dr.proceeding_type_id, dr.parent_id, dr.trigger_event_id,
dr.duration_value, dr.duration_unit, dr.timing,
dr.alt_duration_value, dr.alt_duration_unit, dr.alt_rule_code, dr.anchor_alt,
dr.combine_op, dr.condition_expr, dr.primary_party, dr.sequence_order,
dr.is_spawn, dr.spawn_label, dr.spawn_proceeding_type_id,
dr.is_bilateral, dr.is_court_set, dr.priority,
dr.rule_code, dr.rule_codes, dr.deadline_notes, dr.deadline_notes_en,
dr.choices_offered, dr.applies_to_target,
dr.lifecycle_state, dr.draft_of, dr.published_at, dr.is_active,
dr.created_at, dr.updated_at
FROM paliad.deadline_rules dr
JOIN paliad.procedural_events pe
ON pe.code = COALESCE(dr.submission_code,
'null.' || substring(replace(dr.id::text, '-', ''), 1, 8))
WHERE dr.id = $1
ON CONFLICT (id) DO UPDATE SET
procedural_event_id = EXCLUDED.procedural_event_id,
proceeding_type_id = EXCLUDED.proceeding_type_id,
parent_id = EXCLUDED.parent_id,
trigger_event_id = EXCLUDED.trigger_event_id,
duration_value = EXCLUDED.duration_value,
duration_unit = EXCLUDED.duration_unit,
timing = EXCLUDED.timing,
alt_duration_value = EXCLUDED.alt_duration_value,
alt_duration_unit = EXCLUDED.alt_duration_unit,
alt_rule_code = EXCLUDED.alt_rule_code,
anchor_alt = EXCLUDED.anchor_alt,
combine_op = EXCLUDED.combine_op,
condition_expr = EXCLUDED.condition_expr,
primary_party = EXCLUDED.primary_party,
sequence_order = EXCLUDED.sequence_order,
is_spawn = EXCLUDED.is_spawn,
spawn_label = EXCLUDED.spawn_label,
spawn_proceeding_type_id = EXCLUDED.spawn_proceeding_type_id,
is_bilateral = EXCLUDED.is_bilateral,
is_court_set = EXCLUDED.is_court_set,
priority = EXCLUDED.priority,
rule_code = EXCLUDED.rule_code,
rule_codes = EXCLUDED.rule_codes,
deadline_notes = EXCLUDED.deadline_notes,
deadline_notes_en = EXCLUDED.deadline_notes_en,
choices_offered = EXCLUDED.choices_offered,
applies_to_target = EXCLUDED.applies_to_target,
lifecycle_state = EXCLUDED.lifecycle_state,
draft_of = EXCLUDED.draft_of,
published_at = EXCLUDED.published_at,
is_active = EXCLUDED.is_active,
updated_at = now()`, id); err != nil {
return fmt.Errorf("dual-write sequencing_rules for rule %s: %w", id, err)
}
return nil
}
// syncDeadlineDualLinks mirrors a deadline's legacy rule_id back-link
// onto the new procedural_event_id + sequencing_rule_id columns added
// by mig 136. Call this within an open transaction AFTER any UPDATE
// that mutates paliad.deadlines.rule_id (mig 122 introduced rule_id
// as the deadline→rule FK; today's writers are DeadlineService.Update
// and RuleEditorService.ResolveOrphan).
//
// Idempotent: NULL rule_id collapses both new columns to NULL by virtue
// of the subquery returning NULL. Slice B.2 (t-paliad-305).
func syncDeadlineDualLinks(ctx context.Context, tx *sqlx.Tx, deadlineID uuid.UUID) error {
// Idempotent: NULL sequencing_rule_id collapses procedural_event_id to
// NULL via the subquery returning NULL. Slice B.4 (t-paliad-305).
func syncDeadlineProceduralEventID(ctx context.Context, tx *sqlx.Tx, deadlineID uuid.UUID) error {
if _, err := tx.ExecContext(ctx, `
UPDATE paliad.deadlines d
SET sequencing_rule_id = d.rule_id,
procedural_event_id = (
SET procedural_event_id = (
SELECT sr.procedural_event_id
FROM paliad.sequencing_rules sr
WHERE sr.id = d.rule_id
WHERE sr.id = d.sequencing_rule_id
)
WHERE d.id = $1`, deadlineID); err != nil {
return fmt.Errorf("sync deadline dual-links for %s: %w", deadlineID, err)
return fmt.Errorf("sync deadline procedural_event_id for %s: %w", deadlineID, err)
}
return nil
}
// DualWriteDriftReport summarises the comparison between the legacy
// paliad.deadline_rules table and the new procedural_events /
// sequencing_rules tables that B.2's dual-write is meant to keep in
// sync. A zero-drift report (every count delta zero, every join clean)
// is the steady state during the dual-write window; any non-zero field
// is the signal that a write path either bypassed
// syncDualWriteFromDeadlineRule or that an out-of-band mutation
// happened (e.g. raw SQL run by an operator).
type DualWriteDriftReport struct {
// Counts on the legacy and the projected side.
DeadlineRules int `json:"deadline_rules"`
SequencingRules int `json:"sequencing_rules"`
ProceduralEvents int `json:"procedural_events"`
LegalSources int `json:"legal_sources"`
// Expected (from the legacy side) vs observed (on the new side).
ExpectedPE int `json:"expected_procedural_events"`
ExpectedLegalSources int `json:"expected_legal_sources"`
// MissingSR — deadline_rules rows with no sequencing_rules row by id.
// OrphanedSR — sequencing_rules rows whose id doesn't exist in
// deadline_rules anymore (would only happen with a deletion path
// that bypasses dual-write).
MissingSR int `json:"missing_sequencing_rules"`
OrphanedSR int `json:"orphaned_sequencing_rules"`
// MismatchedLifecycle — rows where deadline_rules.lifecycle_state
// disagrees with sequencing_rules.lifecycle_state. Should always be
// zero during dual-write.
MismatchedLifecycle int `json:"mismatched_lifecycle"`
// MismatchedActive — same shape, for is_active.
MismatchedActive int `json:"mismatched_active"`
}
// HasDrift returns true if any field signals divergence between the
// legacy and projected sides. Used by the drift-check ticker to decide
// whether to log at WARN (drift) or INFO (clean).
func (r DualWriteDriftReport) HasDrift() bool {
if r.SequencingRules != r.DeadlineRules {
return true
}
if r.ProceduralEvents != r.ExpectedPE {
return true
}
if r.LegalSources != r.ExpectedLegalSources {
return true
}
if r.MissingSR != 0 || r.OrphanedSR != 0 {
return true
}
if r.MismatchedLifecycle != 0 || r.MismatchedActive != 0 {
return true
}
return false
}
// CheckDualWriteDrift compares the legacy paliad.deadline_rules table
// against the parallel new tables maintained by Slice B.2's dual-write.
// Returns a DualWriteDriftReport — caller decides what to do with
// non-zero drift (log, page, fail healthcheck, etc.).
//
// Read-only. Safe to run against prod. Single query per metric so the
// pool isn't held for a long time. No locks; tolerates concurrent
// writes (counts may shift by one or two during the read, but a
// persistent drift > 0 is the alarm signal).
func CheckDualWriteDrift(ctx context.Context, conn *sqlx.DB) (*DualWriteDriftReport, error) {
var r DualWriteDriftReport
q := func(label, sql string, dst *int) error {
if err := conn.GetContext(ctx, dst, sql); err != nil {
return fmt.Errorf("drift-check %s: %w", label, err)
}
return nil
}
if err := q("dr_total", `SELECT COUNT(*) FROM paliad.deadline_rules`, &r.DeadlineRules); err != nil {
return nil, err
}
if err := q("sr_total", `SELECT COUNT(*) FROM paliad.sequencing_rules`, &r.SequencingRules); err != nil {
return nil, err
}
if err := q("pe_total", `SELECT COUNT(*) FROM paliad.procedural_events`, &r.ProceduralEvents); err != nil {
return nil, err
}
if err := q("ls_total", `SELECT COUNT(*) FROM paliad.legal_sources`, &r.LegalSources); err != nil {
return nil, err
}
if err := q("expected_pe", `
SELECT
(SELECT COUNT(DISTINCT submission_code) FROM paliad.deadline_rules WHERE submission_code IS NOT NULL)
+
(SELECT COUNT(*) FROM paliad.deadline_rules WHERE submission_code IS NULL)
`, &r.ExpectedPE); err != nil {
return nil, err
}
if err := q("expected_ls",
`SELECT COUNT(DISTINCT legal_source) FROM paliad.deadline_rules WHERE legal_source IS NOT NULL`,
&r.ExpectedLegalSources); err != nil {
return nil, err
}
if err := q("missing_sr", `
SELECT COUNT(*) FROM paliad.deadline_rules dr
LEFT JOIN paliad.sequencing_rules sr ON sr.id = dr.id
WHERE sr.id IS NULL`, &r.MissingSR); err != nil {
return nil, err
}
if err := q("orphaned_sr", `
SELECT COUNT(*) FROM paliad.sequencing_rules sr
LEFT JOIN paliad.deadline_rules dr ON dr.id = sr.id
WHERE dr.id IS NULL`, &r.OrphanedSR); err != nil {
return nil, err
}
if err := q("mismatched_lifecycle", `
SELECT COUNT(*) FROM paliad.deadline_rules dr
JOIN paliad.sequencing_rules sr ON sr.id = dr.id
WHERE dr.lifecycle_state <> sr.lifecycle_state`, &r.MismatchedLifecycle); err != nil {
return nil, err
}
if err := q("mismatched_active", `
SELECT COUNT(*) FROM paliad.deadline_rules dr
JOIN paliad.sequencing_rules sr ON sr.id = dr.id
WHERE dr.is_active <> sr.is_active`, &r.MismatchedActive); err != nil {
return nil, err
}
return &r, nil
}
// StartDualWriteDriftCheckLoop runs CheckDualWriteDrift on a fixed
// interval for the lifetime of ctx. A clean run logs at INFO level;
// drift logs at WARN level with the full report payload. The first
// check fires after `interval`, not immediately on Start — by the time
// the ticker first fires the process has finished booting and the
// initial backfill + dual-write writes have settled.
//
// Slice B.2 (t-paliad-305). interval should be short enough to surface
// drift before the next deploy (so a broken dual-write doesn't sit
// silent for a week) and long enough to avoid noise (the check holds
// no locks but it does run nine SELECT COUNTs).
//
// Recommended interval: 6h. Override via the caller (cmd/server picks
// the runtime value).
func StartDualWriteDriftCheckLoop(ctx context.Context, conn *sqlx.DB, interval time.Duration) {
if interval <= 0 {
interval = 6 * time.Hour
}
go func() {
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
report, err := CheckDualWriteDrift(ctx, conn)
if err != nil {
log.Printf("dual-write drift-check: error: %v", err)
continue
}
if report.HasDrift() {
log.Printf("dual-write drift-check: DRIFT DETECTED — "+
"deadline_rules=%d sequencing_rules=%d "+
"procedural_events=%d (expected %d) "+
"legal_sources=%d (expected %d) "+
"missing_sr=%d orphaned_sr=%d "+
"mismatched_lifecycle=%d mismatched_active=%d",
report.DeadlineRules, report.SequencingRules,
report.ProceduralEvents, report.ExpectedPE,
report.LegalSources, report.ExpectedLegalSources,
report.MissingSR, report.OrphanedSR,
report.MismatchedLifecycle, report.MismatchedActive)
} else {
log.Printf("dual-write drift-check: OK — "+
"deadline_rules=%d sequencing_rules=%d "+
"procedural_events=%d legal_sources=%d",
report.DeadlineRules, report.SequencingRules,
report.ProceduralEvents, report.LegalSources)
}
}
}
}()
}

View File

@@ -202,14 +202,11 @@ func TestDualWrite_RuleEditorLifecycle(t *testing.T) {
t.Errorf("sequencing_rules.lifecycle_state after Archive: got %q, want %q", srLifecycleArchived, "archived")
}
// 5. Drift check should return zero drift right after the dance.
report, err := CheckDualWriteDrift(ctx, pool)
if err != nil {
t.Fatalf("CheckDualWriteDrift: %v", err)
}
if report.HasDrift() {
t.Errorf("CheckDualWriteDrift unexpectedly flagged drift: %+v", report)
}
// Slice B.4 (mig 140, t-paliad-305): the legacy paliad.deadline_rules
// table is gone and so is CheckDualWriteDrift — there's no parallel
// side to compare against. The INSTEAD OF triggers on the view
// guarantee parity by construction (single TX fan-out from one
// SQL write to three target tables).
}
// TestDualWrite_SyntheticCodeForNullSubmission asserts that a rule

View File

@@ -1805,7 +1805,7 @@ func (s *ProjectionService) parentHasAnchoredActual(ctx context.Context, project
err := s.db.GetContext(ctx, &count, `
SELECT COUNT(*) FROM (
SELECT 1 FROM paliad.deadlines
WHERE project_id = $1 AND rule_id = $2
WHERE project_id = $1 AND sequencing_rule_id = $2
AND (completed_at IS NOT NULL
OR status = 'completed'
OR source = 'anchor')
@@ -1843,7 +1843,7 @@ func (s *ProjectionService) upsertAnchorDeadline(ctx context.Context, userID, pr
var existingID uuid.UUID
err := s.db.GetContext(ctx, &existingID,
`SELECT id FROM paliad.deadlines
WHERE project_id = $1 AND rule_id = $2
WHERE project_id = $1 AND sequencing_rule_id = $2
ORDER BY created_at ASC
LIMIT 1`, projectID, rule.ID)
switch {

View File

@@ -212,20 +212,21 @@ func (s *RuleEditorService) ResolveOrphan(ctx context.Context, orphanID uuid.UUI
}
now := time.Now().UTC()
// Slice B.4 (mig 140, t-paliad-305): paliad.deadlines.rule_id column
// dropped. Back-link lives on sequencing_rule_id (same UUIDs as
// before — sr.id inherited dr.id at mig 136 backfill).
// procedural_event_id is derived from the same sequencing_rules row.
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.deadlines
SET rule_id = $1,
updated_at = $2
WHERE id = $3`,
`UPDATE paliad.deadlines d
SET sequencing_rule_id = $1,
procedural_event_id = (SELECT procedural_event_id
FROM paliad.sequencing_rules
WHERE id = $1),
updated_at = $2
WHERE d.id = $3`,
ruleID, now, oc.DeadlineID,
); err != nil {
return fmt.Errorf("set deadline rule_id: %w", err)
}
// Slice B.2 dual-write (t-paliad-305): mirror the new linkage onto
// the parallel deadlines.procedural_event_id + sequencing_rule_id
// columns so they don't drift from rule_id.
if err := syncDeadlineDualLinks(ctx, tx, oc.DeadlineID); err != nil {
return err
return fmt.Errorf("set deadline sequencing_rule_id: %w", err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.deadline_rule_backfill_orphans

View File

@@ -11,6 +11,7 @@ import (
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/models"
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
@@ -76,7 +77,13 @@ type RulePatch struct {
NameEN *string `json:"name_en,omitempty"`
Description *string `json:"description,omitempty"`
PrimaryParty *string `json:"primary_party,omitempty"`
// EventType is the legacy JSON key; EventKind is the Slice B.5
// canonical name. Decoder accepts either — coalescePatchKeys()
// resolves the canonical to the legacy field if only EventKind
// was sent. Same uuid wire shape; emit-side wraps via
// adminRuleResponse to expose both keys for one slice.
EventType *string `json:"event_type,omitempty"`
EventKind *string `json:"event_kind,omitempty"`
DurationValue *int `json:"duration_value,omitempty"`
DurationUnit *string `json:"duration_unit,omitempty"`
Timing *string `json:"timing,omitempty"`
@@ -101,6 +108,24 @@ type RulePatch struct {
ConceptID *uuid.UUID `json:"concept_id,omitempty"`
}
// CoalesceCanonicalKeys folds the Slice B.5 (t-paliad-305) canonical
// JSON aliases into the legacy field positions so the rest of the
// service can keep using the existing field names. Canonical wins
// when both are sent.
//
// json:"event_kind" → EventType (legacy)
//
// Called by the handler immediately after json.Decode. New code can
// adopt the canonical naming; legacy callers continue to work.
func (p *RulePatch) CoalesceCanonicalKeys() {
if p == nil {
return
}
if p.EventKind != nil {
p.EventType = p.EventKind
}
}
// CreateRuleInput is the create payload — a full rule row in draft
// state. Required fields enforce schema NOT-NULL on insert (name,
// name_en, duration_value, duration_unit).
@@ -111,9 +136,16 @@ type CreateRuleInput struct {
TriggerEventID *int64 `json:"trigger_event_id,omitempty"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
ConceptID *uuid.UUID `json:"concept_id,omitempty"`
// SubmissionCode is the legacy JSON key; Code is the Slice B.5
// canonical name. Decoder accepts either — CoalesceCanonicalKeys()
// folds Code → SubmissionCode if only the canonical was sent.
SubmissionCode *string `json:"submission_code,omitempty"`
Code *string `json:"code,omitempty"`
PrimaryParty *string `json:"primary_party,omitempty"`
// EventType is the legacy JSON key; EventKind is the Slice B.5
// canonical name. Same dual-accept pattern as SubmissionCode/Code.
EventType *string `json:"event_type,omitempty"`
EventKind *string `json:"event_kind,omitempty"`
DurationValue int `json:"duration_value"`
DurationUnit string `json:"duration_unit"`
Timing *string `json:"timing,omitempty"`
@@ -135,6 +167,24 @@ type CreateRuleInput struct {
SequenceOrder int `json:"sequence_order"`
}
// CoalesceCanonicalKeys folds the Slice B.5 (t-paliad-305) canonical
// JSON aliases into the legacy field positions. Canonical wins when
// both are sent. Called by the handler immediately after json.Decode.
//
// json:"code" → SubmissionCode (legacy)
// json:"event_kind" → EventType (legacy)
func (in *CreateRuleInput) CoalesceCanonicalKeys() {
if in == nil {
return
}
if in.Code != nil {
in.SubmissionCode = in.Code
}
if in.EventKind != nil {
in.EventType = in.EventKind
}
}
// Create inserts a new rule as lifecycle_state='draft' with
// published_at=NULL. The caller's reason is set on the session BEFORE
// the INSERT so the mig 079 trigger writes an audit row with the
@@ -178,7 +228,7 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r
// here writes the live shape only — priority + condition_expr
// + is_court_set are the new gates.
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.deadline_rules
`INSERT INTO paliad.deadline_rules_unified
(id, proceeding_type_id, trigger_event_id, parent_id, concept_id, submission_code,
name, name_en, description, primary_party, event_type,
duration_value, duration_unit, timing,
@@ -209,13 +259,11 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r
return nil, fmt.Errorf("insert rule: %w", err)
}
// Slice B.2 dual-write (t-paliad-305): project the new row into
// legal_sources / procedural_events / sequencing_rules in the same
// transaction so the parallel tables stay in lock-step with
// deadline_rules through the B.3 read-cutover window.
if err := syncDualWriteFromDeadlineRule(ctx, tx, id); err != nil {
return nil, err
}
// Slice B.4 (mig 140, t-paliad-305): write routes through the
// INSTEAD OF triggers on paliad.deadline_rules_unified, which fan
// out into legal_sources + procedural_events + sequencing_rules.
// No Go-side mirror call needed — the INSERT above already landed
// the parallel rows.
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit create: %w", err)
@@ -279,15 +327,13 @@ func (s *RuleEditorService) UpdateDraft(ctx context.Context, id uuid.UUID, patch
args = append(args, time.Now().UTC())
args = append(args, id)
q := fmt.Sprintf(
`UPDATE paliad.deadline_rules SET %s WHERE id = $%d AND lifecycle_state = 'draft'`,
`UPDATE paliad.deadline_rules_unified SET %s WHERE id = $%d AND lifecycle_state = 'draft'`,
strings.Join(sets, ", "), len(args))
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
return nil, fmt.Errorf("update rule draft: %w", err)
}
// Slice B.2 dual-write (t-paliad-305).
if err := syncDualWriteFromDeadlineRule(ctx, tx, id); err != nil {
return nil, err
}
// Slice B.4 (mig 140, t-paliad-305): INSTEAD OF trigger handles the
// new-table writes — the UPDATE above is already fan-out.
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit update: %w", err)
}
@@ -321,7 +367,7 @@ func (s *RuleEditorService) CloneAsDraft(ctx context.Context, id uuid.UUID, reas
newID := uuid.New()
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.deadline_rules
`INSERT INTO paliad.deadline_rules_unified
(id, proceeding_type_id, trigger_event_id, parent_id, concept_id, submission_code,
name, name_en, description, primary_party, event_type,
duration_value, duration_unit, timing,
@@ -342,20 +388,16 @@ func (s *RuleEditorService) CloneAsDraft(ctx context.Context, id uuid.UUID, reas
is_active,
'draft', $2, NULL,
now(), now()
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE id = $2`,
newID, id,
); err != nil {
return nil, fmt.Errorf("clone rule as draft: %w", err)
}
// Slice B.2 dual-write (t-paliad-305): new draft gets its own
// procedural_events + sequencing_rules row. The synthetic-code
// branch fires here when the source rule had NULL submission_code
// (the clone inherits the NULL and mints a fresh 'null.<8hex>'
// derived from newID).
if err := syncDualWriteFromDeadlineRule(ctx, tx, newID); err != nil {
return nil, err
}
// Slice B.4 (mig 140, t-paliad-305): INSTEAD OF INSERT trigger
// mints the synthetic 'null.<8hex>' code when submission_code is
// NULL (matching mig 136 + the legacy dual-write helper's
// expression).
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit clone: %w", err)
}
@@ -389,7 +431,7 @@ func (s *RuleEditorService) Publish(ctx context.Context, id uuid.UUID, reason st
now := time.Now().UTC()
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.deadline_rules
`UPDATE paliad.deadline_rules_unified
SET lifecycle_state = 'published',
published_at = $1,
updated_at = $1
@@ -402,7 +444,7 @@ func (s *RuleEditorService) Publish(ctx context.Context, id uuid.UUID, reason st
// Archive the peer this draft was cloned from, if any.
if current.DraftOf != nil {
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.deadline_rules
`UPDATE paliad.deadline_rules_unified
SET lifecycle_state = 'archived',
updated_at = $1
WHERE id = $2 AND lifecycle_state = 'published'`,
@@ -412,17 +454,9 @@ func (s *RuleEditorService) Publish(ctx context.Context, id uuid.UUID, reason st
}
}
// Slice B.2 dual-write (t-paliad-305): sync both sides — the newly
// published draft AND the cloned-from peer that just flipped to
// archived (if any).
if err := syncDualWriteFromDeadlineRule(ctx, tx, id); err != nil {
return nil, err
}
if current.DraftOf != nil {
if err := syncDualWriteFromDeadlineRule(ctx, tx, *current.DraftOf); err != nil {
return nil, err
}
}
// Slice B.4 (mig 140, t-paliad-305): both UPDATEs above route via
// the INSTEAD OF UPDATE trigger, which mirrors the lifecycle flip
// onto procedural_events + sequencing_rules in the same TX.
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit publish: %w", err)
@@ -471,7 +505,7 @@ func (s *RuleEditorService) flipLifecycle(ctx context.Context, id uuid.UUID, tar
// timestamp helps audit reads ("when was this rule first live?").
if target == "published" {
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.deadline_rules
`UPDATE paliad.deadline_rules_unified
SET lifecycle_state = $1,
published_at = COALESCE(published_at, $2),
updated_at = $2
@@ -482,7 +516,7 @@ func (s *RuleEditorService) flipLifecycle(ctx context.Context, id uuid.UUID, tar
}
} else {
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.deadline_rules
`UPDATE paliad.deadline_rules_unified
SET lifecycle_state = $1, updated_at = $2
WHERE id = $3`,
target, now, id,
@@ -491,11 +525,8 @@ func (s *RuleEditorService) flipLifecycle(ctx context.Context, id uuid.UUID, tar
}
}
// Slice B.2 dual-write (t-paliad-305): mirror the lifecycle flip
// onto sequencing_rules + procedural_events.
if err := syncDualWriteFromDeadlineRule(ctx, tx, id); err != nil {
return nil, err
}
// Slice B.4 (mig 140, t-paliad-305): INSTEAD OF UPDATE trigger
// mirrors the lifecycle flip onto sr + pe.
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit flip: %w", err)
@@ -647,6 +678,42 @@ func (s *RuleEditorService) ListRules(ctx context.Context, f ListRulesFilter) ([
return rows, nil
}
// LoadProceedingTypeCodes returns an id → code map for every distinct
// non-NULL proceeding_type_id present in rows. Single SELECT against
// paliad.proceeding_types (firm-wide reference data, no RLS). Used by
// /admin/api/procedural-events to enrich the LIST response with a
// proceeding_type_code field so the admin UI can disambiguate
// same-named rules at a glance (t-paliad-321).
func (s *RuleEditorService) LoadProceedingTypeCodes(ctx context.Context, rows []models.DeadlineRule) (map[int]string, error) {
seen := map[int]bool{}
var ids []int
for _, r := range rows {
if r.ProceedingTypeID != nil && !seen[*r.ProceedingTypeID] {
seen[*r.ProceedingTypeID] = true
ids = append(ids, *r.ProceedingTypeID)
}
}
if len(ids) == 0 {
return nil, nil
}
type pair struct {
ID int `db:"id"`
Code string `db:"code"`
}
var pairs []pair
if err := s.db.SelectContext(ctx, &pairs,
`SELECT id, code FROM paliad.proceeding_types WHERE id = ANY($1)`,
pq.Array(ids),
); err != nil {
return nil, fmt.Errorf("load proceeding_type codes: %w", err)
}
out := make(map[int]string, len(pairs))
for _, p := range pairs {
out[p.ID] = p.Code
}
return out, nil
}
// GetByID returns a single rule. Exported so the handler can call it
// directly without round-tripping through ListRules.
func (s *RuleEditorService) GetByID(ctx context.Context, id uuid.UUID) (*models.DeadlineRule, error) {

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

@@ -111,8 +111,15 @@ func (c *SubmissionComposer) Compose(ctx context.Context, opts ComposeOptions) (
return nil, err
}
// Per-compose hyperlink allocator. Each unique URL gets a fresh
// rId outside the base's existing namespace. The post-pass
// (patchDocumentXMLRels) writes the matching Relationship rows
// before the zip is repacked. Slice D adds inline `[label](url)`
// hyperlink support.
linkAlloc := newComposerLinkAllocator()
// Build the rendered-section map: section_key → OOXML span.
style := opts.Base.SectionSpec.Stylemap["paragraph"]
stylemap := opts.Base.SectionSpec.Stylemap
rendered := make(map[string]string, len(sections))
keptSections := make([]SubmissionSection, 0, len(sections))
for _, sec := range sections {
@@ -123,7 +130,7 @@ func (c *SubmissionComposer) Compose(ctx context.Context, opts ComposeOptions) (
if strings.EqualFold(opts.Lang, "en") {
md = sec.ContentMDEN
}
rendered[sec.SectionKey] = RenderMarkdownToOOXML(md, style)
rendered[sec.SectionKey] = RenderMarkdownToOOXMLWithStyles(md, stylemap, linkAlloc.Alloc)
keptSections = append(keptSections, sec)
}
// Stable order — already sorted ascending by ListForDraft, but
@@ -135,6 +142,19 @@ func (c *SubmissionComposer) Compose(ctx context.Context, opts ComposeOptions) (
assembledBody := spliceSections(documentXML, rendered, keptSections, sections)
// Slice D hyperlink patch: when the walker emitted hyperlink rIds
// for inline `[label](url)` links, the base's
// word/_rels/document.xml.rels needs matching <Relationship>
// entries so Word can resolve the rIds. Mutates one zip part in
// otherParts (or appends if missing).
if linkAlloc.HasLinks() {
updatedParts, err := patchDocumentXMLRels(otherParts, linkAlloc.Pairs())
if err != nil {
return nil, err
}
otherParts = updatedParts
}
// 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.
@@ -467,3 +487,121 @@ func readZipEntry(f *zip.File) ([]byte, error) {
defer rc.Close()
return io.ReadAll(rc)
}
// ─────────────────────────────────────────────────────────────────────
// Slice D — hyperlink wiring
// ─────────────────────────────────────────────────────────────────────
// composerLinkAllocator hands out fresh rIds for inline hyperlink
// targets discovered by the MD walker. Each unique URL gets one rId
// (deduped — repeated links to the same URL share one Relationship).
// Allocations land outside the base's rId namespace by prefixing with
// "rIdComposer" so they can't collide with existing relationships.
type composerLinkAllocator struct {
next int
byURL map[string]string
order []string // URLs in allocation order
}
func newComposerLinkAllocator() *composerLinkAllocator {
return &composerLinkAllocator{byURL: map[string]string{}}
}
// Alloc returns the rId for url, allocating one on first sight.
func (a *composerLinkAllocator) Alloc(url string) string {
if rid, ok := a.byURL[url]; ok {
return rid
}
a.next++
rid := fmt.Sprintf("rIdComposer%d", a.next)
a.byURL[url] = rid
a.order = append(a.order, url)
return rid
}
// HasLinks reports whether any links were allocated during this compose.
func (a *composerLinkAllocator) HasLinks() bool {
return len(a.order) > 0
}
// Pairs returns the (rId, URL) pairs in allocation order. The
// document.xml.rels patcher consumes this to emit <Relationship>
// elements.
func (a *composerLinkAllocator) Pairs() [][2]string {
pairs := make([][2]string, 0, len(a.order))
for _, url := range a.order {
pairs = append(pairs, [2]string{a.byURL[url], url})
}
return pairs
}
// patchDocumentXMLRels mutates the word/_rels/document.xml.rels entry
// in `parts` to append the given (rId, URL) pairs as hyperlink
// relationships. If the rels part doesn't exist (some bases omit it
// when the body has no relationships), this function appends a fresh
// part with the minimal Relationships wrapper.
//
// Idempotent on (rId, URL) pairs already present (e.g. when a base
// already references the URL for some other reason).
//
// Returns the (possibly extended) parts slice — callers must overwrite
// their reference because the append in the no-rels-yet case grows the
// backing array.
func patchDocumentXMLRels(parts []baseZipPart, pairs [][2]string) ([]baseZipPart, error) {
const path = "word/_rels/document.xml.rels"
const hyperlinkType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
existingIdx := -1
for i := range parts {
if parts[i].name == path {
existingIdx = i
break
}
}
var body string
if existingIdx >= 0 {
body = string(parts[existingIdx].body)
} else {
body = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`
}
var inserts strings.Builder
for _, p := range pairs {
rid := p[0]
url := p[1]
if strings.Contains(body, `Id="`+rid+`"`) {
continue
}
inserts.WriteString(`<Relationship Id="`)
inserts.WriteString(xmlAttrEscape(rid))
inserts.WriteString(`" Type="`)
inserts.WriteString(hyperlinkType)
inserts.WriteString(`" Target="`)
inserts.WriteString(xmlAttrEscape(url))
inserts.WriteString(`" TargetMode="External"/>`)
}
if inserts.Len() == 0 {
return parts, nil
}
closeIdx := strings.LastIndex(body, "</Relationships>")
if closeIdx < 0 {
return parts, fmt.Errorf("submission compose: malformed document.xml.rels (no closing tag)")
}
patched := body[:closeIdx] + inserts.String() + body[closeIdx:]
if existingIdx >= 0 {
parts[existingIdx].body = []byte(patched)
return parts, nil
}
parts = append(parts, baseZipPart{
name: path,
method: zip.Deflate,
modTime: time.Now().Unix(),
body: []byte(patched),
})
return parts, nil
}

View File

@@ -58,27 +58,32 @@ func minimalBaseBytes(t *testing.T, body string) []byte {
// extractDocumentXML pulls word/document.xml out of a .docx zip for
// assertions.
func extractDocumentXML(t *testing.T, data []byte) string {
return extractZipEntry(t, data, "word/document.xml")
}
// extractZipEntry pulls any named entry out of a .docx zip.
func extractZipEntry(t *testing.T, data []byte, name string) 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" {
if f.Name != name {
continue
}
rc, err := f.Open()
if err != nil {
t.Fatalf("open document.xml: %v", err)
t.Fatalf("open %s: %v", name, err)
}
defer rc.Close()
var buf bytes.Buffer
if _, err := buf.ReadFrom(rc); err != nil {
t.Fatalf("read document.xml: %v", err)
t.Fatalf("read %s: %v", name, err)
}
return buf.String()
}
t.Fatal("document.xml not found in zip")
t.Fatalf("%s not found in zip", name)
return ""
}
@@ -249,6 +254,203 @@ func TestComposer_LangPicksColumn(t *testing.T) {
}
}
// Slice D — rich-prose end-to-end through the composer.
func TestComposer_HeadingsAndLists(t *testing.T) {
base := composerBase()
// Extend the stylemap so the walker has named styles to apply.
base.SectionSpec.Stylemap["heading_1"] = "Heading1"
base.SectionSpec.Stylemap["list_bullet"] = "ListBullet"
base.SectionSpec.Stylemap["list_numbered"] = "ListNumber"
base.SectionSpec.Stylemap["blockquote"] = "Quote"
body := `<w:p><w:r><w:t>{{#section:body}}</w:t></w:r></w:p><w:p><w:r><w:t>{{/section:body}}</w:t></w:r></w:p>`
baseBytes := minimalBaseBytes(t, body)
composer := NewSubmissionComposer(NewSubmissionRenderer())
md := "# Heading line\n\n- bullet a\n- bullet b\n\n1. first\n2. second\n\n> quoted"
sections := []SubmissionSection{
{ID: uuid.New(), SectionKey: "body", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: md},
}
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)
for _, want := range []string{
`<w:pStyle w:val="Heading1"/>`,
`<w:pStyle w:val="ListBullet"/>`,
`<w:pStyle w:val="ListNumber"/>`,
`<w:pStyle w:val="Quote"/>`,
"Heading line",
"bullet a",
"bullet b",
`<w:t xml:space="preserve">1. </w:t>`,
`<w:t xml:space="preserve">2. </w:t>`,
"first",
"second",
"quoted",
} {
if !strings.Contains(docXML, want) {
t.Errorf("expected %q in composed body; got: %s", want, docXML)
}
}
}
func TestComposer_HyperlinkWiresRels(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: "See [BGH](https://bgh.bund.de) and [EuGH](https://curia.europa.eu)."},
}
out, err := composer.Compose(context.Background(), ComposeOptions{
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
})
if err != nil {
t.Fatalf("Compose: %v", err)
}
// Body: hyperlink elements with composer rIds.
docXML := extractDocumentXML(t, out)
if !strings.Contains(docXML, `<w:hyperlink r:id="rIdComposer1">`) ||
!strings.Contains(docXML, `<w:hyperlink r:id="rIdComposer2">`) {
t.Errorf("hyperlink rIds missing in body: %q", docXML)
}
if !strings.Contains(docXML, "BGH") || !strings.Contains(docXML, "EuGH") {
t.Errorf("hyperlink labels missing: %q", docXML)
}
// Rels: the matching <Relationship> rows must be in
// word/_rels/document.xml.rels with the URL targets + External mode.
rels := extractZipEntry(t, out, "word/_rels/document.xml.rels")
for _, want := range []string{
`Id="rIdComposer1"`,
`Id="rIdComposer2"`,
`Target="https://bgh.bund.de"`,
`Target="https://curia.europa.eu"`,
`TargetMode="External"`,
"hyperlink", // the Type URL contains "hyperlink"
} {
if !strings.Contains(rels, want) {
t.Errorf("expected %q in document.xml.rels: %s", want, rels)
}
}
}
func TestComposer_HyperlinkDedupesByURL(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())
// Same URL referenced twice — should produce one rId, two
// <w:hyperlink> elements both pointing at it.
sections := []SubmissionSection{
{ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true,
ContentMDDE: "First [BGH](https://bgh.bund.de) and again [Bundesgerichtshof](https://bgh.bund.de)."},
}
out, _ := composer.Compose(context.Background(), ComposeOptions{
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
})
docXML := extractDocumentXML(t, out)
if strings.Count(docXML, `<w:hyperlink r:id="rIdComposer1">`) != 2 {
t.Errorf("expected 2 hyperlinks sharing rIdComposer1; got: %s", docXML)
}
if strings.Contains(docXML, `<w:hyperlink r:id="rIdComposer2">`) {
t.Errorf("dedupe failed — second rId allocated for same URL: %s", docXML)
}
}
// Slice E — base swap preserves section content; only chrome / styles
// change. This is the design's "Markdown is base-agnostic" contract
// from Q10 + §5.3 ratification. We compose the SAME section text
// against two bases with DIFFERENT stylemaps and verify:
// 1. The section text appears in both outputs.
// 2. Each base applies its OWN paragraph style (the stylemap diff
// is the only visible delta in the document body).
func TestComposer_BaseSwapPreservesContent(t *testing.T) {
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)
// Base A: HLC-style stylemap.
hlc := &SubmissionBase{
ID: uuid.New(), Slug: "hlc-test",
SectionSpec: BaseSectionSpec{
Stylemap: map[string]string{
"paragraph": "HLpat-Body-B0",
"heading_1": "HLpat-Heading-H1",
},
},
}
// Base B: LG-style stylemap.
lg := &SubmissionBase{
ID: uuid.New(), Slug: "lg-test",
SectionSpec: BaseSectionSpec{
Stylemap: map[string]string{
"paragraph": "LG-Body",
"heading_1": "LG-Heading1",
},
},
}
// Identical Markdown content rendered against each base.
md := "# Heading line\n\nA paragraph of substantive prose."
sections := []SubmissionSection{
{ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: md},
}
composer := NewSubmissionComposer(NewSubmissionRenderer())
hlcOut, err := composer.Compose(context.Background(), ComposeOptions{
Sections: sections, Base: hlc, BaseBytes: baseBytes, Lang: "de",
})
if err != nil {
t.Fatalf("Compose hlc: %v", err)
}
lgOut, err := composer.Compose(context.Background(), ComposeOptions{
Sections: sections, Base: lg, BaseBytes: baseBytes, Lang: "de",
})
if err != nil {
t.Fatalf("Compose lg: %v", err)
}
hlcXML := extractDocumentXML(t, hlcOut)
lgXML := extractDocumentXML(t, lgOut)
// Content survives both ways.
for _, want := range []string{"Heading line", "A paragraph of substantive prose."} {
if !strings.Contains(hlcXML, want) {
t.Errorf("HLC output missing content %q", want)
}
if !strings.Contains(lgXML, want) {
t.Errorf("LG output missing content %q", want)
}
}
// Stylemap diff actually shows up in the body — HLC's headings
// use HLpat-Heading-H1, LG's use LG-Heading1. If the composer
// silently passed the wrong stylemap, this would fire.
if !strings.Contains(hlcXML, `<w:pStyle w:val="HLpat-Heading-H1"/>`) {
t.Errorf("HLC heading style missing: %s", hlcXML)
}
if !strings.Contains(lgXML, `<w:pStyle w:val="LG-Heading1"/>`) {
t.Errorf("LG heading style missing: %s", lgXML)
}
if strings.Contains(hlcXML, `<w:pStyle w:val="LG-Heading1"/>`) {
t.Errorf("HLC output leaked LG style: %s", hlcXML)
}
if strings.Contains(lgXML, `<w:pStyle w:val="HLpat-Heading-H1"/>`) {
t.Errorf("LG output leaked HLC style: %s", lgXML)
}
}
func TestComposer_OrderIndexAscending(t *testing.T) {
base := composerBase()
// No anchors → both sections append in order_index ASC order

View File

@@ -27,79 +27,223 @@ package services
// - Otherwise → plain text run
import (
"fmt"
"strings"
)
// HyperlinkAllocator hands the walker a `rId` for each external URL
// it encounters in `[label](url)` inline links. The composer's
// post-pass uses these allocations to mutate
// `word/_rels/document.xml.rels` so the emitted `<w:hyperlink
// r:id="…">` elements resolve correctly. Pass nil to drop links to
// plain text (the label survives, the URL doesn't render).
//
// t-paliad-316 Slice D.
type HyperlinkAllocator func(url string) string
// 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.
//
// Slice B shipped paragraphs + bold/italic. Slice D extends to
// headings (h1/h2/h3), bullet/numbered lists, blockquote, and inline
// hyperlinks via the optional HyperlinkAllocator.
//
// stylemap supplies the paragraph-style names for each kind:
// stylemap["paragraph"] — default body
// stylemap["heading_1/2/3"] — heading levels
// stylemap["list_bullet"] — bullet list paragraph style
// stylemap["list_numbered"] — numbered list paragraph style
// stylemap["blockquote"] — blockquote
// Missing entries fall back to the "paragraph" style.
//
// 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 {
return RenderMarkdownToOOXMLWithStyles(md, map[string]string{"paragraph": paragraphStyle}, nil)
}
// RenderMarkdownToOOXMLWithStyles is the full Slice-D-aware entry
// point. Slice B's RenderMarkdownToOOXML is a wrapper for back-compat.
func RenderMarkdownToOOXMLWithStyles(md string, stylemap map[string]string, links HyperlinkAllocator) string {
defaultStyle := stylemap["paragraph"]
if md == "" {
return emptyParagraph(paragraphStyle)
return emptyParagraph(defaultStyle)
}
paragraphs := splitMarkdownParagraphs(md)
if len(paragraphs) == 0 {
return emptyParagraph(paragraphStyle)
blocks := splitMarkdownBlocks(md)
if len(blocks) == 0 {
return emptyParagraph(defaultStyle)
}
// Numbered-list counter resets on every non-numbered block so
// "1. A\n2. B\n\n1. C" renders as 1./2./1. (the lawyer's input
// determined the ordinal, the walker just renders).
numberedCounter := 0
var b strings.Builder
for _, para := range paragraphs {
b.WriteString(renderParagraph(para, paragraphStyle))
for _, blk := range blocks {
style := stylemap[blk.styleKey]
if style == "" {
style = defaultStyle
}
if blk.styleKey == "list_numbered" {
numberedCounter++
} else {
numberedCounter = 0
}
b.WriteString(renderBlockParagraph(blk, style, links, numberedCounter))
}
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.
// mdBlock is one rendered paragraph: a kind (paragraph / heading_*
// / list_bullet / list_numbered / blockquote) and the inline content
// text. List markers, heading hashes, blockquote `> ` etc. are
// stripped from the text before storage.
type mdBlock struct {
styleKey string // "paragraph" | "heading_1" | "heading_2" | "heading_3" | "list_bullet" | "list_numbered" | "blockquote"
text string
}
// splitMarkdownBlocks parses the source into a sequence of blocks,
// detecting heading / list / blockquote prefixes line-by-line. Blank
// lines split paragraph runs (same semantics as splitMarkdownParagraphs)
// but each line is also tagged with its block kind.
//
// CRLF line endings normalise to LF before splitting.
func splitMarkdownParagraphs(md string) []string {
// Lines that look like block markers don't merge with their neighbours
// even across blank lines — every list / heading / blockquote line is
// its own block in the output. A run of unmarked lines collapses into
// one "paragraph" block (so soft line breaks inside a paragraph still
// concatenate).
//
// CRLF normalised to LF before parsing.
func splitMarkdownBlocks(md string) []mdBlock {
normalised := strings.ReplaceAll(md, "\r\n", "\n")
lines := strings.Split(normalised, "\n")
var paragraphs []string
var current []string
var blocks []mdBlock
var pendingPara []string
blankRun := 0
flushParagraph := func() {
if len(current) > 0 {
paragraphs = append(paragraphs, strings.Join(current, "\n"))
current = nil
flushPara := func() {
if len(pendingPara) > 0 {
blocks = append(blocks, mdBlock{styleKey: "paragraph", text: strings.Join(pendingPara, "\n")})
pendingPara = nil
}
}
for _, line := range lines {
for _, raw := range lines {
line := raw
if strings.TrimSpace(line) == "" {
if len(current) > 0 {
// End of a paragraph; the blank-counting starts now.
flushParagraph()
if len(pendingPara) > 0 {
flushPara()
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, "")
// Detect heading / list / blockquote markers BEFORE we accumulate
// into the paragraph buffer.
kind, payload, ok := detectBlockMarker(line)
if ok {
flushPara()
// Emit spacing paragraphs equivalent to (blankRun - 1) extra.
for i := 1; i < blankRun; i++ {
blocks = append(blocks, mdBlock{styleKey: "paragraph", text: ""})
}
blankRun = 0
blocks = append(blocks, mdBlock{styleKey: kind, text: payload})
continue
}
// Plain paragraph line.
if len(pendingPara) == 0 {
// Starting a new paragraph after a blank run — emit
// (blankRun-1) extra empty paragraphs for vertical spacing.
for i := 1; i < blankRun; i++ {
blocks = append(blocks, mdBlock{styleKey: "paragraph", text: ""})
}
}
blankRun = 0
current = append(current, line)
pendingPara = append(pendingPara, line)
}
flushParagraph()
return paragraphs
flushPara()
return blocks
}
// 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 {
// detectBlockMarker classifies a single line. Returns (styleKey,
// payload-with-marker-stripped, true) for recognised markers; false
// for plain paragraph lines.
//
// Recognised markers (Slice D):
// # Heading → heading_1
// ## Heading → heading_2
// ### Heading → heading_3
// - item / * item → list_bullet
// 1. item / 2. item ... → list_numbered (any positive integer)
// > quote → blockquote
//
// Leading whitespace inside the line is tolerated up to 3 spaces (per
// CommonMark) so the lawyer's contentEditable indentation doesn't
// hide the marker.
func detectBlockMarker(line string) (string, string, bool) {
trimmed := strings.TrimLeft(line, " ")
// Cap to 3 spaces of leading indent — beyond that, treat as a
// regular paragraph line (matches CommonMark).
if len(line)-len(trimmed) > 3 {
return "", "", false
}
if strings.HasPrefix(trimmed, "### ") {
return "heading_3", strings.TrimSpace(trimmed[4:]), true
}
if strings.HasPrefix(trimmed, "## ") {
return "heading_2", strings.TrimSpace(trimmed[3:]), true
}
if strings.HasPrefix(trimmed, "# ") {
return "heading_1", strings.TrimSpace(trimmed[2:]), true
}
if strings.HasPrefix(trimmed, "> ") {
return "blockquote", strings.TrimSpace(trimmed[2:]), true
}
if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") {
return "list_bullet", strings.TrimSpace(trimmed[2:]), true
}
// Numbered: "N. " where N is one or more digits.
if i := indexOfNumberedMarker(trimmed); i > 0 {
return "list_numbered", strings.TrimSpace(trimmed[i:]), true
}
return "", "", false
}
// indexOfNumberedMarker checks for "N. " or "N) " at the start of the
// trimmed line; returns the byte index just past the marker, or -1 if
// no marker present.
func indexOfNumberedMarker(s string) int {
i := 0
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
if i == 0 {
return -1
}
if i >= len(s) {
return -1
}
if s[i] != '.' && s[i] != ')' {
return -1
}
if i+1 >= len(s) || s[i+1] != ' ' {
return -1
}
return i + 2
}
// renderBlockParagraph emits one `<w:p>` for a block. List blocks
// keep the same paragraph style as a default paragraph (the Slice D
// design's contract — list styles come from the base's stylemap and
// Word's numbering.xml is honoured by adding a leading bullet/number
// prefix in the rendered text). This keeps the composer free of
// numbering.xml mutations.
func renderBlockParagraph(blk mdBlock, paragraphStyle string, links HyperlinkAllocator, numberedOrdinal int) string {
var b strings.Builder
b.WriteString(`<w:p>`)
if paragraphStyle != "" {
@@ -107,21 +251,124 @@ func renderParagraph(text, paragraphStyle string) string {
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.
if blk.text == "" {
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))
text := blk.text
// List blocks emit a visible "• " / "N. " prefix run. The
// stylemap entry handles paragraph indentation if the base
// defines a list paragraph style; otherwise the prefix at least
// surfaces the structure in plain Word. Lawyers who want Word's
// auto-numbering reapply a list style post-export.
switch blk.styleKey {
case "list_bullet":
b.WriteString(`<w:r><w:t xml:space="preserve">• </w:t></w:r>`)
case "list_numbered":
ordinal := numberedOrdinal
if ordinal <= 0 {
ordinal = 1
}
b.WriteString(`<w:r><w:t xml:space="preserve">`)
b.WriteString(fmt.Sprintf("%d. ", ordinal))
b.WriteString(`</w:t></w:r>`)
}
for _, run := range parseInlineRuns(text, links) {
b.WriteString(run)
}
b.WriteString(`</w:p>`)
return b.String()
}
// parseInlineRuns extracts inline spans + hyperlink runs and serialises
// each to OOXML. Hyperlinks become `<w:hyperlink r:id="RID">…runs…</w:hyperlink>`
// where RID comes from the HyperlinkAllocator.
func parseInlineRuns(text string, links HyperlinkAllocator) []string {
// Phase 1: find all hyperlink spans `[label](url)` and split the
// text around them.
type segment struct {
text string
isLink bool
url string
}
var segs []segment
rest := text
for {
idx := strings.Index(rest, "[")
if idx < 0 {
if rest != "" {
segs = append(segs, segment{text: rest})
}
break
}
// Find matching closing bracket, then a "(" right after.
closeBracket := strings.Index(rest[idx:], "](")
if closeBracket < 0 {
segs = append(segs, segment{text: rest})
break
}
closeParen := strings.Index(rest[idx+closeBracket:], ")")
if closeParen < 0 {
segs = append(segs, segment{text: rest})
break
}
// idx = start of "["
// idx+closeBracket = position of "]"
// idx+closeBracket+1 = position of "("
// idx+closeBracket+closeParen = position of ")"
label := rest[idx+1 : idx+closeBracket]
url := rest[idx+closeBracket+2 : idx+closeBracket+closeParen]
if idx > 0 {
segs = append(segs, segment{text: rest[:idx]})
}
segs = append(segs, segment{text: label, isLink: true, url: url})
rest = rest[idx+closeBracket+closeParen+1:]
}
var runs []string
for _, seg := range segs {
if seg.isLink && links != nil {
rid := links(seg.url)
if rid != "" {
var hb strings.Builder
hb.WriteString(`<w:hyperlink r:id="`)
hb.WriteString(xmlAttrEscape(rid))
hb.WriteString(`">`)
for _, span := range parseInlineSpans(seg.text) {
hb.WriteString(renderRunWithLinkStyle(span))
}
hb.WriteString(`</w:hyperlink>`)
runs = append(runs, hb.String())
continue
}
}
for _, span := range parseInlineSpans(seg.text) {
runs = append(runs, renderRun(span))
}
}
return runs
}
// renderRunWithLinkStyle emits a hyperlink child run. Same B/I support
// as renderRun, but additionally tags the run with the "Hyperlink"
// character style (Word's built-in) so the link renders in the
// document's hyperlink colour + underline.
func renderRunWithLinkStyle(span inlineSpan) string {
var b strings.Builder
b.WriteString(`<w:r><w:rPr><w:rStyle w:val="Hyperlink"/>`)
if span.Bold {
b.WriteString(`<w:b/>`)
}
if span.Italic {
b.WriteString(`<w:i/>`)
}
b.WriteString(`</w:rPr><w:t xml:space="preserve">`)
b.WriteString(xmlTextEscape(span.Text))
b.WriteString(`</w:t></w:r>`)
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.

View File

@@ -144,3 +144,156 @@ func TestParseInlineSpans_UnderscoreBold(t *testing.T) {
t.Errorf("expected one bold 'strong' span; got %+v", spans)
}
}
// ─────────────────────────────────────────────────────────────────────
// Slice D — rich-prose constructs
// ─────────────────────────────────────────────────────────────────────
func slicedStylemap() map[string]string {
return map[string]string{
"paragraph": "Body",
"heading_1": "H1",
"heading_2": "H2",
"heading_3": "H3",
"list_bullet": "ListBullet",
"list_numbered": "ListNumber",
"blockquote": "Quote",
}
}
func TestRenderMarkdownToOOXML_Heading1(t *testing.T) {
out := RenderMarkdownToOOXMLWithStyles("# A heading", slicedStylemap(), nil)
if !strings.Contains(out, `<w:pStyle w:val="H1"/>`) {
t.Errorf("heading_1 missing H1 style: %q", out)
}
if !strings.Contains(out, "A heading") {
t.Errorf("heading text missing: %q", out)
}
}
func TestRenderMarkdownToOOXML_Heading2And3(t *testing.T) {
out := RenderMarkdownToOOXMLWithStyles("## H2 line\n### H3 line", slicedStylemap(), nil)
if !strings.Contains(out, `<w:pStyle w:val="H2"/>`) || !strings.Contains(out, "H2 line") {
t.Errorf("h2 not rendered: %q", out)
}
if !strings.Contains(out, `<w:pStyle w:val="H3"/>`) || !strings.Contains(out, "H3 line") {
t.Errorf("h3 not rendered: %q", out)
}
}
func TestRenderMarkdownToOOXML_BulletList(t *testing.T) {
out := RenderMarkdownToOOXMLWithStyles("- first\n- second\n* third", slicedStylemap(), nil)
if !strings.Contains(out, `<w:pStyle w:val="ListBullet"/>`) {
t.Errorf("bullet stylemap not applied: %q", out)
}
if strings.Count(out, "• ") != 3 {
t.Errorf("expected 3 bullet prefixes; got %d in %q", strings.Count(out, "• "), out)
}
}
func TestRenderMarkdownToOOXML_NumberedList(t *testing.T) {
out := RenderMarkdownToOOXMLWithStyles("1. first\n2. second\n3. third", slicedStylemap(), nil)
if !strings.Contains(out, `<w:pStyle w:val="ListNumber"/>`) {
t.Errorf("numbered stylemap not applied: %q", out)
}
for _, want := range []string{"1. ", "2. ", "3. "} {
if !strings.Contains(out, want) {
t.Errorf("missing ordinal prefix %q in %q", want, out)
}
}
}
func TestRenderMarkdownToOOXML_NumberedListResetsOnNonList(t *testing.T) {
// "1. A\n2. B\nplain\n1. C" → 1. A, 2. B, plain para, 1. C
out := RenderMarkdownToOOXMLWithStyles("1. A\n2. B\nplain\n1. C", slicedStylemap(), nil)
// The plain "plain" line breaks the list, so the next numbered
// item restarts at 1.
idxA := strings.Index(out, "1. ")
if idxA < 0 {
t.Fatalf("first 1. missing: %q", out)
}
idxB := strings.Index(out, "2. ")
if idxB < 0 || idxB <= idxA {
t.Fatalf("2. not after 1.: idxA=%d idxB=%d", idxA, idxB)
}
rest := out[idxB+1:]
idxC := strings.Index(rest, "1. ")
if idxC < 0 {
t.Errorf("numbered counter didn't reset on non-list block: %q", out)
}
}
func TestRenderMarkdownToOOXML_Blockquote(t *testing.T) {
out := RenderMarkdownToOOXMLWithStyles("> the quoted text", slicedStylemap(), nil)
if !strings.Contains(out, `<w:pStyle w:val="Quote"/>`) {
t.Errorf("blockquote stylemap not applied: %q", out)
}
if !strings.Contains(out, "the quoted text") {
t.Errorf("blockquote text missing: %q", out)
}
}
func TestRenderMarkdownToOOXML_Hyperlink(t *testing.T) {
allocated := map[string]string{}
alloc := func(url string) string {
rid := "rIdComposer" + url
allocated[url] = rid
return rid
}
out := RenderMarkdownToOOXMLWithStyles("See [Bundesgerichtshof](https://bgh.bund.de) for details.", slicedStylemap(), alloc)
if _, ok := allocated["https://bgh.bund.de"]; !ok {
t.Errorf("allocator never called for URL: %q", out)
}
if !strings.Contains(out, `<w:hyperlink r:id="rIdComposerhttps://bgh.bund.de">`) {
t.Errorf("hyperlink tag missing or wrong rid: %q", out)
}
if !strings.Contains(out, "Bundesgerichtshof") {
t.Errorf("link label missing: %q", out)
}
if !strings.Contains(out, `<w:rStyle w:val="Hyperlink"/>`) {
t.Errorf("hyperlink character style missing: %q", out)
}
}
func TestRenderMarkdownToOOXML_HyperlinkNilAllocatorFallsBackToPlain(t *testing.T) {
out := RenderMarkdownToOOXMLWithStyles("See [BGH](https://bgh.bund.de) here.", slicedStylemap(), nil)
// Without an allocator, the label still renders as plain text.
if !strings.Contains(out, "BGH") {
t.Errorf("label dropped: %q", out)
}
if strings.Contains(out, "<w:hyperlink") {
t.Errorf("hyperlink emitted without allocator: %q", out)
}
}
func TestDetectBlockMarker(t *testing.T) {
cases := []struct {
in string
kind string
want string
ok bool
}{
{"# A", "heading_1", "A", true},
{"## B", "heading_2", "B", true},
{"### C", "heading_3", "C", true},
{" # indented", "heading_1", "indented", true}, // up to 3 spaces tolerated
{" # too-deep", "", "", false}, // 4 spaces → not a heading
{"- bullet", "list_bullet", "bullet", true},
{"* star", "list_bullet", "star", true},
{"1. one", "list_numbered", "one", true},
{"42. forty-two", "list_numbered", "forty-two", true},
{"1) paren", "list_numbered", "paren", true},
{"1.no-space", "", "", false}, // ordinal needs trailing space
{"> quote", "blockquote", "quote", true},
{"plain", "", "", false},
{"#nospace", "", "", false}, // heading needs space after hash
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
kind, payload, ok := detectBlockMarker(tc.in)
if ok != tc.ok || kind != tc.kind || payload != tc.want {
t.Errorf("detectBlockMarker(%q) = (%q,%q,%v); want (%q,%q,%v)", tc.in, kind, payload, ok, tc.kind, tc.want, tc.ok)
}
})
}
}

View File

@@ -178,6 +178,130 @@ func (s *SectionService) Update(ctx context.Context, sectionID uuid.UUID, patch
return &sec, nil
}
// SectionCreateInput is the payload for adding a new (lawyer-custom)
// section to a draft (t-paliad-318 Slice F).
type SectionCreateInput struct {
DraftID uuid.UUID
SectionKey string
Kind string
LabelDE string
LabelEN string
ContentMDDE string
ContentMDEN string
OrderIndex int // 0 = append at end
Included bool // defaults to true if not specified at the handler
}
// Create inserts a new section row for the draft. The section_key
// must not already exist on this draft (UNIQUE constraint at the DB
// catches collisions and surfaces as ErrInvalidInput).
//
// OrderIndex=0 means "auto-assign at the end" — the service queries
// the current max(order_index) and increments. Non-zero values insert
// at the requested position; the caller is responsible for any
// subsequent Reorder if they intend to push existing rows down.
func (s *SectionService) Create(ctx context.Context, in SectionCreateInput) (*SubmissionSection, error) {
in.SectionKey = strings.TrimSpace(in.SectionKey)
in.LabelDE = strings.TrimSpace(in.LabelDE)
in.LabelEN = strings.TrimSpace(in.LabelEN)
if in.SectionKey == "" || in.LabelDE == "" || in.LabelEN == "" {
return nil, ErrInvalidInput
}
switch in.Kind {
case "prose", "requests", "evidence":
default:
return nil, ErrInvalidInput
}
if in.OrderIndex == 0 {
var maxOrder int
err := s.db.GetContext(ctx, &maxOrder,
`SELECT COALESCE(MAX(order_index), 0) FROM paliad.submission_sections WHERE draft_id = $1`,
in.DraftID)
if err != nil {
return nil, fmt.Errorf("max order_index: %w", err)
}
in.OrderIndex = maxOrder + 1
}
var sec SubmissionSection
err := s.db.GetContext(ctx, &sec,
`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)
RETURNING `+sectionColumns,
in.DraftID, in.SectionKey, in.OrderIndex, in.Kind,
in.LabelDE, in.LabelEN, in.Included,
in.ContentMDDE, in.ContentMDEN)
if err != nil {
// UNIQUE (draft_id, section_key) collision → invalid input.
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "23505") {
return nil, fmt.Errorf("%w: section_key already exists on this draft", ErrInvalidInput)
}
return nil, fmt.Errorf("create submission section: %w", err)
}
return &sec, nil
}
// Delete removes one section row by id. Owner-scope is the caller's
// responsibility (the handler runs SubmissionDraftService.Get first).
func (s *SectionService) Delete(ctx context.Context, sectionID uuid.UUID) error {
res, err := s.db.ExecContext(ctx,
`DELETE FROM paliad.submission_sections WHERE id = $1`,
sectionID)
if err != nil {
return fmt.Errorf("delete submission section: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrSubmissionSectionNotFound
}
return nil
}
// Reorder updates the order_index of every section row for the draft
// according to the supplied ID sequence. Transactional — partial
// failures roll back. Any section_id present on the draft but not in
// the sequence keeps its previous order_index, then sorts last by
// updated_at (so a partial reorder doesn't lose rows the caller
// forgot to mention).
func (s *SectionService) Reorder(ctx context.Context, draftID uuid.UUID, order []uuid.UUID) ([]SubmissionSection, error) {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("reorder tx: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
// Each id in order gets order_index 10, 20, 30, ... (gaps so a
// future single-row insert doesn't trigger a full reflow). Ids
// not present on the draft are silently ignored.
for i, sectionID := range order {
idx := (i + 1) * 10
_, err := tx.ExecContext(ctx,
`UPDATE paliad.submission_sections
SET order_index = $1
WHERE id = $2 AND draft_id = $3`,
idx, sectionID, draftID)
if err != nil {
return nil, fmt.Errorf("reorder update: %w", err)
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit reorder: %w", err)
}
committed = true
return s.ListForDraft(ctx, draftID)
}
// 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

View File

@@ -0,0 +1,152 @@
package services
// Live-DB tests for Slice F section service additions (Create + Delete
// + Reorder). Gated on TEST_DATABASE_URL, mirroring Slice A's pattern.
import (
"context"
"os"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
func TestSectionService_SliceF(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)
sections := NewSectionService(pool)
// Seed user + draft so we have a draft_id to attach sections to.
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 := "slice-f-" + 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, 'Slice F User', '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)
drafts.AttachComposer(bases, sections, "HLC")
d, err := drafts.Create(ctx, userID, nil, "de.inf.lg.erwidg", "de")
if err != nil {
t.Fatalf("Create draft: %v", err)
}
initial, err := sections.ListForDraft(ctx, d.ID)
if err != nil {
t.Fatalf("ListForDraft initial: %v", err)
}
if len(initial) != 10 {
t.Fatalf("expected 10 seeded sections; got %d", len(initial))
}
t.Run("Create custom section", func(t *testing.T) {
created, err := sections.Create(ctx, SectionCreateInput{
DraftID: d.ID,
SectionKey: "berufungsantraege",
Kind: "requests",
LabelDE: "Berufungsanträge",
LabelEN: "Appeal requests",
Included: true,
})
if err != nil {
t.Fatalf("Create: %v", err)
}
if created.OrderIndex <= 10 {
t.Errorf("auto-assigned order_index should be > existing max; got %d", created.OrderIndex)
}
// Slug collision must surface as ErrInvalidInput.
_, err = sections.Create(ctx, SectionCreateInput{
DraftID: d.ID, SectionKey: "berufungsantraege",
Kind: "prose", LabelDE: "x", LabelEN: "x", Included: true,
})
if err == nil {
t.Errorf("expected unique-key collision error; got nil")
}
})
t.Run("Delete section", func(t *testing.T) {
// Grab one of the seeded rows to delete.
current, _ := sections.ListForDraft(ctx, d.ID)
var victimID uuid.UUID
for _, s := range current {
if s.SectionKey == "exhibits" {
victimID = s.ID
break
}
}
if victimID == uuid.Nil {
t.Fatalf("expected exhibits section to exist")
}
if err := sections.Delete(ctx, victimID); err != nil {
t.Fatalf("Delete: %v", err)
}
// Second delete returns not-found.
if err := sections.Delete(ctx, victimID); err == nil {
t.Errorf("expected ErrSubmissionSectionNotFound on second delete")
}
})
t.Run("Reorder sections", func(t *testing.T) {
current, _ := sections.ListForDraft(ctx, d.ID)
if len(current) < 3 {
t.Skipf("need at least 3 sections to test reorder; got %d", len(current))
}
// Reverse the order list.
ids := make([]uuid.UUID, 0, len(current))
for i := len(current) - 1; i >= 0; i-- {
ids = append(ids, current[i].ID)
}
reordered, err := sections.Reorder(ctx, d.ID, ids)
if err != nil {
t.Fatalf("Reorder: %v", err)
}
// Verify the first ID in our list now has the lowest order_index.
if reordered[0].ID != ids[0] {
t.Errorf("first ID after reorder = %s; want %s", reordered[0].ID, ids[0])
}
// Order indices should be ascending.
prev := 0
for _, s := range reordered {
if s.OrderIndex <= prev {
t.Errorf("non-ascending order_index after reorder: %d (prev=%d) at %s", s.OrderIndex, prev, s.SectionKey)
}
prev = s.OrderIndex
}
})
}

View File

@@ -289,12 +289,12 @@ func (s *SubmissionVarsService) nextOpenDeadline(ctx context.Context, projectID,
var d models.Deadline
err := s.db.GetContext(ctx, &d,
`SELECT id, project_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, rule_code, status, completed_at,
warning_date, source, sequencing_rule_id, rule_code, status, completed_at,
caldav_uid, caldav_etag, notes, created_by, created_at, updated_at,
approval_status, pending_request_id, approved_by, approved_at
FROM paliad.deadlines
WHERE project_id = $1
AND rule_id = $2
AND sequencing_rule_id = $2
AND status = 'pending'
ORDER BY due_date ASC
LIMIT 1`, projectID, ruleID)

View File

@@ -0,0 +1,256 @@
// Composer Slice E base-template generator (t-paliad-317).
//
// Produces a minimal Composer-mode .docx whose <w:body> contains the
// 10 default section anchors and whose word/styles.xml declares a
// named style for each stylemap key the composer references. Each
// "preset" (lg-duesseldorf, upc-formal, …) hard-codes the typography
// (font, sizes, colour) so the lawyer can swap between them and see
// the chrome change while the section content carries through
// unchanged (the Q10 base-swap-content-survival contract).
//
// Run:
//
// go run ./scripts/gen-submission-base -preset lg-duesseldorf -out /tmp/lg-duesseldorf.docx
// go run ./scripts/gen-submission-base -preset upc-formal -out /tmp/upc-formal.docx
//
// Both outputs are byte-reproducible (zip mtimes pinned to a fixed
// UTC timestamp so a clean rebuild diff stays at zero bytes).
//
// Cross-firm: the bases this generator emits are firm-agnostic
// (firm = NULL on the catalog row). They contain no HLC branding
// content. Per-firm bases continue to use gen-hl-skeleton-template
// against the proprietary .dotm source.
package main
import (
"archive/zip"
"bytes"
"flag"
"fmt"
"os"
"strings"
"time"
)
func main() {
preset := flag.String("preset", "", "preset: lg-duesseldorf | upc-formal")
out := flag.String("out", "", "output .docx path (required)")
flag.Parse()
if *preset == "" || *out == "" {
fmt.Fprintln(os.Stderr, "usage: gen-submission-base -preset NAME -out PATH")
os.Exit(2)
}
cfg, ok := presets[*preset]
if !ok {
fmt.Fprintf(os.Stderr, "unknown preset %q (available: ", *preset)
first := true
for k := range presets {
if !first {
fmt.Fprint(os.Stderr, ", ")
}
fmt.Fprint(os.Stderr, k)
first = false
}
fmt.Fprintln(os.Stderr, ")")
os.Exit(2)
}
docx, err := buildDocx(cfg)
if err != nil {
fmt.Fprintln(os.Stderr, "gen-submission-base:", err)
os.Exit(1)
}
if err := os.WriteFile(*out, docx, 0o644); err != nil {
fmt.Fprintln(os.Stderr, "gen-submission-base: write:", err)
os.Exit(1)
}
fmt.Printf("wrote %s (%d bytes) for preset %s\n", *out, len(docx), *preset)
}
// presetConfig captures everything the generator needs to vary between
// bases: typography defaults (font + size + colour) and the style-name
// prefix that surfaces in the styles.xml.
type presetConfig struct {
StylePrefix string // e.g. "LG" / "UPC"
DefaultFont string // e.g. "Times New Roman" / "Calibri"
BodyHalfPoints int // w:sz value (half-points; 22 = 11pt)
Heading1Size int
Heading2Size int
Heading3Size int
Heading1Color string // hex without #
Heading2Color string
Heading3Color string
BlockquoteFont string // separate font for the quote style
}
// presets are the seeded base styles for Slice E. Both are intended
// as starting points the firm's admin can refine via the admin editor
// in a later slice — this is the floor, not the ceiling.
var presets = map[string]presetConfig{
"lg-duesseldorf": {
StylePrefix: "LG",
DefaultFont: "Times New Roman",
BodyHalfPoints: 22, // 11pt
Heading1Size: 28, // 14pt
Heading2Size: 26, // 13pt
Heading3Size: 24, // 12pt
Heading1Color: "000000",
Heading2Color: "000000",
Heading3Color: "000000",
BlockquoteFont: "Times New Roman",
},
"upc-formal": {
StylePrefix: "UPC",
DefaultFont: "Calibri",
BodyHalfPoints: 22, // 11pt
Heading1Size: 32, // 16pt
Heading2Size: 28, // 14pt
Heading3Size: 24, // 12pt
Heading1Color: "1F3864", // UPC dark blue
Heading2Color: "1F3864",
Heading3Color: "1F3864",
BlockquoteFont: "Cambria",
},
}
var fixedTime = time.Date(2026, 5, 26, 0, 0, 0, 0, time.UTC)
func buildDocx(cfg presetConfig) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
add := func(name, body string) error {
hdr := &zip.FileHeader{Name: name, Method: zip.Deflate, Modified: fixedTime}
w, err := zw.CreateHeader(hdr)
if err != nil {
return fmt.Errorf("create %s: %w", name, err)
}
if _, err := w.Write([]byte(body)); err != nil {
return fmt.Errorf("write %s: %w", name, err)
}
return nil
}
if err := add("[Content_Types].xml", contentTypesXML); err != nil {
return nil, err
}
if err := add("_rels/.rels", rootRelsXML); err != nil {
return nil, err
}
if err := add("word/_rels/document.xml.rels", documentRelsXML); err != nil {
return nil, err
}
if err := add("word/styles.xml", buildStylesXML(cfg)); err != nil {
return nil, err
}
if err := add("word/document.xml", buildDocumentXML()); err != nil {
return nil, err
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("finalise zip: %w", err)
}
return buf.Bytes(), nil
}
const contentTypesXML = `<?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"/>
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
</Types>`
const rootRelsXML = `<?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>`
// documentRelsXML — empty relationships envelope. The composer's
// hyperlink patch slots fresh <Relationship Type="…/hyperlink"/>
// rows in here at compose time.
const documentRelsXML = `<?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/styles" Target="styles.xml"/>
</Relationships>`
// buildStylesXML emits the stylemap-aligned named styles. Each style
// id matches what the catalog row's section_spec.stylemap declares
// for the corresponding key (paragraph / heading_1/2/3 / list_*
// / blockquote / Hyperlink).
//
// "Hyperlink" is the built-in Word style id the composer's MD walker
// emits on link-child runs (Slice D). Including it here makes the
// blue-underline-link rendering land out of the box.
func buildStylesXML(cfg presetConfig) string {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
b.WriteString(`<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`)
// Document defaults — sets the body font + size for every paragraph
// that doesn't override.
fmt.Fprintf(&b, `<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="%s" w:hAnsi="%s" w:cs="%s"/><w:sz w:val="%d"/></w:rPr></w:rPrDefault></w:docDefaults>`,
cfg.DefaultFont, cfg.DefaultFont, cfg.DefaultFont, cfg.BodyHalfPoints)
// Normal — Word's default paragraph style; nothing fancy.
b.WriteString(`<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>`)
// Body style — body0 alias for the composer's stylemap.paragraph.
fmt.Fprintf(&b, `<w:style w:type="paragraph" w:styleId="%s-Body"><w:name w:val="%s body"/><w:basedOn w:val="Normal"/><w:pPr><w:spacing w:after="120" w:line="276" w:lineRule="auto"/></w:pPr></w:style>`,
cfg.StylePrefix, cfg.StylePrefix)
// Headings — three levels with descending sizes + colours.
fmt.Fprintf(&b, `<w:style w:type="paragraph" w:styleId="%s-Heading1"><w:name w:val="%s heading 1"/><w:basedOn w:val="Normal"/><w:pPr><w:spacing w:before="320" w:after="160"/></w:pPr><w:rPr><w:b/><w:sz w:val="%d"/><w:color w:val="%s"/></w:rPr></w:style>`,
cfg.StylePrefix, cfg.StylePrefix, cfg.Heading1Size, cfg.Heading1Color)
fmt.Fprintf(&b, `<w:style w:type="paragraph" w:styleId="%s-Heading2"><w:name w:val="%s heading 2"/><w:basedOn w:val="Normal"/><w:pPr><w:spacing w:before="240" w:after="120"/></w:pPr><w:rPr><w:b/><w:sz w:val="%d"/><w:color w:val="%s"/></w:rPr></w:style>`,
cfg.StylePrefix, cfg.StylePrefix, cfg.Heading2Size, cfg.Heading2Color)
fmt.Fprintf(&b, `<w:style w:type="paragraph" w:styleId="%s-Heading3"><w:name w:val="%s heading 3"/><w:basedOn w:val="Normal"/><w:pPr><w:spacing w:before="200" w:after="80"/></w:pPr><w:rPr><w:b/><w:sz w:val="%d"/><w:color w:val="%s"/></w:rPr></w:style>`,
cfg.StylePrefix, cfg.StylePrefix, cfg.Heading3Size, cfg.Heading3Color)
// List paragraph styles — same indent as body but with hanging
// indent so the visible "• " / "N. " prefix from the MD walker
// aligns cleanly.
fmt.Fprintf(&b, `<w:style w:type="paragraph" w:styleId="%s-ListBullet"><w:name w:val="%s list bullet"/><w:basedOn w:val="Normal"/><w:pPr><w:ind w:left="360" w:hanging="360"/><w:spacing w:after="60"/></w:pPr></w:style>`,
cfg.StylePrefix, cfg.StylePrefix)
fmt.Fprintf(&b, `<w:style w:type="paragraph" w:styleId="%s-ListNumber"><w:name w:val="%s list number"/><w:basedOn w:val="Normal"/><w:pPr><w:ind w:left="360" w:hanging="360"/><w:spacing w:after="60"/></w:pPr></w:style>`,
cfg.StylePrefix, cfg.StylePrefix)
// Blockquote — italic, indented, optional alternative font.
fmt.Fprintf(&b, `<w:style w:type="paragraph" w:styleId="%s-Quote"><w:name w:val="%s quote"/><w:basedOn w:val="Normal"/><w:pPr><w:ind w:left="720"/><w:spacing w:before="120" w:after="120"/></w:pPr><w:rPr><w:i/><w:rFonts w:ascii="%s" w:hAnsi="%s"/></w:rPr></w:style>`,
cfg.StylePrefix, cfg.StylePrefix, cfg.BlockquoteFont, cfg.BlockquoteFont)
// Hyperlink — Word's built-in character-style id matches what the
// MD walker emits, so the link runs pick up the colour + underline
// automatically.
b.WriteString(`<w:style w:type="character" w:styleId="Hyperlink"><w:name w:val="Hyperlink"/><w:rPr><w:color w:val="0563C1"/><w:u w:val="single"/></w:rPr></w:style>`)
b.WriteString(`</w:styles>`)
return b.String()
}
// buildDocumentXML emits the composer-mode body — 10 default section
// anchors in the design's §6.1 order, nothing else.
func buildDocumentXML() 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>`)
for _, key := range []string{
"letterhead", "caption", "introduction", "requests",
"facts", "legal_argument", "evidence", "exhibits",
"closing", "signature",
} {
anchor(&b, "{{#section:"+key+"}}")
anchor(&b, "{{/section:"+key+"}}")
}
b.WriteString(`</w:body></w:document>`)
return b.String()
}
func anchor(b *strings.Builder, text string) {
b.WriteString(`<w:p><w:r><w:t xml:space="preserve">`)
b.WriteString(text)
b.WriteString(`</w:t></w:r></w:p>`)
}