Merge: t-paliad-070 partner units rename + /admin/partner-units

This commit is contained in:
m
2026-04-29 22:18:26 +02:00
31 changed files with 2320 additions and 969 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
@@ -121,7 +121,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,687 @@
# Partner Units — rename + admin management UI
**Task:** t-paliad-070
**Inventor:** cronus (mai/cronus/partner-units-rename worktree)
**Date:** 2026-04-29
**Status:** DESIGN v2 — m answered the open questions 21:44 Wed 29.04. Revised doc below; awaiting head greenlight before coder shift.
## m's answers (21:44 Wed 29.04.) summarised
1. **Naming**: `partner_unit` everywhere (snake_case for DB/JSON, `PartnerUnit` for Go types, `partner-unit(s)` for kebab-URLs).
2. **Rename API too**: `paliad.departments``paliad.partner_units`, `paliad.department_members``paliad.partner_unit_members`, `/api/departments/*``/api/partner-units/*`. Full consistency.
3. **Settings admin section**: remove (don't duplicate).
4. **Audit emit**: yes, in this PR.
5. **Free-text column drop**: yes — drop `users.dezernat` entirely instead of renaming. Phase 2 collapses into Phase 1.
This dramatically expands the rename scope but produces a single coherent end-state (no transitional German names anywhere, no duplicate-state debt). Single PR is now even more important — splitting would leave the code in an unrunnable mid-rename state for any non-trivial duration.
---
## 1. The two concerns
m wants:
1. The user-facing concept "Dezernate" renamed to **"Partner units"** everywhere.
2. The placeholder card on `/admin` ("Dezernate / Kommt bald") replaced with a real
`/admin/departments` management surface.
These two concerns share the same code surface, so this design treats them as one PR.
---
## 2. Live-state inventory (2026-04-29)
What already exists:
| Layer | Status |
|---|---|
| **DB tables** | `paliad.departments` and `paliad.department_members` already English (renamed in migrations 020 + 024). RLS policies, FKs, indexes already English. |
| **DB column** | `paliad.users.dezernat` — German legacy, free-text `text` column added in migration 015. |
| **Go service** | `internal/services/department_service.go` — full CRUD + member management. Admin-gated via `requireAdmin` (`global_role='global_admin'`). |
| **Go handlers** | `internal/handlers/departments.go` — 8 routes registered under `/api/departments/*`. |
| **Frontend admin CRUD** | Already shipped — but **inside `/settings?tab=dezernat`**, not on a dedicated admin page. Visible only to global_admin (gated client-side via `me.global_role`). |
| **Admin landing** | `/admin` shows a "Geplant / Kommt bald" Dezernate card pointing nowhere. |
| **Admin team page** | `/admin/team` has a "Dezernat" free-text column and edit input bound to `paliad.users.dezernat`. |
| **Onboarding** | Asks for "Dezernat / Partner" as free text, persists to `users.dezernat`. |
| **Settings profile tab** | Asks for "Dezernat oder Partner" free text. |
| **Team directory** | `/team` groups colleagues by `users.dezernat` free-text fallback when `paliad.departments` membership is missing. |
The duplicate-state debt is real: the same concept lives in two places —
the structured `paliad.departments` registry (admin-managed) and the free-text
`paliad.users.dezernat` column (user-typed). Migration 019 backfilled the
former from the latter, but they have been drifting apart since. **Resolving
that drift is out of scope for this task** — flagged as Phase 2.
Counts (`grep -l`):
- 7 Go files mention `dezernat` / `Dezernat`
- 10 frontend files (`.ts` / `.tsx`)
- 2 SQL migrations (015 = column add, 019 = seed function)
- ~80 i18n strings
---
## 3. Naming decisions (per m)
### 3.1 User-facing label (cross-language)
**"Partner unit" / "Partner units"** — same English phrase in DE and EN.
Capitalised loanword in DE strings ("Partner Unit anlegen", "Partner Units
verwalten").
### 3.2 Internal names — full rename to `partner_unit`
Per m's "lets fix departments even in api?!", everything Department-shaped
on the structured side renames too. End state:
| Surface | Before | After |
|---|---|---|
| Table | `paliad.departments` | `paliad.partner_units` |
| Junction table | `paliad.department_members` | `paliad.partner_unit_members` |
| FK column on junction | `department_id` | `partner_unit_id` |
| Constraint names | `departments_*`, `department_members_*` | `partner_units_*`, `partner_unit_members_*` |
| Index names | same prefix | same prefix |
| RLS policy names | `departments_select` etc. | `partner_units_select` etc. |
| Go type | `models.Department` | `models.PartnerUnit` |
| Go type | `services.DepartmentMember` | `services.PartnerUnitMember` |
| Go type | `services.DepartmentWithMembers` | `services.PartnerUnitWithMembers` |
| Go service | `DepartmentService` (`Service.Department`) | `PartnerUnitService` (`Service.PartnerUnit`) |
| Go file | `internal/services/department_service.go` | `internal/services/partner_unit_service.go` |
| Go file | `internal/handlers/departments.go` | `internal/handlers/partner_units.go` |
| API path | `/api/departments` | `/api/partner-units` |
| API path | `/api/departments/{id}/members` | `/api/partner-units/{id}/members` |
| Admin URL | `/admin/departments` | `/admin/partner-units` |
| TSX file | (new) `admin-partner-units.tsx` | same |
| Client TS | (new) `client/admin-partner-units.ts` | same |
| JSON keys | `department_id`, `lead_user_id`, `members[]` | `partner_unit_id`, `lead_user_id`, `members[]` |
| i18n keys | `dezernat.*` | `partner_unit.*` |
| CSS classes | `.dezernat-*` | `.partner-unit-*` |
| CSS classes | (none today) | `.partner-unit-*` |
### 3.3 The `users.dezernat` free-text column
**Drop entirely** (per m's answer 5). Migration also re-runs migration 019's
seed logic immediately before the drop, to capture any drift since 019 ran
(users who edited their `dezernat` value via `/settings` after 019 won't
have a corresponding `partner_unit_members` row). Idempotent
`ON CONFLICT DO NOTHING`.
This means **the onboarding form stops asking for a free-text Dezernat/
Partner field** and **the settings profile tab stops surfacing it**.
Replacement UX (lightweight — same PR):
- **Onboarding**: replace the free-text `dezernat` input with a `<select>`
populated from `GET /api/partner-units` (anonymous-readable; the public
list is fine to expose). First option = "(noch keine zuordnung / not
assigned yet)" maps to no membership. The select writes a
`partner_unit_id` to the create-user payload, and the user-creation flow
inserts a row in `paliad.partner_unit_members` if a unit was picked.
- **Settings profile tab**: drop the field entirely. Membership management
for non-admins lives on the existing "Mein Partner Units" read-only view
(which stays — see §4.4). If a user wants to change their own membership,
they ask an admin (matches the "global_admin only" model in §5).
- **Admin-team table**: drop the "Dezernat" column and the inline-edit input
for it. Admin sees memberships via the dedicated `/admin/partner-units`
page; the team page already has membership chips shown (per F-44 — verify
during smoke). Reduces double-source-of-truth confusion.
- **Team directory grouping**: the `/team` "Nach Dezernat" group keeps its
partner-unit grouping (now reading only from structured `partner_unit_members`),
drops the free-text fallback bucket.
### 3.4 What does NOT rename
- `lead_user_id` (column on partner_units) — generic FK name, not
Department-flavoured.
- `office` (column on partner_units) — generic.
- The 8 HTTP routes' shape — only the path changes; verbs/handler names
rename (`handleListDepartments``handleListPartnerUnits`).
- `paliad.users.office`, `paliad.users.additional_offices` — orthogonal.
### 3.5 URL strategy
- `/settings?tab=dezernat` — tab is removed (admin section moves to
`/admin/partner-units`, "my unit" view becomes a card on the profile tab).
No redirect needed (settings tabs aren't externally bookmarked).
- `/admin/partner-units` is the new admin page. The old placeholder card
was a no-op, no legacy URL to redirect from.
- `/api/departments/*` — no legacy redirect. The API is internal to the
bundled JS (no third-party consumer); a one-shot rename without aliases is
safe. Should there ever be an integration in flight, add a 301 alias in
`internal/handlers/redirects.go` mirroring the existing `/dezernate`
redirect.
---
## 4. The new `/admin/partner-units` page
### 4.1 Surface
A dedicated admin page mirroring `/admin/team`'s aesthetic:
- **Page title:** "Partner Units verwalten" / "Manage Partner Units"
- **Top bar:** count of partner units, plus a primary "Neue Partner Unit anlegen"
button (opens an inline form panel below the table — matches admin-team's
invite/onboard pattern).
- **Table:** columns = Name · Office · Lead (display name + email) · Members
count · Actions. One row per partner unit, ordered by office then name.
- **Inline edit:** click a row → expand below for {edit name / change office /
change lead / view+manage members}. Same disclosure pattern as the existing
settings admin section, but lifted to a top-level admin page with breathing
room.
- **Member management:** typeahead "add member" input (re-uses the same
`/api/users` endpoint `loadUserOptions()` already calls). Each member row
has a remove button with confirmation. Optional "make lead" pin if the
member is a lead candidate (`job_title` containing "Partner" — soft hint,
not a gate).
- **Delete:** danger button with confirm. Cascades memberships (FK on
`department_members`).
Wireframe (ASCII):
```
┌────────────────────────────────────────────────────────────────────────┐
│ Admin > Partner Units [+ Neue Partner Unit] │
├────────────────────────────────────────────────────────────────────────┤
│ Suche: [____________] Office: [Alle ▼] │
├────────────────────────────────────────────────────────────────────────┤
│ Name Office Lead Mitglieder Aktion │
│ Team Müller München Dr. M. Müller 7 ▾ ✏ 🗑 │
│ Team Schmidt München Dr. A. Schmidt 3 ▾ ✏ 🗑 │
│ Team Lopez Düsseldorf J. Lopez 5 ▾ ✏ 🗑 │
│ ... │
└────────────────────────────────────────────────────────────────────────┘
Click ▾ on a row to expand:
┌─ Mitglieder verwalten — Team Müller ────────────────────────────────────┐
│ • Dr. M. Müller muller@hlc.de ★ Lead │
│ • A. Bauer bauer@hlc.de [Entfernen] │
│ • C. Kim kim@hlc.de [Entfernen] │
│ ... │
│ [Mitglied hinzufügen: __________________ ▼] [Hinzufügen] │
└─────────────────────────────────────────────────────────────────────────┘
```
### 4.2 Files to create
- `frontend/src/admin-partner-units.tsx` — page render, mirrors
`admin-team.tsx` shape: container + tool-header + filters + table.
- `frontend/src/client/admin-partner-units.ts` — fetch, render, edit,
delete, member CRUD. Reuses the office list endpoint, `/api/users`
for the typeahead, `t()` for i18n, sidebar + bottom-nav init.
- `frontend/build.ts` entry — `renderAdminPartnerUnits`
`dist/admin-partner-units.html`, `dist/assets/admin-partner-units.js`.
- `internal/handlers/admin_partner_units.go`
`handleAdminPartnerUnitsPage` (one-liner ServeFile, mirrors
`handleAdminTeamPage`).
### 4.3 Files to edit
- `internal/handlers/handlers.go` — register `GET /admin/partner-units`
inside the existing `if svc != nil && svc.Users != nil` block, gated by
`auth.RequireAdminFunc(svc.Users, gateOnboarded(handleAdminPartnerUnitsPage))`.
Re-register the 8 `/api/partner-units/*` routes (renamed from
`/api/departments/*`).
- `frontend/src/admin.tsx` — flip the Partner-Units card from the
"Geplant" section to the "Verfügbar" section, with
`href="/admin/partner-units"`, remove the `admin-card-soon` class and the
"Kommt bald" badge. Icon stays `ICON_BUILDING`.
- `frontend/src/components/Sidebar.tsx` — add a third admin nav item
inside `#sidebar-admin-group`: `navItem("/admin/partner-units",
ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)`.
- `frontend/src/client/i18n.ts` — replace `dezernat.*` and add new keys
(see §6).
### 4.4 Settings page cleanup
The current `/settings?tab=dezernat` has TWO panels:
- "Mein Dezernat" (read-only, shows the user's own units) — **keep** as a
card on the profile tab (no longer needs its own tab; the only reason it
had one was the admin CRUD section). Renamed to "Meine Partner Units".
- "Dezernate verwalten (Admin)" (full CRUD) — **remove**. Replaced by
`/admin/partner-units`. Reduces duplication and matches the "admin tools
live under /admin" convention established by t-paliad-050.
Net code delta in `settings.tsx` + `settings.ts`: removes ~250 lines (admin
CRUD moves to new page; read-only "my units" card moves into profile tab as
~30 lines). The `dezernat` profile-input field is removed entirely (no
replacement on the profile tab; users manage membership via admin requests).
The settings tab list shrinks from 4 to 3: `profil`, `benachrichtigungen`,
`caldav`. URL `/settings?tab=dezernat` 404s gracefully (the tab resolver in
`appointments_pages.go` falls back to `profil`).
---
## 5. Permission model
| Action | Today | After |
|---|---|---|
| List partner units (read) | any authenticated user | unchanged |
| Get partner unit details | any authenticated user | unchanged |
| List members | any authenticated user | unchanged |
| Get own memberships | any authenticated user | unchanged |
| Create | global_admin only | unchanged |
| Update | global_admin only | unchanged |
| Delete | global_admin only | unchanged |
| Add member | global_admin only | unchanged |
| Remove member | global_admin only | unchanged |
No permission-model changes. Service-level `requireAdmin` already enforces
`global_role='global_admin'` for every write.
**Out of scope (defer):** allowing the partner unit's lead user to manage
their own unit's members. m's brief asks "who can assign members? (global_admin
+ the unit's lead/partner?)" — recommendation: defer. Today there are no
real partners with `lead_user_id` set in prod, and m has been actively
pruning permission complexity. Add later when there's a clear request.
---
## 6. i18n strings
**Drop entirely** (no replacement — surfaces are removed):
- `einstellungen.profil.dezernat`, `einstellungen.profil.dezernat.placeholder`
(settings profile field is gone)
- `einstellungen.tab.dezernat` (tab is gone)
- `onboarding.dezernat`, `onboarding.dezernat.placeholder` (free-text input is
replaced with a select; new keys: `onboarding.partner_unit`,
`onboarding.partner_unit.placeholder`, `onboarding.partner_unit.unassigned`)
- `admin.team.col.dezernat` (column removed from admin-team)
- `admin.team.direct_add.dezernat` (input removed from add-form)
- `dezernat.error.user_required`, `dezernat.field.office`, `dezernat.field.name`,
`dezernat.admin.heading`, `dezernat.admin.new`, `dezernat.admin.create`
these belonged to the settings admin section that moves to the new page;
same strings re-keyed under `admin.partner_units.*`.
- `team.dept.unassigned` ("Ohne Dezernat") — replaced with
`team.partner_unit.unassigned` ("Ohne Partner Unit")
**Add (new admin page):**
- `nav.admin.partner_units` = "Partner Units"
- `admin.partner_units.title`, `admin.partner_units.heading`,
`admin.partner_units.subtitle`
- `admin.partner_units.col.name`, `.col.office`, `.col.lead`, `.col.members`,
`.col.actions`
- `admin.partner_units.new`, `admin.partner_units.new.heading`,
`admin.partner_units.create`, `admin.partner_units.cancel`,
`admin.partner_units.delete`, `admin.partner_units.confirm_delete`
- `admin.partner_units.member.add`, `.member.remove`, `.member.confirm_remove`,
`.member.placeholder`, `.member.empty`, `.member.loading`
- `admin.partner_units.error.name_required`, `.error.user_required`
- `admin.partner_units.empty` ("Noch keine Partner Units angelegt.")
**Rename (settings profile-tab "my partner units" card):**
- `dezernat.heading``partner_unit.heading` ("Meine Partner Units")
- `dezernat.subtitle``partner_unit.subtitle`
- `dezernat.none``partner_unit.none`
- `dezernat.members_label``partner_unit.members_label`
**Update copy** (no key change):
- `admin.card.departments.title` → "Partner Units" (was "Dezernate") — and
the key itself renames to `admin.card.partner_units.title` for consistency
- `admin.card.departments.desc` → "Partner Units anlegen und Mitglieder
verwalten." → key renames to `admin.card.partner_units.desc`
- `admin.card.feature_flags.desc` — German body mentions "Dezernat",
rewrite as "Partner Unit"
- `team.subtitle` and `team.group.department` — German bodies say
"Dezernat", rewrite
DE strings use "Partner Unit" / "Partner Units" verbatim (capitalised
loanword). EN uses the same.
---
## 7. Migration plan
### 7.1 Migration 026: rename tables + drop free-text column
One migration, ordered statements, all wrapped in a single tx by migrate.v4:
```sql
-- 026_rename_to_partner_units.up.sql
BEGIN; -- migrate.v4 wraps automatically; explicit BEGIN for psql -1 fallback
-- 1. Best-effort second seed: pick up any users whose dezernat free-text
-- drifted after migration 019 ran. Idempotent.
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) <> ''
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 free-text column.
ALTER TABLE paliad.users DROP COLUMN dezernat;
-- 3. Rename tables.
ALTER TABLE paliad.departments RENAME TO partner_units;
ALTER TABLE paliad.department_members RENAME TO partner_unit_members;
-- 4. Rename column on the junction.
ALTER TABLE paliad.partner_unit_members RENAME COLUMN department_id TO partner_unit_id;
-- 5. Rename constraints (pkey/fkey/check). Postgres auto-renames the
-- underlying index for pkey/uniq constraints.
ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_pkey TO partner_units_pkey;
ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_lead_user_id_fkey TO partner_units_lead_user_id_fkey;
ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_office_check TO partner_units_office_check;
ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_pkey TO partner_unit_members_pkey;
ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_department_id_fkey TO partner_unit_members_partner_unit_id_fkey;
ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_user_id_fkey TO partner_unit_members_user_id_fkey;
-- 6. Rename non-pkey indexes.
ALTER INDEX paliad.departments_office_idx RENAME TO partner_units_office_idx;
ALTER INDEX paliad.departments_lead_idx RENAME TO partner_units_lead_idx;
ALTER INDEX paliad.department_members_user_idx RENAME TO partner_unit_members_user_idx;
-- 7. Rename RLS policies.
ALTER POLICY departments_select ON paliad.partner_units RENAME TO partner_units_select;
ALTER POLICY departments_write ON paliad.partner_units RENAME TO partner_units_write;
ALTER POLICY department_members_select ON paliad.partner_unit_members RENAME TO partner_unit_members_select;
ALTER POLICY department_members_write ON paliad.partner_unit_members RENAME TO partner_unit_members_write;
-- 8. Audit table for partner-unit events. Per §8 — minimal schema, no UI yet.
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'
)),
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);
-- RLS: any authenticated user can read (matches /api/partner-units read
-- access); only global_admin can write (writes happen inside service
-- methods that already gate with requireAdmin, so RLS is defence-in-depth).
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')
);
COMMIT;
```
**Down migration** is the symmetric reverse of steps 8 → 1, with one caveat:
step 1 (the seed) cannot be perfectly reversed. The `paliad.users.dezernat`
column is recreated with NULLs; original values are lost. This is acceptable
because the data is preserved structurally in `partner_unit_members`.
If a true rollback is ever needed and per-user free-text values must be
restored, an admin script can re-seed from `partner_unit_members`:
`UPDATE paliad.users u SET dezernat = (SELECT pu.name FROM ... LIMIT 1)`.
Documented in the down migration as a comment, not auto-run.
### 7.2 Code cutover
migrate.v4 wraps the up migration in a single tx. If anything in the rename
chain fails (e.g. a constraint name mismatch on a freshly-provisioned DB
that didn't go through 020+024), the entire migration aborts and the dirty
flag is set. To minimise that risk, the constraint/index/policy rename
statements are wrapped in `DO $$ ... EXCEPTION WHEN undefined_object THEN
NULL END $$` blocks (same idempotency pattern migration 024 used).
Order of operations:
1. Push code (with migration 026 in `embed.FS`) to main.
2. Dokploy auto-deploys; the new binary's `migrate.Up()` runs migration 026
atomically before binding the listener.
3. Verify `/api/partner-units` returns the renamed table contents; `/admin/partner-units`
renders; `paliad.users.dezernat` no longer exists.
Migration risk is moderate (multi-statement, table rename + column drop +
new audit table) but contained: every statement is idempotent or
exception-trapped, and it all runs inside one tx so a partial apply is
impossible.
### 7.3 Rollback
`migrate down 1` reverses everything. The data loss noted above (free-text
column re-created with NULLs) is acceptable per §3.3 — structured
membership rows are the source of truth post-rename.
---
## 8. Audit logging — emitted in this PR
Per m's "audit emit? sure, why not", this PR ships audit emission. To stay
small and not pre-empt t-paliad-071's eventual cross-cutting audit design,
the emission goes to a dedicated `paliad.partner_unit_events` table (see
migration 026 step 8) rather than a global audit table. t-paliad-071 can
later subsume it (UNION ALL into a global view, or migrate rows into a
unified table).
### Events emitted
Each event is INSERTed in the same tx as the originating mutation.
| Event | When | Payload |
|---|---|---|
| `created` | `Create` succeeds | `{name, office, lead_user_id}` |
| `updated` | `Update` writes ≥1 column | `{before: {…}, after: {…}, fields: ["name","office",…]}` |
| `deleted` | `Delete` succeeds (before cascade) | `{name, office, lead_user_id, member_count}` |
| `member_added` | `AddMember` actually inserts | `{user_id, user_email, user_display_name}` |
| `member_removed` | `RemoveMember` actually deletes ≥1 row | `{user_id}` |
`actor_id` is the `callerID` already passed to every service method.
`partner_unit_id` is set to NULL on `deleted` after the unit row is gone
(FK has `ON DELETE SET NULL`), so the historical event row survives.
### No new endpoint in this PR
The `partner_unit_events` table is queryable via `/api/partner-units/{id}/events`
in a follow-up — keeping that endpoint out of scope here aligns with the
"ship audit emit, defer audit UX" framing. If t-paliad-071 wants to expose
events through a unified audit surface, that's the right home.
### Service-side wiring
A single helper inside `PartnerUnitService`:
```go
func (s *PartnerUnitService) emit(ctx context.Context, tx *sqlx.Tx,
actorID uuid.UUID, unitID *uuid.UUID, eventType string, payload any) error {
p, err := json.Marshal(payload)
if err != nil { return err }
_, err = tx.ExecContext(ctx,
`INSERT INTO paliad.partner_unit_events
(partner_unit_id, actor_id, event_type, payload)
VALUES ($1, $2, $3, $4)`, unitID, actorID, eventType, p)
return err
}
```
Each mutating method opens a tx (currently they don't — they use
`db.ExecContext` directly), runs the mutation + emit, commits. Adds ~5
lines per method × 5 methods = ~25 lines of audit plumbing.
---
## 9. Test plan
### 9.1 Build gauntlet
- `go build ./...`
- `go vet ./...`
- `go test ./...` (existing user_service_test.go uses `dezernat` test name —
rename to `department` to match)
- `cd frontend && bun run build`
### 9.2 Manual smoke (paliad.de as `tester@hlc.de`)
1. Log in as global_admin.
2. Visit `/admin` — confirm "Partner Units" card under "Verfügbar" (not
"Geplant"), no "Kommt bald" badge.
3. Click → land on `/admin/partner-units` — confirm table renders existing
units (with names migration 019 + the second seed produced).
4. Create a new unit "Test Unit Cronus" (Munich, no lead). Confirm a
`created` row appears in `paliad.partner_unit_events`.
5. Edit name → "Test Unit Cronus (renamed)". Confirm `updated` event row.
6. Add tester@hlc.de as member; confirm `member_added` event; chip appears
on `/team` directory grouping.
7. Remove member. Confirm `member_removed` event.
8. Delete the test unit; confirm row disappears from the table; confirm
`deleted` event row exists with `partner_unit_id IS NULL` (orphaned by
ON DELETE SET NULL).
9. Visit `/settings` — confirm tab list is `Profil | Benachrichtigungen |
CalDAV` (no Dezernat tab). Profile tab has "Meine Partner Units" card;
no free-text dezernat input.
10. Visit `/team` — confirm grouping by Partner Unit (not Dezernat) and
"Ohne Partner Unit" fallback label.
11. Visit `/admin/team` — confirm Dezernat column is gone; add-form has no
Dezernat input.
12. Visit `/onboarding` (with a fresh auth.users-only account) — confirm
the free-text Dezernat input is replaced with a partner-unit `<select>`.
13. Sign out, sign back in as a non-admin — confirm `/admin/partner-units`
returns 302 to `/dashboard?forbidden=admin`, sidebar admin section is
hidden.
### 9.3 Playwright (optional — confirm with head)
If Playwright smoke is desired, mirror t-paliad-050's admin-team pattern:
navigate, create, edit, delete, screenshot. Add an SQL assertion step that
checks `partner_unit_events` row counts after each action.
---
## 10. PR strategy
**Single PR, single merge to main.**
Reasoning:
- The rename touches the same files as the new admin page (admin.tsx,
i18n.ts, settings.tsx, admin-team.tsx, sidebar.tsx, onboarding.tsx,
team.tsx). Splitting forces ugly rebases.
- The migration is multi-statement but single-tx — no risk of partial apply.
- The user-facing label change is consistent only after the WHOLE diff lands.
A split would land "internal rename" with old labels still saying "Dezernat",
then "label change" — confusing during the gap.
- Settings has a redirect dependency (`/settings?tab=dezernat` 404 fallback)
that's only safe once the entire dezernat surface is gone.
Branch already in place: `mai/cronus/partner-units-rename`.
Estimated diff size: ~2200 lines net. Heavier than v1 because the
structured-side rename (Department → PartnerUnit) cascades through
service/handler/types/SQL/tests, plus onboarding form rebuild, plus audit
table + emit plumbing. No new dependencies.
---
## 11. Out of scope (deferred)
- **Hierarchical partner units** — flat list only. Per brief.
- **Per-partner-unit branding** (logo, colour) — defer.
- **Non-admin permission model** (lead manages own unit's members) — defer.
- **Audit UI** (a viewer for `partner_unit_events`) — defer to t-paliad-071.
Emission lands here; consumption + a unified events surface lands there.
- **Other entities' audit emission** — only partner units in this PR.
Projects already have `project_events`; deadlines/appointments already
emit. No global cross-entity audit yet.
- **Onboarding "create new partner unit" inline** — the new select offers
existing units + "(noch keine zuordnung)". A user wanting a new unit asks
an admin or self-promotes via `/admin/partner-units` post-onboarding (only
global_admin sees that page). Inline create-during-onboarding is a small
follow-up if friction surfaces.
---
## 12. Open questions — RESOLVED 21:44 Wed 29.04. (m's answers)
| # | Question | m's answer |
|---|---|---|
| 1 | Column rename target | **partner_unit** (became "drop entirely" after Q5) |
| 2 | API + URL rename | **yes — fix departments in api too** |
| 3 | Settings admin section removal | **yes** ("you do you") |
| 4 | Audit emit in this PR | **yes** ("sure why not") |
| 5 | Drop free-text column | **yes** ("makes sense") |
No remaining open questions. Design is now greenlit pending head's gate
review of this v2 doc.
---
## 13. Files (final)
### New
- `internal/db/migrations/026_rename_to_partner_units.up.sql`
- `internal/db/migrations/026_rename_to_partner_units.down.sql`
- `internal/services/partner_unit_service.go` (renamed from
`department_service.go` via `git mv` so blame survives — content rewritten
for type + SQL renames + audit emit)
- `internal/handlers/partner_units.go` (renamed from `departments.go`)
- `internal/handlers/admin_partner_units.go` — page-serve handler
- `frontend/src/admin-partner-units.tsx`
- `frontend/src/client/admin-partner-units.ts`
### Edit (Go)
- `internal/services/services.go` — wire `PartnerUnit *PartnerUnitService`.
- `internal/services/user_service.go` — drop `Dezernat` field from struct,
drop dezernat from SQL columns, drop dezernat from CreateUserInput +
UpdateUserInput, etc.
- `internal/services/user_service_test.go` — drop dezernat assertions;
add partner_unit_id + member-row assertions if onboarding/admin-create
paths now insert membership.
- `internal/models/models.go` — drop `User.Dezernat`; rename
`Department` → `PartnerUnit`, `DepartmentMember` → `PartnerUnitMember`.
- `internal/handlers/admin_users.go` — drop dezernat from admin
create/update payloads.
- `internal/handlers/handlers.go` — re-register `/api/partner-units/*`,
add `GET /admin/partner-units`, drop `dbSvc.department` field, add
`dbSvc.partnerUnit`.
- `internal/handlers/redirects.go` — drop the `/dezernate` → `/departments`
entry (the path is dead post-rename) OR keep for one cycle; flag in PR
description.
- `internal/handlers/appointments_pages.go` — drop the `"dezernat"` /
`"department"` tab aliases entirely (tab is gone). Default fallback handles
`/settings?tab=dezernat` gracefully.
### Edit (frontend)
- `frontend/src/admin.tsx` — flip the Partner-Units card from "Geplant" to
"Verfügbar".
- `frontend/src/admin-team.tsx` — drop the "Dezernat" column and the
add-form input.
- `frontend/src/client/admin-team.ts` — drop dezernat from payload + render.
- `frontend/src/onboarding.tsx` — replace free-text input with `<select>`
populated from `/api/partner-units`, plus an "(noch keine zuordnung)"
option. Label is `onboarding.partner_unit`.
- `frontend/src/client/onboarding.ts` — submit `partner_unit_id` instead of
`dezernat`. The user-create endpoint now accepts an optional `partner_unit_id`
and inserts a membership row in the same tx.
- `frontend/src/settings.tsx` — drop the dezernat tab, drop the dezernat
profile-field input, add a "Meine Partner Units" card on the profile tab.
- `frontend/src/client/settings.ts` — drop `dezernat` from `TabName` and
`TABS`, drop ~250 lines of admin CRUD + free-text plumbing, replace with
~40 lines for the read-only "my units" card.
- `frontend/src/team.tsx` + `frontend/src/client/team.ts` — labels and
drop the free-text fallback bucket; group only by structured
`partner_unit_members`.
- `frontend/src/components/Sidebar.tsx` — add `/admin/partner-units` nav
item with `nav.admin.partner_units` label.
- `frontend/src/client/i18n.ts` — drop ~30 dezernat keys × 2 langs;
add ~25 partner_unit keys × 2 langs.
- `frontend/src/styles/global.css` — `.dezernat-*` → `.partner-unit-*`.
- `frontend/build.ts` — new `renderAdminPartnerUnits` entry.
---
## 14. Inventor → coder gate
Stop after this design + a `mai report completed "DESIGN READY FOR REVIEW…"`.
Awaiting m's go/no-go on the open questions in §12 before any code change.
Recommended implementer: **cronus** (this same worktree, already on
`mai/cronus/partner-units-rename`). Mechanical rename + one new page is
straightforward Sonnet work; the design context doesn't need to transfer
to a fresh worker.

View File

@@ -32,6 +32,7 @@ import { renderTeam } from "./src/team";
import { renderAdmin } from "./src/admin";
import { renderAdminTeam } from "./src/admin-team";
import { renderAdminAuditLog } from "./src/admin-audit-log";
import { renderAdminPartnerUnits } from "./src/admin-partner-units";
import { renderAdminEmailTemplates } from "./src/admin-email-templates";
import { renderAdminEmailTemplatesEdit } from "./src/admin-email-templates-edit";
import { renderNotFound } from "./src/notfound";
@@ -111,6 +112,7 @@ async function build() {
join(import.meta.dir, "src/client/admin.ts"),
join(import.meta.dir, "src/client/admin-team.ts"),
join(import.meta.dir, "src/client/admin-audit-log.ts"),
join(import.meta.dir, "src/client/admin-partner-units.ts"),
join(import.meta.dir, "src/client/admin-email-templates.ts"),
join(import.meta.dir, "src/client/admin-email-templates-edit.ts"),
join(import.meta.dir, "src/client/notfound.ts"),
@@ -218,6 +220,7 @@ async function build() {
await Bun.write(join(DIST, "admin.html"), renderAdmin());
await Bun.write(join(DIST, "admin-team.html"), renderAdminTeam());
await Bun.write(join(DIST, "admin-audit-log.html"), renderAdminAuditLog());
await Bun.write(join(DIST, "admin-partner-units.html"), renderAdminPartnerUnits());
await Bun.write(join(DIST, "admin-email-templates.html"), renderAdminEmailTemplates());
await Bun.write(join(DIST, "admin-email-templates-edit.html"), renderAdminEmailTemplatesEdit());
await Bun.write(join(DIST, "notfound.html"), renderNotFound());

View File

@@ -0,0 +1,131 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
export function renderAdminPartnerUnits(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="admin.partner_units.title">Partner Units &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/partner-units" />
<BottomNav currentPath="/admin/partner-units" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div>
<h1 data-i18n="admin.partner_units.heading">Partner Units</h1>
<p className="tool-subtitle" data-i18n="admin.partner_units.subtitle">
Strukturelle Partnereinheiten verwalten und Mitglieder zuordnen.
</p>
</div>
<div className="admin-team-actions">
<button className="btn-primary" id="pu-new-btn" type="button" data-i18n="admin.partner_units.new">
Neue Partner Unit
</button>
</div>
</div>
<div id="pu-feedback" className="form-msg" style="display:none" />
<div className="akten-table-wrap admin-team-table-wrap">
<table className="akten-table admin-team-table">
<thead>
<tr>
<th data-i18n="admin.partner_units.col.name">Name</th>
<th data-i18n="admin.partner_units.col.office">B&uuml;ro</th>
<th data-i18n="admin.partner_units.col.lead">Lead</th>
<th data-i18n="admin.partner_units.col.members">Mitglieder</th>
<th data-i18n="admin.partner_units.col.actions">Aktionen</th>
</tr>
</thead>
<tbody id="pu-tbody">
<tr><td colspan={5} className="admin-team-loading" data-i18n="admin.partner_units.loading">Lade...</td></tr>
</tbody>
</table>
</div>
<div className="akten-empty" id="pu-empty" style="display:none">
<p data-i18n="admin.partner_units.empty">Noch keine Partner Units angelegt.</p>
</div>
</div>
</section>
</main>
{/* Create / edit modal — same shape for both, "id" is empty when
creating. Office select is populated from /api/offices at init,
lead picker from /api/users (filtered to display_name+email). */}
<div className="modal-overlay" id="pu-edit-modal" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 id="pu-edit-title" data-i18n="admin.partner_units.new.heading">Partner Unit anlegen</h2>
<button className="modal-close" id="pu-edit-close" type="button" aria-label="Close">&times;</button>
</div>
<form id="pu-edit-form" className="akten-form" autocomplete="off">
<input type="hidden" id="pu-edit-id" />
<div className="form-field">
<label htmlFor="pu-edit-name" data-i18n="admin.partner_units.col.name">Name</label>
<input type="text" id="pu-edit-name" required />
</div>
<div className="form-field">
<label htmlFor="pu-edit-office" data-i18n="admin.partner_units.col.office">B&uuml;ro</label>
<select id="pu-edit-office" required />
</div>
<div className="form-field">
<label htmlFor="pu-edit-lead" data-i18n="admin.partner_units.col.lead">Lead</label>
<select id="pu-edit-lead">
<option value="">&mdash;</option>
</select>
</div>
<p className="form-msg" id="pu-edit-msg" />
<div className="form-actions">
<button type="button" className="btn-cancel" id="pu-edit-cancel" data-i18n="admin.partner_units.cancel">Abbrechen</button>
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="admin.partner_units.create">Speichern</button>
</div>
</form>
</div>
</div>
{/* Member-management modal — opens from the row's "Verwalten" button. */}
<div className="modal-overlay" id="pu-members-modal" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 id="pu-members-title">Mitglieder verwalten</h2>
<button className="modal-close" id="pu-members-close" type="button" aria-label="Close">&times;</button>
</div>
<div id="pu-members-body">
<ul className="partner-unit-member-list" id="pu-members-list" />
<form id="pu-add-form" autocomplete="off" className="akten-form">
<div className="form-field">
<label htmlFor="pu-add-input" data-i18n="admin.partner_units.member.add">Mitglied hinzuf&uuml;gen</label>
<input type="text" id="pu-add-input" data-i18n-placeholder="admin.partner_units.member.placeholder" placeholder="Name oder E-Mail" />
<input type="hidden" id="pu-add-user-id" />
<div className="akten-collab-suggestions" id="pu-add-suggestions" />
</div>
<p className="form-msg" id="pu-add-msg" />
<div className="form-actions">
<button type="submit" className="btn-primary btn-cta-lime btn-small" data-i18n="admin.partner_units.member.add_btn">Hinzuf&uuml;gen</button>
</div>
</form>
</div>
</div>
</div>
<Footer />
<script src="/assets/admin-partner-units.js"></script>
</body>
</html>
);
}

View File

@@ -73,7 +73,6 @@ export function renderAdminTeam(): string {
<th data-i18n="admin.team.col.office">Standort</th>
<th data-i18n="admin.team.col.job_title">Berufsbezeichnung</th>
<th data-i18n="admin.team.col.permission">Berechtigung</th>
<th data-i18n="admin.team.col.dezernat">Dezernat</th>
<th data-i18n="admin.team.col.additional">Weitere Standorte</th>
<th data-i18n="admin.team.col.lang">Sprache</th>
<th data-i18n="admin.team.col.created">Angelegt</th>
@@ -81,7 +80,7 @@ export function renderAdminTeam(): string {
</tr>
</thead>
<tbody id="admin-team-tbody">
<tr><td colspan={10} className="admin-team-loading" data-i18n="admin.team.loading">Lade...</td></tr>
<tr><td colspan={9} className="admin-team-loading" data-i18n="admin.team.loading">Lade...</td></tr>
</tbody>
</table>
</div>
@@ -122,10 +121,6 @@ export function renderAdminTeam(): string {
<label htmlFor="admin-da-role" data-i18n="admin.team.direct_add.job_title">Berufsbezeichnung</label>
<input type="text" id="admin-da-role" name="job_title" placeholder="Associate" />
</div>
<div className="form-field">
<label htmlFor="admin-da-dezernat" data-i18n="admin.team.direct_add.dezernat">Dezernat (optional)</label>
<input type="text" id="admin-da-dezernat" name="dezernat" />
</div>
<div id="admin-da-feedback" className="form-msg" style="display:none" />
<div className="form-actions">
<button type="button" className="btn-cancel" id="admin-da-cancel" data-i18n="admin.team.direct_add.cancel">Abbrechen</button>

View File

@@ -19,19 +19,12 @@ interface PlannedCard {
}
const PLANNED: PlannedCard[] = [
{
icon: ICON_BUILDING,
i18nTitle: "admin.card.departments.title",
i18nDesc: "admin.card.departments.desc",
fallbackTitle: "Dezernate",
fallbackDesc: "Dezernate anlegen und Mitglieder verwalten.",
},
{
icon: ICON_FLAG,
i18nTitle: "admin.card.feature_flags.title",
i18nDesc: "admin.card.feature_flags.desc",
fallbackTitle: "Feature-Flags",
fallbackDesc: "Funktionen pro Standort, Dezernat oder Rolle aktivieren.",
fallbackDesc: "Funktionen pro Standort, Partner Unit oder Rolle aktivieren.",
},
];
@@ -69,6 +62,11 @@ export function renderAdmin(): string {
<h2 data-i18n="admin.card.team.title">Team-Verwaltung</h2>
<p data-i18n="admin.card.team.desc">Benutzer:innen anlegen, bearbeiten, l&ouml;schen.</p>
</a>
<a href="/admin/partner-units" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_BUILDING }} />
<h2 data-i18n="admin.card.partner_units.title">Partner Units</h2>
<p data-i18n="admin.card.partner_units.desc">Strukturelle Partnereinheiten anlegen und Mitglieder zuordnen.</p>
</a>
<a href="/admin/audit-log" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_LOG }} />
<h2 data-i18n="admin.card.audit.title">Audit-Log</h2>

View File

@@ -0,0 +1,403 @@
import { initI18n, onLangChange, t, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface PartnerUnit {
id: string;
name: string;
lead_user_id?: string | null;
office: string;
created_at: string;
updated_at: string;
}
interface Member {
user_id: string;
email: string;
display_name: string;
office: string;
job_title: string | null;
}
interface PartnerUnitWithMembers extends PartnerUnit {
lead_display_name?: string;
lead_email?: string;
members: Member[];
}
interface Office {
key: string;
label_de: string;
label_en: string;
}
interface UserOption {
id: string;
display_name: string;
email: string;
}
let units: PartnerUnitWithMembers[] = [];
let offices: Office[] = [];
let userOptions: UserOption[] = [];
let activeUnitID: string | null = null;
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
function officeLabel(key: string): string {
const o = offices.find((x) => x.key === key);
if (!o) return key;
return getLang() === "de" ? o.label_de : o.label_en;
}
async function loadAll(): Promise<void> {
await Promise.all([loadOffices(), loadUnits(), loadUsers()]);
render();
}
async function loadOffices(): Promise<void> {
const resp = await fetch("/api/offices");
if (resp.ok) offices = (await resp.json()) as Office[];
}
async function loadUnits(): Promise<void> {
const resp = await fetch("/api/partner-units?include=members");
if (resp.ok) units = (await resp.json()) as PartnerUnitWithMembers[];
}
async function loadUsers(): Promise<void> {
const resp = await fetch("/api/users");
if (resp.ok) userOptions = (await resp.json()) as UserOption[];
}
function showFeedback(msg: string, isError: boolean): void {
const el = document.getElementById("pu-feedback")!;
el.textContent = msg;
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-ok");
el.style.display = "block";
if (!isError) setTimeout(() => { el.style.display = "none"; }, 3500);
}
function render(): void {
const tbody = document.getElementById("pu-tbody")!;
const empty = document.getElementById("pu-empty")!;
if (!units.length) {
tbody.innerHTML = "";
empty.style.display = "block";
return;
}
empty.style.display = "none";
tbody.innerHTML = units
.map((u) => {
const lead = u.lead_display_name ?? "&mdash;";
const memberCount = u.members.length;
return `<tr data-id="${esc(u.id)}">
<td class="akten-col-title">${esc(u.name)}</td>
<td><span class="akten-office-chip akten-office-${esc(u.office)}">${esc(officeLabel(u.office))}</span></td>
<td>${esc(lead)}</td>
<td>${memberCount}</td>
<td class="admin-team-actions-cell">
<button type="button" class="btn-link pu-members-btn" data-id="${esc(u.id)}" data-i18n="admin.partner_units.action.members">Mitglieder</button>
<button type="button" class="btn-link pu-edit-btn" data-id="${esc(u.id)}" data-i18n="admin.partner_units.action.edit">Bearbeiten</button>
<button type="button" class="btn-link pu-delete-btn" data-id="${esc(u.id)}" data-i18n="admin.partner_units.action.delete">L&ouml;schen</button>
</td>
</tr>`;
})
.join("");
tbody.querySelectorAll<HTMLButtonElement>(".pu-members-btn").forEach((b) =>
b.addEventListener("click", () => openMembersModal(b.dataset.id!)),
);
tbody.querySelectorAll<HTMLButtonElement>(".pu-edit-btn").forEach((b) =>
b.addEventListener("click", () => openEditModal(b.dataset.id!)),
);
tbody.querySelectorAll<HTMLButtonElement>(".pu-delete-btn").forEach((b) =>
b.addEventListener("click", () => deleteUnit(b.dataset.id!)),
);
}
// ---- Edit modal -----------------------------------------------------------
function openEditModal(id: string | null): void {
const modal = document.getElementById("pu-edit-modal")!;
const titleEl = document.getElementById("pu-edit-title")!;
const idField = document.getElementById("pu-edit-id") as HTMLInputElement;
const nameField = document.getElementById("pu-edit-name") as HTMLInputElement;
const officeSel = document.getElementById("pu-edit-office") as HTMLSelectElement;
const leadSel = document.getElementById("pu-edit-lead") as HTMLSelectElement;
const msg = document.getElementById("pu-edit-msg")!;
msg.textContent = "";
msg.className = "form-msg";
// Populate office options.
officeSel.innerHTML = offices
.map((o) => `<option value="${esc(o.key)}">${esc(officeLabel(o.key))}</option>`)
.join("");
// Populate lead options (sorted).
const leadEntries = userOptions
.slice()
.sort((a, b) => (a.display_name || a.email).localeCompare(b.display_name || b.email));
leadSel.innerHTML =
`<option value="">&mdash;</option>` +
leadEntries
.map((u) => {
const label = u.display_name ? `${u.display_name} (${u.email})` : u.email;
return `<option value="${esc(u.id)}">${esc(label)}</option>`;
})
.join("");
if (id) {
const u = units.find((x) => x.id === id);
if (!u) return;
titleEl.setAttribute("data-i18n", "admin.partner_units.edit.heading");
titleEl.textContent = t("admin.partner_units.edit.heading") || "Partner Unit bearbeiten";
idField.value = u.id;
nameField.value = u.name;
officeSel.value = u.office;
leadSel.value = u.lead_user_id ?? "";
} else {
titleEl.setAttribute("data-i18n", "admin.partner_units.new.heading");
titleEl.textContent = t("admin.partner_units.new.heading") || "Partner Unit anlegen";
idField.value = "";
nameField.value = "";
officeSel.value = offices[0]?.key ?? "munich";
leadSel.value = "";
}
modal.style.display = "flex";
nameField.focus();
}
function closeEditModal(): void {
document.getElementById("pu-edit-modal")!.style.display = "none";
}
async function submitEdit(e: Event): Promise<void> {
e.preventDefault();
const idField = document.getElementById("pu-edit-id") as HTMLInputElement;
const nameField = document.getElementById("pu-edit-name") as HTMLInputElement;
const officeSel = document.getElementById("pu-edit-office") as HTMLSelectElement;
const leadSel = document.getElementById("pu-edit-lead") as HTMLSelectElement;
const msg = document.getElementById("pu-edit-msg")!;
const name = nameField.value.trim();
if (!name) {
msg.textContent = t("admin.partner_units.error.name_required") || "Name erforderlich";
msg.className = "form-msg form-msg-error";
return;
}
const isEdit = !!idField.value;
// Server treats missing keys as "no change". For lead clearing we send the
// nil UUID — service code interprets that as "explicit clear".
const NIL_UUID = "00000000-0000-0000-0000-000000000000";
const payload: Record<string, unknown> = {
name,
office: officeSel.value,
lead_user_id: leadSel.value || NIL_UUID,
};
const url = isEdit ? `/api/partner-units/${idField.value}` : "/api/partner-units";
const method = isEdit ? "PATCH" : "POST";
const resp = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
msg.textContent = body.error || "Fehler.";
msg.className = "form-msg form-msg-error";
return;
}
closeEditModal();
await loadUnits();
render();
showFeedback(
isEdit
? t("admin.partner_units.feedback.updated") || "Aktualisiert."
: t("admin.partner_units.feedback.created") || "Angelegt.",
false,
);
}
async function deleteUnit(id: string): Promise<void> {
const u = units.find((x) => x.id === id);
if (!u) return;
const confirmMsg = (t("admin.partner_units.confirm_delete") || "Partner Unit \"{name}\" wirklich löschen?")
.replace("{name}", u.name);
if (!window.confirm(confirmMsg)) return;
const resp = await fetch(`/api/partner-units/${id}`, { method: "DELETE" });
if (!resp.ok && resp.status !== 204) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
showFeedback(body.error || "Löschen fehlgeschlagen.", true);
return;
}
await loadUnits();
render();
showFeedback(t("admin.partner_units.feedback.deleted") || "Gelöscht.", false);
}
// ---- Members modal --------------------------------------------------------
function openMembersModal(id: string): void {
activeUnitID = id;
const u = units.find((x) => x.id === id);
if (!u) return;
const titleEl = document.getElementById("pu-members-title")!;
titleEl.textContent =
(t("admin.partner_units.member.heading") || "Mitglieder verwalten") + " — " + u.name;
renderMemberList();
// Reset add form
(document.getElementById("pu-add-input") as HTMLInputElement).value = "";
(document.getElementById("pu-add-user-id") as HTMLInputElement).value = "";
document.getElementById("pu-add-suggestions")!.innerHTML = "";
const msg = document.getElementById("pu-add-msg")!;
msg.textContent = "";
msg.className = "form-msg";
document.getElementById("pu-members-modal")!.style.display = "flex";
}
function closeMembersModal(): void {
document.getElementById("pu-members-modal")!.style.display = "none";
activeUnitID = null;
}
function renderMemberList(): void {
if (!activeUnitID) return;
const u = units.find((x) => x.id === activeUnitID);
if (!u) return;
const list = document.getElementById("pu-members-list")!;
if (!u.members.length) {
list.innerHTML = `<li class="form-hint">${esc(t("admin.partner_units.member.empty") || "Noch keine Mitglieder.")}</li>`;
return;
}
list.innerHTML = u.members
.map(
(m) => `<li class="partner-unit-member-item">
<span>${esc(m.display_name || m.email)} <span class="form-hint">(${esc(m.email)})</span></span>
<button type="button" class="btn-ghost btn-small pu-remove-btn" data-user="${esc(m.user_id)}">${esc(t("admin.partner_units.member.remove") || "Entfernen")}</button>
</li>`,
)
.join("");
list.querySelectorAll<HTMLButtonElement>(".pu-remove-btn").forEach((b) =>
b.addEventListener("click", () => removeMember(b.dataset.user!)),
);
}
function wireSuggestions(): void {
const input = document.getElementById("pu-add-input") as HTMLInputElement;
const hidden = document.getElementById("pu-add-user-id") as HTMLInputElement;
const sugs = document.getElementById("pu-add-suggestions")!;
input.addEventListener("input", () => {
const q = input.value.trim().toLowerCase();
hidden.value = "";
if (!q) {
sugs.innerHTML = "";
return;
}
const matches = userOptions
.filter((u) => (u.display_name + " " + u.email).toLowerCase().includes(q))
.slice(0, 8);
sugs.innerHTML = matches
.map(
(u) => `<div class="akten-collab-suggestion" data-id="${esc(u.id)}" data-label="${escAttr(u.display_name || u.email)}">
<strong>${esc(u.display_name || u.email)}</strong>
<span class="form-hint">${esc(u.email)}</span>
</div>`,
)
.join("");
sugs.querySelectorAll<HTMLDivElement>(".akten-collab-suggestion").forEach((el) => {
el.addEventListener("click", () => {
hidden.value = el.dataset.id!;
input.value = el.dataset.label!;
sugs.innerHTML = "";
});
});
});
}
async function submitAddMember(e: Event): Promise<void> {
e.preventDefault();
if (!activeUnitID) return;
const hidden = document.getElementById("pu-add-user-id") as HTMLInputElement;
const msg = document.getElementById("pu-add-msg")!;
if (!hidden.value) {
msg.textContent = t("admin.partner_units.error.user_required") || "Benutzer auswählen";
msg.className = "form-msg form-msg-error";
return;
}
const resp = await fetch(`/api/partner-units/${activeUnitID}/members`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: hidden.value }),
});
if (!resp.ok && resp.status !== 204) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
msg.textContent = body.error || "Fehler.";
msg.className = "form-msg form-msg-error";
return;
}
(document.getElementById("pu-add-input") as HTMLInputElement).value = "";
hidden.value = "";
document.getElementById("pu-add-suggestions")!.innerHTML = "";
msg.textContent = "";
await loadUnits();
renderMemberList();
render();
}
async function removeMember(userID: string): Promise<void> {
if (!activeUnitID) return;
const confirmMsg = t("admin.partner_units.member.confirm_remove") || "Mitglied entfernen?";
if (!window.confirm(confirmMsg)) return;
const resp = await fetch(
`/api/partner-units/${activeUnitID}/members/${userID}`,
{ method: "DELETE" },
);
if (!resp.ok && resp.status !== 204) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
showFeedback(body.error || "Entfernen fehlgeschlagen.", true);
return;
}
await loadUnits();
renderMemberList();
render();
}
// ---- Init -----------------------------------------------------------------
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
document.getElementById("pu-new-btn")!.addEventListener("click", () => openEditModal(null));
document.getElementById("pu-edit-close")!.addEventListener("click", closeEditModal);
document.getElementById("pu-edit-cancel")!.addEventListener("click", closeEditModal);
document.getElementById("pu-edit-form")!.addEventListener("submit", submitEdit);
document.getElementById("pu-edit-modal")!.addEventListener("click", (e) => {
if (e.target === e.currentTarget) closeEditModal();
});
document.getElementById("pu-members-close")!.addEventListener("click", closeMembersModal);
document.getElementById("pu-add-form")!.addEventListener("submit", submitAddMember);
document.getElementById("pu-members-modal")!.addEventListener("click", (e) => {
if (e.target === e.currentTarget) closeMembersModal();
});
wireSuggestions();
onLangChange(() => render());
void loadAll();
});

