Compare commits

...

11 Commits

Author SHA1 Message Date
m
5df4285e1d feat(t-paliad-154) commit 5/5: inbox empty-state nudge + form-time hints
Three remaining surfaces from the locked design (Q9 + Q13):

/inbox empty-state admin nudge (Q9):
- New conditional block (.inbox-admin-nudge) revealed only when:
  * /api/me reports global_role='global_admin'
  * the inbox tab returned zero rows
  * /api/admin/approval-policies/seeded reports any=false (no policies firm-wide)
- Card links to /admin/approval-policies. Hidden in every other case so the
  ordinary post-rollout state (admins with active policies) sees nothing.

Form-time 4-eye hint on /projects/{id}/deadlines/new + /appointments/new (Q13):
- New .approval-hint container above the Speichern button on each form;
  hidden by default.
- Client TS fires GET /api/projects/{id}/approval-policies/effective on
  page load + on project change, reveals the hint when required_role is
  non-null and not 'none'. Renders role label + source attribution
  ('· Standard: Munich Lit') so the user knows where the rule comes from.
- Hides in every 'no policy applies' case (no candidates / 'none' suppression
  / project change to a project with no policy / fetch error).

i18n: 6 new keys × 2 langs (3 inbox-nudge keys + 2 form-hint keys + the
inbox-nudge title/body/cta wired in inbox.tsx). Total i18n keys: 1929.

Dynamic-key call sites use tDyn (admin-approval-policies.ts +
deadlines-new.ts + appointments-new.ts) so the typed t() barrier stays
intact for static keys.

Build: bun run build clean, go build + vet + test clean (no DB tests
require TEST_DATABASE_URL — those run in CI).
2026-05-08 02:31:35 +02:00
m
028423b32f feat(t-paliad-154) commit 4/5: admin /admin/approval-policies page
New TSX page shell + client orchestration + admin-index card + CSS for
the matrix + i18n keys (DE+EN).

Page structure:
- Section 1 'Partner-Unit-Standards': accordion list, each <details>
  block expandable into the 8-cell matrix for that partner unit.
- Section 2 'Projekt-spezifisch': search-driven project picker → matrix
  showing the EFFECTIVE policy per cell with attribution chips
  (Projekt / Geerbt / Standard) per source.
- Bulk-apply modal: 'Auf Unterprojekte anwenden' button per project; lists
  affected descendants; POST to /api/admin/approval-policies/apply-to-descendants.

Cell semantics:
- Select per cell with options: '— keine Regel —' (= DELETE), partner /
  of_counsel / associate / senior_pa / pa / 'Keine Genehmigung' (= 'none'
  sentinel, project-row only).
- Change → PUT for any value, DELETE for empty. Re-fetch the affected
  scope so attribution chips reflect the new state.

CSS: matrix grid on desktop (≥700px); two stacked sections (Fristen /
Termine) below 700px via media query — both rendered in DOM, CSS toggles.
All tokens are existing --color-* / --status-* / --hlc-*-rgb (no bare
--surface / --text-muted / --bg-subtle).

i18n: 42 new keys × 2 languages = 84 entries. Total i18n keys: 1924.

