Merge: t-paliad-148 — split project_teams.role into firm-level profession + project-level responsibility (migration 059 + ApprovalService tuple-with-gate ladder + 3-col team table + admin-team profession + onboarding picker)

This commit is contained in:
m
2026-05-07 22:00:57 +02:00
23 changed files with 1969 additions and 247 deletions

View File

@@ -0,0 +1,841 @@
# Profession vs project responsibility — split `project_teams.role`
Inventor: kepler · Date: 2026-05-07 · Issue: m/paliad#6 (t-paliad-148)
Branch: `mai/kepler/inventor-profession-vs`
Status: **READY FOR REVIEW** — awaits m's go on the 12 open questions before any coder shift.
---
## §0 TL;DR
`paliad.project_teams.role` does two jobs at once: it labels a user's
**career tier at the firm** (PA, Associate, Of Counsel) **and** it labels
their **responsibility on this project** (Lead, Observer). m's bug report
(2026-05-06): you don't redefine someone's profession when staffing them
on a matter. The team-add dropdown should let you pick *responsibility*
only; profession should come from the firm record.
This design splits the column into two:
1. **`paliad.users.profession`** — firm-wide career tier
(`partner | of_counsel | associate | senior_pa | pa | paralegal | NULL`).
Drives the t-138 approval ladder. NULL means "no firm tier" (external).
2. **`paliad.project_teams.responsibility`** — per-project responsibility
(`lead | member | observer | external`). Default `member`. Drives a
simple gate — `lead` and `member` open authority; `observer` and
`external` close it. Replaces the team-add dropdown values m
complained about.
Approval ladder migrates from `project_teams.role` to a tuple
**(profession, responsibility)** evaluated as: *level = profession_level
if responsibility ∈ {lead, member} else 0*. Policy grammar
(`required_role` single-value) stays unchanged from t-138.
Single migration 057. Backfills profession from the highest legacy
`project_teams.role` per user. project_teams.role kept as a deprecated
shadow column for one release, dropped in 058.
---
## §1 Problem & locked premises
### What m said (2026-05-06)
> "The Role should not be definable there. Whether a team member is PA
> or Associate etc is not defined when adding existing members. Roles
> for the project, maybe. But not the 'profession'."
### Locked decisions (m, 2026-05-06)
- **Profession is not redefined per project.** It comes from the user's
firm-level record.
- **Project-level role is meaningful.** Stays editable per project, but
with a smaller value set focused on responsibility.
- **Approval ladder must keep working** — t-138 just shipped. Whatever
drives the ladder must still drive it.
### Three-axis principle (held since t-051)
> "Firm roles ≠ project roles ≠ tool roles."
Today's surfaces:
| Axis | Today's column | Today's values |
|---|---|---|
| Firm — display | `paliad.users.job_title` (free text) | "Counsel Knowledge Lawyer", "Junior Associate"… |
| Firm — tool admin | `paliad.users.global_role` | `standard \| global_admin` |
| Firm — partner-unit slot | `paliad.partner_unit_members.unit_role` | `lead \| attorney \| senior_pa \| pa \| paralegal` (per-unit, not firm-wide) |
| Project — staffing | `paliad.project_teams.role` | **mixed: profession + responsibility** ← the bug |
The split adds a fourth, missing axis — **firm career tier** as a
structured value that drives approval authority. It does not collapse
job_title (free-text label is still useful) or unit_role (per-unit slot
is still useful — see t-139 §11).
---
## §2 Verified live state (2026-05-07)
Probed `ydb` (paliad schema, port 11833) and current branch:
- `paliad.project_teams` CHECK on `role`: `lead, associate, pa,
of_counsel, local_counsel, expert, observer, senior_pa` (from
migration 054).
- `paliad.project_teams` row count: **3 rows, all `role='lead'`**.
- `paliad.partner_unit_members` CHECK on `unit_role`: `lead, attorney,
senior_pa, pa, paralegal`. Row count: **20 rows, all
`unit_role='attorney'`** (the default — nobody has overridden it
yet).
- `paliad.users` columns include `job_title` (text NULL), `global_role`
(text NOT NULL DEFAULT 'standard'). No profession column.
- `paliad.approval_role_level(text) RETURNS int IMMUTABLE` — strict
ladder helper, used in 4 SQL sites in `approval_service.go`.
- `paliad.approval_role_from_unit_role(text) RETURNS text IMMUTABLE` —
bridges unit_role → ladder values for derived authority.
- t-138 (commit `e2e1381`) and t-139 phases 13 all merged on `main`.
Migration tracker at 56 (next is **057**).
**Implication of the live data**: backfill is essentially trivial. Three
project_teams rows. Twenty partner_unit_members rows. The risk surface
of the migration is the SQL rewiring, not the data movement.
### Inventory of references to migrate
| File | Site | What it reads |
|---|---|---|
| `internal/services/team_service.go:53,93,103,122,159` | INSERT/SELECT/validate | `pt.role` for read+write of project membership. |
| `internal/services/derivation_service.go:118,127,314,383,403` | EffectiveProjectRole + manage gate | `pt.role` for ancestor walk + `RoleLead` for project-lead-can-manage check. |
| `internal/services/approval_service.go:103,411,751,854` | canApprove + ListPending + bell badge + deadlock check | `paliad.approval_role_level(pt.role)` — 4 SQL sites. |
| `internal/services/reminder_service.go:317,330` | reminder digest filter | `pt.role = 'lead'` — project-responsibility check. |
| `internal/services/deadline_service.go:695` | legacy authority check | `pt.role IN ('admin', 'lead')` — `'admin'` is dead since t-051; this is half-broken already. |
| `internal/services/project_service.go:486` | creator-as-lead INSERT | `INSERT … role='lead'`. |
| `internal/services/approval_levels.go:70` | Go-side `levelOf()` | Mirror of SQL ladder. Must change with the SQL. |
| `internal/services/project_service.go:57-66` | `RoleLead` etc. constants | Used in 14 places across services. |
| `internal/db/migrations/055_hierarchy_aggregation.up.sql:84,92` | can_see_project body | `pt.role = 'lead'`. |
| `frontend/src/projects-detail.tsx:124-132` | team-add dropdown | The 7 mixed options m complained about. |
| `frontend/src/client/projects-detail.ts:1665,1720,1772,1856` | render + read of role | i18n `projects.team.role.*`. |
| `frontend/src/client/i18n.ts:1139-1145, 2949-2955` | role translations | DE+EN keys. |
This is a wide rewrite but it's mechanical — the column boundary is
clean, the call sites are narrow, and the live data is small.
---
## §3 Sub-design A — Profession axis (Q1, Q2, Q3, Q12)
### Q1 — Where does profession live? Recommendation: **(a) new `paliad.users.profession` column**
Three candidates from issue body:
(a) New `paliad.users.profession` column (firm-wide, simple).
(b) Reuse `paliad.partner_unit_members.unit_role` (already added by
t-139 Phase 2; only set when the user is in a unit).
(c) New separate `paliad.user_professions(user_id, profession,
valid_from)` table for history.
**Recommend (a).**
Rationale:
- (b) breaks for users not in a partner unit. Today: 31 users, ~20 in
units. The other 11 (admins, externals, future hires) have no
unit_role. Profession needs to be defined for everyone or the
approval ladder gets gappy.
- (b) creates ambiguity if a user joins multiple units with different
unit_roles (legal under the t-139 schema). Picking "the highest" or
"the first" hides the data confusion. A firm-wide column is
unambiguous by construction.
- (b) re-couples the per-unit axis to the firm-wide axis. t-139 §11
explicitly kept `unit_role` per-unit to preserve the three-axis
principle. Reusing it for firm-wide authority breaks that invariant.
- (c) overengineered for v1. Profession changes when an HR promotion
fires — no audit, no time-slice. If history becomes a requirement,
add the table later (out-of-scope per issue body).
(a) is one column, one CHECK, no joins on the read path, no per-unit
ambiguity. Drop-in replacement for the slot in the approval ladder.
**Schema:**
```sql
ALTER TABLE paliad.users
ADD COLUMN profession text NULL
CHECK (profession IS NULL OR profession IN (
'partner', 'of_counsel', 'associate',
'senior_pa', 'pa', 'paralegal'
));
CREATE INDEX users_profession_idx ON paliad.users (profession);
```
NULL is a valid value: it means "no firm career tier" (e.g. external
local counsel signed up via invitation, or admin accounts that aren't
practicing lawyers). NULL → ladder level 0 → ineligible to approve.
`job_title` (free-text display) and `global_role` (tool admin) remain
untouched. Three firm-axis columns:
| Column | Purpose | Approval-relevant? |
|---|---|---|
| `users.job_title` | Free-text display label ("Counsel Knowledge Lawyer") | No |
| `users.profession` | Structured career tier (drives ladder) | **Yes** |
| `users.global_role` | Tool admin gate (`standard \| global_admin`) | Override only |
### Q2 — Profession values Recommendation: **`partner | of_counsel | associate | senior_pa | pa | paralegal`** (NULL = external)
The t-138 ladder defined 5 active levels. Today they are mixed
project-level + profession-level:
| Today | Level | Belongs on which axis? |
|---|---|---|
| `lead` | 5 | **project responsibility** (the lawyer in charge of THIS matter) |
| `of_counsel` | 4 | profession |
| `associate` | 3 | profession |
| `senior_pa` | 2 | profession |
| `pa` | 1 | profession |
| `local_counsel` | 0 | project responsibility (`external`) |
| `expert` | 0 | project responsibility (`external`) |
| `observer` | 0 | project responsibility |
Removing the project-axis values from the ladder leaves 4 profession
tiers (of_counsel, associate, senior_pa, pa). But "lead" was implicitly
"a partner is leading this matter", so profession needs **`partner`**
at level 5 to preserve the ceiling.
Add **`paralegal`** at level 0 (mirrors `partner_unit_members.unit_role`
which already has it; current `approval_role_from_unit_role` already
maps it to `observer`/level 0).
Final enum (6 values + NULL):
| Profession | Ladder level | Notes |
|---|---|---|
| `partner` | 5 | Replaces the project-level `lead` as the firm-tier ceiling. |
| `of_counsel` | 4 | unchanged |
| `associate` | 3 | unchanged; default for new firm members |
| `senior_pa` | 2 | unchanged |
| `pa` | 1 | unchanged |
| `paralegal` | 0 | New — present in unit_role; ineligible to approve. |
| NULL | 0 | "External / no firm tier." Approval-ineligible. |
**Why not include `senior_associate`, `counsel`, `trainee`, etc.** that
appear in the existing `i18n.team.role.*` keys (free-text user
directory): those values don't change the ladder level
(senior_associate = associate tier; counsel = of_counsel tier; trainee
= ineligible). Adding them inflates the enum without adding
authority-relevant distinctions. They live in `job_title` (free text)
where they belong. If HR later needs structured senior_associate vs
associate, the migration is one CHECK alter; the call sites are zero
because the ladder only sees levels.
**External roles (`local_counsel`, `expert`)** in the issue body are
project-only labels — they describe what a person *is on this matter*,
not a firm career tier. They land in §4 as `responsibility='external'`.
Their profession is NULL.
### Q3 — Onboarding flow Recommendation: **required-on-invite, default suggestion = `associate`, admin-editable later**
Three options:
- (i) Auto-default to `associate` with admin-edit later.
- (ii) Required-on-invite: inviting colleague picks profession.
- (iii) User picks own profession on first login.
**Recommend (ii) with default = `associate`.**
Rationale:
- (i) recreates the bug m just complained about, in slow motion. Every
PA invited gets shown as "associate" until someone notices and
edits. The whole point of this work is "profession is real, set it
honestly".
- (iii) is wrong: you don't redefine your own firm tier; HR/the firm
does. Self-pick also breaks the audit (anyone could promote
themselves).
- (ii) is one extra `<select>` on the existing invite form (already
rebuilt for t-paliad-141). The inviter is a colleague — they know
whether they're inviting a PA or an associate.
- Default `associate` makes the most common case one click. PAs and
Of Counsels are explicit choices, not silent demotions.
**External invitees** (local counsel, expert): inviter sets
`responsibility='external'` on the project; profession defaults to
NULL (not asked) — the form hides the profession field when
responsibility=external. Admin can fill profession later if the
external collaborator becomes a paliad-tracked firm member.
### Q12 — Bulk add / invite-new flow Recommendation: **profession capture on invite; NULL allowed; admin-edits later**
The existing invite-new-user flow (`team-user-invite-btn` →
`/api/team/invite-new`) accepts email + display_name today. After this
change:
- Invite form gains a profession `<select>` (6 values + "Extern (keine
Profession)").
- Default selected: `associate`.
- Submit creates `paliad.users` row with the picked profession +
`paliad.project_teams` row with the picked responsibility (default
`member`).
- "Extern" sets `responsibility='external'` on the project_teams row,
profession=NULL on users.
**No bulk-add UI exists today** — out of scope. If/when one ships, it
inherits the same per-row profession field.
**Admin re-edit**: `/admin/team` page (already shipped t-paliad-050)
gets a Profession column with inline-edit dropdown. Position next to
job_title. No last-admin guard needed (profession is not a tool gate).
---
## §4 Sub-design B — Project responsibility axis (Q4, Q5, Q6, Q11)
### Q4 — Value set Recommendation: **`lead | member | observer | external`**
Issue body suggests this set. Locking it.
| Value | Meaning | Edit/approve authority |
|---|---|---|
| `lead` | The responsible lawyer/partner for this matter. Also has project-management permissions (manage settings, attach partner units — already wired in derivation_service.go). | Full (subject to profession ceiling). |
| `member` | Staffed on this matter at their profession's level. | Full (subject to profession ceiling). |
| `observer` | Read-only awareness; no edit/approve authority. | None. |
| `external` | Non-firm collaborator (local counsel, expert witness). May edit per project policy, but cannot satisfy the firm-tier approval ladder. | Edit yes, approve no. |
**Why not collapse `external` into `observer`**: externals can actively
write (local counsel files briefs, experts upload reports). Observers
can't. The two are distinct read/write profiles and conflating them
loses information.
**Why not add `pa-on-this-project` etc.** — that's profession × project,
exactly what we just split. Once split, never re-mix.
### Q5 — Default value Recommendation: **`member`**
m's intuition is right. Lock.
The team-add form's default selection is `member`. The project creator
is auto-added as `lead` (already coded in `project_service.go:486` —
just rename the inserted column from `role='lead'` to
`responsibility='lead'`).
### Q6 — Display Recommendation: **3 columns: Name · Profession (read-only badge) · Responsibility (editable inline)**, plus the existing Herkunft column
Layout for the team table on `/projects/{id}` Tab:
```
| Name | Profession | Responsibility (edit) | Herkunft | Aktion |
| Anna Schmidt | [PA] | [Lead ▾] | direkt | 🗑 |
| Max Mustermann| [Associate] | [Member ▾] | über X-Unit | 🗑 |
| Carla Smith | (extern) | [External] | direkt | 🗑 |
```
- **Profession** column: read-only `.projekt-team-profession` pill (CSS
variant of existing `.projekt-team-role`). Click for global_admin
opens `/admin/team#user-{id}` for inline edit. For non-admins, a
hover tooltip explains "Profession wird im Firmenprofil gepflegt".
- **Responsibility** column: existing inline-edit pattern (`.entity-row
select`) — reuses the t-paliad-141 inline-edit affordance. Edit
permission = project lead OR global_admin.
- NULL profession renders as `(extern)` or `(keine Profession)`
depending on `responsibility`.
Inline prose elsewhere (Verlauf entries, inbox rows, email reminders):
**"Anna Schmidt (PA) — Lead"** — profession in parens, responsibility
after a dash. Explicit and parseable.
For the audit trail (`paliad.project_events`), emit
`team_member_added` with `metadata = {responsibility: 'member',
profession_at_time: 'pa'}` so historic rendering survives a profession
change.
### Q11 — Team table layout post-fix Recommendation: **3-column tabular layout above; tooltip-only profession is rejected**
Two alternatives the issue posed:
- **Hover-only profession** ("Anna Schmidt — Lead", profession in
tooltip badge): rejected. Profession is too important to hide. The
whole point of the split is to make profession honestly visible.
- **3-column tabular**: chosen. Matches the existing `.entity-table`
pattern; profession is glanceable.
Tooltip is still useful as *secondary* signal: hover the profession
badge → "PA — gesetzt im Firmenprofil. (Edit by global_admin only)".
The team-add form (the bug surface m complained about) loses the
mixed-axis dropdown. New form:
```
[ User autocomplete ▾ ] ← picks Anna Schmidt
Anna Schmidt (PA) ← shown beneath, read from users.profession
[ Responsibility: Member ▾ ] ← only dropdown left; default Member
[ Cancel ] [ Hinzufügen ]
```
**If the picked person has profession=NULL:** show a yellow warning
under the profession line: "*Anna hat keine Profession gesetzt — sie
kann keine 4-Augen-Genehmigungen erteilen. Admin im Firmenprofil
nachtragen.*" Doesn't block the add, just informs.
---
## §5 Sub-design C — Approval ladder rename + migration (Q7, Q8, Q9, Q10)
### Q7 vs Q8 — Tuple-gated ladder Recommendation: **Q7 (rename to profession) with project-responsibility as a binary gate**
Two views the issue posed:
- **Q7**: ladder migrates from `project_teams.role` → `users.profession`.
Project responsibility goes elsewhere; the ladder is purely
profession-driven.
- **Q8**: ladder becomes a tuple `(profession,
project_responsibility)` — finer policies, e.g. "associate-level
lawyer who is at least a member on this project".
**Recommend Q7-with-gate**: the ladder is profession-driven, and
project responsibility acts as a *binary gate* (open/closed) rather
than a separate dimension in the policy grammar.
Effective level for user U on project P:
```
profession_level = approval_role_level(U.profession) -- 0 if NULL
responsibility = project_teams.responsibility on P (direct or ancestor)
gate_open = responsibility IN ('lead', 'member')
effective_level = profession_level if gate_open else 0
```
For derived (partner-unit) authority (t-139):
```
derived_role = approval_role_from_unit_role(unit_role)
when ppu.derive_grants_authority = true
effective_level = max over all sources (direct, ancestor, derived)
```
(The "max" is operative because a user might be a `member` of one
project at profession=PA, AND derive-with-authority into the same
project via a partner-unit attachment with unit_role=senior_pa. Take
the higher.)
**Why not pure Q8?**
- Pure tuple-grammar means policies look like `required_role='associate'
AND required_responsibility='lead'`. Fine for power users; nobody
has asked. We can add the responsibility dimension to
`approval_policies` later (one new nullable column) if the firm
wants finer rules. v1 stays single-dimension, matching m's t-138
Q3 lock ("per-(project, entity_type, lifecycle_event)
required_role").
- Pure tuple also breaks Verlauf/audit phrasing — the audit currently
reads "Genehmigung erforderlich: Associate-Tier oder höher", which
stays clean under Q7-with-gate. Tuple grammar would need
"Associate-Tier UND mindestens Mitglied".
**Why not pure Q7 without the gate?**
- Without the gate, an `observer` who happens to be a Partner could
approve. That defeats the project-level call. The whole reason
someone is set as observer is "you're not authoritative here, even
though you're senior". The gate restores that semantics.
- `external` (local counsel) without a gate would also approve via
their own firm tier — except their profession is NULL, so they're
level 0 anyway. The gate is defense-in-depth there: if a future
external is given profession=of_counsel by mistake, the
responsibility=external still keeps them at level 0.
**Implementation site**: a new SQL function
`paliad.user_project_authority_level(_user_id uuid, _project_id uuid)
RETURNS int IMMUTABLE` encapsulates the (profession, responsibility,
derivation) computation. Replaces inline
`paliad.approval_role_level(pt.role)` at the 4
`approval_service.go` SQL sites. Plus a Go mirror
`UserProjectAuthorityLevel(ctx, userID, projectID) int` for callers
that need it without a SQL roundtrip (none today, but the
DerivationService.EffectiveProjectRole becomes a thin wrapper).
Policy grammar stays exactly as t-138 designed. `required_role` is a
profession value (`partner`, `of_counsel`, `associate`, `senior_pa`,
`pa`). The CHECK on `approval_policies.required_role` is updated to
the new enum (drop 'lead' — was the project-level value — and rename
nothing; the SQL ladder values are 1:1 except the ceiling). Existing
policy rows get backfilled `lead → partner` (the only mapping that
changes).
### Q9 — Backfill plan Recommendation: **highest-tier-observed per user; `lead/of_counsel/associate/senior_pa/pa → matching profession`; `local_counsel/expert/observer → NULL`**
Backfill rules:
**Profession** (firm-wide, one row per user):
For each user with at least one `paliad.project_teams` row:
```
profession = highest tier among all (direct) project_teams rows
where:
legacy 'lead' → 'partner' (level 5)
legacy 'of_counsel' → 'of_counsel'(level 4)
legacy 'associate' → 'associate' (level 3)
legacy 'senior_pa' → 'senior_pa' (level 2)
legacy 'pa' → 'pa' (level 1)
legacy 'local_counsel' → IGNORED
legacy 'expert' → IGNORED
legacy 'observer' → IGNORED
```
If after ignoring project-only labels the user has no firm-tier row →
profession = NULL.
For users with NO project_teams rows → profession = NULL too. Admin
edits at `/admin/team` if those users are firm members (the 11
unit-only users in current data).
**Tie-break**: pick the highest level. If a user is `lead` on Project A
and `pa` on Project B, profession = `partner` (level 5 > level 1).
This matches m's "highest-tier observed" rule from the issue body.
**Edge case — only `observer` rows**: the user has exactly one
`observer` row across all projects. Profession = NULL (no firm tier
inferable from the data). Admin will need to set it.
**Edge case — `local_counsel` rows only**: user is external. Profession
= NULL. Their project_teams.responsibility row will be 'external'
(see below).
**Responsibility** (per project_teams row):
```
legacy 'lead' → 'lead'
legacy 'observer' → 'observer'
legacy 'local_counsel' → 'external'
legacy 'expert' → 'external'
legacy 'associate' → 'member'
legacy 'pa' → 'member'
legacy 'of_counsel' → 'member'
legacy 'senior_pa' → 'member'
```
This preserves m's stated rules:
- `lead` → `lead`
- `observer` → `observer`
- everything else (firm tier) → `member` (their authority is now
encoded in their profession; the project row just says "they're
staffed")
External labels (`local_counsel`, `expert`) get
`responsibility='external'`. Their profession remains NULL (the
backfill above ignores them for profession purposes).
**Live data sanity check**: today there are 3 project_teams rows, all
`role='lead'`. Backfill produces:
- 3 users get `profession='partner'`.
- 3 project_teams rows get `responsibility='lead'`.
All other users (28 of 31) get `profession=NULL` until admin edits
them at `/admin/team`. Acceptable — the firm has known they need an
audit pass over user records since t-051; this surfaces it cleanly.
### Q10 — Down-migration safety Recommendation: **reversible with documented data loss on edge cases**
Down-migration steps (`057_down`):
1. Re-derive `project_teams.role` from `(responsibility, profession)`:
```sql
UPDATE paliad.project_teams pt
SET role = CASE
WHEN pt.responsibility = 'lead' THEN 'lead'
WHEN pt.responsibility = 'observer' THEN 'observer'
WHEN pt.responsibility = 'external' THEN 'local_counsel'
WHEN pt.responsibility = 'member' THEN COALESCE(
(SELECT u.profession FROM paliad.users u WHERE u.id = pt.user_id),
'associate'
)
END;
```
- `external` always maps back to `local_counsel` (most common
pre-split external label; `expert` is rarer and lossy).
- `member` with profession=`partner` maps back to… ambiguous.
Pre-split there was no firm-tier `partner` row in
`project_teams`. Document data loss: maps to `of_counsel` (next
highest legacy value). If the down is run, the partner re-appears
as of_counsel on that project. Acceptable for a rollback.
- `member` with profession=`paralegal` maps back to `pa` (closest
legacy fit; `paralegal` was never a `project_teams.role` value).
- `member` with profession=NULL maps back to `associate` (safe
default, matches the legacy `RoleAssociate` default).
2. DROP COLUMN `paliad.users.profession`.
3. DROP COLUMN `paliad.project_teams.responsibility`.
4. Drop `paliad.user_project_authority_level` function.
5. Restore `approval_service.go` SQL sites to inline
`approval_role_level(pt.role)`.
Down-migration is best-effort. Documented data loss in `057_down.sql`
comments. The Go code on `main` doesn't need to support both states
(paliad doesn't have multi-version-deployed history); a down is a
manual rollback path.
**Phasing**: `project_teams.role` stays on the table as a deprecated
shadow column for one release (migration 057 keeps it; migration 058 —
follow-up ticket — drops it after Go code is fully migrated). This
means even in the worst case, a fast down doesn't have to recompute
`role`; it just drops the new columns and keeps the old.
---
## §6 Migration plan — single migration 057
Filename: `internal/db/migrations/057_profession_vs_responsibility.up.sql`
Sections:
1. ADD `paliad.users.profession`.
2. ADD `paliad.project_teams.responsibility`.
3. CREATE `paliad.user_project_authority_level(user_id, project_id)`
function.
4. UPDATE `paliad.approval_policies.required_role` CHECK to add
`'partner'` and drop `'lead'`. Backfill `'lead'` → `'partner'` in
any existing rows.
5. Backfill `users.profession` per Q9.
6. Backfill `project_teams.responsibility` per Q9.
7. UPDATE `paliad.can_see_project` body — replace `pt.role = 'lead'`
with `pt.responsibility = 'lead'`. Function CASCADE-rebuild not
needed (only function body changes).
8. UPDATE the comment on `paliad.approval_role_level` to point at
`users.profession` instead of `project_teams.role`.
`project_teams.role` is **kept** in this migration (deprecated, not
read by any new code). Drop in follow-up migration 058 after Go code
fully migrates and is verified live.
### Service-layer migration (single PR alongside 057)
Files to edit:
- `internal/services/team_service.go` — INSERT/SELECT/validate the new
`responsibility` column. `isValidRole` becomes
`isValidResponsibility` with new enum.
- `internal/services/derivation_service.go` — `requireWritePermission`
reads `pt.responsibility = 'lead'` instead of `pt.role = 'lead'`.
`EffectiveProjectRole` (used by t-138 derived authority) replaced by
`UserProjectAuthorityLevel` (returns int from the SQL function +
source string). `ListAttachedUnits`, `ListDerivedMembers` unchanged
(they don't touch the ladder column).
- `internal/services/approval_service.go` — 4 SQL sites switch from
`paliad.approval_role_level(pt.role)` to
`paliad.user_project_authority_level(pt.user_id, $project_id)`.
Self-approval CHECK and policy lookup stay identical.
- `internal/services/approval_levels.go` — Go-side `levelOf()` becomes
`professionLevel()`; new helper `responsibilityOpensGate()`.
`RoleSeniorPA` constant stays (still a valid profession value,
reused). New constants `ProfessionPartner`, `ProfessionOfCounsel`,
`ProfessionAssociate`, `ProfessionSeniorPA`, `ProfessionPA`,
`ProfessionParalegal`. New constants `ResponsibilityLead`,
`ResponsibilityMember`, `ResponsibilityObserver`,
`ResponsibilityExternal`.
- `internal/services/project_service.go:486` — INSERT writes
`responsibility='lead'` (creator-as-lead). Old
`RoleLead`/`RoleAssociate`/etc constants stay as aliases for one
release to ease grep diffs; mark deprecated.
- `internal/services/reminder_service.go:317,330` —
`pt.role = 'lead'` → `pt.responsibility = 'lead'`.
- `internal/services/deadline_service.go:695` —
`pt.role IN ('admin', 'lead')` → `pt.responsibility = 'lead'`.
(`'admin'` was already dead since t-051; this is also a small
cleanup.)
- `internal/services/user_service.go` — onboarding/invite code
accepts a `profession` arg, stores on insert.
- `internal/handlers/team.go` (and friends) — JSON shape change:
`ProjectTeamMember` now exposes `responsibility` instead of `role`,
embeds `User.Profession`.
- `internal/models/models.go` — `ProjectTeamMember.Role` → `.Responsibility`;
`User` gains `.Profession *string`.
### Frontend migration (same PR)
- `frontend/src/projects-detail.tsx:124-132` — replace 7-option mixed
dropdown with 4-option responsibility-only dropdown
(`lead | member | observer | external`). Default `member`.
- `frontend/src/client/projects-detail.ts:1665,1720,1772,1856` — render
3-column team table. New `.projekt-team-profession` CSS pill +
i18n keys `projects.team.profession.partner` …
`projects.team.profession.paralegal`. New i18n keys
`projects.team.responsibility.lead` … `.external` (replace
`projects.team.role.*`).
- `frontend/src/client/team.ts` — `/team` directory page: respect new
profession column for grouping. Falls back to job_title when
profession=NULL (existing free-text behaviour preserved for
externals).
- `frontend/src/admin-team.tsx` + `client/admin-team.ts` — add
Profession column with inline-edit dropdown.
- `frontend/src/onboarding.tsx` — invite flow gains a profession
`<select>`.
- ~30 new i18n keys DE+EN.
### Tests
- `internal/services/team_service_test.go` — happy path on
AddMember/RemoveMember with new responsibility values; reject
invalid values.
- `internal/services/approval_service_test.go` — extend
table-driven ladder tests to cover the
(profession, responsibility) tuple. Cases: `partner+observer = 0`,
`pa+lead = 1`, `null+member = 0`, derived+responsibility=external
combinations.
- `internal/services/migration_057_test.go` — live-DB integration
test (skipped without `TEST_DATABASE_URL`): apply migration on a
seeded snapshot, assert backfill produces expected
(profession, responsibility) pairs.
---
## §7 Implementation phasing
**Single PR, 6 commits** — the schema + service + frontend are tightly
coupled. Splitting risks half-broken intermediate states (the bug
report itself is about a half-broken intermediate state).
1. Migration 057 (schema + backfill + new SQL function). No code
changes — server still reads `pt.role`. Verify backfill on live DB
via BEGIN/ROLLBACK.
2. ApprovalService + DerivationService rewire. Tests updated. Build +
test green. Server reads from new SQL function but writes still go
to `pt.role` (will fix in commit 3).
3. TeamService + UserService rewire. INSERT writes
`responsibility=...`. Reads return `responsibility`. Models
updated. JSON schema change.
4. Frontend rewire — team-add dropdown, team table, admin-team,
onboarding. New i18n keys.
5. Reminder + Deadline service touch-ups + can_see_project body
refresh.
6. Lint + grep sweep — kill any remaining `pt.role` references that
should have been migrated. Add a deprecation comment to the
`RoleLead`/`RoleAssociate` Go constants pointing at the new ones.
**Follow-up ticket (out of scope for this PR)**: t-paliad-149 —
migration 058 to DROP COLUMN `project_teams.role` after one release of
soak time on main. Trivial when the time comes; just keeps this PR
clean.
**Recommended implementer**: any pattern-fluent coder. **NOT cronus**
(retired from paliad per memory directive). Sonnet work — 70% of the
diff is mechanical rename, 30% is the new SQL function + 4 ladder-site
rewrites + the new team-table layout. The substrate is well-trodden
(t-051 split established the pattern; t-138/t-139 left clean call
sites to migrate from).
---
## §8 Trade-offs flagged
1. **One migration touches both axes at once.** A pure-additive
migration (add columns, leave `role`) would be safer-feeling, but
then the team-add dropdown bug stays open (the UX lie m hates is
still on screen until commit 4). I prefer one PR that ships the
fix end-to-end, with `project_teams.role` deprecated-shadow for
one release as the safety net.
2. **Profession=NULL semantics are load-bearing.** NULL means "no
firm tier" → ladder level 0 → ineligible. If a developer later
adds a fast-path that defaults NULL→`associate` for "convenience",
externals would silently gain approval rights. Mitigation: explicit
helper `professionLevel(*string) int` that returns 0 for NULL with
a comment naming the trap. Add a unit test `TestProfessionLevel_NilIsZero`.
3. **`partner` is the new ceiling but `lead` is no longer a profession**.
The mental jump for users: "Lead" was the highest in the dropdown;
now "Partner" is. Renaming is honest but a moment of surprise.
Mitigation: i18n keys carry over the lead-on-this-project sense via
`projects.team.responsibility.lead` so the word "Lead" stays
visible exactly where it should — the project axis. Profession's
"Partner" appears in firm-context surfaces (admin/team, tooltips).
4. **Tuple-gated ladder vs pure-tuple grammar.** Choosing
responsibility as a binary gate means a future "must be a member,
not just having visibility" rule is easy. A future "must be lead
AND of_counsel-tier or higher" rule needs a new dimension on
`approval_policies` (new nullable column). Acceptable: zero
policies today need it; cheap to add when one does.
5. **Backfill produces 28 NULL professions** out of 31 users (the
ones not in any project_teams row). After ship, `/admin/team` will
show a warning column "Profession nicht gesetzt" until admin
completes the audit. This is honest visibility of pre-existing data
debt rather than papering over with a guessed default.
6. **`approval_role_from_unit_role` doesn't change** but its callers
(the derived-authority SQL branches in approval_service.go) need to
move from "compare against `pt.role`" to "compare against
`users.profession` of the project_teams row's user". Mechanical;
listed in §6 file inventory.
---
## §9 Out of scope (v1)
- Replacing the partner-unit-derivation mechanism (t-139 Phase 2) —
derivation stays exactly as designed.
- A full firm-roles / hierarchy / org-chart feature — this design adds
one structured column (profession) and nothing more.
- Multi-profession (paralegal-turned-associate scenario). One
profession per user; admin edits when promoted.
- Time-sliced profession history (who was a PA in 2024). Out per
issue body.
- Adding a `responsibility` dimension to `approval_policies` (Q8 pure
tuple grammar). Deferred to a future ticket if a real policy
requires it.
- Bulk-add UI for project members. None exists today.
- Dropping `project_teams.role` itself. Deferred to follow-up
migration 058 after one release of soak time.
---
## §10 12 Questions — Recommendation summary
| # | Question | Recommendation | Locked? |
|---|---|---|---|
| Q1 | Where does profession live? | (a) New `paliad.users.profession` text column | open — m sign-off |
| Q2 | Profession values | `partner \| of_counsel \| associate \| senior_pa \| pa \| paralegal` (NULL = external) | open — m sign-off |
| Q3 | Onboarding flow | Required-on-invite, default `associate`, admin-editable | open — m sign-off |
| Q4 | Project responsibility values | `lead \| member \| observer \| external` | open — m hinted yes |
| Q5 | Default value | `member` | open — m hinted yes |
| Q6 | Display | 3 columns: Name · Profession (badge) · Responsibility (inline-edit), plus existing Herkunft | open — m sign-off |
| Q7 vs Q8 | Ladder migration | Q7 (rename to profession) WITH project-responsibility as a binary gate (`responsibility ∈ {lead, member}` opens the gate) | open — main architectural call |
| Q9 | Backfill | Profession = highest legacy tier per user (`lead → partner`, `of_counsel → of_counsel`, …, externals → NULL); responsibility per single-row mapping (`lead → lead`, `observer → observer`, externals → `external`, others → `member`) | open — m sign-off |
| Q10 | Down-migration | Reversible with documented best-effort data loss; `project_teams.role` kept as deprecated shadow until follow-up 058 | open — m sign-off |
| Q11 | Team table layout | 3-column tabular (rejecting tooltip-only profession); inline-edit responsibility; profession edits live on `/admin/team` | open — m sign-off |
| Q12 | Bulk add / invite | Profession capture on invite (default `associate`, "Extern" hides field). No bulk-add v1. Admin re-edits via `/admin/team` | open — m sign-off |
---
## §11 Coordination with sibling work
- **t-138 (approvals)**: shipped 2026-05-06 (commit `e2e1381`). Migration
054 sets up the ladder; this design extends it to read from
`users.profession` instead of `project_teams.role`. Policy grammar
unchanged. `required_role` enum gains `partner`, drops `lead`
(renamed in backfill).
- **t-139 (hierarchy + derivation)**: all 3 phases shipped. Migration
055 added `partner_unit_members.unit_role` and the
`approval_role_from_unit_role` bridge. This design leaves the
bridge untouched — `unit_role` values map 1:1 to the new profession
enum (`lead → partner`, `attorney → associate`, `senior_pa →
senior_pa`, `pa → pa`, `paralegal → paralegal`). Update the bridge's
`lead → lead` row to `lead → partner` in migration 057.
- **t-144 (Custom Views)**: shipped. ViewService.runApprovalRequests
uses ApprovalService.ListPendingForApprover, which reads the new
ladder. Inherits the change automatically.
- **t-paliad-145 (local chat)**: parked. Not relevant.
No siblings are blocked by this work, and this work doesn't block any
sibling. Independent migration, independent merge.
---
## §12 Inventor parking
Inventor (kepler) parks here. Awaits m's pass through the 12 questions
in §10 + any course-correction. After m signs off, this design locks
and a fresh coder shift can pick up the single PR. Branch:
`mai/kepler/inventor-profession-vs`.
DESIGN READY FOR REVIEW.

View File

@@ -72,6 +72,7 @@ export function renderAdminTeam(): string {
<th data-i18n="admin.team.col.email">E-Mail</th>
<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.profession">Profession</th>
<th data-i18n="admin.team.col.permission">Berechtigung</th>
<th data-i18n="admin.team.col.additional">Weitere Standorte</th>
<th data-i18n="admin.team.col.lang">Sprache</th>
@@ -80,7 +81,7 @@ export function renderAdminTeam(): string {
</tr>
</thead>
<tbody id="admin-team-tbody">
<tr><td colspan={9} className="admin-team-loading" data-i18n="admin.team.loading">Lade...</td></tr>
<tr><td colspan={10} className="admin-team-loading" data-i18n="admin.team.loading">Lade...</td></tr>
</tbody>
</table>
</div>

View File

@@ -8,6 +8,10 @@ interface User {
office: string;
additional_offices?: string[];
job_title: string | null;
// t-paliad-148: structured firm-tier (partner/of_counsel/associate/
// senior_pa/pa/paralegal). NULL = external. Editable via the
// admin-team Profession column.
profession?: string | null;
global_role: string;
lang: string;
reminder_morning_time?: string;
@@ -16,6 +20,15 @@ interface User {
created_at: string;
}
const PROFESSION_VALUES = [
"partner",
"of_counsel",
"associate",
"senior_pa",
"pa",
"paralegal",
];
interface Office {
key: string;
label_de: string;
@@ -180,6 +193,26 @@ function permissionEditor(u: User): string {
return `<select class="admin-team-input" data-field="global_role"${disabled}${title}>${standardOpt}${adminOpt}</select>`;
}
function professionLabel(p: string | null | undefined): string {
if (!p) return "";
return tDyn(`projects.team.profession.${p}`) || p;
}
function professionCell(u: User): string {
if (!u.profession) {
return `<span class="admin-team-muted" title="${esc(t("admin.team.col.profession.none.hint") || "Keine Profession gesetzt — keine 4-Augen-Befugnis")}">${esc(t("projects.team.profession.none") || "(extern)")}</span>`;
}
return `<span class="projekt-team-profession">${esc(professionLabel(u.profession))}</span>`;
}
function professionEditor(u: User): string {
const noneOpt = `<option value=""${!u.profession ? " selected" : ""}>${esc(t("admin.team.col.profession.none") || "(extern)")}</option>`;
const opts = PROFESSION_VALUES.map(
(p) => `<option value="${esc(p)}"${u.profession === p ? " selected" : ""}>${esc(professionLabel(p))}</option>`,
).join("");
return `<select class="admin-team-input" data-field="profession">${noneOpt}${opts}</select>`;
}
function renderRow(u: User): string {
if (editingId === u.id) return renderEditRow(u);
const additional = (u.additional_offices ?? []).filter((o) => o !== u.office);
@@ -190,6 +223,7 @@ function renderRow(u: User): string {
<td><a href="mailto:${esc(u.email)}">${esc(u.email)}</a></td>
<td><span class="office-chip office-${esc(u.office)}">${esc(officeLabel(u.office))}</span></td>
<td>${jobTitle ? esc(jobTitle) : "<span class=\"admin-team-muted\">—</span>"}</td>
<td>${professionCell(u)}</td>
<td>${permissionCell(u)}</td>
<td>${additional.length ? additional.map((o) => esc(officeLabel(o))).join(", ") : "<span class=\"admin-team-muted\">—</span>"}</td>
<td>${esc(u.lang.toUpperCase())}</td>
@@ -214,6 +248,7 @@ function renderEditRow(u: User): string {
<input type="text" class="admin-team-input" data-field="job_title" value="${esc(jobTitle)}" list="admin-team-job-title-suggest-${esc(u.id)}" />
<datalist id="admin-team-job-title-suggest-${esc(u.id)}">${jobTitleList}</datalist>
</td>
<td>${professionEditor(u)}</td>
<td>${permissionEditor(u)}</td>
<td class="admin-team-multi">${additionalOfficesEditor(additional)}</td>
<td><select class="admin-team-input" data-field="lang">${langOptions(u.lang)}</select></td>

View File

@@ -950,6 +950,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.profession": "Profession",
"onboarding.profession.hint": "Strukturiertes Tier — steuert die 4-Augen-Genehmigung. Distinkt von der Berufsbezeichnung.",
"onboarding.partner_unit": "Partner Unit",
"onboarding.partner_unit.unassigned": "(noch keine Zuordnung)",
"onboarding.optional": "(optional)",
@@ -1093,10 +1095,15 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.verlauf.loadMore": "Mehr laden",
"projects.detail.team.form.user": "Benutzer",
"projects.detail.team.form.role": "Rolle",
"projects.detail.team.form.responsibility": "Rolle im Projekt",
"projects.detail.team.form.profession.label": "Profession",
"projects.detail.team.form.profession.none": "Keine Profession gesetzt \u2014 kann keine 4-Augen-Genehmigungen erteilen.",
"projects.detail.team.form.cancel": "Abbrechen",
"projects.detail.team.form.submit": "Hinzuf\u00fcgen",
"projects.detail.team.col.name": "Name",
"projects.detail.team.col.role": "Rolle",
"projects.detail.team.col.profession": "Profession",
"projects.detail.team.col.responsibility": "Rolle im Projekt",
"projects.detail.team.col.source": "Herkunft",
"projects.detail.kinder.add": "Untervorhaben anlegen",
"projects.detail.kinder.empty": "Keine untergeordneten Projekte.",
@@ -1144,6 +1151,19 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.team.role.local_counsel": "Local Counsel",
"projects.team.role.expert": "Experte",
"projects.team.role.observer": "Beobachter",
"projects.team.responsibility.lead": "Leitung",
"projects.team.responsibility.member": "Mitglied",
"projects.team.responsibility.observer": "Beobachter",
"projects.team.responsibility.external": "Extern",
"projects.team.profession.partner": "Partner",
"projects.team.profession.of_counsel": "Of Counsel",
"projects.team.profession.associate": "Associate",
"projects.team.profession.senior_pa": "Senior PA",
"projects.team.profession.pa": "PA",
"projects.team.profession.paralegal": "Paralegal",
"projects.team.profession.none": "(extern)",
"projects.team.profession.hint": "Profession \u2014 gesetzt im Firmenprofil",
"projects.team.profession.none.hint": "Keine Profession gesetzt \u2014 keine 4-Augen-Befugnis",
"projects.team.direct": "direkt",
"projects.team.inherited.hint": "Vererbt vom \u00dcberprojekt",
"projects.detail.team.add": "Mitglied hinzuf\u00fcgen",
@@ -1569,6 +1589,9 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.team.col.email": "E-Mail",
"admin.team.col.office": "Standort",
"admin.team.col.job_title": "Berufsbezeichnung",
"admin.team.col.profession": "Profession",
"admin.team.col.profession.none": "(extern)",
"admin.team.col.profession.none.hint": "Keine Profession gesetzt — keine 4-Augen-Befugnis.",
"admin.team.col.permission": "Berechtigung",
"admin.team.col.additional": "Weitere Standorte",
"admin.team.col.lang": "Sprache",
@@ -2843,6 +2866,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.profession": "Profession",
"onboarding.profession.hint": "Structured tier — drives the 4-eye approval ladder. Distinct from job title.",
"onboarding.partner_unit": "Partner Unit",
"onboarding.partner_unit.unassigned": "(not assigned yet)",
"onboarding.optional": "(optional)",
@@ -2986,10 +3011,15 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.verlauf.loadMore": "Load more",
"projects.detail.team.form.user": "User",
"projects.detail.team.form.role": "Role",
"projects.detail.team.form.responsibility": "Project role",
"projects.detail.team.form.profession.label": "Profession",
"projects.detail.team.form.profession.none": "No profession set — cannot grant 4-eye approvals.",
"projects.detail.team.form.cancel": "Cancel",
"projects.detail.team.form.submit": "Add",
"projects.detail.team.col.name": "Name",
"projects.detail.team.col.role": "Role",
"projects.detail.team.col.profession": "Profession",
"projects.detail.team.col.responsibility": "Project role",
"projects.detail.team.col.source": "Source",
"projects.detail.kinder.add": "Create sub-project",
"projects.detail.kinder.empty": "No sub-projects.",
@@ -3037,6 +3067,19 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.team.role.local_counsel": "Local Counsel",
"projects.team.role.expert": "Expert",
"projects.team.role.observer": "Observer",
"projects.team.responsibility.lead": "Lead",
"projects.team.responsibility.member": "Member",
"projects.team.responsibility.observer": "Observer",
"projects.team.responsibility.external": "External",
"projects.team.profession.partner": "Partner",
"projects.team.profession.of_counsel": "Of Counsel",
"projects.team.profession.associate": "Associate",
"projects.team.profession.senior_pa": "Senior PA",
"projects.team.profession.pa": "PA",
"projects.team.profession.paralegal": "Paralegal",
"projects.team.profession.none": "(external)",
"projects.team.profession.hint": "Profession — managed in firm profile",
"projects.team.profession.none.hint": "No profession set — no 4-eye authority",
"projects.team.direct": "direct",
"projects.team.inherited.hint": "Inherited from ancestor",
"projects.detail.team.add": "Add member",
@@ -3459,6 +3502,9 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.team.col.email": "Email",
"admin.team.col.office": "Office",
"admin.team.col.job_title": "Job title",
"admin.team.col.profession": "Profession",
"admin.team.col.profession.none": "(external)",
"admin.team.col.profession.none.hint": "No profession set — no 4-eye authority.",
"admin.team.col.permission": "Permission",
"admin.team.col.additional": "Additional offices",
"admin.team.col.lang": "Lang",

View File

@@ -122,6 +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 profession = (data.get("profession") as string || "").trim();
const partnerUnitID = (data.get("partner_unit_id") as string || "").trim();
if (!displayName) {
@@ -141,6 +142,7 @@ async function submitForm(e: Event): Promise<void> {
display_name: displayName,
office,
job_title: jobTitle,
profession,
};
if (partnerUnitID) payload.partner_unit_id = partnerUnitID;

View File

@@ -37,11 +37,19 @@ interface ProjectTeamMember {
id: string;
project_id: string;
user_id: string;
// t-paliad-148: per-project responsibility (lead/member/observer/external).
// The legacy .role field is still set by the server during the
// deprecation window but the UI ignores it for new code.
responsibility: string;
role: string;
inherited: boolean;
user_email: string;
user_display_name: string;
user_office: string;
// user_profession is the structured firm tier (partner/of_counsel/…/
// paralegal). NULL means external collaborator. Read-only here — the
// value is set on the user's firm profile, not at staffing time.
user_profession?: string | null;
inherited_from_id?: string | null;
inherited_from_title?: string | null;
}
@@ -204,7 +212,7 @@ let descendantStaffed: ProjectTeamMember[] = [];
let derivedMembers: DerivedMember[] = [];
let attachedUnits: AttachedUnit[] = [];
let allUnits: { id: string; name: string; office: string }[] = [];
let userOptions: { id: string; display_name: string; email: string }[] = [];
let userOptions: { id: string; display_name: string; email: string; profession?: string }[] = [];
const EVENTS_PAGE_SIZE = 50;
let eventsHasMore = false;
@@ -1662,7 +1670,18 @@ function renderTeam() {
body.innerHTML = teamMembers
.map((m) => {
const roleLabel = tDyn(`projects.team.role.${m.role}`) || m.role;
// t-paliad-148: profession is firm-wide (read-only badge) and
// responsibility is per-project. Both are surfaced; the legacy
// .role field is still set by the server during the deprecation
// window but the UI ignores it.
const responsibility = m.responsibility || "member";
const responsibilityLabel = tDyn(`projects.team.responsibility.${responsibility}`) || responsibility;
const professionLabel = m.user_profession
? tDyn(`projects.team.profession.${m.user_profession}`) || m.user_profession
: (t("projects.team.profession.none") || "(extern)");
const professionTitle = m.user_profession
? (t("projects.team.profession.hint") || "Profession — gesetzt im Firmenprofil")
: (t("projects.team.profession.none.hint") || "Keine Profession gesetzt — keine 4-Augen-Befugnis");
const source = m.inherited
? `<span class="projekt-team-inherited" title="${escAttr(t("projects.team.inherited.hint") || "Inherited from ancestor")}">
&uarr; ${esc(m.inherited_from_title || "")}
@@ -1673,10 +1692,12 @@ function renderTeam() {
? `<button type="button" class="btn-ghost btn-small team-remove-btn" data-user-id="${esc(m.user_id)}">${esc(t("projects.detail.team.remove") || "Entfernen")}</button>`
: "";
const officeLabel = m.user_office ? tDyn("office." + m.user_office) || m.user_office : "";
const profCls = m.user_profession ? "projekt-team-profession" : "projekt-team-profession projekt-team-profession--none";
return `<tr>
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
<span class="form-hint">&middot; ${esc(m.user_email)}${officeLabel ? " &middot; " + esc(officeLabel) : ""}</span></td>
<td><span class="projekt-team-role">${esc(roleLabel)}</span></td>
<td><span class="${profCls}" title="${escAttr(professionTitle)}">${esc(professionLabel)}</span></td>
<td><span class="projekt-team-responsibility">${esc(responsibilityLabel)}</span></td>
<td>${source}</td>
<td>${removeBtn}</td>
</tr>`;
@@ -1717,13 +1738,14 @@ function renderDescendantStaffed() {
section.style.display = "";
body.innerHTML = descendantStaffed
.map((m) => {
const roleLabel = tDyn(`projects.team.role.${m.role}`) || m.role;
const responsibility = m.responsibility || "member";
const responsibilityLabel = tDyn(`projects.team.responsibility.${responsibility}`) || responsibility;
const officeLabel = m.user_office ? tDyn("office." + m.user_office) || m.user_office : "";
const sourceTitle = esc(m.inherited_from_title || "");
return `<tr>
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
<span class="form-hint">&middot; ${esc(m.user_email)}${officeLabel ? " &middot; " + esc(officeLabel) : ""}</span></td>
<td><span class="projekt-team-role">${esc(roleLabel)}</span></td>
<td><span class="projekt-team-responsibility">${esc(responsibilityLabel)}</span></td>
<td><span class="projekt-team-inherited" title="${escAttr(t("aggregation.attribution.on") || "auf")}: ${sourceTitle}">&darr; ${sourceTitle}</span></td>
</tr>`;
})
@@ -1835,7 +1857,7 @@ function canManagePartnerUnits(): boolean {
if (me.global_role === "global_admin") return true;
if (!project) return false;
return teamMembers.some(
(m) => m.user_id === me!.id && m.role === "lead" && m.project_id === project!.id,
(m) => m.user_id === me!.id && m.responsibility === "lead" && m.project_id === project!.id,
);
}
@@ -1853,11 +1875,12 @@ function initTeamForm(id: string) {
const hidden = document.getElementById("team-user-id") as HTMLInputElement | null;
const sugs = document.getElementById("team-user-suggestions") as HTMLDivElement | null;
const msg = document.getElementById("team-msg") as HTMLParagraphElement | null;
const role = document.getElementById("team-role") as HTMLSelectElement | null;
const responsibility = document.getElementById("team-responsibility") as HTMLSelectElement | null;
const professionHint = document.getElementById("team-profession-hint") as HTMLParagraphElement | null;
const inviteHint = document.getElementById("team-user-invite-hint") as HTMLDivElement | null;
const inviteHintText = document.getElementById("team-user-invite-hint-text") as HTMLSpanElement | null;
const inviteBtn = document.getElementById("team-user-invite-btn") as HTMLButtonElement | null;
if (!addBtn || !form || !cancel || !input || !hidden || !sugs || !msg || !role) return;
if (!addBtn || !form || !cancel || !input || !hidden || !sugs || !msg || !responsibility) return;
const hideInviteHint = () => {
if (inviteHint) inviteHint.style.display = "none";
@@ -1913,6 +1936,24 @@ function initTeamForm(id: string) {
input.value = el.dataset.label!;
sugs.innerHTML = "";
hideInviteHint();
// t-paliad-148: surface the picked person's profession so the
// adder sees what firm tier they're staffing on this matter,
// and gets a warning when the user has no profession set.
if (professionHint) {
const picked = userOptions.find((u) => u.id === hidden.value);
const prof = picked?.profession;
if (!prof) {
professionHint.textContent = t("projects.detail.team.form.profession.none") ||
"Keine Profession gesetzt — kann keine 4-Augen-Genehmigungen erteilen.";
professionHint.className = "form-hint form-hint--warning";
professionHint.style.display = "";
} else {
const profLabel = tDyn(`projects.team.profession.${prof}`) || prof;
professionHint.textContent = `${t("projects.detail.team.form.profession.label") || "Profession"}: ${profLabel}`;
professionHint.className = "form-hint";
professionHint.style.display = "";
}
}
});
});
@@ -1947,7 +1988,7 @@ function initTeamForm(id: string) {
const resp = await fetch(`/api/projects/${id}/team`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: hidden.value, role: role.value }),
body: JSON.stringify({ user_id: hidden.value, responsibility: responsibility.value }),
});
if (!resp.ok) {
const b = await resp.json().catch(() => ({ error: "unknown" }));

View File

@@ -231,6 +231,9 @@ export type I18nKey =
| "admin.team.col.name"
| "admin.team.col.office"
| "admin.team.col.permission"
| "admin.team.col.profession"
| "admin.team.col.profession.none"
| "admin.team.col.profession.none.hint"
| "admin.team.confirm.delete"
| "admin.team.direct_add.body"
| "admin.team.direct_add.cancel"
@@ -1390,6 +1393,8 @@ export type I18nKey =
| "onboarding.optional"
| "onboarding.partner_unit"
| "onboarding.partner_unit.unassigned"
| "onboarding.profession"
| "onboarding.profession.hint"
| "onboarding.submit"
| "onboarding.title"
| "palette.action.app.invite"
@@ -1497,12 +1502,17 @@ export type I18nKey =
| "projects.detail.tab.verlauf"
| "projects.detail.team.add"
| "projects.detail.team.col.name"
| "projects.detail.team.col.profession"
| "projects.detail.team.col.responsibility"
| "projects.detail.team.col.role"
| "projects.detail.team.col.source"
| "projects.detail.team.confirm_remove"
| "projects.detail.team.empty"
| "projects.detail.team.error.user_required"
| "projects.detail.team.form.cancel"
| "projects.detail.team.form.profession.label"
| "projects.detail.team.form.profession.none"
| "projects.detail.team.form.responsibility"
| "projects.detail.team.form.role"
| "projects.detail.team.form.submit"
| "projects.detail.team.form.user"
@@ -1585,6 +1595,19 @@ export type I18nKey =
| "projects.team.derived.visibility"
| "projects.team.direct"
| "projects.team.inherited.hint"
| "projects.team.profession.associate"
| "projects.team.profession.hint"
| "projects.team.profession.none"
| "projects.team.profession.none.hint"
| "projects.team.profession.of_counsel"
| "projects.team.profession.pa"
| "projects.team.profession.paralegal"
| "projects.team.profession.partner"
| "projects.team.profession.senior_pa"
| "projects.team.responsibility.external"
| "projects.team.responsibility.lead"
| "projects.team.responsibility.member"
| "projects.team.responsibility.observer"
| "projects.team.role.associate"
| "projects.team.role.expert"
| "projects.team.role.lead"

View File

@@ -71,6 +71,24 @@ export function renderOnboarding(): string {
<option value="Sekretariat"></option>
</datalist>
<label htmlFor="onb-profession" className="login-label" data-i18n="onboarding.profession">Profession</label>
<select
id="onb-profession"
name="profession"
required
className="login-input"
>
<option value="associate" selected data-i18n="projects.team.profession.associate">Associate</option>
<option value="partner" data-i18n="projects.team.profession.partner">Partner</option>
<option value="of_counsel" data-i18n="projects.team.profession.of_counsel">Of Counsel</option>
<option value="senior_pa" data-i18n="projects.team.profession.senior_pa">Senior PA</option>
<option value="pa" data-i18n="projects.team.profession.pa">PA</option>
<option value="paralegal" data-i18n="projects.team.profession.paralegal">Paralegal</option>
</select>
<p className="login-hint" data-i18n="onboarding.profession.hint">
Strukturiertes Tier steuert die 4-Augen-Genehmigung. Distinkt von der Berufsbezeichnung.
</p>
<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>

View File

@@ -120,16 +120,14 @@ export function renderProjectsDetail(): string {
</div>
</div>
<div className="form-field">
<label htmlFor="team-role" data-i18n="projects.detail.team.form.role">Rolle</label>
<select id="team-role">
<option value="lead" data-i18n="projects.team.role.lead">Lead</option>
<option value="associate" selected data-i18n="projects.team.role.associate">Associate</option>
<option value="pa" data-i18n="projects.team.role.pa">PA</option>
<option value="of_counsel" data-i18n="projects.team.role.of_counsel">Of Counsel</option>
<option value="local_counsel" data-i18n="projects.team.role.local_counsel">Local Counsel</option>
<option value="expert" data-i18n="projects.team.role.expert">Experte</option>
<option value="observer" data-i18n="projects.team.role.observer">Beobachter</option>
<label htmlFor="team-responsibility" data-i18n="projects.detail.team.form.responsibility">Rolle im Projekt</label>
<select id="team-responsibility">
<option value="lead" data-i18n="projects.team.responsibility.lead">Lead</option>
<option value="member" selected data-i18n="projects.team.responsibility.member">Mitglied</option>
<option value="observer" data-i18n="projects.team.responsibility.observer">Beobachter</option>
<option value="external" data-i18n="projects.team.responsibility.external">Extern</option>
</select>
<p id="team-profession-hint" className="form-hint" style="display:none" />
</div>
</div>
<div className="form-actions">
@@ -143,7 +141,8 @@ export function renderProjectsDetail(): string {
<thead>
<tr>
<th data-i18n="projects.detail.team.col.name">Name</th>
<th data-i18n="projects.detail.team.col.role">Rolle</th>
<th data-i18n="projects.detail.team.col.profession">Profession</th>
<th data-i18n="projects.detail.team.col.responsibility">Rolle</th>
<th data-i18n="projects.detail.team.col.source">Herkunft</th>
<th />
</tr>

View File

@@ -6263,6 +6263,46 @@ input[type="range"]::-moz-range-thumb {
color: var(--color-text);
}
/* t-paliad-148 — profession (firm-tier, read-only) and responsibility
(per-project, editable inline) badges in the Team-tab table. Pattern
mirrors .derived-badge but slightly larger so the values are
first-class glanceable signal alongside the user's name. */
.projekt-team-profession {
display: inline-block;
padding: 0.1rem 0.55rem;
border-radius: 9999px;
background: var(--color-bg-muted, rgba(0, 0, 0, 0.04));
color: var(--color-text);
font-size: 0.78rem;
font-weight: 500;
cursor: help;
}
.projekt-team-profession--none {
color: var(--color-text-muted, #777);
font-style: italic;
background: transparent;
border: 1px dashed var(--color-border, rgba(0, 0, 0, 0.12));
}
.projekt-team-responsibility {
display: inline-block;
padding: 0.1rem 0.55rem;
border-radius: 9999px;
background: var(--color-accent-soft, rgba(198, 244, 28, 0.18));
color: var(--color-text);
font-size: 0.78rem;
font-weight: 500;
}
/* Warning variant of .form-hint — yellow tint, used by the team-add
profession-hint when the picked user has no profession set. */
.form-hint--warning {
color: #8a6d00;
background: rgba(255, 213, 79, 0.12);
padding: 0.3rem 0.5rem;
border-radius: var(--radius-sm, 4px);
border-left: 2px solid #b88800;
}
/* Inline checkbox label inside the attach-unit form. */
.form-checkbox {
display: inline-flex;

View File

@@ -0,0 +1,124 @@
-- Reverse of 057_profession_vs_responsibility.up.sql.
--
-- Best-effort rollback. The new columns are dropped; the legacy
-- project_teams.role column is re-derived from (responsibility, profession).
-- Down-migration loses information on edges:
-- * external responsibility → role='local_counsel' (loses expert distinction)
-- * member + profession=partner → role='of_counsel' (no legacy 'partner'
-- existed in project_teams.role; closest legacy ceiling)
-- * member + profession=paralegal → role='pa' (no legacy paralegal)
-- * member + profession=NULL → role='associate' (safe default, matches
-- the legacy RoleAssociate default)
-- These edges are documented; if the down is run on real production data,
-- review per-row before commit.
-- ============================================================================
-- 1. Restore approval_role_level to point at legacy ladder values.
-- ============================================================================
CREATE OR REPLACE FUNCTION paliad.approval_role_level(role text)
RETURNS int LANGUAGE SQL IMMUTABLE AS $$
SELECT CASE role
WHEN 'lead' THEN 5
WHEN 'of_counsel' THEN 4
WHEN 'associate' THEN 3
WHEN 'senior_pa' THEN 2
WHEN 'pa' THEN 1
ELSE 0
END
$$;
-- ============================================================================
-- 2. Restore approval_role_from_unit_role lead → lead.
-- ============================================================================
CREATE OR REPLACE FUNCTION paliad.approval_role_from_unit_role(unit_role text)
RETURNS text LANGUAGE SQL IMMUTABLE AS $$
SELECT CASE unit_role
WHEN 'lead' THEN 'lead'
WHEN 'attorney' THEN 'associate'
WHEN 'senior_pa' THEN 'senior_pa'
WHEN 'pa' THEN 'pa'
ELSE 'observer'
END
$$;
-- ============================================================================
-- 3. Re-derive project_teams.role from (responsibility, profession).
-- ============================================================================
UPDATE paliad.project_teams pt
SET role = CASE
WHEN pt.responsibility = 'lead' THEN 'lead'
WHEN pt.responsibility = 'observer' THEN 'observer'
WHEN pt.responsibility = 'external' THEN 'local_counsel'
ELSE COALESCE(
(SELECT CASE u.profession
WHEN 'partner' THEN 'of_counsel' -- best-effort: no legacy 'partner' role
WHEN 'of_counsel' THEN 'of_counsel'
WHEN 'associate' THEN 'associate'
WHEN 'senior_pa' THEN 'senior_pa'
WHEN 'pa' THEN 'pa'
WHEN 'paralegal' THEN 'pa' -- closest legacy fit
END
FROM paliad.users u WHERE u.id = pt.user_id),
'associate'
)
END;
-- ============================================================================
-- 4. Restore approval_policies + approval_requests CHECK constraints.
-- ============================================================================
UPDATE paliad.approval_policies
SET required_role = 'lead'
WHERE required_role = 'partner';
ALTER TABLE paliad.approval_policies DROP CONSTRAINT IF EXISTS approval_policies_required_role_check;
ALTER TABLE paliad.approval_policies ADD CONSTRAINT approval_policies_required_role_check
CHECK (required_role IN ('lead', 'of_counsel', 'associate', 'senior_pa', 'pa'));
UPDATE paliad.approval_requests
SET required_role = 'lead'
WHERE required_role = 'partner';
ALTER TABLE paliad.approval_requests DROP CONSTRAINT IF EXISTS approval_requests_required_role_check;
ALTER TABLE paliad.approval_requests ADD CONSTRAINT approval_requests_required_role_check
CHECK (required_role IN ('lead', 'of_counsel', 'associate', 'senior_pa', 'pa'));
-- ============================================================================
-- 5. Restore project_partner_units RLS to read pt.role = 'lead'.
-- ============================================================================
DROP POLICY IF EXISTS project_partner_units_write ON paliad.project_partner_units;
CREATE POLICY project_partner_units_write
ON paliad.project_partner_units FOR ALL
USING (
EXISTS (SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
OR EXISTS (SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = auth.uid()
AND pt.project_id = project_partner_units.project_id
AND pt.role = 'lead')
)
WITH CHECK (
EXISTS (SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
OR EXISTS (SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = auth.uid()
AND pt.project_id = project_partner_units.project_id
AND pt.role = 'lead')
);
-- ============================================================================
-- 6. Drop the new function and columns.
-- ============================================================================
DROP FUNCTION IF EXISTS paliad.user_project_authority_level(uuid, uuid);
DROP INDEX IF EXISTS paliad.project_teams_responsibility_idx;
ALTER TABLE paliad.project_teams DROP COLUMN IF EXISTS responsibility;
DROP INDEX IF EXISTS paliad.users_profession_idx;
ALTER TABLE paliad.users DROP COLUMN IF EXISTS profession;

View File

@@ -0,0 +1,339 @@
-- t-paliad-148: split paliad.project_teams.role into firm-level profession
-- and project-level responsibility.
--
-- Design: docs/design-profession-vs-project-role-2026-05-07.md (kepler,
-- m-locked 2026-05-07 21:35).
--
-- The legacy column did two jobs at once:
-- - career tier at the firm (PA, Associate, Of Counsel, …)
-- - responsibility on this matter (Lead, Member, Observer)
-- This migration introduces two clean axes and backfills both from the
-- legacy column. The legacy column is kept as a deprecated shadow for one
-- release; a follow-up migration drops it after Go code has fully
-- migrated and the production data is verified clean.
--
-- Day-1 deploy = zero behaviour change because the new code paths read
-- the new columns and the backfill is run inside this migration.
--
-- Sections:
-- 1. ALTER paliad.users ADD COLUMN profession.
-- 2. ALTER paliad.project_teams ADD COLUMN responsibility.
-- 3. Backfill profession from highest legacy project_teams.role per user.
-- 4. Backfill responsibility from legacy project_teams.role.
-- 5. UPDATE paliad.approval_policies.required_role CHECK + 'lead' → 'partner'.
-- 6. UPDATE paliad.approval_requests.required_role CHECK + 'lead' → 'partner'.
-- 7. UPDATE paliad.approval_role_from_unit_role: lead → partner.
-- 8. CREATE paliad.user_project_authority_level — tuple-with-gate ladder.
-- 9. CASCADE-rebuild paliad.project_partner_units RLS policies that
-- reference pt.role = 'lead'.
-- 10. UPDATE COMMENT on paliad.approval_role_level pointing at users.profession.
-- ============================================================================
-- 1. paliad.users.profession — firm-wide career tier.
--
-- NULL means "no firm tier" (external local counsel, expert, admin
-- accounts that aren't practicing lawyers). NULL → ladder level 0 →
-- ineligible to approve. Required-on-invite for firm members; admin
-- editable on /admin/team.
-- ============================================================================
ALTER TABLE paliad.users
ADD COLUMN profession text NULL
CHECK (profession IS NULL OR profession IN (
'partner', 'of_counsel', 'associate',
'senior_pa', 'pa', 'paralegal'
));
CREATE INDEX users_profession_idx ON paliad.users (profession);
COMMENT ON COLUMN paliad.users.profession IS
'Firm-wide career tier driving the t-paliad-138 approval ladder. '
'NULL = no firm tier (external collaborators, admin accounts). '
'Distinct from job_title (free-text display) and global_role (tool admin gate).';
-- ============================================================================
-- 2. paliad.project_teams.responsibility — per-project responsibility.
--
-- Replaces the project-axis values that were mixed into project_teams.role.
-- Default 'member'. 'lead' has additional manage-project privileges (already
-- wired in derivation_service.go). 'observer' and 'external' close the
-- approval gate (level 0 regardless of profession).
--
-- The legacy `role` column is kept on the table as a deprecated shadow for
-- one release. New code reads .responsibility; old code paths that still
-- read .role continue working until the follow-up migration drops the
-- column.
-- ============================================================================
ALTER TABLE paliad.project_teams
ADD COLUMN responsibility text NOT NULL DEFAULT 'member'
CHECK (responsibility IN ('lead', 'member', 'observer', 'external'));
CREATE INDEX project_teams_responsibility_idx
ON paliad.project_teams (project_id, responsibility);
COMMENT ON COLUMN paliad.project_teams.responsibility IS
'Per-project responsibility on this matter. lead/member open the '
'approval gate; observer/external close it. Profession provides the '
'level (paliad.users.profession).';
COMMENT ON COLUMN paliad.project_teams.role IS
'DEPRECATED — split into users.profession + project_teams.responsibility '
'in migration 057 (t-paliad-148). Kept as a shadow column for one release. '
'Drop in follow-up migration 058.';
-- ============================================================================
-- 3. Backfill paliad.users.profession from highest legacy tier per user.
--
-- Mapping (legacy role → profession):
-- lead → partner
-- of_counsel → of_counsel
-- associate → associate
-- senior_pa → senior_pa
-- pa → pa
-- local_counsel/expert/observer → IGNORED (no firm tier inferable)
--
-- For each user with at least one project_teams row carrying a firm-tier
-- value, take the HIGHEST tier (per the t-138 ladder). Ties at same tier
-- collapse trivially (same value). Users with only project-only labels
-- (observer / local_counsel / expert) get profession=NULL — admin will
-- need to fill them in via /admin/team.
-- ============================================================================
WITH legacy_to_profession AS (
SELECT pt.user_id,
CASE pt.role
WHEN 'lead' THEN 'partner'
WHEN 'of_counsel' THEN 'of_counsel'
WHEN 'associate' THEN 'associate'
WHEN 'senior_pa' THEN 'senior_pa'
WHEN 'pa' THEN 'pa'
-- observer / local_counsel / expert → NULL (filtered below)
END AS profession,
CASE pt.role
WHEN 'lead' THEN 5
WHEN 'of_counsel' THEN 4
WHEN 'associate' THEN 3
WHEN 'senior_pa' THEN 2
WHEN 'pa' THEN 1
ELSE 0
END AS lvl
FROM paliad.project_teams pt
),
ranked AS (
SELECT user_id, profession,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY lvl DESC) AS rn
FROM legacy_to_profession
WHERE profession IS NOT NULL
)
UPDATE paliad.users u
SET profession = r.profession
FROM ranked r
WHERE u.id = r.user_id
AND r.rn = 1
AND u.profession IS NULL;
-- ============================================================================
-- 4. Backfill paliad.project_teams.responsibility from legacy role.
--
-- Per-row mapping:
-- lead → lead
-- observer → observer
-- local_counsel → external
-- expert → external
-- associate / pa / of_counsel / senior_pa → member
--
-- Authority for "member" rows now comes from the user's profession.
-- ============================================================================
UPDATE paliad.project_teams
SET responsibility = CASE role
WHEN 'lead' THEN 'lead'
WHEN 'observer' THEN 'observer'
WHEN 'local_counsel' THEN 'external'
WHEN 'expert' THEN 'external'
ELSE 'member'
END;
-- ============================================================================
-- 5. paliad.approval_policies.required_role — drop 'lead', add 'partner'.
--
-- Legacy 'lead' was the project-level value at the ladder ceiling; under the
-- new model the ceiling is profession='partner'. Backfill any existing
-- policy rows from 'lead' to 'partner', then tighten the CHECK.
-- ============================================================================
UPDATE paliad.approval_policies
SET required_role = 'partner'
WHERE required_role = 'lead';
ALTER TABLE paliad.approval_policies DROP CONSTRAINT IF EXISTS approval_policies_required_role_check;
ALTER TABLE paliad.approval_policies ADD CONSTRAINT approval_policies_required_role_check
CHECK (required_role IN ('partner', 'of_counsel', 'associate', 'senior_pa', 'pa'));
-- ============================================================================
-- 6. paliad.approval_requests.required_role — same rename for snapshots.
--
-- Each request snapshots the policy's required_role at submission time so
-- mid-flight policy edits don't change the bar. Backfill 'lead' → 'partner'
-- for parity with the new policy enum, then tighten the CHECK.
-- ============================================================================
UPDATE paliad.approval_requests
SET required_role = 'partner'
WHERE required_role = 'lead';
ALTER TABLE paliad.approval_requests DROP CONSTRAINT IF EXISTS approval_requests_required_role_check;
ALTER TABLE paliad.approval_requests ADD CONSTRAINT approval_requests_required_role_check
CHECK (required_role IN ('partner', 'of_counsel', 'associate', 'senior_pa', 'pa'));
-- ============================================================================
-- 7. paliad.approval_role_from_unit_role — bridge maps lead → partner now.
--
-- Derived authority via partner-unit attachments (t-paliad-139) bridges
-- unit_role to the project-tier ladder. Under the new ladder, the highest
-- tier is 'partner' (was 'lead'). Update the lead → lead row to lead →
-- partner; the rest of the bridge mapping stays unchanged.
-- ============================================================================
CREATE OR REPLACE FUNCTION paliad.approval_role_from_unit_role(unit_role text)
RETURNS text LANGUAGE SQL IMMUTABLE AS $$
SELECT CASE unit_role
WHEN 'lead' THEN 'partner'
WHEN 'attorney' THEN 'associate'
WHEN 'senior_pa' THEN 'senior_pa'
WHEN 'pa' THEN 'pa'
ELSE 'observer'
END
$$;
-- Update the level helper too: 'partner' replaces 'lead' as the ceiling
-- value that the function recognises. The numeric ladder is identical;
-- only the named tier shifts.
CREATE OR REPLACE FUNCTION paliad.approval_role_level(role text)
RETURNS int LANGUAGE SQL IMMUTABLE AS $$
SELECT CASE role
WHEN 'partner' THEN 5
WHEN 'of_counsel' THEN 4
WHEN 'associate' THEN 3
WHEN 'senior_pa' THEN 2
WHEN 'pa' THEN 1
WHEN 'paralegal' THEN 0
-- Legacy 'lead' kept at level 5 for the deprecated-shadow window:
-- old call sites that still read pt.role would otherwise return
-- level 0 and break authority for projects where the migration
-- has run but the Go redirect hasn't. Removed in migration 058.
WHEN 'lead' THEN 5
ELSE 0
END
$$;
COMMENT ON FUNCTION paliad.approval_role_level(text) IS
'Strict-ladder level for the t-paliad-138 / t-paliad-148 approval gate. '
'Reads paliad.users.profession; legacy project_teams.role values still '
'recognised via the lead→5 shadow row until migration 058 retires the '
'column. Higher level always satisfies lower; level 0 = ineligible.';
-- ============================================================================
-- 8. paliad.user_project_authority_level — tuple-with-gate ladder.
--
-- effective_level for user U on project P:
--
-- profession_level = approval_role_level(U.profession) -- 0 if NULL
-- responsibility = direct or ancestor on project P
-- gate_open = responsibility IN ('lead', 'member')
-- derived_role = approval_role_from_unit_role(unit_role)
-- when project_partner_units.derive_grants_authority
-- effective_level = MAX over sources, gated as above
--
-- Direct/ancestor responsibility opens the gate, profession provides the
-- level. Derivation is its own source — derived authority always opens
-- its own gate (the unit attachment's grants_authority flag is the gate).
-- A user can hit this function via direct membership AND derivation; the
-- result is the max of both sources.
-- ============================================================================
CREATE OR REPLACE FUNCTION paliad.user_project_authority_level(
_user_id uuid,
_project_id uuid
) RETURNS int
LANGUAGE sql
STABLE
AS $$
WITH path AS (
SELECT string_to_array(p.path, '.')::uuid[] AS ids
FROM paliad.projects p WHERE p.id = _project_id
),
direct_or_ancestor AS (
SELECT pt.responsibility
FROM paliad.project_teams pt
JOIN path ON pt.project_id = ANY(path.ids)
WHERE pt.user_id = _user_id
),
profession_level AS (
SELECT paliad.approval_role_level(u.profession) AS lvl
FROM paliad.users u WHERE u.id = _user_id
),
direct_level AS (
-- Profession-level if any membership row opens the gate, else 0.
SELECT CASE
WHEN EXISTS (
SELECT 1 FROM direct_or_ancestor doa
WHERE doa.responsibility IN ('lead', 'member')
) THEN COALESCE((SELECT lvl FROM profession_level), 0)
ELSE 0
END AS lvl
),
derived_level AS (
SELECT COALESCE(MAX(paliad.approval_role_level(
paliad.approval_role_from_unit_role(pum.unit_role)
)), 0) AS lvl
FROM paliad.project_partner_units ppu
JOIN paliad.partner_unit_members pum
ON pum.partner_unit_id = ppu.partner_unit_id
AND pum.user_id = _user_id
AND pum.unit_role = ANY(ppu.derive_unit_roles)
JOIN path ON ppu.project_id = ANY(path.ids)
WHERE ppu.derive_grants_authority = true
)
SELECT GREATEST(
(SELECT lvl FROM direct_level),
(SELECT lvl FROM derived_level)
);
$$;
COMMENT ON FUNCTION paliad.user_project_authority_level(uuid, uuid) IS
'Effective approval-ladder level for user U on project P, evaluated as '
'a tuple-with-gate: profession_level if responsibility ∈ {lead,member} '
'else 0; max with derived authority (partner-unit attachment with '
'grants_authority=true). t-paliad-148.';
-- ============================================================================
-- 9. paliad.project_partner_units RLS — switch lead-gate to .responsibility.
--
-- Migration 055 wrote two policies that gate writes on pt.role = 'lead'.
-- Under the new model, lead is a project responsibility, not a profession.
-- Drop and rewrite both policies to read .responsibility.
-- ============================================================================
DROP POLICY IF EXISTS project_partner_units_write ON paliad.project_partner_units;
CREATE POLICY project_partner_units_write
ON paliad.project_partner_units FOR ALL
USING (
EXISTS (SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
OR EXISTS (SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = auth.uid()
AND pt.project_id = project_partner_units.project_id
AND pt.responsibility = 'lead')
)
WITH CHECK (
EXISTS (SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
OR EXISTS (SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = auth.uid()
AND pt.project_id = project_partner_units.project_id
AND pt.responsibility = 'lead')
);

View File

@@ -33,7 +33,11 @@ func handleListProjectTeam(w http.ResponseWriter, r *http.Request) {
}
// POST /api/projects/{id}/team — add a direct member.
// Body: {"user_id": "<uuid>", "role": "<role>"}
// Body: {"user_id": "<uuid>", "responsibility": "<lead|member|observer|external>"}
//
// Legacy clients that submit `role` are still accepted as a synonym
// during the deprecation window; the field is treated as a
// responsibility value when the new field is absent.
func handleAddProjectTeamMember(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
@@ -48,14 +52,20 @@ func handleAddProjectTeamMember(w http.ResponseWriter, r *http.Request) {
return
}
var body struct {
UserID uuid.UUID `json:"user_id"`
Role string `json:"role"`
UserID uuid.UUID `json:"user_id"`
Responsibility string `json:"responsibility"`
// Legacy field, accepted for one release while frontend migrates.
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
m, err := dbSvc.team.AddMember(r.Context(), uid, projectID, body.UserID, body.Role)
resp := body.Responsibility
if resp == "" {
resp = body.Role
}
m, err := dbSvc.team.AddMember(r.Context(), uid, projectID, body.UserID, resp)
if err != nil {
writeServiceError(w, err)
return

View File

@@ -27,10 +27,18 @@ type User struct {
// "Counsel Knowledge Lawyer", …). NULL is allowed for users who never
// picked a title — typically global admins promoted via SQL.
JobTitle *string `db:"job_title" json:"job_title"`
// Profession is the structured firm-tier enum that drives the
// t-paliad-138 / t-paliad-148 approval ladder (partner / of_counsel /
// associate / senior_pa / pa / paralegal). NULL means "no firm tier"
// — external collaborators (local counsel, expert) and admin
// accounts that aren't practicing lawyers. NULL → ladder level 0,
// ineligible to approve. Distinct from JobTitle (display) and
// GlobalRole (tool admin gate). Added by migration 057.
Profession *string `db:"profession" json:"profession,omitempty"`
// GlobalRole is the global-permissions enum: 'standard' | 'global_admin'.
// 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.
// role='admin'. Per-project authority is on paliad.project_teams and
// users.profession; this column is the tool-admin axis, unrelated.
GlobalRole string `db:"global_role" json:"global_role"`
Lang string `db:"lang" json:"lang"`
EmailPreferences json.RawMessage `db:"email_preferences" json:"email_preferences"`
@@ -102,10 +110,19 @@ type Project struct {
// only. Inherited memberships are computed at read time by walking the path;
// services set Inherited=true on the in-memory copy when annotating a list
// result that mixes direct + inherited rows.
//
// t-paliad-148 split: Responsibility is the per-project role (lead /
// member / observer / external). The legacy Role field is kept as a
// deprecated read-only shadow until follow-up migration 058 drops the
// underlying column.
type ProjectTeamMember struct {
ID uuid.UUID `db:"id" json:"id"`
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
ID uuid.UUID `db:"id" json:"id"`
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Responsibility string `db:"responsibility" json:"responsibility"`
// Role: deprecated shadow column. Reader populates it for backwards-
// compatibility with any consumer still reading `role`; new code
// should read .Responsibility instead.
Role string `db:"role" json:"role"`
Inherited bool `db:"inherited" json:"inherited"`
AddedBy *uuid.UUID `db:"added_by" json:"added_by,omitempty"`
@@ -113,13 +130,19 @@ type ProjectTeamMember struct {
}
// ProjectTeamMemberWithUser enriches a team row with display fields so the
// UI can render "<DisplayName> (<Email>) — <Role>" without a per-row lookup.
// Used by TeamService.ListMembers which unions direct + inherited memberships.
// UI can render "<DisplayName> (<Email>) — <Responsibility>" without a
// per-row lookup. Used by TeamService.ListMembers which unions direct +
// inherited memberships.
//
// UserProfession reflects paliad.users.profession at read time — the
// firm-tier badge shown next to the responsibility column on
// /projects/{id} (t-paliad-148 §6).
type ProjectTeamMemberWithUser struct {
ProjectTeamMember
UserEmail string `db:"user_email" json:"user_email"`
UserDisplayName string `db:"user_display_name" json:"user_display_name"`
UserOffice string `db:"user_office" json:"user_office"`
UserEmail string `db:"user_email" json:"user_email"`
UserDisplayName string `db:"user_display_name" json:"user_display_name"`
UserOffice string `db:"user_office" json:"user_office"`
UserProfession *string `db:"user_profession" json:"user_profession,omitempty"`
// InheritedFromID is the ancestor project_id the membership came from
// when Inherited=true. NULL for direct rows.
InheritedFromID *uuid.UUID `db:"inherited_from_id" json:"inherited_from_id,omitempty"`

View File

@@ -2,18 +2,39 @@ package services
import "errors"
// Strict-ladder level helper for the 4-Augen-Prüfung approval gate
// (t-paliad-138). Mirrors paliad.approval_role_level(text) in migration
// 054. A user with project_teams.role R can approve any request whose
// required_role has level <= levelOf(R). Roles outside the approval
// ladder (local_counsel, expert, observer, anything new) return 0 and
// are ineligible to approve at any level.
// Strict-ladder helpers for the 4-Augen-Prüfung approval gate. The ladder
// drives both the t-paliad-138 single-value `required_role` policy
// grammar and the t-paliad-148 (profession, responsibility) tuple-with-
// gate evaluation in paliad.user_project_authority_level().
//
// The ladder values match paliad.approval_role_level(text) in migration
// 057. Higher level always satisfies lower; level 0 means ineligible to
// approve at any level.
// RoleSeniorPA is the new project_teams.role value added by migration 054.
// It sits between associate (3) and pa (1) and gives a named tier between
// "associate" and "PA" for projects that want PAs supervised by senior PAs
// rather than by associates.
const RoleSeniorPA = "senior_pa"
// Profession values on paliad.users.profession. Drive the ladder. NULL is
// represented as the empty string in Go (`*string` nil) — the ladder
// returns 0 for unknown values, including empty.
const (
ProfessionPartner = "partner"
ProfessionOfCounsel = "of_counsel"
ProfessionAssociate = "associate"
ProfessionSeniorPA = "senior_pa"
ProfessionPA = "pa"
ProfessionParalegal = "paralegal"
)
// Project-level responsibility values on paliad.project_teams.responsibility.
// Open the ladder gate (lead/member) or close it (observer/external).
const (
ResponsibilityLead = "lead"
ResponsibilityMember = "member"
ResponsibilityObserver = "observer"
ResponsibilityExternal = "external"
)
// RoleSeniorPA — kept as the legacy constant from t-paliad-138 for any
// remaining reference site that hasn't migrated. Equal to ProfessionSeniorPA.
const RoleSeniorPA = ProfessionSeniorPA
// EntityType values for the polymorphic approval workflow.
const (
@@ -47,47 +68,85 @@ const (
RequestStatusSuperseded = "superseded"
)
// DecisionKind discriminates "peer" (normal in-team sign-off) from
// "admin_override" (global_admin used the escape-hatch path) and
// "derived_peer" (a partner-unit-derived member with authority signed off
// DecisionKind discriminates 'peer' (normal in-team sign-off) from
// 'admin_override' (global_admin used the escape-hatch path) and
// 'derived_peer' (a partner-unit-derived member with authority signed off
// — added by t-paliad-139 / migration 055). Verlauf chronology renders
// these distinctly.
const (
DecisionKindPeer = "peer"
DecisionKindPeer = "peer"
DecisionKindAdminOverride = "admin_override"
DecisionKindDerivedPeer = "derived_peer"
DecisionKindDerivedPeer = "derived_peer"
)
// levelOf maps a project_teams.role value to its strict-ladder level.
// Mirrors paliad.approval_role_level(text) in SQL.
// professionLevel maps a profession value to its strict-ladder level.
// Mirrors paliad.approval_role_level(text). NULL profession (empty
// string) returns 0 — explicit so the trap is visible.
//
// 5: lead partner-tier on this project
// 5: partnerfirm-tier ceiling (replaces legacy 'lead')
// 4: of_counsel
// 3: associate ← default required level on new policies
// 2: senior_pa — added by migration 054
// 2: senior_pa
// 1: pa
// 0: local_counsel / expert / observer / anything new — ineligible to approve
func levelOf(role string) int {
switch role {
case "lead":
// 0: paralegal / "" / unknown — ineligible to approve
//
// CRITICAL: do not silently default NULL/empty to 'associate'. NULL
// profession means "no firm tier", which is the explicit signal that
// the user (e.g. external local counsel) cannot satisfy any tier.
// Test: TestProfessionLevel_NilIsZero pins this behaviour.
func professionLevel(profession string) int {
switch profession {
case ProfessionPartner:
return 5
case "of_counsel":
case ProfessionOfCounsel:
return 4
case "associate":
case ProfessionAssociate:
return 3
case RoleSeniorPA:
case ProfessionSeniorPA:
return 2
case "pa":
case ProfessionPA:
return 1
default:
return 0
}
}
// responsibilityOpensGate returns true iff the project responsibility
// opens the approval gate. Mirrors the SQL predicate
// `pt.responsibility IN ('lead','member')` used by
// paliad.user_project_authority_level().
func responsibilityOpensGate(responsibility string) bool {
return responsibility == ResponsibilityLead || responsibility == ResponsibilityMember
}
// IsValidRequiredRole returns true iff the role can be set as a policy's
// required_role (i.e. it has a non-zero strict-ladder level).
// required_role (i.e. it has a non-zero strict-ladder level). Used by
// the policy-authoring page to validate the dropdown value.
func IsValidRequiredRole(role string) bool {
return levelOf(role) > 0
return professionLevel(role) > 0
}
// IsValidProfession returns true iff the value is one of the recognised
// profession enum values. Empty string is intentionally rejected — the
// service layer represents NULL as a *string nil, not as "".
func IsValidProfession(p string) bool {
switch p {
case ProfessionPartner, ProfessionOfCounsel, ProfessionAssociate,
ProfessionSeniorPA, ProfessionPA, ProfessionParalegal:
return true
}
return false
}
// IsValidResponsibility returns true iff the value is one of the
// recognised project-responsibility enum values. Used by TeamService.
func IsValidResponsibility(r string) bool {
switch r {
case ResponsibilityLead, ResponsibilityMember,
ResponsibilityObserver, ResponsibilityExternal:
return true
}
return false
}
// Approval-flow errors. Handlers map these to the right HTTP status:

View File

@@ -83,14 +83,19 @@ func (s *ApprovalService) LookupPolicy(ctx context.Context, tx *sqlx.Tx, project
}
// hasQualifiedApprover counts users on the project's team-membership path
// (direct OR ancestor) whose role meets the strict-ladder threshold for
// requiredRole, plus any global_admin user, plus any partner-unit-derived
// member where the attachment grants authority (t-paliad-139). Excludes
// requesterID.
// (direct OR ancestor) whose (profession, responsibility) tuple meets the
// strict-ladder threshold, plus any global_admin user, plus any partner-
// unit-derived member where the attachment grants authority (t-paliad-139).
// Excludes requesterID.
//
// Returns true if at least one such user exists. The path-walk JOIN matches
// the visibility predicate so an ancestor lead qualifies for a descendant's
// approval, just like they have visibility.
// the visibility predicate so an ancestor partner qualifies for a
// descendant's approval, just like they have visibility.
//
// t-paliad-148: peer authority requires BOTH a profession with sufficient
// level AND a responsibility ∈ {lead, member} that opens the gate.
// observer/external rows are excluded even if the user's profession would
// otherwise qualify — that's the point of the project-level gate.
func (s *ApprovalService) hasQualifiedApprover(ctx context.Context, tx *sqlx.Tx, projectID, requesterID uuid.UUID, requiredRole string) (bool, error) {
q := `WITH path AS (
SELECT string_to_array(p.path, '.')::uuid[] AS ids
@@ -98,9 +103,11 @@ func (s *ApprovalService) hasQualifiedApprover(ctx context.Context, tx *sqlx.Tx,
)
SELECT EXISTS (
SELECT 1 FROM paliad.project_teams pt
JOIN paliad.users u ON u.id = pt.user_id
JOIN path ON pt.project_id = ANY(path.ids)
WHERE pt.user_id <> $2
AND paliad.approval_role_level(pt.role) >= paliad.approval_role_level($3)
AND pt.responsibility IN ('lead', 'member')
AND paliad.approval_role_level(u.profession) >= paliad.approval_role_level($3)
UNION ALL
SELECT 1 FROM paliad.users u
WHERE u.global_role = 'global_admin' AND u.id <> $2
@@ -402,13 +409,17 @@ func (s *ApprovalService) canApprove(ctx context.Context, tx *sqlx.Tx, callerID
if user.GlobalRole == "global_admin" {
return DecisionKindAdminOverride, nil
}
// Path-walk: check direct OR ancestor team membership with sufficient role.
// Path-walk: check direct OR ancestor team membership with a
// responsibility that opens the gate (lead/member) AND a profession
// whose level meets the threshold (t-paliad-148 tuple-with-gate).
q := `SELECT EXISTS (
SELECT 1 FROM paliad.project_teams pt
JOIN paliad.users u ON u.id = pt.user_id
WHERE pt.user_id = $1
AND pt.project_id = ANY(string_to_array(
(SELECT path FROM paliad.projects WHERE id = $2), '.')::uuid[])
AND paliad.approval_role_level(pt.role) >= paliad.approval_role_level($3)
AND pt.responsibility IN ('lead', 'member')
AND paliad.approval_role_level(u.profession) >= paliad.approval_role_level($3)
)`
var ok bool
if err := tx.GetContext(ctx, &ok, q, callerID, req.ProjectID, req.RequiredRole); err != nil {
@@ -739,16 +750,20 @@ func (s *ApprovalService) ListPendingForApprover(ctx context.Context, callerID u
"ar.requested_by <> $1",
// Eligibility (any one branch suffices):
// - caller is global_admin, OR
// - caller has direct/ancestor project_teams role meeting the threshold, OR
// - caller has direct/ancestor project_teams membership with
// responsibility ∈ {lead, member} AND profession at or above
// the threshold (t-paliad-148 tuple-with-gate), OR
// - caller is a partner-unit-derived member with derive_grants_authority=true
// on an attachment in the project's path, and the unit_role maps to a
// project_role at or above the threshold (t-paliad-139).
// profession at or above the threshold (t-paliad-139).
`(EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $1 AND u.global_role = 'global_admin')
OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
JOIN paliad.users u ON u.id = pt.user_id
WHERE pt.user_id = $1
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND paliad.approval_role_level(pt.role) >= paliad.approval_role_level(ar.required_role)
AND pt.responsibility IN ('lead', 'member')
AND paliad.approval_role_level(u.profession) >= paliad.approval_role_level(ar.required_role)
)
OR EXISTS (
SELECT 1 FROM paliad.project_partner_units ppu
@@ -838,7 +853,8 @@ func (s *ApprovalService) GetRequest(ctx context.Context, requestID uuid.UUID) (
// Cheap query for the sidebar bell badge.
//
// Eligibility mirrors ListPendingForApprover: global_admin OR direct/
// ancestor project_teams role meeting the threshold OR partner-unit-
// ancestor project_teams membership with responsibility ∈ {lead, member}
// AND profession meeting the threshold (t-paliad-148) OR partner-unit-
// derived authority (t-paliad-139).
func (s *ApprovalService) PendingCountForUser(ctx context.Context, callerID uuid.UUID) (int, error) {
q := `SELECT COUNT(*)
@@ -849,9 +865,11 @@ func (s *ApprovalService) PendingCountForUser(ctx context.Context, callerID uuid
AND (EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $1 AND u.global_role = 'global_admin')
OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
JOIN paliad.users u ON u.id = pt.user_id
WHERE pt.user_id = $1
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND paliad.approval_role_level(pt.role) >= paliad.approval_role_level(ar.required_role)
AND pt.responsibility IN ('lead', 'member')
AND paliad.approval_role_level(u.profession) >= paliad.approval_role_level(ar.required_role)
)
OR EXISTS (
SELECT 1 FROM paliad.project_partner_units ppu

View File

@@ -2,7 +2,8 @@ package services
// Approval-service tests. Two layers:
//
// - Pure-Go: levelOf strict ladder + IsValidRequiredRole. No DB touch.
// - Pure-Go: professionLevel strict ladder + IsValidRequiredRole +
// responsibilityOpensGate (t-paliad-148). No DB touch.
// - Live-DB: the full submit→approve and submit→reject flows on real
// paliad.deadlines / paliad.approval_requests rows. Skipped when
// TEST_DATABASE_URL is unset, mirroring audit_service_test and
@@ -26,62 +27,105 @@ import (
// Pure-Go tests.
// ============================================================================
func TestLevelOf_StrictLadder(t *testing.T) {
func TestProfessionLevel_StrictLadder(t *testing.T) {
cases := []struct {
role string
want int
profession string
want int
}{
{"lead", 5},
{"partner", 5},
{"of_counsel", 4},
{"associate", 3},
{"senior_pa", 2},
{"pa", 1},
{"paralegal", 0},
{"", 0},
{"unknown", 0},
// Legacy values that pre-dated the t-paliad-148 split must NOT
// satisfy the ladder. The SQL helper still recognises 'lead' as a
// deprecated-shadow row until migration 058; the Go helper does
// not — call sites have all migrated to read users.profession.
{"lead", 0},
{"local_counsel", 0},
{"expert", 0},
{"observer", 0},
{"", 0},
{"unknown", 0},
}
for _, c := range cases {
t.Run(c.role, func(t *testing.T) {
if got := levelOf(c.role); got != c.want {
t.Errorf("levelOf(%q) = %d, want %d", c.role, got, c.want)
t.Run(c.profession, func(t *testing.T) {
if got := professionLevel(c.profession); got != c.want {
t.Errorf("professionLevel(%q) = %d, want %d", c.profession, got, c.want)
}
})
}
}
func TestLevelOf_HigherSatisfiesLower(t *testing.T) {
// "Anyone strictly above the required level satisfies it" — verify by
// asserting the ladder is monotonic and partner > all PA tiers etc.
if levelOf("lead") <= levelOf("associate") {
t.Errorf("lead must outrank associate")
func TestProfessionLevel_NilIsZero(t *testing.T) {
// CRITICAL trap pin: NULL profession (empty string in Go) returns 0,
// not "default to associate" or anything similar. This is what gates
// external collaborators (local_counsel, expert) out of the approval
// ladder when their project responsibility is set to 'external' but
// their users.profession is also set to a real tier by mistake.
if got := professionLevel(""); got != 0 {
t.Errorf("professionLevel(\"\") must be 0, got %d — NULL profession is ineligible", got)
}
if levelOf("associate") <= levelOf("senior_pa") {
}
func TestProfessionLevel_HigherSatisfiesLower(t *testing.T) {
// "Anyone strictly above the required level satisfies it" — verify by
// asserting the ladder is monotonic.
if professionLevel("partner") <= professionLevel("associate") {
t.Errorf("partner must outrank associate")
}
if professionLevel("associate") <= professionLevel("senior_pa") {
t.Errorf("associate must outrank senior_pa")
}
if levelOf("senior_pa") <= levelOf("pa") {
if professionLevel("senior_pa") <= professionLevel("pa") {
t.Errorf("senior_pa must outrank pa")
}
if levelOf("of_counsel") <= levelOf("associate") {
if professionLevel("of_counsel") <= professionLevel("associate") {
t.Errorf("of_counsel must outrank associate")
}
// PA-required policy: anyone associate-or-above must satisfy.
if levelOf("associate") < levelOf("pa") {
if professionLevel("associate") < professionLevel("pa") {
t.Errorf("associate must satisfy a pa-required policy")
}
}
func TestResponsibilityOpensGate(t *testing.T) {
cases := []struct {
responsibility string
open bool
}{
{"lead", true},
{"member", true},
{"observer", false},
{"external", false},
{"", false},
{"unknown", false},
}
for _, c := range cases {
t.Run(c.responsibility, func(t *testing.T) {
if got := responsibilityOpensGate(c.responsibility); got != c.open {
t.Errorf("responsibilityOpensGate(%q) = %v, want %v",
c.responsibility, got, c.open)
}
})
}
}
func TestIsValidRequiredRole(t *testing.T) {
cases := []struct {
role string
ok bool
}{
{"lead", true},
{"partner", true},
{"of_counsel", true},
{"associate", true},
{"senior_pa", true},
{"pa", true},
{"paralegal", false},
// Legacy values that pre-dated the t-paliad-148 split must be
// rejected as policy targets.
{"lead", false},
{"local_counsel", false},
{"expert", false},
{"observer", false},
@@ -96,6 +140,40 @@ func TestIsValidRequiredRole(t *testing.T) {
}
}
func TestIsValidProfession(t *testing.T) {
for _, p := range []string{"partner", "of_counsel", "associate", "senior_pa", "pa", "paralegal"} {
t.Run(p, func(t *testing.T) {
if !IsValidProfession(p) {
t.Errorf("IsValidProfession(%q) must be true", p)
}
})
}
for _, p := range []string{"", "lead", "junior_associate", "trainee", "unknown"} {
t.Run("invalid_"+p, func(t *testing.T) {
if IsValidProfession(p) {
t.Errorf("IsValidProfession(%q) must be false", p)
}
})
}
}
func TestIsValidResponsibility(t *testing.T) {
for _, r := range []string{"lead", "member", "observer", "external"} {
t.Run(r, func(t *testing.T) {
if !IsValidResponsibility(r) {
t.Errorf("IsValidResponsibility(%q) must be true", r)
}
})
}
for _, r := range []string{"", "associate", "lead2", "unknown"} {
t.Run("invalid_"+r, func(t *testing.T) {
if IsValidResponsibility(r) {
t.Errorf("IsValidResponsibility(%q) must be false", r)
}
})
}
}
func TestApprovalEventType(t *testing.T) {
cases := []struct {
entity, step, want string

View File

@@ -626,7 +626,7 @@ func (s *DeadlineService) Complete(ctx context.Context, userID, deadlineID uuid.
// Reopen flips a completed Deadline back to pending and clears completed_at.
// Authorization: global admin OR a member of the Project (or any ancestor)
// with project_teams.role IN ('admin','lead'). Other authenticated viewers
// with project_teams.responsibility = 'lead'. Other authenticated viewers
// can see the Deadline but cannot reopen it.
func (s *DeadlineService) Reopen(ctx context.Context, userID, deadlineID uuid.UUID) (*models.Deadline, error) {
current, err := s.GetByID(ctx, userID, deadlineID)
@@ -667,11 +667,17 @@ func (s *DeadlineService) Reopen(ctx context.Context, userID, deadlineID uuid.UU
// assertCanAdminProject returns nil if the user may perform admin-level
// actions on the Project (reopen, future bulk ops). Pass-conditions:
// - global users.role = 'admin', or
// - direct/inherited project_teams membership with role IN ('admin','lead').
// - users.global_role = 'global_admin', or
// - direct/inherited project_teams membership with responsibility = 'lead'.
//
// Returns ErrForbidden otherwise. Visibility must be checked separately
// (callers do this via GetByID before calling here).
//
// t-paliad-148: switched from `role IN ('admin','lead')` to
// `responsibility = 'lead'`. The legacy 'admin' value was already dead
// since t-paliad-051 (project_teams.role never had an 'admin' value;
// only the legacy users.role enum did, before it was split into
// global_role).
func (s *DeadlineService) assertCanAdminProject(ctx context.Context, userID, projectID uuid.UUID) error {
user, err := s.users().GetByID(ctx, userID)
if err != nil {
@@ -692,13 +698,13 @@ func (s *DeadlineService) assertCanAdminProject(ctx context.Context, userID, pro
ON pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
WHERE p.id = $1
AND pt.user_id = $2
AND pt.role IN ('admin', 'lead')
AND pt.responsibility = 'lead'
)`, projectID, userID)
if err != nil {
return fmt.Errorf("check project admin: %w", err)
}
if !ok {
return fmt.Errorf("%w: only project admins/leads can reopen Deadlines", ErrForbidden)
return fmt.Errorf("%w: only project leads can reopen Deadlines", ErrForbidden)
}
return nil
}

View File

@@ -113,18 +113,23 @@ func (s *DerivationService) requireWritePermission(ctx context.Context, callerID
if user != nil && user.GlobalRole == "global_admin" {
return nil
}
var role string
err = s.db.GetContext(ctx, &role,
`SELECT role FROM paliad.project_teams
// t-paliad-148: project-management write permission gates on the
// project responsibility, not on the (firm-tier) profession. A
// partner with responsibility=observer on this matter cannot manage
// partner-unit attachments here; conversely a non-partner with
// responsibility=lead can.
var responsibility string
err = s.db.GetContext(ctx, &responsibility,
`SELECT responsibility FROM paliad.project_teams
WHERE project_id = $1 AND user_id = $2`,
projectID, callerID)
if errors.Is(err, sql.ErrNoRows) {
return ErrForbidden
}
if err != nil {
return fmt.Errorf("read project_teams role: %w", err)
return fmt.Errorf("read project_teams responsibility: %w", err)
}
if role != RoleLead {
if responsibility != ResponsibilityLead {
return ErrForbidden
}
return nil
@@ -311,7 +316,8 @@ func (s *DerivationService) ListDescendantStaffed(ctx context.Context, callerID,
WHERE target.id = $1
),
descendant_rows AS (
SELECT pt.id, pt.project_id, pt.user_id, pt.role, pt.added_by, pt.created_at,
SELECT pt.id, pt.project_id, pt.user_id, pt.role, pt.responsibility,
pt.added_by, pt.created_at,
d.title AS source_title
FROM paliad.project_teams pt
JOIN descendants d ON d.id = pt.project_id
@@ -328,18 +334,19 @@ func (s *DerivationService) ListDescendantStaffed(ctx context.Context, callerID,
) AS rn
FROM descendant_rows dr
)
SELECT d.id, d.project_id, d.user_id, d.role,
SELECT d.id, d.project_id, d.user_id, d.role, d.responsibility,
true AS inherited,
d.added_by, d.created_at,
u.email AS user_email,
u.display_name AS user_display_name,
u.office AS user_office,
u.profession AS user_profession,
d.project_id AS inherited_from_id,
d.source_title AS inherited_from_title
FROM dedup d
JOIN paliad.users u ON u.id = d.user_id
WHERE d.rn = 1
ORDER BY d.role, u.display_name`,
ORDER BY d.responsibility, u.display_name`,
projectID)
if err != nil {
return nil, fmt.Errorf("list descendant-staffed: %w", err)
@@ -347,106 +354,30 @@ func (s *DerivationService) ListDescendantStaffed(ctx context.Context, callerID,
return rows, nil
}
// EffectiveProjectRole returns (role, source) where source is one of:
// 'direct', 'ancestor', 'descendant', 'derived'. Used by the t-138
// approval ladder via canApprove() — Phase 3 of t-paliad-139 will wire
// this in.
// UserProjectAuthorityLevel returns the effective approval-ladder level
// for user U on project P, evaluated as a tuple-with-gate:
//
// Resolution order:
// 1. direct (this project_teams row)
// 2. ancestor (project_teams on any ancestor — closest wins)
// 3. derived (partner_unit_members on an attached unit on this project
// or any ancestor — closest wins; only when derive_grants_authority=true)
// 4. descendant (rare for authority — explicit staffing on a descendant
// does NOT confer authority on the ancestor; returned for read use
// only, callers should prefer the higher tiers)
// profession_level = approval_role_level(U.profession) // 0 if NULL
// responsibility = direct or ancestor on project P
// gate_open = responsibility IN {lead, member}
// derived_role = approval_role_from_unit_role(unit_role) // when grants_authority
// level = max( profession_level if gate_open else 0,
// derived_role_level )
//
// Returns ("", "") when the user has no membership of any kind. This is a
// service-internal lookup — it does NOT visibility-check, since callers
// (the t-138 approval gate) need to know the caller's effective role even
// when visibility is being evaluated for the first time.
func (s *DerivationService) EffectiveProjectRole(ctx context.Context, userID, projectID uuid.UUID) (string, string, error) {
var path string
err := s.db.GetContext(ctx, &path,
`SELECT path FROM paliad.projects WHERE id = $1`, projectID)
// Thin wrapper over paliad.user_project_authority_level — kept here so
// any future caller that needs the level without writing raw SQL has a
// single helper to call. The ApprovalService SQL paths inline the
// computation directly for query efficiency.
func (s *DerivationService) UserProjectAuthorityLevel(ctx context.Context, userID, projectID uuid.UUID) (int, error) {
var lvl int
err := s.db.GetContext(ctx, &lvl,
`SELECT paliad.user_project_authority_level($1, $2)`,
userID, projectID)
if errors.Is(err, sql.ErrNoRows) {
return "", "", nil
return 0, nil
}
if err != nil {
return "", "", fmt.Errorf("read project path: %w", err)
return 0, fmt.Errorf("read user project authority level: %w", err)
}
ancestorIDs := pathToIDStrings(path)
// 1. Direct
var directRole string
err = s.db.GetContext(ctx, &directRole,
`SELECT role FROM paliad.project_teams WHERE project_id = $1 AND user_id = $2`,
projectID, userID)
if err == nil {
return directRole, "direct", nil
}
if !errors.Is(err, sql.ErrNoRows) {
return "", "", fmt.Errorf("read direct role: %w", err)
}
// 2. Ancestor (closest wins via path distance — already root→self order
// in the path; pick the row whose project_id appears latest in the
// ancestorIDs array).
type ancRow struct {
Role string `db:"role"`
ProjID string `db:"project_id"`
Position int `db:"position"`
}
var ancestorMatches []ancRow
if len(ancestorIDs) > 0 {
err = s.db.SelectContext(ctx, &ancestorMatches, `
SELECT pt.role,
pt.project_id::text AS project_id,
array_position($1::uuid[], pt.project_id) AS position
FROM paliad.project_teams pt
WHERE pt.user_id = $2
AND pt.project_id = ANY($1::uuid[])
ORDER BY position DESC NULLS LAST
LIMIT 1`,
pq.StringArray(ancestorIDs), userID)
if err != nil {
return "", "", fmt.Errorf("read ancestor role: %w", err)
}
if len(ancestorMatches) > 0 {
return ancestorMatches[0].Role, "ancestor", nil
}
}
// 3. Derived with authority. Only authority-granting attachments count
// here; visibility-only derivation does not yield an effective role for
// approval purposes. The derived role is mapped from unit_role via
// approval_role_from_unit_role (a SQL function added in migration 055).
type derivedRow struct {
Role string `db:"role"`
}
var derived []derivedRow
if len(ancestorIDs) > 0 {
err = s.db.SelectContext(ctx, &derived, `
SELECT paliad.approval_role_from_unit_role(pum.unit_role) AS role
FROM paliad.project_partner_units ppu
JOIN paliad.partner_unit_members pum
ON pum.partner_unit_id = ppu.partner_unit_id
AND pum.user_id = $2
AND pum.unit_role = ANY(ppu.derive_unit_roles)
WHERE ppu.project_id = ANY($1::uuid[])
AND ppu.derive_grants_authority = true
ORDER BY paliad.approval_role_level(
paliad.approval_role_from_unit_role(pum.unit_role)
) DESC
LIMIT 1`,
pq.StringArray(ancestorIDs), userID)
if err != nil {
return "", "", fmt.Errorf("read derived role: %w", err)
}
if len(derived) > 0 {
return derived[0].Role, "derived", nil
}
}
return "", "", nil
return lvl, nil
}

View File

@@ -54,7 +54,16 @@ const (
ProjectTypeProject = "project"
)
// ProjectRole values allowed on project_teams.role.
// Legacy ProjectRole values that used to live on paliad.project_teams.role.
//
// DEPRECATED (t-paliad-148): the role column has been split into
// users.profession (firm-tier) + project_teams.responsibility (per-
// project). New code should use ProfessionPartner / ResponsibilityLead /
// etc. from approval_levels.go. These constants stay defined for one
// release because the deprecated shadow column is still written by
// AddMember (mapped via legacyRoleFromResponsibility); follow-up
// migration 058 retires the column and these constants can be deleted
// then.
const (
RoleLead = "lead"
RoleAssociate = "associate"
@@ -482,9 +491,11 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre
}
// Auto-add creator as team lead so they (and RLS) can see the row.
// Writes both the legacy `role` and the new `responsibility` so the
// deprecated shadow column stays in sync until migration 058.
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.project_teams (project_id, user_id, role, inherited, added_by)
VALUES ($1, $2, 'lead', false, $2)`, id, userID); err != nil {
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
VALUES ($1, $2, 'lead', 'lead', false, $2)`, id, userID); err != nil {
return nil, fmt.Errorf("insert creator team row: %w", err)
}

View File

@@ -296,7 +296,7 @@ func (s *ReminderService) fetchSlotDeadlines(ctx context.Context, u models.User,
// Audience predicates:
// * owner of the deadline — f.created_by = U
// * project lead anywhere on the path — pt.role = 'lead'
// * project lead anywhere on the path — pt.responsibility = 'lead'
// * owner's escalation contact (override) — own.escalation_contact_id = U
// * global admin AND owner has no override — fallback channel
// Per-category recipient rules (e.g. leads don't get overdue) are applied
@@ -314,7 +314,7 @@ func (s *ReminderService) fetchSlotDeadlines(ctx context.Context, u models.User,
EXISTS (
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $2
AND pt.role = 'lead'
AND pt.responsibility = 'lead'
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
) AS is_lead
FROM paliad.deadlines f
@@ -327,7 +327,7 @@ func (s *ReminderService) fetchSlotDeadlines(ctx context.Context, u models.User,
OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $2
AND pt.role = 'lead'
AND pt.responsibility = 'lead'
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
)
OR own.escalation_contact_id = $2

View File

@@ -34,34 +34,68 @@ func NewTeamService(db *sqlx.DB, projects *ProjectService) *TeamService {
return &TeamService{db: db, projects: projects}
}
// AddMember inserts a direct team membership. The caller must have visibility
// on the Project (RLS + service-layer gate). Role defaults to 'associate'
// if empty. Idempotent on (project_id, user_id) — a repeat call updates role.
func (s *TeamService) AddMember(ctx context.Context, callerID, projectID, userID uuid.UUID, role string) (*models.ProjectTeamMember, error) {
// AddMember inserts a direct team membership. The caller must have
// visibility on the Project (RLS + service-layer gate). Responsibility
// defaults to 'member' if empty. Idempotent on (project_id, user_id) —
// a repeat call updates the responsibility.
//
// t-paliad-148: this method writes the per-project responsibility only.
// The user's firm-level profession is NEVER touched here — it lives on
// paliad.users.profession and is set during onboarding / by global_admin
// via /admin/team. The legacy `role` column is kept synchronised
// (mapped from the responsibility) until migration 058 drops it.
func (s *TeamService) AddMember(ctx context.Context, callerID, projectID, userID uuid.UUID, responsibility string) (*models.ProjectTeamMember, error) {
if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
return nil, err
}
if role == "" {
role = RoleAssociate
if responsibility == "" {
responsibility = ResponsibilityMember
}
if !isValidRole(role) {
return nil, fmt.Errorf("%w: invalid role %q", ErrInvalidInput, role)
if !IsValidResponsibility(responsibility) {
return nil, fmt.Errorf("%w: invalid responsibility %q", ErrInvalidInput, responsibility)
}
// Map responsibility → legacy role for the deprecated shadow column.
// Drop this mapping when migration 058 removes the column.
legacyRole := legacyRoleFromResponsibility(responsibility)
var m models.ProjectTeamMember
err := s.db.GetContext(ctx, &m,
`INSERT INTO paliad.project_teams (project_id, user_id, role, inherited, added_by)
VALUES ($1, $2, $3, false, $4)
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
VALUES ($1, $2, $3, $4, false, $5)
ON CONFLICT (project_id, user_id) DO UPDATE
SET role = EXCLUDED.role
RETURNING id, project_id, user_id, role, inherited, added_by, created_at`,
projectID, userID, role, callerID)
SET role = EXCLUDED.role,
responsibility = EXCLUDED.responsibility
RETURNING id, project_id, user_id, role, responsibility, inherited, added_by, created_at`,
projectID, userID, legacyRole, responsibility, callerID)
if err != nil {
return nil, fmt.Errorf("add team member: %w", err)
}
return &m, nil
}
// legacyRoleFromResponsibility maps the new project-responsibility value
// to the closest legacy project_teams.role value, so the deprecated
// shadow column stays consistent. Drop when migration 058 retires the
// column. external → 'local_counsel' is intentionally narrower than the
// new enum (loses the expert distinction); we accept that for the short
// transition window.
func legacyRoleFromResponsibility(r string) string {
switch r {
case ResponsibilityLead:
return "lead"
case ResponsibilityObserver:
return "observer"
case ResponsibilityExternal:
return "local_counsel"
default:
// 'member' has no single legacy mapping — pick 'associate' (the
// default the legacy code used). Real authority comes from
// users.profession now, so this label is purely cosmetic.
return "associate"
}
}
// RemoveMember deletes a direct team membership. Inherited memberships (from
// ancestors) can't be removed at the child level — the caller must remove
// the ancestor row to break the inheritance.
@@ -90,17 +124,18 @@ func (s *TeamService) ListDirectMembers(ctx context.Context, callerID, projectID
}
rows := []models.ProjectTeamMemberWithUser{}
err := s.db.SelectContext(ctx, &rows,
`SELECT pt.id, pt.project_id, pt.user_id, pt.role, pt.inherited,
`SELECT pt.id, pt.project_id, pt.user_id, pt.role, pt.responsibility, pt.inherited,
pt.added_by, pt.created_at,
u.email AS user_email,
u.display_name AS user_display_name,
u.office AS user_office,
u.profession AS user_profession,
NULL::uuid AS inherited_from_id,
NULL::text AS inherited_from_title
FROM paliad.project_teams pt
LEFT JOIN paliad.users u ON u.id = pt.user_id
WHERE pt.project_id = $1
ORDER BY pt.role, u.display_name`, projectID)
ORDER BY pt.responsibility, u.display_name`, projectID)
if err != nil {
return nil, fmt.Errorf("list direct team: %w", err)
}
@@ -119,7 +154,8 @@ func (s *TeamService) ListEffectiveMembers(ctx context.Context, callerID, projec
query := `
WITH candidate AS (
SELECT pt.id, pt.project_id, pt.user_id, pt.role, pt.added_by, pt.created_at,
SELECT pt.id, pt.project_id, pt.user_id, pt.role, pt.responsibility,
pt.added_by, pt.created_at,
(pt.project_id <> $1) AS inherited,
CASE WHEN pt.project_id <> $1 THEN pt.project_id END AS inherited_from_id,
CASE WHEN pt.project_id <> $1 THEN parent.title END AS inherited_from_title
@@ -133,17 +169,18 @@ func (s *TeamService) ListEffectiveMembers(ctx context.Context, callerID, projec
ORDER BY c.inherited ASC, c.created_at ASC
) AS rn FROM candidate c
)
SELECT r.id, r.project_id, r.user_id, r.role, r.inherited,
SELECT r.id, r.project_id, r.user_id, r.role, r.responsibility, r.inherited,
r.added_by, r.created_at,
u.email AS user_email,
u.display_name AS user_display_name,
u.office AS user_office,
u.profession AS user_profession,
r.inherited_from_id,
r.inherited_from_title
FROM ranked r
LEFT JOIN paliad.users u ON u.id = r.user_id
WHERE r.rn = 1
ORDER BY r.inherited ASC, r.role, u.display_name`
ORDER BY r.inherited ASC, r.responsibility, u.display_name`
rows := []models.ProjectTeamMemberWithUser{}
if err := s.db.SelectContext(ctx, &rows, query, projectID, pq.StringArray(ancestorIDs)); err != nil {
@@ -224,15 +261,6 @@ func (s *TeamService) ListMembershipsIndex(ctx context.Context, callerID uuid.UU
// ---------------------------------------------------------------------------
func isValidRole(r string) bool {
switch r {
case RoleLead, RoleAssociate, RolePA, RoleOfCounsel,
RoleLocalCounsel, RoleExpert, RoleObserver:
return true
}
return false
}
// pathToIDStrings splits a materialised path into its UUID labels as strings,
// suitable for pq.StringArray → uuid[] cast.
func pathToIDStrings(path string) []string {

View File

@@ -97,10 +97,17 @@ func (s *UserService) GetByID(ctx context.Context, id uuid.UUID) (*models.User,
// 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.
//
// Profession (t-paliad-148) is the structured firm-tier value that drives
// the approval ladder — partner / of_counsel / associate / senior_pa /
// pa / paralegal. Defaults to 'associate' when empty (the most common
// case for self-service signup). Admins can edit later via /admin/team.
// Distinct from JobTitle which is free-text display only.
type CreateUserInput struct {
DisplayName string `json:"display_name"`
Office string `json:"office"`
JobTitle string `json:"job_title"`
Profession string `json:"profession,omitempty"`
PartnerUnitID *uuid.UUID `json:"partner_unit_id,omitempty"`
}
@@ -131,6 +138,13 @@ func (s *UserService) Create(ctx context.Context, id uuid.UUID, email string, in
if jobTitle == "" {
return nil, fmt.Errorf("job_title is required")
}
profession := strings.TrimSpace(input.Profession)
if profession == "" {
profession = ProfessionAssociate
}
if !IsValidProfession(profession) {
return nil, fmt.Errorf("invalid profession %q", profession)
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
@@ -174,9 +188,9 @@ 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)
VALUES ($1, $2, $3, $4, $5, $6)`,
id, email, displayName, input.Office, jobTitle, globalRole,
`INSERT INTO paliad.users (id, email, display_name, office, job_title, profession, global_role)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
id, email, displayName, input.Office, jobTitle, profession, globalRole,
); err != nil {
return nil, fmt.Errorf("insert user: %w", err)
}
@@ -456,7 +470,13 @@ type AdminCreateInput struct {
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'
// Profession is the structured firm-tier value driving the approval
// ladder (t-paliad-148). Defaults to 'associate' when empty. Distinct
// from JobTitle which is a free-text display label. Use the empty
// string to indicate "no firm tier" (external collaborator); the
// admin form's "Extern" option submits "" here.
Profession string `json:"profession,omitempty"`
Lang string `json:"lang,omitempty"` // defaults to 'de'
}
// AdminCreateUser inserts a paliad.users row for an auth.users entry that has
@@ -485,6 +505,15 @@ func (s *UserService) AdminCreateUser(ctx context.Context, input AdminCreateInpu
if jobTitle == "" {
jobTitle = "Associate"
}
// Profession may be empty to flag an external collaborator; only
// non-empty values must validate against the enum.
profession := strings.TrimSpace(input.Profession)
if profession == "" {
profession = ProfessionAssociate
}
if !IsValidProfession(profession) {
return nil, fmt.Errorf("%w: invalid profession %q", ErrInvalidInput, profession)
}
lang := strings.ToLower(strings.TrimSpace(input.Lang))
if lang == "" {
lang = "de"
@@ -522,9 +551,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, lang)
VALUES ($1, $2, $3, $4, $5, 'standard', $6)`,
authID, email, displayName, input.Office, jobTitle, lang,
`INSERT INTO paliad.users (id, email, display_name, office, job_title, profession, global_role, lang)
VALUES ($1, $2, $3, $4, $5, $6, 'standard', $7)`,
authID, email, displayName, input.Office, jobTitle, profession, lang,
); err != nil {
return nil, fmt.Errorf("insert user: %w", err)
}
@@ -542,6 +571,10 @@ type AdminUpdateInput struct {
DisplayName *string `json:"display_name,omitempty"`
Office *string `json:"office,omitempty"`
JobTitle *string `json:"job_title,omitempty"`
// Profession (t-paliad-148). Empty string clears the column to NULL
// (external collaborator). Any non-empty value must be one of the
// recognised firm-tier values.
Profession *string `json:"profession,omitempty"`
GlobalRole *string `json:"global_role,omitempty"`
AdditionalOffices *[]string `json:"additional_offices,omitempty"`
Lang *string `json:"lang,omitempty"`
@@ -598,6 +631,22 @@ func (s *UserService) AdminUpdateUser(ctx context.Context, id uuid.UUID, input A
args = append(args, val)
i++
}
if input.Profession != nil {
// Empty string clears the column to NULL (external).
prof := strings.TrimSpace(*input.Profession)
var val any
if prof == "" {
val = nil
} else {
if !IsValidProfession(prof) {
return nil, fmt.Errorf("%w: invalid profession %q", ErrInvalidInput, prof)
}
val = prof
}
sets = append(sets, fmt.Sprintf("profession = $%d", i))
args = append(args, val)
i++
}
if input.GlobalRole != nil {
gr := strings.TrimSpace(*input.GlobalRole)
if gr != "standard" && gr != "global_admin" {