diff --git a/cmd/server/main.go b/cmd/server/main.go index a38d337..55b2305 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -52,7 +52,7 @@ func main() { // DATABASE_URL is optional during the Phase A → Phase D transition. The // existing knowledge-platform features (Kostenrechner, Glossar, etc.) work - // without a DB. Akten/Frist endpoints return 503 until DATABASE_URL is set. + // without a DB. Akten/Deadline endpoints return 503 until DATABASE_URL is set. dbURL := os.Getenv("DATABASE_URL") var svcBundle *handlers.Services var caldavSvc *services.CalDAVService @@ -70,9 +70,9 @@ func main() { } holidays := services.NewHolidayService(pool) users := services.NewUserService(pool) - projektSvc := services.NewProjektService(pool, users) + projektSvc := services.NewProjectService(pool, users) teamSvc := services.NewTeamService(pool, projektSvc) - dezernatSvc := services.NewDezernatService(pool, users) + dezernatSvc := services.NewDepartmentService(pool, users) rules := services.NewDeadlineRuleService(pool) // Phase F: optional CalDAV cipher. If CALDAV_ENCRYPTION_KEY is unset @@ -89,7 +89,7 @@ func main() { log.Println("CalDAV encryption configured (AES-256-GCM)") } - terminSvc := services.NewTerminService(pool, projektSvc) + terminSvc := services.NewAppointmentService(pool, projektSvc) caldavSvc = services.NewCalDAVService(pool, cipher, terminSvc) // Wire the push hook so user-driven mutations sync to the external // calendar without waiting for the next 60-second tick. @@ -100,19 +100,19 @@ func main() { reminderSvc := services.NewReminderService(pool, mailSvc, users, baseURL) svcBundle = &handlers.Services{ - Projekt: projektSvc, + Project: projektSvc, Team: teamSvc, Dezernat: dezernatSvc, - Parteien: services.NewParteienService(pool, projektSvc), - Frist: services.NewFristService(pool, projektSvc), - Termin: terminSvc, + Parties: services.NewPartyService(pool, projektSvc), + Deadline: services.NewDeadlineService(pool, projektSvc), + Appointment: terminSvc, CalDAV: caldavSvc, Rules: rules, Calculator: services.NewDeadlineCalculator(holidays), Users: users, Fristenrechner: services.NewFristenrechnerService(rules, holidays), Dashboard: services.NewDashboardService(pool, users), - Notiz: services.NewNotizService(pool, projektSvc, terminSvc), + Note: services.NewNoteService(pool, projektSvc, terminSvc), ChecklistInst: services.NewChecklistInstanceService(pool, projektSvc), Mail: mailSvc, Invite: inviteSvc, @@ -132,7 +132,7 @@ func main() { caldavSvc.Stop() }() } else { - log.Println("DATABASE_URL not set — Akten/Frist endpoints will return 503") + log.Println("DATABASE_URL not set — Akten/Deadline endpoints will return 503") } mux := http.NewServeMux() diff --git a/internal/checklisten/checklisten.go b/internal/checklists/checklists.go similarity index 94% rename from internal/checklisten/checklisten.go rename to internal/checklists/checklists.go index 8b360a2..b4870d7 100644 --- a/internal/checklisten/checklisten.go +++ b/internal/checklists/checklists.go @@ -1,5 +1,5 @@ -// Package checklisten holds the static checklist templates that users -// instantiate on /checklisten/{slug}. Template data lives in Go (not in +// Package checklists holds the static checklist templates that users +// instantiate on /checklists/{slug}. Template data lives in Go (not in // the DB) because it's curated content, versioned with the code, and // rarely changes. Per-user state lives in paliad.checklist_instances. // @@ -7,7 +7,7 @@ // (internal/services) import this package — handlers serve the template // JSON to the frontend, services validate slugs on POST and count items // for progress responses without needing handler types. -package checklisten +package checklists // Item is a single checklist point. type Item struct { diff --git a/internal/checklisten/templates.go b/internal/checklists/templates.go similarity index 92% rename from internal/checklisten/templates.go rename to internal/checklists/templates.go index d8823e8..6c6d3af 100644 --- a/internal/checklisten/templates.go +++ b/internal/checklists/templates.go @@ -1,4 +1,4 @@ -package checklisten +package checklists // Templates is the full list of static checklist definitions. Edit this // file (or split per regime later) to add/update a checklist. @@ -19,7 +19,7 @@ var Templates = []Template{ TitleDE: "Formale Angaben", TitleEN: "Formal details", Items: []Item{ - {LabelDE: "Bezeichnung der Parteien (inkl. Vertreter)", LabelEN: "Names and addresses of the parties (and representatives)", Rule: "RoP 13(1)(a)-(b)"}, + {LabelDE: "Bezeichnung der Parties (inkl. Vertreter)", LabelEN: "Names and addresses of the parties (and representatives)", Rule: "RoP 13(1)(a)-(b)"}, {LabelDE: "Angabe der Lokal-, Regional- oder Zentralkammer", LabelEN: "Identification of the division where the action is brought", Rule: "RoP 13(1)(c)"}, {LabelDE: "Verfahrenssprache gewählt und ggf. begründet", LabelEN: "Language of proceedings selected and justified if required", Rule: "RoP 14"}, {LabelDE: "Anschrift für Zustellungen", LabelEN: "Address for service", Rule: "RoP 8.1, 13(1)(b)"}, @@ -87,9 +87,9 @@ var Templates = []Template{ TitleDE: "Formale Angaben", TitleEN: "Formal details", Items: []Item{ - {LabelDE: "Aktenzeichen und Bezeichnung der Parteien", LabelEN: "Case number and names of the parties", Rule: "RoP 24(a)"}, + {LabelDE: "Aktenzeichen und Bezeichnung der Parties", LabelEN: "Case number and names of the parties", Rule: "RoP 24(a)"}, {LabelDE: "Anschrift für Zustellungen und Vertretungsnachweis", LabelEN: "Address for service and evidence of representation", Rule: "RoP 24(b)-(c)"}, - {LabelDE: "Einhaltung der 3-Monats-Frist", LabelEN: "Compliance with the 3-month deadline", Rule: "RoP 23", NoteDE: "Frist läuft ab Zustellung der Klageschrift.", NoteEN: "Runs from service of the statement of claim."}, + {LabelDE: "Einhaltung der 3-Monats-Deadline", LabelEN: "Compliance with the 3-month deadline", Rule: "RoP 23", NoteDE: "Deadline läuft ab Zustellung der Klageschrift.", NoteEN: "Runs from service of the statement of claim."}, }, }, { @@ -149,7 +149,7 @@ var Templates = []Template{ TitleDE: "Vertraulichkeitsklub", TitleEN: "Confidentiality club", Items: []Item{ - {LabelDE: "Vorschlag des Personenkreises mit Zugang (max. 1 natürliche Person je Partei + Vertreter)", LabelEN: "Proposed list of persons with access (min. one natural person per party plus representatives)", Rule: "RoP 262A.5"}, + {LabelDE: "Vorschlag des Personenkreises mit Zugang (max. 1 natürliche Person je Party + Vertreter)", LabelEN: "Proposed list of persons with access (min. one natural person per party plus representatives)", Rule: "RoP 262A.5"}, {LabelDE: "Geheimhaltungsverpflichtung für alle Klub-Mitglieder vorgesehen", LabelEN: "Confidentiality undertaking foreseen for all club members"}, {LabelDE: "Ggf. besondere Modalitäten (nur Einsicht vor Ort, keine Kopien)", LabelEN: "Specific modalities (on-site inspection only, no copies) if applicable"}, }, @@ -225,7 +225,7 @@ var Templates = []Template{ TitleDE: "Formale Anforderungen", TitleEN: "Formal requirements", Items: []Item{ - {LabelDE: "Bezeichnung der Parteien (Kläger, Beklagter = Patentinhaber)", LabelEN: "Names of the parties (claimant, defendant = patent proprietor)", Rule: "§ 253 ZPO i.V.m. § 99 PatG"}, + {LabelDE: "Bezeichnung der Parties (Kläger, Beklagter = Patentinhaber)", LabelEN: "Names of the parties (claimant, defendant = patent proprietor)", Rule: "§ 253 ZPO i.V.m. § 99 PatG"}, {LabelDE: "Vertretung durch zugelassenen Rechts- oder Patentanwalt", LabelEN: "Representation by admitted attorney-at-law or patent attorney", Rule: "§ 97 PatG"}, {LabelDE: "Schriftform und Unterschrift des Bevollmächtigten", LabelEN: "Written form and signature of the representative"}, {LabelDE: "Zustellungsbevollmächtigter bei Auslandssitz", LabelEN: "Address for service in Germany if abroad"}, @@ -268,7 +268,7 @@ var Templates = []Template{ Slug: "epa-opposition", TitleDE: "EPA Einspruch", TitleEN: "EPO Opposition", - DescriptionDE: "Formvorschriften und Frist für einen Einspruch gegen ein erteiltes europäisches Patent.", + DescriptionDE: "Formvorschriften und Deadline für einen Einspruch gegen ein erteiltes europäisches Patent.", DescriptionEN: "Formal requirements and deadline for an opposition against a granted European patent.", Regime: "EPA", CourtDE: "Europäisches Patentamt, Einspruchsabteilung", @@ -279,16 +279,16 @@ var Templates = []Template{ ReferenceEN: "Art. 99 EPC; Rule 76 EPC", Groups: []Group{ { - TitleDE: "Frist und Einreichung", + TitleDE: "Deadline und Einreichung", TitleEN: "Deadline and filing", Items: []Item{ - {LabelDE: "9-Monats-Frist ab B-Schriften-Veröffentlichung eingehalten", LabelEN: "9-month deadline from B-publication met", Rule: "Art. 99(1) EPÜ / EPC", NoteDE: "Nicht verlängerbar; Wiedereinsetzung nur eng begrenzt möglich.", NoteEN: "Not extendable; re-establishment only in narrow circumstances."}, + {LabelDE: "9-Monats-Deadline ab B-Schriften-Veröffentlichung eingehalten", LabelEN: "9-month deadline from B-publication met", Rule: "Art. 99(1) EPÜ / EPC", NoteDE: "Nicht verlängerbar; Wiedereinsetzung nur eng begrenzt möglich.", NoteEN: "Not extendable; re-establishment only in narrow circumstances."}, {LabelDE: "Einreichung über EPA Online Filing / MyEPO", LabelEN: "Filed via EPO Online Filing / MyEPO"}, - {LabelDE: "Eingangstag dokumentiert und Einhaltung der Frist geprüft", LabelEN: "Filing receipt saved and deadline compliance verified"}, + {LabelDE: "Eingangstag dokumentiert und Einhaltung der Deadline geprüft", LabelEN: "Filing receipt saved and deadline compliance verified"}, }, }, { - TitleDE: "Parteien und Vertretung", + TitleDE: "Parties und Vertretung", TitleEN: "Parties and representation", Items: []Item{ {LabelDE: "Bezeichnung des Einsprechenden (Name, Anschrift, Staat)", LabelEN: "Identification of the opponent (name, address, state)", Rule: "Regel 76(2)(a) EPÜ / EPC"}, diff --git a/internal/db/migrations/018_projekte_v2.down.sql b/internal/db/migrations/018_projects_v2.down.sql similarity index 56% rename from internal/db/migrations/018_projekte_v2.down.sql rename to internal/db/migrations/018_projects_v2.down.sql index 6c5a3e6..c8bca99 100644 --- a/internal/db/migrations/018_projekte_v2.down.sql +++ b/internal/db/migrations/018_projects_v2.down.sql @@ -1,12 +1,15 @@ --- Rollback for 018_projekte_v2. +-- Rollback for 018_projects_v2. -- -- Recreates paliad.akten (same UUIDs), restores akte_id FK columns on the --- children, drops the new projekte/team/dezernat tables. Best-effort: --- * Child rows whose projekt_id points at a non-akten projekte row (i.e., +-- children, renames child tables back to German (notes→notizen, deadlines→ +-- fristen, appointments→termine, parties→parteien, documents→dokumente, +-- project_events→akten_events), drops the new projects/team/department +-- tables. Best-effort: +-- * Child rows whose project_id points at a non-akten project row (i.e., -- anything created post-migration under non-'case' types) will fail -- FK re-creation. The rollback aborts in that case — this is intentional, -- since v2-only data has no v1 home. --- * projekt_teams memberships are folded back into akten.collaborators +-- * project_teams memberships are folded back into akten.collaborators -- (dedup, drop role metadata); users who were only 'lead' end up in -- the array like everyone else. -- * owning_office is set to the creator's user.office when available, @@ -40,7 +43,7 @@ CREATE INDEX akten_owning_office_idx ON paliad.akten (owning_office); CREATE INDEX akten_firm_wide_idx ON paliad.akten (firm_wide_visible) WHERE firm_wide_visible = true; CREATE INDEX akten_collaborators_gin_idx ON paliad.akten USING GIN (collaborators); --- 2. Backfill from projekte (only type='case' — others have no v1 home). +-- 2. Backfill from projects (only type='case' — others have no v1 home). INSERT INTO paliad.akten ( id, aktenzeichen, title, akte_type, court, court_ref, status, ai_summary, owning_office, collaborators, firm_wide_visible, @@ -62,8 +65,8 @@ SELECT ), COALESCE( (SELECT array_agg(DISTINCT pt.user_id) - FROM paliad.projekt_teams pt - WHERE pt.projekt_id = p.id + FROM paliad.project_teams pt + WHERE pt.project_id = p.id AND pt.user_id IS NOT NULL), '{}'::uuid[] ), @@ -72,124 +75,153 @@ SELECT p.metadata, p.created_at, p.updated_at - FROM paliad.projekte p + FROM paliad.projects p WHERE p.type = 'case'; -- 3. Drop new RLS policies + helpers we installed in .up. -DROP POLICY IF EXISTS parteien_all ON paliad.parteien; -DROP POLICY IF EXISTS fristen_all ON paliad.fristen; -DROP POLICY IF EXISTS termine_select ON paliad.termine; -DROP POLICY IF EXISTS termine_insert ON paliad.termine; -DROP POLICY IF EXISTS termine_update ON paliad.termine; -DROP POLICY IF EXISTS termine_delete ON paliad.termine; -DROP POLICY IF EXISTS dokumente_all ON paliad.dokumente; -DROP POLICY IF EXISTS projekt_events_all ON paliad.projekt_events; -DROP POLICY IF EXISTS notizen_all ON paliad.notizen; +DROP POLICY IF EXISTS parties_all ON paliad.parties; +DROP POLICY IF EXISTS deadlines_all ON paliad.deadlines; +DROP POLICY IF EXISTS appointments_select ON paliad.appointments; +DROP POLICY IF EXISTS appointments_insert ON paliad.appointments; +DROP POLICY IF EXISTS appointments_update ON paliad.appointments; +DROP POLICY IF EXISTS appointments_delete ON paliad.appointments; +DROP POLICY IF EXISTS documents_all ON paliad.documents; +DROP POLICY IF EXISTS project_events_all ON paliad.project_events; +DROP POLICY IF EXISTS notes_all ON paliad.notes; DROP POLICY IF EXISTS checklist_instances_select ON paliad.checklist_instances; DROP POLICY IF EXISTS checklist_instances_insert ON paliad.checklist_instances; DROP POLICY IF EXISTS checklist_instances_update ON paliad.checklist_instances; DROP POLICY IF EXISTS checklist_instances_delete ON paliad.checklist_instances; -DROP POLICY IF EXISTS projekte_select ON paliad.projekte; -DROP POLICY IF EXISTS projekte_insert ON paliad.projekte; -DROP POLICY IF EXISTS projekte_update ON paliad.projekte; -DROP POLICY IF EXISTS projekte_delete ON paliad.projekte; -DROP POLICY IF EXISTS projekt_teams_select ON paliad.projekt_teams; -DROP POLICY IF EXISTS projekt_teams_insert ON paliad.projekt_teams; -DROP POLICY IF EXISTS projekt_teams_update ON paliad.projekt_teams; -DROP POLICY IF EXISTS projekt_teams_delete ON paliad.projekt_teams; -DROP POLICY IF EXISTS dezernate_select ON paliad.dezernate; -DROP POLICY IF EXISTS dezernate_write ON paliad.dezernate; -DROP POLICY IF EXISTS dezernat_mitglieder_select ON paliad.dezernat_mitglieder; -DROP POLICY IF EXISTS dezernat_mitglieder_write ON paliad.dezernat_mitglieder; +DROP POLICY IF EXISTS projects_select ON paliad.projects; +DROP POLICY IF EXISTS projects_insert ON paliad.projects; +DROP POLICY IF EXISTS projects_update ON paliad.projects; +DROP POLICY IF EXISTS projects_delete ON paliad.projects; +DROP POLICY IF EXISTS project_teams_select ON paliad.project_teams; +DROP POLICY IF EXISTS project_teams_insert ON paliad.project_teams; +DROP POLICY IF EXISTS project_teams_update ON paliad.project_teams; +DROP POLICY IF EXISTS project_teams_delete ON paliad.project_teams; +DROP POLICY IF EXISTS departments_select ON paliad.departments; +DROP POLICY IF EXISTS departments_write ON paliad.departments; +DROP POLICY IF EXISTS department_members_select ON paliad.department_members; +DROP POLICY IF EXISTS department_members_write ON paliad.department_members; -DROP FUNCTION IF EXISTS paliad.notiz_is_visible(uuid, uuid, uuid, uuid); -DROP FUNCTION IF EXISTS paliad.can_see_projekt(uuid); +DROP FUNCTION IF EXISTS paliad.note_is_visible(uuid, uuid, uuid, uuid); +DROP FUNCTION IF EXISTS paliad.can_see_project(uuid); --- 4. Restore akte_id on children (same UUIDs carry both directions). +-- 4. Rename child tables back to German + restore akte_id column. +ALTER TABLE paliad.parties DROP CONSTRAINT IF EXISTS parties_project_id_fkey; +ALTER TABLE paliad.parties RENAME COLUMN project_id TO akte_id; +ALTER TABLE paliad.parties RENAME TO parteien; ALTER TABLE paliad.parteien - ADD COLUMN akte_id uuid; -UPDATE paliad.parteien SET akte_id = projekt_id; -ALTER TABLE paliad.parteien - ALTER COLUMN akte_id SET NOT NULL, ADD CONSTRAINT parteien_akte_id_fkey FOREIGN KEY (akte_id) REFERENCES paliad.akten(id) ON DELETE CASCADE; -ALTER TABLE paliad.parteien DROP COLUMN projekt_id; +DROP INDEX IF EXISTS paliad.parties_project_idx; CREATE INDEX parteien_akte_idx ON paliad.parteien (akte_id); +ALTER TABLE paliad.deadlines DROP CONSTRAINT IF EXISTS deadlines_project_id_fkey; +ALTER TABLE paliad.deadlines RENAME COLUMN project_id TO akte_id; +ALTER TABLE paliad.deadlines RENAME TO fristen; ALTER TABLE paliad.fristen - ADD COLUMN akte_id uuid; -UPDATE paliad.fristen SET akte_id = projekt_id; -ALTER TABLE paliad.fristen - ALTER COLUMN akte_id SET NOT NULL, ADD CONSTRAINT fristen_akte_id_fkey FOREIGN KEY (akte_id) REFERENCES paliad.akten(id) ON DELETE CASCADE; -ALTER TABLE paliad.fristen DROP COLUMN projekt_id; -CREATE INDEX fristen_akte_idx ON paliad.fristen (akte_id); +DROP INDEX IF EXISTS paliad.deadlines_project_idx; +DROP INDEX IF EXISTS paliad.deadlines_status_due_date_idx; +DROP INDEX IF EXISTS paliad.deadlines_due_date_idx; +CREATE INDEX fristen_akte_idx ON paliad.fristen (akte_id); +CREATE INDEX fristen_status_due_date_idx ON paliad.fristen (status, due_date); +CREATE INDEX fristen_due_date_idx ON paliad.fristen (due_date); +ALTER TABLE paliad.appointments DROP CONSTRAINT IF EXISTS appointments_project_id_fkey; +ALTER TABLE paliad.appointments DROP CONSTRAINT IF EXISTS appointments_appointment_type_check; +ALTER TABLE paliad.appointments RENAME COLUMN project_id TO akte_id; +ALTER TABLE paliad.appointments RENAME COLUMN appointment_type TO termin_type; +ALTER TABLE paliad.appointments RENAME TO termine; ALTER TABLE paliad.termine - ADD COLUMN akte_id uuid REFERENCES paliad.akten(id) ON DELETE CASCADE; -UPDATE paliad.termine SET akte_id = projekt_id; -ALTER TABLE paliad.termine DROP COLUMN projekt_id; -CREATE INDEX termine_akte_idx ON paliad.termine (akte_id); + ADD CONSTRAINT termine_akte_id_fkey FOREIGN KEY (akte_id) + REFERENCES paliad.akten(id) ON DELETE CASCADE, + ADD CONSTRAINT termine_termin_type_check + CHECK (termin_type IS NULL OR termin_type IN ( + 'hearing', 'meeting', 'consultation', 'deadline_hearing' + )); +DROP INDEX IF EXISTS paliad.appointments_project_idx; +DROP INDEX IF EXISTS paliad.appointments_start_at_idx; +CREATE INDEX termine_akte_idx ON paliad.termine (akte_id); +CREATE INDEX termine_start_at_idx ON paliad.termine (start_at); +ALTER TABLE paliad.documents DROP CONSTRAINT IF EXISTS documents_project_id_fkey; +ALTER TABLE paliad.documents RENAME COLUMN project_id TO akte_id; +ALTER TABLE paliad.documents RENAME TO dokumente; ALTER TABLE paliad.dokumente - ADD COLUMN akte_id uuid; -UPDATE paliad.dokumente SET akte_id = projekt_id; -ALTER TABLE paliad.dokumente - ALTER COLUMN akte_id SET NOT NULL, ADD CONSTRAINT dokumente_akte_id_fkey FOREIGN KEY (akte_id) REFERENCES paliad.akten(id) ON DELETE CASCADE; -ALTER TABLE paliad.dokumente DROP COLUMN projekt_id; +DROP INDEX IF EXISTS paliad.documents_project_idx; CREATE INDEX dokumente_akte_idx ON paliad.dokumente (akte_id); --- 5. Rename projekt_events back to akten_events. -ALTER TABLE paliad.projekt_events RENAME TO akten_events; +ALTER TABLE paliad.project_events DROP CONSTRAINT IF EXISTS project_events_project_id_fkey; +ALTER TABLE paliad.project_events RENAME COLUMN project_id TO akte_id; +ALTER TABLE paliad.project_events RENAME TO akten_events; ALTER TABLE paliad.akten_events - ADD COLUMN akte_id uuid; -UPDATE paliad.akten_events SET akte_id = projekt_id; -ALTER TABLE paliad.akten_events - ALTER COLUMN akte_id SET NOT NULL, ADD CONSTRAINT akten_events_akte_id_fkey FOREIGN KEY (akte_id) REFERENCES paliad.akten(id) ON DELETE CASCADE; -ALTER TABLE paliad.akten_events DROP COLUMN projekt_id; -DROP INDEX IF EXISTS paliad.projekt_events_projekt_created_idx; +DROP INDEX IF EXISTS paliad.project_events_project_created_idx; CREATE INDEX akten_events_akte_created_idx ON paliad.akten_events (akte_id, created_at DESC); --- notizen — back to akte_id. -ALTER TABLE paliad.notizen - DROP CONSTRAINT IF EXISTS notizen_exactly_one_parent; -ALTER TABLE paliad.notizen - ADD COLUMN akte_id uuid REFERENCES paliad.akten(id) ON DELETE CASCADE; -UPDATE paliad.notizen SET akte_id = projekt_id WHERE projekt_id IS NOT NULL; -ALTER TABLE paliad.notizen DROP COLUMN projekt_id; -CREATE INDEX notizen_akte_idx ON paliad.notizen (akte_id) WHERE akte_id IS NOT NULL; +-- notes → notizen +ALTER TABLE paliad.notes DROP CONSTRAINT IF EXISTS notes_exactly_one_parent; +ALTER TABLE paliad.notes DROP CONSTRAINT IF EXISTS notes_project_id_fkey; +ALTER TABLE paliad.notes DROP CONSTRAINT IF EXISTS notes_deadline_id_fkey; +ALTER TABLE paliad.notes DROP CONSTRAINT IF EXISTS notes_appointment_id_fkey; +ALTER TABLE paliad.notes DROP CONSTRAINT IF EXISTS notes_project_event_id_fkey; +ALTER TABLE paliad.notes RENAME COLUMN project_id TO akte_id; +ALTER TABLE paliad.notes RENAME COLUMN deadline_id TO frist_id; +ALTER TABLE paliad.notes RENAME COLUMN appointment_id TO termin_id; +ALTER TABLE paliad.notes RENAME COLUMN project_event_id TO akten_event_id; +ALTER TABLE paliad.notes RENAME TO notizen; ALTER TABLE paliad.notizen + ADD CONSTRAINT notizen_akte_id_fkey FOREIGN KEY (akte_id) + REFERENCES paliad.akten(id) ON DELETE CASCADE, + ADD CONSTRAINT notizen_frist_id_fkey FOREIGN KEY (frist_id) + REFERENCES paliad.fristen(id) ON DELETE CASCADE, + ADD CONSTRAINT notizen_termin_id_fkey FOREIGN KEY (termin_id) + REFERENCES paliad.termine(id) ON DELETE CASCADE, + ADD CONSTRAINT notizen_akten_event_id_fkey FOREIGN KEY (akten_event_id) + REFERENCES paliad.akten_events(id) ON DELETE CASCADE, ADD CONSTRAINT notizen_exactly_one_parent CHECK ( (CASE WHEN akte_id IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN frist_id IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN termin_id IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN akten_event_id IS NOT NULL THEN 1 ELSE 0 END) = 1 ); +DROP INDEX IF EXISTS paliad.notes_project_idx; +DROP INDEX IF EXISTS paliad.notes_deadline_idx; +DROP INDEX IF EXISTS paliad.notes_appointment_idx; +DROP INDEX IF EXISTS paliad.notes_project_event_idx; +CREATE INDEX notizen_akte_idx ON paliad.notizen (akte_id) WHERE akte_id IS NOT NULL; +CREATE INDEX notizen_frist_idx ON paliad.notizen (frist_id) WHERE frist_id IS NOT NULL; +CREATE INDEX notizen_termin_idx ON paliad.notizen (termin_id) WHERE termin_id IS NOT NULL; +CREATE INDEX notizen_akten_event_idx ON paliad.notizen (akten_event_id) WHERE akten_event_id IS NOT NULL; --- checklist_instances +-- checklist_instances: restore akte_id column. +ALTER TABLE paliad.checklist_instances DROP CONSTRAINT IF EXISTS checklist_instances_project_id_fkey; +ALTER TABLE paliad.checklist_instances RENAME COLUMN project_id TO akte_id; ALTER TABLE paliad.checklist_instances - ADD COLUMN akte_id uuid REFERENCES paliad.akten(id) ON DELETE SET NULL; -UPDATE paliad.checklist_instances SET akte_id = projekt_id; -ALTER TABLE paliad.checklist_instances DROP COLUMN projekt_id; + ADD CONSTRAINT checklist_instances_akte_id_fkey FOREIGN KEY (akte_id) + REFERENCES paliad.akten(id) ON DELETE SET NULL; +DROP INDEX IF EXISTS paliad.checklist_instances_project_idx; CREATE INDEX checklist_instances_akte_idx ON paliad.checklist_instances (akte_id) WHERE akte_id IS NOT NULL; --- 6. Drop new tables + triggers. -DROP TRIGGER IF EXISTS projekte_rewrite_subtree_after ON paliad.projekte; -DROP TRIGGER IF EXISTS projekte_sync_path_before ON paliad.projekte; -DROP FUNCTION IF EXISTS paliad.projekte_rewrite_subtree(); -DROP FUNCTION IF EXISTS paliad.projekte_sync_path(); +-- 5. Drop new tables + triggers. +DROP TRIGGER IF EXISTS projects_rewrite_subtree_after ON paliad.projects; +DROP TRIGGER IF EXISTS projects_sync_path_before ON paliad.projects; +DROP FUNCTION IF EXISTS paliad.projects_rewrite_subtree(); +DROP FUNCTION IF EXISTS paliad.projects_sync_path(); -DROP TABLE IF EXISTS paliad.dezernat_mitglieder; -DROP TABLE IF EXISTS paliad.dezernate; -DROP TABLE IF EXISTS paliad.projekt_teams; -DROP TABLE IF EXISTS paliad.projekte; +DROP TABLE IF EXISTS paliad.department_members; +DROP TABLE IF EXISTS paliad.departments; +DROP TABLE IF EXISTS paliad.project_teams; +DROP TABLE IF EXISTS paliad.projects; --- 7. Restore the v1 visibility helpers + RLS policies. +-- 6. Restore the v1 visibility helpers + RLS policies. CREATE OR REPLACE FUNCTION paliad.can_see_akte(_akte_id uuid) RETURNS boolean LANGUAGE sql @@ -324,5 +356,5 @@ CREATE POLICY checklist_instances_delete ON paliad.checklist_instances USING ((akte_id IS NULL AND created_by = auth.uid()) OR (akte_id IS NOT NULL AND paliad.can_see_akte(akte_id))); --- 8. Drop the users.additional_offices column we added. +-- 7. Drop the users.additional_offices column we added. ALTER TABLE paliad.users DROP COLUMN IF EXISTS additional_offices; diff --git a/internal/db/migrations/018_projects_v2.up.sql b/internal/db/migrations/018_projects_v2.up.sql new file mode 100644 index 0000000..b56371a --- /dev/null +++ b/internal/db/migrations/018_projects_v2.up.sql @@ -0,0 +1,656 @@ +-- Data model v2 (t-paliad-024) + full English rename (t-paliad-025). +-- +-- Replaces paliad.akten with a single self-referential paliad.projects tree, +-- and renames every remaining German table/column in paliad.* to English. +-- Only user-facing i18n strings stay bilingual (owned by the frontend). +-- +-- Visibility is purely team-based (direct + inherited up the path) + admin. +-- Office becomes an informational attribute on users only — no project-level +-- office gate anymore. Cases are associated with lead partners, not offices. +-- +-- Migration is one-shot: creates the new schema, renames child tables in +-- place and rewrites their FKs (same UUIDs as akten.id), drops paliad.akten, +-- replaces can_see_akte() with can_see_project(). All existing akten rows +-- survive as projects rows of type='case' with parent_id=NULL (orphan cases — +-- admin/partners reparent them under real clients through the new UI). + +-- ============================================================================ +-- 1. users: primary office stays; add additional_offices for partners +-- who work across offices. +-- ============================================================================ +ALTER TABLE paliad.users + ADD COLUMN IF NOT EXISTS additional_offices text[] NOT NULL DEFAULT '{}'; + +-- ============================================================================ +-- 2. paliad.projects — the single hierarchical tree. +-- type in (client, litigation, patent, case, project). +-- Roots (parent_id NULL) are typically type='client' but not enforced — +-- a generic 'project' root is also valid (e.g., internal knowledge project). +-- ============================================================================ +CREATE TABLE paliad.projects ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + type text NOT NULL CHECK (type IN ( + 'client','litigation','patent','case','project' + )), + parent_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE, + -- Materialised path of UUID labels joined by '.'; always includes self + -- as the last label. Root: path = id::text. Child: path = parent.path + -- || '.' || id::text. Maintained by the trigger below; never write + -- directly from the service layer. + path text NOT NULL, + + title text NOT NULL, + reference text, + description text, + status text NOT NULL DEFAULT 'active' + CHECK (status IN ('active','archived','closed')), + + -- created_by is nullable (matches akten; users can be deleted from + -- auth.users). A NULL creator does NOT grant visibility — the row is + -- only visible via explicit team membership (or admin). + created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + + -- Client-specific (type='client'). Nullable for other types. + industry text, + country text, + billing_reference text, + + -- ClientMatter numbers — external HLC billing/DMS identifiers, not + -- generated by Paliad. Format: CCCCCCC.MMMMMMM (7+7 digits). + -- * client_number lives on the Client-level Project and is inherited + -- (by convention in the UI, not enforced) down the tree. + -- * matter_number is assigned independently at any sub-level + -- (litigation / patent / case). Children may override the inherited + -- client_number — rare but allowed. + -- Both are nullable; search/filter is across the tree. + client_number text CHECK (client_number IS NULL OR client_number ~ '^[0-9]{7}$'), + matter_number text CHECK (matter_number IS NULL OR matter_number ~ '^[0-9]{7}$'), + + -- netDocuments: HLC's DMS. We can't integrate via API, so we store a + -- bookmark URL per project. UI renders as an external-link button. + netdocuments_url text, + + -- Patent-specific (type='patent'). Nullable for other types. + patent_number text, + filing_date date, + grant_date date, + + -- Case-specific (type='case'). Nullable for other types. + court text, + case_number text, + proceeding_type_id integer REFERENCES paliad.proceeding_types(id) ON DELETE SET NULL, + + metadata jsonb NOT NULL DEFAULT '{}', + ai_summary text, + + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT projects_parent_self_differs CHECK (parent_id IS NULL OR parent_id <> id) +); + +-- text_pattern_ops index supports LIKE 'prefix.%' for fast descendant lookup. +CREATE INDEX projects_path_prefix_idx ON paliad.projects (path text_pattern_ops); +CREATE INDEX projects_parent_idx ON paliad.projects (parent_id); +CREATE INDEX projects_type_status_idx ON paliad.projects (type, status); +CREATE INDEX projects_reference_idx ON paliad.projects (reference) WHERE reference IS NOT NULL; +CREATE INDEX projects_client_number_idx ON paliad.projects (client_number) WHERE client_number IS NOT NULL; +CREATE INDEX projects_matter_number_idx ON paliad.projects (matter_number) WHERE matter_number IS NOT NULL; + +-- ============================================================================ +-- 3. Path maintenance triggers. +-- BEFORE INSERT / BEFORE UPDATE OF parent_id: recompute this row's path. +-- AFTER UPDATE OF path: propagate the new prefix to every descendant. +-- ============================================================================ +CREATE OR REPLACE FUNCTION paliad.projects_sync_path() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + parent_path text; +BEGIN + IF NEW.parent_id IS NULL THEN + NEW.path := NEW.id::text; + ELSE + SELECT path INTO parent_path + FROM paliad.projects + WHERE id = NEW.parent_id; + IF parent_path IS NULL THEN + RAISE EXCEPTION 'parent project % not found', NEW.parent_id; + END IF; + -- Reject cycles: parent's path cannot contain this row's id. + IF parent_path = NEW.id::text + OR parent_path LIKE '%.' || NEW.id::text + OR parent_path LIKE NEW.id::text || '.%' + OR parent_path LIKE '%.' || NEW.id::text || '.%' + THEN + RAISE EXCEPTION 'cannot set parent to own descendant'; + END IF; + NEW.path := parent_path || '.' || NEW.id::text; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER projects_sync_path_before + BEFORE INSERT OR UPDATE OF parent_id ON paliad.projects + FOR EACH ROW EXECUTE FUNCTION paliad.projects_sync_path(); + +CREATE OR REPLACE FUNCTION paliad.projects_rewrite_subtree() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF OLD.path IS DISTINCT FROM NEW.path THEN + UPDATE paliad.projects + SET path = NEW.path || substring(path FROM length(OLD.path) + 1) + WHERE path LIKE OLD.path || '.%'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER projects_rewrite_subtree_after + AFTER UPDATE OF path ON paliad.projects + FOR EACH ROW EXECUTE FUNCTION paliad.projects_rewrite_subtree(); + +-- ============================================================================ +-- 4. paliad.project_teams — membership. inherited=false rows are writes; +-- inherited=true is a flag the service layer may set on read to annotate +-- rows derived from an ancestor's team. Writes always use inherited=false. +-- ============================================================================ +CREATE TABLE paliad.project_teams ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + role text NOT NULL CHECK (role IN ( + 'lead','associate','pa','of_counsel', + 'local_counsel','expert','observer' + )), + inherited boolean NOT NULL DEFAULT false, + added_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + + UNIQUE (project_id, user_id) +); + +CREATE INDEX project_teams_project_idx ON paliad.project_teams (project_id); +CREATE INDEX project_teams_user_idx ON paliad.project_teams (user_id); + +-- ============================================================================ +-- 5. paliad.departments — structural partner units (distinct from project teams). +-- A user's Department membership is orthogonal to their project-team roles. +-- ============================================================================ +CREATE TABLE paliad.departments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + lead_user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL, + office text NOT NULL CHECK (office IN ( + 'munich','duesseldorf','hamburg', + 'amsterdam','london','paris','milan' + )), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX departments_office_idx ON paliad.departments (office); +CREATE INDEX departments_lead_idx ON paliad.departments (lead_user_id) WHERE lead_user_id IS NOT NULL; + +CREATE TABLE paliad.department_members ( + department_id uuid NOT NULL REFERENCES paliad.departments(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (department_id, user_id) +); + +CREATE INDEX department_members_user_idx ON paliad.department_members (user_id); + +-- ============================================================================ +-- 6. Data migration: akten → projects (same UUIDs), collaborators+created_by +-- → project_teams. +-- All existing akten become type='case' orphans (parent_id NULL). Admins +-- reparent under real clients via the new UI. +-- ============================================================================ +INSERT INTO paliad.projects ( + id, type, parent_id, path, + title, reference, status, + court, case_number, + created_by, metadata, ai_summary, + created_at, updated_at +) +SELECT + a.id, + 'case'::text, + NULL::uuid, + a.id::text, -- root path = id (no parent) + a.title, + NULLIF(a.aktenzeichen, ''), -- aktenzeichen → reference + a.status, + a.court, + a.court_ref, + a.created_by, + a.metadata, + a.ai_summary, + a.created_at, + a.updated_at + FROM paliad.akten a; + +-- Creator → team lead (skip NULL creators; they remain admin-only orphans). +INSERT INTO paliad.project_teams (project_id, user_id, role, inherited, added_by) +SELECT a.id, a.created_by, 'lead', false, a.created_by + FROM paliad.akten a + WHERE a.created_by IS NOT NULL +ON CONFLICT (project_id, user_id) DO NOTHING; + +-- Collaborators → team associates (dedup against the creator row). +INSERT INTO paliad.project_teams (project_id, user_id, role, inherited, added_by) +SELECT a.id, collab_id::uuid, 'associate', false, a.created_by + FROM paliad.akten a + CROSS JOIN LATERAL unnest(a.collaborators) AS collab_id + WHERE collab_id IS NOT NULL +ON CONFLICT (project_id, user_id) DO NOTHING; + +-- ============================================================================ +-- 7. Rename child tables to English + rewrite FK columns (akte_id → project_id). +-- Tables: parteien→parties, fristen→deadlines, termine→appointments, +-- dokumente→documents, akten_events→project_events, notizen→notes. +-- Column renames: termin_type→appointment_type, akten_event_id→ +-- project_event_id, frist_id→deadline_id, termin_id→appointment_id. +-- ============================================================================ + +-- Drop dependent RLS policies on children (rebuilt below against project_id). +DROP POLICY IF EXISTS parteien_all ON paliad.parteien; +DROP POLICY IF EXISTS fristen_all ON paliad.fristen; +DROP POLICY IF EXISTS termine_select ON paliad.termine; +DROP POLICY IF EXISTS termine_insert ON paliad.termine; +DROP POLICY IF EXISTS termine_update ON paliad.termine; +DROP POLICY IF EXISTS termine_delete ON paliad.termine; +DROP POLICY IF EXISTS dokumente_all ON paliad.dokumente; +DROP POLICY IF EXISTS akten_events_all ON paliad.akten_events; +DROP POLICY IF EXISTS notizen_all ON paliad.notizen; +DROP POLICY IF EXISTS checklist_instances_select ON paliad.checklist_instances; +DROP POLICY IF EXISTS checklist_instances_insert ON paliad.checklist_instances; +DROP POLICY IF EXISTS checklist_instances_update ON paliad.checklist_instances; +DROP POLICY IF EXISTS checklist_instances_delete ON paliad.checklist_instances; +-- akten itself — drop policies before we drop the table. +DROP POLICY IF EXISTS akten_select ON paliad.akten; +DROP POLICY IF EXISTS akten_insert ON paliad.akten; +DROP POLICY IF EXISTS akten_update ON paliad.akten; +DROP POLICY IF EXISTS akten_delete ON paliad.akten; + +-- parteien → parties +ALTER TABLE paliad.parteien DROP CONSTRAINT IF EXISTS parteien_akte_id_fkey; +ALTER TABLE paliad.parteien RENAME TO parties; +ALTER TABLE paliad.parties RENAME COLUMN akte_id TO project_id; +ALTER TABLE paliad.parties + ADD CONSTRAINT parties_project_id_fkey FOREIGN KEY (project_id) + REFERENCES paliad.projects(id) ON DELETE CASCADE; +DROP INDEX IF EXISTS paliad.parteien_akte_idx; +CREATE INDEX parties_project_idx ON paliad.parties (project_id); + +-- fristen → deadlines +ALTER TABLE paliad.fristen DROP CONSTRAINT IF EXISTS fristen_akte_id_fkey; +ALTER TABLE paliad.fristen RENAME TO deadlines; +ALTER TABLE paliad.deadlines RENAME COLUMN akte_id TO project_id; +ALTER TABLE paliad.deadlines + ADD CONSTRAINT deadlines_project_id_fkey FOREIGN KEY (project_id) + REFERENCES paliad.projects(id) ON DELETE CASCADE; +DROP INDEX IF EXISTS paliad.fristen_akte_idx; +DROP INDEX IF EXISTS paliad.fristen_status_due_date_idx; +DROP INDEX IF EXISTS paliad.fristen_due_date_idx; +CREATE INDEX deadlines_project_idx ON paliad.deadlines (project_id); +CREATE INDEX deadlines_status_due_date_idx ON paliad.deadlines (status, due_date); +CREATE INDEX deadlines_due_date_idx ON paliad.deadlines (due_date); + +-- termine → appointments (akte_id was nullable) +ALTER TABLE paliad.termine DROP CONSTRAINT IF EXISTS termine_akte_id_fkey; +ALTER TABLE paliad.termine RENAME TO appointments; +ALTER TABLE paliad.appointments RENAME COLUMN akte_id TO project_id; +ALTER TABLE paliad.appointments RENAME COLUMN termin_type TO appointment_type; +ALTER TABLE paliad.appointments DROP CONSTRAINT IF EXISTS termine_termin_type_check; +ALTER TABLE paliad.appointments + ADD CONSTRAINT appointments_appointment_type_check + CHECK (appointment_type IS NULL OR appointment_type IN ( + 'hearing', 'meeting', 'consultation', 'deadline_hearing' + )); +ALTER TABLE paliad.appointments + ADD CONSTRAINT appointments_project_id_fkey FOREIGN KEY (project_id) + REFERENCES paliad.projects(id) ON DELETE CASCADE; +DROP INDEX IF EXISTS paliad.termine_akte_idx; +DROP INDEX IF EXISTS paliad.termine_start_at_idx; +CREATE INDEX appointments_project_idx ON paliad.appointments (project_id) WHERE project_id IS NOT NULL; +CREATE INDEX appointments_start_at_idx ON paliad.appointments (start_at); + +-- dokumente → documents +ALTER TABLE paliad.dokumente DROP CONSTRAINT IF EXISTS dokumente_akte_id_fkey; +ALTER TABLE paliad.dokumente RENAME TO documents; +ALTER TABLE paliad.documents RENAME COLUMN akte_id TO project_id; +ALTER TABLE paliad.documents + ADD CONSTRAINT documents_project_id_fkey FOREIGN KEY (project_id) + REFERENCES paliad.projects(id) ON DELETE CASCADE; +DROP INDEX IF EXISTS paliad.dokumente_akte_idx; +CREATE INDEX documents_project_idx ON paliad.documents (project_id); + +-- akten_events → project_events +ALTER TABLE paliad.akten_events DROP CONSTRAINT IF EXISTS akten_events_akte_id_fkey; +ALTER TABLE paliad.akten_events RENAME TO project_events; +ALTER TABLE paliad.project_events RENAME COLUMN akte_id TO project_id; +ALTER TABLE paliad.project_events + ADD CONSTRAINT project_events_project_id_fkey FOREIGN KEY (project_id) + REFERENCES paliad.projects(id) ON DELETE CASCADE; +DROP INDEX IF EXISTS paliad.akten_events_akte_created_idx; +CREATE INDEX project_events_project_created_idx + ON paliad.project_events (project_id, created_at DESC); + +-- notizen → notes. Polymorphic parent stays; all FK columns get English names. +ALTER TABLE paliad.notizen DROP CONSTRAINT IF EXISTS notizen_exactly_one_parent; +ALTER TABLE paliad.notizen DROP CONSTRAINT IF EXISTS notizen_akte_id_fkey; +ALTER TABLE paliad.notizen DROP CONSTRAINT IF EXISTS notizen_frist_id_fkey; +ALTER TABLE paliad.notizen DROP CONSTRAINT IF EXISTS notizen_termin_id_fkey; +ALTER TABLE paliad.notizen DROP CONSTRAINT IF EXISTS notizen_akten_event_id_fkey; +ALTER TABLE paliad.notizen RENAME TO notes; +ALTER TABLE paliad.notes RENAME COLUMN akte_id TO project_id; +ALTER TABLE paliad.notes RENAME COLUMN frist_id TO deadline_id; +ALTER TABLE paliad.notes RENAME COLUMN termin_id TO appointment_id; +ALTER TABLE paliad.notes RENAME COLUMN akten_event_id TO project_event_id; +ALTER TABLE paliad.notes + ADD CONSTRAINT notes_project_id_fkey FOREIGN KEY (project_id) + REFERENCES paliad.projects(id) ON DELETE CASCADE, + ADD CONSTRAINT notes_deadline_id_fkey FOREIGN KEY (deadline_id) + REFERENCES paliad.deadlines(id) ON DELETE CASCADE, + ADD CONSTRAINT notes_appointment_id_fkey FOREIGN KEY (appointment_id) + REFERENCES paliad.appointments(id) ON DELETE CASCADE, + ADD CONSTRAINT notes_project_event_id_fkey FOREIGN KEY (project_event_id) + REFERENCES paliad.project_events(id) ON DELETE CASCADE; +DROP INDEX IF EXISTS paliad.notizen_akte_idx; +DROP INDEX IF EXISTS paliad.notizen_frist_idx; +DROP INDEX IF EXISTS paliad.notizen_termin_idx; +DROP INDEX IF EXISTS paliad.notizen_akten_event_idx; +CREATE INDEX notes_project_idx ON paliad.notes (project_id) WHERE project_id IS NOT NULL; +CREATE INDEX notes_deadline_idx ON paliad.notes (deadline_id) WHERE deadline_id IS NOT NULL; +CREATE INDEX notes_appointment_idx ON paliad.notes (appointment_id) WHERE appointment_id IS NOT NULL; +CREATE INDEX notes_project_event_idx ON paliad.notes (project_event_id) WHERE project_event_id IS NOT NULL; +ALTER TABLE paliad.notes + ADD CONSTRAINT notes_exactly_one_parent CHECK ( + (CASE WHEN project_id IS NOT NULL THEN 1 ELSE 0 END) + + (CASE WHEN deadline_id IS NOT NULL THEN 1 ELSE 0 END) + + (CASE WHEN appointment_id IS NOT NULL THEN 1 ELSE 0 END) + + (CASE WHEN project_event_id IS NOT NULL THEN 1 ELSE 0 END) = 1 + ); + +-- checklist_instances — akte_id → project_id (table name already English). +ALTER TABLE paliad.checklist_instances DROP CONSTRAINT IF EXISTS checklist_instances_akte_id_fkey; +ALTER TABLE paliad.checklist_instances RENAME COLUMN akte_id TO project_id; +ALTER TABLE paliad.checklist_instances + ADD CONSTRAINT checklist_instances_project_id_fkey FOREIGN KEY (project_id) + REFERENCES paliad.projects(id) ON DELETE SET NULL; +DROP INDEX IF EXISTS paliad.checklist_instances_akte_idx; +CREATE INDEX checklist_instances_project_idx + ON paliad.checklist_instances (project_id) WHERE project_id IS NOT NULL; + +-- ============================================================================ +-- 8. Drop paliad.akten (and its visibility helpers). +-- ============================================================================ +DROP TABLE paliad.akten; +DROP FUNCTION IF EXISTS paliad.can_see_akte(uuid); +DROP FUNCTION IF EXISTS paliad.notiz_is_visible(uuid, uuid, uuid, uuid); + +-- ============================================================================ +-- 9. Visibility: can_see_project(id) — team-based only. +-- A user sees a project iff: +-- - admin, or +-- - direct team member (project_teams.project_id = target), or +-- - inherited team member on any ancestor of target (walk UP path). +-- ============================================================================ +CREATE OR REPLACE FUNCTION paliad.can_see_project(_project_id uuid) +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = paliad, public +AS $$ + SELECT EXISTS ( + SELECT 1 FROM paliad.users u + WHERE u.id = auth.uid() AND u.role = 'admin' + ) + OR EXISTS ( + -- Any team membership (direct or ancestor). `path` always includes + -- the target's own id as the last label, so this handles direct too. + SELECT 1 + FROM paliad.projects target + JOIN paliad.project_teams pt + ON pt.user_id = auth.uid() + AND pt.project_id = ANY(string_to_array(target.path, '.')::uuid[]) + WHERE target.id = _project_id + ); +$$; + +COMMENT ON FUNCTION paliad.can_see_project(uuid) IS + 'Team-based visibility predicate for paliad.projects. Direct or inherited ' + '(ancestor) membership grants access. Admins see all.'; + +-- Helper: note visibility — dispatches by whichever parent FK is set. +CREATE OR REPLACE FUNCTION paliad.note_is_visible( + _project_id uuid, + _deadline_id uuid, + _appointment_id uuid, + _project_event_id uuid +) RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = paliad, public +AS $$ + SELECT CASE + WHEN _project_id IS NOT NULL THEN paliad.can_see_project(_project_id) + WHEN _deadline_id IS NOT NULL THEN paliad.can_see_project( + (SELECT project_id FROM paliad.deadlines WHERE id = _deadline_id)) + WHEN _appointment_id IS NOT NULL THEN + CASE + WHEN (SELECT project_id FROM paliad.appointments WHERE id = _appointment_id) IS NULL + THEN (SELECT created_by FROM paliad.appointments WHERE id = _appointment_id) = auth.uid() + ELSE paliad.can_see_project( + (SELECT project_id FROM paliad.appointments WHERE id = _appointment_id)) + END + WHEN _project_event_id IS NOT NULL THEN paliad.can_see_project( + (SELECT project_id FROM paliad.project_events WHERE id = _project_event_id)) + ELSE false + END; +$$; + +-- ============================================================================ +-- 10. RLS: enable + policies on the new tables. +-- ============================================================================ +ALTER TABLE paliad.projects ENABLE ROW LEVEL SECURITY; +ALTER TABLE paliad.project_teams ENABLE ROW LEVEL SECURITY; +ALTER TABLE paliad.departments ENABLE ROW LEVEL SECURITY; +ALTER TABLE paliad.department_members ENABLE ROW LEVEL SECURITY; + +-- projects +CREATE POLICY projects_select ON paliad.projects + FOR SELECT TO authenticated + USING (paliad.can_see_project(id)); + +-- INSERT: creating a root project is open to any authenticated user. +-- Creating a child requires visibility on the parent. The service must +-- add the creator to project_teams (role='lead') in the same transaction +-- so the creator can see the row afterwards. +CREATE POLICY projects_insert ON paliad.projects + FOR INSERT TO authenticated + WITH CHECK ( + parent_id IS NULL + OR paliad.can_see_project(parent_id) + ); + +CREATE POLICY projects_update ON paliad.projects + FOR UPDATE TO authenticated + USING (paliad.can_see_project(id)) + WITH CHECK (paliad.can_see_project(id)); + +-- Delete: team visibility + admin/lead role. Cascade walks down the tree. +CREATE POLICY projects_delete ON paliad.projects + FOR DELETE TO authenticated + USING ( + paliad.can_see_project(id) + AND EXISTS ( + SELECT 1 FROM paliad.users u + WHERE u.id = auth.uid() AND u.role IN ('partner','admin') + ) + ); + +-- project_teams: everyone on the team (including ancestor-inherited members) +-- can list and modify the team. Anyone with visibility on the project can +-- insert themselves as the initial member (used by the creator-add-self +-- flow). Partner/admin can remove. +CREATE POLICY project_teams_select ON paliad.project_teams + FOR SELECT TO authenticated + USING (paliad.can_see_project(project_id)); + +CREATE POLICY project_teams_insert ON paliad.project_teams + FOR INSERT TO authenticated + WITH CHECK ( + -- creator adding self, or already-a-member adding someone else + user_id = auth.uid() + OR paliad.can_see_project(project_id) + ); + +CREATE POLICY project_teams_update ON paliad.project_teams + FOR UPDATE TO authenticated + USING (paliad.can_see_project(project_id)) + WITH CHECK (paliad.can_see_project(project_id)); + +CREATE POLICY project_teams_delete ON paliad.project_teams + FOR DELETE TO authenticated + USING ( + paliad.can_see_project(project_id) + AND ( + user_id = auth.uid() -- remove self + OR EXISTS ( + SELECT 1 FROM paliad.users u + WHERE u.id = auth.uid() AND u.role IN ('partner','admin') + ) + ) + ); + +-- departments: any authenticated user can read. Only admins write. +CREATE POLICY departments_select ON paliad.departments + FOR SELECT TO authenticated + USING (true); + +CREATE POLICY departments_write ON paliad.departments + FOR ALL TO authenticated + USING ( + EXISTS (SELECT 1 FROM paliad.users u + WHERE u.id = auth.uid() AND u.role = 'admin') + ) + WITH CHECK ( + EXISTS (SELECT 1 FROM paliad.users u + WHERE u.id = auth.uid() AND u.role = 'admin') + ); + +CREATE POLICY department_members_select ON paliad.department_members + FOR SELECT TO authenticated + USING (true); + +CREATE POLICY department_members_write ON paliad.department_members + FOR ALL TO authenticated + USING ( + user_id = auth.uid() + OR EXISTS (SELECT 1 FROM paliad.users u + WHERE u.id = auth.uid() AND u.role = 'admin') + ) + WITH CHECK ( + user_id = auth.uid() + OR EXISTS (SELECT 1 FROM paliad.users u + WHERE u.id = auth.uid() AND u.role = 'admin') + ); + +-- ============================================================================ +-- 11. RLS: rebuild child-table policies against project_id / can_see_project. +-- ============================================================================ +CREATE POLICY parties_all ON paliad.parties + FOR ALL TO authenticated + USING (paliad.can_see_project(project_id)) + WITH CHECK (paliad.can_see_project(project_id)); + +CREATE POLICY deadlines_all ON paliad.deadlines + FOR ALL TO authenticated + USING (paliad.can_see_project(project_id)) + WITH CHECK (paliad.can_see_project(project_id)); + +-- appointments — project_id nullable; personal (NULL) = creator-only. +CREATE POLICY appointments_select ON paliad.appointments + FOR SELECT TO authenticated + USING ( + (project_id IS NULL AND created_by = auth.uid()) + OR (project_id IS NOT NULL AND paliad.can_see_project(project_id)) + ); +CREATE POLICY appointments_insert ON paliad.appointments + FOR INSERT TO authenticated + WITH CHECK ( + (project_id IS NULL AND created_by = auth.uid()) + OR (project_id IS NOT NULL AND paliad.can_see_project(project_id)) + ); +CREATE POLICY appointments_update ON paliad.appointments + FOR UPDATE TO authenticated + USING ( + (project_id IS NULL AND created_by = auth.uid()) + OR (project_id IS NOT NULL AND paliad.can_see_project(project_id)) + ) + WITH CHECK ( + (project_id IS NULL AND created_by = auth.uid()) + OR (project_id IS NOT NULL AND paliad.can_see_project(project_id)) + ); +CREATE POLICY appointments_delete ON paliad.appointments + FOR DELETE TO authenticated + USING ( + (project_id IS NULL AND created_by = auth.uid()) + OR (project_id IS NOT NULL AND paliad.can_see_project(project_id)) + ); + +CREATE POLICY documents_all ON paliad.documents + FOR ALL TO authenticated + USING (paliad.can_see_project(project_id)) + WITH CHECK (paliad.can_see_project(project_id)); + +ALTER TABLE paliad.project_events ENABLE ROW LEVEL SECURITY; +CREATE POLICY project_events_all ON paliad.project_events + FOR ALL TO authenticated + USING (paliad.can_see_project(project_id)) + WITH CHECK (paliad.can_see_project(project_id)); + +-- notes — polymorphic parent dispatch. +CREATE POLICY notes_all ON paliad.notes + FOR ALL TO authenticated + USING (paliad.note_is_visible(project_id, deadline_id, appointment_id, project_event_id)) + WITH CHECK (paliad.note_is_visible(project_id, deadline_id, appointment_id, project_event_id)); + +-- checklist_instances — mirrors appointments: personal (NULL) = creator-only. +CREATE POLICY checklist_instances_select ON paliad.checklist_instances + FOR SELECT TO authenticated + USING ( + (project_id IS NULL AND created_by = auth.uid()) + OR (project_id IS NOT NULL AND paliad.can_see_project(project_id)) + ); +CREATE POLICY checklist_instances_insert ON paliad.checklist_instances + FOR INSERT TO authenticated + WITH CHECK ( + created_by = auth.uid() + AND (project_id IS NULL OR paliad.can_see_project(project_id)) + ); +CREATE POLICY checklist_instances_update ON paliad.checklist_instances + FOR UPDATE TO authenticated + USING ( + (project_id IS NULL AND created_by = auth.uid()) + OR (project_id IS NOT NULL AND paliad.can_see_project(project_id)) + ) + WITH CHECK ( + (project_id IS NULL AND created_by = auth.uid()) + OR (project_id IS NOT NULL AND paliad.can_see_project(project_id)) + ); +CREATE POLICY checklist_instances_delete ON paliad.checklist_instances + FOR DELETE TO authenticated + USING ( + (project_id IS NULL AND created_by = auth.uid()) + OR (project_id IS NOT NULL AND paliad.can_see_project(project_id)) + ); diff --git a/internal/db/migrations/018_projekte_v2.up.sql b/internal/db/migrations/018_projekte_v2.up.sql deleted file mode 100644 index 4f9397f..0000000 --- a/internal/db/migrations/018_projekte_v2.up.sql +++ /dev/null @@ -1,617 +0,0 @@ --- Data model v2 (t-paliad-024): hierarchical projekte + teams with inheritance. --- --- Replaces paliad.akten with a single self-referential paliad.projekte tree. --- Visibility is purely team-based (direct + inherited up the path) + admin. --- Office becomes an informational attribute on users only — no project-level --- office gate anymore. Cases are associated with lead partners, not offices. --- --- Migration is one-shot: creates the new schema, rewrites child FKs in place --- (same UUIDs as akten.id), drops paliad.akten, replaces can_see_akte() with --- can_see_projekt(). All existing akten rows survive as projekte rows of --- type='case' with parent_id=NULL (orphan cases — admin/partners reparent --- them under real clients through the new UI). - --- ============================================================================ --- 1. users: primary office stays; add additional_offices for partners --- who work across offices. --- ============================================================================ -ALTER TABLE paliad.users - ADD COLUMN IF NOT EXISTS additional_offices text[] NOT NULL DEFAULT '{}'; - --- ============================================================================ --- 2. paliad.projekte — the single hierarchical tree. --- type in (client, litigation, patent, case, project). --- Roots (parent_id NULL) are typically type='client' but not enforced — --- a generic 'project' root is also valid (e.g., internal knowledge project). --- ============================================================================ -CREATE TABLE paliad.projekte ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - type text NOT NULL CHECK (type IN ( - 'client','litigation','patent','case','project' - )), - parent_id uuid REFERENCES paliad.projekte(id) ON DELETE CASCADE, - -- Materialised path of UUID labels joined by '.'; always includes self - -- as the last label. Root: path = id::text. Child: path = parent.path - -- || '.' || id::text. Maintained by the trigger below; never write - -- directly from the service layer. - path text NOT NULL, - - title text NOT NULL, - reference text, - description text, - status text NOT NULL DEFAULT 'active' - CHECK (status IN ('active','archived','closed')), - - -- created_by is nullable (matches akten; users can be deleted from - -- auth.users). A NULL creator does NOT grant visibility — the row is - -- only visible via explicit team membership (or admin). - created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, - - -- Client-specific (type='client'). Nullable for other types. - industry text, - country text, - billing_reference text, - - -- ClientMatter numbers — external HLC billing/DMS identifiers, not - -- generated by Paliad. Format: CCCCCCC.MMMMMMM (7+7 digits). - -- * client_number lives on the Client-level Projekt and is inherited - -- (by convention in the UI, not enforced) down the tree. - -- * matter_number is assigned independently at any sub-level - -- (litigation / patent / case). Children may override the inherited - -- client_number — rare but allowed. - -- Both are nullable; search/filter is across the tree. - client_number text CHECK (client_number IS NULL OR client_number ~ '^[0-9]{7}$'), - matter_number text CHECK (matter_number IS NULL OR matter_number ~ '^[0-9]{7}$'), - - -- netDocuments: HLC's DMS. We can't integrate via API, so we store a - -- bookmark URL per project. UI renders as an external-link button. - netdocuments_url text, - - -- Patent-specific (type='patent'). Nullable for other types. - patent_number text, - filing_date date, - grant_date date, - - -- Case-specific (type='case'). Nullable for other types. - court text, - case_number text, - proceeding_type_id integer REFERENCES paliad.proceeding_types(id) ON DELETE SET NULL, - - metadata jsonb NOT NULL DEFAULT '{}', - ai_summary text, - - created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now(), - - CONSTRAINT projekte_parent_self_differs CHECK (parent_id IS NULL OR parent_id <> id) -); - --- text_pattern_ops index supports LIKE 'prefix.%' for fast descendant lookup. -CREATE INDEX projekte_path_prefix_idx ON paliad.projekte (path text_pattern_ops); -CREATE INDEX projekte_parent_idx ON paliad.projekte (parent_id); -CREATE INDEX projekte_type_status_idx ON paliad.projekte (type, status); -CREATE INDEX projekte_reference_idx ON paliad.projekte (reference) WHERE reference IS NOT NULL; --- ClientMatter search/filter indexes. -CREATE INDEX projekte_client_number_idx ON paliad.projekte (client_number) WHERE client_number IS NOT NULL; -CREATE INDEX projekte_matter_number_idx ON paliad.projekte (matter_number) WHERE matter_number IS NOT NULL; - --- ============================================================================ --- 3. Path maintenance triggers. --- BEFORE INSERT / BEFORE UPDATE OF parent_id: recompute this row's path. --- AFTER UPDATE OF path: propagate the new prefix to every descendant. --- ============================================================================ -CREATE OR REPLACE FUNCTION paliad.projekte_sync_path() -RETURNS trigger -LANGUAGE plpgsql -AS $$ -DECLARE - parent_path text; -BEGIN - IF NEW.parent_id IS NULL THEN - NEW.path := NEW.id::text; - ELSE - SELECT path INTO parent_path - FROM paliad.projekte - WHERE id = NEW.parent_id; - IF parent_path IS NULL THEN - RAISE EXCEPTION 'parent projekt % not found', NEW.parent_id; - END IF; - -- Reject cycles: parent's path cannot contain this row's id. - IF parent_path = NEW.id::text - OR parent_path LIKE '%.' || NEW.id::text - OR parent_path LIKE NEW.id::text || '.%' - OR parent_path LIKE '%.' || NEW.id::text || '.%' - THEN - RAISE EXCEPTION 'cannot set parent to own descendant'; - END IF; - NEW.path := parent_path || '.' || NEW.id::text; - END IF; - RETURN NEW; -END; -$$; - -CREATE TRIGGER projekte_sync_path_before - BEFORE INSERT OR UPDATE OF parent_id ON paliad.projekte - FOR EACH ROW EXECUTE FUNCTION paliad.projekte_sync_path(); - -CREATE OR REPLACE FUNCTION paliad.projekte_rewrite_subtree() -RETURNS trigger -LANGUAGE plpgsql -AS $$ -BEGIN - IF OLD.path IS DISTINCT FROM NEW.path THEN - UPDATE paliad.projekte - SET path = NEW.path || substring(path FROM length(OLD.path) + 1) - WHERE path LIKE OLD.path || '.%'; - END IF; - RETURN NEW; -END; -$$; - -CREATE TRIGGER projekte_rewrite_subtree_after - AFTER UPDATE OF path ON paliad.projekte - FOR EACH ROW EXECUTE FUNCTION paliad.projekte_rewrite_subtree(); - --- ============================================================================ --- 4. paliad.projekt_teams — membership. inherited=false rows are writes; --- inherited=true is a flag the service layer may set on read to annotate --- rows derived from an ancestor's team. Writes always use inherited=false. --- ============================================================================ -CREATE TABLE paliad.projekt_teams ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - projekt_id uuid NOT NULL REFERENCES paliad.projekte(id) ON DELETE CASCADE, - user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - role text NOT NULL CHECK (role IN ( - 'lead','associate','pa','of_counsel', - 'local_counsel','expert','observer' - )), - inherited boolean NOT NULL DEFAULT false, - added_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, - created_at timestamptz NOT NULL DEFAULT now(), - - UNIQUE (projekt_id, user_id) -); - -CREATE INDEX projekt_teams_projekt_idx ON paliad.projekt_teams (projekt_id); -CREATE INDEX projekt_teams_user_idx ON paliad.projekt_teams (user_id); - --- ============================================================================ --- 5. paliad.dezernate — structural partner units (distinct from project teams). --- A user's Dezernat membership is orthogonal to their project-team roles. --- ============================================================================ -CREATE TABLE paliad.dezernate ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - name text NOT NULL, - lead_user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL, - office text NOT NULL CHECK (office IN ( - 'munich','duesseldorf','hamburg', - 'amsterdam','london','paris','milan' - )), - created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now() -); - -CREATE INDEX dezernate_office_idx ON paliad.dezernate (office); -CREATE INDEX dezernate_lead_idx ON paliad.dezernate (lead_user_id) WHERE lead_user_id IS NOT NULL; - -CREATE TABLE paliad.dezernat_mitglieder ( - dezernat_id uuid NOT NULL REFERENCES paliad.dezernate(id) ON DELETE CASCADE, - user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - created_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (dezernat_id, user_id) -); - -CREATE INDEX dezernat_mitglieder_user_idx ON paliad.dezernat_mitglieder (user_id); - --- ============================================================================ --- 6. Data migration: akten → projekte (same UUIDs), collaborators+created_by --- → projekt_teams. --- All existing akten become type='case' orphans (parent_id NULL). Admins --- reparent under real clients via the new UI. --- ============================================================================ -INSERT INTO paliad.projekte ( - id, type, parent_id, path, - title, reference, status, - court, case_number, - created_by, metadata, ai_summary, - created_at, updated_at -) -SELECT - a.id, - 'case'::text, - NULL::uuid, - a.id::text, -- root path = id (no parent) - a.title, - NULLIF(a.aktenzeichen, ''), -- aktenzeichen → reference - a.status, - a.court, - a.court_ref, - a.created_by, - a.metadata, - a.ai_summary, - a.created_at, - a.updated_at - FROM paliad.akten a; - --- Creator → team lead (skip NULL creators; they remain admin-only orphans). -INSERT INTO paliad.projekt_teams (projekt_id, user_id, role, inherited, added_by) -SELECT a.id, a.created_by, 'lead', false, a.created_by - FROM paliad.akten a - WHERE a.created_by IS NOT NULL -ON CONFLICT (projekt_id, user_id) DO NOTHING; - --- Collaborators → team associates (dedup against the creator row). -INSERT INTO paliad.projekt_teams (projekt_id, user_id, role, inherited, added_by) -SELECT a.id, collab_id::uuid, 'associate', false, a.created_by - FROM paliad.akten a - CROSS JOIN LATERAL unnest(a.collaborators) AS collab_id - WHERE collab_id IS NOT NULL -ON CONFLICT (projekt_id, user_id) DO NOTHING; - --- ============================================================================ --- 7. Child-table FK rename: akte_id → projekt_id. Same UUIDs, so no data --- move; just DDL churn. Drop old policies that reference akte_id first --- so ALTER can proceed. --- ============================================================================ - --- Drop dependent RLS policies on children (rebuilt below against projekt_id). -DROP POLICY IF EXISTS parteien_all ON paliad.parteien; -DROP POLICY IF EXISTS fristen_all ON paliad.fristen; -DROP POLICY IF EXISTS termine_select ON paliad.termine; -DROP POLICY IF EXISTS termine_insert ON paliad.termine; -DROP POLICY IF EXISTS termine_update ON paliad.termine; -DROP POLICY IF EXISTS termine_delete ON paliad.termine; -DROP POLICY IF EXISTS dokumente_all ON paliad.dokumente; -DROP POLICY IF EXISTS akten_events_all ON paliad.akten_events; -DROP POLICY IF EXISTS notizen_all ON paliad.notizen; -DROP POLICY IF EXISTS checklist_instances_select ON paliad.checklist_instances; -DROP POLICY IF EXISTS checklist_instances_insert ON paliad.checklist_instances; -DROP POLICY IF EXISTS checklist_instances_update ON paliad.checklist_instances; -DROP POLICY IF EXISTS checklist_instances_delete ON paliad.checklist_instances; --- akten itself — drop policies before we drop the table. -DROP POLICY IF EXISTS akten_select ON paliad.akten; -DROP POLICY IF EXISTS akten_insert ON paliad.akten; -DROP POLICY IF EXISTS akten_update ON paliad.akten; -DROP POLICY IF EXISTS akten_delete ON paliad.akten; - --- parteien -ALTER TABLE paliad.parteien - ADD COLUMN projekt_id uuid REFERENCES paliad.projekte(id) ON DELETE CASCADE; -UPDATE paliad.parteien SET projekt_id = akte_id; -ALTER TABLE paliad.parteien ALTER COLUMN projekt_id SET NOT NULL; -ALTER TABLE paliad.parteien DROP COLUMN akte_id; -DROP INDEX IF EXISTS paliad.parteien_akte_idx; -CREATE INDEX parteien_projekt_idx ON paliad.parteien (projekt_id); - --- fristen -ALTER TABLE paliad.fristen - ADD COLUMN projekt_id uuid REFERENCES paliad.projekte(id) ON DELETE CASCADE; -UPDATE paliad.fristen SET projekt_id = akte_id; -ALTER TABLE paliad.fristen ALTER COLUMN projekt_id SET NOT NULL; -ALTER TABLE paliad.fristen DROP COLUMN akte_id; -DROP INDEX IF EXISTS paliad.fristen_akte_idx; -CREATE INDEX fristen_projekt_idx ON paliad.fristen (projekt_id); - --- termine (akte_id was nullable) -ALTER TABLE paliad.termine - ADD COLUMN projekt_id uuid REFERENCES paliad.projekte(id) ON DELETE CASCADE; -UPDATE paliad.termine SET projekt_id = akte_id WHERE akte_id IS NOT NULL; -ALTER TABLE paliad.termine DROP COLUMN akte_id; -DROP INDEX IF EXISTS paliad.termine_akte_idx; -CREATE INDEX termine_projekt_idx ON paliad.termine (projekt_id) WHERE projekt_id IS NOT NULL; - --- dokumente -ALTER TABLE paliad.dokumente - ADD COLUMN projekt_id uuid REFERENCES paliad.projekte(id) ON DELETE CASCADE; -UPDATE paliad.dokumente SET projekt_id = akte_id; -ALTER TABLE paliad.dokumente ALTER COLUMN projekt_id SET NOT NULL; -ALTER TABLE paliad.dokumente DROP COLUMN akte_id; -DROP INDEX IF EXISTS paliad.dokumente_akte_idx; -CREATE INDEX dokumente_projekt_idx ON paliad.dokumente (projekt_id); - --- akten_events — rename column, keep table name (per design §10, the table --- name stays 'akten_events' as a historical artefact; Go struct continues --- to be AkteEvent / ProjektEvent). Hmm — task asks to rename table too. --- Comply: rename to projekt_events for consistency. -ALTER TABLE paliad.akten_events - ADD COLUMN projekt_id uuid REFERENCES paliad.projekte(id) ON DELETE CASCADE; -UPDATE paliad.akten_events SET projekt_id = akte_id; -ALTER TABLE paliad.akten_events ALTER COLUMN projekt_id SET NOT NULL; -ALTER TABLE paliad.akten_events DROP COLUMN akte_id; -DROP INDEX IF EXISTS paliad.akten_events_akte_created_idx; -ALTER TABLE paliad.akten_events RENAME TO projekt_events; -CREATE INDEX projekt_events_projekt_created_idx ON paliad.projekt_events (projekt_id, created_at DESC); - --- notizen — polymorphic stays, akte_id → projekt_id (keep other FKs). --- Also: akten_event_id FK's target table has been renamed. -ALTER TABLE paliad.notizen - DROP CONSTRAINT IF EXISTS notizen_exactly_one_parent; -ALTER TABLE paliad.notizen - ADD COLUMN projekt_id uuid REFERENCES paliad.projekte(id) ON DELETE CASCADE; -UPDATE paliad.notizen SET projekt_id = akte_id WHERE akte_id IS NOT NULL; -ALTER TABLE paliad.notizen DROP COLUMN akte_id; -DROP INDEX IF EXISTS paliad.notizen_akte_idx; -CREATE INDEX notizen_projekt_idx ON paliad.notizen (projekt_id) WHERE projekt_id IS NOT NULL; -ALTER TABLE paliad.notizen - ADD CONSTRAINT notizen_exactly_one_parent CHECK ( - (CASE WHEN projekt_id IS NOT NULL THEN 1 ELSE 0 END) + - (CASE WHEN frist_id IS NOT NULL THEN 1 ELSE 0 END) + - (CASE WHEN termin_id IS NOT NULL THEN 1 ELSE 0 END) + - (CASE WHEN akten_event_id IS NOT NULL THEN 1 ELSE 0 END) = 1 - ); - --- checklist_instances -ALTER TABLE paliad.checklist_instances - ADD COLUMN projekt_id uuid REFERENCES paliad.projekte(id) ON DELETE SET NULL; -UPDATE paliad.checklist_instances SET projekt_id = akte_id WHERE akte_id IS NOT NULL; -ALTER TABLE paliad.checklist_instances DROP COLUMN akte_id; -DROP INDEX IF EXISTS paliad.checklist_instances_akte_idx; -CREATE INDEX checklist_instances_projekt_idx - ON paliad.checklist_instances (projekt_id) WHERE projekt_id IS NOT NULL; - --- ============================================================================ --- 8. Drop paliad.akten (and its visibility helpers). --- ============================================================================ -DROP TABLE paliad.akten; -DROP FUNCTION IF EXISTS paliad.can_see_akte(uuid); -DROP FUNCTION IF EXISTS paliad.notiz_is_visible(uuid, uuid, uuid, uuid); - --- ============================================================================ --- 9. Visibility: can_see_projekt(id) — team-based only. --- A user sees a projekt iff: --- - admin, or --- - direct team member (projekt_teams.projekt_id = target), or --- - inherited team member on any ancestor of target (walk UP path). --- ============================================================================ -CREATE OR REPLACE FUNCTION paliad.can_see_projekt(_projekt_id uuid) -RETURNS boolean -LANGUAGE sql -STABLE -SECURITY DEFINER -SET search_path = paliad, public -AS $$ - SELECT EXISTS ( - SELECT 1 FROM paliad.users u - WHERE u.id = auth.uid() AND u.role = 'admin' - ) - OR EXISTS ( - -- Any team membership (direct or ancestor). `path` always includes - -- the target's own id as the last label, so this handles direct too. - SELECT 1 - FROM paliad.projekte target - JOIN paliad.projekt_teams pt - ON pt.user_id = auth.uid() - AND pt.projekt_id = ANY(string_to_array(target.path, '.')::uuid[]) - WHERE target.id = _projekt_id - ); -$$; - -COMMENT ON FUNCTION paliad.can_see_projekt(uuid) IS - 'Team-based visibility predicate for paliad.projekte. Direct or inherited ' - '(ancestor) membership grants access. Admins see all.'; - --- Helper: notiz visibility — dispatches by whichever parent FK is set. -CREATE OR REPLACE FUNCTION paliad.notiz_is_visible( - _projekt_id uuid, - _frist_id uuid, - _termin_id uuid, - _projekt_event_id uuid -) RETURNS boolean -LANGUAGE sql -STABLE -SECURITY DEFINER -SET search_path = paliad, public -AS $$ - SELECT CASE - WHEN _projekt_id IS NOT NULL THEN paliad.can_see_projekt(_projekt_id) - WHEN _frist_id IS NOT NULL THEN paliad.can_see_projekt( - (SELECT projekt_id FROM paliad.fristen WHERE id = _frist_id)) - WHEN _termin_id IS NOT NULL THEN - CASE - WHEN (SELECT projekt_id FROM paliad.termine WHERE id = _termin_id) IS NULL - THEN (SELECT created_by FROM paliad.termine WHERE id = _termin_id) = auth.uid() - ELSE paliad.can_see_projekt( - (SELECT projekt_id FROM paliad.termine WHERE id = _termin_id)) - END - WHEN _projekt_event_id IS NOT NULL THEN paliad.can_see_projekt( - (SELECT projekt_id FROM paliad.projekt_events WHERE id = _projekt_event_id)) - ELSE false - END; -$$; - --- ============================================================================ --- 10. RLS: enable + policies on the new tables. --- ============================================================================ -ALTER TABLE paliad.projekte ENABLE ROW LEVEL SECURITY; -ALTER TABLE paliad.projekt_teams ENABLE ROW LEVEL SECURITY; -ALTER TABLE paliad.dezernate ENABLE ROW LEVEL SECURITY; -ALTER TABLE paliad.dezernat_mitglieder ENABLE ROW LEVEL SECURITY; - --- projekte -CREATE POLICY projekte_select ON paliad.projekte - FOR SELECT TO authenticated - USING (paliad.can_see_projekt(id)); - --- INSERT: creating a root project is open to any authenticated user. --- Creating a child requires visibility on the parent. The service must --- add the creator to projekt_teams (role='lead') in the same transaction --- so the creator can see the row afterwards. -CREATE POLICY projekte_insert ON paliad.projekte - FOR INSERT TO authenticated - WITH CHECK ( - parent_id IS NULL - OR paliad.can_see_projekt(parent_id) - ); - -CREATE POLICY projekte_update ON paliad.projekte - FOR UPDATE TO authenticated - USING (paliad.can_see_projekt(id)) - WITH CHECK (paliad.can_see_projekt(id)); - --- Delete: team visibility + admin/lead role. Cascade walks down the tree. -CREATE POLICY projekte_delete ON paliad.projekte - FOR DELETE TO authenticated - USING ( - paliad.can_see_projekt(id) - AND EXISTS ( - SELECT 1 FROM paliad.users u - WHERE u.id = auth.uid() AND u.role IN ('partner','admin') - ) - ); - --- projekt_teams: everyone on the team (including ancestor-inherited members) --- can list and modify the team. Anyone with visibility on the projekt can --- insert themselves as the initial member (used by the creator-add-self --- flow). Partner/admin can remove. -CREATE POLICY projekt_teams_select ON paliad.projekt_teams - FOR SELECT TO authenticated - USING (paliad.can_see_projekt(projekt_id)); - -CREATE POLICY projekt_teams_insert ON paliad.projekt_teams - FOR INSERT TO authenticated - WITH CHECK ( - -- creator adding self, or already-a-member adding someone else - user_id = auth.uid() - OR paliad.can_see_projekt(projekt_id) - ); - -CREATE POLICY projekt_teams_update ON paliad.projekt_teams - FOR UPDATE TO authenticated - USING (paliad.can_see_projekt(projekt_id)) - WITH CHECK (paliad.can_see_projekt(projekt_id)); - -CREATE POLICY projekt_teams_delete ON paliad.projekt_teams - FOR DELETE TO authenticated - USING ( - paliad.can_see_projekt(projekt_id) - AND ( - user_id = auth.uid() -- remove self - OR EXISTS ( - SELECT 1 FROM paliad.users u - WHERE u.id = auth.uid() AND u.role IN ('partner','admin') - ) - ) - ); - --- dezernate: any authenticated user can read. Only admins write. -CREATE POLICY dezernate_select ON paliad.dezernate - FOR SELECT TO authenticated - USING (true); - -CREATE POLICY dezernate_write ON paliad.dezernate - FOR ALL TO authenticated - USING ( - EXISTS (SELECT 1 FROM paliad.users u - WHERE u.id = auth.uid() AND u.role = 'admin') - ) - WITH CHECK ( - EXISTS (SELECT 1 FROM paliad.users u - WHERE u.id = auth.uid() AND u.role = 'admin') - ); - -CREATE POLICY dezernat_mitglieder_select ON paliad.dezernat_mitglieder - FOR SELECT TO authenticated - USING (true); - -CREATE POLICY dezernat_mitglieder_write ON paliad.dezernat_mitglieder - FOR ALL TO authenticated - USING ( - user_id = auth.uid() - OR EXISTS (SELECT 1 FROM paliad.users u - WHERE u.id = auth.uid() AND u.role = 'admin') - ) - WITH CHECK ( - user_id = auth.uid() - OR EXISTS (SELECT 1 FROM paliad.users u - WHERE u.id = auth.uid() AND u.role = 'admin') - ); - --- ============================================================================ --- 11. RLS: rebuild child-table policies against projekt_id / can_see_projekt. --- ============================================================================ -CREATE POLICY parteien_all ON paliad.parteien - FOR ALL TO authenticated - USING (paliad.can_see_projekt(projekt_id)) - WITH CHECK (paliad.can_see_projekt(projekt_id)); - -CREATE POLICY fristen_all ON paliad.fristen - FOR ALL TO authenticated - USING (paliad.can_see_projekt(projekt_id)) - WITH CHECK (paliad.can_see_projekt(projekt_id)); - --- termine — projekt_id nullable; personal (NULL) = creator-only. -CREATE POLICY termine_select ON paliad.termine - FOR SELECT TO authenticated - USING ( - (projekt_id IS NULL AND created_by = auth.uid()) - OR (projekt_id IS NOT NULL AND paliad.can_see_projekt(projekt_id)) - ); -CREATE POLICY termine_insert ON paliad.termine - FOR INSERT TO authenticated - WITH CHECK ( - (projekt_id IS NULL AND created_by = auth.uid()) - OR (projekt_id IS NOT NULL AND paliad.can_see_projekt(projekt_id)) - ); -CREATE POLICY termine_update ON paliad.termine - FOR UPDATE TO authenticated - USING ( - (projekt_id IS NULL AND created_by = auth.uid()) - OR (projekt_id IS NOT NULL AND paliad.can_see_projekt(projekt_id)) - ) - WITH CHECK ( - (projekt_id IS NULL AND created_by = auth.uid()) - OR (projekt_id IS NOT NULL AND paliad.can_see_projekt(projekt_id)) - ); -CREATE POLICY termine_delete ON paliad.termine - FOR DELETE TO authenticated - USING ( - (projekt_id IS NULL AND created_by = auth.uid()) - OR (projekt_id IS NOT NULL AND paliad.can_see_projekt(projekt_id)) - ); - -CREATE POLICY dokumente_all ON paliad.dokumente - FOR ALL TO authenticated - USING (paliad.can_see_projekt(projekt_id)) - WITH CHECK (paliad.can_see_projekt(projekt_id)); - -ALTER TABLE paliad.projekt_events ENABLE ROW LEVEL SECURITY; -CREATE POLICY projekt_events_all ON paliad.projekt_events - FOR ALL TO authenticated - USING (paliad.can_see_projekt(projekt_id)) - WITH CHECK (paliad.can_see_projekt(projekt_id)); - --- notizen — polymorphic parent dispatch. -CREATE POLICY notizen_all ON paliad.notizen - FOR ALL TO authenticated - USING (paliad.notiz_is_visible(projekt_id, frist_id, termin_id, akten_event_id)) - WITH CHECK (paliad.notiz_is_visible(projekt_id, frist_id, termin_id, akten_event_id)); - --- checklist_instances — mirrors termine: personal (NULL) = creator-only. -CREATE POLICY checklist_instances_select ON paliad.checklist_instances - FOR SELECT TO authenticated - USING ( - (projekt_id IS NULL AND created_by = auth.uid()) - OR (projekt_id IS NOT NULL AND paliad.can_see_projekt(projekt_id)) - ); -CREATE POLICY checklist_instances_insert ON paliad.checklist_instances - FOR INSERT TO authenticated - WITH CHECK ( - created_by = auth.uid() - AND (projekt_id IS NULL OR paliad.can_see_projekt(projekt_id)) - ); -CREATE POLICY checklist_instances_update ON paliad.checklist_instances - FOR UPDATE TO authenticated - USING ( - (projekt_id IS NULL AND created_by = auth.uid()) - OR (projekt_id IS NOT NULL AND paliad.can_see_projekt(projekt_id)) - ) - WITH CHECK ( - (projekt_id IS NULL AND created_by = auth.uid()) - OR (projekt_id IS NOT NULL AND paliad.can_see_projekt(projekt_id)) - ); -CREATE POLICY checklist_instances_delete ON paliad.checklist_instances - FOR DELETE TO authenticated - USING ( - (projekt_id IS NULL AND created_by = auth.uid()) - OR (projekt_id IS NOT NULL AND paliad.can_see_projekt(projekt_id)) - ); diff --git a/internal/db/migrations/019_seed_departments_from_user_text.down.sql b/internal/db/migrations/019_seed_departments_from_user_text.down.sql new file mode 100644 index 0000000..e58d79d --- /dev/null +++ b/internal/db/migrations/019_seed_departments_from_user_text.down.sql @@ -0,0 +1,20 @@ +-- Rollback the best-effort Department seeding. +-- Non-destructive on the structural tables themselves (those came in 018). +-- We only undo the rows this migration inserted: every (department, user) +-- row whose Department name matches the user's free-text field, and then +-- Department rows with no other memberships. Users' free-text dezernat +-- column is untouched. + +DELETE FROM paliad.department_members dm + USING paliad.users u, paliad.departments d + WHERE dm.user_id = u.id + AND dm.department_id = d.id + AND u.dezernat IS NOT NULL + AND d.name = btrim(u.dezernat); + +DELETE FROM paliad.departments d + WHERE NOT EXISTS ( + SELECT 1 FROM paliad.department_members m + WHERE m.department_id = d.id + ) + AND d.lead_user_id IS NULL; diff --git a/internal/db/migrations/019_seed_dezernate_from_user_text.up.sql b/internal/db/migrations/019_seed_departments_from_user_text.up.sql similarity index 67% rename from internal/db/migrations/019_seed_dezernate_from_user_text.up.sql rename to internal/db/migrations/019_seed_departments_from_user_text.up.sql index 7b59465..e13bd7d 100644 --- a/internal/db/migrations/019_seed_dezernate_from_user_text.up.sql +++ b/internal/db/migrations/019_seed_departments_from_user_text.up.sql @@ -1,14 +1,14 @@ -- Best-effort migration of the paliad.users.dezernat free-text field into --- proper paliad.dezernate + paliad.dezernat_mitglieder rows. +-- proper paliad.departments + paliad.department_members rows. -- --- For every distinct non-empty users.dezernat value, create a Dezernat in +-- For every distinct non-empty users.dezernat value, create a Department in -- the creator's primary office (first user we see with that name wins). --- Every user whose free-text matches the Dezernat name becomes a member. +-- Every user whose free-text matches the Department name becomes a member. -- The free-text column stays as-is (forward compatibility for users who -- haven't been linked); a later cleanup can drop it once all rows have --- a proper dezernat_mitglieder row. +-- a proper department_members row. -INSERT INTO paliad.dezernate (id, name, lead_user_id, office, created_at, updated_at) +INSERT INTO paliad.departments (id, name, lead_user_id, office, created_at, updated_at) SELECT gen_random_uuid(), btrim(u.dezernat), NULL, @@ -21,10 +21,10 @@ SELECT gen_random_uuid(), GROUP BY btrim(u.dezernat) ON CONFLICT DO NOTHING; -INSERT INTO paliad.dezernat_mitglieder (dezernat_id, user_id, created_at) +INSERT INTO paliad.department_members (department_id, user_id, created_at) SELECT d.id, u.id, now() FROM paliad.users u - JOIN paliad.dezernate d + JOIN paliad.departments d ON d.name = btrim(u.dezernat) WHERE u.dezernat IS NOT NULL AND btrim(u.dezernat) <> '' diff --git a/internal/db/migrations/019_seed_dezernate_from_user_text.down.sql b/internal/db/migrations/019_seed_dezernate_from_user_text.down.sql deleted file mode 100644 index 9a1abd6..0000000 --- a/internal/db/migrations/019_seed_dezernate_from_user_text.down.sql +++ /dev/null @@ -1,20 +0,0 @@ --- Rollback the best-effort Dezernat seeding. --- Non-destructive on the structural tables themselves (those came in 018). --- We only undo the rows this migration inserted: every (dezernat, user) --- row whose Dezernat name matches the user's free-text field, and then --- Dezernat rows with no other memberships. Users' free-text dezernat --- column is untouched. - -DELETE FROM paliad.dezernat_mitglieder dm - USING paliad.users u, paliad.dezernate d - WHERE dm.user_id = u.id - AND dm.dezernat_id = d.id - AND u.dezernat IS NOT NULL - AND d.name = btrim(u.dezernat); - -DELETE FROM paliad.dezernate d - WHERE NOT EXISTS ( - SELECT 1 FROM paliad.dezernat_mitglieder m - WHERE m.dezernat_id = d.id - ) - AND d.lead_user_id IS NULL; diff --git a/internal/db/pool.go b/internal/db/pool.go index f81caf5..f003320 100644 --- a/internal/db/pool.go +++ b/internal/db/pool.go @@ -18,7 +18,7 @@ var ( // OpenPool returns a process-wide *sqlx.DB. The first call connects; subsequent // calls return the same instance. If the URL is empty, returns (nil, nil) — the // caller is expected to handle the no-DB case (existing knowledge-platform -// endpoints work without a database; Akten/Frist endpoints return 503). +// endpoints work without a database; Akten/Deadline endpoints return 503). // // Pool sizing assumes a single Paliad replica behind Dokploy. Tune later if // we scale horizontally. diff --git a/internal/handlers/termine.go b/internal/handlers/appointments.go similarity index 90% rename from internal/handlers/termine.go rename to internal/handlers/appointments.go index b27e707..5ceaea8 100644 --- a/internal/handlers/termine.go +++ b/internal/handlers/appointments.go @@ -27,7 +27,7 @@ func requireCalDAV(w http.ResponseWriter) bool { return true } -// GET /api/termine?akte_id=&from=&to=&type= +// GET /api/appointments?project_id=&from=&to=&type= func handleListTermine(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -38,17 +38,17 @@ func handleListTermine(w http.ResponseWriter, r *http.Request) { } q := r.URL.Query() filter := services.TerminListFilter{} - raw := q.Get("projekt_id") + raw := q.Get("project_id") if raw == "" { - raw = q.Get("akte_id") + raw = q.Get("project_id") } if raw != "" { projektID, err := uuid.Parse(raw) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid projekt_id"}) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project_id"}) return } - filter.ProjektID = &projektID + filter.ProjectID = &projektID } if raw := q.Get("from"); raw != "" { t, err := parseDateOrTime(raw) @@ -69,7 +69,7 @@ func handleListTermine(w http.ResponseWriter, r *http.Request) { if raw := q.Get("type"); raw != "" { filter.Type = &raw } - rows, err := dbSvc.termin.ListVisibleForUser(r.Context(), uid, filter) + rows, err := dbSvc.appointment.ListVisibleForUser(r.Context(), uid, filter) if err != nil { writeServiceError(w, err) return @@ -77,7 +77,7 @@ func handleListTermine(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, rows) } -// GET /api/termine/summary +// GET /api/appointments/summary func handleTermineSummary(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -86,7 +86,7 @@ func handleTermineSummary(w http.ResponseWriter, r *http.Request) { if !ok { return } - c, err := dbSvc.termin.SummaryCounts(r.Context(), uid) + c, err := dbSvc.appointment.SummaryCounts(r.Context(), uid) if err != nil { writeServiceError(w, err) return @@ -94,7 +94,7 @@ func handleTermineSummary(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, c) } -// GET /api/projekte/{id}/termine +// GET /api/projects/{id}/appointments func handleListTermineForProjekt(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -108,7 +108,7 @@ func handleListTermineForProjekt(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } - rows, err := dbSvc.termin.ListForProjekt(r.Context(), uid, projektID) + rows, err := dbSvc.appointment.ListForProjekt(r.Context(), uid, projektID) if err != nil { writeServiceError(w, err) return @@ -116,7 +116,7 @@ func handleListTermineForProjekt(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, rows) } -// POST /api/termine +// POST /api/appointments func handleCreateTermin(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -130,7 +130,7 @@ func handleCreateTermin(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) return } - t, err := dbSvc.termin.Create(r.Context(), uid, input) + t, err := dbSvc.appointment.Create(r.Context(), uid, input) if err != nil { writeServiceError(w, err) return @@ -138,7 +138,7 @@ func handleCreateTermin(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, t) } -// GET /api/termine/{id} +// GET /api/appointments/{id} func handleGetTermin(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -152,7 +152,7 @@ func handleGetTermin(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } - t, err := dbSvc.termin.GetByID(r.Context(), uid, id) + t, err := dbSvc.appointment.GetByID(r.Context(), uid, id) if err != nil { writeServiceError(w, err) return @@ -160,7 +160,7 @@ func handleGetTermin(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, t) } -// PATCH /api/termine/{id} +// PATCH /api/appointments/{id} func handleUpdateTermin(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -179,7 +179,7 @@ func handleUpdateTermin(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) return } - t, err := dbSvc.termin.Update(r.Context(), uid, id, input) + t, err := dbSvc.appointment.Update(r.Context(), uid, id, input) if err != nil { writeServiceError(w, err) return @@ -187,7 +187,7 @@ func handleUpdateTermin(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, t) } -// DELETE /api/termine/{id} +// DELETE /api/appointments/{id} func handleDeleteTermin(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -201,7 +201,7 @@ func handleDeleteTermin(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } - if err := dbSvc.termin.Delete(r.Context(), uid, id); err != nil { + if err := dbSvc.appointment.Delete(r.Context(), uid, id); err != nil { writeServiceError(w, err) return } diff --git a/internal/handlers/termine_pages.go b/internal/handlers/appointments_pages.go similarity index 57% rename from internal/handlers/termine_pages.go rename to internal/handlers/appointments_pages.go index 602a0c0..94d8e95 100644 --- a/internal/handlers/termine_pages.go +++ b/internal/handlers/appointments_pages.go @@ -2,38 +2,38 @@ package handlers import "net/http" -// Server-rendered page endpoints for the Phase F Termine UI. +// Server-rendered page endpoints for the Phase F Appointments UI. // HTML is generated at build time by frontend/build.ts; the per-page -// client TS bundles call /api/termine* to populate the DOM and read -// id/akte_id from window.location. +// client TS bundles call /api/appointments* to populate the DOM and read +// id/project_id from window.location. func handleTermineListPage(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, "dist/termine.html") + http.ServeFile(w, r, "dist/appointments.html") } func handleTermineNewPage(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, "dist/termine-neu.html") + http.ServeFile(w, r, "dist/appointments-neu.html") } func handleTermineDetailPage(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, "dist/termine-detail.html") + http.ServeFile(w, r, "dist/appointments-detail.html") } func handleTermineKalenderPage(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, "dist/termine-kalender.html") + http.ServeFile(w, r, "dist/appointments-kalender.html") } // handleEinstellungenPage serves the unified settings page with tabs for // Profil / Benachrichtigungen / CalDAV. The active tab is picked client-side // from ?tab= so switching tabs doesn't round-trip. func handleEinstellungenPage(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, "dist/einstellungen.html") + http.ServeFile(w, r, "dist/settings.html") } -// handleEinstellungenCalDAVRedirect keeps /einstellungen/caldav working for +// handleEinstellungenCalDAVRedirect keeps /settings/caldav working for // bookmarks and any external links while the canonical URL moves to -// /einstellungen?tab=caldav. 301 Moved Permanently — browsers cache the hop +// /settings?tab=caldav. 301 Moved Permanently — browsers cache the hop // so the redirect only costs once per bookmark. func handleEinstellungenCalDAVRedirect(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/einstellungen?tab=caldav", http.StatusMovedPermanently) + http.Redirect(w, r, "/settings?tab=caldav", http.StatusMovedPermanently) } diff --git a/internal/handlers/checklist_instances.go b/internal/handlers/checklist_instances.go index 2911650..bff4f30 100644 --- a/internal/handlers/checklist_instances.go +++ b/internal/handlers/checklist_instances.go @@ -9,7 +9,7 @@ import ( "mgit.msbls.de/m/patholo/internal/services" ) -// GET /api/checklisten/{slug}/instances +// GET /api/checklists/{slug}/instances func handleListChecklistInstancesForTemplate(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -27,7 +27,7 @@ func handleListChecklistInstancesForTemplate(w http.ResponseWriter, r *http.Requ writeJSON(w, http.StatusOK, rows) } -// POST /api/checklisten/{slug}/instances +// POST /api/checklists/{slug}/instances func handleCreateChecklistInstance(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -50,7 +50,7 @@ func handleCreateChecklistInstance(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, inst) } -// GET /api/checklisten/instances/{id} +// GET /api/checklists/instances/{id} func handleGetChecklistInstance(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -72,7 +72,7 @@ func handleGetChecklistInstance(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, inst) } -// PATCH /api/checklisten/instances/{id} +// PATCH /api/checklists/instances/{id} func handleUpdateChecklistInstance(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -99,7 +99,7 @@ func handleUpdateChecklistInstance(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, inst) } -// POST /api/checklisten/instances/{id}/reset +// POST /api/checklists/instances/{id}/reset func handleResetChecklistInstance(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -121,7 +121,7 @@ func handleResetChecklistInstance(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, inst) } -// DELETE /api/checklisten/instances/{id} +// DELETE /api/checklists/instances/{id} func handleDeleteChecklistInstance(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -142,7 +142,7 @@ func handleDeleteChecklistInstance(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -// GET /api/projekte/{id}/checklisten +// GET /api/projects/{id}/checklists func handleListChecklistInstancesForProjekt(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return diff --git a/internal/handlers/checklisten.go b/internal/handlers/checklists.go similarity index 81% rename from internal/handlers/checklisten.go rename to internal/handlers/checklists.go index 755fdc0..cf7b5ec 100644 --- a/internal/handlers/checklisten.go +++ b/internal/handlers/checklists.go @@ -11,7 +11,7 @@ import ( "time" "mgit.msbls.de/m/patholo/internal/auth" - "mgit.msbls.de/m/patholo/internal/checklisten" + "mgit.msbls.de/m/patholo/internal/checklists" ) type ChecklistFeedback struct { @@ -21,29 +21,29 @@ type ChecklistFeedback struct { } func handleChecklistenPage(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, "dist/checklisten.html") + http.ServeFile(w, r, "dist/checklists.html") } func handleChecklistDetailPage(w http.ResponseWriter, r *http.Request) { slug := r.PathValue("slug") - if _, ok := checklisten.Find(slug); !ok { + if _, ok := checklists.Find(slug); !ok { http.NotFound(w, r) return } - http.ServeFile(w, r, "dist/checklisten-detail.html") + http.ServeFile(w, r, "dist/checklists-detail.html") } func handleChecklistInstancePage(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, "dist/checklisten-instance.html") + http.ServeFile(w, r, "dist/checklists-instance.html") } func handleChecklistenAPI(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, checklisten.Summaries()) + writeJSON(w, http.StatusOK, checklists.Summaries()) } func handleChecklistAPI(w http.ResponseWriter, r *http.Request) { slug := r.PathValue("slug") - c, ok := checklisten.Find(slug) + c, ok := checklists.Find(slug) if !ok { writeJSON(w, http.StatusNotFound, map[string]string{"error": "Checkliste nicht gefunden."}) return @@ -83,15 +83,15 @@ func handleChecklistenFeedback(w http.ResponseWriter, r *http.Request) { jsonBody, err := json.Marshal(payload) if err != nil { - log.Printf("checklisten feedback marshal error: %v", err) + log.Printf("checklists feedback marshal error: %v", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Interner Fehler."}) return } - endpoint := fmt.Sprintf("%s/rest/v1/checklisten_feedback", authClient.URL) + endpoint := fmt.Sprintf("%s/rest/v1/checklists_feedback", authClient.URL) req2, err := http.NewRequest("POST", endpoint, bytes.NewReader(jsonBody)) if err != nil { - log.Printf("checklisten feedback request error: %v", err) + log.Printf("checklists feedback request error: %v", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Interner Fehler."}) return } @@ -107,7 +107,7 @@ func handleChecklistenFeedback(w http.ResponseWriter, r *http.Request) { client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Do(req2) if err != nil { - log.Printf("checklisten feedback supabase error: %v", err) + log.Printf("checklists feedback supabase error: %v", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Fehler beim Speichern."}) return } @@ -115,7 +115,7 @@ func handleChecklistenFeedback(w http.ResponseWriter, r *http.Request) { if resp.StatusCode >= 300 { body, _ := io.ReadAll(resp.Body) - log.Printf("checklisten feedback supabase status %d: %s", resp.StatusCode, string(body)) + log.Printf("checklists feedback supabase status %d: %s", resp.StatusCode, string(body)) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Fehler beim Speichern."}) return } diff --git a/internal/handlers/gerichte.go b/internal/handlers/courts.go similarity index 98% rename from internal/handlers/gerichte.go rename to internal/handlers/courts.go index fb190f3..e01a9aa 100644 --- a/internal/handlers/gerichte.go +++ b/internal/handlers/courts.go @@ -312,12 +312,12 @@ var courts = []Court{ Type: "DE-LG", Group: "DE", Country: "DE", City: "München", Address: "Prielmayerstraße 7, 80335 München (Postanschrift: 80316 München)", Phone: "+49 89 5597-01", - Website: "https://www.justiz.bayern.de/gerichte-und-behoerden/landgericht/muenchen-1/", + Website: "https://www.justiz.bayern.de/courts-und-behoerden/landgericht/muenchen-1/", Languages: []string{"DE"}, Filing: "beA (besonderes elektronisches Anwaltspostfach) / EGVP; PDF gemäß ERVV.", NotesDE: "Zwei Patentstreitkammern (7. und 21. Zivilkammer). Bevorzugt intensive Erörterung; mündliche Verhandlungen oft ganztägig.", NotesEN: "Two patent litigation chambers (7th and 21st civil chambers). Known for intensive oral hearings.", - Source: "https://www.justiz.bayern.de/gerichte-und-behoerden/landgericht/muenchen-1/", + Source: "https://www.justiz.bayern.de/courts-und-behoerden/landgericht/muenchen-1/", }, { ID: "de-lg-duesseldorf", @@ -352,10 +352,10 @@ var courts = []Court{ NameEN: "Regional Court Hamburg — Patent Chambers", Type: "DE-LG", Group: "DE", Country: "DE", City: "Hamburg", Address: "Sievekingplatz 1, 20355 Hamburg", - Website: "https://justiz.hamburg.de/gerichte/landgericht-hamburg/", + Website: "https://justiz.hamburg.de/courts/landgericht-hamburg/", Languages: []string{"DE"}, Filing: "beA / EGVP gemäß ERVV.", - Source: "https://justiz.hamburg.de/gerichte/landgericht-hamburg/kontakt", + Source: "https://justiz.hamburg.de/courts/landgericht-hamburg/kontakt", }, { ID: "de-olg-duesseldorf", @@ -376,10 +376,10 @@ var courts = []Court{ NameEN: "Higher Regional Court Munich — 6th Civil Senate (patent appeals)", Type: "DE-OLG", Group: "DE", Country: "DE", City: "München", Address: "Prielmayerstraße 5, 80335 München", - Website: "https://www.justiz.bayern.de/gerichte-und-behoerden/oberlandesgerichte/muenchen/", + Website: "https://www.justiz.bayern.de/courts-und-behoerden/oberlandesgerichte/muenchen/", Languages: []string{"DE"}, Filing: "beA / EGVP gemäß ERVV.", - Source: "https://www.justiz.bayern.de/gerichte-und-behoerden/oberlandesgerichte/muenchen/kontakt.php", + Source: "https://www.justiz.bayern.de/courts-und-behoerden/oberlandesgerichte/muenchen/kontakt.php", }, { ID: "de-olg-karlsruhe", @@ -618,7 +618,7 @@ type GerichteResponse struct { } func handleGerichtePage(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, "dist/gerichte.html") + http.ServeFile(w, r, "dist/courts.html") } func handleGerichteAPI(w http.ResponseWriter, r *http.Request) { @@ -668,7 +668,7 @@ func handleGerichteFeedback(w http.ResponseWriter, r *http.Request) { return } - endpoint := fmt.Sprintf("%s/rest/v1/gerichte_feedback", authClient.URL) + endpoint := fmt.Sprintf("%s/rest/v1/courts_feedback", authClient.URL) req2, err := http.NewRequest("POST", endpoint, bytes.NewReader(jsonBody)) if err != nil { log.Printf("gerichte feedback request error: %v", err) diff --git a/internal/handlers/deadline_rules_db.go b/internal/handlers/deadline_rules_db.go index 733ba31..70d3c6f 100644 --- a/internal/handlers/deadline_rules_db.go +++ b/internal/handlers/deadline_rules_db.go @@ -55,7 +55,7 @@ func handleListProceedingTypesDB(w http.ResponseWriter, r *http.Request) { // Calculates all deadlines for the proceeding type's rule tree, applying // holiday/weekend adjustment via the DB-backed HolidayService. // -// Lives at /api/deadlines/calculate (vs the existing /api/tools/fristenrechner +// Lives at /api/deadlines/calculate (vs the existing /api/tools/deadlinesrechner // which uses the in-memory rule tree). Phase C swaps the Fristenrechner UI // to this endpoint, then deletes the in-memory rule tree. func handleCalculateDeadlines(w http.ResponseWriter, r *http.Request) { diff --git a/internal/handlers/fristen.go b/internal/handlers/deadlines.go similarity index 75% rename from internal/handlers/fristen.go rename to internal/handlers/deadlines.go index 6832079..c89b36c 100644 --- a/internal/handlers/fristen.go +++ b/internal/handlers/deadlines.go @@ -9,7 +9,7 @@ import ( "mgit.msbls.de/m/patholo/internal/services" ) -// GET /api/fristen?status=overdue|this_week|upcoming|completed|pending|all&projekt_id=UUID +// GET /api/deadlines?status=overdue|this_week|upcoming|completed|pending|all&project_id=UUID func handleListFristen(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -21,20 +21,20 @@ func handleListFristen(w http.ResponseWriter, r *http.Request) { filter := services.ListFilter{ Status: services.FristStatusFilter(r.URL.Query().Get("status")), } - // Accept both projekt_id (new) and akte_id (legacy alias). - raw := r.URL.Query().Get("projekt_id") + // Accept both project_id (new) and project_id (legacy alias). + raw := r.URL.Query().Get("project_id") if raw == "" { - raw = r.URL.Query().Get("akte_id") + raw = r.URL.Query().Get("project_id") } if raw != "" { projektID, err := uuid.Parse(raw) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid projekt_id"}) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project_id"}) return } - filter.ProjektID = &projektID + filter.ProjectID = &projektID } - rows, err := dbSvc.frist.ListVisibleForUser(r.Context(), uid, filter) + rows, err := dbSvc.deadline.ListVisibleForUser(r.Context(), uid, filter) if err != nil { writeServiceError(w, err) return @@ -42,7 +42,7 @@ func handleListFristen(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, rows) } -// GET /api/fristen/summary?projekt_id=UUID +// GET /api/deadlines/summary?project_id=UUID func handleFristenSummary(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -52,19 +52,19 @@ func handleFristenSummary(w http.ResponseWriter, r *http.Request) { return } var projektIDPtr *uuid.UUID - raw := r.URL.Query().Get("projekt_id") + raw := r.URL.Query().Get("project_id") if raw == "" { - raw = r.URL.Query().Get("akte_id") + raw = r.URL.Query().Get("project_id") } if raw != "" { projektID, err := uuid.Parse(raw) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid projekt_id"}) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project_id"}) return } projektIDPtr = &projektID } - c, err := dbSvc.frist.SummaryCounts(r.Context(), uid, projektIDPtr) + c, err := dbSvc.deadline.SummaryCounts(r.Context(), uid, projektIDPtr) if err != nil { writeServiceError(w, err) return @@ -72,7 +72,7 @@ func handleFristenSummary(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, c) } -// GET /api/projekte/{id}/fristen +// GET /api/projects/{id}/deadlines func handleListFristenForProjekt(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -86,7 +86,7 @@ func handleListFristenForProjekt(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } - rows, err := dbSvc.frist.ListForProjekt(r.Context(), uid, projektID) + rows, err := dbSvc.deadline.ListForProjekt(r.Context(), uid, projektID) if err != nil { writeServiceError(w, err) return @@ -94,7 +94,7 @@ func handleListFristenForProjekt(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, rows) } -// POST /api/projekte/{id}/fristen +// POST /api/projects/{id}/deadlines func handleCreateFrist(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -113,7 +113,7 @@ func handleCreateFrist(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) return } - f, err := dbSvc.frist.Create(r.Context(), uid, projektID, input) + f, err := dbSvc.deadline.Create(r.Context(), uid, projektID, input) if err != nil { writeServiceError(w, err) return @@ -121,7 +121,7 @@ func handleCreateFrist(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, f) } -// POST /api/projekte/{id}/fristen/bulk — Fristenrechner "save to Projekt". +// POST /api/projects/{id}/deadlines/bulk — Fristenrechner "save to Project". func handleBulkCreateFristen(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -136,13 +136,13 @@ func handleBulkCreateFristen(w http.ResponseWriter, r *http.Request) { return } var body struct { - Fristen []services.CreateFristInput `json:"fristen"` + Deadlines []services.CreateFristInput `json:"deadlines"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) return } - rows, err := dbSvc.frist.CreateBulk(r.Context(), uid, projektID, body.Fristen) + rows, err := dbSvc.deadline.CreateBulk(r.Context(), uid, projektID, body.Deadlines) if err != nil { writeServiceError(w, err) return @@ -150,7 +150,7 @@ func handleBulkCreateFristen(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, rows) } -// GET /api/fristen/{id} +// GET /api/deadlines/{id} func handleGetFrist(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -164,7 +164,7 @@ func handleGetFrist(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } - f, err := dbSvc.frist.GetByID(r.Context(), uid, id) + f, err := dbSvc.deadline.GetByID(r.Context(), uid, id) if err != nil { writeServiceError(w, err) return @@ -172,7 +172,7 @@ func handleGetFrist(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, f) } -// PATCH /api/fristen/{id} +// PATCH /api/deadlines/{id} func handleUpdateFrist(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -191,7 +191,7 @@ func handleUpdateFrist(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) return } - f, err := dbSvc.frist.Update(r.Context(), uid, id, input) + f, err := dbSvc.deadline.Update(r.Context(), uid, id, input) if err != nil { writeServiceError(w, err) return @@ -199,7 +199,7 @@ func handleUpdateFrist(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, f) } -// PATCH /api/fristen/{id}/complete — convenience endpoint for the list-row checkbox. +// PATCH /api/deadlines/{id}/complete — convenience endpoint for the list-row checkbox. func handleCompleteFrist(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -213,7 +213,7 @@ func handleCompleteFrist(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } - f, err := dbSvc.frist.Complete(r.Context(), uid, id) + f, err := dbSvc.deadline.Complete(r.Context(), uid, id) if err != nil { writeServiceError(w, err) return @@ -221,7 +221,7 @@ func handleCompleteFrist(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, f) } -// DELETE /api/fristen/{id} +// DELETE /api/deadlines/{id} func handleDeleteFrist(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -235,7 +235,7 @@ func handleDeleteFrist(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } - if err := dbSvc.frist.Delete(r.Context(), uid, id); err != nil { + if err := dbSvc.deadline.Delete(r.Context(), uid, id); err != nil { writeServiceError(w, err) return } diff --git a/internal/handlers/fristen_pages.go b/internal/handlers/deadlines_pages.go similarity index 51% rename from internal/handlers/fristen_pages.go rename to internal/handlers/deadlines_pages.go index 3278354..3e31848 100644 --- a/internal/handlers/fristen_pages.go +++ b/internal/handlers/deadlines_pages.go @@ -2,23 +2,23 @@ package handlers import "net/http" -// Server-rendered page endpoints for the Phase E Fristen UI. +// Server-rendered page endpoints for the Phase E Deadlines UI. // HTML is generated at build time by frontend/build.ts; the per-page -// client TS bundles call /api/fristen* to populate the DOM and read -// id/akte_id from window.location. +// client TS bundles call /api/deadlines* to populate the DOM and read +// id/project_id from window.location. func handleFristenListPage(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, "dist/fristen.html") + http.ServeFile(w, r, "dist/deadlines.html") } func handleFristenNewPage(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, "dist/fristen-neu.html") + http.ServeFile(w, r, "dist/deadlines-neu.html") } func handleFristenDetailPage(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, "dist/fristen-detail.html") + http.ServeFile(w, r, "dist/deadlines-detail.html") } func handleFristenKalenderPage(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, "dist/fristen-kalender.html") + http.ServeFile(w, r, "dist/deadlines-kalender.html") } diff --git a/internal/handlers/dezernate.go b/internal/handlers/departments.go similarity index 89% rename from internal/handlers/dezernate.go rename to internal/handlers/departments.go index 5e69e40..2442639 100644 --- a/internal/handlers/dezernate.go +++ b/internal/handlers/departments.go @@ -11,7 +11,7 @@ import ( "mgit.msbls.de/m/patholo/internal/services" ) -// GET /api/dezernate — list every Dezernat (readable by all authenticated users). +// GET /api/departments — list every Dezernat (readable by all authenticated users). func handleListDezernate(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -27,7 +27,7 @@ func handleListDezernate(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, rows) } -// POST /api/dezernate — admin-only create. +// POST /api/departments — admin-only create. func handleCreateDezernat(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -36,7 +36,7 @@ func handleCreateDezernat(w http.ResponseWriter, r *http.Request) { if !ok { return } - var input services.CreateDezernatInput + var input services.CreateDepartmentInput if err := json.NewDecoder(r.Body).Decode(&input); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) return @@ -49,7 +49,7 @@ func handleCreateDezernat(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, d) } -// GET /api/dezernate/{id} +// GET /api/departments/{id} func handleGetDezernat(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -74,7 +74,7 @@ func handleGetDezernat(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, d) } -// PATCH /api/dezernate/{id} — admin-only. +// PATCH /api/departments/{id} — admin-only. func handleUpdateDezernat(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -88,7 +88,7 @@ func handleUpdateDezernat(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } - var input services.UpdateDezernatInput + var input services.UpdateDepartmentInput if err := json.NewDecoder(r.Body).Decode(&input); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) return @@ -101,7 +101,7 @@ func handleUpdateDezernat(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, d) } -// DELETE /api/dezernate/{id} — admin-only. +// DELETE /api/departments/{id} — admin-only. func handleDeleteDezernat(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -122,7 +122,7 @@ func handleDeleteDezernat(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -// GET /api/dezernate/{id}/members +// GET /api/departments/{id}/members func handleListDezernatMembers(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -143,7 +143,7 @@ func handleListDezernatMembers(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, rows) } -// POST /api/dezernate/{id}/members — admin-only. Body: {"user_id": ""} +// POST /api/departments/{id}/members — admin-only. Body: {"user_id": ""} func handleAddDezernatMember(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -171,7 +171,7 @@ func handleAddDezernatMember(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -// DELETE /api/dezernate/{id}/members/{user_id} — admin-only. +// DELETE /api/departments/{id}/members/{user_id} — admin-only. func handleRemoveDezernatMember(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return diff --git a/internal/handlers/fristenrechner.go b/internal/handlers/fristenrechner.go index cb14920..3ab238e 100644 --- a/internal/handlers/fristenrechner.go +++ b/internal/handlers/fristenrechner.go @@ -10,10 +10,10 @@ import ( // Fristenrechner page handler: serves the static HTML. No DB dependency. func handleFristenrechnerPage(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, "dist/fristenrechner.html") + http.ServeFile(w, r, "dist/deadlinesrechner.html") } -// POST /api/tools/fristenrechner — calculate the UI timeline for a proceeding. +// POST /api/tools/deadlinesrechner — calculate the UI timeline for a proceeding. // // Phase C: routes through FristenrechnerService which pulls rules from // paliad.deadline_rules. When DATABASE_URL is unset, returns 503; the page diff --git a/internal/handlers/glossar.go b/internal/handlers/glossary.go similarity index 96% rename from internal/handlers/glossar.go rename to internal/handlers/glossary.go index d278fc4..e5db6f4 100644 --- a/internal/handlers/glossar.go +++ b/internal/handlers/glossary.go @@ -94,7 +94,7 @@ var glossarTerms = []GlossarTerm{ {DE: "Recherchenbericht", EN: "Search report", Definition: "Bericht des EPA über den für die Patentanmeldung relevanten Stand der Technik.", Category: "EPA"}, {DE: "Europäisches Patentübereinkommen", EN: "European Patent Convention (EPC)", Definition: "Völkerrechtlicher Vertrag, der das europäische Patentrecht und das EPA regelt.", Category: "EPA"}, {DE: "Benennungsstaaten", EN: "Designated states", Definition: "Staaten, für die Schutz aus einer europäischen Patentanmeldung beansprucht wird.", Category: "EPA"}, - {DE: "Wiedereinsetzung", EN: "Re-establishment of rights", Definition: "Antrag auf Wiedereinsetzung in eine versäumte Frist beim EPA.", Category: "EPA"}, + {DE: "Wiedereinsetzung", EN: "Re-establishment of rights", Definition: "Antrag auf Wiedereinsetzung in eine versäumte Deadline beim EPA.", Category: "EPA"}, {DE: "Weiterbehandlung", EN: "Further processing", Definition: "Verfahren zur Heilung einer Fristversäumnis beim EPA gegen Zahlung einer Gebühr.", Category: "EPA"}, {DE: "Beschränkungsverfahren", EN: "Limitation proceedings", Definition: "Verfahren zur nachträglichen Einschränkung der Patentansprüche eines erteilten europäischen Patents.", Category: "EPA"}, @@ -122,8 +122,8 @@ var glossarTerms = []GlossarTerm{ {DE: "SEP", EN: "SEP", Definition: "Standard Essential Patent \u2014 ein Patent, dessen Nutzung zur Umsetzung eines technischen Standards zwingend erforderlich ist.", Category: "SEP/FRAND"}, {DE: "Standard-essentielles Patent", EN: "Standard-essential patent", Definition: "Deutsche Bezeichnung für ein SEP; ein Patent, das gegenüber einer Standardisierungsorganisation (z.\u202fB. ETSI) als essentiell deklariert wurde.", Category: "SEP/FRAND"}, {DE: "Patentpool", EN: "Patent pool", Definition: "Zusammenschluss mehrerer SEP-Inhaber, die ihre Patente über einen Administrator (z.\u202fB. Avanci, Sisvel) gebündelt lizenzieren.", Category: "SEP/FRAND"}, - {DE: "Anti-Suit Injunction", EN: "Anti-suit injunction (ASI)", Definition: "Gerichtliche Anordnung, die einer Partei verbietet, ein paralleles Verfahren vor einem anderen Gericht zu führen \u2014 in SEP-Streitigkeiten insbesondere aus den USA und UK bekannt.", Category: "SEP/FRAND"}, - {DE: "Anti-Anti-Suit Injunction", EN: "Anti-anti-suit injunction (AASI)", Definition: "Gegenanordnung, die einer Partei untersagt, eine Anti-Suit Injunction zu beantragen oder durchzusetzen \u2014 von deutschen Gerichten (München I, Düsseldorf) als Verteidigung etabliert.", Category: "SEP/FRAND"}, + {DE: "Anti-Suit Injunction", EN: "Anti-suit injunction (ASI)", Definition: "Gerichtliche Anordnung, die einer Party verbietet, ein paralleles Verfahren vor einem anderen Gericht zu führen \u2014 in SEP-Streitigkeiten insbesondere aus den USA und UK bekannt.", Category: "SEP/FRAND"}, + {DE: "Anti-Anti-Suit Injunction", EN: "Anti-anti-suit injunction (AASI)", Definition: "Gegenanordnung, die einer Party untersagt, eine Anti-Suit Injunction zu beantragen oder durchzusetzen \u2014 von deutschen Gerichten (München I, Düsseldorf) als Verteidigung etabliert.", Category: "SEP/FRAND"}, {DE: "Injunction Gap", EN: "Injunction gap", Definition: "Zeitraum zwischen dem Verletzungsurteil eines deutschen Gerichts und der Entscheidung des BPatG über den Rechtsbestand \u2014 umstritten, da SEP-Inhaber Unterlassung durchsetzen können, bevor das Patent auf Nichtigkeit überprüft ist.", Category: "SEP/FRAND"}, {DE: "Orange-Book-Standard", EN: "Orange-Book-Standard", Definition: "BGH-Leitentscheidung (KZR 39/06, 2009) zum kartellrechtlichen Zwangslizenzeinwand im SEP-Kontext; überholt durch Huawei/ZTE (EuGH 2015), aber weiterhin zitiert.", Category: "SEP/FRAND"}, {DE: "Huawei/ZTE-Verhandlungsmuster", EN: "Huawei/ZTE negotiation framework", Definition: "EuGH-Urteil C-170/13 (2015): Schrittweises Verhandlungsmuster (Verletzungshinweis, Lizenzangebot des SEP-Inhabers, FRAND-konformes Gegenangebot des Verletzers, Rechnungslegung), das vor Unterlassungsanträgen einzuhalten ist.", Category: "SEP/FRAND"}, @@ -134,7 +134,7 @@ var glossarTerms = []GlossarTerm{ } func handleGlossarPage(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, "dist/glossar.html") + http.ServeFile(w, r, "dist/glossary.html") } func handleGlossarAPI(w http.ResponseWriter, r *http.Request) { @@ -192,7 +192,7 @@ func handleGlossarSuggest(w http.ResponseWriter, r *http.Request) { return } - endpoint := fmt.Sprintf("%s/rest/v1/glossar_suggestions", authClient.URL) + endpoint := fmt.Sprintf("%s/rest/v1/glossary_suggestions", authClient.URL) req2, err := http.NewRequest("POST", endpoint, bytes.NewReader(jsonBody)) if err != nil { log.Printf("glossar suggest request error: %v", err) diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index b5f6bae..a82b4a2 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -13,19 +13,19 @@ var authClient *auth.Client // Services bundles the Phase B + C database-backed services. Pass nil if // DATABASE_URL was unset; the Akten/deadline endpoints will return 503. type Services struct { - Projekt *services.ProjektService + Project *services.ProjectService Team *services.TeamService - Dezernat *services.DezernatService - Parteien *services.ParteienService - Frist *services.FristService - Termin *services.TerminService + Dezernat *services.DepartmentService + Parties *services.PartyService + Deadline *services.DeadlineService + Appointment *services.AppointmentService CalDAV *services.CalDAVService Rules *services.DeadlineRuleService Calculator *services.DeadlineCalculator Users *services.UserService Fristenrechner *services.FristenrechnerService Dashboard *services.DashboardService - Notiz *services.NotizService + Note *services.NoteService ChecklistInst *services.ChecklistInstanceService Mail *services.MailService Invite *services.InviteService @@ -37,19 +37,19 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc if svc != nil { dbSvc = &dbServices{ - projekte: svc.Projekt, + projects: svc.Project, team: svc.Team, dezernat: svc.Dezernat, - parteien: svc.Parteien, - frist: svc.Frist, - termin: svc.Termin, + parties: svc.Parties, + deadline: svc.Deadline, + appointment: svc.Appointment, caldav: svc.CalDAV, rules: svc.Rules, calc: svc.Calculator, users: svc.Users, fristenrechner: svc.Fristenrechner, dashboard: svc.Dashboard, - notiz: svc.Notiz, + note: svc.Note, checklistInst: svc.ChecklistInst, mail: svc.Mail, invite: svc.Invite, @@ -76,13 +76,13 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc protected := http.NewServeMux() protected.HandleFunc("GET /tools/kostenrechner", handleKostenrechnerPage) protected.HandleFunc("POST /api/tools/kostenrechner", handleKostenrechnerAPI) - protected.HandleFunc("GET /tools/fristenrechner", handleFristenrechnerPage) - protected.HandleFunc("POST /api/tools/fristenrechner", handleFristenrechnerAPI) + protected.HandleFunc("GET /tools/deadlinesrechner", handleFristenrechnerPage) + protected.HandleFunc("POST /api/tools/deadlinesrechner", handleFristenrechnerAPI) protected.HandleFunc("GET /api/tools/proceeding-types", handleProceedingTypes) protected.HandleFunc("GET /downloads", handleDownloadsPage) - protected.HandleFunc("GET /glossar", handleGlossarPage) - protected.HandleFunc("GET /api/glossar", handleGlossarAPI) - protected.HandleFunc("POST /api/glossar/suggest", handleGlossarSuggest) + protected.HandleFunc("GET /glossary", handleGlossarPage) + protected.HandleFunc("GET /api/glossaryy", handleGlossarAPI) + protected.HandleFunc("POST /api/glossaryy/suggest", handleGlossarSuggest) protected.HandleFunc("GET /files/{filename}", handleFileDownload) protected.HandleFunc("POST /api/files/refresh", handleFileRefresh) protected.HandleFunc("GET /links", handleLinksPage) @@ -94,77 +94,77 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc protected.HandleFunc("GET /api/tools/gebuehrentabellen", handleGebuehrentabellenAPI) protected.HandleFunc("GET /api/tools/gebuehrentabellen/lookup", handleGebuehrentabellenLookup) protected.HandleFunc("POST /api/tools/gebuehrentabellen/feedback", handleGebuehrentabellenFeedback) - protected.HandleFunc("GET /checklisten", handleChecklistenPage) - protected.HandleFunc("GET /checklisten/instances/{id}", handleChecklistInstancePage) - protected.HandleFunc("GET /checklisten/{slug}", handleChecklistDetailPage) - protected.HandleFunc("GET /api/checklisten", handleChecklistenAPI) - protected.HandleFunc("GET /api/checklisten/{slug}", handleChecklistAPI) - protected.HandleFunc("POST /api/checklisten/feedback", handleChecklistenFeedback) - protected.HandleFunc("GET /api/checklisten/{slug}/instances", handleListChecklistInstancesForTemplate) - protected.HandleFunc("POST /api/checklisten/{slug}/instances", handleCreateChecklistInstance) + protected.HandleFunc("GET /checklists", handleChecklistenPage) + protected.HandleFunc("GET /checklists/instances/{id}", handleChecklistInstancePage) + protected.HandleFunc("GET /checklists/{slug}", handleChecklistDetailPage) + protected.HandleFunc("GET /api/checklists", handleChecklistenAPI) + protected.HandleFunc("GET /api/checklists/{slug}", handleChecklistAPI) + protected.HandleFunc("POST /api/checklists/feedback", handleChecklistenFeedback) + protected.HandleFunc("GET /api/checklists/{slug}/instances", handleListChecklistInstancesForTemplate) + protected.HandleFunc("POST /api/checklists/{slug}/instances", handleCreateChecklistInstance) protected.HandleFunc("GET /api/checklist-instances/{id}", handleGetChecklistInstance) protected.HandleFunc("PATCH /api/checklist-instances/{id}", handleUpdateChecklistInstance) protected.HandleFunc("POST /api/checklist-instances/{id}/reset", handleResetChecklistInstance) protected.HandleFunc("DELETE /api/checklist-instances/{id}", handleDeleteChecklistInstance) - protected.HandleFunc("GET /api/projekte/{id}/checklisten", handleListChecklistInstancesForProjekt) - protected.HandleFunc("GET /api/akten/{id}/checklisten", handleListChecklistInstancesForProjekt) // legacy alias - protected.HandleFunc("GET /gerichte", handleGerichtePage) - protected.HandleFunc("GET /api/gerichte", handleGerichteAPI) - protected.HandleFunc("POST /api/gerichte/feedback", handleGerichteFeedback) + protected.HandleFunc("GET /api/projects/{id}/checklists", handleListChecklistInstancesForProjekt) + protected.HandleFunc("GET /api/akten/{id}/checklists", handleListChecklistInstancesForProjekt) // legacy alias + protected.HandleFunc("GET /courts", handleGerichtePage) + protected.HandleFunc("GET /api/courts", handleGerichteAPI) + protected.HandleFunc("POST /api/courts/feedback", handleGerichteFeedback) // Phase B (DB-backed) — return 503 if DATABASE_URL unset. protected.HandleFunc("GET /api/deadline-rules", handleListDeadlineRules) protected.HandleFunc("GET /api/proceeding-types-db", handleListProceedingTypesDB) protected.HandleFunc("POST /api/deadlines/calculate", handleCalculateDeadlines) - // Projekte v2 (hierarchical tree — t-paliad-024). - protected.HandleFunc("GET /api/projekte", handleListProjekte) - protected.HandleFunc("POST /api/projekte", handleCreateProjekt) - protected.HandleFunc("GET /api/projekte/{id}", handleGetProjekt) - protected.HandleFunc("PATCH /api/projekte/{id}", handleUpdateProjekt) - protected.HandleFunc("DELETE /api/projekte/{id}", handleDeleteProjekt) - protected.HandleFunc("GET /api/projekte/{id}/events", handleListProjektEvents) - protected.HandleFunc("GET /api/projekte/{id}/kinder", handleListProjektChildren) - protected.HandleFunc("GET /api/projekte/{id}/tree", handleGetProjektTree) - protected.HandleFunc("GET /api/projekte/{id}/ancestors", handleListProjektAncestors) - protected.HandleFunc("GET /api/projekte/{id}/parteien", handleListParteien) - protected.HandleFunc("POST /api/projekte/{id}/parteien", handleCreatePartei) - // Team membership endpoints for Projekt detail "Team" tab. - protected.HandleFunc("GET /api/projekte/{id}/team", handleListProjektTeam) - protected.HandleFunc("POST /api/projekte/{id}/team", handleAddProjektTeamMember) - protected.HandleFunc("DELETE /api/projekte/{id}/team/{user_id}", handleRemoveProjektTeamMember) + // Projects v2 (hierarchical tree — t-paliad-024). + protected.HandleFunc("GET /api/projects", handleListProjekte) + protected.HandleFunc("POST /api/projects", handleCreateProjekt) + protected.HandleFunc("GET /api/projects/{id}", handleGetProjekt) + protected.HandleFunc("PATCH /api/projects/{id}", handleUpdateProjekt) + protected.HandleFunc("DELETE /api/projects/{id}", handleDeleteProjekt) + protected.HandleFunc("GET /api/projects/{id}/events", handleListProjectEvents) + protected.HandleFunc("GET /api/projects/{id}/children", handleListProjektChildren) + protected.HandleFunc("GET /api/projects/{id}/tree", handleGetProjektTree) + protected.HandleFunc("GET /api/projects/{id}/ancestors", handleListProjektAncestors) + protected.HandleFunc("GET /api/projects/{id}/parties", handleListParteien) + protected.HandleFunc("POST /api/projects/{id}/parties", handleCreatePartei) + // Team membership endpoints for Project detail "Team" tab. + protected.HandleFunc("GET /api/projects/{id}/team", handleListProjektTeam) + protected.HandleFunc("POST /api/projects/{id}/team", handleAddProjectTeamMember) + protected.HandleFunc("DELETE /api/projects/{id}/team/{user_id}", handleRemoveProjectTeamMember) - // Dezernate (structural teams). - protected.HandleFunc("GET /api/dezernate", handleListDezernate) - protected.HandleFunc("POST /api/dezernate", handleCreateDezernat) - protected.HandleFunc("GET /api/dezernate/{id}", handleGetDezernat) - protected.HandleFunc("PATCH /api/dezernate/{id}", handleUpdateDezernat) - protected.HandleFunc("DELETE /api/dezernate/{id}", handleDeleteDezernat) - protected.HandleFunc("GET /api/dezernate/{id}/members", handleListDezernatMembers) - protected.HandleFunc("POST /api/dezernate/{id}/members", handleAddDezernatMember) - protected.HandleFunc("DELETE /api/dezernate/{id}/members/{user_id}", handleRemoveDezernatMember) + // Departments (structural teams). + protected.HandleFunc("GET /api/departments", handleListDezernate) + protected.HandleFunc("POST /api/departments", handleCreateDezernat) + protected.HandleFunc("GET /api/departments/{id}", handleGetDezernat) + protected.HandleFunc("PATCH /api/departments/{id}", handleUpdateDezernat) + protected.HandleFunc("DELETE /api/departments/{id}", handleDeleteDezernat) + protected.HandleFunc("GET /api/departments/{id}/members", handleListDezernatMembers) + protected.HandleFunc("POST /api/departments/{id}/members", handleAddDezernatMember) + protected.HandleFunc("DELETE /api/departments/{id}/members/{user_id}", handleRemoveDezernatMember) - // Legacy /api/akten aliases — map to the same Projekt handlers during the - // frontend transition. Remove once all clients use /api/projekte. + // Legacy /api/akten aliases — map to the same Project handlers during the + // frontend transition. Remove once all clients use /api/projects. protected.HandleFunc("GET /api/akten", handleListProjekte) protected.HandleFunc("POST /api/akten", handleCreateProjekt) protected.HandleFunc("GET /api/akten/{id}", handleGetProjekt) protected.HandleFunc("PATCH /api/akten/{id}", handleUpdateProjekt) protected.HandleFunc("DELETE /api/akten/{id}", handleDeleteProjekt) - protected.HandleFunc("GET /api/akten/{id}/events", handleListProjektEvents) - protected.HandleFunc("GET /api/akten/{id}/parteien", handleListParteien) - protected.HandleFunc("POST /api/akten/{id}/parteien", handleCreatePartei) + protected.HandleFunc("GET /api/akten/{id}/events", handleListProjectEvents) + protected.HandleFunc("GET /api/akten/{id}/parties", handleListParteien) + protected.HandleFunc("POST /api/akten/{id}/parties", handleCreatePartei) - protected.HandleFunc("DELETE /api/parteien/{id}", handleDeletePartei) + protected.HandleFunc("DELETE /api/parties/{id}", handleDeletePartei) - // Phase F — Termine (appointments) - protected.HandleFunc("GET /api/termine", handleListTermine) - protected.HandleFunc("GET /api/termine/summary", handleTermineSummary) - protected.HandleFunc("POST /api/termine", handleCreateTermin) - protected.HandleFunc("GET /api/termine/{id}", handleGetTermin) - protected.HandleFunc("PATCH /api/termine/{id}", handleUpdateTermin) - protected.HandleFunc("DELETE /api/termine/{id}", handleDeleteTermin) - protected.HandleFunc("GET /api/projekte/{id}/termine", handleListTermineForProjekt) - protected.HandleFunc("GET /api/akten/{id}/termine", handleListTermineForProjekt) // legacy alias + // Phase F — Appointments (appointments) + protected.HandleFunc("GET /api/appointments", handleListTermine) + protected.HandleFunc("GET /api/appointments/summary", handleTermineSummary) + protected.HandleFunc("POST /api/appointments", handleCreateTermin) + protected.HandleFunc("GET /api/appointments/{id}", handleGetTermin) + protected.HandleFunc("PATCH /api/appointments/{id}", handleUpdateTermin) + protected.HandleFunc("DELETE /api/appointments/{id}", handleDeleteTermin) + protected.HandleFunc("GET /api/projects/{id}/appointments", handleListTermineForProjekt) + protected.HandleFunc("GET /api/akten/{id}/appointments", handleListTermineForProjekt) // legacy alias // Phase F — CalDAV configuration (per-user, encrypted at rest) protected.HandleFunc("GET /api/caldav-config", handleGetCalDAVConfig) @@ -173,32 +173,32 @@ 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) - // Phase E — Fristen (persistent deadlines) - protected.HandleFunc("GET /api/fristen", handleListFristen) - protected.HandleFunc("GET /api/fristen/summary", handleFristenSummary) - protected.HandleFunc("GET /api/fristen/{id}", handleGetFrist) - protected.HandleFunc("PATCH /api/fristen/{id}", handleUpdateFrist) - protected.HandleFunc("PATCH /api/fristen/{id}/complete", handleCompleteFrist) - protected.HandleFunc("DELETE /api/fristen/{id}", handleDeleteFrist) - protected.HandleFunc("GET /api/projekte/{id}/fristen", handleListFristenForProjekt) - protected.HandleFunc("POST /api/projekte/{id}/fristen", handleCreateFrist) - protected.HandleFunc("POST /api/projekte/{id}/fristen/bulk", handleBulkCreateFristen) + // Phase E — Deadlines (persistent deadlines) + protected.HandleFunc("GET /api/deadlines", handleListFristen) + protected.HandleFunc("GET /api/deadlines/summary", handleFristenSummary) + protected.HandleFunc("GET /api/deadlines/{id}", handleGetFrist) + protected.HandleFunc("PATCH /api/deadlines/{id}", handleUpdateFrist) + protected.HandleFunc("PATCH /api/deadlines/{id}/complete", handleCompleteFrist) + protected.HandleFunc("DELETE /api/deadlines/{id}", handleDeleteFrist) + protected.HandleFunc("GET /api/projects/{id}/deadlines", handleListFristenForProjekt) + protected.HandleFunc("POST /api/projects/{id}/deadlines", handleCreateFrist) + protected.HandleFunc("POST /api/projects/{id}/deadlines/bulk", handleBulkCreateFristen) // Legacy aliases. - protected.HandleFunc("GET /api/akten/{id}/fristen", handleListFristenForProjekt) - protected.HandleFunc("POST /api/akten/{id}/fristen", handleCreateFrist) - protected.HandleFunc("POST /api/akten/{id}/fristen/bulk", handleBulkCreateFristen) + protected.HandleFunc("GET /api/akten/{id}/deadlines", handleListFristenForProjekt) + protected.HandleFunc("POST /api/akten/{id}/deadlines", handleCreateFrist) + protected.HandleFunc("POST /api/akten/{id}/deadlines/bulk", handleBulkCreateFristen) - // Phase I — Notizen (polymorphic notes) - protected.HandleFunc("GET /api/projekte/{id}/notizen", handleListNotizenForProjekt) - protected.HandleFunc("POST /api/projekte/{id}/notizen", handleCreateNotizForProjekt) - protected.HandleFunc("GET /api/akten/{id}/notizen", handleListNotizenForProjekt) // legacy - protected.HandleFunc("POST /api/akten/{id}/notizen", handleCreateNotizForProjekt) // legacy - protected.HandleFunc("GET /api/fristen/{id}/notizen", handleListNotizenForFrist) - protected.HandleFunc("POST /api/fristen/{id}/notizen", handleCreateNotizForFrist) - protected.HandleFunc("GET /api/termine/{id}/notizen", handleListNotizenForTermin) - protected.HandleFunc("POST /api/termine/{id}/notizen", handleCreateNotizForTermin) - protected.HandleFunc("PATCH /api/notizen/{id}", handleUpdateNotiz) - protected.HandleFunc("DELETE /api/notizen/{id}", handleDeleteNotiz) + // Phase I — Notes (polymorphic notes) + protected.HandleFunc("GET /api/projects/{id}/notes", handleListNotizenForProjekt) + protected.HandleFunc("POST /api/projects/{id}/notes", handleCreateNotizForProjekt) + protected.HandleFunc("GET /api/akten/{id}/notes", handleListNotizenForProjekt) // legacy + protected.HandleFunc("POST /api/akten/{id}/notes", handleCreateNotizForProjekt) // legacy + protected.HandleFunc("GET /api/deadlines/{id}/notes", handleListNotizenForFrist) + protected.HandleFunc("POST /api/deadlines/{id}/notes", handleCreateNotizForFrist) + protected.HandleFunc("GET /api/appointments/{id}/notes", handleListNotizenForTermin) + protected.HandleFunc("POST /api/appointments/{id}/notes", handleCreateNotizForTermin) + protected.HandleFunc("PATCH /api/notes/{id}", handleUpdateNotiz) + protected.HandleFunc("DELETE /api/notes/{id}", handleDeleteNotiz) protected.HandleFunc("GET /api/me", handleGetMe) protected.HandleFunc("PATCH /api/me", handleUpdateMe) @@ -220,48 +220,48 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc // waterfall fetch (design audit §2.3). protected.HandleFunc("GET /dashboard", gateOnboarded(handleDashboardPage)) - // /projekte (v2) — temporarily serves the same pre-rendered HTML as the + // /projects (v2) — temporarily serves the same pre-rendered HTML as the // legacy /akten pages during the frontend cutover. Once the TSX rewrite // lands, dedicated handlers will replace these aliases. - protected.HandleFunc("GET /projekte", gateOnboarded(handleAktenListPage)) - protected.HandleFunc("GET /projekte/neu", gateOnboarded(handleAktenNewPage)) - protected.HandleFunc("GET /projekte/{id}", gateOnboarded(handleAktenDetailPage)) - protected.HandleFunc("GET /projekte/{id}/verlauf", gateOnboarded(handleAktenDetailPage)) - protected.HandleFunc("GET /projekte/{id}/parteien", gateOnboarded(handleAktenDetailPage)) - protected.HandleFunc("GET /projekte/{id}/fristen", gateOnboarded(handleAktenDetailPage)) - protected.HandleFunc("GET /projekte/{id}/termine", gateOnboarded(handleAktenDetailPage)) - protected.HandleFunc("GET /projekte/{id}/dokumente", gateOnboarded(handleAktenDetailPage)) - protected.HandleFunc("GET /projekte/{id}/notizen", gateOnboarded(handleAktenDetailPage)) - protected.HandleFunc("GET /projekte/{id}/checklisten", gateOnboarded(handleAktenDetailPage)) - protected.HandleFunc("GET /projekte/{id}/team", gateOnboarded(handleAktenDetailPage)) + protected.HandleFunc("GET /projects", gateOnboarded(handleAktenListPage)) + protected.HandleFunc("GET /projects/new", gateOnboarded(handleAktenNewPage)) + protected.HandleFunc("GET /projects/{id}", gateOnboarded(handleAktenDetailPage)) + protected.HandleFunc("GET /projects/{id}/events", gateOnboarded(handleAktenDetailPage)) + protected.HandleFunc("GET /projects/{id}/parties", gateOnboarded(handleAktenDetailPage)) + protected.HandleFunc("GET /projects/{id}/deadlines", gateOnboarded(handleAktenDetailPage)) + protected.HandleFunc("GET /projects/{id}/appointments", gateOnboarded(handleAktenDetailPage)) + protected.HandleFunc("GET /projects/{id}/dokumente", gateOnboarded(handleAktenDetailPage)) + protected.HandleFunc("GET /projects/{id}/notes", gateOnboarded(handleAktenDetailPage)) + protected.HandleFunc("GET /projects/{id}/checklists", gateOnboarded(handleAktenDetailPage)) + protected.HandleFunc("GET /projects/{id}/team", gateOnboarded(handleAktenDetailPage)) // Phase D — server-rendered Akten pages (legacy aliases). protected.HandleFunc("GET /akten", gateOnboarded(handleAktenListPage)) - protected.HandleFunc("GET /akten/neu", gateOnboarded(handleAktenNewPage)) + protected.HandleFunc("GET /projects/new", gateOnboarded(handleAktenNewPage)) protected.HandleFunc("GET /akten/{id}", gateOnboarded(handleAktenDetailPage)) - protected.HandleFunc("GET /akten/{id}/verlauf", gateOnboarded(handleAktenDetailPage)) - protected.HandleFunc("GET /akten/{id}/parteien", gateOnboarded(handleAktenDetailPage)) - protected.HandleFunc("GET /akten/{id}/fristen", gateOnboarded(handleAktenDetailPage)) - protected.HandleFunc("GET /akten/{id}/termine", gateOnboarded(handleAktenDetailPage)) + protected.HandleFunc("GET /akten/{id}/events", gateOnboarded(handleAktenDetailPage)) + protected.HandleFunc("GET /akten/{id}/parties", gateOnboarded(handleAktenDetailPage)) + protected.HandleFunc("GET /akten/{id}/deadlines", gateOnboarded(handleAktenDetailPage)) + protected.HandleFunc("GET /akten/{id}/appointments", gateOnboarded(handleAktenDetailPage)) protected.HandleFunc("GET /akten/{id}/dokumente", gateOnboarded(handleAktenDetailPage)) - protected.HandleFunc("GET /akten/{id}/notizen", gateOnboarded(handleAktenDetailPage)) - protected.HandleFunc("GET /akten/{id}/checklisten", gateOnboarded(handleAktenDetailPage)) + protected.HandleFunc("GET /akten/{id}/notes", gateOnboarded(handleAktenDetailPage)) + protected.HandleFunc("GET /akten/{id}/checklists", gateOnboarded(handleAktenDetailPage)) - // Phase E — Fristen (persistent deadline) pages - protected.HandleFunc("GET /fristen", gateOnboarded(handleFristenListPage)) - protected.HandleFunc("GET /fristen/neu", gateOnboarded(handleFristenNewPage)) - protected.HandleFunc("GET /fristen/kalender", gateOnboarded(handleFristenKalenderPage)) - protected.HandleFunc("GET /fristen/{id}", gateOnboarded(handleFristenDetailPage)) - protected.HandleFunc("GET /akten/{id}/fristen/neu", gateOnboarded(handleFristenNewPage)) + // Phase E — Deadlines (persistent deadline) pages + protected.HandleFunc("GET /deadlines", gateOnboarded(handleFristenListPage)) + protected.HandleFunc("GET /deadlines/new", gateOnboarded(handleFristenNewPage)) + protected.HandleFunc("GET /deadlines/calendar", gateOnboarded(handleFristenKalenderPage)) + protected.HandleFunc("GET /deadlines/{id}", gateOnboarded(handleFristenDetailPage)) + protected.HandleFunc("GET /akten/{id}/deadlines/new", gateOnboarded(handleFristenNewPage)) - // Phase F — Termine pages - protected.HandleFunc("GET /termine", gateOnboarded(handleTermineListPage)) - protected.HandleFunc("GET /termine/neu", gateOnboarded(handleTermineNewPage)) - protected.HandleFunc("GET /termine/kalender", gateOnboarded(handleTermineKalenderPage)) - protected.HandleFunc("GET /termine/{id}", gateOnboarded(handleTermineDetailPage)) - protected.HandleFunc("GET /akten/{id}/termine/neu", gateOnboarded(handleTermineNewPage)) - protected.HandleFunc("GET /einstellungen", gateOnboarded(handleEinstellungenPage)) - protected.HandleFunc("GET /einstellungen/caldav", handleEinstellungenCalDAVRedirect) + // Phase F — Appointments pages + protected.HandleFunc("GET /appointments", gateOnboarded(handleTermineListPage)) + protected.HandleFunc("GET /appointments/new", gateOnboarded(handleTermineNewPage)) + protected.HandleFunc("GET /appointments/calendar", gateOnboarded(handleTermineKalenderPage)) + protected.HandleFunc("GET /appointments/{id}", gateOnboarded(handleTermineDetailPage)) + protected.HandleFunc("GET /akten/{id}/appointments/new", gateOnboarded(handleTermineNewPage)) + protected.HandleFunc("GET /settings", gateOnboarded(handleEinstellungenPage)) + protected.HandleFunc("GET /settings/caldav", handleEinstellungenCalDAVRedirect) // Session middleware refreshes tokens; user-id middleware extracts the // JWT sub claim into the request context for handlers that need it. diff --git a/internal/handlers/links.go b/internal/handlers/links.go index 917a425..74007d8 100644 --- a/internal/handlers/links.go +++ b/internal/handlers/links.go @@ -148,7 +148,7 @@ var curatedLinks = []link{ ID: "upc-website", Category: "upc", Title: "UPC Website", URL: "https://www.unified-patent-court.org", - DescDE: "Offizielle Website des Einheitlichen Patentgerichts. Nachrichten, Termine und Informationen.", + DescDE: "Offizielle Website des Einheitlichen Patentgerichts. Nachrichten, Appointments und Informationen.", DescEN: "Official website of the Unified Patent Court. News, events, and information.", }, { diff --git a/internal/handlers/notizen.go b/internal/handlers/notes.go similarity index 83% rename from internal/handlers/notizen.go rename to internal/handlers/notes.go index 928f0bf..7a71900 100644 --- a/internal/handlers/notizen.go +++ b/internal/handlers/notes.go @@ -9,7 +9,7 @@ import ( "mgit.msbls.de/m/patholo/internal/services" ) -// GET /api/projekte/{id}/notizen +// GET /api/projects/{id}/notes func handleListNotizenForProjekt(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -23,7 +23,7 @@ func handleListNotizenForProjekt(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } - rows, err := dbSvc.notiz.ListForProjekt(r.Context(), uid, projektID) + rows, err := dbSvc.note.ListForProjekt(r.Context(), uid, projektID) if err != nil { writeServiceError(w, err) return @@ -31,7 +31,7 @@ func handleListNotizenForProjekt(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, rows) } -// POST /api/projekte/{id}/notizen +// POST /api/projects/{id}/notes func handleCreateNotizForProjekt(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -50,7 +50,7 @@ func handleCreateNotizForProjekt(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) return } - n, err := dbSvc.notiz.CreateForProjekt(r.Context(), uid, projektID, input) + n, err := dbSvc.note.CreateForProjekt(r.Context(), uid, projektID, input) if err != nil { writeServiceError(w, err) return @@ -58,7 +58,7 @@ func handleCreateNotizForProjekt(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, n) } -// GET /api/fristen/{id}/notizen +// GET /api/deadlines/{id}/notes func handleListNotizenForFrist(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -72,7 +72,7 @@ func handleListNotizenForFrist(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } - rows, err := dbSvc.notiz.ListForFrist(r.Context(), uid, fristID) + rows, err := dbSvc.note.ListForFrist(r.Context(), uid, fristID) if err != nil { writeServiceError(w, err) return @@ -80,7 +80,7 @@ func handleListNotizenForFrist(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, rows) } -// POST /api/fristen/{id}/notizen +// POST /api/deadlines/{id}/notes func handleCreateNotizForFrist(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -99,7 +99,7 @@ func handleCreateNotizForFrist(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) return } - n, err := dbSvc.notiz.CreateForFrist(r.Context(), uid, fristID, input) + n, err := dbSvc.note.CreateForFrist(r.Context(), uid, fristID, input) if err != nil { writeServiceError(w, err) return @@ -107,7 +107,7 @@ func handleCreateNotizForFrist(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, n) } -// GET /api/termine/{id}/notizen +// GET /api/appointments/{id}/notes func handleListNotizenForTermin(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -121,7 +121,7 @@ func handleListNotizenForTermin(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } - rows, err := dbSvc.notiz.ListForTermin(r.Context(), uid, terminID) + rows, err := dbSvc.note.ListForTermin(r.Context(), uid, terminID) if err != nil { writeServiceError(w, err) return @@ -129,7 +129,7 @@ func handleListNotizenForTermin(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, rows) } -// POST /api/termine/{id}/notizen +// POST /api/appointments/{id}/notes func handleCreateNotizForTermin(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -148,7 +148,7 @@ func handleCreateNotizForTermin(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) return } - n, err := dbSvc.notiz.CreateForTermin(r.Context(), uid, terminID, input) + n, err := dbSvc.note.CreateForTermin(r.Context(), uid, terminID, input) if err != nil { writeServiceError(w, err) return @@ -156,7 +156,7 @@ func handleCreateNotizForTermin(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, n) } -// PATCH /api/notizen/{id} +// PATCH /api/notes/{id} func handleUpdateNotiz(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -175,7 +175,7 @@ func handleUpdateNotiz(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) return } - n, err := dbSvc.notiz.Update(r.Context(), uid, id, input) + n, err := dbSvc.note.Update(r.Context(), uid, id, input) if err != nil { writeServiceError(w, err) return @@ -183,7 +183,7 @@ func handleUpdateNotiz(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, n) } -// DELETE /api/notizen/{id} +// DELETE /api/notes/{id} func handleDeleteNotiz(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -197,7 +197,7 @@ func handleDeleteNotiz(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } - if err := dbSvc.notiz.Delete(r.Context(), uid, id); err != nil { + if err := dbSvc.note.Delete(r.Context(), uid, id); err != nil { writeServiceError(w, err) return } diff --git a/internal/handlers/onboarding_gate.go b/internal/handlers/onboarding_gate.go index 9e24ec4..abe398a 100644 --- a/internal/handlers/onboarding_gate.go +++ b/internal/handlers/onboarding_gate.go @@ -11,7 +11,7 @@ import ( // not yet filled in paliad.users is redirected to /onboarding instead of // landing on a page that will silently return empty data. // -// Scope: matter-management pages (Dashboard, Akten, Fristen, Termine, +// Scope: matter-management pages (Dashboard, Akten, Deadlines, Appointments, // CalDAV settings). The knowledge-platform pages (Kostenrechner, Glossar, // Links, Downloads, Gerichte, Gebührentabellen, Checklisten, Fristenrechner) // work without a paliad.users row and are deliberately NOT gated. diff --git a/internal/handlers/projekte.go b/internal/handlers/projects.go similarity index 85% rename from internal/handlers/projekte.go rename to internal/handlers/projects.go index 574ee36..3bf662f 100644 --- a/internal/handlers/projekte.go +++ b/internal/handlers/projects.go @@ -15,19 +15,19 @@ import ( // dbServices bundles the Phase B services so handlers can stay thin. // Nil if DATABASE_URL was unset at startup. type dbServices struct { - projekte *services.ProjektService + projects *services.ProjectService team *services.TeamService - dezernat *services.DezernatService - parteien *services.ParteienService - frist *services.FristService - termin *services.TerminService + dezernat *services.DepartmentService + parties *services.PartyService + deadline *services.DeadlineService + appointment *services.AppointmentService caldav *services.CalDAVService rules *services.DeadlineRuleService calc *services.DeadlineCalculator users *services.UserService fristenrechner *services.FristenrechnerService dashboard *services.DashboardService - notiz *services.NotizService + note *services.NoteService checklistInst *services.ChecklistInstanceService mail *services.MailService invite *services.InviteService @@ -73,7 +73,7 @@ func writeServiceError(w http.ResponseWriter, err error) { } } -// GET /api/projekte — list visible projekte. +// GET /api/projects — list visible projects. // Query params: ?type=case&status=active&parent_id=&parent_null=1&search=foo func handleListProjekte(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { @@ -84,7 +84,7 @@ func handleListProjekte(w http.ResponseWriter, r *http.Request) { return } q := r.URL.Query() - filter := services.ProjektFilter{ + filter := services.ProjectFilter{ Type: q.Get("type"), Status: q.Get("status"), Search: q.Get("search"), @@ -100,7 +100,7 @@ func handleListProjekte(w http.ResponseWriter, r *http.Request) { if q.Get("parent_null") == "1" || q.Get("parent_null") == "true" { filter.ParentNullOnly = true } - rows, err := dbSvc.projekte.List(r.Context(), uid, filter) + rows, err := dbSvc.projects.List(r.Context(), uid, filter) if err != nil { writeServiceError(w, err) return @@ -108,7 +108,7 @@ func handleListProjekte(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, rows) } -// POST /api/projekte — also accepts the legacy POST /api/akten body shape +// POST /api/projects — also accepts the legacy POST /api/akten body shape // ({aktenzeichen, owning_office, court_ref}) for the frontend transition. // aktenzeichen → reference, court_ref → case_number, owning_office is dropped // (no longer part of the visibility model). Type defaults to 'case'. @@ -127,7 +127,7 @@ func handleCreateProjekt(w http.ResponseWriter, r *http.Request) { return } input := services.CreateProjektInput{ - Type: services.ProjektTypeCase, + Type: services.ProjectTypeCase, } if v, ok := raw["type"].(string); ok && v != "" { input.Type = v @@ -172,7 +172,7 @@ func handleCreateProjekt(w http.ResponseWriter, r *http.Request) { if v, ok := raw["netdocuments_url"].(string); ok && v != "" { input.NetDocumentsURL = &v } - p, err := dbSvc.projekte.Create(r.Context(), uid, input) + p, err := dbSvc.projects.Create(r.Context(), uid, input) if err != nil { writeServiceError(w, err) return @@ -180,7 +180,7 @@ func handleCreateProjekt(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, p) } -// GET /api/projekte/{id} +// GET /api/projects/{id} func handleGetProjekt(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -194,7 +194,7 @@ func handleGetProjekt(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } - p, err := dbSvc.projekte.GetByID(r.Context(), uid, id) + p, err := dbSvc.projects.GetByID(r.Context(), uid, id) if err != nil { writeServiceError(w, err) return @@ -202,7 +202,7 @@ func handleGetProjekt(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, p) } -// GET /api/projekte/{id}/kinder — direct children. +// GET /api/projects/{id}/children — direct children. func handleListProjektChildren(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -216,7 +216,7 @@ func handleListProjektChildren(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } - rows, err := dbSvc.projekte.ListChildren(r.Context(), uid, id) + rows, err := dbSvc.projects.ListChildren(r.Context(), uid, id) if err != nil { writeServiceError(w, err) return @@ -224,7 +224,7 @@ func handleListProjektChildren(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, rows) } -// GET /api/projekte/{id}/tree — full subtree depth-first (path-ordered). +// GET /api/projects/{id}/tree — full subtree depth-first (path-ordered). func handleGetProjektTree(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -238,7 +238,7 @@ func handleGetProjektTree(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } - rows, err := dbSvc.projekte.GetTree(r.Context(), uid, id) + rows, err := dbSvc.projects.GetTree(r.Context(), uid, id) if err != nil { writeServiceError(w, err) return @@ -246,7 +246,7 @@ func handleGetProjektTree(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, rows) } -// GET /api/projekte/{id}/ancestors — ancestor chain for breadcrumbs. +// GET /api/projects/{id}/ancestors — ancestor chain for breadcrumbs. func handleListProjektAncestors(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -260,7 +260,7 @@ func handleListProjektAncestors(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } - rows, err := dbSvc.projekte.ListAncestors(r.Context(), uid, id) + rows, err := dbSvc.projects.ListAncestors(r.Context(), uid, id) if err != nil { writeServiceError(w, err) return @@ -268,7 +268,7 @@ func handleListProjektAncestors(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, rows) } -// PATCH /api/projekte/{id} +// PATCH /api/projects/{id} func handleUpdateProjekt(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -287,7 +287,7 @@ func handleUpdateProjekt(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) return } - p, err := dbSvc.projekte.Update(r.Context(), uid, id, input) + p, err := dbSvc.projects.Update(r.Context(), uid, id, input) if err != nil { writeServiceError(w, err) return @@ -295,7 +295,7 @@ func handleUpdateProjekt(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, p) } -// DELETE /api/projekte/{id} +// DELETE /api/projects/{id} func handleDeleteProjekt(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -309,15 +309,15 @@ func handleDeleteProjekt(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } - if err := dbSvc.projekte.Delete(r.Context(), uid, id); err != nil { + if err := dbSvc.projects.Delete(r.Context(), uid, id); err != nil { writeServiceError(w, err) return } w.WriteHeader(http.StatusNoContent) } -// GET /api/projekte/{id}/events — audit trail with cursor pagination. -func handleListProjektEvents(w http.ResponseWriter, r *http.Request) { +// GET /api/projects/{id}/events — audit trail with cursor pagination. +func handleListProjectEvents(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } @@ -349,7 +349,7 @@ func handleListProjektEvents(w http.ResponseWriter, r *http.Request) { } limit = n } - rows, err := dbSvc.projekte.ListEvents(r.Context(), uid, id, before, limit) + rows, err := dbSvc.projects.ListEvents(r.Context(), uid, id, before, limit) if err != nil { writeServiceError(w, err) return @@ -357,7 +357,7 @@ func handleListProjektEvents(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, rows) } -// GET /api/projekte/{id}/parteien +// GET /api/projects/{id}/parties func handleListParteien(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -371,7 +371,7 @@ func handleListParteien(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } - rows, err := dbSvc.parteien.ListForProjekt(r.Context(), uid, id) + rows, err := dbSvc.parties.ListForProjekt(r.Context(), uid, id) if err != nil { writeServiceError(w, err) return @@ -379,7 +379,7 @@ func handleListParteien(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, rows) } -// POST /api/projekte/{id}/parteien +// POST /api/projects/{id}/parties func handleCreatePartei(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -398,7 +398,7 @@ func handleCreatePartei(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) return } - p, err := dbSvc.parteien.Create(r.Context(), uid, id, input) + p, err := dbSvc.parties.Create(r.Context(), uid, id, input) if err != nil { writeServiceError(w, err) return @@ -406,7 +406,7 @@ func handleCreatePartei(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, p) } -// DELETE /api/parteien/{id} +// DELETE /api/parties/{id} func handleDeletePartei(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -420,7 +420,7 @@ func handleDeletePartei(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } - if err := dbSvc.parteien.Delete(r.Context(), uid, parteiID); err != nil { + if err := dbSvc.parties.Delete(r.Context(), uid, parteiID); err != nil { writeServiceError(w, err) return } diff --git a/internal/handlers/akten_pages.go b/internal/handlers/projects_pages.go similarity index 74% rename from internal/handlers/akten_pages.go rename to internal/handlers/projects_pages.go index b6abeda..be33082 100644 --- a/internal/handlers/akten_pages.go +++ b/internal/handlers/projects_pages.go @@ -8,9 +8,9 @@ import "net/http" // (bun run build) and served from disk; per-page client TS bundles call the // JSON APIs in akten.go to populate the DOM. // -// Sub-routes (/akten/{id}/verlauf, /fristen, /termine, /dokumente, /parteien, -// /notizen) all serve the same detail HTML; client JS reads window.location to -// pick the initial tab. Fristen/Termine/Dokumente/Notizen tabs currently show +// Sub-routes (/akten/{id}/events, /deadlines, /appointments, /dokumente, /parties, +// /notes) all serve the same detail HTML; client JS reads window.location to +// pick the initial tab. Deadlines/Appointments/Dokumente/Notes tabs currently show // a "Coming Soon — Phase X" panel in the client until later phases land. func handleAktenListPage(w http.ResponseWriter, r *http.Request) { diff --git a/internal/handlers/teams.go b/internal/handlers/teams.go index e1efd23..edbb688 100644 --- a/internal/handlers/teams.go +++ b/internal/handlers/teams.go @@ -9,7 +9,7 @@ import ( "github.com/google/uuid" ) -// GET /api/projekte/{id}/team — returns direct + inherited team members. +// GET /api/projects/{id}/team — returns direct + inherited team members. // inherited=true rows include inherited_from_id / inherited_from_title. func handleListProjektTeam(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { @@ -32,9 +32,9 @@ func handleListProjektTeam(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, rows) } -// POST /api/projekte/{id}/team — add a direct member. +// POST /api/projects/{id}/team — add a direct member. // Body: {"user_id": "", "role": ""} -func handleAddProjektTeamMember(w http.ResponseWriter, r *http.Request) { +func handleAddProjectTeamMember(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } @@ -63,9 +63,9 @@ func handleAddProjektTeamMember(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, m) } -// DELETE /api/projekte/{id}/team/{user_id} — remove a direct member. +// DELETE /api/projects/{id}/team/{user_id} — remove a direct member. // Inherited memberships can't be removed at the child level. -func handleRemoveProjektTeamMember(w http.ResponseWriter, r *http.Request) { +func handleRemoveProjectTeamMember(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } @@ -75,7 +75,7 @@ func handleRemoveProjektTeamMember(w http.ResponseWriter, r *http.Request) { } projektID, err := uuid.Parse(r.PathValue("id")) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid projekt id"}) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"}) return } userID, err := uuid.Parse(r.PathValue("user_id")) diff --git a/internal/handlers/users.go b/internal/handlers/users.go index b2675a5..97455d2 100644 --- a/internal/handlers/users.go +++ b/internal/handlers/users.go @@ -116,4 +116,4 @@ func handleListUsers(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, users) } -// Removed — superseded by handleListProjektEvents in projekte.go. +// Removed — superseded by handleListProjectEvents in projects.go. diff --git a/internal/models/models.go b/internal/models/models.go index 0df7e3d..615f38d 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -1,6 +1,6 @@ // Package models holds the database row types for paliad.* tables. -// Names mirror the German schema (Projekt, Frist, Termin, Notiz, …). -// See internal/db/migrations/ for the canonical schema definitions. +// Names are English throughout; only user-facing i18n strings live in the +// frontend. See internal/db/migrations/ for the canonical schema definitions. package models import ( @@ -12,33 +12,33 @@ import ( ) // User extends auth.users with firm-specific profile fields. Created by the -// Phase D onboarding flow; without a row here, the user can't see any Projekte. +// Phase D onboarding flow; without a row here, the user can't see any Projects. type User struct { - ID uuid.UUID `db:"id" json:"id"` - Email string `db:"email" json:"email"` - DisplayName string `db:"display_name" json:"display_name"` - Office string `db:"office" json:"office"` + ID uuid.UUID `db:"id" json:"id"` + Email string `db:"email" json:"email"` + DisplayName string `db:"display_name" json:"display_name"` + Office string `db:"office" json:"office"` // AdditionalOffices lists secondary offices a partner works across. // Informational only — office is not a visibility gate under the v2 // data model (t-paliad-024). - AdditionalOffices pq.StringArray `db:"additional_offices" json:"additional_offices"` - PracticeGroup *string `db:"practice_group" json:"practice_group,omitempty"` - Role string `db:"role" json:"role"` - Dezernat *string `db:"dezernat" json:"dezernat,omitempty"` - Lang string `db:"lang" json:"lang"` - EmailPreferences json.RawMessage `db:"email_preferences" json:"email_preferences"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + AdditionalOffices pq.StringArray `db:"additional_offices" json:"additional_offices"` + PracticeGroup *string `db:"practice_group" json:"practice_group,omitempty"` + Role string `db:"role" json:"role"` + Dezernat *string `db:"dezernat" json:"dezernat,omitempty"` + Lang string `db:"lang" json:"lang"` + EmailPreferences json.RawMessage `db:"email_preferences" json:"email_preferences"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } -// Projekt is one node in the paliad.projekte tree. Visibility is team-based -// (direct or inherited via the materialised path) — see paliad.can_see_projekt. +// Project is one node in the paliad.projects tree. Visibility is team-based +// (direct or inherited via the materialised path) — see paliad.can_see_project. // Type-specific fields are nullable; the service layer enforces the subset // that applies to each type. -type Projekt struct { - ID uuid.UUID `db:"id" json:"id"` - Type string `db:"type" json:"type"` - ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"` +type Project struct { + ID uuid.UUID `db:"id" json:"id"` + Type string `db:"type" json:"type"` + ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"` // Path is the '.'-joined UUID list from root to self (inclusive). // Maintained by a Postgres trigger — writes from the service are ignored. Path string `db:"path" json:"path"` @@ -77,13 +77,13 @@ type Projekt struct { UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } -// ProjektTeamMember is one row of paliad.projekt_teams — direct membership +// ProjectTeamMember is one row of paliad.project_teams — direct membership // only. Inherited memberships are computed at read time by walking the path; // services set Inherited=true on the in-memory copy when annotating a list // result that mixes direct + inherited rows. -type ProjektTeamMember struct { +type ProjectTeamMember struct { ID uuid.UUID `db:"id" json:"id"` - ProjektID uuid.UUID `db:"projekt_id" json:"projekt_id"` + ProjectID uuid.UUID `db:"project_id" json:"project_id"` UserID uuid.UUID `db:"user_id" json:"user_id"` Role string `db:"role" json:"role"` Inherited bool `db:"inherited" json:"inherited"` @@ -91,44 +91,44 @@ type ProjektTeamMember struct { CreatedAt time.Time `db:"created_at" json:"created_at"` } -// ProjektTeamMemberWithUser enriches a team row with display fields so the +// ProjectTeamMemberWithUser enriches a team row with display fields so the // UI can render " () — " without a per-row lookup. // Used by TeamService.ListMembers which unions direct + inherited memberships. -type ProjektTeamMemberWithUser struct { - ProjektTeamMember - UserEmail string `db:"user_email" json:"user_email"` - UserDisplayName string `db:"user_display_name" json:"user_display_name"` - UserOffice string `db:"user_office" json:"user_office"` - // InheritedFromID is the ancestor projekt_id the membership came from +type ProjectTeamMemberWithUser struct { + ProjectTeamMember + UserEmail string `db:"user_email" json:"user_email"` + UserDisplayName string `db:"user_display_name" json:"user_display_name"` + UserOffice string `db:"user_office" json:"user_office"` + // InheritedFromID is the ancestor project_id the membership came from // when Inherited=true. NULL for direct rows. InheritedFromID *uuid.UUID `db:"inherited_from_id" json:"inherited_from_id,omitempty"` InheritedFromTitle *string `db:"inherited_from_title" json:"inherited_from_title,omitempty"` } -// Dezernat is one structural partner unit. Dezernat membership is orthogonal -// to project teams — a user typically belongs to exactly one Dezernat but -// may work on projects across all of them. -type Dezernat struct { - ID uuid.UUID `db:"id" json:"id"` - Name string `db:"name" json:"name"` - LeadUserID *uuid.UUID `db:"lead_user_id" json:"lead_user_id,omitempty"` - Office string `db:"office" json:"office"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +// Department is one structural partner unit. Department membership is +// orthogonal to project teams — a user typically belongs to exactly one +// Department but may work on projects across all of them. +type Department struct { + ID uuid.UUID `db:"id" json:"id"` + Name string `db:"name" json:"name"` + LeadUserID *uuid.UUID `db:"lead_user_id" json:"lead_user_id,omitempty"` + Office string `db:"office" json:"office"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } -// DezernatMitglied is one user's membership in a Dezernat. -type DezernatMitglied struct { - DezernatID uuid.UUID `db:"dezernat_id" json:"dezernat_id"` - UserID uuid.UUID `db:"user_id" json:"user_id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` +// DepartmentMember is one user's membership in a Department. +type DepartmentMember struct { + DepartmentID uuid.UUID `db:"department_id" json:"department_id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` } -// ProjektEvent is one row in the per-Projekt audit trail (paliad.projekt_events, -// renamed from paliad.akten_events in migration 018). -type ProjektEvent struct { +// ProjectEvent is one row in the per-Project audit trail +// (paliad.project_events, renamed from paliad.project_events in migration 018). +type ProjectEvent struct { ID uuid.UUID `db:"id" json:"id"` - ProjektID uuid.UUID `db:"projekt_id" json:"projekt_id"` + ProjectID uuid.UUID `db:"project_id" json:"project_id"` EventType *string `db:"event_type" json:"event_type,omitempty"` Title string `db:"title" json:"title"` Description *string `db:"description" json:"description,omitempty"` @@ -139,81 +139,80 @@ type ProjektEvent struct { UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } -// Frist is one persistent deadline attached to a Projekt (typically a case- -// or patent-level node). Visibility is inherited from the parent Projekt via -// paliad.can_see_projekt. -type Frist struct { - ID uuid.UUID `db:"id" json:"id"` - ProjektID uuid.UUID `db:"projekt_id" json:"projekt_id"` - Title string `db:"title" json:"title"` - Description *string `db:"description" json:"description,omitempty"` - DueDate time.Time `db:"due_date" json:"due_date"` - OriginalDueDate *time.Time `db:"original_due_date" json:"original_due_date,omitempty"` - WarningDate *time.Time `db:"warning_date" json:"warning_date,omitempty"` - Source string `db:"source" json:"source"` - RuleID *uuid.UUID `db:"rule_id" json:"rule_id,omitempty"` - Status string `db:"status" json:"status"` - CompletedAt *time.Time `db:"completed_at" json:"completed_at,omitempty"` - CalDAVUID *string `db:"caldav_uid" json:"caldav_uid,omitempty"` - CalDAVEtag *string `db:"caldav_etag" json:"caldav_etag,omitempty"` - Notes *string `db:"notes" json:"notes,omitempty"` - 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"` +// Deadline is one persistent deadline attached to a Project (typically a +// case- or patent-level node). Visibility is inherited from the parent +// Project via paliad.can_see_project. +type Deadline struct { + ID uuid.UUID `db:"id" json:"id"` + ProjectID uuid.UUID `db:"project_id" json:"project_id"` + Title string `db:"title" json:"title"` + Description *string `db:"description" json:"description,omitempty"` + DueDate time.Time `db:"due_date" json:"due_date"` + OriginalDueDate *time.Time `db:"original_due_date" json:"original_due_date,omitempty"` + WarningDate *time.Time `db:"warning_date" json:"warning_date,omitempty"` + Source string `db:"source" json:"source"` + RuleID *uuid.UUID `db:"rule_id" json:"rule_id,omitempty"` + Status string `db:"status" json:"status"` + CompletedAt *time.Time `db:"completed_at" json:"completed_at,omitempty"` + CalDAVUID *string `db:"caldav_uid" json:"caldav_uid,omitempty"` + CalDAVEtag *string `db:"caldav_etag" json:"caldav_etag,omitempty"` + Notes *string `db:"notes" json:"notes,omitempty"` + 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"` } -// FristWithProjekt enriches a Frist with parent-Projekt display fields +// DeadlineWithProject enriches a Deadline with parent-Project display fields // (reference + title) for list views. -type FristWithProjekt struct { - Frist - ProjektReference *string `db:"projekt_reference" json:"projekt_reference,omitempty"` - ProjektTitle string `db:"projekt_title" json:"projekt_title"` - ProjektType string `db:"projekt_type" json:"projekt_type"` +type DeadlineWithProject struct { + Deadline + ProjectReference *string `db:"project_reference" json:"project_reference,omitempty"` + ProjectTitle string `db:"project_title" json:"project_title"` + ProjectType string `db:"project_type" json:"project_type"` RuleCode *string `db:"rule_code" json:"rule_code,omitempty"` } -// Termin is one appointment. projekt_id is nullable: NULL = personal -// (creator-only); set = follows the parent Projekt's team visibility. -type Termin struct { - ID uuid.UUID `db:"id" json:"id"` - ProjektID *uuid.UUID `db:"projekt_id" json:"projekt_id,omitempty"` - Title string `db:"title" json:"title"` - Description *string `db:"description" json:"description,omitempty"` - StartAt time.Time `db:"start_at" json:"start_at"` - EndAt *time.Time `db:"end_at" json:"end_at,omitempty"` - Location *string `db:"location" json:"location,omitempty"` - TerminType *string `db:"termin_type" json:"termin_type,omitempty"` - CalDAVUID *string `db:"caldav_uid" json:"caldav_uid,omitempty"` - CalDAVEtag *string `db:"caldav_etag" json:"caldav_etag,omitempty"` - 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"` +// Appointment is one appointment. project_id is nullable: NULL = personal +// (creator-only); set = follows the parent Project's team visibility. +type Appointment struct { + ID uuid.UUID `db:"id" json:"id"` + ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"` + Title string `db:"title" json:"title"` + Description *string `db:"description" json:"description,omitempty"` + StartAt time.Time `db:"start_at" json:"start_at"` + EndAt *time.Time `db:"end_at" json:"end_at,omitempty"` + Location *string `db:"location" json:"location,omitempty"` + AppointmentType *string `db:"appointment_type" json:"appointment_type,omitempty"` + CalDAVUID *string `db:"caldav_uid" json:"caldav_uid,omitempty"` + CalDAVEtag *string `db:"caldav_etag" json:"caldav_etag,omitempty"` + 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"` } -// TerminWithProjekt enriches a Termin with its parent Projekt display -// fields for list views. All fields nullable because personal Termine have -// no parent. -type TerminWithProjekt struct { - Termin - ProjektReference *string `db:"projekt_reference" json:"projekt_reference,omitempty"` - ProjektTitle *string `db:"projekt_title" json:"projekt_title,omitempty"` - ProjektType *string `db:"projekt_type" json:"projekt_type,omitempty"` +// AppointmentWithProject enriches an Appointment with its parent Project +// display fields for list views. All fields nullable because personal +// Appointments have no parent. +type AppointmentWithProject struct { + Appointment + ProjectReference *string `db:"project_reference" json:"project_reference,omitempty"` + ProjectTitle *string `db:"project_title" json:"project_title,omitempty"` + ProjectType *string `db:"project_type" json:"project_type,omitempty"` } -// Notiz is one polymorphic note attached to exactly one parent row -// (Projekt, Frist, Termin, or ProjektEvent). Visibility follows the parent. -type Notiz struct { - ID uuid.UUID `db:"id" json:"id"` - ProjektID *uuid.UUID `db:"projekt_id" json:"projekt_id,omitempty"` - FristID *uuid.UUID `db:"frist_id" json:"frist_id,omitempty"` - TerminID *uuid.UUID `db:"termin_id" json:"termin_id,omitempty"` - // AktenEventID column name was kept for continuity with the v1 schema; - // the FK now resolves to paliad.projekt_events (renamed in 018). - AktenEventID *uuid.UUID `db:"akten_event_id" json:"akten_event_id,omitempty"` - Content string `db:"content" json:"content"` - 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"` +// Note is one polymorphic note attached to exactly one parent row +// (Project, Deadline, Appointment, or ProjectEvent). Visibility follows the +// parent. +type Note struct { + ID uuid.UUID `db:"id" json:"id"` + ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"` + DeadlineID *uuid.UUID `db:"deadline_id" json:"deadline_id,omitempty"` + AppointmentID *uuid.UUID `db:"appointment_id" json:"appointment_id,omitempty"` + ProjectEventID *uuid.UUID `db:"project_event_id" json:"project_event_id,omitempty"` + Content string `db:"content" json:"content"` + 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"` // Author display fields populated by the service's LEFT JOIN to // paliad.users so the UI can render "von " without a lookup. @@ -222,29 +221,29 @@ type Notiz struct { } // ChecklistInstance is one user's instantiation of a static checklist -// template (defined in internal/checklisten). Checkbox state lives in the +// template (defined in internal/checklists). Checkbox state lives in the // `state` jsonb column. // -// Visibility mirrors Termin: projekt_id nullable. Personal instances -// (projekt_id NULL) are creator-only; Projekt-linked instances follow -// paliad.can_see_projekt. +// Visibility mirrors Appointment: project_id nullable. Personal instances +// (project_id NULL) are creator-only; Project-linked instances follow +// paliad.can_see_project. type ChecklistInstance struct { - ID uuid.UUID `db:"id" json:"id"` - TemplateSlug string `db:"template_slug" json:"template_slug"` - Name string `db:"name" json:"name"` - ProjektID *uuid.UUID `db:"projekt_id" json:"projekt_id,omitempty"` - State json.RawMessage `db:"state" json:"state"` - CreatedBy uuid.UUID `db:"created_by" json:"created_by"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ID uuid.UUID `db:"id" json:"id"` + TemplateSlug string `db:"template_slug" json:"template_slug"` + Name string `db:"name" json:"name"` + ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"` + State json.RawMessage `db:"state" json:"state"` + CreatedBy uuid.UUID `db:"created_by" json:"created_by"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } -// ChecklistInstanceWithProjekt enriches an instance with its parent Projekt +// ChecklistInstanceWithProject enriches an instance with its parent Project // reference fields for list views. -type ChecklistInstanceWithProjekt struct { +type ChecklistInstanceWithProject struct { ChecklistInstance - ProjektReference *string `db:"projekt_reference" json:"projekt_reference,omitempty"` - ProjektTitle *string `db:"projekt_title" json:"projekt_title,omitempty"` + ProjectReference *string `db:"project_reference" json:"project_reference,omitempty"` + ProjectTitle *string `db:"project_title" json:"project_title,omitempty"` } // UserCalDAVConfig holds one user's external CalDAV connection. The password @@ -274,46 +273,46 @@ type CalDAVSyncLogEntry struct { DurationMS *int `db:"duration_ms" json:"duration_ms,omitempty"` } -// Partei is a party to a Projekt (Kläger, Beklagter, etc. — typically on -// a case-level projekt). -type Partei struct { - ID uuid.UUID `db:"id" json:"id"` - ProjektID uuid.UUID `db:"projekt_id" json:"projekt_id"` - Name string `db:"name" json:"name"` - Role *string `db:"role" json:"role,omitempty"` - Representative *string `db:"representative" json:"representative,omitempty"` - ContactInfo json.RawMessage `db:"contact_info" json:"contact_info"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +// Party is a party to a Project (Kläger, Beklagter, etc. — typically on +// a case-level project). +type Party struct { + ID uuid.UUID `db:"id" json:"id"` + ProjectID uuid.UUID `db:"project_id" json:"project_id"` + Name string `db:"name" json:"name"` + Role *string `db:"role" json:"role,omitempty"` + Representative *string `db:"representative" json:"representative,omitempty"` + ContactInfo json.RawMessage `db:"contact_info" json:"contact_info"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } // DeadlineRule is one rule in the proceeding-rule tree (UPC R.023, etc.). type DeadlineRule struct { - ID uuid.UUID `db:"id" json:"id"` - ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"` - ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"` - Code *string `db:"code" json:"code,omitempty"` - Name string `db:"name" json:"name"` - NameEN string `db:"name_en" json:"name_en"` - Description *string `db:"description" json:"description,omitempty"` - PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"` - EventType *string `db:"event_type" json:"event_type,omitempty"` - IsMandatory bool `db:"is_mandatory" json:"is_mandatory"` - DurationValue int `db:"duration_value" json:"duration_value"` - DurationUnit string `db:"duration_unit" json:"duration_unit"` - Timing *string `db:"timing" json:"timing,omitempty"` - RuleCode *string `db:"rule_code" json:"rule_code,omitempty"` - DeadlineNotes *string `db:"deadline_notes" json:"deadline_notes,omitempty"` - SequenceOrder int `db:"sequence_order" json:"sequence_order"` - ConditionRuleID *uuid.UUID `db:"condition_rule_id" json:"condition_rule_id,omitempty"` - AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"` - AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"` - AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"` - IsSpawn bool `db:"is_spawn" json:"is_spawn"` - SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"` - IsActive bool `db:"is_active" json:"is_active"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ID uuid.UUID `db:"id" json:"id"` + ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"` + ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"` + Code *string `db:"code" json:"code,omitempty"` + Name string `db:"name" json:"name"` + NameEN string `db:"name_en" json:"name_en"` + Description *string `db:"description" json:"description,omitempty"` + PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"` + EventType *string `db:"event_type" json:"event_type,omitempty"` + IsMandatory bool `db:"is_mandatory" json:"is_mandatory"` + DurationValue int `db:"duration_value" json:"duration_value"` + DurationUnit string `db:"duration_unit" json:"duration_unit"` + Timing *string `db:"timing" json:"timing,omitempty"` + RuleCode *string `db:"rule_code" json:"rule_code,omitempty"` + DeadlineNotes *string `db:"deadline_notes" json:"deadline_notes,omitempty"` + SequenceOrder int `db:"sequence_order" json:"sequence_order"` + ConditionRuleID *uuid.UUID `db:"condition_rule_id" json:"condition_rule_id,omitempty"` + AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"` + AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"` + AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"` + IsSpawn bool `db:"is_spawn" json:"is_spawn"` + SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"` + IsActive bool `db:"is_active" json:"is_active"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } // ProceedingType is one of INF/REV/CCR/APM/APP/AMD/ZPO_CIVIL (matter diff --git a/internal/services/termin_service.go b/internal/services/appointment_service.go similarity index 51% rename from internal/services/termin_service.go rename to internal/services/appointment_service.go index 8fb2667..a2cbddb 100644 --- a/internal/services/termin_service.go +++ b/internal/services/appointment_service.go @@ -14,89 +14,89 @@ import ( "mgit.msbls.de/m/patholo/internal/models" ) -// TerminService reads and writes paliad.termine. +// AppointmentService reads and writes paliad.appointments. // // Visibility: -// - projekt_id IS NULL → personal Termin, visible/editable only to created_by -// - projekt_id IS NOT NULL → follows ProjektService.GetByID team gate +// - project_id IS NULL → personal Appointment, visible/editable only to created_by +// - project_id IS NOT NULL → follows ProjectService.GetByID team gate // -// Audit: Projekt-attached mutations append projekt_events rows. Personal -// Termine never touch projekt_events. +// Audit: Project-attached mutations append project_events rows. Personal +// Appointments never touch project_events. // // CalDAV: optional hook (TerminCalDAVPusher) is called best-effort after // each mutation. -type TerminService struct { +type AppointmentService struct { db *sqlx.DB - projekte *ProjektService + projects *ProjectService caldav TerminCalDAVPusher } // TerminCalDAVPusher is the contract the CalDAV service implements so the -// TerminService can push individual termin changes without importing the +// AppointmentService can push individual appointment changes without importing the // caldav package directly. type TerminCalDAVPusher interface { - OnTerminCreated(ctx context.Context, userID uuid.UUID, t *models.Termin) - OnTerminUpdated(ctx context.Context, userID uuid.UUID, t *models.Termin) - OnTerminDeleted(ctx context.Context, userID uuid.UUID, t *models.Termin) + OnTerminCreated(ctx context.Context, userID uuid.UUID, t *models.Appointment) + OnTerminUpdated(ctx context.Context, userID uuid.UUID, t *models.Appointment) + OnTerminDeleted(ctx context.Context, userID uuid.UUID, t *models.Appointment) } -func NewTerminService(db *sqlx.DB, projekte *ProjektService) *TerminService { - return &TerminService{db: db, projekte: projekte} +func NewAppointmentService(db *sqlx.DB, projects *ProjectService) *AppointmentService { + return &AppointmentService{db: db, projects: projects} } // SetCalDAVPusher wires an optional CalDAV push hook. -func (s *TerminService) SetCalDAVPusher(p TerminCalDAVPusher) { +func (s *AppointmentService) SetCalDAVPusher(p TerminCalDAVPusher) { s.caldav = p } -const terminColumns = `id, projekt_id, title, description, start_at, end_at, - location, termin_type, caldav_uid, caldav_etag, created_by, +const terminColumns = `id, project_id, title, description, start_at, end_at, + location, appointment_type, caldav_uid, caldav_etag, created_by, created_at, updated_at` -// CreateTerminInput is the payload for POST /api/termine. +// CreateTerminInput is the payload for POST /api/appointments. type CreateTerminInput struct { - ProjektID *uuid.UUID `json:"projekt_id,omitempty"` + ProjectID *uuid.UUID `json:"project_id,omitempty"` Title string `json:"title"` Description *string `json:"description,omitempty"` StartAt time.Time `json:"start_at"` EndAt *time.Time `json:"end_at,omitempty"` Location *string `json:"location,omitempty"` - TerminType *string `json:"termin_type,omitempty"` + AppointmentType *string `json:"appointment_type,omitempty"` } -// UpdateTerminInput is the partial-update payload for PATCH /api/termine/{id}. +// UpdateTerminInput is the partial-update payload for PATCH /api/appointments/{id}. type UpdateTerminInput struct { Title *string `json:"title,omitempty"` Description *string `json:"description,omitempty"` StartAt *time.Time `json:"start_at,omitempty"` EndAt *time.Time `json:"end_at,omitempty"` Location *string `json:"location,omitempty"` - TerminType *string `json:"termin_type,omitempty"` + AppointmentType *string `json:"appointment_type,omitempty"` } // TerminListFilter narrows ListVisibleForUser results. type TerminListFilter struct { - ProjektID *uuid.UUID + ProjectID *uuid.UUID From *time.Time To *time.Time Type *string } -// ListVisibleForUser returns all Termine the user can see (personal + -// Projekt-attached they have visibility for), ordered by start_at ascending. -func (s *TerminService) ListVisibleForUser(ctx context.Context, userID uuid.UUID, filter TerminListFilter) ([]models.TerminWithProjekt, error) { +// ListVisibleForUser returns all Appointments the user can see (personal + +// Project-attached they have visibility for), ordered by start_at ascending. +func (s *AppointmentService) ListVisibleForUser(ctx context.Context, userID uuid.UUID, filter TerminListFilter) ([]models.AppointmentWithProject, error) { user, err := s.users().GetByID(ctx, userID) if err != nil { return nil, err } if user == nil { - return []models.TerminWithProjekt{}, nil + return []models.AppointmentWithProject{}, nil } visibility := `( - (t.projekt_id IS NULL AND t.created_by = :user_id) - OR (t.projekt_id IS NOT NULL AND ` + visibilityPredicate("p") + `) + (t.project_id IS NULL AND t.created_by = :user_id) + OR (t.project_id IS NOT NULL AND ` + visibilityPredicate("p") + `) )` conds := []string{visibility} args := map[string]any{ @@ -104,9 +104,9 @@ func (s *TerminService) ListVisibleForUser(ctx context.Context, userID uuid.UUID "role": user.Role, } - if filter.ProjektID != nil { - conds = append(conds, `t.projekt_id = :projekt_id`) - args["projekt_id"] = *filter.ProjektID + if filter.ProjectID != nil { + conds = append(conds, `t.project_id = :project_id`) + args["project_id"] = *filter.ProjectID } if filter.From != nil { conds = append(conds, `t.start_at >= :from`) @@ -117,64 +117,64 @@ func (s *TerminService) ListVisibleForUser(ctx context.Context, userID uuid.UUID args["to"] = *filter.To } if filter.Type != nil { - if !isValidTerminType(*filter.Type) { - return nil, fmt.Errorf("%w: invalid termin_type %q", ErrInvalidInput, *filter.Type) + if !isValidAppointmentType(*filter.Type) { + return nil, fmt.Errorf("%w: invalid appointment_type %q", ErrInvalidInput, *filter.Type) } - conds = append(conds, `t.termin_type = :type`) + conds = append(conds, `t.appointment_type = :type`) args["type"] = *filter.Type } query := ` - SELECT t.id, t.projekt_id, t.title, t.description, t.start_at, t.end_at, - t.location, t.termin_type, t.caldav_uid, t.caldav_etag, + SELECT t.id, t.project_id, t.title, t.description, t.start_at, t.end_at, + t.location, t.appointment_type, t.caldav_uid, t.caldav_etag, t.created_by, t.created_at, t.updated_at, - p.reference AS projekt_reference, - p.title AS projekt_title, - p.type AS projekt_type - FROM paliad.termine t - LEFT JOIN paliad.projekte p ON p.id = t.projekt_id + p.reference AS project_reference, + p.title AS project_title, + p.type AS project_type + FROM paliad.appointments t + LEFT JOIN paliad.projects p ON p.id = t.project_id WHERE ` + strings.Join(conds, " AND ") + ` ORDER BY t.start_at ASC, t.created_at DESC` stmt, err := s.db.PrepareNamedContext(ctx, query) if err != nil { - return nil, fmt.Errorf("prepare list termine: %w", err) + return nil, fmt.Errorf("prepare list appointments: %w", err) } defer stmt.Close() - var rows []models.TerminWithProjekt + var rows []models.AppointmentWithProject if err := stmt.SelectContext(ctx, &rows, args); err != nil { - return nil, fmt.Errorf("list termine: %w", err) + return nil, fmt.Errorf("list appointments: %w", err) } return rows, nil } -// ListForProjekt returns Termine for a specific Projekt, visibility-checked. -func (s *TerminService) ListForProjekt(ctx context.Context, userID, projektID uuid.UUID) ([]models.Termin, error) { - if _, err := s.projekte.GetByID(ctx, userID, projektID); err != nil { +// ListForProjekt returns Appointments for a specific Project, visibility-checked. +func (s *AppointmentService) ListForProjekt(ctx context.Context, userID, projektID uuid.UUID) ([]models.Appointment, error) { + if _, err := s.projects.GetByID(ctx, userID, projektID); err != nil { return nil, err } - var rows []models.Termin + var rows []models.Appointment if err := s.db.SelectContext(ctx, &rows, `SELECT `+terminColumns+` - FROM paliad.termine - WHERE projekt_id = $1 + FROM paliad.appointments + WHERE project_id = $1 ORDER BY start_at ASC, created_at DESC`, projektID); err != nil { - return nil, fmt.Errorf("list termine for projekt: %w", err) + return nil, fmt.Errorf("list appointments for project: %w", err) } return rows, nil } -// GetByID returns a single Termin if the user has visibility. -func (s *TerminService) GetByID(ctx context.Context, userID, terminID uuid.UUID) (*models.Termin, error) { - var t models.Termin +// GetByID returns a single Appointment if the user has visibility. +func (s *AppointmentService) GetByID(ctx context.Context, userID, terminID uuid.UUID) (*models.Appointment, error) { + var t models.Appointment err := s.db.GetContext(ctx, &t, - `SELECT `+terminColumns+` FROM paliad.termine WHERE id = $1`, terminID) + `SELECT `+terminColumns+` FROM paliad.appointments WHERE id = $1`, terminID) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotVisible } if err != nil { - return nil, fmt.Errorf("fetch termin: %w", err) + return nil, fmt.Errorf("fetch appointment: %w", err) } if !s.canSee(ctx, userID, &t) { @@ -183,9 +183,9 @@ func (s *TerminService) GetByID(ctx context.Context, userID, terminID uuid.UUID) return &t, nil } -// requireMutationRole enforces the partner/admin gate on Projekt-linked -// Termin mutations. The Termin's own creator is also allowed. -func (s *TerminService) requireMutationRole(ctx context.Context, userID uuid.UUID, t *models.Termin) error { +// requireMutationRole enforces the partner/admin gate on Project-linked +// Appointment mutations. The Appointment's own creator is also allowed. +func (s *AppointmentService) requireMutationRole(ctx context.Context, userID uuid.UUID, t *models.Appointment) error { if t.CreatedBy != nil && *t.CreatedBy == userID { return nil } @@ -197,23 +197,23 @@ func (s *TerminService) requireMutationRole(ctx context.Context, userID uuid.UUI return ErrNotVisible } if user.Role != "partner" && user.Role != "admin" { - return fmt.Errorf("%w: only partners/admins can modify Termine on a Projekt", ErrForbidden) + return fmt.Errorf("%w: only partners/admins can modify Appointments on a Project", ErrForbidden) } return nil } -// canSee mirrors the SELECT visibility predicate for one in-memory Termin. -func (s *TerminService) canSee(ctx context.Context, userID uuid.UUID, t *models.Termin) bool { - if t.ProjektID == nil { +// canSee mirrors the SELECT visibility predicate for one in-memory Appointment. +func (s *AppointmentService) canSee(ctx context.Context, userID uuid.UUID, t *models.Appointment) bool { + if t.ProjectID == nil { return t.CreatedBy != nil && *t.CreatedBy == userID } - _, err := s.projekte.GetByID(ctx, userID, *t.ProjektID) + _, err := s.projects.GetByID(ctx, userID, *t.ProjectID) return err == nil } -// Create inserts a Termin. If projekt_id is set, ProjektService visibility -// is enforced and the Projekt's audit trail records the new appointment. -func (s *TerminService) Create(ctx context.Context, userID uuid.UUID, input CreateTerminInput) (*models.Termin, error) { +// Create inserts a Appointment. If project_id is set, ProjectService visibility +// is enforced and the Project's audit trail records the new appointment. +func (s *AppointmentService) Create(ctx context.Context, userID uuid.UUID, input CreateTerminInput) (*models.Appointment, error) { title := strings.TrimSpace(input.Title) if title == "" { return nil, fmt.Errorf("%w: title is required", ErrInvalidInput) @@ -224,12 +224,12 @@ func (s *TerminService) Create(ctx context.Context, userID uuid.UUID, input Crea if input.EndAt != nil && input.EndAt.Before(input.StartAt) { return nil, fmt.Errorf("%w: end_at must be after start_at", ErrInvalidInput) } - if input.TerminType != nil && *input.TerminType != "" && !isValidTerminType(*input.TerminType) { - return nil, fmt.Errorf("%w: invalid termin_type %q", ErrInvalidInput, *input.TerminType) + if input.AppointmentType != nil && *input.AppointmentType != "" && !isValidAppointmentType(*input.AppointmentType) { + return nil, fmt.Errorf("%w: invalid appointment_type %q", ErrInvalidInput, *input.AppointmentType) } - if input.ProjektID != nil { - if _, err := s.projekte.GetByID(ctx, userID, *input.ProjektID); err != nil { + if input.ProjectID != nil { + if _, err := s.projects.GetByID(ctx, userID, *input.ProjectID); err != nil { return nil, err } } @@ -244,25 +244,25 @@ func (s *TerminService) Create(ctx context.Context, userID uuid.UUID, input Crea defer tx.Rollback() if _, err := tx.ExecContext(ctx, - `INSERT INTO paliad.termine - (id, projekt_id, title, description, start_at, end_at, location, - termin_type, created_by, created_at, updated_at) + `INSERT INTO paliad.appointments + (id, project_id, title, description, start_at, end_at, location, + appointment_type, created_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10)`, - id, input.ProjektID, title, input.Description, input.StartAt.UTC(), - nullableUTC(input.EndAt), input.Location, input.TerminType, userID, now, + id, input.ProjectID, title, input.Description, input.StartAt.UTC(), + nullableUTC(input.EndAt), input.Location, input.AppointmentType, userID, now, ); err != nil { - return nil, fmt.Errorf("insert termin: %w", err) + return nil, fmt.Errorf("insert appointment: %w", err) } - if input.ProjektID != nil { - desc := fmt.Sprintf("Termin \u201E%s\u201C angelegt", title) + if input.ProjectID != nil { + desc := fmt.Sprintf("Appointment \u201E%s\u201C angelegt", title) descPtr := &desc - if err := insertProjektEvent(ctx, tx, *input.ProjektID, userID, "termin_created", "Termin angelegt", descPtr); err != nil { + if err := insertProjectEvent(ctx, tx, *input.ProjectID, userID, "termin_created", "Appointment angelegt", descPtr); err != nil { return nil, err } } if err := tx.Commit(); err != nil { - return nil, fmt.Errorf("commit insert termin: %w", err) + return nil, fmt.Errorf("commit insert appointment: %w", err) } t, err := s.GetByID(ctx, userID, id) @@ -276,14 +276,14 @@ func (s *TerminService) Create(ctx context.Context, userID uuid.UUID, input Crea } // Update applies a partial update. -func (s *TerminService) Update(ctx context.Context, userID, terminID uuid.UUID, input UpdateTerminInput) (*models.Termin, error) { +func (s *AppointmentService) Update(ctx context.Context, userID, terminID uuid.UUID, input UpdateTerminInput) (*models.Appointment, error) { current, err := s.GetByID(ctx, userID, terminID) if err != nil { return nil, err } - if current.ProjektID == nil { + if current.ProjectID == nil { if current.CreatedBy == nil || *current.CreatedBy != userID { - return nil, fmt.Errorf("%w: only the creator can edit a personal Termin", ErrForbidden) + return nil, fmt.Errorf("%w: only the creator can edit a personal Appointment", ErrForbidden) } } else if err := s.requireMutationRole(ctx, userID, current); err != nil { return nil, err @@ -317,11 +317,11 @@ func (s *TerminService) Update(ctx context.Context, userID, terminID uuid.UUID, if input.Location != nil { appendSet("location", *input.Location) } - if input.TerminType != nil { - if *input.TerminType != "" && !isValidTerminType(*input.TerminType) { - return nil, fmt.Errorf("%w: invalid termin_type %q", ErrInvalidInput, *input.TerminType) + if input.AppointmentType != nil { + if *input.AppointmentType != "" && !isValidAppointmentType(*input.AppointmentType) { + return nil, fmt.Errorf("%w: invalid appointment_type %q", ErrInvalidInput, *input.AppointmentType) } - appendSet("termin_type", *input.TerminType) + appendSet("appointment_type", *input.AppointmentType) } if len(sets) == 0 { return current, nil @@ -329,7 +329,7 @@ func (s *TerminService) Update(ctx context.Context, userID, terminID uuid.UUID, appendSet("updated_at", time.Now().UTC()) args = append(args, terminID) - query := fmt.Sprintf("UPDATE paliad.termine SET %s WHERE id = $%d", + query := fmt.Sprintf("UPDATE paliad.appointments SET %s WHERE id = $%d", strings.Join(sets, ", "), next) tx, err := s.db.BeginTxx(ctx, nil) @@ -339,18 +339,18 @@ func (s *TerminService) Update(ctx context.Context, userID, terminID uuid.UUID, defer tx.Rollback() if _, err := tx.ExecContext(ctx, query, args...); err != nil { - return nil, fmt.Errorf("update termin: %w", err) + return nil, fmt.Errorf("update appointment: %w", err) } - if current.ProjektID != nil { - desc := fmt.Sprintf("Termin \u201E%s\u201C ge\u00e4ndert", current.Title) + if current.ProjectID != nil { + desc := fmt.Sprintf("Appointment \u201E%s\u201C ge\u00e4ndert", current.Title) descPtr := &desc - if err := insertProjektEvent(ctx, tx, *current.ProjektID, userID, "termin_updated", "Termin ge\u00e4ndert", descPtr); err != nil { + if err := insertProjectEvent(ctx, tx, *current.ProjectID, userID, "termin_updated", "Appointment ge\u00e4ndert", descPtr); err != nil { return nil, err } } if err := tx.Commit(); err != nil { - return nil, fmt.Errorf("commit update termin: %w", err) + return nil, fmt.Errorf("commit update appointment: %w", err) } t, err := s.GetByID(ctx, userID, terminID) if err != nil { @@ -362,15 +362,15 @@ func (s *TerminService) Update(ctx context.Context, userID, terminID uuid.UUID, return t, nil } -// Delete removes a Termin. -func (s *TerminService) Delete(ctx context.Context, userID, terminID uuid.UUID) error { +// Delete removes a Appointment. +func (s *AppointmentService) Delete(ctx context.Context, userID, terminID uuid.UUID) error { current, err := s.GetByID(ctx, userID, terminID) if err != nil { return err } - if current.ProjektID == nil { + if current.ProjectID == nil { if current.CreatedBy == nil || *current.CreatedBy != userID { - return fmt.Errorf("%w: only the creator can delete a personal Termin", ErrForbidden) + return fmt.Errorf("%w: only the creator can delete a personal Appointment", ErrForbidden) } } else if err := s.requireMutationRole(ctx, userID, current); err != nil { return err @@ -383,18 +383,18 @@ func (s *TerminService) Delete(ctx context.Context, userID, terminID uuid.UUID) defer tx.Rollback() if _, err := tx.ExecContext(ctx, - `DELETE FROM paliad.termine WHERE id = $1`, terminID); err != nil { - return fmt.Errorf("delete termin: %w", err) + `DELETE FROM paliad.appointments WHERE id = $1`, terminID); err != nil { + return fmt.Errorf("delete appointment: %w", err) } - if current.ProjektID != nil { - desc := fmt.Sprintf("Termin \u201E%s\u201C gel\u00f6scht", current.Title) + if current.ProjectID != nil { + desc := fmt.Sprintf("Appointment \u201E%s\u201C gel\u00f6scht", current.Title) descPtr := &desc - if err := insertProjektEvent(ctx, tx, *current.ProjektID, userID, "termin_deleted", "Termin gel\u00f6scht", descPtr); err != nil { + if err := insertProjectEvent(ctx, tx, *current.ProjectID, userID, "termin_deleted", "Appointment gel\u00f6scht", descPtr); err != nil { return err } } if err := tx.Commit(); err != nil { - return fmt.Errorf("commit delete termin: %w", err) + return fmt.Errorf("commit delete appointment: %w", err) } if s.caldav != nil { s.caldav.OnTerminDeleted(ctx, userID, current) @@ -402,7 +402,7 @@ func (s *TerminService) Delete(ctx context.Context, userID, terminID uuid.UUID) return nil } -// TerminSummaryCounts buckets visible Termine into today / this_week / later. +// TerminSummaryCounts buckets visible Appointments into today / this_week / later. type TerminSummaryCounts struct { Today int `json:"today"` ThisWeek int `json:"this_week"` @@ -410,8 +410,8 @@ type TerminSummaryCounts struct { Total int `json:"total"` } -// SummaryCounts aggregates Termine by start-date bucket for the user's visible projects. -func (s *TerminService) SummaryCounts(ctx context.Context, userID uuid.UUID) (*TerminSummaryCounts, error) { +// SummaryCounts aggregates Appointments by start-date bucket for the user's visible projects. +func (s *AppointmentService) SummaryCounts(ctx context.Context, userID uuid.UUID) (*TerminSummaryCounts, error) { user, err := s.users().GetByID(ctx, userID) if err != nil { return nil, err @@ -430,15 +430,15 @@ func (s *TerminService) SummaryCounts(ctx context.Context, userID uuid.UUID) (*T COUNT(*) FILTER (WHERE t.start_at >= :tomorrow AND t.start_at < :endweek) AS this_week, COUNT(*) FILTER (WHERE t.start_at >= :endweek) AS later, COUNT(*) FILTER (WHERE t.start_at >= :today) AS total - FROM paliad.termine t - LEFT JOIN paliad.projekte p ON p.id = t.projekt_id + FROM paliad.appointments t + LEFT JOIN paliad.projects p ON p.id = t.project_id WHERE - (t.projekt_id IS NULL AND t.created_by = :user_id) - OR (t.projekt_id IS NOT NULL AND ` + visibilityPredicate("p") + `)` + (t.project_id IS NULL AND t.created_by = :user_id) + OR (t.project_id IS NOT NULL AND ` + visibilityPredicate("p") + `)` stmt, err := s.db.PrepareNamedContext(ctx, query) if err != nil { - return nil, fmt.Errorf("prepare termin summary: %w", err) + return nil, fmt.Errorf("prepare appointment summary: %w", err) } defer stmt.Close() @@ -450,26 +450,26 @@ func (s *TerminService) SummaryCounts(ctx context.Context, userID uuid.UUID) (*T "user_id": userID, "role": user.Role, }); err != nil { - return nil, fmt.Errorf("termin summary: %w", err) + return nil, fmt.Errorf("appointment summary: %w", err) } return &c, nil } // SetCalDAVMeta is called by the CalDAV service after a successful push. -func (s *TerminService) SetCalDAVMeta(ctx context.Context, terminID uuid.UUID, uid, etag string) error { +func (s *AppointmentService) SetCalDAVMeta(ctx context.Context, terminID uuid.UUID, uid, etag string) error { _, err := s.db.ExecContext(ctx, - `UPDATE paliad.termine + `UPDATE paliad.appointments SET caldav_uid = $1, caldav_etag = $2, updated_at = NOW() WHERE id = $3`, uid, etag, terminID) if err != nil { - return fmt.Errorf("update termin caldav meta: %w", err) + return fmt.Errorf("update appointment caldav meta: %w", err) } return nil } -// AllForUser returns every Termin (personal + visible Projekt-attached) the +// AllForUser returns every Appointment (personal + visible Project-attached) the // user owns. Used by the CalDAV push loop. -func (s *TerminService) AllForUser(ctx context.Context, userID uuid.UUID) ([]models.Termin, error) { +func (s *AppointmentService) AllForUser(ctx context.Context, userID uuid.UUID) ([]models.Appointment, error) { user, err := s.users().GetByID(ctx, userID) if err != nil { return nil, err @@ -477,40 +477,40 @@ func (s *TerminService) AllForUser(ctx context.Context, userID uuid.UUID) ([]mod if user == nil { return nil, nil } - rows := []models.Termin{} + rows := []models.Appointment{} query := ` SELECT ` + terminColumns + ` - FROM paliad.termine t - LEFT JOIN paliad.projekte p ON p.id = t.projekt_id + FROM paliad.appointments t + LEFT JOIN paliad.projects p ON p.id = t.project_id WHERE - (t.projekt_id IS NULL AND t.created_by = $1) - OR (t.projekt_id IS NOT NULL AND ($2 = 'admin' OR EXISTS ( - SELECT 1 FROM paliad.projekt_teams pt + (t.project_id IS NULL AND t.created_by = $1) + OR (t.project_id IS NOT NULL AND ($2 = 'admin' OR EXISTS ( + SELECT 1 FROM paliad.project_teams pt WHERE pt.user_id = $1 - AND pt.projekt_id = ANY(string_to_array(p.path, '.')::uuid[]) + AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[]) )))` if err := s.db.SelectContext(ctx, &rows, query, userID, user.Role); err != nil { - return nil, fmt.Errorf("all termine for user: %w", err) + return nil, fmt.Errorf("all appointments for user: %w", err) } return rows, nil } -// FindByCalDAVUID resolves a Termin from its external UID. -func (s *TerminService) FindByCalDAVUID(ctx context.Context, uid string) (*models.Termin, error) { - var t models.Termin +// FindByCalDAVUID resolves a Appointment from its external UID. +func (s *AppointmentService) FindByCalDAVUID(ctx context.Context, uid string) (*models.Appointment, error) { + var t models.Appointment err := s.db.GetContext(ctx, &t, - `SELECT `+terminColumns+` FROM paliad.termine WHERE caldav_uid = $1`, uid) + `SELECT `+terminColumns+` FROM paliad.appointments WHERE caldav_uid = $1`, uid) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotVisible } if err != nil { - return nil, fmt.Errorf("find termin by caldav uid: %w", err) + return nil, fmt.Errorf("find appointment by caldav uid: %w", err) } return &t, nil } // ApplyRemoteUpdate writes pulled CalDAV changes into the local row. -func (s *TerminService) ApplyRemoteUpdate(ctx context.Context, terminID uuid.UUID, title, description, location *string, startAt, endAt *time.Time, etag string) (bool, error) { +func (s *AppointmentService) ApplyRemoteUpdate(ctx context.Context, terminID uuid.UUID, title, description, location *string, startAt, endAt *time.Time, etag string) (bool, error) { sets := []string{"caldav_etag = $1", "updated_at = NOW()"} args := []any{etag} next := 2 @@ -546,53 +546,53 @@ func (s *TerminService) ApplyRemoteUpdate(ctx context.Context, terminID uuid.UUI changed = true } args = append(args, terminID) - query := fmt.Sprintf("UPDATE paliad.termine SET %s WHERE id = $%d", + query := fmt.Sprintf("UPDATE paliad.appointments SET %s WHERE id = $%d", strings.Join(sets, ", "), next) if _, err := s.db.ExecContext(ctx, query, args...); err != nil { - return false, fmt.Errorf("apply remote termin update: %w", err) + return false, fmt.Errorf("apply remote appointment update: %w", err) } return changed, nil } -// DeleteByCalDAVUID removes a Termin pulled-deleted from the remote calendar. -func (s *TerminService) DeleteByCalDAVUID(ctx context.Context, uid string) error { +// DeleteByCalDAVUID removes a Appointment pulled-deleted from the remote calendar. +func (s *AppointmentService) DeleteByCalDAVUID(ctx context.Context, uid string) error { _, err := s.db.ExecContext(ctx, - `DELETE FROM paliad.termine WHERE caldav_uid = $1`, uid) + `DELETE FROM paliad.appointments WHERE caldav_uid = $1`, uid) if err != nil { - return fmt.Errorf("delete termin by caldav uid: %w", err) + return fmt.Errorf("delete appointment by caldav uid: %w", err) } return nil } -// LogConflict appends a conflict event to the parent Projekt's audit trail. -// No-op for personal Termine. -func (s *TerminService) LogConflict(ctx context.Context, terminID uuid.UUID, msg string) error { +// LogConflict appends a conflict event to the parent Project's audit trail. +// No-op for personal Appointments. +func (s *AppointmentService) LogConflict(ctx context.Context, terminID uuid.UUID, msg string) error { var row struct { - ProjektID *uuid.UUID `db:"projekt_id"` + ProjectID *uuid.UUID `db:"project_id"` CreatedBy *uuid.UUID `db:"created_by"` } err := s.db.GetContext(ctx, &row, - `SELECT projekt_id, created_by FROM paliad.termine WHERE id = $1`, terminID) - if err != nil || row.ProjektID == nil { + `SELECT project_id, created_by FROM paliad.appointments WHERE id = $1`, terminID) + if err != nil || row.ProjectID == nil { return nil //nolint:nilerr } now := time.Now().UTC() desc := msg _, err = s.db.ExecContext(ctx, - `INSERT INTO paliad.projekt_events - (id, projekt_id, event_type, title, description, event_date, + `INSERT INTO paliad.project_events + (id, project_id, event_type, title, description, event_date, created_by, metadata, created_at, updated_at) VALUES ($1, $2, 'caldav_conflict', 'CalDAV conflict', $3, $4, $5, '{}', $4, $4)`, - uuid.New(), *row.ProjektID, desc, now, row.CreatedBy) + uuid.New(), *row.ProjectID, desc, now, row.CreatedBy) if err != nil { return fmt.Errorf("insert caldav conflict event: %w", err) } return nil } -// users returns the shared user service via the Projekt handle. -func (s *TerminService) users() *UserService { - return s.projekte.Users() +// users returns the shared user service via the Project handle. +func (s *AppointmentService) users() *UserService { + return s.projects.Users() } func nullableUTC(t *time.Time) any { @@ -603,7 +603,7 @@ func nullableUTC(t *time.Time) any { return u } -func isValidTerminType(t string) bool { +func isValidAppointmentType(t string) bool { switch t { case "hearing", "meeting", "consultation", "deadline_hearing": return true diff --git a/internal/services/caldav_ical.go b/internal/services/caldav_ical.go index e51333a..998a7e8 100644 --- a/internal/services/caldav_ical.go +++ b/internal/services/caldav_ical.go @@ -12,7 +12,7 @@ import ( // Minimal RFC 5545 (iCalendar) writer + reader for VEVENT blocks. // // Why hand-rolled instead of github.com/emersion/go-ical? -// - The Termin schema is small (Title, Description, Location, Start, End, +// - The Appointment schema is small (Title, Description, Location, Start, End, // plus the UID Paliad generates) so a 100-line formatter does the job. // - Avoids two third-party dependencies (go-ical + go-webdav) for ~6 // iCal properties and 4 WebDAV verbs. @@ -22,22 +22,22 @@ import ( // (folding is optional per §3.1). const ( - calProductID = "-//Paliad//Paliad Termine//EN" + calProductID = "-//Paliad//Paliad Appointments//EN" calVersion = "2.0" icalDateUTC = "20060102T150405Z" ) -// terminUID is the canonical CalDAV UID for a Paliad Termin. Paliad-owned +// terminUID is the canonical CalDAV UID for a Paliad Appointment. Paliad-owned // events round-trip through this format; foreign events have arbitrary // UIDs and are ignored on pull. func terminUID(id string) string { - return "paliad-termin-" + id + "@paliad.de" + return "paliad-appointment-" + id + "@paliad.de" } -// extractTerminID returns the Paliad Termin id (uuid string) embedded in a +// extractAppointmentID returns the Paliad Appointment id (uuid string) embedded in a // terminUID, or "" when the UID isn't ours. -func extractTerminID(uid string) string { - const prefix = "paliad-termin-" +func extractAppointmentID(uid string) string { + const prefix = "paliad-appointment-" const suffix = "@paliad.de" if !strings.HasPrefix(uid, prefix) || !strings.HasSuffix(uid, suffix) { return "" @@ -45,9 +45,9 @@ func extractTerminID(uid string) string { return uid[len(prefix) : len(uid)-len(suffix)] } -// formatTermin renders a single VCALENDAR + VEVENT for a Termin. Output +// formatTermin renders a single VCALENDAR + VEVENT for a Appointment. Output // uses CRLF line endings as required by RFC 5545. -func formatTermin(t *models.Termin) string { +func formatTermin(t *models.Appointment) string { var b strings.Builder w := func(line string) { b.WriteString(line) diff --git a/internal/services/caldav_service.go b/internal/services/caldav_service.go index 2d18cb2..aa29f50 100644 --- a/internal/services/caldav_service.go +++ b/internal/services/caldav_service.go @@ -15,11 +15,11 @@ import ( "mgit.msbls.de/m/patholo/internal/models" ) -// CalDAVService — bidirectional CalDAV sync for paliad.termine. +// CalDAVService — bidirectional CalDAV sync for paliad.appointments. // // Per-user goroutine model: // - One goroutine per user with enabled = true and a usable cipher -// - Tick every 60s: push local Termine, then pull remote events +// - Tick every 60s: push local Appointments, then pull remote events // - Spawned on Start() (server boot) and on each PUT /api/caldav-config // - Torn down on DELETE /api/caldav-config // @@ -27,7 +27,7 @@ import ( // remote etag has changed, the remote payload overwrites the local row; // when local mutated more recently, the next push overwrites the remote. // Akte-attached conflicts append a row to akten_events (via -// TerminService.LogConflict) so the audit trail records the change. +// AppointmentService.LogConflict) so the audit trail records the change. // // Audit §1.3 fix: passwords are read from paliad.user_caldav_config in // AES-GCM-encrypted form and decrypted only inside this service. They @@ -36,7 +36,7 @@ import ( type CalDAVService struct { db *sqlx.DB cipher *CalDAVCipher - termine *TerminService + appointments *AppointmentService mu sync.Mutex cancels map[uuid.UUID]context.CancelFunc // userID -> goroutine cancel @@ -47,11 +47,11 @@ type CalDAVService struct { // NewCalDAVService wires the service. cipher may be nil — in that case all // operations return ErrCalDAVNoKey and the goroutines are never spawned. -func NewCalDAVService(db *sqlx.DB, cipher *CalDAVCipher, termine *TerminService) *CalDAVService { +func NewCalDAVService(db *sqlx.DB, cipher *CalDAVCipher, appointments *AppointmentService) *CalDAVService { return &CalDAVService{ db: db, cipher: cipher, - termine: termine, + appointments: appointments, cancels: map[uuid.UUID]context.CancelFunc{}, } } @@ -375,24 +375,24 @@ func (s *CalDAVService) syncOnce(ctx context.Context, userID uuid.UUID) (int, in return pushed, pulled, nil } -// pushAll uploads every visible Termin to the user's external calendar. +// pushAll uploads every visible Appointment to the user's external calendar. // Best effort: a single failed PUT logs and continues. func (s *CalDAVService) pushAll(ctx context.Context, cli *calDAVClient, cfg *decryptedConfig, userID uuid.UUID) (int, error) { - termine, err := s.termine.AllForUser(ctx, userID) + appointments, err := s.appointments.AllForUser(ctx, userID) if err != nil { return 0, err } pushed := 0 - for i := range termine { - t := &termine[i] + for i := range appointments { + t := &appointments[i] body := formatTermin(t) etag, err := cli.PutEvent(ctx, cfg.CalendarPath, terminUID(t.ID.String()), body) if err != nil { - slog.Warn("CalDAV: push termin failed", "id", t.ID, "error", err) + slog.Warn("CalDAV: push appointment failed", "id", t.ID, "error", err) continue } uid := terminUID(t.ID.String()) - if err := s.termine.SetCalDAVMeta(ctx, t.ID, uid, etag); err != nil { + if err := s.appointments.SetCalDAVMeta(ctx, t.ID, uid, etag); err != nil { slog.Warn("CalDAV: write meta failed", "id", t.ID, "error", err) continue } @@ -401,12 +401,12 @@ func (s *CalDAVService) pushAll(ctx context.Context, cli *calDAVClient, cfg *dec return pushed, nil } -// pullAll inspects the remote calendar and reconciles local Termine. UIDs -// outside the Paliad namespace (paliad-termin-*@paliad.de) are ignored. +// pullAll inspects the remote calendar and reconciles local Appointments. UIDs +// outside the Paliad namespace (paliad-appointment-*@paliad.de) are ignored. // // Reconciliation rules: -// - UID matches a known Termin + ETag changed → ApplyRemoteUpdate -// - UID matches a known Termin + ETag unchanged → no-op +// - UID matches a known Appointment + ETag changed → ApplyRemoteUpdate +// - UID matches a known Appointment + ETag unchanged → no-op // - Locally-known UID NOT in remote list → DeleteByCalDAVUID // // Foreign-UID events are intentionally not imported in v1 — Paliad @@ -433,7 +433,7 @@ func (s *CalDAVService) pullAll(ctx context.Context, cli *calDAVClient, cfg *dec continue } for _, ev := range events { - id := extractTerminID(ev.UID) + id := extractAppointmentID(ev.UID) if id == "" { continue } @@ -442,7 +442,7 @@ func (s *CalDAVService) pullAll(ctx context.Context, cli *calDAVClient, cfg *dec if _, err := uuid.Parse(id); err != nil { continue } - local, err := s.termine.FindByCalDAVUID(ctx, ev.UID) + local, err := s.appointments.FindByCalDAVUID(ctx, ev.UID) if err != nil { continue // local row not yet created or deleted } @@ -467,20 +467,20 @@ func (s *CalDAVService) pullAll(ctx context.Context, cli *calDAVClient, cfg *dec ls := ev.Location locPtr = &ls } - changed, err := s.termine.ApplyRemoteUpdate(ctx, local.ID, titlePtr, descPtr, locPtr, ev.DTStart, ev.DTEnd, e.ETag) + changed, err := s.appointments.ApplyRemoteUpdate(ctx, local.ID, titlePtr, descPtr, locPtr, ev.DTStart, ev.DTEnd, e.ETag) if err != nil { slog.Warn("CalDAV: apply remote update failed", "id", local.ID, "error", err) continue } if changed { - _ = s.termine.LogConflict(ctx, local.ID, "Termin from external calendar synced (last-write-wins)") + _ = s.appointments.LogConflict(ctx, local.ID, "Appointment from external calendar synced (last-write-wins)") } pulled++ } } // Detect remote deletions for this user's Paliad-owned events. - all, err := s.termine.AllForUser(ctx, userID) + all, err := s.appointments.AllForUser(ctx, userID) if err == nil { for i := range all { t := &all[i] @@ -491,7 +491,7 @@ func (s *CalDAVService) pullAll(ctx context.Context, cli *calDAVClient, cfg *dec continue } // Remote no longer has this UID — pull-delete. - if err := s.termine.DeleteByCalDAVUID(ctx, *t.CalDAVUID); err != nil { + if err := s.appointments.DeleteByCalDAVUID(ctx, *t.CalDAVUID); err != nil { slog.Warn("CalDAV: pull-delete failed", "uid", *t.CalDAVUID, "error", err) continue } @@ -507,15 +507,15 @@ func (s *CalDAVService) pullAll(ctx context.Context, cli *calDAVClient, cfg *dec // TerminCalDAVPusher interface. They schedule a one-shot best-effort sync // for the relevant user on a fresh background goroutine so the // user-facing request returns immediately. -func (s *CalDAVService) OnTerminCreated(_ context.Context, userID uuid.UUID, t *models.Termin) { +func (s *CalDAVService) OnTerminCreated(_ context.Context, userID uuid.UUID, t *models.Appointment) { s.fireSync(userID, t, "create") } -func (s *CalDAVService) OnTerminUpdated(_ context.Context, userID uuid.UUID, t *models.Termin) { +func (s *CalDAVService) OnTerminUpdated(_ context.Context, userID uuid.UUID, t *models.Appointment) { s.fireSync(userID, t, "update") } -func (s *CalDAVService) OnTerminDeleted(_ context.Context, userID uuid.UUID, t *models.Termin) { +func (s *CalDAVService) OnTerminDeleted(_ context.Context, userID uuid.UUID, t *models.Appointment) { if !s.Enabled() { return } @@ -533,7 +533,7 @@ func (s *CalDAVService) OnTerminDeleted(_ context.Context, userID uuid.UUID, t * }(terminUID(t.ID.String())) } -func (s *CalDAVService) fireSync(userID uuid.UUID, t *models.Termin, op string) { +func (s *CalDAVService) fireSync(userID uuid.UUID, t *models.Appointment, op string) { if !s.Enabled() { return } @@ -551,7 +551,7 @@ func (s *CalDAVService) fireSync(userID uuid.UUID, t *models.Termin, op string) slog.Warn("CalDAV: hook push failed", "op", op, "id", t.ID, "error", err) return } - if err := s.termine.SetCalDAVMeta(ctx, t.ID, terminUID(t.ID.String()), etag); err != nil { + if err := s.appointments.SetCalDAVMeta(ctx, t.ID, terminUID(t.ID.String()), etag); err != nil { slog.Warn("CalDAV: hook write meta failed", "id", t.ID, "error", err) } }() diff --git a/internal/services/checklist_instance_service.go b/internal/services/checklist_instance_service.go index d208aef..64b7517 100644 --- a/internal/services/checklist_instance_service.go +++ b/internal/services/checklist_instance_service.go @@ -12,64 +12,64 @@ import ( "github.com/google/uuid" "github.com/jmoiron/sqlx" - "mgit.msbls.de/m/patholo/internal/checklisten" + "mgit.msbls.de/m/patholo/internal/checklists" "mgit.msbls.de/m/patholo/internal/models" ) // ChecklistInstanceService reads and writes paliad.checklist_instances. // -// Visibility mirrors paliad.termine (projekt_id nullable): -// - projekt_id NULL → creator-only (personal instance) -// - projekt_id NOT NULL → parent Projekt's team-based gate +// Visibility mirrors paliad.appointments (project_id nullable): +// - project_id NULL → creator-only (personal instance) +// - project_id NOT NULL → parent Project's team-based gate type ChecklistInstanceService struct { db *sqlx.DB - projekte *ProjektService + projects *ProjectService } -func NewChecklistInstanceService(db *sqlx.DB, projekte *ProjektService) *ChecklistInstanceService { - return &ChecklistInstanceService{db: db, projekte: projekte} +func NewChecklistInstanceService(db *sqlx.DB, projects *ProjectService) *ChecklistInstanceService { + return &ChecklistInstanceService{db: db, projects: projects} } -const checklistInstanceColumns = `ci.id, ci.template_slug, ci.name, ci.projekt_id, ci.state, +const checklistInstanceColumns = `ci.id, ci.template_slug, ci.name, ci.project_id, ci.state, ci.created_by, ci.created_at, ci.updated_at` const checklistInstanceWithProjektSelect = `SELECT ` + checklistInstanceColumns + `, - p.reference AS projekt_reference, - p.title AS projekt_title + p.reference AS project_reference, + p.title AS project_title FROM paliad.checklist_instances ci - LEFT JOIN paliad.projekte p ON p.id = ci.projekt_id` + LEFT JOIN paliad.projects p ON p.id = ci.project_id` // CreateInstanceInput is the POST body for creating a new instance. type CreateInstanceInput struct { Name string `json:"name"` - ProjektID *uuid.UUID `json:"projekt_id,omitempty"` + ProjectID *uuid.UUID `json:"project_id,omitempty"` } // UpdateInstanceInput is the PATCH body. Any subset of fields may be set. type UpdateInstanceInput struct { Name *string `json:"name,omitempty"` - ProjektID *uuid.UUID `json:"projekt_id,omitempty"` + ProjectID *uuid.UUID `json:"project_id,omitempty"` State map[string]bool `json:"state,omitempty"` ClearProjekt bool `json:"clear_projekt,omitempty"` } // ListForTemplate returns every visible instance of a given template. -func (s *ChecklistInstanceService) ListForTemplate(ctx context.Context, userID uuid.UUID, slug string) ([]models.ChecklistInstanceWithProjekt, error) { - if _, ok := checklisten.Find(slug); !ok { +func (s *ChecklistInstanceService) ListForTemplate(ctx context.Context, userID uuid.UUID, slug string) ([]models.ChecklistInstanceWithProject, error) { + if _, ok := checklists.Find(slug); !ok { return nil, fmt.Errorf("%w: unknown template slug %q", ErrInvalidInput, slug) } - user, err := s.projekte.Users().GetByID(ctx, userID) + user, err := s.projects.Users().GetByID(ctx, userID) if err != nil { return nil, err } if user == nil { - return []models.ChecklistInstanceWithProjekt{}, nil + return []models.ChecklistInstanceWithProject{}, nil } query := checklistInstanceWithProjektSelect + ` WHERE ci.template_slug = :slug AND ( - (ci.projekt_id IS NULL AND ci.created_by = :user_id) - OR (ci.projekt_id IS NOT NULL AND ` + visibilityPredicate("p") + `) + (ci.project_id IS NULL AND ci.created_by = :user_id) + OR (ci.project_id IS NOT NULL AND ` + visibilityPredicate("p") + `) ) ORDER BY ci.updated_at DESC` args := map[string]any{ @@ -80,15 +80,15 @@ func (s *ChecklistInstanceService) ListForTemplate(ctx context.Context, userID u return s.listWithProjekt(ctx, query, args) } -// ListForProjekt returns every visible instance attached to a Projekt. -func (s *ChecklistInstanceService) ListForProjekt(ctx context.Context, userID, projektID uuid.UUID) ([]models.ChecklistInstanceWithProjekt, error) { - if _, err := s.projekte.GetByID(ctx, userID, projektID); err != nil { +// ListForProjekt returns every visible instance attached to a Project. +func (s *ChecklistInstanceService) ListForProjekt(ctx context.Context, userID, projektID uuid.UUID) ([]models.ChecklistInstanceWithProject, error) { + if _, err := s.projects.GetByID(ctx, userID, projektID); err != nil { return nil, err } query := checklistInstanceWithProjektSelect + ` - WHERE ci.projekt_id = :projekt_id + WHERE ci.project_id = :project_id ORDER BY ci.updated_at DESC` - return s.listWithProjekt(ctx, query, map[string]any{"projekt_id": projektID}) + return s.listWithProjekt(ctx, query, map[string]any{"project_id": projektID}) } // GetByID returns a single instance with visibility check applied. @@ -105,7 +105,7 @@ func (s *ChecklistInstanceService) GetByID(ctx context.Context, userID, id uuid. // Create inserts a new instance. func (s *ChecklistInstanceService) Create(ctx context.Context, userID uuid.UUID, slug string, input CreateInstanceInput) (*models.ChecklistInstance, error) { - if _, ok := checklisten.Find(slug); !ok { + if _, ok := checklists.Find(slug); !ok { return nil, fmt.Errorf("%w: unknown template slug %q", ErrInvalidInput, slug) } name := strings.TrimSpace(input.Name) @@ -115,8 +115,8 @@ func (s *ChecklistInstanceService) Create(ctx context.Context, userID uuid.UUID, if len(name) > 200 { return nil, fmt.Errorf("%w: name exceeds 200 characters", ErrInvalidInput) } - if input.ProjektID != nil { - if _, err := s.projekte.GetByID(ctx, userID, *input.ProjektID); err != nil { + if input.ProjectID != nil { + if _, err := s.projects.GetByID(ctx, userID, *input.ProjectID); err != nil { return nil, err } } @@ -132,17 +132,17 @@ func (s *ChecklistInstanceService) Create(ctx context.Context, userID uuid.UUID, if _, err := tx.ExecContext(ctx, `INSERT INTO paliad.checklist_instances - (id, template_slug, name, projekt_id, state, created_by, created_at, updated_at) + (id, template_slug, name, project_id, state, created_by, created_at, updated_at) VALUES ($1, $2, $3, $4, '{}'::jsonb, $5, $6, $6)`, - id, slug, name, input.ProjektID, userID, now, + id, slug, name, input.ProjectID, userID, now, ); err != nil { return nil, fmt.Errorf("insert checklist_instance: %w", err) } - if input.ProjektID != nil { + if input.ProjectID != nil { desc := fmt.Sprintf("Checkliste \u201E%s\u201C angelegt", name) descPtr := &desc - if err := insertProjektEvent(ctx, tx, *input.ProjektID, userID, + if err := insertProjectEvent(ctx, tx, *input.ProjectID, userID, "checkliste_created", "Checkliste angelegt", descPtr); err != nil { return nil, err } @@ -185,14 +185,14 @@ func (s *ChecklistInstanceService) Update(ctx context.Context, userID, id uuid.U var relinkTo *uuid.UUID var unlinking bool if input.ClearProjekt { - appendSet("projekt_id", nil) + appendSet("project_id", nil) unlinking = true - } else if input.ProjektID != nil { - if _, err := s.projekte.GetByID(ctx, userID, *input.ProjektID); err != nil { + } else if input.ProjectID != nil { + if _, err := s.projects.GetByID(ctx, userID, *input.ProjectID); err != nil { return nil, err } - appendSet("projekt_id", *input.ProjektID) - relinkTo = input.ProjektID + appendSet("project_id", *input.ProjectID) + relinkTo = input.ProjectID } if len(input.State) > 0 { @@ -225,24 +225,24 @@ func (s *ChecklistInstanceService) Update(ctx context.Context, userID, id uuid.U } switch { - case renamedTo != nil && current.ProjektID != nil: + case renamedTo != nil && current.ProjectID != nil: desc := fmt.Sprintf("Checkliste umbenannt: \u201E%s\u201C", *renamedTo) descPtr := &desc - if err := insertProjektEvent(ctx, tx, *current.ProjektID, userID, + if err := insertProjectEvent(ctx, tx, *current.ProjectID, userID, "checkliste_renamed", "Checkliste umbenannt", descPtr); err != nil { return nil, err } - case unlinking && current.ProjektID != nil: - desc := fmt.Sprintf("Checkliste \u201E%s\u201C von Projekt getrennt", current.Name) + case unlinking && current.ProjectID != nil: + desc := fmt.Sprintf("Checkliste \u201E%s\u201C von Project getrennt", current.Name) descPtr := &desc - if err := insertProjektEvent(ctx, tx, *current.ProjektID, userID, - "checkliste_unlinked", "Checkliste von Projekt getrennt", descPtr); err != nil { + if err := insertProjectEvent(ctx, tx, *current.ProjectID, userID, + "checkliste_unlinked", "Checkliste von Project getrennt", descPtr); err != nil { return nil, err } case relinkTo != nil: - desc := fmt.Sprintf("Checkliste \u201E%s\u201C mit Projekt verknüpft", current.Name) + desc := fmt.Sprintf("Checkliste \u201E%s\u201C mit Project verknüpft", current.Name) descPtr := &desc - if err := insertProjektEvent(ctx, tx, *relinkTo, userID, + if err := insertProjectEvent(ctx, tx, *relinkTo, userID, "checkliste_linked", "Checkliste verknüpft", descPtr); err != nil { return nil, err } @@ -273,10 +273,10 @@ func (s *ChecklistInstanceService) Reset(ctx context.Context, userID, id uuid.UU WHERE id = $2`, now, id); err != nil { return nil, fmt.Errorf("reset instance: %w", err) } - if current.ProjektID != nil { + if current.ProjectID != nil { desc := fmt.Sprintf("Checkliste \u201E%s\u201C zurückgesetzt", current.Name) descPtr := &desc - if err := insertProjektEvent(ctx, tx, *current.ProjektID, userID, + if err := insertProjectEvent(ctx, tx, *current.ProjectID, userID, "checkliste_reset", "Checkliste zurückgesetzt", descPtr); err != nil { return nil, err } @@ -294,7 +294,7 @@ func (s *ChecklistInstanceService) Delete(ctx context.Context, userID, id uuid.U return err } if current.CreatedBy != userID { - user, err := s.projekte.Users().GetByID(ctx, userID) + user, err := s.projects.Users().GetByID(ctx, userID) if err != nil { return err } @@ -312,10 +312,10 @@ func (s *ChecklistInstanceService) Delete(ctx context.Context, userID, id uuid.U `DELETE FROM paliad.checklist_instances WHERE id = $1`, id); err != nil { return fmt.Errorf("delete instance: %w", err) } - if current.ProjektID != nil { + if current.ProjectID != nil { desc := fmt.Sprintf("Checkliste \u201E%s\u201C gelöscht", current.Name) descPtr := &desc - if err := insertProjektEvent(ctx, tx, *current.ProjektID, userID, + if err := insertProjectEvent(ctx, tx, *current.ProjectID, userID, "checkliste_deleted", "Checkliste gelöscht", descPtr); err != nil { return err } @@ -325,14 +325,14 @@ func (s *ChecklistInstanceService) Delete(ctx context.Context, userID, id uuid.U // --- internals ------------------------------------------------------------ -func (s *ChecklistInstanceService) listWithProjekt(ctx context.Context, query string, args map[string]any) ([]models.ChecklistInstanceWithProjekt, error) { +func (s *ChecklistInstanceService) listWithProjekt(ctx context.Context, query string, args map[string]any) ([]models.ChecklistInstanceWithProject, error) { stmt, err := s.db.PrepareNamedContext(ctx, query) if err != nil { return nil, fmt.Errorf("prepare list instances: %w", err) } defer stmt.Close() - var rows []models.ChecklistInstanceWithProjekt + var rows []models.ChecklistInstanceWithProject if err := stmt.SelectContext(ctx, &rows, args); err != nil { return nil, fmt.Errorf("list checklist_instances: %w", err) } @@ -342,7 +342,7 @@ func (s *ChecklistInstanceService) listWithProjekt(ctx context.Context, query st func (s *ChecklistInstanceService) getByIDUnchecked(ctx context.Context, id uuid.UUID) (*models.ChecklistInstance, error) { var inst models.ChecklistInstance err := s.db.GetContext(ctx, &inst, - `SELECT id, template_slug, name, projekt_id, state, created_by, created_at, updated_at + `SELECT id, template_slug, name, project_id, state, created_by, created_at, updated_at FROM paliad.checklist_instances WHERE id = $1`, id) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotVisible @@ -354,12 +354,12 @@ func (s *ChecklistInstanceService) getByIDUnchecked(ctx context.Context, id uuid } func (s *ChecklistInstanceService) requireVisible(ctx context.Context, userID uuid.UUID, inst *models.ChecklistInstance) error { - if inst.ProjektID == nil { + if inst.ProjectID == nil { if inst.CreatedBy != userID { return ErrNotVisible } return nil } - _, err := s.projekte.GetByID(ctx, userID, *inst.ProjektID) + _, err := s.projects.GetByID(ctx, userID, *inst.ProjectID) return err } diff --git a/internal/services/dashboard_service.go b/internal/services/dashboard_service.go index ac5a80a..9442a04 100644 --- a/internal/services/dashboard_service.go +++ b/internal/services/dashboard_service.go @@ -1,7 +1,7 @@ package services // DashboardService aggregates the logged-in landing-page payload. Scoped to -// Projekte the caller can see — same predicate as ProjektService (team-based, +// Projects the caller can see — same predicate as ProjectService (team-based, // v2 data model, t-paliad-024). import ( @@ -17,7 +17,7 @@ import ( "mgit.msbls.de/m/patholo/internal/models" ) -// DashboardService reads paliad.projekte/fristen/termine/projekt_events for +// DashboardService reads paliad.projects/deadlines/appointments/project_events for // the Dashboard page. type DashboardService struct { db *sqlx.DB @@ -53,7 +53,7 @@ type DeadlineSummary struct { CompletedThisWeek int `json:"completed_this_week" db:"completed_this_week"` } -// MatterSummary counts visible Projekte by status. Field names kept as +// MatterSummary counts visible Projects by status. Field names kept as // "matter" for JSON API compatibility with the dashboard client. type MatterSummary struct { Active int `json:"active" db:"active"` @@ -61,27 +61,27 @@ type MatterSummary struct { Total int `json:"total" db:"total"` } -// UpcomingDeadline is one row for "Kommende Fristen". +// UpcomingDeadline is one row for "Kommende Deadlines". type UpcomingDeadline struct { ID uuid.UUID `json:"id" db:"id"` Title string `json:"title" db:"title"` DueDate string `json:"due_date" db:"due_date"` - ProjektID uuid.UUID `json:"projekt_id" db:"projekt_id"` - ProjektTitle string `json:"projekt_title" db:"projekt_title"` - ProjektRef string `json:"projekt_ref" db:"projekt_ref"` + ProjectID uuid.UUID `json:"project_id" db:"project_id"` + ProjectTitle string `json:"project_title" db:"project_title"` + ProjectRef string `json:"projekt_ref" db:"projekt_ref"` Urgency string `json:"urgency"` } -// UpcomingAppointment is one row for "Kommende Termine". +// UpcomingAppointment is one row for "Kommende Appointments". type UpcomingAppointment struct { ID uuid.UUID `json:"id" db:"id"` Title string `json:"title" db:"title"` StartAt time.Time `json:"start_at" db:"start_at"` EndAt *time.Time `json:"end_at" db:"end_at"` - Type *string `json:"type" db:"termin_type"` - ProjektID *uuid.UUID `json:"projekt_id" db:"projekt_id"` - ProjektTitle *string `json:"projekt_title" db:"projekt_title"` - ProjektRef *string `json:"projekt_ref" db:"projekt_ref"` + Type *string `json:"type" db:"appointment_type"` + ProjectID *uuid.UUID `json:"project_id" db:"project_id"` + ProjectTitle *string `json:"project_title" db:"project_title"` + ProjectRef *string `json:"projekt_ref" db:"projekt_ref"` } // ActivityEntry is one row in the "Letzte Aktivität" feed. @@ -89,9 +89,9 @@ type ActivityEntry struct { Timestamp time.Time `json:"timestamp" db:"timestamp"` ActorEmail *string `json:"actor_email" db:"actor_email"` ActorName *string `json:"actor_name" db:"actor_name"` - ProjektID uuid.UUID `json:"projekt_id" db:"projekt_id"` - ProjektTitle string `json:"projekt_title" db:"projekt_title"` - ProjektRef string `json:"projekt_ref" db:"projekt_ref"` + ProjectID uuid.UUID `json:"project_id" db:"project_id"` + ProjectTitle string `json:"project_title" db:"project_title"` + ProjectRef string `json:"projekt_ref" db:"projekt_ref"` Action *string `json:"action" db:"action"` Details string `json:"details" db:"details"` Description *string `json:"description" db:"description"` @@ -150,12 +150,12 @@ func (s *DashboardService) loadSummary(ctx context.Context, data *DashboardData, query := ` WITH visible_projekte AS ( SELECT p.id, p.status - FROM paliad.projekte p + FROM paliad.projects p WHERE $2 = 'admin' OR EXISTS ( - SELECT 1 FROM paliad.projekt_teams pt + SELECT 1 FROM paliad.project_teams pt WHERE pt.user_id = $1 - AND pt.projekt_id = ANY(string_to_array(p.path, '.')::uuid[]) + AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[]) ) ), deadline_stats AS ( @@ -164,8 +164,8 @@ deadline_stats AS ( COUNT(*) FILTER (WHERE f.due_date >= $3::date AND f.due_date <= $4::date AND f.status = 'pending') AS this_week, COUNT(*) FILTER (WHERE f.due_date > $4::date AND f.status = 'pending') AS upcoming, COUNT(*) FILTER (WHERE f.status = 'completed' AND f.completed_at >= $5) AS completed_this_week - FROM paliad.fristen f - JOIN visible_projekte v ON v.id = f.projekt_id + FROM paliad.deadlines f + JOIN visible_projekte v ON v.id = f.project_id ), matter_stats AS ( SELECT @@ -200,18 +200,18 @@ func (s *DashboardService) loadUpcomingDeadlines(ctx context.Context, data *Dash SELECT f.id, f.title, to_char(f.due_date, 'YYYY-MM-DD') AS due_date, - p.id AS projekt_id, - p.title AS projekt_title, + p.id AS project_id, + p.title AS project_title, COALESCE(p.reference, '') AS projekt_ref - FROM paliad.fristen f - JOIN paliad.projekte p ON p.id = f.projekt_id + FROM paliad.deadlines f + JOIN paliad.projects p ON p.id = f.project_id WHERE f.status = 'pending' AND f.due_date >= $3::date AND f.due_date <= $4::date AND ($2 = 'admin' OR EXISTS ( - SELECT 1 FROM paliad.projekt_teams pt + SELECT 1 FROM paliad.project_teams pt WHERE pt.user_id = $1 - AND pt.projekt_id = ANY(string_to_array(p.path, '.')::uuid[]) + AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[]) )) ORDER BY f.due_date ASC LIMIT 10` @@ -228,20 +228,20 @@ SELECT t.id, t.title, t.start_at, t.end_at, - t.termin_type, - t.projekt_id, - p.title AS projekt_title, + t.appointment_type, + t.project_id, + p.title AS project_title, COALESCE(p.reference, NULL) AS projekt_ref - FROM paliad.termine t - LEFT JOIN paliad.projekte p ON p.id = t.projekt_id + FROM paliad.appointments t + LEFT JOIN paliad.projects p ON p.id = t.project_id WHERE t.start_at >= $3 AND t.start_at < ($3 + interval '7 days') AND ( - (t.projekt_id IS NULL AND t.created_by = $1) - OR (t.projekt_id IS NOT NULL AND ($2 = 'admin' OR EXISTS ( - SELECT 1 FROM paliad.projekt_teams pt + (t.project_id IS NULL AND t.created_by = $1) + OR (t.project_id IS NOT NULL AND ($2 = 'admin' OR EXISTS ( + SELECT 1 FROM paliad.project_teams pt WHERE pt.user_id = $1 - AND pt.projekt_id = ANY(string_to_array(p.path, '.')::uuid[]) + AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[]) ))) ) ORDER BY t.start_at ASC @@ -258,20 +258,20 @@ func (s *DashboardService) loadRecentActivity(ctx context.Context, data *Dashboa SELECT COALESCE(e.event_date, e.created_at) AS timestamp, u.email AS actor_email, u.display_name AS actor_name, - e.projekt_id, - p.title AS projekt_title, + e.project_id, + p.title AS project_title, COALESCE(p.reference, '') AS projekt_ref, e.event_type AS action, e.title AS details, e.description - FROM paliad.projekt_events e - JOIN paliad.projekte p ON p.id = e.projekt_id + FROM paliad.project_events e + JOIN paliad.projects p ON p.id = e.project_id LEFT JOIN paliad.users u ON u.id = e.created_by WHERE $2 = 'admin' OR EXISTS ( - SELECT 1 FROM paliad.projekt_teams pt + SELECT 1 FROM paliad.project_teams pt WHERE pt.user_id = $1 - AND pt.projekt_id = ANY(string_to_array(p.path, '.')::uuid[]) + AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[]) ) ORDER BY COALESCE(e.event_date, e.created_at) DESC LIMIT 10` diff --git a/internal/services/frist_service.go b/internal/services/deadline_service.go similarity index 62% rename from internal/services/frist_service.go rename to internal/services/deadline_service.go index ba5c7c1..73bcfdb 100644 --- a/internal/services/frist_service.go +++ b/internal/services/deadline_service.go @@ -14,23 +14,23 @@ import ( "mgit.msbls.de/m/patholo/internal/models" ) -// FristService reads and writes paliad.fristen. Visibility inherits from the -// parent Projekt via ProjektService.GetByID — every read or write goes through +// DeadlineService reads and writes paliad.deadlines. Visibility inherits from the +// parent Project via ProjectService.GetByID — every read or write goes through // that gate first. // -// Audit: every mutation appends a paliad.projekt_events row via -// insertProjektEvent so the Projekt verlauf shows what changed. -type FristService struct { +// Audit: every mutation appends a paliad.project_events row via +// insertProjectEvent so the Project verlauf shows what changed. +type DeadlineService struct { db *sqlx.DB - projekte *ProjektService + projects *ProjectService } -// NewFristService wires the service. -func NewFristService(db *sqlx.DB, projekte *ProjektService) *FristService { - return &FristService{db: db, projekte: projekte} +// NewDeadlineService wires the service. +func NewDeadlineService(db *sqlx.DB, projects *ProjectService) *DeadlineService { + return &DeadlineService{db: db, projects: projects} } -const fristColumns = `id, projekt_id, title, description, due_date, original_due_date, +const fristColumns = `id, project_id, title, description, due_date, original_due_date, warning_date, source, rule_id, status, completed_at, caldav_uid, caldav_etag, notes, created_by, created_at, updated_at` @@ -69,18 +69,18 @@ const ( // ListFilter narrows ListVisibleForUser results. type ListFilter struct { Status FristStatusFilter - ProjektID *uuid.UUID + ProjectID *uuid.UUID } -// ListVisibleForUser returns Fristen on every Projekt the user can see, -// joined with parent-Projekt display fields. Sorted by due_date ascending. -func (s *FristService) ListVisibleForUser(ctx context.Context, userID uuid.UUID, filter ListFilter) ([]models.FristWithProjekt, error) { +// ListVisibleForUser returns Deadlines on every Project the user can see, +// joined with parent-Project display fields. Sorted by due_date ascending. +func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UUID, filter ListFilter) ([]models.DeadlineWithProject, error) { user, err := s.users().GetByID(ctx, userID) if err != nil { return nil, err } if user == nil { - return []models.FristWithProjekt{}, nil + return []models.DeadlineWithProject{}, nil } conds := []string{visibilityPredicate("p")} @@ -88,9 +88,9 @@ func (s *FristService) ListVisibleForUser(ctx context.Context, userID uuid.UUID, "user_id": userID, "role": user.Role, } - if filter.ProjektID != nil { - conds = append(conds, `f.projekt_id = :projekt_id`) - args["projekt_id"] = *filter.ProjektID + if filter.ProjectID != nil { + conds = append(conds, `f.project_id = :project_id`) + args["project_id"] = *filter.ProjectID } now := time.Now().UTC() @@ -119,69 +119,69 @@ func (s *FristService) ListVisibleForUser(ctx context.Context, userID uuid.UUID, } query := ` - SELECT f.id, f.projekt_id, f.title, f.description, f.due_date, f.original_due_date, + SELECT f.id, f.project_id, f.title, f.description, f.due_date, f.original_due_date, f.warning_date, f.source, f.rule_id, f.status, f.completed_at, f.caldav_uid, f.caldav_etag, f.notes, f.created_by, f.created_at, f.updated_at, - p.reference AS projekt_reference, - p.title AS projekt_title, - p.type AS projekt_type, + p.reference AS project_reference, + p.title AS project_title, + p.type AS project_type, r.code AS rule_code - FROM paliad.fristen f - JOIN paliad.projekte p ON p.id = f.projekt_id + FROM paliad.deadlines f + JOIN paliad.projects p ON p.id = f.project_id LEFT JOIN paliad.deadline_rules r ON r.id = f.rule_id WHERE ` + strings.Join(conds, " AND ") + ` ORDER BY f.due_date ASC, f.created_at DESC` stmt, err := s.db.PrepareNamedContext(ctx, query) if err != nil { - return nil, fmt.Errorf("prepare list fristen: %w", err) + return nil, fmt.Errorf("prepare list deadlines: %w", err) } defer stmt.Close() - var rows []models.FristWithProjekt + var rows []models.DeadlineWithProject if err := stmt.SelectContext(ctx, &rows, args); err != nil { - return nil, fmt.Errorf("list fristen: %w", err) + return nil, fmt.Errorf("list deadlines: %w", err) } return rows, nil } -// ListForProjekt returns Fristen for a specific Projekt (visibility-checked). -func (s *FristService) ListForProjekt(ctx context.Context, userID, projektID uuid.UUID) ([]models.Frist, error) { - if _, err := s.projekte.GetByID(ctx, userID, projektID); err != nil { +// ListForProjekt returns Deadlines for a specific Project (visibility-checked). +func (s *DeadlineService) ListForProjekt(ctx context.Context, userID, projektID uuid.UUID) ([]models.Deadline, error) { + if _, err := s.projects.GetByID(ctx, userID, projektID); err != nil { return nil, err } - var rows []models.Frist + var rows []models.Deadline if err := s.db.SelectContext(ctx, &rows, `SELECT `+fristColumns+` - FROM paliad.fristen - WHERE projekt_id = $1 + FROM paliad.deadlines + WHERE project_id = $1 ORDER BY due_date ASC, created_at DESC`, projektID); err != nil { - return nil, fmt.Errorf("list fristen for projekt: %w", err) + return nil, fmt.Errorf("list deadlines for project: %w", err) } return rows, nil } -// GetByID returns a single Frist, with parent Projekt visibility checked. -func (s *FristService) GetByID(ctx context.Context, userID, fristID uuid.UUID) (*models.Frist, error) { - projektID, err := s.parentProjektID(ctx, fristID) +// GetByID returns a single Deadline, with parent Project visibility checked. +func (s *DeadlineService) GetByID(ctx context.Context, userID, fristID uuid.UUID) (*models.Deadline, error) { + projektID, err := s.parentProjectID(ctx, fristID) if err != nil { return nil, err } - if _, err := s.projekte.GetByID(ctx, userID, projektID); err != nil { + if _, err := s.projects.GetByID(ctx, userID, projektID); err != nil { return nil, err } - var f models.Frist + var f models.Deadline if err := s.db.GetContext(ctx, &f, - `SELECT `+fristColumns+` FROM paliad.fristen WHERE id = $1`, fristID); err != nil { - return nil, fmt.Errorf("fetch frist: %w", err) + `SELECT `+fristColumns+` FROM paliad.deadlines WHERE id = $1`, fristID); err != nil { + return nil, fmt.Errorf("fetch deadline: %w", err) } return &f, nil } -// Create inserts a single Frist under a Projekt. -func (s *FristService) Create(ctx context.Context, userID, projektID uuid.UUID, input CreateFristInput) (*models.Frist, error) { - if _, err := s.projekte.GetByID(ctx, userID, projektID); err != nil { +// Create inserts a single Deadline under a Project. +func (s *DeadlineService) Create(ctx context.Context, userID, projektID uuid.UUID, input CreateFristInput) (*models.Deadline, error) { + if _, err := s.projects.GetByID(ctx, userID, projektID); err != nil { return nil, err } id, err := s.insert(ctx, userID, projektID, input) @@ -191,13 +191,13 @@ func (s *FristService) Create(ctx context.Context, userID, projektID uuid.UUID, return s.GetByID(ctx, userID, id) } -// CreateBulk inserts multiple Fristen under one Projekt in a single -// transaction (Fristenrechner "Als Frist(en) speichern" flow). -func (s *FristService) CreateBulk(ctx context.Context, userID, projektID uuid.UUID, inputs []CreateFristInput) ([]models.Frist, error) { +// 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, projektID uuid.UUID, inputs []CreateFristInput) ([]models.Deadline, error) { if len(inputs) == 0 { - return nil, fmt.Errorf("%w: at least one Frist is required", ErrInvalidInput) + return nil, fmt.Errorf("%w: at least one Deadline is required", ErrInvalidInput) } - if _, err := s.projekte.GetByID(ctx, userID, projektID); err != nil { + if _, err := s.projects.GetByID(ctx, userID, projektID); err != nil { return nil, err } @@ -216,15 +216,15 @@ func (s *FristService) CreateBulk(ctx context.Context, userID, projektID uuid.UU ids = append(ids, id) } - desc := fmt.Sprintf("%d Fristen aus Fristenrechner übernommen", len(inputs)) - if err := insertProjektEvent(ctx, tx, projektID, userID, "fristen_imported", desc, nil); err != nil { + desc := fmt.Sprintf("%d Deadlines aus Fristenrechner übernommen", len(inputs)) + if err := insertProjectEvent(ctx, tx, projektID, userID, "fristen_imported", desc, nil); err != nil { return nil, err } if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit bulk create: %w", err) } - out := make([]models.Frist, 0, len(ids)) + out := make([]models.Deadline, 0, len(ids)) for _, id := range ids { f, err := s.GetByID(ctx, userID, id) if err != nil { @@ -235,8 +235,8 @@ func (s *FristService) CreateBulk(ctx context.Context, userID, projektID uuid.UU return out, nil } -// Update applies a partial update to a Frist. -func (s *FristService) Update(ctx context.Context, userID, fristID uuid.UUID, input UpdateFristInput) (*models.Frist, error) { +// Update applies a partial update to a Deadline. +func (s *DeadlineService) Update(ctx context.Context, userID, fristID uuid.UUID, input UpdateFristInput) (*models.Deadline, error) { current, err := s.GetByID(ctx, userID, fristID) if err != nil { return nil, err @@ -288,7 +288,7 @@ func (s *FristService) Update(ctx context.Context, userID, fristID uuid.UUID, in appendSet("updated_at", time.Now().UTC()) args = append(args, fristID) - query := fmt.Sprintf("UPDATE paliad.fristen SET %s WHERE id = $%d", + query := fmt.Sprintf("UPDATE paliad.deadlines SET %s WHERE id = $%d", strings.Join(sets, ", "), next) tx, err := s.db.BeginTxx(ctx, nil) @@ -298,22 +298,22 @@ func (s *FristService) Update(ctx context.Context, userID, fristID uuid.UUID, in defer tx.Rollback() if _, err := tx.ExecContext(ctx, query, args...); err != nil { - return nil, fmt.Errorf("update frist: %w", err) + return nil, fmt.Errorf("update deadline: %w", err) } - desc := fmt.Sprintf("Frist \u201E%s\u201C geändert", current.Title) + desc := fmt.Sprintf("Deadline \u201E%s\u201C geändert", current.Title) descPtr := &desc - if err := insertProjektEvent(ctx, tx, current.ProjektID, userID, "frist_updated", "Frist geändert", descPtr); err != nil { + if err := insertProjectEvent(ctx, tx, current.ProjectID, userID, "frist_updated", "Deadline geändert", descPtr); err != nil { return nil, err } if err := tx.Commit(); err != nil { - return nil, fmt.Errorf("commit update frist: %w", err) + return nil, fmt.Errorf("commit update deadline: %w", err) } return s.GetByID(ctx, userID, fristID) } -// Complete marks a Frist as completed. -func (s *FristService) Complete(ctx context.Context, userID, fristID uuid.UUID) (*models.Frist, error) { +// Complete marks a Deadline as completed. +func (s *DeadlineService) Complete(ctx context.Context, userID, fristID uuid.UUID) (*models.Deadline, error) { current, err := s.GetByID(ctx, userID, fristID) if err != nil { return nil, err @@ -330,14 +330,14 @@ func (s *FristService) Complete(ctx context.Context, userID, fristID uuid.UUID) now := time.Now().UTC() if _, err := tx.ExecContext(ctx, - `UPDATE paliad.fristen + `UPDATE paliad.deadlines SET status = 'completed', completed_at = $1, updated_at = $1 WHERE id = $2`, now, fristID); err != nil { - return nil, fmt.Errorf("complete frist: %w", err) + return nil, fmt.Errorf("complete deadline: %w", err) } - desc := fmt.Sprintf("Frist \u201E%s\u201C als erledigt markiert", current.Title) + desc := fmt.Sprintf("Deadline \u201E%s\u201C als erledigt markiert", current.Title) descPtr := &desc - if err := insertProjektEvent(ctx, tx, current.ProjektID, userID, "frist_completed", "Frist erledigt", descPtr); err != nil { + if err := insertProjectEvent(ctx, tx, current.ProjectID, userID, "frist_completed", "Deadline erledigt", descPtr); err != nil { return nil, err } if err := tx.Commit(); err != nil { @@ -346,8 +346,8 @@ func (s *FristService) Complete(ctx context.Context, userID, fristID uuid.UUID) return s.GetByID(ctx, userID, fristID) } -// Delete hard-deletes a Frist. Partner/admin only. -func (s *FristService) Delete(ctx context.Context, userID, fristID uuid.UUID) error { +// Delete hard-deletes a Deadline. Partner/admin only. +func (s *DeadlineService) Delete(ctx context.Context, userID, fristID uuid.UUID) error { user, err := s.users().GetByID(ctx, userID) if err != nil { return err @@ -356,7 +356,7 @@ func (s *FristService) Delete(ctx context.Context, userID, fristID uuid.UUID) er return ErrNotVisible } if user.Role != "partner" && user.Role != "admin" { - return fmt.Errorf("%w: only partners/admins can delete Fristen", ErrForbidden) + return fmt.Errorf("%w: only partners/admins can delete Deadlines", ErrForbidden) } current, err := s.GetByID(ctx, userID, fristID) if err != nil { @@ -370,18 +370,18 @@ func (s *FristService) Delete(ctx context.Context, userID, fristID uuid.UUID) er defer tx.Rollback() if _, err := tx.ExecContext(ctx, - `DELETE FROM paliad.fristen WHERE id = $1`, fristID); err != nil { - return fmt.Errorf("delete frist: %w", err) + `DELETE FROM paliad.deadlines WHERE id = $1`, fristID); err != nil { + return fmt.Errorf("delete deadline: %w", err) } - desc := fmt.Sprintf("Frist \u201E%s\u201C gelöscht", current.Title) + desc := fmt.Sprintf("Deadline \u201E%s\u201C gelöscht", current.Title) descPtr := &desc - if err := insertProjektEvent(ctx, tx, current.ProjektID, userID, "frist_deleted", "Frist gelöscht", descPtr); err != nil { + if err := insertProjectEvent(ctx, tx, current.ProjectID, userID, "frist_deleted", "Deadline gelöscht", descPtr); err != nil { return err } return tx.Commit() } -// SummaryCounts returns traffic-light counts across the user's visible Fristen. +// SummaryCounts returns traffic-light counts across the user's visible Deadlines. type SummaryCounts struct { Overdue int `json:"overdue"` ThisWeek int `json:"this_week"` @@ -390,9 +390,9 @@ type SummaryCounts struct { Total int `json:"total"` } -// SummaryCounts aggregates Fristen by due-date bucket for the user's visible -// projects, optionally scoped to a single Projekt. -func (s *FristService) SummaryCounts(ctx context.Context, userID uuid.UUID, projektID *uuid.UUID) (*SummaryCounts, error) { +// SummaryCounts aggregates Deadlines by due-date bucket for the user's visible +// projects, optionally scoped to a single Project. +func (s *DeadlineService) SummaryCounts(ctx context.Context, userID uuid.UUID, projektID *uuid.UUID) (*SummaryCounts, error) { user, err := s.users().GetByID(ctx, userID) if err != nil { return nil, err @@ -412,8 +412,8 @@ func (s *FristService) SummaryCounts(ctx context.Context, userID uuid.UUID, proj "endweek": endWeek, } if projektID != nil { - conds = append(conds, `f.projekt_id = :projekt_id`) - args["projekt_id"] = *projektID + conds = append(conds, `f.project_id = :project_id`) + args["project_id"] = *projektID } query := ` @@ -423,8 +423,8 @@ func (s *FristService) SummaryCounts(ctx context.Context, userID uuid.UUID, proj COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= :endweek) AS upcoming, COUNT(*) FILTER (WHERE f.status = 'completed') AS completed, COUNT(*) AS total - FROM paliad.fristen f - JOIN paliad.projekte p ON p.id = f.projekt_id + FROM paliad.deadlines f + JOIN paliad.projects p ON p.id = f.project_id WHERE ` + strings.Join(conds, " AND ") stmt, err := s.db.PrepareNamedContext(ctx, query) @@ -435,13 +435,13 @@ func (s *FristService) SummaryCounts(ctx context.Context, userID uuid.UUID, proj var c SummaryCounts if err := stmt.GetContext(ctx, &c, args); err != nil { - return nil, fmt.Errorf("frist summary: %w", err) + return nil, fmt.Errorf("deadline summary: %w", err) } return &c, nil } // insert performs one INSERT in its own transaction. -func (s *FristService) insert(ctx context.Context, userID, projektID uuid.UUID, input CreateFristInput) (uuid.UUID, error) { +func (s *DeadlineService) insert(ctx context.Context, userID, projektID uuid.UUID, input CreateFristInput) (uuid.UUID, error) { tx, err := s.db.BeginTxx(ctx, nil) if err != nil { return uuid.Nil, fmt.Errorf("begin tx: %w", err) @@ -453,19 +453,19 @@ func (s *FristService) insert(ctx context.Context, userID, projektID uuid.UUID, return uuid.Nil, err } - desc := fmt.Sprintf("Frist \u201E%s\u201C angelegt", strings.TrimSpace(input.Title)) + desc := fmt.Sprintf("Deadline \u201E%s\u201C angelegt", strings.TrimSpace(input.Title)) descPtr := &desc - if err := insertProjektEvent(ctx, tx, projektID, userID, "frist_created", "Frist angelegt", descPtr); err != nil { + if err := insertProjectEvent(ctx, tx, projektID, userID, "frist_created", "Deadline angelegt", descPtr); err != nil { return uuid.Nil, err } if err := tx.Commit(); err != nil { - return uuid.Nil, fmt.Errorf("commit insert frist: %w", err) + return uuid.Nil, fmt.Errorf("commit insert deadline: %w", err) } return id, nil } -// insertTx writes one fristen row in an existing transaction. -func (s *FristService) insertTx(ctx context.Context, tx *sqlx.Tx, userID, projektID uuid.UUID, input CreateFristInput) (uuid.UUID, error) { +// insertTx writes one deadlines row in an existing transaction. +func (s *DeadlineService) insertTx(ctx context.Context, tx *sqlx.Tx, userID, projektID uuid.UUID, input CreateFristInput) (uuid.UUID, error) { title := strings.TrimSpace(input.Title) if title == "" { return uuid.Nil, fmt.Errorf("%w: title is required", ErrInvalidInput) @@ -493,36 +493,36 @@ func (s *FristService) insertTx(ctx context.Context, tx *sqlx.Tx, userID, projek id := uuid.New() now := time.Now().UTC() if _, err := tx.ExecContext(ctx, - `INSERT INTO paliad.fristen - (id, projekt_id, title, description, due_date, original_due_date, + `INSERT INTO paliad.deadlines + (id, project_id, title, description, due_date, original_due_date, source, rule_id, status, notes, created_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending', $9, $10, $11, $11)`, id, projektID, title, input.Description, due, orig, source, input.RuleID, input.Notes, userID, now, ); err != nil { - return uuid.Nil, fmt.Errorf("insert frist: %w", err) + return uuid.Nil, fmt.Errorf("insert deadline: %w", err) } return id, nil } -// parentProjektID resolves a Frist's parent Projekt ID without a visibility -// check. Internal only — callers must then gate via ProjektService.GetByID. -func (s *FristService) parentProjektID(ctx context.Context, fristID uuid.UUID) (uuid.UUID, error) { +// parentProjectID resolves a Deadline's parent Project ID without a visibility +// check. Internal only — callers must then gate via ProjectService.GetByID. +func (s *DeadlineService) parentProjectID(ctx context.Context, fristID uuid.UUID) (uuid.UUID, error) { var projektID uuid.UUID err := s.db.GetContext(ctx, &projektID, - `SELECT projekt_id FROM paliad.fristen WHERE id = $1`, fristID) + `SELECT project_id FROM paliad.deadlines WHERE id = $1`, fristID) if errors.Is(err, sql.ErrNoRows) { return uuid.Nil, ErrNotVisible } if err != nil { - return uuid.Nil, fmt.Errorf("lookup frist parent: %w", err) + return uuid.Nil, fmt.Errorf("lookup deadline parent: %w", err) } return projektID, nil } -// users returns the shared user service via the ProjektService handle. -func (s *FristService) users() *UserService { - return s.projekte.Users() +// users returns the shared user service via the ProjectService handle. +func (s *DeadlineService) users() *UserService { + return s.projects.Users() } func isValidFristStatus(st string) bool { diff --git a/internal/services/dezernat_service.go b/internal/services/department_service.go similarity index 68% rename from internal/services/dezernat_service.go rename to internal/services/department_service.go index d56eb4a..b85c969 100644 --- a/internal/services/dezernat_service.go +++ b/internal/services/department_service.go @@ -1,6 +1,6 @@ package services -// DezernatService handles paliad.dezernate + paliad.dezernat_mitglieder — +// DepartmentService handles paliad.departments + paliad.department_members — // the structural partner-led units. Orthogonal to project teams. import ( @@ -18,37 +18,37 @@ import ( "mgit.msbls.de/m/patholo/internal/offices" ) -// DezernatService reads and writes paliad.dezernate. -type DezernatService struct { +// DepartmentService reads and writes paliad.departments. +type DepartmentService struct { db *sqlx.DB users *UserService } -// NewDezernatService wires the service. -func NewDezernatService(db *sqlx.DB, users *UserService) *DezernatService { - return &DezernatService{db: db, users: users} +// NewDepartmentService wires the service. +func NewDepartmentService(db *sqlx.DB, users *UserService) *DepartmentService { + return &DepartmentService{db: db, users: users} } -// CreateDezernatInput is the payload for Create. -type CreateDezernatInput struct { +// CreateDepartmentInput is the payload for Create. +type CreateDepartmentInput struct { Name string `json:"name"` LeadUserID *uuid.UUID `json:"lead_user_id,omitempty"` Office string `json:"office"` } -// UpdateDezernatInput is the partial-update payload. -type UpdateDezernatInput struct { +// UpdateDepartmentInput is the partial-update payload. +type UpdateDepartmentInput struct { Name *string `json:"name,omitempty"` LeadUserID *uuid.UUID `json:"lead_user_id,omitempty"` Office *string `json:"office,omitempty"` } // List returns every Dezernat (readable by any authenticated user — see RLS). -func (s *DezernatService) List(ctx context.Context) ([]models.Dezernat, error) { - var rows []models.Dezernat +func (s *DepartmentService) List(ctx context.Context) ([]models.Department, error) { + var rows []models.Department err := s.db.SelectContext(ctx, &rows, `SELECT id, name, lead_user_id, office, created_at, updated_at - FROM paliad.dezernate + FROM paliad.departments ORDER BY office, name`) if err != nil { return nil, fmt.Errorf("list dezernate: %w", err) @@ -57,11 +57,11 @@ func (s *DezernatService) List(ctx context.Context) ([]models.Dezernat, error) { } // GetByID returns one Dezernat or (nil, sql.ErrNoRows). -func (s *DezernatService) GetByID(ctx context.Context, id uuid.UUID) (*models.Dezernat, error) { - var d models.Dezernat +func (s *DepartmentService) GetByID(ctx context.Context, id uuid.UUID) (*models.Department, error) { + var d models.Department err := s.db.GetContext(ctx, &d, `SELECT id, name, lead_user_id, office, created_at, updated_at - FROM paliad.dezernate WHERE id = $1`, id) + FROM paliad.departments WHERE id = $1`, id) if errors.Is(err, sql.ErrNoRows) { return nil, sql.ErrNoRows } @@ -72,7 +72,7 @@ func (s *DezernatService) GetByID(ctx context.Context, id uuid.UUID) (*models.De } // Create inserts a Dezernat. Admin-only. -func (s *DezernatService) Create(ctx context.Context, callerID uuid.UUID, input CreateDezernatInput) (*models.Dezernat, error) { +func (s *DepartmentService) Create(ctx context.Context, callerID uuid.UUID, input CreateDepartmentInput) (*models.Department, error) { if err := s.requireAdmin(ctx, callerID); err != nil { return nil, err } @@ -85,7 +85,7 @@ func (s *DezernatService) Create(ctx context.Context, callerID uuid.UUID, input id := uuid.New() now := time.Now().UTC() if _, err := s.db.ExecContext(ctx, - `INSERT INTO paliad.dezernate (id, name, lead_user_id, office, created_at, updated_at) + `INSERT INTO paliad.departments (id, name, lead_user_id, office, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $5)`, id, input.Name, input.LeadUserID, input.Office, now); err != nil { return nil, fmt.Errorf("insert dezernat: %w", err) @@ -94,7 +94,7 @@ func (s *DezernatService) Create(ctx context.Context, callerID uuid.UUID, input } // Update applies a partial update. Admin-only. -func (s *DezernatService) Update(ctx context.Context, callerID, id uuid.UUID, input UpdateDezernatInput) (*models.Dezernat, error) { +func (s *DepartmentService) Update(ctx context.Context, callerID, id uuid.UUID, input UpdateDepartmentInput) (*models.Department, error) { if err := s.requireAdmin(ctx, callerID); err != nil { return nil, err } @@ -128,7 +128,7 @@ func (s *DezernatService) Update(ctx context.Context, callerID, id uuid.UUID, in } appendSet("updated_at", time.Now().UTC()) args = append(args, id) - query := fmt.Sprintf("UPDATE paliad.dezernate SET %s WHERE id = $%d", + query := fmt.Sprintf("UPDATE paliad.departments SET %s WHERE id = $%d", strings.Join(sets, ", "), next) if _, err := s.db.ExecContext(ctx, query, args...); err != nil { return nil, fmt.Errorf("update dezernat: %w", err) @@ -137,11 +137,11 @@ func (s *DezernatService) Update(ctx context.Context, callerID, id uuid.UUID, in } // Delete removes a Dezernat (cascades memberships). Admin-only. -func (s *DezernatService) Delete(ctx context.Context, callerID, id uuid.UUID) error { +func (s *DepartmentService) Delete(ctx context.Context, callerID, id uuid.UUID) error { if err := s.requireAdmin(ctx, callerID); err != nil { return err } - _, err := s.db.ExecContext(ctx, `DELETE FROM paliad.dezernate WHERE id = $1`, id) + _, err := s.db.ExecContext(ctx, `DELETE FROM paliad.departments WHERE id = $1`, id) if err != nil { return fmt.Errorf("delete dezernat: %w", err) } @@ -149,13 +149,13 @@ func (s *DezernatService) Delete(ctx context.Context, callerID, id uuid.UUID) er } // AddMember inserts a (dezernat, user) membership. Admin-only. Idempotent. -func (s *DezernatService) AddMember(ctx context.Context, callerID, dezernatID, userID uuid.UUID) error { +func (s *DepartmentService) AddMember(ctx context.Context, callerID, dezernatID, userID uuid.UUID) error { if err := s.requireAdmin(ctx, callerID); err != nil { return err } _, err := s.db.ExecContext(ctx, - `INSERT INTO paliad.dezernat_mitglieder (dezernat_id, user_id, created_at) - VALUES ($1, $2, now()) ON CONFLICT (dezernat_id, user_id) DO NOTHING`, + `INSERT INTO paliad.department_members (department_id, user_id, created_at) + VALUES ($1, $2, now()) ON CONFLICT (department_id, user_id) DO NOTHING`, dezernatID, userID) if err != nil { return fmt.Errorf("add dezernat member: %w", err) @@ -164,12 +164,12 @@ func (s *DezernatService) AddMember(ctx context.Context, callerID, dezernatID, u } // RemoveMember deletes a (dezernat, user) membership. Admin-only. -func (s *DezernatService) RemoveMember(ctx context.Context, callerID, dezernatID, userID uuid.UUID) error { +func (s *DepartmentService) RemoveMember(ctx context.Context, callerID, dezernatID, userID uuid.UUID) error { if err := s.requireAdmin(ctx, callerID); err != nil { return err } _, err := s.db.ExecContext(ctx, - `DELETE FROM paliad.dezernat_mitglieder WHERE dezernat_id = $1 AND user_id = $2`, + `DELETE FROM paliad.department_members WHERE department_id = $1 AND user_id = $2`, dezernatID, userID) if err != nil { return fmt.Errorf("remove dezernat member: %w", err) @@ -188,14 +188,14 @@ type DezernatMember struct { } // ListMembers returns users in the Dezernat (readable by any authenticated user). -func (s *DezernatService) ListMembers(ctx context.Context, dezernatID uuid.UUID) ([]DezernatMember, error) { +func (s *DepartmentService) ListMembers(ctx context.Context, dezernatID uuid.UUID) ([]DezernatMember, error) { var rows []DezernatMember err := s.db.SelectContext(ctx, &rows, `SELECT dm.user_id, dm.created_at, u.email, u.display_name, u.office, u.role - FROM paliad.dezernat_mitglieder dm + FROM paliad.department_members dm LEFT JOIN paliad.users u ON u.id = dm.user_id - WHERE dm.dezernat_id = $1 + WHERE dm.department_id = $1 ORDER BY u.display_name`, dezernatID) if err != nil { return nil, fmt.Errorf("list dezernat members: %w", err) @@ -205,12 +205,12 @@ func (s *DezernatService) ListMembers(ctx context.Context, dezernatID uuid.UUID) // GetMembership returns the user's Dezernat memberships (zero or more). // Used by the settings page to render "Your Dezernat: ". -func (s *DezernatService) GetMembership(ctx context.Context, userID uuid.UUID) ([]models.Dezernat, error) { - var rows []models.Dezernat +func (s *DepartmentService) GetMembership(ctx context.Context, userID uuid.UUID) ([]models.Department, error) { + var rows []models.Department err := s.db.SelectContext(ctx, &rows, `SELECT d.id, d.name, d.lead_user_id, d.office, d.created_at, d.updated_at - FROM paliad.dezernate d - JOIN paliad.dezernat_mitglieder dm ON dm.dezernat_id = d.id + FROM paliad.departments d + JOIN paliad.department_members dm ON dm.department_id = d.id WHERE dm.user_id = $1 ORDER BY d.name`, userID) if err != nil { @@ -221,7 +221,7 @@ func (s *DezernatService) GetMembership(ctx context.Context, userID uuid.UUID) ( // --------------------------------------------------------------------------- -func (s *DezernatService) requireAdmin(ctx context.Context, userID uuid.UUID) error { +func (s *DepartmentService) requireAdmin(ctx context.Context, userID uuid.UUID) error { u, err := s.users.GetByID(ctx, userID) if err != nil { return err diff --git a/internal/services/fristenrechner.go b/internal/services/fristenrechner.go index a0bbceb..7915c0c 100644 --- a/internal/services/fristenrechner.go +++ b/internal/services/fristenrechner.go @@ -11,7 +11,7 @@ import ( // FristenrechnerService renders the Paliad public Fristenrechner's response // shape from DB-stored rules. It sits on top of DeadlineRuleService and // HolidayService and produces the bilingual, rule-code + notes-rich payload -// that /tools/fristenrechner's client expects. +// that /tools/deadlinesrechner's client expects. // // The UI-facing response is distinct from the plain calculator in // DeadlineCalculator: it adds IsRootEvent, IsCourtSet, RuleRef, Notes, @@ -28,7 +28,7 @@ func NewFristenrechnerService(rules *DeadlineRuleService, holidays *HolidayServi } // UIDeadline matches the frontend's CalculatedDeadline TypeScript interface -// (camelCase JSON to keep /tools/fristenrechner byte-identical). +// (camelCase JSON to keep /tools/deadlinesrechner byte-identical). type UIDeadline struct { Code string `json:"code"` Name string `json:"name"` diff --git a/internal/services/mail_service_test.go b/internal/services/mail_service_test.go index f6bb495..b79effa 100644 --- a/internal/services/mail_service_test.go +++ b/internal/services/mail_service_test.go @@ -11,10 +11,10 @@ import ( // to the HTML. func TestHTMLToText(t *testing.T) { in := `` + - `

Frist überfällig

Hallo Welt

` + + `

Deadline überfällig

Hallo Welt

` + `

Zweite Zeile — ok.

` got := htmlToText(in) - if !strings.Contains(got, "Frist überfällig") { + if !strings.Contains(got, "Deadline überfällig") { t.Errorf("expected decoded umlauts in %q", got) } if strings.Contains(got, "alert(1)") { @@ -37,7 +37,7 @@ func TestRenderTemplateDeadlineReminder(t *testing.T) { t.Fatalf("NewMailService: %v", err) } html, err := svc.RenderTemplate(TemplateData{ - Subject: "[Paliad] Frist morgen: X", + Subject: "[Paliad] Deadline morgen: X", Lang: "de", Name: "deadline_reminder", Data: map[string]any{ @@ -46,7 +46,7 @@ func TestRenderTemplateDeadlineReminder(t *testing.T) { "DueDate": "2026-04-21", "AkteAktenzeichen": "2026/0042", "AkteTitle": "Mustermann ./. Musterfrau", - "FristURL": "https://paliad.de/fristen/123", + "FristURL": "https://paliad.de/deadlines/123", }, }) if err != nil { @@ -54,7 +54,7 @@ func TestRenderTemplateDeadlineReminder(t *testing.T) { } for _, want := range []string{ "Paliad", "Schriftsatz einreichen", "2026-04-21", "2026/0042", - "Mustermann ./. Musterfrau", "https://paliad.de/fristen/123", + "Mustermann ./. Musterfrau", "https://paliad.de/deadlines/123", "morgen", "#c6f41c", } { if !strings.Contains(html, want) { @@ -108,10 +108,10 @@ func TestRenderTemplateDeadlineWeekly(t *testing.T) { Name: "deadline_weekly", Data: map[string]any{ "Count": 2, - "FristenURL": "https://paliad.de/fristen", + "FristenURL": "https://paliad.de/deadlines", "Items": []map[string]any{ - {"DueDate": "2026-04-20", "Title": "Heute f.", "AkteAktenzeichen": "2026/0001", "URL": "https://paliad.de/fristen/a", "Overdue": true}, - {"DueDate": "2026-04-24", "Title": "Später f.", "AkteAktenzeichen": "2026/0002", "URL": "https://paliad.de/fristen/b", "Overdue": false}, + {"DueDate": "2026-04-20", "Title": "Heute f.", "AkteAktenzeichen": "2026/0001", "URL": "https://paliad.de/deadlines/a", "Overdue": true}, + {"DueDate": "2026-04-24", "Title": "Später f.", "AkteAktenzeichen": "2026/0002", "URL": "https://paliad.de/deadlines/b", "Overdue": false}, }, }, }) @@ -120,7 +120,7 @@ func TestRenderTemplateDeadlineWeekly(t *testing.T) { } for _, want := range []string{ "Heute f.", "Später f.", "2026/0001", "2026/0002", - "https://paliad.de/fristen/a", "https://paliad.de/fristen/b", + "https://paliad.de/deadlines/a", "https://paliad.de/deadlines/b", } { if !strings.Contains(html, want) { t.Errorf("rendered html missing %q", want) diff --git a/internal/services/note_service.go b/internal/services/note_service.go new file mode 100644 index 0000000..a668340 --- /dev/null +++ b/internal/services/note_service.go @@ -0,0 +1,337 @@ +package services + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "mgit.msbls.de/m/patholo/internal/models" +) + +// NoteService reads and writes paliad.notes — polymorphic notes anchored +// to exactly one of { Project, Deadline, Appointment, ProjectEvent }. Visibility +// follows the parent row. +// +// Edit: only the author (created_by) may edit their own note. +// Delete: author, or partner/admin. +type NoteService struct { + db *sqlx.DB + projects *ProjectService + appointment *AppointmentService +} + +func NewNoteService(db *sqlx.DB, projects *ProjectService, appointment *AppointmentService) *NoteService { + return &NoteService{db: db, projects: projects, appointment: appointment} +} + +const notizColumns = `n.id, n.project_id, n.deadline_id, n.appointment_id, n.project_event_id, + n.content, n.created_by, n.created_at, n.updated_at, + u.display_name AS author_name, + u.email AS author_email` + +const notizSelect = `SELECT ` + notizColumns + ` + FROM paliad.notes n + LEFT JOIN paliad.users u ON u.id = n.created_by` + +// CreateNotizInput is the POST payload. +type CreateNotizInput struct { + Content string `json:"content"` +} + +// UpdateNotizInput is the PATCH payload. +type UpdateNotizInput struct { + Content *string `json:"content,omitempty"` +} + +// ListForProjekt returns all notes attached directly to the given Project. +func (s *NoteService) ListForProjekt(ctx context.Context, userID, projektID uuid.UUID) ([]models.Note, error) { + if _, err := s.projects.GetByID(ctx, userID, projektID); err != nil { + return nil, err + } + return s.list(ctx, `n.project_id = $1`, projektID) +} + +// ListForFrist returns all notes attached to a specific Deadline. +func (s *NoteService) ListForFrist(ctx context.Context, userID, fristID uuid.UUID) ([]models.Note, error) { + projektID, err := s.fristProjectID(ctx, fristID) + if err != nil { + return nil, err + } + if _, err := s.projects.GetByID(ctx, userID, projektID); err != nil { + return nil, err + } + return s.list(ctx, `n.deadline_id = $1`, fristID) +} + +// ListForTermin returns all notes attached to a specific Appointment. +func (s *NoteService) ListForTermin(ctx context.Context, userID, terminID uuid.UUID) ([]models.Note, error) { + if _, err := s.appointment.GetByID(ctx, userID, terminID); err != nil { + return nil, err + } + return s.list(ctx, `n.appointment_id = $1`, terminID) +} + +// ListForProjectEvent returns all notes attached to a specific projekt_event row. +func (s *NoteService) ListForProjectEvent(ctx context.Context, userID, eventID uuid.UUID) ([]models.Note, error) { + projektID, err := s.eventProjectID(ctx, eventID) + if err != nil { + return nil, err + } + if _, err := s.projects.GetByID(ctx, userID, projektID); err != nil { + return nil, err + } + return s.list(ctx, `n.project_event_id = $1`, eventID) +} + +// CreateForProjekt inserts a note attached directly to a Project. +func (s *NoteService) CreateForProjekt(ctx context.Context, userID, projektID uuid.UUID, input CreateNotizInput) (*models.Note, error) { + if _, err := s.projects.GetByID(ctx, userID, projektID); err != nil { + return nil, err + } + content, err := validateContent(input.Content) + if err != nil { + return nil, err + } + id, err := s.insertWithAudit(ctx, userID, content, noteParent{ProjectID: &projektID}, &projektID, "project") + if err != nil { + return nil, err + } + return s.getByIDUnchecked(ctx, id) +} + +// CreateForFrist inserts a note attached to a Deadline. +func (s *NoteService) CreateForFrist(ctx context.Context, userID, fristID uuid.UUID, input CreateNotizInput) (*models.Note, error) { + projektID, err := s.fristProjectID(ctx, fristID) + if err != nil { + return nil, err + } + if _, err := s.projects.GetByID(ctx, userID, projektID); err != nil { + return nil, err + } + content, err := validateContent(input.Content) + if err != nil { + return nil, err + } + id, err := s.insertWithAudit(ctx, userID, content, noteParent{DeadlineID: &fristID}, &projektID, "deadline") + if err != nil { + return nil, err + } + return s.getByIDUnchecked(ctx, id) +} + +// CreateForTermin inserts a note attached to a Appointment. Personal Appointment +// notes skip the audit trail; Project-attached Appointment notes append events. +func (s *NoteService) CreateForTermin(ctx context.Context, userID, terminID uuid.UUID, input CreateNotizInput) (*models.Note, error) { + t, err := s.appointment.GetByID(ctx, userID, terminID) + if err != nil { + return nil, err + } + content, err := validateContent(input.Content) + if err != nil { + return nil, err + } + id, err := s.insertWithAudit(ctx, userID, content, noteParent{AppointmentID: &terminID}, t.ProjectID, "appointment") + if err != nil { + return nil, err + } + return s.getByIDUnchecked(ctx, id) +} + +// GetByID returns a single note, visibility-checked. +func (s *NoteService) GetByID(ctx context.Context, userID, id uuid.UUID) (*models.Note, error) { + n, err := s.getByIDUnchecked(ctx, id) + if err != nil { + return nil, err + } + if err := s.requireVisible(ctx, userID, n); err != nil { + return nil, err + } + return n, nil +} + +// Update edits a note's content. Only the original author may edit. +func (s *NoteService) Update(ctx context.Context, userID, id uuid.UUID, input UpdateNotizInput) (*models.Note, error) { + current, err := s.GetByID(ctx, userID, id) + if err != nil { + return nil, err + } + if current.CreatedBy == nil || *current.CreatedBy != userID { + return nil, fmt.Errorf("%w: only the author can edit a Note", ErrForbidden) + } + if input.Content == nil { + return current, nil + } + content, err := validateContent(*input.Content) + if err != nil { + return nil, err + } + _, err = s.db.ExecContext(ctx, + `UPDATE paliad.notes SET content = $1, updated_at = NOW() WHERE id = $2`, + content, id) + if err != nil { + return nil, fmt.Errorf("update note: %w", err) + } + return s.getByIDUnchecked(ctx, id) +} + +// Delete removes a note. Author, partner, or admin only. +func (s *NoteService) Delete(ctx context.Context, userID, id uuid.UUID) error { + current, err := s.GetByID(ctx, userID, id) + if err != nil { + return err + } + isAuthor := current.CreatedBy != nil && *current.CreatedBy == userID + if !isAuthor { + user, err := s.projects.Users().GetByID(ctx, userID) + if err != nil { + return err + } + if user == nil || (user.Role != "partner" && user.Role != "admin") { + return fmt.Errorf("%w: only the author or a partner/admin can delete a Note", ErrForbidden) + } + } + if _, err := s.db.ExecContext(ctx, + `DELETE FROM paliad.notes WHERE id = $1`, id); err != nil { + return fmt.Errorf("delete note: %w", err) + } + return nil +} + +// --- internals ------------------------------------------------------------- + +type noteParent struct { + ProjectID *uuid.UUID + DeadlineID *uuid.UUID + AppointmentID *uuid.UUID + ProjectEventID *uuid.UUID +} + +func (s *NoteService) list(ctx context.Context, where string, arg any) ([]models.Note, error) { + query := notizSelect + ` WHERE ` + where + ` ORDER BY n.created_at DESC` + var rows []models.Note + if err := s.db.SelectContext(ctx, &rows, query, arg); err != nil { + return nil, fmt.Errorf("list notes: %w", err) + } + return rows, nil +} + +// insertWithAudit inserts one notes row and, when an owning Project exists, +// appends a project_events audit row in the same transaction. +func (s *NoteService) insertWithAudit(ctx context.Context, userID uuid.UUID, content string, parent noteParent, projektAuditID *uuid.UUID, parentLabel string) (uuid.UUID, error) { + id := uuid.New() + now := time.Now().UTC() + + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return uuid.Nil, fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, + `INSERT INTO paliad.notes + (id, project_id, deadline_id, appointment_id, project_event_id, + content, created_by, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)`, + id, parent.ProjectID, parent.DeadlineID, parent.AppointmentID, parent.ProjectEventID, + content, userID, now, + ); err != nil { + return uuid.Nil, fmt.Errorf("insert note: %w", err) + } + + if projektAuditID != nil { + title := "Note hinzugef\u00fcgt" + desc := fmt.Sprintf("Note zu %s hinzugef\u00fcgt", parentLabel) + descPtr := &desc + if err := insertProjectEvent(ctx, tx, *projektAuditID, userID, "notiz_created", title, descPtr); err != nil { + return uuid.Nil, err + } + } + if err := tx.Commit(); err != nil { + return uuid.Nil, fmt.Errorf("commit insert note: %w", err) + } + return id, nil +} + +// getByIDUnchecked fetches a note without a visibility check. +func (s *NoteService) getByIDUnchecked(ctx context.Context, id uuid.UUID) (*models.Note, error) { + var n models.Note + err := s.db.GetContext(ctx, &n, notizSelect+` WHERE n.id = $1`, id) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotVisible + } + if err != nil { + return nil, fmt.Errorf("fetch note: %w", err) + } + return &n, nil +} + +// requireVisible re-runs the parent-visibility check. +func (s *NoteService) requireVisible(ctx context.Context, userID uuid.UUID, n *models.Note) error { + switch { + case n.ProjectID != nil: + _, err := s.projects.GetByID(ctx, userID, *n.ProjectID) + return err + case n.DeadlineID != nil: + projektID, err := s.fristProjectID(ctx, *n.DeadlineID) + if err != nil { + return err + } + _, err = s.projects.GetByID(ctx, userID, projektID) + return err + case n.AppointmentID != nil: + _, err := s.appointment.GetByID(ctx, userID, *n.AppointmentID) + return err + case n.ProjectEventID != nil: + projektID, err := s.eventProjectID(ctx, *n.ProjectEventID) + if err != nil { + return err + } + _, err = s.projects.GetByID(ctx, userID, projektID) + return err + default: + return ErrNotVisible + } +} + +func (s *NoteService) fristProjectID(ctx context.Context, fristID uuid.UUID) (uuid.UUID, error) { + var projektID uuid.UUID + err := s.db.GetContext(ctx, &projektID, + `SELECT project_id FROM paliad.deadlines WHERE id = $1`, fristID) + if errors.Is(err, sql.ErrNoRows) { + return uuid.Nil, ErrNotVisible + } + if err != nil { + return uuid.Nil, fmt.Errorf("lookup deadline parent: %w", err) + } + return projektID, nil +} + +func (s *NoteService) eventProjectID(ctx context.Context, eventID uuid.UUID) (uuid.UUID, error) { + var projektID uuid.UUID + err := s.db.GetContext(ctx, &projektID, + `SELECT project_id FROM paliad.project_events WHERE id = $1`, eventID) + if errors.Is(err, sql.ErrNoRows) { + return uuid.Nil, ErrNotVisible + } + if err != nil { + return uuid.Nil, fmt.Errorf("lookup event parent: %w", err) + } + return projektID, nil +} + +func validateContent(raw string) (string, error) { + content := strings.TrimSpace(raw) + if content == "" { + return "", fmt.Errorf("%w: content is required", ErrInvalidInput) + } + if len(content) > 10000 { + return "", fmt.Errorf("%w: content exceeds 10000 characters", ErrInvalidInput) + } + return content, nil +} diff --git a/internal/services/notiz_service.go b/internal/services/notiz_service.go deleted file mode 100644 index f0be611..0000000 --- a/internal/services/notiz_service.go +++ /dev/null @@ -1,337 +0,0 @@ -package services - -import ( - "context" - "database/sql" - "errors" - "fmt" - "strings" - "time" - - "github.com/google/uuid" - "github.com/jmoiron/sqlx" - - "mgit.msbls.de/m/patholo/internal/models" -) - -// NotizService reads and writes paliad.notizen — polymorphic notes anchored -// to exactly one of { Projekt, Frist, Termin, ProjektEvent }. Visibility -// follows the parent row. -// -// Edit: only the author (created_by) may edit their own note. -// Delete: author, or partner/admin. -type NotizService struct { - db *sqlx.DB - projekte *ProjektService - termin *TerminService -} - -func NewNotizService(db *sqlx.DB, projekte *ProjektService, termin *TerminService) *NotizService { - return &NotizService{db: db, projekte: projekte, termin: termin} -} - -const notizColumns = `n.id, n.projekt_id, n.frist_id, n.termin_id, n.akten_event_id, - n.content, n.created_by, n.created_at, n.updated_at, - u.display_name AS author_name, - u.email AS author_email` - -const notizSelect = `SELECT ` + notizColumns + ` - FROM paliad.notizen n - LEFT JOIN paliad.users u ON u.id = n.created_by` - -// CreateNotizInput is the POST payload. -type CreateNotizInput struct { - Content string `json:"content"` -} - -// UpdateNotizInput is the PATCH payload. -type UpdateNotizInput struct { - Content *string `json:"content,omitempty"` -} - -// ListForProjekt returns all notes attached directly to the given Projekt. -func (s *NotizService) ListForProjekt(ctx context.Context, userID, projektID uuid.UUID) ([]models.Notiz, error) { - if _, err := s.projekte.GetByID(ctx, userID, projektID); err != nil { - return nil, err - } - return s.list(ctx, `n.projekt_id = $1`, projektID) -} - -// ListForFrist returns all notes attached to a specific Frist. -func (s *NotizService) ListForFrist(ctx context.Context, userID, fristID uuid.UUID) ([]models.Notiz, error) { - projektID, err := s.fristProjektID(ctx, fristID) - if err != nil { - return nil, err - } - if _, err := s.projekte.GetByID(ctx, userID, projektID); err != nil { - return nil, err - } - return s.list(ctx, `n.frist_id = $1`, fristID) -} - -// ListForTermin returns all notes attached to a specific Termin. -func (s *NotizService) ListForTermin(ctx context.Context, userID, terminID uuid.UUID) ([]models.Notiz, error) { - if _, err := s.termin.GetByID(ctx, userID, terminID); err != nil { - return nil, err - } - return s.list(ctx, `n.termin_id = $1`, terminID) -} - -// ListForProjektEvent returns all notes attached to a specific projekt_event row. -func (s *NotizService) ListForProjektEvent(ctx context.Context, userID, eventID uuid.UUID) ([]models.Notiz, error) { - projektID, err := s.eventProjektID(ctx, eventID) - if err != nil { - return nil, err - } - if _, err := s.projekte.GetByID(ctx, userID, projektID); err != nil { - return nil, err - } - return s.list(ctx, `n.akten_event_id = $1`, eventID) -} - -// CreateForProjekt inserts a note attached directly to a Projekt. -func (s *NotizService) CreateForProjekt(ctx context.Context, userID, projektID uuid.UUID, input CreateNotizInput) (*models.Notiz, error) { - if _, err := s.projekte.GetByID(ctx, userID, projektID); err != nil { - return nil, err - } - content, err := validateContent(input.Content) - if err != nil { - return nil, err - } - id, err := s.insertWithAudit(ctx, userID, content, notizParent{ProjektID: &projektID}, &projektID, "projekt") - if err != nil { - return nil, err - } - return s.getByIDUnchecked(ctx, id) -} - -// CreateForFrist inserts a note attached to a Frist. -func (s *NotizService) CreateForFrist(ctx context.Context, userID, fristID uuid.UUID, input CreateNotizInput) (*models.Notiz, error) { - projektID, err := s.fristProjektID(ctx, fristID) - if err != nil { - return nil, err - } - if _, err := s.projekte.GetByID(ctx, userID, projektID); err != nil { - return nil, err - } - content, err := validateContent(input.Content) - if err != nil { - return nil, err - } - id, err := s.insertWithAudit(ctx, userID, content, notizParent{FristID: &fristID}, &projektID, "frist") - if err != nil { - return nil, err - } - return s.getByIDUnchecked(ctx, id) -} - -// CreateForTermin inserts a note attached to a Termin. Personal Termin -// notes skip the audit trail; Projekt-attached Termin notes append events. -func (s *NotizService) CreateForTermin(ctx context.Context, userID, terminID uuid.UUID, input CreateNotizInput) (*models.Notiz, error) { - t, err := s.termin.GetByID(ctx, userID, terminID) - if err != nil { - return nil, err - } - content, err := validateContent(input.Content) - if err != nil { - return nil, err - } - id, err := s.insertWithAudit(ctx, userID, content, notizParent{TerminID: &terminID}, t.ProjektID, "termin") - if err != nil { - return nil, err - } - return s.getByIDUnchecked(ctx, id) -} - -// GetByID returns a single note, visibility-checked. -func (s *NotizService) GetByID(ctx context.Context, userID, id uuid.UUID) (*models.Notiz, error) { - n, err := s.getByIDUnchecked(ctx, id) - if err != nil { - return nil, err - } - if err := s.requireVisible(ctx, userID, n); err != nil { - return nil, err - } - return n, nil -} - -// Update edits a note's content. Only the original author may edit. -func (s *NotizService) Update(ctx context.Context, userID, id uuid.UUID, input UpdateNotizInput) (*models.Notiz, error) { - current, err := s.GetByID(ctx, userID, id) - if err != nil { - return nil, err - } - if current.CreatedBy == nil || *current.CreatedBy != userID { - return nil, fmt.Errorf("%w: only the author can edit a Notiz", ErrForbidden) - } - if input.Content == nil { - return current, nil - } - content, err := validateContent(*input.Content) - if err != nil { - return nil, err - } - _, err = s.db.ExecContext(ctx, - `UPDATE paliad.notizen SET content = $1, updated_at = NOW() WHERE id = $2`, - content, id) - if err != nil { - return nil, fmt.Errorf("update notiz: %w", err) - } - return s.getByIDUnchecked(ctx, id) -} - -// Delete removes a note. Author, partner, or admin only. -func (s *NotizService) Delete(ctx context.Context, userID, id uuid.UUID) error { - current, err := s.GetByID(ctx, userID, id) - if err != nil { - return err - } - isAuthor := current.CreatedBy != nil && *current.CreatedBy == userID - if !isAuthor { - user, err := s.projekte.Users().GetByID(ctx, userID) - if err != nil { - return err - } - if user == nil || (user.Role != "partner" && user.Role != "admin") { - return fmt.Errorf("%w: only the author or a partner/admin can delete a Notiz", ErrForbidden) - } - } - if _, err := s.db.ExecContext(ctx, - `DELETE FROM paliad.notizen WHERE id = $1`, id); err != nil { - return fmt.Errorf("delete notiz: %w", err) - } - return nil -} - -// --- internals ------------------------------------------------------------- - -type notizParent struct { - ProjektID *uuid.UUID - FristID *uuid.UUID - TerminID *uuid.UUID - AktenEventID *uuid.UUID -} - -func (s *NotizService) list(ctx context.Context, where string, arg any) ([]models.Notiz, error) { - query := notizSelect + ` WHERE ` + where + ` ORDER BY n.created_at DESC` - var rows []models.Notiz - if err := s.db.SelectContext(ctx, &rows, query, arg); err != nil { - return nil, fmt.Errorf("list notizen: %w", err) - } - return rows, nil -} - -// insertWithAudit inserts one notizen row and, when an owning Projekt exists, -// appends a projekt_events audit row in the same transaction. -func (s *NotizService) insertWithAudit(ctx context.Context, userID uuid.UUID, content string, parent notizParent, projektAuditID *uuid.UUID, parentLabel string) (uuid.UUID, error) { - id := uuid.New() - now := time.Now().UTC() - - tx, err := s.db.BeginTxx(ctx, nil) - if err != nil { - return uuid.Nil, fmt.Errorf("begin tx: %w", err) - } - defer tx.Rollback() - - if _, err := tx.ExecContext(ctx, - `INSERT INTO paliad.notizen - (id, projekt_id, frist_id, termin_id, akten_event_id, - content, created_by, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)`, - id, parent.ProjektID, parent.FristID, parent.TerminID, parent.AktenEventID, - content, userID, now, - ); err != nil { - return uuid.Nil, fmt.Errorf("insert notiz: %w", err) - } - - if projektAuditID != nil { - title := "Notiz hinzugef\u00fcgt" - desc := fmt.Sprintf("Notiz zu %s hinzugef\u00fcgt", parentLabel) - descPtr := &desc - if err := insertProjektEvent(ctx, tx, *projektAuditID, userID, "notiz_created", title, descPtr); err != nil { - return uuid.Nil, err - } - } - if err := tx.Commit(); err != nil { - return uuid.Nil, fmt.Errorf("commit insert notiz: %w", err) - } - return id, nil -} - -// getByIDUnchecked fetches a note without a visibility check. -func (s *NotizService) getByIDUnchecked(ctx context.Context, id uuid.UUID) (*models.Notiz, error) { - var n models.Notiz - err := s.db.GetContext(ctx, &n, notizSelect+` WHERE n.id = $1`, id) - if errors.Is(err, sql.ErrNoRows) { - return nil, ErrNotVisible - } - if err != nil { - return nil, fmt.Errorf("fetch notiz: %w", err) - } - return &n, nil -} - -// requireVisible re-runs the parent-visibility check. -func (s *NotizService) requireVisible(ctx context.Context, userID uuid.UUID, n *models.Notiz) error { - switch { - case n.ProjektID != nil: - _, err := s.projekte.GetByID(ctx, userID, *n.ProjektID) - return err - case n.FristID != nil: - projektID, err := s.fristProjektID(ctx, *n.FristID) - if err != nil { - return err - } - _, err = s.projekte.GetByID(ctx, userID, projektID) - return err - case n.TerminID != nil: - _, err := s.termin.GetByID(ctx, userID, *n.TerminID) - return err - case n.AktenEventID != nil: - projektID, err := s.eventProjektID(ctx, *n.AktenEventID) - if err != nil { - return err - } - _, err = s.projekte.GetByID(ctx, userID, projektID) - return err - default: - return ErrNotVisible - } -} - -func (s *NotizService) fristProjektID(ctx context.Context, fristID uuid.UUID) (uuid.UUID, error) { - var projektID uuid.UUID - err := s.db.GetContext(ctx, &projektID, - `SELECT projekt_id FROM paliad.fristen WHERE id = $1`, fristID) - if errors.Is(err, sql.ErrNoRows) { - return uuid.Nil, ErrNotVisible - } - if err != nil { - return uuid.Nil, fmt.Errorf("lookup frist parent: %w", err) - } - return projektID, nil -} - -func (s *NotizService) eventProjektID(ctx context.Context, eventID uuid.UUID) (uuid.UUID, error) { - var projektID uuid.UUID - err := s.db.GetContext(ctx, &projektID, - `SELECT projekt_id FROM paliad.projekt_events WHERE id = $1`, eventID) - if errors.Is(err, sql.ErrNoRows) { - return uuid.Nil, ErrNotVisible - } - if err != nil { - return uuid.Nil, fmt.Errorf("lookup event parent: %w", err) - } - return projektID, nil -} - -func validateContent(raw string) (string, error) { - content := strings.TrimSpace(raw) - if content == "" { - return "", fmt.Errorf("%w: content is required", ErrInvalidInput) - } - if len(content) > 10000 { - return "", fmt.Errorf("%w: content exceeds 10000 characters", ErrInvalidInput) - } - return content, nil -} diff --git a/internal/services/parteien_service.go b/internal/services/parteien_service.go deleted file mode 100644 index 3a261d8..0000000 --- a/internal/services/parteien_service.go +++ /dev/null @@ -1,121 +0,0 @@ -package services - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "strings" - "time" - - "github.com/google/uuid" - "github.com/jmoiron/sqlx" - - "mgit.msbls.de/m/patholo/internal/models" -) - -// ParteienService reads and writes paliad.parteien. Visibility inherits from -// the parent Projekt. -type ParteienService struct { - db *sqlx.DB - projekte *ProjektService -} - -// NewParteienService wires the service. -func NewParteienService(db *sqlx.DB, projekte *ProjektService) *ParteienService { - return &ParteienService{db: db, projekte: projekte} -} - -const parteiColumns = `id, projekt_id, name, role, representative, contact_info, - created_at, updated_at` - -// CreateParteiInput is the payload for Create. -type CreateParteiInput struct { - Name string `json:"name"` - Role *string `json:"role,omitempty"` - Representative *string `json:"representative,omitempty"` - ContactInfo json.RawMessage `json:"contact_info,omitempty"` -} - -// ListForProjekt returns all Parteien for the Projekt, visibility-checked. -func (s *ParteienService) ListForProjekt(ctx context.Context, userID, projektID uuid.UUID) ([]models.Partei, error) { - if _, err := s.projekte.GetByID(ctx, userID, projektID); err != nil { - return nil, err - } - var rows []models.Partei - if err := s.db.SelectContext(ctx, &rows, - `SELECT `+parteiColumns+` - FROM paliad.parteien - WHERE projekt_id = $1 - ORDER BY name`, projektID); err != nil { - return nil, fmt.Errorf("list parteien: %w", err) - } - return rows, nil -} - -// Create inserts a Partei under a Projekt; visibility is checked on the parent. -func (s *ParteienService) Create(ctx context.Context, userID, projektID uuid.UUID, input CreateParteiInput) (*models.Partei, error) { - if strings.TrimSpace(input.Name) == "" { - return nil, fmt.Errorf("%w: name is required", ErrInvalidInput) - } - if _, err := s.projekte.GetByID(ctx, userID, projektID); err != nil { - return nil, err - } - - contact := input.ContactInfo - if len(contact) == 0 { - contact = json.RawMessage(`{}`) - } - - id := uuid.New() - now := time.Now().UTC() - if _, err := s.db.ExecContext(ctx, - `INSERT INTO paliad.parteien - (id, projekt_id, name, role, representative, contact_info, - created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $7)`, - id, projektID, input.Name, input.Role, input.Representative, contact, now, - ); err != nil { - return nil, fmt.Errorf("insert partei: %w", err) - } - - var p models.Partei - if err := s.db.GetContext(ctx, &p, - `SELECT `+parteiColumns+` FROM paliad.parteien WHERE id = $1`, id); err != nil { - return nil, fmt.Errorf("fetch created partei: %w", err) - } - return &p, nil -} - -// Delete removes a Partei. Partner/admin only. -func (s *ParteienService) Delete(ctx context.Context, userID, parteiID uuid.UUID) error { - user, err := s.projekte.Users().GetByID(ctx, userID) - if err != nil { - return err - } - if user == nil { - return ErrNotVisible - } - if user.Role != "partner" && user.Role != "admin" { - return fmt.Errorf("%w: only partners/admins can delete Parteien", ErrForbidden) - } - - var projektID uuid.UUID - err = s.db.GetContext(ctx, &projektID, - `SELECT projekt_id FROM paliad.parteien WHERE id = $1`, parteiID) - if errors.Is(err, sql.ErrNoRows) { - return ErrNotVisible - } - if err != nil { - return fmt.Errorf("lookup partei parent: %w", err) - } - if _, err := s.projekte.GetByID(ctx, userID, projektID); err != nil { - return err - } - if _, err := s.db.ExecContext(ctx, - `DELETE FROM paliad.parteien WHERE id = $1`, parteiID); err != nil { - return fmt.Errorf("delete partei: %w", err) - } - return nil -} diff --git a/internal/services/party_service.go b/internal/services/party_service.go new file mode 100644 index 0000000..047035c --- /dev/null +++ b/internal/services/party_service.go @@ -0,0 +1,121 @@ +package services + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "mgit.msbls.de/m/patholo/internal/models" +) + +// PartyService reads and writes paliad.parties. Visibility inherits from +// the parent Project. +type PartyService struct { + db *sqlx.DB + projects *ProjectService +} + +// NewPartyService wires the service. +func NewPartyService(db *sqlx.DB, projects *ProjectService) *PartyService { + return &PartyService{db: db, projects: projects} +} + +const parteiColumns = `id, project_id, name, role, representative, contact_info, + created_at, updated_at` + +// CreateParteiInput is the payload for Create. +type CreateParteiInput struct { + Name string `json:"name"` + Role *string `json:"role,omitempty"` + Representative *string `json:"representative,omitempty"` + ContactInfo json.RawMessage `json:"contact_info,omitempty"` +} + +// ListForProjekt returns all Parties for the Project, visibility-checked. +func (s *PartyService) ListForProjekt(ctx context.Context, userID, projektID uuid.UUID) ([]models.Party, error) { + if _, err := s.projects.GetByID(ctx, userID, projektID); err != nil { + return nil, err + } + var rows []models.Party + if err := s.db.SelectContext(ctx, &rows, + `SELECT `+parteiColumns+` + FROM paliad.parties + WHERE project_id = $1 + ORDER BY name`, projektID); err != nil { + return nil, fmt.Errorf("list parties: %w", err) + } + return rows, nil +} + +// Create inserts a Party under a Project; visibility is checked on the parent. +func (s *PartyService) Create(ctx context.Context, userID, projektID uuid.UUID, input CreateParteiInput) (*models.Party, error) { + if strings.TrimSpace(input.Name) == "" { + return nil, fmt.Errorf("%w: name is required", ErrInvalidInput) + } + if _, err := s.projects.GetByID(ctx, userID, projektID); err != nil { + return nil, err + } + + contact := input.ContactInfo + if len(contact) == 0 { + contact = json.RawMessage(`{}`) + } + + id := uuid.New() + now := time.Now().UTC() + if _, err := s.db.ExecContext(ctx, + `INSERT INTO paliad.parties + (id, project_id, name, role, representative, contact_info, + created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $7)`, + id, projektID, input.Name, input.Role, input.Representative, contact, now, + ); err != nil { + return nil, fmt.Errorf("insert party: %w", err) + } + + var p models.Party + if err := s.db.GetContext(ctx, &p, + `SELECT `+parteiColumns+` FROM paliad.parties WHERE id = $1`, id); err != nil { + return nil, fmt.Errorf("fetch created party: %w", err) + } + return &p, nil +} + +// Delete removes a Party. Partner/admin only. +func (s *PartyService) Delete(ctx context.Context, userID, parteiID uuid.UUID) error { + user, err := s.projects.Users().GetByID(ctx, userID) + if err != nil { + return err + } + if user == nil { + return ErrNotVisible + } + if user.Role != "partner" && user.Role != "admin" { + return fmt.Errorf("%w: only partners/admins can delete Parties", ErrForbidden) + } + + var projektID uuid.UUID + err = s.db.GetContext(ctx, &projektID, + `SELECT project_id FROM paliad.parties WHERE id = $1`, parteiID) + if errors.Is(err, sql.ErrNoRows) { + return ErrNotVisible + } + if err != nil { + return fmt.Errorf("lookup party parent: %w", err) + } + if _, err := s.projects.GetByID(ctx, userID, projektID); err != nil { + return err + } + if _, err := s.db.ExecContext(ctx, + `DELETE FROM paliad.parties WHERE id = $1`, parteiID); err != nil { + return fmt.Errorf("delete party: %w", err) + } + return nil +} diff --git a/internal/services/projekt_service.go b/internal/services/project_service.go similarity index 75% rename from internal/services/projekt_service.go rename to internal/services/project_service.go index 6814582..94605b8 100644 --- a/internal/services/projekt_service.go +++ b/internal/services/project_service.go @@ -1,18 +1,18 @@ package services -// ProjektService handles CRUD on paliad.projekte — the hierarchical +// ProjectService handles CRUD on paliad.projects — the hierarchical // project tree that replaced the flat paliad.akten model in migration 018. // // Visibility (design v2, adjusted 2026-04-20): team-based only. -// A user can see a Projekt iff +// A user can see a Project iff // - user is admin, or -// - user is a direct member of the Projekt's team, or -// - user is a member of any ancestor Projekt's team (inherited via path). +// - user is a direct member of the Project's team, or +// - user is a member of any ancestor Project's team (inherited via path). // // Office is no longer a visibility gate. Cases associate with lead partners, -// not offices (see paliad.projekt_teams role='lead'). +// not offices (see paliad.project_teams role='lead'). // -// The canonical predicate lives in SQL (paliad.can_see_projekt) and is +// The canonical predicate lives in SQL (paliad.can_see_project) and is // enforced by RLS policies. This service re-implements the same predicate // at the application layer so the service-role DB connection (without an // auth.uid() JWT) still gates correctly. @@ -35,9 +35,9 @@ import ( // Sentinel errors. var ( - // ErrNotVisible indicates the Projekt exists but the user has no + // ErrNotVisible indicates the Project exists but the user has no // visibility. Handlers must map to 404 (never leak existence). - ErrNotVisible = errors.New("projekt not visible") + ErrNotVisible = errors.New("project not visible") // ErrForbidden indicates the user is authenticated but lacks the role // required for the operation (e.g., associate trying to delete). ErrForbidden = errors.New("forbidden") @@ -45,16 +45,16 @@ var ( ErrInvalidInput = errors.New("invalid input") ) -// ProjektType values enumerated on the projekte.type CHECK constraint. +// ProjectType values enumerated on the projects.type CHECK constraint. const ( - ProjektTypeClient = "client" - ProjektTypeLitigation = "litigation" - ProjektTypePatent = "patent" - ProjektTypeCase = "case" - ProjektTypeProject = "project" + ProjectTypeClient = "client" + ProjectTypeLitigation = "litigation" + ProjectTypePatent = "patent" + ProjectTypeCase = "case" + ProjectTypeProject = "project" ) -// ProjektRole values allowed on projekt_teams.role. +// ProjektRole values allowed on project_teams.role. const ( RoleLead = "lead" RoleAssociate = "associate" @@ -65,24 +65,24 @@ const ( RoleObserver = "observer" ) -// ProjektService reads and writes paliad.projekte + paliad.projekt_events. -type ProjektService struct { +// ProjectService reads and writes paliad.projects + paliad.project_events. +type ProjectService struct { db *sqlx.DB users *UserService } -// NewProjektService wires the service. -func NewProjektService(db *sqlx.DB, users *UserService) *ProjektService { - return &ProjektService{db: db, users: users} +// NewProjectService wires the service. +func NewProjectService(db *sqlx.DB, users *UserService) *ProjectService { + return &ProjectService{db: db, users: users} } // Users exposes the shared user service for downstream services that gate -// through ProjektService (FristService, TerminService, NotizService, …). -func (s *ProjektService) Users() *UserService { return s.users } +// through ProjectService (DeadlineService, AppointmentService, NoteService, …). +func (s *ProjectService) Users() *UserService { return s.users } // DB exposes the underlying connection pool for services that need to issue // custom queries (dashboard aggregates, caldav sync). Read-only usage. -func (s *ProjektService) DB() *sqlx.DB { return s.db } +func (s *ProjectService) DB() *sqlx.DB { return s.db } const projektColumns = `id, type, parent_id, path, title, reference, description, status, created_by, industry, country, billing_reference, client_number, matter_number, @@ -136,8 +136,8 @@ type UpdateProjektInput struct { } // ListFilter narrows List results. Zero-value → no filter. -type ProjektFilter struct { - Type string // "", or one of ProjektType* constants +type ProjectFilter struct { + Type string // "", or one of ProjectType* constants Status string // "", "active", "archived", "closed" ParentID *uuid.UUID // filter to direct children of the given parent; use ParentNullOnly for roots // ParentNullOnly restricts to root-level rows (parent_id IS NULL). @@ -146,14 +146,14 @@ type ProjektFilter struct { Search string // trigram / ILIKE on title, reference, client_number, matter_number } -// List returns Projekte visible to the user, filterable. -func (s *ProjektService) List(ctx context.Context, userID uuid.UUID, f ProjektFilter) ([]models.Projekt, error) { +// List returns Projects visible to the user, filterable. +func (s *ProjectService) List(ctx context.Context, userID uuid.UUID, f ProjectFilter) ([]models.Project, error) { user, err := s.users.GetByID(ctx, userID) if err != nil { return nil, err } if user == nil { - return []models.Projekt{}, nil + return []models.Project{}, nil } conds := []string{visibilityPredicate("p")} @@ -182,26 +182,26 @@ func (s *ProjektService) List(ctx context.Context, userID uuid.UUID, f ProjektFi args["search"] = "%" + s + "%" } - query := `SELECT ` + projektColumns + ` FROM paliad.projekte p + query := `SELECT ` + projektColumns + ` FROM paliad.projects p WHERE ` + strings.Join(conds, " AND ") + ` ORDER BY p.updated_at DESC` stmt, err := s.db.PrepareNamedContext(ctx, query) if err != nil { - return nil, fmt.Errorf("prepare list projekte: %w", err) + return nil, fmt.Errorf("prepare list projects: %w", err) } defer stmt.Close() - var rows []models.Projekt + var rows []models.Project if err := stmt.SelectContext(ctx, &rows, args); err != nil { - return nil, fmt.Errorf("list projekte: %w", err) + return nil, fmt.Errorf("list projects: %w", err) } return rows, nil } -// GetByID returns the Projekt if the user can see it. Returns (nil, ErrNotVisible) +// GetByID returns the Project if the user can see it. Returns (nil, ErrNotVisible) // when invisible or missing — handlers must not distinguish. -func (s *ProjektService) GetByID(ctx context.Context, userID, id uuid.UUID) (*models.Projekt, error) { +func (s *ProjectService) GetByID(ctx context.Context, userID, id uuid.UUID) (*models.Project, error) { user, err := s.users.GetByID(ctx, userID) if err != nil { return nil, err @@ -209,37 +209,37 @@ func (s *ProjektService) GetByID(ctx context.Context, userID, id uuid.UUID) (*mo if user == nil { return nil, ErrNotVisible } - var p models.Projekt - query := `SELECT ` + projektColumns + ` FROM paliad.projekte p + var p models.Project + query := `SELECT ` + projektColumns + ` FROM paliad.projects p WHERE p.id = $1 AND ` + visibilityPredicatePositional("p", 2, 3) err = s.db.GetContext(ctx, &p, query, id, userID, user.Role) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotVisible } if err != nil { - return nil, fmt.Errorf("get projekt: %w", err) + return nil, fmt.Errorf("get project: %w", err) } return &p, nil } -// ListChildren returns direct children of a Projekt (visibility-checked on parent). -func (s *ProjektService) ListChildren(ctx context.Context, userID, id uuid.UUID) ([]models.Projekt, error) { +// ListChildren returns direct children of a Project (visibility-checked on parent). +func (s *ProjectService) ListChildren(ctx context.Context, userID, id uuid.UUID) ([]models.Project, error) { if _, err := s.GetByID(ctx, userID, id); err != nil { return nil, err } - return s.List(ctx, userID, ProjektFilter{ParentID: &id}) + return s.List(ctx, userID, ProjectFilter{ParentID: &id}) } // ListAncestors walks up the path and returns ancestors from root → parent -// (exclusive of the Projekt itself). Used for breadcrumbs. -func (s *ProjektService) ListAncestors(ctx context.Context, userID, id uuid.UUID) ([]models.Projekt, error) { +// (exclusive of the Project itself). Used for breadcrumbs. +func (s *ProjectService) ListAncestors(ctx context.Context, userID, id uuid.UUID) ([]models.Project, error) { p, err := s.GetByID(ctx, userID, id) if err != nil { return nil, err } labels := strings.Split(p.Path, ".") if len(labels) <= 1 { - return []models.Projekt{}, nil + return []models.Project{}, nil } // All but last = ancestors. ancestorIDs := labels[:len(labels)-1] @@ -256,12 +256,12 @@ func (s *ProjektService) ListAncestors(ctx context.Context, userID, id uuid.UUID return nil, err } if user == nil { - return []models.Projekt{}, nil + return []models.Project{}, nil } - // Ancestors are visible whenever the Projekt is (inheritance works both + // Ancestors are visible whenever the Project is (inheritance works both // ways through team membership checks). We still apply the predicate // for safety in case path is stale. - query := `SELECT ` + projektColumns + ` FROM paliad.projekte p + query := `SELECT ` + projektColumns + ` FROM paliad.projects p WHERE p.id = ANY($1::uuid[]) AND ` + visibilityPredicatePositional("p", 2, 3) // lib/pq doesn't serialise []uuid.UUID natively; render as string array. @@ -269,7 +269,7 @@ func (s *ProjektService) ListAncestors(ctx context.Context, userID, id uuid.UUID for i, u := range ids { idStrs[i] = u.String() } - var rows []models.Projekt + var rows []models.Project if err := s.db.SelectContext(ctx, &rows, query, pq.StringArray(idStrs), userID, user.Role); err != nil { return nil, fmt.Errorf("list ancestors: %w", err) } @@ -282,10 +282,10 @@ func (s *ProjektService) ListAncestors(ctx context.Context, userID, id uuid.UUID return rows, nil } -// GetTree returns every Projekt in the subtree rooted at id (inclusive), +// GetTree returns every Project in the subtree rooted at id (inclusive), // ordered depth-first. Visibility-checked at root; descendants that the // user can see are returned (the predicate naturally gates sub-branches). -func (s *ProjektService) GetTree(ctx context.Context, userID, id uuid.UUID) ([]models.Projekt, error) { +func (s *ProjectService) GetTree(ctx context.Context, userID, id uuid.UUID) ([]models.Project, error) { root, err := s.GetByID(ctx, userID, id) if err != nil { return nil, err @@ -295,29 +295,29 @@ func (s *ProjektService) GetTree(ctx context.Context, userID, id uuid.UUID) ([]m return nil, err } if user == nil { - return []models.Projekt{}, nil + return []models.Project{}, nil } // path LIKE root.path || '.%' OR path = root.path prefix := root.Path + ".%" - query := `SELECT ` + projektColumns + ` FROM paliad.projekte p + query := `SELECT ` + projektColumns + ` FROM paliad.projects p WHERE (p.path = $1 OR p.path LIKE $2) AND ` + visibilityPredicatePositional("p", 3, 4) + ` ORDER BY p.path` - var rows []models.Projekt + var rows []models.Project if err := s.db.SelectContext(ctx, &rows, query, root.Path, prefix, userID, user.Role); err != nil { return nil, fmt.Errorf("get tree: %w", err) } return rows, nil } -// Create inserts a new Projekt. If parent_id is set, the creator must have -// visibility on the parent. The creator is auto-added to projekt_teams as +// Create inserts a new Project. If parent_id is set, the creator must have +// visibility on the parent. The creator is auto-added to project_teams as // role='lead' in the same transaction so post-create SELECT picks up the row. -func (s *ProjektService) Create(ctx context.Context, userID uuid.UUID, input CreateProjektInput) (*models.Projekt, error) { +func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input CreateProjektInput) (*models.Project, error) { if strings.TrimSpace(input.Title) == "" { return nil, fmt.Errorf("%w: title is required", ErrInvalidInput) } - if !isValidProjektType(input.Type) { + if !isValidProjectType(input.Type) { return nil, fmt.Errorf("%w: invalid type %q", ErrInvalidInput, input.Type) } user, err := s.users.GetByID(ctx, userID) @@ -352,7 +352,7 @@ func (s *ProjektService) Create(ctx context.Context, userID uuid.UUID, input Cre // path is NOT NULL but the trigger populates it; supply a placeholder // the trigger will overwrite. (BEFORE INSERT trigger rewrites path.) if _, err := tx.ExecContext(ctx, - `INSERT INTO paliad.projekte + `INSERT INTO paliad.projects (id, type, parent_id, path, title, reference, description, status, created_by, industry, country, billing_reference, client_number, matter_number, netdocuments_url, patent_number, filing_date, grant_date, @@ -368,28 +368,28 @@ func (s *ProjektService) Create(ctx context.Context, userID uuid.UUID, input Cre input.Court, input.CaseNumber, input.ProceedingTypeID, now, ); err != nil { - return nil, fmt.Errorf("insert projekt: %w", err) + return nil, fmt.Errorf("insert project: %w", err) } // Auto-add creator as team lead so they (and RLS) can see the row. if _, err := tx.ExecContext(ctx, - `INSERT INTO paliad.projekt_teams (projekt_id, user_id, role, inherited, added_by) + `INSERT INTO paliad.project_teams (project_id, user_id, role, inherited, added_by) VALUES ($1, $2, 'lead', false, $2)`, id, userID); err != nil { return nil, fmt.Errorf("insert creator team row: %w", err) } - if err := insertProjektEvent(ctx, tx, id, userID, "projekt_created", "Projekt angelegt", nil); err != nil { + if err := insertProjectEvent(ctx, tx, id, userID, "projekt_created", "Project angelegt", nil); err != nil { return nil, err } if err := tx.Commit(); err != nil { - return nil, fmt.Errorf("commit create projekt: %w", err) + return nil, fmt.Errorf("commit create project: %w", err) } return s.GetByID(ctx, userID, id) } // Update applies a partial update. Reparenting triggers path rewrite for the -// subtree (handled by the AFTER UPDATE trigger on paliad.projekte). -func (s *ProjektService) Update(ctx context.Context, userID, id uuid.UUID, input UpdateProjektInput) (*models.Projekt, error) { +// subtree (handled by the AFTER UPDATE trigger on paliad.projects). +func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input UpdateProjektInput) (*models.Project, error) { current, err := s.GetByID(ctx, userID, id) if err != nil { return nil, err @@ -475,7 +475,7 @@ func (s *ProjektService) Update(ctx context.Context, userID, id uuid.UUID, input appendSet("updated_at", time.Now().UTC()) args = append(args, id) - query := fmt.Sprintf("UPDATE paliad.projekte SET %s WHERE id = $%d", + query := fmt.Sprintf("UPDATE paliad.projects SET %s WHERE id = $%d", strings.Join(sets, ", "), next) tx, err := s.db.BeginTxx(ctx, nil) @@ -485,29 +485,29 @@ func (s *ProjektService) Update(ctx context.Context, userID, id uuid.UUID, input defer tx.Rollback() if _, err := tx.ExecContext(ctx, query, args...); err != nil { - return nil, fmt.Errorf("update projekt: %w", err) + return nil, fmt.Errorf("update project: %w", err) } if input.Status != nil && *input.Status != current.Status { desc := fmt.Sprintf("Status %s → %s", current.Status, *input.Status) - if err := insertProjektEvent(ctx, tx, id, userID, "status_changed", desc, nil); err != nil { + if err := insertProjectEvent(ctx, tx, id, userID, "status_changed", desc, nil); err != nil { return nil, err } } if input.ParentID != nil { - if err := insertProjektEvent(ctx, tx, id, userID, "projekt_reparented", "Projekt neu zugeordnet", nil); err != nil { + if err := insertProjectEvent(ctx, tx, id, userID, "projekt_reparented", "Project neu zugeordnet", nil); err != nil { return nil, err } } if err := tx.Commit(); err != nil { - return nil, fmt.Errorf("commit update projekt: %w", err) + return nil, fmt.Errorf("commit update project: %w", err) } return s.GetByID(ctx, userID, id) } -// Delete archives the Projekt (soft-delete, status='archived'). Partner/admin only. +// Delete archives the Project (soft-delete, status='archived'). Partner/admin only. // Hard-delete cascades through FK; we prefer archival for audit. -func (s *ProjektService) Delete(ctx context.Context, userID, id uuid.UUID) error { +func (s *ProjectService) Delete(ctx context.Context, userID, id uuid.UUID) error { user, err := s.users.GetByID(ctx, userID) if err != nil { return err @@ -516,7 +516,7 @@ func (s *ProjektService) Delete(ctx context.Context, userID, id uuid.UUID) error return ErrNotVisible } if user.Role != "partner" && user.Role != "admin" { - return fmt.Errorf("%w: only partners/admins can archive Projekte", ErrForbidden) + return fmt.Errorf("%w: only partners/admins can archive Projects", ErrForbidden) } if _, err := s.GetByID(ctx, userID, id); err != nil { return err @@ -529,15 +529,15 @@ func (s *ProjektService) Delete(ctx context.Context, userID, id uuid.UUID) error defer tx.Rollback() res, err := tx.ExecContext(ctx, - `UPDATE paliad.projekte SET status = 'archived', updated_at = $1 + `UPDATE paliad.projects SET status = 'archived', updated_at = $1 WHERE id = $2 AND status != 'archived'`, time.Now().UTC(), id) if err != nil { - return fmt.Errorf("archive projekt: %w", err) + return fmt.Errorf("archive project: %w", err) } if rows, _ := res.RowsAffected(); rows == 0 { return tx.Commit() } - if err := insertProjektEvent(ctx, tx, id, userID, "projekt_archived", "Projekt archiviert", nil); err != nil { + if err := insertProjectEvent(ctx, tx, id, userID, "projekt_archived", "Project archiviert", nil); err != nil { return err } return tx.Commit() @@ -549,9 +549,9 @@ const MaxEventsPageLimit = 200 // DefaultEventsPageLimit is the page size when ?limit= is omitted. const DefaultEventsPageLimit = 50 -// ListEvents returns the audit trail for the Projekt, newest first, with +// ListEvents returns the audit trail for the Project, newest first, with // cursor pagination (before = uuid of last seen event). -func (s *ProjektService) ListEvents(ctx context.Context, userID, id uuid.UUID, before *uuid.UUID, limit int) ([]models.ProjektEvent, error) { +func (s *ProjectService) ListEvents(ctx context.Context, userID, id uuid.UUID, before *uuid.UUID, limit int) ([]models.ProjectEvent, error) { if _, err := s.GetByID(ctx, userID, id); err != nil { return nil, err } @@ -565,26 +565,26 @@ func (s *ProjektService) ListEvents(ctx context.Context, userID, id uuid.UUID, b if before != nil { beforeArg = *before } - var events []models.ProjektEvent + var events []models.ProjectEvent err := s.db.SelectContext(ctx, &events, - `SELECT id, projekt_id, event_type, title, description, event_date, + `SELECT id, project_id, event_type, title, description, event_date, created_by, metadata, created_at, updated_at - FROM paliad.projekt_events - WHERE projekt_id = $1 + FROM paliad.project_events + WHERE project_id = $1 AND ($2::uuid IS NULL OR (created_at, id) < ( - SELECT created_at, id FROM paliad.projekt_events WHERE id = $2::uuid + SELECT created_at, id FROM paliad.project_events WHERE id = $2::uuid )) ORDER BY created_at DESC, id DESC LIMIT $3`, id, beforeArg, limit) if err != nil { - return nil, fmt.Errorf("list projekt events: %w", err) + return nil, fmt.Errorf("list project events: %w", err) } return events, nil } // ResolveClientNumber walks up the path to find the first non-null client_number // (inherited convention). Returns nil if none in the ancestor chain. -func (s *ProjektService) ResolveClientNumber(ctx context.Context, userID, id uuid.UUID) (*string, error) { +func (s *ProjectService) ResolveClientNumber(ctx context.Context, userID, id uuid.UUID) (*string, error) { p, err := s.GetByID(ctx, userID, id) if err != nil { return nil, err @@ -612,16 +612,16 @@ func (s *ProjektService) ResolveClientNumber(ctx context.Context, userID, id uui // ============================================================================ // visibilityPredicate returns a SQL snippet that gates rows on -// paliad.can_see_projekt-equivalent checks at the application layer. Uses +// paliad.can_see_project-equivalent checks at the application layer. Uses // named bind variables :user_id and :role. // // Predicate: admin OR any (direct or ancestor) team membership of user_id. -// Walks the path: projekt_teams.projekt_id = ANY(string_to_array(p.path, '.')::uuid[]). +// Walks the path: project_teams.project_id = ANY(string_to_array(p.path, '.')::uuid[]). func visibilityPredicate(alias string) string { return `(:role = 'admin' OR EXISTS ( - SELECT 1 FROM paliad.projekt_teams pt + SELECT 1 FROM paliad.project_teams pt WHERE pt.user_id = :user_id - AND pt.projekt_id = ANY(string_to_array(` + alias + `.path, '.')::uuid[]) + AND pt.project_id = ANY(string_to_array(` + alias + `.path, '.')::uuid[]) ))` } @@ -630,9 +630,9 @@ func visibilityPredicate(alias string) string { // use named parameters. func visibilityPredicatePositional(alias string, userArg, roleArg int) string { return fmt.Sprintf(`($%d = 'admin' OR EXISTS ( - SELECT 1 FROM paliad.projekt_teams pt + SELECT 1 FROM paliad.project_teams pt WHERE pt.user_id = $%d - AND pt.projekt_id = ANY(string_to_array(%s.path, '.')::uuid[]) + AND pt.project_id = ANY(string_to_array(%s.path, '.')::uuid[]) ))`, roleArg, userArg, alias) } @@ -641,22 +641,22 @@ func visibilityPredicatePositional(alias string, userArg, roleArg int) string { // args in that order. func visibilityPredicatePlaceholder(alias string) string { return `(? = 'admin' OR EXISTS ( - SELECT 1 FROM paliad.projekt_teams pt + SELECT 1 FROM paliad.project_teams pt WHERE pt.user_id = ? - AND pt.projekt_id = ANY(string_to_array(` + alias + `.path, '.')::uuid[]) + AND pt.project_id = ANY(string_to_array(` + alias + `.path, '.')::uuid[]) ))` } // Note: visibilityPredicatePlaceholder expects (role, user_id) in that // order. Callers must match. We document this inline where used. -// insertProjektEvent appends one audit row in the given tx. -func insertProjektEvent(ctx context.Context, tx *sqlx.Tx, projektID, userID uuid.UUID, eventType, title string, description *string) error { +// insertProjectEvent appends one audit row in the given tx. +func insertProjectEvent(ctx context.Context, tx *sqlx.Tx, projektID, userID uuid.UUID, eventType, title string, description *string) error { now := time.Now().UTC() meta := json.RawMessage(`{}`) _, err := tx.ExecContext(ctx, - `INSERT INTO paliad.projekt_events - (id, projekt_id, event_type, title, description, event_date, + `INSERT INTO paliad.project_events + (id, project_id, event_type, title, description, event_date, created_by, metadata, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $6, $6)`, uuid.New(), projektID, eventType, title, description, now, userID, meta) @@ -666,10 +666,10 @@ func insertProjektEvent(ctx context.Context, tx *sqlx.Tx, projektID, userID uuid return nil } -func isValidProjektType(t string) bool { +func isValidProjectType(t string) bool { switch t { - case ProjektTypeClient, ProjektTypeLitigation, ProjektTypePatent, - ProjektTypeCase, ProjektTypeProject: + case ProjectTypeClient, ProjectTypeLitigation, ProjectTypePatent, + ProjectTypeCase, ProjectTypeProject: return true } return false @@ -683,7 +683,7 @@ func validateProjektStatus(s string) error { return fmt.Errorf("%w: invalid status %q", ErrInvalidInput, s) } -func sortByOrder(xs []models.Projekt, order map[uuid.UUID]int) { +func sortByOrder(xs []models.Project, order map[uuid.UUID]int) { // Insertion sort — ancestor lists are short (<20). for i := 1; i < len(xs); i++ { for j := i; j > 0 && order[xs[j].ID] < order[xs[j-1].ID]; j-- { diff --git a/internal/services/reminder_service.go b/internal/services/reminder_service.go index a5f59b6..2dc26cb 100644 --- a/internal/services/reminder_service.go +++ b/internal/services/reminder_service.go @@ -1,7 +1,7 @@ // Package services — ReminderService — hourly deadline-reminder emails. // // Runs one goroutine for the process lifetime. Every hour it scans -// paliad.fristen for Fristen that need a reminder, then issues the mail +// paliad.deadlines for Deadlines that need a reminder, then issues the mail // through MailService and records the delivery in paliad.reminder_log so the // next tick doesn't double-send. // @@ -11,18 +11,18 @@ // * tomorrow — due_date = today+1, status = pending. // Pre-deadline nudge. // * weekly — Monday only: due_date BETWEEN today AND today+7. -// Summary table of the week's Fristen. One email per user -// aggregating every Frist they created. +// Summary table of the week's Deadlines. One email per user +// aggregating every Deadline they created. // // Dedup window: 24h. The service refuses to resend the same (user, -// reminder_type, frist_id) pair if a row was inserted in the last 24 hours. -// This means at most one overdue / tomorrow email per Frist per day, and +// reminder_type, deadline_id) pair if a row was inserted in the last 24 hours. +// This means at most one overdue / tomorrow email per Deadline per day, and // at most one weekly email per user per Monday. // -// Recipient selection: the Frist.CreatedBy user — that is, whoever set up +// Recipient selection: the Deadline.CreatedBy user — that is, whoever set up // the deadline. Collaborators on the Akte are not notified (avoids spam when // five people share an Akte). A future refinement could add an opt-in -// preference table; for now, Frist owner only. +// preference table; for now, Deadline owner only. package services import ( @@ -38,13 +38,13 @@ import ( "github.com/jmoiron/sqlx" ) -// reminderTickInterval controls how often the service checks for due Fristen. +// reminderTickInterval controls how often the service checks for due Deadlines. // Hourly is enough given the 24h dedup window and the "tomorrow / today" // granularity — we don't need minute-precision. const reminderTickInterval = time.Hour // reminderDedupWindow is the minimum gap between identical reminders (same -// user, same type, same Frist). 24h matches the "one per day" policy. +// user, same type, same Deadline). 24h matches the "one per day" policy. const reminderDedupWindow = 24 * time.Hour // ReminderService wires the hourly reminder job. Construct with NewReminderService, @@ -109,7 +109,7 @@ func (s *ReminderService) loop(ctx context.Context) { // RunOnce performs one scan+send pass. Exposed so tests (and, later, an // admin trigger endpoint) can exercise the path without waiting for the -// ticker. Errors on individual Fristen are logged and swallowed so one bad +// ticker. Errors on individual Deadlines are logged and swallowed so one bad // row doesn't block the rest of the scan. func (s *ReminderService) RunOnce(ctx context.Context) { now := s.clock() @@ -129,11 +129,11 @@ func (s *ReminderService) RunOnce(ctx context.Context) { } } -// fristReminderRow is the projection needed to render a per-Frist email. +// fristReminderRow is the projection needed to render a per-Deadline email. // We join the parent Akte for its Aktenzeichen / title and the user row for // the preferred language and notification preferences. type fristReminderRow struct { - FristID uuid.UUID `db:"frist_id"` + DeadlineID uuid.UUID `db:"deadline_id"` FristTitle string `db:"frist_title"` DueDate time.Time `db:"due_date"` AkteAktenzeichen string `db:"akte_aktenzeichen"` @@ -145,7 +145,7 @@ type fristReminderRow struct { UserEmailPreferences json.RawMessage `db:"user_email_preferences"` } -// sendPerFrist covers the two per-Frist reminder kinds. The query filters on +// sendPerFrist covers the two per-Deadline reminder kinds. The query filters on // due_date and the dedup table in a single round-trip so concurrent workers // can't both decide to send (though we only run one reminder process). func (s *ReminderService) sendPerFrist(ctx context.Context, today time.Time, kind string) error { @@ -159,7 +159,7 @@ func (s *ReminderService) sendPerFrist(ctx context.Context, today time.Time, kin return fmt.Errorf("unknown kind %q", kind) } - // Overdue is "<= today" — include older still-pending Fristen. Tomorrow is + // Overdue is "<= today" — include older still-pending Deadlines. Tomorrow is // an exact match. var cond string if kind == "overdue" { @@ -169,7 +169,7 @@ func (s *ReminderService) sendPerFrist(ctx context.Context, today time.Time, kin } query := ` - SELECT f.id AS frist_id, + SELECT f.id AS deadline_id, f.title AS frist_title, f.due_date AS due_date, COALESCE(a.reference, '') AS akte_aktenzeichen, @@ -179,8 +179,8 @@ func (s *ReminderService) sendPerFrist(ctx context.Context, today time.Time, kin u.display_name AS user_display_name, u.lang AS user_lang, u.email_preferences AS user_email_preferences - FROM paliad.fristen f - JOIN paliad.projekte a ON a.id = f.projekt_id + FROM paliad.deadlines f + JOIN paliad.projects a ON a.id = f.project_id JOIN paliad.users u ON u.id = f.created_by WHERE f.status = 'pending' AND ` + cond + ` @@ -188,7 +188,7 @@ func (s *ReminderService) sendPerFrist(ctx context.Context, today time.Time, kin SELECT 1 FROM paliad.reminder_log r WHERE r.user_id = u.id AND r.reminder_type = $2 - AND r.frist_id = f.id + AND r.deadline_id = f.id AND r.sent_at >= $3 )` @@ -196,7 +196,7 @@ func (s *ReminderService) sendPerFrist(ctx context.Context, today time.Time, kin if err := s.db.SelectContext(ctx, &rows, query, dueDate, kind, s.clock().Add(-reminderDedupWindow), ); err != nil { - return fmt.Errorf("select fristen for %s: %w", kind, err) + return fmt.Errorf("select deadlines for %s: %w", kind, err) } for _, r := range rows { @@ -205,7 +205,7 @@ func (s *ReminderService) sendPerFrist(ctx context.Context, today time.Time, kin } if err := s.deliverFristReminder(ctx, kind, r); err != nil { slog.Warn("reminder: deliver failed", - "kind", kind, "frist_id", r.FristID, "user_id", r.UserID, "error", err) + "kind", kind, "deadline_id", r.DeadlineID, "user_id", r.UserID, "error", err) continue } } @@ -225,7 +225,7 @@ func (s *ReminderService) deliverFristReminder(ctx context.Context, kind string, "DueDate": r.DueDate.Format("2006-01-02"), "AkteAktenzeichen": r.AkteAktenzeichen, "AkteTitle": r.AkteTitle, - "FristURL": fmt.Sprintf("%s/fristen/%s", s.baseURL, r.FristID), + "FristURL": fmt.Sprintf("%s/deadlines/%s", s.baseURL, r.DeadlineID), } if err := s.mail.SendTemplate(TemplateData{ To: r.UserEmail, @@ -236,10 +236,10 @@ func (s *ReminderService) deliverFristReminder(ctx context.Context, kind string, }); err != nil { return fmt.Errorf("send: %w", err) } - return s.logSend(ctx, r.UserID, &r.FristID, kind) + return s.logSend(ctx, r.UserID, &r.DeadlineID, kind) } -// weeklyRow captures one user's batch of upcoming Fristen plus their +// weeklyRow captures one user's batch of upcoming Deadlines plus their // preferred language. We hold rows per-user in memory and emit one email // per user with the aggregated table. type weeklyRow struct { @@ -249,7 +249,7 @@ type weeklyRow struct { UserLang string `db:"user_lang"` UserEmailPreferences json.RawMessage `db:"user_email_preferences"` - FristID uuid.UUID `db:"frist_id"` + DeadlineID uuid.UUID `db:"deadline_id"` FristTitle string `db:"frist_title"` DueDate time.Time `db:"due_date"` AkteAktenzeichen string `db:"akte_aktenzeichen"` @@ -293,12 +293,12 @@ func (s *ReminderService) sendWeekly(ctx context.Context, today time.Time) error u.display_name AS user_display_name, u.lang AS user_lang, u.email_preferences AS user_email_preferences, - f.id AS frist_id, + f.id AS deadline_id, f.title AS frist_title, f.due_date AS due_date, COALESCE(a.reference, '') AS akte_aktenzeichen - FROM paliad.fristen f - JOIN paliad.projekte a ON a.id = f.projekt_id + FROM paliad.deadlines f + JOIN paliad.projects a ON a.id = f.project_id JOIN paliad.users u ON u.id = f.created_by WHERE f.status = 'pending' AND f.due_date >= $1 @@ -372,7 +372,7 @@ func (s *ReminderService) deliverWeekly(ctx context.Context, today time.Time, ro "DueDate": r.DueDate.Format("2006-01-02"), "Title": r.FristTitle, "AkteAktenzeichen": r.AkteAktenzeichen, - "URL": fmt.Sprintf("%s/fristen/%s", s.baseURL, r.FristID), + "URL": fmt.Sprintf("%s/deadlines/%s", s.baseURL, r.DeadlineID), "Overdue": r.DueDate.Before(today), }) } @@ -386,7 +386,7 @@ func (s *ReminderService) deliverWeekly(ctx context.Context, today time.Time, ro Data: map[string]any{ "Count": len(rows), "Items": items, - "FristenURL": fmt.Sprintf("%s/fristen", s.baseURL), + "FristenURL": fmt.Sprintf("%s/deadlines", s.baseURL), }, }); err != nil { return fmt.Errorf("send weekly: %w", err) @@ -396,7 +396,7 @@ func (s *ReminderService) deliverWeekly(ctx context.Context, today time.Time, ro func (s *ReminderService) logSend(ctx context.Context, userID uuid.UUID, fristID *uuid.UUID, kind string) error { if _, err := s.db.ExecContext(ctx, - `INSERT INTO paliad.reminder_log (user_id, reminder_type, frist_id) + `INSERT INTO paliad.reminder_log (user_id, reminder_type, deadline_id) VALUES ($1, $2, $3)`, userID, kind, fristID, ); err != nil { @@ -420,11 +420,11 @@ func buildSubject(kind, lang, title string, count int) string { } switch kind { case "overdue": - return "[Paliad] Frist überfällig: " + title + return "[Paliad] Deadline überfällig: " + title case "tomorrow": - return "[Paliad] Frist morgen: " + title + return "[Paliad] Deadline morgen: " + title case "weekly": - return fmt.Sprintf("[Paliad] Wochenübersicht: %d Fristen", count) + return fmt.Sprintf("[Paliad] Wochenübersicht: %d Deadlines", count) } return "[Paliad] Erinnerung" } diff --git a/internal/services/team_service.go b/internal/services/team_service.go index 92246d4..c92988e 100644 --- a/internal/services/team_service.go +++ b/internal/services/team_service.go @@ -1,8 +1,8 @@ package services -// TeamService manages paliad.projekt_teams — project team memberships. +// TeamService manages paliad.project_teams — project team memberships. // -// Inheritance model (t-paliad-024): a user added at any ancestor of a Projekt +// Inheritance model (t-paliad-024): a user added at any ancestor of a Project // is implicitly a member of every descendant. Writes only ever touch the // direct level; inherited memberships are computed at read time by walking // UP the materialised path. @@ -23,22 +23,22 @@ import ( "mgit.msbls.de/m/patholo/internal/models" ) -// TeamService reads and writes paliad.projekt_teams. +// TeamService reads and writes paliad.project_teams. type TeamService struct { db *sqlx.DB - projekte *ProjektService + projects *ProjectService } // NewTeamService wires the service. -func NewTeamService(db *sqlx.DB, projekte *ProjektService) *TeamService { - return &TeamService{db: db, projekte: projekte} +func NewTeamService(db *sqlx.DB, projects *ProjectService) *TeamService { + return &TeamService{db: db, projects: projects} } // AddMember inserts a direct team membership. The caller must have visibility -// on the Projekt (RLS + service-layer gate). Role defaults to 'associate' -// if empty. Idempotent on (projekt_id, user_id) — a repeat call updates role. -func (s *TeamService) AddMember(ctx context.Context, callerID, projektID, userID uuid.UUID, role string) (*models.ProjektTeamMember, error) { - if _, err := s.projekte.GetByID(ctx, callerID, projektID); err != nil { +// on the Project (RLS + service-layer gate). Role defaults to 'associate' +// if empty. Idempotent on (project_id, user_id) — a repeat call updates role. +func (s *TeamService) AddMember(ctx context.Context, callerID, projektID, userID uuid.UUID, role string) (*models.ProjectTeamMember, error) { + if _, err := s.projects.GetByID(ctx, callerID, projektID); err != nil { return nil, err } if role == "" { @@ -48,13 +48,13 @@ func (s *TeamService) AddMember(ctx context.Context, callerID, projektID, userID return nil, fmt.Errorf("%w: invalid role %q", ErrInvalidInput, role) } - var m models.ProjektTeamMember + var m models.ProjectTeamMember err := s.db.GetContext(ctx, &m, - `INSERT INTO paliad.projekt_teams (projekt_id, user_id, role, inherited, added_by) + `INSERT INTO paliad.project_teams (project_id, user_id, role, inherited, added_by) VALUES ($1, $2, $3, false, $4) - ON CONFLICT (projekt_id, user_id) DO UPDATE + ON CONFLICT (project_id, user_id) DO UPDATE SET role = EXCLUDED.role - RETURNING id, projekt_id, user_id, role, inherited, added_by, created_at`, + RETURNING id, project_id, user_id, role, inherited, added_by, created_at`, projektID, userID, role, callerID) if err != nil { return nil, fmt.Errorf("add team member: %w", err) @@ -66,12 +66,12 @@ func (s *TeamService) AddMember(ctx context.Context, callerID, projektID, userID // ancestors) can't be removed at the child level — the caller must remove // the ancestor row to break the inheritance. func (s *TeamService) RemoveMember(ctx context.Context, callerID, projektID, userID uuid.UUID) error { - if _, err := s.projekte.GetByID(ctx, callerID, projektID); err != nil { + if _, err := s.projects.GetByID(ctx, callerID, projektID); err != nil { return err } res, err := s.db.ExecContext(ctx, - `DELETE FROM paliad.projekt_teams - WHERE projekt_id = $1 AND user_id = $2 AND inherited = false`, + `DELETE FROM paliad.project_teams + WHERE project_id = $1 AND user_id = $2 AND inherited = false`, projektID, userID) if err != nil { return fmt.Errorf("remove team member: %w", err) @@ -84,22 +84,22 @@ func (s *TeamService) RemoveMember(ctx context.Context, callerID, projektID, use // ListDirectMembers returns only the direct (non-inherited) team members, // enriched with user display fields. -func (s *TeamService) ListDirectMembers(ctx context.Context, callerID, projektID uuid.UUID) ([]models.ProjektTeamMemberWithUser, error) { - if _, err := s.projekte.GetByID(ctx, callerID, projektID); err != nil { +func (s *TeamService) ListDirectMembers(ctx context.Context, callerID, projektID uuid.UUID) ([]models.ProjectTeamMemberWithUser, error) { + if _, err := s.projects.GetByID(ctx, callerID, projektID); err != nil { return nil, err } - var rows []models.ProjektTeamMemberWithUser + var rows []models.ProjectTeamMemberWithUser err := s.db.SelectContext(ctx, &rows, - `SELECT pt.id, pt.projekt_id, pt.user_id, pt.role, pt.inherited, + `SELECT pt.id, pt.project_id, pt.user_id, pt.role, pt.inherited, pt.added_by, pt.created_at, u.email AS user_email, u.display_name AS user_display_name, u.office AS user_office, NULL::uuid AS inherited_from_id, NULL::text AS inherited_from_title - FROM paliad.projekt_teams pt + FROM paliad.project_teams pt LEFT JOIN paliad.users u ON u.id = pt.user_id - WHERE pt.projekt_id = $1 + WHERE pt.project_id = $1 ORDER BY pt.role, u.display_name`, projektID) if err != nil { return nil, fmt.Errorf("list direct team: %w", err) @@ -107,25 +107,25 @@ func (s *TeamService) ListDirectMembers(ctx context.Context, callerID, projektID return rows, nil } -// ListEffectiveMembers returns direct + inherited members of a Projekt. +// ListEffectiveMembers returns direct + inherited members of a Project. // Rows coming from an ancestor carry Inherited=true + InheritedFromID/Title. // If the same user is both direct and inherited, the direct row wins. -func (s *TeamService) ListEffectiveMembers(ctx context.Context, callerID, projektID uuid.UUID) ([]models.ProjektTeamMemberWithUser, error) { - projekt, err := s.projekte.GetByID(ctx, callerID, projektID) +func (s *TeamService) ListEffectiveMembers(ctx context.Context, callerID, projektID uuid.UUID) ([]models.ProjectTeamMemberWithUser, error) { + project, err := s.projects.GetByID(ctx, callerID, projektID) if err != nil { return nil, err } - ancestorIDs := pathToIDStrings(projekt.Path) + ancestorIDs := pathToIDStrings(project.Path) query := ` WITH candidate AS ( - SELECT pt.id, pt.projekt_id, pt.user_id, pt.role, pt.added_by, pt.created_at, - (pt.projekt_id <> $1) AS inherited, - CASE WHEN pt.projekt_id <> $1 THEN pt.projekt_id END AS inherited_from_id, - CASE WHEN pt.projekt_id <> $1 THEN parent.title END AS inherited_from_title - FROM paliad.projekt_teams pt - LEFT JOIN paliad.projekte parent ON parent.id = pt.projekt_id - WHERE pt.projekt_id = ANY($2::uuid[]) + SELECT pt.id, pt.project_id, pt.user_id, pt.role, pt.added_by, pt.created_at, + (pt.project_id <> $1) AS inherited, + CASE WHEN pt.project_id <> $1 THEN pt.project_id END AS inherited_from_id, + CASE WHEN pt.project_id <> $1 THEN parent.title END AS inherited_from_title + FROM paliad.project_teams pt + LEFT JOIN paliad.projects parent ON parent.id = pt.project_id + WHERE pt.project_id = ANY($2::uuid[]) ), ranked AS ( SELECT c.*, ROW_NUMBER() OVER ( @@ -133,7 +133,7 @@ func (s *TeamService) ListEffectiveMembers(ctx context.Context, callerID, projek ORDER BY c.inherited ASC, c.created_at ASC ) AS rn FROM candidate c ) - SELECT r.id, r.projekt_id, r.user_id, r.role, r.inherited, + SELECT r.id, r.project_id, r.user_id, r.role, r.inherited, r.added_by, r.created_at, u.email AS user_email, u.display_name AS user_display_name, @@ -145,7 +145,7 @@ func (s *TeamService) ListEffectiveMembers(ctx context.Context, callerID, projek WHERE r.rn = 1 ORDER BY r.inherited ASC, r.role, u.display_name` - var rows []models.ProjektTeamMemberWithUser + var rows []models.ProjectTeamMemberWithUser if err := s.db.SelectContext(ctx, &rows, query, projektID, pq.StringArray(ancestorIDs)); err != nil { return nil, fmt.Errorf("list effective team: %w", err) } @@ -157,9 +157,9 @@ func (s *TeamService) IsEffectiveMember(ctx context.Context, projektID, userID u var ok bool err := s.db.GetContext(ctx, &ok, `SELECT EXISTS ( - SELECT 1 FROM paliad.projekte p - JOIN paliad.projekt_teams pt - ON pt.projekt_id = ANY(string_to_array(p.path, '.')::uuid[]) + SELECT 1 FROM paliad.projects p + JOIN paliad.project_teams pt + ON pt.project_id = ANY(string_to_array(p.path, '.')::uuid[]) WHERE p.id = $1 AND pt.user_id = $2 )`, projektID, userID) if err != nil {