Files
paliad/docs/design-event-types-2026-04-30.md
m 75867b2a3e design(t-paliad-088): resolve open questions per m's calls
m greenlit all 7 open questions on 2026-04-30 12:23. Notable changes
from the initial draft:

- Submissions are explicitly the primary Event-Type use case, not a
  secondary discriminator. m: "those are the event types I mean,
  mainly". Deferring a separate paliad.submissions table stands.
- /deadlines + /agenda Typ filter is MULTI-SELECT (UNION across
  selected types, AND-intersected with Status/Projekt). New
  EventTypeMultiSelect component spec'd in §4: trigger button styled
  like the existing <select>s, popover with search + grouped checkbox
  list. Status/Projekt stay single-select.
- Firm-wide Event-Type creation OPEN to any authenticated user. RLS
  insert policy simplified to created_by=self. Admins moderate via
  archive. Mitigation: duplicate-warning in the add modal. Follow-up
  t-paliad-089 flagged for admin moderation panel.
- Broader-scope seeds confirmed (UPC + EPO + DPMA + DE + contract).
- §12 rewritten as a resolution table.
2026-04-30 12:26:53 +02:00

33 KiB
Raw Permalink Blame History

Event Types for deadlines + submissions — design

Task: t-paliad-088 Author: cronus (inventor) Date: 2026-04-30 Status: RESOLVED 2026-04-30 12:23 — m greenlit all 7 questions. See §12 for the resolution table. Awaiting head's coder assignment.

m's directive (2026-04-30 11:56):

"let's add an inventor for 'Event Types', in particular deadlines and submissions — I want to be able to select from existing Event Types when creating a deadline but also add a new custom one if it does not exist. This also needs to be filterable in the overview"

