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:
@@ -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,
|
||||
|
||||
48
internal/db/migrations/026_rename_to_partner_units.down.sql
Normal file
48
internal/db/migrations/026_rename_to_partner_units.down.sql
Normal 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;
|
||||
131
internal/db/migrations/026_rename_to_partner_units.up.sql
Normal file
131
internal/db/migrations/026_rename_to_partner_units.up.sql
Normal 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'
|
||||
)
|
||||
);
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
510
internal/services/partner_unit_service.go
Normal file
510
internal/services/partner_unit_service.go
Normal 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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user