Build: bun run build clean (i18n codegen updated, IIFE wrapping enforced).
2026-05-08 02:27:54 +02:00
m
0f87d73b1b feat(t-paliad-154) commit 3/5: HTTP handlers — admin APIs + form-hint endpoint + audit-log union
8 new endpoints under /api/admin/* (admin-gated) and /api/projects (gated
on per-user authentication for the form-time hint):

Admin APIs (gated by adminGate):
- GET    /admin/approval-policies                                                  — page shell
- GET    /api/admin/partner-units/{unit_id}/approval-policies                      — list unit defaults
- PUT    /api/admin/partner-units/{unit_id}/approval-policies/{entity}/{lifecycle} — upsert unit default
- DELETE /api/admin/partner-units/{unit_id}/approval-policies/{entity}/{lifecycle} — clear unit default
- GET    /api/admin/approval-policies/seeded                                       — exists check (gates inbox nudge)
- GET    /api/admin/approval-policies/matrix?project_id=...                        — 8 effective rows w/ attribution
- POST   /api/admin/approval-policies/apply-to-descendants                         — bulk fanout

Form-time hint (NOT admin-gated — every user authoring a deadline /
appointment needs to know whether their save will trigger 4-eye):
- GET /api/projects/{id}/approval-policies/effective?entity_type=&lifecycle=

AuditService extension:
- New AuditSourcePolicyAuditLog source string.
- Fifth UNION ALL branch in auditUnionSQL queries paliad.policy_audit_log,
  packs description as 'entity/lifecycle: old → new'. project_id forwarded
  for project-scoped rows so /admin/audit-log filters work — but
  policy_audit_log is NOT a /verlauf source (the verlauf SELECT in
  ProjectService.ListProjectEvents reads project_events directly), so
  Q8's no-leak constraint is preserved.

Build + go vet clean. The new handler functions register with the existing
adminGate / gateOnboarded patterns; no new middleware.
2026-05-08 02:22:19 +02:00
m
e6067c74db feat(t-paliad-154) commit 2/5: ApprovalService rewire — resolver delegation + scope-split CRUD + audit emission
Service-layer changes implementing the locked design (Q5/Q6/Q8):

LookupPolicy (existing, called by SubmitCreate/Update/Complete/Delete)
delegates to paliad.approval_policy_effective() resolver. Returns nil
for the 'none' sentinel — explicit project-level suppression of inherited
defaults. Synthesizes a *models.ApprovalPolicy carrying the actual
project_id so the existing submit chain branches don't change.

Policy CRUD split into project + unit scope methods:
- ListProjectPolicies / ListUnitPolicies — read-only per scope.
- UpsertProjectPolicy / DeleteProjectPolicy — project-scoped writes,
  audit-emitting (writes paliad.policy_audit_log inside the same tx).
- UpsertUnitPolicy / DeleteUnitPolicy — unit-default writes, same shape.
- All four use validatePolicyTuple for entity_type/lifecycle/required_role
  ranges. IsValidPolicyRole accepts the 'none' sentinel; the existing
  IsValidRequiredRole keeps rejecting 'none' (gate-only contract).

Effective-policy reads:
- GetEffectivePolicyOne(projectID, entity, lifecycle) — single-cell,
  used by the form-time hint endpoint above /projects/{id}/deadlines/new.
- GetEffectivePoliciesMatrix(projectID) — 8 cells in stable display order
  (Fristen/Termine × create/update/complete/delete), each w/ attribution.
- lookupSourceName resolves source_id to projects.title or partner_units.name.

ApplyMatrixToDescendants — bulk-apply (Q10): copies source project's
effective matrix down to listed descendants as project-specific rows,
inside one tx. Validates targetIDs are actual descendants via path-prefix
NOT LIKE check. Idempotent fanout: deletes target's project rows first
then writes the source's effective values. Self-target skipped. Audit
row per affected target.

PoliciesExist() — bool, used by /inbox empty-state nudge.

Models:
- ApprovalPolicy.ProjectID is now *uuid.UUID (was uuid.UUID); new
  *uuid.UUID PartnerUnitID. Existing handler code only reads RequiredRole
  so no upstream breakage.
- New EffectivePolicy struct (resolved cell + source attribution).
- New PolicyAuditEntry struct (paliad.policy_audit_log row).

Handlers:
- handleListApprovalPolicies → ListProjectPolicies (renamed).
- handlePutApprovalPolicy → UpsertProjectPolicy (caller-id reordering).
- handleDeleteApprovalPolicy → DeleteProjectPolicy (now needs uid for
  audit; took the existing requireUser path).

Tests:
- Existing TestApprovalService_PolicyCRUD updated for new method names
  + post-148 enum (partner, not lead) + new 'none' sentinel acceptance.
- New TestIsValidPolicyRole pins the helper that gates writes.
- TestIsValidRequiredRole extended with 'none' rejection (gate-only).

Build + go vet + role-tests clean.

Q8: audit emission writes to paliad.policy_audit_log only — never to
project_events — so /admin/audit-log surfaces the change while /verlauf
stays focused on entity-level lifecycle.
2026-05-08 02:20:15 +02:00
m
e92c56b5f8 feat(t-paliad-154) commit 1.5: extend migration 062 with policy_audit_log
Q8 of locked design: policy CRUD audits to /admin/audit-log only, NOT
to per-project /verlauf. The 4 existing audit sources (project_events,
caldav_sync_log, reminder_log, partner_unit_events) don't fit cleanly:
project_events would surface on /verlauf (rejected by Q8); partner_unit_events
constrains event_type and requires unit_name + a non-null partner_unit_id
which doesn't fit project-scoped policy changes.

Added paliad.policy_audit_log as a fifth audit source — admin-only, scoped
either to a project or a partner unit, snapshots scope_name so post-cascade
rows still render. RLS: select for any authenticated user (route gate is
the actual control); write for global_admin only.

AuditService.ListEntries will union this source in commit 2 of this PR.

Validated insert/select live in BEGIN ... ROLLBACK.
2026-05-08 02:13:58 +02:00
m
f7908f03ad feat(t-paliad-154) commit 1/5: migration 062 — approval_policies unit-defaults + 'none' sentinel + resolver + seed
Schema:
- ALTER paliad.approval_policies: project_id nullable, ADD partner_unit_id
  uuid REFERENCES paliad.partner_units(id) ON DELETE CASCADE.
- XOR check: exactly one of (project_id, partner_unit_id) is set.
- Replace UNIQUE composite with two partial unique indexes (one per scope).
- Extend required_role CHECK with 'none' sentinel.
- approval_role_level('none') already returns 0 via existing ELSE branch
  in 059_profession_vs_responsibility.up.sql:218 — no function update.

Resolver paliad.approval_policy_effective(project, entity_type, lifecycle):
- Step 1: project-specific row wins outright (any value, including 'none').
- Step 2: MAX(approval_role_level) across ancestor rows on project's path
  + unit-default rows for partner units attached to project. Tied levels
  break alphabetically ('ancestor' beats 'unit_default') for stable
  attribution.
- Step 3: zero rows (no candidates) — caller treats as 'no policy applies'.

Returns (required_role, source, source_id) — source ∈ {project, ancestor,
unit_default}; source_id is project_id or partner_unit_id depending.

Seed:
- 8 rows × every existing partner_unit (currently 11): deadline+appointment
  × create/update/delete = associate; complete = none.
- ON CONFLICT (partner_unit_id, entity_type, lifecycle_event)
  WHERE partner_unit_id IS NOT NULL DO NOTHING — idempotent on re-run
  (verified live: 11 units → 88 seed rows, second run is no-op).
- Safe on a DB with 0 partner_units (SELECT returns no rows).

Down migration: reverse-order. Coerces 'none' rows to 'associate' before
restoring CHECK so rollback works without data loss. Drops seeded unit
rows; preserves project rows that pre-date 062.

Validated end-to-end against the live DB inside BEGIN ... ROLLBACK; the
existing project policy (deadline:create=partner) is preserved by the
DO NOTHING clause and the partial-index scope.

Design: docs/design-approval-policy-ui-2026-05-07.md §3.1.

No RAISE EXCEPTION. No bare CSS tokens (no CSS in this commit).
2026-05-08 02:11:23 +02:00
m
01fa4b1287 Merge remote-tracking branch 'origin/main' into mai/hilbert/inventor-approval-policy 2026-05-08 02:07:46 +02:00
m
bb035558be design(t-paliad-154): approval-policy authoring UI
Inventor pass for m/paliad#13. Surfaces the dormant t-138 4-eye system
(zero policies in DB → silent bypass) by adding /admin/approval-policies
with project-picker → 8-cell matrix + partner-unit-defaults section.

12 design questions surfaced sequentially via AskUserQuestion (per dogma)
and locked in §2 of the doc:

1. Surface: /admin/approval-policies only (admin page card on /admin index)
2. Defaults concept: per-partner-unit defaults
3. Multi-unit conflict: most-restrictive wins
4. Tree inheritance: yes (ancestors contribute candidates)
5. Cross-source precedence: most-restrictive across project+ancestor+unit;
   project row overrides outright
6. Suppression sentinel: 'none' value in required_role enum
7. Soft-disable: no, delete-only
8. Audit emission: /admin/audit-log only, not project verlauf
9. Empty-state: admin-only nudge card on /inbox when zero pending+policies
10. Bulk-apply: per-project "Auf Unterprojekte anwenden" button
11. Seed defaults: yes — conservative associate baseline for all partner units
12. Mobile shape: stacked sections per entity_type
13. Form hint: yes, above Speichern button on deadline/appointment new+edit

Migration 062 adds partner_unit_id (XOR with project_id),
'none' to required_role enum, paliad.approval_policy_effective() resolver,
and seeds 8 rows × N partner_units. ApprovalService.LookupPolicy delegates
to the resolver while preserving its calling contract (existing submit/
decide chain unchanged). New admin endpoints for unit-defaults, matrix
view, bulk-apply, and form-time effective lookup. ~3500-4500 LoC, single
PR, 5 commits.

Inventor parked. NOT cronus per memory directive. Awaiting m go/no-go.
2026-05-07 23:51:38 +02:00
m
b78941e293 Merge: t-paliad-152 — /api/events honours direct_only (Fristen/Termine subtree toggle works again — handleListEvents + handleEventsSummary parse direct_only via parseDirectOnly; threaded as DirectOnly bool through EventListFilter / EventSummaryFilter / ListFilter / AppointmentListFilter; project predicate swaps from projectDescendantPredicate to direct project_id eq when set; 3 new DirectOnly subtests in project_filter_descendants_test.go) 2026-05-07 23:21:01 +02:00
m
55c93c9de3 Merge: t-paliad-153 — Frist due_date 02:00 leak (consolidate views/format.ts with UTC-anchored date-only detection + kind-aware formatRowTime/formatRelative; shape-cards skips time slot under day-grouped headings; shape-list reduces deadline relatives to day precision; tests pass under TZ=Berlin/LA/UTC) 2026-05-07 23:08:18 +02:00
m
f90bfeda9b fix(t-paliad-153): deadline due_date renders 02:00 in CEST (UTC-midnight leak)
Substrate marshals deadline.due_date as time.Date(...,0,0,0,0,UTC), so the
JSON arrives as "YYYY-MM-DDT00:00:00Z" — UTC midnight, no real time. Feeding
that into new Date() + toLocaleTimeString() produced "02:00" in CEST,
"01:00" in CET, "20:00 the day before" in EST, etc.

Pattern A: don't render time for date-only fields.

- Centralised the date/time formatters used by the views shapes into
  frontend/src/client/views/format.ts. parseDateOnly recognises both
  "YYYY-MM-DD" and the substrate's "YYYY-MM-DDT00:00:00Z" form; formatDate
  formats those in UTC so the day matches the source day in every timezone.
- shape-cards.ts: per-row time slot is empty for deadlines when the day is
  already in the heading (groupBy=day). Falls back to formatDate when
  groupBy=week|none. Bucketing now anchors date-only inputs to UTC so a
  deadline can't slip into the previous day in negative-offset zones.
- shape-list.ts: formatRelative is kind-aware — deadlines reduce to
  day-precision ("morgen" / "in 3 Tagen") instead of leaking hour math
  ("in 2h") off the UTC midnight.
- Appointments and other timestamped sources are untouched.
- format.test.ts: regression coverage in CEST / PST / UTC. 14 tests pass.
2026-05-07 23:07:26 +02:00
26 changed files with 3801 additions and 107 deletions

View File

@@ -0,0 +1,912 @@
# Approval-policy authoring UI — design
**Task:** t-paliad-154
**Issue:** m/paliad#13
**Inventor:** hilbert (2026-05-07)
**Branch:** mai/hilbert/inventor-approval-policy
**Status:** READY FOR REVIEW
---
## §0 — One-paragraph summary
cronus shipped the t-138 4-eye backend on 2026-05-06: tables, service layer,
HTTP API, audit events, the `/inbox` shell. The whole thing has been **dormant
in production since** because `paliad.approval_policies` has zero rows, and no
UI exists to author policies. m hit this hard 2026-05-07 22:55 — created a
deadline expecting a request on `/approvals`, got nothing. This design fills
the gap with **two coordinated changes**: (a) a backend extension to support
**per-partner-unit defaults** layered with **project-tree inheritance**, both
resolved most-restrictive, with an explicit `'none'` sentinel for project-level
opt-out; (b) a single new admin page `/admin/approval-policies` with a
project-picker → 8-cell matrix and a partner-unit defaults section, plus
in-context hints on the deadline/appointment forms when 4-eye applies. v1
ships seeded conservative defaults for every existing partner unit so the gate
starts working on next deploy without per-project authoring.
---
## §1 — What's already built (verified live, 2026-05-07)
cronus's t-138 implementation is complete and merged. Verified premises:
- **Schema (migration 054, applied):** `paliad.approval_policies` with
`(id, project_id, entity_type, lifecycle_event, required_role, created_at,
updated_at, created_by)` + UNIQUE composite on `(project_id, entity_type,
lifecycle_event)`. RLS enforces SELECT via `can_see_project(project_id)`,
WRITE via `global_role='global_admin'`. Read-only check on the live DB
via the migration file at `internal/db/migrations/054_approvals.up.sql:75`.
- **Required-role enum (post-059):** `partner | of_counsel | associate |
senior_pa | pa`. The `'lead' → 'partner'` rename happened in migration 059
(t-148, kepler) — verified at `internal/db/migrations/059_profession_vs_responsibility.up.sql:166-172`.
Mirrors `paliad.users.profession` (firm-wide career tier), not
`paliad.project_teams.responsibility` (project-level role) — the gate keys
on profession because that's how the strict ladder
`paliad.approval_role_level()` works.
- **HTTP API (admin-gated):** three handlers in
`internal/handlers/approvals.go` register at `internal/handlers/handlers.go:421-426`:
- `GET /api/projects/{id}/approval-policies` → list
- `PUT /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}` → upsert
- `DELETE /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}` → clear
All three wrapped with `auth.RequireAdminFunc(users, ...)`.
- **`LookupPolicy`** (`internal/services/approval_service.go:69-83`) does
**not walk the project tree** today. It SELECTs the exact
`(project_id, entity_type, lifecycle_event)` tuple and returns the row or
nil. Tree inheritance is brand-new in this design.
- **Audit:** approval-request submission and decisions emit
`paliad.project_events` rows; **policy CRUD does not**. Verified at
`internal/services/approval_service.go:255` (request emits) — no
`insertProjectEvent` call inside `UpsertPolicy`/`DeletePolicy` at lines
913-948.
- **Partner-unit substrate (t-139, migration 055, applied):**
- `paliad.partner_units (id, name, lead_user_id, office, ...)` — verified
at `internal/services/partner_unit_service.go:29`.
- `paliad.project_partner_units (project_id, partner_unit_id,
derive_grants_authority, derive_unit_roles)` — verified at
`internal/db/migrations/055_hierarchy_aggregation.up.sql:47`.
- **Admin index pattern:** `/admin` is a card-grid of single-purpose admin
sub-pages — team, partner-units, audit-log, email-templates, event-types,
broadcasts. Verified at `frontend/src/admin.tsx:60-91`. New approval-policy
card slots into the same grid.
- **Migration tracker:** last applied is **061**
(`paliad.user_card_layouts`). Next is **062** — this design's migration.
---
## §2 — m's locked decisions (2026-05-07 23:00)
12 questions surfaced via AskUserQuestion (per dogma, not as a markdown
list). Locked verbatim — quoted as-asked + answer:
### Q1 — Surface placement
> Where should approval policies be authored? The backend admin-gates the
> CRUD endpoints, so anywhere we surface authoring is admin-only by
> definition.
**Locked: Admin page only.** New `/admin/approval-policies` card on the
admin index. Single page with two sections: (a) Partner-unit defaults,
(b) Project picker → 8-cell matrix. Per-project tab is **out**. Project
visibility into effective rules happens at form-time (Q12 below), not as a
permanent tab.
### Q2 — Default-policy concept
> With ~30 projects and 8 cells each, authoring is tedious. Should we add
> firm-wide defaults that individual projects override?
**Locked: Per partner-unit defaults.** Schema gets a nullable
`partner_unit_id`, project_id becomes nullable, XOR check enforces a row
applies to one or the other. Reuses the t-139 partner-unit infra. No
firm-wide defaults — one less concept.
### Q3 — Multi-unit conflict resolution
> A project attached to multiple partner units with conflicting unit
> defaults — e.g. Munich Lit unit defaults to deadline:create=partner,
> Düsseldorf to deadline:create=associate. What does the gate require?
**Locked: Most-restrictive wins.** Take MAX(`approval_role_level`) across
all unit defaults for the project. Conservative — 4-eye exists to prevent
quiet errors, the higher bar wins.
### Q4 — Tree inheritance
> Projects also live in a tree. Should an ancestor project's policy inherit
> DOWN the project tree to descendants when they have no own row, or only
> via partner-unit defaults?
**Locked: Both — tree inheritance AND unit defaults.** Three sources
contribute to the candidate set: project-specific rows, ancestor rows,
unit defaults.
### Q5 — Cross-source precedence
> When tree-inheritance and unit-defaults both produce a candidate, which
> wins?
**Locked: Most-restrictive across ALL sources.** Project-specific row
overrides outright (any value, including `'none'`). When no project row,
take MAX(level) across all ancestor rows + all unit defaults. Symmetric
with the multi-unit rule.
### Q6 — Explicit suppression sentinel
> A project-specific row always wins. To set 'this project explicitly
> bypasses 4-eye on deadline:create' overriding a partner-unit default of
> 'partner', we need a sentinel.
**Locked: `'none'` value in `required_role` enum.** Add `'none'` to the
CHECK constraint. Cell renders as "Keine Genehmigung erforderlich". Project
row with `required_role='none'` returns nil from `LookupPolicy` —
suppresses defaults explicitly. Single column, single concept.
### Q7 — Soft-disable vs delete
> Per-policy enable/disable toggle vs delete-only. With audit-log emission
> already locked in (Q8), do we still need soft-disable?
**Locked: Delete-only.** One row = one rule. "This rule used to apply" is
answered by the audit log. KISS.
### Q8 — Audit emission
> Should policy changes emit project_events?
**Locked: Only on `/admin/audit-log`, not on per-project `/verlauf`.**
New event types `approval_policy_set` and `approval_policy_cleared`
emitted via the existing audit-log path (not via the project-events
union). Project verlauf stays focused on entity-level history.
### Q9 — Empty-state on /inbox
> When admin opens /inbox and pending list is empty AND no policies exist,
> show a one-tap nudge?
**Locked: Yes — admin-only card.** Conditional on `me.global_role ===
'global_admin' && pending.length === 0 && !any_policies_exist`. Card links
to `/admin/approval-policies`. Solves the discoverability gap m hit.
### Q10 — Bulk-apply
> Bulk action on the admin page so an admin can fan a Mandant's matrix
> down to its 12 sub-projects without 96 clicks?
**Locked: Yes — "Auf Unterprojekte anwenden" button per project row.**
Click → confirm modal listing affected descendants → applies the source
project's full matrix to all descendants. Idempotent.
### Q11 — Seed defaults on first deploy
> Should v1 ship seeded defaults, or strictly opt-in?
**Locked: Seed conservative defaults for every partner_unit.** Migration
inserts 8 rows per existing partner_unit:
| entity | lifecycle | required_role |
| :--- | :--- | :--- |
| deadline | create | associate |
| deadline | update | associate |
| deadline | delete | associate |
| deadline | complete | none |
| appointment | create | associate |
| appointment | update | associate |
| appointment | delete | associate |
| appointment | complete | none |
Rationale: marking-as-done is low-risk; the planning ops (create/edit/delete
the date itself) need 4-eye. `none` on `complete` is an explicit "no gate"
sentinel, not a missing row — so MAX-across-sources still works correctly.
### Q12 — Mobile shape
> 8-cell matrix is too wide for narrow viewports.
**Locked: Two stacked sections — Fristen, Termine, each as 4-row list.**
On viewports ≥ 700px: 2-row × 4-col matrix. On viewports < 700px: vertical
section per entity_type with full-width dropdown rows.
### Q13 — Form-time hint visibility
> Should we surface 4-eye to users authoring deadlines, before they save?
**Locked: Yes — hint on the deadline-form.** Above the Speichern button on
`/projects/{id}/deadlines/new` and `/projects/{id}/appointments/new`,
render: "4-Augen-Prüfung erforderlich: nach dem Speichern wird ein
Genehmigungsantrag (associate-Level) ausgelöst." Pulled from new
`GET /api/projects/{id}/approval-policies/effective` endpoint at form load.
---
## §3 — Backend extensions
### §3.1 — Migration 062
`internal/db/migrations/062_approval_policy_unit_defaults.up.sql`:
```sql
-- t-paliad-154: approval-policy authoring UI substrate.
--
-- Extends t-138's paliad.approval_policies with:
-- 1. partner_unit_id column for unit-default rows (XOR with project_id)
-- 2. 'none' sentinel value for required_role (explicit suppression)
-- 3. paliad.approval_policy_effective() resolver — tree + unit + most-restrictive
-- 4. Conservative seed defaults for every existing partner_unit
-- 1. partner_unit_id column + nullable project_id + XOR check.
ALTER TABLE paliad.approval_policies
ALTER COLUMN project_id DROP NOT NULL,
ADD COLUMN partner_unit_id uuid
REFERENCES paliad.partner_units(id) ON DELETE CASCADE,
ADD CONSTRAINT approval_policies_scope_xor CHECK (
(project_id IS NOT NULL AND partner_unit_id IS NULL) OR
(project_id IS NULL AND partner_unit_id IS NOT NULL)
);
-- Replace UNIQUE (project_id, ...) with two partial unique indexes since
-- project_id is now nullable.
ALTER TABLE paliad.approval_policies
DROP CONSTRAINT IF EXISTS approval_policies_project_id_entity_type_lifecycle_event_key;
CREATE UNIQUE INDEX approval_policies_project_unique
ON paliad.approval_policies (project_id, entity_type, lifecycle_event)
WHERE project_id IS NOT NULL;
CREATE UNIQUE INDEX approval_policies_unit_unique
ON paliad.approval_policies (partner_unit_id, entity_type, lifecycle_event)
WHERE partner_unit_id IS NOT NULL;
CREATE INDEX approval_policies_unit_idx
ON paliad.approval_policies (partner_unit_id);
-- 2. 'none' sentinel.
ALTER TABLE paliad.approval_policies
DROP CONSTRAINT IF EXISTS approval_policies_required_role_check;
ALTER TABLE paliad.approval_policies
ADD CONSTRAINT approval_policies_required_role_check
CHECK (required_role IN (
'partner', 'of_counsel', 'associate', 'senior_pa', 'pa', 'none'
));
-- approval_role_level('none') already returns 0 (the ELSE branch). No
-- function change needed.
-- 3. Resolver function.
--
-- Returns the effective policy for (project, entity_type, lifecycle):
-- 1. project-specific row → wins outright (any value including 'none')
-- 2. else MAX(approval_role_level) across:
-- - all ancestor project rows on the path
-- - all unit-default rows for partner units attached to project
-- 3. else NULL (no candidates) → no policy applies
--
-- Returns at most one row. Caller can detect "no policy" via empty result.
CREATE OR REPLACE FUNCTION paliad.approval_policy_effective(
p_project_id uuid,
p_entity_type text,
p_lifecycle text
) RETURNS TABLE (
required_role text,
source text, -- 'project' | 'ancestor' | 'unit_default'
source_id uuid -- project_id for project/ancestor, partner_unit_id for unit_default
)
LANGUAGE plpgsql STABLE AS $$
BEGIN
-- Step 1: project-specific row.
RETURN QUERY
SELECT ap.required_role, 'project'::text, ap.project_id
FROM paliad.approval_policies ap
WHERE ap.project_id = p_project_id
AND ap.entity_type = p_entity_type
AND ap.lifecycle_event = p_lifecycle;
IF FOUND THEN
RETURN;
END IF;
-- Step 2: MAX across ancestor + unit_default.
RETURN QUERY
WITH path AS (
SELECT string_to_array(p.path, '.')::uuid[] AS ids
FROM paliad.projects p WHERE p.id = p_project_id
),
ancestor_rows AS (
SELECT ap.required_role,
'ancestor'::text AS src,
ap.project_id AS sid,
paliad.approval_role_level(ap.required_role) AS lvl
FROM paliad.approval_policies ap, path
WHERE ap.project_id = ANY(path.ids)
AND ap.project_id <> p_project_id
AND ap.entity_type = p_entity_type
AND ap.lifecycle_event = p_lifecycle
),
unit_rows AS (
SELECT ap.required_role,
'unit_default'::text AS src,
ap.partner_unit_id AS sid,
paliad.approval_role_level(ap.required_role) AS lvl
FROM paliad.approval_policies ap
JOIN paliad.project_partner_units ppu
ON ppu.partner_unit_id = ap.partner_unit_id
WHERE ppu.project_id = p_project_id
AND ap.entity_type = p_entity_type
AND ap.lifecycle_event = p_lifecycle
)
SELECT a.required_role, a.src, a.sid
FROM (SELECT * FROM ancestor_rows
UNION ALL
SELECT * FROM unit_rows) a
ORDER BY a.lvl DESC, a.src ASC -- 'ancestor' < 'unit_default' alphabetically; ancestor wins ties for stable attribution
LIMIT 1;
END;
$$;
COMMENT ON FUNCTION paliad.approval_policy_effective(uuid, text, text) IS
'Effective approval policy resolver (t-paliad-154). '
'project-specific row wins outright; else MAX(level) across ancestors '
'and unit-defaults attached to project; else no policy.';
-- 4. Seed conservative defaults for every existing partner_unit.
INSERT INTO paliad.approval_policies (
project_id, partner_unit_id, entity_type, lifecycle_event, required_role
)
SELECT NULL, pu.id, t.entity_type, t.lifecycle_event, t.required_role
FROM paliad.partner_units pu
CROSS JOIN (
VALUES
('deadline', 'create', 'associate'),
('deadline', 'update', 'associate'),
('deadline', 'delete', 'associate'),
('deadline', 'complete', 'none'),
('appointment', 'create', 'associate'),
('appointment', 'update', 'associate'),
('appointment', 'delete', 'associate'),
('appointment', 'complete', 'none')
) AS t(entity_type, lifecycle_event, required_role)
ON CONFLICT DO NOTHING;
```
`062_approval_policy_unit_defaults.down.sql` reverses each step
(deletes seeded rows, drops the function, drops indexes, drops the
column + constraint, restores the original UNIQUE + CHECK).
### §3.2 — Service-layer changes
`internal/services/approval_service.go` changes (additive — existing
callers keep working):
- **Rewire `LookupPolicy`** to call the resolver. New body:
```go
func (s *ApprovalService) LookupPolicy(ctx, tx, projectID, entityType, lifecycleEvent) (*models.ApprovalPolicy, error) {
var row struct {
RequiredRole string `db:"required_role"`
Source string `db:"source"`
SourceID uuid.UUID `db:"source_id"`
}
q := `SELECT required_role, source, source_id
FROM paliad.approval_policy_effective($1, $2, $3)`
err := txOrDB(tx, s.db).GetContext(ctx, &row, q, projectID, entityType, lifecycleEvent)
if errors.Is(err, sql.ErrNoRows) || row.RequiredRole == "none" {
return nil, nil // no policy applies
}
if err != nil { return nil, fmt.Errorf("lookup approval policy: %w", err) }
// Synthetic ApprovalPolicy — preserves the calling contract.
return &models.ApprovalPolicy{
ProjectID: projectID,
EntityType: entityType,
LifecycleEvent: lifecycleEvent,
RequiredRole: row.RequiredRole,
}, nil
}
```
The submit/decide chain at lines 142-380 continues to work unchanged.
`'none'` returning nil means: project explicitly opted out, no request
is created on save.
- **New `GetEffectivePoliciesMatrix(ctx, projectID)`** returns 8 rows
(one per `entity_type × lifecycle_event`), each with attribution. Used
by the admin page and the form-hint endpoint.
```go
type EffectivePolicy struct {
EntityType string
LifecycleEvent string
RequiredRole *string // nil if no policy
Source *string // nil if no policy
SourceID *uuid.UUID
}
func (s *ApprovalService) GetEffectivePoliciesMatrix(ctx, projectID) ([]EffectivePolicy, error)
```
Implementation: 8 calls to the resolver in a single round-trip via
`unnest()` join, or a small batch loop — both fine for ≤8 cells.
- **Extend `UpsertPolicy` signature** to accept `partnerUnitID *uuid.UUID`
alongside `projectID *uuid.UUID`. Existing callers pass projectID + nil.
New callers (unit-default endpoints) pass nil + unit ID.
```go
func (s *ApprovalService) UpsertPolicy(ctx, callerID,
projectID, partnerUnitID *uuid.UUID,
entityType, lifecycle, requiredRole string) (*models.ApprovalPolicy, error)
```
Same for `DeletePolicy`. Validates exactly one of (projectID, partnerUnitID)
is set.
- **New `ApplyMatrixToDescendants(ctx, callerID, sourceProjectID,
targetIDs []uuid.UUID)`**: copies all eight rows of `sourceProjectID`'s
effective matrix to each `targetIDs[i]` as project-specific rows. Inside
one transaction. Validates `targetIDs` are actual descendants via the
ltree path predicate. Returns the count of (project, cell) writes
performed. Skips cells where source is `'none'` and target already has
no row (idempotent). Emits one audit-log event per write.
- **Audit emission** in `UpsertPolicy` + `DeletePolicy` + `ApplyMatrixToDescendants`:
call existing `AuditService.Record` (the same path `/admin/audit-log`
uses). New event type strings: `approval_policy_set`, `approval_policy_cleared`.
Metadata: scope (project|partner_unit), scope_id, entity_type, lifecycle,
old_required_role (for set), new_required_role (for set). The audit
service already handles JSON metadata; no schema change.
**No project_events emission** (per Q8 lock-in). Project verlauf stays
focused on entity-level lifecycle.
### §3.3 — HTTP handlers
`internal/handlers/approvals.go` extensions:
- **Existing routes stay** at `handlers.go:421-426` (gated by
`RequireAdminFunc`).
- **New unit-default routes** (also `RequireAdminFunc`-gated, registered
in the same admin block at handlers.go:386-427):
- `GET /api/admin/partner-units/{unit_id}/approval-policies` — list
all rows for that unit.
- `PUT /api/admin/partner-units/{unit_id}/approval-policies/{entity_type}/{lifecycle}` — upsert.
- `DELETE /api/admin/partner-units/{unit_id}/approval-policies/{entity_type}/{lifecycle}` — clear.
- `GET /api/admin/approval-policies/seeded` — quick existence check
used by the `/inbox` admin nudge ("are any policies set firm-wide?").
- **New endpoint for matrix view** (admin page):
- `GET /api/admin/approval-policies/matrix?project_id=...` — returns
`[]EffectivePolicy` (8 rows with attribution).
- **New endpoint for form hint** (gateOnboarded, NOT admin-only — every
user authoring a deadline needs to see this):
- `GET /api/projects/{id}/approval-policies/effective?entity_type=deadline&lifecycle=create`
— returns one `EffectivePolicy` row.
- **New endpoint for bulk apply**:
- `POST /api/admin/approval-policies/apply-to-descendants` — body
`{source_project_id: uuid, target_project_ids: [uuid, ...]}`. Validates,
applies, returns counts.
- **New endpoint for project tree** (admin page picker — already exists
in part):
- `GET /api/admin/projects/tree-flat` — flat array of all projects with
`id, name, parent_id, depth, path` for the picker. Reuses
`ProjectService.ListAllForAdmin` (already present at
`internal/services/project_service.go` — admin-scoped tree).
- **New page handler**:
- `GET /admin/approval-policies` → `dist/admin-approval-policies.html`
(server-static shell, hydrated on load).
---
## §4 — Frontend
### §4.1 — Admin page `/admin/approval-policies`
New files:
- `frontend/src/admin-approval-policies.tsx` — page shell. Sections:
1. Header: "Genehmigungsrichtlinien" + tool-subtitle.
2. **"Partner-Unit-Standards"** — accordion list of partner units
(fetched from `/api/partner-units`). Each row expandable into the
8-cell matrix (Fristen × 4 lifecycle, Termine × 4 lifecycle), each
cell a `<select>` with options `partner | of_counsel | associate |
senior_pa | pa | none | ❌ keine Regel` (last = delete the row).
3. **"Projekt-spezifisch"** — project picker (search + flat tree dropdown
reusing `ProjectIndentRow` component from t-149). Below, the same
8-cell matrix for the selected project, each cell showing the
**effective** value with a small attribution chip:
`Projekt` (own row, dark) / `Geerbt von Mandant Acme Corp` (light,
italic) / `Standard von Partner Unit Munich Lit` (light, italic) /
`Keine Regel` (faint).
4. **"Auf Unterprojekte anwenden"** button per project row, opens
confirm modal with descendant list.
- `frontend/src/client/admin-approval-policies.ts` — orchestration.
Fetches partner-units, project tree, matrix on selection. Saves on
cell change (`PUT` with required_role; `DELETE` when set to "keine
Regel"). Re-fetches matrix after save for fresh effective view.
Bulk-apply confirm modal + POST.
### §4.2 — Admin index card
`frontend/src/admin.tsx`: add a new card to the available section:
```tsx
<a href="/admin/approval-policies" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_SHIELD }} />
<h2 data-i18n="admin.card.approval_policies.title">Genehmigungspflichten</h2>
<p data-i18n="admin.card.approval_policies.desc">4-Augen-Prüfung pro Projekt und Partner Unit konfigurieren.</p>
</a>
```
`ICON_SHIELD` (new SVG) — small shield icon, matches the visual weight of
ICON_USERS / ICON_BUILDING.
### §4.3 — `/inbox` empty-state nudge
`frontend/src/inbox.tsx`: extend the `<div className="entity-empty"
id="inbox-empty">` block with a hidden admin-only sub-block:
```tsx
<div className="inbox-admin-nudge" id="inbox-admin-nudge" style="display:none">
<h3 data-i18n="inbox.empty.admin.title">Noch keine Richtlinien aktiv?</h3>
<p data-i18n="inbox.empty.admin.body">Konfiguriere, welche Lifecycle-Events 4-Augen-Prüfung erfordern.</p>
<a href="/admin/approval-policies" className="btn-primary btn-cta-lime" data-i18n="inbox.empty.admin.cta">
Genehmigungspflichten konfigurieren
</a>
</div>
```
`frontend/src/client/inbox.ts`: when rendering empty state, fire
`/api/admin/approval-policies/seeded`. If response says `{any: false}` AND
user is `global_admin`, reveal the nudge. Otherwise hide.
### §4.4 — Form-time hint on deadline + appointment new/edit
`frontend/src/deadlines-new.tsx` + `frontend/src/appointments-new.tsx`
(also the edit forms): add a hint container above the form-actions:
```tsx
<div className="approval-hint" id="approval-hint" style="display:none">
<span className="approval-hint-icon" dangerouslySetInnerHTML={{ __html: ICON_SHIELD_SMALL }} />
<span id="approval-hint-text" />
</div>
```
Client TS: on form load, GET
`/api/projects/{project_id}/approval-policies/effective?entity_type=deadline&lifecycle=create`
(or `update` for edit). If result is non-null and `required_role !== 'none'`,
fill the hint:
> 4-Augen-Prüfung erforderlich: nach dem Speichern wird ein
> Genehmigungsantrag (associate-Level) ausgelöst. Geerbt von Partner Unit
> Munich Lit.
Same for appointments.
### §4.5 — Mobile shape
CSS in `frontend/src/styles/global.css`:
```css
/* Desktop: 2-row × 4-col matrix */
.approval-matrix {
display: grid;
grid-template-columns: 8rem repeat(4, 1fr);
gap: 0.5rem;
}
@media (max-width: 700px) {
.approval-matrix { display: block; }
.approval-matrix-section {
margin-bottom: 1.5rem;
}
.approval-matrix-section h3 {
margin: 0 0 0.5rem 0;
font-size: 1.05rem;
}
.approval-matrix-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid var(--paliad-border-soft);
}
.approval-matrix-row select { width: 50%; }
}
```
The TSX renders BOTH structures (matrix grid + section list); CSS toggles
based on viewport. Same pattern as the entity-table → entity-list mobile
flip in `frontend/src/client/projects-detail.ts`.
### §4.6 — i18n keys
~75 new keys in `frontend/src/client/i18n.ts` (DE primary, EN secondary).
Major buckets:
- `admin.card.approval_policies.title` / `.desc`
- `approvals.policy.heading` / `.subtitle` / `.empty`
- `approvals.policy.section.units` / `.projects`
- `approvals.policy.entity.deadline` / `.appointment`
- `approvals.policy.lifecycle.create` / `.update` / `.complete` / `.delete`
- `approvals.policy.required.partner` / `.of_counsel` / `.associate` / `.senior_pa` / `.pa` / `.none` / `.no_rule`
- `approvals.policy.source.project` / `.ancestor` / `.unit_default`
- `approvals.policy.bulk.cta` / `.modal.title` / `.modal.confirm` / `.modal.cancel` / `.modal.target_count` / `.modal.affected_list`
- `approvals.policy.unit_picker.placeholder` / `.project_picker.placeholder`
- `approvals.policy.cell.save_msg` / `.delete_msg` / `.error_msg`
- `inbox.empty.admin.title` / `.body` / `.cta`
- `deadlines.form.approval_hint.create` / `.update`
- `appointments.form.approval_hint.create` / `.update`
- `approvals.policy.audit.set` / `.cleared` (for `/admin/audit-log` rendering)
---
## §5 — Resolution semantics (worked examples)
Helps the implementer + reviewers reason about edge cases.
### Example A — straight unit default
**Setup:** Project P attached to one partner unit U. U has unit-default
`deadline:create=associate`. P has no own row, no ancestor with a row.
**Effective for P, deadline:create:**
- Step 1: no project row.
- Step 2: ancestor_rows = ∅. unit_rows = [{associate, level=3}]. MAX = associate.
- Result: `(required_role='associate', source='unit_default', source_id=U.id)`.
LookupPolicy returns `&ApprovalPolicy{RequiredRole: "associate", ...}`.
SubmitCreate creates a pending request needing associate sign-off.
### Example B — most-restrictive across two unit defaults
**Setup:** Project P attached to U1 (deadline:create=partner) and U2
(deadline:create=associate). No project row, no ancestor row.
**Effective for P, deadline:create:**
- Step 1: no project row.
- Step 2: unit_rows = [{partner, lvl=5}, {associate, lvl=3}]. MAX = partner.
- Result: `(required_role='partner', source='unit_default', source_id=U1.id)`.
### Example C — most-restrictive across tree + unit
**Setup:** Project hierarchy: Mandant M (deadline:create=of_counsel) → Litigation L → Patent P. P attached to unit U (deadline:create=partner).
**Effective for P, deadline:create:**
- Step 1: no row on P.
- Step 2: ancestor_rows = [{of_counsel, lvl=4 (from M)}]. unit_rows = [{partner, lvl=5}]. MAX = partner.
- Result: `(required_role='partner', source='unit_default', source_id=U.id)`.
### Example D — explicit suppression at project level
**Setup:** Same as Example C, but admin sets P's own row to
`required_role='none'` (carve-out for this single Patent — e.g. a low-stakes
auxiliary case).
**Effective for P, deadline:create:**
- Step 1: project row exists with `required_role='none'`. RETURN.
- Result: `(required_role='none', source='project', source_id=P.id)`.
LookupPolicy returns nil (the `'none'` short-circuit). SubmitCreate skips.
### Example E — most-restrictive incl. ancestor
**Setup:** Mandant M (deadline:create=partner). Litigation L below M, no
own row, attached to unit U (deadline:create=pa).
**Effective for L, deadline:create:**
- Step 1: no row on L.
- Step 2: ancestor_rows = [{partner, lvl=5}]. unit_rows = [{pa, lvl=1}]. MAX = partner.
- Result: `(required_role='partner', source='ancestor', source_id=M.id)`.
The Mandant-level rule cascades down — the typical "set once at the
client root" pattern.
---
## §6 — Implementation phasing
Single PR (~3500-4500 LoC). Five commits, ordered for readability:
1. **Migration 062 + resolver function + seed.** No Go code change.
Schema is forward-compatible: existing `LookupPolicy` (still scanning
the table directly) keeps working until commit 2 swaps it. Verify
migration with TEST_DATABASE_URL + reset.
2. **ApprovalService rewire.** New `LookupPolicy` body via resolver, new
`GetEffectivePoliciesMatrix`, extended `UpsertPolicy`/`DeletePolicy`
signatures, new `ApplyMatrixToDescendants`, audit emission. Unit
tests (table-driven): resolver fall-through cases A-E above; bulk-apply
idempotency; `'none'` short-circuit; XOR check.
3. **HTTP handlers.** Wire new admin routes + form-hint endpoint +
matrix endpoint. Hand-roll `models.ApprovalPolicy` extensions
(PartnerUnitID, Source, SourceID nullable fields). Update existing
`handleListApprovalPolicies` to return matrix shape (with attribution)
instead of raw rows.
4. **Frontend admin page.** `admin-approval-policies.tsx` + `.ts`. Cells
render with attribution chips. Bulk-apply confirm modal. Build wires
the new bundle into `frontend/build.ts`. CSS for the matrix grid +
mobile sections.
5. **Frontend touch-ups + i18n.** Admin index card. Inbox empty-state
admin nudge. Deadline/appointment form hints (`/api/projects/{id}/approval-policies/effective`
call + hint render). ~75 i18n keys DE+EN. CSS finalization.
Optional split point: 1+2+3 (backend + schema, "policies authoring works
via curl") and 4+5 (UI). Recommended single PR — 4+5 are the part that
makes the feature reachable to m, and shipping backend-only re-exposes
the issue m hit.
---
## §7 — Tests
**Backend (Go, table-driven):**
- `approval_service_test.go` extensions for the resolver:
- Project row only → returns project row.
- Project row 'none' → returns nil from LookupPolicy.
- Two unit defaults → most-restrictive.
- Ancestor row + unit default → most-restrictive across both.
- Project row + ancestor + unit defaults → project row wins.
- No candidates → returns nil.
- 'none' as unit-default value (low-priority — unusual but allowed) →
loses to any non-none.
- `ApplyMatrixToDescendants` tests:
- Source has 8 cells → target gets 8 cells.
- Source has 5 cells (3 cleared) → target gets 5 cells; existing target
rows for the other 3 are deleted (idempotent fanout, not append).
- Target is not actually a descendant → returns ErrInvalidInput.
- Self-target (target == source) → no-op.
- `UpsertPolicy` XOR validation: both NULL → ErrInvalidInput; both set →
ErrInvalidInput.
- Audit emission: each set/clear writes one `paliad.audit_log` row with
the right event type + scope.
**Live-DB integration tests (TEST_DATABASE_URL):**
- Migration 062 up + seed populates 8 rows × N partner_units. Down
reverses. Idempotent on re-up.
- Resolver function returns expected attribution for the 5 worked
examples above.
**Frontend:**
- `admin-approval-policies` smoke tests (Playwright): load page, select
partner unit, change a cell, verify save → DB. Select project, verify
attribution chips. Bulk-apply happy path.
- Form-hint on `/projects/{id}/deadlines/new` shows when policy applies,
hides when it doesn't.
---
## §8 — Trade-offs flagged
1. **Seed defaults touch live data on first deploy.** Every existing
partner_unit gains 8 policy rows. m's locked-in choice (Q11) — but
worth flagging that the moment migration 062 runs in production, the
4-eye gate becomes active for every project attached to a partner
unit. Mitigation: deploy after announcing to the team. Conservative
`associate` baseline means most users (associate, of_counsel, partner)
can both submit AND approve, so the operational impact is "your save
creates a pending request that any teammate can sign off in /inbox"
rather than "your save is blocked". The bell-icon + sidebar badge
from t-138 surfaces it.
2. **Seed `'none'` on `complete` is structurally invisible.** A
unit-default of `'none'` always loses MAX to any non-none source
(level 0 vs ≥1). So the seed `appointment.complete=none` rows are
essentially "no rule" — they don't appear in `LookupPolicy` results.
We seed them anyway for **UI consistency**: when an admin opens the
matrix, they see 8 cells filled with values, not 4 cells filled +
4 cells empty. Documenting this as intentional.
3. **'ancestor' source attribution can be ambiguous when multiple
ancestors have rows.** The resolver picks the highest-level row;
if Mandant=of_counsel and Litigation=partner, attribution surfaces
`source='ancestor', source_id=Litigation`. The Mandant rule is
silently overridden. The UI chip says "Geerbt von Litigation X" with
no hint that the Mandant also has a rule. Cost: minor — admin can
navigate to the Mandant's matrix and see its row directly. Mitigation
option (deferred): the matrix-endpoint for the admin page returns
the FULL stack of contributing rows per cell, so the chip can say
"Strengste von 3 Quellen". Worth doing if v1 attribution feels
confusing in practice.
4. **Audit lives only in `/admin/audit-log`, not in project verlauf.**
Per Q8 lock-in. Minor side effect: a non-admin user wondering "why
does my deadline now need approval?" can't see the policy-set event
on the project's verlauf. They have to check the deadline-form hint
(which says "Geerbt von Partner Unit Munich Lit") and ask an admin
for the change history. Acceptable trade-off — most users don't need
policy change history, only admins who set them.
5. **Bulk-apply destroys target's existing project-specific rows for the
8 cells.** Idempotent fanout means setting source to "matrix M" makes
targets match M, including DELETE of any pre-existing target rows
that aren't in M. This is by design (otherwise re-applying a partially-
reduced source wouldn't actually reduce). Confirm modal lists the
affected rows clearly: "12 Projekte, 8 Felder pro Projekt, ggf.
bestehende Werte überschrieben". One audit-log row per write so the
change is fully traceable.
6. **Mobile section list duplicates the matrix data structure in the
DOM.** TSX renders both the grid table and the stacked sections; CSS
toggles based on viewport. Slight DOM bloat (16 cells × 2 = 32 form
nodes per partner unit) but matches the entity-table → entity-list
pattern already used elsewhere. Alternative (single DOM rendered
responsively via flex/grid-flow) is uglier in TSX.
---
## §9 — Files the implementer will touch
**Backend (Go):**
- `internal/db/migrations/062_approval_policy_unit_defaults.up.sql` (new)
- `internal/db/migrations/062_approval_policy_unit_defaults.down.sql` (new)
- `internal/services/approval_service.go` (rewire `LookupPolicy`, add
`GetEffectivePoliciesMatrix`, `ApplyMatrixToDescendants`, extend
`UpsertPolicy`/`DeletePolicy`)
- `internal/services/approval_service_test.go` (new resolver tests, bulk-apply tests, XOR tests)
- `internal/models/approval.go` (extend `ApprovalPolicy` with optional
`PartnerUnitID`, `Source`, `SourceID`)
- `internal/handlers/approvals.go` (new unit-default + matrix + form-hint + bulk-apply handlers)
- `internal/handlers/handlers.go` (route registration for the new endpoints + `/admin/approval-policies` page)
**Frontend (TS/TSX):**
- `frontend/src/admin-approval-policies.tsx` (new)
- `frontend/src/client/admin-approval-policies.ts` (new)
- `frontend/src/admin.tsx` (add card)
- `frontend/src/inbox.tsx` (admin-nudge block)
- `frontend/src/client/inbox.ts` (gate + reveal nudge)
- `frontend/src/deadlines-new.tsx` + `frontend/src/client/deadlines-new.ts` (hint render)
- `frontend/src/appointments-new.tsx` + `frontend/src/client/appointments-new.ts` (hint render)
- `frontend/src/styles/global.css` (matrix grid + mobile sections + attribution chip)
- `frontend/src/client/i18n.ts` (~75 new keys × 2 langs)
- `frontend/build.ts` (new bundle entry: admin-approval-policies)
**Estimate:** ~3500-4500 LoC (matches t-138 + t-144 design phases — small
admin page, small migration, mostly mechanical wiring + CSS + i18n).
---
## §10 — Recommended implementer
Pattern-fluent Sonnet — substrate is well-trodden:
- Admin-page pattern → `frontend/src/admin-partner-units.tsx` is the
canonical reference (partner-unit picker → details panel; same shape
here with project picker → matrix panel).
- Project-detail edit-in-place → `client/projects-detail.ts` for the
`<select>`-on-row-click affordance pattern.
- ltree path-walk in SQL → `internal/services/visibility.go` and the
existing `paliad.can_see_project()` are the reference pattern.
- Audit emission → `internal/services/audit_service.go` (already plumbed).
- Form-hint above Speichern → similar to the t-148 profession hint
on `frontend/src/projects-detail.tsx:130` (`team-profession-hint`).
**NOT cronus** per memory directive (paliad). **NOT noether** (parked on
t-151 and t-144). **NOT godel** (just fired on t-149). **NOT hilbert
(me)** — I'm parked after this design; head decides if I take the
coder shift on the same worktree (mai/hilbert/inventor-approval-policy)
or hands it to a fresh coder.
---
## §11 — Out of scope (deferred to follow-ups)
- **Per-policy time-window** — "this rule applies only MonFri 917, after
hours skip 4-eye". Some firms do this. Deferred: another column would
be cheap, but no signal yet that anyone wants it.
- **Per-user exemptions** — "Alice is on PTO, route around her". Same
shape as today's `decision_kind='admin_override'` escape hatch — already
available via global_admin.
- **Multi-step approvals** — "needs partner THEN of_counsel sign-off".
cronus's t-138 is single-step by design (Q3 of t-138 locked it). Not
in scope here.
- **Policy templates / copy-from-other-project** — beyond bulk-apply-to-
descendants. If needed, would slot into the admin page as a
"Vorlage anwenden" affordance. Not v1.
- **Per-event_type policies** — "deadline.create with event_type='Klage'
needs partner; everything else of_counsel". The existing schema is
per-(entity_type, lifecycle_event); event-type granularity would
require an extra column + index. No signal yet.
---
**END OF DESIGN.**
Inventor stays parked. Awaits m's go/no-go on the 12 locked decisions
before any coder shift. Hand-off via head once green.

View File

@@ -38,6 +38,7 @@ import { renderAdminPartnerUnits } from "./src/admin-partner-units";
import { renderAdminEmailTemplates } from "./src/admin-email-templates";
import { renderAdminEmailTemplatesEdit } from "./src/admin-email-templates-edit";
import { renderAdminEventTypes } from "./src/admin-event-types";
import { renderAdminApprovalPolicies } from "./src/admin-approval-policies";
import { renderAdminBroadcasts } from "./src/admin-broadcasts";
import { renderPaliadin } from "./src/paliadin";
import { renderAdminPaliadin } from "./src/admin-paliadin";
@@ -267,6 +268,7 @@ async function build() {
join(import.meta.dir, "src/client/admin-email-templates.ts"),
join(import.meta.dir, "src/client/admin-email-templates-edit.ts"),
join(import.meta.dir, "src/client/admin-event-types.ts"),
join(import.meta.dir, "src/client/admin-approval-policies.ts"),
join(import.meta.dir, "src/client/admin-broadcasts.ts"),
join(import.meta.dir, "src/client/paliadin.ts"),
join(import.meta.dir, "src/client/admin-paliadin.ts"),
@@ -385,6 +387,7 @@ async function build() {
await Bun.write(join(DIST, "admin-email-templates.html"), renderAdminEmailTemplates());
await Bun.write(join(DIST, "admin-email-templates-edit.html"), renderAdminEmailTemplatesEdit());
await Bun.write(join(DIST, "admin-event-types.html"), renderAdminEventTypes());
await Bun.write(join(DIST, "admin-approval-policies.html"), renderAdminApprovalPolicies());
await Bun.write(join(DIST, "admin-broadcasts.html"), renderAdminBroadcasts());
await Bun.write(join(DIST, "paliadin.html"), renderPaliadin());
await Bun.write(join(DIST, "admin-paliadin.html"), renderAdminPaliadin());

View File

@@ -0,0 +1,135 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// t-paliad-154 — admin approval-policy authoring page. Single page with
// two sections:
//
// 1. Partner-Unit-Standards: list of partner_units, each expandable into
// its 8-cell matrix (deadline + appointment × create / update /
// complete / delete). Edits hit /api/admin/partner-units/{id}/...
//
// 2. Projekt-spezifisch: project-tree picker → 8-cell matrix for the
// selected project, showing the EFFECTIVE policy per cell with an
// attribution chip (Projekt / Geerbt / Standard). Edits hit
// /api/projects/{id}/approval-policies/{entity}/{lifecycle}.
//
// Mobile shape: the matrix grid collapses to two stacked sections (Fristen,
// Termine) below 700px — driven by CSS, not by JS.
export function renderAdminApprovalPolicies(): 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="admin.approval_policies.title">Genehmigungspflichten &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/approval-policies" />
<BottomNav currentPath="/admin/approval-policies" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<h1 data-i18n="admin.approval_policies.heading">Genehmigungspflichten</h1>
<p className="tool-subtitle" data-i18n="admin.approval_policies.subtitle">
4-Augen-Pr&uuml;fung pro Projekt und Partner Unit konfigurieren.
</p>
</div>
<div id="ap-feedback" className="form-msg" style="display:none" />
{/* ============================================================
Section 1: Partner-Unit-Standards.
============================================================ */}
<h2 className="section-heading" data-i18n="admin.approval_policies.section.units">
Partner-Unit-Standards
</h2>
<p className="form-hint" data-i18n="admin.approval_policies.section.units.hint">
Standardregeln, die jedes Projekt erbt, das einer Partner Unit zugeordnet ist.
Bei mehreren Partner Units gewinnt die strengste Regel.
</p>
<div className="ap-units-list" id="ap-units-list">
<div className="ap-loading" data-i18n="admin.approval_policies.loading">L&auml;dt &hellip;</div>
</div>
{/* ============================================================
Section 2: Projekt-spezifisch.
============================================================ */}
<h2 className="section-heading" data-i18n="admin.approval_policies.section.projects">
Projekt-spezifisch
</h2>
<p className="form-hint" data-i18n="admin.approval_policies.section.projects.hint">
Eigene Regeln f&uuml;r ein Projekt. &Uuml;berschreiben Standards aus Partner Units und
geerbten Projektregeln.
</p>
<div className="ap-project-picker">
<label htmlFor="ap-project-search" data-i18n="admin.approval_policies.picker.label">
Projekt w&auml;hlen
</label>
<input
type="text"
id="ap-project-search"
className="ap-project-search"
data-i18n-placeholder="admin.approval_policies.picker.placeholder"
placeholder="Suchen..."
autocomplete="off"
/>
<div className="ap-project-results" id="ap-project-results" />
</div>
<div className="ap-project-matrix" id="ap-project-matrix" style="display:none">
<div className="ap-project-header">
<h3 id="ap-project-title" />
<button type="button" className="btn-secondary btn-small" id="ap-bulk-apply-btn"
data-i18n="admin.approval_policies.bulk.cta">
Auf Unterprojekte anwenden
</button>
</div>
<div className="ap-matrix-host" id="ap-matrix-host" />
</div>
</div>
</section>
<Footer />
</main>
{/* Bulk-apply confirm modal — populated client-side. */}
<div className="modal-overlay" id="ap-bulk-modal" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 data-i18n="admin.approval_policies.bulk.modal.title">
Auf Unterprojekte anwenden
</h2>
<button className="modal-close" id="ap-bulk-close" type="button" aria-label="Close">&times;</button>
</div>
<div className="ap-bulk-body">
<p data-i18n="admin.approval_policies.bulk.modal.body">
Die folgenden Unterprojekte erhalten die effektive Matrix dieses Projekts als
projektspezifische Regeln. Bestehende projektspezifische Regeln werden
&uuml;berschrieben. Standards aus Partner Units bleiben unber&uuml;hrt.
</p>
<ul className="ap-bulk-target-list" id="ap-bulk-target-list" />
<p className="form-msg" id="ap-bulk-msg" />
<div className="form-actions">
<button type="button" className="btn-cancel" id="ap-bulk-cancel"
data-i18n="admin.approval_policies.bulk.modal.cancel">Abbrechen</button>
<button type="button" className="btn-primary btn-cta-lime" id="ap-bulk-confirm"
data-i18n="admin.approval_policies.bulk.modal.confirm">&Uuml;bernehmen</button>
</div>
</div>
</div>
</div>
<script src="/assets/admin-approval-policies.js" defer />
</body>
</html>
);
}

View File

@@ -10,6 +10,7 @@ const ICON_LOG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" str
const ICON_MAIL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><polyline points="3 7 12 13 21 7"/></svg>';
const ICON_FLAG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>';
const ICON_TABLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/></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"/><path d="m9 12 2 2 4-4"/></svg>';
interface PlannedCard {
icon: string;
@@ -88,6 +89,11 @@ export function renderAdmin(): string {
<h2 data-i18n="admin.card.broadcasts.title">Broadcasts</h2>
<p data-i18n="admin.card.broadcasts.desc">Versendete Massen-E-Mails an Teamauswahlen einsehen.</p>
</a>
<a href="/admin/approval-policies" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_SHIELD }} />
<h2 data-i18n="admin.card.approval_policies.title">Genehmigungspflichten</h2>
<p data-i18n="admin.card.approval_policies.desc">4-Augen-Pr&uuml;fung pro Projekt und Partner Unit konfigurieren.</p>
</a>
</div>
<h3 className="section-heading admin-section-planned" data-i18n="admin.section.planned">Geplant</h3>

View File

@@ -86,6 +86,14 @@ export function renderAppointmentsNew(): string {
<p className="form-msg" id="appointment-new-msg" />
{/* t-paliad-154 — form-time 4-eye hint. */}
<div className="approval-hint" id="appointment-approval-hint" style="display:none">
<span className="approval-hint-icon" dangerouslySetInnerHTML={{
__html: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>'
}} />
<span id="appointment-approval-hint-text" />
</div>
<div className="form-actions">
<a href="/events?type=appointment" id="appointment-new-cancel" className="btn-cancel" data-i18n="appointments.neu.cancel">Abbrechen</a>
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="appointments.neu.submit">Termin anlegen</button>

View File

@@ -0,0 +1,540 @@
import { initI18n, onLangChange, t, tDyn } from "./i18n";
import { initSidebar } from "./sidebar";
// t-paliad-154 — admin approval-policy authoring page orchestration.
//
// Two sections: Partner-Unit-Standards (accordion list) and Projekt-spezifisch
// (project picker → 8-cell matrix). Edits hit the per-scope CRUD endpoints
// from the same page; re-renders refresh from server state to surface
// inheritance changes.
interface PartnerUnit {
id: string;
name: string;
office: string;
}
interface UnitPolicy {
id: string;
partner_unit_id: string | null;
project_id: string | null;
entity_type: string;
lifecycle_event: string;
required_role: string;
}
interface EffectivePolicy {
entity_type: string;
lifecycle_event: string;
required_role?: string | null;
source?: string | null;
source_id?: string | null;
source_name?: string | null;
}
interface ProjectNode {
id: string;
title: string;
reference?: string | null;
type?: string;
parent_id?: string | null;
children?: ProjectNode[];
}
const ENTITY_TYPES = ["deadline", "appointment"] as const;
const LIFECYCLES = ["create", "update", "complete", "delete"] as const;
const ROLE_OPTIONS = [
"partner",
"of_counsel",
"associate",
"senior_pa",
"pa",
"none",
];
let partnerUnits: PartnerUnit[] = [];
let unitPolicies: Record<string, UnitPolicy[]> = {};
let allProjects: ProjectNode[] = [];
let selectedProjectID: string | null = null;
let selectedProjectTitle: string = "";
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
function showFeedback(msg: string, isError: boolean): void {
const el = document.getElementById("ap-feedback");
if (!el) return;
el.textContent = msg;
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-ok");
el.style.display = "block";
if (!isError) setTimeout(() => { el.style.display = "none"; }, 3500);
}
// ============================================================================
// Loaders.
// ============================================================================
async function loadPartnerUnits(): Promise<void> {
const resp = await fetch("/api/partner-units");
if (!resp.ok) {
partnerUnits = [];
return;
}
partnerUnits = (await resp.json()) as PartnerUnit[];
}
async function loadUnitPolicies(unitID: string): Promise<UnitPolicy[]> {
const resp = await fetch(`/api/admin/partner-units/${encodeURIComponent(unitID)}/approval-policies`);
if (!resp.ok) return [];
return (await resp.json()) as UnitPolicy[];
}
async function loadAllUnitPolicies(): Promise<void> {
const out: Record<string, UnitPolicy[]> = {};
for (const u of partnerUnits) {
out[u.id] = await loadUnitPolicies(u.id);
}
unitPolicies = out;
}
async function loadProjects(): Promise<void> {
const resp = await fetch("/api/projects/tree");
if (!resp.ok) {
allProjects = [];
return;
}
const tree = (await resp.json()) as ProjectNode[];
allProjects = flattenTree(tree);
}
function flattenTree(nodes: ProjectNode[]): ProjectNode[] {
const out: ProjectNode[] = [];
const walk = (n: ProjectNode): void => {
out.push(n);
if (n.children) n.children.forEach(walk);
};
nodes.forEach(walk);
return out;
}
async function loadMatrix(projectID: string): Promise<EffectivePolicy[]> {
const resp = await fetch(`/api/admin/approval-policies/matrix?project_id=${encodeURIComponent(projectID)}`);
if (!resp.ok) return [];
return (await resp.json()) as EffectivePolicy[];
}
// ============================================================================
// Rendering — partner unit accordion.
// ============================================================================
function lifecycleLabel(l: string): string {
return tDyn("admin.approval_policies.lifecycle." + l) || l;
}
function entityLabel(e: string): string {
return tDyn("admin.approval_policies.entity." + e) || e;
}
function roleLabel(r: string): string {
return tDyn("admin.approval_policies.role." + r) || r;
}
function policyForCell(rows: UnitPolicy[], entity: string, lifecycle: string): UnitPolicy | undefined {
return rows.find((p) => p.entity_type === entity && p.lifecycle_event === lifecycle);
}
function renderRoleSelect(currentValue: string | null, dataAttrs: string): string {
const opts: string[] = [];
// "Keine Regel" sentinel — distinct from 'none' (which is the explicit
// suppression value). Empty string maps to DELETE.
opts.push(`<option value=""${currentValue === null ? " selected" : ""}>${esc(t("admin.approval_policies.role.no_rule") || "— keine Regel —")}</option>`);
for (const r of ROLE_OPTIONS) {
opts.push(`<option value="${esc(r)}"${currentValue === r ? " selected" : ""}>${esc(roleLabel(r))}</option>`);
}
return `<select class="ap-cell-select" ${dataAttrs}>${opts.join("")}</select>`;
}
function renderUnitMatrix(unit: PartnerUnit): string {
const rows = unitPolicies[unit.id] || [];
let cells = "";
for (const e of ENTITY_TYPES) {
cells += `<tr><th class="ap-matrix-rowhead">${esc(entityLabel(e))}</th>`;
for (const l of LIFECYCLES) {
const p = policyForCell(rows, e, l);
const v = p ? p.required_role : null;
const attrs = `data-scope="unit" data-unit-id="${escAttr(unit.id)}" data-entity="${esc(e)}" data-lifecycle="${esc(l)}"`;
cells += `<td class="ap-matrix-cell">${renderRoleSelect(v, attrs)}</td>`;
}
cells += `</tr>`;
}
// Stacked sections for mobile (CSS toggles the table vs the list).
let stacked = "";
for (const e of ENTITY_TYPES) {
stacked += `<div class="ap-matrix-section"><h4>${esc(entityLabel(e))}</h4>`;
for (const l of LIFECYCLES) {
const p = policyForCell(rows, e, l);
const v = p ? p.required_role : null;
const attrs = `data-scope="unit" data-unit-id="${escAttr(unit.id)}" data-entity="${esc(e)}" data-lifecycle="${esc(l)}"`;
stacked += `<div class="ap-matrix-row">
<span class="ap-matrix-row-label">${esc(lifecycleLabel(l))}</span>
${renderRoleSelect(v, attrs)}
</div>`;
}
stacked += `</div>`;
}
return `
<table class="ap-matrix">
<thead><tr><th></th>
<th>${esc(lifecycleLabel("create"))}</th>
<th>${esc(lifecycleLabel("update"))}</th>
<th>${esc(lifecycleLabel("complete"))}</th>
<th>${esc(lifecycleLabel("delete"))}</th>
</tr></thead>
<tbody>${cells}</tbody>
</table>
<div class="ap-matrix-stacked">${stacked}</div>
`;
}
function renderUnits(): void {
const host = document.getElementById("ap-units-list");
if (!host) return;
if (partnerUnits.length === 0) {
host.innerHTML = `<p class="form-hint">${esc(t("admin.approval_policies.units.empty") || "Keine Partner Units vorhanden.")}</p>`;
return;
}
host.innerHTML = partnerUnits.map((u) => `
<details class="ap-unit-block">
<summary class="ap-unit-summary">
<span class="ap-unit-name">${esc(u.name)}</span>
<span class="office-chip office-${esc(u.office)}">${esc(u.office)}</span>
</summary>
<div class="ap-unit-body">
${renderUnitMatrix(u)}
</div>
</details>
`).join("");
bindCellChangeHandlers(host);
}
// ============================================================================
// Rendering — project matrix with attribution chips.
// ============================================================================
function renderProjectMatrix(rows: EffectivePolicy[]): string {
const cell = (r: EffectivePolicy): string => {
const v = r.required_role || null;
const own = r.source === "project";
const attrs = `data-scope="project" data-project-id="${escAttr(selectedProjectID || "")}" data-entity="${esc(r.entity_type)}" data-lifecycle="${esc(r.lifecycle_event)}"`;
let chip = "";
if (r.source && !own && r.required_role) {
const sourceKey = r.source === "ancestor" ? "admin.approval_policies.source.ancestor" :
r.source === "unit_default" ? "admin.approval_policies.source.unit_default" :
"admin.approval_policies.source.project";
const label = tDyn(sourceKey) || r.source;
const name = r.source_name ? ` · ${esc(r.source_name)}` : "";
chip = `<span class="ap-source-chip ap-source-${esc(r.source)}">${esc(label)}${name}</span>`;
} else if (own) {
chip = `<span class="ap-source-chip ap-source-project">${esc(t("admin.approval_policies.source.project") || "Projekt")}</span>`;
}
return `<div class="ap-cell-wrap">${renderRoleSelect(own ? v : null, attrs)}${chip}</div>`;
};
const byCell = new Map<string, EffectivePolicy>();
for (const r of rows) byCell.set(`${r.entity_type}:${r.lifecycle_event}`, r);
const cellFor = (e: string, l: string): EffectivePolicy =>
byCell.get(`${e}:${l}`) || { entity_type: e, lifecycle_event: l };
let table = "";
for (const e of ENTITY_TYPES) {
table += `<tr><th class="ap-matrix-rowhead">${esc(entityLabel(e))}</th>`;
for (const l of LIFECYCLES) {
table += `<td class="ap-matrix-cell">${cell(cellFor(e, l))}</td>`;
}
table += `</tr>`;
}
let stacked = "";
for (const e of ENTITY_TYPES) {
stacked += `<div class="ap-matrix-section"><h4>${esc(entityLabel(e))}</h4>`;
for (const l of LIFECYCLES) {
stacked += `<div class="ap-matrix-row">
<span class="ap-matrix-row-label">${esc(lifecycleLabel(l))}</span>
${cell(cellFor(e, l))}
</div>`;
}
stacked += `</div>`;
}
return `
<table class="ap-matrix">
<thead><tr><th></th>
<th>${esc(lifecycleLabel("create"))}</th>
<th>${esc(lifecycleLabel("update"))}</th>
<th>${esc(lifecycleLabel("complete"))}</th>
<th>${esc(lifecycleLabel("delete"))}</th>
</tr></thead>
<tbody>${table}</tbody>
</table>
<div class="ap-matrix-stacked">${stacked}</div>
`;
}
async function selectProject(p: ProjectNode): Promise<void> {
selectedProjectID = p.id;
selectedProjectTitle = p.title;
const matrix = await loadMatrix(p.id);
const wrap = document.getElementById("ap-project-matrix");
const host = document.getElementById("ap-matrix-host");
const titleEl = document.getElementById("ap-project-title");
if (!wrap || !host || !titleEl) return;
wrap.style.display = "block";
titleEl.textContent = p.title + (p.reference ? ` · ${p.reference}` : "");
host.innerHTML = renderProjectMatrix(matrix);
bindCellChangeHandlers(host);
}
function renderProjectResults(filter: string): void {
const host = document.getElementById("ap-project-results");
if (!host) return;
const q = filter.trim().toLowerCase();
let matches = allProjects;
if (q.length > 0) {
matches = allProjects.filter((p) => {
const t = (p.title || "").toLowerCase();
const r = (p.reference || "").toLowerCase();
return t.includes(q) || r.includes(q);
});
}
matches = matches.slice(0, 30);
if (matches.length === 0) {
host.innerHTML = `<p class="form-hint">${esc(t("admin.approval_policies.picker.no_results") || "Keine Treffer.")}</p>`;
return;
}
host.innerHTML = matches.map((p) => `
<button type="button" class="ap-project-result" data-id="${escAttr(p.id)}">
<span class="ap-project-result-title">${esc(p.title)}</span>
${p.reference ? `<span class="ap-project-result-ref">${esc(p.reference)}</span>` : ""}
</button>
`).join("");
host.querySelectorAll<HTMLButtonElement>(".ap-project-result").forEach((btn) => {
btn.addEventListener("click", () => {
const id = btn.dataset.id || "";
const p = allProjects.find((x) => x.id === id);
if (p) void selectProject(p);
});
});
}
// ============================================================================
// Cell change → server.
// ============================================================================
function bindCellChangeHandlers(scope: HTMLElement): void {
scope.querySelectorAll<HTMLSelectElement>(".ap-cell-select").forEach((sel) => {
sel.addEventListener("change", () => void onCellChange(sel));
});
}
async function onCellChange(sel: HTMLSelectElement): Promise<void> {
const scope = sel.dataset.scope;
const entity = sel.dataset.entity || "";
const lifecycle = sel.dataset.lifecycle || "";
const value = sel.value;
let url = "";
if (scope === "unit") {
const unitID = sel.dataset.unitId || "";
url = `/api/admin/partner-units/${encodeURIComponent(unitID)}/approval-policies/${encodeURIComponent(entity)}/${encodeURIComponent(lifecycle)}`;
} else if (scope === "project") {
const projectID = sel.dataset.projectId || "";
url = `/api/projects/${encodeURIComponent(projectID)}/approval-policies/${encodeURIComponent(entity)}/${encodeURIComponent(lifecycle)}`;
} else {
return;
}
try {
let resp: Response;
if (value === "") {
resp = await fetch(url, { method: "DELETE" });
} else {
resp = await fetch(url, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ required_role: value }),
});
}
if (!resp.ok) {
const errBody = await resp.text();
showFeedback(`${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${errBody}`, true);
return;
}
showFeedback(t("admin.approval_policies.cell.saved_msg") || "Gespeichert.", false);
// Re-fetch the affected scope so attribution chips reflect the new state.
if (scope === "unit") {
const unitID = sel.dataset.unitId || "";
unitPolicies[unitID] = await loadUnitPolicies(unitID);
renderUnits();
} else if (selectedProjectID) {
const matrix = await loadMatrix(selectedProjectID);
const host = document.getElementById("ap-matrix-host");
if (host) {
host.innerHTML = renderProjectMatrix(matrix);
bindCellChangeHandlers(host);
}
}
} catch (err) {
showFeedback(`${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${err}`, true);
}
}
// ============================================================================
// Bulk-apply to descendants.
// ============================================================================
function descendantsOf(rootID: string): ProjectNode[] {
// Build parent-child map from the flat list.
const byParent = new Map<string, ProjectNode[]>();
for (const p of allProjects) {
const parent = p.parent_id || "";
if (!byParent.has(parent)) byParent.set(parent, []);
byParent.get(parent)!.push(p);
}
const out: ProjectNode[] = [];
const walk = (id: string): void => {
const kids = byParent.get(id) || [];
for (const k of kids) {
out.push(k);
walk(k.id);
}
};
walk(rootID);
return out;
}
function openBulkModal(): void {
if (!selectedProjectID) return;
const targets = descendantsOf(selectedProjectID);
const list = document.getElementById("ap-bulk-target-list");
const modal = document.getElementById("ap-bulk-modal");
if (!list || !modal) return;
if (targets.length === 0) {
showFeedback(t("admin.approval_policies.bulk.no_descendants") || "Keine Unterprojekte vorhanden.", true);
return;
}
list.innerHTML = targets.map((p) => `
<li><span class="ap-bulk-target-title">${esc(p.title)}</span>${p.reference ? ` <span class="ap-bulk-target-ref">${esc(p.reference)}</span>` : ""}</li>
`).join("");
modal.style.display = "flex";
modal.dataset.targets = JSON.stringify(targets.map((p) => p.id));
}
function closeBulkModal(): void {
const modal = document.getElementById("ap-bulk-modal");
if (modal) modal.style.display = "none";
}
async function confirmBulk(): Promise<void> {
if (!selectedProjectID) return;
const modal = document.getElementById("ap-bulk-modal");
const msg = document.getElementById("ap-bulk-msg");
if (!modal || !msg) return;
const targets = JSON.parse(modal.dataset.targets || "[]") as string[];
msg.textContent = t("admin.approval_policies.bulk.modal.applying") || "Übernehme …";
try {
const resp = await fetch("/api/admin/approval-policies/apply-to-descendants", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
source_project_id: selectedProjectID,
target_project_ids: targets,
}),
});
if (!resp.ok) {
const body = await resp.text();
msg.textContent = `${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${body}`;
return;
}
const out = await resp.json() as { writes: number; targets: number };
closeBulkModal();
showFeedback(
(t("admin.approval_policies.bulk.modal.done") || "Übernommen") +
`${out.writes} ${t("admin.approval_policies.bulk.modal.writes_label") || "Schreibvorgänge"} auf ${out.targets} ${t("admin.approval_policies.bulk.modal.targets_label") || "Projekte"}.`,
false,
);
// Re-fetch the source matrix so any cells the bulk-apply touched on
// descendants are reflected via inheritance attribution if applicable.
if (selectedProjectID) {
const matrix = await loadMatrix(selectedProjectID);
const host = document.getElementById("ap-matrix-host");
if (host) {
host.innerHTML = renderProjectMatrix(matrix);
bindCellChangeHandlers(host);
}
}
} catch (err) {
msg.textContent = `${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${err}`;
}
}
// ============================================================================
// Wire-up.
// ============================================================================
function wirePicker(): void {
const input = document.getElementById("ap-project-search") as HTMLInputElement | null;
if (!input) return;
input.addEventListener("input", () => renderProjectResults(input.value));
// Initial empty-search renders top-of-list.
renderProjectResults("");
}
function wireBulk(): void {
const btn = document.getElementById("ap-bulk-apply-btn");
const close = document.getElementById("ap-bulk-close");
const cancel = document.getElementById("ap-bulk-cancel");
const confirm = document.getElementById("ap-bulk-confirm");
if (btn) btn.addEventListener("click", openBulkModal);
if (close) close.addEventListener("click", closeBulkModal);
if (cancel) cancel.addEventListener("click", closeBulkModal);
if (confirm) confirm.addEventListener("click", () => void confirmBulk());
}
async function init(): Promise<void> {
initI18n();
initSidebar();
await Promise.all([loadPartnerUnits(), loadProjects()]);
await loadAllUnitPolicies();
renderUnits();
wirePicker();
wireBulk();
onLangChange(() => {
renderUnits();
if (selectedProjectID) {
void loadMatrix(selectedProjectID).then((matrix) => {
const host = document.getElementById("ap-matrix-host");
if (host) {
host.innerHTML = renderProjectMatrix(matrix);
bindCellChangeHandlers(host);
}
});
}
renderProjectResults((document.getElementById("ap-project-search") as HTMLInputElement | null)?.value || "");
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => void init());
} else {
void init();
}

View File

@@ -1,4 +1,4 @@
import { initI18n, t } from "./i18n";
import { initI18n, t, tDyn } from "./i18n";
import { initSidebar } from "./sidebar";
import { projectIndent } from "./project-indent";
@@ -107,6 +107,46 @@ async function submitForm(ev: Event) {
}
}
// t-paliad-154 — form-time 4-eye hint, mirroring deadlines-new.ts.
async function refreshApprovalHint(): Promise<void> {
const hint = document.getElementById("appointment-approval-hint");
const text = document.getElementById("appointment-approval-hint-text");
if (!hint || !text) return;
const projectID = (document.getElementById("appointment-project") as HTMLSelectElement | null)?.value || "";
if (!projectID) {
hint.style.display = "none";
return;
}
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectID)}/approval-policies/effective?entity_type=appointment&lifecycle=create`,
{ credentials: "include" },
);
if (!resp.ok) {
hint.style.display = "none";
return;
}
const eff = await resp.json() as {
required_role?: string | null;
source?: string | null;
source_name?: string | null;
};
if (!eff.required_role || eff.required_role === "none") {
hint.style.display = "none";
return;
}
const roleLabel = tDyn("admin.approval_policies.role." + eff.required_role) || eff.required_role;
const sourceLabel = eff.source_name
? ` · ${tDyn("admin.approval_policies.source." + (eff.source || "")) || ""}: ${eff.source_name}`
: "";
text.textContent = (t("appointments.form.approval_hint") || "4-Augen-Prüfung erforderlich")
+ ` · ${roleLabel}${sourceLabel}`;
hint.style.display = "";
} catch {
hint.style.display = "none";
}
}
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
@@ -114,4 +154,8 @@ document.addEventListener("DOMContentLoaded", async () => {
populateProjects();
preFillStart();
document.getElementById("appointment-new-form")!.addEventListener("submit", submitForm);
void refreshApprovalHint();
document.getElementById("appointment-project")?.addEventListener("change", () => {
void refreshApprovalHint();
});
});

View File

@@ -1,4 +1,4 @@
import { initI18n, t } from "./i18n";
import { initI18n, t, tDyn } from "./i18n";
import { initSidebar } from "./sidebar";
import { attachEventTypePicker, type PickerHandle } from "./event-types";
import { projectIndent } from "./project-indent";
@@ -171,6 +171,49 @@ async function loadMe() {
}
}
// t-paliad-154 — fetch the effective approval policy for (project,
// deadline, create) and reveal the form-time hint when it applies.
// Hidden when no policy applies. Re-runs on project change so the hint
// updates if the user picks a different project mid-form.
async function refreshApprovalHint(): Promise<void> {
const hint = document.getElementById("deadline-approval-hint");
const text = document.getElementById("deadline-approval-hint-text");
if (!hint || !text) return;
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
if (!projectID) {
hint.style.display = "none";
return;
}
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectID)}/approval-policies/effective?entity_type=deadline&lifecycle=create`,
{ credentials: "include" },
);
if (!resp.ok) {
hint.style.display = "none";
return;
}
const eff = await resp.json() as {
required_role?: string | null;
source?: string | null;
source_name?: string | null;
};
if (!eff.required_role || eff.required_role === "none") {
hint.style.display = "none";
return;
}
const roleLabel = tDyn("admin.approval_policies.role." + eff.required_role) || eff.required_role;
const sourceLabel = eff.source_name
? ` · ${tDyn("admin.approval_policies.source." + (eff.source || "")) || ""}: ${eff.source_name}`
: "";
text.textContent = (t("deadlines.form.approval_hint") || "4-Augen-Prüfung erforderlich")
+ ` · ${roleLabel}${sourceLabel}`;
hint.style.display = "";
} catch {
hint.style.display = "none";
}
}
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
@@ -187,4 +230,9 @@ document.addEventListener("DOMContentLoaded", async () => {
currentUserAdmin,
});
}
// Wire approval-hint refresh: on first render + on project change.
void refreshApprovalHint();
document.getElementById("deadline-project")?.addEventListener("change", () => {
void refreshApprovalHint();
});
});

View File

@@ -1611,6 +1611,53 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.card.feature_flags.desc": "Funktionen pro Standort, Partner Unit oder Rolle aktivieren.",
"admin.card.broadcasts.title": "Broadcasts",
"admin.card.broadcasts.desc": "Versendete Massen-E-Mails an Teamauswahlen einsehen.",
"admin.card.approval_policies.title": "Genehmigungspflichten",
"admin.card.approval_policies.desc": "4-Augen-Prüfung pro Projekt und Partner Unit konfigurieren.",
"admin.approval_policies.title": "Genehmigungspflichten — Paliad",
"admin.approval_policies.heading": "Genehmigungspflichten",
"admin.approval_policies.subtitle": "4-Augen-Prüfung pro Projekt und Partner Unit konfigurieren.",
"admin.approval_policies.loading": "Lädt …",
"admin.approval_policies.section.units": "Partner-Unit-Standards",
"admin.approval_policies.section.units.hint": "Standardregeln, die jedes Projekt erbt, das einer Partner Unit zugeordnet ist. Bei mehreren Partner Units gewinnt die strengste Regel.",
"admin.approval_policies.section.projects": "Projekt-spezifisch",
"admin.approval_policies.section.projects.hint": "Eigene Regeln für ein Projekt. Überschreiben Standards aus Partner Units und geerbte Projektregeln.",
"admin.approval_policies.units.empty": "Keine Partner Units vorhanden.",
"admin.approval_policies.picker.label": "Projekt wählen",
"admin.approval_policies.picker.placeholder": "Suchen…",
"admin.approval_policies.picker.no_results": "Keine Treffer.",
"admin.approval_policies.entity.deadline": "Fristen",
"admin.approval_policies.entity.appointment": "Termine",
"admin.approval_policies.lifecycle.create": "Erstellen",
"admin.approval_policies.lifecycle.update": "Ändern",
"admin.approval_policies.lifecycle.complete": "Erledigen",
"admin.approval_policies.lifecycle.delete": "Löschen",
"admin.approval_policies.role.partner": "Partner",
"admin.approval_policies.role.of_counsel": "Of Counsel",
"admin.approval_policies.role.associate": "Associate",
"admin.approval_policies.role.senior_pa": "Senior PA",
"admin.approval_policies.role.pa": "PA",
"admin.approval_policies.role.none": "Keine Genehmigung",
"admin.approval_policies.role.no_rule": "— keine Regel —",
"admin.approval_policies.source.project": "Projekt",
"admin.approval_policies.source.ancestor": "Geerbt",
"admin.approval_policies.source.unit_default": "Standard",
"admin.approval_policies.cell.saved_msg": "Gespeichert.",
"admin.approval_policies.cell.error_msg": "Fehler",
"admin.approval_policies.bulk.cta": "Auf Unterprojekte anwenden",
"admin.approval_policies.bulk.no_descendants": "Keine Unterprojekte vorhanden.",
"admin.approval_policies.bulk.modal.title": "Auf Unterprojekte anwenden",
"admin.approval_policies.bulk.modal.body": "Die folgenden Unterprojekte erhalten die effektive Matrix dieses Projekts als projektspezifische Regeln. Bestehende projektspezifische Regeln werden überschrieben. Standards aus Partner Units bleiben unberührt.",
"admin.approval_policies.bulk.modal.cancel": "Abbrechen",
"admin.approval_policies.bulk.modal.confirm": "Übernehmen",
"admin.approval_policies.bulk.modal.applying": "Übernehme …",
"admin.approval_policies.bulk.modal.done": "Übernommen",
"admin.approval_policies.bulk.modal.writes_label": "Schreibvorgänge",
"admin.approval_policies.bulk.modal.targets_label": "Projekte",
"inbox.empty.admin_nudge.title": "Noch keine Genehmigungspflichten konfiguriert?",
"inbox.empty.admin_nudge.body": "Lege fest, welche Lifecycle-Events 4-Augen-Prüfung erfordern.",
"inbox.empty.admin_nudge.cta": "Genehmigungspflichten konfigurieren",
"deadlines.form.approval_hint": "4-Augen-Prüfung erforderlich",
"appointments.form.approval_hint": "4-Augen-Prüfung erforderlich",
"admin.email_templates.title": "Email-Templates — Paliad",
"admin.email_templates.heading": "Email-Templates",
"admin.email_templates.subtitle": "Vorlagen für Einladungen, Erinnerungen und das Layout-Wrapper anpassen.",
@@ -3606,6 +3653,53 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.card.feature_flags.desc": "Enable features per office, partner unit or role.",
"admin.card.broadcasts.title": "Broadcasts",
"admin.card.broadcasts.desc": "Inspect bulk emails sent to team selections.",
"admin.card.approval_policies.title": "Approval Policies",
"admin.card.approval_policies.desc": "Configure 4-eye review per project and partner unit.",
"admin.approval_policies.title": "Approval Policies — Paliad",
"admin.approval_policies.heading": "Approval Policies",
"admin.approval_policies.subtitle": "Configure 4-eye review per project and partner unit.",
"admin.approval_policies.loading": "Loading …",
"admin.approval_policies.section.units": "Partner Unit Defaults",
"admin.approval_policies.section.units.hint": "Default rules that every project attached to a partner unit inherits. When multiple partner units apply, the strictest rule wins.",
"admin.approval_policies.section.projects": "Project-specific",
"admin.approval_policies.section.projects.hint": "Per-project rules. Override partner-unit defaults and inherited project rules.",
"admin.approval_policies.units.empty": "No partner units yet.",
"admin.approval_policies.picker.label": "Pick a project",
"admin.approval_policies.picker.placeholder": "Search…",
"admin.approval_policies.picker.no_results": "No matches.",
"admin.approval_policies.entity.deadline": "Deadlines",
"admin.approval_policies.entity.appointment": "Appointments",
"admin.approval_policies.lifecycle.create": "Create",
"admin.approval_policies.lifecycle.update": "Edit",
"admin.approval_policies.lifecycle.complete": "Complete",
"admin.approval_policies.lifecycle.delete": "Delete",
"admin.approval_policies.role.partner": "Partner",
"admin.approval_policies.role.of_counsel": "Of Counsel",
"admin.approval_policies.role.associate": "Associate",
"admin.approval_policies.role.senior_pa": "Senior PA",
"admin.approval_policies.role.pa": "PA",
"admin.approval_policies.role.none": "No approval",
"admin.approval_policies.role.no_rule": "— no rule —",
"admin.approval_policies.source.project": "Project",
"admin.approval_policies.source.ancestor": "Inherited",
"admin.approval_policies.source.unit_default": "Default",
"admin.approval_policies.cell.saved_msg": "Saved.",
"admin.approval_policies.cell.error_msg": "Error",
"admin.approval_policies.bulk.cta": "Apply to descendants",
"admin.approval_policies.bulk.no_descendants": "No descendants.",
"admin.approval_policies.bulk.modal.title": "Apply to descendants",
"admin.approval_policies.bulk.modal.body": "The descendants below receive this project's effective matrix as project-specific rules. Existing project-specific rules will be overwritten. Partner-unit defaults remain intact.",
"admin.approval_policies.bulk.modal.cancel": "Cancel",
"admin.approval_policies.bulk.modal.confirm": "Apply",
"admin.approval_policies.bulk.modal.applying": "Applying …",
"admin.approval_policies.bulk.modal.done": "Applied",
"admin.approval_policies.bulk.modal.writes_label": "writes",
"admin.approval_policies.bulk.modal.targets_label": "projects",
"inbox.empty.admin_nudge.title": "No approval policies configured yet?",
"inbox.empty.admin_nudge.body": "Set which lifecycle events require 4-eye review.",
"inbox.empty.admin_nudge.cta": "Configure approval policies",
"deadlines.form.approval_hint": "4-eye review required",
"appointments.form.approval_hint": "4-eye review required",
"admin.email_templates.title": "Email Templates — Paliad",
"admin.email_templates.heading": "Email Templates",
"admin.email_templates.subtitle": "Customise templates for invitations, reminders, and the shared layout wrapper.",

View File

@@ -92,11 +92,45 @@ async function refresh() {
: "approvals.empty.mine"
);
empty.style.display = "";
void maybeShowAdminNudge();
return;
}
hideAdminNudge();
for (const row of rows) list.appendChild(renderRow(row));
}
// t-paliad-154 — show the admin-only "configure policies" nudge when:
// - the current user is global_admin
// - the inbox is empty
// - no approval_policies row exists firm-wide (matrix is dormant)
//
// All three checks are AND-ed. Anonymous users + non-admins + active-policy
// admins all skip the nudge.
async function maybeShowAdminNudge(): Promise<void> {
const nudge = document.getElementById("inbox-admin-nudge");
if (!nudge) return;
try {
const meR = await fetch("/api/me", { credentials: "include" });
if (!meR.ok) return;
const me = (await meR.json()) as { global_role?: string };
if (me.global_role !== "global_admin") return;
const seedR = await fetch("/api/admin/approval-policies/seeded", { credentials: "include" });
if (!seedR.ok) return;
const data = (await seedR.json()) as { any: boolean };
if (data.any) return;
nudge.style.display = "";
} catch (_e) {
// Network failure → keep nudge hidden.
}
}
function hideAdminNudge(): void {
const nudge = document.getElementById("inbox-admin-nudge");
if (nudge) nudge.style.display = "none";
}
function renderRow(row: ApprovalRequestView): HTMLLIElement {
const li = document.createElement("li");
li.className = "inbox-row";

View File

@@ -0,0 +1,109 @@
import { describe, expect, test } from "bun:test";
import {
formatDate,
formatRelative,
formatRowTime,
formatTime,
isDateOnly,
parseDateOnly,
} from "./format";
import type { ViewRow } from "./types";
// Regression tests for t-paliad-153: deadline due_date renders as 02:00 in
// CEST. The substrate marshals deadline.due_date as "YYYY-MM-DDT00:00:00Z";
// the formatters must treat that as a calendar day with no time component.
const stubRow = (overrides: Partial<ViewRow> = {}): ViewRow => ({
kind: "deadline",
id: "00000000-0000-0000-0000-000000000000",
title: "Call me",
event_date: "2026-05-08T00:00:00Z",
detail: {},
...overrides,
});
describe("isDateOnly / parseDateOnly", () => {
test("recognises YYYY-MM-DD", () => {
expect(isDateOnly("2026-05-08")).toBe(true);
expect(parseDateOnly("2026-05-08")).not.toBeNull();
});
test("recognises the substrate's UTC-midnight serialisation", () => {
expect(isDateOnly("2026-05-08T00:00:00Z")).toBe(true);
expect(parseDateOnly("2026-05-08T00:00:00Z")).not.toBeNull();
});
test("rejects timestamps with a real time component", () => {
expect(isDateOnly("2026-05-08T14:30:00Z")).toBe(false);
expect(parseDateOnly("2026-05-08T14:30:00Z")).toBeNull();
});
test("rejects garbage", () => {
expect(isDateOnly("not-a-date")).toBe(false);
expect(parseDateOnly("not-a-date")).toBeNull();
});
});
describe("formatTime", () => {
test("returns empty string for date-only inputs (no phantom 02:00)", () => {
expect(formatTime("2026-05-08T00:00:00Z")).toBe("");
expect(formatTime("2026-05-08")).toBe("");
});
test("renders HH:MM for real timestamps", () => {
expect(formatTime("2026-05-08T14:30:00Z")).toMatch(/\d{2}:\d{2}/);
});
});
describe("formatDate", () => {
test("date-only input formats the source day in any timezone", () => {
// Whatever locale getLang() resolves to, the day portion must be 08.
const out = formatDate("2026-05-08T00:00:00Z");
expect(out).toContain("08");
expect(out).toContain("2026");
});
});
describe("formatRowTime", () => {
test("deadline + dateAvailable=true returns empty (heading shows the day)", () => {
expect(formatRowTime(stubRow(), { dateAvailable: true })).toBe("");
});
test("deadline + dateAvailable=false falls back to the date", () => {
expect(formatRowTime(stubRow(), { dateAvailable: false })).toContain("2026");
});
test("appointment with a real start_at still renders HH:MM", () => {
const row = stubRow({ kind: "appointment", event_date: "2026-05-08T14:30:00Z" });
expect(formatRowTime(row, { dateAvailable: true })).toMatch(/\d{2}:\d{2}/);
});
test("appointment with date-only event_date does not leak phantom time", () => {
const row = stubRow({ kind: "appointment", event_date: "2026-05-08T00:00:00Z" });
// Belt-and-braces: even if a stray date-only value shows up under a
// non-deadline kind, the helper detects it and returns "" instead of
// "02:00" / "01:00" / etc.
expect(formatRowTime(row, { dateAvailable: true })).toBe("");
});
});
describe("formatRelative", () => {
test("deadline kind reduces to day precision", () => {
const today = new Date();
const todayISO = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}T00:00:00Z`;
const out = formatRelative(todayISO, "deadline");
expect(out.toLowerCase()).toMatch(/heute|today/);
});
test("date-only iso reduces to day precision even without an explicit kind", () => {
const today = new Date();
const todayISO = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
const out = formatRelative(todayISO);
expect(out.toLowerCase()).toMatch(/heute|today/);
});
test("real timestamp keeps moment-precision relative", () => {
const inAnHour = new Date(Date.now() + 60 * 60 * 1000).toISOString();
expect(formatRelative(inAnHour, "appointment")).toMatch(/\d/);
});
});