View File

@@ -9,7 +9,6 @@ interface User {
additional_offices?: string[];
job_title: string | null;
global_role: string;
dezernat?: string;
lang: string;
reminder_morning_time?: string;
reminder_evening_time?: string;
@@ -131,7 +130,6 @@ function userMatchesSearch(u: User): boolean {
return (
u.display_name.toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q) ||
(u.dezernat ?? "").toLowerCase().includes(q) ||
(u.job_title ?? "").toLowerCase().includes(q)
);
}
@@ -193,7 +191,6 @@ function renderRow(u: User): string {
<td><span class="akten-office-chip akten-office-${esc(u.office)}">${esc(officeLabel(u.office))}</span></td>
<td>${jobTitle ? esc(jobTitle) : "<span class=\"admin-team-muted\">—</span>"}</td>
<td>${permissionCell(u)}</td>
<td>${esc(u.dezernat ?? "")}</td>
<td>${additional.length ? additional.map((o) => esc(officeLabel(o))).join(", ") : "<span class=\"admin-team-muted\">—</span>"}</td>
<td>${esc(u.lang.toUpperCase())}</td>
<td class="akten-col-updated">${esc(fmtDate(u.created_at))}</td>
@@ -218,7 +215,6 @@ function renderEditRow(u: User): string {
<datalist id="admin-team-job-title-suggest-${esc(u.id)}">${jobTitleList}</datalist>
</td>
<td>${permissionEditor(u)}</td>
<td><input type="text" class="admin-team-input" data-field="dezernat" value="${esc(u.dezernat ?? "")}" /></td>
<td class="admin-team-multi">${additionalOfficesEditor(additional)}</td>
<td><select class="admin-team-input" data-field="lang">${langOptions(u.lang)}</select></td>
<td class="akten-col-updated">${esc(fmtDate(u.created_at))}</td>
@@ -348,12 +344,10 @@ function openDirectAddModal() {
const fb = document.getElementById("admin-da-feedback")!;
const nameField = document.getElementById("admin-da-name") as HTMLInputElement;
const jobTitleField = document.getElementById("admin-da-role") as HTMLInputElement;
const dezField = document.getElementById("admin-da-dezernat") as HTMLInputElement;
fb.style.display = "none";
nameField.value = "";
jobTitleField.value = "";
dezField.value = "";
officeSel.innerHTML = officeOptions("munich");
@@ -404,7 +398,6 @@ function initDirectAddModal() {
const officeSel = document.getElementById("admin-da-office") as HTMLSelectElement;
const jobTitleField = document.getElementById("admin-da-role") as HTMLInputElement;
const dezField = document.getElementById("admin-da-dezernat") as HTMLInputElement;
const payload: Record<string, unknown> = {
email: emailSel.value,
@@ -413,7 +406,6 @@ function initDirectAddModal() {
job_title: jobTitleField.value.trim() || "Associate",
lang: "de",
};
if (dezField.value.trim()) payload.dezernat = dezField.value.trim();
const resp = await fetch("/api/admin/users", {
method: "POST",

View File

@@ -781,8 +781,8 @@ const translations: Record<Lang, Record<string, string>> = {
"onboarding.office.placeholder": "Bitte ausw\u00e4hlen",
"onboarding.job_title": "Berufsbezeichnung",
"onboarding.job_title.placeholder": "z.B. Associate, Partner, Patentanwalt",
"onboarding.dezernat": "Dezernat oder Partner",
"onboarding.dezernat.placeholder": "z.B. Dr. M\u00fcller, Team Schmidt",
"onboarding.partner_unit": "Partner Unit",
"onboarding.partner_unit.unassigned": "(noch keine Zuordnung)",
"onboarding.optional": "(optional)",
"onboarding.submit": "Profil anlegen",
"onboarding.error.display_name": "Bitte Anzeigename eingeben.",
@@ -848,33 +848,6 @@ const translations: Record<Lang, Record<string, string>> = {
"einstellungen.tab.profil": "Profil",
"einstellungen.tab.benachrichtigungen": "Benachrichtigungen",
"einstellungen.tab.caldav": "CalDAV",
"einstellungen.tab.dezernat": "Dezernat",
"dezernat.heading": "Mein Dezernat",
"dezernat.subtitle": "Dezernate sind strukturelle Partnereinheiten — getrennt von Projektteams.",
"dezernat.admin.heading": "Dezernate verwalten (Admin)",
"dezernat.admin.new": "Neues Dezernat anlegen",
"dezernat.admin.create": "Anlegen",
"dezernat.col.name": "Name",
"dezernat.col.office": "B\u00fcro",
"dezernat.col.lead": "Partner",
"dezernat.col.members": "Mitglieder",
"dezernat.field.name": "Name",
"dezernat.field.office": "B\u00fcro",
"dezernat.none": "Sie geh\u00f6ren noch keinem Dezernat an.",
"dezernat.members_label": "Mitglieder",
"dezernat.manage": "Verwalten",
"dezernat.delete": "L\u00f6schen",
"dezernat.confirm_delete": "Dezernat wirklich l\u00f6schen?",
"dezernat.error.name_required": "Name erforderlich",
"dezernat.error.user_required": "Benutzer ausw\u00e4hlen",
"dezernat.manage_heading": "Mitglieder verwalten",
"dezernat.loading": "L\u00e4dt\u2026",
"dezernat.no_members": "Noch keine Mitglieder.",
"dezernat.add_member": "Mitglied hinzuf\u00fcgen",
"dezernat.add_member.placeholder": "Name oder E-Mail",
"dezernat.add": "Hinzuf\u00fcgen",
"dezernat.remove": "Entfernen",
"dezernat.confirm_remove": "Mitglied entfernen?",
"projekte.title": "Projekte \u2014 Paliad",
"projekte.heading": "Projekte",
"projekte.subtitle": "Mandanten, Streitsachen, Patente und Verfahren \u2014 hierarchisch organisiert.",
@@ -1024,8 +997,6 @@ const translations: Record<Lang, Record<string, string>> = {
"einstellungen.profil.office": "B\u00fcro",
"einstellungen.profil.job_title": "Berufsbezeichnung",
"einstellungen.profil.job_title.placeholder": "z.B. Associate, Partner, Patentanwalt",
"einstellungen.profil.dezernat": "Dezernat oder Partner",
"einstellungen.profil.dezernat.placeholder": "z.B. Dr. M\u00fcller, Team Schmidt",
"einstellungen.profil.lang": "Sprache",
"einstellungen.profil.lang.de": "Deutsch",
"einstellungen.profil.lang.en": "English",
@@ -1221,15 +1192,20 @@ const translations: Record<Lang, Record<string, string>> = {
// Team directory (t-paliad-029)
"team.title": "Team — Paliad",
"team.heading": "Team",
"team.subtitle": "Alle Paliad-Kolleg:innen, gruppiert nach Standort oder Dezernat.",
"team.subtitle": "Alle Paliad-Kolleg:innen, gruppiert nach Standort oder Partner Unit.",
"team.search.placeholder": "Nach Name, Rolle, Büro suchen…",
"team.group.office": "Nach Standort",
"team.group.department": "Nach Dezernat",
"team.group.department": "Nach Partner Unit",
"team.group.other": "Sonstige",
"team.filter.all": "Alle",
"team.empty": "Keine Treffer.",
"team.dept.lead": "Lead",
"team.dept.unassigned": "Ohne Dezernat",
"team.dept.unassigned": "Ohne Partner Unit",
"team.partner_unit.unassigned": "Ohne Partner Unit",
"partner_unit.heading": "Meine Partner Units",
"partner_unit.subtitle": "Partner Units sind strukturelle Einheiten — getrennt von Projektteams. Mitgliedschaft wird vom Admin verwaltet.",
"partner_unit.none": "Sie sind noch keiner Partner Unit zugeordnet.",
"partner_unit.members_label": "Mitglieder",
// Admin team management (t-paliad-050)
"nav.group.admin": "Admin",
@@ -1243,14 +1219,14 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.coming_soon": "Kommt bald",
"admin.card.team.title": "Team-Verwaltung",
"admin.card.team.desc": "Benutzer:innen anlegen, bearbeiten, löschen.",
"admin.card.departments.title": "Dezernate",
"admin.card.departments.desc": "Dezernate anlegen und Mitglieder verwalten.",
"admin.card.partner_units.title": "Partner Units",
"admin.card.partner_units.desc": "Strukturelle Partnereinheiten anlegen und Mitglieder zuordnen.",
"admin.card.audit.title": "Audit-Log",
"admin.card.audit.desc": "Wer hat wann was geändert? Nachvollziehbarkeit für sicherheitsrelevante Aktionen.",
"admin.card.email_templates.title": "Email-Templates",
"admin.card.email_templates.desc": "Vorlagen für Einladungen, Erinnerungen und Layout anpassen.",
"admin.card.feature_flags.title": "Feature-Flags",
"admin.card.feature_flags.desc": "Funktionen pro Standort, Dezernat oder Rolle aktivieren.",
"admin.card.feature_flags.desc": "Funktionen pro Standort, Partner Unit oder Rolle aktivieren.",
"admin.email_templates.title": "Email-Templates — Paliad",
"admin.email_templates.heading": "Email-Templates",
"admin.email_templates.subtitle": "Vorlagen für Einladungen, Erinnerungen und das Layout-Wrapper anpassen.",
@@ -1312,7 +1288,6 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.team.col.office": "Standort",
"admin.team.col.job_title": "Berufsbezeichnung",
"admin.team.col.permission": "Berechtigung",
"admin.team.col.dezernat": "Dezernat",
"admin.team.col.additional": "Weitere Standorte",
"admin.team.col.lang": "Sprache",
"admin.team.col.created": "Angelegt",
@@ -1333,7 +1308,6 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.team.direct_add.name": "Anzeigename",
"admin.team.direct_add.office": "Standort",
"admin.team.direct_add.job_title": "Berufsbezeichnung",
"admin.team.direct_add.dezernat": "Dezernat (optional)",
"admin.team.permission.standard": "Standard",
"admin.team.permission.global_admin": "Globaler Admin",
"admin.team.permission.last_admin": "Der letzte globale Admin kann nicht degradiert werden.",
@@ -1342,9 +1316,42 @@ const translations: Record<Lang, Record<string, string>> = {
// Admin audit log (t-paliad-071)
"nav.admin.audit": "Audit-Log",
"nav.admin.partner_units": "Partner Units",
"admin.audit.title": "Audit-Log — Paliad",
"admin.audit.heading": "Audit-Log",
"admin.audit.subtitle": "Globale Zeitleiste über Projekt-, CalDAV- und Reminder-Ereignisse.",
"admin.audit.subtitle": "Globale Zeitleiste über Projekt-, CalDAV-, Reminder- und Partner-Unit-Ereignisse.",
"admin.audit.source.partner_unit_events": "Partner Units",
"admin.partner_units.title": "Partner Units — Paliad",
"admin.partner_units.heading": "Partner Units",
"admin.partner_units.subtitle": "Strukturelle Partnereinheiten verwalten und Mitglieder zuordnen.",
"admin.partner_units.loading": "Lade…",
"admin.partner_units.empty": "Noch keine Partner Units angelegt.",
"admin.partner_units.new": "Neue Partner Unit",
"admin.partner_units.new.heading": "Partner Unit anlegen",
"admin.partner_units.edit.heading": "Partner Unit bearbeiten",
"admin.partner_units.create": "Speichern",
"admin.partner_units.cancel": "Abbrechen",
"admin.partner_units.col.name": "Name",
"admin.partner_units.col.office": "Büro",
"admin.partner_units.col.lead": "Lead",
"admin.partner_units.col.members": "Mitglieder",
"admin.partner_units.col.actions": "Aktionen",
"admin.partner_units.action.members": "Mitglieder",
"admin.partner_units.action.edit": "Bearbeiten",
"admin.partner_units.action.delete": "Löschen",
"admin.partner_units.confirm_delete": "Partner Unit \"{name}\" wirklich löschen?",
"admin.partner_units.error.name_required": "Name erforderlich",
"admin.partner_units.error.user_required": "Benutzer auswählen",
"admin.partner_units.feedback.created": "Angelegt.",
"admin.partner_units.feedback.updated": "Aktualisiert.",
"admin.partner_units.feedback.deleted": "Gelöscht.",
"admin.partner_units.member.heading": "Mitglieder verwalten",
"admin.partner_units.member.empty": "Noch keine Mitglieder.",
"admin.partner_units.member.add": "Mitglied hinzufügen",
"admin.partner_units.member.add_btn": "Hinzufügen",
"admin.partner_units.member.remove": "Entfernen",
"admin.partner_units.member.confirm_remove": "Mitglied entfernen?",
"admin.partner_units.member.placeholder": "Name oder E-Mail",
"admin.audit.loading": "Lade…",
"admin.audit.empty": "Keine Ereignisse für die gewählten Filter.",
"admin.audit.loadmore": "Weitere laden",
@@ -2151,8 +2158,8 @@ const translations: Record<Lang, Record<string, string>> = {
"onboarding.office.placeholder": "Please select",
"onboarding.job_title": "Job title",
"onboarding.job_title.placeholder": "e.g. Associate, Partner, Patent Attorney",
"onboarding.dezernat": "Department / Partner",
"onboarding.dezernat.placeholder": "e.g. Dr. M\u00fcller, Team Schmidt",
"onboarding.partner_unit": "Partner Unit",
"onboarding.partner_unit.unassigned": "(not assigned yet)",
"onboarding.optional": "(optional)",
"onboarding.submit": "Create profile",
"onboarding.error.display_name": "Please enter a display name.",
@@ -2218,33 +2225,6 @@ const translations: Record<Lang, Record<string, string>> = {
"einstellungen.tab.profil": "Profile",
"einstellungen.tab.benachrichtigungen": "Notifications",
"einstellungen.tab.caldav": "CalDAV",
"einstellungen.tab.dezernat": "Dezernat",
"dezernat.heading": "My Dezernat",
"dezernat.subtitle": "Dezernate are structural partner units — separate from project teams.",
"dezernat.admin.heading": "Manage Dezernate (Admin)",
"dezernat.admin.new": "Create new Dezernat",
"dezernat.admin.create": "Create",
"dezernat.col.name": "Name",
"dezernat.col.office": "Office",
"dezernat.col.lead": "Partner",
"dezernat.col.members": "Members",
"dezernat.field.name": "Name",
"dezernat.field.office": "Office",
"dezernat.none": "You are not a member of any Dezernat yet.",
"dezernat.members_label": "members",
"dezernat.manage": "Manage",
"dezernat.delete": "Delete",
"dezernat.confirm_delete": "Delete this Dezernat?",
"dezernat.error.name_required": "Name required",
"dezernat.error.user_required": "Select a user",
"dezernat.manage_heading": "Manage members",
"dezernat.loading": "Loading\u2026",
"dezernat.no_members": "No members yet.",
"dezernat.add_member": "Add member",
"dezernat.add_member.placeholder": "Name or email",
"dezernat.add": "Add",
"dezernat.remove": "Remove",
"dezernat.confirm_remove": "Remove member?",
"projekte.title": "Projects \u2014 Paliad",
"projekte.heading": "Projects",
"projekte.subtitle": "Clients, litigations, patents and cases \u2014 organised hierarchically.",
@@ -2394,8 +2374,6 @@ const translations: Record<Lang, Record<string, string>> = {
"einstellungen.profil.office": "Office",
"einstellungen.profil.job_title": "Job title",
"einstellungen.profil.job_title.placeholder": "e.g. Associate, Partner, Patent Attorney",
"einstellungen.profil.dezernat": "Department / Partner",
"einstellungen.profil.dezernat.placeholder": "e.g. Dr. M\u00fcller, Team Schmidt",
"einstellungen.profil.lang": "Language",
"einstellungen.profil.lang.de": "Deutsch",
"einstellungen.profil.lang.en": "English",
@@ -2591,15 +2569,20 @@ const translations: Record<Lang, Record<string, string>> = {
// Team directory (t-paliad-029)
"team.title": "Team — Paliad",
"team.heading": "Team",
"team.subtitle": "All Paliad colleagues, grouped by office or department.",
"team.subtitle": "All Paliad colleagues, grouped by office or partner unit.",
"team.search.placeholder": "Search by name, role, office…",
"team.group.office": "By office",
"team.group.department": "By department",
"team.group.department": "By partner unit",
"team.group.other": "Other",
"team.filter.all": "All",
"team.empty": "No matches.",
"team.dept.lead": "Lead",
"team.dept.unassigned": "No department",
"team.dept.unassigned": "No partner unit",
"team.partner_unit.unassigned": "No partner unit",
"partner_unit.heading": "My Partner Units",
"partner_unit.subtitle": "Partner Units are structural units — separate from project teams. Membership is admin-managed.",
"partner_unit.none": "You are not a member of any Partner Unit yet.",
"partner_unit.members_label": "members",
// Admin team management (t-paliad-050)
"nav.group.admin": "Admin",
@@ -2613,14 +2596,14 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.coming_soon": "Coming soon",
"admin.card.team.title": "Team Management",
"admin.card.team.desc": "Create, edit and delete user accounts.",
"admin.card.departments.title": "Dezernate",
"admin.card.departments.desc": "Create departments and manage their members.",
"admin.card.partner_units.title": "Partner Units",
"admin.card.partner_units.desc": "Create structural partner units and assign members.",
"admin.card.audit.title": "Audit Log",
"admin.card.audit.desc": "Who changed what, and when. Traceability for security-relevant actions.",
"admin.card.email_templates.title": "Email Templates",
"admin.card.email_templates.desc": "Customise templates for invitations, reminders and the wrapper layout.",
"admin.card.feature_flags.title": "Feature Flags",
"admin.card.feature_flags.desc": "Enable features per office, department or role.",
"admin.card.feature_flags.desc": "Enable features per office, partner unit or role.",
"admin.email_templates.title": "Email Templates — Paliad",
"admin.email_templates.heading": "Email Templates",
"admin.email_templates.subtitle": "Customise templates for invitations, reminders, and the shared layout wrapper.",
@@ -2682,7 +2665,6 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.team.col.office": "Office",
"admin.team.col.job_title": "Job title",
"admin.team.col.permission": "Permission",
"admin.team.col.dezernat": "Department",
"admin.team.col.additional": "Additional offices",
"admin.team.col.lang": "Lang",
"admin.team.col.created": "Created",
@@ -2703,7 +2685,6 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.team.direct_add.name": "Display name",
"admin.team.direct_add.office": "Office",
"admin.team.direct_add.job_title": "Job title",
"admin.team.direct_add.dezernat": "Department (optional)",
"admin.team.permission.standard": "Standard",
"admin.team.permission.global_admin": "Global Admin",
"admin.team.permission.last_admin": "The last global admin cannot be demoted.",
@@ -2712,9 +2693,42 @@ const translations: Record<Lang, Record<string, string>> = {
// Admin audit log (t-paliad-071)
"nav.admin.audit": "Audit Log",
"nav.admin.partner_units": "Partner Units",
"admin.audit.title": "Audit Log — Paliad",
"admin.audit.heading": "Audit Log",
"admin.audit.subtitle": "Global timeline across project, CalDAV and reminder events.",
"admin.audit.subtitle": "Global timeline across project, CalDAV, reminder and partner-unit events.",
"admin.audit.source.partner_unit_events": "Partner Units",
"admin.partner_units.title": "Partner Units — Paliad",
"admin.partner_units.heading": "Partner Units",
"admin.partner_units.subtitle": "Manage structural partner units and assign members.",
"admin.partner_units.loading": "Loading…",
"admin.partner_units.empty": "No partner units yet.",
"admin.partner_units.new": "New Partner Unit",
"admin.partner_units.new.heading": "Create Partner Unit",
"admin.partner_units.edit.heading": "Edit Partner Unit",
"admin.partner_units.create": "Save",
"admin.partner_units.cancel": "Cancel",
"admin.partner_units.col.name": "Name",
"admin.partner_units.col.office": "Office",
"admin.partner_units.col.lead": "Lead",
"admin.partner_units.col.members": "Members",
"admin.partner_units.col.actions": "Actions",
"admin.partner_units.action.members": "Members",
"admin.partner_units.action.edit": "Edit",
"admin.partner_units.action.delete": "Delete",
"admin.partner_units.confirm_delete": "Really delete partner unit \"{name}\"?",
"admin.partner_units.error.name_required": "Name required",
"admin.partner_units.error.user_required": "Select a user",
"admin.partner_units.feedback.created": "Created.",
"admin.partner_units.feedback.updated": "Updated.",
"admin.partner_units.feedback.deleted": "Deleted.",
"admin.partner_units.member.heading": "Manage members",
"admin.partner_units.member.empty": "No members yet.",
"admin.partner_units.member.add": "Add member",
"admin.partner_units.member.add_btn": "Add",
"admin.partner_units.member.remove": "Remove",
"admin.partner_units.member.confirm_remove": "Remove member?",
"admin.partner_units.member.placeholder": "Name or email",
"admin.audit.loading": "Loading…",
"admin.audit.empty": "No events match the selected filters.",
"admin.audit.loadmore": "Load more",

View File

@@ -6,7 +6,14 @@ interface Office {
label_en: string;
}
interface PartnerUnit {
id: string;
name: string;
office: string;
}
let offices: Office[] = [];
let partnerUnits: PartnerUnit[] = [];
async function seedDisplayName(): Promise<void> {
const input = document.getElementById("onb-display-name") as HTMLInputElement;
@@ -37,6 +44,35 @@ async function loadOffices(): Promise<void> {
renderOfficeOptions();
}
async function loadPartnerUnits(): Promise<void> {
try {
const resp = await fetch("/api/partner-units");
if (!resp.ok) return;
partnerUnits = (await resp.json()) as PartnerUnit[];
} catch {
partnerUnits = [];
}
renderPartnerUnitOptions();
}
function renderPartnerUnitOptions(): void {
const select = document.getElementById("onb-partner-unit") as HTMLSelectElement | null;
if (!select) return;
const previous = select.value;
const unassignedLabel = t("onboarding.partner_unit.unassigned") || "(noch keine Zuordnung)";
const head = `<option value="">${esc(unassignedLabel)}</option>`;
select.innerHTML =
head +
partnerUnits
.map((u) => `<option value="${esc(u.id)}">${esc(u.name)}</option>`)
.join("");
// Preserve a previous selection across language toggles (which re-render
// the head option).
if (previous && partnerUnits.some((u) => u.id === previous)) {
select.value = previous;
}
}
function renderOfficeOptions(): void {
const select = document.getElementById("onb-office") as HTMLSelectElement | null;
if (!select) return;
@@ -86,7 +122,7 @@ async function submitForm(e: Event): Promise<void> {
const displayName = (data.get("display_name") as string || "").trim();
const office = (data.get("office") as string || "").trim();
const jobTitle = (data.get("job_title") as string || "").trim();
const dezernat = (data.get("dezernat") as string || "").trim();
const partnerUnitID = (data.get("partner_unit_id") as string || "").trim();
if (!displayName) {
showMessage(t("onboarding.error.display_name"), "login-error");
@@ -106,7 +142,7 @@ async function submitForm(e: Event): Promise<void> {
office,
job_title: jobTitle,
};
if (dezernat) payload.dezernat = dezernat;
if (partnerUnitID) payload.partner_unit_id = partnerUnitID;
submitBtn.disabled = true;
try {
@@ -138,5 +174,9 @@ document.addEventListener("DOMContentLoaded", () => {
seedDisplayName();
document.getElementById("onboarding-form")!.addEventListener("submit", submitForm);
loadOffices();
onLangChange(renderOfficeOptions);
loadPartnerUnits();
onLangChange(() => {
renderOfficeOptions();
renderPartnerUnitOptions();
});
});

View File

@@ -18,7 +18,6 @@ interface Me {
office: string;
job_title: string | null;
global_role: string;
dezernat?: string;
lang: Lang;
email_preferences: Record<string, unknown>;
reminder_morning_time: string;
@@ -52,8 +51,8 @@ interface SyncLogEntry {
duration_ms?: number;
}
type TabName = "profil" | "benachrichtigungen" | "caldav" | "dezernat";
const TABS: TabName[] = ["profil", "benachrichtigungen", "caldav", "dezernat"];
type TabName = "profil" | "benachrichtigungen" | "caldav";
const TABS: TabName[] = ["profil", "benachrichtigungen", "caldav"];
const DEFAULT_TAB: TabName = "profil";
let me: Me | null = null;
@@ -116,7 +115,6 @@ function showTab(tab: TabName, pushHistory: boolean) {
if (tab === "profil") void loadProfilTab();
else if (tab === "benachrichtigungen") void loadPrefsTab();
else if (tab === "caldav") void loadCalDAVTab();
else if (tab === "dezernat") void loadDezernatTab();
}
}
@@ -139,6 +137,7 @@ async function loadProfilTab() {
await Promise.all([fetchMe(), fetchOffices()]);
renderOfficeOptions();
fillProfilForm();
void renderMyPartnerUnits();
loading.style.display = "none";
form.style.display = "";
@@ -191,7 +190,6 @@ function fillProfilForm() {
(document.getElementById("profil-display-name") as HTMLInputElement).value = me.display_name;
(document.getElementById("profil-office") as HTMLSelectElement).value = me.office;
(document.getElementById("profil-role") as HTMLInputElement).value = me.job_title ?? "";
(document.getElementById("profil-dezernat") as HTMLInputElement).value = me.dezernat ?? "";
(document.getElementById("profil-lang") as HTMLSelectElement).value = me.lang || "de";
}
@@ -204,7 +202,6 @@ async function saveProfil(ev: Event) {
const displayName = (document.getElementById("profil-display-name") as HTMLInputElement).value.trim();
const office = (document.getElementById("profil-office") as HTMLSelectElement).value;
const jobTitle = (document.getElementById("profil-role") as HTMLInputElement).value.trim();
const dezernat = (document.getElementById("profil-dezernat") as HTMLInputElement).value.trim();
const lang = (document.getElementById("profil-lang") as HTMLSelectElement).value as Lang;
if (!displayName) {
@@ -227,7 +224,6 @@ async function saveProfil(ev: Event) {
display_name: displayName,
office,
job_title: jobTitle,
dezernat,
lang,
};
@@ -282,7 +278,7 @@ function fillEscalationContactOptions() {
// the server enforces it via a CHECK constraint. Sort by display_name
// for a stable order (then email as tiebreaker for users without a
// display name).
const candidates = dezernatUserOptions
const candidates = userOptions
.filter((u) => u.id !== me!.id)
.slice()
.sort((a, b) => {
@@ -361,7 +357,7 @@ async function savePrefs(ev: Event) {
);
// "" = clear back to the global_admins fallback; UUID = explicit
// escalation contact. The server's UpdateProfileInput uses *string with
// empty-string-as-clear semantics (matches the Dezernat field).
// empty-string-as-clear semantics for nullable references.
const escalationContact = (document.getElementById("prefs-escalation-contact") as HTMLSelectElement).value;
if (!morning || !evening) {
@@ -600,341 +596,69 @@ async function deleteCalDAVConfig() {
}
}
// --- Dezernat tab -----------------------------------------------------------
// --- "Meine Partner Units" card on the profile tab -------------------------
//
// Read-only summary of the current user's structural memberships. Membership
// writes are admin-driven via /admin/partner-units.
interface Department {
interface PartnerUnit {
id: string;
name: string;
lead_user_id?: string | null;
office: string;
}
interface DezernatMember {
user_id: string;
display_name: string;
email: string;
office: string;
job_title: string | null;
}
let allDezernate: Dezernat[] = [];
async function loadDezernatTab(): Promise<void> {
// Ensure we know who the caller is (role gate for admin controls).
if (!me) {
try {
const resp = await fetch("/api/me");
if (resp.ok) me = (await resp.json()) as Me;
} catch {
// ignore
}
}
const adminSection = document.getElementById("dezernat-admin-section")!;
if (me && me.global_role === "global_admin") {
adminSection.style.display = "";
}
try {
const resp = await fetch("/api/departments");
if (!resp.ok) return;
allDezernate = (await resp.json()) as Dezernat[];
} catch {
return;
}
await renderMyDezernat();
renderDezernatAdminTable();
// Populate the office select in the admin create form with the same
// office list used on the profile tab. If offices haven't been loaded
// yet (admin arrives here first), fetch them now.
if (!offices.length) {
try {
const resp = await fetch("/api/offices");
if (resp.ok) offices = (await resp.json()) as Office[];
} catch {
// ignore
}
}
const officeSel = document.getElementById("dezernat-new-office") as HTMLSelectElement | null;
if (officeSel) {
officeSel.innerHTML = offices
.map((o) => {
const label = getLang() === "de" ? o.label_de : o.label_en;
return `<option value="${esc(o.key)}">${esc(label)}</option>`;
})
.join("");
}
const form = document.getElementById("dezernat-new-form") as HTMLFormElement | null;
if (form) form.addEventListener("submit", submitNewDezernat);
}
async function renderMyDezernat(): Promise<void> {
const container = document.getElementById("dezernat-my")!;
if (!me) {
container.innerHTML = `<p class="form-hint">${esc(t("dezernat.login_required") || "Login required")}</p>`;
return;
}
// Find dezernate that have the user as member via the members endpoint
// for each — cheap for typical firm size (<20 dezernate).
const myDezernate: Dezernat[] = [];
for (const d of allDezernate) {
try {
const resp = await fetch(`/api/departments/${encodeURIComponent(d.id)}/members`);
if (!resp.ok) continue;
const members = (await resp.json()) as DezernatMember[];
if (members.some((m) => m.user_id === me!.id)) {
myDezernate.push(d);
}
} catch {
// ignore
}
}
if (!myDezernate.length) {
container.innerHTML = `<p class="form-hint">${esc(t("dezernat.none") || "Sie geh\u00f6ren noch keinem Dezernat an.")}</p>`;
return;
}
const parts: string[] = [];
for (const d of myDezernate) {
const resp = await fetch(`/api/departments/${encodeURIComponent(d.id)}/members`);
const members = resp.ok ? ((await resp.json()) as DezernatMember[]) : [];
const leadName = members.find((m) => m.user_id === d.lead_user_id)?.display_name;
parts.push(`<div class="dezernat-card">
<h3>${esc(d.name)}</h3>
<p class="form-hint">${esc(t("office." + d.office) || d.office)}${leadName ? ` &middot; <strong>${esc(leadName)}</strong>` : ""}</p>
<p class="form-hint"><strong>${members.length}</strong> ${esc(t("dezernat.members_label") || "Mitglieder")}</p>
<ul class="dezernat-member-list">
${members
.map((m) => `<li>${esc(m.display_name)} <span class="form-hint">(${esc(m.email)})</span></li>`)
.join("")}
</ul>
</div>`);
}
container.innerHTML = parts.join("");
}
interface UserOption {
id: string;
display_name: string;
email: string;
}
let dezernatUserOptions: UserOption[] = [];
let userOptions: UserOption[] = [];
async function loadUserOptions(): Promise<void> {
if (dezernatUserOptions.length) return;
if (userOptions.length) return;
try {
const resp = await fetch("/api/users");
if (resp.ok) dezernatUserOptions = (await resp.json()) as UserOption[];
if (resp.ok) userOptions = (await resp.json()) as UserOption[];
} catch {
// ignore
}
}
function renderDezernatAdminTable(): void {
const body = document.getElementById("dezernat-admin-body");
if (!body) return;
body.innerHTML = allDezernate
.flatMap((d) => [
`<tr class="dezernat-row" data-id="${esc(d.id)}">
<td>${esc(d.name)}</td>
<td>${esc(t("office." + d.office) || d.office)}</td>
<td>${d.lead_user_id ? esc(d.lead_user_id) : "&mdash;"}</td>
<td><button type="button" class="btn-ghost dezernat-manage-btn" data-id="${esc(d.id)}">${esc(t("dezernat.manage") || "Verwalten")}</button></td>
<td><button type="button" class="btn-danger dezernat-delete-btn" data-id="${esc(d.id)}">${esc(t("dezernat.delete") || "L\u00f6schen")}</button></td>
</tr>`,
`<tr class="dezernat-manage-row" id="dezernat-manage-${esc(d.id)}" style="display:none">
<td colspan="5">
<div class="dezernat-manage-panel" data-dezernat="${esc(d.id)}">
<h4>${esc(t("dezernat.manage_heading") || "Mitglieder verwalten")} &mdash; ${esc(d.name)}</h4>
<ul class="dezernat-member-list" id="dezernat-members-${esc(d.id)}">
<li class="form-hint">${esc(t("dezernat.loading") || "L\u00e4dt\u2026")}</li>
</ul>
<form class="dezernat-add-form" data-dezernat="${esc(d.id)}" autocomplete="off">
<div class="form-field">
<label>${esc(t("dezernat.add_member") || "Mitglied hinzuf\u00fcgen")}</label>
<input type="text" class="dezernat-user-input" placeholder="${escAttr(t("dezernat.add_member.placeholder") || "Name oder E-Mail")}" />
<input type="hidden" class="dezernat-user-id" />
<div class="akten-collab-suggestions dezernat-user-suggestions"></div>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary btn-cta-lime btn-small">${esc(t("dezernat.add") || "Hinzuf\u00fcgen")}</button>
</div>
<p class="form-msg dezernat-add-msg"></p>
</form>
</div>
</td>
</tr>`,
])
.join("");
body.querySelectorAll<HTMLButtonElement>(".dezernat-delete-btn").forEach((btn) => {
btn.addEventListener("click", async () => {
const id = btn.dataset.id!;
if (!window.confirm(t("dezernat.confirm_delete") || "Dezernat wirklich l\u00f6schen?")) return;
const resp = await fetch(`/api/departments/${encodeURIComponent(id)}`, { method: "DELETE" });
if (resp.ok) {
allDezernate = allDezernate.filter((d) => d.id !== id);
renderDezernatAdminTable();
void renderMyDezernat();
}
});
});
body.querySelectorAll<HTMLButtonElement>(".dezernat-manage-btn").forEach((btn) => {
btn.addEventListener("click", async () => {
const id = btn.dataset.id!;
const row = document.getElementById(`dezernat-manage-${id}`);
if (!row) return;
if (row.style.display === "none") {
row.style.display = "";
await loadUserOptions();
await loadAndRenderDezernatMembers(id);
wireDezernatAddForm(id);
} else {
row.style.display = "none";
}
});
});
}
async function loadAndRenderDezernatMembers(dezernatID: string): Promise<void> {
const ul = document.getElementById(`dezernat-members-${dezernatID}`);
if (!ul) return;
async function renderMyPartnerUnits(): Promise<void> {
const container = document.getElementById("partner-unit-my");
if (!container || !me) return;
try {
const resp = await fetch(`/api/departments/${encodeURIComponent(dezernatID)}/members`);
const resp = await fetch("/api/partner-units?include=members");
if (!resp.ok) return;
const members = (await resp.json()) as DezernatMember[];
if (!members.length) {
ul.innerHTML = `<li class="form-hint">${esc(t("dezernat.no_members") || "Noch keine Mitglieder.")}</li>`;
type PUWithMembers = PartnerUnit & {
lead_display_name?: string;
members: { user_id: string; display_name: string; email: string }[];
};
const all = (await resp.json()) as PUWithMembers[];
const mine = all.filter((u) => u.members.some((m) => m.user_id === me!.id));
if (!mine.length) {
container.innerHTML = `<p class="form-hint">${esc(t("partner_unit.none") || "Sie sind noch keiner Partner Unit zugeordnet.")}</p>`;
return;
}
ul.innerHTML = members
container.innerHTML = mine
.map(
(m) => `<li class="dezernat-member-item">
<span>${esc(m.display_name || m.email)} <span class="form-hint">(${esc(m.email)})</span></span>
<button type="button" class="btn-ghost btn-small dezernat-remove-btn" data-dezernat="${esc(dezernatID)}" data-user="${esc(m.user_id)}">${esc(t("dezernat.remove") || "Entfernen")}</button>
</li>`,
)
.join("");
ul.querySelectorAll<HTMLButtonElement>(".dezernat-remove-btn").forEach((btn) => {
btn.addEventListener("click", async () => {
const did = btn.dataset.dezernat!;
const uid = btn.dataset.user!;
if (!window.confirm(t("dezernat.confirm_remove") || "Mitglied entfernen?")) return;
const r = await fetch(
`/api/departments/${encodeURIComponent(did)}/members/${encodeURIComponent(uid)}`,
{ method: "DELETE" },
);
if (r.ok) {
await loadAndRenderDezernatMembers(did);
void renderMyDezernat();
}
});
});
} catch {
// ignore
}
}
function wireDezernatAddForm(dezernatID: string): void {
const form = document.querySelector<HTMLFormElement>(
`.dezernat-add-form[data-dezernat="${dezernatID}"]`,
);
if (!form || form.dataset.wired === "1") return;
form.dataset.wired = "1";
const input = form.querySelector<HTMLInputElement>(".dezernat-user-input")!;
const hidden = form.querySelector<HTMLInputElement>(".dezernat-user-id")!;
const sugs = form.querySelector<HTMLDivElement>(".dezernat-user-suggestions")!;
const msg = form.querySelector<HTMLParagraphElement>(".dezernat-add-msg")!;
input.addEventListener("input", () => {
const q = input.value.trim().toLowerCase();
hidden.value = "";
if (!q) {
sugs.innerHTML = "";
return;
}
const matches = dezernatUserOptions
.filter((u) => (u.display_name + " " + u.email).toLowerCase().includes(q))
.slice(0, 8);
sugs.innerHTML = matches
.map(
(u) => `<div class="akten-collab-suggestion" data-id="${esc(u.id)}" data-label="${escAttr(u.display_name || u.email)}">
<strong>${esc(u.display_name || u.email)}</strong>
<span class="form-hint">${esc(u.email)}</span>
(u) => `<div class="partner-unit-card">
<h3>${esc(u.name)}</h3>
<p class="form-hint">${esc(t("office." + u.office) || u.office)}${u.lead_display_name ? ` &middot; <strong>${esc(u.lead_display_name)}</strong>` : ""}</p>
<p class="form-hint"><strong>${u.members.length}</strong> ${esc(t("partner_unit.members_label") || "Mitglieder")}</p>
<ul class="partner-unit-member-list">
${u.members
.map((m) => `<li>${esc(m.display_name)} <span class="form-hint">(${esc(m.email)})</span></li>`)
.join("")}
</ul>
</div>`,
)
.join("");
sugs.querySelectorAll<HTMLDivElement>(".akten-collab-suggestion").forEach((el) => {
el.addEventListener("click", () => {
hidden.value = el.dataset.id!;
input.value = el.dataset.label!;
sugs.innerHTML = "";
});
});
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
msg.textContent = "";
if (!hidden.value) {
msg.textContent = t("dezernat.error.user_required") || "Benutzer ausw\u00e4hlen";
return;
}
const resp = await fetch(`/api/departments/${encodeURIComponent(dezernatID)}/members`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: hidden.value }),
});
if (!resp.ok) {
const b = await resp.json().catch(() => ({ error: "unknown" }));
msg.textContent = b.error || "failed";
return;
}
input.value = "";
hidden.value = "";
sugs.innerHTML = "";
await loadAndRenderDezernatMembers(dezernatID);
void renderMyDezernat();
});
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
async function submitNewDezernat(e: Event): Promise<void> {
e.preventDefault();
const msg = document.getElementById("dezernat-new-msg") as HTMLParagraphElement;
const name = (document.getElementById("dezernat-new-name") as HTMLInputElement).value.trim();
const office = (document.getElementById("dezernat-new-office") as HTMLSelectElement).value;
msg.textContent = "";
if (!name) {
msg.textContent = t("dezernat.error.name_required") || "Name required";
return;
}
try {
const resp = await fetch("/api/departments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, office }),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: "unknown" }));
msg.textContent = body.error || "failed";
return;
}
const d = (await resp.json()) as Dezernat;
allDezernate.push(d);
(document.getElementById("dezernat-new-name") as HTMLInputElement).value = "";
renderDezernatAdminTable();
} catch (err) {
msg.textContent = String(err);
} catch {
// ignore — leave previous render
}
}

View File

@@ -8,7 +8,6 @@ interface User {
office: string;
additional_offices?: string[];
role: string;
dezernat?: string;
}
interface DepartmentMember {
@@ -60,7 +59,7 @@ function initials(name: string): string {
async function loadAll() {
const [usersResp, deptsResp] = await Promise.all([
fetch("/api/users"),
fetch("/api/departments?include=members"),
fetch("/api/partner-units?include=members"),
]);
if (usersResp.ok) users = (await usersResp.json()) as User[];
if (deptsResp.ok) departments = (await deptsResp.json()) as Department[];
@@ -103,7 +102,6 @@ function userMatchesSearch(u: User): boolean {
u.role,
u.office,
officeLabel(u.office),
u.dezernat ?? "",
...(u.additional_offices ?? []).map(officeLabel),
]
.join(" ")
@@ -122,7 +120,6 @@ function memberAsUser(m: DepartmentMember): User | undefined {
}
function renderUserCard(u: User): string {
const dept = u.dezernat ?? "";
const additional = (u.additional_offices ?? []).filter((o) => o !== u.office);
return `
<article class="team-card">
@@ -133,7 +130,6 @@ function renderUserCard(u: User): string {
<div class="team-card-meta">
<span class="team-office-badge">${ICON_PIN}<span>${esc(officeLabel(u.office))}</span></span>
${additional.length ? `<span class="team-office-extra">+ ${additional.map((o) => esc(officeLabel(o))).join(", ")}</span>` : ""}
${dept ? `<span class="team-dept-tag">${esc(dept)}</span>` : ""}
</div>
<a class="team-card-email" href="mailto:${esc(u.email)}">${ICON_MAIL}<span>${esc(u.email)}</span></a>
</div>
@@ -199,33 +195,20 @@ function renderGroupByDepartment(filtered: User[]): string {
</section>`);
}
// Users not in any department (fall back: free-text dezernat field, or none).
// Users not in any partner unit get a single "ohne Partner Unit" bucket so
// they're still discoverable in the directory.
const inAnyDept = new Set<string>();
for (const d of departments) for (const m of d.members) inAnyDept.add(m.user_id);
const looseGroups = new Map<string, User[]>();
for (const u of filtered) {
if (inAnyDept.has(u.id)) continue;
const key = (u.dezernat ?? "").trim() || "__none__";
if (!looseGroups.has(key)) looseGroups.set(key, []);
looseGroups.get(key)!.push(u);
}
const looseKeys = Array.from(looseGroups.keys()).sort((a, b) => {
if (a === "__none__") return 1;
if (b === "__none__") return -1;
return a.localeCompare(b);
});
for (const key of looseKeys) {
const list = looseGroups.get(key)!;
const heading = key === "__none__"
? (t("team.dept.unassigned") || "Ohne Dezernat")
: key;
const orphans = filtered.filter((u) => !inAnyDept.has(u.id));
if (orphans.length) {
const heading = t("team.partner_unit.unassigned") || "Ohne Partner Unit";
sections.push(`
<section class="team-group team-group-loose">
<header class="team-group-header">
<h2>${esc(heading)}</h2>
<span class="team-group-count">${list.length}</span>
<span class="team-group-count">${orphans.length}</span>
</header>
<div class="team-grid">${list.map(renderUserCard).join("")}</div>
<div class="team-grid">${orphans.map(renderUserCard).join("")}</div>
</section>`);
}

View File

@@ -143,6 +143,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
<div className="sidebar-group-label" data-i18n="nav.group.admin">Admin</div>
{navItem("/admin", ICON_SHIELD, "nav.admin.bereich", "Admin-Bereich", currentPath)}
{navItem("/admin/team", ICON_USERS, "nav.admin.team", "Team-Verwaltung", currentPath)}
{navItem("/admin/partner-units", ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)}
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
</div>
</nav>

View File

@@ -71,18 +71,17 @@ export function renderOnboarding(): string {
<option value="Sekretariat"></option>
</datalist>
<label htmlFor="onb-dezernat" className="login-label" data-i18n="onboarding.dezernat">
Dezernat / Partner <span className="login-label-optional" data-i18n="onboarding.optional">(optional)</span>
<label htmlFor="onb-partner-unit" className="login-label" data-i18n="onboarding.partner_unit">
Partner Unit <span className="login-label-optional" data-i18n="onboarding.optional">(optional)</span>
</label>
<input
type="text"
id="onb-dezernat"
name="dezernat"
autocomplete="off"
<select
id="onb-partner-unit"
name="partner_unit_id"
className="login-input"
data-i18n-placeholder="onboarding.dezernat.placeholder"
placeholder="z.B. Dr. M&uuml;ller, Team Schmidt"
/>
>
{/* Options populated from /api/partner-units at init. */}
<option value="" data-i18n="onboarding.partner_unit.unassigned">(noch keine Zuordnung)</option>
</select>
<button type="submit" className="login-button" data-i18n="onboarding.submit">Profil anlegen</button>
</form>

View File

@@ -39,7 +39,6 @@ export function renderSettings(): string {
<a className="akten-tab" data-tab="profil" href="?tab=profil" data-i18n="einstellungen.tab.profil">Profil</a>
<a className="akten-tab" data-tab="benachrichtigungen" href="?tab=benachrichtigungen" data-i18n="einstellungen.tab.benachrichtigungen">Benachrichtigungen</a>
<a className="akten-tab" data-tab="caldav" href="?tab=caldav" data-i18n="einstellungen.tab.caldav">CalDAV</a>
<a className="akten-tab" data-tab="dezernat" href="?tab=dezernat" data-i18n="einstellungen.tab.dezernat">Dezernat</a>
</nav>
{/* --- Profil tab ---------------------------------------- */}
@@ -100,19 +99,6 @@ export function renderSettings(): string {
</datalist>
</div>
<div className="form-field">
<label htmlFor="profil-dezernat" data-i18n="einstellungen.profil.dezernat">
Dezernat oder Partner <span className="login-label-optional" data-i18n="einstellungen.optional">(optional)</span>
</label>
<input
type="text"
id="profil-dezernat"
autocomplete="off"
data-i18n-placeholder="einstellungen.profil.dezernat.placeholder"
placeholder="z.B. Dr. M&uuml;ller, Team Schmidt"
/>
</div>
<div className="form-field">
<label htmlFor="profil-lang" data-i18n="einstellungen.profil.lang">Sprache</label>
<select id="profil-lang">
@@ -130,6 +116,18 @@ export function renderSettings(): string {
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="einstellungen.save">Speichern</button>
</div>
</form>
{/* "Meine Partner Units" — read-only summary of the current
user's structural memberships. Membership writes are
admin-driven via /admin/partner-units; this surface just
shows what the user is in today. */}
<div className="caldav-info-card partner-unit-my-card" id="profil-partner-units">
<h2 data-i18n="partner_unit.heading">Meine Partner Units</h2>
<p className="form-hint" data-i18n="partner_unit.subtitle">
Partner Units sind strukturelle Einheiten &mdash; getrennt von Projektteams. Mitgliedschaft wird vom Admin verwaltet.
</p>
<div id="partner-unit-my" className="partner-unit-my" />
</div>
</section>
{/* --- Benachrichtigungen tab ---------------------------- */}
@@ -343,54 +341,6 @@ export function renderSettings(): string {
</div>
</section>
{/* --- Dezernat tab ------------------------------------- */}
<section className="akten-tab-panel" id="tab-dezernat" style="display:none">
<div className="caldav-info-card">
<h2 data-i18n="dezernat.heading">Mein Dezernat</h2>
<p className="form-hint" data-i18n="dezernat.subtitle">
Dezernate sind strukturelle Partnereinheiten &mdash; separat von projektspezifischen Teams.
</p>
<div id="dezernat-my" className="dezernat-my" />
</div>
<div className="caldav-info-card" id="dezernat-admin-section" style="display:none">
<h2 data-i18n="dezernat.admin.heading">Dezernate verwalten (Admin)</h2>
<div className="akten-table-wrap">
<table className="akten-table" id="dezernat-admin-table">
<thead>
<tr>
<th data-i18n="dezernat.col.name">Name</th>
<th data-i18n="dezernat.col.office">B&uuml;ro</th>
<th data-i18n="dezernat.col.lead">Partner</th>
<th data-i18n="dezernat.col.members">Mitglieder</th>
<th />
</tr>
</thead>
<tbody id="dezernat-admin-body" />
</table>
</div>
<details className="dezernat-new-wrap">
<summary className="btn-ghost" data-i18n="dezernat.admin.new">Neues Dezernat anlegen</summary>
<form id="dezernat-new-form" className="akten-form">
<div className="form-field-row">
<div className="form-field">
<label htmlFor="dezernat-new-name" data-i18n="dezernat.field.name">Name</label>
<input type="text" id="dezernat-new-name" required />
</div>
<div className="form-field">
<label htmlFor="dezernat-new-office" data-i18n="dezernat.field.office">B&uuml;ro</label>
<select id="dezernat-new-office" required />
</div>
</div>
<p className="form-msg" id="dezernat-new-msg" />
<div className="form-actions">
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="dezernat.admin.create">Anlegen</button>
</div>
</form>
</details>
</div>
</section>
</div>
</section>
</main>

View File

@@ -28,7 +28,7 @@ export function renderTeam(): string {
<div>
<h1 data-i18n="team.heading">Team</h1>
<p className="tool-subtitle" data-i18n="team.subtitle">
Alle Paliad-Kolleg:innen, gruppiert nach Standort oder Dezernat.
Alle Paliad-Kolleg:innen, gruppiert nach Standort oder Partner Unit.
</p>
</div>
</div>
@@ -55,7 +55,7 @@ export function renderTeam(): string {
Nach Standort
</button>
<button className="filter-pill" data-group="department" type="button" data-i18n="team.group.department">
Nach Dezernat
Nach Partner Unit
</button>
</div>
</div>

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
@@ -65,7 +65,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,
@@ -180,15 +180,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)
@@ -302,6 +302,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 /admin/email-templates", adminGate(users, gateOnboarded(handleAdminEmailTemplatesPage)))
protected.HandleFunc("GET /admin/email-templates/{key}", adminGate(users, gateOnboarded(handleAdminEmailTemplatesEditPage)))
protected.HandleFunc("GET /api/admin/users", adminGate(users, handleAdminListUsers))

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