Compare commits
11 Commits
mai/fritz/
...
mai/hilber
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5df4285e1d | ||
|
|
028423b32f | ||
|
|
0f87d73b1b | ||
|
|
e6067c74db | ||
|
|
e92c56b5f8 | ||
|
|
f7908f03ad | ||
|
|
01fa4b1287 | ||
|
|
bb035558be | ||
|
|
b78941e293 | ||
|
|
55c93c9de3 | ||
|
|
f90bfeda9b |
912
docs/design-approval-policy-ui-2026-05-07.md
Normal file
912
docs/design-approval-policy-ui-2026-05-07.md
Normal 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 Mon–Fri 9–17, 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.
|
||||
@@ -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());
|
||||
|
||||
135
frontend/src/admin-approval-policies.tsx
Normal file
135
frontend/src/admin-approval-policies.tsx
Normal 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 — 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ü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ädt …</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ür ein Projekt. Ü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ä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">×</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
|
||||
überschrieben. Standards aus Partner Units bleiben unberü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">Übernehmen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/admin-approval-policies.js" defer />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -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üfung pro Projekt und Partner Unit konfigurieren.</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h3 className="section-heading admin-section-planned" data-i18n="admin.section.planned">Geplant</h3>
|
||||
|
||||
@@ -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>
|
||||
|
||||
540
frontend/src/client/admin-approval-policies.ts
Normal file
540
frontend/src/client/admin-approval-policies.ts
Normal 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, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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";
|
||||
|
||||
109
frontend/src/client/views/format.test.ts
Normal file
109
frontend/src/client/views/format.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
122
frontend/src/client/views/format.ts
Normal file
122
frontend/src/client/views/format.ts
Normal 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`);
|
||||
}
|
||||
@@ -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 timeText = formatRowTime(row, { dateAvailable });
|
||||
if (timeText) {
|
||||
const time = document.createElement("span");
|
||||
time.textContent = formatTime(row.event_date);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -49,6 +49,21 @@ export function renderInbox(): string {
|
||||
<div className="agenda-loading" id="inbox-loading" data-i18n="agenda.loading">Lädt …</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ü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 />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
285
internal/db/migrations/062_approval_policy_unit_defaults.up.sql
Normal file
285
internal/db/migrations/062_approval_policy_unit_defaults.up.sql
Normal 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.';
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user