Compare commits

...

3 Commits

Author SHA1 Message Date
mAi
ea29165d2f feat(t-paliad-209): rename Code → Submission Code + add Rechtsgrundlage column
Workstream B frontend sweep — matches mig 098 + the Go sweep. The
/admin/rules surfaces now distinguish submission_code (the rule's
filing identifier within a proceeding, e.g. upc.inf.cfi.soc) from
rule_code (the legal citation, e.g. RoP.013.1).

Admin rules list (/admin/rules):
- Column header renamed "Code" → "Submission Code / Einreichung-Kennung"
- New "Rechtsgrundlage" column shows rule_code alongside the submission
  code; the old single-column fallback (rule_code || code) is gone.
- Filter-search placeholder updated to "Name, Submission Code,
  Rechtsgrundlage…"
- Rule interface: code → submission_code field.

Admin rules edit (/admin/rules/{id}/edit):
- f-code → f-submission-code; input is now read-only with a
  upc.inf.cfi.soc-style placeholder (consistent with the backend
  RulePatch which doesn't allow editing the submission code).
- Labels reframe rule_code as "Rechtsgrundlage (Kurzform)" and
  legal_source as "Rechtsgrundlage (Langform)" so the legal-citation
  pair is named consistently with the list column.
- Rule interface: code → submission_code field.

i18n: new keys admin.rules.col.submission_code,
admin.rules.col.legal_citation, admin.rules.edit.field.submission_code
in both DE + EN; old admin.rules.col.code + admin.rules.edit.field.code
removed.

bun run build clean.
2026-05-18 15:06:18 +02:00
mAi
bc5b3557d0 feat(t-paliad-209): rename DeadlineRule.Code → SubmissionCode across Go layer
Workstream B Go sweep — matches mig 098. Every place the deadline-rules
service reads/writes the per-rule identifier now uses the new column
name and the new struct field. Distinct from rule_code (legal citation)
and from proceeding_types.code (the proceeding's 3-segment code).

Touch points:
- models.DeadlineRule.Code → SubmissionCode (db + json tags renamed
  in lockstep — JSON contract `submission_code` is the new shape).
- deadline_rule_service: ruleColumns SELECT list updated.
- rule_editor_service: CreateRuleInput.Code → SubmissionCode (json tag
  too), INSERT + CloneAsDraft SELECT updated.
- projection_service: lookupRuleByCode → lookupRuleBySubmissionCode
  (SQL WHERE clause + error message); every r.Code / parent.Code /
  rule.Code / first.Code / src.rule.Code read renamed.
- fristenrechner: r.Code / prev.Code / rule.Code reads renamed in
  Calculate (parent-anchor + override-key + computed-by-code map) and
  in CalculateRule's LocalCode emission; the proceeding-code+submission-
  code resolver query uses `submission_code = $2`.
- event_trigger_service / deadline_calculator: r.Code reads renamed.

UIDeadline.Code (the calculator's wire response) is unchanged — that
field is a separate API contract pointing at the same value; renaming
it would force every frontend deadline-renderer through a contract
break that isn't part of this workstream.

Test fixtures updated to the new SubmissionCode field name; live-DB
tests updated to the post-mig-098 prefixed values (`inf.sod` →
`upc.inf.cfi.sod` etc.). New submission_codes_shape_test asserts
every active+published row matches the 4+-segment proceeding-prefixed
shape (sibling of TestProceedingCodeShape; mirrors mig 098 §6.1).

go build ./... clean. go test ./internal/... green.
2026-05-18 15:06:04 +02:00
mAi
bd2c7a217e feat(t-paliad-209): mig 098 prefix submission codes + rename code → submission_code
m's 2026-05-18 call (workstream B): the paliad.deadline_rules.code field
is a SUBMISSION identifier (the filing/event within a proceeding), not
the legal-citation rule code (which lives in rule_code / legal_source).
Two cleanups land in this migration:

1. DATA — prefix every existing submission code with its proceeding
   code so submission codes carry the full hierarchical shape:
       inf.soc       (on upc.inf.cfi)  → upc.inf.cfi.soc
       de_inf.klage  (on de.inf.lg)    → de.inf.lg.klage
       de_inf_bgh.revision (on de.inf.bgh) → de.inf.bgh.revision
   Idempotent: WHERE NOT LIKE pt.code || '.%' skips already-prefixed
   rows so re-running is a no-op.

2. SCHEMA — rename paliad.deadline_rules.code → submission_code so
   future devs don't conflate it with rule_code (legal citation) or
   proceeding_types.code. The rename is guarded by a column-existence
   check, idempotent on a second run.

Drops + recreates the deadline_search materialized view because its
SELECT bakes `dr.code AS rule_local_code` (mig 051 §4); the rebuild
sources from `dr.submission_code` and reproduces every index from mig
051 verbatim.

Backup snapshot table paliad.deadline_rules_pre_098 captures the rows
before the prefix step; serves as the audit anchor and the down's
source.

Hard assertions (§6) gate the migration on:
- every active+published row matches the 4+-segment proceeding-prefixed
  shape regex
- no NULL submission_code on active+published rows
- the column was actually renamed
2026-05-18 15:05:46 +02:00
21 changed files with 667 additions and 108 deletions

View File

@@ -71,16 +71,16 @@ export function renderAdminRulesEdit(): string {
</div>
<div className="admin-rules-edit-row">
<div className="form-field">
<label htmlFor="f-code" data-i18n="admin.rules.edit.field.code">Code</label>
<input type="text" id="f-code" className="admin-rules-input" />
<label htmlFor="f-submission-code" data-i18n="admin.rules.edit.field.submission_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">
<label htmlFor="f-rule-code" data-i18n="admin.rules.edit.field.rule_code">Rule-Code (zit.)</label>
<label htmlFor="f-rule-code" data-i18n="admin.rules.edit.field.rule_code">Rechtsgrundlage (Kurzform)</label>
<input type="text" id="f-rule-code" className="admin-rules-input" placeholder="z. B. RoP.151" />
</div>
<div className="form-field">
<label htmlFor="f-legal-source" data-i18n="admin.rules.edit.field.legal_source">Rechtsgrundlage</label>
<input type="text" id="f-legal-source" className="admin-rules-input" />
<label htmlFor="f-legal-source" data-i18n="admin.rules.edit.field.legal_source">Rechtsgrundlage (Langform)</label>
<input type="text" id="f-legal-source" className="admin-rules-input" placeholder="z. B. UPC.RoP.151" />
</div>
</div>
</fieldset>

View File

@@ -93,7 +93,7 @@ export function renderAdminRulesList(): string {
type="text"
id="rules-filter-search"
className="admin-rules-input"
placeholder="Name, Code, rule_code..."
placeholder="Name, Submission Code, Rechtsgrundlage..."
data-i18n-placeholder="admin.rules.filter.search.placeholder"
autocomplete="off"
/>
@@ -104,7 +104,8 @@ export function renderAdminRulesList(): string {
<table className="entity-table admin-rules-table">
<thead>
<tr>
<th data-i18n="admin.rules.col.code">Code</th>
<th data-i18n="admin.rules.col.submission_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>
<th data-i18n="admin.rules.col.priority">Priorit&auml;t</th>
@@ -113,7 +114,7 @@ export function renderAdminRulesList(): string {
</tr>
</thead>
<tbody id="rules-tbody">
<tr><td colspan={6} className="admin-rules-loading" data-i18n="admin.rules.loading">Lade...</td></tr>
<tr><td colspan={7} className="admin-rules-loading" data-i18n="admin.rules.loading">Lade...</td></tr>
</tbody>
</table>
</div>

View File

@@ -11,7 +11,10 @@ interface Rule {
id: string;
proceeding_type_id?: number | null;
parent_id?: string | null;
code?: string | null;
// submission_code is the proceeding-prefixed identifier of this rule
// within its proceeding (e.g. `upc.inf.cfi.soc`), distinct from
// rule_code (legal citation, e.g. `RoP.013.1`).
submission_code?: string | null;
rule_code?: string | null;
name: string;
name_en: string;
@@ -255,7 +258,7 @@ function populateForm() {
setInput("f-name", rule.name);
setInput("f-name-en", rule.name_en);
setInput("f-description", rule.description ?? "");
setInput("f-code", rule.code ?? "");
setInput("f-submission-code", rule.submission_code ?? "");
setInput("f-rule-code", rule.rule_code ?? "");
setInput("f-legal-source", rule.legal_source ?? "");
setInput("f-proceeding", rule.proceeding_type_id ?? "");

View File

@@ -11,7 +11,10 @@ import { initSidebar } from "./sidebar";
interface Rule {
id: string;
proceeding_type_id?: number | null;
code?: string | null;
// submission_code is the proceeding-prefixed identifier of this rule
// within its proceeding (e.g. `upc.inf.cfi.soc`), distinct from
// rule_code (the legal citation, e.g. `RoP.013.1`).
submission_code?: string | null;
rule_code?: string | null;
name: string;
name_en: string;
@@ -219,7 +222,8 @@ function renderRulesTable() {
const name = (r: Rule) => (getLang() === "en" ? r.name_en : r.name) || r.name;
tbody.innerHTML = rules.map((r) => `
<tr data-row-id="${esc(r.id)}" class="admin-rules-row">
<td class="admin-rules-col-code"><code>${esc(r.rule_code || r.code || "")}</code></td>
<td class="admin-rules-col-code"><code>${esc(r.submission_code || "")}</code></td>
<td class="admin-rules-col-legal"><code>${esc(r.rule_code || "")}</code></td>
<td>${esc(name(r))}</td>
<td>${esc(proceedingLabel(r.proceeding_type_id ?? null))}</td>
<td><span class="admin-rules-priority admin-rules-priority-${esc(r.priority)}">${esc(priorityLabel(r.priority))}</span></td>

View File

@@ -2420,9 +2420,10 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.filter.lifecycle": "Lifecycle",
"admin.rules.filter.lifecycle.any": "Alle",
"admin.rules.filter.search": "Suche",
"admin.rules.filter.search.placeholder": "Name, Code, rule_code…",
"admin.rules.filter.search.placeholder": "Name, Submission Code, Rechtsgrundlage…",
"admin.rules.col.code": "Code",
"admin.rules.col.submission_code": "Submission Code / Einreichung-Kennung",
"admin.rules.col.legal_citation": "Rechtsgrundlage",
"admin.rules.col.name": "Name",
"admin.rules.col.proceeding": "Verfahrenstyp",
"admin.rules.col.priority": "Priorität",
@@ -2485,9 +2486,9 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.edit.field.name": "Name (DE)",
"admin.rules.edit.field.name_en": "Name (EN)",
"admin.rules.edit.field.description": "Beschreibung",
"admin.rules.edit.field.code": "Code",
"admin.rules.edit.field.rule_code": "Rule-Code (zit.)",
"admin.rules.edit.field.legal_source": "Rechtsgrundlage",
"admin.rules.edit.field.submission_code": "Submission Code / Einreichung-Kennung",
"admin.rules.edit.field.rule_code": "Rechtsgrundlage (Kurzform)",
"admin.rules.edit.field.legal_source": "Rechtsgrundlage (Langform)",
"admin.rules.edit.field.proceeding": "Verfahrenstyp",
"admin.rules.edit.field.proceeding.none": "—",
"admin.rules.edit.field.trigger": "Trigger-Ereignis",
@@ -4969,9 +4970,10 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.filter.lifecycle": "Lifecycle",
"admin.rules.filter.lifecycle.any": "Any",
"admin.rules.filter.search": "Search",
"admin.rules.filter.search.placeholder": "Name, code, rule_code…",
"admin.rules.filter.search.placeholder": "Name, submission code, legal citation…",
"admin.rules.col.code": "Code",
"admin.rules.col.submission_code": "Submission code",
"admin.rules.col.legal_citation": "Legal citation",
"admin.rules.col.name": "Name",
"admin.rules.col.proceeding": "Proceeding type",
"admin.rules.col.priority": "Priority",
@@ -5034,9 +5036,9 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.edit.field.name": "Name (DE)",
"admin.rules.edit.field.name_en": "Name (EN)",
"admin.rules.edit.field.description": "Description",
"admin.rules.edit.field.code": "Code",
"admin.rules.edit.field.rule_code": "Rule code (cit.)",
"admin.rules.edit.field.legal_source": "Legal source",
"admin.rules.edit.field.submission_code": "Submission code",
"admin.rules.edit.field.rule_code": "Legal citation (short form)",
"admin.rules.edit.field.legal_source": "Legal citation (long form)",
"admin.rules.edit.field.proceeding": "Proceeding type",
"admin.rules.edit.field.proceeding.none": "—",
"admin.rules.edit.field.trigger": "Trigger event",

View File

@@ -268,12 +268,13 @@ export type I18nKey =
| "admin.partner_units.new.heading"
| "admin.partner_units.subtitle"
| "admin.partner_units.title"
| "admin.rules.col.code"
| "admin.rules.col.legal_citation"
| "admin.rules.col.lifecycle"
| "admin.rules.col.modified"
| "admin.rules.col.name"
| "admin.rules.col.priority"
| "admin.rules.col.proceeding"
| "admin.rules.col.submission_code"
| "admin.rules.edit.action.archive"
| "admin.rules.edit.action.archive.error"
| "admin.rules.edit.action.archive.ok"
@@ -309,7 +310,6 @@ export type I18nKey =
| "admin.rules.edit.field.alt_duration_value"
| "admin.rules.edit.field.alt_rule_code"
| "admin.rules.edit.field.anchor_alt"
| "admin.rules.edit.field.code"
| "admin.rules.edit.field.combine_op"
| "admin.rules.edit.field.concept"
| "admin.rules.edit.field.condition.valid"
@@ -335,6 +335,7 @@ export type I18nKey =
| "admin.rules.edit.field.spawn_label"
| "admin.rules.edit.field.spawn_proceeding"
| "admin.rules.edit.field.spawn_proceeding.none"
| "admin.rules.edit.field.submission_code"
| "admin.rules.edit.field.timing"
| "admin.rules.edit.field.trigger"
| "admin.rules.edit.field.trigger.none"

View File

@@ -0,0 +1,162 @@
-- Reverses mig 098. Restores the pre-098 submission codes on
-- paliad.deadline_rules, renames the column back to `code`, recreates
-- the deadline_search matview against the restored column, then drops
-- the snapshot table.
--
-- audit_reason wrapper required by the mig 079 audit trigger.
SELECT set_config(
'paliad.audit_reason',
'mig 098 (down): revert t-paliad-209 workstream B — restore paliad.deadline_rules.code values from deadline_rules_pre_098 snapshot and rename submission_code → code; matview deadline_search rebuilt against the restored column.',
true);
-- =============================================================================
-- 1. Drop the matview so the column rename can succeed.
-- =============================================================================
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
-- =============================================================================
-- 2. Rename the column back. Guarded so a down run on a DB where the
-- up never ran (or where the column is already named `code`) is a
-- no-op rather than an error.
-- =============================================================================
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = 'deadline_rules'
AND column_name = 'submission_code'
) THEN
ALTER TABLE paliad.deadline_rules
RENAME COLUMN submission_code TO code;
END IF;
END $$;
-- =============================================================================
-- 3. Restore code values from the pre_098 snapshot. The snapshot was
-- captured at the first up-migration run; if the table is missing
-- (down run before up), the restore is a no-op.
-- =============================================================================
DO $$
DECLARE
v_snap_exists boolean;
BEGIN
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'paliad'
AND table_name = 'deadline_rules_pre_098'
) INTO v_snap_exists;
IF NOT v_snap_exists THEN
RAISE NOTICE
'mig 098 (down): snapshot table paliad.deadline_rules_pre_098 missing — nothing to restore';
RETURN;
END IF;
UPDATE paliad.deadline_rules dr
SET code = snap.code
FROM paliad.deadline_rules_pre_098 snap
WHERE dr.id = snap.id
AND dr.code <> snap.code;
END $$;
-- =============================================================================
-- 4. Recreate the deadline_search matview against the restored column.
-- Identical body to mig 051 §4, reproduced here so the down leaves
-- the schema in the same shape mig 051 created.
-- =============================================================================
CREATE MATERIALIZED VIEW paliad.deadline_search AS
SELECT
'rule'::text AS kind,
'r:' || 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 AS jurisdiction,
pt.display_order AS proceeding_display_order,
dr.code AS rule_local_code,
dr.name AS rule_name_de,
dr.name_en AS rule_name_en,
dr.legal_source AS legal_source,
dr.rule_code AS rule_code,
dr.duration_value,
dr.duration_unit,
dr.timing,
COALESCE(dr.primary_party, dc.party) AS effective_party
FROM paliad.deadline_rules 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'
UNION ALL
SELECT
'trigger'::text,
't:' || te.id::text,
dc.id,
dc.slug,
dc.name_de,
dc.name_en,
dc.description,
dc.aliases,
dc.party,
dc.category,
dc.sort_order,
NULL::uuid,
te.id,
NULL::text,
NULL::text,
NULL::text,
'cross-cutting'::text,
9999::int AS proceeding_display_order,
te.code,
te.name_de,
te.name,
NULL::text,
NULL::text,
NULL::int,
NULL::text,
NULL::text,
dc.party
FROM paliad.trigger_events te
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
WHERE te.is_active;
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);
-- =============================================================================
-- 5. Drop the snapshot table so a re-applied up captures a fresh
-- snapshot of the current state.
-- =============================================================================
DROP TABLE IF EXISTS paliad.deadline_rules_pre_098;

View File

@@ -0,0 +1,268 @@
-- t-paliad-209 / workstream B — submission-code prefix + rename.
--
-- m's 2026-05-18 call: the `paliad.deadline_rules.code` field is a
-- SUBMISSION identifier (the event/filing within a proceeding), not the
-- legal-citation rule code (which lives in `rule_code` / `legal_source`).
-- Two cleanups land here:
--
-- 1. DATA — prefix every existing submission code with its proceeding
-- code so submission codes carry the full hierarchical shape
-- (e.g. `inf.soc` on `upc.inf.cfi` → `upc.inf.cfi.soc`,
-- `de_inf.klage` on `de.inf.lg` → `de.inf.lg.klage`).
-- Algorithm: keep the proceeding-code prefix as-is, strip the
-- old single-segment prefix (everything before the first dot in
-- `dr.code`) and replace it with the proceeding's full `code`.
--
-- 2. SCHEMA — rename `paliad.deadline_rules.code` → `submission_code`
-- so future devs don't conflate it with `rule_code` (legal
-- citation) or `proceeding_types.code`. Explicit name encodes the
-- semantic taxonomy ratified in
-- docs/design-proceeding-code-taxonomy-2026-05-18.md §0.1.
--
-- Materialized-view dependency: `paliad.deadline_search` (mig 051) has
-- `dr.code AS rule_local_code` baked into its SELECT list. Postgres
-- rejects RENAME COLUMN when a matview's column list still resolves
-- via the old name — so the matview is dropped before the rename and
-- recreated against `submission_code` afterwards, with every index
-- reproduced. The mig 047 / 051 indexes are reproduced verbatim here.
--
-- IDs and FKs are untouched. `deadline_rules.proceeding_type_id` /
-- `parent_id` / `spawn_proceeding_type_id` reference ids; no
-- code-string FK exists on submission codes (the parent_id chain is on
-- UUID `id`, not the code string), so the data UPDATE doesn't risk
-- breaking joins.
--
-- Idempotent:
-- * The data UPDATE is gated `WHERE dr.code NOT LIKE pt.code || '.%'`
-- — rows already prefixed with their proceeding code (i.e. the
-- migration ran before) are skipped.
-- * The rename is wrapped in a DO block that checks column existence,
-- so a second run is a no-op.
-- * Snapshot table uses CREATE TABLE IF NOT EXISTS.
-- * Matview drop/recreate is DROP IF EXISTS + CREATE.
--
-- audit_reason wrapper required by the mig 079 audit trigger.
SELECT set_config(
'paliad.audit_reason',
'mig 098: t-paliad-209 workstream B — prefix every paliad.deadline_rules.code with its proceeding code, then rename code → submission_code; matview deadline_search rebuilt against the new column. See docs/design-proceeding-code-taxonomy-2026-05-18.md and the t-paliad-209 task brief.',
true);
-- =============================================================================
-- 1. Backup snapshot of paliad.deadline_rules BEFORE the prefix + rename.
-- Captures the rows as they are; serves as the source for the down
-- migration and the permanent audit anchor.
-- =============================================================================
CREATE TABLE IF NOT EXISTS paliad.deadline_rules_pre_098 AS
SELECT *, now() AS snapshotted_at
FROM paliad.deadline_rules;
COMMENT ON TABLE paliad.deadline_rules_pre_098 IS
'Snapshot of paliad.deadline_rules taken before mig 098 prefixed '
'every `code` with its proceeding code and renamed the column to '
'`submission_code` (t-paliad-209, 2026-05-18). Source-of-truth '
'for the down migration; persists post-rename as the permanent '
'audit record.';
-- =============================================================================
-- 2. Drop the deadline_search materialized view. It bakes `dr.code AS
-- rule_local_code` into its SELECT list (mig 051 §4), and Postgres
-- refuses to rename a column that a matview's column list still
-- resolves via the old name. The matview is recreated verbatim in §5
-- against the renamed column.
-- =============================================================================
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
-- =============================================================================
-- 3. Data UPDATE — prefix every submission code with its proceeding
-- code. Algorithm:
-- * proceeding_code = pt.code
-- * suffix = portion of dr.code after the first '.'
-- * new code = proceeding_code || '.' || suffix
--
-- regexp_replace('inf.soc', '^[^.]+\.', '') = 'soc'
-- regexp_replace('de_inf_bgh.revision', ...) = 'revision'
--
-- The WHERE clause skips rows that already start with `pt.code || '.'`
-- so re-running the migration is a no-op on already-prefixed rows.
-- Archived rows (proceeding `_archived_litigation`) get the same
-- treatment — they end up as `_archived_litigation.<suffix>`. The
-- shape regex in §6 only inspects active+published rows, so the
-- archived form sits outside the constraint by design.
-- =============================================================================
UPDATE paliad.deadline_rules dr
SET code = pt.code || '.' || regexp_replace(dr.code, '^[^.]+\.', '')
FROM paliad.proceeding_types pt
WHERE pt.id = dr.proceeding_type_id
AND dr.code IS NOT NULL
AND position('.' in dr.code) > 0
AND dr.code NOT LIKE pt.code || '.%';
-- =============================================================================
-- 4. Rename the column. Guarded in a DO block so a second run (e.g. a
-- fresh DB built up to mig 098 from an empty schema, or a manual
-- re-apply) is a no-op rather than a hard error.
-- =============================================================================
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = 'deadline_rules'
AND column_name = 'code'
) THEN
ALTER TABLE paliad.deadline_rules
RENAME COLUMN code TO submission_code;
END IF;
END $$;
-- =============================================================================
-- 5. Recreate the deadline_search matview against the renamed column.
-- Column list reproduced verbatim from mig 051 §4 with the single
-- edit: `dr.code AS rule_local_code` → `dr.submission_code AS
-- rule_local_code`. All indexes from mig 051 are reproduced too.
-- =============================================================================
CREATE MATERIALIZED VIEW paliad.deadline_search AS
SELECT
'rule'::text AS kind,
'r:' || 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 AS 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 AS legal_source,
dr.rule_code AS rule_code,
dr.duration_value,
dr.duration_unit,
dr.timing,
COALESCE(dr.primary_party, dc.party) AS effective_party
FROM paliad.deadline_rules 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'
UNION ALL
SELECT
'trigger'::text,
't:' || te.id::text,
dc.id,
dc.slug,
dc.name_de,
dc.name_en,
dc.description,
dc.aliases,
dc.party,
dc.category,
dc.sort_order,
NULL::uuid,
te.id,
NULL::text,
NULL::text,
NULL::text,
'cross-cutting'::text,
9999::int AS proceeding_display_order,
te.code,
te.name_de,
te.name,
NULL::text,
NULL::text,
NULL::int,
NULL::text,
NULL::text,
dc.party
FROM paliad.trigger_events te
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
WHERE te.is_active;
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);
-- =============================================================================
-- 6. Hard assertions. Half-applied migrations would leave the rule
-- corpus inconsistent; gate on the shape of every active+published
-- row and on column existence so this fails loudly rather than
-- leaving the schema in a half-renamed state.
-- =============================================================================
DO $$
DECLARE
v_bad_shape integer;
v_null_codes integer;
v_col_exists boolean;
BEGIN
-- 6.1 Every active+published row has the proceeding-code-prefixed
-- 4+-segment shape. Archived rows (`_archived_litigation` ones)
-- keep their shorter shape by design — they're carved out.
SELECT count(*) INTO v_bad_shape
FROM paliad.deadline_rules
WHERE is_active = true
AND lifecycle_state = 'published'
AND submission_code !~ '^[a-z_]+\.[a-z_]+\.[a-z_]+\.[a-z_]+(\..*)?$';
IF v_bad_shape <> 0 THEN
RAISE EXCEPTION
'mig 098: expected every active+published deadline_rules row to match the 4+-segment submission_code shape, got % violators',
v_bad_shape;
END IF;
-- 6.2 No NULL submission_code on active+published rows. The column
-- is nullable for legacy reasons, but every live row should
-- carry a code after the prefix step.
SELECT count(*) INTO v_null_codes
FROM paliad.deadline_rules
WHERE is_active = true
AND lifecycle_state = 'published'
AND submission_code IS NULL;
IF v_null_codes <> 0 THEN
RAISE EXCEPTION
'mig 098: expected 0 NULL submission_code on active+published rows, got %',
v_null_codes;
END IF;
-- 6.3 Column was actually renamed. Catches the case where the DO
-- guard in §4 short-circuited because the schema hadn't yet
-- been migrated.
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = 'deadline_rules'
AND column_name = 'submission_code'
) INTO v_col_exists;
IF NOT v_col_exists THEN
RAISE EXCEPTION
'mig 098: column paliad.deadline_rules.submission_code missing after rename — half-applied migration';
END IF;
END $$;

View File

@@ -467,7 +467,7 @@ type DeadlineRule struct {
ID uuid.UUID `db:"id" json:"id"`
ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"`
ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"`
Code *string `db:"code" json:"code,omitempty"`
SubmissionCode *string `db:"submission_code" json:"submission_code,omitempty"`
Name string `db:"name" json:"name"`
NameEN string `db:"name_en" json:"name_en"`
Description *string `db:"description" json:"description,omitempty"`

View File

@@ -72,8 +72,8 @@ func (c *DeadlineCalculator) CalculateFromRules(eventDate time.Time, rules []mod
}
code := ""
if r.Code != nil {
code = *r.Code
if r.SubmissionCode != nil {
code = *r.SubmissionCode
}
results = append(results, CalculatedDeadline{

View File

@@ -120,7 +120,7 @@ func TestCalculateFromRules_BatchAndZeroDuration(t *testing.T) {
rules := []models.DeadlineRule{
{ID: uuid.New(), Name: "Filing", DurationValue: 0, DurationUnit: "months"},
{ID: uuid.New(), Name: "Defence", Code: ptr("inf.sod"), DurationValue: 3, DurationUnit: "months", Timing: ptr("after")},
{ID: uuid.New(), Name: "Defence", SubmissionCode: ptr("upc.inf.cfi.sod"), DurationValue: 3, DurationUnit: "months", Timing: ptr("after")},
}
in := time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC)
results := calc.CalculateFromRules(in, rules, "DE", "UPC")
@@ -136,8 +136,8 @@ func TestCalculateFromRules_BatchAndZeroDuration(t *testing.T) {
if results[1].DueDate != "2026-04-13" {
t.Errorf("3-month rule: got %s, want 2026-04-13", results[1].DueDate)
}
if results[1].RuleCode != "inf.sod" {
t.Errorf("rule code: got %q, want inf.sod", results[1].RuleCode)
if results[1].RuleCode != "upc.inf.cfi.sod" {
t.Errorf("rule code: got %q, want upc.inf.cfi.sod", results[1].RuleCode)
}
}

View File

@@ -27,7 +27,7 @@ func NewDeadlineRuleService(db *sqlx.DB) *DeadlineRuleService {
// condition_flag, and condition_rule_id — they were superseded by
// priority / condition_expr / is_court_set in the unified Phase 3
// shape. The SELECT now reads only the live schema.
const ruleColumns = `id, proceeding_type_id, parent_id, code, name, name_en,
const ruleColumns = `id, proceeding_type_id, parent_id, submission_code, name, name_en,
description, primary_party, event_type, duration_value,
duration_unit, timing, rule_code, deadline_notes, deadline_notes_en, sequence_order,
alt_duration_value, alt_duration_unit, alt_rule_code,

View File

@@ -166,8 +166,8 @@ func (s *EventTriggerService) Trigger(ctx context.Context, input EventTriggerInp
WasAdjusted: wasAdj,
AdjustmentReason: reason,
}
if r.Code != nil {
d.Code = *r.Code
if r.SubmissionCode != nil {
d.Code = *r.SubmissionCode
}
if r.PrimaryParty != nil {
d.Party = *r.PrimaryParty

View File

@@ -272,8 +272,8 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
Priority: r.Priority,
ConditionExpr: json.RawMessage(r.ConditionExpr),
}
if r.Code != nil {
d.Code = *r.Code
if r.SubmissionCode != nil {
d.Code = *r.SubmissionCode
}
if r.PrimaryParty != nil {
d.Party = *r.PrimaryParty
@@ -300,8 +300,8 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
if r.ParentID != nil && courtSet[*r.ParentID] {
for _, prev := range rules {
if prev.ID == *r.ParentID {
if prev.Code != nil {
if _, ok := overrideDates[*prev.Code]; ok {
if prev.SubmissionCode != nil {
if _, ok := overrideDates[*prev.SubmissionCode]; ok {
parentOverridden = true
}
}
@@ -328,12 +328,12 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
// court-set placeholder and the parent-inheritance.
if r.DurationValue == 0 {
// User override always wins.
if r.Code != nil {
if ov, ok := overrideDates[*r.Code]; ok {
if r.SubmissionCode != nil {
if ov, ok := overrideDates[*r.SubmissionCode]; ok {
d.DueDate = ov.Format("2006-01-02")
d.OriginalDate = d.DueDate
d.IsOverridden = true
computed[*r.Code] = ov
computed[*r.SubmissionCode] = ov
deadlines = append(deadlines, d)
continue
}
@@ -344,8 +344,8 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
d.IsRootEvent = true
d.DueDate = triggerDateStr
d.OriginalDate = triggerDateStr
if r.Code != nil {
computed[*r.Code] = triggerDate
if r.SubmissionCode != nil {
computed[*r.SubmissionCode] = triggerDate
}
} else if r.ParentID != nil && !r.IsCourtSet {
// Bucket 4: filed-with-parent. Inherit parent's date.
@@ -365,11 +365,11 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
var haveParentDate bool
for _, prev := range rules {
if prev.ID == *r.ParentID {
if prev.Code != nil {
if ov, ok := overrideDates[*prev.Code]; ok {
if prev.SubmissionCode != nil {
if ov, ok := overrideDates[*prev.SubmissionCode]; ok {
parentDate = ov
haveParentDate = true
} else if ref, ok := computed[*prev.Code]; ok {
} else if ref, ok := computed[*prev.SubmissionCode]; ok {
parentDate = ref
haveParentDate = true
}
@@ -380,8 +380,8 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
if haveParentDate {
d.DueDate = parentDate.Format("2006-01-02")
d.OriginalDate = d.DueDate
if r.Code != nil {
computed[*r.Code] = parentDate
if r.SubmissionCode != nil {
computed[*r.SubmissionCode] = parentDate
}
} else {
// Parent not yet computed (defensive — shouldn't
@@ -442,14 +442,14 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
// Linear scan is fine — rule trees are < 20 entries.
for _, prev := range rules {
if prev.ID == *r.ParentID {
if prev.Code != nil {
if prev.SubmissionCode != nil {
// User override on the parent rule wins over
// the calculated date — lets the user redirect
// downstream from a real (court-extended,
// court-set) date.
if ov, ok := overrideDates[*prev.Code]; ok {
if ov, ok := overrideDates[*prev.SubmissionCode]; ok {
baseDate = ov
} else if ref, ok := computed[*prev.Code]; ok {
} else if ref, ok := computed[*prev.SubmissionCode]; ok {
baseDate = ref
}
}
@@ -484,14 +484,14 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
// the user's date. Skip holiday rollover — the user's date is
// authoritative. Downstream rules that chain off this rule will
// see the override via the parent-anchor lookup above.
if r.Code != nil {
if ov, ok := overrideDates[*r.Code]; ok {
if r.SubmissionCode != nil {
if ov, ok := overrideDates[*r.SubmissionCode]; ok {
d.OriginalDate = ov.Format("2006-01-02")
d.DueDate = ov.Format("2006-01-02")
d.WasAdjusted = false
d.AdjustmentReason = nil
d.IsOverridden = true
computed[*r.Code] = ov
computed[*r.SubmissionCode] = ov
deadlines = append(deadlines, d)
continue
}
@@ -527,8 +527,8 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
d.DueDate = adjusted.Format("2006-01-02")
d.WasAdjusted = wasAdj
d.AdjustmentReason = reason
if r.Code != nil {
computed[*r.Code] = adjusted
if r.SubmissionCode != nil {
computed[*r.SubmissionCode] = adjusted
}
deadlines = append(deadlines, d)
}
@@ -661,8 +661,8 @@ func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRu
},
TriggerDate: params.TriggerDate,
}
if rule.Code != nil {
out.Rule.LocalCode = *rule.Code
if rule.SubmissionCode != nil {
out.Rule.LocalCode = *rule.SubmissionCode
}
if rule.RuleCode != nil {
out.Rule.RuleRef = *rule.RuleCode
@@ -797,7 +797,7 @@ func (s *FristenrechnerService) resolveRule(ctx context.Context, params CalcRule
err = s.rules.db.GetContext(ctx, &rule,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
WHERE proceeding_type_id = $1 AND code = $2 AND is_active = true`,
WHERE proceeding_type_id = $1 AND submission_code = $2 AND is_active = true`,
pt.ID, params.RuleLocalCode)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil, ErrUnknownRule
@@ -1206,8 +1206,8 @@ func (s *FristenrechnerService) calculateByTriggerEvent(
WasAdjusted: wasAdj,
AdjustmentReason: reason,
}
if r.Code != nil {
d.Code = *r.Code
if r.SubmissionCode != nil {
d.Code = *r.SubmissionCode
}
if r.PrimaryParty != nil {
d.Party = *r.PrimaryParty

View File

@@ -86,18 +86,18 @@ func TestCalculateRule(t *testing.T) {
courts := NewCourtService(pool)
svc := NewFristenrechnerService(rules, holidays, courts)
t.Run("plain rule calc — upc.inf.cfi inf.sod, R.23(1), 3 months", func(t *testing.T) {
t.Run("plain rule calc — upc.inf.cfi.sod, R.23(1), 3 months", func(t *testing.T) {
// 2026-01-15 + 3 months = 2026-04-15. No vacation overlap.
got, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "inf.sod",
RuleLocalCode: "upc.inf.cfi.sod",
TriggerDate: "2026-01-15",
})
if err != nil {
t.Fatalf("CalculateRule: %v", err)
}
if got.IsCourtSet {
t.Errorf("inf.sod is not court-set; got IsCourtSet=true")
t.Errorf("upc.inf.cfi.sod is not court-set; got IsCourtSet=true")
}
if got.DueDate != "2026-04-15" {
t.Errorf("dueDate = %q, want 2026-04-15", got.DueDate)
@@ -113,14 +113,14 @@ func TestCalculateRule(t *testing.T) {
t.Run("court-determined rule → IsCourtSet=true, no dueDate", func(t *testing.T) {
got, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "inf.decision",
RuleLocalCode: "upc.inf.cfi.decision",
TriggerDate: "2026-01-15",
})
if err != nil {
t.Fatalf("CalculateRule: %v", err)
}
if !got.IsCourtSet {
t.Errorf("inf.decision should be court-set; got IsCourtSet=false")
t.Errorf("upc.inf.cfi.decision should be court-set; got IsCourtSet=false")
}
if got.DueDate != "" {
t.Errorf("court-set dueDate = %q, want empty", got.DueDate)
@@ -128,11 +128,12 @@ func TestCalculateRule(t *testing.T) {
})
t.Run("flag-conditional rule surfaces FlagsRequired even when not satisfied", func(t *testing.T) {
// inf.def_to_ccr requires with_ccr. Without the flag, FlagsRequired
// is still surfaced so the UI can render the checkbox.
// upc.inf.cfi.def_to_ccr requires with_ccr. Without the flag,
// FlagsRequired is still surfaced so the UI can render the
// checkbox.
got, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "inf.def_to_ccr",
RuleLocalCode: "upc.inf.cfi.def_to_ccr",
TriggerDate: "2026-01-15",
})
if err != nil {
@@ -149,7 +150,7 @@ func TestCalculateRule(t *testing.T) {
t.Run("flag-conditional rule with flag → FlagsApplied populated", func(t *testing.T) {
got, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "inf.def_to_ccr",
RuleLocalCode: "upc.inf.cfi.def_to_ccr",
TriggerDate: "2026-01-15",
Flags: []string{"with_ccr"},
})
@@ -164,7 +165,7 @@ func TestCalculateRule(t *testing.T) {
t.Run("missing TriggerDate → error", func(t *testing.T) {
_, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "inf.sod",
RuleLocalCode: "upc.inf.cfi.sod",
TriggerDate: "",
})
if err == nil {

View File

@@ -183,10 +183,10 @@ func TestRuleNameInLang(t *testing.T) {
func TestPredecessorMissingError(t *testing.T) {
pme := &PredecessorMissingError{
MissingRuleCode: "inf.soc",
MissingRuleCode: "upc.inf.cfi.soc",
MissingRuleNameDE: "Klageschrift",
MissingRuleNameEN: "Statement of Claim",
RequestedRuleCode: "inf.sod",
RequestedRuleCode: "upc.inf.cfi.sod",
RequestedRuleNameDE: "Klageerwiderung",
RequestedRuleNameEN: "Statement of Defence",
}
@@ -233,14 +233,14 @@ func TestAnnotateDependsOn(t *testing.T) {
socID := uuid.New()
sodID := uuid.New()
replyID := uuid.New()
socCode := "inf.soc"
sodCode := "inf.sod"
replyCode := "inf.reply"
socCode := "upc.inf.cfi.soc"
sodCode := "upc.inf.cfi.sod"
replyCode := "upc.inf.cfi.reply"
rules := []models.DeadlineRule{
{ID: socID, Code: &socCode, Name: "Klageschrift", NameEN: "Statement of Claim"},
{ID: sodID, ParentID: &socID, Code: &sodCode, Name: "Klageerwiderung", NameEN: "Statement of Defence"},
{ID: replyID, ParentID: &sodID, Code: &replyCode, Name: "Replik", NameEN: "Reply"},
{ID: socID, SubmissionCode: &socCode, Name: "Klageschrift", NameEN: "Statement of Claim"},
{ID: sodID, ParentID: &socID, SubmissionCode: &sodCode, Name: "Klageerwiderung", NameEN: "Statement of Defence"},
{ID: replyID, ParentID: &sodID, SubmissionCode: &replyCode, Name: "Replik", NameEN: "Reply"},
}
socDate := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)

View File

@@ -1126,11 +1126,11 @@ func (s *ProjectionService) expandCrossProceedingSpawns(
Title: title,
DependsOnRuleName: src.rule.Name,
}
if first.Code != nil {
ev.RuleCode = *first.Code
if first.SubmissionCode != nil {
ev.RuleCode = *first.SubmissionCode
}
if src.rule.Code != nil {
ev.DependsOnRuleCode = *src.rule.Code
if src.rule.SubmissionCode != nil {
ev.DependsOnRuleCode = *src.rule.SubmissionCode
}
idCopy := first.ID
ev.DeadlineRuleID = &idCopy
@@ -1227,8 +1227,8 @@ func (s *ProjectionService) collectActualsForOverrides(
}
if d.RuleID != nil {
ruleIDsWithActual[*d.RuleID] = true
if r, ok := ruleByID[*d.RuleID]; ok && r.Code != nil {
overrides[*r.Code] = anchor.Format("2006-01-02")
if r, ok := ruleByID[*d.RuleID]; ok && r.SubmissionCode != nil {
overrides[*r.SubmissionCode] = anchor.Format("2006-01-02")
}
}
if d.RuleCode != nil && *d.RuleCode != "" {
@@ -1253,8 +1253,8 @@ func (s *ProjectionService) collectActualsForOverrides(
continue
}
ruleIDsWithActual[*a.RuleID] = true
if r, ok := ruleByID[*a.RuleID]; ok && r.Code != nil {
overrides[*r.Code] = a.StartAt.UTC().Format("2006-01-02")
if r, ok := ruleByID[*a.RuleID]; ok && r.SubmissionCode != nil {
overrides[*r.SubmissionCode] = a.StartAt.UTC().Format("2006-01-02")
}
}
return nil
@@ -1305,10 +1305,10 @@ func (s *ProjectionService) hydrateAppointmentRuleIDs(ctx context.Context, proje
// which the user fixes by clicking "Datum setzen" on the SoC row.
func (s *ProjectionService) deriveTriggerDate(rules []models.DeadlineRule, overrides map[string]string) string {
for _, r := range rules {
if r.ParentID != nil || r.Code == nil {
if r.ParentID != nil || r.SubmissionCode == nil {
continue
}
if anchor, ok := overrides[*r.Code]; ok {
if anchor, ok := overrides[*r.SubmissionCode]; ok {
return anchor
}
}
@@ -1578,7 +1578,7 @@ func (s *ProjectionService) RecordAnchor(ctx context.Context, userID, projectID
return nil, err
}
rule, err := s.lookupRuleByCode(ctx, *proj.ProceedingTypeID, in.RuleCode)
rule, err := s.lookupRuleBySubmissionCode(ctx, *proj.ProceedingTypeID, in.RuleCode)
if err != nil {
return nil, err
}
@@ -1598,8 +1598,8 @@ func (s *ProjectionService) RecordAnchor(ctx context.Context, userID, projectID
}
if !anchored {
parentCode := ""
if parentRule.Code != nil {
parentCode = *parentRule.Code
if parentRule.SubmissionCode != nil {
parentCode = *parentRule.SubmissionCode
}
return nil, &PredecessorMissingError{
MissingRuleCode: parentCode,
@@ -1662,19 +1662,20 @@ func (s *ProjectionService) RecordRuleSkipped(ctx context.Context, userID, proje
return nil
}
// lookupRuleByCode resolves (proceeding_type_id, code) → DeadlineRule.
func (s *ProjectionService) lookupRuleByCode(ctx context.Context, ptID int, code string) (*models.DeadlineRule, error) {
// lookupRuleBySubmissionCode resolves (proceeding_type_id, submission_code)
// → DeadlineRule.
func (s *ProjectionService) lookupRuleBySubmissionCode(ctx context.Context, ptID int, code string) (*models.DeadlineRule, error) {
var rule models.DeadlineRule
err := s.db.GetContext(ctx, &rule,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
WHERE proceeding_type_id = $1 AND code = $2 AND is_active = true`,
WHERE proceeding_type_id = $1 AND submission_code = $2 AND is_active = true`,
ptID, code)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("%w: unknown rule_code %q", ErrInvalidInput, code)
return nil, fmt.Errorf("%w: unknown submission_code %q", ErrInvalidInput, code)
}
if err != nil {
return nil, fmt.Errorf("lookup rule by code: %w", err)
return nil, fmt.Errorf("lookup rule by submission_code: %w", err)
}
return &rule, nil
}
@@ -1770,8 +1771,8 @@ func (s *ProjectionService) upsertAnchorDeadline(ctx context.Context, userID, pr
id := uuid.New()
title := rule.Name
ruleCode := ""
if rule.Code != nil {
ruleCode = *rule.Code
if rule.SubmissionCode != nil {
ruleCode = *rule.SubmissionCode
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO paliad.deadlines
@@ -1883,8 +1884,8 @@ func (s *ProjectionService) annotateDependsOn(rows []TimelineEvent, rules []mode
if !ok {
continue
}
if parent.Code != nil {
ev.DependsOnRuleCode = *parent.Code
if parent.SubmissionCode != nil {
ev.DependsOnRuleCode = *parent.SubmissionCode
}
ev.DependsOnRuleName = ruleNameInLang(parent, lang)
if dt, ok := dateByRuleID[parent.ID]; ok && !dt.IsZero() {

View File

@@ -331,7 +331,7 @@ func TestExpandCrossProceedingSpawns(t *testing.T) {
// the seed uses the live post-Slice-9 column set.
_, err := pool.ExecContext(ctx,
`INSERT INTO paliad.deadline_rules
(id, proceeding_type_id, name, name_en, code, duration_value, duration_unit,
(id, proceeding_type_id, name, name_en, submission_code, duration_value, duration_unit,
timing, is_court_set, is_spawn,
spawn_proceeding_type_id, sequence_order, is_active, priority,
lifecycle_state, created_at, updated_at)

View File

@@ -110,7 +110,7 @@ type CreateRuleInput struct {
TriggerEventID *int64 `json:"trigger_event_id,omitempty"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
ConceptID *uuid.UUID `json:"concept_id,omitempty"`
Code *string `json:"code,omitempty"`
SubmissionCode *string `json:"submission_code,omitempty"`
PrimaryParty *string `json:"primary_party,omitempty"`
EventType *string `json:"event_type,omitempty"`
DurationValue int `json:"duration_value"`
@@ -168,7 +168,7 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r
// + is_court_set are the new gates.
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.deadline_rules
(id, proceeding_type_id, trigger_event_id, parent_id, concept_id, code,
(id, proceeding_type_id, trigger_event_id, parent_id, concept_id, submission_code,
name, name_en, description, primary_party, event_type,
duration_value, duration_unit, timing,
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt, combine_op,
@@ -187,7 +187,7 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r
true,
'draft', NULL, NULL,
now(), now())`,
id, input.ProceedingTypeID, input.TriggerEventID, input.ParentID, input.ConceptID, input.Code,
id, input.ProceedingTypeID, input.TriggerEventID, input.ParentID, input.ConceptID, input.SubmissionCode,
input.Name, input.NameEN, input.PrimaryParty, input.EventType,
input.DurationValue, input.DurationUnit, input.Timing,
input.AltDurationValue, input.AltDurationUnit, input.AltRuleCode, input.AnchorAlt, input.CombineOp,
@@ -286,7 +286,7 @@ func (s *RuleEditorService) CloneAsDraft(ctx context.Context, id uuid.UUID, reas
newID := uuid.New()
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.deadline_rules
(id, proceeding_type_id, trigger_event_id, parent_id, concept_id, code,
(id, proceeding_type_id, trigger_event_id, parent_id, concept_id, submission_code,
name, name_en, description, primary_party, event_type,
duration_value, duration_unit, timing,
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt, combine_op,
@@ -296,7 +296,7 @@ func (s *RuleEditorService) CloneAsDraft(ctx context.Context, id uuid.UUID, reas
is_active,
lifecycle_state, draft_of, published_at,
created_at, updated_at)
SELECT $1, proceeding_type_id, trigger_event_id, parent_id, concept_id, code,
SELECT $1, proceeding_type_id, trigger_event_id, parent_id, concept_id, submission_code,
name, name_en, description, primary_party, event_type,
duration_value, duration_unit, timing,
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt, combine_op,

View File

@@ -76,7 +76,7 @@ func TestRuleEditorService_Lifecycle(t *testing.T) {
Name: "SLICE11A_TEST_initial",
NameEN: "SLICE11A_TEST_initial_EN",
ProceedingTypeID: &ptID,
Code: ptrString("s11a.initial"),
SubmissionCode: ptrString("s11a.initial"),
DurationValue: 30,
DurationUnit: "days",
Priority: "mandatory",
@@ -263,7 +263,7 @@ func TestRuleEditorService_Preview(t *testing.T) {
// Slice 9 (t-paliad-195) dropped is_mandatory / is_optional.
if _, err := pool.ExecContext(ctx, `
INSERT INTO paliad.deadline_rules
(id, proceeding_type_id, code, name, name_en,
(id, proceeding_type_id, submission_code, name, name_en,
duration_value, duration_unit, timing,
is_court_set, is_spawn,
priority, lifecycle_state, is_active, sequence_order,

View File

@@ -0,0 +1,116 @@
package services
import (
"context"
"os"
"regexp"
"testing"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
// submissionCodeShapeRegex is the proceeding-code-prefixed shape
// installed by mig 098 (t-paliad-209): the proceeding's 3-segment code
// (`^[a-z_]+\.[a-z_]+\.[a-z_]+\.`) followed by at least one suffix
// segment (and optional further dot-separated segments). The regex
// allows underscores so the legacy archived bucket (`_archived_…`) and
// hand-seeded test rules (e.g. `s11a.initial`) match alongside the
// canonical taxonomy. Mirrors the assertion in mig 098 §6.1.
var submissionCodeShapeRegex = regexp.MustCompile(
`^[a-z_]+\.[a-z_]+\.[a-z_]+\.[a-z_]+(\..*)?$`)
// TestSubmissionCodeShape walks every active+published row in
// paliad.deadline_rules and asserts that submission_code matches the
// 4+-segment proceeding-code-prefixed shape ratified for t-paliad-209.
// Sibling of TestProceedingCodeShape — same pattern, same goal: catch
// drift between the migration's hard invariant and runtime state.
//
// Archived rows (proceeding `_archived_litigation`) are exempted; mig
// 098's §6.1 assertion does the same by gating on lifecycle_state =
// 'published'. Their codes get the archived prefix and the wider shape
// they end up with sits outside the 4+-segment canonical form by
// design.
//
// Skipped when TEST_DATABASE_URL is unset, mirroring the pattern in
// proceeding_codes_shape_test.go.
func TestSubmissionCodeShape(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()
var rows []struct {
ID string `db:"id"`
SubmissionCode *string `db:"submission_code"`
}
if err := pool.SelectContext(ctx, &rows,
`SELECT dr.id::text AS id, dr.submission_code
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
WHERE dr.is_active = true
AND dr.lifecycle_state = 'published'
AND pt.category = 'fristenrechner'
ORDER BY dr.id`); err != nil {
t.Fatalf("load active+published deadline_rules rows: %v", err)
}
if len(rows) == 0 {
t.Fatal("no active+published fristenrechner deadline_rules — mig 098 likely not applied")
}
for _, r := range rows {
if r.SubmissionCode == nil {
t.Errorf("deadline_rules[id=%s] submission_code is NULL", r.ID)
continue
}
if !submissionCodeShapeRegex.MatchString(*r.SubmissionCode) {
t.Errorf("deadline_rules[id=%s] submission_code=%q does not match shape %s",
r.ID, *r.SubmissionCode, submissionCodeShapeRegex.String())
}
}
}
// TestSubmissionCodeShapeRegexStandalone exercises the regex without a
// DB so the shape rule is verified on every `go test ./...` run.
func TestSubmissionCodeShapeRegexStandalone(t *testing.T) {
good := []string{
"upc.inf.cfi.soc",
"upc.inf.cfi.sod",
"upc.inf.cfi.def_to_ccr",
"upc.rev.cfi.app",
"de.inf.lg.klage",
"de.inf.bgh.revision",
"de.null.bgh.berufung",
"dpma.appeal.bpatg.begruendung",
"epa.opp.opd.beschwerde_begr",
}
for _, code := range good {
if !submissionCodeShapeRegex.MatchString(code) {
t.Errorf("good code %q rejected by submission-code shape regex", code)
}
}
bad := []string{
"inf.soc", // pre-mig-098: 2 segments
"upc.inf", // 2 segments
"upc.inf.cfi", // proceeding code shape, not a submission code
"UPC.INF.CFI.SOC", // uppercase
"upc-inf-cfi-soc", // dashes
"",
}
for _, code := range bad {
if submissionCodeShapeRegex.MatchString(code) {
t.Errorf("bad code %q accepted by submission-code shape regex", code)
}
}
}