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).
34 KiB
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
- Naming:
partner_uniteverywhere (snake_case for DB/JSON,PartnerUnitfor Go types,partner-unit(s)for kebab-URLs). - Rename API too:
paliad.departments→paliad.partner_units,paliad.department_members→paliad.partner_unit_members,/api/departments/*→/api/partner-units/*. Full consistency. - Settings admin section: remove (don't duplicate).
- Audit emit: yes, in this PR.
- Free-text column drop: yes — drop
users.dezernatentirely 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:
- The user-facing concept "Dezernate" renamed to "Partner units" everywhere.
- The placeholder card on
/admin("Dezernate / Kommt bald") replaced with a real/admin/departmentsmanagement 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
dezernatinput with a<select>populated fromGET /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 apartner_unit_idto the create-user payload, and the user-creation flow inserts a row inpaliad.partner_unit_membersif 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-unitspage; 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 structuredpartner_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-unitsis 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 ininternal/handlers/redirects.gomirroring the existing/dezernateredirect.
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/usersendpointloadUserOptions()already calls). Each member row has a remove button with confirmation. Optional "make lead" pin if the member is a lead candidate (job_titlecontaining "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, mirrorsadmin-team.tsxshape: container + tool-header + filters + table.frontend/src/client/admin-partner-units.ts— fetch, render, edit, delete, member CRUD. Reuses the office list endpoint,/api/usersfor the typeahead,t()for i18n, sidebar + bottom-nav init.frontend/build.tsentry —renderAdminPartnerUnits→dist/admin-partner-units.html,dist/assets/admin-partner-units.js.internal/handlers/admin_partner_units.go—handleAdminPartnerUnitsPage(one-liner ServeFile, mirrorshandleAdminTeamPage).
4.3 Files to edit
internal/handlers/handlers.go— registerGET /admin/partner-unitsinside the existingif svc != nil && svc.Users != nilblock, gated byauth.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, withhref="/admin/partner-units", remove theadmin-card-soonclass and the "Kommt bald" badge. Icon staysICON_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— replacedezernat.*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_idset 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 underadmin.partner_units.*.team.dept.unassigned("Ohne Dezernat") — replaced withteam.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.subtitleadmin.partner_units.col.name,.col.office,.col.lead,.col.members,.col.actionsadmin.partner_units.new,admin.partner_units.new.heading,admin.partner_units.create,admin.partner_units.cancel,admin.partner_units.delete,admin.partner_units.confirm_deleteadmin.partner_units.member.add,.member.remove,.member.confirm_remove,.member.placeholder,.member.empty,.member.loadingadmin.partner_units.error.name_required,.error.user_requiredadmin.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.subtitledezernat.none→partner_unit.nonedezernat.members_label→partner_unit.members_label
Update copy (no key change):
admin.card.departments.title→ "Partner Units" (was "Dezernate") — and the key itself renames toadmin.card.partner_units.titlefor consistencyadmin.card.departments.desc→ "Partner Units anlegen und Mitglieder verwalten." → key renames toadmin.card.partner_units.descadmin.card.feature_flags.desc— German body mentions "Dezernat", rewrite as "Partner Unit"team.subtitleandteam.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:
- Push code (with migration 026 in
embed.FS) to main. - Dokploy auto-deploys; the new binary's
migrate.Up()runs migration 026 atomically before binding the listener. - Verify
/api/partner-unitsreturns the renamed table contents;/admin/partner-unitsrenders;paliad.users.dezernatno 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 usesdezernattest name — rename todepartmentto match)cd frontend && bun run build
9.2 Manual smoke (paliad.de as tester@hlc.de)
- Log in as global_admin.
- Visit
/admin— confirm "Partner Units" card under "Verfügbar" (not "Geplant"), no "Kommt bald" badge. - Click → land on
/admin/partner-units— confirm table renders existing units (with names migration 019 + the second seed produced). - Create a new unit "Test Unit Cronus" (Munich, no lead). Confirm a
createdrow appears inpaliad.partner_unit_events. - Edit name → "Test Unit Cronus (renamed)". Confirm
updatedevent row. - Add tester@hlc.de as member; confirm
member_addedevent; chip appears on/teamdirectory grouping. - Remove member. Confirm
member_removedevent. - Delete the test unit; confirm row disappears from the table; confirm
deletedevent row exists withpartner_unit_id IS NULL(orphaned by ON DELETE SET NULL). - Visit
/settings— confirm tab list isProfil | Benachrichtigungen | CalDAV(no Dezernat tab). Profile tab has "Meine Partner Units" card; no free-text dezernat input. - Visit
/team— confirm grouping by Partner Unit (not Dezernat) and "Ohne Partner Unit" fallback label. - Visit
/admin/team— confirm Dezernat column is gone; add-form has no Dezernat input. - Visit
/onboarding(with a fresh auth.users-only account) — confirm the free-text Dezernat input is replaced with a partner-unit<select>. - Sign out, sign back in as a non-admin — confirm
/admin/partner-unitsreturns 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=dezernat404 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-unitspost-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.sqlinternal/db/migrations/026_rename_to_partner_units.down.sqlinternal/services/partner_unit_service.go(renamed fromdepartment_service.goviagit mvso blame survives — content rewritten for type + SQL renames + audit emit)internal/handlers/partner_units.go(renamed fromdepartments.go)internal/handlers/admin_partner_units.go— page-serve handlerfrontend/src/admin-partner-units.tsxfrontend/src/client/admin-partner-units.ts
Edit (Go)
internal/services/services.go— wirePartnerUnit *PartnerUnitService.internal/services/user_service.go— dropDezernatfield 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— dropUser.Dezernat; renameDepartment→PartnerUnit,DepartmentMember→PartnerUnitMember.internal/handlers/admin_users.go— drop dezernat from admin create/update payloads.internal/handlers/handlers.go— re-register/api/partner-units/*, addGET /admin/partner-units, dropdbSvc.departmentfield, adddbSvc.partnerUnit.internal/handlers/redirects.go— drop the/dezernate→/departmentsentry (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=dezernatgracefully.
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 isonboarding.partner_unit.frontend/src/client/onboarding.ts— submitpartner_unit_idinstead ofdezernat. The user-create endpoint now accepts an optionalpartner_unit_idand 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— dropdezernatfromTabNameandTABS, 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 structuredpartner_unit_members.frontend/src/components/Sidebar.tsx— add/admin/partner-unitsnav item withnav.admin.partner_unitslabel.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— newrenderAdminPartnerUnitsentry.
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.