From 76785da3f68af142fe9193aa007baf95fca89f75 Mon Sep 17 00:00:00 2001 From: m Date: Wed, 29 Apr 2026 22:03:08 +0200 Subject: [PATCH] =?UTF-8?q?feat(t-paliad-070):=20rename=20Department=20?= =?UTF-8?q?=E2=86=92=20PartnerUnit=20on=20the=20Go=20side?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend rename (frontend lands in next commit): - Migration 026: rename paliad.departments → paliad.partner_units, paliad.department_members → paliad.partner_unit_members, junction FK department_id → partner_unit_id, plus all constraints/indexes/policies. Pre-drop seed re-runs migration 019's logic to capture any users.dezernat drift, then DROP COLUMN. Adds paliad.partner_unit_events audit table with RLS (any-authenticated read, global_admin write). - models.User.Dezernat dropped. Department / DepartmentMember → PartnerUnit / PartnerUnitMember. - DepartmentService → PartnerUnitService (file renamed via git mv to preserve blame). Every mutation now opens a tx and emits a partner_unit_events row in the same tx (created/updated/deleted/ member_added/member_removed). Update emits before/after snapshots; Delete emits BEFORE the cascade so the FK still resolves, then ON DELETE SET NULL keeps the historical row. - /api/departments/* → /api/partner-units/*. Handlers renamed. - New /admin/partner-units page handler stub. - AuditService UNIONs the new partner_unit_events source as a 4th branch; handler accepts AuditSourcePartnerUnitEvents. - user_service: drop dezernat from CreateUserInput / UpdateProfileInput / AdminCreateInput / AdminUpdateInput. CreateUserInput gains PartnerUnitID *uuid.UUID — onboarding can pick an initial unit and the membership row + audit event are inserted in the same tx. - Settings tab aliases drop dezernat/department. - Legacy /dezernate and /departments now redirect to /admin/partner-units (admins only see it; non-admins land on the forbidden bounce). go build / vet / test compile clean. --- cmd/server/main.go | 4 +- .../026_rename_to_partner_units.down.sql | 48 ++ .../026_rename_to_partner_units.up.sql | 131 +++++ internal/handlers/admin_users.go | 6 + internal/handlers/appointments_pages.go | 10 +- internal/handlers/audit.go | 14 +- internal/handlers/handlers.go | 23 +- .../{departments.go => partner_units.go} | 67 +-- internal/handlers/projects.go | 2 +- internal/handlers/redirects.go | 3 +- internal/models/models.go | 21 +- internal/services/audit_service.go | 34 +- internal/services/department_service.go | 308 ----------- internal/services/partner_unit_service.go | 510 ++++++++++++++++++ internal/services/user_service.go | 147 ++--- internal/services/user_service_test.go | 5 - 16 files changed, 882 insertions(+), 451 deletions(-) create mode 100644 internal/db/migrations/026_rename_to_partner_units.down.sql create mode 100644 internal/db/migrations/026_rename_to_partner_units.up.sql rename internal/handlers/{departments.go => partner_units.go} (62%) delete mode 100644 internal/services/department_service.go create mode 100644 internal/services/partner_unit_service.go diff --git a/cmd/server/main.go b/cmd/server/main.go index e13c24f..224c13b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -84,7 +84,7 @@ func main() { users := services.NewUserService(pool) projectSvc := services.NewProjectService(pool, users) teamSvc := services.NewTeamService(pool, projectSvc) - departmentSvc := services.NewDepartmentService(pool, users) + partnerUnitSvc := services.NewPartnerUnitService(pool, users) rules := services.NewDeadlineRuleService(pool) // Phase F: optional CalDAV cipher. If CALDAV_ENCRYPTION_KEY is unset @@ -114,7 +114,7 @@ func main() { svcBundle = &handlers.Services{ Project: projectSvc, Team: teamSvc, - Department: departmentSvc, + PartnerUnit: partnerUnitSvc, Party: services.NewPartyService(pool, projectSvc), Deadline: services.NewDeadlineService(pool, projectSvc), Appointment: appointmentSvc, diff --git a/internal/db/migrations/026_rename_to_partner_units.down.sql b/internal/db/migrations/026_rename_to_partner_units.down.sql new file mode 100644 index 0000000..bdc8891 --- /dev/null +++ b/internal/db/migrations/026_rename_to_partner_units.down.sql @@ -0,0 +1,48 @@ +-- Down migration for 026: revert the partner_units rename. +-- +-- Note: paliad.users.dezernat values cannot be perfectly restored. The +-- column is recreated as NULL; structural data (membership rows) is +-- preserved. Per design doc §7.3 — if a true free-text rollback is ever +-- needed, an admin script can reconstruct values from +-- partner_unit_members: +-- +-- UPDATE paliad.users u SET dezernat = ( +-- SELECT pu.name FROM paliad.partner_units pu +-- JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = pu.id +-- WHERE pum.user_id = u.id LIMIT 1) +-- WHERE u.dezernat IS NULL; +-- +-- That step is not auto-run because most rollbacks are recoveries, not +-- data restorations. + +-- 1. Drop the audit table. +DROP TABLE IF EXISTS paliad.partner_unit_events; + +-- 2. Rename RLS policies back. +DO $$ BEGIN ALTER POLICY partner_units_select ON paliad.partner_units RENAME TO departments_select; EXCEPTION WHEN undefined_object THEN NULL; END $$; +DO $$ BEGIN ALTER POLICY partner_units_write ON paliad.partner_units RENAME TO departments_write; EXCEPTION WHEN undefined_object THEN NULL; END $$; +DO $$ BEGIN ALTER POLICY partner_unit_members_select ON paliad.partner_unit_members RENAME TO department_members_select; EXCEPTION WHEN undefined_object THEN NULL; END $$; +DO $$ BEGIN ALTER POLICY partner_unit_members_write ON paliad.partner_unit_members RENAME TO department_members_write; EXCEPTION WHEN undefined_object THEN NULL; END $$; + +-- 3. Rename indexes back. +DO $$ BEGIN ALTER INDEX paliad.partner_units_office_idx RENAME TO departments_office_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$; +DO $$ BEGIN ALTER INDEX paliad.partner_units_lead_idx RENAME TO departments_lead_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$; +DO $$ BEGIN ALTER INDEX paliad.partner_unit_members_user_idx RENAME TO department_members_user_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$; + +-- 4. Rename constraints back. +DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT partner_units_pkey TO departments_pkey; EXCEPTION WHEN undefined_object THEN NULL; END $$; +DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT partner_units_lead_user_id_fkey TO departments_lead_user_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$; +DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT partner_units_office_check TO departments_office_check; EXCEPTION WHEN undefined_object THEN NULL; END $$; +DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT partner_unit_members_pkey TO department_members_pkey; EXCEPTION WHEN undefined_object THEN NULL; END $$; +DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT partner_unit_members_partner_unit_id_fkey TO department_members_department_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$; +DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT partner_unit_members_user_id_fkey TO department_members_user_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$; + +-- 5. Rename junction column back. +ALTER TABLE paliad.partner_unit_members RENAME COLUMN partner_unit_id TO department_id; + +-- 6. Rename tables back. +ALTER TABLE paliad.partner_unit_members RENAME TO department_members; +ALTER TABLE paliad.partner_units RENAME TO departments; + +-- 7. Recreate the legacy free-text column. Values are NULL. +ALTER TABLE paliad.users ADD COLUMN IF NOT EXISTS dezernat text; diff --git a/internal/db/migrations/026_rename_to_partner_units.up.sql b/internal/db/migrations/026_rename_to_partner_units.up.sql new file mode 100644 index 0000000..4d26879 --- /dev/null +++ b/internal/db/migrations/026_rename_to_partner_units.up.sql @@ -0,0 +1,131 @@ +-- t-paliad-070: Rename departments → partner_units across the schema, drop +-- the legacy users.dezernat free-text column, and add the +-- partner_unit_events audit table. +-- +-- Order of operations matters because constraint names are owned by their +-- owning table; we rename the table first, then the columns/constraints/ +-- policies that postgres did not auto-rename. +-- +-- Idempotent renames (DO $$ EXCEPTION WHEN undefined_object $$) are used +-- for constraint/index/policy steps so re-runs after a partial apply on +-- freshly-provisioned DBs (e.g. test DBs that may have run earlier +-- migrations under different names) do not abort the chain. + +-- --------------------------------------------------------------------------- +-- 1. Best-effort second seed of department_members from the legacy +-- users.dezernat free-text field. Mirror of migration 019 — re-run before +-- DROP COLUMN to capture any drift since 019 ran. Idempotent via +-- ON CONFLICT DO NOTHING. +-- --------------------------------------------------------------------------- +INSERT INTO paliad.departments (id, name, lead_user_id, office, created_at, updated_at) +SELECT gen_random_uuid(), + btrim(u.dezernat), + NULL, + MIN(u.office), + now(), + now() + FROM paliad.users u + WHERE u.dezernat IS NOT NULL + AND btrim(u.dezernat) <> '' + AND NOT EXISTS ( + SELECT 1 FROM paliad.departments d2 WHERE d2.name = btrim(u.dezernat) + ) + GROUP BY btrim(u.dezernat) +ON CONFLICT DO NOTHING; + +INSERT INTO paliad.department_members (department_id, user_id, created_at) +SELECT d.id, u.id, now() + FROM paliad.users u + JOIN paliad.departments d + ON d.name = btrim(u.dezernat) + WHERE u.dezernat IS NOT NULL + AND btrim(u.dezernat) <> '' +ON CONFLICT DO NOTHING; + +-- --------------------------------------------------------------------------- +-- 2. Drop the legacy free-text column. The structured side +-- (department_members) is the source of truth from here on. +-- --------------------------------------------------------------------------- +ALTER TABLE paliad.users DROP COLUMN IF EXISTS dezernat; + +-- --------------------------------------------------------------------------- +-- 3. Rename tables. +-- --------------------------------------------------------------------------- +ALTER TABLE paliad.departments RENAME TO partner_units; +ALTER TABLE paliad.department_members RENAME TO partner_unit_members; + +-- --------------------------------------------------------------------------- +-- 4. Rename junction column. +-- --------------------------------------------------------------------------- +ALTER TABLE paliad.partner_unit_members RENAME COLUMN department_id TO partner_unit_id; + +-- --------------------------------------------------------------------------- +-- 5. Rename constraints. Postgres auto-renames the underlying index for +-- pkey/uniq constraints; standalone indexes are renamed in step 6. +-- --------------------------------------------------------------------------- +DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_pkey TO partner_units_pkey; EXCEPTION WHEN undefined_object THEN NULL; END $$; +DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_lead_user_id_fkey TO partner_units_lead_user_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$; +DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_office_check TO partner_units_office_check; EXCEPTION WHEN undefined_object THEN NULL; END $$; +DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_pkey TO partner_unit_members_pkey; EXCEPTION WHEN undefined_object THEN NULL; END $$; +DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_department_id_fkey TO partner_unit_members_partner_unit_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$; +DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_user_id_fkey TO partner_unit_members_user_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$; + +-- --------------------------------------------------------------------------- +-- 6. Rename non-pkey indexes. +-- --------------------------------------------------------------------------- +DO $$ BEGIN ALTER INDEX paliad.departments_office_idx RENAME TO partner_units_office_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$; +DO $$ BEGIN ALTER INDEX paliad.departments_lead_idx RENAME TO partner_units_lead_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$; +DO $$ BEGIN ALTER INDEX paliad.department_members_user_idx RENAME TO partner_unit_members_user_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$; + +-- --------------------------------------------------------------------------- +-- 7. Rename RLS policies. +-- --------------------------------------------------------------------------- +DO $$ BEGIN ALTER POLICY departments_select ON paliad.partner_units RENAME TO partner_units_select; EXCEPTION WHEN undefined_object THEN NULL; END $$; +DO $$ BEGIN ALTER POLICY departments_write ON paliad.partner_units RENAME TO partner_units_write; EXCEPTION WHEN undefined_object THEN NULL; END $$; +DO $$ BEGIN ALTER POLICY department_members_select ON paliad.partner_unit_members RENAME TO partner_unit_members_select; EXCEPTION WHEN undefined_object THEN NULL; END $$; +DO $$ BEGIN ALTER POLICY department_members_write ON paliad.partner_unit_members RENAME TO partner_unit_members_write; EXCEPTION WHEN undefined_object THEN NULL; END $$; + +-- --------------------------------------------------------------------------- +-- 8. Audit table for partner-unit events. Mutations on partner_units + +-- partner_unit_members emit one row each, written in the same tx by +-- PartnerUnitService. The viewer in audit_service.go unions this source +-- in alongside project_events / caldav_sync_log / reminder_log. +-- +-- partner_unit_id is nullable + ON DELETE SET NULL so the historical +-- 'deleted' event survives the cascade that removes the unit row. +-- --------------------------------------------------------------------------- +CREATE TABLE paliad.partner_unit_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + partner_unit_id uuid NULL REFERENCES paliad.partner_units(id) ON DELETE SET NULL, + actor_id uuid NOT NULL REFERENCES auth.users(id), + event_type text NOT NULL CHECK (event_type IN ( + 'created', 'updated', 'deleted', 'member_added', 'member_removed' + )), + -- Snapshot of the unit's name at event time so deleted units still show + -- a human-readable label in the audit timeline (partner_unit_id is NULL + -- on deleted, so we can't JOIN through to partner_units.name). + unit_name text NOT NULL, + payload jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX partner_unit_events_unit_idx ON paliad.partner_unit_events(partner_unit_id, created_at DESC); +CREATE INDEX partner_unit_events_actor_idx ON paliad.partner_unit_events(actor_id, created_at DESC); +CREATE INDEX partner_unit_events_time_idx ON paliad.partner_unit_events(created_at DESC); + +-- RLS: read access matches /api/partner-units (any authenticated user); +-- writes only by global_admin (defence-in-depth — the service already +-- gates with requireAdmin). +ALTER TABLE paliad.partner_unit_events ENABLE ROW LEVEL SECURITY; + +CREATE POLICY partner_unit_events_select ON paliad.partner_unit_events + FOR SELECT USING (auth.uid() IS NOT NULL); + +CREATE POLICY partner_unit_events_write ON paliad.partner_unit_events + FOR INSERT WITH CHECK ( + EXISTS ( + SELECT 1 FROM paliad.users u + WHERE u.id = auth.uid() + AND u.global_role = 'global_admin' + ) + ); diff --git a/internal/handlers/admin_users.go b/internal/handlers/admin_users.go index a969033..1e7e162 100644 --- a/internal/handlers/admin_users.go +++ b/internal/handlers/admin_users.go @@ -162,3 +162,9 @@ func handleAdminTeamPage(w http.ResponseWriter, r *http.Request) { func handleAdminIndexPage(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "dist/admin.html") } + +// handleAdminPartnerUnitsPage serves the SPA shell for /admin/partner-units. +// Same gate pattern as the other /admin/* pages. +func handleAdminPartnerUnitsPage(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "dist/admin-partner-units.html") +} diff --git a/internal/handlers/appointments_pages.go b/internal/handlers/appointments_pages.go index 6f46ca5..22d7873 100644 --- a/internal/handlers/appointments_pages.go +++ b/internal/handlers/appointments_pages.go @@ -24,7 +24,7 @@ func handleAppointmentsCalendarPage(w http.ResponseWriter, r *http.Request) { } // handleSettingsPage serves the unified settings page with tabs for -// Profil / Benachrichtigungen / CalDAV / Dezernat. The active tab is picked +// Profil / Benachrichtigungen / CalDAV. The active tab is picked // client-side from ?tab= so switching tabs doesn't round-trip. func handleSettingsPage(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "dist/settings.html") @@ -32,17 +32,15 @@ func handleSettingsPage(w http.ResponseWriter, r *http.Request) { // settingsTabAliases maps every supported /settings/ deep-link to its // canonical ?tab= value the client TS understands. Both the German tab -// IDs (profil/benachrichtigungen/dezernat) and intuitive English aliases -// (profile/notifications/department) are accepted so bookmarks, smoke tests, -// and manually-typed URLs all land on the right tab. +// IDs (profil/benachrichtigungen) and intuitive English aliases +// (profile/notifications) are accepted so bookmarks, smoke tests, and +// manually-typed URLs all land on the right tab. var settingsTabAliases = map[string]string{ "profil": "profil", "profile": "profil", "benachrichtigungen": "benachrichtigungen", "notifications": "benachrichtigungen", "caldav": "caldav", - "dezernat": "dezernat", - "department": "dezernat", } // handleSettingsTabRedirect turns /settings/ into /settings?tab= diff --git a/internal/handlers/audit.go b/internal/handlers/audit.go index 174aea5..52dc27d 100644 --- a/internal/handlers/audit.go +++ b/internal/handlers/audit.go @@ -16,12 +16,14 @@ import ( // auth.RequireAdminFunc in handlers.go, so the in-handler logic can assume // the caller is a global_admin and only validate the request shape. -// GET /api/audit-log — paginated, filterable timeline across paliad's three -// audit sources (project_events, caldav_sync_log, reminder_log). +// GET /api/audit-log — paginated, filterable timeline across paliad's four +// audit sources (project_events, caldav_sync_log, reminder_log, +// partner_unit_events). // // Query params: // -// source — one of project_events, caldav_sync_log, reminder_log; empty = all +// source — one of project_events, caldav_sync_log, reminder_log, +// partner_unit_events; empty = all // from — ISO-8601 timestamp, inclusive lower bound // to — ISO-8601 timestamp, inclusive upper bound // q — free-text search (subject, description, title, event_type, actor) @@ -47,7 +49,11 @@ func handleListAuditLog(w http.ResponseWriter, r *http.Request) { } switch filter.Source { - case "", services.AuditSourceProjectEvents, services.AuditSourceCalDAVLog, services.AuditSourceReminderLog: + case "", + services.AuditSourceProjectEvents, + services.AuditSourceCalDAVLog, + services.AuditSourceReminderLog, + services.AuditSourcePartnerUnitEvents: // ok default: writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid source"}) diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 14ac52e..a8e29b5 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -38,7 +38,7 @@ func noCachePages(h http.Handler) http.Handler { type Services struct { Project *services.ProjectService Team *services.TeamService - Department *services.DepartmentService + PartnerUnit *services.PartnerUnitService Party *services.PartyService Deadline *services.DeadlineService Appointment *services.AppointmentService @@ -64,7 +64,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc dbSvc = &dbServices{ projects: svc.Project, team: svc.Team, - department: svc.Department, + partnerUnit: svc.PartnerUnit, parties: svc.Party, deadline: svc.Deadline, appointment: svc.Appointment, @@ -178,15 +178,15 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc protected.HandleFunc("POST /api/projects/{id}/team", handleAddProjectTeamMember) protected.HandleFunc("DELETE /api/projects/{id}/team/{user_id}", handleRemoveProjectTeamMember) - // Departments (structural teams). - protected.HandleFunc("GET /api/departments", handleListDepartments) - protected.HandleFunc("POST /api/departments", handleCreateDepartment) - protected.HandleFunc("GET /api/departments/{id}", handleGetDepartment) - protected.HandleFunc("PATCH /api/departments/{id}", handleUpdateDepartment) - protected.HandleFunc("DELETE /api/departments/{id}", handleDeleteDepartment) - protected.HandleFunc("GET /api/departments/{id}/members", handleListDepartmentMembers) - protected.HandleFunc("POST /api/departments/{id}/members", handleAddDepartmentMember) - protected.HandleFunc("DELETE /api/departments/{id}/members/{user_id}", handleRemoveDepartmentMember) + // Partner units (structural partner-led units; legacy "Dezernate"). + protected.HandleFunc("GET /api/partner-units", handleListPartnerUnits) + protected.HandleFunc("POST /api/partner-units", handleCreatePartnerUnit) + protected.HandleFunc("GET /api/partner-units/{id}", handleGetPartnerUnit) + protected.HandleFunc("PATCH /api/partner-units/{id}", handleUpdatePartnerUnit) + protected.HandleFunc("DELETE /api/partner-units/{id}", handleDeletePartnerUnit) + protected.HandleFunc("GET /api/partner-units/{id}/members", handleListPartnerUnitMembers) + protected.HandleFunc("POST /api/partner-units/{id}/members", handleAddPartnerUnitMember) + protected.HandleFunc("DELETE /api/partner-units/{id}/members/{user_id}", handleRemovePartnerUnitMember) protected.HandleFunc("DELETE /api/parties/{id}", handleDeleteParty) @@ -300,6 +300,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc protected.HandleFunc("GET /admin", adminGate(users, gateOnboarded(handleAdminIndexPage))) protected.HandleFunc("GET /admin/team", adminGate(users, gateOnboarded(handleAdminTeamPage))) protected.HandleFunc("GET /admin/audit-log", adminGate(users, gateOnboarded(handleAdminAuditLogPage))) + protected.HandleFunc("GET /admin/partner-units", adminGate(users, gateOnboarded(handleAdminPartnerUnitsPage))) protected.HandleFunc("GET /api/admin/users", adminGate(users, handleAdminListUsers)) protected.HandleFunc("POST /api/admin/users", adminGate(users, handleAdminCreateUser)) protected.HandleFunc("GET /api/admin/users/unonboarded", adminGate(users, handleAdminListUnonboarded)) diff --git a/internal/handlers/departments.go b/internal/handlers/partner_units.go similarity index 62% rename from internal/handlers/departments.go rename to internal/handlers/partner_units.go index 8950e7a..762baff 100644 --- a/internal/handlers/departments.go +++ b/internal/handlers/partner_units.go @@ -11,12 +11,13 @@ import ( "mgit.msbls.de/m/patholo/internal/services" ) -// GET /api/departments — list every Dezernat (readable by all authenticated users). +// GET /api/partner-units — list every PartnerUnit (readable by all +// authenticated users). // -// `?include=members` returns each department enriched with its lead's display -// name + email and the full members list. Used by the /team directory page so -// the frontend can render the "group by department" view with one fetch. -func handleListDepartments(w http.ResponseWriter, r *http.Request) { +// `?include=members` returns each unit enriched with its lead's display +// name + email and the full members list. Used by the /team directory page +// so the frontend can render the "group by partner unit" view with one fetch. +func handleListPartnerUnits(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } @@ -24,7 +25,7 @@ func handleListDepartments(w http.ResponseWriter, r *http.Request) { return } if r.URL.Query().Get("include") == "members" { - rows, err := dbSvc.department.ListWithMembers(r.Context()) + rows, err := dbSvc.partnerUnit.ListWithMembers(r.Context()) if err != nil { writeServiceError(w, err) return @@ -32,7 +33,7 @@ func handleListDepartments(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, rows) return } - rows, err := dbSvc.department.List(r.Context()) + rows, err := dbSvc.partnerUnit.List(r.Context()) if err != nil { writeServiceError(w, err) return @@ -40,8 +41,8 @@ func handleListDepartments(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, rows) } -// POST /api/departments — admin-only create. -func handleCreateDepartment(w http.ResponseWriter, r *http.Request) { +// POST /api/partner-units — admin-only create. +func handleCreatePartnerUnit(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } @@ -49,12 +50,12 @@ func handleCreateDepartment(w http.ResponseWriter, r *http.Request) { if !ok { return } - var input services.CreateDepartmentInput + var input services.CreatePartnerUnitInput if err := json.NewDecoder(r.Body).Decode(&input); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) return } - d, err := dbSvc.department.Create(r.Context(), uid, input) + d, err := dbSvc.partnerUnit.Create(r.Context(), uid, input) if err != nil { writeServiceError(w, err) return @@ -62,8 +63,8 @@ func handleCreateDepartment(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, d) } -// GET /api/departments/{id} -func handleGetDepartment(w http.ResponseWriter, r *http.Request) { +// GET /api/partner-units/{id} +func handleGetPartnerUnit(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } @@ -75,7 +76,7 @@ func handleGetDepartment(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } - d, err := dbSvc.department.GetByID(r.Context(), id) + d, err := dbSvc.partnerUnit.GetByID(r.Context(), id) if err != nil { if errors.Is(err, sql.ErrNoRows) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) @@ -87,8 +88,8 @@ func handleGetDepartment(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, d) } -// PATCH /api/departments/{id} — admin-only. -func handleUpdateDepartment(w http.ResponseWriter, r *http.Request) { +// PATCH /api/partner-units/{id} — admin-only. +func handleUpdatePartnerUnit(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } @@ -101,12 +102,12 @@ func handleUpdateDepartment(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } - var input services.UpdateDepartmentInput + var input services.UpdatePartnerUnitInput if err := json.NewDecoder(r.Body).Decode(&input); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) return } - d, err := dbSvc.department.Update(r.Context(), uid, id, input) + d, err := dbSvc.partnerUnit.Update(r.Context(), uid, id, input) if err != nil { writeServiceError(w, err) return @@ -114,8 +115,8 @@ func handleUpdateDepartment(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, d) } -// DELETE /api/departments/{id} — admin-only. -func handleDeleteDepartment(w http.ResponseWriter, r *http.Request) { +// DELETE /api/partner-units/{id} — admin-only. +func handleDeletePartnerUnit(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } @@ -128,15 +129,15 @@ func handleDeleteDepartment(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } - if err := dbSvc.department.Delete(r.Context(), uid, id); err != nil { + if err := dbSvc.partnerUnit.Delete(r.Context(), uid, id); err != nil { writeServiceError(w, err) return } w.WriteHeader(http.StatusNoContent) } -// GET /api/departments/{id}/members -func handleListDepartmentMembers(w http.ResponseWriter, r *http.Request) { +// GET /api/partner-units/{id}/members +func handleListPartnerUnitMembers(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } @@ -148,7 +149,7 @@ func handleListDepartmentMembers(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } - rows, err := dbSvc.department.ListMembers(r.Context(), id) + rows, err := dbSvc.partnerUnit.ListMembers(r.Context(), id) if err != nil { writeServiceError(w, err) return @@ -156,8 +157,8 @@ func handleListDepartmentMembers(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, rows) } -// POST /api/departments/{id}/members — admin-only. Body: {"user_id": ""} -func handleAddDepartmentMember(w http.ResponseWriter, r *http.Request) { +// POST /api/partner-units/{id}/members — admin-only. Body: {"user_id": ""} +func handleAddPartnerUnitMember(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } @@ -165,7 +166,7 @@ func handleAddDepartmentMember(w http.ResponseWriter, r *http.Request) { if !ok { return } - departmentID, err := uuid.Parse(r.PathValue("id")) + partnerUnitID, err := uuid.Parse(r.PathValue("id")) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return @@ -177,15 +178,15 @@ func handleAddDepartmentMember(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) return } - if err := dbSvc.department.AddMember(r.Context(), uid, departmentID, body.UserID); err != nil { + if err := dbSvc.partnerUnit.AddMember(r.Context(), uid, partnerUnitID, body.UserID); err != nil { writeServiceError(w, err) return } w.WriteHeader(http.StatusNoContent) } -// DELETE /api/departments/{id}/members/{user_id} — admin-only. -func handleRemoveDepartmentMember(w http.ResponseWriter, r *http.Request) { +// DELETE /api/partner-units/{id}/members/{user_id} — admin-only. +func handleRemovePartnerUnitMember(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } @@ -193,9 +194,9 @@ func handleRemoveDepartmentMember(w http.ResponseWriter, r *http.Request) { if !ok { return } - departmentID, err := uuid.Parse(r.PathValue("id")) + partnerUnitID, err := uuid.Parse(r.PathValue("id")) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid dezernat id"}) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid partner_unit id"}) return } userID, err := uuid.Parse(r.PathValue("user_id")) @@ -203,7 +204,7 @@ func handleRemoveDepartmentMember(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid user id"}) return } - if err := dbSvc.department.RemoveMember(r.Context(), uid, departmentID, userID); err != nil { + if err := dbSvc.partnerUnit.RemoveMember(r.Context(), uid, partnerUnitID, userID); err != nil { writeServiceError(w, err) return } diff --git a/internal/handlers/projects.go b/internal/handlers/projects.go index 65e26c3..c0af339 100644 --- a/internal/handlers/projects.go +++ b/internal/handlers/projects.go @@ -18,7 +18,7 @@ import ( type dbServices struct { projects *services.ProjectService team *services.TeamService - department *services.DepartmentService + partnerUnit *services.PartnerUnitService parties *services.PartyService deadline *services.DeadlineService appointment *services.AppointmentService diff --git a/internal/handlers/redirects.go b/internal/handlers/redirects.go index 6faa257..9c8cb8b 100644 --- a/internal/handlers/redirects.go +++ b/internal/handlers/redirects.go @@ -24,7 +24,8 @@ func registerLegacyRedirects(mux *http.ServeMux) { "/notizen": "/notes", "/einstellungen": "/settings", "/checklisten": "/checklists", - "/dezernate": "/departments", + "/dezernate": "/admin/partner-units", + "/departments": "/admin/partner-units", "/parteien": "/parties", "/gerichte": "/courts", "/glossar": "/glossary", diff --git a/internal/models/models.go b/internal/models/models.go index 5c3c9f8..de7e2c3 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -31,8 +31,7 @@ type User struct { // Drives every permission gate that used to look at the legacy // role='admin'. Per-project authority is on paliad.project_teams.role and // is unrelated. - GlobalRole string `db:"global_role" json:"global_role"` - Dezernat *string `db:"dezernat" json:"dezernat,omitempty"` + GlobalRole string `db:"global_role" json:"global_role"` Lang string `db:"lang" json:"lang"` EmailPreferences json.RawMessage `db:"email_preferences" json:"email_preferences"` // ReminderMorningTime / ReminderEveningTime are stored as Postgres TIME and @@ -127,10 +126,10 @@ type ProjectTeamMemberWithUser struct { InheritedFromTitle *string `db:"inherited_from_title" json:"inherited_from_title,omitempty"` } -// 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 { +// PartnerUnit is one structural partner unit (Dezernat in legacy German). +// Membership is orthogonal to project teams — a user typically belongs to +// exactly one PartnerUnit but may work on projects across all of them. +type PartnerUnit 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"` @@ -139,11 +138,11 @@ type Department struct { UpdatedAt time.Time `db:"updated_at" json:"updated_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"` +// PartnerUnitMember is one user's membership in a PartnerUnit. +type PartnerUnitMember struct { + PartnerUnitID uuid.UUID `db:"partner_unit_id" json:"partner_unit_id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` } // ProjectEvent is one row in the per-Project audit trail diff --git a/internal/services/audit_service.go b/internal/services/audit_service.go index 6d82400..69afee3 100644 --- a/internal/services/audit_service.go +++ b/internal/services/audit_service.go @@ -2,11 +2,12 @@ package services // AuditService produces a unified, paginated, filterable timeline across // every audit source we keep in the paliad schema. There is no single -// audit_log table — instead we union three existing sources: +// audit_log table — instead we union four existing sources: // -// - paliad.project_events — per-project audit (creates, updates, etc.) -// - paliad.caldav_sync_log — CalDAV push/pull outcomes per user -// - paliad.reminder_log — bundled-digest reminder sends +// - paliad.project_events — per-project audit (creates, updates, etc.) +// - paliad.caldav_sync_log — CalDAV push/pull outcomes per user +// - paliad.reminder_log — bundled-digest reminder sends +// - paliad.partner_unit_events — partner-unit CRUD + membership changes // // The union happens in SQL (one round-trip, server-side ordering) and is // keyset-paginated on (timestamp, id) DESC so the cursor stays stable across @@ -30,9 +31,10 @@ import ( // Audit source discriminators. Stable strings — exposed in the JSON payload, // referenced in the i18n keys, and used as filter values. const ( - AuditSourceProjectEvents = "project_events" - AuditSourceCalDAVLog = "caldav_sync_log" - AuditSourceReminderLog = "reminder_log" + AuditSourceProjectEvents = "project_events" + AuditSourceCalDAVLog = "caldav_sync_log" + AuditSourceReminderLog = "reminder_log" + AuditSourcePartnerUnitEvents = "partner_unit_events" ) // MaxAuditPageLimit caps a single ListEntries page. @@ -167,6 +169,24 @@ WITH unioned AS ( WHERE ($1::text IS NULL OR $1 = '' OR $1 = 'reminder_log') AND ($2::timestamptz IS NULL OR r.sent_at >= $2) AND ($3::timestamptz IS NULL OR r.sent_at <= $3) + + UNION ALL + + SELECT + 'partner_unit_events'::text AS source, + pue.id AS id, + pue.created_at AS ts, + pue.event_type AS event_type, + COALESCE(au.email, pue.actor_id::text) AS actor, + pue.unit_name AS subject, + NULL::uuid AS project_id, + NULL::text AS title, + pue.payload::text AS description + FROM paliad.partner_unit_events pue + LEFT JOIN paliad.users au ON au.id = pue.actor_id + WHERE ($1::text IS NULL OR $1 = '' OR $1 = 'partner_unit_events') + AND ($2::timestamptz IS NULL OR pue.created_at >= $2) + AND ($3::timestamptz IS NULL OR pue.created_at <= $3) ) SELECT source, id, ts, event_type, actor, subject, project_id, title, description FROM unioned diff --git a/internal/services/department_service.go b/internal/services/department_service.go deleted file mode 100644 index c3f4fa3..0000000 --- a/internal/services/department_service.go +++ /dev/null @@ -1,308 +0,0 @@ -package services - -// DepartmentService handles paliad.departments + paliad.department_members — -// the structural partner-led units. Orthogonal to project teams. - -import ( - "context" - "database/sql" - "errors" - "fmt" - "strings" - "time" - - "github.com/google/uuid" - "github.com/jmoiron/sqlx" - - "mgit.msbls.de/m/patholo/internal/models" - "mgit.msbls.de/m/patholo/internal/offices" -) - -// DepartmentService reads and writes paliad.departments. -type DepartmentService struct { - db *sqlx.DB - users *UserService -} - -// NewDepartmentService wires the service. -func NewDepartmentService(db *sqlx.DB, users *UserService) *DepartmentService { - return &DepartmentService{db: db, users: users} -} - -// 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"` -} - -// 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 *DepartmentService) List(ctx context.Context) ([]models.Department, error) { - rows := []models.Department{} - err := s.db.SelectContext(ctx, &rows, - `SELECT id, name, lead_user_id, office, created_at, updated_at - FROM paliad.departments - ORDER BY office, name`) - if err != nil { - return nil, fmt.Errorf("list dezernate: %w", err) - } - return rows, nil -} - -// GetByID returns one Dezernat or (nil, sql.ErrNoRows). -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.departments WHERE id = $1`, id) - if errors.Is(err, sql.ErrNoRows) { - return nil, sql.ErrNoRows - } - if err != nil { - return nil, fmt.Errorf("get dezernat: %w", err) - } - return &d, nil -} - -// Create inserts a Dezernat. Admin-only. -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 - } - if strings.TrimSpace(input.Name) == "" { - return nil, fmt.Errorf("%w: name is required", ErrInvalidInput) - } - if !offices.IsValid(input.Office) { - return nil, fmt.Errorf("%w: invalid office %q", ErrInvalidInput, input.Office) - } - id := uuid.New() - now := time.Now().UTC() - if _, err := s.db.ExecContext(ctx, - `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) - } - return s.GetByID(ctx, id) -} - -// Update applies a partial update. Admin-only. -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 - } - current, err := s.GetByID(ctx, id) - if err != nil { - return nil, err - } - - sets := []string{} - args := []any{} - next := 1 - appendSet := func(col string, val any) { - sets = append(sets, fmt.Sprintf("%s = $%d", col, next)) - args = append(args, val) - next++ - } - if input.Name != nil { - appendSet("name", *input.Name) - } - if input.LeadUserID != nil { - appendSet("lead_user_id", *input.LeadUserID) - } - if input.Office != nil { - if !offices.IsValid(*input.Office) { - return nil, fmt.Errorf("%w: invalid office %q", ErrInvalidInput, *input.Office) - } - appendSet("office", *input.Office) - } - if len(sets) == 0 { - return current, nil - } - appendSet("updated_at", time.Now().UTC()) - args = append(args, id) - 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) - } - return s.GetByID(ctx, id) -} - -// Delete removes a Dezernat (cascades memberships). Admin-only. -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.departments WHERE id = $1`, id) - if err != nil { - return fmt.Errorf("delete dezernat: %w", err) - } - return nil -} - -// AddMember inserts a (dezernat, user) membership. Admin-only. Idempotent. -func (s *DepartmentService) AddMember(ctx context.Context, callerID, departmentID, userID uuid.UUID) error { - if err := s.requireAdmin(ctx, callerID); err != nil { - return err - } - _, err := s.db.ExecContext(ctx, - `INSERT INTO paliad.department_members (department_id, user_id, created_at) - VALUES ($1, $2, now()) ON CONFLICT (department_id, user_id) DO NOTHING`, - departmentID, userID) - if err != nil { - return fmt.Errorf("add dezernat member: %w", err) - } - return nil -} - -// RemoveMember deletes a (dezernat, user) membership. Admin-only. -func (s *DepartmentService) RemoveMember(ctx context.Context, callerID, departmentID, userID uuid.UUID) error { - if err := s.requireAdmin(ctx, callerID); err != nil { - return err - } - _, err := s.db.ExecContext(ctx, - `DELETE FROM paliad.department_members WHERE department_id = $1 AND user_id = $2`, - departmentID, userID) - if err != nil { - return fmt.Errorf("remove dezernat member: %w", err) - } - return nil -} - -// ListMembers returns users in the Dezernat, enriched with display fields. -type DepartmentMember struct { - UserID uuid.UUID `db:"user_id" json:"user_id"` - Email string `db:"email" json:"email"` - DisplayName string `db:"display_name" json:"display_name"` - Office string `db:"office" json:"office"` - JobTitle *string `db:"job_title" json:"job_title"` - CreatedAt time.Time `db:"created_at" json:"created_at"` -} - -// ListMembers returns users in the Dezernat (readable by any authenticated user). -// -// INNER JOIN on paliad.users: department_members.user_id FKs auth.users, so -// pre-onboarding members (auth row exists, paliad.users row doesn't) would -// otherwise produce NULL display_name/office/role and break the scan. -// Skipping them is the right UX — without an onboarded profile there's -// nothing meaningful to render. -func (s *DepartmentService) ListMembers(ctx context.Context, departmentID uuid.UUID) ([]DepartmentMember, error) { - var rows []DepartmentMember - err := s.db.SelectContext(ctx, &rows, - `SELECT dm.user_id, dm.created_at, - u.email, u.display_name, u.office, u.job_title - FROM paliad.department_members dm - JOIN paliad.users u ON u.id = dm.user_id - WHERE dm.department_id = $1 - ORDER BY u.display_name`, departmentID) - if err != nil { - return nil, fmt.Errorf("list dezernat members: %w", err) - } - return rows, nil -} - -// DepartmentWithMembers is a department row enriched with its lead user -// snapshot and full member list. Used by the /team directory page so the -// frontend can render the "by department" grouping with one fetch. -type DepartmentWithMembers struct { - models.Department - LeadDisplayName *string `json:"lead_display_name,omitempty"` - LeadEmail *string `json:"lead_email,omitempty"` - Members []DepartmentMember `json:"members"` -} - -// ListWithMembers returns every Department enriched with its lead's display -// name + email and the full members list. Two short queries (one per -// table) are joined in Go to avoid a Cartesian explosion when departments -// have many members. -func (s *DepartmentService) ListWithMembers(ctx context.Context) ([]DepartmentWithMembers, error) { - type deptRow struct { - models.Department - LeadDisplayName *string `db:"lead_display_name"` - LeadEmail *string `db:"lead_email"` - } - var depts []deptRow - err := s.db.SelectContext(ctx, &depts, - `SELECT d.id, d.name, d.lead_user_id, d.office, d.created_at, d.updated_at, - lu.display_name AS lead_display_name, - lu.email AS lead_email - FROM paliad.departments d - LEFT JOIN paliad.users lu ON lu.id = d.lead_user_id - ORDER BY d.office, d.name`) - if err != nil { - return nil, fmt.Errorf("list departments: %w", err) - } - - type memberRow struct { - DepartmentMember - DepartmentID uuid.UUID `db:"department_id"` - } - var members []memberRow - // INNER JOIN: see comment on ListMembers for why pre-onboarding members - // (auth row present, paliad.users row missing) must be excluded. - err = s.db.SelectContext(ctx, &members, - `SELECT dm.department_id, dm.user_id, dm.created_at, - u.email, u.display_name, u.office, u.job_title - FROM paliad.department_members dm - JOIN paliad.users u ON u.id = dm.user_id - ORDER BY u.display_name`) - if err != nil { - return nil, fmt.Errorf("list department members: %w", err) - } - - byDept := map[uuid.UUID][]DepartmentMember{} - for _, m := range members { - byDept[m.DepartmentID] = append(byDept[m.DepartmentID], m.DepartmentMember) - } - - out := make([]DepartmentWithMembers, len(depts)) - for i, d := range depts { - out[i] = DepartmentWithMembers{ - Department: d.Department, - LeadDisplayName: d.LeadDisplayName, - LeadEmail: d.LeadEmail, - Members: byDept[d.ID], - } - if out[i].Members == nil { - out[i].Members = []DepartmentMember{} - } - } - return out, nil -} - -// GetMembership returns the user's Dezernat memberships (zero or more). -// Used by the settings page to render "Your Dezernat: ". -func (s *DepartmentService) GetMembership(ctx context.Context, userID uuid.UUID) ([]models.Department, error) { - 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.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 { - return nil, fmt.Errorf("get user dezernat memberships: %w", err) - } - return rows, nil -} - -// --------------------------------------------------------------------------- - -func (s *DepartmentService) requireAdmin(ctx context.Context, userID uuid.UUID) error { - u, err := s.users.GetByID(ctx, userID) - if err != nil { - return err - } - if u == nil || u.GlobalRole != "global_admin" { - return fmt.Errorf("%w: global admin required", ErrForbidden) - } - return nil -} diff --git a/internal/services/partner_unit_service.go b/internal/services/partner_unit_service.go new file mode 100644 index 0000000..c1fd6b1 --- /dev/null +++ b/internal/services/partner_unit_service.go @@ -0,0 +1,510 @@ +package services + +// PartnerUnitService handles paliad.partner_units + paliad.partner_unit_members +// — the structural partner-led units (legacy "Dezernat"). Orthogonal to +// project teams: a user typically belongs to exactly one PartnerUnit but may +// work on projects across all of them. +// +// Every mutation emits a row into paliad.partner_unit_events in the same tx +// as the originating change so the global audit timeline (audit_service.go) +// can render the full history. The unit name is snapshotted into the event +// row so 'deleted' rows stay readable after the FK ON DELETE SET NULL fires. + +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" + "mgit.msbls.de/m/patholo/internal/offices" +) + +// PartnerUnitService reads and writes paliad.partner_units. +type PartnerUnitService struct { + db *sqlx.DB + users *UserService +} + +// NewPartnerUnitService wires the service. +func NewPartnerUnitService(db *sqlx.DB, users *UserService) *PartnerUnitService { + return &PartnerUnitService{db: db, users: users} +} + +// CreatePartnerUnitInput is the payload for Create. +type CreatePartnerUnitInput struct { + Name string `json:"name"` + LeadUserID *uuid.UUID `json:"lead_user_id,omitempty"` + Office string `json:"office"` +} + +// UpdatePartnerUnitInput is the partial-update payload. +type UpdatePartnerUnitInput struct { + Name *string `json:"name,omitempty"` + LeadUserID *uuid.UUID `json:"lead_user_id,omitempty"` + Office *string `json:"office,omitempty"` +} + +// List returns every PartnerUnit (readable by any authenticated user — see RLS). +func (s *PartnerUnitService) List(ctx context.Context) ([]models.PartnerUnit, error) { + rows := []models.PartnerUnit{} + err := s.db.SelectContext(ctx, &rows, + `SELECT id, name, lead_user_id, office, created_at, updated_at + FROM paliad.partner_units + ORDER BY office, name`) + if err != nil { + return nil, fmt.Errorf("list partner_units: %w", err) + } + return rows, nil +} + +// GetByID returns one PartnerUnit or (nil, sql.ErrNoRows). +func (s *PartnerUnitService) GetByID(ctx context.Context, id uuid.UUID) (*models.PartnerUnit, error) { + var d models.PartnerUnit + err := s.db.GetContext(ctx, &d, + `SELECT id, name, lead_user_id, office, created_at, updated_at + FROM paliad.partner_units WHERE id = $1`, id) + if errors.Is(err, sql.ErrNoRows) { + return nil, sql.ErrNoRows + } + if err != nil { + return nil, fmt.Errorf("get partner_unit: %w", err) + } + return &d, nil +} + +// Create inserts a PartnerUnit. Admin-only. Emits a 'created' audit event +// in the same tx. +func (s *PartnerUnitService) Create(ctx context.Context, callerID uuid.UUID, input CreatePartnerUnitInput) (*models.PartnerUnit, error) { + if err := s.requireAdmin(ctx, callerID); err != nil { + return nil, err + } + if strings.TrimSpace(input.Name) == "" { + return nil, fmt.Errorf("%w: name is required", ErrInvalidInput) + } + if !offices.IsValid(input.Office) { + return nil, fmt.Errorf("%w: invalid office %q", ErrInvalidInput, input.Office) + } + id := uuid.New() + now := time.Now().UTC() + + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + if _, err := tx.ExecContext(ctx, + `INSERT INTO paliad.partner_units (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 partner_unit: %w", err) + } + + if err := s.emit(ctx, tx, callerID, &id, input.Name, "created", map[string]any{ + "name": input.Name, + "office": input.Office, + "lead_user_id": input.LeadUserID, + }); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit tx: %w", err) + } + return s.GetByID(ctx, id) +} + +// Update applies a partial update. Admin-only. Emits an 'updated' event with +// before/after snapshots in the same tx. +func (s *PartnerUnitService) Update(ctx context.Context, callerID, id uuid.UUID, input UpdatePartnerUnitInput) (*models.PartnerUnit, error) { + if err := s.requireAdmin(ctx, callerID); err != nil { + return nil, err + } + current, err := s.GetByID(ctx, id) + if err != nil { + return nil, err + } + + sets := []string{} + args := []any{} + next := 1 + appendSet := func(col string, val any) { + sets = append(sets, fmt.Sprintf("%s = $%d", col, next)) + args = append(args, val) + next++ + } + before := map[string]any{} + after := map[string]any{} + fields := []string{} + if input.Name != nil && *input.Name != current.Name { + appendSet("name", *input.Name) + before["name"] = current.Name + after["name"] = *input.Name + fields = append(fields, "name") + } + if input.LeadUserID != nil { + curLead := uuid.Nil + if current.LeadUserID != nil { + curLead = *current.LeadUserID + } + if *input.LeadUserID != curLead { + appendSet("lead_user_id", *input.LeadUserID) + before["lead_user_id"] = current.LeadUserID + after["lead_user_id"] = *input.LeadUserID + fields = append(fields, "lead_user_id") + } + } + if input.Office != nil && *input.Office != current.Office { + if !offices.IsValid(*input.Office) { + return nil, fmt.Errorf("%w: invalid office %q", ErrInvalidInput, *input.Office) + } + appendSet("office", *input.Office) + before["office"] = current.Office + after["office"] = *input.Office + fields = append(fields, "office") + } + if len(sets) == 0 { + return current, nil + } + appendSet("updated_at", time.Now().UTC()) + args = append(args, id) + query := fmt.Sprintf("UPDATE paliad.partner_units SET %s WHERE id = $%d", + strings.Join(sets, ", "), next) + + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + if _, err := tx.ExecContext(ctx, query, args...); err != nil { + return nil, fmt.Errorf("update partner_unit: %w", err) + } + if err := s.emit(ctx, tx, callerID, &id, current.Name, "updated", map[string]any{ + "before": before, + "after": after, + "fields": fields, + }); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit tx: %w", err) + } + return s.GetByID(ctx, id) +} + +// Delete removes a PartnerUnit (cascades memberships). Admin-only. Emits a +// 'deleted' audit event in the same tx — the FK on partner_unit_events has +// ON DELETE SET NULL so the historical row survives the cascade. +func (s *PartnerUnitService) Delete(ctx context.Context, callerID, id uuid.UUID) error { + if err := s.requireAdmin(ctx, callerID); err != nil { + return err + } + current, err := s.GetByID(ctx, id) + if err != nil { + return err + } + + var memberCount int + if err := s.db.GetContext(ctx, &memberCount, + `SELECT COUNT(*) FROM paliad.partner_unit_members WHERE partner_unit_id = $1`, id); err != nil { + return fmt.Errorf("count members: %w", err) + } + + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + // Emit BEFORE delete so the FK still resolves; ON DELETE SET NULL fires + // on cascade and clears partner_unit_id while keeping unit_name + payload. + if err := s.emit(ctx, tx, callerID, &id, current.Name, "deleted", map[string]any{ + "name": current.Name, + "office": current.Office, + "lead_user_id": current.LeadUserID, + "member_count": memberCount, + }); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM paliad.partner_units WHERE id = $1`, id); err != nil { + return fmt.Errorf("delete partner_unit: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit tx: %w", err) + } + return nil +} + +// AddMember inserts a (partner_unit, user) membership. Admin-only. Idempotent. +// Emits 'member_added' only when a row is actually inserted. +func (s *PartnerUnitService) AddMember(ctx context.Context, callerID, partnerUnitID, userID uuid.UUID) error { + if err := s.requireAdmin(ctx, callerID); err != nil { + return err + } + unit, err := s.GetByID(ctx, partnerUnitID) + if err != nil { + return err + } + + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + res, err := tx.ExecContext(ctx, + `INSERT INTO paliad.partner_unit_members (partner_unit_id, user_id, created_at) + VALUES ($1, $2, now()) ON CONFLICT (partner_unit_id, user_id) DO NOTHING`, + partnerUnitID, userID) + if err != nil { + return fmt.Errorf("add partner_unit member: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return tx.Commit() + } + + var disp struct { + DN string `db:"display_name"` + Em string `db:"email"` + } + _ = s.db.GetContext(ctx, &disp, + `SELECT display_name, email FROM paliad.users WHERE id = $1`, userID) + + if err := s.emit(ctx, tx, callerID, &partnerUnitID, unit.Name, "member_added", map[string]any{ + "user_id": userID, + "display_name": disp.DN, + "email": disp.Em, + }); err != nil { + return err + } + return tx.Commit() +} + +// RemoveMember deletes a (partner_unit, user) membership. Admin-only. +// Emits 'member_removed' only when a row is actually deleted. +func (s *PartnerUnitService) RemoveMember(ctx context.Context, callerID, partnerUnitID, userID uuid.UUID) error { + if err := s.requireAdmin(ctx, callerID); err != nil { + return err + } + unit, err := s.GetByID(ctx, partnerUnitID) + if err != nil { + return err + } + + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + res, err := tx.ExecContext(ctx, + `DELETE FROM paliad.partner_unit_members WHERE partner_unit_id = $1 AND user_id = $2`, + partnerUnitID, userID) + if err != nil { + return fmt.Errorf("remove partner_unit member: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return tx.Commit() + } + + if err := s.emit(ctx, tx, callerID, &partnerUnitID, unit.Name, "member_removed", map[string]any{ + "user_id": userID, + }); err != nil { + return err + } + return tx.Commit() +} + +// AddMemberTx is the same as AddMember but runs inside the caller's tx and +// skips the admin gate (caller has already authorised the parent operation). +// Used by user_service.OnboardUser to insert a partner_unit membership in +// the same tx as the user-create. +func (s *PartnerUnitService) AddMemberTx(ctx context.Context, tx *sqlx.Tx, actorID, partnerUnitID, userID uuid.UUID) error { + res, err := tx.ExecContext(ctx, + `INSERT INTO paliad.partner_unit_members (partner_unit_id, user_id, created_at) + VALUES ($1, $2, now()) ON CONFLICT (partner_unit_id, user_id) DO NOTHING`, + partnerUnitID, userID) + if err != nil { + return fmt.Errorf("add partner_unit member (tx): %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return nil + } + var unitName string + if err := tx.GetContext(ctx, &unitName, + `SELECT name FROM paliad.partner_units WHERE id = $1`, partnerUnitID); err != nil { + return fmt.Errorf("lookup partner_unit name: %w", err) + } + var disp struct { + DN string `db:"display_name"` + Em string `db:"email"` + } + _ = tx.GetContext(ctx, &disp, + `SELECT display_name, email FROM paliad.users WHERE id = $1`, userID) + + return s.emit(ctx, tx, actorID, &partnerUnitID, unitName, "member_added", map[string]any{ + "user_id": userID, + "display_name": disp.DN, + "email": disp.Em, + "source": "onboarding", + }) +} + +// PartnerUnitMemberDetail is one user's membership row enriched with display +// fields for the admin/team UIs. +type PartnerUnitMemberDetail struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + Email string `db:"email" json:"email"` + DisplayName string `db:"display_name" json:"display_name"` + Office string `db:"office" json:"office"` + JobTitle *string `db:"job_title" json:"job_title"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + +// ListMembers returns users in the PartnerUnit, enriched with display fields. +// +// INNER JOIN on paliad.users: partner_unit_members.user_id FKs auth.users, so +// pre-onboarding members (auth row exists, paliad.users row doesn't) would +// otherwise produce NULL display_name/office and break the scan. +// Skipping them is the right UX — without an onboarded profile there's +// nothing meaningful to render. +func (s *PartnerUnitService) ListMembers(ctx context.Context, partnerUnitID uuid.UUID) ([]PartnerUnitMemberDetail, error) { + var rows []PartnerUnitMemberDetail + err := s.db.SelectContext(ctx, &rows, + `SELECT pum.user_id, pum.created_at, + u.email, u.display_name, u.office, u.job_title + FROM paliad.partner_unit_members pum + JOIN paliad.users u ON u.id = pum.user_id + WHERE pum.partner_unit_id = $1 + ORDER BY u.display_name`, partnerUnitID) + if err != nil { + return nil, fmt.Errorf("list partner_unit members: %w", err) + } + return rows, nil +} + +// PartnerUnitWithMembers is a unit row enriched with its lead user +// snapshot and full member list. Used by the /team directory page so the +// frontend can render the "by partner unit" grouping with one fetch. +type PartnerUnitWithMembers struct { + models.PartnerUnit + LeadDisplayName *string `json:"lead_display_name,omitempty"` + LeadEmail *string `json:"lead_email,omitempty"` + Members []PartnerUnitMemberDetail `json:"members"` +} + +// ListWithMembers returns every PartnerUnit enriched with its lead's display +// name + email and the full members list. Two short queries (one per +// table) are joined in Go to avoid a Cartesian explosion when units have +// many members. +func (s *PartnerUnitService) ListWithMembers(ctx context.Context) ([]PartnerUnitWithMembers, error) { + type unitRow struct { + models.PartnerUnit + LeadDisplayName *string `db:"lead_display_name"` + LeadEmail *string `db:"lead_email"` + } + var units []unitRow + err := s.db.SelectContext(ctx, &units, + `SELECT pu.id, pu.name, pu.lead_user_id, pu.office, pu.created_at, pu.updated_at, + lu.display_name AS lead_display_name, + lu.email AS lead_email + FROM paliad.partner_units pu + LEFT JOIN paliad.users lu ON lu.id = pu.lead_user_id + ORDER BY pu.office, pu.name`) + if err != nil { + return nil, fmt.Errorf("list partner_units: %w", err) + } + + type memberRow struct { + PartnerUnitMemberDetail + PartnerUnitID uuid.UUID `db:"partner_unit_id"` + } + var members []memberRow + err = s.db.SelectContext(ctx, &members, + `SELECT pum.partner_unit_id, pum.user_id, pum.created_at, + u.email, u.display_name, u.office, u.job_title + FROM paliad.partner_unit_members pum + JOIN paliad.users u ON u.id = pum.user_id + ORDER BY u.display_name`) + if err != nil { + return nil, fmt.Errorf("list partner_unit members: %w", err) + } + + byUnit := map[uuid.UUID][]PartnerUnitMemberDetail{} + for _, m := range members { + byUnit[m.PartnerUnitID] = append(byUnit[m.PartnerUnitID], m.PartnerUnitMemberDetail) + } + + out := make([]PartnerUnitWithMembers, len(units)) + for i, u := range units { + out[i] = PartnerUnitWithMembers{ + PartnerUnit: u.PartnerUnit, + LeadDisplayName: u.LeadDisplayName, + LeadEmail: u.LeadEmail, + Members: byUnit[u.ID], + } + if out[i].Members == nil { + out[i].Members = []PartnerUnitMemberDetail{} + } + } + return out, nil +} + +// GetMembership returns the user's PartnerUnit memberships (zero or more). +// Used by the settings page to render the user's own partner unit card. +func (s *PartnerUnitService) GetMembership(ctx context.Context, userID uuid.UUID) ([]models.PartnerUnit, error) { + rows := []models.PartnerUnit{} + err := s.db.SelectContext(ctx, &rows, + `SELECT pu.id, pu.name, pu.lead_user_id, pu.office, pu.created_at, pu.updated_at + FROM paliad.partner_units pu + JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = pu.id + WHERE pum.user_id = $1 + ORDER BY pu.name`, userID) + if err != nil { + return nil, fmt.Errorf("get user partner_unit memberships: %w", err) + } + return rows, nil +} + +// --------------------------------------------------------------------------- + +func (s *PartnerUnitService) requireAdmin(ctx context.Context, userID uuid.UUID) error { + u, err := s.users.GetByID(ctx, userID) + if err != nil { + return err + } + if u == nil || u.GlobalRole != "global_admin" { + return fmt.Errorf("%w: global admin required", ErrForbidden) + } + return nil +} + +// emit writes one audit row to paliad.partner_unit_events inside the caller's +// tx. unitName is snapshotted so deleted units stay readable in the timeline +// (their FK is cleared to NULL by ON DELETE SET NULL after the unit row is +// gone). +func (s *PartnerUnitService) emit(ctx context.Context, tx *sqlx.Tx, actorID uuid.UUID, + unitID *uuid.UUID, unitName, eventType string, payload any) error { + p, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal audit payload: %w", err) + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO paliad.partner_unit_events + (partner_unit_id, actor_id, event_type, unit_name, payload) + VALUES ($1, $2, $3, $4, $5)`, + unitID, actorID, eventType, unitName, p); err != nil { + return fmt.Errorf("emit partner_unit event %q: %w", eventType, err) + } + return nil +} diff --git a/internal/services/user_service.go b/internal/services/user_service.go index 503fbc2..dcb4473 100644 --- a/internal/services/user_service.go +++ b/internal/services/user_service.go @@ -66,7 +66,7 @@ func NewUserService(db *sqlx.DB) *UserService { } const userColumns = `id, email, display_name, office, additional_offices, practice_group, - job_title, global_role, dezernat, + job_title, global_role, lang, email_preferences, reminder_morning_time::text AS reminder_morning_time, reminder_evening_time::text AS reminder_evening_time, @@ -91,11 +91,17 @@ func (s *UserService) GetByID(ctx context.Context, id uuid.UUID) (*models.User, } // CreateUserInput is the payload for the onboarding flow (POST /api/onboarding). +// +// PartnerUnitID is optional — when set, the onboarding flow inserts a +// paliad.partner_unit_members row in the same tx as the user-create and +// emits a 'member_added' audit event with source='onboarding'. When unset, +// the user is onboarded without any partner-unit membership and an admin +// must assign one later via /admin/partner-units. type CreateUserInput struct { - DisplayName string `json:"display_name"` - Office string `json:"office"` - JobTitle string `json:"job_title"` - Dezernat *string `json:"dezernat,omitempty"` + DisplayName string `json:"display_name"` + Office string `json:"office"` + JobTitle string `json:"job_title"` + PartnerUnitID *uuid.UUID `json:"partner_unit_id,omitempty"` } // Create inserts the paliad.users row for the authenticated user. The caller @@ -126,14 +132,6 @@ func (s *UserService) Create(ctx context.Context, id uuid.UUID, email string, in return nil, fmt.Errorf("job_title is required") } - var dezernat *string - if input.Dezernat != nil { - trimmed := strings.TrimSpace(*input.Dezernat) - if trimmed != "" { - dezernat = &trimmed - } - } - tx, err := s.db.BeginTxx(ctx, nil) if err != nil { return nil, fmt.Errorf("begin tx: %w", err) @@ -176,13 +174,24 @@ func (s *UserService) Create(ctx context.Context, id uuid.UUID, email string, in // future use but no longer collected at onboarding (m, 2026-04-18: every // Paliad user is in patent practice, so the field carried no signal). if _, err := tx.ExecContext(ctx, - `INSERT INTO paliad.users (id, email, display_name, office, job_title, global_role, dezernat) - VALUES ($1, $2, $3, $4, $5, $6, $7)`, - id, email, displayName, input.Office, jobTitle, globalRole, dezernat, + `INSERT INTO paliad.users (id, email, display_name, office, job_title, global_role) + VALUES ($1, $2, $3, $4, $5, $6)`, + id, email, displayName, input.Office, jobTitle, globalRole, ); err != nil { return nil, fmt.Errorf("insert user: %w", err) } + // Optional initial partner-unit membership picked from the onboarding + // form. RLS on partner_unit_members allows user_id = auth.uid() so this + // works even after we strip superuser; the audit event records the user + // as their own actor with source='onboarding' so admins can see how the + // membership originated. + if input.PartnerUnitID != nil { + if err := insertPartnerUnitMembership(ctx, tx, *input.PartnerUnitID, id); err != nil { + return nil, err + } + } + if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit create user: %w", err) } @@ -190,6 +199,52 @@ func (s *UserService) Create(ctx context.Context, id uuid.UUID, email string, in return s.GetByID(ctx, id) } +// insertPartnerUnitMembership inserts a paliad.partner_unit_members row plus +// a paliad.partner_unit_events audit row inside the caller's tx. Used by +// onboarding (Create) — admin-driven membership writes go through +// PartnerUnitService.AddMember which has its own emit. +// +// The user is recorded as the actor for the audit event because the +// onboarding form is self-service. unitName is fetched inside the tx so +// the audit row stays readable if the unit is later deleted. +func insertPartnerUnitMembership(ctx context.Context, tx *sqlx.Tx, partnerUnitID, userID uuid.UUID) error { + res, err := tx.ExecContext(ctx, + `INSERT INTO paliad.partner_unit_members (partner_unit_id, user_id, created_at) + VALUES ($1, $2, now()) + ON CONFLICT (partner_unit_id, user_id) DO NOTHING`, + partnerUnitID, userID) + if err != nil { + return fmt.Errorf("insert partner_unit membership: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return nil + } + + var unitName string + if err := tx.GetContext(ctx, &unitName, + `SELECT name FROM paliad.partner_units WHERE id = $1`, partnerUnitID); err != nil { + return fmt.Errorf("lookup partner_unit name: %w", err) + } + + payload := map[string]any{ + "user_id": userID, + "source": "onboarding", + } + pj, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal audit payload: %w", err) + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO paliad.partner_unit_events + (partner_unit_id, actor_id, event_type, unit_name, payload) + VALUES ($1, $2, 'member_added', $3, $4)`, + partnerUnitID, userID, unitName, pj); err != nil { + return fmt.Errorf("emit member_added event: %w", err) + } + return nil +} + // UpdateProfileInput is the payload for PATCH /api/me. Every field is a // pointer so callers can omit keys they don't want to touch — the settings // page sends only the fields the user changed. Email is deliberately absent: @@ -201,7 +256,6 @@ type UpdateProfileInput struct { DisplayName *string `json:"display_name,omitempty"` Office *string `json:"office,omitempty"` JobTitle *string `json:"job_title,omitempty"` - Dezernat *string `json:"dezernat,omitempty"` Lang *string `json:"lang,omitempty"` EmailPreferences *json.RawMessage `json:"email_preferences,omitempty"` ReminderMorningTime *string `json:"reminder_morning_time,omitempty"` @@ -211,9 +265,9 @@ type UpdateProfileInput struct { // EscalationContactID overrides the DRINGEND/overdue escalation channel: // when non-NULL, the named user replaces the global_admins fallback for // this user's deadlines. Empty string clears (back to fallback). nil = - // don't touch — matches the Dezernat tri-state pattern in the same file - // (a UUID and "no override" are different types, so we encode the clear - // signal as "" rather than juggling JSON null / missing semantics). + // don't touch (a UUID and "no override" are different types, so we + // encode the clear signal as "" rather than juggling JSON null / missing + // semantics). EscalationContactID *string `json:"escalation_contact_id,omitempty"` } @@ -252,18 +306,6 @@ func (s *UserService) UpdateProfile(ctx context.Context, id uuid.UUID, input Upd args = append(args, jt) i++ } - if input.Dezernat != nil { - trimmed := strings.TrimSpace(*input.Dezernat) - var val any - if trimmed == "" { - val = nil - } else { - val = trimmed - } - sets = append(sets, fmt.Sprintf("dezernat = $%d", i)) - args = append(args, val) - i++ - } if input.Lang != nil { lang := strings.ToLower(strings.TrimSpace(*input.Lang)) if lang != "de" && lang != "en" { @@ -406,13 +448,15 @@ func (s *UserService) IsAdmin(ctx context.Context, id uuid.UUID) (bool, error) { // AdminCreateInput is the payload an admin uses to onboard a colleague who // already exists in auth.users. Email is required (must already be in // auth.users with an allowed domain — both checks happen in AdminCreateUser). +// +// Partner-unit membership is intentionally NOT settable here; admins assign +// memberships separately via /admin/partner-units after the row exists. type AdminCreateInput struct { - Email string `json:"email"` - DisplayName string `json:"display_name"` - Office string `json:"office"` - JobTitle string `json:"job_title,omitempty"` // defaults to 'Associate' - Dezernat *string `json:"dezernat,omitempty"` - Lang string `json:"lang,omitempty"` // defaults to 'de' + Email string `json:"email"` + DisplayName string `json:"display_name"` + Office string `json:"office"` + JobTitle string `json:"job_title,omitempty"` // defaults to 'Associate' + Lang string `json:"lang,omitempty"` // defaults to 'de' } // AdminCreateUser inserts a paliad.users row for an auth.users entry that has @@ -449,14 +493,6 @@ func (s *UserService) AdminCreateUser(ctx context.Context, input AdminCreateInpu return nil, fmt.Errorf("%w: invalid lang %q", ErrInvalidInput, input.Lang) } - var dezernat *string - if input.Dezernat != nil { - trimmed := strings.TrimSpace(*input.Dezernat) - if trimmed != "" { - dezernat = &trimmed - } - } - tx, err := s.db.BeginTxx(ctx, nil) if err != nil { return nil, fmt.Errorf("begin tx: %w", err) @@ -486,9 +522,9 @@ func (s *UserService) AdminCreateUser(ctx context.Context, input AdminCreateInpu } if _, err := tx.ExecContext(ctx, - `INSERT INTO paliad.users (id, email, display_name, office, job_title, global_role, dezernat, lang) - VALUES ($1, $2, $3, $4, $5, 'standard', $6, $7)`, - authID, email, displayName, input.Office, jobTitle, dezernat, lang, + `INSERT INTO paliad.users (id, email, display_name, office, job_title, global_role, lang) + VALUES ($1, $2, $3, $4, $5, 'standard', $6)`, + authID, email, displayName, input.Office, jobTitle, lang, ); err != nil { return nil, fmt.Errorf("insert user: %w", err) } @@ -507,7 +543,6 @@ type AdminUpdateInput struct { Office *string `json:"office,omitempty"` JobTitle *string `json:"job_title,omitempty"` GlobalRole *string `json:"global_role,omitempty"` - Dezernat *string `json:"dezernat,omitempty"` AdditionalOffices *[]string `json:"additional_offices,omitempty"` Lang *string `json:"lang,omitempty"` EmailPreferences *json.RawMessage `json:"email_preferences,omitempty"` @@ -595,18 +630,6 @@ func (s *UserService) AdminUpdateUser(ctx context.Context, id uuid.UUID, input A args = append(args, gr) i++ } - if input.Dezernat != nil { - trimmed := strings.TrimSpace(*input.Dezernat) - var val any - if trimmed == "" { - val = nil - } else { - val = trimmed - } - sets = append(sets, fmt.Sprintf("dezernat = $%d", i)) - args = append(args, val) - i++ - } if input.AdditionalOffices != nil { // Validate each key against the canonical office list. A typo here // would silently break the /team filter pills for that user. diff --git a/internal/services/user_service_test.go b/internal/services/user_service_test.go index 5344345..6ba8030 100644 --- a/internal/services/user_service_test.go +++ b/internal/services/user_service_test.go @@ -65,12 +65,10 @@ func TestUserService_Create_Valid(t *testing.T) { seedAuthUser(t, pool, id, "first@hlc.com") defer cleanupUsers(t, pool, id) - dezernat := " Team Müller " u, err := users.Create(context.Background(), id, "first@hlc.com", CreateUserInput{ DisplayName: " First User ", Office: "munich", JobTitle: "Trainee", - Dezernat: &dezernat, }) if err != nil { t.Fatalf("create: %v", err) @@ -84,9 +82,6 @@ func TestUserService_Create_Valid(t *testing.T) { if u.Office != "munich" || u.JobTitle == nil || *u.JobTitle != "Trainee" || u.Email != "first@hlc.com" { t.Errorf("field mismatch: %+v", u) } - if u.Dezernat == nil || *u.Dezernat != "Team Müller" { - t.Errorf("dezernat not trimmed/persisted: %+v", u.Dezernat) - } } func TestUserService_Create_InvalidInput(t *testing.T) {