Compare commits

..

33 Commits

Author SHA1 Message Date
m
bfc48b1420 fix(t-paliad-143): derived team members all show 'Attorney' + Herkunft collapses multi-unit users
Two related bugs on /projects/{id} Team tab → "Abgeleitet (Partner Unit)":

1. **All derived members labeled 'Attorney'.** Migration 055 added
   partner_unit_members.unit_role with DEFAULT 'attorney' but never exposed
   the column in the admin UI. So 100% of pum rows are 'attorney' and
   Siemens AG's derive_unit_roles=['pa','senior_pa','attorney'] config
   surfaces every member as 'attorney' even when they're really PAs.

2. **Multi-unit users collapsed to one source.** ListDerivedMembers used
   ROW_NUMBER() OVER (PARTITION BY user_id) WHERE rn=1 — closest-attachment
   wins, every other unit-membership dropped. Judith Molarinho Vaz +
   Sabrina Franken belong to BOTH Lehment AND Plassmann; UI showed only one.

**Backend** (internal/services/derivation_service.go):
- DerivedMember.Memberships []DerivedMembership replaces scalar
  UnitID/UnitName/UnitRole. DeriveGrantsAuthority becomes bool_or across
  all source attachments (any granting → true).
- ListDerivedMembers SQL: jsonb_agg(DISTINCT jsonb_build_object(...)) +
  bool_or(derive_grants_authority), GROUP BY user. One row per user, every
  (unit, role) pair preserved. Memberships sorted by unit_name in Go (PG
  doesn't allow ORDER BY inside DISTINCT-aggregated jsonb_agg).
- DerivedMembershipList implements sql.Scanner so the jsonb column maps
  directly into the Go struct. Pinned by unit test.

**Frontend** (projects-detail.ts):
- DerivedMember interface mirrors the new shape. Herkunft renders every
  (unit, role) source — single-unit users render as before
  ("über: **Lehment** [Sicht]"); multi-unit users render
  "über: **Lehment** (Attorney), **Plassmann** (PA) [Sicht & 4-Augen]".
- Role column shows distinct unit_role values.

**Frontend** (admin-partner-units.ts):
- Member modal gains a per-row <select> with the 5 unit_role options. On
  change, PATCH /api/partner-units/{id}/members/{user_id}/role (endpoint
  already shipped in t-paliad-139 Phase 2). Disables during request,
  rolls back the prior selection on failure.
- 2 new i18n keys (DE + EN): admin.partner_units.member.role,
  admin.partner_units.feedback.role_updated.
- New CSS for .partner-unit-member-item flex layout + .pu-role-select.

**Out of scope** (per design): semantics of derive_unit_roles, new
unit_role values beyond the 5-row CHECK, the bigger profession-vs-project-
role redesign (#6).

**Verification**:
- Live SQL dry-run on Siemens AG (61e3fb9e-29fb-44aa-867e-a89469e2cacb)
  returns Judith + Sabrina each with [{Lehment,attorney},{Plassmann,attorney}]
  and derive_grants_authority=true (Plassmann grants authority).
- DerivedMembershipList.Scan unit-tested for nil / single / multi /
  unsupported-type cases.
- Go build + tests pass; frontend build clean (1608 i18n keys).

After merge, m can verify on prod: /admin/partner-units → Plassmann →
set Judith to 'pa' → reload Siemens AG Team tab → Judith shows as 'PA'
with Herkunft "über: **Lehment** (Attorney), **Plassmann** (PA)".
2026-05-06 17:16:17 +02:00
m
5cb7f76160 Merge: t-paliad-142 — sidebar no longer slide-in animates on every page nav when pinned (parallel :root.sidebar-pinned CSS selectors) 2026-05-06 16:56:14 +02:00
m
8b76d0c8fa fix(t-paliad-142): sidebar slide-in on every page nav when pinned
The FOUC script in PWAHead.tsx sets `<html class="sidebar-pinned">`
pre-paint, which kept body padding correct from frame 1 — but the
.sidebar element's own width keyed off `.sidebar.pinned` (set by
initSidebar in DOMContentLoaded). That made every navigation paint
the rail at collapsed width, then animate width 150ms → pinned width
once JS ran. Visible slide-in on Dashboard / Agenda / Projekte / etc.

Fix: extend every `.sidebar.pinned` rule with a parallel
`:root.sidebar-pinned .sidebar ...` selector so the html-class set
pre-paint is sufficient to render the full pinned visual state from
frame 1 (width, label opacity, pin/resize/badge visibility, search
input). Runtime initSidebar still mirrors `.pinned` onto the element
itself for explicit pin/unpin click animation. Same dual-selector
pattern already used by `.has-sidebar.sidebar-pinned` /
`:root.sidebar-pinned .has-sidebar` for body padding.

Mobile unaffected — FOUC script only sets html.sidebar-pinned when
window.innerWidth >= 1024, and initSidebar clears it on resize.
2026-05-06 16:55:30 +02:00
m
9cd05e7c59 Merge: fix team-add suggestions dropdown floating off-screen (parent needed position:relative) 2026-05-06 16:54:22 +02:00
m
5598aef074 fix(t-paliad-141): collab-suggestions dropdown floats off-screen — parent .form-field had no position. Scope :has() rule sets parent to position:relative only where the dropdown actually exists 2026-05-06 16:54:22 +02:00
m
16fe5763f3 Merge: fix /inbox sidebar — call initSidebar() so pin restoration + hover work like every other page 2026-05-06 16:50:21 +02:00
m
18faf81f58 fix(t-paliad-138): /inbox missing initSidebar() call — sidebar JS never ran on inbox page (pin restoration, hover-expand, badge polling all dead). One-liner add to inbox.ts 2026-05-06 16:50:21 +02:00
m
aeaba66892 Merge: t-paliad-139 Phase 2+3 — partner-unit derivation schema (migration 055) + DerivationService + Team-tab subsections + derived_peer approval-authority extension 2026-05-06 16:47:36 +02:00
m
a61c1490e3 feat(t-paliad-139): Phase 3 — derived_peer authority extension to t-138 approval gate
Wires DerivationService.EffectiveProjectRole into the t-paliad-138
approval ladder so partner-unit-derived members with derive_grants_authority=true
can act as approvers (per design §4.2). When they sign off, the audit row
records decision_kind='derived_peer' — a third value alongside the existing
'peer' and 'admin_override' — so the chronology discloses the derivation
chain.

Schema (migration 055 update)
-----------------------------
  - paliad.approval_requests.decision_kind CHECK extended to accept
    'derived_peer'. Down migration restores the t-138 two-value CHECK.
    Live SQL dry-run confirmed the new value is accepted.

Service layer
-------------
  - approval_levels.go: new constant DecisionKindDerivedPeer.
  - approval_service.go (4 sites widened with the derivation EXISTS branch):
      1. canApprove — third resolution step after global_admin + direct/
         ancestor team membership: matches partner-unit-derived members
         on path with derive_grants_authority=true and a unit_role whose
         approval_role_from_unit_role mapping meets the threshold.
         Returns DecisionKindDerivedPeer when this branch is the one that
         passed.
      2. hasQualifiedApprover (the deadlock-check at submit time) —
         widened so a project with no direct approvers but an authority-
         granting unit attachment is still submittable.
      3. ListPendingForApprover (the /inbox query) — third UNION ALL
         branch so derived authority sees their queue.
      4. PendingCountForUser (the bell-badge query) — same widening so
         derived authority sees the count tick.
  All four queries reuse paliad.approval_role_from_unit_role(text) added
  by Phase 2 of migration 055.

Frontend
--------
  - 2 i18n keys (DE+EN): approvals.decision_kind.derived_peer →
    "Genehmigt durch abgeleitetes Mitglied (Partner Unit)" / "Approved by
    derived member (Partner Unit)". Verlauf rendering of the third
    decision_kind value works through the existing translateEvent /
    decision_kind switch with no other change. 1606 keys total.

Strict-default unchanged
------------------------
Derived members are visibility-only by default. Authority requires the
project lead/admin to explicitly flip derive_grants_authority=true on the
project_partner_units row (UI on /projects/{id} Team tab, Phase 2). This
preserves the m-locked Q12 stance.

Phase 3 closes the t-paliad-139 implementation. m's bug closes (Phase 1),
the derivation schema is in place (Phase 2), and approval authority
flows through the new ladder (Phase 3).
2026-05-06 16:45:19 +02:00
m
544bb63684 feat(t-paliad-139): Phase 2 — partner-unit derivation schema + Team-tab subsections
Migration 055 adds the structural pieces the issue's PA-derivation premise
needed (the design-§1.3 verify-before-trust check found all three were
missing today):

  - paliad.partner_unit_members.unit_role text DEFAULT 'attorney'
    CHECK ('lead'|'attorney'|'senior_pa'|'pa'|'paralegal') — per-unit role
    distinction so derivation can target specific tiers without re-
    introducing a firm-wide rank column. The same human can be 'attorney'
    in one unit and 'lead' in another.
  - paliad.project_partner_units junction (project_id, partner_unit_id,
    derive_unit_roles[] DEFAULT {pa,senior_pa}, derive_grants_authority bool
    DEFAULT false, attached_at, attached_by) with composite PK and RLS
    (read = can_see_project; write = global_admin OR project lead).
  - paliad.approval_role_from_unit_role(text) helper used by Phase 3 when
    derived authority is consulted by the t-138 ladder.
  - paliad.can_see_project extended with one EXISTS branch — derivation
    walks the path: a user is visible on P if any (ancestor of P) is
    attached to a unit they are a member of with a matching unit_role.

No RAISE EXCEPTION (Maria's build constraint). Day-1 deploy = zero
behaviour change because every existing unit member defaults to
unit_role='attorney' and the default derive_unit_roles is {pa,senior_pa},
so until both diverge no derivation happens.

Backend services
----------------
  - DerivationService (new, internal/services/derivation_service.go):
      AttachUnitToProject, DetachUnitFromProject, ListAttachedUnits,
      ListDerivedMembers (path-walking dedupe by closest attachment),
      ListDescendantStaffed (descendant-direct rows excluding ancestor-
      already-staffed), EffectiveProjectRole (returns role + source ∈
      {direct, ancestor, derived} for the t-138 approval gate in Phase 3).
  - PartnerUnitService extensions:
      PartnerUnitMemberDetail gains UnitRole (db:"unit_role"). Constants
      UnitRoleLead/Attorney/SeniorPA/PA/Paralegal + isValidUnitRole.
      SetMemberRole(callerID, unitID, userID, role) with admin gate, prior-
      role read in tx, audit emit 'member_role_changed'. ListMembers and
      ListWithMembers SELECT projection now includes pum.unit_role.

Handlers
--------
  - GET /api/projects/{id}/partner-units              → ListAttachedUnits
  - POST /api/projects/{id}/partner-units             → AttachUnitToProject
  - DELETE /api/projects/{id}/partner-units/{unit_id} → DetachUnitFromProject
  - GET /api/projects/{id}/team/derived               → ListDerivedMembers
  - GET /api/projects/{id}/team/from-descendants      → ListDescendantStaffed
  - PATCH /api/partner-units/{id}/members/{user_id}/role → SetMemberRole
  - Services bundle gains Derivation; cmd/server/main.go wires it.

Frontend (Team-tab on /projects/{id})
-------------------------------------
Three new subsections rendered after the existing direct+ancestor table:
  - "Aus Unterprojekten" — descendant-direct rows with attribution arrow.
  - "Abgeleitet (Partner Unit)" — derived rows with [Sicht] / [Sicht & 4-
    Augen] badge per the m-locked honesty rule (§3.5).
  - "Partner Units" — attached-unit list with attach/detach controls
    (lead/admin only) and a form picker for derive_unit_roles +
    derive_grants_authority.
Each subsection is hidden when its data is empty (Partner Units block
also surfaces for managers when empty so they can attach).

Loaders + state in projects-detail.ts; renderTeam orchestrates all
four subsections; renderAttachedUnits owns the unit list + detach
handlers; initAttachUnitForm wires the picker + checkbox role-set.
canManagePartnerUnits gates the attach UI on global_admin OR direct
'lead' on the current project.

i18n keys (DE+EN, ~30 new) under projects.team.section.*,
projects.team.derived.*, projects.team.units.*, unit_role.*. Codegen now
emits 1605 keys (was 1494).

CSS additions: .entity-section-heading (subsection h3),
.derived-badge / .derived-badge--authority, .form-checkbox.

Phase 3 (approval extension to honour derived_peer decision_kind) stacks
on top — gates on EffectiveProjectRole returning ('role','derived') being
wired into the t-138 canApprove + inbox SQL.
2026-05-06 16:41:41 +02:00
m
2d06cdf20e Merge: t-paliad-139 Phase 1 — /projects/{id} aggregation bug fix (use projectDescendantPredicate on 3 legacy narrow methods + frontend toggle + attribution chip) 2026-05-06 16:29:14 +02:00
m
f8d8ea591d Merge remote-tracking branch 'origin/main' into mai/noether/inventor-project 2026-05-06 16:26:46 +02:00
m
77d664c5cc Merge: fix inbox sidebar collapse — drop duplicate app.js include 2026-05-06 16:24:36 +02:00
m
8cf95761d0 fix(t-paliad-138): drop double-include of /assets/app.js on /inbox — PWAHead already injects it; the duplicate ran sidebar.ts twice and collapsed the sidebar on navigation 2026-05-06 16:24:36 +02:00
m
d41fc49809 feat(t-paliad-139): Phase 1 — /projects/{id} aggregation bug fix
m's bug: /projects/{client_id} renders "Keine Fristen" / "Keine Termine" /
"Noch keine Ereignisse" even when descendant Cases carry deadlines, appts,
and audit events. Live verification on Siemens AG client
(61e3fb9e-29fb-44aa-867e-a89469e2cacb): 9 descendant projects, 19
deadlines, 37 project_events, 4 appointments — none on the Client row,
all invisible until now.

Root cause: 3 legacy per-project read paths used WHERE project_id = $1
(exact match), bypassing the projectDescendantPredicate primitive that
internal/services/visibility.go:68 already provides and that the t-124
union endpoints (DeadlineService.ListVisibleForUser etc.) already use.

Backend
-------
- DeadlineService.ListForProject(..., directOnly bool): subtree by
  default via WHERE project_id IN (SELECT pp.id FROM paliad.projects pp
  WHERE $1 = ANY(string_to_array(pp.path, '.')::uuid[])); collapses to
  WHERE project_id = $1 when directOnly=true.
- AppointmentService.ListForProject: same shape.
- ProjectService.ListEvents(..., directOnly bool): same shape, plus
  LEFT JOIN paliad.projects to surface project_title for the Verlauf
  attribution chip on /projects/{id}. Inner subquery aliased pp to
  avoid shadowing the outer join's p.
- models.ProjectEvent: new optional ProjectTitle string for the Verlauf
  enrichment. Other readers leave it nil and the JSON serialiser omits
  it (json:"project_title,omitempty").
- handlers/{deadlines,appointments,projects}.go: handler reads
  ?direct_only=true|false and passes through to the service. New
  handlers.parseDirectOnly helper centralises the parse.
- project_filter_descendants_test.go: extended to also pin
  DeadlineService.ListForProject + AppointmentService.ListForProject
  + ProjectService.ListEvents (live-DB test, skipped without
  TEST_DATABASE_URL).

Frontend
--------
- projects-detail.ts: switched the deadline + appointment fetches from
  /api/projects/{id}/deadlines + /appointments (legacy narrow) to
  /api/events?type=deadline|appointment&project_id={id} (the union
  endpoints, already aggregating + enriching with project_title). The
  Verlauf still uses /api/projects/{id}/events but with the new
  direct_only flag wiring.
- New subtreeMode state machine + URL param ?subtree=false. Default =
  subtree (true). persistSubtreeMode replaceState keeps back-button
  friendly.
- 3 new .subtree-toggle buttons in /projects/{id} History, Deadlines,
  Appointments sections. Shared state across the three; clicking any
  toggle reloads all three sections at once.
- attributionChip(rowProjectID, rowProjectTitle): inline chip "auf:
  Case 14-vs-Müller" rendered when row.project_id !== currentProjectID.
  Suppressed for direct rows.
- Deadline / Appointment / ProjectEvent interfaces gained an optional
  project_title for the chip data path.
- 3 new i18n keys: aggregation.toggle.subtree (Inkl. Unterprojekte /
  Incl. sub-projects), aggregation.toggle.direct_only (Nur direkt /
  Direct only), aggregation.attribution.on (auf / on). DE+EN.
- global.css: .subtree-toggle, .subtree-toggle--active,
  .aggregation-chip — small additive styling.

No schema. No migration. Phases 2 + 3 stack on top per design §7.
2026-05-06 16:24:31 +02:00
m
1eebf2fc44 Merge: t-paliad-141 — project team-add autocomplete fix + invite-new-user inline flow 2026-05-06 16:22:41 +02:00
m
fb1a709bb8 fix(t-paliad-141): project team-add autocomplete + invite-new-user inline flow
Root cause: `.collab-suggestions` had `display: none` in CSS but no JS site
ever toggled it back on. Suggestions rendered into a permanently hidden div.
Bug originated when the akten-collab-* pattern was renamed and copied for
project team-add and partner-units member-add — the original akten-neu.ts
toggled `style.display`, but the copies relied on innerHTML alone.

Fix: switch to content-driven visibility — `.collab-suggestions:not(:empty)
{ display: block }`. No JS changes needed at consumer sites; fixes all three
broken pickers (project team-add, project parent picker, partner-units member-
add) at once. Added missing styling for `.collab-suggestion` items (padding,
hover, separators) — they were unstyled even when visible.

Plus: invite-new-user inline affordance on project /team. When the typed
query matches zero existing users, a "Benutzer nicht gefunden? Einladen"
row appears below the dropdown. Click opens the existing global invite modal
(sidebar-invite-btn → /api/invite) and pre-fills the email if the query
looks like one. No new backend, no new modal — reuses what /admin/team and
the sidebar already use.
2026-05-06 16:21:53 +02:00
m
e2e1381395 Merge: t-paliad-138 — dual-control approvals (4-eye principle) — migration 054 + ApprovalService + inbox/bell + pending pills + CalDAV PENDING + Verlauf integration 2026-05-06 16:10:29 +02:00
m
0d54da1d5b feat(t-paliad-138): Verlauf rendering for approval lifecycle events
Commit 8 of 8. Bilingual (DE primary / EN secondary) translations for
the four approval event_types per entity that ApprovalService emits
into paliad.project_events:

  deadline_approval_requested / _approved / _rejected / _revoked
  appointment_approval_requested / _approved / _rejected / _revoked

Each gets:
- event.title.<event_type>          — full Verlauf-card heading
- event.description.<event_type>    — full-sentence localized description
- dashboard.action.short.<event_type> — verb-form for the dashboard activity feed

The existing translateEvent dispatch in i18n.ts handles these
automatically — it already keys off event.title.<event_type> for the
title, and the deadline_* / appointment_* prefix branch in
translateEventDescription falls through to event.description.<event_type>
when the stored body has no quoted title (which is true for the
approval-event descriptions emitted by ApprovalService).

Result: every project's Verlauf tab now renders the full 4-eye
lifecycle trail inline alongside the existing deadline_created /
deadline_updated / etc. rows. The /admin/audit-log timeline picks
them up too via the union path.

Pair-card rendering (request + decision side-by-side keyed by
metadata.approval_request_id) was a stretch goal in the design doc;
the current per-event row rendering already conveys the full story
chronologically without needing that pairing logic.
2026-05-06 16:08:35 +02:00
m
deef5aaff5 feat(t-paliad-138): CalDAV [PENDING] prefix + reminder digest pending banner
Commit 7 of 8. Outbound surfaces honour the pending-approval state
instead of going silent on it.

CalDAV (caldav_ical.go formatAppointment): when an appointment is
approval_status='pending', the iCal SUMMARY line is prefixed with
"[PENDING] ". External clients (Outlook, Apple Calendar, etc.) thus
display the unverified state honestly. Approved entries sync clean.

Email reminder digest (reminder_service.go):
- digestRow gains ApprovalStatus, sourced from f.approval_status in
  the SELECT.
- Each pending row's Title is rewritten to "[PENDING] <title>" before
  it lands in the template — visible in every email-rendered list.
- Template data carries PendingCount (count of pending rows in this
  digest) + InboxURL so future template revisions can render a
  banner like "Hinweis: N Frist(en) wartet auf 4-Augen-Genehmigung —
  /inbox" without further code changes. Existing templates unchanged
  for backwards compat; the prefix on row titles already conveys the
  signal.
- IsPending flag on each item map for future per-row template
  conditionals.

Rationale: silence on a pending change is the worst outcome for a
4-eye system. The user's external calendar and reminder mail must
reflect "this exists but isn't verified" so they can act before the
deadline lapses.
2026-05-06 16:07:14 +02:00
m
bc47d78d97 feat(t-paliad-138): pending pills on /events and /agenda
Commit 6 of 8. Renders the approval-pending warning pill on the two
busiest list surfaces:

- /events (deadline + appointment list): ⚠ pill next to the title +
  soft-tinted row via .entity-row--pending-update modifier.
- /agenda (timeline): ⚠ pill in the headline + same row tint.

Changes:

- internal/services/event_service.go: EventListItem gains
  ApprovalStatus *string; projectDeadline / projectAppointment
  populate it from the embedded model.
- internal/services/deadline_service.go ListVisibleForUser: SQL adds
  f.approval_status / pending_request_id / approved_by / approved_at
  to the SELECT so DeadlineWithProject hydrates them.
- internal/services/appointment_service.go ListVisibleForUser: same
  for appointments + completed_at.
- internal/services/agenda_service.go: AgendaItem gains
  ApprovalStatus; the per-source SQL queries select it; the
  loadDeadlines / loadAppointments projection sets it.
- frontend/src/client/events.ts renderRow: adds entity-row--pending-update
  modifier and an inline approval-pill on the title cell when status='pending'.
- frontend/src/client/agenda.ts renderItem: same treatment on the
  agenda-item headline.

Generic "pending update" label (approvals.pending_update.label) — not
lifecycle-specific. The inbox carries the lifecycle detail. Showing
just one pill keeps the visual signal clear; an approver scanning a
list of pending entities sees them at a glance via the row tint, then
clicks through to /inbox to see what's pending and act.

Detail pages (/deadlines/{id}, /appointments/{id}) and /dashboard
deadline rail — pill rendering for those surfaces deferred to a
follow-up to keep this commit focused. Rendered everywhere it
matters most for daily use.
2026-05-06 16:05:00 +02:00
m
07a1c17861 feat(t-paliad-138): /inbox page + sidebar bell badge
Commit 5 of 8. End-user surface for the approval workflow:

- /inbox page (frontend/src/inbox.tsx + client/inbox.ts) with two tabs:
  "Zur Genehmigung" (requests I qualify to approve) and "Meine
  Anfragen" (requests I submitted). Each row shows the project, entity
  title, lifecycle event, requester name + age, the date-field diff
  (for update/complete/delete) and the relevant action buttons:
  approve + reject when on pending-mine, revoke when on mine.
  Historic rows render a status pill instead of buttons.
- Sidebar bell entry "Genehmigungen" (with sidebar-inbox-badge) under
  the Übersicht group. sidebar.ts polls /api/inbox/count every 60s and
  shows the count (or 9+ ceiling) when > 0.
- Server registration: GET /inbox → dist/inbox.html, gated by
  gateOnboarded. Already-registered API endpoints (commit 4) handle
  the data path.
- Bilingual (DE primary / EN secondary) i18n strings under
  approvals.* — labels, status names, lifecycle names, role names,
  decision-kind names, action verbs, error messages. ~50 new keys.
- Pending-state CSS classes: .approval-pill, .approval-pill--historic,
  .entity-row--pending-{create,update,complete,delete},
  #sidebar-inbox-badge. Soft-tint rows + amber pill so an approver
  can scan a list of pending entities at a glance. Used by commit 6
  (pending pills across surfaces) — no other surface picks them up
  yet, but the styles are wired and ready.
- Sidebar.tsx navItem signature gains an optional badgeID parameter
  so any future sidebar entry can host a count-badge with one extra
  argument (no per-entry custom rendering).
2026-05-06 16:00:17 +02:00
m
2247c0707d docs(t-paliad-139): design lock — m signed off on all 19 §6 recommendations
m's go/no-go pass at 2026-05-06 15:58: "I agree with all your recommendations
- go." All 19 questions in §6 lock as the recommended answers verbatim.

§0 status flipped from READY-FOR-REVIEW to LOCKED. New "Locked m decisions
on §6" subsection captures the highlights inline so future readers don't
have to scan the whole table to know what's pinned.

§13 end-of-design line updated to reflect the lock.

Implementation phasing (§7) unchanged:
- Phase 1: bug fix on the 3 narrow service methods (no schema, ~400 LoC,
  ships standalone, closes the user-visible /projects/{id} "Keine Fristen"
  bug).
- Phase 2: migration 055 (partner_unit_members.unit_role,
  project_partner_units, extended can_see_project()) + DerivationService +
  frontend Team-tab subsections + /admin/partner-units unit_role tagging
  + project /settings/team Partner Units section. Independent of t-138.
- Phase 3: approval extension — canApprove + inbox SQL widening for
  derived_peer decision_kind. Gates on cronus's t-138 (currently on
  mai/cronus/inventor-dual-control @ b3401ec) landing on main.

Inventor parked. Awaiting head's coder-shift assignment.
2026-05-06 15:59:37 +02:00
m
93c4453ce5 Merge remote-tracking branch 'origin/main' into mai/cronus/inventor-dual-control 2026-05-06 15:53:46 +02:00
m
a42322de3f Merge: t-paliad-140 — editable project on /deadlines/{id} + /appointments/{id} 2026-05-06 15:43:20 +02:00
m
abc395fcfa Merge: i18n fix — drop 'informieren' from Verfahrensablauf pathway label 2026-05-06 15:38:45 +02:00
m
747d85fe49 fix(i18n): drop nonsensical 'informieren' from Verfahrensablauf pathway label 2026-05-06 15:38:42 +02:00
m
6c41550945 docs(t-paliad-139): inventor design — hierarchy aggregation + effective team + PA derivation
Three coordinated sub-designs in one doc, scoped to m's locked constraints
(2026-05-06):

1. Surface-by-surface aggregation policy. Bug surface fix:
   /projects/{client_id} renders "Keine Fristen" because
   DeadlineService.ListForProject + AppointmentService.ListForProject +
   ProjectService.ListProjectEvents all WHERE project_id=$1 exact-match
   instead of walking paliad.projects.path descendants. The shipped t-124
   contract (projectDescendantPredicate, deadline_service.go:133 etc.)
   already aggregates correctly on the union endpoints — three legacy
   narrow paths just bypass it. Per-surface decision table for events /
   deadlines / termine / Verlauf / project tree counts / dashboard /
   CalDAV / email / search.

2. Effective-team semantics. Three structural gaps in the issue's
   premise (verified against schema):
   - No project↔unit junction (partner_unit involvement on a project).
   - No PA/lawyer distinction in partner_unit_members (no role column).
   - No lawyer↔PA pairing anywhere — Q11's "where is it stored" → nowhere.
   Proposes:
   - paliad.partner_unit_members.unit_role (lead|attorney|senior_pa|pa|paralegal),
     unit-scoped not firm-wide so 3-axis principle holds.
   - paliad.project_partner_units junction with derive_unit_roles[]
     (default {pa, senior_pa}) + derive_grants_authority bool.
   - Compute-on-read derivation via extended can_see_project() — no
     materialised state, no drift.
   - Display-effective vs visibility-effective team are different sets;
     rename ListEffectiveMembers to ListVisibilityEffectiveMembers + add
     ListSubtreeMembers.

3. Approval policy × hierarchy × derivation. Coordinates with t-138
   (cronus, mai/cronus/inventor-dual-control @ 7d1ddb9):
   - Q10: keep cronus's no-auto-inheritance, harden UX with a "Eltern-
     Politik (zur Information)" panel showing parent rules without
     applying.
   - Q12: derived members visibility-only by default; per-(project, unit)
     opt-in flag derive_grants_authority. When opted in, decision_kind
     extends with derived_peer for honest audit chronology.
   - canApprove + inbox SQL extension shape spec'd; coordinates with
     cronus's t-138 §3.4 / §7.4.

Locked m decisions surfaced in §0:
- Behaviour is surface-specific.
- Effective Team of a Client = direct ∪ descendants ∪ partner-unit-derived.
- PA derivation = unit-on-project trigger.
- Derivation honesty: annotated everywhere.
- paliad-only scope.

19 design questions with proposed answers in §6 for m to lock. Migration
055 specced (§5). Implementation phased into 3 PRs (§7) — Phase 1 bug fix
ships standalone if m wants quick win.

Inventor parked. Awaiting m go/no-go before coder shift.
2026-05-06 15:38:41 +02:00
m
fb6a07f4b7 feat(t-paliad-138): approval API endpoints (policy CRUD + inbox + decisions)
Combined backend API for the upcoming policy-authoring page (commit 4)
and inbox + bell (commit 5). Registers:

Policy CRUD (admin-only via RequireAdminFunc gate):
- GET    /api/projects/{id}/approval-policies
- PUT    /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}
- DELETE /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}

Inbox (any authenticated user; service-layer query gates by visibility
+ role-tier match):
- GET /api/inbox/pending-mine     — requests I qualify to approve
- GET /api/inbox/mine             — requests I submitted
- GET /api/inbox/count            — bell badge count
- GET /api/approval-requests/{id} — one hydrated request

Decisions (caller authorization checked at service layer; the CHECK
constraint on approval_requests blocks self-approval as a second
defence):
- POST /api/approval-requests/{id}/approve
- POST /api/approval-requests/{id}/reject
- POST /api/approval-requests/{id}/revoke

Error mapping (writeApprovalError):
- ErrSelfApproval        → 403 self_approval_blocked
- ErrNoQualifiedApprover → 409 no_qualified_approver
- ErrConcurrentPending   → 409 concurrent_pending
- ErrNotApprover         → 403 not_authorized
- ErrRequestNotPending   → 409 request_not_pending

Frontend pages (the policy authoring tab on /projects/{id}/settings
and the /inbox page with bell) follow in subsequent commits — the
endpoints are usable via curl + admin tooling immediately.
2026-05-06 15:30:28 +02:00
m
10b3426086 feat(t-paliad-138): wire ApprovalService into deadline + appointment paths
Commit 3 of 8. The 4-eye gate now actually fires. With migration 054
applied and an approval_policies row configured for a project, the
relevant Create/Update/Complete/Delete on a Deadline or Appointment
flips approval_status='pending' and emits a *_approval_requested audit
event. Without policies, behaviour is unchanged.

Backend changes:

- models.Deadline + models.Appointment gain approval_status,
  pending_request_id, approved_by, approved_at; appointments also gain
  completed_at (for the appointment:complete lifecycle event).
- deadlineColumns + appointmentColumns include the new fields so
  every existing read path hydrates them via sqlx StructScan with
  no per-call-site changes.
- DeadlineService gains SetApprovalService (nil-tolerant). Wired in
  main.go after the bundle is built.
- AppointmentService gains the same hook + dependency.

Lifecycle wiring:

- DeadlineService.Create / Update / Complete / Delete each consult
  the approval gate. Update only triggers approval when a date-bearing
  field actually changes (Q4 allowlist: due_date, original_due_date,
  warning_date). Cosmetic edits (title, description, notes,
  rule_code, event_type_ids, status, completed_at via reopen) bypass.
- AppointmentService.Create / Update / Delete same shape. Update
  only gates on start_at / end_at changes. Personal appointments
  (project_id IS NULL) never gate (no project policy to consult).
- Delete is the one stage-then-write exception: the row stays alive
  with approval_status='pending' until the approver hard-deletes
  (approve) or restores it (reject). On no-policy projects, delete
  is immediate as before.
- Concurrent-pending guard: any mutation on a row whose
  approval_status='pending' returns ErrConcurrentPending. The user
  must wait for the in-flight request to settle (or revoke if
  they're the requester).

Pre_image capture: the date-bearing fields that are about to change
are snapshotted into the approval_requests.pre_image jsonb at submit
time. Reject/Revoke applies them back over the row to revert.
2026-05-06 15:27:40 +02:00
m
4ebbf2c1af feat(t-paliad-138): ApprovalService core + tests
Commit 2 of 8 — the workflow engine for the 4-Augen-Prüfung. Wires the
service into the handlers.Services bundle so commit 3 can call into
SubmitCreate/Update/Complete/Delete from DeadlineService and
AppointmentService.

Public surface:

- Submit{Create,Update,Complete,Delete} — invoked by Deadline /
  AppointmentService inside their existing tx. Looks up policy,
  runs the deadlock check, inserts paliad.approval_requests, marks
  the entity pending, emits the *_approval_requested project_events
  audit row.
- Approve / Reject / Revoke — top-level operations (own tx). Approve
  finalises the lifecycle (clears pending markers + sets approved_by
  for non-delete; hard-deletes for delete). Reject / Revoke revert
  the entity from pre_image (delete a pending-create, restore date
  fields, NULL completed_at).
- ListPendingForApprover / ListSubmittedByUser / GetRequest /
  PendingCountForUser — read paths the inbox + bell will hit in
  commit 5.
- ListPolicies / UpsertPolicy / DeletePolicy — CRUD for the
  authoring page in commit 4.

Self-approval is blocked at three layers:
  1. canApprove() returns ErrSelfApproval when caller == requester.
  2. The DB CHECK constraint approval_requests_no_self_approval.
  3. The deadlock check excludes the requester from the pool.

Strict-ladder helper levelOf(role) mirrors the SQL function added in
migration 054. Path-walk authorization: ancestors with eligible roles
qualify for descendant requests (matches the visibility predicate).

Tests:
- Pure-Go: levelOf strict-ladder semantics, IsValidRequiredRole,
  approvalEventType. All pass under `go test`.
- Live-DB (TEST_DATABASE_URL): no-policy noop; submit→approve cycle;
  reject-create deletes; reject-update restores pre_image;
  no-qualified-approver fail; revoke flow; policy CRUD roundtrip.
  Skipped when TEST_DATABASE_URL is unset, mirroring the existing
  audit_service_test pattern.

No call sites in DeadlineService / AppointmentService yet — that's
commit 3. Paliad continues to behave identically until that lands.
2026-05-06 15:21:47 +02:00
m
b3401ec8ac feat(t-paliad-138): migration 054 — dual-control approvals schema
Schema-only commit (1 of 8) for the 4-Augen-Prüfung workflow per
docs/design-approvals-2026-05-06.md. No Go code reads these yet —
paliad behaves identically until commit 2 wires ApprovalService into
the mutation paths.

Migration 054 adds:

1. `senior_pa` to paliad.project_teams.role CHECK. Drops both the
   English `project_teams_role_check` and the German-legacy
   `projekt_teams_role_check` (live-DB constraint name carried over
   from migration 018's pre-rename era).

2. `paliad.approval_role_level(text) RETURNS int IMMUTABLE` — strict
   ladder helper: lead(5) > of_counsel(4) > associate(3) > senior_pa(2)
   > pa(1) > [local_counsel/expert/observer = 0 = ineligible]. Mirrors
   the upcoming Go `levelOf()`.

3. `paliad.approval_policies` (project_id, entity_type, lifecycle_event,
   required_role) — UNIQUE composite key gives at most 8 rows per
   project. RLS: SELECT via can_see_project; INSERT/UPDATE/DELETE only
   for global_admin (defence-in-depth; service-role pool bypasses RLS,
   so the actual gate is service-layer).

4. `paliad.approval_requests` — operational pending workflow.
   pre_image jsonb captures revert state; payload echoes the diff;
   required_role snapshots the policy at request time. CHECK
   `decided_by != requested_by` is the second layer of self-approval
   block. RLS = same can_see_project predicate as deadlines /
   appointments — anyone with project visibility sees pending requests.

5. `approval_status` (default 'approved'), `pending_request_id`,
   `approved_by`, `approved_at` columns on both deadlines and
   appointments. `appointments.completed_at` (new) lands the
   appointment:complete lifecycle event.

6. Backfill: every existing deadline + appointment row marked
   approval_status='legacy'. Per Q11, no retroactive approval; the
   next mutation on a legacy row that hits an active policy follows
   the normal flow.

Live-DB dry-run verified end-to-end: 20 deadlines + 5 appointments
backfill, both new tables instantiate cleanly, helper function returns
correct levels, self-approval CHECK fires on invalid INSERT, valid
pending insert succeeds.
2026-05-06 15:13:26 +02:00
m
7d1ddb9b84 docs(t-paliad-138): inventor design — dual-control approvals (4-eye)
Locked design for 4-Augen-Prüfung on Fristen + Termine. m-confirmed
decisions on all 11 open questions:

- Qualification gate reuses paliad.project_teams.role per-project
  (no new firm-wide axis). Adds new value `senior_pa` to the enum.
- Strict ladder: lead > of_counsel > associate > senior_pa > pa.
  Default required_role = associate. Per-project override allows pa-
  approves-pa or senior_pa-tier escalation.
- Per-(project, entity_type, lifecycle_event) policy grammar — up to
  8 settable rows per project in paliad.approval_policies.
- Edit-trigger allowlist = date-bearing fields only (Frist due_date /
  original_due_date / warning_date; Termin start_at / end_at).
- Write-then-approve: row mutates immediately, approval_status flips
  between approved/pending/legacy. Delete is the one stage-then-write
  exception (hard-delete on approve, restore on reject).
- Refuse + global_admin override on single-qualified-approver deadlock.
- Pending state visualised everywhere — list views, agenda, dashboard
  traffic-light, project detail, CalDAV-synced calendars (`[PENDING] `
  title prefix), email reminders.
- Bell + /inbox page with two tabs (zur Genehmigung / meine Anfragen).
- Operational paliad.approval_requests + audit lifecycle written to
  existing paliad.project_events (4 new event_types per entity).
- RLS = same can_see_project predicate; service layer enforces the
  approve/reject action gate. CHECK constraint blocks self-approval.
- Mark-legacy backfill: approval_status='legacy' on existing rows;
  next mutation flows through the gate.

Implementation phasing: single migration 054 + 8-commit PR plan
covering schema, service, wiring, policy authoring page, inbox,
pending pills, CalDAV/email integration, Verlauf rendering.

Inventor parked. Awaiting m go/no-go before any coder shift.
2026-05-06 14:58:01 +02:00
41 changed files with 7449 additions and 102 deletions

View File

@@ -157,7 +157,15 @@ func main() {
EmailTemplate: emailTemplateSvc,
Link: services.NewLinkService(pool),
Event: services.NewEventService(pool, deadlineSvc, appointmentSvc),
Approval: services.NewApprovalService(pool, users),
Derivation: services.NewDerivationService(pool, projectSvc, partnerUnitSvc),
}
// Wire ApprovalService into the entity services so Create / Update /
// Complete / Delete consult paliad.approval_policies (t-paliad-138).
// Without this wiring, the policies and request tables exist but no
// mutation path consults them — paliad behaves as before.
deadlineSvc.SetApprovalService(svcBundle.Approval)
appointmentSvc.SetApprovalService(svcBundle.Approval)
// v3 (t-paliad-133): wire EventCategoryService and cross-link
// it into DeadlineSearchService so ?event_category_slug= can
// resolve to a concept-id allow-list during search.

View File

@@ -0,0 +1,828 @@
# Design — Dual-control approvals (4-Augen-Prüfung) for Deadlines + Appointments
**Author:** cronus (inventor)
**Date:** 2026-05-06
**Task:** t-paliad-138 (Gitea m/paliad#3)
**Branch:** `mai/cronus/inventor-dual-control`
**Status:** DESIGN READY FOR REVIEW. Awaiting m go/no-go before any coder shift.
---
## 0. TL;DR
Add a 4-eye principle to `paliad.deadlines` and `paliad.appointments`. Every state-changing action (create / update-of-date-fields / complete / delete) submitted by one team member must be signed off by a qualified second team member from the same project before the change is "approved".
Six locked design decisions from m (2026-05-06):
| # | Question | Locked answer |
|---|---|---|
| Q1 | Where does the qualification level live? | **Reuse `project_teams.role` per-project** (no new firm-wide column). New value `senior_pa` added to the role enum. |
| Q1+ | Strict-ladder default? | **Default approval-eligible = {lead, associate}**. Per-project / per-event setting can extend to `senior_pa` or `pa` (so PAs can approve other PAs in some projects). |
| Q2 | Hierarchy semantics | **Strict ladder.** Higher level always satisfies lower. |
| Q3 | Policy granularity | **Per-(project, entity_type, lifecycle_event)** \— up to 8 settable rows per project. |
| Q4 | Edit-trigger fields | **Only date-changing fields.** Deadline: `due_date`, `original_due_date`, `warning_date`. Appointment: `start_at`, `end_at`. All other field changes bypass approval. |
| Q5 | Pending-state architecture | **Write-then-approve.** Field changes apply immediately; the entity carries `approval_status='pending'` until an approver flips it. (Delete is the one exception — see §5.4.) |
| Q6 | Inbox surface | **Bell icon (sidebar header) + dedicated `/inbox` page** with two tabs: "Zur Genehmigung" / "Meine Anfragen". |
| Q7 | Revocation | **Pending-only revoke.** After approval, only path back is a new request. |
| Q8 | Single-qualified-approver deadlock | **Refuse + global_admin override.** UI refuses with "Kein qualifizierter Approver verfügbar"; global_admin can manually approve as override (audit-marked). |
| Q9 | Audit / chronology | **Both** \— operational `paliad.approval_requests` table + new event types in `paliad.project_events`. Both creator and approver names persist on the entity row. |
| Q10 | RLS | **Visible to project team, action gated by service.** Same `can_see_project()` predicate; service layer checks "caller has required role tier AND caller_id != requested_by". |
| Q11 | Migration of existing rows | **Mark legacy + skip backfill.** All existing rows get `approval_status='legacy'`. New lifecycle events on legacy rows trigger normal approval flow. |
Plus m's explicit interjection: **pending state must be visualised everywhere the entity normally surfaces** — list views, agenda, dashboard traffic-light, project detail, CalDAV-synced calendars, and email reminders. Silence on a pending change creates more risk than visible-but-flagged-pending.
Out of scope for v1: notes, parties, documents, checklists; cross-app generalisation; multi-step n-of-m chains; email/WhatsApp/Telegram approvals (in-app only).
---
## 1. Context — what's already in the code
What this design slots into:
- **Three-axis principle (m, t-paliad-051, sacrosanct).** "Firm roles ≠ project roles ≠ tool roles."
- `paliad.users.job_title` — free-text display. Never gates anything.
- `paliad.users.global_role``standard` | `global_admin`. Tool-admin gate only.
- `paliad.project_teams.role``lead | associate | pa | of_counsel | local_counsel | expert | observer`. Per-project membership role.
- **Visibility:** `paliad.can_see_project()` SQL function (migration 023) + Go mirror `services.visibilityPredicate()` — global_admin OR any team membership on the project's path. Service-role connection bypasses RLS, so the Go mirror is load-bearing; RLS is defense-in-depth.
- **Audit:** `paliad.project_events` (created in migration 005 as `akten_events`, renamed in 018). Every mutation on every project-scoped entity emits one row via `services.insertProjectEventWithMeta()` inside the same tx. Carries `event_type`, `title`, `description`, `metadata jsonb`, `created_by`, `event_date`. Read by `services.AuditService` and by the Verlauf card on each project / deadline / appointment detail page (t-paliad-097, t-paliad-102).
- **Entity tables:** `paliad.deadlines` and `paliad.appointments`. Both already carry `created_by uuid REFERENCES auth.users(id)`. Deadlines have `status text CHECK IN ('pending','completed','cancelled','waived')`. Appointments have no status column.
- **Service layer:** `DeadlineService.{Create,Update,Complete,Reopen,Delete}`, `AppointmentService.{Create,Update,Delete}`. Each goes through `ProjectService.GetByID(ctx, userID, projectID)` for visibility before mutating. Each emits its `*_created` / `*_updated` / `*_completed` / `*_deleted` event in the same tx.
- **Existing patterns this design reuses:**
- `paliad.partner_unit_events` audit table (migration 027) — proves the side-table-with-RLS shape works alongside `project_events`.
- `paliad.event_types` + `paliad.deadline_event_types` (migration 030) — the picker / multi-select / chip UI pattern is reusable for the "required role" select on the policy authoring page.
- `services.visibilityPredicate(alias)` — same shape for the new `approvalEligibleInProject(userID, projectID, requiredRole)` helper.
This design adds **no new auth/permission axis**. It reuses `project_teams.role` for the qualification gate, per m's Q1 decision. The 3-axis principle holds because the gate uses the existing project axis, not a new firm-wide one.
---
## 2. Approval ladder
### 2.1 Strict ladder over `project_teams.role`
```
level | role | approval-eligible by default?
------+------------------+-------------------------------
5 | lead | yes — partner-tier on this project
4 | of_counsel | yes — senior tier
3 | associate | yes ← default required level
2 | senior_pa (new) | only if project policy lowers required to 'senior_pa' or below
1 | pa | only if project policy lowers required to 'pa'
0 | local_counsel | ineligible — external attorney, not in approval scope
0 | expert | ineligible — technical witness role
0 | observer | ineligible — read-only audit role
```
`senior_pa` is added to the `paliad.project_teams.role` CHECK constraint via migration 054 (see §6.1). It currently has no value in the enum.
**Strict-ladder rule:** a user with project_teams.role `R` can approve any request whose `required_role` is at level ≤ `level(R)`. So:
- Default `required_role = 'associate'` (level 3) → eligible approvers: lead, of_counsel, associate.
- Override to `required_role = 'senior_pa'` (level 2) → eligible: lead, of_counsel, associate, senior_pa.
- Override to `required_role = 'pa'` (level 1) → eligible: lead, of_counsel, associate, senior_pa, pa. This is the "PAs approve other PAs" mode m called for.
- Override to `required_role = 'lead'` → only the project lead can approve.
**Hard rules:**
1. **Self-approval is hard-blocked.** `caller_id = requested_by` always returns 403, regardless of role. This is enforced at the Go service layer (the only place that mutates approval state) and by a CHECK constraint on the row at decision time (`approved_by != requested_by`).
2. **Eligible level 0 = ineligible.** A user with role=local_counsel/expert/observer **cannot** approve any request, even if they're the only team member. They appear in the inbox with "Sie sind nicht qualifiziert" instead of the approve button.
3. **`global_admin` is an explicit override path** (§4.2) — not a normal approver. global_admin sign-off is allowed regardless of project_teams.role and audit-marked as `decision_kind='admin_override'`.
### 2.2 Why not introduce a firm-wide qualification column?
The issue listed candidates `partner / senior_attorney / attorney / senior_pa / pa / paralegal` and asked whether roles should be global, per-team, or per-project. m chose **per-project** (Q1 = "Reuse project_teams.role"). Rationale (mine, before m chose; reproduced for the record):
A firm-wide rank column would have:
- Cleanly separated from `job_title` (display) and `global_role` (tool admin).
- Made authoring rules trivial — one column on `users`, one int compare.
- Worked even before a project's team was fully populated.
But it would have:
- Added a 4th identity-axis to maintain (firm rank), violating the spirit of the three-axis principle even if the letter holds.
- Forced a firm-wide ladder onto a project context where seniority is already encoded — `lead` on a project IS the partner-tier on that project.
- Introduced the question "what if firm rank disagrees with project role" (a senior partner staffed as `observer` on a small case) without a clean answer.
m's per-project choice is consistent with how the rest of paliad treats authority: the `lead` role on `project_teams` is the source of truth for "who is the partner running this case", and approvals naturally cluster around that.
### 2.3 What about local_counsel / expert / observer?
Default: ineligible to approve. Rationale:
- **local_counsel** is an external attorney (Mitanwalt) — not always a firm employee, often outside the firm's approval chain.
- **expert** is a technical / scientific consultant role — not legally qualified to sign off on procedural deadlines.
- **observer** is explicitly a read-only role.
**Escape hatch:** if a project genuinely wants its local_counsel to approve, the team admin can re-add them with `role='associate'` (or whatever tier is intended). The role on `project_teams` is a per-project assignment; the same human can be `local_counsel` on Project A and `associate` on Project B if that's the correct authority on each.
**Out of scope (follow-up if needed):** a per-project list of "additional approval-eligible roles" that promotes local_counsel/expert into the eligible set without changing their primary project role. Probably not worth the complexity for the few cases where it'd matter.
---
## 3. Policy grammar — `paliad.approval_policies`
### 3.1 Schema
```sql
CREATE TABLE paliad.approval_policies (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
entity_type text NOT NULL CHECK (entity_type IN ('deadline','appointment')),
lifecycle_event text NOT NULL CHECK (lifecycle_event IN ('create','update','complete','delete')),
required_role text NOT NULL CHECK (required_role IN ('lead','of_counsel','associate','senior_pa','pa')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
UNIQUE (project_id, entity_type, lifecycle_event)
);
CREATE INDEX approval_policies_project_idx ON paliad.approval_policies (project_id);
```
Design choices:
- **Up to 8 rows per project.** `(deadline,create), (deadline,update), (deadline,complete), (deadline,delete), (appointment,create), (appointment,update), (appointment,complete), (appointment,delete)`. UNIQUE composite key enforces this.
- **No row = no approval needed for that event.** A project with zero policy rows is in the same operational state as today — no 4-eye anywhere.
- **`required_role` is a single value**, not a min-level int. Stored as text matching `project_teams.role` values; the strict ladder is applied in code (see `levelOf(role)` in §3.4). Storing the enum value (rather than an int level) keeps the row readable in `psql` and survives any future ladder reordering.
- **Appointment lifecycle includes `complete`**. Today appointments don't have a `completed_at` column or status field. We add one via migration 054 to give `appointment:complete` somewhere to land — see §6.4. (m may choose to defer this; if so, the policy CHECK can drop `complete` for `appointment` and the migration becomes lighter.)
### 3.2 Inheritance
**No automatic inheritance from parent project.** A child project (e.g. a single Verfahren under a Litigation parent) does NOT auto-inherit its parent's policy. Reasons:
- Inheriting would silently change behaviour when projects are reparented (t-paliad-018 already has reparent semantics).
- Policy authoring per-Verfahren is the right default — different stages of a litigation may legitimately need different scrutiny.
- The path-walking logic for "find the closest ancestor with policy" adds complexity for marginal value.
**UI affordance:** project detail → Settings → Approvals tab → "Aus Eltern-Projekt übernehmen" button copies the parent's 8 rows into this project. One-shot copy, no live link. Documented as a productivity shortcut.
### 3.3 Authoring permission
**v1: global_admin only.** Consistent with the existing /admin/team and /admin/partner-units pattern. Per-project leads cannot edit policy on their own projects in v1.
**Reasoning:** approval policy is firm-governance-grade — getting it wrong loosens compliance. Concentrating in global_admin is safer for v1. Lifting to "project lead can edit policy on their project" is a one-line gate change.
**Out of scope follow-up:** lead-can-edit-own-project-policy. File as t-paliad-139 if needed once the v1 ships.
### 3.4 Service-layer helpers
```go
// internal/services/approval_levels.go
// levelOf maps a project_teams.role value to the strict-ladder level used
// for approval gating. Returns 0 (ineligible) for roles outside the
// approval ladder (local_counsel, expert, observer).
func levelOf(role string) int {
switch role {
case "lead": return 5
case "of_counsel": return 4
case "associate": return 3
case "senior_pa": return 2
case "pa": return 1
default: return 0 // local_counsel, expert, observer, anything new
}
}
// canApprove returns true iff:
// - caller is not the requester (self-approval blocked)
// - caller's project_teams.role on this project has level >= required level
// OR caller is global_admin (which is always allowed and audit-marked separately).
func (s *ApprovalService) canApprove(ctx, callerID, projectID, requiredRole string, requesterID uuid.UUID) (bool, kind string, err error) {
if callerID == requesterID {
return false, "", ErrSelfApprovalBlocked
}
user, err := s.users.GetByID(ctx, callerID)
if err != nil { return false, "", err }
if user.GlobalRole == "global_admin" {
return true, "admin_override", nil
}
membership, err := s.projects.MembershipFor(ctx, callerID, projectID)
if err != nil || membership == nil {
return false, "", nil // not on team, cannot approve
}
if levelOf(membership.Role) >= levelOf(requiredRole) {
return true, "peer", nil
}
return false, "", nil
}
```
`decision_kind` values: `peer` (normal in-team sign-off), `admin_override` (global_admin used override path). Stored on `approval_requests.decision_kind`.
---
## 4. Lifecycle flow (write-then-approve)
### 4.1 The four lifecycle events
For each entity (deadline, appointment), four lifecycle events trigger an approval check:
1. **create** — new row submitted by user.
2. **update** — change to one or more date-bearing fields (allowlist in §4.5).
3. **complete** — flip status from `pending` to `completed` on a deadline; flip new `completed_at` (see §6.4) on appointment.
4. **delete** — request to remove the row.
### 4.2 Submission
User clicks Save / Complete / Delete on the entity. The service layer:
1. Looks up `paliad.approval_policies(project_id, entity_type, event)`.
2. **No row found:** apply mutation immediately (today's behaviour). `approval_status` defaults to `'approved'`. No request row written. Done.
3. **Row found:** apply mutation **except for delete** (see §4.3) and additionally:
- Set `approval_status = 'pending'` and `pending_request_id = <new uuid>` on the entity row.
- Insert one `paliad.approval_requests` row with `lifecycle_event`, `pre_image jsonb` (a snapshot of the now-overwritten field values, used for revert on rejection — see §4.4), `payload jsonb` (echo of what was submitted, for audit), `requested_by = caller`, `requested_at = now()`, `required_role = policy.required_role`, `status = 'pending'`.
- Emit `paliad.project_events` row with `event_type = 'deadline_approval_requested'` (or `appointment_approval_requested`) carrying `metadata.approval_request_id = <uuid>`. The Verlauf shows the lifecycle inline.
- All four writes happen in **one transaction** (entity update + request insert + event emit).
4. **Single-qualified-approver deadlock check.** Before committing, the service runs a count: how many users on this project's team have `levelOf(project_teams.role) >= levelOf(required_role) AND user_id != caller`? If 0, the submission **fails with HTTP 409** and a structured error: `{ "error": "no_qualified_approver", "required_role": "associate", "hint": "add_team_member_or_contact_admin" }`. Frontend translates to a user-facing dialog with two action buttons: "Mehr Team-Mitglieder hinzufügen" (jumps to project team page) and "Admin kontaktieren" (mailto link to global_admin emails). global_admin override is the escape hatch (§4.7).
### 4.3 Delete is special — stage-then-write
m's chosen architecture is write-then-approve, but delete cannot be applied immediately and reverted: a hard-delete is irrecoverable.
**Resolution:** for `lifecycle_event = 'delete'`, the entity row stays in place. We set `approval_status = 'pending'` and link to an `approval_requests` row carrying `lifecycle_event = 'delete'`. The UI marks the row "Zur Löschung beantragt" (see §5.3). On approve: hard-delete the row in a tx (cascades clean up the FK from `approval_requests`). On reject: clear `approval_status` back to `'approved'` and `pending_request_id` to NULL. The deletion never happened.
This is the one departure from pure write-then-approve. It's a write-then-approve from the user's perspective (they "submit a delete" and the entity behaves as if it's about to disappear) but at the data-layer it's stage-then-write for delete. Documented explicitly to avoid surprise.
### 4.4 Approval / rejection
Approver opens `/inbox`, picks a request, clicks Approve (or Reject with optional reason).
**Approve:**
1. Service-layer `canApprove(caller, project, request)` check (see §3.4).
2. If `decision_kind = 'peer'` or `'admin_override'`, set `approval_requests.status = 'approved'`, `decided_by = caller`, `decided_at = now()`, `decision_kind = …`.
3. Update entity row: `approval_status = 'approved'`, clear `pending_request_id`. Set `approved_by = caller`, `approved_at = now()`.
4. For `delete`: hard-delete the entity (cascade clears the request FK).
5. Emit `paliad.project_events` row with `event_type = 'deadline_approval_approved'` (or `appointment_approval_approved`) carrying `metadata.approval_request_id`, `metadata.decision_kind`. Verlauf line: "Frist X — Genehmigung erteilt von Bert · 2026-05-06".
6. Tx commits.
**Reject:**
1. Same `canApprove` check.
2. Set `approval_requests.status = 'rejected'`, `decided_by`, `decided_at`, `decision_note` (optional reason text from approver).
3. **Revert entity** — restore from `pre_image`:
- `create`: hard-delete the entity (it never should have been live).
- `update`: write `pre_image` field values back over the row.
- `complete`: revert deadline `status` to `'pending'`, NULL `completed_at`. Revert appointment `completed_at` to NULL (only meaningful once §6.4 lands).
- `delete`: clear `pending_request_id` and `approval_status`. Entity stays live as before.
4. Emit `paliad.project_events` row `event_type = 'deadline_approval_rejected'` (or appointment_) with `metadata.approval_request_id`, `metadata.decision_note`. Verlauf line: "Frist X — Genehmigung abgelehnt von Bert · 2026-05-06 — Grund: Datum noch nicht best."
5. Tx commits.
### 4.5 Edit-trigger field allowlist (per Q4)
The service layer only enters the approval-request flow when an `update` touches the date-bearing fields. All other edits apply immediately as `approval_status='approved'` writes — no request row, no pending state.
**Deadlines — date-bearing (gates approval):**
- `due_date`
- `original_due_date`
- `warning_date`
**Deadlines — bypass (no approval):**
- `title`, `description`, `notes`
- `rule_id`, `rule_code` (legal-basis citation — m chose to bypass; see Q4 trade-off below)
- `event_type_ids` (Typ tags via `paliad.deadline_event_types` junction)
- `status` other than via the `complete` lifecycle (e.g. cancel, waive — these are out of approval scope per the issue's "all four lifecycle events" framing, which lists complete but not cancel/waive)
**Appointments — date-bearing (gates approval):**
- `start_at`
- `end_at`
**Appointments — bypass (no approval):**
- `title`, `description`
- `location` (m's Q4 choice excludes location; documented trade-off below)
- `appointment_type`
**Trade-off (m's call):** the looser allowlist means a wrongful change to `rule_code` (legal basis) or `location` (wrong courthouse) won't trigger 4-eye. m's reasoning is implicit but consistent: dates are the highest-stakes mistake category (missed deadline = malpractice exposure), and gating every metadata edit creates approval fatigue that makes approvers rubber-stamp.
If the team finds this allowlist too loose in practice, the constants in `internal/services/approval_fields.go` (proposed location) are a one-PR widening — no schema change.
### 4.6 Optimistic-concurrency / superseded requests
Race scenario: User A submits an `update` request with `pre_image = {due_date: 2026-05-10}`. Before it's approved, user B submits another `update` with their own pre-image. Now there are two pending requests on the same row.
**Rule:** a row can have at most one pending request at a time. The submission service-layer does:
```sql
UPDATE paliad.deadlines
SET ...new field values..., approval_status = 'pending', pending_request_id = $newRequestID
WHERE id = $entityID
AND approval_status = 'approved' -- only mutate if currently clean
RETURNING id;
```
If the UPDATE returns 0 rows (because `approval_status != 'approved'`), the submission fails with HTTP 409 `{ "error": "concurrent_pending", "hint": "wait_for_existing_approval_or_revoke" }`. Frontend shows "Es liegt bereits eine Genehmigungsanfrage auf dieser Frist vor."
Submitter has options: revoke their own pending (if they own it) and resubmit; or wait for the existing request to settle.
### 4.7 Single-qualified-approver deadlock — global_admin override path
Per Q8, the default behaviour is **refuse to submit** when no qualified approver other than the requester exists on the team. Submission is blocked at the API layer.
**Override mechanism:** any `global_admin` (regardless of project membership) has the approval right. So if the user's team has nobody else qualified, the user can submit anyway IF the project has at least one global_admin who can approve. The submission service runs the deadlock check as:
```
SELECT COUNT(*) FROM paliad.project_teams pt
WHERE pt.project_id = $proj
AND pt.user_id <> $caller
AND pt.role IN (eligible roles for required_role)
+
SELECT COUNT(*) FROM paliad.users u
WHERE u.global_role = 'global_admin'
AND u.id <> $caller
```
If sum > 0, submission is allowed. If sum = 0, the 409 fires. In practice, paliad currently has 2 global_admins so sum is rarely 0 — but the design contemplates the case.
When global_admin signs off, the `decision_kind` on the approval_request row is `'admin_override'` (vs `'peer'`). Verlauf chronology renders this distinctly: "Admin-Sign-off von m · 2026-05-06" rather than "Genehmigt von Bert · 2026-05-06". The audit log timeline filters can pivot on `decision_kind`.
### 4.8 Revocation (per Q7)
- **Requester revokes:** while `request.status = 'pending'`, the requester can DELETE their own request. Service-layer reverts the entity from pre_image (same code path as Reject), but instead of marking the request `'rejected'`, marks it `'revoked'`. New `paliad.project_events` event_type `'deadline_approval_revoked'`.
- **Approver revokes after approval:** **not supported** per Q7. Once approved, the only path back is a new request — e.g. an over-eager Complete is reversed by a fresh "Reopen" lifecycle event, which itself flows through the approval gate.
---
## 5. UI surfaces
### 5.1 The pending pill — visible everywhere
Per m's interjection, pending state must surface in every view that shows the entity. Visual treatment:
- **Pending CREATE** — striped/dashed border on the row, ⚠ icon, label "Erstellung wartet auf Genehmigung von <required_role>+". Counted toward traffic-light buckets (the deadline IS real, just unverified) but rendered with a "tentative" CSS class.
- **Pending UPDATE** — solid border, but a yellow chip in the date column saying "Datum geändert — wartet auf Genehmigung". Tooltip on the chip shows the diff: "vorher: 2026-05-10 → 2026-05-12".
- **Pending COMPLETE** — solid border, status badge "Erledigt (wartet auf Genehmigung)" with strike-through-pending styling. The traffic-light treats the row as completed (the action-taker thinks they're done) but with the same striped class as create-pending so an approver can see the queue at a glance.
- **Pending DELETE** — dashed-red border, label "Zur Löschung beantragt". Date / details still visible but strike-through. Click → details + approval request.
CSS classes (proposed, in `frontend/src/styles/global.css`):
```css
.entity-row--pending-create { border-style: dashed; border-color: var(--frist-amber); }
.entity-row--pending-update { /* solid border, chip handles the signal */ }
.entity-row--pending-complete { background: linear-gradient(...striped...); }
.entity-row--pending-delete { border-style: dashed; border-color: var(--frist-red); text-decoration: line-through; }
.approval-pill { display: inline-flex; align-items: center; gap: 4px;
padding: 2px 8px; border-radius: 9999px;
background: var(--bg-warn-soft); color: var(--fg-warn);
font-size: 12px; }
.approval-pill::before { content: "⚠ "; }
```
i18n keys (DE primary, EN secondary):
- `approvals.pending_create.label` — "Erstellung wartet auf Genehmigung" / "Awaits approval (creation)"
- `approvals.pending_update.label` — "Änderung wartet auf Genehmigung" / "Awaits approval (change)"
- `approvals.pending_complete.label` — "Erledigung wartet auf Genehmigung" / "Awaits approval (completion)"
- `approvals.pending_delete.label` — "Zur Löschung beantragt" / "Awaits approval (deletion)"
- `approvals.required_role.<role>` — "Lead", "Of Counsel", "Associate", "Senior PA", "PA"
- `approvals.requested_by` — "Eingereicht von {name}" / "Submitted by {name}"
- `approvals.no_approver_dialog.*` — full deadlock dialog strings
- `approvals.approve.button` — "Genehmigen" / "Approve"
- `approvals.reject.button` — "Ablehnen" / "Reject"
- `approvals.revoke.button` — "Zurückziehen" / "Revoke"
- `approvals.decision_kind.peer` — "Genehmigt von {name}" / "Approved by {name}"
- `approvals.decision_kind.admin_override` — "Admin-Sign-off von {name}" / "Admin sign-off by {name}"
Surfaces that show the pending pill:
- `/deadlines` and `/appointments` table rows (one pill per row).
- `/agenda` timeline (per-row pill).
- `/dashboard` traffic-light card-list previews.
- `/projects/{id}` details — Fristen + Termine sections.
- `/deadlines/{id}` and `/appointments/{id}` detail pages — full diff display.
- CalDAV: pending entries sync to the user's external calendar with title prefix `[PENDING] ` (e.g. `[PENDING] Frist Erwiderung`). Approved entries sync clean.
- Email reminders (`internal/services/reminder_service.go`): pending entries get a banner in the mail body and a `[PENDING] ` subject prefix.
### 5.2 Bell + `/inbox` page (per Q6)
**Bell** in the sidebar header (next to the user-menu). Shows count of "open requests where I am a qualified approver and not the requester". Click → `/inbox`. Refreshes via the existing dashboard-polling pattern (60s interval; `Last-Modified` ETag if cheap to add).
**`/inbox` page**, two tabs:
1. **"Zur Genehmigung"** (`?tab=pending-mine`): list of `approval_requests` where:
- `status = 'pending'`
- `requested_by != me`
- I have eligible role on the project (or I'm global_admin)
Sorted by `requested_at` ASC (oldest first — stale requests demand attention). Each item shows: project title, entity title, lifecycle event, requester name, age ("vor 4h"), required-role badge. Inline Approve / Reject buttons, expand-row reveals the diff (for update / complete / delete) or full payload (for create).
2. **"Meine Anfragen"** (`?tab=mine`): list of `approval_requests` where `requested_by = me`. Status filter pills: pending / approved / rejected / revoked. For pending items, a Revoke button.
URL structure: `/inbox?tab=pending-mine|mine&status=pending|...&project_id=...`. Back-button friendly.
Why distinct from email reminder flow: email reminders are outbound notifications (digest of upcoming deadlines). The inbox is a workflow surface — actions are taken there. Sharing infra would conflate two purposes.
### 5.3 Policy authoring — `/projects/{id}/settings/approvals`
Tab on the project detail page, gated to global_admin. Rendered as a 2×4 table:
```
CREATE UPDATE (date) COMPLETE DELETE
Frist [select] [select] [select] [select]
Termin [select] [select] [select] [select]
```
Each `<select>` offers: "Keine Genehmigung erforderlich (default)" / "Lead" / "Of Counsel" / "Associate" / "Senior PA" / "PA". Submitting upserts/deletes rows in `paliad.approval_policies`.
Helpers:
- "Aus Eltern-Projekt übernehmen" button — copies the parent project's policy rows in one click. One-shot copy, no live link.
- "Alle auf Associate setzen" button — fills all 8 cells with `associate` for fast onboarding of a new project.
### 5.4 Diff rendering
For `update` requests, the `pre_image` jsonb captured at submission and the entity's current values let the UI render a clean diff. For deadlines: a 1-3 line comparison ("Datum: 2026-05-10 → 2026-05-12 · Warnung: 2026-05-08 → 2026-05-10"). Done in pure TS in `frontend/src/client/inbox.ts` consuming the request payload.
---
## 6. Schema changes (migration 054)
### 6.1 Add `senior_pa` to `project_teams.role`
```sql
ALTER TABLE paliad.project_teams DROP CONSTRAINT IF EXISTS project_teams_role_check;
ALTER TABLE paliad.project_teams ADD CONSTRAINT project_teams_role_check
CHECK (role IN (
'lead','associate','pa','of_counsel',
'local_counsel','expert','observer',
'senior_pa'
));
```
i18n labels for the new role (in DE+EN per existing `team.role.*` keys).
### 6.2 `paliad.approval_policies`
See §3.1 — full DDL.
### 6.3 `paliad.approval_requests`
```sql
CREATE TABLE paliad.approval_requests (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
entity_type text NOT NULL CHECK (entity_type IN ('deadline','appointment')),
-- entity_id is the deadline.id / appointment.id this request operates on.
-- For 'create' lifecycle, this is the id of the just-inserted entity row
-- (so the request can reference back to it). For 'delete', it's the row
-- being requested for removal.
entity_id uuid NOT NULL,
lifecycle_event text NOT NULL CHECK (lifecycle_event IN ('create','update','complete','delete')),
-- For 'update'/'complete'/'delete': pre_image carries the field values
-- needed to revert on rejection. For 'create': pre_image IS NULL.
pre_image jsonb,
-- For audit/visibility, payload echoes the diff or new values that were
-- written. Read-only after insert.
payload jsonb,
requested_by uuid NOT NULL REFERENCES paliad.users(id) ON DELETE RESTRICT,
requested_at timestamptz NOT NULL DEFAULT now(),
-- Snapshot of policy.required_role at request time. Even if the policy
-- changes mid-flight, the request honours the level it was submitted under.
required_role text NOT NULL CHECK (required_role IN ('lead','of_counsel','associate','senior_pa','pa')),
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','approved','rejected','revoked','superseded')),
decided_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
decided_at timestamptz,
decision_kind text CHECK (decision_kind IS NULL OR decision_kind IN ('peer','admin_override')),
decision_note text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- Hard CHECK: an approver is never the requester.
CHECK (decided_by IS NULL OR decided_by <> requested_by)
);
CREATE INDEX approval_requests_project_status_idx
ON paliad.approval_requests (project_id, status);
CREATE INDEX approval_requests_entity_idx
ON paliad.approval_requests (entity_type, entity_id);
CREATE INDEX approval_requests_requested_by_idx
ON paliad.approval_requests (requested_by, status);
CREATE INDEX approval_requests_pending_idx
ON paliad.approval_requests (status, requested_at)
WHERE status = 'pending';
```
RLS on `approval_requests`: per Q10, mirror `paliad.deadlines` policy — visible if `paliad.can_see_project(project_id)`. RLS does NOT gate the approve/reject action; that's enforced at the service layer.
```sql
ALTER TABLE paliad.approval_requests ENABLE ROW LEVEL SECURITY;
CREATE POLICY approval_requests_all ON paliad.approval_requests
FOR ALL USING (paliad.can_see_project(project_id));
```
### 6.4 New columns on `paliad.deadlines` and `paliad.appointments`
```sql
-- deadlines: approval state + approver tracking
ALTER TABLE paliad.deadlines ADD COLUMN approval_status text NOT NULL DEFAULT 'approved'
CHECK (approval_status IN ('approved','pending','legacy'));
ALTER TABLE paliad.deadlines ADD COLUMN pending_request_id uuid
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL;
ALTER TABLE paliad.deadlines ADD COLUMN approved_by uuid
REFERENCES paliad.users(id) ON DELETE SET NULL;
ALTER TABLE paliad.deadlines ADD COLUMN approved_at timestamptz;
CREATE INDEX deadlines_approval_status_idx
ON paliad.deadlines (approval_status) WHERE approval_status = 'pending';
-- appointments: same triple
ALTER TABLE paliad.appointments ADD COLUMN approval_status text NOT NULL DEFAULT 'approved'
CHECK (approval_status IN ('approved','pending','legacy'));
ALTER TABLE paliad.appointments ADD COLUMN pending_request_id uuid
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL;
ALTER TABLE paliad.appointments ADD COLUMN approved_by uuid
REFERENCES paliad.users(id) ON DELETE SET NULL;
ALTER TABLE paliad.appointments ADD COLUMN approved_at timestamptz;
-- appointments need a completed_at for the 'complete' lifecycle event to land
ALTER TABLE paliad.appointments ADD COLUMN completed_at timestamptz;
CREATE INDEX appointments_approval_status_idx
ON paliad.appointments (approval_status) WHERE approval_status = 'pending';
```
**`appointments.completed_at`** is new. Today appointments don't have a completion concept — they just sit on the calendar. The `complete` lifecycle event for appointments is meaningful when m wants to mark hearings/meetings as actually-happened (e.g. "Mündliche Verhandlung am 2026-05-15 — abgehalten"). If m prefers to drop appointment-complete from the lifecycle list (deadline-complete only), the `completed_at` column drops out and the policy CHECK constraint excludes `(appointment, complete)`.
This is a clean place for m to make a smaller call: keep appointment:complete (and add `completed_at`), or drop it.
### 6.5 Backfill
```sql
-- Mark all existing rows as legacy (predates 4-eye).
UPDATE paliad.deadlines SET approval_status = 'legacy';
UPDATE paliad.appointments SET approval_status = 'legacy';
```
`approved_by`/`approved_at` stay NULL on legacy rows. `created_by` is already populated since migration 005 (the column has been required from day one).
**No retroactive approval** — m's Q11 choice. Legacy rows are read-only-clean. The next mutation on a legacy row that hits an active policy follows the normal flow (e.g. editing a date on a legacy deadline triggers `update` approval; the row becomes `approval_status='pending'` and goes through the gate; once approved, `approval_status='approved'`).
### 6.6 Down migration
The down migration drops the four new columns + `completed_at` + `approval_policies` + `approval_requests` + restores the `project_teams.role` CHECK without `senior_pa`. If any user has been re-roled to `senior_pa`, the down migration will fail loudly until they're migrated to another role — intentional, mirrors the t-paliad-051 down strategy.
---
## 7. Service-layer integration
### 7.1 New service: `ApprovalService`
```go
// internal/services/approval_service.go
type ApprovalService struct {
db *sqlx.DB
projects *ProjectService
users *UserService
}
// SubmitCreate is invoked by DeadlineService.Create / AppointmentService.Create
// inside the existing entity-write tx. If a policy applies, it inserts the
// approval_requests row and sets entity.approval_status = 'pending' + entity.
// pending_request_id. Returns (requestID, isPending, err).
func (s *ApprovalService) SubmitCreate(ctx, tx, projectID, entityType, entityID, requesterID) (uuid.UUID, bool, error)
// Same shape for Update / Complete / Delete. Update takes a preImage map.
func (s *ApprovalService) SubmitUpdate(ctx, tx, projectID, entityType, entityID, requesterID, preImage map[string]any) (uuid.UUID, bool, error)
func (s *ApprovalService) SubmitComplete(ctx, tx, projectID, entityType, entityID, requesterID) (uuid.UUID, bool, error)
func (s *ApprovalService) SubmitDelete(ctx, tx, projectID, entityType, entityID, requesterID) (uuid.UUID, bool, error)
// Approve / Reject / Revoke — invoked by the inbox handler.
func (s *ApprovalService) Approve(ctx, requestID, callerID, note string) error
func (s *ApprovalService) Reject(ctx, requestID, callerID, note string) error
func (s *ApprovalService) Revoke(ctx, requestID, callerID string) error
// ListInbox returns the pending-mine and my-submitted views.
func (s *ApprovalService) ListPendingForApprover(ctx, callerID, filter) ([]ApprovalRequestView, error)
func (s *ApprovalService) ListSubmittedByUser(ctx, callerID, filter) ([]ApprovalRequestView, error)
```
### 7.2 Wiring into existing services
**`DeadlineService.Create`** today:
1. ProjectService.GetByID gate (visibility check)
2. Begin tx
3. INSERT into paliad.deadlines
4. Attach event_types junction rows
5. insertProjectEventWithMeta(deadline_created)
6. Commit
After integration:
1. ProjectService.GetByID gate
2. Begin tx
3. INSERT into paliad.deadlines (approval_status defaults to 'approved')
4. **`approvals.SubmitCreate(ctx, tx, projectID, "deadline", id, userID)`** — if policy applies, this:
- Updates approval_status='pending', pending_request_id=… on the just-inserted row
- INSERTs approval_requests row
- Performs deadlock count, fails the tx if 0 qualified approvers exist
5. Attach event_types junction rows
6. insertProjectEventWithMeta(deadline_created) — unchanged
7. **insertProjectEventWithMeta(deadline_approval_requested)** if approval is pending
8. Commit
Same shape for `Update`, `Complete`, `Delete` on both DeadlineService and AppointmentService. The `Complete` call site is `MarkComplete`/`Reopen` in DeadlineService (today); reopen would be modelled as a fresh "create-style" approval if it lands on a legacy row, or as part of "update" lifecycle on the `status` field — but `status` is not in the date-bearing allowlist so reopen goes through immediately. **Reopen does NOT trigger 4-eye** under this design (Q4 = date-fields-only). If m wants reopen-needs-approval, add `status` to the allowlist or treat reopen as its own lifecycle event.
### 7.3 Read-path changes
Existing list/summary queries (`ListVisibleForUser`, `SummaryCounts`) need to:
- Hydrate `approval_status`, `approved_by`, `approved_at`, and the linked `approval_requests.lifecycle_event` (via JOIN) for each row.
- Pass these through to the frontend so the pending pill and traffic-light styling can render.
Bucket math (t-paliad-106 5-bucket harmonisation) is **unchanged** — pending CREATEs still bucket by `due_date` like any other; the visual just adds the pending pill. Pending DELETEs still appear in their bucket until the delete is approved.
`/api/inbox/pending-mine` and `/api/inbox/mine` are new endpoints, served by `internal/handlers/inbox.go`.
### 7.4 Visibility gating for the inbox
The pending-mine list is gated by:
```sql
SELECT ar.* FROM paliad.approval_requests ar
JOIN paliad.projects p ON p.id = ar.project_id
WHERE ar.status = 'pending'
AND ar.requested_by != $callerID
AND <visibilityPredicate>(p) for callerID
AND (
-- caller is global_admin
EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $callerID AND u.global_role = 'global_admin')
OR
-- caller has eligible role on this specific project
EXISTS (SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $callerID
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND levelOf(pt.role) >= levelOf(ar.required_role))
)
ORDER BY ar.requested_at ASC;
```
`levelOf` in SQL is a small immutable function:
```sql
CREATE 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
$$;
```
Stable values; mirrors the Go `levelOf`. Used in the inbox SQL and in any future RLS policy. Migration ships both.
---
## 8. Audit / chronology integration
Per Q9, the existing `paliad.project_events` audit gains four new event_type values per entity:
- `deadline_approval_requested` — a request was submitted. Metadata: `{ approval_request_id, lifecycle_event, required_role }`.
- `deadline_approval_approved` — request approved. Metadata: `{ approval_request_id, decision_kind, decided_by_email }`.
- `deadline_approval_rejected` — request rejected. Metadata: `{ approval_request_id, decision_note }`.
- `deadline_approval_revoked` — requester revoked their own pending. Metadata: `{ approval_request_id }`.
Same four for appointments (`appointment_approval_*`).
These appear in:
- The `paliad.project_events` Verlauf card on `/projects/{id}` (via existing render path; new translateEvent cases needed in `frontend/src/client/projects-detail.ts`).
- The `paliad.project_events` Verlauf card on `/deadlines/{id}` and `/appointments/{id}` (same pattern).
- The cross-project `AuditService.ListEntries` timeline at `/admin/audit-log` (already unions project_events; new event types ride along automatically).
- Dashboard recent-activity rail (filter through existing `translateEvent` to render the correct sentence).
**Both names persist on the entity** per the issue's m-locked requirement: `created_by` (already there) + `approved_by` (new). Verlauf renders for an approved deadline:
```
Frist erstellt — eingereicht von Anna 2026-05-06 14:23
· genehmigt von Bert 2026-05-06 14:31
```
This is two project_events rows rendered as a paired card in the Verlauf. The frontend pairs them by `metadata.approval_request_id`.
---
## 9. RLS / security plan
Per Q10:
1. **`approval_requests`** — RLS = `paliad.can_see_project(project_id)`. Same predicate as `deadlines`/`appointments`. Anyone on the project can read pending requests (transparency).
2. **`approval_policies`** — RLS = `paliad.can_see_project(project_id)` for SELECT; INSERT/UPDATE/DELETE gated to `global_role = 'global_admin'` (consistent with /admin/team / /admin/partner-units precedent).
3. **The `approve`/`reject`/`revoke` action** — service-layer gate only. The pgx pool runs as service role and bypasses RLS, so the check happens in `ApprovalService.canApprove()` (§3.4). RLS provides defense-in-depth for any future direct-DB query path.
4. **Self-approval block** — enforced both at the service layer and via a CHECK constraint on `approval_requests` (`decided_by IS NULL OR decided_by <> requested_by`). Two layers because either alone is insufficient (a SQL bug bypasses the service; a service bug bypasses the CHECK).
The path-walking team-membership + global_admin predicate (`visibilityPredicate`) extends naturally to "approvable-by-me" via the inline JOIN shown in §7.4. No new SQL function needed; the inline form is read-only on the inbox query path.
**Out of scope follow-up:** if any future direct-DB tooling needs to query "approvable by me", extract a `paliad.can_approve_in_project(user_id, project_id, required_role)` SQL function. For v1, the inline JOIN is sufficient and avoids adding a function that no migration currently calls.
---
## 10. Migration plan
### 10.1 Single migration, single PR
Migration 054 (`054_approvals.{up,down}.sql`):
1. Add `senior_pa` to `project_teams.role` CHECK (§6.1).
2. Create `paliad.approval_role_level(text) RETURNS int` SQL function.
3. Create `paliad.approval_policies` table (§6.2) + indexes + RLS.
4. Create `paliad.approval_requests` table (§6.3) + indexes + RLS.
5. Add new columns on `paliad.deadlines` and `paliad.appointments` (§6.4) + indexes.
6. Mark all existing rows `approval_status='legacy'` (§6.5).
No data move. No FK hijinks. ms-level apply on a 200-ish-row deadlines table.
### 10.2 Implementation phasing
The PR is large but clean. Recommended split into commits (single branch, single PR):
1. **Commit 1 — Migration 054.** Schema + backfill. No code changes. Runs cleanly on prod; existing flows don't read the new columns yet.
2. **Commit 2 — `ApprovalService` core.** Submit / Approve / Reject / Revoke, deadlock check, pre_image capture, request lifecycle. Unit tests (table-driven over the strict-ladder + self-approval rules, deadlock count edge cases).
3. **Commit 3 — Wire into `DeadlineService` + `AppointmentService`.** Mutation paths gain the SubmitCreate/Update/Complete/Delete hooks. Read paths hydrate approval_status. Adds new event_types to project_events emit path. Live-DB integration test: TEST_DATABASE_URL covering submit→approve / submit→reject / submit→revoke / single-approver-deadlock / global-admin-override.
4. **Commit 4 — Policy authoring page.** `/projects/{id}/settings/approvals` tab + handler + frontend. global_admin-only gate.
5. **Commit 5 — Inbox.** `/inbox` page + bell icon + `/api/inbox/*` endpoints + frontend list rendering with diff display.
6. **Commit 6 — Pending pills + traffic-light variants.** CSS + i18n + per-surface pill rendering on /deadlines, /appointments, /agenda, /dashboard, /projects/{id}, detail pages.
7. **Commit 7 — CalDAV `[PENDING] ` prefix + email-reminder pending banner.** Updates `caldav_service.go` and `mail_service.go` formatting. Integration tests on iCal output and rendered email body.
8. **Commit 8 — Verlauf rendering of approval lifecycle.** translateEvent cases for the four new event_types. Pair-card rendering for request+decision events.
Each commit is testable in isolation; commits 13 are merge-safe even before the UI lands (legacy rows + pending state hidden by default = no behaviour change on existing flows because no project has policies until commit 4 ships).
### 10.3 Roll-out
Suggested:
1. Migration 054 lands → no behaviour change (no policies exist yet).
2. Pick one pilot project, set policy `(deadline,*)=associate`. Smoke through one CREATE / UPDATE / COMPLETE / DELETE cycle as a non-admin user. Verify pending pills, inbox, approver flow, audit chronology.
3. Once validated, m authors policies on real client projects. Each project opts in by adding rows.
4. Backfill any free-form leftover later if needed (admin scripts).
---
## 11. Trade-offs and known limitations
### 11.1 Write-then-approve dilution risk
Per Q5 m chose write-then-approve. This means a pending CREATE is "live" in lists / dashboard / agenda / CalDAV / email reminders before approval. A wrongful create that's eventually rejected briefly polluted the user's mental model and external calendars.
**Mitigations:**
- Pending pill is highly visible (striped border, ⚠ icon).
- CalDAV title prefix `[PENDING] ` makes external surfaces honest.
- Rejected creates emit `*_approval_rejected` event in Verlauf so the "what happened to that deadline" question has a paper trail.
- Approval flow surfaces immediately in inbox (bell badge), so latency between submit and approve is short.
The alternative (stage-then-write) was strictly safer but m rejected it; the strict-safer architecture would have forced each Frist to live in `approval_requests` until approved, which means views had to UNION the entity table with the requests table — heavy read-path changes and the kind of complexity that compounds into bugs.
### 11.2 Date-fields-only edit allowlist
m chose Q4 = "Only date-changing fields". Trade-off: a wrongful change to `rule_code` (legal basis) or `location` (wrong courthouse) bypasses 4-eye. The ladder-based approval-fatigue argument (every metadata edit triggering approvals causes rubber-stamping) is the case for the looser scope.
If the team finds this too loose in practice, extending the allowlist is a one-line constants change in `internal/services/approval_fields.go` — documented as the place to widen.
### 11.3 No inheritance from parent project
§3.2 — a child project doesn't auto-inherit its parent's policy. Trade-off: explicit per-project authoring (more control, more clicks). The "Aus Eltern-Projekt übernehmen" button in the authoring UI (§5.3) reduces the friction.
### 11.4 v1 is global_admin-only for policy authoring
Per §3.3, only global_admins can create/edit policies. Project leads cannot edit their own project's policy. Trade-off: tighter governance vs. lead self-service. Lifting to "lead can edit" is a one-line gate change (file as t-paliad-139).
### 11.5 senior_pa is the only new role enum value
§6.1 only adds `senior_pa`. Other firm-rank candidates from the issue (`partner`, `senior_attorney`, `attorney`, `paralegal`) were redundant: `lead` already represents partner-tier on a project, `of_counsel` covers senior-attorney, `associate` covers attorney, and paralegal sits below pa (mapped to `observer` in v1). If those distinctions matter later, additional values can be added without breaking existing rows.
### 11.6 Reopen is not a separate lifecycle
Today reopening a deadline (revert from `completed` to `pending`) is a status-only change. With Q4 = date-fields-only, reopen does NOT trigger 4-eye. If m wants reopen-needs-approval, it can be modelled as a 5th lifecycle event or as a special-case status-field entry in the allowlist. Documented for future tightening.
### 11.7 Approval timeout
No automatic timeout on pending requests. A request can sit pending forever. UI surfaces age ("vor 4 Tagen") to nudge approvers. Future addition: nightly digest email to approvers with a list of pending items > 24h old. Out of scope for v1.
---
## 12. Implementation recommendation
Recommended implementer: **cronus** (this same worktree). Rationale: shipped t-paliad-088 (Event Types — schema + service + handlers + frontend, similar shape), t-paliad-110 (events unification — read-path with new columns hydrated and rendered), t-paliad-122 (courts entity with role-tier-like ladder over countries+regimes). Pattern fluency is high.
Alternative: split — cronus does commits 13 (schema + service core + service-layer wiring) on `mai/cronus/approvals-impl-1`. Then a fresh coder (curie or fritz) does commits 48 (UI + inbox + pills + CalDAV + email) on a sibling branch. Trade-off: smaller PRs, but two coordination handovers.
Head decides.
---
## 13. End-of-design checklist
- [x] Locked constraints summarised (§0)
- [x] Existing-code grounding (§1)
- [x] Role taxonomy / hierarchy (§2)
- [x] Rule grammar (§3)
- [x] Lifecycle flow + edit allowlist + deadlock + revocation (§4)
- [x] UI surfaces (§5)
- [x] Schema (§6)
- [x] Service-layer integration (§7)
- [x] Audit / chronology (§8)
- [x] RLS / security (§9)
- [x] Migration plan + phasing (§10)
- [x] Trade-offs (§11)
- [x] Implementation recommendation (§12)
**Inventor stays parked.** Design committed; awaiting m's go/no-go before any coder shift starts. No `/mai-coder` self-load. The `DESIGN READY FOR REVIEW` signal is sent via `mai report completed` so the head can gate.

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,7 @@ import { renderOnboarding } from "./src/onboarding";
import { renderChangelog } from "./src/changelog";
import { renderTeam } from "./src/team";
import { renderAdmin } from "./src/admin";
import { renderInbox } from "./src/inbox";
import { renderAdminTeam } from "./src/admin-team";
import { renderAdminAuditLog } from "./src/admin-audit-log";
import { renderAdminPartnerUnits } from "./src/admin-partner-units";
@@ -248,6 +249,7 @@ async function build() {
join(import.meta.dir, "src/client/settings.ts"),
join(import.meta.dir, "src/client/dashboard.ts"),
join(import.meta.dir, "src/client/agenda.ts"),
join(import.meta.dir, "src/client/inbox.ts"),
join(import.meta.dir, "src/client/onboarding.ts"),
join(import.meta.dir, "src/client/changelog.ts"),
join(import.meta.dir, "src/client/team.ts"),
@@ -360,6 +362,7 @@ async function build() {
await Bun.write(join(DIST, "settings.html"), renderSettings());
await Bun.write(join(DIST, "dashboard.html"), renderDashboard());
await Bun.write(join(DIST, "agenda.html"), renderAgenda());
await Bun.write(join(DIST, "inbox.html"), renderInbox());
await Bun.write(join(DIST, "onboarding.html"), renderOnboarding());
await Bun.write(join(DIST, "changelog.html"), renderChangelog());
await Bun.write(join(DIST, "team.html"), renderTeam());

View File

@@ -1,4 +1,4 @@
import { initI18n, onLangChange, t, getLang } from "./i18n";
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface PartnerUnit {
@@ -16,8 +16,11 @@ interface Member {
display_name: string;
office: string;
job_title: string | null;
unit_role: string;
}
const UNIT_ROLES = ["lead", "attorney", "senior_pa", "pa", "paralegal"] as const;
interface PartnerUnitWithMembers extends PartnerUnit {
lead_display_name?: string;
lead_email?: string;
@@ -284,16 +287,54 @@ function renderMemberList(): void {
return;
}
list.innerHTML = u.members
.map(
(m) => `<li class="partner-unit-member-item">
.map((m) => {
const roleOptions = UNIT_ROLES.map((r) => {
const label = tDyn(`unit_role.${r}`) || r;
const sel = m.unit_role === r ? " selected" : "";
return `<option value="${esc(r)}"${sel}>${esc(label)}</option>`;
}).join("");
return `<li class="partner-unit-member-item">
<span>${esc(m.display_name || m.email)} <span class="form-hint">(${esc(m.email)})</span></span>
<button type="button" class="btn-ghost btn-small pu-remove-btn" data-user="${esc(m.user_id)}">${esc(t("admin.partner_units.member.remove") || "Entfernen")}</button>
</li>`,
)
<span class="partner-unit-member-actions">
<select class="pu-role-select" data-user="${esc(m.user_id)}" aria-label="${escAttr(tDyn("admin.partner_units.member.role") || "Rolle")}">${roleOptions}</select>
<button type="button" class="btn-ghost btn-small pu-remove-btn" data-user="${esc(m.user_id)}">${esc(t("admin.partner_units.member.remove") || "Entfernen")}</button>
</span>
</li>`;
})
.join("");
list.querySelectorAll<HTMLButtonElement>(".pu-remove-btn").forEach((b) =>
b.addEventListener("click", () => removeMember(b.dataset.user!)),
);
list.querySelectorAll<HTMLSelectElement>(".pu-role-select").forEach((s) =>
s.addEventListener("change", () => setMemberRole(s.dataset.user!, s.value, s)),
);
}
async function setMemberRole(userID: string, role: string, sel: HTMLSelectElement): Promise<void> {
if (!activeUnitID) return;
// Snapshot the prior selection so we can roll back on failure.
const u = units.find((x) => x.id === activeUnitID);
const prior = u?.members.find((m) => m.user_id === userID)?.unit_role;
sel.disabled = true;
const resp = await fetch(
`/api/partner-units/${activeUnitID}/members/${userID}/role`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ unit_role: role }),
},
);
sel.disabled = false;
if (!resp.ok) {
if (prior !== undefined) sel.value = prior;
const body = await resp.json().catch(() => ({ error: resp.statusText }));
showFeedback(body.error || "Rolle konnte nicht gespeichert werden.", true);
return;
}
await loadUnits();
renderMemberList();
render();
showFeedback(tDyn("admin.partner_units.feedback.role_updated") || "Rolle aktualisiert.", false);
}
function wireSuggestions(): void {

View File

@@ -23,6 +23,8 @@ interface AgendaItem {
project_title?: string | null;
project_type?: string | null; // client | litigation | patent | case | project
project_reference?: string | null;
// Approval workflow (t-paliad-138). "pending" → render the warning pill.
approval_status?: "approved" | "pending" | "legacy" | null;
}
interface AgendaPayload {
@@ -271,11 +273,15 @@ function expectedUrgency(day: Date): Urgency {
function renderItem(it: AgendaItem, bucketUrgency: Urgency): string {
const urgencyClass = `agenda-item-${it.urgency}`;
const typeClass = `agenda-item-type-${it.type}`;
const pendingClass = it.approval_status === "pending" ? " entity-row--pending-update" : "";
const iconHTML = it.type === "deadline" ? deadlineIcon() : appointmentIcon();
const detailHref = itemDetailHref(it);
const project = it.project_id
? `<a class="agenda-item-project" href="/projects/${esc(it.project_id)}">${esc(formatProjectLabel(it))}</a>`
: "";
const pendingPill = it.approval_status === "pending"
? `<span class="approval-pill" title="${esc(tDyn("approvals.pending_update.label"))}">${esc(tDyn("approvals.pending_update.label"))}</span>`
: "";
const timePart = it.type === "appointment"
? `<span class="agenda-item-time">${esc(formatAppointmentTime(it))}</span>`
@@ -291,13 +297,14 @@ function renderItem(it: AgendaItem, bucketUrgency: Urgency): string {
: (it.appointment_type ? `agenda.appointment_type.${it.appointment_type}` : "agenda.label.appointment");
const typeLabel = tDyn(typeLabelKey);
return `<li class="agenda-item ${typeClass} ${urgencyClass}">
return `<li class="agenda-item ${typeClass} ${urgencyClass}${pendingClass}">
<a class="agenda-item-link" href="${esc(detailHref)}">
<span class="agenda-item-icon" aria-hidden="true">${iconHTML}</span>
<span class="agenda-item-main">
<span class="agenda-item-headline">
<span class="agenda-item-type-label">${esc(typeLabel)}:</span>
<span class="agenda-item-title">${esc(it.title)}</span>
${pendingPill}
</span>
<span class="agenda-item-sub">
${project}

View File

@@ -38,6 +38,9 @@ interface EventListItem {
project_title?: string;
project_type?: string;
// Approval workflow (t-paliad-138). "pending" → render the warning pill.
approval_status?: "approved" | "pending" | "legacy";
// deadline-only
due_date?: string;
status?: string;
@@ -504,11 +507,19 @@ function renderRow(item: EventListItem, showReopen: boolean): string {
? `<span class="termin-type-chip termin-type-${esc(item.appointment_type)}">${esc(tDyn(`appointments.type.${item.appointment_type}`) || item.appointment_type)}</span>`
: "&mdash;";
return `<tr class="frist-row events-row events-row-${item.type}" data-id="${esc(item.id)}" data-type="${item.type}">
// Approval pending pill (t-paliad-138). Soft-tint the row + insert a
// ⚠ chip next to the title. Generic "pending approval" — the inbox
// shows the lifecycle detail.
const pendingClass = item.approval_status === "pending" ? " entity-row--pending-update" : "";
const pendingPill = item.approval_status === "pending"
? `<span class="approval-pill" title="${esc(t("approvals.pending_update.label"))}">${esc(t("approvals.pending_update.label"))}</span>`
: "";
return `<tr class="frist-row events-row events-row-${item.type}${pendingClass}" data-id="${esc(item.id)}" data-type="${item.type}">
<td class="frist-col-check">${checkCell}</td>
<td class="events-col-row-type">${rowTypeChip(item)}</td>
<td class="frist-col-due ${urgency}"><span class="frist-due-dot"></span>${esc(dateLabel)}</td>
<td class="frist-col-title ${titleClass}">${esc(item.title)}</td>
<td class="frist-col-title ${titleClass}">${esc(item.title)}${pendingPill ? " " + pendingPill : ""}</td>
<td class="frist-col-project">${projectCell}</td>
<td class="frist-col-rule events-col-rule">${ruleLabel || "&mdash;"}</td>
<td class="entity-col-event-type">${eventTypeCell || "&mdash;"}</td>

View File

@@ -34,6 +34,7 @@ const translations: Record<Lang, Record<string, string>> = {
"nav.termine": "Termine",
"nav.dashboard": "Dashboard",
"nav.agenda": "Agenda",
"nav.inbox": "Genehmigungen",
"nav.team": "Team",
"nav.group.uebersicht": "\u00dcbersicht",
"nav.group.arbeit": "Arbeit",
@@ -305,7 +306,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.card.calc.add_to_project": "Zu Akte hinzufügen",
"deadlines.card.calc.add_to_project.disabled": "Gerichtsbestimmt — manuell anlegen",
"deadlines.pathway.fork.heading": "Was möchten Sie tun?",
"deadlines.pathway.a.title": "Verfahrensablauf informieren",
"deadlines.pathway.a.title": "Verfahrensablauf",
"deadlines.pathway.a.desc": "Verfahrenstyp wählen und alle dazugehörigen Fristen auf einer Zeitleiste sehen.",
"deadlines.pathway.b.title": "Frist eintragen aufgrund Ereignis",
"deadlines.pathway.b.desc": "Ein Ereignis ist eingetreten — ich brauche die richtige Frist für meine Akte.",
@@ -648,6 +649,40 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.urgency.later": "Sp\u00e4ter",
"deadlines.complete.action": "Erledigen",
// t-paliad-139 \u2014 subtree aggregation toggle and attribution chip
"aggregation.toggle.subtree": "Inkl. Unterprojekte",
"aggregation.toggle.direct_only": "Nur direkt",
"aggregation.attribution.on": "auf",
// t-paliad-139 Phase 2 \u2014 Team-tab subsections + Partner Units management
"projects.team.section.from_descendants": "Aus Unterprojekten",
"projects.team.section.from_descendants.hint": "Personen, die direkt auf einem Unterprojekt eingetragen sind und nicht auf diesem oder einem \u00dcbergeordneten.",
"projects.team.section.derived": "Abgeleitet (Partner Unit)",
"projects.team.section.derived.hint": "Mitglieder, die \u00fcber eine zugeordnete Partner Unit auf diesem Projekt aktiv sind.",
"projects.team.section.units": "Partner Units",
"projects.team.section.units.hint": "Partner Units, die auf diesem Projekt eingebunden sind. Mitglieder mit passenden Unit-Rollen werden automatisch abgeleitet.",
"projects.team.derived.from": "\u00fcber",
"projects.team.derived.visibility": "Sicht",
"projects.team.derived.authority": "Sicht & 4-Augen",
"projects.team.derived.authority.hint": "Stimmrecht: Abgeleitete Mitglieder z\u00e4hlen als Approver.",
"projects.team.units.attach": "Partner Unit zuordnen",
"projects.team.units.detach": "Entfernen",
"projects.team.units.choose": "Bitte Unit w\u00e4hlen\u2026",
"projects.team.units.select": "Unit",
"projects.team.units.derive_roles": "Welche Unit-Rollen ableiten?",
"projects.team.units.grants_authority": "Stimmrecht abgeben (4-Augen)",
"projects.team.units.col.name": "Unit",
"projects.team.units.col.derive_roles": "Abgeleitete Rollen",
"projects.team.units.col.authority": "Authority",
"projects.team.units.members": "Mitglieder",
"projects.team.units.empty": "Keine Partner Units zugeordnet.",
"projects.team.units.confirm_detach": "Partner Unit entfernen?",
"unit_role.lead": "Lead",
"unit_role.attorney": "Attorney",
"unit_role.senior_pa": "Senior PA",
"unit_role.pa": "PA",
"unit_role.paralegal": "Paralegal",
"deadlines.neu.title": "Neue Frist \u2014 Paliad",
"deadlines.neu.heading": "Neue Frist anlegen",
"deadlines.neu.subtitle": "Eine persistente Frist an einer Akte. Sichtbar f\u00fcr alle Personen, die die Akte sehen k\u00f6nnen.",
@@ -820,6 +855,33 @@ const translations: Record<Lang, Record<string, string>> = {
"event.title.appointment_updated": "Termin ge\u00e4ndert",
"event.title.appointment_deleted": "Termin gel\u00f6scht",
"event.title.appointment_project_changed": "Termin verschoben",
// 4-eye approval lifecycle (t-paliad-138). Verlauf renders these as
// a paired card with the original lifecycle event (e.g.
// "Frist angelegt" + "Genehmigung erteilt von Bert").
"event.title.deadline_approval_requested": "Genehmigung beantragt",
"event.title.deadline_approval_approved": "Genehmigung erteilt",
"event.title.deadline_approval_rejected": "Genehmigung abgelehnt",
"event.title.deadline_approval_revoked": "Anfrage zurückgezogen",
"event.title.appointment_approval_requested": "Genehmigung beantragt",
"event.title.appointment_approval_approved": "Genehmigung erteilt",
"event.title.appointment_approval_rejected": "Genehmigung abgelehnt",
"event.title.appointment_approval_revoked": "Anfrage zurückgezogen",
"event.description.deadline_approval_requested": "4-Augen-Genehmigung für Frist beantragt",
"event.description.deadline_approval_approved": "Genehmigung für Frist erteilt",
"event.description.deadline_approval_rejected": "Genehmigung für Frist abgelehnt",
"event.description.deadline_approval_revoked": "Genehmigungsanfrage für Frist zurückgezogen",
"event.description.appointment_approval_requested": "4-Augen-Genehmigung für Termin beantragt",
"event.description.appointment_approval_approved": "Genehmigung für Termin erteilt",
"event.description.appointment_approval_rejected": "Genehmigung für Termin abgelehnt",
"event.description.appointment_approval_revoked": "Genehmigungsanfrage für Termin zurückgezogen",
"dashboard.action.short.deadline_approval_requested": "beantragte Genehmigung",
"dashboard.action.short.deadline_approval_approved": "genehmigte Frist",
"dashboard.action.short.deadline_approval_rejected": "lehnte Frist ab",
"dashboard.action.short.deadline_approval_revoked": "zog Anfrage zurück",
"dashboard.action.short.appointment_approval_requested": "beantragte Genehmigung",
"dashboard.action.short.appointment_approval_approved": "genehmigte Termin",
"dashboard.action.short.appointment_approval_rejected": "lehnte Termin ab",
"dashboard.action.short.appointment_approval_revoked": "zog Anfrage zurück",
"event.title.checklist_created": "Checkliste angelegt",
"event.title.checklist_renamed": "Checkliste umbenannt",
"event.title.checklist_linked": "Checkliste verkn\u00fcpft",
@@ -1088,6 +1150,9 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.team.confirm_remove": "Mitglied entfernen?",
"projects.detail.team.empty": "Noch keine Teammitglieder.",
"projects.detail.team.error.user_required": "Benutzer ausw\u00e4hlen",
"projects.detail.team.invite.hint": "Benutzer nicht gefunden?",
"projects.detail.team.invite.hint_email": "Niemand mit dieser E-Mail.",
"projects.detail.team.invite.cta": "Einladen",
"projects.view.tree": "Baumansicht",
"projects.tree.toggle": "Aufklappen / Zuklappen",
"projects.tree.loading": "Baum wird geladen\u2026",
@@ -1479,6 +1544,7 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.partner_units.feedback.created": "Angelegt.",
"admin.partner_units.feedback.updated": "Aktualisiert.",
"admin.partner_units.feedback.deleted": "Gelöscht.",
"admin.partner_units.feedback.role_updated": "Rolle aktualisiert.",
"admin.partner_units.member.heading": "Mitglieder verwalten",
"admin.partner_units.member.empty": "Noch keine Mitglieder.",
"admin.partner_units.member.add": "Mitglied hinzufügen",
@@ -1486,6 +1552,7 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.partner_units.member.remove": "Entfernen",
"admin.partner_units.member.confirm_remove": "Mitglied entfernen?",
"admin.partner_units.member.placeholder": "Name oder E-Mail",
"admin.partner_units.member.role": "Rolle",
"admin.audit.loading": "Lade…",
"admin.audit.empty": "Keine Ereignisse für die gewählten Filter.",
"admin.audit.loadmore": "Weitere laden",
@@ -1619,6 +1686,59 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.event_types.merge.title": "Typen zusammenführen",
"admin.event_types.merge.body": "Wählen Sie den Gewinner-Typ. Die Junction-Einträge der Verlierer werden auf den Gewinner umgeleitet, anschließend werden die Verlierer archiviert.",
"admin.event_types.merge.submit": "Zusammenführen",
// Approval workflow (t-paliad-138).
"approvals.title": "Genehmigungen",
"approvals.heading": "Genehmigungen",
"approvals.subtitle": "4-Augen-Prüfung für Fristen und Termine.",
"approvals.tab.pending_mine": "Zur Genehmigung",
"approvals.tab.mine": "Meine Anfragen",
"approvals.empty.pending_mine": "Aktuell nichts zu genehmigen.",
"approvals.empty.mine": "Sie haben keine offenen Anfragen.",
"approvals.lifecycle.create": "Erstellung",
"approvals.lifecycle.update": "Änderung",
"approvals.lifecycle.complete": "Erledigung",
"approvals.lifecycle.delete": "Löschung",
"approvals.entity.deadline": "Frist",
"approvals.entity.appointment": "Termin",
"approvals.required_role.lead": "Lead",
"approvals.required_role.of_counsel": "Of Counsel",
"approvals.required_role.associate": "Associate",
"approvals.required_role.senior_pa": "Senior PA",
"approvals.required_role.pa": "PA",
"approvals.status.pending": "Offen",
"approvals.status.approved": "Genehmigt",
"approvals.status.rejected": "Abgelehnt",
"approvals.status.revoked": "Zurückgezogen",
"approvals.status.superseded": "Ersetzt",
"approvals.action.approve": "Genehmigen",
"approvals.action.reject": "Ablehnen",
"approvals.action.revoke": "Zurückziehen",
"approvals.note.placeholder": "Optionale Begründung...",
"approvals.requested_by": "Eingereicht von",
"approvals.decided_by": "Entschieden von",
"approvals.decision_kind.peer": "Genehmigt durch Teammitglied",
"approvals.decision_kind.admin_override": "Admin-Sign-off",
"approvals.decision_kind.derived_peer": "Genehmigt durch abgeleitetes Mitglied (Partner Unit)",
"approvals.error.self_approval": "Eigengenehmigung nicht zulässig.",
"approvals.error.not_authorized": "Sie haben nicht die erforderliche Rolle.",
"approvals.error.no_qualified_approver": "Kein qualifizierter Approver verfügbar — bitte einen Approver ins Projekt-Team aufnehmen oder Admin kontaktieren.",
"approvals.error.concurrent_pending": "Es liegt bereits eine Genehmigungsanfrage auf diesem Eintrag vor.",
"approvals.error.request_not_pending": "Diese Anfrage ist nicht mehr offen.",
"approvals.pending_create.label": "Erstellung wartet auf Genehmigung",
"approvals.pending_update.label": "Änderung wartet auf Genehmigung",
"approvals.pending_complete.label": "Erledigung wartet auf Genehmigung",
"approvals.pending_delete.label": "Zur Löschung beantragt",
"approvals.diff.before": "Vorher",
"approvals.diff.after": "Nachher",
"approvals.policies.title": "Genehmigungsrichtlinien",
"approvals.policies.subtitle": "Welche Lebenszyklus-Schritte benötigen 4-Augen-Prüfung in diesem Projekt?",
"approvals.policies.column.event": "Ereignis",
"approvals.policies.column.deadline": "Frist",
"approvals.policies.column.appointment": "Termin",
"approvals.policies.no_approval": "Keine Genehmigung erforderlich",
"approvals.policies.copy_parent": "Aus Eltern-Projekt übernehmen",
"approvals.policies.set_all_associate": "Alle auf Associate setzen",
},
en: {
@@ -1639,6 +1759,7 @@ const translations: Record<Lang, Record<string, string>> = {
"nav.termine": "Appointments",
"nav.dashboard": "Dashboard",
"nav.agenda": "Agenda",
"nav.inbox": "Approvals",
"nav.team": "Team",
"nav.group.uebersicht": "Overview",
"nav.group.arbeit": "Work",
@@ -2250,6 +2371,40 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.urgency.later": "Later",
"deadlines.complete.action": "Complete",
// t-paliad-139 \u2014 subtree aggregation toggle and attribution chip
"aggregation.toggle.subtree": "Incl. sub-projects",
"aggregation.toggle.direct_only": "Direct only",
"aggregation.attribution.on": "on",
// t-paliad-139 Phase 2 \u2014 Team-tab subsections + Partner Units management
"projects.team.section.from_descendants": "From sub-projects",
"projects.team.section.from_descendants.hint": "People directly staffed on a sub-project who are not on this project or an ancestor.",
"projects.team.section.derived": "Derived (Partner Unit)",
"projects.team.section.derived.hint": "Members active on this project via an attached partner unit.",
"projects.team.section.units": "Partner Units",
"projects.team.section.units.hint": "Partner units attached to this project. Members in the listed unit roles auto-derive.",
"projects.team.derived.from": "via",
"projects.team.derived.visibility": "View",
"projects.team.derived.authority": "View & 4-eye",
"projects.team.derived.authority.hint": "Authority: derived members count as approvers.",
"projects.team.units.attach": "Attach partner unit",
"projects.team.units.detach": "Remove",
"projects.team.units.choose": "Select a unit\u2026",
"projects.team.units.select": "Unit",
"projects.team.units.derive_roles": "Which unit roles should derive?",
"projects.team.units.grants_authority": "Grant authority (4-eye)",
"projects.team.units.col.name": "Unit",
"projects.team.units.col.derive_roles": "Derived roles",
"projects.team.units.col.authority": "Authority",
"projects.team.units.members": "members",
"projects.team.units.empty": "No partner units attached.",
"projects.team.units.confirm_detach": "Remove partner unit?",
"unit_role.lead": "Lead",
"unit_role.attorney": "Attorney",
"unit_role.senior_pa": "Senior PA",
"unit_role.pa": "PA",
"unit_role.paralegal": "Paralegal",
"deadlines.neu.title": "New deadline \u2014 Paliad",
"deadlines.neu.heading": "Create new deadline",
"deadlines.neu.subtitle": "A persistent deadline attached to a matter. Visible to anyone who can see that matter.",
@@ -2418,6 +2573,31 @@ const translations: Record<Lang, Record<string, string>> = {
"event.title.appointment_updated": "Appointment updated",
"event.title.appointment_deleted": "Appointment deleted",
"event.title.appointment_project_changed": "Appointment moved",
// 4-eye approval lifecycle (t-paliad-138).
"event.title.deadline_approval_requested": "Approval requested",
"event.title.deadline_approval_approved": "Approval granted",
"event.title.deadline_approval_rejected": "Approval rejected",
"event.title.deadline_approval_revoked": "Request revoked",
"event.title.appointment_approval_requested": "Approval requested",
"event.title.appointment_approval_approved": "Approval granted",
"event.title.appointment_approval_rejected": "Approval rejected",
"event.title.appointment_approval_revoked": "Request revoked",
"event.description.deadline_approval_requested": "Four-eyes approval requested for deadline",
"event.description.deadline_approval_approved": "Deadline approval granted",
"event.description.deadline_approval_rejected": "Deadline approval rejected",
"event.description.deadline_approval_revoked": "Deadline approval request revoked",
"event.description.appointment_approval_requested": "Four-eyes approval requested for appointment",
"event.description.appointment_approval_approved": "Appointment approval granted",
"event.description.appointment_approval_rejected": "Appointment approval rejected",
"event.description.appointment_approval_revoked": "Appointment approval request revoked",
"dashboard.action.short.deadline_approval_requested": "requested approval",
"dashboard.action.short.deadline_approval_approved": "approved deadline",
"dashboard.action.short.deadline_approval_rejected": "rejected deadline",
"dashboard.action.short.deadline_approval_revoked": "revoked request",
"dashboard.action.short.appointment_approval_requested": "requested approval",
"dashboard.action.short.appointment_approval_approved": "approved appointment",
"dashboard.action.short.appointment_approval_rejected": "rejected appointment",
"dashboard.action.short.appointment_approval_revoked": "revoked request",
"event.title.checklist_created": "Checklist created",
"event.title.checklist_renamed": "Checklist renamed",
"event.title.checklist_linked": "Checklist linked",
@@ -2686,6 +2866,9 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.team.confirm_remove": "Remove member?",
"projects.detail.team.empty": "No team members yet.",
"projects.detail.team.error.user_required": "Select a user",
"projects.detail.team.invite.hint": "User not found?",
"projects.detail.team.invite.hint_email": "No one with that email.",
"projects.detail.team.invite.cta": "Invite",
"projects.view.tree": "Tree view",
"projects.tree.toggle": "Expand / collapse",
"projects.tree.loading": "Loading tree…",
@@ -3074,6 +3257,7 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.partner_units.feedback.created": "Created.",
"admin.partner_units.feedback.updated": "Updated.",
"admin.partner_units.feedback.deleted": "Deleted.",
"admin.partner_units.feedback.role_updated": "Role updated.",
"admin.partner_units.member.heading": "Manage members",
"admin.partner_units.member.empty": "No members yet.",
"admin.partner_units.member.add": "Add member",
@@ -3081,6 +3265,7 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.partner_units.member.remove": "Remove",
"admin.partner_units.member.confirm_remove": "Remove member?",
"admin.partner_units.member.placeholder": "Name or email",
"admin.partner_units.member.role": "Role",
"admin.audit.loading": "Loading…",
"admin.audit.empty": "No events match the selected filters.",
"admin.audit.loadmore": "Load more",
@@ -3214,6 +3399,59 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.event_types.merge.title": "Merge types",
"admin.event_types.merge.body": "Pick the winner. Loser junction rows get redirected to the winner, then the losers are archived.",
"admin.event_types.merge.submit": "Merge",
// Approval workflow (t-paliad-138).
"approvals.title": "Approvals",
"approvals.heading": "Approvals",
"approvals.subtitle": "Four-eyes review for deadlines and appointments.",
"approvals.tab.pending_mine": "Awaiting approval",
"approvals.tab.mine": "My requests",
"approvals.empty.pending_mine": "Nothing awaits your approval.",
"approvals.empty.mine": "You have no open requests.",
"approvals.lifecycle.create": "Creation",
"approvals.lifecycle.update": "Change",
"approvals.lifecycle.complete": "Completion",
"approvals.lifecycle.delete": "Deletion",
"approvals.entity.deadline": "Deadline",
"approvals.entity.appointment": "Appointment",
"approvals.required_role.lead": "Lead",
"approvals.required_role.of_counsel": "Of Counsel",
"approvals.required_role.associate": "Associate",
"approvals.required_role.senior_pa": "Senior PA",
"approvals.required_role.pa": "PA",
"approvals.status.pending": "Open",
"approvals.status.approved": "Approved",
"approvals.status.rejected": "Rejected",
"approvals.status.revoked": "Revoked",
"approvals.status.superseded": "Superseded",
"approvals.action.approve": "Approve",
"approvals.action.reject": "Reject",
"approvals.action.revoke": "Revoke",
"approvals.note.placeholder": "Optional note...",
"approvals.requested_by": "Submitted by",
"approvals.decided_by": "Decided by",
"approvals.decision_kind.peer": "Peer approval",
"approvals.decision_kind.admin_override": "Admin override",
"approvals.decision_kind.derived_peer": "Approved by derived member (Partner Unit)",
"approvals.error.self_approval": "You cannot approve your own request.",
"approvals.error.not_authorized": "You don't have the required role.",
"approvals.error.no_qualified_approver": "No qualified approver available — please add an approver to the project team or contact an admin.",
"approvals.error.concurrent_pending": "Another approval request is already in flight on this entity.",
"approvals.error.request_not_pending": "This request is no longer open.",
"approvals.pending_create.label": "Awaits approval (creation)",
"approvals.pending_update.label": "Awaits approval (change)",
"approvals.pending_complete.label": "Awaits approval (completion)",
"approvals.pending_delete.label": "Awaits approval (deletion)",
"approvals.diff.before": "Before",
"approvals.diff.after": "After",
"approvals.policies.title": "Approval policies",
"approvals.policies.subtitle": "Which lifecycle events need four-eyes review on this project?",
"approvals.policies.column.event": "Event",
"approvals.policies.column.deadline": "Deadline",
"approvals.policies.column.appointment": "Appointment",
"approvals.policies.no_approval": "No approval needed",
"approvals.policies.copy_parent": "Copy from parent project",
"approvals.policies.set_all_associate": "Set all to Associate",
},
};

View File

@@ -0,0 +1,279 @@
import { initI18n, t, getLang, type I18nKey } from "./i18n";
import { initSidebar } from "./sidebar";
// /inbox client. Two tabs (pending-mine / mine), action buttons (approve /
// reject / revoke), and a small inline diff for update / complete / delete
// lifecycle events.
//
// State is URL-driven via ?tab= so back/forward buttons work and the bell
// badge can deep-link to either tab. The badge in the sidebar (id
// sidebar-inbox-badge) is updated by the shared global polling loop in
// sidebar.ts; this module just keeps the page content in sync.
type Lifecycle = "create" | "update" | "complete" | "delete";
type RequestStatus = "pending" | "approved" | "rejected" | "revoked" | "superseded";
type DecisionKind = "peer" | "admin_override";
interface ApprovalRequestView {
id: string;
project_id: string;
project_title: string;
entity_type: "deadline" | "appointment";
entity_id: string;
entity_title?: string;
lifecycle_event: Lifecycle;
pre_image?: Record<string, unknown> | null;
payload?: Record<string, unknown> | null;
required_role: string;
status: RequestStatus;
requested_at: string;
requested_by: string;
requester_name: string;
decided_at?: string;
decided_by?: string;
decider_name?: string;
decision_kind?: DecisionKind;
decision_note?: string;
}
type Tab = "pending-mine" | "mine";
let currentTab: Tab = "pending-mine";
initI18n();
initSidebar();
document.addEventListener("DOMContentLoaded", () => {
const url = new URL(window.location.href);
const t = url.searchParams.get("tab");
if (t === "mine") currentTab = "mine";
bindTabs();
refresh();
});
function bindTabs() {
document.querySelectorAll<HTMLButtonElement>("#inbox-tab-row [data-tab]").forEach((btn) => {
btn.addEventListener("click", () => {
const tab = (btn.dataset.tab as Tab) || "pending-mine";
if (tab === currentTab) return;
currentTab = tab;
const url = new URL(window.location.href);
url.searchParams.set("tab", tab);
history.replaceState({}, "", url.toString());
document.querySelectorAll<HTMLButtonElement>("#inbox-tab-row [data-tab]").forEach((b) => {
b.classList.toggle("active", b.dataset.tab === tab);
});
refresh();
});
});
}
async function refresh() {
const loading = document.getElementById("inbox-loading") as HTMLElement | null;
const empty = document.getElementById("inbox-empty") as HTMLElement | null;
const list = document.getElementById("inbox-list") as HTMLUListElement | null;
if (!loading || !empty || !list) return;
loading.style.display = "";
empty.style.display = "none";
list.innerHTML = "";
const path = currentTab === "pending-mine" ? "/api/inbox/pending-mine" : "/api/inbox/mine";
let rows: ApprovalRequestView[] = [];
try {
const r = await fetch(path, { credentials: "include" });
if (r.ok) rows = (await r.json()) as ApprovalRequestView[];
} catch (_e) {
// Network errors fall through to empty render.
}
loading.style.display = "none";
if (rows.length === 0) {
empty.textContent = t(
currentTab === "pending-mine"
? "approvals.empty.pending_mine"
: "approvals.empty.mine"
);
empty.style.display = "";
return;
}
for (const row of rows) list.appendChild(renderRow(row));
}
function renderRow(row: ApprovalRequestView): HTMLLIElement {
const li = document.createElement("li");
li.className = "inbox-row";
// Header: project / entity / lifecycle / required-role
const head = document.createElement("div");
head.className = "inbox-row-head";
const title = document.createElement("div");
title.className = "inbox-row-title";
const entityLabel = t(("approvals.entity." + row.entity_type) as I18nKey);
const lifecycleLabel = t(("approvals.lifecycle." + row.lifecycle_event) as I18nKey);
const entityTitle = row.entity_title || "—";
title.textContent = `${entityLabel}: ${entityTitle}${lifecycleLabel}`;
head.appendChild(title);
const meta = document.createElement("div");
meta.className = "inbox-row-meta";
const reqByLabel = t("approvals.requested_by");
const roleLabel = t(("approvals.required_role." + row.required_role) as I18nKey);
meta.textContent = `${row.project_title} · ${reqByLabel} ${row.requester_name} · ${roleLabel}+ · ${formatRelativeTime(row.requested_at)}`;
head.appendChild(meta);
li.appendChild(head);
// Diff for update / complete (date-bearing fields)
const diff = renderDiff(row);
if (diff) li.appendChild(diff);
// Decision note if any
if (row.decision_note) {
const note = document.createElement("div");
note.className = "inbox-row-note";
note.textContent = row.decision_note;
li.appendChild(note);
}
// Action row
const actions = document.createElement("div");
actions.className = "inbox-row-actions";
if (row.status === "pending" && currentTab === "pending-mine") {
actions.appendChild(actionButton("approve", row.id, () => doDecision(row.id, "approve")));
actions.appendChild(actionButton("reject", row.id, () => doDecision(row.id, "reject")));
} else if (row.status === "pending" && currentTab === "mine") {
actions.appendChild(actionButton("revoke", row.id, () => doDecision(row.id, "revoke")));
} else {
// historic — show status pill
const pill = document.createElement("span");
pill.className = "approval-pill approval-pill--historic";
pill.textContent = t(("approvals.status." + row.status) as I18nKey);
if (row.decider_name && row.status !== "revoked") {
const decided = document.createElement("span");
decided.className = "inbox-row-decided";
decided.textContent = ` · ${t("approvals.decided_by")} ${row.decider_name}`;
pill.appendChild(decided);
}
actions.appendChild(pill);
}
li.appendChild(actions);
return li;
}
function renderDiff(row: ApprovalRequestView): HTMLElement | null {
const before = (row.pre_image || {}) as Record<string, unknown>;
const after = (row.payload || {}) as Record<string, unknown>;
const keys = Array.from(new Set([...Object.keys(before), ...Object.keys(after)]));
if (keys.length === 0) return null;
const wrap = document.createElement("div");
wrap.className = "inbox-row-diff";
for (const k of keys) {
const line = document.createElement("div");
line.className = "inbox-row-diff-line";
const label = document.createElement("span");
label.className = "inbox-row-diff-key";
label.textContent = k;
line.appendChild(label);
const span = document.createElement("span");
span.className = "inbox-row-diff-values";
const fmt = (v: unknown) =>
v === null || v === undefined ? "—" : String(v);
if (k in before && k in after) {
span.textContent = `${fmt(before[k])}${fmt(after[k])}`;
} else if (k in before) {
span.textContent = `${t("approvals.diff.before")}: ${fmt(before[k])}`;
} else {
span.textContent = `${t("approvals.diff.after")}: ${fmt(after[k])}`;
}
line.appendChild(span);
wrap.appendChild(line);
}
return wrap;
}
function actionButton(action: "approve" | "reject" | "revoke", _requestID: string, onClick: () => void): HTMLButtonElement {
const btn = document.createElement("button");
btn.type = "button";
btn.className = `btn btn-${action === "approve" ? "primary" : action === "reject" ? "danger" : "secondary"} inbox-row-action`;
btn.textContent = t(("approvals.action." + action) as I18nKey);
btn.addEventListener("click", onClick);
return btn;
}
async function doDecision(requestID: string, action: "approve" | "reject" | "revoke") {
let note = "";
if (action === "reject") {
note = window.prompt(t("approvals.note.placeholder")) || "";
}
let r: Response;
try {
r = await fetch(`/api/approval-requests/${requestID}/${action}`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note }),
});
} catch (_e) {
alert("Network error");
return;
}
if (!r.ok) {
const body = await r.json().catch(() => ({}));
const errKey = (body && body.error) || "internal";
const msg = mapApprovalError(errKey);
alert(msg);
return;
}
refresh();
// Update sidebar bell count.
refreshInboxBadge();
}
function mapApprovalError(key: string): string {
switch (key) {
case "self_approval_blocked":
return t("approvals.error.self_approval");
case "no_qualified_approver":
return t("approvals.error.no_qualified_approver");
case "concurrent_pending":
return t("approvals.error.concurrent_pending");
case "not_authorized":
return t("approvals.error.not_authorized");
case "request_not_pending":
return t("approvals.error.request_not_pending");
default:
return key;
}
}
function formatRelativeTime(iso: string): string {
const t0 = Date.parse(iso);
if (isNaN(t0)) return iso;
const diffMs = Date.now() - t0;
const sec = Math.floor(diffMs / 1000);
if (sec < 60) return getLang() === "de" ? `vor ${sec}s` : `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return getLang() === "de" ? `vor ${min}m` : `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return getLang() === "de" ? `vor ${hr}h` : `${hr}h ago`;
const day = Math.floor(hr / 24);
return getLang() === "de" ? `vor ${day}d` : `${day}d ago`;
}
// Update the sidebar inbox badge (shared with sidebar.ts polling).
async function refreshInboxBadge() {
const badge = document.getElementById("sidebar-inbox-badge");
if (!badge) return;
try {
const r = await fetch("/api/inbox/count", { credentials: "include" });
if (!r.ok) return;
const data = (await r.json()) as { count: number };
if (data.count > 0) {
badge.textContent = String(data.count);
badge.style.display = "";
} else {
badge.style.display = "none";
}
} catch (_e) {
/* noop */
}
}

View File

@@ -46,6 +46,35 @@ interface ProjectTeamMember {
inherited_from_title?: string | null;
}
// t-paliad-139 — derived team member from a partner-unit attachment.
// One DerivedMember per user; users in multiple attached units carry one
// DerivedMembership per (unit, role) pair so the Herkunft column can list
// every source (t-paliad-143).
interface DerivedMembership {
unit_id: string;
unit_name: string;
unit_role: string;
}
interface DerivedMember {
user_id: string;
user_email: string;
user_display_name: string;
user_office: string;
memberships: DerivedMembership[];
derive_grants_authority: boolean;
}
// t-paliad-139 — partner unit attached to this project.
interface AttachedUnit {
project_id: string;
partner_unit_id: string;
unit_name: string;
derive_unit_roles: string[];
derive_grants_authority: boolean;
derived_member_count: number;
}
interface ProjectMini {
id: string;
type: string;
@@ -71,6 +100,10 @@ interface ProjectEvent {
created_at: string;
created_by?: string;
metadata?: Record<string, unknown>;
// Populated only when the response was joined to paliad.projects (Verlauf
// subtree-aggregating queries on /projects/{id}, t-paliad-139). Used to
// render the attribution chip when the event lives on a descendant.
project_title?: string;
}
interface Deadline {
@@ -81,6 +114,10 @@ interface Deadline {
status: string;
rule_id?: string;
rule_code?: string;
// Populated by the union endpoint (/api/events) which is what the project
// detail page calls — used for attribution when the row lives on a
// descendant project (t-paliad-139).
project_title?: string;
}
interface Appointment {
@@ -91,6 +128,7 @@ interface Appointment {
end_at?: string;
location?: string;
appointment_type?: string;
project_title?: string;
}
interface Me {
@@ -161,12 +199,46 @@ let appointments: Appointment[] = [];
let ancestors: ProjectMini[] = [];
let children: ProjectMini[] = [];
let teamMembers: ProjectTeamMember[] = [];
// t-paliad-139 — additional Team-tab sections.
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 }[] = [];
const EVENTS_PAGE_SIZE = 50;
let eventsHasMore = false;
let eventsLoadingMore = false;
// Subtree aggregation mode (t-paliad-139). Default true → Fristen, Termine,
// Verlauf show rows from this project AND all descendant projects with an
// attribution chip per non-direct row. URL param `?subtree=false` flips to
// narrow (this project's own rows only).
let subtreeMode: boolean = true;
function parseSubtreeMode(): boolean {
try {
const raw = new URLSearchParams(window.location.search).get("subtree");
return raw !== "false";
} catch {
return true;
}
}
function persistSubtreeMode() {
try {
const url = new URL(window.location.href);
if (subtreeMode) {
url.searchParams.delete("subtree");
} else {
url.searchParams.set("subtree", "false");
}
window.history.replaceState({}, "", url.toString());
} catch {
// ignore
}
}
function parseProjectID(): string | null {
const parts = window.location.pathname.split("/").filter(Boolean);
if (parts[0] !== "projects" || !parts[1]) return null;
@@ -211,9 +283,18 @@ async function loadParties(id: string) {
}
}
// Build a query string suffix conveying the current subtree mode. The
// backend defaults to subtree (direct_only=false), so we only emit the
// param when the user has flipped to direct.
function subtreeParam(): string {
return subtreeMode ? "" : "&direct_only=true";
}
async function loadEvents(id: string) {
try {
const resp = await fetch(`/api/projects/${id}/events?limit=${EVENTS_PAGE_SIZE}`);
const resp = await fetch(
`/api/projects/${id}/events?limit=${EVENTS_PAGE_SIZE}${subtreeParam()}`,
);
if (resp.ok) {
events = (await resp.json()) ?? [];
eventsHasMore = events.length === EVENTS_PAGE_SIZE;
@@ -238,7 +319,7 @@ async function loadMoreEvents(id: string) {
}
try {
const resp = await fetch(
`/api/projects/${id}/events?before=${encodeURIComponent(cursor)}&limit=${EVENTS_PAGE_SIZE}`,
`/api/projects/${id}/events?before=${encodeURIComponent(cursor)}&limit=${EVENTS_PAGE_SIZE}${subtreeParam()}`,
);
if (resp.ok) {
const page: ProjectEvent[] = await resp.json();
@@ -257,10 +338,50 @@ async function loadMoreEvents(id: string) {
}
}
// Shape returned by /api/events — matches EventListItem in
// frontend/src/client/events.ts. Only the fields projects-detail needs.
interface UnionEvent {
type: "deadline" | "appointment";
id: string;
title: string;
project_id?: string;
project_title?: string;
due_date?: string;
status?: string;
rule_id?: string;
rule_code?: string;
start_at?: string;
end_at?: string;
location?: string;
appointment_type?: string;
}
async function loadDeadlines(id: string) {
try {
const resp = await fetch(`/api/projects/${id}/deadlines`);
if (resp.ok) deadlines = (await resp.json()) ?? [];
// t-paliad-139: switched from /api/projects/{id}/deadlines (legacy
// narrow path) to the union endpoint, which already aggregates
// descendants and enriches each row with project_title for the
// attribution chip.
const resp = await fetch(
`/api/events?type=deadline&project_id=${encodeURIComponent(id)}${subtreeParam()}`,
);
if (resp.ok) {
const items: UnionEvent[] = (await resp.json()) ?? [];
deadlines = items
.filter((it) => it.type === "deadline")
.map((it) => ({
id: it.id,
project_id: it.project_id ?? "",
title: it.title,
due_date: it.due_date ?? "",
status: it.status ?? "pending",
rule_id: it.rule_id,
rule_code: it.rule_code,
project_title: it.project_title,
}));
} else {
deadlines = [];
}
} catch {
deadlines = [];
}
@@ -268,8 +389,27 @@ async function loadDeadlines(id: string) {
async function loadAppointments(id: string) {
try {
const resp = await fetch(`/api/projects/${id}/appointments`);
if (resp.ok) appointments = (await resp.json()) ?? [];
// t-paliad-139: same migration as loadDeadlines.
const resp = await fetch(
`/api/events?type=appointment&project_id=${encodeURIComponent(id)}${subtreeParam()}`,
);
if (resp.ok) {
const items: UnionEvent[] = (await resp.json()) ?? [];
appointments = items
.filter((it) => it.type === "appointment")
.map((it) => ({
id: it.id,
project_id: it.project_id,
title: it.title,
start_at: it.start_at ?? "",
end_at: it.end_at,
location: it.location,
appointment_type: it.appointment_type,
project_title: it.project_title,
}));
} else {
appointments = [];
}
} catch {
appointments = [];
}
@@ -310,7 +450,7 @@ function renderAppointments() {
return `<tr class="termin-row" data-id="${esc(tt.id)}">
<td class="frist-col-check"><span class="termin-dot ${typeClass}" /></td>
<td>${esc(fmtDateTimeLocal(tt.start_at))}</td>
<td>${esc(tt.title)}</td>
<td>${esc(tt.title)}${attributionChip(tt.project_id, tt.project_title)}</td>
<td>${esc(tt.location ?? "")}</td>
<td><span class="termin-type-chip ${typeClass}">${esc(typeLabel)}</span></td>
</tr>`;
@@ -443,7 +583,7 @@ function renderDeadlines() {
aria-label="${esc(t("deadlines.complete.action"))}" />
</td>
<td class="frist-col-due ${urgency}"><span class="frist-due-dot"></span>${fmtDateOnly(f.due_date)}</td>
<td class="frist-col-title ${titleClass}">${esc(f.title)}</td>
<td class="frist-col-title ${titleClass}">${esc(f.title)}${attributionChip(f.project_id, f.project_title)}</td>
<td class="frist-col-rule">${f.rule_code ? esc(f.rule_code) : "—"}</td>
<td><span class="entity-status-chip entity-status-${esc(f.status)}">${esc(statusLabel)}</span></td>
</tr>`;
@@ -477,6 +617,19 @@ function renderDeadlines() {
});
}
// attributionChip renders a small inline chip showing which descendant
// project a row actually anchors on, when the row is from an aggregated
// subtree result and not from the project being viewed (t-paliad-139).
// Returns "" when the row's project is the current page or attribution
// data is missing.
function attributionChip(rowProjectID?: string, rowProjectTitle?: string): string {
if (!project) return "";
if (!rowProjectID || !rowProjectTitle) return "";
if (rowProjectID === project.id) return "";
const label = t("aggregation.attribution.on") || "auf";
return ` <span class="aggregation-chip" title="${escAttr(rowProjectTitle)}">${esc(label)}: ${esc(rowProjectTitle)}</span>`;
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
@@ -580,7 +733,7 @@ function renderEvents() {
return `<li class="entity-event">
<div class="entity-event-date">${fmtDateTime(e.created_at)}</div>
<div class="entity-event-body">
<div class="entity-event-title">${titleHTML}</div>
<div class="entity-event-title">${titleHTML}${attributionChip(e.project_id, e.project_title)}</div>
${description ? `<div class="entity-event-desc">${esc(description)}</div>` : ""}
</div>
</li>`;
@@ -1117,6 +1270,10 @@ async function main() {
return;
}
// Read subtree mode from URL once at startup; subsequent toggles update
// the URL via persistSubtreeMode (replaceState — back-button friendly).
subtreeMode = parseSubtreeMode();
await loadMe();
const ok = await loadProject(id);
if (!ok || !project) {
@@ -1133,6 +1290,10 @@ async function main() {
loadAncestors(id),
loadChildren(id),
loadTeam(id),
loadDescendantStaffed(id),
loadDerivedMembers(id),
loadAttachedUnits(id),
loadAllUnits(),
loadUserList(),
]);
@@ -1155,10 +1316,117 @@ async function main() {
initTeamForm(id);
initDelete();
initEventsLoadMore();
initSubtreeToggles(id);
initAttachUnitForm(id);
initNotesContainer(id);
showTab(parseTab());
}
// initAttachUnitForm wires the "Partner Unit zuordnen" form on the Team
// tab (project lead / global_admin only). The select is populated from
// /api/partner-units excluding units already attached.
function initAttachUnitForm(id: string) {
const wrap = document.getElementById("unit-attach-form-wrap");
const form = document.getElementById("unit-attach-form") as HTMLFormElement | null;
const showBtn = document.getElementById("unit-attach-show") as HTMLButtonElement | null;
const cancelBtn = document.getElementById("unit-attach-cancel") as HTMLButtonElement | null;
const select = document.getElementById("unit-attach-select") as HTMLSelectElement | null;
if (!wrap || !form || !showBtn || !cancelBtn || !select) return;
if (!canManagePartnerUnits()) {
showBtn.style.display = "none";
return;
}
const refreshSelect = () => {
const attachedIDs = new Set(attachedUnits.map((u) => u.partner_unit_id));
const placeholder = `<option value="">${esc(t("projects.team.units.choose") || "Bitte Unit wählen…")}</option>`;
const opts = allUnits
.filter((u) => !attachedIDs.has(u.id))
.map((u) => `<option value="${esc(u.id)}">${esc(u.name)}</option>`)
.join("");
select.innerHTML = placeholder + opts;
};
refreshSelect();
showBtn.addEventListener("click", () => {
refreshSelect();
wrap.style.display = "";
showBtn.style.display = "none";
});
cancelBtn.addEventListener("click", () => {
form.reset();
wrap.style.display = "none";
showBtn.style.display = "";
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
const unitID = select.value;
if (!unitID) return;
const rolePA = (document.getElementById("unit-attach-role-pa") as HTMLInputElement).checked;
const roleSenior = (document.getElementById("unit-attach-role-senior_pa") as HTMLInputElement).checked;
const roleAtty = (document.getElementById("unit-attach-role-attorney") as HTMLInputElement).checked;
const grantsAuthority = (document.getElementById("unit-attach-authority") as HTMLInputElement).checked;
const roles: string[] = [];
if (rolePA) roles.push("pa");
if (roleSenior) roles.push("senior_pa");
if (roleAtty) roles.push("attorney");
if (roles.length === 0) {
// Defaults: pa + senior_pa.
roles.push("pa", "senior_pa");
}
const resp = await fetch(`/api/projects/${id}/partner-units`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
partner_unit_id: unitID,
derive_unit_roles: roles,
derive_grants_authority: grantsAuthority,
}),
});
if (resp.ok) {
form.reset();
wrap.style.display = "none";
showBtn.style.display = "";
await Promise.all([loadAttachedUnits(id), loadDerivedMembers(id)]);
renderTeam();
}
});
}
// initSubtreeToggles wires the "Inkl. Unterprojekte / Nur direkt" buttons
// in the History, Deadlines, and Appointments sections. State is shared
// across the three sections (one toggle flips all) and persisted in the
// URL via ?subtree=false. Default = subtree (true).
function initSubtreeToggles(id: string) {
const buttons = document.querySelectorAll<HTMLButtonElement>(".subtree-toggle");
if (buttons.length === 0) return;
const refreshLabels = () => {
buttons.forEach((btn) => {
btn.textContent = subtreeMode
? t("aggregation.toggle.subtree")
: t("aggregation.toggle.direct_only");
btn.setAttribute("aria-pressed", subtreeMode ? "true" : "false");
btn.classList.toggle("subtree-toggle--active", !subtreeMode);
});
};
refreshLabels();
buttons.forEach((btn) => {
btn.addEventListener("click", async () => {
subtreeMode = !subtreeMode;
persistSubtreeMode();
refreshLabels();
await Promise.all([loadEvents(id), loadDeadlines(id), loadAppointments(id)]);
renderEvents();
renderDeadlines();
renderAppointments();
});
});
}
// ----- Breadcrumb + ancestor resolution -----------------------------------
function inheritedClientNumber(): string | null {
@@ -1310,6 +1578,59 @@ async function loadTeam(id: string) {
}
}
// t-paliad-139 — Team-tab subsection loaders. All three are independent so
// main() runs them in parallel.
async function loadDescendantStaffed(id: string) {
try {
const resp = await fetch(`/api/projects/${id}/team/from-descendants`);
if (resp.ok) {
descendantStaffed = ((await resp.json()) as ProjectTeamMember[]) ?? [];
} else {
descendantStaffed = [];
}
} catch {
descendantStaffed = [];
}
}
async function loadDerivedMembers(id: string) {
try {
const resp = await fetch(`/api/projects/${id}/team/derived`);
if (resp.ok) {
derivedMembers = ((await resp.json()) as DerivedMember[]) ?? [];
} else {
derivedMembers = [];
}
} catch {
derivedMembers = [];
}
}
async function loadAttachedUnits(id: string) {
try {
const resp = await fetch(`/api/projects/${id}/partner-units`);
if (resp.ok) {
attachedUnits = ((await resp.json()) as AttachedUnit[]) ?? [];
} else {
attachedUnits = [];
}
} catch {
attachedUnits = [];
}
}
async function loadAllUnits() {
try {
const resp = await fetch(`/api/partner-units`);
if (resp.ok) {
const all = (await resp.json()) as { id: string; name: string; office: string }[];
allUnits = all ?? [];
}
} catch {
allUnits = [];
}
}
async function loadUserList() {
try {
const resp = await fetch("/api/users");
@@ -1322,12 +1643,23 @@ async function loadUserList() {
function renderTeam() {
const body = document.getElementById("team-body")!;
const empty = document.getElementById("team-empty")!;
if (!teamMembers.length) {
// Existing team-body shows the direct + ancestor-inherited members
// returned by /api/projects/{id}/team. The derived + descendant
// sections render into separate tbodies (added in TSX). Empty state
// applies to the union — only show when EVERY section is empty.
const totalRows =
teamMembers.length + descendantStaffed.length + derivedMembers.length;
if (totalRows === 0) {
body.innerHTML = "";
empty.style.display = "";
renderDescendantStaffed();
renderDerivedMembers();
renderAttachedUnits();
return;
}
empty.style.display = "none";
body.innerHTML = teamMembers
.map((m) => {
const roleLabel = tDyn(`projects.team.role.${m.role}`) || m.role;
@@ -1366,6 +1698,145 @@ function renderTeam() {
}
});
});
renderDescendantStaffed();
renderDerivedMembers();
renderAttachedUnits();
}
// t-paliad-139 — "Aus Unterprojekten" subsection.
function renderDescendantStaffed() {
const section = document.getElementById("team-section-descendants");
const body = document.getElementById("team-descendants-body");
if (!section || !body) return;
if (descendantStaffed.length === 0) {
section.style.display = "none";
body.innerHTML = "";
return;
}
section.style.display = "";
body.innerHTML = descendantStaffed
.map((m) => {
const roleLabel = tDyn(`projects.team.role.${m.role}`) || m.role;
const officeLabel = m.user_office ? tDyn("office." + m.user_office) || m.user_office : "";
const sourceTitle = esc(m.inherited_from_title || "");
return `<tr>
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
<span class="form-hint">&middot; ${esc(m.user_email)}${officeLabel ? " &middot; " + esc(officeLabel) : ""}</span></td>
<td><span class="projekt-team-role">${esc(roleLabel)}</span></td>
<td><span class="projekt-team-inherited" title="${escAttr(t("aggregation.attribution.on") || "auf")}: ${sourceTitle}">&darr; ${sourceTitle}</span></td>
</tr>`;
})
.join("");
}
// t-paliad-139 — "Abgeleitet (Partner Unit)" subsection.
function renderDerivedMembers() {
const section = document.getElementById("team-section-derived");
const body = document.getElementById("team-derived-body");
if (!section || !body) return;
if (derivedMembers.length === 0) {
section.style.display = "none";
body.innerHTML = "";
return;
}
section.style.display = "";
body.innerHTML = derivedMembers
.map((m) => {
const memberships = m.memberships || [];
// Role column shows distinct unit_role values (usually one — only
// diverges if the user has different roles in different units).
const distinctRoles = Array.from(new Set(memberships.map((x) => x.unit_role)));
const roleLabel = distinctRoles
.map((r) => tDyn(`unit_role.${r}`) || r)
.join(", ");
// Herkunft column lists every (unit, role) pair so multi-unit users
// surface all their sources, not just the closest one (t-paliad-143).
// Multi-unit: bold each unit name and append the role in parentheses.
// Single-unit: bold the one unit name (matches the legacy rendering).
const sourceLabel = memberships
.map((x) => {
const name = `<strong>${esc(x.unit_name)}</strong>`;
if (memberships.length === 1) return name;
const role = esc(tDyn(`unit_role.${x.unit_role}`) || x.unit_role);
return `${name} (${role})`;
})
.join(", ");
const officeLabel = m.user_office ? tDyn("office." + m.user_office) || m.user_office : "";
const authBadge = m.derive_grants_authority
? `<span class="derived-badge derived-badge--authority" title="${escAttr(t("projects.team.derived.authority.hint") || "Authority granted")}">${esc(t("projects.team.derived.authority") || "Sicht & 4-Augen")}</span>`
: `<span class="derived-badge">${esc(t("projects.team.derived.visibility") || "Sicht")}</span>`;
return `<tr>
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
<span class="form-hint">&middot; ${esc(m.user_email)}${officeLabel ? " &middot; " + esc(officeLabel) : ""}</span></td>
<td><span class="projekt-team-role">${esc(roleLabel)}</span></td>
<td>${esc(t("projects.team.derived.from") || "über")}: ${sourceLabel} ${authBadge}</td>
</tr>`;
})
.join("");
}
// t-paliad-139 — Partner Units management section. Lists attached units
// with detach buttons; admin/lead can add new attachments.
function renderAttachedUnits() {
const section = document.getElementById("team-section-units");
const body = document.getElementById("team-units-body");
if (!section || !body) return;
const canManage = canManagePartnerUnits();
// Always show the section to admins/leads (even if empty so they can attach).
if (!canManage && attachedUnits.length === 0) {
section.style.display = "none";
return;
}
section.style.display = "";
if (attachedUnits.length === 0) {
body.innerHTML = `<tr><td colspan="4" class="form-hint">${esc(t("projects.team.units.empty") || "Keine Partner Units zugeordnet.")}</td></tr>`;
return;
}
body.innerHTML = attachedUnits
.map((u) => {
const roles = (u.derive_unit_roles || []).map((r) => tDyn(`unit_role.${r}`) || r).join(", ");
const auth = u.derive_grants_authority
? esc(t("projects.team.derived.authority") || "Sicht & 4-Augen")
: esc(t("projects.team.derived.visibility") || "Sicht");
const detachBtn = canManage
? `<button type="button" class="btn-ghost btn-small unit-detach-btn" data-unit-id="${esc(u.partner_unit_id)}">${esc(t("projects.team.units.detach") || "Entfernen")}</button>`
: "";
return `<tr>
<td><strong>${esc(u.unit_name)}</strong></td>
<td>${esc(roles)}</td>
<td>${auth}</td>
<td>${u.derived_member_count} ${esc(t("projects.team.units.members") || "Mitglieder")} ${detachBtn}</td>
</tr>`;
})
.join("");
body.querySelectorAll<HTMLButtonElement>(".unit-detach-btn").forEach((btn) => {
btn.addEventListener("click", async () => {
if (!project) return;
const unitID = btn.dataset.unitId!;
if (!window.confirm(t("projects.team.units.confirm_detach") || "Partner Unit entfernen?")) return;
const resp = await fetch(
`/api/projects/${project.id}/partner-units/${encodeURIComponent(unitID)}`,
{ method: "DELETE" },
);
if (resp.ok) {
await Promise.all([loadAttachedUnits(project.id), loadDerivedMembers(project.id)]);
renderTeam();
}
});
});
}
// canManagePartnerUnits returns true for global_admin or this project's
// lead. Mirrors the migration-055 RLS write policy.
function canManagePartnerUnits(): boolean {
if (!me) return false;
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,
);
}
function canRemoveTeamMember(m: ProjectTeamMember): boolean {
@@ -1383,8 +1854,24 @@ function initTeamForm(id: string) {
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 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;
const hideInviteHint = () => {
if (inviteHint) inviteHint.style.display = "none";
};
const showInviteHint = (q: string) => {
if (!inviteHint || !inviteHintText) return;
const looksLikeEmail = /@/.test(q) && /\./.test(q.split("@")[1] || "");
inviteHintText.textContent = looksLikeEmail
? t("projects.detail.team.invite.hint_email") || "Niemand mit dieser E-Mail."
: t("projects.detail.team.invite.hint") || "Benutzer nicht gefunden?";
inviteHint.dataset.email = looksLikeEmail ? q : "";
inviteHint.style.display = "";
};
addBtn.addEventListener("click", () => {
form.style.display = "";
addBtn.style.display = "none";
@@ -1396,18 +1883,21 @@ function initTeamForm(id: string) {
input.value = "";
hidden.value = "";
sugs.innerHTML = "";
hideInviteHint();
msg.textContent = "";
});
input.addEventListener("input", () => {
const q = input.value.trim().toLowerCase();
const q = input.value.trim();
const lc = q.toLowerCase();
hidden.value = "";
if (!q) {
sugs.innerHTML = "";
hideInviteHint();
return;
}
const matches = userOptions
.filter((u) => (u.display_name + " " + u.email).toLowerCase().includes(q))
.filter((u) => (u.display_name + " " + u.email).toLowerCase().includes(lc))
.slice(0, 8);
sugs.innerHTML = matches
.map(
@@ -1422,8 +1912,29 @@ function initTeamForm(id: string) {
hidden.value = el.dataset.id!;
input.value = el.dataset.label!;
sugs.innerHTML = "";
hideInviteHint();
});
});
if (matches.length === 0) {
showInviteHint(q);
} else {
hideInviteHint();
}
});
inviteBtn?.addEventListener("click", () => {
const sidebarBtn = document.getElementById("sidebar-invite-btn") as HTMLButtonElement | null;
if (!sidebarBtn) return;
sidebarBtn.click();
const prefill = inviteHint?.dataset.email || "";
if (prefill) {
const inviteEmail = document.getElementById("invite-email") as HTMLInputElement | null;
if (inviteEmail) {
inviteEmail.value = prefill;
inviteEmail.dispatchEvent(new Event("input", { bubbles: true }));
}
}
});
form.addEventListener("submit", async (e) => {
@@ -1446,6 +1957,7 @@ function initTeamForm(id: string) {
input.value = "";
hidden.value = "";
sugs.innerHTML = "";
hideInviteHint();
form.style.display = "none";
addBtn.style.display = "";
await loadTeam(id);

View File

@@ -70,6 +70,7 @@ export function initSidebar() {
initInviteModal();
initGlobalSearch();
initChangelogBadge();
initInboxBadge();
initAdminGroup();
initThemeToggle();
const sidebar = document.querySelector<HTMLElement>(".sidebar");
@@ -314,6 +315,33 @@ function initChangelogBadge(): void {
});
}
// Inbox badge (t-paliad-138) — count of approval requests where the
// current user is qualified to approve. Polls every 60s while the page
// is open. Silently swallows errors (badge is optional).
function initInboxBadge(): void {
const badge = document.getElementById("sidebar-inbox-badge") as HTMLElement | null;
if (!badge) return;
const refresh = () => {
fetch("/api/inbox/count", { credentials: "same-origin" })
.then((r) => (r.ok ? r.json() : null))
.then((data: { count?: number } | null) => {
if (!data || typeof data.count !== "number" || data.count <= 0) {
badge.style.display = "none";
return;
}
badge.textContent = data.count > 9 ? "9+" : String(data.count);
badge.style.display = "";
})
.catch(() => {
/* silent */
});
};
refresh();
setInterval(refresh, 60_000);
}
// initThemeToggle wires the sun/moon button at the bottom of the sidebar
// (m/paliad#2). The pre-paint inline script in PWAHead.tsx already set
// the data-theme attribute on <html>; this function only owns the post-

View File

@@ -25,6 +25,8 @@ const ICON_SPARKLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
const ICON_USERS = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>';
const ICON_SHIELD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>';
const ICON_AUDIT_LOG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="15" y2="17"/></svg>';
// Bell icon for the /inbox entry (t-paliad-138 4-eye approval inbox).
const ICON_BELL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
// Theme-toggle icons. The button cycles auto → light → dark → auto, and
// the icon swaps to reflect the *current* preference (auto/light/dark)
// — not the eventual click target. SSR renders the auto variant; the
@@ -44,7 +46,7 @@ interface SidebarProps {
authenticated?: boolean;
}
function navItem(href: string, icon: string, i18nKey: string, label: string, currentPath: string): string {
function navItem(href: string, icon: string, i18nKey: string, label: string, currentPath: string, badgeID?: string): string {
// "Active" is true for the item whose href is a prefix of currentPath.
// That way sub-routes like /projekte/{id}/events keep the /projekte entry lit.
// /akten and /akten/* are kept as legacy aliases and also highlight /projekte
@@ -55,6 +57,7 @@ function navItem(href: string, icon: string, i18nKey: string, label: string, cur
<a href={href} className={`sidebar-item${active ? " active" : ""}`}>
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: icon }} />
<span className="sidebar-label" data-i18n={i18nKey}>{label}</span>
{badgeID ? <span className="sidebar-badge" id={badgeID} style="display:none" aria-hidden="true" /> : ""}
</a>
);
}
@@ -112,6 +115,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
{group("nav.group.uebersicht", "\u00DCbersicht",
navItem("/dashboard", ICON_GAUGE, "nav.dashboard", "Dashboard", currentPath) +
navItem("/agenda", ICON_AGENDA, "nav.agenda", "Agenda", currentPath) +
navItem("/inbox", ICON_BELL, "nav.inbox", "Inbox", currentPath, "sidebar-inbox-badge") +
navItem("/team", ICON_USERS, "nav.team", "Team", currentPath),
)}

View File

@@ -167,6 +167,7 @@ export type I18nKey =
| "admin.partner_units.error.user_required"
| "admin.partner_units.feedback.created"
| "admin.partner_units.feedback.deleted"
| "admin.partner_units.feedback.role_updated"
| "admin.partner_units.feedback.updated"
| "admin.partner_units.heading"
| "admin.partner_units.loading"
@@ -177,6 +178,7 @@ export type I18nKey =
| "admin.partner_units.member.heading"
| "admin.partner_units.member.placeholder"
| "admin.partner_units.member.remove"
| "admin.partner_units.member.role"
| "admin.partner_units.new"
| "admin.partner_units.new.heading"
| "admin.partner_units.subtitle"
@@ -254,6 +256,9 @@ export type I18nKey =
| "agenda.urgency.this_week"
| "agenda.urgency.today"
| "agenda.urgency.tomorrow"
| "aggregation.attribution.on"
| "aggregation.toggle.direct_only"
| "aggregation.toggle.subtree"
| "appointments.col.akte"
| "appointments.col.location"
| "appointments.col.start"
@@ -318,6 +323,57 @@ export type I18nKey =
| "appointments.type.hearing"
| "appointments.type.meeting"
| "appointments.unavailable"
| "approvals.action.approve"
| "approvals.action.reject"
| "approvals.action.revoke"
| "approvals.decided_by"
| "approvals.decision_kind.admin_override"
| "approvals.decision_kind.derived_peer"
| "approvals.decision_kind.peer"
| "approvals.diff.after"
| "approvals.diff.before"
| "approvals.empty.mine"
| "approvals.empty.pending_mine"
| "approvals.entity.appointment"
| "approvals.entity.deadline"
| "approvals.error.concurrent_pending"
| "approvals.error.no_qualified_approver"
| "approvals.error.not_authorized"
| "approvals.error.request_not_pending"
| "approvals.error.self_approval"
| "approvals.heading"
| "approvals.lifecycle.complete"
| "approvals.lifecycle.create"
| "approvals.lifecycle.delete"
| "approvals.lifecycle.update"
| "approvals.note.placeholder"
| "approvals.pending_complete.label"
| "approvals.pending_create.label"
| "approvals.pending_delete.label"
| "approvals.pending_update.label"
| "approvals.policies.column.appointment"
| "approvals.policies.column.deadline"
| "approvals.policies.column.event"
| "approvals.policies.copy_parent"
| "approvals.policies.no_approval"
| "approvals.policies.set_all_associate"
| "approvals.policies.subtitle"
| "approvals.policies.title"
| "approvals.requested_by"
| "approvals.required_role.associate"
| "approvals.required_role.lead"
| "approvals.required_role.of_counsel"
| "approvals.required_role.pa"
| "approvals.required_role.senior_pa"
| "approvals.status.approved"
| "approvals.status.pending"
| "approvals.status.rejected"
| "approvals.status.revoked"
| "approvals.status.superseded"
| "approvals.subtitle"
| "approvals.tab.mine"
| "approvals.tab.pending_mine"
| "approvals.title"
| "bottomnav.add"
| "bottomnav.add.appointment"
| "bottomnav.add.appointment.sub"
@@ -458,6 +514,10 @@ export type I18nKey =
| "common.cancel"
| "dashboard.action.short.akte_archived"
| "dashboard.action.short.akte_created"
| "dashboard.action.short.appointment_approval_approved"
| "dashboard.action.short.appointment_approval_rejected"
| "dashboard.action.short.appointment_approval_requested"
| "dashboard.action.short.appointment_approval_revoked"
| "dashboard.action.short.appointment_created"
| "dashboard.action.short.appointment_deleted"
| "dashboard.action.short.appointment_project_changed"
@@ -475,6 +535,10 @@ export type I18nKey =
| "dashboard.action.short.checkliste_reset"
| "dashboard.action.short.checkliste_unlinked"
| "dashboard.action.short.collaborators_updated"
| "dashboard.action.short.deadline_approval_approved"
| "dashboard.action.short.deadline_approval_rejected"
| "dashboard.action.short.deadline_approval_requested"
| "dashboard.action.short.deadline_approval_revoked"
| "dashboard.action.short.deadline_completed"
| "dashboard.action.short.deadline_created"
| "dashboard.action.short.deadline_deleted"
@@ -847,10 +911,18 @@ export type I18nKey =
| "einstellungen.tab.caldav"
| "einstellungen.tab.profil"
| "einstellungen.title"
| "event.description.appointment_approval_approved"
| "event.description.appointment_approval_rejected"
| "event.description.appointment_approval_requested"
| "event.description.appointment_approval_revoked"
| "event.description.appointment_created"
| "event.description.appointment_deleted"
| "event.description.appointment_project_changed"
| "event.description.appointment_updated"
| "event.description.deadline_approval_approved"
| "event.description.deadline_approval_rejected"
| "event.description.deadline_approval_requested"
| "event.description.deadline_approval_revoked"
| "event.description.deadline_completed"
| "event.description.deadline_created"
| "event.description.deadline_deleted"
@@ -862,6 +934,10 @@ export type I18nKey =
| "event.note.parent.appointment"
| "event.note.parent.deadline"
| "event.note.parent.project"
| "event.title.appointment_approval_approved"
| "event.title.appointment_approval_rejected"
| "event.title.appointment_approval_requested"
| "event.title.appointment_approval_revoked"
| "event.title.appointment_created"
| "event.title.appointment_deleted"
| "event.title.appointment_project_changed"
@@ -872,6 +948,10 @@ export type I18nKey =
| "event.title.checklist_renamed"
| "event.title.checklist_reset"
| "event.title.checklist_unlinked"
| "event.title.deadline_approval_approved"
| "event.title.deadline_approval_rejected"
| "event.title.deadline_approval_requested"
| "event.title.deadline_approval_revoked"
| "event.title.deadline_completed"
| "event.title.deadline_created"
| "event.title.deadline_deleted"
@@ -1217,6 +1297,7 @@ export type I18nKey =
| "nav.group.werkzeuge"
| "nav.group.wissen"
| "nav.home"
| "nav.inbox"
| "nav.kostenrechner"
| "nav.links"
| "nav.logout"
@@ -1372,6 +1453,9 @@ export type I18nKey =
| "projects.detail.team.form.role"
| "projects.detail.team.form.submit"
| "projects.detail.team.form.user"
| "projects.detail.team.invite.cta"
| "projects.detail.team.invite.hint"
| "projects.detail.team.invite.hint_email"
| "projects.detail.team.remove"
| "projects.detail.title"
| "projects.detail.verlauf.empty"
@@ -1442,6 +1526,10 @@ export type I18nKey =
| "projects.status.completed"
| "projects.submit"
| "projects.subtitle"
| "projects.team.derived.authority"
| "projects.team.derived.authority.hint"
| "projects.team.derived.from"
| "projects.team.derived.visibility"
| "projects.team.direct"
| "projects.team.inherited.hint"
| "projects.team.role.associate"
@@ -1451,6 +1539,24 @@ export type I18nKey =
| "projects.team.role.observer"
| "projects.team.role.of_counsel"
| "projects.team.role.pa"
| "projects.team.section.derived"
| "projects.team.section.derived.hint"
| "projects.team.section.from_descendants"
| "projects.team.section.from_descendants.hint"
| "projects.team.section.units"
| "projects.team.section.units.hint"
| "projects.team.units.attach"
| "projects.team.units.choose"
| "projects.team.units.col.authority"
| "projects.team.units.col.derive_roles"
| "projects.team.units.col.name"
| "projects.team.units.confirm_detach"
| "projects.team.units.derive_roles"
| "projects.team.units.detach"
| "projects.team.units.empty"
| "projects.team.units.grants_authority"
| "projects.team.units.members"
| "projects.team.units.select"
| "projects.title"
| "projects.tree.deadlines.open"
| "projects.tree.deadlines.overdue"
@@ -1505,4 +1611,9 @@ export type I18nKey =
| "theme.toggle.cycle.dark"
| "theme.toggle.cycle.light"
| "theme.toggle.dark"
| "theme.toggle.light";
| "theme.toggle.light"
| "unit_role.attorney"
| "unit_role.lead"
| "unit_role.pa"
| "unit_role.paralegal"
| "unit_role.senior_pa";

61
frontend/src/inbox.tsx Normal file
View File

@@ -0,0 +1,61 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// Approval inbox page (t-paliad-138). Two-tab UI:
// - "Zur Genehmigung": requests where the caller is qualified to approve
// - "Meine Anfragen": requests submitted by the caller
//
// Hydrates lazily on load (no inline payload) — unlike the dashboard, the
// inbox doesn't carry SSR state. The client bundle calls /api/inbox/* on
// hydration and re-renders.
export function renderInbox(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<PWAHead />
<title data-i18n="approvals.title">Genehmigungen &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/inbox" />
<BottomNav currentPath="/inbox" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<h1 data-i18n="approvals.heading">Genehmigungen</h1>
<p className="tool-subtitle" data-i18n="approvals.subtitle">
4-Augen-Pr&uuml;fung f&uuml;r Fristen und Termine.
</p>
</div>
<div className="agenda-controls">
<div className="agenda-filter-group" role="group">
<div className="agenda-chip-row" id="inbox-tab-row">
<button type="button" className="agenda-chip active" data-tab="pending-mine" data-i18n="approvals.tab.pending_mine">Zur Genehmigung</button>
<button type="button" className="agenda-chip" data-tab="mine" data-i18n="approvals.tab.mine">Meine Anfragen</button>
</div>
</div>
</div>
<div className="agenda-loading" id="inbox-loading" data-i18n="agenda.loading">L&auml;dt &hellip;</div>
<div className="entity-empty" id="inbox-empty" style="display:none" />
<ul className="inbox-list" id="inbox-list" />
</div>
</section>
<Footer />
</main>
<script src="/assets/inbox.js" defer />
</body>
</html>
);
}

View File

@@ -83,6 +83,11 @@ export function renderProjectsDetail(): string {
{/* History (Verlauf) */}
<section className="entity-tab-panel" id="tab-history">
<div className="party-controls">
<button type="button" className="btn-secondary btn-small subtree-toggle" aria-pressed="false">
Inkl. Unterprojekte
</button>
</div>
<ul className="entity-events" id="project-events-list" />
<p className="entity-events-empty" id="project-events-empty" style="display:none" data-i18n="projects.detail.verlauf.empty">
Noch keine Ereignisse aufgezeichnet.
@@ -109,6 +114,10 @@ export function renderProjectsDetail(): string {
<input type="text" id="team-user-input" placeholder="Name oder E-Mail..." autocomplete="off" />
<input type="hidden" id="team-user-id" />
<div id="team-user-suggestions" className="collab-suggestions" />
<div id="team-user-invite-hint" className="collab-invite-hint" style="display:none">
<span id="team-user-invite-hint-text" data-i18n="projects.detail.team.invite.hint">Benutzer nicht gefunden?</span>
<button type="button" className="btn-secondary btn-small" id="team-user-invite-btn" data-i18n="projects.detail.team.invite.cta">Einladen</button>
</div>
</div>
<div className="form-field">
<label htmlFor="team-role" data-i18n="projects.detail.team.form.role">Rolle</label>
@@ -145,6 +154,101 @@ export function renderProjectsDetail(): string {
<p className="entity-events-empty" id="team-empty" style="display:none" data-i18n="projects.detail.team.empty">
Noch keine Teammitglieder.
</p>
{/* t-paliad-139 — Aus Unterprojekten subsection. */}
<div id="team-section-descendants" style="display:none">
<h3 className="entity-section-heading" data-i18n="projects.team.section.from_descendants">
Aus Unterprojekten
</h3>
<p className="form-hint" data-i18n="projects.team.section.from_descendants.hint">
Personen, die direkt auf einem Unterprojekt eingetragen sind und nicht auf diesem oder einem &Uuml;bergeordneten.
</p>
<table className="party-table">
<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.source">Herkunft</th>
</tr>
</thead>
<tbody id="team-descendants-body" />
</table>
</div>
{/* t-paliad-139 — Abgeleitet (Partner Unit) subsection. */}
<div id="team-section-derived" style="display:none">
<h3 className="entity-section-heading" data-i18n="projects.team.section.derived">
Abgeleitet (Partner Unit)
</h3>
<p className="form-hint" data-i18n="projects.team.section.derived.hint">
Mitglieder, die &uuml;ber eine zugeordnete Partner Unit auf diesem Projekt aktiv sind.
</p>
<table className="party-table">
<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.source">Herkunft</th>
</tr>
</thead>
<tbody id="team-derived-body" />
</table>
</div>
{/* t-paliad-139 — Partner Units management. */}
<div id="team-section-units" style="display:none">
<h3 className="entity-section-heading" data-i18n="projects.team.section.units">
Partner Units
</h3>
<p className="form-hint" data-i18n="projects.team.section.units.hint">
Partner Units, die auf diesem Projekt eingebunden sind. Mitglieder mit passenden Unit-Rollen werden automatisch abgeleitet.
</p>
<div className="party-controls">
<button type="button" id="unit-attach-show" className="btn-primary btn-cta-lime btn-small" data-i18n="projects.team.units.attach">
Partner Unit zuordnen
</button>
</div>
<div id="unit-attach-form-wrap" style="display:none">
<form id="unit-attach-form" className="entity-form party-form" autocomplete="off">
<div className="form-field-row">
<div className="form-field">
<label htmlFor="unit-attach-select" data-i18n="projects.team.units.select">Unit</label>
<select id="unit-attach-select" required />
</div>
</div>
<fieldset className="form-field">
<legend data-i18n="projects.team.units.derive_roles">Welche Unit-Rollen ableiten?</legend>
<label className="form-checkbox">
<input type="checkbox" id="unit-attach-role-pa" checked /> <span data-i18n="unit_role.pa">PA</span>
</label>
<label className="form-checkbox">
<input type="checkbox" id="unit-attach-role-senior_pa" checked /> <span data-i18n="unit_role.senior_pa">Senior PA</span>
</label>
<label className="form-checkbox">
<input type="checkbox" id="unit-attach-role-attorney" /> <span data-i18n="unit_role.attorney">Attorney</span>
</label>
</fieldset>
<label className="form-checkbox">
<input type="checkbox" id="unit-attach-authority" /> <span data-i18n="projects.team.units.grants_authority">Stimmrecht abgeben (4-Augen)</span>
</label>
<div className="form-actions">
<button type="button" className="btn-cancel" id="unit-attach-cancel" data-i18n="projects.detail.team.form.cancel">Abbrechen</button>
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="projects.team.units.attach">Zuordnen</button>
</div>
</form>
</div>
<table className="party-table">
<thead>
<tr>
<th data-i18n="projects.team.units.col.name">Unit</th>
<th data-i18n="projects.team.units.col.derive_roles">Abgeleitete Rollen</th>
<th data-i18n="projects.team.units.col.authority">Authority</th>
<th />
</tr>
</thead>
<tbody id="team-units-body" />
</table>
</div>
</section>
{/* Children (Untergeordnet) */}
@@ -222,6 +326,9 @@ export function renderProjectsDetail(): string {
<a id="deadline-add-link" className="btn-primary btn-cta-lime btn-small" data-i18n="projects.detail.deadlines.add" href="#">
Frist hinzuf&uuml;gen
</a>
<button type="button" className="btn-secondary btn-small subtree-toggle" aria-pressed="false">
Inkl. Unterprojekte
</button>
</div>
<div className="entity-table-wrap" id="project-deadlines-tablewrap">
<table className="entity-table fristen-table">
@@ -248,6 +355,9 @@ export function renderProjectsDetail(): string {
<button type="button" id="appointment-add-btn" className="btn-primary btn-cta-lime btn-small" data-i18n="projects.detail.appointments.add">
Termin hinzuf&uuml;gen
</button>
<button type="button" className="btn-secondary btn-small subtree-toggle" aria-pressed="false">
Inkl. Unterprojekte
</button>
</div>
<form id="project-appointment-form" className="party-form" style="display:none">

View File

@@ -714,8 +714,16 @@ main {
--color-accent-fg: var(--sidebar-text-active);
}
/* `:root.sidebar-pinned .sidebar` is the pre-paint companion to
`.sidebar.pinned` (t-paliad-142). The FOUC script in PWAHead.tsx sets
the html class before paint, so the sidebar element renders at pinned
width from frame 1; runtime initSidebar later mirrors `.pinned` onto
the element itself for the explicit pin/unpin click animation. Same
pattern as the `.has-sidebar.sidebar-pinned` / `:root.sidebar-pinned
.has-sidebar` pair below. */
.sidebar.expanded,
.sidebar.pinned {
.sidebar.pinned,
:root.sidebar-pinned .sidebar {
width: var(--sidebar-width);
}
@@ -765,7 +773,8 @@ main {
}
.sidebar.expanded .sidebar-pin,
.sidebar.pinned .sidebar-pin {
.sidebar.pinned .sidebar-pin,
:root.sidebar-pinned .sidebar .sidebar-pin {
opacity: 1;
pointer-events: auto;
}
@@ -781,7 +790,8 @@ main {
display: block;
}
.sidebar.pinned .sidebar-pin {
.sidebar.pinned .sidebar-pin,
:root.sidebar-pinned .sidebar .sidebar-pin {
color: var(--sidebar-text-active);
}
@@ -806,7 +816,8 @@ main {
}
.sidebar.expanded .sidebar-resize-handle,
.sidebar.pinned .sidebar-resize-handle {
.sidebar.pinned .sidebar-resize-handle,
:root.sidebar-pinned .sidebar .sidebar-resize-handle {
opacity: 1;
pointer-events: auto;
}
@@ -922,7 +933,8 @@ main {
}
.sidebar.expanded .sidebar-label,
.sidebar.pinned .sidebar-label {
.sidebar.pinned .sidebar-label,
:root.sidebar-pinned .sidebar .sidebar-label {
opacity: 1;
}
@@ -5250,7 +5262,8 @@ input[type="range"]::-moz-range-thumb {
.sidebar.expanded .sidebar-group-label,
.sidebar.pinned .sidebar-group-label,
.sidebar.mobile-open .sidebar-group-label {
.sidebar.mobile-open .sidebar-group-label,
:root.sidebar-pinned .sidebar .sidebar-group-label {
opacity: 0.75;
}
@@ -5856,6 +5869,17 @@ input[type="range"]::-moz-range-thumb {
padding: 0;
}
/* `.collab-suggestions` is absolute-positioned with top: 100% — needs a
positioned ancestor or it falls back to the viewport's initial
containing block and renders off-screen (text is in the DOM, but
visually invisible — m hit this on the project team-add picker after
t-141 made the dropdown content-visible but still let it float to
nowhere). Scoped via :has() so we only flip the parent's position
where it actually contains a suggestions dropdown. */
.form-field:has(> .collab-suggestions) {
position: relative;
}
.collab-suggestions {
position: absolute;
top: 100%;
@@ -5872,6 +5896,61 @@ input[type="range"]::-moz-range-thumb {
z-index: 10;
}
/* Visibility is content-driven: when innerHTML is "" the div has no children
and stays hidden; the moment a consumer renders <.collab-suggestion> rows
the dropdown shows. Keeps the JS sites (project team-add, project parent
picker, partner-units member-add) from each having to toggle display. */
.collab-suggestions:not(:empty) {
display: block;
}
.collab-suggestion {
display: block;
padding: 0.5rem 0.75rem;
cursor: pointer;
border-bottom: 1px solid var(--color-border);
}
.collab-suggestion:last-child {
border-bottom: none;
}
.collab-suggestion:hover,
.collab-suggestion.is-active {
background: var(--color-bg-lime-tint);
}
.collab-suggestion strong {
display: block;
font-weight: 600;
font-size: 0.9rem;
}
.collab-suggestion .form-hint {
display: block;
font-size: 0.8rem;
margin-top: 0.1rem;
}
/* Inline "invite this user instead" affordance shown beneath an empty
.collab-suggestions when the typed query has no matches. */
.collab-invite-hint {
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--color-surface-alt, var(--color-bg-lime-tint));
border: 1px dashed var(--color-border);
border-radius: var(--radius);
font-size: 0.85rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.collab-invite-hint button {
flex-shrink: 0;
}
.entity-suggestion {
width: 100%;
display: flex;
@@ -6128,6 +6207,71 @@ input[type="range"]::-moz-range-thumb {
font-size: 0.82rem;
}
/* t-paliad-139 — subtree aggregation toggle (Inkl. Unterprojekte / Nur direkt).
Lives in .party-controls beside the section CTA. The active state shows
"Nur direkt" with a darker chrome so the user can see at a glance that
they've narrowed the view. Default (subtree) keeps standard secondary
styling. */
.subtree-toggle {
margin-left: 0.5rem;
}
.subtree-toggle--active {
background: var(--color-accent-soft, var(--color-surface));
border-color: var(--color-accent, var(--color-border));
color: var(--color-text);
}
/* Attribution chip — shows the descendant project a row anchors on when
the project detail page is aggregating. Inline-flush with the title cell;
readable on both light and dark themes. */
.aggregation-chip {
display: inline-block;
margin-left: 0.5rem;
padding: 0.1rem 0.45rem;
border-radius: 9999px;
background: var(--color-bg-muted, rgba(0, 0, 0, 0.04));
color: var(--color-text-muted, #555);
font-size: 0.75rem;
font-weight: normal;
vertical-align: baseline;
white-space: nowrap;
}
/* t-paliad-139 Phase 2 — Team-tab subsection headings. */
.entity-section-heading {
margin-top: 1.5rem;
margin-bottom: 0.25rem;
font-size: 1rem;
font-weight: 600;
color: var(--color-text);
}
/* Derived-membership badges. */
.derived-badge {
display: inline-block;
margin-left: 0.4rem;
padding: 0.05rem 0.45rem;
border-radius: 9999px;
background: var(--color-bg-muted, rgba(0, 0, 0, 0.04));
color: var(--color-text-muted, #555);
font-size: 0.72rem;
font-weight: 500;
vertical-align: baseline;
}
.derived-badge--authority {
background: var(--color-accent-soft, rgba(198, 244, 28, 0.18));
color: var(--color-text);
}
/* Inline checkbox label inside the attach-unit form. */
.form-checkbox {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-right: 1rem;
font-size: 0.9rem;
}
.party-form {
margin-bottom: 1.25rem;
padding: 1rem;
@@ -7541,7 +7685,8 @@ label.caldav-toggle-label {
.sidebar.expanded .sidebar-search-input,
.sidebar.pinned .sidebar-search-input,
.sidebar.mobile-open .sidebar-search-input {
.sidebar.mobile-open .sidebar-search-input,
:root.sidebar-pinned .sidebar .sidebar-search-input {
opacity: 1;
pointer-events: auto;
}
@@ -7571,7 +7716,8 @@ label.caldav-toggle-label {
.sidebar.expanded .sidebar-search-kbd,
.sidebar.pinned .sidebar-search-kbd,
.sidebar.mobile-open .sidebar-search-kbd {
.sidebar.mobile-open .sidebar-search-kbd,
:root.sidebar-pinned .sidebar .sidebar-search-kbd {
opacity: 1;
}
@@ -7778,7 +7924,8 @@ label.caldav-toggle-label {
}
.sidebar.expanded .sidebar-badge,
.sidebar.pinned .sidebar-badge {
.sidebar.pinned .sidebar-badge,
:root.sidebar-pinned .sidebar .sidebar-badge {
left: auto;
right: 1rem;
top: 50%;
@@ -8764,6 +8911,44 @@ dialog.quick-add-sheet::backdrop {
background: var(--color-bg-lime-tint);
}
/* /admin/partner-units member modal — list of (display_name, role-select,
remove) rows. The role-select is wired to PATCH …/members/{user}/role
(t-paliad-143). */
.partner-unit-member-list {
list-style: none;
margin: 0 0 1rem 0;
padding: 0;
}
.partner-unit-member-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--color-border);
}
.partner-unit-member-item:last-child {
border-bottom: none;
}
.partner-unit-member-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.pu-role-select {
padding: 0.25rem 0.4rem;
font-size: 0.82rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
}
.admin-team-input {
width: 100%;
padding: 0.3rem 0.45rem;
@@ -10197,3 +10382,161 @@ dialog.quick-add-sheet::backdrop {
}
}
/* ============================================================================
* Approval workflow (t-paliad-138).
* ========================================================================== */
.inbox-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.inbox-row {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.inbox-row-head {
display: flex;
flex-direction: column;
gap: 2px;
}
.inbox-row-title {
font-weight: 600;
color: var(--fg);
font-size: 15px;
}
.inbox-row-meta {
font-size: 13px;
color: var(--fg-muted);
}
.inbox-row-diff {
background: var(--bg-soft);
border-left: 3px solid var(--accent);
border-radius: 4px;
padding: 8px 12px;
font-size: 13px;
display: flex;
flex-direction: column;
gap: 4px;
}
.inbox-row-diff-line {
display: flex;
gap: 8px;
}
.inbox-row-diff-key {
font-weight: 600;
color: var(--fg-muted);
min-width: 110px;
}
.inbox-row-diff-values {
color: var(--fg);
font-family: var(--font-mono, ui-monospace, monospace);
}
.inbox-row-note {
font-size: 13px;
color: var(--fg-muted);
font-style: italic;
}
.inbox-row-actions {
display: flex;
gap: 8px;
align-items: center;
margin-top: 4px;
}
.inbox-row-action {
padding: 6px 14px;
font-size: 13px;
}
.inbox-row-decided {
color: var(--fg-muted);
font-size: 12px;
margin-left: 4px;
}
/* Pending-approval pill — shown on every entity surface where a row is
* approval_status='pending'. Soft amber background, ⚠ leader. */
.approval-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 9999px;
background: rgba(146, 64, 14, 0.12);
color: #92400e;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
}
.approval-pill::before { content: "⚠"; }
.approval-pill--historic {
background: var(--bg-soft);
color: var(--fg-muted);
}
.approval-pill--historic::before { content: ""; }
[data-theme="dark"] .approval-pill {
background: rgba(252, 211, 77, 0.18);
color: #fcd34d;
}
/* Pending-state row modifiers — soft tint per lifecycle so an approver can
* scan a list of pending entities at a glance. Write-then-approve means
* the row is "live" already; the tint is the only visual flag. */
.entity-row--pending-create {
background: rgba(146, 64, 14, 0.05);
}
.entity-row--pending-update {
background: rgba(146, 64, 14, 0.04);
}
.entity-row--pending-complete {
background: rgba(34, 197, 94, 0.05);
}
.entity-row--pending-delete {
background: rgba(220, 38, 38, 0.05);
text-decoration: line-through;
text-decoration-color: rgba(220, 38, 38, 0.4);
text-decoration-thickness: 1px;
}
/* Sidebar inbox badge — orange when something needs the user's
* attention; matches the changelog-badge shape but distinct color. */
#sidebar-inbox-badge {
background: #92400e;
color: #fff;
}
[data-theme="dark"] #sidebar-inbox-badge {
background: #fcd34d;
color: #0a0a0a;
}
/* Approval policies authoring table on /projects/{id}/settings/approvals. */
.approval-policies-table th,
.approval-policies-table td {
padding: 10px 12px;
text-align: left;
}
.approval-policies-table .entity-select {
min-width: 180px;
}

View File

@@ -0,0 +1,42 @@
-- t-paliad-138: rollback dual-control approvals.
--
-- Reverses 054_approvals.up.sql:
-- 1. Drop appointment + deadline approval columns.
-- 2. Drop paliad.approval_requests.
-- 3. Drop paliad.approval_policies.
-- 4. Drop paliad.approval_role_level().
-- 5. Restore project_teams.role CHECK without 'senior_pa'.
--
-- Step 5 will fail loudly if any user has been re-roled to 'senior_pa' —
-- intentional, mirrors the t-paliad-051 down strategy. Operator must
-- migrate those rows to another role before rolling back.
ALTER TABLE paliad.appointments
DROP COLUMN IF EXISTS completed_at,
DROP COLUMN IF EXISTS approved_at,
DROP COLUMN IF EXISTS approved_by,
DROP COLUMN IF EXISTS pending_request_id,
DROP COLUMN IF EXISTS approval_status;
ALTER TABLE paliad.deadlines
DROP COLUMN IF EXISTS approved_at,
DROP COLUMN IF EXISTS approved_by,
DROP COLUMN IF EXISTS pending_request_id,
DROP COLUMN IF EXISTS approval_status;
DROP INDEX IF EXISTS paliad.deadlines_approval_status_pending_idx;
DROP INDEX IF EXISTS paliad.appointments_approval_status_pending_idx;
DROP TABLE IF EXISTS paliad.approval_requests;
DROP TABLE IF EXISTS paliad.approval_policies;
DROP FUNCTION IF EXISTS paliad.approval_role_level(text);
-- Drop by both English and the German-legacy name (see up migration §1).
ALTER TABLE paliad.project_teams DROP CONSTRAINT IF EXISTS projekt_teams_role_check;
ALTER TABLE paliad.project_teams DROP CONSTRAINT IF EXISTS project_teams_role_check;
ALTER TABLE paliad.project_teams ADD CONSTRAINT project_teams_role_check
CHECK (role IN (
'lead', 'associate', 'pa', 'of_counsel',
'local_counsel', 'expert', 'observer'
));

View File

@@ -0,0 +1,237 @@
-- t-paliad-138: dual-control approvals (4-Augen-Prüfung) for Deadlines + Appointments.
--
-- Design: docs/design-approvals-2026-05-06.md (cronus, m-locked 2026-05-06).
--
-- Schema-only migration (commit 1 of 8). Adds the operational tables, the
-- strict-ladder helper, and the per-entity tracking columns. No Go code
-- reads these yet — paliad behaves identically until commit 2 wires the
-- ApprovalService into the mutation paths.
--
-- Sections:
-- 1. Add 'senior_pa' to paliad.project_teams.role CHECK.
-- 2. paliad.approval_role_level(text) — strict ladder helper.
-- 3. paliad.approval_policies — per-(project, entity_type, lifecycle_event).
-- 4. paliad.approval_requests — operational pending workflow.
-- 5. ALTER paliad.deadlines + paliad.appointments — approval columns
-- (approval_status, pending_request_id, approved_by, approved_at;
-- appointments also gains completed_at).
-- 6. Backfill: mark every existing row approval_status='legacy'.
--
-- ============================================================================
-- 1. Add 'senior_pa' to paliad.project_teams.role CHECK.
--
-- Live-DB finding (cronus, 2026-05-06): the existing constraint is named
-- `projekt_teams_role_check` (German leftover from migration 018, when the
-- table was `paliad.projekt_teams`; the table was renamed in 020 but the
-- constraint name was preserved by Postgres). Dropping by both names
-- defensively handles any future re-creation under the English name.
-- ============================================================================
ALTER TABLE paliad.project_teams DROP CONSTRAINT IF EXISTS projekt_teams_role_check;
ALTER TABLE paliad.project_teams DROP CONSTRAINT IF EXISTS project_teams_role_check;
ALTER TABLE paliad.project_teams ADD CONSTRAINT project_teams_role_check
CHECK (role IN (
'lead', 'associate', 'pa', 'of_counsel',
'local_counsel', 'expert', 'observer',
'senior_pa'
));
-- ============================================================================
-- 2. paliad.approval_role_level — strict ladder over project_teams.role.
--
-- Mirrors internal/services/approval_levels.go:levelOf. A user with
-- project_teams.role R can approve any request whose required_role has level
-- <= level(R). Roles outside the approval ladder (local_counsel, expert,
-- observer, anything new) return 0 and are ineligible to approve at any
-- level. Default required_role on policies is 'associate' (level 3).
-- ============================================================================
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
$$;
COMMENT ON FUNCTION paliad.approval_role_level(text) IS
'Strict-ladder level for approval gating (t-paliad-138). '
'Higher level always satisfies lower. Level 0 = ineligible. '
'Default policy required_role=associate (level 3) — eligible: lead, of_counsel, associate.';
-- ============================================================================
-- 3. paliad.approval_policies — per-(project, entity_type, lifecycle_event).
--
-- Up to 8 rows per project (deadline×4 + appointment×4). UNIQUE composite key
-- enforces this. No row = no approval needed for that event. Authoring is
-- gated to global_admin in the service layer; RLS lets project members read
-- their own project's policies (transparency: "do my edits need 4-eye?").
-- ============================================================================
CREATE TABLE paliad.approval_policies (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
entity_type text NOT NULL CHECK (entity_type IN ('deadline', 'appointment')),
lifecycle_event text NOT NULL CHECK (lifecycle_event IN ('create', 'update', 'complete', 'delete')),
required_role text NOT NULL CHECK (required_role IN (
'lead', 'of_counsel', 'associate', 'senior_pa', 'pa'
)),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
UNIQUE (project_id, entity_type, lifecycle_event)
);
CREATE INDEX approval_policies_project_idx
ON paliad.approval_policies (project_id);
ALTER TABLE paliad.approval_policies ENABLE ROW LEVEL SECURITY;
CREATE POLICY approval_policies_select ON paliad.approval_policies
FOR SELECT TO authenticated
USING (paliad.can_see_project(project_id));
-- Writes are restricted to global_admin in the application layer. The
-- service-role connection bypasses RLS, so these policies are
-- defence-in-depth for any future direct-DB access path.
CREATE POLICY approval_policies_write ON paliad.approval_policies
FOR ALL TO authenticated
USING (
EXISTS (SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
)
WITH CHECK (
EXISTS (SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
);
-- ============================================================================
-- 4. paliad.approval_requests — operational pending workflow.
--
-- One row per submitted state-change that needs 4-eye sign-off. The entity
-- being changed is referenced by (entity_type, entity_id) — polymorphic
-- across deadlines / appointments, so no FK constraint on entity_id.
--
-- pre_image carries the field values needed to revert on rejection
-- (NULL for 'create' since there's nothing to revert to). payload echoes
-- the diff or new values that were written, for audit display.
--
-- required_role is a snapshot of the policy at request time — even if the
-- policy changes mid-flight, the request honours the level it was submitted
-- under.
--
-- decision_kind discriminates 'peer' (normal in-team sign-off) from
-- 'admin_override' (global_admin used the escape-hatch path). Verlauf
-- chronology renders these distinctly.
--
-- The CHECK on (decided_by != requested_by) is defence-in-depth alongside
-- the service-layer self-approval block.
-- ============================================================================
CREATE TABLE paliad.approval_requests (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
entity_type text NOT NULL CHECK (entity_type IN ('deadline', 'appointment')),
entity_id uuid NOT NULL,
lifecycle_event text NOT NULL CHECK (lifecycle_event IN ('create', 'update', 'complete', 'delete')),
pre_image jsonb,
payload jsonb,
requested_by uuid NOT NULL REFERENCES paliad.users(id) ON DELETE RESTRICT,
requested_at timestamptz NOT NULL DEFAULT now(),
required_role text NOT NULL CHECK (required_role IN (
'lead', 'of_counsel', 'associate', 'senior_pa', 'pa'
)),
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'approved', 'rejected', 'revoked', 'superseded')),
decided_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
decided_at timestamptz,
decision_kind text CHECK (decision_kind IS NULL OR decision_kind IN ('peer', 'admin_override')),
decision_note text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT approval_requests_no_self_approval
CHECK (decided_by IS NULL OR decided_by <> requested_by)
);
CREATE INDEX approval_requests_project_status_idx
ON paliad.approval_requests (project_id, status);
CREATE INDEX approval_requests_entity_idx
ON paliad.approval_requests (entity_type, entity_id);
CREATE INDEX approval_requests_requested_by_idx
ON paliad.approval_requests (requested_by, status);
CREATE INDEX approval_requests_pending_idx
ON paliad.approval_requests (status, requested_at)
WHERE status = 'pending';
ALTER TABLE paliad.approval_requests ENABLE ROW LEVEL SECURITY;
-- Visible to anyone with project visibility (mirrors deadlines / appointments).
-- The approve/reject action is gated at the service layer, not here.
CREATE POLICY approval_requests_all ON paliad.approval_requests
FOR ALL TO authenticated
USING (paliad.can_see_project(project_id))
WITH CHECK (paliad.can_see_project(project_id));
-- ============================================================================
-- 5. Approval columns on paliad.deadlines + paliad.appointments.
--
-- approval_status:
-- 'approved' (default for new + existing-after-backfill-clears),
-- 'pending' (an approval_request is in flight; pending_request_id set),
-- 'legacy' (predates 4-eye; backfilled in §6 below).
--
-- pending_request_id: FK to the in-flight approval_requests row. NULL when
-- approval_status != 'pending'. ON DELETE SET NULL keeps the entity row
-- intact if an approval_requests row is ever pruned.
--
-- approved_by / approved_at: set on transition to approval_status='approved'
-- after a 4-eye approval. NULL for 'legacy' rows and rows that never went
-- through 4-eye (no policy applied).
--
-- appointments.completed_at: new column for the appointment:complete
-- lifecycle event. Nullable; NULL means "not yet marked done".
-- ============================================================================
ALTER TABLE paliad.deadlines
ADD COLUMN approval_status text NOT NULL DEFAULT 'approved'
CHECK (approval_status IN ('approved', 'pending', 'legacy')),
ADD COLUMN pending_request_id uuid
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL,
ADD COLUMN approved_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
ADD COLUMN approved_at timestamptz;
CREATE INDEX deadlines_approval_status_pending_idx
ON paliad.deadlines (approval_status)
WHERE approval_status = 'pending';
ALTER TABLE paliad.appointments
ADD COLUMN approval_status text NOT NULL DEFAULT 'approved'
CHECK (approval_status IN ('approved', 'pending', 'legacy')),
ADD COLUMN pending_request_id uuid
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL,
ADD COLUMN approved_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
ADD COLUMN approved_at timestamptz,
ADD COLUMN completed_at timestamptz;
CREATE INDEX appointments_approval_status_pending_idx
ON paliad.appointments (approval_status)
WHERE approval_status = 'pending';
-- ============================================================================
-- 6. Backfill: mark every existing row legacy.
--
-- Per design §6.5 / m's Q11 answer: existing pre-4-eye rows are read-clean;
-- they don't need retroactive approval. The next mutation on a legacy row
-- that hits an active policy (none exist on day 1) will trigger normal flow
-- and lift the row to 'approved' (or 'pending' until signed off).
--
-- created_by is already populated since migration 005. approved_by stays
-- NULL on legacy rows.
-- ============================================================================
UPDATE paliad.deadlines SET approval_status = 'legacy';
UPDATE paliad.appointments SET approval_status = 'legacy';

View File

@@ -0,0 +1,53 @@
-- Down migration for t-paliad-139 (055_hierarchy_aggregation).
--
-- Reverses the schema additions in lockstep with the up migration:
-- 1. Restore can_see_project to the migration-023 body (drop derivation
-- branch).
-- 2. Drop paliad.approval_role_from_unit_role helper.
-- 3. Drop paliad.project_partner_units (cascades the policies + index).
-- 4. Drop paliad.partner_unit_members.unit_role.
--
-- If any project has project_partner_units rows with derive_grants_authority=true
-- AND any approval_request was ever signed using a derived_peer decision_kind
-- (t-paliad-139 Phase 3), the down does NOT roll those back — the audit rows
-- stay valid; only the schema is reverted. Down is intentionally lossy on
-- in-flight derivation state.
-- Restore the migration-054 decision_kind CHECK (without 'derived_peer').
-- Any existing rows with decision_kind='derived_peer' would fail the
-- restored CHECK; the down deliberately doesn't update them — operators
-- must reconcile before applying the down migration.
ALTER TABLE paliad.approval_requests DROP CONSTRAINT IF EXISTS approval_requests_decision_kind_check;
ALTER TABLE paliad.approval_requests ADD CONSTRAINT approval_requests_decision_kind_check
CHECK (decision_kind IS NULL OR decision_kind IN ('peer', 'admin_override'));
-- 1. Restore migration-023 can_see_project body (no derivation branch).
CREATE OR REPLACE FUNCTION paliad.can_see_project(_project_id uuid)
RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = paliad, public
AS $$
SELECT EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
OR EXISTS (
SELECT 1
FROM paliad.projects target
JOIN paliad.project_teams pt
ON pt.user_id = auth.uid()
AND pt.project_id = ANY(string_to_array(target.path, '.')::uuid[])
WHERE target.id = _project_id
);
$$;
-- 2. Drop the unit_role → project_role mapping helper.
DROP FUNCTION IF EXISTS paliad.approval_role_from_unit_role(text);
-- 3. Drop the project↔unit junction (CASCADE clears policies + index).
DROP TABLE IF EXISTS paliad.project_partner_units;
-- 4. Drop the unit_role column.
ALTER TABLE paliad.partner_unit_members DROP COLUMN IF EXISTS unit_role;

View File

@@ -0,0 +1,174 @@
-- t-paliad-139: hierarchy aggregation — partner-unit derivation schema.
--
-- Design: docs/design-hierarchy-aggregation-2026-05-06.md (noether, m-locked 2026-05-06).
--
-- This is the Phase 2 schema migration. Day-1 deploy = zero behaviour change
-- because:
-- - Every existing partner_unit_members row defaults to unit_role='attorney'.
-- - The default derive_unit_roles on the new junction is {'pa','senior_pa'}.
-- - No project_partner_units rows exist yet; admins opt-in by attaching
-- units to projects.
-- Until those two conditions diverge, no derivation happens and visibility
-- behaves identically to the pre-055 world.
--
-- Sections:
-- 1. ALTER paliad.partner_unit_members ADD COLUMN unit_role.
-- 2. CREATE paliad.project_partner_units junction (with RLS).
-- 3. CREATE paliad.approval_role_from_unit_role helper.
-- 4. CREATE OR REPLACE paliad.can_see_project — extended with derivation
-- branch.
-- ============================================================================
-- 1. unit_role on paliad.partner_unit_members.
--
-- Per-unit role distinction so derivation can target specific tiers (default
-- {pa, senior_pa}) without re-introducing a firm-wide rank column. The same
-- user can have a different unit_role in different units; in practice most
-- users belong to one unit so this is effectively a firm-rank, but the per-
-- unit framing preserves the t-paliad-051/-138 three-axis principle on the
-- user side (job_title remains free-text display, global_role stays
-- standard|global_admin).
-- ============================================================================
ALTER TABLE paliad.partner_unit_members
ADD COLUMN unit_role text NOT NULL DEFAULT 'attorney'
CHECK (unit_role IN ('lead', 'attorney', 'senior_pa', 'pa', 'paralegal'));
-- ============================================================================
-- 2. paliad.project_partner_units — project ↔ unit involvement.
--
-- A row here means "this unit is involved on this project, and the listed
-- unit_roles auto-derive onto the project team". Authority defaults to off
-- (visibility-only): set derive_grants_authority=true to let derived members
-- count as approvers (per t-paliad-139 §3.4). Composite PK enforces "one
-- attachment per (project, unit)".
-- ============================================================================
CREATE TABLE paliad.project_partner_units (
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
partner_unit_id uuid NOT NULL REFERENCES paliad.partner_units(id) ON DELETE CASCADE,
-- Roles in the unit that auto-derive onto the project team. Defaults
-- target PAs only; a project can widen to ['pa','senior_pa','attorney']
-- to pull the whole unit, or narrow to ['pa'] to exclude senior_pa.
derive_unit_roles text[] NOT NULL DEFAULT ARRAY['pa', 'senior_pa'],
-- Strict default: derived members are visibility-only. Flipping this on
-- lets them be eligible approvers per the t-138 ladder via the mapping
-- in paliad.approval_role_from_unit_role.
derive_grants_authority boolean NOT NULL DEFAULT false,
attached_at timestamptz NOT NULL DEFAULT now(),
attached_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
PRIMARY KEY (project_id, partner_unit_id)
);
CREATE INDEX project_partner_units_unit_idx
ON paliad.project_partner_units (partner_unit_id, project_id);
ALTER TABLE paliad.project_partner_units ENABLE ROW LEVEL SECURITY;
-- Anyone who can see the project can see the unit attachment. Mirrors the
-- approval_requests / deadlines / appointments policy.
CREATE POLICY project_partner_units_select
ON paliad.project_partner_units FOR SELECT
USING (paliad.can_see_project(project_id));
-- Writes gated to global_admin OR project lead. Same pattern as
-- /admin/team and /admin/partner-units precedent.
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')
);
-- ============================================================================
-- 3. paliad.approval_role_from_unit_role — unit_role → project_role mapping.
--
-- Used when a derived member's authority is evaluated by the t-138 strict
-- ladder. The mapping is intentional:
-- lead → lead (the unit's lead, matches project lead tier)
-- attorney → associate (default for working lawyers)
-- senior_pa → senior_pa (1:1)
-- pa → pa (1:1)
-- paralegal → observer (level 0 — ineligible to approve)
-- The ApprovalService (t-138) reads project_teams.role first; only when that
-- has no row does it fall back to derived authority via this mapping (and
-- only when the project_partner_units row has derive_grants_authority=true).
-- ============================================================================
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
$$;
-- ============================================================================
-- 4. Extend paliad.approval_requests.decision_kind CHECK to allow
-- 'derived_peer' — a derived (partner-unit) member with authority who
-- signed off via the t-paliad-138 inbox path. Distinct from plain
-- 'peer' so the audit trail discloses the derivation chain.
-- ============================================================================
ALTER TABLE paliad.approval_requests DROP CONSTRAINT IF EXISTS approval_requests_decision_kind_check;
ALTER TABLE paliad.approval_requests ADD CONSTRAINT approval_requests_decision_kind_check
CHECK (decision_kind IS NULL OR decision_kind IN ('peer', 'admin_override', 'derived_peer'));
-- ============================================================================
-- 5. paliad.can_see_project — extended with derivation branch.
--
-- Same shape as the migration-023 body, plus one EXISTS branch: a user is
-- visible on a project if there is any (ancestor of project) attached to a
-- partner_unit they are a member of, AND their unit_role is in the derive
-- set for that attachment. Read-cost is small (project_partner_units +
-- partner_unit_members are tiny).
--
-- t-paliad-139 §3.3 Option B: compute on read, no materialised state.
-- ============================================================================
CREATE OR REPLACE FUNCTION paliad.can_see_project(_project_id uuid)
RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = paliad, public
AS $$
SELECT EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
OR EXISTS (
SELECT 1
FROM paliad.projects target
JOIN paliad.project_teams pt
ON pt.user_id = auth.uid()
AND pt.project_id = ANY(string_to_array(target.path, '.')::uuid[])
WHERE target.id = _project_id
)
OR EXISTS (
SELECT 1
FROM paliad.projects target
JOIN paliad.project_partner_units ppu
ON ppu.project_id = ANY(string_to_array(target.path, '.')::uuid[])
JOIN paliad.partner_unit_members pum
ON pum.partner_unit_id = ppu.partner_unit_id
AND pum.user_id = auth.uid()
AND pum.unit_role = ANY(ppu.derive_unit_roles)
WHERE target.id = _project_id
);
$$;

View File

@@ -108,7 +108,8 @@ func handleListAppointmentsForProject(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
rows, err := dbSvc.appointment.ListForProject(r.Context(), uid, projectID)
directOnly := parseDirectOnly(r.URL.Query().Get("direct_only"))
rows, err := dbSvc.appointment.ListForProject(r.Context(), uid, projectID, directOnly)
if err != nil {
writeServiceError(w, err)
return

View File

@@ -0,0 +1,297 @@
package handlers
// Approval workflow HTTP endpoints (t-paliad-138).
//
// Three groups of routes:
//
// 1. Policy CRUD (admin-only, gated at the route layer):
// GET /api/projects/{id}/approval-policies
// PUT /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}
// DELETE /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}
//
// 2. Inbox (any authenticated user — the service-layer query gates by
// project visibility + approver eligibility):
// GET /api/inbox/pending-mine — requests I can approve
// GET /api/inbox/mine — requests I submitted
// GET /api/inbox/count — bell badge count
// GET /api/approval-requests/{id} — one request hydrated
//
// 3. Decisions (any authenticated user — service layer gates the action):
// POST /api/approval-requests/{id}/approve
// POST /api/approval-requests/{id}/reject
// POST /api/approval-requests/{id}/revoke
import (
"encoding/json"
"errors"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/models"
"mgit.msbls.de/m/paliad/internal/services"
)
// ============================================================================
// Policy CRUD (admin only — gated by RequireAdminFunc at registration).
// ============================================================================
// GET /api/projects/{id}/approval-policies
func handleListApprovalPolicies(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if _, ok := requireUser(w, r); !ok {
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
return
}
rows, err := dbSvc.approval.ListPolicies(r.Context(), projectID)
if err != nil {
writeServiceError(w, err)
return
}
if rows == nil {
rows = []models.ApprovalPolicy{} // ensure JSON [] not null
}
writeJSON(w, http.StatusOK, rows)
}
// PUT /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}
//
// Body: {"required_role": "associate"}
//
// Semantics: upsert. Replaces any existing row for the same
// (project, entity_type, lifecycle) tuple.
func handlePutApprovalPolicy(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
return
}
entityType := r.PathValue("entity_type")
lifecycle := r.PathValue("lifecycle")
var body struct {
RequiredRole string `json:"required_role"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
policy, err := dbSvc.approval.UpsertPolicy(r.Context(), projectID, uid, entityType, lifecycle, body.RequiredRole)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, policy)
}
// DELETE /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}
//
// Removes one policy row, reverting that lifecycle event back to the
// no-approval-needed default.
func handleDeleteApprovalPolicy(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if _, ok := requireUser(w, r); !ok {
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
return
}
entityType := r.PathValue("entity_type")
lifecycle := r.PathValue("lifecycle")
if err := dbSvc.approval.DeletePolicy(r.Context(), projectID, entityType, lifecycle); err != nil {
writeServiceError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ============================================================================
// Inbox.
// ============================================================================
// GET /api/inbox/pending-mine — requests I'm qualified to approve.
func handleListInboxPendingMine(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
rows, err := dbSvc.approval.ListPendingForApprover(r.Context(), uid, parseInboxFilter(r))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// GET /api/inbox/mine — requests I submitted.
func handleListInboxMine(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
rows, err := dbSvc.approval.ListSubmittedByUser(r.Context(), uid, parseInboxFilter(r))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// GET /api/inbox/count — bell badge count for the sidebar.
func handleInboxCount(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
n, err := dbSvc.approval.PendingCountForUser(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]int{"count": n})
}
// parseInboxFilter pulls common filter knobs off the query string.
func parseInboxFilter(r *http.Request) services.InboxFilter {
q := r.URL.Query()
f := services.InboxFilter{
Status: q.Get("status"),
EntityType: q.Get("entity_type"),
}
if pid := q.Get("project_id"); pid != "" {
if id, err := uuid.Parse(pid); err == nil {
f.ProjectID = &id
}
}
return f
}
// GET /api/approval-requests/{id} — one hydrated request.
func handleGetApprovalRequest(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if _, ok := requireUser(w, r); !ok {
return
}
requestID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request id"})
return
}
row, err := dbSvc.approval.GetRequest(r.Context(), requestID)
if err != nil {
writeServiceError(w, err)
return
}
if row == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
writeJSON(w, http.StatusOK, row)
}
// ============================================================================
// Decisions.
// ============================================================================
type approvalDecisionBody struct {
Note string `json:"note"`
}
// POST /api/approval-requests/{id}/approve
func handleApproveApprovalRequest(w http.ResponseWriter, r *http.Request) {
handleApprovalDecision(w, r, "approve")
}
// POST /api/approval-requests/{id}/reject
func handleRejectApprovalRequest(w http.ResponseWriter, r *http.Request) {
handleApprovalDecision(w, r, "reject")
}
// POST /api/approval-requests/{id}/revoke
func handleRevokeApprovalRequest(w http.ResponseWriter, r *http.Request) {
handleApprovalDecision(w, r, "revoke")
}
func handleApprovalDecision(w http.ResponseWriter, r *http.Request, action string) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
requestID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request id"})
return
}
var body approvalDecisionBody
if r.Body != nil && r.ContentLength > 0 {
_ = json.NewDecoder(r.Body).Decode(&body) // body is optional
}
switch action {
case "approve":
err = dbSvc.approval.Approve(r.Context(), requestID, uid, body.Note)
case "reject":
err = dbSvc.approval.Reject(r.Context(), requestID, uid, body.Note)
case "revoke":
err = dbSvc.approval.Revoke(r.Context(), requestID, uid)
}
if err != nil {
writeApprovalError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// GET /inbox — server-static page shell. Hydration is purely client-side
// (the bundle calls /api/inbox/pending-mine on load).
func handleInboxPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/inbox.html")
}
// writeApprovalError maps approval-flow errors to HTTP status codes.
func writeApprovalError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, services.ErrSelfApproval):
writeJSON(w, http.StatusForbidden, map[string]string{"error": "self_approval_blocked"})
case errors.Is(err, services.ErrNoQualifiedApprover):
writeJSON(w, http.StatusConflict, map[string]string{"error": "no_qualified_approver"})
case errors.Is(err, services.ErrConcurrentPending):
writeJSON(w, http.StatusConflict, map[string]string{"error": "concurrent_pending"})
case errors.Is(err, services.ErrNotApprover):
writeJSON(w, http.StatusForbidden, map[string]string{"error": "not_authorized"})
case errors.Is(err, services.ErrRequestNotPending):
writeJSON(w, http.StatusConflict, map[string]string{"error": "request_not_pending"})
default:
writeServiceError(w, err)
}
}

View File

@@ -128,7 +128,8 @@ func handleListDeadlinesForProject(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
rows, err := dbSvc.deadline.ListForProject(r.Context(), uid, projectID)
directOnly := parseDirectOnly(r.URL.Query().Get("direct_only"))
rows, err := dbSvc.deadline.ListForProject(r.Context(), uid, projectID, directOnly)
if err != nil {
writeServiceError(w, err)
return

View File

@@ -0,0 +1,189 @@
package handlers
// HTTP handlers for partner-unit derivation (t-paliad-139 Phase 2).
//
// Endpoints:
// GET /api/projects/{id}/partner-units → list attached units
// POST /api/projects/{id}/partner-units → attach (or update opts)
// DELETE /api/projects/{id}/partner-units/{unit_id} → detach
// GET /api/projects/{id}/team/derived → list derived members
// GET /api/projects/{id}/team/from-descendants → list descendant-staffed
// PATCH /api/partner-units/{id}/members/{user_id}/role → set unit_role on a member
import (
"encoding/json"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// GET /api/projects/{id}/partner-units
func handleListAttachedUnits(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
rows, err := dbSvc.derivation.ListAttachedUnits(r.Context(), uid, projectID)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// POST /api/projects/{id}/partner-units
//
// Body: { partner_unit_id, derive_unit_roles[]?, derive_grants_authority? }.
// Idempotent on (project_id, partner_unit_id) — repeat calls update opts.
func handleAttachPartnerUnit(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
var body struct {
PartnerUnitID string `json:"partner_unit_id"`
DeriveUnitRoles []string `json:"derive_unit_roles"`
DeriveGrantsAuthority bool `json:"derive_grants_authority"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
unitID, err := uuid.Parse(body.PartnerUnitID)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid partner_unit_id"})
return
}
if err := dbSvc.derivation.AttachUnitToProject(r.Context(), uid, projectID, unitID, services.AttachUnitOptions{
DeriveUnitRoles: body.DeriveUnitRoles,
DeriveGrantsAuthority: body.DeriveGrantsAuthority,
}); err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// DELETE /api/projects/{id}/partner-units/{unit_id}
func handleDetachPartnerUnit(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
unitID, err := uuid.Parse(r.PathValue("unit_id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid unit_id"})
return
}
if err := dbSvc.derivation.DetachUnitFromProject(r.Context(), uid, projectID, unitID); err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusNoContent, nil)
}
// GET /api/projects/{id}/team/derived
func handleListDerivedTeam(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
rows, err := dbSvc.derivation.ListDerivedMembers(r.Context(), uid, projectID)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// GET /api/projects/{id}/team/from-descendants
func handleListDescendantStaffedTeam(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
rows, err := dbSvc.derivation.ListDescendantStaffed(r.Context(), uid, projectID)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// PATCH /api/partner-units/{id}/members/{user_id}/role
//
// Body: { unit_role: 'lead'|'attorney'|'senior_pa'|'pa'|'paralegal' }.
// Admin-only (gated by PartnerUnitService.SetMemberRole's requireAdmin).
func handleSetUnitMemberRole(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
unitID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
userID, err := uuid.Parse(r.PathValue("user_id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid user_id"})
return
}
var body struct {
UnitRole string `json:"unit_role"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if err := dbSvc.partnerUnit.SetMemberRole(r.Context(), uid, unitID, userID, body.UnitRole); err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

View File

@@ -62,6 +62,8 @@ type Services struct {
Link *services.LinkService
Event *services.EventService
Courts *services.CourtService
Approval *services.ApprovalService
Derivation *services.DerivationService
}
func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc *Services) {
@@ -96,6 +98,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
link: svc.Link,
event: svc.Event,
courts: svc.Courts,
approval: svc.Approval,
derivation: svc.Derivation,
}
}
@@ -200,6 +204,13 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /api/projects/{id}/team", handleListProjectTeam)
protected.HandleFunc("POST /api/projects/{id}/team", handleAddProjectTeamMember)
protected.HandleFunc("DELETE /api/projects/{id}/team/{user_id}", handleRemoveProjectTeamMember)
// t-paliad-139 — sub-team aggregation surfaces for the Team tab.
protected.HandleFunc("GET /api/projects/{id}/team/derived", handleListDerivedTeam)
protected.HandleFunc("GET /api/projects/{id}/team/from-descendants", handleListDescendantStaffedTeam)
// t-paliad-139 — project ↔ partner-unit attachment management.
protected.HandleFunc("GET /api/projects/{id}/partner-units", handleListAttachedUnits)
protected.HandleFunc("POST /api/projects/{id}/partner-units", handleAttachPartnerUnit)
protected.HandleFunc("DELETE /api/projects/{id}/partner-units/{unit_id}", handleDetachPartnerUnit)
// Partner units (structural partner-led units; legacy "Dezernate").
protected.HandleFunc("GET /api/partner-units", handleListPartnerUnits)
@@ -210,6 +221,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /api/partner-units/{id}/members", handleListPartnerUnitMembers)
protected.HandleFunc("POST /api/partner-units/{id}/members", handleAddPartnerUnitMember)
protected.HandleFunc("DELETE /api/partner-units/{id}/members/{user_id}", handleRemovePartnerUnitMember)
// t-paliad-139 — set unit_role on a member.
protected.HandleFunc("PATCH /api/partner-units/{id}/members/{user_id}/role", handleSetUnitMemberRole)
protected.HandleFunc("DELETE /api/parties/{id}", handleDeleteParty)
@@ -366,6 +379,28 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("POST /api/admin/event-types/merge", adminGate(users, handleAdminMergeEventTypes))
protected.HandleFunc("POST /api/admin/event-types/{id}/promote", adminGate(users, handleAdminPromoteEventType))
protected.HandleFunc("POST /api/admin/event-types/{id}/restore", adminGate(users, handleAdminRestoreEventType))
// t-paliad-138 — approval-policy CRUD (admin only). The inbox
// + decision endpoints are NOT admin-only — they're below.
protected.HandleFunc("GET /api/projects/{id}/approval-policies",
adminGate(users, handleListApprovalPolicies))
protected.HandleFunc("PUT /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}",
adminGate(users, handlePutApprovalPolicy))
protected.HandleFunc("DELETE /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}",
adminGate(users, handleDeleteApprovalPolicy))
}
// t-paliad-138 — approval inbox + decision endpoints (any authenticated
// user; the service layer gates approve/reject by required-role match).
if svc != nil && svc.Approval != nil {
protected.HandleFunc("GET /inbox", gateOnboarded(handleInboxPage))
protected.HandleFunc("GET /api/inbox/pending-mine", handleListInboxPendingMine)
protected.HandleFunc("GET /api/inbox/mine", handleListInboxMine)
protected.HandleFunc("GET /api/inbox/count", handleInboxCount)
protected.HandleFunc("GET /api/approval-requests/{id}", handleGetApprovalRequest)
protected.HandleFunc("POST /api/approval-requests/{id}/approve", handleApproveApprovalRequest)
protected.HandleFunc("POST /api/approval-requests/{id}/reject", handleRejectApprovalRequest)
protected.HandleFunc("POST /api/approval-requests/{id}/revoke", handleRevokeApprovalRequest)
}
// Catch-all 404 — runs for any authenticated path that no more-specific
@@ -391,3 +426,15 @@ func writeJSON(w http.ResponseWriter, status int, data any) {
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
// parseDirectOnly reads a `direct_only=true|false` query value. Returns true
// only for the explicit "true" / "1" forms; everything else (including empty)
// is the subtree-aggregating default per t-paliad-139.
func parseDirectOnly(raw string) bool {
switch raw {
case "true", "1":
return true
default:
return false
}
}

View File

@@ -42,6 +42,8 @@ type dbServices struct {
link *services.LinkService
event *services.EventService
courts *services.CourtService
approval *services.ApprovalService
derivation *services.DerivationService
}
var dbSvc *dbServices
@@ -382,7 +384,8 @@ func handleListProjectEvents(w http.ResponseWriter, r *http.Request) {
}
limit = n
}
rows, err := dbSvc.projects.ListEvents(r.Context(), uid, id, before, limit)
directOnly := parseDirectOnly(q.Get("direct_only"))
rows, err := dbSvc.projects.ListEvents(r.Context(), uid, id, before, limit, directOnly)
if err != nil {
writeServiceError(w, err)
return

View File

@@ -147,17 +147,23 @@ type PartnerUnitMember struct {
// ProjectEvent is one row in the per-Project audit trail
// (paliad.project_events, renamed from paliad.project_events in migration 018).
//
// ProjectTitle is populated only by readers that join paliad.projects (e.g.
// ProjectService.ListEvents — Verlauf attribution for descendant events on
// /projects/{id}, t-paliad-139). Other readers leave it nil and the JSON
// serialiser omits it.
type ProjectEvent struct {
ID uuid.UUID `db:"id" json:"id"`
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
EventType *string `db:"event_type" json:"event_type,omitempty"`
Title string `db:"title" json:"title"`
Description *string `db:"description" json:"description,omitempty"`
EventDate *time.Time `db:"event_date" json:"event_date,omitempty"`
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
Metadata json.RawMessage `db:"metadata" json:"metadata"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ID uuid.UUID `db:"id" json:"id"`
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
EventType *string `db:"event_type" json:"event_type,omitempty"`
Title string `db:"title" json:"title"`
Description *string `db:"description" json:"description,omitempty"`
EventDate *time.Time `db:"event_date" json:"event_date,omitempty"`
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
Metadata json.RawMessage `db:"metadata" json:"metadata"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ProjectTitle *string `db:"project_title" json:"project_title,omitempty"`
}
// Deadline is one persistent deadline attached to a Project (typically a
@@ -187,6 +193,17 @@ type Deadline struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
// Approval-workflow columns added by migration 054 (t-paliad-138).
// approval_status: 'approved' (default), 'pending' (a request is in
// flight; pending_request_id is set), 'legacy' (predates 4-eye).
// approved_by / approved_at: populated when a 4-eye approval flips
// the row from 'pending' back to 'approved'. NULL on legacy rows
// and rows that never went through 4-eye.
ApprovalStatus string `db:"approval_status" json:"approval_status"`
PendingRequestID *uuid.UUID `db:"pending_request_id" json:"pending_request_id,omitempty"`
ApprovedBy *uuid.UUID `db:"approved_by" json:"approved_by,omitempty"`
ApprovedAt *time.Time `db:"approved_at" json:"approved_at,omitempty"`
// EventTypeIDs lists the paliad.event_types attached to this deadline
// via the paliad.deadline_event_types junction. Always present (never
// nil) once the row has been hydrated by DeadlineService.
@@ -225,6 +242,17 @@ type Appointment struct {
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
// CompletedAt is non-NULL once the appointment is marked done. New
// column added by migration 054 (t-paliad-138) — required to land the
// appointment:complete lifecycle event.
CompletedAt *time.Time `db:"completed_at" json:"completed_at,omitempty"`
// Approval-workflow columns (see Deadline doc above for semantics).
ApprovalStatus string `db:"approval_status" json:"approval_status"`
PendingRequestID *uuid.UUID `db:"pending_request_id" json:"pending_request_id,omitempty"`
ApprovedBy *uuid.UUID `db:"approved_by" json:"approved_by,omitempty"`
ApprovedAt *time.Time `db:"approved_at" json:"approved_at,omitempty"`
}
// AppointmentWithProject enriches an Appointment with its parent Project
@@ -469,3 +497,48 @@ const (
EventTypeJurisdictionDE = "DE"
EventTypeJurisdictionAny = "any"
)
// ApprovalPolicy is one row of paliad.approval_policies — the per-(project,
// entity_type, lifecycle_event) rule that says "this lifecycle event needs
// 4-eye sign-off at the given role tier or above". Up to 8 rows per project
// (deadline×4 + appointment×4); missing rows = no approval needed.
type ApprovalPolicy struct {
ID uuid.UUID `db:"id" json:"id"`
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
EntityType string `db:"entity_type" json:"entity_type"`
LifecycleEvent string `db:"lifecycle_event" json:"lifecycle_event"`
RequiredRole string `db:"required_role" json:"required_role"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
}
// ApprovalRequest is one row of paliad.approval_requests — an in-flight
// state-change awaiting 4-eye sign-off.
//
// PreImage carries the field values needed to revert on rejection (NULL for
// 'create' since there's nothing to revert to). Payload echoes the diff or
// new values that were written, for audit display. RequiredRole is a
// snapshot of the policy at request time.
//
// DecisionKind discriminates 'peer' (normal in-team sign-off) from
// 'admin_override' (global_admin used the escape-hatch path).
type ApprovalRequest struct {
ID uuid.UUID `db:"id" json:"id"`
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
EntityType string `db:"entity_type" json:"entity_type"`
EntityID uuid.UUID `db:"entity_id" json:"entity_id"`
LifecycleEvent string `db:"lifecycle_event" json:"lifecycle_event"`
PreImage json.RawMessage `db:"pre_image" json:"pre_image,omitempty"`
Payload json.RawMessage `db:"payload" json:"payload,omitempty"`
RequestedBy uuid.UUID `db:"requested_by" json:"requested_by"`
RequestedAt time.Time `db:"requested_at" json:"requested_at"`
RequiredRole string `db:"required_role" json:"required_role"`
Status string `db:"status" json:"status"`
DecidedBy *uuid.UUID `db:"decided_by" json:"decided_by,omitempty"`
DecidedAt *time.Time `db:"decided_at" json:"decided_at,omitempty"`
DecisionKind *string `db:"decision_kind" json:"decision_kind,omitempty"`
DecisionNote *string `db:"decision_note" json:"decision_note,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}

View File

@@ -53,6 +53,9 @@ type AgendaItem struct {
ProjectTitle *string `json:"project_title,omitempty"`
ProjectType *string `json:"project_type,omitempty"`
ProjectRef *string `json:"project_reference,omitempty"`
// ApprovalStatus (t-paliad-138) — "pending" → render warning pill on
// the agenda timeline. "approved"/"legacy" → no pill.
ApprovalStatus *string `json:"approval_status,omitempty"`
}
// AgendaFilter narrows the merged feed.
@@ -167,6 +170,7 @@ SELECT f.id,
f.title,
f.due_date,
f.status,
f.approval_status,
p.id AS project_id,
p.title AS project_title,
p.type AS project_type,
@@ -184,6 +188,7 @@ SELECT f.id,
Title string `db:"title"`
DueDate time.Time `db:"due_date"`
Status string `db:"status"`
ApprovalStatus string `db:"approval_status"`
ProjectID uuid.UUID `db:"project_id"`
ProjectTitle string `db:"project_title"`
ProjectType string `db:"project_type"`
@@ -198,20 +203,22 @@ SELECT f.id,
for _, r := range rows {
due := r.DueDate.Format("2006-01-02")
status := r.Status
approvalStatus := r.ApprovalStatus
projectID := r.ProjectID
projectTitle := r.ProjectTitle
projectType := r.ProjectType
out = append(out, AgendaItem{
ID: r.ID,
Type: "deadline",
Title: r.Title,
Date: time.Date(r.DueDate.Year(), r.DueDate.Month(), r.DueDate.Day(), 0, 0, 0, 0, time.UTC),
DueDate: &due,
Status: &status,
ProjectID: &projectID,
ProjectTitle: &projectTitle,
ProjectType: &projectType,
ProjectRef: r.ProjectReference,
ID: r.ID,
Type: "deadline",
Title: r.Title,
Date: time.Date(r.DueDate.Year(), r.DueDate.Month(), r.DueDate.Day(), 0, 0, 0, 0, time.UTC),
DueDate: &due,
Status: &status,
ApprovalStatus: &approvalStatus,
ProjectID: &projectID,
ProjectTitle: &projectTitle,
ProjectType: &projectType,
ProjectRef: r.ProjectReference,
})
}
return out, nil
@@ -228,6 +235,7 @@ SELECT t.id,
t.end_at,
t.location,
t.appointment_type,
t.approval_status,
t.project_id,
p.title AS project_title,
p.type AS project_type,
@@ -249,6 +257,7 @@ SELECT t.id,
EndAt *time.Time `db:"end_at"`
Location *string `db:"location"`
AppointmentType *string `db:"appointment_type"`
ApprovalStatus string `db:"approval_status"`
ProjectID *uuid.UUID `db:"project_id"`
ProjectTitle *string `db:"project_title"`
ProjectType *string `db:"project_type"`
@@ -261,6 +270,7 @@ SELECT t.id,
out := make([]AgendaItem, 0, len(rows))
for _, r := range rows {
approvalStatus := r.ApprovalStatus
out = append(out, AgendaItem{
ID: r.ID,
Type: "appointment",
@@ -269,6 +279,7 @@ SELECT t.id,
EndAt: r.EndAt,
Location: r.Location,
AppointmentType: r.AppointmentType,
ApprovalStatus: &approvalStatus,
ProjectID: r.ProjectID,
ProjectTitle: r.ProjectTitle,
ProjectType: r.ProjectType,

View File

@@ -29,7 +29,14 @@ type AppointmentService struct {
db *sqlx.DB
projects *ProjectService
caldav AppointmentCalDAVPusher
caldav AppointmentCalDAVPusher
approvals *ApprovalService
}
// SetApprovalService wires the optional 4-eye approval workflow
// (t-paliad-138). See DeadlineService.SetApprovalService.
func (s *AppointmentService) SetApprovalService(a *ApprovalService) {
s.approvals = a
}
// AppointmentCalDAVPusher is the contract the CalDAV service implements so the
@@ -52,7 +59,8 @@ func (s *AppointmentService) SetCalDAVPusher(p AppointmentCalDAVPusher) {
const appointmentColumns = `id, project_id, title, description, start_at, end_at,
location, appointment_type, caldav_uid, caldav_etag, created_by,
created_at, updated_at`
created_at, updated_at, completed_at,
approval_status, pending_request_id, approved_by, approved_at`
// CreateAppointmentInput is the payload for POST /api/appointments.
type CreateAppointmentInput struct {
@@ -146,6 +154,8 @@ func (s *AppointmentService) ListVisibleForUser(ctx context.Context, userID uuid
SELECT t.id, t.project_id, t.title, t.description, t.start_at, t.end_at,
t.location, t.appointment_type, t.caldav_uid, t.caldav_etag,
t.created_by, t.created_at, t.updated_at,
t.completed_at,
t.approval_status, t.pending_request_id, t.approved_by, t.approved_at,
p.reference AS project_reference,
p.title AS project_title,
p.type AS project_type
@@ -167,16 +177,33 @@ func (s *AppointmentService) ListVisibleForUser(ctx context.Context, userID uuid
return rows, nil
}
// ListForProject returns Appointments for a specific Project, visibility-checked.
func (s *AppointmentService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.Appointment, error) {
// ListForProject returns Appointments for a Project (visibility-checked).
//
// When directOnly is false (default), the result aggregates appointments
// from the Project itself AND every descendant Project (per the
// t-paliad-139 hierarchy aggregation contract). When directOnly is true,
// only appointments whose project_id exactly equals the filter are
// returned.
//
// The descendant aggregation mirrors DeadlineService.ListForProject — see
// the doc comment there for the rationale.
func (s *AppointmentService) ListForProject(ctx context.Context, userID, projectID uuid.UUID, directOnly bool) ([]models.Appointment, error) {
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
return nil, err
}
rows := []models.Appointment{}
var filter string
if directOnly {
filter = `WHERE project_id = $1`
} else {
filter = `WHERE project_id IN (
SELECT p.id FROM paliad.projects p
WHERE $1 = ANY(string_to_array(p.path, '.')::uuid[]))`
}
if err := s.db.SelectContext(ctx, &rows,
`SELECT `+appointmentColumns+`
FROM paliad.appointments
WHERE project_id = $1
`+filter+`
ORDER BY start_at ASC, created_at DESC`, projectID); err != nil {
return nil, fmt.Errorf("list appointments for project: %w", err)
}
@@ -305,6 +332,15 @@ func (s *AppointmentService) Create(ctx context.Context, userID uuid.UUID, input
map[string]any{"appointment_id": id}); err != nil {
return nil, err
}
// Approval gate (t-paliad-138). No-op for personal appointments
// (project_id IS NULL) and when no policy applies.
if s.approvals != nil {
payload := map[string]any{"title": title, "start_at": input.StartAt.UTC().Format(time.RFC3339)}
if _, err := s.approvals.SubmitCreate(ctx, tx, *input.ProjectID, id, userID, EntityTypeAppointment, payload); err != nil {
return nil, err
}
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit insert appointment: %w", err)
@@ -321,6 +357,11 @@ func (s *AppointmentService) Create(ctx context.Context, userID uuid.UUID, input
}
// Update applies a partial update.
//
// Approval gate (t-paliad-138): only date-bearing fields (start_at,
// end_at) trigger 4-eye per Q4. Cosmetic edits (title, description,
// location, appointment_type) bypass approval. Personal appointments
// (project_id IS NULL) never gate — there's no project policy to consult.
func (s *AppointmentService) Update(ctx context.Context, userID, appointmentID uuid.UUID, input UpdateAppointmentInput) (*models.Appointment, error) {
current, err := s.GetByID(ctx, userID, appointmentID)
if err != nil {
@@ -333,6 +374,9 @@ func (s *AppointmentService) Update(ctx context.Context, userID, appointmentID u
} else if err := s.requireMutationRole(ctx, userID, current); err != nil {
return nil, err
}
if current.ApprovalStatus == ApprovalStatusPending {
return nil, ErrConcurrentPending
}
sets := []string{}
args := []any{}
@@ -343,6 +387,9 @@ func (s *AppointmentService) Update(ctx context.Context, userID, appointmentID u
next++
}
preImage := map[string]any{}
payload := map[string]any{}
if input.Title != nil {
title := strings.TrimSpace(*input.Title)
if title == "" {
@@ -354,10 +401,28 @@ func (s *AppointmentService) Update(ctx context.Context, userID, appointmentID u
appendSet("description", *input.Description)
}
if input.StartAt != nil {
appendSet("start_at", input.StartAt.UTC())
newStart := input.StartAt.UTC()
if !newStart.Equal(current.StartAt) {
preImage["start_at"] = current.StartAt.Format(time.RFC3339)
payload["start_at"] = newStart.Format(time.RFC3339)
}
appendSet("start_at", newStart)
}
if input.EndAt != nil {
appendSet("end_at", input.EndAt.UTC())
newEnd := input.EndAt.UTC()
oldEnd := time.Time{}
if current.EndAt != nil {
oldEnd = *current.EndAt
}
if !newEnd.Equal(oldEnd) {
if current.EndAt != nil {
preImage["end_at"] = current.EndAt.Format(time.RFC3339)
} else {
preImage["end_at"] = nil
}
payload["end_at"] = newEnd.Format(time.RFC3339)
}
appendSet("end_at", newEnd)
}
if input.Location != nil {
appendSet("location", *input.Location)
@@ -474,6 +539,11 @@ func (s *AppointmentService) Update(ctx context.Context, userID, appointmentID u
return nil, err
}
}
if s.approvals != nil {
if _, err := s.approvals.SubmitUpdate(ctx, tx, *current.ProjectID, appointmentID, userID, EntityTypeAppointment, preImage, payload); err != nil {
return nil, err
}
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit update appointment: %w", err)
@@ -489,6 +559,12 @@ func (s *AppointmentService) Update(ctx context.Context, userID, appointmentID u
}
// Delete removes an Appointment.
//
// Approval gate (t-paliad-138): for project-attached appointments, if a
// (project, appointment, delete) policy applies, the row stays alive
// with approval_status='pending' until the approver hard-deletes
// (approve) or restores it (reject) — same stage-then-write exception
// as DeadlineService.Delete.
func (s *AppointmentService) Delete(ctx context.Context, userID, appointmentID uuid.UUID) error {
current, err := s.GetByID(ctx, userID, appointmentID)
if err != nil {
@@ -501,6 +577,9 @@ func (s *AppointmentService) Delete(ctx context.Context, userID, appointmentID u
} else if err := s.requireMutationRole(ctx, userID, current); err != nil {
return err
}
if current.ApprovalStatus == ApprovalStatusPending {
return ErrConcurrentPending
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
@@ -508,21 +587,39 @@ func (s *AppointmentService) Delete(ctx context.Context, userID, appointmentID u
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.appointments WHERE id = $1`, appointmentID); err != nil {
return fmt.Errorf("delete appointment: %w", err)
}
if current.ProjectID != nil {
desc := current.Title
descPtr := &desc
if err := insertProjectEvent(ctx, tx, *current.ProjectID, userID, "appointment_deleted", "Appointment deleted", descPtr); err != nil {
// Approval gate runs first for project-attached appointments. If a
// policy applies, SubmitDelete returns a non-nil request id and we
// skip the hard delete + the deletion event emit.
var pendingRequest *uuid.UUID
if current.ProjectID != nil && s.approvals != nil {
preImage := map[string]any{
"title": current.Title,
"start_at": current.StartAt.Format(time.RFC3339),
}
req, err := s.approvals.SubmitDelete(ctx, tx, *current.ProjectID, appointmentID, userID, EntityTypeAppointment, preImage)
if err != nil {
return err
}
pendingRequest = req
}
if pendingRequest == nil {
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.appointments WHERE id = $1`, appointmentID); err != nil {
return fmt.Errorf("delete appointment: %w", err)
}
if current.ProjectID != nil {
desc := current.Title
descPtr := &desc
if err := insertProjectEvent(ctx, tx, *current.ProjectID, userID, "appointment_deleted", "Appointment deleted", descPtr); err != nil {
return err
}
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit delete appointment: %w", err)
}
if s.caldav != nil {
if pendingRequest == nil && s.caldav != nil {
s.caldav.OnAppointmentDeleted(ctx, userID, current)
}
return nil

View File

@@ -0,0 +1,108 @@
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.
// 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"
// EntityType values for the polymorphic approval workflow.
const (
EntityTypeDeadline = "deadline"
EntityTypeAppointment = "appointment"
)
// LifecycleEvent values matching paliad.approval_policies.lifecycle_event
// and paliad.approval_requests.lifecycle_event CHECK constraints.
const (
LifecycleCreate = "create"
LifecycleUpdate = "update"
LifecycleComplete = "complete"
LifecycleDelete = "delete"
)
// ApprovalStatus values on paliad.deadlines.approval_status and
// paliad.appointments.approval_status.
const (
ApprovalStatusApproved = "approved"
ApprovalStatusPending = "pending"
ApprovalStatusLegacy = "legacy"
)
// RequestStatus values on paliad.approval_requests.status.
const (
RequestStatusPending = "pending"
RequestStatusApproved = "approved"
RequestStatusRejected = "rejected"
RequestStatusRevoked = "revoked"
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
// — added by t-paliad-139 / migration 055). Verlauf chronology renders
// these distinctly.
const (
DecisionKindPeer = "peer"
DecisionKindAdminOverride = "admin_override"
DecisionKindDerivedPeer = "derived_peer"
)
// levelOf maps a project_teams.role value to its strict-ladder level.
// Mirrors paliad.approval_role_level(text) in SQL.
//
// 5: lead — partner-tier on this project
// 4: of_counsel
// 3: associate ← default required level on new policies
// 2: senior_pa — added by migration 054
// 1: pa
// 0: local_counsel / expert / observer / anything new — ineligible to approve
func levelOf(role string) int {
switch role {
case "lead":
return 5
case "of_counsel":
return 4
case "associate":
return 3
case RoleSeniorPA:
return 2
case "pa":
return 1
default:
return 0
}
}
// 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).
func IsValidRequiredRole(role string) bool {
return levelOf(role) > 0
}
// Approval-flow errors. Handlers map these to the right HTTP status:
//
// ErrSelfApproval -> 403
// ErrNoQualifiedApprover -> 409 (with required_role hint)
// ErrConcurrentPending -> 409 (with the existing request id hint)
// ErrNotApprover -> 403
// ErrRequestNotPending -> 409
// ErrUnknownEntityType -> 500 (programming error)
var (
ErrSelfApproval = errors.New("self-approval blocked")
ErrNoQualifiedApprover = errors.New("no qualified approver available")
ErrConcurrentPending = errors.New("entity already has a pending approval request")
ErrNotApprover = errors.New("not authorized to approve this request")
ErrRequestNotPending = errors.New("request is not pending")
ErrUnknownEntityType = errors.New("unknown entity type")
)

View File

@@ -0,0 +1,932 @@
package services
// ApprovalService implements the 4-Augen-Prüfung workflow on
// paliad.deadlines and paliad.appointments (t-paliad-138).
//
// Architecture: write-then-approve (m's Q5 choice). The mutation lands on
// the entity row immediately; the entity carries approval_status='pending'
// + pending_request_id until an approver flips it to 'approved'. Delete is
// the one stage-then-write exception — we mark the row pending instead of
// hard-deleting, then hard-delete on approve / restore on reject.
//
// Submission entry points (Submit{Create,Update,Complete,Delete}) are
// invoked by DeadlineService / AppointmentService inside their existing
// transactions. They:
// 1. Look up the policy for (project, entity_type, lifecycle_event).
// 2. If no policy → no-op (entity stays approval_status='approved').
// 3. If policy → run a deadlock check (qualified approver != requester
// must exist), insert an approval_requests row, mark the entity
// pending, emit a *_approval_requested project_events row.
//
// Decision entry points (Approve / Reject / Revoke) run their own tx and:
// - Approve: validate canApprove(caller, request); flip the entity back
// to approved (or hard-delete for delete-lifecycle); emit
// *_approval_approved.
// - Reject: validate canApprove; revert the entity from pre_image (or
// hard-delete a pending-create); emit *_approval_rejected.
// - Revoke: validate caller == requester; same revert as Reject; emit
// *_approval_revoked.
//
// Self-approval is blocked at three layers:
// 1. canApprove() returns ErrSelfApproval when caller == requester.
// 2. The DB CHECK constraint approval_requests_no_self_approval refuses
// decided_by == requested_by writes.
// 3. The deadlock-check excludes the requester from the qualified-approver
// pool, so the deadlock path can't be silently bypassed.
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/models"
)
// ApprovalService is the workflow orchestrator. It holds no entity-specific
// knowledge — DeadlineService / AppointmentService call its Submit*
// methods, and the Approve / Reject / Revoke paths run direct SQL on the
// entity tables to keep the dependency graph acyclic.
type ApprovalService struct {
db *sqlx.DB
users *UserService
}
// NewApprovalService wires the service.
func NewApprovalService(db *sqlx.DB, users *UserService) *ApprovalService {
return &ApprovalService{db: db, users: users}
}
// LookupPolicy returns the approval policy for the given tuple, or nil if
// none exists. Read inside the same tx as Submit* so policy reads see
// whatever the calling tx may have already written.
func (s *ApprovalService) LookupPolicy(ctx context.Context, tx *sqlx.Tx, projectID uuid.UUID, entityType, lifecycleEvent string) (*models.ApprovalPolicy, error) {
var p models.ApprovalPolicy
q := `SELECT id, project_id, entity_type, lifecycle_event, required_role,
created_at, updated_at, created_by
FROM paliad.approval_policies
WHERE project_id = $1 AND entity_type = $2 AND lifecycle_event = $3`
row := txOrDB(tx, s.db).QueryRowxContext(ctx, q, projectID, entityType, lifecycleEvent)
if err := row.StructScan(&p); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("lookup approval policy: %w", err)
}
return &p, nil
}
// 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.
//
// 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.
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
FROM paliad.projects p WHERE p.id = $1
)
SELECT EXISTS (
SELECT 1 FROM paliad.project_teams pt
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)
UNION ALL
SELECT 1 FROM paliad.users u
WHERE u.global_role = 'global_admin' AND u.id <> $2
UNION ALL
SELECT 1 FROM paliad.project_partner_units ppu
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
JOIN path ON ppu.project_id = ANY(path.ids)
WHERE pum.user_id <> $2
AND ppu.derive_grants_authority = true
AND pum.unit_role = ANY(ppu.derive_unit_roles)
AND paliad.approval_role_level(
paliad.approval_role_from_unit_role(pum.unit_role)
) >= paliad.approval_role_level($3)
LIMIT 1
) AS ok`
var ok bool
if err := txOrDB(tx, s.db).GetContext(ctx, &ok, q, projectID, requesterID, requiredRole); err != nil {
return false, fmt.Errorf("deadlock check: %w", err)
}
return ok, nil
}
// SubmitCreate is invoked by Deadline/AppointmentService inside their
// create-tx, after the entity row has been INSERTed but before the
// commit. If a (project, entity_type, 'create') policy applies, it inserts
// the approval_requests row, marks the entity pending, and emits the
// *_approval_requested audit event.
//
// payload is the just-inserted entity's field values (used as audit echo).
//
// Returns the new request ID if pending, nil if no policy applied.
func (s *ApprovalService) SubmitCreate(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType string, payload map[string]any) (*uuid.UUID, error) {
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleCreate, nil, payload)
}
// SubmitUpdate is invoked after the entity row has been UPDATEd. preImage
// carries the date-bearing fields that were just overwritten (per Q4
// allowlist) so a rejection can restore them. payload echoes the new values.
func (s *ApprovalService) SubmitUpdate(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType string, preImage, payload map[string]any) (*uuid.UUID, error) {
if len(preImage) == 0 {
// Nothing in the date-bearing allowlist actually changed — bypass
// the approval flow entirely (the underlying UPDATE was cosmetic).
return nil, nil
}
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleUpdate, preImage, payload)
}
// SubmitComplete is invoked after status was flipped to 'completed'
// (deadline) or completed_at was set (appointment). preImage stores the
// pre-completion state so a rejection can revert.
func (s *ApprovalService) SubmitComplete(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType string, preImage, payload map[string]any) (*uuid.UUID, error) {
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleComplete, preImage, payload)
}
// SubmitDelete is invoked WITHOUT a prior delete on the entity (delete is
// the stage-then-write exception). The entity row stays alive with
// approval_status='pending'; on approve we hard-delete, on reject we just
// clear the pending markers.
//
// preImage stores the full row state so the inbox can render
// "About to delete: Frist X (due 2026-05-12)".
func (s *ApprovalService) SubmitDelete(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType string, preImage map[string]any) (*uuid.UUID, error) {
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleDelete, preImage, nil)
}
// submit is the shared lifecycle-handling kernel.
func (s *ApprovalService) submit(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType, lifecycle string, preImage, payload map[string]any) (*uuid.UUID, error) {
policy, err := s.LookupPolicy(ctx, tx, projectID, entityType, lifecycle)
if err != nil {
return nil, err
}
if policy == nil {
// No policy applies — entity stays approval_status='approved'. No-op.
return nil, nil
}
// Deadlock check: somebody other than the requester must be qualified
// to approve, either via project team membership or as global_admin.
ok, err := s.hasQualifiedApprover(ctx, tx, projectID, requesterID, policy.RequiredRole)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("%w: required role %q", ErrNoQualifiedApprover, policy.RequiredRole)
}
// Concurrent-pending guard: the entity table has a CHECK / NOT NULL
// guard against double-pending — but we surface a clean error rather
// than letting the UPDATE silently fail. The guard relies on
// approval_status='approved' being the precondition for a fresh
// pending state.
currentStatus, err := s.entityApprovalStatus(ctx, tx, entityType, entityID)
if err != nil {
return nil, err
}
if currentStatus == ApprovalStatusPending {
return nil, ErrConcurrentPending
}
requestID := uuid.New()
preImageJSON, err := marshalJSONOrNull(preImage)
if err != nil {
return nil, fmt.Errorf("marshal pre_image: %w", err)
}
payloadJSON, err := marshalJSONOrNull(payload)
if err != nil {
return nil, fmt.Errorf("marshal payload: %w", err)
}
insertReqSQL := `INSERT INTO paliad.approval_requests
(id, project_id, entity_type, entity_id, lifecycle_event,
pre_image, payload, requested_by, required_role, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending')`
if _, err := tx.ExecContext(ctx, insertReqSQL,
requestID, projectID, entityType, entityID, lifecycle,
preImageJSON, payloadJSON, requesterID, policy.RequiredRole); err != nil {
return nil, fmt.Errorf("insert approval_request: %w", err)
}
// Mark the entity row pending. The WHERE approval_status='approved'
// (or 'legacy') guard makes the UPDATE atomic vs concurrent pending.
updateEntitySQL := fmt.Sprintf(`UPDATE paliad.%s
SET approval_status = 'pending', pending_request_id = $1, updated_at = now()
WHERE id = $2 AND approval_status IN ('approved','legacy')`,
entityTableName(entityType))
res, err := tx.ExecContext(ctx, updateEntitySQL, requestID, entityID)
if err != nil {
return nil, fmt.Errorf("mark entity pending: %w", err)
}
rows, _ := res.RowsAffected()
if rows != 1 {
// Either the entity vanished or another tx flipped it pending.
return nil, ErrConcurrentPending
}
// Audit emit.
eventType := approvalEventType(entityType, "requested")
descPtr := approvalDescription("requested", policy.RequiredRole, lifecycle)
meta := map[string]any{
"approval_request_id": requestID.String(),
"lifecycle_event": lifecycle,
"required_role": policy.RequiredRole,
entityType + "_id": entityID.String(),
}
if err := insertProjectEventWithMeta(ctx, tx, projectID, requesterID, eventType, eventType, descPtr, meta); err != nil {
return nil, err
}
return &requestID, nil
}
// Approve flips a pending request to 'approved' and applies the lifecycle
// to the entity. Runs in its own transaction.
func (s *ApprovalService) Approve(ctx context.Context, requestID, callerID uuid.UUID, note string) error {
return s.decide(ctx, requestID, callerID, RequestStatusApproved, note)
}
// Reject flips a pending request to 'rejected' and reverts the entity from
// pre_image. Runs in its own transaction.
func (s *ApprovalService) Reject(ctx context.Context, requestID, callerID uuid.UUID, note string) error {
return s.decide(ctx, requestID, callerID, RequestStatusRejected, note)
}
// Revoke is invoked by the requester to undo their own pending submission
// before any approver acts on it. The entity reverts as if the request had
// been rejected, but the request status is 'revoked'. Runs in its own tx.
func (s *ApprovalService) Revoke(ctx context.Context, requestID, callerID uuid.UUID) error {
return s.decide(ctx, requestID, callerID, RequestStatusRevoked, "")
}
// decide is the shared kernel for Approve / Reject / Revoke. The decision
// kind is derived from the (caller, request) relationship and the requested
// final status:
// - RequestStatusApproved: caller must NOT be requester; admin override or peer.
// - RequestStatusRejected: same authorization rules as Approve.
// - RequestStatusRevoked: caller MUST be requester.
func (s *ApprovalService) decide(ctx context.Context, requestID, callerID uuid.UUID, finalStatus, note string) error {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
req, err := s.getRequestForUpdate(ctx, tx, requestID)
if err != nil {
return err
}
if req.Status != RequestStatusPending {
return fmt.Errorf("%w: status=%s", ErrRequestNotPending, req.Status)
}
var decisionKind string
switch finalStatus {
case RequestStatusApproved, RequestStatusRejected:
kind, err := s.canApprove(ctx, tx, callerID, req)
if err != nil {
return err
}
decisionKind = kind
case RequestStatusRevoked:
if callerID != req.RequestedBy {
return ErrNotApprover
}
decisionKind = DecisionKindPeer // unused for revoke but keeps non-NULL audit
default:
return fmt.Errorf("invalid final status %q", finalStatus)
}
// Apply the lifecycle outcome to the entity.
switch finalStatus {
case RequestStatusApproved:
if err := s.applyApproved(ctx, tx, req, callerID); err != nil {
return err
}
case RequestStatusRejected, RequestStatusRevoked:
if err := s.applyRevert(ctx, tx, req); err != nil {
return err
}
}
// Update the request row.
now := time.Now().UTC()
var trimmedNote *string
if n := strings.TrimSpace(note); n != "" {
trimmedNote = &n
}
updateReqSQL := `UPDATE paliad.approval_requests
SET status = $1, decided_by = $2, decided_at = $3, decision_kind = $4,
decision_note = $5, updated_at = $3
WHERE id = $6`
// For revoke, decided_by stays NULL (the requester didn't "decide" to
// approve, they pulled the request) — but a CHECK (decided_by != requested_by)
// would block decided_by=requester anyway. NULL is correct.
var decidedBy any
var decisionKindArg any
if finalStatus != RequestStatusRevoked {
decidedBy = callerID
decisionKindArg = decisionKind
} else {
decidedBy = nil
decisionKindArg = nil
}
if _, err := tx.ExecContext(ctx, updateReqSQL,
finalStatus, decidedBy, now, decisionKindArg, trimmedNote, requestID); err != nil {
return fmt.Errorf("update approval_request: %w", err)
}
// Audit emit.
var verlaufKind string
switch finalStatus {
case RequestStatusApproved:
verlaufKind = "approved"
case RequestStatusRejected:
verlaufKind = "rejected"
case RequestStatusRevoked:
verlaufKind = "revoked"
}
eventType := approvalEventType(req.EntityType, verlaufKind)
descPtr := approvalDescription(verlaufKind, req.RequiredRole, req.LifecycleEvent)
meta := map[string]any{
"approval_request_id": req.ID.String(),
"lifecycle_event": req.LifecycleEvent,
req.EntityType + "_id": req.EntityID.String(),
}
if finalStatus != RequestStatusRevoked {
meta["decision_kind"] = decisionKind
}
if trimmedNote != nil {
meta["decision_note"] = *trimmedNote
}
if err := insertProjectEventWithMeta(ctx, tx, req.ProjectID, callerID, eventType, eventType, descPtr, meta); err != nil {
return err
}
return tx.Commit()
}
// canApprove enforces the strict-ladder gate plus the no-self-approval
// rule. Returns the decision_kind ('peer' | 'admin_override' |
// 'derived_peer') the caller should record, or an error.
//
// Resolution order (t-paliad-139 §4.2):
// 1. Self-approval is hard-blocked.
// 2. global_admin always wins ('admin_override').
// 3. Direct or ancestor project_teams membership with sufficient role
// ('peer').
// 4. Partner-unit-derived membership with derive_grants_authority=true
// and a unit_role that maps (via approval_role_from_unit_role) to a
// project_role with sufficient level ('derived_peer').
func (s *ApprovalService) canApprove(ctx context.Context, tx *sqlx.Tx, callerID uuid.UUID, req *models.ApprovalRequest) (string, error) {
if callerID == req.RequestedBy {
return "", ErrSelfApproval
}
user, err := s.users.GetByID(ctx, callerID)
if err != nil {
return "", err
}
if user == nil {
return "", ErrNotApprover
}
if user.GlobalRole == "global_admin" {
return DecisionKindAdminOverride, nil
}
// Path-walk: check direct OR ancestor team membership with sufficient role.
q := `SELECT EXISTS (
SELECT 1 FROM paliad.project_teams pt
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)
)`
var ok bool
if err := tx.GetContext(ctx, &ok, q, callerID, req.ProjectID, req.RequiredRole); err != nil {
return "", fmt.Errorf("authorization check: %w", err)
}
if ok {
return DecisionKindPeer, nil
}
// t-paliad-139 derivation branch: check authority-granting partner-unit
// attachments on the project's path.
qDerived := `SELECT EXISTS (
SELECT 1 FROM paliad.project_partner_units ppu
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
WHERE pum.user_id = $1
AND ppu.project_id = ANY(string_to_array(
(SELECT path FROM paliad.projects WHERE id = $2), '.')::uuid[])
AND ppu.derive_grants_authority = true
AND pum.unit_role = ANY(ppu.derive_unit_roles)
AND paliad.approval_role_level(
paliad.approval_role_from_unit_role(pum.unit_role)
) >= paliad.approval_role_level($3)
)`
var derivedOK bool
if err := tx.GetContext(ctx, &derivedOK, qDerived, callerID, req.ProjectID, req.RequiredRole); err != nil {
return "", fmt.Errorf("derived authorization check: %w", err)
}
if derivedOK {
return DecisionKindDerivedPeer, nil
}
return "", ErrNotApprover
}
// applyApproved finalises the lifecycle on the entity row.
func (s *ApprovalService) applyApproved(ctx context.Context, tx *sqlx.Tx, req *models.ApprovalRequest, approverID uuid.UUID) error {
table := entityTableName(req.EntityType)
now := time.Now().UTC()
if req.LifecycleEvent == LifecycleDelete {
// Hard-delete the entity. The approval_requests.entity_id reference
// is a polymorphic uuid (no FK) so it survives the row going away.
// pending_request_id on the entity has ON DELETE SET NULL but the
// entity is the one being deleted, not the request — so this is
// just a plain DELETE.
if _, err := tx.ExecContext(ctx,
fmt.Sprintf(`DELETE FROM paliad.%s WHERE id = $1`, table),
req.EntityID); err != nil {
return fmt.Errorf("delete on approve: %w", err)
}
return nil
}
// Non-delete approve = clear pending markers, set approved_by/at.
q := fmt.Sprintf(`UPDATE paliad.%s
SET approval_status = 'approved',
pending_request_id = NULL,
approved_by = $1,
approved_at = $2,
updated_at = $2
WHERE id = $3`, table)
if _, err := tx.ExecContext(ctx, q, approverID, now, req.EntityID); err != nil {
return fmt.Errorf("clear pending on approve: %w", err)
}
return nil
}
// applyRevert undoes the in-flight change on the entity row, restoring it
// from the request's pre_image jsonb. Used by both Reject and Revoke.
func (s *ApprovalService) applyRevert(ctx context.Context, tx *sqlx.Tx, req *models.ApprovalRequest) error {
table := entityTableName(req.EntityType)
switch req.LifecycleEvent {
case LifecycleCreate:
// The entity should never have existed. Hard-delete.
if _, err := tx.ExecContext(ctx,
fmt.Sprintf(`DELETE FROM paliad.%s WHERE id = $1`, table),
req.EntityID); err != nil {
return fmt.Errorf("delete on reject-create: %w", err)
}
return nil
case LifecycleDelete:
// We never deleted the entity (delete is stage-then-write); just
// clear the pending markers so the row is fully alive again.
q := fmt.Sprintf(`UPDATE paliad.%s
SET approval_status = CASE WHEN approval_status = 'pending'
THEN 'approved' ELSE approval_status END,
pending_request_id = NULL,
updated_at = now()
WHERE id = $1`, table)
if _, err := tx.ExecContext(ctx, q, req.EntityID); err != nil {
return fmt.Errorf("clear pending on reject-delete: %w", err)
}
return nil
case LifecycleUpdate, LifecycleComplete:
// Restore pre_image fields, clear pending markers.
preImage := map[string]any{}
if len(req.PreImage) > 0 {
if err := json.Unmarshal(req.PreImage, &preImage); err != nil {
return fmt.Errorf("unmarshal pre_image: %w", err)
}
}
setClauses, args, err := buildRevertSetClauses(req.EntityType, preImage)
if err != nil {
return err
}
// Always clear pending markers + revert approval_status.
setClauses = append(setClauses,
"approval_status = 'approved'",
"pending_request_id = NULL",
"updated_at = now()")
args = append(args, req.EntityID)
q := fmt.Sprintf(`UPDATE paliad.%s SET %s WHERE id = $%d`,
table, strings.Join(setClauses, ", "), len(args))
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
return fmt.Errorf("revert entity from pre_image: %w", err)
}
return nil
default:
return fmt.Errorf("%w: lifecycle %q", ErrUnknownEntityType, req.LifecycleEvent)
}
}
// buildRevertSetClauses translates pre_image jsonb keys into SQL SET
// fragments. Only the date-bearing allowlist (Q4) is honoured; unknown
// keys are silently dropped to defend against malformed pre_image rows
// (defence-in-depth: callers should already be sending only allowlisted
// fields, but a hostile UPDATE on the request row shouldn't let arbitrary
// fields be reverted).
func buildRevertSetClauses(entityType string, preImage map[string]any) ([]string, []any, error) {
var setClauses []string
var args []any
add := func(col string, val any) {
args = append(args, val)
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, len(args)))
}
switch entityType {
case EntityTypeDeadline:
for _, col := range []string{"due_date", "original_due_date", "warning_date"} {
if v, ok := preImage[col]; ok {
add(col, v)
}
}
// Complete-revert restores status='pending' + completed_at NULL.
// We detect this branch by the presence of a status key; lifecycle
// is the formal source but pre_image is what the caller stored.
if v, ok := preImage["status"]; ok {
add("status", v)
}
if _, ok := preImage["completed_at"]; ok {
// Always NULL on revert — completion didn't really happen.
args = append(args, nil)
setClauses = append(setClauses, fmt.Sprintf("completed_at = $%d", len(args)))
}
case EntityTypeAppointment:
for _, col := range []string{"start_at", "end_at"} {
if v, ok := preImage[col]; ok {
add(col, v)
}
}
if _, ok := preImage["completed_at"]; ok {
args = append(args, nil)
setClauses = append(setClauses, fmt.Sprintf("completed_at = $%d", len(args)))
}
default:
return nil, nil, fmt.Errorf("%w: %q", ErrUnknownEntityType, entityType)
}
if len(setClauses) == 0 {
return nil, nil, fmt.Errorf("%w: empty pre_image for %s", ErrUnknownEntityType, entityType)
}
return setClauses, args, nil
}
// getRequestForUpdate locks an approval_requests row inside the tx for
// decision processing.
func (s *ApprovalService) getRequestForUpdate(ctx context.Context, tx *sqlx.Tx, requestID uuid.UUID) (*models.ApprovalRequest, error) {
var req models.ApprovalRequest
q := `SELECT id, project_id, entity_type, entity_id, lifecycle_event,
pre_image, payload, requested_by, requested_at, required_role,
status, decided_by, decided_at, decision_kind, decision_note,
created_at, updated_at
FROM paliad.approval_requests
WHERE id = $1
FOR UPDATE`
if err := tx.GetContext(ctx, &req, q, requestID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrRequestNotPending
}
return nil, fmt.Errorf("load request: %w", err)
}
return &req, nil
}
// entityApprovalStatus reads the current approval_status on the entity
// row. Returns "" if the row doesn't exist.
func (s *ApprovalService) entityApprovalStatus(ctx context.Context, tx *sqlx.Tx, entityType string, entityID uuid.UUID) (string, error) {
q := fmt.Sprintf(`SELECT approval_status FROM paliad.%s WHERE id = $1`,
entityTableName(entityType))
var status string
if err := txOrDB(tx, s.db).GetContext(ctx, &status, q, entityID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return "", fmt.Errorf("read approval_status: %w", err)
}
return status, nil
}
// entityTableName resolves the SQL table name for a given entity_type.
// Internal helper — entityType comes from server-side constants, not user
// input, so a panic on an unknown value is a programming error.
func entityTableName(entityType string) string {
switch entityType {
case EntityTypeDeadline:
return "deadlines"
case EntityTypeAppointment:
return "appointments"
default:
panic(fmt.Sprintf("approval: unknown entity_type %q", entityType))
}
}
// approvalEventType returns the project_events.event_type value for a
// given (entity, lifecycle-step) pair. Step is one of "requested" |
// "approved" | "rejected" | "revoked".
func approvalEventType(entityType, step string) string {
return entityType + "_approval_" + step
}
// approvalDescription returns the short audit description string. Frontend
// renders the localized version via translateEvent; this is the raw audit
// row's description column, used as a fallback and for /admin/audit-log.
func approvalDescription(step, requiredRole, lifecycle string) *string {
d := fmt.Sprintf("%s — %s/%s", step, lifecycle, requiredRole)
return &d
}
// txOrDB returns the tx if non-nil, else the db. Lets read helpers run
// either inside a calling tx (for consistency with concurrent writes) or
// standalone for List endpoints.
func txOrDB(tx *sqlx.Tx, db *sqlx.DB) sqlxQueryer {
if tx != nil {
return tx
}
return db
}
// sqlxQueryer is the minimal subset of *sqlx.DB / *sqlx.Tx we need.
// Defined here to avoid adding a public abstraction across the package.
type sqlxQueryer interface {
GetContext(ctx context.Context, dest any, query string, args ...any) error
SelectContext(ctx context.Context, dest any, query string, args ...any) error
QueryRowxContext(ctx context.Context, query string, args ...any) *sqlx.Row
}
// marshalJSONOrNull returns []byte("null") JSON-RawMessage style for
// nil/empty maps so callers can pass it directly to a jsonb column without
// branching at every call site.
func marshalJSONOrNull(m map[string]any) ([]byte, error) {
if len(m) == 0 {
return nil, nil
}
return json.Marshal(m)
}
// ============================================================================
// Read paths — inbox + policy CRUD.
// ============================================================================
// ApprovalRequestView is the inbox-friendly projection of an approval
// request: the bare ApprovalRequest plus the contextual labels the inbox
// needs to render a row without further fetches.
type ApprovalRequestView struct {
models.ApprovalRequest
ProjectTitle string `db:"project_title" json:"project_title"`
EntityTitle *string `db:"entity_title" json:"entity_title,omitempty"`
RequesterName string `db:"requester_name" json:"requester_name"`
RequesterEmail string `db:"requester_email" json:"requester_email"`
DeciderName *string `db:"decider_name" json:"decider_name,omitempty"`
DeciderEmail *string `db:"decider_email" json:"decider_email,omitempty"`
}
const approvalRequestViewColumns = `
ar.id, ar.project_id, ar.entity_type, ar.entity_id, ar.lifecycle_event,
ar.pre_image, ar.payload, ar.requested_by, ar.requested_at, ar.required_role,
ar.status, ar.decided_by, ar.decided_at, ar.decision_kind, ar.decision_note,
ar.created_at, ar.updated_at,
p.title AS project_title,
CASE WHEN ar.entity_type = 'deadline' THEN d.title
WHEN ar.entity_type = 'appointment' THEN a.title
END AS entity_title,
COALESCE(ru.display_name, ru.email) AS requester_name,
ru.email AS requester_email,
du.display_name AS decider_name,
du.email AS decider_email`
const approvalRequestViewJoins = `
paliad.approval_requests ar
JOIN paliad.projects p ON p.id = ar.project_id
JOIN paliad.users ru ON ru.id = ar.requested_by
LEFT JOIN paliad.users du ON du.id = ar.decided_by
LEFT JOIN paliad.deadlines d ON ar.entity_type = 'deadline' AND d.id = ar.entity_id
LEFT JOIN paliad.appointments a ON ar.entity_type = 'appointment' AND a.id = ar.entity_id`
// InboxFilter narrows the inbox listings.
type InboxFilter struct {
Status string // "" → no filter; otherwise one of RequestStatus*
ProjectID *uuid.UUID
EntityType string // "" → both
Limit int // 0 → 100
}
// ListPendingForApprover returns approval requests where the caller is
// qualified to approve and is not the requester.
func (s *ApprovalService) ListPendingForApprover(ctx context.Context, callerID uuid.UUID, filter InboxFilter) ([]ApprovalRequestView, error) {
limit := filter.Limit
if limit <= 0 || limit > 200 {
limit = 100
}
conds := []string{
"ar.status = 'pending'",
"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 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).
`(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
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)
)
OR EXISTS (
SELECT 1 FROM paliad.project_partner_units ppu
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
WHERE pum.user_id = $1
AND ppu.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND ppu.derive_grants_authority = true
AND pum.unit_role = ANY(ppu.derive_unit_roles)
AND paliad.approval_role_level(
paliad.approval_role_from_unit_role(pum.unit_role)
) >= paliad.approval_role_level(ar.required_role)
))`,
}
args := []any{callerID}
if filter.ProjectID != nil {
args = append(args, *filter.ProjectID)
conds = append(conds, fmt.Sprintf("ar.project_id = $%d", len(args)))
}
if filter.EntityType != "" {
args = append(args, filter.EntityType)
conds = append(conds, fmt.Sprintf("ar.entity_type = $%d", len(args)))
}
args = append(args, limit)
q := fmt.Sprintf(`SELECT %s FROM %s WHERE %s ORDER BY ar.requested_at ASC LIMIT $%d`,
approvalRequestViewColumns, approvalRequestViewJoins,
strings.Join(conds, " AND "), len(args))
var out []ApprovalRequestView
if err := s.db.SelectContext(ctx, &out, q, args...); err != nil {
return nil, fmt.Errorf("list pending for approver: %w", err)
}
return out, nil
}
// ListSubmittedByUser returns approval requests authored by the caller.
// Status filter optional.
func (s *ApprovalService) ListSubmittedByUser(ctx context.Context, callerID uuid.UUID, filter InboxFilter) ([]ApprovalRequestView, error) {
limit := filter.Limit
if limit <= 0 || limit > 200 {
limit = 100
}
conds := []string{"ar.requested_by = $1"}
args := []any{callerID}
if filter.Status != "" {
args = append(args, filter.Status)
conds = append(conds, fmt.Sprintf("ar.status = $%d", len(args)))
}
if filter.ProjectID != nil {
args = append(args, *filter.ProjectID)
conds = append(conds, fmt.Sprintf("ar.project_id = $%d", len(args)))
}
if filter.EntityType != "" {
args = append(args, filter.EntityType)
conds = append(conds, fmt.Sprintf("ar.entity_type = $%d", len(args)))
}
args = append(args, limit)
q := fmt.Sprintf(`SELECT %s FROM %s WHERE %s ORDER BY ar.requested_at DESC LIMIT $%d`,
approvalRequestViewColumns, approvalRequestViewJoins,
strings.Join(conds, " AND "), len(args))
var out []ApprovalRequestView
if err := s.db.SelectContext(ctx, &out, q, args...); err != nil {
return nil, fmt.Errorf("list submitted by user: %w", err)
}
return out, nil
}
// GetRequest returns one approval request hydrated for the inbox detail
// view. Visibility is gated upstream by the handler (anyone with project
// access can see the request).
func (s *ApprovalService) GetRequest(ctx context.Context, requestID uuid.UUID) (*ApprovalRequestView, error) {
q := fmt.Sprintf(`SELECT %s FROM %s WHERE ar.id = $1`,
approvalRequestViewColumns, approvalRequestViewJoins)
var v ApprovalRequestView
if err := s.db.GetContext(ctx, &v, q, requestID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("get approval request: %w", err)
}
return &v, nil
}
// PendingCountForUser returns how many requests await this user's approval.
// Cheap query for the sidebar bell badge.
//
// Eligibility mirrors ListPendingForApprover: global_admin OR direct/
// ancestor project_teams role meeting the threshold OR partner-unit-
// derived authority (t-paliad-139).
func (s *ApprovalService) PendingCountForUser(ctx context.Context, callerID uuid.UUID) (int, error) {
q := `SELECT COUNT(*)
FROM paliad.approval_requests ar
JOIN paliad.projects p ON p.id = ar.project_id
WHERE ar.status = 'pending'
AND ar.requested_by <> $1
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
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)
)
OR EXISTS (
SELECT 1 FROM paliad.project_partner_units ppu
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
WHERE pum.user_id = $1
AND ppu.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND ppu.derive_grants_authority = true
AND pum.unit_role = ANY(ppu.derive_unit_roles)
AND paliad.approval_role_level(
paliad.approval_role_from_unit_role(pum.unit_role)
) >= paliad.approval_role_level(ar.required_role)
))`
var n int
if err := s.db.GetContext(ctx, &n, q, callerID); err != nil {
return 0, fmt.Errorf("pending count: %w", err)
}
return n, nil
}
// ============================================================================
// Policy CRUD — paliad.approval_policies.
// ============================================================================
// ListPolicies returns the (up to 8) policy rows for a project. Caller
// must already have project visibility.
func (s *ApprovalService) ListPolicies(ctx context.Context, projectID uuid.UUID) ([]models.ApprovalPolicy, error) {
q := `SELECT id, project_id, entity_type, lifecycle_event, required_role,
created_at, updated_at, created_by
FROM paliad.approval_policies
WHERE project_id = $1
ORDER BY entity_type, lifecycle_event`
var out []models.ApprovalPolicy
if err := s.db.SelectContext(ctx, &out, q, projectID); err != nil {
return nil, fmt.Errorf("list approval policies: %w", err)
}
return out, nil
}
// UpsertPolicy creates or replaces a single (project, entity, lifecycle)
// policy row. Caller must be global_admin (gate enforced at handler).
func (s *ApprovalService) UpsertPolicy(ctx context.Context, projectID, callerID uuid.UUID, entityType, lifecycle, requiredRole string) (*models.ApprovalPolicy, error) {
if !IsValidRequiredRole(requiredRole) {
return nil, fmt.Errorf("%w: required_role %q", ErrInvalidInput, requiredRole)
}
if entityType != EntityTypeDeadline && entityType != EntityTypeAppointment {
return nil, fmt.Errorf("%w: entity_type %q", ErrInvalidInput, entityType)
}
switch lifecycle {
case LifecycleCreate, LifecycleUpdate, LifecycleComplete, LifecycleDelete:
default:
return nil, fmt.Errorf("%w: lifecycle_event %q", ErrInvalidInput, lifecycle)
}
q := `INSERT INTO paliad.approval_policies
(project_id, entity_type, lifecycle_event, required_role, created_by)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (project_id, entity_type, lifecycle_event)
DO UPDATE SET required_role = EXCLUDED.required_role,
updated_at = now()
RETURNING id, project_id, entity_type, lifecycle_event, required_role,
created_at, updated_at, created_by`
var p models.ApprovalPolicy
if err := s.db.GetContext(ctx, &p, q, projectID, entityType, lifecycle, requiredRole, callerID); err != nil {
return nil, fmt.Errorf("upsert approval policy: %w", err)
}
return &p, nil
}
// DeletePolicy removes a single (project, entity, lifecycle) policy row,
// reverting that lifecycle event back to the no-approval-needed default.
func (s *ApprovalService) DeletePolicy(ctx context.Context, projectID uuid.UUID, entityType, lifecycle string) error {
q := `DELETE FROM paliad.approval_policies
WHERE project_id = $1 AND entity_type = $2 AND lifecycle_event = $3`
if _, err := s.db.ExecContext(ctx, q, projectID, entityType, lifecycle); err != nil {
return fmt.Errorf("delete approval policy: %w", err)
}
return nil
}

View File

@@ -0,0 +1,617 @@
package services
// Approval-service tests. Two layers:
//
// - Pure-Go: levelOf strict ladder + IsValidRequiredRole. 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
// deadline_service_test.
import (
"context"
"errors"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
// ============================================================================
// Pure-Go tests.
// ============================================================================
func TestLevelOf_StrictLadder(t *testing.T) {
cases := []struct {
role string
want int
}{
{"lead", 5},
{"of_counsel", 4},
{"associate", 3},
{"senior_pa", 2},
{"pa", 1},
{"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)
}
})
}
}
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")
}
if levelOf("associate") <= levelOf("senior_pa") {
t.Errorf("associate must outrank senior_pa")
}
if levelOf("senior_pa") <= levelOf("pa") {
t.Errorf("senior_pa must outrank pa")
}
if levelOf("of_counsel") <= levelOf("associate") {
t.Errorf("of_counsel must outrank associate")
}
// PA-required policy: anyone associate-or-above must satisfy.
if levelOf("associate") < levelOf("pa") {
t.Errorf("associate must satisfy a pa-required policy")
}
}
func TestIsValidRequiredRole(t *testing.T) {
cases := []struct {
role string
ok bool
}{
{"lead", true},
{"of_counsel", true},
{"associate", true},
{"senior_pa", true},
{"pa", true},
{"local_counsel", false},
{"expert", false},
{"observer", false},
{"", false},
}
for _, c := range cases {
t.Run(c.role, func(t *testing.T) {
if got := IsValidRequiredRole(c.role); got != c.ok {
t.Errorf("IsValidRequiredRole(%q) = %v, want %v", c.role, got, c.ok)
}
})
}
}
func TestApprovalEventType(t *testing.T) {
cases := []struct {
entity, step, want string
}{
{"deadline", "requested", "deadline_approval_requested"},
{"deadline", "approved", "deadline_approval_approved"},
{"deadline", "rejected", "deadline_approval_rejected"},
{"deadline", "revoked", "deadline_approval_revoked"},
{"appointment", "requested", "appointment_approval_requested"},
}
for _, c := range cases {
if got := approvalEventType(c.entity, c.step); got != c.want {
t.Errorf("approvalEventType(%q,%q) = %q, want %q",
c.entity, c.step, got, c.want)
}
}
}
// ============================================================================
// Live-DB tests.
// ============================================================================
// approvalTestEnv holds a configured ApprovalService + helpers tied to a
// throwaway project / user pool. Caller cleans up via env.cleanup().
type approvalTestEnv struct {
t *testing.T
pool *sqlx.DB
approvals *ApprovalService
deadlines *DeadlineService
users *UserService
projects *ProjectService
projectID uuid.UUID
requester uuid.UUID
approver uuid.UUID
other uuid.UUID
cleanup func()
}
func setupApprovalTest(t *testing.T) *approvalTestEnv {
t.Helper()
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
ctx := context.Background()
users := NewUserService(pool)
projects := NewProjectService(pool, users)
deadlines := NewDeadlineService(pool, projects, nil)
approvals := NewApprovalService(pool, users)
// Seed two users + one project. The requester owns the deadline; the
// approver is the other lead on the team. "other" has no role and is
// used for the deadlock check (no qualified approver scenario).
requesterID := uuid.New()
approverID := uuid.New()
otherID := uuid.New()
for _, id := range []uuid.UUID{requesterID, approverID, otherID} {
if _, err := pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, $1::text || '@test.local')
ON CONFLICT (id) DO NOTHING`, id); err != nil {
t.Logf("skip auth.users seed: %v (continuing — auth schema may be locked down)", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, global_role)
VALUES ($1, $1::text || '@test.local', 'Test User', 'munich', 'standard')
ON CONFLICT (id) DO NOTHING`, id); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
}
projectID := uuid.New()
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects (id, type, title, status, created_by)
VALUES ($1, 'project', 'Approval Test Project', 'active', $2)`,
projectID, requesterID); err != nil {
t.Fatalf("seed project: %v", err)
}
// Add requester + approver to the project team. Requester=associate
// (cannot approve associate-required policy), approver=lead (can).
for _, m := range []struct {
uid uuid.UUID
role string
}{
{requesterID, "associate"},
{approverID, "lead"},
} {
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_teams (project_id, user_id, role)
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
projectID, m.uid, m.role); err != nil {
t.Fatalf("seed project_teams: %v", err)
}
}
cleanup := func() {
ctx := context.Background()
pool.ExecContext(ctx, `DELETE FROM paliad.approval_requests WHERE project_id = $1`, projectID)
pool.ExecContext(ctx, `DELETE FROM paliad.approval_policies WHERE project_id = $1`, projectID)
pool.ExecContext(ctx, `DELETE FROM paliad.deadlines WHERE project_id = $1`, projectID)
pool.ExecContext(ctx, `DELETE FROM paliad.appointments WHERE project_id = $1`, projectID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id = $1`, projectID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, projectID)
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, projectID)
for _, id := range []uuid.UUID{requesterID, approverID, otherID} {
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, id)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, id)
}
pool.Close()
}
return &approvalTestEnv{
t: t,
pool: pool,
approvals: approvals,
deadlines: deadlines,
users: users,
projects: projects,
projectID: projectID,
requester: requesterID,
approver: approverID,
other: otherID,
cleanup: cleanup,
}
}
// seedPolicy sets a policy on the env's project for one (entity, lifecycle).
func (e *approvalTestEnv) seedPolicy(entityType, lifecycle, requiredRole string) {
e.t.Helper()
if _, err := e.approvals.UpsertPolicy(context.Background(),
e.projectID, e.requester, entityType, lifecycle, requiredRole); err != nil {
e.t.Fatalf("seed policy: %v", err)
}
}
// seedDeadline inserts a basic deadline row directly (bypassing the
// service so we can test ApprovalService.Submit* in isolation). Returns
// the deadline's ID.
func (e *approvalTestEnv) seedDeadline(due time.Time) uuid.UUID {
e.t.Helper()
id := uuid.New()
if _, err := e.pool.ExecContext(context.Background(),
`INSERT INTO paliad.deadlines (id, project_id, title, due_date, source, status, created_by, approval_status)
VALUES ($1, $2, 'Test Deadline', $3, 'manual', 'pending', $4, 'approved')`,
id, e.projectID, due, e.requester); err != nil {
e.t.Fatalf("seed deadline: %v", err)
}
return id
}
// TestApprovalService_NoPolicyIsNoop: with no policy, Submit* returns
// (nil, nil) and the entity stays approval_status='approved'.
func TestApprovalService_NoPolicyIsNoop(t *testing.T) {
env := setupApprovalTest(t)
defer env.cleanup()
ctx := context.Background()
deadlineID := env.seedDeadline(time.Now().AddDate(0, 0, 14))
tx, err := env.pool.BeginTxx(ctx, nil)
if err != nil {
t.Fatalf("begin: %v", err)
}
defer tx.Rollback()
reqID, err := env.approvals.SubmitCreate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline, nil)
if err != nil {
t.Fatalf("SubmitCreate: %v", err)
}
if reqID != nil {
t.Errorf("expected nil request id with no policy, got %v", reqID)
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit: %v", err)
}
var status string
if err := env.pool.GetContext(ctx, &status,
`SELECT approval_status FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
t.Fatalf("read status: %v", err)
}
if status != "approved" {
t.Errorf("expected approval_status=approved, got %q", status)
}
}
// TestApprovalService_SubmitMarksPendingAndApproveClears: end-to-end happy
// path. With a policy in place: submit → request row + entity pending →
// approve → entity back to approved with approved_by set.
func TestApprovalService_SubmitApproveCycle(t *testing.T) {
env := setupApprovalTest(t)
defer env.cleanup()
ctx := context.Background()
env.seedPolicy(EntityTypeDeadline, LifecycleCreate, "associate")
deadlineID := env.seedDeadline(time.Now().AddDate(0, 0, 14))
// Submit (inside a tx, as DeadlineService would).
tx, err := env.pool.BeginTxx(ctx, nil)
if err != nil {
t.Fatalf("begin: %v", err)
}
reqID, err := env.approvals.SubmitCreate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline,
map[string]any{"due_date": "2026-05-20"})
if err != nil {
tx.Rollback()
t.Fatalf("SubmitCreate: %v", err)
}
if reqID == nil {
tx.Rollback()
t.Fatalf("expected request id, got nil")
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit: %v", err)
}
// Entity is now pending.
var status string
if err := env.pool.GetContext(ctx, &status,
`SELECT approval_status FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
t.Fatalf("read status: %v", err)
}
if status != "pending" {
t.Errorf("after submit: approval_status=%q, want pending", status)
}
// Self-approval blocks.
if err := env.approvals.Approve(ctx, *reqID, env.requester, ""); !errors.Is(err, ErrSelfApproval) {
t.Errorf("self-approve: got %v, want ErrSelfApproval", err)
}
// Approver (lead) signs off.
if err := env.approvals.Approve(ctx, *reqID, env.approver, "looks good"); err != nil {
t.Fatalf("Approve: %v", err)
}
// Entity flipped back to approved with approved_by populated.
row := struct {
Status string `db:"approval_status"`
ApprovedBy *uuid.UUID `db:"approved_by"`
}{}
if err := env.pool.GetContext(ctx, &row,
`SELECT approval_status, approved_by FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
t.Fatalf("read post-approve: %v", err)
}
if row.Status != "approved" {
t.Errorf("after approve: approval_status=%q, want approved", row.Status)
}
if row.ApprovedBy == nil || *row.ApprovedBy != env.approver {
t.Errorf("after approve: approved_by=%v, want %v", row.ApprovedBy, env.approver)
}
// Request row marked approved.
var reqStatus string
if err := env.pool.GetContext(ctx, &reqStatus,
`SELECT status FROM paliad.approval_requests WHERE id = $1`, *reqID); err != nil {
t.Fatalf("read request status: %v", err)
}
if reqStatus != "approved" {
t.Errorf("request status=%q, want approved", reqStatus)
}
// Approving again fails (not pending anymore).
if err := env.approvals.Approve(ctx, *reqID, env.approver, ""); !errors.Is(err, ErrRequestNotPending) {
t.Errorf("re-approve: got %v, want ErrRequestNotPending", err)
}
}
// TestApprovalService_RejectRevertsCreateAsDelete: rejecting a CREATE
// request hard-deletes the entity (it never should have existed).
func TestApprovalService_RejectCreateDeletes(t *testing.T) {
env := setupApprovalTest(t)
defer env.cleanup()
ctx := context.Background()
env.seedPolicy(EntityTypeDeadline, LifecycleCreate, "associate")
deadlineID := env.seedDeadline(time.Now().AddDate(0, 0, 7))
tx, err := env.pool.BeginTxx(ctx, nil)
if err != nil {
t.Fatalf("begin: %v", err)
}
reqID, err := env.approvals.SubmitCreate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline, nil)
if err != nil {
tx.Rollback()
t.Fatalf("SubmitCreate: %v", err)
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit: %v", err)
}
if err := env.approvals.Reject(ctx, *reqID, env.approver, "wrong date"); err != nil {
t.Fatalf("Reject: %v", err)
}
// Entity row is gone.
var n int
if err := env.pool.GetContext(ctx, &n,
`SELECT COUNT(*) FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
t.Fatalf("count deadline: %v", err)
}
if n != 0 {
t.Errorf("after reject-create: deadline still exists (count=%d)", n)
}
}
// TestApprovalService_RejectUpdateRestoresPreImage: rejecting an UPDATE
// reverts the date fields back to the snapshotted pre_image values.
func TestApprovalService_RejectUpdateRestoresPreImage(t *testing.T) {
env := setupApprovalTest(t)
defer env.cleanup()
ctx := context.Background()
env.seedPolicy(EntityTypeDeadline, LifecycleUpdate, "associate")
originalDue := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
deadlineID := env.seedDeadline(originalDue)
// Simulate an update: set due to 2026-06-15, then submit.
newDue := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
tx, err := env.pool.BeginTxx(ctx, nil)
if err != nil {
t.Fatalf("begin: %v", err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.deadlines SET due_date = $1 WHERE id = $2`,
newDue, deadlineID); err != nil {
tx.Rollback()
t.Fatalf("UPDATE pre-submit: %v", err)
}
preImage := map[string]any{"due_date": "2026-06-01"}
payload := map[string]any{"due_date": "2026-06-15"}
reqID, err := env.approvals.SubmitUpdate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline, preImage, payload)
if err != nil {
tx.Rollback()
t.Fatalf("SubmitUpdate: %v", err)
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit: %v", err)
}
// Reject — due_date should snap back to 2026-06-01.
if err := env.approvals.Reject(ctx, *reqID, env.approver, ""); err != nil {
t.Fatalf("Reject: %v", err)
}
var got time.Time
if err := env.pool.GetContext(ctx, &got,
`SELECT due_date FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
t.Fatalf("read due_date: %v", err)
}
if !got.Equal(originalDue) {
t.Errorf("after reject-update: due_date=%v, want %v", got, originalDue)
}
}
// TestApprovalService_NoQualifiedApprover: when only the requester would
// qualify, Submit returns ErrNoQualifiedApprover.
func TestApprovalService_NoQualifiedApprover(t *testing.T) {
env := setupApprovalTest(t)
defer env.cleanup()
ctx := context.Background()
// Demote the approver to observer (level 0 = ineligible). Now requester
// (associate) is the only on-team user with any role, and observer
// can't approve.
if _, err := env.pool.ExecContext(ctx,
`UPDATE paliad.project_teams SET role='observer' WHERE project_id=$1 AND user_id=$2`,
env.projectID, env.approver); err != nil {
t.Fatalf("demote approver: %v", err)
}
// Make sure no global_admin exists in our test pool — promote-and-revert
// any existing global_admin so the deadlock kicks in. We can't safely do
// that without affecting other tests, so use a project where the
// requester is the only person + setup excludes other users.
// Easier approach: temporarily set requester to global_admin, then test
// against a different "pretend requester" — but we want the case where
// our seeded requester is the only candidate.
//
// Approach: use UpsertPolicy to set 'lead' as required role. Then no
// project team member (associate, observer) qualifies. The deadlock
// check still passes if any global_admin exists firmwide (Q8 escape
// hatch), so we accept this test may be a no-op on pools with admins.
env.seedPolicy(EntityTypeDeadline, LifecycleCreate, "lead")
deadlineID := env.seedDeadline(time.Now().AddDate(0, 0, 14))
// Count global admins; if any exist (e.g. m or tester) the deadlock
// path can't fire — skip with a note.
var nAdmins int
if err := env.pool.GetContext(ctx, &nAdmins,
`SELECT COUNT(*) FROM paliad.users WHERE global_role='global_admin' AND id <> $1`,
env.requester); err != nil {
t.Fatalf("count admins: %v", err)
}
if nAdmins > 0 {
t.Skip("global_admin exists in test pool — deadlock fallback hides ErrNoQualifiedApprover; covered indirectly via canApprove unit checks")
}
tx, err := env.pool.BeginTxx(ctx, nil)
if err != nil {
t.Fatalf("begin: %v", err)
}
defer tx.Rollback()
_, err = env.approvals.SubmitCreate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline, nil)
if !errors.Is(err, ErrNoQualifiedApprover) {
t.Errorf("got %v, want ErrNoQualifiedApprover", err)
}
}
// TestApprovalService_RevokeRevertsAndMarksRevoked: requester revokes
// their own pending → entity reverts, request status='revoked'.
func TestApprovalService_RevokeRevertsAndMarksRevoked(t *testing.T) {
env := setupApprovalTest(t)
defer env.cleanup()
ctx := context.Background()
env.seedPolicy(EntityTypeDeadline, LifecycleCreate, "associate")
deadlineID := env.seedDeadline(time.Now().AddDate(0, 0, 14))
tx, err := env.pool.BeginTxx(ctx, nil)
if err != nil {
t.Fatalf("begin: %v", err)
}
reqID, err := env.approvals.SubmitCreate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline, nil)
if err != nil {
tx.Rollback()
t.Fatalf("SubmitCreate: %v", err)
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit: %v", err)
}
// Non-requester can't revoke.
if err := env.approvals.Revoke(ctx, *reqID, env.approver); !errors.Is(err, ErrNotApprover) {
t.Errorf("non-requester revoke: got %v, want ErrNotApprover", err)
}
// Requester revokes — succeeds. Create lifecycle = entity gets deleted.
if err := env.approvals.Revoke(ctx, *reqID, env.requester); err != nil {
t.Fatalf("Revoke: %v", err)
}
var n int
if err := env.pool.GetContext(ctx, &n,
`SELECT COUNT(*) FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
t.Fatalf("count: %v", err)
}
if n != 0 {
t.Errorf("after revoke-create: entity should be gone (count=%d)", n)
}
var reqStatus string
if err := env.pool.GetContext(ctx, &reqStatus,
`SELECT status FROM paliad.approval_requests WHERE id = $1`, *reqID); err != nil {
t.Fatalf("read request: %v", err)
}
if reqStatus != "revoked" {
t.Errorf("request status=%q, want revoked", reqStatus)
}
}
// TestApprovalService_PolicyCRUDRoundtrip: upsert → list → delete.
func TestApprovalService_PolicyCRUD(t *testing.T) {
env := setupApprovalTest(t)
defer env.cleanup()
ctx := context.Background()
// Upsert two rows.
if _, err := env.approvals.UpsertPolicy(ctx, env.projectID, env.requester, EntityTypeDeadline, LifecycleCreate, "associate"); err != nil {
t.Fatalf("upsert 1: %v", err)
}
if _, err := env.approvals.UpsertPolicy(ctx, env.projectID, env.requester, EntityTypeAppointment, LifecycleUpdate, "lead"); err != nil {
t.Fatalf("upsert 2: %v", err)
}
// List.
got, err := env.approvals.ListPolicies(ctx, env.projectID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(got) != 2 {
t.Errorf("list returned %d rows, want 2", len(got))
}
// Re-upsert the first to a different role.
if _, err := env.approvals.UpsertPolicy(ctx, env.projectID, env.requester, EntityTypeDeadline, LifecycleCreate, "lead"); err != nil {
t.Fatalf("re-upsert: %v", err)
}
got, _ = env.approvals.ListPolicies(ctx, env.projectID)
for _, p := range got {
if p.EntityType == EntityTypeDeadline && p.LifecycleEvent == LifecycleCreate && p.RequiredRole != "lead" {
t.Errorf("after re-upsert: required_role=%q, want lead", p.RequiredRole)
}
}
// Invalid role rejected.
if _, err := env.approvals.UpsertPolicy(ctx, env.projectID, env.requester, EntityTypeDeadline, LifecycleCreate, "observer"); !errors.Is(err, ErrInvalidInput) {
t.Errorf("invalid required_role: got %v, want ErrInvalidInput", err)
}
// Delete.
if err := env.approvals.DeletePolicy(ctx, env.projectID, EntityTypeDeadline, LifecycleCreate); err != nil {
t.Fatalf("delete: %v", err)
}
got, _ = env.approvals.ListPolicies(ctx, env.projectID)
if len(got) != 1 {
t.Errorf("after delete: %d rows, want 1", len(got))
}
}

View File

@@ -63,7 +63,15 @@ func formatAppointment(t *models.Appointment) string {
if t.EndAt != nil {
w("DTEND:" + t.EndAt.UTC().Format(icalDateUTC))
}
w("SUMMARY:" + escapeText(t.Title))
// Prepend "[PENDING] " on the SUMMARY when the appointment is awaiting
// 4-eye approval (t-paliad-138). External clients (Outlook etc.) thus
// reflect the unverified state honestly — silence on a pending change
// would be a worse outcome than visible-but-flagged.
summary := t.Title
if t.ApprovalStatus == "pending" {
summary = "[PENDING] " + t.Title
}
w("SUMMARY:" + escapeText(summary))
if t.Description != nil && *t.Description != "" {
w("DESCRIPTION:" + escapeText(*t.Description))
}

View File

@@ -30,17 +30,28 @@ type DeadlineService struct {
db *sqlx.DB
projects *ProjectService
eventTypes *EventTypeService
approvals *ApprovalService
}
// NewDeadlineService wires the service. eventTypes may be nil in tests
// that don't exercise the event_types junction; production wires it.
// NewDeadlineService wires the service. eventTypes and approvals may be
// nil in tests that don't exercise those features; production wires both.
func NewDeadlineService(db *sqlx.DB, projects *ProjectService, eventTypes *EventTypeService) *DeadlineService {
return &DeadlineService{db: db, projects: projects, eventTypes: eventTypes}
}
// SetApprovalService wires the optional 4-eye approval workflow
// (t-paliad-138). When set, every Create/Update/Complete/Delete consults
// paliad.approval_policies and may stage the change as a pending request
// instead of applying it directly. main.go wires this in production;
// tests that don't exercise the workflow can leave it unset.
func (s *DeadlineService) SetApprovalService(a *ApprovalService) {
s.approvals = a
}
const deadlineColumns = `id, project_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, rule_code, status, completed_at, caldav_uid, caldav_etag,
notes, created_by, created_at, updated_at`
notes, created_by, created_at, updated_at,
approval_status, pending_request_id, approved_by, approved_at`
// CreateDeadlineInput is the payload for Create / bulk create entries.
type CreateDeadlineInput struct {
@@ -192,6 +203,7 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
f.warning_date, f.source, f.rule_id, f.rule_code, f.status, f.completed_at,
f.caldav_uid, f.caldav_etag, f.notes, f.created_by,
f.created_at, f.updated_at,
f.approval_status, f.pending_request_id, f.approved_by, f.approved_at,
p.reference AS project_reference,
p.title AS project_title,
p.type AS project_type,
@@ -229,16 +241,35 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
return rows, nil
}
// ListForProject returns Deadlines for a specific Project (visibility-checked).
func (s *DeadlineService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.Deadline, error) {
// ListForProject returns Deadlines for a Project (visibility-checked).
//
// When directOnly is false (default), the result aggregates deadlines from
// the Project itself AND every descendant Project (per the t-paliad-139
// hierarchy aggregation contract). When directOnly is true, only deadlines
// whose project_id exactly equals the filter are returned — useful for
// edit / attribution surfaces that want exact narrowing.
//
// The descendant aggregation reuses the materialised path on
// paliad.projects (text-shaped, t-paliad-018). The visibility check on
// the filter Project is sufficient: paliad.can_see_project walks ancestors,
// so a user who can see Project P can see every descendant of P.
func (s *DeadlineService) ListForProject(ctx context.Context, userID, projectID uuid.UUID, directOnly bool) ([]models.Deadline, error) {
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
return nil, err
}
rows := []models.Deadline{}
var filter string
if directOnly {
filter = `WHERE project_id = $1`
} else {
filter = `WHERE project_id IN (
SELECT p.id FROM paliad.projects p
WHERE $1 = ANY(string_to_array(p.path, '.')::uuid[]))`
}
if err := s.db.SelectContext(ctx, &rows,
`SELECT `+deadlineColumns+`
FROM paliad.deadlines
WHERE project_id = $1
`+filter+`
ORDER BY due_date ASC, created_at DESC`, projectID); err != nil {
return nil, fmt.Errorf("list deadlines for project: %w", err)
}
@@ -365,11 +396,23 @@ func (s *DeadlineService) CreateBulk(ctx context.Context, userID, projectID uuid
}
// Update applies a partial update to a Deadline.
//
// Approval gate (t-paliad-138): if any date-bearing field actually changes
// (due_date / original_due_date / warning_date — Q4 allowlist), the change
// is applied immediately AND parked in paliad.approval_requests with
// approval_status='pending' on the row. Approver flips it to 'approved'
// or rejects (which reverts the row from the snapshotted pre_image).
//
// Refuses to mutate a row whose approval_status is already 'pending'
// (a different request is in flight) — caller must wait or revoke.
func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UUID, input UpdateDeadlineInput) (*models.Deadline, error) {
current, err := s.GetByID(ctx, userID, deadlineID)
if err != nil {
return nil, err
}
if current.ApprovalStatus == ApprovalStatusPending {
return nil, ErrConcurrentPending
}
sets := []string{}
args := []any{}
@@ -380,6 +423,13 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
next++
}
// Capture pre_image / payload for the date-bearing allowlist as fields
// are about to be set. Only populated when a field actually changes —
// SubmitUpdate skips the approval flow entirely when nothing in the
// allowlist moved.
preImage := map[string]any{}
payload := map[string]any{}
if input.Title != nil {
title := strings.TrimSpace(*input.Title)
if title == "" {
@@ -395,6 +445,10 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
if err != nil {
return nil, fmt.Errorf("%w: due_date must be YYYY-MM-DD", ErrInvalidInput)
}
if !due.Equal(current.DueDate) {
preImage["due_date"] = current.DueDate.Format("2006-01-02")
payload["due_date"] = *input.DueDate
}
appendSet("due_date", due)
}
if input.Notes != nil {
@@ -501,6 +555,15 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
return nil, err
}
}
// Approval gate (Q4 = date-bearing allowlist only). When preImage is
// empty (no allowlisted field changed), SubmitUpdate is a no-op.
if s.approvals != nil {
if _, err := s.approvals.SubmitUpdate(ctx, tx, current.ProjectID, deadlineID, userID, EntityTypeDeadline, preImage, payload); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit update deadline: %w", err)
}
@@ -508,6 +571,11 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
}
// Complete marks a Deadline as completed.
//
// Approval gate (t-paliad-138): if a (project, deadline, complete) policy
// applies, the row is flipped to status='completed' immediately AND
// parked in approval_requests with approval_status='pending'. Reject
// reverts (status back to 'pending', completed_at cleared).
func (s *DeadlineService) Complete(ctx context.Context, userID, deadlineID uuid.UUID) (*models.Deadline, error) {
current, err := s.GetByID(ctx, userID, deadlineID)
if err != nil {
@@ -516,6 +584,9 @@ func (s *DeadlineService) Complete(ctx context.Context, userID, deadlineID uuid.
if current.Status == "completed" {
return current, nil
}
if current.ApprovalStatus == ApprovalStatusPending {
return nil, ErrConcurrentPending
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
@@ -536,6 +607,17 @@ func (s *DeadlineService) Complete(ctx context.Context, userID, deadlineID uuid.
map[string]any{"deadline_id": deadlineID}); err != nil {
return nil, err
}
if s.approvals != nil {
preImage := map[string]any{
"status": current.Status,
"completed_at": nil,
}
if _, err := s.approvals.SubmitComplete(ctx, tx, current.ProjectID, deadlineID, userID, EntityTypeDeadline, preImage, nil); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit complete: %w", err)
}
@@ -621,7 +703,13 @@ func (s *DeadlineService) assertCanAdminProject(ctx context.Context, userID, pro
return nil
}
// Delete hard-deletes a Deadline. Partner/admin only.
// Delete removes a Deadline. Partner/admin only.
//
// Approval gate (t-paliad-138): if a (project, deadline, delete) policy
// applies, this is the one stage-then-write exception in the otherwise
// write-then-approve architecture. The row stays alive with
// approval_status='pending' until the approver hard-deletes (approve) or
// restores it (reject).
func (s *DeadlineService) Delete(ctx context.Context, userID, deadlineID uuid.UUID) error {
user, err := s.users().GetByID(ctx, userID)
if err != nil {
@@ -637,6 +725,9 @@ func (s *DeadlineService) Delete(ctx context.Context, userID, deadlineID uuid.UU
if err != nil {
return err
}
if current.ApprovalStatus == ApprovalStatusPending {
return ErrConcurrentPending
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
@@ -644,14 +735,35 @@ func (s *DeadlineService) Delete(ctx context.Context, userID, deadlineID uuid.UU
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
return fmt.Errorf("delete deadline: %w", err)
// Approval gate runs FIRST (before the actual delete). If a policy
// applies, SubmitDelete returns a non-nil request id and we skip the
// hard delete — the row is now flagged pending. The approver's
// Approve flips it to a real delete; their Reject clears the marker.
var pendingRequest *uuid.UUID
if s.approvals != nil {
preImage := map[string]any{
"title": current.Title,
"due_date": current.DueDate.Format("2006-01-02"),
"status": current.Status,
}
req, err := s.approvals.SubmitDelete(ctx, tx, current.ProjectID, deadlineID, userID, EntityTypeDeadline, preImage)
if err != nil {
return err
}
pendingRequest = req
}
desc := current.Title
descPtr := &desc
if err := insertProjectEvent(ctx, tx, current.ProjectID, userID, "deadline_deleted", "Deadline deleted", descPtr); err != nil {
return err
if pendingRequest == nil {
// No policy applied — proceed with the immediate hard-delete.
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
return fmt.Errorf("delete deadline: %w", err)
}
desc := current.Title
descPtr := &desc
if err := insertProjectEvent(ctx, tx, current.ProjectID, userID, "deadline_deleted", "Deadline deleted", descPtr); err != nil {
return err
}
}
return tx.Commit()
}
@@ -785,6 +897,21 @@ func (s *DeadlineService) insert(ctx context.Context, userID, projectID uuid.UUI
map[string]any{"deadline_id": id}); err != nil {
return uuid.Nil, err
}
// Approval gate: if a (project, deadline, create) policy applies, this
// flips the just-inserted row's approval_status to 'pending' and emits
// a 'deadline_approval_requested' audit event. No-op when no policy is
// configured or when the approval service isn't wired (test harness).
if s.approvals != nil {
payload := map[string]any{
"title": desc,
"due_date": input.DueDate,
}
if _, err := s.approvals.SubmitCreate(ctx, tx, projectID, id, userID, EntityTypeDeadline, payload); err != nil {
return uuid.Nil, err
}
}
if err := tx.Commit(); err != nil {
return uuid.Nil, fmt.Errorf("commit insert deadline: %w", err)
}

View File

@@ -0,0 +1,71 @@
package services
import (
"testing"
"github.com/google/uuid"
)
// TestDerivedMembershipListScan covers the sql.Scanner over a Postgres
// jsonb column — the wire format that ListDerivedMembers' jsonb_agg
// returns. Pinned because if a future migration changes the JSON shape
// (e.g. drops a key), the rendered Herkunft column on /projects/{id}
// silently breaks (t-paliad-143).
func TestDerivedMembershipListScan(t *testing.T) {
unitA := uuid.New()
unitB := uuid.New()
cases := []struct {
name string
src any
want []DerivedMembership
}{
{
name: "nil",
src: nil,
want: nil,
},
{
name: "single membership as bytes",
src: []byte(`[{"unit_id":"` + unitA.String() + `","unit_name":"Lehment","unit_role":"attorney"}]`),
want: []DerivedMembership{{UnitID: unitA, UnitName: "Lehment", UnitRole: "attorney"}},
},
{
name: "two memberships as string",
src: `[
{"unit_id":"` + unitA.String() + `","unit_name":"Lehment","unit_role":"attorney"},
{"unit_id":"` + unitB.String() + `","unit_name":"Plassmann","unit_role":"pa"}
]`,
want: []DerivedMembership{
{UnitID: unitA, UnitName: "Lehment", UnitRole: "attorney"},
{UnitID: unitB, UnitName: "Plassmann", UnitRole: "pa"},
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var got DerivedMembershipList
if err := got.Scan(tc.src); err != nil {
t.Fatalf("Scan: %v", err)
}
if len(got) != len(tc.want) {
t.Fatalf("len: got %d want %d", len(got), len(tc.want))
}
for i := range got {
if got[i] != tc.want[i] {
t.Errorf("row %d: got %+v want %+v", i, got[i], tc.want[i])
}
}
})
}
}
// TestDerivedMembershipListScanRejectsUnknown ensures we don't silently
// accept random column types and produce an empty list (which would mask
// a schema regression).
func TestDerivedMembershipListScanRejectsUnknown(t *testing.T) {
var l DerivedMembershipList
if err := l.Scan(123); err == nil {
t.Fatal("expected error scanning int into DerivedMembershipList, got nil")
}
}

View File

@@ -0,0 +1,452 @@
package services
// DerivationService manages partner-unit derivation onto project teams
// (t-paliad-139). It owns the project↔unit junction table
// (paliad.project_partner_units) and the read paths the Team tab + the
// approval inbox use to compute "who's effectively on this project via a
// partner unit".
//
// Derivation is computed on read (no materialised state). The visibility
// predicate paliad.can_see_project (extended in migration 055) is the
// authoritative gate for what users can see; this service is the read /
// authoring API on top of it.
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/models"
)
// DerivationService is the read + authoring path for partner-unit derivation.
type DerivationService struct {
db *sqlx.DB
projects *ProjectService
partnerUnit *PartnerUnitService
}
// NewDerivationService wires the service.
func NewDerivationService(db *sqlx.DB, projects *ProjectService, partnerUnit *PartnerUnitService) *DerivationService {
return &DerivationService{db: db, projects: projects, partnerUnit: partnerUnit}
}
// AttachedUnit is one row in paliad.project_partner_units enriched with the
// unit's display name + count of members that would currently derive given
// the configured derive_unit_roles. The frontend renders this on the
// /projects/{id}/settings/team Partner Units section.
type AttachedUnit struct {
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
PartnerUnitID uuid.UUID `db:"partner_unit_id" json:"partner_unit_id"`
UnitName string `db:"unit_name" json:"unit_name"`
DeriveUnitRoles []string `db:"derive_unit_roles" json:"derive_unit_roles"`
DeriveGrantsAuthority bool `db:"derive_grants_authority" json:"derive_grants_authority"`
DerivedMemberCount int `db:"derived_member_count" json:"derived_member_count"`
}
// DerivedMembership is one (unit, role) pair through which a user currently
// derives onto a project. A multi-unit user has one DerivedMembership per
// unit they belong to that's attached to the project (or one of its
// ancestors) AND whose unit_role is in the attachment's derive_unit_roles.
type DerivedMembership struct {
UnitID uuid.UUID `json:"unit_id"`
UnitName string `json:"unit_name"`
UnitRole string `json:"unit_role"`
}
// DerivedMembershipList is a []DerivedMembership that scans from a Postgres
// jsonb column (the array_agg/jsonb_agg payload in ListDerivedMembers).
type DerivedMembershipList []DerivedMembership
// Scan implements sql.Scanner over a jsonb array.
func (l *DerivedMembershipList) Scan(src any) error {
if src == nil {
*l = nil
return nil
}
var raw []byte
switch v := src.(type) {
case []byte:
raw = v
case string:
raw = []byte(v)
default:
return fmt.Errorf("DerivedMembershipList.Scan: unsupported type %T", src)
}
return json.Unmarshal(raw, (*[]DerivedMembership)(l))
}
// DerivedMember is one user who currently derives onto a project. The user
// may derive via multiple units (e.g. a PA who works with two partners);
// each is one entry in Memberships. DeriveGrantsAuthority is true if any
// of the source attachments have authority enabled.
type DerivedMember struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
Email string `db:"email" json:"user_email"`
DisplayName string `db:"display_name" json:"user_display_name"`
Office string `db:"office" json:"user_office"`
Memberships DerivedMembershipList `db:"memberships" json:"memberships"`
DeriveGrantsAuthority bool `db:"derive_grants_authority" json:"derive_grants_authority"`
}
// AttachUnitOptions controls how a unit is attached. Empty values use the
// migration-055 defaults: derive_unit_roles = {pa, senior_pa},
// derive_grants_authority = false (visibility-only).
type AttachUnitOptions struct {
DeriveUnitRoles []string
DeriveGrantsAuthority bool
}
// requireWritePermission gates project↔unit attach/detach to project lead
// or global_admin. Mirrors the RLS write policy in migration 055.
func (s *DerivationService) requireWritePermission(ctx context.Context, callerID, projectID uuid.UUID) error {
user, err := s.projects.Users().GetByID(ctx, callerID)
if err != nil {
return err
}
if user != nil && user.GlobalRole == "global_admin" {
return nil
}
var role string
err = s.db.GetContext(ctx, &role,
`SELECT role 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)
}
if role != RoleLead {
return ErrForbidden
}
return nil
}
// AttachUnitToProject creates a project_partner_units row. Idempotent on
// (project_id, partner_unit_id) — a repeat call updates the derive options.
// Caller must be project lead OR global_admin.
func (s *DerivationService) AttachUnitToProject(ctx context.Context, callerID, projectID, unitID uuid.UUID, opts AttachUnitOptions) error {
if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
return err
}
if err := s.requireWritePermission(ctx, callerID, projectID); err != nil {
return err
}
if _, err := s.partnerUnit.GetByID(ctx, unitID); err != nil {
return err
}
roles := opts.DeriveUnitRoles
if len(roles) == 0 {
roles = []string{UnitRolePA, UnitRoleSeniorPA}
}
for _, r := range roles {
if !isValidUnitRole(r) {
return fmt.Errorf("%w: invalid unit_role %q in derive_unit_roles", ErrInvalidInput, r)
}
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO paliad.project_partner_units
(project_id, partner_unit_id, derive_unit_roles, derive_grants_authority,
attached_at, attached_by)
VALUES ($1, $2, $3, $4, now(), $5)
ON CONFLICT (project_id, partner_unit_id) DO UPDATE
SET derive_unit_roles = EXCLUDED.derive_unit_roles,
derive_grants_authority = EXCLUDED.derive_grants_authority`,
projectID, unitID, pq.StringArray(roles), opts.DeriveGrantsAuthority, callerID)
if err != nil {
return fmt.Errorf("attach unit to project: %w", err)
}
return nil
}
// DetachUnitFromProject deletes a project_partner_units row. Idempotent —
// repeat detach is a no-op.
func (s *DerivationService) DetachUnitFromProject(ctx context.Context, callerID, projectID, unitID uuid.UUID) error {
if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
return err
}
if err := s.requireWritePermission(ctx, callerID, projectID); err != nil {
return err
}
if _, err := s.db.ExecContext(ctx,
`DELETE FROM paliad.project_partner_units
WHERE project_id = $1 AND partner_unit_id = $2`,
projectID, unitID); err != nil {
return fmt.Errorf("detach unit from project: %w", err)
}
return nil
}
// ListAttachedUnits returns the unit attachments anchored on this exact
// project (NOT walking ancestors — the project /settings/team page wants
// to manage its own attachments only). Each row is enriched with the unit
// name and the count of members that would currently derive given the
// configured derive_unit_roles.
func (s *DerivationService) ListAttachedUnits(ctx context.Context, callerID, projectID uuid.UUID) ([]AttachedUnit, error) {
if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
return nil, err
}
rows := []AttachedUnit{}
err := s.db.SelectContext(ctx, &rows,
`SELECT ppu.project_id,
ppu.partner_unit_id,
pu.name AS unit_name,
ppu.derive_unit_roles,
ppu.derive_grants_authority,
(SELECT COUNT(*) FROM paliad.partner_unit_members pum
WHERE pum.partner_unit_id = ppu.partner_unit_id
AND pum.unit_role = ANY(ppu.derive_unit_roles)) AS derived_member_count
FROM paliad.project_partner_units ppu
JOIN paliad.partner_units pu ON pu.id = ppu.partner_unit_id
WHERE ppu.project_id = $1
ORDER BY pu.name`, projectID)
if err != nil {
return nil, fmt.Errorf("list attached units: %w", err)
}
return rows, nil
}
// ListDerivedMembers returns users who currently derive onto this project
// via any attached unit on the project's path (this project + ancestors).
// Walks UP the path because a unit attached at the Client level cascades
// down to descendants — derivation honours the same direction as
// can_see_project.
//
// One row per user. Multi-unit users (e.g. a PA working across two partner
// units, both of which are attached to the project's path) carry every
// (unit, role) pair in Memberships so the Herkunft column can list them
// all (t-paliad-143). DeriveGrantsAuthority is bool_or across the
// underlying attachments — a user with at least one authority-granting
// derivation source qualifies as authority-bearing for approval purposes.
func (s *DerivationService) ListDerivedMembers(ctx context.Context, callerID, projectID uuid.UUID) ([]DerivedMember, error) {
project, err := s.projects.GetByID(ctx, callerID, projectID)
if err != nil {
return nil, err
}
ancestorIDs := pathToIDStrings(project.Path)
if len(ancestorIDs) == 0 {
return []DerivedMember{}, nil
}
rows := []DerivedMember{}
err = s.db.SelectContext(ctx, &rows, `
WITH attached AS (
SELECT ppu.project_id AS attach_project_id,
ppu.partner_unit_id,
ppu.derive_unit_roles,
ppu.derive_grants_authority
FROM paliad.project_partner_units ppu
WHERE ppu.project_id = ANY($1::uuid[])
)
SELECT pum.user_id,
u.email, u.display_name, u.office,
jsonb_agg(DISTINCT jsonb_build_object(
'unit_id', a.partner_unit_id,
'unit_name', pu.name,
'unit_role', pum.unit_role
)) AS memberships,
bool_or(a.derive_grants_authority) AS derive_grants_authority
FROM attached a
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = a.partner_unit_id
JOIN paliad.users u ON u.id = pum.user_id
JOIN paliad.partner_units pu ON pu.id = a.partner_unit_id
WHERE pum.unit_role = ANY(a.derive_unit_roles)
GROUP BY pum.user_id, u.email, u.display_name, u.office
ORDER BY u.display_name`,
pq.StringArray(ancestorIDs))
if err != nil {
return nil, fmt.Errorf("list derived members: %w", err)
}
// jsonb_agg(DISTINCT …) doesn't support ORDER BY in the same call.
// Sort each member's memberships by unit_name in Go so the Herkunft
// column renders deterministically.
for i := range rows {
ms := rows[i].Memberships
for j := 1; j < len(ms); j++ {
for k := j; k > 0 && ms[k-1].UnitName > ms[k].UnitName; k-- {
ms[k-1], ms[k] = ms[k], ms[k-1]
}
}
}
return rows, nil
}
// ListDescendantStaffed returns users who are directly staffed on a
// descendant of the given project but not on the project itself or its
// ancestors. This is the new "Aus Unterprojekten" subsection on the Team
// tab — explicit Case-level staff that surfaces up to the parent for
// awareness.
//
// Excludes inherited rows (descendant team rows are by definition direct
// at their level — what we filter out are users already on this project
// or its ancestors so the same user doesn't appear in two subsections).
func (s *DerivationService) ListDescendantStaffed(ctx context.Context, callerID, projectID uuid.UUID) ([]models.ProjectTeamMemberWithUser, error) {
if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
return nil, err
}
rows := []models.ProjectTeamMemberWithUser{}
err := s.db.SelectContext(ctx, &rows, `
WITH descendants AS (
SELECT p.id, p.title
FROM paliad.projects p
WHERE p.id <> $1
AND $1 = ANY(string_to_array(p.path, '.')::uuid[])
),
ancestor_or_self AS (
SELECT pp.id
FROM paliad.projects target
JOIN paliad.projects pp
ON pp.id = ANY(string_to_array(target.path, '.')::uuid[])
WHERE target.id = $1
),
descendant_rows AS (
SELECT pt.id, pt.project_id, pt.user_id, pt.role, 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
WHERE pt.user_id NOT IN (
SELECT user_id FROM paliad.project_teams
WHERE project_id IN (SELECT id FROM ancestor_or_self)
)
),
dedup AS (
SELECT dr.*,
ROW_NUMBER() OVER (
PARTITION BY dr.user_id
ORDER BY dr.created_at ASC
) AS rn
FROM descendant_rows dr
)
SELECT d.id, d.project_id, d.user_id, d.role,
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,
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`,
projectID)
if err != nil {
return nil, fmt.Errorf("list descendant-staffed: %w", err)
}
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.
//
// 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)
//
// 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)
if errors.Is(err, sql.ErrNoRows) {
return "", "", nil
}
if err != nil {
return "", "", fmt.Errorf("read project path: %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
}

View File

@@ -86,6 +86,11 @@ type EventListItem struct {
ProjectType *string `json:"project_type,omitempty"`
CreatedBy *uuid.UUID `json:"created_by,omitempty"`
// Approval workflow (t-paliad-138). ApprovalStatus is "approved"
// (default), "pending" (in-flight 4-eye request — pill rendered on
// every list surface), or "legacy" (pre-4-eye row, no pill).
ApprovalStatus *string `json:"approval_status,omitempty"`
// Deadline-only.
DueDate *string `json:"due_date,omitempty"` // YYYY-MM-DD
Status *string `json:"status,omitempty"`
@@ -195,6 +200,7 @@ func projectDeadline(d models.DeadlineWithProject) EventListItem {
due := d.DueDate.Format("2006-01-02")
status := d.Status
src := d.Source
approvalStatus := d.ApprovalStatus
return EventListItem{
Type: "deadline",
@@ -207,6 +213,7 @@ func projectDeadline(d models.DeadlineWithProject) EventListItem {
ProjectTitle: &pt,
ProjectType: &ptype,
CreatedBy: d.CreatedBy,
ApprovalStatus: &approvalStatus,
DueDate: &due,
Status: &status,
CompletedAt: d.CompletedAt,
@@ -222,6 +229,7 @@ func projectDeadline(d models.DeadlineWithProject) EventListItem {
// projectAppointment projects an AppointmentWithProject row into the union shape.
func projectAppointment(a models.AppointmentWithProject) EventListItem {
startCopy := a.StartAt
approvalStatus := a.ApprovalStatus
return EventListItem{
Type: "appointment",
ID: a.ID,
@@ -233,6 +241,7 @@ func projectAppointment(a models.AppointmentWithProject) EventListItem {
ProjectTitle: a.ProjectTitle,
ProjectType: a.ProjectType,
CreatedBy: a.CreatedBy,
ApprovalStatus: &approvalStatus,
StartAt: &startCopy,
EndAt: a.EndAt,
Location: a.Location,

View File

@@ -324,6 +324,58 @@ func (s *PartnerUnitService) RemoveMember(ctx context.Context, callerID, partner
return tx.Commit()
}
// SetMemberRole updates the unit_role column on a (partner_unit, user)
// membership. Admin-only. Validates the role against the migration-055 CHECK.
// Emits 'member_role_changed' carrying the prior + new role values so the
// audit trail captures the transition.
func (s *PartnerUnitService) SetMemberRole(ctx context.Context, callerID, partnerUnitID, userID uuid.UUID, role string) error {
if err := s.requireAdmin(ctx, callerID); err != nil {
return err
}
if !isValidUnitRole(role) {
return fmt.Errorf("%w: invalid unit_role %q", ErrInvalidInput, role)
}
unit, err := s.GetByID(ctx, partnerUnitID)
if err != nil {
return err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
var prior string
err = tx.GetContext(ctx, &prior,
`SELECT unit_role FROM paliad.partner_unit_members
WHERE partner_unit_id = $1 AND user_id = $2`,
partnerUnitID, userID)
if err != nil {
return fmt.Errorf("read prior unit_role: %w", err)
}
if prior == role {
return tx.Commit()
}
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.partner_unit_members
SET unit_role = $3
WHERE partner_unit_id = $1 AND user_id = $2`,
partnerUnitID, userID, role); err != nil {
return fmt.Errorf("update unit_role: %w", err)
}
if err := s.emit(ctx, tx, callerID, &partnerUnitID, unit.Name, "member_role_changed", map[string]any{
"user_id": userID,
"old_role": prior,
"new_role": role,
}); err != nil {
return err
}
return tx.Commit()
}
// AddMemberTx is the same as AddMember but runs inside the caller's tx and
// skips the admin gate (caller has already authorised the parent operation).
// Used by user_service.OnboardUser to insert a partner_unit membership in
@@ -362,15 +414,40 @@ func (s *PartnerUnitService) AddMemberTx(ctx context.Context, tx *sqlx.Tx, actor
// PartnerUnitMemberDetail is one user's membership row enriched with display
// fields for the admin/team UIs.
//
// UnitRole (added by t-paliad-139 / migration 055) is the per-unit role
// distinction used by the derivation rule: a unit attached to a project
// auto-derives its members whose unit_role is in the attachment's
// derive_unit_roles set (default {pa, senior_pa}). Possible values:
// 'lead' | 'attorney' | 'senior_pa' | 'pa' | 'paralegal'. Defaults to
// 'attorney' for every pre-055 row.
type PartnerUnitMemberDetail struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
Email string `db:"email" json:"email"`
DisplayName string `db:"display_name" json:"display_name"`
Office string `db:"office" json:"office"`
JobTitle *string `db:"job_title" json:"job_title"`
UnitRole string `db:"unit_role" json:"unit_role"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// PartnerUnitMemberRole values (mirror migration 055 CHECK constraint).
const (
UnitRoleLead = "lead"
UnitRoleAttorney = "attorney"
UnitRoleSeniorPA = "senior_pa"
UnitRolePA = "pa"
UnitRoleParalegal = "paralegal"
)
func isValidUnitRole(r string) bool {
switch r {
case UnitRoleLead, UnitRoleAttorney, UnitRoleSeniorPA, UnitRolePA, UnitRoleParalegal:
return true
}
return false
}
// ListMembers returns users in the PartnerUnit, enriched with display fields.
//
// INNER JOIN on paliad.users: partner_unit_members.user_id FKs auth.users, so
@@ -381,7 +458,7 @@ type PartnerUnitMemberDetail struct {
func (s *PartnerUnitService) ListMembers(ctx context.Context, partnerUnitID uuid.UUID) ([]PartnerUnitMemberDetail, error) {
var rows []PartnerUnitMemberDetail
err := s.db.SelectContext(ctx, &rows,
`SELECT pum.user_id, pum.created_at,
`SELECT pum.user_id, pum.created_at, pum.unit_role,
u.email, u.display_name, u.office, u.job_title
FROM paliad.partner_unit_members pum
JOIN paliad.users u ON u.id = pum.user_id
@@ -431,7 +508,7 @@ func (s *PartnerUnitService) ListWithMembers(ctx context.Context) ([]PartnerUnit
}
var members []memberRow
err = s.db.SelectContext(ctx, &members,
`SELECT pum.partner_unit_id, pum.user_id, pum.created_at,
`SELECT pum.partner_unit_id, pum.user_id, pum.created_at, pum.unit_role,
u.email, u.display_name, u.office, u.job_title
FROM paliad.partner_unit_members pum
JOIN paliad.users u ON u.id = pum.user_id

View File

@@ -18,11 +18,24 @@ import (
// rows attached to that Project AND every descendant Project (Litigation,
// Patent, Case below it). The descendant set is derived from
// paliad.projects.path, which the schema's path trigger keeps in sync from
// parent_id. The check is exercised against four entry points:
// - DeadlineService.ListVisibleForUser
// - DeadlineService.SummaryCounts
// - AppointmentService.ListVisibleForUser
// - EventService.ListVisibleForUser (union of deadlines + appointments)
// parent_id.
//
// t-paliad-139 extends the contract to the per-project narrow read paths
// that the /projects/{id} detail surfaces use:
// - DeadlineService.ListForProject
// - AppointmentService.ListForProject
// - ProjectService.ListEvents (audit / Verlauf)
// All three default to subtree aggregation (directOnly=false). When
// directOnly=true, only rows with project_id == filter are returned.
//
// The check is exercised against:
// - DeadlineService.ListVisibleForUser (union, t-124)
// - DeadlineService.SummaryCounts (union, t-124)
// - AppointmentService.ListVisibleForUser (union, t-124)
// - EventService.ListVisibleForUser (union, t-124)
// - DeadlineService.ListForProject (per-project narrow, t-139)
// - AppointmentService.ListForProject (per-project narrow, t-139)
// - ProjectService.ListEvents (per-project narrow, t-139)
//
// Skipped when TEST_DATABASE_URL is unset.
func TestProjectFilter_IncludesDescendants(t *testing.T) {
@@ -275,6 +288,72 @@ func TestProjectFilter_IncludesDescendants(t *testing.T) {
if gotEA != c.wantAppointmts {
t.Errorf("events.ListVisibleForUser appointments: got %d, want %d", gotEA, c.wantAppointmts)
}
// t-paliad-139: per-project narrow paths must match the union path
// when directOnly=false (subtree default), and must collapse to
// just the direct row when directOnly=true.
// DeadlineService.ListForProject — subtree (default).
dlfp, err := deadlines.ListForProject(ctx, adminID, pid, false)
if err != nil {
t.Fatalf("deadlines.ListForProject subtree: %v", err)
}
gotDFP := 0
for _, r := range dlfp {
if seedDeadlines[r.ID] {
gotDFP++
}
}
if gotDFP != c.wantDeadlines {
t.Errorf("deadlines.ListForProject subtree: got %d, want %d", gotDFP, c.wantDeadlines)
}
// DeadlineService.ListForProject — directOnly=true.
dlfpDirect, err := deadlines.ListForProject(ctx, adminID, pid, true)
if err != nil {
t.Fatalf("deadlines.ListForProject direct: %v", err)
}
gotDFPDirect := 0
for _, r := range dlfpDirect {
if seedDeadlines[r.ID] {
gotDFPDirect++
}
}
// directOnly: only the deadline whose project_id == filter is
// returned (the seeded direct-row at this level).
if gotDFPDirect != 1 {
t.Errorf("deadlines.ListForProject directOnly: got %d, want 1", gotDFPDirect)
}
// AppointmentService.ListForProject — subtree (default).
alfp, err := appointments.ListForProject(ctx, adminID, pid, false)
if err != nil {
t.Fatalf("appointments.ListForProject subtree: %v", err)
}
gotAFP := 0
for _, r := range alfp {
if seedAppointments[r.ID] {
gotAFP++
}
}
if gotAFP != c.wantAppointmts {
t.Errorf("appointments.ListForProject subtree: got %d, want %d", gotAFP, c.wantAppointmts)
}
// AppointmentService.ListForProject — directOnly=true.
alfpDirect, err := appointments.ListForProject(ctx, adminID, pid, true)
if err != nil {
t.Fatalf("appointments.ListForProject direct: %v", err)
}
gotAFPDirect := 0
for _, r := range alfpDirect {
if seedAppointments[r.ID] {
gotAFPDirect++
}
}
if gotAFPDirect != 1 {
t.Errorf("appointments.ListForProject directOnly: got %d, want 1", gotAFPDirect)
}
})
}
}

View File

@@ -704,7 +704,14 @@ const DefaultEventsPageLimit = 50
// ListEvents returns the audit trail for the Project, newest first, with
// cursor pagination (before = uuid of last seen event).
func (s *ProjectService) ListEvents(ctx context.Context, userID, id uuid.UUID, before *uuid.UUID, limit int) ([]models.ProjectEvent, error) {
//
// When directOnly is false (default), the result aggregates events from
// the Project itself AND every descendant Project (per the t-paliad-139
// hierarchy aggregation contract — Verlauf on a Client should show the
// matter's complete history, not just rows attached at the root). When
// directOnly is true, only events whose project_id exactly equals the
// filter are returned.
func (s *ProjectService) ListEvents(ctx context.Context, userID, id uuid.UUID, before *uuid.UUID, limit int, directOnly bool) ([]models.ProjectEvent, error) {
if _, err := s.GetByID(ctx, userID, id); err != nil {
return nil, err
}
@@ -718,16 +725,27 @@ func (s *ProjectService) ListEvents(ctx context.Context, userID, id uuid.UUID, b
if before != nil {
beforeArg = *before
}
var projectFilter string
if directOnly {
projectFilter = `project_id = $1`
} else {
// Inner alias `pp` to avoid shadowing the outer `p` JOIN below.
projectFilter = `project_id IN (
SELECT pp.id FROM paliad.projects pp
WHERE $1 = ANY(string_to_array(pp.path, '.')::uuid[]))`
}
var events []models.ProjectEvent
err := s.db.SelectContext(ctx, &events,
`SELECT id, project_id, event_type, title, description, event_date,
created_by, metadata, created_at, updated_at
FROM paliad.project_events
WHERE project_id = $1
AND ($2::uuid IS NULL OR (created_at, id) < (
`SELECT pe.id, pe.project_id, pe.event_type, pe.title, pe.description, pe.event_date,
pe.created_by, pe.metadata, pe.created_at, pe.updated_at,
p.title AS project_title
FROM paliad.project_events pe
LEFT JOIN paliad.projects p ON p.id = pe.project_id
WHERE pe.`+projectFilter+`
AND ($2::uuid IS NULL OR (pe.created_at, pe.id) < (
SELECT created_at, id FROM paliad.project_events WHERE id = $2::uuid
))
ORDER BY created_at DESC, id DESC
ORDER BY pe.created_at DESC, pe.id DESC
LIMIT $3`, id, beforeArg, limit)
if err != nil {
return nil, fmt.Errorf("list project events: %w", err)

View File

@@ -242,6 +242,11 @@ type digestRow struct {
ProjectReference string `db:"project_reference"`
ProjectTitle string `db:"project_title"`
IsLead bool `db:"is_lead"`
// ApprovalStatus (t-paliad-138). When 'pending', the digest renders
// the row with a "[PENDING] " title prefix so the user can't miss
// that the deadline is unverified — silence on a pending change is
// the worst outcome.
ApprovalStatus string `db:"approval_status"`
// OwnerEscalationContactID is the owner's optional escalation override:
// non-NULL diverts overdue/DRINGEND escalation away from global_admins
// to the named user. Used by visibleForCategory to decide whether the
@@ -300,6 +305,7 @@ func (s *ReminderService) fetchSlotDeadlines(ctx context.Context, u models.User,
SELECT f.id AS deadline_id,
f.title AS title,
f.due_date AS due_date,
f.approval_status AS approval_status,
f.created_by AS owner_id,
COALESCE(own.display_name, '') AS owner_name,
own.escalation_contact_id AS owner_escalation_contact_id,
@@ -590,11 +596,23 @@ func (s *ReminderService) deliverDigest(u models.User, slot string, rows []diges
// Bucket rows by category for the template. Within a category, rows
// arrive sorted by due_date already (SQL ORDER BY).
//
// Pending-approval rows (t-paliad-138) get a "[PENDING] " title prefix
// so the recipient can't miss that the deadline is unverified — silence
// on a pending change is the worst outcome for a 4-eye system.
var overdue, dueToday, dueWarning []map[string]any
pendingCount := 0
for _, r := range rows {
title := r.Title
isPending := r.ApprovalStatus == ApprovalStatusPending
if isPending {
title = "[PENDING] " + title
pendingCount++
}
item := map[string]any{
"DueDate": r.DueDate.Format("2006-01-02"),
"Title": r.Title,
"Title": title,
"IsPending": isPending,
"ProjectReference": r.ProjectReference,
"ProjectTitle": r.ProjectTitle,
"OwnerName": r.OwnerName,
@@ -627,6 +645,13 @@ func (s *ReminderService) deliverDigest(u models.User, slot string, rows []diges
"DueWarningCount": len(dueWarning),
"OpenTotal": len(dueToday) + len(dueWarning),
"DeadlinesURL": fmt.Sprintf("%s/deadlines", s.baseURL),
// PendingCount > 0 → templates can render a banner like
// "Hinweis: N Frist(en) wartet auf 4-Augen-Genehmigung —
// /inbox" above the digest body. Available even when the
// template doesn't currently use it (forward-compat, no
// existing-template breakage).
"PendingCount": pendingCount,
"InboxURL": fmt.Sprintf("%s/inbox", s.baseURL),
}
return s.mail.SendTemplate(TemplateData{
To: u.Email,