refactor(rename): German→English for backend (tables, types, services, handler files)

t-paliad-025 — Phase 1: backend rename.

Migrations 018+019 rewritten from scratch with English table/column
names throughout. Since v2 schema (018/019) has never been applied to
youpc prod DB, this is a clean replacement — not an ALTER RENAME chain.
Pre-existing German tables (parteien, fristen, termine, dokumente,
akten_events, notizen) are renamed inline in 018 via ALTER TABLE … RENAME
TO alongside the akte_id → project_id column rewrite.

Renames applied:
  projekte            → projects
  projekt_teams       → project_teams
  projekt_events      → project_events (via akten_events → project_events)
  fristen             → deadlines
  termine             → appointments
  parteien            → parties
  notizen             → notes
  dezernate           → departments
  dezernat_mitglieder → department_members
  dokumente           → documents
  can_see_projekt     → can_see_project
  notiz_is_visible    → note_is_visible
  akte_id  / frist_id / termin_id / akten_event_id → project_id /
    deadline_id / appointment_id / project_event_id
  termin_type → appointment_type

Go types + services renamed:
  Projekt / ProjektService / ProjektEvent / ProjektTeamMember
  Frist / FristService / FristWithProjekt
  Termin / TerminService / TerminWithProjekt / TerminType
  Notiz / NotizService / ChecklistInstanceWithProjekt
  Dezernat / DezernatService / DezernatMitglied
  Partei / Parteien / ParteienService

Files renamed (git mv):
  internal/services/{projekt,frist,termin,notiz,dezernat,parteien}_service.go
    → {project,deadline,appointment,note,department,party}_service.go
  internal/handlers/{projekte,fristen,fristen_pages,termine,termine_pages,
    notizen,dezernate,akten_pages,gerichte,glossar,checklisten}.go
    → {projects,deadlines,deadlines_pages,appointments,appointments_pages,
       notes,departments,projects_pages,courts,glossary,checklists}.go
  internal/checklisten/ → internal/checklists/
  internal/db/migrations/018_projekte_v2.* → 018_projects_v2.*
  internal/db/migrations/019_seed_dezernate_from_user_text.*
    → 019_seed_departments_from_user_text.*

User-facing i18n strings (DE/EN labels) stay untouched. Product names
Fristenrechner / Kostenrechner / Gebührentabellen stay German.

Build + vet + tests clean.
This commit is contained in:
m
2026-04-20 17:35:38 +02:00
parent eb6de16e88
commit 3faec6c526
46 changed files with 2339 additions and 2269 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": "<uuid>"}
// POST /api/departments/{id}/members — admin-only. Body: {"user_id": "<uuid>"}
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

View File

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

View File

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

View File

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

View File

@@ -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.",
},
{

View File

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

View File

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

View File

@@ -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=<uuid>&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
}

View File

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

View File

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

View File

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

View File

@@ -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 "<DisplayName> (<Email>) — <Role>" 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 <Name>" 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: <name>".
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

View File

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

View File

@@ -11,10 +11,10 @@ import (
// to the HTML.
func TestHTMLToText(t *testing.T) {
in := `<html><head><style>b{color:red}</style></head><body>` +
`<h1>Frist &uuml;berf&auml;llig</h1><p>Hallo <b>Welt</b></p>` +
`<h1>Deadline &uuml;berf&auml;llig</h1><p>Hallo <b>Welt</b></p>` +
`<p>Zweite Zeile &mdash; ok.</p><script>alert(1)</script></body></html>`
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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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