Compare commits
20 Commits
mai/fritz/
...
mai/noethe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d714dd95e | ||
|
|
7b66c4d035 | ||
|
|
c81ca6a12a | ||
|
|
905e743281 | ||
|
|
215a1ceeda | ||
|
|
e4adc39833 | ||
|
|
3dffce7a0d | ||
|
|
d8b84d0c58 | ||
|
|
d24f73358c | ||
|
|
52ee319fd8 | ||
|
|
dc7c807725 | ||
|
|
99f08e3863 | ||
|
|
dd4f563212 | ||
|
|
95f6f03cda | ||
|
|
fdde9eb754 | ||
|
|
cda4b4083d | ||
|
|
b516201110 | ||
|
|
956ff10e4d | ||
|
|
5c263102e3 | ||
|
|
f44ee0af0f |
@@ -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.
|
||||
|
||||
@@ -159,7 +159,24 @@ func main() {
|
||||
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),
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
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."`
|
||||
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.
|
||||
@@ -30,12 +30,17 @@ 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");
|
||||
@@ -250,6 +255,8 @@ async function build() {
|
||||
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"),
|
||||
@@ -260,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"),
|
||||
@@ -363,6 +373,8 @@ async function build() {
|
||||
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());
|
||||
@@ -373,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
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, """);
|
||||
}
|
||||
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();
|
||||
}
|
||||
@@ -35,6 +35,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"nav.dashboard": "Dashboard",
|
||||
"nav.agenda": "Agenda",
|
||||
"nav.inbox": "Genehmigungen",
|
||||
"nav.paliadin": "Paliadin",
|
||||
"nav.team": "Team",
|
||||
"nav.group.uebersicht": "\u00dcbersicht",
|
||||
"nav.group.arbeit": "Arbeit",
|
||||
@@ -1401,6 +1402,86 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"team.dept.lead": "Lead",
|
||||
"team.dept.unassigned": "Ohne Partner Unit",
|
||||
"team.partner_unit.unassigned": "Ohne Partner Unit",
|
||||
// Project filter (t-paliad-147)
|
||||
"team.filter.project": "Projekt",
|
||||
"team.filter.project.all": "Alle Projekte",
|
||||
"team.filter.project.selected": "ausgewählt",
|
||||
"team.filter.project.clear": "Alle abwählen",
|
||||
// Broadcast modal (t-paliad-147)
|
||||
"team.broadcast.button": "E-Mail an Auswahl",
|
||||
"team.broadcast.title": "E-Mail an Auswahl",
|
||||
"team.broadcast.recipients": "Empfänger",
|
||||
"team.broadcast.show_all": "Alle anzeigen",
|
||||
"team.broadcast.template": "Vorlage",
|
||||
"team.broadcast.template_optional": "optional",
|
||||
"team.broadcast.template_freeform": "Freitext",
|
||||
"team.broadcast.template.invitation": "Einladung",
|
||||
"team.broadcast.template.deadline_digest": "Frist-Digest",
|
||||
"team.broadcast.subject": "Betreff",
|
||||
"team.broadcast.body": "Nachricht",
|
||||
"team.broadcast.body_placeholder": "Hallo {{first_name}}, …",
|
||||
"team.broadcast.placeholders_hint": "Platzhalter: {{name}}, {{first_name}}, {{role_on_project}}",
|
||||
"team.broadcast.markdown_hint": "Markdown unterstützt: **fett**, *kursiv*, [Link](https://...), - Aufzählung.",
|
||||
"team.broadcast.send": "Senden",
|
||||
"team.broadcast.sending": "Sende…",
|
||||
"team.broadcast.sent": "Versandt",
|
||||
"team.broadcast.success": "{sent} von {total} Mails versandt ({failed} fehlgeschlagen).",
|
||||
"team.broadcast.error.no_recipients": "Keine Empfänger ausgewählt.",
|
||||
"team.broadcast.error.too_many": "Empfängerlimit ({cap}) überschritten.",
|
||||
"team.broadcast.error.subject_required": "Betreff ist erforderlich.",
|
||||
"team.broadcast.error.body_required": "Nachricht ist erforderlich.",
|
||||
"common.close": "Schließen",
|
||||
// Admin broadcasts viewer (t-paliad-147)
|
||||
"admin.broadcasts.title": "Broadcasts — Paliad",
|
||||
"admin.broadcasts.heading": "Broadcasts",
|
||||
"admin.broadcasts.subtitle": "Versendete Massen-E-Mails an Teamauswahlen.",
|
||||
"admin.broadcasts.col.sent_at": "Gesendet",
|
||||
"admin.broadcasts.col.subject": "Betreff",
|
||||
"admin.broadcasts.col.sender": "Absender:in",
|
||||
"admin.broadcasts.col.count": "Empfänger",
|
||||
"admin.broadcasts.loading": "Lade…",
|
||||
"admin.broadcasts.empty": "Noch keine Broadcasts versandt.",
|
||||
"admin.broadcasts.detail.sent_by": "Gesendet von",
|
||||
"admin.broadcasts.detail.delivered": "versandt",
|
||||
"admin.broadcasts.detail.failed": "fehlgeschlagen",
|
||||
"admin.broadcasts.detail.recipients": "Empfänger",
|
||||
|
||||
// t-paliad-146: Paliadin in-app AI buddy (PoC)
|
||||
"paliadin.title": "Paliadin — Paliad",
|
||||
"paliadin.heading": "✨ Paliadin",
|
||||
"paliadin.tagline": "Ich kenne deine Akten und Paliads Wissensbasis.",
|
||||
"paliadin.empty": "Was kann ich für dich tun?",
|
||||
"paliadin.starter.today": "Was steht heute an?",
|
||||
"paliadin.starter.week": "Welche Fristen sind diese Woche fällig?",
|
||||
"paliadin.starter.concept": "Erkläre mir Klageerwiderung.",
|
||||
"paliadin.input.placeholder": "Frag den Paliadin…",
|
||||
"paliadin.send": "Senden",
|
||||
"paliadin.stop": "Stop",
|
||||
"paliadin.reset": "Neue Unterhaltung",
|
||||
"nav.admin.paliadin": "Paliadin Monitor",
|
||||
"admin.paliadin.title": "Paliadin Monitor — Paliad",
|
||||
"admin.paliadin.heading": "Paliadin Monitor",
|
||||
"admin.paliadin.subtitle": "Wie wird Paliadin tatsächlich verwendet?",
|
||||
"admin.paliadin.total": "Gesamt",
|
||||
"admin.paliadin.last7": "Letzte 7 Tage",
|
||||
"admin.paliadin.median_dur": "Median Dauer",
|
||||
"admin.paliadin.tool_rate": "Tool-Use Rate",
|
||||
"admin.paliadin.abandon_rate": "Abbruchrate",
|
||||
"admin.paliadin.classifier_heading": "Anfragearten",
|
||||
"admin.paliadin.daily_heading": "Tägliche Nutzung",
|
||||
"admin.paliadin.top_heading": "Top Anfragen",
|
||||
"admin.paliadin.recent_heading": "Letzte Anfragen",
|
||||
"admin.paliadin.col.prompt": "Anfrage",
|
||||
"admin.paliadin.col.count": "Anzahl",
|
||||
"admin.paliadin.col.started": "Zeit",
|
||||
"admin.paliadin.col.classifier": "Art",
|
||||
"admin.paliadin.col.tools": "Tools",
|
||||
"admin.paliadin.col.duration": "Dauer",
|
||||
"admin.paliadin.loading": "Lade…",
|
||||
|
||||
"common.forbidden": "Zugriff verweigert.",
|
||||
"common.load_error": "Fehler beim Laden.",
|
||||
"common.loading": "Lade…",
|
||||
"partner_unit.heading": "Meine Partner Units",
|
||||
"partner_unit.subtitle": "Partner Units sind strukturelle Einheiten — getrennt von Projektteams. Mitgliedschaft wird vom Admin verwaltet.",
|
||||
"partner_unit.none": "Sie sind noch keiner Partner Unit zugeordnet.",
|
||||
@@ -1426,6 +1507,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.card.email_templates.desc": "Vorlagen für Einladungen, Erinnerungen und Layout anpassen.",
|
||||
"admin.card.feature_flags.title": "Feature-Flags",
|
||||
"admin.card.feature_flags.desc": "Funktionen pro Standort, Partner Unit oder Rolle aktivieren.",
|
||||
"admin.card.broadcasts.title": "Broadcasts",
|
||||
"admin.card.broadcasts.desc": "Versendete Massen-E-Mails an Teamauswahlen einsehen.",
|
||||
"admin.email_templates.title": "Email-Templates — Paliad",
|
||||
"admin.email_templates.heading": "Email-Templates",
|
||||
"admin.email_templates.subtitle": "Vorlagen für Einladungen, Erinnerungen und das Layout-Wrapper anpassen.",
|
||||
@@ -1739,6 +1822,100 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"approvals.policies.no_approval": "Keine Genehmigung erforderlich",
|
||||
"approvals.policies.copy_parent": "Aus Eltern-Projekt übernehmen",
|
||||
"approvals.policies.set_all_associate": "Alle auf Associate setzen",
|
||||
|
||||
// t-paliad-144 — Custom Views
|
||||
"nav.group.user_views": "Meine Sichten",
|
||||
"nav.user_views.new": "Neue Sicht",
|
||||
"views.title": "Sichten — Paliad",
|
||||
"views.heading": "Sichten",
|
||||
"views.subtitle": "Eigene Sichten über Ihre Daten — Filter und Darstellung speicherbar.",
|
||||
"views.loading": "Lädt …",
|
||||
"views.shape.list": "Liste",
|
||||
"views.shape.cards": "Karten",
|
||||
"views.shape.calendar": "Kalender",
|
||||
"views.save_as": "Als Sicht speichern",
|
||||
"views.action.edit": "Bearbeiten",
|
||||
"views.empty.title": "Keine Einträge gefunden.",
|
||||
"views.error.back": "Zurück zur Sichten-Übersicht",
|
||||
"views.error.not_found": "Sicht nicht gefunden.",
|
||||
"views.error.network": "Netzwerkfehler — bitte erneut versuchen.",
|
||||
"views.toast.inaccessible_one": "1 Projekt in dieser Sicht ist nicht mehr sichtbar.",
|
||||
"views.toast.inaccessible_n": "{n} Projekte in dieser Sicht sind nicht mehr sichtbar.",
|
||||
"views.calendar.mobile_fallback": "Kalender-Ansicht ist auf grossen Bildschirmen am besten.",
|
||||
"views.onboarding.title": "Eigene Sichten — was ist das?",
|
||||
"views.onboarding.body": "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.",
|
||||
"views.onboarding.create": "Beispiel-Sicht erstellen",
|
||||
"views.source.deadline": "Fristen",
|
||||
"views.source.appointment": "Termine",
|
||||
"views.source.project_event": "Projekt-Verlauf",
|
||||
"views.source.approval_request": "Genehmigungen",
|
||||
"views.kind.deadline": "Frist",
|
||||
"views.kind.appointment": "Termin",
|
||||
"views.kind.project_event": "Verlauf",
|
||||
"views.kind.approval_request": "Genehmigung",
|
||||
"views.scope.all_visible": "Alle sichtbaren",
|
||||
"views.scope.my_subtree": "Mein Teilbaum",
|
||||
"views.scope.explicit": "Bestimmte Projekte",
|
||||
"views.scope.personal_only": "Nur persönliche",
|
||||
"views.horizon.next_7d": "Nächste 7 Tage",
|
||||
"views.horizon.next_30d": "Nächste 30 Tage",
|
||||
"views.horizon.next_90d": "Nächste 90 Tage",
|
||||
"views.horizon.past_30d": "Letzte 30 Tage",
|
||||
"views.horizon.past_90d": "Letzte 90 Tage",
|
||||
"views.horizon.any": "Beliebig",
|
||||
"views.horizon.all": "Komplett (alle Daten)",
|
||||
"views.horizon.custom": "Benutzerdefiniert",
|
||||
"views.density.comfortable": "Bequem",
|
||||
"views.density.compact": "Kompakt",
|
||||
"views.col.date": "Datum",
|
||||
"views.col.time": "Wann",
|
||||
"views.col.title": "Titel",
|
||||
"views.col.project": "Projekt",
|
||||
"views.col.actor": "Akteur",
|
||||
"views.col.status": "Status",
|
||||
"views.col.rule": "Regel",
|
||||
"views.col.event_type": "Typ",
|
||||
"views.col.location": "Ort",
|
||||
"views.col.appointment_type": "Termin-Typ",
|
||||
"views.col.approval_status": "Genehmigung",
|
||||
"views.col.decided_by": "Entschieden von",
|
||||
"views.col.kind": "Art",
|
||||
"views.editor.title": "Sicht bearbeiten — Paliad",
|
||||
"views.editor.heading.new": "Neue Sicht",
|
||||
"views.editor.heading.edit": "Sicht bearbeiten",
|
||||
"views.editor.subtitle": "Wählen Sie Quellen, Filter und Darstellung. Änderungen speichern Sie unten.",
|
||||
"views.editor.section.identity": "Bezeichnung",
|
||||
"views.editor.section.sources": "Quellen",
|
||||
"views.editor.section.scope": "Geltungsbereich",
|
||||
"views.editor.section.time": "Zeitraum",
|
||||
"views.editor.section.render": "Darstellung",
|
||||
"views.editor.field.name": "Name",
|
||||
"views.editor.field.slug": "Slug (URL)",
|
||||
"views.editor.field.icon": "Icon",
|
||||
"views.editor.field.show_count": "Treffer-Anzahl in der Sidebar anzeigen",
|
||||
"views.editor.field.scope_mode": "Projekte",
|
||||
"views.editor.field.personal_only": "Nur persönliche",
|
||||
"views.editor.field.horizon": "Horizont",
|
||||
"views.editor.field.shape": "Form",
|
||||
"views.editor.field.density": "Dichte",
|
||||
"views.editor.hint.slug": "Kleinbuchstaben, Ziffern und Bindestriche — nicht reservierte Wörter.",
|
||||
"views.editor.hint.sources": "Welche Datenarten zeigt diese Sicht?",
|
||||
"views.editor.icon.default": "Standard (Ordner)",
|
||||
"views.editor.icon.clock": "Uhr",
|
||||
"views.editor.icon.calendar": "Kalender",
|
||||
"views.editor.icon.bell": "Glocke",
|
||||
"views.editor.icon.folder": "Ordner",
|
||||
"views.editor.icon.users": "Personen",
|
||||
"views.editor.icon.building": "Gebäude",
|
||||
"views.editor.save": "Speichern",
|
||||
"views.editor.cancel": "Abbrechen",
|
||||
"views.editor.delete": "Löschen",
|
||||
"views.editor.confirm_delete": "Diese Sicht wirklich löschen?",
|
||||
"views.editor.error.name_required": "Name ist erforderlich.",
|
||||
"views.editor.error.slug_format": "Slug darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten und muss mit einem Buchstaben oder einer Ziffer beginnen.",
|
||||
"views.editor.error.sources_required": "Mindestens eine Quelle wählen.",
|
||||
"views.editor.error.load_failed": "Sicht konnte nicht geladen werden.",
|
||||
"views.editor.error.delete_failed": "Sicht konnte nicht gelöscht werden.",
|
||||
},
|
||||
|
||||
en: {
|
||||
@@ -1760,6 +1937,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"nav.dashboard": "Dashboard",
|
||||
"nav.agenda": "Agenda",
|
||||
"nav.inbox": "Approvals",
|
||||
"nav.paliadin": "Paliadin",
|
||||
"nav.team": "Team",
|
||||
"nav.group.uebersicht": "Overview",
|
||||
"nav.group.arbeit": "Work",
|
||||
@@ -3114,6 +3292,86 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"team.dept.lead": "Lead",
|
||||
"team.dept.unassigned": "No partner unit",
|
||||
"team.partner_unit.unassigned": "No partner unit",
|
||||
// Project filter (t-paliad-147)
|
||||
"team.filter.project": "Project",
|
||||
"team.filter.project.all": "All projects",
|
||||
"team.filter.project.selected": "selected",
|
||||
"team.filter.project.clear": "Deselect all",
|
||||
// Broadcast modal (t-paliad-147)
|
||||
"team.broadcast.button": "Email selection",
|
||||
"team.broadcast.title": "Email selection",
|
||||
"team.broadcast.recipients": "Recipients",
|
||||
"team.broadcast.show_all": "Show all",
|
||||
"team.broadcast.template": "Template",
|
||||
"team.broadcast.template_optional": "optional",
|
||||
"team.broadcast.template_freeform": "Free-form",
|
||||
"team.broadcast.template.invitation": "Invitation",
|
||||
"team.broadcast.template.deadline_digest": "Deadline digest",
|
||||
"team.broadcast.subject": "Subject",
|
||||
"team.broadcast.body": "Message",
|
||||
"team.broadcast.body_placeholder": "Hi {{first_name}}, …",
|
||||
"team.broadcast.placeholders_hint": "Placeholders: {{name}}, {{first_name}}, {{role_on_project}}",
|
||||
"team.broadcast.markdown_hint": "Markdown supported: **bold**, *italic*, [link](https://...), - bullet.",
|
||||
"team.broadcast.send": "Send",
|
||||
"team.broadcast.sending": "Sending…",
|
||||
"team.broadcast.sent": "Sent",
|
||||
"team.broadcast.success": "{sent} of {total} emails sent ({failed} failed).",
|
||||
"team.broadcast.error.no_recipients": "No recipients selected.",
|
||||
"team.broadcast.error.too_many": "Recipient limit ({cap}) exceeded.",
|
||||
"team.broadcast.error.subject_required": "Subject is required.",
|
||||
"team.broadcast.error.body_required": "Message is required.",
|
||||
"common.close": "Close",
|
||||
// Admin broadcasts viewer (t-paliad-147)
|
||||
"admin.broadcasts.title": "Broadcasts — Paliad",
|
||||
"admin.broadcasts.heading": "Broadcasts",
|
||||
"admin.broadcasts.subtitle": "Sent bulk emails to team selections.",
|
||||
"admin.broadcasts.col.sent_at": "Sent",
|
||||
"admin.broadcasts.col.subject": "Subject",
|
||||
"admin.broadcasts.col.sender": "Sender",
|
||||
"admin.broadcasts.col.count": "Recipients",
|
||||
"admin.broadcasts.loading": "Loading…",
|
||||
"admin.broadcasts.empty": "No broadcasts sent yet.",
|
||||
"admin.broadcasts.detail.sent_by": "Sent by",
|
||||
"admin.broadcasts.detail.delivered": "delivered",
|
||||
"admin.broadcasts.detail.failed": "failed",
|
||||
"admin.broadcasts.detail.recipients": "Recipients",
|
||||
|
||||
// t-paliad-146: Paliadin in-app AI buddy (PoC)
|
||||
"paliadin.title": "Paliadin — Paliad",
|
||||
"paliadin.heading": "✨ Paliadin",
|
||||
"paliadin.tagline": "I know your matters and Paliad's knowledge base.",
|
||||
"paliadin.empty": "What can I help you with?",
|
||||
"paliadin.starter.today": "What's on my plate today?",
|
||||
"paliadin.starter.week": "Which deadlines are due this week?",
|
||||
"paliadin.starter.concept": "Explain Klageerwiderung.",
|
||||
"paliadin.input.placeholder": "Ask Paliadin…",
|
||||
"paliadin.send": "Send",
|
||||
"paliadin.stop": "Stop",
|
||||
"paliadin.reset": "New conversation",
|
||||
"nav.admin.paliadin": "Paliadin Monitor",
|
||||
"admin.paliadin.title": "Paliadin Monitor — Paliad",
|
||||
"admin.paliadin.heading": "Paliadin Monitor",
|
||||
"admin.paliadin.subtitle": "How is Paliadin actually being used?",
|
||||
"admin.paliadin.total": "Total",
|
||||
"admin.paliadin.last7": "Last 7 days",
|
||||
"admin.paliadin.median_dur": "Median duration",
|
||||
"admin.paliadin.tool_rate": "Tool-use rate",
|
||||
"admin.paliadin.abandon_rate": "Abandon rate",
|
||||
"admin.paliadin.classifier_heading": "Question types",
|
||||
"admin.paliadin.daily_heading": "Daily usage",
|
||||
"admin.paliadin.top_heading": "Top queries",
|
||||
"admin.paliadin.recent_heading": "Recent queries",
|
||||
"admin.paliadin.col.prompt": "Query",
|
||||
"admin.paliadin.col.count": "Count",
|
||||
"admin.paliadin.col.started": "Time",
|
||||
"admin.paliadin.col.classifier": "Type",
|
||||
"admin.paliadin.col.tools": "Tools",
|
||||
"admin.paliadin.col.duration": "Duration",
|
||||
"admin.paliadin.loading": "Loading…",
|
||||
|
||||
"common.forbidden": "Access denied.",
|
||||
"common.load_error": "Load error.",
|
||||
"common.loading": "Loading…",
|
||||
"partner_unit.heading": "My Partner Units",
|
||||
"partner_unit.subtitle": "Partner Units are structural units — separate from project teams. Membership is admin-managed.",
|
||||
"partner_unit.none": "You are not a member of any Partner Unit yet.",
|
||||
@@ -3139,6 +3397,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.card.email_templates.desc": "Customise templates for invitations, reminders and the wrapper layout.",
|
||||
"admin.card.feature_flags.title": "Feature Flags",
|
||||
"admin.card.feature_flags.desc": "Enable features per office, partner unit or role.",
|
||||
"admin.card.broadcasts.title": "Broadcasts",
|
||||
"admin.card.broadcasts.desc": "Inspect bulk emails sent to team selections.",
|
||||
"admin.email_templates.title": "Email Templates — Paliad",
|
||||
"admin.email_templates.heading": "Email Templates",
|
||||
"admin.email_templates.subtitle": "Customise templates for invitations, reminders, and the shared layout wrapper.",
|
||||
@@ -3452,6 +3712,100 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"approvals.policies.no_approval": "No approval needed",
|
||||
"approvals.policies.copy_parent": "Copy from parent project",
|
||||
"approvals.policies.set_all_associate": "Set all to Associate",
|
||||
|
||||
// t-paliad-144 — Custom Views
|
||||
"nav.group.user_views": "My Views",
|
||||
"nav.user_views.new": "New view",
|
||||
"views.title": "Views — Paliad",
|
||||
"views.heading": "Views",
|
||||
"views.subtitle": "Saved views over your data — filters and shape preserved.",
|
||||
"views.loading": "Loading …",
|
||||
"views.shape.list": "List",
|
||||
"views.shape.cards": "Cards",
|
||||
"views.shape.calendar": "Calendar",
|
||||
"views.save_as": "Save as view",
|
||||
"views.action.edit": "Edit",
|
||||
"views.empty.title": "No matches found.",
|
||||
"views.error.back": "Back to views",
|
||||
"views.error.not_found": "View not found.",
|
||||
"views.error.network": "Network error — please retry.",
|
||||
"views.toast.inaccessible_one": "1 project in this view is no longer visible to you.",
|
||||
"views.toast.inaccessible_n": "{n} projects in this view are no longer visible to you.",
|
||||
"views.calendar.mobile_fallback": "Calendar view works best on a wide screen.",
|
||||
"views.onboarding.title": "Saved views — what are they?",
|
||||
"views.onboarding.body": "A view is a saved filter combination — e.g. \"Deadlines in my projects in the next 14 days\". Views appear as their own buttons in the sidebar.",
|
||||
"views.onboarding.create": "Create example view",
|
||||
"views.source.deadline": "Deadlines",
|
||||
"views.source.appointment": "Appointments",
|
||||
"views.source.project_event": "Project history",
|
||||
"views.source.approval_request": "Approvals",
|
||||
"views.kind.deadline": "Deadline",
|
||||
"views.kind.appointment": "Appointment",
|
||||
"views.kind.project_event": "History",
|
||||
"views.kind.approval_request": "Approval",
|
||||
"views.scope.all_visible": "All visible",
|
||||
"views.scope.my_subtree": "My subtree",
|
||||
"views.scope.explicit": "Specific projects",
|
||||
"views.scope.personal_only": "Personal only",
|
||||
"views.horizon.next_7d": "Next 7 days",
|
||||
"views.horizon.next_30d": "Next 30 days",
|
||||
"views.horizon.next_90d": "Next 90 days",
|
||||
"views.horizon.past_30d": "Last 30 days",
|
||||
"views.horizon.past_90d": "Last 90 days",
|
||||
"views.horizon.any": "Any",
|
||||
"views.horizon.all": "All-time",
|
||||
"views.horizon.custom": "Custom",
|
||||
"views.density.comfortable": "Comfortable",
|
||||
"views.density.compact": "Compact",
|
||||
"views.col.date": "Date",
|
||||
"views.col.time": "When",
|
||||
"views.col.title": "Title",
|
||||
"views.col.project": "Project",
|
||||
"views.col.actor": "Actor",
|
||||
"views.col.status": "Status",
|
||||
"views.col.rule": "Rule",
|
||||
"views.col.event_type": "Type",
|
||||
"views.col.location": "Location",
|
||||
"views.col.appointment_type": "Appointment type",
|
||||
"views.col.approval_status": "Approval",
|
||||
"views.col.decided_by": "Decided by",
|
||||
"views.col.kind": "Kind",
|
||||
"views.editor.title": "Edit view — Paliad",
|
||||
"views.editor.heading.new": "New view",
|
||||
"views.editor.heading.edit": "Edit view",
|
||||
"views.editor.subtitle": "Pick sources, filters, and shape. Save to confirm.",
|
||||
"views.editor.section.identity": "Identity",
|
||||
"views.editor.section.sources": "Sources",
|
||||
"views.editor.section.scope": "Scope",
|
||||
"views.editor.section.time": "Time",
|
||||
"views.editor.section.render": "Display",
|
||||
"views.editor.field.name": "Name",
|
||||
"views.editor.field.slug": "Slug (URL)",
|
||||
"views.editor.field.icon": "Icon",
|
||||
"views.editor.field.show_count": "Show count badge in sidebar",
|
||||
"views.editor.field.scope_mode": "Projects",
|
||||
"views.editor.field.personal_only": "Personal only",
|
||||
"views.editor.field.horizon": "Horizon",
|
||||
"views.editor.field.shape": "Shape",
|
||||
"views.editor.field.density": "Density",
|
||||
"views.editor.hint.slug": "Lowercase letters, digits, hyphens — no reserved words.",
|
||||
"views.editor.hint.sources": "Which data sources should this view include?",
|
||||
"views.editor.icon.default": "Default (folder)",
|
||||
"views.editor.icon.clock": "Clock",
|
||||
"views.editor.icon.calendar": "Calendar",
|
||||
"views.editor.icon.bell": "Bell",
|
||||
"views.editor.icon.folder": "Folder",
|
||||
"views.editor.icon.users": "People",
|
||||
"views.editor.icon.building": "Building",
|
||||
"views.editor.save": "Save",
|
||||
"views.editor.cancel": "Cancel",
|
||||
"views.editor.delete": "Delete",
|
||||
"views.editor.confirm_delete": "Delete this view permanently?",
|
||||
"views.editor.error.name_required": "Name is required.",
|
||||
"views.editor.error.slug_format": "Slug must be lowercase, start with a letter or digit, contain only letters, digits, and hyphens.",
|
||||
"views.editor.error.sources_required": "Pick at least one source.",
|
||||
"views.editor.error.load_failed": "Could not load this view.",
|
||||
"views.editor.error.delete_failed": "Could not delete this view.",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
383
frontend/src/client/paliadin.ts
Normal file
383
frontend/src/client/paliadin.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import { initI18n, getLang } 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 =
|
||||
"Fehler beim Senden: " + String(err);
|
||||
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) => {
|
||||
const msg = (ev as MessageEvent).data
|
||||
? "Fehler: " + (ev as MessageEvent).data
|
||||
: "Verbindung verloren.";
|
||||
placeholder.querySelector(".paliadin-bubble-text")!.textContent = msg;
|
||||
placeholder.classList.add("paliadin-bubble--error");
|
||||
placeholder.dataset.streaming = "false";
|
||||
cleanupTurn();
|
||||
});
|
||||
|
||||
es.addEventListener("ping", () => {
|
||||
// heartbeat — no-op
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -72,6 +72,8 @@ export function initSidebar() {
|
||||
initChangelogBadge();
|
||||
initInboxBadge();
|
||||
initAdminGroup();
|
||||
initPaliadinLinks();
|
||||
initUserViewsGroup();
|
||||
initThemeToggle();
|
||||
const sidebar = document.querySelector<HTMLElement>(".sidebar");
|
||||
if (!sidebar) return;
|
||||
@@ -400,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" },
|
||||
};
|
||||
}
|
||||
@@ -116,6 +116,14 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
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),
|
||||
)}
|
||||
|
||||
@@ -125,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) +
|
||||
@@ -158,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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -512,6 +546,10 @@ 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"
|
||||
@@ -1275,6 +1313,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"
|
||||
@@ -1294,6 +1333,7 @@ export type I18nKey =
|
||||
| "nav.group.einstellungen"
|
||||
| "nav.group.ressourcen"
|
||||
| "nav.group.uebersicht"
|
||||
| "nav.group.user_views"
|
||||
| "nav.group.werkzeuge"
|
||||
| "nav.group.wissen"
|
||||
| "nav.home"
|
||||
@@ -1302,10 +1342,12 @@ export type I18nKey =
|
||||
| "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"
|
||||
@@ -1373,6 +1415,17 @@ export type I18nKey =
|
||||
| "palette.footer.navigate"
|
||||
| "palette.footer.open"
|
||||
| "palette.section.actions"
|
||||
| "paliadin.empty"
|
||||
| "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"
|
||||
@@ -1583,10 +1636,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"
|
||||
@@ -1616,4 +1695,94 @@ export type I18nKey =
|
||||
| "unit_role.lead"
|
||||
| "unit_role.pa"
|
||||
| "unit_role.paralegal"
|
||||
| "unit_role.senior_pa";
|
||||
| "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";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -10540,3 +10540,727 @@ dialog.quick-add-sheet::backdrop {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ============================================================================
|
||||
* t-paliad-144 Phase A2 — Custom Views shell + Meine Sichten sidebar group
|
||||
* + render-shape components (list / cards / calendar).
|
||||
* ============================================================================ */
|
||||
|
||||
/* Sidebar — Meine Sichten group. Mirrors .sidebar-admin-group spacing. */
|
||||
.sidebar-views-group .sidebar-views-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.sidebar-views-group .sidebar-views-new {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.sidebar-views-group .sidebar-user-view-item {
|
||||
/* Inherits .sidebar-item; nothing override-worthy yet. */
|
||||
}
|
||||
.sidebar-user-view-badge {
|
||||
/* Reuses .sidebar-badge styles; this keeps the selector available
|
||||
for future tweaks. */
|
||||
}
|
||||
|
||||
/* Views shell page — toolbar + states. */
|
||||
.views-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.views-toolbar-spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.views-loading,
|
||||
.views-error,
|
||||
.views-empty,
|
||||
.views-onboarding {
|
||||
margin: 24px 0;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.views-onboarding-actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.views-toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 12px 0;
|
||||
padding: 10px 14px;
|
||||
background: var(--color-bg-lime-tint);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.views-toast-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
color: inherit;
|
||||
}
|
||||
.views-shape-host {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* Header actions — edit / delete buttons. */
|
||||
.views-header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* shape=list (compact density). */
|
||||
.views-list--compact {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.views-list-row {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 110px 1fr auto auto;
|
||||
gap: 12px;
|
||||
align-items: baseline;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.views-list-row:hover {
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
.views-list-time {
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.views-list-kind {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.views-list-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
.views-list-project,
|
||||
.views-list-actor {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.views-list-subtitle {
|
||||
grid-column: 3 / -1;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* shape=cards. */
|
||||
.views-cards-day {
|
||||
margin-top: 24px;
|
||||
}
|
||||
.views-cards-day-heading {
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
.views-cards-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.views-card {
|
||||
padding: 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.views-card-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.views-card-kind {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.views-card-title {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
.views-card-meta {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.views-card-meta > * + *::before {
|
||||
content: "·";
|
||||
margin-right: 8px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.views-card-subtitle {
|
||||
margin: 8px 0 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* shape=calendar. */
|
||||
.views-calendar-month-label {
|
||||
font-size: 18px;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
.views-calendar-weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.views-calendar-weekday {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
padding: 4px;
|
||||
}
|
||||
.views-calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
.views-calendar-cell {
|
||||
min-height: 80px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.views-calendar-cell--out {
|
||||
background: transparent;
|
||||
border: 1px dashed var(--color-border);
|
||||
}
|
||||
.views-calendar-cell-day {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.views-calendar-pills {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.views-calendar-pill {
|
||||
font-size: 11px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.views-calendar-pill--more {
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
background: transparent;
|
||||
}
|
||||
.views-calendar-mobile-notice {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* === Bulk team-email broadcast (t-paliad-147) === */
|
||||
|
||||
/* Project multi-select filter on /team. */
|
||||
.team-filter-row-project {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.team-project-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.team-project-summary {
|
||||
font-weight: 500;
|
||||
}
|
||||
.team-project-panel {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
min-width: 280px;
|
||||
max-width: 420px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
margin-top: 4px;
|
||||
padding: 12px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.team-project-panel.hidden {
|
||||
display: none;
|
||||
}
|
||||
.team-project-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.team-project-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.team-project-options .filter-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
.team-project-options .filter-checkbox:hover {
|
||||
background: var(--color-bg-muted);
|
||||
}
|
||||
.team-broadcast-wrap {
|
||||
margin: 12px 0 0 0;
|
||||
}
|
||||
.team-broadcast-count {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 8px;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Broadcast compose modal — extends .modal-overlay / .modal pattern. */
|
||||
.modal-broadcast {
|
||||
width: 720px;
|
||||
max-width: 92vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.modal-broadcast .modal-body {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
.modal-broadcast label {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
.modal-broadcast input[type="text"],
|
||||
.modal-broadcast textarea,
|
||||
.modal-broadcast select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
.modal-broadcast textarea {
|
||||
resize: vertical;
|
||||
min-height: 200px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.broadcast-recipient-summary {
|
||||
padding: 10px 12px;
|
||||
background: var(--color-bg-muted);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.broadcast-recipient-preview {
|
||||
margin-top: 4px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.broadcast-recipient-list {
|
||||
margin-top: 8px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.broadcast-recipient-list.hidden {
|
||||
display: none;
|
||||
}
|
||||
.broadcast-recipient-list ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
.broadcast-recipient-list li {
|
||||
margin: 4px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.broadcast-recip-email {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.broadcast-recip-role {
|
||||
margin-left: 6px;
|
||||
padding: 0 6px;
|
||||
background: var(--color-bg-muted);
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.broadcast-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.broadcast-error {
|
||||
margin-top: 12px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
color: rgb(185, 28, 28);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.broadcast-error.hidden,
|
||||
.broadcast-success.hidden {
|
||||
display: none;
|
||||
}
|
||||
.broadcast-success {
|
||||
margin-top: 12px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: rgb(21, 128, 61);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.link-button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--color-link, #2563eb);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* /admin/broadcasts viewer */
|
||||
.broadcasts-table td {
|
||||
vertical-align: top;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.broadcast-detail-body {
|
||||
margin-top: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--color-bg-muted);
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.broadcast-detail-recipients ul {
|
||||
margin: 8px 0;
|
||||
padding-left: 18px;
|
||||
columns: 2;
|
||||
}
|
||||
.broadcast-detail-recipients li {
|
||||
break-inside: avoid;
|
||||
font-size: 13px;
|
||||
margin: 2px 0;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.broadcast-detail-recipients ul {
|
||||
columns: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================================
|
||||
* t-paliad-146 — Paliadin in-app AI buddy (PoC).
|
||||
* ========================================================================== */
|
||||
|
||||
.paliadin-page {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.paliadin-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - var(--page-chrome-h, 80px));
|
||||
max-height: calc(100vh - 80px);
|
||||
}
|
||||
|
||||
.paliadin-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.paliadin-tagline {
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.paliadin-reset {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.paliadin-stream {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.paliadin-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.paliadin-empty p {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.paliadin-starters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.paliadin-starter {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.paliadin-starter:hover {
|
||||
background: var(--color-surface-hover, var(--color-surface));
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.paliadin-bubble {
|
||||
max-width: 85%;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 12px;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.paliadin-bubble--user {
|
||||
align-self: flex-end;
|
||||
background: var(--color-accent-tint, #e8fbb2);
|
||||
border: 1px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.paliadin-bubble--assistant {
|
||||
align-self: flex-start;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.paliadin-bubble--error {
|
||||
border-color: var(--color-status-red, #c54);
|
||||
background: var(--color-status-red-tint, #fee);
|
||||
}
|
||||
|
||||
.paliadin-bubble-role {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.paliadin-bubble-text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.paliadin-bubble-meta {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.paliadin-chip {
|
||||
display: inline-block;
|
||||
background: var(--color-accent-tint, #e8fbb2);
|
||||
border: 1px solid var(--color-accent);
|
||||
color: var(--color-text);
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
text-decoration: none;
|
||||
margin: 0 0.15rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.paliadin-chip:hover {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.paliadin-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 0;
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.paliadin-input {
|
||||
flex: 1;
|
||||
resize: vertical;
|
||||
min-height: 2.5rem;
|
||||
max-height: 12rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.paliadin-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* /admin/paliadin dashboard. */
|
||||
|
||||
.paliadin-stat-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 1.5rem 0 2rem;
|
||||
}
|
||||
|
||||
.paliadin-stat-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.paliadin-stat-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.paliadin-stat-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.paliadin-classifier {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin: 1rem 0 2rem;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.paliadin-classifier-row {
|
||||
display: grid;
|
||||
grid-template-columns: 6rem 1fr 3rem;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.paliadin-classifier-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.paliadin-classifier-bar {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
height: 1.25rem;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.paliadin-classifier-fill {
|
||||
height: 100%;
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.paliadin-classifier-count {
|
||||
font-family: monospace;
|
||||
text-align: right;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.paliadin-spark {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 60px;
|
||||
margin: 1rem 0 2rem;
|
||||
padding: 0 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.paliadin-spark-bar {
|
||||
flex: 1;
|
||||
background: var(--color-accent);
|
||||
min-height: 1px;
|
||||
max-width: 12px;
|
||||
}
|
||||
|
||||
@@ -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">Sicht 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 Sicht</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 Sicht?</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">Sichten — 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">Sichten</h1>
|
||||
<p className="tool-subtitle" id="views-subtitle" data-i18n="views.subtitle">
|
||||
Eigene Sichten ü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 Sicht 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 Sichten — was ist das?</h2>
|
||||
<p data-i18n="views.onboarding.body">
|
||||
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.
|
||||
</p>
|
||||
<div className="views-onboarding-actions">
|
||||
<a href="/views/new" className="btn-primary btn-cta-lime" data-i18n="views.onboarding.create">
|
||||
Beispiel-Sicht 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 Sichten-Ü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>
|
||||
);
|
||||
}
|
||||
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.';
|
||||
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")
|
||||
}
|
||||
@@ -64,12 +64,23 @@ type Services struct {
|
||||
Courts *services.CourtService
|
||||
Approval *services.ApprovalService
|
||||
Derivation *services.DerivationService
|
||||
UserView *services.UserViewService
|
||||
Broadcast *services.BroadcastService
|
||||
|
||||
// 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,
|
||||
@@ -100,6 +111,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
courts: svc.Courts,
|
||||
approval: svc.Approval,
|
||||
derivation: svc.Derivation,
|
||||
userView: svc.UserView,
|
||||
broadcast: svc.Broadcast,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,6 +352,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)
|
||||
@@ -403,6 +426,42 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
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.
|
||||
|
||||
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).
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,8 @@ type dbServices struct {
|
||||
courts *services.CourtService
|
||||
approval *services.ApprovalService
|
||||
derivation *services.DerivationService
|
||||
userView *services.UserViewService
|
||||
broadcast *services.BroadcastService
|
||||
}
|
||||
|
||||
var dbSvc *dbServices
|
||||
|
||||
@@ -63,6 +63,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")
|
||||
}
|
||||
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
|
||||
}
|
||||
393
internal/services/filter_spec.go
Normal file
393
internal/services/filter_spec.go
Normal file
@@ -0,0 +1,393 @@
|
||||
package services
|
||||
|
||||
// FilterSpec is the structured filter description that drives the substrate
|
||||
// (ViewService.RunSpec). Same shape lives in paliad.user_views.filter_spec
|
||||
// (jsonb) and is composed by the frontend's view editor.
|
||||
//
|
||||
// Design: docs/design-data-display-model-2026-05-06.md §3 Q2.
|
||||
//
|
||||
// Validation rules live alongside the type. Every public mutation path
|
||||
// (POST/PUT /api/user-views, /api/views/{slug}/run with override params)
|
||||
// runs FilterSpec.Validate() before touching the DB or executing a query.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// DataSource identifies one of the substrate's source rails.
|
||||
type DataSource string
|
||||
|
||||
const (
|
||||
SourceDeadline DataSource = "deadline"
|
||||
SourceAppointment DataSource = "appointment"
|
||||
SourceProjectEvent DataSource = "project_event"
|
||||
SourceApprovalRequest DataSource = "approval_request"
|
||||
)
|
||||
|
||||
// AllSources lists every supported source. Used by the validator and by
|
||||
// the "all sources" UI affordance.
|
||||
var AllSources = []DataSource{
|
||||
SourceDeadline,
|
||||
SourceAppointment,
|
||||
SourceProjectEvent,
|
||||
SourceApprovalRequest,
|
||||
}
|
||||
|
||||
// SpecVersion is the on-the-wire schema version. Bumped when the shape
|
||||
// changes incompatibly. Validator rejects unknown versions so we get a
|
||||
// clear error instead of silent misinterpretation.
|
||||
const SpecVersion = 1
|
||||
|
||||
// FilterSpec is the top-level filter description.
|
||||
type FilterSpec struct {
|
||||
Version int `json:"version"`
|
||||
Sources []DataSource `json:"sources"`
|
||||
Scope ScopeSpec `json:"scope"`
|
||||
Time TimeSpec `json:"time"`
|
||||
Predicates map[DataSource]Predicates `json:"predicates,omitempty"`
|
||||
}
|
||||
|
||||
// ScopeSpec narrows which projects contribute rows. Resolved at query
|
||||
// time:
|
||||
// - "all_visible" (default): no extra narrowing; RLS bounds to projects
|
||||
// the caller can see (direct + descendant + derived per t-paliad-139).
|
||||
// - "my_subtree": narrows to the caller's direct/descendant/derived
|
||||
// staffing tree only. Equivalent to "all_visible" today (visibility
|
||||
// == subtree); reserved for future when global_admin or shared
|
||||
// visibility models add a wider tier.
|
||||
// - explicit slice: exact project IDs. RLS still applies — IDs the
|
||||
// caller can't see contribute zero rows (Q17 fail-open).
|
||||
//
|
||||
// PersonalOnly narrows deadline + appointment to caller-created rows.
|
||||
// Mutually exclusive with explicit Projects (validator enforces). When
|
||||
// PersonalOnly is true, project_event + approval_request sources still
|
||||
// run with the standard visibility predicate — the "personal" framing
|
||||
// only meaningfully applies to entity rows.
|
||||
type ScopeSpec struct {
|
||||
Projects ScopeProjects `json:"projects"`
|
||||
PersonalOnly bool `json:"personal_only,omitempty"`
|
||||
}
|
||||
|
||||
// ScopeProjects is the project-narrowing payload. Either a sentinel string
|
||||
// ("all_visible" / "my_subtree") or an explicit []uuid.UUID.
|
||||
//
|
||||
// Encoded as a tagged shape on the wire so we don't have to teach a custom
|
||||
// JSON unmarshaller a polymorphic field:
|
||||
//
|
||||
// {"mode": "all_visible"}
|
||||
// {"mode": "my_subtree"}
|
||||
// {"mode": "explicit", "ids": ["uuid", …]}
|
||||
type ScopeProjects struct {
|
||||
Mode ScopeMode `json:"mode"`
|
||||
IDs []uuid.UUID `json:"ids,omitempty"`
|
||||
}
|
||||
|
||||
type ScopeMode string
|
||||
|
||||
const (
|
||||
ScopeAllVisible ScopeMode = "all_visible"
|
||||
ScopeMySubtree ScopeMode = "my_subtree"
|
||||
ScopeExplicit ScopeMode = "explicit"
|
||||
)
|
||||
|
||||
// TimeSpec is the time-window narrowing. Horizon picks a relative window;
|
||||
// when Horizon == "custom", From/To are honoured as an absolute window.
|
||||
//
|
||||
// Field selects which date column to filter against per source:
|
||||
// - "auto" (default): each source picks — deadline → due_date,
|
||||
// appointment → start_at, project_event → created_at,
|
||||
// approval_request → requested_at.
|
||||
// - "created_at": every source uses its own created_at column. Useful
|
||||
// for "newly added events" views.
|
||||
type TimeSpec struct {
|
||||
Horizon TimeHorizon `json:"horizon"`
|
||||
Field TimeField `json:"field,omitempty"`
|
||||
From *time.Time `json:"from,omitempty"`
|
||||
To *time.Time `json:"to,omitempty"`
|
||||
}
|
||||
|
||||
type TimeHorizon string
|
||||
|
||||
const (
|
||||
HorizonNext7d TimeHorizon = "next_7d"
|
||||
HorizonNext30d TimeHorizon = "next_30d"
|
||||
HorizonNext90d TimeHorizon = "next_90d"
|
||||
HorizonPast30d TimeHorizon = "past_30d"
|
||||
HorizonPast90d TimeHorizon = "past_90d"
|
||||
HorizonAny TimeHorizon = "any"
|
||||
HorizonAll TimeHorizon = "all"
|
||||
HorizonCustom TimeHorizon = "custom"
|
||||
)
|
||||
|
||||
type TimeField string
|
||||
|
||||
const (
|
||||
FieldAuto TimeField = "auto"
|
||||
FieldCreatedAt TimeField = "created_at"
|
||||
)
|
||||
|
||||
// Predicates is the per-source narrowing payload. Empty fields mean
|
||||
// "no narrowing" — never "exclude all".
|
||||
type Predicates struct {
|
||||
Deadline *DeadlinePredicates `json:"deadline,omitempty"`
|
||||
Appointment *AppointmentPredicates `json:"appointment,omitempty"`
|
||||
ProjectEvent *ProjectEventPredicates `json:"project_event,omitempty"`
|
||||
ApprovalRequest *ApprovalRequestPredicates `json:"approval_request,omitempty"`
|
||||
}
|
||||
|
||||
// DeadlinePredicates narrows the deadline rail.
|
||||
type DeadlinePredicates struct {
|
||||
Status []string `json:"status,omitempty"` // "pending" | "completed"
|
||||
ApprovalStatus []string `json:"approval_status,omitempty"` // "approved" | "pending" | "legacy"
|
||||
EventTypeIDs []uuid.UUID `json:"event_types,omitempty"`
|
||||
IncludeUntyped bool `json:"include_untyped,omitempty"`
|
||||
}
|
||||
|
||||
// AppointmentPredicates narrows the appointment rail.
|
||||
type AppointmentPredicates struct {
|
||||
ApprovalStatus []string `json:"approval_status,omitempty"`
|
||||
AppointmentTypes []string `json:"appointment_types,omitempty"` // hearing/meeting/consultation/deadline_hearing
|
||||
}
|
||||
|
||||
// ProjectEventPredicates narrows the audit/Verlauf rail. EventTypes is the
|
||||
// curated list of project-event kinds (project_created, status_changed,
|
||||
// deadline_created, …) — see KnownProjectEventKinds.
|
||||
type ProjectEventPredicates struct {
|
||||
EventTypes []string `json:"event_types,omitempty"`
|
||||
}
|
||||
|
||||
// ApprovalRequestPredicates narrows the approval-inbox rail.
|
||||
//
|
||||
// ViewerRole shapes the per-row visibility:
|
||||
// - "approver_eligible": rows where the caller is qualified to approve
|
||||
// (the t-paliad-138 inbox path, extended by t-paliad-139 derivation).
|
||||
// - "self_requested": rows the caller submitted.
|
||||
// - "any_visible": every approval_request the caller can see via RLS
|
||||
// (includes both of the above plus already-decided rows on visible
|
||||
// projects). Used for retrospective/audit views.
|
||||
type ApprovalRequestPredicates struct {
|
||||
ViewerRole string `json:"viewer_role,omitempty"`
|
||||
Status []string `json:"status,omitempty"` // "pending" | "approved" | "rejected" | "revoked"
|
||||
EntityTypes []string `json:"entity_types,omitempty"` // "deadline" | "appointment"
|
||||
}
|
||||
|
||||
// KnownProjectEventKinds is the curated set of project_event.event_type
|
||||
// values the substrate exposes via the filter UI. Code-resident per
|
||||
// design Q19.
|
||||
//
|
||||
// Mirrors the strings emitted by insertProjectEvent / insertProjectEventWithMeta
|
||||
// in internal/services/*. New kinds appear here as they're added in code.
|
||||
var KnownProjectEventKinds = []string{
|
||||
"project_created",
|
||||
"project_archived",
|
||||
"project_reparented",
|
||||
"project_type_changed",
|
||||
"status_changed",
|
||||
"deadline_created",
|
||||
"deadline_completed",
|
||||
"deadline_reopened",
|
||||
"appointment_created",
|
||||
"appointment_updated",
|
||||
"appointment_deleted",
|
||||
"approval_decided",
|
||||
"member_role_changed",
|
||||
}
|
||||
|
||||
// validApprovalStatuses are the legal values for entity-side approval_status
|
||||
// filters and request-side status filters respectively.
|
||||
var (
|
||||
validEntityApprovalStatuses = []string{"approved", "pending", "legacy"}
|
||||
validRequestStatuses = []string{"pending", "approved", "rejected", "revoked"}
|
||||
validApprovalEntityTypes = []string{"deadline", "appointment"}
|
||||
validApprovalViewerRoles = []string{"approver_eligible", "self_requested", "any_visible"}
|
||||
validDeadlineStatuses = []string{"pending", "completed"}
|
||||
validAppointmentTypes = []string{"hearing", "meeting", "consultation", "deadline_hearing"}
|
||||
)
|
||||
|
||||
// Validate runs the full FilterSpec contract. Returns a wrapped
|
||||
// ErrInvalidInput on the first violation.
|
||||
//
|
||||
// The validator is the single gate that protects the DB from malformed
|
||||
// jsonb. Both POST /api/user-views and runtime-override params on
|
||||
// GET /api/views/{slug}/run go through it.
|
||||
func (s *FilterSpec) Validate() error {
|
||||
if s == nil {
|
||||
return fmt.Errorf("%w: filter_spec is required", ErrInvalidInput)
|
||||
}
|
||||
if s.Version != SpecVersion {
|
||||
return fmt.Errorf("%w: filter_spec version %d not supported (expected %d)", ErrInvalidInput, s.Version, SpecVersion)
|
||||
}
|
||||
if len(s.Sources) == 0 {
|
||||
return fmt.Errorf("%w: at least one source must be selected", ErrInvalidInput)
|
||||
}
|
||||
seen := make(map[DataSource]bool, len(s.Sources))
|
||||
for _, src := range s.Sources {
|
||||
if !isKnownSource(src) {
|
||||
return fmt.Errorf("%w: unknown source %q", ErrInvalidInput, src)
|
||||
}
|
||||
if seen[src] {
|
||||
return fmt.Errorf("%w: duplicate source %q", ErrInvalidInput, src)
|
||||
}
|
||||
seen[src] = true
|
||||
}
|
||||
|
||||
if err := s.Scope.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.Time.validate(s.Scope); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for src, preds := range s.Predicates {
|
||||
if !isKnownSource(src) {
|
||||
return fmt.Errorf("%w: predicates set on unknown source %q", ErrInvalidInput, src)
|
||||
}
|
||||
if !seen[src] {
|
||||
return fmt.Errorf("%w: predicates set on source %q which is not selected", ErrInvalidInput, src)
|
||||
}
|
||||
if err := preds.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ScopeSpec) validate() error {
|
||||
switch s.Projects.Mode {
|
||||
case ScopeAllVisible, ScopeMySubtree:
|
||||
if len(s.Projects.IDs) > 0 {
|
||||
return fmt.Errorf("%w: scope.projects.ids must be empty when mode=%q", ErrInvalidInput, s.Projects.Mode)
|
||||
}
|
||||
case ScopeExplicit:
|
||||
if len(s.Projects.IDs) == 0 {
|
||||
return fmt.Errorf("%w: scope.projects.ids must be non-empty when mode=%q", ErrInvalidInput, ScopeExplicit)
|
||||
}
|
||||
if s.PersonalOnly {
|
||||
return fmt.Errorf("%w: scope.personal_only cannot be combined with explicit projects", ErrInvalidInput)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("%w: unknown scope.projects.mode %q", ErrInvalidInput, s.Projects.Mode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TimeSpec) validate(scope ScopeSpec) error {
|
||||
switch t.Horizon {
|
||||
case HorizonNext7d, HorizonNext30d, HorizonNext90d,
|
||||
HorizonPast30d, HorizonPast90d, HorizonAny:
|
||||
// fine
|
||||
case HorizonAll:
|
||||
// Q26: reject "all" unless scope.projects is explicit. Performance
|
||||
// safeguard — an unbounded substrate query across every visible
|
||||
// project is the worst case and we want it gated by intent.
|
||||
if scope.Projects.Mode != ScopeExplicit {
|
||||
return fmt.Errorf("%w: time.horizon=%q requires scope.projects.mode=%q", ErrInvalidInput, HorizonAll, ScopeExplicit)
|
||||
}
|
||||
case HorizonCustom:
|
||||
if t.From == nil || t.To == nil {
|
||||
return fmt.Errorf("%w: time.horizon=%q requires both from and to", ErrInvalidInput, HorizonCustom)
|
||||
}
|
||||
if !t.To.After(*t.From) {
|
||||
return fmt.Errorf("%w: time.to must be strictly after time.from", ErrInvalidInput)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("%w: unknown time.horizon %q", ErrInvalidInput, t.Horizon)
|
||||
}
|
||||
|
||||
switch t.Field {
|
||||
case "", FieldAuto, FieldCreatedAt:
|
||||
// fine
|
||||
default:
|
||||
return fmt.Errorf("%w: unknown time.field %q", ErrInvalidInput, t.Field)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Predicates) validate() error {
|
||||
if p.Deadline != nil {
|
||||
if err := validateStringEnum("deadline.status", p.Deadline.Status, validDeadlineStatuses); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateStringEnum("deadline.approval_status", p.Deadline.ApprovalStatus, validEntityApprovalStatuses); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if p.Appointment != nil {
|
||||
if err := validateStringEnum("appointment.approval_status", p.Appointment.ApprovalStatus, validEntityApprovalStatuses); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateStringEnum("appointment.appointment_types", p.Appointment.AppointmentTypes, validAppointmentTypes); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if p.ProjectEvent != nil {
|
||||
if err := validateStringEnum("project_event.event_types", p.ProjectEvent.EventTypes, KnownProjectEventKinds); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if p.ApprovalRequest != nil {
|
||||
if r := p.ApprovalRequest.ViewerRole; r != "" {
|
||||
if err := validateStringEnum("approval_request.viewer_role", []string{r}, validApprovalViewerRoles); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := validateStringEnum("approval_request.status", p.ApprovalRequest.Status, validRequestStatuses); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateStringEnum("approval_request.entity_types", p.ApprovalRequest.EntityTypes, validApprovalEntityTypes); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateStringEnum(field string, values, allowed []string) error {
|
||||
for _, v := range values {
|
||||
if !slices.Contains(allowed, v) {
|
||||
return fmt.Errorf("%w: unknown %s value %q", ErrInvalidInput, field, v)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isKnownSource(s DataSource) bool {
|
||||
return slices.Contains(AllSources, s)
|
||||
}
|
||||
|
||||
// DefaultFilterSpec returns a minimal valid spec — used as the seed when
|
||||
// the editor opens with a blank canvas.
|
||||
func DefaultFilterSpec() FilterSpec {
|
||||
return FilterSpec{
|
||||
Version: SpecVersion,
|
||||
Sources: []DataSource{SourceDeadline, SourceAppointment},
|
||||
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
|
||||
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalFilterSpec is a convenience wrapper used by handlers when storing
|
||||
// the spec into the jsonb column. Pre-validates so a malformed spec never
|
||||
// reaches the DB.
|
||||
func MarshalFilterSpec(s FilterSpec) ([]byte, error) {
|
||||
if err := s.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(s)
|
||||
}
|
||||
|
||||
// UnmarshalFilterSpec parses + validates a stored / submitted spec.
|
||||
func UnmarshalFilterSpec(b []byte) (FilterSpec, error) {
|
||||
var s FilterSpec
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return FilterSpec{}, fmt.Errorf("%w: filter_spec malformed: %v", ErrInvalidInput, err)
|
||||
}
|
||||
if err := s.Validate(); err != nil {
|
||||
return FilterSpec{}, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
263
internal/services/filter_spec_test.go
Normal file
263
internal/services/filter_spec_test.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package services
|
||||
|
||||
// Pure-Go tests for FilterSpec — no DB touch. Cover happy path, every
|
||||
// reject branch, and the cross-field constraints (Q26 horizon-clamp,
|
||||
// scope mode/IDs invariants).
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func validBaseSpec() FilterSpec {
|
||||
return FilterSpec{
|
||||
Version: SpecVersion,
|
||||
Sources: []DataSource{SourceDeadline},
|
||||
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
|
||||
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpec_HappyPath(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
if err := s.Validate(); err != nil {
|
||||
t.Fatalf("base spec should validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpec_DefaultIsValid(t *testing.T) {
|
||||
s := DefaultFilterSpec()
|
||||
if err := s.Validate(); err != nil {
|
||||
t.Fatalf("DefaultFilterSpec must validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpec_Version(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Version = 2
|
||||
err := s.Validate()
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown version must reject with ErrInvalidInput, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpec_NoSources(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Sources = nil
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("empty sources must reject, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpec_UnknownSource(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Sources = []DataSource{"bogus"}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown source must reject, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpec_DuplicateSource(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Sources = []DataSource{SourceDeadline, SourceDeadline}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("duplicate source must reject, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpec_AllSourcesAccepted(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Sources = []DataSource{SourceDeadline, SourceAppointment, SourceProjectEvent, SourceApprovalRequest}
|
||||
if err := s.Validate(); err != nil {
|
||||
t.Fatalf("all four sources together must validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpec_ScopeAllVisibleRejectsIDs(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Scope.Projects.IDs = []uuid.UUID{uuid.New()}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("ids on all_visible mode must reject, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpec_ScopeExplicitNeedsIDs(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Scope.Projects = ScopeProjects{Mode: ScopeExplicit}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("explicit mode without ids must reject, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpec_ScopeExplicitWithIDsValidates(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Scope.Projects = ScopeProjects{Mode: ScopeExplicit, IDs: []uuid.UUID{uuid.New()}}
|
||||
if err := s.Validate(); err != nil {
|
||||
t.Fatalf("explicit mode + ids must validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpec_PersonalOnlyConflictsWithExplicit(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Scope.Projects = ScopeProjects{Mode: ScopeExplicit, IDs: []uuid.UUID{uuid.New()}}
|
||||
s.Scope.PersonalOnly = true
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("personal_only + explicit projects must reject, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpec_HorizonAllRejectsWithoutExplicit(t *testing.T) {
|
||||
// Q26 lock-in: horizon=all is rejected unless scope.projects.mode=explicit.
|
||||
s := validBaseSpec()
|
||||
s.Time.Horizon = HorizonAll
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("horizon=all without explicit projects must reject, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpec_HorizonAllAcceptsWithExplicit(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Scope.Projects = ScopeProjects{Mode: ScopeExplicit, IDs: []uuid.UUID{uuid.New()}}
|
||||
s.Time.Horizon = HorizonAll
|
||||
if err := s.Validate(); err != nil {
|
||||
t.Fatalf("horizon=all with explicit projects must validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpec_HorizonCustomNeedsBothBounds(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Time.Horizon = HorizonCustom
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("custom horizon without bounds must reject, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpec_HorizonCustomRejectsInvertedRange(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Time.Horizon = HorizonCustom
|
||||
earlier := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
later := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
// to before from
|
||||
s.Time.From = &later
|
||||
s.Time.To = &earlier
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("inverted from/to must reject, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpec_HorizonCustomAcceptsValidRange(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Time.Horizon = HorizonCustom
|
||||
from := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
to := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
s.Time.From = &from
|
||||
s.Time.To = &to
|
||||
if err := s.Validate(); err != nil {
|
||||
t.Fatalf("valid custom range must accept: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpec_PredicatesRequireSourceSelected(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Sources = []DataSource{SourceDeadline}
|
||||
s.Predicates = map[DataSource]Predicates{
|
||||
SourceAppointment: {Appointment: &AppointmentPredicates{AppointmentTypes: []string{"hearing"}}},
|
||||
}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("predicates on unselected source must reject, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpec_DeadlineStatusEnum(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Predicates = map[DataSource]Predicates{
|
||||
SourceDeadline: {Deadline: &DeadlinePredicates{Status: []string{"weird"}}},
|
||||
}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown deadline.status must reject, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpec_AppointmentTypeEnum(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Sources = append(s.Sources, SourceAppointment)
|
||||
s.Predicates = map[DataSource]Predicates{
|
||||
SourceAppointment: {Appointment: &AppointmentPredicates{AppointmentTypes: []string{"bogus"}}},
|
||||
}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown appointment_type must reject, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpec_ProjectEventKindMustBeKnown(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Sources = []DataSource{SourceProjectEvent}
|
||||
s.Predicates = map[DataSource]Predicates{
|
||||
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{EventTypes: []string{"unknown_kind"}}},
|
||||
}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown project_event kind must reject, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpec_ApprovalViewerRoleEnum(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Sources = []DataSource{SourceApprovalRequest}
|
||||
s.Predicates = map[DataSource]Predicates{
|
||||
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{ViewerRole: "everyone"}},
|
||||
}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown viewer_role must reject, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpec_ApprovalRequestStatusEnum(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Sources = []DataSource{SourceApprovalRequest}
|
||||
s.Predicates = map[DataSource]Predicates{
|
||||
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{Status: []string{"weird"}}},
|
||||
}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown approval_request.status must reject, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpec_RoundTripJSON(t *testing.T) {
|
||||
original := FilterSpec{
|
||||
Version: SpecVersion,
|
||||
Sources: []DataSource{SourceDeadline, SourceApprovalRequest},
|
||||
Scope: ScopeSpec{
|
||||
Projects: ScopeProjects{Mode: ScopeAllVisible},
|
||||
PersonalOnly: false,
|
||||
},
|
||||
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
|
||||
Predicates: map[DataSource]Predicates{
|
||||
SourceDeadline: {Deadline: &DeadlinePredicates{
|
||||
Status: []string{"pending"},
|
||||
ApprovalStatus: []string{"approved", "pending"},
|
||||
}},
|
||||
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
|
||||
ViewerRole: "approver_eligible",
|
||||
Status: []string{"pending"},
|
||||
}},
|
||||
},
|
||||
}
|
||||
b, err := MarshalFilterSpec(original)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
parsed, err := UnmarshalFilterSpec(b)
|
||||
if err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if parsed.Version != original.Version {
|
||||
t.Errorf("version mismatch: %d vs %d", parsed.Version, original.Version)
|
||||
}
|
||||
if len(parsed.Sources) != len(original.Sources) {
|
||||
t.Errorf("sources mismatch: %v vs %v", parsed.Sources, original.Sources)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,6 +421,13 @@ func hostnameForHelo() string {
|
||||
// Subjects are encoded as UTF-8 per RFC 2047 so non-ASCII characters
|
||||
// (umlauts) render correctly in every client.
|
||||
func buildMIME(from, fromName, to, subject, htmlBody, textBody string) []byte {
|
||||
return buildMIMEWithReplyTo(from, fromName, "", to, subject, htmlBody, textBody)
|
||||
}
|
||||
|
||||
// buildMIMEWithReplyTo is buildMIME plus an optional Reply-To header.
|
||||
// Bulk-broadcast email uses this so replies route to the human sender even
|
||||
// though From: stays on the SMTP infrastructure address.
|
||||
func buildMIMEWithReplyTo(from, fromName, replyTo, to, subject, htmlBody, textBody string) []byte {
|
||||
boundary := "paliad-mixed-" + randBoundary()
|
||||
fromHeader := from
|
||||
if fromName != "" {
|
||||
@@ -428,6 +435,9 @@ func buildMIME(from, fromName, to, subject, htmlBody, textBody string) []byte {
|
||||
}
|
||||
var b bytes.Buffer
|
||||
fmt.Fprintf(&b, "From: %s\r\n", fromHeader)
|
||||
if replyTo != "" {
|
||||
fmt.Fprintf(&b, "Reply-To: %s\r\n", replyTo)
|
||||
}
|
||||
fmt.Fprintf(&b, "To: %s\r\n", to)
|
||||
fmt.Fprintf(&b, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject))
|
||||
fmt.Fprintf(&b, "Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
|
||||
|
||||
127
internal/services/markdown.go
Normal file
127
internal/services/markdown.go
Normal file
@@ -0,0 +1,127 @@
|
||||
// markdown.go — minimal Markdown → safe HTML converter for broadcast emails.
|
||||
//
|
||||
// Paliad doesn't pull in a third-party Markdown library — the body subset
|
||||
// senders need is small and predictable, so we render it inline. Inputs are
|
||||
// HTML-escaped first; the renderer then re-introduces a small whitelist of
|
||||
// inline tags (<strong>, <em>, <code>, <a>) and block elements (<p>, <ul>,
|
||||
// <li>, <br>) for the patterns it recognises. Anything we don't recognise
|
||||
// stays escaped, so an attacker who tries to slip a <script> tag through
|
||||
// the compose modal sees a literal "<script>" in the rendered email.
|
||||
//
|
||||
// Supported syntax:
|
||||
// - Paragraphs separated by blank lines.
|
||||
// - Single line break inside a paragraph → <br>.
|
||||
// - **bold** → <strong>bold</strong>
|
||||
// - _italic_ or *italic* → <em>italic</em>
|
||||
// - `inline code` → <code>inline code</code>
|
||||
// - [text](https://link) → <a href="...">text</a>
|
||||
// - Lines starting with "- " or "* " → <ul><li>...</li></ul>
|
||||
//
|
||||
// Out-of-scope (intentional, per t-paliad-147 v1):
|
||||
// - Headings, blockquotes, ordered lists, fenced code blocks, images,
|
||||
// tables. These can be added on demand without changing the contract.
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// renderMarkdownSafe converts Markdown to HTML. Output is safe for direct
|
||||
// embedding in an HTML email body: every byte of input is escaped before
|
||||
// the markdown post-processor runs, and the inline rewriter only re-emits
|
||||
// a small whitelist of tags.
|
||||
func renderMarkdownSafe(src string) string {
|
||||
src = strings.ReplaceAll(src, "\r\n", "\n")
|
||||
src = strings.ReplaceAll(src, "\r", "\n")
|
||||
|
||||
// Split into paragraphs on blank lines.
|
||||
paragraphs := strings.Split(src, "\n\n")
|
||||
var out strings.Builder
|
||||
for _, raw := range paragraphs {
|
||||
p := strings.TrimSpace(raw)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
// Bullet lists: every line starts with "- " or "* ".
|
||||
if isBulletList(p) {
|
||||
out.WriteString("<ul>\n")
|
||||
for _, line := range strings.Split(p, "\n") {
|
||||
item := strings.TrimSpace(line)
|
||||
if len(item) >= 2 && (item[:2] == "- " || item[:2] == "* ") {
|
||||
item = strings.TrimSpace(item[2:])
|
||||
}
|
||||
out.WriteString(" <li>")
|
||||
out.WriteString(renderInline(item))
|
||||
out.WriteString("</li>\n")
|
||||
}
|
||||
out.WriteString("</ul>\n")
|
||||
continue
|
||||
}
|
||||
|
||||
// Plain paragraph. Single-newline within → <br>.
|
||||
lines := strings.Split(p, "\n")
|
||||
out.WriteString("<p>")
|
||||
for i, line := range lines {
|
||||
if i > 0 {
|
||||
out.WriteString("<br>\n")
|
||||
}
|
||||
out.WriteString(renderInline(strings.TrimSpace(line)))
|
||||
}
|
||||
out.WriteString("</p>\n")
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func isBulletList(p string) bool {
|
||||
for _, line := range strings.Split(p, "\n") {
|
||||
t := strings.TrimSpace(line)
|
||||
if len(t) < 2 {
|
||||
return false
|
||||
}
|
||||
if t[:2] != "- " && t[:2] != "* " {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var (
|
||||
mdLinkRE = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^\s)]+)\)`)
|
||||
mdBoldRE = regexp.MustCompile(`\*\*([^*]+)\*\*`)
|
||||
mdItalRE1 = regexp.MustCompile(`(^|[^\w])_([^_]+)_($|[^\w])`)
|
||||
mdItalRE2 = regexp.MustCompile(`(^|[^\w*])\*([^*]+)\*($|[^\w*])`)
|
||||
mdCodeRE = regexp.MustCompile("`([^`]+)`")
|
||||
)
|
||||
|
||||
// renderInline applies inline markdown to one line. The input is escaped
|
||||
// first; replacements re-emit whitelisted tags.
|
||||
func renderInline(line string) string {
|
||||
s := html.EscapeString(line)
|
||||
// Order matters: links first (they wrap text+URL), then bold (which is
|
||||
// **…** and would otherwise be split by the italic *…* rule), then
|
||||
// italics, then code.
|
||||
s = mdLinkRE.ReplaceAllStringFunc(s, func(m string) string {
|
||||
matches := mdLinkRE.FindStringSubmatch(m)
|
||||
if len(matches) != 3 {
|
||||
return m
|
||||
}
|
||||
text, url := matches[1], matches[2]
|
||||
// URL is already escaped by html.EscapeString above; href quoting
|
||||
// also needs the &-form so screen readers don't choke.
|
||||
return fmt.Sprintf(`<a href="%s">%s</a>`, url, text)
|
||||
})
|
||||
s = mdBoldRE.ReplaceAllString(s, `<strong>$1</strong>`)
|
||||
s = mdItalRE1.ReplaceAllString(s, `$1<em>$2</em>$3`)
|
||||
s = mdItalRE2.ReplaceAllString(s, `$1<em>$2</em>$3`)
|
||||
s = mdCodeRE.ReplaceAllString(s, `<code>$1</code>`)
|
||||
return s
|
||||
}
|
||||
|
||||
// escapeHTML is a thin alias used by senderSignature so the broadcast file
|
||||
// doesn't need to import html directly.
|
||||
func escapeHTML(s string) string {
|
||||
return html.EscapeString(s)
|
||||
}
|
||||
757
internal/services/paliadin.go
Normal file
757
internal/services/paliadin.go
Normal file
@@ -0,0 +1,757 @@
|
||||
package services
|
||||
|
||||
// PaliadinService — Phase 0 PoC of the in-app AI buddy (t-paliad-146).
|
||||
//
|
||||
// Design: docs/design-paliadin-2026-05-07.md §0.5 (PoC track).
|
||||
//
|
||||
// Architecture: a long-lived `claude` process inside a tmux session.
|
||||
// Prompts go in via `tmux send-keys -l`; responses come back via a
|
||||
// per-turn file the system prompt instructs Claude to write
|
||||
// (Write(/tmp/paliadin/{turn_id}.txt)). The service polls that file,
|
||||
// strips the [paliadin-meta] trailer block, parses the metadata, writes
|
||||
// an audit row, and emits the response back to the SSE handler.
|
||||
//
|
||||
// The architecture is lifted (with adaptation to Go) from
|
||||
// ~/dev/mVoice/server.py:250-380, which has been driving the goldi voice
|
||||
// surface in production since 2026-Q1.
|
||||
//
|
||||
// PoC ONLY runs on m's laptop (PALIADIN_ENABLED=false on prod default).
|
||||
// Hardcoded single-user, single-tmux-window scope. Do not attempt to
|
||||
// deploy this to the Dokploy container — there is no `claude` CLI there.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// PaliadinOwnerEmail is the only account allowed to use the Paliadin
|
||||
// PoC. Hardcoded — by design — so the gate cannot be flipped via a
|
||||
// deploy env var. PoC ships at this scope; multi-user opens up only
|
||||
// when production v1 lands behind its own auth model.
|
||||
//
|
||||
// Matches the seed in migration 023 (m's job_title row). If m's email
|
||||
// ever rotates, this constant must rotate with it; there is no other
|
||||
// path to enabling Paliadin.
|
||||
const PaliadinOwnerEmail = "matthias.siebels@hoganlovells.com"
|
||||
|
||||
// PaliadinService manages the tmux-claude PoC.
|
||||
type PaliadinService struct {
|
||||
db *sqlx.DB
|
||||
tmuxSession string
|
||||
responseDir string
|
||||
users *UserService
|
||||
|
||||
// Cached pane target ("session:window-idx") once the voice window is
|
||||
// either discovered or created. Reset to "" if the pane dies.
|
||||
mu sync.Mutex
|
||||
paneTarget string
|
||||
|
||||
// Single in-flight turn at a time. PoC scope — one user (m), serialised
|
||||
// by a session-level mutex. Production v1 would queue / fan out.
|
||||
turnMu sync.Mutex
|
||||
}
|
||||
|
||||
// IsOwner returns true when the given user_id corresponds to m's
|
||||
// account (the only Paliadin PoC user). Resolves via paliad.users.email
|
||||
// rather than caching a UUID so a DB rebuild that reassigns auth UUIDs
|
||||
// doesn't strand the gate.
|
||||
//
|
||||
// Returns (false, nil) for any other user — including unknown UUIDs and
|
||||
// users without an email row. Errors only on DB failure.
|
||||
func (s *PaliadinService) IsOwner(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||
var email string
|
||||
err := s.db.QueryRowxContext(ctx,
|
||||
`SELECT email FROM paliad.users WHERE id = $1`, userID).Scan(&email)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("paliadin: lookup owner: %w", err)
|
||||
}
|
||||
return strings.EqualFold(email, PaliadinOwnerEmail), nil
|
||||
}
|
||||
|
||||
// NewPaliadinService wires the service. Call only when PALIADIN_ENABLED=true.
|
||||
func NewPaliadinService(db *sqlx.DB, users *UserService, tmuxSession, responseDir string) *PaliadinService {
|
||||
if tmuxSession == "" {
|
||||
tmuxSession = "paliad-paliadin"
|
||||
}
|
||||
if responseDir == "" {
|
||||
responseDir = "/tmp/paliadin"
|
||||
}
|
||||
return &PaliadinService{
|
||||
db: db,
|
||||
tmuxSession: tmuxSession,
|
||||
responseDir: responseDir,
|
||||
users: users,
|
||||
}
|
||||
}
|
||||
|
||||
// PaliadinTurn is the audit row.
|
||||
type PaliadinTurn struct {
|
||||
TurnID uuid.UUID `db:"turn_id" json:"turn_id"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
SessionID string `db:"session_id" json:"session_id"`
|
||||
StartedAt time.Time `db:"started_at" json:"started_at"`
|
||||
FinishedAt *time.Time `db:"finished_at" json:"finished_at,omitempty"`
|
||||
DurationMS *int `db:"duration_ms" json:"duration_ms,omitempty"`
|
||||
UserMessage string `db:"user_message" json:"user_message"`
|
||||
Response *string `db:"response" json:"response,omitempty"`
|
||||
ResponseTokens *int `db:"response_tokens" json:"response_tokens,omitempty"`
|
||||
UsedTools pq.StringArray `db:"used_tools" json:"used_tools"`
|
||||
RowsSeen pq.Int64Array `db:"rows_seen" json:"rows_seen"`
|
||||
ChipCount int `db:"chip_count" json:"chip_count"`
|
||||
Abandoned bool `db:"abandoned" json:"abandoned"`
|
||||
PageOrigin *string `db:"page_origin" json:"page_origin,omitempty"`
|
||||
ErrorCode *string `db:"error_code" json:"error_code,omitempty"`
|
||||
ClassifierTag *string `db:"classifier_tag" json:"classifier_tag,omitempty"`
|
||||
}
|
||||
|
||||
// TurnRequest is what the handler passes to RunTurn.
|
||||
type TurnRequest struct {
|
||||
UserID uuid.UUID
|
||||
SessionID string
|
||||
UserMessage string
|
||||
PageOrigin string // empty when unknown
|
||||
}
|
||||
|
||||
// TurnResult is what RunTurn returns to the handler.
|
||||
type TurnResult struct {
|
||||
TurnID uuid.UUID
|
||||
Response string // body without [paliadin-meta] trailer
|
||||
UsedTools []string
|
||||
RowsSeen []int
|
||||
ChipCount int
|
||||
ClassifierTag string
|
||||
DurationMS int
|
||||
}
|
||||
|
||||
// ErrPaliadinDisabled is the canonical "service is wired but turned off"
|
||||
// signal. Handlers map it to 503.
|
||||
var ErrPaliadinDisabled = errors.New("paliadin: disabled")
|
||||
|
||||
// ErrTmuxUnavailable indicates we couldn't talk to tmux (binary missing,
|
||||
// session unreachable, etc.). Handlers map it to 503 with a hint.
|
||||
var ErrTmuxUnavailable = errors.New("paliadin: tmux unavailable")
|
||||
|
||||
// RunTurn executes one full Q&A round. Blocks until Claude has written
|
||||
// the response file or we time out (default 60 s). Writes the audit row
|
||||
// in both success + error paths.
|
||||
//
|
||||
// PoC: serialised. The package-level turnMu enforces "one at a time".
|
||||
// m is the only user, so this is fine.
|
||||
func (s *PaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*TurnResult, error) {
|
||||
s.turnMu.Lock()
|
||||
defer s.turnMu.Unlock()
|
||||
|
||||
turnID := uuid.New()
|
||||
startedAt := time.Now().UTC()
|
||||
|
||||
// Audit row — written *first* so a crash mid-turn still leaves traces.
|
||||
if err := s.insertTurnRow(ctx, &PaliadinTurn{
|
||||
TurnID: turnID,
|
||||
UserID: req.UserID,
|
||||
SessionID: req.SessionID,
|
||||
StartedAt: startedAt,
|
||||
UserMessage: req.UserMessage,
|
||||
PageOrigin: optionalString(req.PageOrigin),
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("paliadin: insert turn row: %w", err)
|
||||
}
|
||||
|
||||
// Ensure tmux session + Claude pane.
|
||||
target, err := s.ensurePane(ctx)
|
||||
if err != nil {
|
||||
_ = s.markTurnError(ctx, turnID, "tmux_unresponsive")
|
||||
return nil, fmt.Errorf("%w: %v", ErrTmuxUnavailable, err)
|
||||
}
|
||||
|
||||
// Make sure the response dir exists.
|
||||
if err := os.MkdirAll(s.responseDir, 0o755); err != nil {
|
||||
_ = s.markTurnError(ctx, turnID, "tmux_unresponsive")
|
||||
return nil, fmt.Errorf("paliadin: mkdir response dir: %w", err)
|
||||
}
|
||||
|
||||
// Send the framed prompt. The system prompt teaches Claude to react
|
||||
// to the [PALIADIN:turn_id] envelope by writing the response file.
|
||||
envelope := fmt.Sprintf("[PALIADIN:%s] %s", turnID, sanitiseForTmux(req.UserMessage))
|
||||
if err := s.sendToPane(ctx, target, envelope); err != nil {
|
||||
_ = s.markTurnError(ctx, turnID, "tmux_unresponsive")
|
||||
return nil, fmt.Errorf("%w: send prompt: %v", ErrTmuxUnavailable, err)
|
||||
}
|
||||
|
||||
// Poll for the response file. Fixed 60 s timeout; abort early if the
|
||||
// caller's context is cancelled (e.g. user clicked Stop).
|
||||
respPath := filepath.Join(s.responseDir, turnID.String()+".txt")
|
||||
body, err := s.pollForResponse(ctx, respPath, 60*time.Second)
|
||||
if err != nil {
|
||||
ec := "timeout"
|
||||
if errors.Is(err, context.Canceled) {
|
||||
ec = "user_aborted"
|
||||
}
|
||||
_ = s.markTurnAbandonedOrError(ctx, turnID, ec, ec == "user_aborted")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Strip + parse the [paliadin-meta] trailer. Best-effort: the prompt
|
||||
// instructs Claude to emit it but the PoC's monitoring is precisely
|
||||
// what tells us how reliable that is in practice.
|
||||
cleanBody, meta := splitTrailer(body)
|
||||
tokens := approxTokenCount(cleanBody)
|
||||
chipCount := countChips(cleanBody)
|
||||
finished := time.Now().UTC()
|
||||
durationMS := int(finished.Sub(startedAt) / time.Millisecond)
|
||||
|
||||
// Write the result back into the audit row.
|
||||
if err := s.completeTurn(ctx, turnID, finished, durationMS, cleanBody, tokens, meta, chipCount); err != nil {
|
||||
log.Printf("paliadin: complete turn %s: %v", turnID, err)
|
||||
// Don't fail the user-facing request on audit-row write errors —
|
||||
// the response is real even if the bookkeeping is broken.
|
||||
}
|
||||
|
||||
return &TurnResult{
|
||||
TurnID: turnID,
|
||||
Response: cleanBody,
|
||||
UsedTools: meta.UsedTools,
|
||||
RowsSeen: meta.RowsSeen,
|
||||
ChipCount: chipCount,
|
||||
ClassifierTag: meta.ClassifierTag,
|
||||
DurationMS: durationMS,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResetSession sends `/clear` to the Claude pane so the next turn starts
|
||||
// from a clean conversation. Used by the "New conversation" button.
|
||||
func (s *PaliadinService) ResetSession(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
target := s.paneTarget
|
||||
s.mu.Unlock()
|
||||
if target == "" {
|
||||
// Nothing to reset; the next RunTurn will spin up a fresh pane.
|
||||
return nil
|
||||
}
|
||||
if err := s.sendToPane(ctx, target, "/clear"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListRecentTurns reads the last N turns visible to the caller.
|
||||
// global_admin sees everything; everyone else sees their own.
|
||||
func (s *PaliadinService) ListRecentTurns(ctx context.Context, callerID uuid.UUID, limit int) ([]PaliadinTurn, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
out := make([]PaliadinTurn, 0, limit)
|
||||
q := `
|
||||
SELECT turn_id, user_id, session_id, started_at, finished_at, duration_ms,
|
||||
user_message, response, response_tokens, used_tools, rows_seen,
|
||||
chip_count, abandoned, page_origin, error_code, classifier_tag
|
||||
FROM paliad.paliadin_turns
|
||||
WHERE user_id = $1
|
||||
OR EXISTS (SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = $1 AND u.global_role = 'global_admin')
|
||||
ORDER BY started_at DESC
|
||||
LIMIT $2
|
||||
`
|
||||
if err := s.db.SelectContext(ctx, &out, q, callerID, limit); err != nil {
|
||||
return nil, fmt.Errorf("paliadin: list turns: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PaliadinStats is the aggregate view shown on /admin/paliadin.
|
||||
type PaliadinStats struct {
|
||||
TotalTurns int `json:"total_turns"`
|
||||
TurnsLast7Days int `json:"turns_last_7_days"`
|
||||
MedianDurationMS int `json:"median_duration_ms"`
|
||||
P90DurationMS int `json:"p90_duration_ms"`
|
||||
ToolUseRate float64 `json:"tool_use_rate"` // 0..1
|
||||
AbandonRate float64 `json:"abandon_rate"` // 0..1
|
||||
ByClassifier map[string]int `json:"by_classifier"` // tag → count
|
||||
DailyCounts []PaliadinDailyCount `json:"daily_counts"` // last 30 days
|
||||
TopPrompts []PaliadinPromptCount `json:"top_prompts"` // most-frequent normalised prompts
|
||||
}
|
||||
|
||||
type PaliadinDailyCount struct {
|
||||
Day string `db:"day" json:"day"` // YYYY-MM-DD
|
||||
Count int `db:"count" json:"count"`
|
||||
}
|
||||
|
||||
type PaliadinPromptCount struct {
|
||||
Prompt string `db:"prompt" json:"prompt"`
|
||||
Count int `db:"count" json:"count"`
|
||||
}
|
||||
|
||||
// Stats computes the dashboard aggregate. global_admin sees everything;
|
||||
// everyone else sees their own slice (PoC has only m, but the policy
|
||||
// matches RLS on the table).
|
||||
func (s *PaliadinService) Stats(ctx context.Context, callerID uuid.UUID) (*PaliadinStats, error) {
|
||||
stats := &PaliadinStats{
|
||||
ByClassifier: map[string]int{},
|
||||
DailyCounts: []PaliadinDailyCount{},
|
||||
TopPrompts: []PaliadinPromptCount{},
|
||||
}
|
||||
|
||||
// Visibility predicate: caller's own rows OR all rows if global_admin.
|
||||
visible := `(user_id = $1 OR EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $1 AND u.global_role = 'global_admin'))`
|
||||
|
||||
// Total + 7-day count.
|
||||
if err := s.db.QueryRowxContext(ctx, fmt.Sprintf(`
|
||||
SELECT COUNT(*),
|
||||
COUNT(*) FILTER (WHERE started_at >= now() - interval '7 days')
|
||||
FROM paliad.paliadin_turns
|
||||
WHERE %s
|
||||
`, visible), callerID).Scan(&stats.TotalTurns, &stats.TurnsLast7Days); err != nil {
|
||||
return nil, fmt.Errorf("paliadin: stats totals: %w", err)
|
||||
}
|
||||
|
||||
if stats.TotalTurns == 0 {
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// Duration percentiles. Skip rows still in flight (duration_ms NULL).
|
||||
if err := s.db.QueryRowxContext(ctx, fmt.Sprintf(`
|
||||
SELECT COALESCE(percentile_cont(0.5) WITHIN GROUP (ORDER BY duration_ms), 0)::int,
|
||||
COALESCE(percentile_cont(0.9) WITHIN GROUP (ORDER BY duration_ms), 0)::int
|
||||
FROM paliad.paliadin_turns
|
||||
WHERE %s AND duration_ms IS NOT NULL
|
||||
`, visible), callerID).Scan(&stats.MedianDurationMS, &stats.P90DurationMS); err != nil {
|
||||
return nil, fmt.Errorf("paliadin: stats percentiles: %w", err)
|
||||
}
|
||||
|
||||
// Tool-use + abandon rates.
|
||||
var toolUsedTurns, abandonedTurns int
|
||||
if err := s.db.QueryRowxContext(ctx, fmt.Sprintf(`
|
||||
SELECT COUNT(*) FILTER (WHERE array_length(used_tools, 1) > 0),
|
||||
COUNT(*) FILTER (WHERE abandoned = true)
|
||||
FROM paliad.paliadin_turns
|
||||
WHERE %s
|
||||
`, visible), callerID).Scan(&toolUsedTurns, &abandonedTurns); err != nil {
|
||||
return nil, fmt.Errorf("paliadin: stats rates: %w", err)
|
||||
}
|
||||
stats.ToolUseRate = float64(toolUsedTurns) / float64(stats.TotalTurns)
|
||||
stats.AbandonRate = float64(abandonedTurns) / float64(stats.TotalTurns)
|
||||
|
||||
// Histogram by classifier_tag.
|
||||
rows, err := s.db.QueryxContext(ctx, fmt.Sprintf(`
|
||||
SELECT COALESCE(classifier_tag, 'untagged') AS tag, COUNT(*) AS n
|
||||
FROM paliad.paliadin_turns
|
||||
WHERE %s
|
||||
GROUP BY tag
|
||||
`, visible), callerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("paliadin: stats classifier: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var tag string
|
||||
var n int
|
||||
if err := rows.Scan(&tag, &n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.ByClassifier[tag] = n
|
||||
}
|
||||
|
||||
// Daily counts (last 30 days).
|
||||
if err := s.db.SelectContext(ctx, &stats.DailyCounts, fmt.Sprintf(`
|
||||
SELECT to_char(date_trunc('day', started_at), 'YYYY-MM-DD') AS day,
|
||||
COUNT(*) AS count
|
||||
FROM paliad.paliadin_turns
|
||||
WHERE %s
|
||||
AND started_at >= now() - interval '30 days'
|
||||
GROUP BY day
|
||||
ORDER BY day ASC
|
||||
`, visible), callerID); err != nil {
|
||||
return nil, fmt.Errorf("paliadin: stats daily: %w", err)
|
||||
}
|
||||
|
||||
// Top prompts (normalised: lowercase + collapse whitespace + trim).
|
||||
if err := s.db.SelectContext(ctx, &stats.TopPrompts, fmt.Sprintf(`
|
||||
SELECT trim(regexp_replace(lower(user_message), '\s+', ' ', 'g')) AS prompt,
|
||||
COUNT(*) AS count
|
||||
FROM paliad.paliadin_turns
|
||||
WHERE %s
|
||||
GROUP BY prompt
|
||||
ORDER BY count DESC, prompt ASC
|
||||
LIMIT 10
|
||||
`, visible), callerID); err != nil {
|
||||
return nil, fmt.Errorf("paliadin: stats top prompts: %w", err)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// tmux orchestration — adapted from mVoice/server.py:250-380.
|
||||
// =============================================================================
|
||||
|
||||
// ensurePane returns the tmux target ("session:window-idx") of the live
|
||||
// Claude pane, creating both session and window if missing.
|
||||
func (s *PaliadinService) ensurePane(ctx context.Context) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Cheap path: if we have a cached target and it's still alive, reuse.
|
||||
if s.paneTarget != "" && s.paneAlive(ctx, s.paneTarget) {
|
||||
return s.paneTarget, nil
|
||||
}
|
||||
|
||||
// Ensure session.
|
||||
if err := runTmux(ctx, "has-session", "-t", s.tmuxSession); err != nil {
|
||||
// Create detached.
|
||||
if err := runTmux(ctx, "new-session", "-d", "-s", s.tmuxSession); err != nil {
|
||||
return "", fmt.Errorf("new-session: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Look for an existing window tagged with @paliadin-scope=chat.
|
||||
if existing := s.findChatWindow(ctx); existing != "" {
|
||||
s.paneTarget = existing
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
// No window — create one running `claude` in a fresh pane. Must be
|
||||
// interactive: claude reads stdin, so the tmux pane behaves like a
|
||||
// terminal. We use `new-window -P -F` to print the new index back.
|
||||
out, err := runTmuxOut(ctx, "new-window", "-t", s.tmuxSession,
|
||||
"-n", "claude-paliadin",
|
||||
"-P", "-F", "#{window_index}",
|
||||
"claude")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("new-window claude: %w", err)
|
||||
}
|
||||
idx := strings.TrimSpace(out)
|
||||
target := fmt.Sprintf("%s:%s", s.tmuxSession, idx)
|
||||
|
||||
// Wait for Claude's prompt indicator. Claude Code's interactive
|
||||
// prompt rendering varies but always settles into a state where the
|
||||
// pane has a "❯" prompt glyph or "│" sidebar visible. We give it
|
||||
// 30 s, which is generous.
|
||||
if err := s.waitForPaneReady(ctx, target, 30*time.Second); err != nil {
|
||||
return "", fmt.Errorf("wait-for-ready: %w", err)
|
||||
}
|
||||
|
||||
// Tag the window so a re-discover next boot finds it.
|
||||
_ = runTmux(ctx, "set-window-option", "-t", target, "@paliadin-scope", "chat")
|
||||
_ = runTmux(ctx, "set-window-option", "-t", target, "@fix-name", "claude-paliadin")
|
||||
|
||||
// Send the bootstrap system prompt so Claude knows who it is and how
|
||||
// to reply (write to the per-turn file with [paliadin-meta] trailer).
|
||||
if err := s.sendToPane(ctx, target, paliadinSystemPrompt(s.responseDir)); err != nil {
|
||||
return "", fmt.Errorf("send system prompt: %w", err)
|
||||
}
|
||||
// Give Claude a moment to absorb the system prompt before turns flow.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
|
||||
s.paneTarget = target
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (s *PaliadinService) findChatWindow(ctx context.Context) string {
|
||||
out, err := runTmuxOut(ctx, "list-windows", "-t", s.tmuxSession,
|
||||
"-F", "#{window_index}")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, idx := range strings.Fields(out) {
|
||||
target := fmt.Sprintf("%s:%s", s.tmuxSession, idx)
|
||||
scope, err := runTmuxOut(ctx, "show-window-option",
|
||||
"-t", target, "-v", "@paliadin-scope")
|
||||
if err == nil && strings.TrimSpace(scope) == "chat" {
|
||||
return target
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *PaliadinService) paneAlive(ctx context.Context, target string) bool {
|
||||
if err := runTmux(ctx, "has-session", "-t", target); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *PaliadinService) waitForPaneReady(ctx context.Context, target string, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
out, err := runTmuxOut(ctx, "capture-pane", "-t", target, "-p")
|
||||
if err == nil && (strings.Contains(out, "❯") || strings.Contains(out, "│")) {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("pane %s not ready within %s", target, timeout)
|
||||
}
|
||||
|
||||
func (s *PaliadinService) sendToPane(ctx context.Context, target, msg string) error {
|
||||
// `-l` sends the message literally (no key parsing) — necessary so
|
||||
// our prompt's special characters don't get interpreted.
|
||||
if err := runTmux(ctx, "send-keys", "-t", target, "-l", msg); err != nil {
|
||||
return err
|
||||
}
|
||||
// Trailing Enter. tmux send-keys treats "Enter" as a special key name.
|
||||
if err := runTmux(ctx, "send-keys", "-t", target, "Enter"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pollForResponse waits for the response file to materialise. Returns
|
||||
// the file's content (and removes the file). Treats stale files (left
|
||||
// over from earlier turns) as a non-event — the file existing without a
|
||||
// fresh mtime is a corner case the caller already de-duplicates by
|
||||
// having a unique turn_id per request.
|
||||
func (s *PaliadinService) pollForResponse(ctx context.Context, path string, timeout time.Duration) (string, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
default:
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil && len(data) > 0 {
|
||||
// Brief settle delay so we don't read mid-flush.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
data, _ = os.ReadFile(path)
|
||||
_ = os.Remove(path)
|
||||
return string(data), nil
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
return "", fmt.Errorf("paliadin: response timeout after %s", timeout)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// shell / tmux helpers.
|
||||
// =============================================================================
|
||||
|
||||
// runTmux runs `tmux <args...>`. Discards output. Returns error if tmux
|
||||
// returns non-zero.
|
||||
func runTmux(ctx context.Context, args ...string) error {
|
||||
c, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(c, "tmux", args...)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("tmux %s: %w (stderr: %s)", strings.Join(args, " "), err, stderr.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runTmuxOut runs `tmux <args...>` and returns stdout. Useful for
|
||||
// capture-pane / list-windows / show-window-option.
|
||||
func runTmuxOut(ctx context.Context, args ...string) (string, error) {
|
||||
c, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(c, "tmux", args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("tmux %s: %w (stderr: %s)", strings.Join(args, " "), err, stderr.String())
|
||||
}
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
// sanitiseForTmux removes control sequences that would confuse the pane.
|
||||
// `tmux send-keys -l` sends literally, but stray newlines inside the
|
||||
// message would split it across multiple "send" actions, breaking the
|
||||
// turn envelope.
|
||||
func sanitiseForTmux(s string) string {
|
||||
s = strings.ReplaceAll(s, "\r", " ")
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
// Cap length: a runaway prompt is a footgun.
|
||||
const maxLen = 8000
|
||||
if len(s) > maxLen {
|
||||
s = s[:maxLen] + " […truncated]"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// trailer parsing.
|
||||
// =============================================================================
|
||||
|
||||
// trailerMeta is what we extract from the [paliadin-meta]…[/paliadin-meta]
|
||||
// block at the end of Claude's response. Best-effort: missing fields
|
||||
// default to zero values.
|
||||
type trailerMeta struct {
|
||||
UsedTools []string
|
||||
RowsSeen []int
|
||||
ClassifierTag string
|
||||
}
|
||||
|
||||
var trailerRE = regexp.MustCompile(`(?s)\n*---\s*\n+\[paliadin-meta\]\s*\n(.+?)\n\[/paliadin-meta\]\s*$`)
|
||||
|
||||
// splitTrailer separates the meta block from the body. If no trailer is
|
||||
// present, the entire input is returned as the body.
|
||||
func splitTrailer(body string) (string, trailerMeta) {
|
||||
body = strings.TrimRight(body, " \t\n\r")
|
||||
m := trailerRE.FindStringSubmatchIndex(body)
|
||||
if m == nil {
|
||||
return body, trailerMeta{}
|
||||
}
|
||||
cleanBody := strings.TrimRight(body[:m[0]], " \t\n\r")
|
||||
metaText := body[m[2]:m[3]]
|
||||
return cleanBody, parseTrailer(metaText)
|
||||
}
|
||||
|
||||
func parseTrailer(text string) trailerMeta {
|
||||
out := trailerMeta{}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
k, v, ok := splitFirst(strings.TrimSpace(line), ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
v = strings.TrimSpace(v)
|
||||
switch strings.ToLower(strings.TrimSpace(k)) {
|
||||
case "used_tools":
|
||||
for _, t := range strings.Split(v, ",") {
|
||||
t = strings.TrimSpace(t)
|
||||
if t != "" {
|
||||
out.UsedTools = append(out.UsedTools, t)
|
||||
}
|
||||
}
|
||||
case "rows_seen":
|
||||
for _, t := range strings.Split(v, ",") {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(t))
|
||||
if err == nil {
|
||||
out.RowsSeen = append(out.RowsSeen, n)
|
||||
}
|
||||
}
|
||||
case "classifier_tag":
|
||||
out.ClassifierTag = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitFirst(s, sep string) (string, string, bool) {
|
||||
i := strings.Index(s, sep)
|
||||
if i < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return s[:i], s[i+len(sep):], true
|
||||
}
|
||||
|
||||
// approxTokenCount is a coarse word-count × 1.3 heuristic. Real token
|
||||
// counts aren't exposed by Claude Code via tmux; this is just for the
|
||||
// dashboard's cost-trend sense.
|
||||
func approxTokenCount(s string) int {
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
words := strings.Fields(s)
|
||||
return int(float64(len(words)) * 1.3)
|
||||
}
|
||||
|
||||
// countChips matches the `[#deadline-OPEN:…]`, `[#projekt-OPEN:…]`,
|
||||
// `[chip:…]` markers the system prompt asks Claude to embed. PoC's
|
||||
// frontend renders these as buttons; for the audit log we only need
|
||||
// the count.
|
||||
var chipRE = regexp.MustCompile(`\[(?:#[a-z]+-OPEN:[A-Za-z0-9\-_]+|chip:[a-z]+:[^\]]+)\]`)
|
||||
|
||||
func countChips(s string) int {
|
||||
return len(chipRE.FindAllString(s, -1))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// audit-row writers.
|
||||
// =============================================================================
|
||||
|
||||
func (s *PaliadinService) insertTurnRow(ctx context.Context, t *PaliadinTurn) error {
|
||||
q := `
|
||||
INSERT INTO paliad.paliadin_turns (
|
||||
turn_id, user_id, session_id, started_at, user_message, page_origin
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`
|
||||
_, err := s.db.ExecContext(ctx, q,
|
||||
t.TurnID, t.UserID, t.SessionID, t.StartedAt, t.UserMessage, t.PageOrigin)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PaliadinService) completeTurn(ctx context.Context, turnID uuid.UUID,
|
||||
finishedAt time.Time, durationMS int, response string, tokens int,
|
||||
meta trailerMeta, chipCount int) error {
|
||||
rowsSeen := make(pq.Int64Array, 0, len(meta.RowsSeen))
|
||||
for _, n := range meta.RowsSeen {
|
||||
rowsSeen = append(rowsSeen, int64(n))
|
||||
}
|
||||
q := `
|
||||
UPDATE paliad.paliadin_turns
|
||||
SET finished_at = $2,
|
||||
duration_ms = $3,
|
||||
response = $4,
|
||||
response_tokens = $5,
|
||||
used_tools = $6,
|
||||
rows_seen = $7,
|
||||
chip_count = $8,
|
||||
classifier_tag = $9
|
||||
WHERE turn_id = $1
|
||||
`
|
||||
_, err := s.db.ExecContext(ctx, q,
|
||||
turnID, finishedAt, durationMS, response, tokens,
|
||||
pq.StringArray(meta.UsedTools), rowsSeen, chipCount,
|
||||
optionalString(meta.ClassifierTag))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PaliadinService) markTurnError(ctx context.Context, turnID uuid.UUID, code string) error {
|
||||
finished := time.Now().UTC()
|
||||
q := `
|
||||
UPDATE paliad.paliadin_turns
|
||||
SET finished_at = $2, error_code = $3
|
||||
WHERE turn_id = $1 AND finished_at IS NULL
|
||||
`
|
||||
_, err := s.db.ExecContext(ctx, q, turnID, finished, code)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PaliadinService) markTurnAbandonedOrError(ctx context.Context, turnID uuid.UUID, code string, abandoned bool) error {
|
||||
finished := time.Now().UTC()
|
||||
q := `
|
||||
UPDATE paliad.paliadin_turns
|
||||
SET finished_at = $2, error_code = $3, abandoned = $4
|
||||
WHERE turn_id = $1 AND finished_at IS NULL
|
||||
`
|
||||
_, err := s.db.ExecContext(ctx, q, turnID, finished, code, abandoned)
|
||||
return err
|
||||
}
|
||||
|
||||
func optionalString(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
// Compile-time type guards (catches sql.ErrNoRows shifts).
|
||||
var _ error = sql.ErrNoRows
|
||||
269
internal/services/paliadin_prompt.go
Normal file
269
internal/services/paliadin_prompt.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package services
|
||||
|
||||
// Paliadin system prompt — Phase 0 PoC.
|
||||
//
|
||||
// This is the bootstrap message sent to the long-lived `claude` pane
|
||||
// once, right after the pane is created. It defines who Paliadin is,
|
||||
// how to reply (write to the per-turn response file, emit a
|
||||
// [paliadin-meta] trailer block), what SQL to run, and how visibility
|
||||
// is enforced.
|
||||
//
|
||||
// Design: docs/design-paliadin-2026-05-07.md §0.5.3 + §2.2.1.
|
||||
//
|
||||
// Conventions:
|
||||
// - The prompt MUST end with the response-file write rule, since that
|
||||
// is the contract the Go service polls on.
|
||||
// - SQL recipes MUST always include the visibility predicate
|
||||
// (paliad.can_see_project) on any project-scoped query — even
|
||||
// though m's global_role=global_admin technically lets him see
|
||||
// everything, we keep the muscle memory consistent with the
|
||||
// production-v1 design.
|
||||
// - The trailer format is stable; the trailer parser in paliadin.go
|
||||
// must be kept in sync.
|
||||
|
||||
import "strings"
|
||||
|
||||
// paliadinSystemPrompt returns the full bootstrap message for a fresh
|
||||
// Claude pane. The response_dir argument is the path where Claude must
|
||||
// write its per-turn response files.
|
||||
//
|
||||
// Built via concatenation rather than fmt.Sprintf because the prompt
|
||||
// contains German genitive apostrophes ("m's") that Sprintf misreads as
|
||||
// format verbs.
|
||||
func paliadinSystemPrompt(responseDir string) string {
|
||||
return strings.TrimSpace(`
|
||||
Du bist Paliadin — der eingebaute KI-Assistent in Paliad, m's Patentpraxis-Plattform. Du hilfst m bei seiner täglichen Arbeit: Akten finden, Fristen prüfen, Begriffe erklären, Gerichte nachschlagen, UPC-Rechtsprechung recherchieren.
|
||||
|
||||
# Persönlichkeit
|
||||
|
||||
- Direkt, kompetent, juristisch präzise. Keine Floskeln.
|
||||
- Sprich wie ein Patentanwalts-Kollege mit zehn Jahren UPC-Erfahrung — nicht wie ein generischer Chatbot.
|
||||
- Belege jede konkrete Aussage mit einem Tool-Call oder einer Zitat-Quelle. Niemals raten.
|
||||
- Antworte standardmäßig auf Deutsch (m's Arbeitssprache). Wenn m auf Englisch fragt, antworte auf Englisch.
|
||||
- Keine Emojis, keine "Ich helfe dir gerne!"-Phrasen.
|
||||
|
||||
# Antwort-Protokoll (KRITISCH)
|
||||
|
||||
Jede Anfrage von m kommt im Format: ` + "`[PALIADIN:turn_id] <Frage>`" + `
|
||||
|
||||
Sobald du die turn_id liest:
|
||||
1. Recherchiere mit deinen Tools (siehe SQL-Rezepte unten).
|
||||
2. Formuliere eine knappe, faktenbasierte Antwort in Markdown.
|
||||
3. Schreibe die Antwort in eine Datei: ` + "`Write(" + responseDir + "/{turn_id}.txt)`" + `
|
||||
4. WICHTIG: Schreib SOFORT, sobald du die Antwort hast. Das System wartet (Timeout: 60s).
|
||||
5. Häng am Ende des Antworttextes IMMER einen [paliadin-meta]-Block an — sonst weiß das System nicht, was du gemacht hast.
|
||||
|
||||
# Trailer-Format (PFLICHT am Ende jeder Antwort)
|
||||
|
||||
Trenne den Block mit einer Leerzeile + ---, dann:
|
||||
|
||||
[paliadin-meta]
|
||||
used_tools: <komma-separierte Tool-Namen, leer wenn keiner verwendet>
|
||||
rows_seen: <komma-separierte Zeilen-Counts, parallel zu used_tools>
|
||||
classifier_tag: <data | concept | navigation | meta | other>
|
||||
[/paliadin-meta]
|
||||
|
||||
Beispiel:
|
||||
|
||||
[paliadin-meta]
|
||||
used_tools: search_my_deadlines, lookup_court
|
||||
rows_seen: 3, 1
|
||||
classifier_tag: data
|
||||
[/paliadin-meta]
|
||||
|
||||
Die classifier_tag-Werte:
|
||||
- ` + "`data`" + ` — m fragt nach seinen eigenen Daten ("welche Frist…", "auf welchem Projekt…")
|
||||
- ` + "`concept`" + ` — m fragt nach einem juristischen Begriff/Verfahren ("was ist Klageerwiderung?")
|
||||
- ` + "`navigation`" + ` — m sucht eine Seite/Funktion in Paliad ("wie öffne ich…")
|
||||
- ` + "`meta`" + ` — Frage über Paliadin selbst, oder Smalltalk
|
||||
- ` + "`other`" + ` — alles andere (Recherche, Web-Wissen)
|
||||
|
||||
# Action-Chips (optional, aber gerne nutzen)
|
||||
|
||||
Wenn du eine konkrete Folge-Aktion anbieten kannst, embed einen Chip-Marker direkt in den Antworttext. Das Frontend rendert ihn als anklickbaren Button:
|
||||
|
||||
- ` + "`[#deadline-OPEN:c47bd2-...]`" + ` — öffnet die Fristen-Detailseite
|
||||
- ` + "`[#projekt-OPEN:slug-x]`" + ` — öffnet die Projekt-Detailseite
|
||||
- ` + "`[chip:nav:/projects/abc-123]`" + ` — beliebige Navigation
|
||||
- ` + "`[chip:filter:status=pending&due=this_week]`" + ` — gefilterter Inbox-Link
|
||||
|
||||
Verwende NUR IDs/Slugs, die du tatsächlich aus einem Tool-Call zurückbekommen hast. Niemals erfinden.
|
||||
|
||||
# Hard Rules
|
||||
|
||||
1. **Keine Erfindungen.** Wenn ein Tool keine Daten liefert, sag das. Niemals Aktenzeichen, Daten, Gerichts- oder Parteinamen erfinden.
|
||||
2. **Jede konkrete Aussage über m's eigene Arbeit MUSS aus einem Tool-Call der aktuellen Antwort kommen.** Erinnerung an frühere Gespräche reicht nicht — Daten ändern sich.
|
||||
3. **Schreibe nichts in die DB.** Du bist read-only. Wenn m etwas ändern will, sag ihm wo in Paliad.
|
||||
4. **Visibility-Gate respektieren.** Auch wenn m global_admin ist: jede projekt-bezogene Abfrage MUSS ` + "`paliad.can_see_project(project_id)`" + ` enthalten. Konsistenz mit der späteren Multi-User-Version.
|
||||
5. **Nicht über die Daten anderer User spekulieren**, selbst wenn m sie namentlich erwähnt — frag nach Projekt-ID/Slug.
|
||||
|
||||
# SQL-Rezepte
|
||||
|
||||
Du hast Zugriff auf zwei Datenquellen über das Supabase MCP (mcp__supabase__execute_sql):
|
||||
- ` + "`paliad.*`" + ` — m's Patent-Praxis-Daten (Projekte, Fristen, Termine, Parteien, Gerichte, Glossar, Deadline-Rules)
|
||||
- ` + "`data.*`" + ` — youpc.org UPC-Rechtsprechung (Urteile, Headnotes, Knowledge Graph) — selbe physische DB!
|
||||
|
||||
## 1. whats_on_my_plate — m's Dashboard-Übersicht
|
||||
|
||||
` + "```sql" + `
|
||||
SELECT
|
||||
(SELECT count(*) FROM paliad.deadlines d
|
||||
WHERE paliad.can_see_project(d.project_id)
|
||||
AND d.status = 'pending' AND d.due_date < current_date) AS overdue,
|
||||
(SELECT count(*) FROM paliad.deadlines d
|
||||
WHERE paliad.can_see_project(d.project_id)
|
||||
AND d.status = 'pending' AND d.due_date = current_date) AS today,
|
||||
(SELECT count(*) FROM paliad.deadlines d
|
||||
WHERE paliad.can_see_project(d.project_id)
|
||||
AND d.status = 'pending'
|
||||
AND d.due_date BETWEEN current_date AND current_date + 7) AS this_week,
|
||||
(SELECT count(*) FROM paliad.appointments a
|
||||
WHERE (a.project_id IS NULL OR paliad.can_see_project(a.project_id))
|
||||
AND a.start_at::date = current_date) AS appointments_today;
|
||||
` + "```" + `
|
||||
|
||||
## 2. list_my_projects
|
||||
|
||||
` + "```sql" + `
|
||||
SELECT id, kind, label, status, parent_id, path
|
||||
FROM paliad.projects
|
||||
WHERE paliad.can_see_project(id)
|
||||
AND status = 'active'
|
||||
ORDER BY path
|
||||
LIMIT 25;
|
||||
` + "```" + `
|
||||
|
||||
## 3. get_project_detail (gegeben slug oder id)
|
||||
|
||||
` + "```sql" + `
|
||||
SELECT p.*,
|
||||
(SELECT json_agg(d ORDER BY d.due_date)
|
||||
FROM paliad.deadlines d WHERE d.project_id = p.id
|
||||
AND paliad.can_see_project(d.project_id)) AS deadlines,
|
||||
(SELECT json_agg(a ORDER BY a.start_at)
|
||||
FROM paliad.appointments a WHERE a.project_id = p.id
|
||||
AND paliad.can_see_project(a.project_id)) AS appointments,
|
||||
(SELECT json_agg(pa) FROM paliad.parties pa WHERE pa.project_id = p.id) AS parties
|
||||
FROM paliad.projects p
|
||||
WHERE paliad.can_see_project(p.id)
|
||||
AND (p.id::text = '<UUID>' OR p.slug = '<slug>')
|
||||
LIMIT 1;
|
||||
` + "```" + `
|
||||
|
||||
## 4. search_my_deadlines (status / Datum / Projekt)
|
||||
|
||||
` + "```sql" + `
|
||||
SELECT d.id, d.title, d.due_date, d.status, p.label AS project_label, d.event_id
|
||||
FROM paliad.deadlines d
|
||||
JOIN paliad.projects p ON p.id = d.project_id
|
||||
WHERE paliad.can_see_project(d.project_id)
|
||||
AND ($status::text IS NULL OR d.status = $status)
|
||||
AND ($due_after::date IS NULL OR d.due_date >= $due_after)
|
||||
AND ($due_before::date IS NULL OR d.due_date <= $due_before)
|
||||
ORDER BY d.due_date ASC
|
||||
LIMIT 25;
|
||||
` + "```" + `
|
||||
|
||||
## 5. list_my_appointments (Zeitfenster)
|
||||
|
||||
` + "```sql" + `
|
||||
SELECT a.id, a.title, a.start_at, a.end_at, a.location, p.label AS project_label
|
||||
FROM paliad.appointments a
|
||||
LEFT JOIN paliad.projects p ON p.id = a.project_id
|
||||
WHERE (a.project_id IS NULL OR paliad.can_see_project(a.project_id))
|
||||
AND a.start_at >= $from
|
||||
AND a.start_at <= $to
|
||||
ORDER BY a.start_at ASC
|
||||
LIMIT 25;
|
||||
` + "```" + `
|
||||
|
||||
## 6. lookup_court (Gerichtskatalog — firm-wide reference)
|
||||
|
||||
` + "```sql" + `
|
||||
SELECT c.slug, c.name, c.country, c.kind, c.address
|
||||
FROM paliad.courts c
|
||||
WHERE c.name ILIKE '%' || $q || '%'
|
||||
OR c.slug ILIKE '%' || $q || '%'
|
||||
ORDER BY similarity(c.name, $q) DESC
|
||||
LIMIT 10;
|
||||
` + "```" + `
|
||||
|
||||
## 7. lookup_glossary_term (Patent-Glossar, DE+EN)
|
||||
|
||||
` + "```sql" + `
|
||||
-- Hinweis: Glossar ist statisch in internal/handlers/glossary.go.
|
||||
-- Der Service lädt JSON beim Boot. Wenn du einen Begriff suchst, frag mich
|
||||
-- direkt im Chat — m hat den Glossar-Volltext im Kopf, oder ich kann ihn
|
||||
-- aus paliad.deadline_rules.legal_source ableiten.
|
||||
` + "```" + `
|
||||
|
||||
## 8. lookup_deadline_rule (Fristenrechner-Konzepte)
|
||||
|
||||
` + "```sql" + `
|
||||
SELECT r.rule_code, r.concept_label, r.trigger_event, r.deadline_text,
|
||||
r.deadline_text_en, r.legal_source, r.deadline_notes, r.deadline_notes_en
|
||||
FROM paliad.deadline_rules r
|
||||
WHERE r.concept_label ILIKE '%' || $q || '%'
|
||||
OR r.rule_code ILIKE '%' || $q || '%'
|
||||
OR r.legal_source ILIKE '%' || $q || '%'
|
||||
ORDER BY similarity(r.concept_label, $q) DESC
|
||||
LIMIT 5;
|
||||
` + "```" + `
|
||||
|
||||
## 9. lookup_youpc_case (UPC-Rechtsprechung — cross-schema!)
|
||||
|
||||
` + "```sql" + `
|
||||
SELECT j.node_id, j.upc_number, j.court_division, j.judgment_type,
|
||||
j.proceedings_type, j.decision_date, j.headnote_summary,
|
||||
j.tags
|
||||
FROM data.judgments j
|
||||
WHERE j.upc_number ILIKE '%' || $q || '%'
|
||||
OR j.headnote_summary ILIKE '%' || $q || '%'
|
||||
OR j.tags::text ILIKE '%' || $q || '%'
|
||||
ORDER BY j.decision_date DESC
|
||||
LIMIT 5;
|
||||
` + "```" + `
|
||||
|
||||
Volltext eines Urteils (wenn m fragt "was steht in dem Urteil?"):
|
||||
|
||||
` + "```sql" + `
|
||||
SELECT content
|
||||
FROM data.judgment_markdown_content
|
||||
WHERE judgment_node_id = <node_id>
|
||||
ORDER BY chunk_index
|
||||
LIMIT 1;
|
||||
` + "```" + `
|
||||
|
||||
# Beispiel-Antwort
|
||||
|
||||
m fragt: ` + "`[PALIADIN:abc-123] welche fristen sind diese woche fällig?`" + `
|
||||
|
||||
Du machst:
|
||||
1. ` + "`mcp__supabase__execute_sql`" + ` mit Rezept #4 (search_my_deadlines), $status='pending', $due_after=current_date, $due_before=current_date+7
|
||||
2. Du bekommst z.B. 3 Zeilen zurück.
|
||||
3. Du schreibst:
|
||||
|
||||
` + "```" + `
|
||||
Write("/tmp/paliadin/abc-123.txt", """
|
||||
Diese Woche stehen 3 Fristen an:
|
||||
|
||||
- **16.05.** Klageerwiderung auf Müller v. Acme [#deadline-OPEN:c47bd2-1] — UPC LD München
|
||||
- **17.05.** Replik auf BMW v. Daimler [#deadline-OPEN:e92a01-3]
|
||||
- **20.05.** Wiedereinsetzungsantrag auf Bosch-Patent [#deadline-OPEN:f31b09-7]
|
||||
|
||||
Willst du eine davon im Detail anschauen?
|
||||
|
||||
---
|
||||
[paliadin-meta]
|
||||
used_tools: search_my_deadlines
|
||||
rows_seen: 3
|
||||
classifier_tag: data
|
||||
[/paliadin-meta]
|
||||
""")
|
||||
` + "```" + `
|
||||
|
||||
# Wichtig
|
||||
|
||||
Der erste turn-Envelope, den du nach diesem System-Prompt bekommst, ist eine richtige m-Anfrage. Antworte gemäß Protokoll. Bei der allerersten Anfrage darfst du dich kurz vorstellen ("Hi m, ich bin Paliadin — bereit."), danach normaler Modus.
|
||||
`)
|
||||
}
|
||||
165
internal/services/paliadin_test.go
Normal file
165
internal/services/paliadin_test.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Tests for the PoC paliadin trailer parser. The parser is load-bearing:
|
||||
// it's how the dashboard learns which tools Claude used, how many rows
|
||||
// each returned, and how Claude classified the question. Wrong parsing
|
||||
// = silently broken monitoring.
|
||||
|
||||
func TestSplitTrailer_HappyPath(t *testing.T) {
|
||||
body := strings.TrimSpace(`
|
||||
Diese Woche stehen 3 Fristen an:
|
||||
|
||||
- 16.05. Klageerwiderung [#deadline-OPEN:c47bd2-1]
|
||||
- 17.05. Replik [#deadline-OPEN:e92a01-3]
|
||||
- 20.05. Wiedereinsetzung [#deadline-OPEN:f31b09-7]
|
||||
|
||||
---
|
||||
[paliadin-meta]
|
||||
used_tools: search_my_deadlines, lookup_court
|
||||
rows_seen: 3, 1
|
||||
classifier_tag: data
|
||||
[/paliadin-meta]
|
||||
`)
|
||||
clean, meta := splitTrailer(body)
|
||||
|
||||
if strings.Contains(clean, "[paliadin-meta]") {
|
||||
t.Fatalf("trailer not stripped from body:\n%s", clean)
|
||||
}
|
||||
if !strings.HasPrefix(clean, "Diese Woche") {
|
||||
t.Errorf("body lost prefix: %q", clean[:50])
|
||||
}
|
||||
wantTools := []string{"search_my_deadlines", "lookup_court"}
|
||||
if len(meta.UsedTools) != len(wantTools) {
|
||||
t.Fatalf("UsedTools len = %d; want %d", len(meta.UsedTools), len(wantTools))
|
||||
}
|
||||
for i, want := range wantTools {
|
||||
if meta.UsedTools[i] != want {
|
||||
t.Errorf("UsedTools[%d] = %q; want %q", i, meta.UsedTools[i], want)
|
||||
}
|
||||
}
|
||||
wantRows := []int{3, 1}
|
||||
if len(meta.RowsSeen) != len(wantRows) {
|
||||
t.Fatalf("RowsSeen len = %d; want %d", len(meta.RowsSeen), len(wantRows))
|
||||
}
|
||||
for i, want := range wantRows {
|
||||
if meta.RowsSeen[i] != want {
|
||||
t.Errorf("RowsSeen[%d] = %d; want %d", i, meta.RowsSeen[i], want)
|
||||
}
|
||||
}
|
||||
if meta.ClassifierTag != "data" {
|
||||
t.Errorf("ClassifierTag = %q; want %q", meta.ClassifierTag, "data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitTrailer_NoTrailer(t *testing.T) {
|
||||
body := "Just a response, no trailer."
|
||||
clean, meta := splitTrailer(body)
|
||||
if clean != body {
|
||||
t.Errorf("body changed: %q vs %q", clean, body)
|
||||
}
|
||||
if len(meta.UsedTools) != 0 || len(meta.RowsSeen) != 0 || meta.ClassifierTag != "" {
|
||||
t.Errorf("meta should be zero: %+v", meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitTrailer_EmptyToolsList(t *testing.T) {
|
||||
body := strings.TrimSpace(`
|
||||
Klageerwiderung ist die Erwiderung auf die Klage.
|
||||
|
||||
---
|
||||
[paliadin-meta]
|
||||
used_tools:
|
||||
rows_seen:
|
||||
classifier_tag: concept
|
||||
[/paliadin-meta]
|
||||
`)
|
||||
clean, meta := splitTrailer(body)
|
||||
if strings.Contains(clean, "[paliadin-meta]") {
|
||||
t.Errorf("trailer not stripped")
|
||||
}
|
||||
if len(meta.UsedTools) != 0 {
|
||||
t.Errorf("UsedTools should be empty: %v", meta.UsedTools)
|
||||
}
|
||||
if meta.ClassifierTag != "concept" {
|
||||
t.Errorf("ClassifierTag = %q; want concept", meta.ClassifierTag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountChips(t *testing.T) {
|
||||
cases := []struct {
|
||||
body string
|
||||
want int
|
||||
}{
|
||||
{"plain text", 0},
|
||||
{"see [#deadline-OPEN:abc-123]", 1},
|
||||
{"two [#deadline-OPEN:abc] and [#projekt-OPEN:slug]", 2},
|
||||
{"chip nav [chip:nav:/projects/123]", 1},
|
||||
{"chip filter [chip:filter:status=pending]", 1},
|
||||
{"mixed [#frist-OPEN:x] and [chip:nav:/y]", 2},
|
||||
// Hallucinated / malformed markers don't count.
|
||||
{"[#deadline-OPEN:]", 0},
|
||||
{"[#deadline-OPEN]", 0},
|
||||
{"[chip:invalid]", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := countChips(c.body)
|
||||
if got != c.want {
|
||||
t.Errorf("countChips(%q) = %d; want %d", c.body, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproxTokenCount(t *testing.T) {
|
||||
cases := []struct {
|
||||
s string
|
||||
want int
|
||||
}{
|
||||
{"", 0},
|
||||
{"hello", 1}, // 1 word × 1.3 → 1
|
||||
{"hello world", 2}, // 2 × 1.3 = 2.6 → 2
|
||||
{"one two three four five six seven", 9}, // 7 × 1.3 = 9.1 → 9
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := approxTokenCount(c.s)
|
||||
if got != c.want {
|
||||
t.Errorf("approxTokenCount(%q) = %d; want %d", c.s, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitiseForTmux(t *testing.T) {
|
||||
in := "first line\nsecond line\rthird"
|
||||
got := sanitiseForTmux(in)
|
||||
if strings.ContainsAny(got, "\n\r") {
|
||||
t.Errorf("sanitiseForTmux did not strip newlines: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaliadinOwnerEmail_IsLowercaseStable(t *testing.T) {
|
||||
// Sanity check: the constant matches the email seeded in
|
||||
// migration 023 verbatim. If it ever drifts, the gate would
|
||||
// reject m on a fresh DB without anyone noticing.
|
||||
want := "matthias.siebels@hoganlovells.com"
|
||||
if PaliadinOwnerEmail != want {
|
||||
t.Fatalf("PaliadinOwnerEmail = %q; want %q (matches migration 023 seed)",
|
||||
PaliadinOwnerEmail, want)
|
||||
}
|
||||
// Lowercase invariant — the gate uses strings.EqualFold but we
|
||||
// store + compare lowercase consistently anyway.
|
||||
if strings.ToLower(PaliadinOwnerEmail) != PaliadinOwnerEmail {
|
||||
t.Errorf("PaliadinOwnerEmail must be lowercase: %q", PaliadinOwnerEmail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitiseForTmux_TruncatesLong(t *testing.T) {
|
||||
long := strings.Repeat("x", 10_000)
|
||||
got := sanitiseForTmux(long)
|
||||
if !strings.HasSuffix(got, "[…truncated]") {
|
||||
t.Errorf("expected truncation marker, got tail: %q", got[len(got)-20:])
|
||||
}
|
||||
}
|
||||
207
internal/services/render_spec.go
Normal file
207
internal/services/render_spec.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package services
|
||||
|
||||
// RenderSpec is the structured render description that drives how a view
|
||||
// is presented. Same shape lives in paliad.user_views.render_spec (jsonb)
|
||||
// and is composed by the frontend's view editor.
|
||||
//
|
||||
// Design: docs/design-data-display-model-2026-05-06.md §4 (Q4-Q5).
|
||||
//
|
||||
// Q4 lock-in (m, 2026-05-07): three shapes — list, cards, calendar.
|
||||
// "Activity" is content selection (sources + filters), not visualisation —
|
||||
// achieved via shape="list", list.density="compact", list.columns=
|
||||
// ["time","actor","title","project"]. Same component, different config.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// RenderShape identifies which presentation component a view uses.
|
||||
type RenderShape string
|
||||
|
||||
const (
|
||||
ShapeList RenderShape = "list"
|
||||
ShapeCards RenderShape = "cards"
|
||||
ShapeCalendar RenderShape = "calendar"
|
||||
)
|
||||
|
||||
// AllShapes lists every supported shape. Used by the validator and by
|
||||
// the in-page shape switcher.
|
||||
var AllShapes = []RenderShape{ShapeList, ShapeCards, ShapeCalendar}
|
||||
|
||||
// RenderSpec is the top-level render description.
|
||||
//
|
||||
// Per-shape config blocks are kept on save even when a different shape
|
||||
// is selected, so flipping back to a previously-used shape preserves
|
||||
// its tweaks (Q5 design decision).
|
||||
type RenderSpec struct {
|
||||
Shape RenderShape `json:"shape"`
|
||||
List *ListConfig `json:"list,omitempty"`
|
||||
Cards *CardsConfig `json:"cards,omitempty"`
|
||||
Calendar *CalendarConfig `json:"calendar,omitempty"`
|
||||
}
|
||||
|
||||
// ListConfig is the per-shape config for shape=list. Powers both the
|
||||
// /events table look (density=comfortable) and the activity-feed look
|
||||
// (density=compact + actor/time columns).
|
||||
type ListConfig struct {
|
||||
Columns []string `json:"columns,omitempty"`
|
||||
Sort SortOrder `json:"sort,omitempty"`
|
||||
Density ListDensity `json:"density,omitempty"`
|
||||
}
|
||||
|
||||
// CardsConfig is the per-shape config for shape=cards.
|
||||
type CardsConfig struct {
|
||||
GroupBy CardsGroupBy `json:"group_by,omitempty"`
|
||||
Sort SortOrder `json:"sort,omitempty"`
|
||||
ShowEmptyDays bool `json:"show_empty_days,omitempty"`
|
||||
}
|
||||
|
||||
// CalendarConfig is the per-shape config for shape=calendar.
|
||||
type CalendarConfig struct {
|
||||
DefaultView CalendarView `json:"default_view,omitempty"`
|
||||
ShowWeekends bool `json:"show_weekends,omitempty"`
|
||||
}
|
||||
|
||||
type SortOrder string
|
||||
|
||||
const (
|
||||
SortDateAsc SortOrder = "date_asc"
|
||||
SortDateDesc SortOrder = "date_desc"
|
||||
)
|
||||
|
||||
type ListDensity string
|
||||
|
||||
const (
|
||||
DensityComfortable ListDensity = "comfortable"
|
||||
DensityCompact ListDensity = "compact"
|
||||
)
|
||||
|
||||
type CardsGroupBy string
|
||||
|
||||
const (
|
||||
CardsGroupByDay CardsGroupBy = "day"
|
||||
CardsGroupByWeek CardsGroupBy = "week"
|
||||
CardsGroupByNone CardsGroupBy = "none"
|
||||
)
|
||||
|
||||
type CalendarView string
|
||||
|
||||
const (
|
||||
CalendarMonth CalendarView = "month"
|
||||
CalendarWeek CalendarView = "week"
|
||||
)
|
||||
|
||||
// KnownListColumns is the curated set of column keys the list-shape
|
||||
// renderer understands. Validator rejects anything else so a typo in
|
||||
// the editor surfaces immediately.
|
||||
var KnownListColumns = []string{
|
||||
"date", "time", "title", "project", "actor", "status",
|
||||
"rule", "event_type", "location", "appointment_type",
|
||||
"approval_status", "decided_by", "kind",
|
||||
}
|
||||
|
||||
// Validate runs the full RenderSpec contract.
|
||||
func (s *RenderSpec) Validate() error {
|
||||
if s == nil {
|
||||
return fmt.Errorf("%w: render_spec is required", ErrInvalidInput)
|
||||
}
|
||||
switch s.Shape {
|
||||
case ShapeList, ShapeCards, ShapeCalendar:
|
||||
// fine
|
||||
default:
|
||||
return fmt.Errorf("%w: unknown render_spec.shape %q", ErrInvalidInput, s.Shape)
|
||||
}
|
||||
|
||||
if s.List != nil {
|
||||
if err := s.List.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if s.Cards != nil {
|
||||
if err := s.Cards.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if s.Calendar != nil {
|
||||
if err := s.Calendar.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ListConfig) validate() error {
|
||||
for _, col := range c.Columns {
|
||||
if !slices.Contains(KnownListColumns, col) {
|
||||
return fmt.Errorf("%w: unknown list.columns value %q", ErrInvalidInput, col)
|
||||
}
|
||||
}
|
||||
switch c.Sort {
|
||||
case "", SortDateAsc, SortDateDesc:
|
||||
default:
|
||||
return fmt.Errorf("%w: unknown list.sort %q", ErrInvalidInput, c.Sort)
|
||||
}
|
||||
switch c.Density {
|
||||
case "", DensityComfortable, DensityCompact:
|
||||
default:
|
||||
return fmt.Errorf("%w: unknown list.density %q", ErrInvalidInput, c.Density)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *CardsConfig) validate() error {
|
||||
switch c.GroupBy {
|
||||
case "", CardsGroupByDay, CardsGroupByWeek, CardsGroupByNone:
|
||||
default:
|
||||
return fmt.Errorf("%w: unknown cards.group_by %q", ErrInvalidInput, c.GroupBy)
|
||||
}
|
||||
switch c.Sort {
|
||||
case "", SortDateAsc, SortDateDesc:
|
||||
default:
|
||||
return fmt.Errorf("%w: unknown cards.sort %q", ErrInvalidInput, c.Sort)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *CalendarConfig) validate() error {
|
||||
switch c.DefaultView {
|
||||
case "", CalendarMonth, CalendarWeek:
|
||||
default:
|
||||
return fmt.Errorf("%w: unknown calendar.default_view %q", ErrInvalidInput, c.DefaultView)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultRenderSpec returns a minimal valid spec — used as the seed when
|
||||
// the editor opens with a blank canvas.
|
||||
func DefaultRenderSpec() RenderSpec {
|
||||
return RenderSpec{
|
||||
Shape: ShapeList,
|
||||
List: &ListConfig{
|
||||
Sort: SortDateAsc,
|
||||
Density: DensityComfortable,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalRenderSpec validates + encodes for storage.
|
||||
func MarshalRenderSpec(s RenderSpec) ([]byte, error) {
|
||||
if err := s.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(s)
|
||||
}
|
||||
|
||||
// UnmarshalRenderSpec parses + validates.
|
||||
func UnmarshalRenderSpec(b []byte) (RenderSpec, error) {
|
||||
var s RenderSpec
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return RenderSpec{}, fmt.Errorf("%w: render_spec malformed: %v", ErrInvalidInput, err)
|
||||
}
|
||||
if err := s.Validate(); err != nil {
|
||||
return RenderSpec{}, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
103
internal/services/render_spec_test.go
Normal file
103
internal/services/render_spec_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package services
|
||||
|
||||
// Pure-Go tests for RenderSpec.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderSpec_HappyPath(t *testing.T) {
|
||||
s := DefaultRenderSpec()
|
||||
if err := s.Validate(); err != nil {
|
||||
t.Fatalf("default render spec must validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSpec_ShapeMustBeKnown(t *testing.T) {
|
||||
cases := []RenderShape{ShapeList, ShapeCards, ShapeCalendar}
|
||||
for _, sh := range cases {
|
||||
t.Run(string(sh), func(t *testing.T) {
|
||||
s := RenderSpec{Shape: sh}
|
||||
if err := s.Validate(); err != nil {
|
||||
t.Fatalf("shape %q must validate: %v", sh, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSpec_UnknownShapeRejects(t *testing.T) {
|
||||
s := RenderSpec{Shape: "kanban"}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown shape must reject, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSpec_ListColumnEnum(t *testing.T) {
|
||||
s := RenderSpec{Shape: ShapeList, List: &ListConfig{Columns: []string{"date", "bogus"}}}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown list column must reject, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSpec_KnownListColumnsAccepted(t *testing.T) {
|
||||
s := RenderSpec{Shape: ShapeList, List: &ListConfig{Columns: KnownListColumns}}
|
||||
if err := s.Validate(); err != nil {
|
||||
t.Fatalf("known columns must validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSpec_ListSortEnum(t *testing.T) {
|
||||
s := RenderSpec{Shape: ShapeList, List: &ListConfig{Sort: "weird"}}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown sort must reject, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSpec_ListDensityEnum(t *testing.T) {
|
||||
s := RenderSpec{Shape: ShapeList, List: &ListConfig{Density: "huge"}}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown density must reject, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSpec_CardsGroupByEnum(t *testing.T) {
|
||||
s := RenderSpec{Shape: ShapeCards, Cards: &CardsConfig{GroupBy: "month"}}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown group_by must reject, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSpec_CalendarViewEnum(t *testing.T) {
|
||||
s := RenderSpec{Shape: ShapeCalendar, Calendar: &CalendarConfig{DefaultView: "year"}}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown default_view must reject, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSpec_RoundTrip(t *testing.T) {
|
||||
original := RenderSpec{
|
||||
Shape: ShapeList,
|
||||
List: &ListConfig{
|
||||
Columns: []string{"time", "actor", "title", "project"},
|
||||
Sort: SortDateDesc,
|
||||
Density: DensityCompact,
|
||||
},
|
||||
Cards: &CardsConfig{GroupBy: CardsGroupByDay, Sort: SortDateAsc},
|
||||
Calendar: &CalendarConfig{DefaultView: CalendarMonth},
|
||||
}
|
||||
b, err := MarshalRenderSpec(original)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
parsed, err := UnmarshalRenderSpec(b)
|
||||
if err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if parsed.Shape != original.Shape {
|
||||
t.Errorf("shape mismatch: %v vs %v", parsed.Shape, original.Shape)
|
||||
}
|
||||
if parsed.List == nil || parsed.List.Density != DensityCompact {
|
||||
t.Errorf("list config not preserved: %+v", parsed.List)
|
||||
}
|
||||
}
|
||||
208
internal/services/system_views.go
Normal file
208
internal/services/system_views.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package services
|
||||
|
||||
// SystemView is a code-resident view definition. The four system pages
|
||||
// (dashboard / agenda / events / inbox) resolve to one of these when
|
||||
// they want to consume the substrate as if they were a Custom View.
|
||||
//
|
||||
// Design: docs/design-data-display-model-2026-05-06.md §5 Q8.
|
||||
//
|
||||
// Q8 lock-in: defaults are config-as-code, not seeded rows in
|
||||
// paliad.user_views. Their slugs are reserved (validator rejects
|
||||
// matching user-view slugs).
|
||||
|
||||
import (
|
||||
"slices"
|
||||
)
|
||||
|
||||
// SystemView is the in-process projection used by the substrate's
|
||||
// SystemView callers. It mirrors the persisted user-view shape but
|
||||
// never round-trips through the DB.
|
||||
type SystemView struct {
|
||||
Slug string // matches the system-page URL ("/dashboard" → "dashboard")
|
||||
Name string // display label (kept English here; UI re-translates via i18n)
|
||||
Filter FilterSpec // canonical filter the page resolves to today
|
||||
Render RenderSpec // canonical render shape
|
||||
}
|
||||
|
||||
// DashboardSystemView returns the SystemView definition for /dashboard.
|
||||
//
|
||||
// Note: /dashboard is composed of multiple sections (5-bucket summary +
|
||||
// matter card + two-column lists + activity feed). It does NOT resolve
|
||||
// to a single FilterSpec/RenderSpec — Phase B will compose several
|
||||
// SystemView resolutions into the dashboard page. This entry exists so
|
||||
// the slug is known to the reserved-list and so future composition has
|
||||
// a stable hook.
|
||||
func DashboardSystemView() SystemView {
|
||||
return SystemView{
|
||||
Slug: "dashboard",
|
||||
Name: "Dashboard",
|
||||
// Placeholder filter — the dashboard composes multiple queries
|
||||
// in Phase B; this single spec covers the activity feed only.
|
||||
Filter: FilterSpec{
|
||||
Version: SpecVersion,
|
||||
Sources: []DataSource{SourceProjectEvent},
|
||||
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
|
||||
Time: TimeSpec{Horizon: HorizonPast30d, Field: FieldCreatedAt},
|
||||
},
|
||||
Render: RenderSpec{
|
||||
Shape: ShapeList,
|
||||
List: &ListConfig{
|
||||
Density: DensityCompact,
|
||||
Sort: SortDateDesc,
|
||||
Columns: []string{"time", "actor", "title", "project"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// AgendaSystemView returns the SystemView definition for /agenda — a
|
||||
// day-grouped feed of upcoming deadlines + appointments.
|
||||
func AgendaSystemView() SystemView {
|
||||
return SystemView{
|
||||
Slug: "agenda",
|
||||
Name: "Agenda",
|
||||
Filter: FilterSpec{
|
||||
Version: SpecVersion,
|
||||
Sources: []DataSource{SourceDeadline, SourceAppointment},
|
||||
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
|
||||
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
|
||||
Predicates: map[DataSource]Predicates{
|
||||
SourceDeadline: {Deadline: &DeadlinePredicates{Status: []string{"pending"}}},
|
||||
},
|
||||
},
|
||||
Render: RenderSpec{
|
||||
Shape: ShapeCards,
|
||||
Cards: &CardsConfig{GroupBy: CardsGroupByDay, Sort: SortDateAsc},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// EventsSystemView returns the SystemView definition for /events — the
|
||||
// table view over deadlines + appointments. The legacy URL keeps a
|
||||
// per-type chip toggle; this SystemView reflects the "all" tab default.
|
||||
func EventsSystemView() SystemView {
|
||||
return SystemView{
|
||||
Slug: "events",
|
||||
Name: "Events",
|
||||
Filter: FilterSpec{
|
||||
Version: SpecVersion,
|
||||
Sources: []DataSource{SourceDeadline, SourceAppointment},
|
||||
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
|
||||
Time: TimeSpec{Horizon: HorizonAny, Field: FieldAuto},
|
||||
},
|
||||
Render: RenderSpec{
|
||||
Shape: ShapeList,
|
||||
List: &ListConfig{
|
||||
Density: DensityComfortable,
|
||||
Sort: SortDateAsc,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// InboxSystemView returns the SystemView definition for /inbox — the
|
||||
// 4-eye approval surface (the "Zur Genehmigung" tab). The "Meine
|
||||
// Anfragen" tab is a sibling spec resolved by tab-state on the page.
|
||||
func InboxSystemView() SystemView {
|
||||
return SystemView{
|
||||
Slug: "inbox",
|
||||
Name: "Inbox",
|
||||
Filter: FilterSpec{
|
||||
Version: SpecVersion,
|
||||
Sources: []DataSource{SourceApprovalRequest},
|
||||
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
|
||||
Time: TimeSpec{Horizon: HorizonAny, Field: FieldAuto},
|
||||
Predicates: map[DataSource]Predicates{
|
||||
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
|
||||
ViewerRole: "approver_eligible",
|
||||
Status: []string{"pending"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
Render: RenderSpec{
|
||||
Shape: ShapeList,
|
||||
List: &ListConfig{
|
||||
Density: DensityComfortable,
|
||||
Sort: SortDateAsc,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// InboxRequesterSystemView is the "Meine Anfragen" tab of /inbox.
|
||||
func InboxRequesterSystemView() SystemView {
|
||||
return SystemView{
|
||||
Slug: "inbox-mine",
|
||||
Name: "Inbox (mine)",
|
||||
Filter: FilterSpec{
|
||||
Version: SpecVersion,
|
||||
Sources: []DataSource{SourceApprovalRequest},
|
||||
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
|
||||
Time: TimeSpec{Horizon: HorizonAny, Field: FieldAuto},
|
||||
Predicates: map[DataSource]Predicates{
|
||||
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
|
||||
ViewerRole: "self_requested",
|
||||
}},
|
||||
},
|
||||
},
|
||||
Render: RenderSpec{
|
||||
Shape: ShapeList,
|
||||
List: &ListConfig{
|
||||
Density: DensityComfortable,
|
||||
Sort: SortDateAsc,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// AllSystemViews returns every system-defined view in registration order.
|
||||
// Used by the reserved-slug list and by future Phase B composition.
|
||||
func AllSystemViews() []SystemView {
|
||||
return []SystemView{
|
||||
DashboardSystemView(),
|
||||
AgendaSystemView(),
|
||||
EventsSystemView(),
|
||||
InboxSystemView(),
|
||||
InboxRequesterSystemView(),
|
||||
}
|
||||
}
|
||||
|
||||
// reservedUserViewSlugs is the static list of slugs the user-view CRUD
|
||||
// rejects on create / update. Includes the SystemView slugs plus URLs
|
||||
// the application owns at the top level (admin, settings, login, …).
|
||||
//
|
||||
// Q23 lock-in (m, 2026-05-07): list as drafted.
|
||||
var reservedUserViewSlugs = []string{
|
||||
// SystemView slugs:
|
||||
"dashboard", "agenda", "events", "inbox", "inbox-mine",
|
||||
// /views/* routes:
|
||||
"new", "edit",
|
||||
// Top-level application URLs:
|
||||
"tools", "admin", "settings", "login", "logout",
|
||||
"projects", "team", "courts", "glossary", "links",
|
||||
"downloads", "checklists", "views", "changelog",
|
||||
}
|
||||
|
||||
// IsReservedUserViewSlug returns true if `slug` matches a reserved slug.
|
||||
// User-view CRUD rejects matches with ErrInvalidInput. Case-folded so
|
||||
// "Dashboard" is also rejected.
|
||||
func IsReservedUserViewSlug(slug string) bool {
|
||||
return slices.Contains(reservedUserViewSlugs, foldSlug(slug))
|
||||
}
|
||||
|
||||
// foldSlug normalises a slug for reserved-list comparison. Slugs are
|
||||
// already lowercased + dash-only by the validator before this is called,
|
||||
// but this lets IsReservedUserViewSlug be safe under direct calls.
|
||||
func foldSlug(s string) string {
|
||||
out := make([]byte, 0, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c >= 'A' && c <= 'Z':
|
||||
out = append(out, c+('a'-'A'))
|
||||
default:
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
47
internal/services/system_views_test.go
Normal file
47
internal/services/system_views_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package services
|
||||
|
||||
import "testing"
|
||||
|
||||
// Pure-Go tests for the SystemView registry. Each system view's specs
|
||||
// must self-validate; the slugs must be reserved.
|
||||
|
||||
func TestSystemViews_AllValidate(t *testing.T) {
|
||||
for _, sv := range AllSystemViews() {
|
||||
t.Run(sv.Slug, func(t *testing.T) {
|
||||
if err := sv.Filter.Validate(); err != nil {
|
||||
t.Errorf("%s filter spec invalid: %v", sv.Slug, err)
|
||||
}
|
||||
if err := sv.Render.Validate(); err != nil {
|
||||
t.Errorf("%s render spec invalid: %v", sv.Slug, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemViews_SlugsReserved(t *testing.T) {
|
||||
for _, sv := range AllSystemViews() {
|
||||
t.Run(sv.Slug, func(t *testing.T) {
|
||||
if !IsReservedUserViewSlug(sv.Slug) {
|
||||
t.Errorf("system slug %q must be reserved against user_views", sv.Slug)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReservedSlugs_CaseFolded(t *testing.T) {
|
||||
if !IsReservedUserViewSlug("Dashboard") {
|
||||
t.Error("reserved-slug check must be case-insensitive")
|
||||
}
|
||||
if !IsReservedUserViewSlug("INBOX") {
|
||||
t.Error("reserved-slug check must be case-insensitive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReservedSlugs_NonReservedAccepted(t *testing.T) {
|
||||
cases := []string{"freitag-stand", "approval-pending-mine", "siemens", "my-view"}
|
||||
for _, slug := range cases {
|
||||
if IsReservedUserViewSlug(slug) {
|
||||
t.Errorf("user-friendly slug %q must not be reserved", slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,76 @@ func (s *TeamService) ListEffectiveMembers(ctx context.Context, callerID, projec
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// MembershipEntry is one row in the team-memberships index.
|
||||
// Powers the /team page project-multi-select filter (t-paliad-147):
|
||||
// the frontend pulls the index once, then filters users locally
|
||||
// by intersecting the UI-selected project_ids against each user's
|
||||
// project_ids list.
|
||||
type MembershipEntry struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
ProjectIDs []string `json:"project_ids"`
|
||||
// LeadProjectIDs is the subset of project_ids on which this
|
||||
// user has role='lead'. Surfaces the "I am a lead on N projects"
|
||||
// state the broadcast send-button needs.
|
||||
LeadProjectIDs []string `json:"lead_project_ids"`
|
||||
// Role on each project — same indexing as project_ids — so the
|
||||
// frontend can offer a project_teams.role filter.
|
||||
Roles []string `json:"roles"`
|
||||
}
|
||||
|
||||
// ListMembershipsIndex returns one row per user × project_team membership
|
||||
// the caller can see. global_admin sees everything; non-admin only sees
|
||||
// memberships on projects whose visibility predicate they pass.
|
||||
//
|
||||
// Membership rows are direct (paliad.project_teams.project_id) only —
|
||||
// inherited memberships are left to the client to compute, since the
|
||||
// project-multi-select filter wants "user is on this exact project"
|
||||
// semantics, not "user inherits from somewhere up the tree".
|
||||
func (s *TeamService) ListMembershipsIndex(ctx context.Context, callerID uuid.UUID) ([]MembershipEntry, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT pt.user_id::text, pt.project_id::text, pt.role
|
||||
FROM paliad.project_teams pt
|
||||
JOIN paliad.projects p ON p.id = pt.project_id
|
||||
WHERE `+visibilityPredicatePositional("p", 1)+`
|
||||
ORDER BY pt.user_id, pt.project_id`,
|
||||
callerID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list memberships index: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
byUser := map[uuid.UUID]*MembershipEntry{}
|
||||
for rows.Next() {
|
||||
var userIDStr, projectIDStr, role string
|
||||
if err := rows.Scan(&userIDStr, &projectIDStr, &role); err != nil {
|
||||
return nil, fmt.Errorf("scan membership: %w", err)
|
||||
}
|
||||
uid, err := uuid.Parse(userIDStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
entry, ok := byUser[uid]
|
||||
if !ok {
|
||||
entry = &MembershipEntry{UserID: uid}
|
||||
byUser[uid] = entry
|
||||
}
|
||||
entry.ProjectIDs = append(entry.ProjectIDs, projectIDStr)
|
||||
entry.Roles = append(entry.Roles, role)
|
||||
if role == RoleLead {
|
||||
entry.LeadProjectIDs = append(entry.LeadProjectIDs, projectIDStr)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iter memberships: %w", err)
|
||||
}
|
||||
out := make([]MembershipEntry, 0, len(byUser))
|
||||
for _, e := range byUser {
|
||||
out = append(out, *e)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func isValidRole(r string) bool {
|
||||
|
||||
377
internal/services/user_view_service.go
Normal file
377
internal/services/user_view_service.go
Normal file
@@ -0,0 +1,377 @@
|
||||
package services
|
||||
|
||||
// UserViewService is the CRUD layer for paliad.user_views — saved Custom
|
||||
// View definitions per user.
|
||||
//
|
||||
// Design: docs/design-data-display-model-2026-05-06.md §5.
|
||||
//
|
||||
// Visibility: every read and write is scoped to the calling user via the
|
||||
// RLS policy `user_views_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"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// UserView is the persisted shape of a saved Custom View.
|
||||
type UserView struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
Slug string `db:"slug" json:"slug"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Icon *string `db:"icon" json:"icon,omitempty"`
|
||||
FilterSpec json.RawMessage `db:"filter_spec" json:"filter_spec"`
|
||||
RenderSpec json.RawMessage `db:"render_spec" json:"render_spec"`
|
||||
SortOrder int `db:"sort_order" json:"sort_order"`
|
||||
ShowCount bool `db:"show_count" json:"show_count"`
|
||||
LastUsedAt *time.Time `db:"last_used_at" json:"last_used_at,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// UserViewService manages paliad.user_views.
|
||||
type UserViewService struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// NewUserViewService wires the service to the pool.
|
||||
func NewUserViewService(db *sqlx.DB) *UserViewService {
|
||||
return &UserViewService{db: db}
|
||||
}
|
||||
|
||||
// CreateUserViewInput is the payload for Create.
|
||||
type CreateUserViewInput struct {
|
||||
Slug string
|
||||
Name string
|
||||
Icon *string
|
||||
FilterSpec FilterSpec
|
||||
RenderSpec RenderSpec
|
||||
ShowCount bool
|
||||
// SortOrder is server-assigned (MAX+1) on create — callers cannot set it.
|
||||
}
|
||||
|
||||
// UpdateUserViewInput is the partial-update payload. All fields are
|
||||
// optional; nil means "no change".
|
||||
type UpdateUserViewInput struct {
|
||||
Slug *string
|
||||
Name *string
|
||||
Icon *string // pointer-to-pointer-of-string would be clearer for "clear vs unchanged"; we treat *string{""} as "clear"
|
||||
FilterSpec *FilterSpec
|
||||
RenderSpec *RenderSpec
|
||||
SortOrder *int
|
||||
ShowCount *bool
|
||||
}
|
||||
|
||||
// slugRE caps slugs to URL-safe lowercase. Same shape as the migration
|
||||
// comment promises (^[a-z0-9][a-z0-9-]{0,62}$).
|
||||
var slugRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
|
||||
|
||||
// ErrUserViewSlugTaken signals "slug already exists for this user". The
|
||||
// HTTP layer maps this to 409.
|
||||
var ErrUserViewSlugTaken = errors.New("user_view slug already exists for this user")
|
||||
|
||||
// ListForUser returns the caller's saved views, ordered by sort_order ASC
|
||||
// then name. Result is the same shape /api/user-views returns to the
|
||||
// frontend on app load (sidebar hydration).
|
||||
func (s *UserViewService) ListForUser(ctx context.Context, userID uuid.UUID) ([]UserView, error) {
|
||||
var rows []UserView
|
||||
err := s.db.SelectContext(ctx, &rows, `
|
||||
SELECT id, user_id, slug, name, icon, filter_spec, render_spec,
|
||||
sort_order, show_count, last_used_at, created_at, updated_at
|
||||
FROM paliad.user_views
|
||||
WHERE user_id = $1
|
||||
ORDER BY sort_order ASC, name ASC`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list user_views: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// GetBySlug fetches one view by slug. Returns (nil, nil) when the slug
|
||||
// is unknown for this user.
|
||||
func (s *UserViewService) GetBySlug(ctx context.Context, userID uuid.UUID, slug string) (*UserView, error) {
|
||||
var v UserView
|
||||
err := s.db.GetContext(ctx, &v, `
|
||||
SELECT id, user_id, slug, name, icon, filter_spec, render_spec,
|
||||
sort_order, show_count, last_used_at, created_at, updated_at
|
||||
FROM paliad.user_views
|
||||
WHERE user_id = $1 AND slug = $2`, userID, slug)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user_view: %w", err)
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// GetByID fetches one view by id. Same nil-on-miss semantic.
|
||||
func (s *UserViewService) GetByID(ctx context.Context, userID, id uuid.UUID) (*UserView, error) {
|
||||
var v UserView
|
||||
err := s.db.GetContext(ctx, &v, `
|
||||
SELECT id, user_id, slug, name, icon, filter_spec, render_spec,
|
||||
sort_order, show_count, last_used_at, created_at, updated_at
|
||||
FROM paliad.user_views
|
||||
WHERE user_id = $1 AND id = $2`, userID, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user_view: %w", err)
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// Create persists a new view for the caller. Server-assigns sort_order
|
||||
// to MAX(existing)+1 inside the same tx so two parallel creates don't
|
||||
// collide.
|
||||
func (s *UserViewService) Create(ctx context.Context, userID uuid.UUID, input CreateUserViewInput) (*UserView, error) {
|
||||
if err := validateCreateInput(input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filterJSON, err := MarshalFilterSpec(input.FilterSpec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
renderJSON, err := MarshalRenderSpec(input.RenderSpec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var nextSortOrder int
|
||||
if err := tx.GetContext(ctx, &nextSortOrder, `
|
||||
SELECT COALESCE(MAX(sort_order), -1) + 1
|
||||
FROM paliad.user_views
|
||||
WHERE user_id = $1`, userID); err != nil {
|
||||
return nil, fmt.Errorf("compute next sort_order: %w", err)
|
||||
}
|
||||
|
||||
var v UserView
|
||||
err = tx.GetContext(ctx, &v, `
|
||||
INSERT INTO paliad.user_views
|
||||
(user_id, slug, name, icon, filter_spec, render_spec, sort_order, show_count)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, user_id, slug, name, icon, filter_spec, render_spec,
|
||||
sort_order, show_count, last_used_at, created_at, updated_at`,
|
||||
userID, input.Slug, input.Name, input.Icon,
|
||||
filterJSON, renderJSON, nextSortOrder, input.ShowCount)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrUserViewSlugTaken, input.Slug)
|
||||
}
|
||||
return nil, fmt.Errorf("insert user_view: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit user_view create: %w", err)
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// Update applies a partial update to an existing view. Returns
|
||||
// (nil, nil) if the row doesn't exist for this user.
|
||||
func (s *UserViewService) Update(ctx context.Context, userID, id uuid.UUID, input UpdateUserViewInput) (*UserView, error) {
|
||||
current, err := s.GetByID(ctx, userID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if current == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Coalesce input over current.
|
||||
slug := current.Slug
|
||||
if input.Slug != nil {
|
||||
slug = *input.Slug
|
||||
}
|
||||
if err := validateSlug(slug); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
name := current.Name
|
||||
if input.Name != nil {
|
||||
name = *input.Name
|
||||
}
|
||||
if err := validateName(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
icon := current.Icon
|
||||
if input.Icon != nil {
|
||||
s := *input.Icon
|
||||
if s == "" {
|
||||
icon = nil
|
||||
} else {
|
||||
icon = &s
|
||||
}
|
||||
}
|
||||
|
||||
filterJSON := []byte(current.FilterSpec)
|
||||
if input.FilterSpec != nil {
|
||||
b, err := MarshalFilterSpec(*input.FilterSpec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filterJSON = b
|
||||
}
|
||||
|
||||
renderJSON := []byte(current.RenderSpec)
|
||||
if input.RenderSpec != nil {
|
||||
b, err := MarshalRenderSpec(*input.RenderSpec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
renderJSON = b
|
||||
}
|
||||
|
||||
sortOrder := current.SortOrder
|
||||
if input.SortOrder != nil {
|
||||
sortOrder = *input.SortOrder
|
||||
}
|
||||
|
||||
showCount := current.ShowCount
|
||||
if input.ShowCount != nil {
|
||||
showCount = *input.ShowCount
|
||||
}
|
||||
|
||||
var v UserView
|
||||
err = s.db.GetContext(ctx, &v, `
|
||||
UPDATE paliad.user_views
|
||||
SET slug = $3, name = $4, icon = $5,
|
||||
filter_spec = $6, render_spec = $7,
|
||||
sort_order = $8, show_count = $9,
|
||||
updated_at = now()
|
||||
WHERE user_id = $1 AND id = $2
|
||||
RETURNING id, user_id, slug, name, icon, filter_spec, render_spec,
|
||||
sort_order, show_count, last_used_at, created_at, updated_at`,
|
||||
userID, id, slug, name, icon, filterJSON, renderJSON, sortOrder, showCount)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrUserViewSlugTaken, slug)
|
||||
}
|
||||
return nil, fmt.Errorf("update user_view: %w", err)
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// Delete removes a saved view. Single Yes/No modal on the frontend
|
||||
// (Q25 lock-in); no audit emit (these are personal working state).
|
||||
// Returns (false, nil) when the row didn't exist.
|
||||
func (s *UserViewService) Delete(ctx context.Context, userID, id uuid.UUID) (bool, error) {
|
||||
res, err := s.db.ExecContext(ctx, `
|
||||
DELETE FROM paliad.user_views
|
||||
WHERE user_id = $1 AND id = $2`, userID, id)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("delete user_view: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("delete user_view rows affected: %w", err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// Touch updates last_used_at to now. Fire-and-forget from the page
|
||||
// handler — no error surface to the user.
|
||||
func (s *UserViewService) Touch(ctx context.Context, userID, id uuid.UUID) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE paliad.user_views
|
||||
SET last_used_at = now()
|
||||
WHERE user_id = $1 AND id = $2`, userID, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("touch user_view: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MostRecent returns the caller's most-recently-used view, or nil if
|
||||
// the user has none / has never opened one. Used for the /views landing
|
||||
// (Q10 most-recently-used default).
|
||||
func (s *UserViewService) MostRecent(ctx context.Context, userID uuid.UUID) (*UserView, error) {
|
||||
var v UserView
|
||||
err := s.db.GetContext(ctx, &v, `
|
||||
SELECT id, user_id, slug, name, icon, filter_spec, render_spec,
|
||||
sort_order, show_count, last_used_at, created_at, updated_at
|
||||
FROM paliad.user_views
|
||||
WHERE user_id = $1
|
||||
AND last_used_at IS NOT NULL
|
||||
ORDER BY last_used_at DESC
|
||||
LIMIT 1`, userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("most-recent user_view: %w", err)
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Validators (slug + name + create input)
|
||||
// ============================================================================
|
||||
|
||||
func validateSlug(slug string) error {
|
||||
if slug == "" {
|
||||
return fmt.Errorf("%w: slug is required", ErrInvalidInput)
|
||||
}
|
||||
if !slugRE.MatchString(slug) {
|
||||
return fmt.Errorf("%w: slug must match ^[a-z0-9][a-z0-9-]{0,62}$ (got %q)", ErrInvalidInput, slug)
|
||||
}
|
||||
if IsReservedUserViewSlug(slug) {
|
||||
return fmt.Errorf("%w: slug %q is reserved", ErrInvalidInput, slug)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("%w: name is required", ErrInvalidInput)
|
||||
}
|
||||
// 1-character names are fine (some users may want 1-letter shortcuts).
|
||||
// 200 is the codebase-wide cap (matches Notes / Checklists).
|
||||
if len(name) > 200 {
|
||||
return fmt.Errorf("%w: name exceeds 200 characters", ErrInvalidInput)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCreateInput(input CreateUserViewInput) error {
|
||||
if err := validateSlug(input.Slug); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateName(input.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if input.Icon != nil && len(*input.Icon) > 64 {
|
||||
return fmt.Errorf("%w: icon key exceeds 64 characters", ErrInvalidInput)
|
||||
}
|
||||
if err := input.FilterSpec.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := input.RenderSpec.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isUniqueViolation is shared with event_type_service.go (defined there).
|
||||
324
internal/services/user_view_service_test.go
Normal file
324
internal/services/user_view_service_test.go
Normal file
@@ -0,0 +1,324 @@
|
||||
package services
|
||||
|
||||
// Live-DB tests for UserViewService. Skipped when TEST_DATABASE_URL is
|
||||
// unset, mirroring the rest of the live-DB test suite.
|
||||
|
||||
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 userViewTestEnv struct {
|
||||
t *testing.T
|
||||
pool *sqlx.DB
|
||||
svc *UserViewService
|
||||
userID uuid.UUID
|
||||
cleanup func()
|
||||
}
|
||||
|
||||
func setupUserViewTest(t *testing.T) *userViewTestEnv {
|
||||
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 (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', 'View Test User', 'munich', 'standard')
|
||||
ON CONFLICT (id) DO NOTHING`, userID); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
ctx := context.Background()
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.user_views WHERE user_id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||
pool.Close()
|
||||
}
|
||||
|
||||
return &userViewTestEnv{
|
||||
t: t,
|
||||
pool: pool,
|
||||
svc: NewUserViewService(pool),
|
||||
userID: userID,
|
||||
cleanup: cleanup,
|
||||
}
|
||||
}
|
||||
|
||||
func goodCreateInput(slug, name string) CreateUserViewInput {
|
||||
return CreateUserViewInput{
|
||||
Slug: slug,
|
||||
Name: name,
|
||||
FilterSpec: DefaultFilterSpec(),
|
||||
RenderSpec: DefaultRenderSpec(),
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserViewService_CreateAndList(t *testing.T) {
|
||||
env := setupUserViewTest(t)
|
||||
defer env.cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
created, err := env.svc.Create(ctx, env.userID, goodCreateInput("freitag-stand", "Freitag-Stand"))
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if created.Slug != "freitag-stand" || created.Name != "Freitag-Stand" {
|
||||
t.Errorf("created shape: %+v", created)
|
||||
}
|
||||
if created.SortOrder != 0 {
|
||||
t.Errorf("first view sort_order = %d, want 0", created.SortOrder)
|
||||
}
|
||||
|
||||
second, err := env.svc.Create(ctx, env.userID, goodCreateInput("siemens", "Siemens-Aktivität"))
|
||||
if err != nil {
|
||||
t.Fatalf("second create: %v", err)
|
||||
}
|
||||
if second.SortOrder != 1 {
|
||||
t.Errorf("second view sort_order = %d, want 1", second.SortOrder)
|
||||
}
|
||||
|
||||
list, err := env.svc.ListForUser(ctx, env.userID)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("expected 2 views, got %d", len(list))
|
||||
}
|
||||
// sort_order ASC ordering
|
||||
if list[0].Slug != "freitag-stand" || list[1].Slug != "siemens" {
|
||||
t.Errorf("ordering: %v", []string{list[0].Slug, list[1].Slug})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserViewService_GetBySlugAndID(t *testing.T) {
|
||||
env := setupUserViewTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
created, err := env.svc.Create(ctx, env.userID, goodCreateInput("test-view", "Test View"))
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
bySlug, err := env.svc.GetBySlug(ctx, env.userID, "test-view")
|
||||
if err != nil || bySlug == nil {
|
||||
t.Fatalf("GetBySlug: %v / nil", err)
|
||||
}
|
||||
if bySlug.ID != created.ID {
|
||||
t.Errorf("GetBySlug id mismatch")
|
||||
}
|
||||
|
||||
byID, err := env.svc.GetByID(ctx, env.userID, created.ID)
|
||||
if err != nil || byID == nil {
|
||||
t.Fatalf("GetByID: %v / nil", err)
|
||||
}
|
||||
|
||||
missing, err := env.svc.GetBySlug(ctx, env.userID, "does-not-exist")
|
||||
if err != nil {
|
||||
t.Fatalf("GetBySlug missing: %v", err)
|
||||
}
|
||||
if missing != nil {
|
||||
t.Error("missing slug should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserViewService_SlugUniquenessPerUser(t *testing.T) {
|
||||
env := setupUserViewTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := env.svc.Create(ctx, env.userID, goodCreateInput("dup", "First")); err != nil {
|
||||
t.Fatalf("first create: %v", err)
|
||||
}
|
||||
_, err := env.svc.Create(ctx, env.userID, goodCreateInput("dup", "Second"))
|
||||
if !errors.Is(err, ErrUserViewSlugTaken) {
|
||||
t.Fatalf("duplicate slug must return ErrUserViewSlugTaken, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserViewService_Update(t *testing.T) {
|
||||
env := setupUserViewTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
created, err := env.svc.Create(ctx, env.userID, goodCreateInput("orig", "Original"))
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
newName := "Updated"
|
||||
newShowCount := true
|
||||
updated, err := env.svc.Update(ctx, env.userID, created.ID, UpdateUserViewInput{
|
||||
Name: &newName,
|
||||
ShowCount: &newShowCount,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
if updated.Name != "Updated" {
|
||||
t.Errorf("name not updated: %s", updated.Name)
|
||||
}
|
||||
if !updated.ShowCount {
|
||||
t.Errorf("show_count not updated")
|
||||
}
|
||||
// Slug should be unchanged.
|
||||
if updated.Slug != "orig" {
|
||||
t.Errorf("slug should be unchanged, got %s", updated.Slug)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserViewService_UpdateRejectsReservedSlug(t *testing.T) {
|
||||
env := setupUserViewTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
created, err := env.svc.Create(ctx, env.userID, goodCreateInput("freely", "Freely"))
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
reserved := "dashboard"
|
||||
_, err = env.svc.Update(ctx, env.userID, created.ID, UpdateUserViewInput{Slug: &reserved})
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("update to reserved slug must reject, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserViewService_Delete(t *testing.T) {
|
||||
env := setupUserViewTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
created, err := env.svc.Create(ctx, env.userID, goodCreateInput("doomed", "Doomed"))
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
deleted, err := env.svc.Delete(ctx, env.userID, created.ID)
|
||||
if err != nil || !deleted {
|
||||
t.Fatalf("delete: %v, %v", deleted, err)
|
||||
}
|
||||
deletedAgain, err := env.svc.Delete(ctx, env.userID, created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("second delete: %v", err)
|
||||
}
|
||||
if deletedAgain {
|
||||
t.Error("second delete should report not-deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserViewService_TouchAndMostRecent(t *testing.T) {
|
||||
env := setupUserViewTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
a, _ := env.svc.Create(ctx, env.userID, goodCreateInput("a-view", "A"))
|
||||
b, _ := env.svc.Create(ctx, env.userID, goodCreateInput("b-view", "B"))
|
||||
|
||||
// Before any touch — MostRecent is nil.
|
||||
mr, err := env.svc.MostRecent(ctx, env.userID)
|
||||
if err != nil {
|
||||
t.Fatalf("most_recent: %v", err)
|
||||
}
|
||||
if mr != nil {
|
||||
t.Errorf("MostRecent should be nil before any touch")
|
||||
}
|
||||
|
||||
if err := env.svc.Touch(ctx, env.userID, a.ID); err != nil {
|
||||
t.Fatalf("touch a: %v", err)
|
||||
}
|
||||
if err := env.svc.Touch(ctx, env.userID, b.ID); err != nil {
|
||||
t.Fatalf("touch b: %v", err)
|
||||
}
|
||||
mr, err = env.svc.MostRecent(ctx, env.userID)
|
||||
if err != nil || mr == nil {
|
||||
t.Fatalf("most_recent after touch: %v / nil", err)
|
||||
}
|
||||
if mr.ID != b.ID {
|
||||
t.Errorf("most-recent should be b (touched last), got %s", mr.Slug)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserViewService_RejectsReservedSlugOnCreate(t *testing.T) {
|
||||
env := setupUserViewTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := env.svc.Create(ctx, env.userID, goodCreateInput("inbox", "Inbox copy"))
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("reserved slug on create must reject, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserViewService_RejectsBadSlug(t *testing.T) {
|
||||
env := setupUserViewTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := env.svc.Create(ctx, env.userID, goodCreateInput("Has Spaces", "Bad"))
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("slug with spaces must reject, got %v", err)
|
||||
}
|
||||
_, err = env.svc.Create(ctx, env.userID, goodCreateInput("UPPER", "Bad"))
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("uppercase slug must reject, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserViewService_RejectsEmptyName(t *testing.T) {
|
||||
env := setupUserViewTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := env.svc.Create(ctx, env.userID, CreateUserViewInput{
|
||||
Slug: "no-name",
|
||||
Name: "",
|
||||
FilterSpec: DefaultFilterSpec(),
|
||||
RenderSpec: DefaultRenderSpec(),
|
||||
})
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("empty name must reject, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserViewService_RejectsInvalidSpec(t *testing.T) {
|
||||
env := setupUserViewTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
bad := DefaultFilterSpec()
|
||||
bad.Sources = nil
|
||||
_, err := env.svc.Create(ctx, env.userID, CreateUserViewInput{
|
||||
Slug: "bad-spec",
|
||||
Name: "Bad",
|
||||
FilterSpec: bad,
|
||||
RenderSpec: DefaultRenderSpec(),
|
||||
})
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("invalid spec must reject, got %v", err)
|
||||
}
|
||||
}
|
||||
719
internal/services/view_service.go
Normal file
719
internal/services/view_service.go
Normal file
@@ -0,0 +1,719 @@
|
||||
package services
|
||||
|
||||
// ViewService extension on EventService — runs a FilterSpec across the
|
||||
// 4 substrate sources (deadline, appointment, project_event,
|
||||
// approval_request) and returns a unified []ViewRow.
|
||||
//
|
||||
// Design: docs/design-data-display-model-2026-05-06.md §3 + §6.3.
|
||||
//
|
||||
// EventService is extended (not renamed) so the existing handlers
|
||||
// (/api/events, /api/events/summary) keep working unchanged. New
|
||||
// handlers (/api/views/{slug}/run, /api/user-views/...) call RunSpec.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// ViewRow is the unified row shape returned by RunSpec. Discriminated by
|
||||
// `Kind`; type-specific fields live under `Detail` as a per-source struct
|
||||
// marshalled via json.RawMessage.
|
||||
type ViewRow struct {
|
||||
Kind DataSource `json:"kind"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
Title string `json:"title"`
|
||||
|
||||
// Subtitle: one short context line (e.g. "Frist", "Termin",
|
||||
// "Genehmigung von …"). Optional; UIs render it under the title.
|
||||
Subtitle *string `json:"subtitle,omitempty"`
|
||||
|
||||
// EventDate is the canonical sort key per row. Source-determined:
|
||||
// - deadline: due_date at 00:00 UTC
|
||||
// - appointment: start_at
|
||||
// - project_event: created_at
|
||||
// - approval_request: requested_at (or decided_at if status decided)
|
||||
EventDate time.Time `json:"event_date"`
|
||||
|
||||
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"`
|
||||
|
||||
ActorID *uuid.UUID `json:"actor_id,omitempty"`
|
||||
ActorName *string `json:"actor_name,omitempty"`
|
||||
|
||||
// Detail is the per-source typed payload as raw JSON. Frontend
|
||||
// type-narrows on Kind and parses Detail accordingly.
|
||||
Detail json.RawMessage `json:"detail"`
|
||||
}
|
||||
|
||||
// ViewRunResult is the response shape of RunSpec — rows + a count of
|
||||
// projects that contributed zero rows because the caller can't see them
|
||||
// (Q17 fail-open attribution).
|
||||
type ViewRunResult struct {
|
||||
Rows []ViewRow `json:"rows"`
|
||||
InaccessibleProjectIDs []uuid.UUID `json:"inaccessible_project_ids,omitempty"`
|
||||
}
|
||||
|
||||
// RunSpec executes the FilterSpec against the substrate and returns
|
||||
// merged rows sorted by EventDate (ascending for forward-looking,
|
||||
// descending if any sort hint says so). Visibility is enforced via
|
||||
// the per-source RLS predicates already used by the underlying tables;
|
||||
// `userID` is the caller for context propagation.
|
||||
//
|
||||
// Caller has run spec.Validate() before us. We trust the spec.
|
||||
func (s *EventService) RunSpec(ctx context.Context, userID uuid.UUID, spec FilterSpec, approval *ApprovalService) (*ViewRunResult, error) {
|
||||
if approval == nil && slices.Contains(spec.Sources, SourceApprovalRequest) {
|
||||
// Approval source requires the approval service. Return a clear
|
||||
// error rather than silently skipping it — handlers always pass
|
||||
// the bundle's approval service.
|
||||
return nil, fmt.Errorf("RunSpec: approval source selected but ApprovalService is nil")
|
||||
}
|
||||
|
||||
rows := make([]ViewRow, 0, 256)
|
||||
bounds := computeViewSpecBounds(time.Now().UTC(), spec.Time)
|
||||
|
||||
for _, src := range spec.Sources {
|
||||
switch src {
|
||||
case SourceDeadline:
|
||||
batch, err := s.runDeadlines(ctx, userID, spec, bounds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows = append(rows, batch...)
|
||||
|
||||
case SourceAppointment:
|
||||
batch, err := s.runAppointments(ctx, userID, spec, bounds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows = append(rows, batch...)
|
||||
|
||||
case SourceProjectEvent:
|
||||
batch, err := s.runProjectEvents(ctx, userID, spec, bounds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows = append(rows, batch...)
|
||||
|
||||
case SourceApprovalRequest:
|
||||
batch, err := s.runApprovalRequests(ctx, userID, spec, approval, bounds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows = append(rows, batch...)
|
||||
}
|
||||
}
|
||||
|
||||
// Default sort: ascending. Per-source sort hints don't apply here —
|
||||
// Render-side sort (RenderSpec.List/Cards.Sort) is the user-facing
|
||||
// knob. We give the substrate a stable shape; the renderer flips it.
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
if rows[i].EventDate.Equal(rows[j].EventDate) {
|
||||
// Tiebreaker: kind alphabetical, then title — deterministic.
|
||||
if rows[i].Kind != rows[j].Kind {
|
||||
return rows[i].Kind < rows[j].Kind
|
||||
}
|
||||
return rows[i].Title < rows[j].Title
|
||||
}
|
||||
return rows[i].EventDate.Before(rows[j].EventDate)
|
||||
})
|
||||
|
||||
out := &ViewRunResult{Rows: rows}
|
||||
// Q17 fail-open attribution: if the caller specified explicit
|
||||
// project IDs, surface the ones they couldn't see. We do that with
|
||||
// one cheap check against can_see_project (via RLS-aware visibility
|
||||
// predicate), batched per call.
|
||||
if spec.Scope.Projects.Mode == ScopeExplicit {
|
||||
inaccessible, err := s.filterInaccessibleProjects(ctx, userID, spec.Scope.Projects.IDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(inaccessible) > 0 {
|
||||
out.InaccessibleProjectIDs = inaccessible
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// viewSpecBounds carries the resolved [from, to) window the spec
|
||||
// translates into. Either bound can be nil (open-ended).
|
||||
type viewSpecBounds struct {
|
||||
from *time.Time
|
||||
to *time.Time
|
||||
}
|
||||
|
||||
func computeViewSpecBounds(now time.Time, ts TimeSpec) viewSpecBounds {
|
||||
now = now.UTC()
|
||||
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
switch ts.Horizon {
|
||||
case HorizonNext7d:
|
||||
from := day
|
||||
to := day.AddDate(0, 0, 7)
|
||||
return viewSpecBounds{from: &from, to: &to}
|
||||
case HorizonNext30d:
|
||||
from := day
|
||||
to := day.AddDate(0, 0, 30)
|
||||
return viewSpecBounds{from: &from, to: &to}
|
||||
case HorizonNext90d:
|
||||
from := day
|
||||
to := day.AddDate(0, 0, 90)
|
||||
return viewSpecBounds{from: &from, to: &to}
|
||||
case HorizonPast30d:
|
||||
from := day.AddDate(0, 0, -30)
|
||||
to := day.AddDate(0, 0, 1)
|
||||
return viewSpecBounds{from: &from, to: &to}
|
||||
case HorizonPast90d:
|
||||
from := day.AddDate(0, 0, -90)
|
||||
to := day.AddDate(0, 0, 1)
|
||||
return viewSpecBounds{from: &from, to: &to}
|
||||
case HorizonAny, HorizonAll:
|
||||
return viewSpecBounds{}
|
||||
case HorizonCustom:
|
||||
return viewSpecBounds{from: ts.From, to: ts.To}
|
||||
}
|
||||
return viewSpecBounds{}
|
||||
}
|
||||
|
||||
// runDeadlines projects DeadlineWithProject rows from the existing
|
||||
// DeadlineService.ListVisibleForUser onto ViewRow, applying spec narrowing.
|
||||
func (s *EventService) runDeadlines(ctx context.Context, userID uuid.UUID, spec FilterSpec, bounds viewSpecBounds) ([]ViewRow, error) {
|
||||
df := ListFilter{}
|
||||
if spec.Scope.PersonalOnly {
|
||||
uid := userID
|
||||
df.CreatedBy = &uid
|
||||
}
|
||||
if preds, ok := spec.Predicates[SourceDeadline]; ok && preds.Deadline != nil {
|
||||
dp := preds.Deadline
|
||||
// Status: ListFilter has DeadlineStatusFilter (single-value filter).
|
||||
// If the spec asks for both pending+completed → no narrowing; if
|
||||
// only pending → DeadlineFilterPending; only completed → Completed.
|
||||
switch {
|
||||
case len(dp.Status) == 1 && dp.Status[0] == "pending":
|
||||
df.Status = DeadlineFilterPending
|
||||
case len(dp.Status) == 1 && dp.Status[0] == "completed":
|
||||
df.Status = DeadlineFilterCompleted
|
||||
default:
|
||||
df.Status = DeadlineFilterAll
|
||||
}
|
||||
df.EventTypeIDs = dp.EventTypeIDs
|
||||
df.IncludeUntyped = dp.IncludeUntyped
|
||||
}
|
||||
if spec.Scope.Projects.Mode == ScopeExplicit && len(spec.Scope.Projects.IDs) > 0 {
|
||||
// DeadlineService takes one project id; we filter post-load when
|
||||
// spec selects multiple projects (the visibility predicate already
|
||||
// bounds to the caller's set, and explicit IDs are a refinement).
|
||||
}
|
||||
|
||||
rows, err := s.deadlines.ListVisibleForUser(ctx, userID, df)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]ViewRow, 0, len(rows))
|
||||
allowedProjects := explicitProjectSet(spec)
|
||||
|
||||
for _, r := range rows {
|
||||
eventDate := time.Date(r.DueDate.Year(), r.DueDate.Month(), r.DueDate.Day(), 0, 0, 0, 0, time.UTC)
|
||||
if !inSpecWindow(eventDate, bounds) {
|
||||
continue
|
||||
}
|
||||
if allowedProjects != nil && !allowedProjects[r.ProjectID] {
|
||||
continue
|
||||
}
|
||||
// Approval-status narrowing (entity-side pill).
|
||||
if !approvalStatusMatches(r.ApprovalStatus, spec, SourceDeadline) {
|
||||
continue
|
||||
}
|
||||
|
||||
detail, _ := json.Marshal(map[string]any{
|
||||
"due_date": r.DueDate.Format("2006-01-02"),
|
||||
"status": r.Status,
|
||||
"approval_status": r.ApprovalStatus,
|
||||
"source": r.Source,
|
||||
"rule_id": r.RuleID,
|
||||
"rule_code": r.RuleCode,
|
||||
"rule_name": r.RuleName,
|
||||
"event_type_ids": r.EventTypeIDs,
|
||||
"description": r.Description,
|
||||
"completed_at": r.CompletedAt,
|
||||
})
|
||||
pid := r.ProjectID
|
||||
pt := r.ProjectTitle
|
||||
ptype := r.ProjectType
|
||||
out = append(out, ViewRow{
|
||||
Kind: SourceDeadline,
|
||||
ID: r.ID,
|
||||
Title: r.Title,
|
||||
EventDate: eventDate,
|
||||
ProjectID: &pid,
|
||||
ProjectTitle: &pt,
|
||||
ProjectReference: r.ProjectReference,
|
||||
ProjectType: &ptype,
|
||||
ActorID: r.CreatedBy,
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// runAppointments projects AppointmentWithProject onto ViewRow.
|
||||
func (s *EventService) runAppointments(ctx context.Context, userID uuid.UUID, spec FilterSpec, bounds viewSpecBounds) ([]ViewRow, error) {
|
||||
af := AppointmentListFilter{}
|
||||
if spec.Scope.PersonalOnly {
|
||||
uid := userID
|
||||
af.CreatedBy = &uid
|
||||
}
|
||||
af.From = bounds.from
|
||||
af.To = bounds.to
|
||||
if preds, ok := spec.Predicates[SourceAppointment]; ok && preds.Appointment != nil {
|
||||
ap := preds.Appointment
|
||||
// AppointmentListFilter takes a single Type today; narrow to first
|
||||
// listed value, fall back to all if multiple.
|
||||
if len(ap.AppointmentTypes) == 1 {
|
||||
t := ap.AppointmentTypes[0]
|
||||
af.Type = &t
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := s.appointments.ListVisibleForUser(ctx, userID, af)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]ViewRow, 0, len(rows))
|
||||
allowedProjects := explicitProjectSet(spec)
|
||||
allowedTypes := allowedAppointmentTypes(spec)
|
||||
|
||||
for _, r := range rows {
|
||||
if !inSpecWindow(r.StartAt, bounds) {
|
||||
continue
|
||||
}
|
||||
if r.ProjectID != nil && allowedProjects != nil && !allowedProjects[*r.ProjectID] {
|
||||
continue
|
||||
}
|
||||
if r.ProjectID == nil && allowedProjects != nil {
|
||||
continue
|
||||
}
|
||||
if !approvalStatusMatches(r.ApprovalStatus, spec, SourceAppointment) {
|
||||
continue
|
||||
}
|
||||
if allowedTypes != nil {
|
||||
if r.AppointmentType == nil || !allowedTypes[*r.AppointmentType] {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
detail, _ := json.Marshal(map[string]any{
|
||||
"start_at": r.StartAt,
|
||||
"end_at": r.EndAt,
|
||||
"location": r.Location,
|
||||
"appointment_type": r.AppointmentType,
|
||||
"approval_status": r.ApprovalStatus,
|
||||
"description": r.Description,
|
||||
})
|
||||
out = append(out, ViewRow{
|
||||
Kind: SourceAppointment,
|
||||
ID: r.ID,
|
||||
Title: r.Title,
|
||||
EventDate: r.StartAt,
|
||||
ProjectID: r.ProjectID,
|
||||
ProjectTitle: r.ProjectTitle,
|
||||
ProjectReference: r.ProjectReference,
|
||||
ProjectType: r.ProjectType,
|
||||
ActorID: r.CreatedBy,
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// runProjectEvents queries paliad.project_events with the visibility
|
||||
// predicate. The audit table doesn't have a service wrapper today; we
|
||||
// run our own SQL bounded by the spec.
|
||||
func (s *EventService) runProjectEvents(ctx context.Context, userID uuid.UUID, spec FilterSpec, bounds viewSpecBounds) ([]ViewRow, error) {
|
||||
conds := []string{visibilityPredicatePositional("p", 1)}
|
||||
args := []any{userID}
|
||||
|
||||
allowedKinds := allowedProjectEventKinds(spec)
|
||||
if len(allowedKinds) > 0 {
|
||||
args = append(args, pq.Array(allowedKinds))
|
||||
conds = append(conds, fmt.Sprintf("pe.event_type = ANY($%d)", len(args)))
|
||||
}
|
||||
if bounds.from != nil {
|
||||
args = append(args, *bounds.from)
|
||||
conds = append(conds, fmt.Sprintf("pe.created_at >= $%d", len(args)))
|
||||
}
|
||||
if bounds.to != nil {
|
||||
args = append(args, *bounds.to)
|
||||
conds = append(conds, fmt.Sprintf("pe.created_at < $%d", len(args)))
|
||||
}
|
||||
if spec.Scope.Projects.Mode == ScopeExplicit && len(spec.Scope.Projects.IDs) > 0 {
|
||||
args = append(args, spec.Scope.Projects.IDs)
|
||||
conds = append(conds, fmt.Sprintf("pe.project_id = ANY($%d)", len(args)))
|
||||
}
|
||||
|
||||
q := `
|
||||
SELECT pe.id, pe.project_id, pe.event_type, pe.title, pe.description,
|
||||
pe.event_date, pe.created_by, pe.created_at,
|
||||
p.title AS project_title,
|
||||
p.type AS project_type,
|
||||
p.reference AS project_reference,
|
||||
u.display_name AS actor_name
|
||||
FROM paliad.project_events pe
|
||||
JOIN paliad.projects p ON p.id = pe.project_id
|
||||
LEFT JOIN paliad.users u ON u.id = pe.created_by
|
||||
WHERE ` + strings.Join(conds, " AND ") + `
|
||||
ORDER BY pe.created_at DESC
|
||||
LIMIT 500`
|
||||
|
||||
type row struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
ProjectID uuid.UUID `db:"project_id"`
|
||||
EventType *string `db:"event_type"`
|
||||
Title string `db:"title"`
|
||||
Description *string `db:"description"`
|
||||
EventDate *time.Time `db:"event_date"`
|
||||
CreatedBy *uuid.UUID `db:"created_by"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
ProjectTitle string `db:"project_title"`
|
||||
ProjectType string `db:"project_type"`
|
||||
ProjectReference *string `db:"project_reference"`
|
||||
ActorName *string `db:"actor_name"`
|
||||
}
|
||||
|
||||
var dbRows []row
|
||||
if err := s.db.SelectContext(ctx, &dbRows, q, args...); err != nil {
|
||||
return nil, fmt.Errorf("project_events query: %w", err)
|
||||
}
|
||||
|
||||
out := make([]ViewRow, 0, len(dbRows))
|
||||
for _, r := range dbRows {
|
||||
detail, _ := json.Marshal(map[string]any{
|
||||
"event_type": r.EventType,
|
||||
"description": r.Description,
|
||||
"event_date": r.EventDate,
|
||||
})
|
||||
pid := r.ProjectID
|
||||
pt := r.ProjectTitle
|
||||
ptype := r.ProjectType
|
||||
out = append(out, ViewRow{
|
||||
Kind: SourceProjectEvent,
|
||||
ID: r.ID,
|
||||
Title: r.Title,
|
||||
EventDate: r.CreatedAt,
|
||||
ProjectID: &pid,
|
||||
ProjectTitle: &pt,
|
||||
ProjectReference: r.ProjectReference,
|
||||
ProjectType: &ptype,
|
||||
ActorID: r.CreatedBy,
|
||||
ActorName: r.ActorName,
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// runApprovalRequests projects approval_request rows via the existing
|
||||
// ApprovalService inbox queries. ViewerRole picks which underlying
|
||||
// query runs.
|
||||
func (s *EventService) runApprovalRequests(ctx context.Context, userID uuid.UUID, spec FilterSpec, approval *ApprovalService, bounds viewSpecBounds) ([]ViewRow, error) {
|
||||
preds := spec.Predicates[SourceApprovalRequest]
|
||||
role := "approver_eligible"
|
||||
if preds.ApprovalRequest != nil && preds.ApprovalRequest.ViewerRole != "" {
|
||||
role = preds.ApprovalRequest.ViewerRole
|
||||
}
|
||||
|
||||
filter := InboxFilter{}
|
||||
if preds.ApprovalRequest != nil {
|
||||
// InboxFilter takes a single status today. If the spec says
|
||||
// only one, narrow; if multiple, leave open.
|
||||
if len(preds.ApprovalRequest.Status) == 1 {
|
||||
filter.Status = preds.ApprovalRequest.Status[0]
|
||||
}
|
||||
if len(preds.ApprovalRequest.EntityTypes) == 1 {
|
||||
filter.EntityType = preds.ApprovalRequest.EntityTypes[0]
|
||||
}
|
||||
}
|
||||
if spec.Scope.Projects.Mode == ScopeExplicit && len(spec.Scope.Projects.IDs) == 1 {
|
||||
pid := spec.Scope.Projects.IDs[0]
|
||||
filter.ProjectID = &pid
|
||||
}
|
||||
|
||||
var rows []ApprovalRequestView
|
||||
var err error
|
||||
switch role {
|
||||
case "approver_eligible":
|
||||
rows, err = approval.ListPendingForApprover(ctx, userID, filter)
|
||||
case "self_requested":
|
||||
rows, err = approval.ListSubmittedByUser(ctx, userID, filter)
|
||||
case "any_visible":
|
||||
// any_visible is the broadest read — RLS bounds it. The existing
|
||||
// ApprovalService doesn't have a "list all visible" call; we
|
||||
// approximate by running both inbox queries and de-duping. Future
|
||||
// optimization: dedicated service method.
|
||||
a, errA := approval.ListPendingForApprover(ctx, userID, filter)
|
||||
if errA != nil {
|
||||
return nil, errA
|
||||
}
|
||||
b, errB := approval.ListSubmittedByUser(ctx, userID, filter)
|
||||
if errB != nil {
|
||||
return nil, errB
|
||||
}
|
||||
seen := make(map[uuid.UUID]bool, len(a)+len(b))
|
||||
for _, r := range a {
|
||||
if !seen[r.ID] {
|
||||
rows = append(rows, r)
|
||||
seen[r.ID] = true
|
||||
}
|
||||
}
|
||||
for _, r := range b {
|
||||
if !seen[r.ID] {
|
||||
rows = append(rows, r)
|
||||
seen[r.ID] = true
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: approval_request.viewer_role %q", ErrInvalidInput, role)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]ViewRow, 0, len(rows))
|
||||
allowedStatuses := allowedRequestStatuses(spec)
|
||||
allowedEntityTypes := allowedRequestEntityTypes(spec)
|
||||
allowedProjects := explicitProjectSet(spec)
|
||||
|
||||
for _, r := range rows {
|
||||
// Spec status filter (when the inbox query received broad results).
|
||||
if allowedStatuses != nil && !allowedStatuses[r.Status] {
|
||||
continue
|
||||
}
|
||||
if allowedEntityTypes != nil && !allowedEntityTypes[r.EntityType] {
|
||||
continue
|
||||
}
|
||||
if allowedProjects != nil && !allowedProjects[r.ProjectID] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Sort key: decided_at if decided, else requested_at.
|
||||
eventDate := r.RequestedAt
|
||||
if r.DecidedAt != nil {
|
||||
eventDate = *r.DecidedAt
|
||||
}
|
||||
if !inSpecWindow(eventDate, bounds) {
|
||||
continue
|
||||
}
|
||||
|
||||
title := approvalRowTitle(r)
|
||||
subtitle := approvalRowSubtitle(r)
|
||||
detail, _ := json.Marshal(r) // request view already carries everything the UI needs
|
||||
actorID := r.RequestedBy
|
||||
actorName := r.RequesterName
|
||||
pid := r.ProjectID
|
||||
pt := r.ProjectTitle
|
||||
out = append(out, ViewRow{
|
||||
Kind: SourceApprovalRequest,
|
||||
ID: r.ID,
|
||||
Title: title,
|
||||
Subtitle: &subtitle,
|
||||
EventDate: eventDate,
|
||||
ProjectID: &pid,
|
||||
ProjectTitle: &pt,
|
||||
ActorID: &actorID,
|
||||
ActorName: &actorName,
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// approvalRowTitle returns a one-line title describing the approval
|
||||
// request — used as the ViewRow.Title.
|
||||
func approvalRowTitle(r ApprovalRequestView) string {
|
||||
if r.EntityTitle != nil && *r.EntityTitle != "" {
|
||||
return *r.EntityTitle
|
||||
}
|
||||
return fmt.Sprintf("%s %s", r.EntityType, r.LifecycleEvent)
|
||||
}
|
||||
|
||||
// approvalRowSubtitle returns a one-line context for the request.
|
||||
func approvalRowSubtitle(r ApprovalRequestView) string {
|
||||
switch r.Status {
|
||||
case "pending":
|
||||
return fmt.Sprintf("Genehmigung angefragt von %s", r.RequesterName)
|
||||
case "approved":
|
||||
if r.DeciderName != nil {
|
||||
return fmt.Sprintf("Genehmigt von %s", *r.DeciderName)
|
||||
}
|
||||
return "Genehmigt"
|
||||
case "rejected":
|
||||
if r.DeciderName != nil {
|
||||
return fmt.Sprintf("Abgelehnt von %s", *r.DeciderName)
|
||||
}
|
||||
return "Abgelehnt"
|
||||
case "revoked":
|
||||
return "Widerrufen"
|
||||
}
|
||||
return r.Status
|
||||
}
|
||||
|
||||
// inSpecWindow returns true when ts is within [from, to). nil bounds
|
||||
// are open-ended.
|
||||
func inSpecWindow(ts time.Time, b viewSpecBounds) bool {
|
||||
if b.from != nil && ts.Before(*b.from) {
|
||||
return false
|
||||
}
|
||||
if b.to != nil && !ts.Before(*b.to) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// explicitProjectSet returns nil when the scope isn't explicit, otherwise
|
||||
// a set membership map for fast filtering.
|
||||
func explicitProjectSet(spec FilterSpec) map[uuid.UUID]bool {
|
||||
if spec.Scope.Projects.Mode != ScopeExplicit {
|
||||
return nil
|
||||
}
|
||||
out := make(map[uuid.UUID]bool, len(spec.Scope.Projects.IDs))
|
||||
for _, id := range spec.Scope.Projects.IDs {
|
||||
out[id] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// approvalStatusMatches checks the entity-side approval_status filter.
|
||||
// Returns true when the row passes (no filter set → always true).
|
||||
func approvalStatusMatches(rowStatus string, spec FilterSpec, src DataSource) bool {
|
||||
preds, ok := spec.Predicates[src]
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
var allowed []string
|
||||
switch src {
|
||||
case SourceDeadline:
|
||||
if preds.Deadline != nil {
|
||||
allowed = preds.Deadline.ApprovalStatus
|
||||
}
|
||||
case SourceAppointment:
|
||||
if preds.Appointment != nil {
|
||||
allowed = preds.Appointment.ApprovalStatus
|
||||
}
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
return true
|
||||
}
|
||||
return slices.Contains(allowed, rowStatus)
|
||||
}
|
||||
|
||||
// allowedAppointmentTypes returns nil when the filter is open, otherwise
|
||||
// a set of legal appointment_type values.
|
||||
func allowedAppointmentTypes(spec FilterSpec) map[string]bool {
|
||||
preds, ok := spec.Predicates[SourceAppointment]
|
||||
if !ok || preds.Appointment == nil {
|
||||
return nil
|
||||
}
|
||||
if len(preds.Appointment.AppointmentTypes) <= 1 {
|
||||
return nil // single-value already pushed down via AppointmentListFilter.Type
|
||||
}
|
||||
out := make(map[string]bool, len(preds.Appointment.AppointmentTypes))
|
||||
for _, t := range preds.Appointment.AppointmentTypes {
|
||||
out[t] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// allowedProjectEventKinds returns the slice of project_event.event_type
|
||||
// values the spec narrows to, or nil for "all known kinds".
|
||||
func allowedProjectEventKinds(spec FilterSpec) []string {
|
||||
preds, ok := spec.Predicates[SourceProjectEvent]
|
||||
if !ok || preds.ProjectEvent == nil {
|
||||
return nil
|
||||
}
|
||||
if len(preds.ProjectEvent.EventTypes) == 0 {
|
||||
return nil
|
||||
}
|
||||
return preds.ProjectEvent.EventTypes
|
||||
}
|
||||
|
||||
// allowedRequestStatuses returns nil for "no narrowing" (or "single value
|
||||
// already pushed into InboxFilter.Status").
|
||||
func allowedRequestStatuses(spec FilterSpec) map[string]bool {
|
||||
preds, ok := spec.Predicates[SourceApprovalRequest]
|
||||
if !ok || preds.ApprovalRequest == nil {
|
||||
return nil
|
||||
}
|
||||
if len(preds.ApprovalRequest.Status) <= 1 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]bool, len(preds.ApprovalRequest.Status))
|
||||
for _, s := range preds.ApprovalRequest.Status {
|
||||
out[s] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func allowedRequestEntityTypes(spec FilterSpec) map[string]bool {
|
||||
preds, ok := spec.Predicates[SourceApprovalRequest]
|
||||
if !ok || preds.ApprovalRequest == nil {
|
||||
return nil
|
||||
}
|
||||
if len(preds.ApprovalRequest.EntityTypes) <= 1 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]bool, len(preds.ApprovalRequest.EntityTypes))
|
||||
for _, t := range preds.ApprovalRequest.EntityTypes {
|
||||
out[t] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// filterInaccessibleProjects returns the subset of `requested` that the
|
||||
// caller cannot see. Implementation: SELECT id FROM paliad.projects
|
||||
// WHERE id = ANY(...) (RLS filters the visible ones); the missing ones
|
||||
// are inaccessible. One DB hit per RunSpec when scope is explicit.
|
||||
func (s *EventService) filterInaccessibleProjects(ctx context.Context, userID uuid.UUID, requested []uuid.UUID) ([]uuid.UUID, error) {
|
||||
if len(requested) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
q := `SELECT p.id
|
||||
FROM paliad.projects p
|
||||
WHERE p.id = ANY($1)
|
||||
AND ` + visibilityPredicatePositional("p", 2)
|
||||
var visible []uuid.UUID
|
||||
if err := s.db.SelectContext(ctx, &visible, q, requested, userID); err != nil {
|
||||
return nil, fmt.Errorf("filter inaccessible projects: %w", err)
|
||||
}
|
||||
visibleSet := make(map[uuid.UUID]bool, len(visible))
|
||||
for _, id := range visible {
|
||||
visibleSet[id] = true
|
||||
}
|
||||
out := make([]uuid.UUID, 0)
|
||||
for _, id := range requested {
|
||||
if !visibleSet[id] {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Compile-time guards: the substrate's source loaders read fields off
|
||||
// known model shapes. If a model rename breaks this, the build fails
|
||||
// here rather than at runtime in production.
|
||||
var (
|
||||
_ = models.DeadlineWithProject{}
|
||||
_ = models.AppointmentWithProject{}
|
||||
_ = models.ProjectEvent{}
|
||||
)
|
||||
Reference in New Issue
Block a user