From 0c126445635a2dbe6493f1b5b1ec955ffaf36530 Mon Sep 17 00:00:00 2001 From: m Date: Fri, 8 May 2026 21:55:15 +0200 Subject: [PATCH 1/2] feat(deadline-rules): expose concept's canonical event_type per rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add paliad.deadline_concept_event_types junction (mig 072) mapping each deadline_concept to its canonical paliad.event_types row(s). Hydrate DeadlineRule.ConceptDefaultEventTypeID via one IN query per List call so /api/deadline-rules carries the autofill hint for the deadline create form (t-paliad-165 / m/paliad#18). Seed mapping covers the active concepts driving existing rules — 29 rows across 26 distinct concepts. Concepts without an obvious event_type counterpart (decision, filing, grant, the DE-only Begründung family) stay unmapped; auto-fill silently skips them. --- .../072_deadline_concept_event_types.down.sql | 2 + .../072_deadline_concept_event_types.up.sql | 113 ++++++++++++++++++ internal/models/models.go | 6 + internal/services/deadline_rule_service.go | 56 +++++++++ 4 files changed, 177 insertions(+) create mode 100644 internal/db/migrations/072_deadline_concept_event_types.down.sql create mode 100644 internal/db/migrations/072_deadline_concept_event_types.up.sql diff --git a/internal/db/migrations/072_deadline_concept_event_types.down.sql b/internal/db/migrations/072_deadline_concept_event_types.down.sql new file mode 100644 index 0000000..2dfad73 --- /dev/null +++ b/internal/db/migrations/072_deadline_concept_event_types.down.sql @@ -0,0 +1,2 @@ +-- t-paliad-165 down: drop the concept→event_type junction. +DROP TABLE IF EXISTS paliad.deadline_concept_event_types; diff --git a/internal/db/migrations/072_deadline_concept_event_types.up.sql b/internal/db/migrations/072_deadline_concept_event_types.up.sql new file mode 100644 index 0000000..29d04c6 --- /dev/null +++ b/internal/db/migrations/072_deadline_concept_event_types.up.sql @@ -0,0 +1,113 @@ +-- t-paliad-165: junction paliad.deadline_concept_event_types — maps each +-- deadline_concept to the canonical paliad.event_types row(s) that +-- represent it on the Typ chip cluster of the deadline create form. +-- +-- Why this exists +-- --------------- +-- The deadline create form (/projects/{id}/deadlines/new and the global +-- /deadlines/new) lets the user pick a Regel (paliad.deadline_rules) AND +-- independently pick a Typ (paliad.event_types). They are decoupled, so a +-- user can save a deadline whose Regel is `damages.rejoin — Duplik` but +-- Typ is `Klageerwiderung` — two different legal events. m hit this +-- contradiction during 2026-05-08 dogfooding (Gitea m/paliad#18). +-- +-- Each rule already carries paliad.deadline_rules.concept_id (mig 040), +-- so the rule knows what legal idea it represents. What was missing was +-- the canonical event_type for that concept. Slug-pattern heuristics are +-- unreliable (concept `notice-of-appeal` ↔ event_type +-- `upc_statement_of_appeal_2201`) and many concepts have multiple +-- candidate event_types (`statement-of-defence` ↔ base + with_ccr + +-- no_ccr); this junction makes the mapping explicit and curated. +-- +-- Shape +-- ----- +-- Many-to-many, so concepts that genuinely have several candidate types +-- (with_ccr / no_ccr / base; UPC + EPO + DPMA opposition) get one row +-- per type. is_default picks the single row the create-form auto-fills +-- when the user picks a Regel attached to this concept. The remaining +-- rows are reserved for future surfaces (e.g. Determinator save flow +-- might want to see all candidates) but the create-form only consumes +-- is_default for now. +-- +-- Idempotent against re-seeds: the seed below uses ON CONFLICT DO +-- NOTHING so a second run after manual mapping additions doesn't blow +-- them away. Down migration drops the table entirely. + +CREATE TABLE paliad.deadline_concept_event_types ( + concept_id uuid NOT NULL + REFERENCES paliad.deadline_concepts(id) ON DELETE CASCADE, + event_type_id uuid NOT NULL + REFERENCES paliad.event_types(id) ON DELETE CASCADE, + is_default bool NOT NULL DEFAULT false, + sort_order int NOT NULL DEFAULT 100, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (concept_id, event_type_id) +); + +COMMENT ON TABLE paliad.deadline_concept_event_types IS + 'Junction mapping paliad.deadline_concepts → paliad.event_types. ' + 'Lets the deadline create form auto-populate the Typ chip when the ' + 'user picks a Regel — the rule''s concept points here for its ' + 'canonical event_type(s). Many-to-many for concepts with several ' + 'natural variants (with_ccr / no_ccr / base, EPO + DPMA opposition).'; + +COMMENT ON COLUMN paliad.deadline_concept_event_types.is_default IS + 'Exactly one row per concept_id should be marked default — that is ' + 'the row the create-form chip cluster auto-fills with. Other rows ' + 'remain selectable from the picker as alternatives.'; + +CREATE UNIQUE INDEX deadline_concept_event_types_one_default + ON paliad.deadline_concept_event_types (concept_id) + WHERE is_default = true; + +CREATE INDEX deadline_concept_event_types_event_type + ON paliad.deadline_concept_event_types (event_type_id); + +-- ============================================================================ +-- Seed: curated mapping for active concepts that drive existing rules. +-- +-- Concepts without an obvious event_type counterpart (filing, grant, +-- decision, publication, communication-r71-3, search-report, the various +-- DE-only Begründung concepts) stay unmapped — auto-fill silently +-- skips them, leaving the user to pick a Typ manually as today. +-- Future migrations can fill those gaps as event_types are added. +-- ============================================================================ + +INSERT INTO paliad.deadline_concept_event_types (concept_id, event_type_id, is_default, sort_order) +SELECT dc.id, et.id, mapping.is_default, mapping.sort_order + FROM (VALUES + -- (concept_slug, event_type_slug, is_default, sort_order) + ('application-for-cost-decision', 'upc_application_for_cost_decision', true, 10), + ('application-for-determination-of-damages', 'upc_application_for_damages', true, 10), + ('application-for-revocation', 'upc_statement_for_revocation', true, 10), + ('application-for-provisional-measures', 'upc_protective_letter', true, 10), + ('cost-decision', 'upc_decision_on_costs', true, 10), + ('counterclaim-for-infringement', 'upc_counterclaim_for_infringement', true, 10), + ('counterclaim-for-revocation', 'upc_counterclaim_for_revocation', true, 10), + ('cross-appeal', 'upc_cross_appeal_2242a', true, 10), + ('defence-to-application-to-amend', 'upc_defence_to_amend_patent', true, 10), + ('defence-to-counterclaim-for-revocation', 'upc_defence_to_revocation', true, 10), + ('notice-of-appeal', 'upc_statement_of_appeal_2201', true, 10), + ('opposition', 'epo_opposition_filing', true, 10), + ('opposition', 'dpma_opposition', false, 20), + ('oral-hearing', 'upc_oral_hearing', true, 10), + ('order', 'upc_case_management_order', true, 10), + ('rejoinder', 'upc_rejoinder_to_reply', true, 10), + ('reply-to-cross-appeal', 'upc_cross_appeal_2242a', true, 10), + ('reply-to-defence', 'upc_reply_to_defence', true, 10), + ('reply-to-defence-to-application-to-amend', 'upc_reply_to_defence_to_amend_patent', true, 10), + ('reply-to-defence-to-counterclaim-for-revocation','upc_reply_to_defence_to_revocation', true, 10), + ('request-for-examination', 'dpma_examination_request', true, 10), + ('request-to-lay-open-books', 'upc_request_to_lay_open_books', true, 10), + ('response-to-appeal', 'upc_grounds_of_appeal_2242a', true, 10), + ('statement-of-claim', 'upc_statement_of_claim', true, 10), + ('statement-of-defence', 'upc_statement_of_defence', true, 10), + ('statement-of-defence', 'upc_statement_of_defence_with_ccr', false, 20), + ('statement-of-defence', 'upc_statement_of_defence_no_ccr', false, 30), + ('statement-of-grounds-of-appeal', 'upc_grounds_of_appeal_2242a', true, 10), + ('statement-of-grounds-of-appeal', 'epo_appeal_grounds', false, 20) + ) AS mapping(concept_slug, event_type_slug, is_default, sort_order) + JOIN paliad.deadline_concepts dc ON dc.slug = mapping.concept_slug + JOIN paliad.event_types et ON et.slug = mapping.event_type_slug + AND et.archived_at IS NULL +ON CONFLICT (concept_id, event_type_id) DO NOTHING; diff --git a/internal/models/models.go b/internal/models/models.go index 011d2e9..697ce95 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -468,6 +468,12 @@ type DeadlineRule struct { AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"` AnchorAlt *string `db:"anchor_alt" json:"anchor_alt,omitempty"` ConceptID *uuid.UUID `db:"concept_id" json:"concept_id,omitempty"` + // ConceptDefaultEventTypeID is the canonical paliad.event_types row for + // this rule's concept (joined via paliad.deadline_concept_event_types + // where is_default = true). Lets the deadline create form auto-populate + // the Typ chip when the user picks this rule. Hydrated by the service + // layer; not a column. NULL when the concept has no mapped event_type. + ConceptDefaultEventTypeID *uuid.UUID `db:"-" json:"concept_default_event_type_id,omitempty"` LegalSource *string `db:"legal_source" json:"legal_source,omitempty"` IsSpawn bool `db:"is_spawn" json:"is_spawn"` SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"` diff --git a/internal/services/deadline_rule_service.go b/internal/services/deadline_rule_service.go index 45fbf64..5463efc 100644 --- a/internal/services/deadline_rule_service.go +++ b/internal/services/deadline_rule_service.go @@ -32,6 +32,9 @@ const proceedingTypeColumns = `id, code, name, name_en, description, jurisdictio category, default_color, sort_order, is_active` // List returns active rules, optionally filtered by proceeding type. +// Each row has ConceptDefaultEventTypeID hydrated from +// paliad.deadline_concept_event_types so the deadline-create form can +// auto-populate the Typ chip when the user picks a Regel. func (s *DeadlineRuleService) List(ctx context.Context, proceedingTypeID *int) ([]models.DeadlineRule, error) { var rules []models.DeadlineRule var err error @@ -52,9 +55,62 @@ func (s *DeadlineRuleService) List(ctx context.Context, proceedingTypeID *int) ( if err != nil { return nil, fmt.Errorf("list deadline rules: %w", err) } + if err := s.hydrateConceptDefaultEventTypes(ctx, rules); err != nil { + return nil, err + } return rules, nil } +// hydrateConceptDefaultEventTypes resolves rule.ConceptID → +// paliad.deadline_concept_event_types.event_type_id (where is_default) +// for every rule with a non-nil ConceptID, and assigns the result. +// One round-trip; rules whose concept has no default mapping stay NULL. +func (s *DeadlineRuleService) hydrateConceptDefaultEventTypes(ctx context.Context, rules []models.DeadlineRule) error { + conceptIDs := make([]uuid.UUID, 0, len(rules)) + seen := make(map[uuid.UUID]bool, len(rules)) + for _, r := range rules { + if r.ConceptID == nil || seen[*r.ConceptID] { + continue + } + seen[*r.ConceptID] = true + conceptIDs = append(conceptIDs, *r.ConceptID) + } + if len(conceptIDs) == 0 { + return nil + } + query, args, err := sqlx.In( + `SELECT concept_id, event_type_id + FROM paliad.deadline_concept_event_types + WHERE is_default = true AND concept_id IN (?)`, conceptIDs) + if err != nil { + return fmt.Errorf("build concept→event_type IN query: %w", err) + } + query = s.db.Rebind(query) + + type row struct { + ConceptID uuid.UUID `db:"concept_id"` + EventTypeID uuid.UUID `db:"event_type_id"` + } + var rows []row + if err := s.db.SelectContext(ctx, &rows, query, args...); err != nil { + return fmt.Errorf("load concept→event_type defaults: %w", err) + } + defaultByConcept := make(map[uuid.UUID]uuid.UUID, len(rows)) + for _, r := range rows { + defaultByConcept[r.ConceptID] = r.EventTypeID + } + for i := range rules { + if rules[i].ConceptID == nil { + continue + } + if et, ok := defaultByConcept[*rules[i].ConceptID]; ok { + etCopy := et + rules[i].ConceptDefaultEventTypeID = &etCopy + } + } + return nil +} + // RuleTreeNode pairs a rule with its child rules in a parent_id hierarchy. type RuleTreeNode struct { models.DeadlineRule From 1e97eccaed054bece26c773b46a2e39a07bc5522 Mon Sep 17 00:00:00 2001 From: m Date: Fri, 8 May 2026 21:59:22 +0200 Subject: [PATCH 2/2] feat(deadlines/new): auto-link Typ to Regel's concept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user picks a Regel on /projects/{id}/deadlines/new (or the global /deadlines/new), auto-populate the Typ chip with the rule's concept's canonical event_type — using the concept_default_event_type_id field server-side hydrated by mig 072. Soft hint "Typ vorgegeben durch Regel — entfernen, um zu überschreiben" when the chip exactly matches the rule's suggestion. Soft warning "Hinweis: Typ widerspricht Regel" when the user has picked an event_type that contradicts the rule's concept. The picker is replaced silently when it still reflects the previous rule's auto-fill (or is empty); leaves a manually-edited picker alone. DE+EN i18n via deadlines.field.rule.{autofill,mismatch}. Reuses the existing .form-hint--warning yellow-tint style; no new CSS. Closes m/paliad#18 Item A — rule-vs-event redundancy on the manual deadline create form. --- frontend/src/client/deadlines-new.ts | 81 ++++++++++++++++++++++++++++ frontend/src/client/i18n.ts | 4 ++ frontend/src/deadlines-new.tsx | 20 +++++++ frontend/src/i18n-keys.ts | 2 + 4 files changed, 107 insertions(+) diff --git a/frontend/src/client/deadlines-new.ts b/frontend/src/client/deadlines-new.ts index 3df1551..78b4e8b 100644 --- a/frontend/src/client/deadlines-new.ts +++ b/frontend/src/client/deadlines-new.ts @@ -19,8 +19,22 @@ interface DeadlineRule { name: string; name_en: string; rule_code?: string; + // t-paliad-165 — canonical event_type for this rule's concept, + // hydrated server-side from paliad.deadline_concept_event_types. + // Drives auto-fill of the Typ chip when the user picks this rule. + concept_default_event_type_id?: string | null; } +// Rules indexed by id so the Regel-change handler can look up the +// concept's canonical event_type without re-fetching. +let rulesByID = new Map(); + +// Last event_type the rule auto-filled. Tracked so we can tell whether +// the picker still reflects the rule's suggestion (replace silently on +// new rule pick) or whether the user has manually edited (leave alone, +// surface the mismatch warning instead). +let lastAutoFilledEventTypeID: string | null = null; + let preselectedProjectID = ""; function esc(s: string): string { @@ -71,6 +85,7 @@ async function loadRules() { const resp = await fetch("/api/deadline-rules"); if (!resp.ok) return; const rules: DeadlineRule[] = await resp.json(); + rulesByID = new Map(rules.map((r) => [r.id, r])); const opts: string[] = [ ``, ]; @@ -85,6 +100,65 @@ async function loadRules() { } } +// t-paliad-165 — refresh the autofill hint + mismatch warning state. The +// hint shows when the picker exactly matches the current rule's +// suggestion (so the chip is "vorgegeben durch Regel"). The warning +// shows when the user has picked event_types that don't include the +// rule's canonical default (override is fine, just call it out). +function refreshRuleHints(): void { + const hint = document.getElementById("deadline-event-type-rule-hint"); + const warn = document.getElementById("deadline-event-type-rule-mismatch"); + if (!hint || !warn) return; + + const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || ""; + const rule = ruleID ? rulesByID.get(ruleID) : undefined; + const expected = rule?.concept_default_event_type_id ?? null; + const picked = eventTypePicker?.getIDs() ?? []; + + if (!ruleID || !expected) { + hint.style.display = "none"; + warn.style.display = "none"; + return; + } + if (picked.length === 1 && picked[0] === expected) { + hint.style.display = ""; + warn.style.display = "none"; + return; + } + hint.style.display = "none"; + warn.style.display = picked.includes(expected) ? "none" : ""; +} + +// applyRuleAutoFill replaces the picker silently when it still reflects +// the previous rule's suggestion (or is empty); leaves a manually-edited +// picker alone. Called whenever the Regel select changes. +function applyRuleAutoFill(): void { + if (!eventTypePicker) return; + const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || ""; + const rule = ruleID ? rulesByID.get(ruleID) : undefined; + const expected = rule?.concept_default_event_type_id ?? null; + const current = eventTypePicker.getIDs(); + + const pickerStillReflectsLastSuggestion = + lastAutoFilledEventTypeID !== null && + current.length === 1 && + current[0] === lastAutoFilledEventTypeID; + const pickerIsEmpty = current.length === 0; + + if (expected) { + if (pickerIsEmpty || pickerStillReflectsLastSuggestion) { + eventTypePicker.setIDs([expected]); + lastAutoFilledEventTypeID = expected; + } + } else if (pickerStillReflectsLastSuggestion) { + // New rule has no canonical event_type — clear the stale auto-fill + // so the picker doesn't carry a hint from the old rule. + eventTypePicker.setIDs([]); + lastAutoFilledEventTypeID = null; + } + refreshRuleHints(); +} + function initBackLinks() { if (preselectedProjectID) { const back = document.getElementById("deadline-new-back") as HTMLAnchorElement; @@ -233,8 +307,15 @@ document.addEventListener("DOMContentLoaded", async () => { if (pickerHost) { eventTypePicker = attachEventTypePicker(pickerHost, { currentUserAdmin, + onChange: () => refreshRuleHints(), }); } + // t-paliad-165 — Regel change auto-fills the Typ chip from the rule's + // concept's canonical event_type, when the picker hasn't been + // manually edited away from the previous rule's suggestion. + document.getElementById("deadline-rule")?.addEventListener("change", () => { + applyRuleAutoFill(); + }); // Wire approval-hint refresh: on first render + on project change. void refreshApprovalHint(); document.getElementById("deadline-project")?.addEventListener("change", () => { diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index 4972c4e..f7a298a 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -740,6 +740,8 @@ const translations: Record> = { "deadlines.field.due": "F\u00e4lligkeitsdatum", "deadlines.field.rule": "Regel (optional)", "deadlines.field.rule.none": "Keine Regel", + "deadlines.field.rule.autofill": "Typ vorgegeben durch Regel — entfernen, um zu überschreiben.", + "deadlines.field.rule.mismatch": "Hinweis: Typ widerspricht Regel — Sie haben den Typ überschrieben.", "deadlines.field.notes": "Notizen (optional)", "deadlines.field.notes.placeholder": "Hinweise, Verweise, n\u00e4chste Schritte\u2026", "deadlines.error.required": "Akte, Titel und F\u00e4lligkeitsdatum sind Pflichtfelder.", @@ -2877,6 +2879,8 @@ const translations: Record> = { "deadlines.field.due": "Due date", "deadlines.field.rule": "Rule (optional)", "deadlines.field.rule.none": "No rule", + "deadlines.field.rule.autofill": "Type set by rule — remove to override.", + "deadlines.field.rule.mismatch": "Note: type contradicts rule — you have overridden the type.", "deadlines.field.notes": "Notes (optional)", "deadlines.field.notes.placeholder": "References, hints, next steps\u2026", "deadlines.error.required": "Matter, title and due date are required.", diff --git a/frontend/src/deadlines-new.tsx b/frontend/src/deadlines-new.tsx index e2b9164..3596662 100644 --- a/frontend/src/deadlines-new.tsx +++ b/frontend/src/deadlines-new.tsx @@ -58,6 +58,26 @@ export function renderDeadlinesNew(): string {
+ {/* t-paliad-165 — soft hint when the Regel auto-populated + the chip, and a soft warning when the user picked a + Regel + Typ combination that contradicts the rule's + concept. Both rendered yellow, never blocking. */} + +
diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts index 82dcd34..aaf26e3 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -819,6 +819,8 @@ export type I18nKey = | "deadlines.field.notes" | "deadlines.field.notes.placeholder" | "deadlines.field.rule" + | "deadlines.field.rule.autofill" + | "deadlines.field.rule.mismatch" | "deadlines.field.rule.none" | "deadlines.field.title" | "deadlines.field.title.placeholder"