Compare commits
33 Commits
mai/fritz/
...
mai/fritz/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfc48b1420 | ||
|
|
5cb7f76160 | ||
|
|
8b76d0c8fa | ||
|
|
9cd05e7c59 | ||
|
|
5598aef074 | ||
|
|
16fe5763f3 | ||
|
|
18faf81f58 | ||
|
|
aeaba66892 | ||
|
|
a61c1490e3 | ||
|
|
544bb63684 | ||
|
|
2d06cdf20e | ||
|
|
f8d8ea591d | ||
|
|
77d664c5cc | ||
|
|
8cf95761d0 | ||
|
|
d41fc49809 | ||
|
|
1eebf2fc44 | ||
|
|
fb1a709bb8 | ||
|
|
e2e1381395 | ||
|
|
0d54da1d5b | ||
|
|
deef5aaff5 | ||
|
|
bc47d78d97 | ||
|
|
07a1c17861 | ||
|
|
2247c0707d | ||
|
|
93c4453ce5 | ||
|
|
a42322de3f | ||
|
|
abc395fcfa | ||
|
|
747d85fe49 | ||
|
|
6c41550945 | ||
|
|
fb6a07f4b7 | ||
|
|
10b3426086 | ||
|
|
4ebbf2c1af | ||
|
|
b3401ec8ac | ||
|
|
7d1ddb9b84 |
@@ -157,7 +157,15 @@ func main() {
|
||||
EmailTemplate: emailTemplateSvc,
|
||||
Link: services.NewLinkService(pool),
|
||||
Event: services.NewEventService(pool, deadlineSvc, appointmentSvc),
|
||||
Approval: services.NewApprovalService(pool, users),
|
||||
Derivation: services.NewDerivationService(pool, projectSvc, partnerUnitSvc),
|
||||
}
|
||||
// Wire ApprovalService into the entity services so Create / Update /
|
||||
// Complete / Delete consult paliad.approval_policies (t-paliad-138).
|
||||
// Without this wiring, the policies and request tables exist but no
|
||||
// mutation path consults them — paliad behaves as before.
|
||||
deadlineSvc.SetApprovalService(svcBundle.Approval)
|
||||
appointmentSvc.SetApprovalService(svcBundle.Approval)
|
||||
// v3 (t-paliad-133): wire EventCategoryService and cross-link
|
||||
// it into DeadlineSearchService so ?event_category_slug= can
|
||||
// resolve to a concept-id allow-list during search.
|
||||
|
||||
828
docs/design-approvals-2026-05-06.md
Normal file
828
docs/design-approvals-2026-05-06.md
Normal file
@@ -0,0 +1,828 @@
|
||||
# Design — Dual-control approvals (4-Augen-Prüfung) for Deadlines + Appointments
|
||||
|
||||
**Author:** cronus (inventor)
|
||||
**Date:** 2026-05-06
|
||||
**Task:** t-paliad-138 (Gitea m/paliad#3)
|
||||
**Branch:** `mai/cronus/inventor-dual-control`
|
||||
**Status:** DESIGN READY FOR REVIEW. Awaiting m go/no-go before any coder shift.
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
Add a 4-eye principle to `paliad.deadlines` and `paliad.appointments`. Every state-changing action (create / update-of-date-fields / complete / delete) submitted by one team member must be signed off by a qualified second team member from the same project before the change is "approved".
|
||||
|
||||
Six locked design decisions from m (2026-05-06):
|
||||
|
||||
| # | Question | Locked answer |
|
||||
|---|---|---|
|
||||
| Q1 | Where does the qualification level live? | **Reuse `project_teams.role` per-project** (no new firm-wide column). New value `senior_pa` added to the role enum. |
|
||||
| Q1+ | Strict-ladder default? | **Default approval-eligible = {lead, associate}**. Per-project / per-event setting can extend to `senior_pa` or `pa` (so PAs can approve other PAs in some projects). |
|
||||
| Q2 | Hierarchy semantics | **Strict ladder.** Higher level always satisfies lower. |
|
||||
| Q3 | Policy granularity | **Per-(project, entity_type, lifecycle_event)** \— up to 8 settable rows per project. |
|
||||
| Q4 | Edit-trigger fields | **Only date-changing fields.** Deadline: `due_date`, `original_due_date`, `warning_date`. Appointment: `start_at`, `end_at`. All other field changes bypass approval. |
|
||||
| Q5 | Pending-state architecture | **Write-then-approve.** Field changes apply immediately; the entity carries `approval_status='pending'` until an approver flips it. (Delete is the one exception — see §5.4.) |
|
||||
| Q6 | Inbox surface | **Bell icon (sidebar header) + dedicated `/inbox` page** with two tabs: "Zur Genehmigung" / "Meine Anfragen". |
|
||||
| Q7 | Revocation | **Pending-only revoke.** After approval, only path back is a new request. |
|
||||
| Q8 | Single-qualified-approver deadlock | **Refuse + global_admin override.** UI refuses with "Kein qualifizierter Approver verfügbar"; global_admin can manually approve as override (audit-marked). |
|
||||
| Q9 | Audit / chronology | **Both** \— operational `paliad.approval_requests` table + new event types in `paliad.project_events`. Both creator and approver names persist on the entity row. |
|
||||
| Q10 | RLS | **Visible to project team, action gated by service.** Same `can_see_project()` predicate; service layer checks "caller has required role tier AND caller_id != requested_by". |
|
||||
| Q11 | Migration of existing rows | **Mark legacy + skip backfill.** All existing rows get `approval_status='legacy'`. New lifecycle events on legacy rows trigger normal approval flow. |
|
||||
|
||||
Plus m's explicit interjection: **pending state must be visualised everywhere the entity normally surfaces** — list views, agenda, dashboard traffic-light, project detail, CalDAV-synced calendars, and email reminders. Silence on a pending change creates more risk than visible-but-flagged-pending.
|
||||
|
||||
Out of scope for v1: notes, parties, documents, checklists; cross-app generalisation; multi-step n-of-m chains; email/WhatsApp/Telegram approvals (in-app only).
|
||||
|
||||
---
|
||||
|
||||
## 1. Context — what's already in the code
|
||||
|
||||
What this design slots into:
|
||||
|
||||
- **Three-axis principle (m, t-paliad-051, sacrosanct).** "Firm roles ≠ project roles ≠ tool roles."
|
||||
- `paliad.users.job_title` — free-text display. Never gates anything.
|
||||
- `paliad.users.global_role` — `standard` | `global_admin`. Tool-admin gate only.
|
||||
- `paliad.project_teams.role` — `lead | associate | pa | of_counsel | local_counsel | expert | observer`. Per-project membership role.
|
||||
- **Visibility:** `paliad.can_see_project()` SQL function (migration 023) + Go mirror `services.visibilityPredicate()` — global_admin OR any team membership on the project's path. Service-role connection bypasses RLS, so the Go mirror is load-bearing; RLS is defense-in-depth.
|
||||
- **Audit:** `paliad.project_events` (created in migration 005 as `akten_events`, renamed in 018). Every mutation on every project-scoped entity emits one row via `services.insertProjectEventWithMeta()` inside the same tx. Carries `event_type`, `title`, `description`, `metadata jsonb`, `created_by`, `event_date`. Read by `services.AuditService` and by the Verlauf card on each project / deadline / appointment detail page (t-paliad-097, t-paliad-102).
|
||||
- **Entity tables:** `paliad.deadlines` and `paliad.appointments`. Both already carry `created_by uuid REFERENCES auth.users(id)`. Deadlines have `status text CHECK IN ('pending','completed','cancelled','waived')`. Appointments have no status column.
|
||||
- **Service layer:** `DeadlineService.{Create,Update,Complete,Reopen,Delete}`, `AppointmentService.{Create,Update,Delete}`. Each goes through `ProjectService.GetByID(ctx, userID, projectID)` for visibility before mutating. Each emits its `*_created` / `*_updated` / `*_completed` / `*_deleted` event in the same tx.
|
||||
- **Existing patterns this design reuses:**
|
||||
- `paliad.partner_unit_events` audit table (migration 027) — proves the side-table-with-RLS shape works alongside `project_events`.
|
||||
- `paliad.event_types` + `paliad.deadline_event_types` (migration 030) — the picker / multi-select / chip UI pattern is reusable for the "required role" select on the policy authoring page.
|
||||
- `services.visibilityPredicate(alias)` — same shape for the new `approvalEligibleInProject(userID, projectID, requiredRole)` helper.
|
||||
|
||||
This design adds **no new auth/permission axis**. It reuses `project_teams.role` for the qualification gate, per m's Q1 decision. The 3-axis principle holds because the gate uses the existing project axis, not a new firm-wide one.
|
||||
|
||||
---
|
||||
|
||||
## 2. Approval ladder
|
||||
|
||||
### 2.1 Strict ladder over `project_teams.role`
|
||||
|
||||
```
|
||||
level | role | approval-eligible by default?
|
||||
------+------------------+-------------------------------
|
||||
5 | lead | yes — partner-tier on this project
|
||||
4 | of_counsel | yes — senior tier
|
||||
3 | associate | yes ← default required level
|
||||
2 | senior_pa (new) | only if project policy lowers required to 'senior_pa' or below
|
||||
1 | pa | only if project policy lowers required to 'pa'
|
||||
0 | local_counsel | ineligible — external attorney, not in approval scope
|
||||
0 | expert | ineligible — technical witness role
|
||||
0 | observer | ineligible — read-only audit role
|
||||
```
|
||||
|
||||
`senior_pa` is added to the `paliad.project_teams.role` CHECK constraint via migration 054 (see §6.1). It currently has no value in the enum.
|
||||
|
||||
**Strict-ladder rule:** a user with project_teams.role `R` can approve any request whose `required_role` is at level ≤ `level(R)`. So:
|
||||
|
||||
- Default `required_role = 'associate'` (level 3) → eligible approvers: lead, of_counsel, associate.
|
||||
- Override to `required_role = 'senior_pa'` (level 2) → eligible: lead, of_counsel, associate, senior_pa.
|
||||
- Override to `required_role = 'pa'` (level 1) → eligible: lead, of_counsel, associate, senior_pa, pa. This is the "PAs approve other PAs" mode m called for.
|
||||
- Override to `required_role = 'lead'` → only the project lead can approve.
|
||||
|
||||
**Hard rules:**
|
||||
|
||||
1. **Self-approval is hard-blocked.** `caller_id = requested_by` always returns 403, regardless of role. This is enforced at the Go service layer (the only place that mutates approval state) and by a CHECK constraint on the row at decision time (`approved_by != requested_by`).
|
||||
2. **Eligible level 0 = ineligible.** A user with role=local_counsel/expert/observer **cannot** approve any request, even if they're the only team member. They appear in the inbox with "Sie sind nicht qualifiziert" instead of the approve button.
|
||||
3. **`global_admin` is an explicit override path** (§4.2) — not a normal approver. global_admin sign-off is allowed regardless of project_teams.role and audit-marked as `decision_kind='admin_override'`.
|
||||
|
||||
### 2.2 Why not introduce a firm-wide qualification column?
|
||||
|
||||
The issue listed candidates `partner / senior_attorney / attorney / senior_pa / pa / paralegal` and asked whether roles should be global, per-team, or per-project. m chose **per-project** (Q1 = "Reuse project_teams.role"). Rationale (mine, before m chose; reproduced for the record):
|
||||
|
||||
A firm-wide rank column would have:
|
||||
- Cleanly separated from `job_title` (display) and `global_role` (tool admin).
|
||||
- Made authoring rules trivial — one column on `users`, one int compare.
|
||||
- Worked even before a project's team was fully populated.
|
||||
|
||||
But it would have:
|
||||
- Added a 4th identity-axis to maintain (firm rank), violating the spirit of the three-axis principle even if the letter holds.
|
||||
- Forced a firm-wide ladder onto a project context where seniority is already encoded — `lead` on a project IS the partner-tier on that project.
|
||||
- Introduced the question "what if firm rank disagrees with project role" (a senior partner staffed as `observer` on a small case) without a clean answer.
|
||||
|
||||
m's per-project choice is consistent with how the rest of paliad treats authority: the `lead` role on `project_teams` is the source of truth for "who is the partner running this case", and approvals naturally cluster around that.
|
||||
|
||||
### 2.3 What about local_counsel / expert / observer?
|
||||
|
||||
Default: ineligible to approve. Rationale:
|
||||
|
||||
- **local_counsel** is an external attorney (Mitanwalt) — not always a firm employee, often outside the firm's approval chain.
|
||||
- **expert** is a technical / scientific consultant role — not legally qualified to sign off on procedural deadlines.
|
||||
- **observer** is explicitly a read-only role.
|
||||
|
||||
**Escape hatch:** if a project genuinely wants its local_counsel to approve, the team admin can re-add them with `role='associate'` (or whatever tier is intended). The role on `project_teams` is a per-project assignment; the same human can be `local_counsel` on Project A and `associate` on Project B if that's the correct authority on each.
|
||||
|
||||
**Out of scope (follow-up if needed):** a per-project list of "additional approval-eligible roles" that promotes local_counsel/expert into the eligible set without changing their primary project role. Probably not worth the complexity for the few cases where it'd matter.
|
||||
|
||||
---
|
||||
|
||||
## 3. Policy grammar — `paliad.approval_policies`
|
||||
|
||||
### 3.1 Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.approval_policies (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
entity_type text NOT NULL CHECK (entity_type IN ('deadline','appointment')),
|
||||
lifecycle_event text NOT NULL CHECK (lifecycle_event IN ('create','update','complete','delete')),
|
||||
required_role text NOT NULL CHECK (required_role IN ('lead','of_counsel','associate','senior_pa','pa')),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
UNIQUE (project_id, entity_type, lifecycle_event)
|
||||
);
|
||||
|
||||
CREATE INDEX approval_policies_project_idx ON paliad.approval_policies (project_id);
|
||||
```
|
||||
|
||||
Design choices:
|
||||
|
||||
- **Up to 8 rows per project.** `(deadline,create), (deadline,update), (deadline,complete), (deadline,delete), (appointment,create), (appointment,update), (appointment,complete), (appointment,delete)`. UNIQUE composite key enforces this.
|
||||
- **No row = no approval needed for that event.** A project with zero policy rows is in the same operational state as today — no 4-eye anywhere.
|
||||
- **`required_role` is a single value**, not a min-level int. Stored as text matching `project_teams.role` values; the strict ladder is applied in code (see `levelOf(role)` in §3.4). Storing the enum value (rather than an int level) keeps the row readable in `psql` and survives any future ladder reordering.
|
||||
- **Appointment lifecycle includes `complete`**. Today appointments don't have a `completed_at` column or status field. We add one via migration 054 to give `appointment:complete` somewhere to land — see §6.4. (m may choose to defer this; if so, the policy CHECK can drop `complete` for `appointment` and the migration becomes lighter.)
|
||||
|
||||
### 3.2 Inheritance
|
||||
|
||||
**No automatic inheritance from parent project.** A child project (e.g. a single Verfahren under a Litigation parent) does NOT auto-inherit its parent's policy. Reasons:
|
||||
|
||||
- Inheriting would silently change behaviour when projects are reparented (t-paliad-018 already has reparent semantics).
|
||||
- Policy authoring per-Verfahren is the right default — different stages of a litigation may legitimately need different scrutiny.
|
||||
- The path-walking logic for "find the closest ancestor with policy" adds complexity for marginal value.
|
||||
|
||||
**UI affordance:** project detail → Settings → Approvals tab → "Aus Eltern-Projekt übernehmen" button copies the parent's 8 rows into this project. One-shot copy, no live link. Documented as a productivity shortcut.
|
||||
|
||||
### 3.3 Authoring permission
|
||||
|
||||
**v1: global_admin only.** Consistent with the existing /admin/team and /admin/partner-units pattern. Per-project leads cannot edit policy on their own projects in v1.
|
||||
|
||||
**Reasoning:** approval policy is firm-governance-grade — getting it wrong loosens compliance. Concentrating in global_admin is safer for v1. Lifting to "project lead can edit policy on their project" is a one-line gate change.
|
||||
|
||||
**Out of scope follow-up:** lead-can-edit-own-project-policy. File as t-paliad-139 if needed once the v1 ships.
|
||||
|
||||
### 3.4 Service-layer helpers
|
||||
|
||||
```go
|
||||
// internal/services/approval_levels.go
|
||||
|
||||
// levelOf maps a project_teams.role value to the strict-ladder level used
|
||||
// for approval gating. Returns 0 (ineligible) for roles outside the
|
||||
// approval ladder (local_counsel, expert, observer).
|
||||
func levelOf(role string) int {
|
||||
switch role {
|
||||
case "lead": return 5
|
||||
case "of_counsel": return 4
|
||||
case "associate": return 3
|
||||
case "senior_pa": return 2
|
||||
case "pa": return 1
|
||||
default: return 0 // local_counsel, expert, observer, anything new
|
||||
}
|
||||
}
|
||||
|
||||
// canApprove returns true iff:
|
||||
// - caller is not the requester (self-approval blocked)
|
||||
// - caller's project_teams.role on this project has level >= required level
|
||||
// OR caller is global_admin (which is always allowed and audit-marked separately).
|
||||
func (s *ApprovalService) canApprove(ctx, callerID, projectID, requiredRole string, requesterID uuid.UUID) (bool, kind string, err error) {
|
||||
if callerID == requesterID {
|
||||
return false, "", ErrSelfApprovalBlocked
|
||||
}
|
||||
user, err := s.users.GetByID(ctx, callerID)
|
||||
if err != nil { return false, "", err }
|
||||
if user.GlobalRole == "global_admin" {
|
||||
return true, "admin_override", nil
|
||||
}
|
||||
membership, err := s.projects.MembershipFor(ctx, callerID, projectID)
|
||||
if err != nil || membership == nil {
|
||||
return false, "", nil // not on team, cannot approve
|
||||
}
|
||||
if levelOf(membership.Role) >= levelOf(requiredRole) {
|
||||
return true, "peer", nil
|
||||
}
|
||||
return false, "", nil
|
||||
}
|
||||
```
|
||||
|
||||
`decision_kind` values: `peer` (normal in-team sign-off), `admin_override` (global_admin used override path). Stored on `approval_requests.decision_kind`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Lifecycle flow (write-then-approve)
|
||||
|
||||
### 4.1 The four lifecycle events
|
||||
|
||||
For each entity (deadline, appointment), four lifecycle events trigger an approval check:
|
||||
|
||||
1. **create** — new row submitted by user.
|
||||
2. **update** — change to one or more date-bearing fields (allowlist in §4.5).
|
||||
3. **complete** — flip status from `pending` to `completed` on a deadline; flip new `completed_at` (see §6.4) on appointment.
|
||||
4. **delete** — request to remove the row.
|
||||
|
||||
### 4.2 Submission
|
||||
|
||||
User clicks Save / Complete / Delete on the entity. The service layer:
|
||||
|
||||
1. Looks up `paliad.approval_policies(project_id, entity_type, event)`.
|
||||
2. **No row found:** apply mutation immediately (today's behaviour). `approval_status` defaults to `'approved'`. No request row written. Done.
|
||||
3. **Row found:** apply mutation **except for delete** (see §4.3) and additionally:
|
||||
- Set `approval_status = 'pending'` and `pending_request_id = <new uuid>` on the entity row.
|
||||
- Insert one `paliad.approval_requests` row with `lifecycle_event`, `pre_image jsonb` (a snapshot of the now-overwritten field values, used for revert on rejection — see §4.4), `payload jsonb` (echo of what was submitted, for audit), `requested_by = caller`, `requested_at = now()`, `required_role = policy.required_role`, `status = 'pending'`.
|
||||
- Emit `paliad.project_events` row with `event_type = 'deadline_approval_requested'` (or `appointment_approval_requested`) carrying `metadata.approval_request_id = <uuid>`. The Verlauf shows the lifecycle inline.
|
||||
- All four writes happen in **one transaction** (entity update + request insert + event emit).
|
||||
4. **Single-qualified-approver deadlock check.** Before committing, the service runs a count: how many users on this project's team have `levelOf(project_teams.role) >= levelOf(required_role) AND user_id != caller`? If 0, the submission **fails with HTTP 409** and a structured error: `{ "error": "no_qualified_approver", "required_role": "associate", "hint": "add_team_member_or_contact_admin" }`. Frontend translates to a user-facing dialog with two action buttons: "Mehr Team-Mitglieder hinzufügen" (jumps to project team page) and "Admin kontaktieren" (mailto link to global_admin emails). global_admin override is the escape hatch (§4.7).
|
||||
|
||||
### 4.3 Delete is special — stage-then-write
|
||||
|
||||
m's chosen architecture is write-then-approve, but delete cannot be applied immediately and reverted: a hard-delete is irrecoverable.
|
||||
|
||||
**Resolution:** for `lifecycle_event = 'delete'`, the entity row stays in place. We set `approval_status = 'pending'` and link to an `approval_requests` row carrying `lifecycle_event = 'delete'`. The UI marks the row "Zur Löschung beantragt" (see §5.3). On approve: hard-delete the row in a tx (cascades clean up the FK from `approval_requests`). On reject: clear `approval_status` back to `'approved'` and `pending_request_id` to NULL. The deletion never happened.
|
||||
|
||||
This is the one departure from pure write-then-approve. It's a write-then-approve from the user's perspective (they "submit a delete" and the entity behaves as if it's about to disappear) but at the data-layer it's stage-then-write for delete. Documented explicitly to avoid surprise.
|
||||
|
||||
### 4.4 Approval / rejection
|
||||
|
||||
Approver opens `/inbox`, picks a request, clicks Approve (or Reject with optional reason).
|
||||
|
||||
**Approve:**
|
||||
|
||||
1. Service-layer `canApprove(caller, project, request)` check (see §3.4).
|
||||
2. If `decision_kind = 'peer'` or `'admin_override'`, set `approval_requests.status = 'approved'`, `decided_by = caller`, `decided_at = now()`, `decision_kind = …`.
|
||||
3. Update entity row: `approval_status = 'approved'`, clear `pending_request_id`. Set `approved_by = caller`, `approved_at = now()`.
|
||||
4. For `delete`: hard-delete the entity (cascade clears the request FK).
|
||||
5. Emit `paliad.project_events` row with `event_type = 'deadline_approval_approved'` (or `appointment_approval_approved`) carrying `metadata.approval_request_id`, `metadata.decision_kind`. Verlauf line: "Frist X — Genehmigung erteilt von Bert · 2026-05-06".
|
||||
6. Tx commits.
|
||||
|
||||
**Reject:**
|
||||
|
||||
1. Same `canApprove` check.
|
||||
2. Set `approval_requests.status = 'rejected'`, `decided_by`, `decided_at`, `decision_note` (optional reason text from approver).
|
||||
3. **Revert entity** — restore from `pre_image`:
|
||||
- `create`: hard-delete the entity (it never should have been live).
|
||||
- `update`: write `pre_image` field values back over the row.
|
||||
- `complete`: revert deadline `status` to `'pending'`, NULL `completed_at`. Revert appointment `completed_at` to NULL (only meaningful once §6.4 lands).
|
||||
- `delete`: clear `pending_request_id` and `approval_status`. Entity stays live as before.
|
||||
4. Emit `paliad.project_events` row `event_type = 'deadline_approval_rejected'` (or appointment_) with `metadata.approval_request_id`, `metadata.decision_note`. Verlauf line: "Frist X — Genehmigung abgelehnt von Bert · 2026-05-06 — Grund: Datum noch nicht best."
|
||||
5. Tx commits.
|
||||
|
||||
### 4.5 Edit-trigger field allowlist (per Q4)
|
||||
|
||||
The service layer only enters the approval-request flow when an `update` touches the date-bearing fields. All other edits apply immediately as `approval_status='approved'` writes — no request row, no pending state.
|
||||
|
||||
**Deadlines — date-bearing (gates approval):**
|
||||
- `due_date`
|
||||
- `original_due_date`
|
||||
- `warning_date`
|
||||
|
||||
**Deadlines — bypass (no approval):**
|
||||
- `title`, `description`, `notes`
|
||||
- `rule_id`, `rule_code` (legal-basis citation — m chose to bypass; see Q4 trade-off below)
|
||||
- `event_type_ids` (Typ tags via `paliad.deadline_event_types` junction)
|
||||
- `status` other than via the `complete` lifecycle (e.g. cancel, waive — these are out of approval scope per the issue's "all four lifecycle events" framing, which lists complete but not cancel/waive)
|
||||
|
||||
**Appointments — date-bearing (gates approval):**
|
||||
- `start_at`
|
||||
- `end_at`
|
||||
|
||||
**Appointments — bypass (no approval):**
|
||||
- `title`, `description`
|
||||
- `location` (m's Q4 choice excludes location; documented trade-off below)
|
||||
- `appointment_type`
|
||||
|
||||
**Trade-off (m's call):** the looser allowlist means a wrongful change to `rule_code` (legal basis) or `location` (wrong courthouse) won't trigger 4-eye. m's reasoning is implicit but consistent: dates are the highest-stakes mistake category (missed deadline = malpractice exposure), and gating every metadata edit creates approval fatigue that makes approvers rubber-stamp.
|
||||
|
||||
If the team finds this allowlist too loose in practice, the constants in `internal/services/approval_fields.go` (proposed location) are a one-PR widening — no schema change.
|
||||
|
||||
### 4.6 Optimistic-concurrency / superseded requests
|
||||
|
||||
Race scenario: User A submits an `update` request with `pre_image = {due_date: 2026-05-10}`. Before it's approved, user B submits another `update` with their own pre-image. Now there are two pending requests on the same row.
|
||||
|
||||
**Rule:** a row can have at most one pending request at a time. The submission service-layer does:
|
||||
|
||||
```sql
|
||||
UPDATE paliad.deadlines
|
||||
SET ...new field values..., approval_status = 'pending', pending_request_id = $newRequestID
|
||||
WHERE id = $entityID
|
||||
AND approval_status = 'approved' -- only mutate if currently clean
|
||||
RETURNING id;
|
||||
```
|
||||
|
||||
If the UPDATE returns 0 rows (because `approval_status != 'approved'`), the submission fails with HTTP 409 `{ "error": "concurrent_pending", "hint": "wait_for_existing_approval_or_revoke" }`. Frontend shows "Es liegt bereits eine Genehmigungsanfrage auf dieser Frist vor."
|
||||
|
||||
Submitter has options: revoke their own pending (if they own it) and resubmit; or wait for the existing request to settle.
|
||||
|
||||
### 4.7 Single-qualified-approver deadlock — global_admin override path
|
||||
|
||||
Per Q8, the default behaviour is **refuse to submit** when no qualified approver other than the requester exists on the team. Submission is blocked at the API layer.
|
||||
|
||||
**Override mechanism:** any `global_admin` (regardless of project membership) has the approval right. So if the user's team has nobody else qualified, the user can submit anyway IF the project has at least one global_admin who can approve. The submission service runs the deadlock check as:
|
||||
|
||||
```
|
||||
SELECT COUNT(*) FROM paliad.project_teams pt
|
||||
WHERE pt.project_id = $proj
|
||||
AND pt.user_id <> $caller
|
||||
AND pt.role IN (eligible roles for required_role)
|
||||
+
|
||||
SELECT COUNT(*) FROM paliad.users u
|
||||
WHERE u.global_role = 'global_admin'
|
||||
AND u.id <> $caller
|
||||
```
|
||||
|
||||
If sum > 0, submission is allowed. If sum = 0, the 409 fires. In practice, paliad currently has 2 global_admins so sum is rarely 0 — but the design contemplates the case.
|
||||
|
||||
When global_admin signs off, the `decision_kind` on the approval_request row is `'admin_override'` (vs `'peer'`). Verlauf chronology renders this distinctly: "Admin-Sign-off von m · 2026-05-06" rather than "Genehmigt von Bert · 2026-05-06". The audit log timeline filters can pivot on `decision_kind`.
|
||||
|
||||
### 4.8 Revocation (per Q7)
|
||||
|
||||
- **Requester revokes:** while `request.status = 'pending'`, the requester can DELETE their own request. Service-layer reverts the entity from pre_image (same code path as Reject), but instead of marking the request `'rejected'`, marks it `'revoked'`. New `paliad.project_events` event_type `'deadline_approval_revoked'`.
|
||||
- **Approver revokes after approval:** **not supported** per Q7. Once approved, the only path back is a new request — e.g. an over-eager Complete is reversed by a fresh "Reopen" lifecycle event, which itself flows through the approval gate.
|
||||
|
||||
---
|
||||
|
||||
## 5. UI surfaces
|
||||
|
||||
### 5.1 The pending pill — visible everywhere
|
||||
|
||||
Per m's interjection, pending state must surface in every view that shows the entity. Visual treatment:
|
||||
|
||||
- **Pending CREATE** — striped/dashed border on the row, ⚠ icon, label "Erstellung wartet auf Genehmigung von <required_role>+". Counted toward traffic-light buckets (the deadline IS real, just unverified) but rendered with a "tentative" CSS class.
|
||||
- **Pending UPDATE** — solid border, but a yellow chip in the date column saying "Datum geändert — wartet auf Genehmigung". Tooltip on the chip shows the diff: "vorher: 2026-05-10 → 2026-05-12".
|
||||
- **Pending COMPLETE** — solid border, status badge "Erledigt (wartet auf Genehmigung)" with strike-through-pending styling. The traffic-light treats the row as completed (the action-taker thinks they're done) but with the same striped class as create-pending so an approver can see the queue at a glance.
|
||||
- **Pending DELETE** — dashed-red border, label "Zur Löschung beantragt". Date / details still visible but strike-through. Click → details + approval request.
|
||||
|
||||
CSS classes (proposed, in `frontend/src/styles/global.css`):
|
||||
|
||||
```css
|
||||
.entity-row--pending-create { border-style: dashed; border-color: var(--frist-amber); }
|
||||
.entity-row--pending-update { /* solid border, chip handles the signal */ }
|
||||
.entity-row--pending-complete { background: linear-gradient(...striped...); }
|
||||
.entity-row--pending-delete { border-style: dashed; border-color: var(--frist-red); text-decoration: line-through; }
|
||||
|
||||
.approval-pill { display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 2px 8px; border-radius: 9999px;
|
||||
background: var(--bg-warn-soft); color: var(--fg-warn);
|
||||
font-size: 12px; }
|
||||
.approval-pill::before { content: "⚠ "; }
|
||||
```
|
||||
|
||||
i18n keys (DE primary, EN secondary):
|
||||
|
||||
- `approvals.pending_create.label` — "Erstellung wartet auf Genehmigung" / "Awaits approval (creation)"
|
||||
- `approvals.pending_update.label` — "Änderung wartet auf Genehmigung" / "Awaits approval (change)"
|
||||
- `approvals.pending_complete.label` — "Erledigung wartet auf Genehmigung" / "Awaits approval (completion)"
|
||||
- `approvals.pending_delete.label` — "Zur Löschung beantragt" / "Awaits approval (deletion)"
|
||||
- `approvals.required_role.<role>` — "Lead", "Of Counsel", "Associate", "Senior PA", "PA"
|
||||
- `approvals.requested_by` — "Eingereicht von {name}" / "Submitted by {name}"
|
||||
- `approvals.no_approver_dialog.*` — full deadlock dialog strings
|
||||
- `approvals.approve.button` — "Genehmigen" / "Approve"
|
||||
- `approvals.reject.button` — "Ablehnen" / "Reject"
|
||||
- `approvals.revoke.button` — "Zurückziehen" / "Revoke"
|
||||
- `approvals.decision_kind.peer` — "Genehmigt von {name}" / "Approved by {name}"
|
||||
- `approvals.decision_kind.admin_override` — "Admin-Sign-off von {name}" / "Admin sign-off by {name}"
|
||||
|
||||
Surfaces that show the pending pill:
|
||||
|
||||
- `/deadlines` and `/appointments` table rows (one pill per row).
|
||||
- `/agenda` timeline (per-row pill).
|
||||
- `/dashboard` traffic-light card-list previews.
|
||||
- `/projects/{id}` details — Fristen + Termine sections.
|
||||
- `/deadlines/{id}` and `/appointments/{id}` detail pages — full diff display.
|
||||
- CalDAV: pending entries sync to the user's external calendar with title prefix `[PENDING] ` (e.g. `[PENDING] Frist Erwiderung`). Approved entries sync clean.
|
||||
- Email reminders (`internal/services/reminder_service.go`): pending entries get a banner in the mail body and a `[PENDING] ` subject prefix.
|
||||
|
||||
### 5.2 Bell + `/inbox` page (per Q6)
|
||||
|
||||
**Bell** in the sidebar header (next to the user-menu). Shows count of "open requests where I am a qualified approver and not the requester". Click → `/inbox`. Refreshes via the existing dashboard-polling pattern (60s interval; `Last-Modified` ETag if cheap to add).
|
||||
|
||||
**`/inbox` page**, two tabs:
|
||||
|
||||
1. **"Zur Genehmigung"** (`?tab=pending-mine`): list of `approval_requests` where:
|
||||
- `status = 'pending'`
|
||||
- `requested_by != me`
|
||||
- I have eligible role on the project (or I'm global_admin)
|
||||
Sorted by `requested_at` ASC (oldest first — stale requests demand attention). Each item shows: project title, entity title, lifecycle event, requester name, age ("vor 4h"), required-role badge. Inline Approve / Reject buttons, expand-row reveals the diff (for update / complete / delete) or full payload (for create).
|
||||
|
||||
2. **"Meine Anfragen"** (`?tab=mine`): list of `approval_requests` where `requested_by = me`. Status filter pills: pending / approved / rejected / revoked. For pending items, a Revoke button.
|
||||
|
||||
URL structure: `/inbox?tab=pending-mine|mine&status=pending|...&project_id=...`. Back-button friendly.
|
||||
|
||||
Why distinct from email reminder flow: email reminders are outbound notifications (digest of upcoming deadlines). The inbox is a workflow surface — actions are taken there. Sharing infra would conflate two purposes.
|
||||
|
||||
### 5.3 Policy authoring — `/projects/{id}/settings/approvals`
|
||||
|
||||
Tab on the project detail page, gated to global_admin. Rendered as a 2×4 table:
|
||||
|
||||
```
|
||||
CREATE UPDATE (date) COMPLETE DELETE
|
||||
Frist [select] [select] [select] [select]
|
||||
Termin [select] [select] [select] [select]
|
||||
```
|
||||
|
||||
Each `<select>` offers: "Keine Genehmigung erforderlich (default)" / "Lead" / "Of Counsel" / "Associate" / "Senior PA" / "PA". Submitting upserts/deletes rows in `paliad.approval_policies`.
|
||||
|
||||
Helpers:
|
||||
- "Aus Eltern-Projekt übernehmen" button — copies the parent project's policy rows in one click. One-shot copy, no live link.
|
||||
- "Alle auf Associate setzen" button — fills all 8 cells with `associate` for fast onboarding of a new project.
|
||||
|
||||
### 5.4 Diff rendering
|
||||
|
||||
For `update` requests, the `pre_image` jsonb captured at submission and the entity's current values let the UI render a clean diff. For deadlines: a 1-3 line comparison ("Datum: 2026-05-10 → 2026-05-12 · Warnung: 2026-05-08 → 2026-05-10"). Done in pure TS in `frontend/src/client/inbox.ts` consuming the request payload.
|
||||
|
||||
---
|
||||
|
||||
## 6. Schema changes (migration 054)
|
||||
|
||||
### 6.1 Add `senior_pa` to `project_teams.role`
|
||||
|
||||
```sql
|
||||
ALTER TABLE paliad.project_teams DROP CONSTRAINT IF EXISTS project_teams_role_check;
|
||||
ALTER TABLE paliad.project_teams ADD CONSTRAINT project_teams_role_check
|
||||
CHECK (role IN (
|
||||
'lead','associate','pa','of_counsel',
|
||||
'local_counsel','expert','observer',
|
||||
'senior_pa'
|
||||
));
|
||||
```
|
||||
|
||||
i18n labels for the new role (in DE+EN per existing `team.role.*` keys).
|
||||
|
||||
### 6.2 `paliad.approval_policies`
|
||||
|
||||
See §3.1 — full DDL.
|
||||
|
||||
### 6.3 `paliad.approval_requests`
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.approval_requests (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
entity_type text NOT NULL CHECK (entity_type IN ('deadline','appointment')),
|
||||
-- entity_id is the deadline.id / appointment.id this request operates on.
|
||||
-- For 'create' lifecycle, this is the id of the just-inserted entity row
|
||||
-- (so the request can reference back to it). For 'delete', it's the row
|
||||
-- being requested for removal.
|
||||
entity_id uuid NOT NULL,
|
||||
lifecycle_event text NOT NULL CHECK (lifecycle_event IN ('create','update','complete','delete')),
|
||||
-- For 'update'/'complete'/'delete': pre_image carries the field values
|
||||
-- needed to revert on rejection. For 'create': pre_image IS NULL.
|
||||
pre_image jsonb,
|
||||
-- For audit/visibility, payload echoes the diff or new values that were
|
||||
-- written. Read-only after insert.
|
||||
payload jsonb,
|
||||
requested_by uuid NOT NULL REFERENCES paliad.users(id) ON DELETE RESTRICT,
|
||||
requested_at timestamptz NOT NULL DEFAULT now(),
|
||||
-- Snapshot of policy.required_role at request time. Even if the policy
|
||||
-- changes mid-flight, the request honours the level it was submitted under.
|
||||
required_role text NOT NULL CHECK (required_role IN ('lead','of_counsel','associate','senior_pa','pa')),
|
||||
status text NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending','approved','rejected','revoked','superseded')),
|
||||
decided_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
decided_at timestamptz,
|
||||
decision_kind text CHECK (decision_kind IS NULL OR decision_kind IN ('peer','admin_override')),
|
||||
decision_note text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
-- Hard CHECK: an approver is never the requester.
|
||||
CHECK (decided_by IS NULL OR decided_by <> requested_by)
|
||||
);
|
||||
|
||||
CREATE INDEX approval_requests_project_status_idx
|
||||
ON paliad.approval_requests (project_id, status);
|
||||
CREATE INDEX approval_requests_entity_idx
|
||||
ON paliad.approval_requests (entity_type, entity_id);
|
||||
CREATE INDEX approval_requests_requested_by_idx
|
||||
ON paliad.approval_requests (requested_by, status);
|
||||
CREATE INDEX approval_requests_pending_idx
|
||||
ON paliad.approval_requests (status, requested_at)
|
||||
WHERE status = 'pending';
|
||||
```
|
||||
|
||||
RLS on `approval_requests`: per Q10, mirror `paliad.deadlines` policy — visible if `paliad.can_see_project(project_id)`. RLS does NOT gate the approve/reject action; that's enforced at the service layer.
|
||||
|
||||
```sql
|
||||
ALTER TABLE paliad.approval_requests ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY approval_requests_all ON paliad.approval_requests
|
||||
FOR ALL USING (paliad.can_see_project(project_id));
|
||||
```
|
||||
|
||||
### 6.4 New columns on `paliad.deadlines` and `paliad.appointments`
|
||||
|
||||
```sql
|
||||
-- deadlines: approval state + approver tracking
|
||||
ALTER TABLE paliad.deadlines ADD COLUMN approval_status text NOT NULL DEFAULT 'approved'
|
||||
CHECK (approval_status IN ('approved','pending','legacy'));
|
||||
ALTER TABLE paliad.deadlines ADD COLUMN pending_request_id uuid
|
||||
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL;
|
||||
ALTER TABLE paliad.deadlines ADD COLUMN approved_by uuid
|
||||
REFERENCES paliad.users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE paliad.deadlines ADD COLUMN approved_at timestamptz;
|
||||
|
||||
CREATE INDEX deadlines_approval_status_idx
|
||||
ON paliad.deadlines (approval_status) WHERE approval_status = 'pending';
|
||||
|
||||
-- appointments: same triple
|
||||
ALTER TABLE paliad.appointments ADD COLUMN approval_status text NOT NULL DEFAULT 'approved'
|
||||
CHECK (approval_status IN ('approved','pending','legacy'));
|
||||
ALTER TABLE paliad.appointments ADD COLUMN pending_request_id uuid
|
||||
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL;
|
||||
ALTER TABLE paliad.appointments ADD COLUMN approved_by uuid
|
||||
REFERENCES paliad.users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE paliad.appointments ADD COLUMN approved_at timestamptz;
|
||||
|
||||
-- appointments need a completed_at for the 'complete' lifecycle event to land
|
||||
ALTER TABLE paliad.appointments ADD COLUMN completed_at timestamptz;
|
||||
|
||||
CREATE INDEX appointments_approval_status_idx
|
||||
ON paliad.appointments (approval_status) WHERE approval_status = 'pending';
|
||||
```
|
||||
|
||||
**`appointments.completed_at`** is new. Today appointments don't have a completion concept — they just sit on the calendar. The `complete` lifecycle event for appointments is meaningful when m wants to mark hearings/meetings as actually-happened (e.g. "Mündliche Verhandlung am 2026-05-15 — abgehalten"). If m prefers to drop appointment-complete from the lifecycle list (deadline-complete only), the `completed_at` column drops out and the policy CHECK constraint excludes `(appointment, complete)`.
|
||||
|
||||
This is a clean place for m to make a smaller call: keep appointment:complete (and add `completed_at`), or drop it.
|
||||
|
||||
### 6.5 Backfill
|
||||
|
||||
```sql
|
||||
-- Mark all existing rows as legacy (predates 4-eye).
|
||||
UPDATE paliad.deadlines SET approval_status = 'legacy';
|
||||
UPDATE paliad.appointments SET approval_status = 'legacy';
|
||||
```
|
||||
|
||||
`approved_by`/`approved_at` stay NULL on legacy rows. `created_by` is already populated since migration 005 (the column has been required from day one).
|
||||
|
||||
**No retroactive approval** — m's Q11 choice. Legacy rows are read-only-clean. The next mutation on a legacy row that hits an active policy follows the normal flow (e.g. editing a date on a legacy deadline triggers `update` approval; the row becomes `approval_status='pending'` and goes through the gate; once approved, `approval_status='approved'`).
|
||||
|
||||
### 6.6 Down migration
|
||||
|
||||
The down migration drops the four new columns + `completed_at` + `approval_policies` + `approval_requests` + restores the `project_teams.role` CHECK without `senior_pa`. If any user has been re-roled to `senior_pa`, the down migration will fail loudly until they're migrated to another role — intentional, mirrors the t-paliad-051 down strategy.
|
||||
|
||||
---
|
||||
|
||||
## 7. Service-layer integration
|
||||
|
||||
### 7.1 New service: `ApprovalService`
|
||||
|
||||
```go
|
||||
// internal/services/approval_service.go
|
||||
|
||||
type ApprovalService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
users *UserService
|
||||
}
|
||||
|
||||
// SubmitCreate is invoked by DeadlineService.Create / AppointmentService.Create
|
||||
// inside the existing entity-write tx. If a policy applies, it inserts the
|
||||
// approval_requests row and sets entity.approval_status = 'pending' + entity.
|
||||
// pending_request_id. Returns (requestID, isPending, err).
|
||||
func (s *ApprovalService) SubmitCreate(ctx, tx, projectID, entityType, entityID, requesterID) (uuid.UUID, bool, error)
|
||||
|
||||
// Same shape for Update / Complete / Delete. Update takes a preImage map.
|
||||
func (s *ApprovalService) SubmitUpdate(ctx, tx, projectID, entityType, entityID, requesterID, preImage map[string]any) (uuid.UUID, bool, error)
|
||||
func (s *ApprovalService) SubmitComplete(ctx, tx, projectID, entityType, entityID, requesterID) (uuid.UUID, bool, error)
|
||||
func (s *ApprovalService) SubmitDelete(ctx, tx, projectID, entityType, entityID, requesterID) (uuid.UUID, bool, error)
|
||||
|
||||
// Approve / Reject / Revoke — invoked by the inbox handler.
|
||||
func (s *ApprovalService) Approve(ctx, requestID, callerID, note string) error
|
||||
func (s *ApprovalService) Reject(ctx, requestID, callerID, note string) error
|
||||
func (s *ApprovalService) Revoke(ctx, requestID, callerID string) error
|
||||
|
||||
// ListInbox returns the pending-mine and my-submitted views.
|
||||
func (s *ApprovalService) ListPendingForApprover(ctx, callerID, filter) ([]ApprovalRequestView, error)
|
||||
func (s *ApprovalService) ListSubmittedByUser(ctx, callerID, filter) ([]ApprovalRequestView, error)
|
||||
```
|
||||
|
||||
### 7.2 Wiring into existing services
|
||||
|
||||
**`DeadlineService.Create`** today:
|
||||
1. ProjectService.GetByID gate (visibility check)
|
||||
2. Begin tx
|
||||
3. INSERT into paliad.deadlines
|
||||
4. Attach event_types junction rows
|
||||
5. insertProjectEventWithMeta(deadline_created)
|
||||
6. Commit
|
||||
|
||||
After integration:
|
||||
1. ProjectService.GetByID gate
|
||||
2. Begin tx
|
||||
3. INSERT into paliad.deadlines (approval_status defaults to 'approved')
|
||||
4. **`approvals.SubmitCreate(ctx, tx, projectID, "deadline", id, userID)`** — if policy applies, this:
|
||||
- Updates approval_status='pending', pending_request_id=… on the just-inserted row
|
||||
- INSERTs approval_requests row
|
||||
- Performs deadlock count, fails the tx if 0 qualified approvers exist
|
||||
5. Attach event_types junction rows
|
||||
6. insertProjectEventWithMeta(deadline_created) — unchanged
|
||||
7. **insertProjectEventWithMeta(deadline_approval_requested)** if approval is pending
|
||||
8. Commit
|
||||
|
||||
Same shape for `Update`, `Complete`, `Delete` on both DeadlineService and AppointmentService. The `Complete` call site is `MarkComplete`/`Reopen` in DeadlineService (today); reopen would be modelled as a fresh "create-style" approval if it lands on a legacy row, or as part of "update" lifecycle on the `status` field — but `status` is not in the date-bearing allowlist so reopen goes through immediately. **Reopen does NOT trigger 4-eye** under this design (Q4 = date-fields-only). If m wants reopen-needs-approval, add `status` to the allowlist or treat reopen as its own lifecycle event.
|
||||
|
||||
### 7.3 Read-path changes
|
||||
|
||||
Existing list/summary queries (`ListVisibleForUser`, `SummaryCounts`) need to:
|
||||
|
||||
- Hydrate `approval_status`, `approved_by`, `approved_at`, and the linked `approval_requests.lifecycle_event` (via JOIN) for each row.
|
||||
- Pass these through to the frontend so the pending pill and traffic-light styling can render.
|
||||
|
||||
Bucket math (t-paliad-106 5-bucket harmonisation) is **unchanged** — pending CREATEs still bucket by `due_date` like any other; the visual just adds the pending pill. Pending DELETEs still appear in their bucket until the delete is approved.
|
||||
|
||||
`/api/inbox/pending-mine` and `/api/inbox/mine` are new endpoints, served by `internal/handlers/inbox.go`.
|
||||
|
||||
### 7.4 Visibility gating for the inbox
|
||||
|
||||
The pending-mine list is gated by:
|
||||
|
||||
```sql
|
||||
SELECT ar.* FROM paliad.approval_requests ar
|
||||
JOIN paliad.projects p ON p.id = ar.project_id
|
||||
WHERE ar.status = 'pending'
|
||||
AND ar.requested_by != $callerID
|
||||
AND <visibilityPredicate>(p) for callerID
|
||||
AND (
|
||||
-- caller is global_admin
|
||||
EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $callerID AND u.global_role = 'global_admin')
|
||||
OR
|
||||
-- caller has eligible role on this specific project
|
||||
EXISTS (SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = $callerID
|
||||
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
|
||||
AND levelOf(pt.role) >= levelOf(ar.required_role))
|
||||
)
|
||||
ORDER BY ar.requested_at ASC;
|
||||
```
|
||||
|
||||
`levelOf` in SQL is a small immutable function:
|
||||
|
||||
```sql
|
||||
CREATE FUNCTION paliad.approval_role_level(role text) RETURNS int LANGUAGE SQL IMMUTABLE AS $$
|
||||
SELECT CASE role
|
||||
WHEN 'lead' THEN 5
|
||||
WHEN 'of_counsel' THEN 4
|
||||
WHEN 'associate' THEN 3
|
||||
WHEN 'senior_pa' THEN 2
|
||||
WHEN 'pa' THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
$$;
|
||||
```
|
||||
|
||||
Stable values; mirrors the Go `levelOf`. Used in the inbox SQL and in any future RLS policy. Migration ships both.
|
||||
|
||||
---
|
||||
|
||||
## 8. Audit / chronology integration
|
||||
|
||||
Per Q9, the existing `paliad.project_events` audit gains four new event_type values per entity:
|
||||
|
||||
- `deadline_approval_requested` — a request was submitted. Metadata: `{ approval_request_id, lifecycle_event, required_role }`.
|
||||
- `deadline_approval_approved` — request approved. Metadata: `{ approval_request_id, decision_kind, decided_by_email }`.
|
||||
- `deadline_approval_rejected` — request rejected. Metadata: `{ approval_request_id, decision_note }`.
|
||||
- `deadline_approval_revoked` — requester revoked their own pending. Metadata: `{ approval_request_id }`.
|
||||
|
||||
Same four for appointments (`appointment_approval_*`).
|
||||
|
||||
These appear in:
|
||||
|
||||
- The `paliad.project_events` Verlauf card on `/projects/{id}` (via existing render path; new translateEvent cases needed in `frontend/src/client/projects-detail.ts`).
|
||||
- The `paliad.project_events` Verlauf card on `/deadlines/{id}` and `/appointments/{id}` (same pattern).
|
||||
- The cross-project `AuditService.ListEntries` timeline at `/admin/audit-log` (already unions project_events; new event types ride along automatically).
|
||||
- Dashboard recent-activity rail (filter through existing `translateEvent` to render the correct sentence).
|
||||
|
||||
**Both names persist on the entity** per the issue's m-locked requirement: `created_by` (already there) + `approved_by` (new). Verlauf renders for an approved deadline:
|
||||
|
||||
```
|
||||
Frist erstellt — eingereicht von Anna 2026-05-06 14:23
|
||||
· genehmigt von Bert 2026-05-06 14:31
|
||||
```
|
||||
|
||||
This is two project_events rows rendered as a paired card in the Verlauf. The frontend pairs them by `metadata.approval_request_id`.
|
||||
|
||||
---
|
||||
|
||||
## 9. RLS / security plan
|
||||
|
||||
Per Q10:
|
||||
|
||||
1. **`approval_requests`** — RLS = `paliad.can_see_project(project_id)`. Same predicate as `deadlines`/`appointments`. Anyone on the project can read pending requests (transparency).
|
||||
2. **`approval_policies`** — RLS = `paliad.can_see_project(project_id)` for SELECT; INSERT/UPDATE/DELETE gated to `global_role = 'global_admin'` (consistent with /admin/team / /admin/partner-units precedent).
|
||||
3. **The `approve`/`reject`/`revoke` action** — service-layer gate only. The pgx pool runs as service role and bypasses RLS, so the check happens in `ApprovalService.canApprove()` (§3.4). RLS provides defense-in-depth for any future direct-DB query path.
|
||||
4. **Self-approval block** — enforced both at the service layer and via a CHECK constraint on `approval_requests` (`decided_by IS NULL OR decided_by <> requested_by`). Two layers because either alone is insufficient (a SQL bug bypasses the service; a service bug bypasses the CHECK).
|
||||
|
||||
The path-walking team-membership + global_admin predicate (`visibilityPredicate`) extends naturally to "approvable-by-me" via the inline JOIN shown in §7.4. No new SQL function needed; the inline form is read-only on the inbox query path.
|
||||
|
||||
**Out of scope follow-up:** if any future direct-DB tooling needs to query "approvable by me", extract a `paliad.can_approve_in_project(user_id, project_id, required_role)` SQL function. For v1, the inline JOIN is sufficient and avoids adding a function that no migration currently calls.
|
||||
|
||||
---
|
||||
|
||||
## 10. Migration plan
|
||||
|
||||
### 10.1 Single migration, single PR
|
||||
|
||||
Migration 054 (`054_approvals.{up,down}.sql`):
|
||||
|
||||
1. Add `senior_pa` to `project_teams.role` CHECK (§6.1).
|
||||
2. Create `paliad.approval_role_level(text) RETURNS int` SQL function.
|
||||
3. Create `paliad.approval_policies` table (§6.2) + indexes + RLS.
|
||||
4. Create `paliad.approval_requests` table (§6.3) + indexes + RLS.
|
||||
5. Add new columns on `paliad.deadlines` and `paliad.appointments` (§6.4) + indexes.
|
||||
6. Mark all existing rows `approval_status='legacy'` (§6.5).
|
||||
|
||||
No data move. No FK hijinks. ms-level apply on a 200-ish-row deadlines table.
|
||||
|
||||
### 10.2 Implementation phasing
|
||||
|
||||
The PR is large but clean. Recommended split into commits (single branch, single PR):
|
||||
|
||||
1. **Commit 1 — Migration 054.** Schema + backfill. No code changes. Runs cleanly on prod; existing flows don't read the new columns yet.
|
||||
2. **Commit 2 — `ApprovalService` core.** Submit / Approve / Reject / Revoke, deadlock check, pre_image capture, request lifecycle. Unit tests (table-driven over the strict-ladder + self-approval rules, deadlock count edge cases).
|
||||
3. **Commit 3 — Wire into `DeadlineService` + `AppointmentService`.** Mutation paths gain the SubmitCreate/Update/Complete/Delete hooks. Read paths hydrate approval_status. Adds new event_types to project_events emit path. Live-DB integration test: TEST_DATABASE_URL covering submit→approve / submit→reject / submit→revoke / single-approver-deadlock / global-admin-override.
|
||||
4. **Commit 4 — Policy authoring page.** `/projects/{id}/settings/approvals` tab + handler + frontend. global_admin-only gate.
|
||||
5. **Commit 5 — Inbox.** `/inbox` page + bell icon + `/api/inbox/*` endpoints + frontend list rendering with diff display.
|
||||
6. **Commit 6 — Pending pills + traffic-light variants.** CSS + i18n + per-surface pill rendering on /deadlines, /appointments, /agenda, /dashboard, /projects/{id}, detail pages.
|
||||
7. **Commit 7 — CalDAV `[PENDING] ` prefix + email-reminder pending banner.** Updates `caldav_service.go` and `mail_service.go` formatting. Integration tests on iCal output and rendered email body.
|
||||
8. **Commit 8 — Verlauf rendering of approval lifecycle.** translateEvent cases for the four new event_types. Pair-card rendering for request+decision events.
|
||||
|
||||
Each commit is testable in isolation; commits 1–3 are merge-safe even before the UI lands (legacy rows + pending state hidden by default = no behaviour change on existing flows because no project has policies until commit 4 ships).
|
||||
|
||||
### 10.3 Roll-out
|
||||
|
||||
Suggested:
|
||||
|
||||
1. Migration 054 lands → no behaviour change (no policies exist yet).
|
||||
2. Pick one pilot project, set policy `(deadline,*)=associate`. Smoke through one CREATE / UPDATE / COMPLETE / DELETE cycle as a non-admin user. Verify pending pills, inbox, approver flow, audit chronology.
|
||||
3. Once validated, m authors policies on real client projects. Each project opts in by adding rows.
|
||||
4. Backfill any free-form leftover later if needed (admin scripts).
|
||||
|
||||
---
|
||||
|
||||
## 11. Trade-offs and known limitations
|
||||
|
||||
### 11.1 Write-then-approve dilution risk
|
||||
|
||||
Per Q5 m chose write-then-approve. This means a pending CREATE is "live" in lists / dashboard / agenda / CalDAV / email reminders before approval. A wrongful create that's eventually rejected briefly polluted the user's mental model and external calendars.
|
||||
|
||||
**Mitigations:**
|
||||
- Pending pill is highly visible (striped border, ⚠ icon).
|
||||
- CalDAV title prefix `[PENDING] ` makes external surfaces honest.
|
||||
- Rejected creates emit `*_approval_rejected` event in Verlauf so the "what happened to that deadline" question has a paper trail.
|
||||
- Approval flow surfaces immediately in inbox (bell badge), so latency between submit and approve is short.
|
||||
|
||||
The alternative (stage-then-write) was strictly safer but m rejected it; the strict-safer architecture would have forced each Frist to live in `approval_requests` until approved, which means views had to UNION the entity table with the requests table — heavy read-path changes and the kind of complexity that compounds into bugs.
|
||||
|
||||
### 11.2 Date-fields-only edit allowlist
|
||||
|
||||
m chose Q4 = "Only date-changing fields". Trade-off: a wrongful change to `rule_code` (legal basis) or `location` (wrong courthouse) bypasses 4-eye. The ladder-based approval-fatigue argument (every metadata edit triggering approvals causes rubber-stamping) is the case for the looser scope.
|
||||
|
||||
If the team finds this too loose in practice, extending the allowlist is a one-line constants change in `internal/services/approval_fields.go` — documented as the place to widen.
|
||||
|
||||
### 11.3 No inheritance from parent project
|
||||
|
||||
§3.2 — a child project doesn't auto-inherit its parent's policy. Trade-off: explicit per-project authoring (more control, more clicks). The "Aus Eltern-Projekt übernehmen" button in the authoring UI (§5.3) reduces the friction.
|
||||
|
||||
### 11.4 v1 is global_admin-only for policy authoring
|
||||
|
||||
Per §3.3, only global_admins can create/edit policies. Project leads cannot edit their own project's policy. Trade-off: tighter governance vs. lead self-service. Lifting to "lead can edit" is a one-line gate change (file as t-paliad-139).
|
||||
|
||||
### 11.5 senior_pa is the only new role enum value
|
||||
|
||||
§6.1 only adds `senior_pa`. Other firm-rank candidates from the issue (`partner`, `senior_attorney`, `attorney`, `paralegal`) were redundant: `lead` already represents partner-tier on a project, `of_counsel` covers senior-attorney, `associate` covers attorney, and paralegal sits below pa (mapped to `observer` in v1). If those distinctions matter later, additional values can be added without breaking existing rows.
|
||||
|
||||
### 11.6 Reopen is not a separate lifecycle
|
||||
|
||||
Today reopening a deadline (revert from `completed` to `pending`) is a status-only change. With Q4 = date-fields-only, reopen does NOT trigger 4-eye. If m wants reopen-needs-approval, it can be modelled as a 5th lifecycle event or as a special-case status-field entry in the allowlist. Documented for future tightening.
|
||||
|
||||
### 11.7 Approval timeout
|
||||
|
||||
No automatic timeout on pending requests. A request can sit pending forever. UI surfaces age ("vor 4 Tagen") to nudge approvers. Future addition: nightly digest email to approvers with a list of pending items > 24h old. Out of scope for v1.
|
||||
|
||||
---
|
||||
|
||||
## 12. Implementation recommendation
|
||||
|
||||
Recommended implementer: **cronus** (this same worktree). Rationale: shipped t-paliad-088 (Event Types — schema + service + handlers + frontend, similar shape), t-paliad-110 (events unification — read-path with new columns hydrated and rendered), t-paliad-122 (courts entity with role-tier-like ladder over countries+regimes). Pattern fluency is high.
|
||||
|
||||
Alternative: split — cronus does commits 1–3 (schema + service core + service-layer wiring) on `mai/cronus/approvals-impl-1`. Then a fresh coder (curie or fritz) does commits 4–8 (UI + inbox + pills + CalDAV + email) on a sibling branch. Trade-off: smaller PRs, but two coordination handovers.
|
||||
|
||||
Head decides.
|
||||
|
||||
---
|
||||
|
||||
## 13. End-of-design checklist
|
||||
|
||||
- [x] Locked constraints summarised (§0)
|
||||
- [x] Existing-code grounding (§1)
|
||||
- [x] Role taxonomy / hierarchy (§2)
|
||||
- [x] Rule grammar (§3)
|
||||
- [x] Lifecycle flow + edit allowlist + deadlock + revocation (§4)
|
||||
- [x] UI surfaces (§5)
|
||||
- [x] Schema (§6)
|
||||
- [x] Service-layer integration (§7)
|
||||
- [x] Audit / chronology (§8)
|
||||
- [x] RLS / security (§9)
|
||||
- [x] Migration plan + phasing (§10)
|
||||
- [x] Trade-offs (§11)
|
||||
- [x] Implementation recommendation (§12)
|
||||
|
||||
**Inventor stays parked.** Design committed; awaiting m's go/no-go before any coder shift starts. No `/mai-coder` self-load. The `DESIGN READY FOR REVIEW` signal is sent via `mai report completed` so the head can gate.
|
||||
1015
docs/design-hierarchy-aggregation-2026-05-06.md
Normal file
1015
docs/design-hierarchy-aggregation-2026-05-06.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,7 @@ import { renderOnboarding } from "./src/onboarding";
|
||||
import { renderChangelog } from "./src/changelog";
|
||||
import { renderTeam } from "./src/team";
|
||||
import { renderAdmin } from "./src/admin";
|
||||
import { renderInbox } from "./src/inbox";
|
||||
import { renderAdminTeam } from "./src/admin-team";
|
||||
import { renderAdminAuditLog } from "./src/admin-audit-log";
|
||||
import { renderAdminPartnerUnits } from "./src/admin-partner-units";
|
||||
@@ -248,6 +249,7 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/settings.ts"),
|
||||
join(import.meta.dir, "src/client/dashboard.ts"),
|
||||
join(import.meta.dir, "src/client/agenda.ts"),
|
||||
join(import.meta.dir, "src/client/inbox.ts"),
|
||||
join(import.meta.dir, "src/client/onboarding.ts"),
|
||||
join(import.meta.dir, "src/client/changelog.ts"),
|
||||
join(import.meta.dir, "src/client/team.ts"),
|
||||
@@ -360,6 +362,7 @@ async function build() {
|
||||
await Bun.write(join(DIST, "settings.html"), renderSettings());
|
||||
await Bun.write(join(DIST, "dashboard.html"), renderDashboard());
|
||||
await Bun.write(join(DIST, "agenda.html"), renderAgenda());
|
||||
await Bun.write(join(DIST, "inbox.html"), renderInbox());
|
||||
await Bun.write(join(DIST, "onboarding.html"), renderOnboarding());
|
||||
await Bun.write(join(DIST, "changelog.html"), renderChangelog());
|
||||
await Bun.write(join(DIST, "team.html"), renderTeam());
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { initI18n, onLangChange, t, getLang } from "./i18n";
|
||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface PartnerUnit {
|
||||
@@ -16,8 +16,11 @@ interface Member {
|
||||
display_name: string;
|
||||
office: string;
|
||||
job_title: string | null;
|
||||
unit_role: string;
|
||||
}
|
||||
|
||||
const UNIT_ROLES = ["lead", "attorney", "senior_pa", "pa", "paralegal"] as const;
|
||||
|
||||
interface PartnerUnitWithMembers extends PartnerUnit {
|
||||
lead_display_name?: string;
|
||||
lead_email?: string;
|
||||
@@ -284,16 +287,54 @@ function renderMemberList(): void {
|
||||
return;
|
||||
}
|
||||
list.innerHTML = u.members
|
||||
.map(
|
||||
(m) => `<li class="partner-unit-member-item">
|
||||
.map((m) => {
|
||||
const roleOptions = UNIT_ROLES.map((r) => {
|
||||
const label = tDyn(`unit_role.${r}`) || r;
|
||||
const sel = m.unit_role === r ? " selected" : "";
|
||||
return `<option value="${esc(r)}"${sel}>${esc(label)}</option>`;
|
||||
}).join("");
|
||||
return `<li class="partner-unit-member-item">
|
||||
<span>${esc(m.display_name || m.email)} <span class="form-hint">(${esc(m.email)})</span></span>
|
||||
<button type="button" class="btn-ghost btn-small pu-remove-btn" data-user="${esc(m.user_id)}">${esc(t("admin.partner_units.member.remove") || "Entfernen")}</button>
|
||||
</li>`,
|
||||
)
|
||||
<span class="partner-unit-member-actions">
|
||||
<select class="pu-role-select" data-user="${esc(m.user_id)}" aria-label="${escAttr(tDyn("admin.partner_units.member.role") || "Rolle")}">${roleOptions}</select>
|
||||
<button type="button" class="btn-ghost btn-small pu-remove-btn" data-user="${esc(m.user_id)}">${esc(t("admin.partner_units.member.remove") || "Entfernen")}</button>
|
||||
</span>
|
||||
</li>`;
|
||||
})
|
||||
.join("");
|
||||
list.querySelectorAll<HTMLButtonElement>(".pu-remove-btn").forEach((b) =>
|
||||
b.addEventListener("click", () => removeMember(b.dataset.user!)),
|
||||
);
|
||||
list.querySelectorAll<HTMLSelectElement>(".pu-role-select").forEach((s) =>
|
||||
s.addEventListener("change", () => setMemberRole(s.dataset.user!, s.value, s)),
|
||||
);
|
||||
}
|
||||
|
||||
async function setMemberRole(userID: string, role: string, sel: HTMLSelectElement): Promise<void> {
|
||||
if (!activeUnitID) return;
|
||||
// Snapshot the prior selection so we can roll back on failure.
|
||||
const u = units.find((x) => x.id === activeUnitID);
|
||||
const prior = u?.members.find((m) => m.user_id === userID)?.unit_role;
|
||||
sel.disabled = true;
|
||||
const resp = await fetch(
|
||||
`/api/partner-units/${activeUnitID}/members/${userID}/role`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ unit_role: role }),
|
||||
},
|
||||
);
|
||||
sel.disabled = false;
|
||||
if (!resp.ok) {
|
||||
if (prior !== undefined) sel.value = prior;
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
showFeedback(body.error || "Rolle konnte nicht gespeichert werden.", true);
|
||||
return;
|
||||
}
|
||||
await loadUnits();
|
||||
renderMemberList();
|
||||
render();
|
||||
showFeedback(tDyn("admin.partner_units.feedback.role_updated") || "Rolle aktualisiert.", false);
|
||||
}
|
||||
|
||||
function wireSuggestions(): void {
|
||||
|
||||
@@ -23,6 +23,8 @@ interface AgendaItem {
|
||||
project_title?: string | null;
|
||||
project_type?: string | null; // client | litigation | patent | case | project
|
||||
project_reference?: string | null;
|
||||
// Approval workflow (t-paliad-138). "pending" → render the warning pill.
|
||||
approval_status?: "approved" | "pending" | "legacy" | null;
|
||||
}
|
||||
|
||||
interface AgendaPayload {
|
||||
@@ -271,11 +273,15 @@ function expectedUrgency(day: Date): Urgency {
|
||||
function renderItem(it: AgendaItem, bucketUrgency: Urgency): string {
|
||||
const urgencyClass = `agenda-item-${it.urgency}`;
|
||||
const typeClass = `agenda-item-type-${it.type}`;
|
||||
const pendingClass = it.approval_status === "pending" ? " entity-row--pending-update" : "";
|
||||
const iconHTML = it.type === "deadline" ? deadlineIcon() : appointmentIcon();
|
||||
const detailHref = itemDetailHref(it);
|
||||
const project = it.project_id
|
||||
? `<a class="agenda-item-project" href="/projects/${esc(it.project_id)}">${esc(formatProjectLabel(it))}</a>`
|
||||
: "";
|
||||
const pendingPill = it.approval_status === "pending"
|
||||
? `<span class="approval-pill" title="${esc(tDyn("approvals.pending_update.label"))}">${esc(tDyn("approvals.pending_update.label"))}</span>`
|
||||
: "";
|
||||
|
||||
const timePart = it.type === "appointment"
|
||||
? `<span class="agenda-item-time">${esc(formatAppointmentTime(it))}</span>`
|
||||
@@ -291,13 +297,14 @@ function renderItem(it: AgendaItem, bucketUrgency: Urgency): string {
|
||||
: (it.appointment_type ? `agenda.appointment_type.${it.appointment_type}` : "agenda.label.appointment");
|
||||
const typeLabel = tDyn(typeLabelKey);
|
||||
|
||||
return `<li class="agenda-item ${typeClass} ${urgencyClass}">
|
||||
return `<li class="agenda-item ${typeClass} ${urgencyClass}${pendingClass}">
|
||||
<a class="agenda-item-link" href="${esc(detailHref)}">
|
||||
<span class="agenda-item-icon" aria-hidden="true">${iconHTML}</span>
|
||||
<span class="agenda-item-main">
|
||||
<span class="agenda-item-headline">
|
||||
<span class="agenda-item-type-label">${esc(typeLabel)}:</span>
|
||||
<span class="agenda-item-title">${esc(it.title)}</span>
|
||||
${pendingPill}
|
||||
</span>
|
||||
<span class="agenda-item-sub">
|
||||
${project}
|
||||
|
||||
@@ -38,6 +38,9 @@ interface EventListItem {
|
||||
project_title?: string;
|
||||
project_type?: string;
|
||||
|
||||
// Approval workflow (t-paliad-138). "pending" → render the warning pill.
|
||||
approval_status?: "approved" | "pending" | "legacy";
|
||||
|
||||
// deadline-only
|
||||
due_date?: string;
|
||||
status?: string;
|
||||
@@ -504,11 +507,19 @@ function renderRow(item: EventListItem, showReopen: boolean): string {
|
||||
? `<span class="termin-type-chip termin-type-${esc(item.appointment_type)}">${esc(tDyn(`appointments.type.${item.appointment_type}`) || item.appointment_type)}</span>`
|
||||
: "—";
|
||||
|
||||
return `<tr class="frist-row events-row events-row-${item.type}" data-id="${esc(item.id)}" data-type="${item.type}">
|
||||
// Approval pending pill (t-paliad-138). Soft-tint the row + insert a
|
||||
// ⚠ chip next to the title. Generic "pending approval" — the inbox
|
||||
// shows the lifecycle detail.
|
||||
const pendingClass = item.approval_status === "pending" ? " entity-row--pending-update" : "";
|
||||
const pendingPill = item.approval_status === "pending"
|
||||
? `<span class="approval-pill" title="${esc(t("approvals.pending_update.label"))}">${esc(t("approvals.pending_update.label"))}</span>`
|
||||
: "";
|
||||
|
||||
return `<tr class="frist-row events-row events-row-${item.type}${pendingClass}" data-id="${esc(item.id)}" data-type="${item.type}">
|
||||
<td class="frist-col-check">${checkCell}</td>
|
||||
<td class="events-col-row-type">${rowTypeChip(item)}</td>
|
||||
<td class="frist-col-due ${urgency}"><span class="frist-due-dot"></span>${esc(dateLabel)}</td>
|
||||
<td class="frist-col-title ${titleClass}">${esc(item.title)}</td>
|
||||
<td class="frist-col-title ${titleClass}">${esc(item.title)}${pendingPill ? " " + pendingPill : ""}</td>
|
||||
<td class="frist-col-project">${projectCell}</td>
|
||||
<td class="frist-col-rule events-col-rule">${ruleLabel || "—"}</td>
|
||||
<td class="entity-col-event-type">${eventTypeCell || "—"}</td>
|
||||
|
||||
@@ -34,6 +34,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"nav.termine": "Termine",
|
||||
"nav.dashboard": "Dashboard",
|
||||
"nav.agenda": "Agenda",
|
||||
"nav.inbox": "Genehmigungen",
|
||||
"nav.team": "Team",
|
||||
"nav.group.uebersicht": "\u00dcbersicht",
|
||||
"nav.group.arbeit": "Arbeit",
|
||||
@@ -305,7 +306,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.card.calc.add_to_project": "Zu Akte hinzufügen",
|
||||
"deadlines.card.calc.add_to_project.disabled": "Gerichtsbestimmt — manuell anlegen",
|
||||
"deadlines.pathway.fork.heading": "Was möchten Sie tun?",
|
||||
"deadlines.pathway.a.title": "Verfahrensablauf informieren",
|
||||
"deadlines.pathway.a.title": "Verfahrensablauf",
|
||||
"deadlines.pathway.a.desc": "Verfahrenstyp wählen und alle dazugehörigen Fristen auf einer Zeitleiste sehen.",
|
||||
"deadlines.pathway.b.title": "Frist eintragen aufgrund Ereignis",
|
||||
"deadlines.pathway.b.desc": "Ein Ereignis ist eingetreten — ich brauche die richtige Frist für meine Akte.",
|
||||
@@ -648,6 +649,40 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.urgency.later": "Sp\u00e4ter",
|
||||
"deadlines.complete.action": "Erledigen",
|
||||
|
||||
// t-paliad-139 \u2014 subtree aggregation toggle and attribution chip
|
||||
"aggregation.toggle.subtree": "Inkl. Unterprojekte",
|
||||
"aggregation.toggle.direct_only": "Nur direkt",
|
||||
"aggregation.attribution.on": "auf",
|
||||
|
||||
// t-paliad-139 Phase 2 \u2014 Team-tab subsections + Partner Units management
|
||||
"projects.team.section.from_descendants": "Aus Unterprojekten",
|
||||
"projects.team.section.from_descendants.hint": "Personen, die direkt auf einem Unterprojekt eingetragen sind und nicht auf diesem oder einem \u00dcbergeordneten.",
|
||||
"projects.team.section.derived": "Abgeleitet (Partner Unit)",
|
||||
"projects.team.section.derived.hint": "Mitglieder, die \u00fcber eine zugeordnete Partner Unit auf diesem Projekt aktiv sind.",
|
||||
"projects.team.section.units": "Partner Units",
|
||||
"projects.team.section.units.hint": "Partner Units, die auf diesem Projekt eingebunden sind. Mitglieder mit passenden Unit-Rollen werden automatisch abgeleitet.",
|
||||
"projects.team.derived.from": "\u00fcber",
|
||||
"projects.team.derived.visibility": "Sicht",
|
||||
"projects.team.derived.authority": "Sicht & 4-Augen",
|
||||
"projects.team.derived.authority.hint": "Stimmrecht: Abgeleitete Mitglieder z\u00e4hlen als Approver.",
|
||||
"projects.team.units.attach": "Partner Unit zuordnen",
|
||||
"projects.team.units.detach": "Entfernen",
|
||||
"projects.team.units.choose": "Bitte Unit w\u00e4hlen\u2026",
|
||||
"projects.team.units.select": "Unit",
|
||||
"projects.team.units.derive_roles": "Welche Unit-Rollen ableiten?",
|
||||
"projects.team.units.grants_authority": "Stimmrecht abgeben (4-Augen)",
|
||||
"projects.team.units.col.name": "Unit",
|
||||
"projects.team.units.col.derive_roles": "Abgeleitete Rollen",
|
||||
"projects.team.units.col.authority": "Authority",
|
||||
"projects.team.units.members": "Mitglieder",
|
||||
"projects.team.units.empty": "Keine Partner Units zugeordnet.",
|
||||
"projects.team.units.confirm_detach": "Partner Unit entfernen?",
|
||||
"unit_role.lead": "Lead",
|
||||
"unit_role.attorney": "Attorney",
|
||||
"unit_role.senior_pa": "Senior PA",
|
||||
"unit_role.pa": "PA",
|
||||
"unit_role.paralegal": "Paralegal",
|
||||
|
||||
"deadlines.neu.title": "Neue Frist \u2014 Paliad",
|
||||
"deadlines.neu.heading": "Neue Frist anlegen",
|
||||
"deadlines.neu.subtitle": "Eine persistente Frist an einer Akte. Sichtbar f\u00fcr alle Personen, die die Akte sehen k\u00f6nnen.",
|
||||
@@ -820,6 +855,33 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"event.title.appointment_updated": "Termin ge\u00e4ndert",
|
||||
"event.title.appointment_deleted": "Termin gel\u00f6scht",
|
||||
"event.title.appointment_project_changed": "Termin verschoben",
|
||||
// 4-eye approval lifecycle (t-paliad-138). Verlauf renders these as
|
||||
// a paired card with the original lifecycle event (e.g.
|
||||
// "Frist angelegt" + "Genehmigung erteilt von Bert").
|
||||
"event.title.deadline_approval_requested": "Genehmigung beantragt",
|
||||
"event.title.deadline_approval_approved": "Genehmigung erteilt",
|
||||
"event.title.deadline_approval_rejected": "Genehmigung abgelehnt",
|
||||
"event.title.deadline_approval_revoked": "Anfrage zurückgezogen",
|
||||
"event.title.appointment_approval_requested": "Genehmigung beantragt",
|
||||
"event.title.appointment_approval_approved": "Genehmigung erteilt",
|
||||
"event.title.appointment_approval_rejected": "Genehmigung abgelehnt",
|
||||
"event.title.appointment_approval_revoked": "Anfrage zurückgezogen",
|
||||
"event.description.deadline_approval_requested": "4-Augen-Genehmigung für Frist beantragt",
|
||||
"event.description.deadline_approval_approved": "Genehmigung für Frist erteilt",
|
||||
"event.description.deadline_approval_rejected": "Genehmigung für Frist abgelehnt",
|
||||
"event.description.deadline_approval_revoked": "Genehmigungsanfrage für Frist zurückgezogen",
|
||||
"event.description.appointment_approval_requested": "4-Augen-Genehmigung für Termin beantragt",
|
||||
"event.description.appointment_approval_approved": "Genehmigung für Termin erteilt",
|
||||
"event.description.appointment_approval_rejected": "Genehmigung für Termin abgelehnt",
|
||||
"event.description.appointment_approval_revoked": "Genehmigungsanfrage für Termin zurückgezogen",
|
||||
"dashboard.action.short.deadline_approval_requested": "beantragte Genehmigung",
|
||||
"dashboard.action.short.deadline_approval_approved": "genehmigte Frist",
|
||||
"dashboard.action.short.deadline_approval_rejected": "lehnte Frist ab",
|
||||
"dashboard.action.short.deadline_approval_revoked": "zog Anfrage zurück",
|
||||
"dashboard.action.short.appointment_approval_requested": "beantragte Genehmigung",
|
||||
"dashboard.action.short.appointment_approval_approved": "genehmigte Termin",
|
||||
"dashboard.action.short.appointment_approval_rejected": "lehnte Termin ab",
|
||||
"dashboard.action.short.appointment_approval_revoked": "zog Anfrage zurück",
|
||||
"event.title.checklist_created": "Checkliste angelegt",
|
||||
"event.title.checklist_renamed": "Checkliste umbenannt",
|
||||
"event.title.checklist_linked": "Checkliste verkn\u00fcpft",
|
||||
@@ -1088,6 +1150,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.team.confirm_remove": "Mitglied entfernen?",
|
||||
"projects.detail.team.empty": "Noch keine Teammitglieder.",
|
||||
"projects.detail.team.error.user_required": "Benutzer ausw\u00e4hlen",
|
||||
"projects.detail.team.invite.hint": "Benutzer nicht gefunden?",
|
||||
"projects.detail.team.invite.hint_email": "Niemand mit dieser E-Mail.",
|
||||
"projects.detail.team.invite.cta": "Einladen",
|
||||
"projects.view.tree": "Baumansicht",
|
||||
"projects.tree.toggle": "Aufklappen / Zuklappen",
|
||||
"projects.tree.loading": "Baum wird geladen\u2026",
|
||||
@@ -1479,6 +1544,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.partner_units.feedback.created": "Angelegt.",
|
||||
"admin.partner_units.feedback.updated": "Aktualisiert.",
|
||||
"admin.partner_units.feedback.deleted": "Gelöscht.",
|
||||
"admin.partner_units.feedback.role_updated": "Rolle aktualisiert.",
|
||||
"admin.partner_units.member.heading": "Mitglieder verwalten",
|
||||
"admin.partner_units.member.empty": "Noch keine Mitglieder.",
|
||||
"admin.partner_units.member.add": "Mitglied hinzufügen",
|
||||
@@ -1486,6 +1552,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.partner_units.member.remove": "Entfernen",
|
||||
"admin.partner_units.member.confirm_remove": "Mitglied entfernen?",
|
||||
"admin.partner_units.member.placeholder": "Name oder E-Mail",
|
||||
"admin.partner_units.member.role": "Rolle",
|
||||
"admin.audit.loading": "Lade…",
|
||||
"admin.audit.empty": "Keine Ereignisse für die gewählten Filter.",
|
||||
"admin.audit.loadmore": "Weitere laden",
|
||||
@@ -1619,6 +1686,59 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.event_types.merge.title": "Typen zusammenführen",
|
||||
"admin.event_types.merge.body": "Wählen Sie den Gewinner-Typ. Die Junction-Einträge der Verlierer werden auf den Gewinner umgeleitet, anschließend werden die Verlierer archiviert.",
|
||||
"admin.event_types.merge.submit": "Zusammenführen",
|
||||
|
||||
// Approval workflow (t-paliad-138).
|
||||
"approvals.title": "Genehmigungen",
|
||||
"approvals.heading": "Genehmigungen",
|
||||
"approvals.subtitle": "4-Augen-Prüfung für Fristen und Termine.",
|
||||
"approvals.tab.pending_mine": "Zur Genehmigung",
|
||||
"approvals.tab.mine": "Meine Anfragen",
|
||||
"approvals.empty.pending_mine": "Aktuell nichts zu genehmigen.",
|
||||
"approvals.empty.mine": "Sie haben keine offenen Anfragen.",
|
||||
"approvals.lifecycle.create": "Erstellung",
|
||||
"approvals.lifecycle.update": "Änderung",
|
||||
"approvals.lifecycle.complete": "Erledigung",
|
||||
"approvals.lifecycle.delete": "Löschung",
|
||||
"approvals.entity.deadline": "Frist",
|
||||
"approvals.entity.appointment": "Termin",
|
||||
"approvals.required_role.lead": "Lead",
|
||||
"approvals.required_role.of_counsel": "Of Counsel",
|
||||
"approvals.required_role.associate": "Associate",
|
||||
"approvals.required_role.senior_pa": "Senior PA",
|
||||
"approvals.required_role.pa": "PA",
|
||||
"approvals.status.pending": "Offen",
|
||||
"approvals.status.approved": "Genehmigt",
|
||||
"approvals.status.rejected": "Abgelehnt",
|
||||
"approvals.status.revoked": "Zurückgezogen",
|
||||
"approvals.status.superseded": "Ersetzt",
|
||||
"approvals.action.approve": "Genehmigen",
|
||||
"approvals.action.reject": "Ablehnen",
|
||||
"approvals.action.revoke": "Zurückziehen",
|
||||
"approvals.note.placeholder": "Optionale Begründung...",
|
||||
"approvals.requested_by": "Eingereicht von",
|
||||
"approvals.decided_by": "Entschieden von",
|
||||
"approvals.decision_kind.peer": "Genehmigt durch Teammitglied",
|
||||
"approvals.decision_kind.admin_override": "Admin-Sign-off",
|
||||
"approvals.decision_kind.derived_peer": "Genehmigt durch abgeleitetes Mitglied (Partner Unit)",
|
||||
"approvals.error.self_approval": "Eigengenehmigung nicht zulässig.",
|
||||
"approvals.error.not_authorized": "Sie haben nicht die erforderliche Rolle.",
|
||||
"approvals.error.no_qualified_approver": "Kein qualifizierter Approver verfügbar — bitte einen Approver ins Projekt-Team aufnehmen oder Admin kontaktieren.",
|
||||
"approvals.error.concurrent_pending": "Es liegt bereits eine Genehmigungsanfrage auf diesem Eintrag vor.",
|
||||
"approvals.error.request_not_pending": "Diese Anfrage ist nicht mehr offen.",
|
||||
"approvals.pending_create.label": "Erstellung wartet auf Genehmigung",
|
||||
"approvals.pending_update.label": "Änderung wartet auf Genehmigung",
|
||||
"approvals.pending_complete.label": "Erledigung wartet auf Genehmigung",
|
||||
"approvals.pending_delete.label": "Zur Löschung beantragt",
|
||||
"approvals.diff.before": "Vorher",
|
||||
"approvals.diff.after": "Nachher",
|
||||
"approvals.policies.title": "Genehmigungsrichtlinien",
|
||||
"approvals.policies.subtitle": "Welche Lebenszyklus-Schritte benötigen 4-Augen-Prüfung in diesem Projekt?",
|
||||
"approvals.policies.column.event": "Ereignis",
|
||||
"approvals.policies.column.deadline": "Frist",
|
||||
"approvals.policies.column.appointment": "Termin",
|
||||
"approvals.policies.no_approval": "Keine Genehmigung erforderlich",
|
||||
"approvals.policies.copy_parent": "Aus Eltern-Projekt übernehmen",
|
||||
"approvals.policies.set_all_associate": "Alle auf Associate setzen",
|
||||
},
|
||||
|
||||
en: {
|
||||
@@ -1639,6 +1759,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"nav.termine": "Appointments",
|
||||
"nav.dashboard": "Dashboard",
|
||||
"nav.agenda": "Agenda",
|
||||
"nav.inbox": "Approvals",
|
||||
"nav.team": "Team",
|
||||
"nav.group.uebersicht": "Overview",
|
||||
"nav.group.arbeit": "Work",
|
||||
@@ -2250,6 +2371,40 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.urgency.later": "Later",
|
||||
"deadlines.complete.action": "Complete",
|
||||
|
||||
// t-paliad-139 \u2014 subtree aggregation toggle and attribution chip
|
||||
"aggregation.toggle.subtree": "Incl. sub-projects",
|
||||
"aggregation.toggle.direct_only": "Direct only",
|
||||
"aggregation.attribution.on": "on",
|
||||
|
||||
// t-paliad-139 Phase 2 \u2014 Team-tab subsections + Partner Units management
|
||||
"projects.team.section.from_descendants": "From sub-projects",
|
||||
"projects.team.section.from_descendants.hint": "People directly staffed on a sub-project who are not on this project or an ancestor.",
|
||||
"projects.team.section.derived": "Derived (Partner Unit)",
|
||||
"projects.team.section.derived.hint": "Members active on this project via an attached partner unit.",
|
||||
"projects.team.section.units": "Partner Units",
|
||||
"projects.team.section.units.hint": "Partner units attached to this project. Members in the listed unit roles auto-derive.",
|
||||
"projects.team.derived.from": "via",
|
||||
"projects.team.derived.visibility": "View",
|
||||
"projects.team.derived.authority": "View & 4-eye",
|
||||
"projects.team.derived.authority.hint": "Authority: derived members count as approvers.",
|
||||
"projects.team.units.attach": "Attach partner unit",
|
||||
"projects.team.units.detach": "Remove",
|
||||
"projects.team.units.choose": "Select a unit\u2026",
|
||||
"projects.team.units.select": "Unit",
|
||||
"projects.team.units.derive_roles": "Which unit roles should derive?",
|
||||
"projects.team.units.grants_authority": "Grant authority (4-eye)",
|
||||
"projects.team.units.col.name": "Unit",
|
||||
"projects.team.units.col.derive_roles": "Derived roles",
|
||||
"projects.team.units.col.authority": "Authority",
|
||||
"projects.team.units.members": "members",
|
||||
"projects.team.units.empty": "No partner units attached.",
|
||||
"projects.team.units.confirm_detach": "Remove partner unit?",
|
||||
"unit_role.lead": "Lead",
|
||||
"unit_role.attorney": "Attorney",
|
||||
"unit_role.senior_pa": "Senior PA",
|
||||
"unit_role.pa": "PA",
|
||||
"unit_role.paralegal": "Paralegal",
|
||||
|
||||
"deadlines.neu.title": "New deadline \u2014 Paliad",
|
||||
"deadlines.neu.heading": "Create new deadline",
|
||||
"deadlines.neu.subtitle": "A persistent deadline attached to a matter. Visible to anyone who can see that matter.",
|
||||
@@ -2418,6 +2573,31 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"event.title.appointment_updated": "Appointment updated",
|
||||
"event.title.appointment_deleted": "Appointment deleted",
|
||||
"event.title.appointment_project_changed": "Appointment moved",
|
||||
// 4-eye approval lifecycle (t-paliad-138).
|
||||
"event.title.deadline_approval_requested": "Approval requested",
|
||||
"event.title.deadline_approval_approved": "Approval granted",
|
||||
"event.title.deadline_approval_rejected": "Approval rejected",
|
||||
"event.title.deadline_approval_revoked": "Request revoked",
|
||||
"event.title.appointment_approval_requested": "Approval requested",
|
||||
"event.title.appointment_approval_approved": "Approval granted",
|
||||
"event.title.appointment_approval_rejected": "Approval rejected",
|
||||
"event.title.appointment_approval_revoked": "Request revoked",
|
||||
"event.description.deadline_approval_requested": "Four-eyes approval requested for deadline",
|
||||
"event.description.deadline_approval_approved": "Deadline approval granted",
|
||||
"event.description.deadline_approval_rejected": "Deadline approval rejected",
|
||||
"event.description.deadline_approval_revoked": "Deadline approval request revoked",
|
||||
"event.description.appointment_approval_requested": "Four-eyes approval requested for appointment",
|
||||
"event.description.appointment_approval_approved": "Appointment approval granted",
|
||||
"event.description.appointment_approval_rejected": "Appointment approval rejected",
|
||||
"event.description.appointment_approval_revoked": "Appointment approval request revoked",
|
||||
"dashboard.action.short.deadline_approval_requested": "requested approval",
|
||||
"dashboard.action.short.deadline_approval_approved": "approved deadline",
|
||||
"dashboard.action.short.deadline_approval_rejected": "rejected deadline",
|
||||
"dashboard.action.short.deadline_approval_revoked": "revoked request",
|
||||
"dashboard.action.short.appointment_approval_requested": "requested approval",
|
||||
"dashboard.action.short.appointment_approval_approved": "approved appointment",
|
||||
"dashboard.action.short.appointment_approval_rejected": "rejected appointment",
|
||||
"dashboard.action.short.appointment_approval_revoked": "revoked request",
|
||||
"event.title.checklist_created": "Checklist created",
|
||||
"event.title.checklist_renamed": "Checklist renamed",
|
||||
"event.title.checklist_linked": "Checklist linked",
|
||||
@@ -2686,6 +2866,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.team.confirm_remove": "Remove member?",
|
||||
"projects.detail.team.empty": "No team members yet.",
|
||||
"projects.detail.team.error.user_required": "Select a user",
|
||||
"projects.detail.team.invite.hint": "User not found?",
|
||||
"projects.detail.team.invite.hint_email": "No one with that email.",
|
||||
"projects.detail.team.invite.cta": "Invite",
|
||||
"projects.view.tree": "Tree view",
|
||||
"projects.tree.toggle": "Expand / collapse",
|
||||
"projects.tree.loading": "Loading tree…",
|
||||
@@ -3074,6 +3257,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.partner_units.feedback.created": "Created.",
|
||||
"admin.partner_units.feedback.updated": "Updated.",
|
||||
"admin.partner_units.feedback.deleted": "Deleted.",
|
||||
"admin.partner_units.feedback.role_updated": "Role updated.",
|
||||
"admin.partner_units.member.heading": "Manage members",
|
||||
"admin.partner_units.member.empty": "No members yet.",
|
||||
"admin.partner_units.member.add": "Add member",
|
||||
@@ -3081,6 +3265,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.partner_units.member.remove": "Remove",
|
||||
"admin.partner_units.member.confirm_remove": "Remove member?",
|
||||
"admin.partner_units.member.placeholder": "Name or email",
|
||||
"admin.partner_units.member.role": "Role",
|
||||
"admin.audit.loading": "Loading…",
|
||||
"admin.audit.empty": "No events match the selected filters.",
|
||||
"admin.audit.loadmore": "Load more",
|
||||
@@ -3214,6 +3399,59 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.event_types.merge.title": "Merge types",
|
||||
"admin.event_types.merge.body": "Pick the winner. Loser junction rows get redirected to the winner, then the losers are archived.",
|
||||
"admin.event_types.merge.submit": "Merge",
|
||||
|
||||
// Approval workflow (t-paliad-138).
|
||||
"approvals.title": "Approvals",
|
||||
"approvals.heading": "Approvals",
|
||||
"approvals.subtitle": "Four-eyes review for deadlines and appointments.",
|
||||
"approvals.tab.pending_mine": "Awaiting approval",
|
||||
"approvals.tab.mine": "My requests",
|
||||
"approvals.empty.pending_mine": "Nothing awaits your approval.",
|
||||
"approvals.empty.mine": "You have no open requests.",
|
||||
"approvals.lifecycle.create": "Creation",
|
||||
"approvals.lifecycle.update": "Change",
|
||||
"approvals.lifecycle.complete": "Completion",
|
||||
"approvals.lifecycle.delete": "Deletion",
|
||||
"approvals.entity.deadline": "Deadline",
|
||||
"approvals.entity.appointment": "Appointment",
|
||||
"approvals.required_role.lead": "Lead",
|
||||
"approvals.required_role.of_counsel": "Of Counsel",
|
||||
"approvals.required_role.associate": "Associate",
|
||||
"approvals.required_role.senior_pa": "Senior PA",
|
||||
"approvals.required_role.pa": "PA",
|
||||
"approvals.status.pending": "Open",
|
||||
"approvals.status.approved": "Approved",
|
||||
"approvals.status.rejected": "Rejected",
|
||||
"approvals.status.revoked": "Revoked",
|
||||
"approvals.status.superseded": "Superseded",
|
||||
"approvals.action.approve": "Approve",
|
||||
"approvals.action.reject": "Reject",
|
||||
"approvals.action.revoke": "Revoke",
|
||||
"approvals.note.placeholder": "Optional note...",
|
||||
"approvals.requested_by": "Submitted by",
|
||||
"approvals.decided_by": "Decided by",
|
||||
"approvals.decision_kind.peer": "Peer approval",
|
||||
"approvals.decision_kind.admin_override": "Admin override",
|
||||
"approvals.decision_kind.derived_peer": "Approved by derived member (Partner Unit)",
|
||||
"approvals.error.self_approval": "You cannot approve your own request.",
|
||||
"approvals.error.not_authorized": "You don't have the required role.",
|
||||
"approvals.error.no_qualified_approver": "No qualified approver available — please add an approver to the project team or contact an admin.",
|
||||
"approvals.error.concurrent_pending": "Another approval request is already in flight on this entity.",
|
||||
"approvals.error.request_not_pending": "This request is no longer open.",
|
||||
"approvals.pending_create.label": "Awaits approval (creation)",
|
||||
"approvals.pending_update.label": "Awaits approval (change)",
|
||||
"approvals.pending_complete.label": "Awaits approval (completion)",
|
||||
"approvals.pending_delete.label": "Awaits approval (deletion)",
|
||||
"approvals.diff.before": "Before",
|
||||
"approvals.diff.after": "After",
|
||||
"approvals.policies.title": "Approval policies",
|
||||
"approvals.policies.subtitle": "Which lifecycle events need four-eyes review on this project?",
|
||||
"approvals.policies.column.event": "Event",
|
||||
"approvals.policies.column.deadline": "Deadline",
|
||||
"approvals.policies.column.appointment": "Appointment",
|
||||
"approvals.policies.no_approval": "No approval needed",
|
||||
"approvals.policies.copy_parent": "Copy from parent project",
|
||||
"approvals.policies.set_all_associate": "Set all to Associate",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
279
frontend/src/client/inbox.ts
Normal file
279
frontend/src/client/inbox.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { initI18n, t, getLang, type I18nKey } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// /inbox client. Two tabs (pending-mine / mine), action buttons (approve /
|
||||
// reject / revoke), and a small inline diff for update / complete / delete
|
||||
// lifecycle events.
|
||||
//
|
||||
// State is URL-driven via ?tab= so back/forward buttons work and the bell
|
||||
// badge can deep-link to either tab. The badge in the sidebar (id
|
||||
// sidebar-inbox-badge) is updated by the shared global polling loop in
|
||||
// sidebar.ts; this module just keeps the page content in sync.
|
||||
|
||||
type Lifecycle = "create" | "update" | "complete" | "delete";
|
||||
type RequestStatus = "pending" | "approved" | "rejected" | "revoked" | "superseded";
|
||||
type DecisionKind = "peer" | "admin_override";
|
||||
|
||||
interface ApprovalRequestView {
|
||||
id: string;
|
||||
project_id: string;
|
||||
project_title: string;
|
||||
entity_type: "deadline" | "appointment";
|
||||
entity_id: string;
|
||||
entity_title?: string;
|
||||
lifecycle_event: Lifecycle;
|
||||
pre_image?: Record<string, unknown> | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
required_role: string;
|
||||
status: RequestStatus;
|
||||
requested_at: string;
|
||||
requested_by: string;
|
||||
requester_name: string;
|
||||
decided_at?: string;
|
||||
decided_by?: string;
|
||||
decider_name?: string;
|
||||
decision_kind?: DecisionKind;
|
||||
decision_note?: string;
|
||||
}
|
||||
|
||||
type Tab = "pending-mine" | "mine";
|
||||
|
||||
let currentTab: Tab = "pending-mine";
|
||||
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const url = new URL(window.location.href);
|
||||
const t = url.searchParams.get("tab");
|
||||
if (t === "mine") currentTab = "mine";
|
||||
bindTabs();
|
||||
refresh();
|
||||
});
|
||||
|
||||
function bindTabs() {
|
||||
document.querySelectorAll<HTMLButtonElement>("#inbox-tab-row [data-tab]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const tab = (btn.dataset.tab as Tab) || "pending-mine";
|
||||
if (tab === currentTab) return;
|
||||
currentTab = tab;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", tab);
|
||||
history.replaceState({}, "", url.toString());
|
||||
document.querySelectorAll<HTMLButtonElement>("#inbox-tab-row [data-tab]").forEach((b) => {
|
||||
b.classList.toggle("active", b.dataset.tab === tab);
|
||||
});
|
||||
refresh();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const loading = document.getElementById("inbox-loading") as HTMLElement | null;
|
||||
const empty = document.getElementById("inbox-empty") as HTMLElement | null;
|
||||
const list = document.getElementById("inbox-list") as HTMLUListElement | null;
|
||||
if (!loading || !empty || !list) return;
|
||||
loading.style.display = "";
|
||||
empty.style.display = "none";
|
||||
list.innerHTML = "";
|
||||
const path = currentTab === "pending-mine" ? "/api/inbox/pending-mine" : "/api/inbox/mine";
|
||||
let rows: ApprovalRequestView[] = [];
|
||||
try {
|
||||
const r = await fetch(path, { credentials: "include" });
|
||||
if (r.ok) rows = (await r.json()) as ApprovalRequestView[];
|
||||
} catch (_e) {
|
||||
// Network errors fall through to empty render.
|
||||
}
|
||||
loading.style.display = "none";
|
||||
if (rows.length === 0) {
|
||||
empty.textContent = t(
|
||||
currentTab === "pending-mine"
|
||||
? "approvals.empty.pending_mine"
|
||||
: "approvals.empty.mine"
|
||||
);
|
||||
empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
for (const row of rows) list.appendChild(renderRow(row));
|
||||
}
|
||||
|
||||
function renderRow(row: ApprovalRequestView): HTMLLIElement {
|
||||
const li = document.createElement("li");
|
||||
li.className = "inbox-row";
|
||||
|
||||
// Header: project / entity / lifecycle / required-role
|
||||
const head = document.createElement("div");
|
||||
head.className = "inbox-row-head";
|
||||
const title = document.createElement("div");
|
||||
title.className = "inbox-row-title";
|
||||
const entityLabel = t(("approvals.entity." + row.entity_type) as I18nKey);
|
||||
const lifecycleLabel = t(("approvals.lifecycle." + row.lifecycle_event) as I18nKey);
|
||||
const entityTitle = row.entity_title || "—";
|
||||
title.textContent = `${entityLabel}: ${entityTitle} — ${lifecycleLabel}`;
|
||||
head.appendChild(title);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "inbox-row-meta";
|
||||
const reqByLabel = t("approvals.requested_by");
|
||||
const roleLabel = t(("approvals.required_role." + row.required_role) as I18nKey);
|
||||
meta.textContent = `${row.project_title} · ${reqByLabel} ${row.requester_name} · ${roleLabel}+ · ${formatRelativeTime(row.requested_at)}`;
|
||||
head.appendChild(meta);
|
||||
li.appendChild(head);
|
||||
|
||||
// Diff for update / complete (date-bearing fields)
|
||||
const diff = renderDiff(row);
|
||||
if (diff) li.appendChild(diff);
|
||||
|
||||
// Decision note if any
|
||||
if (row.decision_note) {
|
||||
const note = document.createElement("div");
|
||||
note.className = "inbox-row-note";
|
||||
note.textContent = row.decision_note;
|
||||
li.appendChild(note);
|
||||
}
|
||||
|
||||
// Action row
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "inbox-row-actions";
|
||||
|
||||
if (row.status === "pending" && currentTab === "pending-mine") {
|
||||
actions.appendChild(actionButton("approve", row.id, () => doDecision(row.id, "approve")));
|
||||
actions.appendChild(actionButton("reject", row.id, () => doDecision(row.id, "reject")));
|
||||
} else if (row.status === "pending" && currentTab === "mine") {
|
||||
actions.appendChild(actionButton("revoke", row.id, () => doDecision(row.id, "revoke")));
|
||||
} else {
|
||||
// historic — show status pill
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "approval-pill approval-pill--historic";
|
||||
pill.textContent = t(("approvals.status." + row.status) as I18nKey);
|
||||
if (row.decider_name && row.status !== "revoked") {
|
||||
const decided = document.createElement("span");
|
||||
decided.className = "inbox-row-decided";
|
||||
decided.textContent = ` · ${t("approvals.decided_by")} ${row.decider_name}`;
|
||||
pill.appendChild(decided);
|
||||
}
|
||||
actions.appendChild(pill);
|
||||
}
|
||||
li.appendChild(actions);
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
function renderDiff(row: ApprovalRequestView): HTMLElement | null {
|
||||
const before = (row.pre_image || {}) as Record<string, unknown>;
|
||||
const after = (row.payload || {}) as Record<string, unknown>;
|
||||
const keys = Array.from(new Set([...Object.keys(before), ...Object.keys(after)]));
|
||||
if (keys.length === 0) return null;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "inbox-row-diff";
|
||||
for (const k of keys) {
|
||||
const line = document.createElement("div");
|
||||
line.className = "inbox-row-diff-line";
|
||||
const label = document.createElement("span");
|
||||
label.className = "inbox-row-diff-key";
|
||||
label.textContent = k;
|
||||
line.appendChild(label);
|
||||
const span = document.createElement("span");
|
||||
span.className = "inbox-row-diff-values";
|
||||
const fmt = (v: unknown) =>
|
||||
v === null || v === undefined ? "—" : String(v);
|
||||
if (k in before && k in after) {
|
||||
span.textContent = `${fmt(before[k])} → ${fmt(after[k])}`;
|
||||
} else if (k in before) {
|
||||
span.textContent = `${t("approvals.diff.before")}: ${fmt(before[k])}`;
|
||||
} else {
|
||||
span.textContent = `${t("approvals.diff.after")}: ${fmt(after[k])}`;
|
||||
}
|
||||
line.appendChild(span);
|
||||
wrap.appendChild(line);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function actionButton(action: "approve" | "reject" | "revoke", _requestID: string, onClick: () => void): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = `btn btn-${action === "approve" ? "primary" : action === "reject" ? "danger" : "secondary"} inbox-row-action`;
|
||||
btn.textContent = t(("approvals.action." + action) as I18nKey);
|
||||
btn.addEventListener("click", onClick);
|
||||
return btn;
|
||||
}
|
||||
|
||||
async function doDecision(requestID: string, action: "approve" | "reject" | "revoke") {
|
||||
let note = "";
|
||||
if (action === "reject") {
|
||||
note = window.prompt(t("approvals.note.placeholder")) || "";
|
||||
}
|
||||
let r: Response;
|
||||
try {
|
||||
r = await fetch(`/api/approval-requests/${requestID}/${action}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ note }),
|
||||
});
|
||||
} catch (_e) {
|
||||
alert("Network error");
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
const body = await r.json().catch(() => ({}));
|
||||
const errKey = (body && body.error) || "internal";
|
||||
const msg = mapApprovalError(errKey);
|
||||
alert(msg);
|
||||
return;
|
||||
}
|
||||
refresh();
|
||||
// Update sidebar bell count.
|
||||
refreshInboxBadge();
|
||||
}
|
||||
|
||||
function mapApprovalError(key: string): string {
|
||||
switch (key) {
|
||||
case "self_approval_blocked":
|
||||
return t("approvals.error.self_approval");
|
||||
case "no_qualified_approver":
|
||||
return t("approvals.error.no_qualified_approver");
|
||||
case "concurrent_pending":
|
||||
return t("approvals.error.concurrent_pending");
|
||||
case "not_authorized":
|
||||
return t("approvals.error.not_authorized");
|
||||
case "request_not_pending":
|
||||
return t("approvals.error.request_not_pending");
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelativeTime(iso: string): string {
|
||||
const t0 = Date.parse(iso);
|
||||
if (isNaN(t0)) return iso;
|
||||
const diffMs = Date.now() - t0;
|
||||
const sec = Math.floor(diffMs / 1000);
|
||||
if (sec < 60) return getLang() === "de" ? `vor ${sec}s` : `${sec}s ago`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return getLang() === "de" ? `vor ${min}m` : `${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return getLang() === "de" ? `vor ${hr}h` : `${hr}h ago`;
|
||||
const day = Math.floor(hr / 24);
|
||||
return getLang() === "de" ? `vor ${day}d` : `${day}d ago`;
|
||||
}
|
||||
|
||||
// Update the sidebar inbox badge (shared with sidebar.ts polling).
|
||||
async function refreshInboxBadge() {
|
||||
const badge = document.getElementById("sidebar-inbox-badge");
|
||||
if (!badge) return;
|
||||
try {
|
||||
const r = await fetch("/api/inbox/count", { credentials: "include" });
|
||||
if (!r.ok) return;
|
||||
const data = (await r.json()) as { count: number };
|
||||
if (data.count > 0) {
|
||||
badge.textContent = String(data.count);
|
||||
badge.style.display = "";
|
||||
} else {
|
||||
badge.style.display = "none";
|
||||
}
|
||||
} catch (_e) {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,35 @@ interface ProjectTeamMember {
|
||||
inherited_from_title?: string | null;
|
||||
}
|
||||
|
||||
// t-paliad-139 — derived team member from a partner-unit attachment.
|
||||
// One DerivedMember per user; users in multiple attached units carry one
|
||||
// DerivedMembership per (unit, role) pair so the Herkunft column can list
|
||||
// every source (t-paliad-143).
|
||||
interface DerivedMembership {
|
||||
unit_id: string;
|
||||
unit_name: string;
|
||||
unit_role: string;
|
||||
}
|
||||
|
||||
interface DerivedMember {
|
||||
user_id: string;
|
||||
user_email: string;
|
||||
user_display_name: string;
|
||||
user_office: string;
|
||||
memberships: DerivedMembership[];
|
||||
derive_grants_authority: boolean;
|
||||
}
|
||||
|
||||
// t-paliad-139 — partner unit attached to this project.
|
||||
interface AttachedUnit {
|
||||
project_id: string;
|
||||
partner_unit_id: string;
|
||||
unit_name: string;
|
||||
derive_unit_roles: string[];
|
||||
derive_grants_authority: boolean;
|
||||
derived_member_count: number;
|
||||
}
|
||||
|
||||
interface ProjectMini {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -71,6 +100,10 @@ interface ProjectEvent {
|
||||
created_at: string;
|
||||
created_by?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
// Populated only when the response was joined to paliad.projects (Verlauf
|
||||
// subtree-aggregating queries on /projects/{id}, t-paliad-139). Used to
|
||||
// render the attribution chip when the event lives on a descendant.
|
||||
project_title?: string;
|
||||
}
|
||||
|
||||
interface Deadline {
|
||||
@@ -81,6 +114,10 @@ interface Deadline {
|
||||
status: string;
|
||||
rule_id?: string;
|
||||
rule_code?: string;
|
||||
// Populated by the union endpoint (/api/events) which is what the project
|
||||
// detail page calls — used for attribution when the row lives on a
|
||||
// descendant project (t-paliad-139).
|
||||
project_title?: string;
|
||||
}
|
||||
|
||||
interface Appointment {
|
||||
@@ -91,6 +128,7 @@ interface Appointment {
|
||||
end_at?: string;
|
||||
location?: string;
|
||||
appointment_type?: string;
|
||||
project_title?: string;
|
||||
}
|
||||
|
||||
interface Me {
|
||||
@@ -161,12 +199,46 @@ let appointments: Appointment[] = [];
|
||||
let ancestors: ProjectMini[] = [];
|
||||
let children: ProjectMini[] = [];
|
||||
let teamMembers: ProjectTeamMember[] = [];
|
||||
// t-paliad-139 — additional Team-tab sections.
|
||||
let descendantStaffed: ProjectTeamMember[] = [];
|
||||
let derivedMembers: DerivedMember[] = [];
|
||||
let attachedUnits: AttachedUnit[] = [];
|
||||
let allUnits: { id: string; name: string; office: string }[] = [];
|
||||
let userOptions: { id: string; display_name: string; email: string }[] = [];
|
||||
|
||||
const EVENTS_PAGE_SIZE = 50;
|
||||
let eventsHasMore = false;
|
||||
let eventsLoadingMore = false;
|
||||
|
||||
// Subtree aggregation mode (t-paliad-139). Default true → Fristen, Termine,
|
||||
// Verlauf show rows from this project AND all descendant projects with an
|
||||
// attribution chip per non-direct row. URL param `?subtree=false` flips to
|
||||
// narrow (this project's own rows only).
|
||||
let subtreeMode: boolean = true;
|
||||
|
||||
function parseSubtreeMode(): boolean {
|
||||
try {
|
||||
const raw = new URLSearchParams(window.location.search).get("subtree");
|
||||
return raw !== "false";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function persistSubtreeMode() {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
if (subtreeMode) {
|
||||
url.searchParams.delete("subtree");
|
||||
} else {
|
||||
url.searchParams.set("subtree", "false");
|
||||
}
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function parseProjectID(): string | null {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
if (parts[0] !== "projects" || !parts[1]) return null;
|
||||
@@ -211,9 +283,18 @@ async function loadParties(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Build a query string suffix conveying the current subtree mode. The
|
||||
// backend defaults to subtree (direct_only=false), so we only emit the
|
||||
// param when the user has flipped to direct.
|
||||
function subtreeParam(): string {
|
||||
return subtreeMode ? "" : "&direct_only=true";
|
||||
}
|
||||
|
||||
async function loadEvents(id: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${id}/events?limit=${EVENTS_PAGE_SIZE}`);
|
||||
const resp = await fetch(
|
||||
`/api/projects/${id}/events?limit=${EVENTS_PAGE_SIZE}${subtreeParam()}`,
|
||||
);
|
||||
if (resp.ok) {
|
||||
events = (await resp.json()) ?? [];
|
||||
eventsHasMore = events.length === EVENTS_PAGE_SIZE;
|
||||
@@ -238,7 +319,7 @@ async function loadMoreEvents(id: string) {
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/projects/${id}/events?before=${encodeURIComponent(cursor)}&limit=${EVENTS_PAGE_SIZE}`,
|
||||
`/api/projects/${id}/events?before=${encodeURIComponent(cursor)}&limit=${EVENTS_PAGE_SIZE}${subtreeParam()}`,
|
||||
);
|
||||
if (resp.ok) {
|
||||
const page: ProjectEvent[] = await resp.json();
|
||||
@@ -257,10 +338,50 @@ async function loadMoreEvents(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Shape returned by /api/events — matches EventListItem in
|
||||
// frontend/src/client/events.ts. Only the fields projects-detail needs.
|
||||
interface UnionEvent {
|
||||
type: "deadline" | "appointment";
|
||||
id: string;
|
||||
title: string;
|
||||
project_id?: string;
|
||||
project_title?: string;
|
||||
due_date?: string;
|
||||
status?: string;
|
||||
rule_id?: string;
|
||||
rule_code?: string;
|
||||
start_at?: string;
|
||||
end_at?: string;
|
||||
location?: string;
|
||||
appointment_type?: string;
|
||||
}
|
||||
|
||||
async function loadDeadlines(id: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${id}/deadlines`);
|
||||
if (resp.ok) deadlines = (await resp.json()) ?? [];
|
||||
// t-paliad-139: switched from /api/projects/{id}/deadlines (legacy
|
||||
// narrow path) to the union endpoint, which already aggregates
|
||||
// descendants and enriches each row with project_title for the
|
||||
// attribution chip.
|
||||
const resp = await fetch(
|
||||
`/api/events?type=deadline&project_id=${encodeURIComponent(id)}${subtreeParam()}`,
|
||||
);
|
||||
if (resp.ok) {
|
||||
const items: UnionEvent[] = (await resp.json()) ?? [];
|
||||
deadlines = items
|
||||
.filter((it) => it.type === "deadline")
|
||||
.map((it) => ({
|
||||
id: it.id,
|
||||
project_id: it.project_id ?? "",
|
||||
title: it.title,
|
||||
due_date: it.due_date ?? "",
|
||||
status: it.status ?? "pending",
|
||||
rule_id: it.rule_id,
|
||||
rule_code: it.rule_code,
|
||||
project_title: it.project_title,
|
||||
}));
|
||||
} else {
|
||||
deadlines = [];
|
||||
}
|
||||
} catch {
|
||||
deadlines = [];
|
||||
}
|
||||
@@ -268,8 +389,27 @@ async function loadDeadlines(id: string) {
|
||||
|
||||
async function loadAppointments(id: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${id}/appointments`);
|
||||
if (resp.ok) appointments = (await resp.json()) ?? [];
|
||||
// t-paliad-139: same migration as loadDeadlines.
|
||||
const resp = await fetch(
|
||||
`/api/events?type=appointment&project_id=${encodeURIComponent(id)}${subtreeParam()}`,
|
||||
);
|
||||
if (resp.ok) {
|
||||
const items: UnionEvent[] = (await resp.json()) ?? [];
|
||||
appointments = items
|
||||
.filter((it) => it.type === "appointment")
|
||||
.map((it) => ({
|
||||
id: it.id,
|
||||
project_id: it.project_id,
|
||||
title: it.title,
|
||||
start_at: it.start_at ?? "",
|
||||
end_at: it.end_at,
|
||||
location: it.location,
|
||||
appointment_type: it.appointment_type,
|
||||
project_title: it.project_title,
|
||||
}));
|
||||
} else {
|
||||
appointments = [];
|
||||
}
|
||||
} catch {
|
||||
appointments = [];
|
||||
}
|
||||
@@ -310,7 +450,7 @@ function renderAppointments() {
|
||||
return `<tr class="termin-row" data-id="${esc(tt.id)}">
|
||||
<td class="frist-col-check"><span class="termin-dot ${typeClass}" /></td>
|
||||
<td>${esc(fmtDateTimeLocal(tt.start_at))}</td>
|
||||
<td>${esc(tt.title)}</td>
|
||||
<td>${esc(tt.title)}${attributionChip(tt.project_id, tt.project_title)}</td>
|
||||
<td>${esc(tt.location ?? "")}</td>
|
||||
<td><span class="termin-type-chip ${typeClass}">${esc(typeLabel)}</span></td>
|
||||
</tr>`;
|
||||
@@ -443,7 +583,7 @@ function renderDeadlines() {
|
||||
aria-label="${esc(t("deadlines.complete.action"))}" />
|
||||
</td>
|
||||
<td class="frist-col-due ${urgency}"><span class="frist-due-dot"></span>${fmtDateOnly(f.due_date)}</td>
|
||||
<td class="frist-col-title ${titleClass}">${esc(f.title)}</td>
|
||||
<td class="frist-col-title ${titleClass}">${esc(f.title)}${attributionChip(f.project_id, f.project_title)}</td>
|
||||
<td class="frist-col-rule">${f.rule_code ? esc(f.rule_code) : "—"}</td>
|
||||
<td><span class="entity-status-chip entity-status-${esc(f.status)}">${esc(statusLabel)}</span></td>
|
||||
</tr>`;
|
||||
@@ -477,6 +617,19 @@ function renderDeadlines() {
|
||||
});
|
||||
}
|
||||
|
||||
// attributionChip renders a small inline chip showing which descendant
|
||||
// project a row actually anchors on, when the row is from an aggregated
|
||||
// subtree result and not from the project being viewed (t-paliad-139).
|
||||
// Returns "" when the row's project is the current page or attribution
|
||||
// data is missing.
|
||||
function attributionChip(rowProjectID?: string, rowProjectTitle?: string): string {
|
||||
if (!project) return "";
|
||||
if (!rowProjectID || !rowProjectTitle) return "";
|
||||
if (rowProjectID === project.id) return "";
|
||||
const label = t("aggregation.attribution.on") || "auf";
|
||||
return ` <span class="aggregation-chip" title="${escAttr(rowProjectTitle)}">${esc(label)}: ${esc(rowProjectTitle)}</span>`;
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
@@ -580,7 +733,7 @@ function renderEvents() {
|
||||
return `<li class="entity-event">
|
||||
<div class="entity-event-date">${fmtDateTime(e.created_at)}</div>
|
||||
<div class="entity-event-body">
|
||||
<div class="entity-event-title">${titleHTML}</div>
|
||||
<div class="entity-event-title">${titleHTML}${attributionChip(e.project_id, e.project_title)}</div>
|
||||
${description ? `<div class="entity-event-desc">${esc(description)}</div>` : ""}
|
||||
</div>
|
||||
</li>`;
|
||||
@@ -1117,6 +1270,10 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read subtree mode from URL once at startup; subsequent toggles update
|
||||
// the URL via persistSubtreeMode (replaceState — back-button friendly).
|
||||
subtreeMode = parseSubtreeMode();
|
||||
|
||||
await loadMe();
|
||||
const ok = await loadProject(id);
|
||||
if (!ok || !project) {
|
||||
@@ -1133,6 +1290,10 @@ async function main() {
|
||||
loadAncestors(id),
|
||||
loadChildren(id),
|
||||
loadTeam(id),
|
||||
loadDescendantStaffed(id),
|
||||
loadDerivedMembers(id),
|
||||
loadAttachedUnits(id),
|
||||
loadAllUnits(),
|
||||
loadUserList(),
|
||||
]);
|
||||
|
||||
@@ -1155,10 +1316,117 @@ async function main() {
|
||||
initTeamForm(id);
|
||||
initDelete();
|
||||
initEventsLoadMore();
|
||||
initSubtreeToggles(id);
|
||||
initAttachUnitForm(id);
|
||||
initNotesContainer(id);
|
||||
showTab(parseTab());
|
||||
}
|
||||
|
||||
// initAttachUnitForm wires the "Partner Unit zuordnen" form on the Team
|
||||
// tab (project lead / global_admin only). The select is populated from
|
||||
// /api/partner-units excluding units already attached.
|
||||
function initAttachUnitForm(id: string) {
|
||||
const wrap = document.getElementById("unit-attach-form-wrap");
|
||||
const form = document.getElementById("unit-attach-form") as HTMLFormElement | null;
|
||||
const showBtn = document.getElementById("unit-attach-show") as HTMLButtonElement | null;
|
||||
const cancelBtn = document.getElementById("unit-attach-cancel") as HTMLButtonElement | null;
|
||||
const select = document.getElementById("unit-attach-select") as HTMLSelectElement | null;
|
||||
if (!wrap || !form || !showBtn || !cancelBtn || !select) return;
|
||||
|
||||
if (!canManagePartnerUnits()) {
|
||||
showBtn.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshSelect = () => {
|
||||
const attachedIDs = new Set(attachedUnits.map((u) => u.partner_unit_id));
|
||||
const placeholder = `<option value="">${esc(t("projects.team.units.choose") || "Bitte Unit wählen…")}</option>`;
|
||||
const opts = allUnits
|
||||
.filter((u) => !attachedIDs.has(u.id))
|
||||
.map((u) => `<option value="${esc(u.id)}">${esc(u.name)}</option>`)
|
||||
.join("");
|
||||
select.innerHTML = placeholder + opts;
|
||||
};
|
||||
refreshSelect();
|
||||
|
||||
showBtn.addEventListener("click", () => {
|
||||
refreshSelect();
|
||||
wrap.style.display = "";
|
||||
showBtn.style.display = "none";
|
||||
});
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
form.reset();
|
||||
wrap.style.display = "none";
|
||||
showBtn.style.display = "";
|
||||
});
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const unitID = select.value;
|
||||
if (!unitID) return;
|
||||
const rolePA = (document.getElementById("unit-attach-role-pa") as HTMLInputElement).checked;
|
||||
const roleSenior = (document.getElementById("unit-attach-role-senior_pa") as HTMLInputElement).checked;
|
||||
const roleAtty = (document.getElementById("unit-attach-role-attorney") as HTMLInputElement).checked;
|
||||
const grantsAuthority = (document.getElementById("unit-attach-authority") as HTMLInputElement).checked;
|
||||
const roles: string[] = [];
|
||||
if (rolePA) roles.push("pa");
|
||||
if (roleSenior) roles.push("senior_pa");
|
||||
if (roleAtty) roles.push("attorney");
|
||||
if (roles.length === 0) {
|
||||
// Defaults: pa + senior_pa.
|
||||
roles.push("pa", "senior_pa");
|
||||
}
|
||||
const resp = await fetch(`/api/projects/${id}/partner-units`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
partner_unit_id: unitID,
|
||||
derive_unit_roles: roles,
|
||||
derive_grants_authority: grantsAuthority,
|
||||
}),
|
||||
});
|
||||
if (resp.ok) {
|
||||
form.reset();
|
||||
wrap.style.display = "none";
|
||||
showBtn.style.display = "";
|
||||
await Promise.all([loadAttachedUnits(id), loadDerivedMembers(id)]);
|
||||
renderTeam();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// initSubtreeToggles wires the "Inkl. Unterprojekte / Nur direkt" buttons
|
||||
// in the History, Deadlines, and Appointments sections. State is shared
|
||||
// across the three sections (one toggle flips all) and persisted in the
|
||||
// URL via ?subtree=false. Default = subtree (true).
|
||||
function initSubtreeToggles(id: string) {
|
||||
const buttons = document.querySelectorAll<HTMLButtonElement>(".subtree-toggle");
|
||||
if (buttons.length === 0) return;
|
||||
|
||||
const refreshLabels = () => {
|
||||
buttons.forEach((btn) => {
|
||||
btn.textContent = subtreeMode
|
||||
? t("aggregation.toggle.subtree")
|
||||
: t("aggregation.toggle.direct_only");
|
||||
btn.setAttribute("aria-pressed", subtreeMode ? "true" : "false");
|
||||
btn.classList.toggle("subtree-toggle--active", !subtreeMode);
|
||||
});
|
||||
};
|
||||
|
||||
refreshLabels();
|
||||
|
||||
buttons.forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
subtreeMode = !subtreeMode;
|
||||
persistSubtreeMode();
|
||||
refreshLabels();
|
||||
await Promise.all([loadEvents(id), loadDeadlines(id), loadAppointments(id)]);
|
||||
renderEvents();
|
||||
renderDeadlines();
|
||||
renderAppointments();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Breadcrumb + ancestor resolution -----------------------------------
|
||||
|
||||
function inheritedClientNumber(): string | null {
|
||||
@@ -1310,6 +1578,59 @@ async function loadTeam(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-139 — Team-tab subsection loaders. All three are independent so
|
||||
// main() runs them in parallel.
|
||||
async function loadDescendantStaffed(id: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${id}/team/from-descendants`);
|
||||
if (resp.ok) {
|
||||
descendantStaffed = ((await resp.json()) as ProjectTeamMember[]) ?? [];
|
||||
} else {
|
||||
descendantStaffed = [];
|
||||
}
|
||||
} catch {
|
||||
descendantStaffed = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDerivedMembers(id: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${id}/team/derived`);
|
||||
if (resp.ok) {
|
||||
derivedMembers = ((await resp.json()) as DerivedMember[]) ?? [];
|
||||
} else {
|
||||
derivedMembers = [];
|
||||
}
|
||||
} catch {
|
||||
derivedMembers = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAttachedUnits(id: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${id}/partner-units`);
|
||||
if (resp.ok) {
|
||||
attachedUnits = ((await resp.json()) as AttachedUnit[]) ?? [];
|
||||
} else {
|
||||
attachedUnits = [];
|
||||
}
|
||||
} catch {
|
||||
attachedUnits = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllUnits() {
|
||||
try {
|
||||
const resp = await fetch(`/api/partner-units`);
|
||||
if (resp.ok) {
|
||||
const all = (await resp.json()) as { id: string; name: string; office: string }[];
|
||||
allUnits = all ?? [];
|
||||
}
|
||||
} catch {
|
||||
allUnits = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUserList() {
|
||||
try {
|
||||
const resp = await fetch("/api/users");
|
||||
@@ -1322,12 +1643,23 @@ async function loadUserList() {
|
||||
function renderTeam() {
|
||||
const body = document.getElementById("team-body")!;
|
||||
const empty = document.getElementById("team-empty")!;
|
||||
if (!teamMembers.length) {
|
||||
|
||||
// Existing team-body shows the direct + ancestor-inherited members
|
||||
// returned by /api/projects/{id}/team. The derived + descendant
|
||||
// sections render into separate tbodies (added in TSX). Empty state
|
||||
// applies to the union — only show when EVERY section is empty.
|
||||
const totalRows =
|
||||
teamMembers.length + descendantStaffed.length + derivedMembers.length;
|
||||
if (totalRows === 0) {
|
||||
body.innerHTML = "";
|
||||
empty.style.display = "";
|
||||
renderDescendantStaffed();
|
||||
renderDerivedMembers();
|
||||
renderAttachedUnits();
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
|
||||
body.innerHTML = teamMembers
|
||||
.map((m) => {
|
||||
const roleLabel = tDyn(`projects.team.role.${m.role}`) || m.role;
|
||||
@@ -1366,6 +1698,145 @@ function renderTeam() {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
renderDescendantStaffed();
|
||||
renderDerivedMembers();
|
||||
renderAttachedUnits();
|
||||
}
|
||||
|
||||
// t-paliad-139 — "Aus Unterprojekten" subsection.
|
||||
function renderDescendantStaffed() {
|
||||
const section = document.getElementById("team-section-descendants");
|
||||
const body = document.getElementById("team-descendants-body");
|
||||
if (!section || !body) return;
|
||||
if (descendantStaffed.length === 0) {
|
||||
section.style.display = "none";
|
||||
body.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
section.style.display = "";
|
||||
body.innerHTML = descendantStaffed
|
||||
.map((m) => {
|
||||
const roleLabel = tDyn(`projects.team.role.${m.role}`) || m.role;
|
||||
const officeLabel = m.user_office ? tDyn("office." + m.user_office) || m.user_office : "";
|
||||
const sourceTitle = esc(m.inherited_from_title || "");
|
||||
return `<tr>
|
||||
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
|
||||
<span class="form-hint">· ${esc(m.user_email)}${officeLabel ? " · " + esc(officeLabel) : ""}</span></td>
|
||||
<td><span class="projekt-team-role">${esc(roleLabel)}</span></td>
|
||||
<td><span class="projekt-team-inherited" title="${escAttr(t("aggregation.attribution.on") || "auf")}: ${sourceTitle}">↓ ${sourceTitle}</span></td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
// t-paliad-139 — "Abgeleitet (Partner Unit)" subsection.
|
||||
function renderDerivedMembers() {
|
||||
const section = document.getElementById("team-section-derived");
|
||||
const body = document.getElementById("team-derived-body");
|
||||
if (!section || !body) return;
|
||||
if (derivedMembers.length === 0) {
|
||||
section.style.display = "none";
|
||||
body.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
section.style.display = "";
|
||||
body.innerHTML = derivedMembers
|
||||
.map((m) => {
|
||||
const memberships = m.memberships || [];
|
||||
// Role column shows distinct unit_role values (usually one — only
|
||||
// diverges if the user has different roles in different units).
|
||||
const distinctRoles = Array.from(new Set(memberships.map((x) => x.unit_role)));
|
||||
const roleLabel = distinctRoles
|
||||
.map((r) => tDyn(`unit_role.${r}`) || r)
|
||||
.join(", ");
|
||||
// Herkunft column lists every (unit, role) pair so multi-unit users
|
||||
// surface all their sources, not just the closest one (t-paliad-143).
|
||||
// Multi-unit: bold each unit name and append the role in parentheses.
|
||||
// Single-unit: bold the one unit name (matches the legacy rendering).
|
||||
const sourceLabel = memberships
|
||||
.map((x) => {
|
||||
const name = `<strong>${esc(x.unit_name)}</strong>`;
|
||||
if (memberships.length === 1) return name;
|
||||
const role = esc(tDyn(`unit_role.${x.unit_role}`) || x.unit_role);
|
||||
return `${name} (${role})`;
|
||||
})
|
||||
.join(", ");
|
||||
const officeLabel = m.user_office ? tDyn("office." + m.user_office) || m.user_office : "";
|
||||
const authBadge = m.derive_grants_authority
|
||||
? `<span class="derived-badge derived-badge--authority" title="${escAttr(t("projects.team.derived.authority.hint") || "Authority granted")}">${esc(t("projects.team.derived.authority") || "Sicht & 4-Augen")}</span>`
|
||||
: `<span class="derived-badge">${esc(t("projects.team.derived.visibility") || "Sicht")}</span>`;
|
||||
return `<tr>
|
||||
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
|
||||
<span class="form-hint">· ${esc(m.user_email)}${officeLabel ? " · " + esc(officeLabel) : ""}</span></td>
|
||||
<td><span class="projekt-team-role">${esc(roleLabel)}</span></td>
|
||||
<td>${esc(t("projects.team.derived.from") || "über")}: ${sourceLabel} ${authBadge}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
// t-paliad-139 — Partner Units management section. Lists attached units
|
||||
// with detach buttons; admin/lead can add new attachments.
|
||||
function renderAttachedUnits() {
|
||||
const section = document.getElementById("team-section-units");
|
||||
const body = document.getElementById("team-units-body");
|
||||
if (!section || !body) return;
|
||||
const canManage = canManagePartnerUnits();
|
||||
// Always show the section to admins/leads (even if empty so they can attach).
|
||||
if (!canManage && attachedUnits.length === 0) {
|
||||
section.style.display = "none";
|
||||
return;
|
||||
}
|
||||
section.style.display = "";
|
||||
if (attachedUnits.length === 0) {
|
||||
body.innerHTML = `<tr><td colspan="4" class="form-hint">${esc(t("projects.team.units.empty") || "Keine Partner Units zugeordnet.")}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
body.innerHTML = attachedUnits
|
||||
.map((u) => {
|
||||
const roles = (u.derive_unit_roles || []).map((r) => tDyn(`unit_role.${r}`) || r).join(", ");
|
||||
const auth = u.derive_grants_authority
|
||||
? esc(t("projects.team.derived.authority") || "Sicht & 4-Augen")
|
||||
: esc(t("projects.team.derived.visibility") || "Sicht");
|
||||
const detachBtn = canManage
|
||||
? `<button type="button" class="btn-ghost btn-small unit-detach-btn" data-unit-id="${esc(u.partner_unit_id)}">${esc(t("projects.team.units.detach") || "Entfernen")}</button>`
|
||||
: "";
|
||||
return `<tr>
|
||||
<td><strong>${esc(u.unit_name)}</strong></td>
|
||||
<td>${esc(roles)}</td>
|
||||
<td>${auth}</td>
|
||||
<td>${u.derived_member_count} ${esc(t("projects.team.units.members") || "Mitglieder")} ${detachBtn}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
body.querySelectorAll<HTMLButtonElement>(".unit-detach-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
if (!project) return;
|
||||
const unitID = btn.dataset.unitId!;
|
||||
if (!window.confirm(t("projects.team.units.confirm_detach") || "Partner Unit entfernen?")) return;
|
||||
const resp = await fetch(
|
||||
`/api/projects/${project.id}/partner-units/${encodeURIComponent(unitID)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (resp.ok) {
|
||||
await Promise.all([loadAttachedUnits(project.id), loadDerivedMembers(project.id)]);
|
||||
renderTeam();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// canManagePartnerUnits returns true for global_admin or this project's
|
||||
// lead. Mirrors the migration-055 RLS write policy.
|
||||
function canManagePartnerUnits(): boolean {
|
||||
if (!me) return false;
|
||||
if (me.global_role === "global_admin") return true;
|
||||
if (!project) return false;
|
||||
return teamMembers.some(
|
||||
(m) => m.user_id === me!.id && m.role === "lead" && m.project_id === project!.id,
|
||||
);
|
||||
}
|
||||
|
||||
function canRemoveTeamMember(m: ProjectTeamMember): boolean {
|
||||
@@ -1383,8 +1854,24 @@ function initTeamForm(id: string) {
|
||||
const sugs = document.getElementById("team-user-suggestions") as HTMLDivElement | null;
|
||||
const msg = document.getElementById("team-msg") as HTMLParagraphElement | null;
|
||||
const role = document.getElementById("team-role") as HTMLSelectElement | null;
|
||||
const inviteHint = document.getElementById("team-user-invite-hint") as HTMLDivElement | null;
|
||||
const inviteHintText = document.getElementById("team-user-invite-hint-text") as HTMLSpanElement | null;
|
||||
const inviteBtn = document.getElementById("team-user-invite-btn") as HTMLButtonElement | null;
|
||||
if (!addBtn || !form || !cancel || !input || !hidden || !sugs || !msg || !role) return;
|
||||
|
||||
const hideInviteHint = () => {
|
||||
if (inviteHint) inviteHint.style.display = "none";
|
||||
};
|
||||
const showInviteHint = (q: string) => {
|
||||
if (!inviteHint || !inviteHintText) return;
|
||||
const looksLikeEmail = /@/.test(q) && /\./.test(q.split("@")[1] || "");
|
||||
inviteHintText.textContent = looksLikeEmail
|
||||
? t("projects.detail.team.invite.hint_email") || "Niemand mit dieser E-Mail."
|
||||
: t("projects.detail.team.invite.hint") || "Benutzer nicht gefunden?";
|
||||
inviteHint.dataset.email = looksLikeEmail ? q : "";
|
||||
inviteHint.style.display = "";
|
||||
};
|
||||
|
||||
addBtn.addEventListener("click", () => {
|
||||
form.style.display = "";
|
||||
addBtn.style.display = "none";
|
||||
@@ -1396,18 +1883,21 @@ function initTeamForm(id: string) {
|
||||
input.value = "";
|
||||
hidden.value = "";
|
||||
sugs.innerHTML = "";
|
||||
hideInviteHint();
|
||||
msg.textContent = "";
|
||||
});
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
const q = input.value.trim().toLowerCase();
|
||||
const q = input.value.trim();
|
||||
const lc = q.toLowerCase();
|
||||
hidden.value = "";
|
||||
if (!q) {
|
||||
sugs.innerHTML = "";
|
||||
hideInviteHint();
|
||||
return;
|
||||
}
|
||||
const matches = userOptions
|
||||
.filter((u) => (u.display_name + " " + u.email).toLowerCase().includes(q))
|
||||
.filter((u) => (u.display_name + " " + u.email).toLowerCase().includes(lc))
|
||||
.slice(0, 8);
|
||||
sugs.innerHTML = matches
|
||||
.map(
|
||||
@@ -1422,8 +1912,29 @@ function initTeamForm(id: string) {
|
||||
hidden.value = el.dataset.id!;
|
||||
input.value = el.dataset.label!;
|
||||
sugs.innerHTML = "";
|
||||
hideInviteHint();
|
||||
});
|
||||
});
|
||||
|
||||
if (matches.length === 0) {
|
||||
showInviteHint(q);
|
||||
} else {
|
||||
hideInviteHint();
|
||||
}
|
||||
});
|
||||
|
||||
inviteBtn?.addEventListener("click", () => {
|
||||
const sidebarBtn = document.getElementById("sidebar-invite-btn") as HTMLButtonElement | null;
|
||||
if (!sidebarBtn) return;
|
||||
sidebarBtn.click();
|
||||
const prefill = inviteHint?.dataset.email || "";
|
||||
if (prefill) {
|
||||
const inviteEmail = document.getElementById("invite-email") as HTMLInputElement | null;
|
||||
if (inviteEmail) {
|
||||
inviteEmail.value = prefill;
|
||||
inviteEmail.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
@@ -1446,6 +1957,7 @@ function initTeamForm(id: string) {
|
||||
input.value = "";
|
||||
hidden.value = "";
|
||||
sugs.innerHTML = "";
|
||||
hideInviteHint();
|
||||
form.style.display = "none";
|
||||
addBtn.style.display = "";
|
||||
await loadTeam(id);
|
||||
|
||||
@@ -70,6 +70,7 @@ export function initSidebar() {
|
||||
initInviteModal();
|
||||
initGlobalSearch();
|
||||
initChangelogBadge();
|
||||
initInboxBadge();
|
||||
initAdminGroup();
|
||||
initThemeToggle();
|
||||
const sidebar = document.querySelector<HTMLElement>(".sidebar");
|
||||
@@ -314,6 +315,33 @@ function initChangelogBadge(): void {
|
||||
});
|
||||
}
|
||||
|
||||
// Inbox badge (t-paliad-138) — count of approval requests where the
|
||||
// current user is qualified to approve. Polls every 60s while the page
|
||||
// is open. Silently swallows errors (badge is optional).
|
||||
function initInboxBadge(): void {
|
||||
const badge = document.getElementById("sidebar-inbox-badge") as HTMLElement | null;
|
||||
if (!badge) return;
|
||||
|
||||
const refresh = () => {
|
||||
fetch("/api/inbox/count", { credentials: "same-origin" })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data: { count?: number } | null) => {
|
||||
if (!data || typeof data.count !== "number" || data.count <= 0) {
|
||||
badge.style.display = "none";
|
||||
return;
|
||||
}
|
||||
badge.textContent = data.count > 9 ? "9+" : String(data.count);
|
||||
badge.style.display = "";
|
||||
})
|
||||
.catch(() => {
|
||||
/* silent */
|
||||
});
|
||||
};
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 60_000);
|
||||
}
|
||||
|
||||
// initThemeToggle wires the sun/moon button at the bottom of the sidebar
|
||||
// (m/paliad#2). The pre-paint inline script in PWAHead.tsx already set
|
||||
// the data-theme attribute on <html>; this function only owns the post-
|
||||
|
||||
@@ -25,6 +25,8 @@ const ICON_SPARKLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
const ICON_USERS = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>';
|
||||
const ICON_SHIELD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>';
|
||||
const ICON_AUDIT_LOG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="15" y2="17"/></svg>';
|
||||
// Bell icon for the /inbox entry (t-paliad-138 4-eye approval inbox).
|
||||
const ICON_BELL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
|
||||
// Theme-toggle icons. The button cycles auto → light → dark → auto, and
|
||||
// the icon swaps to reflect the *current* preference (auto/light/dark)
|
||||
// — not the eventual click target. SSR renders the auto variant; the
|
||||
@@ -44,7 +46,7 @@ interface SidebarProps {
|
||||
authenticated?: boolean;
|
||||
}
|
||||
|
||||
function navItem(href: string, icon: string, i18nKey: string, label: string, currentPath: string): string {
|
||||
function navItem(href: string, icon: string, i18nKey: string, label: string, currentPath: string, badgeID?: string): string {
|
||||
// "Active" is true for the item whose href is a prefix of currentPath.
|
||||
// That way sub-routes like /projekte/{id}/events keep the /projekte entry lit.
|
||||
// /akten and /akten/* are kept as legacy aliases and also highlight /projekte
|
||||
@@ -55,6 +57,7 @@ function navItem(href: string, icon: string, i18nKey: string, label: string, cur
|
||||
<a href={href} className={`sidebar-item${active ? " active" : ""}`}>
|
||||
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: icon }} />
|
||||
<span className="sidebar-label" data-i18n={i18nKey}>{label}</span>
|
||||
{badgeID ? <span className="sidebar-badge" id={badgeID} style="display:none" aria-hidden="true" /> : ""}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -112,6 +115,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
{group("nav.group.uebersicht", "\u00DCbersicht",
|
||||
navItem("/dashboard", ICON_GAUGE, "nav.dashboard", "Dashboard", currentPath) +
|
||||
navItem("/agenda", ICON_AGENDA, "nav.agenda", "Agenda", currentPath) +
|
||||
navItem("/inbox", ICON_BELL, "nav.inbox", "Inbox", currentPath, "sidebar-inbox-badge") +
|
||||
navItem("/team", ICON_USERS, "nav.team", "Team", currentPath),
|
||||
)}
|
||||
|
||||
|
||||
@@ -167,6 +167,7 @@ export type I18nKey =
|
||||
| "admin.partner_units.error.user_required"
|
||||
| "admin.partner_units.feedback.created"
|
||||
| "admin.partner_units.feedback.deleted"
|
||||
| "admin.partner_units.feedback.role_updated"
|
||||
| "admin.partner_units.feedback.updated"
|
||||
| "admin.partner_units.heading"
|
||||
| "admin.partner_units.loading"
|
||||
@@ -177,6 +178,7 @@ export type I18nKey =
|
||||
| "admin.partner_units.member.heading"
|
||||
| "admin.partner_units.member.placeholder"
|
||||
| "admin.partner_units.member.remove"
|
||||
| "admin.partner_units.member.role"
|
||||
| "admin.partner_units.new"
|
||||
| "admin.partner_units.new.heading"
|
||||
| "admin.partner_units.subtitle"
|
||||
@@ -254,6 +256,9 @@ export type I18nKey =
|
||||
| "agenda.urgency.this_week"
|
||||
| "agenda.urgency.today"
|
||||
| "agenda.urgency.tomorrow"
|
||||
| "aggregation.attribution.on"
|
||||
| "aggregation.toggle.direct_only"
|
||||
| "aggregation.toggle.subtree"
|
||||
| "appointments.col.akte"
|
||||
| "appointments.col.location"
|
||||
| "appointments.col.start"
|
||||
@@ -318,6 +323,57 @@ export type I18nKey =
|
||||
| "appointments.type.hearing"
|
||||
| "appointments.type.meeting"
|
||||
| "appointments.unavailable"
|
||||
| "approvals.action.approve"
|
||||
| "approvals.action.reject"
|
||||
| "approvals.action.revoke"
|
||||
| "approvals.decided_by"
|
||||
| "approvals.decision_kind.admin_override"
|
||||
| "approvals.decision_kind.derived_peer"
|
||||
| "approvals.decision_kind.peer"
|
||||
| "approvals.diff.after"
|
||||
| "approvals.diff.before"
|
||||
| "approvals.empty.mine"
|
||||
| "approvals.empty.pending_mine"
|
||||
| "approvals.entity.appointment"
|
||||
| "approvals.entity.deadline"
|
||||
| "approvals.error.concurrent_pending"
|
||||
| "approvals.error.no_qualified_approver"
|
||||
| "approvals.error.not_authorized"
|
||||
| "approvals.error.request_not_pending"
|
||||
| "approvals.error.self_approval"
|
||||
| "approvals.heading"
|
||||
| "approvals.lifecycle.complete"
|
||||
| "approvals.lifecycle.create"
|
||||
| "approvals.lifecycle.delete"
|
||||
| "approvals.lifecycle.update"
|
||||
| "approvals.note.placeholder"
|
||||
| "approvals.pending_complete.label"
|
||||
| "approvals.pending_create.label"
|
||||
| "approvals.pending_delete.label"
|
||||
| "approvals.pending_update.label"
|
||||
| "approvals.policies.column.appointment"
|
||||
| "approvals.policies.column.deadline"
|
||||
| "approvals.policies.column.event"
|
||||
| "approvals.policies.copy_parent"
|
||||
| "approvals.policies.no_approval"
|
||||
| "approvals.policies.set_all_associate"
|
||||
| "approvals.policies.subtitle"
|
||||
| "approvals.policies.title"
|
||||
| "approvals.requested_by"
|
||||
| "approvals.required_role.associate"
|
||||
| "approvals.required_role.lead"
|
||||
| "approvals.required_role.of_counsel"
|
||||
| "approvals.required_role.pa"
|
||||
| "approvals.required_role.senior_pa"
|
||||
| "approvals.status.approved"
|
||||
| "approvals.status.pending"
|
||||
| "approvals.status.rejected"
|
||||
| "approvals.status.revoked"
|
||||
| "approvals.status.superseded"
|
||||
| "approvals.subtitle"
|
||||
| "approvals.tab.mine"
|
||||
| "approvals.tab.pending_mine"
|
||||
| "approvals.title"
|
||||
| "bottomnav.add"
|
||||
| "bottomnav.add.appointment"
|
||||
| "bottomnav.add.appointment.sub"
|
||||
@@ -458,6 +514,10 @@ export type I18nKey =
|
||||
| "common.cancel"
|
||||
| "dashboard.action.short.akte_archived"
|
||||
| "dashboard.action.short.akte_created"
|
||||
| "dashboard.action.short.appointment_approval_approved"
|
||||
| "dashboard.action.short.appointment_approval_rejected"
|
||||
| "dashboard.action.short.appointment_approval_requested"
|
||||
| "dashboard.action.short.appointment_approval_revoked"
|
||||
| "dashboard.action.short.appointment_created"
|
||||
| "dashboard.action.short.appointment_deleted"
|
||||
| "dashboard.action.short.appointment_project_changed"
|
||||
@@ -475,6 +535,10 @@ export type I18nKey =
|
||||
| "dashboard.action.short.checkliste_reset"
|
||||
| "dashboard.action.short.checkliste_unlinked"
|
||||
| "dashboard.action.short.collaborators_updated"
|
||||
| "dashboard.action.short.deadline_approval_approved"
|
||||
| "dashboard.action.short.deadline_approval_rejected"
|
||||
| "dashboard.action.short.deadline_approval_requested"
|
||||
| "dashboard.action.short.deadline_approval_revoked"
|
||||
| "dashboard.action.short.deadline_completed"
|
||||
| "dashboard.action.short.deadline_created"
|
||||
| "dashboard.action.short.deadline_deleted"
|
||||
@@ -847,10 +911,18 @@ export type I18nKey =
|
||||
| "einstellungen.tab.caldav"
|
||||
| "einstellungen.tab.profil"
|
||||
| "einstellungen.title"
|
||||
| "event.description.appointment_approval_approved"
|
||||
| "event.description.appointment_approval_rejected"
|
||||
| "event.description.appointment_approval_requested"
|
||||
| "event.description.appointment_approval_revoked"
|
||||
| "event.description.appointment_created"
|
||||
| "event.description.appointment_deleted"
|
||||
| "event.description.appointment_project_changed"
|
||||
| "event.description.appointment_updated"
|
||||
| "event.description.deadline_approval_approved"
|
||||
| "event.description.deadline_approval_rejected"
|
||||
| "event.description.deadline_approval_requested"
|
||||
| "event.description.deadline_approval_revoked"
|
||||
| "event.description.deadline_completed"
|
||||
| "event.description.deadline_created"
|
||||
| "event.description.deadline_deleted"
|
||||
@@ -862,6 +934,10 @@ export type I18nKey =
|
||||
| "event.note.parent.appointment"
|
||||
| "event.note.parent.deadline"
|
||||
| "event.note.parent.project"
|
||||
| "event.title.appointment_approval_approved"
|
||||
| "event.title.appointment_approval_rejected"
|
||||
| "event.title.appointment_approval_requested"
|
||||
| "event.title.appointment_approval_revoked"
|
||||
| "event.title.appointment_created"
|
||||
| "event.title.appointment_deleted"
|
||||
| "event.title.appointment_project_changed"
|
||||
@@ -872,6 +948,10 @@ export type I18nKey =
|
||||
| "event.title.checklist_renamed"
|
||||
| "event.title.checklist_reset"
|
||||
| "event.title.checklist_unlinked"
|
||||
| "event.title.deadline_approval_approved"
|
||||
| "event.title.deadline_approval_rejected"
|
||||
| "event.title.deadline_approval_requested"
|
||||
| "event.title.deadline_approval_revoked"
|
||||
| "event.title.deadline_completed"
|
||||
| "event.title.deadline_created"
|
||||
| "event.title.deadline_deleted"
|
||||
@@ -1217,6 +1297,7 @@ export type I18nKey =
|
||||
| "nav.group.werkzeuge"
|
||||
| "nav.group.wissen"
|
||||
| "nav.home"
|
||||
| "nav.inbox"
|
||||
| "nav.kostenrechner"
|
||||
| "nav.links"
|
||||
| "nav.logout"
|
||||
@@ -1372,6 +1453,9 @@ export type I18nKey =
|
||||
| "projects.detail.team.form.role"
|
||||
| "projects.detail.team.form.submit"
|
||||
| "projects.detail.team.form.user"
|
||||
| "projects.detail.team.invite.cta"
|
||||
| "projects.detail.team.invite.hint"
|
||||
| "projects.detail.team.invite.hint_email"
|
||||
| "projects.detail.team.remove"
|
||||
| "projects.detail.title"
|
||||
| "projects.detail.verlauf.empty"
|
||||
@@ -1442,6 +1526,10 @@ export type I18nKey =
|
||||
| "projects.status.completed"
|
||||
| "projects.submit"
|
||||
| "projects.subtitle"
|
||||
| "projects.team.derived.authority"
|
||||
| "projects.team.derived.authority.hint"
|
||||
| "projects.team.derived.from"
|
||||
| "projects.team.derived.visibility"
|
||||
| "projects.team.direct"
|
||||
| "projects.team.inherited.hint"
|
||||
| "projects.team.role.associate"
|
||||
@@ -1451,6 +1539,24 @@ export type I18nKey =
|
||||
| "projects.team.role.observer"
|
||||
| "projects.team.role.of_counsel"
|
||||
| "projects.team.role.pa"
|
||||
| "projects.team.section.derived"
|
||||
| "projects.team.section.derived.hint"
|
||||
| "projects.team.section.from_descendants"
|
||||
| "projects.team.section.from_descendants.hint"
|
||||
| "projects.team.section.units"
|
||||
| "projects.team.section.units.hint"
|
||||
| "projects.team.units.attach"
|
||||
| "projects.team.units.choose"
|
||||
| "projects.team.units.col.authority"
|
||||
| "projects.team.units.col.derive_roles"
|
||||
| "projects.team.units.col.name"
|
||||
| "projects.team.units.confirm_detach"
|
||||
| "projects.team.units.derive_roles"
|
||||
| "projects.team.units.detach"
|
||||
| "projects.team.units.empty"
|
||||
| "projects.team.units.grants_authority"
|
||||
| "projects.team.units.members"
|
||||
| "projects.team.units.select"
|
||||
| "projects.title"
|
||||
| "projects.tree.deadlines.open"
|
||||
| "projects.tree.deadlines.overdue"
|
||||
@@ -1505,4 +1611,9 @@ export type I18nKey =
|
||||
| "theme.toggle.cycle.dark"
|
||||
| "theme.toggle.cycle.light"
|
||||
| "theme.toggle.dark"
|
||||
| "theme.toggle.light";
|
||||
| "theme.toggle.light"
|
||||
| "unit_role.attorney"
|
||||
| "unit_role.lead"
|
||||
| "unit_role.pa"
|
||||
| "unit_role.paralegal"
|
||||
| "unit_role.senior_pa";
|
||||
|
||||
61
frontend/src/inbox.tsx
Normal file
61
frontend/src/inbox.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// Approval inbox page (t-paliad-138). Two-tab UI:
|
||||
// - "Zur Genehmigung": requests where the caller is qualified to approve
|
||||
// - "Meine Anfragen": requests submitted by the caller
|
||||
//
|
||||
// Hydrates lazily on load (no inline payload) — unlike the dashboard, the
|
||||
// inbox doesn't carry SSR state. The client bundle calls /api/inbox/* on
|
||||
// hydration and re-renders.
|
||||
|
||||
export function renderInbox(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<PWAHead />
|
||||
<title data-i18n="approvals.title">Genehmigungen — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/inbox" />
|
||||
<BottomNav currentPath="/inbox" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<h1 data-i18n="approvals.heading">Genehmigungen</h1>
|
||||
<p className="tool-subtitle" data-i18n="approvals.subtitle">
|
||||
4-Augen-Prüfung für Fristen und Termine.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="agenda-controls">
|
||||
<div className="agenda-filter-group" role="group">
|
||||
<div className="agenda-chip-row" id="inbox-tab-row">
|
||||
<button type="button" className="agenda-chip active" data-tab="pending-mine" data-i18n="approvals.tab.pending_mine">Zur Genehmigung</button>
|
||||
<button type="button" className="agenda-chip" data-tab="mine" data-i18n="approvals.tab.mine">Meine Anfragen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="agenda-loading" id="inbox-loading" data-i18n="agenda.loading">Lädt …</div>
|
||||
<div className="entity-empty" id="inbox-empty" style="display:none" />
|
||||
<ul className="inbox-list" id="inbox-list" />
|
||||
</div>
|
||||
</section>
|
||||
<Footer />
|
||||
</main>
|
||||
|
||||
<script src="/assets/inbox.js" defer />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -83,6 +83,11 @@ export function renderProjectsDetail(): string {
|
||||
|
||||
{/* History (Verlauf) */}
|
||||
<section className="entity-tab-panel" id="tab-history">
|
||||
<div className="party-controls">
|
||||
<button type="button" className="btn-secondary btn-small subtree-toggle" aria-pressed="false">
|
||||
Inkl. Unterprojekte
|
||||
</button>
|
||||
</div>
|
||||
<ul className="entity-events" id="project-events-list" />
|
||||
<p className="entity-events-empty" id="project-events-empty" style="display:none" data-i18n="projects.detail.verlauf.empty">
|
||||
Noch keine Ereignisse aufgezeichnet.
|
||||
@@ -109,6 +114,10 @@ export function renderProjectsDetail(): string {
|
||||
<input type="text" id="team-user-input" placeholder="Name oder E-Mail..." autocomplete="off" />
|
||||
<input type="hidden" id="team-user-id" />
|
||||
<div id="team-user-suggestions" className="collab-suggestions" />
|
||||
<div id="team-user-invite-hint" className="collab-invite-hint" style="display:none">
|
||||
<span id="team-user-invite-hint-text" data-i18n="projects.detail.team.invite.hint">Benutzer nicht gefunden?</span>
|
||||
<button type="button" className="btn-secondary btn-small" id="team-user-invite-btn" data-i18n="projects.detail.team.invite.cta">Einladen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="team-role" data-i18n="projects.detail.team.form.role">Rolle</label>
|
||||
@@ -145,6 +154,101 @@ export function renderProjectsDetail(): string {
|
||||
<p className="entity-events-empty" id="team-empty" style="display:none" data-i18n="projects.detail.team.empty">
|
||||
Noch keine Teammitglieder.
|
||||
</p>
|
||||
|
||||
{/* t-paliad-139 — Aus Unterprojekten subsection. */}
|
||||
<div id="team-section-descendants" style="display:none">
|
||||
<h3 className="entity-section-heading" data-i18n="projects.team.section.from_descendants">
|
||||
Aus Unterprojekten
|
||||
</h3>
|
||||
<p className="form-hint" data-i18n="projects.team.section.from_descendants.hint">
|
||||
Personen, die direkt auf einem Unterprojekt eingetragen sind und nicht auf diesem oder einem Übergeordneten.
|
||||
</p>
|
||||
<table className="party-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="projects.detail.team.col.name">Name</th>
|
||||
<th data-i18n="projects.detail.team.col.role">Rolle</th>
|
||||
<th data-i18n="projects.detail.team.col.source">Herkunft</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="team-descendants-body" />
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* t-paliad-139 — Abgeleitet (Partner Unit) subsection. */}
|
||||
<div id="team-section-derived" style="display:none">
|
||||
<h3 className="entity-section-heading" data-i18n="projects.team.section.derived">
|
||||
Abgeleitet (Partner Unit)
|
||||
</h3>
|
||||
<p className="form-hint" data-i18n="projects.team.section.derived.hint">
|
||||
Mitglieder, die über eine zugeordnete Partner Unit auf diesem Projekt aktiv sind.
|
||||
</p>
|
||||
<table className="party-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="projects.detail.team.col.name">Name</th>
|
||||
<th data-i18n="projects.detail.team.col.role">Rolle</th>
|
||||
<th data-i18n="projects.detail.team.col.source">Herkunft</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="team-derived-body" />
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* t-paliad-139 — Partner Units management. */}
|
||||
<div id="team-section-units" style="display:none">
|
||||
<h3 className="entity-section-heading" data-i18n="projects.team.section.units">
|
||||
Partner Units
|
||||
</h3>
|
||||
<p className="form-hint" data-i18n="projects.team.section.units.hint">
|
||||
Partner Units, die auf diesem Projekt eingebunden sind. Mitglieder mit passenden Unit-Rollen werden automatisch abgeleitet.
|
||||
</p>
|
||||
<div className="party-controls">
|
||||
<button type="button" id="unit-attach-show" className="btn-primary btn-cta-lime btn-small" data-i18n="projects.team.units.attach">
|
||||
Partner Unit zuordnen
|
||||
</button>
|
||||
</div>
|
||||
<div id="unit-attach-form-wrap" style="display:none">
|
||||
<form id="unit-attach-form" className="entity-form party-form" autocomplete="off">
|
||||
<div className="form-field-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="unit-attach-select" data-i18n="projects.team.units.select">Unit</label>
|
||||
<select id="unit-attach-select" required />
|
||||
</div>
|
||||
</div>
|
||||
<fieldset className="form-field">
|
||||
<legend data-i18n="projects.team.units.derive_roles">Welche Unit-Rollen ableiten?</legend>
|
||||
<label className="form-checkbox">
|
||||
<input type="checkbox" id="unit-attach-role-pa" checked /> <span data-i18n="unit_role.pa">PA</span>
|
||||
</label>
|
||||
<label className="form-checkbox">
|
||||
<input type="checkbox" id="unit-attach-role-senior_pa" checked /> <span data-i18n="unit_role.senior_pa">Senior PA</span>
|
||||
</label>
|
||||
<label className="form-checkbox">
|
||||
<input type="checkbox" id="unit-attach-role-attorney" /> <span data-i18n="unit_role.attorney">Attorney</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<label className="form-checkbox">
|
||||
<input type="checkbox" id="unit-attach-authority" /> <span data-i18n="projects.team.units.grants_authority">Stimmrecht abgeben (4-Augen)</span>
|
||||
</label>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-cancel" id="unit-attach-cancel" data-i18n="projects.detail.team.form.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="projects.team.units.attach">Zuordnen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<table className="party-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="projects.team.units.col.name">Unit</th>
|
||||
<th data-i18n="projects.team.units.col.derive_roles">Abgeleitete Rollen</th>
|
||||
<th data-i18n="projects.team.units.col.authority">Authority</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="team-units-body" />
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Children (Untergeordnet) */}
|
||||
@@ -222,6 +326,9 @@ export function renderProjectsDetail(): string {
|
||||
<a id="deadline-add-link" className="btn-primary btn-cta-lime btn-small" data-i18n="projects.detail.deadlines.add" href="#">
|
||||
Frist hinzufügen
|
||||
</a>
|
||||
<button type="button" className="btn-secondary btn-small subtree-toggle" aria-pressed="false">
|
||||
Inkl. Unterprojekte
|
||||
</button>
|
||||
</div>
|
||||
<div className="entity-table-wrap" id="project-deadlines-tablewrap">
|
||||
<table className="entity-table fristen-table">
|
||||
@@ -248,6 +355,9 @@ export function renderProjectsDetail(): string {
|
||||
<button type="button" id="appointment-add-btn" className="btn-primary btn-cta-lime btn-small" data-i18n="projects.detail.appointments.add">
|
||||
Termin hinzufügen
|
||||
</button>
|
||||
<button type="button" className="btn-secondary btn-small subtree-toggle" aria-pressed="false">
|
||||
Inkl. Unterprojekte
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="project-appointment-form" className="party-form" style="display:none">
|
||||
|
||||
@@ -714,8 +714,16 @@ main {
|
||||
--color-accent-fg: var(--sidebar-text-active);
|
||||
}
|
||||
|
||||
/* `:root.sidebar-pinned .sidebar` is the pre-paint companion to
|
||||
`.sidebar.pinned` (t-paliad-142). The FOUC script in PWAHead.tsx sets
|
||||
the html class before paint, so the sidebar element renders at pinned
|
||||
width from frame 1; runtime initSidebar later mirrors `.pinned` onto
|
||||
the element itself for the explicit pin/unpin click animation. Same
|
||||
pattern as the `.has-sidebar.sidebar-pinned` / `:root.sidebar-pinned
|
||||
.has-sidebar` pair below. */
|
||||
.sidebar.expanded,
|
||||
.sidebar.pinned {
|
||||
.sidebar.pinned,
|
||||
:root.sidebar-pinned .sidebar {
|
||||
width: var(--sidebar-width);
|
||||
}
|
||||
|
||||
@@ -765,7 +773,8 @@ main {
|
||||
}
|
||||
|
||||
.sidebar.expanded .sidebar-pin,
|
||||
.sidebar.pinned .sidebar-pin {
|
||||
.sidebar.pinned .sidebar-pin,
|
||||
:root.sidebar-pinned .sidebar .sidebar-pin {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -781,7 +790,8 @@ main {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sidebar.pinned .sidebar-pin {
|
||||
.sidebar.pinned .sidebar-pin,
|
||||
:root.sidebar-pinned .sidebar .sidebar-pin {
|
||||
color: var(--sidebar-text-active);
|
||||
}
|
||||
|
||||
@@ -806,7 +816,8 @@ main {
|
||||
}
|
||||
|
||||
.sidebar.expanded .sidebar-resize-handle,
|
||||
.sidebar.pinned .sidebar-resize-handle {
|
||||
.sidebar.pinned .sidebar-resize-handle,
|
||||
:root.sidebar-pinned .sidebar .sidebar-resize-handle {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -922,7 +933,8 @@ main {
|
||||
}
|
||||
|
||||
.sidebar.expanded .sidebar-label,
|
||||
.sidebar.pinned .sidebar-label {
|
||||
.sidebar.pinned .sidebar-label,
|
||||
:root.sidebar-pinned .sidebar .sidebar-label {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -5250,7 +5262,8 @@ input[type="range"]::-moz-range-thumb {
|
||||
|
||||
.sidebar.expanded .sidebar-group-label,
|
||||
.sidebar.pinned .sidebar-group-label,
|
||||
.sidebar.mobile-open .sidebar-group-label {
|
||||
.sidebar.mobile-open .sidebar-group-label,
|
||||
:root.sidebar-pinned .sidebar .sidebar-group-label {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
@@ -5856,6 +5869,17 @@ input[type="range"]::-moz-range-thumb {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* `.collab-suggestions` is absolute-positioned with top: 100% — needs a
|
||||
positioned ancestor or it falls back to the viewport's initial
|
||||
containing block and renders off-screen (text is in the DOM, but
|
||||
visually invisible — m hit this on the project team-add picker after
|
||||
t-141 made the dropdown content-visible but still let it float to
|
||||
nowhere). Scoped via :has() so we only flip the parent's position
|
||||
where it actually contains a suggestions dropdown. */
|
||||
.form-field:has(> .collab-suggestions) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.collab-suggestions {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
@@ -5872,6 +5896,61 @@ input[type="range"]::-moz-range-thumb {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Visibility is content-driven: when innerHTML is "" the div has no children
|
||||
and stays hidden; the moment a consumer renders <.collab-suggestion> rows
|
||||
the dropdown shows. Keeps the JS sites (project team-add, project parent
|
||||
picker, partner-units member-add) from each having to toggle display. */
|
||||
.collab-suggestions:not(:empty) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.collab-suggestion {
|
||||
display: block;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.collab-suggestion:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.collab-suggestion:hover,
|
||||
.collab-suggestion.is-active {
|
||||
background: var(--color-bg-lime-tint);
|
||||
}
|
||||
|
||||
.collab-suggestion strong {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.collab-suggestion .form-hint {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
/* Inline "invite this user instead" affordance shown beneath an empty
|
||||
.collab-suggestions when the typed query has no matches. */
|
||||
.collab-invite-hint {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--color-surface-alt, var(--color-bg-lime-tint));
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.collab-invite-hint button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.entity-suggestion {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
@@ -6128,6 +6207,71 @@ input[type="range"]::-moz-range-thumb {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
/* t-paliad-139 — subtree aggregation toggle (Inkl. Unterprojekte / Nur direkt).
|
||||
Lives in .party-controls beside the section CTA. The active state shows
|
||||
"Nur direkt" with a darker chrome so the user can see at a glance that
|
||||
they've narrowed the view. Default (subtree) keeps standard secondary
|
||||
styling. */
|
||||
.subtree-toggle {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.subtree-toggle--active {
|
||||
background: var(--color-accent-soft, var(--color-surface));
|
||||
border-color: var(--color-accent, var(--color-border));
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Attribution chip — shows the descendant project a row anchors on when
|
||||
the project detail page is aggregating. Inline-flush with the title cell;
|
||||
readable on both light and dark themes. */
|
||||
.aggregation-chip {
|
||||
display: inline-block;
|
||||
margin-left: 0.5rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-bg-muted, rgba(0, 0, 0, 0.04));
|
||||
color: var(--color-text-muted, #555);
|
||||
font-size: 0.75rem;
|
||||
font-weight: normal;
|
||||
vertical-align: baseline;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* t-paliad-139 Phase 2 — Team-tab subsection headings. */
|
||||
.entity-section-heading {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Derived-membership badges. */
|
||||
.derived-badge {
|
||||
display: inline-block;
|
||||
margin-left: 0.4rem;
|
||||
padding: 0.05rem 0.45rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-bg-muted, rgba(0, 0, 0, 0.04));
|
||||
color: var(--color-text-muted, #555);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
.derived-badge--authority {
|
||||
background: var(--color-accent-soft, rgba(198, 244, 28, 0.18));
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Inline checkbox label inside the attach-unit form. */
|
||||
.form-checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-right: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.party-form {
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 1rem;
|
||||
@@ -7541,7 +7685,8 @@ label.caldav-toggle-label {
|
||||
|
||||
.sidebar.expanded .sidebar-search-input,
|
||||
.sidebar.pinned .sidebar-search-input,
|
||||
.sidebar.mobile-open .sidebar-search-input {
|
||||
.sidebar.mobile-open .sidebar-search-input,
|
||||
:root.sidebar-pinned .sidebar .sidebar-search-input {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -7571,7 +7716,8 @@ label.caldav-toggle-label {
|
||||
|
||||
.sidebar.expanded .sidebar-search-kbd,
|
||||
.sidebar.pinned .sidebar-search-kbd,
|
||||
.sidebar.mobile-open .sidebar-search-kbd {
|
||||
.sidebar.mobile-open .sidebar-search-kbd,
|
||||
:root.sidebar-pinned .sidebar .sidebar-search-kbd {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -7778,7 +7924,8 @@ label.caldav-toggle-label {
|
||||
}
|
||||
|
||||
.sidebar.expanded .sidebar-badge,
|
||||
.sidebar.pinned .sidebar-badge {
|
||||
.sidebar.pinned .sidebar-badge,
|
||||
:root.sidebar-pinned .sidebar .sidebar-badge {
|
||||
left: auto;
|
||||
right: 1rem;
|
||||
top: 50%;
|
||||
@@ -8764,6 +8911,44 @@ dialog.quick-add-sheet::backdrop {
|
||||
background: var(--color-bg-lime-tint);
|
||||
}
|
||||
|
||||
/* /admin/partner-units member modal — list of (display_name, role-select,
|
||||
remove) rows. The role-select is wired to PATCH …/members/{user}/role
|
||||
(t-paliad-143). */
|
||||
.partner-unit-member-list {
|
||||
list-style: none;
|
||||
margin: 0 0 1rem 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.partner-unit-member-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.4rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.partner-unit-member-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.partner-unit-member-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pu-role-select {
|
||||
padding: 0.25rem 0.4rem;
|
||||
font-size: 0.82rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.admin-team-input {
|
||||
width: 100%;
|
||||
padding: 0.3rem 0.45rem;
|
||||
@@ -10197,3 +10382,161 @@ dialog.quick-add-sheet::backdrop {
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* Approval workflow (t-paliad-138).
|
||||
* ========================================================================== */
|
||||
|
||||
.inbox-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.inbox-row {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inbox-row-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.inbox-row-title {
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.inbox-row-meta {
|
||||
font-size: 13px;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
.inbox-row-diff {
|
||||
background: var(--bg-soft);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.inbox-row-diff-line {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inbox-row-diff-key {
|
||||
font-weight: 600;
|
||||
color: var(--fg-muted);
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
.inbox-row-diff-values {
|
||||
color: var(--fg);
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
}
|
||||
|
||||
.inbox-row-note {
|
||||
font-size: 13px;
|
||||
color: var(--fg-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.inbox-row-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.inbox-row-action {
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.inbox-row-decided {
|
||||
color: var(--fg-muted);
|
||||
font-size: 12px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* Pending-approval pill — shown on every entity surface where a row is
|
||||
* approval_status='pending'. Soft amber background, ⚠ leader. */
|
||||
.approval-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(146, 64, 14, 0.12);
|
||||
color: #92400e;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.approval-pill::before { content: "⚠"; }
|
||||
|
||||
.approval-pill--historic {
|
||||
background: var(--bg-soft);
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.approval-pill--historic::before { content: ""; }
|
||||
|
||||
[data-theme="dark"] .approval-pill {
|
||||
background: rgba(252, 211, 77, 0.18);
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
/* Pending-state row modifiers — soft tint per lifecycle so an approver can
|
||||
* scan a list of pending entities at a glance. Write-then-approve means
|
||||
* the row is "live" already; the tint is the only visual flag. */
|
||||
.entity-row--pending-create {
|
||||
background: rgba(146, 64, 14, 0.05);
|
||||
}
|
||||
.entity-row--pending-update {
|
||||
background: rgba(146, 64, 14, 0.04);
|
||||
}
|
||||
.entity-row--pending-complete {
|
||||
background: rgba(34, 197, 94, 0.05);
|
||||
}
|
||||
.entity-row--pending-delete {
|
||||
background: rgba(220, 38, 38, 0.05);
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: rgba(220, 38, 38, 0.4);
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
|
||||
/* Sidebar inbox badge — orange when something needs the user's
|
||||
* attention; matches the changelog-badge shape but distinct color. */
|
||||
#sidebar-inbox-badge {
|
||||
background: #92400e;
|
||||
color: #fff;
|
||||
}
|
||||
[data-theme="dark"] #sidebar-inbox-badge {
|
||||
background: #fcd34d;
|
||||
color: #0a0a0a;
|
||||
}
|
||||
|
||||
/* Approval policies authoring table on /projects/{id}/settings/approvals. */
|
||||
.approval-policies-table th,
|
||||
.approval-policies-table td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
.approval-policies-table .entity-select {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
|
||||
42
internal/db/migrations/054_approvals.down.sql
Normal file
42
internal/db/migrations/054_approvals.down.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
-- t-paliad-138: rollback dual-control approvals.
|
||||
--
|
||||
-- Reverses 054_approvals.up.sql:
|
||||
-- 1. Drop appointment + deadline approval columns.
|
||||
-- 2. Drop paliad.approval_requests.
|
||||
-- 3. Drop paliad.approval_policies.
|
||||
-- 4. Drop paliad.approval_role_level().
|
||||
-- 5. Restore project_teams.role CHECK without 'senior_pa'.
|
||||
--
|
||||
-- Step 5 will fail loudly if any user has been re-roled to 'senior_pa' —
|
||||
-- intentional, mirrors the t-paliad-051 down strategy. Operator must
|
||||
-- migrate those rows to another role before rolling back.
|
||||
|
||||
ALTER TABLE paliad.appointments
|
||||
DROP COLUMN IF EXISTS completed_at,
|
||||
DROP COLUMN IF EXISTS approved_at,
|
||||
DROP COLUMN IF EXISTS approved_by,
|
||||
DROP COLUMN IF EXISTS pending_request_id,
|
||||
DROP COLUMN IF EXISTS approval_status;
|
||||
|
||||
ALTER TABLE paliad.deadlines
|
||||
DROP COLUMN IF EXISTS approved_at,
|
||||
DROP COLUMN IF EXISTS approved_by,
|
||||
DROP COLUMN IF EXISTS pending_request_id,
|
||||
DROP COLUMN IF EXISTS approval_status;
|
||||
|
||||
DROP INDEX IF EXISTS paliad.deadlines_approval_status_pending_idx;
|
||||
DROP INDEX IF EXISTS paliad.appointments_approval_status_pending_idx;
|
||||
|
||||
DROP TABLE IF EXISTS paliad.approval_requests;
|
||||
DROP TABLE IF EXISTS paliad.approval_policies;
|
||||
|
||||
DROP FUNCTION IF EXISTS paliad.approval_role_level(text);
|
||||
|
||||
-- Drop by both English and the German-legacy name (see up migration §1).
|
||||
ALTER TABLE paliad.project_teams DROP CONSTRAINT IF EXISTS projekt_teams_role_check;
|
||||
ALTER TABLE paliad.project_teams DROP CONSTRAINT IF EXISTS project_teams_role_check;
|
||||
ALTER TABLE paliad.project_teams ADD CONSTRAINT project_teams_role_check
|
||||
CHECK (role IN (
|
||||
'lead', 'associate', 'pa', 'of_counsel',
|
||||
'local_counsel', 'expert', 'observer'
|
||||
));
|
||||
237
internal/db/migrations/054_approvals.up.sql
Normal file
237
internal/db/migrations/054_approvals.up.sql
Normal file
@@ -0,0 +1,237 @@
|
||||
-- t-paliad-138: dual-control approvals (4-Augen-Prüfung) for Deadlines + Appointments.
|
||||
--
|
||||
-- Design: docs/design-approvals-2026-05-06.md (cronus, m-locked 2026-05-06).
|
||||
--
|
||||
-- Schema-only migration (commit 1 of 8). Adds the operational tables, the
|
||||
-- strict-ladder helper, and the per-entity tracking columns. No Go code
|
||||
-- reads these yet — paliad behaves identically until commit 2 wires the
|
||||
-- ApprovalService into the mutation paths.
|
||||
--
|
||||
-- Sections:
|
||||
-- 1. Add 'senior_pa' to paliad.project_teams.role CHECK.
|
||||
-- 2. paliad.approval_role_level(text) — strict ladder helper.
|
||||
-- 3. paliad.approval_policies — per-(project, entity_type, lifecycle_event).
|
||||
-- 4. paliad.approval_requests — operational pending workflow.
|
||||
-- 5. ALTER paliad.deadlines + paliad.appointments — approval columns
|
||||
-- (approval_status, pending_request_id, approved_by, approved_at;
|
||||
-- appointments also gains completed_at).
|
||||
-- 6. Backfill: mark every existing row approval_status='legacy'.
|
||||
--
|
||||
-- ============================================================================
|
||||
-- 1. Add 'senior_pa' to paliad.project_teams.role CHECK.
|
||||
--
|
||||
-- Live-DB finding (cronus, 2026-05-06): the existing constraint is named
|
||||
-- `projekt_teams_role_check` (German leftover from migration 018, when the
|
||||
-- table was `paliad.projekt_teams`; the table was renamed in 020 but the
|
||||
-- constraint name was preserved by Postgres). Dropping by both names
|
||||
-- defensively handles any future re-creation under the English name.
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.project_teams DROP CONSTRAINT IF EXISTS projekt_teams_role_check;
|
||||
ALTER TABLE paliad.project_teams DROP CONSTRAINT IF EXISTS project_teams_role_check;
|
||||
ALTER TABLE paliad.project_teams ADD CONSTRAINT project_teams_role_check
|
||||
CHECK (role IN (
|
||||
'lead', 'associate', 'pa', 'of_counsel',
|
||||
'local_counsel', 'expert', 'observer',
|
||||
'senior_pa'
|
||||
));
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. paliad.approval_role_level — strict ladder over project_teams.role.
|
||||
--
|
||||
-- Mirrors internal/services/approval_levels.go:levelOf. A user with
|
||||
-- project_teams.role R can approve any request whose required_role has level
|
||||
-- <= level(R). Roles outside the approval ladder (local_counsel, expert,
|
||||
-- observer, anything new) return 0 and are ineligible to approve at any
|
||||
-- level. Default required_role on policies is 'associate' (level 3).
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.approval_role_level(role text)
|
||||
RETURNS int LANGUAGE SQL IMMUTABLE AS $$
|
||||
SELECT CASE role
|
||||
WHEN 'lead' THEN 5
|
||||
WHEN 'of_counsel' THEN 4
|
||||
WHEN 'associate' THEN 3
|
||||
WHEN 'senior_pa' THEN 2
|
||||
WHEN 'pa' THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.approval_role_level(text) IS
|
||||
'Strict-ladder level for approval gating (t-paliad-138). '
|
||||
'Higher level always satisfies lower. Level 0 = ineligible. '
|
||||
'Default policy required_role=associate (level 3) — eligible: lead, of_counsel, associate.';
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. paliad.approval_policies — per-(project, entity_type, lifecycle_event).
|
||||
--
|
||||
-- Up to 8 rows per project (deadline×4 + appointment×4). UNIQUE composite key
|
||||
-- enforces this. No row = no approval needed for that event. Authoring is
|
||||
-- gated to global_admin in the service layer; RLS lets project members read
|
||||
-- their own project's policies (transparency: "do my edits need 4-eye?").
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE paliad.approval_policies (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
entity_type text NOT NULL CHECK (entity_type IN ('deadline', 'appointment')),
|
||||
lifecycle_event text NOT NULL CHECK (lifecycle_event IN ('create', 'update', 'complete', 'delete')),
|
||||
required_role text NOT NULL CHECK (required_role IN (
|
||||
'lead', 'of_counsel', 'associate', 'senior_pa', 'pa'
|
||||
)),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
UNIQUE (project_id, entity_type, lifecycle_event)
|
||||
);
|
||||
|
||||
CREATE INDEX approval_policies_project_idx
|
||||
ON paliad.approval_policies (project_id);
|
||||
|
||||
ALTER TABLE paliad.approval_policies ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY approval_policies_select ON paliad.approval_policies
|
||||
FOR SELECT TO authenticated
|
||||
USING (paliad.can_see_project(project_id));
|
||||
|
||||
-- Writes are restricted to global_admin in the application layer. The
|
||||
-- service-role connection bypasses RLS, so these policies are
|
||||
-- defence-in-depth for any future direct-DB access path.
|
||||
CREATE POLICY approval_policies_write ON paliad.approval_policies
|
||||
FOR ALL TO authenticated
|
||||
USING (
|
||||
EXISTS (SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 4. paliad.approval_requests — operational pending workflow.
|
||||
--
|
||||
-- One row per submitted state-change that needs 4-eye sign-off. The entity
|
||||
-- being changed is referenced by (entity_type, entity_id) — polymorphic
|
||||
-- across deadlines / appointments, so no FK constraint on entity_id.
|
||||
--
|
||||
-- pre_image carries the field values needed to revert on rejection
|
||||
-- (NULL for 'create' since there's nothing to revert to). payload echoes
|
||||
-- the diff or new values that were written, for audit display.
|
||||
--
|
||||
-- required_role is a snapshot of the policy at request time — even if the
|
||||
-- policy changes mid-flight, the request honours the level it was submitted
|
||||
-- under.
|
||||
--
|
||||
-- decision_kind discriminates 'peer' (normal in-team sign-off) from
|
||||
-- 'admin_override' (global_admin used the escape-hatch path). Verlauf
|
||||
-- chronology renders these distinctly.
|
||||
--
|
||||
-- The CHECK on (decided_by != requested_by) is defence-in-depth alongside
|
||||
-- the service-layer self-approval block.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE paliad.approval_requests (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
entity_type text NOT NULL CHECK (entity_type IN ('deadline', 'appointment')),
|
||||
entity_id uuid NOT NULL,
|
||||
lifecycle_event text NOT NULL CHECK (lifecycle_event IN ('create', 'update', 'complete', 'delete')),
|
||||
pre_image jsonb,
|
||||
payload jsonb,
|
||||
requested_by uuid NOT NULL REFERENCES paliad.users(id) ON DELETE RESTRICT,
|
||||
requested_at timestamptz NOT NULL DEFAULT now(),
|
||||
required_role text NOT NULL CHECK (required_role IN (
|
||||
'lead', 'of_counsel', 'associate', 'senior_pa', 'pa'
|
||||
)),
|
||||
status text NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'approved', 'rejected', 'revoked', 'superseded')),
|
||||
decided_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
decided_at timestamptz,
|
||||
decision_kind text CHECK (decision_kind IS NULL OR decision_kind IN ('peer', 'admin_override')),
|
||||
decision_note text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT approval_requests_no_self_approval
|
||||
CHECK (decided_by IS NULL OR decided_by <> requested_by)
|
||||
);
|
||||
|
||||
CREATE INDEX approval_requests_project_status_idx
|
||||
ON paliad.approval_requests (project_id, status);
|
||||
CREATE INDEX approval_requests_entity_idx
|
||||
ON paliad.approval_requests (entity_type, entity_id);
|
||||
CREATE INDEX approval_requests_requested_by_idx
|
||||
ON paliad.approval_requests (requested_by, status);
|
||||
CREATE INDEX approval_requests_pending_idx
|
||||
ON paliad.approval_requests (status, requested_at)
|
||||
WHERE status = 'pending';
|
||||
|
||||
ALTER TABLE paliad.approval_requests ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Visible to anyone with project visibility (mirrors deadlines / appointments).
|
||||
-- The approve/reject action is gated at the service layer, not here.
|
||||
CREATE POLICY approval_requests_all ON paliad.approval_requests
|
||||
FOR ALL TO authenticated
|
||||
USING (paliad.can_see_project(project_id))
|
||||
WITH CHECK (paliad.can_see_project(project_id));
|
||||
|
||||
-- ============================================================================
|
||||
-- 5. Approval columns on paliad.deadlines + paliad.appointments.
|
||||
--
|
||||
-- approval_status:
|
||||
-- 'approved' (default for new + existing-after-backfill-clears),
|
||||
-- 'pending' (an approval_request is in flight; pending_request_id set),
|
||||
-- 'legacy' (predates 4-eye; backfilled in §6 below).
|
||||
--
|
||||
-- pending_request_id: FK to the in-flight approval_requests row. NULL when
|
||||
-- approval_status != 'pending'. ON DELETE SET NULL keeps the entity row
|
||||
-- intact if an approval_requests row is ever pruned.
|
||||
--
|
||||
-- approved_by / approved_at: set on transition to approval_status='approved'
|
||||
-- after a 4-eye approval. NULL for 'legacy' rows and rows that never went
|
||||
-- through 4-eye (no policy applied).
|
||||
--
|
||||
-- appointments.completed_at: new column for the appointment:complete
|
||||
-- lifecycle event. Nullable; NULL means "not yet marked done".
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.deadlines
|
||||
ADD COLUMN approval_status text NOT NULL DEFAULT 'approved'
|
||||
CHECK (approval_status IN ('approved', 'pending', 'legacy')),
|
||||
ADD COLUMN pending_request_id uuid
|
||||
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL,
|
||||
ADD COLUMN approved_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
ADD COLUMN approved_at timestamptz;
|
||||
|
||||
CREATE INDEX deadlines_approval_status_pending_idx
|
||||
ON paliad.deadlines (approval_status)
|
||||
WHERE approval_status = 'pending';
|
||||
|
||||
ALTER TABLE paliad.appointments
|
||||
ADD COLUMN approval_status text NOT NULL DEFAULT 'approved'
|
||||
CHECK (approval_status IN ('approved', 'pending', 'legacy')),
|
||||
ADD COLUMN pending_request_id uuid
|
||||
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL,
|
||||
ADD COLUMN approved_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
ADD COLUMN approved_at timestamptz,
|
||||
ADD COLUMN completed_at timestamptz;
|
||||
|
||||
CREATE INDEX appointments_approval_status_pending_idx
|
||||
ON paliad.appointments (approval_status)
|
||||
WHERE approval_status = 'pending';
|
||||
|
||||
-- ============================================================================
|
||||
-- 6. Backfill: mark every existing row legacy.
|
||||
--
|
||||
-- Per design §6.5 / m's Q11 answer: existing pre-4-eye rows are read-clean;
|
||||
-- they don't need retroactive approval. The next mutation on a legacy row
|
||||
-- that hits an active policy (none exist on day 1) will trigger normal flow
|
||||
-- and lift the row to 'approved' (or 'pending' until signed off).
|
||||
--
|
||||
-- created_by is already populated since migration 005. approved_by stays
|
||||
-- NULL on legacy rows.
|
||||
-- ============================================================================
|
||||
|
||||
UPDATE paliad.deadlines SET approval_status = 'legacy';
|
||||
UPDATE paliad.appointments SET approval_status = 'legacy';
|
||||
53
internal/db/migrations/055_hierarchy_aggregation.down.sql
Normal file
53
internal/db/migrations/055_hierarchy_aggregation.down.sql
Normal file
@@ -0,0 +1,53 @@
|
||||
-- Down migration for t-paliad-139 (055_hierarchy_aggregation).
|
||||
--
|
||||
-- Reverses the schema additions in lockstep with the up migration:
|
||||
-- 1. Restore can_see_project to the migration-023 body (drop derivation
|
||||
-- branch).
|
||||
-- 2. Drop paliad.approval_role_from_unit_role helper.
|
||||
-- 3. Drop paliad.project_partner_units (cascades the policies + index).
|
||||
-- 4. Drop paliad.partner_unit_members.unit_role.
|
||||
--
|
||||
-- If any project has project_partner_units rows with derive_grants_authority=true
|
||||
-- AND any approval_request was ever signed using a derived_peer decision_kind
|
||||
-- (t-paliad-139 Phase 3), the down does NOT roll those back — the audit rows
|
||||
-- stay valid; only the schema is reverted. Down is intentionally lossy on
|
||||
-- in-flight derivation state.
|
||||
|
||||
-- Restore the migration-054 decision_kind CHECK (without 'derived_peer').
|
||||
-- Any existing rows with decision_kind='derived_peer' would fail the
|
||||
-- restored CHECK; the down deliberately doesn't update them — operators
|
||||
-- must reconcile before applying the down migration.
|
||||
ALTER TABLE paliad.approval_requests DROP CONSTRAINT IF EXISTS approval_requests_decision_kind_check;
|
||||
ALTER TABLE paliad.approval_requests ADD CONSTRAINT approval_requests_decision_kind_check
|
||||
CHECK (decision_kind IS NULL OR decision_kind IN ('peer', 'admin_override'));
|
||||
|
||||
-- 1. Restore migration-023 can_see_project body (no derivation branch).
|
||||
CREATE OR REPLACE FUNCTION paliad.can_see_project(_project_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = paliad, public
|
||||
AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.projects target
|
||||
JOIN paliad.project_teams pt
|
||||
ON pt.user_id = auth.uid()
|
||||
AND pt.project_id = ANY(string_to_array(target.path, '.')::uuid[])
|
||||
WHERE target.id = _project_id
|
||||
);
|
||||
$$;
|
||||
|
||||
-- 2. Drop the unit_role → project_role mapping helper.
|
||||
DROP FUNCTION IF EXISTS paliad.approval_role_from_unit_role(text);
|
||||
|
||||
-- 3. Drop the project↔unit junction (CASCADE clears policies + index).
|
||||
DROP TABLE IF EXISTS paliad.project_partner_units;
|
||||
|
||||
-- 4. Drop the unit_role column.
|
||||
ALTER TABLE paliad.partner_unit_members DROP COLUMN IF EXISTS unit_role;
|
||||
174
internal/db/migrations/055_hierarchy_aggregation.up.sql
Normal file
174
internal/db/migrations/055_hierarchy_aggregation.up.sql
Normal file
@@ -0,0 +1,174 @@
|
||||
-- t-paliad-139: hierarchy aggregation — partner-unit derivation schema.
|
||||
--
|
||||
-- Design: docs/design-hierarchy-aggregation-2026-05-06.md (noether, m-locked 2026-05-06).
|
||||
--
|
||||
-- This is the Phase 2 schema migration. Day-1 deploy = zero behaviour change
|
||||
-- because:
|
||||
-- - Every existing partner_unit_members row defaults to unit_role='attorney'.
|
||||
-- - The default derive_unit_roles on the new junction is {'pa','senior_pa'}.
|
||||
-- - No project_partner_units rows exist yet; admins opt-in by attaching
|
||||
-- units to projects.
|
||||
-- Until those two conditions diverge, no derivation happens and visibility
|
||||
-- behaves identically to the pre-055 world.
|
||||
--
|
||||
-- Sections:
|
||||
-- 1. ALTER paliad.partner_unit_members ADD COLUMN unit_role.
|
||||
-- 2. CREATE paliad.project_partner_units junction (with RLS).
|
||||
-- 3. CREATE paliad.approval_role_from_unit_role helper.
|
||||
-- 4. CREATE OR REPLACE paliad.can_see_project — extended with derivation
|
||||
-- branch.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. unit_role on paliad.partner_unit_members.
|
||||
--
|
||||
-- Per-unit role distinction so derivation can target specific tiers (default
|
||||
-- {pa, senior_pa}) without re-introducing a firm-wide rank column. The same
|
||||
-- user can have a different unit_role in different units; in practice most
|
||||
-- users belong to one unit so this is effectively a firm-rank, but the per-
|
||||
-- unit framing preserves the t-paliad-051/-138 three-axis principle on the
|
||||
-- user side (job_title remains free-text display, global_role stays
|
||||
-- standard|global_admin).
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.partner_unit_members
|
||||
ADD COLUMN unit_role text NOT NULL DEFAULT 'attorney'
|
||||
CHECK (unit_role IN ('lead', 'attorney', 'senior_pa', 'pa', 'paralegal'));
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. paliad.project_partner_units — project ↔ unit involvement.
|
||||
--
|
||||
-- A row here means "this unit is involved on this project, and the listed
|
||||
-- unit_roles auto-derive onto the project team". Authority defaults to off
|
||||
-- (visibility-only): set derive_grants_authority=true to let derived members
|
||||
-- count as approvers (per t-paliad-139 §3.4). Composite PK enforces "one
|
||||
-- attachment per (project, unit)".
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE paliad.project_partner_units (
|
||||
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
partner_unit_id uuid NOT NULL REFERENCES paliad.partner_units(id) ON DELETE CASCADE,
|
||||
-- Roles in the unit that auto-derive onto the project team. Defaults
|
||||
-- target PAs only; a project can widen to ['pa','senior_pa','attorney']
|
||||
-- to pull the whole unit, or narrow to ['pa'] to exclude senior_pa.
|
||||
derive_unit_roles text[] NOT NULL DEFAULT ARRAY['pa', 'senior_pa'],
|
||||
-- Strict default: derived members are visibility-only. Flipping this on
|
||||
-- lets them be eligible approvers per the t-138 ladder via the mapping
|
||||
-- in paliad.approval_role_from_unit_role.
|
||||
derive_grants_authority boolean NOT NULL DEFAULT false,
|
||||
attached_at timestamptz NOT NULL DEFAULT now(),
|
||||
attached_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
PRIMARY KEY (project_id, partner_unit_id)
|
||||
);
|
||||
|
||||
CREATE INDEX project_partner_units_unit_idx
|
||||
ON paliad.project_partner_units (partner_unit_id, project_id);
|
||||
|
||||
ALTER TABLE paliad.project_partner_units ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Anyone who can see the project can see the unit attachment. Mirrors the
|
||||
-- approval_requests / deadlines / appointments policy.
|
||||
CREATE POLICY project_partner_units_select
|
||||
ON paliad.project_partner_units FOR SELECT
|
||||
USING (paliad.can_see_project(project_id));
|
||||
|
||||
-- Writes gated to global_admin OR project lead. Same pattern as
|
||||
-- /admin/team and /admin/partner-units precedent.
|
||||
CREATE POLICY project_partner_units_write
|
||||
ON paliad.project_partner_units FOR ALL
|
||||
USING (
|
||||
EXISTS (SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
|
||||
OR EXISTS (SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = auth.uid()
|
||||
AND pt.project_id = project_partner_units.project_id
|
||||
AND pt.role = 'lead')
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
|
||||
OR EXISTS (SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = auth.uid()
|
||||
AND pt.project_id = project_partner_units.project_id
|
||||
AND pt.role = 'lead')
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. paliad.approval_role_from_unit_role — unit_role → project_role mapping.
|
||||
--
|
||||
-- Used when a derived member's authority is evaluated by the t-138 strict
|
||||
-- ladder. The mapping is intentional:
|
||||
-- lead → lead (the unit's lead, matches project lead tier)
|
||||
-- attorney → associate (default for working lawyers)
|
||||
-- senior_pa → senior_pa (1:1)
|
||||
-- pa → pa (1:1)
|
||||
-- paralegal → observer (level 0 — ineligible to approve)
|
||||
-- The ApprovalService (t-138) reads project_teams.role first; only when that
|
||||
-- has no row does it fall back to derived authority via this mapping (and
|
||||
-- only when the project_partner_units row has derive_grants_authority=true).
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.approval_role_from_unit_role(unit_role text)
|
||||
RETURNS text LANGUAGE SQL IMMUTABLE AS $$
|
||||
SELECT CASE unit_role
|
||||
WHEN 'lead' THEN 'lead'
|
||||
WHEN 'attorney' THEN 'associate'
|
||||
WHEN 'senior_pa' THEN 'senior_pa'
|
||||
WHEN 'pa' THEN 'pa'
|
||||
ELSE 'observer'
|
||||
END
|
||||
$$;
|
||||
|
||||
-- ============================================================================
|
||||
-- 4. Extend paliad.approval_requests.decision_kind CHECK to allow
|
||||
-- 'derived_peer' — a derived (partner-unit) member with authority who
|
||||
-- signed off via the t-paliad-138 inbox path. Distinct from plain
|
||||
-- 'peer' so the audit trail discloses the derivation chain.
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.approval_requests DROP CONSTRAINT IF EXISTS approval_requests_decision_kind_check;
|
||||
ALTER TABLE paliad.approval_requests ADD CONSTRAINT approval_requests_decision_kind_check
|
||||
CHECK (decision_kind IS NULL OR decision_kind IN ('peer', 'admin_override', 'derived_peer'));
|
||||
|
||||
-- ============================================================================
|
||||
-- 5. paliad.can_see_project — extended with derivation branch.
|
||||
--
|
||||
-- Same shape as the migration-023 body, plus one EXISTS branch: a user is
|
||||
-- visible on a project if there is any (ancestor of project) attached to a
|
||||
-- partner_unit they are a member of, AND their unit_role is in the derive
|
||||
-- set for that attachment. Read-cost is small (project_partner_units +
|
||||
-- partner_unit_members are tiny).
|
||||
--
|
||||
-- t-paliad-139 §3.3 Option B: compute on read, no materialised state.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.can_see_project(_project_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = paliad, public
|
||||
AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.projects target
|
||||
JOIN paliad.project_teams pt
|
||||
ON pt.user_id = auth.uid()
|
||||
AND pt.project_id = ANY(string_to_array(target.path, '.')::uuid[])
|
||||
WHERE target.id = _project_id
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.projects target
|
||||
JOIN paliad.project_partner_units ppu
|
||||
ON ppu.project_id = ANY(string_to_array(target.path, '.')::uuid[])
|
||||
JOIN paliad.partner_unit_members pum
|
||||
ON pum.partner_unit_id = ppu.partner_unit_id
|
||||
AND pum.user_id = auth.uid()
|
||||
AND pum.unit_role = ANY(ppu.derive_unit_roles)
|
||||
WHERE target.id = _project_id
|
||||
);
|
||||
$$;
|
||||
@@ -108,7 +108,8 @@ func handleListAppointmentsForProject(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
rows, err := dbSvc.appointment.ListForProject(r.Context(), uid, projectID)
|
||||
directOnly := parseDirectOnly(r.URL.Query().Get("direct_only"))
|
||||
rows, err := dbSvc.appointment.ListForProject(r.Context(), uid, projectID, directOnly)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
|
||||
297
internal/handlers/approvals.go
Normal file
297
internal/handlers/approvals.go
Normal file
@@ -0,0 +1,297 @@
|
||||
package handlers
|
||||
|
||||
// Approval workflow HTTP endpoints (t-paliad-138).
|
||||
//
|
||||
// Three groups of routes:
|
||||
//
|
||||
// 1. Policy CRUD (admin-only, gated at the route layer):
|
||||
// GET /api/projects/{id}/approval-policies
|
||||
// PUT /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}
|
||||
// DELETE /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}
|
||||
//
|
||||
// 2. Inbox (any authenticated user — the service-layer query gates by
|
||||
// project visibility + approver eligibility):
|
||||
// GET /api/inbox/pending-mine — requests I can approve
|
||||
// GET /api/inbox/mine — requests I submitted
|
||||
// GET /api/inbox/count — bell badge count
|
||||
// GET /api/approval-requests/{id} — one request hydrated
|
||||
//
|
||||
// 3. Decisions (any authenticated user — service layer gates the action):
|
||||
// POST /api/approval-requests/{id}/approve
|
||||
// POST /api/approval-requests/{id}/reject
|
||||
// POST /api/approval-requests/{id}/revoke
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Policy CRUD (admin only — gated by RequireAdminFunc at registration).
|
||||
// ============================================================================
|
||||
|
||||
// GET /api/projects/{id}/approval-policies
|
||||
func handleListApprovalPolicies(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
projectID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
rows, err := dbSvc.approval.ListPolicies(r.Context(), projectID)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []models.ApprovalPolicy{} // ensure JSON [] not null
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// PUT /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}
|
||||
//
|
||||
// Body: {"required_role": "associate"}
|
||||
//
|
||||
// Semantics: upsert. Replaces any existing row for the same
|
||||
// (project, entity_type, lifecycle) tuple.
|
||||
func handlePutApprovalPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
projectID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
entityType := r.PathValue("entity_type")
|
||||
lifecycle := r.PathValue("lifecycle")
|
||||
var body struct {
|
||||
RequiredRole string `json:"required_role"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
policy, err := dbSvc.approval.UpsertPolicy(r.Context(), projectID, uid, entityType, lifecycle, body.RequiredRole)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, policy)
|
||||
}
|
||||
|
||||
// DELETE /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}
|
||||
//
|
||||
// Removes one policy row, reverting that lifecycle event back to the
|
||||
// no-approval-needed default.
|
||||
func handleDeleteApprovalPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
projectID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
entityType := r.PathValue("entity_type")
|
||||
lifecycle := r.PathValue("lifecycle")
|
||||
if err := dbSvc.approval.DeletePolicy(r.Context(), projectID, entityType, lifecycle); err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Inbox.
|
||||
// ============================================================================
|
||||
|
||||
// GET /api/inbox/pending-mine — requests I'm qualified to approve.
|
||||
func handleListInboxPendingMine(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := dbSvc.approval.ListPendingForApprover(r.Context(), uid, parseInboxFilter(r))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// GET /api/inbox/mine — requests I submitted.
|
||||
func handleListInboxMine(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := dbSvc.approval.ListSubmittedByUser(r.Context(), uid, parseInboxFilter(r))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// GET /api/inbox/count — bell badge count for the sidebar.
|
||||
func handleInboxCount(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
n, err := dbSvc.approval.PendingCountForUser(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int{"count": n})
|
||||
}
|
||||
|
||||
// parseInboxFilter pulls common filter knobs off the query string.
|
||||
func parseInboxFilter(r *http.Request) services.InboxFilter {
|
||||
q := r.URL.Query()
|
||||
f := services.InboxFilter{
|
||||
Status: q.Get("status"),
|
||||
EntityType: q.Get("entity_type"),
|
||||
}
|
||||
if pid := q.Get("project_id"); pid != "" {
|
||||
if id, err := uuid.Parse(pid); err == nil {
|
||||
f.ProjectID = &id
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// GET /api/approval-requests/{id} — one hydrated request.
|
||||
func handleGetApprovalRequest(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
requestID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request id"})
|
||||
return
|
||||
}
|
||||
row, err := dbSvc.approval.GetRequest(r.Context(), requestID)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if row == nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Decisions.
|
||||
// ============================================================================
|
||||
|
||||
type approvalDecisionBody struct {
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// POST /api/approval-requests/{id}/approve
|
||||
func handleApproveApprovalRequest(w http.ResponseWriter, r *http.Request) {
|
||||
handleApprovalDecision(w, r, "approve")
|
||||
}
|
||||
|
||||
// POST /api/approval-requests/{id}/reject
|
||||
func handleRejectApprovalRequest(w http.ResponseWriter, r *http.Request) {
|
||||
handleApprovalDecision(w, r, "reject")
|
||||
}
|
||||
|
||||
// POST /api/approval-requests/{id}/revoke
|
||||
func handleRevokeApprovalRequest(w http.ResponseWriter, r *http.Request) {
|
||||
handleApprovalDecision(w, r, "revoke")
|
||||
}
|
||||
|
||||
func handleApprovalDecision(w http.ResponseWriter, r *http.Request, action string) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
requestID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request id"})
|
||||
return
|
||||
}
|
||||
var body approvalDecisionBody
|
||||
if r.Body != nil && r.ContentLength > 0 {
|
||||
_ = json.NewDecoder(r.Body).Decode(&body) // body is optional
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "approve":
|
||||
err = dbSvc.approval.Approve(r.Context(), requestID, uid, body.Note)
|
||||
case "reject":
|
||||
err = dbSvc.approval.Reject(r.Context(), requestID, uid, body.Note)
|
||||
case "revoke":
|
||||
err = dbSvc.approval.Revoke(r.Context(), requestID, uid)
|
||||
}
|
||||
if err != nil {
|
||||
writeApprovalError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// GET /inbox — server-static page shell. Hydration is purely client-side
|
||||
// (the bundle calls /api/inbox/pending-mine on load).
|
||||
func handleInboxPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/inbox.html")
|
||||
}
|
||||
|
||||
// writeApprovalError maps approval-flow errors to HTTP status codes.
|
||||
func writeApprovalError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrSelfApproval):
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "self_approval_blocked"})
|
||||
case errors.Is(err, services.ErrNoQualifiedApprover):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "no_qualified_approver"})
|
||||
case errors.Is(err, services.ErrConcurrentPending):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "concurrent_pending"})
|
||||
case errors.Is(err, services.ErrNotApprover):
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "not_authorized"})
|
||||
case errors.Is(err, services.ErrRequestNotPending):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "request_not_pending"})
|
||||
default:
|
||||
writeServiceError(w, err)
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,8 @@ func handleListDeadlinesForProject(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
rows, err := dbSvc.deadline.ListForProject(r.Context(), uid, projectID)
|
||||
directOnly := parseDirectOnly(r.URL.Query().Get("direct_only"))
|
||||
rows, err := dbSvc.deadline.ListForProject(r.Context(), uid, projectID, directOnly)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
|
||||
189
internal/handlers/derivation.go
Normal file
189
internal/handlers/derivation.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package handlers
|
||||
|
||||
// HTTP handlers for partner-unit derivation (t-paliad-139 Phase 2).
|
||||
//
|
||||
// Endpoints:
|
||||
// GET /api/projects/{id}/partner-units → list attached units
|
||||
// POST /api/projects/{id}/partner-units → attach (or update opts)
|
||||
// DELETE /api/projects/{id}/partner-units/{unit_id} → detach
|
||||
// GET /api/projects/{id}/team/derived → list derived members
|
||||
// GET /api/projects/{id}/team/from-descendants → list descendant-staffed
|
||||
// PATCH /api/partner-units/{id}/members/{user_id}/role → set unit_role on a member
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// GET /api/projects/{id}/partner-units
|
||||
func handleListAttachedUnits(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
projectID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
rows, err := dbSvc.derivation.ListAttachedUnits(r.Context(), uid, projectID)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// POST /api/projects/{id}/partner-units
|
||||
//
|
||||
// Body: { partner_unit_id, derive_unit_roles[]?, derive_grants_authority? }.
|
||||
// Idempotent on (project_id, partner_unit_id) — repeat calls update opts.
|
||||
func handleAttachPartnerUnit(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
projectID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
PartnerUnitID string `json:"partner_unit_id"`
|
||||
DeriveUnitRoles []string `json:"derive_unit_roles"`
|
||||
DeriveGrantsAuthority bool `json:"derive_grants_authority"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
unitID, err := uuid.Parse(body.PartnerUnitID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid partner_unit_id"})
|
||||
return
|
||||
}
|
||||
if err := dbSvc.derivation.AttachUnitToProject(r.Context(), uid, projectID, unitID, services.AttachUnitOptions{
|
||||
DeriveUnitRoles: body.DeriveUnitRoles,
|
||||
DeriveGrantsAuthority: body.DeriveGrantsAuthority,
|
||||
}); err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// DELETE /api/projects/{id}/partner-units/{unit_id}
|
||||
func handleDetachPartnerUnit(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
projectID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
unitID, err := uuid.Parse(r.PathValue("unit_id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid unit_id"})
|
||||
return
|
||||
}
|
||||
if err := dbSvc.derivation.DetachUnitFromProject(r.Context(), uid, projectID, unitID); err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
// GET /api/projects/{id}/team/derived
|
||||
func handleListDerivedTeam(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
projectID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
rows, err := dbSvc.derivation.ListDerivedMembers(r.Context(), uid, projectID)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// GET /api/projects/{id}/team/from-descendants
|
||||
func handleListDescendantStaffedTeam(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
projectID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
rows, err := dbSvc.derivation.ListDescendantStaffed(r.Context(), uid, projectID)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// PATCH /api/partner-units/{id}/members/{user_id}/role
|
||||
//
|
||||
// Body: { unit_role: 'lead'|'attorney'|'senior_pa'|'pa'|'paralegal' }.
|
||||
// Admin-only (gated by PartnerUnitService.SetMemberRole's requireAdmin).
|
||||
func handleSetUnitMemberRole(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
unitID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
userID, err := uuid.Parse(r.PathValue("user_id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid user_id"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
UnitRole string `json:"unit_role"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
if err := dbSvc.partnerUnit.SetMemberRole(r.Context(), uid, unitID, userID, body.UnitRole); err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
@@ -62,6 +62,8 @@ type Services struct {
|
||||
Link *services.LinkService
|
||||
Event *services.EventService
|
||||
Courts *services.CourtService
|
||||
Approval *services.ApprovalService
|
||||
Derivation *services.DerivationService
|
||||
}
|
||||
|
||||
func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc *Services) {
|
||||
@@ -96,6 +98,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
link: svc.Link,
|
||||
event: svc.Event,
|
||||
courts: svc.Courts,
|
||||
approval: svc.Approval,
|
||||
derivation: svc.Derivation,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +204,13 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /api/projects/{id}/team", handleListProjectTeam)
|
||||
protected.HandleFunc("POST /api/projects/{id}/team", handleAddProjectTeamMember)
|
||||
protected.HandleFunc("DELETE /api/projects/{id}/team/{user_id}", handleRemoveProjectTeamMember)
|
||||
// t-paliad-139 — sub-team aggregation surfaces for the Team tab.
|
||||
protected.HandleFunc("GET /api/projects/{id}/team/derived", handleListDerivedTeam)
|
||||
protected.HandleFunc("GET /api/projects/{id}/team/from-descendants", handleListDescendantStaffedTeam)
|
||||
// t-paliad-139 — project ↔ partner-unit attachment management.
|
||||
protected.HandleFunc("GET /api/projects/{id}/partner-units", handleListAttachedUnits)
|
||||
protected.HandleFunc("POST /api/projects/{id}/partner-units", handleAttachPartnerUnit)
|
||||
protected.HandleFunc("DELETE /api/projects/{id}/partner-units/{unit_id}", handleDetachPartnerUnit)
|
||||
|
||||
// Partner units (structural partner-led units; legacy "Dezernate").
|
||||
protected.HandleFunc("GET /api/partner-units", handleListPartnerUnits)
|
||||
@@ -210,6 +221,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /api/partner-units/{id}/members", handleListPartnerUnitMembers)
|
||||
protected.HandleFunc("POST /api/partner-units/{id}/members", handleAddPartnerUnitMember)
|
||||
protected.HandleFunc("DELETE /api/partner-units/{id}/members/{user_id}", handleRemovePartnerUnitMember)
|
||||
// t-paliad-139 — set unit_role on a member.
|
||||
protected.HandleFunc("PATCH /api/partner-units/{id}/members/{user_id}/role", handleSetUnitMemberRole)
|
||||
|
||||
protected.HandleFunc("DELETE /api/parties/{id}", handleDeleteParty)
|
||||
|
||||
@@ -366,6 +379,28 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("POST /api/admin/event-types/merge", adminGate(users, handleAdminMergeEventTypes))
|
||||
protected.HandleFunc("POST /api/admin/event-types/{id}/promote", adminGate(users, handleAdminPromoteEventType))
|
||||
protected.HandleFunc("POST /api/admin/event-types/{id}/restore", adminGate(users, handleAdminRestoreEventType))
|
||||
|
||||
// t-paliad-138 — approval-policy CRUD (admin only). The inbox
|
||||
// + decision endpoints are NOT admin-only — they're below.
|
||||
protected.HandleFunc("GET /api/projects/{id}/approval-policies",
|
||||
adminGate(users, handleListApprovalPolicies))
|
||||
protected.HandleFunc("PUT /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}",
|
||||
adminGate(users, handlePutApprovalPolicy))
|
||||
protected.HandleFunc("DELETE /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}",
|
||||
adminGate(users, handleDeleteApprovalPolicy))
|
||||
}
|
||||
|
||||
// t-paliad-138 — approval inbox + decision endpoints (any authenticated
|
||||
// user; the service layer gates approve/reject by required-role match).
|
||||
if svc != nil && svc.Approval != nil {
|
||||
protected.HandleFunc("GET /inbox", gateOnboarded(handleInboxPage))
|
||||
protected.HandleFunc("GET /api/inbox/pending-mine", handleListInboxPendingMine)
|
||||
protected.HandleFunc("GET /api/inbox/mine", handleListInboxMine)
|
||||
protected.HandleFunc("GET /api/inbox/count", handleInboxCount)
|
||||
protected.HandleFunc("GET /api/approval-requests/{id}", handleGetApprovalRequest)
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/approve", handleApproveApprovalRequest)
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/reject", handleRejectApprovalRequest)
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/revoke", handleRevokeApprovalRequest)
|
||||
}
|
||||
|
||||
// Catch-all 404 — runs for any authenticated path that no more-specific
|
||||
@@ -391,3 +426,15 @@ func writeJSON(w http.ResponseWriter, status int, data any) {
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
// parseDirectOnly reads a `direct_only=true|false` query value. Returns true
|
||||
// only for the explicit "true" / "1" forms; everything else (including empty)
|
||||
// is the subtree-aggregating default per t-paliad-139.
|
||||
func parseDirectOnly(raw string) bool {
|
||||
switch raw {
|
||||
case "true", "1":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ type dbServices struct {
|
||||
link *services.LinkService
|
||||
event *services.EventService
|
||||
courts *services.CourtService
|
||||
approval *services.ApprovalService
|
||||
derivation *services.DerivationService
|
||||
}
|
||||
|
||||
var dbSvc *dbServices
|
||||
@@ -382,7 +384,8 @@ func handleListProjectEvents(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
limit = n
|
||||
}
|
||||
rows, err := dbSvc.projects.ListEvents(r.Context(), uid, id, before, limit)
|
||||
directOnly := parseDirectOnly(q.Get("direct_only"))
|
||||
rows, err := dbSvc.projects.ListEvents(r.Context(), uid, id, before, limit, directOnly)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
|
||||
@@ -147,17 +147,23 @@ type PartnerUnitMember struct {
|
||||
|
||||
// ProjectEvent is one row in the per-Project audit trail
|
||||
// (paliad.project_events, renamed from paliad.project_events in migration 018).
|
||||
//
|
||||
// ProjectTitle is populated only by readers that join paliad.projects (e.g.
|
||||
// ProjectService.ListEvents — Verlauf attribution for descendant events on
|
||||
// /projects/{id}, t-paliad-139). Other readers leave it nil and the JSON
|
||||
// serialiser omits it.
|
||||
type ProjectEvent struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
|
||||
EventType *string `db:"event_type" json:"event_type,omitempty"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
EventDate *time.Time `db:"event_date" json:"event_date,omitempty"`
|
||||
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
|
||||
Metadata json.RawMessage `db:"metadata" json:"metadata"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
|
||||
EventType *string `db:"event_type" json:"event_type,omitempty"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
EventDate *time.Time `db:"event_date" json:"event_date,omitempty"`
|
||||
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
|
||||
Metadata json.RawMessage `db:"metadata" json:"metadata"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
ProjectTitle *string `db:"project_title" json:"project_title,omitempty"`
|
||||
}
|
||||
|
||||
// Deadline is one persistent deadline attached to a Project (typically a
|
||||
@@ -187,6 +193,17 @@ type Deadline struct {
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
|
||||
// Approval-workflow columns added by migration 054 (t-paliad-138).
|
||||
// approval_status: 'approved' (default), 'pending' (a request is in
|
||||
// flight; pending_request_id is set), 'legacy' (predates 4-eye).
|
||||
// approved_by / approved_at: populated when a 4-eye approval flips
|
||||
// the row from 'pending' back to 'approved'. NULL on legacy rows
|
||||
// and rows that never went through 4-eye.
|
||||
ApprovalStatus string `db:"approval_status" json:"approval_status"`
|
||||
PendingRequestID *uuid.UUID `db:"pending_request_id" json:"pending_request_id,omitempty"`
|
||||
ApprovedBy *uuid.UUID `db:"approved_by" json:"approved_by,omitempty"`
|
||||
ApprovedAt *time.Time `db:"approved_at" json:"approved_at,omitempty"`
|
||||
|
||||
// EventTypeIDs lists the paliad.event_types attached to this deadline
|
||||
// via the paliad.deadline_event_types junction. Always present (never
|
||||
// nil) once the row has been hydrated by DeadlineService.
|
||||
@@ -225,6 +242,17 @@ type Appointment struct {
|
||||
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
|
||||
// CompletedAt is non-NULL once the appointment is marked done. New
|
||||
// column added by migration 054 (t-paliad-138) — required to land the
|
||||
// appointment:complete lifecycle event.
|
||||
CompletedAt *time.Time `db:"completed_at" json:"completed_at,omitempty"`
|
||||
|
||||
// Approval-workflow columns (see Deadline doc above for semantics).
|
||||
ApprovalStatus string `db:"approval_status" json:"approval_status"`
|
||||
PendingRequestID *uuid.UUID `db:"pending_request_id" json:"pending_request_id,omitempty"`
|
||||
ApprovedBy *uuid.UUID `db:"approved_by" json:"approved_by,omitempty"`
|
||||
ApprovedAt *time.Time `db:"approved_at" json:"approved_at,omitempty"`
|
||||
}
|
||||
|
||||
// AppointmentWithProject enriches an Appointment with its parent Project
|
||||
@@ -469,3 +497,48 @@ const (
|
||||
EventTypeJurisdictionDE = "DE"
|
||||
EventTypeJurisdictionAny = "any"
|
||||
)
|
||||
|
||||
// ApprovalPolicy is one row of paliad.approval_policies — the per-(project,
|
||||
// entity_type, lifecycle_event) rule that says "this lifecycle event needs
|
||||
// 4-eye sign-off at the given role tier or above". Up to 8 rows per project
|
||||
// (deadline×4 + appointment×4); missing rows = no approval needed.
|
||||
type ApprovalPolicy struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
|
||||
EntityType string `db:"entity_type" json:"entity_type"`
|
||||
LifecycleEvent string `db:"lifecycle_event" json:"lifecycle_event"`
|
||||
RequiredRole string `db:"required_role" json:"required_role"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
|
||||
}
|
||||
|
||||
// ApprovalRequest is one row of paliad.approval_requests — an in-flight
|
||||
// state-change awaiting 4-eye sign-off.
|
||||
//
|
||||
// PreImage carries the field values needed to revert on rejection (NULL for
|
||||
// 'create' since there's nothing to revert to). Payload echoes the diff or
|
||||
// new values that were written, for audit display. RequiredRole is a
|
||||
// snapshot of the policy at request time.
|
||||
//
|
||||
// DecisionKind discriminates 'peer' (normal in-team sign-off) from
|
||||
// 'admin_override' (global_admin used the escape-hatch path).
|
||||
type ApprovalRequest struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
|
||||
EntityType string `db:"entity_type" json:"entity_type"`
|
||||
EntityID uuid.UUID `db:"entity_id" json:"entity_id"`
|
||||
LifecycleEvent string `db:"lifecycle_event" json:"lifecycle_event"`
|
||||
PreImage json.RawMessage `db:"pre_image" json:"pre_image,omitempty"`
|
||||
Payload json.RawMessage `db:"payload" json:"payload,omitempty"`
|
||||
RequestedBy uuid.UUID `db:"requested_by" json:"requested_by"`
|
||||
RequestedAt time.Time `db:"requested_at" json:"requested_at"`
|
||||
RequiredRole string `db:"required_role" json:"required_role"`
|
||||
Status string `db:"status" json:"status"`
|
||||
DecidedBy *uuid.UUID `db:"decided_by" json:"decided_by,omitempty"`
|
||||
DecidedAt *time.Time `db:"decided_at" json:"decided_at,omitempty"`
|
||||
DecisionKind *string `db:"decision_kind" json:"decision_kind,omitempty"`
|
||||
DecisionNote *string `db:"decision_note" json:"decision_note,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@ type AgendaItem struct {
|
||||
ProjectTitle *string `json:"project_title,omitempty"`
|
||||
ProjectType *string `json:"project_type,omitempty"`
|
||||
ProjectRef *string `json:"project_reference,omitempty"`
|
||||
// ApprovalStatus (t-paliad-138) — "pending" → render warning pill on
|
||||
// the agenda timeline. "approved"/"legacy" → no pill.
|
||||
ApprovalStatus *string `json:"approval_status,omitempty"`
|
||||
}
|
||||
|
||||
// AgendaFilter narrows the merged feed.
|
||||
@@ -167,6 +170,7 @@ SELECT f.id,
|
||||
f.title,
|
||||
f.due_date,
|
||||
f.status,
|
||||
f.approval_status,
|
||||
p.id AS project_id,
|
||||
p.title AS project_title,
|
||||
p.type AS project_type,
|
||||
@@ -184,6 +188,7 @@ SELECT f.id,
|
||||
Title string `db:"title"`
|
||||
DueDate time.Time `db:"due_date"`
|
||||
Status string `db:"status"`
|
||||
ApprovalStatus string `db:"approval_status"`
|
||||
ProjectID uuid.UUID `db:"project_id"`
|
||||
ProjectTitle string `db:"project_title"`
|
||||
ProjectType string `db:"project_type"`
|
||||
@@ -198,20 +203,22 @@ SELECT f.id,
|
||||
for _, r := range rows {
|
||||
due := r.DueDate.Format("2006-01-02")
|
||||
status := r.Status
|
||||
approvalStatus := r.ApprovalStatus
|
||||
projectID := r.ProjectID
|
||||
projectTitle := r.ProjectTitle
|
||||
projectType := r.ProjectType
|
||||
out = append(out, AgendaItem{
|
||||
ID: r.ID,
|
||||
Type: "deadline",
|
||||
Title: r.Title,
|
||||
Date: time.Date(r.DueDate.Year(), r.DueDate.Month(), r.DueDate.Day(), 0, 0, 0, 0, time.UTC),
|
||||
DueDate: &due,
|
||||
Status: &status,
|
||||
ProjectID: &projectID,
|
||||
ProjectTitle: &projectTitle,
|
||||
ProjectType: &projectType,
|
||||
ProjectRef: r.ProjectReference,
|
||||
ID: r.ID,
|
||||
Type: "deadline",
|
||||
Title: r.Title,
|
||||
Date: time.Date(r.DueDate.Year(), r.DueDate.Month(), r.DueDate.Day(), 0, 0, 0, 0, time.UTC),
|
||||
DueDate: &due,
|
||||
Status: &status,
|
||||
ApprovalStatus: &approvalStatus,
|
||||
ProjectID: &projectID,
|
||||
ProjectTitle: &projectTitle,
|
||||
ProjectType: &projectType,
|
||||
ProjectRef: r.ProjectReference,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
@@ -228,6 +235,7 @@ SELECT t.id,
|
||||
t.end_at,
|
||||
t.location,
|
||||
t.appointment_type,
|
||||
t.approval_status,
|
||||
t.project_id,
|
||||
p.title AS project_title,
|
||||
p.type AS project_type,
|
||||
@@ -249,6 +257,7 @@ SELECT t.id,
|
||||
EndAt *time.Time `db:"end_at"`
|
||||
Location *string `db:"location"`
|
||||
AppointmentType *string `db:"appointment_type"`
|
||||
ApprovalStatus string `db:"approval_status"`
|
||||
ProjectID *uuid.UUID `db:"project_id"`
|
||||
ProjectTitle *string `db:"project_title"`
|
||||
ProjectType *string `db:"project_type"`
|
||||
@@ -261,6 +270,7 @@ SELECT t.id,
|
||||
|
||||
out := make([]AgendaItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
approvalStatus := r.ApprovalStatus
|
||||
out = append(out, AgendaItem{
|
||||
ID: r.ID,
|
||||
Type: "appointment",
|
||||
@@ -269,6 +279,7 @@ SELECT t.id,
|
||||
EndAt: r.EndAt,
|
||||
Location: r.Location,
|
||||
AppointmentType: r.AppointmentType,
|
||||
ApprovalStatus: &approvalStatus,
|
||||
ProjectID: r.ProjectID,
|
||||
ProjectTitle: r.ProjectTitle,
|
||||
ProjectType: r.ProjectType,
|
||||
|
||||
@@ -29,7 +29,14 @@ type AppointmentService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
|
||||
caldav AppointmentCalDAVPusher
|
||||
caldav AppointmentCalDAVPusher
|
||||
approvals *ApprovalService
|
||||
}
|
||||
|
||||
// SetApprovalService wires the optional 4-eye approval workflow
|
||||
// (t-paliad-138). See DeadlineService.SetApprovalService.
|
||||
func (s *AppointmentService) SetApprovalService(a *ApprovalService) {
|
||||
s.approvals = a
|
||||
}
|
||||
|
||||
// AppointmentCalDAVPusher is the contract the CalDAV service implements so the
|
||||
@@ -52,7 +59,8 @@ func (s *AppointmentService) SetCalDAVPusher(p AppointmentCalDAVPusher) {
|
||||
|
||||
const appointmentColumns = `id, project_id, title, description, start_at, end_at,
|
||||
location, appointment_type, caldav_uid, caldav_etag, created_by,
|
||||
created_at, updated_at`
|
||||
created_at, updated_at, completed_at,
|
||||
approval_status, pending_request_id, approved_by, approved_at`
|
||||
|
||||
// CreateAppointmentInput is the payload for POST /api/appointments.
|
||||
type CreateAppointmentInput struct {
|
||||
@@ -146,6 +154,8 @@ func (s *AppointmentService) ListVisibleForUser(ctx context.Context, userID uuid
|
||||
SELECT t.id, t.project_id, t.title, t.description, t.start_at, t.end_at,
|
||||
t.location, t.appointment_type, t.caldav_uid, t.caldav_etag,
|
||||
t.created_by, t.created_at, t.updated_at,
|
||||
t.completed_at,
|
||||
t.approval_status, t.pending_request_id, t.approved_by, t.approved_at,
|
||||
p.reference AS project_reference,
|
||||
p.title AS project_title,
|
||||
p.type AS project_type
|
||||
@@ -167,16 +177,33 @@ func (s *AppointmentService) ListVisibleForUser(ctx context.Context, userID uuid
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ListForProject returns Appointments for a specific Project, visibility-checked.
|
||||
func (s *AppointmentService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.Appointment, error) {
|
||||
// ListForProject returns Appointments for a Project (visibility-checked).
|
||||
//
|
||||
// When directOnly is false (default), the result aggregates appointments
|
||||
// from the Project itself AND every descendant Project (per the
|
||||
// t-paliad-139 hierarchy aggregation contract). When directOnly is true,
|
||||
// only appointments whose project_id exactly equals the filter are
|
||||
// returned.
|
||||
//
|
||||
// The descendant aggregation mirrors DeadlineService.ListForProject — see
|
||||
// the doc comment there for the rationale.
|
||||
func (s *AppointmentService) ListForProject(ctx context.Context, userID, projectID uuid.UUID, directOnly bool) ([]models.Appointment, error) {
|
||||
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := []models.Appointment{}
|
||||
var filter string
|
||||
if directOnly {
|
||||
filter = `WHERE project_id = $1`
|
||||
} else {
|
||||
filter = `WHERE project_id IN (
|
||||
SELECT p.id FROM paliad.projects p
|
||||
WHERE $1 = ANY(string_to_array(p.path, '.')::uuid[]))`
|
||||
}
|
||||
if err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT `+appointmentColumns+`
|
||||
FROM paliad.appointments
|
||||
WHERE project_id = $1
|
||||
`+filter+`
|
||||
ORDER BY start_at ASC, created_at DESC`, projectID); err != nil {
|
||||
return nil, fmt.Errorf("list appointments for project: %w", err)
|
||||
}
|
||||
@@ -305,6 +332,15 @@ func (s *AppointmentService) Create(ctx context.Context, userID uuid.UUID, input
|
||||
map[string]any{"appointment_id": id}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Approval gate (t-paliad-138). No-op for personal appointments
|
||||
// (project_id IS NULL) and when no policy applies.
|
||||
if s.approvals != nil {
|
||||
payload := map[string]any{"title": title, "start_at": input.StartAt.UTC().Format(time.RFC3339)}
|
||||
if _, err := s.approvals.SubmitCreate(ctx, tx, *input.ProjectID, id, userID, EntityTypeAppointment, payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit insert appointment: %w", err)
|
||||
@@ -321,6 +357,11 @@ func (s *AppointmentService) Create(ctx context.Context, userID uuid.UUID, input
|
||||
}
|
||||
|
||||
// Update applies a partial update.
|
||||
//
|
||||
// Approval gate (t-paliad-138): only date-bearing fields (start_at,
|
||||
// end_at) trigger 4-eye per Q4. Cosmetic edits (title, description,
|
||||
// location, appointment_type) bypass approval. Personal appointments
|
||||
// (project_id IS NULL) never gate — there's no project policy to consult.
|
||||
func (s *AppointmentService) Update(ctx context.Context, userID, appointmentID uuid.UUID, input UpdateAppointmentInput) (*models.Appointment, error) {
|
||||
current, err := s.GetByID(ctx, userID, appointmentID)
|
||||
if err != nil {
|
||||
@@ -333,6 +374,9 @@ func (s *AppointmentService) Update(ctx context.Context, userID, appointmentID u
|
||||
} else if err := s.requireMutationRole(ctx, userID, current); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if current.ApprovalStatus == ApprovalStatusPending {
|
||||
return nil, ErrConcurrentPending
|
||||
}
|
||||
|
||||
sets := []string{}
|
||||
args := []any{}
|
||||
@@ -343,6 +387,9 @@ func (s *AppointmentService) Update(ctx context.Context, userID, appointmentID u
|
||||
next++
|
||||
}
|
||||
|
||||
preImage := map[string]any{}
|
||||
payload := map[string]any{}
|
||||
|
||||
if input.Title != nil {
|
||||
title := strings.TrimSpace(*input.Title)
|
||||
if title == "" {
|
||||
@@ -354,10 +401,28 @@ func (s *AppointmentService) Update(ctx context.Context, userID, appointmentID u
|
||||
appendSet("description", *input.Description)
|
||||
}
|
||||
if input.StartAt != nil {
|
||||
appendSet("start_at", input.StartAt.UTC())
|
||||
newStart := input.StartAt.UTC()
|
||||
if !newStart.Equal(current.StartAt) {
|
||||
preImage["start_at"] = current.StartAt.Format(time.RFC3339)
|
||||
payload["start_at"] = newStart.Format(time.RFC3339)
|
||||
}
|
||||
appendSet("start_at", newStart)
|
||||
}
|
||||
if input.EndAt != nil {
|
||||
appendSet("end_at", input.EndAt.UTC())
|
||||
newEnd := input.EndAt.UTC()
|
||||
oldEnd := time.Time{}
|
||||
if current.EndAt != nil {
|
||||
oldEnd = *current.EndAt
|
||||
}
|
||||
if !newEnd.Equal(oldEnd) {
|
||||
if current.EndAt != nil {
|
||||
preImage["end_at"] = current.EndAt.Format(time.RFC3339)
|
||||
} else {
|
||||
preImage["end_at"] = nil
|
||||
}
|
||||
payload["end_at"] = newEnd.Format(time.RFC3339)
|
||||
}
|
||||
appendSet("end_at", newEnd)
|
||||
}
|
||||
if input.Location != nil {
|
||||
appendSet("location", *input.Location)
|
||||
@@ -474,6 +539,11 @@ func (s *AppointmentService) Update(ctx context.Context, userID, appointmentID u
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if s.approvals != nil {
|
||||
if _, err := s.approvals.SubmitUpdate(ctx, tx, *current.ProjectID, appointmentID, userID, EntityTypeAppointment, preImage, payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit update appointment: %w", err)
|
||||
@@ -489,6 +559,12 @@ func (s *AppointmentService) Update(ctx context.Context, userID, appointmentID u
|
||||
}
|
||||
|
||||
// Delete removes an Appointment.
|
||||
//
|
||||
// Approval gate (t-paliad-138): for project-attached appointments, if a
|
||||
// (project, appointment, delete) policy applies, the row stays alive
|
||||
// with approval_status='pending' until the approver hard-deletes
|
||||
// (approve) or restores it (reject) — same stage-then-write exception
|
||||
// as DeadlineService.Delete.
|
||||
func (s *AppointmentService) Delete(ctx context.Context, userID, appointmentID uuid.UUID) error {
|
||||
current, err := s.GetByID(ctx, userID, appointmentID)
|
||||
if err != nil {
|
||||
@@ -501,6 +577,9 @@ func (s *AppointmentService) Delete(ctx context.Context, userID, appointmentID u
|
||||
} else if err := s.requireMutationRole(ctx, userID, current); err != nil {
|
||||
return err
|
||||
}
|
||||
if current.ApprovalStatus == ApprovalStatusPending {
|
||||
return ErrConcurrentPending
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
@@ -508,21 +587,39 @@ func (s *AppointmentService) Delete(ctx context.Context, userID, appointmentID u
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM paliad.appointments WHERE id = $1`, appointmentID); err != nil {
|
||||
return fmt.Errorf("delete appointment: %w", err)
|
||||
}
|
||||
if current.ProjectID != nil {
|
||||
desc := current.Title
|
||||
descPtr := &desc
|
||||
if err := insertProjectEvent(ctx, tx, *current.ProjectID, userID, "appointment_deleted", "Appointment deleted", descPtr); err != nil {
|
||||
// Approval gate runs first for project-attached appointments. If a
|
||||
// policy applies, SubmitDelete returns a non-nil request id and we
|
||||
// skip the hard delete + the deletion event emit.
|
||||
var pendingRequest *uuid.UUID
|
||||
if current.ProjectID != nil && s.approvals != nil {
|
||||
preImage := map[string]any{
|
||||
"title": current.Title,
|
||||
"start_at": current.StartAt.Format(time.RFC3339),
|
||||
}
|
||||
req, err := s.approvals.SubmitDelete(ctx, tx, *current.ProjectID, appointmentID, userID, EntityTypeAppointment, preImage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pendingRequest = req
|
||||
}
|
||||
|
||||
if pendingRequest == nil {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM paliad.appointments WHERE id = $1`, appointmentID); err != nil {
|
||||
return fmt.Errorf("delete appointment: %w", err)
|
||||
}
|
||||
if current.ProjectID != nil {
|
||||
desc := current.Title
|
||||
descPtr := &desc
|
||||
if err := insertProjectEvent(ctx, tx, *current.ProjectID, userID, "appointment_deleted", "Appointment deleted", descPtr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit delete appointment: %w", err)
|
||||
}
|
||||
if s.caldav != nil {
|
||||
if pendingRequest == nil && s.caldav != nil {
|
||||
s.caldav.OnAppointmentDeleted(ctx, userID, current)
|
||||
}
|
||||
return nil
|
||||
|
||||
108
internal/services/approval_levels.go
Normal file
108
internal/services/approval_levels.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package services
|
||||
|
||||
import "errors"
|
||||
|
||||
// Strict-ladder level helper for the 4-Augen-Prüfung approval gate
|
||||
// (t-paliad-138). Mirrors paliad.approval_role_level(text) in migration
|
||||
// 054. A user with project_teams.role R can approve any request whose
|
||||
// required_role has level <= levelOf(R). Roles outside the approval
|
||||
// ladder (local_counsel, expert, observer, anything new) return 0 and
|
||||
// are ineligible to approve at any level.
|
||||
|
||||
// RoleSeniorPA is the new project_teams.role value added by migration 054.
|
||||
// It sits between associate (3) and pa (1) and gives a named tier between
|
||||
// "associate" and "PA" for projects that want PAs supervised by senior PAs
|
||||
// rather than by associates.
|
||||
const RoleSeniorPA = "senior_pa"
|
||||
|
||||
// EntityType values for the polymorphic approval workflow.
|
||||
const (
|
||||
EntityTypeDeadline = "deadline"
|
||||
EntityTypeAppointment = "appointment"
|
||||
)
|
||||
|
||||
// LifecycleEvent values matching paliad.approval_policies.lifecycle_event
|
||||
// and paliad.approval_requests.lifecycle_event CHECK constraints.
|
||||
const (
|
||||
LifecycleCreate = "create"
|
||||
LifecycleUpdate = "update"
|
||||
LifecycleComplete = "complete"
|
||||
LifecycleDelete = "delete"
|
||||
)
|
||||
|
||||
// ApprovalStatus values on paliad.deadlines.approval_status and
|
||||
// paliad.appointments.approval_status.
|
||||
const (
|
||||
ApprovalStatusApproved = "approved"
|
||||
ApprovalStatusPending = "pending"
|
||||
ApprovalStatusLegacy = "legacy"
|
||||
)
|
||||
|
||||
// RequestStatus values on paliad.approval_requests.status.
|
||||
const (
|
||||
RequestStatusPending = "pending"
|
||||
RequestStatusApproved = "approved"
|
||||
RequestStatusRejected = "rejected"
|
||||
RequestStatusRevoked = "revoked"
|
||||
RequestStatusSuperseded = "superseded"
|
||||
)
|
||||
|
||||
// DecisionKind discriminates "peer" (normal in-team sign-off) from
|
||||
// "admin_override" (global_admin used the escape-hatch path) and
|
||||
// "derived_peer" (a partner-unit-derived member with authority signed off
|
||||
// — added by t-paliad-139 / migration 055). Verlauf chronology renders
|
||||
// these distinctly.
|
||||
const (
|
||||
DecisionKindPeer = "peer"
|
||||
DecisionKindAdminOverride = "admin_override"
|
||||
DecisionKindDerivedPeer = "derived_peer"
|
||||
)
|
||||
|
||||
// levelOf maps a project_teams.role value to its strict-ladder level.
|
||||
// Mirrors paliad.approval_role_level(text) in SQL.
|
||||
//
|
||||
// 5: lead — partner-tier on this project
|
||||
// 4: of_counsel
|
||||
// 3: associate ← default required level on new policies
|
||||
// 2: senior_pa — added by migration 054
|
||||
// 1: pa
|
||||
// 0: local_counsel / expert / observer / anything new — ineligible to approve
|
||||
func levelOf(role string) int {
|
||||
switch role {
|
||||
case "lead":
|
||||
return 5
|
||||
case "of_counsel":
|
||||
return 4
|
||||
case "associate":
|
||||
return 3
|
||||
case RoleSeniorPA:
|
||||
return 2
|
||||
case "pa":
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// IsValidRequiredRole returns true iff the role can be set as a policy's
|
||||
// required_role (i.e. it has a non-zero strict-ladder level).
|
||||
func IsValidRequiredRole(role string) bool {
|
||||
return levelOf(role) > 0
|
||||
}
|
||||
|
||||
// Approval-flow errors. Handlers map these to the right HTTP status:
|
||||
//
|
||||
// ErrSelfApproval -> 403
|
||||
// ErrNoQualifiedApprover -> 409 (with required_role hint)
|
||||
// ErrConcurrentPending -> 409 (with the existing request id hint)
|
||||
// ErrNotApprover -> 403
|
||||
// ErrRequestNotPending -> 409
|
||||
// ErrUnknownEntityType -> 500 (programming error)
|
||||
var (
|
||||
ErrSelfApproval = errors.New("self-approval blocked")
|
||||
ErrNoQualifiedApprover = errors.New("no qualified approver available")
|
||||
ErrConcurrentPending = errors.New("entity already has a pending approval request")
|
||||
ErrNotApprover = errors.New("not authorized to approve this request")
|
||||
ErrRequestNotPending = errors.New("request is not pending")
|
||||
ErrUnknownEntityType = errors.New("unknown entity type")
|
||||
)
|
||||
932
internal/services/approval_service.go
Normal file
932
internal/services/approval_service.go
Normal file
@@ -0,0 +1,932 @@
|
||||
package services
|
||||
|
||||
// ApprovalService implements the 4-Augen-Prüfung workflow on
|
||||
// paliad.deadlines and paliad.appointments (t-paliad-138).
|
||||
//
|
||||
// Architecture: write-then-approve (m's Q5 choice). The mutation lands on
|
||||
// the entity row immediately; the entity carries approval_status='pending'
|
||||
// + pending_request_id until an approver flips it to 'approved'. Delete is
|
||||
// the one stage-then-write exception — we mark the row pending instead of
|
||||
// hard-deleting, then hard-delete on approve / restore on reject.
|
||||
//
|
||||
// Submission entry points (Submit{Create,Update,Complete,Delete}) are
|
||||
// invoked by DeadlineService / AppointmentService inside their existing
|
||||
// transactions. They:
|
||||
// 1. Look up the policy for (project, entity_type, lifecycle_event).
|
||||
// 2. If no policy → no-op (entity stays approval_status='approved').
|
||||
// 3. If policy → run a deadlock check (qualified approver != requester
|
||||
// must exist), insert an approval_requests row, mark the entity
|
||||
// pending, emit a *_approval_requested project_events row.
|
||||
//
|
||||
// Decision entry points (Approve / Reject / Revoke) run their own tx and:
|
||||
// - Approve: validate canApprove(caller, request); flip the entity back
|
||||
// to approved (or hard-delete for delete-lifecycle); emit
|
||||
// *_approval_approved.
|
||||
// - Reject: validate canApprove; revert the entity from pre_image (or
|
||||
// hard-delete a pending-create); emit *_approval_rejected.
|
||||
// - Revoke: validate caller == requester; same revert as Reject; emit
|
||||
// *_approval_revoked.
|
||||
//
|
||||
// Self-approval is blocked at three layers:
|
||||
// 1. canApprove() returns ErrSelfApproval when caller == requester.
|
||||
// 2. The DB CHECK constraint approval_requests_no_self_approval refuses
|
||||
// decided_by == requested_by writes.
|
||||
// 3. The deadlock-check excludes the requester from the qualified-approver
|
||||
// pool, so the deadlock path can't be silently bypassed.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// ApprovalService is the workflow orchestrator. It holds no entity-specific
|
||||
// knowledge — DeadlineService / AppointmentService call its Submit*
|
||||
// methods, and the Approve / Reject / Revoke paths run direct SQL on the
|
||||
// entity tables to keep the dependency graph acyclic.
|
||||
type ApprovalService struct {
|
||||
db *sqlx.DB
|
||||
users *UserService
|
||||
}
|
||||
|
||||
// NewApprovalService wires the service.
|
||||
func NewApprovalService(db *sqlx.DB, users *UserService) *ApprovalService {
|
||||
return &ApprovalService{db: db, users: users}
|
||||
}
|
||||
|
||||
// LookupPolicy returns the approval policy for the given tuple, or nil if
|
||||
// none exists. Read inside the same tx as Submit* so policy reads see
|
||||
// whatever the calling tx may have already written.
|
||||
func (s *ApprovalService) LookupPolicy(ctx context.Context, tx *sqlx.Tx, projectID uuid.UUID, entityType, lifecycleEvent string) (*models.ApprovalPolicy, error) {
|
||||
var p models.ApprovalPolicy
|
||||
q := `SELECT id, project_id, entity_type, lifecycle_event, required_role,
|
||||
created_at, updated_at, created_by
|
||||
FROM paliad.approval_policies
|
||||
WHERE project_id = $1 AND entity_type = $2 AND lifecycle_event = $3`
|
||||
row := txOrDB(tx, s.db).QueryRowxContext(ctx, q, projectID, entityType, lifecycleEvent)
|
||||
if err := row.StructScan(&p); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("lookup approval policy: %w", err)
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// hasQualifiedApprover counts users on the project's team-membership path
|
||||
// (direct OR ancestor) whose role meets the strict-ladder threshold for
|
||||
// requiredRole, plus any global_admin user, plus any partner-unit-derived
|
||||
// member where the attachment grants authority (t-paliad-139). Excludes
|
||||
// requesterID.
|
||||
//
|
||||
// Returns true if at least one such user exists. The path-walk JOIN matches
|
||||
// the visibility predicate so an ancestor lead qualifies for a descendant's
|
||||
// approval, just like they have visibility.
|
||||
func (s *ApprovalService) hasQualifiedApprover(ctx context.Context, tx *sqlx.Tx, projectID, requesterID uuid.UUID, requiredRole string) (bool, error) {
|
||||
q := `WITH path AS (
|
||||
SELECT string_to_array(p.path, '.')::uuid[] AS ids
|
||||
FROM paliad.projects p WHERE p.id = $1
|
||||
)
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.project_teams pt
|
||||
JOIN path ON pt.project_id = ANY(path.ids)
|
||||
WHERE pt.user_id <> $2
|
||||
AND paliad.approval_role_level(pt.role) >= paliad.approval_role_level($3)
|
||||
UNION ALL
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.global_role = 'global_admin' AND u.id <> $2
|
||||
UNION ALL
|
||||
SELECT 1 FROM paliad.project_partner_units ppu
|
||||
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
|
||||
JOIN path ON ppu.project_id = ANY(path.ids)
|
||||
WHERE pum.user_id <> $2
|
||||
AND ppu.derive_grants_authority = true
|
||||
AND pum.unit_role = ANY(ppu.derive_unit_roles)
|
||||
AND paliad.approval_role_level(
|
||||
paliad.approval_role_from_unit_role(pum.unit_role)
|
||||
) >= paliad.approval_role_level($3)
|
||||
LIMIT 1
|
||||
) AS ok`
|
||||
var ok bool
|
||||
if err := txOrDB(tx, s.db).GetContext(ctx, &ok, q, projectID, requesterID, requiredRole); err != nil {
|
||||
return false, fmt.Errorf("deadlock check: %w", err)
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
// SubmitCreate is invoked by Deadline/AppointmentService inside their
|
||||
// create-tx, after the entity row has been INSERTed but before the
|
||||
// commit. If a (project, entity_type, 'create') policy applies, it inserts
|
||||
// the approval_requests row, marks the entity pending, and emits the
|
||||
// *_approval_requested audit event.
|
||||
//
|
||||
// payload is the just-inserted entity's field values (used as audit echo).
|
||||
//
|
||||
// Returns the new request ID if pending, nil if no policy applied.
|
||||
func (s *ApprovalService) SubmitCreate(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType string, payload map[string]any) (*uuid.UUID, error) {
|
||||
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleCreate, nil, payload)
|
||||
}
|
||||
|
||||
// SubmitUpdate is invoked after the entity row has been UPDATEd. preImage
|
||||
// carries the date-bearing fields that were just overwritten (per Q4
|
||||
// allowlist) so a rejection can restore them. payload echoes the new values.
|
||||
func (s *ApprovalService) SubmitUpdate(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType string, preImage, payload map[string]any) (*uuid.UUID, error) {
|
||||
if len(preImage) == 0 {
|
||||
// Nothing in the date-bearing allowlist actually changed — bypass
|
||||
// the approval flow entirely (the underlying UPDATE was cosmetic).
|
||||
return nil, nil
|
||||
}
|
||||
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleUpdate, preImage, payload)
|
||||
}
|
||||
|
||||
// SubmitComplete is invoked after status was flipped to 'completed'
|
||||
// (deadline) or completed_at was set (appointment). preImage stores the
|
||||
// pre-completion state so a rejection can revert.
|
||||
func (s *ApprovalService) SubmitComplete(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType string, preImage, payload map[string]any) (*uuid.UUID, error) {
|
||||
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleComplete, preImage, payload)
|
||||
}
|
||||
|
||||
// SubmitDelete is invoked WITHOUT a prior delete on the entity (delete is
|
||||
// the stage-then-write exception). The entity row stays alive with
|
||||
// approval_status='pending'; on approve we hard-delete, on reject we just
|
||||
// clear the pending markers.
|
||||
//
|
||||
// preImage stores the full row state so the inbox can render
|
||||
// "About to delete: Frist X (due 2026-05-12)".
|
||||
func (s *ApprovalService) SubmitDelete(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType string, preImage map[string]any) (*uuid.UUID, error) {
|
||||
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleDelete, preImage, nil)
|
||||
}
|
||||
|
||||
// submit is the shared lifecycle-handling kernel.
|
||||
func (s *ApprovalService) submit(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType, lifecycle string, preImage, payload map[string]any) (*uuid.UUID, error) {
|
||||
policy, err := s.LookupPolicy(ctx, tx, projectID, entityType, lifecycle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if policy == nil {
|
||||
// No policy applies — entity stays approval_status='approved'. No-op.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Deadlock check: somebody other than the requester must be qualified
|
||||
// to approve, either via project team membership or as global_admin.
|
||||
ok, err := s.hasQualifiedApprover(ctx, tx, projectID, requesterID, policy.RequiredRole)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: required role %q", ErrNoQualifiedApprover, policy.RequiredRole)
|
||||
}
|
||||
|
||||
// Concurrent-pending guard: the entity table has a CHECK / NOT NULL
|
||||
// guard against double-pending — but we surface a clean error rather
|
||||
// than letting the UPDATE silently fail. The guard relies on
|
||||
// approval_status='approved' being the precondition for a fresh
|
||||
// pending state.
|
||||
currentStatus, err := s.entityApprovalStatus(ctx, tx, entityType, entityID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if currentStatus == ApprovalStatusPending {
|
||||
return nil, ErrConcurrentPending
|
||||
}
|
||||
|
||||
requestID := uuid.New()
|
||||
preImageJSON, err := marshalJSONOrNull(preImage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal pre_image: %w", err)
|
||||
}
|
||||
payloadJSON, err := marshalJSONOrNull(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
|
||||
insertReqSQL := `INSERT INTO paliad.approval_requests
|
||||
(id, project_id, entity_type, entity_id, lifecycle_event,
|
||||
pre_image, payload, requested_by, required_role, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending')`
|
||||
if _, err := tx.ExecContext(ctx, insertReqSQL,
|
||||
requestID, projectID, entityType, entityID, lifecycle,
|
||||
preImageJSON, payloadJSON, requesterID, policy.RequiredRole); err != nil {
|
||||
return nil, fmt.Errorf("insert approval_request: %w", err)
|
||||
}
|
||||
|
||||
// Mark the entity row pending. The WHERE approval_status='approved'
|
||||
// (or 'legacy') guard makes the UPDATE atomic vs concurrent pending.
|
||||
updateEntitySQL := fmt.Sprintf(`UPDATE paliad.%s
|
||||
SET approval_status = 'pending', pending_request_id = $1, updated_at = now()
|
||||
WHERE id = $2 AND approval_status IN ('approved','legacy')`,
|
||||
entityTableName(entityType))
|
||||
res, err := tx.ExecContext(ctx, updateEntitySQL, requestID, entityID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mark entity pending: %w", err)
|
||||
}
|
||||
rows, _ := res.RowsAffected()
|
||||
if rows != 1 {
|
||||
// Either the entity vanished or another tx flipped it pending.
|
||||
return nil, ErrConcurrentPending
|
||||
}
|
||||
|
||||
// Audit emit.
|
||||
eventType := approvalEventType(entityType, "requested")
|
||||
descPtr := approvalDescription("requested", policy.RequiredRole, lifecycle)
|
||||
meta := map[string]any{
|
||||
"approval_request_id": requestID.String(),
|
||||
"lifecycle_event": lifecycle,
|
||||
"required_role": policy.RequiredRole,
|
||||
entityType + "_id": entityID.String(),
|
||||
}
|
||||
if err := insertProjectEventWithMeta(ctx, tx, projectID, requesterID, eventType, eventType, descPtr, meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &requestID, nil
|
||||
}
|
||||
|
||||
// Approve flips a pending request to 'approved' and applies the lifecycle
|
||||
// to the entity. Runs in its own transaction.
|
||||
func (s *ApprovalService) Approve(ctx context.Context, requestID, callerID uuid.UUID, note string) error {
|
||||
return s.decide(ctx, requestID, callerID, RequestStatusApproved, note)
|
||||
}
|
||||
|
||||
// Reject flips a pending request to 'rejected' and reverts the entity from
|
||||
// pre_image. Runs in its own transaction.
|
||||
func (s *ApprovalService) Reject(ctx context.Context, requestID, callerID uuid.UUID, note string) error {
|
||||
return s.decide(ctx, requestID, callerID, RequestStatusRejected, note)
|
||||
}
|
||||
|
||||
// Revoke is invoked by the requester to undo their own pending submission
|
||||
// before any approver acts on it. The entity reverts as if the request had
|
||||
// been rejected, but the request status is 'revoked'. Runs in its own tx.
|
||||
func (s *ApprovalService) Revoke(ctx context.Context, requestID, callerID uuid.UUID) error {
|
||||
return s.decide(ctx, requestID, callerID, RequestStatusRevoked, "")
|
||||
}
|
||||
|
||||
// decide is the shared kernel for Approve / Reject / Revoke. The decision
|
||||
// kind is derived from the (caller, request) relationship and the requested
|
||||
// final status:
|
||||
// - RequestStatusApproved: caller must NOT be requester; admin override or peer.
|
||||
// - RequestStatusRejected: same authorization rules as Approve.
|
||||
// - RequestStatusRevoked: caller MUST be requester.
|
||||
func (s *ApprovalService) decide(ctx context.Context, requestID, callerID uuid.UUID, finalStatus, note string) error {
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
req, err := s.getRequestForUpdate(ctx, tx, requestID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Status != RequestStatusPending {
|
||||
return fmt.Errorf("%w: status=%s", ErrRequestNotPending, req.Status)
|
||||
}
|
||||
|
||||
var decisionKind string
|
||||
switch finalStatus {
|
||||
case RequestStatusApproved, RequestStatusRejected:
|
||||
kind, err := s.canApprove(ctx, tx, callerID, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
decisionKind = kind
|
||||
case RequestStatusRevoked:
|
||||
if callerID != req.RequestedBy {
|
||||
return ErrNotApprover
|
||||
}
|
||||
decisionKind = DecisionKindPeer // unused for revoke but keeps non-NULL audit
|
||||
default:
|
||||
return fmt.Errorf("invalid final status %q", finalStatus)
|
||||
}
|
||||
|
||||
// Apply the lifecycle outcome to the entity.
|
||||
switch finalStatus {
|
||||
case RequestStatusApproved:
|
||||
if err := s.applyApproved(ctx, tx, req, callerID); err != nil {
|
||||
return err
|
||||
}
|
||||
case RequestStatusRejected, RequestStatusRevoked:
|
||||
if err := s.applyRevert(ctx, tx, req); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Update the request row.
|
||||
now := time.Now().UTC()
|
||||
var trimmedNote *string
|
||||
if n := strings.TrimSpace(note); n != "" {
|
||||
trimmedNote = &n
|
||||
}
|
||||
updateReqSQL := `UPDATE paliad.approval_requests
|
||||
SET status = $1, decided_by = $2, decided_at = $3, decision_kind = $4,
|
||||
decision_note = $5, updated_at = $3
|
||||
WHERE id = $6`
|
||||
// For revoke, decided_by stays NULL (the requester didn't "decide" to
|
||||
// approve, they pulled the request) — but a CHECK (decided_by != requested_by)
|
||||
// would block decided_by=requester anyway. NULL is correct.
|
||||
var decidedBy any
|
||||
var decisionKindArg any
|
||||
if finalStatus != RequestStatusRevoked {
|
||||
decidedBy = callerID
|
||||
decisionKindArg = decisionKind
|
||||
} else {
|
||||
decidedBy = nil
|
||||
decisionKindArg = nil
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, updateReqSQL,
|
||||
finalStatus, decidedBy, now, decisionKindArg, trimmedNote, requestID); err != nil {
|
||||
return fmt.Errorf("update approval_request: %w", err)
|
||||
}
|
||||
|
||||
// Audit emit.
|
||||
var verlaufKind string
|
||||
switch finalStatus {
|
||||
case RequestStatusApproved:
|
||||
verlaufKind = "approved"
|
||||
case RequestStatusRejected:
|
||||
verlaufKind = "rejected"
|
||||
case RequestStatusRevoked:
|
||||
verlaufKind = "revoked"
|
||||
}
|
||||
eventType := approvalEventType(req.EntityType, verlaufKind)
|
||||
descPtr := approvalDescription(verlaufKind, req.RequiredRole, req.LifecycleEvent)
|
||||
meta := map[string]any{
|
||||
"approval_request_id": req.ID.String(),
|
||||
"lifecycle_event": req.LifecycleEvent,
|
||||
req.EntityType + "_id": req.EntityID.String(),
|
||||
}
|
||||
if finalStatus != RequestStatusRevoked {
|
||||
meta["decision_kind"] = decisionKind
|
||||
}
|
||||
if trimmedNote != nil {
|
||||
meta["decision_note"] = *trimmedNote
|
||||
}
|
||||
if err := insertProjectEventWithMeta(ctx, tx, req.ProjectID, callerID, eventType, eventType, descPtr, meta); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// canApprove enforces the strict-ladder gate plus the no-self-approval
|
||||
// rule. Returns the decision_kind ('peer' | 'admin_override' |
|
||||
// 'derived_peer') the caller should record, or an error.
|
||||
//
|
||||
// Resolution order (t-paliad-139 §4.2):
|
||||
// 1. Self-approval is hard-blocked.
|
||||
// 2. global_admin always wins ('admin_override').
|
||||
// 3. Direct or ancestor project_teams membership with sufficient role
|
||||
// ('peer').
|
||||
// 4. Partner-unit-derived membership with derive_grants_authority=true
|
||||
// and a unit_role that maps (via approval_role_from_unit_role) to a
|
||||
// project_role with sufficient level ('derived_peer').
|
||||
func (s *ApprovalService) canApprove(ctx context.Context, tx *sqlx.Tx, callerID uuid.UUID, req *models.ApprovalRequest) (string, error) {
|
||||
if callerID == req.RequestedBy {
|
||||
return "", ErrSelfApproval
|
||||
}
|
||||
user, err := s.users.GetByID(ctx, callerID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if user == nil {
|
||||
return "", ErrNotApprover
|
||||
}
|
||||
if user.GlobalRole == "global_admin" {
|
||||
return DecisionKindAdminOverride, nil
|
||||
}
|
||||
// Path-walk: check direct OR ancestor team membership with sufficient role.
|
||||
q := `SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = $1
|
||||
AND pt.project_id = ANY(string_to_array(
|
||||
(SELECT path FROM paliad.projects WHERE id = $2), '.')::uuid[])
|
||||
AND paliad.approval_role_level(pt.role) >= paliad.approval_role_level($3)
|
||||
)`
|
||||
var ok bool
|
||||
if err := tx.GetContext(ctx, &ok, q, callerID, req.ProjectID, req.RequiredRole); err != nil {
|
||||
return "", fmt.Errorf("authorization check: %w", err)
|
||||
}
|
||||
if ok {
|
||||
return DecisionKindPeer, nil
|
||||
}
|
||||
// t-paliad-139 derivation branch: check authority-granting partner-unit
|
||||
// attachments on the project's path.
|
||||
qDerived := `SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.project_partner_units ppu
|
||||
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
|
||||
WHERE pum.user_id = $1
|
||||
AND ppu.project_id = ANY(string_to_array(
|
||||
(SELECT path FROM paliad.projects WHERE id = $2), '.')::uuid[])
|
||||
AND ppu.derive_grants_authority = true
|
||||
AND pum.unit_role = ANY(ppu.derive_unit_roles)
|
||||
AND paliad.approval_role_level(
|
||||
paliad.approval_role_from_unit_role(pum.unit_role)
|
||||
) >= paliad.approval_role_level($3)
|
||||
)`
|
||||
var derivedOK bool
|
||||
if err := tx.GetContext(ctx, &derivedOK, qDerived, callerID, req.ProjectID, req.RequiredRole); err != nil {
|
||||
return "", fmt.Errorf("derived authorization check: %w", err)
|
||||
}
|
||||
if derivedOK {
|
||||
return DecisionKindDerivedPeer, nil
|
||||
}
|
||||
return "", ErrNotApprover
|
||||
}
|
||||
|
||||
// applyApproved finalises the lifecycle on the entity row.
|
||||
func (s *ApprovalService) applyApproved(ctx context.Context, tx *sqlx.Tx, req *models.ApprovalRequest, approverID uuid.UUID) error {
|
||||
table := entityTableName(req.EntityType)
|
||||
now := time.Now().UTC()
|
||||
|
||||
if req.LifecycleEvent == LifecycleDelete {
|
||||
// Hard-delete the entity. The approval_requests.entity_id reference
|
||||
// is a polymorphic uuid (no FK) so it survives the row going away.
|
||||
// pending_request_id on the entity has ON DELETE SET NULL but the
|
||||
// entity is the one being deleted, not the request — so this is
|
||||
// just a plain DELETE.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
fmt.Sprintf(`DELETE FROM paliad.%s WHERE id = $1`, table),
|
||||
req.EntityID); err != nil {
|
||||
return fmt.Errorf("delete on approve: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Non-delete approve = clear pending markers, set approved_by/at.
|
||||
q := fmt.Sprintf(`UPDATE paliad.%s
|
||||
SET approval_status = 'approved',
|
||||
pending_request_id = NULL,
|
||||
approved_by = $1,
|
||||
approved_at = $2,
|
||||
updated_at = $2
|
||||
WHERE id = $3`, table)
|
||||
if _, err := tx.ExecContext(ctx, q, approverID, now, req.EntityID); err != nil {
|
||||
return fmt.Errorf("clear pending on approve: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyRevert undoes the in-flight change on the entity row, restoring it
|
||||
// from the request's pre_image jsonb. Used by both Reject and Revoke.
|
||||
func (s *ApprovalService) applyRevert(ctx context.Context, tx *sqlx.Tx, req *models.ApprovalRequest) error {
|
||||
table := entityTableName(req.EntityType)
|
||||
|
||||
switch req.LifecycleEvent {
|
||||
case LifecycleCreate:
|
||||
// The entity should never have existed. Hard-delete.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
fmt.Sprintf(`DELETE FROM paliad.%s WHERE id = $1`, table),
|
||||
req.EntityID); err != nil {
|
||||
return fmt.Errorf("delete on reject-create: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
case LifecycleDelete:
|
||||
// We never deleted the entity (delete is stage-then-write); just
|
||||
// clear the pending markers so the row is fully alive again.
|
||||
q := fmt.Sprintf(`UPDATE paliad.%s
|
||||
SET approval_status = CASE WHEN approval_status = 'pending'
|
||||
THEN 'approved' ELSE approval_status END,
|
||||
pending_request_id = NULL,
|
||||
updated_at = now()
|
||||
WHERE id = $1`, table)
|
||||
if _, err := tx.ExecContext(ctx, q, req.EntityID); err != nil {
|
||||
return fmt.Errorf("clear pending on reject-delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
case LifecycleUpdate, LifecycleComplete:
|
||||
// Restore pre_image fields, clear pending markers.
|
||||
preImage := map[string]any{}
|
||||
if len(req.PreImage) > 0 {
|
||||
if err := json.Unmarshal(req.PreImage, &preImage); err != nil {
|
||||
return fmt.Errorf("unmarshal pre_image: %w", err)
|
||||
}
|
||||
}
|
||||
setClauses, args, err := buildRevertSetClauses(req.EntityType, preImage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Always clear pending markers + revert approval_status.
|
||||
setClauses = append(setClauses,
|
||||
"approval_status = 'approved'",
|
||||
"pending_request_id = NULL",
|
||||
"updated_at = now()")
|
||||
args = append(args, req.EntityID)
|
||||
q := fmt.Sprintf(`UPDATE paliad.%s SET %s WHERE id = $%d`,
|
||||
table, strings.Join(setClauses, ", "), len(args))
|
||||
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
|
||||
return fmt.Errorf("revert entity from pre_image: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
default:
|
||||
return fmt.Errorf("%w: lifecycle %q", ErrUnknownEntityType, req.LifecycleEvent)
|
||||
}
|
||||
}
|
||||
|
||||
// buildRevertSetClauses translates pre_image jsonb keys into SQL SET
|
||||
// fragments. Only the date-bearing allowlist (Q4) is honoured; unknown
|
||||
// keys are silently dropped to defend against malformed pre_image rows
|
||||
// (defence-in-depth: callers should already be sending only allowlisted
|
||||
// fields, but a hostile UPDATE on the request row shouldn't let arbitrary
|
||||
// fields be reverted).
|
||||
func buildRevertSetClauses(entityType string, preImage map[string]any) ([]string, []any, error) {
|
||||
var setClauses []string
|
||||
var args []any
|
||||
|
||||
add := func(col string, val any) {
|
||||
args = append(args, val)
|
||||
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, len(args)))
|
||||
}
|
||||
|
||||
switch entityType {
|
||||
case EntityTypeDeadline:
|
||||
for _, col := range []string{"due_date", "original_due_date", "warning_date"} {
|
||||
if v, ok := preImage[col]; ok {
|
||||
add(col, v)
|
||||
}
|
||||
}
|
||||
// Complete-revert restores status='pending' + completed_at NULL.
|
||||
// We detect this branch by the presence of a status key; lifecycle
|
||||
// is the formal source but pre_image is what the caller stored.
|
||||
if v, ok := preImage["status"]; ok {
|
||||
add("status", v)
|
||||
}
|
||||
if _, ok := preImage["completed_at"]; ok {
|
||||
// Always NULL on revert — completion didn't really happen.
|
||||
args = append(args, nil)
|
||||
setClauses = append(setClauses, fmt.Sprintf("completed_at = $%d", len(args)))
|
||||
}
|
||||
|
||||
case EntityTypeAppointment:
|
||||
for _, col := range []string{"start_at", "end_at"} {
|
||||
if v, ok := preImage[col]; ok {
|
||||
add(col, v)
|
||||
}
|
||||
}
|
||||
if _, ok := preImage["completed_at"]; ok {
|
||||
args = append(args, nil)
|
||||
setClauses = append(setClauses, fmt.Sprintf("completed_at = $%d", len(args)))
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("%w: %q", ErrUnknownEntityType, entityType)
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
return nil, nil, fmt.Errorf("%w: empty pre_image for %s", ErrUnknownEntityType, entityType)
|
||||
}
|
||||
return setClauses, args, nil
|
||||
}
|
||||
|
||||
// getRequestForUpdate locks an approval_requests row inside the tx for
|
||||
// decision processing.
|
||||
func (s *ApprovalService) getRequestForUpdate(ctx context.Context, tx *sqlx.Tx, requestID uuid.UUID) (*models.ApprovalRequest, error) {
|
||||
var req models.ApprovalRequest
|
||||
q := `SELECT id, project_id, entity_type, entity_id, lifecycle_event,
|
||||
pre_image, payload, requested_by, requested_at, required_role,
|
||||
status, decided_by, decided_at, decision_kind, decision_note,
|
||||
created_at, updated_at
|
||||
FROM paliad.approval_requests
|
||||
WHERE id = $1
|
||||
FOR UPDATE`
|
||||
if err := tx.GetContext(ctx, &req, q, requestID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrRequestNotPending
|
||||
}
|
||||
return nil, fmt.Errorf("load request: %w", err)
|
||||
}
|
||||
return &req, nil
|
||||
}
|
||||
|
||||
// entityApprovalStatus reads the current approval_status on the entity
|
||||
// row. Returns "" if the row doesn't exist.
|
||||
func (s *ApprovalService) entityApprovalStatus(ctx context.Context, tx *sqlx.Tx, entityType string, entityID uuid.UUID) (string, error) {
|
||||
q := fmt.Sprintf(`SELECT approval_status FROM paliad.%s WHERE id = $1`,
|
||||
entityTableName(entityType))
|
||||
var status string
|
||||
if err := txOrDB(tx, s.db).GetContext(ctx, &status, q, entityID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("read approval_status: %w", err)
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// entityTableName resolves the SQL table name for a given entity_type.
|
||||
// Internal helper — entityType comes from server-side constants, not user
|
||||
// input, so a panic on an unknown value is a programming error.
|
||||
func entityTableName(entityType string) string {
|
||||
switch entityType {
|
||||
case EntityTypeDeadline:
|
||||
return "deadlines"
|
||||
case EntityTypeAppointment:
|
||||
return "appointments"
|
||||
default:
|
||||
panic(fmt.Sprintf("approval: unknown entity_type %q", entityType))
|
||||
}
|
||||
}
|
||||
|
||||
// approvalEventType returns the project_events.event_type value for a
|
||||
// given (entity, lifecycle-step) pair. Step is one of "requested" |
|
||||
// "approved" | "rejected" | "revoked".
|
||||
func approvalEventType(entityType, step string) string {
|
||||
return entityType + "_approval_" + step
|
||||
}
|
||||
|
||||
// approvalDescription returns the short audit description string. Frontend
|
||||
// renders the localized version via translateEvent; this is the raw audit
|
||||
// row's description column, used as a fallback and for /admin/audit-log.
|
||||
func approvalDescription(step, requiredRole, lifecycle string) *string {
|
||||
d := fmt.Sprintf("%s — %s/%s", step, lifecycle, requiredRole)
|
||||
return &d
|
||||
}
|
||||
|
||||
// txOrDB returns the tx if non-nil, else the db. Lets read helpers run
|
||||
// either inside a calling tx (for consistency with concurrent writes) or
|
||||
// standalone for List endpoints.
|
||||
func txOrDB(tx *sqlx.Tx, db *sqlx.DB) sqlxQueryer {
|
||||
if tx != nil {
|
||||
return tx
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// sqlxQueryer is the minimal subset of *sqlx.DB / *sqlx.Tx we need.
|
||||
// Defined here to avoid adding a public abstraction across the package.
|
||||
type sqlxQueryer interface {
|
||||
GetContext(ctx context.Context, dest any, query string, args ...any) error
|
||||
SelectContext(ctx context.Context, dest any, query string, args ...any) error
|
||||
QueryRowxContext(ctx context.Context, query string, args ...any) *sqlx.Row
|
||||
}
|
||||
|
||||
// marshalJSONOrNull returns []byte("null") JSON-RawMessage style for
|
||||
// nil/empty maps so callers can pass it directly to a jsonb column without
|
||||
// branching at every call site.
|
||||
func marshalJSONOrNull(m map[string]any) ([]byte, error) {
|
||||
if len(m) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Read paths — inbox + policy CRUD.
|
||||
// ============================================================================
|
||||
|
||||
// ApprovalRequestView is the inbox-friendly projection of an approval
|
||||
// request: the bare ApprovalRequest plus the contextual labels the inbox
|
||||
// needs to render a row without further fetches.
|
||||
type ApprovalRequestView struct {
|
||||
models.ApprovalRequest
|
||||
ProjectTitle string `db:"project_title" json:"project_title"`
|
||||
EntityTitle *string `db:"entity_title" json:"entity_title,omitempty"`
|
||||
RequesterName string `db:"requester_name" json:"requester_name"`
|
||||
RequesterEmail string `db:"requester_email" json:"requester_email"`
|
||||
DeciderName *string `db:"decider_name" json:"decider_name,omitempty"`
|
||||
DeciderEmail *string `db:"decider_email" json:"decider_email,omitempty"`
|
||||
}
|
||||
|
||||
const approvalRequestViewColumns = `
|
||||
ar.id, ar.project_id, ar.entity_type, ar.entity_id, ar.lifecycle_event,
|
||||
ar.pre_image, ar.payload, ar.requested_by, ar.requested_at, ar.required_role,
|
||||
ar.status, ar.decided_by, ar.decided_at, ar.decision_kind, ar.decision_note,
|
||||
ar.created_at, ar.updated_at,
|
||||
p.title AS project_title,
|
||||
CASE WHEN ar.entity_type = 'deadline' THEN d.title
|
||||
WHEN ar.entity_type = 'appointment' THEN a.title
|
||||
END AS entity_title,
|
||||
COALESCE(ru.display_name, ru.email) AS requester_name,
|
||||
ru.email AS requester_email,
|
||||
du.display_name AS decider_name,
|
||||
du.email AS decider_email`
|
||||
|
||||
const approvalRequestViewJoins = `
|
||||
paliad.approval_requests ar
|
||||
JOIN paliad.projects p ON p.id = ar.project_id
|
||||
JOIN paliad.users ru ON ru.id = ar.requested_by
|
||||
LEFT JOIN paliad.users du ON du.id = ar.decided_by
|
||||
LEFT JOIN paliad.deadlines d ON ar.entity_type = 'deadline' AND d.id = ar.entity_id
|
||||
LEFT JOIN paliad.appointments a ON ar.entity_type = 'appointment' AND a.id = ar.entity_id`
|
||||
|
||||
// InboxFilter narrows the inbox listings.
|
||||
type InboxFilter struct {
|
||||
Status string // "" → no filter; otherwise one of RequestStatus*
|
||||
ProjectID *uuid.UUID
|
||||
EntityType string // "" → both
|
||||
Limit int // 0 → 100
|
||||
}
|
||||
|
||||
// ListPendingForApprover returns approval requests where the caller is
|
||||
// qualified to approve and is not the requester.
|
||||
func (s *ApprovalService) ListPendingForApprover(ctx context.Context, callerID uuid.UUID, filter InboxFilter) ([]ApprovalRequestView, error) {
|
||||
limit := filter.Limit
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 100
|
||||
}
|
||||
conds := []string{
|
||||
"ar.status = 'pending'",
|
||||
"ar.requested_by <> $1",
|
||||
// Eligibility (any one branch suffices):
|
||||
// - caller is global_admin, OR
|
||||
// - caller has direct/ancestor project_teams role meeting the threshold, OR
|
||||
// - caller is a partner-unit-derived member with derive_grants_authority=true
|
||||
// on an attachment in the project's path, and the unit_role maps to a
|
||||
// project_role at or above the threshold (t-paliad-139).
|
||||
`(EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $1 AND u.global_role = 'global_admin')
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = $1
|
||||
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
|
||||
AND paliad.approval_role_level(pt.role) >= paliad.approval_role_level(ar.required_role)
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.project_partner_units ppu
|
||||
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
|
||||
WHERE pum.user_id = $1
|
||||
AND ppu.project_id = ANY(string_to_array(p.path, '.')::uuid[])
|
||||
AND ppu.derive_grants_authority = true
|
||||
AND pum.unit_role = ANY(ppu.derive_unit_roles)
|
||||
AND paliad.approval_role_level(
|
||||
paliad.approval_role_from_unit_role(pum.unit_role)
|
||||
) >= paliad.approval_role_level(ar.required_role)
|
||||
))`,
|
||||
}
|
||||
args := []any{callerID}
|
||||
if filter.ProjectID != nil {
|
||||
args = append(args, *filter.ProjectID)
|
||||
conds = append(conds, fmt.Sprintf("ar.project_id = $%d", len(args)))
|
||||
}
|
||||
if filter.EntityType != "" {
|
||||
args = append(args, filter.EntityType)
|
||||
conds = append(conds, fmt.Sprintf("ar.entity_type = $%d", len(args)))
|
||||
}
|
||||
args = append(args, limit)
|
||||
|
||||
q := fmt.Sprintf(`SELECT %s FROM %s WHERE %s ORDER BY ar.requested_at ASC LIMIT $%d`,
|
||||
approvalRequestViewColumns, approvalRequestViewJoins,
|
||||
strings.Join(conds, " AND "), len(args))
|
||||
|
||||
var out []ApprovalRequestView
|
||||
if err := s.db.SelectContext(ctx, &out, q, args...); err != nil {
|
||||
return nil, fmt.Errorf("list pending for approver: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListSubmittedByUser returns approval requests authored by the caller.
|
||||
// Status filter optional.
|
||||
func (s *ApprovalService) ListSubmittedByUser(ctx context.Context, callerID uuid.UUID, filter InboxFilter) ([]ApprovalRequestView, error) {
|
||||
limit := filter.Limit
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 100
|
||||
}
|
||||
conds := []string{"ar.requested_by = $1"}
|
||||
args := []any{callerID}
|
||||
if filter.Status != "" {
|
||||
args = append(args, filter.Status)
|
||||
conds = append(conds, fmt.Sprintf("ar.status = $%d", len(args)))
|
||||
}
|
||||
if filter.ProjectID != nil {
|
||||
args = append(args, *filter.ProjectID)
|
||||
conds = append(conds, fmt.Sprintf("ar.project_id = $%d", len(args)))
|
||||
}
|
||||
if filter.EntityType != "" {
|
||||
args = append(args, filter.EntityType)
|
||||
conds = append(conds, fmt.Sprintf("ar.entity_type = $%d", len(args)))
|
||||
}
|
||||
args = append(args, limit)
|
||||
|
||||
q := fmt.Sprintf(`SELECT %s FROM %s WHERE %s ORDER BY ar.requested_at DESC LIMIT $%d`,
|
||||
approvalRequestViewColumns, approvalRequestViewJoins,
|
||||
strings.Join(conds, " AND "), len(args))
|
||||
|
||||
var out []ApprovalRequestView
|
||||
if err := s.db.SelectContext(ctx, &out, q, args...); err != nil {
|
||||
return nil, fmt.Errorf("list submitted by user: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetRequest returns one approval request hydrated for the inbox detail
|
||||
// view. Visibility is gated upstream by the handler (anyone with project
|
||||
// access can see the request).
|
||||
func (s *ApprovalService) GetRequest(ctx context.Context, requestID uuid.UUID) (*ApprovalRequestView, error) {
|
||||
q := fmt.Sprintf(`SELECT %s FROM %s WHERE ar.id = $1`,
|
||||
approvalRequestViewColumns, approvalRequestViewJoins)
|
||||
var v ApprovalRequestView
|
||||
if err := s.db.GetContext(ctx, &v, q, requestID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("get approval request: %w", err)
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// PendingCountForUser returns how many requests await this user's approval.
|
||||
// Cheap query for the sidebar bell badge.
|
||||
//
|
||||
// Eligibility mirrors ListPendingForApprover: global_admin OR direct/
|
||||
// ancestor project_teams role meeting the threshold OR partner-unit-
|
||||
// derived authority (t-paliad-139).
|
||||
func (s *ApprovalService) PendingCountForUser(ctx context.Context, callerID uuid.UUID) (int, error) {
|
||||
q := `SELECT COUNT(*)
|
||||
FROM paliad.approval_requests ar
|
||||
JOIN paliad.projects p ON p.id = ar.project_id
|
||||
WHERE ar.status = 'pending'
|
||||
AND ar.requested_by <> $1
|
||||
AND (EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $1 AND u.global_role = 'global_admin')
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = $1
|
||||
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
|
||||
AND paliad.approval_role_level(pt.role) >= paliad.approval_role_level(ar.required_role)
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.project_partner_units ppu
|
||||
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
|
||||
WHERE pum.user_id = $1
|
||||
AND ppu.project_id = ANY(string_to_array(p.path, '.')::uuid[])
|
||||
AND ppu.derive_grants_authority = true
|
||||
AND pum.unit_role = ANY(ppu.derive_unit_roles)
|
||||
AND paliad.approval_role_level(
|
||||
paliad.approval_role_from_unit_role(pum.unit_role)
|
||||
) >= paliad.approval_role_level(ar.required_role)
|
||||
))`
|
||||
var n int
|
||||
if err := s.db.GetContext(ctx, &n, q, callerID); err != nil {
|
||||
return 0, fmt.Errorf("pending count: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Policy CRUD — paliad.approval_policies.
|
||||
// ============================================================================
|
||||
|
||||
// ListPolicies returns the (up to 8) policy rows for a project. Caller
|
||||
// must already have project visibility.
|
||||
func (s *ApprovalService) ListPolicies(ctx context.Context, projectID uuid.UUID) ([]models.ApprovalPolicy, error) {
|
||||
q := `SELECT id, project_id, entity_type, lifecycle_event, required_role,
|
||||
created_at, updated_at, created_by
|
||||
FROM paliad.approval_policies
|
||||
WHERE project_id = $1
|
||||
ORDER BY entity_type, lifecycle_event`
|
||||
var out []models.ApprovalPolicy
|
||||
if err := s.db.SelectContext(ctx, &out, q, projectID); err != nil {
|
||||
return nil, fmt.Errorf("list approval policies: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpsertPolicy creates or replaces a single (project, entity, lifecycle)
|
||||
// policy row. Caller must be global_admin (gate enforced at handler).
|
||||
func (s *ApprovalService) UpsertPolicy(ctx context.Context, projectID, callerID uuid.UUID, entityType, lifecycle, requiredRole string) (*models.ApprovalPolicy, error) {
|
||||
if !IsValidRequiredRole(requiredRole) {
|
||||
return nil, fmt.Errorf("%w: required_role %q", ErrInvalidInput, requiredRole)
|
||||
}
|
||||
if entityType != EntityTypeDeadline && entityType != EntityTypeAppointment {
|
||||
return nil, fmt.Errorf("%w: entity_type %q", ErrInvalidInput, entityType)
|
||||
}
|
||||
switch lifecycle {
|
||||
case LifecycleCreate, LifecycleUpdate, LifecycleComplete, LifecycleDelete:
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: lifecycle_event %q", ErrInvalidInput, lifecycle)
|
||||
}
|
||||
|
||||
q := `INSERT INTO paliad.approval_policies
|
||||
(project_id, entity_type, lifecycle_event, required_role, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (project_id, entity_type, lifecycle_event)
|
||||
DO UPDATE SET required_role = EXCLUDED.required_role,
|
||||
updated_at = now()
|
||||
RETURNING id, project_id, entity_type, lifecycle_event, required_role,
|
||||
created_at, updated_at, created_by`
|
||||
var p models.ApprovalPolicy
|
||||
if err := s.db.GetContext(ctx, &p, q, projectID, entityType, lifecycle, requiredRole, callerID); err != nil {
|
||||
return nil, fmt.Errorf("upsert approval policy: %w", err)
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// DeletePolicy removes a single (project, entity, lifecycle) policy row,
|
||||
// reverting that lifecycle event back to the no-approval-needed default.
|
||||
func (s *ApprovalService) DeletePolicy(ctx context.Context, projectID uuid.UUID, entityType, lifecycle string) error {
|
||||
q := `DELETE FROM paliad.approval_policies
|
||||
WHERE project_id = $1 AND entity_type = $2 AND lifecycle_event = $3`
|
||||
if _, err := s.db.ExecContext(ctx, q, projectID, entityType, lifecycle); err != nil {
|
||||
return fmt.Errorf("delete approval policy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
617
internal/services/approval_service_test.go
Normal file
617
internal/services/approval_service_test.go
Normal file
@@ -0,0 +1,617 @@
|
||||
package services
|
||||
|
||||
// Approval-service tests. Two layers:
|
||||
//
|
||||
// - Pure-Go: levelOf strict ladder + IsValidRequiredRole. No DB touch.
|
||||
// - Live-DB: the full submit→approve and submit→reject flows on real
|
||||
// paliad.deadlines / paliad.approval_requests rows. Skipped when
|
||||
// TEST_DATABASE_URL is unset, mirroring audit_service_test and
|
||||
// deadline_service_test.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Pure-Go tests.
|
||||
// ============================================================================
|
||||
|
||||
func TestLevelOf_StrictLadder(t *testing.T) {
|
||||
cases := []struct {
|
||||
role string
|
||||
want int
|
||||
}{
|
||||
{"lead", 5},
|
||||
{"of_counsel", 4},
|
||||
{"associate", 3},
|
||||
{"senior_pa", 2},
|
||||
{"pa", 1},
|
||||
{"local_counsel", 0},
|
||||
{"expert", 0},
|
||||
{"observer", 0},
|
||||
{"", 0},
|
||||
{"unknown", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.role, func(t *testing.T) {
|
||||
if got := levelOf(c.role); got != c.want {
|
||||
t.Errorf("levelOf(%q) = %d, want %d", c.role, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLevelOf_HigherSatisfiesLower(t *testing.T) {
|
||||
// "Anyone strictly above the required level satisfies it" — verify by
|
||||
// asserting the ladder is monotonic and partner > all PA tiers etc.
|
||||
if levelOf("lead") <= levelOf("associate") {
|
||||
t.Errorf("lead must outrank associate")
|
||||
}
|
||||
if levelOf("associate") <= levelOf("senior_pa") {
|
||||
t.Errorf("associate must outrank senior_pa")
|
||||
}
|
||||
if levelOf("senior_pa") <= levelOf("pa") {
|
||||
t.Errorf("senior_pa must outrank pa")
|
||||
}
|
||||
if levelOf("of_counsel") <= levelOf("associate") {
|
||||
t.Errorf("of_counsel must outrank associate")
|
||||
}
|
||||
// PA-required policy: anyone associate-or-above must satisfy.
|
||||
if levelOf("associate") < levelOf("pa") {
|
||||
t.Errorf("associate must satisfy a pa-required policy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidRequiredRole(t *testing.T) {
|
||||
cases := []struct {
|
||||
role string
|
||||
ok bool
|
||||
}{
|
||||
{"lead", true},
|
||||
{"of_counsel", true},
|
||||
{"associate", true},
|
||||
{"senior_pa", true},
|
||||
{"pa", true},
|
||||
{"local_counsel", false},
|
||||
{"expert", false},
|
||||
{"observer", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.role, func(t *testing.T) {
|
||||
if got := IsValidRequiredRole(c.role); got != c.ok {
|
||||
t.Errorf("IsValidRequiredRole(%q) = %v, want %v", c.role, got, c.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalEventType(t *testing.T) {
|
||||
cases := []struct {
|
||||
entity, step, want string
|
||||
}{
|
||||
{"deadline", "requested", "deadline_approval_requested"},
|
||||
{"deadline", "approved", "deadline_approval_approved"},
|
||||
{"deadline", "rejected", "deadline_approval_rejected"},
|
||||
{"deadline", "revoked", "deadline_approval_revoked"},
|
||||
{"appointment", "requested", "appointment_approval_requested"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := approvalEventType(c.entity, c.step); got != c.want {
|
||||
t.Errorf("approvalEventType(%q,%q) = %q, want %q",
|
||||
c.entity, c.step, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Live-DB tests.
|
||||
// ============================================================================
|
||||
|
||||
// approvalTestEnv holds a configured ApprovalService + helpers tied to a
|
||||
// throwaway project / user pool. Caller cleans up via env.cleanup().
|
||||
type approvalTestEnv struct {
|
||||
t *testing.T
|
||||
pool *sqlx.DB
|
||||
approvals *ApprovalService
|
||||
deadlines *DeadlineService
|
||||
users *UserService
|
||||
projects *ProjectService
|
||||
projectID uuid.UUID
|
||||
requester uuid.UUID
|
||||
approver uuid.UUID
|
||||
other uuid.UUID
|
||||
cleanup func()
|
||||
}
|
||||
|
||||
func setupApprovalTest(t *testing.T) *approvalTestEnv {
|
||||
t.Helper()
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
users := NewUserService(pool)
|
||||
projects := NewProjectService(pool, users)
|
||||
deadlines := NewDeadlineService(pool, projects, nil)
|
||||
approvals := NewApprovalService(pool, users)
|
||||
|
||||
// Seed two users + one project. The requester owns the deadline; the
|
||||
// approver is the other lead on the team. "other" has no role and is
|
||||
// used for the deadlock check (no qualified approver scenario).
|
||||
requesterID := uuid.New()
|
||||
approverID := uuid.New()
|
||||
otherID := uuid.New()
|
||||
|
||||
for _, id := range []uuid.UUID{requesterID, approverID, otherID} {
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO auth.users (id, email) VALUES ($1, $1::text || '@test.local')
|
||||
ON CONFLICT (id) DO NOTHING`, id); err != nil {
|
||||
t.Logf("skip auth.users seed: %v (continuing — auth schema may be locked down)", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, global_role)
|
||||
VALUES ($1, $1::text || '@test.local', 'Test User', 'munich', 'standard')
|
||||
ON CONFLICT (id) DO NOTHING`, id); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
projectID := uuid.New()
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects (id, type, title, status, created_by)
|
||||
VALUES ($1, 'project', 'Approval Test Project', 'active', $2)`,
|
||||
projectID, requesterID); err != nil {
|
||||
t.Fatalf("seed project: %v", err)
|
||||
}
|
||||
|
||||
// Add requester + approver to the project team. Requester=associate
|
||||
// (cannot approve associate-required policy), approver=lead (can).
|
||||
for _, m := range []struct {
|
||||
uid uuid.UUID
|
||||
role string
|
||||
}{
|
||||
{requesterID, "associate"},
|
||||
{approverID, "lead"},
|
||||
} {
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_teams (project_id, user_id, role)
|
||||
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
|
||||
projectID, m.uid, m.role); err != nil {
|
||||
t.Fatalf("seed project_teams: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
ctx := context.Background()
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.approval_requests WHERE project_id = $1`, projectID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.approval_policies WHERE project_id = $1`, projectID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.deadlines WHERE project_id = $1`, projectID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.appointments WHERE project_id = $1`, projectID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id = $1`, projectID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, projectID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, projectID)
|
||||
for _, id := range []uuid.UUID{requesterID, approverID, otherID} {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, id)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, id)
|
||||
}
|
||||
pool.Close()
|
||||
}
|
||||
|
||||
return &approvalTestEnv{
|
||||
t: t,
|
||||
pool: pool,
|
||||
approvals: approvals,
|
||||
deadlines: deadlines,
|
||||
users: users,
|
||||
projects: projects,
|
||||
projectID: projectID,
|
||||
requester: requesterID,
|
||||
approver: approverID,
|
||||
other: otherID,
|
||||
cleanup: cleanup,
|
||||
}
|
||||
}
|
||||
|
||||
// seedPolicy sets a policy on the env's project for one (entity, lifecycle).
|
||||
func (e *approvalTestEnv) seedPolicy(entityType, lifecycle, requiredRole string) {
|
||||
e.t.Helper()
|
||||
if _, err := e.approvals.UpsertPolicy(context.Background(),
|
||||
e.projectID, e.requester, entityType, lifecycle, requiredRole); err != nil {
|
||||
e.t.Fatalf("seed policy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedDeadline inserts a basic deadline row directly (bypassing the
|
||||
// service so we can test ApprovalService.Submit* in isolation). Returns
|
||||
// the deadline's ID.
|
||||
func (e *approvalTestEnv) seedDeadline(due time.Time) uuid.UUID {
|
||||
e.t.Helper()
|
||||
id := uuid.New()
|
||||
if _, err := e.pool.ExecContext(context.Background(),
|
||||
`INSERT INTO paliad.deadlines (id, project_id, title, due_date, source, status, created_by, approval_status)
|
||||
VALUES ($1, $2, 'Test Deadline', $3, 'manual', 'pending', $4, 'approved')`,
|
||||
id, e.projectID, due, e.requester); err != nil {
|
||||
e.t.Fatalf("seed deadline: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// TestApprovalService_NoPolicyIsNoop: with no policy, Submit* returns
|
||||
// (nil, nil) and the entity stays approval_status='approved'.
|
||||
func TestApprovalService_NoPolicyIsNoop(t *testing.T) {
|
||||
env := setupApprovalTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
deadlineID := env.seedDeadline(time.Now().AddDate(0, 0, 14))
|
||||
|
||||
tx, err := env.pool.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
reqID, err := env.approvals.SubmitCreate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("SubmitCreate: %v", err)
|
||||
}
|
||||
if reqID != nil {
|
||||
t.Errorf("expected nil request id with no policy, got %v", reqID)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatalf("commit: %v", err)
|
||||
}
|
||||
|
||||
var status string
|
||||
if err := env.pool.GetContext(ctx, &status,
|
||||
`SELECT approval_status FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
|
||||
t.Fatalf("read status: %v", err)
|
||||
}
|
||||
if status != "approved" {
|
||||
t.Errorf("expected approval_status=approved, got %q", status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalService_SubmitMarksPendingAndApproveClears: end-to-end happy
|
||||
// path. With a policy in place: submit → request row + entity pending →
|
||||
// approve → entity back to approved with approved_by set.
|
||||
func TestApprovalService_SubmitApproveCycle(t *testing.T) {
|
||||
env := setupApprovalTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
env.seedPolicy(EntityTypeDeadline, LifecycleCreate, "associate")
|
||||
deadlineID := env.seedDeadline(time.Now().AddDate(0, 0, 14))
|
||||
|
||||
// Submit (inside a tx, as DeadlineService would).
|
||||
tx, err := env.pool.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
reqID, err := env.approvals.SubmitCreate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline,
|
||||
map[string]any{"due_date": "2026-05-20"})
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("SubmitCreate: %v", err)
|
||||
}
|
||||
if reqID == nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("expected request id, got nil")
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatalf("commit: %v", err)
|
||||
}
|
||||
|
||||
// Entity is now pending.
|
||||
var status string
|
||||
if err := env.pool.GetContext(ctx, &status,
|
||||
`SELECT approval_status FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
|
||||
t.Fatalf("read status: %v", err)
|
||||
}
|
||||
if status != "pending" {
|
||||
t.Errorf("after submit: approval_status=%q, want pending", status)
|
||||
}
|
||||
|
||||
// Self-approval blocks.
|
||||
if err := env.approvals.Approve(ctx, *reqID, env.requester, ""); !errors.Is(err, ErrSelfApproval) {
|
||||
t.Errorf("self-approve: got %v, want ErrSelfApproval", err)
|
||||
}
|
||||
|
||||
// Approver (lead) signs off.
|
||||
if err := env.approvals.Approve(ctx, *reqID, env.approver, "looks good"); err != nil {
|
||||
t.Fatalf("Approve: %v", err)
|
||||
}
|
||||
|
||||
// Entity flipped back to approved with approved_by populated.
|
||||
row := struct {
|
||||
Status string `db:"approval_status"`
|
||||
ApprovedBy *uuid.UUID `db:"approved_by"`
|
||||
}{}
|
||||
if err := env.pool.GetContext(ctx, &row,
|
||||
`SELECT approval_status, approved_by FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
|
||||
t.Fatalf("read post-approve: %v", err)
|
||||
}
|
||||
if row.Status != "approved" {
|
||||
t.Errorf("after approve: approval_status=%q, want approved", row.Status)
|
||||
}
|
||||
if row.ApprovedBy == nil || *row.ApprovedBy != env.approver {
|
||||
t.Errorf("after approve: approved_by=%v, want %v", row.ApprovedBy, env.approver)
|
||||
}
|
||||
|
||||
// Request row marked approved.
|
||||
var reqStatus string
|
||||
if err := env.pool.GetContext(ctx, &reqStatus,
|
||||
`SELECT status FROM paliad.approval_requests WHERE id = $1`, *reqID); err != nil {
|
||||
t.Fatalf("read request status: %v", err)
|
||||
}
|
||||
if reqStatus != "approved" {
|
||||
t.Errorf("request status=%q, want approved", reqStatus)
|
||||
}
|
||||
|
||||
// Approving again fails (not pending anymore).
|
||||
if err := env.approvals.Approve(ctx, *reqID, env.approver, ""); !errors.Is(err, ErrRequestNotPending) {
|
||||
t.Errorf("re-approve: got %v, want ErrRequestNotPending", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalService_RejectRevertsCreateAsDelete: rejecting a CREATE
|
||||
// request hard-deletes the entity (it never should have existed).
|
||||
func TestApprovalService_RejectCreateDeletes(t *testing.T) {
|
||||
env := setupApprovalTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
env.seedPolicy(EntityTypeDeadline, LifecycleCreate, "associate")
|
||||
deadlineID := env.seedDeadline(time.Now().AddDate(0, 0, 7))
|
||||
|
||||
tx, err := env.pool.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
reqID, err := env.approvals.SubmitCreate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline, nil)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("SubmitCreate: %v", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatalf("commit: %v", err)
|
||||
}
|
||||
|
||||
if err := env.approvals.Reject(ctx, *reqID, env.approver, "wrong date"); err != nil {
|
||||
t.Fatalf("Reject: %v", err)
|
||||
}
|
||||
|
||||
// Entity row is gone.
|
||||
var n int
|
||||
if err := env.pool.GetContext(ctx, &n,
|
||||
`SELECT COUNT(*) FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
|
||||
t.Fatalf("count deadline: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("after reject-create: deadline still exists (count=%d)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalService_RejectUpdateRestoresPreImage: rejecting an UPDATE
|
||||
// reverts the date fields back to the snapshotted pre_image values.
|
||||
func TestApprovalService_RejectUpdateRestoresPreImage(t *testing.T) {
|
||||
env := setupApprovalTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
env.seedPolicy(EntityTypeDeadline, LifecycleUpdate, "associate")
|
||||
|
||||
originalDue := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
deadlineID := env.seedDeadline(originalDue)
|
||||
|
||||
// Simulate an update: set due to 2026-06-15, then submit.
|
||||
newDue := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||
tx, err := env.pool.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.deadlines SET due_date = $1 WHERE id = $2`,
|
||||
newDue, deadlineID); err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("UPDATE pre-submit: %v", err)
|
||||
}
|
||||
preImage := map[string]any{"due_date": "2026-06-01"}
|
||||
payload := map[string]any{"due_date": "2026-06-15"}
|
||||
reqID, err := env.approvals.SubmitUpdate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline, preImage, payload)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("SubmitUpdate: %v", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatalf("commit: %v", err)
|
||||
}
|
||||
|
||||
// Reject — due_date should snap back to 2026-06-01.
|
||||
if err := env.approvals.Reject(ctx, *reqID, env.approver, ""); err != nil {
|
||||
t.Fatalf("Reject: %v", err)
|
||||
}
|
||||
|
||||
var got time.Time
|
||||
if err := env.pool.GetContext(ctx, &got,
|
||||
`SELECT due_date FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
|
||||
t.Fatalf("read due_date: %v", err)
|
||||
}
|
||||
if !got.Equal(originalDue) {
|
||||
t.Errorf("after reject-update: due_date=%v, want %v", got, originalDue)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalService_NoQualifiedApprover: when only the requester would
|
||||
// qualify, Submit returns ErrNoQualifiedApprover.
|
||||
func TestApprovalService_NoQualifiedApprover(t *testing.T) {
|
||||
env := setupApprovalTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// Demote the approver to observer (level 0 = ineligible). Now requester
|
||||
// (associate) is the only on-team user with any role, and observer
|
||||
// can't approve.
|
||||
if _, err := env.pool.ExecContext(ctx,
|
||||
`UPDATE paliad.project_teams SET role='observer' WHERE project_id=$1 AND user_id=$2`,
|
||||
env.projectID, env.approver); err != nil {
|
||||
t.Fatalf("demote approver: %v", err)
|
||||
}
|
||||
|
||||
// Make sure no global_admin exists in our test pool — promote-and-revert
|
||||
// any existing global_admin so the deadlock kicks in. We can't safely do
|
||||
// that without affecting other tests, so use a project where the
|
||||
// requester is the only person + setup excludes other users.
|
||||
// Easier approach: temporarily set requester to global_admin, then test
|
||||
// against a different "pretend requester" — but we want the case where
|
||||
// our seeded requester is the only candidate.
|
||||
//
|
||||
// Approach: use UpsertPolicy to set 'lead' as required role. Then no
|
||||
// project team member (associate, observer) qualifies. The deadlock
|
||||
// check still passes if any global_admin exists firmwide (Q8 escape
|
||||
// hatch), so we accept this test may be a no-op on pools with admins.
|
||||
env.seedPolicy(EntityTypeDeadline, LifecycleCreate, "lead")
|
||||
deadlineID := env.seedDeadline(time.Now().AddDate(0, 0, 14))
|
||||
|
||||
// Count global admins; if any exist (e.g. m or tester) the deadlock
|
||||
// path can't fire — skip with a note.
|
||||
var nAdmins int
|
||||
if err := env.pool.GetContext(ctx, &nAdmins,
|
||||
`SELECT COUNT(*) FROM paliad.users WHERE global_role='global_admin' AND id <> $1`,
|
||||
env.requester); err != nil {
|
||||
t.Fatalf("count admins: %v", err)
|
||||
}
|
||||
if nAdmins > 0 {
|
||||
t.Skip("global_admin exists in test pool — deadlock fallback hides ErrNoQualifiedApprover; covered indirectly via canApprove unit checks")
|
||||
}
|
||||
|
||||
tx, err := env.pool.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
_, err = env.approvals.SubmitCreate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline, nil)
|
||||
if !errors.Is(err, ErrNoQualifiedApprover) {
|
||||
t.Errorf("got %v, want ErrNoQualifiedApprover", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalService_RevokeRevertsAndMarksRevoked: requester revokes
|
||||
// their own pending → entity reverts, request status='revoked'.
|
||||
func TestApprovalService_RevokeRevertsAndMarksRevoked(t *testing.T) {
|
||||
env := setupApprovalTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
env.seedPolicy(EntityTypeDeadline, LifecycleCreate, "associate")
|
||||
deadlineID := env.seedDeadline(time.Now().AddDate(0, 0, 14))
|
||||
|
||||
tx, err := env.pool.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
reqID, err := env.approvals.SubmitCreate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline, nil)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("SubmitCreate: %v", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatalf("commit: %v", err)
|
||||
}
|
||||
|
||||
// Non-requester can't revoke.
|
||||
if err := env.approvals.Revoke(ctx, *reqID, env.approver); !errors.Is(err, ErrNotApprover) {
|
||||
t.Errorf("non-requester revoke: got %v, want ErrNotApprover", err)
|
||||
}
|
||||
|
||||
// Requester revokes — succeeds. Create lifecycle = entity gets deleted.
|
||||
if err := env.approvals.Revoke(ctx, *reqID, env.requester); err != nil {
|
||||
t.Fatalf("Revoke: %v", err)
|
||||
}
|
||||
|
||||
var n int
|
||||
if err := env.pool.GetContext(ctx, &n,
|
||||
`SELECT COUNT(*) FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("after revoke-create: entity should be gone (count=%d)", n)
|
||||
}
|
||||
|
||||
var reqStatus string
|
||||
if err := env.pool.GetContext(ctx, &reqStatus,
|
||||
`SELECT status FROM paliad.approval_requests WHERE id = $1`, *reqID); err != nil {
|
||||
t.Fatalf("read request: %v", err)
|
||||
}
|
||||
if reqStatus != "revoked" {
|
||||
t.Errorf("request status=%q, want revoked", reqStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalService_PolicyCRUDRoundtrip: upsert → list → delete.
|
||||
func TestApprovalService_PolicyCRUD(t *testing.T) {
|
||||
env := setupApprovalTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// Upsert two rows.
|
||||
if _, err := env.approvals.UpsertPolicy(ctx, env.projectID, env.requester, EntityTypeDeadline, LifecycleCreate, "associate"); err != nil {
|
||||
t.Fatalf("upsert 1: %v", err)
|
||||
}
|
||||
if _, err := env.approvals.UpsertPolicy(ctx, env.projectID, env.requester, EntityTypeAppointment, LifecycleUpdate, "lead"); err != nil {
|
||||
t.Fatalf("upsert 2: %v", err)
|
||||
}
|
||||
|
||||
// List.
|
||||
got, err := env.approvals.ListPolicies(ctx, env.projectID)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Errorf("list returned %d rows, want 2", len(got))
|
||||
}
|
||||
|
||||
// Re-upsert the first to a different role.
|
||||
if _, err := env.approvals.UpsertPolicy(ctx, env.projectID, env.requester, EntityTypeDeadline, LifecycleCreate, "lead"); err != nil {
|
||||
t.Fatalf("re-upsert: %v", err)
|
||||
}
|
||||
got, _ = env.approvals.ListPolicies(ctx, env.projectID)
|
||||
for _, p := range got {
|
||||
if p.EntityType == EntityTypeDeadline && p.LifecycleEvent == LifecycleCreate && p.RequiredRole != "lead" {
|
||||
t.Errorf("after re-upsert: required_role=%q, want lead", p.RequiredRole)
|
||||
}
|
||||
}
|
||||
|
||||
// Invalid role rejected.
|
||||
if _, err := env.approvals.UpsertPolicy(ctx, env.projectID, env.requester, EntityTypeDeadline, LifecycleCreate, "observer"); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("invalid required_role: got %v, want ErrInvalidInput", err)
|
||||
}
|
||||
|
||||
// Delete.
|
||||
if err := env.approvals.DeletePolicy(ctx, env.projectID, EntityTypeDeadline, LifecycleCreate); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
got, _ = env.approvals.ListPolicies(ctx, env.projectID)
|
||||
if len(got) != 1 {
|
||||
t.Errorf("after delete: %d rows, want 1", len(got))
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,15 @@ func formatAppointment(t *models.Appointment) string {
|
||||
if t.EndAt != nil {
|
||||
w("DTEND:" + t.EndAt.UTC().Format(icalDateUTC))
|
||||
}
|
||||
w("SUMMARY:" + escapeText(t.Title))
|
||||
// Prepend "[PENDING] " on the SUMMARY when the appointment is awaiting
|
||||
// 4-eye approval (t-paliad-138). External clients (Outlook etc.) thus
|
||||
// reflect the unverified state honestly — silence on a pending change
|
||||
// would be a worse outcome than visible-but-flagged.
|
||||
summary := t.Title
|
||||
if t.ApprovalStatus == "pending" {
|
||||
summary = "[PENDING] " + t.Title
|
||||
}
|
||||
w("SUMMARY:" + escapeText(summary))
|
||||
if t.Description != nil && *t.Description != "" {
|
||||
w("DESCRIPTION:" + escapeText(*t.Description))
|
||||
}
|
||||
|
||||
@@ -30,17 +30,28 @@ type DeadlineService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
eventTypes *EventTypeService
|
||||
approvals *ApprovalService
|
||||
}
|
||||
|
||||
// NewDeadlineService wires the service. eventTypes may be nil in tests
|
||||
// that don't exercise the event_types junction; production wires it.
|
||||
// NewDeadlineService wires the service. eventTypes and approvals may be
|
||||
// nil in tests that don't exercise those features; production wires both.
|
||||
func NewDeadlineService(db *sqlx.DB, projects *ProjectService, eventTypes *EventTypeService) *DeadlineService {
|
||||
return &DeadlineService{db: db, projects: projects, eventTypes: eventTypes}
|
||||
}
|
||||
|
||||
// SetApprovalService wires the optional 4-eye approval workflow
|
||||
// (t-paliad-138). When set, every Create/Update/Complete/Delete consults
|
||||
// paliad.approval_policies and may stage the change as a pending request
|
||||
// instead of applying it directly. main.go wires this in production;
|
||||
// tests that don't exercise the workflow can leave it unset.
|
||||
func (s *DeadlineService) SetApprovalService(a *ApprovalService) {
|
||||
s.approvals = a
|
||||
}
|
||||
|
||||
const deadlineColumns = `id, project_id, title, description, due_date, original_due_date,
|
||||
warning_date, source, rule_id, rule_code, status, completed_at, caldav_uid, caldav_etag,
|
||||
notes, created_by, created_at, updated_at`
|
||||
notes, created_by, created_at, updated_at,
|
||||
approval_status, pending_request_id, approved_by, approved_at`
|
||||
|
||||
// CreateDeadlineInput is the payload for Create / bulk create entries.
|
||||
type CreateDeadlineInput struct {
|
||||
@@ -192,6 +203,7 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
|
||||
f.warning_date, f.source, f.rule_id, f.rule_code, f.status, f.completed_at,
|
||||
f.caldav_uid, f.caldav_etag, f.notes, f.created_by,
|
||||
f.created_at, f.updated_at,
|
||||
f.approval_status, f.pending_request_id, f.approved_by, f.approved_at,
|
||||
p.reference AS project_reference,
|
||||
p.title AS project_title,
|
||||
p.type AS project_type,
|
||||
@@ -229,16 +241,35 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ListForProject returns Deadlines for a specific Project (visibility-checked).
|
||||
func (s *DeadlineService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.Deadline, error) {
|
||||
// ListForProject returns Deadlines for a Project (visibility-checked).
|
||||
//
|
||||
// When directOnly is false (default), the result aggregates deadlines from
|
||||
// the Project itself AND every descendant Project (per the t-paliad-139
|
||||
// hierarchy aggregation contract). When directOnly is true, only deadlines
|
||||
// whose project_id exactly equals the filter are returned — useful for
|
||||
// edit / attribution surfaces that want exact narrowing.
|
||||
//
|
||||
// The descendant aggregation reuses the materialised path on
|
||||
// paliad.projects (text-shaped, t-paliad-018). The visibility check on
|
||||
// the filter Project is sufficient: paliad.can_see_project walks ancestors,
|
||||
// so a user who can see Project P can see every descendant of P.
|
||||
func (s *DeadlineService) ListForProject(ctx context.Context, userID, projectID uuid.UUID, directOnly bool) ([]models.Deadline, error) {
|
||||
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := []models.Deadline{}
|
||||
var filter string
|
||||
if directOnly {
|
||||
filter = `WHERE project_id = $1`
|
||||
} else {
|
||||
filter = `WHERE project_id IN (
|
||||
SELECT p.id FROM paliad.projects p
|
||||
WHERE $1 = ANY(string_to_array(p.path, '.')::uuid[]))`
|
||||
}
|
||||
if err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT `+deadlineColumns+`
|
||||
FROM paliad.deadlines
|
||||
WHERE project_id = $1
|
||||
`+filter+`
|
||||
ORDER BY due_date ASC, created_at DESC`, projectID); err != nil {
|
||||
return nil, fmt.Errorf("list deadlines for project: %w", err)
|
||||
}
|
||||
@@ -365,11 +396,23 @@ func (s *DeadlineService) CreateBulk(ctx context.Context, userID, projectID uuid
|
||||
}
|
||||
|
||||
// Update applies a partial update to a Deadline.
|
||||
//
|
||||
// Approval gate (t-paliad-138): if any date-bearing field actually changes
|
||||
// (due_date / original_due_date / warning_date — Q4 allowlist), the change
|
||||
// is applied immediately AND parked in paliad.approval_requests with
|
||||
// approval_status='pending' on the row. Approver flips it to 'approved'
|
||||
// or rejects (which reverts the row from the snapshotted pre_image).
|
||||
//
|
||||
// Refuses to mutate a row whose approval_status is already 'pending'
|
||||
// (a different request is in flight) — caller must wait or revoke.
|
||||
func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UUID, input UpdateDeadlineInput) (*models.Deadline, error) {
|
||||
current, err := s.GetByID(ctx, userID, deadlineID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if current.ApprovalStatus == ApprovalStatusPending {
|
||||
return nil, ErrConcurrentPending
|
||||
}
|
||||
|
||||
sets := []string{}
|
||||
args := []any{}
|
||||
@@ -380,6 +423,13 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
|
||||
next++
|
||||
}
|
||||
|
||||
// Capture pre_image / payload for the date-bearing allowlist as fields
|
||||
// are about to be set. Only populated when a field actually changes —
|
||||
// SubmitUpdate skips the approval flow entirely when nothing in the
|
||||
// allowlist moved.
|
||||
preImage := map[string]any{}
|
||||
payload := map[string]any{}
|
||||
|
||||
if input.Title != nil {
|
||||
title := strings.TrimSpace(*input.Title)
|
||||
if title == "" {
|
||||
@@ -395,6 +445,10 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: due_date must be YYYY-MM-DD", ErrInvalidInput)
|
||||
}
|
||||
if !due.Equal(current.DueDate) {
|
||||
preImage["due_date"] = current.DueDate.Format("2006-01-02")
|
||||
payload["due_date"] = *input.DueDate
|
||||
}
|
||||
appendSet("due_date", due)
|
||||
}
|
||||
if input.Notes != nil {
|
||||
@@ -501,6 +555,15 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Approval gate (Q4 = date-bearing allowlist only). When preImage is
|
||||
// empty (no allowlisted field changed), SubmitUpdate is a no-op.
|
||||
if s.approvals != nil {
|
||||
if _, err := s.approvals.SubmitUpdate(ctx, tx, current.ProjectID, deadlineID, userID, EntityTypeDeadline, preImage, payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit update deadline: %w", err)
|
||||
}
|
||||
@@ -508,6 +571,11 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
|
||||
}
|
||||
|
||||
// Complete marks a Deadline as completed.
|
||||
//
|
||||
// Approval gate (t-paliad-138): if a (project, deadline, complete) policy
|
||||
// applies, the row is flipped to status='completed' immediately AND
|
||||
// parked in approval_requests with approval_status='pending'. Reject
|
||||
// reverts (status back to 'pending', completed_at cleared).
|
||||
func (s *DeadlineService) Complete(ctx context.Context, userID, deadlineID uuid.UUID) (*models.Deadline, error) {
|
||||
current, err := s.GetByID(ctx, userID, deadlineID)
|
||||
if err != nil {
|
||||
@@ -516,6 +584,9 @@ func (s *DeadlineService) Complete(ctx context.Context, userID, deadlineID uuid.
|
||||
if current.Status == "completed" {
|
||||
return current, nil
|
||||
}
|
||||
if current.ApprovalStatus == ApprovalStatusPending {
|
||||
return nil, ErrConcurrentPending
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
@@ -536,6 +607,17 @@ func (s *DeadlineService) Complete(ctx context.Context, userID, deadlineID uuid.
|
||||
map[string]any{"deadline_id": deadlineID}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.approvals != nil {
|
||||
preImage := map[string]any{
|
||||
"status": current.Status,
|
||||
"completed_at": nil,
|
||||
}
|
||||
if _, err := s.approvals.SubmitComplete(ctx, tx, current.ProjectID, deadlineID, userID, EntityTypeDeadline, preImage, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit complete: %w", err)
|
||||
}
|
||||
@@ -621,7 +703,13 @@ func (s *DeadlineService) assertCanAdminProject(ctx context.Context, userID, pro
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete hard-deletes a Deadline. Partner/admin only.
|
||||
// Delete removes a Deadline. Partner/admin only.
|
||||
//
|
||||
// Approval gate (t-paliad-138): if a (project, deadline, delete) policy
|
||||
// applies, this is the one stage-then-write exception in the otherwise
|
||||
// write-then-approve architecture. The row stays alive with
|
||||
// approval_status='pending' until the approver hard-deletes (approve) or
|
||||
// restores it (reject).
|
||||
func (s *DeadlineService) Delete(ctx context.Context, userID, deadlineID uuid.UUID) error {
|
||||
user, err := s.users().GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
@@ -637,6 +725,9 @@ func (s *DeadlineService) Delete(ctx context.Context, userID, deadlineID uuid.UU
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current.ApprovalStatus == ApprovalStatusPending {
|
||||
return ErrConcurrentPending
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
@@ -644,14 +735,35 @@ func (s *DeadlineService) Delete(ctx context.Context, userID, deadlineID uuid.UU
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
|
||||
return fmt.Errorf("delete deadline: %w", err)
|
||||
// Approval gate runs FIRST (before the actual delete). If a policy
|
||||
// applies, SubmitDelete returns a non-nil request id and we skip the
|
||||
// hard delete — the row is now flagged pending. The approver's
|
||||
// Approve flips it to a real delete; their Reject clears the marker.
|
||||
var pendingRequest *uuid.UUID
|
||||
if s.approvals != nil {
|
||||
preImage := map[string]any{
|
||||
"title": current.Title,
|
||||
"due_date": current.DueDate.Format("2006-01-02"),
|
||||
"status": current.Status,
|
||||
}
|
||||
req, err := s.approvals.SubmitDelete(ctx, tx, current.ProjectID, deadlineID, userID, EntityTypeDeadline, preImage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pendingRequest = req
|
||||
}
|
||||
desc := current.Title
|
||||
descPtr := &desc
|
||||
if err := insertProjectEvent(ctx, tx, current.ProjectID, userID, "deadline_deleted", "Deadline deleted", descPtr); err != nil {
|
||||
return err
|
||||
|
||||
if pendingRequest == nil {
|
||||
// No policy applied — proceed with the immediate hard-delete.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
|
||||
return fmt.Errorf("delete deadline: %w", err)
|
||||
}
|
||||
desc := current.Title
|
||||
descPtr := &desc
|
||||
if err := insertProjectEvent(ctx, tx, current.ProjectID, userID, "deadline_deleted", "Deadline deleted", descPtr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -785,6 +897,21 @@ func (s *DeadlineService) insert(ctx context.Context, userID, projectID uuid.UUI
|
||||
map[string]any{"deadline_id": id}); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
|
||||
// Approval gate: if a (project, deadline, create) policy applies, this
|
||||
// flips the just-inserted row's approval_status to 'pending' and emits
|
||||
// a 'deadline_approval_requested' audit event. No-op when no policy is
|
||||
// configured or when the approval service isn't wired (test harness).
|
||||
if s.approvals != nil {
|
||||
payload := map[string]any{
|
||||
"title": desc,
|
||||
"due_date": input.DueDate,
|
||||
}
|
||||
if _, err := s.approvals.SubmitCreate(ctx, tx, projectID, id, userID, EntityTypeDeadline, payload); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return uuid.Nil, fmt.Errorf("commit insert deadline: %w", err)
|
||||
}
|
||||
|
||||
71
internal/services/derivation_membership_scan_test.go
Normal file
71
internal/services/derivation_membership_scan_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TestDerivedMembershipListScan covers the sql.Scanner over a Postgres
|
||||
// jsonb column — the wire format that ListDerivedMembers' jsonb_agg
|
||||
// returns. Pinned because if a future migration changes the JSON shape
|
||||
// (e.g. drops a key), the rendered Herkunft column on /projects/{id}
|
||||
// silently breaks (t-paliad-143).
|
||||
func TestDerivedMembershipListScan(t *testing.T) {
|
||||
unitA := uuid.New()
|
||||
unitB := uuid.New()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
src any
|
||||
want []DerivedMembership
|
||||
}{
|
||||
{
|
||||
name: "nil",
|
||||
src: nil,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "single membership as bytes",
|
||||
src: []byte(`[{"unit_id":"` + unitA.String() + `","unit_name":"Lehment","unit_role":"attorney"}]`),
|
||||
want: []DerivedMembership{{UnitID: unitA, UnitName: "Lehment", UnitRole: "attorney"}},
|
||||
},
|
||||
{
|
||||
name: "two memberships as string",
|
||||
src: `[
|
||||
{"unit_id":"` + unitA.String() + `","unit_name":"Lehment","unit_role":"attorney"},
|
||||
{"unit_id":"` + unitB.String() + `","unit_name":"Plassmann","unit_role":"pa"}
|
||||
]`,
|
||||
want: []DerivedMembership{
|
||||
{UnitID: unitA, UnitName: "Lehment", UnitRole: "attorney"},
|
||||
{UnitID: unitB, UnitName: "Plassmann", UnitRole: "pa"},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var got DerivedMembershipList
|
||||
if err := got.Scan(tc.src); err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("len: got %d want %d", len(got), len(tc.want))
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Errorf("row %d: got %+v want %+v", i, got[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDerivedMembershipListScanRejectsUnknown ensures we don't silently
|
||||
// accept random column types and produce an empty list (which would mask
|
||||
// a schema regression).
|
||||
func TestDerivedMembershipListScanRejectsUnknown(t *testing.T) {
|
||||
var l DerivedMembershipList
|
||||
if err := l.Scan(123); err == nil {
|
||||
t.Fatal("expected error scanning int into DerivedMembershipList, got nil")
|
||||
}
|
||||
}
|
||||
452
internal/services/derivation_service.go
Normal file
452
internal/services/derivation_service.go
Normal file
@@ -0,0 +1,452 @@
|
||||
package services
|
||||
|
||||
// DerivationService manages partner-unit derivation onto project teams
|
||||
// (t-paliad-139). It owns the project↔unit junction table
|
||||
// (paliad.project_partner_units) and the read paths the Team tab + the
|
||||
// approval inbox use to compute "who's effectively on this project via a
|
||||
// partner unit".
|
||||
//
|
||||
// Derivation is computed on read (no materialised state). The visibility
|
||||
// predicate paliad.can_see_project (extended in migration 055) is the
|
||||
// authoritative gate for what users can see; this service is the read /
|
||||
// authoring API on top of it.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// DerivationService is the read + authoring path for partner-unit derivation.
|
||||
type DerivationService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
partnerUnit *PartnerUnitService
|
||||
}
|
||||
|
||||
// NewDerivationService wires the service.
|
||||
func NewDerivationService(db *sqlx.DB, projects *ProjectService, partnerUnit *PartnerUnitService) *DerivationService {
|
||||
return &DerivationService{db: db, projects: projects, partnerUnit: partnerUnit}
|
||||
}
|
||||
|
||||
// AttachedUnit is one row in paliad.project_partner_units enriched with the
|
||||
// unit's display name + count of members that would currently derive given
|
||||
// the configured derive_unit_roles. The frontend renders this on the
|
||||
// /projects/{id}/settings/team Partner Units section.
|
||||
type AttachedUnit struct {
|
||||
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
|
||||
PartnerUnitID uuid.UUID `db:"partner_unit_id" json:"partner_unit_id"`
|
||||
UnitName string `db:"unit_name" json:"unit_name"`
|
||||
DeriveUnitRoles []string `db:"derive_unit_roles" json:"derive_unit_roles"`
|
||||
DeriveGrantsAuthority bool `db:"derive_grants_authority" json:"derive_grants_authority"`
|
||||
DerivedMemberCount int `db:"derived_member_count" json:"derived_member_count"`
|
||||
}
|
||||
|
||||
// DerivedMembership is one (unit, role) pair through which a user currently
|
||||
// derives onto a project. A multi-unit user has one DerivedMembership per
|
||||
// unit they belong to that's attached to the project (or one of its
|
||||
// ancestors) AND whose unit_role is in the attachment's derive_unit_roles.
|
||||
type DerivedMembership struct {
|
||||
UnitID uuid.UUID `json:"unit_id"`
|
||||
UnitName string `json:"unit_name"`
|
||||
UnitRole string `json:"unit_role"`
|
||||
}
|
||||
|
||||
// DerivedMembershipList is a []DerivedMembership that scans from a Postgres
|
||||
// jsonb column (the array_agg/jsonb_agg payload in ListDerivedMembers).
|
||||
type DerivedMembershipList []DerivedMembership
|
||||
|
||||
// Scan implements sql.Scanner over a jsonb array.
|
||||
func (l *DerivedMembershipList) Scan(src any) error {
|
||||
if src == nil {
|
||||
*l = nil
|
||||
return nil
|
||||
}
|
||||
var raw []byte
|
||||
switch v := src.(type) {
|
||||
case []byte:
|
||||
raw = v
|
||||
case string:
|
||||
raw = []byte(v)
|
||||
default:
|
||||
return fmt.Errorf("DerivedMembershipList.Scan: unsupported type %T", src)
|
||||
}
|
||||
return json.Unmarshal(raw, (*[]DerivedMembership)(l))
|
||||
}
|
||||
|
||||
// DerivedMember is one user who currently derives onto a project. The user
|
||||
// may derive via multiple units (e.g. a PA who works with two partners);
|
||||
// each is one entry in Memberships. DeriveGrantsAuthority is true if any
|
||||
// of the source attachments have authority enabled.
|
||||
type DerivedMember struct {
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
Email string `db:"email" json:"user_email"`
|
||||
DisplayName string `db:"display_name" json:"user_display_name"`
|
||||
Office string `db:"office" json:"user_office"`
|
||||
Memberships DerivedMembershipList `db:"memberships" json:"memberships"`
|
||||
DeriveGrantsAuthority bool `db:"derive_grants_authority" json:"derive_grants_authority"`
|
||||
}
|
||||
|
||||
// AttachUnitOptions controls how a unit is attached. Empty values use the
|
||||
// migration-055 defaults: derive_unit_roles = {pa, senior_pa},
|
||||
// derive_grants_authority = false (visibility-only).
|
||||
type AttachUnitOptions struct {
|
||||
DeriveUnitRoles []string
|
||||
DeriveGrantsAuthority bool
|
||||
}
|
||||
|
||||
// requireWritePermission gates project↔unit attach/detach to project lead
|
||||
// or global_admin. Mirrors the RLS write policy in migration 055.
|
||||
func (s *DerivationService) requireWritePermission(ctx context.Context, callerID, projectID uuid.UUID) error {
|
||||
user, err := s.projects.Users().GetByID(ctx, callerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user != nil && user.GlobalRole == "global_admin" {
|
||||
return nil
|
||||
}
|
||||
var role string
|
||||
err = s.db.GetContext(ctx, &role,
|
||||
`SELECT role FROM paliad.project_teams
|
||||
WHERE project_id = $1 AND user_id = $2`,
|
||||
projectID, callerID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrForbidden
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read project_teams role: %w", err)
|
||||
}
|
||||
if role != RoleLead {
|
||||
return ErrForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AttachUnitToProject creates a project_partner_units row. Idempotent on
|
||||
// (project_id, partner_unit_id) — a repeat call updates the derive options.
|
||||
// Caller must be project lead OR global_admin.
|
||||
func (s *DerivationService) AttachUnitToProject(ctx context.Context, callerID, projectID, unitID uuid.UUID, opts AttachUnitOptions) error {
|
||||
if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.requireWritePermission(ctx, callerID, projectID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.partnerUnit.GetByID(ctx, unitID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
roles := opts.DeriveUnitRoles
|
||||
if len(roles) == 0 {
|
||||
roles = []string{UnitRolePA, UnitRoleSeniorPA}
|
||||
}
|
||||
for _, r := range roles {
|
||||
if !isValidUnitRole(r) {
|
||||
return fmt.Errorf("%w: invalid unit_role %q in derive_unit_roles", ErrInvalidInput, r)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_partner_units
|
||||
(project_id, partner_unit_id, derive_unit_roles, derive_grants_authority,
|
||||
attached_at, attached_by)
|
||||
VALUES ($1, $2, $3, $4, now(), $5)
|
||||
ON CONFLICT (project_id, partner_unit_id) DO UPDATE
|
||||
SET derive_unit_roles = EXCLUDED.derive_unit_roles,
|
||||
derive_grants_authority = EXCLUDED.derive_grants_authority`,
|
||||
projectID, unitID, pq.StringArray(roles), opts.DeriveGrantsAuthority, callerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("attach unit to project: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DetachUnitFromProject deletes a project_partner_units row. Idempotent —
|
||||
// repeat detach is a no-op.
|
||||
func (s *DerivationService) DetachUnitFromProject(ctx context.Context, callerID, projectID, unitID uuid.UUID) error {
|
||||
if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.requireWritePermission(ctx, callerID, projectID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM paliad.project_partner_units
|
||||
WHERE project_id = $1 AND partner_unit_id = $2`,
|
||||
projectID, unitID); err != nil {
|
||||
return fmt.Errorf("detach unit from project: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListAttachedUnits returns the unit attachments anchored on this exact
|
||||
// project (NOT walking ancestors — the project /settings/team page wants
|
||||
// to manage its own attachments only). Each row is enriched with the unit
|
||||
// name and the count of members that would currently derive given the
|
||||
// configured derive_unit_roles.
|
||||
func (s *DerivationService) ListAttachedUnits(ctx context.Context, callerID, projectID uuid.UUID) ([]AttachedUnit, error) {
|
||||
if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := []AttachedUnit{}
|
||||
err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT ppu.project_id,
|
||||
ppu.partner_unit_id,
|
||||
pu.name AS unit_name,
|
||||
ppu.derive_unit_roles,
|
||||
ppu.derive_grants_authority,
|
||||
(SELECT COUNT(*) FROM paliad.partner_unit_members pum
|
||||
WHERE pum.partner_unit_id = ppu.partner_unit_id
|
||||
AND pum.unit_role = ANY(ppu.derive_unit_roles)) AS derived_member_count
|
||||
FROM paliad.project_partner_units ppu
|
||||
JOIN paliad.partner_units pu ON pu.id = ppu.partner_unit_id
|
||||
WHERE ppu.project_id = $1
|
||||
ORDER BY pu.name`, projectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list attached units: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ListDerivedMembers returns users who currently derive onto this project
|
||||
// via any attached unit on the project's path (this project + ancestors).
|
||||
// Walks UP the path because a unit attached at the Client level cascades
|
||||
// down to descendants — derivation honours the same direction as
|
||||
// can_see_project.
|
||||
//
|
||||
// One row per user. Multi-unit users (e.g. a PA working across two partner
|
||||
// units, both of which are attached to the project's path) carry every
|
||||
// (unit, role) pair in Memberships so the Herkunft column can list them
|
||||
// all (t-paliad-143). DeriveGrantsAuthority is bool_or across the
|
||||
// underlying attachments — a user with at least one authority-granting
|
||||
// derivation source qualifies as authority-bearing for approval purposes.
|
||||
func (s *DerivationService) ListDerivedMembers(ctx context.Context, callerID, projectID uuid.UUID) ([]DerivedMember, error) {
|
||||
project, err := s.projects.GetByID(ctx, callerID, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ancestorIDs := pathToIDStrings(project.Path)
|
||||
if len(ancestorIDs) == 0 {
|
||||
return []DerivedMember{}, nil
|
||||
}
|
||||
|
||||
rows := []DerivedMember{}
|
||||
err = s.db.SelectContext(ctx, &rows, `
|
||||
WITH attached AS (
|
||||
SELECT ppu.project_id AS attach_project_id,
|
||||
ppu.partner_unit_id,
|
||||
ppu.derive_unit_roles,
|
||||
ppu.derive_grants_authority
|
||||
FROM paliad.project_partner_units ppu
|
||||
WHERE ppu.project_id = ANY($1::uuid[])
|
||||
)
|
||||
SELECT pum.user_id,
|
||||
u.email, u.display_name, u.office,
|
||||
jsonb_agg(DISTINCT jsonb_build_object(
|
||||
'unit_id', a.partner_unit_id,
|
||||
'unit_name', pu.name,
|
||||
'unit_role', pum.unit_role
|
||||
)) AS memberships,
|
||||
bool_or(a.derive_grants_authority) AS derive_grants_authority
|
||||
FROM attached a
|
||||
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = a.partner_unit_id
|
||||
JOIN paliad.users u ON u.id = pum.user_id
|
||||
JOIN paliad.partner_units pu ON pu.id = a.partner_unit_id
|
||||
WHERE pum.unit_role = ANY(a.derive_unit_roles)
|
||||
GROUP BY pum.user_id, u.email, u.display_name, u.office
|
||||
ORDER BY u.display_name`,
|
||||
pq.StringArray(ancestorIDs))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list derived members: %w", err)
|
||||
}
|
||||
// jsonb_agg(DISTINCT …) doesn't support ORDER BY in the same call.
|
||||
// Sort each member's memberships by unit_name in Go so the Herkunft
|
||||
// column renders deterministically.
|
||||
for i := range rows {
|
||||
ms := rows[i].Memberships
|
||||
for j := 1; j < len(ms); j++ {
|
||||
for k := j; k > 0 && ms[k-1].UnitName > ms[k].UnitName; k-- {
|
||||
ms[k-1], ms[k] = ms[k], ms[k-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ListDescendantStaffed returns users who are directly staffed on a
|
||||
// descendant of the given project but not on the project itself or its
|
||||
// ancestors. This is the new "Aus Unterprojekten" subsection on the Team
|
||||
// tab — explicit Case-level staff that surfaces up to the parent for
|
||||
// awareness.
|
||||
//
|
||||
// Excludes inherited rows (descendant team rows are by definition direct
|
||||
// at their level — what we filter out are users already on this project
|
||||
// or its ancestors so the same user doesn't appear in two subsections).
|
||||
func (s *DerivationService) ListDescendantStaffed(ctx context.Context, callerID, projectID uuid.UUID) ([]models.ProjectTeamMemberWithUser, error) {
|
||||
if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows := []models.ProjectTeamMemberWithUser{}
|
||||
err := s.db.SelectContext(ctx, &rows, `
|
||||
WITH descendants AS (
|
||||
SELECT p.id, p.title
|
||||
FROM paliad.projects p
|
||||
WHERE p.id <> $1
|
||||
AND $1 = ANY(string_to_array(p.path, '.')::uuid[])
|
||||
),
|
||||
ancestor_or_self AS (
|
||||
SELECT pp.id
|
||||
FROM paliad.projects target
|
||||
JOIN paliad.projects pp
|
||||
ON pp.id = ANY(string_to_array(target.path, '.')::uuid[])
|
||||
WHERE target.id = $1
|
||||
),
|
||||
descendant_rows AS (
|
||||
SELECT pt.id, pt.project_id, pt.user_id, pt.role, pt.added_by, pt.created_at,
|
||||
d.title AS source_title
|
||||
FROM paliad.project_teams pt
|
||||
JOIN descendants d ON d.id = pt.project_id
|
||||
WHERE pt.user_id NOT IN (
|
||||
SELECT user_id FROM paliad.project_teams
|
||||
WHERE project_id IN (SELECT id FROM ancestor_or_self)
|
||||
)
|
||||
),
|
||||
dedup AS (
|
||||
SELECT dr.*,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY dr.user_id
|
||||
ORDER BY dr.created_at ASC
|
||||
) AS rn
|
||||
FROM descendant_rows dr
|
||||
)
|
||||
SELECT d.id, d.project_id, d.user_id, d.role,
|
||||
true AS inherited,
|
||||
d.added_by, d.created_at,
|
||||
u.email AS user_email,
|
||||
u.display_name AS user_display_name,
|
||||
u.office AS user_office,
|
||||
d.project_id AS inherited_from_id,
|
||||
d.source_title AS inherited_from_title
|
||||
FROM dedup d
|
||||
JOIN paliad.users u ON u.id = d.user_id
|
||||
WHERE d.rn = 1
|
||||
ORDER BY d.role, u.display_name`,
|
||||
projectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list descendant-staffed: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// EffectiveProjectRole returns (role, source) where source is one of:
|
||||
// 'direct', 'ancestor', 'descendant', 'derived'. Used by the t-138
|
||||
// approval ladder via canApprove() — Phase 3 of t-paliad-139 will wire
|
||||
// this in.
|
||||
//
|
||||
// Resolution order:
|
||||
// 1. direct (this project_teams row)
|
||||
// 2. ancestor (project_teams on any ancestor — closest wins)
|
||||
// 3. derived (partner_unit_members on an attached unit on this project
|
||||
// or any ancestor — closest wins; only when derive_grants_authority=true)
|
||||
// 4. descendant (rare for authority — explicit staffing on a descendant
|
||||
// does NOT confer authority on the ancestor; returned for read use
|
||||
// only, callers should prefer the higher tiers)
|
||||
//
|
||||
// Returns ("", "") when the user has no membership of any kind. This is a
|
||||
// service-internal lookup — it does NOT visibility-check, since callers
|
||||
// (the t-138 approval gate) need to know the caller's effective role even
|
||||
// when visibility is being evaluated for the first time.
|
||||
func (s *DerivationService) EffectiveProjectRole(ctx context.Context, userID, projectID uuid.UUID) (string, string, error) {
|
||||
var path string
|
||||
err := s.db.GetContext(ctx, &path,
|
||||
`SELECT path FROM paliad.projects WHERE id = $1`, projectID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("read project path: %w", err)
|
||||
}
|
||||
ancestorIDs := pathToIDStrings(path)
|
||||
|
||||
// 1. Direct
|
||||
var directRole string
|
||||
err = s.db.GetContext(ctx, &directRole,
|
||||
`SELECT role FROM paliad.project_teams WHERE project_id = $1 AND user_id = $2`,
|
||||
projectID, userID)
|
||||
if err == nil {
|
||||
return directRole, "direct", nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return "", "", fmt.Errorf("read direct role: %w", err)
|
||||
}
|
||||
|
||||
// 2. Ancestor (closest wins via path distance — already root→self order
|
||||
// in the path; pick the row whose project_id appears latest in the
|
||||
// ancestorIDs array).
|
||||
type ancRow struct {
|
||||
Role string `db:"role"`
|
||||
ProjID string `db:"project_id"`
|
||||
Position int `db:"position"`
|
||||
}
|
||||
var ancestorMatches []ancRow
|
||||
if len(ancestorIDs) > 0 {
|
||||
err = s.db.SelectContext(ctx, &ancestorMatches, `
|
||||
SELECT pt.role,
|
||||
pt.project_id::text AS project_id,
|
||||
array_position($1::uuid[], pt.project_id) AS position
|
||||
FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = $2
|
||||
AND pt.project_id = ANY($1::uuid[])
|
||||
ORDER BY position DESC NULLS LAST
|
||||
LIMIT 1`,
|
||||
pq.StringArray(ancestorIDs), userID)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("read ancestor role: %w", err)
|
||||
}
|
||||
if len(ancestorMatches) > 0 {
|
||||
return ancestorMatches[0].Role, "ancestor", nil
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Derived with authority. Only authority-granting attachments count
|
||||
// here; visibility-only derivation does not yield an effective role for
|
||||
// approval purposes. The derived role is mapped from unit_role via
|
||||
// approval_role_from_unit_role (a SQL function added in migration 055).
|
||||
type derivedRow struct {
|
||||
Role string `db:"role"`
|
||||
}
|
||||
var derived []derivedRow
|
||||
if len(ancestorIDs) > 0 {
|
||||
err = s.db.SelectContext(ctx, &derived, `
|
||||
SELECT paliad.approval_role_from_unit_role(pum.unit_role) AS role
|
||||
FROM paliad.project_partner_units ppu
|
||||
JOIN paliad.partner_unit_members pum
|
||||
ON pum.partner_unit_id = ppu.partner_unit_id
|
||||
AND pum.user_id = $2
|
||||
AND pum.unit_role = ANY(ppu.derive_unit_roles)
|
||||
WHERE ppu.project_id = ANY($1::uuid[])
|
||||
AND ppu.derive_grants_authority = true
|
||||
ORDER BY paliad.approval_role_level(
|
||||
paliad.approval_role_from_unit_role(pum.unit_role)
|
||||
) DESC
|
||||
LIMIT 1`,
|
||||
pq.StringArray(ancestorIDs), userID)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("read derived role: %w", err)
|
||||
}
|
||||
if len(derived) > 0 {
|
||||
return derived[0].Role, "derived", nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", nil
|
||||
}
|
||||
@@ -86,6 +86,11 @@ type EventListItem struct {
|
||||
ProjectType *string `json:"project_type,omitempty"`
|
||||
CreatedBy *uuid.UUID `json:"created_by,omitempty"`
|
||||
|
||||
// Approval workflow (t-paliad-138). ApprovalStatus is "approved"
|
||||
// (default), "pending" (in-flight 4-eye request — pill rendered on
|
||||
// every list surface), or "legacy" (pre-4-eye row, no pill).
|
||||
ApprovalStatus *string `json:"approval_status,omitempty"`
|
||||
|
||||
// Deadline-only.
|
||||
DueDate *string `json:"due_date,omitempty"` // YYYY-MM-DD
|
||||
Status *string `json:"status,omitempty"`
|
||||
@@ -195,6 +200,7 @@ func projectDeadline(d models.DeadlineWithProject) EventListItem {
|
||||
due := d.DueDate.Format("2006-01-02")
|
||||
status := d.Status
|
||||
src := d.Source
|
||||
approvalStatus := d.ApprovalStatus
|
||||
|
||||
return EventListItem{
|
||||
Type: "deadline",
|
||||
@@ -207,6 +213,7 @@ func projectDeadline(d models.DeadlineWithProject) EventListItem {
|
||||
ProjectTitle: &pt,
|
||||
ProjectType: &ptype,
|
||||
CreatedBy: d.CreatedBy,
|
||||
ApprovalStatus: &approvalStatus,
|
||||
DueDate: &due,
|
||||
Status: &status,
|
||||
CompletedAt: d.CompletedAt,
|
||||
@@ -222,6 +229,7 @@ func projectDeadline(d models.DeadlineWithProject) EventListItem {
|
||||
// projectAppointment projects an AppointmentWithProject row into the union shape.
|
||||
func projectAppointment(a models.AppointmentWithProject) EventListItem {
|
||||
startCopy := a.StartAt
|
||||
approvalStatus := a.ApprovalStatus
|
||||
return EventListItem{
|
||||
Type: "appointment",
|
||||
ID: a.ID,
|
||||
@@ -233,6 +241,7 @@ func projectAppointment(a models.AppointmentWithProject) EventListItem {
|
||||
ProjectTitle: a.ProjectTitle,
|
||||
ProjectType: a.ProjectType,
|
||||
CreatedBy: a.CreatedBy,
|
||||
ApprovalStatus: &approvalStatus,
|
||||
StartAt: &startCopy,
|
||||
EndAt: a.EndAt,
|
||||
Location: a.Location,
|
||||
|
||||
@@ -324,6 +324,58 @@ func (s *PartnerUnitService) RemoveMember(ctx context.Context, callerID, partner
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// SetMemberRole updates the unit_role column on a (partner_unit, user)
|
||||
// membership. Admin-only. Validates the role against the migration-055 CHECK.
|
||||
// Emits 'member_role_changed' carrying the prior + new role values so the
|
||||
// audit trail captures the transition.
|
||||
func (s *PartnerUnitService) SetMemberRole(ctx context.Context, callerID, partnerUnitID, userID uuid.UUID, role string) error {
|
||||
if err := s.requireAdmin(ctx, callerID); err != nil {
|
||||
return err
|
||||
}
|
||||
if !isValidUnitRole(role) {
|
||||
return fmt.Errorf("%w: invalid unit_role %q", ErrInvalidInput, role)
|
||||
}
|
||||
unit, err := s.GetByID(ctx, partnerUnitID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
var prior string
|
||||
err = tx.GetContext(ctx, &prior,
|
||||
`SELECT unit_role FROM paliad.partner_unit_members
|
||||
WHERE partner_unit_id = $1 AND user_id = $2`,
|
||||
partnerUnitID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read prior unit_role: %w", err)
|
||||
}
|
||||
if prior == role {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.partner_unit_members
|
||||
SET unit_role = $3
|
||||
WHERE partner_unit_id = $1 AND user_id = $2`,
|
||||
partnerUnitID, userID, role); err != nil {
|
||||
return fmt.Errorf("update unit_role: %w", err)
|
||||
}
|
||||
|
||||
if err := s.emit(ctx, tx, callerID, &partnerUnitID, unit.Name, "member_role_changed", map[string]any{
|
||||
"user_id": userID,
|
||||
"old_role": prior,
|
||||
"new_role": role,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// AddMemberTx is the same as AddMember but runs inside the caller's tx and
|
||||
// skips the admin gate (caller has already authorised the parent operation).
|
||||
// Used by user_service.OnboardUser to insert a partner_unit membership in
|
||||
@@ -362,15 +414,40 @@ func (s *PartnerUnitService) AddMemberTx(ctx context.Context, tx *sqlx.Tx, actor
|
||||
|
||||
// PartnerUnitMemberDetail is one user's membership row enriched with display
|
||||
// fields for the admin/team UIs.
|
||||
//
|
||||
// UnitRole (added by t-paliad-139 / migration 055) is the per-unit role
|
||||
// distinction used by the derivation rule: a unit attached to a project
|
||||
// auto-derives its members whose unit_role is in the attachment's
|
||||
// derive_unit_roles set (default {pa, senior_pa}). Possible values:
|
||||
// 'lead' | 'attorney' | 'senior_pa' | 'pa' | 'paralegal'. Defaults to
|
||||
// 'attorney' for every pre-055 row.
|
||||
type PartnerUnitMemberDetail struct {
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
Email string `db:"email" json:"email"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
Office string `db:"office" json:"office"`
|
||||
JobTitle *string `db:"job_title" json:"job_title"`
|
||||
UnitRole string `db:"unit_role" json:"unit_role"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
// PartnerUnitMemberRole values (mirror migration 055 CHECK constraint).
|
||||
const (
|
||||
UnitRoleLead = "lead"
|
||||
UnitRoleAttorney = "attorney"
|
||||
UnitRoleSeniorPA = "senior_pa"
|
||||
UnitRolePA = "pa"
|
||||
UnitRoleParalegal = "paralegal"
|
||||
)
|
||||
|
||||
func isValidUnitRole(r string) bool {
|
||||
switch r {
|
||||
case UnitRoleLead, UnitRoleAttorney, UnitRoleSeniorPA, UnitRolePA, UnitRoleParalegal:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ListMembers returns users in the PartnerUnit, enriched with display fields.
|
||||
//
|
||||
// INNER JOIN on paliad.users: partner_unit_members.user_id FKs auth.users, so
|
||||
@@ -381,7 +458,7 @@ type PartnerUnitMemberDetail struct {
|
||||
func (s *PartnerUnitService) ListMembers(ctx context.Context, partnerUnitID uuid.UUID) ([]PartnerUnitMemberDetail, error) {
|
||||
var rows []PartnerUnitMemberDetail
|
||||
err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT pum.user_id, pum.created_at,
|
||||
`SELECT pum.user_id, pum.created_at, pum.unit_role,
|
||||
u.email, u.display_name, u.office, u.job_title
|
||||
FROM paliad.partner_unit_members pum
|
||||
JOIN paliad.users u ON u.id = pum.user_id
|
||||
@@ -431,7 +508,7 @@ func (s *PartnerUnitService) ListWithMembers(ctx context.Context) ([]PartnerUnit
|
||||
}
|
||||
var members []memberRow
|
||||
err = s.db.SelectContext(ctx, &members,
|
||||
`SELECT pum.partner_unit_id, pum.user_id, pum.created_at,
|
||||
`SELECT pum.partner_unit_id, pum.user_id, pum.created_at, pum.unit_role,
|
||||
u.email, u.display_name, u.office, u.job_title
|
||||
FROM paliad.partner_unit_members pum
|
||||
JOIN paliad.users u ON u.id = pum.user_id
|
||||
|
||||
@@ -18,11 +18,24 @@ import (
|
||||
// rows attached to that Project AND every descendant Project (Litigation,
|
||||
// Patent, Case below it). The descendant set is derived from
|
||||
// paliad.projects.path, which the schema's path trigger keeps in sync from
|
||||
// parent_id. The check is exercised against four entry points:
|
||||
// - DeadlineService.ListVisibleForUser
|
||||
// - DeadlineService.SummaryCounts
|
||||
// - AppointmentService.ListVisibleForUser
|
||||
// - EventService.ListVisibleForUser (union of deadlines + appointments)
|
||||
// parent_id.
|
||||
//
|
||||
// t-paliad-139 extends the contract to the per-project narrow read paths
|
||||
// that the /projects/{id} detail surfaces use:
|
||||
// - DeadlineService.ListForProject
|
||||
// - AppointmentService.ListForProject
|
||||
// - ProjectService.ListEvents (audit / Verlauf)
|
||||
// All three default to subtree aggregation (directOnly=false). When
|
||||
// directOnly=true, only rows with project_id == filter are returned.
|
||||
//
|
||||
// The check is exercised against:
|
||||
// - DeadlineService.ListVisibleForUser (union, t-124)
|
||||
// - DeadlineService.SummaryCounts (union, t-124)
|
||||
// - AppointmentService.ListVisibleForUser (union, t-124)
|
||||
// - EventService.ListVisibleForUser (union, t-124)
|
||||
// - DeadlineService.ListForProject (per-project narrow, t-139)
|
||||
// - AppointmentService.ListForProject (per-project narrow, t-139)
|
||||
// - ProjectService.ListEvents (per-project narrow, t-139)
|
||||
//
|
||||
// Skipped when TEST_DATABASE_URL is unset.
|
||||
func TestProjectFilter_IncludesDescendants(t *testing.T) {
|
||||
@@ -275,6 +288,72 @@ func TestProjectFilter_IncludesDescendants(t *testing.T) {
|
||||
if gotEA != c.wantAppointmts {
|
||||
t.Errorf("events.ListVisibleForUser appointments: got %d, want %d", gotEA, c.wantAppointmts)
|
||||
}
|
||||
|
||||
// t-paliad-139: per-project narrow paths must match the union path
|
||||
// when directOnly=false (subtree default), and must collapse to
|
||||
// just the direct row when directOnly=true.
|
||||
|
||||
// DeadlineService.ListForProject — subtree (default).
|
||||
dlfp, err := deadlines.ListForProject(ctx, adminID, pid, false)
|
||||
if err != nil {
|
||||
t.Fatalf("deadlines.ListForProject subtree: %v", err)
|
||||
}
|
||||
gotDFP := 0
|
||||
for _, r := range dlfp {
|
||||
if seedDeadlines[r.ID] {
|
||||
gotDFP++
|
||||
}
|
||||
}
|
||||
if gotDFP != c.wantDeadlines {
|
||||
t.Errorf("deadlines.ListForProject subtree: got %d, want %d", gotDFP, c.wantDeadlines)
|
||||
}
|
||||
|
||||
// DeadlineService.ListForProject — directOnly=true.
|
||||
dlfpDirect, err := deadlines.ListForProject(ctx, adminID, pid, true)
|
||||
if err != nil {
|
||||
t.Fatalf("deadlines.ListForProject direct: %v", err)
|
||||
}
|
||||
gotDFPDirect := 0
|
||||
for _, r := range dlfpDirect {
|
||||
if seedDeadlines[r.ID] {
|
||||
gotDFPDirect++
|
||||
}
|
||||
}
|
||||
// directOnly: only the deadline whose project_id == filter is
|
||||
// returned (the seeded direct-row at this level).
|
||||
if gotDFPDirect != 1 {
|
||||
t.Errorf("deadlines.ListForProject directOnly: got %d, want 1", gotDFPDirect)
|
||||
}
|
||||
|
||||
// AppointmentService.ListForProject — subtree (default).
|
||||
alfp, err := appointments.ListForProject(ctx, adminID, pid, false)
|
||||
if err != nil {
|
||||
t.Fatalf("appointments.ListForProject subtree: %v", err)
|
||||
}
|
||||
gotAFP := 0
|
||||
for _, r := range alfp {
|
||||
if seedAppointments[r.ID] {
|
||||
gotAFP++
|
||||
}
|
||||
}
|
||||
if gotAFP != c.wantAppointmts {
|
||||
t.Errorf("appointments.ListForProject subtree: got %d, want %d", gotAFP, c.wantAppointmts)
|
||||
}
|
||||
|
||||
// AppointmentService.ListForProject — directOnly=true.
|
||||
alfpDirect, err := appointments.ListForProject(ctx, adminID, pid, true)
|
||||
if err != nil {
|
||||
t.Fatalf("appointments.ListForProject direct: %v", err)
|
||||
}
|
||||
gotAFPDirect := 0
|
||||
for _, r := range alfpDirect {
|
||||
if seedAppointments[r.ID] {
|
||||
gotAFPDirect++
|
||||
}
|
||||
}
|
||||
if gotAFPDirect != 1 {
|
||||
t.Errorf("appointments.ListForProject directOnly: got %d, want 1", gotAFPDirect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -704,7 +704,14 @@ const DefaultEventsPageLimit = 50
|
||||
|
||||
// ListEvents returns the audit trail for the Project, newest first, with
|
||||
// cursor pagination (before = uuid of last seen event).
|
||||
func (s *ProjectService) ListEvents(ctx context.Context, userID, id uuid.UUID, before *uuid.UUID, limit int) ([]models.ProjectEvent, error) {
|
||||
//
|
||||
// When directOnly is false (default), the result aggregates events from
|
||||
// the Project itself AND every descendant Project (per the t-paliad-139
|
||||
// hierarchy aggregation contract — Verlauf on a Client should show the
|
||||
// matter's complete history, not just rows attached at the root). When
|
||||
// directOnly is true, only events whose project_id exactly equals the
|
||||
// filter are returned.
|
||||
func (s *ProjectService) ListEvents(ctx context.Context, userID, id uuid.UUID, before *uuid.UUID, limit int, directOnly bool) ([]models.ProjectEvent, error) {
|
||||
if _, err := s.GetByID(ctx, userID, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -718,16 +725,27 @@ func (s *ProjectService) ListEvents(ctx context.Context, userID, id uuid.UUID, b
|
||||
if before != nil {
|
||||
beforeArg = *before
|
||||
}
|
||||
var projectFilter string
|
||||
if directOnly {
|
||||
projectFilter = `project_id = $1`
|
||||
} else {
|
||||
// Inner alias `pp` to avoid shadowing the outer `p` JOIN below.
|
||||
projectFilter = `project_id IN (
|
||||
SELECT pp.id FROM paliad.projects pp
|
||||
WHERE $1 = ANY(string_to_array(pp.path, '.')::uuid[]))`
|
||||
}
|
||||
var events []models.ProjectEvent
|
||||
err := s.db.SelectContext(ctx, &events,
|
||||
`SELECT id, project_id, event_type, title, description, event_date,
|
||||
created_by, metadata, created_at, updated_at
|
||||
FROM paliad.project_events
|
||||
WHERE project_id = $1
|
||||
AND ($2::uuid IS NULL OR (created_at, id) < (
|
||||
`SELECT pe.id, pe.project_id, pe.event_type, pe.title, pe.description, pe.event_date,
|
||||
pe.created_by, pe.metadata, pe.created_at, pe.updated_at,
|
||||
p.title AS project_title
|
||||
FROM paliad.project_events pe
|
||||
LEFT JOIN paliad.projects p ON p.id = pe.project_id
|
||||
WHERE pe.`+projectFilter+`
|
||||
AND ($2::uuid IS NULL OR (pe.created_at, pe.id) < (
|
||||
SELECT created_at, id FROM paliad.project_events WHERE id = $2::uuid
|
||||
))
|
||||
ORDER BY created_at DESC, id DESC
|
||||
ORDER BY pe.created_at DESC, pe.id DESC
|
||||
LIMIT $3`, id, beforeArg, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list project events: %w", err)
|
||||
|
||||
@@ -242,6 +242,11 @@ type digestRow struct {
|
||||
ProjectReference string `db:"project_reference"`
|
||||
ProjectTitle string `db:"project_title"`
|
||||
IsLead bool `db:"is_lead"`
|
||||
// ApprovalStatus (t-paliad-138). When 'pending', the digest renders
|
||||
// the row with a "[PENDING] " title prefix so the user can't miss
|
||||
// that the deadline is unverified — silence on a pending change is
|
||||
// the worst outcome.
|
||||
ApprovalStatus string `db:"approval_status"`
|
||||
// OwnerEscalationContactID is the owner's optional escalation override:
|
||||
// non-NULL diverts overdue/DRINGEND escalation away from global_admins
|
||||
// to the named user. Used by visibleForCategory to decide whether the
|
||||
@@ -300,6 +305,7 @@ func (s *ReminderService) fetchSlotDeadlines(ctx context.Context, u models.User,
|
||||
SELECT f.id AS deadline_id,
|
||||
f.title AS title,
|
||||
f.due_date AS due_date,
|
||||
f.approval_status AS approval_status,
|
||||
f.created_by AS owner_id,
|
||||
COALESCE(own.display_name, '') AS owner_name,
|
||||
own.escalation_contact_id AS owner_escalation_contact_id,
|
||||
@@ -590,11 +596,23 @@ func (s *ReminderService) deliverDigest(u models.User, slot string, rows []diges
|
||||
|
||||
// Bucket rows by category for the template. Within a category, rows
|
||||
// arrive sorted by due_date already (SQL ORDER BY).
|
||||
//
|
||||
// Pending-approval rows (t-paliad-138) get a "[PENDING] " title prefix
|
||||
// so the recipient can't miss that the deadline is unverified — silence
|
||||
// on a pending change is the worst outcome for a 4-eye system.
|
||||
var overdue, dueToday, dueWarning []map[string]any
|
||||
pendingCount := 0
|
||||
for _, r := range rows {
|
||||
title := r.Title
|
||||
isPending := r.ApprovalStatus == ApprovalStatusPending
|
||||
if isPending {
|
||||
title = "[PENDING] " + title
|
||||
pendingCount++
|
||||
}
|
||||
item := map[string]any{
|
||||
"DueDate": r.DueDate.Format("2006-01-02"),
|
||||
"Title": r.Title,
|
||||
"Title": title,
|
||||
"IsPending": isPending,
|
||||
"ProjectReference": r.ProjectReference,
|
||||
"ProjectTitle": r.ProjectTitle,
|
||||
"OwnerName": r.OwnerName,
|
||||
@@ -627,6 +645,13 @@ func (s *ReminderService) deliverDigest(u models.User, slot string, rows []diges
|
||||
"DueWarningCount": len(dueWarning),
|
||||
"OpenTotal": len(dueToday) + len(dueWarning),
|
||||
"DeadlinesURL": fmt.Sprintf("%s/deadlines", s.baseURL),
|
||||
// PendingCount > 0 → templates can render a banner like
|
||||
// "Hinweis: N Frist(en) wartet auf 4-Augen-Genehmigung —
|
||||
// /inbox" above the digest body. Available even when the
|
||||
// template doesn't currently use it (forward-compat, no
|
||||
// existing-template breakage).
|
||||
"PendingCount": pendingCount,
|
||||
"InboxURL": fmt.Sprintf("%s/inbox", s.baseURL),
|
||||
}
|
||||
return s.mail.SendTemplate(TemplateData{
|
||||
To: u.Email,
|
||||
|
||||
Reference in New Issue
Block a user