View File

@@ -0,0 +1,122 @@
import { getLang } from "../i18n";
import type { ViewRow } from "./types";
// Shared date/time formatters for the views shapes (list / cards / calendar).
//
// The substrate marshals deadline.due_date as time.Date(...,0,0,0,0,UTC), so
// the JSON arrives as "YYYY-MM-DDT00:00:00Z" — UTC midnight, no real time
// component. Feeding that into new Date() + toLocaleTimeString() in a
// non-UTC browser produces "02:00" (CEST), "01:00" (CET), "20:00" the day
// before (EST), and so on — a phantom hour that the source data never had.
//
// The fix is to recognise the date-only shape and either render the date
// (formatted in UTC so the day matches the source day everywhere) or render
// nothing in the time slot. The kind-aware helpers below thread that
// distinction through the shapes; see t-paliad-153.
const DATE_ONLY_RE = /^(\d{4})-(\d{2})-(\d{2})(?:T00:00:00(?:\.0+)?Z)?$/;
export function isDateOnly(iso: string): boolean {
return typeof iso === "string" && DATE_ONLY_RE.test(iso);
}
export function parseDateOnly(iso: string): Date | null {
if (typeof iso !== "string") return null;
const m = iso.match(DATE_ONLY_RE);
if (!m) return null;
const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
return isNaN(d.getTime()) ? null : d;
}
function locale(): string {
return getLang() === "de" ? "de-DE" : "en-GB";
}
export function formatDate(iso: string): string {
const dateOnly = parseDateOnly(iso);
if (dateOnly) {
return dateOnly.toLocaleDateString(locale(), {
day: "2-digit", month: "2-digit", year: "numeric", timeZone: "UTC",
});
}
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleDateString(locale(), {
day: "2-digit", month: "2-digit", year: "numeric",
});
}
export function formatLongDate(iso: string): string {
const dateOnly = parseDateOnly(iso);
if (dateOnly) {
return dateOnly.toLocaleDateString(locale(), {
weekday: "long", year: "numeric", month: "long", day: "numeric", timeZone: "UTC",
});
}
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleDateString(locale(), {
weekday: "long", year: "numeric", month: "long", day: "numeric",
});
}
// formatTime returns "" for date-only inputs — they have no real time and
// rendering them as HH:MM leaks the local UTC offset.
export function formatTime(iso: string): string {
if (isDateOnly(iso)) return "";
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleTimeString(locale(), { hour: "2-digit", minute: "2-digit" });
}
// formatRowTime: the time-slot helper used by shape-cards. When the
// surrounding shape already shows the date (e.g. day-grouped headings),
// deadlines render nothing — the date is implicit. Otherwise the deadline
// row falls back to its date so the user still knows when it's due.
export function formatRowTime(row: ViewRow, opts: { dateAvailable: boolean }): string {
if (row.kind === "deadline" || isDateOnly(row.event_date)) {
return opts.dateAvailable ? "" : formatDate(row.event_date);
}
return formatTime(row.event_date);
}
// formatRelative: deadlines reduce to day precision so a Frist due
// "tomorrow" never shows up as "in 2h" because of the UTC offset.
export function formatRelative(iso: string, kind?: ViewRow["kind"]): string {
if (kind === "deadline" || isDateOnly(iso)) return formatDayRelative(iso);
return formatMomentRelative(iso);
}
function formatDayRelative(iso: string): string {
const due = parseDateOnly(iso);
if (!due) return formatMomentRelative(iso);
const today = new Date();
const todayUTC = Date.UTC(today.getFullYear(), today.getMonth(), today.getDate());
const diffDays = Math.round((due.getTime() - todayUTC) / 86400000);
const lang = getLang();
if (diffDays < 0) {
const n = Math.abs(diffDays);
return lang === "de"
? (n === 1 ? "vor 1 Tag" : `vor ${n} Tagen`)
: (n === 1 ? "1 day ago" : `${n} days ago`);
}
if (diffDays === 0) return lang === "de" ? "heute" : "today";
if (diffDays === 1) return lang === "de" ? "morgen" : "tomorrow";
return lang === "de" ? `in ${diffDays} Tagen` : `in ${diffDays} days`;
}
function formatMomentRelative(iso: string): string {
const t0 = Date.parse(iso);
if (isNaN(t0)) return iso;
const diffMs = t0 - Date.now();
const past = diffMs < 0;
const sec = Math.abs(Math.floor(diffMs / 1000));
const lang = getLang();
if (sec < 60) return past ? (lang === "de" ? `vor ${sec}s` : `${sec}s ago`) : (lang === "de" ? `in ${sec}s` : `in ${sec}s`);
const min = Math.floor(sec / 60);
if (min < 60) return past ? (lang === "de" ? `vor ${min}m` : `${min}m ago`) : (lang === "de" ? `in ${min}m` : `in ${min}m`);
const hr = Math.floor(min / 60);
if (hr < 24) return past ? (lang === "de" ? `vor ${hr}h` : `${hr}h ago`) : (lang === "de" ? `in ${hr}h` : `in ${hr}h`);
const day = Math.floor(hr / 24);
return past ? (lang === "de" ? `vor ${day}d` : `${day}d ago`) : (lang === "de" ? `in ${day}d` : `in ${day}d`);
}

