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:
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
@@ -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"},
|
||||
@@ -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;
|
||||
656
internal/db/migrations/018_projects_v2.up.sql
Normal file
656
internal/db/migrations/018_projects_v2.up.sql
Normal 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))
|
||||
);
|
||||
@@ -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))
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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) <> ''
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -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"))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 {
|
||||
@@ -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
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 überfällig</h1><p>Hallo <b>Welt</b></p>` +
|
||||
`<h1>Deadline überfällig</h1><p>Hallo <b>Welt</b></p>` +
|
||||
`<p>Zweite Zeile — 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)
|
||||
|
||||
337
internal/services/note_service.go
Normal file
337
internal/services/note_service.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
121
internal/services/party_service.go
Normal file
121
internal/services/party_service.go
Normal 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
|
||||
}
|
||||
@@ -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-- {
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user