Merge: t-paliad-070 partner units rename + /admin/partner-units
This commit is contained in:
@@ -84,7 +84,7 @@ func main() {
|
||||
users := services.NewUserService(pool)
|
||||
projectSvc := services.NewProjectService(pool, users)
|
||||
teamSvc := services.NewTeamService(pool, projectSvc)
|
||||
departmentSvc := services.NewDepartmentService(pool, users)
|
||||
partnerUnitSvc := services.NewPartnerUnitService(pool, users)
|
||||
rules := services.NewDeadlineRuleService(pool)
|
||||
|
||||
// Phase F: optional CalDAV cipher. If CALDAV_ENCRYPTION_KEY is unset
|
||||
@@ -121,7 +121,7 @@ func main() {
|
||||
svcBundle = &handlers.Services{
|
||||
Project: projectSvc,
|
||||
Team: teamSvc,
|
||||
Department: departmentSvc,
|
||||
PartnerUnit: partnerUnitSvc,
|
||||
Party: services.NewPartyService(pool, projectSvc),
|
||||
Deadline: services.NewDeadlineService(pool, projectSvc),
|
||||
Appointment: appointmentSvc,
|
||||
|
||||
687
docs/design-partner-units-2026-04-29.md
Normal file
687
docs/design-partner-units-2026-04-29.md
Normal file
@@ -0,0 +1,687 @@
|
||||
# Partner Units — rename + admin management UI
|
||||
|
||||
**Task:** t-paliad-070
|
||||
**Inventor:** cronus (mai/cronus/partner-units-rename worktree)
|
||||
**Date:** 2026-04-29
|
||||
**Status:** DESIGN v2 — m answered the open questions 21:44 Wed 29.04. Revised doc below; awaiting head greenlight before coder shift.
|
||||
|
||||
## m's answers (21:44 Wed 29.04.) summarised
|
||||
|
||||
1. **Naming**: `partner_unit` everywhere (snake_case for DB/JSON, `PartnerUnit` for Go types, `partner-unit(s)` for kebab-URLs).
|
||||
2. **Rename API too**: `paliad.departments` → `paliad.partner_units`, `paliad.department_members` → `paliad.partner_unit_members`, `/api/departments/*` → `/api/partner-units/*`. Full consistency.
|
||||
3. **Settings admin section**: remove (don't duplicate).
|
||||
4. **Audit emit**: yes, in this PR.
|
||||
5. **Free-text column drop**: yes — drop `users.dezernat` entirely instead of renaming. Phase 2 collapses into Phase 1.
|
||||
|
||||
This dramatically expands the rename scope but produces a single coherent end-state (no transitional German names anywhere, no duplicate-state debt). Single PR is now even more important — splitting would leave the code in an unrunnable mid-rename state for any non-trivial duration.
|
||||
|
||||
---
|
||||
|
||||
## 1. The two concerns
|
||||
|
||||
m wants:
|
||||
|
||||
1. The user-facing concept "Dezernate" renamed to **"Partner units"** everywhere.
|
||||
2. The placeholder card on `/admin` ("Dezernate / Kommt bald") replaced with a real
|
||||
`/admin/departments` management surface.
|
||||
|
||||
These two concerns share the same code surface, so this design treats them as one PR.
|
||||
|
||||
---
|
||||
|
||||
## 2. Live-state inventory (2026-04-29)
|
||||
|
||||
What already exists:
|
||||
|
||||
| Layer | Status |
|
||||
|---|---|
|
||||
| **DB tables** | `paliad.departments` and `paliad.department_members` already English (renamed in migrations 020 + 024). RLS policies, FKs, indexes already English. |
|
||||
| **DB column** | `paliad.users.dezernat` — German legacy, free-text `text` column added in migration 015. |
|
||||
| **Go service** | `internal/services/department_service.go` — full CRUD + member management. Admin-gated via `requireAdmin` (`global_role='global_admin'`). |
|
||||
| **Go handlers** | `internal/handlers/departments.go` — 8 routes registered under `/api/departments/*`. |
|
||||
| **Frontend admin CRUD** | Already shipped — but **inside `/settings?tab=dezernat`**, not on a dedicated admin page. Visible only to global_admin (gated client-side via `me.global_role`). |
|
||||
| **Admin landing** | `/admin` shows a "Geplant / Kommt bald" Dezernate card pointing nowhere. |
|
||||
| **Admin team page** | `/admin/team` has a "Dezernat" free-text column and edit input bound to `paliad.users.dezernat`. |
|
||||
| **Onboarding** | Asks for "Dezernat / Partner" as free text, persists to `users.dezernat`. |
|
||||
| **Settings profile tab** | Asks for "Dezernat oder Partner" free text. |
|
||||
| **Team directory** | `/team` groups colleagues by `users.dezernat` free-text fallback when `paliad.departments` membership is missing. |
|
||||
|
||||
The duplicate-state debt is real: the same concept lives in two places —
|
||||
the structured `paliad.departments` registry (admin-managed) and the free-text
|
||||
`paliad.users.dezernat` column (user-typed). Migration 019 backfilled the
|
||||
former from the latter, but they have been drifting apart since. **Resolving
|
||||
that drift is out of scope for this task** — flagged as Phase 2.
|
||||
|
||||
Counts (`grep -l`):
|
||||
- 7 Go files mention `dezernat` / `Dezernat`
|
||||
- 10 frontend files (`.ts` / `.tsx`)
|
||||
- 2 SQL migrations (015 = column add, 019 = seed function)
|
||||
- ~80 i18n strings
|
||||
|
||||
---
|
||||
|
||||
## 3. Naming decisions (per m)
|
||||
|
||||
### 3.1 User-facing label (cross-language)
|
||||
|
||||
**"Partner unit" / "Partner units"** — same English phrase in DE and EN.
|
||||
Capitalised loanword in DE strings ("Partner Unit anlegen", "Partner Units
|
||||
verwalten").
|
||||
|
||||
### 3.2 Internal names — full rename to `partner_unit`
|
||||
|
||||
Per m's "lets fix departments even in api?!", everything Department-shaped
|
||||
on the structured side renames too. End state:
|
||||
|
||||
| Surface | Before | After |
|
||||
|---|---|---|
|
||||
| Table | `paliad.departments` | `paliad.partner_units` |
|
||||
| Junction table | `paliad.department_members` | `paliad.partner_unit_members` |
|
||||
| FK column on junction | `department_id` | `partner_unit_id` |
|
||||
| Constraint names | `departments_*`, `department_members_*` | `partner_units_*`, `partner_unit_members_*` |
|
||||
| Index names | same prefix | same prefix |
|
||||
| RLS policy names | `departments_select` etc. | `partner_units_select` etc. |
|
||||
| Go type | `models.Department` | `models.PartnerUnit` |
|
||||
| Go type | `services.DepartmentMember` | `services.PartnerUnitMember` |
|
||||
| Go type | `services.DepartmentWithMembers` | `services.PartnerUnitWithMembers` |
|
||||
| Go service | `DepartmentService` (`Service.Department`) | `PartnerUnitService` (`Service.PartnerUnit`) |
|
||||
| Go file | `internal/services/department_service.go` | `internal/services/partner_unit_service.go` |
|
||||
| Go file | `internal/handlers/departments.go` | `internal/handlers/partner_units.go` |
|
||||
| API path | `/api/departments` | `/api/partner-units` |
|
||||
| API path | `/api/departments/{id}/members` | `/api/partner-units/{id}/members` |
|
||||
| Admin URL | `/admin/departments` | `/admin/partner-units` |
|
||||
| TSX file | (new) `admin-partner-units.tsx` | same |
|
||||
| Client TS | (new) `client/admin-partner-units.ts` | same |
|
||||
| JSON keys | `department_id`, `lead_user_id`, `members[]` | `partner_unit_id`, `lead_user_id`, `members[]` |
|
||||
| i18n keys | `dezernat.*` | `partner_unit.*` |
|
||||
| CSS classes | `.dezernat-*` | `.partner-unit-*` |
|
||||
| CSS classes | (none today) | `.partner-unit-*` |
|
||||
|
||||
### 3.3 The `users.dezernat` free-text column
|
||||
|
||||
**Drop entirely** (per m's answer 5). Migration also re-runs migration 019's
|
||||
seed logic immediately before the drop, to capture any drift since 019 ran
|
||||
(users who edited their `dezernat` value via `/settings` after 019 won't
|
||||
have a corresponding `partner_unit_members` row). Idempotent
|
||||
`ON CONFLICT DO NOTHING`.
|
||||
|
||||
This means **the onboarding form stops asking for a free-text Dezernat/
|
||||
Partner field** and **the settings profile tab stops surfacing it**.
|
||||
|
||||
Replacement UX (lightweight — same PR):
|
||||
- **Onboarding**: replace the free-text `dezernat` input with a `<select>`
|
||||
populated from `GET /api/partner-units` (anonymous-readable; the public
|
||||
list is fine to expose). First option = "(noch keine zuordnung / not
|
||||
assigned yet)" maps to no membership. The select writes a
|
||||
`partner_unit_id` to the create-user payload, and the user-creation flow
|
||||
inserts a row in `paliad.partner_unit_members` if a unit was picked.
|
||||
- **Settings profile tab**: drop the field entirely. Membership management
|
||||
for non-admins lives on the existing "Mein Partner Units" read-only view
|
||||
(which stays — see §4.4). If a user wants to change their own membership,
|
||||
they ask an admin (matches the "global_admin only" model in §5).
|
||||
- **Admin-team table**: drop the "Dezernat" column and the inline-edit input
|
||||
for it. Admin sees memberships via the dedicated `/admin/partner-units`
|
||||
page; the team page already has membership chips shown (per F-44 — verify
|
||||
during smoke). Reduces double-source-of-truth confusion.
|
||||
- **Team directory grouping**: the `/team` "Nach Dezernat" group keeps its
|
||||
partner-unit grouping (now reading only from structured `partner_unit_members`),
|
||||
drops the free-text fallback bucket.
|
||||
|
||||
### 3.4 What does NOT rename
|
||||
|
||||
- `lead_user_id` (column on partner_units) — generic FK name, not
|
||||
Department-flavoured.
|
||||
- `office` (column on partner_units) — generic.
|
||||
- The 8 HTTP routes' shape — only the path changes; verbs/handler names
|
||||
rename (`handleListDepartments` → `handleListPartnerUnits`).
|
||||
- `paliad.users.office`, `paliad.users.additional_offices` — orthogonal.
|
||||
|
||||
### 3.5 URL strategy
|
||||
|
||||
- `/settings?tab=dezernat` — tab is removed (admin section moves to
|
||||
`/admin/partner-units`, "my unit" view becomes a card on the profile tab).
|
||||
No redirect needed (settings tabs aren't externally bookmarked).
|
||||
- `/admin/partner-units` is the new admin page. The old placeholder card
|
||||
was a no-op, no legacy URL to redirect from.
|
||||
- `/api/departments/*` — no legacy redirect. The API is internal to the
|
||||
bundled JS (no third-party consumer); a one-shot rename without aliases is
|
||||
safe. Should there ever be an integration in flight, add a 301 alias in
|
||||
`internal/handlers/redirects.go` mirroring the existing `/dezernate`
|
||||
redirect.
|
||||
|
||||
---
|
||||
|
||||
## 4. The new `/admin/partner-units` page
|
||||
|
||||
### 4.1 Surface
|
||||
|
||||
A dedicated admin page mirroring `/admin/team`'s aesthetic:
|
||||
|
||||
- **Page title:** "Partner Units verwalten" / "Manage Partner Units"
|
||||
- **Top bar:** count of partner units, plus a primary "Neue Partner Unit anlegen"
|
||||
button (opens an inline form panel below the table — matches admin-team's
|
||||
invite/onboard pattern).
|
||||
- **Table:** columns = Name · Office · Lead (display name + email) · Members
|
||||
count · Actions. One row per partner unit, ordered by office then name.
|
||||
- **Inline edit:** click a row → expand below for {edit name / change office /
|
||||
change lead / view+manage members}. Same disclosure pattern as the existing
|
||||
settings admin section, but lifted to a top-level admin page with breathing
|
||||
room.
|
||||
- **Member management:** typeahead "add member" input (re-uses the same
|
||||
`/api/users` endpoint `loadUserOptions()` already calls). Each member row
|
||||
has a remove button with confirmation. Optional "make lead" pin if the
|
||||
member is a lead candidate (`job_title` containing "Partner" — soft hint,
|
||||
not a gate).
|
||||
- **Delete:** danger button with confirm. Cascades memberships (FK on
|
||||
`department_members`).
|
||||
|
||||
Wireframe (ASCII):
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ Admin > Partner Units [+ Neue Partner Unit] │
|
||||
├────────────────────────────────────────────────────────────────────────┤
|
||||
│ Suche: [____________] Office: [Alle ▼] │
|
||||
├────────────────────────────────────────────────────────────────────────┤
|
||||
│ Name Office Lead Mitglieder Aktion │
|
||||
│ Team Müller München Dr. M. Müller 7 ▾ ✏ 🗑 │
|
||||
│ Team Schmidt München Dr. A. Schmidt 3 ▾ ✏ 🗑 │
|
||||
│ Team Lopez Düsseldorf J. Lopez 5 ▾ ✏ 🗑 │
|
||||
│ ... │
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Click ▾ on a row to expand:
|
||||
┌─ Mitglieder verwalten — Team Müller ────────────────────────────────────┐
|
||||
│ • Dr. M. Müller muller@hlc.de ★ Lead │
|
||||
│ • A. Bauer bauer@hlc.de [Entfernen] │
|
||||
│ • C. Kim kim@hlc.de [Entfernen] │
|
||||
│ ... │
|
||||
│ [Mitglied hinzufügen: __________________ ▼] [Hinzufügen] │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 Files to create
|
||||
|
||||
- `frontend/src/admin-partner-units.tsx` — page render, mirrors
|
||||
`admin-team.tsx` shape: container + tool-header + filters + table.
|
||||
- `frontend/src/client/admin-partner-units.ts` — fetch, render, edit,
|
||||
delete, member CRUD. Reuses the office list endpoint, `/api/users`
|
||||
for the typeahead, `t()` for i18n, sidebar + bottom-nav init.
|
||||
- `frontend/build.ts` entry — `renderAdminPartnerUnits` →
|
||||
`dist/admin-partner-units.html`, `dist/assets/admin-partner-units.js`.
|
||||
- `internal/handlers/admin_partner_units.go` —
|
||||
`handleAdminPartnerUnitsPage` (one-liner ServeFile, mirrors
|
||||
`handleAdminTeamPage`).
|
||||
|
||||
### 4.3 Files to edit
|
||||
|
||||
- `internal/handlers/handlers.go` — register `GET /admin/partner-units`
|
||||
inside the existing `if svc != nil && svc.Users != nil` block, gated by
|
||||
`auth.RequireAdminFunc(svc.Users, gateOnboarded(handleAdminPartnerUnitsPage))`.
|
||||
Re-register the 8 `/api/partner-units/*` routes (renamed from
|
||||
`/api/departments/*`).
|
||||
- `frontend/src/admin.tsx` — flip the Partner-Units card from the
|
||||
"Geplant" section to the "Verfügbar" section, with
|
||||
`href="/admin/partner-units"`, remove the `admin-card-soon` class and the
|
||||
"Kommt bald" badge. Icon stays `ICON_BUILDING`.
|
||||
- `frontend/src/components/Sidebar.tsx` — add a third admin nav item
|
||||
inside `#sidebar-admin-group`: `navItem("/admin/partner-units",
|
||||
ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)`.
|
||||
- `frontend/src/client/i18n.ts` — replace `dezernat.*` and add new keys
|
||||
(see §6).
|
||||
|
||||
### 4.4 Settings page cleanup
|
||||
|
||||
The current `/settings?tab=dezernat` has TWO panels:
|
||||
- "Mein Dezernat" (read-only, shows the user's own units) — **keep** as a
|
||||
card on the profile tab (no longer needs its own tab; the only reason it
|
||||
had one was the admin CRUD section). Renamed to "Meine Partner Units".
|
||||
- "Dezernate verwalten (Admin)" (full CRUD) — **remove**. Replaced by
|
||||
`/admin/partner-units`. Reduces duplication and matches the "admin tools
|
||||
live under /admin" convention established by t-paliad-050.
|
||||
|
||||
Net code delta in `settings.tsx` + `settings.ts`: removes ~250 lines (admin
|
||||
CRUD moves to new page; read-only "my units" card moves into profile tab as
|
||||
~30 lines). The `dezernat` profile-input field is removed entirely (no
|
||||
replacement on the profile tab; users manage membership via admin requests).
|
||||
|
||||
The settings tab list shrinks from 4 to 3: `profil`, `benachrichtigungen`,
|
||||
`caldav`. URL `/settings?tab=dezernat` 404s gracefully (the tab resolver in
|
||||
`appointments_pages.go` falls back to `profil`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Permission model
|
||||
|
||||
| Action | Today | After |
|
||||
|---|---|---|
|
||||
| List partner units (read) | any authenticated user | unchanged |
|
||||
| Get partner unit details | any authenticated user | unchanged |
|
||||
| List members | any authenticated user | unchanged |
|
||||
| Get own memberships | any authenticated user | unchanged |
|
||||
| Create | global_admin only | unchanged |
|
||||
| Update | global_admin only | unchanged |
|
||||
| Delete | global_admin only | unchanged |
|
||||
| Add member | global_admin only | unchanged |
|
||||
| Remove member | global_admin only | unchanged |
|
||||
|
||||
No permission-model changes. Service-level `requireAdmin` already enforces
|
||||
`global_role='global_admin'` for every write.
|
||||
|
||||
**Out of scope (defer):** allowing the partner unit's lead user to manage
|
||||
their own unit's members. m's brief asks "who can assign members? (global_admin
|
||||
+ the unit's lead/partner?)" — recommendation: defer. Today there are no
|
||||
real partners with `lead_user_id` set in prod, and m has been actively
|
||||
pruning permission complexity. Add later when there's a clear request.
|
||||
|
||||
---
|
||||
|
||||
## 6. i18n strings
|
||||
|
||||
**Drop entirely** (no replacement — surfaces are removed):
|
||||
- `einstellungen.profil.dezernat`, `einstellungen.profil.dezernat.placeholder`
|
||||
(settings profile field is gone)
|
||||
- `einstellungen.tab.dezernat` (tab is gone)
|
||||
- `onboarding.dezernat`, `onboarding.dezernat.placeholder` (free-text input is
|
||||
replaced with a select; new keys: `onboarding.partner_unit`,
|
||||
`onboarding.partner_unit.placeholder`, `onboarding.partner_unit.unassigned`)
|
||||
- `admin.team.col.dezernat` (column removed from admin-team)
|
||||
- `admin.team.direct_add.dezernat` (input removed from add-form)
|
||||
- `dezernat.error.user_required`, `dezernat.field.office`, `dezernat.field.name`,
|
||||
`dezernat.admin.heading`, `dezernat.admin.new`, `dezernat.admin.create` —
|
||||
these belonged to the settings admin section that moves to the new page;
|
||||
same strings re-keyed under `admin.partner_units.*`.
|
||||
- `team.dept.unassigned` ("Ohne Dezernat") — replaced with
|
||||
`team.partner_unit.unassigned` ("Ohne Partner Unit")
|
||||
|
||||
**Add (new admin page):**
|
||||
- `nav.admin.partner_units` = "Partner Units"
|
||||
- `admin.partner_units.title`, `admin.partner_units.heading`,
|
||||
`admin.partner_units.subtitle`
|
||||
- `admin.partner_units.col.name`, `.col.office`, `.col.lead`, `.col.members`,
|
||||
`.col.actions`
|
||||
- `admin.partner_units.new`, `admin.partner_units.new.heading`,
|
||||
`admin.partner_units.create`, `admin.partner_units.cancel`,
|
||||
`admin.partner_units.delete`, `admin.partner_units.confirm_delete`
|
||||
- `admin.partner_units.member.add`, `.member.remove`, `.member.confirm_remove`,
|
||||
`.member.placeholder`, `.member.empty`, `.member.loading`
|
||||
- `admin.partner_units.error.name_required`, `.error.user_required`
|
||||
- `admin.partner_units.empty` ("Noch keine Partner Units angelegt.")
|
||||
|
||||
**Rename (settings profile-tab "my partner units" card):**
|
||||
- `dezernat.heading` → `partner_unit.heading` ("Meine Partner Units")
|
||||
- `dezernat.subtitle` → `partner_unit.subtitle`
|
||||
- `dezernat.none` → `partner_unit.none`
|
||||
- `dezernat.members_label` → `partner_unit.members_label`
|
||||
|
||||
**Update copy** (no key change):
|
||||
- `admin.card.departments.title` → "Partner Units" (was "Dezernate") — and
|
||||
the key itself renames to `admin.card.partner_units.title` for consistency
|
||||
- `admin.card.departments.desc` → "Partner Units anlegen und Mitglieder
|
||||
verwalten." → key renames to `admin.card.partner_units.desc`
|
||||
- `admin.card.feature_flags.desc` — German body mentions "Dezernat",
|
||||
rewrite as "Partner Unit"
|
||||
- `team.subtitle` and `team.group.department` — German bodies say
|
||||
"Dezernat", rewrite
|
||||
|
||||
DE strings use "Partner Unit" / "Partner Units" verbatim (capitalised
|
||||
loanword). EN uses the same.
|
||||
|
||||
---
|
||||
|
||||
## 7. Migration plan
|
||||
|
||||
### 7.1 Migration 026: rename tables + drop free-text column
|
||||
|
||||
One migration, ordered statements, all wrapped in a single tx by migrate.v4:
|
||||
|
||||
```sql
|
||||
-- 026_rename_to_partner_units.up.sql
|
||||
BEGIN; -- migrate.v4 wraps automatically; explicit BEGIN for psql -1 fallback
|
||||
|
||||
-- 1. Best-effort second seed: pick up any users whose dezernat free-text
|
||||
-- drifted after migration 019 ran. Idempotent.
|
||||
INSERT INTO paliad.departments (id, name, lead_user_id, office, created_at, updated_at)
|
||||
SELECT gen_random_uuid(), btrim(u.dezernat), NULL, MIN(u.office), now(), now()
|
||||
FROM paliad.users u
|
||||
WHERE u.dezernat IS NOT NULL AND btrim(u.dezernat) <> ''
|
||||
GROUP BY btrim(u.dezernat)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO paliad.department_members (department_id, user_id, created_at)
|
||||
SELECT d.id, u.id, now()
|
||||
FROM paliad.users u
|
||||
JOIN paliad.departments d ON d.name = btrim(u.dezernat)
|
||||
WHERE u.dezernat IS NOT NULL AND btrim(u.dezernat) <> ''
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- 2. Drop the free-text column.
|
||||
ALTER TABLE paliad.users DROP COLUMN dezernat;
|
||||
|
||||
-- 3. Rename tables.
|
||||
ALTER TABLE paliad.departments RENAME TO partner_units;
|
||||
ALTER TABLE paliad.department_members RENAME TO partner_unit_members;
|
||||
|
||||
-- 4. Rename column on the junction.
|
||||
ALTER TABLE paliad.partner_unit_members RENAME COLUMN department_id TO partner_unit_id;
|
||||
|
||||
-- 5. Rename constraints (pkey/fkey/check). Postgres auto-renames the
|
||||
-- underlying index for pkey/uniq constraints.
|
||||
ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_pkey TO partner_units_pkey;
|
||||
ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_lead_user_id_fkey TO partner_units_lead_user_id_fkey;
|
||||
ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_office_check TO partner_units_office_check;
|
||||
ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_pkey TO partner_unit_members_pkey;
|
||||
ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_department_id_fkey TO partner_unit_members_partner_unit_id_fkey;
|
||||
ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_user_id_fkey TO partner_unit_members_user_id_fkey;
|
||||
|
||||
-- 6. Rename non-pkey indexes.
|
||||
ALTER INDEX paliad.departments_office_idx RENAME TO partner_units_office_idx;
|
||||
ALTER INDEX paliad.departments_lead_idx RENAME TO partner_units_lead_idx;
|
||||
ALTER INDEX paliad.department_members_user_idx RENAME TO partner_unit_members_user_idx;
|
||||
|
||||
-- 7. Rename RLS policies.
|
||||
ALTER POLICY departments_select ON paliad.partner_units RENAME TO partner_units_select;
|
||||
ALTER POLICY departments_write ON paliad.partner_units RENAME TO partner_units_write;
|
||||
ALTER POLICY department_members_select ON paliad.partner_unit_members RENAME TO partner_unit_members_select;
|
||||
ALTER POLICY department_members_write ON paliad.partner_unit_members RENAME TO partner_unit_members_write;
|
||||
|
||||
-- 8. Audit table for partner-unit events. Per §8 — minimal schema, no UI yet.
|
||||
CREATE TABLE paliad.partner_unit_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
partner_unit_id uuid NULL REFERENCES paliad.partner_units(id) ON DELETE SET NULL,
|
||||
actor_id uuid NOT NULL REFERENCES auth.users(id),
|
||||
event_type text NOT NULL CHECK (event_type IN (
|
||||
'created', 'updated', 'deleted', 'member_added', 'member_removed'
|
||||
)),
|
||||
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX partner_unit_events_unit_idx ON paliad.partner_unit_events(partner_unit_id, created_at DESC);
|
||||
CREATE INDEX partner_unit_events_actor_idx ON paliad.partner_unit_events(actor_id, created_at DESC);
|
||||
|
||||
-- RLS: any authenticated user can read (matches /api/partner-units read
|
||||
-- access); only global_admin can write (writes happen inside service
|
||||
-- methods that already gate with requireAdmin, so RLS is defence-in-depth).
|
||||
ALTER TABLE paliad.partner_unit_events ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY partner_unit_events_select ON paliad.partner_unit_events
|
||||
FOR SELECT USING (auth.uid() IS NOT NULL);
|
||||
CREATE POLICY partner_unit_events_write ON paliad.partner_unit_events
|
||||
FOR INSERT WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
**Down migration** is the symmetric reverse of steps 8 → 1, with one caveat:
|
||||
step 1 (the seed) cannot be perfectly reversed. The `paliad.users.dezernat`
|
||||
column is recreated with NULLs; original values are lost. This is acceptable
|
||||
because the data is preserved structurally in `partner_unit_members`.
|
||||
|
||||
If a true rollback is ever needed and per-user free-text values must be
|
||||
restored, an admin script can re-seed from `partner_unit_members`:
|
||||
`UPDATE paliad.users u SET dezernat = (SELECT pu.name FROM ... LIMIT 1)`.
|
||||
Documented in the down migration as a comment, not auto-run.
|
||||
|
||||
### 7.2 Code cutover
|
||||
|
||||
migrate.v4 wraps the up migration in a single tx. If anything in the rename
|
||||
chain fails (e.g. a constraint name mismatch on a freshly-provisioned DB
|
||||
that didn't go through 020+024), the entire migration aborts and the dirty
|
||||
flag is set. To minimise that risk, the constraint/index/policy rename
|
||||
statements are wrapped in `DO $$ ... EXCEPTION WHEN undefined_object THEN
|
||||
NULL END $$` blocks (same idempotency pattern migration 024 used).
|
||||
|
||||
Order of operations:
|
||||
1. Push code (with migration 026 in `embed.FS`) to main.
|
||||
2. Dokploy auto-deploys; the new binary's `migrate.Up()` runs migration 026
|
||||
atomically before binding the listener.
|
||||
3. Verify `/api/partner-units` returns the renamed table contents; `/admin/partner-units`
|
||||
renders; `paliad.users.dezernat` no longer exists.
|
||||
|
||||
Migration risk is moderate (multi-statement, table rename + column drop +
|
||||
new audit table) but contained: every statement is idempotent or
|
||||
exception-trapped, and it all runs inside one tx so a partial apply is
|
||||
impossible.
|
||||
|
||||
### 7.3 Rollback
|
||||
|
||||
`migrate down 1` reverses everything. The data loss noted above (free-text
|
||||
column re-created with NULLs) is acceptable per §3.3 — structured
|
||||
membership rows are the source of truth post-rename.
|
||||
|
||||
---
|
||||
|
||||
## 8. Audit logging — emitted in this PR
|
||||
|
||||
Per m's "audit emit? sure, why not", this PR ships audit emission. To stay
|
||||
small and not pre-empt t-paliad-071's eventual cross-cutting audit design,
|
||||
the emission goes to a dedicated `paliad.partner_unit_events` table (see
|
||||
migration 026 step 8) rather than a global audit table. t-paliad-071 can
|
||||
later subsume it (UNION ALL into a global view, or migrate rows into a
|
||||
unified table).
|
||||
|
||||
### Events emitted
|
||||
|
||||
Each event is INSERTed in the same tx as the originating mutation.
|
||||
|
||||
| Event | When | Payload |
|
||||
|---|---|---|
|
||||
| `created` | `Create` succeeds | `{name, office, lead_user_id}` |
|
||||
| `updated` | `Update` writes ≥1 column | `{before: {…}, after: {…}, fields: ["name","office",…]}` |
|
||||
| `deleted` | `Delete` succeeds (before cascade) | `{name, office, lead_user_id, member_count}` |
|
||||
| `member_added` | `AddMember` actually inserts | `{user_id, user_email, user_display_name}` |
|
||||
| `member_removed` | `RemoveMember` actually deletes ≥1 row | `{user_id}` |
|
||||
|
||||
`actor_id` is the `callerID` already passed to every service method.
|
||||
`partner_unit_id` is set to NULL on `deleted` after the unit row is gone
|
||||
(FK has `ON DELETE SET NULL`), so the historical event row survives.
|
||||
|
||||
### No new endpoint in this PR
|
||||
|
||||
The `partner_unit_events` table is queryable via `/api/partner-units/{id}/events`
|
||||
in a follow-up — keeping that endpoint out of scope here aligns with the
|
||||
"ship audit emit, defer audit UX" framing. If t-paliad-071 wants to expose
|
||||
events through a unified audit surface, that's the right home.
|
||||
|
||||
### Service-side wiring
|
||||
|
||||
A single helper inside `PartnerUnitService`:
|
||||
|
||||
```go
|
||||
func (s *PartnerUnitService) emit(ctx context.Context, tx *sqlx.Tx,
|
||||
actorID uuid.UUID, unitID *uuid.UUID, eventType string, payload any) error {
|
||||
p, err := json.Marshal(payload)
|
||||
if err != nil { return err }
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.partner_unit_events
|
||||
(partner_unit_id, actor_id, event_type, payload)
|
||||
VALUES ($1, $2, $3, $4)`, unitID, actorID, eventType, p)
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
Each mutating method opens a tx (currently they don't — they use
|
||||
`db.ExecContext` directly), runs the mutation + emit, commits. Adds ~5
|
||||
lines per method × 5 methods = ~25 lines of audit plumbing.
|
||||
|
||||
---
|
||||
|
||||
## 9. Test plan
|
||||
|
||||
### 9.1 Build gauntlet
|
||||
|
||||
- `go build ./...`
|
||||
- `go vet ./...`
|
||||
- `go test ./...` (existing user_service_test.go uses `dezernat` test name —
|
||||
rename to `department` to match)
|
||||
- `cd frontend && bun run build`
|
||||
|
||||
### 9.2 Manual smoke (paliad.de as `tester@hlc.de`)
|
||||
|
||||
1. Log in as global_admin.
|
||||
2. Visit `/admin` — confirm "Partner Units" card under "Verfügbar" (not
|
||||
"Geplant"), no "Kommt bald" badge.
|
||||
3. Click → land on `/admin/partner-units` — confirm table renders existing
|
||||
units (with names migration 019 + the second seed produced).
|
||||
4. Create a new unit "Test Unit Cronus" (Munich, no lead). Confirm a
|
||||
`created` row appears in `paliad.partner_unit_events`.
|
||||
5. Edit name → "Test Unit Cronus (renamed)". Confirm `updated` event row.
|
||||
6. Add tester@hlc.de as member; confirm `member_added` event; chip appears
|
||||
on `/team` directory grouping.
|
||||
7. Remove member. Confirm `member_removed` event.
|
||||
8. Delete the test unit; confirm row disappears from the table; confirm
|
||||
`deleted` event row exists with `partner_unit_id IS NULL` (orphaned by
|
||||
ON DELETE SET NULL).
|
||||
9. Visit `/settings` — confirm tab list is `Profil | Benachrichtigungen |
|
||||
CalDAV` (no Dezernat tab). Profile tab has "Meine Partner Units" card;
|
||||
no free-text dezernat input.
|
||||
10. Visit `/team` — confirm grouping by Partner Unit (not Dezernat) and
|
||||
"Ohne Partner Unit" fallback label.
|
||||
11. Visit `/admin/team` — confirm Dezernat column is gone; add-form has no
|
||||
Dezernat input.
|
||||
12. Visit `/onboarding` (with a fresh auth.users-only account) — confirm
|
||||
the free-text Dezernat input is replaced with a partner-unit `<select>`.
|
||||
13. Sign out, sign back in as a non-admin — confirm `/admin/partner-units`
|
||||
returns 302 to `/dashboard?forbidden=admin`, sidebar admin section is
|
||||
hidden.
|
||||
|
||||
### 9.3 Playwright (optional — confirm with head)
|
||||
|
||||
If Playwright smoke is desired, mirror t-paliad-050's admin-team pattern:
|
||||
navigate, create, edit, delete, screenshot. Add an SQL assertion step that
|
||||
checks `partner_unit_events` row counts after each action.
|
||||
|
||||
---
|
||||
|
||||
## 10. PR strategy
|
||||
|
||||
**Single PR, single merge to main.**
|
||||
|
||||
Reasoning:
|
||||
- The rename touches the same files as the new admin page (admin.tsx,
|
||||
i18n.ts, settings.tsx, admin-team.tsx, sidebar.tsx, onboarding.tsx,
|
||||
team.tsx). Splitting forces ugly rebases.
|
||||
- The migration is multi-statement but single-tx — no risk of partial apply.
|
||||
- The user-facing label change is consistent only after the WHOLE diff lands.
|
||||
A split would land "internal rename" with old labels still saying "Dezernat",
|
||||
then "label change" — confusing during the gap.
|
||||
- Settings has a redirect dependency (`/settings?tab=dezernat` 404 fallback)
|
||||
that's only safe once the entire dezernat surface is gone.
|
||||
|
||||
Branch already in place: `mai/cronus/partner-units-rename`.
|
||||
|
||||
Estimated diff size: ~2200 lines net. Heavier than v1 because the
|
||||
structured-side rename (Department → PartnerUnit) cascades through
|
||||
service/handler/types/SQL/tests, plus onboarding form rebuild, plus audit
|
||||
table + emit plumbing. No new dependencies.
|
||||
|
||||
---
|
||||
|
||||
## 11. Out of scope (deferred)
|
||||
|
||||
- **Hierarchical partner units** — flat list only. Per brief.
|
||||
- **Per-partner-unit branding** (logo, colour) — defer.
|
||||
- **Non-admin permission model** (lead manages own unit's members) — defer.
|
||||
- **Audit UI** (a viewer for `partner_unit_events`) — defer to t-paliad-071.
|
||||
Emission lands here; consumption + a unified events surface lands there.
|
||||
- **Other entities' audit emission** — only partner units in this PR.
|
||||
Projects already have `project_events`; deadlines/appointments already
|
||||
emit. No global cross-entity audit yet.
|
||||
- **Onboarding "create new partner unit" inline** — the new select offers
|
||||
existing units + "(noch keine zuordnung)". A user wanting a new unit asks
|
||||
an admin or self-promotes via `/admin/partner-units` post-onboarding (only
|
||||
global_admin sees that page). Inline create-during-onboarding is a small
|
||||
follow-up if friction surfaces.
|
||||
|
||||
---
|
||||
|
||||
## 12. Open questions — RESOLVED 21:44 Wed 29.04. (m's answers)
|
||||
|
||||
| # | Question | m's answer |
|
||||
|---|---|---|
|
||||
| 1 | Column rename target | **partner_unit** (became "drop entirely" after Q5) |
|
||||
| 2 | API + URL rename | **yes — fix departments in api too** |
|
||||
| 3 | Settings admin section removal | **yes** ("you do you") |
|
||||
| 4 | Audit emit in this PR | **yes** ("sure why not") |
|
||||
| 5 | Drop free-text column | **yes** ("makes sense") |
|
||||
|
||||
No remaining open questions. Design is now greenlit pending head's gate
|
||||
review of this v2 doc.
|
||||
|
||||
---
|
||||
|
||||
## 13. Files (final)
|
||||
|
||||
### New
|
||||
- `internal/db/migrations/026_rename_to_partner_units.up.sql`
|
||||
- `internal/db/migrations/026_rename_to_partner_units.down.sql`
|
||||
- `internal/services/partner_unit_service.go` (renamed from
|
||||
`department_service.go` via `git mv` so blame survives — content rewritten
|
||||
for type + SQL renames + audit emit)
|
||||
- `internal/handlers/partner_units.go` (renamed from `departments.go`)
|
||||
- `internal/handlers/admin_partner_units.go` — page-serve handler
|
||||
- `frontend/src/admin-partner-units.tsx`
|
||||
- `frontend/src/client/admin-partner-units.ts`
|
||||
|
||||
### Edit (Go)
|
||||
- `internal/services/services.go` — wire `PartnerUnit *PartnerUnitService`.
|
||||
- `internal/services/user_service.go` — drop `Dezernat` field from struct,
|
||||
drop dezernat from SQL columns, drop dezernat from CreateUserInput +
|
||||
UpdateUserInput, etc.
|
||||
- `internal/services/user_service_test.go` — drop dezernat assertions;
|
||||
add partner_unit_id + member-row assertions if onboarding/admin-create
|
||||
paths now insert membership.
|
||||
- `internal/models/models.go` — drop `User.Dezernat`; rename
|
||||
`Department` → `PartnerUnit`, `DepartmentMember` → `PartnerUnitMember`.
|
||||
- `internal/handlers/admin_users.go` — drop dezernat from admin
|
||||
create/update payloads.
|
||||
- `internal/handlers/handlers.go` — re-register `/api/partner-units/*`,
|
||||
add `GET /admin/partner-units`, drop `dbSvc.department` field, add
|
||||
`dbSvc.partnerUnit`.
|
||||
- `internal/handlers/redirects.go` — drop the `/dezernate` → `/departments`
|
||||
entry (the path is dead post-rename) OR keep for one cycle; flag in PR
|
||||
description.
|
||||
- `internal/handlers/appointments_pages.go` — drop the `"dezernat"` /
|
||||
`"department"` tab aliases entirely (tab is gone). Default fallback handles
|
||||
`/settings?tab=dezernat` gracefully.
|
||||
|
||||
### Edit (frontend)
|
||||
- `frontend/src/admin.tsx` — flip the Partner-Units card from "Geplant" to
|
||||
"Verfügbar".
|
||||
- `frontend/src/admin-team.tsx` — drop the "Dezernat" column and the
|
||||
add-form input.
|
||||
- `frontend/src/client/admin-team.ts` — drop dezernat from payload + render.
|
||||
- `frontend/src/onboarding.tsx` — replace free-text input with `<select>`
|
||||
populated from `/api/partner-units`, plus an "(noch keine zuordnung)"
|
||||
option. Label is `onboarding.partner_unit`.
|
||||
- `frontend/src/client/onboarding.ts` — submit `partner_unit_id` instead of
|
||||
`dezernat`. The user-create endpoint now accepts an optional `partner_unit_id`
|
||||
and inserts a membership row in the same tx.
|
||||
- `frontend/src/settings.tsx` — drop the dezernat tab, drop the dezernat
|
||||
profile-field input, add a "Meine Partner Units" card on the profile tab.
|
||||
- `frontend/src/client/settings.ts` — drop `dezernat` from `TabName` and
|
||||
`TABS`, drop ~250 lines of admin CRUD + free-text plumbing, replace with
|
||||
~40 lines for the read-only "my units" card.
|
||||
- `frontend/src/team.tsx` + `frontend/src/client/team.ts` — labels and
|
||||
drop the free-text fallback bucket; group only by structured
|
||||
`partner_unit_members`.
|
||||
- `frontend/src/components/Sidebar.tsx` — add `/admin/partner-units` nav
|
||||
item with `nav.admin.partner_units` label.
|
||||
- `frontend/src/client/i18n.ts` — drop ~30 dezernat keys × 2 langs;
|
||||
add ~25 partner_unit keys × 2 langs.
|
||||
- `frontend/src/styles/global.css` — `.dezernat-*` → `.partner-unit-*`.
|
||||
- `frontend/build.ts` — new `renderAdminPartnerUnits` entry.
|
||||
|
||||
---
|
||||
|
||||
## 14. Inventor → coder gate
|
||||
|
||||
Stop after this design + a `mai report completed "DESIGN READY FOR REVIEW…"`.
|
||||
Awaiting m's go/no-go on the open questions in §12 before any code change.
|
||||
|
||||
Recommended implementer: **cronus** (this same worktree, already on
|
||||
`mai/cronus/partner-units-rename`). Mechanical rename + one new page is
|
||||
straightforward Sonnet work; the design context doesn't need to transfer
|
||||
to a fresh worker.
|
||||
@@ -32,6 +32,7 @@ import { renderTeam } from "./src/team";
|
||||
import { renderAdmin } from "./src/admin";
|
||||
import { renderAdminTeam } from "./src/admin-team";
|
||||
import { renderAdminAuditLog } from "./src/admin-audit-log";
|
||||
import { renderAdminPartnerUnits } from "./src/admin-partner-units";
|
||||
import { renderAdminEmailTemplates } from "./src/admin-email-templates";
|
||||
import { renderAdminEmailTemplatesEdit } from "./src/admin-email-templates-edit";
|
||||
import { renderNotFound } from "./src/notfound";
|
||||
@@ -111,6 +112,7 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/admin.ts"),
|
||||
join(import.meta.dir, "src/client/admin-team.ts"),
|
||||
join(import.meta.dir, "src/client/admin-audit-log.ts"),
|
||||
join(import.meta.dir, "src/client/admin-partner-units.ts"),
|
||||
join(import.meta.dir, "src/client/admin-email-templates.ts"),
|
||||
join(import.meta.dir, "src/client/admin-email-templates-edit.ts"),
|
||||
join(import.meta.dir, "src/client/notfound.ts"),
|
||||
@@ -218,6 +220,7 @@ async function build() {
|
||||
await Bun.write(join(DIST, "admin.html"), renderAdmin());
|
||||
await Bun.write(join(DIST, "admin-team.html"), renderAdminTeam());
|
||||
await Bun.write(join(DIST, "admin-audit-log.html"), renderAdminAuditLog());
|
||||
await Bun.write(join(DIST, "admin-partner-units.html"), renderAdminPartnerUnits());
|
||||
await Bun.write(join(DIST, "admin-email-templates.html"), renderAdminEmailTemplates());
|
||||
await Bun.write(join(DIST, "admin-email-templates-edit.html"), renderAdminEmailTemplatesEdit());
|
||||
await Bun.write(join(DIST, "notfound.html"), renderNotFound());
|
||||
|
||||
131
frontend/src/admin-partner-units.tsx
Normal file
131
frontend/src/admin-partner-units.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderAdminPartnerUnits(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.partner_units.title">Partner Units — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/partner-units" />
|
||||
<BottomNav currentPath="/admin/partner-units" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<h1 data-i18n="admin.partner_units.heading">Partner Units</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.partner_units.subtitle">
|
||||
Strukturelle Partnereinheiten verwalten und Mitglieder zuordnen.
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-team-actions">
|
||||
<button className="btn-primary" id="pu-new-btn" type="button" data-i18n="admin.partner_units.new">
|
||||
Neue Partner Unit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pu-feedback" className="form-msg" style="display:none" />
|
||||
|
||||
<div className="akten-table-wrap admin-team-table-wrap">
|
||||
<table className="akten-table admin-team-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.partner_units.col.name">Name</th>
|
||||
<th data-i18n="admin.partner_units.col.office">Büro</th>
|
||||
<th data-i18n="admin.partner_units.col.lead">Lead</th>
|
||||
<th data-i18n="admin.partner_units.col.members">Mitglieder</th>
|
||||
<th data-i18n="admin.partner_units.col.actions">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pu-tbody">
|
||||
<tr><td colspan={5} className="admin-team-loading" data-i18n="admin.partner_units.loading">Lade...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="akten-empty" id="pu-empty" style="display:none">
|
||||
<p data-i18n="admin.partner_units.empty">Noch keine Partner Units angelegt.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Create / edit modal — same shape for both, "id" is empty when
|
||||
creating. Office select is populated from /api/offices at init,
|
||||
lead picker from /api/users (filtered to display_name+email). */}
|
||||
<div className="modal-overlay" id="pu-edit-modal" style="display:none">
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h2 id="pu-edit-title" data-i18n="admin.partner_units.new.heading">Partner Unit anlegen</h2>
|
||||
<button className="modal-close" id="pu-edit-close" type="button" aria-label="Close">×</button>
|
||||
</div>
|
||||
<form id="pu-edit-form" className="akten-form" autocomplete="off">
|
||||
<input type="hidden" id="pu-edit-id" />
|
||||
<div className="form-field">
|
||||
<label htmlFor="pu-edit-name" data-i18n="admin.partner_units.col.name">Name</label>
|
||||
<input type="text" id="pu-edit-name" required />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="pu-edit-office" data-i18n="admin.partner_units.col.office">Büro</label>
|
||||
<select id="pu-edit-office" required />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="pu-edit-lead" data-i18n="admin.partner_units.col.lead">Lead</label>
|
||||
<select id="pu-edit-lead">
|
||||
<option value="">—</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="form-msg" id="pu-edit-msg" />
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-cancel" id="pu-edit-cancel" data-i18n="admin.partner_units.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="admin.partner_units.create">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Member-management modal — opens from the row's "Verwalten" button. */}
|
||||
<div className="modal-overlay" id="pu-members-modal" style="display:none">
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h2 id="pu-members-title">Mitglieder verwalten</h2>
|
||||
<button className="modal-close" id="pu-members-close" type="button" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div id="pu-members-body">
|
||||
<ul className="partner-unit-member-list" id="pu-members-list" />
|
||||
<form id="pu-add-form" autocomplete="off" className="akten-form">
|
||||
<div className="form-field">
|
||||
<label htmlFor="pu-add-input" data-i18n="admin.partner_units.member.add">Mitglied hinzufügen</label>
|
||||
<input type="text" id="pu-add-input" data-i18n-placeholder="admin.partner_units.member.placeholder" placeholder="Name oder E-Mail" />
|
||||
<input type="hidden" id="pu-add-user-id" />
|
||||
<div className="akten-collab-suggestions" id="pu-add-suggestions" />
|
||||
</div>
|
||||
<p className="form-msg" id="pu-add-msg" />
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn-primary btn-cta-lime btn-small" data-i18n="admin.partner_units.member.add_btn">Hinzufügen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/admin-partner-units.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -73,7 +73,6 @@ export function renderAdminTeam(): string {
|
||||
<th data-i18n="admin.team.col.office">Standort</th>
|
||||
<th data-i18n="admin.team.col.job_title">Berufsbezeichnung</th>
|
||||
<th data-i18n="admin.team.col.permission">Berechtigung</th>
|
||||
<th data-i18n="admin.team.col.dezernat">Dezernat</th>
|
||||
<th data-i18n="admin.team.col.additional">Weitere Standorte</th>
|
||||
<th data-i18n="admin.team.col.lang">Sprache</th>
|
||||
<th data-i18n="admin.team.col.created">Angelegt</th>
|
||||
@@ -81,7 +80,7 @@ export function renderAdminTeam(): string {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="admin-team-tbody">
|
||||
<tr><td colspan={10} className="admin-team-loading" data-i18n="admin.team.loading">Lade...</td></tr>
|
||||
<tr><td colspan={9} className="admin-team-loading" data-i18n="admin.team.loading">Lade...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -122,10 +121,6 @@ export function renderAdminTeam(): string {
|
||||
<label htmlFor="admin-da-role" data-i18n="admin.team.direct_add.job_title">Berufsbezeichnung</label>
|
||||
<input type="text" id="admin-da-role" name="job_title" placeholder="Associate" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="admin-da-dezernat" data-i18n="admin.team.direct_add.dezernat">Dezernat (optional)</label>
|
||||
<input type="text" id="admin-da-dezernat" name="dezernat" />
|
||||
</div>
|
||||
<div id="admin-da-feedback" className="form-msg" style="display:none" />
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-cancel" id="admin-da-cancel" data-i18n="admin.team.direct_add.cancel">Abbrechen</button>
|
||||
|
||||
@@ -19,19 +19,12 @@ interface PlannedCard {
|
||||
}
|
||||
|
||||
const PLANNED: PlannedCard[] = [
|
||||
{
|
||||
icon: ICON_BUILDING,
|
||||
i18nTitle: "admin.card.departments.title",
|
||||
i18nDesc: "admin.card.departments.desc",
|
||||
fallbackTitle: "Dezernate",
|
||||
fallbackDesc: "Dezernate anlegen und Mitglieder verwalten.",
|
||||
},
|
||||
{
|
||||
icon: ICON_FLAG,
|
||||
i18nTitle: "admin.card.feature_flags.title",
|
||||
i18nDesc: "admin.card.feature_flags.desc",
|
||||
fallbackTitle: "Feature-Flags",
|
||||
fallbackDesc: "Funktionen pro Standort, Dezernat oder Rolle aktivieren.",
|
||||
fallbackDesc: "Funktionen pro Standort, Partner Unit oder Rolle aktivieren.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -69,6 +62,11 @@ export function renderAdmin(): string {
|
||||
<h2 data-i18n="admin.card.team.title">Team-Verwaltung</h2>
|
||||
<p data-i18n="admin.card.team.desc">Benutzer:innen anlegen, bearbeiten, löschen.</p>
|
||||
</a>
|
||||
<a href="/admin/partner-units" className="card card-link">
|
||||
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_BUILDING }} />
|
||||
<h2 data-i18n="admin.card.partner_units.title">Partner Units</h2>
|
||||
<p data-i18n="admin.card.partner_units.desc">Strukturelle Partnereinheiten anlegen und Mitglieder zuordnen.</p>
|
||||
</a>
|
||||
<a href="/admin/audit-log" className="card card-link">
|
||||
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_LOG }} />
|
||||
<h2 data-i18n="admin.card.audit.title">Audit-Log</h2>
|
||||
|
||||
403
frontend/src/client/admin-partner-units.ts
Normal file
403
frontend/src/client/admin-partner-units.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
import { initI18n, onLangChange, t, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface PartnerUnit {
|
||||
id: string;
|
||||
name: string;
|
||||
lead_user_id?: string | null;
|
||||
office: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface Member {
|
||||
user_id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
office: string;
|
||||
job_title: string | null;
|
||||
}
|
||||
|
||||
interface PartnerUnitWithMembers extends PartnerUnit {
|
||||
lead_display_name?: string;
|
||||
lead_email?: string;
|
||||
members: Member[];
|
||||
}
|
||||
|
||||
interface Office {
|
||||
key: string;
|
||||
label_de: string;
|
||||
label_en: string;
|
||||
}
|
||||
|
||||
interface UserOption {
|
||||
id: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
let units: PartnerUnitWithMembers[] = [];
|
||||
let offices: Office[] = [];
|
||||
let userOptions: UserOption[] = [];
|
||||
let activeUnitID: string | null = null;
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function officeLabel(key: string): string {
|
||||
const o = offices.find((x) => x.key === key);
|
||||
if (!o) return key;
|
||||
return getLang() === "de" ? o.label_de : o.label_en;
|
||||
}
|
||||
|
||||
async function loadAll(): Promise<void> {
|
||||
await Promise.all([loadOffices(), loadUnits(), loadUsers()]);
|
||||
render();
|
||||
}
|
||||
|
||||
async function loadOffices(): Promise<void> {
|
||||
const resp = await fetch("/api/offices");
|
||||
if (resp.ok) offices = (await resp.json()) as Office[];
|
||||
}
|
||||
|
||||
async function loadUnits(): Promise<void> {
|
||||
const resp = await fetch("/api/partner-units?include=members");
|
||||
if (resp.ok) units = (await resp.json()) as PartnerUnitWithMembers[];
|
||||
}
|
||||
|
||||
async function loadUsers(): Promise<void> {
|
||||
const resp = await fetch("/api/users");
|
||||
if (resp.ok) userOptions = (await resp.json()) as UserOption[];
|
||||
}
|
||||
|
||||
function showFeedback(msg: string, isError: boolean): void {
|
||||
const el = document.getElementById("pu-feedback")!;
|
||||
el.textContent = msg;
|
||||
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-ok");
|
||||
el.style.display = "block";
|
||||
if (!isError) setTimeout(() => { el.style.display = "none"; }, 3500);
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const tbody = document.getElementById("pu-tbody")!;
|
||||
const empty = document.getElementById("pu-empty")!;
|
||||
if (!units.length) {
|
||||
tbody.innerHTML = "";
|
||||
empty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
tbody.innerHTML = units
|
||||
.map((u) => {
|
||||
const lead = u.lead_display_name ?? "—";
|
||||
const memberCount = u.members.length;
|
||||
return `<tr data-id="${esc(u.id)}">
|
||||
<td class="akten-col-title">${esc(u.name)}</td>
|
||||
<td><span class="akten-office-chip akten-office-${esc(u.office)}">${esc(officeLabel(u.office))}</span></td>
|
||||
<td>${esc(lead)}</td>
|
||||
<td>${memberCount}</td>
|
||||
<td class="admin-team-actions-cell">
|
||||
<button type="button" class="btn-link pu-members-btn" data-id="${esc(u.id)}" data-i18n="admin.partner_units.action.members">Mitglieder</button>
|
||||
<button type="button" class="btn-link pu-edit-btn" data-id="${esc(u.id)}" data-i18n="admin.partner_units.action.edit">Bearbeiten</button>
|
||||
<button type="button" class="btn-link pu-delete-btn" data-id="${esc(u.id)}" data-i18n="admin.partner_units.action.delete">Löschen</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
tbody.querySelectorAll<HTMLButtonElement>(".pu-members-btn").forEach((b) =>
|
||||
b.addEventListener("click", () => openMembersModal(b.dataset.id!)),
|
||||
);
|
||||
tbody.querySelectorAll<HTMLButtonElement>(".pu-edit-btn").forEach((b) =>
|
||||
b.addEventListener("click", () => openEditModal(b.dataset.id!)),
|
||||
);
|
||||
tbody.querySelectorAll<HTMLButtonElement>(".pu-delete-btn").forEach((b) =>
|
||||
b.addEventListener("click", () => deleteUnit(b.dataset.id!)),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Edit modal -----------------------------------------------------------
|
||||
|
||||
function openEditModal(id: string | null): void {
|
||||
const modal = document.getElementById("pu-edit-modal")!;
|
||||
const titleEl = document.getElementById("pu-edit-title")!;
|
||||
const idField = document.getElementById("pu-edit-id") as HTMLInputElement;
|
||||
const nameField = document.getElementById("pu-edit-name") as HTMLInputElement;
|
||||
const officeSel = document.getElementById("pu-edit-office") as HTMLSelectElement;
|
||||
const leadSel = document.getElementById("pu-edit-lead") as HTMLSelectElement;
|
||||
const msg = document.getElementById("pu-edit-msg")!;
|
||||
|
||||
msg.textContent = "";
|
||||
msg.className = "form-msg";
|
||||
|
||||
// Populate office options.
|
||||
officeSel.innerHTML = offices
|
||||
.map((o) => `<option value="${esc(o.key)}">${esc(officeLabel(o.key))}</option>`)
|
||||
.join("");
|
||||
|
||||
// Populate lead options (sorted).
|
||||
const leadEntries = userOptions
|
||||
.slice()
|
||||
.sort((a, b) => (a.display_name || a.email).localeCompare(b.display_name || b.email));
|
||||
leadSel.innerHTML =
|
||||
`<option value="">—</option>` +
|
||||
leadEntries
|
||||
.map((u) => {
|
||||
const label = u.display_name ? `${u.display_name} (${u.email})` : u.email;
|
||||
return `<option value="${esc(u.id)}">${esc(label)}</option>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
if (id) {
|
||||
const u = units.find((x) => x.id === id);
|
||||
if (!u) return;
|
||||
titleEl.setAttribute("data-i18n", "admin.partner_units.edit.heading");
|
||||
titleEl.textContent = t("admin.partner_units.edit.heading") || "Partner Unit bearbeiten";
|
||||
idField.value = u.id;
|
||||
nameField.value = u.name;
|
||||
officeSel.value = u.office;
|
||||
leadSel.value = u.lead_user_id ?? "";
|
||||
} else {
|
||||
titleEl.setAttribute("data-i18n", "admin.partner_units.new.heading");
|
||||
titleEl.textContent = t("admin.partner_units.new.heading") || "Partner Unit anlegen";
|
||||
idField.value = "";
|
||||
nameField.value = "";
|
||||
officeSel.value = offices[0]?.key ?? "munich";
|
||||
leadSel.value = "";
|
||||
}
|
||||
|
||||
modal.style.display = "flex";
|
||||
nameField.focus();
|
||||
}
|
||||
|
||||
function closeEditModal(): void {
|
||||
document.getElementById("pu-edit-modal")!.style.display = "none";
|
||||
}
|
||||
|
||||
async function submitEdit(e: Event): Promise<void> {
|
||||
e.preventDefault();
|
||||
const idField = document.getElementById("pu-edit-id") as HTMLInputElement;
|
||||
const nameField = document.getElementById("pu-edit-name") as HTMLInputElement;
|
||||
const officeSel = document.getElementById("pu-edit-office") as HTMLSelectElement;
|
||||
const leadSel = document.getElementById("pu-edit-lead") as HTMLSelectElement;
|
||||
const msg = document.getElementById("pu-edit-msg")!;
|
||||
|
||||
const name = nameField.value.trim();
|
||||
if (!name) {
|
||||
msg.textContent = t("admin.partner_units.error.name_required") || "Name erforderlich";
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
|
||||
const isEdit = !!idField.value;
|
||||
// Server treats missing keys as "no change". For lead clearing we send the
|
||||
// nil UUID — service code interprets that as "explicit clear".
|
||||
const NIL_UUID = "00000000-0000-0000-0000-000000000000";
|
||||
const payload: Record<string, unknown> = {
|
||||
name,
|
||||
office: officeSel.value,
|
||||
lead_user_id: leadSel.value || NIL_UUID,
|
||||
};
|
||||
const url = isEdit ? `/api/partner-units/${idField.value}` : "/api/partner-units";
|
||||
const method = isEdit ? "PATCH" : "POST";
|
||||
|
||||
const resp = await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
msg.textContent = body.error || "Fehler.";
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
closeEditModal();
|
||||
await loadUnits();
|
||||
render();
|
||||
showFeedback(
|
||||
isEdit
|
||||
? t("admin.partner_units.feedback.updated") || "Aktualisiert."
|
||||
: t("admin.partner_units.feedback.created") || "Angelegt.",
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteUnit(id: string): Promise<void> {
|
||||
const u = units.find((x) => x.id === id);
|
||||
if (!u) return;
|
||||
const confirmMsg = (t("admin.partner_units.confirm_delete") || "Partner Unit \"{name}\" wirklich löschen?")
|
||||
.replace("{name}", u.name);
|
||||
if (!window.confirm(confirmMsg)) return;
|
||||
const resp = await fetch(`/api/partner-units/${id}`, { method: "DELETE" });
|
||||
if (!resp.ok && resp.status !== 204) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
showFeedback(body.error || "Löschen fehlgeschlagen.", true);
|
||||
return;
|
||||
}
|
||||
await loadUnits();
|
||||
render();
|
||||
showFeedback(t("admin.partner_units.feedback.deleted") || "Gelöscht.", false);
|
||||
}
|
||||
|
||||
// ---- Members modal --------------------------------------------------------
|
||||
|
||||
function openMembersModal(id: string): void {
|
||||
activeUnitID = id;
|
||||
const u = units.find((x) => x.id === id);
|
||||
if (!u) return;
|
||||
const titleEl = document.getElementById("pu-members-title")!;
|
||||
titleEl.textContent =
|
||||
(t("admin.partner_units.member.heading") || "Mitglieder verwalten") + " — " + u.name;
|
||||
renderMemberList();
|
||||
|
||||
// Reset add form
|
||||
(document.getElementById("pu-add-input") as HTMLInputElement).value = "";
|
||||
(document.getElementById("pu-add-user-id") as HTMLInputElement).value = "";
|
||||
document.getElementById("pu-add-suggestions")!.innerHTML = "";
|
||||
const msg = document.getElementById("pu-add-msg")!;
|
||||
msg.textContent = "";
|
||||
msg.className = "form-msg";
|
||||
|
||||
document.getElementById("pu-members-modal")!.style.display = "flex";
|
||||
}
|
||||
|
||||
function closeMembersModal(): void {
|
||||
document.getElementById("pu-members-modal")!.style.display = "none";
|
||||
activeUnitID = null;
|
||||
}
|
||||
|
||||
function renderMemberList(): void {
|
||||
if (!activeUnitID) return;
|
||||
const u = units.find((x) => x.id === activeUnitID);
|
||||
if (!u) return;
|
||||
const list = document.getElementById("pu-members-list")!;
|
||||
if (!u.members.length) {
|
||||
list.innerHTML = `<li class="form-hint">${esc(t("admin.partner_units.member.empty") || "Noch keine Mitglieder.")}</li>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = u.members
|
||||
.map(
|
||||
(m) => `<li class="partner-unit-member-item">
|
||||
<span>${esc(m.display_name || m.email)} <span class="form-hint">(${esc(m.email)})</span></span>
|
||||
<button type="button" class="btn-ghost btn-small pu-remove-btn" data-user="${esc(m.user_id)}">${esc(t("admin.partner_units.member.remove") || "Entfernen")}</button>
|
||||
</li>`,
|
||||
)
|
||||
.join("");
|
||||
list.querySelectorAll<HTMLButtonElement>(".pu-remove-btn").forEach((b) =>
|
||||
b.addEventListener("click", () => removeMember(b.dataset.user!)),
|
||||
);
|
||||
}
|
||||
|
||||
function wireSuggestions(): void {
|
||||
const input = document.getElementById("pu-add-input") as HTMLInputElement;
|
||||
const hidden = document.getElementById("pu-add-user-id") as HTMLInputElement;
|
||||
const sugs = document.getElementById("pu-add-suggestions")!;
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
const q = input.value.trim().toLowerCase();
|
||||
hidden.value = "";
|
||||
if (!q) {
|
||||
sugs.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
const matches = userOptions
|
||||
.filter((u) => (u.display_name + " " + u.email).toLowerCase().includes(q))
|
||||
.slice(0, 8);
|
||||
sugs.innerHTML = matches
|
||||
.map(
|
||||
(u) => `<div class="akten-collab-suggestion" data-id="${esc(u.id)}" data-label="${escAttr(u.display_name || u.email)}">
|
||||
<strong>${esc(u.display_name || u.email)}</strong>
|
||||
<span class="form-hint">${esc(u.email)}</span>
|
||||
</div>`,
|
||||
)
|
||||
.join("");
|
||||
sugs.querySelectorAll<HTMLDivElement>(".akten-collab-suggestion").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
hidden.value = el.dataset.id!;
|
||||
input.value = el.dataset.label!;
|
||||
sugs.innerHTML = "";
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function submitAddMember(e: Event): Promise<void> {
|
||||
e.preventDefault();
|
||||
if (!activeUnitID) return;
|
||||
const hidden = document.getElementById("pu-add-user-id") as HTMLInputElement;
|
||||
const msg = document.getElementById("pu-add-msg")!;
|
||||
if (!hidden.value) {
|
||||
msg.textContent = t("admin.partner_units.error.user_required") || "Benutzer auswählen";
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
const resp = await fetch(`/api/partner-units/${activeUnitID}/members`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_id: hidden.value }),
|
||||
});
|
||||
if (!resp.ok && resp.status !== 204) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
msg.textContent = body.error || "Fehler.";
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
(document.getElementById("pu-add-input") as HTMLInputElement).value = "";
|
||||
hidden.value = "";
|
||||
document.getElementById("pu-add-suggestions")!.innerHTML = "";
|
||||
msg.textContent = "";
|
||||
await loadUnits();
|
||||
renderMemberList();
|
||||
render();
|
||||
}
|
||||
|
||||
async function removeMember(userID: string): Promise<void> {
|
||||
if (!activeUnitID) return;
|
||||
const confirmMsg = t("admin.partner_units.member.confirm_remove") || "Mitglied entfernen?";
|
||||
if (!window.confirm(confirmMsg)) return;
|
||||
const resp = await fetch(
|
||||
`/api/partner-units/${activeUnitID}/members/${userID}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!resp.ok && resp.status !== 204) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
showFeedback(body.error || "Entfernen fehlgeschlagen.", true);
|
||||
return;
|
||||
}
|
||||
await loadUnits();
|
||||
renderMemberList();
|
||||
render();
|
||||
}
|
||||
|
||||
// ---- Init -----------------------------------------------------------------
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
document.getElementById("pu-new-btn")!.addEventListener("click", () => openEditModal(null));
|
||||
document.getElementById("pu-edit-close")!.addEventListener("click", closeEditModal);
|
||||
document.getElementById("pu-edit-cancel")!.addEventListener("click", closeEditModal);
|
||||
document.getElementById("pu-edit-form")!.addEventListener("submit", submitEdit);
|
||||
document.getElementById("pu-edit-modal")!.addEventListener("click", (e) => {
|
||||
if (e.target === e.currentTarget) closeEditModal();
|
||||
});
|
||||
|
||||
document.getElementById("pu-members-close")!.addEventListener("click", closeMembersModal);
|
||||
document.getElementById("pu-add-form")!.addEventListener("submit", submitAddMember);
|
||||
document.getElementById("pu-members-modal")!.addEventListener("click", (e) => {
|
||||
if (e.target === e.currentTarget) closeMembersModal();
|
||||
});
|
||||
wireSuggestions();
|
||||
|
||||
onLangChange(() => render());
|
||||
void loadAll();
|
||||
});
|
||||
@@ -9,7 +9,6 @@ interface User {
|
||||
additional_offices?: string[];
|
||||
job_title: string | null;
|
||||
global_role: string;
|
||||
dezernat?: string;
|
||||
lang: string;
|
||||
reminder_morning_time?: string;
|
||||
reminder_evening_time?: string;
|
||||
@@ -131,7 +130,6 @@ function userMatchesSearch(u: User): boolean {
|
||||
return (
|
||||
u.display_name.toLowerCase().includes(q) ||
|
||||
u.email.toLowerCase().includes(q) ||
|
||||
(u.dezernat ?? "").toLowerCase().includes(q) ||
|
||||
(u.job_title ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
@@ -193,7 +191,6 @@ function renderRow(u: User): string {
|
||||
<td><span class="akten-office-chip akten-office-${esc(u.office)}">${esc(officeLabel(u.office))}</span></td>
|
||||
<td>${jobTitle ? esc(jobTitle) : "<span class=\"admin-team-muted\">—</span>"}</td>
|
||||
<td>${permissionCell(u)}</td>
|
||||
<td>${esc(u.dezernat ?? "")}</td>
|
||||
<td>${additional.length ? additional.map((o) => esc(officeLabel(o))).join(", ") : "<span class=\"admin-team-muted\">—</span>"}</td>
|
||||
<td>${esc(u.lang.toUpperCase())}</td>
|
||||
<td class="akten-col-updated">${esc(fmtDate(u.created_at))}</td>
|
||||
@@ -218,7 +215,6 @@ function renderEditRow(u: User): string {
|
||||
<datalist id="admin-team-job-title-suggest-${esc(u.id)}">${jobTitleList}</datalist>
|
||||
</td>
|
||||
<td>${permissionEditor(u)}</td>
|
||||
<td><input type="text" class="admin-team-input" data-field="dezernat" value="${esc(u.dezernat ?? "")}" /></td>
|
||||
<td class="admin-team-multi">${additionalOfficesEditor(additional)}</td>
|
||||
<td><select class="admin-team-input" data-field="lang">${langOptions(u.lang)}</select></td>
|
||||
<td class="akten-col-updated">${esc(fmtDate(u.created_at))}</td>
|
||||
@@ -348,12 +344,10 @@ function openDirectAddModal() {
|
||||
const fb = document.getElementById("admin-da-feedback")!;
|
||||
const nameField = document.getElementById("admin-da-name") as HTMLInputElement;
|
||||
const jobTitleField = document.getElementById("admin-da-role") as HTMLInputElement;
|
||||
const dezField = document.getElementById("admin-da-dezernat") as HTMLInputElement;
|
||||
|
||||
fb.style.display = "none";
|
||||
nameField.value = "";
|
||||
jobTitleField.value = "";
|
||||
dezField.value = "";
|
||||
|
||||
officeSel.innerHTML = officeOptions("munich");
|
||||
|
||||
@@ -404,7 +398,6 @@ function initDirectAddModal() {
|
||||
|
||||
const officeSel = document.getElementById("admin-da-office") as HTMLSelectElement;
|
||||
const jobTitleField = document.getElementById("admin-da-role") as HTMLInputElement;
|
||||
const dezField = document.getElementById("admin-da-dezernat") as HTMLInputElement;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
email: emailSel.value,
|
||||
@@ -413,7 +406,6 @@ function initDirectAddModal() {
|
||||
job_title: jobTitleField.value.trim() || "Associate",
|
||||
lang: "de",
|
||||
};
|
||||
if (dezField.value.trim()) payload.dezernat = dezField.value.trim();
|
||||
|
||||
const resp = await fetch("/api/admin/users", {
|
||||
method: "POST",
|
||||
|
||||
@@ -781,8 +781,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"onboarding.office.placeholder": "Bitte ausw\u00e4hlen",
|
||||
"onboarding.job_title": "Berufsbezeichnung",
|
||||
"onboarding.job_title.placeholder": "z.B. Associate, Partner, Patentanwalt",
|
||||
"onboarding.dezernat": "Dezernat oder Partner",
|
||||
"onboarding.dezernat.placeholder": "z.B. Dr. M\u00fcller, Team Schmidt",
|
||||
"onboarding.partner_unit": "Partner Unit",
|
||||
"onboarding.partner_unit.unassigned": "(noch keine Zuordnung)",
|
||||
"onboarding.optional": "(optional)",
|
||||
"onboarding.submit": "Profil anlegen",
|
||||
"onboarding.error.display_name": "Bitte Anzeigename eingeben.",
|
||||
@@ -848,33 +848,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"einstellungen.tab.profil": "Profil",
|
||||
"einstellungen.tab.benachrichtigungen": "Benachrichtigungen",
|
||||
"einstellungen.tab.caldav": "CalDAV",
|
||||
"einstellungen.tab.dezernat": "Dezernat",
|
||||
"dezernat.heading": "Mein Dezernat",
|
||||
"dezernat.subtitle": "Dezernate sind strukturelle Partnereinheiten — getrennt von Projektteams.",
|
||||
"dezernat.admin.heading": "Dezernate verwalten (Admin)",
|
||||
"dezernat.admin.new": "Neues Dezernat anlegen",
|
||||
"dezernat.admin.create": "Anlegen",
|
||||
"dezernat.col.name": "Name",
|
||||
"dezernat.col.office": "B\u00fcro",
|
||||
"dezernat.col.lead": "Partner",
|
||||
"dezernat.col.members": "Mitglieder",
|
||||
"dezernat.field.name": "Name",
|
||||
"dezernat.field.office": "B\u00fcro",
|
||||
"dezernat.none": "Sie geh\u00f6ren noch keinem Dezernat an.",
|
||||
"dezernat.members_label": "Mitglieder",
|
||||
"dezernat.manage": "Verwalten",
|
||||
"dezernat.delete": "L\u00f6schen",
|
||||
"dezernat.confirm_delete": "Dezernat wirklich l\u00f6schen?",
|
||||
"dezernat.error.name_required": "Name erforderlich",
|
||||
"dezernat.error.user_required": "Benutzer ausw\u00e4hlen",
|
||||
"dezernat.manage_heading": "Mitglieder verwalten",
|
||||
"dezernat.loading": "L\u00e4dt\u2026",
|
||||
"dezernat.no_members": "Noch keine Mitglieder.",
|
||||
"dezernat.add_member": "Mitglied hinzuf\u00fcgen",
|
||||
"dezernat.add_member.placeholder": "Name oder E-Mail",
|
||||
"dezernat.add": "Hinzuf\u00fcgen",
|
||||
"dezernat.remove": "Entfernen",
|
||||
"dezernat.confirm_remove": "Mitglied entfernen?",
|
||||
"projekte.title": "Projekte \u2014 Paliad",
|
||||
"projekte.heading": "Projekte",
|
||||
"projekte.subtitle": "Mandanten, Streitsachen, Patente und Verfahren \u2014 hierarchisch organisiert.",
|
||||
@@ -1024,8 +997,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"einstellungen.profil.office": "B\u00fcro",
|
||||
"einstellungen.profil.job_title": "Berufsbezeichnung",
|
||||
"einstellungen.profil.job_title.placeholder": "z.B. Associate, Partner, Patentanwalt",
|
||||
"einstellungen.profil.dezernat": "Dezernat oder Partner",
|
||||
"einstellungen.profil.dezernat.placeholder": "z.B. Dr. M\u00fcller, Team Schmidt",
|
||||
"einstellungen.profil.lang": "Sprache",
|
||||
"einstellungen.profil.lang.de": "Deutsch",
|
||||
"einstellungen.profil.lang.en": "English",
|
||||
@@ -1221,15 +1192,20 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
// Team directory (t-paliad-029)
|
||||
"team.title": "Team — Paliad",
|
||||
"team.heading": "Team",
|
||||
"team.subtitle": "Alle Paliad-Kolleg:innen, gruppiert nach Standort oder Dezernat.",
|
||||
"team.subtitle": "Alle Paliad-Kolleg:innen, gruppiert nach Standort oder Partner Unit.",
|
||||
"team.search.placeholder": "Nach Name, Rolle, Büro suchen…",
|
||||
"team.group.office": "Nach Standort",
|
||||
"team.group.department": "Nach Dezernat",
|
||||
"team.group.department": "Nach Partner Unit",
|
||||
"team.group.other": "Sonstige",
|
||||
"team.filter.all": "Alle",
|
||||
"team.empty": "Keine Treffer.",
|
||||
"team.dept.lead": "Lead",
|
||||
"team.dept.unassigned": "Ohne Dezernat",
|
||||
"team.dept.unassigned": "Ohne Partner Unit",
|
||||
"team.partner_unit.unassigned": "Ohne Partner Unit",
|
||||
"partner_unit.heading": "Meine Partner Units",
|
||||
"partner_unit.subtitle": "Partner Units sind strukturelle Einheiten — getrennt von Projektteams. Mitgliedschaft wird vom Admin verwaltet.",
|
||||
"partner_unit.none": "Sie sind noch keiner Partner Unit zugeordnet.",
|
||||
"partner_unit.members_label": "Mitglieder",
|
||||
|
||||
// Admin team management (t-paliad-050)
|
||||
"nav.group.admin": "Admin",
|
||||
@@ -1243,14 +1219,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.coming_soon": "Kommt bald",
|
||||
"admin.card.team.title": "Team-Verwaltung",
|
||||
"admin.card.team.desc": "Benutzer:innen anlegen, bearbeiten, löschen.",
|
||||
"admin.card.departments.title": "Dezernate",
|
||||
"admin.card.departments.desc": "Dezernate anlegen und Mitglieder verwalten.",
|
||||
"admin.card.partner_units.title": "Partner Units",
|
||||
"admin.card.partner_units.desc": "Strukturelle Partnereinheiten anlegen und Mitglieder zuordnen.",
|
||||
"admin.card.audit.title": "Audit-Log",
|
||||
"admin.card.audit.desc": "Wer hat wann was geändert? Nachvollziehbarkeit für sicherheitsrelevante Aktionen.",
|
||||
"admin.card.email_templates.title": "Email-Templates",
|
||||
"admin.card.email_templates.desc": "Vorlagen für Einladungen, Erinnerungen und Layout anpassen.",
|
||||
"admin.card.feature_flags.title": "Feature-Flags",
|
||||
"admin.card.feature_flags.desc": "Funktionen pro Standort, Dezernat oder Rolle aktivieren.",
|
||||
"admin.card.feature_flags.desc": "Funktionen pro Standort, Partner Unit oder Rolle aktivieren.",
|
||||
"admin.email_templates.title": "Email-Templates — Paliad",
|
||||
"admin.email_templates.heading": "Email-Templates",
|
||||
"admin.email_templates.subtitle": "Vorlagen für Einladungen, Erinnerungen und das Layout-Wrapper anpassen.",
|
||||
@@ -1312,7 +1288,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.team.col.office": "Standort",
|
||||
"admin.team.col.job_title": "Berufsbezeichnung",
|
||||
"admin.team.col.permission": "Berechtigung",
|
||||
"admin.team.col.dezernat": "Dezernat",
|
||||
"admin.team.col.additional": "Weitere Standorte",
|
||||
"admin.team.col.lang": "Sprache",
|
||||
"admin.team.col.created": "Angelegt",
|
||||
@@ -1333,7 +1308,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.team.direct_add.name": "Anzeigename",
|
||||
"admin.team.direct_add.office": "Standort",
|
||||
"admin.team.direct_add.job_title": "Berufsbezeichnung",
|
||||
"admin.team.direct_add.dezernat": "Dezernat (optional)",
|
||||
"admin.team.permission.standard": "Standard",
|
||||
"admin.team.permission.global_admin": "Globaler Admin",
|
||||
"admin.team.permission.last_admin": "Der letzte globale Admin kann nicht degradiert werden.",
|
||||
@@ -1342,9 +1316,42 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
|
||||
// Admin audit log (t-paliad-071)
|
||||
"nav.admin.audit": "Audit-Log",
|
||||
"nav.admin.partner_units": "Partner Units",
|
||||
"admin.audit.title": "Audit-Log — Paliad",
|
||||
"admin.audit.heading": "Audit-Log",
|
||||
"admin.audit.subtitle": "Globale Zeitleiste über Projekt-, CalDAV- und Reminder-Ereignisse.",
|
||||
"admin.audit.subtitle": "Globale Zeitleiste über Projekt-, CalDAV-, Reminder- und Partner-Unit-Ereignisse.",
|
||||
"admin.audit.source.partner_unit_events": "Partner Units",
|
||||
"admin.partner_units.title": "Partner Units — Paliad",
|
||||
"admin.partner_units.heading": "Partner Units",
|
||||
"admin.partner_units.subtitle": "Strukturelle Partnereinheiten verwalten und Mitglieder zuordnen.",
|
||||
"admin.partner_units.loading": "Lade…",
|
||||
"admin.partner_units.empty": "Noch keine Partner Units angelegt.",
|
||||
"admin.partner_units.new": "Neue Partner Unit",
|
||||
"admin.partner_units.new.heading": "Partner Unit anlegen",
|
||||
"admin.partner_units.edit.heading": "Partner Unit bearbeiten",
|
||||
"admin.partner_units.create": "Speichern",
|
||||
"admin.partner_units.cancel": "Abbrechen",
|
||||
"admin.partner_units.col.name": "Name",
|
||||
"admin.partner_units.col.office": "Büro",
|
||||
"admin.partner_units.col.lead": "Lead",
|
||||
"admin.partner_units.col.members": "Mitglieder",
|
||||
"admin.partner_units.col.actions": "Aktionen",
|
||||
"admin.partner_units.action.members": "Mitglieder",
|
||||
"admin.partner_units.action.edit": "Bearbeiten",
|
||||
"admin.partner_units.action.delete": "Löschen",
|
||||
"admin.partner_units.confirm_delete": "Partner Unit \"{name}\" wirklich löschen?",
|
||||
"admin.partner_units.error.name_required": "Name erforderlich",
|
||||
"admin.partner_units.error.user_required": "Benutzer auswählen",
|
||||
"admin.partner_units.feedback.created": "Angelegt.",
|
||||
"admin.partner_units.feedback.updated": "Aktualisiert.",
|
||||
"admin.partner_units.feedback.deleted": "Gelöscht.",
|
||||
"admin.partner_units.member.heading": "Mitglieder verwalten",
|
||||
"admin.partner_units.member.empty": "Noch keine Mitglieder.",
|
||||
"admin.partner_units.member.add": "Mitglied hinzufügen",
|
||||
"admin.partner_units.member.add_btn": "Hinzufügen",
|
||||
"admin.partner_units.member.remove": "Entfernen",
|
||||
"admin.partner_units.member.confirm_remove": "Mitglied entfernen?",
|
||||
"admin.partner_units.member.placeholder": "Name oder E-Mail",
|
||||
"admin.audit.loading": "Lade…",
|
||||
"admin.audit.empty": "Keine Ereignisse für die gewählten Filter.",
|
||||
"admin.audit.loadmore": "Weitere laden",
|
||||
@@ -2151,8 +2158,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"onboarding.office.placeholder": "Please select",
|
||||
"onboarding.job_title": "Job title",
|
||||
"onboarding.job_title.placeholder": "e.g. Associate, Partner, Patent Attorney",
|
||||
"onboarding.dezernat": "Department / Partner",
|
||||
"onboarding.dezernat.placeholder": "e.g. Dr. M\u00fcller, Team Schmidt",
|
||||
"onboarding.partner_unit": "Partner Unit",
|
||||
"onboarding.partner_unit.unassigned": "(not assigned yet)",
|
||||
"onboarding.optional": "(optional)",
|
||||
"onboarding.submit": "Create profile",
|
||||
"onboarding.error.display_name": "Please enter a display name.",
|
||||
@@ -2218,33 +2225,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"einstellungen.tab.profil": "Profile",
|
||||
"einstellungen.tab.benachrichtigungen": "Notifications",
|
||||
"einstellungen.tab.caldav": "CalDAV",
|
||||
"einstellungen.tab.dezernat": "Dezernat",
|
||||
"dezernat.heading": "My Dezernat",
|
||||
"dezernat.subtitle": "Dezernate are structural partner units — separate from project teams.",
|
||||
"dezernat.admin.heading": "Manage Dezernate (Admin)",
|
||||
"dezernat.admin.new": "Create new Dezernat",
|
||||
"dezernat.admin.create": "Create",
|
||||
"dezernat.col.name": "Name",
|
||||
"dezernat.col.office": "Office",
|
||||
"dezernat.col.lead": "Partner",
|
||||
"dezernat.col.members": "Members",
|
||||
"dezernat.field.name": "Name",
|
||||
"dezernat.field.office": "Office",
|
||||
"dezernat.none": "You are not a member of any Dezernat yet.",
|
||||
"dezernat.members_label": "members",
|
||||
"dezernat.manage": "Manage",
|
||||
"dezernat.delete": "Delete",
|
||||
"dezernat.confirm_delete": "Delete this Dezernat?",
|
||||
"dezernat.error.name_required": "Name required",
|
||||
"dezernat.error.user_required": "Select a user",
|
||||
"dezernat.manage_heading": "Manage members",
|
||||
"dezernat.loading": "Loading\u2026",
|
||||
"dezernat.no_members": "No members yet.",
|
||||
"dezernat.add_member": "Add member",
|
||||
"dezernat.add_member.placeholder": "Name or email",
|
||||
"dezernat.add": "Add",
|
||||
"dezernat.remove": "Remove",
|
||||
"dezernat.confirm_remove": "Remove member?",
|
||||
"projekte.title": "Projects \u2014 Paliad",
|
||||
"projekte.heading": "Projects",
|
||||
"projekte.subtitle": "Clients, litigations, patents and cases \u2014 organised hierarchically.",
|
||||
@@ -2394,8 +2374,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"einstellungen.profil.office": "Office",
|
||||
"einstellungen.profil.job_title": "Job title",
|
||||
"einstellungen.profil.job_title.placeholder": "e.g. Associate, Partner, Patent Attorney",
|
||||
"einstellungen.profil.dezernat": "Department / Partner",
|
||||
"einstellungen.profil.dezernat.placeholder": "e.g. Dr. M\u00fcller, Team Schmidt",
|
||||
"einstellungen.profil.lang": "Language",
|
||||
"einstellungen.profil.lang.de": "Deutsch",
|
||||
"einstellungen.profil.lang.en": "English",
|
||||
@@ -2591,15 +2569,20 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
// Team directory (t-paliad-029)
|
||||
"team.title": "Team — Paliad",
|
||||
"team.heading": "Team",
|
||||
"team.subtitle": "All Paliad colleagues, grouped by office or department.",
|
||||
"team.subtitle": "All Paliad colleagues, grouped by office or partner unit.",
|
||||
"team.search.placeholder": "Search by name, role, office…",
|
||||
"team.group.office": "By office",
|
||||
"team.group.department": "By department",
|
||||
"team.group.department": "By partner unit",
|
||||
"team.group.other": "Other",
|
||||
"team.filter.all": "All",
|
||||
"team.empty": "No matches.",
|
||||
"team.dept.lead": "Lead",
|
||||
"team.dept.unassigned": "No department",
|
||||
"team.dept.unassigned": "No partner unit",
|
||||
"team.partner_unit.unassigned": "No partner unit",
|
||||
"partner_unit.heading": "My Partner Units",
|
||||
"partner_unit.subtitle": "Partner Units are structural units — separate from project teams. Membership is admin-managed.",
|
||||
"partner_unit.none": "You are not a member of any Partner Unit yet.",
|
||||
"partner_unit.members_label": "members",
|
||||
|
||||
// Admin team management (t-paliad-050)
|
||||
"nav.group.admin": "Admin",
|
||||
@@ -2613,14 +2596,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.coming_soon": "Coming soon",
|
||||
"admin.card.team.title": "Team Management",
|
||||
"admin.card.team.desc": "Create, edit and delete user accounts.",
|
||||
"admin.card.departments.title": "Dezernate",
|
||||
"admin.card.departments.desc": "Create departments and manage their members.",
|
||||
"admin.card.partner_units.title": "Partner Units",
|
||||
"admin.card.partner_units.desc": "Create structural partner units and assign members.",
|
||||
"admin.card.audit.title": "Audit Log",
|
||||
"admin.card.audit.desc": "Who changed what, and when. Traceability for security-relevant actions.",
|
||||
"admin.card.email_templates.title": "Email Templates",
|
||||
"admin.card.email_templates.desc": "Customise templates for invitations, reminders and the wrapper layout.",
|
||||
"admin.card.feature_flags.title": "Feature Flags",
|
||||
"admin.card.feature_flags.desc": "Enable features per office, department or role.",
|
||||
"admin.card.feature_flags.desc": "Enable features per office, partner unit or role.",
|
||||
"admin.email_templates.title": "Email Templates — Paliad",
|
||||
"admin.email_templates.heading": "Email Templates",
|
||||
"admin.email_templates.subtitle": "Customise templates for invitations, reminders, and the shared layout wrapper.",
|
||||
@@ -2682,7 +2665,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.team.col.office": "Office",
|
||||
"admin.team.col.job_title": "Job title",
|
||||
"admin.team.col.permission": "Permission",
|
||||
"admin.team.col.dezernat": "Department",
|
||||
"admin.team.col.additional": "Additional offices",
|
||||
"admin.team.col.lang": "Lang",
|
||||
"admin.team.col.created": "Created",
|
||||
@@ -2703,7 +2685,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.team.direct_add.name": "Display name",
|
||||
"admin.team.direct_add.office": "Office",
|
||||
"admin.team.direct_add.job_title": "Job title",
|
||||
"admin.team.direct_add.dezernat": "Department (optional)",
|
||||
"admin.team.permission.standard": "Standard",
|
||||
"admin.team.permission.global_admin": "Global Admin",
|
||||
"admin.team.permission.last_admin": "The last global admin cannot be demoted.",
|
||||
@@ -2712,9 +2693,42 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
|
||||
// Admin audit log (t-paliad-071)
|
||||
"nav.admin.audit": "Audit Log",
|
||||
"nav.admin.partner_units": "Partner Units",
|
||||
"admin.audit.title": "Audit Log — Paliad",
|
||||
"admin.audit.heading": "Audit Log",
|
||||
"admin.audit.subtitle": "Global timeline across project, CalDAV and reminder events.",
|
||||
"admin.audit.subtitle": "Global timeline across project, CalDAV, reminder and partner-unit events.",
|
||||
"admin.audit.source.partner_unit_events": "Partner Units",
|
||||
"admin.partner_units.title": "Partner Units — Paliad",
|
||||
"admin.partner_units.heading": "Partner Units",
|
||||
"admin.partner_units.subtitle": "Manage structural partner units and assign members.",
|
||||
"admin.partner_units.loading": "Loading…",
|
||||
"admin.partner_units.empty": "No partner units yet.",
|
||||
"admin.partner_units.new": "New Partner Unit",
|
||||
"admin.partner_units.new.heading": "Create Partner Unit",
|
||||
"admin.partner_units.edit.heading": "Edit Partner Unit",
|
||||
"admin.partner_units.create": "Save",
|
||||
"admin.partner_units.cancel": "Cancel",
|
||||
"admin.partner_units.col.name": "Name",
|
||||
"admin.partner_units.col.office": "Office",
|
||||
"admin.partner_units.col.lead": "Lead",
|
||||
"admin.partner_units.col.members": "Members",
|
||||
"admin.partner_units.col.actions": "Actions",
|
||||
"admin.partner_units.action.members": "Members",
|
||||
"admin.partner_units.action.edit": "Edit",
|
||||
"admin.partner_units.action.delete": "Delete",
|
||||
"admin.partner_units.confirm_delete": "Really delete partner unit \"{name}\"?",
|
||||
"admin.partner_units.error.name_required": "Name required",
|
||||
"admin.partner_units.error.user_required": "Select a user",
|
||||
"admin.partner_units.feedback.created": "Created.",
|
||||
"admin.partner_units.feedback.updated": "Updated.",
|
||||
"admin.partner_units.feedback.deleted": "Deleted.",
|
||||
"admin.partner_units.member.heading": "Manage members",
|
||||
"admin.partner_units.member.empty": "No members yet.",
|
||||
"admin.partner_units.member.add": "Add member",
|
||||
"admin.partner_units.member.add_btn": "Add",
|
||||
"admin.partner_units.member.remove": "Remove",
|
||||
"admin.partner_units.member.confirm_remove": "Remove member?",
|
||||
"admin.partner_units.member.placeholder": "Name or email",
|
||||
"admin.audit.loading": "Loading…",
|
||||
"admin.audit.empty": "No events match the selected filters.",
|
||||
"admin.audit.loadmore": "Load more",
|
||||
|
||||
@@ -6,7 +6,14 @@ interface Office {
|
||||
label_en: string;
|
||||
}
|
||||
|
||||
interface PartnerUnit {
|
||||
id: string;
|
||||
name: string;
|
||||
office: string;
|
||||
}
|
||||
|
||||
let offices: Office[] = [];
|
||||
let partnerUnits: PartnerUnit[] = [];
|
||||
|
||||
async function seedDisplayName(): Promise<void> {
|
||||
const input = document.getElementById("onb-display-name") as HTMLInputElement;
|
||||
@@ -37,6 +44,35 @@ async function loadOffices(): Promise<void> {
|
||||
renderOfficeOptions();
|
||||
}
|
||||
|
||||
async function loadPartnerUnits(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch("/api/partner-units");
|
||||
if (!resp.ok) return;
|
||||
partnerUnits = (await resp.json()) as PartnerUnit[];
|
||||
} catch {
|
||||
partnerUnits = [];
|
||||
}
|
||||
renderPartnerUnitOptions();
|
||||
}
|
||||
|
||||
function renderPartnerUnitOptions(): void {
|
||||
const select = document.getElementById("onb-partner-unit") as HTMLSelectElement | null;
|
||||
if (!select) return;
|
||||
const previous = select.value;
|
||||
const unassignedLabel = t("onboarding.partner_unit.unassigned") || "(noch keine Zuordnung)";
|
||||
const head = `<option value="">${esc(unassignedLabel)}</option>`;
|
||||
select.innerHTML =
|
||||
head +
|
||||
partnerUnits
|
||||
.map((u) => `<option value="${esc(u.id)}">${esc(u.name)}</option>`)
|
||||
.join("");
|
||||
// Preserve a previous selection across language toggles (which re-render
|
||||
// the head option).
|
||||
if (previous && partnerUnits.some((u) => u.id === previous)) {
|
||||
select.value = previous;
|
||||
}
|
||||
}
|
||||
|
||||
function renderOfficeOptions(): void {
|
||||
const select = document.getElementById("onb-office") as HTMLSelectElement | null;
|
||||
if (!select) return;
|
||||
@@ -86,7 +122,7 @@ async function submitForm(e: Event): Promise<void> {
|
||||
const displayName = (data.get("display_name") as string || "").trim();
|
||||
const office = (data.get("office") as string || "").trim();
|
||||
const jobTitle = (data.get("job_title") as string || "").trim();
|
||||
const dezernat = (data.get("dezernat") as string || "").trim();
|
||||
const partnerUnitID = (data.get("partner_unit_id") as string || "").trim();
|
||||
|
||||
if (!displayName) {
|
||||
showMessage(t("onboarding.error.display_name"), "login-error");
|
||||
@@ -106,7 +142,7 @@ async function submitForm(e: Event): Promise<void> {
|
||||
office,
|
||||
job_title: jobTitle,
|
||||
};
|
||||
if (dezernat) payload.dezernat = dezernat;
|
||||
if (partnerUnitID) payload.partner_unit_id = partnerUnitID;
|
||||
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
@@ -138,5 +174,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
seedDisplayName();
|
||||
document.getElementById("onboarding-form")!.addEventListener("submit", submitForm);
|
||||
loadOffices();
|
||||
onLangChange(renderOfficeOptions);
|
||||
loadPartnerUnits();
|
||||
onLangChange(() => {
|
||||
renderOfficeOptions();
|
||||
renderPartnerUnitOptions();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,6 @@ interface Me {
|
||||
office: string;
|
||||
job_title: string | null;
|
||||
global_role: string;
|
||||
dezernat?: string;
|
||||
lang: Lang;
|
||||
email_preferences: Record<string, unknown>;
|
||||
reminder_morning_time: string;
|
||||
@@ -52,8 +51,8 @@ interface SyncLogEntry {
|
||||
duration_ms?: number;
|
||||
}
|
||||
|
||||
type TabName = "profil" | "benachrichtigungen" | "caldav" | "dezernat";
|
||||
const TABS: TabName[] = ["profil", "benachrichtigungen", "caldav", "dezernat"];
|
||||
type TabName = "profil" | "benachrichtigungen" | "caldav";
|
||||
const TABS: TabName[] = ["profil", "benachrichtigungen", "caldav"];
|
||||
const DEFAULT_TAB: TabName = "profil";
|
||||
|
||||
let me: Me | null = null;
|
||||
@@ -116,7 +115,6 @@ function showTab(tab: TabName, pushHistory: boolean) {
|
||||
if (tab === "profil") void loadProfilTab();
|
||||
else if (tab === "benachrichtigungen") void loadPrefsTab();
|
||||
else if (tab === "caldav") void loadCalDAVTab();
|
||||
else if (tab === "dezernat") void loadDezernatTab();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +137,7 @@ async function loadProfilTab() {
|
||||
await Promise.all([fetchMe(), fetchOffices()]);
|
||||
renderOfficeOptions();
|
||||
fillProfilForm();
|
||||
void renderMyPartnerUnits();
|
||||
|
||||
loading.style.display = "none";
|
||||
form.style.display = "";
|
||||
@@ -191,7 +190,6 @@ function fillProfilForm() {
|
||||
(document.getElementById("profil-display-name") as HTMLInputElement).value = me.display_name;
|
||||
(document.getElementById("profil-office") as HTMLSelectElement).value = me.office;
|
||||
(document.getElementById("profil-role") as HTMLInputElement).value = me.job_title ?? "";
|
||||
(document.getElementById("profil-dezernat") as HTMLInputElement).value = me.dezernat ?? "";
|
||||
(document.getElementById("profil-lang") as HTMLSelectElement).value = me.lang || "de";
|
||||
}
|
||||
|
||||
@@ -204,7 +202,6 @@ async function saveProfil(ev: Event) {
|
||||
const displayName = (document.getElementById("profil-display-name") as HTMLInputElement).value.trim();
|
||||
const office = (document.getElementById("profil-office") as HTMLSelectElement).value;
|
||||
const jobTitle = (document.getElementById("profil-role") as HTMLInputElement).value.trim();
|
||||
const dezernat = (document.getElementById("profil-dezernat") as HTMLInputElement).value.trim();
|
||||
const lang = (document.getElementById("profil-lang") as HTMLSelectElement).value as Lang;
|
||||
|
||||
if (!displayName) {
|
||||
@@ -227,7 +224,6 @@ async function saveProfil(ev: Event) {
|
||||
display_name: displayName,
|
||||
office,
|
||||
job_title: jobTitle,
|
||||
dezernat,
|
||||
lang,
|
||||
};
|
||||
|
||||
@@ -282,7 +278,7 @@ function fillEscalationContactOptions() {
|
||||
// the server enforces it via a CHECK constraint. Sort by display_name
|
||||
// for a stable order (then email as tiebreaker for users without a
|
||||
// display name).
|
||||
const candidates = dezernatUserOptions
|
||||
const candidates = userOptions
|
||||
.filter((u) => u.id !== me!.id)
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
@@ -361,7 +357,7 @@ async function savePrefs(ev: Event) {
|
||||
);
|
||||
// "" = clear back to the global_admins fallback; UUID = explicit
|
||||
// escalation contact. The server's UpdateProfileInput uses *string with
|
||||
// empty-string-as-clear semantics (matches the Dezernat field).
|
||||
// empty-string-as-clear semantics for nullable references.
|
||||
const escalationContact = (document.getElementById("prefs-escalation-contact") as HTMLSelectElement).value;
|
||||
|
||||
if (!morning || !evening) {
|
||||
@@ -600,341 +596,69 @@ async function deleteCalDAVConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dezernat tab -----------------------------------------------------------
|
||||
// --- "Meine Partner Units" card on the profile tab -------------------------
|
||||
//
|
||||
// Read-only summary of the current user's structural memberships. Membership
|
||||
// writes are admin-driven via /admin/partner-units.
|
||||
|
||||
interface Department {
|
||||
interface PartnerUnit {
|
||||
id: string;
|
||||
name: string;
|
||||
lead_user_id?: string | null;
|
||||
office: string;
|
||||
}
|
||||
|
||||
interface DezernatMember {
|
||||
user_id: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
office: string;
|
||||
job_title: string | null;
|
||||
}
|
||||
|
||||
let allDezernate: Dezernat[] = [];
|
||||
|
||||
async function loadDezernatTab(): Promise<void> {
|
||||
// Ensure we know who the caller is (role gate for admin controls).
|
||||
if (!me) {
|
||||
try {
|
||||
const resp = await fetch("/api/me");
|
||||
if (resp.ok) me = (await resp.json()) as Me;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
const adminSection = document.getElementById("dezernat-admin-section")!;
|
||||
if (me && me.global_role === "global_admin") {
|
||||
adminSection.style.display = "";
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/departments");
|
||||
if (!resp.ok) return;
|
||||
allDezernate = (await resp.json()) as Dezernat[];
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
await renderMyDezernat();
|
||||
renderDezernatAdminTable();
|
||||
|
||||
// Populate the office select in the admin create form with the same
|
||||
// office list used on the profile tab. If offices haven't been loaded
|
||||
// yet (admin arrives here first), fetch them now.
|
||||
if (!offices.length) {
|
||||
try {
|
||||
const resp = await fetch("/api/offices");
|
||||
if (resp.ok) offices = (await resp.json()) as Office[];
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
const officeSel = document.getElementById("dezernat-new-office") as HTMLSelectElement | null;
|
||||
if (officeSel) {
|
||||
officeSel.innerHTML = offices
|
||||
.map((o) => {
|
||||
const label = getLang() === "de" ? o.label_de : o.label_en;
|
||||
return `<option value="${esc(o.key)}">${esc(label)}</option>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
const form = document.getElementById("dezernat-new-form") as HTMLFormElement | null;
|
||||
if (form) form.addEventListener("submit", submitNewDezernat);
|
||||
}
|
||||
|
||||
async function renderMyDezernat(): Promise<void> {
|
||||
const container = document.getElementById("dezernat-my")!;
|
||||
if (!me) {
|
||||
container.innerHTML = `<p class="form-hint">${esc(t("dezernat.login_required") || "Login required")}</p>`;
|
||||
return;
|
||||
}
|
||||
// Find dezernate that have the user as member via the members endpoint
|
||||
// for each — cheap for typical firm size (<20 dezernate).
|
||||
const myDezernate: Dezernat[] = [];
|
||||
for (const d of allDezernate) {
|
||||
try {
|
||||
const resp = await fetch(`/api/departments/${encodeURIComponent(d.id)}/members`);
|
||||
if (!resp.ok) continue;
|
||||
const members = (await resp.json()) as DezernatMember[];
|
||||
if (members.some((m) => m.user_id === me!.id)) {
|
||||
myDezernate.push(d);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (!myDezernate.length) {
|
||||
container.innerHTML = `<p class="form-hint">${esc(t("dezernat.none") || "Sie geh\u00f6ren noch keinem Dezernat an.")}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const d of myDezernate) {
|
||||
const resp = await fetch(`/api/departments/${encodeURIComponent(d.id)}/members`);
|
||||
const members = resp.ok ? ((await resp.json()) as DezernatMember[]) : [];
|
||||
const leadName = members.find((m) => m.user_id === d.lead_user_id)?.display_name;
|
||||
parts.push(`<div class="dezernat-card">
|
||||
<h3>${esc(d.name)}</h3>
|
||||
<p class="form-hint">${esc(t("office." + d.office) || d.office)}${leadName ? ` · <strong>${esc(leadName)}</strong>` : ""}</p>
|
||||
<p class="form-hint"><strong>${members.length}</strong> ${esc(t("dezernat.members_label") || "Mitglieder")}</p>
|
||||
<ul class="dezernat-member-list">
|
||||
${members
|
||||
.map((m) => `<li>${esc(m.display_name)} <span class="form-hint">(${esc(m.email)})</span></li>`)
|
||||
.join("")}
|
||||
</ul>
|
||||
</div>`);
|
||||
}
|
||||
container.innerHTML = parts.join("");
|
||||
}
|
||||
|
||||
interface UserOption {
|
||||
id: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
let dezernatUserOptions: UserOption[] = [];
|
||||
let userOptions: UserOption[] = [];
|
||||
|
||||
async function loadUserOptions(): Promise<void> {
|
||||
if (dezernatUserOptions.length) return;
|
||||
if (userOptions.length) return;
|
||||
try {
|
||||
const resp = await fetch("/api/users");
|
||||
if (resp.ok) dezernatUserOptions = (await resp.json()) as UserOption[];
|
||||
if (resp.ok) userOptions = (await resp.json()) as UserOption[];
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function renderDezernatAdminTable(): void {
|
||||
const body = document.getElementById("dezernat-admin-body");
|
||||
if (!body) return;
|
||||
body.innerHTML = allDezernate
|
||||
.flatMap((d) => [
|
||||
`<tr class="dezernat-row" data-id="${esc(d.id)}">
|
||||
<td>${esc(d.name)}</td>
|
||||
<td>${esc(t("office." + d.office) || d.office)}</td>
|
||||
<td>${d.lead_user_id ? esc(d.lead_user_id) : "—"}</td>
|
||||
<td><button type="button" class="btn-ghost dezernat-manage-btn" data-id="${esc(d.id)}">${esc(t("dezernat.manage") || "Verwalten")}</button></td>
|
||||
<td><button type="button" class="btn-danger dezernat-delete-btn" data-id="${esc(d.id)}">${esc(t("dezernat.delete") || "L\u00f6schen")}</button></td>
|
||||
</tr>`,
|
||||
`<tr class="dezernat-manage-row" id="dezernat-manage-${esc(d.id)}" style="display:none">
|
||||
<td colspan="5">
|
||||
<div class="dezernat-manage-panel" data-dezernat="${esc(d.id)}">
|
||||
<h4>${esc(t("dezernat.manage_heading") || "Mitglieder verwalten")} — ${esc(d.name)}</h4>
|
||||
<ul class="dezernat-member-list" id="dezernat-members-${esc(d.id)}">
|
||||
<li class="form-hint">${esc(t("dezernat.loading") || "L\u00e4dt\u2026")}</li>
|
||||
</ul>
|
||||
<form class="dezernat-add-form" data-dezernat="${esc(d.id)}" autocomplete="off">
|
||||
<div class="form-field">
|
||||
<label>${esc(t("dezernat.add_member") || "Mitglied hinzuf\u00fcgen")}</label>
|
||||
<input type="text" class="dezernat-user-input" placeholder="${escAttr(t("dezernat.add_member.placeholder") || "Name oder E-Mail")}" />
|
||||
<input type="hidden" class="dezernat-user-id" />
|
||||
<div class="akten-collab-suggestions dezernat-user-suggestions"></div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary btn-cta-lime btn-small">${esc(t("dezernat.add") || "Hinzuf\u00fcgen")}</button>
|
||||
</div>
|
||||
<p class="form-msg dezernat-add-msg"></p>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`,
|
||||
])
|
||||
.join("");
|
||||
|
||||
body.querySelectorAll<HTMLButtonElement>(".dezernat-delete-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const id = btn.dataset.id!;
|
||||
if (!window.confirm(t("dezernat.confirm_delete") || "Dezernat wirklich l\u00f6schen?")) return;
|
||||
const resp = await fetch(`/api/departments/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
if (resp.ok) {
|
||||
allDezernate = allDezernate.filter((d) => d.id !== id);
|
||||
renderDezernatAdminTable();
|
||||
void renderMyDezernat();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
body.querySelectorAll<HTMLButtonElement>(".dezernat-manage-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const id = btn.dataset.id!;
|
||||
const row = document.getElementById(`dezernat-manage-${id}`);
|
||||
if (!row) return;
|
||||
if (row.style.display === "none") {
|
||||
row.style.display = "";
|
||||
await loadUserOptions();
|
||||
await loadAndRenderDezernatMembers(id);
|
||||
wireDezernatAddForm(id);
|
||||
} else {
|
||||
row.style.display = "none";
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadAndRenderDezernatMembers(dezernatID: string): Promise<void> {
|
||||
const ul = document.getElementById(`dezernat-members-${dezernatID}`);
|
||||
if (!ul) return;
|
||||
async function renderMyPartnerUnits(): Promise<void> {
|
||||
const container = document.getElementById("partner-unit-my");
|
||||
if (!container || !me) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/departments/${encodeURIComponent(dezernatID)}/members`);
|
||||
const resp = await fetch("/api/partner-units?include=members");
|
||||
if (!resp.ok) return;
|
||||
const members = (await resp.json()) as DezernatMember[];
|
||||
if (!members.length) {
|
||||
ul.innerHTML = `<li class="form-hint">${esc(t("dezernat.no_members") || "Noch keine Mitglieder.")}</li>`;
|
||||
type PUWithMembers = PartnerUnit & {
|
||||
lead_display_name?: string;
|
||||
members: { user_id: string; display_name: string; email: string }[];
|
||||
};
|
||||
const all = (await resp.json()) as PUWithMembers[];
|
||||
const mine = all.filter((u) => u.members.some((m) => m.user_id === me!.id));
|
||||
|
||||
if (!mine.length) {
|
||||
container.innerHTML = `<p class="form-hint">${esc(t("partner_unit.none") || "Sie sind noch keiner Partner Unit zugeordnet.")}</p>`;
|
||||
return;
|
||||
}
|
||||
ul.innerHTML = members
|
||||
container.innerHTML = mine
|
||||
.map(
|
||||
(m) => `<li class="dezernat-member-item">
|
||||
<span>${esc(m.display_name || m.email)} <span class="form-hint">(${esc(m.email)})</span></span>
|
||||
<button type="button" class="btn-ghost btn-small dezernat-remove-btn" data-dezernat="${esc(dezernatID)}" data-user="${esc(m.user_id)}">${esc(t("dezernat.remove") || "Entfernen")}</button>
|
||||
</li>`,
|
||||
)
|
||||
.join("");
|
||||
ul.querySelectorAll<HTMLButtonElement>(".dezernat-remove-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const did = btn.dataset.dezernat!;
|
||||
const uid = btn.dataset.user!;
|
||||
if (!window.confirm(t("dezernat.confirm_remove") || "Mitglied entfernen?")) return;
|
||||
const r = await fetch(
|
||||
`/api/departments/${encodeURIComponent(did)}/members/${encodeURIComponent(uid)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (r.ok) {
|
||||
await loadAndRenderDezernatMembers(did);
|
||||
void renderMyDezernat();
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function wireDezernatAddForm(dezernatID: string): void {
|
||||
const form = document.querySelector<HTMLFormElement>(
|
||||
`.dezernat-add-form[data-dezernat="${dezernatID}"]`,
|
||||
);
|
||||
if (!form || form.dataset.wired === "1") return;
|
||||
form.dataset.wired = "1";
|
||||
|
||||
const input = form.querySelector<HTMLInputElement>(".dezernat-user-input")!;
|
||||
const hidden = form.querySelector<HTMLInputElement>(".dezernat-user-id")!;
|
||||
const sugs = form.querySelector<HTMLDivElement>(".dezernat-user-suggestions")!;
|
||||
const msg = form.querySelector<HTMLParagraphElement>(".dezernat-add-msg")!;
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
const q = input.value.trim().toLowerCase();
|
||||
hidden.value = "";
|
||||
if (!q) {
|
||||
sugs.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
const matches = dezernatUserOptions
|
||||
.filter((u) => (u.display_name + " " + u.email).toLowerCase().includes(q))
|
||||
.slice(0, 8);
|
||||
sugs.innerHTML = matches
|
||||
.map(
|
||||
(u) => `<div class="akten-collab-suggestion" data-id="${esc(u.id)}" data-label="${escAttr(u.display_name || u.email)}">
|
||||
<strong>${esc(u.display_name || u.email)}</strong>
|
||||
<span class="form-hint">${esc(u.email)}</span>
|
||||
(u) => `<div class="partner-unit-card">
|
||||
<h3>${esc(u.name)}</h3>
|
||||
<p class="form-hint">${esc(t("office." + u.office) || u.office)}${u.lead_display_name ? ` · <strong>${esc(u.lead_display_name)}</strong>` : ""}</p>
|
||||
<p class="form-hint"><strong>${u.members.length}</strong> ${esc(t("partner_unit.members_label") || "Mitglieder")}</p>
|
||||
<ul class="partner-unit-member-list">
|
||||
${u.members
|
||||
.map((m) => `<li>${esc(m.display_name)} <span class="form-hint">(${esc(m.email)})</span></li>`)
|
||||
.join("")}
|
||||
</ul>
|
||||
</div>`,
|
||||
)
|
||||
.join("");
|
||||
sugs.querySelectorAll<HTMLDivElement>(".akten-collab-suggestion").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
hidden.value = el.dataset.id!;
|
||||
input.value = el.dataset.label!;
|
||||
sugs.innerHTML = "";
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
msg.textContent = "";
|
||||
if (!hidden.value) {
|
||||
msg.textContent = t("dezernat.error.user_required") || "Benutzer ausw\u00e4hlen";
|
||||
return;
|
||||
}
|
||||
const resp = await fetch(`/api/departments/${encodeURIComponent(dezernatID)}/members`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_id: hidden.value }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const b = await resp.json().catch(() => ({ error: "unknown" }));
|
||||
msg.textContent = b.error || "failed";
|
||||
return;
|
||||
}
|
||||
input.value = "";
|
||||
hidden.value = "";
|
||||
sugs.innerHTML = "";
|
||||
await loadAndRenderDezernatMembers(dezernatID);
|
||||
void renderMyDezernat();
|
||||
});
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
async function submitNewDezernat(e: Event): Promise<void> {
|
||||
e.preventDefault();
|
||||
const msg = document.getElementById("dezernat-new-msg") as HTMLParagraphElement;
|
||||
const name = (document.getElementById("dezernat-new-name") as HTMLInputElement).value.trim();
|
||||
const office = (document.getElementById("dezernat-new-office") as HTMLSelectElement).value;
|
||||
msg.textContent = "";
|
||||
if (!name) {
|
||||
msg.textContent = t("dezernat.error.name_required") || "Name required";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch("/api/departments", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, office }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: "unknown" }));
|
||||
msg.textContent = body.error || "failed";
|
||||
return;
|
||||
}
|
||||
const d = (await resp.json()) as Dezernat;
|
||||
allDezernate.push(d);
|
||||
(document.getElementById("dezernat-new-name") as HTMLInputElement).value = "";
|
||||
renderDezernatAdminTable();
|
||||
} catch (err) {
|
||||
msg.textContent = String(err);
|
||||
} catch {
|
||||
// ignore — leave previous render
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ interface User {
|
||||
office: string;
|
||||
additional_offices?: string[];
|
||||
role: string;
|
||||
dezernat?: string;
|
||||
}
|
||||
|
||||
interface DepartmentMember {
|
||||
@@ -60,7 +59,7 @@ function initials(name: string): string {
|
||||
async function loadAll() {
|
||||
const [usersResp, deptsResp] = await Promise.all([
|
||||
fetch("/api/users"),
|
||||
fetch("/api/departments?include=members"),
|
||||
fetch("/api/partner-units?include=members"),
|
||||
]);
|
||||
if (usersResp.ok) users = (await usersResp.json()) as User[];
|
||||
if (deptsResp.ok) departments = (await deptsResp.json()) as Department[];
|
||||
@@ -103,7 +102,6 @@ function userMatchesSearch(u: User): boolean {
|
||||
u.role,
|
||||
u.office,
|
||||
officeLabel(u.office),
|
||||
u.dezernat ?? "",
|
||||
...(u.additional_offices ?? []).map(officeLabel),
|
||||
]
|
||||
.join(" ")
|
||||
@@ -122,7 +120,6 @@ function memberAsUser(m: DepartmentMember): User | undefined {
|
||||
}
|
||||
|
||||
function renderUserCard(u: User): string {
|
||||
const dept = u.dezernat ?? "";
|
||||
const additional = (u.additional_offices ?? []).filter((o) => o !== u.office);
|
||||
return `
|
||||
<article class="team-card">
|
||||
@@ -133,7 +130,6 @@ function renderUserCard(u: User): string {
|
||||
<div class="team-card-meta">
|
||||
<span class="team-office-badge">${ICON_PIN}<span>${esc(officeLabel(u.office))}</span></span>
|
||||
${additional.length ? `<span class="team-office-extra">+ ${additional.map((o) => esc(officeLabel(o))).join(", ")}</span>` : ""}
|
||||
${dept ? `<span class="team-dept-tag">${esc(dept)}</span>` : ""}
|
||||
</div>
|
||||
<a class="team-card-email" href="mailto:${esc(u.email)}">${ICON_MAIL}<span>${esc(u.email)}</span></a>
|
||||
</div>
|
||||
@@ -199,33 +195,20 @@ function renderGroupByDepartment(filtered: User[]): string {
|
||||
</section>`);
|
||||
}
|
||||
|
||||
// Users not in any department (fall back: free-text dezernat field, or none).
|
||||
// Users not in any partner unit get a single "ohne Partner Unit" bucket so
|
||||
// they're still discoverable in the directory.
|
||||
const inAnyDept = new Set<string>();
|
||||
for (const d of departments) for (const m of d.members) inAnyDept.add(m.user_id);
|
||||
const looseGroups = new Map<string, User[]>();
|
||||
for (const u of filtered) {
|
||||
if (inAnyDept.has(u.id)) continue;
|
||||
const key = (u.dezernat ?? "").trim() || "__none__";
|
||||
if (!looseGroups.has(key)) looseGroups.set(key, []);
|
||||
looseGroups.get(key)!.push(u);
|
||||
}
|
||||
const looseKeys = Array.from(looseGroups.keys()).sort((a, b) => {
|
||||
if (a === "__none__") return 1;
|
||||
if (b === "__none__") return -1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
for (const key of looseKeys) {
|
||||
const list = looseGroups.get(key)!;
|
||||
const heading = key === "__none__"
|
||||
? (t("team.dept.unassigned") || "Ohne Dezernat")
|
||||
: key;
|
||||
const orphans = filtered.filter((u) => !inAnyDept.has(u.id));
|
||||
if (orphans.length) {
|
||||
const heading = t("team.partner_unit.unassigned") || "Ohne Partner Unit";
|
||||
sections.push(`
|
||||
<section class="team-group team-group-loose">
|
||||
<header class="team-group-header">
|
||||
<h2>${esc(heading)}</h2>
|
||||
<span class="team-group-count">${list.length}</span>
|
||||
<span class="team-group-count">${orphans.length}</span>
|
||||
</header>
|
||||
<div class="team-grid">${list.map(renderUserCard).join("")}</div>
|
||||
<div class="team-grid">${orphans.map(renderUserCard).join("")}</div>
|
||||
</section>`);
|
||||
}
|
||||
|
||||
|
||||
@@ -143,6 +143,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
<div className="sidebar-group-label" data-i18n="nav.group.admin">Admin</div>
|
||||
{navItem("/admin", ICON_SHIELD, "nav.admin.bereich", "Admin-Bereich", currentPath)}
|
||||
{navItem("/admin/team", ICON_USERS, "nav.admin.team", "Team-Verwaltung", currentPath)}
|
||||
{navItem("/admin/partner-units", ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)}
|
||||
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -71,18 +71,17 @@ export function renderOnboarding(): string {
|
||||
<option value="Sekretariat"></option>
|
||||
</datalist>
|
||||
|
||||
<label htmlFor="onb-dezernat" className="login-label" data-i18n="onboarding.dezernat">
|
||||
Dezernat / Partner <span className="login-label-optional" data-i18n="onboarding.optional">(optional)</span>
|
||||
<label htmlFor="onb-partner-unit" className="login-label" data-i18n="onboarding.partner_unit">
|
||||
Partner Unit <span className="login-label-optional" data-i18n="onboarding.optional">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="onb-dezernat"
|
||||
name="dezernat"
|
||||
autocomplete="off"
|
||||
<select
|
||||
id="onb-partner-unit"
|
||||
name="partner_unit_id"
|
||||
className="login-input"
|
||||
data-i18n-placeholder="onboarding.dezernat.placeholder"
|
||||
placeholder="z.B. Dr. Müller, Team Schmidt"
|
||||
/>
|
||||
>
|
||||
{/* Options populated from /api/partner-units at init. */}
|
||||
<option value="" data-i18n="onboarding.partner_unit.unassigned">(noch keine Zuordnung)</option>
|
||||
</select>
|
||||
|
||||
<button type="submit" className="login-button" data-i18n="onboarding.submit">Profil anlegen</button>
|
||||
</form>
|
||||
|
||||
@@ -39,7 +39,6 @@ export function renderSettings(): string {
|
||||
<a className="akten-tab" data-tab="profil" href="?tab=profil" data-i18n="einstellungen.tab.profil">Profil</a>
|
||||
<a className="akten-tab" data-tab="benachrichtigungen" href="?tab=benachrichtigungen" data-i18n="einstellungen.tab.benachrichtigungen">Benachrichtigungen</a>
|
||||
<a className="akten-tab" data-tab="caldav" href="?tab=caldav" data-i18n="einstellungen.tab.caldav">CalDAV</a>
|
||||
<a className="akten-tab" data-tab="dezernat" href="?tab=dezernat" data-i18n="einstellungen.tab.dezernat">Dezernat</a>
|
||||
</nav>
|
||||
|
||||
{/* --- Profil tab ---------------------------------------- */}
|
||||
@@ -100,19 +99,6 @@ export function renderSettings(): string {
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="profil-dezernat" data-i18n="einstellungen.profil.dezernat">
|
||||
Dezernat oder Partner <span className="login-label-optional" data-i18n="einstellungen.optional">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="profil-dezernat"
|
||||
autocomplete="off"
|
||||
data-i18n-placeholder="einstellungen.profil.dezernat.placeholder"
|
||||
placeholder="z.B. Dr. Müller, Team Schmidt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="profil-lang" data-i18n="einstellungen.profil.lang">Sprache</label>
|
||||
<select id="profil-lang">
|
||||
@@ -130,6 +116,18 @@ export function renderSettings(): string {
|
||||
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="einstellungen.save">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* "Meine Partner Units" — read-only summary of the current
|
||||
user's structural memberships. Membership writes are
|
||||
admin-driven via /admin/partner-units; this surface just
|
||||
shows what the user is in today. */}
|
||||
<div className="caldav-info-card partner-unit-my-card" id="profil-partner-units">
|
||||
<h2 data-i18n="partner_unit.heading">Meine Partner Units</h2>
|
||||
<p className="form-hint" data-i18n="partner_unit.subtitle">
|
||||
Partner Units sind strukturelle Einheiten — getrennt von Projektteams. Mitgliedschaft wird vom Admin verwaltet.
|
||||
</p>
|
||||
<div id="partner-unit-my" className="partner-unit-my" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* --- Benachrichtigungen tab ---------------------------- */}
|
||||
@@ -343,54 +341,6 @@ export function renderSettings(): string {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* --- Dezernat tab ------------------------------------- */}
|
||||
<section className="akten-tab-panel" id="tab-dezernat" style="display:none">
|
||||
<div className="caldav-info-card">
|
||||
<h2 data-i18n="dezernat.heading">Mein Dezernat</h2>
|
||||
<p className="form-hint" data-i18n="dezernat.subtitle">
|
||||
Dezernate sind strukturelle Partnereinheiten — separat von projektspezifischen Teams.
|
||||
</p>
|
||||
<div id="dezernat-my" className="dezernat-my" />
|
||||
</div>
|
||||
|
||||
<div className="caldav-info-card" id="dezernat-admin-section" style="display:none">
|
||||
<h2 data-i18n="dezernat.admin.heading">Dezernate verwalten (Admin)</h2>
|
||||
<div className="akten-table-wrap">
|
||||
<table className="akten-table" id="dezernat-admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="dezernat.col.name">Name</th>
|
||||
<th data-i18n="dezernat.col.office">Büro</th>
|
||||
<th data-i18n="dezernat.col.lead">Partner</th>
|
||||
<th data-i18n="dezernat.col.members">Mitglieder</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dezernat-admin-body" />
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<details className="dezernat-new-wrap">
|
||||
<summary className="btn-ghost" data-i18n="dezernat.admin.new">Neues Dezernat anlegen</summary>
|
||||
<form id="dezernat-new-form" className="akten-form">
|
||||
<div className="form-field-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="dezernat-new-name" data-i18n="dezernat.field.name">Name</label>
|
||||
<input type="text" id="dezernat-new-name" required />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="dezernat-new-office" data-i18n="dezernat.field.office">Büro</label>
|
||||
<select id="dezernat-new-office" required />
|
||||
</div>
|
||||
</div>
|
||||
<p className="form-msg" id="dezernat-new-msg" />
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="dezernat.admin.create">Anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -28,7 +28,7 @@ export function renderTeam(): string {
|
||||
<div>
|
||||
<h1 data-i18n="team.heading">Team</h1>
|
||||
<p className="tool-subtitle" data-i18n="team.subtitle">
|
||||
Alle Paliad-Kolleg:innen, gruppiert nach Standort oder Dezernat.
|
||||
Alle Paliad-Kolleg:innen, gruppiert nach Standort oder Partner Unit.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,7 +55,7 @@ export function renderTeam(): string {
|
||||
Nach Standort
|
||||
</button>
|
||||
<button className="filter-pill" data-group="department" type="button" data-i18n="team.group.department">
|
||||
Nach Dezernat
|
||||
Nach Partner Unit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
48
internal/db/migrations/027_rename_to_partner_units.down.sql
Normal file
48
internal/db/migrations/027_rename_to_partner_units.down.sql
Normal file
@@ -0,0 +1,48 @@
|
||||
-- Down migration for 026: revert the partner_units rename.
|
||||
--
|
||||
-- Note: paliad.users.dezernat values cannot be perfectly restored. The
|
||||
-- column is recreated as NULL; structural data (membership rows) is
|
||||
-- preserved. Per design doc §7.3 — if a true free-text rollback is ever
|
||||
-- needed, an admin script can reconstruct values from
|
||||
-- partner_unit_members:
|
||||
--
|
||||
-- UPDATE paliad.users u SET dezernat = (
|
||||
-- SELECT pu.name FROM paliad.partner_units pu
|
||||
-- JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = pu.id
|
||||
-- WHERE pum.user_id = u.id LIMIT 1)
|
||||
-- WHERE u.dezernat IS NULL;
|
||||
--
|
||||
-- That step is not auto-run because most rollbacks are recoveries, not
|
||||
-- data restorations.
|
||||
|
||||
-- 1. Drop the audit table.
|
||||
DROP TABLE IF EXISTS paliad.partner_unit_events;
|
||||
|
||||
-- 2. Rename RLS policies back.
|
||||
DO $$ BEGIN ALTER POLICY partner_units_select ON paliad.partner_units RENAME TO departments_select; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY partner_units_write ON paliad.partner_units RENAME TO departments_write; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY partner_unit_members_select ON paliad.partner_unit_members RENAME TO department_members_select; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY partner_unit_members_write ON paliad.partner_unit_members RENAME TO department_members_write; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
-- 3. Rename indexes back.
|
||||
DO $$ BEGIN ALTER INDEX paliad.partner_units_office_idx RENAME TO departments_office_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.partner_units_lead_idx RENAME TO departments_lead_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.partner_unit_members_user_idx RENAME TO department_members_user_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
-- 4. Rename constraints back.
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT partner_units_pkey TO departments_pkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT partner_units_lead_user_id_fkey TO departments_lead_user_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT partner_units_office_check TO departments_office_check; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT partner_unit_members_pkey TO department_members_pkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT partner_unit_members_partner_unit_id_fkey TO department_members_department_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT partner_unit_members_user_id_fkey TO department_members_user_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
-- 5. Rename junction column back.
|
||||
ALTER TABLE paliad.partner_unit_members RENAME COLUMN partner_unit_id TO department_id;
|
||||
|
||||
-- 6. Rename tables back.
|
||||
ALTER TABLE paliad.partner_unit_members RENAME TO department_members;
|
||||
ALTER TABLE paliad.partner_units RENAME TO departments;
|
||||
|
||||
-- 7. Recreate the legacy free-text column. Values are NULL.
|
||||
ALTER TABLE paliad.users ADD COLUMN IF NOT EXISTS dezernat text;
|
||||
131
internal/db/migrations/027_rename_to_partner_units.up.sql
Normal file
131
internal/db/migrations/027_rename_to_partner_units.up.sql
Normal file
@@ -0,0 +1,131 @@
|
||||
-- t-paliad-070: Rename departments → partner_units across the schema, drop
|
||||
-- the legacy users.dezernat free-text column, and add the
|
||||
-- partner_unit_events audit table.
|
||||
--
|
||||
-- Order of operations matters because constraint names are owned by their
|
||||
-- owning table; we rename the table first, then the columns/constraints/
|
||||
-- policies that postgres did not auto-rename.
|
||||
--
|
||||
-- Idempotent renames (DO $$ EXCEPTION WHEN undefined_object $$) are used
|
||||
-- for constraint/index/policy steps so re-runs after a partial apply on
|
||||
-- freshly-provisioned DBs (e.g. test DBs that may have run earlier
|
||||
-- migrations under different names) do not abort the chain.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1. Best-effort second seed of department_members from the legacy
|
||||
-- users.dezernat free-text field. Mirror of migration 019 — re-run before
|
||||
-- DROP COLUMN to capture any drift since 019 ran. Idempotent via
|
||||
-- ON CONFLICT DO NOTHING.
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO paliad.departments (id, name, lead_user_id, office, created_at, updated_at)
|
||||
SELECT gen_random_uuid(),
|
||||
btrim(u.dezernat),
|
||||
NULL,
|
||||
MIN(u.office),
|
||||
now(),
|
||||
now()
|
||||
FROM paliad.users u
|
||||
WHERE u.dezernat IS NOT NULL
|
||||
AND btrim(u.dezernat) <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM paliad.departments d2 WHERE d2.name = btrim(u.dezernat)
|
||||
)
|
||||
GROUP BY btrim(u.dezernat)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO paliad.department_members (department_id, user_id, created_at)
|
||||
SELECT d.id, u.id, now()
|
||||
FROM paliad.users u
|
||||
JOIN paliad.departments d
|
||||
ON d.name = btrim(u.dezernat)
|
||||
WHERE u.dezernat IS NOT NULL
|
||||
AND btrim(u.dezernat) <> ''
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2. Drop the legacy free-text column. The structured side
|
||||
-- (department_members) is the source of truth from here on.
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE paliad.users DROP COLUMN IF EXISTS dezernat;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3. Rename tables.
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE paliad.departments RENAME TO partner_units;
|
||||
ALTER TABLE paliad.department_members RENAME TO partner_unit_members;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 4. Rename junction column.
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE paliad.partner_unit_members RENAME COLUMN department_id TO partner_unit_id;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 5. Rename constraints. Postgres auto-renames the underlying index for
|
||||
-- pkey/uniq constraints; standalone indexes are renamed in step 6.
|
||||
-- ---------------------------------------------------------------------------
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_pkey TO partner_units_pkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_lead_user_id_fkey TO partner_units_lead_user_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_office_check TO partner_units_office_check; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_pkey TO partner_unit_members_pkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_department_id_fkey TO partner_unit_members_partner_unit_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_user_id_fkey TO partner_unit_members_user_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 6. Rename non-pkey indexes.
|
||||
-- ---------------------------------------------------------------------------
|
||||
DO $$ BEGIN ALTER INDEX paliad.departments_office_idx RENAME TO partner_units_office_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.departments_lead_idx RENAME TO partner_units_lead_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER INDEX paliad.department_members_user_idx RENAME TO partner_unit_members_user_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 7. Rename RLS policies.
|
||||
-- ---------------------------------------------------------------------------
|
||||
DO $$ BEGIN ALTER POLICY departments_select ON paliad.partner_units RENAME TO partner_units_select; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY departments_write ON paliad.partner_units RENAME TO partner_units_write; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY department_members_select ON paliad.partner_unit_members RENAME TO partner_unit_members_select; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER POLICY department_members_write ON paliad.partner_unit_members RENAME TO partner_unit_members_write; EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 8. Audit table for partner-unit events. Mutations on partner_units +
|
||||
-- partner_unit_members emit one row each, written in the same tx by
|
||||
-- PartnerUnitService. The viewer in audit_service.go unions this source
|
||||
-- in alongside project_events / caldav_sync_log / reminder_log.
|
||||
--
|
||||
-- partner_unit_id is nullable + ON DELETE SET NULL so the historical
|
||||
-- 'deleted' event survives the cascade that removes the unit row.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE paliad.partner_unit_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
partner_unit_id uuid NULL REFERENCES paliad.partner_units(id) ON DELETE SET NULL,
|
||||
actor_id uuid NOT NULL REFERENCES auth.users(id),
|
||||
event_type text NOT NULL CHECK (event_type IN (
|
||||
'created', 'updated', 'deleted', 'member_added', 'member_removed'
|
||||
)),
|
||||
-- Snapshot of the unit's name at event time so deleted units still show
|
||||
-- a human-readable label in the audit timeline (partner_unit_id is NULL
|
||||
-- on deleted, so we can't JOIN through to partner_units.name).
|
||||
unit_name text NOT NULL,
|
||||
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX partner_unit_events_unit_idx ON paliad.partner_unit_events(partner_unit_id, created_at DESC);
|
||||
CREATE INDEX partner_unit_events_actor_idx ON paliad.partner_unit_events(actor_id, created_at DESC);
|
||||
CREATE INDEX partner_unit_events_time_idx ON paliad.partner_unit_events(created_at DESC);
|
||||
|
||||
-- RLS: read access matches /api/partner-units (any authenticated user);
|
||||
-- writes only by global_admin (defence-in-depth — the service already
|
||||
-- gates with requireAdmin).
|
||||
ALTER TABLE paliad.partner_unit_events ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY partner_unit_events_select ON paliad.partner_unit_events
|
||||
FOR SELECT USING (auth.uid() IS NOT NULL);
|
||||
|
||||
CREATE POLICY partner_unit_events_write ON paliad.partner_unit_events
|
||||
FOR INSERT WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid()
|
||||
AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
@@ -162,3 +162,9 @@ func handleAdminTeamPage(w http.ResponseWriter, r *http.Request) {
|
||||
func handleAdminIndexPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/admin.html")
|
||||
}
|
||||
|
||||
// handleAdminPartnerUnitsPage serves the SPA shell for /admin/partner-units.
|
||||
// Same gate pattern as the other /admin/* pages.
|
||||
func handleAdminPartnerUnitsPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/admin-partner-units.html")
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func handleAppointmentsCalendarPage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// handleSettingsPage serves the unified settings page with tabs for
|
||||
// Profil / Benachrichtigungen / CalDAV / Dezernat. The active tab is picked
|
||||
// Profil / Benachrichtigungen / CalDAV. The active tab is picked
|
||||
// client-side from ?tab=<name> so switching tabs doesn't round-trip.
|
||||
func handleSettingsPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/settings.html")
|
||||
@@ -32,17 +32,15 @@ func handleSettingsPage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// settingsTabAliases maps every supported /settings/<slug> deep-link to its
|
||||
// canonical ?tab=<name> value the client TS understands. Both the German tab
|
||||
// IDs (profil/benachrichtigungen/dezernat) and intuitive English aliases
|
||||
// (profile/notifications/department) are accepted so bookmarks, smoke tests,
|
||||
// and manually-typed URLs all land on the right tab.
|
||||
// IDs (profil/benachrichtigungen) and intuitive English aliases
|
||||
// (profile/notifications) are accepted so bookmarks, smoke tests, and
|
||||
// manually-typed URLs all land on the right tab.
|
||||
var settingsTabAliases = map[string]string{
|
||||
"profil": "profil",
|
||||
"profile": "profil",
|
||||
"benachrichtigungen": "benachrichtigungen",
|
||||
"notifications": "benachrichtigungen",
|
||||
"caldav": "caldav",
|
||||
"dezernat": "dezernat",
|
||||
"department": "dezernat",
|
||||
}
|
||||
|
||||
// handleSettingsTabRedirect turns /settings/<slug> into /settings?tab=<canonical>
|
||||
|
||||
@@ -16,12 +16,14 @@ import (
|
||||
// auth.RequireAdminFunc in handlers.go, so the in-handler logic can assume
|
||||
// the caller is a global_admin and only validate the request shape.
|
||||
|
||||
// GET /api/audit-log — paginated, filterable timeline across paliad's three
|
||||
// audit sources (project_events, caldav_sync_log, reminder_log).
|
||||
// GET /api/audit-log — paginated, filterable timeline across paliad's four
|
||||
// audit sources (project_events, caldav_sync_log, reminder_log,
|
||||
// partner_unit_events).
|
||||
//
|
||||
// Query params:
|
||||
//
|
||||
// source — one of project_events, caldav_sync_log, reminder_log; empty = all
|
||||
// source — one of project_events, caldav_sync_log, reminder_log,
|
||||
// partner_unit_events; empty = all
|
||||
// from — ISO-8601 timestamp, inclusive lower bound
|
||||
// to — ISO-8601 timestamp, inclusive upper bound
|
||||
// q — free-text search (subject, description, title, event_type, actor)
|
||||
@@ -47,7 +49,11 @@ func handleListAuditLog(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
switch filter.Source {
|
||||
case "", services.AuditSourceProjectEvents, services.AuditSourceCalDAVLog, services.AuditSourceReminderLog:
|
||||
case "",
|
||||
services.AuditSourceProjectEvents,
|
||||
services.AuditSourceCalDAVLog,
|
||||
services.AuditSourceReminderLog,
|
||||
services.AuditSourcePartnerUnitEvents:
|
||||
// ok
|
||||
default:
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid source"})
|
||||
|
||||
@@ -38,7 +38,7 @@ func noCachePages(h http.Handler) http.Handler {
|
||||
type Services struct {
|
||||
Project *services.ProjectService
|
||||
Team *services.TeamService
|
||||
Department *services.DepartmentService
|
||||
PartnerUnit *services.PartnerUnitService
|
||||
Party *services.PartyService
|
||||
Deadline *services.DeadlineService
|
||||
Appointment *services.AppointmentService
|
||||
@@ -65,7 +65,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
dbSvc = &dbServices{
|
||||
projects: svc.Project,
|
||||
team: svc.Team,
|
||||
department: svc.Department,
|
||||
partnerUnit: svc.PartnerUnit,
|
||||
parties: svc.Party,
|
||||
deadline: svc.Deadline,
|
||||
appointment: svc.Appointment,
|
||||
@@ -180,15 +180,15 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("POST /api/projects/{id}/team", handleAddProjectTeamMember)
|
||||
protected.HandleFunc("DELETE /api/projects/{id}/team/{user_id}", handleRemoveProjectTeamMember)
|
||||
|
||||
// Departments (structural teams).
|
||||
protected.HandleFunc("GET /api/departments", handleListDepartments)
|
||||
protected.HandleFunc("POST /api/departments", handleCreateDepartment)
|
||||
protected.HandleFunc("GET /api/departments/{id}", handleGetDepartment)
|
||||
protected.HandleFunc("PATCH /api/departments/{id}", handleUpdateDepartment)
|
||||
protected.HandleFunc("DELETE /api/departments/{id}", handleDeleteDepartment)
|
||||
protected.HandleFunc("GET /api/departments/{id}/members", handleListDepartmentMembers)
|
||||
protected.HandleFunc("POST /api/departments/{id}/members", handleAddDepartmentMember)
|
||||
protected.HandleFunc("DELETE /api/departments/{id}/members/{user_id}", handleRemoveDepartmentMember)
|
||||
// Partner units (structural partner-led units; legacy "Dezernate").
|
||||
protected.HandleFunc("GET /api/partner-units", handleListPartnerUnits)
|
||||
protected.HandleFunc("POST /api/partner-units", handleCreatePartnerUnit)
|
||||
protected.HandleFunc("GET /api/partner-units/{id}", handleGetPartnerUnit)
|
||||
protected.HandleFunc("PATCH /api/partner-units/{id}", handleUpdatePartnerUnit)
|
||||
protected.HandleFunc("DELETE /api/partner-units/{id}", handleDeletePartnerUnit)
|
||||
protected.HandleFunc("GET /api/partner-units/{id}/members", handleListPartnerUnitMembers)
|
||||
protected.HandleFunc("POST /api/partner-units/{id}/members", handleAddPartnerUnitMember)
|
||||
protected.HandleFunc("DELETE /api/partner-units/{id}/members/{user_id}", handleRemovePartnerUnitMember)
|
||||
|
||||
protected.HandleFunc("DELETE /api/parties/{id}", handleDeleteParty)
|
||||
|
||||
@@ -302,6 +302,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /admin", adminGate(users, gateOnboarded(handleAdminIndexPage)))
|
||||
protected.HandleFunc("GET /admin/team", adminGate(users, gateOnboarded(handleAdminTeamPage)))
|
||||
protected.HandleFunc("GET /admin/audit-log", adminGate(users, gateOnboarded(handleAdminAuditLogPage)))
|
||||
protected.HandleFunc("GET /admin/partner-units", adminGate(users, gateOnboarded(handleAdminPartnerUnitsPage)))
|
||||
protected.HandleFunc("GET /admin/email-templates", adminGate(users, gateOnboarded(handleAdminEmailTemplatesPage)))
|
||||
protected.HandleFunc("GET /admin/email-templates/{key}", adminGate(users, gateOnboarded(handleAdminEmailTemplatesEditPage)))
|
||||
protected.HandleFunc("GET /api/admin/users", adminGate(users, handleAdminListUsers))
|
||||
|
||||
@@ -11,12 +11,13 @@ import (
|
||||
"mgit.msbls.de/m/patholo/internal/services"
|
||||
)
|
||||
|
||||
// GET /api/departments — list every Dezernat (readable by all authenticated users).
|
||||
// GET /api/partner-units — list every PartnerUnit (readable by all
|
||||
// authenticated users).
|
||||
//
|
||||
// `?include=members` returns each department enriched with its lead's display
|
||||
// name + email and the full members list. Used by the /team directory page so
|
||||
// the frontend can render the "group by department" view with one fetch.
|
||||
func handleListDepartments(w http.ResponseWriter, r *http.Request) {
|
||||
// `?include=members` returns each unit enriched with its lead's display
|
||||
// name + email and the full members list. Used by the /team directory page
|
||||
// so the frontend can render the "group by partner unit" view with one fetch.
|
||||
func handleListPartnerUnits(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
@@ -24,7 +25,7 @@ func handleListDepartments(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("include") == "members" {
|
||||
rows, err := dbSvc.department.ListWithMembers(r.Context())
|
||||
rows, err := dbSvc.partnerUnit.ListWithMembers(r.Context())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
@@ -32,7 +33,7 @@ func handleListDepartments(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
return
|
||||
}
|
||||
rows, err := dbSvc.department.List(r.Context())
|
||||
rows, err := dbSvc.partnerUnit.List(r.Context())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
@@ -40,8 +41,8 @@ func handleListDepartments(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// POST /api/departments — admin-only create.
|
||||
func handleCreateDepartment(w http.ResponseWriter, r *http.Request) {
|
||||
// POST /api/partner-units — admin-only create.
|
||||
func handleCreatePartnerUnit(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
@@ -49,12 +50,12 @@ func handleCreateDepartment(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input services.CreateDepartmentInput
|
||||
var input services.CreatePartnerUnitInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
d, err := dbSvc.department.Create(r.Context(), uid, input)
|
||||
d, err := dbSvc.partnerUnit.Create(r.Context(), uid, input)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
@@ -62,8 +63,8 @@ func handleCreateDepartment(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, d)
|
||||
}
|
||||
|
||||
// GET /api/departments/{id}
|
||||
func handleGetDepartment(w http.ResponseWriter, r *http.Request) {
|
||||
// GET /api/partner-units/{id}
|
||||
func handleGetPartnerUnit(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
@@ -75,7 +76,7 @@ func handleGetDepartment(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
d, err := dbSvc.department.GetByID(r.Context(), id)
|
||||
d, err := dbSvc.partnerUnit.GetByID(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
@@ -87,8 +88,8 @@ func handleGetDepartment(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, d)
|
||||
}
|
||||
|
||||
// PATCH /api/departments/{id} — admin-only.
|
||||
func handleUpdateDepartment(w http.ResponseWriter, r *http.Request) {
|
||||
// PATCH /api/partner-units/{id} — admin-only.
|
||||
func handleUpdatePartnerUnit(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
@@ -101,12 +102,12 @@ func handleUpdateDepartment(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var input services.UpdateDepartmentInput
|
||||
var input services.UpdatePartnerUnitInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
d, err := dbSvc.department.Update(r.Context(), uid, id, input)
|
||||
d, err := dbSvc.partnerUnit.Update(r.Context(), uid, id, input)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
@@ -114,8 +115,8 @@ func handleUpdateDepartment(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, d)
|
||||
}
|
||||
|
||||
// DELETE /api/departments/{id} — admin-only.
|
||||
func handleDeleteDepartment(w http.ResponseWriter, r *http.Request) {
|
||||
// DELETE /api/partner-units/{id} — admin-only.
|
||||
func handleDeletePartnerUnit(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
@@ -128,15 +129,15 @@ func handleDeleteDepartment(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
if err := dbSvc.department.Delete(r.Context(), uid, id); err != nil {
|
||||
if err := dbSvc.partnerUnit.Delete(r.Context(), uid, id); err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GET /api/departments/{id}/members
|
||||
func handleListDepartmentMembers(w http.ResponseWriter, r *http.Request) {
|
||||
// GET /api/partner-units/{id}/members
|
||||
func handleListPartnerUnitMembers(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
@@ -148,7 +149,7 @@ func handleListDepartmentMembers(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
rows, err := dbSvc.department.ListMembers(r.Context(), id)
|
||||
rows, err := dbSvc.partnerUnit.ListMembers(r.Context(), id)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
@@ -156,8 +157,8 @@ func handleListDepartmentMembers(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// POST /api/departments/{id}/members — admin-only. Body: {"user_id": "<uuid>"}
|
||||
func handleAddDepartmentMember(w http.ResponseWriter, r *http.Request) {
|
||||
// POST /api/partner-units/{id}/members — admin-only. Body: {"user_id": "<uuid>"}
|
||||
func handleAddPartnerUnitMember(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
@@ -165,7 +166,7 @@ func handleAddDepartmentMember(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
departmentID, err := uuid.Parse(r.PathValue("id"))
|
||||
partnerUnitID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
@@ -177,15 +178,15 @@ func handleAddDepartmentMember(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
if err := dbSvc.department.AddMember(r.Context(), uid, departmentID, body.UserID); err != nil {
|
||||
if err := dbSvc.partnerUnit.AddMember(r.Context(), uid, partnerUnitID, body.UserID); err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// DELETE /api/departments/{id}/members/{user_id} — admin-only.
|
||||
func handleRemoveDepartmentMember(w http.ResponseWriter, r *http.Request) {
|
||||
// DELETE /api/partner-units/{id}/members/{user_id} — admin-only.
|
||||
func handleRemovePartnerUnitMember(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
@@ -193,9 +194,9 @@ func handleRemoveDepartmentMember(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
departmentID, err := uuid.Parse(r.PathValue("id"))
|
||||
partnerUnitID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid dezernat id"})
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid partner_unit id"})
|
||||
return
|
||||
}
|
||||
userID, err := uuid.Parse(r.PathValue("user_id"))
|
||||
@@ -203,7 +204,7 @@ func handleRemoveDepartmentMember(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||
return
|
||||
}
|
||||
if err := dbSvc.department.RemoveMember(r.Context(), uid, departmentID, userID); err != nil {
|
||||
if err := dbSvc.partnerUnit.RemoveMember(r.Context(), uid, partnerUnitID, userID); err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
type dbServices struct {
|
||||
projects *services.ProjectService
|
||||
team *services.TeamService
|
||||
department *services.DepartmentService
|
||||
partnerUnit *services.PartnerUnitService
|
||||
parties *services.PartyService
|
||||
deadline *services.DeadlineService
|
||||
appointment *services.AppointmentService
|
||||
|
||||
@@ -24,7 +24,8 @@ func registerLegacyRedirects(mux *http.ServeMux) {
|
||||
"/notizen": "/notes",
|
||||
"/einstellungen": "/settings",
|
||||
"/checklisten": "/checklists",
|
||||
"/dezernate": "/departments",
|
||||
"/dezernate": "/admin/partner-units",
|
||||
"/departments": "/admin/partner-units",
|
||||
"/parteien": "/parties",
|
||||
"/gerichte": "/courts",
|
||||
"/glossar": "/glossary",
|
||||
|
||||
@@ -31,8 +31,7 @@ type User struct {
|
||||
// Drives every permission gate that used to look at the legacy
|
||||
// role='admin'. Per-project authority is on paliad.project_teams.role and
|
||||
// is unrelated.
|
||||
GlobalRole string `db:"global_role" json:"global_role"`
|
||||
Dezernat *string `db:"dezernat" json:"dezernat,omitempty"`
|
||||
GlobalRole string `db:"global_role" json:"global_role"`
|
||||
Lang string `db:"lang" json:"lang"`
|
||||
EmailPreferences json.RawMessage `db:"email_preferences" json:"email_preferences"`
|
||||
// ReminderMorningTime / ReminderEveningTime are stored as Postgres TIME and
|
||||
@@ -127,10 +126,10 @@ type ProjectTeamMemberWithUser struct {
|
||||
InheritedFromTitle *string `db:"inherited_from_title" json:"inherited_from_title,omitempty"`
|
||||
}
|
||||
|
||||
// Department is one structural partner unit. Department membership is
|
||||
// orthogonal to project teams — a user typically belongs to exactly one
|
||||
// Department but may work on projects across all of them.
|
||||
type Department struct {
|
||||
// PartnerUnit is one structural partner unit (Dezernat in legacy German).
|
||||
// Membership is orthogonal to project teams — a user typically belongs to
|
||||
// exactly one PartnerUnit but may work on projects across all of them.
|
||||
type PartnerUnit struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
LeadUserID *uuid.UUID `db:"lead_user_id" json:"lead_user_id,omitempty"`
|
||||
@@ -139,11 +138,11 @@ type Department struct {
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// DepartmentMember is one user's membership in a Department.
|
||||
type DepartmentMember struct {
|
||||
DepartmentID uuid.UUID `db:"department_id" json:"department_id"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
// PartnerUnitMember is one user's membership in a PartnerUnit.
|
||||
type PartnerUnitMember struct {
|
||||
PartnerUnitID uuid.UUID `db:"partner_unit_id" json:"partner_unit_id"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
// ProjectEvent is one row in the per-Project audit trail
|
||||
|
||||
@@ -2,11 +2,12 @@ package services
|
||||
|
||||
// AuditService produces a unified, paginated, filterable timeline across
|
||||
// every audit source we keep in the paliad schema. There is no single
|
||||
// audit_log table — instead we union three existing sources:
|
||||
// audit_log table — instead we union four existing sources:
|
||||
//
|
||||
// - paliad.project_events — per-project audit (creates, updates, etc.)
|
||||
// - paliad.caldav_sync_log — CalDAV push/pull outcomes per user
|
||||
// - paliad.reminder_log — bundled-digest reminder sends
|
||||
// - paliad.project_events — per-project audit (creates, updates, etc.)
|
||||
// - paliad.caldav_sync_log — CalDAV push/pull outcomes per user
|
||||
// - paliad.reminder_log — bundled-digest reminder sends
|
||||
// - paliad.partner_unit_events — partner-unit CRUD + membership changes
|
||||
//
|
||||
// The union happens in SQL (one round-trip, server-side ordering) and is
|
||||
// keyset-paginated on (timestamp, id) DESC so the cursor stays stable across
|
||||
@@ -30,9 +31,10 @@ import (
|
||||
// Audit source discriminators. Stable strings — exposed in the JSON payload,
|
||||
// referenced in the i18n keys, and used as filter values.
|
||||
const (
|
||||
AuditSourceProjectEvents = "project_events"
|
||||
AuditSourceCalDAVLog = "caldav_sync_log"
|
||||
AuditSourceReminderLog = "reminder_log"
|
||||
AuditSourceProjectEvents = "project_events"
|
||||
AuditSourceCalDAVLog = "caldav_sync_log"
|
||||
AuditSourceReminderLog = "reminder_log"
|
||||
AuditSourcePartnerUnitEvents = "partner_unit_events"
|
||||
)
|
||||
|
||||
// MaxAuditPageLimit caps a single ListEntries page.
|
||||
@@ -167,6 +169,24 @@ WITH unioned AS (
|
||||
WHERE ($1::text IS NULL OR $1 = '' OR $1 = 'reminder_log')
|
||||
AND ($2::timestamptz IS NULL OR r.sent_at >= $2)
|
||||
AND ($3::timestamptz IS NULL OR r.sent_at <= $3)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'partner_unit_events'::text AS source,
|
||||
pue.id AS id,
|
||||
pue.created_at AS ts,
|
||||
pue.event_type AS event_type,
|
||||
COALESCE(au.email, pue.actor_id::text) AS actor,
|
||||
pue.unit_name AS subject,
|
||||
NULL::uuid AS project_id,
|
||||
NULL::text AS title,
|
||||
pue.payload::text AS description
|
||||
FROM paliad.partner_unit_events pue
|
||||
LEFT JOIN paliad.users au ON au.id = pue.actor_id
|
||||
WHERE ($1::text IS NULL OR $1 = '' OR $1 = 'partner_unit_events')
|
||||
AND ($2::timestamptz IS NULL OR pue.created_at >= $2)
|
||||
AND ($3::timestamptz IS NULL OR pue.created_at <= $3)
|
||||
)
|
||||
SELECT source, id, ts, event_type, actor, subject, project_id, title, description
|
||||
FROM unioned
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
package services
|
||||
|
||||
// DepartmentService handles paliad.departments + paliad.department_members —
|
||||
// the structural partner-led units. Orthogonal to project teams.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/patholo/internal/models"
|
||||
"mgit.msbls.de/m/patholo/internal/offices"
|
||||
)
|
||||
|
||||
// DepartmentService reads and writes paliad.departments.
|
||||
type DepartmentService struct {
|
||||
db *sqlx.DB
|
||||
users *UserService
|
||||
}
|
||||
|
||||
// NewDepartmentService wires the service.
|
||||
func NewDepartmentService(db *sqlx.DB, users *UserService) *DepartmentService {
|
||||
return &DepartmentService{db: db, users: users}
|
||||
}
|
||||
|
||||
// CreateDepartmentInput is the payload for Create.
|
||||
type CreateDepartmentInput struct {
|
||||
Name string `json:"name"`
|
||||
LeadUserID *uuid.UUID `json:"lead_user_id,omitempty"`
|
||||
Office string `json:"office"`
|
||||
}
|
||||
|
||||
// UpdateDepartmentInput is the partial-update payload.
|
||||
type UpdateDepartmentInput struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
LeadUserID *uuid.UUID `json:"lead_user_id,omitempty"`
|
||||
Office *string `json:"office,omitempty"`
|
||||
}
|
||||
|
||||
// List returns every Dezernat (readable by any authenticated user — see RLS).
|
||||
func (s *DepartmentService) List(ctx context.Context) ([]models.Department, error) {
|
||||
rows := []models.Department{}
|
||||
err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT id, name, lead_user_id, office, created_at, updated_at
|
||||
FROM paliad.departments
|
||||
ORDER BY office, name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list dezernate: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// GetByID returns one Dezernat or (nil, sql.ErrNoRows).
|
||||
func (s *DepartmentService) GetByID(ctx context.Context, id uuid.UUID) (*models.Department, error) {
|
||||
var d models.Department
|
||||
err := s.db.GetContext(ctx, &d,
|
||||
`SELECT id, name, lead_user_id, office, created_at, updated_at
|
||||
FROM paliad.departments WHERE id = $1`, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get dezernat: %w", err)
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// Create inserts a Dezernat. Admin-only.
|
||||
func (s *DepartmentService) Create(ctx context.Context, callerID uuid.UUID, input CreateDepartmentInput) (*models.Department, error) {
|
||||
if err := s.requireAdmin(ctx, callerID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(input.Name) == "" {
|
||||
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
|
||||
}
|
||||
if !offices.IsValid(input.Office) {
|
||||
return nil, fmt.Errorf("%w: invalid office %q", ErrInvalidInput, input.Office)
|
||||
}
|
||||
id := uuid.New()
|
||||
now := time.Now().UTC()
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO paliad.departments (id, name, lead_user_id, office, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $5)`,
|
||||
id, input.Name, input.LeadUserID, input.Office, now); err != nil {
|
||||
return nil, fmt.Errorf("insert dezernat: %w", err)
|
||||
}
|
||||
return s.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// Update applies a partial update. Admin-only.
|
||||
func (s *DepartmentService) Update(ctx context.Context, callerID, id uuid.UUID, input UpdateDepartmentInput) (*models.Department, error) {
|
||||
if err := s.requireAdmin(ctx, callerID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current, err := s.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sets := []string{}
|
||||
args := []any{}
|
||||
next := 1
|
||||
appendSet := func(col string, val any) {
|
||||
sets = append(sets, fmt.Sprintf("%s = $%d", col, next))
|
||||
args = append(args, val)
|
||||
next++
|
||||
}
|
||||
if input.Name != nil {
|
||||
appendSet("name", *input.Name)
|
||||
}
|
||||
if input.LeadUserID != nil {
|
||||
appendSet("lead_user_id", *input.LeadUserID)
|
||||
}
|
||||
if input.Office != nil {
|
||||
if !offices.IsValid(*input.Office) {
|
||||
return nil, fmt.Errorf("%w: invalid office %q", ErrInvalidInput, *input.Office)
|
||||
}
|
||||
appendSet("office", *input.Office)
|
||||
}
|
||||
if len(sets) == 0 {
|
||||
return current, nil
|
||||
}
|
||||
appendSet("updated_at", time.Now().UTC())
|
||||
args = append(args, id)
|
||||
query := fmt.Sprintf("UPDATE paliad.departments SET %s WHERE id = $%d",
|
||||
strings.Join(sets, ", "), next)
|
||||
if _, err := s.db.ExecContext(ctx, query, args...); err != nil {
|
||||
return nil, fmt.Errorf("update dezernat: %w", err)
|
||||
}
|
||||
return s.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// Delete removes a Dezernat (cascades memberships). Admin-only.
|
||||
func (s *DepartmentService) Delete(ctx context.Context, callerID, id uuid.UUID) error {
|
||||
if err := s.requireAdmin(ctx, callerID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM paliad.departments WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete dezernat: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddMember inserts a (dezernat, user) membership. Admin-only. Idempotent.
|
||||
func (s *DepartmentService) AddMember(ctx context.Context, callerID, departmentID, userID uuid.UUID) error {
|
||||
if err := s.requireAdmin(ctx, callerID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO paliad.department_members (department_id, user_id, created_at)
|
||||
VALUES ($1, $2, now()) ON CONFLICT (department_id, user_id) DO NOTHING`,
|
||||
departmentID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add dezernat member: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveMember deletes a (dezernat, user) membership. Admin-only.
|
||||
func (s *DepartmentService) RemoveMember(ctx context.Context, callerID, departmentID, userID uuid.UUID) error {
|
||||
if err := s.requireAdmin(ctx, callerID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM paliad.department_members WHERE department_id = $1 AND user_id = $2`,
|
||||
departmentID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove dezernat member: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListMembers returns users in the Dezernat, enriched with display fields.
|
||||
type DepartmentMember struct {
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
Email string `db:"email" json:"email"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
Office string `db:"office" json:"office"`
|
||||
JobTitle *string `db:"job_title" json:"job_title"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
// ListMembers returns users in the Dezernat (readable by any authenticated user).
|
||||
//
|
||||
// INNER JOIN on paliad.users: department_members.user_id FKs auth.users, so
|
||||
// pre-onboarding members (auth row exists, paliad.users row doesn't) would
|
||||
// otherwise produce NULL display_name/office/role and break the scan.
|
||||
// Skipping them is the right UX — without an onboarded profile there's
|
||||
// nothing meaningful to render.
|
||||
func (s *DepartmentService) ListMembers(ctx context.Context, departmentID uuid.UUID) ([]DepartmentMember, error) {
|
||||
var rows []DepartmentMember
|
||||
err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT dm.user_id, dm.created_at,
|
||||
u.email, u.display_name, u.office, u.job_title
|
||||
FROM paliad.department_members dm
|
||||
JOIN paliad.users u ON u.id = dm.user_id
|
||||
WHERE dm.department_id = $1
|
||||
ORDER BY u.display_name`, departmentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list dezernat members: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// DepartmentWithMembers is a department row enriched with its lead user
|
||||
// snapshot and full member list. Used by the /team directory page so the
|
||||
// frontend can render the "by department" grouping with one fetch.
|
||||
type DepartmentWithMembers struct {
|
||||
models.Department
|
||||
LeadDisplayName *string `json:"lead_display_name,omitempty"`
|
||||
LeadEmail *string `json:"lead_email,omitempty"`
|
||||
Members []DepartmentMember `json:"members"`
|
||||
}
|
||||
|
||||
// ListWithMembers returns every Department enriched with its lead's display
|
||||
// name + email and the full members list. Two short queries (one per
|
||||
// table) are joined in Go to avoid a Cartesian explosion when departments
|
||||
// have many members.
|
||||
func (s *DepartmentService) ListWithMembers(ctx context.Context) ([]DepartmentWithMembers, error) {
|
||||
type deptRow struct {
|
||||
models.Department
|
||||
LeadDisplayName *string `db:"lead_display_name"`
|
||||
LeadEmail *string `db:"lead_email"`
|
||||
}
|
||||
var depts []deptRow
|
||||
err := s.db.SelectContext(ctx, &depts,
|
||||
`SELECT d.id, d.name, d.lead_user_id, d.office, d.created_at, d.updated_at,
|
||||
lu.display_name AS lead_display_name,
|
||||
lu.email AS lead_email
|
||||
FROM paliad.departments d
|
||||
LEFT JOIN paliad.users lu ON lu.id = d.lead_user_id
|
||||
ORDER BY d.office, d.name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list departments: %w", err)
|
||||
}
|
||||
|
||||
type memberRow struct {
|
||||
DepartmentMember
|
||||
DepartmentID uuid.UUID `db:"department_id"`
|
||||
}
|
||||
var members []memberRow
|
||||
// INNER JOIN: see comment on ListMembers for why pre-onboarding members
|
||||
// (auth row present, paliad.users row missing) must be excluded.
|
||||
err = s.db.SelectContext(ctx, &members,
|
||||
`SELECT dm.department_id, dm.user_id, dm.created_at,
|
||||
u.email, u.display_name, u.office, u.job_title
|
||||
FROM paliad.department_members dm
|
||||
JOIN paliad.users u ON u.id = dm.user_id
|
||||
ORDER BY u.display_name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list department members: %w", err)
|
||||
}
|
||||
|
||||
byDept := map[uuid.UUID][]DepartmentMember{}
|
||||
for _, m := range members {
|
||||
byDept[m.DepartmentID] = append(byDept[m.DepartmentID], m.DepartmentMember)
|
||||
}
|
||||
|
||||
out := make([]DepartmentWithMembers, len(depts))
|
||||
for i, d := range depts {
|
||||
out[i] = DepartmentWithMembers{
|
||||
Department: d.Department,
|
||||
LeadDisplayName: d.LeadDisplayName,
|
||||
LeadEmail: d.LeadEmail,
|
||||
Members: byDept[d.ID],
|
||||
}
|
||||
if out[i].Members == nil {
|
||||
out[i].Members = []DepartmentMember{}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetMembership returns the user's Dezernat memberships (zero or more).
|
||||
// Used by the settings page to render "Your Dezernat: <name>".
|
||||
func (s *DepartmentService) GetMembership(ctx context.Context, userID uuid.UUID) ([]models.Department, error) {
|
||||
rows := []models.Department{}
|
||||
err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT d.id, d.name, d.lead_user_id, d.office, d.created_at, d.updated_at
|
||||
FROM paliad.departments d
|
||||
JOIN paliad.department_members dm ON dm.department_id = d.id
|
||||
WHERE dm.user_id = $1
|
||||
ORDER BY d.name`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user dezernat memberships: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (s *DepartmentService) requireAdmin(ctx context.Context, userID uuid.UUID) error {
|
||||
u, err := s.users.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u == nil || u.GlobalRole != "global_admin" {
|
||||
return fmt.Errorf("%w: global admin required", ErrForbidden)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
510
internal/services/partner_unit_service.go
Normal file
510
internal/services/partner_unit_service.go
Normal file
@@ -0,0 +1,510 @@
|
||||
package services
|
||||
|
||||
// PartnerUnitService handles paliad.partner_units + paliad.partner_unit_members
|
||||
// — the structural partner-led units (legacy "Dezernat"). Orthogonal to
|
||||
// project teams: a user typically belongs to exactly one PartnerUnit but may
|
||||
// work on projects across all of them.
|
||||
//
|
||||
// Every mutation emits a row into paliad.partner_unit_events in the same tx
|
||||
// as the originating change so the global audit timeline (audit_service.go)
|
||||
// can render the full history. The unit name is snapshotted into the event
|
||||
// row so 'deleted' rows stay readable after the FK ON DELETE SET NULL fires.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/patholo/internal/models"
|
||||
"mgit.msbls.de/m/patholo/internal/offices"
|
||||
)
|
||||
|
||||
// PartnerUnitService reads and writes paliad.partner_units.
|
||||
type PartnerUnitService struct {
|
||||
db *sqlx.DB
|
||||
users *UserService
|
||||
}
|
||||
|
||||
// NewPartnerUnitService wires the service.
|
||||
func NewPartnerUnitService(db *sqlx.DB, users *UserService) *PartnerUnitService {
|
||||
return &PartnerUnitService{db: db, users: users}
|
||||
}
|
||||
|
||||
// CreatePartnerUnitInput is the payload for Create.
|
||||
type CreatePartnerUnitInput struct {
|
||||
Name string `json:"name"`
|
||||
LeadUserID *uuid.UUID `json:"lead_user_id,omitempty"`
|
||||
Office string `json:"office"`
|
||||
}
|
||||
|
||||
// UpdatePartnerUnitInput is the partial-update payload.
|
||||
type UpdatePartnerUnitInput struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
LeadUserID *uuid.UUID `json:"lead_user_id,omitempty"`
|
||||
Office *string `json:"office,omitempty"`
|
||||
}
|
||||
|
||||
// List returns every PartnerUnit (readable by any authenticated user — see RLS).
|
||||
func (s *PartnerUnitService) List(ctx context.Context) ([]models.PartnerUnit, error) {
|
||||
rows := []models.PartnerUnit{}
|
||||
err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT id, name, lead_user_id, office, created_at, updated_at
|
||||
FROM paliad.partner_units
|
||||
ORDER BY office, name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list partner_units: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// GetByID returns one PartnerUnit or (nil, sql.ErrNoRows).
|
||||
func (s *PartnerUnitService) GetByID(ctx context.Context, id uuid.UUID) (*models.PartnerUnit, error) {
|
||||
var d models.PartnerUnit
|
||||
err := s.db.GetContext(ctx, &d,
|
||||
`SELECT id, name, lead_user_id, office, created_at, updated_at
|
||||
FROM paliad.partner_units WHERE id = $1`, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get partner_unit: %w", err)
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// Create inserts a PartnerUnit. Admin-only. Emits a 'created' audit event
|
||||
// in the same tx.
|
||||
func (s *PartnerUnitService) Create(ctx context.Context, callerID uuid.UUID, input CreatePartnerUnitInput) (*models.PartnerUnit, error) {
|
||||
if err := s.requireAdmin(ctx, callerID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(input.Name) == "" {
|
||||
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
|
||||
}
|
||||
if !offices.IsValid(input.Office) {
|
||||
return nil, fmt.Errorf("%w: invalid office %q", ErrInvalidInput, input.Office)
|
||||
}
|
||||
id := uuid.New()
|
||||
now := time.Now().UTC()
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.partner_units (id, name, lead_user_id, office, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $5)`,
|
||||
id, input.Name, input.LeadUserID, input.Office, now); err != nil {
|
||||
return nil, fmt.Errorf("insert partner_unit: %w", err)
|
||||
}
|
||||
|
||||
if err := s.emit(ctx, tx, callerID, &id, input.Name, "created", map[string]any{
|
||||
"name": input.Name,
|
||||
"office": input.Office,
|
||||
"lead_user_id": input.LeadUserID,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit tx: %w", err)
|
||||
}
|
||||
return s.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// Update applies a partial update. Admin-only. Emits an 'updated' event with
|
||||
// before/after snapshots in the same tx.
|
||||
func (s *PartnerUnitService) Update(ctx context.Context, callerID, id uuid.UUID, input UpdatePartnerUnitInput) (*models.PartnerUnit, error) {
|
||||
if err := s.requireAdmin(ctx, callerID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current, err := s.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sets := []string{}
|
||||
args := []any{}
|
||||
next := 1
|
||||
appendSet := func(col string, val any) {
|
||||
sets = append(sets, fmt.Sprintf("%s = $%d", col, next))
|
||||
args = append(args, val)
|
||||
next++
|
||||
}
|
||||
before := map[string]any{}
|
||||
after := map[string]any{}
|
||||
fields := []string{}
|
||||
if input.Name != nil && *input.Name != current.Name {
|
||||
appendSet("name", *input.Name)
|
||||
before["name"] = current.Name
|
||||
after["name"] = *input.Name
|
||||
fields = append(fields, "name")
|
||||
}
|
||||
if input.LeadUserID != nil {
|
||||
curLead := uuid.Nil
|
||||
if current.LeadUserID != nil {
|
||||
curLead = *current.LeadUserID
|
||||
}
|
||||
if *input.LeadUserID != curLead {
|
||||
appendSet("lead_user_id", *input.LeadUserID)
|
||||
before["lead_user_id"] = current.LeadUserID
|
||||
after["lead_user_id"] = *input.LeadUserID
|
||||
fields = append(fields, "lead_user_id")
|
||||
}
|
||||
}
|
||||
if input.Office != nil && *input.Office != current.Office {
|
||||
if !offices.IsValid(*input.Office) {
|
||||
return nil, fmt.Errorf("%w: invalid office %q", ErrInvalidInput, *input.Office)
|
||||
}
|
||||
appendSet("office", *input.Office)
|
||||
before["office"] = current.Office
|
||||
after["office"] = *input.Office
|
||||
fields = append(fields, "office")
|
||||
}
|
||||
if len(sets) == 0 {
|
||||
return current, nil
|
||||
}
|
||||
appendSet("updated_at", time.Now().UTC())
|
||||
args = append(args, id)
|
||||
query := fmt.Sprintf("UPDATE paliad.partner_units SET %s WHERE id = $%d",
|
||||
strings.Join(sets, ", "), next)
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
|
||||
return nil, fmt.Errorf("update partner_unit: %w", err)
|
||||
}
|
||||
if err := s.emit(ctx, tx, callerID, &id, current.Name, "updated", map[string]any{
|
||||
"before": before,
|
||||
"after": after,
|
||||
"fields": fields,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit tx: %w", err)
|
||||
}
|
||||
return s.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// Delete removes a PartnerUnit (cascades memberships). Admin-only. Emits a
|
||||
// 'deleted' audit event in the same tx — the FK on partner_unit_events has
|
||||
// ON DELETE SET NULL so the historical row survives the cascade.
|
||||
func (s *PartnerUnitService) Delete(ctx context.Context, callerID, id uuid.UUID) error {
|
||||
if err := s.requireAdmin(ctx, callerID); err != nil {
|
||||
return err
|
||||
}
|
||||
current, err := s.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var memberCount int
|
||||
if err := s.db.GetContext(ctx, &memberCount,
|
||||
`SELECT COUNT(*) FROM paliad.partner_unit_members WHERE partner_unit_id = $1`, id); err != nil {
|
||||
return fmt.Errorf("count members: %w", err)
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
// Emit BEFORE delete so the FK still resolves; ON DELETE SET NULL fires
|
||||
// on cascade and clears partner_unit_id while keeping unit_name + payload.
|
||||
if err := s.emit(ctx, tx, callerID, &id, current.Name, "deleted", map[string]any{
|
||||
"name": current.Name,
|
||||
"office": current.Office,
|
||||
"lead_user_id": current.LeadUserID,
|
||||
"member_count": memberCount,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM paliad.partner_units WHERE id = $1`, id); err != nil {
|
||||
return fmt.Errorf("delete partner_unit: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit tx: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddMember inserts a (partner_unit, user) membership. Admin-only. Idempotent.
|
||||
// Emits 'member_added' only when a row is actually inserted.
|
||||
func (s *PartnerUnitService) AddMember(ctx context.Context, callerID, partnerUnitID, userID uuid.UUID) error {
|
||||
if err := s.requireAdmin(ctx, callerID); err != nil {
|
||||
return err
|
||||
}
|
||||
unit, err := s.GetByID(ctx, partnerUnitID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.partner_unit_members (partner_unit_id, user_id, created_at)
|
||||
VALUES ($1, $2, now()) ON CONFLICT (partner_unit_id, user_id) DO NOTHING`,
|
||||
partnerUnitID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add partner_unit member: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
var disp struct {
|
||||
DN string `db:"display_name"`
|
||||
Em string `db:"email"`
|
||||
}
|
||||
_ = s.db.GetContext(ctx, &disp,
|
||||
`SELECT display_name, email FROM paliad.users WHERE id = $1`, userID)
|
||||
|
||||
if err := s.emit(ctx, tx, callerID, &partnerUnitID, unit.Name, "member_added", map[string]any{
|
||||
"user_id": userID,
|
||||
"display_name": disp.DN,
|
||||
"email": disp.Em,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// RemoveMember deletes a (partner_unit, user) membership. Admin-only.
|
||||
// Emits 'member_removed' only when a row is actually deleted.
|
||||
func (s *PartnerUnitService) RemoveMember(ctx context.Context, callerID, partnerUnitID, userID uuid.UUID) error {
|
||||
if err := s.requireAdmin(ctx, callerID); err != nil {
|
||||
return err
|
||||
}
|
||||
unit, err := s.GetByID(ctx, partnerUnitID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM paliad.partner_unit_members WHERE partner_unit_id = $1 AND user_id = $2`,
|
||||
partnerUnitID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove partner_unit member: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
if err := s.emit(ctx, tx, callerID, &partnerUnitID, unit.Name, "member_removed", map[string]any{
|
||||
"user_id": userID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// AddMemberTx is the same as AddMember but runs inside the caller's tx and
|
||||
// skips the admin gate (caller has already authorised the parent operation).
|
||||
// Used by user_service.OnboardUser to insert a partner_unit membership in
|
||||
// the same tx as the user-create.
|
||||
func (s *PartnerUnitService) AddMemberTx(ctx context.Context, tx *sqlx.Tx, actorID, partnerUnitID, userID uuid.UUID) error {
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.partner_unit_members (partner_unit_id, user_id, created_at)
|
||||
VALUES ($1, $2, now()) ON CONFLICT (partner_unit_id, user_id) DO NOTHING`,
|
||||
partnerUnitID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add partner_unit member (tx): %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
var unitName string
|
||||
if err := tx.GetContext(ctx, &unitName,
|
||||
`SELECT name FROM paliad.partner_units WHERE id = $1`, partnerUnitID); err != nil {
|
||||
return fmt.Errorf("lookup partner_unit name: %w", err)
|
||||
}
|
||||
var disp struct {
|
||||
DN string `db:"display_name"`
|
||||
Em string `db:"email"`
|
||||
}
|
||||
_ = tx.GetContext(ctx, &disp,
|
||||
`SELECT display_name, email FROM paliad.users WHERE id = $1`, userID)
|
||||
|
||||
return s.emit(ctx, tx, actorID, &partnerUnitID, unitName, "member_added", map[string]any{
|
||||
"user_id": userID,
|
||||
"display_name": disp.DN,
|
||||
"email": disp.Em,
|
||||
"source": "onboarding",
|
||||
})
|
||||
}
|
||||
|
||||
// PartnerUnitMemberDetail is one user's membership row enriched with display
|
||||
// fields for the admin/team UIs.
|
||||
type PartnerUnitMemberDetail struct {
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
Email string `db:"email" json:"email"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
Office string `db:"office" json:"office"`
|
||||
JobTitle *string `db:"job_title" json:"job_title"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
// ListMembers returns users in the PartnerUnit, enriched with display fields.
|
||||
//
|
||||
// INNER JOIN on paliad.users: partner_unit_members.user_id FKs auth.users, so
|
||||
// pre-onboarding members (auth row exists, paliad.users row doesn't) would
|
||||
// otherwise produce NULL display_name/office and break the scan.
|
||||
// Skipping them is the right UX — without an onboarded profile there's
|
||||
// nothing meaningful to render.
|
||||
func (s *PartnerUnitService) ListMembers(ctx context.Context, partnerUnitID uuid.UUID) ([]PartnerUnitMemberDetail, error) {
|
||||
var rows []PartnerUnitMemberDetail
|
||||
err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT pum.user_id, pum.created_at,
|
||||
u.email, u.display_name, u.office, u.job_title
|
||||
FROM paliad.partner_unit_members pum
|
||||
JOIN paliad.users u ON u.id = pum.user_id
|
||||
WHERE pum.partner_unit_id = $1
|
||||
ORDER BY u.display_name`, partnerUnitID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list partner_unit members: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// PartnerUnitWithMembers is a unit row enriched with its lead user
|
||||
// snapshot and full member list. Used by the /team directory page so the
|
||||
// frontend can render the "by partner unit" grouping with one fetch.
|
||||
type PartnerUnitWithMembers struct {
|
||||
models.PartnerUnit
|
||||
LeadDisplayName *string `json:"lead_display_name,omitempty"`
|
||||
LeadEmail *string `json:"lead_email,omitempty"`
|
||||
Members []PartnerUnitMemberDetail `json:"members"`
|
||||
}
|
||||
|
||||
// ListWithMembers returns every PartnerUnit enriched with its lead's display
|
||||
// name + email and the full members list. Two short queries (one per
|
||||
// table) are joined in Go to avoid a Cartesian explosion when units have
|
||||
// many members.
|
||||
func (s *PartnerUnitService) ListWithMembers(ctx context.Context) ([]PartnerUnitWithMembers, error) {
|
||||
type unitRow struct {
|
||||
models.PartnerUnit
|
||||
LeadDisplayName *string `db:"lead_display_name"`
|
||||
LeadEmail *string `db:"lead_email"`
|
||||
}
|
||||
var units []unitRow
|
||||
err := s.db.SelectContext(ctx, &units,
|
||||
`SELECT pu.id, pu.name, pu.lead_user_id, pu.office, pu.created_at, pu.updated_at,
|
||||
lu.display_name AS lead_display_name,
|
||||
lu.email AS lead_email
|
||||
FROM paliad.partner_units pu
|
||||
LEFT JOIN paliad.users lu ON lu.id = pu.lead_user_id
|
||||
ORDER BY pu.office, pu.name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list partner_units: %w", err)
|
||||
}
|
||||
|
||||
type memberRow struct {
|
||||
PartnerUnitMemberDetail
|
||||
PartnerUnitID uuid.UUID `db:"partner_unit_id"`
|
||||
}
|
||||
var members []memberRow
|
||||
err = s.db.SelectContext(ctx, &members,
|
||||
`SELECT pum.partner_unit_id, pum.user_id, pum.created_at,
|
||||
u.email, u.display_name, u.office, u.job_title
|
||||
FROM paliad.partner_unit_members pum
|
||||
JOIN paliad.users u ON u.id = pum.user_id
|
||||
ORDER BY u.display_name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list partner_unit members: %w", err)
|
||||
}
|
||||
|
||||
byUnit := map[uuid.UUID][]PartnerUnitMemberDetail{}
|
||||
for _, m := range members {
|
||||
byUnit[m.PartnerUnitID] = append(byUnit[m.PartnerUnitID], m.PartnerUnitMemberDetail)
|
||||
}
|
||||
|
||||
out := make([]PartnerUnitWithMembers, len(units))
|
||||
for i, u := range units {
|
||||
out[i] = PartnerUnitWithMembers{
|
||||
PartnerUnit: u.PartnerUnit,
|
||||
LeadDisplayName: u.LeadDisplayName,
|
||||
LeadEmail: u.LeadEmail,
|
||||
Members: byUnit[u.ID],
|
||||
}
|
||||
if out[i].Members == nil {
|
||||
out[i].Members = []PartnerUnitMemberDetail{}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetMembership returns the user's PartnerUnit memberships (zero or more).
|
||||
// Used by the settings page to render the user's own partner unit card.
|
||||
func (s *PartnerUnitService) GetMembership(ctx context.Context, userID uuid.UUID) ([]models.PartnerUnit, error) {
|
||||
rows := []models.PartnerUnit{}
|
||||
err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT pu.id, pu.name, pu.lead_user_id, pu.office, pu.created_at, pu.updated_at
|
||||
FROM paliad.partner_units pu
|
||||
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = pu.id
|
||||
WHERE pum.user_id = $1
|
||||
ORDER BY pu.name`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user partner_unit memberships: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (s *PartnerUnitService) requireAdmin(ctx context.Context, userID uuid.UUID) error {
|
||||
u, err := s.users.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u == nil || u.GlobalRole != "global_admin" {
|
||||
return fmt.Errorf("%w: global admin required", ErrForbidden)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// emit writes one audit row to paliad.partner_unit_events inside the caller's
|
||||
// tx. unitName is snapshotted so deleted units stay readable in the timeline
|
||||
// (their FK is cleared to NULL by ON DELETE SET NULL after the unit row is
|
||||
// gone).
|
||||
func (s *PartnerUnitService) emit(ctx context.Context, tx *sqlx.Tx, actorID uuid.UUID,
|
||||
unitID *uuid.UUID, unitName, eventType string, payload any) error {
|
||||
p, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal audit payload: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.partner_unit_events
|
||||
(partner_unit_id, actor_id, event_type, unit_name, payload)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
unitID, actorID, eventType, unitName, p); err != nil {
|
||||
return fmt.Errorf("emit partner_unit event %q: %w", eventType, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -66,7 +66,7 @@ func NewUserService(db *sqlx.DB) *UserService {
|
||||
}
|
||||
|
||||
const userColumns = `id, email, display_name, office, additional_offices, practice_group,
|
||||
job_title, global_role, dezernat,
|
||||
job_title, global_role,
|
||||
lang, email_preferences,
|
||||
reminder_morning_time::text AS reminder_morning_time,
|
||||
reminder_evening_time::text AS reminder_evening_time,
|
||||
@@ -91,11 +91,17 @@ func (s *UserService) GetByID(ctx context.Context, id uuid.UUID) (*models.User,
|
||||
}
|
||||
|
||||
// CreateUserInput is the payload for the onboarding flow (POST /api/onboarding).
|
||||
//
|
||||
// PartnerUnitID is optional — when set, the onboarding flow inserts a
|
||||
// paliad.partner_unit_members row in the same tx as the user-create and
|
||||
// emits a 'member_added' audit event with source='onboarding'. When unset,
|
||||
// the user is onboarded without any partner-unit membership and an admin
|
||||
// must assign one later via /admin/partner-units.
|
||||
type CreateUserInput struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
Office string `json:"office"`
|
||||
JobTitle string `json:"job_title"`
|
||||
Dezernat *string `json:"dezernat,omitempty"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Office string `json:"office"`
|
||||
JobTitle string `json:"job_title"`
|
||||
PartnerUnitID *uuid.UUID `json:"partner_unit_id,omitempty"`
|
||||
}
|
||||
|
||||
// Create inserts the paliad.users row for the authenticated user. The caller
|
||||
@@ -126,14 +132,6 @@ func (s *UserService) Create(ctx context.Context, id uuid.UUID, email string, in
|
||||
return nil, fmt.Errorf("job_title is required")
|
||||
}
|
||||
|
||||
var dezernat *string
|
||||
if input.Dezernat != nil {
|
||||
trimmed := strings.TrimSpace(*input.Dezernat)
|
||||
if trimmed != "" {
|
||||
dezernat = &trimmed
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
@@ -176,13 +174,24 @@ func (s *UserService) Create(ctx context.Context, id uuid.UUID, email string, in
|
||||
// future use but no longer collected at onboarding (m, 2026-04-18: every
|
||||
// Paliad user is in patent practice, so the field carried no signal).
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, job_title, global_role, dezernat)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
id, email, displayName, input.Office, jobTitle, globalRole, dezernat,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, job_title, global_role)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
id, email, displayName, input.Office, jobTitle, globalRole,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("insert user: %w", err)
|
||||
}
|
||||
|
||||
// Optional initial partner-unit membership picked from the onboarding
|
||||
// form. RLS on partner_unit_members allows user_id = auth.uid() so this
|
||||
// works even after we strip superuser; the audit event records the user
|
||||
// as their own actor with source='onboarding' so admins can see how the
|
||||
// membership originated.
|
||||
if input.PartnerUnitID != nil {
|
||||
if err := insertPartnerUnitMembership(ctx, tx, *input.PartnerUnitID, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit create user: %w", err)
|
||||
}
|
||||
@@ -190,6 +199,52 @@ func (s *UserService) Create(ctx context.Context, id uuid.UUID, email string, in
|
||||
return s.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// insertPartnerUnitMembership inserts a paliad.partner_unit_members row plus
|
||||
// a paliad.partner_unit_events audit row inside the caller's tx. Used by
|
||||
// onboarding (Create) — admin-driven membership writes go through
|
||||
// PartnerUnitService.AddMember which has its own emit.
|
||||
//
|
||||
// The user is recorded as the actor for the audit event because the
|
||||
// onboarding form is self-service. unitName is fetched inside the tx so
|
||||
// the audit row stays readable if the unit is later deleted.
|
||||
func insertPartnerUnitMembership(ctx context.Context, tx *sqlx.Tx, partnerUnitID, userID uuid.UUID) error {
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.partner_unit_members (partner_unit_id, user_id, created_at)
|
||||
VALUES ($1, $2, now())
|
||||
ON CONFLICT (partner_unit_id, user_id) DO NOTHING`,
|
||||
partnerUnitID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert partner_unit membership: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var unitName string
|
||||
if err := tx.GetContext(ctx, &unitName,
|
||||
`SELECT name FROM paliad.partner_units WHERE id = $1`, partnerUnitID); err != nil {
|
||||
return fmt.Errorf("lookup partner_unit name: %w", err)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"user_id": userID,
|
||||
"source": "onboarding",
|
||||
}
|
||||
pj, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal audit payload: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.partner_unit_events
|
||||
(partner_unit_id, actor_id, event_type, unit_name, payload)
|
||||
VALUES ($1, $2, 'member_added', $3, $4)`,
|
||||
partnerUnitID, userID, unitName, pj); err != nil {
|
||||
return fmt.Errorf("emit member_added event: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateProfileInput is the payload for PATCH /api/me. Every field is a
|
||||
// pointer so callers can omit keys they don't want to touch — the settings
|
||||
// page sends only the fields the user changed. Email is deliberately absent:
|
||||
@@ -201,7 +256,6 @@ type UpdateProfileInput struct {
|
||||
DisplayName *string `json:"display_name,omitempty"`
|
||||
Office *string `json:"office,omitempty"`
|
||||
JobTitle *string `json:"job_title,omitempty"`
|
||||
Dezernat *string `json:"dezernat,omitempty"`
|
||||
Lang *string `json:"lang,omitempty"`
|
||||
EmailPreferences *json.RawMessage `json:"email_preferences,omitempty"`
|
||||
ReminderMorningTime *string `json:"reminder_morning_time,omitempty"`
|
||||
@@ -211,9 +265,9 @@ type UpdateProfileInput struct {
|
||||
// EscalationContactID overrides the DRINGEND/overdue escalation channel:
|
||||
// when non-NULL, the named user replaces the global_admins fallback for
|
||||
// this user's deadlines. Empty string clears (back to fallback). nil =
|
||||
// don't touch — matches the Dezernat tri-state pattern in the same file
|
||||
// (a UUID and "no override" are different types, so we encode the clear
|
||||
// signal as "" rather than juggling JSON null / missing semantics).
|
||||
// don't touch (a UUID and "no override" are different types, so we
|
||||
// encode the clear signal as "" rather than juggling JSON null / missing
|
||||
// semantics).
|
||||
EscalationContactID *string `json:"escalation_contact_id,omitempty"`
|
||||
}
|
||||
|
||||
@@ -252,18 +306,6 @@ func (s *UserService) UpdateProfile(ctx context.Context, id uuid.UUID, input Upd
|
||||
args = append(args, jt)
|
||||
i++
|
||||
}
|
||||
if input.Dezernat != nil {
|
||||
trimmed := strings.TrimSpace(*input.Dezernat)
|
||||
var val any
|
||||
if trimmed == "" {
|
||||
val = nil
|
||||
} else {
|
||||
val = trimmed
|
||||
}
|
||||
sets = append(sets, fmt.Sprintf("dezernat = $%d", i))
|
||||
args = append(args, val)
|
||||
i++
|
||||
}
|
||||
if input.Lang != nil {
|
||||
lang := strings.ToLower(strings.TrimSpace(*input.Lang))
|
||||
if lang != "de" && lang != "en" {
|
||||
@@ -406,13 +448,15 @@ func (s *UserService) IsAdmin(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
// AdminCreateInput is the payload an admin uses to onboard a colleague who
|
||||
// already exists in auth.users. Email is required (must already be in
|
||||
// auth.users with an allowed domain — both checks happen in AdminCreateUser).
|
||||
//
|
||||
// Partner-unit membership is intentionally NOT settable here; admins assign
|
||||
// memberships separately via /admin/partner-units after the row exists.
|
||||
type AdminCreateInput struct {
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Office string `json:"office"`
|
||||
JobTitle string `json:"job_title,omitempty"` // defaults to 'Associate'
|
||||
Dezernat *string `json:"dezernat,omitempty"`
|
||||
Lang string `json:"lang,omitempty"` // defaults to 'de'
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Office string `json:"office"`
|
||||
JobTitle string `json:"job_title,omitempty"` // defaults to 'Associate'
|
||||
Lang string `json:"lang,omitempty"` // defaults to 'de'
|
||||
}
|
||||
|
||||
// AdminCreateUser inserts a paliad.users row for an auth.users entry that has
|
||||
@@ -449,14 +493,6 @@ func (s *UserService) AdminCreateUser(ctx context.Context, input AdminCreateInpu
|
||||
return nil, fmt.Errorf("%w: invalid lang %q", ErrInvalidInput, input.Lang)
|
||||
}
|
||||
|
||||
var dezernat *string
|
||||
if input.Dezernat != nil {
|
||||
trimmed := strings.TrimSpace(*input.Dezernat)
|
||||
if trimmed != "" {
|
||||
dezernat = &trimmed
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
@@ -486,9 +522,9 @@ func (s *UserService) AdminCreateUser(ctx context.Context, input AdminCreateInpu
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, job_title, global_role, dezernat, lang)
|
||||
VALUES ($1, $2, $3, $4, $5, 'standard', $6, $7)`,
|
||||
authID, email, displayName, input.Office, jobTitle, dezernat, lang,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, job_title, global_role, lang)
|
||||
VALUES ($1, $2, $3, $4, $5, 'standard', $6)`,
|
||||
authID, email, displayName, input.Office, jobTitle, lang,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("insert user: %w", err)
|
||||
}
|
||||
@@ -507,7 +543,6 @@ type AdminUpdateInput struct {
|
||||
Office *string `json:"office,omitempty"`
|
||||
JobTitle *string `json:"job_title,omitempty"`
|
||||
GlobalRole *string `json:"global_role,omitempty"`
|
||||
Dezernat *string `json:"dezernat,omitempty"`
|
||||
AdditionalOffices *[]string `json:"additional_offices,omitempty"`
|
||||
Lang *string `json:"lang,omitempty"`
|
||||
EmailPreferences *json.RawMessage `json:"email_preferences,omitempty"`
|
||||
@@ -595,18 +630,6 @@ func (s *UserService) AdminUpdateUser(ctx context.Context, id uuid.UUID, input A
|
||||
args = append(args, gr)
|
||||
i++
|
||||
}
|
||||
if input.Dezernat != nil {
|
||||
trimmed := strings.TrimSpace(*input.Dezernat)
|
||||
var val any
|
||||
if trimmed == "" {
|
||||
val = nil
|
||||
} else {
|
||||
val = trimmed
|
||||
}
|
||||
sets = append(sets, fmt.Sprintf("dezernat = $%d", i))
|
||||
args = append(args, val)
|
||||
i++
|
||||
}
|
||||
if input.AdditionalOffices != nil {
|
||||
// Validate each key against the canonical office list. A typo here
|
||||
// would silently break the /team filter pills for that user.
|
||||
|
||||
@@ -65,12 +65,10 @@ func TestUserService_Create_Valid(t *testing.T) {
|
||||
seedAuthUser(t, pool, id, "first@hlc.com")
|
||||
defer cleanupUsers(t, pool, id)
|
||||
|
||||
dezernat := " Team Müller "
|
||||
u, err := users.Create(context.Background(), id, "first@hlc.com", CreateUserInput{
|
||||
DisplayName: " First User ",
|
||||
Office: "munich",
|
||||
JobTitle: "Trainee",
|
||||
Dezernat: &dezernat,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
@@ -84,9 +82,6 @@ func TestUserService_Create_Valid(t *testing.T) {
|
||||
if u.Office != "munich" || u.JobTitle == nil || *u.JobTitle != "Trainee" || u.Email != "first@hlc.com" {
|
||||
t.Errorf("field mismatch: %+v", u)
|
||||
}
|
||||
if u.Dezernat == nil || *u.Dezernat != "Team Müller" {
|
||||
t.Errorf("dezernat not trimmed/persisted: %+v", u.Dezernat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserService_Create_InvalidInput(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user