Merge: t-paliad-165 — Regel ↔ Typ collapse via auto-link on the deadline create form
Two slices on mai/noether/collapse-regel-typ-on:0c12644feat(deadline-rules): expose concept's canonical event_type per rule1e97eccfeat(deadlines/new): auto-link Typ to Regel's concept What ships: - New junction paliad.deadline_concept_event_types maps every paliad.deadline_concepts row to its canonical paliad.event_types row(s). Many-to-many for concepts with multiple legitimate variants (statement-of-defence ↔ base + with_ccr + no_ccr; opposition across EPO + DPMA). Exactly one row per concept marked is_default = true by a partial unique index — that is the row the deadline form auto-fills with. - Backend: paliad.deadline_rules_with_concept_event_type view + the deadline-rules read path now expose the rule's default concept event_type so the form has the auto-fill target without an extra round-trip. - Frontend deadline create / edit form: when the user picks a Regel, the Typ chip auto-fills with the rule's concept's default event_type. A small "vorgegeben durch Regel — überschreiben?" hint sits next to the chip so the auto-fill is visible. The user can override (free- text or pick a different type); the override is explicit, no blocking validation. - Free-text Typ stays available — manual deadlines without a matching rule (e.g. "Call me" reminders) keep working as today. Migration housekeeping ====================== noether authored her migration as 072 on her branch but main had already taken 072 via minkowski's t-paliad-164 (paliad.projects.our_side). Renumbered to 073 during merge resolution to resolve the same-number collision. Added IF NOT EXISTS guards on CREATE TABLE / CREATE INDEX for re-run safety (the seed INSERT already had ON CONFLICT DO NOTHING). Live tracker bumped 72 → 73 in the same operation: both effects (our_side column AND deadline_concept_event_types table) were applied to live during dev (each worker against the same DB), so the tracker advance reflects schema reality. Next deploy sees tracker=73 with file 073 present and has nothing to apply. Refs m/paliad#18.
This commit is contained in:
@@ -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<string, DeadlineRule>();
|
||||
|
||||
// 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[] = [
|
||||
`<option value="" data-i18n="deadlines.field.rule.none">${esc(t("deadlines.field.rule.none"))}</option>`,
|
||||
];
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -741,6 +741,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"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.",
|
||||
@@ -2889,6 +2891,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"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.",
|
||||
|
||||
@@ -58,6 +58,26 @@ export function renderDeadlinesNew(): string {
|
||||
<div className="form-field">
|
||||
<label data-i18n="deadlines.field.event_type">Typ (optional)</label>
|
||||
<div id="deadline-event-types" className="event-type-picker-host" />
|
||||
{/* 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. */}
|
||||
<p
|
||||
className="form-hint"
|
||||
id="deadline-event-type-rule-hint"
|
||||
style="display:none"
|
||||
data-i18n="deadlines.field.rule.autofill"
|
||||
>
|
||||
Typ vorgegeben durch Regel — entfernen, um zu überschreiben.
|
||||
</p>
|
||||
<p
|
||||
className="form-hint form-hint--warning"
|
||||
id="deadline-event-type-rule-mismatch"
|
||||
style="display:none"
|
||||
data-i18n="deadlines.field.rule.mismatch"
|
||||
>
|
||||
Hinweis: Typ widerspricht Regel — Sie haben den Typ überschrieben.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-field-row">
|
||||
|
||||
@@ -820,6 +820,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"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- t-paliad-165 down: drop the concept→event_type junction.
|
||||
DROP TABLE IF EXISTS paliad.deadline_concept_event_types;
|
||||
113
internal/db/migrations/073_deadline_concept_event_types.up.sql
Normal file
113
internal/db/migrations/073_deadline_concept_event_types.up.sql
Normal file
@@ -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 IF NOT EXISTS 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 IF NOT EXISTS deadline_concept_event_types_one_default
|
||||
ON paliad.deadline_concept_event_types (concept_id)
|
||||
WHERE is_default = true;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS 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;
|
||||
@@ -475,6 +475,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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user