Merge: t-paliad-088 PR-1 — Event Types schema + service + handlers (43 firm-wide seeds, junction table)
This commit is contained in:
@@ -118,12 +118,13 @@ func main() {
|
||||
emailTemplateSvc := services.NewEmailTemplateService(pool)
|
||||
mailSvc.SetTemplateService(emailTemplateSvc)
|
||||
|
||||
eventTypeSvc := services.NewEventTypeService(pool, users)
|
||||
svcBundle = &handlers.Services{
|
||||
Project: projectSvc,
|
||||
Team: teamSvc,
|
||||
PartnerUnit: partnerUnitSvc,
|
||||
Party: services.NewPartyService(pool, projectSvc),
|
||||
Deadline: services.NewDeadlineService(pool, projectSvc),
|
||||
Deadline: services.NewDeadlineService(pool, projectSvc, eventTypeSvc),
|
||||
Appointment: appointmentSvc,
|
||||
CalDAV: caldavSvc,
|
||||
Rules: rules,
|
||||
@@ -131,12 +132,13 @@ func main() {
|
||||
Users: users,
|
||||
Fristenrechner: services.NewFristenrechnerService(rules, holidays),
|
||||
EventDeadline: services.NewEventDeadlineService(pool, services.NewDeadlineCalculator(holidays), holidays),
|
||||
EventType: eventTypeSvc,
|
||||
Dashboard: services.NewDashboardService(pool, users),
|
||||
Note: services.NewNoteService(pool, projectSvc, appointmentSvc),
|
||||
ChecklistInst: services.NewChecklistInstanceService(pool, projectSvc),
|
||||
Mail: mailSvc,
|
||||
Invite: inviteSvc,
|
||||
Agenda: services.NewAgendaService(pool, users),
|
||||
Agenda: services.NewAgendaService(pool, users, eventTypeSvc),
|
||||
Audit: services.NewAuditService(pool),
|
||||
EmailTemplate: emailTemplateSvc,
|
||||
Link: services.NewLinkService(pool),
|
||||
|
||||
461
docs/design-event-types-2026-04-30.md
Normal file
461
docs/design-event-types-2026-04-30.md
Normal file
@@ -0,0 +1,461 @@
|
||||
# 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_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 `<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 layer** — `EventTypeService.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 + seeds** — `030_event_types.up.sql` (table, indexes, triggers, RLS policies, ~40 seed rows). `030_event_types.down.sql` reverses cleanly.
|
||||
2. **Models + service** — `internal/models/event_type.go`, `internal/services/event_type_service.go`. Wire into `cmd/server/main.go` services bundle.
|
||||
3. **Handlers + routes** — `internal/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 + modal** — `frontend/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 column** — `deadlines.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.
|
||||
6
internal/db/migrations/030_event_types.down.sql
Normal file
6
internal/db/migrations/030_event_types.down.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- Reverses 030_event_types.up.sql.
|
||||
-- Drops in FK-safe order: junction → event_types.
|
||||
-- All seeds and any user-added types vanish — caller's responsibility.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.deadline_event_types;
|
||||
DROP TABLE IF EXISTS paliad.event_types;
|
||||
208
internal/db/migrations/030_event_types.up.sql
Normal file
208
internal/db/migrations/030_event_types.up.sql
Normal file
@@ -0,0 +1,208 @@
|
||||
-- t-paliad-088: Event Types for deadlines.
|
||||
--
|
||||
-- A user-facing categorization of deadlines: "what kind of work is this?"
|
||||
-- (Statement of Defence, Reply, Decision on the merits, EPO opposition,
|
||||
-- DPMA Beschwerde, IP licence renewal, …). Distinct from
|
||||
-- paliad.trigger_events (calc-engine state, UPC-only, verbatim youpc
|
||||
-- imports). The two concepts overlap by ~70% of UPC rows, so seeded
|
||||
-- firm-wide types carry an OPTIONAL trigger_event_id linkage column.
|
||||
-- The column has NO foreign-key constraint by design — event_types
|
||||
-- leads, trigger_events follows. If a youpc re-sync drops a trigger
|
||||
-- event id, the seeded event_type stays usable.
|
||||
--
|
||||
-- Per-deadline mapping is many-to-many via paliad.deadline_event_types.
|
||||
-- A deadline can be tagged with 0..N event types.
|
||||
--
|
||||
-- Permissions:
|
||||
-- - Any authenticated user can create both private (is_firm_wide=false)
|
||||
-- and firm-wide (is_firm_wide=true) types.
|
||||
-- - Authors can edit/archive their own rows.
|
||||
-- - global_admin can edit/archive any firm-wide row (moderation lever).
|
||||
-- - Soft-delete only (archived_at); never hard delete — existing
|
||||
-- deadlines hold references to archived types and should keep their
|
||||
-- label.
|
||||
--
|
||||
-- The Go service layer enforces visibility (firm-wide ∪ own-private)
|
||||
-- because the connection runs as service-role; RLS below is
|
||||
-- defense-in-depth for any auth-context query path.
|
||||
|
||||
-- ============================================================================
|
||||
-- Schema: paliad.event_types
|
||||
-- ============================================================================
|
||||
|
||||
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 NOT NULL DEFAULT '',
|
||||
trigger_event_id bigint,
|
||||
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 so two users can each have their own "klage".
|
||||
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;
|
||||
CREATE INDEX event_types_trigger_event_idx ON paliad.event_types(trigger_event_id) WHERE trigger_event_id IS NOT NULL;
|
||||
|
||||
-- ============================================================================
|
||||
-- Schema: paliad.deadline_event_types (junction)
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE paliad.deadline_event_types (
|
||||
deadline_id uuid NOT NULL REFERENCES paliad.deadlines(id) ON DELETE CASCADE,
|
||||
event_type_id uuid NOT NULL REFERENCES paliad.event_types(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (deadline_id, event_type_id)
|
||||
);
|
||||
|
||||
-- PK index covers WHERE deadline_id = ?; need a separate one for the
|
||||
-- reverse direction (filter list by event_type_id).
|
||||
CREATE INDEX deadline_event_types_event_type_idx
|
||||
ON paliad.deadline_event_types(event_type_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- RLS (defense-in-depth; primary enforcement is in the Go service)
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.event_types ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE paliad.deadline_event_types ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- event_types: read firm-wide ∪ own private. Insert any row with
|
||||
-- created_by=self. Update own rows; admins update any firm-wide row.
|
||||
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())
|
||||
);
|
||||
|
||||
CREATE POLICY event_types_insert ON paliad.event_types
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (created_by = auth.uid());
|
||||
|
||||
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'
|
||||
)
|
||||
);
|
||||
|
||||
-- deadline_event_types: visibility follows the parent Deadline's
|
||||
-- Project visibility (paliad.can_see_project), AND the event_type itself
|
||||
-- must be visible to the user.
|
||||
CREATE POLICY deadline_event_types_all ON paliad.deadline_event_types
|
||||
FOR ALL TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.deadlines d
|
||||
WHERE d.id = deadline_event_types.deadline_id
|
||||
AND paliad.can_see_project(d.project_id)
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM paliad.event_types et
|
||||
WHERE et.id = deadline_event_types.event_type_id
|
||||
AND et.archived_at IS NULL
|
||||
AND (et.is_firm_wide = true OR et.created_by = auth.uid())
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.deadlines d
|
||||
WHERE d.id = deadline_event_types.deadline_id
|
||||
AND paliad.can_see_project(d.project_id)
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM paliad.event_types et
|
||||
WHERE et.id = deadline_event_types.event_type_id
|
||||
AND et.archived_at IS NULL
|
||||
AND (et.is_firm_wide = true OR et.created_by = auth.uid())
|
||||
)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- Seed: ~40 firm-wide types, biased toward submissions per m's Q3 call.
|
||||
-- created_by = NULL marks them as system seeds (no human author).
|
||||
-- trigger_event_id linkage populated only for seeded UPC rows.
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO paliad.event_types
|
||||
(slug, label_de, label_en, category, jurisdiction, description, trigger_event_id, is_firm_wide)
|
||||
VALUES
|
||||
-- UPC submissions (25 rows)
|
||||
('upc_statement_of_claim', 'Klageschrift', 'Statement of Claim', 'submission', 'UPC', '', 5, true),
|
||||
('upc_statement_of_defence', 'Klageerwiderung', 'Statement of Defence', 'submission', 'UPC', '', 84, true),
|
||||
('upc_statement_of_defence_with_ccr', 'Klageerwiderung mit Widerklage auf Nichtigerklärung', 'Statement of Defence with Counterclaim for Revocation', 'submission', 'UPC', '', 1, true),
|
||||
('upc_statement_of_defence_no_ccr', 'Klageerwiderung ohne Widerklage auf Nichtigerklärung', 'Statement of Defence without Counterclaim for Revocation', 'submission', 'UPC', '', 28, true),
|
||||
('upc_reply_to_defence', 'Replik', 'Reply to the Statement of Defence', 'submission', 'UPC', '', 85, true),
|
||||
('upc_rejoinder_to_reply', 'Duplik', 'Rejoinder to the Reply to the Statement of Defence', 'submission', 'UPC', '', 61, true),
|
||||
('upc_counterclaim_for_revocation', 'Widerklage auf Nichtigerklärung', 'Counterclaim for Revocation', 'submission', 'UPC', '', 101, true),
|
||||
('upc_counterclaim_for_infringement', 'Widerklage wegen Verletzung', 'Counterclaim for Infringement', 'submission', 'UPC', '', 10, true),
|
||||
('upc_defence_to_revocation', 'Erwiderung auf Nichtigkeitsantrag', 'Defence to revocation', 'submission', 'UPC', '', 34, true),
|
||||
('upc_reply_to_defence_to_revocation', 'Replik auf Erwiderung auf Nichtigkeitsantrag', 'Reply to the Defence to revocation', 'submission', 'UPC', '', 21, true),
|
||||
('upc_application_to_amend_patent', 'Antrag auf Patentänderung', 'Application to amend the patent', 'submission', 'UPC', '', 38, true),
|
||||
('upc_defence_to_amend_patent', 'Erwiderung auf Patentänderungsantrag', 'Defence to the Application to amend the patent', 'submission', 'UPC', '', 19, true),
|
||||
('upc_reply_to_defence_to_amend_patent', 'Replik auf Erwiderung auf Patentänderungsantrag', 'Reply to the Defence to an Application to amend the patent', 'submission', 'UPC', '', 20, true),
|
||||
('upc_statement_for_revocation', 'Nichtigkeitsklage', 'Statement for Revocation', 'submission', 'UPC', '', 6, true),
|
||||
('upc_statement_dni', 'Antrag auf Feststellung der Nichtverletzung', 'Statement for a declaration of non-infringement', 'submission', 'UPC', '', 7, true),
|
||||
('upc_defence_to_statement_dni', 'Erwiderung auf Antrag auf Feststellung der Nichtverletzung', 'Defence to the Statement for a declaration of non-infringement', 'submission', 'UPC', '', 93, true),
|
||||
('upc_application_for_cost_decision', 'Antrag auf Kostenentscheidung', 'Application for cost decision', 'submission', 'UPC', '', 8, true),
|
||||
('upc_statement_of_appeal_2201', 'Berufungsschrift (R.220.1(a)/(b))', 'Statement of Appeal against decision Rule 220.1(a)/(b)', 'submission', 'UPC', '', 11, true),
|
||||
('upc_grounds_of_appeal_2242a', 'Berufungsbegründung (R.224.2(a))', 'Statement of grounds of appeal pursuant to Rule 224.2(a)', 'submission', 'UPC', '', 89, true),
|
||||
('upc_grounds_of_appeal_2242b', 'Berufungsbegründung (R.224.2(b))', 'Statement of grounds of appeal pursuant to Rule 224.2(b)', 'submission', 'UPC', '', 60, true),
|
||||
('upc_cross_appeal_2242a', 'Anschlussberufung (R.224.2(a))', 'Statement of cross-appeal pursuant to Rule 224.2(a)', 'submission', 'UPC', '', 63, true),
|
||||
('upc_application_for_damages', 'Antrag auf Schadensbestimmung', 'Application for the determination of damages', 'submission', 'UPC', '', 82, true),
|
||||
('upc_protective_letter', 'Schutzschrift', 'Protective Letter', 'submission', 'UPC', '', 55, true),
|
||||
('upc_preliminary_objection', 'Vorbringen zur Unzulässigkeit', 'Preliminary Objection', 'submission', 'UPC', '', 68, true),
|
||||
('upc_request_to_lay_open_books', 'Antrag auf Buchoffenlegung', 'Request to lay open books', 'submission', 'UPC', '', 62, true),
|
||||
|
||||
-- UPC decisions / orders / hearings (8 rows)
|
||||
('upc_decision_on_merits', 'Hauptsacheentscheidung', 'Decision on the merits', 'decision', 'UPC', '', 104, true),
|
||||
('upc_decision_on_costs', 'Kostenfestsetzungsbeschluss (R.157)', 'Decision on fixation of costs (Rule 157)', 'decision', 'UPC', '', 2, true),
|
||||
('upc_decision_of_epo', 'Entscheidung des EPA', 'Decision of the EPO', 'decision', 'UPC', '', 39, true),
|
||||
('upc_case_management_order', 'Verfahrensleitende Anordnung (Zustellung)', 'Case management order (Service)', 'order', 'UPC', '', 78, true),
|
||||
('upc_order_lodge_translations', 'Anordnung Übersetzungseinreichung', 'Order of the judge-rapporteur to lodge translations', 'order', 'UPC', '', 113, true),
|
||||
('upc_summons_oral_hearing', 'Ladung zur mündlichen Verhandlung', 'Summons to Oral Hearing', 'service', 'UPC', '', 56, true),
|
||||
('upc_oral_hearing', 'Mündliche Verhandlung', 'Oral hearing', 'hearing', 'UPC', '', 49, true),
|
||||
('upc_final_decision', 'Endurteil (Zustellung)', 'Final decision (Service)', 'decision', 'UPC', '', 88, true),
|
||||
|
||||
-- EPO (4 rows, no trigger_event_id — out of UPC corpus)
|
||||
('epo_opposition_filing', 'Einspruch gegen EP-Patent', 'Notice of Opposition (EPO)', 'submission', 'EPO', 'Article 99 EPC, 9-month opposition period', NULL, true),
|
||||
('epo_opposition_reply', 'Erwiderung im Einspruchsverfahren (EPA)', 'Reply in EPO opposition proceedings', 'submission', 'EPO', '', NULL, true),
|
||||
('epo_appeal_notice', 'Beschwerdeschrift (EPA)', 'Notice of Appeal (EPO)', 'submission', 'EPO', 'Article 108 EPC, 2-month notice + 4-month grounds', NULL, true),
|
||||
('epo_appeal_grounds', 'Beschwerdebegründung (EPA)', 'Statement of Grounds of Appeal (EPO)', 'submission', 'EPO', '', NULL, true),
|
||||
('epo_renewal_fee', 'Jahresgebühr (EP)', 'Renewal fee (EP)', 'fee', 'EPO', '', NULL, true),
|
||||
|
||||
-- DPMA / DE national (4 rows, no trigger_event_id)
|
||||
('dpma_examination_request', 'Prüfungsantrag (DPMA)', 'Request for examination (DPMA)', 'submission', 'DPMA', '', NULL, true),
|
||||
('dpma_opposition', 'Einspruch (DPMA)', 'Opposition (DPMA)', 'submission', 'DPMA', '', NULL, true),
|
||||
('dpma_appeal', 'Beschwerde (DPMA)', 'Appeal (DPMA)', 'submission', 'DPMA', '', NULL, true),
|
||||
('de_klageerwiderung', 'Klageerwiderung (DE Zivilgericht)', 'Statement of Defence (DE national court)', 'submission', 'DE', '', NULL, true),
|
||||
|
||||
-- Cross-jurisdiction / contract (1 row)
|
||||
('contract_renewal', 'Vertragsverlängerung / Lizenzerneuerung', 'Contract renewal / licence renewal', 'fee', 'any', 'Generic non-procedural deadline (licence renewal, NDA expiry, …)', NULL, true);
|
||||
@@ -109,6 +109,13 @@ func parseAgendaFilter(r *http.Request) (services.AgendaFilter, error) {
|
||||
filter.IncludeAppointments = true
|
||||
}
|
||||
}
|
||||
|
||||
if ids, untyped, err := parseEventTypeFilter(q.Get("event_type")); err != nil {
|
||||
return services.AgendaFilter{}, err
|
||||
} else {
|
||||
filter.EventTypeIDs = ids
|
||||
filter.IncludeUntyped = untyped
|
||||
}
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@ package handlers
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/patholo/internal/services"
|
||||
)
|
||||
|
||||
// GET /api/deadlines?status=overdue|this_week|upcoming|completed|pending|all&project_id=UUID
|
||||
// GET /api/deadlines?status=overdue|this_week|upcoming|completed|pending|all&project_id=UUID&event_type=<uuid>,<uuid>,none
|
||||
func handleListDeadlines(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
@@ -34,6 +35,14 @@ func handleListDeadlines(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
filter.ProjectID = &projectID
|
||||
}
|
||||
ids, untyped, err := parseEventTypeFilter(r.URL.Query().Get("event_type"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
filter.EventTypeIDs = ids
|
||||
filter.IncludeUntyped = untyped
|
||||
|
||||
rows, err := dbSvc.deadline.ListVisibleForUser(r.Context(), uid, filter)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
@@ -42,6 +51,39 @@ func handleListDeadlines(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// parseEventTypeFilter parses the comma-separated `event_type` query
|
||||
// parameter used by both /api/deadlines and /api/agenda. The literal
|
||||
// keyword "none" is the toggle for "deadlines without any Event Type
|
||||
// attached" (filter.IncludeUntyped). All other tokens must be valid
|
||||
// UUIDs of visible event_types — the service layer validates visibility.
|
||||
//
|
||||
// Returns (ids, includeUntyped, err). Empty/blank input returns
|
||||
// (nil, false, nil) — interpret as "no filter".
|
||||
func parseEventTypeFilter(raw string) ([]uuid.UUID, bool, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
ids := []uuid.UUID{}
|
||||
includeUntyped := false
|
||||
for tok := range strings.SplitSeq(raw, ",") {
|
||||
t := strings.TrimSpace(tok)
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
if t == "none" {
|
||||
includeUntyped = true
|
||||
continue
|
||||
}
|
||||
id, err := uuid.Parse(t)
|
||||
if err != nil {
|
||||
return nil, false, &agendaErr{msg: "invalid event_type id " + t}
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, includeUntyped, nil
|
||||
}
|
||||
|
||||
// GET /api/deadlines/summary?project_id=UUID
|
||||
func handleDeadlinesSummary(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
|
||||
111
internal/handlers/event_types.go
Normal file
111
internal/handlers/event_types.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/patholo/internal/services"
|
||||
)
|
||||
|
||||
// GET /api/event-types?category=&jurisdiction=
|
||||
//
|
||||
// Returns event_types visible to the caller (firm-wide ∪ own private,
|
||||
// not archived), optionally filtered by category and/or jurisdiction.
|
||||
func handleListEventTypes(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
rows, err := dbSvc.eventType.List(r.Context(), uid, services.EventTypeListFilter{
|
||||
Category: q.Get("category"),
|
||||
Jurisdiction: q.Get("jurisdiction"),
|
||||
})
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// GET /api/event-types/suggest?q=foo — surfaces firm-wide rows whose
|
||||
// label_de or label_en matches `q` (case-insensitive substring) so the
|
||||
// add-modal can warn "Existiert vermutlich schon: …".
|
||||
func handleSuggestEventTypes(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := dbSvc.eventType.SuggestSimilar(r.Context(), uid, r.URL.Query().Get("q"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// POST /api/event-types
|
||||
//
|
||||
// Creates a private (default) or firm-wide (is_firm_wide=true) type for
|
||||
// the caller. Any authenticated user may set is_firm_wide=true (per m's
|
||||
// Q6 call); admins moderate via PATCH archive=true after the fact.
|
||||
func handleCreateEventType(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input services.CreateEventTypeInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
row, err := dbSvc.eventType.Create(r.Context(), uid, input)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, row)
|
||||
}
|
||||
|
||||
// PATCH /api/event-types/{id}
|
||||
//
|
||||
// Edits or archives an event_type. Authorization: author of the row,
|
||||
// or global_admin if the row is firm-wide. Soft-delete only — set
|
||||
// {"archive": true} to hide the type from pickers without breaking
|
||||
// any deadline that already references it.
|
||||
func handleUpdateEventType(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var input services.UpdateEventTypeInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
row, err := dbSvc.eventType.Update(r.Context(), uid, id, input)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
}
|
||||
@@ -48,6 +48,7 @@ type Services struct {
|
||||
Users *services.UserService
|
||||
Fristenrechner *services.FristenrechnerService
|
||||
EventDeadline *services.EventDeadlineService
|
||||
EventType *services.EventTypeService
|
||||
Dashboard *services.DashboardService
|
||||
Note *services.NoteService
|
||||
ChecklistInst *services.ChecklistInstanceService
|
||||
@@ -77,6 +78,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
users: svc.Users,
|
||||
fristenrechner: svc.Fristenrechner,
|
||||
eventDeadline: svc.EventDeadline,
|
||||
eventType: svc.EventType,
|
||||
dashboard: svc.Dashboard,
|
||||
note: svc.Note,
|
||||
checklistInst: svc.ChecklistInst,
|
||||
@@ -214,6 +216,12 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("POST /api/caldav-config/test", handleTestCalDAVConfig)
|
||||
protected.HandleFunc("GET /api/caldav-config/log", handleCalDAVSyncLog)
|
||||
|
||||
// t-paliad-088 — Event Types (categorization for Deadlines).
|
||||
protected.HandleFunc("GET /api/event-types", handleListEventTypes)
|
||||
protected.HandleFunc("GET /api/event-types/suggest", handleSuggestEventTypes)
|
||||
protected.HandleFunc("POST /api/event-types", handleCreateEventType)
|
||||
protected.HandleFunc("PATCH /api/event-types/{id}", handleUpdateEventType)
|
||||
|
||||
// Phase E — Deadlines (persistent deadlines)
|
||||
protected.HandleFunc("GET /api/deadlines", handleListDeadlines)
|
||||
protected.HandleFunc("GET /api/deadlines/summary", handleDeadlinesSummary)
|
||||
|
||||
@@ -28,6 +28,7 @@ type dbServices struct {
|
||||
users *services.UserService
|
||||
fristenrechner *services.FristenrechnerService
|
||||
eventDeadline *services.EventDeadlineService
|
||||
eventType *services.EventTypeService
|
||||
dashboard *services.DashboardService
|
||||
note *services.NoteService
|
||||
checklistInst *services.ChecklistInstanceService
|
||||
@@ -74,6 +75,8 @@ func writeServiceError(w http.ResponseWriter, err error) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrInvalidInput):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrEventTypeSlugTaken):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
||||
default:
|
||||
log.Printf("ERROR service: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
||||
|
||||
@@ -181,6 +181,11 @@ type Deadline struct {
|
||||
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
|
||||
// EventTypeIDs lists the paliad.event_types attached to this deadline
|
||||
// via the paliad.deadline_event_types junction. Always present (never
|
||||
// nil) once the row has been hydrated by DeadlineService.
|
||||
EventTypeIDs []uuid.UUID `db:"-" json:"event_type_ids"`
|
||||
}
|
||||
|
||||
// DeadlineWithProject enriches a Deadline with parent-Project display fields
|
||||
@@ -403,3 +408,51 @@ type EventDeadlineRuleCode struct {
|
||||
RuleCode string `db:"rule_code" json:"rule_code"`
|
||||
SortOrder int `db:"sort_order" json:"sort_order"`
|
||||
}
|
||||
|
||||
// EventType is a user-facing categorization tag for a Deadline (Statement
|
||||
// of Defence, Reply, Decision on the merits, EPO opposition, …). Distinct
|
||||
// from TriggerEvent: TriggerEvents are calc-engine state (UPC-only,
|
||||
// verbatim youpc imports), EventTypes are the broader taxonomy users
|
||||
// pick from when creating a Deadline.
|
||||
//
|
||||
// CreatedBy NULL on system seeds; set on user-created rows. IsFirmWide
|
||||
// true for seeds and any firm-wide row a user explicitly publishes;
|
||||
// false for personal taxonomy. TriggerEventID is a loose linkage column
|
||||
// (no FK constraint) populated only for seeded UPC rows.
|
||||
type EventType struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Slug string `db:"slug" json:"slug"`
|
||||
LabelDE string `db:"label_de" json:"label_de"`
|
||||
LabelEN string `db:"label_en" json:"label_en"`
|
||||
Category string `db:"category" json:"category"`
|
||||
Jurisdiction *string `db:"jurisdiction" json:"jurisdiction,omitempty"`
|
||||
Description string `db:"description" json:"description"`
|
||||
TriggerEventID *int64 `db:"trigger_event_id" json:"trigger_event_id,omitempty"`
|
||||
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
|
||||
IsFirmWide bool `db:"is_firm_wide" json:"is_firm_wide"`
|
||||
ArchivedAt *time.Time `db:"archived_at" json:"archived_at,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// EventTypeCategory enumerates the values allowed on event_types.category.
|
||||
// Mirrors the CHECK constraint in migration 030.
|
||||
const (
|
||||
EventTypeCategorySubmission = "submission"
|
||||
EventTypeCategoryDecision = "decision"
|
||||
EventTypeCategoryOrder = "order"
|
||||
EventTypeCategoryService = "service"
|
||||
EventTypeCategoryFee = "fee"
|
||||
EventTypeCategoryHearing = "hearing"
|
||||
EventTypeCategoryOther = "other"
|
||||
)
|
||||
|
||||
// EventTypeJurisdiction enumerates the values allowed on
|
||||
// event_types.jurisdiction (NULL is also valid).
|
||||
const (
|
||||
EventTypeJurisdictionUPC = "UPC"
|
||||
EventTypeJurisdictionEPO = "EPO"
|
||||
EventTypeJurisdictionDPMA = "DPMA"
|
||||
EventTypeJurisdictionDE = "DE"
|
||||
EventTypeJurisdictionAny = "any"
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -21,13 +22,16 @@ import (
|
||||
|
||||
// AgendaService returns agenda feed rows for the Dashboard's /agenda page.
|
||||
type AgendaService struct {
|
||||
db *sqlx.DB
|
||||
users *UserService
|
||||
db *sqlx.DB
|
||||
users *UserService
|
||||
eventTypes *EventTypeService
|
||||
}
|
||||
|
||||
// NewAgendaService wires the service.
|
||||
func NewAgendaService(db *sqlx.DB, users *UserService) *AgendaService {
|
||||
return &AgendaService{db: db, users: users}
|
||||
// NewAgendaService wires the service. eventTypes powers the optional
|
||||
// Event-Type filter on /agenda (t-paliad-088); pass nil in tests that
|
||||
// don't exercise that surface.
|
||||
func NewAgendaService(db *sqlx.DB, users *UserService, eventTypes *EventTypeService) *AgendaService {
|
||||
return &AgendaService{db: db, users: users, eventTypes: eventTypes}
|
||||
}
|
||||
|
||||
// AgendaItem is one row in the merged feed. `Type` is "deadline" or
|
||||
@@ -52,11 +56,17 @@ type AgendaItem struct {
|
||||
}
|
||||
|
||||
// AgendaFilter narrows the merged feed.
|
||||
//
|
||||
// EventTypeIDs / IncludeUntyped restrict the deadline side of the feed
|
||||
// (appointments are unaffected — they have no event_types). When both
|
||||
// are zero/false the filter is inactive.
|
||||
type AgendaFilter struct {
|
||||
From time.Time // inclusive, UTC
|
||||
To time.Time // exclusive, UTC
|
||||
IncludeDeadlines bool
|
||||
From time.Time // inclusive, UTC
|
||||
To time.Time // exclusive, UTC
|
||||
IncludeDeadlines bool
|
||||
IncludeAppointments bool
|
||||
EventTypeIDs []uuid.UUID
|
||||
IncludeUntyped bool
|
||||
}
|
||||
|
||||
// List returns all AgendaItems for the user's visible projects within
|
||||
@@ -84,7 +94,7 @@ func (s *AgendaService) List(ctx context.Context, userID uuid.UUID, f AgendaFilt
|
||||
items := make([]AgendaItem, 0, 64)
|
||||
|
||||
if f.IncludeDeadlines {
|
||||
rows, err := s.loadDeadlines(ctx, userID, f.From, f.To)
|
||||
rows, err := s.loadDeadlines(ctx, userID, f.From, f.To, f.EventTypeIDs, f.IncludeUntyped)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -116,11 +126,42 @@ func (s *AgendaService) List(ctx context.Context, userID uuid.UUID, f AgendaFilt
|
||||
|
||||
// loadDeadlines pulls pending deadlines whose due_date falls in [from, to).
|
||||
// Completed deadlines are hidden — agenda is forward-looking.
|
||||
func (s *AgendaService) loadDeadlines(ctx context.Context, userID uuid.UUID, from, to time.Time) ([]AgendaItem, error) {
|
||||
// eventTypeIDs / includeUntyped restrict which deadlines are returned;
|
||||
// see AgendaFilter for the OR-composition semantics.
|
||||
func (s *AgendaService) loadDeadlines(ctx context.Context, userID uuid.UUID, from, to time.Time, eventTypeIDs []uuid.UUID, includeUntyped bool) ([]AgendaItem, error) {
|
||||
// due_date is a DATE; compare against the date portion of the window.
|
||||
fromDate := from.Format("2006-01-02")
|
||||
toDate := to.Format("2006-01-02")
|
||||
|
||||
args := []any{userID, fromDate, toDate}
|
||||
etClause := ""
|
||||
if len(eventTypeIDs) > 0 || includeUntyped {
|
||||
parts := []string{}
|
||||
if len(eventTypeIDs) > 0 {
|
||||
placeholders := make([]string, 0, len(eventTypeIDs))
|
||||
for _, id := range eventTypeIDs {
|
||||
args = append(args, id)
|
||||
placeholders = append(placeholders, fmt.Sprintf("$%d", len(args)))
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf(`EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_event_types det
|
||||
WHERE det.deadline_id = f.id
|
||||
AND det.event_type_id IN (%s)
|
||||
)`, strings.Join(placeholders, ", ")))
|
||||
}
|
||||
if includeUntyped {
|
||||
parts = append(parts, `NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_event_types det
|
||||
WHERE det.deadline_id = f.id
|
||||
)`)
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
etClause = "\n AND " + parts[0]
|
||||
} else {
|
||||
etClause = "\n AND (" + strings.Join(parts, " OR ") + ")"
|
||||
}
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT f.id,
|
||||
f.title,
|
||||
@@ -135,7 +176,7 @@ SELECT f.id,
|
||||
WHERE f.status = 'pending'
|
||||
AND f.due_date >= $2::date
|
||||
AND f.due_date < $3::date
|
||||
AND ` + visibilityPredicatePositional("p", 1) + `
|
||||
AND ` + visibilityPredicatePositional("p", 1) + etClause + `
|
||||
ORDER BY f.due_date ASC, f.created_at ASC`
|
||||
|
||||
type row struct {
|
||||
|
||||
@@ -21,14 +21,21 @@ import (
|
||||
//
|
||||
// Audit: every mutation appends a paliad.project_events row via
|
||||
// insertProjectEvent so the Project verlauf shows what changed.
|
||||
//
|
||||
// EventTypes: every Deadline can carry 0..N paliad.event_types via the
|
||||
// paliad.deadline_event_types junction. The dependency is optional so
|
||||
// the service stays runnable in isolated tests; in production main.go
|
||||
// always wires it.
|
||||
type DeadlineService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
eventTypes *EventTypeService
|
||||
}
|
||||
|
||||
// NewDeadlineService wires the service.
|
||||
func NewDeadlineService(db *sqlx.DB, projects *ProjectService) *DeadlineService {
|
||||
return &DeadlineService{db: db, projects: projects}
|
||||
// NewDeadlineService wires the service. eventTypes may be nil in tests
|
||||
// that don't exercise the event_types junction; production wires it.
|
||||
func NewDeadlineService(db *sqlx.DB, projects *ProjectService, eventTypes *EventTypeService) *DeadlineService {
|
||||
return &DeadlineService{db: db, projects: projects, eventTypes: eventTypes}
|
||||
}
|
||||
|
||||
const deadlineColumns = `id, project_id, title, description, due_date, original_due_date,
|
||||
@@ -37,22 +44,26 @@ const deadlineColumns = `id, project_id, title, description, due_date, original_
|
||||
|
||||
// CreateDeadlineInput is the payload for Create / bulk create entries.
|
||||
type CreateDeadlineInput struct {
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
DueDate string `json:"due_date"` // YYYY-MM-DD
|
||||
OriginalDueDate *string `json:"original_due_date,omitempty"`
|
||||
RuleID *uuid.UUID `json:"rule_id,omitempty"`
|
||||
Source string `json:"source,omitempty"` // default "manual"
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
DueDate string `json:"due_date"` // YYYY-MM-DD
|
||||
OriginalDueDate *string `json:"original_due_date,omitempty"`
|
||||
RuleID *uuid.UUID `json:"rule_id,omitempty"`
|
||||
Source string `json:"source,omitempty"` // default "manual"
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
EventTypeIDs []uuid.UUID `json:"event_type_ids,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateDeadlineInput is the partial-update payload for PATCH.
|
||||
// EventTypeIDs uses pointer-to-slice semantics: nil = leave existing
|
||||
// attachments untouched; non-nil (including empty) = replace.
|
||||
type UpdateDeadlineInput struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
DueDate *string `json:"due_date,omitempty"` // YYYY-MM-DD
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
DueDate *string `json:"due_date,omitempty"` // YYYY-MM-DD
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
EventTypeIDs *[]uuid.UUID `json:"event_type_ids,omitempty"`
|
||||
}
|
||||
|
||||
// DeadlineStatusFilter is a server-side bucket for ListVisibleForUser.
|
||||
@@ -68,9 +79,17 @@ const (
|
||||
)
|
||||
|
||||
// ListFilter narrows ListVisibleForUser results.
|
||||
//
|
||||
// EventTypeIDs / IncludeUntyped form the multi-select Typ filter on
|
||||
// /deadlines and /agenda (t-paliad-088). The two flags compose with OR:
|
||||
// a deadline matches if it has at least one of EventTypeIDs attached
|
||||
// OR (IncludeUntyped && it has none). When BOTH are zero/false the
|
||||
// filter is inactive.
|
||||
type ListFilter struct {
|
||||
Status DeadlineStatusFilter
|
||||
ProjectID *uuid.UUID
|
||||
Status DeadlineStatusFilter
|
||||
ProjectID *uuid.UUID
|
||||
EventTypeIDs []uuid.UUID
|
||||
IncludeUntyped bool
|
||||
}
|
||||
|
||||
// ListVisibleForUser returns Deadlines on every Project the user can see,
|
||||
@@ -92,6 +111,9 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
|
||||
conds = append(conds, `f.project_id = :project_id`)
|
||||
args["project_id"] = *filter.ProjectID
|
||||
}
|
||||
if etCond := buildEventTypeFilterClause(filter, args); etCond != "" {
|
||||
conds = append(conds, etCond)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
today := now.Truncate(24 * time.Hour)
|
||||
@@ -145,6 +167,19 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
|
||||
if err := stmt.SelectContext(ctx, &rows, args); err != nil {
|
||||
return nil, fmt.Errorf("list deadlines: %w", err)
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
ids := make([]uuid.UUID, len(rows))
|
||||
for i := range rows {
|
||||
ids[i] = rows[i].ID
|
||||
}
|
||||
etByID, err := s.hydrateEventTypes(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range rows {
|
||||
rows[i].EventTypeIDs = etByID[rows[i].ID]
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
@@ -161,6 +196,19 @@ func (s *DeadlineService) ListForProject(ctx context.Context, userID, projectID
|
||||
ORDER BY due_date ASC, created_at DESC`, projectID); err != nil {
|
||||
return nil, fmt.Errorf("list deadlines for project: %w", err)
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
ids := make([]uuid.UUID, len(rows))
|
||||
for i := range rows {
|
||||
ids[i] = rows[i].ID
|
||||
}
|
||||
etByID, err := s.hydrateEventTypes(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range rows {
|
||||
rows[i].EventTypeIDs = etByID[rows[i].ID]
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
@@ -178,6 +226,11 @@ func (s *DeadlineService) GetByID(ctx context.Context, userID, deadlineID uuid.U
|
||||
`SELECT `+deadlineColumns+` FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
|
||||
return nil, fmt.Errorf("fetch deadline: %w", err)
|
||||
}
|
||||
etIDs, err := s.hydrateEventTypes(ctx, []uuid.UUID{f.ID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.EventTypeIDs = etIDs[f.ID]
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
@@ -186,6 +239,9 @@ func (s *DeadlineService) Create(ctx context.Context, userID, projectID uuid.UUI
|
||||
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.validateEventTypeIDs(ctx, userID, input.EventTypeIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := s.insert(ctx, userID, projectID, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -193,6 +249,19 @@ func (s *DeadlineService) Create(ctx context.Context, userID, projectID uuid.UUI
|
||||
return s.GetByID(ctx, userID, id)
|
||||
}
|
||||
|
||||
// validateEventTypeIDs returns nil if every id is visible to userID, or
|
||||
// ErrNotVisible / ErrInvalidInput otherwise. No-op when ids is empty
|
||||
// or when the eventTypes service is unwired (test harness).
|
||||
func (s *DeadlineService) validateEventTypeIDs(ctx context.Context, userID uuid.UUID, ids []uuid.UUID) error {
|
||||
if len(ids) == 0 || s.eventTypes == nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.eventTypes.ValidateForUser(ctx, userID, ids); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateBulk inserts multiple Deadlines under one Project in a single
|
||||
// transaction (Fristenrechner "Als Deadline(en) speichern" flow).
|
||||
func (s *DeadlineService) CreateBulk(ctx context.Context, userID, projectID uuid.UUID, inputs []CreateDeadlineInput) ([]models.Deadline, error) {
|
||||
@@ -211,10 +280,18 @@ func (s *DeadlineService) CreateBulk(ctx context.Context, userID, projectID uuid
|
||||
|
||||
ids := make([]uuid.UUID, 0, len(inputs))
|
||||
for _, in := range inputs {
|
||||
if err := s.validateEventTypeIDs(ctx, userID, in.EventTypeIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := s.insertTx(ctx, tx, userID, projectID, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.eventTypes != nil && len(in.EventTypeIDs) > 0 {
|
||||
if err := s.eventTypes.AttachToDeadlineTx(ctx, tx, id, in.EventTypeIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
@@ -288,14 +365,17 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
|
||||
appendSet("completed_at", nil)
|
||||
}
|
||||
}
|
||||
if len(sets) == 0 {
|
||||
if input.EventTypeIDs != nil {
|
||||
if err := s.validateEventTypeIDs(ctx, userID, *input.EventTypeIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(sets) == 0 && input.EventTypeIDs == nil {
|
||||
return current, nil
|
||||
}
|
||||
appendSet("updated_at", time.Now().UTC())
|
||||
|
||||
args = append(args, deadlineID)
|
||||
query := fmt.Sprintf("UPDATE paliad.deadlines SET %s WHERE id = $%d",
|
||||
strings.Join(sets, ", "), next)
|
||||
if len(sets) > 0 {
|
||||
appendSet("updated_at", time.Now().UTC())
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
@@ -303,8 +383,19 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
|
||||
return nil, fmt.Errorf("update deadline: %w", err)
|
||||
if len(sets) > 0 {
|
||||
args = append(args, deadlineID)
|
||||
query := fmt.Sprintf("UPDATE paliad.deadlines SET %s WHERE id = $%d",
|
||||
strings.Join(sets, ", "), next)
|
||||
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
|
||||
return nil, fmt.Errorf("update deadline: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if input.EventTypeIDs != nil && s.eventTypes != nil {
|
||||
if err := s.eventTypes.AttachToDeadlineTx(ctx, tx, deadlineID, *input.EventTypeIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Description carries value-only payload (the deadline title); frontend
|
||||
@@ -543,6 +634,12 @@ func (s *DeadlineService) insert(ctx context.Context, userID, projectID uuid.UUI
|
||||
return uuid.Nil, err
|
||||
}
|
||||
|
||||
if s.eventTypes != nil && len(input.EventTypeIDs) > 0 {
|
||||
if err := s.eventTypes.AttachToDeadlineTx(ctx, tx, id, input.EventTypeIDs); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
}
|
||||
|
||||
desc := strings.TrimSpace(input.Title)
|
||||
descPtr := &desc
|
||||
if err := insertProjectEvent(ctx, tx, projectID, userID, "deadline_created", "Deadline created", descPtr); err != nil {
|
||||
@@ -622,3 +719,55 @@ func isValidDeadlineStatus(st string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// buildEventTypeFilterClause returns the WHERE-fragment that enforces
|
||||
// ListFilter.EventTypeIDs / ListFilter.IncludeUntyped against the
|
||||
// `paliad.deadlines` row aliased `f`. Caller is using a sqlx named-args
|
||||
// map; this function injects the params directly into that map and
|
||||
// returns a fragment usable with the named-statement compiler. Returns
|
||||
// empty when no Typ filter is active.
|
||||
func buildEventTypeFilterClause(filter ListFilter, args map[string]any) string {
|
||||
if len(filter.EventTypeIDs) == 0 && !filter.IncludeUntyped {
|
||||
return ""
|
||||
}
|
||||
parts := []string{}
|
||||
if len(filter.EventTypeIDs) > 0 {
|
||||
// sqlx.PrepareNamedContext doesn't expand IN-with-slice; build
|
||||
// per-element named placeholders manually instead.
|
||||
phs := make([]string, 0, len(filter.EventTypeIDs))
|
||||
for i, id := range filter.EventTypeIDs {
|
||||
key := fmt.Sprintf("event_type_id_%d", i)
|
||||
args[key] = id
|
||||
phs = append(phs, ":"+key)
|
||||
}
|
||||
parts = append(parts, `EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_event_types det
|
||||
WHERE det.deadline_id = f.id
|
||||
AND det.event_type_id IN (`+strings.Join(phs, ", ")+`)
|
||||
)`)
|
||||
}
|
||||
if filter.IncludeUntyped {
|
||||
parts = append(parts, `NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_event_types det
|
||||
WHERE det.deadline_id = f.id
|
||||
)`)
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
return parts[0]
|
||||
}
|
||||
return "(" + strings.Join(parts, " OR ") + ")"
|
||||
}
|
||||
|
||||
// hydrateEventTypes loads the attached event_type_ids for each Deadline
|
||||
// (or DeadlineWithProject) in rows. No-op when eventTypes service is
|
||||
// unset (test fixtures).
|
||||
func (s *DeadlineService) hydrateEventTypes(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID][]uuid.UUID, error) {
|
||||
if s.eventTypes == nil {
|
||||
out := make(map[uuid.UUID][]uuid.UUID, len(ids))
|
||||
for _, id := range ids {
|
||||
out[id] = []uuid.UUID{}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return s.eventTypes.ListForDeadlines(ctx, ids)
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ func TestDeadlineReopen_AdminAndNonAdmin(t *testing.T) {
|
||||
|
||||
users := NewUserService(pool)
|
||||
projects := NewProjectService(pool, users)
|
||||
svc := NewDeadlineService(pool, projects)
|
||||
svc := NewDeadlineService(pool, projects, nil)
|
||||
|
||||
// Non-admin associate cannot reopen.
|
||||
if _, err := svc.Reopen(ctx, memberID, deadlineID); !errors.Is(err, ErrForbidden) {
|
||||
|
||||
478
internal/services/event_type_service.go
Normal file
478
internal/services/event_type_service.go
Normal file
@@ -0,0 +1,478 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/patholo/internal/models"
|
||||
)
|
||||
|
||||
// EventTypeService manages paliad.event_types and the
|
||||
// paliad.deadline_event_types junction. Visibility is enforced in this
|
||||
// layer (not via RLS) because the DB pool runs as service-role; the
|
||||
// migration's RLS policies are defense-in-depth.
|
||||
//
|
||||
// Users see firm-wide types (is_firm_wide=true) plus their own private
|
||||
// types (created_by = user_id). Any authenticated user can create
|
||||
// firm-wide types — admins moderate via Archive after the fact (m's
|
||||
// Q6 call on t-paliad-088). Authors can edit/archive their own rows;
|
||||
// global_admin can edit/archive any firm-wide row.
|
||||
type EventTypeService struct {
|
||||
db *sqlx.DB
|
||||
users *UserService
|
||||
}
|
||||
|
||||
// NewEventTypeService wires the service.
|
||||
func NewEventTypeService(db *sqlx.DB, users *UserService) *EventTypeService {
|
||||
return &EventTypeService{db: db, users: users}
|
||||
}
|
||||
|
||||
const eventTypeColumns = `id, slug, label_de, label_en, category, jurisdiction,
|
||||
description, trigger_event_id, created_by, is_firm_wide, archived_at,
|
||||
created_at, updated_at`
|
||||
|
||||
var validEventTypeCategories = map[string]bool{
|
||||
models.EventTypeCategorySubmission: true,
|
||||
models.EventTypeCategoryDecision: true,
|
||||
models.EventTypeCategoryOrder: true,
|
||||
models.EventTypeCategoryService: true,
|
||||
models.EventTypeCategoryFee: true,
|
||||
models.EventTypeCategoryHearing: true,
|
||||
models.EventTypeCategoryOther: true,
|
||||
}
|
||||
|
||||
var validEventTypeJurisdictions = map[string]bool{
|
||||
models.EventTypeJurisdictionUPC: true,
|
||||
models.EventTypeJurisdictionEPO: true,
|
||||
models.EventTypeJurisdictionDPMA: true,
|
||||
models.EventTypeJurisdictionDE: true,
|
||||
models.EventTypeJurisdictionAny: true,
|
||||
}
|
||||
|
||||
// ErrEventTypeSlugTaken signals an attempt to create a row whose slug
|
||||
// already exists in the same scope (firm-wide or per-user). Handlers
|
||||
// surface this as 409.
|
||||
var ErrEventTypeSlugTaken = errors.New("event_type slug already exists in scope")
|
||||
|
||||
// CreateEventTypeInput is the payload for POST /api/event-types.
|
||||
type CreateEventTypeInput struct {
|
||||
Slug *string `json:"slug,omitempty"` // optional; auto-derived from label_de if absent
|
||||
LabelDE string `json:"label_de"`
|
||||
LabelEN string `json:"label_en,omitempty"` // falls back to label_de when blank
|
||||
Category string `json:"category,omitempty"` // defaults to 'submission'
|
||||
Jurisdiction *string `json:"jurisdiction,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
IsFirmWide bool `json:"is_firm_wide,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateEventTypeInput is the partial-update payload for PATCH.
|
||||
// Setting Archive=true is the soft-delete pathway; never expose a hard
|
||||
// delete.
|
||||
type UpdateEventTypeInput struct {
|
||||
LabelDE *string `json:"label_de,omitempty"`
|
||||
LabelEN *string `json:"label_en,omitempty"`
|
||||
Category *string `json:"category,omitempty"`
|
||||
Jurisdiction *string `json:"jurisdiction,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Archive *bool `json:"archive,omitempty"`
|
||||
}
|
||||
|
||||
// ListFilter narrows List results.
|
||||
type EventTypeListFilter struct {
|
||||
Category string
|
||||
Jurisdiction string
|
||||
}
|
||||
|
||||
// List returns event_types visible to userID (firm-wide ∪ own private,
|
||||
// not archived), optionally filtered by category / jurisdiction. Sorted
|
||||
// by category, then label_de.
|
||||
func (s *EventTypeService) List(ctx context.Context, userID uuid.UUID, filter EventTypeListFilter) ([]models.EventType, error) {
|
||||
conds := []string{"archived_at IS NULL", "(is_firm_wide = TRUE OR created_by = $1)"}
|
||||
args := []any{userID}
|
||||
next := 2
|
||||
if filter.Category != "" {
|
||||
if !validEventTypeCategories[filter.Category] {
|
||||
return nil, fmt.Errorf("%w: invalid category %q", ErrInvalidInput, filter.Category)
|
||||
}
|
||||
conds = append(conds, fmt.Sprintf("category = $%d", next))
|
||||
args = append(args, filter.Category)
|
||||
next++
|
||||
}
|
||||
if filter.Jurisdiction != "" {
|
||||
if !validEventTypeJurisdictions[filter.Jurisdiction] {
|
||||
return nil, fmt.Errorf("%w: invalid jurisdiction %q", ErrInvalidInput, filter.Jurisdiction)
|
||||
}
|
||||
conds = append(conds, fmt.Sprintf("jurisdiction = $%d", next))
|
||||
args = append(args, filter.Jurisdiction)
|
||||
next++
|
||||
}
|
||||
rows := []models.EventType{}
|
||||
q := `SELECT ` + eventTypeColumns + ` FROM paliad.event_types
|
||||
WHERE ` + strings.Join(conds, " AND ") + `
|
||||
ORDER BY category ASC, label_de ASC`
|
||||
if err := s.db.SelectContext(ctx, &rows, q, args...); err != nil {
|
||||
return nil, fmt.Errorf("list event_types: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// GetByID returns one event_type, visibility-checked.
|
||||
func (s *EventTypeService) GetByID(ctx context.Context, userID, id uuid.UUID) (*models.EventType, error) {
|
||||
var et models.EventType
|
||||
err := s.db.GetContext(ctx, &et,
|
||||
`SELECT `+eventTypeColumns+` FROM paliad.event_types
|
||||
WHERE id = $1
|
||||
AND archived_at IS NULL
|
||||
AND (is_firm_wide = TRUE OR created_by = $2)`, id, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotVisible
|
||||
}
|
||||
return nil, fmt.Errorf("fetch event_type: %w", err)
|
||||
}
|
||||
return &et, nil
|
||||
}
|
||||
|
||||
// Create inserts a new event_type. created_by always = userID — the
|
||||
// service never accepts a different value from the client. is_firm_wide
|
||||
// is taken verbatim from the input (any user can publish firm-wide,
|
||||
// per Q6).
|
||||
func (s *EventTypeService) Create(ctx context.Context, userID uuid.UUID, input CreateEventTypeInput) (*models.EventType, error) {
|
||||
labelDE := strings.TrimSpace(input.LabelDE)
|
||||
if labelDE == "" {
|
||||
return nil, fmt.Errorf("%w: label_de is required", ErrInvalidInput)
|
||||
}
|
||||
labelEN := strings.TrimSpace(input.LabelEN)
|
||||
if labelEN == "" {
|
||||
labelEN = labelDE
|
||||
}
|
||||
category := input.Category
|
||||
if category == "" {
|
||||
category = models.EventTypeCategorySubmission
|
||||
}
|
||||
if !validEventTypeCategories[category] {
|
||||
return nil, fmt.Errorf("%w: invalid category %q", ErrInvalidInput, category)
|
||||
}
|
||||
if input.Jurisdiction != nil {
|
||||
j := strings.TrimSpace(*input.Jurisdiction)
|
||||
if j == "" {
|
||||
input.Jurisdiction = nil
|
||||
} else if !validEventTypeJurisdictions[j] {
|
||||
return nil, fmt.Errorf("%w: invalid jurisdiction %q", ErrInvalidInput, j)
|
||||
} else {
|
||||
input.Jurisdiction = &j
|
||||
}
|
||||
}
|
||||
slug := ""
|
||||
if input.Slug != nil {
|
||||
slug = strings.TrimSpace(*input.Slug)
|
||||
}
|
||||
if slug == "" {
|
||||
slug = slugify(labelDE)
|
||||
} else {
|
||||
slug = slugify(slug)
|
||||
}
|
||||
if slug == "" {
|
||||
return nil, fmt.Errorf("%w: could not derive slug from label", ErrInvalidInput)
|
||||
}
|
||||
|
||||
id := uuid.New()
|
||||
const q = `INSERT INTO paliad.event_types
|
||||
(id, slug, label_de, label_en, category, jurisdiction, description,
|
||||
trigger_event_id, created_by, is_firm_wide)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, $8, $9)`
|
||||
_, err := s.db.ExecContext(ctx, q,
|
||||
id, slug, labelDE, labelEN, category, input.Jurisdiction,
|
||||
strings.TrimSpace(input.Description), userID, input.IsFirmWide)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrEventTypeSlugTaken
|
||||
}
|
||||
return nil, fmt.Errorf("insert event_type: %w", err)
|
||||
}
|
||||
return s.GetByID(ctx, userID, id)
|
||||
}
|
||||
|
||||
// Update applies a partial update. Authorization: author OR (firm-wide
|
||||
// AND user.global_role='global_admin'). Setting Archive=true sets
|
||||
// archived_at = now(); never hard-delete.
|
||||
func (s *EventTypeService) Update(ctx context.Context, userID, id uuid.UUID, input UpdateEventTypeInput) (*models.EventType, error) {
|
||||
current, err := s.GetByID(ctx, userID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.assertCanEdit(ctx, userID, current); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sets := []string{}
|
||||
args := []any{}
|
||||
next := 1
|
||||
appendSet := func(col string, val any) {
|
||||
sets = append(sets, fmt.Sprintf("%s = $%d", col, next))
|
||||
args = append(args, val)
|
||||
next++
|
||||
}
|
||||
|
||||
if input.LabelDE != nil {
|
||||
v := strings.TrimSpace(*input.LabelDE)
|
||||
if v == "" {
|
||||
return nil, fmt.Errorf("%w: label_de cannot be empty", ErrInvalidInput)
|
||||
}
|
||||
appendSet("label_de", v)
|
||||
}
|
||||
if input.LabelEN != nil {
|
||||
appendSet("label_en", strings.TrimSpace(*input.LabelEN))
|
||||
}
|
||||
if input.Category != nil {
|
||||
v := *input.Category
|
||||
if !validEventTypeCategories[v] {
|
||||
return nil, fmt.Errorf("%w: invalid category %q", ErrInvalidInput, v)
|
||||
}
|
||||
appendSet("category", v)
|
||||
}
|
||||
if input.Jurisdiction != nil {
|
||||
v := strings.TrimSpace(*input.Jurisdiction)
|
||||
if v == "" {
|
||||
appendSet("jurisdiction", nil)
|
||||
} else {
|
||||
if !validEventTypeJurisdictions[v] {
|
||||
return nil, fmt.Errorf("%w: invalid jurisdiction %q", ErrInvalidInput, v)
|
||||
}
|
||||
appendSet("jurisdiction", v)
|
||||
}
|
||||
}
|
||||
if input.Description != nil {
|
||||
appendSet("description", *input.Description)
|
||||
}
|
||||
if input.Archive != nil && *input.Archive {
|
||||
appendSet("archived_at", time.Now().UTC())
|
||||
}
|
||||
if len(sets) == 0 {
|
||||
return current, nil
|
||||
}
|
||||
appendSet("updated_at", time.Now().UTC())
|
||||
|
||||
args = append(args, id)
|
||||
q := fmt.Sprintf(`UPDATE paliad.event_types SET %s WHERE id = $%d`,
|
||||
strings.Join(sets, ", "), next)
|
||||
if _, err := s.db.ExecContext(ctx, q, args...); err != nil {
|
||||
return nil, fmt.Errorf("update event_type: %w", err)
|
||||
}
|
||||
// Re-fetch through GetByID — if the row was archived in this update
|
||||
// the visibility check will return ErrNotVisible, which is the right
|
||||
// signal for the caller (the row no longer appears in pickers).
|
||||
if input.Archive != nil && *input.Archive {
|
||||
// Use a direct read so we can return the archived row exactly once.
|
||||
var et models.EventType
|
||||
if err := s.db.GetContext(ctx, &et,
|
||||
`SELECT `+eventTypeColumns+` FROM paliad.event_types WHERE id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("fetch archived event_type: %w", err)
|
||||
}
|
||||
return &et, nil
|
||||
}
|
||||
return s.GetByID(ctx, userID, id)
|
||||
}
|
||||
|
||||
func (s *EventTypeService) assertCanEdit(ctx context.Context, userID uuid.UUID, et *models.EventType) error {
|
||||
if et.CreatedBy != nil && *et.CreatedBy == userID {
|
||||
return nil
|
||||
}
|
||||
if !et.IsFirmWide {
|
||||
// Private row, not the author — should be invisible already, but
|
||||
// belt-and-braces: forbid the mutation explicitly.
|
||||
return ErrForbidden
|
||||
}
|
||||
// Firm-wide row, not the author — admin only.
|
||||
user, err := s.users.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user == nil || user.GlobalRole != "global_admin" {
|
||||
return fmt.Errorf("%w: only admins can edit firm-wide event types they did not create", ErrForbidden)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SuggestSimilar surfaces firm-wide rows whose label_de OR label_en
|
||||
// starts with or contains the trimmed query (case-insensitive). Powers
|
||||
// the "Existiert vermutlich schon: …" warning in the add modal (Q6
|
||||
// mitigation against drift).
|
||||
func (s *EventTypeService) SuggestSimilar(ctx context.Context, userID uuid.UUID, query string) ([]models.EventType, error) {
|
||||
q := strings.TrimSpace(query)
|
||||
if len(q) < 2 {
|
||||
return []models.EventType{}, nil
|
||||
}
|
||||
pattern := "%" + strings.ToLower(q) + "%"
|
||||
rows := []models.EventType{}
|
||||
const sqlQ = `SELECT ` + eventTypeColumns + ` FROM paliad.event_types
|
||||
WHERE archived_at IS NULL
|
||||
AND (is_firm_wide = TRUE OR created_by = $1)
|
||||
AND (lower(label_de) LIKE $2 OR lower(label_en) LIKE $2)
|
||||
ORDER BY (lower(label_de) = $3) DESC, label_de ASC
|
||||
LIMIT 5`
|
||||
if err := s.db.SelectContext(ctx, &rows, sqlQ, userID, pattern, strings.ToLower(q)); err != nil {
|
||||
return nil, fmt.Errorf("suggest similar event_types: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Junction: paliad.deadline_event_types
|
||||
// ============================================================================
|
||||
|
||||
// AttachToDeadlineTx replaces the set of event_type_ids attached to a
|
||||
// deadline (delete + bulk insert). Caller is responsible for the visibility
|
||||
// check on the deadline AND for the transaction lifecycle. event_type_ids
|
||||
// must already be visibility-checked by the caller (use ValidateForUser
|
||||
// before passing them in).
|
||||
func (s *EventTypeService) AttachToDeadlineTx(ctx context.Context, tx *sqlx.Tx, deadlineID uuid.UUID, eventTypeIDs []uuid.UUID) error {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM paliad.deadline_event_types WHERE deadline_id = $1`, deadlineID); err != nil {
|
||||
return fmt.Errorf("clear deadline_event_types: %w", err)
|
||||
}
|
||||
if len(eventTypeIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
// One round-trip: build a multi-row VALUES clause.
|
||||
placeholders := make([]string, 0, len(eventTypeIDs))
|
||||
args := make([]any, 0, len(eventTypeIDs)*2)
|
||||
for i, etID := range eventTypeIDs {
|
||||
placeholders = append(placeholders, fmt.Sprintf("($%d, $%d)", i*2+1, i*2+2))
|
||||
args = append(args, deadlineID, etID)
|
||||
}
|
||||
q := `INSERT INTO paliad.deadline_event_types (deadline_id, event_type_id) VALUES ` +
|
||||
strings.Join(placeholders, ", ") +
|
||||
` ON CONFLICT DO NOTHING`
|
||||
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
|
||||
return fmt.Errorf("insert deadline_event_types: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateForUser ensures every id in ids points at a row visible to
|
||||
// userID. Returns the deduplicated set in stable order; ErrNotVisible
|
||||
// when any id is missing.
|
||||
func (s *EventTypeService) ValidateForUser(ctx context.Context, userID uuid.UUID, ids []uuid.UUID) ([]uuid.UUID, error) {
|
||||
if len(ids) == 0 {
|
||||
return []uuid.UUID{}, nil
|
||||
}
|
||||
// Dedup while preserving order.
|
||||
seen := make(map[uuid.UUID]bool, len(ids))
|
||||
deduped := make([]uuid.UUID, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
deduped = append(deduped, id)
|
||||
}
|
||||
rows := []uuid.UUID{}
|
||||
const q = `SELECT id FROM paliad.event_types
|
||||
WHERE id = ANY($1)
|
||||
AND archived_at IS NULL
|
||||
AND (is_firm_wide = TRUE OR created_by = $2)`
|
||||
if err := s.db.SelectContext(ctx, &rows, q, deduped, userID); err != nil {
|
||||
return nil, fmt.Errorf("validate event_types: %w", err)
|
||||
}
|
||||
if len(rows) != len(deduped) {
|
||||
return nil, fmt.Errorf("%w: one or more event_type_ids not visible", ErrNotVisible)
|
||||
}
|
||||
return deduped, nil
|
||||
}
|
||||
|
||||
// ListForDeadlines returns the event_type_ids attached to each deadline
|
||||
// in deadlineIDs, keyed by deadline_id. Empty slices for deadlines with
|
||||
// no attachments. Used by the list endpoints to enrich each row.
|
||||
func (s *EventTypeService) ListForDeadlines(ctx context.Context, deadlineIDs []uuid.UUID) (map[uuid.UUID][]uuid.UUID, error) {
|
||||
out := make(map[uuid.UUID][]uuid.UUID, len(deadlineIDs))
|
||||
for _, id := range deadlineIDs {
|
||||
out[id] = []uuid.UUID{}
|
||||
}
|
||||
if len(deadlineIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
type pair struct {
|
||||
DeadlineID uuid.UUID `db:"deadline_id"`
|
||||
EventTypeID uuid.UUID `db:"event_type_id"`
|
||||
}
|
||||
rows := []pair{}
|
||||
const q = `SELECT deadline_id, event_type_id
|
||||
FROM paliad.deadline_event_types
|
||||
WHERE deadline_id = ANY($1)
|
||||
ORDER BY created_at ASC`
|
||||
if err := s.db.SelectContext(ctx, &rows, q, deadlineIDs); err != nil {
|
||||
return nil, fmt.Errorf("list event_types for deadlines: %w", err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
out[r.DeadlineID] = append(out[r.DeadlineID], r.EventTypeID)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListForDeadline is the single-row convenience wrapper.
|
||||
func (s *EventTypeService) ListForDeadline(ctx context.Context, deadlineID uuid.UUID) ([]uuid.UUID, error) {
|
||||
m, err := s.ListForDeadlines(ctx, []uuid.UUID{deadlineID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m[deadlineID], nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
var nonSlugChars = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// slugify lower-cases, strips diacritics, replaces non-alphanumeric runs
|
||||
// with underscores, and trims leading/trailing underscores. Keeps stable
|
||||
// output across Go versions.
|
||||
func slugify(s string) string {
|
||||
// Decompose Umlauts / accents to ASCII the cheap way.
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(s) {
|
||||
switch r {
|
||||
case 'ä':
|
||||
b.WriteString("ae")
|
||||
case 'ö':
|
||||
b.WriteString("oe")
|
||||
case 'ü':
|
||||
b.WriteString("ue")
|
||||
case 'ß':
|
||||
b.WriteString("ss")
|
||||
default:
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == ' ' || r == '-' || r == '_' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
out := nonSlugChars.ReplaceAllString(b.String(), "_")
|
||||
out = strings.Trim(out, "_")
|
||||
if len(out) > 80 {
|
||||
out = out[:80]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isUniqueViolation detects Postgres SQLSTATE 23505 in a driver-agnostic
|
||||
// way (string match on the error text). Used to convert slug-collision
|
||||
// inserts into ErrEventTypeSlugTaken.
|
||||
func isUniqueViolation(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := err.Error()
|
||||
return strings.Contains(s, "SQLSTATE 23505") ||
|
||||
strings.Contains(s, "duplicate key") ||
|
||||
strings.Contains(s, "unique constraint")
|
||||
}
|
||||
@@ -213,7 +213,7 @@ func TestVisibilityPredicate_DashboardAgendaForGlobalAdmin(t *testing.T) {
|
||||
|
||||
users := NewUserService(pool)
|
||||
dashboard := NewDashboardService(pool, users)
|
||||
agenda := NewAgendaService(pool, users)
|
||||
agenda := NewAgendaService(pool, users, nil)
|
||||
|
||||
t.Run("global_admin sees dashboard rows without team membership", func(t *testing.T) {
|
||||
data, err := dashboard.Get(ctx, adminID)
|
||||
|
||||
Reference in New Issue
Block a user