Merge: t-paliad-148 — split project_teams.role into firm-level profession + project-level responsibility (migration 059 + ApprovalService tuple-with-gate ladder + 3-col team table + admin-team profession + onboarding picker)
This commit is contained in:
841
docs/design-profession-vs-project-role-2026-05-07.md
Normal file
841
docs/design-profession-vs-project-role-2026-05-07.md
Normal file
@@ -0,0 +1,841 @@
|
||||
# Profession vs project responsibility — split `project_teams.role`
|
||||
|
||||
Inventor: kepler · Date: 2026-05-07 · Issue: m/paliad#6 (t-paliad-148)
|
||||
Branch: `mai/kepler/inventor-profession-vs`
|
||||
Status: **READY FOR REVIEW** — awaits m's go on the 12 open questions before any coder shift.
|
||||
|
||||
---
|
||||
|
||||
## §0 TL;DR
|
||||
|
||||
`paliad.project_teams.role` does two jobs at once: it labels a user's
|
||||
**career tier at the firm** (PA, Associate, Of Counsel) **and** it labels
|
||||
their **responsibility on this project** (Lead, Observer). m's bug report
|
||||
(2026-05-06): you don't redefine someone's profession when staffing them
|
||||
on a matter. The team-add dropdown should let you pick *responsibility*
|
||||
only; profession should come from the firm record.
|
||||
|
||||
This design splits the column into two:
|
||||
|
||||
1. **`paliad.users.profession`** — firm-wide career tier
|
||||
(`partner | of_counsel | associate | senior_pa | pa | paralegal | NULL`).
|
||||
Drives the t-138 approval ladder. NULL means "no firm tier" (external).
|
||||
2. **`paliad.project_teams.responsibility`** — per-project responsibility
|
||||
(`lead | member | observer | external`). Default `member`. Drives a
|
||||
simple gate — `lead` and `member` open authority; `observer` and
|
||||
`external` close it. Replaces the team-add dropdown values m
|
||||
complained about.
|
||||
|
||||
Approval ladder migrates from `project_teams.role` to a tuple
|
||||
**(profession, responsibility)** evaluated as: *level = profession_level
|
||||
if responsibility ∈ {lead, member} else 0*. Policy grammar
|
||||
(`required_role` single-value) stays unchanged from t-138.
|
||||
|
||||
Single migration 057. Backfills profession from the highest legacy
|
||||
`project_teams.role` per user. project_teams.role kept as a deprecated
|
||||
shadow column for one release, dropped in 058.
|
||||
|
||||
---
|
||||
|
||||
## §1 Problem & locked premises
|
||||
|
||||
### What m said (2026-05-06)
|
||||
|
||||
> "The Role should not be definable there. Whether a team member is PA
|
||||
> or Associate etc is not defined when adding existing members. Roles
|
||||
> for the project, maybe. But not the 'profession'."
|
||||
|
||||
### Locked decisions (m, 2026-05-06)
|
||||
|
||||
- **Profession is not redefined per project.** It comes from the user's
|
||||
firm-level record.
|
||||
- **Project-level role is meaningful.** Stays editable per project, but
|
||||
with a smaller value set focused on responsibility.
|
||||
- **Approval ladder must keep working** — t-138 just shipped. Whatever
|
||||
drives the ladder must still drive it.
|
||||
|
||||
### Three-axis principle (held since t-051)
|
||||
|
||||
> "Firm roles ≠ project roles ≠ tool roles."
|
||||
|
||||
Today's surfaces:
|
||||
|
||||
| Axis | Today's column | Today's values |
|
||||
|---|---|---|
|
||||
| Firm — display | `paliad.users.job_title` (free text) | "Counsel Knowledge Lawyer", "Junior Associate"… |
|
||||
| Firm — tool admin | `paliad.users.global_role` | `standard \| global_admin` |
|
||||
| Firm — partner-unit slot | `paliad.partner_unit_members.unit_role` | `lead \| attorney \| senior_pa \| pa \| paralegal` (per-unit, not firm-wide) |
|
||||
| Project — staffing | `paliad.project_teams.role` | **mixed: profession + responsibility** ← the bug |
|
||||
|
||||
The split adds a fourth, missing axis — **firm career tier** as a
|
||||
structured value that drives approval authority. It does not collapse
|
||||
job_title (free-text label is still useful) or unit_role (per-unit slot
|
||||
is still useful — see t-139 §11).
|
||||
|
||||
---
|
||||
|
||||
## §2 Verified live state (2026-05-07)
|
||||
|
||||
Probed `ydb` (paliad schema, port 11833) and current branch:
|
||||
|
||||
- `paliad.project_teams` CHECK on `role`: `lead, associate, pa,
|
||||
of_counsel, local_counsel, expert, observer, senior_pa` (from
|
||||
migration 054).
|
||||
- `paliad.project_teams` row count: **3 rows, all `role='lead'`**.
|
||||
- `paliad.partner_unit_members` CHECK on `unit_role`: `lead, attorney,
|
||||
senior_pa, pa, paralegal`. Row count: **20 rows, all
|
||||
`unit_role='attorney'`** (the default — nobody has overridden it
|
||||
yet).
|
||||
- `paliad.users` columns include `job_title` (text NULL), `global_role`
|
||||
(text NOT NULL DEFAULT 'standard'). No profession column.
|
||||
- `paliad.approval_role_level(text) RETURNS int IMMUTABLE` — strict
|
||||
ladder helper, used in 4 SQL sites in `approval_service.go`.
|
||||
- `paliad.approval_role_from_unit_role(text) RETURNS text IMMUTABLE` —
|
||||
bridges unit_role → ladder values for derived authority.
|
||||
- t-138 (commit `e2e1381`) and t-139 phases 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 `<select>` on the existing invite form (already
|
||||
rebuilt for t-paliad-141). The inviter is a colleague — they know
|
||||
whether they're inviting a PA or an associate.
|
||||
- Default `associate` makes the most common case one click. PAs and
|
||||
Of Counsels are explicit choices, not silent demotions.
|
||||
|
||||
**External invitees** (local counsel, expert): inviter sets
|
||||
`responsibility='external'` on the project; profession defaults to
|
||||
NULL (not asked) — the form hides the profession field when
|
||||
responsibility=external. Admin can fill profession later if the
|
||||
external collaborator becomes a paliad-tracked firm member.
|
||||
|
||||
### Q12 — Bulk add / invite-new flow Recommendation: **profession capture on invite; NULL allowed; admin-edits later**
|
||||
|
||||
The existing invite-new-user flow (`team-user-invite-btn` →
|
||||
`/api/team/invite-new`) accepts email + display_name today. After this
|
||||
change:
|
||||
|
||||
- Invite form gains a profession `<select>` (6 values + "Extern (keine
|
||||
Profession)").
|
||||
- Default selected: `associate`.
|
||||
- Submit creates `paliad.users` row with the picked profession +
|
||||
`paliad.project_teams` row with the picked responsibility (default
|
||||
`member`).
|
||||
- "Extern" sets `responsibility='external'` on the project_teams row,
|
||||
profession=NULL on users.
|
||||
|
||||
**No bulk-add UI exists today** — out of scope. If/when one ships, it
|
||||
inherits the same per-row profession field.
|
||||
|
||||
**Admin re-edit**: `/admin/team` page (already shipped t-paliad-050)
|
||||
gets a Profession column with inline-edit dropdown. Position next to
|
||||
job_title. No last-admin guard needed (profession is not a tool gate).
|
||||
|
||||
---
|
||||
|
||||
## §4 Sub-design B — Project responsibility axis (Q4, Q5, Q6, Q11)
|
||||
|
||||
### Q4 — Value set Recommendation: **`lead | member | observer | external`**
|
||||
|
||||
Issue body suggests this set. Locking it.
|
||||
|
||||
| Value | Meaning | Edit/approve authority |
|
||||
|---|---|---|
|
||||
| `lead` | The responsible lawyer/partner for this matter. Also has project-management permissions (manage settings, attach partner units — already wired in derivation_service.go). | Full (subject to profession ceiling). |
|
||||
| `member` | Staffed on this matter at their profession's level. | Full (subject to profession ceiling). |
|
||||
| `observer` | Read-only awareness; no edit/approve authority. | None. |
|
||||
| `external` | Non-firm collaborator (local counsel, expert witness). May edit per project policy, but cannot satisfy the firm-tier approval ladder. | Edit yes, approve no. |
|
||||
|
||||
**Why not collapse `external` into `observer`**: externals can actively
|
||||
write (local counsel files briefs, experts upload reports). Observers
|
||||
can't. The two are distinct read/write profiles and conflating them
|
||||
loses information.
|
||||
|
||||
**Why not add `pa-on-this-project` etc.** — that's profession × project,
|
||||
exactly what we just split. Once split, never re-mix.
|
||||
|
||||
### Q5 — Default value Recommendation: **`member`**
|
||||
|
||||
m's intuition is right. Lock.
|
||||
|
||||
The team-add form's default selection is `member`. The project creator
|
||||
is auto-added as `lead` (already coded in `project_service.go:486` —
|
||||
just rename the inserted column from `role='lead'` to
|
||||
`responsibility='lead'`).
|
||||
|
||||
### Q6 — Display Recommendation: **3 columns: Name · Profession (read-only badge) · Responsibility (editable inline)**, plus the existing Herkunft column
|
||||
|
||||
Layout for the team table on `/projects/{id}` Tab:
|
||||
|
||||
```
|
||||
| Name | Profession | Responsibility (edit) | Herkunft | Aktion |
|
||||
| Anna Schmidt | [PA] | [Lead ▾] | direkt | 🗑 |
|
||||
| Max Mustermann| [Associate] | [Member ▾] | über X-Unit | 🗑 |
|
||||
| Carla Smith | (extern) | [External] | direkt | 🗑 |
|
||||
```
|
||||
|
||||
- **Profession** column: read-only `.projekt-team-profession` pill (CSS
|
||||
variant of existing `.projekt-team-role`). Click for global_admin
|
||||
opens `/admin/team#user-{id}` for inline edit. For non-admins, a
|
||||
hover tooltip explains "Profession wird im Firmenprofil gepflegt".
|
||||
- **Responsibility** column: existing inline-edit pattern (`.entity-row
|
||||
select`) — reuses the t-paliad-141 inline-edit affordance. Edit
|
||||
permission = project lead OR global_admin.
|
||||
- NULL profession renders as `(extern)` or `(keine Profession)`
|
||||
depending on `responsibility`.
|
||||
|
||||
Inline prose elsewhere (Verlauf entries, inbox rows, email reminders):
|
||||
**"Anna Schmidt (PA) — Lead"** — profession in parens, responsibility
|
||||
after a dash. Explicit and parseable.
|
||||
|
||||
For the audit trail (`paliad.project_events`), emit
|
||||
`team_member_added` with `metadata = {responsibility: 'member',
|
||||
profession_at_time: 'pa'}` so historic rendering survives a profession
|
||||
change.
|
||||
|
||||
### Q11 — Team table layout post-fix Recommendation: **3-column tabular layout above; tooltip-only profession is rejected**
|
||||
|
||||
Two alternatives the issue posed:
|
||||
|
||||
- **Hover-only profession** ("Anna Schmidt — Lead", profession in
|
||||
tooltip badge): rejected. Profession is too important to hide. The
|
||||
whole point of the split is to make profession honestly visible.
|
||||
- **3-column tabular**: chosen. Matches the existing `.entity-table`
|
||||
pattern; profession is glanceable.
|
||||
|
||||
Tooltip is still useful as *secondary* signal: hover the profession
|
||||
badge → "PA — gesetzt im Firmenprofil. (Edit by global_admin only)".
|
||||
|
||||
The team-add form (the bug surface m complained about) loses the
|
||||
mixed-axis dropdown. New form:
|
||||
|
||||
```
|
||||
[ User autocomplete ▾ ] ← picks Anna Schmidt
|
||||
Anna Schmidt (PA) ← shown beneath, read from users.profession
|
||||
[ Responsibility: Member ▾ ] ← only dropdown left; default Member
|
||||
[ Cancel ] [ Hinzufügen ]
|
||||
```
|
||||
|
||||
**If the picked person has profession=NULL:** show a yellow warning
|
||||
under the profession line: "*Anna hat keine Profession gesetzt — sie
|
||||
kann keine 4-Augen-Genehmigungen erteilen. Admin im Firmenprofil
|
||||
nachtragen.*" Doesn't block the add, just informs.
|
||||
|
||||
---
|
||||
|
||||
## §5 Sub-design C — Approval ladder rename + migration (Q7, Q8, Q9, Q10)
|
||||
|
||||
### Q7 vs Q8 — Tuple-gated ladder Recommendation: **Q7 (rename to profession) with project-responsibility as a binary gate**
|
||||
|
||||
Two views the issue posed:
|
||||
|
||||
- **Q7**: ladder migrates from `project_teams.role` → `users.profession`.
|
||||
Project responsibility goes elsewhere; the ladder is purely
|
||||
profession-driven.
|
||||
- **Q8**: ladder becomes a tuple `(profession,
|
||||
project_responsibility)` — finer policies, e.g. "associate-level
|
||||
lawyer who is at least a member on this project".
|
||||
|
||||
**Recommend Q7-with-gate**: the ladder is profession-driven, and
|
||||
project responsibility acts as a *binary gate* (open/closed) rather
|
||||
than a separate dimension in the policy grammar.
|
||||
|
||||
Effective level for user U on project P:
|
||||
|
||||
```
|
||||
profession_level = approval_role_level(U.profession) -- 0 if NULL
|
||||
responsibility = project_teams.responsibility on P (direct or ancestor)
|
||||
gate_open = responsibility IN ('lead', 'member')
|
||||
|
||||
effective_level = profession_level if gate_open else 0
|
||||
```
|
||||
|
||||
For derived (partner-unit) authority (t-139):
|
||||
|
||||
```
|
||||
derived_role = approval_role_from_unit_role(unit_role)
|
||||
when ppu.derive_grants_authority = true
|
||||
effective_level = max over all sources (direct, ancestor, derived)
|
||||
```
|
||||
|
||||
(The "max" is operative because a user might be a `member` of one
|
||||
project at profession=PA, AND derive-with-authority into the same
|
||||
project via a partner-unit attachment with unit_role=senior_pa. Take
|
||||
the higher.)
|
||||
|
||||
**Why not pure Q8?**
|
||||
|
||||
- Pure tuple-grammar means policies look like `required_role='associate'
|
||||
AND required_responsibility='lead'`. Fine for power users; nobody
|
||||
has asked. We can add the responsibility dimension to
|
||||
`approval_policies` later (one new nullable column) if the firm
|
||||
wants finer rules. v1 stays single-dimension, matching m's t-138
|
||||
Q3 lock ("per-(project, entity_type, lifecycle_event)
|
||||
required_role").
|
||||
- Pure tuple also breaks Verlauf/audit phrasing — the audit currently
|
||||
reads "Genehmigung erforderlich: Associate-Tier oder höher", which
|
||||
stays clean under Q7-with-gate. Tuple grammar would need
|
||||
"Associate-Tier UND mindestens Mitglied".
|
||||
|
||||
**Why not pure Q7 without the gate?**
|
||||
|
||||
- Without the gate, an `observer` who happens to be a Partner could
|
||||
approve. That defeats the project-level call. The whole reason
|
||||
someone is set as observer is "you're not authoritative here, even
|
||||
though you're senior". The gate restores that semantics.
|
||||
- `external` (local counsel) without a gate would also approve via
|
||||
their own firm tier — except their profession is NULL, so they're
|
||||
level 0 anyway. The gate is defense-in-depth there: if a future
|
||||
external is given profession=of_counsel by mistake, the
|
||||
responsibility=external still keeps them at level 0.
|
||||
|
||||
**Implementation site**: a new SQL function
|
||||
`paliad.user_project_authority_level(_user_id uuid, _project_id uuid)
|
||||
RETURNS int IMMUTABLE` encapsulates the (profession, responsibility,
|
||||
derivation) computation. Replaces inline
|
||||
`paliad.approval_role_level(pt.role)` at the 4
|
||||
`approval_service.go` SQL sites. Plus a Go mirror
|
||||
`UserProjectAuthorityLevel(ctx, userID, projectID) int` for callers
|
||||
that need it without a SQL roundtrip (none today, but the
|
||||
DerivationService.EffectiveProjectRole becomes a thin wrapper).
|
||||
|
||||
Policy grammar stays exactly as t-138 designed. `required_role` is a
|
||||
profession value (`partner`, `of_counsel`, `associate`, `senior_pa`,
|
||||
`pa`). The CHECK on `approval_policies.required_role` is updated to
|
||||
the new enum (drop 'lead' — was the project-level value — and rename
|
||||
nothing; the SQL ladder values are 1:1 except the ceiling). Existing
|
||||
policy rows get backfilled `lead → partner` (the only mapping that
|
||||
changes).
|
||||
|
||||
### Q9 — Backfill plan Recommendation: **highest-tier-observed per user; `lead/of_counsel/associate/senior_pa/pa → matching profession`; `local_counsel/expert/observer → NULL`**
|
||||
|
||||
Backfill rules:
|
||||
|
||||
**Profession** (firm-wide, one row per user):
|
||||
|
||||
For each user with at least one `paliad.project_teams` row:
|
||||
|
||||
```
|
||||
profession = highest tier among all (direct) project_teams rows
|
||||
where:
|
||||
legacy 'lead' → 'partner' (level 5)
|
||||
legacy 'of_counsel' → 'of_counsel'(level 4)
|
||||
legacy 'associate' → 'associate' (level 3)
|
||||
legacy 'senior_pa' → 'senior_pa' (level 2)
|
||||
legacy 'pa' → 'pa' (level 1)
|
||||
legacy 'local_counsel' → IGNORED
|
||||
legacy 'expert' → IGNORED
|
||||
legacy 'observer' → IGNORED
|
||||
```
|
||||
|
||||
If after ignoring project-only labels the user has no firm-tier row →
|
||||
profession = NULL.
|
||||
|
||||
For users with NO project_teams rows → profession = NULL too. Admin
|
||||
edits at `/admin/team` if those users are firm members (the 11
|
||||
unit-only users in current data).
|
||||
|
||||
**Tie-break**: pick the highest level. If a user is `lead` on Project A
|
||||
and `pa` on Project B, profession = `partner` (level 5 > level 1).
|
||||
This matches m's "highest-tier observed" rule from the issue body.
|
||||
|
||||
**Edge case — only `observer` rows**: the user has exactly one
|
||||
`observer` row across all projects. Profession = NULL (no firm tier
|
||||
inferable from the data). Admin will need to set it.
|
||||
|
||||
**Edge case — `local_counsel` rows only**: user is external. Profession
|
||||
= NULL. Their project_teams.responsibility row will be 'external'
|
||||
(see below).
|
||||
|
||||
**Responsibility** (per project_teams row):
|
||||
|
||||
```
|
||||
legacy 'lead' → 'lead'
|
||||
legacy 'observer' → 'observer'
|
||||
legacy 'local_counsel' → 'external'
|
||||
legacy 'expert' → 'external'
|
||||
legacy 'associate' → 'member'
|
||||
legacy 'pa' → 'member'
|
||||
legacy 'of_counsel' → 'member'
|
||||
legacy 'senior_pa' → 'member'
|
||||
```
|
||||
|
||||
This preserves m's stated rules:
|
||||
|
||||
- `lead` → `lead`
|
||||
- `observer` → `observer`
|
||||
- everything else (firm tier) → `member` (their authority is now
|
||||
encoded in their profession; the project row just says "they're
|
||||
staffed")
|
||||
|
||||
External labels (`local_counsel`, `expert`) get
|
||||
`responsibility='external'`. Their profession remains NULL (the
|
||||
backfill above ignores them for profession purposes).
|
||||
|
||||
**Live data sanity check**: today there are 3 project_teams rows, all
|
||||
`role='lead'`. Backfill produces:
|
||||
|
||||
- 3 users get `profession='partner'`.
|
||||
- 3 project_teams rows get `responsibility='lead'`.
|
||||
|
||||
All other users (28 of 31) get `profession=NULL` until admin edits
|
||||
them at `/admin/team`. Acceptable — the firm has known they need an
|
||||
audit pass over user records since t-051; this surfaces it cleanly.
|
||||
|
||||
### Q10 — Down-migration safety Recommendation: **reversible with documented data loss on edge cases**
|
||||
|
||||
Down-migration steps (`057_down`):
|
||||
|
||||
1. Re-derive `project_teams.role` from `(responsibility, profession)`:
|
||||
|
||||
```sql
|
||||
UPDATE paliad.project_teams pt
|
||||
SET role = CASE
|
||||
WHEN pt.responsibility = 'lead' THEN 'lead'
|
||||
WHEN pt.responsibility = 'observer' THEN 'observer'
|
||||
WHEN pt.responsibility = 'external' THEN 'local_counsel'
|
||||
WHEN pt.responsibility = 'member' THEN COALESCE(
|
||||
(SELECT u.profession FROM paliad.users u WHERE u.id = pt.user_id),
|
||||
'associate'
|
||||
)
|
||||
END;
|
||||
```
|
||||
|
||||
- `external` always maps back to `local_counsel` (most common
|
||||
pre-split external label; `expert` is rarer and lossy).
|
||||
- `member` with profession=`partner` maps back to… ambiguous.
|
||||
Pre-split there was no firm-tier `partner` row in
|
||||
`project_teams`. Document data loss: maps to `of_counsel` (next
|
||||
highest legacy value). If the down is run, the partner re-appears
|
||||
as of_counsel on that project. Acceptable for a rollback.
|
||||
- `member` with profession=`paralegal` maps back to `pa` (closest
|
||||
legacy fit; `paralegal` was never a `project_teams.role` value).
|
||||
- `member` with profession=NULL maps back to `associate` (safe
|
||||
default, matches the legacy `RoleAssociate` default).
|
||||
|
||||
2. DROP COLUMN `paliad.users.profession`.
|
||||
3. DROP COLUMN `paliad.project_teams.responsibility`.
|
||||
4. Drop `paliad.user_project_authority_level` function.
|
||||
5. Restore `approval_service.go` SQL sites to inline
|
||||
`approval_role_level(pt.role)`.
|
||||
|
||||
Down-migration is best-effort. Documented data loss in `057_down.sql`
|
||||
comments. The Go code on `main` doesn't need to support both states
|
||||
(paliad doesn't have multi-version-deployed history); a down is a
|
||||
manual rollback path.
|
||||
|
||||
**Phasing**: `project_teams.role` stays on the table as a deprecated
|
||||
shadow column for one release (migration 057 keeps it; migration 058 —
|
||||
follow-up ticket — drops it after Go code is fully migrated). This
|
||||
means even in the worst case, a fast down doesn't have to recompute
|
||||
`role`; it just drops the new columns and keeps the old.
|
||||
|
||||
---
|
||||
|
||||
## §6 Migration plan — single migration 057
|
||||
|
||||
Filename: `internal/db/migrations/057_profession_vs_responsibility.up.sql`
|
||||
|
||||
Sections:
|
||||
|
||||
1. ADD `paliad.users.profession`.
|
||||
2. ADD `paliad.project_teams.responsibility`.
|
||||
3. CREATE `paliad.user_project_authority_level(user_id, project_id)`
|
||||
function.
|
||||
4. UPDATE `paliad.approval_policies.required_role` CHECK to add
|
||||
`'partner'` and drop `'lead'`. Backfill `'lead'` → `'partner'` in
|
||||
any existing rows.
|
||||
5. Backfill `users.profession` per Q9.
|
||||
6. Backfill `project_teams.responsibility` per Q9.
|
||||
7. UPDATE `paliad.can_see_project` body — replace `pt.role = 'lead'`
|
||||
with `pt.responsibility = 'lead'`. Function CASCADE-rebuild not
|
||||
needed (only function body changes).
|
||||
8. UPDATE the comment on `paliad.approval_role_level` to point at
|
||||
`users.profession` instead of `project_teams.role`.
|
||||
|
||||
`project_teams.role` is **kept** in this migration (deprecated, not
|
||||
read by any new code). Drop in follow-up migration 058 after Go code
|
||||
fully migrates and is verified live.
|
||||
|
||||
### Service-layer migration (single PR alongside 057)
|
||||
|
||||
Files to edit:
|
||||
|
||||
- `internal/services/team_service.go` — INSERT/SELECT/validate the new
|
||||
`responsibility` column. `isValidRole` becomes
|
||||
`isValidResponsibility` with new enum.
|
||||
- `internal/services/derivation_service.go` — `requireWritePermission`
|
||||
reads `pt.responsibility = 'lead'` instead of `pt.role = 'lead'`.
|
||||
`EffectiveProjectRole` (used by t-138 derived authority) replaced by
|
||||
`UserProjectAuthorityLevel` (returns int from the SQL function +
|
||||
source string). `ListAttachedUnits`, `ListDerivedMembers` unchanged
|
||||
(they don't touch the ladder column).
|
||||
- `internal/services/approval_service.go` — 4 SQL sites switch from
|
||||
`paliad.approval_role_level(pt.role)` to
|
||||
`paliad.user_project_authority_level(pt.user_id, $project_id)`.
|
||||
Self-approval CHECK and policy lookup stay identical.
|
||||
- `internal/services/approval_levels.go` — Go-side `levelOf()` becomes
|
||||
`professionLevel()`; new helper `responsibilityOpensGate()`.
|
||||
`RoleSeniorPA` constant stays (still a valid profession value,
|
||||
reused). New constants `ProfessionPartner`, `ProfessionOfCounsel`,
|
||||
`ProfessionAssociate`, `ProfessionSeniorPA`, `ProfessionPA`,
|
||||
`ProfessionParalegal`. New constants `ResponsibilityLead`,
|
||||
`ResponsibilityMember`, `ResponsibilityObserver`,
|
||||
`ResponsibilityExternal`.
|
||||
- `internal/services/project_service.go:486` — INSERT writes
|
||||
`responsibility='lead'` (creator-as-lead). Old
|
||||
`RoleLead`/`RoleAssociate`/etc constants stay as aliases for one
|
||||
release to ease grep diffs; mark deprecated.
|
||||
- `internal/services/reminder_service.go:317,330` —
|
||||
`pt.role = 'lead'` → `pt.responsibility = 'lead'`.
|
||||
- `internal/services/deadline_service.go:695` —
|
||||
`pt.role IN ('admin', 'lead')` → `pt.responsibility = 'lead'`.
|
||||
(`'admin'` was already dead since t-051; this is also a small
|
||||
cleanup.)
|
||||
- `internal/services/user_service.go` — onboarding/invite code
|
||||
accepts a `profession` arg, stores on insert.
|
||||
- `internal/handlers/team.go` (and friends) — JSON shape change:
|
||||
`ProjectTeamMember` now exposes `responsibility` instead of `role`,
|
||||
embeds `User.Profession`.
|
||||
- `internal/models/models.go` — `ProjectTeamMember.Role` → `.Responsibility`;
|
||||
`User` gains `.Profession *string`.
|
||||
|
||||
### Frontend migration (same PR)
|
||||
|
||||
- `frontend/src/projects-detail.tsx:124-132` — replace 7-option mixed
|
||||
dropdown with 4-option responsibility-only dropdown
|
||||
(`lead | member | observer | external`). Default `member`.
|
||||
- `frontend/src/client/projects-detail.ts:1665,1720,1772,1856` — render
|
||||
3-column team table. New `.projekt-team-profession` CSS pill +
|
||||
i18n keys `projects.team.profession.partner` …
|
||||
`projects.team.profession.paralegal`. New i18n keys
|
||||
`projects.team.responsibility.lead` … `.external` (replace
|
||||
`projects.team.role.*`).
|
||||
- `frontend/src/client/team.ts` — `/team` directory page: respect new
|
||||
profession column for grouping. Falls back to job_title when
|
||||
profession=NULL (existing free-text behaviour preserved for
|
||||
externals).
|
||||
- `frontend/src/admin-team.tsx` + `client/admin-team.ts` — add
|
||||
Profession column with inline-edit dropdown.
|
||||
- `frontend/src/onboarding.tsx` — invite flow gains a profession
|
||||
`<select>`.
|
||||
- ~30 new i18n keys DE+EN.
|
||||
|
||||
### Tests
|
||||
|
||||
- `internal/services/team_service_test.go` — happy path on
|
||||
AddMember/RemoveMember with new responsibility values; reject
|
||||
invalid values.
|
||||
- `internal/services/approval_service_test.go` — extend
|
||||
table-driven ladder tests to cover the
|
||||
(profession, responsibility) tuple. Cases: `partner+observer = 0`,
|
||||
`pa+lead = 1`, `null+member = 0`, derived+responsibility=external
|
||||
combinations.
|
||||
- `internal/services/migration_057_test.go` — live-DB integration
|
||||
test (skipped without `TEST_DATABASE_URL`): apply migration on a
|
||||
seeded snapshot, assert backfill produces expected
|
||||
(profession, responsibility) pairs.
|
||||
|
||||
---
|
||||
|
||||
## §7 Implementation phasing
|
||||
|
||||
**Single PR, 6 commits** — the schema + service + frontend are tightly
|
||||
coupled. Splitting risks half-broken intermediate states (the bug
|
||||
report itself is about a half-broken intermediate state).
|
||||
|
||||
1. Migration 057 (schema + backfill + new SQL function). No code
|
||||
changes — server still reads `pt.role`. Verify backfill on live DB
|
||||
via BEGIN/ROLLBACK.
|
||||
2. ApprovalService + DerivationService rewire. Tests updated. Build +
|
||||
test green. Server reads from new SQL function but writes still go
|
||||
to `pt.role` (will fix in commit 3).
|
||||
3. TeamService + UserService rewire. INSERT writes
|
||||
`responsibility=...`. Reads return `responsibility`. Models
|
||||
updated. JSON schema change.
|
||||
4. Frontend rewire — team-add dropdown, team table, admin-team,
|
||||
onboarding. New i18n keys.
|
||||
5. Reminder + Deadline service touch-ups + can_see_project body
|
||||
refresh.
|
||||
6. Lint + grep sweep — kill any remaining `pt.role` references that
|
||||
should have been migrated. Add a deprecation comment to the
|
||||
`RoleLead`/`RoleAssociate` Go constants pointing at the new ones.
|
||||
|
||||
**Follow-up ticket (out of scope for this PR)**: t-paliad-149 —
|
||||
migration 058 to DROP COLUMN `project_teams.role` after one release of
|
||||
soak time on main. Trivial when the time comes; just keeps this PR
|
||||
clean.
|
||||
|
||||
**Recommended implementer**: any pattern-fluent coder. **NOT cronus**
|
||||
(retired from paliad per memory directive). Sonnet work — 70% of the
|
||||
diff is mechanical rename, 30% is the new SQL function + 4 ladder-site
|
||||
rewrites + the new team-table layout. The substrate is well-trodden
|
||||
(t-051 split established the pattern; t-138/t-139 left clean call
|
||||
sites to migrate from).
|
||||
|
||||
---
|
||||
|
||||
## §8 Trade-offs flagged
|
||||
|
||||
1. **One migration touches both axes at once.** A pure-additive
|
||||
migration (add columns, leave `role`) would be safer-feeling, but
|
||||
then the team-add dropdown bug stays open (the UX lie m hates is
|
||||
still on screen until commit 4). I prefer one PR that ships the
|
||||
fix end-to-end, with `project_teams.role` deprecated-shadow for
|
||||
one release as the safety net.
|
||||
2. **Profession=NULL semantics are load-bearing.** NULL means "no
|
||||
firm tier" → ladder level 0 → ineligible. If a developer later
|
||||
adds a fast-path that defaults NULL→`associate` for "convenience",
|
||||
externals would silently gain approval rights. Mitigation: explicit
|
||||
helper `professionLevel(*string) int` that returns 0 for NULL with
|
||||
a comment naming the trap. Add a unit test `TestProfessionLevel_NilIsZero`.
|
||||
3. **`partner` is the new ceiling but `lead` is no longer a profession**.
|
||||
The mental jump for users: "Lead" was the highest in the dropdown;
|
||||
now "Partner" is. Renaming is honest but a moment of surprise.
|
||||
Mitigation: i18n keys carry over the lead-on-this-project sense via
|
||||
`projects.team.responsibility.lead` so the word "Lead" stays
|
||||
visible exactly where it should — the project axis. Profession's
|
||||
"Partner" appears in firm-context surfaces (admin/team, tooltips).
|
||||
4. **Tuple-gated ladder vs pure-tuple grammar.** Choosing
|
||||
responsibility as a binary gate means a future "must be a member,
|
||||
not just having visibility" rule is easy. A future "must be lead
|
||||
AND of_counsel-tier or higher" rule needs a new dimension on
|
||||
`approval_policies` (new nullable column). Acceptable: zero
|
||||
policies today need it; cheap to add when one does.
|
||||
5. **Backfill produces 28 NULL professions** out of 31 users (the
|
||||
ones not in any project_teams row). After ship, `/admin/team` will
|
||||
show a warning column "Profession nicht gesetzt" until admin
|
||||
completes the audit. This is honest visibility of pre-existing data
|
||||
debt rather than papering over with a guessed default.
|
||||
6. **`approval_role_from_unit_role` doesn't change** but its callers
|
||||
(the derived-authority SQL branches in approval_service.go) need to
|
||||
move from "compare against `pt.role`" to "compare against
|
||||
`users.profession` of the project_teams row's user". Mechanical;
|
||||
listed in §6 file inventory.
|
||||
|
||||
---
|
||||
|
||||
## §9 Out of scope (v1)
|
||||
|
||||
- Replacing the partner-unit-derivation mechanism (t-139 Phase 2) —
|
||||
derivation stays exactly as designed.
|
||||
- A full firm-roles / hierarchy / org-chart feature — this design adds
|
||||
one structured column (profession) and nothing more.
|
||||
- Multi-profession (paralegal-turned-associate scenario). One
|
||||
profession per user; admin edits when promoted.
|
||||
- Time-sliced profession history (who was a PA in 2024). Out per
|
||||
issue body.
|
||||
- Adding a `responsibility` dimension to `approval_policies` (Q8 pure
|
||||
tuple grammar). Deferred to a future ticket if a real policy
|
||||
requires it.
|
||||
- Bulk-add UI for project members. None exists today.
|
||||
- Dropping `project_teams.role` itself. Deferred to follow-up
|
||||
migration 058 after one release of soak time.
|
||||
|
||||
---
|
||||
|
||||
## §10 12 Questions — Recommendation summary
|
||||
|
||||
| # | Question | Recommendation | Locked? |
|
||||
|---|---|---|---|
|
||||
| Q1 | Where does profession live? | (a) New `paliad.users.profession` text column | open — m sign-off |
|
||||
| Q2 | Profession values | `partner \| of_counsel \| associate \| senior_pa \| pa \| paralegal` (NULL = external) | open — m sign-off |
|
||||
| Q3 | Onboarding flow | Required-on-invite, default `associate`, admin-editable | open — m sign-off |
|
||||
| Q4 | Project responsibility values | `lead \| member \| observer \| external` | open — m hinted yes |
|
||||
| Q5 | Default value | `member` | open — m hinted yes |
|
||||
| Q6 | Display | 3 columns: Name · Profession (badge) · Responsibility (inline-edit), plus existing Herkunft | open — m sign-off |
|
||||
| Q7 vs Q8 | Ladder migration | Q7 (rename to profession) WITH project-responsibility as a binary gate (`responsibility ∈ {lead, member}` opens the gate) | open — main architectural call |
|
||||
| Q9 | Backfill | Profession = highest legacy tier per user (`lead → partner`, `of_counsel → of_counsel`, …, externals → NULL); responsibility per single-row mapping (`lead → lead`, `observer → observer`, externals → `external`, others → `member`) | open — m sign-off |
|
||||
| Q10 | Down-migration | Reversible with documented best-effort data loss; `project_teams.role` kept as deprecated shadow until follow-up 058 | open — m sign-off |
|
||||
| Q11 | Team table layout | 3-column tabular (rejecting tooltip-only profession); inline-edit responsibility; profession edits live on `/admin/team` | open — m sign-off |
|
||||
| Q12 | Bulk add / invite | Profession capture on invite (default `associate`, "Extern" hides field). No bulk-add v1. Admin re-edits via `/admin/team` | open — m sign-off |
|
||||
|
||||
---
|
||||
|
||||
## §11 Coordination with sibling work
|
||||
|
||||
- **t-138 (approvals)**: shipped 2026-05-06 (commit `e2e1381`). Migration
|
||||
054 sets up the ladder; this design extends it to read from
|
||||
`users.profession` instead of `project_teams.role`. Policy grammar
|
||||
unchanged. `required_role` enum gains `partner`, drops `lead`
|
||||
(renamed in backfill).
|
||||
- **t-139 (hierarchy + derivation)**: all 3 phases shipped. Migration
|
||||
055 added `partner_unit_members.unit_role` and the
|
||||
`approval_role_from_unit_role` bridge. This design leaves the
|
||||
bridge untouched — `unit_role` values map 1:1 to the new profession
|
||||
enum (`lead → partner`, `attorney → associate`, `senior_pa →
|
||||
senior_pa`, `pa → pa`, `paralegal → paralegal`). Update the bridge's
|
||||
`lead → lead` row to `lead → partner` in migration 057.
|
||||
- **t-144 (Custom Views)**: shipped. ViewService.runApprovalRequests
|
||||
uses ApprovalService.ListPendingForApprover, which reads the new
|
||||
ladder. Inherits the change automatically.
|
||||
- **t-paliad-145 (local chat)**: parked. Not relevant.
|
||||
|
||||
No siblings are blocked by this work, and this work doesn't block any
|
||||
sibling. Independent migration, independent merge.
|
||||
|
||||
---
|
||||
|
||||
## §12 Inventor parking
|
||||
|
||||
Inventor (kepler) parks here. Awaits m's pass through the 12 questions
|
||||
in §10 + any course-correction. After m signs off, this design locks
|
||||
and a fresh coder shift can pick up the single PR. Branch:
|
||||
`mai/kepler/inventor-profession-vs`.
|
||||
|
||||
DESIGN READY FOR REVIEW.
|
||||
@@ -72,6 +72,7 @@ export function renderAdminTeam(): string {
|
||||
<th data-i18n="admin.team.col.email">E-Mail</th>
|
||||
<th data-i18n="admin.team.col.office">Standort</th>
|
||||
<th data-i18n="admin.team.col.job_title">Berufsbezeichnung</th>
|
||||
<th data-i18n="admin.team.col.profession">Profession</th>
|
||||
<th data-i18n="admin.team.col.permission">Berechtigung</th>
|
||||
<th data-i18n="admin.team.col.additional">Weitere Standorte</th>
|
||||
<th data-i18n="admin.team.col.lang">Sprache</th>
|
||||
@@ -80,7 +81,7 @@ export function renderAdminTeam(): string {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="admin-team-tbody">
|
||||
<tr><td colspan={9} className="admin-team-loading" data-i18n="admin.team.loading">Lade...</td></tr>
|
||||
<tr><td colspan={10} className="admin-team-loading" data-i18n="admin.team.loading">Lade...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,10 @@ interface User {
|
||||
office: string;
|
||||
additional_offices?: string[];
|
||||
job_title: string | null;
|
||||
// t-paliad-148: structured firm-tier (partner/of_counsel/associate/
|
||||
// senior_pa/pa/paralegal). NULL = external. Editable via the
|
||||
// admin-team Profession column.
|
||||
profession?: string | null;
|
||||
global_role: string;
|
||||
lang: string;
|
||||
reminder_morning_time?: string;
|
||||
@@ -16,6 +20,15 @@ interface User {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const PROFESSION_VALUES = [
|
||||
"partner",
|
||||
"of_counsel",
|
||||
"associate",
|
||||
"senior_pa",
|
||||
"pa",
|
||||
"paralegal",
|
||||
];
|
||||
|
||||
interface Office {
|
||||
key: string;
|
||||
label_de: string;
|
||||
@@ -180,6 +193,26 @@ function permissionEditor(u: User): string {
|
||||
return `<select class="admin-team-input" data-field="global_role"${disabled}${title}>${standardOpt}${adminOpt}</select>`;
|
||||
}
|
||||
|
||||
function professionLabel(p: string | null | undefined): string {
|
||||
if (!p) return "";
|
||||
return tDyn(`projects.team.profession.${p}`) || p;
|
||||
}
|
||||
|
||||
function professionCell(u: User): string {
|
||||
if (!u.profession) {
|
||||
return `<span class="admin-team-muted" title="${esc(t("admin.team.col.profession.none.hint") || "Keine Profession gesetzt — keine 4-Augen-Befugnis")}">${esc(t("projects.team.profession.none") || "(extern)")}</span>`;
|
||||
}
|
||||
return `<span class="projekt-team-profession">${esc(professionLabel(u.profession))}</span>`;
|
||||
}
|
||||
|
||||
function professionEditor(u: User): string {
|
||||
const noneOpt = `<option value=""${!u.profession ? " selected" : ""}>${esc(t("admin.team.col.profession.none") || "(extern)")}</option>`;
|
||||
const opts = PROFESSION_VALUES.map(
|
||||
(p) => `<option value="${esc(p)}"${u.profession === p ? " selected" : ""}>${esc(professionLabel(p))}</option>`,
|
||||
).join("");
|
||||
return `<select class="admin-team-input" data-field="profession">${noneOpt}${opts}</select>`;
|
||||
}
|
||||
|
||||
function renderRow(u: User): string {
|
||||
if (editingId === u.id) return renderEditRow(u);
|
||||
const additional = (u.additional_offices ?? []).filter((o) => o !== u.office);
|
||||
@@ -190,6 +223,7 @@ function renderRow(u: User): string {
|
||||
<td><a href="mailto:${esc(u.email)}">${esc(u.email)}</a></td>
|
||||
<td><span class="office-chip office-${esc(u.office)}">${esc(officeLabel(u.office))}</span></td>
|
||||
<td>${jobTitle ? esc(jobTitle) : "<span class=\"admin-team-muted\">—</span>"}</td>
|
||||
<td>${professionCell(u)}</td>
|
||||
<td>${permissionCell(u)}</td>
|
||||
<td>${additional.length ? additional.map((o) => esc(officeLabel(o))).join(", ") : "<span class=\"admin-team-muted\">—</span>"}</td>
|
||||
<td>${esc(u.lang.toUpperCase())}</td>
|
||||
@@ -214,6 +248,7 @@ function renderEditRow(u: User): string {
|
||||
<input type="text" class="admin-team-input" data-field="job_title" value="${esc(jobTitle)}" list="admin-team-job-title-suggest-${esc(u.id)}" />
|
||||
<datalist id="admin-team-job-title-suggest-${esc(u.id)}">${jobTitleList}</datalist>
|
||||
</td>
|
||||
<td>${professionEditor(u)}</td>
|
||||
<td>${permissionEditor(u)}</td>
|
||||
<td class="admin-team-multi">${additionalOfficesEditor(additional)}</td>
|
||||
<td><select class="admin-team-input" data-field="lang">${langOptions(u.lang)}</select></td>
|
||||
|
||||
@@ -950,6 +950,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"onboarding.office.placeholder": "Bitte ausw\u00e4hlen",
|
||||
"onboarding.job_title": "Berufsbezeichnung",
|
||||
"onboarding.job_title.placeholder": "z.B. Associate, Partner, Patentanwalt",
|
||||
"onboarding.profession": "Profession",
|
||||
"onboarding.profession.hint": "Strukturiertes Tier — steuert die 4-Augen-Genehmigung. Distinkt von der Berufsbezeichnung.",
|
||||
"onboarding.partner_unit": "Partner Unit",
|
||||
"onboarding.partner_unit.unassigned": "(noch keine Zuordnung)",
|
||||
"onboarding.optional": "(optional)",
|
||||
@@ -1093,10 +1095,15 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.verlauf.loadMore": "Mehr laden",
|
||||
"projects.detail.team.form.user": "Benutzer",
|
||||
"projects.detail.team.form.role": "Rolle",
|
||||
"projects.detail.team.form.responsibility": "Rolle im Projekt",
|
||||
"projects.detail.team.form.profession.label": "Profession",
|
||||
"projects.detail.team.form.profession.none": "Keine Profession gesetzt \u2014 kann keine 4-Augen-Genehmigungen erteilen.",
|
||||
"projects.detail.team.form.cancel": "Abbrechen",
|
||||
"projects.detail.team.form.submit": "Hinzuf\u00fcgen",
|
||||
"projects.detail.team.col.name": "Name",
|
||||
"projects.detail.team.col.role": "Rolle",
|
||||
"projects.detail.team.col.profession": "Profession",
|
||||
"projects.detail.team.col.responsibility": "Rolle im Projekt",
|
||||
"projects.detail.team.col.source": "Herkunft",
|
||||
"projects.detail.kinder.add": "Untervorhaben anlegen",
|
||||
"projects.detail.kinder.empty": "Keine untergeordneten Projekte.",
|
||||
@@ -1144,6 +1151,19 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.team.role.local_counsel": "Local Counsel",
|
||||
"projects.team.role.expert": "Experte",
|
||||
"projects.team.role.observer": "Beobachter",
|
||||
"projects.team.responsibility.lead": "Leitung",
|
||||
"projects.team.responsibility.member": "Mitglied",
|
||||
"projects.team.responsibility.observer": "Beobachter",
|
||||
"projects.team.responsibility.external": "Extern",
|
||||
"projects.team.profession.partner": "Partner",
|
||||
"projects.team.profession.of_counsel": "Of Counsel",
|
||||
"projects.team.profession.associate": "Associate",
|
||||
"projects.team.profession.senior_pa": "Senior PA",
|
||||
"projects.team.profession.pa": "PA",
|
||||
"projects.team.profession.paralegal": "Paralegal",
|
||||
"projects.team.profession.none": "(extern)",
|
||||
"projects.team.profession.hint": "Profession \u2014 gesetzt im Firmenprofil",
|
||||
"projects.team.profession.none.hint": "Keine Profession gesetzt \u2014 keine 4-Augen-Befugnis",
|
||||
"projects.team.direct": "direkt",
|
||||
"projects.team.inherited.hint": "Vererbt vom \u00dcberprojekt",
|
||||
"projects.detail.team.add": "Mitglied hinzuf\u00fcgen",
|
||||
@@ -1569,6 +1589,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.team.col.email": "E-Mail",
|
||||
"admin.team.col.office": "Standort",
|
||||
"admin.team.col.job_title": "Berufsbezeichnung",
|
||||
"admin.team.col.profession": "Profession",
|
||||
"admin.team.col.profession.none": "(extern)",
|
||||
"admin.team.col.profession.none.hint": "Keine Profession gesetzt — keine 4-Augen-Befugnis.",
|
||||
"admin.team.col.permission": "Berechtigung",
|
||||
"admin.team.col.additional": "Weitere Standorte",
|
||||
"admin.team.col.lang": "Sprache",
|
||||
@@ -2843,6 +2866,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"onboarding.office.placeholder": "Please select",
|
||||
"onboarding.job_title": "Job title",
|
||||
"onboarding.job_title.placeholder": "e.g. Associate, Partner, Patent Attorney",
|
||||
"onboarding.profession": "Profession",
|
||||
"onboarding.profession.hint": "Structured tier — drives the 4-eye approval ladder. Distinct from job title.",
|
||||
"onboarding.partner_unit": "Partner Unit",
|
||||
"onboarding.partner_unit.unassigned": "(not assigned yet)",
|
||||
"onboarding.optional": "(optional)",
|
||||
@@ -2986,10 +3011,15 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.verlauf.loadMore": "Load more",
|
||||
"projects.detail.team.form.user": "User",
|
||||
"projects.detail.team.form.role": "Role",
|
||||
"projects.detail.team.form.responsibility": "Project role",
|
||||
"projects.detail.team.form.profession.label": "Profession",
|
||||
"projects.detail.team.form.profession.none": "No profession set — cannot grant 4-eye approvals.",
|
||||
"projects.detail.team.form.cancel": "Cancel",
|
||||
"projects.detail.team.form.submit": "Add",
|
||||
"projects.detail.team.col.name": "Name",
|
||||
"projects.detail.team.col.role": "Role",
|
||||
"projects.detail.team.col.profession": "Profession",
|
||||
"projects.detail.team.col.responsibility": "Project role",
|
||||
"projects.detail.team.col.source": "Source",
|
||||
"projects.detail.kinder.add": "Create sub-project",
|
||||
"projects.detail.kinder.empty": "No sub-projects.",
|
||||
@@ -3037,6 +3067,19 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.team.role.local_counsel": "Local Counsel",
|
||||
"projects.team.role.expert": "Expert",
|
||||
"projects.team.role.observer": "Observer",
|
||||
"projects.team.responsibility.lead": "Lead",
|
||||
"projects.team.responsibility.member": "Member",
|
||||
"projects.team.responsibility.observer": "Observer",
|
||||
"projects.team.responsibility.external": "External",
|
||||
"projects.team.profession.partner": "Partner",
|
||||
"projects.team.profession.of_counsel": "Of Counsel",
|
||||
"projects.team.profession.associate": "Associate",
|
||||
"projects.team.profession.senior_pa": "Senior PA",
|
||||
"projects.team.profession.pa": "PA",
|
||||
"projects.team.profession.paralegal": "Paralegal",
|
||||
"projects.team.profession.none": "(external)",
|
||||
"projects.team.profession.hint": "Profession — managed in firm profile",
|
||||
"projects.team.profession.none.hint": "No profession set — no 4-eye authority",
|
||||
"projects.team.direct": "direct",
|
||||
"projects.team.inherited.hint": "Inherited from ancestor",
|
||||
"projects.detail.team.add": "Add member",
|
||||
@@ -3459,6 +3502,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.team.col.email": "Email",
|
||||
"admin.team.col.office": "Office",
|
||||
"admin.team.col.job_title": "Job title",
|
||||
"admin.team.col.profession": "Profession",
|
||||
"admin.team.col.profession.none": "(external)",
|
||||
"admin.team.col.profession.none.hint": "No profession set — no 4-eye authority.",
|
||||
"admin.team.col.permission": "Permission",
|
||||
"admin.team.col.additional": "Additional offices",
|
||||
"admin.team.col.lang": "Lang",
|
||||
|
||||
@@ -122,6 +122,7 @@ async function submitForm(e: Event): Promise<void> {
|
||||
const displayName = (data.get("display_name") as string || "").trim();
|
||||
const office = (data.get("office") as string || "").trim();
|
||||
const jobTitle = (data.get("job_title") as string || "").trim();
|
||||
const profession = (data.get("profession") as string || "").trim();
|
||||
const partnerUnitID = (data.get("partner_unit_id") as string || "").trim();
|
||||
|
||||
if (!displayName) {
|
||||
@@ -141,6 +142,7 @@ async function submitForm(e: Event): Promise<void> {
|
||||
display_name: displayName,
|
||||
office,
|
||||
job_title: jobTitle,
|
||||
profession,
|
||||
};
|
||||
if (partnerUnitID) payload.partner_unit_id = partnerUnitID;
|
||||
|
||||
|
||||
@@ -37,11 +37,19 @@ interface ProjectTeamMember {
|
||||
id: string;
|
||||
project_id: string;
|
||||
user_id: string;
|
||||
// t-paliad-148: per-project responsibility (lead/member/observer/external).
|
||||
// The legacy .role field is still set by the server during the
|
||||
// deprecation window but the UI ignores it for new code.
|
||||
responsibility: string;
|
||||
role: string;
|
||||
inherited: boolean;
|
||||
user_email: string;
|
||||
user_display_name: string;
|
||||
user_office: string;
|
||||
// user_profession is the structured firm tier (partner/of_counsel/…/
|
||||
// paralegal). NULL means external collaborator. Read-only here — the
|
||||
// value is set on the user's firm profile, not at staffing time.
|
||||
user_profession?: string | null;
|
||||
inherited_from_id?: string | null;
|
||||
inherited_from_title?: string | null;
|
||||
}
|
||||
@@ -204,7 +212,7 @@ let descendantStaffed: ProjectTeamMember[] = [];
|
||||
let derivedMembers: DerivedMember[] = [];
|
||||
let attachedUnits: AttachedUnit[] = [];
|
||||
let allUnits: { id: string; name: string; office: string }[] = [];
|
||||
let userOptions: { id: string; display_name: string; email: string }[] = [];
|
||||
let userOptions: { id: string; display_name: string; email: string; profession?: string }[] = [];
|
||||
|
||||
const EVENTS_PAGE_SIZE = 50;
|
||||
let eventsHasMore = false;
|
||||
@@ -1662,7 +1670,18 @@ function renderTeam() {
|
||||
|
||||
body.innerHTML = teamMembers
|
||||
.map((m) => {
|
||||
const roleLabel = tDyn(`projects.team.role.${m.role}`) || m.role;
|
||||
// t-paliad-148: profession is firm-wide (read-only badge) and
|
||||
// responsibility is per-project. Both are surfaced; the legacy
|
||||
// .role field is still set by the server during the deprecation
|
||||
// window but the UI ignores it.
|
||||
const responsibility = m.responsibility || "member";
|
||||
const responsibilityLabel = tDyn(`projects.team.responsibility.${responsibility}`) || responsibility;
|
||||
const professionLabel = m.user_profession
|
||||
? tDyn(`projects.team.profession.${m.user_profession}`) || m.user_profession
|
||||
: (t("projects.team.profession.none") || "(extern)");
|
||||
const professionTitle = m.user_profession
|
||||
? (t("projects.team.profession.hint") || "Profession — gesetzt im Firmenprofil")
|
||||
: (t("projects.team.profession.none.hint") || "Keine Profession gesetzt — keine 4-Augen-Befugnis");
|
||||
const source = m.inherited
|
||||
? `<span class="projekt-team-inherited" title="${escAttr(t("projects.team.inherited.hint") || "Inherited from ancestor")}">
|
||||
↑ ${esc(m.inherited_from_title || "")}
|
||||
@@ -1673,10 +1692,12 @@ function renderTeam() {
|
||||
? `<button type="button" class="btn-ghost btn-small team-remove-btn" data-user-id="${esc(m.user_id)}">${esc(t("projects.detail.team.remove") || "Entfernen")}</button>`
|
||||
: "";
|
||||
const officeLabel = m.user_office ? tDyn("office." + m.user_office) || m.user_office : "";
|
||||
const profCls = m.user_profession ? "projekt-team-profession" : "projekt-team-profession projekt-team-profession--none";
|
||||
return `<tr>
|
||||
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
|
||||
<span class="form-hint">· ${esc(m.user_email)}${officeLabel ? " · " + esc(officeLabel) : ""}</span></td>
|
||||
<td><span class="projekt-team-role">${esc(roleLabel)}</span></td>
|
||||
<td><span class="${profCls}" title="${escAttr(professionTitle)}">${esc(professionLabel)}</span></td>
|
||||
<td><span class="projekt-team-responsibility">${esc(responsibilityLabel)}</span></td>
|
||||
<td>${source}</td>
|
||||
<td>${removeBtn}</td>
|
||||
</tr>`;
|
||||
@@ -1717,13 +1738,14 @@ function renderDescendantStaffed() {
|
||||
section.style.display = "";
|
||||
body.innerHTML = descendantStaffed
|
||||
.map((m) => {
|
||||
const roleLabel = tDyn(`projects.team.role.${m.role}`) || m.role;
|
||||
const responsibility = m.responsibility || "member";
|
||||
const responsibilityLabel = tDyn(`projects.team.responsibility.${responsibility}`) || responsibility;
|
||||
const officeLabel = m.user_office ? tDyn("office." + m.user_office) || m.user_office : "";
|
||||
const sourceTitle = esc(m.inherited_from_title || "");
|
||||
return `<tr>
|
||||
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
|
||||
<span class="form-hint">· ${esc(m.user_email)}${officeLabel ? " · " + esc(officeLabel) : ""}</span></td>
|
||||
<td><span class="projekt-team-role">${esc(roleLabel)}</span></td>
|
||||
<td><span class="projekt-team-responsibility">${esc(responsibilityLabel)}</span></td>
|
||||
<td><span class="projekt-team-inherited" title="${escAttr(t("aggregation.attribution.on") || "auf")}: ${sourceTitle}">↓ ${sourceTitle}</span></td>
|
||||
</tr>`;
|
||||
})
|
||||
@@ -1835,7 +1857,7 @@ function canManagePartnerUnits(): boolean {
|
||||
if (me.global_role === "global_admin") return true;
|
||||
if (!project) return false;
|
||||
return teamMembers.some(
|
||||
(m) => m.user_id === me!.id && m.role === "lead" && m.project_id === project!.id,
|
||||
(m) => m.user_id === me!.id && m.responsibility === "lead" && m.project_id === project!.id,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1853,11 +1875,12 @@ function initTeamForm(id: string) {
|
||||
const hidden = document.getElementById("team-user-id") as HTMLInputElement | null;
|
||||
const sugs = document.getElementById("team-user-suggestions") as HTMLDivElement | null;
|
||||
const msg = document.getElementById("team-msg") as HTMLParagraphElement | null;
|
||||
const role = document.getElementById("team-role") as HTMLSelectElement | null;
|
||||
const responsibility = document.getElementById("team-responsibility") as HTMLSelectElement | null;
|
||||
const professionHint = document.getElementById("team-profession-hint") as HTMLParagraphElement | null;
|
||||
const inviteHint = document.getElementById("team-user-invite-hint") as HTMLDivElement | null;
|
||||
const inviteHintText = document.getElementById("team-user-invite-hint-text") as HTMLSpanElement | null;
|
||||
const inviteBtn = document.getElementById("team-user-invite-btn") as HTMLButtonElement | null;
|
||||
if (!addBtn || !form || !cancel || !input || !hidden || !sugs || !msg || !role) return;
|
||||
if (!addBtn || !form || !cancel || !input || !hidden || !sugs || !msg || !responsibility) return;
|
||||
|
||||
const hideInviteHint = () => {
|
||||
if (inviteHint) inviteHint.style.display = "none";
|
||||
@@ -1913,6 +1936,24 @@ function initTeamForm(id: string) {
|
||||
input.value = el.dataset.label!;
|
||||
sugs.innerHTML = "";
|
||||
hideInviteHint();
|
||||
// t-paliad-148: surface the picked person's profession so the
|
||||
// adder sees what firm tier they're staffing on this matter,
|
||||
// and gets a warning when the user has no profession set.
|
||||
if (professionHint) {
|
||||
const picked = userOptions.find((u) => u.id === hidden.value);
|
||||
const prof = picked?.profession;
|
||||
if (!prof) {
|
||||
professionHint.textContent = t("projects.detail.team.form.profession.none") ||
|
||||
"Keine Profession gesetzt — kann keine 4-Augen-Genehmigungen erteilen.";
|
||||
professionHint.className = "form-hint form-hint--warning";
|
||||
professionHint.style.display = "";
|
||||
} else {
|
||||
const profLabel = tDyn(`projects.team.profession.${prof}`) || prof;
|
||||
professionHint.textContent = `${t("projects.detail.team.form.profession.label") || "Profession"}: ${profLabel}`;
|
||||
professionHint.className = "form-hint";
|
||||
professionHint.style.display = "";
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1947,7 +1988,7 @@ function initTeamForm(id: string) {
|
||||
const resp = await fetch(`/api/projects/${id}/team`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_id: hidden.value, role: role.value }),
|
||||
body: JSON.stringify({ user_id: hidden.value, responsibility: responsibility.value }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const b = await resp.json().catch(() => ({ error: "unknown" }));
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -71,6 +71,24 @@ export function renderOnboarding(): string {
|
||||
<option value="Sekretariat"></option>
|
||||
</datalist>
|
||||
|
||||
<label htmlFor="onb-profession" className="login-label" data-i18n="onboarding.profession">Profession</label>
|
||||
<select
|
||||
id="onb-profession"
|
||||
name="profession"
|
||||
required
|
||||
className="login-input"
|
||||
>
|
||||
<option value="associate" selected data-i18n="projects.team.profession.associate">Associate</option>
|
||||
<option value="partner" data-i18n="projects.team.profession.partner">Partner</option>
|
||||
<option value="of_counsel" data-i18n="projects.team.profession.of_counsel">Of Counsel</option>
|
||||
<option value="senior_pa" data-i18n="projects.team.profession.senior_pa">Senior PA</option>
|
||||
<option value="pa" data-i18n="projects.team.profession.pa">PA</option>
|
||||
<option value="paralegal" data-i18n="projects.team.profession.paralegal">Paralegal</option>
|
||||
</select>
|
||||
<p className="login-hint" data-i18n="onboarding.profession.hint">
|
||||
Strukturiertes Tier — steuert die 4-Augen-Genehmigung. Distinkt von der Berufsbezeichnung.
|
||||
</p>
|
||||
|
||||
<label htmlFor="onb-partner-unit" className="login-label" data-i18n="onboarding.partner_unit">
|
||||
Partner Unit <span className="login-label-optional" data-i18n="onboarding.optional">(optional)</span>
|
||||
</label>
|
||||
|
||||
@@ -120,16 +120,14 @@ export function renderProjectsDetail(): string {
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="team-role" data-i18n="projects.detail.team.form.role">Rolle</label>
|
||||
<select id="team-role">
|
||||
<option value="lead" data-i18n="projects.team.role.lead">Lead</option>
|
||||
<option value="associate" selected data-i18n="projects.team.role.associate">Associate</option>
|
||||
<option value="pa" data-i18n="projects.team.role.pa">PA</option>
|
||||
<option value="of_counsel" data-i18n="projects.team.role.of_counsel">Of Counsel</option>
|
||||
<option value="local_counsel" data-i18n="projects.team.role.local_counsel">Local Counsel</option>
|
||||
<option value="expert" data-i18n="projects.team.role.expert">Experte</option>
|
||||
<option value="observer" data-i18n="projects.team.role.observer">Beobachter</option>
|
||||
<label htmlFor="team-responsibility" data-i18n="projects.detail.team.form.responsibility">Rolle im Projekt</label>
|
||||
<select id="team-responsibility">
|
||||
<option value="lead" data-i18n="projects.team.responsibility.lead">Lead</option>
|
||||
<option value="member" selected data-i18n="projects.team.responsibility.member">Mitglied</option>
|
||||
<option value="observer" data-i18n="projects.team.responsibility.observer">Beobachter</option>
|
||||
<option value="external" data-i18n="projects.team.responsibility.external">Extern</option>
|
||||
</select>
|
||||
<p id="team-profession-hint" className="form-hint" style="display:none" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
@@ -143,7 +141,8 @@ export function renderProjectsDetail(): string {
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="projects.detail.team.col.name">Name</th>
|
||||
<th data-i18n="projects.detail.team.col.role">Rolle</th>
|
||||
<th data-i18n="projects.detail.team.col.profession">Profession</th>
|
||||
<th data-i18n="projects.detail.team.col.responsibility">Rolle</th>
|
||||
<th data-i18n="projects.detail.team.col.source">Herkunft</th>
|
||||
<th />
|
||||
</tr>
|
||||
|
||||
@@ -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;
|
||||
|
||||
124
internal/db/migrations/059_profession_vs_responsibility.down.sql
Normal file
124
internal/db/migrations/059_profession_vs_responsibility.down.sql
Normal file
@@ -0,0 +1,124 @@
|
||||
-- Reverse of 057_profession_vs_responsibility.up.sql.
|
||||
--
|
||||
-- Best-effort rollback. The new columns are dropped; the legacy
|
||||
-- project_teams.role column is re-derived from (responsibility, profession).
|
||||
-- Down-migration loses information on edges:
|
||||
-- * external responsibility → role='local_counsel' (loses expert distinction)
|
||||
-- * member + profession=partner → role='of_counsel' (no legacy 'partner'
|
||||
-- existed in project_teams.role; closest legacy ceiling)
|
||||
-- * member + profession=paralegal → role='pa' (no legacy paralegal)
|
||||
-- * member + profession=NULL → role='associate' (safe default, matches
|
||||
-- the legacy RoleAssociate default)
|
||||
-- These edges are documented; if the down is run on real production data,
|
||||
-- review per-row before commit.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. Restore approval_role_level to point at legacy ladder values.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.approval_role_level(role text)
|
||||
RETURNS int LANGUAGE SQL IMMUTABLE AS $$
|
||||
SELECT CASE role
|
||||
WHEN 'lead' THEN 5
|
||||
WHEN 'of_counsel' THEN 4
|
||||
WHEN 'associate' THEN 3
|
||||
WHEN 'senior_pa' THEN 2
|
||||
WHEN 'pa' THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
$$;
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. Restore approval_role_from_unit_role lead → lead.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.approval_role_from_unit_role(unit_role text)
|
||||
RETURNS text LANGUAGE SQL IMMUTABLE AS $$
|
||||
SELECT CASE unit_role
|
||||
WHEN 'lead' THEN 'lead'
|
||||
WHEN 'attorney' THEN 'associate'
|
||||
WHEN 'senior_pa' THEN 'senior_pa'
|
||||
WHEN 'pa' THEN 'pa'
|
||||
ELSE 'observer'
|
||||
END
|
||||
$$;
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. Re-derive project_teams.role from (responsibility, profession).
|
||||
-- ============================================================================
|
||||
|
||||
UPDATE paliad.project_teams pt
|
||||
SET role = CASE
|
||||
WHEN pt.responsibility = 'lead' THEN 'lead'
|
||||
WHEN pt.responsibility = 'observer' THEN 'observer'
|
||||
WHEN pt.responsibility = 'external' THEN 'local_counsel'
|
||||
ELSE COALESCE(
|
||||
(SELECT CASE u.profession
|
||||
WHEN 'partner' THEN 'of_counsel' -- best-effort: no legacy 'partner' role
|
||||
WHEN 'of_counsel' THEN 'of_counsel'
|
||||
WHEN 'associate' THEN 'associate'
|
||||
WHEN 'senior_pa' THEN 'senior_pa'
|
||||
WHEN 'pa' THEN 'pa'
|
||||
WHEN 'paralegal' THEN 'pa' -- closest legacy fit
|
||||
END
|
||||
FROM paliad.users u WHERE u.id = pt.user_id),
|
||||
'associate'
|
||||
)
|
||||
END;
|
||||
|
||||
-- ============================================================================
|
||||
-- 4. Restore approval_policies + approval_requests CHECK constraints.
|
||||
-- ============================================================================
|
||||
|
||||
UPDATE paliad.approval_policies
|
||||
SET required_role = 'lead'
|
||||
WHERE required_role = 'partner';
|
||||
|
||||
ALTER TABLE paliad.approval_policies DROP CONSTRAINT IF EXISTS approval_policies_required_role_check;
|
||||
ALTER TABLE paliad.approval_policies ADD CONSTRAINT approval_policies_required_role_check
|
||||
CHECK (required_role IN ('lead', 'of_counsel', 'associate', 'senior_pa', 'pa'));
|
||||
|
||||
UPDATE paliad.approval_requests
|
||||
SET required_role = 'lead'
|
||||
WHERE required_role = 'partner';
|
||||
|
||||
ALTER TABLE paliad.approval_requests DROP CONSTRAINT IF EXISTS approval_requests_required_role_check;
|
||||
ALTER TABLE paliad.approval_requests ADD CONSTRAINT approval_requests_required_role_check
|
||||
CHECK (required_role IN ('lead', 'of_counsel', 'associate', 'senior_pa', 'pa'));
|
||||
|
||||
-- ============================================================================
|
||||
-- 5. Restore project_partner_units RLS to read pt.role = 'lead'.
|
||||
-- ============================================================================
|
||||
|
||||
DROP POLICY IF EXISTS project_partner_units_write ON paliad.project_partner_units;
|
||||
|
||||
CREATE POLICY project_partner_units_write
|
||||
ON paliad.project_partner_units FOR ALL
|
||||
USING (
|
||||
EXISTS (SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
|
||||
OR EXISTS (SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = auth.uid()
|
||||
AND pt.project_id = project_partner_units.project_id
|
||||
AND pt.role = 'lead')
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
|
||||
OR EXISTS (SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = auth.uid()
|
||||
AND pt.project_id = project_partner_units.project_id
|
||||
AND pt.role = 'lead')
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 6. Drop the new function and columns.
|
||||
-- ============================================================================
|
||||
|
||||
DROP FUNCTION IF EXISTS paliad.user_project_authority_level(uuid, uuid);
|
||||
|
||||
DROP INDEX IF EXISTS paliad.project_teams_responsibility_idx;
|
||||
ALTER TABLE paliad.project_teams DROP COLUMN IF EXISTS responsibility;
|
||||
|
||||
DROP INDEX IF EXISTS paliad.users_profession_idx;
|
||||
ALTER TABLE paliad.users DROP COLUMN IF EXISTS profession;
|
||||
339
internal/db/migrations/059_profession_vs_responsibility.up.sql
Normal file
339
internal/db/migrations/059_profession_vs_responsibility.up.sql
Normal file
@@ -0,0 +1,339 @@
|
||||
-- t-paliad-148: split paliad.project_teams.role into firm-level profession
|
||||
-- and project-level responsibility.
|
||||
--
|
||||
-- Design: docs/design-profession-vs-project-role-2026-05-07.md (kepler,
|
||||
-- m-locked 2026-05-07 21:35).
|
||||
--
|
||||
-- The legacy column did two jobs at once:
|
||||
-- - career tier at the firm (PA, Associate, Of Counsel, …)
|
||||
-- - responsibility on this matter (Lead, Member, Observer)
|
||||
-- This migration introduces two clean axes and backfills both from the
|
||||
-- legacy column. The legacy column is kept as a deprecated shadow for one
|
||||
-- release; a follow-up migration drops it after Go code has fully
|
||||
-- migrated and the production data is verified clean.
|
||||
--
|
||||
-- Day-1 deploy = zero behaviour change because the new code paths read
|
||||
-- the new columns and the backfill is run inside this migration.
|
||||
--
|
||||
-- Sections:
|
||||
-- 1. ALTER paliad.users ADD COLUMN profession.
|
||||
-- 2. ALTER paliad.project_teams ADD COLUMN responsibility.
|
||||
-- 3. Backfill profession from highest legacy project_teams.role per user.
|
||||
-- 4. Backfill responsibility from legacy project_teams.role.
|
||||
-- 5. UPDATE paliad.approval_policies.required_role CHECK + 'lead' → 'partner'.
|
||||
-- 6. UPDATE paliad.approval_requests.required_role CHECK + 'lead' → 'partner'.
|
||||
-- 7. UPDATE paliad.approval_role_from_unit_role: lead → partner.
|
||||
-- 8. CREATE paliad.user_project_authority_level — tuple-with-gate ladder.
|
||||
-- 9. CASCADE-rebuild paliad.project_partner_units RLS policies that
|
||||
-- reference pt.role = 'lead'.
|
||||
-- 10. UPDATE COMMENT on paliad.approval_role_level pointing at users.profession.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. paliad.users.profession — firm-wide career tier.
|
||||
--
|
||||
-- NULL means "no firm tier" (external local counsel, expert, admin
|
||||
-- accounts that aren't practicing lawyers). NULL → ladder level 0 →
|
||||
-- ineligible to approve. Required-on-invite for firm members; admin
|
||||
-- editable on /admin/team.
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
ADD COLUMN profession text NULL
|
||||
CHECK (profession IS NULL OR profession IN (
|
||||
'partner', 'of_counsel', 'associate',
|
||||
'senior_pa', 'pa', 'paralegal'
|
||||
));
|
||||
|
||||
CREATE INDEX users_profession_idx ON paliad.users (profession);
|
||||
|
||||
COMMENT ON COLUMN paliad.users.profession IS
|
||||
'Firm-wide career tier driving the t-paliad-138 approval ladder. '
|
||||
'NULL = no firm tier (external collaborators, admin accounts). '
|
||||
'Distinct from job_title (free-text display) and global_role (tool admin gate).';
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. paliad.project_teams.responsibility — per-project responsibility.
|
||||
--
|
||||
-- Replaces the project-axis values that were mixed into project_teams.role.
|
||||
-- Default 'member'. 'lead' has additional manage-project privileges (already
|
||||
-- wired in derivation_service.go). 'observer' and 'external' close the
|
||||
-- approval gate (level 0 regardless of profession).
|
||||
--
|
||||
-- The legacy `role` column is kept on the table as a deprecated shadow for
|
||||
-- one release. New code reads .responsibility; old code paths that still
|
||||
-- read .role continue working until the follow-up migration drops the
|
||||
-- column.
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.project_teams
|
||||
ADD COLUMN responsibility text NOT NULL DEFAULT 'member'
|
||||
CHECK (responsibility IN ('lead', 'member', 'observer', 'external'));
|
||||
|
||||
CREATE INDEX project_teams_responsibility_idx
|
||||
ON paliad.project_teams (project_id, responsibility);
|
||||
|
||||
COMMENT ON COLUMN paliad.project_teams.responsibility IS
|
||||
'Per-project responsibility on this matter. lead/member open the '
|
||||
'approval gate; observer/external close it. Profession provides the '
|
||||
'level (paliad.users.profession).';
|
||||
|
||||
COMMENT ON COLUMN paliad.project_teams.role IS
|
||||
'DEPRECATED — split into users.profession + project_teams.responsibility '
|
||||
'in migration 057 (t-paliad-148). Kept as a shadow column for one release. '
|
||||
'Drop in follow-up migration 058.';
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. Backfill paliad.users.profession from highest legacy tier per user.
|
||||
--
|
||||
-- Mapping (legacy role → profession):
|
||||
-- lead → partner
|
||||
-- of_counsel → of_counsel
|
||||
-- associate → associate
|
||||
-- senior_pa → senior_pa
|
||||
-- pa → pa
|
||||
-- local_counsel/expert/observer → IGNORED (no firm tier inferable)
|
||||
--
|
||||
-- For each user with at least one project_teams row carrying a firm-tier
|
||||
-- value, take the HIGHEST tier (per the t-138 ladder). Ties at same tier
|
||||
-- collapse trivially (same value). Users with only project-only labels
|
||||
-- (observer / local_counsel / expert) get profession=NULL — admin will
|
||||
-- need to fill them in via /admin/team.
|
||||
-- ============================================================================
|
||||
|
||||
WITH legacy_to_profession AS (
|
||||
SELECT pt.user_id,
|
||||
CASE pt.role
|
||||
WHEN 'lead' THEN 'partner'
|
||||
WHEN 'of_counsel' THEN 'of_counsel'
|
||||
WHEN 'associate' THEN 'associate'
|
||||
WHEN 'senior_pa' THEN 'senior_pa'
|
||||
WHEN 'pa' THEN 'pa'
|
||||
-- observer / local_counsel / expert → NULL (filtered below)
|
||||
END AS profession,
|
||||
CASE pt.role
|
||||
WHEN 'lead' THEN 5
|
||||
WHEN 'of_counsel' THEN 4
|
||||
WHEN 'associate' THEN 3
|
||||
WHEN 'senior_pa' THEN 2
|
||||
WHEN 'pa' THEN 1
|
||||
ELSE 0
|
||||
END AS lvl
|
||||
FROM paliad.project_teams pt
|
||||
),
|
||||
ranked AS (
|
||||
SELECT user_id, profession,
|
||||
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY lvl DESC) AS rn
|
||||
FROM legacy_to_profession
|
||||
WHERE profession IS NOT NULL
|
||||
)
|
||||
UPDATE paliad.users u
|
||||
SET profession = r.profession
|
||||
FROM ranked r
|
||||
WHERE u.id = r.user_id
|
||||
AND r.rn = 1
|
||||
AND u.profession IS NULL;
|
||||
|
||||
-- ============================================================================
|
||||
-- 4. Backfill paliad.project_teams.responsibility from legacy role.
|
||||
--
|
||||
-- Per-row mapping:
|
||||
-- lead → lead
|
||||
-- observer → observer
|
||||
-- local_counsel → external
|
||||
-- expert → external
|
||||
-- associate / pa / of_counsel / senior_pa → member
|
||||
--
|
||||
-- Authority for "member" rows now comes from the user's profession.
|
||||
-- ============================================================================
|
||||
|
||||
UPDATE paliad.project_teams
|
||||
SET responsibility = CASE role
|
||||
WHEN 'lead' THEN 'lead'
|
||||
WHEN 'observer' THEN 'observer'
|
||||
WHEN 'local_counsel' THEN 'external'
|
||||
WHEN 'expert' THEN 'external'
|
||||
ELSE 'member'
|
||||
END;
|
||||
|
||||
-- ============================================================================
|
||||
-- 5. paliad.approval_policies.required_role — drop 'lead', add 'partner'.
|
||||
--
|
||||
-- Legacy 'lead' was the project-level value at the ladder ceiling; under the
|
||||
-- new model the ceiling is profession='partner'. Backfill any existing
|
||||
-- policy rows from 'lead' to 'partner', then tighten the CHECK.
|
||||
-- ============================================================================
|
||||
|
||||
UPDATE paliad.approval_policies
|
||||
SET required_role = 'partner'
|
||||
WHERE required_role = 'lead';
|
||||
|
||||
ALTER TABLE paliad.approval_policies DROP CONSTRAINT IF EXISTS approval_policies_required_role_check;
|
||||
ALTER TABLE paliad.approval_policies ADD CONSTRAINT approval_policies_required_role_check
|
||||
CHECK (required_role IN ('partner', 'of_counsel', 'associate', 'senior_pa', 'pa'));
|
||||
|
||||
-- ============================================================================
|
||||
-- 6. paliad.approval_requests.required_role — same rename for snapshots.
|
||||
--
|
||||
-- Each request snapshots the policy's required_role at submission time so
|
||||
-- mid-flight policy edits don't change the bar. Backfill 'lead' → 'partner'
|
||||
-- for parity with the new policy enum, then tighten the CHECK.
|
||||
-- ============================================================================
|
||||
|
||||
UPDATE paliad.approval_requests
|
||||
SET required_role = 'partner'
|
||||
WHERE required_role = 'lead';
|
||||
|
||||
ALTER TABLE paliad.approval_requests DROP CONSTRAINT IF EXISTS approval_requests_required_role_check;
|
||||
ALTER TABLE paliad.approval_requests ADD CONSTRAINT approval_requests_required_role_check
|
||||
CHECK (required_role IN ('partner', 'of_counsel', 'associate', 'senior_pa', 'pa'));
|
||||
|
||||
-- ============================================================================
|
||||
-- 7. paliad.approval_role_from_unit_role — bridge maps lead → partner now.
|
||||
--
|
||||
-- Derived authority via partner-unit attachments (t-paliad-139) bridges
|
||||
-- unit_role to the project-tier ladder. Under the new ladder, the highest
|
||||
-- tier is 'partner' (was 'lead'). Update the lead → lead row to lead →
|
||||
-- partner; the rest of the bridge mapping stays unchanged.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.approval_role_from_unit_role(unit_role text)
|
||||
RETURNS text LANGUAGE SQL IMMUTABLE AS $$
|
||||
SELECT CASE unit_role
|
||||
WHEN 'lead' THEN 'partner'
|
||||
WHEN 'attorney' THEN 'associate'
|
||||
WHEN 'senior_pa' THEN 'senior_pa'
|
||||
WHEN 'pa' THEN 'pa'
|
||||
ELSE 'observer'
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Update the level helper too: 'partner' replaces 'lead' as the ceiling
|
||||
-- value that the function recognises. The numeric ladder is identical;
|
||||
-- only the named tier shifts.
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.approval_role_level(role text)
|
||||
RETURNS int LANGUAGE SQL IMMUTABLE AS $$
|
||||
SELECT CASE role
|
||||
WHEN 'partner' THEN 5
|
||||
WHEN 'of_counsel' THEN 4
|
||||
WHEN 'associate' THEN 3
|
||||
WHEN 'senior_pa' THEN 2
|
||||
WHEN 'pa' THEN 1
|
||||
WHEN 'paralegal' THEN 0
|
||||
-- Legacy 'lead' kept at level 5 for the deprecated-shadow window:
|
||||
-- old call sites that still read pt.role would otherwise return
|
||||
-- level 0 and break authority for projects where the migration
|
||||
-- has run but the Go redirect hasn't. Removed in migration 058.
|
||||
WHEN 'lead' THEN 5
|
||||
ELSE 0
|
||||
END
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.approval_role_level(text) IS
|
||||
'Strict-ladder level for the t-paliad-138 / t-paliad-148 approval gate. '
|
||||
'Reads paliad.users.profession; legacy project_teams.role values still '
|
||||
'recognised via the lead→5 shadow row until migration 058 retires the '
|
||||
'column. Higher level always satisfies lower; level 0 = ineligible.';
|
||||
|
||||
-- ============================================================================
|
||||
-- 8. paliad.user_project_authority_level — tuple-with-gate ladder.
|
||||
--
|
||||
-- effective_level for user U on project P:
|
||||
--
|
||||
-- profession_level = approval_role_level(U.profession) -- 0 if NULL
|
||||
-- responsibility = direct or ancestor on project P
|
||||
-- gate_open = responsibility IN ('lead', 'member')
|
||||
-- derived_role = approval_role_from_unit_role(unit_role)
|
||||
-- when project_partner_units.derive_grants_authority
|
||||
-- effective_level = MAX over sources, gated as above
|
||||
--
|
||||
-- Direct/ancestor responsibility opens the gate, profession provides the
|
||||
-- level. Derivation is its own source — derived authority always opens
|
||||
-- its own gate (the unit attachment's grants_authority flag is the gate).
|
||||
-- A user can hit this function via direct membership AND derivation; the
|
||||
-- result is the max of both sources.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.user_project_authority_level(
|
||||
_user_id uuid,
|
||||
_project_id uuid
|
||||
) RETURNS int
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $$
|
||||
WITH path AS (
|
||||
SELECT string_to_array(p.path, '.')::uuid[] AS ids
|
||||
FROM paliad.projects p WHERE p.id = _project_id
|
||||
),
|
||||
direct_or_ancestor AS (
|
||||
SELECT pt.responsibility
|
||||
FROM paliad.project_teams pt
|
||||
JOIN path ON pt.project_id = ANY(path.ids)
|
||||
WHERE pt.user_id = _user_id
|
||||
),
|
||||
profession_level AS (
|
||||
SELECT paliad.approval_role_level(u.profession) AS lvl
|
||||
FROM paliad.users u WHERE u.id = _user_id
|
||||
),
|
||||
direct_level AS (
|
||||
-- Profession-level if any membership row opens the gate, else 0.
|
||||
SELECT CASE
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM direct_or_ancestor doa
|
||||
WHERE doa.responsibility IN ('lead', 'member')
|
||||
) THEN COALESCE((SELECT lvl FROM profession_level), 0)
|
||||
ELSE 0
|
||||
END AS lvl
|
||||
),
|
||||
derived_level AS (
|
||||
SELECT COALESCE(MAX(paliad.approval_role_level(
|
||||
paliad.approval_role_from_unit_role(pum.unit_role)
|
||||
)), 0) AS lvl
|
||||
FROM paliad.project_partner_units ppu
|
||||
JOIN paliad.partner_unit_members pum
|
||||
ON pum.partner_unit_id = ppu.partner_unit_id
|
||||
AND pum.user_id = _user_id
|
||||
AND pum.unit_role = ANY(ppu.derive_unit_roles)
|
||||
JOIN path ON ppu.project_id = ANY(path.ids)
|
||||
WHERE ppu.derive_grants_authority = true
|
||||
)
|
||||
SELECT GREATEST(
|
||||
(SELECT lvl FROM direct_level),
|
||||
(SELECT lvl FROM derived_level)
|
||||
);
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.user_project_authority_level(uuid, uuid) IS
|
||||
'Effective approval-ladder level for user U on project P, evaluated as '
|
||||
'a tuple-with-gate: profession_level if responsibility ∈ {lead,member} '
|
||||
'else 0; max with derived authority (partner-unit attachment with '
|
||||
'grants_authority=true). t-paliad-148.';
|
||||
|
||||
-- ============================================================================
|
||||
-- 9. paliad.project_partner_units RLS — switch lead-gate to .responsibility.
|
||||
--
|
||||
-- Migration 055 wrote two policies that gate writes on pt.role = 'lead'.
|
||||
-- Under the new model, lead is a project responsibility, not a profession.
|
||||
-- Drop and rewrite both policies to read .responsibility.
|
||||
-- ============================================================================
|
||||
|
||||
DROP POLICY IF EXISTS project_partner_units_write ON paliad.project_partner_units;
|
||||
|
||||
CREATE POLICY project_partner_units_write
|
||||
ON paliad.project_partner_units FOR ALL
|
||||
USING (
|
||||
EXISTS (SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
|
||||
OR EXISTS (SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = auth.uid()
|
||||
AND pt.project_id = project_partner_units.project_id
|
||||
AND pt.responsibility = 'lead')
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
|
||||
OR EXISTS (SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = auth.uid()
|
||||
AND pt.project_id = project_partner_units.project_id
|
||||
AND pt.responsibility = 'lead')
|
||||
);
|
||||
@@ -33,7 +33,11 @@ func handleListProjectTeam(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// POST /api/projects/{id}/team — add a direct member.
|
||||
// Body: {"user_id": "<uuid>", "role": "<role>"}
|
||||
// Body: {"user_id": "<uuid>", "responsibility": "<lead|member|observer|external>"}
|
||||
//
|
||||
// Legacy clients that submit `role` are still accepted as a synonym
|
||||
// during the deprecation window; the field is treated as a
|
||||
// responsibility value when the new field is absent.
|
||||
func handleAddProjectTeamMember(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
@@ -48,14 +52,20 @@ func handleAddProjectTeamMember(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Responsibility string `json:"responsibility"`
|
||||
// Legacy field, accepted for one release while frontend migrates.
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
m, err := dbSvc.team.AddMember(r.Context(), uid, projectID, body.UserID, body.Role)
|
||||
resp := body.Responsibility
|
||||
if resp == "" {
|
||||
resp = body.Role
|
||||
}
|
||||
m, err := dbSvc.team.AddMember(r.Context(), uid, projectID, body.UserID, resp)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
|
||||
@@ -27,10 +27,18 @@ type User struct {
|
||||
// "Counsel Knowledge Lawyer", …). NULL is allowed for users who never
|
||||
// picked a title — typically global admins promoted via SQL.
|
||||
JobTitle *string `db:"job_title" json:"job_title"`
|
||||
// Profession is the structured firm-tier enum that drives the
|
||||
// t-paliad-138 / t-paliad-148 approval ladder (partner / of_counsel /
|
||||
// associate / senior_pa / pa / paralegal). NULL means "no firm tier"
|
||||
// — external collaborators (local counsel, expert) and admin
|
||||
// accounts that aren't practicing lawyers. NULL → ladder level 0,
|
||||
// ineligible to approve. Distinct from JobTitle (display) and
|
||||
// GlobalRole (tool admin gate). Added by migration 057.
|
||||
Profession *string `db:"profession" json:"profession,omitempty"`
|
||||
// GlobalRole is the global-permissions enum: 'standard' | 'global_admin'.
|
||||
// Drives every permission gate that used to look at the legacy
|
||||
// role='admin'. Per-project authority is on paliad.project_teams.role and
|
||||
// is unrelated.
|
||||
// role='admin'. Per-project authority is on paliad.project_teams and
|
||||
// users.profession; this column is the tool-admin axis, unrelated.
|
||||
GlobalRole string `db:"global_role" json:"global_role"`
|
||||
Lang string `db:"lang" json:"lang"`
|
||||
EmailPreferences json.RawMessage `db:"email_preferences" json:"email_preferences"`
|
||||
@@ -102,10 +110,19 @@ type Project struct {
|
||||
// only. Inherited memberships are computed at read time by walking the path;
|
||||
// services set Inherited=true on the in-memory copy when annotating a list
|
||||
// result that mixes direct + inherited rows.
|
||||
//
|
||||
// t-paliad-148 split: Responsibility is the per-project role (lead /
|
||||
// member / observer / external). The legacy Role field is kept as a
|
||||
// deprecated read-only shadow until follow-up migration 058 drops the
|
||||
// underlying column.
|
||||
type ProjectTeamMember struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
Responsibility string `db:"responsibility" json:"responsibility"`
|
||||
// Role: deprecated shadow column. Reader populates it for backwards-
|
||||
// compatibility with any consumer still reading `role`; new code
|
||||
// should read .Responsibility instead.
|
||||
Role string `db:"role" json:"role"`
|
||||
Inherited bool `db:"inherited" json:"inherited"`
|
||||
AddedBy *uuid.UUID `db:"added_by" json:"added_by,omitempty"`
|
||||
@@ -113,13 +130,19 @@ type ProjectTeamMember struct {
|
||||
}
|
||||
|
||||
// ProjectTeamMemberWithUser enriches a team row with display fields so the
|
||||
// UI can render "<DisplayName> (<Email>) — <Role>" without a per-row lookup.
|
||||
// Used by TeamService.ListMembers which unions direct + inherited memberships.
|
||||
// UI can render "<DisplayName> (<Email>) — <Responsibility>" without a
|
||||
// per-row lookup. Used by TeamService.ListMembers which unions direct +
|
||||
// inherited memberships.
|
||||
//
|
||||
// UserProfession reflects paliad.users.profession at read time — the
|
||||
// firm-tier badge shown next to the responsibility column on
|
||||
// /projects/{id} (t-paliad-148 §6).
|
||||
type ProjectTeamMemberWithUser struct {
|
||||
ProjectTeamMember
|
||||
UserEmail string `db:"user_email" json:"user_email"`
|
||||
UserDisplayName string `db:"user_display_name" json:"user_display_name"`
|
||||
UserOffice string `db:"user_office" json:"user_office"`
|
||||
UserEmail string `db:"user_email" json:"user_email"`
|
||||
UserDisplayName string `db:"user_display_name" json:"user_display_name"`
|
||||
UserOffice string `db:"user_office" json:"user_office"`
|
||||
UserProfession *string `db:"user_profession" json:"user_profession,omitempty"`
|
||||
// InheritedFromID is the ancestor project_id the membership came from
|
||||
// when Inherited=true. NULL for direct rows.
|
||||
InheritedFromID *uuid.UUID `db:"inherited_from_id" json:"inherited_from_id,omitempty"`
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" {
|
||||
|
||||
Reference in New Issue
Block a user