Files
paliad/docs/design-partner-units-2026-04-29.md
m 633ce5a9fe 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).
2026-04-29 21:50:27 +02:00

34 KiB
Raw Permalink Blame History

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.departmentspaliad.partner_units, paliad.department_memberspaliad.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 (handleListDepartmentshandleListPartnerUnits).
  • 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 — renderAdminPartnerUnitsdist/admin-partner-units.html, dist/assets/admin-partner-units.js.
  • internal/handlers/admin_partner_units.gohandleAdminPartnerUnitsPage (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.headingpartner_unit.heading ("Meine Partner Units")
  • dezernat.subtitlepartner_unit.subtitle
  • dezernat.nonepartner_unit.none
  • dezernat.members_labelpartner_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:

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

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 DepartmentPartnerUnit, DepartmentMemberPartnerUnitMember.
  • 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.