View File

@@ -1,5 +1,6 @@
import { t, type I18nKey, getLang } from "../i18n";
import type { RenderSpec, ViewRow } from "./types";
import { formatLongDate, formatRowTime, parseDateOnly } from "./format";
// shape-cards: day-grouped chronological cards. Same layout style as the
// existing /agenda timeline; works for any source mix.
@@ -11,13 +12,13 @@ export function renderCardsShape(host: HTMLElement, rows: ViewRow[], render: Ren
const sort = cfg.sort ?? "date_asc";
const sorted = [...rows].sort((a, b) => {
const aT = Date.parse(a.event_date);
const bT = Date.parse(b.event_date);
const aT = sortKey(a.event_date);
const bT = sortKey(b.event_date);
return sort === "date_asc" ? aT - bT : bT - aT;
});
if (groupBy === "none") {
host.appendChild(renderCardList(sorted));
host.appendChild(renderCardList(sorted, "none"));
return;
}
@@ -29,14 +30,17 @@ export function renderCardsShape(host: HTMLElement, rows: ViewRow[], render: Ren
heading.className = "views-cards-day-heading";
heading.textContent = key;
section.appendChild(heading);
section.appendChild(renderCardList(items));
section.appendChild(renderCardList(items, groupBy));
host.appendChild(section);
}
}
function renderCardList(rows: ViewRow[]): HTMLElement {
function renderCardList(rows: ViewRow[], groupBy: "day" | "week" | "none"): HTMLElement {
const ul = document.createElement("ul");
ul.className = "views-cards-list";
// The day-grouped heading already shows the date — only that mode lets the
// per-row time slot stay blank for date-only sources.
const dateAvailable = groupBy === "day";
for (const row of rows) {
const li = document.createElement("li");
li.className = `views-card views-card--${row.kind}`;
@@ -55,9 +59,12 @@ function renderCardList(rows: ViewRow[]): HTMLElement {
const meta = document.createElement("div");
meta.className = "views-card-meta";
const time = document.createElement("span");
time.textContent = formatTime(row.event_date);
meta.appendChild(time);
const timeText = formatRowTime(row, { dateAvailable });
if (timeText) {
const time = document.createElement("span");
time.textContent = timeText;
meta.appendChild(time);
}
if (row.project_title) {
const proj = document.createElement("span");
proj.className = "views-card-project";
@@ -95,11 +102,14 @@ function groupRows(rows: ViewRow[], groupBy: "day" | "week"): Array<[string, Vie
}
function bucketKey(iso: string, groupBy: "day" | "week"): string {
const d = new Date(iso);
// Date-only inputs (deadlines) are anchored to UTC midnight so the bucket
// matches the source day in every timezone — otherwise a UTC-X user would
// see deadlines slip into the previous day.
const dateOnly = parseDateOnly(iso);
const d = dateOnly ?? new Date(iso);
if (isNaN(d.getTime())) return iso;
const lang = getLang() === "de" ? "de-DE" : "en-GB";
if (groupBy === "week") {
// Round down to Monday, format as "KW NN, YYYY".
const monday = new Date(d);
const day = monday.getDay() || 7; // Sunday=0 → 7
monday.setDate(monday.getDate() - day + 1);
@@ -107,12 +117,12 @@ function bucketKey(iso: string, groupBy: "day" | "week"): string {
const weekNo = Math.ceil(((monday.getTime() - yearStart.getTime()) / 86400000 + yearStart.getDay() + 1) / 7);
return `KW ${weekNo}, ${monday.getFullYear()}`;
}
if (dateOnly) return formatLongDate(iso);
return d.toLocaleDateString(lang, { weekday: "long", year: "numeric", month: "long", day: "numeric" });
}
function formatTime(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const lang = getLang() === "de" ? "de-DE" : "en-GB";
return d.toLocaleTimeString(lang, { hour: "2-digit", minute: "2-digit" });
function sortKey(iso: string): number {
const dateOnly = parseDateOnly(iso);
if (dateOnly) return dateOnly.getTime();
return Date.parse(iso);
}

View File

@@ -1,5 +1,6 @@
import { t, type I18nKey, getLang } from "../i18n";
import { t, type I18nKey } from "../i18n";
import type { RenderSpec, ViewRow } from "./types";
import { formatDate, formatRelative, parseDateOnly } from "./format";
// shape-list: renders ViewRows as a table (density=comfortable) or a
// compact one-line stream (density=compact). The "activity feed" look
@@ -13,8 +14,8 @@ export function renderListShape(host: HTMLElement, rows: ViewRow[], render: Rend
const sort = list.sort ?? "date_asc";
const sorted = [...rows].sort((a, b) => {
const aT = Date.parse(a.event_date);
const bT = Date.parse(b.event_date);
const aT = sortKey(a.event_date);
const bT = sortKey(b.event_date);
return sort === "date_asc" ? aT - bT : bT - aT;
});
@@ -34,7 +35,7 @@ function renderCompact(rows: ViewRow[]): HTMLElement {
const time = document.createElement("span");
time.className = "views-list-time";
time.textContent = formatRelative(row.event_date);
time.textContent = formatRelative(row.event_date, row.kind);
li.appendChild(time);
const kindIcon = document.createElement("span");
@@ -122,7 +123,7 @@ function formatColumn(row: ViewRow, col: string): string {
case "date":
return formatDate(row.event_date);
case "time":
return formatRelative(row.event_date);
return formatRelative(row.event_date, row.kind);
case "title":
return row.title;
case "project":
@@ -156,26 +157,8 @@ function kindLabel(kind: string): string {
return t(("views.kind." + kind) as I18nKey);
}
function formatDate(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
year: "numeric", month: "2-digit", day: "2-digit",
});
}
function formatRelative(iso: string): string {
const t0 = Date.parse(iso);
if (isNaN(t0)) return iso;
const diffMs = t0 - Date.now();
const past = diffMs < 0;
const sec = Math.abs(Math.floor(diffMs / 1000));
const lang = getLang();
if (sec < 60) return past ? (lang === "de" ? `vor ${sec}s` : `${sec}s ago`) : (lang === "de" ? `in ${sec}s` : `in ${sec}s`);
const min = Math.floor(sec / 60);
if (min < 60) return past ? (lang === "de" ? `vor ${min}m` : `${min}m ago`) : (lang === "de" ? `in ${min}m` : `in ${min}m`);
const hr = Math.floor(min / 60);
if (hr < 24) return past ? (lang === "de" ? `vor ${hr}h` : `${hr}h ago`) : (lang === "de" ? `in ${hr}h` : `in ${hr}h`);
const day = Math.floor(hr / 24);
return past ? (lang === "de" ? `vor ${day}d` : `${day}d ago`) : (lang === "de" ? `in ${day}d` : `in ${day}d`);
function sortKey(iso: string): number {
const dateOnly = parseDateOnly(iso);
if (dateOnly) return dateOnly.getTime();
return Date.parse(iso);
}

View File

@@ -80,6 +80,16 @@ export function renderDeadlinesNew(): string {
<p className="form-msg" id="deadline-new-msg" />
{/* t-paliad-154 — form-time 4-eye hint. Hidden by default;
revealed by client TS when an effective policy applies
to the chosen project. */}
<div className="approval-hint" id="deadline-approval-hint" style="display:none">
<span className="approval-hint-icon" dangerouslySetInnerHTML={{
__html: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>'
}} />
<span id="deadline-approval-hint-text" />
</div>
<div className="form-actions">
<a href="/events?type=deadline" id="deadline-new-cancel" className="btn-cancel" data-i18n="deadlines.neu.cancel">Abbrechen</a>
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="deadlines.neu.submit">Frist anlegen</button>

View File

@@ -9,6 +9,46 @@
// `data-i18n*` attributes in TSX/TS sources.
export type I18nKey =
| "admin.approval_policies.bulk.cta"
| "admin.approval_policies.bulk.modal.applying"
| "admin.approval_policies.bulk.modal.body"
| "admin.approval_policies.bulk.modal.cancel"
| "admin.approval_policies.bulk.modal.confirm"
| "admin.approval_policies.bulk.modal.done"
| "admin.approval_policies.bulk.modal.targets_label"
| "admin.approval_policies.bulk.modal.title"
| "admin.approval_policies.bulk.modal.writes_label"
| "admin.approval_policies.bulk.no_descendants"
| "admin.approval_policies.cell.error_msg"
| "admin.approval_policies.cell.saved_msg"
| "admin.approval_policies.entity.appointment"
| "admin.approval_policies.entity.deadline"
| "admin.approval_policies.heading"
| "admin.approval_policies.lifecycle.complete"
| "admin.approval_policies.lifecycle.create"
| "admin.approval_policies.lifecycle.delete"
| "admin.approval_policies.lifecycle.update"
| "admin.approval_policies.loading"
| "admin.approval_policies.picker.label"
| "admin.approval_policies.picker.no_results"
| "admin.approval_policies.picker.placeholder"
| "admin.approval_policies.role.associate"
| "admin.approval_policies.role.no_rule"
| "admin.approval_policies.role.none"
| "admin.approval_policies.role.of_counsel"
| "admin.approval_policies.role.pa"
| "admin.approval_policies.role.partner"
| "admin.approval_policies.role.senior_pa"
| "admin.approval_policies.section.projects"
| "admin.approval_policies.section.projects.hint"
| "admin.approval_policies.section.units"
| "admin.approval_policies.section.units.hint"
| "admin.approval_policies.source.ancestor"
| "admin.approval_policies.source.project"
| "admin.approval_policies.source.unit_default"
| "admin.approval_policies.subtitle"
| "admin.approval_policies.title"
| "admin.approval_policies.units.empty"
| "admin.audit.col.actor"
| "admin.audit.col.description"
| "admin.audit.col.event"
@@ -59,6 +99,8 @@ export type I18nKey =
| "admin.broadcasts.loading"
| "admin.broadcasts.subtitle"
| "admin.broadcasts.title"
| "admin.card.approval_policies.desc"
| "admin.card.approval_policies.title"
| "admin.card.audit.desc"
| "admin.card.audit.title"
| "admin.card.broadcasts.desc"
@@ -335,6 +377,7 @@ export type I18nKey =
| "appointments.filter.to"
| "appointments.filter.type"
| "appointments.filter.type.all"
| "appointments.form.approval_hint"
| "appointments.kalender.empty"
| "appointments.kalender.heading"
| "appointments.kalender.list"
@@ -785,6 +828,7 @@ export type I18nKey =
| "deadlines.flag.inf_amend"
| "deadlines.flag.rev_amend"
| "deadlines.flag.rev_cci"
| "deadlines.form.approval_hint"
| "deadlines.heading"
| "deadlines.kalender.empty"
| "deadlines.kalender.heading"
@@ -1165,6 +1209,9 @@ export type I18nKey =
| "glossar.suggest.success"
| "glossar.suggest.title"
| "glossar.title"
| "inbox.empty.admin_nudge.body"
| "inbox.empty.admin_nudge.cta"
| "inbox.empty.admin_nudge.title"
| "index.checklisten.desc"
| "index.checklisten.title"
| "index.cost.desc"

View File

@@ -49,6 +49,21 @@ export function renderInbox(): string {
<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" />
{/* t-paliad-154 — admin-only nudge surfaced when:
- the user is global_admin
- the inbox is empty (no pending / mine)
- no approval_policies row exists firm-wide
Hidden in all other cases. Wires via /api/admin/approval-policies/seeded. */}
<div className="inbox-admin-nudge" id="inbox-admin-nudge" style="display:none">
<h3 data-i18n="inbox.empty.admin_nudge.title">Noch keine Genehmigungspflichten konfiguriert?</h3>
<p data-i18n="inbox.empty.admin_nudge.body">
Lege fest, welche Lifecycle-Events 4-Augen-Pr&uuml;fung erfordern.
</p>
<a href="/admin/approval-policies" className="btn-primary btn-cta-lime" data-i18n="inbox.empty.admin_nudge.cta">
Genehmigungspflichten konfigurieren
</a>
</div>
</div>
</section>
<Footer />

View File

@@ -11823,3 +11823,336 @@ dialog.quick-add-sheet::backdrop {
align-items: stretch;
}
}
/* ============================================================================
* t-paliad-154 — admin /admin/approval-policies page.
* ========================================================================== */
.ap-units-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 2rem;
}
.ap-unit-block {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
}
.ap-unit-summary {
cursor: pointer;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
list-style: none;
user-select: none;
}
.ap-unit-summary::-webkit-details-marker {
display: none;
}
.ap-unit-summary::before {
content: "▸";
color: var(--color-text-muted);
transition: transform 120ms ease;
}
.ap-unit-block[open] > .ap-unit-summary::before {
transform: rotate(90deg);
}
.ap-unit-block[open] > .ap-unit-summary {
border-bottom: 1px solid var(--color-border);
}
.ap-unit-name {
flex: 1;
font-weight: 600;
color: var(--color-text);
}
.ap-unit-body {
padding: 1rem;
background: var(--color-bg-subtle);
}
/* Project picker. */
.ap-project-picker {
margin-bottom: 1rem;
}
.ap-project-search {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border-strong);
border-radius: 6px;
font-size: 1rem;
background: var(--color-input-bg);
color: var(--color-text);
}
.ap-project-results {
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 240px;
overflow-y: auto;
}
.ap-project-result {
text-align: left;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.4rem 0.6rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.95rem;
color: var(--color-text);
}
.ap-project-result:hover {
background: var(--color-bg-lime-tint);
}
.ap-project-result-title {
flex: 1;
}
.ap-project-result-ref {
color: var(--color-text-muted);
font-size: 0.85rem;
}
.ap-project-matrix {
margin-top: 1rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 1rem;
}
.ap-project-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 0.75rem;
}
.ap-project-header h3 {
margin: 0;
font-size: 1.1rem;
color: var(--color-text);
}
.ap-matrix-host {
margin-top: 0.5rem;
}
/* Matrix table — desktop. */
.ap-matrix {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.ap-matrix thead th {
background: var(--color-bg-subtle);
color: var(--color-text-muted);
font-weight: 500;
text-align: left;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid var(--color-border);
}
.ap-matrix-rowhead {
font-weight: 600;
color: var(--color-text);
padding: 0.5rem 0.6rem;
width: 8rem;
background: var(--color-bg-subtle);
border-right: 1px solid var(--color-border);
}
.ap-matrix-cell {
padding: 0.4rem;
vertical-align: top;
border-bottom: 1px solid var(--color-border);
}
.ap-cell-wrap {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.ap-cell-select {
width: 100%;
padding: 0.3rem 0.4rem;
border: 1px solid var(--color-border-strong);
border-radius: 5px;
background: var(--color-input-bg);
color: var(--color-text);
font-size: 0.85rem;
}
.ap-source-chip {
display: inline-block;
font-size: 0.7rem;
padding: 0.1rem 0.4rem;
border-radius: 3px;
color: var(--color-text-muted);
background: var(--color-surface-muted);
border: 1px solid var(--color-border);
}
.ap-source-chip.ap-source-project {
background: var(--color-bg-lime-tint);
color: var(--color-accent-dark);
border-color: var(--color-accent);
}
.ap-source-chip.ap-source-ancestor {
background: var(--status-blue-soft-bg);
color: var(--status-blue-soft-fg);
border-color: var(--status-blue-bg);
}
.ap-source-chip.ap-source-unit_default {
background: var(--status-neutral-bg);
color: var(--status-neutral-fg);
border-color: var(--color-border);
}
/* Mobile stacked sections — hidden on desktop, shown < 700px. */
.ap-matrix-stacked {
display: none;
}
@media (max-width: 700px) {
.ap-matrix {
display: none;
}
.ap-matrix-stacked {
display: block;
}
.ap-matrix-section {
margin-bottom: 1.25rem;
}
.ap-matrix-section h4 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
color: var(--color-text);
}
.ap-matrix-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid var(--color-border);
gap: 0.5rem;
}
.ap-matrix-row-label {
flex: 1;
font-size: 0.9rem;
color: var(--color-text);
}
.ap-matrix-row .ap-cell-select {
width: 50%;
}
.ap-matrix-row .ap-cell-wrap {
width: 50%;
}
}
/* Bulk-apply modal — list of affected descendants. */
.ap-bulk-target-list {
list-style: none;
margin: 0.5rem 0;
padding: 0;
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--color-border);
border-radius: 5px;
background: var(--color-bg-subtle);
}
.ap-bulk-target-list li {
padding: 0.3rem 0.6rem;
border-bottom: 1px solid var(--color-border);
font-size: 0.85rem;
color: var(--color-text);
}
.ap-bulk-target-list li:last-child {
border-bottom: none;
}
.ap-bulk-target-ref {
color: var(--color-text-muted);
font-size: 0.8rem;
margin-left: 0.5rem;
}
.ap-loading {
color: var(--color-text-muted);
padding: 0.75rem 1rem;
font-style: italic;
}
/* Form-time 4-eye hint above Speichern on /projects/{id}/deadlines/new + appointments/new. */
.approval-hint {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
margin: 0.75rem 0;
background: var(--status-amber-bg);
color: var(--status-amber-fg);
border: 1px solid var(--status-amber-border);
border-radius: 6px;
font-size: 0.85rem;
}
.approval-hint-icon {
flex-shrink: 0;
width: 18px;
height: 18px;
color: var(--status-amber-fg);
}
.approval-hint-icon svg {
width: 100%;
height: 100%;
}
/* Inbox empty-state admin nudge. */
.inbox-admin-nudge {
margin-top: 1.5rem;
padding: 1rem 1.25rem;
background: var(--color-surface);
border: 1px dashed var(--color-border-strong);
border-radius: 8px;
text-align: center;
}
.inbox-admin-nudge h3 {
margin: 0 0 0.4rem 0;
font-size: 1.05rem;
color: var(--color-text);
}
.inbox-admin-nudge p {
margin: 0 0 0.75rem 0;
color: var(--color-text-muted);
font-size: 0.9rem;
}

View File

@@ -0,0 +1,59 @@
-- t-paliad-154 down migration. Reverses 062_approval_policy_unit_defaults.up.sql.
--
-- Order is the reverse of up:
-- 0. Drop policy_audit_log table.
-- 1. Drop seeded unit-default rows (anything where partner_unit_id IS NOT NULL).
-- 2. Drop the resolver function.
-- 3. Restore required_role CHECK without 'none'.
-- 4. Drop the two partial unique indexes + restore the original UNIQUE composite.
-- 5. Drop XOR check + partner_unit_id column.
-- 6. Restore project_id NOT NULL.
-- 0. Drop the audit table.
DROP TABLE IF EXISTS paliad.policy_audit_log;
--
-- Best-effort reversibility: any project-specific row with required_role='none'
-- will fail the CHECK restoration. We coerce those to 'associate' before
-- restoring the CHECK so the migration can roll back without data loss.
-- 1. Drop unit-default rows. Keep project rows intact (they pre-date 062).
DELETE FROM paliad.approval_policies WHERE partner_unit_id IS NOT NULL;
-- 2. Drop resolver.
DROP FUNCTION IF EXISTS paliad.approval_policy_effective(uuid, text, text);
-- 3. Coerce 'none' rows to 'associate' so the restored CHECK passes.
UPDATE paliad.approval_policies
SET required_role = 'associate'
WHERE required_role = 'none';
ALTER TABLE paliad.approval_policies
DROP CONSTRAINT IF EXISTS approval_policies_required_role_check;
ALTER TABLE paliad.approval_policies
ADD CONSTRAINT approval_policies_required_role_check
CHECK (required_role IN (
'partner', 'of_counsel', 'associate', 'senior_pa', 'pa'
));
-- 4. Drop partial unique indexes; restore composite UNIQUE on project rows
-- (down migration leaves the column NOT NULL so the unique-on-project_id
-- composite is sound again).
DROP INDEX IF EXISTS paliad.approval_policies_project_unique;
DROP INDEX IF EXISTS paliad.approval_policies_unit_unique;
DROP INDEX IF EXISTS paliad.approval_policies_unit_idx;
-- 5. Drop XOR check + partner_unit_id column.
ALTER TABLE paliad.approval_policies
DROP CONSTRAINT IF EXISTS approval_policies_scope_xor;
ALTER TABLE paliad.approval_policies
DROP COLUMN IF EXISTS partner_unit_id;
-- 6. Restore NOT NULL on project_id (no rows should be NULL by now since
-- step 1 deleted every unit-default row).
ALTER TABLE paliad.approval_policies
ALTER COLUMN project_id SET NOT NULL;
-- Restore composite UNIQUE constraint name to match migration 054.
ALTER TABLE paliad.approval_policies
ADD CONSTRAINT approval_policies_project_id_entity_type_lifecycle_event_key
UNIQUE (project_id, entity_type, lifecycle_event);

View File

@@ -0,0 +1,285 @@
-- t-paliad-154: approval-policy authoring UI substrate.
--
-- Design: docs/design-approval-policy-ui-2026-05-07.md (hilbert, m-locked
-- 2026-05-07). Surfaces the dormant t-138 4-eye system by adding:
--
-- 1. partner_unit_id column on paliad.approval_policies — XOR with project_id
-- so a row applies to either one project or one partner unit (firm-wide
-- default for projects attached to that unit). Existing rows
-- (project_id IS NOT NULL) keep their meaning unchanged.
-- 2. 'none' sentinel value for required_role — explicit "no approval needed"
-- override at project-row level. Suppresses inherited defaults.
-- 3. paliad.approval_policy_effective() resolver — most-restrictive across
-- project row | ancestor rows | unit defaults. Project-specific row
-- wins outright (any value including 'none'). Otherwise MAX(level) across
-- ancestor + unit_default candidates.
-- 4. Conservative seed defaults for every existing partner_unit:
-- deadline+appointment × create/update/delete=associate, complete=none.
-- 5. paliad.policy_audit_log — fifth audit source for /admin/audit-log only.
-- Q8 of the locked design: policy changes audit on /admin/audit-log,
-- NOT on per-project /verlauf. Distinct table keeps the verlauf union
-- clean while letting AuditService union the new source.
--
-- Idempotent on re-run (ON CONFLICT DO NOTHING on the seed). Safe on a DB
-- with 0 partner_units (the seed simply inserts zero rows).
--
-- Sections:
-- 1. Make project_id nullable + ADD partner_unit_id + XOR check.
-- 2. Replace UNIQUE composite with two partial unique indexes.
-- 3. Extend required_role CHECK with 'none' sentinel.
-- 4. CREATE FUNCTION paliad.approval_policy_effective(uuid, text, text).
-- 5. Seed conservative defaults for every existing partner_unit.
-- 6. CREATE TABLE paliad.policy_audit_log (admin-only audit source).
-- ============================================================================
-- 1. project_id becomes nullable; add partner_unit_id; XOR check.
-- ============================================================================
ALTER TABLE paliad.approval_policies
ALTER COLUMN project_id DROP NOT NULL;
ALTER TABLE paliad.approval_policies
ADD COLUMN partner_unit_id uuid
REFERENCES paliad.partner_units(id) ON DELETE CASCADE;
-- Exactly one of (project_id, partner_unit_id) must be set. NEVER both NULL,
-- NEVER both set. Defence against orphaned rows.
ALTER TABLE paliad.approval_policies
ADD CONSTRAINT approval_policies_scope_xor CHECK (
(project_id IS NOT NULL AND partner_unit_id IS NULL) OR
(project_id IS NULL AND partner_unit_id IS NOT NULL)
);
-- ============================================================================
-- 2. Replace UNIQUE (project_id, ...) with two partial unique indexes.
--
-- The original UNIQUE composite assumed project_id NOT NULL. Now that
-- project_id is nullable, we split: one partial unique index per scope
-- (project rows | unit rows). Cells stay 1:1 within their scope.
-- ============================================================================
ALTER TABLE paliad.approval_policies
DROP CONSTRAINT IF EXISTS approval_policies_project_id_entity_type_lifecycle_event_key;
CREATE UNIQUE INDEX approval_policies_project_unique
ON paliad.approval_policies (project_id, entity_type, lifecycle_event)
WHERE project_id IS NOT NULL;
CREATE UNIQUE INDEX approval_policies_unit_unique
ON paliad.approval_policies (partner_unit_id, entity_type, lifecycle_event)
WHERE partner_unit_id IS NOT NULL;
CREATE INDEX approval_policies_unit_idx
ON paliad.approval_policies (partner_unit_id)
WHERE partner_unit_id IS NOT NULL;
-- ============================================================================
-- 3. 'none' sentinel value for required_role.
--
-- Project row with required_role='none' suppresses inherited defaults
-- explicitly (caller-side: LookupPolicy returns nil when the resolver
-- yields 'none'). Unit-default rows can also carry 'none' but are
-- structurally invisible to the MAX(level) computation since
-- approval_role_level('none')=0 (loses to any non-none).
--
-- approval_role_level('none') already returns 0 via the existing ELSE
-- branch in migration 059 §7. No function update needed.
-- ============================================================================
ALTER TABLE paliad.approval_policies
DROP CONSTRAINT IF EXISTS approval_policies_required_role_check;
ALTER TABLE paliad.approval_policies
ADD CONSTRAINT approval_policies_required_role_check
CHECK (required_role IN (
'partner', 'of_counsel', 'associate', 'senior_pa', 'pa', 'none'
));
-- ============================================================================
-- 4. paliad.approval_policy_effective(project, entity_type, lifecycle).
--
-- Returns at most one row, or zero rows when no policy applies.
--
-- Resolution order:
-- Step 1: if a project-specific row exists for (project, entity, lifecycle),
-- return it outright. Any value (including 'none') wins.
-- Step 2: else MAX(approval_role_level) across:
-- - ancestor project rows on this project's ltree path
-- - unit-default rows for partner units attached to this project
-- Tied levels: 'ancestor' beats 'unit_default' alphabetically for
-- stable attribution.
-- Step 3: else (no candidates) — return zero rows. Caller treats as
-- "no policy applies".
--
-- Returned columns:
-- required_role text — one of partner|of_counsel|associate|senior_pa|pa|none
-- source text — 'project' | 'ancestor' | 'unit_default'
-- source_id uuid — project_id for project/ancestor, partner_unit_id for unit_default
-- ============================================================================
CREATE OR REPLACE FUNCTION paliad.approval_policy_effective(
p_project_id uuid,
p_entity_type text,
p_lifecycle text
) RETURNS TABLE (
required_role text,
source text,
source_id uuid
)
LANGUAGE plpgsql STABLE AS $$
BEGIN
-- Step 1: project-specific row wins outright.
RETURN QUERY
SELECT ap.required_role, 'project'::text AS source, ap.project_id AS source_id
FROM paliad.approval_policies ap
WHERE ap.project_id = p_project_id
AND ap.entity_type = p_entity_type
AND ap.lifecycle_event = p_lifecycle;
IF FOUND THEN
RETURN;
END IF;
-- Step 2: MAX(level) across ancestor rows + unit defaults attached to project.
RETURN QUERY
WITH path AS (
SELECT string_to_array(p.path, '.')::uuid[] AS ids
FROM paliad.projects p WHERE p.id = p_project_id
),
ancestor_rows AS (
SELECT ap.required_role,
'ancestor'::text AS src,
ap.project_id AS sid,
paliad.approval_role_level(ap.required_role) AS lvl
FROM paliad.approval_policies ap, path
WHERE ap.project_id = ANY(path.ids)
AND ap.project_id <> p_project_id
AND ap.entity_type = p_entity_type
AND ap.lifecycle_event = p_lifecycle
),
unit_rows AS (
SELECT ap.required_role,
'unit_default'::text AS src,
ap.partner_unit_id AS sid,
paliad.approval_role_level(ap.required_role) AS lvl
FROM paliad.approval_policies ap
JOIN paliad.project_partner_units ppu
ON ppu.partner_unit_id = ap.partner_unit_id
WHERE ppu.project_id = p_project_id
AND ap.entity_type = p_entity_type
AND ap.lifecycle_event = p_lifecycle
)
SELECT a.required_role, a.src, a.sid
FROM (
SELECT * FROM ancestor_rows
UNION ALL
SELECT * FROM unit_rows
) AS a
ORDER BY a.lvl DESC, a.src ASC
LIMIT 1;
END;
$$;
COMMENT ON FUNCTION paliad.approval_policy_effective(uuid, text, text) IS
'Effective approval policy resolver (t-paliad-154). '
'Project-specific row wins outright; else MAX(level) across ancestor '
'rows on the project path and unit-default rows for attached partner '
'units; else no row. Caller treats 0 rows or required_role=''none'' as '
'"no policy applies".';
-- ============================================================================
-- 5. Seed conservative defaults for every existing partner_unit.
--
-- 8 rows per unit: deadline+appointment × create/update/delete=associate,
-- complete=none. Marking-as-done is low-risk; planning operations need 4-eye.
--
-- ON CONFLICT DO NOTHING on (partner_unit_id, entity_type, lifecycle_event)
-- via the partial unique index so re-running the migration (or topping up
-- after a partner_unit is added) is a no-op for existing rows.
--
-- Safe on a DB with 0 partner_units — the SELECT returns no rows.
-- ============================================================================
INSERT INTO paliad.approval_policies (
project_id, partner_unit_id, entity_type, lifecycle_event, required_role
)
SELECT NULL, pu.id, t.entity_type, t.lifecycle_event, t.required_role
FROM paliad.partner_units pu
CROSS JOIN (
VALUES
('deadline', 'create', 'associate'),
('deadline', 'update', 'associate'),
('deadline', 'delete', 'associate'),
('deadline', 'complete', 'none'),
('appointment', 'create', 'associate'),
('appointment', 'update', 'associate'),
('appointment', 'delete', 'associate'),
('appointment', 'complete', 'none')
) AS t(entity_type, lifecycle_event, required_role)
ON CONFLICT (partner_unit_id, entity_type, lifecycle_event)
WHERE partner_unit_id IS NOT NULL
DO NOTHING;
-- ============================================================================
-- 6. paliad.policy_audit_log — admin-only audit source.
--
-- Q8 of the design: emit audit on /admin/audit-log, NOT on per-project
-- /verlauf. Distinct table from project_events so AuditService can union it
-- as a fifth source without polluting the verlauf SELECT.
--
-- One row per policy mutation (set or cleared, project-scoped or unit-scoped).
-- The actor is always a global_admin (the route gate enforces this).
--
-- scope_type / scope_id: ('project', project_id) or ('unit', partner_unit_id).
-- The FK columns (project_id, partner_unit_id) are nullable so a deleted
-- target row doesn't cascade-purge the audit history.
-- ============================================================================
CREATE TABLE paliad.policy_audit_log (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
actor_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE RESTRICT,
event_type text NOT NULL CHECK (event_type IN (
'approval_policy_set', 'approval_policy_cleared'
)),
scope_type text NOT NULL CHECK (scope_type IN ('project', 'unit')),
project_id uuid REFERENCES paliad.projects(id) ON DELETE SET NULL,
partner_unit_id uuid REFERENCES paliad.partner_units(id) ON DELETE SET NULL,
-- Snapshot of the target's name at event time so a later cascade-set-null
-- doesn't lose the human label.
scope_name text NOT NULL,
entity_type text NOT NULL CHECK (entity_type IN ('deadline', 'appointment')),
lifecycle_event text NOT NULL CHECK (lifecycle_event IN ('create', 'update', 'complete', 'delete')),
old_required_role text,
new_required_role text,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT policy_audit_log_scope_xor CHECK (
(scope_type = 'project' AND project_id IS NOT NULL AND partner_unit_id IS NULL) OR
(scope_type = 'unit' AND partner_unit_id IS NOT NULL AND project_id IS NULL) OR
(scope_type = 'project' AND project_id IS NULL AND partner_unit_id IS NULL) OR -- post-cascade
(scope_type = 'unit' AND partner_unit_id IS NULL AND project_id IS NULL) -- post-cascade
)
);
CREATE INDEX policy_audit_log_time_idx ON paliad.policy_audit_log (created_at DESC);
CREATE INDEX policy_audit_log_actor_idx ON paliad.policy_audit_log (actor_id, created_at DESC);
ALTER TABLE paliad.policy_audit_log ENABLE ROW LEVEL SECURITY;
-- Read access: any authenticated user (the audit-log page itself is
-- admin-gated at the route layer; this is defence-in-depth).
CREATE POLICY policy_audit_log_select ON paliad.policy_audit_log
FOR SELECT USING (auth.uid() IS NOT NULL);
-- Write access: only global_admin (defence-in-depth; service layer also
-- gates).
CREATE POLICY policy_audit_log_write ON paliad.policy_audit_log
FOR INSERT WITH CHECK (
EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
);
COMMENT ON TABLE paliad.policy_audit_log IS
'Audit trail for paliad.approval_policies CRUD (t-paliad-154). '
'Surfaces on /admin/audit-log only — not on per-project /verlauf, per '
'design Q8 lock-in. Unioned by services.AuditService alongside '
'project_events / partner_unit_events / caldav_sync_log / reminder_log.';

View File

@@ -49,7 +49,7 @@ func handleListApprovalPolicies(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
return
}
rows, err := dbSvc.approval.ListPolicies(r.Context(), projectID)
rows, err := dbSvc.approval.ListProjectPolicies(r.Context(), projectID)
if err != nil {
writeServiceError(w, err)
return
@@ -88,7 +88,7 @@ func handlePutApprovalPolicy(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
policy, err := dbSvc.approval.UpsertPolicy(r.Context(), projectID, uid, entityType, lifecycle, body.RequiredRole)
policy, err := dbSvc.approval.UpsertProjectPolicy(r.Context(), uid, projectID, entityType, lifecycle, body.RequiredRole)
if err != nil {
writeServiceError(w, err)
return
@@ -104,7 +104,8 @@ func handleDeleteApprovalPolicy(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if _, ok := requireUser(w, r); !ok {
uid, ok := requireUser(w, r)
if !ok {
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
@@ -114,7 +115,7 @@ func handleDeleteApprovalPolicy(w http.ResponseWriter, r *http.Request) {
}
entityType := r.PathValue("entity_type")
lifecycle := r.PathValue("lifecycle")
if err := dbSvc.approval.DeletePolicy(r.Context(), projectID, entityType, lifecycle); err != nil {
if err := dbSvc.approval.DeleteProjectPolicy(r.Context(), uid, projectID, entityType, lifecycle); err != nil {
writeServiceError(w, err)
return
}
@@ -278,6 +279,218 @@ func handleInboxPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/inbox.html")
}
// ============================================================================
// t-paliad-154 — admin approval-policy authoring page + APIs.
//
// Most endpoints below register under /api/admin and are admin-gated by the
// outer adminGate(users, ...) wrapper at handlers.go. The form-time hint
// endpoint at /api/projects/{id}/approval-policies/effective is the one
// exception — it's reachable by every authenticated user authoring a
// deadline/appointment so the form can render the "this needs 4-eye"
// banner before they save.
// ============================================================================
// GET /admin/approval-policies — server-static page shell.
func handleAdminApprovalPoliciesPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/admin-approval-policies.html")
}
// GET /api/admin/partner-units/{unit_id}/approval-policies — list one
// partner unit's default policy rows.
func handleListUnitApprovalPolicies(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if _, ok := requireUser(w, r); !ok {
return
}
unitID, err := uuid.Parse(r.PathValue("unit_id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid unit id"})
return
}
rows, err := dbSvc.approval.ListUnitPolicies(r.Context(), unitID)
if err != nil {
writeServiceError(w, err)
return
}
if rows == nil {
rows = []models.ApprovalPolicy{}
}
writeJSON(w, http.StatusOK, rows)
}
// PUT /api/admin/partner-units/{unit_id}/approval-policies/{entity_type}/{lifecycle}
//
// Body: {"required_role": "associate"}
func handlePutUnitApprovalPolicy(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
unitID, err := uuid.Parse(r.PathValue("unit_id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid unit 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.UpsertUnitPolicy(r.Context(), uid, unitID, entityType, lifecycle, body.RequiredRole)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, policy)
}
// DELETE /api/admin/partner-units/{unit_id}/approval-policies/{entity_type}/{lifecycle}
func handleDeleteUnitApprovalPolicy(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
unitID, err := uuid.Parse(r.PathValue("unit_id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid unit id"})
return
}
entityType := r.PathValue("entity_type")
lifecycle := r.PathValue("lifecycle")
if err := dbSvc.approval.DeleteUnitPolicy(r.Context(), uid, unitID, entityType, lifecycle); err != nil {
writeServiceError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// GET /api/admin/approval-policies/seeded — has any policy been authored
// firm-wide? Used by /inbox to gate the admin "configure policies" nudge.
func handleApprovalPoliciesSeeded(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if _, ok := requireUser(w, r); !ok {
return
}
any, err := dbSvc.approval.PoliciesExist(r.Context())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]bool{"any": any})
}
// GET /api/admin/approval-policies/matrix?project_id=... — 8 effective
// policy rows for one project, with attribution chips.
func handleApprovalPoliciesMatrix(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if _, ok := requireUser(w, r); !ok {
return
}
pidStr := r.URL.Query().Get("project_id")
if pidStr == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "project_id required"})
return
}
projectID, err := uuid.Parse(pidStr)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project_id"})
return
}
rows, err := dbSvc.approval.GetEffectivePoliciesMatrix(r.Context(), projectID)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// POST /api/admin/approval-policies/apply-to-descendants
//
// Body: {"source_project_id": uuid, "target_project_ids": [uuid, ...]}
//
// Copies the source's effective matrix down to every target as
// project-scoped rows. Targets must be actual descendants of source.
func handleApplyMatrixToDescendants(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var body struct {
SourceProjectID uuid.UUID `json:"source_project_id"`
TargetProjectIDs []uuid.UUID `json:"target_project_ids"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if body.SourceProjectID == uuid.Nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "source_project_id required"})
return
}
writes, err := dbSvc.approval.ApplyMatrixToDescendants(r.Context(), uid, body.SourceProjectID, body.TargetProjectIDs)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"writes": writes,
"targets": len(body.TargetProjectIDs),
})
}
// GET /api/projects/{id}/approval-policies/effective?entity_type=&lifecycle=
//
// Single-cell effective policy lookup. Used by the deadline + appointment
// new/edit forms to render the form-time 4-eye hint above the Speichern
// button (Q13 of the locked design). Reachable by every authenticated user
// (NOT admin-gated) — they need to know their save will trigger an
// approval request.
func handleProjectEffectivePolicy(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
}
q := r.URL.Query()
entityType := q.Get("entity_type")
lifecycle := q.Get("lifecycle")
if entityType == "" || lifecycle == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "entity_type and lifecycle required"})
return
}
row, err := dbSvc.approval.GetEffectivePolicyOne(r.Context(), projectID, entityType, lifecycle)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, row)
}
// writeApprovalError maps approval-flow errors to HTTP status codes.
func writeApprovalError(w http.ResponseWriter, err error) {
switch {

View File

@@ -424,6 +424,24 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
adminGate(users, handlePutApprovalPolicy))
protected.HandleFunc("DELETE /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}",
adminGate(users, handleDeleteApprovalPolicy))
// t-paliad-154 — approval-policy authoring page + admin APIs for
// per-partner-unit defaults, matrix view, bulk-apply, and the
// existence-check used by /inbox.
protected.HandleFunc("GET /admin/approval-policies",
adminGate(users, gateOnboarded(handleAdminApprovalPoliciesPage)))
protected.HandleFunc("GET /api/admin/partner-units/{unit_id}/approval-policies",
adminGate(users, handleListUnitApprovalPolicies))
protected.HandleFunc("PUT /api/admin/partner-units/{unit_id}/approval-policies/{entity_type}/{lifecycle}",
adminGate(users, handlePutUnitApprovalPolicy))
protected.HandleFunc("DELETE /api/admin/partner-units/{unit_id}/approval-policies/{entity_type}/{lifecycle}",
adminGate(users, handleDeleteUnitApprovalPolicy))
protected.HandleFunc("GET /api/admin/approval-policies/seeded",
adminGate(users, handleApprovalPoliciesSeeded))
protected.HandleFunc("GET /api/admin/approval-policies/matrix",
adminGate(users, handleApprovalPoliciesMatrix))
protected.HandleFunc("POST /api/admin/approval-policies/apply-to-descendants",
adminGate(users, handleApplyMatrixToDescendants))
}
// t-paliad-138 — approval inbox + decision endpoints (any authenticated
@@ -437,6 +455,12 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
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)
// t-paliad-154 — form-time effective policy lookup. Reachable by
// every authenticated user (NOT admin-gated) so deadline +
// appointment forms can render the 4-eye hint.
protected.HandleFunc("GET /api/projects/{id}/approval-policies/effective",
gateOnboarded(handleProjectEffectivePolicy))
}
// t-paliad-144 Phase A1+A2 — Custom Views (substrate + user_views CRUD

View File

@@ -521,13 +521,19 @@ const (
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.
// ApprovalPolicy is one row of paliad.approval_policies — a rule that says
// "this (entity_type, lifecycle_event) needs 4-eye sign-off at the given
// role tier or above" within a scope. The scope is either a single project
// (ProjectID set, PartnerUnitID nil) OR a single partner unit (PartnerUnitID
// set, ProjectID nil) — XOR enforced by the DB CHECK
// approval_policies_scope_xor.
//
// Project rows act as the most-specific override; partner-unit rows act as
// firm-wide defaults for projects attached to that unit (t-paliad-154).
type ApprovalPolicy struct {
ID uuid.UUID `db:"id" json:"id"`
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
PartnerUnitID *uuid.UUID `db:"partner_unit_id" json:"partner_unit_id,omitempty"`
EntityType string `db:"entity_type" json:"entity_type"`
LifecycleEvent string `db:"lifecycle_event" json:"lifecycle_event"`
RequiredRole string `db:"required_role" json:"required_role"`
@@ -536,6 +542,44 @@ type ApprovalPolicy struct {
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
}
// EffectivePolicy is the resolved policy for one (project, entity_type,
// lifecycle_event) cell — what the gate actually does, after the
// project-row / ancestor-row / unit-default cascade in
// paliad.approval_policy_effective(). Populated by
// ApprovalService.GetEffectivePoliciesMatrix and the form-time hint
// endpoint.
//
// RequiredRole is nil iff no policy applies (no candidates OR the project
// row carries the 'none' sentinel). Source ∈ {"project", "ancestor",
// "unit_default"} when RequiredRole is non-nil. SourceID is the project_id
// for project / ancestor sources; the partner_unit_id for unit_default.
type EffectivePolicy struct {
EntityType string `json:"entity_type"`
LifecycleEvent string `json:"lifecycle_event"`
RequiredRole *string `json:"required_role,omitempty"`
Source *string `json:"source,omitempty"`
SourceID *uuid.UUID `json:"source_id,omitempty"`
SourceName *string `json:"source_name,omitempty"`
}
// PolicyAuditEntry is one row of paliad.policy_audit_log — admin-only audit
// trail for approval-policy CRUD (t-paliad-154). Surfaces on /admin/audit-log
// via AuditService union; never on per-project /verlauf.
type PolicyAuditEntry struct {
ID uuid.UUID `db:"id" json:"id"`
ActorID uuid.UUID `db:"actor_id" json:"actor_id"`
EventType string `db:"event_type" json:"event_type"`
ScopeType string `db:"scope_type" json:"scope_type"`
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
PartnerUnitID *uuid.UUID `db:"partner_unit_id" json:"partner_unit_id,omitempty"`
ScopeName string `db:"scope_name" json:"scope_name"`
EntityType string `db:"entity_type" json:"entity_type"`
LifecycleEvent string `db:"lifecycle_event" json:"lifecycle_event"`
OldRequiredRole *string `db:"old_required_role" json:"old_required_role,omitempty"`
NewRequiredRole *string `db:"new_required_role" json:"new_required_role,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// ApprovalRequest is one row of paliad.approval_requests — an in-flight
// state-change awaiting 4-eye sign-off.
//

View File

@@ -45,6 +45,7 @@ import (
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/models"
)
@@ -63,23 +64,47 @@ 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.
// LookupPolicy returns the effective approval policy for the given tuple,
// or nil if none applies. Reads inside the same tx as Submit* so policy
// reads see whatever the calling tx may have already written.
//
// Resolution (t-paliad-154): delegates to paliad.approval_policy_effective(),
// which returns at most one row after walking the project-row → ancestor-row
// → unit-default cascade and picking most-restrictive across candidates.
//
// 'none' short-circuit: when the resolver yields required_role='none' (only
// possible from a project-specific row, since unit/ancestor candidates with
// 'none' lose MAX to any non-none), this returns nil — the gate is
// suppressed and no approval request is created.
//
// The returned ApprovalPolicy is synthetic when source != 'project': it
// carries the resolved required_role + the actual project_id (so downstream
// code that branches on ProjectID still works), but no DB id since the
// effective rule may have been computed across multiple rows.
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 {
var row struct {
RequiredRole string `db:"required_role"`
Source sql.NullString `db:"source"`
SourceID *uuid.UUID `db:"source_id"`
}
q := `SELECT required_role, source, source_id
FROM paliad.approval_policy_effective($1, $2, $3)`
if err := txOrDB(tx, s.db).GetContext(ctx, &row, q, projectID, entityType, lifecycleEvent); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
return nil, nil // no candidates → no policy applies
}
return nil, fmt.Errorf("lookup approval policy: %w", err)
}
return &p, nil
if row.RequiredRole == "none" {
return nil, nil // explicit suppression at project-row level
}
pid := projectID
return &models.ApprovalPolicy{
ProjectID: &pid,
EntityType: entityType,
LifecycleEvent: lifecycleEvent,
RequiredRole: row.RequiredRole,
}, nil
}
// hasQualifiedApprover counts users on the project's team-membership path
@@ -890,61 +915,584 @@ func (s *ApprovalService) PendingCountForUser(ctx context.Context, callerID uuid
}
// ============================================================================
// Policy CRUD — paliad.approval_policies.
// Policy CRUD — paliad.approval_policies (t-paliad-138 + t-paliad-154).
//
// Two scopes coexist:
//
// - Project rows (project_id IS NOT NULL, partner_unit_id IS NULL):
// the most-specific override for that one project.
// - Unit defaults (project_id IS NULL, partner_unit_id IS NOT NULL):
// firm-wide defaults applied to every project attached
// to that partner unit (via paliad.project_partner_units).
//
// XOR enforced by approval_policies_scope_xor in migration 062.
//
// Audit emission: every set / cleared writes one row to paliad.policy_audit_log
// (Q8 of the locked design — surfaces on /admin/audit-log only, never on
// per-project /verlauf). The actor is always a global_admin.
// ============================================================================
// 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
// IsValidPolicyRole returns true iff the value is a valid required_role for
// an approval_policies row. Accepts the strict-ladder roles AND the 'none'
// sentinel that suppresses inherited defaults at project-row level. Distinct
// from IsValidRequiredRole, which is used by the gate (and rejects 'none' as
// a level-0 ineligible value).
func IsValidPolicyRole(role string) bool {
switch role {
case ProfessionPartner, ProfessionOfCounsel, ProfessionAssociate,
ProfessionSeniorPA, ProfessionPA, "none":
return true
}
return false
}
// ListProjectPolicies returns the project-specific policy rows for a single
// project (up to 8: deadline×4 + appointment×4). Does NOT include inherited
// rows or unit defaults — those come via GetEffectivePoliciesMatrix.
func (s *ApprovalService) ListProjectPolicies(ctx context.Context, projectID uuid.UUID) ([]models.ApprovalPolicy, error) {
q := `SELECT id, project_id, partner_unit_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 nil, fmt.Errorf("list project 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)
// ListUnitPolicies returns the unit-default policy rows for a single
// partner unit (up to 8).
func (s *ApprovalService) ListUnitPolicies(ctx context.Context, unitID uuid.UUID) ([]models.ApprovalPolicy, error) {
q := `SELECT id, project_id, partner_unit_id, entity_type, lifecycle_event,
required_role, created_at, updated_at, created_by
FROM paliad.approval_policies
WHERE partner_unit_id = $1
ORDER BY entity_type, lifecycle_event`
var out []models.ApprovalPolicy
if err := s.db.SelectContext(ctx, &out, q, unitID); err != nil {
return nil, fmt.Errorf("list unit approval policies: %w", err)
}
return out, nil
}
// validatePolicyTuple returns ErrInvalidInput if any of the three policy
// strings are out of range for the underlying CHECK constraints.
func validatePolicyTuple(entityType, lifecycle, requiredRole string) error {
if !IsValidPolicyRole(requiredRole) {
return fmt.Errorf("%w: required_role %q", ErrInvalidInput, requiredRole)
}
if entityType != EntityTypeDeadline && entityType != EntityTypeAppointment {
return nil, fmt.Errorf("%w: entity_type %q", ErrInvalidInput, entityType)
return 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)
return fmt.Errorf("%w: lifecycle_event %q", ErrInvalidInput, lifecycle)
}
return nil
}
// UpsertProjectPolicy creates or replaces a single project-scoped policy
// row. Caller must be global_admin (gate enforced at the handler layer).
// Audit row written via writePolicyAudit. 'none' as required_role is
// allowed and suppresses inherited defaults explicitly.
func (s *ApprovalService) UpsertProjectPolicy(ctx context.Context, callerID, projectID uuid.UUID, entityType, lifecycle, requiredRole string) (*models.ApprovalPolicy, error) {
if err := validatePolicyTuple(entityType, lifecycle, requiredRole); err != nil {
return nil, err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("upsert project policy: begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
// Snapshot pre-existing required_role for the audit row.
var oldRole sql.NullString
if err := tx.GetContext(ctx, &oldRole,
`SELECT required_role FROM paliad.approval_policies
WHERE project_id = $1 AND entity_type = $2 AND lifecycle_event = $3`,
projectID, entityType, lifecycle); err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("upsert project policy: read pre-image: %w", err)
}
q := `INSERT INTO paliad.approval_policies
(project_id, entity_type, lifecycle_event, required_role, created_by)
VALUES ($1, $2, $3, $4, $5)
(project_id, partner_unit_id, entity_type, lifecycle_event, required_role, created_by)
VALUES ($1, NULL, $2, $3, $4, $5)
ON CONFLICT (project_id, entity_type, lifecycle_event)
WHERE project_id IS NOT NULL
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`
RETURNING id, project_id, partner_unit_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)
if err := tx.GetContext(ctx, &p, q, projectID, entityType, lifecycle, requiredRole, callerID); err != nil {
return nil, fmt.Errorf("upsert project policy: %w", err)
}
// Snapshot project name for the audit row (so cascade-set-null doesn't
// lose the human label).
var scopeName string
if err := tx.GetContext(ctx, &scopeName,
`SELECT title FROM paliad.projects WHERE id = $1`, projectID); err != nil {
// Tolerate name lookup failure — still audit with empty scope_name.
scopeName = ""
}
if err := s.writePolicyAudit(ctx, tx, callerID, "approval_policy_set",
"project", &projectID, nil, scopeName, entityType, lifecycle,
nullToPtr(oldRole), &requiredRole); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("upsert project policy: commit: %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)
// DeleteProjectPolicy removes a single project-scoped policy row, reverting
// that cell to inherit from ancestors / unit defaults. Audit row written.
func (s *ApprovalService) DeleteProjectPolicy(ctx context.Context, callerID, projectID uuid.UUID, entityType, lifecycle string) error {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("delete project policy: begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
var oldRole sql.NullString
if err := tx.GetContext(ctx, &oldRole,
`SELECT required_role FROM paliad.approval_policies
WHERE project_id = $1 AND entity_type = $2 AND lifecycle_event = $3`,
projectID, entityType, lifecycle); err != nil {
if errors.Is(err, sql.ErrNoRows) {
// Nothing to delete — exit cleanly without auditing a no-op.
return nil
}
return fmt.Errorf("delete project policy: read pre-image: %w", err)
}
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.approval_policies
WHERE project_id = $1 AND entity_type = $2 AND lifecycle_event = $3`,
projectID, entityType, lifecycle); err != nil {
return fmt.Errorf("delete project policy: %w", err)
}
var scopeName string
if err := tx.GetContext(ctx, &scopeName,
`SELECT title FROM paliad.projects WHERE id = $1`, projectID); err != nil {
scopeName = ""
}
if err := s.writePolicyAudit(ctx, tx, callerID, "approval_policy_cleared",
"project", &projectID, nil, scopeName, entityType, lifecycle,
nullToPtr(oldRole), nil); err != nil {
return err
}
return tx.Commit()
}
// UpsertUnitPolicy creates or replaces a single unit-default policy row.
func (s *ApprovalService) UpsertUnitPolicy(ctx context.Context, callerID, unitID uuid.UUID, entityType, lifecycle, requiredRole string) (*models.ApprovalPolicy, error) {
if err := validatePolicyTuple(entityType, lifecycle, requiredRole); err != nil {
return nil, err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("upsert unit policy: begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
var oldRole sql.NullString
if err := tx.GetContext(ctx, &oldRole,
`SELECT required_role FROM paliad.approval_policies
WHERE partner_unit_id = $1 AND entity_type = $2 AND lifecycle_event = $3`,
unitID, entityType, lifecycle); err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("upsert unit policy: read pre-image: %w", err)
}
q := `INSERT INTO paliad.approval_policies
(project_id, partner_unit_id, entity_type, lifecycle_event, required_role, created_by)
VALUES (NULL, $1, $2, $3, $4, $5)
ON CONFLICT (partner_unit_id, entity_type, lifecycle_event)
WHERE partner_unit_id IS NOT NULL
DO UPDATE SET required_role = EXCLUDED.required_role,
updated_at = now()
RETURNING id, project_id, partner_unit_id, entity_type, lifecycle_event,
required_role, created_at, updated_at, created_by`
var p models.ApprovalPolicy
if err := tx.GetContext(ctx, &p, q, unitID, entityType, lifecycle, requiredRole, callerID); err != nil {
return nil, fmt.Errorf("upsert unit policy: %w", err)
}
var scopeName string
if err := tx.GetContext(ctx, &scopeName,
`SELECT name FROM paliad.partner_units WHERE id = $1`, unitID); err != nil {
scopeName = ""
}
if err := s.writePolicyAudit(ctx, tx, callerID, "approval_policy_set",
"unit", nil, &unitID, scopeName, entityType, lifecycle,
nullToPtr(oldRole), &requiredRole); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("upsert unit policy: commit: %w", err)
}
return &p, nil
}
// DeleteUnitPolicy removes a single unit-default policy row.
func (s *ApprovalService) DeleteUnitPolicy(ctx context.Context, callerID, unitID uuid.UUID, entityType, lifecycle string) error {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("delete unit policy: begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
var oldRole sql.NullString
if err := tx.GetContext(ctx, &oldRole,
`SELECT required_role FROM paliad.approval_policies
WHERE partner_unit_id = $1 AND entity_type = $2 AND lifecycle_event = $3`,
unitID, entityType, lifecycle); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil
}
return fmt.Errorf("delete unit policy: read pre-image: %w", err)
}
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.approval_policies
WHERE partner_unit_id = $1 AND entity_type = $2 AND lifecycle_event = $3`,
unitID, entityType, lifecycle); err != nil {
return fmt.Errorf("delete unit policy: %w", err)
}
var scopeName string
if err := tx.GetContext(ctx, &scopeName,
`SELECT name FROM paliad.partner_units WHERE id = $1`, unitID); err != nil {
scopeName = ""
}
if err := s.writePolicyAudit(ctx, tx, callerID, "approval_policy_cleared",
"unit", nil, &unitID, scopeName, entityType, lifecycle,
nullToPtr(oldRole), nil); err != nil {
return err
}
return tx.Commit()
}
// allLifecycleEvents enumerates the 8 (entity_type, lifecycle) cells in
// stable display order: Fristen first (create / update / complete / delete),
// then Termine.
var allLifecycleEvents = []struct {
EntityType string
Lifecycle string
}{
{EntityTypeDeadline, LifecycleCreate},
{EntityTypeDeadline, LifecycleUpdate},
{EntityTypeDeadline, LifecycleComplete},
{EntityTypeDeadline, LifecycleDelete},
{EntityTypeAppointment, LifecycleCreate},
{EntityTypeAppointment, LifecycleUpdate},
{EntityTypeAppointment, LifecycleComplete},
{EntityTypeAppointment, LifecycleDelete},
}
// GetEffectivePoliciesMatrix returns one EffectivePolicy per (entity_type,
// lifecycle_event) cell — 8 rows in stable display order. Each row carries
// the resolved required_role + attribution (source ∈ {project, ancestor,
// unit_default}) + a human-readable source name (project title or partner
// unit name).
//
// RequiredRole is nil iff no policy applies to that cell. 'none' surfaces
// as required_role='none' with source='project' so the admin UI can render
// "Keine Genehmigung erforderlich (projektspezifisch)".
func (s *ApprovalService) GetEffectivePoliciesMatrix(ctx context.Context, projectID uuid.UUID) ([]models.EffectivePolicy, error) {
out := make([]models.EffectivePolicy, 0, len(allLifecycleEvents))
for _, c := range allLifecycleEvents {
row, err := s.GetEffectivePolicyOne(ctx, projectID, c.EntityType, c.Lifecycle)
if err != nil {
return nil, err
}
out = append(out, *row)
}
return out, nil
}
// GetEffectivePolicyOne returns the EffectivePolicy for a single cell.
// Used by the form-time hint endpoint on /projects/{id}/deadlines/new etc.
func (s *ApprovalService) GetEffectivePolicyOne(ctx context.Context, projectID uuid.UUID, entityType, lifecycle string) (*models.EffectivePolicy, error) {
var row struct {
RequiredRole sql.NullString `db:"required_role"`
Source sql.NullString `db:"source"`
SourceID *uuid.UUID `db:"source_id"`
}
q := `SELECT required_role, source, source_id
FROM paliad.approval_policy_effective($1, $2, $3)`
if err := s.db.GetContext(ctx, &row, q, projectID, entityType, lifecycle); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return &models.EffectivePolicy{
EntityType: entityType,
LifecycleEvent: lifecycle,
}, nil
}
return nil, fmt.Errorf("effective policy: %w", err)
}
res := &models.EffectivePolicy{
EntityType: entityType,
LifecycleEvent: lifecycle,
}
if row.RequiredRole.Valid {
rr := row.RequiredRole.String
res.RequiredRole = &rr
}
if row.Source.Valid {
src := row.Source.String
res.Source = &src
}
if row.SourceID != nil {
res.SourceID = row.SourceID
// Best-effort source-name lookup. Failure is non-fatal — chip just
// renders the unattributed source label.
if name, err := s.lookupSourceName(ctx, *row.SourceID, row.Source.String); err == nil {
res.SourceName = &name
}
}
return res, nil
}
// lookupSourceName resolves a source_id to a human label depending on the
// source kind. project / ancestor → projects.title; unit_default →
// partner_units.name. Returns ("", err) if the row vanished.
func (s *ApprovalService) lookupSourceName(ctx context.Context, id uuid.UUID, source string) (string, error) {
var q string
switch source {
case "project", "ancestor":
q = `SELECT title FROM paliad.projects WHERE id = $1`
case "unit_default":
q = `SELECT name FROM paliad.partner_units WHERE id = $1`
default:
return "", fmt.Errorf("unknown source %q", source)
}
var name string
if err := s.db.GetContext(ctx, &name, q, id); err != nil {
return "", err
}
return name, nil
}
// PoliciesExist returns true iff any approval_policies row exists firm-wide
// (project or unit, any cell). Used by the /inbox empty-state nudge to hide
// the "configure policies" card once any policy is set.
func (s *ApprovalService) PoliciesExist(ctx context.Context) (bool, error) {
var ok bool
if err := s.db.GetContext(ctx, &ok,
`SELECT EXISTS(SELECT 1 FROM paliad.approval_policies LIMIT 1)`); err != nil {
return false, fmt.Errorf("policies exist check: %w", err)
}
return ok, nil
}
// ApplyMatrixToDescendants copies the source project's effective matrix
// down to every project in `targetIDs` as project-specific rows. Idempotent
// fanout — each target's existing project rows for the 8 cells are first
// DELETEd, then the source's effective values INSERTed (excluding cells
// where the source resolves to no policy and the target already has none).
//
// Validates every target is an actual descendant of source via the project
// path. Self-target (source ∈ targetIDs) is silently skipped. Caller must
// be global_admin (handler-layer gate). Audit row per affected target+cell.
//
// Returns the number of policy-cell writes performed (INSERTs + post-clear
// re-applies).
func (s *ApprovalService) ApplyMatrixToDescendants(ctx context.Context, callerID, sourceProjectID uuid.UUID, targetIDs []uuid.UUID) (int, error) {
if len(targetIDs) == 0 {
return 0, nil
}
// Resolve source's effective matrix (fold inherited values into the
// target's project-scoped rows for predictable behaviour).
matrix, err := s.GetEffectivePoliciesMatrix(ctx, sourceProjectID)
if err != nil {
return 0, fmt.Errorf("apply matrix: source resolve: %w", err)
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return 0, fmt.Errorf("apply matrix: begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
// Validate each target_id is a descendant of source. Anything else =
// caller-bug → ErrInvalidInput.
if err := s.validateDescendants(ctx, tx, sourceProjectID, targetIDs); err != nil {
return 0, err
}
writes := 0
for _, target := range targetIDs {
if target == sourceProjectID {
continue // skip self
}
// Snapshot pre-existing project rows for audit.
oldRows, err := s.snapshotProjectRows(ctx, tx, target)
if err != nil {
return 0, err
}
// Wipe the target's 8 cells (project-scoped only — leaves unit-default
// inheritance intact).
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.approval_policies
WHERE project_id = $1`, target); err != nil {
return 0, fmt.Errorf("apply matrix: clear target %s: %w", target, err)
}
// Apply source's effective values as project-scoped rows.
for _, cell := range matrix {
if cell.RequiredRole == nil {
continue
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.approval_policies
(project_id, partner_unit_id, entity_type, lifecycle_event, required_role, created_by)
VALUES ($1, NULL, $2, $3, $4, $5)`,
target, cell.EntityType, cell.LifecycleEvent, *cell.RequiredRole, callerID); err != nil {
return 0, fmt.Errorf("apply matrix: write target %s cell %s/%s: %w",
target, cell.EntityType, cell.LifecycleEvent, err)
}
writes++
}
// Audit one row per target (set-event with a synthesised payload —
// individual cells are too noisy for the audit timeline).
var scopeName string
if err := tx.GetContext(ctx, &scopeName,
`SELECT title FROM paliad.projects WHERE id = $1`, target); err != nil {
scopeName = ""
}
// Use lifecycle='create' as a stand-in marker for the bulk apply
// audit row — the meaningful payload is "matrix copied from source".
// The audit row is informational; the per-cell set/clear are not
// re-emitted for bulk to avoid log spam.
_ = oldRows // pre-image not currently surfaced; reserved for future
if err := s.writePolicyAuditRaw(ctx, tx, callerID, "approval_policy_set",
"project", &target, nil, scopeName, "deadline", "create",
nil, strPtr(fmt.Sprintf("bulk-apply from source=%s", sourceProjectID))); err != nil {
return 0, err
}
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("apply matrix: commit: %w", err)
}
return writes, nil
}
// validateDescendants checks that every target_id is on the source's
// descendant subtree (path LIKE source.path || '.%'). Returns ErrInvalidInput
// listing offending IDs if any are not descendants.
func (s *ApprovalService) validateDescendants(ctx context.Context, tx *sqlx.Tx, sourceID uuid.UUID, targetIDs []uuid.UUID) error {
if len(targetIDs) == 0 {
return nil
}
q := `WITH src AS (
SELECT path FROM paliad.projects WHERE id = $1
)
SELECT p.id::text
FROM paliad.projects p, src
WHERE p.id = ANY($2)
AND p.path NOT LIKE src.path || '.%'`
rows, err := tx.QueryxContext(ctx, q, sourceID, pqUUIDArray(targetIDs))
if err != nil {
return fmt.Errorf("apply matrix: validate descendants: %w", err)
}
defer rows.Close()
var bad []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return err
}
bad = append(bad, id)
}
if len(bad) > 0 {
return fmt.Errorf("%w: not descendants of %s: %s",
ErrInvalidInput, sourceID, strings.Join(bad, ", "))
}
return nil
}
// snapshotProjectRows reads the current project-scoped policy rows for a
// project. Used as audit pre-image during ApplyMatrixToDescendants.
func (s *ApprovalService) snapshotProjectRows(ctx context.Context, tx *sqlx.Tx, projectID uuid.UUID) ([]models.ApprovalPolicy, error) {
var rows []models.ApprovalPolicy
if err := tx.SelectContext(ctx, &rows,
`SELECT id, project_id, partner_unit_id, entity_type, lifecycle_event,
required_role, created_at, updated_at, created_by
FROM paliad.approval_policies
WHERE project_id = $1`, projectID); err != nil {
return nil, fmt.Errorf("snapshot project rows: %w", err)
}
return rows, nil
}
// writePolicyAudit writes one paliad.policy_audit_log row inside the calling
// tx. tx may be nil in which case we run on s.db directly.
func (s *ApprovalService) writePolicyAudit(
ctx context.Context, tx *sqlx.Tx, actorID uuid.UUID,
eventType, scopeType string, projectID, partnerUnitID *uuid.UUID,
scopeName, entityType, lifecycle string,
oldRole, newRole *string,
) error {
return s.writePolicyAuditRaw(ctx, tx, actorID, eventType, scopeType,
projectID, partnerUnitID, scopeName, entityType, lifecycle, oldRole, newRole)
}
// writePolicyAuditRaw expects a non-nil tx (the audit row must commit
// atomically with the data mutation).
func (s *ApprovalService) writePolicyAuditRaw(
ctx context.Context, tx *sqlx.Tx, actorID uuid.UUID,
eventType, scopeType string, projectID, partnerUnitID *uuid.UUID,
scopeName, entityType, lifecycle string,
oldRole, newRole *string,
) error {
q := `INSERT INTO paliad.policy_audit_log
(actor_id, event_type, scope_type, project_id, partner_unit_id,
scope_name, entity_type, lifecycle_event,
old_required_role, new_required_role)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`
if _, err := tx.ExecContext(ctx, q,
actorID, eventType, scopeType, projectID, partnerUnitID,
scopeName, entityType, lifecycle, oldRole, newRole); err != nil {
return fmt.Errorf("write policy audit: %w", err)
}
return nil
}
// nullToPtr converts a sql.NullString to a *string pointer.
func nullToPtr(s sql.NullString) *string {
if !s.Valid {
return nil
}
v := s.String
return &v
}
// strPtr is a small helper for inline string literals.
func strPtr(s string) *string { return &s }
// pqUUIDArray converts a []uuid.UUID to the pq array format used by the
// sqlx driver. Reuses the github.com/lib/pq Array helper.
func pqUUIDArray(ids []uuid.UUID) any {
strs := make([]string, len(ids))
for i, id := range ids {
strs[i] = id.String()
}
return pq.Array(strs)
}

View File

@@ -130,6 +130,10 @@ func TestIsValidRequiredRole(t *testing.T) {
{"expert", false},
{"observer", false},
{"", false},
// 'none' is the t-paliad-154 sentinel for explicit suppression — it
// is NOT a valid required_role for the gate (level 0). Use
// IsValidPolicyRole if you want to allow it as a stored value.
{"none", false},
}
for _, c := range cases {
t.Run(c.role, func(t *testing.T) {
@@ -140,6 +144,34 @@ func TestIsValidRequiredRole(t *testing.T) {
}
}
// TestIsValidPolicyRole pins the t-paliad-154 helper used by Upsert*Policy:
// it accepts the strict-ladder roles AND the 'none' sentinel that suppresses
// inherited defaults at project-row level.
func TestIsValidPolicyRole(t *testing.T) {
cases := []struct {
role string
ok bool
}{
{"partner", true},
{"of_counsel", true},
{"associate", true},
{"senior_pa", true},
{"pa", true},
{"none", true}, // sentinel
{"paralegal", false},
{"lead", false},
{"observer", false},
{"", false},
}
for _, c := range cases {
t.Run(c.role, func(t *testing.T) {
if got := IsValidPolicyRole(c.role); got != c.ok {
t.Errorf("IsValidPolicyRole(%q) = %v, want %v", c.role, got, c.ok)
}
})
}
}
func TestIsValidProfession(t *testing.T) {
for _, p := range []string{"partner", "of_counsel", "associate", "senior_pa", "pa", "paralegal"} {
t.Run(p, func(t *testing.T) {
@@ -312,8 +344,8 @@ func setupApprovalTest(t *testing.T) *approvalTestEnv {
// 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 {
if _, err := e.approvals.UpsertProjectPolicy(context.Background(),
e.requester, e.projectID, entityType, lifecycle, requiredRole); err != nil {
e.t.Fatalf("seed policy: %v", err)
}
}
@@ -646,21 +678,23 @@ func TestApprovalService_RevokeRevertsAndMarksRevoked(t *testing.T) {
}
// TestApprovalService_PolicyCRUDRoundtrip: upsert → list → delete.
// Uses post-t-paliad-148 profession enum (partner replaced legacy 'lead')
// and post-t-paliad-154 method names (UpsertProjectPolicy / etc).
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 {
if _, err := env.approvals.UpsertProjectPolicy(ctx, env.requester, env.projectID, 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 {
if _, err := env.approvals.UpsertProjectPolicy(ctx, env.requester, env.projectID, EntityTypeAppointment, LifecycleUpdate, "partner"); err != nil {
t.Fatalf("upsert 2: %v", err)
}
// List.
got, err := env.approvals.ListPolicies(ctx, env.projectID)
got, err := env.approvals.ListProjectPolicies(ctx, env.projectID)
if err != nil {
t.Fatalf("list: %v", err)
}
@@ -669,27 +703,32 @@ func TestApprovalService_PolicyCRUD(t *testing.T) {
}
// Re-upsert the first to a different role.
if _, err := env.approvals.UpsertPolicy(ctx, env.projectID, env.requester, EntityTypeDeadline, LifecycleCreate, "lead"); err != nil {
if _, err := env.approvals.UpsertProjectPolicy(ctx, env.requester, env.projectID, EntityTypeDeadline, LifecycleCreate, "partner"); err != nil {
t.Fatalf("re-upsert: %v", err)
}
got, _ = env.approvals.ListPolicies(ctx, env.projectID)
got, _ = env.approvals.ListProjectPolicies(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)
if p.EntityType == EntityTypeDeadline && p.LifecycleEvent == LifecycleCreate && p.RequiredRole != "partner" {
t.Errorf("after re-upsert: required_role=%q, want partner", p.RequiredRole)
}
}
// Invalid role rejected.
if _, err := env.approvals.UpsertPolicy(ctx, env.projectID, env.requester, EntityTypeDeadline, LifecycleCreate, "observer"); !errors.Is(err, ErrInvalidInput) {
if _, err := env.approvals.UpsertProjectPolicy(ctx, env.requester, env.projectID, EntityTypeDeadline, LifecycleCreate, "observer"); !errors.Is(err, ErrInvalidInput) {
t.Errorf("invalid required_role: got %v, want ErrInvalidInput", err)
}
// 'none' sentinel accepted (suppresses inherited defaults).
if _, err := env.approvals.UpsertProjectPolicy(ctx, env.requester, env.projectID, EntityTypeDeadline, LifecycleDelete, "none"); err != nil {
t.Errorf("'none' sentinel rejected: %v", err)
}
// Delete.
if err := env.approvals.DeletePolicy(ctx, env.projectID, EntityTypeDeadline, LifecycleCreate); err != nil {
if err := env.approvals.DeleteProjectPolicy(ctx, env.requester, 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))
got, _ = env.approvals.ListProjectPolicies(ctx, env.projectID)
if len(got) != 2 { // appointment.update + deadline.delete='none' remain
t.Errorf("after delete: %d rows, want 2", len(got))
}
}

View File

@@ -2,12 +2,13 @@ package services
// AuditService produces a unified, paginated, filterable timeline across
// every audit source we keep in the paliad schema. There is no single
// audit_log table — instead we union four existing sources:
// audit_log table — instead we union five sources:
//
// - paliad.project_events — per-project audit (creates, updates, etc.)
// - paliad.caldav_sync_log — CalDAV push/pull outcomes per user
// - paliad.reminder_log — bundled-digest reminder sends
// - paliad.partner_unit_events — partner-unit CRUD + membership changes
// - paliad.policy_audit_log — approval-policy CRUD (t-paliad-154)
//
// The union happens in SQL (one round-trip, server-side ordering) and is
// keyset-paginated on (timestamp, id) DESC so the cursor stays stable across
@@ -35,6 +36,7 @@ const (
AuditSourceCalDAVLog = "caldav_sync_log"
AuditSourceReminderLog = "reminder_log"
AuditSourcePartnerUnitEvents = "partner_unit_events"
AuditSourcePolicyAuditLog = "policy_audit_log"
)
// MaxAuditPageLimit caps a single ListEntries page.
@@ -187,6 +189,33 @@ WITH unioned AS (
WHERE ($1::text IS NULL OR $1 = '' OR $1 = 'partner_unit_events')
AND ($2::timestamptz IS NULL OR pue.created_at >= $2)
AND ($3::timestamptz IS NULL OR pue.created_at <= $3)
UNION ALL
-- t-paliad-154 — approval-policy CRUD audit. Project-scoped rows carry
-- project_id (so the timeline filter by project still works on the
-- /verlauf SELECT — but project_events is the source for that surface,
-- not policy_audit_log, so no leakage). Description packs the field
-- transition (entity_type/lifecycle: old → new).
SELECT
'policy_audit_log'::text AS source,
pal.id AS id,
pal.created_at AS ts,
pal.event_type AS event_type,
COALESCE(au.email, pal.actor_id::text) AS actor,
pal.scope_name AS subject,
pal.project_id AS project_id,
NULL::text AS title,
format('%s/%s: %s → %s',
pal.entity_type,
pal.lifecycle_event,
COALESCE(pal.old_required_role, '—'),
COALESCE(pal.new_required_role, '—')) AS description
FROM paliad.policy_audit_log pal
LEFT JOIN paliad.users au ON au.id = pal.actor_id
WHERE ($1::text IS NULL OR $1 = '' OR $1 = 'policy_audit_log')
AND ($2::timestamptz IS NULL OR pal.created_at >= $2)
AND ($3::timestamptz IS NULL OR pal.created_at <= $3)
)
SELECT source, id, ts, event_type, actor, subject, project_id, title, description
FROM unioned