Compare commits
85 Commits
mai/cronus
...
mai/fritz/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db4279d148 | ||
|
|
552c9200bc | ||
|
|
aeeded7e21 | ||
|
|
4e1d311a9c | ||
|
|
1061685981 | ||
|
|
a5f7b5009b | ||
|
|
b59e44616d | ||
|
|
fb608321ca | ||
|
|
35f307d61d | ||
|
|
8412328dec | ||
|
|
2201c6da73 | ||
|
|
438e73fd13 | ||
|
|
597d76e21c | ||
|
|
8bdebe9bc1 | ||
|
|
d53cc3553c | ||
|
|
b9824dd86f | ||
|
|
397a9b1854 | ||
|
|
f4aa2033f9 | ||
|
|
efaa7787af | ||
|
|
c6cdd2c855 | ||
|
|
fc7192c115 | ||
|
|
8d714dd95e | ||
|
|
0b4de1c645 | ||
|
|
2af4bf1f88 | ||
|
|
9184e9b0ef | ||
|
|
7b66c4d035 | ||
|
|
e6937d232e | ||
|
|
6506864730 | ||
|
|
ab2530ff44 | ||
|
|
8cc8435d2e | ||
|
|
c81ca6a12a | ||
|
|
0f835b6c59 | ||
|
|
905e743281 | ||
|
|
215a1ceeda | ||
|
|
e4adc39833 | ||
|
|
3dffce7a0d | ||
|
|
d8b84d0c58 | ||
|
|
d24f73358c | ||
|
|
52ee319fd8 | ||
|
|
dc7c807725 | ||
|
|
1eb43ceb6b | ||
|
|
99f08e3863 | ||
|
|
dd4f563212 | ||
|
|
95f6f03cda | ||
|
|
fdde9eb754 | ||
|
|
cda4b4083d | ||
|
|
b516201110 | ||
|
|
956ff10e4d | ||
|
|
5c263102e3 | ||
|
|
f44ee0af0f | ||
|
|
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 | ||
|
|
457af2f6c4 | ||
|
|
abc395fcfa | ||
|
|
747d85fe49 | ||
|
|
6c41550945 | ||
|
|
fb6a07f4b7 | ||
|
|
10b3426086 | ||
|
|
4ebbf2c1af | ||
|
|
b3401ec8ac | ||
|
|
7d1ddb9b84 | ||
|
|
c1ceab7f4b |
@@ -46,7 +46,11 @@ Paliad — the patent paladin. All-in-one patent practice platform for HLC (form
|
||||
| `GITEA_TOKEN` | optional | Gitea API token for the private file proxy (Downloads) |
|
||||
| `PALIAD_BASE_URL` | optional | Public origin used in email links. Defaults to `https://paliad.de`; override for staging/preview. |
|
||||
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USERNAME` / `SMTP_PASSWORD` / `SMTP_FROM` / `SMTP_FROM_NAME` / `SMTP_USE_TLS` | for email | SMTP credentials for Paliad's transactional mail (reminders, invitations). Port 465 uses implicit TLS. `MailService` silently no-ops when any required var is missing — the server still boots for knowledge-platform-only deployments. |
|
||||
| `ANTHROPIC_API_KEY` | not used today | Reserved for Phase H (AI Frist-Extraktion) which is deferred per m's 2026-04-16 decision. Do not set. |
|
||||
| `ANTHROPIC_API_KEY` | not used in PoC | Reserved for the eventual production-v1 Paliadin (the Anthropic Messages API path, see `docs/design-paliadin-2026-05-07.md` §2). The Phase 0 PoC (t-paliad-146) does NOT use this — it shells out to a local `claude` CLI via tmux instead, which uses m's existing Claude Code subscription. Set this env var only after the PoC validates and we cut over to the API-backed path. The earlier "Phase H Frist-Extraktion" reservation is dead — that feature is deferred separately (memory `b6a11b55…`). |
|
||||
| `PALIADIN_TMUX_SESSION` | optional (default `paliad-paliadin`) | tmux session name the Paliadin service uses for its long-lived `claude` pane. |
|
||||
| `PALIADIN_RESPONSE_DIR` | optional (default `/tmp/paliadin`) | Directory where Claude writes its per-turn response files. The Go service polls this directory for `{turn_id}.txt` files. |
|
||||
|
||||
> *Note on Paliadin gating (t-paliad-146):* there is **no** `PALIADIN_ENABLED` env var. Access is gated in code via `services.PaliadinOwnerEmail` (currently `matthias.siebels@hoganlovells.com`). Every other authenticated user gets a 404 on `/paliadin` and `/admin/paliadin`. This means the routes register on every paliad deploy (including paliad.de prod), but only m can reach them — and even then, prod only works if the host has `tmux` + a `claude` CLI in PATH (which the Dokploy container does not). PoC remains a laptop-only feature; the gate is in the code, not the deploy.
|
||||
| `FIRM_NAME` | optional (default `HLC`) | Display name of the firm Paliad is being branded for in this deployment. Read once at process start by `internal/branding.Name` (Go) and inlined into client bundles by `frontend/build.ts` (TypeScript). Powers every user-facing surface — landing hero, page titles, login hint, Downloads page, footer, invitation/reminder email bodies. The `ALLOWED_EMAIL_DOMAINS` whitelist is a separate concern (real DNS domains, not display name) and rotates independently. |
|
||||
|
||||
> *Note on `DATABASE_URL`:* "Work without DB" ≠ "ungated". All knowledge-platform routes (Kostenrechner, Glossar, Links, Gebührentabellen, Checklisten, Gerichte, Downloads) are still behind the auth gate (302 to `/login` for anon visitors); only `/`, `/login`, `/logout`, and `/assets/*` are public. The `gateOnboarded` middleware additionally blocks unonboarded users from app pages but does NOT gate the knowledge-platform pages.
|
||||
|
||||
@@ -50,7 +50,7 @@ worker:
|
||||
max_workers: 5
|
||||
persistent: true
|
||||
head:
|
||||
name: "maria"
|
||||
name: "paliadin"
|
||||
max_loops: 50
|
||||
infinity_mode: false
|
||||
max_idle_duration: 2h0m0s
|
||||
|
||||
@@ -157,7 +157,34 @@ 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),
|
||||
UserView: services.NewUserViewService(pool),
|
||||
Broadcast: services.NewBroadcastService(pool, mailSvc, users, teamSvc, emailTemplateSvc),
|
||||
Pin: services.NewPinService(pool, projectSvc),
|
||||
CardLayout: services.NewCardLayoutService(pool),
|
||||
}
|
||||
|
||||
// t-paliad-146 — Paliadin PoC. Always wired when DATABASE_URL
|
||||
// is set; the per-request handler gate (requirePaliadinOwner)
|
||||
// restricts access to the single owner email
|
||||
// (services.PaliadinOwnerEmail). All other authenticated users
|
||||
// get a 404 — the route effectively does not exist for them.
|
||||
// On hosts without tmux + the `claude` CLI (e.g. the Dokploy
|
||||
// container), the owner gate still applies; if m ever hits the
|
||||
// route from such a host, the service returns "tmux unavailable"
|
||||
// without ever invoking shell-out.
|
||||
tmuxSession := os.Getenv("PALIADIN_TMUX_SESSION")
|
||||
responseDir := os.Getenv("PALIADIN_RESPONSE_DIR")
|
||||
svcBundle.Paliadin = services.NewPaliadinService(pool, users, tmuxSession, responseDir)
|
||||
log.Printf("paliadin: wired (owner=%s; gate is per-request, not per-deploy)",
|
||||
services.PaliadinOwnerEmail)
|
||||
// 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.
|
||||
888
docs/design-data-display-model-2026-05-06.md
Normal file
888
docs/design-data-display-model-2026-05-06.md
Normal file
@@ -0,0 +1,888 @@
|
||||
# Design: Data display model — additive Custom Views layer + unified inbox subsume + render-shape switcher
|
||||
|
||||
**Task:** t-paliad-144
|
||||
**Issue:** m/paliad#5
|
||||
**Author:** noether (inventor)
|
||||
**Date:** 2026-05-06
|
||||
**Status:** LOCKED 2026-05-07 — m signed off on all recommendations + §10 follow-ups, with one correction (Q4 narrowed from 4 shapes → 3; "activity" is a filter/source choice, not a render shape — folded into `list` shape with density config). Inventor → coder transition initiated. PR split chosen: A1 backend substrate, A2 frontend Custom Views.
|
||||
**Branch:** `mai/noether/inventor-data-display`
|
||||
**Builds on:** t-paliad-109 (events unification, shipped) + t-paliad-138 (approvals, shipped) + t-paliad-139 (hierarchy aggregation, all 3 phases on `mai/noether/inventor-project` awaiting merge gate)
|
||||
|
||||
---
|
||||
|
||||
## 0. Premise check (read this first)
|
||||
|
||||
The issue body asks for a unified data-display model. Three premises in the brief that I verified against the live tree on this worktree before designing on top of them:
|
||||
|
||||
| Premise | Live state | Verdict |
|
||||
|---|---|---|
|
||||
| `EventService` is already a 2-source union over `paliad.deadlines` + `paliad.appointments` | `internal/services/event_service.go` lines 40–193 — `ListVisibleForUser` runs the deadline path then the appointment path then merges in Go, sorted by `event_date` | **confirmed**; substrate exists in miniature today |
|
||||
| `/agenda` is a separate timeline service, not the same code path | `internal/services/agenda_service.go` lines 78–128 — `AgendaService.List` independently joins deadlines + appointments. Different SQL, different projection (`AgendaItem` vs `EventListItem`), different urgency annotation. | **confirmed**; we have *two* substrates already, both 2-source. Generalising means picking one and retiring the other (or keeping both temporarily). |
|
||||
| `/inbox` is a 4-eye approval surface, not a generic activity feed | `frontend/src/inbox.tsx` (61 lines) + `internal/services/approval_service.go` lines 730–810 — two-tab UI ("Zur Genehmigung", "Meine Anfragen") backed by `ListPendingForApprover` / `ListSubmittedByUser`. | **confirmed**; today's `/inbox` is approval-only, not the unified-inbox concept m's brainstorm describes |
|
||||
| t-paliad-139 Phase 2 schema (migration 055) is incoming but not on main | Migration file exists at `internal/db/migrations/055_hierarchy_aggregation.up.sql`; per noether's prior memory, all 3 phases are stacked on `mai/noether/inventor-project` awaiting merge gate. | **confirmed**; this design must compose on top of 055's `paliad.project_partner_units` + `derive_grants_authority` model without forcing 139 to re-land |
|
||||
| `paliad.project_events` carries audit kinds (`project_created`, `status_changed`, `project_archived`, `project_reparented`, …) | `internal/services/project_service.go` lines 491–805 — five `insertProjectEvent` call-sites today; `event_type` column is free-text. | **confirmed**; `project_events` is the natural fourth data source for "what happened on my projects?" |
|
||||
|
||||
So the premises that anchor the design are sound. One correction to the issue body itself worth flagging:
|
||||
|
||||
> the issue body lists `paliad.deadlines`, `paliad.appointments`, `paliad.project_events`, `paliad.approval_requests` as the four current data tables.
|
||||
|
||||
That is right, but `event_service.go` only unions the **first two**. The Verlauf surface on `/projects/{id}` (project_events) and the inbox surface (approval_requests) are *each* their own bespoke endpoint today. The design below makes all four first-class `data_source` values in the substrate; flagging that the existing `EventService` will need to grow, not stay frozen.
|
||||
|
||||
---
|
||||
|
||||
## 1. m's intent (as I read it)
|
||||
|
||||
> "Custom views with saving them. […] If they could customize their view like 'myVerySpecialAgenda' with criteria and view options (filters, type of view — calendar vs cards vs list) and turn on parts — and then those views would be shown in the sidenavbar under a separate button. And on the page, the user can select all kinds of visuals."
|
||||
|
||||
Plus the locked direction of 2026-05-06 16:42:
|
||||
|
||||
- **Additive.** Fixed defaults stay; Custom Views ship alongside.
|
||||
- **Subsume the unified inbox.** Approval candidates + project activity + new cases + status changes — all viewable through the same substrate, with configurable granularity.
|
||||
- **Sidebar layout:** separate "Meine Sichten" group.
|
||||
- **In-page render-shape switcher.**
|
||||
- **paliad-only scope.**
|
||||
|
||||
Three design pieces fall out of this:
|
||||
|
||||
1. **A substrate** — one read API that returns rows from N data sources, filterable by one shared grammar.
|
||||
2. **A render layer** — a small set of presentation components (List, Cards, Calendar, Activity) that all consume the substrate's row shape.
|
||||
3. **A persistence + sidebar story** — `paliad.user_views` + a "Meine Sichten" group + URL contract `/views/{slug}`.
|
||||
|
||||
§§3–5 cover those three. §6 covers cross-cutting concerns (RLS, performance, migration). §10 lists open questions for m to answer before coder shift.
|
||||
|
||||
---
|
||||
|
||||
## 2. Recommended design (TL;DR)
|
||||
|
||||
| Area | Recommendation | Smallest-diff alternative considered & rejected |
|
||||
|---|---|---|
|
||||
| **Substrate shape** | One `ViewService` (new) that union-loads from 4 data sources: `deadline`, `appointment`, `project_event` (audit), `approval_request`. Returns a discriminated `[]ViewRow` keyed by `kind`. | Single virtual SQL `view_row` table with UNION ALL across all 4 — too many polymorphic columns; harder to evolve per-source filters. |
|
||||
| **Filter grammar** | Structured JSON spec validated server-side (`FilterSpec`). UI builds it via affordance widgets; the JSON is also human-editable for power users. | SQL DSL (security risk + complexity); UI-only (forces every dimension to have a widget). |
|
||||
| **Render shapes for v1** | `list`, `cards`, `calendar` (3). Activity-feed appearance is achieved by source/filter choice (`sources: ["project_event", …]`) rendered through `list` shape with `density: "compact"` + actor/time columns — *not* a separate shape. Defer `kanban`, `connections-graph`, `timeline-distinct-from-cards`. | Ship 4+ shapes including a dedicated "activity" — m's correction (2026-05-07): activity is content selection, not visualisation. Shape ⊥ source. |
|
||||
| **Persistence** | New table `paliad.user_views` (id, user_id, slug, name, filter_spec jsonb, render_spec jsonb, sort_order, icon, last_used_at, …). RLS = caller's own rows only. | Per-user JSON column on `paliad.users` — kills the sidebar count badge query path (`SELECT count(*) WHERE user_id`); also no indexed sort. |
|
||||
| **System defaults — code or DB?** | **Code.** Defaults stay as their own pages (`/dashboard`, `/agenda`, `/events`, `/inbox`); they are *built using the same render components* the custom-view system uses. No `is_system=true` row in `user_views`. | Seed system rows per user — drifts on schema bumps; new users miss bumps; `is_system=true` is a synonym for "config-as-data when config-as-code is cleaner". |
|
||||
| **Sidebar** | New "Meine Sichten" group between "Arbeit" and "Werkzeuge". Each saved view appears as one nav entry (icon + name). One trailing "+ Neue Sicht" entry. | "Meine Sichten" as a single sidebar entry expanding to a panel — extra click cost on every navigation. |
|
||||
| **In-page render-shape switcher** | A 4-button switcher on every view page (system + custom). Same component already exists on `/events` (cards/list/calendar). Generalise + add `activity`. | Per-route hardcoded shape — fights m's intent ("user can select all kinds of visuals"). |
|
||||
| **URL contract** | `/views/{slug}` for custom views (slug is user-scoped). System views keep their existing URLs. Filter overrides via query params, transient (don't mutate stored spec). | UUID URLs (`/views/{uuid}`) — unsharable, unbookmarkable. |
|
||||
| **`/inbox` page** | Stays as a fixed sidebar entry at the same URL. **Internally** refactored to use the new substrate as its read path, but the UI + URL stay. | Refactor /inbox away — needless break for users + email links. The locked direction is "subsume the inbox concept", which I read as substrate sharing, not URL retirement. |
|
||||
| **Approval-candidate visibility** | Approval requests are their own `data_source`; an inbox-shaped view picks `sources: ["approval_request"]`. Pending pills on entity rows are a separate concern (already shipped via `entity.approval_status='pending'`). | Predicate-only — collapses two genuinely-different shapes (the request row vs the entity row). |
|
||||
| **Migration / coexistence** | **Phase A:** ship substrate + render components + Custom Views + `paliad.user_views`. Existing pages untouched. **Phase B (later, separate task):** refactor system pages internally to use the substrate. | Refactor system pages in the same PR — bigger blast radius; harder to roll back. |
|
||||
| **Performance v1** | Run on every load. Cursor pagination (`event_date` + `id` tiebreaker). No materialised views. Add per-source row caps later if telemetry says so. | Materialised view per saved view — refresh complexity, drift risk, doesn't help the first load. |
|
||||
|
||||
The rest of this doc is the detail behind those rows.
|
||||
|
||||
---
|
||||
|
||||
## 3. Section A — Substrate: data sources + filter grammar (Q1–Q3, Q13)
|
||||
|
||||
### Q1 — What's the fundamental row?
|
||||
|
||||
**Recommendation: discriminated `ViewRow` projection over an explicit data-source registry.**
|
||||
|
||||
```go
|
||||
// internal/services/view_service.go (new)
|
||||
|
||||
type DataSource string
|
||||
|
||||
const (
|
||||
SourceDeadline DataSource = "deadline"
|
||||
SourceAppointment DataSource = "appointment"
|
||||
SourceProjectEvent DataSource = "project_event" // audit / Verlauf
|
||||
SourceApprovalRequest DataSource = "approval_request" // 4-eye inbox
|
||||
)
|
||||
|
||||
// ViewRow is the union shape served by the substrate. The shape is
|
||||
// projection-stable: every source fills the common header fields; type-
|
||||
// specific fields hang off `Detail` as a discriminated payload.
|
||||
type ViewRow struct {
|
||||
Kind DataSource `json:"kind"` // discriminator
|
||||
ID uuid.UUID `json:"id"` // source-row id
|
||||
Title string `json:"title"` // display title
|
||||
Subtitle *string `json:"subtitle,omitempty"` // short context line
|
||||
EventDate time.Time `json:"event_date"` // canonical sort key
|
||||
|
||||
// Project context — every row in paliad has a project (approval_requests
|
||||
// and project_events are project-attached by definition; deadlines and
|
||||
// appointments may be personal but inherit project context when set).
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
ProjectTitle *string `json:"project_title,omitempty"`
|
||||
ProjectReference *string `json:"project_reference,omitempty"`
|
||||
ProjectType *string `json:"project_type,omitempty"`
|
||||
|
||||
// Actor — who created this row (deadline/appointment) or who acted
|
||||
// on it (project_event author, approval_request requester).
|
||||
ActorID *uuid.UUID `json:"actor_id,omitempty"`
|
||||
ActorName *string `json:"actor_name,omitempty"`
|
||||
|
||||
// Detail carries the source-specific payload the render layer reads
|
||||
// when it needs more than the header (e.g. cards render the deadline
|
||||
// status pill, the calendar renders the appointment time range, the
|
||||
// activity feed renders the audit description).
|
||||
Detail json.RawMessage `json:"detail"` // shape determined by `kind`
|
||||
}
|
||||
```
|
||||
|
||||
`Detail` is a per-source typed Go struct (`DeadlineDetail`, `AppointmentDetail`, `ProjectEventDetail`, `ApprovalRequestDetail`) marshalled via `json.RawMessage` so the row stays a single struct on the wire. The frontend type-narrows on `kind`.
|
||||
|
||||
Why a registry over a single virtual SQL view:
|
||||
|
||||
- The four source tables have **truly disjoint columns** — deadline has `due_date` and `rule_code`, appointment has `start_at`/`end_at`/`location`, project_event has `event_type` (free text) + `metadata jsonb`, approval_request has `lifecycle_event` + `requested_at`. A `UNION ALL` materialised view ends up with ~40 nullable columns, half of them per row.
|
||||
- Per-source filtering is fundamentally different — deadline filters look at `status`, appointment filters look at `appointment_type`, project_event filters look at `event_type`, approval_request filters look at `lifecycle_event` + `status`. Translating those into one CHECK-style filter grammar is harder than running per-source SQL paths and merging.
|
||||
- The substrate already exists in miniature today — `event_service.go` line 114 union-loads two sources and merges in Go. Generalising to four sources is the same shape, more code, no new architectural concept.
|
||||
|
||||
### Q2 — Filter grammar shape
|
||||
|
||||
**Recommendation: structured JSON spec, validated server-side, exposed to the UI as predicates.**
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"sources": ["deadline", "appointment", "project_event", "approval_request"],
|
||||
|
||||
"scope": {
|
||||
"projects": "all_visible",
|
||||
"personal_only": false
|
||||
},
|
||||
|
||||
"time": {
|
||||
"horizon": "next_30d",
|
||||
"field": "auto"
|
||||
},
|
||||
|
||||
"predicates": {
|
||||
"deadline": {
|
||||
"status": ["pending"],
|
||||
"approval_status": ["approved", "pending", "legacy"],
|
||||
"event_types": [],
|
||||
"include_untyped": true
|
||||
},
|
||||
"appointment": {
|
||||
"approval_status": ["approved", "pending", "legacy"],
|
||||
"appointment_types": []
|
||||
},
|
||||
"project_event": {
|
||||
"event_types": [
|
||||
"project_created", "status_changed", "project_archived",
|
||||
"deadline_created", "appointment_created", "approval_decided"
|
||||
]
|
||||
},
|
||||
"approval_request": {
|
||||
"viewer_role": "approver_eligible",
|
||||
"status": ["pending"],
|
||||
"entity_types": ["deadline", "appointment"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The shape:
|
||||
|
||||
- **`sources`** — one or more `DataSource` values. Drives which per-source SQL paths run.
|
||||
- **`scope.projects`** — `"all_visible"` (default — RLS-bounded) | `"my_subtree"` (semantic: caller's direct/derived staffing tree) | `[<uuid>...]` (explicit list, RLS still applies).
|
||||
- **`scope.personal_only`** — narrows deadline + appointment to caller-created rows; ignored for project_event + approval_request (where actor scoping is already implicit).
|
||||
- **`time.horizon`** — `"any"` | `"next_7d"` | `"next_30d"` | `"next_90d"` | `"past_30d"` | `"past_90d"` | `"all"` | `{from, to}` literal range. `"auto"` for the date field means each source picks: deadline → `due_date`, appointment → `start_at`, project_event → `created_at`, approval_request → `requested_at` (or `decided_at` if status is decided).
|
||||
- **`predicates.<source>`** — per-source narrowing (status, types, eligibility). Empty / missing = no narrowing.
|
||||
|
||||
Validation lives in Go: a `ValidateFilterSpec(spec)` function rejects unknown fields, unknown enum values, conflicting combos (`personal_only=true` + explicit `projects` list → error). The UI never sends raw user-typed JSON; it composes the spec from widget state. A "Show JSON" reveal is available in the editor for power users — but the same validator runs on POST.
|
||||
|
||||
Three options considered:
|
||||
|
||||
| Option | Power | Risk | Verdict |
|
||||
|---|---|---|---|
|
||||
| **JSON predicate spec (recommended)** | High — every dimension addressable | Schema drift → validator bug | ✅ |
|
||||
| SQL-fragment DSL (`WHERE status='pending' AND …`) | Highest | Injection, RLS-bypass risk; needs a parser | ✗ |
|
||||
| UI-only, no spec language | Lowest | Every new dimension = UI work + DB migration | ✗ |
|
||||
|
||||
### Q3 — Granularity dimensions
|
||||
|
||||
m's brainstorm called out: my projects / specific projects / newly added cases / newly added events / changes to events / approved-vs-unapproved / time horizon / event type / role-perspective.
|
||||
|
||||
The full dimension set, mapped to the spec:
|
||||
|
||||
| Dimension | Where it lives in `FilterSpec` | UI affordance | Notes |
|
||||
|---|---|---|---|
|
||||
| My projects | `scope.projects = "my_subtree"` | toggle | semantic, resolved at query time via t-139 derivation predicate |
|
||||
| Specific projects | `scope.projects = [...]` | multi-select | RLS still applies; rows from inaccessible projects are silently filtered (Q17) |
|
||||
| Personal-only | `scope.personal_only = true` | toggle | mutually exclusive with `projects` (server enforces) |
|
||||
| Newly added cases | `sources: ["project_event"]` + `predicates.project_event.event_types: ["project_created"]` + `time.horizon` | source toggle + event-type chip group | same shape captures status_changed, project_archived |
|
||||
| Newly added events | `sources: ["deadline","appointment"]` + `time.horizon` + `time.field = "created_at"` | source toggles + time-field selector | the `created_at` rather than `due_date`/`start_at` view |
|
||||
| Changes to events | `sources: ["project_event"]` + `predicates.project_event.event_types: ["deadline_*","appointment_*"]` | event-type chips | project_events already audit deadline + appointment lifecycle (verified via existing emit sites) |
|
||||
| Approval status of entities | `predicates.deadline.approval_status` + `predicates.appointment.approval_status` | tri-state chip | reflects the entity-side `approval_status` column |
|
||||
| Approval lifecycle (the requests themselves) | `sources: ["approval_request"]` + `predicates.approval_request.status` + `predicates.approval_request.viewer_role` | source toggle + role chip | Q13 — the inbox shape |
|
||||
| Time horizon | `time.horizon` + optional `{from, to}` | range chips + date pickers | shared across all sources |
|
||||
| Event type (deadline) | `predicates.deadline.event_types` | multi-select | reuses existing `paliad.event_types` registry |
|
||||
| Appointment type | `predicates.appointment.appointment_types` | multi-select | hearing/meeting/consultation/deadline_hearing |
|
||||
| Project event kind | `predicates.project_event.event_types` | multi-select | free-text today; we'll need a curated list (§10 Q19) |
|
||||
| Role-perspective | implicit — every query is "from caller's viewpoint" | n/a | not a filter; visibility predicate is the user identity |
|
||||
|
||||
Hidden defaults vs UI affordances:
|
||||
|
||||
- **Hidden** — `version`, `time.field` (`"auto"` is the default), per-source `include_untyped`, validator branches.
|
||||
- **First-class UI** — sources, scope, time horizon, status, event_type/appointment_type/project_event_kind, approval status.
|
||||
- **Power-only** (revealed in JSON editor) — explicit `{from, to}` ranges beyond the chip set, `time.field` override.
|
||||
|
||||
### Q13 — Approval candidates: predicate or source?
|
||||
|
||||
**Recommendation: source (`approval_request`).**
|
||||
|
||||
Reasoning: the approval_requests table has fundamentally different columns (`lifecycle_event`, `pre_image`, `payload`, `requested_by`, `decision_kind`, `decided_at`) than deadline/appointment, and the inbox UI renders different things (requester avatar, "Approve / Reject" buttons, decision history). Forcing this into a predicate on deadline/appointment rows means either:
|
||||
|
||||
- (a) hiding the request rows entirely — but then "show me pending approvals" is impossible to express, or
|
||||
- (b) hydrating every deadline row with its pending-request payload — bloats the row shape, kills the "approval_status pill" abstraction.
|
||||
|
||||
By making it a source:
|
||||
|
||||
- `sources: ["approval_request"]` is the *inbox shape* — list of pending requests, decided requests, etc.
|
||||
- `predicates.deadline.approval_status: ["pending"]` is the *entity shape* — list of deadlines that have a pending request (good for "show me my deadlines that are blocked on someone else's approval").
|
||||
|
||||
These are genuinely two views; the substrate exposes both.
|
||||
|
||||
---
|
||||
|
||||
## 4. Section B — Render shapes + view authoring UX (Q4–Q6, Q11–Q12, Q16)
|
||||
|
||||
### Q4 — Which render shapes are first-class for v1?
|
||||
|
||||
**Recommendation: `list`, `cards`, `calendar` — three shapes.**
|
||||
|
||||
m's correction (2026-05-07): activity is a content selection (sources + filters), not a render shape. The "compact one-line stream with type icons" appearance is `list` shape with `density: "compact"` + an actor/time column set — same component, different config. Shape is orthogonal to source: any source can render in any shape.
|
||||
|
||||
| Shape | Status today | What it does | Source bias |
|
||||
|---|---|---|---|
|
||||
| **`list`** | shipped on `/events` (table), `/inbox` (`<ul class="inbox-list">`), `/dashboard` activity feed | One row per result; columns vary per source. Table for desktop, stacked card-rows on mobile. Density modes: `comfortable` (default, full table) / `compact` (one-line stream — the activity-feed look). | source-agnostic |
|
||||
| **`cards`** | shipped on `/agenda` (day-grouped timeline) | Day-grouped chronological cards; primary date drives grouping. The unified-inbox-feel m described — *when fed activity-style content*. | source-agnostic |
|
||||
| **`calendar`** | shipped on `/events?view=calendar` | Month grid (toggleable to week). Shows up to N pills per day. Click → popup with the day's rows. | works best for time-bound sources (deadline, appointment, project_event) |
|
||||
|
||||
How "activity feed" is expressed in this model:
|
||||
- **Filter side**: `sources: ["project_event", "approval_request"]`, `time.horizon: past_30d`, `time.field: created_at`.
|
||||
- **Render side**: `shape: "list"`, `list.density: "compact"`, `list.columns: ["time", "actor", "title", "project"]`.
|
||||
|
||||
That same `list` shape — with `density: "comfortable"` + the deadline column set — also powers `/events`. One component, two configs. Same logic for `cards`: the day-grouped Verlauf on `/projects/{id}` and a "newest cases this week" card view share the component.
|
||||
|
||||
Defer to v2: `kanban` (no obvious column axis across mixed sources), `connections-graph` (the events↔files visualisation referenced in the issue body — that's specifically about graph rendering, which is a 5x bigger component and works better as its own page than as a saved-view shape), `timeline-distinct-from-cards` (a horizontal Gantt would be the natural shape but adds a lot for marginal value at v1).
|
||||
|
||||
Why these three and not all six: each shape is a real frontend component with empty states, error states, layout, density toggles, mobile behaviour. We have three already shipped today, generalising them costs little. Adding `kanban` + `graph` is each its own component-week. Better to ship 3 polished than 6 half-baked.
|
||||
|
||||
### Q5 — Per-shape config
|
||||
|
||||
**Recommendation: shape config lives alongside filter spec in `render_spec`, keyed by shape.**
|
||||
|
||||
```json
|
||||
{
|
||||
"shape": "list",
|
||||
"list": { "columns": ["date", "title", "project", "status"], "sort": "date_asc", "density": "comfortable" },
|
||||
"cards": { "group_by": "day", "sort": "date_asc", "show_empty_days": false },
|
||||
"calendar": { "default_view": "month", "show_weekends": true }
|
||||
}
|
||||
```
|
||||
|
||||
The user picks one `shape`; the matching config block is read at render time. Other shape configs are kept (so flipping back to a previously-used shape preserves its tweaks).
|
||||
|
||||
UI: the shape switcher is a **3-button row** at the top of every view page. Right of it, a small "Shape settings" gear opens a modal with the per-shape knobs. Most users never touch the gear.
|
||||
|
||||
Default values per shape:
|
||||
|
||||
- `list.columns` = source-determined (deadline view = date/title/rule/status; appointment view = date/title/location/type; activity-feel view = time/actor/title — auto-selected when sources are activity-flavoured)
|
||||
- `list.density` = `"comfortable"` for entity sources, `"compact"` when sources include project_event or approval_request
|
||||
- `list.sort` = `"date_asc"` for forward-looking views, `"date_desc"` for retrospective
|
||||
- `cards.group_by` = `"day"`
|
||||
- `calendar.default_view` = `"month"`
|
||||
|
||||
### Q6 — Empty state per view
|
||||
|
||||
**Recommendation: filter-aware empty states. Render component receives the resolved `FilterSpec` and produces a guidance line.**
|
||||
|
||||
Generic shape:
|
||||
|
||||
> **Keine Einträge gefunden.**
|
||||
> Sicht: *{view name}* — {N} Filter aktiv (*Zeitraum: nächste 7 Tage, Status: offen*).
|
||||
> Vorschläge: [Zeitraum erweitern] [Filter zurücksetzen]
|
||||
|
||||
The component derives the human-readable filter summary from the spec. For specific known patterns:
|
||||
|
||||
- All-empty across sources + horizon `next_7d` → "Nichts in den nächsten 7 Tagen — versuchen Sie 30 Tage."
|
||||
- Sources picked but all 0 in 90d → "Keine Daten für diese Quellen — Sicht eventuell zu eng."
|
||||
- Project filter set but project has no team → already handled at API layer (Q17).
|
||||
|
||||
Empty-state strings live in i18n; the view name + filter summary are interpolated at render time.
|
||||
|
||||
### Q11 — Where do you create a view?
|
||||
|
||||
**Recommendation: both, with the inline path as primary.**
|
||||
|
||||
Two creation paths:
|
||||
|
||||
1. **Inline "save current filters as a Sicht"** (primary) — on any view page (system or existing custom), once the user has tweaked the filter spec away from the saved baseline, a "Speichern als Sicht" button appears in the toolbar. Click → modal asks for name + icon + sidebar position + render shape (defaults to current). Save → POST `/api/user-views` → sidebar refreshes → user is now on the new view. The same modal on an existing custom view shows a "Save changes / Save as new" pair.
|
||||
|
||||
2. **Full editor at `/views/new`** (secondary) — for the power case where the user wants to build a Sicht from a blank slate. Same modal fields, plus a JSON view of the filter spec for power users. Edit existing at `/views/{slug}/edit`.
|
||||
|
||||
Why both:
|
||||
|
||||
- The inline path covers the 90% case ("I tweaked the inbox to show only my projects, save it") with one click.
|
||||
- The full editor covers the 10% case where the user knows what they want but isn't currently looking at the right starting point ("I want a view of all approval-decided rows in the last 90 days").
|
||||
|
||||
Critically, **the inline path teaches the full editor** — both render the same form component.
|
||||
|
||||
### Q12 — Default-first onboarding
|
||||
|
||||
**Recommendation: empty + tutorial card on the first visit. No seeded examples.**
|
||||
|
||||
When a user with zero saved views clicks "Meine Sichten" or visits `/views`, they see:
|
||||
|
||||
> **Eigene Sichten — was ist das?**
|
||||
> Eine Sicht ist eine gespeicherte Filterkombination — z.B. "Fristen meiner Projekte in den nächsten 14 Tagen". Sichten erscheinen als eigene Buttons in der Sidebar.
|
||||
> [Beispiel-Sicht erstellen ▶] [Aus aktueller Seite speichern ▶]
|
||||
|
||||
The first button drops the user into the editor pre-populated with a sensible starter (e.g. "Activity feed for my subtree, last 30 days"). The second is contextual — only appears if the user has been on a system page recently (tracked client-side).
|
||||
|
||||
Why no seeded rows: seeded examples become orphan-confusion later ("did I make this Freitag-Stand thing? when?"). A dismissible tutorial card is cheaper to maintain and clearer about ownership.
|
||||
|
||||
### Q16 — URL contract
|
||||
|
||||
**Recommendation: `/views/{slug}` for custom views, slug user-scoped. System views keep their existing URLs.**
|
||||
|
||||
- **`/views/{slug}`** — slug is unique per `(user_id, slug)`. Slug is friendly: `freitag-stand`, `approvals-pending-mine`, `siemens-aktivitaet`. No UUIDs in URLs.
|
||||
- **`/views/new`** — creation editor.
|
||||
- **`/views/{slug}/edit`** — edit existing.
|
||||
|
||||
Filter overrides via query params:
|
||||
|
||||
- `/views/freitag-stand?from=2026-05-10&to=2026-05-17` — overrides the saved time horizon for this load only. Doesn't mutate the stored spec.
|
||||
- `/views/freitag-stand?shape=calendar` — overrides the saved render shape for this load only.
|
||||
|
||||
Override params follow the same validator as the stored spec; unknown params are ignored.
|
||||
|
||||
System views — `/dashboard`, `/agenda`, `/events`, `/inbox` — keep their URLs. They never become `/views/dashboard` (a slug collision the validator must reject — slug `dashboard` is reserved).
|
||||
|
||||
---
|
||||
|
||||
## 5. Section C — Persistence + sidebar + system-vs-user-view shape (Q7–Q10, Q14, Q15, Q17, Q18)
|
||||
|
||||
### Q7 — Schema for `paliad.user_views`
|
||||
|
||||
**Recommendation:**
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.user_views (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
|
||||
-- Stable user-facing identifier. Goes into the URL. Validated:
|
||||
-- ^[a-z0-9][a-z0-9-]{0,62}$ with reserved-list rejection (dashboard,
|
||||
-- agenda, events, inbox, new, edit, …).
|
||||
slug text NOT NULL,
|
||||
|
||||
-- Display name. Free-form; no enforced i18n (the user picks the language
|
||||
-- they think in). Sidebar renders it verbatim; no fallback or translation.
|
||||
name text NOT NULL,
|
||||
|
||||
-- One of a fixed set of icon keys (see frontend/src/components/Sidebar.tsx
|
||||
-- icon registry). NULL → default icon (folder).
|
||||
icon text,
|
||||
|
||||
-- Filter spec (§3 Q2). Validated on write.
|
||||
filter_spec jsonb NOT NULL,
|
||||
|
||||
-- Render spec (§4 Q5). Validated on write.
|
||||
render_spec jsonb NOT NULL,
|
||||
|
||||
-- Sidebar ordering. Lower-first. Server defaults to MAX+1 on insert so
|
||||
-- new views land at the bottom; the editor lets the user drag-reorder.
|
||||
sort_order int NOT NULL DEFAULT 0,
|
||||
|
||||
-- Show a row-count badge on the sidebar entry (like /inbox today).
|
||||
-- Costs one COUNT(*) per saved view per badge refresh; opt-in.
|
||||
show_count boolean NOT NULL DEFAULT false,
|
||||
|
||||
-- "Most-recently-used" landing (Q10). PATCH on every view-load (cheap).
|
||||
last_used_at timestamptz,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
UNIQUE (user_id, slug)
|
||||
);
|
||||
|
||||
CREATE INDEX user_views_owner_idx
|
||||
ON paliad.user_views (user_id, sort_order);
|
||||
|
||||
ALTER TABLE paliad.user_views ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY user_views_owner_all
|
||||
ON paliad.user_views FOR ALL
|
||||
USING (user_id = auth.uid())
|
||||
WITH CHECK (user_id = auth.uid());
|
||||
|
||||
-- updated_at autoset trigger reusing existing paliad.set_updated_at().
|
||||
CREATE TRIGGER user_views_updated_at
|
||||
BEFORE UPDATE ON paliad.user_views
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.set_updated_at();
|
||||
```
|
||||
|
||||
Notes on the shape:
|
||||
|
||||
- **No `is_system` flag** — system views are code-resident (Q8), not seeded rows. Keeps the table strictly user-owned.
|
||||
- **`filter_spec`/`render_spec` as `jsonb`** — Postgres validates only structural well-formedness; the application layer (`ValidateFilterSpec` + `ValidateRenderSpec`) enforces semantic constraints at write time. Storing the parsed shapes as columns would force a schema migration per new dimension.
|
||||
- **No cross-user sharing column** — explicit `OUT OF SCOPE` per the issue body. If sharing lands later, add a separate `user_view_shares (view_id, target_user_id, can_edit)` table.
|
||||
- **Slug uniqueness scoped to user** — two users can both have a view called `freitag-stand`; URL is `/views/freitag-stand` and resolves against `auth.uid()`.
|
||||
|
||||
Migration shape: new file `056_user_views.up.sql`. Standalone — no dependencies on 055's schema beyond `paliad.users` (which is in 002). 056 can land before 055 lands on main if needed.
|
||||
|
||||
### Q8 — System views: code or DB?
|
||||
|
||||
**Recommendation: code-resident.** Defaults stay as their own pages; their handlers continue to render their existing TSX shells; their data path is the substrate.
|
||||
|
||||
```go
|
||||
// internal/services/system_views.go (new)
|
||||
|
||||
// SystemView is a code-resident view definition. Used by the substrate
|
||||
// when a system page (dashboard, agenda, events, inbox) needs to resolve
|
||||
// its data through the unified pipeline.
|
||||
type SystemView struct {
|
||||
Slug string // "dashboard" | "agenda" | "events" | "inbox" — matches URL
|
||||
Filter FilterSpec // canonical spec the page resolves to today
|
||||
Render RenderSpec // canonical render shape
|
||||
Reserved bool // if true, slug is unavailable for user views (true for all 4)
|
||||
}
|
||||
|
||||
func DashboardSystemView() SystemView { /* …multi-section, special-cased… */ }
|
||||
func AgendaSystemView() SystemView { /* sources: deadline+appointment, shape: cards, horizon: 30d */ }
|
||||
func EventsSystemView() SystemView { /* sources: deadline+appointment, shape: list, configurable */ }
|
||||
func InboxSystemView() SystemView { /* sources: approval_request, viewer_role: approver_eligible, shape: list */ }
|
||||
```
|
||||
|
||||
Tradeoff (config-as-code vs config-as-data):
|
||||
|
||||
| Axis | Code (recommended) | DB seed |
|
||||
|---|---|---|
|
||||
| Ships with releases | ✅ atomic with code | ✗ requires per-user backfill |
|
||||
| New users get latest | ✅ always | ✗ depends on seed timing |
|
||||
| User-editable | ✗ — system views deliberately frozen | ✅ — but then "system" is meaningless |
|
||||
| Drift risk | none | high (schema bump → seeded rows go stale) |
|
||||
| Validator complexity | one path | two paths (code path + seed path) |
|
||||
|
||||
The locked direction is "additive — fixed defaults stay alongside Custom Views". I read that as: defaults are *not* user-editable; the user can build a custom view that mimics a default if they want a tweaked version. Config-as-code matches that intent exactly.
|
||||
|
||||
Dashboard is the awkward one — it's not a single saved view, it's a multi-section page (5-bucket summary + matter card + 2-column lists + activity feed). The recommendation is: keep `/dashboard` as a bespoke page composed of *several* internal queries, each of which can resolve to a `SystemView` later. Don't try to express the dashboard as one SystemView; that's the wrong abstraction.
|
||||
|
||||
### Q9 — Sidebar layout
|
||||
|
||||
**Recommendation:** new "Meine Sichten" group between "Arbeit" and "Werkzeuge".
|
||||
|
||||
```
|
||||
Übersicht:
|
||||
Dashboard
|
||||
Agenda
|
||||
Inbox [3]
|
||||
Team
|
||||
|
||||
Arbeit:
|
||||
Projekte
|
||||
Fristen
|
||||
Termine
|
||||
|
||||
Meine Sichten: ← new group
|
||||
Freitag-Stand [12]
|
||||
Approval-Pending-Mine
|
||||
Siemens-Aktivität
|
||||
+ Neue Sicht ← always-last entry
|
||||
|
||||
Werkzeuge: …
|
||||
Wissen: …
|
||||
Ressourcen: …
|
||||
Einstellungen: …
|
||||
Admin: …
|
||||
```
|
||||
|
||||
Layout decisions:
|
||||
|
||||
- **Position**: between Arbeit and Werkzeuge — close to the work flow, before the tools/knowledge sections. m's brainstorm placed it as "a separate button" but didn't pin top vs bottom; this position keeps it in the work-context band.
|
||||
- **Group label**: "Meine Sichten" / "My Views" — i18n key `nav.group.user_views`.
|
||||
- **Empty group**: if the user has zero saved views, the group still renders, with only the "+ Neue Sicht" entry inside. That makes the feature discoverable; the alternative (hide empty group) buries it.
|
||||
- **Per-entry icon**: from a fixed registry of ~20 icons (folder, calendar, clock, bell, files, users, …) reused from the existing sidebar SVG set. Default = folder.
|
||||
- **Per-entry badge**: shown when `show_count=true` on the saved view. Server returns the count via `/api/user-views?include_count=true`; the same client refresh interval as `/api/inbox/count` (~60s). Badge is the count of currently-matching rows — same shape as the inbox bell today.
|
||||
- **Drag-reorder**: the editor lets users drag entries; click-to-edit on hover.
|
||||
- **Mobile**: the bottom-nav shows fixed entries only (Übersicht items) — saved views are accessible via the burger drawer. Otherwise the bottom-nav fills up the moment a power user has 5 saved views.
|
||||
|
||||
### Q10 — Default landing
|
||||
|
||||
**Recommendation: most-recently-used.**
|
||||
|
||||
When the user clicks "Meine Sichten" (the group label, not a specific entry), they navigate to `/views`, which resolves to:
|
||||
|
||||
- If `last_used_at` is set on any view → 302 to that view's URL.
|
||||
- If no view has `last_used_at` → render the onboarding card (Q12).
|
||||
|
||||
`last_used_at` is updated on every view-load via a fire-and-forget PATCH `/api/user-views/{id}/touch`. Cheap; no UI latency.
|
||||
|
||||
Alternative (always-default to first by sort_order) was considered — feels less helpful (the user sorted by what they want to see *most easily*, but might not be visiting *most often*). Most-recently-used reflects actual workflow.
|
||||
|
||||
### Q14 — `/inbox` page
|
||||
|
||||
**Recommendation: stays as a fixed sidebar entry. Internally refactored to use the substrate.**
|
||||
|
||||
Three paths considered:
|
||||
|
||||
| Path | Pros | Cons |
|
||||
|---|---|---|
|
||||
| Keep `/inbox` as today, no internal change | zero migration risk | duplicate read path; "subsume" goal not met |
|
||||
| **Refactor `/inbox` to use the substrate (recommended)** | one read path; future enhancements lift everyone | small migration effort |
|
||||
| Retire `/inbox`, ship as a Custom View | cleanest concept | breaks every email link; users with the URL bookmarked get 404 |
|
||||
|
||||
The recommendation refactors `/inbox` internally but keeps the URL + sidebar entry. Concretely:
|
||||
|
||||
- The two-tab UI ("Zur Genehmigung" / "Meine Anfragen") on `/inbox` becomes two `SystemView` definitions:
|
||||
- `InboxApproverView`: `sources: ["approval_request"]`, `predicates.approval_request: {viewer_role: "approver_eligible", status: ["pending"]}`, `render.shape: "list"`.
|
||||
- `InboxRequesterView`: `sources: ["approval_request"]`, `predicates.approval_request: {viewer_role: "self_requested"}`, `render.shape: "list"`.
|
||||
- The `/inbox` handler resolves to one of these depending on the active tab; the data path goes through `ViewService.Run(ctx, userID, spec)`.
|
||||
- The frontend keeps the existing two-tab UI; the per-row card markup also stays (the substrate's `list` shape with `kind="approval_request"` knows how to render approval rows including approve/reject buttons).
|
||||
- The `nav.inbox` sidebar entry stays; the bell badge keeps reading from `ApprovalService.PendingCountForUser`.
|
||||
|
||||
This satisfies the "subsume the unified-inbox concept" goal: any user can build a Custom View that picks `approval_request` as one source plus `project_event` as another, and gets the unified-inbox feel m's brainstorm described — without losing the dedicated `/inbox` shortcut.
|
||||
|
||||
### Q15 — Existing fixed pages: reroute or stay independent?
|
||||
|
||||
**Recommendation: phased.** Phase A (this design's implementation) leaves system pages independent; Phase B (separate later task) refactors them to use the substrate.
|
||||
|
||||
| Phase | Scope | Risk | Locked direction fit |
|
||||
|---|---|---|---|
|
||||
| **A — substrate + Custom Views ship; defaults untouched** | new code: ViewService, FilterSpec, RenderSpec, view_service handlers, /views/* pages, paliad.user_views | low — additive | exactly matches m's "additive" framing |
|
||||
| **B — refactor /agenda, /events, /dashboard, /inbox internals to use ViewService** | rip out parallel read paths; defaults become SystemView-resolved | medium — touches every default page | optional; ship when A is stable |
|
||||
|
||||
Why phase A is enough on its own to ship value: the user gets Custom Views, the unified-inbox-shape becomes available, every system page keeps working untouched. Phase B is a clean-up — eliminating duplicate read paths — and can wait until A's substrate is exercised.
|
||||
|
||||
If we tried to do A+B in one shot, the PR would be:
|
||||
|
||||
- 1× new substrate (~1500 LoC across services + handlers + frontend)
|
||||
- 4× system page refactors (~800 LoC each = ~3200 LoC)
|
||||
- = ~4700 LoC, 4 surfaces moving simultaneously
|
||||
|
||||
That's a 2-week change and a much higher rollback-cost. Phasing means A is shippable in ~1500 LoC and B can be tackled per-page later.
|
||||
|
||||
### Q17 — Auth + RLS + lost project access
|
||||
|
||||
**Recommendation: fail open with attribution.**
|
||||
|
||||
Behaviour:
|
||||
|
||||
- A saved view's `filter_spec.scope.projects` may include UUIDs the user no longer has team access to.
|
||||
- The substrate query JOINs through `paliad.projects p` with the visibility predicate (`paliad.can_see_project(p.id)` per t-139). RLS naturally hides rows from inaccessible projects.
|
||||
- The view loads. The user sees the rows they *can* see; the inaccessible ones are absent.
|
||||
- A one-time toast surfaces: "1 Projekt in dieser Sicht ist nicht mehr sichtbar" (count derived server-side: requested-IDs minus visible-IDs).
|
||||
- The toast offers a "Sicht bearbeiten" link → opens the editor with the inaccessible IDs prefilled in a "Entfernen?" section.
|
||||
|
||||
Alternatives considered:
|
||||
|
||||
| Alternative | Why rejected |
|
||||
|---|---|
|
||||
| Fail closed (whole view 403) | Too aggressive — a 50-project view shouldn't black out because 1 was archived. |
|
||||
| Silently drop with no surface | Confuses the user; "why is my view empty today?" |
|
||||
| Auto-prune on first load | Mutates stored data without consent. |
|
||||
|
||||
Failing open + attributing matches the "transparent honesty" principle from t-139 (derived membership annotated, not silent).
|
||||
|
||||
### Q18 — Materialisation & performance
|
||||
|
||||
**Recommendation: no materialisation v1. Cursor pagination + per-source row caps.**
|
||||
|
||||
Performance shape:
|
||||
|
||||
- **Substrate runs on every load.** Each source contributes one SQL path; merge happens in Go (small per-page result set). No precomputation.
|
||||
- **Pagination** is cursor-based: `(event_date DESC, id DESC)` for retrospective views, `(event_date ASC, id ASC)` for forward-looking. Cursor = base64-encoded `{date, id}`. Default page size 100; cap 200.
|
||||
- **Time horizon is mandatory.** Default is `next_30d` for forward-looking views, `past_30d` for retrospective. The validator rejects `time.horizon = "all"` *unless* `scope.projects` is set to a non-empty explicit list (capping the row pool).
|
||||
- **Per-source LIMIT** inside each SQL path (default 500; configurable per-source). Caps the worst case where one source dominates the union.
|
||||
|
||||
What this looks like for the worst case the issue body raised — "all events from all my projects in the next 90 days, sorted by due_date":
|
||||
|
||||
- 50 projects × thousands of rows each = ~150k rows, theoretical. In practice, paliad data today has dozens-to-low-hundreds per project; even at 50 projects, the *date-bounded* result is in the hundreds-low-thousands range.
|
||||
- Each per-source query has the visibility predicate (RLS is via `EXISTS` against `project_teams` + path-walk) — t-124 confirmed this scales with depth, not row count.
|
||||
- Even at 5k merged rows, in-memory sort + 100-row paginated slice is a few ms.
|
||||
|
||||
We add materialisation only if telemetry says we need to. Concretely: a request-duration histogram on `/api/views/{slug}/run` with p99 alarm at 1s. If p99 climbs past 500ms, we add per-source materialised rollups (e.g. `mv_user_view_counts_daily`) and short-circuit summary cards through them.
|
||||
|
||||
The substrate's `count` endpoint (used by the sidebar badge for `show_count=true` views) is a lighter shape — it returns one integer per source. That can hit a lighter path (no JOINs to projects beyond the RLS predicate). If a user has 10 saved views with `show_count=true` × 60s refresh = 10 COUNT(*) queries per minute per logged-in user. That's the first scale wall and is the candidate for caching in Phase B.
|
||||
|
||||
---
|
||||
|
||||
## 6. Section D — Cross-cutting concerns
|
||||
|
||||
### 6.1 Coexistence with t-139 (hierarchy aggregation, in flight)
|
||||
|
||||
t-139 adds `paliad.project_partner_units` + `derive_grants_authority` + an extended `can_see_project()` predicate. The substrate uses `can_see_project()` (or equivalent positional helpers like `visibilityPredicate("p")` already does) — so derived membership transparently widens what shows up in saved views, just like it widens what shows up on `/agenda` today.
|
||||
|
||||
**No coordination commit required.** If t-139 lands first, this design's substrate inherits derivation for free. If this design lands first (unlikely given the merge order), the substrate works against the pre-139 visibility predicate; t-139's later landing widens results without code change here.
|
||||
|
||||
The `scope.projects = "my_subtree"` semantic resolves through `DerivationService.EffectiveProjectRole` (added by t-139 Phase 2). Until t-139 lands, "my_subtree" falls back to "direct + descendant" (via `projectDescendantPredicate` from t-124). The frontend chip label stays the same; only the resolved set widens.
|
||||
|
||||
### 6.2 Coexistence with t-138 (approvals, shipped)
|
||||
|
||||
t-138 added `paliad.approval_requests` + `entity.approval_status` + the inbox SQL. The substrate uses `approval_requests` as `data_source = "approval_request"` directly — same RLS, same JOIN against `paliad.users` for requester/decider names. The substrate's approval-side filter `predicates.approval_request.viewer_role = "approver_eligible"` resolves via `ApprovalService.ListPendingForApprover` (its existing SQL).
|
||||
|
||||
The entity-side pill (`approval_status='pending'`) on deadline/appointment rows in the substrate is unchanged — `EventListItem.ApprovalStatus` is already populated in `event_service.go`.
|
||||
|
||||
### 6.3 Existing `EventService` — extend or replace?
|
||||
|
||||
**Recommendation: extend.** Rename `EventService` → `ViewService` (or keep `EventService` as the type and add a `ListVisibleAsViewRows` method that returns `[]ViewRow` instead of `[]EventListItem`). The existing `ListVisibleForUser([]EventListItem, …)` callers (`/api/events`, `/api/events/summary`) keep working unchanged.
|
||||
|
||||
Two-source → four-source generalisation:
|
||||
|
||||
- Add `loadProjectEventRows(ctx, userID, spec)` → similar to `loadAppointments` shape, queries `paliad.project_events` JOIN `paliad.projects` with visibility predicate.
|
||||
- Add `loadApprovalRequestRows(ctx, userID, spec)` → wraps `ApprovalService.ListPendingForApprover` / `ListSubmittedByUser` and projects to `ViewRow`.
|
||||
- The merge step in `ListVisibleForUser` becomes "merge N source results sorted by event_date".
|
||||
|
||||
`AgendaService` is the second substrate today (timeline-shaped). Phase B can retire it (Agenda becomes a SystemView with `shape: "cards"`); Phase A leaves it untouched.
|
||||
|
||||
### 6.4 i18n
|
||||
|
||||
User-facing strings:
|
||||
|
||||
- "Meine Sichten" / "My Views" (sidebar group label)
|
||||
- "Neue Sicht" / "New View" (creation entry)
|
||||
- "Speichern als Sicht" / "Save as View"
|
||||
- "Sicht bearbeiten" / "Edit View"
|
||||
- shape labels: "Liste / List", "Karten / Cards", "Kalender / Calendar"
|
||||
- per-source labels: "Fristen / Deadlines", "Termine / Appointments", "Projekt-Verlauf / Project history", "Genehmigungen / Approvals"
|
||||
- empty-state composition strings (filter summary)
|
||||
- error toast for inaccessible-project case
|
||||
|
||||
Total estimate: ~80 new keys, DE + EN.
|
||||
|
||||
### 6.5 Bottom nav (mobile)
|
||||
|
||||
The bottom nav today shows 4 fixed entries (Übersicht-band). It does NOT extend with saved views — that would balloon to N+4 at every saved view. Saved views remain accessible via the sidebar drawer.
|
||||
|
||||
If telemetry shows mobile users routinely hitting saved views, consider a "Pin to bottom-nav" toggle on individual views (max 1 pinned view added between Übersicht and the burger).
|
||||
|
||||
---
|
||||
|
||||
## 7. Section E — Implementation phasing (PR shape)
|
||||
|
||||
### PR split decision (2026-05-07)
|
||||
|
||||
m delegated the split call to the inventor. Phase A is split into **two stacked PRs**:
|
||||
|
||||
- **A1 — Backend substrate + Custom Views API.** Migration 056, FilterSpec/RenderSpec types + validators, ViewService 4-source extension, UserViewService CRUD, SystemView registry, all `/api/*` endpoints, full backend test coverage. *No user-visible change.* Smoke-testable via curl. ~1800 LoC.
|
||||
- **A2 — Frontend Custom Views UI.** Generic view shell (`/views/{slug}`), view editor (`/views/new`, `/views/{slug}/edit`), 3 render-shape components (list/cards/calendar), sidebar "Meine Sichten" group, i18n, CSS. Builds on A1's API. ~1600 LoC.
|
||||
|
||||
Why split: A1 is mergeable + deployable in isolation (additive, no UI risk), exercises the validator surface, lets A2 build on a stable contract. A2 is purely additive once A1 lands. Each PR fits in a normal review window.
|
||||
|
||||
A1 → main → A2 → main is the merge order.
|
||||
|
||||
### Phase A — substrate + Custom Views (this task's locked scope)
|
||||
|
||||
| Step | Files | Approx. LoC | Notes |
|
||||
|---|---|---|---|
|
||||
| 1. Migration `056_user_views` | `internal/db/migrations/056_user_views.up.sql` (+ down) | 60 | table + indexes + RLS + trigger |
|
||||
| 2. Filter/Render spec types + validator | `internal/services/filter_spec.go`, `render_spec.go` | 350 | Go structs + JSON marshalling + `Validate*` |
|
||||
| 3. ViewService — extend EventService | `internal/services/view_service.go` (rename + extend) | 500 | add 2 source loaders; merge N sources |
|
||||
| 4. UserViewService — CRUD | `internal/services/user_view_service.go` | 300 | List/Get/Create/Update/Delete/Touch |
|
||||
| 5. SystemView registry | `internal/services/system_views.go` | 150 | 4 SystemView definitions + reserved-slug list |
|
||||
| 6. HTTP handlers | `internal/handlers/views.go` (new) + adjust `events.go`, `agenda.go`, `inbox.go` minimally | 400 | `/api/user-views/*`, `/api/views/{slug}/run`, `/views/*` page handlers |
|
||||
| 7. Frontend — generic view shell | `frontend/src/views.tsx` + `client/views.ts` | 500 | renders any FilterSpec + RenderSpec; powers `/views/*` |
|
||||
| 8. Frontend — render shape components | `frontend/src/views/{list,cards,calendar,activity}.ts` | 600 | shared by system + custom |
|
||||
| 9. Frontend — view editor | `frontend/src/views-editor.tsx` + client | 400 | inline-save modal + full editor |
|
||||
| 10. Sidebar — Meine Sichten group | `frontend/src/components/Sidebar.tsx` + sidebar.ts | 150 | render saved views from /api/user-views; badge refresh |
|
||||
| 11. i18n | `frontend/src/i18n.ts` | ~80 keys | DE + EN |
|
||||
| 12. Tests | `*_test.go` for spec validators + ViewService | 400 | spec round-trip, RLS, source merge ordering |
|
||||
| **Total** | | ~3400 | one PR |
|
||||
|
||||
Phase A ships standalone — no defaults are touched, no existing pages move.
|
||||
|
||||
### Phase B — refactor system pages onto substrate (separate task)
|
||||
|
||||
Per-page refactor: `/agenda` (substrate-shape `cards`), `/events` (substrate-shape `list`/`calendar`), `/inbox` (substrate-shape `list` + tab tied to viewer_role), `/dashboard` (composes multiple SystemViews into its sections). Each is its own PR. Total estimate: ~2000 LoC across all four. Ships any time after A is stable.
|
||||
|
||||
### Phase C — sharing + advanced shapes (future)
|
||||
|
||||
Cross-user sharing (`user_view_shares`), connections-graph render shape, kanban shape, real-time push updates. None of these are in scope for the current task; called out so the v1 spec doesn't paint us into a corner.
|
||||
|
||||
---
|
||||
|
||||
## 8. Section F — Worked examples
|
||||
|
||||
### 8.1 The unified-inbox m described
|
||||
|
||||
m's brainstorm: "approval candidates + project activity + new cases + status changes + everything that happened on my projects."
|
||||
|
||||
`FilterSpec`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"sources": ["approval_request", "project_event", "deadline", "appointment"],
|
||||
"scope": { "projects": "my_subtree" },
|
||||
"time": { "horizon": "past_30d", "field": "auto" },
|
||||
"predicates": {
|
||||
"approval_request": { "viewer_role": "approver_eligible", "status": ["pending"] },
|
||||
"project_event": { "event_types": ["project_created", "status_changed", "deadline_created", "appointment_created", "approval_decided", "project_archived"] },
|
||||
"deadline": { "approval_status": ["approved","pending","legacy"], "status": ["pending"] },
|
||||
"appointment": { }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`RenderSpec`:
|
||||
|
||||
```json
|
||||
{ "shape": "list", "list": { "density": "compact", "columns": ["time", "actor", "title", "project"], "sort": "date_desc" } }
|
||||
```
|
||||
|
||||
(The "activity-feed feel" comes from `density: "compact"` + the actor/time column set, not from a separate shape — m's correction 2026-05-07.)
|
||||
|
||||
User saves as `meine-aktivitaet`. URL: `/views/meine-aktivitaet`. Sidebar entry under "Meine Sichten" with the bell icon. show_count=true → badge shows count of pending approvals + new audit events in past 30d.
|
||||
|
||||
### 8.2 The "myVerySpecialAgenda"
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"sources": ["deadline", "appointment"],
|
||||
"scope": { "projects": [<project-uuid-1>, <project-uuid-2>] },
|
||||
"time": { "horizon": "next_14d" },
|
||||
"predicates": {
|
||||
"deadline": { "status": ["pending"], "event_types": [<litigation-event-type-uuid>] },
|
||||
"appointment": { "appointment_types": ["hearing", "deadline_hearing"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`RenderSpec`: `{ "shape": "calendar", "calendar": { "default_view": "week" } }`
|
||||
|
||||
### 8.3 "Was hat sich auf Siemens AG geändert?"
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"sources": ["project_event"],
|
||||
"scope": { "projects": [<siemens-client-uuid>] },
|
||||
"time": { "horizon": "past_90d" },
|
||||
"predicates": { "project_event": { "event_types": ["status_changed", "project_reparented", "deadline_completed"] } }
|
||||
}
|
||||
```
|
||||
|
||||
`RenderSpec`: `{ "shape": "list", "list": { "density": "compact", "columns": ["time", "actor", "title"], "sort": "date_desc" } }`
|
||||
|
||||
(`scope.projects` referencing a top-level Client UUID + the path-walk visibility predicate naturally pulls all descendants — this is exactly the t-139 aggregation, surfaced through the substrate.)
|
||||
|
||||
---
|
||||
|
||||
## 9. Section G — Trade-offs flagged
|
||||
|
||||
1. **Substrate complexity vs default-page simplicity.** The substrate is meaningfully more complex than today's `EventService`. The win is that *every future "show me X across my work"* request maps to the same code path. Without it, every new viewpoint is a new bespoke handler — t-138's inbox is the most recent precedent (~900 LoC).
|
||||
2. **JSON spec discoverability.** Power users will appreciate the JSON-spec affordance; casual users may never see it. The risk is that the affordance attracts feature-creep ("can we just add a `like_pattern` predicate?"). Mitigation: `version: 1` field + strict validator + a "spec changes go through inventor" rule documented in `docs/`.
|
||||
3. **Storage cost of `paliad.user_views`.** Each saved view is ~2KB jsonb. 100 active users × 5 saved views = 1MB. Negligible.
|
||||
4. **Sidebar growth.** Heavy users may end up with 10+ saved views in the sidebar group. The drag-reorder editor is the relief valve; if pain emerges, add a "Collapse group" affordance.
|
||||
5. **`show_count` query load.** Each show_count=true view = 1 COUNT(*) per refresh. If users go count-happy, this becomes a real load. Mitigation: cap show_count=true to 5 per user; cache counts for 30s server-side.
|
||||
6. **System pages staying independent (Phase A).** Two read paths during the A→B window. Drift risk if the substrate gains behaviour the system pages miss. Mitigation: feature flag the new `/views/*` for power users until B is in flight.
|
||||
7. **Slug collisions with future system URLs.** Reserve a static list (`dashboard`, `agenda`, `events`, `inbox`, `new`, `edit`, `tools`, `admin`, `settings`, `login`, `logout`, `projects`, `team`, `courts`, `glossary`, `links`, `downloads`, `checklists`, `views`). Validator rejects on write. Future URLs added → migration script renames any user views that crash.
|
||||
8. **Mobile UX of in-page render-shape switcher.** Calendar shape on a phone is cramped. Mitigation: when viewport width < 600px, calendar shape auto-falls back to cards (with a notice). Same pattern as `/events` today.
|
||||
|
||||
---
|
||||
|
||||
## 10. Section H — Open questions for m
|
||||
|
||||
**Status: LOCKED 2026-05-07.** m signed off on all Q19–Q27 recommendations.
|
||||
|
||||
Inventor has made recommendations on every Q1–Q18 from the issue body. The questions below are points where m's call would specifically refine the design before coder shift starts. Numbered fresh (Q19+) so they don't collide with the issue body's numbering.
|
||||
|
||||
**Q19. Curated `project_event` event-type list.**
|
||||
The audit table today has free-text `event_type` strings (`project_created`, `status_changed`, `deadline_created`, `approval_decided`, …). The substrate's filter dropdown needs a curated list. Should I:
|
||||
- (a) ship a hardcoded list of ~12 known kinds (verified via grep on `insertProjectEvent` callsites), or
|
||||
- (b) ship a `paliad.project_event_kinds` registry table seeded with the same list, future-extensible by admins?
|
||||
|
||||
Recommendation: (a). Free-text `event_type` is a code-resident constant; new kinds appear when code emits them, so a registry table would just shadow the code.
|
||||
|
||||
**Q20. Sidebar group position.**
|
||||
I placed "Meine Sichten" between Arbeit and Werkzeuge. Three other reasonable positions:
|
||||
- top of the sidebar (above Übersicht — most-used-first)
|
||||
- inside Übersicht (mixed with Dashboard/Agenda — but blurs the system/user distinction)
|
||||
- between Übersicht and Arbeit (saved views are *overviews* by intent)
|
||||
|
||||
Pick one — the implementation is identical in all four placements.
|
||||
|
||||
**Q21. Bottom-nav inclusion.**
|
||||
Mobile bottom-nav today has 4 fixed entries. The recommendation is to **not** extend it with saved views (sidebar drawer fills the gap). Confirm or reject. If reject: should pinned views be a per-view setting (max 1 pinned), or auto-pin the most-recently-used?
|
||||
|
||||
**Q22. Show-count default.**
|
||||
Per-view `show_count` defaults to false (recommendation §5 Q7). Confirm — alternative is default true with an explicit opt-out. The cost of true-default is more COUNT(*) queries.
|
||||
|
||||
**Q23. Reserved slugs.**
|
||||
List of forbidden user-view slugs (§9 trade-off 7). Anything to add or remove?
|
||||
|
||||
**Q24. Phase A surface area in coder shift.**
|
||||
Phase A is ~3400 LoC. Confirm one PR is the right shape, or split into A1 (substrate + spec types + system view refactor of /events only) + A2 (Custom Views CRUD + sidebar + editor)?
|
||||
|
||||
**Q25. View deletion confirmation.**
|
||||
A user deleting a saved view: should I require a "type the view name to confirm" pattern (matching admin deletes elsewhere in paliad), or a single Yes/No modal?
|
||||
|
||||
**Q26. Time-horizon mandatory clamp.**
|
||||
The validator rejects `time.horizon = "all"` unless `scope.projects` is non-empty (perf safeguard, §5 Q18). Does this feel right, or should `"all"` always be allowed (and we trust the per-source LIMIT to bound things)?
|
||||
|
||||
**Q27. Render-spec live preview in editor.**
|
||||
The editor today (proposed) saves on submit. Should the editor render a *live preview* of the current spec (running the substrate against the in-progress filter) — useful but adds a query per keystroke? Default-debounced (500ms) or explicit "Vorschau" button?
|
||||
|
||||
---
|
||||
|
||||
## 11. Out of scope (v1)
|
||||
|
||||
Per the issue body — quoted for traceability:
|
||||
|
||||
- Replacing the fixed pages (they stay; can be removed later if usage warrants).
|
||||
- Cross-user view sharing.
|
||||
- Public / read-only links to views.
|
||||
- Real-time push updates ("inbox row appears when someone files an approval").
|
||||
- Cross-project rollups (rolling rows across unrelated projects).
|
||||
- Themes / per-view colour palettes.
|
||||
|
||||
Adding from inventor analysis:
|
||||
|
||||
- Connections-graph render shape (deferred per §4 Q4 — its own page later).
|
||||
- Kanban shape (no obvious column axis across mixed sources).
|
||||
- "Pin to bottom-nav" mobile affordance.
|
||||
- Materialised view/cache layer (deferred per §5 Q18 — telemetry-driven).
|
||||
|
||||
---
|
||||
|
||||
## 12. Files the implementer will touch (Phase A)
|
||||
|
||||
Backend:
|
||||
- `internal/db/migrations/056_user_views.up.sql` + `.down.sql` (new)
|
||||
- `internal/services/filter_spec.go` (new) — types + validator
|
||||
- `internal/services/render_spec.go` (new) — types + validator
|
||||
- `internal/services/view_service.go` (new — extends/renames `event_service.go`)
|
||||
- `internal/services/user_view_service.go` (new) — CRUD
|
||||
- `internal/services/system_views.go` (new) — 4 SystemView definitions
|
||||
- `internal/services/event_service.go` — update callers (or alias for back-compat)
|
||||
- `internal/handlers/views.go` (new) — `/api/user-views/*`, `/api/views/{slug}/run`, page handlers for `/views/*`
|
||||
- `internal/handlers/handlers.go` — wire the new routes
|
||||
- `internal/handlers/inbox.go` (light touch) — refactor read path to `ViewService` (Phase B candidate; can stay independent in Phase A if we want to minimize blast radius)
|
||||
|
||||
Frontend:
|
||||
- `frontend/src/views.tsx` (new) — generic view shell (`/views/{slug}` and `/views`)
|
||||
- `frontend/src/views-editor.tsx` (new) — full editor at `/views/new`, `/views/{slug}/edit`
|
||||
- `frontend/src/client/views/list.ts`, `cards.ts`, `calendar.ts`, `activity.ts` (new) — render shape components
|
||||
- `frontend/src/client/views.ts` (new) — view shell glue + shape switcher
|
||||
- `frontend/src/client/views-editor.ts` (new) — editor logic
|
||||
- `frontend/src/components/Sidebar.tsx` — add Meine Sichten group + render from `window.__PALIAD_USER_VIEWS__`
|
||||
- `frontend/src/client/sidebar.ts` — fetch/cache user views; badge refresh
|
||||
- `frontend/src/i18n.ts` — ~80 new keys DE+EN
|
||||
- `frontend/src/styles/global.css` — view-shell + render-shape switcher styles
|
||||
|
||||
Tests:
|
||||
- `internal/services/filter_spec_test.go` — validator (happy + edge cases + reject paths)
|
||||
- `internal/services/render_spec_test.go` — same
|
||||
- `internal/services/view_service_test.go` — 4-source merge ordering, RLS bounded
|
||||
- `internal/services/user_view_service_test.go` — CRUD + RLS
|
||||
- `frontend/src/client/views/*.test.ts` (if frontend testing infra exists; otherwise smoke via Playwright)
|
||||
|
||||
Build infra: none — uses existing `golang-migrate` + Bun pipelines.
|
||||
|
||||
---
|
||||
|
||||
## 13. Inventor stays parked
|
||||
|
||||
This design needs m's go on §10 (Q19–Q27) before coder shift starts. After m's call, the head routes the implementer (recommendation: noether or fresh coder; Phase A is mechanical-substantial but pattern-fluent — t-139's hierarchy substrate is the closest precedent in the codebase).
|
||||
|
||||
NOT cronus per m's directive (2026-05-06: cronus retired from paliad).
|
||||
|
||||
`mai report completed "DESIGN READY FOR REVIEW: data display model — additive Custom Views + 4-source substrate + 4 render shapes + paliad.user_views. 27 questions answered (18 from issue body + 9 follow-ups in §10). Awaiting m's go/no-go before coder shift."`
|
||||
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
947
docs/design-local-chat-2026-05-07.md
Normal file
947
docs/design-local-chat-2026-05-07.md
Normal file
@@ -0,0 +1,947 @@
|
||||
# Design: Local Chat for Teams (t-paliad-145)
|
||||
|
||||
**Status:** READY FOR REVIEW
|
||||
**Author:** noether (inventor)
|
||||
**Issue:** [m/paliad#8](https://mgit.msbls.de/m/paliad/issues/8)
|
||||
**Date:** 2026-05-07
|
||||
**Branch:** `mai/noether/inventor-local-chat-for`
|
||||
|
||||
---
|
||||
|
||||
## §0 TL;DR
|
||||
|
||||
A new chat surface inside paliad: **per-project threads + 1:1/small-group DMs**, with @mentions and entity references (`#frist-…`, `#projekt-…`, `#approval-…`). Real-time delivery via **SSE** (one new long-lived endpoint). New schema: `paliad.chat_threads` + `paliad.chat_messages` + `paliad.chat_reads` + `paliad.chat_thread_participants` + `paliad.chat_mentions`. Visibility composes the existing `paliad.can_see_project` predicate; write-access adds a `chat_access` flag on `project_teams` (default ON for internal roles, OFF for `local_counsel`/`expert`); `observer` is read-only.
|
||||
|
||||
In-app badge in the sidebar (alongside the existing approvals bell). **No PWA push, no email digest, no attachments, no search-across-threads in v1** — all deferred to Phase 2. Markdown subset (bold, italic, code, lists, links, blockquote — no headings, no images). Edit window 5 min by author; soft-delete by author or admin. System auto-post into project chat when an approval is requested on that project (the only auto-event in v1).
|
||||
|
||||
Total scope: one migration (`057_chat`), one new service (`ChatService`) + an in-process pubsub (`ChatBus` interface — pg_notify implementation later when paliad multi-replicas), eight HTTP endpoints, one new top-level page `/chat`, one new `/projects/{id}` tab. Estimated ~3500–4500 LoC for the bundled v1 ship; phasable into 3 PRs (schema + service core, real-time + frontend, mentions + auto-post).
|
||||
|
||||
**Trade-off flagged up-front (read §9.1 before approving):** chat-in-paliad collides with HLC's existing internal comms (Slack/Teams/WhatsApp). Compliance is the cited differentiator, but adoption depends on whether team members actually move "Anna, kannst du auf meine Frist 16.05. drauf schauen?" from WhatsApp into paliad. Recommend m sanity-check this with two PA colleagues before locking the v1 scope.
|
||||
|
||||
---
|
||||
|
||||
## §1 Premises verified live (2026-05-07)
|
||||
|
||||
Before designing on top, I verified each load-bearing claim against the running system rather than CLAUDE.md / memory:
|
||||
|
||||
| Claim | Source | Verification |
|
||||
|---|---|---|
|
||||
| `paliad.notifications` does not exist | issue body Q5/§References | `information_schema.tables WHERE table_schema='paliad'` — confirmed absent. Only `paliad.reminder_log` (email dedup). |
|
||||
| Service worker is cache-only (no push handler) | brand expectation | `frontend/public/sw.js` — only `install`/`activate`/`fetch` handlers. No `push`, no VAPID keys, no `web-push` Go dep. |
|
||||
| Supabase realtime is not enabled on this Postgres | infra | `pg_extension WHERE extname='supabase_realtime'` → empty. Adding it is a separate decision (changes paliad's Postgres surface). |
|
||||
| `paliad.can_see_project()` already extended for derivation | t-139 Phase 2 | Migration 055 added the partner-unit branch; visibility predicate is the canonical entry. |
|
||||
| `project_teams.role` enum is `{lead, of_counsel, associate, senior_pa, pa, local_counsel, expert, observer}` | t-138 + t-139 | `pg_constraint` on `project_teams_role_check`. Confirms eight values; `observer` is the read-only one. |
|
||||
| Sidebar has a bell badge `id="sidebar-inbox-badge"` for approvals | t-138 | `frontend/src/components/Sidebar.tsx:118`. Same pattern reused for chat unread badge. |
|
||||
| BottomNav has exactly 5 slots (Start / Projekte / + / Agenda / Menü) | mobile UX constraint | `frontend/src/components/BottomNav.tsx`. Adding chat to bottom-nav would need swap-out — deferred to per-project tab on mobile. |
|
||||
| Migration tracker is at version 56 (`056_user_views`) | t-144 A1 | `paliad_schema_migrations` row. Next migration is **057**. |
|
||||
| `paliad.notes` exists as annotations on deadlines/appointments/project_events | data model v2 | Different concept from chat (annotations, not conversation). Document the distinction so they don't collide. |
|
||||
| Single web replica today on Dokploy | docker-compose.yml | One `web` service, no horizontal scaling. SSE in-process bus is safe v1; document multi-replica migration path. |
|
||||
| `feature-roadmap.md` mentions "AI chat" | feature-roadmap.md | This is a different concept (Claude-grounded RAG over paliad content, blocked by no-Anthropic-API decision). Reserve `/chat` for human-to-human; AI chat goes elsewhere if it ever ships (`/ask`, `/assist`, etc.). |
|
||||
|
||||
**Doc-vs-live conflicts encountered:** none material. CLAUDE.md and memory are consistent with the live substrate for this task.
|
||||
|
||||
---
|
||||
|
||||
## §2 What v1 is and what it isn't
|
||||
|
||||
### 2.1 In scope (v1)
|
||||
|
||||
- **Per-project threads**, one chat per project node. Visible to: same set as `can_see_project()` (direct + ancestor + derived). One thread auto-provisioned on first access.
|
||||
- **Direct messages (DMs)**: 1:1 + small-group ad-hoc. Recipient picker pulls from any user the caller can already see (i.e. someone who shares a visible project).
|
||||
- **Plain-text + Markdown subset** (bold, italic, code inline + block, bullet/numbered lists, blockquote, auto-linked URLs). No headings, no images, no inline HTML.
|
||||
- **@mentions** and **entity references** (`#frist-<short_id>`, `#projekt-<slug>`, `#termin-<short_id>`, `#approval-<short_id>`).
|
||||
- **Edit** within 5 min, by author. Tombstone-style **delete** by author or admin.
|
||||
- **Real-time delivery** via SSE.
|
||||
- **In-app sidebar badge** with unread count.
|
||||
- **Read marker** per (user, thread).
|
||||
- **System auto-post**: when an approval request is created on this project's deadlines/appointments, system message in chat ("Anna hat Genehmigung angefordert: …"). One auto-event class only.
|
||||
- **Chat tab on `/projects/{id}`** (deep-link entry).
|
||||
- **Top-level `/chat` page** (global view + DM landing).
|
||||
|
||||
### 2.2 Out of scope (v1, deferred)
|
||||
|
||||
| Feature | Why deferred | Phase |
|
||||
|---|---|---|
|
||||
| PWA push notifications | Needs VAPID + push subscription endpoint + SW push handler. Non-trivial; chat MVP works without it. | 2 |
|
||||
| Email digest of unread chats | Reminder system already saturates email; digest math + SMTP load. | 2 |
|
||||
| File attachments | `paliad.documents` already exists as the canonical document store; chat reuse is a Phase 2 plumbing exercise. | 2 |
|
||||
| Cross-thread search | Postgres FTS + visibility join is a separate optimisation. v1 has thread-scoped LIKE search. | 2 |
|
||||
| Per-deadline / per-termin micro-threads | High-noise risk. Project chat with `#frist-…` references covers most uses. | 3 |
|
||||
| Partner-unit room ("cross-cutting team room") | Semantically maps to a partner_unit-scoped chat; v2 once project chat usage validates. | 3 |
|
||||
| Reactions (👍 / 👎) | Issue body lists this as Phase 2. | 2 |
|
||||
| Threaded sub-replies (Slack-style) | UX complexity + count-math change. Flat threads for v1. | 3 |
|
||||
| End-to-end encryption | HLC's storage assumptions are server-trusted; defer. | — |
|
||||
| External-firm chat (opposing counsel etc.) | Compliance + identity boundary. Out of scope, possibly forever. | — |
|
||||
|
||||
---
|
||||
|
||||
## §3 Sub-design A — Surface set, visibility, permissions
|
||||
|
||||
Answers Q1, Q2, Q3, Q19, Q20.
|
||||
|
||||
### 3.1 Surface set (Q1)
|
||||
|
||||
**Recommendation: project chat + DMs in v1. Defer per-deadline/per-termin/partner-unit/topical.**
|
||||
|
||||
| Surface | v1? | Rationale |
|
||||
|---|---|---|
|
||||
| Per-project | ✅ | Already-resolved team set, contextual references, replaces "@channel"-style coordination on a project. The high-leverage default. |
|
||||
| DM (1:1) | ✅ | Replaces "schick mir kurz das Aktenzeichen" WhatsApps. Recipient set = anyone the caller can see. |
|
||||
| DM (small group, ad-hoc 3–8) | ✅ | Same plumbing as 1:1 — participants set instead of pair. No project context required. |
|
||||
| Per-deadline | ❌ Phase 3 | High-noise risk; project chat with `#frist-1234` reference does 95% of the same work. Revisit if usage shows demand. |
|
||||
| Per-termin | ❌ Phase 3 | Same reasoning as per-deadline. |
|
||||
| Partner-unit room | ❌ Phase 3 | Maps cleanly onto `partner_units` once we see the surface gain traction. Extra surface for v1 = extra surface to maintain. |
|
||||
| Cross-cutting topical rooms | ❌ Defer | No clear v1 use case; would need user-driven creation + naming + discovery. Wait for organic demand. |
|
||||
|
||||
### 3.2 Visibility model (Q2 — hierarchy)
|
||||
|
||||
**Recommendation:**
|
||||
|
||||
- **Each project node has its own thread.** A `Client` chat is a separate thread from its child `Litigation`, which is separate from each `Patent` and `Case`. Threads are NOT aggregated up the hierarchy.
|
||||
- **Read access per thread** = the existing `paliad.can_see_project(project_id)` predicate (which already includes direct + ancestor team, derived partner-unit members, and global_admin). This means a member added at `Client` level sees the Client thread *and* every descendant's thread (because they can already see those projects' deadlines/appointments). Conversely, a member added only at `Case` level sees only the Case thread.
|
||||
- **Why not aggregate down?** Aggregating ("Client thread = union of all descendant threads") breaks privacy: Case 14-vs-Müller chat content would surface in the Siemens AG Client thread, visible to all Siemens AG team members. Each project-level thread is its own boundary.
|
||||
- **Why use per-thread visibility = `can_see_project`?** It mirrors every other visibility decision in paliad (deadlines, appointments, events, approvals). One predicate, one mental model. If t-139's derivation rules change, chat tracks for free.
|
||||
|
||||
**Practical example:**
|
||||
|
||||
```
|
||||
Client: Siemens AG ← thread S
|
||||
├─ Litigation: UPC München patent X ← thread L1
|
||||
│ ├─ Patent: EP1234567 ← thread P1
|
||||
│ │ └─ Case: 14-vs-Müller ← thread C1
|
||||
│ └─ Patent: EP7654321 ← thread P2
|
||||
└─ Litigation: EPO Opposition ← thread L2
|
||||
```
|
||||
|
||||
A member added at `Client` (Siemens AG) sees S, L1, L2, P1, P2, C1. A member added only at `Case 14-vs-Müller` sees only C1. A derived partner-unit member attached at L1 sees L1, P1, P2, C1.
|
||||
|
||||
**Anti-feature flagged:** no "broadcast to whole subtree" on send. If a lead wants to message everyone on every Siemens AG thread, they post to the `Client`-level thread; sub-thread members do not get cross-posted. This is intentional — broadcast is a separate UX (Issue #7 bulk team email) and shouldn't be smuggled into chat.
|
||||
|
||||
### 3.3 Approval flow integration (Q3 — t-138 cross-cut)
|
||||
|
||||
**Recommendation: chat does NOT replace inbox or email for approval. Instead, on approval-request creation, post a system message into the project chat with a deep-link to `/inbox`.**
|
||||
|
||||
Rationale:
|
||||
|
||||
- Approvals are structured (approve/reject buttons, decision_kind, audit). Chat is unstructured. Conflating them dilutes the structure.
|
||||
- But chat is where the team's eyeballs live ambiently. Posting "📌 Anna hat Genehmigung angefordert: Frist 16.05. (Replik einreichen). [Zur Genehmigung →]" surfaces the request without forcing anyone to refresh /inbox.
|
||||
- **De-dup with email + bell:** the system post is informational only. Email reminder + bell badge stay primary signals. If the approver opens the chat first and clicks the deep-link, they reach /inbox; the bell decrements as soon as they act there.
|
||||
|
||||
**Mechanism:**
|
||||
|
||||
- `ApprovalService.Submit*` calls `ChatService.PostSystemMessage(threadID, kind="approval_requested", refs={approval_id})` inside the same tx as the approval row insert. If chat post fails, log + continue (approval is the load-bearing record; chat is observability).
|
||||
- One auto-event class only. NOT every deadline-created / appointment-created. Reason: existing Verlauf already captures those; chat would become a duplicate event log.
|
||||
- System messages render with a distinct chip/style (`.chat-system-message`) — non-author, no edit/delete affordance for users.
|
||||
|
||||
**Anti-feature flagged:** approval *decisions* (approve/reject/revoke) do NOT auto-post. Only the *request* posts. This keeps signal density manageable.
|
||||
|
||||
### 3.4 Who can read + write (Q19, Q20)
|
||||
|
||||
**Recommendation:**
|
||||
|
||||
- **Read**: anyone with `can_see_project` access (direct + ancestor + derived + global_admin). Same predicate as deadlines/appointments. No further gating.
|
||||
- **Write**: same set, minus:
|
||||
- `observer` — always read-only on chat (mirrors observer's read-only contract on deadlines/appointments).
|
||||
- `local_counsel` and `expert` — opt-in per project via a new `project_teams.chat_access` boolean. Default `false` for these two roles, `true` for everyone else. Project lead or global_admin can flip the toggle on `/projects/{id}/settings/team`.
|
||||
|
||||
**Schema delta (in 057):**
|
||||
|
||||
```sql
|
||||
ALTER TABLE paliad.project_teams
|
||||
ADD COLUMN chat_access boolean NOT NULL DEFAULT true;
|
||||
|
||||
UPDATE paliad.project_teams SET chat_access = false
|
||||
WHERE role IN ('local_counsel', 'expert');
|
||||
|
||||
CREATE INDEX project_teams_chat_idx
|
||||
ON paliad.project_teams (project_id, user_id) WHERE chat_access = true;
|
||||
```
|
||||
|
||||
**Why a boolean instead of a separate `chat_access_role` enum?** External counsel/expert participation in chat is binary in practice ("included or excluded"). Granular ladder isn't needed. If product later wants "external can read but not write", we revisit.
|
||||
|
||||
**Why default ON for internal roles?** Path of least surprise: paliad already gives them visibility on all project artifacts; chat read+write is the same trust level.
|
||||
|
||||
**Why default OFF for external?** Compliance is the marquee differentiator m cited. External counsel chatting in paliad creates audit/disclosure surface that internal counsel may not anticipate. Default OFF puts the lead in control.
|
||||
|
||||
**Derived members (partner-unit derivation, t-139)**: read = visibility (yes). Write = also yes by default (they can already see the project's other artifacts; chat is no more privileged). Derived members do NOT need `chat_access=true` — that flag is on `project_teams` only, which derived members don't appear in. The derivation branch in the read query already covers them; for write, the service-layer check is "caller has any access (direct/ancestor/derived/admin) AND if direct, role != observer AND chat_access != false".
|
||||
|
||||
**Service-layer write predicate (Go):**
|
||||
|
||||
```go
|
||||
func (s *ChatService) canPostToProject(ctx context.Context, callerID, projectID uuid.UUID) (bool, error) {
|
||||
// global_admin shortcut
|
||||
if isGlobalAdmin, _ := s.users.IsGlobalAdmin(ctx, callerID); isGlobalAdmin {
|
||||
return true, nil
|
||||
}
|
||||
// Direct/ancestor membership with role != observer AND chat_access = true
|
||||
var directOK bool
|
||||
err := s.db.QueryRowxContext(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = $1
|
||||
AND pt.role <> 'observer'
|
||||
AND pt.chat_access = true
|
||||
AND pt.project_id = ANY(string_to_array((SELECT path FROM paliad.projects WHERE id = $2), '.')::uuid[])
|
||||
)`, callerID, projectID).Scan(&directOK)
|
||||
if err != nil { return false, err }
|
||||
if directOK { return true, nil }
|
||||
// Derived (partner-unit) membership: observer/external-flag not relevant — derivation has no role
|
||||
return s.derivation.IsDerivedMember(ctx, callerID, projectID)
|
||||
}
|
||||
```
|
||||
|
||||
`canRead` is the simpler `can_see_project` mirror — no observer/external gating.
|
||||
|
||||
---
|
||||
|
||||
## §4 Sub-design B — Real-time, content, persistence
|
||||
|
||||
Answers Q4–Q15, Q21.
|
||||
|
||||
### 4.1 Real-time architecture (Q4)
|
||||
|
||||
**Recommendation: Server-Sent Events (SSE).**
|
||||
|
||||
| Option | v1 fit | Notes |
|
||||
|---|---|---|
|
||||
| (a) Polling | ❌ | Cheap to ship but lossy under tab-sleep, doubles the API load on every active tab. Already a pain point for the bell badge. |
|
||||
| (b) **SSE** | ✅ | One-way push, native in Go's `net/http` via `http.Flusher`, EventSource auto-reconnects, single endpoint, no per-message connection. Traefik forwards `text/event-stream` with no special config beyond disabling response buffering. |
|
||||
| (c) WebSockets | Defer | Bidirectional we don't need (post is a regular POST + bus publish). Adds heartbeat/reconnect/sticky-session complexity. Worth it only if v2 surfaces typing-indicators or read-receipts that need bidi. |
|
||||
|
||||
**Endpoint shape:**
|
||||
|
||||
```
|
||||
GET /api/chat/stream
|
||||
Accept: text/event-stream
|
||||
[Last-Event-ID: <message_id>] ← optional for resume
|
||||
```
|
||||
|
||||
Server emits:
|
||||
|
||||
```
|
||||
event: message
|
||||
id: <message_id>
|
||||
data: {"type":"message_created","thread_id":"…","message":{…}}
|
||||
|
||||
event: message
|
||||
id: <message_id>
|
||||
data: {"type":"message_edited","thread_id":"…","message_id":"…","body":"…","edited_at":"…"}
|
||||
|
||||
event: message
|
||||
id: <message_id>
|
||||
data: {"type":"message_deleted","thread_id":"…","message_id":"…"}
|
||||
|
||||
event: read
|
||||
data: {"type":"read_advanced","thread_id":"…","user_id":"…","up_to_message_id":"…"}
|
||||
|
||||
: ping ← every 25s, keeps Traefik from reaping idle stream
|
||||
```
|
||||
|
||||
Server-side: per-user goroutine subscribed to `ChatBus` (see §4.2). On bus event, filter against the user's visibility (cached at connect; invalidate on team-membership change), encode SSE frame, flush. Connection close = unsubscribe.
|
||||
|
||||
**Failure modes + mitigations:**
|
||||
|
||||
| Failure | Mitigation |
|
||||
|---|---|
|
||||
| Idle proxy reaper (Traefik default ~3min) | Heartbeat comment every 25s. |
|
||||
| Backpressure if recipient connection is slow | Per-user channel has a small buffer (16). Overflow drops the slow consumer's connection (client EventSource auto-reconnects with `Last-Event-ID`, replay catches up). |
|
||||
| Multi-replica fanout (future) | Bus interface allows swap to `pgnotify.ChatBus` (LISTEN/NOTIFY on `paliad_chat`) without touching ChatService. |
|
||||
| HTTP/1.1 6-conn-per-origin browser cap | Document. Single tab = no issue. Multi-tab is a known SSE constraint; users with many tabs will see one tab's stream go silent. Rare in legal-team usage; defer. |
|
||||
|
||||
**Disable response compression on this endpoint** in handler (`w.Header().Set("Content-Encoding", "identity")`) to prevent Traefik from buffering.
|
||||
|
||||
### 4.2 Message bus (interface)
|
||||
|
||||
**`internal/services/chat_bus.go`**:
|
||||
|
||||
```go
|
||||
type ChatEvent struct {
|
||||
Kind string // message_created | message_edited | message_deleted | read_advanced
|
||||
ThreadID uuid.UUID
|
||||
MessageID uuid.UUID // for message_* events
|
||||
Payload map[string]any
|
||||
AudienceFn func(uid uuid.UUID) bool // visibility filter — applied per subscriber
|
||||
}
|
||||
|
||||
type ChatBus interface {
|
||||
Publish(ctx context.Context, ev ChatEvent) error
|
||||
Subscribe(ctx context.Context, userID uuid.UUID) (<-chan ChatEvent, func())
|
||||
}
|
||||
|
||||
// Default: in-process. Per-user channel registry under sync.Map.
|
||||
// Future: postgresChatBus uses pg_notify(channel="paliad_chat") for fanout.
|
||||
```
|
||||
|
||||
**Why an interface from day 1?** paliad's deploy is single-replica today (docker-compose, one `web` container on Dokploy). When/if we scale to N, swap in the pg_notify implementation; no callsite changes. Cheap insurance.
|
||||
|
||||
### 4.3 Notification path (Q5)
|
||||
|
||||
**Recommendation: in-app sidebar badge ONLY in v1. Email digest deferred. PWA push deferred.**
|
||||
|
||||
| Channel | v1 | Phase 2 | Phase 3+ |
|
||||
|---|---|---|---|
|
||||
| In-app sidebar Chat unread badge | ✅ | | |
|
||||
| Browser tab title flash on incoming message (foreground tab on chat surface) | ✅ (cheap) | | |
|
||||
| `Notification` API (foreground, opt-in per browser permission) | ✅ (cheap) | | |
|
||||
| Email digest of unread-since-last-login | | ✅ | |
|
||||
| PWA push (background, requires VAPID + SW push handler) | | | ✅ |
|
||||
| CalDAV alarm | ❌ | ❌ | ❌ Wrong channel — calendar is for time-anchored events. |
|
||||
|
||||
**Rationale for deferring PWA push:**
|
||||
|
||||
- paliad's `frontend/public/sw.js` is currently a 90-line cache-only worker. Adding push needs:
|
||||
1. A new `addEventListener('push', …)` and `addEventListener('notificationclick', …)` block.
|
||||
2. VAPID keypair generation + secure storage (env vars).
|
||||
3. New table `paliad.push_subscriptions(user_id, endpoint, p256dh, auth, user_agent, created_at)`.
|
||||
4. Server-side `web-push` Go lib (e.g. `github.com/SherClockHolmes/webpush-go`).
|
||||
5. New endpoint `POST /api/push/subscribe` + permission-prompt UX.
|
||||
- Worth ~600–800 LoC and a separate review cycle. Don't bundle into chat MVP. Once chat usage is validated, push graduates as a Phase 2 task and serves chat + approvals + reminders together (one push pipeline, multiple producers).
|
||||
|
||||
**Rationale for deferring email digest:**
|
||||
|
||||
- Mail volume is already a friction point — t-paliad-064 just collapsed reminders into bundled digests. Layering an unread-chat email on top would re-saturate.
|
||||
- Once usage shows it's needed, the digest can compose with the existing morning/evening slot reminder pipeline.
|
||||
|
||||
### 4.4 Read / unread + delivery state (Q6)
|
||||
|
||||
**Recommendation: per-(user, thread) last-read marker. No per-message read receipts.**
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.chat_reads (
|
||||
user_id uuid REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
thread_id uuid REFERENCES paliad.chat_threads(id) ON DELETE CASCADE,
|
||||
last_read_message_id uuid REFERENCES paliad.chat_messages(id) ON DELETE SET NULL,
|
||||
last_read_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, thread_id)
|
||||
);
|
||||
```
|
||||
|
||||
**Unread count for sidebar badge:**
|
||||
|
||||
```sql
|
||||
SELECT COUNT(*)
|
||||
FROM paliad.chat_messages m
|
||||
WHERE m.thread_id IN (<visible thread ids for caller>)
|
||||
AND m.deleted_at IS NULL
|
||||
AND m.author_id <> $caller
|
||||
AND (
|
||||
NOT EXISTS (SELECT 1 FROM paliad.chat_reads cr WHERE cr.user_id = $caller AND cr.thread_id = m.thread_id)
|
||||
OR m.created_at > (SELECT cr.last_read_at FROM paliad.chat_reads cr WHERE cr.user_id = $caller AND cr.thread_id = m.thread_id)
|
||||
);
|
||||
```
|
||||
|
||||
Optionally cap at 99+ in UI.
|
||||
|
||||
**Why no per-message read receipts?** Privacy concern (legal team won't want "Anna saw your message 14 min ago, didn't reply"). UX clutter. Slack made the same call (workspace-default).
|
||||
|
||||
**`last_read_message_id` on `chat_reads`** is for "scroll to the boundary" UX — when you open a thread, the client scrolls to the message immediately above the marker and inserts a "neue Nachrichten" divider. The boundary is sticky until you mark-read again.
|
||||
|
||||
### 4.5 Message body (Q7)
|
||||
|
||||
**Recommendation: stored as Markdown source, rendered with a small whitelisted renderer.**
|
||||
|
||||
| Render | v1 | Notes |
|
||||
|---|---|---|
|
||||
| Bold (`**`/`__`) | ✅ | |
|
||||
| Italic (`*`/`_`) | ✅ | |
|
||||
| Inline code (` `` `) | ✅ | |
|
||||
| Code block (```` ``` ````) | ✅ | Three-backtick fenced; preserves whitespace. |
|
||||
| Bullet list | ✅ | |
|
||||
| Numbered list | ✅ | |
|
||||
| Blockquote (`>`) | ✅ | |
|
||||
| Auto-link URLs | ✅ | `https?://` patterns auto-wrap as anchor with `target=_blank rel=noopener`. |
|
||||
| Headings (`#`) | ❌ | Chat ≠ doc. Strip to plain text on render. |
|
||||
| Images / embeds | ❌ | Use attachments (Phase 2). |
|
||||
| Inline HTML | ❌ | Always sanitised out. |
|
||||
| Raw URLs | ✅ | Auto-link them. |
|
||||
|
||||
**Library choice:** Server-side, use a small custom renderer or `github.com/yuin/goldmark` with a whitelist extension. Either works; I lean toward a tiny custom one (~150 LoC) because the subset is small and goldmark imports a lot. Frontend is render-only — server delivers HTML-rendered + raw source; client picks based on edit/view state.
|
||||
|
||||
**Storage:** raw Markdown source in `paliad.chat_messages.body`. Rendered HTML is computed on read (cheaply; cache in a separate column if benchmarks ever justify). Rendering on read keeps mention/entity-ref resolution dynamic (a deleted deadline's `#frist-…` chip degrades to a dimmed pill instead of a stale link).
|
||||
|
||||
### 4.6 Mentions + entity references (Q8)
|
||||
|
||||
**Recommendation: yes for v1 — `@user` + `#frist-…` + `#projekt-…` + `#termin-…` + `#approval-…`.**
|
||||
|
||||
**Resolution:**
|
||||
|
||||
- **Compose-side**: client-side autocomplete on `@` and `#`. Hits `/api/chat/autocomplete?q=…&context=<thread_id>` — server returns a small list scoped to the thread's visibility (mentions: thread members; entities: project's items + globally-visible items the caller can see).
|
||||
- **Persist-side**: on POST, server parses tokens `@<slug>` / `#<entity>-<short_id>`, resolves to UUIDs, stores in `paliad.chat_mentions` (for users) and in `metadata.entity_refs` JSON (for entities). Original Markdown source preserves the `@anna` / `#frist-1234` syntax.
|
||||
- **Render-side**: on read, server renders tokens as HTML with deep-links: `<a class="chat-mention" href="/team#user-<uuid>">@anna</a>`, `<a class="chat-entity-ref entity-frist" href="/deadlines/<id>">Frist 1234</a>`. Rendering re-checks visibility per recipient — invisible references render as dimmed `<span class="chat-entity-ref dimmed">[#frist-…]</span>`.
|
||||
|
||||
**Notification effect**: a mention drives a unread-count bump. A future Phase 2 enhancement: a separate "Erwähnungen" tab on `/chat` that filters to messages mentioning the caller, with a separate badge. v1 just lifts the unread-count visibility (mention or no mention, the badge ticks).
|
||||
|
||||
### 4.7 Attachments (Q9)
|
||||
|
||||
**Recommendation: out of scope for v1. Reference existing `paliad.documents` via `#dok-<id>` is a v2 pattern.**
|
||||
|
||||
Rationale:
|
||||
|
||||
- `paliad.documents` is the canonical document store with metadata (folder, tags, ACL planned). Adding a parallel attachment surface from chat would create two upload pipelines.
|
||||
- v1 chat references existing documents via entity-ref `#dok-<id>` (deferred until v2 for the implementation; the syntax is reserved now).
|
||||
- v2 attachment flow: drag-and-drop into chat → uploads into `paliad.documents` → message body gains a `#dok-<id>` reference. Single document, two surfaces.
|
||||
|
||||
### 4.8 Edits / deletions (Q10)
|
||||
|
||||
**Recommendation:**
|
||||
|
||||
- **Edit** allowed within 5 min of post. After 5 min, edit affordance is hidden — correct via reply.
|
||||
- **Delete** allowed at any time by author. Soft-delete: `deleted_at` set, body replaced server-side with the rendered tombstone "Diese Nachricht wurde gelöscht." in the API payload (DE/EN per `users.lang`). Client renders the tombstone in muted styling. Mentions/entity-refs in deleted messages are preserved server-side (audit) but suppressed in render.
|
||||
- **Admin override**: global_admin can delete any message at any time. Audit-marked: `metadata.deleted_by_admin = <admin_id>` and a system-message in the same thread "Admin hat eine Nachricht entfernt." (no body content disclosed).
|
||||
|
||||
**Why 5 min?**
|
||||
|
||||
- Long enough for typo undo, short enough to keep audit trust ("the message you just read isn't the one stored an hour later").
|
||||
- Mirrors many legal-team chat tools (Slack's default-edit-window can be configured; Teams has 0 by default but admin can extend).
|
||||
- Edit shows `(bearbeitet)` chip with tooltip showing `edited_at`.
|
||||
|
||||
**Why soft-delete only?** Compliance: paliad may need to demonstrate message provenance even after deletion. Soft-delete keeps the row + author + created_at; only `body` is hidden. Hard-delete is escalation-only (manual SQL by global_admin if legal forces).
|
||||
|
||||
### 4.9 Replies / threading (Q11)
|
||||
|
||||
**Recommendation: flat threads in v1. Slack-style sub-threads deferred.**
|
||||
|
||||
A flat thread means every message in the project chat lands at the bottom, ordered chronologically. To reply to a specific message, quote it (`>` Markdown blockquote) or @mention the author — same pattern Twitter/Mastodon use successfully without sub-threads.
|
||||
|
||||
**Why flat?**
|
||||
|
||||
- Sub-threads add: a `parent_message_id` column, a parent-thread-summary fold-out UI, "thread of threads" navigation, separate unread counts for thread vs sub-thread.
|
||||
- For project-team chat (5–15 active members per project), flat is cleaner. Sub-threads pay off in larger channels (50+ members, parallel conversations).
|
||||
- Re-introduce in v2 if usage shows specific demand for parallel parlay.
|
||||
|
||||
### 4.10 Search (Q12)
|
||||
|
||||
**Recommendation: thread-scoped search in v1. Cross-thread search deferred.**
|
||||
|
||||
- Thread-scoped: `WHERE thread_id = $1 AND body ILIKE '%' || $2 || '%' AND deleted_at IS NULL` — sub-second on threads up to ~10k messages. Above that, add a Postgres FTS index in v2.
|
||||
- Cross-thread: would need `paliad.chat_messages` FTS + visibility join — workable but separable. Defer to Phase 2 once we know the cross-thread use case.
|
||||
|
||||
### 4.11 Storage schema (Q13)
|
||||
|
||||
**Migration 057:**
|
||||
|
||||
```sql
|
||||
-- paliad.chat_threads ----------------------------------------------------------
|
||||
|
||||
CREATE TYPE paliad.chat_thread_kind AS ENUM ('project', 'dm');
|
||||
|
||||
CREATE TABLE paliad.chat_threads (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
kind paliad.chat_thread_kind NOT NULL,
|
||||
project_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
-- DM 1:1 / small group: participants in chat_thread_participants;
|
||||
-- project: visibility predicate (no rows in chat_thread_participants).
|
||||
title text, -- DM small-group: optional user-supplied; project: NULL (use project name)
|
||||
created_by uuid REFERENCES paliad.users(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_activity timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT chat_thread_kind_consistency CHECK (
|
||||
(kind = 'project' AND project_id IS NOT NULL) OR
|
||||
(kind = 'dm' AND project_id IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
-- One project = one thread (idempotent provisioning).
|
||||
CREATE UNIQUE INDEX chat_threads_project_idx ON paliad.chat_threads (project_id) WHERE kind = 'project';
|
||||
|
||||
CREATE INDEX chat_threads_activity_idx ON paliad.chat_threads (last_activity DESC);
|
||||
|
||||
-- paliad.chat_thread_participants ---------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.chat_thread_participants (
|
||||
thread_id uuid NOT NULL REFERENCES paliad.chat_threads(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
joined_at timestamptz NOT NULL DEFAULT now(),
|
||||
role text NOT NULL DEFAULT 'member' CHECK (role IN ('member', 'admin')),
|
||||
-- 'admin' on a DM = the creator who can add/remove participants. Project chats have no rows here.
|
||||
PRIMARY KEY (thread_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX chat_thread_participants_user_idx ON paliad.chat_thread_participants (user_id);
|
||||
|
||||
-- paliad.chat_messages --------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.chat_messages (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
thread_id uuid NOT NULL REFERENCES paliad.chat_threads(id) ON DELETE CASCADE,
|
||||
author_id uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
-- author_id NULL = system message (auto-post)
|
||||
body text NOT NULL,
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
-- metadata: { system: true, system_kind: "approval_requested", entity_refs: [...], deleted_by_admin: <uuid>, … }
|
||||
edited_at timestamptz,
|
||||
deleted_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX chat_messages_thread_idx ON paliad.chat_messages (thread_id, created_at DESC);
|
||||
CREATE INDEX chat_messages_author_idx ON paliad.chat_messages (author_id, created_at DESC);
|
||||
|
||||
-- paliad.chat_reads -----------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.chat_reads (
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
thread_id uuid NOT NULL REFERENCES paliad.chat_threads(id) ON DELETE CASCADE,
|
||||
last_read_message_id uuid REFERENCES paliad.chat_messages(id) ON DELETE SET NULL,
|
||||
last_read_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, thread_id)
|
||||
);
|
||||
|
||||
-- paliad.chat_mentions --------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.chat_mentions (
|
||||
message_id uuid NOT NULL REFERENCES paliad.chat_messages(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (message_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX chat_mentions_user_idx ON paliad.chat_mentions (user_id);
|
||||
|
||||
-- paliad.project_teams.chat_access -------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.project_teams
|
||||
ADD COLUMN chat_access boolean NOT NULL DEFAULT true;
|
||||
|
||||
UPDATE paliad.project_teams SET chat_access = false
|
||||
WHERE role IN ('local_counsel', 'expert');
|
||||
|
||||
-- RLS ------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.chat_threads ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE paliad.chat_thread_participants ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE paliad.chat_messages ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE paliad.chat_reads ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE paliad.chat_mentions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Service-role bypasses RLS (paliad's pgx pool runs as service-role per t-paliad-088 lesson #2).
|
||||
-- Policies exist for any future direct-DB query path; service-layer is the load-bearing gate.
|
||||
-- (RLS predicates omitted from this design doc; sketch in §13 of impl plan.)
|
||||
```
|
||||
|
||||
**`chat_threads.kind = 'dm'` uniqueness for 1:1**: enforce via service layer (sort participant UUIDs, check existing thread with exact participant set). Not in the schema CHECK because participants live in another table.
|
||||
|
||||
### 4.12 Retention (Q14)
|
||||
|
||||
**Recommendation: forever in v1. Soft-delete only. Phase 2 = export/archive flow.**
|
||||
|
||||
Compliance: HLC may need permanent archival of project-related conversations. Forever-storage is the safest default; cheaper than implementing rolling-window deletion + getting it wrong.
|
||||
|
||||
`deleted_at` is soft-delete by user/admin action; no time-based purge in v1.
|
||||
|
||||
**Phase 2** features for retention/compliance:
|
||||
- Export thread to PDF/JSON (audit trail).
|
||||
- Per-project retention override (e.g. Case closed → archive after 6 months).
|
||||
- Search across archived (read-only) threads.
|
||||
|
||||
### 4.13 Audit / Verlauf integration (Q15)
|
||||
|
||||
**Recommendation: chat does NOT appear in Verlauf by default. Optional "Pin to Verlauf" affordance on individual messages → creates a `note` (Phase 2).**
|
||||
|
||||
Rationale:
|
||||
|
||||
- Verlauf already has 18 distinct `event_type` values (`deadline_*`, `appointment_*`, `checklist_*`, `note_*`, `project_type_changed`). Adding `chat_message_*` events for every chat post would dilute signal — Verlauf should answer "what changed on this matter" not "what was said".
|
||||
- The "Pin to Verlauf" affordance lets users explicitly promote a chat message to a `paliad.note` (and emit a `note_created` event_type — the existing pattern). Phase 2; reserve the UX hook now.
|
||||
|
||||
### 4.14 PWA push (Q21)
|
||||
|
||||
**Recommendation: defer to Phase 2.** See §4.3 above for the cost/value reasoning. v1 ships without push; users get unread badge + tab-flash + foreground `Notification` API.
|
||||
|
||||
---
|
||||
|
||||
## §5 Sub-design C — Integration with existing surfaces
|
||||
|
||||
Answers Q16–Q18.
|
||||
|
||||
### 5.1 Sidebar entry (Q16)
|
||||
|
||||
**Recommendation: BOTH — a top-level `Chat` sidebar entry with global unread badge AND a per-project `Chat` tab on `/projects/{id}` deep-linking the same thread.**
|
||||
|
||||
**Sidebar:**
|
||||
|
||||
```
|
||||
Übersicht
|
||||
├─ Home
|
||||
├─ Dashboard
|
||||
├─ Agenda
|
||||
├─ Inbox (bell badge — approvals)
|
||||
├─ Chat (new — chat badge — unread messages) ← new
|
||||
└─ Team
|
||||
Arbeit
|
||||
└─ …
|
||||
Meine Sichten
|
||||
└─ …
|
||||
…
|
||||
```
|
||||
|
||||
Position: directly under `Inbox`. Same group ("Übersicht"). Same badge pattern (`id="sidebar-chat-badge"`).
|
||||
|
||||
**`/chat` page** (new top-level):
|
||||
- Two-pane layout: thread list left (recently-active first), active thread right (messages + composer).
|
||||
- Thread list shows: project chats the user has access to (sorted by `last_activity DESC`), DMs (sorted by `last_activity DESC`).
|
||||
- Tabs: `Alle` (default), `Projekte`, `DMs`, `Erwähnungen` (Phase 2). Visual style mirrors `/inbox` tab chips.
|
||||
|
||||
**Per-project Chat tab on `/projects/{id}`:**
|
||||
- Adds a new "Chat" tab next to existing tabs (Übersicht / Fristen / Termine / Verlauf / Team / …). Tab opens the same project thread, full-width-in-tab.
|
||||
- Deep-links: `/projects/<id>?tab=chat` and `/projects/<id>/chat` (server resolves both).
|
||||
|
||||
**Mobile (BottomNav)**:
|
||||
- BottomNav slots are full at 5 (Start / Projekte / + / Agenda / Menü). Don't swap a slot — chat surfaces from `Menü` and from per-project `Chat` tab. Defer dedicated mobile slot to Phase 2 once usage justifies.
|
||||
|
||||
### 5.2 Custom Views (#5) integration (Q17)
|
||||
|
||||
**Recommendation: chat messages are NOT a 5th source in the ViewService union.**
|
||||
|
||||
Rationale:
|
||||
|
||||
- ViewService unions four kinds: deadline, appointment, project_event, approval_request. Each is a *time-anchored event* with a structured semantics ("Frist X due on Y"). Chat messages are conversation, not events.
|
||||
- Adding a `chat_message` source would dilute the substrate's purpose and produce noisy Custom Views ("show me everything in the next 30 days" → 80% chat noise).
|
||||
- **Mentions and entity-refs do NOT cross over either.** A `#frist-1234` reference inside chat doesn't promote the chat message into the deadline's audit; the reference is a navigation aid, not an audit fact.
|
||||
|
||||
**Phase 2 escape hatch**: if demand emerges for "show me activity (chat + events) on this project", introduce a new `chat_activity` synthesised source that emits one row per *day-bucket-with-N-messages*, not per-message. That keeps Custom Views unflooded while exposing the "this project has been busy" signal. Reserve the source name; don't implement v1.
|
||||
|
||||
### 5.3 Bulk team email (#7) overlap (Q18)
|
||||
|
||||
**Recommendation: distinct surfaces, deliberate split.**
|
||||
|
||||
| Use case | Bulk email (#7) | Chat |
|
||||
|---|---|---|
|
||||
| Team-wide announcement, no reply expected ("Server-Wartung am Montag 18 Uhr") | ✅ | — |
|
||||
| Coordination on a specific matter ("Anna, kannst du auf meine Frist 16.05. drauf schauen?") | — | ✅ |
|
||||
| Process reminders / quarterly newsletters | ✅ | — |
|
||||
| "Wer sieht heute den 14:00 hearing-call?" | — | ✅ |
|
||||
| External-counsel briefing | ✅ (mail) | — (chat is internal-only by default) |
|
||||
| Hot-fix coordination during litigation prep | — | ✅ |
|
||||
| Birthday / kudos (if product wants it) | — | ✅ |
|
||||
|
||||
**Pattern:**
|
||||
- **Email** is broadcast, archive-friendly, no expectation of synchronous reply, lives in user's regular inbox alongside client mail.
|
||||
- **Chat** is back-and-forth, ambient, threaded, scoped to a project's team or a small DM group.
|
||||
|
||||
The two coexist; users self-select. No automatic cross-posting. If a user writes a chat post that is "really" a broadcast announcement, that's a soft heuristic the product can teach later (Phase 3 nudge: "Looks like a broadcast — send as email instead?").
|
||||
|
||||
---
|
||||
|
||||
## §6 Inventor follow-up questions for m
|
||||
|
||||
Beyond the 21 questions in the issue body, my design surfaced a few I cannot lock without a call:
|
||||
|
||||
| # | Question | Recommendation |
|
||||
|---|---|---|
|
||||
| Q22 | **DM creation policy**: anyone-to-anyone, or scoped to "people I share a project with"? | Recommend: scoped to "people I share at least one visible project with" — keeps DMs inside the matter graph, prevents random cross-firm pings. global_admin always reachable. |
|
||||
| Q23 | **DM small-group cap**: hard limit on DM participants? | Recommend cap at **8** (informal coordination tier; above that, a project chat or partner-unit room is right). Not a hard schema cap; service-layer + UI cap. Lift later if we see demand. |
|
||||
| Q24 | **Project chat auto-provision timing**: lazily on first read, or eagerly on project create? | Recommend lazy. Most projects never get chatter; lazy provisioning saves rows + noise on `/projects` list. Once demand is shown, switch to eager (one-line change). |
|
||||
| Q25 | **System auto-post audience**: project chat post is visible to ALL thread members, including external counsel + observer. Is approval-request system-post leaking signal that external counsel shouldn't see? | Recommend: respect existing `chat_access` flag — observer reads, external counsel reads only if their `chat_access=true`. The same predicate as any chat post; the system just authors instead of a user. |
|
||||
| Q26 | **Edit window length**: 5 min as recommended, or shorter (1 min) / longer (15 min) / no edit at all? | Recommend 5 min. 1 min is too short for "wait did I tag the right person", 15 min is long enough for someone else to have read+replied based on the original. |
|
||||
| Q27 | **Markdown subset**: include or exclude blockquote? Tables? Strikethrough? | Recommend: blockquote ✅ (quoting prior message is the flat-thread alternative to sub-threading). Tables ❌ (unusual in chat, complicates renderer). Strikethrough ❌ (chat ≠ doc; rare and ambiguous). |
|
||||
| Q28 | **`@everyone` / `@team`**: support a "ping the whole project team" mention? | Recommend: NO in v1. Spam risk. Lead can post a normal message; team members on the thread already see it. Phase 2: optional `@team` for project leads only, with confirmation prompt. |
|
||||
| Q29 | **Chat unread badge: count messages or count threads?** | Recommend: count *messages* with caps at 99+. Threads-with-unread is easier on the eye but obscures volume; messages match user mental model of "you have N things to read". |
|
||||
| Q30 | **Sound on incoming message** (foreground tab)? | Recommend: NO by default. Opt-in setting (`users.chat_sound_enabled`) deferred to Phase 2. Lawyers in court rooms with their phone open is a real failure mode. |
|
||||
| Q31 | **Default landing on `/chat`**: most-recently-used thread, "Alle" thread list, or empty placeholder? | Recommend: most-recently-used thread (mirrors `/views` MRU pattern from t-144). First-time users land on an empty-state "Wähle einen Thread links". |
|
||||
| Q32 | **Chat post triggers `last_activity` bump** on associated project (drives sidebar sort, dashboard "recent activity") — yes/no? | Recommend: yes for chat threads themselves (sort thread list). NO for `paliad.projects.last_modified` (chat shouldn't ride sibling sort signals — that's reserved for case-substantive changes). |
|
||||
|
||||
m's go on these locks the design. If any answer flips, I rev the doc before handing to the implementer.
|
||||
|
||||
---
|
||||
|
||||
## §7 Trade-offs and risks
|
||||
|
||||
### 7.1 Adoption risk (the elephant in the room)
|
||||
|
||||
**The biggest risk is not technical — it's whether teams actually use this.** HLC colleagues already have:
|
||||
|
||||
- WhatsApp + Telegram for fast informal coordination.
|
||||
- Microsoft Teams / Outlook chat for firm-internal IM (assumption — verify).
|
||||
- Email for formal asynchronous comms.
|
||||
|
||||
paliad chat would need to attract `"Anna, kannst du auf meine Frist 16.05. drauf schauen?"` away from those tools. The differentiator m cited in the issue is compliance + context-rich (auto-resolve `#frist-1234`, team set is pre-derived). That's plausible — but the cost of building it is real, and if PA colleagues stick to WhatsApp, paliad chat becomes a half-empty room that signals "this product doesn't know its users".
|
||||
|
||||
**Recommendation before implementation:** m asks two PA colleagues from different offices ("would you actually use this if it existed?", "what would make you switch from WhatsApp?"). Either keeps you honest or surfaces feature gaps the design doesn't cover.
|
||||
|
||||
If adoption looks weak, alternative scopes worth considering:
|
||||
- **A: ship project chat only (no DM).** Project context is the real differentiator; DM is what WhatsApp does well. Less surface, less work, less risk of half-empty.
|
||||
- **B: ship `@mention + reply` as a comment thread on each deadline/termin first** — closer to the Verlauf pattern, lower lift, and validates the idea before the full chat surface.
|
||||
|
||||
### 7.2 Single-replica SSE constraint
|
||||
|
||||
Today's docker-compose is one `web` container. SSE works fine. If we ever scale (multi-Dokploy-replica, blue-green deploy with overlap), in-process bus drops cross-replica messages.
|
||||
|
||||
**Mitigation:** abstract `ChatBus` interface from day 1. Future `pgnotify.ChatBus` implementation is ~80 LoC and a one-line wiring change. Document this in `internal/services/chat_bus.go`.
|
||||
|
||||
### 7.3 Observer-write semantics
|
||||
|
||||
`observer` role is read-only for chat per recommendation. There's a UX edge case: an observer who *thinks* they're a regular member (because everyone else is chatting) and gets a write-disabled composer. Mitigate with a clear empty composer hint: "Du bist Beobachter:in für dieses Projekt — Lesezugriff nur." Same pattern as observer's read-only Frist edit.
|
||||
|
||||
### 7.4 External counsel default OFF chat
|
||||
|
||||
Defaulting `chat_access=false` for `local_counsel`/`expert` is the right compliance default but creates onboarding friction: the first time external counsel is added to a project, the lead has to explicitly toggle them in. **Mitigate** with a one-time hint in the team-add modal: "Externe Anwält:in/Sachverständige:r — Chat ist standardmäßig deaktiviert. Aktivieren?".
|
||||
|
||||
### 7.5 Markdown sanitisation correctness
|
||||
|
||||
Hand-rolling a small Markdown subset risks XSS through subtle edge cases (`[click](javascript:…)`, malformed image URI, etc.).
|
||||
|
||||
**Mitigate:**
|
||||
- Escape all rendered text first, then apply whitelisted Markdown tokens.
|
||||
- For URLs: validate `https?://` prefix with stdlib `url.Parse`; reject everything else.
|
||||
- Add a render test suite with known-bad payloads (data URIs, javascript: URIs, broken closures).
|
||||
- If we end up importing goldmark anyway, lean on its strict mode + a custom rendering walker.
|
||||
|
||||
### 7.6 Chat as a Verlauf-substitute
|
||||
|
||||
Risk that users start treating chat as the audit log ("I told Anna in chat to extend that deadline"). Verlauf is the audit; chat is conversation. Mitigate by:
|
||||
- The Phase 2 "Pin to Verlauf" affordance promotes specific chat messages to notes.
|
||||
- UX copy on the chat composer: "Notizen am Vorgang? → Verlauf." (small hint, not a wall).
|
||||
|
||||
### 7.7 Mobile keyboard + composer + bottom-nav
|
||||
|
||||
Mobile keyboards on iOS Safari overlap fixed bottom-nav elements. The composer needs to play nicely with that — anchor at viewport-bottom but adjust on focus. Standard pattern (the existing checklist comment composer probably has the same issue solved). Worth a quick check in implementation, not a design blocker.
|
||||
|
||||
### 7.8 chat_messages explosion
|
||||
|
||||
Multiplied across all paliad projects, chat could grow to millions of rows over years. Indexes on `(thread_id, created_at DESC)` keep reads fast. PG handles 10M+ rows with ease at this index shape. Storage cost is negligible. Document the size projection in the impl plan but don't pre-optimize.
|
||||
|
||||
---
|
||||
|
||||
## §8 Phasing
|
||||
|
||||
**Phase 1 (chat MVP — bundled v1, single PR):** ~3500–4500 LoC
|
||||
|
||||
1. Migration 057 (chat schema + `project_teams.chat_access`).
|
||||
2. `ChatService` + `ChatBus` interface + in-process implementation.
|
||||
3. HTTP endpoints (8 in §10).
|
||||
4. SSE stream endpoint with heartbeat + Last-Event-ID resume.
|
||||
5. `frontend/src/chat.tsx` + client `client/chat.ts` + Markdown renderer.
|
||||
6. `frontend/src/components/Sidebar.tsx` updated with Chat entry + badge.
|
||||
7. Per-project Chat tab on `/projects/{id}`.
|
||||
8. Approval auto-post wiring in `ApprovalService.Submit*`.
|
||||
9. ~80 i18n keys DE+EN.
|
||||
10. CSS for chat shell + bubbles + mention chips + composer.
|
||||
|
||||
**Phase 2** (~2000 LoC each, separate PRs as demand justifies):
|
||||
- Email digest of unread chats (composes with reminder pipeline).
|
||||
- PWA push notifications (VAPID + SW push handler + subscription endpoint).
|
||||
- File attachments (chat → `paliad.documents`).
|
||||
- Cross-thread search (FTS index + global search).
|
||||
- "Pin to Verlauf" affordance.
|
||||
|
||||
**Phase 3+** (defer until Phase 1+2 usage validates):
|
||||
- Per-deadline / per-termin micro-threads.
|
||||
- Partner-unit rooms.
|
||||
- Reactions, sub-threads, `@team`, sound/Notification config UI.
|
||||
- Topical/cross-cutting rooms.
|
||||
|
||||
**Optional Phase 1 split (if implementer prefers):**
|
||||
- 1A — Schema + `ChatService` + REST endpoints + project chat shell. No DMs, no SSE (polling stub for unread badge).
|
||||
- 1B — DMs + SSE + mentions + entity-refs + approval auto-post.
|
||||
|
||||
If the implementer splits, they own the call. Both 1A+1B in a single PR is ~4500 LoC; each in its own PR is ~2000-2500. m can decide on the split when locking the design.
|
||||
|
||||
---
|
||||
|
||||
## §9 Implementer recommendation
|
||||
|
||||
**Recommended worker: noether (this worktree)** or a fresh coder.
|
||||
|
||||
Pattern-fluent Sonnet work; nothing here requires Opus-level architectural reasoning past this design. The substrate (visibility predicate, project_teams shape, SSE handling, sidebar/badge pattern, ViewService precedent) is well-trodden — implementation is mostly composition.
|
||||
|
||||
NOT cronus per memory directive (cronus retired from paliad).
|
||||
|
||||
Expected files (Phase 1):
|
||||
|
||||
- `internal/db/migrations/057_chat.{up,down}.sql`
|
||||
- `internal/services/chat_service.go`
|
||||
- `internal/services/chat_bus.go`
|
||||
- `internal/services/markdown.go` (small renderer)
|
||||
- `internal/handlers/chat.go`
|
||||
- `internal/handlers/chat_stream.go` (SSE)
|
||||
- `internal/handlers/handlers.go` (route wiring under `if svc.Chat != nil`)
|
||||
- `internal/services/approval_service.go` (auto-post hook on Submit*)
|
||||
- `cmd/server/main.go` (`chatBus := services.NewInProcessChatBus(); chatSvc := services.NewChatService(pool, …, chatBus)`)
|
||||
- `frontend/src/chat.tsx` (page shell)
|
||||
- `frontend/src/projects-detail.tsx` (Chat tab integration)
|
||||
- `frontend/src/client/chat.ts` (orchestration, EventSource, autocomplete, edit/delete, read marker)
|
||||
- `frontend/src/client/markdown.ts` (render-side companion if any)
|
||||
- `frontend/src/client/sidebar.ts` (badge + unread-count fetch)
|
||||
- `frontend/src/components/Sidebar.tsx` (new Chat entry)
|
||||
- `frontend/src/styles/global.css` (chat-shell + chat-bubble + chat-mention + chat-composer styles)
|
||||
- `frontend/src/i18n.ts` (~80 keys DE+EN)
|
||||
- `frontend/src/build.ts` (chat.html bundle)
|
||||
|
||||
---
|
||||
|
||||
## §10 HTTP endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/chat` | Chat shell page |
|
||||
| GET | `/chat/dm/<thread_id>` | Deep-link to specific DM thread (server resolves visibility, redirects to /chat with state) |
|
||||
| GET | `/api/chat/threads` | List threads visible to caller (project + DM), sorted by last_activity. Includes per-thread unread count. |
|
||||
| POST | `/api/chat/dm` | Body `{ "participant_ids": [...], "title": "..." }`. Returns thread (idempotent for 1:1 by sorted participant set). |
|
||||
| GET | `/api/chat/threads/<id>/messages?before=<msg_id>&limit=50` | List messages with cursor pagination. Returns rendered HTML + raw source per message. |
|
||||
| POST | `/api/chat/threads/<id>/messages` | Post message. Body `{ "body": "..." }`. Server parses mentions/refs, inserts, publishes bus event. |
|
||||
| PATCH | `/api/chat/messages/<id>` | Edit (5min author window). Body `{ "body": "..." }`. |
|
||||
| DELETE | `/api/chat/messages/<id>` | Soft-delete (author or admin). |
|
||||
| POST | `/api/chat/threads/<id>/read` | Body `{ "up_to_message_id": "..." }`. Updates `chat_reads`. |
|
||||
| GET | `/api/chat/unread-count` | Sidebar badge. Returns `{ "total": N, "by_thread": {...} }`. |
|
||||
| GET | `/api/chat/autocomplete?q=&context=<thread_id>` | Server-resolved mention/entity-ref autocomplete. |
|
||||
| GET | `/api/chat/stream` | SSE long-lived; returns events filtered to caller's visibility. |
|
||||
|
||||
---
|
||||
|
||||
## §11 Frontend shape (Phase 1)
|
||||
|
||||
```
|
||||
/chat [/chat]
|
||||
┌───────────────────┬──────────────────────────────────────┐
|
||||
│ THREADS │ Siemens AG · Litigation UPC München │
|
||||
├───────────────────┼──────────────────────────────────────┤
|
||||
│ Alle | Proj | DM │ ┌──────────────────────────────────┐ │
|
||||
│ │ │ Anna · 14:23 │ │
|
||||
│ ▶ Siemens AG · L. │ │ Hat jemand auf Frist #frist-1234 │ │
|
||||
│ 3 ungelesen │ │ drauf geschaut? Replik bis Mo. │ │
|
||||
│ ▷ EP1234567 │ └──────────────────────────────────┘ │
|
||||
│ ▷ DM mit Anna │ ┌──────────────────────────────────┐ │
|
||||
│ ▷ DM (3) UPC-Team │ │ ── neue Nachrichten ────────────│ │
|
||||
│ │ ├──────────────────────────────────┤ │
|
||||
│ │ │ Lukas · 14:30 │ │
|
||||
│ │ │ Schau gleich rein, ist das EAU? │ │
|
||||
│ │ └──────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ├──────────────────────────────────────┤
|
||||
│ │ ┌──────────────────────────────────┐ │
|
||||
│ │ │ @anna danke! … ▶│ │
|
||||
│ │ └──────────────────────────────────┘ │
|
||||
└───────────────────┴──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
On mobile: thread list is a full page, tap → message page.
|
||||
|
||||
On `/projects/{id}?tab=chat`: messages pane only (thread list hidden), with project header above.
|
||||
|
||||
System-post visual:
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────┐
|
||||
│ 🔔 Anna hat Genehmigung angefordert: │
|
||||
│ Frist 16.05. (Replik einreichen) │
|
||||
│ [ Zur Genehmigung → ] │
|
||||
└───────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
(Distinct background, no edit/delete affordance, deep-link button.)
|
||||
|
||||
---
|
||||
|
||||
## §12 Concrete recommendation summary
|
||||
|
||||
| # | Question | Recommendation |
|
||||
|---|---|---|
|
||||
| Q1 | Surface set v1 | Per-project + DMs |
|
||||
| Q2 | Hierarchy visibility | Per-thread, predicate = `can_see_project` |
|
||||
| Q3 | Approval cross-cut | System auto-post, no replacement |
|
||||
| Q4 | Real-time arch | SSE |
|
||||
| Q5 | Notification path | In-app badge + tab-flash + Notification API; defer push + email digest |
|
||||
| Q6 | Read/unread | Per-(user,thread) last-read marker; no per-message receipts |
|
||||
| Q7 | Body format | Markdown subset (no headings, no images) |
|
||||
| Q8 | Mentions + refs | `@user`, `#frist-…`, `#projekt-…`, `#termin-…`, `#approval-…` |
|
||||
| Q9 | Attachments | Defer Phase 2 |
|
||||
| Q10 | Edits / deletes | Edit ≤5 min author; soft-delete author or admin |
|
||||
| Q11 | Threading | Flat |
|
||||
| Q12 | Search | Thread-scoped LIKE; defer cross-thread |
|
||||
| Q13 | Schema | Migration 057: 5 new tables + `project_teams.chat_access` |
|
||||
| Q14 | Retention | Forever, soft-delete only |
|
||||
| Q15 | Verlauf | No; "Pin to Verlauf" Phase 2 |
|
||||
| Q16 | Sidebar entry | Both — top-level Chat + per-project tab |
|
||||
| Q17 | Custom Views | NOT a 5th source |
|
||||
| Q18 | Bulk email overlap | Distinct surfaces |
|
||||
| Q19 | Who can chat | Read = visibility; observer read-only; external opt-in via `chat_access` |
|
||||
| Q20 | External counsel/expert | Default `chat_access=false`; lead toggles per project |
|
||||
| Q21 | PWA push | Defer Phase 2 |
|
||||
| Q22 | DM reachability | Scoped to "shares ≥1 visible project" |
|
||||
| Q23 | DM small-group cap | 8 |
|
||||
| Q24 | Auto-provision | Lazy on first read |
|
||||
| Q25 | System-post audience | Same as any chat post (respects `chat_access`) |
|
||||
| Q26 | Edit window | 5 min |
|
||||
| Q27 | Markdown subset | Blockquote yes; tables no; strikethrough no |
|
||||
| Q28 | `@everyone` / `@team` | No in v1 |
|
||||
| Q29 | Badge count | Messages, capped at 99+ |
|
||||
| Q30 | Sound | No (opt-in deferred) |
|
||||
| Q31 | Default landing | Most-recently-used thread |
|
||||
| Q32 | last_activity bump | Yes on chat thread; no on project record |
|
||||
|
||||
---
|
||||
|
||||
## §13 Open follow-ups (not for v1)
|
||||
|
||||
- **Bot integrations** (e.g. /Frist-Bot for natural-language deadline lookup). Out of scope; if AI chat (`feature-roadmap.md`) ever ships, it lives at `/ask` not `/chat`. Reserve mental separation.
|
||||
- **External-firm participants** (opposing counsel, expert witnesses outside HLC). Big compliance question; not v1 / not v2 / not yet.
|
||||
- **Slack / Teams bridging**. Very tempting and very complex (auth, identity mapping, message format translation). Defer until paliad chat usage justifies.
|
||||
- **Voice messages** (German lawyers love voice notes). Out of scope.
|
||||
|
||||
---
|
||||
|
||||
## §14 What I need from m to lock
|
||||
|
||||
1. **§7.1 adoption sanity-check**: are PAs likely to use this, or is it a half-empty surface?
|
||||
2. **Q1 — surface set**: confirm per-project + DMs, defer per-deadline / per-termin / partner-unit / topical.
|
||||
3. **Q4 — SSE**: confirm SSE direction.
|
||||
4. **Q5 — notification path**: confirm in-app-only v1 (push + email digest deferred).
|
||||
5. **Q19/Q20 — chat_access flag** on `project_teams`, defaulting OFF for `local_counsel`/`expert`.
|
||||
6. **Q22–Q32 — the 11 follow-up questions** in §6.
|
||||
7. **§8 phasing** — single PR or 1A+1B split.
|
||||
|
||||
If m greenlights with "I agree with all your recommendations - go." (the Q4 of t-139 pattern), I lock the design and the head routes the coder shift.
|
||||
|
||||
If m flips any answer, I rev the doc before handover.
|
||||
|
||||
**Inventor parks here.** No coder self-load.
|
||||
|
||||
---
|
||||
|
||||
## §15 Appendix — file/index inventory
|
||||
|
||||
For the implementer's reference; verified live 2026-05-07.
|
||||
|
||||
**Existing tables touched:**
|
||||
- `paliad.project_teams` — new column `chat_access`, backfill external roles.
|
||||
- `paliad.projects` — read-only, source for `path` traversal.
|
||||
- `paliad.users` — read-only, FK target.
|
||||
- `paliad.partner_unit_members`, `paliad.project_partner_units` — read-only, derivation predicate.
|
||||
|
||||
**New tables:**
|
||||
- `paliad.chat_threads`
|
||||
- `paliad.chat_thread_participants`
|
||||
- `paliad.chat_messages`
|
||||
- `paliad.chat_reads`
|
||||
- `paliad.chat_mentions`
|
||||
|
||||
**Existing Go services touched:**
|
||||
- `internal/services/visibility.go` — read-only reuse.
|
||||
- `internal/services/derivation_service.go` — read-only reuse for partner-unit derivation check.
|
||||
- `internal/services/approval_service.go` — auto-post hook on `Submit*`.
|
||||
|
||||
**New Go services:**
|
||||
- `internal/services/chat_service.go`
|
||||
- `internal/services/chat_bus.go` (interface + in-process default)
|
||||
- `internal/services/markdown.go`
|
||||
|
||||
**Existing handlers touched:**
|
||||
- `internal/handlers/handlers.go` — wire chat routes when `Chat != nil`.
|
||||
|
||||
**New handlers:**
|
||||
- `internal/handlers/chat.go`
|
||||
- `internal/handlers/chat_stream.go`
|
||||
|
||||
**Existing frontend touched:**
|
||||
- `frontend/src/components/Sidebar.tsx`
|
||||
- `frontend/src/projects-detail.tsx` (Chat tab)
|
||||
- `frontend/src/client/sidebar.ts` (badge update)
|
||||
- `frontend/src/i18n.ts` (~80 new keys)
|
||||
- `frontend/src/build.ts` (chat bundle)
|
||||
- `frontend/src/styles/global.css`
|
||||
|
||||
**New frontend:**
|
||||
- `frontend/src/chat.tsx`
|
||||
- `frontend/src/client/chat.ts`
|
||||
- `frontend/src/client/markdown.ts` (or shared with views)
|
||||
|
||||
— end of design —
|
||||
955
docs/design-paliadin-2026-05-07.md
Normal file
955
docs/design-paliadin-2026-05-07.md
Normal file
@@ -0,0 +1,955 @@
|
||||
# Design: Paliadin — in-app AI buddy / pet (t-paliad-146)
|
||||
|
||||
**Status:** READY FOR REVIEW (revised 2026-05-07 20:56 — PoC track inserted)
|
||||
**Author:** noether (inventor)
|
||||
**Issue:** [m/paliad#9](https://mgit.msbls.de/m/paliad/issues/9)
|
||||
**Date:** 2026-05-07
|
||||
**Branch:** `mai/noether/inventor-paliadin-in-app`
|
||||
|
||||
> **Revision note (2026-05-07 20:56):** m re-scoped this from "ship to HLC users" → **"PoC for m, monitor usage, expand only if it earns it"**. The original Anthropic-API design in §2–§6 is preserved as the production-v1 spec, but **§0.5 (new) supersedes it for what gets built first**: a tmux-Claude PoC lifted from goldi/mVoice, m-only on his laptop, with monitoring instrumentation as the load-bearing instrument for the expand/kill decision. §7 (Phasing) and §8.5 (Open questions) are revised to reflect the two-stage shape.
|
||||
|
||||
---
|
||||
|
||||
## §0 TL;DR
|
||||
|
||||
A new conversational surface inside paliad: **Paliadin**, a Claude‑backed assistant that answers questions grounded in the user's own paliad data and paliad's domain knowledge. The Paliadin is a long‑lived in‑process Go service, not a per‑session worker spawn — it talks to the Anthropic Messages API directly with **tool use**, where every tool is a thin shim over an existing paliad service (DashboardService, ProjectService, DeadlineService, CourtService, GlossaryService, DeadlineRuleService, AgendaService). RLS / visibility is enforced at the service layer, exactly as it is for the rest of the app, so Paliadin literally cannot see what the caller cannot see.
|
||||
|
||||
Phase 1 surface: **dedicated `/paliadin` page + a sidebar entry under "Übersicht"**, server‑side SSE stream of Anthropic's response (same shape paliad's parked t‑145 chat design specced), session‑only conversation (no DB persistence in v1), 7 read‑only tools, ~30 turns/hour rate limit per user, hard token caps (4 k input + 2 k output per turn), per‑request audit row (no full transcript v1 — store a redacted hash + token counts + tool‑call list).
|
||||
|
||||
**No avatar, no mascot SVG, no proactive onboarding pop‑up in v1.** Just a clean chat panel with the name "Paliadin" in the header. Mascot, drawer mode, persistent threads, write‑tools, and youpc.org case‑law lookup all deferred to Phase 2/3.
|
||||
|
||||
**mlex / `/lex-*` reuse: pattern, not code.** mLex turns out to be a *workspace* (`extractions/`, `analysis/`, `docs/`) — there is no Go/TS code to fork. The `/lex-*` skills are Claude Code instruction docs that drive *Claude itself* against youpc's MCP tools; they cannot be embedded in a paliad Go service. What carries over is the **shape**: tool catalog (search → fetch → cite), system‑prompt voice (precise, citation‑backed, flag uncertainty honestly), and the "every legal claim needs a citation" guardrail. §2.4 maps the carry‑over precisely.
|
||||
|
||||
**Trade‑off flagged up‑front (read §9.1 before approving):** the same adoption‑risk concern that just parked the local‑chat design (t‑paliad‑145, today 17:03) applies here. Paliadin's edge over "open ChatGPT in another tab" is *only* that it sees the user's own data — and that edge collapses if v1 doesn't make the data‑grounding visible (citation chips, tool‑call evidence) and explicit ("Paliadin sees only YOUR projects"). Without those, Paliadin is just a worse Claude. With them, it's the only Claude that can answer "welche Frist ist als nächstes auf dem Müller‑Verfahren?".
|
||||
|
||||
---
|
||||
|
||||
## §0.5 PoC track — m-only, monitored, expandable (REVISED 2026-05-07 20:56)
|
||||
|
||||
**This section supersedes §2–§7 for what actually gets built first.** §2–§6 stay valid as the production‑v1 spec; they're picked up only if the PoC earns expansion.
|
||||
|
||||
### 0.5.1 Why the re-scope
|
||||
|
||||
m's reframing: "Paliadin is mostly for myself now but can be expanded — monitoring use." Two consequences:
|
||||
|
||||
1. **Single user (m) on m's laptop**, not 38 HLC PAs on paliad.de. Multi‑tenant concerns drop. RLS still matters because m's `global_role=global_admin` shouldn't let Paliadin sweep data across projects sloppily, but the cross‑user PII surface goes to zero.
|
||||
2. **The build is for m to feel the UX and decide whether to expand.** That makes monitoring instrumentation load‑bearing — it's the artefact that drives the next decision, not a compliance afterthought. PoC architecture: cheap to ship, expensive to *not* observe.
|
||||
|
||||
### 0.5.2 Architecture: lift goldi/mVoice tmux‑Claude
|
||||
|
||||
Verified pattern in `~/dev/mVoice/server.py:250–380` (and `~/dev/goldi/goldi/brain.py` for the soul/prompt assembly). Working production code today on m's voice stack.
|
||||
|
||||
```
|
||||
┌──────────────────────┐ POST /api/paliadin/turn ┌────────────────────────────┐
|
||||
│ Browser │ ────────────────────────────────▶ │ paliad Go server (laptop) │
|
||||
│ /paliadin chat panel │ │ │
|
||||
│ │ ◀──────── SSE stream ──────────── │ PaliadinService │
|
||||
└──────────────────────┘ (file‑tail of response) │ ├─ ensure tmux session │
|
||||
│ ├─ tmux send-keys -l … │
|
||||
│ ├─ poll/tail │
|
||||
│ │ /tmp/paliadin/{tid} │
|
||||
│ └─ audit row write │
|
||||
└──────────────┬─────────────┘
|
||||
│ tmux send-keys
|
||||
▼
|
||||
┌────────────────────────────┐
|
||||
│ tmux: paliad-paliadin │
|
||||
│ window: claude-paliad │
|
||||
│ $ claude (interactive) │
|
||||
│ w/ system prompt + │
|
||||
│ mcp__supabase__* │
|
||||
│ scoped to paliad.* │
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
Lift verbatim from mVoice:
|
||||
|
||||
- `_ensure_voice_session()` → `_ensure_paliadin_session()`. Same `tmux has-session` / `new-session` / `new-window` / "wait for ❯ prompt" dance.
|
||||
- `tmux_generate(prompt) → response` → same shape, just reads via tail‑f instead of one‑shot poll so we can stream deltas to the SSE consumer (see §0.5.5).
|
||||
- `_reset_paliadin_session()` for `/clear` — surfaced in the chat panel's "New conversation" button.
|
||||
|
||||
### 0.5.3 What we keep from §2–§6 (it's still right)
|
||||
|
||||
| Section | Carry‑over | Why it survives the re‑scope |
|
||||
|---|---|---|
|
||||
| §2.2.1 system prompt template | ✅ ported as the *first message* sent into Claude after `/clear` | The voice + guardrails (no fabrication, cite specifically, can't mutate) are exactly what we want. Just delivered via tmux send-keys instead of API `system:` field. |
|
||||
| §2.5 tool catalog | ✅ but *as instructions, not as wrappers* | Claude already has `mcp__supabase__execute_sql`. The system prompt teaches it the read patterns ("to find m's pending deadlines: `SELECT … FROM paliad.deadlines WHERE status='pending' AND paliad.can_see_project(project_id)`"). Zero Go shim code; ~15 SQL recipes in the prompt. |
|
||||
| §3.2 visibility gate | ✅ | The system prompt *requires* `paliad.can_see_project(project_id)` in every project‑scoped query. Defence in depth: the supabase MCP runs with a service role, so RLS doesn't auto‑gate — the prompt rule is the gate, and we cross‑check via audit (§0.5.6). |
|
||||
| §4 surface placement (`/paliadin` full page + sidebar entry) | ✅ | Same UI shell. |
|
||||
| §4.5 streaming + interruption | ✅ adapted | SSE stream still happens; backing source is `tail -f /tmp/paliadin/{turn_id}.txt` instead of Anthropic's stream events. Choppier but works. |
|
||||
| §4.4 action chips | ⚠ best‑effort | System prompt asks Claude to emit `[#deadline-OPEN:c47bd2]` markers; whether it does so reliably is an *observation* the PoC will surface. |
|
||||
| §5.4 audit table (`paliad.paliadin_turns`) | ✅ | Reused for monitoring (§0.5.6). Added: `pane_lines_captured` so we can debug stream issues. Dropped: `input_tokens`/`output_tokens` (Claude Code doesn't expose these via the tmux interface — derive coarse cost estimate from elapsed time × Claude Code's published rates if we want it later). |
|
||||
|
||||
### 0.5.4 What we drop for the PoC
|
||||
|
||||
| Drop | Reason |
|
||||
|---|---|
|
||||
| Anthropic Messages API client (`anthropic.go`) | Replaced by tmux/Claude. Saves ~400 LoC. |
|
||||
| Per‑user rate limit (`paliadin_rate_limit` table) | Single user. m's own restraint is the rate limit. Re-add at expansion. |
|
||||
| Token caps + history truncation | Claude Code manages its own context window. |
|
||||
| BYO‑AI / OpenAI adapter | Out of scope — m's prior message; punted. |
|
||||
| Multi‑user RLS edge cases (cross‑user PII) | Single‑user; not exercised. |
|
||||
| Compliance disclosure on first use | m → m's own Claude subscription. m has already accepted Anthropic's TOS. |
|
||||
| `/admin/paliadin` cost dashboard | One user; cost is m's monthly Claude bill. |
|
||||
| Most i18n keys | m switches DE/EN naturally; ~6 keys instead of ~25. |
|
||||
|
||||
### 0.5.5 SSE shape adapted to tmux backing
|
||||
|
||||
Same event vocabulary as §4.5.1, fed by a goroutine that tails `/tmp/paliadin/{turn_id}.txt` and emits content_delta events as new bytes arrive. Trade‑offs:
|
||||
|
||||
- **Latency to first token:** ~3–8 s (Claude Code "thinking" before first write). Worse than native API streaming. Mitigation: surface a "Paliadin denkt nach …" placeholder bubble until the first byte arrives.
|
||||
- **No native tool‑call events.** Claude Code does its tool‑use internally; we see only the final text written to the response file. To still surface "ran search_my_deadlines (3 results)" evidence, the system prompt instructs Claude to write a structured trailer block at the end of its response: `\n\n---\n[paliadin-meta]\nused_tools: search_my_deadlines, lookup_court\nrows_seen: 3, 1\n[/paliadin-meta]\n`. Frontend strips that block and renders it as the citation evidence row. Brittle but observable; this is the kind of thing the PoC's monitoring is for.
|
||||
- **Heartbeat:** still emit `event: ping` every 25 s so the SSE connection survives any reverse proxy. (Not strictly needed on `localhost` but keeps the production migration cheap.)
|
||||
|
||||
### 0.5.6 Monitoring instrumentation — the load‑bearing artefact
|
||||
|
||||
Because the whole point of the PoC is "watch m use it", the audit shape is the most important thing in the PoC ship.
|
||||
|
||||
**Migration 057 (PoC variant):**
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.paliadin_turns (
|
||||
turn_id uuid PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id),
|
||||
started_at timestamptz NOT NULL DEFAULT now(),
|
||||
finished_at timestamptz,
|
||||
duration_ms int, -- end - start
|
||||
user_message text, -- FULL prompt (m‑only PoC; redact at expansion)
|
||||
response text, -- FULL response (same)
|
||||
response_tokens int, -- approx via word count × 1.3
|
||||
used_tools text[], -- parsed from [paliadin-meta] trailer
|
||||
rows_seen int[], -- parallel to used_tools
|
||||
chip_count int NOT NULL DEFAULT 0,
|
||||
abandoned boolean NOT NULL DEFAULT false, -- user closed mid-stream
|
||||
page_origin text, -- which paliad page m was on when he asked
|
||||
error_code text, -- 'tmux_unresponsive', 'pane_died', 'user_aborted', NULL on ok
|
||||
classifier_tag text -- coarse self-classification: 'data', 'concept', 'navigation', 'meta', 'other'
|
||||
);
|
||||
|
||||
CREATE INDEX paliadin_turns_started_idx
|
||||
ON paliad.paliadin_turns(started_at DESC);
|
||||
```
|
||||
|
||||
Critical departure from the production design: at PoC scope **we DO store the full prompt + response**. m is the only user, m is m's own compliance officer, and the whole point is to *read what was asked* later. Redaction returns at expansion.
|
||||
|
||||
**`/admin/paliadin` page (PoC variant)** renders:
|
||||
|
||||
- 7‑day rolling turn count + median/p90 duration.
|
||||
- Histogram by `classifier_tag` (so m sees: "60 % of my queries were 'data', 25 % 'concept', 10 % 'navigation', 5 % 'meta'" — that's the use‑case shape).
|
||||
- Top 10 prompts by frequency (textually similar grouping via simple normalised string match — fancy clustering is Phase 1 expansion).
|
||||
- Tool‑use rate (turns where `used_tools` is non-empty / total turns). **Load‑bearing for the expansion decision** — see §0.5.7.
|
||||
- Abandonment rate (`abandoned=true / total`).
|
||||
- Daily usage sparkline.
|
||||
|
||||
The classifier_tag is set by Claude itself in the `[paliadin-meta]` trailer, instructed by the system prompt — same brittleness caveat as the tool‑use evidence.
|
||||
|
||||
### 0.5.7 The expansion gate — what triggers production v1?
|
||||
|
||||
**m decides; this section gives m the metric set he asked for.** Suggested green‑light criteria after 4 weeks:
|
||||
|
||||
1. **Sustained use:** ≥ 3 turns/working‑day average over weeks 3–4.
|
||||
2. **Data‑grounded use:** tool‑use rate ≥ 50 % (otherwise Paliadin is being used like ChatGPT and there's no differentiation argument for the production build).
|
||||
3. **Useful by m's own gut.** No metric beats this; the dashboard helps m frame it but doesn't decide for him.
|
||||
|
||||
**Yellow flag criteria** (interesting but not green):
|
||||
|
||||
- < 1 turn/day → m isn't using it; either kill or rebuild the affordance to be more discoverable.
|
||||
- Tool‑use rate < 30 % → the value isn't in the data grounding; reconsider the whole premise.
|
||||
- High abandonment rate → UX issue (latency? wrong answers? broken streaming?). Investigate before expansion.
|
||||
|
||||
**Kill criteria:**
|
||||
|
||||
- m looks at the dashboard 4 weeks in and shrugs.
|
||||
- Frequent tmux session deaths or `/clear`-too-often patterns suggest the architecture is fighting m. PoC failure ≠ Paliadin failure; might be the tmux pattern's failure.
|
||||
|
||||
### 0.5.8 PoC scope — what gets built
|
||||
|
||||
| Item | In PoC |
|
||||
|---|---|
|
||||
| `internal/services/paliadin/tmux.go` (lifted + adapted from `mVoice/server.py:250–380`) | ✅ |
|
||||
| `internal/services/paliadin/prompt.go` (system prompt template + `[paliadin-meta]` trailer rule) | ✅ |
|
||||
| `internal/services/paliadin/sse.go` (file‑tail → SSE relay) | ✅ |
|
||||
| `internal/handlers/paliadin.go` (POST /turn, GET /stream/{id}, /paliadin shell page, /admin/paliadin dashboard) | ✅ |
|
||||
| Migration 057 — PoC `paliadin_turns` (full prompt + response stored) | ✅ |
|
||||
| `frontend/src/paliadin.tsx` + `client/paliadin.ts` (chat panel, EventSource, chip parser, "Stop"/"New" buttons) | ✅ |
|
||||
| `frontend/src/admin-paliadin.tsx` + `.ts` (the monitoring dashboard) | ✅ |
|
||||
| Sidebar entry under Übersicht with `ICON_SPARKLE` | ✅ |
|
||||
| ~6 i18n keys (DE+EN) | ✅ |
|
||||
| `PALIADIN_TMUX_SESSION` env var (default `paliad-paliadin`), `PALIADIN_RESPONSE_DIR` (default `/tmp/paliadin`), `PALIADIN_ENABLED` (default false on prod, true on m's laptop) | ✅ |
|
||||
| **Hard guard:** if `PALIADIN_ENABLED=false` (paliad.de prod default) the routes are not even registered. PoC stays on m's laptop, full stop. | ✅ |
|
||||
|
||||
**Estimated scope:** ~600–900 LoC. ~1 day of coder work. Same single‑PR pattern as t‑144 / t-145.
|
||||
|
||||
### 0.5.9 What stays unbuilt (production v1, see §2–§6)
|
||||
|
||||
The Anthropic API client, the 7 Go tool shims, the per‑user rate limit, the encrypted‑key BYO‑AI surface, the redacted audit, the multi‑replica SSE bus — all of it. Picked up only if §0.5.7's expansion gate fires.
|
||||
|
||||
**The two‑stage shape protects against the t‑145 pattern:** ship cheap, observe, decide. No 4500‑LoC investment based on m's gut feel about adoption.
|
||||
|
||||
---
|
||||
|
||||
## §1 Premises verified live (2026-05-07)
|
||||
|
||||
Before designing on top, I checked each load‑bearing claim against the running system rather than CLAUDE.md / memory.
|
||||
|
||||
| Claim | Source | Verification |
|
||||
|---|---|---|
|
||||
| **mLex is a workspace, not a code repo** | issue framing "mlex project we could partially reuse" | `~/dev/mLex/` contains only `extractions/`, `analysis/`, `docs/`, plus `CLAUDE.md` + `AGENTS.md`. No `*.go`, no `package.json`, no tools that aren't Claude skills. The "code" is the `/lex-*` skill family in `~/.claude/skills/`, which is instruction docs driving Claude against `mcp__youpc__*` MCP tools. **Carry‑over is shape (system prompt, tool catalog, citation style), not adapters.** |
|
||||
| `/lex-*` skill family | brief reference | `~/.claude/skills/{lex-research,lex-extract,lex-classify,lex-classify-patent,mai-lexy}/SKILL.md`. All five inventoried in §2.4. |
|
||||
| Paliad has no anthropic / claude code | CLAUDE.md `ANTHROPIC_API_KEY` "do not set" row | `grep -ri anthropic ~/dev/paliad/internal ~/dev/paliad/cmd` → only `internal/branding/firm.go` comment unrelated to AI. `go.mod` has no `anthropic-sdk-go` dep. **This task un‑defers the env var; CLAUDE.md row needs updating in the same PR.** |
|
||||
| Paliad has no SSE pattern shipped | substrate scan | `grep -rn 'http.Flusher\|text/event-stream' internal/` returns only references inside the parked t‑145 chat design doc — no live code. We bring our own. |
|
||||
| Paliad and youpc share the same physical Postgres | infra | Both run on `100.99.98.201:11833` (port 11833 = ydb). Paliad's schema is `paliad`; youpc's is `data`. **A future "search UPC case law" tool would be a same‑DB cross‑schema SELECT, not an HTTP hop** — but Phase 1 still excludes case‑law lookup (see §3). |
|
||||
| Visibility is enforced at service layer (not via SET LOCAL auth.uid) | code | `internal/services/visibility.go` defines `visibilityPredicate(alias)` + `visibilityPredicatePositional(alias, idx)`; every project‑scoped query inlines it. Paliadin's tools call existing services, inheriting the predicate. |
|
||||
| `paliad.can_see_project()` is the canonical visibility function in DB (RLS, t‑139) | t‑139 migration 055 | `internal/db/migrations/055_hierarchy_aggregation.up.sql:144` `CREATE OR REPLACE FUNCTION paliad.can_see_project(_project_id uuid)`. Same predicate echoed in `services/visibility.go`. |
|
||||
| Migration tracker is at 56 (`056_user_views`) | t‑144 A1 | `paliad_schema_migrations` row. Next migration is **057**. (t‑145 was parked before its `057_chat` shipped, so 057 is open.) |
|
||||
| t‑paliad‑145 (local chat) was parked today 2026-05-07 17:03 | memory + commit log | Commit `99f08e3` "Merge: t-paliad-145 design doc only — local chat feature PARKED per m's call". The chat SSE substrate that would have been shared is **not** built — Paliadin builds its own minimal stream. |
|
||||
| Sidebar bell pattern (`sidebar-inbox-badge`) is reusable for a chat‑style entry | t‑138 | `frontend/src/components/Sidebar.tsx` — `navItem(href, icon, i18nKey, label, currentPath, badgeID?)` already takes an optional badge id. The same plumbing fits a Paliadin entry. |
|
||||
| Sidebar `ICON_SPARKLE` already exists | UI scan | `frontend/src/components/Sidebar.tsx` defines `ICON_SPARKLE` (a star/sparkle SVG). Free icon for the Paliadin nav item. |
|
||||
| `auth.UserIDFromContext(r.Context())` is the standard handler‑side user lookup | code | `internal/handlers/dashboard.go:31` is the canonical pattern. Paliadin handlers will use it. |
|
||||
| `branding.Name` (default "HLC") is the firm‑name source | t‑paliad‑065 | `internal/branding/firm.go` reads `FIRM_NAME` once at boot. Paliadin's system prompt + greeting must use `branding.Name`, never hardcode "HLC". |
|
||||
| Single web replica on Dokploy today | `docker-compose.yml` | One `web` service. SSE state in‑process is fine v1; multi‑replica migration deferred along with chat. |
|
||||
|
||||
**Doc‑vs‑live conflicts encountered (must be fixed in the implementation PR):**
|
||||
|
||||
1. **CLAUDE.md** still says `ANTHROPIC_API_KEY` is "Reserved for Phase H (AI Frist‑Extraktion) which is deferred per m's 2026-04-16 decision. Do not set." Paliadin un‑defers it. The CLAUDE.md row needs to flip to "Required for Paliadin (read‑only Claude assistant) — set on Dokploy."
|
||||
2. The earlier "do not want anthropic API" decision (memory `b6a11b55…`, 2026-04-16) was specifically about *Frist extraction from documents*. Paliadin is a different surface (interactive read‑only Q&A over already‑structured data). It does not silently revive the parked extraction feature — t‑paliad‑011 stays blocked unless m explicitly un‑parks it too.
|
||||
|
||||
---
|
||||
|
||||
## §2 Sub-design A — LLM architecture, prompt, tool use, mlex/lex reuse
|
||||
|
||||
Answers Q1, Q2, Q3, Q4, Q17, Q18.
|
||||
|
||||
### 2.1 LLM provider (Q1)
|
||||
|
||||
**Recommendation: Anthropic Claude, single provider, accessed directly via the Messages API. Lock to Claude in v1; abstract behind a one‑function interface so future portability is cheap.**
|
||||
|
||||
| Provider | v1? | Why |
|
||||
|---|---|---|
|
||||
| Anthropic Claude (Messages API + tool use) | ✅ | Matches m's "wire into my claude" framing. Tool‑use shape is mature. Streaming via SSE is native. Paliad already has `ANTHROPIC_API_KEY` reserved. |
|
||||
| Mixed (Claude reasoning + smaller routing model) | ❌ | Premature optimisation; for ~30 turns/hour/user we don't need the routing layer. Single‑model latency is fine. |
|
||||
| OpenAI / open weight | ❌ | No HLC compliance review for those vendors; m's Anthropic key is on file. |
|
||||
|
||||
**Model selection within Anthropic:** default to **Claude Sonnet 4.6** (fast, tool‑use‑capable, cheap enough for chat use). Allow override via `PALIADIN_MODEL` env var so we can drop down to Haiku for cost or up to Opus for tricky onboarding sessions without redeploying.
|
||||
|
||||
**Wire shape:** one Go HTTP client (`internal/services/paliadin/anthropic.go`) that POSTs `/v1/messages` with `stream: true`. We do not adopt `github.com/anthropics/anthropic-sdk-go` in v1 — the API surface we use (one streaming POST + tool‑use loop) is small enough that a hand‑rolled client is shorter than wiring the SDK and safer than depending on a Go SDK that has historically broken on minor version bumps in mAi's experience. Keep the option open for Phase 2 if the token‑accounting / structured tool‑use helpers in the SDK become attractive.
|
||||
|
||||
```go
|
||||
// internal/services/paliadin/anthropic.go
|
||||
type AnthropicClient interface {
|
||||
Stream(ctx context.Context, req MessagesRequest, w StreamWriter) (Usage, error)
|
||||
}
|
||||
```
|
||||
|
||||
The interface is the only swap‑point. Switching providers later means a new implementation, not a rewrite.
|
||||
|
||||
### 2.2 System prompt + message shape (Q2)
|
||||
|
||||
**Recommendation: single `system` prompt with paliad context + tool definitions; one persistent prompt across pages (no per‑route system prompts in v1).**
|
||||
|
||||
#### 2.2.1 System prompt (locked, v1)
|
||||
|
||||
The system prompt is computed at process start from `branding.Name`, the user's locale (DE/EN), the user's `display_name`, the current date, and the visible‑project count (a single count, not the project list — keeps the prompt small). Computed *per request*, not per process — but its template is a constant.
|
||||
|
||||
```
|
||||
You are Paliadin, an AI assistant inside {{firm}}'s patent practice
|
||||
platform "Paliad". You help {{display_name}} ({{office}}) answer
|
||||
questions about their own work in Paliad and about UPC / EPO / DPMA
|
||||
patent practice.
|
||||
|
||||
Today is {{today}}. The user's display language is {{language}}; reply
|
||||
in {{language}} unless the user switches mid‑conversation.
|
||||
|
||||
You have read‑only access to the following tools:
|
||||
- whats_on_my_plate — the user's dashboard (deadline / appointment / matter buckets)
|
||||
- list_my_projects — every project the user can see
|
||||
- get_project_detail — full detail of one project (deadlines, appointments, parties, partner units)
|
||||
- search_my_deadlines — filter the user's deadlines by status / date / project
|
||||
- list_my_appointments — the user's upcoming appointments (next 30 days by default)
|
||||
- lookup_court — Paliad's catalog of patent courts (UPC LDs, German LGs/OLGs/BGH, EPO, DPMA, ...)
|
||||
- lookup_glossary_term — Paliad's bilingual patent glossary
|
||||
- lookup_deadline_rule — Paliad's Fristenrechner concept tree (named deadline rules + their triggers)
|
||||
|
||||
Hard rules:
|
||||
1. Never invent facts. If a tool returns nothing, say so. Do not guess
|
||||
case numbers, deadline dates, court names, or party names.
|
||||
2. Every concrete factual claim about the user's work MUST come from a
|
||||
tool call in the current conversation. Cite using "[#deadline-XXXX]",
|
||||
"[#projekt-XXXX]", "[court: Munich LD]", "[glossary: Klageerwiderung]"
|
||||
so the UI can render citation chips.
|
||||
3. You cannot mutate any data. If the user asks you to change something,
|
||||
explain that v1 is read‑only and point them to the right page in
|
||||
Paliad.
|
||||
4. Visibility is enforced before tools return — if your tool call comes
|
||||
back empty, the data either doesn't exist OR the user can't see it.
|
||||
Never disclose the latter; just answer "I couldn't find anything
|
||||
matching that".
|
||||
5. You cannot answer questions about other users' projects, even if the
|
||||
user names them.
|
||||
6. Respect the user's role. If the user has global_role=standard, do not
|
||||
speculate about admin‑only functions.
|
||||
|
||||
Style:
|
||||
- Direct, professional, slightly warm. Lawyer‑adjacent.
|
||||
- Reply in Markdown. Use lists, code blocks, blockquotes.
|
||||
- Cite specifically (case numbers, dates, court names) — never "around
|
||||
the 14th".
|
||||
- When uncertain, flag it. ("I don't see a deadline matching that
|
||||
description on the projects you can access.")
|
||||
- No emojis unless the user uses one first.
|
||||
|
||||
You are NOT:
|
||||
- A code‑writing assistant
|
||||
- A replacement for legal advice
|
||||
- A web search
|
||||
```
|
||||
|
||||
This is ~250 input tokens — well under the budget.
|
||||
|
||||
#### 2.2.2 Per‑message envelope
|
||||
|
||||
The browser POSTs to `/api/paliadin/turn` with `{ session_id, user_message, history }`, where `history` is the prior turns *in the current session only* (session = browser tab; localStorage backs it). The server prepends the system prompt and runs the tool‑use loop.
|
||||
|
||||
#### 2.2.3 Tool use vs RAG‑only (Q2 secondary)
|
||||
|
||||
**Tool use, not RAG.** RAG (vector search over chunks of paliad content) is the wrong shape for this surface — paliad data is highly structured, the most useful answers come from filtered SQL queries (e.g. "all deadlines on my projects with `status='pending'` and `due_date<=now()+7d`"), and a vector store would just paraphrase what an SQL query returns more accurately. Tools give the model the same query power the user has, with hard visibility gates. Phase 2 may add RAG over a small static corpus (HL Patents Style guide, Paliadin docs) if onboarding queries don't get good answers from glossary lookups alone.
|
||||
|
||||
### 2.3 Long‑lived service vs lexy‑style worker spawn (Q4)
|
||||
|
||||
**Recommendation: long‑lived Go service (in‑process) — *not* a per‑session Claude Code worker.**
|
||||
|
||||
| Option | Latency to first token | Cost / turn | Operational shape |
|
||||
|---|---|---|---|
|
||||
| In‑process Go service calling Anthropic API directly | < 1 s (just network + queueing) | Pay only for the model tokens we use | Single binary, single Postgres conn, scales with paliad |
|
||||
| `mai hire paliadin` per session (Claude Code worker) | 5–15 s | Worker startup overhead × N concurrent sessions × Claude Code's own context overhead | Operational footprint of running a worker per active user — dozens of tmux panes, tasks, reports |
|
||||
|
||||
The lexy / cassandra worker pattern works because it's *batch*: classify N judgments, emit JSON, exit. A chat surface needs sub‑second response times across dozens of HLC users in parallel. A Claude‑Code‑per‑session pattern would give each user their own Claude in the loop, with all the tooling and message‑bus scaffolding that implies — wrong scale of abstraction.
|
||||
|
||||
**That said, two things from the worker pattern do carry over:**
|
||||
1. **System‑prompt voice.** The lexy / mai-lexy SKILL.md persona ("Sharp, analytical, direct. Cites provisions and case law naturally. Flags uncertainty honestly.") is the right voice for Paliadin. We borrow it — see §2.2.1.
|
||||
2. **Tool catalog shape.** The lex-research SKILL.md tool list (search → fetch full text → enrich → analyse → cite) maps cleanly onto Paliadin's read tools — see §3.
|
||||
|
||||
### 2.4 mlex / `/lex-*` carry‑over map (Q3, Q18)
|
||||
|
||||
**Inventory result, with the shape‑vs‑code split called out for each:**
|
||||
|
||||
| Skill / asset | What it does | Carry‑over to Paliadin |
|
||||
|---|---|---|
|
||||
| `~/dev/mLex/` (workspace) | `extractions/` (per‑case JSON), `analysis/` (markdown reports), `docs/` (legal references), `extractions/queue.json` | **None as code.** Workspace artifacts are the *output* of the skills — they don't give us anything embeddable. |
|
||||
| `lex-research` skill | UPC case law search → analysis report. Tool catalog: `mcp__supabase__execute_sql`, `mcp__youpc__*`, `mcp__youpc-memory__*`. Output format: structured markdown with citation tables. | **Voice + tool‑catalog shape.** "Search → enrich → analyse → cite" is the Paliadin flow. The skill's output‑format conventions (case number on first mention, division comparison tables) seed the system prompt's style guidance. |
|
||||
| `lex-extract` skill | Read full judgment text → structured holdings / principles / interpretations JSON. | **Not v1.** Phase 2 candidate iff Paliadin gets a `extract_judgment(node_id)` write tool — orthogonal to read‑only v1. |
|
||||
| `lex-classify` skill | Classify judgments against a 47‑leaf taxonomy. | **Not v1.** Same as above — write‑surface, batch‑shaped, irrelevant to interactive Q&A. |
|
||||
| `lex-classify-patent` skill | Classify patents into IPC technology sectors via Anthropic. | **Pattern reference only.** It's already an Anthropic‑backed pipeline, so its prompt structure is a working example we can crib from for the system‑prompt template — but the actual classification target is paliad‑irrelevant. |
|
||||
| `mai-lexy` skill | Lawyer persona that orchestrates the above. "Citation‑backed, flags uncertainty." | **Voice template.** The persona text is the closest thing to a working Paliadin system prompt; §2.2.1 borrows directly from it. |
|
||||
| `claude-api` skill | Anthropic SDK / Messages API patterns + prompt caching guidance. | **Implementation reference for the Go client + caching strategy.** §6.4 picks up its prompt caching guidance. |
|
||||
|
||||
**Anti‑reuse:** the `mcp__youpc__*` MCP tools that `lex-research` uses are designed for an interactive Claude Code session. Paliadin's tools must instead be Go service calls — same data shape, different transport. Don't try to embed an MCP client in a paliad Go process; rebuild the same SQL queries against the same Postgres directly.
|
||||
|
||||
### 2.5 Tool catalog v1 (Q17)
|
||||
|
||||
Seven read‑only tools. Each is a thin Go shim around an existing service; each enforces visibility through that service's existing `visibilityPredicate`.
|
||||
|
||||
| Tool name | Backing service / method | Inputs | Output (truncated to fit budget) |
|
||||
|---|---|---|---|
|
||||
| `whats_on_my_plate` | `DashboardService.Get(userID)` | none | `{deadline_summary, appointment_summary, matter_summary, upcoming_deadlines[≤10], upcoming_appointments[≤10], recent_activity[≤10]}` |
|
||||
| `list_my_projects` | `ProjectService.ListVisible(userID, filter)` | optional `{status, kind}` | `[{id, kind, label, status, parent_id, path}]` paged 25 |
|
||||
| `get_project_detail` | `ProjectService.Get(userID, id) + DeadlineService.ListByProject + AppointmentService.ListByProject + PartyService.ListByProject + DerivationService.AttachedUnits` | `{project_id}` | `{project, deadlines[≤25], appointments[≤25], parties[≤10], partner_units[≤5]}` — 503 if user can't see it (LLM gets a clean "not found", same response as truly missing) |
|
||||
| `search_my_deadlines` | new helper on `DeadlineService` (reuses `visibilityPredicate`) | `{q?, status?, project_id?, due_after?, due_before?, limit≤25}` | `[{id, title, due_date, status, project_label, court}]` |
|
||||
| `list_my_appointments` | new helper on `AppointmentService` | `{from, to, project_id?}` | `[{id, title, start_at, end_at, location, project_label}]` |
|
||||
| `lookup_court` | `CourtService.Search(q)` (firm‑wide; no visibility filter — courts are reference data) | `{q}` | `[{slug, name, country, kind, address, vacation_periods[≤4]}]` truncated 10 |
|
||||
| `lookup_glossary_term` | static JSON loader (`internal/handlers/glossary.go` data) | `{q, lang?}` | `[{de, en, definition, category}]` top 5 |
|
||||
| `lookup_deadline_rule` | `DeadlineRuleService.SearchConcept(q)` | `{q}` | `[{rule_code, concept_label, trigger_event, deadline_text, legal_source}]` top 5 |
|
||||
|
||||
**Bumped out of v1 (Phase 2 candidates):**
|
||||
|
||||
- `list_my_pending_approvals` (the inbox bell payload) — useful but adds RLS surface; let v1 stabilise first.
|
||||
- `search_youpc_case_law` — m's framing example, but cross‑schema → bigger blast radius. Phase 2 once Paliadin proves its weight on paliad‑internal data.
|
||||
- `search_my_audit_log` — high signal but PII heavy.
|
||||
- `compute_frist` — would invoke the existing `DeadlineCalculator`. Useful but the user can already do this on `/tools/fristenrechner`; defer until we see queries that actually want it.
|
||||
- All write tools (`create_deadline`, `attach_partner_unit`, etc.) — Phase 3 minimum, with hard confirmation gate (see §6).
|
||||
|
||||
### 2.6 The tool‑use loop (Q2 tertiary)
|
||||
|
||||
Standard Anthropic tool‑use loop:
|
||||
|
||||
```
|
||||
1. Build messages = [system, ...history, user_message]
|
||||
2. POST /v1/messages with tools=[...catalog]
|
||||
3. Stream assistant reply chunks → relay to client SSE
|
||||
4. If stop_reason == "tool_use":
|
||||
for each tool_use block:
|
||||
execute tool(input) on the matching Go service
|
||||
emit tool_result block back into messages
|
||||
goto 2 (with the same stream/SSE connection)
|
||||
5. If stop_reason == "end_turn": close stream
|
||||
```
|
||||
|
||||
**Hard cap on the loop:** ≤ 5 tool‑call rounds per turn. After 5 rounds without `end_turn`, force‑close with "Sorry, I got stuck — try rephrasing." Hitting the cap is a UI red flag we want to see in audit (see §6.3).
|
||||
|
||||
---
|
||||
|
||||
## §3 Sub-design B — Data access, RLS, PII
|
||||
|
||||
Answers Q5, Q6, Q7.
|
||||
|
||||
### 3.1 Knowledge sources for v1 (Q5)
|
||||
|
||||
**Recommendation: paliad‑internal data + paliad's static reference data ONLY. youpc.org case law deferred to Phase 2.**
|
||||
|
||||
| Source | v1 | Reason |
|
||||
|---|---|---|
|
||||
| **Per‑user paliad data** (deadlines, appointments, projects, parties, partner units, attached units) | ✅ | The whole point of Paliadin. Visibility enforced via `visibilityPredicate` (every backing service already does this; tool inherits it). |
|
||||
| **Static reference data** in paliad (court catalog t‑122, glossary, deadline rules, Fristenrechner concept tree) | ✅ | Firm‑wide, no per‑user gating, low blast radius. |
|
||||
| **UPC case law** (youpc Postgres `data.judgments`, `data.judgment_markdown_content`) | ❌ Phase 2 | Cross‑schema SELECT is technically trivial (same Postgres) but: (a) inflates the v1 surface; (b) brings in 1700+ judgments → scaling RAG/full‑text question; (c) m's framing called out research as a *use case*, not a v1 must‑have. Ship paliad‑internal Q&A first; layer case‑law on once the substrate is proven. |
|
||||
| **HL Patents Style guide / Paliad onboarding docs** | ❌ Phase 2 | No internal corpus exists yet; would need docs‑authoring + indexing. The `lookup_glossary_term` tool already covers the most common onboarding question shape ("was bedeutet X?"). |
|
||||
| **External web search** | ❌ | Out of scope; Paliadin is a *grounded* assistant, not a web surfer. m can use the regular Claude for that. |
|
||||
|
||||
**Ranking inside the v1 set (when Paliadin has to choose):**
|
||||
|
||||
1. User‑data tools first when the question references "my", "the case", "the deadline", or names a project / case number that resolves.
|
||||
2. Static reference next when the question is conceptual ("what's a Klageerwiderung?", "which court is the Munich LD?").
|
||||
3. Combine when both apply ("when is my Klageerwiderung due?" → `lookup_deadline_rule` for the rule + `search_my_deadlines` for the user's instance).
|
||||
|
||||
The system prompt names tools in this priority order; the model's tool‑selection follows.
|
||||
|
||||
### 3.2 Auth / visibility boundary (Q6)
|
||||
|
||||
**The gate:** every backing service already runs `visibilityPredicate(alias)` against the caller's UUID. The Paliadin tool shim is a 5‑line wrapper that calls the service with `userID` derived from `auth.UserIDFromContext(r.Context())` at the SSE handler boundary. There is no service‑role escape — the shim simply has no other UUID to pass in.
|
||||
|
||||
**Belt‑and‑braces:** every tool result is inspected for `project_id` columns; for each distinct `project_id`, the shim asserts `paliad.can_see_project(_project_id)` returns `true`. (Defence‑in‑depth: catches any future service‑layer regression where someone forgets the predicate. Costs one extra cheap function call per tool turn; cheap.)
|
||||
|
||||
**The "tell, don't disclose" rule (§2.2.1 hard‑rule 4):** if the user names a project they cannot see, the tool returns `{error: "not found"}` — same response as a project that doesn't exist. The system prompt instructs the model to say "I couldn't find anything matching that" without distinguishing the two cases. This is the same rule the t‑144 ViewService already applies.
|
||||
|
||||
**Cross‑user PII in tool outputs:** tool outputs may legitimately contain other users' display names (e.g. project teams, deadline assignees). These are visible to the caller through the regular UI already, so disclosing them through Paliadin is no worse. We do NOT redact them.
|
||||
|
||||
**Approval / partner‑unit derivation:** `get_project_detail` returns the derived team (per t‑139 `DerivationService.AttachedUnits`). Same predicate as the rest of the app.
|
||||
|
||||
### 3.3 PII handling, retention, encryption (Q7)
|
||||
|
||||
**v1 stance: minimum viable persistence, maximum auditability of the access pattern.**
|
||||
|
||||
| Data | Stored where | Retention | Encryption | Notes |
|
||||
|---|---|---|---|---|
|
||||
| Conversation history (the actual messages) | **Browser localStorage only.** Cleared on browser data wipe / reload‑with‑fresh‑session. | Session only | n/a | Phase 2: opt‑in DB persistence with retention controls. |
|
||||
| Per‑request audit row | New `paliad.paliadin_turns` table | Forever (matches audit‑log pattern; soft‑delete only) | At‑rest by Postgres / Supabase volume encryption | Stores: `turn_id, user_id, started_at, finished_at, model, input_tokens, output_tokens, tool_calls (jsonb of tool names + arg hashes — NOT arg values), prompt_hash (sha256 of redacted user message), error_code`. **No prompt body, no completion body.** |
|
||||
| Tool‑call inputs (e.g. project_id arguments) | Hashed (sha256) into the audit row's `tool_calls` jsonb | Forever | n/a | The hash is enough to detect "this user kept asking about project X" patterns without storing the readable id. |
|
||||
| Anthropic API request/response bodies | **Not stored.** Streamed through the Go service straight to the SSE writer. | n/a | TLS in flight | Anthropic's own retention is governed by the org's API contract — pulling Paliad onto an existing HLC enterprise key would inherit that. |
|
||||
|
||||
**Why this shape:**
|
||||
|
||||
- **Compliance‑lite v1.** HLC's compliance team has not yet weighed in on AI‑mediated PII (memory says the Phase H decision was "we don't want anthropic API… for a while"). Storing the full transcript opens a retention/disclosure question we don't need to answer to ship Paliadin's MVP. The audit‑metadata row is enough to demonstrate: (a) who used it, (b) how often, (c) what tools they triggered, (d) cost.
|
||||
- **Phase 2 transcript persistence** would add a `paliadin_messages` table (turn_id FK, role, content, redact_marks jsonb) and a per‑user setting "keep my history". Default off.
|
||||
- **Why no PII redaction in the user prompt?** v1 is opt‑in (the user typed the prompt). Redacting client names / case numbers in the audit hash would defeat the point; we redact by *not storing the prompt*, only its hash.
|
||||
|
||||
**The Anthropic side:** if HLC's enterprise contract forbids vendor‑side retention, the Go client must set `metadata: {user_id: "<hash>"}` and ensure the API call is on an org with zero‑retention guarantees. **Open question for m: which Anthropic key are we using — m's personal key (existing `ANTHROPIC_API_KEY` precedent in mAi/youpcms) or a new HLC enterprise key?** This is the single biggest compliance question; see §9.2.
|
||||
|
||||
---
|
||||
|
||||
## §4 Sub-design C — UX
|
||||
|
||||
Answers Q8, Q9, Q10, Q11, Q12.
|
||||
|
||||
### 4.1 Surface placement (Q8)
|
||||
|
||||
**Recommendation (counter to brief): start with a dedicated `/paliadin` full‑page route + a sidebar entry under the "Übersicht" group. Defer the right‑drawer to Phase 2.**
|
||||
|
||||
| Option | v1? | Why |
|
||||
|---|---|---|
|
||||
| **`/paliadin` full page** + sidebar entry | ✅ | Lowest CSS risk; mobile‑responsive for free (paliad's existing breakpoints work); easy to test via Playwright; matches paliad's "every feature is a top‑level page" pattern; no z‑index / overlay debugging. |
|
||||
| Right‑drawer slide‑out from any page | ❌ Phase 2 | Pretty, matches m's "panel docked into UI" framing — but adds: drawer toggle wiring on all 30 pages, scroll‑lock interaction, focus management, mobile small‑screen fallback. Not worth the v1 surface area. Phase 2 wraps the same `/paliadin` UI in a slide‑out container. |
|
||||
| Floating bottom‑right bubble | ❌ | Clippy comparison is *visual*, not *positional*. A floating overlay on every page collides with the BottomNav on mobile (already 5/5 slots) and the inbox bell on desktop. |
|
||||
| Page‑embedded panel on `/paliadin` only | — | This *is* the v1 recommendation, just framed differently. |
|
||||
|
||||
**Sidebar entry:**
|
||||
|
||||
```
|
||||
Übersicht
|
||||
Start
|
||||
Agenda
|
||||
Inbox 🛎
|
||||
Paliadin ✨ ← new, ICON_SPARKLE
|
||||
```
|
||||
|
||||
Group placement under Übersicht (not under Tools or Wissen) because Paliadin is conversation about *the user's work*, not a knowledge tool.
|
||||
|
||||
**Mobile:** Paliadin is reachable via the sidebar drawer (existing mobile pattern). No BottomNav slot — those are full and the ranking (Start / Projekte / + / Agenda / Menü) is more important than a chat shortcut for v1.
|
||||
|
||||
### 4.2 Avatar / personality (Q9)
|
||||
|
||||
**Recommendation: no avatar SVG in v1. Just a chat panel with the name "Paliadin" in the header. Mascot is Phase 2.**
|
||||
|
||||
Why:
|
||||
|
||||
- Mascot design is a real design exercise (3–4 iterations to get something that doesn't read as kitsch in a law firm). Not inventor's call to bash one out in a v1 ship.
|
||||
- The brand cue (lime‑green `#c6f41c` accent) is enough to make Paliadin feel like part of paliad without a character.
|
||||
- Paliadin's *personality* lives in the system prompt (§2.2.1), not in pixels. Voice carries the buddy framing; mascot makes it visual but isn't load‑bearing.
|
||||
|
||||
What we ship in v1 instead:
|
||||
|
||||
- Header: "✨ Paliadin" (sparkle icon + name) above the chat panel.
|
||||
- Empty‑state prompt: "Was kann ich für dich tun?" (DE) / "How can I help?" (EN).
|
||||
- One‑line tagline under the header: "Ich kenne deine Akten und Paliads Wissensbasis." (DE) / "I know your matters and Paliad's knowledge base." (EN). This is the *only* v1 affordance that explicitly tells the user "I see your data" — load‑bearing for the differentiation argument in §0/§9.1.
|
||||
|
||||
**Phase 2 mascot brief (for when m greenlights it):** small SVG, friendly, lime‑green primary, no eyes‑darting / animated‑on‑idle (creepy), modular pose set so it can react to "thinking" / "found it" / "stuck" without being an MMORPG pet.
|
||||
|
||||
### 4.3 Onboarding hint (Q10)
|
||||
|
||||
**Recommendation: silent‑until‑invoked. No proactive pop‑up, no first‑run modal, no toast.**
|
||||
|
||||
Why:
|
||||
|
||||
- Paliad already has a polished onboarding flow (t‑paliad‑034). Adding a Paliadin pop‑up on top would be the kind of "surprise the user" affordance that erodes trust the first time it misfires.
|
||||
- The empty‑state inside `/paliadin` itself is the right onboarding surface: 3 starter‑prompt buttons rendered when the chat is empty.
|
||||
|
||||
**Three starter prompts (DE primary):**
|
||||
|
||||
1. "Was steht heute an?" → triggers `whats_on_my_plate`
|
||||
2. "Welche Fristen sind diese Woche fällig?" → triggers `search_my_deadlines` with `due_before=now()+7d`
|
||||
3. "Erkläre mir Klageerwiderung." → triggers `lookup_glossary_term` + `lookup_deadline_rule`
|
||||
|
||||
EN equivalents: "What's on my plate?" / "Which deadlines are due this week?" / "Explain Klageerwiderung."
|
||||
|
||||
Picking one from the row sends it as if the user typed it. Keeps the surface zero‑weight when ignored.
|
||||
|
||||
**Phase 2 candidate:** post‑onboarding email / inbox card "Paliadin ist live, frag ihn was deine Daten dir sagen." Driven by the existing reminder/email substrate. Out of v1 scope.
|
||||
|
||||
### 4.4 Action chips in responses (Q11)
|
||||
|
||||
**Recommendation: action chips parsed from a simple inline syntax in the model's reply, rendered client‑side, NOT a tool the model invokes.**
|
||||
|
||||
Why simple syntax over a tool: tool invocations cost a round‑trip; we want the model to "suggest" an action without paying for an extra tool turn. The model emits a structured marker in its prose; the frontend client parses it and renders a chip below the bubble.
|
||||
|
||||
**Marker format:**
|
||||
|
||||
```
|
||||
[#deadline-OPEN:c47bd2]
|
||||
[#projekt-OPEN:slug-x]
|
||||
[#frist-OPEN:c47bd2]
|
||||
[#termin-OPEN:abc123]
|
||||
[chip:nav:/projects/abc-123] (for arbitrary navigation)
|
||||
[chip:filter:status=pending&due=this_week] (for parameterised inbox links)
|
||||
```
|
||||
|
||||
The system prompt teaches the model to emit chips when navigation or filtering would help the user act on the answer. Each marker resolves to one chip, rendered as:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ Frist 16.05.2026 fällt morgen. │
|
||||
│ [Frist öffnen] [Akte ansehen] │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Client parser** (`frontend/src/client/paliadin.ts`): regex over the streamed text, replaces marker with a button. Buttons are real `<a>` elements (Cmd‑click works, keyboard works), styled like the existing `.entity-table` row chips.
|
||||
|
||||
**Why not let the model embed full URLs?** Two reasons:
|
||||
1. URLs change (we renamed `/akten` → `/projekte` mid‑project). Markers are stable; we resolve them at render time.
|
||||
2. Hallucinated URLs are real risk. If the model can only emit a marker tied to an id we *know* it just retrieved, the chip can't navigate to a fake page.
|
||||
|
||||
### 4.5 Streaming + interruption (Q12)
|
||||
|
||||
**Recommendation: SSE stream from `/api/paliadin/stream`, client EventSource, user‑initiated abort via "Stop" button.**
|
||||
|
||||
#### 4.5.1 Stream shape
|
||||
|
||||
Mirrors Anthropic's native streaming events, adapted for our SSE consumer:
|
||||
|
||||
```
|
||||
event: meta
|
||||
data: {"turn_id":"01H…","model":"claude-sonnet-4-6"}
|
||||
|
||||
event: content_delta
|
||||
data: {"text":"Auf der Akte Müller…"}
|
||||
|
||||
event: tool_call
|
||||
data: {"name":"search_my_deadlines","args_hash":"…","status":"running"}
|
||||
|
||||
event: tool_result
|
||||
data: {"name":"search_my_deadlines","status":"ok","summary":"3 results"}
|
||||
|
||||
event: content_delta
|
||||
data: {"text":"… ist die Klageerwiderung am 16.05. fällig."}
|
||||
|
||||
event: chip
|
||||
data: {"kind":"deadline","action":"open","id":"c47bd2"}
|
||||
|
||||
event: end
|
||||
data: {"input_tokens":342,"output_tokens":88,"tool_calls":1}
|
||||
|
||||
# heartbeat every 25 s to keep Traefik from reaping
|
||||
event: ping
|
||||
data: {}
|
||||
```
|
||||
|
||||
The `tool_call` / `tool_result` events are visible in the UI as small dim "ran search_my_deadlines (3 results)" lines under the bubble — the **citation evidence** that distinguishes Paliadin from a generic chatbot. (Direct quote from the §0 framing: "the differentiation collapses if v1 doesn't make the data‑grounding visible.")
|
||||
|
||||
#### 4.5.2 Interruption
|
||||
|
||||
- "Stop" button next to the input. Click → `EventSource.close()` + `fetch('/api/paliadin/stream/{turn_id}/abort', {method:'POST'})`.
|
||||
- Server abort closes the upstream Anthropic request via context cancellation.
|
||||
- Stopped turns still write an audit row with `error_code='user_aborted'` so we see how often users hit it.
|
||||
|
||||
#### 4.5.3 Reconnect
|
||||
|
||||
Same Last‑Event‑ID resume pattern the t‑145 chat design specced. Server keeps the in‑flight stream buffered for 30 s after disconnect; reconnect within that window replays missed events. After 30 s, the turn is considered done — reconnect arrives at the start of a fresh session.
|
||||
|
||||
---
|
||||
|
||||
## §5 Sub-design D — Token budget, cost, audit
|
||||
|
||||
Answers Q13, Q14, Q15, Q16.
|
||||
|
||||
### 5.1 Per‑request token cap (Q13)
|
||||
|
||||
**Recommendation: `max_input_tokens=4000` (model's view of input including system + history + tool defs + user msg) and `max_tokens=2000` (model's max output) — same as brief. Hard‑fail above; soft‑truncate history below.**
|
||||
|
||||
Rationale:
|
||||
|
||||
- A typical paliad data tool result is < 500 tokens (truncated lists, capped at 25 rows). Even with system prompt (~250) + tool defs (~600) + 5 prior turns (~600 each on average) the input stays well under 4 k.
|
||||
- If the conversation runs long (~8+ turns), the client/server soft‑truncates history (drops oldest user/assistant pairs first) before sending. The user sees a "Earlier in this conversation, we discussed X (truncated)" pseudo‑system message. Cleaner than failing the turn.
|
||||
- Hard cap at 6 k input tokens — over that, refuse the turn with "Conversation too long, start a new one." Defends against jailbreak attempts that try to balloon the prompt.
|
||||
|
||||
**Cost math at Sonnet 4.6 per‑turn typical (3 k input, 1 k output):** ~$0.012/turn. At 30 turns/hour/user × 38 onboarded HLC users × 5 working hours/day = ~5 700 turns/day = **~$70/day worst case**. Realistic load is probably 10× lower. Phase 2: prompt caching (§5.4) drops it further.
|
||||
|
||||
### 5.2 Conversation history persistence (Q14)
|
||||
|
||||
**Recommendation: session‑only in v1. Persistent threads in Phase 2.**
|
||||
|
||||
| Option | v1? | Why |
|
||||
|---|---|---|
|
||||
| Session‑only (browser localStorage, cleared on tab close + Sign Out) | ✅ | Zero schema. Zero retention question. Aligns with §3.3 "minimum viable persistence." Lets us ship paliadin without compliance review of stored transcripts. |
|
||||
| Persistent threads (DB‑stored, named) | ❌ Phase 2 | Real schema (`paliadin_threads`, `paliadin_messages`), retention policy, cross‑device sync, "delete my history" UX, possibly opt‑in toggle. None of which is needed to validate "is Paliadin actually useful". |
|
||||
|
||||
**Edge case: page reload during a conversation.** localStorage persists the history *for that browser tab*. Closing and reopening the tab restores. Closing the browser & reopening also restores. Sign‑out clears. Multi‑device = different histories. We're explicit about this in the panel header: "Conversation lives in this browser only" tooltip.
|
||||
|
||||
**Why opt for slightly worse UX over the easy schema work:** the t‑paliad‑145 chat just got parked over an *adoption*‑risk concern, not a schema concern. Paliadin should ship the smallest possible footprint that proves usefulness. Persistent threads can be a "you asked for this" Phase 2.
|
||||
|
||||
### 5.3 Rate limit per user (Q15)
|
||||
|
||||
**Recommendation: 30 turns/hour/user (slightly tighter than the brief's 50). Plus a global ceiling of 1 000 turns/hour across the firm. Both configurable.**
|
||||
|
||||
Per‑user 30/hour because:
|
||||
|
||||
- 30/hour ≈ one turn every two minutes during sustained use. That's heavy use. A reasonable user asks 3–5 questions in a session.
|
||||
- Soft hint at 25 ("you've used 25 of 30 messages this hour"), hard block at 30 with retry‑after.
|
||||
- Lower than 50 to give us a safety margin for runaway cost in week 1; we can raise it once we see real usage.
|
||||
|
||||
Global 1 000/hour ceiling because:
|
||||
|
||||
- Global cap = circuit breaker against the long tail (a script that sends 1000 turns/hour from one user we missed in the per‑user cap, or a developer bug).
|
||||
- 1 000 turns × ~$0.012 = $12/hour worst case = $288/day. We tolerate that for a day; we'd notice and tune.
|
||||
|
||||
**Storage:** simple Postgres `paliad.paliadin_rate_limit` table with `(user_id, hour_bucket, turn_count)` upserted on every turn start. No Redis, no extra dependency. Fast at this scale.
|
||||
|
||||
**Admin override:** global_admin can lift their own cap (they typically test things). Surface this in the audit row, not in a CLI.
|
||||
|
||||
### 5.4 Audit + logging (Q16)
|
||||
|
||||
**Recommendation: every turn writes a metadata‑only row to `paliad.paliadin_turns`. Full transcripts are NOT stored in v1. Tool‑call args are hashed. Anthropic vendor side is governed by org‑level retention.**
|
||||
|
||||
#### 5.4.1 Schema (migration 057)
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.paliadin_turns (
|
||||
turn_id uuid PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id),
|
||||
session_id text NOT NULL, -- browser session, opaque
|
||||
started_at timestamptz NOT NULL DEFAULT now(),
|
||||
finished_at timestamptz, -- NULL until end‑of‑turn
|
||||
model text NOT NULL, -- e.g. 'claude-sonnet-4-6'
|
||||
input_tokens int, -- from Anthropic usage block
|
||||
output_tokens int,
|
||||
tool_calls jsonb NOT NULL DEFAULT '[]', -- [{name, args_hash, status, latency_ms}]
|
||||
prompt_hash text, -- sha256 of user_message after PII redaction (best effort)
|
||||
response_hash text, -- sha256 of full response (citation only, not stored)
|
||||
chip_count int NOT NULL DEFAULT 0,
|
||||
error_code text, -- NULL on success; 'user_aborted', 'rate_limited', 'token_cap', 'tool_loop_cap', 'upstream_error'
|
||||
estimated_cost_usd numeric(10, 6) -- for ops dashboards
|
||||
);
|
||||
|
||||
CREATE INDEX paliadin_turns_user_started_idx
|
||||
ON paliad.paliadin_turns(user_id, started_at DESC);
|
||||
CREATE INDEX paliadin_turns_started_idx
|
||||
ON paliad.paliadin_turns(started_at DESC);
|
||||
|
||||
ALTER TABLE paliad.paliadin_turns ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- User sees their own; global_admin sees all.
|
||||
CREATE POLICY paliadin_turns_select
|
||||
ON paliad.paliadin_turns FOR SELECT
|
||||
USING (
|
||||
user_id = auth.uid()
|
||||
OR EXISTS (SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
|
||||
);
|
||||
|
||||
-- Service-role (paliad backend) writes; no user‑direct INSERT.
|
||||
-- (Paliad uses service-role conn, so policies on writes are inert,
|
||||
-- but we still ENABLE RLS so future direct‑auth callers are gated.)
|
||||
```
|
||||
|
||||
Rate‑limit table also lives in this migration:
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.paliadin_rate_limit (
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id),
|
||||
hour_bucket timestamptz NOT NULL,
|
||||
turn_count int NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (user_id, hour_bucket)
|
||||
);
|
||||
```
|
||||
|
||||
#### 5.4.2 What we DON'T store (v1)
|
||||
|
||||
- The user's actual prompt text. Only `prompt_hash`.
|
||||
- The model's actual response text. Only `response_hash`.
|
||||
- The tool inputs. Only `tool_calls[].args_hash`.
|
||||
|
||||
**Phase 2 transcript persistence** unlocks all three — deliberately separate migration so the compliance review sits at *that* boundary.
|
||||
|
||||
#### 5.4.3 Vendor retention
|
||||
|
||||
The Anthropic side is governed by the org‑level contract. **Open question for m (§9.2):** does HLC have an enterprise / zero‑retention agreement, or are we using m's personal key (matches existing `ANTHROPIC_API_KEY` precedent in mAi/youpcms)? The answer changes whether v1 needs a "data sent to Anthropic" disclosure on first use.
|
||||
|
||||
#### 5.4.4 Prompt caching (Phase 2)
|
||||
|
||||
The Anthropic API supports prompt caching for repeated system prompts + tool definitions. Our system prompt + 7 tool defs is ~850 tokens — perfect cache target. Phase 2: enable cache_control on the system block; cuts input cost by ~90% on repeat turns within the 5‑minute cache window. Skip in v1 to keep the client minimal; pick up after the API surface stabilises.
|
||||
|
||||
---
|
||||
|
||||
## §6 Schema, endpoints, files
|
||||
|
||||
### 6.1 New endpoints
|
||||
|
||||
| Method | Path | Purpose | Auth |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/api/paliadin/turn` | Initiate a turn — assigns `turn_id`, opens SSE | logged‑in (302 to /login otherwise) |
|
||||
| `GET` | `/api/paliadin/stream/{turn_id}` | SSE stream of the turn's response (mostly invoked from the same `POST` to keep the connection live; separate GET supports reconnect) | logged‑in |
|
||||
| `POST` | `/api/paliadin/stream/{turn_id}/abort` | User cancels mid‑turn | logged‑in, must own the turn |
|
||||
| `GET` | `/api/paliadin/limits` | Returns `{used_this_hour, hourly_cap, global_cap, global_used}` | logged‑in |
|
||||
| `GET` | `/paliadin` | The page shell (server‑renders the panel + initial empty state) | logged‑in |
|
||||
| `GET` | `/admin/paliadin` | Per‑user usage / cost dashboard | global_admin |
|
||||
|
||||
The `POST /api/paliadin/turn` returns `{turn_id, sse_url}`; the client opens an `EventSource` on `sse_url`. Two‑step keeps the POST cheap for telemetry / audit row creation, while the long‑lived stream lives on a GET that's safe to retry / resume.
|
||||
|
||||
### 6.2 New / extended services
|
||||
|
||||
| File | Status | Purpose |
|
||||
|---|---|---|
|
||||
| `internal/services/paliadin/service.go` | NEW | The orchestrator: run loop, history truncation, rate‑limit check, audit‑row writer |
|
||||
| `internal/services/paliadin/anthropic.go` | NEW | Hand‑rolled Messages API client (POST `/v1/messages`, stream parser) |
|
||||
| `internal/services/paliadin/tools.go` | NEW | Tool catalog declaration + dispatch into existing services |
|
||||
| `internal/services/paliadin/prompt.go` | NEW | System prompt template + per‑turn assembly |
|
||||
| `internal/handlers/paliadin.go` | NEW | HTTP / SSE handlers |
|
||||
| `internal/services/deadline_service.go` | extend | Add `SearchVisible(userID, q, status, projectID, dueAfter, dueBefore, limit)` (currently search is only on the global Fristenrechner matview) |
|
||||
| `internal/services/appointment_service.go` | extend | Add `ListVisibleInWindow(userID, from, to, projectID)` |
|
||||
| `internal/services/glossary_service.go` | NEW (or refactor of glossary handler data load) | A real service so the tool can call it; today it lives inline in the handler |
|
||||
|
||||
### 6.3 Frontend
|
||||
|
||||
| File | Status | Purpose |
|
||||
|---|---|---|
|
||||
| `frontend/src/paliadin.tsx` | NEW | Page shell |
|
||||
| `frontend/src/client/paliadin.ts` | NEW | Chat panel, EventSource, history serialise to localStorage, chip parser, "Stop" button |
|
||||
| `frontend/src/styles/global.css` | extend | New CSS section: `.paliadin-panel`, `.paliadin-bubble`, `.paliadin-bubble--user/--assistant/--tool`, `.paliadin-chip`, `.paliadin-input`, `.paliadin-meta` |
|
||||
| `frontend/src/components/Sidebar.tsx` | extend | Add Paliadin navItem to the Übersicht group with `ICON_SPARKLE` |
|
||||
| `frontend/src/i18n-keys.ts` | extend | ~25 new keys: `paliadin.title`, `paliadin.tagline`, `paliadin.starter.*`, `paliadin.empty`, `paliadin.input.placeholder`, `paliadin.stop`, `paliadin.rate_limited`, `paliadin.error.*` |
|
||||
|
||||
### 6.4 Migration 057
|
||||
|
||||
```
|
||||
057_paliadin.up.sql:
|
||||
- paliad.paliadin_turns (audit row, RLS, indexes)
|
||||
- paliad.paliadin_rate_limit (counter table, PK on user+hour)
|
||||
- GRANTs: service-role full, anon read disallowed by RLS
|
||||
057_paliadin.down.sql: drop both tables.
|
||||
```
|
||||
|
||||
### 6.5 Env vars (add to CLAUDE.md table)
|
||||
|
||||
| Variable | Required | Purpose |
|
||||
|---|---|---|
|
||||
| `ANTHROPIC_API_KEY` | for Paliadin | Anthropic Messages API key. **Replaces** the "do not set" row that referred to the parked Phase H. Without it, `/paliadin` returns 503 (server still boots; the rest of paliad keeps working). |
|
||||
| `PALIADIN_MODEL` | optional (default `claude-sonnet-4-6`) | Override model for tuning / fallback to Haiku for cost or Opus for accuracy without redeploying. |
|
||||
| `PALIADIN_HOURLY_CAP` | optional (default `30`) | Per‑user turn cap per hour. |
|
||||
| `PALIADIN_GLOBAL_HOURLY_CAP` | optional (default `1000`) | Firm‑wide turn cap per hour. |
|
||||
| `PALIADIN_MAX_INPUT_TOKENS` | optional (default `4000`) | Soft cap; over this we truncate history. |
|
||||
| `PALIADIN_MAX_OUTPUT_TOKENS` | optional (default `2000`) | Hard cap; passed straight to Anthropic. |
|
||||
|
||||
The Service must boot **without** `ANTHROPIC_API_KEY` (return 503 on `/paliadin*` routes; rest of paliad keeps working). Same pattern as `DATABASE_URL` and `CALDAV_ENCRYPTION_KEY`.
|
||||
|
||||
---
|
||||
|
||||
## §7 Sub-design E — Phasing (REVISED 2026-05-07 20:56)
|
||||
|
||||
Answers Q19, Q20. Two‑stage shape after m's re‑scope:
|
||||
|
||||
- **Phase 0 (PoC, m‑only):** §0.5 is the spec. ~600–900 LoC, ~1 day. Ships first.
|
||||
- **Phase 1 (production v1, multi‑user):** §7.1 below. Picked up only if §0.5.7's expansion gate fires.
|
||||
- **Phase 2 / 3:** unchanged.
|
||||
|
||||
### 7.1 Phase 1 (production v1) — confirmed scope, GATED on PoC success
|
||||
|
||||
**Single coherent slice that proves the value proposition end‑to‑end.**
|
||||
|
||||
| Item | In v1 |
|
||||
|---|---|
|
||||
| `/paliadin` page + sidebar entry under Übersicht | ✅ |
|
||||
| Migration 057 (`paliadin_turns` + `paliadin_rate_limit`) | ✅ |
|
||||
| Anthropic client (hand‑rolled, streaming) | ✅ |
|
||||
| 7 read‑only tools | ✅ |
|
||||
| System prompt with `branding.Name` + visibility rules | ✅ |
|
||||
| SSE stream with `meta`/`content_delta`/`tool_call`/`tool_result`/`chip`/`end`/`ping` events | ✅ |
|
||||
| Citation chips (parsed from inline markers) | ✅ |
|
||||
| Rate limiting (per‑user + global) | ✅ |
|
||||
| Audit row per turn (metadata only, no transcript) | ✅ |
|
||||
| Session‑only history (browser localStorage) | ✅ |
|
||||
| 3 starter prompts in DE+EN | ✅ |
|
||||
| Token caps + soft history truncation | ✅ |
|
||||
| `/admin/paliadin` cost dashboard (global_admin only) | ✅ |
|
||||
| ~25 i18n keys (DE+EN) | ✅ |
|
||||
| Mobile responsiveness (uses sidebar drawer like every other page) | ✅ |
|
||||
| CLAUDE.md update flipping the `ANTHROPIC_API_KEY` row | ✅ |
|
||||
|
||||
**Estimated scope:** ~3 500–4 500 LoC for the bundled v1 ship. Comparable to t‑144 (Custom Views) and t‑145's would‑have‑been chat slice.
|
||||
|
||||
**Single PR or split?** Recommend **single PR** for v1. The Anthropic client + tool dispatch + handler + frontend panel are too tightly coupled to ship one without the others — every component is on the critical path of "demonstrate Paliadin actually works". Splitting buys nothing review‑wise (no reviewer can validate "Anthropic client works" without "the tool dispatch that exercises it"). Use the same single‑PR pattern as t‑144 A1+A2 in retrospect.
|
||||
|
||||
### 7.2 Phase 2 candidates (post‑v1, prioritised)
|
||||
|
||||
In rough order of value:
|
||||
|
||||
1. **Persistent threads** + per‑user "keep my history" toggle. Adds `paliadin_threads` + `paliadin_messages` tables, retention policy, cross‑device sync. Compliance review attaches here, not to v1.
|
||||
2. **Prompt caching** for system prompt + tool defs. ~90 % input‑cost reduction on repeat turns. Pure server‑side change.
|
||||
3. **`search_youpc_case_law` tool.** Cross‑schema SELECT into `data.judgments` + `data.judgment_markdown_content`. Returns case number, division, date, headnote, top 3 holdings. The "research assistant" use case from m's framing.
|
||||
4. **Right‑drawer mode.** Wrap the `/paliadin` panel in a slide‑out container; toggle on every page from a header button.
|
||||
5. **Mascot SVG** + idle / thinking / found‑it pose set. Real visual design pass.
|
||||
6. **Onboarding tip** — post‑onboarding inbox card or one‑time toast on first dashboard visit after Paliadin lands.
|
||||
7. **`list_my_pending_approvals` tool.** Wraps inbox bell payload.
|
||||
8. **Voice input / output.** Web Speech API (paliad already has the substrate from the no‑Voice‑v1 t‑paliad‑042 PWA).
|
||||
|
||||
### 7.3 Phase 3 candidates (validate first)
|
||||
|
||||
- **Write tools.** `create_deadline`, `create_appointment`, `attach_partner_unit`, `add_party`. Each behind a hard confirmation gate ("Paliadin will create a deadline 16.05. on project X — confirm? [Yes / No]"). Audit‑row marks these as mutating turns. Heavy compliance question; not Phase 2.
|
||||
- **Per‑deadline / per‑termin micro‑threads.** Long‑lived per‑entity Q&A. Plumbing collision with the (parked) chat design — re‑evaluate when chat un‑parks.
|
||||
- **Proactive Paliadin.** Push tips when the user hits a known confused state ("You've been on /tools/fristenrechner for 8 minutes — want me to walk you through it?"). Powerful, but creepy if poorly tuned.
|
||||
- **Compliance‑aware redaction layer.** Strip client names from the prompt before it leaves the building, swap stable hashes back in client‑side. Big project; only sensible if HLC compliance forbids vendor‑side PII.
|
||||
|
||||
---
|
||||
|
||||
## §8 Risks, mitigations, open questions
|
||||
|
||||
### 8.1 Adoption risk (the §0 callout, expanded)
|
||||
|
||||
**The risk:** Paliadin competes with three things HLC already has:
|
||||
1. The user's own Claude / ChatGPT in another tab (for general patent‑practice questions).
|
||||
2. "Ask a colleague on Teams" (for paliad‑specific questions about how to use the app).
|
||||
3. Just clicking around the UI (for "what's on my plate today").
|
||||
|
||||
Paliadin's edge over (1) is data grounding. Edge over (2) is 24/7 + privacy. Edge over (3) is conversational discovery and answering one‑shot natural‑language queries that the structured UI doesn't expose.
|
||||
|
||||
**The risk realised:** if v1 doesn't make the data‑grounding visible (citation chips, tool‑call evidence under each bubble, the tagline "I see your data"), users default to ChatGPT for everything, and Paliadin becomes a ghost feature that ate 3 weeks of build. Same pattern that just parked t‑paliad‑145.
|
||||
|
||||
**Mitigations baked into v1:**
|
||||
|
||||
- **Tool‑call evidence visible** in every bubble. The user *sees* "ran search_my_deadlines (3 results)" — instant differentiation from a generic chatbot.
|
||||
- **Citation chips** make answers actionable, not just informative.
|
||||
- **Tagline + empty state** explicitly say "I see your projects."
|
||||
- **Three starter prompts** demonstrate the data‑grounding immediately on first use.
|
||||
|
||||
**Mitigations m should consider before approving:**
|
||||
|
||||
- **Sanity‑check with two PA colleagues** before locking v1 scope. Same recommendation t‑145 got. If two PAs say "I'd just open Claude in another tab", the scope shifts toward making the data‑grounding *more* prominent (e.g. ship "Paliadin sees only your data" as a persistent banner above the input, not a tooltip) before shipping at all.
|
||||
- **Soft launch + telemetry.** v1's audit row gives us cheap measurement of: (a) total turns/day, (b) turns per user, (c) tool‑call frequency (low = Paliadin is being used like ChatGPT, defeating the differentiation). Watch for two weeks; if tool‑calls/turn < 1.5 average, the feature isn't doing what we shipped it for and Phase 2 priorities change.
|
||||
|
||||
### 8.2 Compliance / vendor‑data risk
|
||||
|
||||
**The risk:** sending client names + case content to Anthropic's API may not be sanctioned by HLC IT/compliance. The 2026‑04‑16 "we don't want anthropic API… for a while" decision (memory `b6a11b55…`) was about *Frist extraction from documents*; Paliadin is conversational, but the data envelope sent to Anthropic still contains PII whenever a tool returns a project name.
|
||||
|
||||
**Mitigations:**
|
||||
|
||||
- **HLC enterprise key** (vs m's personal key) if available — gives org‑level retention + DPA coverage.
|
||||
- **Zero‑retention configuration** on the Anthropic call (`metadata: {user_id: "<hash>"}`, `cache_control` only on the system block, no `eval` enrolment).
|
||||
- **First‑use disclosure** in the panel: "Your messages and the data Paliadin retrieves on your behalf are sent to Anthropic. [Learn more]" — load‑bearing and required if the legal answer to §9.2 is "personal key, not enterprise".
|
||||
- **Phase 2 hardening:** server‑side redaction layer that swaps client names → stable hashes before the API call, restores them client‑side after. Big project; only sensible if compliance forbids vendor‑side PII.
|
||||
|
||||
### 8.3 Rate‑limit / runaway‑cost risk
|
||||
|
||||
**The risk:** a user (or a bug) loops fast enough to drain budget before alarms fire.
|
||||
|
||||
**Mitigations:**
|
||||
|
||||
- Per‑user 30/hour + global 1 000/hour caps (§5.3). Both surfaced on `/admin/paliadin`.
|
||||
- Per‑turn token cap (§5.1).
|
||||
- Per‑turn tool‑loop cap (≤ 5 rounds, §2.6).
|
||||
- Audit row written *before* the upstream call so a rate‑limit‑evading bug still leaves traces.
|
||||
- `PALIADIN_HOURLY_CAP` / `PALIADIN_GLOBAL_HOURLY_CAP` are env‑var configurable so we can tighten without a deploy.
|
||||
|
||||
### 8.4 Hallucination risk (model invents a deadline)
|
||||
|
||||
**The risk:** the model fabricates a deadline date / case number that doesn't exist in the user's data.
|
||||
|
||||
**Mitigations:**
|
||||
|
||||
- Hard rule in system prompt: "Every concrete factual claim about the user's work MUST come from a tool call in the current conversation."
|
||||
- Citation markers tied to tool‑result IDs only. Marker `#deadline-OPEN:c47bd2` resolves only if the id was returned by a real tool call this turn (frontend validates).
|
||||
- Tool‑call‑evidence visibility: the user can see that a tool ran and what it returned. Hallucination becomes obvious because the chip says "0 results" but the bubble claims a deadline.
|
||||
- **Phase 2:** server‑side post‑hoc validation that checks every cited id against the tool‑result set; reject the message and retry if the model invented one.
|
||||
|
||||
### 8.5 Open questions for m (REVISED 2026-05-07 20:56 for the PoC scope)
|
||||
|
||||
The re‑scope mooted most of the original questions. Tracking which are still active vs deferred:
|
||||
|
||||
**PoC‑relevant (decide before coder shift):**
|
||||
|
||||
1. **Q‑PoC‑1:** What goes in the system prompt's read‑recipe set? §0.5.3 says ~15 SQL recipes; the actual list is design‑level. Recommendation: start with `whats_on_my_plate`, `list_my_projects`, `get_project_detail`, `search_my_deadlines_by_status`, `lookup_court_by_name`, `lookup_glossary_term`, `lookup_deadline_rule_by_concept`. Same shape as §2.5, just expressed as SQL recipes Claude follows.
|
||||
2. **Q‑PoC‑2:** Does m want the response file (`/tmp/paliadin/{turn_id}.txt`) cleaned up after each turn (mVoice does), or kept around for offline review? Recommendation: keep them in `~/.paliad-poc/turns/{date}/` with a 30‑day janitor — m said "monitoring use", and raw response artefacts are great for post‑hoc analysis.
|
||||
3. **Q‑PoC‑3:** Should `/admin/paliadin` be reachable from the sidebar, or hidden behind a direct URL? Recommendation: sidebar entry (`/admin/paliadin`) since m is the only user and the only audience for the dashboard.
|
||||
4. **Q‑PoC‑4:** classifier_tag — let Claude self‑tag in the trailer block, or post‑process server‑side from the prompt text? Recommendation: Claude self‑tags (cheap and richer); we add a server‑side fallback if Claude's tag is missing.
|
||||
5. **Q‑PoC‑5:** Expansion gate threshold — §0.5.7 suggests "≥3 turns/working‑day, ≥50 % tool‑use rate, 4 weeks." Tighten? Loosen? Pure feel.
|
||||
|
||||
**Production‑v1‑deferred (only relevant if §0.5.7 expansion gate fires):**
|
||||
|
||||
- Q‑A (Anthropic key) — moot for PoC; Claude Code handles it.
|
||||
- Q‑B (first‑use disclosure) — moot; m‑only.
|
||||
- Q‑C (default model) — moot; Claude Code defaults.
|
||||
- Q‑D (sanity‑check with 2 PAs before locking scope) — *becomes* the expansion‑gate question. Don't ask the PAs about Paliadin until the PoC has earned the conversation.
|
||||
- Q‑E (surface confirmation) — kept; PoC ships the same `/paliadin` page so the question is already answered.
|
||||
- Q‑F (mascot) — Phase 2 still.
|
||||
- Q‑G (starter prompts) — relevant for the PoC empty state; recommendation unchanged.
|
||||
- Q‑H (`branding.Name` in prompt) — relevant for PoC; recommendation: yes, but the firm‑agnostic prompt can read "Paliad" instead of `branding.Name` since m's PoC is on his laptop and the firm‑name distinction adds no value for a single user.
|
||||
- Q‑I (rate limit) — moot for PoC.
|
||||
- Q‑J (youpc case‑law tool) — interesting at PoC since m himself does case‑law research; promoted to **Q‑PoC‑6**: include `lookup_youpc_case` as one of the system‑prompt SQL recipes from day one? Cross‑schema SELECT into `data.judgments` is technically trivial, and m is exactly the user who'd benefit. Recommendation: yes, include it.
|
||||
- Q‑K (audit retention) — PoC stores everything forever (one user, no compliance pressure).
|
||||
- Q‑L (default language) — moot; m's locale is set, Claude reads it.
|
||||
|
||||
---
|
||||
|
||||
## §9 What this design does NOT cover (deliberately)
|
||||
|
||||
- **The implementation.** This is a design pass; coder shift writes the code. No commits beyond this doc on the inventor branch.
|
||||
- **Mascot visual design.** Phase 2; deserves its own design pass (and probably a designer's eye, not an inventor's).
|
||||
- **HL Patents Style guide ingestion.** Out of v1; Phase 2 RAG candidate.
|
||||
- **Voice input / TTS output.** Phase 2.
|
||||
- **Multi‑user collaboration (e.g. share a paliadin chat).** Out of scope; users have their own visibility, and joint chat is a chat‑feature shape (parked).
|
||||
- **Offline mode.** Paliadin is online‑only by definition (it calls Anthropic). The PWA service worker should NOT cache `/paliadin` responses.
|
||||
- **The renaming question.** "Paliadin" is m's name. Locked.
|
||||
|
||||
---
|
||||
|
||||
## §10 Recommended implementer
|
||||
|
||||
Same recommendation as t‑145: **noether, or a fresh coder Sonnet that has noether's substrate context.** NOT cronus per the standing memory directive on paliad.
|
||||
|
||||
Why:
|
||||
|
||||
- Substrate touchpoints are the same set the chat design covered: `visibilityPredicate`, `auth.UserIDFromContext`, sidebar entry pattern, migration tracker discipline, Dashboard/Agenda/Project/Deadline service interfaces. noether built half of these; the other half noether mapped during the chat design pass.
|
||||
- Anthropic Go client is novel in paliad but is small and well‑specified by §6.2 + the `claude-api` skill.
|
||||
- Front‑end SSE consumer + chip parser is a one‑page TS file.
|
||||
|
||||
---
|
||||
|
||||
## §11 End of design — STOP
|
||||
|
||||
This is the inventor deliverable. Per the role brief: **STOP after design. Do not begin implementation. Do not load `/mai-coder`.** Wait for m's explicit go/no‑go on the questions in §8.5 before any coder shift starts.
|
||||
|
||||
The completion signal sent to head will use the literal phrase **"DESIGN READY FOR REVIEW"** so the head's gate fires.
|
||||
841
docs/design-profession-vs-project-role-2026-05-07.md
Normal file
841
docs/design-profession-vs-project-role-2026-05-07.md
Normal file
@@ -0,0 +1,841 @@
|
||||
# Profession vs project responsibility — split `project_teams.role`
|
||||
|
||||
Inventor: kepler · Date: 2026-05-07 · Issue: m/paliad#6 (t-paliad-148)
|
||||
Branch: `mai/kepler/inventor-profession-vs`
|
||||
Status: **READY FOR REVIEW** — awaits m's go on the 12 open questions before any coder shift.
|
||||
|
||||
---
|
||||
|
||||
## §0 TL;DR
|
||||
|
||||
`paliad.project_teams.role` does two jobs at once: it labels a user's
|
||||
**career tier at the firm** (PA, Associate, Of Counsel) **and** it labels
|
||||
their **responsibility on this project** (Lead, Observer). m's bug report
|
||||
(2026-05-06): you don't redefine someone's profession when staffing them
|
||||
on a matter. The team-add dropdown should let you pick *responsibility*
|
||||
only; profession should come from the firm record.
|
||||
|
||||
This design splits the column into two:
|
||||
|
||||
1. **`paliad.users.profession`** — firm-wide career tier
|
||||
(`partner | of_counsel | associate | senior_pa | pa | paralegal | NULL`).
|
||||
Drives the t-138 approval ladder. NULL means "no firm tier" (external).
|
||||
2. **`paliad.project_teams.responsibility`** — per-project responsibility
|
||||
(`lead | member | observer | external`). Default `member`. Drives a
|
||||
simple gate — `lead` and `member` open authority; `observer` and
|
||||
`external` close it. Replaces the team-add dropdown values m
|
||||
complained about.
|
||||
|
||||
Approval ladder migrates from `project_teams.role` to a tuple
|
||||
**(profession, responsibility)** evaluated as: *level = profession_level
|
||||
if responsibility ∈ {lead, member} else 0*. Policy grammar
|
||||
(`required_role` single-value) stays unchanged from t-138.
|
||||
|
||||
Single migration 057. Backfills profession from the highest legacy
|
||||
`project_teams.role` per user. project_teams.role kept as a deprecated
|
||||
shadow column for one release, dropped in 058.
|
||||
|
||||
---
|
||||
|
||||
## §1 Problem & locked premises
|
||||
|
||||
### What m said (2026-05-06)
|
||||
|
||||
> "The Role should not be definable there. Whether a team member is PA
|
||||
> or Associate etc is not defined when adding existing members. Roles
|
||||
> for the project, maybe. But not the 'profession'."
|
||||
|
||||
### Locked decisions (m, 2026-05-06)
|
||||
|
||||
- **Profession is not redefined per project.** It comes from the user's
|
||||
firm-level record.
|
||||
- **Project-level role is meaningful.** Stays editable per project, but
|
||||
with a smaller value set focused on responsibility.
|
||||
- **Approval ladder must keep working** — t-138 just shipped. Whatever
|
||||
drives the ladder must still drive it.
|
||||
|
||||
### Three-axis principle (held since t-051)
|
||||
|
||||
> "Firm roles ≠ project roles ≠ tool roles."
|
||||
|
||||
Today's surfaces:
|
||||
|
||||
| Axis | Today's column | Today's values |
|
||||
|---|---|---|
|
||||
| Firm — display | `paliad.users.job_title` (free text) | "Counsel Knowledge Lawyer", "Junior Associate"… |
|
||||
| Firm — tool admin | `paliad.users.global_role` | `standard \| global_admin` |
|
||||
| Firm — partner-unit slot | `paliad.partner_unit_members.unit_role` | `lead \| attorney \| senior_pa \| pa \| paralegal` (per-unit, not firm-wide) |
|
||||
| Project — staffing | `paliad.project_teams.role` | **mixed: profession + responsibility** ← the bug |
|
||||
|
||||
The split adds a fourth, missing axis — **firm career tier** as a
|
||||
structured value that drives approval authority. It does not collapse
|
||||
job_title (free-text label is still useful) or unit_role (per-unit slot
|
||||
is still useful — see t-139 §11).
|
||||
|
||||
---
|
||||
|
||||
## §2 Verified live state (2026-05-07)
|
||||
|
||||
Probed `ydb` (paliad schema, port 11833) and current branch:
|
||||
|
||||
- `paliad.project_teams` CHECK on `role`: `lead, associate, pa,
|
||||
of_counsel, local_counsel, expert, observer, senior_pa` (from
|
||||
migration 054).
|
||||
- `paliad.project_teams` row count: **3 rows, all `role='lead'`**.
|
||||
- `paliad.partner_unit_members` CHECK on `unit_role`: `lead, attorney,
|
||||
senior_pa, pa, paralegal`. Row count: **20 rows, all
|
||||
`unit_role='attorney'`** (the default — nobody has overridden it
|
||||
yet).
|
||||
- `paliad.users` columns include `job_title` (text NULL), `global_role`
|
||||
(text NOT NULL DEFAULT 'standard'). No profession column.
|
||||
- `paliad.approval_role_level(text) RETURNS int IMMUTABLE` — strict
|
||||
ladder helper, used in 4 SQL sites in `approval_service.go`.
|
||||
- `paliad.approval_role_from_unit_role(text) RETURNS text IMMUTABLE` —
|
||||
bridges unit_role → ladder values for derived authority.
|
||||
- t-138 (commit `e2e1381`) and t-139 phases 1–3 all merged on `main`.
|
||||
Migration tracker at 56 (next is **057**).
|
||||
|
||||
**Implication of the live data**: backfill is essentially trivial. Three
|
||||
project_teams rows. Twenty partner_unit_members rows. The risk surface
|
||||
of the migration is the SQL rewiring, not the data movement.
|
||||
|
||||
### Inventory of references to migrate
|
||||
|
||||
| File | Site | What it reads |
|
||||
|---|---|---|
|
||||
| `internal/services/team_service.go:53,93,103,122,159` | INSERT/SELECT/validate | `pt.role` for read+write of project membership. |
|
||||
| `internal/services/derivation_service.go:118,127,314,383,403` | EffectiveProjectRole + manage gate | `pt.role` for ancestor walk + `RoleLead` for project-lead-can-manage check. |
|
||||
| `internal/services/approval_service.go:103,411,751,854` | canApprove + ListPending + bell badge + deadlock check | `paliad.approval_role_level(pt.role)` — 4 SQL sites. |
|
||||
| `internal/services/reminder_service.go:317,330` | reminder digest filter | `pt.role = 'lead'` — project-responsibility check. |
|
||||
| `internal/services/deadline_service.go:695` | legacy authority check | `pt.role IN ('admin', 'lead')` — `'admin'` is dead since t-051; this is half-broken already. |
|
||||
| `internal/services/project_service.go:486` | creator-as-lead INSERT | `INSERT … role='lead'`. |
|
||||
| `internal/services/approval_levels.go:70` | Go-side `levelOf()` | Mirror of SQL ladder. Must change with the SQL. |
|
||||
| `internal/services/project_service.go:57-66` | `RoleLead` etc. constants | Used in 14 places across services. |
|
||||
| `internal/db/migrations/055_hierarchy_aggregation.up.sql:84,92` | can_see_project body | `pt.role = 'lead'`. |
|
||||
| `frontend/src/projects-detail.tsx:124-132` | team-add dropdown | The 7 mixed options m complained about. |
|
||||
| `frontend/src/client/projects-detail.ts:1665,1720,1772,1856` | render + read of role | i18n `projects.team.role.*`. |
|
||||
| `frontend/src/client/i18n.ts:1139-1145, 2949-2955` | role translations | DE+EN keys. |
|
||||
|
||||
This is a wide rewrite but it's mechanical — the column boundary is
|
||||
clean, the call sites are narrow, and the live data is small.
|
||||
|
||||
---
|
||||
|
||||
## §3 Sub-design A — Profession axis (Q1, Q2, Q3, Q12)
|
||||
|
||||
### Q1 — Where does profession live? Recommendation: **(a) new `paliad.users.profession` column**
|
||||
|
||||
Three candidates from issue body:
|
||||
|
||||
(a) New `paliad.users.profession` column (firm-wide, simple).
|
||||
(b) Reuse `paliad.partner_unit_members.unit_role` (already added by
|
||||
t-139 Phase 2; only set when the user is in a unit).
|
||||
(c) New separate `paliad.user_professions(user_id, profession,
|
||||
valid_from)` table for history.
|
||||
|
||||
**Recommend (a).**
|
||||
|
||||
Rationale:
|
||||
|
||||
- (b) breaks for users not in a partner unit. Today: 31 users, ~20 in
|
||||
units. The other 11 (admins, externals, future hires) have no
|
||||
unit_role. Profession needs to be defined for everyone or the
|
||||
approval ladder gets gappy.
|
||||
- (b) creates ambiguity if a user joins multiple units with different
|
||||
unit_roles (legal under the t-139 schema). Picking "the highest" or
|
||||
"the first" hides the data confusion. A firm-wide column is
|
||||
unambiguous by construction.
|
||||
- (b) re-couples the per-unit axis to the firm-wide axis. t-139 §11
|
||||
explicitly kept `unit_role` per-unit to preserve the three-axis
|
||||
principle. Reusing it for firm-wide authority breaks that invariant.
|
||||
- (c) overengineered for v1. Profession changes when an HR promotion
|
||||
fires — no audit, no time-slice. If history becomes a requirement,
|
||||
add the table later (out-of-scope per issue body).
|
||||
|
||||
(a) is one column, one CHECK, no joins on the read path, no per-unit
|
||||
ambiguity. Drop-in replacement for the slot in the approval ladder.
|
||||
|
||||
**Schema:**
|
||||
|
||||
```sql
|
||||
ALTER TABLE paliad.users
|
||||
ADD COLUMN profession text NULL
|
||||
CHECK (profession IS NULL OR profession IN (
|
||||
'partner', 'of_counsel', 'associate',
|
||||
'senior_pa', 'pa', 'paralegal'
|
||||
));
|
||||
|
||||
CREATE INDEX users_profession_idx ON paliad.users (profession);
|
||||
```
|
||||
|
||||
NULL is a valid value: it means "no firm career tier" (e.g. external
|
||||
local counsel signed up via invitation, or admin accounts that aren't
|
||||
practicing lawyers). NULL → ladder level 0 → ineligible to approve.
|
||||
|
||||
`job_title` (free-text display) and `global_role` (tool admin) remain
|
||||
untouched. Three firm-axis columns:
|
||||
|
||||
| Column | Purpose | Approval-relevant? |
|
||||
|---|---|---|
|
||||
| `users.job_title` | Free-text display label ("Counsel Knowledge Lawyer") | No |
|
||||
| `users.profession` | Structured career tier (drives ladder) | **Yes** |
|
||||
| `users.global_role` | Tool admin gate (`standard \| global_admin`) | Override only |
|
||||
|
||||
### Q2 — Profession values Recommendation: **`partner | of_counsel | associate | senior_pa | pa | paralegal`** (NULL = external)
|
||||
|
||||
The t-138 ladder defined 5 active levels. Today they are mixed
|
||||
project-level + profession-level:
|
||||
|
||||
| Today | Level | Belongs on which axis? |
|
||||
|---|---|---|
|
||||
| `lead` | 5 | **project responsibility** (the lawyer in charge of THIS matter) |
|
||||
| `of_counsel` | 4 | profession |
|
||||
| `associate` | 3 | profession |
|
||||
| `senior_pa` | 2 | profession |
|
||||
| `pa` | 1 | profession |
|
||||
| `local_counsel` | 0 | project responsibility (`external`) |
|
||||
| `expert` | 0 | project responsibility (`external`) |
|
||||
| `observer` | 0 | project responsibility |
|
||||
|
||||
Removing the project-axis values from the ladder leaves 4 profession
|
||||
tiers (of_counsel, associate, senior_pa, pa). But "lead" was implicitly
|
||||
"a partner is leading this matter", so profession needs **`partner`**
|
||||
at level 5 to preserve the ceiling.
|
||||
|
||||
Add **`paralegal`** at level 0 (mirrors `partner_unit_members.unit_role`
|
||||
which already has it; current `approval_role_from_unit_role` already
|
||||
maps it to `observer`/level 0).
|
||||
|
||||
Final enum (6 values + NULL):
|
||||
|
||||
| Profession | Ladder level | Notes |
|
||||
|---|---|---|
|
||||
| `partner` | 5 | Replaces the project-level `lead` as the firm-tier ceiling. |
|
||||
| `of_counsel` | 4 | unchanged |
|
||||
| `associate` | 3 | unchanged; default for new firm members |
|
||||
| `senior_pa` | 2 | unchanged |
|
||||
| `pa` | 1 | unchanged |
|
||||
| `paralegal` | 0 | New — present in unit_role; ineligible to approve. |
|
||||
| NULL | 0 | "External / no firm tier." Approval-ineligible. |
|
||||
|
||||
**Why not include `senior_associate`, `counsel`, `trainee`, etc.** that
|
||||
appear in the existing `i18n.team.role.*` keys (free-text user
|
||||
directory): those values don't change the ladder level
|
||||
(senior_associate = associate tier; counsel = of_counsel tier; trainee
|
||||
= ineligible). Adding them inflates the enum without adding
|
||||
authority-relevant distinctions. They live in `job_title` (free text)
|
||||
where they belong. If HR later needs structured senior_associate vs
|
||||
associate, the migration is one CHECK alter; the call sites are zero
|
||||
because the ladder only sees levels.
|
||||
|
||||
**External roles (`local_counsel`, `expert`)** in the issue body are
|
||||
project-only labels — they describe what a person *is on this matter*,
|
||||
not a firm career tier. They land in §4 as `responsibility='external'`.
|
||||
Their profession is NULL.
|
||||
|
||||
### Q3 — Onboarding flow Recommendation: **required-on-invite, default suggestion = `associate`, admin-editable later**
|
||||
|
||||
Three options:
|
||||
|
||||
- (i) Auto-default to `associate` with admin-edit later.
|
||||
- (ii) Required-on-invite: inviting colleague picks profession.
|
||||
- (iii) User picks own profession on first login.
|
||||
|
||||
**Recommend (ii) with default = `associate`.**
|
||||
|
||||
Rationale:
|
||||
|
||||
- (i) recreates the bug m just complained about, in slow motion. Every
|
||||
PA invited gets shown as "associate" until someone notices and
|
||||
edits. The whole point of this work is "profession is real, set it
|
||||
honestly".
|
||||
- (iii) is wrong: you don't redefine your own firm tier; HR/the firm
|
||||
does. Self-pick also breaks the audit (anyone could promote
|
||||
themselves).
|
||||
- (ii) is one extra `<select>` on the existing invite form (already
|
||||
rebuilt for t-paliad-141). The inviter is a colleague — they know
|
||||
whether they're inviting a PA or an associate.
|
||||
- Default `associate` makes the most common case one click. PAs and
|
||||
Of Counsels are explicit choices, not silent demotions.
|
||||
|
||||
**External invitees** (local counsel, expert): inviter sets
|
||||
`responsibility='external'` on the project; profession defaults to
|
||||
NULL (not asked) — the form hides the profession field when
|
||||
responsibility=external. Admin can fill profession later if the
|
||||
external collaborator becomes a paliad-tracked firm member.
|
||||
|
||||
### Q12 — Bulk add / invite-new flow Recommendation: **profession capture on invite; NULL allowed; admin-edits later**
|
||||
|
||||
The existing invite-new-user flow (`team-user-invite-btn` →
|
||||
`/api/team/invite-new`) accepts email + display_name today. After this
|
||||
change:
|
||||
|
||||
- Invite form gains a profession `<select>` (6 values + "Extern (keine
|
||||
Profession)").
|
||||
- Default selected: `associate`.
|
||||
- Submit creates `paliad.users` row with the picked profession +
|
||||
`paliad.project_teams` row with the picked responsibility (default
|
||||
`member`).
|
||||
- "Extern" sets `responsibility='external'` on the project_teams row,
|
||||
profession=NULL on users.
|
||||
|
||||
**No bulk-add UI exists today** — out of scope. If/when one ships, it
|
||||
inherits the same per-row profession field.
|
||||
|
||||
**Admin re-edit**: `/admin/team` page (already shipped t-paliad-050)
|
||||
gets a Profession column with inline-edit dropdown. Position next to
|
||||
job_title. No last-admin guard needed (profession is not a tool gate).
|
||||
|
||||
---
|
||||
|
||||
## §4 Sub-design B — Project responsibility axis (Q4, Q5, Q6, Q11)
|
||||
|
||||
### Q4 — Value set Recommendation: **`lead | member | observer | external`**
|
||||
|
||||
Issue body suggests this set. Locking it.
|
||||
|
||||
| Value | Meaning | Edit/approve authority |
|
||||
|---|---|---|
|
||||
| `lead` | The responsible lawyer/partner for this matter. Also has project-management permissions (manage settings, attach partner units — already wired in derivation_service.go). | Full (subject to profession ceiling). |
|
||||
| `member` | Staffed on this matter at their profession's level. | Full (subject to profession ceiling). |
|
||||
| `observer` | Read-only awareness; no edit/approve authority. | None. |
|
||||
| `external` | Non-firm collaborator (local counsel, expert witness). May edit per project policy, but cannot satisfy the firm-tier approval ladder. | Edit yes, approve no. |
|
||||
|
||||
**Why not collapse `external` into `observer`**: externals can actively
|
||||
write (local counsel files briefs, experts upload reports). Observers
|
||||
can't. The two are distinct read/write profiles and conflating them
|
||||
loses information.
|
||||
|
||||
**Why not add `pa-on-this-project` etc.** — that's profession × project,
|
||||
exactly what we just split. Once split, never re-mix.
|
||||
|
||||
### Q5 — Default value Recommendation: **`member`**
|
||||
|
||||
m's intuition is right. Lock.
|
||||
|
||||
The team-add form's default selection is `member`. The project creator
|
||||
is auto-added as `lead` (already coded in `project_service.go:486` —
|
||||
just rename the inserted column from `role='lead'` to
|
||||
`responsibility='lead'`).
|
||||
|
||||
### Q6 — Display Recommendation: **3 columns: Name · Profession (read-only badge) · Responsibility (editable inline)**, plus the existing Herkunft column
|
||||
|
||||
Layout for the team table on `/projects/{id}` Tab:
|
||||
|
||||
```
|
||||
| Name | Profession | Responsibility (edit) | Herkunft | Aktion |
|
||||
| Anna Schmidt | [PA] | [Lead ▾] | direkt | 🗑 |
|
||||
| Max Mustermann| [Associate] | [Member ▾] | über X-Unit | 🗑 |
|
||||
| Carla Smith | (extern) | [External] | direkt | 🗑 |
|
||||
```
|
||||
|
||||
- **Profession** column: read-only `.projekt-team-profession` pill (CSS
|
||||
variant of existing `.projekt-team-role`). Click for global_admin
|
||||
opens `/admin/team#user-{id}` for inline edit. For non-admins, a
|
||||
hover tooltip explains "Profession wird im Firmenprofil gepflegt".
|
||||
- **Responsibility** column: existing inline-edit pattern (`.entity-row
|
||||
select`) — reuses the t-paliad-141 inline-edit affordance. Edit
|
||||
permission = project lead OR global_admin.
|
||||
- NULL profession renders as `(extern)` or `(keine Profession)`
|
||||
depending on `responsibility`.
|
||||
|
||||
Inline prose elsewhere (Verlauf entries, inbox rows, email reminders):
|
||||
**"Anna Schmidt (PA) — Lead"** — profession in parens, responsibility
|
||||
after a dash. Explicit and parseable.
|
||||
|
||||
For the audit trail (`paliad.project_events`), emit
|
||||
`team_member_added` with `metadata = {responsibility: 'member',
|
||||
profession_at_time: 'pa'}` so historic rendering survives a profession
|
||||
change.
|
||||
|
||||
### Q11 — Team table layout post-fix Recommendation: **3-column tabular layout above; tooltip-only profession is rejected**
|
||||
|
||||
Two alternatives the issue posed:
|
||||
|
||||
- **Hover-only profession** ("Anna Schmidt — Lead", profession in
|
||||
tooltip badge): rejected. Profession is too important to hide. The
|
||||
whole point of the split is to make profession honestly visible.
|
||||
- **3-column tabular**: chosen. Matches the existing `.entity-table`
|
||||
pattern; profession is glanceable.
|
||||
|
||||
Tooltip is still useful as *secondary* signal: hover the profession
|
||||
badge → "PA — gesetzt im Firmenprofil. (Edit by global_admin only)".
|
||||
|
||||
The team-add form (the bug surface m complained about) loses the
|
||||
mixed-axis dropdown. New form:
|
||||
|
||||
```
|
||||
[ User autocomplete ▾ ] ← picks Anna Schmidt
|
||||
Anna Schmidt (PA) ← shown beneath, read from users.profession
|
||||
[ Responsibility: Member ▾ ] ← only dropdown left; default Member
|
||||
[ Cancel ] [ Hinzufügen ]
|
||||
```
|
||||
|
||||
**If the picked person has profession=NULL:** show a yellow warning
|
||||
under the profession line: "*Anna hat keine Profession gesetzt — sie
|
||||
kann keine 4-Augen-Genehmigungen erteilen. Admin im Firmenprofil
|
||||
nachtragen.*" Doesn't block the add, just informs.
|
||||
|
||||
---
|
||||
|
||||
## §5 Sub-design C — Approval ladder rename + migration (Q7, Q8, Q9, Q10)
|
||||
|
||||
### Q7 vs Q8 — Tuple-gated ladder Recommendation: **Q7 (rename to profession) with project-responsibility as a binary gate**
|
||||
|
||||
Two views the issue posed:
|
||||
|
||||
- **Q7**: ladder migrates from `project_teams.role` → `users.profession`.
|
||||
Project responsibility goes elsewhere; the ladder is purely
|
||||
profession-driven.
|
||||
- **Q8**: ladder becomes a tuple `(profession,
|
||||
project_responsibility)` — finer policies, e.g. "associate-level
|
||||
lawyer who is at least a member on this project".
|
||||
|
||||
**Recommend Q7-with-gate**: the ladder is profession-driven, and
|
||||
project responsibility acts as a *binary gate* (open/closed) rather
|
||||
than a separate dimension in the policy grammar.
|
||||
|
||||
Effective level for user U on project P:
|
||||
|
||||
```
|
||||
profession_level = approval_role_level(U.profession) -- 0 if NULL
|
||||
responsibility = project_teams.responsibility on P (direct or ancestor)
|
||||
gate_open = responsibility IN ('lead', 'member')
|
||||
|
||||
effective_level = profession_level if gate_open else 0
|
||||
```
|
||||
|
||||
For derived (partner-unit) authority (t-139):
|
||||
|
||||
```
|
||||
derived_role = approval_role_from_unit_role(unit_role)
|
||||
when ppu.derive_grants_authority = true
|
||||
effective_level = max over all sources (direct, ancestor, derived)
|
||||
```
|
||||
|
||||
(The "max" is operative because a user might be a `member` of one
|
||||
project at profession=PA, AND derive-with-authority into the same
|
||||
project via a partner-unit attachment with unit_role=senior_pa. Take
|
||||
the higher.)
|
||||
|
||||
**Why not pure Q8?**
|
||||
|
||||
- Pure tuple-grammar means policies look like `required_role='associate'
|
||||
AND required_responsibility='lead'`. Fine for power users; nobody
|
||||
has asked. We can add the responsibility dimension to
|
||||
`approval_policies` later (one new nullable column) if the firm
|
||||
wants finer rules. v1 stays single-dimension, matching m's t-138
|
||||
Q3 lock ("per-(project, entity_type, lifecycle_event)
|
||||
required_role").
|
||||
- Pure tuple also breaks Verlauf/audit phrasing — the audit currently
|
||||
reads "Genehmigung erforderlich: Associate-Tier oder höher", which
|
||||
stays clean under Q7-with-gate. Tuple grammar would need
|
||||
"Associate-Tier UND mindestens Mitglied".
|
||||
|
||||
**Why not pure Q7 without the gate?**
|
||||
|
||||
- Without the gate, an `observer` who happens to be a Partner could
|
||||
approve. That defeats the project-level call. The whole reason
|
||||
someone is set as observer is "you're not authoritative here, even
|
||||
though you're senior". The gate restores that semantics.
|
||||
- `external` (local counsel) without a gate would also approve via
|
||||
their own firm tier — except their profession is NULL, so they're
|
||||
level 0 anyway. The gate is defense-in-depth there: if a future
|
||||
external is given profession=of_counsel by mistake, the
|
||||
responsibility=external still keeps them at level 0.
|
||||
|
||||
**Implementation site**: a new SQL function
|
||||
`paliad.user_project_authority_level(_user_id uuid, _project_id uuid)
|
||||
RETURNS int IMMUTABLE` encapsulates the (profession, responsibility,
|
||||
derivation) computation. Replaces inline
|
||||
`paliad.approval_role_level(pt.role)` at the 4
|
||||
`approval_service.go` SQL sites. Plus a Go mirror
|
||||
`UserProjectAuthorityLevel(ctx, userID, projectID) int` for callers
|
||||
that need it without a SQL roundtrip (none today, but the
|
||||
DerivationService.EffectiveProjectRole becomes a thin wrapper).
|
||||
|
||||
Policy grammar stays exactly as t-138 designed. `required_role` is a
|
||||
profession value (`partner`, `of_counsel`, `associate`, `senior_pa`,
|
||||
`pa`). The CHECK on `approval_policies.required_role` is updated to
|
||||
the new enum (drop 'lead' — was the project-level value — and rename
|
||||
nothing; the SQL ladder values are 1:1 except the ceiling). Existing
|
||||
policy rows get backfilled `lead → partner` (the only mapping that
|
||||
changes).
|
||||
|
||||
### Q9 — Backfill plan Recommendation: **highest-tier-observed per user; `lead/of_counsel/associate/senior_pa/pa → matching profession`; `local_counsel/expert/observer → NULL`**
|
||||
|
||||
Backfill rules:
|
||||
|
||||
**Profession** (firm-wide, one row per user):
|
||||
|
||||
For each user with at least one `paliad.project_teams` row:
|
||||
|
||||
```
|
||||
profession = highest tier among all (direct) project_teams rows
|
||||
where:
|
||||
legacy 'lead' → 'partner' (level 5)
|
||||
legacy 'of_counsel' → 'of_counsel'(level 4)
|
||||
legacy 'associate' → 'associate' (level 3)
|
||||
legacy 'senior_pa' → 'senior_pa' (level 2)
|
||||
legacy 'pa' → 'pa' (level 1)
|
||||
legacy 'local_counsel' → IGNORED
|
||||
legacy 'expert' → IGNORED
|
||||
legacy 'observer' → IGNORED
|
||||
```
|
||||
|
||||
If after ignoring project-only labels the user has no firm-tier row →
|
||||
profession = NULL.
|
||||
|
||||
For users with NO project_teams rows → profession = NULL too. Admin
|
||||
edits at `/admin/team` if those users are firm members (the 11
|
||||
unit-only users in current data).
|
||||
|
||||
**Tie-break**: pick the highest level. If a user is `lead` on Project A
|
||||
and `pa` on Project B, profession = `partner` (level 5 > level 1).
|
||||
This matches m's "highest-tier observed" rule from the issue body.
|
||||
|
||||
**Edge case — only `observer` rows**: the user has exactly one
|
||||
`observer` row across all projects. Profession = NULL (no firm tier
|
||||
inferable from the data). Admin will need to set it.
|
||||
|
||||
**Edge case — `local_counsel` rows only**: user is external. Profession
|
||||
= NULL. Their project_teams.responsibility row will be 'external'
|
||||
(see below).
|
||||
|
||||
**Responsibility** (per project_teams row):
|
||||
|
||||
```
|
||||
legacy 'lead' → 'lead'
|
||||
legacy 'observer' → 'observer'
|
||||
legacy 'local_counsel' → 'external'
|
||||
legacy 'expert' → 'external'
|
||||
legacy 'associate' → 'member'
|
||||
legacy 'pa' → 'member'
|
||||
legacy 'of_counsel' → 'member'
|
||||
legacy 'senior_pa' → 'member'
|
||||
```
|
||||
|
||||
This preserves m's stated rules:
|
||||
|
||||
- `lead` → `lead`
|
||||
- `observer` → `observer`
|
||||
- everything else (firm tier) → `member` (their authority is now
|
||||
encoded in their profession; the project row just says "they're
|
||||
staffed")
|
||||
|
||||
External labels (`local_counsel`, `expert`) get
|
||||
`responsibility='external'`. Their profession remains NULL (the
|
||||
backfill above ignores them for profession purposes).
|
||||
|
||||
**Live data sanity check**: today there are 3 project_teams rows, all
|
||||
`role='lead'`. Backfill produces:
|
||||
|
||||
- 3 users get `profession='partner'`.
|
||||
- 3 project_teams rows get `responsibility='lead'`.
|
||||
|
||||
All other users (28 of 31) get `profession=NULL` until admin edits
|
||||
them at `/admin/team`. Acceptable — the firm has known they need an
|
||||
audit pass over user records since t-051; this surfaces it cleanly.
|
||||
|
||||
### Q10 — Down-migration safety Recommendation: **reversible with documented data loss on edge cases**
|
||||
|
||||
Down-migration steps (`057_down`):
|
||||
|
||||
1. Re-derive `project_teams.role` from `(responsibility, profession)`:
|
||||
|
||||
```sql
|
||||
UPDATE paliad.project_teams pt
|
||||
SET role = CASE
|
||||
WHEN pt.responsibility = 'lead' THEN 'lead'
|
||||
WHEN pt.responsibility = 'observer' THEN 'observer'
|
||||
WHEN pt.responsibility = 'external' THEN 'local_counsel'
|
||||
WHEN pt.responsibility = 'member' THEN COALESCE(
|
||||
(SELECT u.profession FROM paliad.users u WHERE u.id = pt.user_id),
|
||||
'associate'
|
||||
)
|
||||
END;
|
||||
```
|
||||
|
||||
- `external` always maps back to `local_counsel` (most common
|
||||
pre-split external label; `expert` is rarer and lossy).
|
||||
- `member` with profession=`partner` maps back to… ambiguous.
|
||||
Pre-split there was no firm-tier `partner` row in
|
||||
`project_teams`. Document data loss: maps to `of_counsel` (next
|
||||
highest legacy value). If the down is run, the partner re-appears
|
||||
as of_counsel on that project. Acceptable for a rollback.
|
||||
- `member` with profession=`paralegal` maps back to `pa` (closest
|
||||
legacy fit; `paralegal` was never a `project_teams.role` value).
|
||||
- `member` with profession=NULL maps back to `associate` (safe
|
||||
default, matches the legacy `RoleAssociate` default).
|
||||
|
||||
2. DROP COLUMN `paliad.users.profession`.
|
||||
3. DROP COLUMN `paliad.project_teams.responsibility`.
|
||||
4. Drop `paliad.user_project_authority_level` function.
|
||||
5. Restore `approval_service.go` SQL sites to inline
|
||||
`approval_role_level(pt.role)`.
|
||||
|
||||
Down-migration is best-effort. Documented data loss in `057_down.sql`
|
||||
comments. The Go code on `main` doesn't need to support both states
|
||||
(paliad doesn't have multi-version-deployed history); a down is a
|
||||
manual rollback path.
|
||||
|
||||
**Phasing**: `project_teams.role` stays on the table as a deprecated
|
||||
shadow column for one release (migration 057 keeps it; migration 058 —
|
||||
follow-up ticket — drops it after Go code is fully migrated). This
|
||||
means even in the worst case, a fast down doesn't have to recompute
|
||||
`role`; it just drops the new columns and keeps the old.
|
||||
|
||||
---
|
||||
|
||||
## §6 Migration plan — single migration 057
|
||||
|
||||
Filename: `internal/db/migrations/057_profession_vs_responsibility.up.sql`
|
||||
|
||||
Sections:
|
||||
|
||||
1. ADD `paliad.users.profession`.
|
||||
2. ADD `paliad.project_teams.responsibility`.
|
||||
3. CREATE `paliad.user_project_authority_level(user_id, project_id)`
|
||||
function.
|
||||
4. UPDATE `paliad.approval_policies.required_role` CHECK to add
|
||||
`'partner'` and drop `'lead'`. Backfill `'lead'` → `'partner'` in
|
||||
any existing rows.
|
||||
5. Backfill `users.profession` per Q9.
|
||||
6. Backfill `project_teams.responsibility` per Q9.
|
||||
7. UPDATE `paliad.can_see_project` body — replace `pt.role = 'lead'`
|
||||
with `pt.responsibility = 'lead'`. Function CASCADE-rebuild not
|
||||
needed (only function body changes).
|
||||
8. UPDATE the comment on `paliad.approval_role_level` to point at
|
||||
`users.profession` instead of `project_teams.role`.
|
||||
|
||||
`project_teams.role` is **kept** in this migration (deprecated, not
|
||||
read by any new code). Drop in follow-up migration 058 after Go code
|
||||
fully migrates and is verified live.
|
||||
|
||||
### Service-layer migration (single PR alongside 057)
|
||||
|
||||
Files to edit:
|
||||
|
||||
- `internal/services/team_service.go` — INSERT/SELECT/validate the new
|
||||
`responsibility` column. `isValidRole` becomes
|
||||
`isValidResponsibility` with new enum.
|
||||
- `internal/services/derivation_service.go` — `requireWritePermission`
|
||||
reads `pt.responsibility = 'lead'` instead of `pt.role = 'lead'`.
|
||||
`EffectiveProjectRole` (used by t-138 derived authority) replaced by
|
||||
`UserProjectAuthorityLevel` (returns int from the SQL function +
|
||||
source string). `ListAttachedUnits`, `ListDerivedMembers` unchanged
|
||||
(they don't touch the ladder column).
|
||||
- `internal/services/approval_service.go` — 4 SQL sites switch from
|
||||
`paliad.approval_role_level(pt.role)` to
|
||||
`paliad.user_project_authority_level(pt.user_id, $project_id)`.
|
||||
Self-approval CHECK and policy lookup stay identical.
|
||||
- `internal/services/approval_levels.go` — Go-side `levelOf()` becomes
|
||||
`professionLevel()`; new helper `responsibilityOpensGate()`.
|
||||
`RoleSeniorPA` constant stays (still a valid profession value,
|
||||
reused). New constants `ProfessionPartner`, `ProfessionOfCounsel`,
|
||||
`ProfessionAssociate`, `ProfessionSeniorPA`, `ProfessionPA`,
|
||||
`ProfessionParalegal`. New constants `ResponsibilityLead`,
|
||||
`ResponsibilityMember`, `ResponsibilityObserver`,
|
||||
`ResponsibilityExternal`.
|
||||
- `internal/services/project_service.go:486` — INSERT writes
|
||||
`responsibility='lead'` (creator-as-lead). Old
|
||||
`RoleLead`/`RoleAssociate`/etc constants stay as aliases for one
|
||||
release to ease grep diffs; mark deprecated.
|
||||
- `internal/services/reminder_service.go:317,330` —
|
||||
`pt.role = 'lead'` → `pt.responsibility = 'lead'`.
|
||||
- `internal/services/deadline_service.go:695` —
|
||||
`pt.role IN ('admin', 'lead')` → `pt.responsibility = 'lead'`.
|
||||
(`'admin'` was already dead since t-051; this is also a small
|
||||
cleanup.)
|
||||
- `internal/services/user_service.go` — onboarding/invite code
|
||||
accepts a `profession` arg, stores on insert.
|
||||
- `internal/handlers/team.go` (and friends) — JSON shape change:
|
||||
`ProjectTeamMember` now exposes `responsibility` instead of `role`,
|
||||
embeds `User.Profession`.
|
||||
- `internal/models/models.go` — `ProjectTeamMember.Role` → `.Responsibility`;
|
||||
`User` gains `.Profession *string`.
|
||||
|
||||
### Frontend migration (same PR)
|
||||
|
||||
- `frontend/src/projects-detail.tsx:124-132` — replace 7-option mixed
|
||||
dropdown with 4-option responsibility-only dropdown
|
||||
(`lead | member | observer | external`). Default `member`.
|
||||
- `frontend/src/client/projects-detail.ts:1665,1720,1772,1856` — render
|
||||
3-column team table. New `.projekt-team-profession` CSS pill +
|
||||
i18n keys `projects.team.profession.partner` …
|
||||
`projects.team.profession.paralegal`. New i18n keys
|
||||
`projects.team.responsibility.lead` … `.external` (replace
|
||||
`projects.team.role.*`).
|
||||
- `frontend/src/client/team.ts` — `/team` directory page: respect new
|
||||
profession column for grouping. Falls back to job_title when
|
||||
profession=NULL (existing free-text behaviour preserved for
|
||||
externals).
|
||||
- `frontend/src/admin-team.tsx` + `client/admin-team.ts` — add
|
||||
Profession column with inline-edit dropdown.
|
||||
- `frontend/src/onboarding.tsx` — invite flow gains a profession
|
||||
`<select>`.
|
||||
- ~30 new i18n keys DE+EN.
|
||||
|
||||
### Tests
|
||||
|
||||
- `internal/services/team_service_test.go` — happy path on
|
||||
AddMember/RemoveMember with new responsibility values; reject
|
||||
invalid values.
|
||||
- `internal/services/approval_service_test.go` — extend
|
||||
table-driven ladder tests to cover the
|
||||
(profession, responsibility) tuple. Cases: `partner+observer = 0`,
|
||||
`pa+lead = 1`, `null+member = 0`, derived+responsibility=external
|
||||
combinations.
|
||||
- `internal/services/migration_057_test.go` — live-DB integration
|
||||
test (skipped without `TEST_DATABASE_URL`): apply migration on a
|
||||
seeded snapshot, assert backfill produces expected
|
||||
(profession, responsibility) pairs.
|
||||
|
||||
---
|
||||
|
||||
## §7 Implementation phasing
|
||||
|
||||
**Single PR, 6 commits** — the schema + service + frontend are tightly
|
||||
coupled. Splitting risks half-broken intermediate states (the bug
|
||||
report itself is about a half-broken intermediate state).
|
||||
|
||||
1. Migration 057 (schema + backfill + new SQL function). No code
|
||||
changes — server still reads `pt.role`. Verify backfill on live DB
|
||||
via BEGIN/ROLLBACK.
|
||||
2. ApprovalService + DerivationService rewire. Tests updated. Build +
|
||||
test green. Server reads from new SQL function but writes still go
|
||||
to `pt.role` (will fix in commit 3).
|
||||
3. TeamService + UserService rewire. INSERT writes
|
||||
`responsibility=...`. Reads return `responsibility`. Models
|
||||
updated. JSON schema change.
|
||||
4. Frontend rewire — team-add dropdown, team table, admin-team,
|
||||
onboarding. New i18n keys.
|
||||
5. Reminder + Deadline service touch-ups + can_see_project body
|
||||
refresh.
|
||||
6. Lint + grep sweep — kill any remaining `pt.role` references that
|
||||
should have been migrated. Add a deprecation comment to the
|
||||
`RoleLead`/`RoleAssociate` Go constants pointing at the new ones.
|
||||
|
||||
**Follow-up ticket (out of scope for this PR)**: t-paliad-149 —
|
||||
migration 058 to DROP COLUMN `project_teams.role` after one release of
|
||||
soak time on main. Trivial when the time comes; just keeps this PR
|
||||
clean.
|
||||
|
||||
**Recommended implementer**: any pattern-fluent coder. **NOT cronus**
|
||||
(retired from paliad per memory directive). Sonnet work — 70% of the
|
||||
diff is mechanical rename, 30% is the new SQL function + 4 ladder-site
|
||||
rewrites + the new team-table layout. The substrate is well-trodden
|
||||
(t-051 split established the pattern; t-138/t-139 left clean call
|
||||
sites to migrate from).
|
||||
|
||||
---
|
||||
|
||||
## §8 Trade-offs flagged
|
||||
|
||||
1. **One migration touches both axes at once.** A pure-additive
|
||||
migration (add columns, leave `role`) would be safer-feeling, but
|
||||
then the team-add dropdown bug stays open (the UX lie m hates is
|
||||
still on screen until commit 4). I prefer one PR that ships the
|
||||
fix end-to-end, with `project_teams.role` deprecated-shadow for
|
||||
one release as the safety net.
|
||||
2. **Profession=NULL semantics are load-bearing.** NULL means "no
|
||||
firm tier" → ladder level 0 → ineligible. If a developer later
|
||||
adds a fast-path that defaults NULL→`associate` for "convenience",
|
||||
externals would silently gain approval rights. Mitigation: explicit
|
||||
helper `professionLevel(*string) int` that returns 0 for NULL with
|
||||
a comment naming the trap. Add a unit test `TestProfessionLevel_NilIsZero`.
|
||||
3. **`partner` is the new ceiling but `lead` is no longer a profession**.
|
||||
The mental jump for users: "Lead" was the highest in the dropdown;
|
||||
now "Partner" is. Renaming is honest but a moment of surprise.
|
||||
Mitigation: i18n keys carry over the lead-on-this-project sense via
|
||||
`projects.team.responsibility.lead` so the word "Lead" stays
|
||||
visible exactly where it should — the project axis. Profession's
|
||||
"Partner" appears in firm-context surfaces (admin/team, tooltips).
|
||||
4. **Tuple-gated ladder vs pure-tuple grammar.** Choosing
|
||||
responsibility as a binary gate means a future "must be a member,
|
||||
not just having visibility" rule is easy. A future "must be lead
|
||||
AND of_counsel-tier or higher" rule needs a new dimension on
|
||||
`approval_policies` (new nullable column). Acceptable: zero
|
||||
policies today need it; cheap to add when one does.
|
||||
5. **Backfill produces 28 NULL professions** out of 31 users (the
|
||||
ones not in any project_teams row). After ship, `/admin/team` will
|
||||
show a warning column "Profession nicht gesetzt" until admin
|
||||
completes the audit. This is honest visibility of pre-existing data
|
||||
debt rather than papering over with a guessed default.
|
||||
6. **`approval_role_from_unit_role` doesn't change** but its callers
|
||||
(the derived-authority SQL branches in approval_service.go) need to
|
||||
move from "compare against `pt.role`" to "compare against
|
||||
`users.profession` of the project_teams row's user". Mechanical;
|
||||
listed in §6 file inventory.
|
||||
|
||||
---
|
||||
|
||||
## §9 Out of scope (v1)
|
||||
|
||||
- Replacing the partner-unit-derivation mechanism (t-139 Phase 2) —
|
||||
derivation stays exactly as designed.
|
||||
- A full firm-roles / hierarchy / org-chart feature — this design adds
|
||||
one structured column (profession) and nothing more.
|
||||
- Multi-profession (paralegal-turned-associate scenario). One
|
||||
profession per user; admin edits when promoted.
|
||||
- Time-sliced profession history (who was a PA in 2024). Out per
|
||||
issue body.
|
||||
- Adding a `responsibility` dimension to `approval_policies` (Q8 pure
|
||||
tuple grammar). Deferred to a future ticket if a real policy
|
||||
requires it.
|
||||
- Bulk-add UI for project members. None exists today.
|
||||
- Dropping `project_teams.role` itself. Deferred to follow-up
|
||||
migration 058 after one release of soak time.
|
||||
|
||||
---
|
||||
|
||||
## §10 12 Questions — Recommendation summary
|
||||
|
||||
| # | Question | Recommendation | Locked? |
|
||||
|---|---|---|---|
|
||||
| Q1 | Where does profession live? | (a) New `paliad.users.profession` text column | open — m sign-off |
|
||||
| Q2 | Profession values | `partner \| of_counsel \| associate \| senior_pa \| pa \| paralegal` (NULL = external) | open — m sign-off |
|
||||
| Q3 | Onboarding flow | Required-on-invite, default `associate`, admin-editable | open — m sign-off |
|
||||
| Q4 | Project responsibility values | `lead \| member \| observer \| external` | open — m hinted yes |
|
||||
| Q5 | Default value | `member` | open — m hinted yes |
|
||||
| Q6 | Display | 3 columns: Name · Profession (badge) · Responsibility (inline-edit), plus existing Herkunft | open — m sign-off |
|
||||
| Q7 vs Q8 | Ladder migration | Q7 (rename to profession) WITH project-responsibility as a binary gate (`responsibility ∈ {lead, member}` opens the gate) | open — main architectural call |
|
||||
| Q9 | Backfill | Profession = highest legacy tier per user (`lead → partner`, `of_counsel → of_counsel`, …, externals → NULL); responsibility per single-row mapping (`lead → lead`, `observer → observer`, externals → `external`, others → `member`) | open — m sign-off |
|
||||
| Q10 | Down-migration | Reversible with documented best-effort data loss; `project_teams.role` kept as deprecated shadow until follow-up 058 | open — m sign-off |
|
||||
| Q11 | Team table layout | 3-column tabular (rejecting tooltip-only profession); inline-edit responsibility; profession edits live on `/admin/team` | open — m sign-off |
|
||||
| Q12 | Bulk add / invite | Profession capture on invite (default `associate`, "Extern" hides field). No bulk-add v1. Admin re-edits via `/admin/team` | open — m sign-off |
|
||||
|
||||
---
|
||||
|
||||
## §11 Coordination with sibling work
|
||||
|
||||
- **t-138 (approvals)**: shipped 2026-05-06 (commit `e2e1381`). Migration
|
||||
054 sets up the ladder; this design extends it to read from
|
||||
`users.profession` instead of `project_teams.role`. Policy grammar
|
||||
unchanged. `required_role` enum gains `partner`, drops `lead`
|
||||
(renamed in backfill).
|
||||
- **t-139 (hierarchy + derivation)**: all 3 phases shipped. Migration
|
||||
055 added `partner_unit_members.unit_role` and the
|
||||
`approval_role_from_unit_role` bridge. This design leaves the
|
||||
bridge untouched — `unit_role` values map 1:1 to the new profession
|
||||
enum (`lead → partner`, `attorney → associate`, `senior_pa →
|
||||
senior_pa`, `pa → pa`, `paralegal → paralegal`). Update the bridge's
|
||||
`lead → lead` row to `lead → partner` in migration 057.
|
||||
- **t-144 (Custom Views)**: shipped. ViewService.runApprovalRequests
|
||||
uses ApprovalService.ListPendingForApprover, which reads the new
|
||||
ladder. Inherits the change automatically.
|
||||
- **t-paliad-145 (local chat)**: parked. Not relevant.
|
||||
|
||||
No siblings are blocked by this work, and this work doesn't block any
|
||||
sibling. Independent migration, independent merge.
|
||||
|
||||
---
|
||||
|
||||
## §12 Inventor parking
|
||||
|
||||
Inventor (kepler) parks here. Awaits m's pass through the 12 questions
|
||||
in §10 + any course-correction. After m signs off, this design locks
|
||||
and a fresh coder shift can pick up the single PR. Branch:
|
||||
`mai/kepler/inventor-profession-vs`.
|
||||
|
||||
DESIGN READY FOR REVIEW.
|
||||
1250
docs/design-projects-page-2026-05-07.md
Normal file
1250
docs/design-projects-page-2026-05-07.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -29,12 +29,18 @@ 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 { renderViews } from "./src/views";
|
||||
import { renderViewsEditor } from "./src/views-editor";
|
||||
import { renderAdminTeam } from "./src/admin-team";
|
||||
import { renderAdminAuditLog } from "./src/admin-audit-log";
|
||||
import { renderAdminPartnerUnits } from "./src/admin-partner-units";
|
||||
import { renderAdminEmailTemplates } from "./src/admin-email-templates";
|
||||
import { renderAdminEmailTemplatesEdit } from "./src/admin-email-templates-edit";
|
||||
import { renderAdminEventTypes } from "./src/admin-event-types";
|
||||
import { renderAdminBroadcasts } from "./src/admin-broadcasts";
|
||||
import { renderPaliadin } from "./src/paliadin";
|
||||
import { renderAdminPaliadin } from "./src/admin-paliadin";
|
||||
import { renderNotFound } from "./src/notfound";
|
||||
|
||||
const DIST = join(import.meta.dir, "dist");
|
||||
@@ -248,6 +254,9 @@ 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/views.ts"),
|
||||
join(import.meta.dir, "src/client/views-editor.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"),
|
||||
@@ -258,6 +267,9 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/admin-email-templates.ts"),
|
||||
join(import.meta.dir, "src/client/admin-email-templates-edit.ts"),
|
||||
join(import.meta.dir, "src/client/admin-event-types.ts"),
|
||||
join(import.meta.dir, "src/client/admin-broadcasts.ts"),
|
||||
join(import.meta.dir, "src/client/paliadin.ts"),
|
||||
join(import.meta.dir, "src/client/admin-paliadin.ts"),
|
||||
join(import.meta.dir, "src/client/notfound.ts"),
|
||||
],
|
||||
outdir: join(DIST, "assets"),
|
||||
@@ -360,6 +372,9 @@ 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, "views.html"), renderViews());
|
||||
await Bun.write(join(DIST, "views-editor.html"), renderViewsEditor());
|
||||
await Bun.write(join(DIST, "onboarding.html"), renderOnboarding());
|
||||
await Bun.write(join(DIST, "changelog.html"), renderChangelog());
|
||||
await Bun.write(join(DIST, "team.html"), renderTeam());
|
||||
@@ -370,6 +385,9 @@ async function build() {
|
||||
await Bun.write(join(DIST, "admin-email-templates.html"), renderAdminEmailTemplates());
|
||||
await Bun.write(join(DIST, "admin-email-templates-edit.html"), renderAdminEmailTemplatesEdit());
|
||||
await Bun.write(join(DIST, "admin-event-types.html"), renderAdminEventTypes());
|
||||
await Bun.write(join(DIST, "admin-broadcasts.html"), renderAdminBroadcasts());
|
||||
await Bun.write(join(DIST, "paliadin.html"), renderPaliadin());
|
||||
await Bun.write(join(DIST, "admin-paliadin.html"), renderAdminPaliadin());
|
||||
await Bun.write(join(DIST, "notfound.html"), renderNotFound());
|
||||
|
||||
// Append ?v=<buildVersion> to every /assets/*.js and /assets/*.css URL in
|
||||
|
||||
66
frontend/src/admin-broadcasts.tsx
Normal file
66
frontend/src/admin-broadcasts.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderAdminBroadcasts(): 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" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.broadcasts.title">Broadcasts — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/broadcasts" />
|
||||
<BottomNav currentPath="/admin/broadcasts" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<h1 data-i18n="admin.broadcasts.heading">Broadcasts</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.broadcasts.subtitle">
|
||||
Versendete Massen-E-Mails an Teamauswahlen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="entity-table-wrap">
|
||||
<table className="entity-table entity-table--readonly broadcasts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.broadcasts.col.sent_at">Gesendet</th>
|
||||
<th data-i18n="admin.broadcasts.col.subject">Betreff</th>
|
||||
<th data-i18n="admin.broadcasts.col.sender">Absender:in</th>
|
||||
<th data-i18n="admin.broadcasts.col.count">Empfänger</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="broadcasts-tbody">
|
||||
<tr><td colspan={4} data-i18n="admin.broadcasts.loading">Lade ...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="entity-empty" id="broadcasts-empty" style="display:none">
|
||||
<p data-i18n="admin.broadcasts.empty">Noch keine Broadcasts versandt.</p>
|
||||
</div>
|
||||
|
||||
<div id="broadcast-detail" className="hidden" />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/admin-broadcasts.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
109
frontend/src/admin-paliadin.tsx
Normal file
109
frontend/src/admin-paliadin.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// Paliadin monitoring dashboard (t-paliad-146 PoC).
|
||||
//
|
||||
// global_admin only. The load-bearing artefact for §0.5.7's expansion
|
||||
// gate decision: m looks at this every week or two and decides if
|
||||
// Paliadin earns a production v1 build.
|
||||
export function renderAdminPaliadin(): 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" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.paliadin.title">Paliadin Monitor — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/paliadin" />
|
||||
<BottomNav currentPath="/admin/paliadin" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<h1 data-i18n="admin.paliadin.heading">Paliadin Monitor</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.paliadin.subtitle">
|
||||
Wie wird Paliadin tatsächlich verwendet?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="paliadin-stats" id="paliadin-stats">
|
||||
<div className="paliadin-stat-cards">
|
||||
<div className="paliadin-stat-card">
|
||||
<div className="paliadin-stat-label" data-i18n="admin.paliadin.total">Gesamt</div>
|
||||
<div className="paliadin-stat-value" id="stat-total">—</div>
|
||||
</div>
|
||||
<div className="paliadin-stat-card">
|
||||
<div className="paliadin-stat-label" data-i18n="admin.paliadin.last7">Letzte 7 Tage</div>
|
||||
<div className="paliadin-stat-value" id="stat-7d">—</div>
|
||||
</div>
|
||||
<div className="paliadin-stat-card">
|
||||
<div className="paliadin-stat-label" data-i18n="admin.paliadin.median_dur">Median Dauer</div>
|
||||
<div className="paliadin-stat-value" id="stat-median">—</div>
|
||||
</div>
|
||||
<div className="paliadin-stat-card">
|
||||
<div className="paliadin-stat-label" data-i18n="admin.paliadin.tool_rate">Tool-Use Rate</div>
|
||||
<div className="paliadin-stat-value" id="stat-tools">—</div>
|
||||
</div>
|
||||
<div className="paliadin-stat-card">
|
||||
<div className="paliadin-stat-label" data-i18n="admin.paliadin.abandon_rate">Abbruchrate</div>
|
||||
<div className="paliadin-stat-value" id="stat-abandon">—</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 data-i18n="admin.paliadin.classifier_heading">Anfragearten</h2>
|
||||
<div className="paliadin-classifier" id="classifier-bars" />
|
||||
|
||||
<h2 data-i18n="admin.paliadin.daily_heading">Tägliche Nutzung</h2>
|
||||
<div className="paliadin-spark" id="daily-spark" />
|
||||
|
||||
<h2 data-i18n="admin.paliadin.top_heading">Top Anfragen</h2>
|
||||
<table className="entity-table entity-table--readonly">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.paliadin.col.prompt">Anfrage</th>
|
||||
<th data-i18n="admin.paliadin.col.count">Anzahl</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="top-prompts-tbody">
|
||||
<tr><td colspan={2} data-i18n="admin.paliadin.loading">Lade …</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 data-i18n="admin.paliadin.recent_heading">Letzte Anfragen</h2>
|
||||
<table className="entity-table entity-table--readonly">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.paliadin.col.started">Zeit</th>
|
||||
<th data-i18n="admin.paliadin.col.classifier">Art</th>
|
||||
<th data-i18n="admin.paliadin.col.prompt">Anfrage</th>
|
||||
<th data-i18n="admin.paliadin.col.tools">Tools</th>
|
||||
<th data-i18n="admin.paliadin.col.duration">Dauer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="recent-turns-tbody">
|
||||
<tr><td colspan={5} data-i18n="admin.paliadin.loading">Lade …</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/admin-paliadin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -72,6 +72,7 @@ export function renderAdminTeam(): string {
|
||||
<th data-i18n="admin.team.col.email">E-Mail</th>
|
||||
<th data-i18n="admin.team.col.office">Standort</th>
|
||||
<th data-i18n="admin.team.col.job_title">Berufsbezeichnung</th>
|
||||
<th data-i18n="admin.team.col.profession">Profession</th>
|
||||
<th data-i18n="admin.team.col.permission">Berechtigung</th>
|
||||
<th data-i18n="admin.team.col.additional">Weitere Standorte</th>
|
||||
<th data-i18n="admin.team.col.lang">Sprache</th>
|
||||
@@ -80,7 +81,7 @@ export function renderAdminTeam(): string {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="admin-team-tbody">
|
||||
<tr><td colspan={9} className="admin-team-loading" data-i18n="admin.team.loading">Lade...</td></tr>
|
||||
<tr><td colspan={10} className="admin-team-loading" data-i18n="admin.team.loading">Lade...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -83,6 +83,11 @@ export function renderAdmin(): string {
|
||||
<h2 data-i18n="admin.card.event_types.title">Event-Typen</h2>
|
||||
<p data-i18n="admin.card.event_types.desc">Firmenweite Event-Typen moderieren: archivieren, zusammenführen, befördern.</p>
|
||||
</a>
|
||||
<a href="/admin/broadcasts" className="card card-link">
|
||||
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_MAIL }} />
|
||||
<h2 data-i18n="admin.card.broadcasts.title">Broadcasts</h2>
|
||||
<p data-i18n="admin.card.broadcasts.desc">Versendete Massen-E-Mails an Teamauswahlen einsehen.</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h3 className="section-heading admin-section-planned" data-i18n="admin.section.planned">Geplant</h3>
|
||||
|
||||
@@ -54,6 +54,12 @@ export function renderAppointmentsDetail(): string {
|
||||
<label htmlFor="appointment-title-edit" data-i18n="appointments.field.title">Titel</label>
|
||||
<input type="text" id="appointment-title-edit" required />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="appointment-project-edit" data-i18n="appointments.field.akte">Akte (optional)</label>
|
||||
<select id="appointment-project-edit">
|
||||
<option value="" data-i18n="appointments.field.akte.none">Persönlicher Termin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="appointment-start-edit" data-i18n="appointments.field.start">Beginn</label>
|
||||
|
||||
137
frontend/src/client/admin-broadcasts.ts
Normal file
137
frontend/src/client/admin-broadcasts.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
// admin-broadcasts.ts — read-only viewer for paliad.email_broadcasts.
|
||||
//
|
||||
// global_admin sees every row; senders see only their own. Authority is
|
||||
// enforced server-side; this client just renders whatever /api/admin/broadcasts
|
||||
// returns. Click a row → load detail (subject, body, recipient list).
|
||||
|
||||
import { initI18n, onLangChange, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface BroadcastRow {
|
||||
id: string;
|
||||
subject: string;
|
||||
sender_id: string;
|
||||
sender_name: string;
|
||||
sender_email: string;
|
||||
recipient_count: number;
|
||||
sent_at: string;
|
||||
template_key?: string;
|
||||
}
|
||||
|
||||
interface BroadcastDetailRecipient {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
interface BroadcastDetail extends BroadcastRow {
|
||||
body: string;
|
||||
recipient_filter: Record<string, unknown>;
|
||||
send_report: { total: number; sent: number; failed: number };
|
||||
recipients: BroadcastDetailRecipient[];
|
||||
}
|
||||
|
||||
let rows: BroadcastRow[] = [];
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const tbody = document.getElementById("broadcasts-tbody")!;
|
||||
const empty = document.getElementById("broadcasts-empty")!;
|
||||
try {
|
||||
const res = await fetch("/api/admin/broadcasts");
|
||||
if (!res.ok) {
|
||||
if (res.status === 403) {
|
||||
tbody.innerHTML = `<tr><td colspan="4">${esc(t("common.forbidden") || "Zugriff verweigert.")}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = `<tr><td colspan="4">${esc(t("common.load_error") || "Fehler beim Laden.")}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
rows = (await res.json()) as BroadcastRow[];
|
||||
} catch {
|
||||
tbody.innerHTML = `<tr><td colspan="4">${esc(t("common.load_error") || "Fehler beim Laden.")}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML = "";
|
||||
empty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
tbody.innerHTML = rows
|
||||
.map(
|
||||
(r) => `
|
||||
<tr data-broadcast-id="${esc(r.id)}">
|
||||
<td>${esc(fmtDate(r.sent_at))}</td>
|
||||
<td>${esc(r.subject)}</td>
|
||||
<td>${esc(r.sender_name || r.sender_email || "—")}</td>
|
||||
<td>${r.recipient_count}</td>
|
||||
</tr>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
tbody.querySelectorAll<HTMLTableRowElement>("tr[data-broadcast-id]").forEach((tr) => {
|
||||
tr.addEventListener("click", () => loadDetail(tr.dataset.broadcastId!));
|
||||
tr.style.cursor = "pointer";
|
||||
});
|
||||
}
|
||||
|
||||
async function loadDetail(id: string): Promise<void> {
|
||||
const detail = document.getElementById("broadcast-detail")!;
|
||||
detail.classList.remove("hidden");
|
||||
detail.innerHTML = `<p>${esc(t("common.loading") || "Lade…")}</p>`;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/broadcasts/${encodeURIComponent(id)}`);
|
||||
if (!res.ok) {
|
||||
detail.innerHTML = `<p>${esc(t("common.load_error") || "Fehler beim Laden.")}</p>`;
|
||||
return;
|
||||
}
|
||||
const d = (await res.json()) as BroadcastDetail;
|
||||
const recList = (d.recipients || [])
|
||||
.map(
|
||||
(r) =>
|
||||
`<li>${esc(r.display_name || "—")} <span class="broadcast-recip-email"><${esc(r.email)}></span></li>`,
|
||||
)
|
||||
.join("");
|
||||
const report = d.send_report || { total: d.recipient_count, sent: d.recipient_count, failed: 0 };
|
||||
detail.innerHTML = `
|
||||
<article class="card broadcast-detail-card">
|
||||
<header>
|
||||
<h2>${esc(d.subject)}</h2>
|
||||
<p class="muted">
|
||||
${esc(t("admin.broadcasts.detail.sent_by") || "Gesendet von")} <strong>${esc(d.sender_name || d.sender_email)}</strong>
|
||||
• ${esc(fmtDate(d.sent_at))}
|
||||
• ${report.sent}/${report.total} ${esc(t("admin.broadcasts.detail.delivered") || "versandt")}
|
||||
${report.failed > 0 ? ` • ${report.failed} ${esc(t("admin.broadcasts.detail.failed") || "fehlgeschlagen")}` : ""}
|
||||
</p>
|
||||
</header>
|
||||
<div class="broadcast-detail-body">${esc(d.body)}</div>
|
||||
<section class="broadcast-detail-recipients">
|
||||
<h3>${esc(t("admin.broadcasts.detail.recipients") || "Empfänger")} (${d.recipients?.length ?? 0})</h3>
|
||||
<ul>${recList}</ul>
|
||||
</section>
|
||||
</article>
|
||||
`;
|
||||
detail.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
} catch {
|
||||
detail.innerHTML = `<p>${esc(t("common.load_error") || "Fehler beim Laden.")}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
onLangChange(() => load());
|
||||
load();
|
||||
});
|
||||
168
frontend/src/client/admin-paliadin.ts
Normal file
168
frontend/src/client/admin-paliadin.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { initI18n } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// Paliadin admin dashboard client (t-paliad-146 PoC).
|
||||
//
|
||||
// Reads /api/admin/paliadin/stats + /api/admin/paliadin/turns and
|
||||
// renders the cards / bars / sparkline / tables. Pure read-only;
|
||||
// dashboard refreshes on each visit (no live polling — m comes here
|
||||
// every few days, not every few seconds).
|
||||
|
||||
interface Stats {
|
||||
total_turns: number;
|
||||
turns_last_7_days: number;
|
||||
median_duration_ms: number;
|
||||
p90_duration_ms: number;
|
||||
tool_use_rate: number;
|
||||
abandon_rate: number;
|
||||
by_classifier: Record<string, number>;
|
||||
daily_counts: { day: string; count: number }[];
|
||||
top_prompts: { prompt: string; count: number }[];
|
||||
}
|
||||
|
||||
interface Turn {
|
||||
turn_id: string;
|
||||
user_id: string;
|
||||
started_at: string;
|
||||
duration_ms: number | null;
|
||||
user_message: string;
|
||||
used_tools: string[] | null;
|
||||
rows_seen: number[] | null;
|
||||
classifier_tag: string | null;
|
||||
abandoned: boolean;
|
||||
error_code: string | null;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
const [stats, turns] = await Promise.all([
|
||||
fetchJSON<Stats>("/api/admin/paliadin/stats"),
|
||||
fetchJSON<Turn[]>("/api/admin/paliadin/turns"),
|
||||
]);
|
||||
|
||||
if (stats) renderStats(stats);
|
||||
if (turns) renderTurns(turns);
|
||||
});
|
||||
|
||||
async function fetchJSON<T>(url: string): Promise<T | null> {
|
||||
try {
|
||||
const r = await fetch(url, { credentials: "same-origin" });
|
||||
if (!r.ok) return null;
|
||||
return (await r.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderStats(s: Stats): void {
|
||||
setText("stat-total", String(s.total_turns));
|
||||
setText("stat-7d", String(s.turns_last_7_days));
|
||||
setText("stat-median", formatMs(s.median_duration_ms));
|
||||
setText("stat-tools", formatPct(s.tool_use_rate));
|
||||
setText("stat-abandon", formatPct(s.abandon_rate));
|
||||
|
||||
// Classifier histogram bars.
|
||||
const cont = document.getElementById("classifier-bars");
|
||||
if (cont) {
|
||||
const entries = Object.entries(s.by_classifier).sort((a, b) => b[1] - a[1]);
|
||||
const max = Math.max(...entries.map((e) => e[1]), 1);
|
||||
cont.innerHTML = entries
|
||||
.map(([tag, n]) => {
|
||||
const pct = (n / max) * 100;
|
||||
return `<div class="paliadin-classifier-row">
|
||||
<div class="paliadin-classifier-label">${escapeHTML(tag)}</div>
|
||||
<div class="paliadin-classifier-bar"><div class="paliadin-classifier-fill" style="width:${pct}%"></div></div>
|
||||
<div class="paliadin-classifier-count">${n}</div>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
// Daily sparkline (last 30 days, vertical bars).
|
||||
const spark = document.getElementById("daily-spark");
|
||||
if (spark) {
|
||||
const days = s.daily_counts;
|
||||
const max = Math.max(...days.map((d) => d.count), 1);
|
||||
spark.innerHTML = days
|
||||
.map((d) => {
|
||||
const h = (d.count / max) * 60;
|
||||
return `<div class="paliadin-spark-bar" style="height:${h}px" title="${d.day}: ${d.count}"></div>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
// Top prompts table.
|
||||
const tbody = document.getElementById("top-prompts-tbody");
|
||||
if (tbody) {
|
||||
if (s.top_prompts.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="2">Noch keine Daten.</td></tr>`;
|
||||
} else {
|
||||
tbody.innerHTML = s.top_prompts
|
||||
.map(
|
||||
(p) =>
|
||||
`<tr><td>${escapeHTML(p.prompt)}</td><td>${p.count}</td></tr>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderTurns(turns: Turn[]): void {
|
||||
const tbody = document.getElementById("recent-turns-tbody");
|
||||
if (!tbody) return;
|
||||
if (turns.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="5">Noch keine Anfragen.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = turns
|
||||
.map((t) => {
|
||||
const tag = t.classifier_tag || "—";
|
||||
const tools = t.used_tools && t.used_tools.length > 0
|
||||
? t.used_tools.join(", ")
|
||||
: "—";
|
||||
const dur = t.duration_ms != null ? formatMs(t.duration_ms) : "—";
|
||||
const errMark = t.error_code ? ` ⚠ ${t.error_code}` : "";
|
||||
return `<tr>
|
||||
<td>${formatTime(t.started_at)}</td>
|
||||
<td>${escapeHTML(tag)}</td>
|
||||
<td>${escapeHTML(truncate(t.user_message, 120))}${errMark}</td>
|
||||
<td>${escapeHTML(tools)}</td>
|
||||
<td>${dur}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function setText(id: string, val: string): void {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = val;
|
||||
}
|
||||
|
||||
function formatMs(ms: number): string {
|
||||
if (ms < 1000) return `${ms} ms`;
|
||||
return `${(ms / 1000).toFixed(1)} s`;
|
||||
}
|
||||
|
||||
function formatPct(r: number): string {
|
||||
return `${Math.round(r * 100)} %`;
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
function truncate(s: string, n: number): string {
|
||||
if (s.length <= n) return s;
|
||||
return s.slice(0, n - 1) + "…";
|
||||
}
|
||||
|
||||
function escapeHTML(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -8,6 +8,10 @@ interface User {
|
||||
office: string;
|
||||
additional_offices?: string[];
|
||||
job_title: string | null;
|
||||
// t-paliad-148: structured firm-tier (partner/of_counsel/associate/
|
||||
// senior_pa/pa/paralegal). NULL = external. Editable via the
|
||||
// admin-team Profession column.
|
||||
profession?: string | null;
|
||||
global_role: string;
|
||||
lang: string;
|
||||
reminder_morning_time?: string;
|
||||
@@ -16,6 +20,15 @@ interface User {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const PROFESSION_VALUES = [
|
||||
"partner",
|
||||
"of_counsel",
|
||||
"associate",
|
||||
"senior_pa",
|
||||
"pa",
|
||||
"paralegal",
|
||||
];
|
||||
|
||||
interface Office {
|
||||
key: string;
|
||||
label_de: string;
|
||||
@@ -180,6 +193,26 @@ function permissionEditor(u: User): string {
|
||||
return `<select class="admin-team-input" data-field="global_role"${disabled}${title}>${standardOpt}${adminOpt}</select>`;
|
||||
}
|
||||
|
||||
function professionLabel(p: string | null | undefined): string {
|
||||
if (!p) return "";
|
||||
return tDyn(`projects.team.profession.${p}`) || p;
|
||||
}
|
||||
|
||||
function professionCell(u: User): string {
|
||||
if (!u.profession) {
|
||||
return `<span class="admin-team-muted" title="${esc(t("admin.team.col.profession.none.hint") || "Keine Profession gesetzt — keine 4-Augen-Befugnis")}">${esc(t("projects.team.profession.none") || "(extern)")}</span>`;
|
||||
}
|
||||
return `<span class="projekt-team-profession">${esc(professionLabel(u.profession))}</span>`;
|
||||
}
|
||||
|
||||
function professionEditor(u: User): string {
|
||||
const noneOpt = `<option value=""${!u.profession ? " selected" : ""}>${esc(t("admin.team.col.profession.none") || "(extern)")}</option>`;
|
||||
const opts = PROFESSION_VALUES.map(
|
||||
(p) => `<option value="${esc(p)}"${u.profession === p ? " selected" : ""}>${esc(professionLabel(p))}</option>`,
|
||||
).join("");
|
||||
return `<select class="admin-team-input" data-field="profession">${noneOpt}${opts}</select>`;
|
||||
}
|
||||
|
||||
function renderRow(u: User): string {
|
||||
if (editingId === u.id) return renderEditRow(u);
|
||||
const additional = (u.additional_offices ?? []).filter((o) => o !== u.office);
|
||||
@@ -190,6 +223,7 @@ function renderRow(u: User): string {
|
||||
<td><a href="mailto:${esc(u.email)}">${esc(u.email)}</a></td>
|
||||
<td><span class="office-chip office-${esc(u.office)}">${esc(officeLabel(u.office))}</span></td>
|
||||
<td>${jobTitle ? esc(jobTitle) : "<span class=\"admin-team-muted\">—</span>"}</td>
|
||||
<td>${professionCell(u)}</td>
|
||||
<td>${permissionCell(u)}</td>
|
||||
<td>${additional.length ? additional.map((o) => esc(officeLabel(o))).join(", ") : "<span class=\"admin-team-muted\">—</span>"}</td>
|
||||
<td>${esc(u.lang.toUpperCase())}</td>
|
||||
@@ -214,6 +248,7 @@ function renderEditRow(u: User): string {
|
||||
<input type="text" class="admin-team-input" data-field="job_title" value="${esc(jobTitle)}" list="admin-team-job-title-suggest-${esc(u.id)}" />
|
||||
<datalist id="admin-team-job-title-suggest-${esc(u.id)}">${jobTitleList}</datalist>
|
||||
</td>
|
||||
<td>${professionEditor(u)}</td>
|
||||
<td>${permissionEditor(u)}</td>
|
||||
<td class="admin-team-multi">${additionalOfficesEditor(additional)}</td>
|
||||
<td><select class="admin-team-input" data-field="lang">${langOptions(u.lang)}</select></td>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { initI18n, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { initNotes } from "./notes";
|
||||
import { projectIndent } from "./project-indent";
|
||||
|
||||
interface Appointment {
|
||||
id: string;
|
||||
@@ -18,10 +19,12 @@ interface Project {
|
||||
id: string;
|
||||
reference?: string | null;
|
||||
title: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
let appointment: Appointment | null = null;
|
||||
let project: Project | null = null;
|
||||
let allProjects: Project[] = [];
|
||||
|
||||
function parseAppointmentID(): string | null {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
@@ -77,6 +80,32 @@ async function loadProject(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllProjects() {
|
||||
try {
|
||||
const resp = await fetch("/api/projects");
|
||||
if (resp.ok) allProjects = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function populateProjectPicker() {
|
||||
const sel = document.getElementById("appointment-project-edit") as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const none = sel.querySelector('option[value=""]');
|
||||
sel.innerHTML = "";
|
||||
if (none) sel.appendChild(none);
|
||||
for (const p of allProjects) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = p.id;
|
||||
opt.textContent = `${projectIndent(p.path)}${p.reference || ""} — ${p.title}`;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
if (appointment) {
|
||||
sel.value = appointment.project_id ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
function renderHeader() {
|
||||
if (!appointment) return;
|
||||
document.getElementById("appointment-title-display")!.textContent = appointment.title;
|
||||
@@ -114,6 +143,8 @@ function fillEditForm() {
|
||||
(document.getElementById("appointment-type-edit") as HTMLSelectElement).value = appointment.appointment_type ?? "";
|
||||
(document.getElementById("appointment-location-edit") as HTMLInputElement).value = appointment.location ?? "";
|
||||
(document.getElementById("appointment-description-edit") as HTMLTextAreaElement).value = appointment.description ?? "";
|
||||
const projectSel = document.getElementById("appointment-project-edit") as HTMLSelectElement | null;
|
||||
if (projectSel) projectSel.value = appointment.project_id ?? "";
|
||||
}
|
||||
|
||||
async function saveEdit(ev: Event) {
|
||||
@@ -129,6 +160,9 @@ async function saveEdit(ev: Event) {
|
||||
const type = (document.getElementById("appointment-type-edit") as HTMLSelectElement).value;
|
||||
const location = (document.getElementById("appointment-location-edit") as HTMLInputElement).value.trim();
|
||||
const description = (document.getElementById("appointment-description-edit") as HTMLTextAreaElement).value;
|
||||
const projectSel = document.getElementById("appointment-project-edit") as HTMLSelectElement | null;
|
||||
const newProjectID = projectSel ? projectSel.value : "";
|
||||
const currentProjectID = appointment.project_id ?? "";
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
title,
|
||||
@@ -138,6 +172,13 @@ async function saveEdit(ev: Event) {
|
||||
location,
|
||||
description,
|
||||
};
|
||||
if (newProjectID !== currentProjectID) {
|
||||
if (newProjectID === "") {
|
||||
payload.clear_project = true;
|
||||
} else {
|
||||
payload.project_id = newProjectID;
|
||||
}
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
@@ -147,7 +188,13 @@ async function saveEdit(ev: Event) {
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (resp.ok) {
|
||||
const prevProjectID = appointment.project_id ?? "";
|
||||
appointment = await resp.json();
|
||||
const nextProjectID = appointment?.project_id ?? "";
|
||||
if (nextProjectID !== prevProjectID) {
|
||||
project = null;
|
||||
if (appointment?.project_id) await loadProject(appointment.project_id);
|
||||
}
|
||||
renderHeader();
|
||||
msg.textContent = t("appointments.detail.saved");
|
||||
msg.className = "form-msg form-msg-ok";
|
||||
@@ -200,10 +247,14 @@ async function main() {
|
||||
notFound.style.display = "block";
|
||||
return;
|
||||
}
|
||||
if (appointment.project_id) await loadProject(appointment.project_id);
|
||||
await Promise.all([
|
||||
appointment.project_id ? loadProject(appointment.project_id) : Promise.resolve(),
|
||||
loadAllProjects(),
|
||||
]);
|
||||
loading.style.display = "none";
|
||||
body.style.display = "";
|
||||
renderHeader();
|
||||
populateProjectPicker();
|
||||
fillEditForm();
|
||||
|
||||
document.getElementById("appointment-edit-form")!.addEventListener("submit", saveEdit);
|
||||
|
||||
283
frontend/src/client/broadcast.ts
Normal file
283
frontend/src/client/broadcast.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
// broadcast.ts — bulk team-email compose modal (t-paliad-147 / issue #7).
|
||||
//
|
||||
// Exposes openBroadcastModal({ recipients, projectIDs }) which the /team
|
||||
// page calls when the "E-Mail an Auswahl" button is clicked. The modal
|
||||
// collects subject + body + (optional) template and posts to
|
||||
// /api/team/broadcast. On success it shows a per-recipient send report
|
||||
// and closes.
|
||||
//
|
||||
// Per-recipient privacy: each member receives their own envelope. The
|
||||
// modal lists every addressee so the sender knows exactly who will be
|
||||
// mailed; there is no surprise to-line.
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
export interface BroadcastRecipient {
|
||||
user_id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
first_name: string;
|
||||
role_on_project: string;
|
||||
}
|
||||
|
||||
export interface OpenBroadcastModalArgs {
|
||||
recipients: BroadcastRecipient[];
|
||||
projectID?: string | null;
|
||||
projectIDs?: string[];
|
||||
offices?: string[];
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
interface EmailTemplateOption {
|
||||
key: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
const RECIPIENT_CAP = 100;
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// firstName extracts the first whitespace-separated token from a display
|
||||
// name. "Anna von Beispiel" → "Anna". Empty input → "".
|
||||
export function firstName(displayName: string): string {
|
||||
return displayName.trim().split(/\s+/)[0] ?? "";
|
||||
}
|
||||
|
||||
export function openBroadcastModal(args: OpenBroadcastModalArgs): void {
|
||||
if (!args.recipients.length) {
|
||||
alert(t("team.broadcast.error.no_recipients") || "Keine Empfänger ausgewählt.");
|
||||
return;
|
||||
}
|
||||
if (args.recipients.length > RECIPIENT_CAP) {
|
||||
alert(
|
||||
(t("team.broadcast.error.too_many") || "Empfängerlimit ({cap}) überschritten.").replace(
|
||||
"{cap}",
|
||||
String(RECIPIENT_CAP),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Existing modal? Remove. Avoids stacking on rapid double-click.
|
||||
document.getElementById("broadcast-modal")?.remove();
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.id = "broadcast-modal";
|
||||
overlay.className = "modal-overlay";
|
||||
overlay.innerHTML = renderShell(args);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Close handlers
|
||||
overlay.querySelector("[data-broadcast-close]")?.addEventListener("click", () => overlay.remove());
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) overlay.remove();
|
||||
});
|
||||
document.addEventListener("keydown", function escClose(e) {
|
||||
if (e.key === "Escape") {
|
||||
overlay.remove();
|
||||
document.removeEventListener("keydown", escClose);
|
||||
}
|
||||
});
|
||||
|
||||
// Recipient toggle
|
||||
overlay.querySelector("[data-broadcast-toggle-recipients]")?.addEventListener("click", () => {
|
||||
const list = overlay.querySelector<HTMLDivElement>("[data-broadcast-recipient-list]");
|
||||
if (!list) return;
|
||||
list.classList.toggle("hidden");
|
||||
});
|
||||
|
||||
// Template dropdown
|
||||
const templateSelect = overlay.querySelector<HTMLSelectElement>("[data-broadcast-template]");
|
||||
templateSelect?.addEventListener("change", async () => {
|
||||
const key = templateSelect.value;
|
||||
if (!key) return;
|
||||
const lang = (document.documentElement.lang || "de") as "de" | "en";
|
||||
try {
|
||||
const res = await fetch(`/api/admin/email-templates/${encodeURIComponent(key)}/${lang}`);
|
||||
if (!res.ok) return;
|
||||
const tpl = (await res.json()) as EmailTemplateOption;
|
||||
const subjectInput = overlay.querySelector<HTMLInputElement>("[data-broadcast-subject]");
|
||||
const bodyInput = overlay.querySelector<HTMLTextAreaElement>("[data-broadcast-body]");
|
||||
if (subjectInput) subjectInput.value = stripGoTemplate(tpl.subject);
|
||||
if (bodyInput) bodyInput.value = stripGoTemplate(tpl.body);
|
||||
} catch {
|
||||
/* template load failure is non-fatal — sender keeps freeform mode. */
|
||||
}
|
||||
});
|
||||
|
||||
// Submit
|
||||
const form = overlay.querySelector<HTMLFormElement>("[data-broadcast-form]");
|
||||
form?.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
await onSubmit(form, overlay, args);
|
||||
});
|
||||
}
|
||||
|
||||
function renderShell(args: OpenBroadcastModalArgs): string {
|
||||
const count = args.recipients.length;
|
||||
const previewItems = args.recipients
|
||||
.slice(0, 5)
|
||||
.map((r) => esc(r.display_name) + " <" + esc(r.email) + ">")
|
||||
.join(", ");
|
||||
const more = count > 5 ? ` +${count - 5}` : "";
|
||||
|
||||
const fullList = args.recipients
|
||||
.map(
|
||||
(r) =>
|
||||
`<li><span class="broadcast-recip-name">${esc(r.display_name)}</span> <span class="broadcast-recip-email"><${esc(r.email)}></span>${
|
||||
r.role_on_project ? ` <span class="broadcast-recip-role">${esc(r.role_on_project)}</span>` : ""
|
||||
}</li>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<div class="modal modal-broadcast" role="dialog" aria-modal="true" aria-labelledby="broadcast-title">
|
||||
<header class="modal-header">
|
||||
<h2 id="broadcast-title">${esc(t("team.broadcast.title") || "E-Mail an Auswahl")}</h2>
|
||||
<button type="button" class="modal-close" data-broadcast-close aria-label="${esc(t("common.close") || "Schließen")}">×</button>
|
||||
</header>
|
||||
<form data-broadcast-form>
|
||||
<div class="modal-body">
|
||||
<div class="broadcast-recipient-summary">
|
||||
<strong>${esc(t("team.broadcast.recipients") || "Empfänger")}: ${count}</strong>
|
||||
<button type="button" class="link-button" data-broadcast-toggle-recipients>${esc(t("team.broadcast.show_all") || "Alle anzeigen")}</button>
|
||||
<div class="broadcast-recipient-preview">${previewItems}${more}</div>
|
||||
<div class="broadcast-recipient-list hidden" data-broadcast-recipient-list>
|
||||
<ul>${fullList}</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="broadcast-template-select">${esc(t("team.broadcast.template") || "Vorlage")} <span class="muted">(${esc(t("team.broadcast.template_optional") || "optional")})</span></label>
|
||||
<select id="broadcast-template-select" data-broadcast-template>
|
||||
<option value="">${esc(t("team.broadcast.template_freeform") || "Freitext")}</option>
|
||||
<option value="invitation">${esc(t("team.broadcast.template.invitation") || "Einladung")}</option>
|
||||
<option value="deadline_digest">${esc(t("team.broadcast.template.deadline_digest") || "Frist-Digest")}</option>
|
||||
</select>
|
||||
|
||||
<label for="broadcast-subject">${esc(t("team.broadcast.subject") || "Betreff")}</label>
|
||||
<input type="text" id="broadcast-subject" data-broadcast-subject required maxlength="200" />
|
||||
|
||||
<label for="broadcast-body">${esc(t("team.broadcast.body") || "Nachricht")}</label>
|
||||
<textarea id="broadcast-body" data-broadcast-body required rows="12" placeholder="${esc(t("team.broadcast.body_placeholder") || "Hallo {{first_name}}, …")}"></textarea>
|
||||
|
||||
<p class="broadcast-hint muted">
|
||||
${esc(t("team.broadcast.placeholders_hint") || "Platzhalter: {{name}}, {{first_name}}, {{role_on_project}}")}
|
||||
</p>
|
||||
<p class="broadcast-hint muted">
|
||||
${esc(t("team.broadcast.markdown_hint") || "Markdown unterstützt: **fett**, *kursiv*, [Link](https://...), - Aufzählung.")}
|
||||
</p>
|
||||
|
||||
<div class="broadcast-error hidden" data-broadcast-error></div>
|
||||
<div class="broadcast-success hidden" data-broadcast-success></div>
|
||||
</div>
|
||||
|
||||
<footer class="modal-footer">
|
||||
<button type="button" class="btn btn-ghost" data-broadcast-close>${esc(t("common.cancel") || "Abbrechen")}</button>
|
||||
<button type="submit" class="btn btn-primary" data-broadcast-submit>${esc(t("team.broadcast.send") || "Senden")} (${count})</button>
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenBroadcastModalArgs): Promise<void> {
|
||||
const subject = (form.querySelector<HTMLInputElement>("[data-broadcast-subject]")?.value ?? "").trim();
|
||||
const body = (form.querySelector<HTMLTextAreaElement>("[data-broadcast-body]")?.value ?? "").trim();
|
||||
const templateKey = form.querySelector<HTMLSelectElement>("[data-broadcast-template]")?.value ?? "";
|
||||
const errEl = overlay.querySelector<HTMLDivElement>("[data-broadcast-error]");
|
||||
const okEl = overlay.querySelector<HTMLDivElement>("[data-broadcast-success]");
|
||||
errEl?.classList.add("hidden");
|
||||
okEl?.classList.add("hidden");
|
||||
|
||||
if (!subject) {
|
||||
showError(errEl, t("team.broadcast.error.subject_required") || "Betreff ist erforderlich.");
|
||||
return;
|
||||
}
|
||||
if (!body) {
|
||||
showError(errEl, t("team.broadcast.error.body_required") || "Nachricht ist erforderlich.");
|
||||
return;
|
||||
}
|
||||
|
||||
const submitBtn = form.querySelector<HTMLButtonElement>("[data-broadcast-submit]");
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = t("team.broadcast.sending") || "Sende…";
|
||||
}
|
||||
|
||||
const recipientFilter: Record<string, unknown> = {};
|
||||
if (args.projectIDs?.length) recipientFilter.project_ids = args.projectIDs;
|
||||
if (args.projectID) recipientFilter.project_id = args.projectID;
|
||||
if (args.offices?.length) recipientFilter.offices = args.offices;
|
||||
if (args.roles?.length) recipientFilter.roles = args.roles;
|
||||
|
||||
const lang = (document.documentElement.lang === "en" ? "en" : "de");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/team/broadcast", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
project_id: args.projectID ?? null,
|
||||
subject,
|
||||
body,
|
||||
template_key: templateKey || undefined,
|
||||
lang,
|
||||
recipient_filter: recipientFilter,
|
||||
recipients: args.recipients,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errBody = await res.json().catch(() => ({ error: "Send failed" }));
|
||||
showError(errEl, (errBody as { error?: string }).error || "Send failed");
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = (t("team.broadcast.send") || "Senden") + ` (${args.recipients.length})`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const report = (await res.json()) as { sent: number; failed: number; total: number };
|
||||
if (okEl) {
|
||||
okEl.classList.remove("hidden");
|
||||
const tpl = t("team.broadcast.success") || "{sent} von {total} Mails versandt ({failed} fehlgeschlagen).";
|
||||
okEl.textContent = tpl
|
||||
.replace("{sent}", String(report.sent))
|
||||
.replace("{total}", String(report.total))
|
||||
.replace("{failed}", String(report.failed));
|
||||
}
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = t("team.broadcast.sent") || "Versandt";
|
||||
}
|
||||
setTimeout(() => overlay.remove(), 2500);
|
||||
} catch (e) {
|
||||
showError(errEl, String(e));
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = (t("team.broadcast.send") || "Senden") + ` (${args.recipients.length})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showError(el: HTMLDivElement | null | undefined, msg: string) {
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.classList.remove("hidden");
|
||||
}
|
||||
|
||||
// stripGoTemplate is best-effort: existing email templates carry
|
||||
// `{{define "content"}}` wrappers and Go-template branches the broadcast
|
||||
// compose form can't honour. The bulk-send pipeline expects plain
|
||||
// Markdown + the placeholder set documented in the modal, so we strip
|
||||
// the template directives before populating the textarea. Senders can
|
||||
// still edit further.
|
||||
function stripGoTemplate(src: string): string {
|
||||
return src
|
||||
.replace(/\{\{\s*(define|end|block|if|else|range|with)\b[^}]*\}\}/g, "")
|
||||
.trim();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { initNotes } from "./notes";
|
||||
import { projectIndent } from "./project-indent";
|
||||
import {
|
||||
attachEventTypePicker,
|
||||
fetchEventTypes,
|
||||
@@ -32,6 +33,7 @@ interface Project {
|
||||
id: string;
|
||||
reference?: string | null;
|
||||
title: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
interface DeadlineRule {
|
||||
@@ -51,6 +53,7 @@ let deadline: Deadline | null = null;
|
||||
let project: Project | null = null;
|
||||
let rule: DeadlineRule | null = null;
|
||||
let me: Me | null = null;
|
||||
let allProjects: Project[] = [];
|
||||
|
||||
function parseDeadlineID(): string | null {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
@@ -123,6 +126,30 @@ async function loadProject(projectID: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllProjects() {
|
||||
try {
|
||||
const resp = await fetch("/api/projects");
|
||||
if (resp.ok) allProjects = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function populateProjectPicker() {
|
||||
const sel = document.getElementById("deadline-project-edit") as HTMLSelectElement | null;
|
||||
if (!sel || !deadline) return;
|
||||
const opts: string[] = [];
|
||||
for (const p of allProjects) {
|
||||
const indent = projectIndent(p.path);
|
||||
const ref = p.reference || "";
|
||||
opts.push(
|
||||
`<option value="${esc(p.id)}">${indent}${esc(ref)} — ${esc(p.title)}</option>`,
|
||||
);
|
||||
}
|
||||
sel.innerHTML = opts.join("");
|
||||
sel.value = deadline.project_id;
|
||||
}
|
||||
|
||||
async function loadRule(ruleID: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/deadline-rules`);
|
||||
@@ -261,6 +288,8 @@ function initEdit() {
|
||||
const saveBtn = document.getElementById("deadline-save-btn") as HTMLButtonElement;
|
||||
const etDisplay = document.getElementById("deadline-event-types-display");
|
||||
const etEdit = document.getElementById("deadline-event-types-edit");
|
||||
const projectLink = document.getElementById("deadline-project-link") as HTMLAnchorElement;
|
||||
const projectEdit = document.getElementById("deadline-project-edit") as HTMLSelectElement | null;
|
||||
|
||||
function enterEdit() {
|
||||
titleDisplay.style.display = "none";
|
||||
@@ -271,6 +300,11 @@ function initEdit() {
|
||||
notesEdit.style.display = "";
|
||||
if (etDisplay) etDisplay.style.display = "none";
|
||||
if (etEdit) etEdit.style.display = "";
|
||||
if (projectEdit && deadline) {
|
||||
projectLink.style.display = "none";
|
||||
projectEdit.style.display = "";
|
||||
projectEdit.value = deadline.project_id;
|
||||
}
|
||||
saveBtn.style.display = "";
|
||||
editBtn.style.display = "none";
|
||||
titleEdit.focus();
|
||||
@@ -285,6 +319,10 @@ function initEdit() {
|
||||
notesEdit.style.display = "none";
|
||||
if (etDisplay) etDisplay.style.display = "";
|
||||
if (etEdit) etEdit.style.display = "none";
|
||||
if (projectEdit) {
|
||||
projectEdit.style.display = "none";
|
||||
projectLink.style.display = "";
|
||||
}
|
||||
saveBtn.style.display = "none";
|
||||
editBtn.style.display = "";
|
||||
}
|
||||
@@ -307,13 +345,20 @@ function initEdit() {
|
||||
if (eventTypePicker) {
|
||||
payload.event_type_ids = eventTypePicker.getIDs();
|
||||
}
|
||||
if (projectEdit && projectEdit.value && projectEdit.value !== deadline.project_id) {
|
||||
payload.project_id = projectEdit.value;
|
||||
}
|
||||
const resp = await fetch(`/api/deadlines/${deadline.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (resp.ok) {
|
||||
const prevProjectID = deadline.project_id;
|
||||
deadline = await resp.json();
|
||||
if (deadline && deadline.project_id !== prevProjectID) {
|
||||
await loadProject(deadline.project_id);
|
||||
}
|
||||
render();
|
||||
}
|
||||
} finally {
|
||||
@@ -410,7 +455,7 @@ async function main() {
|
||||
notfound.style.display = "block";
|
||||
return;
|
||||
}
|
||||
await loadProject(deadline.project_id);
|
||||
await Promise.all([loadProject(deadline.project_id), loadAllProjects()]);
|
||||
if (deadline.rule_id) await loadRule(deadline.rule_id);
|
||||
|
||||
// Load event types in parallel; render once ready (the picker re-renders
|
||||
@@ -435,6 +480,7 @@ async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
populateProjectPicker();
|
||||
render();
|
||||
initEdit();
|
||||
initComplete();
|
||||
|
||||
@@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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 */
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,7 @@ async function submitForm(e: Event): Promise<void> {
|
||||
const displayName = (data.get("display_name") as string || "").trim();
|
||||
const office = (data.get("office") as string || "").trim();
|
||||
const jobTitle = (data.get("job_title") as string || "").trim();
|
||||
const profession = (data.get("profession") as string || "").trim();
|
||||
const partnerUnitID = (data.get("partner_unit_id") as string || "").trim();
|
||||
|
||||
if (!displayName) {
|
||||
@@ -141,6 +142,7 @@ async function submitForm(e: Event): Promise<void> {
|
||||
display_name: displayName,
|
||||
office,
|
||||
job_title: jobTitle,
|
||||
profession,
|
||||
};
|
||||
if (partnerUnitID) payload.partner_unit_id = partnerUnitID;
|
||||
|
||||
|
||||
401
frontend/src/client/paliadin.ts
Normal file
401
frontend/src/client/paliadin.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
import { initI18n, getLang, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// Paliadin chat panel client (t-paliad-146 PoC).
|
||||
//
|
||||
// State machine: empty → typing → sending → streaming → done.
|
||||
// History lives in localStorage under "paliadin:history:<sessionId>"
|
||||
// — design §0.5.4 session-only persistence.
|
||||
//
|
||||
// SSE consumer subscribes to `event: meta`, `event: content`,
|
||||
// `event: end`, `event: error`, `event: ping`. Backend currently
|
||||
// emits one `content` blob per turn (real chunked streaming is
|
||||
// production-v1; PoC simulates with a typewriter effect).
|
||||
|
||||
interface HistoryEntry {
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
meta?: {
|
||||
used_tools?: string[];
|
||||
rows_seen?: number[];
|
||||
classifier_tag?: string;
|
||||
duration_ms?: number;
|
||||
chip_count?: number;
|
||||
};
|
||||
ts: string; // ISO
|
||||
}
|
||||
|
||||
const SESSION_KEY = "paliadin:session";
|
||||
const HISTORY_PREFIX = "paliadin:history:";
|
||||
|
||||
let sessionId: string;
|
||||
let history: HistoryEntry[] = [];
|
||||
let currentEventSource: EventSource | null = null;
|
||||
let currentTurnId: string | null = null;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
bootSession();
|
||||
wireForm();
|
||||
wireStarters();
|
||||
wireReset();
|
||||
renderHistory();
|
||||
});
|
||||
|
||||
function bootSession(): void {
|
||||
let s = localStorage.getItem(SESSION_KEY);
|
||||
if (!s) {
|
||||
s = crypto.randomUUID();
|
||||
localStorage.setItem(SESSION_KEY, s);
|
||||
}
|
||||
sessionId = s;
|
||||
const stored = localStorage.getItem(HISTORY_PREFIX + sessionId);
|
||||
if (stored) {
|
||||
try {
|
||||
history = JSON.parse(stored);
|
||||
} catch {
|
||||
history = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wireForm(): void {
|
||||
const form = document.getElementById("paliadin-form") as HTMLFormElement | null;
|
||||
const input = document.getElementById("paliadin-input") as HTMLTextAreaElement | null;
|
||||
if (!form || !input) return;
|
||||
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
input.value = "";
|
||||
sendTurn(text);
|
||||
});
|
||||
|
||||
// Enter sends; Shift+Enter inserts newline.
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
form.dispatchEvent(new Event("submit"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function wireStarters(): void {
|
||||
const starters = document.querySelectorAll<HTMLButtonElement>(".paliadin-starter");
|
||||
starters.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const lang = getLang();
|
||||
const promptText = lang === "en"
|
||||
? btn.dataset.promptEn || btn.textContent?.trim() || ""
|
||||
: btn.dataset.promptDe || btn.textContent?.trim() || "";
|
||||
if (promptText) sendTurn(promptText);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function wireReset(): void {
|
||||
const btn = document.getElementById("paliadin-reset");
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
history = [];
|
||||
saveHistory();
|
||||
renderHistory();
|
||||
try {
|
||||
await fetch("/api/paliadin/reset", { method: "POST", credentials: "same-origin" });
|
||||
} catch {
|
||||
// Reset failure is non-fatal — the next turn will spin up a fresh pane anyway.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function sendTurn(text: string): Promise<void> {
|
||||
// Hide empty state on first send.
|
||||
const empty = document.getElementById("paliadin-empty");
|
||||
if (empty) empty.style.display = "none";
|
||||
|
||||
// Append user bubble.
|
||||
history.push({ role: "user", text, ts: new Date().toISOString() });
|
||||
saveHistory();
|
||||
appendBubble("user", text);
|
||||
|
||||
// Insert placeholder assistant bubble.
|
||||
const placeholder = appendBubble("assistant", "");
|
||||
placeholder.dataset.streaming = "true";
|
||||
placeholder.querySelector(".paliadin-bubble-text")!.textContent = "Paliadin denkt nach …";
|
||||
|
||||
toggleStopButton(true);
|
||||
|
||||
// Kick off the turn.
|
||||
let turnRes: { turn_id: string; sse_url: string };
|
||||
try {
|
||||
const r = await fetch("/api/paliadin/turn", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({
|
||||
user_message: text,
|
||||
session_id: sessionId,
|
||||
page_origin: "/paliadin",
|
||||
}),
|
||||
});
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
turnRes = await r.json();
|
||||
} catch (err) {
|
||||
placeholder.querySelector(".paliadin-bubble-text")!.textContent =
|
||||
t("paliadin.error.upstream");
|
||||
placeholder.dataset.streaming = "false";
|
||||
placeholder.classList.add("paliadin-bubble--error");
|
||||
toggleStopButton(false);
|
||||
return;
|
||||
}
|
||||
|
||||
currentTurnId = turnRes.turn_id;
|
||||
|
||||
// Open SSE.
|
||||
const es = new EventSource(turnRes.sse_url);
|
||||
currentEventSource = es;
|
||||
|
||||
es.addEventListener("meta", () => {
|
||||
// Could surface a "thinking" indicator; placeholder text already does.
|
||||
});
|
||||
|
||||
es.addEventListener("content", (ev) => {
|
||||
const data = JSON.parse((ev as MessageEvent).data);
|
||||
const text = String(data.text || "");
|
||||
typewriter(placeholder, text);
|
||||
});
|
||||
|
||||
es.addEventListener("end", (ev) => {
|
||||
const data = JSON.parse((ev as MessageEvent).data);
|
||||
placeholder.dataset.streaming = "false";
|
||||
finishBubble(placeholder, data);
|
||||
history.push({
|
||||
role: "assistant",
|
||||
text: getBubbleText(placeholder),
|
||||
meta: {
|
||||
used_tools: data.used_tools,
|
||||
rows_seen: data.rows_seen,
|
||||
classifier_tag: data.classifier_tag,
|
||||
duration_ms: data.duration_ms,
|
||||
chip_count: data.chip_count,
|
||||
},
|
||||
ts: new Date().toISOString(),
|
||||
});
|
||||
saveHistory();
|
||||
cleanupTurn();
|
||||
});
|
||||
|
||||
es.addEventListener("error", (ev) => {
|
||||
placeholder.querySelector(".paliadin-bubble-text")!.textContent =
|
||||
friendlyErrorMessage((ev as MessageEvent).data);
|
||||
placeholder.classList.add("paliadin-bubble--error");
|
||||
placeholder.dataset.streaming = "false";
|
||||
cleanupTurn();
|
||||
});
|
||||
|
||||
es.addEventListener("ping", () => {
|
||||
// heartbeat — no-op
|
||||
});
|
||||
}
|
||||
|
||||
// Server emits SSE error events as JSON `{code, message}`. Map known
|
||||
// codes to localised, user-friendly text; fall through to a generic
|
||||
// "connection lost" for anything we don't recognise (including raw
|
||||
// EventSource transport errors where data is absent).
|
||||
function friendlyErrorMessage(data: unknown): string {
|
||||
if (typeof data !== "string" || data === "") {
|
||||
return t("paliadin.error.connection_lost");
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(data) as { code?: string };
|
||||
if (parsed.code === "tmux_unavailable") {
|
||||
return t("paliadin.error.local_only");
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — fall through to the generic connection-lost message
|
||||
// rather than leaking a raw payload into the bubble.
|
||||
}
|
||||
return t("paliadin.error.connection_lost");
|
||||
}
|
||||
|
||||
function cleanupTurn(): void {
|
||||
if (currentEventSource) {
|
||||
currentEventSource.close();
|
||||
currentEventSource = null;
|
||||
}
|
||||
currentTurnId = null;
|
||||
toggleStopButton(false);
|
||||
}
|
||||
|
||||
function toggleStopButton(streaming: boolean): void {
|
||||
const send = document.getElementById("paliadin-send") as HTMLButtonElement | null;
|
||||
const stop = document.getElementById("paliadin-stop") as HTMLButtonElement | null;
|
||||
if (send) send.style.display = streaming ? "none" : "";
|
||||
if (stop) {
|
||||
stop.style.display = streaming ? "" : "none";
|
||||
stop.onclick = () => {
|
||||
cleanupTurn();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function appendBubble(role: "user" | "assistant", text: string): HTMLElement {
|
||||
const stream = document.getElementById("paliadin-stream")!;
|
||||
const bubble = document.createElement("div");
|
||||
bubble.className = "paliadin-bubble paliadin-bubble--" + role;
|
||||
bubble.innerHTML = `
|
||||
<div class="paliadin-bubble-role">${role === "user" ? "Du" : "Paliadin"}</div>
|
||||
<div class="paliadin-bubble-text"></div>
|
||||
<div class="paliadin-bubble-meta" style="display:none"></div>
|
||||
`;
|
||||
bubble.querySelector(".paliadin-bubble-text")!.textContent = text;
|
||||
stream.appendChild(bubble);
|
||||
stream.scrollTop = stream.scrollHeight;
|
||||
return bubble;
|
||||
}
|
||||
|
||||
// typewriter incrementally fills the bubble's text node so a one-shot
|
||||
// content blob feels like streaming. ~5 ms per character; fast enough
|
||||
// to keep up with even a 4k-char response.
|
||||
function typewriter(bubble: HTMLElement, text: string): void {
|
||||
const node = bubble.querySelector(".paliadin-bubble-text")!;
|
||||
node.textContent = "";
|
||||
let i = 0;
|
||||
const speed = 6;
|
||||
const tick = () => {
|
||||
if (bubble.dataset.streaming !== "true") {
|
||||
// Aborted — flush remaining text instantly.
|
||||
node.textContent = text;
|
||||
return;
|
||||
}
|
||||
if (i >= text.length) return;
|
||||
const next = Math.min(i + 8, text.length);
|
||||
node.textContent = text.slice(0, next);
|
||||
i = next;
|
||||
const stream = document.getElementById("paliadin-stream")!;
|
||||
stream.scrollTop = stream.scrollHeight;
|
||||
setTimeout(tick, speed);
|
||||
};
|
||||
tick();
|
||||
}
|
||||
|
||||
function getBubbleText(bubble: HTMLElement): string {
|
||||
return bubble.querySelector(".paliadin-bubble-text")?.textContent || "";
|
||||
}
|
||||
|
||||
// finishBubble parses the response for citation markers + tool-use
|
||||
// evidence and renders both. Markers found in the text get replaced
|
||||
// by anchor buttons; the meta row at the bottom shows
|
||||
// "ran search_my_deadlines (3 results)".
|
||||
function finishBubble(bubble: HTMLElement, data: any): void {
|
||||
const textNode = bubble.querySelector(".paliadin-bubble-text")! as HTMLElement;
|
||||
const raw = textNode.textContent || "";
|
||||
textNode.innerHTML = renderResponseHTML(raw);
|
||||
|
||||
const metaEl = bubble.querySelector(".paliadin-bubble-meta") as HTMLElement | null;
|
||||
if (metaEl) {
|
||||
const tools = (data.used_tools || []) as string[];
|
||||
const rows = (data.rows_seen || []) as number[];
|
||||
if (tools.length > 0) {
|
||||
const parts = tools.map((t, i) => {
|
||||
const r = rows[i];
|
||||
return r != null ? `${t} (${r})` : t;
|
||||
});
|
||||
metaEl.innerHTML = "▸ " + parts.join(" · ");
|
||||
metaEl.style.display = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Marker → button render. Mirrors §4.4 of the design.
|
||||
const CHIP_RE = /\[(?:#([a-z]+)-OPEN:([A-Za-z0-9\-_]+)|chip:([a-z]+):([^\]]+))\]/g;
|
||||
function renderResponseHTML(raw: string): string {
|
||||
// First escape any HTML in the raw text (simple textContent → innerHTML
|
||||
// would have been fine but we then need to inject anchors, so the
|
||||
// manual escape is unavoidable).
|
||||
const esc = raw
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
|
||||
// Walk markers; replace each with a paliadin-chip anchor.
|
||||
return esc.replace(CHIP_RE, (_match, kind, id, chipKind, chipArg) => {
|
||||
if (kind && id) {
|
||||
const url = chipURL(kind, id);
|
||||
const label = chipLabel(kind);
|
||||
return `<a class="paliadin-chip" href="${url}">${label}</a>`;
|
||||
}
|
||||
if (chipKind === "nav") {
|
||||
return `<a class="paliadin-chip" href="${chipArg}">öffnen</a>`;
|
||||
}
|
||||
if (chipKind === "filter") {
|
||||
return `<a class="paliadin-chip" href="/inbox?${chipArg}">Filter anwenden</a>`;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
}
|
||||
|
||||
function chipURL(kind: string, id: string): string {
|
||||
switch (kind) {
|
||||
case "deadline":
|
||||
case "frist":
|
||||
return "/deadlines/" + id;
|
||||
case "projekt":
|
||||
case "project":
|
||||
return "/projects/" + id;
|
||||
case "termin":
|
||||
case "appointment":
|
||||
return "/appointments/" + id;
|
||||
default:
|
||||
return "#";
|
||||
}
|
||||
}
|
||||
|
||||
function chipLabel(kind: string): string {
|
||||
switch (kind) {
|
||||
case "deadline":
|
||||
case "frist":
|
||||
return "Frist öffnen";
|
||||
case "projekt":
|
||||
case "project":
|
||||
return "Akte ansehen";
|
||||
case "termin":
|
||||
case "appointment":
|
||||
return "Termin öffnen";
|
||||
default:
|
||||
return "öffnen";
|
||||
}
|
||||
}
|
||||
|
||||
function saveHistory(): void {
|
||||
localStorage.setItem(HISTORY_PREFIX + sessionId, JSON.stringify(history));
|
||||
}
|
||||
|
||||
function renderHistory(): void {
|
||||
const stream = document.getElementById("paliadin-stream");
|
||||
if (!stream) return;
|
||||
// Clear non-empty bubbles, keep the empty-state.
|
||||
Array.from(stream.children).forEach((el) => {
|
||||
if (!el.classList.contains("paliadin-empty")) el.remove();
|
||||
});
|
||||
if (history.length === 0) {
|
||||
const empty = document.getElementById("paliadin-empty");
|
||||
if (empty) empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
const empty = document.getElementById("paliadin-empty");
|
||||
if (empty) empty.style.display = "none";
|
||||
history.forEach((h) => {
|
||||
const bubble = appendBubble(h.role, h.text);
|
||||
if (h.role === "assistant" && h.meta) {
|
||||
bubble.dataset.streaming = "false";
|
||||
finishBubble(bubble, {
|
||||
used_tools: h.meta.used_tools,
|
||||
rows_seen: h.meta.rows_seen,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { t, tDyn } from "./i18n";
|
||||
|
||||
// Tree view of paliad.projects rendered into a container element. Reads
|
||||
// /api/projects/tree once on first init and caches the response. Top two
|
||||
// levels expand by default; deeper nodes start collapsed and toggle via the
|
||||
// chevron.
|
||||
// Tree view of paliad.projects rendered into a container element.
|
||||
// t-paliad-149 redesign: tree fetches with the orchestrator's chip / search
|
||||
// state encoded as query params on /api/projects/tree, so the cache invalidates
|
||||
// when the orchestrator calls refreshProjectTree({ params }).
|
||||
|
||||
export interface ProjectTreeNode {
|
||||
id: string;
|
||||
@@ -17,10 +17,22 @@ export interface ProjectTreeNode {
|
||||
matter_number?: string | null;
|
||||
open_deadlines: number;
|
||||
overdue_deadlines: number;
|
||||
// t-paliad-149: subtree-aggregated counts populated when ?subtree_counts=true
|
||||
// (the new default). Per-node fields above stay populated regardless.
|
||||
open_deadlines_subtree?: number;
|
||||
overdue_deadlines_subtree?: number;
|
||||
// t-paliad-149: pin state on /api/projects/tree response.
|
||||
pinned?: boolean;
|
||||
// t-paliad-149: greyed-ancestor flag (Scope=Mine / Scope=Pinned).
|
||||
inherited_visibility?: boolean;
|
||||
// t-paliad-149: search match kind. Empty when no search active.
|
||||
match_kind?: "self" | "ancestor" | "descendant" | "";
|
||||
children: ProjectTreeNode[];
|
||||
}
|
||||
|
||||
let cache: ProjectTreeNode[] | null = null;
|
||||
let cacheParams = "";
|
||||
let useSubtreeCounts = true;
|
||||
const expandedKey = "paliad.projectTree.expanded";
|
||||
let expanded = loadExpanded();
|
||||
|
||||
@@ -121,8 +133,12 @@ function renderNode(node: ProjectTreeNode, depth: number): string {
|
||||
const statusLabel = tDyn(`projects.filter.status.${node.status}`) || node.status;
|
||||
const cm = clientMatter(node);
|
||||
const ref = node.reference || "";
|
||||
const overdue = node.overdue_deadlines;
|
||||
const openCount = node.open_deadlines;
|
||||
|
||||
// Subtree-aggregated counts when available, per-node otherwise (the
|
||||
// chip-row toolbar can flip the orchestrator's subtree_counts param).
|
||||
const useSubtree = useSubtreeCounts && (node.open_deadlines_subtree !== undefined);
|
||||
const overdue = useSubtree ? (node.overdue_deadlines_subtree ?? 0) : node.overdue_deadlines;
|
||||
const openCount = useSubtree ? (node.open_deadlines_subtree ?? 0) : node.open_deadlines;
|
||||
|
||||
const toggle = hasChildren
|
||||
? `<button class="projekt-tree-toggle${open ? " is-open" : ""}" type="button" aria-label="${esc(t("projects.tree.toggle") || "Aufklappen / Zuklappen")}">${chevron}</button>`
|
||||
@@ -130,13 +146,24 @@ function renderNode(node: ProjectTreeNode, depth: number): string {
|
||||
|
||||
const icon = typeIcons[node.type] || typeIcons.project;
|
||||
|
||||
// Pin star — always-visible (touch-friendly per design §4.6).
|
||||
const pinned = !!node.pinned;
|
||||
const pinLabel = pinned
|
||||
? (t("projects.tree.unpin") || "Pin entfernen")
|
||||
: (t("projects.tree.pin") || "Anpinnen");
|
||||
const pinStar = `<button class="projekt-tree-pin${pinned ? " is-pinned" : ""}" type="button" data-action="pin" aria-label="${esc(pinLabel)}" title="${esc(pinLabel)}">${pinned ? starFilled : starOutline}</button>`;
|
||||
|
||||
const subtreeHint = useSubtree
|
||||
? (t("projects.tree.deadlines.subtree.tooltip") || "Inkl. Unterprojekte")
|
||||
: (t("projects.tree.deadlines.direct.tooltip") || "Nur direkt");
|
||||
|
||||
let badges = "";
|
||||
if (overdue > 0) {
|
||||
const label = t("projects.tree.deadlines.overdue") || "überfällig";
|
||||
const label = (t("projects.tree.deadlines.overdue") || "überfällig") + " — " + subtreeHint;
|
||||
badges += `<span class="projekt-tree-badge projekt-tree-badge-overdue" title="${esc(label)}">${overdue}</span>`;
|
||||
}
|
||||
if (openCount > 0 && overdue === 0) {
|
||||
const label = t("projects.tree.deadlines.open") || "offene Fristen";
|
||||
const label = (t("projects.tree.deadlines.open") || "offene Fristen") + " — " + subtreeHint;
|
||||
badges += `<span class="projekt-tree-badge projekt-tree-badge-open" title="${esc(label)}">${openCount}</span>`;
|
||||
}
|
||||
|
||||
@@ -148,11 +175,24 @@ function renderNode(node: ProjectTreeNode, depth: number): string {
|
||||
? `<ul class="projekt-tree-children" role="group">${node.children.map((c) => renderNode(c, depth + 1)).join("")}</ul>`
|
||||
: "";
|
||||
|
||||
// Modifier classes for the row:
|
||||
// - is-inherited: greyed-ancestor under Scope=Mine / Scope=Pinned (design §3.3)
|
||||
// - is-match-self / is-match-ancestor / is-match-descendant: search highlighting
|
||||
const modifiers: string[] = [];
|
||||
if (node.inherited_visibility) modifiers.push("is-inherited");
|
||||
if (node.match_kind === "self") modifiers.push("is-match-self");
|
||||
if (node.match_kind === "ancestor") modifiers.push("is-match-ancestor");
|
||||
if (node.match_kind === "descendant") modifiers.push("is-match-descendant");
|
||||
const rowClass = ["projekt-tree-row", ...modifiers].join(" ");
|
||||
const inheritedHint = node.inherited_visibility
|
||||
? ` title="${esc(t("projects.tree.inherited.context") || "Sichtbar wegen Unterprojekt")}"`
|
||||
: "";
|
||||
|
||||
return (
|
||||
`<li class="projekt-tree-node" data-id="${esc(node.id)}" data-depth="${depth}" role="treeitem"` +
|
||||
(hasChildren ? ` aria-expanded="${open ? "true" : "false"}"` : "") +
|
||||
`>` +
|
||||
`<div class="projekt-tree-row" tabindex="0">` +
|
||||
`<div class="${rowClass}" tabindex="0"${inheritedHint}>` +
|
||||
toggle +
|
||||
`<span class="projekt-tree-icon projekt-tree-icon-${esc(node.type)}">${icon}</span>` +
|
||||
`<span class="projekt-tree-title">${esc(node.title)}</span>` +
|
||||
@@ -161,16 +201,32 @@ function renderNode(node: ProjectTreeNode, depth: number): string {
|
||||
`<span class="projekt-tree-spacer"></span>` +
|
||||
badges +
|
||||
`<span class="projekt-tree-status entity-status-chip entity-status-${esc(node.status)}">${esc(statusLabel)}</span>` +
|
||||
pinStar +
|
||||
`</div>` +
|
||||
childMarkup +
|
||||
`</li>`
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchTree(container: HTMLElement): Promise<ProjectTreeNode[] | null> {
|
||||
if (cache) return cache;
|
||||
// Lucide-style filled / outline star for the pin toggle.
|
||||
const starFilled =
|
||||
`<svg viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true">` +
|
||||
`<polygon points="12 2 15 9 22 10 17 15 18 22 12 18 6 22 7 15 2 10 9 9"/>` +
|
||||
`</svg>`;
|
||||
const starOutline =
|
||||
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" aria-hidden="true">` +
|
||||
`<polygon points="12 2 15 9 22 10 17 15 18 22 12 18 6 22 7 15 2 10 9 9"/>` +
|
||||
`</svg>`;
|
||||
|
||||
// fetchTree calls /api/projects/tree with the orchestrator's query params.
|
||||
// The cache key is the params string — any change reloads. Pass an empty
|
||||
// URLSearchParams for the legacy "all visible projects" call.
|
||||
async function fetchTree(container: HTMLElement, params: URLSearchParams): Promise<ProjectTreeNode[] | null> {
|
||||
const key = params.toString();
|
||||
if (cache && cacheParams === key) return cache;
|
||||
const url = key ? `/api/projects/tree?${key}` : "/api/projects/tree";
|
||||
try {
|
||||
const resp = await fetch("/api/projects/tree");
|
||||
const resp = await fetch(url);
|
||||
if (resp.status === 503) {
|
||||
container.innerHTML = `<div class="projekt-tree-unavailable" data-i18n="projects.unavailable">${esc(t("projects.unavailable") || "Projektverwaltung zurzeit nicht verfügbar")}</div>`;
|
||||
return null;
|
||||
@@ -180,6 +236,7 @@ async function fetchTree(container: HTMLElement): Promise<ProjectTreeNode[] | nu
|
||||
return null;
|
||||
}
|
||||
cache = (await resp.json()) as ProjectTreeNode[];
|
||||
cacheParams = key;
|
||||
return cache;
|
||||
} catch {
|
||||
container.innerHTML = `<div class="projekt-tree-error">${esc(t("projects.tree.error") || "Baumansicht konnte nicht geladen werden.")}</div>`;
|
||||
@@ -192,6 +249,7 @@ function attachHandlers(container: HTMLElement) {
|
||||
const row = node.querySelector<HTMLElement>(":scope > .projekt-tree-row");
|
||||
if (!row) return;
|
||||
const toggle = row.querySelector<HTMLElement>(".projekt-tree-toggle");
|
||||
const pinBtn = row.querySelector<HTMLElement>(".projekt-tree-pin");
|
||||
const id = node.dataset.id!;
|
||||
const depth = Number(node.dataset.depth || "0");
|
||||
|
||||
@@ -199,9 +257,23 @@ function attachHandlers(container: HTMLElement) {
|
||||
window.location.href = `/projects/${id}`;
|
||||
};
|
||||
|
||||
// Pin toggle — POST/DELETE, optimistic update on the row, hard refresh
|
||||
// of the cache afterwards so subtree counts (if visible) stay coherent.
|
||||
if (pinBtn) {
|
||||
pinBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const data = cache && findById(cache, id);
|
||||
if (!data) return;
|
||||
void togglePin(data, pinBtn);
|
||||
});
|
||||
}
|
||||
|
||||
if (toggle && toggle.classList.contains("is-leaf")) {
|
||||
// No children — entire row navigates.
|
||||
row.addEventListener("click", navigate);
|
||||
row.addEventListener("click", (e) => {
|
||||
if ((e.target as HTMLElement).closest(".projekt-tree-pin")) return;
|
||||
navigate();
|
||||
});
|
||||
row.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
@@ -223,8 +295,10 @@ function attachHandlers(container: HTMLElement) {
|
||||
}
|
||||
|
||||
row.addEventListener("click", (e) => {
|
||||
// Clicking the row (but not the toggle) navigates.
|
||||
if ((e.target as HTMLElement).closest(".projekt-tree-toggle")) return;
|
||||
// Clicking the row (but not the toggle / pin) navigates.
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest(".projekt-tree-toggle")) return;
|
||||
if (target.closest(".projekt-tree-pin")) return;
|
||||
navigate();
|
||||
});
|
||||
row.addEventListener("keydown", (e) => {
|
||||
@@ -243,6 +317,41 @@ function attachHandlers(container: HTMLElement) {
|
||||
});
|
||||
}
|
||||
|
||||
async function togglePin(node: ProjectTreeNode, btn: HTMLElement) {
|
||||
const wasPinned = !!node.pinned;
|
||||
// Optimistic flip — visually toggle the star immediately so the user
|
||||
// sees feedback even on slow networks.
|
||||
node.pinned = !wasPinned;
|
||||
btn.classList.toggle("is-pinned", node.pinned);
|
||||
btn.innerHTML = node.pinned ? starFilled : starOutline;
|
||||
const newLabel = node.pinned
|
||||
? (t("projects.tree.unpin") || "Pin entfernen")
|
||||
: (t("projects.tree.pin") || "Anpinnen");
|
||||
btn.setAttribute("aria-label", newLabel);
|
||||
btn.setAttribute("title", newLabel);
|
||||
|
||||
try {
|
||||
const method = wasPinned ? "DELETE" : "POST";
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(node.id)}/pin`, { method });
|
||||
if (!resp.ok && resp.status !== 201 && resp.status !== 204) {
|
||||
// Revert on failure.
|
||||
node.pinned = wasPinned;
|
||||
btn.classList.toggle("is-pinned", node.pinned);
|
||||
btn.innerHTML = node.pinned ? starFilled : starOutline;
|
||||
return;
|
||||
}
|
||||
// Success — invalidate cache so the next chip-driven refresh
|
||||
// (e.g. user clicks "Angepinnt") gets fresh server state.
|
||||
cache = null;
|
||||
cacheParams = "";
|
||||
} catch {
|
||||
// Revert on network error.
|
||||
node.pinned = wasPinned;
|
||||
btn.classList.toggle("is-pinned", node.pinned);
|
||||
btn.innerHTML = node.pinned ? starFilled : starOutline;
|
||||
}
|
||||
}
|
||||
|
||||
function findById(nodes: ProjectTreeNode[], id: string): ProjectTreeNode | null {
|
||||
for (const n of nodes) {
|
||||
if (n.id === id) return n;
|
||||
@@ -253,6 +362,7 @@ function findById(nodes: ProjectTreeNode[], id: string): ProjectTreeNode | null
|
||||
}
|
||||
|
||||
let mountContainer: HTMLElement | null = null;
|
||||
let mountParams: URLSearchParams = new URLSearchParams();
|
||||
|
||||
function rerender() {
|
||||
if (!mountContainer || !cache) return;
|
||||
@@ -265,20 +375,35 @@ function rerender() {
|
||||
attachHandlers(mountContainer);
|
||||
}
|
||||
|
||||
export async function initProjectTree(container: HTMLElement) {
|
||||
// initProjectTree mounts the tree at `container`. The optional params encode
|
||||
// the orchestrator's chip / search state — see /api/projects/tree handler.
|
||||
// Empty params → legacy "every visible project" behaviour.
|
||||
export async function initProjectTree(container: HTMLElement, params?: URLSearchParams) {
|
||||
mountContainer = container;
|
||||
mountParams = params ? new URLSearchParams(params) : new URLSearchParams();
|
||||
// Honour the orchestrator's subtree_counts param when the tree renders
|
||||
// its badges. Default true.
|
||||
const sc = mountParams.get("subtree_counts");
|
||||
useSubtreeCounts = sc === null ? true : sc === "true";
|
||||
// If params changed, the cache is stale.
|
||||
if (cache && cacheParams !== mountParams.toString()) {
|
||||
cache = null;
|
||||
}
|
||||
if (!cache) {
|
||||
container.innerHTML = `<div class="projekt-tree-loading">${esc(t("projects.tree.loading") || "Baum wird geladen…")}</div>`;
|
||||
const data = await fetchTree(container);
|
||||
const data = await fetchTree(container, mountParams);
|
||||
if (!data) return;
|
||||
}
|
||||
rerender();
|
||||
}
|
||||
|
||||
export function refreshProjectTree() {
|
||||
// refreshProjectTree forces a fresh fetch — used by the orchestrator
|
||||
// after a chip change or after a pin toggle invalidates the cache.
|
||||
export function refreshProjectTree(params?: URLSearchParams) {
|
||||
cache = null;
|
||||
cacheParams = "";
|
||||
if (mountContainer) {
|
||||
void initProjectTree(mountContainer);
|
||||
void initProjectTree(mountContainer, params || mountParams);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
796
frontend/src/client/projects-cards.ts
Normal file
796
frontend/src/client/projects-cards.ts
Normal file
@@ -0,0 +1,796 @@
|
||||
import { t, tDyn, getLang } from "./i18n";
|
||||
|
||||
// /projects Cards view (t-paliad-149 PR 2).
|
||||
//
|
||||
// Renders one card per project with configurable facts (title row, type
|
||||
// chip, status, clientmatter, parent path, deadline counts, next 3 events,
|
||||
// last 3 Verlauf entries, team chips). Layout is per-user, named, and
|
||||
// drag-rearrangeable in edit mode (see editMode flag below).
|
||||
//
|
||||
// Data flow:
|
||||
// 1. orchestrator (client/projects.ts) calls renderCardsView(...)
|
||||
// 2. we fetch the active layout (GET /api/user-card-layouts → default first)
|
||||
// 3. we fetch the projects tree with the orchestrator's chip/search params
|
||||
// 4. we fetch /api/projects/cards-preview for per-project event rollups
|
||||
// 5. flatten tree to cards (leaf-ish only by default; "Alle Ebenen" toggle)
|
||||
// 6. render the grid; lazy-fill preview slots via IntersectionObserver
|
||||
// when the project list is huge (cap currently 200 — paliad fits today)
|
||||
//
|
||||
// Edit mode toggles in-place: each card grows drag handles + visibility
|
||||
// toggles + count steppers; the toolbar grows save/discard/rename/delete.
|
||||
|
||||
import type { ProjectTreeNode } from "./project-tree";
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Types — mirror internal/services/layout_spec.go
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export type FactKey =
|
||||
| "title-row"
|
||||
| "type-chip"
|
||||
| "status-chip"
|
||||
| "client-matter"
|
||||
| "parent-path"
|
||||
| "deadline-counts"
|
||||
| "next-events"
|
||||
| "recent-verlauf"
|
||||
| "team-chips"
|
||||
| "reference"
|
||||
| "last-activity-at";
|
||||
|
||||
const ALL_FACT_KEYS: FactKey[] = [
|
||||
"title-row",
|
||||
"type-chip",
|
||||
"status-chip",
|
||||
"client-matter",
|
||||
"parent-path",
|
||||
"deadline-counts",
|
||||
"next-events",
|
||||
"recent-verlauf",
|
||||
"team-chips",
|
||||
"reference",
|
||||
"last-activity-at",
|
||||
];
|
||||
|
||||
interface LayoutFact {
|
||||
key: FactKey;
|
||||
visible: boolean;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
interface LayoutSpec {
|
||||
facts: LayoutFact[];
|
||||
density: "compact" | "roomy";
|
||||
grid_columns: "auto" | "2" | "3" | "4";
|
||||
show_all_levels: boolean;
|
||||
}
|
||||
|
||||
interface UserCardLayout {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
is_default: boolean;
|
||||
layout: LayoutSpec;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface CardEventPreview {
|
||||
kind: "deadline" | "appointment" | "project_event";
|
||||
id: string;
|
||||
title: string;
|
||||
event_date: string;
|
||||
status?: string | null;
|
||||
actor_name?: string | null;
|
||||
route: string;
|
||||
}
|
||||
|
||||
interface ProjectCardPreview {
|
||||
project_id: string;
|
||||
next_events: CardEventPreview[];
|
||||
recent_verlauf: CardEventPreview[];
|
||||
team_initials: string[];
|
||||
team_count: number;
|
||||
last_activity_at?: string | null;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Module state
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
let layouts: UserCardLayout[] = [];
|
||||
let activeLayoutId: string | null = null;
|
||||
let editMode = false;
|
||||
let editDraft: LayoutSpec | null = null;
|
||||
let treeCache: ProjectTreeNode[] = [];
|
||||
let previewCache: Map<string, ProjectCardPreview> = new Map();
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Public entry — called by the orchestrator when view-mode = "cards"
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export interface CardsViewOpts {
|
||||
treeParams: URLSearchParams;
|
||||
}
|
||||
|
||||
export async function renderCardsView(opts: CardsViewOpts) {
|
||||
const wrap = document.getElementById("projects-cards-wrap");
|
||||
const toolbar = document.getElementById("projects-cards-toolbar");
|
||||
const grid = document.getElementById("projects-cards-grid");
|
||||
if (!wrap || !toolbar || !grid) return;
|
||||
wrap.style.display = "block";
|
||||
toolbar.style.display = "flex";
|
||||
|
||||
// Step 1: layouts.
|
||||
if (layouts.length === 0) {
|
||||
await reloadLayouts();
|
||||
}
|
||||
if (layouts.length === 0) {
|
||||
grid.innerHTML = `<div class="projects-cards-empty">${escHTML(t("projects.cards.empty") || "Keine Projekte zum Anzeigen.")}</div>`;
|
||||
return;
|
||||
}
|
||||
if (!activeLayoutId) {
|
||||
const def = layouts.find((l) => l.is_default) || layouts[0];
|
||||
activeLayoutId = def.id;
|
||||
}
|
||||
populateLayoutSelect();
|
||||
attachToolbarHandlers();
|
||||
|
||||
// Step 2: tree (chip/search-narrowed) + cards preview.
|
||||
const treeURL = `/api/projects/tree?${opts.treeParams.toString()}`;
|
||||
const previewURL = `/api/projects/cards-preview`;
|
||||
const [treeResp, previewResp] = await Promise.all([
|
||||
fetch(treeURL).then((r) => (r.ok ? r.json() : [])),
|
||||
fetch(previewURL).then((r) => (r.ok ? r.json() : [])),
|
||||
]);
|
||||
treeCache = treeResp as ProjectTreeNode[];
|
||||
previewCache = new Map();
|
||||
for (const p of previewResp as ProjectCardPreview[]) {
|
||||
previewCache.set(p.project_id, p);
|
||||
}
|
||||
|
||||
rerender();
|
||||
}
|
||||
|
||||
export function teardownCardsView() {
|
||||
const wrap = document.getElementById("projects-cards-wrap");
|
||||
const toolbar = document.getElementById("projects-cards-toolbar");
|
||||
const editToolbar = document.getElementById("projects-cards-edit-toolbar");
|
||||
if (wrap) wrap.style.display = "none";
|
||||
if (toolbar) toolbar.style.display = "none";
|
||||
if (editToolbar) editToolbar.style.display = "none";
|
||||
editMode = false;
|
||||
editDraft = null;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Layout management — fetch + select + edit mode
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
async function reloadLayouts(): Promise<void> {
|
||||
const resp = await fetch("/api/user-card-layouts");
|
||||
if (!resp.ok) {
|
||||
layouts = [];
|
||||
return;
|
||||
}
|
||||
layouts = (await resp.json()) as UserCardLayout[];
|
||||
// Server may not have any rows yet — auto-seed by hitting the cards
|
||||
// view's "default" path: GET /api/user-card-layouts/__seed-default__
|
||||
// Actually the seed happens server-side on GetDefault; we trigger it
|
||||
// by making a no-op preview request which doesn't pull layouts. The
|
||||
// simplest path: if empty, POST a Standard layout from the client.
|
||||
if (layouts.length === 0) {
|
||||
const seed = await fetch("/api/user-card-layouts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "Standard", layout: defaultLayout(), is_default: true }),
|
||||
});
|
||||
if (seed.ok) {
|
||||
const row = (await seed.json()) as UserCardLayout;
|
||||
layouts = [row];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function defaultLayout(): LayoutSpec {
|
||||
return {
|
||||
facts: [
|
||||
{ key: "title-row", visible: true },
|
||||
{ key: "type-chip", visible: true },
|
||||
{ key: "status-chip", visible: true },
|
||||
{ key: "client-matter", visible: true },
|
||||
{ key: "parent-path", visible: true },
|
||||
{ key: "deadline-counts", visible: true },
|
||||
{ key: "next-events", visible: true, count: 3 },
|
||||
{ key: "recent-verlauf", visible: true, count: 3 },
|
||||
{ key: "team-chips", visible: true },
|
||||
],
|
||||
density: "roomy",
|
||||
grid_columns: "auto",
|
||||
show_all_levels: false,
|
||||
};
|
||||
}
|
||||
|
||||
function getActiveLayout(): UserCardLayout | null {
|
||||
if (!activeLayoutId) return null;
|
||||
return layouts.find((l) => l.id === activeLayoutId) || null;
|
||||
}
|
||||
|
||||
function getEffectiveSpec(): LayoutSpec {
|
||||
if (editMode && editDraft) return editDraft;
|
||||
const a = getActiveLayout();
|
||||
return a ? a.layout : defaultLayout();
|
||||
}
|
||||
|
||||
function populateLayoutSelect() {
|
||||
const sel = document.getElementById("projects-cards-layout-select") as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
sel.innerHTML = "";
|
||||
for (const l of layouts) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = l.id;
|
||||
opt.textContent = l.is_default ? `${l.name} · ${t("projects.cards.layout.is_default") || "Standard"}` : l.name;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
if (activeLayoutId) sel.value = activeLayoutId;
|
||||
}
|
||||
|
||||
function attachToolbarHandlers() {
|
||||
const sel = document.getElementById("projects-cards-layout-select") as HTMLSelectElement | null;
|
||||
const editBtn = document.getElementById("projects-cards-layout-edit") as HTMLButtonElement | null;
|
||||
const newBtn = document.getElementById("projects-cards-layout-new") as HTMLButtonElement | null;
|
||||
const showAll = document.getElementById("projects-cards-show-all-levels") as HTMLInputElement | null;
|
||||
|
||||
// Idempotent attach guards via dataset flag.
|
||||
if (sel && !sel.dataset.bound) {
|
||||
sel.dataset.bound = "1";
|
||||
sel.addEventListener("change", () => {
|
||||
activeLayoutId = sel.value;
|
||||
const a = getActiveLayout();
|
||||
if (showAll && a) showAll.checked = a.layout.show_all_levels;
|
||||
rerender();
|
||||
});
|
||||
}
|
||||
if (editBtn && !editBtn.dataset.bound) {
|
||||
editBtn.dataset.bound = "1";
|
||||
editBtn.addEventListener("click", () => enterEditMode());
|
||||
}
|
||||
if (newBtn && !newBtn.dataset.bound) {
|
||||
newBtn.dataset.bound = "1";
|
||||
newBtn.addEventListener("click", () => createNewLayout());
|
||||
}
|
||||
if (showAll && !showAll.dataset.bound) {
|
||||
showAll.dataset.bound = "1";
|
||||
showAll.checked = getEffectiveSpec().show_all_levels;
|
||||
showAll.addEventListener("change", () => {
|
||||
// In view mode this is a save-as-active toggle. In edit mode it
|
||||
// updates the draft.
|
||||
if (editMode && editDraft) {
|
||||
editDraft.show_all_levels = showAll.checked;
|
||||
rerender();
|
||||
} else {
|
||||
const a = getActiveLayout();
|
||||
if (!a) return;
|
||||
const newSpec: LayoutSpec = { ...a.layout, show_all_levels: showAll.checked };
|
||||
void persistLayout(a.id, { layout: newSpec });
|
||||
a.layout = newSpec;
|
||||
rerender();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Edit-toolbar wiring (only first time the elements exist).
|
||||
const eDensity = document.getElementById("projects-cards-edit-density") as HTMLSelectElement | null;
|
||||
const eGrid = document.getElementById("projects-cards-edit-grid") as HTMLSelectElement | null;
|
||||
const eRename = document.getElementById("projects-cards-edit-rename") as HTMLButtonElement | null;
|
||||
const eDelete = document.getElementById("projects-cards-edit-delete") as HTMLButtonElement | null;
|
||||
const eSetDefault = document.getElementById("projects-cards-edit-set-default") as HTMLButtonElement | null;
|
||||
const eDiscard = document.getElementById("projects-cards-edit-discard") as HTMLButtonElement | null;
|
||||
const eSave = document.getElementById("projects-cards-edit-save") as HTMLButtonElement | null;
|
||||
|
||||
if (eDensity && !eDensity.dataset.bound) {
|
||||
eDensity.dataset.bound = "1";
|
||||
eDensity.addEventListener("change", () => {
|
||||
if (!editDraft) return;
|
||||
editDraft.density = eDensity.value as "compact" | "roomy";
|
||||
rerender();
|
||||
});
|
||||
}
|
||||
if (eGrid && !eGrid.dataset.bound) {
|
||||
eGrid.dataset.bound = "1";
|
||||
eGrid.addEventListener("change", () => {
|
||||
if (!editDraft) return;
|
||||
editDraft.grid_columns = eGrid.value as LayoutSpec["grid_columns"];
|
||||
rerender();
|
||||
});
|
||||
}
|
||||
if (eRename && !eRename.dataset.bound) {
|
||||
eRename.dataset.bound = "1";
|
||||
eRename.addEventListener("click", () => renameActiveLayout());
|
||||
}
|
||||
if (eDelete && !eDelete.dataset.bound) {
|
||||
eDelete.dataset.bound = "1";
|
||||
eDelete.addEventListener("click", () => deleteActiveLayout());
|
||||
}
|
||||
if (eSetDefault && !eSetDefault.dataset.bound) {
|
||||
eSetDefault.dataset.bound = "1";
|
||||
eSetDefault.addEventListener("click", () => setActiveAsDefault());
|
||||
}
|
||||
if (eDiscard && !eDiscard.dataset.bound) {
|
||||
eDiscard.dataset.bound = "1";
|
||||
eDiscard.addEventListener("click", () => leaveEditMode(false));
|
||||
}
|
||||
if (eSave && !eSave.dataset.bound) {
|
||||
eSave.dataset.bound = "1";
|
||||
eSave.addEventListener("click", () => leaveEditMode(true));
|
||||
}
|
||||
}
|
||||
|
||||
function enterEditMode() {
|
||||
const a = getActiveLayout();
|
||||
if (!a) return;
|
||||
editMode = true;
|
||||
editDraft = JSON.parse(JSON.stringify(a.layout)) as LayoutSpec;
|
||||
reflectEditToolbar();
|
||||
rerender();
|
||||
}
|
||||
|
||||
async function leaveEditMode(saveChanges: boolean) {
|
||||
const a = getActiveLayout();
|
||||
if (!a) {
|
||||
editMode = false;
|
||||
editDraft = null;
|
||||
rerender();
|
||||
return;
|
||||
}
|
||||
if (saveChanges && editDraft) {
|
||||
await persistLayout(a.id, { layout: editDraft });
|
||||
a.layout = editDraft;
|
||||
}
|
||||
editMode = false;
|
||||
editDraft = null;
|
||||
reflectEditToolbar();
|
||||
rerender();
|
||||
}
|
||||
|
||||
function reflectEditToolbar() {
|
||||
const editToolbar = document.getElementById("projects-cards-edit-toolbar");
|
||||
if (editToolbar) editToolbar.style.display = editMode ? "flex" : "none";
|
||||
|
||||
if (editMode && editDraft) {
|
||||
const eDensity = document.getElementById("projects-cards-edit-density") as HTMLSelectElement | null;
|
||||
const eGrid = document.getElementById("projects-cards-edit-grid") as HTMLSelectElement | null;
|
||||
if (eDensity) eDensity.value = editDraft.density;
|
||||
if (eGrid) eGrid.value = editDraft.grid_columns;
|
||||
}
|
||||
}
|
||||
|
||||
async function persistLayout(id: string, patch: Partial<{ name: string; layout: LayoutSpec; is_default: boolean }>) {
|
||||
const resp = await fetch(`/api/user-card-layouts/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (resp.ok) {
|
||||
const row = (await resp.json()) as UserCardLayout;
|
||||
const idx = layouts.findIndex((l) => l.id === id);
|
||||
if (idx >= 0) layouts[idx] = row;
|
||||
}
|
||||
}
|
||||
|
||||
async function createNewLayout() {
|
||||
const name = window.prompt(t("projects.cards.layout.new.prompt") || "Name der neuen Ansicht");
|
||||
if (!name || !name.trim()) return;
|
||||
const seed = JSON.parse(JSON.stringify(getEffectiveSpec())) as LayoutSpec;
|
||||
const resp = await fetch("/api/user-card-layouts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: name.trim(), layout: seed, is_default: false }),
|
||||
});
|
||||
if (resp.status === 409) {
|
||||
window.alert("Name already exists.");
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) return;
|
||||
const row = (await resp.json()) as UserCardLayout;
|
||||
layouts.push(row);
|
||||
activeLayoutId = row.id;
|
||||
populateLayoutSelect();
|
||||
enterEditMode();
|
||||
}
|
||||
|
||||
async function renameActiveLayout() {
|
||||
const a = getActiveLayout();
|
||||
if (!a) return;
|
||||
const name = window.prompt(t("projects.cards.layout.rename") || "Umbenennen", a.name);
|
||||
if (!name || !name.trim() || name.trim() === a.name) return;
|
||||
await persistLayout(a.id, { name: name.trim() });
|
||||
populateLayoutSelect();
|
||||
}
|
||||
|
||||
async function deleteActiveLayout() {
|
||||
const a = getActiveLayout();
|
||||
if (!a) return;
|
||||
if (a.is_default) {
|
||||
window.alert(t("projects.cards.layout.delete.default_blocked") || "Cannot delete default.");
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(t("projects.cards.layout.delete.confirm") || "Delete?")) return;
|
||||
const resp = await fetch(`/api/user-card-layouts/${encodeURIComponent(a.id)}`, { method: "DELETE" });
|
||||
if (!resp.ok && resp.status !== 204) return;
|
||||
layouts = layouts.filter((l) => l.id !== a.id);
|
||||
const def = layouts.find((l) => l.is_default) || layouts[0];
|
||||
activeLayoutId = def ? def.id : null;
|
||||
editMode = false;
|
||||
editDraft = null;
|
||||
populateLayoutSelect();
|
||||
reflectEditToolbar();
|
||||
rerender();
|
||||
}
|
||||
|
||||
async function setActiveAsDefault() {
|
||||
const a = getActiveLayout();
|
||||
if (!a) return;
|
||||
const resp = await fetch(`/api/user-card-layouts/${encodeURIComponent(a.id)}/set-default`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!resp.ok) return;
|
||||
// Refresh the list — server cleared the prior default in tx.
|
||||
await reloadLayouts();
|
||||
populateLayoutSelect();
|
||||
rerender();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function rerender() {
|
||||
const grid = document.getElementById("projects-cards-grid");
|
||||
if (!grid) return;
|
||||
const spec = getEffectiveSpec();
|
||||
|
||||
// Apply grid columns + density.
|
||||
grid.classList.toggle("is-density-compact", spec.density === "compact");
|
||||
grid.classList.toggle("is-density-roomy", spec.density === "roomy");
|
||||
grid.classList.remove("is-grid-2", "is-grid-3", "is-grid-4");
|
||||
if (spec.grid_columns !== "auto") {
|
||||
grid.classList.add(`is-grid-${spec.grid_columns}`);
|
||||
}
|
||||
|
||||
const cards = flattenTreeToCards(treeCache, spec.show_all_levels);
|
||||
if (cards.length === 0) {
|
||||
grid.innerHTML = `<div class="projects-cards-empty">${escHTML(t("projects.cards.empty") || "Keine Projekte zum Anzeigen.")}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = cards.map((n) => renderCard(n, spec)).join("");
|
||||
attachCardHandlers(grid);
|
||||
}
|
||||
|
||||
function flattenTreeToCards(roots: ProjectTreeNode[], showAllLevels: boolean): ProjectTreeNode[] {
|
||||
const out: ProjectTreeNode[] = [];
|
||||
const walk = (n: ProjectTreeNode) => {
|
||||
if (showAllLevels) {
|
||||
out.push(n);
|
||||
} else if (isLeafish(n)) {
|
||||
out.push(n);
|
||||
}
|
||||
for (const c of n.children) walk(c);
|
||||
};
|
||||
roots.forEach(walk);
|
||||
// Sort by last_activity_at DESC (from preview), pinned first.
|
||||
out.sort((a, b) => {
|
||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
||||
const aT = previewCache.get(a.id)?.last_activity_at || "";
|
||||
const bT = previewCache.get(b.id)?.last_activity_at || "";
|
||||
if (aT !== bT) return bT.localeCompare(aT);
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function isLeafish(n: ProjectTreeNode): boolean {
|
||||
// Cases / Patents / Verfahren / Projekte. Mandanten + Litigations are
|
||||
// scaffolding when "Alle Ebenen" is off.
|
||||
return n.type === "case" || n.type === "patent" || n.type === "project";
|
||||
}
|
||||
|
||||
function renderCard(n: ProjectTreeNode, spec: LayoutSpec): string {
|
||||
const visibleFacts = spec.facts.filter((f) => f.visible);
|
||||
const factHTML = visibleFacts.map((f) => renderFact(n, f, spec)).join("");
|
||||
|
||||
const editChrome = editMode ? renderEditChromeForCard(spec) : "";
|
||||
|
||||
return (
|
||||
`<article class="projects-card${editMode ? " is-edit-mode" : ""}" data-id="${escAttr(n.id)}">` +
|
||||
factHTML +
|
||||
editChrome +
|
||||
`</article>`
|
||||
);
|
||||
}
|
||||
|
||||
function renderEditChromeForCard(spec: LayoutSpec): string {
|
||||
// Inline fact-row controls (drag handle + visibility toggle) per fact key.
|
||||
// We render this as a horizontally-laid-out list at the bottom of each
|
||||
// card so the user can rearrange order. In edit mode the rendered facts
|
||||
// above are "preview"; the ordering controls below mutate the draft.
|
||||
const rows = spec.facts.map((f, i) => {
|
||||
const label = t(`projects.cards.layout.fact.${f.key}` as never) || f.key;
|
||||
const cnt = f.count !== undefined
|
||||
? `<input type="number" min="1" max="5" value="${f.count}" class="projects-cards-edit-count" data-key="${escAttr(f.key)}" />`
|
||||
: "";
|
||||
return (
|
||||
`<li class="projects-cards-edit-fact" draggable="true" data-key="${escAttr(f.key)}" data-index="${i}">` +
|
||||
`<span class="projects-cards-edit-handle" aria-hidden="true">⠿</span>` +
|
||||
`<input type="checkbox" class="projects-cards-edit-vis" ${f.visible ? "checked" : ""} data-key="${escAttr(f.key)}" />` +
|
||||
`<span class="projects-cards-edit-label">${escHTML(String(label))}</span>` +
|
||||
cnt +
|
||||
`</li>`
|
||||
);
|
||||
}).join("");
|
||||
return `<ul class="projects-cards-edit-facts">${rows}</ul>`;
|
||||
}
|
||||
|
||||
function renderFact(n: ProjectTreeNode, f: LayoutFact, spec: LayoutSpec): string {
|
||||
const preview = previewCache.get(n.id);
|
||||
switch (f.key) {
|
||||
case "title-row":
|
||||
return renderTitleRow(n);
|
||||
case "type-chip":
|
||||
return `<div class="projects-card-row"><span class="entity-type-chip entity-type-${escAttr(n.type)}">${escHTML(String(tDyn(`projects.type.${n.type}`) || n.type))}</span></div>`;
|
||||
case "status-chip":
|
||||
return `<div class="projects-card-row"><span class="entity-status-chip entity-status-${escAttr(n.status)}">${escHTML(String(tDyn(`projects.filter.status.${n.status}`) || n.status))}</span></div>`;
|
||||
case "client-matter": {
|
||||
const cm = (n.client_number && n.matter_number)
|
||||
? `${n.client_number}.${n.matter_number}`
|
||||
: (n.client_number || n.matter_number || "");
|
||||
if (!cm) return "";
|
||||
return `<div class="projects-card-row projects-card-cm">${escHTML(cm)}</div>`;
|
||||
}
|
||||
case "parent-path":
|
||||
// Parent path is omitted in v1 — building the breadcrumb requires
|
||||
// an extra fetch per card. Display the project's own .reference if
|
||||
// present as a stand-in cue for hierarchy.
|
||||
if (!n.reference) return "";
|
||||
return `<div class="projects-card-row projects-card-ref">${escHTML(n.reference)}</div>`;
|
||||
case "deadline-counts": {
|
||||
const open = n.open_deadlines_subtree ?? n.open_deadlines;
|
||||
const overdue = n.overdue_deadlines_subtree ?? n.overdue_deadlines;
|
||||
if (open === 0 && overdue === 0) return "";
|
||||
const parts: string[] = [];
|
||||
if (overdue > 0) parts.push(`<span class="projekt-tree-badge projekt-tree-badge-overdue">${overdue} ${escHTML(String(t("projects.cards.deadline_overdue") || "überfällig"))}</span>`);
|
||||
if (open > 0) parts.push(`<span class="projekt-tree-badge projekt-tree-badge-open">${open} ${escHTML(String(t("projects.cards.deadline_open") || "offen"))}</span>`);
|
||||
return `<div class="projects-card-row projects-card-counts">${parts.join(" ")}</div>`;
|
||||
}
|
||||
case "next-events": {
|
||||
const cap = clampCount(f.count);
|
||||
const evs = preview?.next_events || [];
|
||||
if (evs.length === 0) {
|
||||
return `<div class="projects-card-section"><div class="projects-card-section-title">${escHTML(String(t("projects.cards.next_events") || "Nächste Termine"))}</div><div class="projects-card-empty">${escHTML(String(t("projects.cards.no_next_events") || ""))}</div></div>`;
|
||||
}
|
||||
const rows = evs.slice(0, cap).map((e) => renderEventRow(e)).join("");
|
||||
return `<div class="projects-card-section"><div class="projects-card-section-title">${escHTML(String(t("projects.cards.next_events") || "Nächste Termine"))}</div>${rows}</div>`;
|
||||
}
|
||||
case "recent-verlauf": {
|
||||
const cap = clampCount(f.count);
|
||||
const evs = preview?.recent_verlauf || [];
|
||||
if (evs.length === 0) {
|
||||
return `<div class="projects-card-section"><div class="projects-card-section-title">${escHTML(String(t("projects.cards.recent_verlauf") || "Zuletzt"))}</div><div class="projects-card-empty">${escHTML(String(t("projects.cards.no_recent") || ""))}</div></div>`;
|
||||
}
|
||||
const rows = evs.slice(0, cap).map((e) => renderEventRow(e)).join("");
|
||||
return `<div class="projects-card-section"><div class="projects-card-section-title">${escHTML(String(t("projects.cards.recent_verlauf") || "Zuletzt"))}</div>${rows}</div>`;
|
||||
}
|
||||
case "team-chips": {
|
||||
if (!preview || preview.team_count === 0) return "";
|
||||
const initials = preview.team_initials.map((i) => `<span class="projects-card-team-initial">${escHTML(i)}</span>`).join("");
|
||||
const overflow = preview.team_count > preview.team_initials.length
|
||||
? `<span class="projects-card-team-overflow">+${preview.team_count - preview.team_initials.length}</span>`
|
||||
: "";
|
||||
return `<div class="projects-card-row projects-card-team">${initials}${overflow}</div>`;
|
||||
}
|
||||
case "reference":
|
||||
if (!n.reference) return "";
|
||||
return `<div class="projects-card-row projects-card-ref">${escHTML(n.reference)}</div>`;
|
||||
case "last-activity-at": {
|
||||
const at = preview?.last_activity_at;
|
||||
if (!at) return "";
|
||||
return `<div class="projects-card-row projects-card-last-activity">${escHTML(fmtDate(at))}</div>`;
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
// unreachable
|
||||
void spec;
|
||||
}
|
||||
|
||||
function renderTitleRow(n: ProjectTreeNode): string {
|
||||
const pinClass = n.pinned ? " is-pinned" : "";
|
||||
const pinLabel = n.pinned
|
||||
? (t("projects.tree.unpin") || "Pin entfernen")
|
||||
: (t("projects.tree.pin") || "Anpinnen");
|
||||
return (
|
||||
`<div class="projects-card-title-row">` +
|
||||
`<span class="projects-card-icon projekt-tree-icon-${escAttr(n.type)}" aria-hidden="true">●</span>` +
|
||||
`<a class="projects-card-title" href="/projects/${escAttr(n.id)}">${escHTML(n.title)}</a>` +
|
||||
`<button type="button" class="projekt-tree-pin projects-card-pin${pinClass}" data-id="${escAttr(n.id)}" aria-label="${escAttr(String(pinLabel))}" title="${escAttr(String(pinLabel))}">${n.pinned ? "★" : "☆"}</button>` +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
|
||||
function renderEventRow(e: CardEventPreview): string {
|
||||
const kindLabel = t(`projects.cards.event.kind.${e.kind}` as never) || e.kind;
|
||||
const dateStr = fmtDate(e.event_date);
|
||||
const status = e.status ? ` <span class="projects-card-event-status entity-status-chip entity-status-${escAttr(e.status)}">${escHTML(String(tDyn(`projects.filter.status.${e.status}`) || e.status))}</span>` : "";
|
||||
const actor = e.actor_name ? ` <span class="projects-card-event-actor">${escHTML(e.actor_name)}</span>` : "";
|
||||
return (
|
||||
`<a class="projects-card-event-row" href="${escAttr(e.route)}" title="${escAttr(e.title)}">` +
|
||||
`<span class="projects-card-event-date">${escHTML(dateStr)}</span>` +
|
||||
`<span class="projects-card-event-kind">${escHTML(String(kindLabel))}</span>` +
|
||||
`<span class="projects-card-event-title">${escHTML(e.title)}</span>` +
|
||||
status +
|
||||
actor +
|
||||
`</a>`
|
||||
);
|
||||
}
|
||||
|
||||
function clampCount(n: number | undefined): number {
|
||||
if (n === undefined) return 3;
|
||||
if (n < 1) return 1;
|
||||
if (n > 5) return 5;
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Card-level event handlers (pin click + edit-mode drag/check/count)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function attachCardHandlers(grid: HTMLElement) {
|
||||
// Pin star (always-active, edit mode or not).
|
||||
grid.querySelectorAll<HTMLButtonElement>(".projects-card-pin").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const id = btn.dataset.id!;
|
||||
void togglePin(id, btn);
|
||||
});
|
||||
});
|
||||
|
||||
if (!editMode || !editDraft) return;
|
||||
|
||||
// Edit-mode: visibility checkbox per fact.
|
||||
grid.querySelectorAll<HTMLInputElement>(".projects-cards-edit-vis").forEach((cb) => {
|
||||
cb.addEventListener("change", () => {
|
||||
if (!editDraft) return;
|
||||
const key = cb.dataset.key as FactKey;
|
||||
const f = editDraft.facts.find((x) => x.key === key);
|
||||
if (!f) return;
|
||||
f.visible = cb.checked;
|
||||
// The first VISIBLE fact must remain "title-row" — gate this.
|
||||
// Easier approach: always keep title-row visible regardless of click.
|
||||
const titleRow = editDraft.facts.find((x) => x.key === "title-row");
|
||||
if (titleRow) titleRow.visible = true;
|
||||
rerender();
|
||||
});
|
||||
});
|
||||
|
||||
// Edit-mode: count steppers for next-events / recent-verlauf.
|
||||
grid.querySelectorAll<HTMLInputElement>(".projects-cards-edit-count").forEach((inp) => {
|
||||
inp.addEventListener("change", () => {
|
||||
if (!editDraft) return;
|
||||
const key = inp.dataset.key as FactKey;
|
||||
const f = editDraft.facts.find((x) => x.key === key);
|
||||
if (!f) return;
|
||||
const v = Math.max(1, Math.min(5, parseInt(inp.value, 10) || 3));
|
||||
f.count = v;
|
||||
rerender();
|
||||
});
|
||||
});
|
||||
|
||||
// Edit-mode: HTML5 drag-and-drop for fact reordering. We attach handlers
|
||||
// on each .projects-cards-edit-fact <li>; dragover on <ul>; drop reorders
|
||||
// the editDraft.facts array.
|
||||
grid.querySelectorAll<HTMLLIElement>(".projects-cards-edit-fact").forEach((li) => {
|
||||
li.addEventListener("dragstart", (ev) => {
|
||||
const key = li.dataset.key!;
|
||||
ev.dataTransfer?.setData("text/plain", key);
|
||||
li.classList.add("is-dragging");
|
||||
});
|
||||
li.addEventListener("dragend", () => li.classList.remove("is-dragging"));
|
||||
li.addEventListener("dragover", (ev) => {
|
||||
ev.preventDefault();
|
||||
li.classList.add("is-drop-target");
|
||||
});
|
||||
li.addEventListener("dragleave", () => li.classList.remove("is-drop-target"));
|
||||
li.addEventListener("drop", (ev) => {
|
||||
ev.preventDefault();
|
||||
li.classList.remove("is-drop-target");
|
||||
const fromKey = ev.dataTransfer?.getData("text/plain") as FactKey | undefined;
|
||||
const toKey = li.dataset.key as FactKey;
|
||||
if (!fromKey || !toKey || fromKey === toKey) return;
|
||||
reorderFacts(fromKey, toKey);
|
||||
rerender();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function reorderFacts(fromKey: FactKey, toKey: FactKey) {
|
||||
if (!editDraft) return;
|
||||
const fromIdx = editDraft.facts.findIndex((f) => f.key === fromKey);
|
||||
const toIdx = editDraft.facts.findIndex((f) => f.key === toKey);
|
||||
if (fromIdx < 0 || toIdx < 0) return;
|
||||
const [moved] = editDraft.facts.splice(fromIdx, 1);
|
||||
editDraft.facts.splice(toIdx, 0, moved);
|
||||
// Server validator requires title-row to be the first visible fact.
|
||||
// Pull it to the top if it's now somewhere else.
|
||||
const trIdx = editDraft.facts.findIndex((f) => f.key === "title-row");
|
||||
if (trIdx > 0) {
|
||||
const [tr] = editDraft.facts.splice(trIdx, 1);
|
||||
editDraft.facts.unshift(tr);
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePin(projectID: string, btn: HTMLElement) {
|
||||
const wasPinned = btn.classList.contains("is-pinned");
|
||||
btn.classList.toggle("is-pinned", !wasPinned);
|
||||
btn.textContent = !wasPinned ? "★" : "☆";
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(projectID)}/pin`, {
|
||||
method: wasPinned ? "DELETE" : "POST",
|
||||
});
|
||||
if (!resp.ok && resp.status !== 201 && resp.status !== 204) {
|
||||
btn.classList.toggle("is-pinned", wasPinned);
|
||||
btn.textContent = wasPinned ? "★" : "☆";
|
||||
return;
|
||||
}
|
||||
// Update tree cache in place so re-renders show the new state.
|
||||
const update = (n: ProjectTreeNode) => {
|
||||
if (n.id === projectID) n.pinned = !wasPinned;
|
||||
n.children.forEach(update);
|
||||
};
|
||||
treeCache.forEach(update);
|
||||
} catch {
|
||||
btn.classList.toggle("is-pinned", wasPinned);
|
||||
btn.textContent = wasPinned ? "★" : "☆";
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function escHTML(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/[&<>"']/g, (c) => {
|
||||
switch (c) {
|
||||
case "&": return "&";
|
||||
case "<": return "<";
|
||||
case ">": return ">";
|
||||
case '"': return """;
|
||||
case "'": return "'";
|
||||
}
|
||||
return c;
|
||||
});
|
||||
}
|
||||
|
||||
// Avoid an "unused" warning from the type-only import.
|
||||
const _unusedFactKeys: FactKey[] = ALL_FACT_KEYS;
|
||||
void _unusedFactKeys;
|
||||
@@ -37,15 +37,52 @@ interface ProjectTeamMember {
|
||||
id: string;
|
||||
project_id: string;
|
||||
user_id: string;
|
||||
// t-paliad-148: per-project responsibility (lead/member/observer/external).
|
||||
// The legacy .role field is still set by the server during the
|
||||
// deprecation window but the UI ignores it for new code.
|
||||
responsibility: string;
|
||||
role: string;
|
||||
inherited: boolean;
|
||||
user_email: string;
|
||||
user_display_name: string;
|
||||
user_office: string;
|
||||
// user_profession is the structured firm tier (partner/of_counsel/…/
|
||||
// paralegal). NULL means external collaborator. Read-only here — the
|
||||
// value is set on the user's firm profile, not at staffing time.
|
||||
user_profession?: string | null;
|
||||
inherited_from_id?: string | null;
|
||||
inherited_from_title?: string | null;
|
||||
}
|
||||
|
||||
// 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 +108,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 +122,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 +136,7 @@ interface Appointment {
|
||||
end_at?: string;
|
||||
location?: string;
|
||||
appointment_type?: string;
|
||||
project_title?: string;
|
||||
}
|
||||
|
||||
interface Me {
|
||||
@@ -161,12 +207,46 @@ let appointments: Appointment[] = [];
|
||||
let ancestors: ProjectMini[] = [];
|
||||
let children: ProjectMini[] = [];
|
||||
let teamMembers: ProjectTeamMember[] = [];
|
||||
let userOptions: { id: string; display_name: string; email: string }[] = [];
|
||||
// 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; profession?: 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 +291,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 +327,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 +346,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 +397,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 +458,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 +591,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 +625,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 +741,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 +1278,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 +1298,10 @@ async function main() {
|
||||
loadAncestors(id),
|
||||
loadChildren(id),
|
||||
loadTeam(id),
|
||||
loadDescendantStaffed(id),
|
||||
loadDerivedMembers(id),
|
||||
loadAttachedUnits(id),
|
||||
loadAllUnits(),
|
||||
loadUserList(),
|
||||
]);
|
||||
|
||||
@@ -1155,10 +1324,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 +1586,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,15 +1651,37 @@ 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;
|
||||
// t-paliad-148: profession is firm-wide (read-only badge) and
|
||||
// responsibility is per-project. Both are surfaced; the legacy
|
||||
// .role field is still set by the server during the deprecation
|
||||
// window but the UI ignores it.
|
||||
const responsibility = m.responsibility || "member";
|
||||
const responsibilityLabel = tDyn(`projects.team.responsibility.${responsibility}`) || responsibility;
|
||||
const professionLabel = m.user_profession
|
||||
? tDyn(`projects.team.profession.${m.user_profession}`) || m.user_profession
|
||||
: (t("projects.team.profession.none") || "(extern)");
|
||||
const professionTitle = m.user_profession
|
||||
? (t("projects.team.profession.hint") || "Profession — gesetzt im Firmenprofil")
|
||||
: (t("projects.team.profession.none.hint") || "Keine Profession gesetzt — keine 4-Augen-Befugnis");
|
||||
const source = m.inherited
|
||||
? `<span class="projekt-team-inherited" title="${escAttr(t("projects.team.inherited.hint") || "Inherited from ancestor")}">
|
||||
↑ ${esc(m.inherited_from_title || "")}
|
||||
@@ -1341,10 +1692,12 @@ function renderTeam() {
|
||||
? `<button type="button" class="btn-ghost btn-small team-remove-btn" data-user-id="${esc(m.user_id)}">${esc(t("projects.detail.team.remove") || "Entfernen")}</button>`
|
||||
: "";
|
||||
const officeLabel = m.user_office ? tDyn("office." + m.user_office) || m.user_office : "";
|
||||
const profCls = m.user_profession ? "projekt-team-profession" : "projekt-team-profession projekt-team-profession--none";
|
||||
return `<tr>
|
||||
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
|
||||
<span class="form-hint">· ${esc(m.user_email)}${officeLabel ? " · " + esc(officeLabel) : ""}</span></td>
|
||||
<td><span class="projekt-team-role">${esc(roleLabel)}</span></td>
|
||||
<td><span class="${profCls}" title="${escAttr(professionTitle)}">${esc(professionLabel)}</span></td>
|
||||
<td><span class="projekt-team-responsibility">${esc(responsibilityLabel)}</span></td>
|
||||
<td>${source}</td>
|
||||
<td>${removeBtn}</td>
|
||||
</tr>`;
|
||||
@@ -1366,6 +1719,146 @@ 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 responsibility = m.responsibility || "member";
|
||||
const responsibilityLabel = tDyn(`projects.team.responsibility.${responsibility}`) || responsibility;
|
||||
const officeLabel = m.user_office ? tDyn("office." + m.user_office) || m.user_office : "";
|
||||
const sourceTitle = esc(m.inherited_from_title || "");
|
||||
return `<tr>
|
||||
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
|
||||
<span class="form-hint">· ${esc(m.user_email)}${officeLabel ? " · " + esc(officeLabel) : ""}</span></td>
|
||||
<td><span class="projekt-team-responsibility">${esc(responsibilityLabel)}</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.responsibility === "lead" && m.project_id === project!.id,
|
||||
);
|
||||
}
|
||||
|
||||
function canRemoveTeamMember(m: ProjectTeamMember): boolean {
|
||||
@@ -1382,8 +1875,25 @@ function initTeamForm(id: string) {
|
||||
const hidden = document.getElementById("team-user-id") as HTMLInputElement | null;
|
||||
const sugs = document.getElementById("team-user-suggestions") as HTMLDivElement | null;
|
||||
const msg = document.getElementById("team-msg") as HTMLParagraphElement | null;
|
||||
const role = document.getElementById("team-role") as HTMLSelectElement | null;
|
||||
if (!addBtn || !form || !cancel || !input || !hidden || !sugs || !msg || !role) return;
|
||||
const responsibility = document.getElementById("team-responsibility") as HTMLSelectElement | null;
|
||||
const professionHint = document.getElementById("team-profession-hint") as HTMLParagraphElement | null;
|
||||
const inviteHint = document.getElementById("team-user-invite-hint") as HTMLDivElement | null;
|
||||
const inviteHintText = document.getElementById("team-user-invite-hint-text") as HTMLSpanElement | null;
|
||||
const inviteBtn = document.getElementById("team-user-invite-btn") as HTMLButtonElement | null;
|
||||
if (!addBtn || !form || !cancel || !input || !hidden || !sugs || !msg || !responsibility) 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 = "";
|
||||
@@ -1396,18 +1906,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 +1935,47 @@ function initTeamForm(id: string) {
|
||||
hidden.value = el.dataset.id!;
|
||||
input.value = el.dataset.label!;
|
||||
sugs.innerHTML = "";
|
||||
hideInviteHint();
|
||||
// t-paliad-148: surface the picked person's profession so the
|
||||
// adder sees what firm tier they're staffing on this matter,
|
||||
// and gets a warning when the user has no profession set.
|
||||
if (professionHint) {
|
||||
const picked = userOptions.find((u) => u.id === hidden.value);
|
||||
const prof = picked?.profession;
|
||||
if (!prof) {
|
||||
professionHint.textContent = t("projects.detail.team.form.profession.none") ||
|
||||
"Keine Profession gesetzt — kann keine 4-Augen-Genehmigungen erteilen.";
|
||||
professionHint.className = "form-hint form-hint--warning";
|
||||
professionHint.style.display = "";
|
||||
} else {
|
||||
const profLabel = tDyn(`projects.team.profession.${prof}`) || prof;
|
||||
professionHint.textContent = `${t("projects.detail.team.form.profession.label") || "Profession"}: ${profLabel}`;
|
||||
professionHint.className = "form-hint";
|
||||
professionHint.style.display = "";
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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) => {
|
||||
@@ -1436,7 +1988,7 @@ function initTeamForm(id: string) {
|
||||
const resp = await fetch(`/api/projects/${id}/team`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_id: hidden.value, role: role.value }),
|
||||
body: JSON.stringify({ user_id: hidden.value, responsibility: responsibility.value }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const b = await resp.json().catch(() => ({ error: "unknown" }));
|
||||
@@ -1446,6 +1998,7 @@ function initTeamForm(id: string) {
|
||||
input.value = "";
|
||||
hidden.value = "";
|
||||
sugs.innerHTML = "";
|
||||
hideInviteHint();
|
||||
form.style.display = "none";
|
||||
addBtn.style.display = "";
|
||||
await loadTeam(id);
|
||||
|
||||
81
frontend/src/client/projects-flat.ts
Normal file
81
frontend/src/client/projects-flat.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { tDyn, getLang } from "./i18n";
|
||||
|
||||
// Flat-list (table) rendering for /projects.
|
||||
// Extracted from the pre-t-paliad-149 client/projects.ts so the orchestrator
|
||||
// can mount/unmount table view alongside the tree view without code duplication.
|
||||
|
||||
export interface ProjectFlatRow {
|
||||
id: string;
|
||||
type: string;
|
||||
parent_id?: string | null;
|
||||
path: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
status: string;
|
||||
client_number?: string | null;
|
||||
matter_number?: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface RenderOpts {
|
||||
rows: ProjectFlatRow[];
|
||||
}
|
||||
|
||||
// renderFlatList writes the table rows + wires row-click navigation.
|
||||
// Caller is responsible for showing/hiding the wrapping table element.
|
||||
export function renderFlatList(opts: RenderOpts) {
|
||||
const tbody = document.getElementById("projects-body")!;
|
||||
tbody.innerHTML = opts.rows
|
||||
.map((p) => {
|
||||
const typeLabel = tDyn(`projects.type.${p.type}`) || p.type;
|
||||
const statusLabel = tDyn(`projects.filter.status.${p.status}`) || p.status;
|
||||
const clientMatter =
|
||||
p.client_number && p.matter_number
|
||||
? `${p.client_number}.${p.matter_number}`
|
||||
: p.client_number || p.matter_number || "";
|
||||
const refCell = p.reference ? esc(p.reference) : "—";
|
||||
const clientMatterCell = clientMatter ? esc(clientMatter) : "—";
|
||||
return `<tr class="entity-row" data-id="${esc(p.id)}">
|
||||
<td class="entity-col-title">${esc(p.title)}</td>
|
||||
<td><span class="entity-type-chip entity-type-${esc(p.type)}">${esc(typeLabel)}</span></td>
|
||||
<td class="entity-col-ref">${refCell}</td>
|
||||
<td class="entity-col-ref">${clientMatterCell}</td>
|
||||
<td class="entity-col-status"><span class="entity-status-chip entity-status-${esc(p.status)}">${esc(statusLabel)}</span></td>
|
||||
<td class="entity-col-updated">${fmtDate(p.updated_at)}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
// F-23: when every visible row shares the same status, hide the column to
|
||||
// cut redundant noise. The toggle re-runs on every filter change, so the
|
||||
// column comes back as soon as the rows mix again.
|
||||
const statusUnique = new Set(opts.rows.map((p) => p.status)).size;
|
||||
const table = document.getElementById("entity-table");
|
||||
table?.classList.toggle("entity-table--hide-status", statusUnique <= 1);
|
||||
|
||||
tbody.querySelectorAll<HTMLTableRowElement>(".entity-row").forEach((row) => {
|
||||
row.addEventListener("click", () => {
|
||||
const id = row.dataset.id!;
|
||||
window.location.href = `/projects/${id}`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
@@ -1,71 +1,318 @@
|
||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||
import { initI18n, onLangChange, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { initProjectTree, rerenderProjectTree } from "./project-tree";
|
||||
import { initProjectTree, refreshProjectTree, rerenderProjectTree } from "./project-tree";
|
||||
import { renderFlatList, ProjectFlatRow } from "./projects-flat";
|
||||
import { renderCardsView, teardownCardsView } from "./projects-cards";
|
||||
|
||||
// /projekte list page client. Reads v2 shape from /api/projects.
|
||||
interface Project {
|
||||
id: string;
|
||||
type: string;
|
||||
parent_id?: string | null;
|
||||
path: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
status: string;
|
||||
client_number?: string | null;
|
||||
matter_number?: string | null;
|
||||
updated_at: string;
|
||||
// /projects orchestrator (t-paliad-149).
|
||||
//
|
||||
// Owns:
|
||||
// - chip state (scope + status + type + pinned + has_open_deadlines)
|
||||
// - search term (in-place filter, server-side)
|
||||
// - view mode (tree | flat). Cards lands in PR 2.
|
||||
// - last-view restore + URL params (Q1 lock-in: last-viewed restore).
|
||||
//
|
||||
// Delegates rendering to:
|
||||
// - project-tree.ts for tree mode
|
||||
// - projects-flat.ts for flat-table mode
|
||||
|
||||
type ViewMode = "tree" | "cards" | "flat";
|
||||
type Scope = "all" | "mine" | "pinned";
|
||||
|
||||
interface Chips {
|
||||
scope: Scope;
|
||||
status: Set<string>;
|
||||
type: Set<string>;
|
||||
hasOpenDeadlines: boolean;
|
||||
}
|
||||
|
||||
let allRows: Project[] = [];
|
||||
let typeFilter = "";
|
||||
let statusFilter = "";
|
||||
let viewMode: "flat" | "tree" | "roots" = parseInitialView();
|
||||
let searchQuery = "";
|
||||
let loadedOK = false;
|
||||
|
||||
// Honour ?view=flat|tree|roots from the URL so dashboard links and bookmarks
|
||||
// land on the right layout. Anything else falls back to "flat".
|
||||
function parseInitialView(): "flat" | "tree" | "roots" {
|
||||
const v = new URLSearchParams(window.location.search).get("view");
|
||||
if (v === "tree" || v === "roots" || v === "flat") return v;
|
||||
return "flat";
|
||||
interface State {
|
||||
viewMode: ViewMode;
|
||||
chips: Chips;
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
async function loadProjekte() {
|
||||
const STORAGE_KEY = "paliad.projects.lastView";
|
||||
const SEARCH_DEBOUNCE_MS = 250;
|
||||
|
||||
let state: State = defaultState();
|
||||
let flatRows: ProjectFlatRow[] | null = null;
|
||||
let searchDebounce: number | null = null;
|
||||
|
||||
function defaultState(): State {
|
||||
return {
|
||||
viewMode: "tree",
|
||||
chips: {
|
||||
scope: "all",
|
||||
status: new Set(),
|
||||
type: new Set(),
|
||||
hasOpenDeadlines: false,
|
||||
},
|
||||
searchQuery: "",
|
||||
};
|
||||
}
|
||||
|
||||
function loadStoredState(): State | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as {
|
||||
viewMode?: ViewMode;
|
||||
chips?: { scope?: Scope; status?: string[]; type?: string[]; hasOpenDeadlines?: boolean };
|
||||
searchQuery?: string;
|
||||
};
|
||||
const viewMode: ViewMode =
|
||||
parsed.viewMode === "flat" ? "flat" :
|
||||
parsed.viewMode === "cards" ? "cards" :
|
||||
"tree";
|
||||
return {
|
||||
viewMode,
|
||||
chips: {
|
||||
scope: parsed.chips?.scope === "mine" || parsed.chips?.scope === "pinned" ? parsed.chips.scope : "all",
|
||||
status: new Set(parsed.chips?.status || []),
|
||||
type: new Set(parsed.chips?.type || []),
|
||||
hasOpenDeadlines: !!parsed.chips?.hasOpenDeadlines,
|
||||
},
|
||||
searchQuery: typeof parsed.searchQuery === "string" ? parsed.searchQuery : "",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveState() {
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
viewMode: state.viewMode,
|
||||
chips: {
|
||||
scope: state.chips.scope,
|
||||
status: [...state.chips.status],
|
||||
type: [...state.chips.type],
|
||||
hasOpenDeadlines: state.chips.hasOpenDeadlines,
|
||||
},
|
||||
searchQuery: state.searchQuery,
|
||||
}));
|
||||
} catch {
|
||||
/* private mode, quota — ignore */
|
||||
}
|
||||
}
|
||||
|
||||
// applyURL overlays ?view=, ?scope=, ?status=, ?type=, ?has_open_deadlines=,
|
||||
// ?q= onto the current state. URL > sessionStorage > defaults.
|
||||
function applyURL() {
|
||||
const url = new URL(window.location.href);
|
||||
const v = url.searchParams.get("view");
|
||||
if (v === "tree" || v === "flat" || v === "cards") state.viewMode = v;
|
||||
const sc = url.searchParams.get("scope");
|
||||
if (sc === "mine" || sc === "pinned" || sc === "all") state.chips.scope = sc;
|
||||
const status = url.searchParams.get("status");
|
||||
if (status !== null) {
|
||||
state.chips.status = new Set(status.split(",").map((s) => s.trim()).filter(Boolean));
|
||||
}
|
||||
const type = url.searchParams.get("type");
|
||||
if (type !== null) {
|
||||
state.chips.type = new Set(type.split(",").map((s) => s.trim()).filter(Boolean));
|
||||
}
|
||||
const has = url.searchParams.get("has_open_deadlines");
|
||||
if (has === "true" || has === "false") state.chips.hasOpenDeadlines = has === "true";
|
||||
const q = url.searchParams.get("q");
|
||||
if (q !== null) state.searchQuery = q;
|
||||
}
|
||||
|
||||
function syncURL() {
|
||||
const url = new URL(window.location.href);
|
||||
// Clear all known params, then re-set only the non-default ones (keeps URLs short).
|
||||
["view", "scope", "status", "type", "has_open_deadlines", "q"].forEach((k) => url.searchParams.delete(k));
|
||||
if (state.viewMode !== "tree") url.searchParams.set("view", state.viewMode);
|
||||
if (state.chips.scope !== "all") url.searchParams.set("scope", state.chips.scope);
|
||||
if (state.chips.status.size > 0) url.searchParams.set("status", [...state.chips.status].join(","));
|
||||
if (state.chips.type.size > 0) url.searchParams.set("type", [...state.chips.type].join(","));
|
||||
if (state.chips.hasOpenDeadlines) url.searchParams.set("has_open_deadlines", "true");
|
||||
if (state.searchQuery.trim()) url.searchParams.set("q", state.searchQuery.trim());
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
}
|
||||
|
||||
// Build the query string the tree endpoint expects. Same shape as the URL
|
||||
// state but always written (we don't omit "all" because the server expects
|
||||
// ?subtree_counts=true to get the new field).
|
||||
function treeParams(): URLSearchParams {
|
||||
const p = new URLSearchParams();
|
||||
if (state.chips.scope !== "all") p.set("scope", state.chips.scope);
|
||||
if (state.chips.status.size > 0) p.set("status", [...state.chips.status].join(","));
|
||||
if (state.chips.type.size > 0) p.set("type", [...state.chips.type].join(","));
|
||||
if (state.chips.hasOpenDeadlines) p.set("has_open_deadlines", "true");
|
||||
if (state.searchQuery.trim()) p.set("q", state.searchQuery.trim());
|
||||
p.set("subtree_counts", "true");
|
||||
return p;
|
||||
}
|
||||
|
||||
function reflectChipsToDOM() {
|
||||
// Scope toggles
|
||||
const scopes: Scope[] = ["all", "mine", "pinned"];
|
||||
scopes.forEach((s) => {
|
||||
const btn = document.querySelector<HTMLButtonElement>(`.projects-chip[data-chip="${s}"]`);
|
||||
btn?.classList.toggle("is-active", state.chips.scope === s);
|
||||
});
|
||||
// Has-open-deadlines
|
||||
const hasBtn = document.querySelector<HTMLButtonElement>(`.projects-chip[data-chip="has_open_deadlines"]`);
|
||||
hasBtn?.classList.toggle("is-active", state.chips.hasOpenDeadlines);
|
||||
|
||||
// Multi-select panels
|
||||
reflectMulti("status", state.chips.status);
|
||||
reflectMulti("type", state.chips.type);
|
||||
|
||||
// View mode segment-control
|
||||
document.querySelectorAll<HTMLButtonElement>(".projects-view-btn").forEach((btn) => {
|
||||
btn.classList.toggle("is-active", btn.dataset.view === state.viewMode);
|
||||
});
|
||||
|
||||
// Search input value (when restoring state on init)
|
||||
const searchInput = document.getElementById("projects-search") as HTMLInputElement | null;
|
||||
if (searchInput && searchInput.value !== state.searchQuery) {
|
||||
searchInput.value = state.searchQuery;
|
||||
}
|
||||
}
|
||||
|
||||
function reflectMulti(name: string, set: Set<string>) {
|
||||
const wrap = document.querySelector<HTMLDetailsElement>(`.projects-chip-multi[data-chip-multi="${name}"]`);
|
||||
if (!wrap) return;
|
||||
const summary = wrap.querySelector<HTMLElement>("summary");
|
||||
const inputs = wrap.querySelectorAll<HTMLInputElement>('input[type="checkbox"]');
|
||||
inputs.forEach((cb) => { cb.checked = set.has(cb.value); });
|
||||
if (summary) {
|
||||
summary.classList.toggle("is-active", set.size > 0);
|
||||
const baseLabel = t(`projects.chip.${name}` as never) || (name === "status" ? "Status" : "Typ");
|
||||
if (set.size === 0) {
|
||||
summary.textContent = String(baseLabel);
|
||||
} else if (set.size === 1) {
|
||||
const sole = [...set][0];
|
||||
const labelKey = `projects.chip.${name}.${sole}` as never;
|
||||
const label = t(labelKey) || sole;
|
||||
summary.textContent = `${baseLabel}: ${label}`;
|
||||
} else {
|
||||
const tmpl = t("projects.chip.multi.count" as never) || "{n} ausgewählt";
|
||||
summary.textContent = `${baseLabel}: ${String(tmpl).replace("{n}", String(set.size))}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setScope(s: Scope) {
|
||||
state.chips.scope = s;
|
||||
postChipChange();
|
||||
}
|
||||
|
||||
function toggleHasOpen() {
|
||||
state.chips.hasOpenDeadlines = !state.chips.hasOpenDeadlines;
|
||||
postChipChange();
|
||||
}
|
||||
|
||||
function postChipChange() {
|
||||
syncURL();
|
||||
saveState();
|
||||
reflectChipsToDOM();
|
||||
void render();
|
||||
}
|
||||
|
||||
function clearAllChips() {
|
||||
state = { ...state, chips: defaultState().chips, searchQuery: "" };
|
||||
postChipChange();
|
||||
const searchInput = document.getElementById("projects-search") as HTMLInputElement | null;
|
||||
if (searchInput) searchInput.value = "";
|
||||
}
|
||||
|
||||
async function render() {
|
||||
const treeWrap = document.getElementById("projekt-tree-wrap")!;
|
||||
const tableWrap = document.getElementById("entity-table-wrap")!;
|
||||
const cardsWrap = document.getElementById("projects-cards-wrap")!;
|
||||
const empty = document.getElementById("entity-empty")!;
|
||||
const emptyFiltered = document.getElementById("entity-empty-filtered")!;
|
||||
|
||||
if (state.viewMode === "tree") {
|
||||
teardownCardsView();
|
||||
treeWrap.style.display = "block";
|
||||
tableWrap.style.display = "none";
|
||||
cardsWrap.style.display = "none";
|
||||
empty.style.display = "none";
|
||||
emptyFiltered.style.display = "none";
|
||||
const container = document.getElementById("projekt-tree-container") as HTMLElement;
|
||||
await initProjectTree(container, treeParams());
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.viewMode === "cards") {
|
||||
treeWrap.style.display = "none";
|
||||
tableWrap.style.display = "none";
|
||||
empty.style.display = "none";
|
||||
emptyFiltered.style.display = "none";
|
||||
await renderCardsView({ treeParams: treeParams() });
|
||||
return;
|
||||
}
|
||||
|
||||
// Flat-list mode. Reuses /api/projects (existing flat endpoint).
|
||||
teardownCardsView();
|
||||
treeWrap.style.display = "none";
|
||||
if (!flatRows) {
|
||||
flatRows = await loadFlatRows();
|
||||
}
|
||||
if (!flatRows) {
|
||||
tableWrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const filtered = filterFlatRows(flatRows);
|
||||
const count = document.getElementById("projects-count")!;
|
||||
count.textContent = `${filtered.length} / ${flatRows.length}`;
|
||||
if (flatRows.length === 0) {
|
||||
tableWrap.style.display = "none";
|
||||
empty.style.display = "block";
|
||||
emptyFiltered.style.display = "none";
|
||||
return;
|
||||
}
|
||||
if (filtered.length === 0) {
|
||||
tableWrap.style.display = "none";
|
||||
empty.style.display = "none";
|
||||
emptyFiltered.style.display = "block";
|
||||
return;
|
||||
}
|
||||
tableWrap.style.display = "";
|
||||
empty.style.display = "none";
|
||||
emptyFiltered.style.display = "none";
|
||||
renderFlatList({ rows: filtered });
|
||||
}
|
||||
|
||||
async function loadFlatRows(): Promise<ProjectFlatRow[] | null> {
|
||||
const unavailable = document.getElementById("entity-unavailable")!;
|
||||
const table = document.querySelector<HTMLElement>(".entity-table-wrap")!;
|
||||
try {
|
||||
const resp = await fetch("/api/projects");
|
||||
if (resp.status === 503) {
|
||||
unavailable.style.display = "block";
|
||||
table.style.display = "none";
|
||||
document.getElementById("entity-empty")!.style.display = "none";
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
unavailable.style.display = "block";
|
||||
table.style.display = "none";
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
allRows = await resp.json();
|
||||
loadedOK = true;
|
||||
render();
|
||||
return (await resp.json()) as ProjectFlatRow[];
|
||||
} catch {
|
||||
unavailable.style.display = "block";
|
||||
table.style.display = "none";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getFiltered(): Project[] {
|
||||
// Tree view is handled by the dedicated tree module. Filters and search
|
||||
// here only apply to the flat list.
|
||||
let rows = allRows;
|
||||
if (viewMode === "roots") rows = rows.filter((p) => !p.parent_id);
|
||||
if (typeFilter) rows = rows.filter((p) => p.type === typeFilter);
|
||||
if (statusFilter) rows = rows.filter((p) => p.status === statusFilter);
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
rows = rows.filter((p) => {
|
||||
function filterFlatRows(rows: ProjectFlatRow[]): ProjectFlatRow[] {
|
||||
let out = rows;
|
||||
if (state.chips.status.size > 0) {
|
||||
out = out.filter((p) => state.chips.status.has(p.status));
|
||||
}
|
||||
if (state.chips.type.size > 0) {
|
||||
out = out.filter((p) => state.chips.type.has(p.type));
|
||||
}
|
||||
// Note: scope=mine / scope=pinned / has_open_deadlines are not applied
|
||||
// to the flat-list view — those need server-side support and the flat
|
||||
// endpoint /api/projects is unchanged from pre-redesign. The chips simply
|
||||
// narrow status + type in flat mode; tree mode honours all chips.
|
||||
if (state.searchQuery.trim()) {
|
||||
const q = state.searchQuery.toLowerCase();
|
||||
out = out.filter((p) => {
|
||||
const haystack = [
|
||||
p.title,
|
||||
p.reference || "",
|
||||
@@ -77,162 +324,100 @@ function getFiltered(): Project[] {
|
||||
return haystack.includes(q);
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!loadedOK) return;
|
||||
const tbody = document.getElementById("projects-body")!;
|
||||
const empty = document.getElementById("entity-empty")!;
|
||||
const emptyFiltered = document.getElementById("entity-empty-filtered")!;
|
||||
const tableWrap = document.getElementById("entity-table-wrap")!;
|
||||
const treeWrap = document.getElementById("projekt-tree-wrap")!;
|
||||
const count = document.getElementById("projects-count")!;
|
||||
|
||||
if (viewMode === "tree") {
|
||||
// Tree view is rendered by project-tree.ts; reflect the toggle state here
|
||||
// and let it handle its own data fetch (separate /api/projects/tree call).
|
||||
tbody.innerHTML = "";
|
||||
tableWrap.style.display = "none";
|
||||
empty.style.display = allRows.length === 0 ? "block" : "none";
|
||||
emptyFiltered.style.display = "none";
|
||||
treeWrap.style.display = allRows.length === 0 ? "none" : "block";
|
||||
// Match the flat-view "X / Y" format so the counter reads consistently
|
||||
// when toggling between views (F-39). Tree view shows everything, so the
|
||||
// numerator equals the total.
|
||||
count.textContent = `${allRows.length} / ${allRows.length}`;
|
||||
if (allRows.length > 0) {
|
||||
const container = document.getElementById("projekt-tree-container") as HTMLElement;
|
||||
void initProjectTree(container);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
treeWrap.style.display = "none";
|
||||
|
||||
const filtered = getFiltered();
|
||||
count.textContent = `${filtered.length} / ${allRows.length}`;
|
||||
|
||||
if (allRows.length === 0) {
|
||||
tbody.innerHTML = "";
|
||||
tableWrap.style.display = "none";
|
||||
empty.style.display = "block";
|
||||
emptyFiltered.style.display = "none";
|
||||
return;
|
||||
}
|
||||
if (filtered.length === 0) {
|
||||
tbody.innerHTML = "";
|
||||
tableWrap.style.display = "none";
|
||||
empty.style.display = "none";
|
||||
emptyFiltered.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
tableWrap.style.display = "";
|
||||
empty.style.display = "none";
|
||||
emptyFiltered.style.display = "none";
|
||||
|
||||
tbody.innerHTML = filtered
|
||||
.map((p) => {
|
||||
const typeLabel = tDyn(`projects.type.${p.type}`) || p.type;
|
||||
const statusLabel = tDyn(`projects.filter.status.${p.status}`) || p.status;
|
||||
const clientMatter =
|
||||
p.client_number && p.matter_number
|
||||
? `${p.client_number}.${p.matter_number}`
|
||||
: p.client_number || p.matter_number || "";
|
||||
// Empty cells render an em-dash to match the rest of the app (F-28).
|
||||
const refCell = p.reference ? esc(p.reference) : "—";
|
||||
const clientMatterCell = clientMatter ? esc(clientMatter) : "—";
|
||||
return `<tr class="entity-row" data-id="${esc(p.id)}">
|
||||
<td class="entity-col-title">${esc(p.title)}</td>
|
||||
<td><span class="entity-type-chip entity-type-${esc(p.type)}">${esc(typeLabel)}</span></td>
|
||||
<td class="entity-col-ref">${refCell}</td>
|
||||
<td class="entity-col-ref">${clientMatterCell}</td>
|
||||
<td class="entity-col-status"><span class="entity-status-chip entity-status-${esc(p.status)}">${esc(statusLabel)}</span></td>
|
||||
<td class="entity-col-updated">${fmtDate(p.updated_at)}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
// F-23: when every visible row shares the same status, hide the column to
|
||||
// cut redundant noise. The toggle re-runs on every filter change, so the
|
||||
// column comes back as soon as the rows mix again.
|
||||
const statusUnique = new Set(filtered.map((p) => p.status)).size;
|
||||
const table = document.getElementById("entity-table");
|
||||
table?.classList.toggle("entity-table--hide-status", statusUnique <= 1);
|
||||
|
||||
tbody.querySelectorAll<HTMLTableRowElement>(".entity-row").forEach((row) => {
|
||||
row.addEventListener("click", () => {
|
||||
const id = row.dataset.id!;
|
||||
window.location.href = `/projects/${id}`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
return out;
|
||||
}
|
||||
|
||||
function initSearch() {
|
||||
const input = document.getElementById("projects-search") as HTMLInputElement;
|
||||
const input = document.getElementById("projects-search") as HTMLInputElement | null;
|
||||
if (!input) return;
|
||||
input.addEventListener("input", () => {
|
||||
searchQuery = input.value.trim();
|
||||
render();
|
||||
if (searchDebounce !== null) {
|
||||
window.clearTimeout(searchDebounce);
|
||||
}
|
||||
searchDebounce = window.setTimeout(() => {
|
||||
state.searchQuery = input.value;
|
||||
syncURL();
|
||||
saveState();
|
||||
void render();
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
});
|
||||
}
|
||||
|
||||
function initFilters() {
|
||||
const typeSel = document.getElementById("project-type") as HTMLSelectElement;
|
||||
const status = document.getElementById("project-status") as HTMLSelectElement;
|
||||
const view = document.getElementById("project-view") as HTMLSelectElement;
|
||||
view.value = viewMode;
|
||||
typeSel.addEventListener("change", () => {
|
||||
typeFilter = typeSel.value;
|
||||
render();
|
||||
function initChips() {
|
||||
document.querySelectorAll<HTMLButtonElement>(".projects-chip[data-chip]").forEach((btn) => {
|
||||
const chip = btn.dataset.chip!;
|
||||
if (chip === "all") {
|
||||
btn.addEventListener("click", () => clearAllChips());
|
||||
} else if (chip === "mine") {
|
||||
btn.addEventListener("click", () => setScope(state.chips.scope === "mine" ? "all" : "mine"));
|
||||
} else if (chip === "pinned") {
|
||||
btn.addEventListener("click", () => setScope(state.chips.scope === "pinned" ? "all" : "pinned"));
|
||||
} else if (chip === "has_open_deadlines") {
|
||||
btn.addEventListener("click", () => toggleHasOpen());
|
||||
}
|
||||
});
|
||||
status.addEventListener("change", () => {
|
||||
statusFilter = status.value;
|
||||
render();
|
||||
});
|
||||
view.addEventListener("change", () => {
|
||||
viewMode = view.value as "flat" | "tree" | "roots";
|
||||
syncViewQuery();
|
||||
render();
|
||||
|
||||
// Multi-select panels — wire each checkbox change.
|
||||
document.querySelectorAll<HTMLDetailsElement>(".projects-chip-multi").forEach((wrap) => {
|
||||
const name = wrap.dataset.chipMulti!;
|
||||
const set = (name === "status" ? state.chips.status : state.chips.type);
|
||||
wrap.querySelectorAll<HTMLInputElement>('input[type="checkbox"]').forEach((cb) => {
|
||||
cb.addEventListener("change", () => {
|
||||
if (cb.checked) set.add(cb.value); else set.delete(cb.value);
|
||||
postChipChange();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const reset = document.getElementById("projects-reset-filters");
|
||||
if (reset) reset.addEventListener("click", () => clearAllChips());
|
||||
}
|
||||
|
||||
// Mirror viewMode into ?view= so the URL is shareable. Default "flat" stays
|
||||
// implicit (drop the param) to keep the canonical path clean.
|
||||
function syncViewQuery() {
|
||||
const url = new URL(window.location.href);
|
||||
if (viewMode === "flat") url.searchParams.delete("view");
|
||||
else url.searchParams.set("view", viewMode);
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
function initViewSegment() {
|
||||
document.querySelectorAll<HTMLButtonElement>(".projects-view-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const v = btn.dataset.view as ViewMode;
|
||||
if (v !== "tree" && v !== "flat" && v !== "cards") return;
|
||||
if (state.viewMode === v) return;
|
||||
state.viewMode = v;
|
||||
syncURL();
|
||||
saveState();
|
||||
reflectChipsToDOM();
|
||||
void render();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
// Q1 lock-in: last-viewed restore. URL > sessionStorage > defaults.
|
||||
const stored = loadStoredState();
|
||||
if (stored) state = stored;
|
||||
applyURL();
|
||||
reflectChipsToDOM();
|
||||
initSearch();
|
||||
initFilters();
|
||||
initChips();
|
||||
initViewSegment();
|
||||
onLangChange(() => {
|
||||
render();
|
||||
if (viewMode === "tree") rerenderProjectTree();
|
||||
reflectChipsToDOM();
|
||||
if (state.viewMode === "tree") {
|
||||
rerenderProjectTree();
|
||||
} else {
|
||||
void render();
|
||||
}
|
||||
});
|
||||
void render();
|
||||
// The pin handler in project-tree.ts mutates the per-node cache and then
|
||||
// invalidates it, so subsequent chip changes refetch with fresh pin data.
|
||||
// When the user navigates back to /projects via popstate (in-app links),
|
||||
// re-apply URL state.
|
||||
window.addEventListener("popstate", () => {
|
||||
state = loadStoredState() || defaultState();
|
||||
applyURL();
|
||||
reflectChipsToDOM();
|
||||
refreshProjectTree(treeParams());
|
||||
flatRows = null;
|
||||
void render();
|
||||
});
|
||||
loadProjekte();
|
||||
});
|
||||
|
||||
@@ -70,7 +70,10 @@ export function initSidebar() {
|
||||
initInviteModal();
|
||||
initGlobalSearch();
|
||||
initChangelogBadge();
|
||||
initInboxBadge();
|
||||
initAdminGroup();
|
||||
initPaliadinLinks();
|
||||
initUserViewsGroup();
|
||||
initThemeToggle();
|
||||
const sidebar = document.querySelector<HTMLElement>(".sidebar");
|
||||
if (!sidebar) return;
|
||||
@@ -314,6 +317,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-
|
||||
@@ -372,6 +402,148 @@ function initThemeToggle(): void {
|
||||
render();
|
||||
}
|
||||
|
||||
// t-paliad-144 Phase A2 — Meine Sichten group hydration. Fetches the
|
||||
// caller's saved views and renders one nav item per view between the
|
||||
// group label and the "+ Neue Sicht" trailing entry. Optional count
|
||||
// badge per view (when show_count=true on the row). The "+ Neue Sicht"
|
||||
// entry stays in the DOM unconditionally so the group has something
|
||||
// to show even for first-time users.
|
||||
interface UserViewLite {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
show_count: boolean;
|
||||
}
|
||||
|
||||
function initUserViewsGroup(): void {
|
||||
const items = document.getElementById("sidebar-views-items");
|
||||
if (!items) return;
|
||||
// Skip on auth-anon pages (/login, landing) — /api/user-views would 401.
|
||||
if (!document.body.classList.contains("has-sidebar")) return;
|
||||
|
||||
fetch("/api/user-views", { credentials: "same-origin" })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((views: UserViewLite[] | null) => {
|
||||
if (!views) return;
|
||||
const currentPath = window.location.pathname;
|
||||
items.innerHTML = "";
|
||||
for (const view of views) {
|
||||
items.appendChild(renderUserViewItem(view, currentPath));
|
||||
}
|
||||
// After rendering, kick off count refresh for views that opted in.
|
||||
for (const view of views) {
|
||||
if (view.show_count) {
|
||||
void refreshUserViewCount(view);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Silent — sidebar already shows "+ Neue Sicht" even on failure.
|
||||
});
|
||||
}
|
||||
|
||||
function renderUserViewItem(view: UserViewLite, currentPath: string): HTMLElement {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/views/${encodeURIComponent(view.slug)}`;
|
||||
const active = currentPath === a.pathname;
|
||||
a.className = `sidebar-item sidebar-user-view-item${active ? " active" : ""}`;
|
||||
a.dataset.slug = view.slug;
|
||||
a.dataset.viewId = view.id;
|
||||
|
||||
const iconWrap = document.createElement("span");
|
||||
iconWrap.className = "sidebar-icon";
|
||||
iconWrap.innerHTML = userViewIconSvg(view.icon);
|
||||
a.appendChild(iconWrap);
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "sidebar-label";
|
||||
label.textContent = view.name;
|
||||
a.appendChild(label);
|
||||
|
||||
if (view.show_count) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "sidebar-badge sidebar-user-view-badge";
|
||||
badge.id = `sidebar-user-view-badge-${view.id}`;
|
||||
badge.style.display = "none";
|
||||
badge.setAttribute("aria-hidden", "true");
|
||||
a.appendChild(badge);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
async function refreshUserViewCount(view: UserViewLite): Promise<void> {
|
||||
try {
|
||||
const r = await fetch(`/api/views/${encodeURIComponent(view.slug)}/run`, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!r.ok) return;
|
||||
const data = (await r.json()) as { rows: unknown[] };
|
||||
const badge = document.getElementById(`sidebar-user-view-badge-${view.id}`);
|
||||
if (!badge) return;
|
||||
if (data.rows.length > 0) {
|
||||
badge.textContent = String(data.rows.length);
|
||||
badge.style.display = "";
|
||||
} else {
|
||||
badge.style.display = "none";
|
||||
}
|
||||
} catch (_e) {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
// userViewIconSvg picks an SVG from a small fixed registry. Falls back
|
||||
// to the folder icon for unknown / missing keys. Inline SVGs are used
|
||||
// elsewhere in the sidebar (Sidebar.tsx); we duplicate a minimal subset
|
||||
// here rather than re-exporting because client TS doesn't import from
|
||||
// JSX-emitting modules.
|
||||
function userViewIconSvg(icon?: string): string {
|
||||
switch (icon) {
|
||||
case "clock":
|
||||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
|
||||
case "calendar":
|
||||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
|
||||
case "bell":
|
||||
return '<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>';
|
||||
case "users":
|
||||
return '<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>';
|
||||
case "building":
|
||||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V5a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v16"/><path d="M16 9h3a2 2 0 0 1 2 2v10"/></svg>';
|
||||
case "folder":
|
||||
default:
|
||||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
|
||||
}
|
||||
}
|
||||
|
||||
// PALIADIN_OWNER_EMAIL must match services.PaliadinOwnerEmail (Go side).
|
||||
// PoC scope — see docs/design-paliadin-2026-05-07.md §0.5.
|
||||
const PALIADIN_OWNER_EMAIL = "matthias.siebels@hoganlovells.com";
|
||||
|
||||
// initPaliadinLinks reveals the Paliadin sidebar entries (under Übersicht
|
||||
// + Admin) when /api/me confirms the caller is the Paliadin owner. Same
|
||||
// fail-closed display:none pattern as initAdminGroup. Non-owners never
|
||||
// see the entries; the routes themselves return 404 if they navigate
|
||||
// to /paliadin or /admin/paliadin manually anyway.
|
||||
function initPaliadinLinks(): void {
|
||||
const top = document.getElementById("sidebar-paliadin-link") as HTMLElement | null;
|
||||
const admin = document.getElementById("sidebar-admin-paliadin-link") as HTMLElement | null;
|
||||
if (!top && !admin) return;
|
||||
fetch("/api/me", { credentials: "same-origin" })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((me: { email?: string } | null) => {
|
||||
if (me && me.email && me.email.toLowerCase() === PALIADIN_OWNER_EMAIL) {
|
||||
if (top) top.style.display = "";
|
||||
if (admin) admin.style.display = "";
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// silent: failing closed is the safe default.
|
||||
});
|
||||
}
|
||||
|
||||
// initAdminGroup reveals the Admin section in the sidebar when the caller's
|
||||
// /api/me lookup confirms global_role='global_admin'. The markup is in the
|
||||
// DOM with display:none for everyone — flipping it on after the fetch lands
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { initI18n, onLangChange, t, tDyn } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { openBroadcastModal, firstName, type BroadcastRecipient } from "./broadcast";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -10,6 +11,25 @@ interface User {
|
||||
job_title?: string | null;
|
||||
}
|
||||
|
||||
interface MembershipEntry {
|
||||
user_id: string;
|
||||
project_ids: string[];
|
||||
lead_project_ids: string[];
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
interface ProjectSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
reference?: string | null;
|
||||
}
|
||||
|
||||
interface MeUser {
|
||||
id: string;
|
||||
global_role: string;
|
||||
}
|
||||
|
||||
interface DepartmentMember {
|
||||
user_id: string;
|
||||
email: string;
|
||||
@@ -48,9 +68,13 @@ const ROLE_ORDER = [
|
||||
|
||||
let users: User[] = [];
|
||||
let departments: Department[] = [];
|
||||
let memberships: MembershipEntry[] = [];
|
||||
let projectsList: ProjectSummary[] = [];
|
||||
let me: MeUser | null = null;
|
||||
let groupBy: "office" | "department" = "office";
|
||||
let activeOffice = "all";
|
||||
let activeRole = "all";
|
||||
let activeProjectIDs: Set<string> = new Set();
|
||||
let searchQuery = "";
|
||||
|
||||
const ICON_MAIL = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>';
|
||||
@@ -87,15 +111,26 @@ function initials(name: string): string {
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
const [usersResp, deptsResp] = await Promise.all([
|
||||
const [usersResp, deptsResp, membershipsResp, projectsResp, meResp] = await Promise.all([
|
||||
fetch("/api/users"),
|
||||
fetch("/api/partner-units?include=members"),
|
||||
fetch("/api/team/memberships"),
|
||||
fetch("/api/projects"),
|
||||
fetch("/api/me"),
|
||||
]);
|
||||
if (usersResp.ok) users = (await usersResp.json()) as User[];
|
||||
if (deptsResp.ok) departments = (await deptsResp.json()) as Department[];
|
||||
if (membershipsResp.ok) memberships = (await membershipsResp.json()) as MembershipEntry[];
|
||||
if (projectsResp.ok) {
|
||||
const raw = (await projectsResp.json()) as ProjectSummary[];
|
||||
projectsList = raw;
|
||||
}
|
||||
if (meResp.ok) me = (await meResp.json()) as MeUser;
|
||||
buildOfficeFilters();
|
||||
buildRoleFilters();
|
||||
buildProjectFilter();
|
||||
render();
|
||||
updateBroadcastButton();
|
||||
}
|
||||
|
||||
function presentOffices(): string[] {
|
||||
@@ -191,6 +226,176 @@ function userMatchesRole(u: User): boolean {
|
||||
return roleKey(u.job_title) === activeRole.toLowerCase();
|
||||
}
|
||||
|
||||
// userMatchesProject returns true when the project filter is empty or
|
||||
// when the user is a direct member of at least one selected project.
|
||||
// Inherited memberships intentionally don't qualify here — users want
|
||||
// "people I can mail on this matter", which means direct membership.
|
||||
function userMatchesProject(u: User): boolean {
|
||||
if (activeProjectIDs.size === 0) return true;
|
||||
const m = memberships.find((m) => m.user_id === u.id);
|
||||
if (!m) return false;
|
||||
for (const pid of m.project_ids) {
|
||||
if (activeProjectIDs.has(pid)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// canBroadcast reports whether the current user is allowed to send a
|
||||
// broadcast given the active project filter. global_admin always wins.
|
||||
// Otherwise the user must be a 'lead' on every project they have
|
||||
// selected (or, when no project is selected, on at least one of their
|
||||
// own projects).
|
||||
function canBroadcast(): boolean {
|
||||
if (!me) return false;
|
||||
if (me.global_role === "global_admin") return true;
|
||||
const myMembership = memberships.find((m) => m.user_id === me?.id);
|
||||
if (!myMembership || !myMembership.lead_project_ids.length) return false;
|
||||
if (activeProjectIDs.size === 0) {
|
||||
// No project filter — allow when caller leads at least one project.
|
||||
// Server-side check still runs per-broadcast so a non-lead can never
|
||||
// actually send.
|
||||
return true;
|
||||
}
|
||||
for (const pid of activeProjectIDs) {
|
||||
if (!myMembership.lead_project_ids.includes(pid)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildProjectFilter() {
|
||||
const container = document.getElementById("team-project-filter");
|
||||
if (!container) return;
|
||||
// Show only projects the caller can see — projectsList already does
|
||||
// that via the visibility-gated /api/projects endpoint.
|
||||
const sortedProjects = [...projectsList].sort((a, b) =>
|
||||
(a.title || "").localeCompare(b.title || ""),
|
||||
);
|
||||
const options = sortedProjects
|
||||
.map(
|
||||
(p) =>
|
||||
`<label class="filter-checkbox"><input type="checkbox" data-project-id="${esc(p.id)}" ${
|
||||
activeProjectIDs.has(p.id) ? "checked" : ""
|
||||
} /> <span>${esc(p.title)}</span></label>`,
|
||||
)
|
||||
.join("");
|
||||
const summary = activeProjectIDs.size === 0
|
||||
? (t("team.filter.project.all") || "Alle Projekte")
|
||||
: `${activeProjectIDs.size} ${t("team.filter.project.selected") || "ausgewählt"}`;
|
||||
container.innerHTML = `
|
||||
<button type="button" class="filter-pill team-project-trigger" data-project-trigger>
|
||||
<span class="team-project-summary">${esc(t("team.filter.project") || "Projekt")}: ${esc(summary)}</span>
|
||||
</button>
|
||||
<div class="team-project-panel hidden" data-project-panel>
|
||||
<div class="team-project-actions">
|
||||
<button type="button" class="link-button" data-project-clear>${esc(t("team.filter.project.clear") || "Alle abwählen")}</button>
|
||||
</div>
|
||||
<div class="team-project-options">${options}</div>
|
||||
</div>
|
||||
`;
|
||||
const trigger = container.querySelector<HTMLButtonElement>("[data-project-trigger]");
|
||||
const panel = container.querySelector<HTMLDivElement>("[data-project-panel]");
|
||||
trigger?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
panel?.classList.toggle("hidden");
|
||||
});
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!container.contains(e.target as Node)) panel?.classList.add("hidden");
|
||||
});
|
||||
container.querySelectorAll<HTMLInputElement>("input[data-project-id]").forEach((cb) => {
|
||||
cb.addEventListener("change", () => {
|
||||
const pid = cb.dataset.projectId!;
|
||||
if (cb.checked) activeProjectIDs.add(pid);
|
||||
else activeProjectIDs.delete(pid);
|
||||
buildProjectFilter();
|
||||
render();
|
||||
updateBroadcastButton();
|
||||
});
|
||||
});
|
||||
container.querySelector<HTMLButtonElement>("[data-project-clear]")?.addEventListener("click", () => {
|
||||
activeProjectIDs.clear();
|
||||
buildProjectFilter();
|
||||
render();
|
||||
updateBroadcastButton();
|
||||
});
|
||||
}
|
||||
|
||||
function buildBroadcastButton() {
|
||||
const wrap = document.getElementById("team-broadcast-wrap");
|
||||
if (!wrap) return;
|
||||
if (!canBroadcast()) {
|
||||
wrap.innerHTML = "";
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
wrap.style.display = "";
|
||||
wrap.innerHTML = `
|
||||
<button type="button" class="btn btn-primary" id="team-broadcast-btn">
|
||||
${esc(t("team.broadcast.button") || "E-Mail an Auswahl")} <span class="team-broadcast-count" id="team-broadcast-count">0</span>
|
||||
</button>
|
||||
`;
|
||||
document.getElementById("team-broadcast-btn")?.addEventListener("click", () => onBroadcastClick());
|
||||
}
|
||||
|
||||
function updateBroadcastButton() {
|
||||
buildBroadcastButton();
|
||||
const countEl = document.getElementById("team-broadcast-count");
|
||||
if (countEl) {
|
||||
const n = displayedRecipients().length;
|
||||
countEl.textContent = String(n);
|
||||
const btn = document.getElementById("team-broadcast-btn") as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = n === 0;
|
||||
}
|
||||
}
|
||||
|
||||
// displayedRecipients returns the currently visible users as broadcast
|
||||
// recipients. Personal placeholder fields are sourced from each user
|
||||
// (display_name / first_name) and from the membership index when a
|
||||
// project filter is set (role_on_project = the role on the selected
|
||||
// project; falls back to first available role).
|
||||
function displayedRecipients(): BroadcastRecipient[] {
|
||||
const filtered = users.filter(
|
||||
(u) => userMatchesOffice(u) && userMatchesRole(u) && userMatchesProject(u) && userMatchesSearch(u),
|
||||
);
|
||||
return filtered.map((u) => {
|
||||
const m = memberships.find((m) => m.user_id === u.id);
|
||||
let role = "";
|
||||
if (m) {
|
||||
if (activeProjectIDs.size > 0) {
|
||||
const idx = m.project_ids.findIndex((pid) => activeProjectIDs.has(pid));
|
||||
if (idx >= 0) role = m.roles[idx];
|
||||
} else if (m.roles.length > 0) {
|
||||
role = m.roles[0];
|
||||
}
|
||||
}
|
||||
return {
|
||||
user_id: u.id,
|
||||
email: u.email,
|
||||
display_name: u.display_name,
|
||||
first_name: firstName(u.display_name),
|
||||
role_on_project: role,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function onBroadcastClick() {
|
||||
const recipients = displayedRecipients();
|
||||
const selectedProjectIDs = Array.from(activeProjectIDs);
|
||||
// When exactly one project is selected we pass it as project_id so
|
||||
// the backend can verify lead-ship on that project. With multi-
|
||||
// select we leave project_id null and rely on global_admin (the
|
||||
// service rejects non-admin senders without a project_id).
|
||||
const projectID = selectedProjectIDs.length === 1 ? selectedProjectIDs[0] : null;
|
||||
const offices = activeOffice === "all" ? [] : [activeOffice];
|
||||
const roles = activeRole === "all" ? [] : [activeRole];
|
||||
openBroadcastModal({
|
||||
recipients,
|
||||
projectID,
|
||||
projectIDs: selectedProjectIDs,
|
||||
offices,
|
||||
roles,
|
||||
});
|
||||
}
|
||||
|
||||
function memberAsUser(m: DepartmentMember): User | undefined {
|
||||
return users.find((u) => u.id === m.user_id);
|
||||
}
|
||||
@@ -297,8 +502,11 @@ function render() {
|
||||
const empty = document.getElementById("team-empty")!;
|
||||
const count = document.getElementById("team-count")!;
|
||||
|
||||
const filtered = users.filter((u) => userMatchesOffice(u) && userMatchesRole(u) && userMatchesSearch(u));
|
||||
const filtered = users.filter(
|
||||
(u) => userMatchesOffice(u) && userMatchesRole(u) && userMatchesProject(u) && userMatchesSearch(u),
|
||||
);
|
||||
count.textContent = `${filtered.length} / ${users.length}`;
|
||||
updateBroadcastButton();
|
||||
|
||||
if (filtered.length === 0) {
|
||||
list.innerHTML = "";
|
||||
|
||||
282
frontend/src/client/views-editor.ts
Normal file
282
frontend/src/client/views-editor.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import {
|
||||
defaultFilterSpec,
|
||||
defaultRenderSpec,
|
||||
type DataSource,
|
||||
type FilterSpec,
|
||||
type RenderShape,
|
||||
type RenderSpec,
|
||||
type ScopeMode,
|
||||
type TimeHorizon,
|
||||
type UserView,
|
||||
} from "./views/types";
|
||||
|
||||
// View editor — /views/new (create) and /views/{slug}/edit (modify).
|
||||
// The form has a small fixed set of widgets (no full predicate JSON
|
||||
// editor in v1 — that's a follow-up if power users ask). Saves via
|
||||
// POST/PATCH /api/user-views.
|
||||
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
interface EditorState {
|
||||
mode: "new" | "edit";
|
||||
// Set in edit mode after the existing view is fetched.
|
||||
existing?: UserView;
|
||||
}
|
||||
|
||||
let state: EditorState = { mode: "new" };
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
state = detectMode();
|
||||
bindShapeToggle();
|
||||
bindForm();
|
||||
bindDelete();
|
||||
if (state.mode === "edit") {
|
||||
void loadExisting();
|
||||
const heading = document.getElementById("editor-heading");
|
||||
if (heading) heading.textContent = t("views.editor.heading.edit");
|
||||
const del = document.getElementById("editor-delete");
|
||||
if (del) del.hidden = false;
|
||||
} else {
|
||||
seedDefaults();
|
||||
}
|
||||
});
|
||||
|
||||
function detectMode(): EditorState {
|
||||
const m = window.location.pathname.match(/^\/views\/([^\/]+)\/edit$/);
|
||||
if (m) return { mode: "edit" };
|
||||
return { mode: "new" };
|
||||
}
|
||||
|
||||
function editSlugFromPath(): string | null {
|
||||
const m = window.location.pathname.match(/^\/views\/([^\/]+)\/edit$/);
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
}
|
||||
|
||||
async function loadExisting(): Promise<void> {
|
||||
const slug = editSlugFromPath();
|
||||
if (!slug) return;
|
||||
const r = await fetch("/api/user-views", { credentials: "include" });
|
||||
if (!r.ok) {
|
||||
showFeedback("error", t("views.editor.error.load_failed"));
|
||||
return;
|
||||
}
|
||||
const list = (await r.json()) as UserView[];
|
||||
const view = list.find((v) => v.slug === slug);
|
||||
if (!view) {
|
||||
showFeedback("error", t("views.error.not_found"));
|
||||
return;
|
||||
}
|
||||
state.existing = view;
|
||||
populateForm(view);
|
||||
}
|
||||
|
||||
function populateForm(view: UserView): void {
|
||||
setInputValue("editor-name", view.name);
|
||||
setInputValue("editor-slug", view.slug);
|
||||
setSelectValue("editor-icon", view.icon ?? "");
|
||||
setCheckboxValue("editor-show-count", view.show_count);
|
||||
|
||||
for (const src of ["deadline", "appointment", "project_event", "approval_request"] as DataSource[]) {
|
||||
setCheckboxValue(`source-${src}`, view.filter_spec.sources.includes(src), { name: "source", value: src });
|
||||
}
|
||||
|
||||
setSelectValue("editor-scope-mode", view.filter_spec.scope.projects.mode);
|
||||
setCheckboxValue("editor-personal-only", view.filter_spec.scope.personal_only ?? false);
|
||||
|
||||
setSelectValue("editor-time-horizon", view.filter_spec.time.horizon);
|
||||
|
||||
setSelectValue("editor-shape", view.render_spec.shape);
|
||||
setSelectValue("editor-list-density", view.render_spec.list?.density ?? "comfortable");
|
||||
|
||||
// Hide list-density when shape isn't list.
|
||||
toggleListDensityVisibility(view.render_spec.shape);
|
||||
}
|
||||
|
||||
function seedDefaults(): void {
|
||||
// Seed the form with a useful blank-slate spec.
|
||||
const filter = defaultFilterSpec();
|
||||
const render = defaultRenderSpec();
|
||||
for (const src of filter.sources) {
|
||||
setCheckboxValue(`source-${src}`, true, { name: "source", value: src });
|
||||
}
|
||||
setSelectValue("editor-scope-mode", filter.scope.projects.mode);
|
||||
setSelectValue("editor-time-horizon", filter.time.horizon);
|
||||
setSelectValue("editor-shape", render.shape);
|
||||
setSelectValue("editor-list-density", render.list?.density ?? "comfortable");
|
||||
toggleListDensityVisibility(render.shape);
|
||||
}
|
||||
|
||||
function bindShapeToggle(): void {
|
||||
const shapeSelect = document.getElementById("editor-shape") as HTMLSelectElement | null;
|
||||
if (!shapeSelect) return;
|
||||
shapeSelect.addEventListener("change", () => {
|
||||
toggleListDensityVisibility(shapeSelect.value as RenderShape);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleListDensityVisibility(shape: RenderShape): void {
|
||||
const group = document.getElementById("editor-list-density-group");
|
||||
if (!group) return;
|
||||
group.style.display = shape === "list" ? "" : "none";
|
||||
}
|
||||
|
||||
function bindForm(): void {
|
||||
const form = document.getElementById("editor-form") as HTMLFormElement | null;
|
||||
if (!form) return;
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const payload = collectForm();
|
||||
if (!payload) return; // collectForm already shows feedback
|
||||
if (state.mode === "edit" && state.existing) {
|
||||
await save("PATCH", `/api/user-views/${state.existing.id}`, payload);
|
||||
} else {
|
||||
await save("POST", `/api/user-views`, payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindDelete(): void {
|
||||
const btn = document.getElementById("editor-delete") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
if (!state.existing) return;
|
||||
if (!confirm(t("views.editor.confirm_delete"))) return;
|
||||
const r = await fetch(`/api/user-views/${state.existing.id}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!r.ok) {
|
||||
showFeedback("error", t("views.editor.error.delete_failed"));
|
||||
return;
|
||||
}
|
||||
window.location.href = "/views";
|
||||
});
|
||||
}
|
||||
|
||||
interface CreatePayload {
|
||||
slug: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
filter_spec: FilterSpec;
|
||||
render_spec: RenderSpec;
|
||||
show_count: boolean;
|
||||
}
|
||||
|
||||
function collectForm(): CreatePayload | null {
|
||||
const name = getInputValue("editor-name").trim();
|
||||
const slug = getInputValue("editor-slug").trim();
|
||||
const iconRaw = getSelectValue("editor-icon");
|
||||
const icon = iconRaw === "" ? undefined : iconRaw;
|
||||
const showCount = getCheckboxValue("editor-show-count");
|
||||
|
||||
if (!name) {
|
||||
showFeedback("error", t("views.editor.error.name_required"));
|
||||
return null;
|
||||
}
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(slug)) {
|
||||
showFeedback("error", t("views.editor.error.slug_format"));
|
||||
return null;
|
||||
}
|
||||
|
||||
const sources: DataSource[] = (["deadline", "appointment", "project_event", "approval_request"] as DataSource[])
|
||||
.filter((s) => getCheckboxValue(`source-${s}`));
|
||||
if (sources.length === 0) {
|
||||
showFeedback("error", t("views.editor.error.sources_required"));
|
||||
return null;
|
||||
}
|
||||
|
||||
const scopeMode = getSelectValue("editor-scope-mode") as ScopeMode;
|
||||
const personalOnly = getCheckboxValue("editor-personal-only");
|
||||
const horizon = getSelectValue("editor-time-horizon") as TimeHorizon;
|
||||
|
||||
const shape = getSelectValue("editor-shape") as RenderShape;
|
||||
const listDensity = getSelectValue("editor-list-density") as "comfortable" | "compact";
|
||||
|
||||
const filter: FilterSpec = {
|
||||
version: 1,
|
||||
sources,
|
||||
scope: {
|
||||
projects: { mode: scopeMode },
|
||||
personal_only: personalOnly,
|
||||
},
|
||||
time: { horizon, field: "auto" },
|
||||
};
|
||||
const render: RenderSpec = {
|
||||
shape,
|
||||
list: shape === "list" ? { density: listDensity, sort: "date_asc" } : undefined,
|
||||
cards: shape === "cards" ? { group_by: "day", sort: "date_asc" } : undefined,
|
||||
calendar: shape === "calendar" ? { default_view: "month" } : undefined,
|
||||
};
|
||||
|
||||
return { slug, name, icon, filter_spec: filter, render_spec: render, show_count: showCount };
|
||||
}
|
||||
|
||||
async function save(method: "POST" | "PATCH", url: string, payload: CreatePayload): Promise<void> {
|
||||
const r = await fetch(url, {
|
||||
method,
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const body = await r.json().catch(() => ({} as { error?: string }));
|
||||
showFeedback("error", body.error || `${r.status}: ${r.statusText}`);
|
||||
return;
|
||||
}
|
||||
// Saved — go to the saved view.
|
||||
window.location.href = `/views/${encodeURIComponent(payload.slug)}`;
|
||||
}
|
||||
|
||||
// ----- DOM helpers -----
|
||||
|
||||
function getInputValue(id: string): string {
|
||||
const el = document.getElementById(id) as HTMLInputElement | null;
|
||||
return el?.value ?? "";
|
||||
}
|
||||
|
||||
function setInputValue(id: string, value: string): void {
|
||||
const el = document.getElementById(id) as HTMLInputElement | null;
|
||||
if (el) el.value = value;
|
||||
}
|
||||
|
||||
function getSelectValue(id: string): string {
|
||||
const el = document.getElementById(id) as HTMLSelectElement | null;
|
||||
return el?.value ?? "";
|
||||
}
|
||||
|
||||
function setSelectValue(id: string, value: string): void {
|
||||
const el = document.getElementById(id) as HTMLSelectElement | null;
|
||||
if (el) el.value = value;
|
||||
}
|
||||
|
||||
function getCheckboxValue(id: string): boolean {
|
||||
const el = document.getElementById(id) as HTMLInputElement | null;
|
||||
if (el) return el.checked;
|
||||
// Fallback: lookup by name+value (for the source-* checkbox group).
|
||||
const m = id.match(/^source-(.+)$/);
|
||||
if (m) {
|
||||
const cb = document.querySelector<HTMLInputElement>(`input[name="source"][value="${m[1]}"]`);
|
||||
return !!cb?.checked;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function setCheckboxValue(id: string, value: boolean, fallback?: { name: string; value: string }): void {
|
||||
let el = document.getElementById(id) as HTMLInputElement | null;
|
||||
if (!el && fallback) {
|
||||
el = document.querySelector<HTMLInputElement>(`input[name="${fallback.name}"][value="${fallback.value}"]`);
|
||||
}
|
||||
if (el) el.checked = value;
|
||||
}
|
||||
|
||||
function showFeedback(kind: "success" | "error", text: string): void {
|
||||
const el = document.getElementById("editor-feedback");
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
el.classList.remove("form-msg-success", "form-msg-error");
|
||||
el.classList.add(kind === "success" ? "form-msg-success" : "form-msg-error");
|
||||
el.hidden = false;
|
||||
}
|
||||
251
frontend/src/client/views.ts
Normal file
251
frontend/src/client/views.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { initI18n, t, type I18nKey } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import type { FilterSpec, RenderSpec, ViewRunResult, UserView, RenderShape } from "./views/types";
|
||||
import { renderListShape } from "./views/shape-list";
|
||||
import { renderCardsShape } from "./views/shape-cards";
|
||||
import { renderCalendarShape } from "./views/shape-calendar";
|
||||
|
||||
// /views and /views/{slug} client. Loads the saved or system view, runs
|
||||
// it via /api/views/{slug}/run, and dispatches to the matching render-
|
||||
// shape component. Shape-switcher chips toggle the live render without
|
||||
// re-fetching (the rows are already in memory).
|
||||
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
interface ViewMeta {
|
||||
// For saved views: identifies the row for touch/edit/delete.
|
||||
user_view_id?: string;
|
||||
// Display name + slug.
|
||||
name: string;
|
||||
slug: string;
|
||||
// Filter + render specs (may be overridden by slug detection).
|
||||
filter: FilterSpec;
|
||||
render: RenderSpec;
|
||||
// Whether this is a code-resident SystemView.
|
||||
is_system: boolean;
|
||||
}
|
||||
|
||||
let currentMeta: ViewMeta | null = null;
|
||||
let currentRows: ViewRunResult | null = null;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
bindShapeChips();
|
||||
bindToastClose();
|
||||
void hydrate();
|
||||
});
|
||||
|
||||
async function hydrate(): Promise<void> {
|
||||
const slug = pathSlug();
|
||||
if (!slug) {
|
||||
// /views with no slug → empty / onboarding state.
|
||||
const onboarding = document.getElementById("views-onboarding");
|
||||
const loading = document.getElementById("views-loading");
|
||||
if (loading) loading.hidden = true;
|
||||
if (onboarding) onboarding.hidden = false;
|
||||
return;
|
||||
}
|
||||
// Resolve the view: try system first, then user.
|
||||
const meta = await resolveMeta(slug);
|
||||
if (!meta) {
|
||||
showError(t("views.error.not_found"));
|
||||
return;
|
||||
}
|
||||
currentMeta = meta;
|
||||
document.title = `${meta.name} — Paliad`;
|
||||
updateHeader(meta);
|
||||
await runAndRender(meta);
|
||||
if (meta.user_view_id) {
|
||||
fireAndForget(`/api/user-views/${meta.user_view_id}/touch`, "POST");
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveMeta(slug: string): Promise<ViewMeta | null> {
|
||||
// Try the system view list first — cheap, code-resident.
|
||||
try {
|
||||
const r = await fetch("/api/views/system", { credentials: "include" });
|
||||
if (r.ok) {
|
||||
const list = (await r.json()) as Array<{ Slug: string; Name: string; Filter: FilterSpec; Render: RenderSpec }>;
|
||||
const sys = list.find((sv) => sv.Slug === slug);
|
||||
if (sys) {
|
||||
return { name: sys.Name, slug: sys.Slug, filter: sys.Filter, render: sys.Render, is_system: true };
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
// fall through to user lookup
|
||||
}
|
||||
// Try a saved user view.
|
||||
try {
|
||||
const r = await fetch("/api/user-views", { credentials: "include" });
|
||||
if (r.ok) {
|
||||
const list = (await r.json()) as UserView[];
|
||||
const v = list.find((uv) => uv.slug === slug);
|
||||
if (v) {
|
||||
return {
|
||||
user_view_id: v.id,
|
||||
name: v.name,
|
||||
slug: v.slug,
|
||||
filter: v.filter_spec,
|
||||
render: v.render_spec,
|
||||
is_system: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (_e) { /* noop */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
async function runAndRender(meta: ViewMeta): Promise<void> {
|
||||
const loading = document.getElementById("views-loading");
|
||||
const empty = document.getElementById("views-empty");
|
||||
const errorEl = document.getElementById("views-error");
|
||||
const toolbar = document.getElementById("views-toolbar");
|
||||
if (loading) loading.hidden = false;
|
||||
if (empty) empty.hidden = true;
|
||||
if (errorEl) errorEl.hidden = true;
|
||||
if (toolbar) toolbar.hidden = false;
|
||||
|
||||
let result: ViewRunResult;
|
||||
try {
|
||||
const r = await fetch(`/api/views/${encodeURIComponent(meta.slug)}/run`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!r.ok) {
|
||||
showError(`${r.status}: ${r.statusText}`);
|
||||
return;
|
||||
}
|
||||
result = (await r.json()) as ViewRunResult;
|
||||
} catch (e) {
|
||||
showError(t("views.error.network"));
|
||||
return;
|
||||
}
|
||||
if (loading) loading.hidden = true;
|
||||
|
||||
currentRows = result;
|
||||
if (result.inaccessible_project_ids && result.inaccessible_project_ids.length > 0) {
|
||||
showInaccessibleToast(result.inaccessible_project_ids.length);
|
||||
}
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
if (empty) {
|
||||
empty.hidden = false;
|
||||
const hint = document.getElementById("views-empty-hint");
|
||||
if (hint) hint.textContent = filterSummary(meta.filter);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveShape(meta.render.shape);
|
||||
renderShape(meta.render.shape, meta.render, result.rows);
|
||||
}
|
||||
|
||||
function setActiveShape(shape: RenderShape): void {
|
||||
for (const host of ["views-shape-list", "views-shape-cards", "views-shape-calendar"]) {
|
||||
const el = document.getElementById(host);
|
||||
if (el) el.hidden = !host.endsWith("-" + shape);
|
||||
}
|
||||
document.querySelectorAll<HTMLButtonElement>("#views-shape-chips [data-shape]").forEach((btn) => {
|
||||
btn.classList.toggle("active", btn.dataset.shape === shape);
|
||||
});
|
||||
}
|
||||
|
||||
function renderShape(shape: RenderShape, render: RenderSpec, rows: ViewRunResult["rows"]): void {
|
||||
const host = document.getElementById(`views-shape-${shape}`);
|
||||
if (!host) return;
|
||||
switch (shape) {
|
||||
case "list":
|
||||
renderListShape(host, rows, render);
|
||||
break;
|
||||
case "cards":
|
||||
renderCardsShape(host, rows, render);
|
||||
break;
|
||||
case "calendar":
|
||||
renderCalendarShape(host, rows, render);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function bindShapeChips(): void {
|
||||
document.querySelectorAll<HTMLButtonElement>("#views-shape-chips [data-shape]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const shape = (btn.dataset.shape ?? "list") as RenderShape;
|
||||
if (!currentMeta || !currentRows) return;
|
||||
// Override the shape transiently — doesn't mutate the saved spec.
|
||||
const overrideRender = { ...currentMeta.render, shape };
|
||||
setActiveShape(shape);
|
||||
renderShape(shape, overrideRender, currentRows.rows);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateHeader(meta: ViewMeta): void {
|
||||
const heading = document.getElementById("views-heading");
|
||||
if (heading) heading.textContent = meta.name;
|
||||
const subtitle = document.getElementById("views-subtitle");
|
||||
if (subtitle) subtitle.textContent = filterSummary(meta.filter);
|
||||
const actions = document.getElementById("views-header-actions");
|
||||
if (actions) {
|
||||
actions.innerHTML = "";
|
||||
if (!meta.is_system && meta.user_view_id) {
|
||||
const editLink = document.createElement("a");
|
||||
editLink.href = `/views/${encodeURIComponent(meta.slug)}/edit`;
|
||||
editLink.className = "btn-secondary btn-small";
|
||||
editLink.textContent = t("views.action.edit");
|
||||
actions.appendChild(editLink);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function filterSummary(filter: FilterSpec): string {
|
||||
const parts: string[] = [];
|
||||
// Sources
|
||||
parts.push(filter.sources.map((s) => t(("views.source." + s) as I18nKey)).join(" + "));
|
||||
// Time
|
||||
parts.push(t(("views.horizon." + filter.time.horizon) as I18nKey));
|
||||
// Scope
|
||||
if (filter.scope.personal_only) {
|
||||
parts.push(t("views.scope.personal_only"));
|
||||
} else if (filter.scope.projects.mode !== "all_visible") {
|
||||
parts.push(t(("views.scope." + filter.scope.projects.mode) as I18nKey));
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function showError(message: string): void {
|
||||
const loading = document.getElementById("views-loading");
|
||||
const errorEl = document.getElementById("views-error");
|
||||
const msg = document.getElementById("views-error-message");
|
||||
if (loading) loading.hidden = true;
|
||||
if (errorEl) errorEl.hidden = false;
|
||||
if (msg) msg.textContent = message;
|
||||
}
|
||||
|
||||
function showInaccessibleToast(count: number): void {
|
||||
const toast = document.getElementById("views-toast");
|
||||
const text = document.getElementById("views-toast-text");
|
||||
if (!toast || !text) return;
|
||||
text.textContent = count === 1
|
||||
? t("views.toast.inaccessible_one")
|
||||
: t("views.toast.inaccessible_n").replace("{n}", String(count));
|
||||
toast.hidden = false;
|
||||
}
|
||||
|
||||
function bindToastClose(): void {
|
||||
const close = document.getElementById("views-toast-close");
|
||||
const toast = document.getElementById("views-toast");
|
||||
if (!close || !toast) return;
|
||||
close.addEventListener("click", () => { toast.hidden = true; });
|
||||
}
|
||||
|
||||
function pathSlug(): string | null {
|
||||
const m = window.location.pathname.match(/^\/views\/([^\/]+)$/);
|
||||
if (!m) return null;
|
||||
return decodeURIComponent(m[1]);
|
||||
}
|
||||
|
||||
function fireAndForget(url: string, method: string): void {
|
||||
fetch(url, { method, credentials: "include" }).catch(() => { /* noop */ });
|
||||
}
|
||||
129
frontend/src/client/views/shape-calendar.ts
Normal file
129
frontend/src/client/views/shape-calendar.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { t, type I18nKey, getLang } from "../i18n";
|
||||
import type { RenderSpec, ViewRow } from "./types";
|
||||
|
||||
// shape-calendar: month grid. Toggleable to week-view via per-shape
|
||||
// config. Mirrors the look of /events?view=calendar but generic across
|
||||
// sources.
|
||||
|
||||
export function renderCalendarShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
|
||||
host.innerHTML = "";
|
||||
const cfg = render.calendar ?? {};
|
||||
const view = cfg.default_view ?? "month";
|
||||
|
||||
// Mobile fallback: viewport <600px collapses to cards (cleaner on narrow
|
||||
// screens). Documented in design §9 trade-off 8.
|
||||
if (window.innerWidth < 600) {
|
||||
const notice = document.createElement("p");
|
||||
notice.className = "views-calendar-mobile-notice";
|
||||
notice.textContent = t("views.calendar.mobile_fallback");
|
||||
host.appendChild(notice);
|
||||
}
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = `views-calendar views-calendar--${view}`;
|
||||
|
||||
const monthRef = pickMonthAnchor(rows);
|
||||
wrap.appendChild(renderMonth(monthRef, rows));
|
||||
host.appendChild(wrap);
|
||||
}
|
||||
|
||||
function renderMonth(anchor: Date, rows: ViewRow[]): HTMLElement {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "views-calendar-month";
|
||||
|
||||
const header = document.createElement("h2");
|
||||
header.className = "views-calendar-month-label";
|
||||
header.textContent = anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
|
||||
wrap.appendChild(header);
|
||||
|
||||
// Weekday headers (Mon-Sun, ISO week).
|
||||
const weekdayBar = document.createElement("div");
|
||||
weekdayBar.className = "views-calendar-weekdays";
|
||||
const weekdayKeys: I18nKey[] = ["cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu", "cal.day.fri", "cal.day.sat", "cal.day.sun"];
|
||||
for (const k of weekdayKeys) {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "views-calendar-weekday";
|
||||
cell.textContent = t(k);
|
||||
weekdayBar.appendChild(cell);
|
||||
}
|
||||
wrap.appendChild(weekdayBar);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "views-calendar-grid";
|
||||
|
||||
const monthStart = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
|
||||
const startWeekday = (monthStart.getDay() + 6) % 7; // Mon=0
|
||||
const daysInMonth = new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0).getDate();
|
||||
|
||||
// Pad start with prev-month spillover.
|
||||
for (let i = 0; i < startWeekday; i++) {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "views-calendar-cell views-calendar-cell--out";
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
|
||||
// Bucket rows by ISO date (yyyy-mm-dd).
|
||||
const byDate = new Map<string, ViewRow[]>();
|
||||
for (const row of rows) {
|
||||
const d = new Date(row.event_date);
|
||||
if (isNaN(d.getTime())) continue;
|
||||
if (d.getMonth() !== anchor.getMonth() || d.getFullYear() !== anchor.getFullYear()) continue;
|
||||
const key = isoDate(d);
|
||||
const arr = byDate.get(key);
|
||||
if (arr) arr.push(row);
|
||||
else byDate.set(key, [row]);
|
||||
}
|
||||
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "views-calendar-cell";
|
||||
const dayLabel = document.createElement("div");
|
||||
dayLabel.className = "views-calendar-cell-day";
|
||||
dayLabel.textContent = String(day);
|
||||
cell.appendChild(dayLabel);
|
||||
|
||||
const dateKey = isoDate(new Date(anchor.getFullYear(), anchor.getMonth(), day));
|
||||
const dayRows = byDate.get(dateKey) ?? [];
|
||||
if (dayRows.length > 0) {
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "views-calendar-pills";
|
||||
const visible = dayRows.slice(0, 3);
|
||||
for (const row of visible) {
|
||||
const li = document.createElement("li");
|
||||
li.className = `views-calendar-pill views-calendar-pill--${row.kind}`;
|
||||
li.textContent = row.title;
|
||||
li.title = row.title + (row.project_title ? ` — ${row.project_title}` : "");
|
||||
ul.appendChild(li);
|
||||
}
|
||||
if (dayRows.length > visible.length) {
|
||||
const more = document.createElement("li");
|
||||
more.className = "views-calendar-pill views-calendar-pill--more";
|
||||
more.textContent = `+${dayRows.length - visible.length}`;
|
||||
ul.appendChild(more);
|
||||
}
|
||||
cell.appendChild(ul);
|
||||
}
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
|
||||
wrap.appendChild(grid);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function pickMonthAnchor(rows: ViewRow[]): Date {
|
||||
// Anchor on the first row's month, or "this month" if empty.
|
||||
for (const row of rows) {
|
||||
const d = new Date(row.event_date);
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
}
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
}
|
||||
|
||||
function isoDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
118
frontend/src/client/views/shape-cards.ts
Normal file
118
frontend/src/client/views/shape-cards.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { t, type I18nKey, getLang } from "../i18n";
|
||||
import type { RenderSpec, ViewRow } from "./types";
|
||||
|
||||
// shape-cards: day-grouped chronological cards. Same layout style as the
|
||||
// existing /agenda timeline; works for any source mix.
|
||||
|
||||
export function renderCardsShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
|
||||
host.innerHTML = "";
|
||||
const cfg = render.cards ?? {};
|
||||
const groupBy = cfg.group_by ?? "day";
|
||||
const sort = cfg.sort ?? "date_asc";
|
||||
|
||||
const sorted = [...rows].sort((a, b) => {
|
||||
const aT = Date.parse(a.event_date);
|
||||
const bT = Date.parse(b.event_date);
|
||||
return sort === "date_asc" ? aT - bT : bT - aT;
|
||||
});
|
||||
|
||||
if (groupBy === "none") {
|
||||
host.appendChild(renderCardList(sorted));
|
||||
return;
|
||||
}
|
||||
|
||||
const groups = groupRows(sorted, groupBy);
|
||||
for (const [key, items] of groups) {
|
||||
const section = document.createElement("section");
|
||||
section.className = "views-cards-day";
|
||||
const heading = document.createElement("h2");
|
||||
heading.className = "views-cards-day-heading";
|
||||
heading.textContent = key;
|
||||
section.appendChild(heading);
|
||||
section.appendChild(renderCardList(items));
|
||||
host.appendChild(section);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCardList(rows: ViewRow[]): HTMLElement {
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "views-cards-list";
|
||||
for (const row of rows) {
|
||||
const li = document.createElement("li");
|
||||
li.className = `views-card views-card--${row.kind}`;
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "views-card-head";
|
||||
const kind = document.createElement("span");
|
||||
kind.className = "views-card-kind";
|
||||
kind.textContent = t(("views.kind." + row.kind) as I18nKey);
|
||||
head.appendChild(kind);
|
||||
const title = document.createElement("h3");
|
||||
title.className = "views-card-title";
|
||||
title.textContent = row.title;
|
||||
head.appendChild(title);
|
||||
li.appendChild(head);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "views-card-meta";
|
||||
const time = document.createElement("span");
|
||||
time.textContent = formatTime(row.event_date);
|
||||
meta.appendChild(time);
|
||||
if (row.project_title) {
|
||||
const proj = document.createElement("span");
|
||||
proj.className = "views-card-project";
|
||||
proj.textContent = row.project_title;
|
||||
meta.appendChild(proj);
|
||||
}
|
||||
if (row.actor_name) {
|
||||
const actor = document.createElement("span");
|
||||
actor.className = "views-card-actor";
|
||||
actor.textContent = row.actor_name;
|
||||
meta.appendChild(actor);
|
||||
}
|
||||
li.appendChild(meta);
|
||||
|
||||
if (row.subtitle) {
|
||||
const sub = document.createElement("p");
|
||||
sub.className = "views-card-subtitle";
|
||||
sub.textContent = row.subtitle;
|
||||
li.appendChild(sub);
|
||||
}
|
||||
ul.appendChild(li);
|
||||
}
|
||||
return ul;
|
||||
}
|
||||
|
||||
function groupRows(rows: ViewRow[], groupBy: "day" | "week"): Array<[string, ViewRow[]]> {
|
||||
const map = new Map<string, ViewRow[]>();
|
||||
for (const row of rows) {
|
||||
const key = bucketKey(row.event_date, groupBy);
|
||||
const arr = map.get(key);
|
||||
if (arr) arr.push(row);
|
||||
else map.set(key, [row]);
|
||||
}
|
||||
return Array.from(map.entries());
|
||||
}
|
||||
|
||||
function bucketKey(iso: string, groupBy: "day" | "week"): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
if (groupBy === "week") {
|
||||
// Round down to Monday, format as "KW NN, YYYY".
|
||||
const monday = new Date(d);
|
||||
const day = monday.getDay() || 7; // Sunday=0 → 7
|
||||
monday.setDate(monday.getDate() - day + 1);
|
||||
const yearStart = new Date(Date.UTC(monday.getFullYear(), 0, 1));
|
||||
const weekNo = Math.ceil(((monday.getTime() - yearStart.getTime()) / 86400000 + yearStart.getDay() + 1) / 7);
|
||||
return `KW ${weekNo}, ${monday.getFullYear()}`;
|
||||
}
|
||||
return d.toLocaleDateString(lang, { weekday: "long", year: "numeric", month: "long", day: "numeric" });
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
return d.toLocaleTimeString(lang, { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
181
frontend/src/client/views/shape-list.ts
Normal file
181
frontend/src/client/views/shape-list.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { t, type I18nKey, getLang } from "../i18n";
|
||||
import type { RenderSpec, ViewRow } from "./types";
|
||||
|
||||
// shape-list: renders ViewRows as a table (density=comfortable) or a
|
||||
// compact one-line stream (density=compact). The "activity feed" look
|
||||
// is just density=compact + actor/time columns — see Q4 lock-in
|
||||
// 2026-05-07 (3 shapes; no separate "activity").
|
||||
|
||||
export function renderListShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
|
||||
host.innerHTML = "";
|
||||
const list = render.list ?? {};
|
||||
const density = list.density ?? "comfortable";
|
||||
const sort = list.sort ?? "date_asc";
|
||||
|
||||
const sorted = [...rows].sort((a, b) => {
|
||||
const aT = Date.parse(a.event_date);
|
||||
const bT = Date.parse(b.event_date);
|
||||
return sort === "date_asc" ? aT - bT : bT - aT;
|
||||
});
|
||||
|
||||
if (density === "compact") {
|
||||
host.appendChild(renderCompact(sorted));
|
||||
} else {
|
||||
host.appendChild(renderTable(sorted, list.columns ?? defaultColumns(rows)));
|
||||
}
|
||||
}
|
||||
|
||||
function renderCompact(rows: ViewRow[]): HTMLElement {
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "views-list views-list--compact";
|
||||
for (const row of rows) {
|
||||
const li = document.createElement("li");
|
||||
li.className = `views-list-row views-list-row--${row.kind}`;
|
||||
|
||||
const time = document.createElement("span");
|
||||
time.className = "views-list-time";
|
||||
time.textContent = formatRelative(row.event_date);
|
||||
li.appendChild(time);
|
||||
|
||||
const kindIcon = document.createElement("span");
|
||||
kindIcon.className = "views-list-kind";
|
||||
kindIcon.textContent = kindLabel(row.kind);
|
||||
li.appendChild(kindIcon);
|
||||
|
||||
const title = document.createElement("span");
|
||||
title.className = "views-list-title";
|
||||
title.textContent = row.title;
|
||||
li.appendChild(title);
|
||||
|
||||
if (row.project_title) {
|
||||
const proj = document.createElement("span");
|
||||
proj.className = "views-list-project";
|
||||
proj.textContent = row.project_title;
|
||||
li.appendChild(proj);
|
||||
}
|
||||
|
||||
if (row.actor_name) {
|
||||
const actor = document.createElement("span");
|
||||
actor.className = "views-list-actor";
|
||||
actor.textContent = row.actor_name;
|
||||
li.appendChild(actor);
|
||||
}
|
||||
|
||||
if (row.subtitle) {
|
||||
const sub = document.createElement("span");
|
||||
sub.className = "views-list-subtitle";
|
||||
sub.textContent = row.subtitle;
|
||||
li.appendChild(sub);
|
||||
}
|
||||
ul.appendChild(li);
|
||||
}
|
||||
return ul;
|
||||
}
|
||||
|
||||
function renderTable(rows: ViewRow[], columns: string[]): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "entity-table-wrap";
|
||||
const table = document.createElement("table");
|
||||
table.className = "entity-table views-list views-list--table entity-table--readonly";
|
||||
const thead = document.createElement("thead");
|
||||
const trHead = document.createElement("tr");
|
||||
for (const col of columns) {
|
||||
const th = document.createElement("th");
|
||||
th.textContent = t(("views.col." + col) as I18nKey);
|
||||
trHead.appendChild(th);
|
||||
}
|
||||
thead.appendChild(trHead);
|
||||
table.appendChild(thead);
|
||||
|
||||
const tbody = document.createElement("tbody");
|
||||
for (const row of rows) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = `views-table-row views-table-row--${row.kind}`;
|
||||
for (const col of columns) {
|
||||
const td = document.createElement("td");
|
||||
td.textContent = formatColumn(row, col);
|
||||
tr.appendChild(td);
|
||||
}
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
table.appendChild(tbody);
|
||||
wrap.appendChild(table);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function defaultColumns(rows: ViewRow[]): string[] {
|
||||
// Pick a sensible default column set from the kinds present in the
|
||||
// result. Keeps the UI honest when a user lands on a saved view that
|
||||
// has no explicit list.columns.
|
||||
const kinds = new Set(rows.map((r) => r.kind));
|
||||
if (kinds.has("project_event") || kinds.has("approval_request")) {
|
||||
return ["time", "actor", "title", "project"];
|
||||
}
|
||||
if (kinds.has("appointment")) {
|
||||
return ["date", "title", "project", "location"];
|
||||
}
|
||||
return ["date", "title", "project", "status"];
|
||||
}
|
||||
|
||||
function formatColumn(row: ViewRow, col: string): string {
|
||||
switch (col) {
|
||||
case "date":
|
||||
return formatDate(row.event_date);
|
||||
case "time":
|
||||
return formatRelative(row.event_date);
|
||||
case "title":
|
||||
return row.title;
|
||||
case "project":
|
||||
return row.project_title ?? "—";
|
||||
case "actor":
|
||||
return row.actor_name ?? "—";
|
||||
case "status": {
|
||||
const s = (row.detail.status as string | undefined) ?? "";
|
||||
return s ? t(("deadlines.status." + s) as I18nKey) : "—";
|
||||
}
|
||||
case "rule":
|
||||
return (row.detail.rule_code as string | undefined) ?? "—";
|
||||
case "event_type":
|
||||
return (row.detail.event_type as string | undefined) ?? "—";
|
||||
case "location":
|
||||
return (row.detail.location as string | undefined) ?? "—";
|
||||
case "appointment_type":
|
||||
return (row.detail.appointment_type as string | undefined) ?? "—";
|
||||
case "approval_status":
|
||||
return (row.detail.approval_status as string | undefined) ?? "—";
|
||||
case "decided_by":
|
||||
return (row.detail.decider_name as string | undefined) ?? "—";
|
||||
case "kind":
|
||||
return kindLabel(row.kind);
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function kindLabel(kind: string): string {
|
||||
return t(("views.kind." + kind) as I18nKey);
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
year: "numeric", month: "2-digit", day: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
const t0 = Date.parse(iso);
|
||||
if (isNaN(t0)) return iso;
|
||||
const diffMs = t0 - Date.now();
|
||||
const past = diffMs < 0;
|
||||
const sec = Math.abs(Math.floor(diffMs / 1000));
|
||||
const lang = getLang();
|
||||
if (sec < 60) return past ? (lang === "de" ? `vor ${sec}s` : `${sec}s ago`) : (lang === "de" ? `in ${sec}s` : `in ${sec}s`);
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return past ? (lang === "de" ? `vor ${min}m` : `${min}m ago`) : (lang === "de" ? `in ${min}m` : `in ${min}m`);
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return past ? (lang === "de" ? `vor ${hr}h` : `${hr}h ago`) : (lang === "de" ? `in ${hr}h` : `in ${hr}h`);
|
||||
const day = Math.floor(hr / 24);
|
||||
return past ? (lang === "de" ? `vor ${day}d` : `${day}d ago`) : (lang === "de" ? `in ${day}d` : `in ${day}d`);
|
||||
}
|
||||
159
frontend/src/client/views/types.ts
Normal file
159
frontend/src/client/views/types.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
// Shared TypeScript types for the Custom Views frontend.
|
||||
//
|
||||
// These mirror the Go shapes in internal/services/{filter_spec,
|
||||
// render_spec,view_service,user_view_service}.go. Keep field names + enum
|
||||
// values in sync — the substrate's validator will reject anything else.
|
||||
|
||||
export type DataSource = "deadline" | "appointment" | "project_event" | "approval_request";
|
||||
|
||||
export type ScopeMode = "all_visible" | "my_subtree" | "explicit";
|
||||
|
||||
export interface ScopeProjects {
|
||||
mode: ScopeMode;
|
||||
ids?: string[];
|
||||
}
|
||||
|
||||
export interface ScopeSpec {
|
||||
projects: ScopeProjects;
|
||||
personal_only?: boolean;
|
||||
}
|
||||
|
||||
export type TimeHorizon =
|
||||
| "next_7d" | "next_30d" | "next_90d"
|
||||
| "past_30d" | "past_90d"
|
||||
| "any" | "all" | "custom";
|
||||
|
||||
export type TimeField = "auto" | "created_at";
|
||||
|
||||
export interface TimeSpec {
|
||||
horizon: TimeHorizon;
|
||||
field?: TimeField;
|
||||
from?: string; // ISO 8601
|
||||
to?: string;
|
||||
}
|
||||
|
||||
export interface DeadlinePredicates {
|
||||
status?: string[];
|
||||
approval_status?: string[];
|
||||
event_types?: string[];
|
||||
include_untyped?: boolean;
|
||||
}
|
||||
|
||||
export interface AppointmentPredicates {
|
||||
approval_status?: string[];
|
||||
appointment_types?: string[];
|
||||
}
|
||||
|
||||
export interface ProjectEventPredicates {
|
||||
event_types?: string[];
|
||||
}
|
||||
|
||||
export interface ApprovalRequestPredicates {
|
||||
viewer_role?: "approver_eligible" | "self_requested" | "any_visible";
|
||||
status?: string[];
|
||||
entity_types?: string[];
|
||||
}
|
||||
|
||||
export interface Predicates {
|
||||
deadline?: DeadlinePredicates;
|
||||
appointment?: AppointmentPredicates;
|
||||
project_event?: ProjectEventPredicates;
|
||||
approval_request?: ApprovalRequestPredicates;
|
||||
}
|
||||
|
||||
export interface FilterSpec {
|
||||
version: number;
|
||||
sources: DataSource[];
|
||||
scope: ScopeSpec;
|
||||
time: TimeSpec;
|
||||
predicates?: Partial<Record<DataSource, Predicates>>;
|
||||
}
|
||||
|
||||
export type RenderShape = "list" | "cards" | "calendar";
|
||||
|
||||
export interface ListConfig {
|
||||
columns?: string[];
|
||||
sort?: "date_asc" | "date_desc";
|
||||
density?: "comfortable" | "compact";
|
||||
}
|
||||
|
||||
export interface CardsConfig {
|
||||
group_by?: "day" | "week" | "none";
|
||||
sort?: "date_asc" | "date_desc";
|
||||
show_empty_days?: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarConfig {
|
||||
default_view?: "month" | "week";
|
||||
show_weekends?: boolean;
|
||||
}
|
||||
|
||||
export interface RenderSpec {
|
||||
shape: RenderShape;
|
||||
list?: ListConfig;
|
||||
cards?: CardsConfig;
|
||||
calendar?: CalendarConfig;
|
||||
}
|
||||
|
||||
// ViewRow — the discriminated row shape from ViewService.RunSpec.
|
||||
export interface ViewRow {
|
||||
kind: DataSource;
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
event_date: string;
|
||||
project_id?: string;
|
||||
project_title?: string;
|
||||
project_reference?: string;
|
||||
project_type?: string;
|
||||
actor_id?: string;
|
||||
actor_name?: string;
|
||||
detail: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ViewRunResult {
|
||||
rows: ViewRow[];
|
||||
inaccessible_project_ids?: string[];
|
||||
}
|
||||
|
||||
// UserView — the persisted shape from /api/user-views.
|
||||
export interface UserView {
|
||||
id: string;
|
||||
user_id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
filter_spec: FilterSpec;
|
||||
render_spec: RenderSpec;
|
||||
sort_order: number;
|
||||
show_count: boolean;
|
||||
last_used_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// SystemView — code-resident definition from /api/views/system.
|
||||
export interface SystemView {
|
||||
Slug: string;
|
||||
Name: string;
|
||||
Filter: FilterSpec;
|
||||
Render: RenderSpec;
|
||||
}
|
||||
|
||||
export const SPEC_VERSION = 1;
|
||||
|
||||
export function defaultFilterSpec(): FilterSpec {
|
||||
return {
|
||||
version: SPEC_VERSION,
|
||||
sources: ["deadline", "appointment"],
|
||||
scope: { projects: { mode: "all_visible" } },
|
||||
time: { horizon: "next_30d", field: "auto" },
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultRenderSpec(): RenderSpec {
|
||||
return {
|
||||
shape: "list",
|
||||
list: { sort: "date_asc", density: "comfortable" },
|
||||
};
|
||||
}
|
||||
@@ -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,15 @@ 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") +
|
||||
// Paliadin entry \u2014 owner-only, hidden by default. sidebar.ts
|
||||
// reveals it after /api/me confirms the caller is the
|
||||
// Paliadin owner (t-paliad-146 PoC scope). Same fail-closed
|
||||
// pattern as the admin group below.
|
||||
`<a href="/paliadin" class="sidebar-item sidebar-paliadin${currentPath === "/paliadin" ? " active" : ""}" id="sidebar-paliadin-link" style="display:none">` +
|
||||
`<span class="sidebar-icon">${ICON_SPARKLE}</span>` +
|
||||
`<span class="sidebar-label" data-i18n="nav.paliadin">Paliadin</span>` +
|
||||
`</a>` +
|
||||
navItem("/team", ICON_USERS, "nav.team", "Team", currentPath),
|
||||
)}
|
||||
|
||||
@@ -121,6 +133,19 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
navItem("/events?type=appointment", ICON_CALENDAR, "nav.termine", "Termine", currentPath),
|
||||
)}
|
||||
|
||||
{/* t-paliad-144 Phase A2 — Meine Sichten group. Hydrated by
|
||||
client/sidebar.ts from /api/user-views on mount. The
|
||||
"+ Neue Sicht" entry is always present so first-time
|
||||
users have an obvious way in. */}
|
||||
<div className="sidebar-group sidebar-views-group" id="sidebar-views-group">
|
||||
<div className="sidebar-group-label" data-i18n="nav.group.user_views">Meine Sichten</div>
|
||||
<div className="sidebar-views-items" id="sidebar-views-items" />
|
||||
<a href="/views/new" className="sidebar-item sidebar-views-new">
|
||||
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_FOLDER }} />
|
||||
<span className="sidebar-label" data-i18n="nav.user_views.new">Neue Sicht</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{group("nav.group.werkzeuge", "Werkzeuge",
|
||||
navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath) +
|
||||
navItem("/tools/fristenrechner", ICON_CLOCK, "nav.fristenrechner", "Fristenrechner", currentPath) +
|
||||
@@ -154,6 +179,13 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
{navItem("/admin/partner-units", ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)}
|
||||
{navItem("/admin/event-types", ICON_TABLE, "nav.admin.event_types", "Event-Typen", currentPath)}
|
||||
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
|
||||
{/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */}
|
||||
<a href="/admin/paliadin" id="sidebar-admin-paliadin-link"
|
||||
className={`sidebar-item${currentPath === "/admin/paliadin" ? " active" : ""}`}
|
||||
style="display:none">
|
||||
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_SPARKLE }} />
|
||||
<span className="sidebar-label" data-i18n="nav.admin.paliadin">Paliadin Monitor</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ export function renderDeadlinesDetail(): string {
|
||||
<span id="deadline-due-chip" className="frist-due-chip" />
|
||||
<span id="deadline-status-chip" className="entity-status-chip" />
|
||||
<a id="deadline-project-link" className="entity-ref" href="#" />
|
||||
<select id="deadline-project-edit" className="entity-ref-select" style="display:none" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="entity-detail-actions">
|
||||
|
||||
@@ -46,8 +46,23 @@ export type I18nKey =
|
||||
| "admin.audit.source.reminder_log"
|
||||
| "admin.audit.subtitle"
|
||||
| "admin.audit.title"
|
||||
| "admin.broadcasts.col.count"
|
||||
| "admin.broadcasts.col.sender"
|
||||
| "admin.broadcasts.col.sent_at"
|
||||
| "admin.broadcasts.col.subject"
|
||||
| "admin.broadcasts.detail.delivered"
|
||||
| "admin.broadcasts.detail.failed"
|
||||
| "admin.broadcasts.detail.recipients"
|
||||
| "admin.broadcasts.detail.sent_by"
|
||||
| "admin.broadcasts.empty"
|
||||
| "admin.broadcasts.heading"
|
||||
| "admin.broadcasts.loading"
|
||||
| "admin.broadcasts.subtitle"
|
||||
| "admin.broadcasts.title"
|
||||
| "admin.card.audit.desc"
|
||||
| "admin.card.audit.title"
|
||||
| "admin.card.broadcasts.desc"
|
||||
| "admin.card.broadcasts.title"
|
||||
| "admin.card.email_templates.desc"
|
||||
| "admin.card.email_templates.title"
|
||||
| "admin.card.event_types.desc"
|
||||
@@ -150,6 +165,25 @@ export type I18nKey =
|
||||
| "admin.event_types.subtitle"
|
||||
| "admin.event_types.title"
|
||||
| "admin.heading"
|
||||
| "admin.paliadin.abandon_rate"
|
||||
| "admin.paliadin.classifier_heading"
|
||||
| "admin.paliadin.col.classifier"
|
||||
| "admin.paliadin.col.count"
|
||||
| "admin.paliadin.col.duration"
|
||||
| "admin.paliadin.col.prompt"
|
||||
| "admin.paliadin.col.started"
|
||||
| "admin.paliadin.col.tools"
|
||||
| "admin.paliadin.daily_heading"
|
||||
| "admin.paliadin.heading"
|
||||
| "admin.paliadin.last7"
|
||||
| "admin.paliadin.loading"
|
||||
| "admin.paliadin.median_dur"
|
||||
| "admin.paliadin.recent_heading"
|
||||
| "admin.paliadin.subtitle"
|
||||
| "admin.paliadin.title"
|
||||
| "admin.paliadin.tool_rate"
|
||||
| "admin.paliadin.top_heading"
|
||||
| "admin.paliadin.total"
|
||||
| "admin.partner_units.action.delete"
|
||||
| "admin.partner_units.action.edit"
|
||||
| "admin.partner_units.action.members"
|
||||
@@ -167,6 +201,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 +212,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"
|
||||
@@ -195,6 +231,9 @@ export type I18nKey =
|
||||
| "admin.team.col.name"
|
||||
| "admin.team.col.office"
|
||||
| "admin.team.col.permission"
|
||||
| "admin.team.col.profession"
|
||||
| "admin.team.col.profession.none"
|
||||
| "admin.team.col.profession.none.hint"
|
||||
| "admin.team.confirm.delete"
|
||||
| "admin.team.direct_add.body"
|
||||
| "admin.team.direct_add.cancel"
|
||||
@@ -254,6 +293,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 +360,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"
|
||||
@@ -456,10 +549,19 @@ export type I18nKey =
|
||||
| "checklisten.tab.templates"
|
||||
| "checklisten.title"
|
||||
| "common.cancel"
|
||||
| "common.close"
|
||||
| "common.forbidden"
|
||||
| "common.load_error"
|
||||
| "common.loading"
|
||||
| "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"
|
||||
| "dashboard.action.short.appointment_updated"
|
||||
| "dashboard.action.short.checklist_created"
|
||||
| "dashboard.action.short.checklist_deleted"
|
||||
@@ -474,9 +576,14 @@ 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"
|
||||
| "dashboard.action.short.deadline_project_changed"
|
||||
| "dashboard.action.short.deadline_reopened"
|
||||
| "dashboard.action.short.deadline_updated"
|
||||
| "dashboard.action.short.deadlines_imported"
|
||||
@@ -845,12 +952,22 @@ 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"
|
||||
| "event.description.deadline_project_changed"
|
||||
| "event.description.deadline_reopened"
|
||||
| "event.description.deadline_updated"
|
||||
| "event.description.deadlines_imported"
|
||||
@@ -858,8 +975,13 @@ 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"
|
||||
| "event.title.appointment_updated"
|
||||
| "event.title.checklist_created"
|
||||
| "event.title.checklist_deleted"
|
||||
@@ -867,9 +989,14 @@ 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"
|
||||
| "event.title.deadline_project_changed"
|
||||
| "event.title.deadline_reopened"
|
||||
| "event.title.deadline_updated"
|
||||
| "event.title.deadlines_imported"
|
||||
@@ -1189,6 +1316,7 @@ export type I18nKey =
|
||||
| "nav.admin.audit"
|
||||
| "nav.admin.bereich"
|
||||
| "nav.admin.event_types"
|
||||
| "nav.admin.paliadin"
|
||||
| "nav.admin.partner_units"
|
||||
| "nav.admin.team"
|
||||
| "nav.agenda"
|
||||
@@ -1208,17 +1336,21 @@ export type I18nKey =
|
||||
| "nav.group.einstellungen"
|
||||
| "nav.group.ressourcen"
|
||||
| "nav.group.uebersicht"
|
||||
| "nav.group.user_views"
|
||||
| "nav.group.werkzeuge"
|
||||
| "nav.group.wissen"
|
||||
| "nav.home"
|
||||
| "nav.inbox"
|
||||
| "nav.kostenrechner"
|
||||
| "nav.links"
|
||||
| "nav.logout"
|
||||
| "nav.neuigkeiten"
|
||||
| "nav.paliadin"
|
||||
| "nav.projekte"
|
||||
| "nav.soon.tooltip"
|
||||
| "nav.team"
|
||||
| "nav.termine"
|
||||
| "nav.user_views.new"
|
||||
| "notes.cancel"
|
||||
| "notes.delete"
|
||||
| "notes.delete.confirm"
|
||||
@@ -1261,6 +1393,8 @@ export type I18nKey =
|
||||
| "onboarding.optional"
|
||||
| "onboarding.partner_unit"
|
||||
| "onboarding.partner_unit.unassigned"
|
||||
| "onboarding.profession"
|
||||
| "onboarding.profession.hint"
|
||||
| "onboarding.submit"
|
||||
| "onboarding.title"
|
||||
| "palette.action.app.invite"
|
||||
@@ -1286,11 +1420,90 @@ export type I18nKey =
|
||||
| "palette.footer.navigate"
|
||||
| "palette.footer.open"
|
||||
| "palette.section.actions"
|
||||
| "paliadin.empty"
|
||||
| "paliadin.error.connection_lost"
|
||||
| "paliadin.error.local_only"
|
||||
| "paliadin.error.upstream"
|
||||
| "paliadin.heading"
|
||||
| "paliadin.input.placeholder"
|
||||
| "paliadin.reset"
|
||||
| "paliadin.send"
|
||||
| "paliadin.starter.concept"
|
||||
| "paliadin.starter.today"
|
||||
| "paliadin.starter.week"
|
||||
| "paliadin.stop"
|
||||
| "paliadin.tagline"
|
||||
| "paliadin.title"
|
||||
| "partner_unit.heading"
|
||||
| "partner_unit.members_label"
|
||||
| "partner_unit.none"
|
||||
| "partner_unit.subtitle"
|
||||
| "projects.cancel"
|
||||
| "projects.cards.deadline_open"
|
||||
| "projects.cards.deadline_overdue"
|
||||
| "projects.cards.empty"
|
||||
| "projects.cards.event.kind.appointment"
|
||||
| "projects.cards.event.kind.deadline"
|
||||
| "projects.cards.event.kind.project_event"
|
||||
| "projects.cards.layout.delete"
|
||||
| "projects.cards.layout.delete.confirm"
|
||||
| "projects.cards.layout.delete.default_blocked"
|
||||
| "projects.cards.layout.density"
|
||||
| "projects.cards.layout.density.compact"
|
||||
| "projects.cards.layout.density.roomy"
|
||||
| "projects.cards.layout.discard"
|
||||
| "projects.cards.layout.edit"
|
||||
| "projects.cards.layout.fact.client-matter"
|
||||
| "projects.cards.layout.fact.count"
|
||||
| "projects.cards.layout.fact.deadline-counts"
|
||||
| "projects.cards.layout.fact.last-activity-at"
|
||||
| "projects.cards.layout.fact.move_down"
|
||||
| "projects.cards.layout.fact.move_up"
|
||||
| "projects.cards.layout.fact.next-events"
|
||||
| "projects.cards.layout.fact.parent-path"
|
||||
| "projects.cards.layout.fact.recent-verlauf"
|
||||
| "projects.cards.layout.fact.reference"
|
||||
| "projects.cards.layout.fact.status-chip"
|
||||
| "projects.cards.layout.fact.team-chips"
|
||||
| "projects.cards.layout.fact.title-row"
|
||||
| "projects.cards.layout.fact.toggle.hide"
|
||||
| "projects.cards.layout.fact.toggle.show"
|
||||
| "projects.cards.layout.fact.type-chip"
|
||||
| "projects.cards.layout.grid"
|
||||
| "projects.cards.layout.grid.2"
|
||||
| "projects.cards.layout.grid.3"
|
||||
| "projects.cards.layout.grid.4"
|
||||
| "projects.cards.layout.grid.auto"
|
||||
| "projects.cards.layout.is_default"
|
||||
| "projects.cards.layout.label"
|
||||
| "projects.cards.layout.new"
|
||||
| "projects.cards.layout.new.prompt"
|
||||
| "projects.cards.layout.rename"
|
||||
| "projects.cards.layout.save"
|
||||
| "projects.cards.layout.set_default"
|
||||
| "projects.cards.next_events"
|
||||
| "projects.cards.no_next_events"
|
||||
| "projects.cards.no_recent"
|
||||
| "projects.cards.recent_verlauf"
|
||||
| "projects.cards.show_all_levels"
|
||||
| "projects.cards.show_all_levels.hint"
|
||||
| "projects.cards.team"
|
||||
| "projects.chip.all"
|
||||
| "projects.chip.has_open_deadlines"
|
||||
| "projects.chip.mine"
|
||||
| "projects.chip.multi.count"
|
||||
| "projects.chip.multi.none"
|
||||
| "projects.chip.pinned"
|
||||
| "projects.chip.status"
|
||||
| "projects.chip.status.active"
|
||||
| "projects.chip.status.archived"
|
||||
| "projects.chip.status.closed"
|
||||
| "projects.chip.type"
|
||||
| "projects.chip.type.case"
|
||||
| "projects.chip.type.client"
|
||||
| "projects.chip.type.litigation"
|
||||
| "projects.chip.type.patent"
|
||||
| "projects.chip.type.project"
|
||||
| "projects.col.clientmatter"
|
||||
| "projects.col.office"
|
||||
| "projects.col.ref"
|
||||
@@ -1357,21 +1570,30 @@ export type I18nKey =
|
||||
| "projects.detail.tab.verlauf"
|
||||
| "projects.detail.team.add"
|
||||
| "projects.detail.team.col.name"
|
||||
| "projects.detail.team.col.profession"
|
||||
| "projects.detail.team.col.responsibility"
|
||||
| "projects.detail.team.col.role"
|
||||
| "projects.detail.team.col.source"
|
||||
| "projects.detail.team.confirm_remove"
|
||||
| "projects.detail.team.empty"
|
||||
| "projects.detail.team.error.user_required"
|
||||
| "projects.detail.team.form.cancel"
|
||||
| "projects.detail.team.form.profession.label"
|
||||
| "projects.detail.team.form.profession.none"
|
||||
| "projects.detail.team.form.responsibility"
|
||||
| "projects.detail.team.form.role"
|
||||
| "projects.detail.team.form.submit"
|
||||
| "projects.detail.team.form.user"
|
||||
| "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"
|
||||
| "projects.detail.verlauf.loadMore"
|
||||
| "projects.detail.verlauf.loadingMore"
|
||||
| "projects.empty.filtered"
|
||||
| "projects.empty.filtered.action"
|
||||
| "projects.empty.hint"
|
||||
| "projects.empty.title"
|
||||
| "projects.error.forbidden"
|
||||
@@ -1430,14 +1652,34 @@ export type I18nKey =
|
||||
| "projects.neu.title"
|
||||
| "projects.new"
|
||||
| "projects.onboarding.required"
|
||||
| "projects.search.match.ancestor"
|
||||
| "projects.search.match.descendant"
|
||||
| "projects.search.match.self"
|
||||
| "projects.search.placeholder"
|
||||
| "projects.status.active"
|
||||
| "projects.status.archived"
|
||||
| "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.profession.associate"
|
||||
| "projects.team.profession.hint"
|
||||
| "projects.team.profession.none"
|
||||
| "projects.team.profession.none.hint"
|
||||
| "projects.team.profession.of_counsel"
|
||||
| "projects.team.profession.pa"
|
||||
| "projects.team.profession.paralegal"
|
||||
| "projects.team.profession.partner"
|
||||
| "projects.team.profession.senior_pa"
|
||||
| "projects.team.responsibility.external"
|
||||
| "projects.team.responsibility.lead"
|
||||
| "projects.team.responsibility.member"
|
||||
| "projects.team.responsibility.observer"
|
||||
| "projects.team.role.associate"
|
||||
| "projects.team.role.expert"
|
||||
| "projects.team.role.lead"
|
||||
@@ -1445,12 +1687,40 @@ 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.toolbar.search.placeholder"
|
||||
| "projects.toolbar.subtree_counts"
|
||||
| "projects.toolbar.view.cards"
|
||||
| "projects.toolbar.view.flat"
|
||||
| "projects.toolbar.view.tree"
|
||||
| "projects.tree.deadlines.direct.tooltip"
|
||||
| "projects.tree.deadlines.open"
|
||||
| "projects.tree.deadlines.overdue"
|
||||
| "projects.tree.deadlines.subtree.tooltip"
|
||||
| "projects.tree.error"
|
||||
| "projects.tree.inherited.context"
|
||||
| "projects.tree.loading"
|
||||
| "projects.tree.pin"
|
||||
| "projects.tree.toggle"
|
||||
| "projects.tree.unpin"
|
||||
| "projects.type.case"
|
||||
| "projects.type.client"
|
||||
| "projects.type.litigation"
|
||||
@@ -1471,10 +1741,36 @@ export type I18nKey =
|
||||
| "search.no_results"
|
||||
| "search.placeholder"
|
||||
| "sidebar.resize.title"
|
||||
| "team.broadcast.body"
|
||||
| "team.broadcast.body_placeholder"
|
||||
| "team.broadcast.button"
|
||||
| "team.broadcast.error.body_required"
|
||||
| "team.broadcast.error.no_recipients"
|
||||
| "team.broadcast.error.subject_required"
|
||||
| "team.broadcast.error.too_many"
|
||||
| "team.broadcast.markdown_hint"
|
||||
| "team.broadcast.placeholders_hint"
|
||||
| "team.broadcast.recipients"
|
||||
| "team.broadcast.send"
|
||||
| "team.broadcast.sending"
|
||||
| "team.broadcast.sent"
|
||||
| "team.broadcast.show_all"
|
||||
| "team.broadcast.subject"
|
||||
| "team.broadcast.success"
|
||||
| "team.broadcast.template"
|
||||
| "team.broadcast.template.deadline_digest"
|
||||
| "team.broadcast.template.invitation"
|
||||
| "team.broadcast.template_freeform"
|
||||
| "team.broadcast.template_optional"
|
||||
| "team.broadcast.title"
|
||||
| "team.dept.lead"
|
||||
| "team.dept.unassigned"
|
||||
| "team.empty"
|
||||
| "team.filter.all"
|
||||
| "team.filter.project"
|
||||
| "team.filter.project.all"
|
||||
| "team.filter.project.clear"
|
||||
| "team.filter.project.selected"
|
||||
| "team.filter.role"
|
||||
| "team.group.department"
|
||||
| "team.group.office"
|
||||
@@ -1499,4 +1795,99 @@ 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"
|
||||
| "views.action.edit"
|
||||
| "views.calendar.mobile_fallback"
|
||||
| "views.col.actor"
|
||||
| "views.col.appointment_type"
|
||||
| "views.col.approval_status"
|
||||
| "views.col.date"
|
||||
| "views.col.decided_by"
|
||||
| "views.col.event_type"
|
||||
| "views.col.kind"
|
||||
| "views.col.location"
|
||||
| "views.col.project"
|
||||
| "views.col.rule"
|
||||
| "views.col.status"
|
||||
| "views.col.time"
|
||||
| "views.col.title"
|
||||
| "views.density.comfortable"
|
||||
| "views.density.compact"
|
||||
| "views.editor.cancel"
|
||||
| "views.editor.confirm_delete"
|
||||
| "views.editor.delete"
|
||||
| "views.editor.error.delete_failed"
|
||||
| "views.editor.error.load_failed"
|
||||
| "views.editor.error.name_required"
|
||||
| "views.editor.error.slug_format"
|
||||
| "views.editor.error.sources_required"
|
||||
| "views.editor.field.density"
|
||||
| "views.editor.field.horizon"
|
||||
| "views.editor.field.icon"
|
||||
| "views.editor.field.name"
|
||||
| "views.editor.field.personal_only"
|
||||
| "views.editor.field.scope_mode"
|
||||
| "views.editor.field.shape"
|
||||
| "views.editor.field.show_count"
|
||||
| "views.editor.field.slug"
|
||||
| "views.editor.heading.edit"
|
||||
| "views.editor.heading.new"
|
||||
| "views.editor.hint.slug"
|
||||
| "views.editor.hint.sources"
|
||||
| "views.editor.icon.bell"
|
||||
| "views.editor.icon.building"
|
||||
| "views.editor.icon.calendar"
|
||||
| "views.editor.icon.clock"
|
||||
| "views.editor.icon.default"
|
||||
| "views.editor.icon.folder"
|
||||
| "views.editor.icon.users"
|
||||
| "views.editor.save"
|
||||
| "views.editor.section.identity"
|
||||
| "views.editor.section.render"
|
||||
| "views.editor.section.scope"
|
||||
| "views.editor.section.sources"
|
||||
| "views.editor.section.time"
|
||||
| "views.editor.subtitle"
|
||||
| "views.editor.title"
|
||||
| "views.empty.title"
|
||||
| "views.error.back"
|
||||
| "views.error.network"
|
||||
| "views.error.not_found"
|
||||
| "views.heading"
|
||||
| "views.horizon.all"
|
||||
| "views.horizon.any"
|
||||
| "views.horizon.custom"
|
||||
| "views.horizon.next_30d"
|
||||
| "views.horizon.next_7d"
|
||||
| "views.horizon.next_90d"
|
||||
| "views.horizon.past_30d"
|
||||
| "views.horizon.past_90d"
|
||||
| "views.kind.appointment"
|
||||
| "views.kind.approval_request"
|
||||
| "views.kind.deadline"
|
||||
| "views.kind.project_event"
|
||||
| "views.loading"
|
||||
| "views.onboarding.body"
|
||||
| "views.onboarding.create"
|
||||
| "views.onboarding.title"
|
||||
| "views.save_as"
|
||||
| "views.scope.all_visible"
|
||||
| "views.scope.explicit"
|
||||
| "views.scope.my_subtree"
|
||||
| "views.scope.personal_only"
|
||||
| "views.shape.calendar"
|
||||
| "views.shape.cards"
|
||||
| "views.shape.list"
|
||||
| "views.source.appointment"
|
||||
| "views.source.approval_request"
|
||||
| "views.source.deadline"
|
||||
| "views.source.project_event"
|
||||
| "views.subtitle"
|
||||
| "views.title"
|
||||
| "views.toast.inaccessible_n"
|
||||
| "views.toast.inaccessible_one";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -34,9 +34,9 @@ export function renderIndex(): string {
|
||||
<main>
|
||||
<section className="hero">
|
||||
<div className="container">
|
||||
<h1>Patent Knowledge<br /><span className="hero-accent" data-i18n="index.hero.accent">{`für ${FIRM}`}</span></h1>
|
||||
<h1>Patent Litigation<br /><span className="hero-accent" data-i18n="index.hero.accent">{`für ${FIRM}`}</span></h1>
|
||||
<p className="hero-sub" data-i18n="index.hero.sub">
|
||||
{`Leitfäden, Vorlagen und Dokumente für das ${FIRM} Patent-Team.`}
|
||||
{`Administration, Knowledge und Tools für das ${FIRM} Patent-Team.`}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -71,6 +71,24 @@ export function renderOnboarding(): string {
|
||||
<option value="Sekretariat"></option>
|
||||
</datalist>
|
||||
|
||||
<label htmlFor="onb-profession" className="login-label" data-i18n="onboarding.profession">Profession</label>
|
||||
<select
|
||||
id="onb-profession"
|
||||
name="profession"
|
||||
required
|
||||
className="login-input"
|
||||
>
|
||||
<option value="associate" selected data-i18n="projects.team.profession.associate">Associate</option>
|
||||
<option value="partner" data-i18n="projects.team.profession.partner">Partner</option>
|
||||
<option value="of_counsel" data-i18n="projects.team.profession.of_counsel">Of Counsel</option>
|
||||
<option value="senior_pa" data-i18n="projects.team.profession.senior_pa">Senior PA</option>
|
||||
<option value="pa" data-i18n="projects.team.profession.pa">PA</option>
|
||||
<option value="paralegal" data-i18n="projects.team.profession.paralegal">Paralegal</option>
|
||||
</select>
|
||||
<p className="login-hint" data-i18n="onboarding.profession.hint">
|
||||
Strukturiertes Tier — steuert die 4-Augen-Genehmigung. Distinkt von der Berufsbezeichnung.
|
||||
</p>
|
||||
|
||||
<label htmlFor="onb-partner-unit" className="login-label" data-i18n="onboarding.partner_unit">
|
||||
Partner Unit <span className="login-label-optional" data-i18n="onboarding.optional">(optional)</span>
|
||||
</label>
|
||||
|
||||
97
frontend/src/paliadin.tsx
Normal file
97
frontend/src/paliadin.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// Paliadin chat panel page (t-paliad-146 PoC).
|
||||
//
|
||||
// Single full-page surface; m types into an input at the bottom, sees
|
||||
// a stream of bubbles above. Server-side hydration is deliberately
|
||||
// minimal — the panel boots empty, the client manages state in
|
||||
// localStorage (per design §0.5.4 session-only history).
|
||||
export function renderPaliadin(): 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" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="paliadin.title">Paliadin — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/paliadin" />
|
||||
<BottomNav currentPath="/paliadin" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page paliadin-page">
|
||||
<div className="container paliadin-container">
|
||||
<div className="tool-header paliadin-header">
|
||||
<div>
|
||||
<h1 data-i18n="paliadin.heading">✨ Paliadin</h1>
|
||||
<p className="tool-subtitle paliadin-tagline" data-i18n="paliadin.tagline">
|
||||
Ich kenne deine Akten und Paliads Wissensbasis.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn-secondary paliadin-reset" id="paliadin-reset"
|
||||
data-i18n="paliadin.reset">
|
||||
Neue Unterhaltung
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="paliadin-stream" id="paliadin-stream" aria-live="polite">
|
||||
<div className="paliadin-empty" id="paliadin-empty">
|
||||
<p data-i18n="paliadin.empty">Was kann ich für dich tun?</p>
|
||||
<div className="paliadin-starters" id="paliadin-starters">
|
||||
<button type="button" className="paliadin-starter"
|
||||
data-prompt-de="Was steht heute an?"
|
||||
data-prompt-en="What's on my plate today?"
|
||||
data-i18n="paliadin.starter.today">
|
||||
Was steht heute an?
|
||||
</button>
|
||||
<button type="button" className="paliadin-starter"
|
||||
data-prompt-de="Welche Fristen sind diese Woche fällig?"
|
||||
data-prompt-en="Which deadlines are due this week?"
|
||||
data-i18n="paliadin.starter.week">
|
||||
Welche Fristen sind diese Woche fällig?
|
||||
</button>
|
||||
<button type="button" className="paliadin-starter"
|
||||
data-prompt-de="Erkläre mir Klageerwiderung."
|
||||
data-prompt-en="Explain Klageerwiderung."
|
||||
data-i18n="paliadin.starter.concept">
|
||||
Erkläre mir Klageerwiderung.
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="paliadin-form" id="paliadin-form">
|
||||
<textarea className="paliadin-input" id="paliadin-input"
|
||||
rows={2}
|
||||
data-i18n-placeholder="paliadin.input.placeholder"
|
||||
placeholder="Frag den Paliadin…"
|
||||
required></textarea>
|
||||
<button type="submit" className="btn-primary paliadin-send" id="paliadin-send"
|
||||
data-i18n="paliadin.send">
|
||||
Senden
|
||||
</button>
|
||||
<button type="button" className="btn-secondary paliadin-stop" id="paliadin-stop"
|
||||
style="display:none"
|
||||
data-i18n="paliadin.stop">
|
||||
Stop
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/paliadin.js"></script>
|
||||
</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,18 +114,20 @@ 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>
|
||||
<select id="team-role">
|
||||
<option value="lead" data-i18n="projects.team.role.lead">Lead</option>
|
||||
<option value="associate" selected data-i18n="projects.team.role.associate">Associate</option>
|
||||
<option value="pa" data-i18n="projects.team.role.pa">PA</option>
|
||||
<option value="of_counsel" data-i18n="projects.team.role.of_counsel">Of Counsel</option>
|
||||
<option value="local_counsel" data-i18n="projects.team.role.local_counsel">Local Counsel</option>
|
||||
<option value="expert" data-i18n="projects.team.role.expert">Experte</option>
|
||||
<option value="observer" data-i18n="projects.team.role.observer">Beobachter</option>
|
||||
<label htmlFor="team-responsibility" data-i18n="projects.detail.team.form.responsibility">Rolle im Projekt</label>
|
||||
<select id="team-responsibility">
|
||||
<option value="lead" data-i18n="projects.team.responsibility.lead">Lead</option>
|
||||
<option value="member" selected data-i18n="projects.team.responsibility.member">Mitglied</option>
|
||||
<option value="observer" data-i18n="projects.team.responsibility.observer">Beobachter</option>
|
||||
<option value="external" data-i18n="projects.team.responsibility.external">Extern</option>
|
||||
</select>
|
||||
<p id="team-profession-hint" className="form-hint" style="display:none" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
@@ -134,7 +141,8 @@ export function renderProjectsDetail(): string {
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="projects.detail.team.col.name">Name</th>
|
||||
<th data-i18n="projects.detail.team.col.role">Rolle</th>
|
||||
<th data-i18n="projects.detail.team.col.profession">Profession</th>
|
||||
<th data-i18n="projects.detail.team.col.responsibility">Rolle</th>
|
||||
<th data-i18n="projects.detail.team.col.source">Herkunft</th>
|
||||
<th />
|
||||
</tr>
|
||||
@@ -145,6 +153,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 +325,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 +354,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">
|
||||
|
||||
@@ -4,8 +4,14 @@ import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// Renders the /projekte list page. File + export name stays `Akten` for build
|
||||
// pipeline compatibility; labels + data bindings are v2 (t-paliad-024).
|
||||
// /projects page (t-paliad-149 redesign):
|
||||
// - Tree view by default (rooted at clients, descendants navigable)
|
||||
// - Chip filter row: Alle / Nur meine / Angepinnt / Status / Typ / Mit aktiven Fristen
|
||||
// - Single prominent search input (in-place filter on the active view)
|
||||
// - View-mode segment-control: Tree | Liste (Cards added in PR 2)
|
||||
//
|
||||
// All client behaviour lives in client/projects.ts orchestrator + the
|
||||
// shape modules (project-tree.ts, projects-flat.ts).
|
||||
export function renderProjects(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
@@ -40,64 +46,99 @@ export function renderProjects(): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="entity-controls">
|
||||
<div className="glossar-search-wrap entity-search-wrap">
|
||||
<svg className="glossar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<div className="projects-toolbar">
|
||||
<div className="projects-search-wrap">
|
||||
<svg className="projects-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
id="projects-search"
|
||||
className="glossar-search"
|
||||
placeholder="Titel, Referenz oder ClientMatter..."
|
||||
data-i18n-placeholder="projects.search.placeholder"
|
||||
className="projects-search-input"
|
||||
placeholder="Suchen — Titel, Referenz, ClientMatter…"
|
||||
data-i18n-placeholder="projects.toolbar.search.placeholder"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<span className="glossar-count" id="projects-count" />
|
||||
<span className="projects-search-count" id="projects-count" />
|
||||
</div>
|
||||
|
||||
<div className="filter-row">
|
||||
<div className="filter-group">
|
||||
<label className="filter-label" htmlFor="project-type" data-i18n="projects.filter.type">Typ</label>
|
||||
<select id="project-type" className="entity-select">
|
||||
<option value="" data-i18n="projects.filter.type.all">Alle Typen</option>
|
||||
<option value="client" data-i18n="projects.type.client">Mandant</option>
|
||||
<option value="litigation" data-i18n="projects.type.litigation">Streitsache</option>
|
||||
<option value="patent" data-i18n="projects.type.patent">Patent</option>
|
||||
<option value="case" data-i18n="projects.type.case">Verfahren</option>
|
||||
<option value="project" data-i18n="projects.type.project">Projekt</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label className="filter-label" htmlFor="project-status" data-i18n="projects.filter.status">Status</label>
|
||||
<select id="project-status" className="entity-select">
|
||||
<option value="" data-i18n="projects.filter.status.all">Alle Status</option>
|
||||
<option value="active" data-i18n="projects.filter.status.active">Aktiv</option>
|
||||
<option value="archived" data-i18n="projects.filter.status.archived">Archiviert</option>
|
||||
<option value="closed" data-i18n="projects.filter.status.closed">Abgeschlossen</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label className="filter-label" htmlFor="project-view" data-i18n="projects.filter.view">Ansicht</label>
|
||||
<select id="project-view" className="entity-select">
|
||||
<option value="flat" data-i18n="projects.view.flat">Flache Liste</option>
|
||||
<option value="tree" data-i18n="projects.view.tree">Baumansicht</option>
|
||||
<option value="roots" data-i18n="projects.view.roots">Nur Wurzeln</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="projects-view-segment" role="tablist" aria-label="Ansicht">
|
||||
<button type="button" className="projects-view-btn" data-view="tree" data-i18n="projects.toolbar.view.tree">Baum</button>
|
||||
<button type="button" className="projects-view-btn" data-view="cards" data-i18n="projects.toolbar.view.cards">Karten</button>
|
||||
<button type="button" className="projects-view-btn" data-view="flat" data-i18n="projects.toolbar.view.flat">Liste</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="projects-cards-toolbar" id="projects-cards-toolbar" style="display:none">
|
||||
<div className="projects-cards-layout-picker">
|
||||
<label className="projects-cards-layout-label" data-i18n="projects.cards.layout.label">Ansicht</label>
|
||||
<select id="projects-cards-layout-select" className="entity-select" />
|
||||
<button type="button" id="projects-cards-layout-edit" className="btn-secondary" data-i18n="projects.cards.layout.edit">Bearbeiten</button>
|
||||
<button type="button" id="projects-cards-layout-new" className="btn-secondary" data-i18n="projects.cards.layout.new">Neue Ansicht</button>
|
||||
</div>
|
||||
<label className="projects-cards-show-all-levels">
|
||||
<input type="checkbox" id="projects-cards-show-all-levels" />
|
||||
<span data-i18n="projects.cards.show_all_levels">Alle Ebenen anzeigen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="projects-cards-edit-toolbar" id="projects-cards-edit-toolbar" style="display:none">
|
||||
<div className="projects-cards-edit-controls">
|
||||
<label data-i18n="projects.cards.layout.density">Dichte</label>
|
||||
<select id="projects-cards-edit-density">
|
||||
<option value="roomy" data-i18n="projects.cards.layout.density.roomy">Geräumig</option>
|
||||
<option value="compact" data-i18n="projects.cards.layout.density.compact">Kompakt</option>
|
||||
</select>
|
||||
<label data-i18n="projects.cards.layout.grid">Spalten</label>
|
||||
<select id="projects-cards-edit-grid">
|
||||
<option value="auto" data-i18n="projects.cards.layout.grid.auto">Auto</option>
|
||||
<option value="2" data-i18n="projects.cards.layout.grid.2">2</option>
|
||||
<option value="3" data-i18n="projects.cards.layout.grid.3">3</option>
|
||||
<option value="4" data-i18n="projects.cards.layout.grid.4">4</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="projects-cards-edit-actions">
|
||||
<button type="button" id="projects-cards-edit-rename" className="btn-secondary" data-i18n="projects.cards.layout.rename">Umbenennen</button>
|
||||
<button type="button" id="projects-cards-edit-delete" className="btn-secondary" data-i18n="projects.cards.layout.delete">Löschen</button>
|
||||
<button type="button" id="projects-cards-edit-set-default" className="btn-secondary" data-i18n="projects.cards.layout.set_default">Als Standard festlegen</button>
|
||||
<button type="button" id="projects-cards-edit-discard" className="btn-secondary" data-i18n="projects.cards.layout.discard">Verwerfen</button>
|
||||
<button type="button" id="projects-cards-edit-save" className="btn-primary" data-i18n="projects.cards.layout.save">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="projects-chip-row" id="projects-chip-row" role="group" aria-label="Filter">
|
||||
<button type="button" className="projects-chip" data-chip="all" data-i18n="projects.chip.all">Alle</button>
|
||||
<button type="button" className="projects-chip" data-chip="mine" data-i18n="projects.chip.mine">Nur meine</button>
|
||||
<button type="button" className="projects-chip" data-chip="pinned" data-i18n="projects.chip.pinned">Angepinnt</button>
|
||||
<details className="projects-chip-multi" data-chip-multi="status">
|
||||
<summary className="projects-chip" data-i18n="projects.chip.status">Status</summary>
|
||||
<div className="projects-chip-panel" role="menu">
|
||||
<label><input type="checkbox" value="active" /><span data-i18n="projects.chip.status.active">Aktiv</span></label>
|
||||
<label><input type="checkbox" value="archived" /><span data-i18n="projects.chip.status.archived">Archiviert</span></label>
|
||||
<label><input type="checkbox" value="closed" /><span data-i18n="projects.chip.status.closed">Abgeschlossen</span></label>
|
||||
</div>
|
||||
</details>
|
||||
<details className="projects-chip-multi" data-chip-multi="type">
|
||||
<summary className="projects-chip" data-i18n="projects.chip.type">Typ</summary>
|
||||
<div className="projects-chip-panel" role="menu">
|
||||
<label><input type="checkbox" value="client" /><span data-i18n="projects.chip.type.client">Mandant</span></label>
|
||||
<label><input type="checkbox" value="litigation" /><span data-i18n="projects.chip.type.litigation">Streitsache</span></label>
|
||||
<label><input type="checkbox" value="patent" /><span data-i18n="projects.chip.type.patent">Patent</span></label>
|
||||
<label><input type="checkbox" value="case" /><span data-i18n="projects.chip.type.case">Verfahren</span></label>
|
||||
<label><input type="checkbox" value="project" data-i18n-text="projects.chip.type.project"><span data-i18n="projects.chip.type.project">Projekt</span></input></label>
|
||||
</div>
|
||||
</details>
|
||||
<button type="button" className="projects-chip" data-chip="has_open_deadlines" data-i18n="projects.chip.has_open_deadlines">Mit aktiven Fristen</button>
|
||||
</div>
|
||||
|
||||
<div id="entity-unavailable" className="entity-unavailable" style="display:none">
|
||||
<p data-i18n="projects.unavailable">
|
||||
Projektverwaltung zurzeit nicht verfügbar — bitte Administrator kontaktieren.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="entity-table-wrap" id="entity-table-wrap">
|
||||
<div className="entity-table-wrap" id="entity-table-wrap" style="display:none">
|
||||
<table className="entity-table" id="entity-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -113,10 +154,14 @@ export function renderProjects(): string {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="projekt-tree-wrap" id="projekt-tree-wrap" style="display:none">
|
||||
<div className="projekt-tree-wrap" id="projekt-tree-wrap">
|
||||
<div id="projekt-tree-container" />
|
||||
</div>
|
||||
|
||||
<div className="projects-cards-wrap" id="projects-cards-wrap" style="display:none">
|
||||
<div id="projects-cards-grid" className="projects-cards-grid" />
|
||||
</div>
|
||||
|
||||
<div className="entity-empty" id="entity-empty" style="display:none">
|
||||
<h2 data-i18n="projects.empty.title">Noch kein Projekt angelegt</h2>
|
||||
<p data-i18n="projects.empty.hint">
|
||||
@@ -127,6 +172,7 @@ export function renderProjects(): string {
|
||||
|
||||
<div className="entity-empty entity-empty-filtered" id="entity-empty-filtered" style="display:none">
|
||||
<p data-i18n="projects.empty.filtered">Keine Treffer für diese Filter.</p>
|
||||
<button type="button" id="projects-reset-filters" className="btn-secondary" data-i18n="projects.empty.filtered.action">Filter zurücksetzen</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,6 +68,12 @@ export function renderTeam(): string {
|
||||
<button className="filter-pill active" data-role="all" type="button" data-i18n="team.filter.all">Alle</button>
|
||||
</div>
|
||||
|
||||
<div className="team-filter-row team-filter-row-project" id="team-project-filter" aria-label="Projekt">
|
||||
</div>
|
||||
|
||||
<div className="team-broadcast-wrap" id="team-broadcast-wrap" style="display:none">
|
||||
</div>
|
||||
|
||||
<div className="team-list" id="team-list" />
|
||||
|
||||
<div className="glossar-empty" id="team-empty" style="display:none">
|
||||
|
||||
153
frontend/src/views-editor.tsx
Normal file
153
frontend/src/views-editor.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// Custom Views editor (t-paliad-144 Phase A2). Powers /views/new (blank
|
||||
// slate) and /views/{slug}/edit (mode chosen at hydration via path
|
||||
// inspection). One TSX, one bundle (client/views-editor.ts).
|
||||
|
||||
export function renderViewsEditor(): 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="views.editor.title">Ansicht bearbeiten — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/views" />
|
||||
<BottomNav currentPath="/views" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<h1 id="editor-heading" data-i18n="views.editor.heading.new">Neue Ansicht</h1>
|
||||
<p className="tool-subtitle" data-i18n="views.editor.subtitle">
|
||||
Wählen Sie Quellen, Filter und Darstellung. Änderungen speichern Sie unten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form id="editor-form" className="entity-form" novalidate>
|
||||
<fieldset className="form-section">
|
||||
<legend data-i18n="views.editor.section.identity">Bezeichnung</legend>
|
||||
<div className="form-field">
|
||||
<label htmlFor="editor-name" data-i18n="views.editor.field.name">Name</label>
|
||||
<input id="editor-name" type="text" required maxlength={200} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="editor-slug" data-i18n="views.editor.field.slug">Slug (URL)</label>
|
||||
<input id="editor-slug" type="text" required pattern="^[a-z0-9][a-z0-9-]{0,62}$" maxlength={63} />
|
||||
<small className="form-hint" data-i18n="views.editor.hint.slug">
|
||||
Kleinbuchstaben, Ziffern und Bindestriche — nicht reservierte Wörter.
|
||||
</small>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="editor-icon" data-i18n="views.editor.field.icon">Icon</label>
|
||||
<select id="editor-icon">
|
||||
<option value="" data-i18n="views.editor.icon.default">Standard (Ordner)</option>
|
||||
<option value="clock" data-i18n="views.editor.icon.clock">Uhr</option>
|
||||
<option value="calendar" data-i18n="views.editor.icon.calendar">Kalender</option>
|
||||
<option value="bell" data-i18n="views.editor.icon.bell">Glocke</option>
|
||||
<option value="folder" data-i18n="views.editor.icon.folder">Ordner</option>
|
||||
<option value="users" data-i18n="views.editor.icon.users">Personen</option>
|
||||
<option value="building" data-i18n="views.editor.icon.building">Gebäude</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field form-field-checkbox">
|
||||
<label>
|
||||
<input id="editor-show-count" type="checkbox" />
|
||||
<span data-i18n="views.editor.field.show_count">Treffer-Anzahl in der Sidebar anzeigen</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="form-section">
|
||||
<legend data-i18n="views.editor.section.sources">Quellen</legend>
|
||||
<p className="form-hint" data-i18n="views.editor.hint.sources">Welche Datenarten zeigt diese Ansicht?</p>
|
||||
<div className="form-field form-field-checkbox-group">
|
||||
<label><input type="checkbox" name="source" value="deadline" /> <span data-i18n="views.source.deadline">Fristen</span></label>
|
||||
<label><input type="checkbox" name="source" value="appointment" /> <span data-i18n="views.source.appointment">Termine</span></label>
|
||||
<label><input type="checkbox" name="source" value="project_event" /> <span data-i18n="views.source.project_event">Projekt-Verlauf</span></label>
|
||||
<label><input type="checkbox" name="source" value="approval_request" /> <span data-i18n="views.source.approval_request">Genehmigungen</span></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="form-section">
|
||||
<legend data-i18n="views.editor.section.scope">Geltungsbereich</legend>
|
||||
<div className="form-field">
|
||||
<label htmlFor="editor-scope-mode" data-i18n="views.editor.field.scope_mode">Projekte</label>
|
||||
<select id="editor-scope-mode">
|
||||
<option value="all_visible" data-i18n="views.scope.all_visible">Alle sichtbaren</option>
|
||||
<option value="my_subtree" data-i18n="views.scope.my_subtree">Mein Teilbaum</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field form-field-checkbox">
|
||||
<label>
|
||||
<input id="editor-personal-only" type="checkbox" />
|
||||
<span data-i18n="views.editor.field.personal_only">Nur persönliche</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="form-section">
|
||||
<legend data-i18n="views.editor.section.time">Zeitraum</legend>
|
||||
<div className="form-field">
|
||||
<label htmlFor="editor-time-horizon" data-i18n="views.editor.field.horizon">Horizont</label>
|
||||
<select id="editor-time-horizon">
|
||||
<option value="next_7d" data-i18n="views.horizon.next_7d">Nächste 7 Tage</option>
|
||||
<option value="next_30d" data-i18n="views.horizon.next_30d">Nächste 30 Tage</option>
|
||||
<option value="next_90d" data-i18n="views.horizon.next_90d">Nächste 90 Tage</option>
|
||||
<option value="past_30d" data-i18n="views.horizon.past_30d">Letzte 30 Tage</option>
|
||||
<option value="past_90d" data-i18n="views.horizon.past_90d">Letzte 90 Tage</option>
|
||||
<option value="any" data-i18n="views.horizon.any">Beliebig</option>
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="form-section">
|
||||
<legend data-i18n="views.editor.section.render">Darstellung</legend>
|
||||
<div className="form-field">
|
||||
<label htmlFor="editor-shape" data-i18n="views.editor.field.shape">Form</label>
|
||||
<select id="editor-shape">
|
||||
<option value="list" data-i18n="views.shape.list">Liste</option>
|
||||
<option value="cards" data-i18n="views.shape.cards">Karten</option>
|
||||
<option value="calendar" data-i18n="views.shape.calendar">Kalender</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field" id="editor-list-density-group">
|
||||
<label htmlFor="editor-list-density" data-i18n="views.editor.field.density">Dichte</label>
|
||||
<select id="editor-list-density">
|
||||
<option value="comfortable" data-i18n="views.density.comfortable">Bequem</option>
|
||||
<option value="compact" data-i18n="views.density.compact">Kompakt</option>
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="entity-form-feedback" id="editor-feedback" hidden />
|
||||
|
||||
<div className="entity-form-actions">
|
||||
<button type="submit" className="btn-primary btn-cta-lime" id="editor-save" data-i18n="views.editor.save">
|
||||
Speichern
|
||||
</button>
|
||||
<a href="/views" className="btn-secondary" data-i18n="views.editor.cancel">Abbrechen</a>
|
||||
<button type="button" className="btn-danger" id="editor-delete" hidden data-i18n="views.editor.delete">
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<Footer />
|
||||
</main>
|
||||
|
||||
<script src="/assets/views-editor.js" defer />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
105
frontend/src/views.tsx
Normal file
105
frontend/src/views.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// Custom Views shell (t-paliad-144 Phase A2). One TSX powers /views (the
|
||||
// landing) and /views/{slug} (a specific view). The client bundle reads
|
||||
// window.location.pathname to decide which mode to render.
|
||||
//
|
||||
// Hydration: client/views.ts loads the saved or system view via /api/views
|
||||
// and dispatches to the matching render-shape component (list / cards /
|
||||
// calendar — Q4 lock-in 2026-05-07: 3 shapes, no separate "activity").
|
||||
|
||||
export function renderViews(): 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="views.title">Ansichten — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/views" />
|
||||
<BottomNav currentPath="/views" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
{/* Header — populated by client/views.ts from the loaded view name. */}
|
||||
<div className="tool-header" id="views-header">
|
||||
<div className="entity-header-row">
|
||||
<div>
|
||||
<h1 id="views-heading" data-i18n="views.heading">Ansichten</h1>
|
||||
<p className="tool-subtitle" id="views-subtitle" data-i18n="views.subtitle">
|
||||
Eigene Ansichten über Ihre Daten — Filter und Darstellung speicherbar.
|
||||
</p>
|
||||
</div>
|
||||
<div className="views-header-actions" id="views-header-actions">
|
||||
{/* Edit + delete buttons inserted by client/views.ts when on a custom view. */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar — shape switcher (3 shapes per Q4 lock-in). */}
|
||||
<div className="views-toolbar" id="views-toolbar" hidden>
|
||||
<div className="agenda-chip-row" role="tablist" id="views-shape-chips" aria-label="Form">
|
||||
<button type="button" className="agenda-chip" data-shape="list" role="tab" data-i18n="views.shape.list">Liste</button>
|
||||
<button type="button" className="agenda-chip" data-shape="cards" role="tab" data-i18n="views.shape.cards">Karten</button>
|
||||
<button type="button" className="agenda-chip" data-shape="calendar" role="tab" data-i18n="views.shape.calendar">Kalender</button>
|
||||
</div>
|
||||
<div className="views-toolbar-spacer" />
|
||||
<a href="#" className="btn-secondary btn-small" id="views-save-as" data-i18n="views.save_as" hidden>
|
||||
Als Ansicht speichern
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Empty / onboarding state — shown on bare /views with no saved views. */}
|
||||
<div className="views-onboarding" id="views-onboarding" hidden>
|
||||
<h2 data-i18n="views.onboarding.title">Eigene Ansichten — was ist das?</h2>
|
||||
<p data-i18n="views.onboarding.body">
|
||||
Eine Ansicht ist eine gespeicherte Filterkombination — z. B. „Fristen meiner Projekte in den nächsten 14 Tagen“.
|
||||
Ansichten erscheinen als eigene Buttons in der Sidebar.
|
||||
</p>
|
||||
<div className="views-onboarding-actions">
|
||||
<a href="/views/new" className="btn-primary btn-cta-lime" data-i18n="views.onboarding.create">
|
||||
Beispiel-Ansicht erstellen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inaccessible-projects toast (Q17 attribution). */}
|
||||
<div className="views-toast" id="views-toast" hidden>
|
||||
<span className="views-toast-text" id="views-toast-text" />
|
||||
<button type="button" className="views-toast-close" id="views-toast-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
|
||||
{/* Loading + error + empty states (mutually exclusive). */}
|
||||
<div className="views-loading" id="views-loading" data-i18n="views.loading">Lädt …</div>
|
||||
<div className="views-error" id="views-error" hidden>
|
||||
<p id="views-error-message" />
|
||||
<a href="/views" className="btn-secondary btn-small" data-i18n="views.error.back">Zurück zur Ansichten-Übersicht</a>
|
||||
</div>
|
||||
<div className="views-empty" id="views-empty" hidden>
|
||||
<p data-i18n="views.empty.title">Keine Einträge gefunden.</p>
|
||||
<p className="views-empty-hint" id="views-empty-hint" />
|
||||
</div>
|
||||
|
||||
{/* Render targets — only the active shape is visible. */}
|
||||
<div className="views-shape-host views-shape-list" id="views-shape-list" hidden />
|
||||
<div className="views-shape-host views-shape-cards" id="views-shape-cards" hidden />
|
||||
<div className="views-shape-host views-shape-calendar" id="views-shape-calendar" hidden />
|
||||
</div>
|
||||
</section>
|
||||
<Footer />
|
||||
</main>
|
||||
|
||||
<script src="/assets/views.js" defer />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
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
|
||||
);
|
||||
$$;
|
||||
3
internal/db/migrations/056_user_views.down.sql
Normal file
3
internal/db/migrations/056_user_views.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- Reverse of 056_user_views.up.sql.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.user_views;
|
||||
77
internal/db/migrations/056_user_views.up.sql
Normal file
77
internal/db/migrations/056_user_views.up.sql
Normal file
@@ -0,0 +1,77 @@
|
||||
-- t-paliad-144 Phase A1: Custom Views — paliad.user_views.
|
||||
--
|
||||
-- Design: docs/design-data-display-model-2026-05-06.md (noether,
|
||||
-- m-locked 2026-05-07).
|
||||
--
|
||||
-- Stores per-user saved view definitions. A view is a `(filter_spec,
|
||||
-- render_spec, sidebar metadata)` tuple. RLS scopes every operation
|
||||
-- to the calling user — there is no cross-user visibility in v1.
|
||||
--
|
||||
-- System defaults (dashboard / agenda / events / inbox) stay code-
|
||||
-- resident in internal/services/system_views.go. They never appear
|
||||
-- as rows in this table; the slugs are reserved and rejected at write
|
||||
-- time by the application layer.
|
||||
--
|
||||
-- Sections:
|
||||
-- 1. CREATE paliad.user_views (with RLS).
|
||||
-- 2. Indexes.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. paliad.user_views
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE paliad.user_views (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
|
||||
-- Stable user-facing identifier. Goes into the URL.
|
||||
-- Application-layer validator enforces ^[a-z0-9][a-z0-9-]{0,62}$ +
|
||||
-- a reserved-list rejection (dashboard, agenda, events, inbox, …).
|
||||
slug text NOT NULL,
|
||||
|
||||
-- Display name. Free-form; user picks the language they think in.
|
||||
-- Rendered verbatim in the sidebar; no fallback or translation.
|
||||
name text NOT NULL,
|
||||
|
||||
-- One of a fixed set of icon keys (see Sidebar.tsx icon registry).
|
||||
-- NULL → default icon (folder). Validator caps length to keep the
|
||||
-- column sane even if the registry is bypassed.
|
||||
icon text,
|
||||
|
||||
-- Filter spec — see internal/services/filter_spec.go FilterSpec.
|
||||
-- Validated on write; jsonb here for forward-compat without
|
||||
-- migrations as new dimensions land.
|
||||
filter_spec jsonb NOT NULL,
|
||||
|
||||
-- Render spec — see internal/services/render_spec.go RenderSpec.
|
||||
render_spec jsonb NOT NULL,
|
||||
|
||||
-- Sidebar ordering. Lower-first. New views land at MAX+1 server-side
|
||||
-- so they sort to the bottom; the editor lets users drag-reorder.
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
|
||||
-- Show a row-count badge on the sidebar entry. Costs one COUNT(*)
|
||||
-- per refresh; opt-in (default false) so casual users don't pay.
|
||||
show_count boolean NOT NULL DEFAULT false,
|
||||
|
||||
-- Most-recently-used landing on /views (Q10). Updated by a fire-
|
||||
-- and-forget PATCH on every view-load.
|
||||
last_used_at timestamptz,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
UNIQUE (user_id, slug)
|
||||
);
|
||||
|
||||
CREATE INDEX user_views_owner_idx
|
||||
ON paliad.user_views (user_id, sort_order);
|
||||
|
||||
ALTER TABLE paliad.user_views ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Owner-only access. No global_admin override: views are personal
|
||||
-- working state, not auditable infrastructure.
|
||||
CREATE POLICY user_views_owner_all
|
||||
ON paliad.user_views FOR ALL
|
||||
USING (user_id = auth.uid())
|
||||
WITH CHECK (user_id = auth.uid());
|
||||
3
internal/db/migrations/057_email_broadcasts.down.sql
Normal file
3
internal/db/migrations/057_email_broadcasts.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- Reverse of 057_email_broadcasts.up.sql.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.email_broadcasts;
|
||||
91
internal/db/migrations/057_email_broadcasts.up.sql
Normal file
91
internal/db/migrations/057_email_broadcasts.up.sql
Normal file
@@ -0,0 +1,91 @@
|
||||
-- t-paliad-147: Bulk team email — paliad.email_broadcasts.
|
||||
--
|
||||
-- Records every bulk-send sent from /team's "E-Mail an Auswahl" flow.
|
||||
-- Powers the /admin/broadcasts viewer (global_admin sees all rows;
|
||||
-- senders see their own).
|
||||
--
|
||||
-- recipient_filter snapshots the filter chips the sender had selected
|
||||
-- (project_ids, offices, roles) so a future deploy that tweaks the
|
||||
-- filter UX can still render past sends. recipient_user_ids snapshots
|
||||
-- the resolved user list — the actual addressees, immune to later
|
||||
-- team-membership changes.
|
||||
--
|
||||
-- Sections:
|
||||
-- 1. CREATE paliad.email_broadcasts.
|
||||
-- 2. Indexes.
|
||||
-- 3. RLS.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. paliad.email_broadcasts
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE paliad.email_broadcasts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- Renderable subject (post-template). Stored verbatim for audit.
|
||||
subject text NOT NULL,
|
||||
|
||||
-- Body source as the sender typed it (Markdown). NOT the per-recipient
|
||||
-- rendered output — those are reconstructable by re-rendering with the
|
||||
-- snapshotted recipient row, but the source is what we audit.
|
||||
body text NOT NULL,
|
||||
|
||||
-- The sender. FK to paliad.users (not auth.users) so deleting an auth
|
||||
-- row leaves the audit trail intact via paliad.users.
|
||||
sender_id uuid NOT NULL REFERENCES paliad.users(id),
|
||||
|
||||
-- Optional template the sender started from. NULL when freeform.
|
||||
template_key text,
|
||||
|
||||
-- Snapshot of filter chips selected at send time. Keys: project_ids
|
||||
-- (uuid[]), offices (text[]), roles (text[]). jsonb for forward-compat.
|
||||
recipient_filter jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
-- Resolved addressee list — the user_ids that received (or attempted)
|
||||
-- the mail. Immune to subsequent team-membership changes.
|
||||
recipient_user_ids uuid[] NOT NULL DEFAULT '{}'::uuid[],
|
||||
|
||||
-- Per-send result counts (sent, failed, total). jsonb so we can grow
|
||||
-- the report shape without a migration.
|
||||
send_report jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
sent_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. Indexes
|
||||
-- ============================================================================
|
||||
|
||||
CREATE INDEX email_broadcasts_sent_at_idx
|
||||
ON paliad.email_broadcasts (sent_at DESC);
|
||||
|
||||
CREATE INDEX email_broadcasts_sender_idx
|
||||
ON paliad.email_broadcasts (sender_id, sent_at DESC);
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. RLS
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.email_broadcasts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Senders can read their own rows; global_admin can read everything.
|
||||
-- The Go service layer (BroadcastService) is the load-bearing gate; RLS
|
||||
-- here is defence-in-depth for any future auth-context query path.
|
||||
CREATE POLICY email_broadcasts_select
|
||||
ON paliad.email_broadcasts FOR SELECT
|
||||
USING (
|
||||
sender_id = auth.uid()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid()
|
||||
AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- Inserts only by the sender themselves (defence-in-depth — the service
|
||||
-- enforces project_lead-OR-global_admin authorship; RLS only enforces the
|
||||
-- self-attribution bit).
|
||||
CREATE POLICY email_broadcasts_insert
|
||||
ON paliad.email_broadcasts FOR INSERT
|
||||
WITH CHECK (sender_id = auth.uid());
|
||||
3
internal/db/migrations/058_paliadin_poc.down.sql
Normal file
3
internal/db/migrations/058_paliadin_poc.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- t-paliad-146: Paliadin PoC — drop paliad.paliadin_turns.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.paliadin_turns;
|
||||
142
internal/db/migrations/058_paliadin_poc.up.sql
Normal file
142
internal/db/migrations/058_paliadin_poc.up.sql
Normal file
@@ -0,0 +1,142 @@
|
||||
-- t-paliad-146: Paliadin PoC — paliad.paliadin_turns.
|
||||
--
|
||||
-- Design: docs/design-paliadin-2026-05-07.md §0.5.6 (PoC variant).
|
||||
--
|
||||
-- Paliadin is the in-app conversational AI assistant. Phase 0 PoC runs on
|
||||
-- m's laptop only (PALIADIN_ENABLED=false on prod default), backed by a
|
||||
-- long-lived `claude` process inside a tmux session — not the Anthropic
|
||||
-- Messages API. The PoC's load-bearing artefact is monitoring: every
|
||||
-- turn writes a row here so m can decide via /admin/paliadin whether the
|
||||
-- feature earns a production v1 build.
|
||||
--
|
||||
-- The PoC variant of this table stores the FULL prompt + response (no
|
||||
-- redaction) because m is the only user, m is m's own compliance officer,
|
||||
-- and the whole point is to read what was asked later. Production v1
|
||||
-- swaps to hash-only storage; that's a separate migration.
|
||||
--
|
||||
-- Sections:
|
||||
-- 1. CREATE paliad.paliadin_turns (with RLS).
|
||||
-- 2. Indexes.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. paliad.paliadin_turns
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE paliad.paliadin_turns (
|
||||
turn_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- Who asked. FK to paliad.users (not auth.users) so deleting an auth
|
||||
-- row leaves the audit trail intact via paliad.users.
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id),
|
||||
|
||||
-- Browser session ID (opaque). Lets us group turns into "a single
|
||||
-- conversation" without storing the full thread server-side.
|
||||
session_id text NOT NULL,
|
||||
|
||||
started_at timestamptz NOT NULL DEFAULT now(),
|
||||
finished_at timestamptz, -- NULL until end-of-turn
|
||||
duration_ms int, -- finished_at - started_at
|
||||
|
||||
-- The user's prompt, verbatim. PoC scope only — production v1 stores
|
||||
-- a redacted hash instead. See docs/design-paliadin-2026-05-07.md §3.3.
|
||||
user_message text NOT NULL,
|
||||
|
||||
-- Claude's response, verbatim, with the [paliadin-meta] trailer
|
||||
-- already stripped. The trailer's parsed fields land in `used_tools`,
|
||||
-- `rows_seen`, `chip_count`, `classifier_tag` below.
|
||||
response text,
|
||||
|
||||
-- Approximate token count (server-side word_count * 1.3). Claude Code
|
||||
-- via tmux doesn't expose Anthropic's usage block, so this is a
|
||||
-- coarse heuristic for the dashboard cost trend — not a billing
|
||||
-- number.
|
||||
response_tokens int,
|
||||
|
||||
-- Tool names Claude used during this turn, parsed from the
|
||||
-- [paliadin-meta] trailer block ("used_tools: search_my_deadlines,
|
||||
-- lookup_court"). Empty array means Claude didn't use any tool —
|
||||
-- the load-bearing dashboard signal: high tool-use rate justifies
|
||||
-- the data-grounding pitch in §8.1.
|
||||
used_tools text[] NOT NULL DEFAULT '{}'::text[],
|
||||
|
||||
-- Row counts parallel to used_tools (e.g. "rows_seen: 3, 1" → {3, 1}).
|
||||
-- Helps spot "tool ran but returned nothing" patterns.
|
||||
rows_seen int[] NOT NULL DEFAULT '{}'::int[],
|
||||
|
||||
-- Number of action chips Claude embedded in the response.
|
||||
chip_count int NOT NULL DEFAULT 0,
|
||||
|
||||
-- True if the user closed the SSE stream before Claude finished.
|
||||
abandoned boolean NOT NULL DEFAULT false,
|
||||
|
||||
-- Which paliad page m was on when he asked. Empty when invoked from
|
||||
-- /paliadin directly.
|
||||
page_origin text,
|
||||
|
||||
-- Error code, NULL on success. Possible values:
|
||||
-- tmux_unresponsive — couldn't write to the pane / pane died
|
||||
-- pane_died — tmux window closed mid-turn
|
||||
-- user_aborted — abandoned=true synonym, kept for query clarity
|
||||
-- timeout — Claude didn't write the response file in time
|
||||
-- prompt_disabled — PALIADIN_ENABLED=false at request time
|
||||
error_code text,
|
||||
|
||||
-- Coarse self-classification by Claude itself in the [paliadin-meta]
|
||||
-- trailer ("data" / "concept" / "navigation" / "meta" / "other").
|
||||
-- Drives the use-case-shape histogram on /admin/paliadin.
|
||||
classifier_tag text
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. Indexes
|
||||
-- ============================================================================
|
||||
|
||||
-- Per-user timeline (the "my recent paliadin turns" query). Most rows for
|
||||
-- the PoC will share user_id=m, so this index is mostly useful as a sort
|
||||
-- helper.
|
||||
CREATE INDEX paliadin_turns_user_started_idx
|
||||
ON paliad.paliadin_turns(user_id, started_at DESC);
|
||||
|
||||
-- Global timeline for /admin/paliadin dashboard. Keeps the dashboard
|
||||
-- queries (top-N recent turns, daily counts) on an index scan even as
|
||||
-- the table grows.
|
||||
CREATE INDEX paliadin_turns_started_idx
|
||||
ON paliad.paliadin_turns(started_at DESC);
|
||||
|
||||
-- Histogram queries on classifier_tag. Tiny table at PoC scale; the
|
||||
-- index pays for itself once we have weeks of data.
|
||||
CREATE INDEX paliadin_turns_classifier_idx
|
||||
ON paliad.paliadin_turns(classifier_tag, started_at DESC)
|
||||
WHERE classifier_tag IS NOT NULL;
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. RLS
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.paliadin_turns ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- A user sees their own turns; global_admin sees all rows. The /admin/
|
||||
-- paliadin dashboard runs under m (global_admin) and so sees the full
|
||||
-- log. Other users would only see their own — though in PoC scope
|
||||
-- there's only m, the policy is the production-shape from day one.
|
||||
CREATE POLICY paliadin_turns_select
|
||||
ON paliad.paliadin_turns FOR SELECT
|
||||
USING (
|
||||
user_id = auth.uid()
|
||||
OR EXISTS (SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
|
||||
);
|
||||
|
||||
-- Service-role (paliad backend) writes. Direct-auth INSERT is blocked.
|
||||
-- Paliad runs with the service role today so the policy is inert in
|
||||
-- practice; we still enable RLS so future direct-auth callers are gated.
|
||||
CREATE POLICY paliadin_turns_insert_admin_only
|
||||
ON paliad.paliadin_turns FOR INSERT
|
||||
WITH CHECK (false);
|
||||
|
||||
CREATE POLICY paliadin_turns_update_admin_only
|
||||
ON paliad.paliadin_turns FOR UPDATE
|
||||
USING (false);
|
||||
|
||||
COMMENT ON TABLE paliad.paliadin_turns IS
|
||||
'Per-turn audit log for Paliadin (in-app AI). PoC variant stores full prompt + response — production v1 will swap to hash-only. Powers /admin/paliadin dashboard. Design: docs/design-paliadin-2026-05-07.md §0.5.6.';
|
||||
124
internal/db/migrations/059_profession_vs_responsibility.down.sql
Normal file
124
internal/db/migrations/059_profession_vs_responsibility.down.sql
Normal file
@@ -0,0 +1,124 @@
|
||||
-- Reverse of 057_profession_vs_responsibility.up.sql.
|
||||
--
|
||||
-- Best-effort rollback. The new columns are dropped; the legacy
|
||||
-- project_teams.role column is re-derived from (responsibility, profession).
|
||||
-- Down-migration loses information on edges:
|
||||
-- * external responsibility → role='local_counsel' (loses expert distinction)
|
||||
-- * member + profession=partner → role='of_counsel' (no legacy 'partner'
|
||||
-- existed in project_teams.role; closest legacy ceiling)
|
||||
-- * member + profession=paralegal → role='pa' (no legacy paralegal)
|
||||
-- * member + profession=NULL → role='associate' (safe default, matches
|
||||
-- the legacy RoleAssociate default)
|
||||
-- These edges are documented; if the down is run on real production data,
|
||||
-- review per-row before commit.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. Restore approval_role_level to point at legacy ladder values.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.approval_role_level(role text)
|
||||
RETURNS int LANGUAGE SQL IMMUTABLE AS $$
|
||||
SELECT CASE role
|
||||
WHEN 'lead' THEN 5
|
||||
WHEN 'of_counsel' THEN 4
|
||||
WHEN 'associate' THEN 3
|
||||
WHEN 'senior_pa' THEN 2
|
||||
WHEN 'pa' THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
$$;
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. Restore approval_role_from_unit_role lead → lead.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.approval_role_from_unit_role(unit_role text)
|
||||
RETURNS text LANGUAGE SQL IMMUTABLE AS $$
|
||||
SELECT CASE unit_role
|
||||
WHEN 'lead' THEN 'lead'
|
||||
WHEN 'attorney' THEN 'associate'
|
||||
WHEN 'senior_pa' THEN 'senior_pa'
|
||||
WHEN 'pa' THEN 'pa'
|
||||
ELSE 'observer'
|
||||
END
|
||||
$$;
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. Re-derive project_teams.role from (responsibility, profession).
|
||||
-- ============================================================================
|
||||
|
||||
UPDATE paliad.project_teams pt
|
||||
SET role = CASE
|
||||
WHEN pt.responsibility = 'lead' THEN 'lead'
|
||||
WHEN pt.responsibility = 'observer' THEN 'observer'
|
||||
WHEN pt.responsibility = 'external' THEN 'local_counsel'
|
||||
ELSE COALESCE(
|
||||
(SELECT CASE u.profession
|
||||
WHEN 'partner' THEN 'of_counsel' -- best-effort: no legacy 'partner' role
|
||||
WHEN 'of_counsel' THEN 'of_counsel'
|
||||
WHEN 'associate' THEN 'associate'
|
||||
WHEN 'senior_pa' THEN 'senior_pa'
|
||||
WHEN 'pa' THEN 'pa'
|
||||
WHEN 'paralegal' THEN 'pa' -- closest legacy fit
|
||||
END
|
||||
FROM paliad.users u WHERE u.id = pt.user_id),
|
||||
'associate'
|
||||
)
|
||||
END;
|
||||
|
||||
-- ============================================================================
|
||||
-- 4. Restore approval_policies + approval_requests CHECK constraints.
|
||||
-- ============================================================================
|
||||
|
||||
UPDATE paliad.approval_policies
|
||||
SET required_role = 'lead'
|
||||
WHERE required_role = 'partner';
|
||||
|
||||
ALTER TABLE paliad.approval_policies DROP CONSTRAINT IF EXISTS approval_policies_required_role_check;
|
||||
ALTER TABLE paliad.approval_policies ADD CONSTRAINT approval_policies_required_role_check
|
||||
CHECK (required_role IN ('lead', 'of_counsel', 'associate', 'senior_pa', 'pa'));
|
||||
|
||||
UPDATE paliad.approval_requests
|
||||
SET required_role = 'lead'
|
||||
WHERE required_role = 'partner';
|
||||
|
||||
ALTER TABLE paliad.approval_requests DROP CONSTRAINT IF EXISTS approval_requests_required_role_check;
|
||||
ALTER TABLE paliad.approval_requests ADD CONSTRAINT approval_requests_required_role_check
|
||||
CHECK (required_role IN ('lead', 'of_counsel', 'associate', 'senior_pa', 'pa'));
|
||||
|
||||
-- ============================================================================
|
||||
-- 5. Restore project_partner_units RLS to read pt.role = 'lead'.
|
||||
-- ============================================================================
|
||||
|
||||
DROP POLICY IF EXISTS project_partner_units_write ON paliad.project_partner_units;
|
||||
|
||||
CREATE POLICY project_partner_units_write
|
||||
ON paliad.project_partner_units FOR ALL
|
||||
USING (
|
||||
EXISTS (SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
|
||||
OR EXISTS (SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = auth.uid()
|
||||
AND pt.project_id = project_partner_units.project_id
|
||||
AND pt.role = 'lead')
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
|
||||
OR EXISTS (SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = auth.uid()
|
||||
AND pt.project_id = project_partner_units.project_id
|
||||
AND pt.role = 'lead')
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 6. Drop the new function and columns.
|
||||
-- ============================================================================
|
||||
|
||||
DROP FUNCTION IF EXISTS paliad.user_project_authority_level(uuid, uuid);
|
||||
|
||||
DROP INDEX IF EXISTS paliad.project_teams_responsibility_idx;
|
||||
ALTER TABLE paliad.project_teams DROP COLUMN IF EXISTS responsibility;
|
||||
|
||||
DROP INDEX IF EXISTS paliad.users_profession_idx;
|
||||
ALTER TABLE paliad.users DROP COLUMN IF EXISTS profession;
|
||||
339
internal/db/migrations/059_profession_vs_responsibility.up.sql
Normal file
339
internal/db/migrations/059_profession_vs_responsibility.up.sql
Normal file
@@ -0,0 +1,339 @@
|
||||
-- t-paliad-148: split paliad.project_teams.role into firm-level profession
|
||||
-- and project-level responsibility.
|
||||
--
|
||||
-- Design: docs/design-profession-vs-project-role-2026-05-07.md (kepler,
|
||||
-- m-locked 2026-05-07 21:35).
|
||||
--
|
||||
-- The legacy column did two jobs at once:
|
||||
-- - career tier at the firm (PA, Associate, Of Counsel, …)
|
||||
-- - responsibility on this matter (Lead, Member, Observer)
|
||||
-- This migration introduces two clean axes and backfills both from the
|
||||
-- legacy column. The legacy column is kept as a deprecated shadow for one
|
||||
-- release; a follow-up migration drops it after Go code has fully
|
||||
-- migrated and the production data is verified clean.
|
||||
--
|
||||
-- Day-1 deploy = zero behaviour change because the new code paths read
|
||||
-- the new columns and the backfill is run inside this migration.
|
||||
--
|
||||
-- Sections:
|
||||
-- 1. ALTER paliad.users ADD COLUMN profession.
|
||||
-- 2. ALTER paliad.project_teams ADD COLUMN responsibility.
|
||||
-- 3. Backfill profession from highest legacy project_teams.role per user.
|
||||
-- 4. Backfill responsibility from legacy project_teams.role.
|
||||
-- 5. UPDATE paliad.approval_policies.required_role CHECK + 'lead' → 'partner'.
|
||||
-- 6. UPDATE paliad.approval_requests.required_role CHECK + 'lead' → 'partner'.
|
||||
-- 7. UPDATE paliad.approval_role_from_unit_role: lead → partner.
|
||||
-- 8. CREATE paliad.user_project_authority_level — tuple-with-gate ladder.
|
||||
-- 9. CASCADE-rebuild paliad.project_partner_units RLS policies that
|
||||
-- reference pt.role = 'lead'.
|
||||
-- 10. UPDATE COMMENT on paliad.approval_role_level pointing at users.profession.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. paliad.users.profession — firm-wide career tier.
|
||||
--
|
||||
-- NULL means "no firm tier" (external local counsel, expert, admin
|
||||
-- accounts that aren't practicing lawyers). NULL → ladder level 0 →
|
||||
-- ineligible to approve. Required-on-invite for firm members; admin
|
||||
-- editable on /admin/team.
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
ADD COLUMN profession text NULL
|
||||
CHECK (profession IS NULL OR profession IN (
|
||||
'partner', 'of_counsel', 'associate',
|
||||
'senior_pa', 'pa', 'paralegal'
|
||||
));
|
||||
|
||||
CREATE INDEX users_profession_idx ON paliad.users (profession);
|
||||
|
||||
COMMENT ON COLUMN paliad.users.profession IS
|
||||
'Firm-wide career tier driving the t-paliad-138 approval ladder. '
|
||||
'NULL = no firm tier (external collaborators, admin accounts). '
|
||||
'Distinct from job_title (free-text display) and global_role (tool admin gate).';
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. paliad.project_teams.responsibility — per-project responsibility.
|
||||
--
|
||||
-- Replaces the project-axis values that were mixed into project_teams.role.
|
||||
-- Default 'member'. 'lead' has additional manage-project privileges (already
|
||||
-- wired in derivation_service.go). 'observer' and 'external' close the
|
||||
-- approval gate (level 0 regardless of profession).
|
||||
--
|
||||
-- The legacy `role` column is kept on the table as a deprecated shadow for
|
||||
-- one release. New code reads .responsibility; old code paths that still
|
||||
-- read .role continue working until the follow-up migration drops the
|
||||
-- column.
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.project_teams
|
||||
ADD COLUMN responsibility text NOT NULL DEFAULT 'member'
|
||||
CHECK (responsibility IN ('lead', 'member', 'observer', 'external'));
|
||||
|
||||
CREATE INDEX project_teams_responsibility_idx
|
||||
ON paliad.project_teams (project_id, responsibility);
|
||||
|
||||
COMMENT ON COLUMN paliad.project_teams.responsibility IS
|
||||
'Per-project responsibility on this matter. lead/member open the '
|
||||
'approval gate; observer/external close it. Profession provides the '
|
||||
'level (paliad.users.profession).';
|
||||
|
||||
COMMENT ON COLUMN paliad.project_teams.role IS
|
||||
'DEPRECATED — split into users.profession + project_teams.responsibility '
|
||||
'in migration 057 (t-paliad-148). Kept as a shadow column for one release. '
|
||||
'Drop in follow-up migration 058.';
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. Backfill paliad.users.profession from highest legacy tier per user.
|
||||
--
|
||||
-- Mapping (legacy role → profession):
|
||||
-- lead → partner
|
||||
-- of_counsel → of_counsel
|
||||
-- associate → associate
|
||||
-- senior_pa → senior_pa
|
||||
-- pa → pa
|
||||
-- local_counsel/expert/observer → IGNORED (no firm tier inferable)
|
||||
--
|
||||
-- For each user with at least one project_teams row carrying a firm-tier
|
||||
-- value, take the HIGHEST tier (per the t-138 ladder). Ties at same tier
|
||||
-- collapse trivially (same value). Users with only project-only labels
|
||||
-- (observer / local_counsel / expert) get profession=NULL — admin will
|
||||
-- need to fill them in via /admin/team.
|
||||
-- ============================================================================
|
||||
|
||||
WITH legacy_to_profession AS (
|
||||
SELECT pt.user_id,
|
||||
CASE pt.role
|
||||
WHEN 'lead' THEN 'partner'
|
||||
WHEN 'of_counsel' THEN 'of_counsel'
|
||||
WHEN 'associate' THEN 'associate'
|
||||
WHEN 'senior_pa' THEN 'senior_pa'
|
||||
WHEN 'pa' THEN 'pa'
|
||||
-- observer / local_counsel / expert → NULL (filtered below)
|
||||
END AS profession,
|
||||
CASE pt.role
|
||||
WHEN 'lead' THEN 5
|
||||
WHEN 'of_counsel' THEN 4
|
||||
WHEN 'associate' THEN 3
|
||||
WHEN 'senior_pa' THEN 2
|
||||
WHEN 'pa' THEN 1
|
||||
ELSE 0
|
||||
END AS lvl
|
||||
FROM paliad.project_teams pt
|
||||
),
|
||||
ranked AS (
|
||||
SELECT user_id, profession,
|
||||
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY lvl DESC) AS rn
|
||||
FROM legacy_to_profession
|
||||
WHERE profession IS NOT NULL
|
||||
)
|
||||
UPDATE paliad.users u
|
||||
SET profession = r.profession
|
||||
FROM ranked r
|
||||
WHERE u.id = r.user_id
|
||||
AND r.rn = 1
|
||||
AND u.profession IS NULL;
|
||||
|
||||
-- ============================================================================
|
||||
-- 4. Backfill paliad.project_teams.responsibility from legacy role.
|
||||
--
|
||||
-- Per-row mapping:
|
||||
-- lead → lead
|
||||
-- observer → observer
|
||||
-- local_counsel → external
|
||||
-- expert → external
|
||||
-- associate / pa / of_counsel / senior_pa → member
|
||||
--
|
||||
-- Authority for "member" rows now comes from the user's profession.
|
||||
-- ============================================================================
|
||||
|
||||
UPDATE paliad.project_teams
|
||||
SET responsibility = CASE role
|
||||
WHEN 'lead' THEN 'lead'
|
||||
WHEN 'observer' THEN 'observer'
|
||||
WHEN 'local_counsel' THEN 'external'
|
||||
WHEN 'expert' THEN 'external'
|
||||
ELSE 'member'
|
||||
END;
|
||||
|
||||
-- ============================================================================
|
||||
-- 5. paliad.approval_policies.required_role — drop 'lead', add 'partner'.
|
||||
--
|
||||
-- Legacy 'lead' was the project-level value at the ladder ceiling; under the
|
||||
-- new model the ceiling is profession='partner'. Backfill any existing
|
||||
-- policy rows from 'lead' to 'partner', then tighten the CHECK.
|
||||
-- ============================================================================
|
||||
|
||||
UPDATE paliad.approval_policies
|
||||
SET required_role = 'partner'
|
||||
WHERE required_role = 'lead';
|
||||
|
||||
ALTER TABLE paliad.approval_policies DROP CONSTRAINT IF EXISTS approval_policies_required_role_check;
|
||||
ALTER TABLE paliad.approval_policies ADD CONSTRAINT approval_policies_required_role_check
|
||||
CHECK (required_role IN ('partner', 'of_counsel', 'associate', 'senior_pa', 'pa'));
|
||||
|
||||
-- ============================================================================
|
||||
-- 6. paliad.approval_requests.required_role — same rename for snapshots.
|
||||
--
|
||||
-- Each request snapshots the policy's required_role at submission time so
|
||||
-- mid-flight policy edits don't change the bar. Backfill 'lead' → 'partner'
|
||||
-- for parity with the new policy enum, then tighten the CHECK.
|
||||
-- ============================================================================
|
||||
|
||||
UPDATE paliad.approval_requests
|
||||
SET required_role = 'partner'
|
||||
WHERE required_role = 'lead';
|
||||
|
||||
ALTER TABLE paliad.approval_requests DROP CONSTRAINT IF EXISTS approval_requests_required_role_check;
|
||||
ALTER TABLE paliad.approval_requests ADD CONSTRAINT approval_requests_required_role_check
|
||||
CHECK (required_role IN ('partner', 'of_counsel', 'associate', 'senior_pa', 'pa'));
|
||||
|
||||
-- ============================================================================
|
||||
-- 7. paliad.approval_role_from_unit_role — bridge maps lead → partner now.
|
||||
--
|
||||
-- Derived authority via partner-unit attachments (t-paliad-139) bridges
|
||||
-- unit_role to the project-tier ladder. Under the new ladder, the highest
|
||||
-- tier is 'partner' (was 'lead'). Update the lead → lead row to lead →
|
||||
-- partner; the rest of the bridge mapping stays unchanged.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.approval_role_from_unit_role(unit_role text)
|
||||
RETURNS text LANGUAGE SQL IMMUTABLE AS $$
|
||||
SELECT CASE unit_role
|
||||
WHEN 'lead' THEN 'partner'
|
||||
WHEN 'attorney' THEN 'associate'
|
||||
WHEN 'senior_pa' THEN 'senior_pa'
|
||||
WHEN 'pa' THEN 'pa'
|
||||
ELSE 'observer'
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Update the level helper too: 'partner' replaces 'lead' as the ceiling
|
||||
-- value that the function recognises. The numeric ladder is identical;
|
||||
-- only the named tier shifts.
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.approval_role_level(role text)
|
||||
RETURNS int LANGUAGE SQL IMMUTABLE AS $$
|
||||
SELECT CASE role
|
||||
WHEN 'partner' THEN 5
|
||||
WHEN 'of_counsel' THEN 4
|
||||
WHEN 'associate' THEN 3
|
||||
WHEN 'senior_pa' THEN 2
|
||||
WHEN 'pa' THEN 1
|
||||
WHEN 'paralegal' THEN 0
|
||||
-- Legacy 'lead' kept at level 5 for the deprecated-shadow window:
|
||||
-- old call sites that still read pt.role would otherwise return
|
||||
-- level 0 and break authority for projects where the migration
|
||||
-- has run but the Go redirect hasn't. Removed in migration 058.
|
||||
WHEN 'lead' THEN 5
|
||||
ELSE 0
|
||||
END
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.approval_role_level(text) IS
|
||||
'Strict-ladder level for the t-paliad-138 / t-paliad-148 approval gate. '
|
||||
'Reads paliad.users.profession; legacy project_teams.role values still '
|
||||
'recognised via the lead→5 shadow row until migration 058 retires the '
|
||||
'column. Higher level always satisfies lower; level 0 = ineligible.';
|
||||
|
||||
-- ============================================================================
|
||||
-- 8. paliad.user_project_authority_level — tuple-with-gate ladder.
|
||||
--
|
||||
-- effective_level for user U on project P:
|
||||
--
|
||||
-- profession_level = approval_role_level(U.profession) -- 0 if NULL
|
||||
-- responsibility = direct or ancestor on project P
|
||||
-- gate_open = responsibility IN ('lead', 'member')
|
||||
-- derived_role = approval_role_from_unit_role(unit_role)
|
||||
-- when project_partner_units.derive_grants_authority
|
||||
-- effective_level = MAX over sources, gated as above
|
||||
--
|
||||
-- Direct/ancestor responsibility opens the gate, profession provides the
|
||||
-- level. Derivation is its own source — derived authority always opens
|
||||
-- its own gate (the unit attachment's grants_authority flag is the gate).
|
||||
-- A user can hit this function via direct membership AND derivation; the
|
||||
-- result is the max of both sources.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.user_project_authority_level(
|
||||
_user_id uuid,
|
||||
_project_id uuid
|
||||
) RETURNS int
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $$
|
||||
WITH path AS (
|
||||
SELECT string_to_array(p.path, '.')::uuid[] AS ids
|
||||
FROM paliad.projects p WHERE p.id = _project_id
|
||||
),
|
||||
direct_or_ancestor AS (
|
||||
SELECT pt.responsibility
|
||||
FROM paliad.project_teams pt
|
||||
JOIN path ON pt.project_id = ANY(path.ids)
|
||||
WHERE pt.user_id = _user_id
|
||||
),
|
||||
profession_level AS (
|
||||
SELECT paliad.approval_role_level(u.profession) AS lvl
|
||||
FROM paliad.users u WHERE u.id = _user_id
|
||||
),
|
||||
direct_level AS (
|
||||
-- Profession-level if any membership row opens the gate, else 0.
|
||||
SELECT CASE
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM direct_or_ancestor doa
|
||||
WHERE doa.responsibility IN ('lead', 'member')
|
||||
) THEN COALESCE((SELECT lvl FROM profession_level), 0)
|
||||
ELSE 0
|
||||
END AS lvl
|
||||
),
|
||||
derived_level AS (
|
||||
SELECT COALESCE(MAX(paliad.approval_role_level(
|
||||
paliad.approval_role_from_unit_role(pum.unit_role)
|
||||
)), 0) AS lvl
|
||||
FROM paliad.project_partner_units ppu
|
||||
JOIN paliad.partner_unit_members pum
|
||||
ON pum.partner_unit_id = ppu.partner_unit_id
|
||||
AND pum.user_id = _user_id
|
||||
AND pum.unit_role = ANY(ppu.derive_unit_roles)
|
||||
JOIN path ON ppu.project_id = ANY(path.ids)
|
||||
WHERE ppu.derive_grants_authority = true
|
||||
)
|
||||
SELECT GREATEST(
|
||||
(SELECT lvl FROM direct_level),
|
||||
(SELECT lvl FROM derived_level)
|
||||
);
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.user_project_authority_level(uuid, uuid) IS
|
||||
'Effective approval-ladder level for user U on project P, evaluated as '
|
||||
'a tuple-with-gate: profession_level if responsibility ∈ {lead,member} '
|
||||
'else 0; max with derived authority (partner-unit attachment with '
|
||||
'grants_authority=true). t-paliad-148.';
|
||||
|
||||
-- ============================================================================
|
||||
-- 9. paliad.project_partner_units RLS — switch lead-gate to .responsibility.
|
||||
--
|
||||
-- Migration 055 wrote two policies that gate writes on pt.role = 'lead'.
|
||||
-- Under the new model, lead is a project responsibility, not a profession.
|
||||
-- Drop and rewrite both policies to read .responsibility.
|
||||
-- ============================================================================
|
||||
|
||||
DROP POLICY IF EXISTS project_partner_units_write ON paliad.project_partner_units;
|
||||
|
||||
CREATE POLICY project_partner_units_write
|
||||
ON paliad.project_partner_units FOR ALL
|
||||
USING (
|
||||
EXISTS (SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
|
||||
OR EXISTS (SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = auth.uid()
|
||||
AND pt.project_id = project_partner_units.project_id
|
||||
AND pt.responsibility = 'lead')
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
|
||||
OR EXISTS (SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = auth.uid()
|
||||
AND pt.project_id = project_partner_units.project_id
|
||||
AND pt.responsibility = 'lead')
|
||||
);
|
||||
3
internal/db/migrations/060_user_pinned_projects.down.sql
Normal file
3
internal/db/migrations/060_user_pinned_projects.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- Reverse of 060_user_pinned_projects.up.sql.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.user_pinned_projects;
|
||||
53
internal/db/migrations/060_user_pinned_projects.up.sql
Normal file
53
internal/db/migrations/060_user_pinned_projects.up.sql
Normal file
@@ -0,0 +1,53 @@
|
||||
-- t-paliad-149 PR 1: per-user project pins.
|
||||
--
|
||||
-- Design: docs/design-projects-page-2026-05-07.md §4.7 (godel,
|
||||
-- m-locked 2026-05-07).
|
||||
--
|
||||
-- Stores per-user pinned projects. A user pins a project to mark it
|
||||
-- as a favourite for the /projects page (chip "Angepinnt" filters
|
||||
-- the tree to pinned-only; star marker on every row toggles state).
|
||||
--
|
||||
-- RLS scopes every operation to the calling user — pins are personal
|
||||
-- working state, not project-team metadata. There is no cross-user
|
||||
-- visibility in v1; no global_admin override.
|
||||
--
|
||||
-- ON DELETE CASCADE on both FKs: project deletion removes pin rows;
|
||||
-- user deletion removes their pins. No referential drift possible.
|
||||
--
|
||||
-- Sections:
|
||||
-- 1. CREATE paliad.user_pinned_projects (with RLS).
|
||||
-- 2. Indexes.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. paliad.user_pinned_projects
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE paliad.user_pinned_projects (
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
pinned_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, project_id)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. Indexes
|
||||
-- ============================================================================
|
||||
|
||||
-- Hot path: list pins for a user, most-recently-pinned first. The PK
|
||||
-- already covers (user_id, project_id), but a separate index ordered
|
||||
-- by pinned_at DESC keeps the "Angepinnt" filter chip fast even as a
|
||||
-- user accumulates many pins.
|
||||
CREATE INDEX user_pinned_projects_user_idx
|
||||
ON paliad.user_pinned_projects (user_id, pinned_at DESC);
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. RLS
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.user_pinned_projects ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Owner-only access. No global_admin override.
|
||||
CREATE POLICY user_pinned_projects_owner_all
|
||||
ON paliad.user_pinned_projects FOR ALL
|
||||
USING (user_id = auth.uid())
|
||||
WITH CHECK (user_id = auth.uid());
|
||||
3
internal/db/migrations/061_user_card_layouts.down.sql
Normal file
3
internal/db/migrations/061_user_card_layouts.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- Reverse of 061_user_card_layouts.up.sql.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.user_card_layouts;
|
||||
76
internal/db/migrations/061_user_card_layouts.up.sql
Normal file
76
internal/db/migrations/061_user_card_layouts.up.sql
Normal file
@@ -0,0 +1,76 @@
|
||||
-- t-paliad-149 PR 2: per-user named card layouts.
|
||||
--
|
||||
-- Design: docs/design-projects-page-2026-05-07.md §5b.3 (godel,
|
||||
-- m-locked 2026-05-07: full drag-rearrange + named layouts).
|
||||
--
|
||||
-- Stores per-user named card-layout definitions for the /projects Cards
|
||||
-- view. A layout is a `(facts[], density, gridColumns, showAllLevels)`
|
||||
-- bundle plus a name and the is_default flag.
|
||||
--
|
||||
-- The very first time a user opens Cards view, the application layer
|
||||
-- auto-seeds a "Standard" layout (the rich content set per design §5b.4)
|
||||
-- and flips its is_default=true. From there the user can rename, create
|
||||
-- new layouts, drag facts around, switch defaults, and delete (except
|
||||
-- the active default — UI gates).
|
||||
--
|
||||
-- RLS scopes every operation to the calling user; layouts are personal
|
||||
-- working state (no firm-wide / cross-user visibility v1). Partial unique
|
||||
-- index keeps "at most one default per user" honest at the DB level even
|
||||
-- if the application layer's tx-flip-default ever races.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. paliad.user_card_layouts
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE paliad.user_card_layouts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
|
||||
-- Display name. Free-form; user picks the language they think in.
|
||||
-- Renders verbatim in the layout dropdown; no translation.
|
||||
name text NOT NULL,
|
||||
|
||||
-- Exactly one default per user, enforced via partial unique index below.
|
||||
-- Application layer flips this in a transaction (clear old, set new).
|
||||
is_default boolean NOT NULL DEFAULT false,
|
||||
|
||||
-- Layout JSON — see internal/services/layout_spec.go LayoutSpec.
|
||||
-- Validated on write; jsonb here for forward-compat without migrations
|
||||
-- as new fact keys land.
|
||||
layout_json jsonb NOT NULL,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
-- Names are unique per user so the layout dropdown can use names as
|
||||
-- stable labels and the application layer can return ErrUserCardLayoutNameTaken.
|
||||
UNIQUE (user_id, name)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. Indexes
|
||||
-- ============================================================================
|
||||
|
||||
-- Hot path: list a user's layouts in name order.
|
||||
CREATE INDEX user_card_layouts_user_idx
|
||||
ON paliad.user_card_layouts (user_id, name);
|
||||
|
||||
-- Partial unique index: at most one default layout per user. Keeps the
|
||||
-- invariant honest even if two concurrent PATCH .../set-default calls land
|
||||
-- (the second one's UPDATE will conflict, the application layer retries).
|
||||
CREATE UNIQUE INDEX user_card_layouts_default_idx
|
||||
ON paliad.user_card_layouts (user_id)
|
||||
WHERE is_default = true;
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. RLS
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.user_card_layouts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Owner-only access. No global_admin override (mirrors paliad.user_views;
|
||||
-- card layouts are personal working state, not auditable infrastructure).
|
||||
CREATE POLICY user_card_layouts_owner_all
|
||||
ON paliad.user_card_layouts FOR ALL
|
||||
USING (user_id = auth.uid())
|
||||
WITH CHECK (user_id = auth.uid());
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
197
internal/handlers/broadcasts.go
Normal file
197
internal/handlers/broadcasts.go
Normal file
@@ -0,0 +1,197 @@
|
||||
// broadcasts.go — bulk team-email send (t-paliad-147 / issue #7).
|
||||
//
|
||||
// One write endpoint (/api/team/broadcast) and a pair of read endpoints
|
||||
// for the /admin/broadcasts viewer.
|
||||
//
|
||||
// The /api/team/broadcast handler enforces the project-lead-OR-global_admin
|
||||
// authorisation in BroadcastService.Send, so non-leads receive 403.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// broadcastRequest is the JSON body for POST /api/team/broadcast.
|
||||
//
|
||||
// Recipients carry the addresseelist as resolved on the client side: the
|
||||
// frontend filters the displayed team table, then submits the user_ids the
|
||||
// user wanted to mail. The server validates each address and rejects if
|
||||
// any is malformed.
|
||||
type broadcastRequest struct {
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
TemplateKey string `json:"template_key,omitempty"`
|
||||
Lang string `json:"lang,omitempty"`
|
||||
RecipientFilter map[string]any `json:"recipient_filter,omitempty"`
|
||||
Recipients []broadcastRequestRecipient `json:"recipients"`
|
||||
}
|
||||
|
||||
type broadcastRequestRecipient struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
FirstName string `json:"first_name"`
|
||||
RoleOnProject string `json:"role_on_project"`
|
||||
}
|
||||
|
||||
// POST /api/team/broadcast — dispatch a personalised email to a filtered
|
||||
// team subset. Returns the broadcast ID and per-recipient send report.
|
||||
func handleTeamBroadcast(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
if dbSvc.broadcast == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "broadcasts unavailable — broadcast service not configured",
|
||||
})
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req broadcastRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
|
||||
in := services.BroadcastInput{
|
||||
ProjectID: req.ProjectID,
|
||||
Subject: req.Subject,
|
||||
Body: req.Body,
|
||||
TemplateKey: req.TemplateKey,
|
||||
Lang: req.Lang,
|
||||
RecipientFilter: req.RecipientFilter,
|
||||
Recipients: make([]services.BroadcastRecipient, 0, len(req.Recipients)),
|
||||
}
|
||||
for _, rc := range req.Recipients {
|
||||
in.Recipients = append(in.Recipients, services.BroadcastRecipient{
|
||||
UserID: rc.UserID,
|
||||
Email: rc.Email,
|
||||
DisplayName: rc.DisplayName,
|
||||
FirstName: rc.FirstName,
|
||||
RoleOnProject: rc.RoleOnProject,
|
||||
})
|
||||
}
|
||||
|
||||
report, err := dbSvc.broadcast.Send(r.Context(), uid, in)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrBroadcastForbidden):
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{
|
||||
"error": "only project leads or global admins can send broadcasts",
|
||||
})
|
||||
case errors.Is(err, services.ErrBroadcastNoRecipients):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"error": "no recipients selected",
|
||||
})
|
||||
case errors.Is(err, services.ErrBroadcastTooManyRecipients):
|
||||
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{
|
||||
"error": err.Error(),
|
||||
})
|
||||
case errors.Is(err, services.ErrBroadcastEmptySubject):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"error": "subject is required",
|
||||
})
|
||||
case errors.Is(err, services.ErrBroadcastEmptyBody):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"error": "body is required",
|
||||
})
|
||||
case errors.Is(err, services.ErrBroadcastInvalidEmail):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"error": err.Error(),
|
||||
})
|
||||
default:
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "failed to send broadcast",
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, report)
|
||||
}
|
||||
|
||||
// GET /api/admin/broadcasts — list broadcasts visible to the caller.
|
||||
// global_admin sees all rows; senders see their own.
|
||||
//
|
||||
// Lives behind the gateOnboarded gate (not adminGate) so a project lead
|
||||
// who's never been promoted to global_admin can still see their own
|
||||
// sends.
|
||||
func handleListBroadcasts(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
if dbSvc.broadcast == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "broadcasts unavailable",
|
||||
})
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
limit := 50
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if parsed, err := strconv.Atoi(v); err == nil {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
rows, err := dbSvc.broadcast.List(r.Context(), uid, limit)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrBroadcastForbidden) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// GET /api/admin/broadcasts/{id} — full detail for one broadcast.
|
||||
func handleGetBroadcast(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
if dbSvc.broadcast == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "broadcasts unavailable",
|
||||
})
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
detail, err := dbSvc.broadcast.Get(r.Context(), uid, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrBroadcastForbidden) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, detail)
|
||||
}
|
||||
|
||||
// GET /admin/broadcasts — server-rendered shell.
|
||||
func handleAdminBroadcastsPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/admin-broadcasts.html")
|
||||
}
|
||||
185
internal/handlers/card_layouts.go
Normal file
185
internal/handlers/card_layouts.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// GET /api/user-card-layouts — list the user's named card layouts (default first).
|
||||
func handleListCardLayouts(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.cardLayout == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "card-layout service not configured"})
|
||||
return
|
||||
}
|
||||
rows, err := dbSvc.cardLayout.List(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
type createCardLayoutBody struct {
|
||||
Name string `json:"name"`
|
||||
Layout services.LayoutSpec `json:"layout"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
}
|
||||
|
||||
// POST /api/user-card-layouts — create a new named layout.
|
||||
func handleCreateCardLayout(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.cardLayout == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "card-layout service not configured"})
|
||||
return
|
||||
}
|
||||
var body createCardLayoutBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
row, err := dbSvc.cardLayout.Create(r.Context(), uid, services.CreateCardLayoutInput{
|
||||
Name: body.Name,
|
||||
Layout: body.Layout,
|
||||
IsDefault: body.IsDefault,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrUserCardLayoutNameTaken) {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "name already exists"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, row)
|
||||
}
|
||||
|
||||
type updateCardLayoutBody struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Layout *services.LayoutSpec `json:"layout,omitempty"`
|
||||
IsDefault *bool `json:"is_default,omitempty"`
|
||||
}
|
||||
|
||||
// PATCH /api/user-card-layouts/{id} — partial update.
|
||||
func handleUpdateCardLayout(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.cardLayout == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "card-layout service not configured"})
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var body updateCardLayoutBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
row, err := dbSvc.cardLayout.Update(r.Context(), uid, id, services.UpdateCardLayoutInput{
|
||||
Name: body.Name,
|
||||
Layout: body.Layout,
|
||||
IsDefault: body.IsDefault,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrUserCardLayoutNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, services.ErrUserCardLayoutNameTaken) {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "name already exists"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
}
|
||||
|
||||
// DELETE /api/user-card-layouts/{id} — remove a named layout. The active
|
||||
// default cannot be deleted (return 409); the UI gates this.
|
||||
func handleDeleteCardLayout(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.cardLayout == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "card-layout service not configured"})
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
if err := dbSvc.cardLayout.Delete(r.Context(), uid, id); err != nil {
|
||||
if errors.Is(err, services.ErrUserCardLayoutNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, services.ErrUserCardLayoutDefaultGate) {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "cannot delete default layout — switch defaults first"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// POST /api/user-card-layouts/{id}/set-default — sugar over PATCH .{is_default:true}.
|
||||
func handleSetDefaultCardLayout(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.cardLayout == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "card-layout service not configured"})
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
row, err := dbSvc.cardLayout.SetDefault(r.Context(), uid, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrUserCardLayoutNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// GET /api/events?type=deadline|appointment|all&status=…&project_id=…&event_type=…&type_filter=…&from=…&to=…&personal_only=true
|
||||
// GET /api/events?type=deadline|appointment|all&status=…&project_id=…&event_type=…&type_filter=…&from=…&to=…&personal_only=true&direct_only=true
|
||||
//
|
||||
// type — discriminator for the union; default "all" (Beides on the
|
||||
// front-end). When "deadline" or "appointment", the matching
|
||||
@@ -25,6 +25,10 @@ import (
|
||||
// personal_only — narrow BOTH rails to rows the caller created
|
||||
// (t-paliad-128). Mutually exclusive with project_id —
|
||||
// if both are sent, project_id is ignored.
|
||||
// direct_only — narrow project_id from "this project + every descendant"
|
||||
// (t-paliad-139 subtree default) to "this project only"
|
||||
// (t-paliad-152). No effect when project_id is unset or
|
||||
// personal_only=true.
|
||||
func handleListEvents(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
@@ -39,6 +43,7 @@ func handleListEvents(w http.ResponseWriter, r *http.Request) {
|
||||
Type: parseEventTypeDiscriminator(q.Get("type")),
|
||||
Status: services.DeadlineStatusFilter(q.Get("status")),
|
||||
PersonalOnly: parseBoolFlag(q.Get("personal_only")),
|
||||
DirectOnly: parseDirectOnly(q.Get("direct_only")),
|
||||
}
|
||||
|
||||
if raw := q.Get("project_id"); raw != "" {
|
||||
@@ -86,7 +91,11 @@ func handleListEvents(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// GET /api/events/summary?type=deadline|appointment|all&project_id=…&personal_only=true
|
||||
// GET /api/events/summary?type=deadline|appointment|all&project_id=…&personal_only=true&direct_only=true
|
||||
//
|
||||
// direct_only mirrors /api/events — narrows project_id to the direct project
|
||||
// (no descendants) when truthy. No effect when project_id is unset or
|
||||
// personal_only=true.
|
||||
func handleEventsSummary(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
@@ -100,6 +109,7 @@ func handleEventsSummary(w http.ResponseWriter, r *http.Request) {
|
||||
filter := services.EventSummaryFilter{
|
||||
Type: parseEventTypeDiscriminator(q.Get("type")),
|
||||
PersonalOnly: parseBoolFlag(q.Get("personal_only")),
|
||||
DirectOnly: parseDirectOnly(q.Get("direct_only")),
|
||||
}
|
||||
if raw := q.Get("project_id"); raw != "" {
|
||||
projectID, err := uuid.Parse(raw)
|
||||
|
||||
@@ -62,12 +62,27 @@ type Services struct {
|
||||
Link *services.LinkService
|
||||
Event *services.EventService
|
||||
Courts *services.CourtService
|
||||
Approval *services.ApprovalService
|
||||
Derivation *services.DerivationService
|
||||
UserView *services.UserViewService
|
||||
Broadcast *services.BroadcastService
|
||||
Pin *services.PinService
|
||||
CardLayout *services.CardLayoutService
|
||||
|
||||
// Paliadin is wired only when PALIADIN_ENABLED=true at boot
|
||||
// (PoC; m's laptop only). On prod it stays nil and all /paliadin*
|
||||
// routes 404 because Register() skips registering them.
|
||||
Paliadin *services.PaliadinService
|
||||
}
|
||||
|
||||
func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc *Services) {
|
||||
authClient = client
|
||||
giteaToken = giteaAPIToken
|
||||
|
||||
if svc != nil && svc.Paliadin != nil {
|
||||
paliadinSvc = svc.Paliadin
|
||||
}
|
||||
|
||||
if svc != nil {
|
||||
dbSvc = &dbServices{
|
||||
projects: svc.Project,
|
||||
@@ -96,6 +111,12 @@ 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,
|
||||
userView: svc.UserView,
|
||||
broadcast: svc.Broadcast,
|
||||
pin: svc.Pin,
|
||||
cardLayout: svc.CardLayout,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +214,15 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /api/projects/{id}/events", handleListProjectEvents)
|
||||
protected.HandleFunc("GET /api/projects/{id}/children", handleListProjectChildren)
|
||||
protected.HandleFunc("GET /api/projects/{id}/tree", handleGetProjectTree)
|
||||
protected.HandleFunc("POST /api/projects/{id}/pin", handlePinProject)
|
||||
protected.HandleFunc("DELETE /api/projects/{id}/pin", handleUnpinProject)
|
||||
protected.HandleFunc("GET /api/user-pinned-projects", handleListPinnedProjects)
|
||||
protected.HandleFunc("GET /api/projects/cards-preview", handleProjectsCardsPreview)
|
||||
protected.HandleFunc("GET /api/user-card-layouts", handleListCardLayouts)
|
||||
protected.HandleFunc("POST /api/user-card-layouts", handleCreateCardLayout)
|
||||
protected.HandleFunc("PATCH /api/user-card-layouts/{id}", handleUpdateCardLayout)
|
||||
protected.HandleFunc("DELETE /api/user-card-layouts/{id}", handleDeleteCardLayout)
|
||||
protected.HandleFunc("POST /api/user-card-layouts/{id}/set-default", handleSetDefaultCardLayout)
|
||||
protected.HandleFunc("GET /api/projects/{id}/ancestors", handleListProjectAncestors)
|
||||
protected.HandleFunc("GET /api/projects/{id}/parties", handleListParties)
|
||||
protected.HandleFunc("POST /api/projects/{id}/parties", handleCreateParty)
|
||||
@@ -200,6 +230,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 +247,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)
|
||||
|
||||
@@ -326,6 +365,16 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// Team directory — browsable list of all onboarded users (t-paliad-029).
|
||||
protected.HandleFunc("GET /team", gateOnboarded(handleTeamPage))
|
||||
|
||||
// t-paliad-147 — bulk team-email broadcast.
|
||||
// /api/team/broadcast: project lead OR global_admin → BroadcastService gates.
|
||||
// /admin/broadcasts page + list/detail API: visibility-gated in service
|
||||
// (global_admin sees all; sender sees own).
|
||||
protected.HandleFunc("GET /api/team/memberships", gateOnboarded(handleListMembershipsIndex))
|
||||
protected.HandleFunc("POST /api/team/broadcast", gateOnboarded(handleTeamBroadcast))
|
||||
protected.HandleFunc("GET /admin/broadcasts", gateOnboarded(handleAdminBroadcastsPage))
|
||||
protected.HandleFunc("GET /api/admin/broadcasts", gateOnboarded(handleListBroadcasts))
|
||||
protected.HandleFunc("GET /api/admin/broadcasts/{id}", gateOnboarded(handleGetBroadcast))
|
||||
|
||||
// Settings
|
||||
protected.HandleFunc("GET /settings", gateOnboarded(handleSettingsPage))
|
||||
protected.HandleFunc("GET /settings/{tab}", handleSettingsTabRedirect)
|
||||
@@ -366,8 +415,66 @@ 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)
|
||||
}
|
||||
|
||||
// t-paliad-144 Phase A1+A2 — Custom Views (substrate + user_views CRUD
|
||||
// + page shells). API endpoints register when the substrate services are
|
||||
// wired; page shells register unconditionally so /views itself stays
|
||||
// reachable for the empty-state onboarding.
|
||||
if svc != nil && svc.UserView != nil && svc.Event != nil {
|
||||
// API
|
||||
protected.HandleFunc("GET /api/user-views", handleListUserViews)
|
||||
protected.HandleFunc("POST /api/user-views", handleCreateUserView)
|
||||
protected.HandleFunc("GET /api/user-views/{id}", handleGetUserView)
|
||||
protected.HandleFunc("PATCH /api/user-views/{id}", handleUpdateUserView)
|
||||
protected.HandleFunc("DELETE /api/user-views/{id}", handleDeleteUserView)
|
||||
protected.HandleFunc("POST /api/user-views/{id}/touch", handleTouchUserView)
|
||||
|
||||
protected.HandleFunc("POST /api/views/run", handleRunAdhocView)
|
||||
protected.HandleFunc("POST /api/views/{slug}/run", handleRunSavedView)
|
||||
protected.HandleFunc("GET /api/views/system", handleListSystemViews)
|
||||
|
||||
// Page shells (A2)
|
||||
protected.HandleFunc("GET /views", gateOnboarded(handleViewsLandingPage))
|
||||
protected.HandleFunc("GET /views/new", gateOnboarded(handleViewsNewPage))
|
||||
protected.HandleFunc("GET /views/{slug}/edit", gateOnboarded(handleViewsEditPage))
|
||||
protected.HandleFunc("GET /views/{slug}", gateOnboarded(handleViewsShellPage))
|
||||
}
|
||||
|
||||
// t-paliad-146 — Paliadin (PoC). Routes register unconditionally;
|
||||
// the per-request handler gate (requirePaliadinOwner) returns 404
|
||||
// for any authenticated user other than services.PaliadinOwnerEmail.
|
||||
// No deploy-time toggle — the gate is in the code, not in the env.
|
||||
protected.HandleFunc("GET /paliadin", gateOnboarded(handlePaliadinPage))
|
||||
protected.HandleFunc("POST /api/paliadin/turn", handlePaliadinTurn)
|
||||
protected.HandleFunc("GET /api/paliadin/stream/{id}", handlePaliadinStream)
|
||||
protected.HandleFunc("POST /api/paliadin/reset", handlePaliadinReset)
|
||||
protected.HandleFunc("GET /admin/paliadin", gateOnboarded(handleAdminPaliadinPage))
|
||||
protected.HandleFunc("GET /api/admin/paliadin/stats", handleAdminPaliadinStats)
|
||||
protected.HandleFunc("GET /api/admin/paliadin/turns", handleAdminPaliadinTurns)
|
||||
|
||||
// Catch-all 404 — runs for any authenticated path that no more-specific
|
||||
// pattern claimed. Renders the chromed shell with HTTP 404 (Bug 9 from
|
||||
// tests/smoke-auth-2026-04-25.md). Must be registered last on this mux.
|
||||
@@ -391,3 +498,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
|
||||
}
|
||||
}
|
||||
|
||||
351
internal/handlers/paliadin.go
Normal file
351
internal/handlers/paliadin.go
Normal file
@@ -0,0 +1,351 @@
|
||||
package handlers
|
||||
|
||||
// paliadin.go — HTTP/SSE handlers for the Paliadin PoC (t-paliad-146).
|
||||
//
|
||||
// Design: docs/design-paliadin-2026-05-07.md §0.5.
|
||||
//
|
||||
// Three user-facing surfaces:
|
||||
// GET /paliadin — chat panel page shell
|
||||
// POST /api/paliadin/turn — initiate a turn, returns {turn_id, sse_url}
|
||||
// GET /api/paliadin/stream/{id} — SSE stream of the turn
|
||||
// POST /api/paliadin/reset — /clear the conversation
|
||||
// GET /admin/paliadin — monitoring dashboard (global_admin)
|
||||
// GET /api/admin/paliadin/stats — stats JSON
|
||||
// GET /api/admin/paliadin/turns — recent turns JSON
|
||||
//
|
||||
// Routes register only when the PaliadinService is wired (which only
|
||||
// happens when PALIADIN_ENABLED=true at boot). On prod, where it's
|
||||
// false by default, none of these URLs exist — they 404 like any
|
||||
// unrouted path.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// newDetachedContext returns a context with timeout that is independent
|
||||
// of any incoming request — needed so the Claude-via-tmux turn isn't
|
||||
// cancelled when the originating POST returns ahead of the SSE stream.
|
||||
func newDetachedContext(timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), timeout)
|
||||
}
|
||||
|
||||
// paliadinSvc is the live PaliadinService instance. nil when
|
||||
// DATABASE_URL was unset (the service depends on the audit table).
|
||||
// Set by Register() at boot.
|
||||
var paliadinSvc *services.PaliadinService
|
||||
|
||||
// requirePaliadinOwner gates every paliadin handler to the single
|
||||
// owner email (services.PaliadinOwnerEmail = m). Anyone else gets a
|
||||
// 404 — the gate is a "this URL doesn't exist for you" pretence
|
||||
// rather than a 403, so a curious colleague can't even confirm the
|
||||
// route is wired.
|
||||
func requirePaliadinOwner(w http.ResponseWriter, r *http.Request) bool {
|
||||
if paliadinSvc == nil {
|
||||
http.NotFound(w, r)
|
||||
return false
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
owner, err := paliadinSvc.IsOwner(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return false
|
||||
}
|
||||
if !owner {
|
||||
http.NotFound(w, r)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// pendingTurns is an in-memory map of turn_id → result channel. The POST
|
||||
// /api/paliadin/turn endpoint kicks off the work + writes a synthetic
|
||||
// turn record; the GET /api/paliadin/stream/{id} endpoint reads from
|
||||
// the channel + emits SSE events. PoC scope: single user, in-process
|
||||
// state. Production v1 would use a Postgres-backed queue or pgnotify.
|
||||
var (
|
||||
pendingMu sync.Mutex
|
||||
pendingTurns = map[uuid.UUID]chan turnEvent{}
|
||||
)
|
||||
|
||||
// turnEvent is one SSE event for a turn-in-flight.
|
||||
type turnEvent struct {
|
||||
Kind string `json:"kind"` // meta | content | end | error
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
|
||||
// turnRequest is the JSON body of POST /api/paliadin/turn.
|
||||
type turnRequest struct {
|
||||
UserMessage string `json:"user_message"`
|
||||
SessionID string `json:"session_id"`
|
||||
PageOrigin string `json:"page_origin,omitempty"`
|
||||
}
|
||||
|
||||
// turnResponse is what POST /api/paliadin/turn returns.
|
||||
type turnResponse struct {
|
||||
TurnID string `json:"turn_id"`
|
||||
SSEURL string `json:"sse_url"`
|
||||
}
|
||||
|
||||
// handlePaliadinPage serves the static /paliadin chat panel. Gated to
|
||||
// the single Paliadin owner (m); every other authenticated user gets
|
||||
// a 404 — the route effectively does not exist for them.
|
||||
func handlePaliadinPage(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePaliadinOwner(w, r) {
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, "dist/paliadin.html")
|
||||
}
|
||||
|
||||
// handleAdminPaliadinPage serves the /admin/paliadin monitoring page.
|
||||
// Same owner gate — even other global_admins can't see this surface.
|
||||
func handleAdminPaliadinPage(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePaliadinOwner(w, r) {
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, "dist/admin-paliadin.html")
|
||||
}
|
||||
|
||||
// handlePaliadinTurn kicks off a turn and returns the SSE URL.
|
||||
//
|
||||
// We don't block here; the actual Claude work runs in a goroutine that
|
||||
// pushes events into the per-turn channel. The client immediately opens
|
||||
// EventSource on the returned URL and reads as the goroutine writes.
|
||||
func handlePaliadinTurn(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePaliadinOwner(w, r) {
|
||||
return
|
||||
}
|
||||
uid, _ := requireUser(w, r) // already validated by requirePaliadinOwner
|
||||
var req turnRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
if req.UserMessage == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user_message required"})
|
||||
return
|
||||
}
|
||||
if req.SessionID == "" {
|
||||
req.SessionID = uuid.New().String()
|
||||
}
|
||||
|
||||
turnID := uuid.New()
|
||||
ch := make(chan turnEvent, 16)
|
||||
pendingMu.Lock()
|
||||
pendingTurns[turnID] = ch
|
||||
pendingMu.Unlock()
|
||||
|
||||
// Goroutine drives the actual Claude turn. We use a fresh context
|
||||
// (not r.Context()) because the request is going to return as soon
|
||||
// as we hand back the SSE URL — we don't want the whole turn to
|
||||
// cancel when the POST completes.
|
||||
go runPaliadinTurnAsync(turnID, services.TurnRequest{
|
||||
UserID: uid,
|
||||
SessionID: req.SessionID,
|
||||
UserMessage: req.UserMessage,
|
||||
PageOrigin: req.PageOrigin,
|
||||
}, ch)
|
||||
|
||||
writeJSON(w, http.StatusOK, turnResponse{
|
||||
TurnID: turnID.String(),
|
||||
SSEURL: "/api/paliadin/stream/" + turnID.String(),
|
||||
})
|
||||
}
|
||||
|
||||
// runPaliadinTurnAsync executes the turn and writes events into ch.
|
||||
// Uses a 2-minute hard timeout independently of the originating request.
|
||||
func runPaliadinTurnAsync(turnID uuid.UUID, req services.TurnRequest, ch chan<- turnEvent) {
|
||||
defer func() {
|
||||
// Drain + close. The SSE handler reads until the channel closes.
|
||||
close(ch)
|
||||
}()
|
||||
|
||||
// Send a meta event so the client can show "Paliadin denkt nach …"
|
||||
send(ch, turnEvent{
|
||||
Kind: "meta",
|
||||
Data: map[string]any{
|
||||
"turn_id": turnID.String(),
|
||||
"started_at": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
})
|
||||
|
||||
ctx, cancel := newDetachedContext(120 * time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := paliadinSvc.RunTurn(ctx, req)
|
||||
if err != nil {
|
||||
errCode := "upstream_error"
|
||||
if errors.Is(err, services.ErrTmuxUnavailable) {
|
||||
errCode = "tmux_unavailable"
|
||||
}
|
||||
send(ch, turnEvent{
|
||||
Kind: "error",
|
||||
Data: map[string]any{"code": errCode, "message": err.Error()},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// One-shot content event with the full body. The frontend simulates
|
||||
// streaming with a typewriter effect (cf. design §0.5.5: real
|
||||
// chunked streaming would require Claude to write the response file
|
||||
// progressively — out of PoC scope).
|
||||
send(ch, turnEvent{
|
||||
Kind: "content",
|
||||
Data: map[string]any{"text": result.Response},
|
||||
})
|
||||
|
||||
send(ch, turnEvent{
|
||||
Kind: "end",
|
||||
Data: map[string]any{
|
||||
"turn_id": turnID.String(),
|
||||
"used_tools": result.UsedTools,
|
||||
"rows_seen": result.RowsSeen,
|
||||
"chip_count": result.ChipCount,
|
||||
"classifier_tag": result.ClassifierTag,
|
||||
"duration_ms": result.DurationMS,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// handlePaliadinStream is the SSE endpoint the EventSource subscribes
|
||||
// to. Reads from the per-turn channel + writes SSE-framed events.
|
||||
func handlePaliadinStream(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePaliadinOwner(w, r) {
|
||||
return
|
||||
}
|
||||
turnIDStr := r.PathValue("id")
|
||||
turnID, err := uuid.Parse(turnIDStr)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid turn_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
pendingMu.Lock()
|
||||
ch, ok := pendingTurns[turnID]
|
||||
pendingMu.Unlock()
|
||||
if !ok {
|
||||
http.Error(w, "unknown turn_id (already finished, or never started)", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// SSE headers.
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-transform")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no") // disable nginx/Traefik buffering
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Heartbeat ticker keeps reverse proxies from reaping the connection.
|
||||
heartbeat := time.NewTicker(25 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
// Client closed the connection — don't drain, leave the
|
||||
// channel for the goroutine to finish into. Pending-turns
|
||||
// cleanup happens after end/error events flush below.
|
||||
return
|
||||
case <-heartbeat.C:
|
||||
fmt.Fprint(w, "event: ping\ndata: {}\n\n")
|
||||
flusher.Flush()
|
||||
case ev, more := <-ch:
|
||||
if !more {
|
||||
// Goroutine finished. Tidy up the pending-turns map.
|
||||
pendingMu.Lock()
|
||||
delete(pendingTurns, turnID)
|
||||
pendingMu.Unlock()
|
||||
return
|
||||
}
|
||||
payload, _ := json.Marshal(ev.Data)
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Kind, payload)
|
||||
flusher.Flush()
|
||||
if ev.Kind == "end" || ev.Kind == "error" {
|
||||
// Don't return immediately — wait for the channel to
|
||||
// close so the cleanup branch above runs and the client
|
||||
// gets a clean EOF.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handlePaliadinReset clears the Claude conversation context.
|
||||
func handlePaliadinReset(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePaliadinOwner(w, r) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := newDetachedContext(10 * time.Second)
|
||||
defer cancel()
|
||||
if err := paliadinSvc.ResetSession(ctx); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "reset failed: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// /admin/paliadin — monitoring dashboard.
|
||||
// =============================================================================
|
||||
|
||||
// handleAdminPaliadinStats returns the aggregate stats for the dashboard.
|
||||
func handleAdminPaliadinStats(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePaliadinOwner(w, r) {
|
||||
return
|
||||
}
|
||||
uid, _ := requireUser(w, r)
|
||||
stats, err := paliadinSvc.Stats(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// handleAdminPaliadinTurns returns the most recent turn rows.
|
||||
func handleAdminPaliadinTurns(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePaliadinOwner(w, r) {
|
||||
return
|
||||
}
|
||||
uid, _ := requireUser(w, r)
|
||||
turns, err := paliadinSvc.ListRecentTurns(r.Context(), uid, 50)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, turns)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// helpers.
|
||||
// =============================================================================
|
||||
|
||||
// send pushes an event onto the channel without blocking — drops on
|
||||
// overflow. PoC scope: 16-deep buffer, single subscriber, very unlikely
|
||||
// to overflow even with the slow Claude-via-tmux path.
|
||||
func send(ch chan<- turnEvent, ev turnEvent) {
|
||||
select {
|
||||
case ch <- ev:
|
||||
default:
|
||||
// Channel full — drop. Logged by the SSE consumer's gap (it
|
||||
// keeps reading; only "end"/"error" matter for completion).
|
||||
}
|
||||
}
|
||||
91
internal/handlers/pins.go
Normal file
91
internal/handlers/pins.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// POST /api/projects/{id}/pin — idempotent pin. Returns 201 (or 200 if
|
||||
// already pinned). Empty body. Visibility-gated by PinService.
|
||||
func handlePinProject(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
if dbSvc.pin == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "pin service not configured"})
|
||||
return
|
||||
}
|
||||
if err := dbSvc.pin.Pin(r.Context(), uid, id); err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
// DELETE /api/projects/{id}/pin — idempotent unpin. Always returns 204.
|
||||
// Does NOT gate on visibility (so users can clean up pins on projects
|
||||
// they've lost access to).
|
||||
func handleUnpinProject(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
if dbSvc.pin == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "pin service not configured"})
|
||||
return
|
||||
}
|
||||
if err := dbSvc.pin.Unpin(r.Context(), uid, id); err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GET /api/user-pinned-projects — flat list of project IDs the user has
|
||||
// pinned, most-recent-pin first. Used by the (deferred) sidebar pin widget;
|
||||
// the /projects tree response carries `pinned: bool` per node, so the
|
||||
// /projects page itself doesn't hit this endpoint.
|
||||
func handleListPinnedProjects(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.pin == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "pin service not configured"})
|
||||
return
|
||||
}
|
||||
ids, err := dbSvc.pin.ListPinned(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
type row struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
}
|
||||
out := make([]row, len(ids))
|
||||
for i, id := range ids {
|
||||
out[i] = row{ProjectID: id}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
@@ -42,6 +43,12 @@ type dbServices struct {
|
||||
link *services.LinkService
|
||||
event *services.EventService
|
||||
courts *services.CourtService
|
||||
approval *services.ApprovalService
|
||||
derivation *services.DerivationService
|
||||
userView *services.UserViewService
|
||||
broadcast *services.BroadcastService
|
||||
pin *services.PinService
|
||||
cardLayout *services.CardLayoutService
|
||||
}
|
||||
|
||||
var dbSvc *dbServices
|
||||
@@ -241,6 +248,17 @@ func handleListProjectChildren(w http.ResponseWriter, r *http.Request) {
|
||||
// GET /api/projects/tree — nested tree of every visible Project. Each node
|
||||
// carries open/overdue deadline counts and embedded children so the UI can
|
||||
// render the full hierarchy in one round-trip. Visibility-scoped.
|
||||
//
|
||||
// Query parameters (all optional, additive):
|
||||
// ?scope=all|mine|pinned — chip-driven scope (default "all")
|
||||
// ?status=active,archived,closed — status whitelist (CSV; default = no narrowing)
|
||||
// ?type=client,litigation,patent,case,project — type whitelist
|
||||
// ?has_open_deadlines=true|false — narrow by deadline activity
|
||||
// ?q=<term> — search title / reference / clientmatter
|
||||
// ?subtree_counts=true|false — populate *_subtree fields (default true)
|
||||
//
|
||||
// Zero query string preserves the legacy behaviour for back-compat (existing
|
||||
// callers that just want every visible project).
|
||||
func handleGetProjectsTree(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
@@ -249,7 +267,41 @@ func handleGetProjectsTree(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
tree, err := dbSvc.projects.BuildTree(r.Context(), uid)
|
||||
|
||||
q := r.URL.Query()
|
||||
opts := services.BuildTreeOptions{
|
||||
IncludeSubtreeCounts: parseBoolQuery(q.Get("subtree_counts"), true),
|
||||
SearchTerm: q.Get("q"),
|
||||
StatusIn: splitCSV(q.Get("status")),
|
||||
TypeIn: splitCSV(q.Get("type")),
|
||||
}
|
||||
switch q.Get("scope") {
|
||||
case "mine":
|
||||
opts.Scope = services.ScopeMine
|
||||
case "pinned":
|
||||
opts.Scope = services.ScopePinned
|
||||
}
|
||||
if v := q.Get("has_open_deadlines"); v != "" {
|
||||
b := parseBoolQuery(v, false)
|
||||
opts.HasOpenDeadlines = &b
|
||||
}
|
||||
|
||||
// Pin set is needed when the response carries `pinned: bool` per node
|
||||
// (always, when PinService is wired) AND when scope=pinned narrows.
|
||||
if dbSvc.pin != nil {
|
||||
set, err := dbSvc.pin.PinnedSet(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
opts.PinnedSet = set
|
||||
} else if opts.Scope == services.ScopePinned {
|
||||
// scope=pinned without PinService can never have hits.
|
||||
writeJSON(w, http.StatusOK, []any{})
|
||||
return
|
||||
}
|
||||
|
||||
tree, err := dbSvc.projects.BuildTreeWithOptions(r.Context(), uid, opts)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
@@ -257,6 +309,81 @@ func handleGetProjectsTree(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, tree)
|
||||
}
|
||||
|
||||
// parseBoolQuery accepts true/false/1/0/yes/no/on/off (case-insensitive).
|
||||
// Falls back to def for empty / unrecognised input.
|
||||
func parseBoolQuery(v string, def bool) bool {
|
||||
switch v {
|
||||
case "true", "1", "yes", "on":
|
||||
return true
|
||||
case "false", "0", "no", "off":
|
||||
return false
|
||||
default:
|
||||
return def
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/projects/cards-preview — per-project event rollups for the
|
||||
// Cards view. Returns a flat list of {project_id, next_events,
|
||||
// recent_verlauf, team_initials, team_count, last_activity_at} for every
|
||||
// project the user can see (or the subset given via ?ids=<csv-of-uuids>).
|
||||
//
|
||||
// Visibility-scoped server-side. Caller (Cards mode) lazy-fetches batches
|
||||
// via IntersectionObserver.
|
||||
func handleProjectsCardsPreview(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var ids []uuid.UUID
|
||||
if raw := r.URL.Query().Get("ids"); raw != "" {
|
||||
for _, s := range splitCSV(raw) {
|
||||
u, err := uuid.Parse(s)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid uuid in ?ids"})
|
||||
return
|
||||
}
|
||||
ids = append(ids, u)
|
||||
}
|
||||
}
|
||||
|
||||
previews, err := dbSvc.projects.CardsPreview(r.Context(), uid, ids)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
// Flat array for JSON; the map order is irrelevant to the client (it
|
||||
// keys on project_id when stitching to its tree-id list).
|
||||
out := make([]*services.ProjectCardPreview, 0, len(previews))
|
||||
for _, p := range previews {
|
||||
out = append(out, p)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// splitCSV splits a comma-separated query value into trimmed non-empty
|
||||
// tokens. Empty input → nil so callers can branch on `len(out) > 0`.
|
||||
func splitCSV(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GET /api/projects/{id}/tree — full subtree depth-first (path-ordered).
|
||||
func handleGetProjectTree(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
@@ -382,7 +509,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
|
||||
|
||||
@@ -33,7 +33,11 @@ func handleListProjectTeam(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// POST /api/projects/{id}/team — add a direct member.
|
||||
// Body: {"user_id": "<uuid>", "role": "<role>"}
|
||||
// Body: {"user_id": "<uuid>", "responsibility": "<lead|member|observer|external>"}
|
||||
//
|
||||
// Legacy clients that submit `role` are still accepted as a synonym
|
||||
// during the deprecation window; the field is treated as a
|
||||
// responsibility value when the new field is absent.
|
||||
func handleAddProjectTeamMember(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
@@ -48,14 +52,20 @@ func handleAddProjectTeamMember(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Responsibility string `json:"responsibility"`
|
||||
// Legacy field, accepted for one release while frontend migrates.
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
m, err := dbSvc.team.AddMember(r.Context(), uid, projectID, body.UserID, body.Role)
|
||||
resp := body.Responsibility
|
||||
if resp == "" {
|
||||
resp = body.Role
|
||||
}
|
||||
m, err := dbSvc.team.AddMember(r.Context(), uid, projectID, body.UserID, resp)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
@@ -63,6 +73,26 @@ func handleAddProjectTeamMember(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, m)
|
||||
}
|
||||
|
||||
// GET /api/team/memberships — bulk index of project_teams membership for
|
||||
// every (visible) user × project pair. Powers the /team page project-
|
||||
// multi-select filter (t-paliad-147 / issue #7). Cheap to call: one
|
||||
// scan per call; client-side filter handles everything from there.
|
||||
func handleListMembershipsIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := dbSvc.team.ListMembershipsIndex(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// DELETE /api/projects/{id}/team/{user_id} — remove a direct member.
|
||||
// Inherited memberships can't be removed at the child level.
|
||||
func handleRemoveProjectTeamMember(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
393
internal/handlers/views.go
Normal file
393
internal/handlers/views.go
Normal file
@@ -0,0 +1,393 @@
|
||||
package handlers
|
||||
|
||||
// HTTP handlers for the Custom Views feature (t-paliad-144 Phase A1).
|
||||
//
|
||||
// Endpoints:
|
||||
// GET /api/user-views — list saved views
|
||||
// POST /api/user-views — create
|
||||
// GET /api/user-views/{id} — fetch one
|
||||
// PATCH /api/user-views/{id} — partial update
|
||||
// DELETE /api/user-views/{id} — delete
|
||||
// POST /api/user-views/{id}/touch — bump last_used_at
|
||||
//
|
||||
// POST /api/views/run — run an ad-hoc spec
|
||||
// POST /api/views/{slug}/run — run a saved view by slug
|
||||
// GET /api/views/system — list system view definitions
|
||||
//
|
||||
// All endpoints require authentication. Paliad's RLS scopes user_views
|
||||
// rows to auth.uid(); the handler layer also AND-joins userID for
|
||||
// defense-in-depth.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// requireUserViews returns true when the user-view + substrate services
|
||||
// are wired. Calls writeJSON 503 + returns false otherwise.
|
||||
func requireUserViews(w http.ResponseWriter) bool {
|
||||
if !requireDB(w) {
|
||||
return false
|
||||
}
|
||||
if dbSvc.userView == nil || dbSvc.event == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "views not configured",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// /api/user-views — CRUD
|
||||
// ============================================================================
|
||||
|
||||
// GET /api/user-views — list the caller's saved views.
|
||||
func handleListUserViews(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireUserViews(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
views, err := dbSvc.userView.ListForUser(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, views)
|
||||
}
|
||||
|
||||
// userViewCreatePayload mirrors services.CreateUserViewInput on the wire.
|
||||
type userViewCreatePayload struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
FilterSpec services.FilterSpec `json:"filter_spec"`
|
||||
RenderSpec services.RenderSpec `json:"render_spec"`
|
||||
ShowCount bool `json:"show_count,omitempty"`
|
||||
}
|
||||
|
||||
// POST /api/user-views — create.
|
||||
func handleCreateUserView(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireUserViews(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var p userViewCreatePayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
created, err := dbSvc.userView.Create(r.Context(), uid, services.CreateUserViewInput{
|
||||
Slug: p.Slug,
|
||||
Name: p.Name,
|
||||
Icon: p.Icon,
|
||||
FilterSpec: p.FilterSpec,
|
||||
RenderSpec: p.RenderSpec,
|
||||
ShowCount: p.ShowCount,
|
||||
})
|
||||
if err != nil {
|
||||
writeUserViewError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
// GET /api/user-views/{id} — fetch one.
|
||||
func handleGetUserView(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireUserViews(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
view, err := dbSvc.userView.GetByID(r.Context(), uid, id)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if view == nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// userViewUpdatePayload accepts every field as optional. `null` icon
|
||||
// clears the field (matching service-side semantic of *string{""} → clear).
|
||||
type userViewUpdatePayload struct {
|
||||
Slug *string `json:"slug,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
FilterSpec *services.FilterSpec `json:"filter_spec,omitempty"`
|
||||
RenderSpec *services.RenderSpec `json:"render_spec,omitempty"`
|
||||
SortOrder *int `json:"sort_order,omitempty"`
|
||||
ShowCount *bool `json:"show_count,omitempty"`
|
||||
}
|
||||
|
||||
// PATCH /api/user-views/{id} — partial update.
|
||||
func handleUpdateUserView(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireUserViews(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var p userViewUpdatePayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
updated, err := dbSvc.userView.Update(r.Context(), uid, id, services.UpdateUserViewInput{
|
||||
Slug: p.Slug,
|
||||
Name: p.Name,
|
||||
Icon: p.Icon,
|
||||
FilterSpec: p.FilterSpec,
|
||||
RenderSpec: p.RenderSpec,
|
||||
SortOrder: p.SortOrder,
|
||||
ShowCount: p.ShowCount,
|
||||
})
|
||||
if err != nil {
|
||||
writeUserViewError(w, err)
|
||||
return
|
||||
}
|
||||
if updated == nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
// DELETE /api/user-views/{id} — delete.
|
||||
func handleDeleteUserView(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireUserViews(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
deleted, err := dbSvc.userView.Delete(r.Context(), uid, id)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if !deleted {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// POST /api/user-views/{id}/touch — bump last_used_at. Fire-and-forget
|
||||
// from the page handler (Q10 most-recently-used landing).
|
||||
func handleTouchUserView(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireUserViews(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
if err := dbSvc.userView.Touch(r.Context(), uid, id); err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// /api/views — substrate execution
|
||||
// ============================================================================
|
||||
|
||||
// runRequest wraps the optional spec override for /api/views/{slug}/run.
|
||||
// When body is empty / fields are zero-valued, the saved spec is used as-is.
|
||||
type runRequest struct {
|
||||
Filter *services.FilterSpec `json:"filter,omitempty"`
|
||||
Render *services.RenderSpec `json:"render,omitempty"` // currently informational; substrate ignores
|
||||
}
|
||||
|
||||
// POST /api/views/run — execute an ad-hoc FilterSpec without persisting.
|
||||
//
|
||||
// Used by the editor's live-preview (Q27) and by the inbox/agenda
|
||||
// system pages internally (Phase B will route them here; Phase A1
|
||||
// leaves the wiring as a no-op for those pages).
|
||||
func handleRunAdhocView(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireUserViews(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var p runRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
if p.Filter == nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "filter is required"})
|
||||
return
|
||||
}
|
||||
if err := p.Filter.Validate(); err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
res, err := dbSvc.event.RunSpec(r.Context(), uid, *p.Filter, dbSvc.approval)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// POST /api/views/{slug}/run — run a saved view (or a system view by slug).
|
||||
//
|
||||
// Optional body: { filter: <override> } overrides the saved spec for
|
||||
// this run only (transient — doesn't mutate the stored row). Used for
|
||||
// query-param overrides in the URL contract (Q16).
|
||||
func handleRunSavedView(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireUserViews(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "slug is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// System view first — code-resident; doesn't need DB read.
|
||||
if sys := lookupSystemView(slug); sys != nil {
|
||||
spec := sys.Filter
|
||||
if err := maybeOverrideSpec(&spec, r.Body); err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
res, err := dbSvc.event.RunSpec(r.Context(), uid, spec, dbSvc.approval)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
return
|
||||
}
|
||||
|
||||
// User view.
|
||||
view, err := dbSvc.userView.GetBySlug(r.Context(), uid, slug)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if view == nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "view not found"})
|
||||
return
|
||||
}
|
||||
spec, err := services.UnmarshalFilterSpec(view.FilterSpec)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if err := maybeOverrideSpec(&spec, r.Body); err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
res, err := dbSvc.event.RunSpec(r.Context(), uid, spec, dbSvc.approval)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// GET /api/views/system — list system view definitions. Used by the
|
||||
// editor to seed "start from a system view as a template".
|
||||
func handleListSystemViews(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, services.AllSystemViews())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// helpers
|
||||
// ============================================================================
|
||||
|
||||
// lookupSystemView returns a SystemView whose slug matches, or nil.
|
||||
func lookupSystemView(slug string) *services.SystemView {
|
||||
for _, sv := range services.AllSystemViews() {
|
||||
if sv.Slug == slug {
|
||||
view := sv
|
||||
return &view
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// maybeOverrideSpec replaces `spec` with body.filter when the request
|
||||
// body parses as a runRequest with a non-nil Filter. Empty body / no
|
||||
// override → no-op. The override is validated.
|
||||
func maybeOverrideSpec(spec *services.FilterSpec, body io.Reader) error {
|
||||
var p runRequest
|
||||
dec := json.NewDecoder(body)
|
||||
if err := dec.Decode(&p); err != nil {
|
||||
// Empty body is fine — no override.
|
||||
return nil
|
||||
}
|
||||
if p.Filter == nil {
|
||||
return nil
|
||||
}
|
||||
if err := p.Filter.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
*spec = *p.Filter
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeUserViewError adds slug-taken handling on top of writeServiceError.
|
||||
func writeUserViewError(w http.ResponseWriter, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if errors.Is(err, services.ErrUserViewSlugTaken) {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
}
|
||||
61
internal/handlers/views_pages.go
Normal file
61
internal/handlers/views_pages.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package handlers
|
||||
|
||||
// Page handlers for the Custom Views shell (t-paliad-144 Phase A2).
|
||||
//
|
||||
// Three URLs:
|
||||
// GET /views — landing; redirects to most-recently-used
|
||||
// saved view, or shows the empty/onboarding
|
||||
// card.
|
||||
// GET /views/{slug} — render a saved or system view.
|
||||
// GET /views/new — view editor (blank slate).
|
||||
// GET /views/{slug}/edit — view editor (edit existing).
|
||||
//
|
||||
// Each route serves the static dist HTML; the client bundle (views.ts /
|
||||
// views-editor.ts) hydrates via /api/* on load.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// GET /views — landing.
|
||||
//
|
||||
// Behaviour matches design Q10 most-recently-used:
|
||||
// - If the caller has a saved view with last_used_at set → 302 to it.
|
||||
// - Otherwise serve the onboarding shell (the views.html dist file
|
||||
// handles the empty state in JS).
|
||||
func handleViewsLandingPage(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.userView != nil {
|
||||
mr, err := dbSvc.userView.MostRecent(r.Context(), uid)
|
||||
if err == nil && mr != nil {
|
||||
http.Redirect(w, r, "/views/"+mr.Slug, http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.ServeFile(w, r, "dist/views.html")
|
||||
}
|
||||
|
||||
// GET /views/{slug} — saved or system view shell.
|
||||
//
|
||||
// The handler doesn't validate the slug here — the client bundle calls
|
||||
// POST /api/views/{slug}/run and lets the API surface the 404 with a
|
||||
// proper empty-state. This keeps the page surface trivially cacheable.
|
||||
func handleViewsShellPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/views.html")
|
||||
}
|
||||
|
||||
// GET /views/new — editor with a blank slate.
|
||||
func handleViewsNewPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/views-editor.html")
|
||||
}
|
||||
|
||||
// GET /views/{slug}/edit — editor for an existing saved view.
|
||||
func handleViewsEditPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/views-editor.html")
|
||||
}
|
||||
@@ -27,10 +27,18 @@ type User struct {
|
||||
// "Counsel Knowledge Lawyer", …). NULL is allowed for users who never
|
||||
// picked a title — typically global admins promoted via SQL.
|
||||
JobTitle *string `db:"job_title" json:"job_title"`
|
||||
// Profession is the structured firm-tier enum that drives the
|
||||
// t-paliad-138 / t-paliad-148 approval ladder (partner / of_counsel /
|
||||
// associate / senior_pa / pa / paralegal). NULL means "no firm tier"
|
||||
// — external collaborators (local counsel, expert) and admin
|
||||
// accounts that aren't practicing lawyers. NULL → ladder level 0,
|
||||
// ineligible to approve. Distinct from JobTitle (display) and
|
||||
// GlobalRole (tool admin gate). Added by migration 057.
|
||||
Profession *string `db:"profession" json:"profession,omitempty"`
|
||||
// GlobalRole is the global-permissions enum: 'standard' | 'global_admin'.
|
||||
// Drives every permission gate that used to look at the legacy
|
||||
// role='admin'. Per-project authority is on paliad.project_teams.role and
|
||||
// is unrelated.
|
||||
// role='admin'. Per-project authority is on paliad.project_teams and
|
||||
// users.profession; this column is the tool-admin axis, unrelated.
|
||||
GlobalRole string `db:"global_role" json:"global_role"`
|
||||
Lang string `db:"lang" json:"lang"`
|
||||
EmailPreferences json.RawMessage `db:"email_preferences" json:"email_preferences"`
|
||||
@@ -102,10 +110,19 @@ type Project struct {
|
||||
// only. Inherited memberships are computed at read time by walking the path;
|
||||
// services set Inherited=true on the in-memory copy when annotating a list
|
||||
// result that mixes direct + inherited rows.
|
||||
//
|
||||
// t-paliad-148 split: Responsibility is the per-project role (lead /
|
||||
// member / observer / external). The legacy Role field is kept as a
|
||||
// deprecated read-only shadow until follow-up migration 058 drops the
|
||||
// underlying column.
|
||||
type ProjectTeamMember struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
Responsibility string `db:"responsibility" json:"responsibility"`
|
||||
// Role: deprecated shadow column. Reader populates it for backwards-
|
||||
// compatibility with any consumer still reading `role`; new code
|
||||
// should read .Responsibility instead.
|
||||
Role string `db:"role" json:"role"`
|
||||
Inherited bool `db:"inherited" json:"inherited"`
|
||||
AddedBy *uuid.UUID `db:"added_by" json:"added_by,omitempty"`
|
||||
@@ -113,13 +130,19 @@ type ProjectTeamMember struct {
|
||||
}
|
||||
|
||||
// ProjectTeamMemberWithUser enriches a team row with display fields so the
|
||||
// UI can render "<DisplayName> (<Email>) — <Role>" without a per-row lookup.
|
||||
// Used by TeamService.ListMembers which unions direct + inherited memberships.
|
||||
// UI can render "<DisplayName> (<Email>) — <Responsibility>" without a
|
||||
// per-row lookup. Used by TeamService.ListMembers which unions direct +
|
||||
// inherited memberships.
|
||||
//
|
||||
// UserProfession reflects paliad.users.profession at read time — the
|
||||
// firm-tier badge shown next to the responsibility column on
|
||||
// /projects/{id} (t-paliad-148 §6).
|
||||
type ProjectTeamMemberWithUser struct {
|
||||
ProjectTeamMember
|
||||
UserEmail string `db:"user_email" json:"user_email"`
|
||||
UserDisplayName string `db:"user_display_name" json:"user_display_name"`
|
||||
UserOffice string `db:"user_office" json:"user_office"`
|
||||
UserEmail string `db:"user_email" json:"user_email"`
|
||||
UserDisplayName string `db:"user_display_name" json:"user_display_name"`
|
||||
UserOffice string `db:"user_office" json:"user_office"`
|
||||
UserProfession *string `db:"user_profession" json:"user_profession,omitempty"`
|
||||
// InheritedFromID is the ancestor project_id the membership came from
|
||||
// when Inherited=true. NULL for direct rows.
|
||||
InheritedFromID *uuid.UUID `db:"inherited_from_id" json:"inherited_from_id,omitempty"`
|
||||
@@ -147,17 +170,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 +216,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 +265,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 +520,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 {
|
||||
@@ -66,13 +74,21 @@ type CreateAppointmentInput struct {
|
||||
}
|
||||
|
||||
// UpdateAppointmentInput is the partial-update payload for PATCH /api/appointments/{id}.
|
||||
//
|
||||
// ProjectID + ClearProject control the project move (t-paliad-140). Both
|
||||
// nil/false = leave project_id untouched. ClearProject=true unlinks the
|
||||
// appointment from its current project (only the creator may do this,
|
||||
// matching the personal-appointment edit gate). ProjectID set = move under
|
||||
// that project (visibility on the new project is enforced).
|
||||
type UpdateAppointmentInput struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
StartAt *time.Time `json:"start_at,omitempty"`
|
||||
EndAt *time.Time `json:"end_at,omitempty"`
|
||||
Location *string `json:"location,omitempty"`
|
||||
AppointmentType *string `json:"appointment_type,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
StartAt *time.Time `json:"start_at,omitempty"`
|
||||
EndAt *time.Time `json:"end_at,omitempty"`
|
||||
Location *string `json:"location,omitempty"`
|
||||
AppointmentType *string `json:"appointment_type,omitempty"`
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
ClearProject bool `json:"clear_project,omitempty"`
|
||||
}
|
||||
|
||||
// AppointmentListFilter narrows ListVisibleForUser results.
|
||||
@@ -82,12 +98,17 @@ type UpdateAppointmentInput struct {
|
||||
// the team-visibility predicate (AND) rather than replacing it, so an
|
||||
// appointment a user created on a team they have since left still
|
||||
// won't leak through.
|
||||
//
|
||||
// DirectOnly narrows ProjectID from "this project + every descendant" (the
|
||||
// t-paliad-139 subtree default) to "this project only" (t-paliad-152).
|
||||
// Has no effect when ProjectID is nil.
|
||||
type AppointmentListFilter struct {
|
||||
ProjectID *uuid.UUID
|
||||
From *time.Time
|
||||
To *time.Time
|
||||
Type *string
|
||||
CreatedBy *uuid.UUID
|
||||
ProjectID *uuid.UUID
|
||||
From *time.Time
|
||||
To *time.Time
|
||||
Type *string
|
||||
CreatedBy *uuid.UUID
|
||||
DirectOnly bool
|
||||
}
|
||||
|
||||
// ListVisibleForUser returns all Appointments the user can see (personal +
|
||||
@@ -111,7 +132,11 @@ func (s *AppointmentService) ListVisibleForUser(ctx context.Context, userID uuid
|
||||
}
|
||||
|
||||
if filter.ProjectID != nil {
|
||||
conds = append(conds, projectDescendantPredicate("p"))
|
||||
if filter.DirectOnly {
|
||||
conds = append(conds, `t.project_id = :project_id`)
|
||||
} else {
|
||||
conds = append(conds, projectDescendantPredicate("p"))
|
||||
}
|
||||
args["project_id"] = *filter.ProjectID
|
||||
}
|
||||
if filter.From != nil {
|
||||
@@ -138,6 +163,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
|
||||
@@ -159,16 +186,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)
|
||||
}
|
||||
@@ -297,6 +341,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)
|
||||
@@ -313,6 +366,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 {
|
||||
@@ -325,6 +383,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{}
|
||||
@@ -335,6 +396,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 == "" {
|
||||
@@ -346,10 +410,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)
|
||||
@@ -360,6 +442,41 @@ func (s *AppointmentService) Update(ctx context.Context, userID, appointmentID u
|
||||
}
|
||||
appendSet("appointment_type", *input.AppointmentType)
|
||||
}
|
||||
|
||||
// Project move (t-paliad-140). ClearProject takes precedence over
|
||||
// ProjectID so a payload that sets both falls into the unlink branch
|
||||
// rather than silently ignoring the contradiction. Visibility on the
|
||||
// destination is enforced via projects.GetByID (matches Create).
|
||||
// Unlinking to a personal appointment is creator-only — same gate
|
||||
// personal-only Update branches enforce above — so a non-creator who
|
||||
// can mutate the project-attached row can't strand it on someone else's
|
||||
// personal calendar.
|
||||
var movedFromProject *uuid.UUID
|
||||
var movedToProject *uuid.UUID
|
||||
if input.ClearProject {
|
||||
if current.ProjectID != nil {
|
||||
if current.CreatedBy == nil || *current.CreatedBy != userID {
|
||||
return nil, fmt.Errorf("%w: only the creator can convert this Appointment to personal", ErrForbidden)
|
||||
}
|
||||
from := *current.ProjectID
|
||||
movedFromProject = &from
|
||||
appendSet("project_id", nil)
|
||||
}
|
||||
} else if input.ProjectID != nil {
|
||||
if current.ProjectID == nil || *input.ProjectID != *current.ProjectID {
|
||||
if _, err := s.projects.GetByID(ctx, userID, *input.ProjectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
to := *input.ProjectID
|
||||
movedToProject = &to
|
||||
if current.ProjectID != nil {
|
||||
from := *current.ProjectID
|
||||
movedFromProject = &from
|
||||
}
|
||||
appendSet("project_id", *input.ProjectID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(sets) == 0 {
|
||||
return current, nil
|
||||
}
|
||||
@@ -379,12 +496,62 @@ func (s *AppointmentService) Update(ctx context.Context, userID, appointmentID u
|
||||
return nil, fmt.Errorf("update appointment: %w", err)
|
||||
}
|
||||
|
||||
if current.ProjectID != nil {
|
||||
desc := current.Title
|
||||
descPtr := &desc
|
||||
if err := insertProjectEventWithMeta(ctx, tx, *current.ProjectID, userID, "appointment_updated", "Appointment updated", descPtr,
|
||||
map[string]any{"appointment_id": appointmentID}); err != nil {
|
||||
return nil, err
|
||||
// Audit emission. Project moves (t-paliad-140) get their own
|
||||
// appointment_project_changed pair so the OLD and NEW project rows
|
||||
// keep an honest chronology. Edits to other fields land as
|
||||
// appointment_updated on whichever project the row sits on AFTER the
|
||||
// move (or on the source project if it was unlinked). Personal
|
||||
// appointments don't have audit history, so unlink/link rows on the
|
||||
// "personal" side are skipped.
|
||||
desc := current.Title
|
||||
descPtr := &desc
|
||||
if movedFromProject != nil || movedToProject != nil {
|
||||
moveMeta := map[string]any{"appointment_id": appointmentID}
|
||||
if movedFromProject != nil {
|
||||
moveMeta["from_project_id"] = *movedFromProject
|
||||
}
|
||||
if movedToProject != nil {
|
||||
moveMeta["to_project_id"] = *movedToProject
|
||||
}
|
||||
if movedFromProject != nil {
|
||||
if err := insertProjectEventWithMeta(ctx, tx, *movedFromProject, userID,
|
||||
"appointment_project_changed", "Appointment project changed", descPtr, moveMeta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if movedToProject != nil {
|
||||
if err := insertProjectEventWithMeta(ctx, tx, *movedToProject, userID,
|
||||
"appointment_project_changed", "Appointment project changed", descPtr, moveMeta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
otherFieldsTouched := input.Title != nil || input.Description != nil ||
|
||||
input.StartAt != nil || input.EndAt != nil || input.Location != nil ||
|
||||
input.AppointmentType != nil
|
||||
if otherFieldsTouched {
|
||||
// After-move project. If the row is now personal (unlink), no
|
||||
// audit row — personal appointments don't surface in any
|
||||
// project's Verlauf.
|
||||
var auditProject *uuid.UUID
|
||||
switch {
|
||||
case movedToProject != nil:
|
||||
auditProject = movedToProject
|
||||
case movedFromProject != nil:
|
||||
// Unlink: no audit project
|
||||
default:
|
||||
auditProject = current.ProjectID
|
||||
}
|
||||
if auditProject != nil {
|
||||
if err := insertProjectEventWithMeta(ctx, tx, *auditProject, userID, "appointment_updated", "Appointment updated", descPtr,
|
||||
map[string]any{"appointment_id": appointmentID}); err != nil {
|
||||
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 {
|
||||
@@ -401,6 +568,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 {
|
||||
@@ -413,6 +586,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 {
|
||||
@@ -420,21 +596,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
|
||||
|
||||
167
internal/services/approval_levels.go
Normal file
167
internal/services/approval_levels.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package services
|
||||
|
||||
import "errors"
|
||||
|
||||
// Strict-ladder helpers for the 4-Augen-Prüfung approval gate. The ladder
|
||||
// drives both the t-paliad-138 single-value `required_role` policy
|
||||
// grammar and the t-paliad-148 (profession, responsibility) tuple-with-
|
||||
// gate evaluation in paliad.user_project_authority_level().
|
||||
//
|
||||
// The ladder values match paliad.approval_role_level(text) in migration
|
||||
// 057. Higher level always satisfies lower; level 0 means ineligible to
|
||||
// approve at any level.
|
||||
|
||||
// Profession values on paliad.users.profession. Drive the ladder. NULL is
|
||||
// represented as the empty string in Go (`*string` nil) — the ladder
|
||||
// returns 0 for unknown values, including empty.
|
||||
const (
|
||||
ProfessionPartner = "partner"
|
||||
ProfessionOfCounsel = "of_counsel"
|
||||
ProfessionAssociate = "associate"
|
||||
ProfessionSeniorPA = "senior_pa"
|
||||
ProfessionPA = "pa"
|
||||
ProfessionParalegal = "paralegal"
|
||||
)
|
||||
|
||||
// Project-level responsibility values on paliad.project_teams.responsibility.
|
||||
// Open the ladder gate (lead/member) or close it (observer/external).
|
||||
const (
|
||||
ResponsibilityLead = "lead"
|
||||
ResponsibilityMember = "member"
|
||||
ResponsibilityObserver = "observer"
|
||||
ResponsibilityExternal = "external"
|
||||
)
|
||||
|
||||
// RoleSeniorPA — kept as the legacy constant from t-paliad-138 for any
|
||||
// remaining reference site that hasn't migrated. Equal to ProfessionSeniorPA.
|
||||
const RoleSeniorPA = ProfessionSeniorPA
|
||||
|
||||
// EntityType values for the polymorphic approval workflow.
|
||||
const (
|
||||
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"
|
||||
)
|
||||
|
||||
// professionLevel maps a profession value to its strict-ladder level.
|
||||
// Mirrors paliad.approval_role_level(text). NULL profession (empty
|
||||
// string) returns 0 — explicit so the trap is visible.
|
||||
//
|
||||
// 5: partner — firm-tier ceiling (replaces legacy 'lead')
|
||||
// 4: of_counsel
|
||||
// 3: associate ← default required level on new policies
|
||||
// 2: senior_pa
|
||||
// 1: pa
|
||||
// 0: paralegal / "" / unknown — ineligible to approve
|
||||
//
|
||||
// CRITICAL: do not silently default NULL/empty to 'associate'. NULL
|
||||
// profession means "no firm tier", which is the explicit signal that
|
||||
// the user (e.g. external local counsel) cannot satisfy any tier.
|
||||
// Test: TestProfessionLevel_NilIsZero pins this behaviour.
|
||||
func professionLevel(profession string) int {
|
||||
switch profession {
|
||||
case ProfessionPartner:
|
||||
return 5
|
||||
case ProfessionOfCounsel:
|
||||
return 4
|
||||
case ProfessionAssociate:
|
||||
return 3
|
||||
case ProfessionSeniorPA:
|
||||
return 2
|
||||
case ProfessionPA:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// responsibilityOpensGate returns true iff the project responsibility
|
||||
// opens the approval gate. Mirrors the SQL predicate
|
||||
// `pt.responsibility IN ('lead','member')` used by
|
||||
// paliad.user_project_authority_level().
|
||||
func responsibilityOpensGate(responsibility string) bool {
|
||||
return responsibility == ResponsibilityLead || responsibility == ResponsibilityMember
|
||||
}
|
||||
|
||||
// IsValidRequiredRole returns true iff the role can be set as a policy's
|
||||
// required_role (i.e. it has a non-zero strict-ladder level). Used by
|
||||
// the policy-authoring page to validate the dropdown value.
|
||||
func IsValidRequiredRole(role string) bool {
|
||||
return professionLevel(role) > 0
|
||||
}
|
||||
|
||||
// IsValidProfession returns true iff the value is one of the recognised
|
||||
// profession enum values. Empty string is intentionally rejected — the
|
||||
// service layer represents NULL as a *string nil, not as "".
|
||||
func IsValidProfession(p string) bool {
|
||||
switch p {
|
||||
case ProfessionPartner, ProfessionOfCounsel, ProfessionAssociate,
|
||||
ProfessionSeniorPA, ProfessionPA, ProfessionParalegal:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsValidResponsibility returns true iff the value is one of the
|
||||
// recognised project-responsibility enum values. Used by TeamService.
|
||||
func IsValidResponsibility(r string) bool {
|
||||
switch r {
|
||||
case ResponsibilityLead, ResponsibilityMember,
|
||||
ResponsibilityObserver, ResponsibilityExternal:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Approval-flow errors. Handlers map these to the right HTTP status:
|
||||
//
|
||||
// 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")
|
||||
)
|
||||
950
internal/services/approval_service.go
Normal file
950
internal/services/approval_service.go
Normal file
@@ -0,0 +1,950 @@
|
||||
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 (profession, responsibility) tuple meets the
|
||||
// strict-ladder threshold, plus any global_admin user, plus any partner-
|
||||
// unit-derived member where the attachment grants authority (t-paliad-139).
|
||||
// Excludes requesterID.
|
||||
//
|
||||
// Returns true if at least one such user exists. The path-walk JOIN matches
|
||||
// the visibility predicate so an ancestor partner qualifies for a
|
||||
// descendant's approval, just like they have visibility.
|
||||
//
|
||||
// t-paliad-148: peer authority requires BOTH a profession with sufficient
|
||||
// level AND a responsibility ∈ {lead, member} that opens the gate.
|
||||
// observer/external rows are excluded even if the user's profession would
|
||||
// otherwise qualify — that's the point of the project-level gate.
|
||||
func (s *ApprovalService) hasQualifiedApprover(ctx context.Context, tx *sqlx.Tx, projectID, requesterID uuid.UUID, requiredRole string) (bool, error) {
|
||||
q := `WITH path AS (
|
||||
SELECT string_to_array(p.path, '.')::uuid[] AS ids
|
||||
FROM paliad.projects p WHERE p.id = $1
|
||||
)
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.project_teams pt
|
||||
JOIN paliad.users u ON u.id = pt.user_id
|
||||
JOIN path ON pt.project_id = ANY(path.ids)
|
||||
WHERE pt.user_id <> $2
|
||||
AND pt.responsibility IN ('lead', 'member')
|
||||
AND paliad.approval_role_level(u.profession) >= paliad.approval_role_level($3)
|
||||
UNION ALL
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.global_role = 'global_admin' AND u.id <> $2
|
||||
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 a
|
||||
// responsibility that opens the gate (lead/member) AND a profession
|
||||
// whose level meets the threshold (t-paliad-148 tuple-with-gate).
|
||||
q := `SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.project_teams pt
|
||||
JOIN paliad.users u ON u.id = pt.user_id
|
||||
WHERE pt.user_id = $1
|
||||
AND pt.project_id = ANY(string_to_array(
|
||||
(SELECT path FROM paliad.projects WHERE id = $2), '.')::uuid[])
|
||||
AND pt.responsibility IN ('lead', 'member')
|
||||
AND paliad.approval_role_level(u.profession) >= paliad.approval_role_level($3)
|
||||
)`
|
||||
var ok bool
|
||||
if err := tx.GetContext(ctx, &ok, q, callerID, req.ProjectID, req.RequiredRole); err != nil {
|
||||
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 membership with
|
||||
// responsibility ∈ {lead, member} AND profession at or above
|
||||
// the threshold (t-paliad-148 tuple-with-gate), OR
|
||||
// - caller is a partner-unit-derived member with derive_grants_authority=true
|
||||
// on an attachment in the project's path, and the unit_role maps to a
|
||||
// profession at or above the threshold (t-paliad-139).
|
||||
`(EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $1 AND u.global_role = 'global_admin')
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.project_teams pt
|
||||
JOIN paliad.users u ON u.id = pt.user_id
|
||||
WHERE pt.user_id = $1
|
||||
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
|
||||
AND pt.responsibility IN ('lead', 'member')
|
||||
AND paliad.approval_role_level(u.profession) >= paliad.approval_role_level(ar.required_role)
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.project_partner_units ppu
|
||||
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 membership with responsibility ∈ {lead, member}
|
||||
// AND profession meeting the threshold (t-paliad-148) OR partner-unit-
|
||||
// derived authority (t-paliad-139).
|
||||
func (s *ApprovalService) PendingCountForUser(ctx context.Context, callerID uuid.UUID) (int, error) {
|
||||
q := `SELECT COUNT(*)
|
||||
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
|
||||
JOIN paliad.users u ON u.id = pt.user_id
|
||||
WHERE pt.user_id = $1
|
||||
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
|
||||
AND pt.responsibility IN ('lead', 'member')
|
||||
AND paliad.approval_role_level(u.profession) >= paliad.approval_role_level(ar.required_role)
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.project_partner_units ppu
|
||||
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
|
||||
}
|
||||
695
internal/services/approval_service_test.go
Normal file
695
internal/services/approval_service_test.go
Normal file
@@ -0,0 +1,695 @@
|
||||
package services
|
||||
|
||||
// Approval-service tests. Two layers:
|
||||
//
|
||||
// - Pure-Go: professionLevel strict ladder + IsValidRequiredRole +
|
||||
// responsibilityOpensGate (t-paliad-148). No DB touch.
|
||||
// - Live-DB: the full submit→approve and submit→reject flows on real
|
||||
// paliad.deadlines / paliad.approval_requests rows. Skipped when
|
||||
// TEST_DATABASE_URL is unset, mirroring audit_service_test and
|
||||
// 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 TestProfessionLevel_StrictLadder(t *testing.T) {
|
||||
cases := []struct {
|
||||
profession string
|
||||
want int
|
||||
}{
|
||||
{"partner", 5},
|
||||
{"of_counsel", 4},
|
||||
{"associate", 3},
|
||||
{"senior_pa", 2},
|
||||
{"pa", 1},
|
||||
{"paralegal", 0},
|
||||
{"", 0},
|
||||
{"unknown", 0},
|
||||
// Legacy values that pre-dated the t-paliad-148 split must NOT
|
||||
// satisfy the ladder. The SQL helper still recognises 'lead' as a
|
||||
// deprecated-shadow row until migration 058; the Go helper does
|
||||
// not — call sites have all migrated to read users.profession.
|
||||
{"lead", 0},
|
||||
{"local_counsel", 0},
|
||||
{"expert", 0},
|
||||
{"observer", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.profession, func(t *testing.T) {
|
||||
if got := professionLevel(c.profession); got != c.want {
|
||||
t.Errorf("professionLevel(%q) = %d, want %d", c.profession, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfessionLevel_NilIsZero(t *testing.T) {
|
||||
// CRITICAL trap pin: NULL profession (empty string in Go) returns 0,
|
||||
// not "default to associate" or anything similar. This is what gates
|
||||
// external collaborators (local_counsel, expert) out of the approval
|
||||
// ladder when their project responsibility is set to 'external' but
|
||||
// their users.profession is also set to a real tier by mistake.
|
||||
if got := professionLevel(""); got != 0 {
|
||||
t.Errorf("professionLevel(\"\") must be 0, got %d — NULL profession is ineligible", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfessionLevel_HigherSatisfiesLower(t *testing.T) {
|
||||
// "Anyone strictly above the required level satisfies it" — verify by
|
||||
// asserting the ladder is monotonic.
|
||||
if professionLevel("partner") <= professionLevel("associate") {
|
||||
t.Errorf("partner must outrank associate")
|
||||
}
|
||||
if professionLevel("associate") <= professionLevel("senior_pa") {
|
||||
t.Errorf("associate must outrank senior_pa")
|
||||
}
|
||||
if professionLevel("senior_pa") <= professionLevel("pa") {
|
||||
t.Errorf("senior_pa must outrank pa")
|
||||
}
|
||||
if professionLevel("of_counsel") <= professionLevel("associate") {
|
||||
t.Errorf("of_counsel must outrank associate")
|
||||
}
|
||||
// PA-required policy: anyone associate-or-above must satisfy.
|
||||
if professionLevel("associate") < professionLevel("pa") {
|
||||
t.Errorf("associate must satisfy a pa-required policy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsibilityOpensGate(t *testing.T) {
|
||||
cases := []struct {
|
||||
responsibility string
|
||||
open bool
|
||||
}{
|
||||
{"lead", true},
|
||||
{"member", true},
|
||||
{"observer", false},
|
||||
{"external", false},
|
||||
{"", false},
|
||||
{"unknown", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.responsibility, func(t *testing.T) {
|
||||
if got := responsibilityOpensGate(c.responsibility); got != c.open {
|
||||
t.Errorf("responsibilityOpensGate(%q) = %v, want %v",
|
||||
c.responsibility, got, c.open)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidRequiredRole(t *testing.T) {
|
||||
cases := []struct {
|
||||
role string
|
||||
ok bool
|
||||
}{
|
||||
{"partner", true},
|
||||
{"of_counsel", true},
|
||||
{"associate", true},
|
||||
{"senior_pa", true},
|
||||
{"pa", true},
|
||||
{"paralegal", false},
|
||||
// Legacy values that pre-dated the t-paliad-148 split must be
|
||||
// rejected as policy targets.
|
||||
{"lead", false},
|
||||
{"local_counsel", false},
|
||||
{"expert", false},
|
||||
{"observer", false},
|
||||
{"", 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 TestIsValidProfession(t *testing.T) {
|
||||
for _, p := range []string{"partner", "of_counsel", "associate", "senior_pa", "pa", "paralegal"} {
|
||||
t.Run(p, func(t *testing.T) {
|
||||
if !IsValidProfession(p) {
|
||||
t.Errorf("IsValidProfession(%q) must be true", p)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, p := range []string{"", "lead", "junior_associate", "trainee", "unknown"} {
|
||||
t.Run("invalid_"+p, func(t *testing.T) {
|
||||
if IsValidProfession(p) {
|
||||
t.Errorf("IsValidProfession(%q) must be false", p)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidResponsibility(t *testing.T) {
|
||||
for _, r := range []string{"lead", "member", "observer", "external"} {
|
||||
t.Run(r, func(t *testing.T) {
|
||||
if !IsValidResponsibility(r) {
|
||||
t.Errorf("IsValidResponsibility(%q) must be true", r)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, r := range []string{"", "associate", "lead2", "unknown"} {
|
||||
t.Run("invalid_"+r, func(t *testing.T) {
|
||||
if IsValidResponsibility(r) {
|
||||
t.Errorf("IsValidResponsibility(%q) must be false", r)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalEventType(t *testing.T) {
|
||||
cases := []struct {
|
||||
entity, step, want string
|
||||
}{
|
||||
{"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))
|
||||
}
|
||||
}
|
||||
587
internal/services/broadcast_service.go
Normal file
587
internal/services/broadcast_service.go
Normal file
@@ -0,0 +1,587 @@
|
||||
// Package services — BroadcastService — bulk team-email send.
|
||||
//
|
||||
// Backs the /team page "E-Mail an Auswahl" flow (t-paliad-147 / issue #7).
|
||||
// Each call:
|
||||
//
|
||||
// 1. Validates the sender's authority (project lead OR global_admin)
|
||||
// and the recipient cap.
|
||||
// 2. Renders the per-recipient body (Markdown → HTML, with
|
||||
// {{name}} / {{first_name}} / {{role_on_project}} placeholder
|
||||
// substitution) inside the standard email base wrapper.
|
||||
// 3. Dispatches via MailService.Send with Reply-To set to the
|
||||
// sender's address — From: stays on the SMTP infra address so
|
||||
// DKIM/SPF still hold. Replies route back to the human.
|
||||
// 4. Persists a paliad.email_broadcasts row capturing subject,
|
||||
// body, sender, filter snapshot, and per-recipient send report.
|
||||
//
|
||||
// Per-recipient privacy: each recipient gets their own envelope. We
|
||||
// never put more than one address on the To: header. Recipients can't
|
||||
// see each other.
|
||||
//
|
||||
// Concurrency: a fixed 5-deep goroutine pool dispatches sends with a
|
||||
// per-send timeout. SMTP failures are logged into the report and the
|
||||
// batch continues — one bad address never blocks the rest.
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/branding"
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// BroadcastRecipientCap is the soft maximum number of recipients per
|
||||
// broadcast. m-locked at 100 (2026-05-07) — admin-tweakable later if
|
||||
// HLC's regular use case grows.
|
||||
const BroadcastRecipientCap = 100
|
||||
|
||||
// BroadcastSendConcurrency caps the number of in-flight SMTP
|
||||
// connections during a single broadcast. Five is generous enough to
|
||||
// finish a 100-recipient batch in a few seconds while leaving headroom
|
||||
// for the reminder job's own SMTP usage.
|
||||
const BroadcastSendConcurrency = 5
|
||||
|
||||
// BroadcastSendTimeout bounds a single per-recipient SMTP delivery.
|
||||
// Hostinger's submission endpoint typically returns within a second;
|
||||
// 15s gives plenty of slack for transient slowness without holding the
|
||||
// HTTP request open indefinitely.
|
||||
const BroadcastSendTimeout = 15 * time.Second
|
||||
|
||||
// Sentinel errors. Handlers map these to HTTP status codes.
|
||||
var (
|
||||
ErrBroadcastForbidden = errors.New("broadcast: caller is neither project lead nor global_admin")
|
||||
ErrBroadcastNoRecipients = errors.New("broadcast: empty recipient list")
|
||||
ErrBroadcastTooManyRecipients = errors.New("broadcast: recipient cap exceeded")
|
||||
ErrBroadcastEmptySubject = errors.New("broadcast: empty subject")
|
||||
ErrBroadcastEmptyBody = errors.New("broadcast: empty body")
|
||||
ErrBroadcastInvalidEmail = errors.New("broadcast: invalid recipient email")
|
||||
)
|
||||
|
||||
// BroadcastService wires the bulk-send flow.
|
||||
type BroadcastService struct {
|
||||
db *sqlx.DB
|
||||
mail *MailService
|
||||
users *UserService
|
||||
team *TeamService
|
||||
templates *EmailTemplateService
|
||||
|
||||
// clock isolates time.Now for tests.
|
||||
clock func() time.Time
|
||||
}
|
||||
|
||||
// NewBroadcastService wires the service. mail/users/team/templates
|
||||
// must all be non-nil — the service is only constructed in the DB-backed
|
||||
// path.
|
||||
func NewBroadcastService(db *sqlx.DB, mail *MailService, users *UserService, team *TeamService, templates *EmailTemplateService) *BroadcastService {
|
||||
return &BroadcastService{
|
||||
db: db,
|
||||
mail: mail,
|
||||
users: users,
|
||||
team: team,
|
||||
templates: templates,
|
||||
clock: func() time.Time { return time.Now() },
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastRecipient is one row in the resolved addressee list. Name
|
||||
// values are the per-recipient placeholder substitutions surfaced in
|
||||
// the body.
|
||||
type BroadcastRecipient struct {
|
||||
UserID uuid.UUID
|
||||
Email string
|
||||
DisplayName string
|
||||
FirstName string
|
||||
RoleOnProject string
|
||||
}
|
||||
|
||||
// BroadcastInput is what a handler hands to Send.
|
||||
type BroadcastInput struct {
|
||||
// ProjectID identifies the project the broadcast is scoped to. The
|
||||
// caller must be a 'lead' on this project (or a global_admin) for
|
||||
// the send to proceed. nil/zero means "no specific project" —
|
||||
// only global_admin may send in that case.
|
||||
ProjectID *uuid.UUID
|
||||
|
||||
Subject string
|
||||
// Body is the Markdown source the sender typed. Per-recipient
|
||||
// placeholders ({{name}}, {{first_name}}, {{role_on_project}})
|
||||
// are substituted before Markdown rendering.
|
||||
Body string
|
||||
|
||||
// TemplateKey is optional — when set, the broadcast is recorded as
|
||||
// having started from a template, but Subject/Body are still the
|
||||
// authoritative source (we don't re-fetch from the template at
|
||||
// send time).
|
||||
TemplateKey string
|
||||
|
||||
// RecipientFilter is the snapshot of filter chips the sender had
|
||||
// selected. Persisted into email_broadcasts.recipient_filter for
|
||||
// future audit.
|
||||
RecipientFilter map[string]any
|
||||
|
||||
Recipients []BroadcastRecipient
|
||||
|
||||
// Lang controls the wrapper template language. Defaults to "de".
|
||||
Lang string
|
||||
}
|
||||
|
||||
// BroadcastReport summarises a send.
|
||||
type BroadcastReport struct {
|
||||
BroadcastID uuid.UUID `json:"broadcast_id"`
|
||||
Total int `json:"total"`
|
||||
Sent int `json:"sent"`
|
||||
Failed int `json:"failed"`
|
||||
Errors map[string]string `json:"errors,omitempty"` // user_id → error
|
||||
SentAt time.Time `json:"sent_at"`
|
||||
}
|
||||
|
||||
// Send dispatches a broadcast. Returns the persisted ID and a per-send
|
||||
// report. The full pipeline runs even when MailService is disabled —
|
||||
// the audit row still lands so deploys without SMTP can be exercised.
|
||||
func (s *BroadcastService) Send(ctx context.Context, callerID uuid.UUID, in BroadcastInput) (*BroadcastReport, error) {
|
||||
// --- Validation (cheap checks first) ----------------------------
|
||||
subject := strings.TrimSpace(in.Subject)
|
||||
if subject == "" {
|
||||
return nil, ErrBroadcastEmptySubject
|
||||
}
|
||||
body := strings.TrimSpace(in.Body)
|
||||
if body == "" {
|
||||
return nil, ErrBroadcastEmptyBody
|
||||
}
|
||||
if len(in.Recipients) == 0 {
|
||||
return nil, ErrBroadcastNoRecipients
|
||||
}
|
||||
if len(in.Recipients) > BroadcastRecipientCap {
|
||||
return nil, fmt.Errorf("%w: %d > %d", ErrBroadcastTooManyRecipients, len(in.Recipients), BroadcastRecipientCap)
|
||||
}
|
||||
for _, r := range in.Recipients {
|
||||
if _, err := mail.ParseAddress(r.Email); err != nil {
|
||||
return nil, fmt.Errorf("%w: %q", ErrBroadcastInvalidEmail, r.Email)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Authorisation ---------------------------------------------
|
||||
sender, err := s.users.GetByID(ctx, callerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load sender: %w", err)
|
||||
}
|
||||
if sender == nil {
|
||||
return nil, ErrBroadcastForbidden
|
||||
}
|
||||
if err := s.assertCanBroadcast(ctx, sender, in.ProjectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// --- Persist audit row ahead of send so a partial-batch crash
|
||||
// still leaves a record of intent. send_report is filled in
|
||||
// post-dispatch via UPDATE.
|
||||
lang := in.Lang
|
||||
if lang == "" {
|
||||
lang = "de"
|
||||
}
|
||||
broadcastID := uuid.New()
|
||||
recipientIDs := make([]uuid.UUID, 0, len(in.Recipients))
|
||||
for _, r := range in.Recipients {
|
||||
recipientIDs = append(recipientIDs, r.UserID)
|
||||
}
|
||||
filterJSON, err := json.Marshal(filterMapOrEmpty(in.RecipientFilter))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal filter: %w", err)
|
||||
}
|
||||
|
||||
templateKey := strings.TrimSpace(in.TemplateKey)
|
||||
var templateKeyArg any
|
||||
if templateKey != "" {
|
||||
templateKeyArg = templateKey
|
||||
}
|
||||
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO paliad.email_broadcasts
|
||||
(id, subject, body, sender_id, template_key, recipient_filter, recipient_user_ids, send_report, sent_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, '{}'::jsonb, now())`,
|
||||
broadcastID, subject, body, callerID, templateKeyArg, string(filterJSON), pq.Array(recipientIDs),
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("insert broadcast: %w", err)
|
||||
}
|
||||
|
||||
// --- Dispatch -------------------------------------------------
|
||||
report, sendErr := s.dispatch(ctx, *sender, broadcastID, subject, body, lang, in.Recipients)
|
||||
report.BroadcastID = broadcastID
|
||||
|
||||
// Persist the report regardless of dispatch outcome; surface the
|
||||
// dispatch error to the caller so the UI can show a partial-success
|
||||
// toast.
|
||||
reportJSON, marshalErr := json.Marshal(report)
|
||||
if marshalErr != nil {
|
||||
// Truly unexpected — fall back to an empty report shape rather
|
||||
// than wedging the audit row.
|
||||
slog.Error("broadcast: marshal report failed", "broadcast_id", broadcastID, "error", marshalErr)
|
||||
reportJSON = []byte(`{}`)
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE paliad.email_broadcasts SET send_report = $1::jsonb WHERE id = $2`,
|
||||
string(reportJSON), broadcastID,
|
||||
); err != nil {
|
||||
slog.Error("broadcast: persist report failed", "broadcast_id", broadcastID, "error", err)
|
||||
}
|
||||
|
||||
if sendErr != nil {
|
||||
return report, sendErr
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// assertCanBroadcast enforces project_lead-OR-global_admin. global_admin
|
||||
// always wins; otherwise the sender must have role='lead' on
|
||||
// in.ProjectID.
|
||||
func (s *BroadcastService) assertCanBroadcast(ctx context.Context, sender *models.User, projectID *uuid.UUID) error {
|
||||
if sender.GlobalRole == "global_admin" {
|
||||
return nil
|
||||
}
|
||||
if projectID == nil {
|
||||
return ErrBroadcastForbidden
|
||||
}
|
||||
var count int
|
||||
if err := s.db.GetContext(ctx, &count,
|
||||
`SELECT COUNT(*) FROM paliad.project_teams
|
||||
WHERE project_id = $1 AND user_id = $2 AND role = 'lead'`,
|
||||
*projectID, sender.ID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("check lead role: %w", err)
|
||||
}
|
||||
if count == 0 {
|
||||
return ErrBroadcastForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dispatch fans out the per-recipient sends through a bounded pool and
|
||||
// collects the report.
|
||||
func (s *BroadcastService) dispatch(ctx context.Context, sender models.User, broadcastID uuid.UUID, subject, body, lang string, recipients []BroadcastRecipient) (*BroadcastReport, error) {
|
||||
type result struct {
|
||||
userID uuid.UUID
|
||||
err error
|
||||
}
|
||||
results := make(chan result, len(recipients))
|
||||
|
||||
sem := make(chan struct{}, BroadcastSendConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for _, r := range recipients {
|
||||
wg.Add(1)
|
||||
go func(rec BroadcastRecipient) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
sendCtx, cancel := context.WithTimeout(ctx, BroadcastSendTimeout)
|
||||
defer cancel()
|
||||
err := s.sendOne(sendCtx, sender, broadcastID, subject, body, lang, rec)
|
||||
results <- result{userID: rec.UserID, err: err}
|
||||
}(r)
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
report := &BroadcastReport{
|
||||
Total: len(recipients),
|
||||
Errors: map[string]string{},
|
||||
SentAt: s.clock(),
|
||||
}
|
||||
for res := range results {
|
||||
if res.err != nil {
|
||||
report.Failed++
|
||||
report.Errors[res.userID.String()] = res.err.Error()
|
||||
slog.Warn("broadcast: send failed",
|
||||
"broadcast_id", broadcastID, "user_id", res.userID, "error", res.err)
|
||||
} else {
|
||||
report.Sent++
|
||||
}
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// sendOne renders one personalised email and dispatches it. The
|
||||
// MailService no-ops cleanly when disabled — that path still treats
|
||||
// the recipient as "sent" for the purposes of the report so dev
|
||||
// deploys aren't littered with phantom failures.
|
||||
func (s *BroadcastService) sendOne(ctx context.Context, sender models.User, broadcastID uuid.UUID, subject, body, lang string, rec BroadcastRecipient) error {
|
||||
// Subject can carry placeholders too ("Hallo {{first_name}}, …").
|
||||
rendered := substitutePlaceholders(subject, rec)
|
||||
personalisedBody := substitutePlaceholders(body, rec)
|
||||
htmlBody, err := s.renderBroadcastBody(ctx, lang, personalisedBody, sender)
|
||||
if err != nil {
|
||||
return fmt.Errorf("render body: %w", err)
|
||||
}
|
||||
textBody := htmlToText(htmlBody)
|
||||
|
||||
// Custom envelope — we want Reply-To: sender so replies route to the
|
||||
// human who composed the broadcast.
|
||||
if !s.mail.Enabled() {
|
||||
slog.Debug("broadcast: SendOne skipped (mail disabled)",
|
||||
"broadcast_id", broadcastID, "to", rec.Email)
|
||||
return nil
|
||||
}
|
||||
msg := buildMIMEWithReplyTo(s.mail.cfg.From, s.mail.cfg.FromName, sender.Email,
|
||||
rec.Email, rendered, htmlBody, textBody)
|
||||
deliverDone := make(chan error, 1)
|
||||
go func() {
|
||||
deliverDone <- s.mail.deliver(rec.Email, msg)
|
||||
}()
|
||||
select {
|
||||
case err := <-deliverDone:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// renderBroadcastBody wraps the personalised Markdown body in the
|
||||
// standard base.html (DB override or embedded fallback) so broadcast
|
||||
// emails look like the rest of Paliad's mail.
|
||||
func (s *BroadcastService) renderBroadcastBody(ctx context.Context, lang, markdownBody string, sender models.User) (string, error) {
|
||||
htmlContent := renderMarkdownSafe(markdownBody)
|
||||
signature := senderSignature(lang, sender)
|
||||
|
||||
// Build the {{define "content"}} block expected by base.html. The
|
||||
// inner HTML is treated as trusted output (we generated it from
|
||||
// known-safe Markdown rules). Senders can't sneak script tags
|
||||
// because renderMarkdownSafe escapes everything before re-introducing
|
||||
// the whitelisted markup.
|
||||
contentBlock := fmt.Sprintf(`{{define "content"}}%s%s{{end}}`, htmlContent, signature)
|
||||
|
||||
// Look up base.html (key='base'). Same fallback discipline as
|
||||
// MailService.RenderTemplate — if the active row is malformed we
|
||||
// retry with the embedded default.
|
||||
var (
|
||||
baseBody string
|
||||
err error
|
||||
)
|
||||
if s.templates != nil {
|
||||
row, lookupErr := s.templates.GetActive(ctx, EmailTemplateKeyBase, lang)
|
||||
if lookupErr != nil {
|
||||
return "", fmt.Errorf("lookup base template: %w", lookupErr)
|
||||
}
|
||||
baseBody = row.Body
|
||||
} else {
|
||||
baseBody, err = readEmbeddedBody(EmailTemplateKeyBase, lang)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read embedded base: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"Lang": lang,
|
||||
"Firm": branding.Name,
|
||||
"Subject": "", // base.html title field; we don't need it here.
|
||||
}
|
||||
|
||||
html, err := renderBaseAndContent(baseBody, contentBlock, payload)
|
||||
if err == nil {
|
||||
return html, nil
|
||||
}
|
||||
// Active row malformed — fall back to embedded.
|
||||
slog.Error("broadcast: base render failed, falling back to embedded",
|
||||
"lang", lang, "error", err)
|
||||
fbBase, fbErr := readEmbeddedBody(EmailTemplateKeyBase, lang)
|
||||
if fbErr != nil {
|
||||
return "", fmt.Errorf("fallback base: %w", fbErr)
|
||||
}
|
||||
return renderBaseAndContent(fbBase, contentBlock, payload)
|
||||
}
|
||||
|
||||
// substitutePlaceholders replaces {{name}}, {{first_name}}, and
|
||||
// {{role_on_project}} with the per-recipient values. Whitespace
|
||||
// inside the braces is tolerated. Unknown {{...}} tokens pass through
|
||||
// untouched so a sender's accidental "literal {{example}}" stays
|
||||
// readable in the rendered mail.
|
||||
func substitutePlaceholders(src string, rec BroadcastRecipient) string {
|
||||
repl := strings.NewReplacer(
|
||||
"{{name}}", rec.DisplayName,
|
||||
"{{ name }}", rec.DisplayName,
|
||||
"{{first_name}}", rec.FirstName,
|
||||
"{{ first_name }}", rec.FirstName,
|
||||
"{{role_on_project}}", rec.RoleOnProject,
|
||||
"{{ role_on_project }}", rec.RoleOnProject,
|
||||
)
|
||||
return repl.Replace(src)
|
||||
}
|
||||
|
||||
// senderSignature appends a "Geschickt von <DisplayName> <email>"
|
||||
// footer below the body so the recipient sees who wrote the mail
|
||||
// even though From: is the SMTP infrastructure address.
|
||||
func senderSignature(lang string, sender models.User) string {
|
||||
prefix := "Gesendet von"
|
||||
if lang == "en" {
|
||||
prefix = "Sent by"
|
||||
}
|
||||
if sender.DisplayName == "" {
|
||||
return fmt.Sprintf(`<p style="margin-top:24px;font-size:13px;color:#78716c;">%s <a href="mailto:%s">%s</a></p>`,
|
||||
prefix, escapeHTML(sender.Email), escapeHTML(sender.Email))
|
||||
}
|
||||
return fmt.Sprintf(`<p style="margin-top:24px;font-size:13px;color:#78716c;">%s %s <<a href="mailto:%s">%s</a>></p>`,
|
||||
prefix, escapeHTML(sender.DisplayName), escapeHTML(sender.Email), escapeHTML(sender.Email))
|
||||
}
|
||||
|
||||
// filterMapOrEmpty normalises a nil filter map to an empty one for
|
||||
// jsonb persistence.
|
||||
func filterMapOrEmpty(in map[string]any) map[string]any {
|
||||
if in == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// --- broadcast list / get queries ----------------------------------
|
||||
|
||||
// BroadcastListEntry is one row on the /admin/broadcasts list.
|
||||
type BroadcastListEntry struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Subject string `db:"subject" json:"subject"`
|
||||
SenderID uuid.UUID `db:"sender_id" json:"sender_id"`
|
||||
SenderName string `db:"sender_name" json:"sender_name"`
|
||||
SenderEmail string `db:"sender_email" json:"sender_email"`
|
||||
RecipientCount int `db:"recipient_count" json:"recipient_count"`
|
||||
SentAt time.Time `db:"sent_at" json:"sent_at"`
|
||||
TemplateKey *string `db:"template_key" json:"template_key,omitempty"`
|
||||
}
|
||||
|
||||
// BroadcastDetail is the per-row detail view.
|
||||
type BroadcastDetail struct {
|
||||
BroadcastListEntry
|
||||
Body string `db:"body" json:"body"`
|
||||
RecipientFilter json.RawMessage `db:"recipient_filter" json:"recipient_filter"`
|
||||
SendReport json.RawMessage `db:"send_report" json:"send_report"`
|
||||
Recipients []BroadcastDetailRecipient `json:"recipients"`
|
||||
}
|
||||
|
||||
// BroadcastDetailRecipient is one resolved addressee on the detail page.
|
||||
// Names are joined from paliad.users at read time so the most recent
|
||||
// display_name shows up; the audit row only retains the user_id.
|
||||
type BroadcastDetailRecipient struct {
|
||||
UserID uuid.UUID `db:"id" json:"id"`
|
||||
Email string `db:"email" json:"email"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
}
|
||||
|
||||
// List returns broadcasts visible to the caller. global_admin sees
|
||||
// every row; everyone else sees only their own sends.
|
||||
func (s *BroadcastService) List(ctx context.Context, callerID uuid.UUID, limit int) ([]BroadcastListEntry, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
caller, err := s.users.GetByID(ctx, callerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load caller: %w", err)
|
||||
}
|
||||
if caller == nil {
|
||||
return nil, ErrBroadcastForbidden
|
||||
}
|
||||
|
||||
var (
|
||||
rows []BroadcastListEntry
|
||||
q string
|
||||
args []any
|
||||
)
|
||||
if caller.GlobalRole == "global_admin" {
|
||||
q = listBroadcastsSQL + ` ORDER BY b.sent_at DESC LIMIT $1`
|
||||
args = []any{limit}
|
||||
} else {
|
||||
q = listBroadcastsSQL + ` WHERE b.sender_id = $1 ORDER BY b.sent_at DESC LIMIT $2`
|
||||
args = []any{callerID, limit}
|
||||
}
|
||||
if err := s.db.SelectContext(ctx, &rows, q, args...); err != nil {
|
||||
return nil, fmt.Errorf("list broadcasts: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// Get returns one broadcast plus its resolved recipient list. Same
|
||||
// visibility rules as List.
|
||||
func (s *BroadcastService) Get(ctx context.Context, callerID, id uuid.UUID) (*BroadcastDetail, error) {
|
||||
caller, err := s.users.GetByID(ctx, callerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load caller: %w", err)
|
||||
}
|
||||
if caller == nil {
|
||||
return nil, ErrBroadcastForbidden
|
||||
}
|
||||
var detail BroadcastDetail
|
||||
q := `
|
||||
SELECT b.id, b.subject, b.sender_id, b.template_key,
|
||||
array_length(b.recipient_user_ids, 1) AS recipient_count,
|
||||
b.sent_at, b.body, b.recipient_filter, b.send_report,
|
||||
u.display_name AS sender_name, u.email AS sender_email
|
||||
FROM paliad.email_broadcasts b
|
||||
LEFT JOIN paliad.users u ON u.id = b.sender_id
|
||||
WHERE b.id = $1`
|
||||
if err := s.db.GetContext(ctx, &detail, q, id); err != nil {
|
||||
return nil, fmt.Errorf("get broadcast: %w", err)
|
||||
}
|
||||
if caller.GlobalRole != "global_admin" && detail.SenderID != callerID {
|
||||
return nil, ErrBroadcastForbidden
|
||||
}
|
||||
|
||||
// Resolve recipient names. The audit row stores user_ids only; we
|
||||
// re-join paliad.users at read time so renames flow through. The
|
||||
// uuid[] column comes back as pq.Array; copy it out for sqlx.
|
||||
var idArr pq.StringArray
|
||||
if err := s.db.GetContext(ctx, &idArr,
|
||||
`SELECT recipient_user_ids::text[] FROM paliad.email_broadcasts WHERE id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("load recipient ids: %w", err)
|
||||
}
|
||||
recipientIDs := make([]uuid.UUID, 0, len(idArr))
|
||||
for _, s := range idArr {
|
||||
if uid, err := uuid.Parse(s); err == nil {
|
||||
recipientIDs = append(recipientIDs, uid)
|
||||
}
|
||||
}
|
||||
if len(recipientIDs) > 0 {
|
||||
var rec []BroadcastDetailRecipient
|
||||
if err := s.db.SelectContext(ctx, &rec,
|
||||
`SELECT id, email, display_name
|
||||
FROM paliad.users
|
||||
WHERE id = ANY($1)`, pq.Array(recipientIDs),
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("load recipients: %w", err)
|
||||
}
|
||||
// Preserve the audit-row order — clients want the original
|
||||
// dispatch list, not whatever paliad.users ordered them by.
|
||||
byID := make(map[uuid.UUID]BroadcastDetailRecipient, len(rec))
|
||||
for _, r := range rec {
|
||||
byID[r.UserID] = r
|
||||
}
|
||||
ordered := make([]BroadcastDetailRecipient, 0, len(recipientIDs))
|
||||
for _, uid := range recipientIDs {
|
||||
if r, ok := byID[uid]; ok {
|
||||
ordered = append(ordered, r)
|
||||
continue
|
||||
}
|
||||
// User row was deleted post-broadcast. Show the bare ID so
|
||||
// the audit page still accounts for the slot.
|
||||
ordered = append(ordered, BroadcastDetailRecipient{UserID: uid})
|
||||
}
|
||||
detail.Recipients = ordered
|
||||
}
|
||||
return &detail, nil
|
||||
}
|
||||
|
||||
const listBroadcastsSQL = `
|
||||
SELECT b.id, b.subject, b.sender_id, b.template_key,
|
||||
COALESCE(array_length(b.recipient_user_ids, 1), 0) AS recipient_count,
|
||||
b.sent_at,
|
||||
u.display_name AS sender_name, u.email AS sender_email
|
||||
FROM paliad.email_broadcasts b
|
||||
LEFT JOIN paliad.users u ON u.id = b.sender_id
|
||||
`
|
||||
|
||||
191
internal/services/broadcast_service_live_test.go
Normal file
191
internal/services/broadcast_service_live_test.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
// TestBroadcastService_SendAndAudit_Live exercises the full BroadcastService
|
||||
// pipeline against a real Postgres: the row lands in paliad.email_broadcasts,
|
||||
// the send_report jsonb captures per-recipient outcomes, and List/Get
|
||||
// honours the visibility rules (sender sees own; global_admin sees all).
|
||||
//
|
||||
// SMTP delivery is not exercised — the MailService is left disabled
|
||||
// (Enabled() == false) so sendOne short-circuits cleanly. That's the same
|
||||
// contract the dev/preview deploys run under.
|
||||
//
|
||||
// Skipped when TEST_DATABASE_URL is unset.
|
||||
func TestBroadcastService_SendAndAudit_Live(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
leadID := uuid.New()
|
||||
memberID := uuid.New()
|
||||
otherSenderID := uuid.New()
|
||||
projectID := uuid.New()
|
||||
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
|
||||
VALUES ($1, $2, 'Bcast Lead', 'munich', 'standard', 'de'),
|
||||
($3, $4, 'Bcast Mem', 'munich', 'standard', 'de'),
|
||||
($5, $6, 'Bcast Admin', 'munich', 'global_admin', 'de')`,
|
||||
leadID, "bcast-lead@hlc.com",
|
||||
memberID, "bcast-member@hlc.com",
|
||||
otherSenderID, "bcast-admin@hlc.com",
|
||||
); err != nil {
|
||||
t.Fatalf("seed users: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.ExecContext(ctx, `DELETE FROM paliad.email_broadcasts WHERE sender_id = ANY($1)`,
|
||||
[]string{leadID.String(), otherSenderID.String()})
|
||||
_, _ = pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, projectID)
|
||||
_, _ = pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, projectID)
|
||||
_, _ = pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = ANY($1)`,
|
||||
[]string{leadID.String(), memberID.String(), otherSenderID.String()})
|
||||
})
|
||||
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects (id, type, path, title, status, created_by)
|
||||
VALUES ($1, 'project', $1::text, 'Bcast Project', 'active', $2)`,
|
||||
projectID, leadID,
|
||||
); err != nil {
|
||||
t.Fatalf("seed project: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_teams (project_id, user_id, role, inherited, added_by)
|
||||
VALUES ($1, $2, 'lead', false, $2),
|
||||
($1, $3, 'associate', false, $2)`,
|
||||
projectID, leadID, memberID,
|
||||
); err != nil {
|
||||
t.Fatalf("seed team: %v", err)
|
||||
}
|
||||
|
||||
users := NewUserService(pool)
|
||||
projectSvc := NewProjectService(pool, users)
|
||||
teamSvc := NewTeamService(pool, projectSvc)
|
||||
mailSvc, err := NewMailService()
|
||||
if err != nil {
|
||||
t.Fatalf("mail svc: %v", err)
|
||||
}
|
||||
tplSvc := NewEmailTemplateService(pool)
|
||||
mailSvc.SetTemplateService(tplSvc)
|
||||
bcast := NewBroadcastService(pool, mailSvc, users, teamSvc, tplSvc)
|
||||
|
||||
// --- 1. lead can send a broadcast on their project --------------
|
||||
pid := projectID
|
||||
report, err := bcast.Send(ctx, leadID, BroadcastInput{
|
||||
ProjectID: &pid,
|
||||
Subject: "Hallo Team",
|
||||
Body: "Hi {{first_name}}, kurze Nachricht.",
|
||||
Recipients: []BroadcastRecipient{{
|
||||
UserID: memberID,
|
||||
Email: "bcast-member@hlc.com",
|
||||
DisplayName: "Bcast Mem",
|
||||
FirstName: "Bcast",
|
||||
RoleOnProject: "associate",
|
||||
}},
|
||||
RecipientFilter: map[string]any{"project_ids": []string{pid.String()}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Send (lead): %v", err)
|
||||
}
|
||||
if report.BroadcastID == uuid.Nil {
|
||||
t.Fatal("BroadcastID empty")
|
||||
}
|
||||
if report.Total != 1 {
|
||||
t.Errorf("Total=%d, want 1", report.Total)
|
||||
}
|
||||
if report.Sent != 1 || report.Failed != 0 {
|
||||
t.Errorf("Sent=%d Failed=%d, want Sent=1 Failed=0", report.Sent, report.Failed)
|
||||
}
|
||||
|
||||
// --- 2. non-lead sender (member) → forbidden --------------------
|
||||
_, err = bcast.Send(ctx, memberID, BroadcastInput{
|
||||
ProjectID: &pid,
|
||||
Subject: "Should fail",
|
||||
Body: "x",
|
||||
Recipients: []BroadcastRecipient{{
|
||||
UserID: leadID, Email: "bcast-lead@hlc.com", DisplayName: "Bcast Lead",
|
||||
}},
|
||||
})
|
||||
if err == nil || !errorIs(err, ErrBroadcastForbidden) {
|
||||
t.Errorf("non-lead Send: got %v, want ErrBroadcastForbidden", err)
|
||||
}
|
||||
|
||||
// --- 3. global_admin sees all rows in List ----------------------
|
||||
rowsAdmin, err := bcast.List(ctx, otherSenderID, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("List(admin): %v", err)
|
||||
}
|
||||
foundOurRow := false
|
||||
for _, r := range rowsAdmin {
|
||||
if r.ID == report.BroadcastID {
|
||||
foundOurRow = true
|
||||
if r.RecipientCount != 1 {
|
||||
t.Errorf("RecipientCount=%d, want 1", r.RecipientCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundOurRow {
|
||||
t.Error("admin's List did not include our broadcast")
|
||||
}
|
||||
|
||||
// --- 4. lead sees own rows --------------------------------------
|
||||
rowsLead, err := bcast.List(ctx, leadID, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("List(lead): %v", err)
|
||||
}
|
||||
if len(rowsLead) == 0 || rowsLead[0].ID != report.BroadcastID {
|
||||
t.Errorf("lead List didn't return own row; got %+v", rowsLead)
|
||||
}
|
||||
|
||||
// --- 5. non-sender, non-admin gets nothing back -----------------
|
||||
rowsMember, err := bcast.List(ctx, memberID, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("List(member): %v", err)
|
||||
}
|
||||
for _, r := range rowsMember {
|
||||
if r.ID == report.BroadcastID {
|
||||
t.Errorf("member should not see lead's broadcast %s", r.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 6. Get returns full detail w/ recipients -------------------
|
||||
detail, err := bcast.Get(ctx, leadID, report.BroadcastID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if detail.Subject != "Hallo Team" {
|
||||
t.Errorf("Subject=%q", detail.Subject)
|
||||
}
|
||||
if len(detail.Recipients) != 1 {
|
||||
t.Errorf("Recipients=%d, want 1", len(detail.Recipients))
|
||||
}
|
||||
if len(detail.Recipients) >= 1 && detail.Recipients[0].UserID != memberID {
|
||||
t.Errorf("Recipients[0].UserID=%s, want %s", detail.Recipients[0].UserID, memberID)
|
||||
}
|
||||
|
||||
// --- 7. member calling Get on lead's row → forbidden -----------
|
||||
if _, err := bcast.Get(ctx, memberID, report.BroadcastID); err == nil ||
|
||||
!errorIs(err, ErrBroadcastForbidden) {
|
||||
t.Errorf("member Get: got %v, want ErrBroadcastForbidden", err)
|
||||
}
|
||||
}
|
||||
233
internal/services/broadcast_service_test.go
Normal file
233
internal/services/broadcast_service_test.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
func TestSubstitutePlaceholders(t *testing.T) {
|
||||
rec := BroadcastRecipient{
|
||||
UserID: uuid.New(),
|
||||
Email: "anna@hlc.com",
|
||||
DisplayName: "Anna Beispiel",
|
||||
FirstName: "Anna",
|
||||
RoleOnProject: "lead",
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"name", "Hallo {{name}}", "Hallo Anna Beispiel"},
|
||||
{"first_name", "Hi {{first_name}}!", "Hi Anna!"},
|
||||
{"role_on_project", "Du bist {{role_on_project}}.", "Du bist lead."},
|
||||
{"whitespace tolerated", "{{ first_name }}", "Anna"},
|
||||
{"unknown token passes through", "Literal {{example}} stays", "Literal {{example}} stays"},
|
||||
{"all three together",
|
||||
"{{name}} ({{first_name}}, {{role_on_project}})",
|
||||
"Anna Beispiel (Anna, lead)"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := substitutePlaceholders(tc.in, rec)
|
||||
if got != tc.want {
|
||||
t.Errorf("got %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// renderMarkdownSafe must escape raw HTML and only re-emit a small whitelist
|
||||
// of tags. Any leakage of a <script> tag would be an XSS vector since the
|
||||
// rendered output goes straight into an HTML email body.
|
||||
func TestRenderMarkdownSafe(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
wantContains []string
|
||||
wantMissing []string
|
||||
}{
|
||||
{
|
||||
name: "bold",
|
||||
in: "**hallo**",
|
||||
wantContains: []string{"<strong>hallo</strong>"},
|
||||
},
|
||||
{
|
||||
name: "italic underscore",
|
||||
in: "_hallo_",
|
||||
wantContains: []string{"<em>hallo</em>"},
|
||||
},
|
||||
{
|
||||
name: "link",
|
||||
in: "[paliad](https://paliad.de)",
|
||||
wantContains: []string{`<a href="https://paliad.de">paliad</a>`},
|
||||
},
|
||||
{
|
||||
name: "bullet list",
|
||||
in: "- erstens\n- zweitens",
|
||||
wantContains: []string{"<ul>", "<li>erstens</li>", "<li>zweitens</li>", "</ul>"},
|
||||
},
|
||||
{
|
||||
name: "paragraph break",
|
||||
in: "Erste Zeile\n\nZweite Zeile",
|
||||
wantContains: []string{"<p>Erste Zeile</p>", "<p>Zweite Zeile</p>"},
|
||||
},
|
||||
{
|
||||
name: "single newline → br",
|
||||
in: "Zeile A\nZeile B",
|
||||
wantContains: []string{"<p>Zeile A<br>", "Zeile B</p>"},
|
||||
},
|
||||
{
|
||||
name: "script tag escaped",
|
||||
in: "Hallo <script>alert(1)</script>",
|
||||
wantContains: []string{"<script>", "</script>"},
|
||||
wantMissing: []string{"<script>", "alert(1)</script>"},
|
||||
},
|
||||
{
|
||||
name: "link injection attempt — javascript: URL is rejected",
|
||||
in: "[click](javascript:alert(1))",
|
||||
wantMissing: []string{`href="javascript:`},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := renderMarkdownSafe(tc.in)
|
||||
for _, want := range tc.wantContains {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in %q", want, got)
|
||||
}
|
||||
}
|
||||
for _, miss := range tc.wantMissing {
|
||||
if strings.Contains(got, miss) {
|
||||
t.Errorf("unexpected %q in %q", miss, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstNameExtraction(t *testing.T) {
|
||||
// senderSignature uses DisplayName directly; firstName extraction is
|
||||
// frontend-side. Smoke-test only that DisplayName placeholder lands.
|
||||
sender := models.User{
|
||||
ID: uuid.New(),
|
||||
Email: "max@hlc.com",
|
||||
DisplayName: "Max Mustermann",
|
||||
}
|
||||
sig := senderSignature("de", sender)
|
||||
if !strings.Contains(sig, "Max Mustermann") {
|
||||
t.Errorf("DisplayName not in signature: %q", sig)
|
||||
}
|
||||
if !strings.Contains(sig, "Gesendet von") {
|
||||
t.Errorf("DE prefix missing: %q", sig)
|
||||
}
|
||||
if !strings.Contains(sig, `mailto:max@hlc.com`) {
|
||||
t.Errorf("mailto link missing: %q", sig)
|
||||
}
|
||||
sigEN := senderSignature("en", sender)
|
||||
if !strings.Contains(sigEN, "Sent by") {
|
||||
t.Errorf("EN prefix missing: %q", sigEN)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcastValidation exercises the cheap guards that fire before any
|
||||
// SQL or SMTP I/O. Constructed with a nil DB so the tests don't need a
|
||||
// connection string. The Send path bails out at validation before touching
|
||||
// db.ExecContext.
|
||||
func TestBroadcastValidation(t *testing.T) {
|
||||
mailSvc, err := NewMailService()
|
||||
if err != nil {
|
||||
t.Fatalf("NewMailService: %v", err)
|
||||
}
|
||||
svc := NewBroadcastService(nil, mailSvc, nil, nil, NewEmailTemplateService(nil))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in BroadcastInput
|
||||
want error
|
||||
}{
|
||||
{
|
||||
name: "empty subject",
|
||||
in: BroadcastInput{Subject: "", Body: "x", Recipients: oneRec()},
|
||||
want: ErrBroadcastEmptySubject,
|
||||
},
|
||||
{
|
||||
name: "empty body",
|
||||
in: BroadcastInput{Subject: "Hi", Body: " ", Recipients: oneRec()},
|
||||
want: ErrBroadcastEmptyBody,
|
||||
},
|
||||
{
|
||||
name: "no recipients",
|
||||
in: BroadcastInput{Subject: "Hi", Body: "x", Recipients: nil},
|
||||
want: ErrBroadcastNoRecipients,
|
||||
},
|
||||
{
|
||||
name: "too many recipients",
|
||||
in: BroadcastInput{Subject: "Hi", Body: "x", Recipients: nRecipients(BroadcastRecipientCap + 1)},
|
||||
want: ErrBroadcastTooManyRecipients,
|
||||
},
|
||||
{
|
||||
name: "invalid email",
|
||||
in: BroadcastInput{
|
||||
Subject: "Hi",
|
||||
Body: "x",
|
||||
Recipients: []BroadcastRecipient{{
|
||||
UserID: uuid.New(),
|
||||
Email: "not-an-email",
|
||||
}},
|
||||
},
|
||||
want: ErrBroadcastInvalidEmail,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := svc.Send(t.Context(), uuid.New(), tc.in)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
// Use errors.Is so wrapped errors still match.
|
||||
if !errorIs(err, tc.want) {
|
||||
t.Errorf("got %v, want %v", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// errorIs is a tiny shim so the test file doesn't need to import "errors".
|
||||
// (Imports are kept terse on purpose — see existing test files.)
|
||||
func errorIs(have, want error) bool {
|
||||
if have == want {
|
||||
return true
|
||||
}
|
||||
if have == nil || want == nil {
|
||||
return false
|
||||
}
|
||||
// Fall back to message-level matching for fmt.Errorf %w wraps.
|
||||
return strings.Contains(have.Error(), want.Error())
|
||||
}
|
||||
|
||||
func oneRec() []BroadcastRecipient {
|
||||
return []BroadcastRecipient{{
|
||||
UserID: uuid.New(),
|
||||
Email: "anna@hlc.com",
|
||||
DisplayName: "Anna",
|
||||
FirstName: "Anna",
|
||||
}}
|
||||
}
|
||||
|
||||
func nRecipients(n int) []BroadcastRecipient {
|
||||
out := make([]BroadcastRecipient, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, BroadcastRecipient{
|
||||
UserID: uuid.New(),
|
||||
Email: "user@hlc.com",
|
||||
DisplayName: "User",
|
||||
FirstName: "User",
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
310
internal/services/card_layout_service.go
Normal file
310
internal/services/card_layout_service.go
Normal file
@@ -0,0 +1,310 @@
|
||||
package services
|
||||
|
||||
// CardLayoutService is the CRUD layer for paliad.user_card_layouts —
|
||||
// per-user named card layouts for the /projects Cards view.
|
||||
//
|
||||
// Design: docs/design-projects-page-2026-05-07.md §5b.3.
|
||||
//
|
||||
// Visibility: every read and write is scoped to the calling user via the
|
||||
// RLS policy `user_card_layouts_owner_all` on auth.uid() = user_id. The
|
||||
// service also AND-joins user_id in the SQL for defense-in-depth (RLS
|
||||
// can be disabled in tests, the code-level check still holds).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// UserCardLayout is the persisted shape of a saved card layout.
|
||||
type UserCardLayout struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
IsDefault bool `db:"is_default" json:"is_default"`
|
||||
LayoutJSON json.RawMessage `db:"layout_json" json:"layout"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// CardLayoutService manages paliad.user_card_layouts.
|
||||
type CardLayoutService struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// NewCardLayoutService wires the service.
|
||||
func NewCardLayoutService(db *sqlx.DB) *CardLayoutService {
|
||||
return &CardLayoutService{db: db}
|
||||
}
|
||||
|
||||
// ErrUserCardLayoutNameTaken signals "name already exists for this user".
|
||||
// HTTP layer maps to 409.
|
||||
var ErrUserCardLayoutNameTaken = errors.New("card layout name taken")
|
||||
|
||||
// ErrUserCardLayoutNotFound signals "no row matches (id, user_id)". HTTP
|
||||
// layer maps to 404.
|
||||
var ErrUserCardLayoutNotFound = errors.New("card layout not found")
|
||||
|
||||
// ErrUserCardLayoutDefaultGate signals "cannot delete the active default
|
||||
// layout — switch defaults first." HTTP layer maps to 409.
|
||||
var ErrUserCardLayoutDefaultGate = errors.New("cannot delete default card layout")
|
||||
|
||||
// CreateInput is the payload for Create.
|
||||
type CreateCardLayoutInput struct {
|
||||
Name string
|
||||
Layout LayoutSpec
|
||||
IsDefault bool // first layout per user is implicitly the default
|
||||
}
|
||||
|
||||
// UpdateInput is the partial-update payload. All fields nil = no change.
|
||||
type UpdateCardLayoutInput struct {
|
||||
Name *string
|
||||
Layout *LayoutSpec
|
||||
IsDefault *bool
|
||||
}
|
||||
|
||||
// List returns the user's layouts in name order, default first.
|
||||
func (s *CardLayoutService) List(ctx context.Context, userID uuid.UUID) ([]UserCardLayout, error) {
|
||||
out := []UserCardLayout{}
|
||||
err := s.db.SelectContext(ctx, &out, `
|
||||
SELECT id, user_id, name, is_default, layout_json, created_at, updated_at
|
||||
FROM paliad.user_card_layouts
|
||||
WHERE user_id = $1
|
||||
ORDER BY is_default DESC, name ASC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list card layouts: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Get returns one layout by id (gated on user_id).
|
||||
func (s *CardLayoutService) Get(ctx context.Context, userID, id uuid.UUID) (*UserCardLayout, error) {
|
||||
var l UserCardLayout
|
||||
err := s.db.GetContext(ctx, &l, `
|
||||
SELECT id, user_id, name, is_default, layout_json, created_at, updated_at
|
||||
FROM paliad.user_card_layouts
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`, id, userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrUserCardLayoutNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get card layout: %w", err)
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
// GetDefault returns the user's default layout. Auto-seeds the seed
|
||||
// "Standard" layout (DefaultLayoutSpec) on the first call so callers can
|
||||
// always treat it as never-failing for read-only paths.
|
||||
func (s *CardLayoutService) GetDefault(ctx context.Context, userID uuid.UUID) (*UserCardLayout, error) {
|
||||
var l UserCardLayout
|
||||
err := s.db.GetContext(ctx, &l, `
|
||||
SELECT id, user_id, name, is_default, layout_json, created_at, updated_at
|
||||
FROM paliad.user_card_layouts
|
||||
WHERE user_id = $1 AND is_default = true
|
||||
`, userID)
|
||||
if err == nil {
|
||||
return &l, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("get default card layout: %w", err)
|
||||
}
|
||||
// First-ever call for this user — seed the Standard layout.
|
||||
return s.Create(ctx, userID, CreateCardLayoutInput{
|
||||
Name: "Standard",
|
||||
Layout: DefaultLayoutSpec(),
|
||||
IsDefault: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Create writes a new layout. If the user has no rows yet, the new row
|
||||
// becomes the default regardless of input.IsDefault. If input.IsDefault
|
||||
// is true and another default exists, the previous default's flag is
|
||||
// cleared in the same transaction.
|
||||
func (s *CardLayoutService) Create(ctx context.Context, userID uuid.UUID, in CreateCardLayoutInput) (*UserCardLayout, error) {
|
||||
if err := validateLayoutName(in.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := in.Layout.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
layoutBytes, err := json.Marshal(in.Layout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: layout JSON encode: %v", ErrInvalidInput, err)
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create card layout begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck — Commit() supersedes; Rollback() on success is a no-op
|
||||
|
||||
var existingCount int
|
||||
if err := tx.GetContext(ctx, &existingCount, `
|
||||
SELECT COUNT(*) FROM paliad.user_card_layouts WHERE user_id = $1
|
||||
`, userID); err != nil {
|
||||
return nil, fmt.Errorf("count existing layouts: %w", err)
|
||||
}
|
||||
wantDefault := in.IsDefault || existingCount == 0
|
||||
if wantDefault {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE paliad.user_card_layouts SET is_default = false, updated_at = now()
|
||||
WHERE user_id = $1 AND is_default = true
|
||||
`, userID); err != nil {
|
||||
return nil, fmt.Errorf("clear prior default: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var l UserCardLayout
|
||||
err = tx.GetContext(ctx, &l, `
|
||||
INSERT INTO paliad.user_card_layouts (user_id, name, is_default, layout_json)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, user_id, name, is_default, layout_json, created_at, updated_at
|
||||
`, userID, in.Name, wantDefault, json.RawMessage(layoutBytes))
|
||||
if err != nil {
|
||||
if isPgUniqueViolation(err) {
|
||||
return nil, ErrUserCardLayoutNameTaken
|
||||
}
|
||||
return nil, fmt.Errorf("insert card layout: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("create card layout commit: %w", err)
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
// Update writes one or more partial fields. is_default=true flips the
|
||||
// user's default in a transaction.
|
||||
func (s *CardLayoutService) Update(ctx context.Context, userID, id uuid.UUID, in UpdateCardLayoutInput) (*UserCardLayout, error) {
|
||||
// First ensure the row exists + is owned.
|
||||
if _, err := s.Get(ctx, userID, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if in.Name != nil {
|
||||
if err := validateLayoutName(*in.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if in.Layout != nil {
|
||||
if err := in.Layout.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update card layout begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
if in.IsDefault != nil && *in.IsDefault {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE paliad.user_card_layouts SET is_default = false, updated_at = now()
|
||||
WHERE user_id = $1 AND is_default = true AND id <> $2
|
||||
`, userID, id); err != nil {
|
||||
return nil, fmt.Errorf("clear prior default: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
sets := []string{"updated_at = now()"}
|
||||
args := []any{userID, id}
|
||||
if in.Name != nil {
|
||||
sets = append(sets, fmt.Sprintf("name = $%d", len(args)+1))
|
||||
args = append(args, *in.Name)
|
||||
}
|
||||
if in.Layout != nil {
|
||||
layoutBytes, err := json.Marshal(*in.Layout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: layout JSON encode: %v", ErrInvalidInput, err)
|
||||
}
|
||||
sets = append(sets, fmt.Sprintf("layout_json = $%d", len(args)+1))
|
||||
args = append(args, json.RawMessage(layoutBytes))
|
||||
}
|
||||
if in.IsDefault != nil {
|
||||
sets = append(sets, fmt.Sprintf("is_default = $%d", len(args)+1))
|
||||
args = append(args, *in.IsDefault)
|
||||
}
|
||||
|
||||
q := fmt.Sprintf(`
|
||||
UPDATE paliad.user_card_layouts SET %s
|
||||
WHERE user_id = $1 AND id = $2
|
||||
RETURNING id, user_id, name, is_default, layout_json, created_at, updated_at
|
||||
`, strings.Join(sets, ", "))
|
||||
|
||||
var l UserCardLayout
|
||||
err = tx.GetContext(ctx, &l, q, args...)
|
||||
if err != nil {
|
||||
if isPgUniqueViolation(err) {
|
||||
return nil, ErrUserCardLayoutNameTaken
|
||||
}
|
||||
return nil, fmt.Errorf("update card layout: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("update card layout commit: %w", err)
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
// SetDefault is sugar over Update with only IsDefault set. It also flips
|
||||
// the prior default in the same transaction.
|
||||
func (s *CardLayoutService) SetDefault(ctx context.Context, userID, id uuid.UUID) (*UserCardLayout, error) {
|
||||
def := true
|
||||
return s.Update(ctx, userID, id, UpdateCardLayoutInput{IsDefault: &def})
|
||||
}
|
||||
|
||||
// Delete removes a layout. Cannot delete the active default — UI gates
|
||||
// this; the service returns ErrUserCardLayoutDefaultGate as a backstop.
|
||||
func (s *CardLayoutService) Delete(ctx context.Context, userID, id uuid.UUID) error {
|
||||
row, err := s.Get(ctx, userID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if row.IsDefault {
|
||||
return ErrUserCardLayoutDefaultGate
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `
|
||||
DELETE FROM paliad.user_card_layouts
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`, id, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete card layout: %w", err)
|
||||
}
|
||||
rows, _ := res.RowsAffected()
|
||||
if rows == 0 {
|
||||
return ErrUserCardLayoutNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateLayoutName mirrors the column's NOT NULL + a sane length cap so
|
||||
// the UI dropdown doesn't have 200-char names breaking the layout. Named
|
||||
// distinctly from user_view_service.validateName because the user_view
|
||||
// rules differ (they enforce a slug regex separately).
|
||||
func validateLayoutName(name string) error {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return fmt.Errorf("%w: name is required", ErrInvalidInput)
|
||||
}
|
||||
if len([]rune(name)) > 80 {
|
||||
return fmt.Errorf("%w: name exceeds 80 characters", ErrInvalidInput)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isPgUniqueViolation reports whether err is a Postgres unique-violation
|
||||
// (SQLSTATE 23505). Used to map "duplicate (user_id, name)" to a clean
|
||||
// 409 ErrUserCardLayoutNameTaken.
|
||||
func isPgUniqueViolation(err error) bool {
|
||||
var pgErr *pq.Error
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
||||
}
|
||||
231
internal/services/card_layout_service_test.go
Normal file
231
internal/services/card_layout_service_test.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package services
|
||||
|
||||
// Live-DB tests for CardLayoutService. Skipped when TEST_DATABASE_URL
|
||||
// is unset.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
type cardLayoutTestEnv struct {
|
||||
t *testing.T
|
||||
pool *sqlx.DB
|
||||
svc *CardLayoutService
|
||||
userID uuid.UUID
|
||||
cleanup func()
|
||||
}
|
||||
|
||||
func setupCardLayoutTest(t *testing.T) *cardLayoutTestEnv {
|
||||
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()
|
||||
|
||||
userID := uuid.New()
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO auth.users (id, email) VALUES ($1, $1::text || '@test.local')
|
||||
ON CONFLICT (id) DO NOTHING`, userID); err != nil {
|
||||
t.Logf("skip auth.users seed: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, global_role)
|
||||
VALUES ($1, $1::text || '@test.local', 'Card Layout Test', 'munich', 'standard')
|
||||
ON CONFLICT (id) DO NOTHING`, userID); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
c := context.Background()
|
||||
pool.ExecContext(c, `DELETE FROM paliad.user_card_layouts WHERE user_id = $1`, userID)
|
||||
pool.ExecContext(c, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||
pool.ExecContext(c, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||
pool.Close()
|
||||
}
|
||||
|
||||
return &cardLayoutTestEnv{
|
||||
t: t,
|
||||
pool: pool,
|
||||
svc: NewCardLayoutService(pool),
|
||||
userID: userID,
|
||||
cleanup: cleanup,
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardLayoutService_GetDefaultAutoSeeds(t *testing.T) {
|
||||
env := setupCardLayoutTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// First call seeds the default.
|
||||
def, err := env.svc.GetDefault(ctx, env.userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDefault: %v", err)
|
||||
}
|
||||
if def.Name != "Standard" || !def.IsDefault {
|
||||
t.Errorf("seeded default: name=%q is_default=%v; want Standard, true", def.Name, def.IsDefault)
|
||||
}
|
||||
|
||||
// Second call returns the same row, not a new seed.
|
||||
def2, err := env.svc.GetDefault(ctx, env.userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDefault second: %v", err)
|
||||
}
|
||||
if def2.ID != def.ID {
|
||||
t.Errorf("second GetDefault returned %v; want same id %v", def2.ID, def.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardLayoutService_FirstCreateBecomesDefault(t *testing.T) {
|
||||
env := setupCardLayoutTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
row, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{
|
||||
Name: "Mein Erstes",
|
||||
Layout: DefaultLayoutSpec(),
|
||||
IsDefault: false, // even with false, first row becomes default.
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if !row.IsDefault {
|
||||
t.Errorf("first layout is_default=false; want true (auto-flip)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardLayoutService_SetDefaultClearsPrior(t *testing.T) {
|
||||
env := setupCardLayoutTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
a, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "A", Layout: DefaultLayoutSpec()})
|
||||
if err != nil {
|
||||
t.Fatalf("Create A: %v", err)
|
||||
}
|
||||
if !a.IsDefault {
|
||||
t.Fatalf("A is_default=false; want true")
|
||||
}
|
||||
b, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "B", Layout: DefaultLayoutSpec()})
|
||||
if err != nil {
|
||||
t.Fatalf("Create B: %v", err)
|
||||
}
|
||||
if b.IsDefault {
|
||||
t.Fatalf("B is_default=true; want false (A is still default)")
|
||||
}
|
||||
|
||||
// Flip B → default.
|
||||
bAfter, err := env.svc.SetDefault(ctx, env.userID, b.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("SetDefault B: %v", err)
|
||||
}
|
||||
if !bAfter.IsDefault {
|
||||
t.Errorf("after SetDefault B.is_default=false")
|
||||
}
|
||||
|
||||
// A should no longer be default.
|
||||
aAfter, err := env.svc.Get(ctx, env.userID, a.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get A: %v", err)
|
||||
}
|
||||
if aAfter.IsDefault {
|
||||
t.Errorf("A still is_default=true after B took the flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardLayoutService_DeleteRefusesActiveDefault(t *testing.T) {
|
||||
env := setupCardLayoutTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
row, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "OnlyOne", Layout: DefaultLayoutSpec()})
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
err = env.svc.Delete(ctx, env.userID, row.ID)
|
||||
if !errors.Is(err, ErrUserCardLayoutDefaultGate) {
|
||||
t.Errorf("Delete default = %v; want ErrUserCardLayoutDefaultGate", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardLayoutService_DeleteNonDefault(t *testing.T) {
|
||||
env := setupCardLayoutTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
_, _ = env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "Default", Layout: DefaultLayoutSpec()})
|
||||
b, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "Throwaway", Layout: DefaultLayoutSpec()})
|
||||
if err != nil {
|
||||
t.Fatalf("Create throwaway: %v", err)
|
||||
}
|
||||
if err := env.svc.Delete(ctx, env.userID, b.ID); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if _, err := env.svc.Get(ctx, env.userID, b.ID); !errors.Is(err, ErrUserCardLayoutNotFound) {
|
||||
t.Errorf("Get after delete = %v; want ErrUserCardLayoutNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardLayoutService_DuplicateNameRejected(t *testing.T) {
|
||||
env := setupCardLayoutTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "Same", Layout: DefaultLayoutSpec()}); err != nil {
|
||||
t.Fatalf("Create first: %v", err)
|
||||
}
|
||||
_, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "Same", Layout: DefaultLayoutSpec()})
|
||||
if !errors.Is(err, ErrUserCardLayoutNameTaken) {
|
||||
t.Errorf("duplicate name = %v; want ErrUserCardLayoutNameTaken", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardLayoutService_UpdateRoundTrip(t *testing.T) {
|
||||
env := setupCardLayoutTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
row, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "Editable", Layout: DefaultLayoutSpec()})
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
newName := "Renamed"
|
||||
newLayout := DefaultLayoutSpec()
|
||||
newLayout.Density = CardDensityCompact
|
||||
updated, err := env.svc.Update(ctx, env.userID, row.ID, UpdateCardLayoutInput{
|
||||
Name: &newName,
|
||||
Layout: &newLayout,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
if updated.Name != "Renamed" {
|
||||
t.Errorf("name = %q; want Renamed", updated.Name)
|
||||
}
|
||||
|
||||
parsed, err := ParseLayoutSpec(updated.LayoutJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseLayoutSpec on updated: %v", err)
|
||||
}
|
||||
if parsed.Density != CardDensityCompact {
|
||||
t.Errorf("density round-trip = %q; want compact", parsed.Density)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -61,13 +72,19 @@ type CreateDeadlineInput struct {
|
||||
// UpdateDeadlineInput is the partial-update payload for PATCH.
|
||||
// EventTypeIDs uses pointer-to-slice semantics: nil = leave existing
|
||||
// attachments untouched; non-nil (including empty) = replace.
|
||||
//
|
||||
// ProjectID, when non-nil, moves the deadline under a different project
|
||||
// (t-paliad-140). The caller must be able to see the new project; the
|
||||
// service emits a deadline_project_changed audit row on both the old and
|
||||
// new project so each side's Verlauf still shows the move.
|
||||
type UpdateDeadlineInput struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
DueDate *string `json:"due_date,omitempty"` // YYYY-MM-DD
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
EventTypeIDs *[]uuid.UUID `json:"event_type_ids,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
DueDate *string `json:"due_date,omitempty"` // YYYY-MM-DD
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
EventTypeIDs *[]uuid.UUID `json:"event_type_ids,omitempty"`
|
||||
}
|
||||
|
||||
// DeadlineStatusFilter is a server-side bucket for ListVisibleForUser.
|
||||
@@ -106,12 +123,17 @@ const (
|
||||
// "Nur persönliche" filter on /events (t-paliad-128) — applied on top of
|
||||
// the team-visibility predicate so a deadline a user created on a team
|
||||
// they have since left still doesn't leak through.
|
||||
//
|
||||
// DirectOnly narrows ProjectID from "this project + every descendant" (the
|
||||
// t-paliad-139 subtree default) to "this project only" (t-paliad-152).
|
||||
// Has no effect when ProjectID is nil.
|
||||
type ListFilter struct {
|
||||
Status DeadlineStatusFilter
|
||||
ProjectID *uuid.UUID
|
||||
EventTypeIDs []uuid.UUID
|
||||
IncludeUntyped bool
|
||||
CreatedBy *uuid.UUID
|
||||
DirectOnly bool
|
||||
}
|
||||
|
||||
// ListVisibleForUser returns Deadlines on every Project the user can see,
|
||||
@@ -130,7 +152,11 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
|
||||
"user_id": userID,
|
||||
}
|
||||
if filter.ProjectID != nil {
|
||||
conds = append(conds, projectDescendantPredicate("p"))
|
||||
if filter.DirectOnly {
|
||||
conds = append(conds, `f.project_id = :project_id`)
|
||||
} else {
|
||||
conds = append(conds, projectDescendantPredicate("p"))
|
||||
}
|
||||
args["project_id"] = *filter.ProjectID
|
||||
}
|
||||
if filter.CreatedBy != nil {
|
||||
@@ -186,6 +212,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,
|
||||
@@ -223,16 +250,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)
|
||||
}
|
||||
@@ -359,11 +405,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{}
|
||||
@@ -374,6 +432,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 == "" {
|
||||
@@ -389,6 +454,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 {
|
||||
@@ -410,6 +479,22 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Project move (t-paliad-140). Visibility on the destination is enforced
|
||||
// the same way as on Create — a GetByID round-trip through ProjectService
|
||||
// returns ErrNotVisible if the user can't see the target. Same-project
|
||||
// "moves" are silently dropped so a UI that always sends project_id in
|
||||
// the PATCH payload doesn't churn the Verlauf.
|
||||
var movedFromProject *uuid.UUID
|
||||
if input.ProjectID != nil && *input.ProjectID != current.ProjectID {
|
||||
if _, err := s.projects.GetByID(ctx, userID, *input.ProjectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appendSet("project_id", *input.ProjectID)
|
||||
from := current.ProjectID
|
||||
movedFromProject = &from
|
||||
}
|
||||
|
||||
if len(sets) == 0 && input.EventTypeIDs == nil {
|
||||
return current, nil
|
||||
}
|
||||
@@ -441,12 +526,53 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
|
||||
// Description carries value-only payload (the deadline title); frontend
|
||||
// renders via the localized event.description.deadline_updated template.
|
||||
// Same pattern below for completed/reopened/deleted/created.
|
||||
//
|
||||
// Audit shape for project moves (t-paliad-140): emit
|
||||
// deadline_project_changed on both old and new project rows so each
|
||||
// side's Verlauf still shows the move (the row is gone from the old
|
||||
// project, but its history shouldn't be). If the same PATCH also
|
||||
// touched other fields, the new project additionally records a
|
||||
// deadline_updated covering those edits.
|
||||
desc := current.Title
|
||||
descPtr := &desc
|
||||
if err := insertProjectEventWithMeta(ctx, tx, current.ProjectID, userID, "deadline_updated", "Deadline updated", descPtr,
|
||||
map[string]any{"deadline_id": deadlineID}); err != nil {
|
||||
return nil, err
|
||||
if movedFromProject != nil {
|
||||
moveMeta := map[string]any{
|
||||
"deadline_id": deadlineID,
|
||||
"from_project_id": *movedFromProject,
|
||||
"to_project_id": *input.ProjectID,
|
||||
}
|
||||
if err := insertProjectEventWithMeta(ctx, tx, *movedFromProject, userID,
|
||||
"deadline_project_changed", "Deadline project changed", descPtr, moveMeta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := insertProjectEventWithMeta(ctx, tx, *input.ProjectID, userID,
|
||||
"deadline_project_changed", "Deadline project changed", descPtr, moveMeta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Did the PATCH touch anything beyond the project move?
|
||||
otherFieldsTouched := input.Title != nil || input.Description != nil ||
|
||||
input.DueDate != nil || input.Notes != nil || input.Status != nil ||
|
||||
input.EventTypeIDs != nil
|
||||
if otherFieldsTouched {
|
||||
auditProject := current.ProjectID
|
||||
if movedFromProject != nil {
|
||||
auditProject = *input.ProjectID
|
||||
}
|
||||
if err := insertProjectEventWithMeta(ctx, tx, auditProject, userID, "deadline_updated", "Deadline updated", descPtr,
|
||||
map[string]any{"deadline_id": deadlineID}); err != nil {
|
||||
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)
|
||||
}
|
||||
@@ -454,6 +580,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 {
|
||||
@@ -462,6 +593,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 {
|
||||
@@ -482,6 +616,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)
|
||||
}
|
||||
@@ -490,7 +635,7 @@ func (s *DeadlineService) Complete(ctx context.Context, userID, deadlineID uuid.
|
||||
|
||||
// Reopen flips a completed Deadline back to pending and clears completed_at.
|
||||
// Authorization: global admin OR a member of the Project (or any ancestor)
|
||||
// with project_teams.role IN ('admin','lead'). Other authenticated viewers
|
||||
// with project_teams.responsibility = 'lead'. Other authenticated viewers
|
||||
// can see the Deadline but cannot reopen it.
|
||||
func (s *DeadlineService) Reopen(ctx context.Context, userID, deadlineID uuid.UUID) (*models.Deadline, error) {
|
||||
current, err := s.GetByID(ctx, userID, deadlineID)
|
||||
@@ -531,11 +676,17 @@ func (s *DeadlineService) Reopen(ctx context.Context, userID, deadlineID uuid.UU
|
||||
|
||||
// assertCanAdminProject returns nil if the user may perform admin-level
|
||||
// actions on the Project (reopen, future bulk ops). Pass-conditions:
|
||||
// - global users.role = 'admin', or
|
||||
// - direct/inherited project_teams membership with role IN ('admin','lead').
|
||||
// - users.global_role = 'global_admin', or
|
||||
// - direct/inherited project_teams membership with responsibility = 'lead'.
|
||||
//
|
||||
// Returns ErrForbidden otherwise. Visibility must be checked separately
|
||||
// (callers do this via GetByID before calling here).
|
||||
//
|
||||
// t-paliad-148: switched from `role IN ('admin','lead')` to
|
||||
// `responsibility = 'lead'`. The legacy 'admin' value was already dead
|
||||
// since t-paliad-051 (project_teams.role never had an 'admin' value;
|
||||
// only the legacy users.role enum did, before it was split into
|
||||
// global_role).
|
||||
func (s *DeadlineService) assertCanAdminProject(ctx context.Context, userID, projectID uuid.UUID) error {
|
||||
user, err := s.users().GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
@@ -556,18 +707,24 @@ func (s *DeadlineService) assertCanAdminProject(ctx context.Context, userID, pro
|
||||
ON pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
|
||||
WHERE p.id = $1
|
||||
AND pt.user_id = $2
|
||||
AND pt.role IN ('admin', 'lead')
|
||||
AND pt.responsibility = 'lead'
|
||||
)`, projectID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check project admin: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: only project admins/leads can reopen Deadlines", ErrForbidden)
|
||||
return fmt.Errorf("%w: only project leads can reopen Deadlines", ErrForbidden)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -583,6 +740,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 {
|
||||
@@ -590,14 +750,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()
|
||||
}
|
||||
@@ -731,6 +912,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")
|
||||
}
|
||||
}
|
||||
383
internal/services/derivation_service.go
Normal file
383
internal/services/derivation_service.go
Normal file
@@ -0,0 +1,383 @@
|
||||
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
|
||||
}
|
||||
// t-paliad-148: project-management write permission gates on the
|
||||
// project responsibility, not on the (firm-tier) profession. A
|
||||
// partner with responsibility=observer on this matter cannot manage
|
||||
// partner-unit attachments here; conversely a non-partner with
|
||||
// responsibility=lead can.
|
||||
var responsibility string
|
||||
err = s.db.GetContext(ctx, &responsibility,
|
||||
`SELECT responsibility FROM paliad.project_teams
|
||||
WHERE project_id = $1 AND user_id = $2`,
|
||||
projectID, callerID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrForbidden
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read project_teams responsibility: %w", err)
|
||||
}
|
||||
if responsibility != ResponsibilityLead {
|
||||
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.responsibility,
|
||||
pt.added_by, pt.created_at,
|
||||
d.title AS source_title
|
||||
FROM paliad.project_teams pt
|
||||
JOIN descendants d ON d.id = pt.project_id
|
||||
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, d.responsibility,
|
||||
true AS inherited,
|
||||
d.added_by, d.created_at,
|
||||
u.email AS user_email,
|
||||
u.display_name AS user_display_name,
|
||||
u.office AS user_office,
|
||||
u.profession AS user_profession,
|
||||
d.project_id AS inherited_from_id,
|
||||
d.source_title AS inherited_from_title
|
||||
FROM dedup d
|
||||
JOIN paliad.users u ON u.id = d.user_id
|
||||
WHERE d.rn = 1
|
||||
ORDER BY d.responsibility, u.display_name`,
|
||||
projectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list descendant-staffed: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// UserProjectAuthorityLevel returns the effective approval-ladder level
|
||||
// for user U on project P, evaluated as a tuple-with-gate:
|
||||
//
|
||||
// profession_level = approval_role_level(U.profession) // 0 if NULL
|
||||
// responsibility = direct or ancestor on project P
|
||||
// gate_open = responsibility IN {lead, member}
|
||||
// derived_role = approval_role_from_unit_role(unit_role) // when grants_authority
|
||||
// level = max( profession_level if gate_open else 0,
|
||||
// derived_role_level )
|
||||
//
|
||||
// Thin wrapper over paliad.user_project_authority_level — kept here so
|
||||
// any future caller that needs the level without writing raw SQL has a
|
||||
// single helper to call. The ApprovalService SQL paths inline the
|
||||
// computation directly for query efficiency.
|
||||
func (s *DerivationService) UserProjectAuthorityLevel(ctx context.Context, userID, projectID uuid.UUID) (int, error) {
|
||||
var lvl int
|
||||
err := s.db.GetContext(ctx, &lvl,
|
||||
`SELECT paliad.user_project_authority_level($1, $2)`,
|
||||
userID, projectID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read user project authority level: %w", err)
|
||||
}
|
||||
return lvl, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user