diff --git a/docs/design-profession-vs-project-role-2026-05-07.md b/docs/design-profession-vs-project-role-2026-05-07.md new file mode 100644 index 0000000..68ca153 --- /dev/null +++ b/docs/design-profession-vs-project-role-2026-05-07.md @@ -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 1–3 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 `` (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 + `${standardOpt}${adminOpt}`; } +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 `${esc(t("projects.team.profession.none") || "(extern)")}`; + } + return `${esc(professionLabel(u.profession))}`; +} + +function professionEditor(u: User): string { + const noneOpt = ``; + const opts = PROFESSION_VALUES.map( + (p) => ``, + ).join(""); + return ``; +} + 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 { ${esc(u.email)} ${esc(officeLabel(u.office))} ${jobTitle ? esc(jobTitle) : ""} + ${professionCell(u)} ${permissionCell(u)} ${additional.length ? additional.map((o) => esc(officeLabel(o))).join(", ") : ""} ${esc(u.lang.toUpperCase())} @@ -214,6 +248,7 @@ function renderEditRow(u: User): string { ${jobTitleList} + ${professionEditor(u)} ${permissionEditor(u)} ${additionalOfficesEditor(additional)} diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index db9d09a..da42e75 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -950,6 +950,8 @@ const translations: Record> = { "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> = { "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> = { "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> = { "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> = { "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> = { "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> = { "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> = { "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", diff --git a/frontend/src/client/onboarding.ts b/frontend/src/client/onboarding.ts index fd25b93..dc55a23 100644 --- a/frontend/src/client/onboarding.ts +++ b/frontend/src/client/onboarding.ts @@ -122,6 +122,7 @@ async function submitForm(e: Event): Promise { 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 { display_name: displayName, office, job_title: jobTitle, + profession, }; if (partnerUnitID) payload.partner_unit_id = partnerUnitID; diff --git a/frontend/src/client/projects-detail.ts b/frontend/src/client/projects-detail.ts index 1590ba9..1874ddf 100644 --- a/frontend/src/client/projects-detail.ts +++ b/frontend/src/client/projects-detail.ts @@ -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 ? ` ↑ ${esc(m.inherited_from_title || "")} @@ -1673,10 +1692,12 @@ function renderTeam() { ? `` : ""; 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 ` ${esc(m.user_display_name || m.user_email)} · ${esc(m.user_email)}${officeLabel ? " · " + esc(officeLabel) : ""} - ${esc(roleLabel)} + ${esc(professionLabel)} + ${esc(responsibilityLabel)} ${source} ${removeBtn} `; @@ -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 ` ${esc(m.user_display_name || m.user_email)} · ${esc(m.user_email)}${officeLabel ? " · " + esc(officeLabel) : ""} - ${esc(roleLabel)} + ${esc(responsibilityLabel)} ↓ ${sourceTitle} `; }) @@ -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" })); diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts index 7421e30..c796955 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -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" diff --git a/frontend/src/onboarding.tsx b/frontend/src/onboarding.tsx index 6aa9690..ca85ca3 100644 --- a/frontend/src/onboarding.tsx +++ b/frontend/src/onboarding.tsx @@ -71,6 +71,24 @@ export function renderOnboarding(): string { + + +

+ Strukturiertes Tier — steuert die 4-Augen-Genehmigung. Distinkt von der Berufsbezeichnung. +

+ diff --git a/frontend/src/projects-detail.tsx b/frontend/src/projects-detail.tsx index 3b88d53..bc909cb 100644 --- a/frontend/src/projects-detail.tsx +++ b/frontend/src/projects-detail.tsx @@ -120,16 +120,14 @@ export function renderProjectsDetail(): string {
- - + + + + +
@@ -143,7 +141,8 @@ export function renderProjectsDetail(): string { Name - Rolle + Profession + Rolle Herkunft diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index b6ebeff..5ab23b5 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -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; diff --git a/internal/db/migrations/059_profession_vs_responsibility.down.sql b/internal/db/migrations/059_profession_vs_responsibility.down.sql new file mode 100644 index 0000000..b6ae4c4 --- /dev/null +++ b/internal/db/migrations/059_profession_vs_responsibility.down.sql @@ -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; diff --git a/internal/db/migrations/059_profession_vs_responsibility.up.sql b/internal/db/migrations/059_profession_vs_responsibility.up.sql new file mode 100644 index 0000000..d9ef668 --- /dev/null +++ b/internal/db/migrations/059_profession_vs_responsibility.up.sql @@ -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') + ); diff --git a/internal/handlers/teams.go b/internal/handlers/teams.go index ec83ef8..e520f57 100644 --- a/internal/handlers/teams.go +++ b/internal/handlers/teams.go @@ -33,7 +33,11 @@ func handleListProjectTeam(w http.ResponseWriter, r *http.Request) { } // POST /api/projects/{id}/team — add a direct member. -// Body: {"user_id": "", "role": ""} +// Body: {"user_id": "", "responsibility": ""} +// +// 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 diff --git a/internal/models/models.go b/internal/models/models.go index b6d3c4d..c7018f5 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -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 " () — " without a per-row lookup. -// Used by TeamService.ListMembers which unions direct + inherited memberships. +// UI can render " () — " 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"` diff --git a/internal/services/approval_levels.go b/internal/services/approval_levels.go index 2f5f90a..379bf49 100644 --- a/internal/services/approval_levels.go +++ b/internal/services/approval_levels.go @@ -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: partner — firm-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: diff --git a/internal/services/approval_service.go b/internal/services/approval_service.go index 26115fd..4e15c4a 100644 --- a/internal/services/approval_service.go +++ b/internal/services/approval_service.go @@ -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 diff --git a/internal/services/approval_service_test.go b/internal/services/approval_service_test.go index 8da57f8..cf17417 100644 --- a/internal/services/approval_service_test.go +++ b/internal/services/approval_service_test.go @@ -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 diff --git a/internal/services/deadline_service.go b/internal/services/deadline_service.go index 0d471f1..cec228d 100644 --- a/internal/services/deadline_service.go +++ b/internal/services/deadline_service.go @@ -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 } diff --git a/internal/services/derivation_service.go b/internal/services/derivation_service.go index a901c4e..d194d78 100644 --- a/internal/services/derivation_service.go +++ b/internal/services/derivation_service.go @@ -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 } diff --git a/internal/services/project_service.go b/internal/services/project_service.go index bac697b..883b42c 100644 --- a/internal/services/project_service.go +++ b/internal/services/project_service.go @@ -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) } diff --git a/internal/services/reminder_service.go b/internal/services/reminder_service.go index ac8363b..934aaaf 100644 --- a/internal/services/reminder_service.go +++ b/internal/services/reminder_service.go @@ -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 diff --git a/internal/services/team_service.go b/internal/services/team_service.go index 460fbe2..a989826 100644 --- a/internal/services/team_service.go +++ b/internal/services/team_service.go @@ -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 { diff --git a/internal/services/user_service.go b/internal/services/user_service.go index a5ada97..b35ee4b 100644 --- a/internal/services/user_service.go +++ b/internal/services/user_service.go @@ -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" {