design(t-paliad-070): incorporate m's answers — full partner_unit rename

m's 21:44 answers expanded the rename scope and resolved all 5 open Qs:
- Naming: partner_unit everywhere (not 'department')
- API + URL rename too: paliad.departments → paliad.partner_units,
  /api/partner-units, /admin/partner-units
- Settings admin section: removed
- Audit emit: in this PR (paliad.partner_unit_events table)
- users.dezernat: dropped entirely (not renamed)

Migration 026 now does: best-effort second seed of department_members from
dezernat free-text → DROP COLUMN → rename departments + department_members
tables to partner_units + partner_unit_members → rename junction column to
partner_unit_id → rename constraints/indexes/policies → create
partner_unit_events audit table with RLS.

Single tx, exception-trapped renames for idempotency on freshly-provisioned
DBs.

Onboarding form: free-text input replaced with a partner-unit <select> that
inserts a membership row in the user-create tx. Settings profile loses the
free-text field.

PR strategy: still single PR, ~2200 lines net (heavier than v1 due to
structured-side rename + audit plumbing).
This commit is contained in:
m
2026-04-29 21:50:27 +02:00
parent 1a89b0c490
commit 633ce5a9fe

View File

@@ -3,7 +3,17 @@
**Task:** t-paliad-070
**Inventor:** cronus (mai/cronus/partner-units-rename worktree)
**Date:** 2026-04-29
**Status:** DESIGN awaiting m greenlight before any rename or coding
**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.
---
@@ -50,100 +60,98 @@ Counts (`grep -l`):
---
## 3. Naming decisions
## 3. Naming decisions (per m)
### 3.1 User-facing label (cross-language)
m chose **"Partner unit" / "Partner units"** — same English phrase in DE and EN.
Rationale: the German "Dezernat" has no clean English equivalent, and m wants
firm-agnostic branding. "Partner unit" reads naturally in both languages and is
unambiguous (a unit led by a partner).
**"Partner unit" / "Partner units"** — same English phrase in DE and EN.
Capitalised loanword in DE strings ("Partner Unit anlegen", "Partner Units
verwalten").
DE i18n strings keep grammatical agreement: "Partner unit anlegen",
"Partner units verwalten", etc. — borrowed singular/plural treated as a
loanword (capitalised in DE). EN is identical text.
### 3.2 Internal names — full rename to `partner_unit`
### 3.2 Internal Go / TS / DB names
Per m's "lets fix departments even in api?!", everything Department-shaped
on the structured side renames too. End state:
The structured side is already English and stays:
| 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-*` |
- `paliad.departments`, `paliad.department_members` (tables)
- `models.Department`, `models.DepartmentMember` (Go types)
- `services.DepartmentService` (service)
- `/api/departments/*` (routes)
- `frontend/src/.../department*.ts(x)` (none today, but new files use this)
### 3.3 The `users.dezernat` free-text column
The German leftover is `paliad.users.dezernat` and its mirrors throughout the
code. Two options:
**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`.
**Option A (m's stated preference): rename to `department`**
`paliad.users.dezernat``paliad.users.department`. Go field
`User.Dezernat``User.Department`. JSON `dezernat``department`. i18n keys
`einstellungen.profil.dezernat``einstellungen.profil.department`. Etc.
This means **the onboarding form stops asking for a free-text Dezernat/
Partner field** and **the settings profile tab stops surfacing it**.
- ✓ Matches the brief literally.
- ✓ Conventional pattern (User has `department` free-text column + a
`departments` registry table — many apps look like this).
- ✗ Two co-existing representations of the same concept under closely
related names: `users.department` (singular text on user) vs
`paliad.departments` (registry). Future readers may assume `users.department`
is a FK.
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.
**Option B: rename to `partner_unit_label`** — explicit "this is the
free-text label, distinct from structured department membership".
### 3.4 What does NOT rename
- ✓ No ambiguity.
- ✗ Verbose. Diverges from m's brief without strong reason.
- ✗ Bakes the "free-text legacy" framing permanently into the name even
though Phase 2 drops it.
- `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.
**Option C: don't rename the column. Just rename user-facing labels.**
### 3.5 URL strategy
- ✓ Minimal diff. Phase 2 (drop column) would have made the rename
wasted work.
- ✗ Violates the global "system language is English" rule that m has been
enforcing for months.
- ✗ Leaves a German identifier visibly present in `/api/me` JSON
(current shape: `{"dezernat": "..."}`).
**Recommendation: Option A.** It matches the brief, follows the established
English-naming convention, and the table/column overlap is conventional
enough to be self-evident.
A code comment on `models.User.Department` should call out that this is the
**free-text label**, separate from `department_members` rows, and that Phase 2
plans to drop it once onboarding can pick from the structured registry.
### 3.3 What does NOT rename
- `paliad.departments` table — already English.
- `paliad.department_members` table — already English.
- `models.Department` / `models.DepartmentMember` — already English.
- `services.DepartmentService` — already English.
- `/api/departments/*` URL paths — already English.
- The 4 i18n keys `dezernat.*` that today drive the settings admin section
→ these keys move to `partner_unit.*` because they're user-facing labels
exposed via translation, not internal identifiers. Fine to rename.
- CSS classes `.dezernat-*``.partner-unit-*`. Fine to rename.
Result: post-rename, **the only place "department" appears in code is the
rename-from-Dezernat side**: `User.Department` field, `users.department`
column, `/settings?tab=department` URL, `data-tab="department"`. The
existing `paliad.departments` table / `Department` struct keep their names —
they're orthogonal.
### 3.4 URL strategy
- `/settings?tab=dezernat``/settings?tab=department`. Add a redirect
(or accept both query values) for in-flight bookmarks — but settings tabs
aren't deep-linked from outside, so a one-shot rename without a redirect
is acceptable.
- `/admin/departments` is the new admin page. No legacy URL to redirect from
(the placeholder card was a no-op).
- `/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/departments` page
## 4. The new `/admin/partner-units` page
### 4.1 Surface
@@ -194,44 +202,52 @@ Click ▾ on a row to expand:
### 4.2 Files to create
- `frontend/src/admin-departments.tsx` — page render, mirrors
- `frontend/src/admin-partner-units.tsx` — page render, mirrors
`admin-team.tsx` shape: container + tool-header + filters + table.
- `frontend/src/client/admin-departments.ts` — fetch, render, edit,
- `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 — `renderAdminDepartments``dist/admin-departments.html`,
`dist/assets/admin-departments.js`.
- `internal/handlers/admin_departments.go` `handleAdminDepartmentsPage`
(one-liner ServeFile, mirrors `handleAdminTeamPage`).
- `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/departments`
- `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(handleAdminDepartmentsPage))`.
The `/api/departments/*` routes are already registered and already
admin-gated for write operations (service-level `requireAdmin`) — no change.
- `frontend/src/admin.tsx` — flip the Departments card from the
"Geplant" section to the "Verfügbar" section, with `href="/admin/departments"`,
remove the `admin-card-soon` class and the "Kommt bald" badge. Update icon
to `ICON_BUILDING` (already imported).
`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/departments", ICON_BUILDING,
"nav.admin.departments", "Partner Units", currentPath)`.
- `frontend/src/client/i18n.ts` — add new keys (see §6).
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**, rename
tab + heading + body to "Partner Units".
- "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/departments`. Reduces duplication and matches the "admin tools live
under /admin" convention established by t-paliad-050.
`/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 ~150 lines of
admin CRUD that move to the new page. The "my partner units" read-only view
stays (~50 lines).
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`).
---
@@ -262,118 +278,233 @@ pruning permission complexity. Add later when there's a clear request.
## 6. i18n strings
**Drop:**
- `dezernat.*` (28 keys × 2 langs = 56 strings — settings admin CRUD ones move
to the admin page, others get renamed)
- `einstellungen.profil.dezernat` + `einstellungen.profil.dezernat.placeholder`
- `einstellungen.tab.dezernat`
- `onboarding.dezernat` + `onboarding.dezernat.placeholder`
- `team.dept.unassigned` (German "Ohne Dezernat" → "Ohne Partner Unit")
- `admin.team.col.dezernat`
- `admin.team.direct_add.dezernat`
- `admin.card.departments.title` ("Dezernate") → "Partner Units"
- `admin.card.departments.desc` ("Dezernate anlegen…") → "Partner Units anlegen…"
- `admin.card.feature_flags.desc` (mentions "Dezernat")
**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.departments` = "Partner Units"
- `admin.departments.title`, `admin.departments.heading`, `admin.departments.subtitle`
- `admin.departments.col.name`, `.col.office`, `.col.lead`, `.col.members`, `.col.actions`
- `admin.departments.new`, `admin.departments.new.heading`, `admin.departments.create`,
`admin.departments.cancel`, `admin.departments.delete`, `admin.departments.confirm_delete`
- `admin.departments.member.add`, `.member.remove`, `.member.confirm_remove`,
- `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.departments.error.name_required`, `.error.user_required`
- `admin.departments.empty` ("Noch keine Partner Units angelegt.")
- `admin.partner_units.error.name_required`, `.error.user_required`
- `admin.partner_units.empty` ("Noch keine Partner Units angelegt.")
**Rename (settings — read-only "my partner units" view):**
**Rename (settings profile-tab "my partner units" card):**
- `dezernat.heading``partner_unit.heading` ("Meine Partner Units")
- `dezernat.subtitle``partner_unit.subtitle` ("Partner Units sind strukturelle
Einheiten — getrennt von Projektteams.")
- `dezernat.subtitle``partner_unit.subtitle`
- `dezernat.none``partner_unit.none`
- `dezernat.members_label``partner_unit.members_label`
DE strings use "Partner Unit" / "Partner Units" verbatim (capitalised loanword).
EN uses the same.
**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 `users.dezernat` → `users.department`
### 7.1 Migration 026: rename tables + drop free-text column
Migration file pair:
- `internal/db/migrations/026_rename_user_dezernat_to_department.up.sql`
- `internal/db/migrations/026_rename_user_dezernat_to_department.down.sql`
One migration, ordered statements, all wrapped in a single tx by migrate.v4:
Up:
```sql
ALTER TABLE paliad.users RENAME COLUMN dezernat TO department;
-- 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:
```sql
ALTER TABLE paliad.users RENAME COLUMN department TO dezernat;
```
**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`.
**Locks:** `ALTER TABLE ... RENAME COLUMN` takes a brief AccessExclusive lock
on the table. Live `paliad.users` has 31 rows. Lock duration = milliseconds.
No concurrent-write hazard.
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.
**No RLS / policy / function rebuilds needed:** verified via
`grep -rn "dezernat" internal/db/migrations/021_*.sql` — the function bodies
in migration 021 (`can_see_project`, `note_is_visible`, etc.) do not reference
the column. Migration 015 (column add) and 019 (one-shot seed) reference it
but those are historical, not active code paths.
### 7.2 Code cutover
**Migration 019 (seed_departments_from_user_text):** stays in the migrations
dir for history. It already ran once on prod. The function it INSERTed from
referenced `u.dezernat`; that's a one-shot, not a recurring trigger. No
function rewrite needed.
### 7.2 Code cutover with the migration
Order of operations (adapted from t-paliad-051 lessons learned):
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 on boot calls `migrate.Up()` which
applies migration 026 atomically before binding the HTTP listener. No
manual `psql -1 < file.sql` needed for a single-statement migration that
migrate.v4 wraps in a transaction.
3. Verify `/api/me` returns `{"department": "..."}` (no longer `dezernat`).
4. Verify `/admin/departments` renders.
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.
The brief 500 window from t-paliad-051 (binary expects new schema, prod has
old one) does not apply here because:
- Migration 026 is a single statement (no partial-apply trap).
- The deploy and migration run in the same boot sequence (migrations gate the
listener bind).
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 cleanly. No data loss — the column is renamed in
both directions.
`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
## 8. Audit logging — emitted in this PR
t-paliad-071 is in flight for the audit log surface. To avoid coupling the
two designs, this PR does **not** emit audit events directly. Instead:
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).
- Document the events that would be useful for t-paliad-071 to wire:
- `partner_unit.created``{id, name, office, lead_user_id, by}`
- `partner_unit.updated``{id, fields_changed, before, after, by}`
- `partner_unit.deleted``{id, name, by}`
- `partner_unit.member.added``{department_id, user_id, by}`
- `partner_unit.member.removed``{department_id, user_id, by}`
- Coordinate with whoever picks up t-paliad-071 — they can add the emit calls
inside `DepartmentService` once the audit interface lands. Touch points are
tagged in the service today via the `requireAdmin` checks (each service
method that calls `requireAdmin` is an audit-eligible action).
### Events emitted
If t-paliad-071 ships first, this task adds the emit calls. If this ships
first, t-paliad-071 adds them.
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.
---
@@ -392,27 +523,35 @@ first, t-paliad-071 adds them.
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/departments` — confirm table renders existing
units.
4. Create a new unit "Test Unit Cronus" (Munich, no lead).
5. Edit name → "Test Unit Cronus (renamed)".
6. Add tester@hlc.de as member; verify chip appears on `/admin/team` and
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.
8. Delete the test unit; confirm row disappears from the table.
9. Visit `/settings?tab=department` — confirm "Mein Partner Unit" tab
renders (read-only), admin CRUD section is gone.
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. Sign out, sign back in as a non-admin (an existing seeded associate) —
confirm `/admin/departments` returns 302 to `/dashboard?forbidden=admin`,
sidebar admin section is hidden.
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, the existing test-pack pattern from
t-paliad-050 covers admin-team. A near-identical pattern for
admin-departments: navigate, create, edit, delete, screenshot.
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.
---
@@ -422,17 +561,21 @@ admin-departments: navigate, create, edit, delete, screenshot.
Reasoning:
- The rename touches the same files as the new admin page (admin.tsx,
i18n.ts, settings.tsx, admin-team.tsx, sidebar.tsx). Splitting forces
ugly rebases.
- The migration is one statement. No risk of partial apply.
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: ~1500 lines net (heavy on i18n key renames + new
admin-departments client). No new dependencies.
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.
---
@@ -441,86 +584,95 @@ admin-departments client). No new dependencies.
- **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 event emission** — coordinate with t-paliad-071, do not couple.
- **Dropping the duplicate-state debt** (`users.department` free-text vs
`department_members` structured rows) — Phase 2. Ideal endgame:
- Onboarding form replaces the free-text input with a select that lists
existing partner units, plus an "Other / create new" option.
- Existing `users.department` values are migrated into `department_members`
rows by the admin via the new `/admin/departments` page (same UX as today's
settings admin section, just on a dedicated page).
- Once `users.department` is empty for all rows, drop the column.
- **Settings profile tab keeping a free-text field** — for now it stays,
binding to the renamed `users.department`. Phase 2 removes it.
- **t-paliad-070 already has a chip on `/admin/team`** showing the user's
free-text department field — this stays and reads from `users.department`
post-rename. Removing the chip is Phase 2.
- **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 for m
## 12. Open questions — RESOLVED 21:44 Wed 29.04. (m's answers)
1. **Naming**: confirm Option A (`paliad.users.dezernat`
`paliad.users.department`)? Or push for Option B (`partner_unit_label`)
to make the "free-text legacy" semantics explicit?
2. **URL**: keep `/admin/departments`, or use `/admin/partner-units` to match
the user-facing label even though the underlying entity is `Department`?
Recommendation: `/admin/departments` — matches the API path and the table
name, consistent with how admin URLs follow internal entity names elsewhere
(`/admin/team` follows `paliad.users` but the page header says "Team").
3. **Settings admin section removal**: confirm that the existing CRUD inside
`/settings?tab=dezernat` should be **removed** (not duplicated) once the
new admin page lands?
4. **Audit coupling**: defer audit emit to t-paliad-071, or add now?
Recommendation: defer — keeps PR focused and t-paliad-071 owns the event
shape.
5. **Phase 2 ticket filing**: file a new task t-paliad-NNN for the
"drop free-text duplicate" cleanup now, or wait?
| # | 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_user_dezernat_to_department.up.sql`
- `internal/db/migrations/026_rename_user_dezernat_to_department.down.sql`
- `frontend/src/admin-departments.tsx`
- `frontend/src/client/admin-departments.ts`
- `internal/handlers/admin_departments.go`
- `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/user_service.go``Dezernat` field → `Department`,
SQL column refs `dezernat``department`, struct tags, JSON tags.
- `internal/services/user_service_test.go` — variable names + assertions.
- `internal/services/department_service.go` — comments only ("Dezernat" →
"partner unit" in doc comments). The SQL already targets `paliad.departments`
/ `paliad.department_members`.
- `internal/models/models.go``User.Dezernat` field → `User.Department`,
comment update.
- `internal/handlers/admin_users.go` — wherever `Dezernat` is in the input/
output struct.
- `internal/handlers/handlers.go` — register `GET /admin/departments`.
- `internal/handlers/redirects.go`already redirects `/dezernate`
`/departments`; nothing to change.
- `internal/handlers/appointments_pages.go` — settings tab id resolver
map: `"dezernat"``"department"`.
- `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 Departments card from "Geplant" to
- `frontend/src/admin.tsx` — flip the Partner-Units card from "Geplant" to
"Verfügbar".
- `frontend/src/admin-team.tsx`column header + add-form label.
- `frontend/src/client/admin-team.ts` — field name + display.
- `frontend/src/onboarding.tsx` + `frontend/src/client/onboarding.ts`
field name, label, JSON.
- `frontend/src/settings.tsx` — tab id, profile field, remove admin section.
- `frontend/src/client/settings.ts` — tab type, field name, remove
~150 lines of admin CRUD.
- `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
the free-text fallback grouping.
- `frontend/src/components/Sidebar.tsx` — add `/admin/departments` nav item.
- `frontend/src/client/i18n.ts` — string renames + new admin page keys.
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 `renderAdminDepartments` entry.
- `frontend/build.ts` — new `renderAdminPartnerUnits` entry.
---