TL;DR — resolved decisions

  1. Concept: "Event Type" is the categorization tag on a deadline. Event Types lead, with an optional bridge FK to paliad.trigger_events for the seeded UPC rows (m's call: "event_types should lead and later we can connect things to it"). Submissions are explicitly the primary use case — m's words: "those are the event types I mean, mainly". trigger_events stays as separate calc-engine state (UPC-only verbatim youpc imports).
  2. Schema: new table paliad.event_types + nullable FK paliad.deadlines.event_type_id. The bridge event_types.trigger_event_id bigint NULL REFERENCES paliad.trigger_events(id) populates only for seeded UPC rows; user customs and non-UPC entries leave it NULL. Broader scope from day one (UPC + EPO + DPMA + DE-national + contract).
  3. Submissions live as Event Types. No separate paliad.submissions table. event_types.category='submission' carries the discrimination. A future Schriftsatz-Verwaltung surface pivots on that filter.
  4. Picker: typeahead <select>-flavoured combobox with grouping by category, plus inline "+ Neuen Typ hinzufügen…" → small modal. Reuses the /tools/fristenrechner trigger-picker style.
  5. Filter on /deadlines is MULTI-SELECT. Trigger button styled like an <select>; click opens a listbox panel with search + "Alle" toggle + checkbox list grouped by category. Backend: ?event_type=uuid1,uuid2,… (UNION within Event Types, AND-intersected with Status/Projekt). Special value none for "Ohne Typ"; combinable with selected types. Same multi-select filter on /agenda.
  6. Permissions: any authenticated user can create both private AND firm-wide types. Admins moderate firm-wide via archive after the fact (m's call: lighter-weight onboarding > admin gating).
  7. Backfill: existing 10 deadlines get event_type_id=NULL, render as "Ohne Typ". Migration 030 ships ~40 curated firm-wide seeds (~25 UPC submissions + ~10 UPC decisions/orders + ~10 non-UPC EPO/DPMA/DE-national/contract). Spreadsheet attached to the implementation PR.

1 · Concept clarification

What lives where today

Surface Concept Examples Cardinality Origin
paliad.trigger_events (migration 028) "What just happened" — anchor for event_deadlines calc service_of_complaint, decision_handed_down, statement_of_defence 102 rows, UPC only Imported verbatim from youpc.data.events for diffable re-syncs
paliad.event_deadlines (migration 028) "After event X, deadline Y fires" rules RoP.029 1-mo Reply after Defence 70 rows Imported verbatim from youpc.data.deadlines
/tools/fristenrechner trigger-picker (PR-2) UI input over trigger_events "Was kommt nach 'Statement of defence'?" Public knowledge tool
paliad.deadlines (migration 003+) Persistent per-project scheduled deadline Free-text title, due_date, project_id, optional rule_iddeadline_rules 10 rows in production User-created, sometimes Fristenrechner-seeded

What m is asking for

A categorization on paliad.deadlines so the user can:

  • pick from a known taxonomy when creating a deadline,
  • add a custom type if missing,
  • filter the /deadlines list by type.

That is unambiguously a taxonomy column on deadlines, not a trigger event.

Are Event Types == trigger_events with a UX rename?

No. Three reasons:

  1. Scope. trigger_events is UPC-only (102 rows from youpc's UPC corpus — see §verified data below). Paliad's deadlines also cover EPO opposition/appeal, DPMA, German national court (LG/OLG Düsseldorf, München), and contract/IP-licensing renewal dates. The trigger_events corpus has zero EPO-opposition events, zero DPMA events, and only a handful of cross-jurisdiction items (Decision of the EPO is still in the UPC unitary-effect context). Renaming it would falsely suggest paliad covers all jurisdictions.
  2. Diffability invariant. Memory note from t-paliad-086: "IDs preserved verbatim from youpc data.events / data.deadlines / data.deadline_rule_codes for diffable re-syncs." Letting users insert custom rows into trigger_events would either break this (id collisions) or require a separate id range — both compromise the import contract. trigger_events is canonical-imports state, not user-extensible.
  3. Different semantics. A trigger_event is "the event that just occurred, used as anchor". An Event Type on a deadline is "what this deadline categorically IS". For 70-ish rows they coincide (a deadline whose Type is "Reply to Defence" would naturally have trigger_event_id pointing to the same code). For decisions/orders they don't really coincide — decision_handed_down as a trigger anchors future deadlines, but as an Event Type it labels the date the decision is expected. Both views are useful. Conflating them collapses the distinction.

Are Event Types == "submissions" as a sibling entity?

No. Not now.

A submission is a deadline-bearing item ("filed Statement of Defence on 2026-08-15" — that's a deadline whose category is submission). Splitting submissions into their own table either duplicates data or forces a migration of existing deadlines, both for negative gain. The Event Type's category column carries the discrimination (submission | decision | order | service | fee | hearing | other). A future Schriftsatz-Verwaltung surface (out of scope here) can pivot on WHERE event_types.category='submission'.

Re-evaluate this if/when m wants a true Schriftsatz-Verwaltung with submission-specific fields (file uploads, version tracking, recipient party, language) that don't fit on a generic deadline row. That's a separate task; flagging here so we don't have to migrate twice.

Bridge to trigger_events

paliad.event_types carries an optional trigger_event_id bigint REFERENCES paliad.trigger_events(id). For the seeded firm-wide types we populate it; for user customs and non-UPC types it stays NULL. This:

  • preserves provenance for the UPC-seeded types,
  • enables future polish: "this deadline's Event Type matches a trigger event → offer to compute downstream deadlines via Fristenrechner",
  • doesn't force a circular dependency (Event Types can exist without a trigger_event).

2 · Schema decision

Migration 030 — paliad.event_types + FK on paliad.deadlines

-- internal/db/migrations/030_event_types.up.sql

CREATE TABLE paliad.event_types (
    id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    slug            text NOT NULL,
    label_de        text NOT NULL,
    label_en        text NOT NULL,
    category        text NOT NULL DEFAULT 'submission'
                        CHECK (category IN ('submission','decision','order','service','fee','hearing','other')),
    jurisdiction    text
                        CHECK (jurisdiction IS NULL OR jurisdiction IN ('UPC','EPO','DPMA','DE','any')),
    description     text,
    trigger_event_id bigint REFERENCES paliad.trigger_events(id) ON DELETE SET NULL,
    created_by      uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
    is_firm_wide    boolean NOT NULL DEFAULT false,
    archived_at     timestamptz,
    created_at      timestamptz NOT NULL DEFAULT now(),
    updated_at      timestamptz NOT NULL DEFAULT now()
);

-- Slug uniqueness: firm-wide types share one namespace; private types are scoped per user.
CREATE UNIQUE INDEX event_types_firm_slug_idx
  ON paliad.event_types(slug)
  WHERE is_firm_wide = true AND archived_at IS NULL;

CREATE UNIQUE INDEX event_types_private_slug_idx
  ON paliad.event_types(created_by, slug)
  WHERE is_firm_wide = false AND archived_at IS NULL;

CREATE INDEX event_types_category_idx ON paliad.event_types(category);
CREATE INDEX event_types_jurisdiction_idx ON paliad.event_types(jurisdiction) WHERE jurisdiction IS NOT NULL;

-- FK on deadlines
ALTER TABLE paliad.deadlines
  ADD COLUMN event_type_id uuid
    REFERENCES paliad.event_types(id) ON DELETE SET NULL;

CREATE INDEX deadlines_event_type_idx
  ON paliad.deadlines(event_type_id)
  WHERE event_type_id IS NOT NULL;

-- updated_at trigger (mirrors existing paliad.set_updated_at pattern)
CREATE TRIGGER event_types_set_updated_at
  BEFORE UPDATE ON paliad.event_types
  FOR EACH ROW EXECUTE FUNCTION paliad.set_updated_at();

-- RLS
ALTER TABLE paliad.event_types ENABLE ROW LEVEL SECURITY;

-- Read: firm-wide types visible to all authenticated users; private types only to author.
CREATE POLICY event_types_select ON paliad.event_types
  FOR SELECT TO authenticated
  USING (
    archived_at IS NULL
    AND (is_firm_wide = true OR created_by = auth.uid())
  );

-- Insert: any authenticated user can insert any row, as long as created_by = self.
-- Firm-wide types are open to all users; admins moderate via archive after the fact.
CREATE POLICY event_types_insert ON paliad.event_types
  FOR INSERT TO authenticated
  WITH CHECK (created_by = auth.uid());

-- Update: author owns their own rows (private or firm-wide they created).
-- global_admin can update / archive any firm-wide row regardless of authorship.
CREATE POLICY event_types_update_owner ON paliad.event_types
  FOR UPDATE TO authenticated
  USING (created_by = auth.uid())
  WITH CHECK (created_by = auth.uid());

CREATE POLICY event_types_update_admin ON paliad.event_types
  FOR UPDATE TO authenticated
  USING (
    is_firm_wide = true
    AND EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
  );

-- Delete: never. Use archived_at.

Why a separate table (not a deadlines.event_type text column)

A free-text column would let users type the same concept three ways ("Reply", "reply", "Erwiderung") and the filter would silently miss matches. The whole point of typing is that the same name resolves to one row. A table also lets us:

  • ship 30+ curated firm-wide labels with bilingual text,
  • carry category + jurisdiction metadata for grouped picker,
  • cross-link to trigger_events for the Fristenrechner-handoff polish,
  • support archiving (firm renames "Beschwerdebegründung" → leave old rows untouched, archive the type).

Why optional FK and not NOT NULL on deadlines

Existing 10 deadlines have no event_type. Backfilling automatically would either guess or be wrong. NULL = "Ohne Typ" works fine — the filter row has an "Alle" default and an "Ohne Typ" option. Users tag retrospectively when they edit a deadline.

Slug strategy

Slug is auto-derived from label_de (kebab-case) on insert if not supplied; user-private slugs are scoped per user so two users can each have their own "klage" without colliding. Firm-wide slugs share one namespace — global_admins coordinate.

3 · Picker UX

Where the picker appears

  • /deadlines/new — new optional field below "Titel", above the date+rule row.
  • /deadlines/{id} — edit modal, same field shape.
  • (Future polish, NOT in scope) /tools/fristenrechner "Send to deadline" button — pre-fills event_type_id from the originating trigger_event.

Visual shape (matches existing .akten-form field-row pattern)

┌─────────────────────────────────────────────────────────┐
│ Typ (optional)                                          │
│ ┌─────────────────────────────────────────────────┬───┐ │
│ │ Bitte wählen oder tippen…                       │ ▼ │ │
│ └─────────────────────────────────────────────────┴───┘ │
│                                                         │
│ When opened (with no input):                            │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Eigene                                              │ │
│ │   ⭐ Mein Mahnschriftsatz-Template                  │ │
│ │ Eingaben (UPC)                                      │ │
│ │   • Statement of Defence                            │ │
│ │   • Reply to the Defence                            │ │
│ │   • Counterclaim for Revocation                     │ │
│ │   • Statement of Appeal                             │ │
│ │   ... (~30)                                         │ │
│ │ Entscheidungen                                      │ │
│ │   • Decision on the merits                          │ │
│ │   • Decision on costs                               │ │
│ │ Anordnungen                                         │ │
│ │   • Case management order (Service)                 │ │
│ │ Gebühren                                            │ │
│ │   • Annuity payment (DPMA)                          │ │
│ │   • EP renewal fee                                  │ │
│ │ ─────────────────────────────────────────────────── │ │
│ │ + Neuen Typ hinzufügen…                             │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘

The trigger-event picker on /tools/fristenrechner (PR-2) already implements typeahead-over-list. Reuse its filter logic and visual style; differences:

  • Grouped by category with sticky group headers (the trigger picker is flat).
  • "Eigene" group at top (private types of the current user) with a star icon.
  • "+ Neuen Typ hinzufügen…" footer row triggers the add modal.

Custom-add modal

Lightweight <dialog> (matches the existing .modal pattern from t-paliad-049):

┌─ Neuen Event-Typ anlegen ─────────────────────────┐
│                                                   │
│  Bezeichnung (DE) *                               │
│  [_______________________________________]        │
│                                                   │
│  Bezeichnung (EN, optional)                       │
│  [_______________________________________]        │
│                                                   │
│  Kategorie *                                      │
│  [Eingabe ▼]  (Eingabe / Entscheidung /           │
│                Anordnung / Zustellung /           │
│                Gebühr / Sitzung / Sonstiges)      │
│                                                   │
│  Jurisdiktion (optional)                          │
│  [— ▼]  (UPC / EPA / DPMA / DE / —)               │
│                                                   │
│  ☐ Firmenweit verfügbar machen  *                 │
│   (* nur für Admins sichtbar)                     │
│                                                   │
│         [ Abbrechen ]  [ Anlegen ]                │
└───────────────────────────────────────────────────┘

Behaviour:

  • If user typed text in the picker before clicking "+ Neuen Typ", that text pre-fills "Bezeichnung (DE)".
  • "Firmenweit" checkbox is only rendered for users where currentUser.global_role === 'global_admin'. Non-admin private-only.
  • On submit: POST /api/event-types, on 201 the picker re-fetches its option list and selects the new id.
  • Error 409 (slug collision): show inline error "Ein Typ mit diesem Namen existiert bereits".

Why a modal vs. inline expansion

Inline expansion would push the deadline-create form down by 4 fields and feel cramped. A modal is the existing paliad pattern (project edit, invitation flow). Smaller scope: 1 form, 1 button, escape-to-close.

Why not free-text fallback that auto-creates

Two reasons:

  1. Typo-driven duplication. A user types "Klage" → a row is created → next time they type "Klagen" → another row. Within a week the firm has 12 rows for one concept. The deliberate "+ Neuen Typ" affordance forces the user to confirm "yes, this is new" and to set category/jurisdiction.
  2. Permission asymmetry. Auto-create defaults to private; users who actually want firm-wide need an explicit toggle. The modal makes that visible.

4 · Filter UX on /deadlines (and /agenda)

/deadlines (primary scope) — multi-select

m's call (Q4): match the existing <select>-row pattern visually, but make Event Types multi-select. Status/Projekt stay single-select. New EventTypeMultiSelect component:

.akten-filter-row
├─ <label>Typ</label>
└─ <button class="akten-select akten-multi-trigger" aria-haspopup="listbox">
     <span class="akten-multi-label">Alle</span>           ← / "3 Typen" / single label
     <span class="akten-multi-chevron">▾</span>
   </button>

opens (popover, anchored to the trigger, click-outside dismisses):
┌──────────────────────────────────────────┐
│ 🔍 [Suche…]                              │
│ ☐ Alle  /  ☐ — Ohne Typ —                │
│ ─────────────────────────────────────────│
│ Eingaben (UPC)                           │
│   ☐ Statement of Defence                 │
│   ☐ Reply to the Defence                 │
│   ☐ Counterclaim for Revocation     …    │
│ Entscheidungen                           │
│   ☐ Decision on the merits          …    │
│ Anordnungen                              │
│ Gebühren                                 │
│ Eigene                                   │
│   ☐ Mein Mahnschriftsatz-Template        │
│ ─────────────────────────────────────────│
│   [ Zurücksetzen ]   [ Anwenden ]        │
└──────────────────────────────────────────┘

Behaviour:

  • Default state: "Alle" toggle on, list disabled. Toggling any list item turns "Alle" off and ticks the row.
  • "— Ohne Typ —" is a special row separate from "Alle"; ticking it adds event_type_id IS NULL rows alongside whatever specific types are ticked.
  • Trigger label shows "Alle", "Ohne Typ", "Statement of Defence", or "3 Typen" depending on count.
  • Search box filters the list across label_de + label_en.
  • "Anwenden" (or click-outside) commits; "Zurücksetzen" returns to "Alle".
  • Mobile: popover becomes a full-width sheet from the bottom (reuses the existing .modal-mobile-bottom class if it exists, else a one-CSS-rule bottom sheet).

Backend / query param:

  • ?event_type=<uuid>,<uuid>,none — comma-separated UUIDs and/or the literal none keyword. Empty / absent = "Alle".
  • Service layer parses to (event_type_id IN (uuid1,uuid2,…) OR event_type_id IS NULL [if 'none' present]), AND-intersected with the existing status / project predicates.
  • State persists in URL — bookmark + back-button preserve the filter.

Add a Typ column to the deadlines table?

Yes — small column showing label_de (or label_en per current language). Column hides via .akten-table--hide-status-style toggle (t-paliad-073 pattern: hide when every visible row shares the same value).

/agenda — same multi-select

m's call (Q5): ship /agenda filter in the same task. Same EventTypeMultiSelect component, mounted as a second filter row below the existing Type chip row (deadlines/appointments). AgendaService.List accepts the same ?event_type= param; appointments are unaffected (they have no event_type) — they're returned regardless when "appointments" is in the type-of-item filter.

5 · Permission model

Action Permission
Read firm-wide types any authenticated user
Read own private types only the author
Create private type (is_firm_wide=false) any authenticated user (created_by=self)
Create firm-wide type (is_firm_wide=true) any authenticated user (m's Q6: lighter-weight onboarding, admin moderates after the fact)
Edit own type (private or firm-wide) the author
Edit / archive any firm-wide type global_role='global_admin' (moderation lever)
Archive a type same as edit
Delete a type never (set archived_at instead)

Enforced at two layers:

  • RLS policies (§2 above) — the safety net.
  • Service layerEventTypeService.Create validates the slug shape and uniqueness before insert, rejects created_by mismatches with 400.

Why this looser firm-wide-create policy

m's call. Two consequences worth naming:

  1. Drift risk: users will create overlapping firm-wide types ("Klage", "Klageeinreichung", "Klage erheben"). Mitigation: search-prevent-duplicate in the add modal — if a fuzzy match (%label_de%) already exists firm-wide, surface "Existiert vermutlich schon: …" with a "Trotzdem anlegen" override. Reduces but doesn't eliminate.
  2. Moderation backlog: admins need a lightweight surface to scan + archive. Not in scope for this task; flag as follow-up t-paliad-089: admin Event-Type moderation panel.

Why allow user-private types at all

Each lawyer has a couple of personal categories that nobody else needs ("Reminder for myself when X", "My standing template for Y"). Private types stay out of others' pickers; the user's own picker shows them under "Eigene".

6 · Backfill strategy

Existing data

SELECT count(*) FROM paliad.deadlines → 10 rows (production, 2026-04-30).

All get event_type_id=NULL after migration 030. Render in the UI as "Ohne Typ" (separate option in the filter, displayed as a faint chip next to the title in the table).

Seeded firm-wide types

Migration 030 seeds ~40 firm-wide types in a separate 030b_seed_event_types.up.sql (or appended to 030 — single-migration ok if it stays readable). Three pools:

  1. UPC submissions (~25 rows) — picked from paliad.trigger_events codes for the most common procedural submissions (Statement of Claim/Defence, Counterclaim, Reply, Rejoinder, Statement of Appeal, Statement of grounds of appeal, Cross-appeal, Application to amend the patent, Defence to revocation, Application for damages, Application for cost decision, Protective Letter, Preliminary Objection). Each row gets trigger_event_id populated.
  2. UPC decisions/orders (~10 rows) — Decision on the merits, Decision on costs, Case management order, Order of the judge-rapporteur, Final decision, Summons to oral hearing, Service of complaint. trigger_event_id populated.
  3. Non-UPC (~10 rows, hand-written, no trigger_event_id) — EPO opposition filing, EPO opposition reply, EPO appeal filing, EPO appeal grounds, EP annuity payment, DPMA examination request, DPMA opposition, German national court Klageerwiderung, German national court Beschwerde, IP-licence renewal date.

I will NOT seed all 102 trigger_events as Event Types — most are highly specific procedural sub-events ("Rejoinder to the Reply, Reply to the Defence to an Application to amend the patent" — that level of granularity belongs in the calc engine, not the picker dropdown). The curated subset of ~25 captures the 80 % case; users with niche needs add private types.

The exact seed list lives in the migration; I'll attach the spreadsheet to the implementation PR.

No automatic title-based backfill

Tempting: parse paliad.deadlines.title against seeded label_de/label_en. Don't.

  • 10 production rows; not worth the script.
  • High false-positive risk ("Reply" matches at least 8 different seed types).
  • Better UX: when a user opens a typed-NULL deadline in edit mode, the form shows "Typ: — Ohne Typ —" with the picker open, prompting them to tag.

If/when the row count grows past ~200, revisit with a manual reconciliation script run by an admin.

7 · API endpoints

GET  /api/event-types                  → list (firm-wide  own private), filterable by ?category= and ?jurisdiction=
POST /api/event-types                  → create; body {label_de, label_en?, category, jurisdiction?, is_firm_wide?}
PATCH /api/event-types/{id}            → edit (label/category/jurisdiction/archived_at); RLS enforces ownership
GET  /api/deadlines?event_type_id=     → already handled if we add the param to the list handler
GET  /api/agenda?event_type_id=        → same on the agenda handler

POST /api/event-types returns 201 with the created row; 403 for non-admin trying is_firm_wide=true; 409 on slug collision; 422 on missing label_de or invalid category. Standard paliad envelope.

The existing paliad.deadlines POST handler (/api/deadlines) gets a new optional event_type_id field — validate it points to a row the user can see (firm-wide or own private).

8 · Test plan

Unit / Go

  • EventTypeService.Create happy path (private type by regular user).
  • EventTypeService.Create 403 when regular user tries is_firm_wide=true.
  • EventTypeService.Create 409 on slug collision (firm-wide same slug; per-user same slug).
  • EventTypeService.List returns firm-wide own-private; not other users' private.
  • DeadlineService.Create accepts event_type_id; rejects FK pointing at someone else's private type.
  • DeadlineService.List filter by event_type_id intersects with status + project filters.
  • AgendaService.List filter by event_type_id.

Integration / Playwright

Login as tester@hlc.de:

  1. Pick existing type: /deadlines/new → fill title + project + due → open Typ picker → select "Statement of Defence" → submit → /deadlines shows row with Typ column = "Statement of Defence".
  2. Filter: /deadlines → set Typ filter to "Statement of Defence" → list narrows to 1 row → set to "Alle" → all rows back.
  3. Custom-add (private): /deadlines/new → open Typ picker → click "+ Neuen Typ hinzufügen" → modal → name "Mein Test-Typ", category Eingabe → Anlegen → modal closes, picker re-opens with new option selected → submit deadline → /deadlines shows it.
  4. Privacy: custom private types only visible to creator. Verify with API call as second test account if available; else assert via direct SQL after the test.
  5. No-type filter: filter "— Ohne Typ —" returns the pre-existing 10 rows that were never tagged.
  6. Mobile snapshot: filter row wraps cleanly on 375px viewport.
  7. DE/EN switch: language toggle re-renders both picker and filter labels (label_de/label_en swap, optgroup labels swap).
  8. Archive flow (admin): as global_admin, edit a firm-wide type → set archived_at → existing deadlines keep their event_type_id (label still renders for legacy rows) but the type no longer appears in the picker for new deadlines.

Manual smoke

  • New deadline reachable through Fristenrechner "Send to deadline" path (if/once that flow exists) carries event_type_id matching the trigger event.

9 · Coordination

  • t-paliad-086 (curie, shipped): trigger_events table is the seed source for the curated firm-wide types. The Fristenrechner trigger picker on /tools/fristenrechner STAYS as-is — it's a calc tool, not a categorization tool. No conflict.
  • t-paliad-087 (brunel, in flight): light-grey BG sweep on global.css. Low overlap — this task adds new picker styles + the custom-add modal; brunel touches existing surfaces. Coordinate via merge order: brunel merges first (bigger surface area), then this task rebases.
  • Tier 2 Fristenrechner ports (damages, cost-appeal, cross-appeal, lay-open, leave-to-appeal): unrelated; their trigger_events rows (already imported in PR-1) become eligible seed candidates if/when the curated list expands.

10 · Migration outline (single PR, ~5 commits)

  1. Schema + seeds030_event_types.up.sql (table, indexes, triggers, RLS policies, ~40 seed rows). 030_event_types.down.sql reverses cleanly.
  2. Models + serviceinternal/models/event_type.go, internal/services/event_type_service.go. Wire into cmd/server/main.go services bundle.
  3. Handlers + routesinternal/handlers/event_types.go (CRUD), update internal/handlers/deadlines.go to accept event_type_id, update internal/handlers/agenda.go to accept ?event_type_id.
  4. Frontend picker + modalfrontend/src/components/EventTypePicker.tsx (shared) + frontend/src/components/EventTypeAddModal.tsx. Wire into deadlines-new.tsx and deadlines-detail.tsx edit modal. ~30 i18n keys (DE+EN) under event_types.* and deadlines.field.event_type.*.
  5. Frontend filter + table columndeadlines.tsx adds the <select> filter row + Typ column; client/deadlines.ts handles the ?event_type= query param. agenda.tsx adds the pill-row variant; client/agenda.ts handles its query param.

Tests live alongside each layer. Verify via bun run build + go test ./... + Playwright smoke.

11 · Alternatives considered, not picked

Alternative Why not
Reuse trigger_events directly with a created_by column Breaks the verbatim-import-for-diffability invariant; mixes calc state with user state; bigint vs uuid id space friction.
Free-text deadlines.event_type column with self-distinct lookup Typo-driven duplicates kill the filter UX; no metadata (category/jurisdiction); no privacy boundary.
paliad.submissions as a sibling entity to deadlines Forces migration of existing rows; duplicates fields (due_date, project_id, created_by); a submission is a deadline-bearing item. Defer until real submission-specific fields (file uploads, recipient party) are needed.
Multi-select filter (UNION across multiple Event Types) PICKED. m's Q4 call — Event Types specifically benefits from multi-select (a user often wants "show me all my Replies, Rejoinders, and Defences"). Status/Projekt stay single-select; the asymmetry is intentional.
Auto-create on free-text in picker Generates noise; can't ask the user category/jurisdiction; wrong default permission. Keep the explicit "+ Neuen Typ" affordance.
Hierarchical Event Types (parent type → sub-type) Over-engineered for 40 seeds + handful of customs. Use category for the one level of grouping users care about.

12 · Open questions — RESOLVED 2026-04-30 12:23

# Question m's call
1 Concept boundary — broader (UPC + EPO + DPMA + DE + contract) or UPC-only first cut? A — broader from day one
2 Schema — new paliad.event_types table + FK? A — yes, with the bridge event_types.trigger_event_id → paliad.trigger_events(id) populated only for seeded UPC rows. m: "event_types should lead and later we can connect things to it"
3 Submissions — defer separate table? A — yes, defer. m: "those are the event types I mean, mainly"
4 Filter style — <select> matching existing pattern? A, but multi-select. Custom listbox-panel multi-select component (see §4 above). Status/Projekt stay single-select.
5 /agenda filter — same task? A — same task
6 Firm-wide type permission floor — global_admin only? B — any authenticated user can create firm-wide; admins moderate via archive after the fact. Mitigation: duplicate-warning in the add modal. Follow-up: t-paliad-089: admin moderation panel.
7 Seed list — ~40 curated rows in migration 030? A — yes, spreadsheet on the implementation PR for review.

Status: all gate-blocking calls answered. Awaiting head's coder assignment. Inventor stays parked.

13 · Inventor recommendation on implementer

cronus did this design (data-model area). Either cronus or curie would be a good fit to implement: cronus knows the trigger_events corpus from this design pass; curie just shipped the trigger_events import (t-paliad-086) and knows the calc-engine context. Single coder is fine — the surface is one table, one FK, one picker, one filter, one modal. Head decides, not me.


End of design. Awaiting m's go/no-go on §12 #1, #2, #3, #4. Will not begin implementation until greenlit.