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