# 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 ``; 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_id` → `deadline_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` ```sql -- 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 `` (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 `` 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 — `