Compare commits

..

16 Commits

Author SHA1 Message Date
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
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
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
24 changed files with 2068 additions and 64 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

@@ -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,7 +101,7 @@ 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.rules.col.legal_citation">Rechtsgrundlage</th>
<th data-i18n="admin.rules.col.name">Name</th>
<th data-i18n="admin.rules.col.proceeding">Verfahrenstyp</th>

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,10 +1,10 @@
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.
@@ -145,7 +145,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> {
@@ -248,7 +248,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 +392,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 +416,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

@@ -2905,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.",

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,6 +1396,16 @@ 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 — Slice D rich-prose affordances: B/I + H1/H2/H3 +
@@ -1614,6 +1664,214 @@ 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
// ─────────────────────────────────────────────────────────────────────

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

@@ -6294,6 +6294,71 @@ 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;

View File

@@ -109,11 +109,21 @@ ALTER TABLE paliad.deadlines
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.
-- ---------------------------------------------------------------
@@ -304,15 +314,28 @@ CREATE TRIGGER deadline_rules_unified_update
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
SELECT COUNT(*) INTO v_snapshot_count FROM paliad.deadline_rules_pre_140;
-- 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_view_count THEN
RAISE EXCEPTION '[mig 140] FAILED POST: snapshot has % rows, view has % rows — drift between final state and snapshot',
v_snapshot_count, v_view_count;
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
@@ -329,6 +352,97 @@ BEGIN
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, view=% rows, INSTEAD OF triggers active',
v_snapshot_count, v_view_count;
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,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

@@ -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,60 @@ 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.
type adminRuleResponse struct {
*models.DeadlineRule
Code *string `json:"code,omitempty"`
EventKind *string `json:"event_kind,omitempty"`
}
// wrapRuleResponse builds the dual-emit wrapper from a service result.
// Same values, two keys per concept — no semantic change.
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.
func wrapRuleListResponse(rows []models.DeadlineRule) []adminRuleResponse {
out := make([]adminRuleResponse, len(rows))
for i := range rows {
out[i] = wrapRuleResponse(&rows[i])
}
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 +128,8 @@ func handleAdminListRules(w http.ResponseWriter, r *http.Request) {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
adminRuleDeprecationHeaders(w)
writeJSON(w, http.StatusOK, wrapRuleListResponse(rows))
}
// GET /admin/api/rules/{id}
@@ -91,7 +147,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 +165,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 +194,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 +224,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 +247,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 +270,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 +293,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 +486,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

@@ -432,6 +432,11 @@ 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)
@@ -722,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

@@ -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

@@ -553,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

@@ -76,7 +76,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 +107,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 +135,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 +166,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

View File

@@ -368,6 +368,89 @@ func TestComposer_HyperlinkDedupesByURL(t *testing.T) {
}
}
// 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

@@ -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

@@ -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>`)
}