Compare commits
3 Commits
mai/huygen
...
mai/ohm/wo
| Author | SHA1 | Date | |
|---|---|---|---|
| ea29165d2f | |||
| bc5b3557d0 | |||
| bd2c7a217e |
@@ -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>
|
||||
|
||||
@@ -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ä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>
|
||||
|
||||
@@ -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 ?? "");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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 $$;
|
||||
@@ -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"`
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
116
internal/services/submission_codes_shape_test.go
Normal file
116
internal/services/submission_codes_shape_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user