feat(t-paliad-088): Event Types for deadlines — schema + service + handlers (PR-1)

Migration 030 adds paliad.event_types and paliad.deadline_event_types
junction. ~43 firm-wide seeds biased toward submissions (25 UPC
submissions + 8 UPC decisions/orders/hearings + 5 EPO + 4 DPMA/DE + 1
cross-jurisdiction). UPC-seeded rows carry a loose trigger_event_id
column (no FK constraint per Q2: event_types leads, trigger_events
follows). RLS policies are defense-in-depth — primary enforcement is
in the Go service layer. Per Q6, any authenticated user can create
firm-wide types; admins moderate via the soft-delete archive lever.

EventTypeService: List (firm-wide ∪ own-private), GetByID, Create
(slug auto-derived, supports diacritics → ASCII), Update (author OR
admin-on-firm-wide), SuggestSimilar (powers the duplicate-warning in
the add modal), AttachToDeadlineTx + ValidateForUser + ListForDeadlines
for the junction.

DeadlineService gains an EventTypeService dependency and now:
- accepts event_type_ids on Create / Update / CreateBulk
- attaches them in the same transaction as the deadline insert
- hydrates EventTypeIDs on every Get / List / ListForProject
- supports the multi-select Typ filter via ListFilter.EventTypeIDs +
  IncludeUntyped (UNION semantics within types, AND-intersected with
  Status/Project)

AgendaService gets the same Typ filter on its deadline side;
appointments are unaffected.

API:
- GET /api/event-types?category=&jurisdiction=
- GET /api/event-types/suggest?q=
- POST /api/event-types
- PATCH /api/event-types/{id}        (set archive=true to hide)
- GET /api/deadlines?event_type=<uuid>,<uuid>,none
- GET /api/agenda?event_type=<uuid>,<uuid>,none
- POST/PATCH /api/deadlines accept event_type_ids: [uuid]

go build / go vet / go test ./... clean.

Frontend (picker + custom-add modal + multi-select filter) follows in
PR-2. Admin moderation panel deferred to t-paliad-089 follow-up.
This commit is contained in:
m
2026-04-30 12:49:04 +02:00
parent c74f6b494c
commit 04ce6a8bfa
14 changed files with 1151 additions and 43 deletions

View File

@@ -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),

View 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;

View 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);

View File

@@ -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
}

View File

@@ -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) {

View 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)
}

View File

@@ -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)

View File

@@ -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"})

View File

@@ -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"
)

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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) {

View 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")
}

View File

@@ -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)