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