Compare commits

..

4 Commits

Author SHA1 Message Date
mAi
aa2f4aacc6 mAi: #98 - move Auto-rule resolved name to its own row
The Auto-mode resolved rule name was rendered as an inline-flex pill
that sat visually crammed next to the [Eigene Regel eingeben] toggle.
Promote .rule-mode-auto to a full-width block-level flex row (width:
100%, margin-top: 0.35rem) so it sits cleanly on its own line beneath
the toggle, and render the rule label via the canonical
formatRuleLabelHTML helper so the citation gets the muted-secondary
styling from rule-label.ts.

Applies to both /deadlines/new and /deadlines/:id edit form. Custom
mode (free-text input) is unaffected — the input already filled the
column.

Refs: m/paliad#98 (t-paliad-267), addendum to t-paliad-258 / m/paliad#89.
2026-05-25 16:01:15 +02:00
mAi
e0c8401482 Merge: t-paliad-266 — event-type modal cross-cutting filter by court system (mig 125) (m/paliad#97) 2026-05-25 15:53:50 +02:00
mAi
90f5dd4b1b fix: t-paliad-266 — bump migration to slot 125 (123 taken by cronus #77 backups) 2026-05-25 15:40:24 +02:00
mAi
24f3baf61f mAi: #97 - t-paliad-266 — event-type modal: narrow cross-cutting trigger pills by court system
Cross-cutting Wiedereinsetzung sub-rows (PatG §123 / ZPO §233 /
EPC Art.122 / DPMA PatG §123 / UPC R.320) used to bypass the
forum-bucket chip selection by design — every chip combination
returned all five rows. m/paliad#97: chip the chips through
to triggers via legal_source inference.

  - mig 123 backfills the missing deadline_rules row for trigger
    207 (UPC R.320 Wiedereinsetzung, orphaned by mig 063 because
    mig 092 dropped event_deadlines before that path was seeded)
    and rebuilds paliad.deadline_search with a LEFT JOIN on
    deadline_rules so cross-cutting trigger pills carry their
    structured legal_source.
  - DeadlineSearchService gains ForumToLegalSourcePrefixes (10
    buckets → UPC. / DE.ZPO. / DE.PatG. / EU.EPC + EU.EPÜ)
    paralleling ForumToProceedingCodes. Rule pills still narrow
    by proceeding_code; trigger pills now narrow by legal_source
    LIKE prefix. Multiple chips union the prefix allow-list as
    expected.
  - Live golden-table test gains a Wiedereinsetzung×forum matrix
    plus a multi-chip union case, and the existing 4-pill assertion
    is updated to the now-5-pill state (mig 063 added trigger 207).

Branch: mai/hermes/gitster-event-type-modal.
2026-05-25 15:36:08 +02:00
12 changed files with 647 additions and 364 deletions

View File

@@ -465,7 +465,8 @@ function refreshRuleAutoDisplay(): void {
panel.style.display = "";
const r = currentAutoRule();
if (r) {
text.textContent = formatRuleLabel(r);
// Canonical "Name · Citation" with muted citation (t-paliad-258 addendum).
text.innerHTML = formatRuleLabelHTML(r, esc);
text.classList.remove("rule-auto-text--empty");
return;
}

View File

@@ -8,7 +8,7 @@ import {
type PickerHandle,
} from "./event-types";
import { projectIndent } from "./project-indent";
import { formatRuleLabel } from "./rule-label";
import { formatRuleLabel, formatRuleLabelHTML } from "./rule-label";
let eventTypePicker: PickerHandle | null = null;
let currentUserAdmin = false;
@@ -192,7 +192,8 @@ function refreshRuleAutoDisplay(): void {
panel.style.display = "";
const rule = currentAutoRule();
if (rule) {
text.textContent = formatRuleLabel(rule);
// Canonical "Name · Citation" with muted citation (t-paliad-258 addendum).
text.innerHTML = formatRuleLabelHTML(rule, esc);
text.classList.remove("rule-auto-text--empty");
return;
}

View File

@@ -2841,26 +2841,21 @@ const translations: Record<Lang, Record<string, string>> = {
"views.bar.save.error.network": "Netzwerkfehler — bitte erneut versuchen.",
// t-paliad-192 Slice 11b — Admin rule-editor UI.
// t-paliad-262 Slice A — "Regel" relabelled as "Verfahrensschritt".
// The admin URL `/admin/rules` and i18n key prefix `admin.rules.*` stay
// (URL change is Slice B.6); the visible labels rename. Canonical
// `admin.procedural_events.*` aliases live after the EN block — they
// pin the contract for when .tsx files rebind in Slice B (B.5).
"nav.admin.rules": "Verfahrensschritte verwalten",
"nav.admin.rules_export": "Verfahrensschritt-Migrations",
"admin.card.rules.title": "Verfahrensschritte verwalten",
"admin.card.rules.desc": "Verfahrensschritte anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.",
"nav.admin.rules": "Regeln verwalten",
"nav.admin.rules_export": "Regel-Migrations",
"admin.card.rules.title": "Regeln verwalten",
"admin.card.rules.desc": "Fristen-Regeln anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.",
"admin.rules.list.title": "Verfahrensschritte verwalten — Paliad",
"admin.rules.list.heading": "Verfahrensschritte verwalten",
"admin.rules.list.subtitle": "Verfahrensschritte (Schriftsätze, Anhörungen, Entscheidungen, …) anlegen, bearbeiten und freigeben. Lifecycle: draft → published → archived.",
"admin.rules.list.new": "+ Neuer Verfahrensschritt",
"admin.rules.list.title": "Regeln verwalten — Paliad",
"admin.rules.list.heading": "Regeln verwalten",
"admin.rules.list.subtitle": "Fristen-Regeln anlegen, bearbeiten und freigeben. Lifecycle: draft → published → archived.",
"admin.rules.list.new": "+ Neue Regel",
"admin.rules.list.export": "Migrations exportieren",
"admin.rules.tab.rules": "Regeln",
"admin.rules.tab.orphans": "Orphans",
"admin.rules.loading": "Lade…",
"admin.rules.empty": "Keine Verfahrensschritte für die gewählten Filter.",
"admin.rules.error.load": "Konnte Verfahrensschritte nicht laden.",
"admin.rules.empty": "Keine Regeln für die gewählten Filter.",
"admin.rules.error.load": "Konnte Regeln nicht laden.",
"admin.rules.filter.proceeding": "Verfahrenstyp",
"admin.rules.filter.proceeding.any": "Alle",
@@ -2871,7 +2866,7 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.filter.search": "Suche",
"admin.rules.filter.search.placeholder": "Name, Submission Code, Rechtsgrundlage…",
"admin.rules.col.submission_code": "Code (Verfahrensschritt)",
"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",
@@ -2901,8 +2896,8 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.orphans.reason.manual_unbound": "Manuell entkoppelt",
"admin.rules.orphans.resolved": "Orphan zugeordnet.",
"admin.rules.modal.new.title": "Neuen Verfahrensschritt anlegen",
"admin.rules.modal.new.body": "Ein neuer Verfahrensschritt wird als Draft angelegt. Bitte einen Grund (mind. 10 Zeichen) angeben — dieser wandert ins Audit-Log und beim Export in die Migration.",
"admin.rules.modal.new.title": "Neue Regel anlegen",
"admin.rules.modal.new.body": "Eine neue Regel wird als Draft angelegt. Bitte einen Grund (mind. 10 Zeichen) angeben — dieser wandert ins Audit-Log und beim Export in die Migration.",
"admin.rules.modal.resolve.title": "Orphan zuordnen",
"admin.rules.modal.resolve.body": "Bitte einen Grund (mind. 10 Zeichen) angeben. Die Regel-Verknüpfung wird sofort auf der Deadline gespeichert.",
"admin.rules.modal.reason": "Grund",
@@ -2917,12 +2912,12 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.modal.error.create": "Anlegen fehlgeschlagen.",
"admin.rules.modal.error.resolve": "Zuordnung fehlgeschlagen.",
"admin.rules.edit.title": "Verfahrensschritt bearbeiten — Paliad",
"admin.rules.edit.heading.loading": "Verfahrensschritt laden…",
"admin.rules.edit.breadcrumb": "← Verfahrensschritte verwalten",
"admin.rules.edit.error.bad_id": "Ungültige Verfahrensschritt-ID in der URL.",
"admin.rules.edit.error.not_found": "Verfahrensschritt nicht gefunden.",
"admin.rules.edit.error.load": "Konnte Verfahrensschritt nicht laden.",
"admin.rules.edit.title": "Regel bearbeiten — Paliad",
"admin.rules.edit.heading.loading": "Regel laden…",
"admin.rules.edit.breadcrumb": "← Regeln verwalten",
"admin.rules.edit.error.bad_id": "Ungültige Regel-ID in der URL.",
"admin.rules.edit.error.not_found": "Regel nicht gefunden.",
"admin.rules.edit.error.load": "Konnte Regel nicht laden.",
"admin.rules.edit.section.identity": "Identität",
"admin.rules.edit.section.proceeding": "Verfahren & Trigger",
@@ -2935,14 +2930,14 @@ 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.submission_code": "Code (Verfahrensschritt-Identifikator)",
"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",
"admin.rules.edit.field.trigger.none": "—",
"admin.rules.edit.field.parent": "Übergeordneter Verfahrensschritt (UUID)",
"admin.rules.edit.field.parent": "Parent-Regel (UUID)",
"admin.rules.edit.field.concept": "Konzept (UUID)",
"admin.rules.edit.field.sequence_order": "Reihenfolge",
"admin.rules.edit.field.duration_value": "Dauer",
@@ -2954,7 +2949,7 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.edit.field.alt_rule_code": "Alt-Rule-Code",
"admin.rules.edit.field.anchor_alt": "Alt-Anchor",
"admin.rules.edit.field.primary_party": "Primäre Partei",
"admin.rules.edit.field.event_type": "Art des Verfahrensschritts (filing / hearing / decision / order)",
"admin.rules.edit.field.event_type": "Event-Typ (frei)",
"admin.rules.edit.field.deadline_notes": "Hinweise (DE)",
"admin.rules.edit.field.deadline_notes_en": "Hinweise (EN)",
"admin.rules.edit.field.priority": "Priorität",
@@ -3065,21 +3060,6 @@ const translations: Record<Lang, Record<string, string>> = {
"date_range.custom.invalid": "Bis-Datum muss nach Von-Datum liegen.",
"date_range.custom.invalid_format": "Datum nicht erkannt (Format JJJJ-MM-TT).",
"date_range.custom.invalid_missing": "Bitte beide Datumsfelder ausfüllen.",
// t-paliad-262 Slice A — canonical `procedural_event` i18n contract.
// The values are identical to the legacy `admin.rules.*` keys above —
// these aliases let .tsx files rebind in Slice B (B.5) without
// touching DE/EN strings then. Adding/changing values? Update BOTH
// sides.
"admin.procedural_events.list.title": "Verfahrensschritte verwalten — Paliad",
"admin.procedural_events.list.heading": "Verfahrensschritte verwalten",
"admin.procedural_events.list.new": "+ Neuer Verfahrensschritt",
"admin.procedural_events.col.code": "Code (Verfahrensschritt)",
"admin.procedural_events.edit.title": "Verfahrensschritt bearbeiten — Paliad",
"admin.procedural_events.edit.breadcrumb":"← Verfahrensschritte verwalten",
"admin.procedural_events.edit.field.code": "Code (Verfahrensschritt-Identifikator)",
"admin.procedural_events.edit.field.event_kind": "Art des Verfahrensschritts (filing / hearing / decision / order)",
"admin.procedural_events.edit.field.parent": "Übergeordneter Verfahrensschritt (UUID)",
},
en: {
@@ -5874,22 +5854,21 @@ const translations: Record<Lang, Record<string, string>> = {
"views.bar.save.error.network": "Network error — please retry.",
// t-paliad-192 Slice 11b — Admin rule-editor UI.
// t-paliad-262 Slice A — "Rule" relabelled as "Procedural event".
"nav.admin.rules": "Manage procedural events",
"nav.admin.rules_export": "Procedural-event migrations",
"admin.card.rules.title": "Manage procedural events",
"admin.card.rules.desc": "Author, edit and publish procedural-event templates. Audit log, preview, migration export.",
"nav.admin.rules": "Manage Rules",
"nav.admin.rules_export": "Rule Migrations",
"admin.card.rules.title": "Manage Rules",
"admin.card.rules.desc": "Author, edit and publish deadline rules. Audit log, preview, migration export.",
"admin.rules.list.title": "Manage procedural events — Paliad",
"admin.rules.list.heading": "Manage procedural events",
"admin.rules.list.subtitle": "Author, edit and publish procedural events (filings, hearings, decisions, …). Lifecycle: draft → published → archived.",
"admin.rules.list.new": "+ New procedural event",
"admin.rules.list.title": "Manage Rules — Paliad",
"admin.rules.list.heading": "Manage Rules",
"admin.rules.list.subtitle": "Author, edit and publish deadline rules. Lifecycle: draft → published → archived.",
"admin.rules.list.new": "+ New Rule",
"admin.rules.list.export": "Export migrations",
"admin.rules.tab.rules": "Rules",
"admin.rules.tab.orphans": "Orphans",
"admin.rules.loading": "Loading…",
"admin.rules.empty": "No procedural events for the chosen filters.",
"admin.rules.error.load": "Could not load procedural events.",
"admin.rules.empty": "No rules for the chosen filters.",
"admin.rules.error.load": "Could not load rules.",
"admin.rules.filter.proceeding": "Proceeding type",
"admin.rules.filter.proceeding.any": "Any",
@@ -5900,7 +5879,7 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.filter.search": "Search",
"admin.rules.filter.search.placeholder": "Name, submission code, legal citation…",
"admin.rules.col.submission_code": "Code (procedural event)",
"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",
@@ -5930,8 +5909,8 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.orphans.reason.manual_unbound": "Manually unbound",
"admin.rules.orphans.resolved": "Orphan resolved.",
"admin.rules.modal.new.title": "Create new procedural event",
"admin.rules.modal.new.body": "A new procedural event will be created as a draft. Please supply a reason (≥10 chars) — recorded in the audit log and exported into the migration file.",
"admin.rules.modal.new.title": "Create new rule",
"admin.rules.modal.new.body": "A new rule will be created as a draft. Please supply a reason (≥10 chars) — recorded in the audit log and exported into the migration file.",
"admin.rules.modal.resolve.title": "Resolve orphan",
"admin.rules.modal.resolve.body": "Please supply a reason (≥10 chars). The rule binding is persisted immediately on the deadline.",
"admin.rules.modal.reason": "Reason",
@@ -5946,12 +5925,12 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.modal.error.create": "Creation failed.",
"admin.rules.modal.error.resolve": "Resolution failed.",
"admin.rules.edit.title": "Edit procedural event — Paliad",
"admin.rules.edit.heading.loading": "Loading procedural event…",
"admin.rules.edit.breadcrumb": "← Manage procedural events",
"admin.rules.edit.error.bad_id": "Invalid procedural-event id in URL.",
"admin.rules.edit.error.not_found": "Procedural event not found.",
"admin.rules.edit.error.load": "Could not load procedural event.",
"admin.rules.edit.title": "Edit Rule — Paliad",
"admin.rules.edit.heading.loading": "Loading rule…",
"admin.rules.edit.breadcrumb": "← Manage Rules",
"admin.rules.edit.error.bad_id": "Invalid rule id in URL.",
"admin.rules.edit.error.not_found": "Rule not found.",
"admin.rules.edit.error.load": "Could not load rule.",
"admin.rules.edit.section.identity": "Identity",
"admin.rules.edit.section.proceeding": "Proceeding & Trigger",
@@ -5964,14 +5943,14 @@ 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.submission_code": "Code (procedural-event identifier)",
"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",
"admin.rules.edit.field.trigger.none": "—",
"admin.rules.edit.field.parent": "Parent procedural event (UUID)",
"admin.rules.edit.field.parent": "Parent rule (UUID)",
"admin.rules.edit.field.concept": "Concept (UUID)",
"admin.rules.edit.field.sequence_order": "Order",
"admin.rules.edit.field.duration_value": "Duration",
@@ -5983,7 +5962,7 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.edit.field.alt_rule_code": "Alt rule code",
"admin.rules.edit.field.anchor_alt": "Alt anchor",
"admin.rules.edit.field.primary_party": "Primary party",
"admin.rules.edit.field.event_type": "Procedural-event kind (filing / hearing / decision / order)",
"admin.rules.edit.field.event_type": "Event type (free)",
"admin.rules.edit.field.deadline_notes": "Notes (DE)",
"admin.rules.edit.field.deadline_notes_en": "Notes (EN)",
"admin.rules.edit.field.priority": "Priority",
@@ -6091,19 +6070,6 @@ const translations: Record<Lang, Record<string, string>> = {
"date_range.custom.invalid": "End date must be strictly after start date.",
"date_range.custom.invalid_format": "Date not recognised (format YYYY-MM-DD).",
"date_range.custom.invalid_missing": "Please fill in both date fields.",
// t-paliad-262 Slice A — canonical `procedural_event` i18n contract.
// Mirrors the DE block; values identical to the legacy
// `admin.rules.*` keys. Adding/changing values? Update BOTH sides.
"admin.procedural_events.list.title": "Manage procedural events — Paliad",
"admin.procedural_events.list.heading": "Manage procedural events",
"admin.procedural_events.list.new": "+ New procedural event",
"admin.procedural_events.col.code": "Code (procedural event)",
"admin.procedural_events.edit.title": "Edit procedural event — Paliad",
"admin.procedural_events.edit.breadcrumb":"← Manage procedural events",
"admin.procedural_events.edit.field.code": "Code (procedural-event identifier)",
"admin.procedural_events.edit.field.event_kind": "Procedural-event kind (filing / hearing / decision / order)",
"admin.procedural_events.edit.field.parent": "Parent procedural event (UUID)",
},
};

View File

@@ -155,29 +155,14 @@ const VARIABLE_LABELS: Record<string, VariableLabel> = {
"parties.defendant.representative":{ de: "Beklagten-Vertreter", en: "Defendant representative" },
"parties.other.name": { de: "Weitere Partei", en: "Other party" },
"parties.other.representative": { de: "Weitere-Partei-Vertreter", en: "Other party representative" },
// Procedural-event namespace (t-paliad-262 Slice A, design doc
// docs/design-procedural-events-model-2026-05-25.md). The canonical
// placeholder names are below; the `rule.*` aliases that follow are
// @deprecated but kept forever per m's Q7 lock — existing Word
// templates and saved drafts authored with the old names keep
// merging identically.
"procedural_event.code": { de: "Code (Verfahrensschritt)", en: "Code (procedural event)" },
"procedural_event.name": { de: "Verfahrensschritt", en: "Procedural event" },
"procedural_event.name_de": { de: "Verfahrensschritt (DE)", en: "Procedural event (DE)" },
"procedural_event.name_en": { de: "Verfahrensschritt (EN)", en: "Procedural event (EN)" },
"procedural_event.legal_source": { de: "Rechtsgrundlage (Code)", en: "Legal source (code)" },
"procedural_event.legal_source_pretty":{ de: "Rechtsgrundlage", en: "Legal source" },
"procedural_event.primary_party": { de: "Partei (typisch)", en: "Primary party" },
"procedural_event.event_kind": { de: "Art des Verfahrensschritts", en: "Procedural-event kind" },
// Legacy aliases — @deprecated, kept forever (m/paliad#93 Q7).
"rule.submission_code": { de: "Schriftsatz-Code (legacy)", en: "Submission code (legacy)" },
"rule.name": { de: "Schriftsatz (legacy)", en: "Submission (legacy)" },
"rule.name_de": { de: "Schriftsatz (DE, legacy)", en: "Submission (DE, legacy)" },
"rule.name_en": { de: "Schriftsatz (EN, legacy)", en: "Submission (EN, legacy)" },
"rule.legal_source": { de: "Rechtsgrundlage (Code, legacy)", en: "Legal source (code, legacy)" },
"rule.legal_source_pretty": { de: "Rechtsgrundlage (legacy)", en: "Legal source (legacy)" },
"rule.primary_party": { de: "Partei (typisch, legacy)", en: "Primary party (legacy)" },
"rule.event_type": { de: "Schriftsatz-Typ (legacy)", en: "Event type (legacy)" },
"rule.submission_code": { de: "Schriftsatz-Code", en: "Submission code" },
"rule.name": { de: "Schriftsatz", en: "Submission" },
"rule.name_de": { de: "Schriftsatz (DE)", en: "Submission (DE)" },
"rule.name_en": { de: "Schriftsatz (EN)", en: "Submission (EN)" },
"rule.legal_source": { de: "Rechtsgrundlage (Code)", en: "Legal source (code)" },
"rule.legal_source_pretty": { de: "Rechtsgrundlage", en: "Legal source" },
"rule.primary_party": { de: "Partei (typisch)", en: "Primary party" },
"rule.event_type": { de: "Schriftsatz-Typ", en: "Event type" },
"deadline.due_date": { de: "Frist (ISO)", en: "Deadline (ISO)" },
"deadline.due_date_long_de": { de: "Frist (DE lang)", en: "Deadline (DE long)" },
"deadline.due_date_long_en": { de: "Frist (EN lang)", en: "Deadline (EN long)" },
@@ -189,14 +174,14 @@ const VARIABLE_LABELS: Record<string, VariableLabel> = {
const VARIABLE_GROUPS: VariableGroup[] = [
{
id: "procedural_event",
label: { de: "Verfahrensschritt", en: "Procedural event" },
id: "rule",
label: { de: "Schriftsatz", en: "Submission" },
keys: [
"procedural_event.name",
"procedural_event.legal_source_pretty",
"procedural_event.primary_party",
"procedural_event.event_kind",
"procedural_event.code",
"rule.name",
"rule.legal_source_pretty",
"rule.primary_party",
"rule.event_type",
"rule.submission_code",
],
},
{

View File

@@ -290,15 +290,6 @@ export type I18nKey =
| "admin.partner_units.new.heading"
| "admin.partner_units.subtitle"
| "admin.partner_units.title"
| "admin.procedural_events.col.code"
| "admin.procedural_events.edit.breadcrumb"
| "admin.procedural_events.edit.field.code"
| "admin.procedural_events.edit.field.event_kind"
| "admin.procedural_events.edit.field.parent"
| "admin.procedural_events.edit.title"
| "admin.procedural_events.list.heading"
| "admin.procedural_events.list.new"
| "admin.procedural_events.list.title"
| "admin.rules.col.legal_citation"
| "admin.rules.col.lifecycle"
| "admin.rules.col.modified"

View File

@@ -7690,11 +7690,16 @@ dialog.modal::backdrop {
/* t-paliad-258 — Auto/Custom Rule editor (m/paliad#89).
Replaces the t-paliad-251 catalog dropdown + sort selector with a
binary toggle:
.rule-mode-auto — read-only display, lime-tint pill + label.
.rule-mode-auto — read-only display, lime-tint chip + label.
.rule-mode-custom — free-text input, full-width.
Toggle button reuses .btn-link-action for the inline link styling. */
Toggle button reuses .btn-link-action for the inline link styling.
t-paliad-267 / m/paliad#98 — the auto display is now a block-level
row of its own so the resolved rule name sits on its own line
beneath the toggle, not crammed beside it. Width is content-sized
(align-self:flex-start within form-field's block flow keeps the
chip from spanning the whole form column gratuitously). */
.rule-mode-auto {
display: inline-flex;
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.55rem;
@@ -7702,6 +7707,9 @@ dialog.modal::backdrop {
border-left: 2px solid var(--color-accent);
border-radius: var(--radius-sm, 4px);
min-height: 2rem;
width: 100%;
box-sizing: border-box;
margin-top: 0.35rem;
}
.rule-auto-text {
color: var(--color-text);

View File

@@ -0,0 +1,103 @@
-- Down migration for 125_cross_cutting_filter_legal_source.up.sql.
--
-- Rebuilds the mig 098 matview shape (NULL legal_source on trigger
-- rows) and removes the trigger-207 backfill row. Two steps in
-- forward-reverse order so the matview drop doesn't trip on the
-- deadline_rules delete.
SELECT set_config(
'paliad.audit_reason',
'mig 125 down: revert cross-cutting filter legal_source (drop trigger-207 backfill + rebuild matview without LEFT JOIN to deadline_rules).',
true);
-- 1. Drop the matview before pulling rows underneath it.
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
-- 2. Delete the trigger 207 backfill row.
DELETE FROM paliad.deadline_rules
WHERE trigger_event_id = 207
AND sequence_order = 1207;
-- 3. Recreate the mig 098 matview verbatim (NULL legal_source on
-- trigger rows).
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);

View File

@@ -0,0 +1,222 @@
-- t-paliad-266 / m/paliad#97 — make cross-cutting trigger pills filter
-- by court system in the event-type / Fristen search modal.
--
-- Two things land here:
--
-- 1. DATA — backfill the missing deadline_rules row for trigger 207
-- (Wegfall des Hindernisses, UPC R.320). Mig 063 added the
-- trigger_event but never seeded its event_deadlines counterpart;
-- mig 092 then dropped event_deadlines after copying the four
-- sibling Wiedereinsetzungen (ids 200..203) into deadline_rules,
-- so trigger 207 stayed orphaned with no duration / legal_source.
-- Adding the row makes UPC R.320 Wiedereinsetzung calculable on
-- par with the four siblings (2 months from removal of obstacle,
-- legal_source = 'UPC.RoP.320', party = 'both') and gives the
-- matview a legal_source to surface for the UPC trigger pill.
-- Pattern mirrors the four sibling rows mig 085 inserted.
--
-- 2. MATVIEW — rebuild paliad.deadline_search with a LEFT JOIN on
-- paliad.deadline_rules for trigger pills, exposing the trigger's
-- legal_source on the row. The cross-cutting concept card pills
-- then carry a structured citation prefix (UPC.* / DE.ZPO.* /
-- DE.PatG.* / EU.EPC* / EU.EPÜ.*) that the search service can
-- match against the active forum-bucket filter — see
-- DeadlineSearchService.translateForums + ForumToLegalSourcePrefixes
-- (added in this same change). Without the matview surfacing
-- legal_source for trigger rows, every cross-cutting sub-row
-- ignored the court-system chip selection (the bug m reported).
--
-- The materialised view paliad.deadline_search refreshes on the next
-- server boot via services.RefreshSearchView (cmd/server/main.go), so
-- the new legal_source column for triggers becomes searchable as soon
-- as the deploy restarts the process. No matview refresh from the
-- migration itself.
SELECT set_config(
'paliad.audit_reason',
'mig 125: t-paliad-266 — backfill missing deadline_rules row for trigger 207 (UPC R.320 Wiedereinsetzung) and rebuild deadline_search matview so trigger pills carry legal_source (cross-cutting court-system filter, m/paliad#97).',
true);
-- =============================================================================
-- 1. Backfill: deadline_rules row for trigger 207.
--
-- Idempotency: gated on NOT EXISTS by (trigger_event_id, name). Mirrors
-- mig 085's guard so re-runs are no-ops once the row is present.
-- =============================================================================
INSERT INTO paliad.deadline_rules (
id,
proceeding_type_id,
parent_id,
trigger_event_id,
spawn_proceeding_type_id,
submission_code,
name,
name_en,
primary_party,
event_type,
is_mandatory,
is_optional,
is_court_set,
is_spawn,
duration_value,
duration_unit,
timing,
alt_duration_value,
alt_duration_unit,
combine_op,
rule_code,
deadline_notes,
deadline_notes_en,
legal_source,
condition_expr,
condition_flag,
sequence_order,
is_active,
priority,
lifecycle_state,
draft_of,
published_at,
concept_id
)
SELECT
gen_random_uuid(),
NULL::integer,
NULL::uuid,
207,
NULL::integer,
NULL::text,
'Wiedereinsetzungsantrag (UPC R.320)',
'Petition for re-establishment of rights (UPC R.320)',
NULL::text,
NULL::text,
true,
false,
false,
false,
2,
'months',
'after',
NULL::integer,
NULL::text,
NULL::text,
NULL::text,
'Frist beträgt 2 Monate ab Wegfall des Hindernisses (R.320 RoP). Spätestens 12 Monate nach Ablauf der versäumten Frist.',
'Period is 2 months from removal of the obstacle (UPC R.320 RoP). Latest 12 months after expiry of the missed deadline.',
'UPC.RoP.320',
NULL::jsonb,
NULL::text[],
1207,
true,
'mandatory',
'published',
NULL::uuid,
now(),
(SELECT id FROM paliad.deadline_concepts WHERE slug = 'wiedereinsetzung')
WHERE NOT EXISTS (
SELECT 1
FROM paliad.deadline_rules dr
WHERE dr.trigger_event_id = 207
);
-- =============================================================================
-- 2. Matview rebuild — LEFT JOIN deadline_rules on trigger_event_id so
-- cross-cutting trigger pills carry legal_source. Indexes reproduced
-- verbatim from mig 098 §5.
--
-- The trigger-row JOIN matches the Pipeline-C convention (mig 085 §2.5 /
-- mig 092 §2): each cross-cutting trigger has a single deadline_rules
-- row with proceeding_type_id IS NULL. A trigger event without that
-- row leaves legal_source NULL and the trigger pill keeps its current
-- "no jurisdiction filter match" semantics — same shape as before this
-- migration, just structurally surfaceable.
-- =============================================================================
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
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,
dr_trig.legal_source AS legal_source,
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
LEFT JOIN paliad.deadline_rules dr_trig
ON dr_trig.trigger_event_id = te.id
AND dr_trig.proceeding_type_id IS NULL
AND dr_trig.is_active
AND dr_trig.lifecycle_state = 'published'
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);

View File

@@ -33,7 +33,12 @@ import (
// tree alone is enough to produce a candidate concept set.
// - Forums: a list of forum slugs from the v3 bucket map. Translated
// to proceeding_type_codes by the search service; trigger-event
// pills bypass the forum filter (cross-cutting by design).
// pills carry a structured legal_source citation (via mig 123)
// and narrow by the per-forum legal-source prefix set instead of
// by proceeding_code — see ForumToLegalSourcePrefixes. Before mig
// 123 trigger pills bypassed the forum filter unconditionally;
// m/paliad#97 (t-paliad-266) requires the cross-cutting sub-rows
// to narrow with the active court-system chip.
//
// See docs/plans/unified-fristenrechner.md §4.6 + §6 (v2) and
// docs/plans/unified-fristenrechner-v3.md §3.5 + §5.2 (v3).
@@ -74,6 +79,40 @@ var ForumToProceedingCodes = map[string][]string{
"dpma": {CodeDPMAOpposition},
}
// ForumToLegalSourcePrefixes maps the v3 forum buckets to the
// structured legal_source prefixes that cross-cutting trigger pills
// must match against (t-paliad-266 / m/paliad#97). Rule pills already
// narrow by proceeding_code via ForumToProceedingCodes; trigger pills
// have no proceeding context, so the narrowing key is the citation
// body itself.
//
// Mapping mirrors m's spec on the issue:
//
// - UPC chips → UPC.* (UPC RoP / UPC Agreement / UPC Statute)
// - DE LG/OLG/BGH chips → DE.ZPO.* (civil-procedure path)
// - DE BPatG chip → DE.PatG.* (national patent path)
// - DPMA chip → DE.PatG.* (national patent path)
// - EPA chips → EU.EPC* / EU.EPÜ* (EPC / EPÜ citations)
//
// Two forums (de_bgh, de_bpatg) intentionally collapse: BGH hears
// both civil-patent and nullity appeals; PatG covers DPMA + BPatG
// patent jurisdiction. The matching SQL uses startsWith against the
// union of the active forums' prefixes, so a chip combination like
// "DPMA + de_bgh" surfaces every trigger whose legal_source starts
// with DE.PatG.* OR DE.ZPO.* — exactly the user's union expectation.
var ForumToLegalSourcePrefixes = map[string][]string{
"upc_cfi": {"UPC."},
"upc_coa": {"UPC."},
"de_lg": {"DE.ZPO."},
"de_olg": {"DE.ZPO."},
"de_bgh": {"DE.ZPO."},
"de_bpatg": {"DE.PatG."},
"epa_grant": {"EU.EPC", "EU.EPÜ"},
"epa_opp": {"EU.EPC", "EU.EPÜ"},
"epa_appeal": {"EU.EPC", "EU.EPÜ"},
"dpma": {"DE.PatG."},
}
// SearchOptions carries the optional facet filters from the URL query
// string. Empty strings / empty slices mean "no filter on this facet".
type SearchOptions struct {
@@ -279,8 +318,12 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc
subtree = newSubtreeFilter(outcomes)
}
// v3: translate forum slugs to proceeding_code allow-list.
// v3: translate forum slugs to proceeding_code allow-list (rule
// pills) and t-paliad-266: parallel legal_source prefix allow-list
// for trigger pills. Empty slice for either axis = no narrowing on
// that pill kind.
forumCodes := translateForums(opts.Forums)
forumLegalPrefixes := translateForumsToLegalSourcePrefixes(opts.Forums)
if !browseMode && qNorm == "" {
return resp, nil
@@ -293,11 +336,11 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc
var ranks []rankRow
if browseMode {
// Browse mode: synthesize ranks from the allow-list directly.
ranks = s.browseRanks(ctx, subtree, party, proc, source, forumCodes, limit)
ranks = s.browseRanks(ctx, subtree, party, proc, source, forumCodes, forumLegalPrefixes, limit)
} else {
qLow := strings.ToLower(qNorm)
var err error
ranks, err = s.rankConcepts(ctx, qNorm, qLow, party, proc, source, subtree, forumCodes, limit)
ranks, err = s.rankConcepts(ctx, qNorm, qLow, party, proc, source, subtree, forumCodes, forumLegalPrefixes, limit)
if err != nil {
return nil, err
}
@@ -310,7 +353,7 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc
for i, r := range ranks {
conceptIDs[i] = r.ConceptID
}
pills, err := s.loadPills(ctx, conceptIDs, party, proc, source, subtree, forumCodes)
pills, err := s.loadPills(ctx, conceptIDs, party, proc, source, subtree, forumCodes, forumLegalPrefixes)
if err != nil {
return nil, err
}
@@ -418,6 +461,33 @@ func translateForums(slugs []string) []string {
return out
}
// translateForumsToLegalSourcePrefixes maps a list of forum slugs to
// the union of legal_source prefixes those forums admit for trigger
// pills (t-paliad-266). Empty when no slug carries a prefix mapping —
// callers must treat empty as "no trigger narrowing applies" rather
// than "match nothing", mirroring translateForums.
func translateForumsToLegalSourcePrefixes(slugs []string) []string {
if len(slugs) == 0 {
return nil
}
seen := map[string]bool{}
var out []string
for _, slug := range slugs {
prefixes, ok := ForumToLegalSourcePrefixes[slug]
if !ok {
continue
}
for _, p := range prefixes {
if seen[p] {
continue
}
seen[p] = true
out = append(out, p)
}
}
return out
}
// browseRanks synthesizes a rank list from a subtree-filter tuple set
// (v3 B1 browse mode). No trigram scoring — order is by concept
// sort_order then name. Forum filter applies post-hoc to keep concepts
@@ -430,6 +500,7 @@ func (s *DeadlineSearchService) browseRanks(
subtree *subtreeFilter,
party, proc, source *string,
forumCodes []string,
forumLegalPrefixes []string,
limit int,
) []rankRow {
const sqlText = `
@@ -452,8 +523,18 @@ SELECT DISTINCT
AND (
$6::text[] IS NULL
OR cardinality($6::text[]) = 0
OR s.kind = 'trigger'
OR s.proceeding_code = ANY($6::text[])
OR (
s.kind = 'rule'
AND s.proceeding_code = ANY($6::text[])
)
OR (
s.kind = 'trigger'
AND ($8::text[] IS NULL OR cardinality($8::text[]) = 0
OR EXISTS (
SELECT 1 FROM unnest($8::text[]) AS lp
WHERE s.legal_source LIKE lp || '%'
))
)
)
ORDER BY s.concept_sort_order ASC, s.concept_name_de ASC
LIMIT $7
@@ -465,6 +546,7 @@ SELECT DISTINCT
party, proc, source,
nullableArray(forumCodes),
limit,
nullableArray(forumLegalPrefixes),
); err != nil {
// Browse mode failures degrade to empty (taxonomy-driven UX
// shouldn't crash on a malformed slug); log via the caller.
@@ -490,11 +572,12 @@ func (s *DeadlineSearchService) rankConcepts(
party, proc, source *string,
subtree *subtreeFilter,
forumCodes []string,
forumLegalPrefixes []string,
limit int,
) ([]rankRow, error) {
// $1 q · $2 qLow · $3 party · $4 proc · $5 source ·
// $6 subtree_cids uuid[]? · $7 subtree_procs text[]? ·
// $8 forum_codes text[]? · $9 limit
// $8 forum_codes text[]? · $9 limit · $10 forum_legal_prefixes text[]?
const sqlText = `
WITH matched AS (
SELECT
@@ -544,8 +627,18 @@ WITH matched AS (
AND (
$8::text[] IS NULL
OR cardinality($8::text[]) = 0
OR s.kind = 'trigger'
OR s.proceeding_code = ANY($8::text[])
OR (
s.kind = 'rule'
AND s.proceeding_code = ANY($8::text[])
)
OR (
s.kind = 'trigger'
AND ($10::text[] IS NULL OR cardinality($10::text[]) = 0
OR EXISTS (
SELECT 1 FROM unnest($10::text[]) AS lp
WHERE s.legal_source LIKE lp || '%'
))
)
)
)
SELECT
@@ -569,6 +662,7 @@ SELECT
cidArg, procArg,
nullableArray(forumCodes),
limit,
nullableArray(forumLegalPrefixes),
); err != nil {
return nil, fmt.Errorf("rank concepts: %w", err)
}
@@ -581,10 +675,11 @@ func (s *DeadlineSearchService) loadPills(
party, proc, source *string,
subtree *subtreeFilter,
forumCodes []string,
forumLegalPrefixes []string,
) ([]pillRow, error) {
// $1 concept_ids uuid[] · $2 party · $3 proc · $4 source ·
// $5 subtree_cids uuid[]? · $6 subtree_procs text[]? ·
// $7 forum_codes text[]?
// $7 forum_codes text[]? · $8 forum_legal_prefixes text[]?
const sqlText = `
SELECT
s.kind,
@@ -627,8 +722,18 @@ SELECT
AND (
$7::text[] IS NULL
OR cardinality($7::text[]) = 0
OR s.kind = 'trigger'
OR s.proceeding_code = ANY($7::text[])
OR (
s.kind = 'rule'
AND s.proceeding_code = ANY($7::text[])
)
OR (
s.kind = 'trigger'
AND ($8::text[] IS NULL OR cardinality($8::text[]) = 0
OR EXISTS (
SELECT 1 FROM unnest($8::text[]) AS lp
WHERE s.legal_source LIKE lp || '%'
))
)
)
ORDER BY s.concept_id, s.kind, s.proceeding_display_order, s.proceeding_code NULLS LAST, s.rule_local_code
`
@@ -638,6 +743,7 @@ SELECT
pq.Array(conceptIDs), party, proc, source,
cidArg, procArg,
nullableArray(forumCodes),
nullableArray(forumLegalPrefixes),
); err != nil {
return nil, fmt.Errorf("load pills: %w", err)
}

View File

@@ -166,15 +166,15 @@ func TestDeadlineSearch(t *testing.T) {
mustHaveLegalSource(t, card, "DE.PatG.82.1")
})
t.Run("Wiedereinsetzung returns the cross-cutting concept with 4 trigger pills", func(t *testing.T) {
t.Run("Wiedereinsetzung returns the cross-cutting concept with 5 trigger pills", func(t *testing.T) {
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{Limit: 12})
if err != nil {
t.Fatalf("search: %v", err)
}
card := findCardBySlug(t, resp, "wiedereinsetzung")
// Exactly 4 trigger pills: PatG §123 (DE), ZPO §233 (DE), EPÜ
// Art.122 (EU), DPMA §123 — corresponding to trigger_event ids
// 200..203 from migration 046.
// Exactly 5 trigger pills: PatG §123 (DE), ZPO §233 (DE), EPÜ
// Art.122 (EU), DPMA §123, and UPC R.320 — trigger_event ids
// 200..203 from mig 046 plus 207 from mig 063.
triggerIDs := []int64{}
for _, p := range card.Pills {
if p.Kind != "trigger" {
@@ -184,9 +184,9 @@ func TestDeadlineSearch(t *testing.T) {
triggerIDs = append(triggerIDs, *p.TriggerEventID)
}
}
want := map[int64]bool{200: true, 201: true, 202: true, 203: true}
if len(triggerIDs) != 4 {
t.Fatalf("Wiedereinsetzung card: got %d trigger pills, want 4 (ids 200..203)", len(triggerIDs))
want := map[int64]bool{200: true, 201: true, 202: true, 203: true, 207: true}
if len(triggerIDs) != 5 {
t.Fatalf("Wiedereinsetzung card: got %d trigger pills, want 5 (ids 200..203, 207)", len(triggerIDs))
}
for _, id := range triggerIDs {
if !want[id] {
@@ -195,6 +195,107 @@ func TestDeadlineSearch(t *testing.T) {
}
})
// t-paliad-266 / m/paliad#97 — court-system filter narrows
// cross-cutting trigger pills via legal_source inference.
t.Run("forum filter narrows Wiedereinsetzung trigger pills by court system", func(t *testing.T) {
// Each pair is (forum slug, expected trigger_event_ids).
cases := []struct {
name string
forum string
wantTrigIDs []int64
}{
{"upc_cfi shows only UPC R.320", "upc_cfi", []int64{207}},
{"upc_coa shows only UPC R.320", "upc_coa", []int64{207}},
{"de_lg shows only ZPO §233", "de_lg", []int64{201}},
{"de_olg shows only ZPO §233", "de_olg", []int64{201}},
{"de_bgh shows only ZPO §233", "de_bgh", []int64{201}},
{"de_bpatg shows only PatG §123 (DE national)", "de_bpatg", []int64{200, 203}},
{"dpma shows only PatG §123 (DPMA)", "dpma", []int64{200, 203}},
{"epa_grant shows only EPC Art.122", "epa_grant", []int64{202}},
{"epa_opp shows only EPC Art.122", "epa_opp", []int64{202}},
{"epa_appeal shows only EPC Art.122", "epa_appeal", []int64{202}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{
Forums: []string{tc.forum},
Limit: 12,
})
if err != nil {
t.Fatalf("search: %v", err)
}
card := findCardBySlug(t, resp, "wiedereinsetzung")
got := map[int64]bool{}
for _, p := range card.Pills {
if p.TriggerEventID != nil {
got[*p.TriggerEventID] = true
}
}
want := map[int64]bool{}
for _, id := range tc.wantTrigIDs {
want[id] = true
}
for id := range got {
if !want[id] {
t.Errorf("forum=%s leaked trigger id %d (got pills: %v)", tc.forum, id, got)
}
}
for id := range want {
if !got[id] {
t.Errorf("forum=%s missing expected trigger id %d (got pills: %v)", tc.forum, id, got)
}
}
})
}
})
t.Run("multiple forum chips union the legal_source allow-list for triggers", func(t *testing.T) {
// upc_cfi + de_lg → UPC.* OR DE.ZPO.* → trigger ids 201 + 207.
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{
Forums: []string{"upc_cfi", "de_lg"},
Limit: 12,
})
if err != nil {
t.Fatalf("search: %v", err)
}
card := findCardBySlug(t, resp, "wiedereinsetzung")
got := map[int64]bool{}
for _, p := range card.Pills {
if p.TriggerEventID != nil {
got[*p.TriggerEventID] = true
}
}
want := map[int64]bool{201: true, 207: true}
for id := range got {
if !want[id] {
t.Errorf("union forum upc_cfi+de_lg leaked trigger id %d", id)
}
}
for id := range want {
if !got[id] {
t.Errorf("union forum upc_cfi+de_lg missing trigger id %d", id)
}
}
})
t.Run("empty forum filter leaves cross-cutting pills untouched", func(t *testing.T) {
// No forum chips = all 5 triggers stay visible.
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{Limit: 12})
if err != nil {
t.Fatalf("search: %v", err)
}
card := findCardBySlug(t, resp, "wiedereinsetzung")
count := 0
for _, p := range card.Pills {
if p.Kind == "trigger" {
count++
}
}
if count != 5 {
t.Errorf("empty forum filter dropped a trigger pill: got %d, want 5", count)
}
})
t.Run("party filter narrows to defendant-only", func(t *testing.T) {
resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Party: "claimant", Limit: 12})
if err != nil {

View File

@@ -6,28 +6,17 @@ package services
//
// Variables span six namespaces:
//
// firm.* process-wide (branding.Name)
// user.* caller's user row
// today.* server time in Europe/Berlin, locale-aware
// project.* paliad.projects + joined proceeding type
// parties.* paliad.parties grouped by role
// procedural_event.* paliad.deadline_rules row keyed by submission_code
// — the "what kind of step in the proceeding"
// identity (Schriftsatz, Anhörung, Entscheidung,
// …). See docs/design-procedural-events-model-
// 2026-05-25.md (t-paliad-262 Slice A).
// rule.* legacy alias for procedural_event.*; emitted
// unconditionally for backward compatibility
// with Word templates and saved drafts authored
// before the rename. @deprecated — new templates
// should use the procedural_event.* form.
// deadline.* next open paliad.deadlines row for
// (project, procedural_event), if any
// firm.* process-wide (branding.Name)
// user.* caller's user row
// today.* server time in Europe/Berlin, locale-aware
// project.* paliad.projects + joined proceeding type
// parties.* paliad.parties grouped by role
// rule.* paliad.deadline_rules row keyed by submission_code
// deadline.* next open paliad.deadlines row for (project, rule), if any
//
// Locale handling: every long-form date string is computed in both DE
// and EN; the renderer picks based on the user's lang preference. The
// procedural-event pretty-printer (legalSourcePretty) also has DE/EN
// variants.
// rule pretty-printer (legalSourcePretty) also has DE/EN variants.
//
// Visibility: caller passes userID; ProjectService.GetByID enforces
// paliad.can_see_project — unauthorised callers get the standard
@@ -184,12 +173,9 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
return out, nil
}
// loadPublishedRule fetches the published procedural-event template
// (paliad.deadline_rules row) keyed by submission_code. Restricts to
// lifecycle_state='published' so drafts never end up shaping a real
// submission. Function name retained for Slice A (prose-only); Slice
// B renames it to loadPublishedProceduralEvent when the Go type is
// renamed (t-paliad-262 §6).
// loadPublishedRule fetches the deadline_rule that owns the given
// submission_code. Restricts to lifecycle_state='published' so drafts
// never end up shaping a real submission.
func (s *SubmissionVarsService) loadPublishedRule(ctx context.Context, submissionCode string) (*models.DeadlineRule, error) {
if submissionCode == "" {
return nil, ErrSubmissionRuleNotFound
@@ -360,55 +346,21 @@ func addPartyVars(bag PlaceholderMap, parties []models.Party) {
}
}
// addRuleVars populates the procedural-event variable namespace —
// code, name(_en), legal_source (+ pretty form), primary_party, kind.
//
// Two key prefixes are emitted for every value:
//
// - procedural_event.* — canonical name (t-paliad-262 Slice A,
// design docs/design-procedural-events-model-2026-05-25.md).
// - rule.* — legacy alias kept forever (m's call,
// issue m/paliad#93 Q7); existing Word templates and saved
// submission_drafts authored before the rename keep working.
//
// `procedural_event.event_kind` is the canonical key for the
// procedural-event kind (filing|reply|hearing|decision|order). The
// legacy `rule.event_type` alias holds the same string. The column
// itself stays named `event_type` on `paliad.deadline_rules` — Slice
// A is prose-only; the column-level rename to `event_kind` is Slice B.
//
// Function name stays `addRuleVars` to avoid coupling Slice A to the
// Go-type rename which is Slice B (B.5 sub-slice).
// addRuleVars populates rule.* — submission_code, name(_en),
// legal_source (+ pretty form), primary_party, event_type.
func addRuleVars(bag PlaceholderMap, r *models.DeadlineRule, lang string) {
code := derefString(r.SubmissionCode)
var localizedName string
bag["rule.submission_code"] = derefString(r.SubmissionCode)
if strings.EqualFold(lang, "en") {
localizedName = r.NameEN
bag["rule.name"] = r.NameEN
} else {
localizedName = r.Name
bag["rule.name"] = r.Name
}
legalSource := derefString(r.LegalSource)
legalSourcePrettyVal := legalSourcePretty(legalSource, lang)
primaryParty := derefString(r.PrimaryParty)
eventKind := derefString(r.EventType)
bag["procedural_event.code"] = code
bag["procedural_event.name"] = localizedName
bag["procedural_event.name_de"] = r.Name
bag["procedural_event.name_en"] = r.NameEN
bag["procedural_event.legal_source"] = legalSource
bag["procedural_event.legal_source_pretty"] = legalSourcePrettyVal
bag["procedural_event.primary_party"] = primaryParty
bag["procedural_event.event_kind"] = eventKind
bag["rule.submission_code"] = code
bag["rule.name"] = localizedName
bag["rule.name_de"] = r.Name
bag["rule.name_en"] = r.NameEN
bag["rule.legal_source"] = legalSource
bag["rule.legal_source_pretty"] = legalSourcePrettyVal
bag["rule.primary_party"] = primaryParty
bag["rule.event_type"] = eventKind
bag["rule.legal_source"] = derefString(r.LegalSource)
bag["rule.legal_source_pretty"] = legalSourcePretty(derefString(r.LegalSource), lang)
bag["rule.primary_party"] = derefString(r.PrimaryParty)
bag["rule.event_type"] = derefString(r.EventType)
}
// addDeadlineVars populates deadline.* from the next pending row. When

View File

@@ -1,153 +0,0 @@
package services
// Regression test for the procedural-event placeholder aliases
// (t-paliad-262 Slice A, m/paliad#93 Q7).
//
// The variable bag emits TWO key prefixes for the procedural-event
// namespace:
//
// - procedural_event.* (canonical, post-rename)
// - rule.* (legacy, @deprecated)
//
// m's lock: keep the legacy aliases forever so lawyer-authored Word
// templates and existing paliad.submission_drafts rows that already
// contain `{{rule.X}}` keep merging correctly.
//
// This test pins the contract: every (canonical, legacy) pair must
// resolve to the same string in the placeholder map, for every value
// of (lang, present-vs-NULL columns). Removing the legacy aliases —
// or letting them drift in value from the canonical — must light up
// here BEFORE the change can land in main.
import (
"strings"
"testing"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/models"
)
func TestAddRuleVars_CanonicalAndLegacyAliasesMatch(t *testing.T) {
t.Parallel()
// Pairs are (canonical key, legacy key). Order matters only for
// the assertion message — the test checks string equality both
// ways round.
pairs := []struct {
canonical string
legacy string
}{
{"procedural_event.code", "rule.submission_code"},
{"procedural_event.name", "rule.name"},
{"procedural_event.name_de", "rule.name_de"},
{"procedural_event.name_en", "rule.name_en"},
{"procedural_event.legal_source", "rule.legal_source"},
{"procedural_event.legal_source_pretty", "rule.legal_source_pretty"},
{"procedural_event.primary_party", "rule.primary_party"},
{"procedural_event.event_kind", "rule.event_type"},
}
// Build a fully-populated rule row. Every nullable column has a
// distinct non-empty value so missing-value bugs (e.g. the legacy
// key copying "" while the canonical key copies the real value)
// would surface.
code := "dpma.appeal.bgh.begruendung"
desc := "Rechtsbeschwerdebegründung — § 102 PatG"
party := "both"
kind := "filing"
legal := "DE.PatG.102"
ruleCode := "§ 102 PatG"
rule := &models.DeadlineRule{
ID: uuid.New(),
SubmissionCode: &code,
Name: "Rechtsbeschwerdebegründung",
NameEN: "Appeal brief",
Description: &desc,
PrimaryParty: &party,
EventType: &kind,
LegalSource: &legal,
RuleCode: &ruleCode,
}
for _, lang := range []string{"de", "en"} {
t.Run(lang, func(t *testing.T) {
t.Parallel()
bag := PlaceholderMap{}
addRuleVars(bag, rule, lang)
for _, p := range pairs {
canonicalVal, canonicalOK := bag[p.canonical]
legacyVal, legacyOK := bag[p.legacy]
if !canonicalOK {
t.Errorf("canonical key %q missing from bag (lang=%s); "+
"Slice A must emit both forms", p.canonical, lang)
}
if !legacyOK {
t.Errorf("legacy alias %q missing from bag (lang=%s); "+
"removing legacy aliases would break existing Word "+
"templates that paliad doesn't see — keep the "+
"emission per m/paliad#93 Q7", p.legacy, lang)
}
if canonicalVal != legacyVal {
t.Errorf("alias drift: %q=%q vs %q=%q (lang=%s)",
p.canonical, canonicalVal,
p.legacy, legacyVal, lang)
}
}
// Sanity: the localized name actually localizes (the
// canonical and legacy `name` keys depend on lang). If
// this fails the loop above wouldn't catch it (both keys
// would agree on the wrong language).
localized := bag["procedural_event.name"]
if strings.EqualFold(lang, "en") && localized != rule.NameEN {
t.Errorf("expected EN localized name=%q, got %q",
rule.NameEN, localized)
}
if strings.EqualFold(lang, "de") && localized != rule.Name {
t.Errorf("expected DE localized name=%q, got %q",
rule.Name, localized)
}
})
}
}
func TestAddRuleVars_NullableFieldsEmitEmptyOnBothPrefixes(t *testing.T) {
t.Parallel()
// A minimal rule with every optional column NULL. The bag must
// still emit every canonical + legacy key — with the empty
// string — so downstream merging produces the standard
// "[KEIN WERT: ...]" marker rather than a broken template.
rule := &models.DeadlineRule{
ID: uuid.New(),
Name: "Generic step",
NameEN: "Generic step",
}
bag := PlaceholderMap{}
addRuleVars(bag, rule, "de")
mustHave := []string{
"procedural_event.code", "rule.submission_code",
"procedural_event.legal_source", "rule.legal_source",
"procedural_event.legal_source_pretty", "rule.legal_source_pretty",
"procedural_event.primary_party", "rule.primary_party",
"procedural_event.event_kind", "rule.event_type",
}
for _, key := range mustHave {
val, ok := bag[key]
if !ok {
t.Errorf("key %q missing from bag even with NULL source column; "+
"derefString must materialize the empty string so the "+
"merger sees the variable and renders the missing-value "+
"marker", key)
}
if val != "" {
t.Errorf("key %q = %q, want \"\" (source column was NULL)", key, val)
}
}
}