feat(t-paliad-070): rename Department → PartnerUnit on the Go side

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.
This commit is contained in:
m
2026-04-29 22:03:08 +02:00
parent f963b4b2bc
commit 76785da3f6
16 changed files with 882 additions and 451 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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=<name> 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/<slug> deep-link to its
// canonical ?tab=<name> 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/<slug> into /settings?tab=<canonical>

View File

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

View File

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

View File

@@ -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": "<uuid>"}
func handleAddDepartmentMember(w http.ResponseWriter, r *http.Request) {
// POST /api/partner-units/{id}/members — admin-only. Body: {"user_id": "<uuid>"}
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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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