Compare commits

...

3 Commits

Author SHA1 Message Date
mAi
bcfde73815 feat(inbox): t-paliad-249 Slice A frontend — inbox dispatch + UI axes (m/paliad#80)
The /inbox surface drops "Genehmigungen" framing in favour of "Inbox"
and renders the unified feed.

- shape-list.ts: factor renderApprovalRow out of renderApprovalList so
  it can be reused alongside renderProjectEventInboxRow inside the new
  renderInboxList (row_action="inbox"). Project_event rows show a
  compact stream layout with an Öffnen link pointing at the right
  project tab (deadlines / appointments / notes).
- filter-bar gets two new axes: unread_only (binary chip cluster) +
  inbox_focus (4-chip coarse cluster: Alles / Genehmigungen / +Termine
  / +Fristen). Both round-trip via url-codec; inbox_focus translates
  to (sources, project_event.event_types, approval_request.entity_types)
  at the bar's resolve step (applyInboxFocusOverlay).
- FilterSpec gains a top-level unread_only flag; the bar writes it
  when the user toggles the chip; the server overlays the cursor.
- /inbox header: new "Alles als gelesen markieren" button POSTs
  /api/inbox/mark-all-seen with up_to=<newest visible row> for
  race-safety against a second tab.
- INBOX_AXES adds project + project_event_kind as advanced override
  chips so power users can still narrow per kind.
- i18n: inbox.title.feed / inbox.heading.feed / inbox.action.mark_all_seen
  / inbox.action.open / inbox.empty.feed / views.bar.unread_only.*  /
  views.bar.inbox_focus.* (DE + EN).
- url-codec round-trip tests for the two new axes.
2026-05-25 15:49:54 +02:00
mAi
4ead2d08c1 feat(inbox): t-paliad-249 Slice A backend — project_event feed + read cursor (m/paliad#80)
Substrate changes that turn /inbox from approvals-only into the
unified notification surface m asked for.

- Migration 126: paliad.users.inbox_seen_at (high-watermark read cursor;
  pending approval_requests bypass it per design §3).
- KnownProjectEventKinds gains note_created, our_side_changed,
  deadline_updated/deleted, deadlines_imported. New
  InboxProjectEventKinds curated subset (head's Q1=A lock).
- InboxSystemView spans [approval_request, project_event]; defaults to
  past 30 days, newest first, row_action="inbox".
- view_service.allowedProjectEventKinds drops *_approval_* audits when
  ApprovalRequest is also in spec.Sources (no double-count).
- RunSpec resolves the caller's inbox_seen_at once and threads it
  through viewSpecBounds; runProjectEvents excludes self-authored
  events and rows older than the cursor when unread_only is set.
  Decided approval_requests follow the cursor; pending always survives.
- ApprovalService.UnseenInboxCountForUser (unified badge count) +
  MarkInboxSeen + InboxSeenAt service methods.
- GET /api/inbox/count returns the unified count; new
  POST /api/inbox/mark-all-seen advances the cursor (optional up_to=).

Tests cover the InboxSystemView shape, the audit-dedup helper, the
isApprovalAuditKind matcher, and the no-narrow-no-approvals nil path.
2026-05-25 15:49:39 +02:00
mAi
2683c5f9cf docs(inbox): t-paliad-249 — inbox overhaul inventor design (m/paliad#80)
LOCKED design with head decisions (Q1=A) folded in §12. Slice plan
A/B/C reuses existing FilterSpec + RunSpec engine; no new aggregation
service. Slice A adds inbox_seen_at cursor + project_event source on
InboxSystemView + RowActionInbox dispatch in shape-list; Slice B adds
shape toggle (list/cards/calendar) + member_role_changed narrowing;
Slice C upgrades the badge + per-item dismiss.
2026-05-25 15:33:36 +02:00
23 changed files with 1875 additions and 157 deletions

View File

@@ -0,0 +1,848 @@
# Design: /inbox overhaul — project-events feed + filtering + list/cards/calendar toggles
**Task:** t-paliad-249
**Gitea:** m/paliad#80
**Author:** icarus (inventor)
**Date:** 2026-05-25
**Status:** LOCKED — head confirmed Q1=A with two refinements (2026-05-25), see §12.
**Branch:** `mai/icarus/inventor-inbox-overhaul`
---
## 0. TL;DR
`/inbox` today is approval-requests only. m wants it to become the actual
"what's new on my projects" surface — approval requests **plus** recent
project_events on visible projects — with the same view-toggle paradigm
as `/events` (list / cards / calendar) and a meaningful filter row.
The good news: the substrate already exists.
- `view_service.RunSpec` unions four sources (deadline, appointment,
**project_event**, **approval_request**) into one ranked `[]ViewRow`.
- `FilterSpec` has predicates for every axis we need
(`ProjectEventPredicates.EventTypes`, `ApprovalRequestPredicates`).
- `filter-bar` knows the axes we need: `time`, `project`,
`approval_viewer_role`, `approval_status`, `approval_entity_type`,
`project_event_kind`, plus `shape` / `sort` / `density`.
- Shape renderers exist: `shape-list` (table + compact + approval), `shape-cards`
(day-grouped), `shape-calendar` (thin adapter on `mountCalendar`).
So the work is **mostly re-mix**:
1. Extend `InboxSystemView` from `Sources=[ApprovalRequest]` to
`Sources=[ApprovalRequest, ProjectEvent]`, default
`Time.Horizon=Past30d`, and add a curated `project_event.event_types`
default that filters out noise (approvals duplicate-suppression,
checklist mutations, status churn).
2. Extend `shape-list.ts` so `row_action="approve"` no longer assumes
every row is an approval — rename it `"inbox"`, dispatch per
`row.kind` (approval → existing approve-card layout; project_event →
navigate-style stream row).
3. Wire the existing view-axis selector (the chip cluster on `/events`)
onto `/inbox`'s host, persisting selection via the filter-bar URL
codec (axis `shape` already in `AxisKey`).
4. Add a high-watermark read cursor (`paliad.users.inbox_seen_at`) +
`POST /api/inbox/mark-all-seen` + extend `/api/inbox/count` to count
unseen project_events too. Adds one new axis `unread_only` to the bar.
That's Slice A. Slice B layers cards + calendar toggles cleanly. Slice C
is per-item dismissal — keep out of v1 unless the cursor proves not
enough (m's pick Q3 is the cursor).
No new aggregation service, no new endpoint family — the inbox runs on
`/api/views/inbox/run` like every other system view does today.
---
## 1. Current `/inbox` state
**Routes (`internal/handlers/approvals.go`):**
| Path | Behaviour |
|---------------------------------------|--------------------------------------------------------------|
| `GET /inbox` | Serves `dist/inbox.html`, a thin shell. No SSR data. |
| `GET /api/inbox/pending-mine` | Approval requests I can approve. |
| `GET /api/inbox/mine` | Approval requests I submitted (all statuses by default). |
| `GET /api/inbox/count` | `{count: N}` for the sidebar bell badge — `PendingCountForUser`. |
| `GET /api/approval-requests/{id}` | Hydrate one request (used by suggest-changes modal). |
| `POST /api/approval-requests/{id}/{action}` | `approve` / `reject` / `revoke` / `suggest-changes`. |
**Data path:** `frontend/src/client/inbox.ts` mounts the universal
`FilterBar` over the inbox `SystemView` (slug `"inbox"`, sources
`[approval_request]`, viewer_role `any_visible`, status `[pending]`).
The bar fetches `/api/views/system`, hands the spec to itself, calls
`/api/views/inbox/run?…`, and stamps rows via `shape-list.ts`'s
`renderApprovalList(rows)` path (gated by `row_action="approve"`).
**Action wiring:** `wireApprovalActions(host)` listens on
`.views-approval-action` clicks; on success it triggers
`bar.refresh()` and `refreshInboxBadge()` (which pokes
`/api/inbox/count`).
**Empty state + admin nudge:** when the result list is empty AND the
caller is `global_admin` AND no `approval_policies` row exists firm-wide,
the page shows a "configure policies" CTA. Otherwise the localized
"no items" empty-state text.
**Sidebar bell:** `Sidebar.tsx:143` `navItem("/inbox", BELL_ICON, …)`
plus `client/sidebar.ts:320345`'s `initInboxBadge` which polls
`/api/inbox/count` every 60s. Badge clamps to `"9+"`.
### What aggregates cleanly
The whole approval flow already plugs into `RunSpec`'s union pipeline.
That's the win — extending sources from `[ApprovalRequest]` to
`[ApprovalRequest, ProjectEvent]` is a `[]DataSource` literal edit in
`InboxSystemView()` and the engine fans out per source, sorts, returns
one `[]ViewRow`. The hard work (`runProjectEvents` + the
visibility predicate + project metadata join) is already in
`view_service.go:344430`.
### What doesn't aggregate (yet)
- **Read state.** There is no `inbox_seen_at` on `paliad.users` (verified
via information_schema). The bell badge counts pending **approval
requests for the caller** only — it has no notion of "new project
events since last visit". We have to add it.
- **Mixed `row_action`.** `shape-list.ts`'s `renderApprovalList` assumes
every row is an approval and unconditionally parses
`row.detail` as an `ApprovalDetail`. Project_event rows in the same
list would crash the parse. We need to branch per `row.kind` inside
the inbox row stamper.
- **`/inbox` shape toggle.** `client/inbox.ts` hardcodes `shape-list`;
the `shape` axis is wired into `filter-bar/axes.ts` but `/inbox`'s
`INBOX_AXES` deliberately omits it (because today the only meaningful
shape was list). Adding it onto INBOX_AXES + a small dispatcher in
`onResult` gives us cards + calendar for free.
Everything else (sidebar entry, /api/views machinery, FilterBar URL
codec, RowAction validation) carries through unchanged.
---
## 2. Event-type catalogue for inbox v1 (Q1)
This is the only design pick that requires a head/m signal. **Open
question Q1 in §9 — defaulting to (A) until head answers.**
### (R) Recommendation (A): curated subset
Sources: `[approval_request, project_event]`.
**Approval requests:** all rows whose `viewer_role=any_visible` AND
status ∈ {pending} by default; the existing chip cluster
(approver_eligible / self_requested / any_visible) stays. Decided
requests are filtered by the chip, not hidden by source-removal — so a
user who wants to see "what got approved this week" toggles the status
chip rather than the source.
**Project events:** filter by `event_type ∈ InboxProjectEventKinds`
where InboxProjectEventKinds is a new sub-list of KnownProjectEventKinds:
| event_type | In inbox v1? | Reason |
|-------------------------|--------------|---------------------------------------------------------------------|
| `project_created` | no | The author already saw the page; not news to the team yet (the team grows post-creation). |
| `project_archived` | **yes** | High-signal lifecycle event ("Akte XY wurde archiviert"). |
| `project_reparented` | **yes** | Hierarchy moves matter to everyone with access. |
| `project_type_changed` | **yes** | Same reason. |
| `status_changed` | no | Currently too granular; surface in Verlauf, revisit if m disagrees. |
| `deadline_created` | **yes** | New deadline on a project I can see — exactly the kind of event m named ("we should also display new events"). |
| `deadline_completed` | **yes** | Likewise. |
| `deadline_reopened` | **yes** | Likewise. |
| `deadline_updated` | **yes** | Currently in DB (11 rows live) but not in KnownProjectEventKinds — add it. |
| `deadline_deleted` | **yes** | Likewise — add to KnownProjectEventKinds. |
| `deadlines_imported` | **yes** | Bulk-import event surfaces what got added. |
| `appointment_created` | **yes** | |
| `appointment_updated` | **yes** | |
| `appointment_deleted` | **yes** | |
| `note_created` | **yes** | A note is "someone said something about this project". High-signal; add to KnownProjectEventKinds. |
| `our_side_changed` | **yes** | Party-side flip; high-signal, add to KnownProjectEventKinds. |
| `member_role_changed` | no | Admin churn; would dominate active users' inbox. Revisit slice B. |
| `*_approval_requested` | **no — de-duped** | The approval_request row itself carries the signal; the audit event is the same fact in a different table. Filtering it out avoids duplicate inbox entries. |
| `*_approval_approved/rejected/revoked` | **no — de-duped** | Same reason. The approval_request row's status flip is what the user sees. |
| `*_approval_changes_suggested` | **no — de-duped** | Same. |
| `approval_decided` | no | This is the umbrella audit-only kind; superseded by the approval_request row. |
| `checklist_*` | no | Low signal; checklists are surfaced on the project's checklist page. |
The de-dup pattern means: if a row exists in `approval_requests` for an
entity, the corresponding `*_approval_*` project_event is **not** shown
in the inbox — we trust the approval_request row.
### Alternative (B): everything in KnownProjectEventKinds + approvals
Simpler — no curated sub-list, no de-dup. Two drawbacks:
1. `*_approval_*` duplicates would render twice per request.
2. `status_changed` and `member_role_changed` are admin churn; in firm
tests both would dominate.
If head picks B, we need at minimum the `*_approval_*` de-dup; otherwise
the inbox renders the same fact twice.
### Alternative (C): minimal — approvals + appointment_* + deadline_*
Tightest set. Drops notes + our_side_changed + project_*. Risk: m's
brief literally says "new events that relate to one's projects" — notes
and side changes ARE such events. C feels too narrow.
---
## 3. Read/unread model (Q3 → R: high-watermark cursor)
### (R) Decision: per-user high-watermark `inbox_seen_at`
**Schema:**
```sql
ALTER TABLE paliad.users
ADD COLUMN inbox_seen_at timestamptz NULL;
```
NULL means "never visited" → everything counts as unread. The high-water
cursor advances exactly when the user POSTs to
`/api/inbox/mark-all-seen` (UI affordance: a button in the inbox header
+ implicit advance on page-mount, see Slice A wiring below).
### Why cursor, not per-item
m's recommendation: cursor. Mine matches: single column, no fan-out
table, covers the common case ("I checked my inbox, mark everything
read"). Per-item dismiss is Slice C — opt-in only if the cursor proves
inadequate. The risk we're guarding against: a single high-value pending
approval that's a week old gets buried by 80 fresh deadline_updated
events; the user clears the badge and may now never look at the
approval. Mitigation: **approval_requests with status=pending never
fall behind the cursor** — they count toward the badge regardless of
seen_at. This is a tiny conditional in the count query (Slice A).
### Cursor advance behaviour
- **Explicit:** "Alles als gelesen markieren" button in the inbox
header. POSTs `/api/inbox/mark-all-seen`; server sets
`inbox_seen_at = now()`.
- **Implicit:** when the page mounts AND the bar surfaces at least one
row that's newer than the current cursor, the *new* cursor is
remembered locally as the timestamp of the **newest visible row**.
We do **not** auto-advance the server cursor on mount — too easy to
lose items behind a stray pageview. The "neu" highlight on rows
newer than the saved cursor is the silent UX. Explicit click is the
one and only path to clearing the badge.
### `unread_only` axis
New filter-bar axis (Slice A):
```ts
// types.ts
unread_only?: boolean;
```
When `true`, the bar overlays a FilterSpec predicate:
`row.event_date > inbox_seen_at` (substrate-side filter; for project_events
that's `pe.created_at > $cursor`, for approval_requests that's
`requested_at > $cursor` OR `status='pending'` per the carve-out above).
Default: **unread_only=true** for first paint (per Slice A — landing on
the inbox shows you what's new). The "Alle" chip flips it off so the
user can see history.
---
## 4. Filter contract
The bar surfaces these axes on `/inbox` (`INBOX_AXES` constant in
`client/inbox.ts`):
| Axis | Why on /inbox | New? |
|--------------------------|----------------------------------------------------------------------|------|
| `time` | "Last 30 days" (default) with chip cluster + "Älter anzeigen" . | already |
| `project` | Single-select autocomplete from visible projects. | already |
| `approval_viewer_role` | "Zur Genehmigung" / "Eigene Anfragen" / "Alle sichtbaren". | already |
| `approval_status` | pending / approved / rejected / revoked / changes_requested. | already |
| `approval_entity_type` | Frist / Termin (chip pair). | already |
| `project_event_kind` | Chip cluster over InboxProjectEventKinds. | already |
| **`unread_only`** | Boolean toggle ("Nur ungelesen" / "Alle"); defaults to ungelesen. | **Slice A new axis** |
| `shape` | list / cards / calendar. | already in `AxisKey`, not yet on `/inbox` |
| `sort` | Newest first (default) / oldest first. | already |
| `density` | comfortable / compact. | already |
**Default landing state** for a brand-new pageview:
`?time=past_30d&unread_only=true&a_status=pending&shape=list&sort=date_desc`.
Bookmarks from older clients (e.g. the legacy `?tab=pending-mine`)
still work because `client/inbox.ts:4658` already applies the legacy
tab → `a_role` redirect at hydration.
### Source-removal not exposed as an axis
Users do **not** see a "show approvals only / show events only" chip.
The signal we want is "what's new across my projects"; splitting the
two via the filter row is busywork. If they want approvals-only they
chip-pick `project_event_kind` empty + status=any (or future axis pick
`source=approval_request`). If feedback shows otherwise after Slice A
ships, we add the axis in Slice B trivially (`Sources` is a
spec.Sources literal flip).
---
## 5. View toggle implementation plan (Q5 → R: list / cards / calendar)
The pattern `/events` uses today (see `frontend/src/events.tsx:107141`
for the `<div className="events-view-selector">` block and
`client/events.ts:617650` for the `applyView` function):
- One chip cluster `data-event-view="cards|list|calendar"`.
- Active class toggle.
- Per-shape `display: none` on the table-wrap / cards-wrap / cal-wrap
hosts.
- For calendar, `mountCalendar()` constructs a month/week/day grid
into a dedicated `events-calendar-wrap` host; the handle is destroyed
on shape-leave so its URL state doesn't leak into the other shapes.
### Mapping onto /inbox
The cleanest path: **use `filter-bar`'s built-in `shape` axis instead of
a per-page selector.** The axis already round-trips into the URL via
`url-codec.ts` and serialises into `RenderSpec.Shape`. `client/inbox.ts`
just needs:
1. Add `"shape"` to `INBOX_AXES`.
2. Dispatch in the `onResult` callback by `effective.render.shape`:
```ts
onResult: (result, effective) => {
switch (effective.render.shape) {
case "cards": return paintCards(result.rows, effective.render, ...);
case "calendar": return paintCalendar(result.rows, ...);
case "list":
default: return paintList(result.rows, effective.render, ...);
}
}
```
3. The renderers exist already: `renderCardsShape` in
`views/shape-cards.ts`, `renderCalendarShape` in
`views/shape-calendar.ts`, `renderListShape` in `views/shape-list.ts`.
The only piece of new code is the per-shape host-clearing on switch
(so we don't leak a stale shape's DOM into the new host).
### Calendar shape — items without dates
Calendar can only render rows with a calendar-mappable date. Today:
- **approval_request:** `requested_at` (timestamp). Maps fine, but
shows up as a single point — rendering an approval-request on a month
grid is semantically "you got asked on this day". OK for v1.
- **project_event:** `created_at`. Same shape.
- **deadline:** `due_date`. Already supported.
- **appointment:** `start_at`. Already supported.
So every row in the inbox v1 has a calendar position. No
need to filter rows on calendar-mount. **One caveat:** the calendar
shape currently doesn't render action affordances (approve/reject) — it
opens a detail dialog on click. Slice B accepts that: clicking an
approval row on the calendar opens the inbox-list-style detail in a
modal (re-using the existing per-row /api/approval-requests/{id}
fetch). Out of scope for Slice A.
### Cards shape — day-grouped chronological cards
`shape-cards.ts` groups by day and renders one card per row, with
title + meta + actor. The approval-card layout there is the standard
card (no approve buttons — same caveat as calendar). For Slice B, we
extend `shape-cards.ts` to detect `row.kind === "approval_request"
&& row.detail.status === "pending"` and stamp the approve/reject button
strip inline. The DOM template is the same as
`shape-list.ts:renderApprovalRow`, so most of the work is hoisting that
template into a shared util.
---
## 6. Backend aggregation service (Q6 → R: reuse RunSpec)
**Decision: do not build a new aggregation service.** The
substrate-level work is exactly two edits:
### 6.1 InboxSystemView (system_views.go:103144)
```go
func InboxSystemView() SystemView {
return SystemView{
Slug: "inbox",
Name: "Inbox",
Filter: FilterSpec{
Version: SpecVersion,
Sources: []DataSource{
SourceApprovalRequest,
SourceProjectEvent,
},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonPast30d, Field: FieldAuto},
Predicates: map[DataSource]Predicates{
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
ViewerRole: "any_visible",
Status: []string{"pending"}, // default; bar can override
}},
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{
EventTypes: InboxProjectEventKinds, // curated subset
}},
},
},
Render: RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Density: DensityComfortable,
Sort: SortDateDesc, // newest first — different from today's date_asc
RowAction: RowActionInbox, // new — see §6.3
},
},
}
}
```
Curated sub-list lives in `filter_spec.go` next to KnownProjectEventKinds:
```go
var InboxProjectEventKinds = []string{
"project_archived", "project_reparented", "project_type_changed",
"deadline_created", "deadline_completed", "deadline_reopened",
"deadline_updated", "deadline_deleted", "deadlines_imported",
"appointment_created", "appointment_updated", "appointment_deleted",
"note_created", "our_side_changed",
}
```
(With Q1 pick A locked. If head picks B, drop the InboxProjectEventKinds
list and remove the `EventTypes` predicate. If head picks C, narrow the
list to deadline_* + appointment_* only.)
KnownProjectEventKinds in `filter_spec.go:186` needs **additions** so
`note_created`, `our_side_changed`, `deadline_updated`, `deadline_deleted`,
`deadlines_imported` are valid filter values — without this the
validator rejects the InboxSystemView spec. Migrate this list at the
same time. (`event_categories` and similar grouping infra are already
covered by `event_category_service.go` and won't move.)
### 6.2 Approval-duplicate suppression
In `view_service.runProjectEvents` (or in a tiny new predicate helper),
skip `event_type LIKE '%_approval_%'` when source-set includes
ApprovalRequest. This avoids the double-count described in Q1 §2.
Implementation: extend `allowedProjectEventKinds` (view_service.go:649) to
auto-drop the `*_approval_*` strings when the same RunSpec already
fans out the approval_request source. One conditional, six lines.
### 6.3 Mixed-row row_action
`shape-list.ts` today: `row_action="approve"` → calls
`renderApprovalList(rows)` which assumes every row is an approval.
Need a new value:
```go
// render_spec.go
const RowActionInbox ListRowAction = "inbox"
```
And register it in `KnownRowActions`.
Frontend (`shape-list.ts`):
```ts
if (rowAction === "inbox") {
host.appendChild(renderInboxList(sorted));
return;
}
```
Where `renderInboxList(rows)`:
- approval_request rows → existing `renderApprovalRow(row)` template (the
per-row factor-out from `renderApprovalList`).
- project_event rows → a new `renderProjectEventRow(row)` template:
timestamp + actor + title + project chip + optional "Öffnen" link
to the underlying entity (deadline / appointment / note / project
detail). Modelled on the Verlauf row in
`client/projects-detail.ts:651700` (`.entity-event` markup).
This makes the inbox stamping kind-aware. The
existing `wireApprovalActions` continues to find buttons via class
`.views-approval-action` and works unchanged.
### 6.4 Endpoints — what's new vs reused
| Path | Behaviour | Slice |
|-------------------------------------|----------------------------------------------------------|-------|
| `GET /api/views/inbox/run` | **Already exists** — fans the InboxSystemView spec. | A reuse |
| `GET /api/inbox/count` | **Behaviour change:** count includes unread project_events on visible projects + pending approval_requests (the latter regardless of cursor). | A |
| `POST /api/inbox/mark-all-seen` | New. Sets `users.inbox_seen_at = now()` for the caller. | A |
| `GET /api/inbox/pending-mine` | **Keep** — backwards-compat for clients (sidebar bell may still use it). | unchanged |
| `GET /api/inbox/mine` | **Keep** — used by the saved view `inbox-mine`. | unchanged |
The two `/api/inbox/{pending-mine,mine}` endpoints stay because they're
narrower-than-RunSpec optimisations and used by the dashboard's
`loadInboxSummary`. No reason to remove them.
### 6.5 InboxSummary on the dashboard (out of scope, but flag)
`DashboardData.InboxSummary` (dashboard_service.go:89) currently counts
only pending approvals. If Slice C extends the badge count to include
unread project_events, the dashboard widget also needs to swap
`PendingCountForUser` for the new unified count — keep this as a small
follow-up after Slice A ships and the cursor semantics are proven.
---
## 7. Slice plan
### Slice A — Project-event aggregation + read cursor + list view
**Goal:** /inbox shows pending approvals + curated project_events for
visible projects in the last 30 days, with the new "Nur ungelesen"
toggle. List view only.
Tasks:
1. **Migration `NNN_inbox_seen_at.up.sql`:**
`ALTER TABLE paliad.users ADD COLUMN inbox_seen_at timestamptz NULL;`
2. **`filter_spec.go`:** extend `KnownProjectEventKinds` (add
`note_created`, `our_side_changed`, `deadline_updated`,
`deadline_deleted`, `deadlines_imported`). Add
`InboxProjectEventKinds` (curated subset, Q1=A).
3. **`system_views.go`:** rewrite `InboxSystemView` per §6.1 with
both sources, `HorizonPast30d`, `SortDateDesc`,
`RowAction=RowActionInbox`.
4. **`render_spec.go`:** add `RowActionInbox`, register in
`KnownRowActions`.
5. **`view_service.go`:** in `runProjectEvents`, auto-drop
`*_approval_*` event_types when ApprovalRequest is in
`spec.Sources` (§6.2).
6. **`approvals.go`:**
- New handler `handleInboxMarkAllSeen` →
`UPDATE paliad.users SET inbox_seen_at = now() WHERE id = $1`.
- Modify `handleInboxCount` to return
`pending_approvals_count + unread_project_events_count`. SQL
in approval_service.go: one new method
`UnseenInboxCountForUser(userID)` returning that union. Keep
`PendingCountForUser` (dashboard still uses it).
7. **`shape-list.ts`:** factor `renderApprovalRow(row)` out of
`renderApprovalList`. Add `renderInboxList(rows)` that dispatches
per `row.kind`. Wire `row_action="inbox"` to it.
8. **`client/inbox.ts`:**
- Add the `unread_only` axis to `INBOX_AXES` and wire to a FilterSpec
overlay (sub-spec `Time.Horizon=Past30d` AND
filter predicate "newer than cursor OR pending-approval").
- Render "Alles als gelesen markieren" button in the page header
(in `inbox.tsx`); on click POST `/api/inbox/mark-all-seen`,
refresh bar + badge.
- Listen for cursor update (server response) and refresh.
9. **Sidebar badge (`client/sidebar.ts:initInboxBadge`):** unchanged code
path, but the new server count includes project_events. Add no client
changes for v1 — server returns the wider count.
10. **i18n:** new keys —
- `inbox.title.feed` ("Inbox") replaces "Genehmigungen" in the page
header (since the page is now more than approvals).
- `inbox.subtitle.feed` ("Neuigkeiten zu Ihren Projekten und offene
Genehmigungen.").
- `inbox.action.mark_all_seen` ("Alles als gelesen markieren").
- `inbox.axis.unread_only.on/off`.
- `inbox.empty.feed` ("Keine Neuigkeiten in den letzten 30 Tagen.").
- `views.col.event_kind` (for the kind column in
table-density list).
- DE primary, EN secondary, both in `i18n.ts`.
11. **Tests:** `system_views_test.go` covers the
InboxSystemView spec shape; new test for the de-dup helper in
view_service. `approval_service_test.go` adds tests for the new
`UnseenInboxCountForUser` method. New
`inbox_seen_at_test.go` covers the cursor migration + the POST
handler.
12. **Verify** the page renders for a sample user with both event types
visible, "Nur ungelesen" toggles correctly, mark-all-seen clears the
badge, the project-events deduplicate against approval requests.
### Slice B — Cards + calendar shape toggles
**Goal:** `?shape=cards` and `?shape=calendar` work on /inbox; users can
switch via the bar's shape chip. Approval rows on cards/calendar are
*read-only* (open detail modal on click; no inline approve/reject).
Tasks:
1. **`client/inbox.ts`:** add `"shape"` to `INBOX_AXES`. Add the
per-shape host divs to `inbox.tsx` (one for cards, one for calendar)
matching the `/events` pattern. Implement `onResult` dispatch.
2. **`shape-cards.ts`:** when `row.kind==="approval_request"` AND
`row.detail.status==="pending"`, stamp the approval row template
inline. Hoist the template out of `shape-list.ts` if reuse pays.
3. **`shape-calendar.ts`:** approval_request rows render as date-point
chips; click opens a detail modal. The modal reuses the existing
`approval-edit-modal` for suggest-changes when the user is the
approver; otherwise a read-only summary.
4. **CSS:** ensure `.entity-event` and `.views-approval-row` markup
coexist on the cards view without z-index clashes; lightweight
targeting via `.views-cards-list[data-surface="inbox"]`.
5. **Tests:** shape toggle persistence via URL codec (already covered
in `url-codec.test.ts`; add one inbox-surface case).
### Slice C — Badge upgrade + per-item dismiss (deferred)
**Goal:** sidebar badge reflects unified count; per-item dismiss for
power-users.
Tasks:
1. **`paliad.inbox_dismissals` table** —
`(user_id, source, row_id, dismissed_at)` PK `(user_id, source, row_id)`.
"source" is `approval_request` / `project_event`; "row_id" is the
row's UUID. New endpoint `POST /api/inbox/dismiss` body
`{source, row_id}`. RunSpec for inbox subtracts dismissed rows.
2. **`/api/inbox/count`:** subtract dismissed rows from the count.
3. **Dashboard widget:** `DashboardData.InboxSummary` swaps to a new
`UnifiedInboxSummary` that mirrors the page count. Backwards-compat
JSON: keep old fields, add `total_count` and `top_unified`.
4. **Empty-state:** "Alle Einträge gelesen — gut gemacht."
5. **Optional `member_role_changed` etc.:** if Slice A surfaces that
one of the excluded event_types is actually wanted, this slice opens
up `InboxProjectEventKinds` accordingly.
### Why Slice A alone is shippable
Slice A delivers m's full ask except the cards/calendar views — which
are aesthetic shape toggles, not data changes. Slice A gives:
- Inbox feed across approvals + project_events for visible projects
- Project / type / time / read-state filters
- Newest-first list with mark-all-seen
- Sidebar badge reflects unified unread count (server-side)
Slice B + C are layer cake on top with no schema or substrate changes.
---
## 8. Out of scope
- **Push notifications.** Telegram / WhatsApp / email — different
channel concerns, separate design.
- **Cross-user inbox views.** No "admin sees others' inboxes" in v1.
- **Pinning / starring items.** Not in m's ask. If feedback after Slice
A wants it, opens its own design.
- **Paliadin chat unread.** Not part of project_events; paliadin lives
in its own pane. Slice C could surface a banner if asked.
- **Replacement of the existing /api/inbox/{pending-mine,mine} endpoints.**
They stay because the dashboard's `loadInboxSummary` uses them and
no benefit to consolidating.
- **Detail-page changes.** Clicking a project_event row in the inbox
navigates to the existing entity detail page (deadline, appointment,
note); we don't build a new "event detail" view.
- **InboxSummary on the dashboard.** Out of Slice A. Slice C upgrades
it; for now the widget keeps showing approval-only.
---
## 9. Open questions for m
Defaulted to (R) per the inventor protocol — only **Q1** is escalated
to head for explicit confirmation because it changes the
inbox's surface area. Everything else falls to the recommended pick
unless head/m flag otherwise.
**Q1 — Event-type catalogue (material pick, head answered):**
**LOCKED = A** (curated subset with `*_approval_*` de-dup). Head added
`member_role_changed` to the curated list with a Slice B narrowing
follow-up + a coarser `inbox_focus` chip cluster on the bar. Full
decision recorded in §12.
**Q2 — Time window:** (R) Past30d default + chip cluster
(today / past_7d / past_30d / past_90d / any) + custom range via the
existing time picker. Locked unless head overrides.
**Q3 — Read/unread model:** (R) High-watermark cursor
(`users.inbox_seen_at`). Pending approval_requests carry forward even
when older than the cursor — guards against burying a high-value
approval. Per-item dismiss is Slice C, opt-in. Locked.
**Q4 — Filters surfaced on the bar:** (R) time / project /
approval_viewer_role / approval_status / approval_entity_type /
project_event_kind / unread_only / shape / sort / density. Locked
unless head wants `source` (approvals-only vs events-only chip)
added — defaulting to "not in v1".
**Q5 — View toggle parity with /events:** (R) list (default — newest
first) / cards (day-grouped) / calendar (date-point). Wired via the
filter-bar's existing `shape` axis, not a per-page selector. Locked.
**Q6 — Architecture:** (R) Reuse `view_service.RunSpec` with both
sources in the InboxSystemView spec; no new aggregation service.
Approval-event de-dup applied in `runProjectEvents`. Locked.
**Q7 — Notification badge:** (R) Yes — Slice A makes the existing
`/api/inbox/count` return the unified unread count; sidebar badge
client unchanged. Locked.
**Q8 — Acknowledgement flow:** (R) Approval rows keep
approve/reject/revoke buttons inline (list shape only). project_event
rows have no inline action — click row → navigate to the underlying
entity. Cursor advance is via "Alles als gelesen markieren" only —
no per-row mark-read in v1. Locked.
**Q9 — Empty-state copy:** (R) "Keine Neuigkeiten in den letzten 30
Tagen." (DE primary) / "No updates in the last 30 days." (EN). The
existing admin nudge for unseeded approval_policies stays untouched.
Locked.
---
## 10. Risks + mitigations
- **Performance.** `runProjectEvents` reads up to LIMIT 500 rows per
user-call; with two sources unioned + 30-day window + visibility
predicate this should stay under 50ms on the live shape (project
count ~100, events/day low double digits). If
it doesn't, partial index hint: `paliad.project_events (created_at DESC)
WHERE event_type IN (curated list)` — Slice A optional, add if
EXPLAIN shows a seq scan in dev.
- **De-dup correctness.** Suppressing `*_approval_*` events in the
project_event source relies on the approval_request row being the
authoritative signal. **Edge case:** a request gets revoked, then
re-requested — both audit events exist. Both correspond to a single
approval_request row at any moment (the latter via the partial-index
upsert). De-dup stays valid.
- **Cursor advance race.** If two browser tabs both POST mark-all-seen,
the second wins (now() wins). Acceptable. If a user reads in tab A
then clicks an item in tab B that was created between the two reads,
tab A's "Alles als gelesen" advances past that newer item without
the user seeing it. Mitigation: server-side, `mark-all-seen` accepts
an optional `?up_to=<iso>` so the client can pin to the timestamp of
the newest visible row. Slice A wires this.
- **shape-list factor-out.** Pulling `renderApprovalRow` out of
`renderApprovalList` risks regressions on the *current* /inbox. Cover
with a snapshot/golden test on the approval row markup in Slice A
before the dispatch change.
- **Sidebar bell badge cap.** Current code clamps at "9+". Once we add
project_events, the count can easily exceed 100. Keep the "9+" clamp
for visual reasons — but make the page header show the *exact* count
("123 neu") so the user knows what's behind it.
- **Q1 fallback.** If head doesn't reply before Slice A coder shift
starts, the (R) pick A locks. If head later picks B or C, the only
change is the `InboxProjectEventKinds` list literal in
`filter_spec.go` — no schema impact, no migration change. Cheap to
flip.
---
## 11. Build/test verify list (Slice A done-when)
1. `make build` clean.
2. `go test ./...` passes; new tests cover:
- InboxSystemView spec shape includes both sources + curated kinds.
- `runProjectEvents` drops `*_approval_*` when ApprovalRequest is in spec.
- `UnseenInboxCountForUser` returns expected count for cursor and pending-approval combinations.
- POST `/api/inbox/mark-all-seen` updates the column.
- URL codec round-trip for `unread_only` axis.
3. Inbox loads at `/inbox` with project-event rows interleaved with
approval rows in date-desc order.
4. "Nur ungelesen" chip toggles between unread (with pending-approval
carve-out) and full feed.
5. "Alles als gelesen markieren" advances cursor; bar refreshes;
badge clears (except for any still-pending approvals).
6. Sidebar bell badge count is the unified number (approval + unread events).
7. Existing approve/reject/revoke + suggest-changes flows on inbox
rows still work unchanged.
8. `?tab=mine` legacy redirect still hits the right state.
9. Bilingual labels render (DE/EN toggle).
That's the doneness bar for Slice A.
---
## §12 — m's decisions (head 2026-05-25 11:30)
Head replied to the `mai instruct head` escalation; folded in below.
**Q1 (Event-type catalogue): A — locked.** Curated subset with
`*_approval_*` de-dup. Tracks Verlauf, matches m's framing ("new events
that relate to one's projects"), avoids double-counting approval audit
events against the approval_request row.
Locked InboxProjectEventKinds:
- IN: `project_archived`, `project_reparented`, `project_type_changed`,
`deadline_created`, `deadline_completed`, `deadline_reopened`,
`deadline_updated`, `deadline_deleted`, `deadlines_imported`,
`appointment_created`, `appointment_updated`, `appointment_deleted`,
`note_created`, `our_side_changed`, **`member_role_changed`**
(added by head — see refinement #1).
- OUT (audit duplicates of approval_requests): every `*_approval_*` event.
- OUT (too granular / authoring noise): `status_changed`,
`project_created`, `checklist_*`.
**Refinement 1 — `member_role_changed` visibility predicate.**
Head wants this kind included but narrowed: surface the row only when
the role change applies to the **viewer themselves** or someone above
them in the project tree (i.e. impacts the viewer's permissions / chain
of command), not when it's a peer's role changing on a project the
viewer happens to see.
- Slice A: include `member_role_changed` in
`InboxProjectEventKinds` without the narrowing predicate. The row
will appear for everyone who can see the project — over-surfacing but
not wrong. This keeps Slice A's MVP scope tight.
- Slice B: add a per-row narrowing filter on top of the inbox source
(likely a small extension to `runProjectEvents` that, when
`event_type='member_role_changed'`, inspects `metadata.affects_user_id`
+ walks the project-membership predicate before emitting). The
metadata shape is already written by the responsible handler; verify
+ lock the filter in B.
Q2-Q9 all default to (R) per the inventor protocol.
**Refinement 2 — Filter chip copy.**
For the visible chip cluster in the bar, head wants user-readable groupings,
not raw event-kind names. The bar today exposes `project_event_kind`
as one chip per kind (rendered via the
`event.title.<kind>` i18n key). For the inbox surface, surface a
**coarser grouping chip cluster** ahead of that:
- "Genehmigungen" — narrows to `Sources=[approval_request]` only.
- "Genehmigungen + Termine" — adds appointment_* event_kinds + the
approval_entity_type=appointment slice of approvals.
- "Genehmigungen + Fristen" — adds deadline_* event_kinds + the
approval_entity_type=deadline slice of approvals.
- "Alles" — default; both sources, full curated kinds list.
Implementation: a new axis `inbox_focus` (Slice A, additive — replaces
the lower-level `project_event_kind` chip's *default visibility* in the
inbox UI; advanced users still see `project_event_kind` if they expand
the bar). The four values map to FilterSpec overlays that tweak
`Sources` + per-source `EventTypes`. Coder owns the exact chip-text
final copy and the placement (probably first axis in `INBOX_AXES`).
The lower-level `project_event_kind` chip stays in `INBOX_AXES` as an
advanced override for power users — when active, it overrides the
`inbox_focus` chip's per-kind defaults.
---
### What changes for Slice A as a result
Doc deltas vs the draft text above:
1. **§2 / §6.1:** add `member_role_changed` to InboxProjectEventKinds.
Note Slice B narrowing follow-up.
2. **§4 / §5:** front of the bar gets a new `inbox_focus` axis
(4 chips: Alles / Genehmigungen / +Termine / +Fristen). Default
"Alles". `project_event_kind` stays available as an advanced chip,
visible after the user expands the bar's overflow section.
3. **§7 Slice A task list:** add task —
"**12a.** New `inbox_focus` axis (`filter-bar/types.ts`,
`axes.ts`). FilterSpec overlay translates the chip value to a
`(Sources, ProjectEventPredicates.EventTypes, ApprovalRequestPredicates.EntityTypes)`
triple. URL codec round-trips."
4. **§11 Slice B done-when:** add — "`member_role_changed` narrowing
predicate is in place; rows surface only when the change affects
the viewer's permissions chain."
No schema changes from the head's adjustments. The `inbox_focus` axis
is a pure UI/overlay primitive; nothing about the InboxSystemView spec
schema moves.

View File

@@ -12,7 +12,7 @@
// New classes are scoped under .filter-bar-* so they don't bleed.
import { t, tDyn, type I18nKey } from "../i18n";
import type { BarState, AxisKey } from "./types";
import type { BarState, AxisKey, InboxFocus } from "./types";
export interface AxisCtx {
// Read the current value for this axis.
@@ -47,6 +47,8 @@ export function renderAxis(axis: AxisKey, ctx: AxisCtx, opts?: RenderAxisOpts):
case "shape": return renderShapeAxis(ctx);
case "density": return renderDensityAxis(ctx);
case "sort": return renderSortAxis(ctx);
case "unread_only": return renderUnreadOnlyAxis(ctx);
case "inbox_focus": return renderInboxFocusAxis(ctx);
// Per-source predicates that need their own widgets and a roundtrip
// through fetched option lists. Phase 2+ will fill these in by
@@ -484,6 +486,56 @@ function renderSortAxis(ctx: AxisCtx): HTMLElement {
return wrap;
}
// ----------------------------------------------------------------------
// unread_only — single binary chip (t-paliad-249, inbox only)
// ----------------------------------------------------------------------
function renderUnreadOnlyAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.unread_only");
const row = chipRow();
const isUnread = ctx.get("unread_only") !== false; // default on
const unreadChip = chipBtn(t("views.bar.unread_only.on"), isUnread);
unreadChip.addEventListener("click", () => ctx.patch({ unread_only: true }));
const allChip = chipBtn(t("views.bar.unread_only.off"), !isUnread);
allChip.addEventListener("click", () => ctx.patch({ unread_only: false }));
row.appendChild(unreadChip);
row.appendChild(allChip);
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// inbox_focus — coarse 4-chip cluster (t-paliad-249, inbox only)
//
// Head's UX refinement #2 (2026-05-25): users pick "what to see" in
// human terms, not abstract event-kind names. The overlay translates
// the chip to a (Sources, ProjectEventPredicates.EventTypes,
// ApprovalRequestPredicates.EntityTypes) triple at spec-resolve time
// (see applyInboxFocusOverlay in url-codec.ts).
// ----------------------------------------------------------------------
const INBOX_FOCUS_CHIPS: Array<{ value: InboxFocus; key: I18nKey }> = [
{ value: "alles", key: "views.bar.inbox_focus.alles" },
{ value: "genehmigungen", key: "views.bar.inbox_focus.genehmigungen" },
{ value: "plus_termine", key: "views.bar.inbox_focus.plus_termine" },
{ value: "plus_fristen", key: "views.bar.inbox_focus.plus_fristen" },
];
function renderInboxFocusAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.inbox_focus");
const row = chipRow();
const current: InboxFocus = ctx.get("inbox_focus") ?? "alles";
for (const f of INBOX_FOCUS_CHIPS) {
const chip = chipBtn(t(f.key), f.value === current);
chip.addEventListener("click", () => {
ctx.patch({ inbox_focus: f.value === "alles" ? undefined : f.value });
});
row.appendChild(chip);
}
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// shared helpers — group + chip + row
// ----------------------------------------------------------------------

View File

@@ -333,9 +333,65 @@ export function computeEffective(
render.list = { ...(render.list ?? {}), density: state.density };
}
// Inbox overlays (t-paliad-249).
//
// unread_only is a top-level FilterSpec field; the server resolves
// the actual cursor at run-time. Default-on for the inbox surface is
// baked into the base spec — but we ALSO need to write `true` here
// when the user explicitly picks the chip so the server doesn't
// confuse "user wants unread" with "user wants no filter".
if (state.unread_only !== undefined) {
filter.unread_only = state.unread_only;
}
// inbox_focus is a coarse axis that overlays Sources + a few
// per-source predicates. Translate here so the server sees a clean
// spec; the validator + RunSpec don't need to know about the chip.
if (state.inbox_focus && state.inbox_focus !== "alles") {
applyInboxFocusOverlay(filter, state.inbox_focus);
}
return { filter, render };
}
// applyInboxFocusOverlay narrows the spec to the chip's intent.
// Mutates `filter` in place. Called only when state.inbox_focus is
// set to a non-default value.
//
// Contract:
// - "genehmigungen" → drop project_event from sources entirely.
// - "plus_termine" → keep both sources; narrow project_event to
// appointment_* kinds; narrow approval_request
// entity_types to ["appointment"].
// - "plus_fristen" → keep both sources; narrow project_event to
// deadline_* kinds; narrow approval_request
// entity_types to ["deadline"].
function applyInboxFocusOverlay(filter: FilterSpec, focus: Exclude<NonNullable<BarState["inbox_focus"]>, "alles">): void {
filter.predicates = filter.predicates ?? {};
if (focus === "genehmigungen") {
filter.sources = filter.sources.filter((s) => s !== "project_event");
delete filter.predicates.project_event;
return;
}
const kindPrefix = focus === "plus_fristen" ? "deadline_" : "appointment_";
const entity = focus === "plus_fristen" ? "deadline" : "appointment";
if (filter.sources.includes("project_event")) {
const baseKinds = filter.predicates.project_event?.event_types ?? [];
const narrowed = baseKinds.filter((k) => k.startsWith(kindPrefix));
filter.predicates.project_event = {
...(filter.predicates.project_event ?? {}),
event_types: narrowed,
};
}
if (filter.sources.includes("approval_request")) {
filter.predicates.approval_request = {
...(filter.predicates.approval_request ?? {}),
entity_types: [entity],
};
}
}
// isDirty — used to enable the Reset button only when there's something
// to reset to.
function isDirty(state: BarState): boolean {

View File

@@ -25,7 +25,17 @@ export type AxisKey =
| "timeline_track"
| "shape"
| "sort"
| "density";
| "density"
// Inbox-only (t-paliad-249): unread/all toggle + coarse focus chip
// (Alles / Genehmigungen / +Termine / +Fristen). The focus chip
// overlays Sources + per-source predicates at resolve-time.
| "unread_only"
| "inbox_focus";
// Inbox focus chip values. "alles" is the default — both sources, full
// curated kinds. Other values narrow at the bar's resolve step. See
// applyInboxFocusOverlay() in url-codec.ts for the spec rewrite.
export type InboxFocus = "alles" | "genehmigungen" | "plus_termine" | "plus_fristen";
// Effective spec — the result of overlaying URL + localStorage prefs
// on top of the base spec. Handed back to onResult so the surface can
@@ -62,6 +72,10 @@ export interface BarState {
shape?: RenderShape;
sort?: "date_asc" | "date_desc";
density?: "comfortable" | "compact";
// Inbox (t-paliad-249)
unread_only?: boolean;
inbox_focus?: InboxFocus;
}
export interface TimeOverlay {

View File

@@ -99,4 +99,28 @@ describe("filter-bar/url-codec", () => {
params.set("density", "huge");
expect(parseBar(params)).toEqual({});
});
// t-paliad-249 — inbox axes
test("unread_only round-trips both states", () => {
expect(roundTrip({ unread_only: true })).toEqual({ unread_only: true });
expect(roundTrip({ unread_only: false })).toEqual({ unread_only: false });
});
test("unread_only undefined stays out of the URL", () => {
const params = new URLSearchParams();
encodeBar({}, params);
expect(params.has("unread")).toBe(false);
});
test("inbox_focus round-trips for non-default values", () => {
for (const f of ["genehmigungen", "plus_termine", "plus_fristen"] as const) {
expect(roundTrip({ inbox_focus: f })).toEqual({ inbox_focus: f });
}
});
test("inbox_focus alles is omitted (it's the default)", () => {
const params = new URLSearchParams();
encodeBar({ inbox_focus: "alles" }, params);
expect(params.has("focus")).toBe(false);
});
});

View File

@@ -9,7 +9,7 @@
// Empty / default values are NOT written — the URL stays clean for
// users who don't tweak. The page's base spec is the implicit baseline.
import type { BarState, TimeOverlay, ProjectOverlay } from "./types";
import type { BarState, TimeOverlay, ProjectOverlay, InboxFocus } from "./types";
const PERSONAL_PROJECT_SENTINEL = "personal";
@@ -108,6 +108,16 @@ export function parseBar(params: URLSearchParams, ns?: string): BarState {
const density = params.get(k("density"));
if (density === "comfortable" || density === "compact") out.density = density;
// inbox (t-paliad-249)
const unread = params.get(k("unread"));
if (unread === "0") out.unread_only = false;
else if (unread === "1") out.unread_only = true;
const focus = params.get(k("focus"));
if (focus === "genehmigungen" || focus === "plus_termine" || focus === "plus_fristen" || focus === "alles") {
out.inbox_focus = focus as InboxFocus;
}
return out;
}
@@ -127,6 +137,7 @@ export function encodeBar(state: BarState, params: URLSearchParams, ns?: string)
"pe_kind",
"tl_status", "tl_track",
"shape", "sort", "density",
"unread", "focus",
]) {
params.delete(k(key));
}
@@ -168,6 +179,15 @@ export function encodeBar(state: BarState, params: URLSearchParams, ns?: string)
if (state.shape) params.set(k("shape"), state.shape);
if (state.sort) params.set(k("sort"), state.sort);
if (state.density) params.set(k("density"), state.density);
// inbox (t-paliad-249). unread_only is tri-state in BarState (undefined
// means "page default"); we only write a key when the user has flipped
// it explicitly so the URL stays clean for the default landing state.
if (state.unread_only === false) params.set(k("unread"), "0");
else if (state.unread_only === true) params.set(k("unread"), "1");
if (state.inbox_focus && state.inbox_focus !== "alles") {
params.set(k("focus"), state.inbox_focus);
}
}
function parseHorizon(s: string): TimeOverlay["horizon"] | null {

View File

@@ -2239,6 +2239,20 @@ const translations: Record<Lang, Record<string, string>> = {
"inbox.empty.admin_nudge.title": "Noch keine Genehmigungspflichten konfiguriert?",
"inbox.empty.admin_nudge.body": "Lege fest, welche Lifecycle-Events 4-Augen-Prüfung erfordern.",
"inbox.empty.admin_nudge.cta": "Genehmigungspflichten konfigurieren",
"inbox.title.feed": "Inbox — Paliad",
"inbox.heading.feed": "Inbox",
"inbox.subtitle.feed": "Neuigkeiten zu Ihren Projekten und offene Genehmigungen.",
"inbox.action.mark_all_seen": "Alles als gelesen markieren",
"inbox.action.open": "Öffnen",
"inbox.empty.feed": "Keine Neuigkeiten in den letzten 30 Tagen.",
"views.bar.label.unread_only": "Lesestatus",
"views.bar.unread_only.on": "Nur ungelesen",
"views.bar.unread_only.off": "Alle",
"views.bar.label.inbox_focus": "Anzeigen",
"views.bar.inbox_focus.alles": "Alles",
"views.bar.inbox_focus.genehmigungen": "Nur Genehmigungen",
"views.bar.inbox_focus.plus_termine": "+ Termine",
"views.bar.inbox_focus.plus_fristen": "+ Fristen",
"deadlines.form.approval_hint": "4-Augen-Prüfung erforderlich",
"appointments.form.approval_hint": "4-Augen-Prüfung erforderlich",
"admin.email_templates.title": "Email-Templates — Paliad",
@@ -5207,6 +5221,20 @@ const translations: Record<Lang, Record<string, string>> = {
"inbox.empty.admin_nudge.title": "No approval policies configured yet?",
"inbox.empty.admin_nudge.body": "Set which lifecycle events require 4-eye review.",
"inbox.empty.admin_nudge.cta": "Configure approval policies",
"inbox.title.feed": "Inbox — Paliad",
"inbox.heading.feed": "Inbox",
"inbox.subtitle.feed": "Updates on your projects and open approvals.",
"inbox.action.mark_all_seen": "Mark all as read",
"inbox.action.open": "Open",
"inbox.empty.feed": "No updates in the last 30 days.",
"views.bar.label.unread_only": "Read state",
"views.bar.unread_only.on": "Unread only",
"views.bar.unread_only.off": "All",
"views.bar.label.inbox_focus": "Show",
"views.bar.inbox_focus.alles": "Everything",
"views.bar.inbox_focus.genehmigungen": "Approvals only",
"views.bar.inbox_focus.plus_termine": "+ Appointments",
"views.bar.inbox_focus.plus_fristen": "+ Deadlines",
"deadlines.form.approval_hint": "4-eye review required",
"appointments.form.approval_hint": "4-eye review required",
"admin.email_templates.title": "Email Templates — Paliad",

View File

@@ -6,37 +6,45 @@ import type { FilterSpec, RenderSpec, SystemView, ViewRunResult } from "./views/
import { renderListShape } from "./views/shape-list";
import { openApprovalEditModal } from "./components/approval-edit-modal";
// /inbox client — t-paliad-163 universal-filter migration.
// /inbox client — t-paliad-249 unified inbox feed.
//
// The bar owns every axis the old tab UI exposed plus more:
// - approval_viewer_role: "Zur Genehmigung" / "Eigene Anfragen" /
// "Alle sichtbaren" (collapses the legacy two-tab UI per Q4 lock-in)
// - approval_status: chip cluster (default: pending)
// - approval_entity_type: chip pair (Frist / Termin)
// - time: chip cluster (Any default)
// - density: comfortable / compact
// - sort: date asc / desc
// The bar exposes:
// - inbox_focus: coarse Alles / Genehmigungen / +Termine / +Fristen
// - unread_only: Nur ungelesen / Alle (default: ungelesen)
// - time: last 30 days default; chip cluster + custom range
// - project: single-select autocomplete from visible projects
// - approval_viewer_role: Zur Genehmigung / Eigene / Alle sichtbaren
// - approval_status / approval_entity_type / project_event_kind: power-user overrides
// - sort / density: newest first default
//
// Row rendering: shape-list.ts with row_action="approve" stamps the
// inbox markup (entity title, diff, approve/reject/revoke buttons).
// We wire action click handlers in onResult and refresh through the
// bar handle.
// Row rendering: shape-list.ts with row_action="inbox" dispatches per
// row.kind. Approval rows keep approve/reject/revoke; project_event
// rows render compact with an Öffnen link.
const INBOX_AXES: AxisKey[] = [
"inbox_focus",
"unread_only",
"time",
"project",
"approval_viewer_role",
"approval_status",
"approval_entity_type",
"density",
"project_event_kind",
"sort",
"density",
];
// Last paint's newest row timestamp — used to pin mark-all-seen so a
// second tab can't race the cursor past items the user hasn't seen.
let newestVisibleAt: string | null = null;
let bar: BarHandle | null = null;
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
applyLegacyTabRedirect();
wireMarkAllSeen();
void hydrate();
});
@@ -105,15 +113,25 @@ function paint(
if (!result.rows || result.rows.length === 0) {
results.innerHTML = "";
empty.style.display = "";
empty.textContent = t("approvals.empty.pending_mine");
empty.textContent = t("inbox.empty.feed");
newestVisibleAt = null;
void maybeShowAdminNudge();
return;
}
hideAdminNudge();
empty.style.display = "none";
// Remember the newest timestamp so mark-all-seen can pin the cursor
// to it (race-safety: a second tab adding a row between this paint
// and the click won't get wiped out).
newestVisibleAt = result.rows.reduce<string | null>((acc, r) => {
if (!acc) return r.event_date;
return r.event_date > acc ? r.event_date : acc;
}, null);
// shape-list.ts honours render.list.row_action — InboxSystemView's
// RenderSpec sets row_action="approve" so we get the inbox markup.
// RenderSpec sets row_action="inbox" so we get the unified dispatch
// (approval rows + project_event rows).
renderListShape(results, result.rows, render);
// Wire action handlers on the freshly stamped DOM. The action
@@ -122,6 +140,38 @@ function paint(
wireApprovalActions(results);
}
// wireMarkAllSeen wires the page-header "Alles als gelesen markieren"
// button. POSTs the newest visible row's timestamp as `up_to` so a
// stale second tab can't rewind anyone else's cursor; on success the
// bar refreshes (rows newer than now disappear under unread_only) and
// the sidebar badge re-counts.
function wireMarkAllSeen(): void {
const btn = document.getElementById("inbox-mark-all-seen") as HTMLButtonElement | null;
if (!btn) return;
btn.addEventListener("click", async () => {
btn.disabled = true;
try {
const body = newestVisibleAt ? JSON.stringify({ up_to: newestVisibleAt }) : "{}";
const r = await fetch("/api/inbox/mark-all-seen", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body,
});
if (!r.ok) {
alert(t("approvals.error.internal"));
return;
}
await bar?.refresh();
await refreshInboxBadge();
} catch (_e) {
alert("Network error");
} finally {
btn.disabled = false;
}
});
}
function wireApprovalActions(host: HTMLElement): void {
host.querySelectorAll<HTMLButtonElement>(".views-approval-action").forEach((btn) => {
const action = btn.dataset.action as

View File

@@ -32,6 +32,11 @@ export function renderListShape(host: HTMLElement, rows: ViewRow[], render: Rend
return;
}
if (rowAction === "inbox") {
host.appendChild(renderInboxList(sorted));
return;
}
if (density === "compact") {
host.appendChild(renderCompact(sorted));
} else {
@@ -233,111 +238,215 @@ function renderApprovalList(rows: ViewRow[]): HTMLElement {
const ul = document.createElement("ul");
ul.className = "inbox-list views-approval-list";
for (const row of rows) {
const detail = (row.detail || {}) as ApprovalDetail;
const li = document.createElement("li");
li.className = "inbox-row views-approval-row";
li.dataset.requestId = row.id;
li.dataset.status = detail.status ?? "";
// Header: entity / lifecycle
const head = document.createElement("div");
head.className = "inbox-row-head";
const title = document.createElement("div");
title.className = "inbox-row-title";
const entityLabel = detail.entity_type ? t(("approvals.entity." + detail.entity_type) as I18nKey) : "";
const lifecycleLabel = detail.lifecycle_event ? t(("approvals.lifecycle." + detail.lifecycle_event) as I18nKey) : "";
const entityTitle = detail.entity_title || row.title || "—";
title.textContent = `${entityLabel}: ${entityTitle}${lifecycleLabel}`;
head.appendChild(title);
const meta = document.createElement("div");
meta.className = "inbox-row-meta";
const reqByLabel = t("approvals.requested_by");
const roleLabel = detail.required_role
? t(("approvals.required_role." + detail.required_role) as I18nKey)
: "";
const requester = detail.requester_name || row.actor_name || "";
const requesterTag = detail.requester_kind === "agent"
? `${requester}${t("approvals.agent.byline")}`
: requester;
const projectTitle = row.project_title ?? "";
const parts = [
projectTitle,
`${reqByLabel} ${requesterTag}`,
];
if (roleLabel) parts.push(`${roleLabel}+`);
parts.push(formatRelativeTime(row.event_date));
meta.textContent = parts.filter(Boolean).join(" · ");
head.appendChild(meta);
li.appendChild(head);
// Diff for update / complete
const diff = renderDiff(detail);
if (diff) li.appendChild(diff);
if (detail.decision_note) {
const note = document.createElement("div");
note.className = "inbox-row-note";
note.textContent = detail.decision_note;
li.appendChild(note);
}
// Action row — surface attaches handlers via data-attrs.
const actions = document.createElement("div");
actions.className = "inbox-row-actions";
if (detail.status === "pending") {
// All four actions are stamped on every pending row; the per-viewer
// viewer_can_approve / viewer_is_requester flags (resolved server-side)
// decide which are enabled vs. greyed out with a tooltip. m's ask
// (2026-05-17): show what's possible but disable what isn't, rather
// than alert-after-click. The server still enforces — disabled buttons
// are a UI hint, not a security gate.
//
// suggest_changes is hidden for non-update lifecycles (the backend
// returns ErrSuggestionLifecycleInvalid for create/complete/delete,
// so we don't even render the button for them).
actions.appendChild(approvalActionBtn("approve", detail));
if (detail.lifecycle_event === "update") {
actions.appendChild(approvalActionBtn("suggest_changes", detail));
}
actions.appendChild(approvalActionBtn("reject", detail));
actions.appendChild(approvalActionBtn("revoke", detail));
} else if (detail.status) {
const pill = document.createElement("span");
pill.className = "approval-pill approval-pill--historic";
pill.textContent = t(("approvals.status." + detail.status) as I18nKey);
if (detail.decider_name && detail.status !== "revoked") {
const decided = document.createElement("span");
decided.className = "inbox-row-decided";
decided.textContent = ` · ${t("approvals.decided_by")} ${detail.decider_name}`;
pill.appendChild(decided);
}
actions.appendChild(pill);
}
li.appendChild(actions);
// Back-link from the OLD changes_requested row to the NEW pending
// counter row (t-paliad-216). Hydrated server-side as
// detail.next_request_id; the surface renders a link that scrolls
// / filters to the new row. Falsy next_request_id = no link (e.g.
// older rows pre-mig-103, or rows where the server hasn't joined the
// back-pointer).
if (detail.status === "changes_requested" && detail.next_request_id) {
const link = document.createElement("a");
link.className = "inbox-row-next-request";
link.href = `#request-${detail.next_request_id}`;
link.dataset.nextRequestId = detail.next_request_id;
const deciderName = detail.decider_name || "";
link.textContent = t("approvals.suggest.next_request_link").replace("{name}", deciderName);
li.appendChild(link);
}
ul.appendChild(li);
ul.appendChild(renderApprovalRow(row));
}
return ul;
}
// renderApprovalRow stamps one <li> for an approval_request row.
// Factored out of renderApprovalList in t-paliad-249 so the unified
// inbox dispatch (renderInboxList) can reuse the exact same markup for
// approval rows interleaved with project_event rows.
export function renderApprovalRow(row: ViewRow): HTMLLIElement {
const detail = (row.detail || {}) as ApprovalDetail;
const li = document.createElement("li");
li.className = "inbox-row views-approval-row";
li.dataset.requestId = row.id;
li.dataset.status = detail.status ?? "";
// Header: entity / lifecycle
const head = document.createElement("div");
head.className = "inbox-row-head";
const title = document.createElement("div");
title.className = "inbox-row-title";
const entityLabel = detail.entity_type ? t(("approvals.entity." + detail.entity_type) as I18nKey) : "";
const lifecycleLabel = detail.lifecycle_event ? t(("approvals.lifecycle." + detail.lifecycle_event) as I18nKey) : "";
const entityTitle = detail.entity_title || row.title || "—";
title.textContent = `${entityLabel}: ${entityTitle}${lifecycleLabel}`;
head.appendChild(title);
const meta = document.createElement("div");
meta.className = "inbox-row-meta";
const reqByLabel = t("approvals.requested_by");
const roleLabel = detail.required_role
? t(("approvals.required_role." + detail.required_role) as I18nKey)
: "";
const requester = detail.requester_name || row.actor_name || "";
const requesterTag = detail.requester_kind === "agent"
? `${requester}${t("approvals.agent.byline")}`
: requester;
const projectTitle = row.project_title ?? "";
const parts = [
projectTitle,
`${reqByLabel} ${requesterTag}`,
];
if (roleLabel) parts.push(`${roleLabel}+`);
parts.push(formatRelativeTime(row.event_date));
meta.textContent = parts.filter(Boolean).join(" · ");
head.appendChild(meta);
li.appendChild(head);
// Diff for update / complete
const diff = renderDiff(detail);
if (diff) li.appendChild(diff);
if (detail.decision_note) {
const note = document.createElement("div");
note.className = "inbox-row-note";
note.textContent = detail.decision_note;
li.appendChild(note);
}
// Action row — surface attaches handlers via data-attrs.
const actions = document.createElement("div");
actions.className = "inbox-row-actions";
if (detail.status === "pending") {
// All four actions are stamped on every pending row; the per-viewer
// viewer_can_approve / viewer_is_requester flags (resolved server-side)
// decide which are enabled vs. greyed out with a tooltip. m's ask
// (2026-05-17): show what's possible but disable what isn't, rather
// than alert-after-click. The server still enforces — disabled buttons
// are a UI hint, not a security gate.
//
// suggest_changes is hidden for non-update lifecycles (the backend
// returns ErrSuggestionLifecycleInvalid for create/complete/delete,
// so we don't even render the button for them).
actions.appendChild(approvalActionBtn("approve", detail));
if (detail.lifecycle_event === "update") {
actions.appendChild(approvalActionBtn("suggest_changes", detail));
}
actions.appendChild(approvalActionBtn("reject", detail));
actions.appendChild(approvalActionBtn("revoke", detail));
} else if (detail.status) {
const pill = document.createElement("span");
pill.className = "approval-pill approval-pill--historic";
pill.textContent = t(("approvals.status." + detail.status) as I18nKey);
if (detail.decider_name && detail.status !== "revoked") {
const decided = document.createElement("span");
decided.className = "inbox-row-decided";
decided.textContent = ` · ${t("approvals.decided_by")} ${detail.decider_name}`;
pill.appendChild(decided);
}
actions.appendChild(pill);
}
li.appendChild(actions);
// Back-link from the OLD changes_requested row to the NEW pending
// counter row (t-paliad-216). Hydrated server-side as
// detail.next_request_id; the surface renders a link that scrolls
// / filters to the new row. Falsy next_request_id = no link (e.g.
// older rows pre-mig-103, or rows where the server hasn't joined the
// back-pointer).
if (detail.status === "changes_requested" && detail.next_request_id) {
const link = document.createElement("a");
link.className = "inbox-row-next-request";
link.href = `#request-${detail.next_request_id}`;
link.dataset.nextRequestId = detail.next_request_id;
const deciderName = detail.decider_name || "";
link.textContent = t("approvals.suggest.next_request_link").replace("{name}", deciderName);
li.appendChild(link);
}
return li;
}
// ----------------------------------------------------------------------
// row_action = "inbox" — unified inbox layout (t-paliad-249)
//
// Dispatches per row.kind so approval_request rows reuse the existing
// approve/reject/revoke markup while project_event rows render as a
// compact stream row (timestamp + actor + title + project chip +
// Öffnen link to the underlying entity).
// ----------------------------------------------------------------------
function renderInboxList(rows: ViewRow[]): HTMLElement {
const ul = document.createElement("ul");
ul.className = "inbox-list inbox-list--unified";
for (const row of rows) {
if (row.kind === "approval_request") {
ul.appendChild(renderApprovalRow(row));
} else if (row.kind === "project_event") {
ul.appendChild(renderProjectEventInboxRow(row));
}
}
return ul;
}
interface ProjectEventDetail {
event_type?: string | null;
description?: string | null;
}
function renderProjectEventInboxRow(row: ViewRow): HTMLLIElement {
const detail = (row.detail || {}) as ProjectEventDetail;
const li = document.createElement("li");
li.className = "inbox-row inbox-row--project-event";
li.dataset.eventId = row.id;
if (detail.event_type) li.dataset.eventType = detail.event_type;
const head = document.createElement("div");
head.className = "inbox-row-head";
const title = document.createElement("div");
title.className = "inbox-row-title";
// Prefer the row.title (server-side authored, project-aware); fall
// back to a synthesised event-kind label so a malformed row never
// produces an empty <li>.
const kindLabelText = detail.event_type ? t(("event.title." + detail.event_type) as I18nKey) : "";
title.textContent = row.title || kindLabelText || "—";
head.appendChild(title);
const meta = document.createElement("div");
meta.className = "inbox-row-meta";
const parts: string[] = [];
if (row.project_title) parts.push(row.project_title);
if (row.actor_name) parts.push(row.actor_name);
parts.push(formatRelativeTime(row.event_date));
meta.textContent = parts.filter(Boolean).join(" · ");
head.appendChild(meta);
li.appendChild(head);
if (detail.description) {
const desc = document.createElement("div");
desc.className = "inbox-row-description";
desc.textContent = detail.description;
li.appendChild(desc);
}
const actions = document.createElement("div");
actions.className = "inbox-row-actions";
const openLink = projectEventLink(row, detail);
if (openLink) actions.appendChild(openLink);
li.appendChild(actions);
return li;
}
// projectEventLink builds an "Öffnen" anchor that points to the most
// useful target for the event kind. Falls back to the project detail
// page when the kind doesn't carry a richer pointer.
//
// Slice B can deepen this (e.g. note_created → scroll to note anchor);
// keep it minimal for Slice A.
function projectEventLink(row: ViewRow, detail: ProjectEventDetail): HTMLAnchorElement | null {
if (!row.project_id) return null;
const kind = detail.event_type ?? "";
const a = document.createElement("a");
a.className = "inbox-row-open";
a.textContent = t("inbox.action.open");
if (kind.startsWith("deadline_")) {
a.href = `/projects/${row.project_id}#deadlines`;
} else if (kind.startsWith("appointment_")) {
a.href = `/projects/${row.project_id}#appointments`;
} else if (kind === "note_created") {
a.href = `/projects/${row.project_id}#notes`;
} else {
a.href = `/projects/${row.project_id}`;
}
return a;
}
function renderDiff(detail: ApprovalDetail): HTMLElement | null {
const before = (detail.pre_image || {}) as Record<string, unknown>;
const after = (detail.payload || {}) as Record<string, unknown>;

View File

@@ -67,6 +67,11 @@ export interface FilterSpec {
scope: ScopeSpec;
time: TimeSpec;
predicates?: Partial<Record<DataSource, Predicates>>;
// Inbox unread-only overlay (t-paliad-249). When true, the view
// service drops project_event rows older than the caller's
// users.inbox_seen_at cursor. Pending approval_requests always
// survive — the cursor can't bury an in-flight approval.
unread_only?: boolean;
}
export type RenderShape = "list" | "cards" | "calendar" | "timeline";
@@ -79,7 +84,7 @@ export interface TimelineCVConfig {
range_to?: string;
}
export type ListRowAction = "navigate" | "complete_toggle" | "approve" | "none";
export type ListRowAction = "navigate" | "complete_toggle" | "approve" | "inbox" | "none";
export interface ListConfig {
columns?: string[];

View File

@@ -1763,9 +1763,15 @@ export type I18nKey =
| "glossar.suggest.success"
| "glossar.suggest.title"
| "glossar.title"
| "inbox.action.mark_all_seen"
| "inbox.action.open"
| "inbox.empty.admin_nudge.body"
| "inbox.empty.admin_nudge.cta"
| "inbox.empty.admin_nudge.title"
| "inbox.empty.feed"
| "inbox.heading.feed"
| "inbox.subtitle.feed"
| "inbox.title.feed"
| "index.checklisten.desc"
| "index.checklisten.title"
| "index.cost.desc"
@@ -2684,12 +2690,17 @@ export type I18nKey =
| "views.bar.deadline_status.pending"
| "views.bar.density.comfortable"
| "views.bar.density.compact"
| "views.bar.inbox_focus.alles"
| "views.bar.inbox_focus.genehmigungen"
| "views.bar.inbox_focus.plus_fristen"
| "views.bar.inbox_focus.plus_termine"
| "views.bar.label.appointment_type"
| "views.bar.label.approval_entity"
| "views.bar.label.approval_role"
| "views.bar.label.approval_status"
| "views.bar.label.deadline_status"
| "views.bar.label.density"
| "views.bar.label.inbox_focus"
| "views.bar.label.personal"
| "views.bar.label.project_event_kind"
| "views.bar.label.shape"
@@ -2697,6 +2708,7 @@ export type I18nKey =
| "views.bar.label.time"
| "views.bar.label.timeline_status"
| "views.bar.label.timeline_track"
| "views.bar.label.unread_only"
| "views.bar.personal.on"
| "views.bar.save.cancel"
| "views.bar.save.confirm"
@@ -2736,6 +2748,8 @@ export type I18nKey =
| "views.bar.timeline_track.counterclaim"
| "views.bar.timeline_track.off_script"
| "views.bar.timeline_track.parent"
| "views.bar.unread_only.off"
| "views.bar.unread_only.on"
| "views.calendar.mobile_fallback"
| "views.col.actor"
| "views.col.appointment_type"

View File

@@ -5,15 +5,14 @@ import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// /inbox — t-paliad-163 universal-filter migration.
// /inbox — t-paliad-249 unified inbox feed.
//
// The page is a thin shell around two host divs: one for the
// <FilterBar> primitive and one for the result list. The bar takes
// care of every axis (approval_viewer_role chip cluster replaces the
// two-tab UI; status / entity_type / time chips are new affordances).
// Rows render via shape-list.ts with row_action="approve" — the
// inbox-specific markup that produces the diff + approve/reject/revoke
// buttons. Action handlers are wired in client/inbox.ts.
// Since t-paliad-249 the page is a thin shell around the FilterBar +
// result list as before, but the InboxSystemView now spans both
// approval_request and project_event sources. Rows render via
// shape-list.ts's row_action="inbox" dispatch — approval rows keep
// the existing diff + approve/reject/revoke markup, project_event
// rows render as compact stream items.
//
// The legacy `?tab=` URL is preserved by the client: ?tab=mine maps
// to ?a_role=self_requested before the bar mounts so old bookmarks
@@ -28,7 +27,7 @@ export function renderInbox(): string {
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<PWAHead />
<title data-i18n="approvals.title">Genehmigungen &mdash; Paliad</title>
<title data-i18n="inbox.title.feed">Inbox &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
@@ -39,10 +38,24 @@ export function renderInbox(): string {
<section className="tool-page">
<div className="container">
<div className="tool-header">
<h1 data-i18n="approvals.heading">Genehmigungen</h1>
<p className="tool-subtitle" data-i18n="approvals.subtitle">
4-Augen-Pr&uuml;fung f&uuml;r Fristen und Termine.
</p>
<div className="entity-header-row">
<div>
<h1 data-i18n="inbox.heading.feed">Inbox</h1>
<p className="tool-subtitle" data-i18n="inbox.subtitle.feed">
Neuigkeiten zu Ihren Projekten und offene Genehmigungen.
</p>
</div>
<div className="inbox-header-actions">
<button
type="button"
id="inbox-mark-all-seen"
className="btn-secondary"
data-i18n="inbox.action.mark_all_seen"
>
Alles als gelesen markieren
</button>
</div>
</div>
</div>
<div id="inbox-filter-bar" />

View File

@@ -0,0 +1,4 @@
-- t-paliad-249 — drop inbox read cursor.
ALTER TABLE paliad.users
DROP COLUMN IF EXISTS inbox_seen_at;

View File

@@ -0,0 +1,21 @@
-- t-paliad-249 — /inbox overhaul, Slice A.
-- Add a per-user high-watermark read cursor for the inbox feed
-- (approval requests + curated project_events). The cursor advances
-- only when the user POSTs to /api/inbox/mark-all-seen. NULL means
-- "never visited" → every row counts as unread on first paint.
--
-- Note on the carve-out enforced in service code: pending
-- approval_requests count toward the inbox's unread state regardless
-- of this column. The cursor narrows the project_event source only,
-- so a stale cursor never buries a high-value pending approval.
--
-- Design ref: docs/design-inbox-overhaul-2026-05-25.md §3.
ALTER TABLE paliad.users
ADD COLUMN IF NOT EXISTS inbox_seen_at timestamptz NULL;
COMMENT ON COLUMN paliad.users.inbox_seen_at IS
'High-watermark cursor for the /inbox feed. project_events newer '
'than this timestamp are unread for the caller; NULL = never '
'visited (everything unread). Pending approval_requests bypass '
'this column and stay unread until decided.';

View File

@@ -25,6 +25,7 @@ import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/google/uuid"
@@ -221,6 +222,16 @@ func handleListInboxMine(w http.ResponseWriter, r *http.Request) {
}
// GET /api/inbox/count — bell badge count for the sidebar.
//
// Since t-paliad-249 (Slice A) the count is the **unified** unread
// count: pending approval requests (regardless of cursor) +
// curated project_events (InboxProjectEventKinds) on visible
// projects whose created_at is newer than users.inbox_seen_at. See
// ApprovalService.UnseenInboxCountForUser for the contract.
//
// The legacy approval-only count is still reachable via
// PendingCountForUser inside the dashboard widget — that path
// doesn't go through this endpoint.
func handleInboxCount(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
@@ -229,7 +240,7 @@ func handleInboxCount(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
n, err := dbSvc.approval.PendingCountForUser(r.Context(), uid)
n, err := dbSvc.approval.UnseenInboxCountForUser(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
@@ -237,6 +248,57 @@ func handleInboxCount(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]int{"count": n})
}
// POST /api/inbox/mark-all-seen — advance the caller's inbox read
// cursor (paliad.users.inbox_seen_at). Optional body
// `{"up_to": "<iso8601>"}` pins the advance to the timestamp of the
// newest row the client actually saw — handy when a second tab made
// the inbox grow between the read and the click. Missing body =>
// advance to now().
//
// Returns the new cursor as `{"inbox_seen_at": "<iso8601>"}` so the
// client can keep its local state in sync.
func handleInboxMarkAllSeen(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var body struct {
UpTo string `json:"up_to"`
}
if r.Body != nil && r.ContentLength > 0 {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
}
var upTo time.Time
if body.UpTo != "" {
parsed, err := time.Parse(time.RFC3339Nano, body.UpTo)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "up_to must be RFC3339"})
return
}
upTo = parsed
}
if err := dbSvc.approval.MarkInboxSeen(r.Context(), uid, upTo); err != nil {
writeServiceError(w, err)
return
}
cur, err := dbSvc.approval.InboxSeenAt(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
resp := map[string]any{}
if cur != nil {
resp["inbox_seen_at"] = cur.UTC().Format(time.RFC3339Nano)
}
writeJSON(w, http.StatusOK, resp)
}
// parseInboxFilter pulls common filter knobs off the query string.
//
// Status / EntityType pass through validation: an unrecognised value is

View File

@@ -671,6 +671,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /api/inbox/pending-mine", handleListInboxPendingMine)
protected.HandleFunc("GET /api/inbox/mine", handleListInboxMine)
protected.HandleFunc("GET /api/inbox/count", handleInboxCount)
protected.HandleFunc("POST /api/inbox/mark-all-seen", handleInboxMarkAllSeen)
protected.HandleFunc("GET /api/approval-requests/{id}", handleGetApprovalRequest)
protected.HandleFunc("POST /api/approval-requests/{id}/approve", handleApproveApprovalRequest)
protected.HandleFunc("POST /api/approval-requests/{id}/reject", handleRejectApprovalRequest)

View File

@@ -1607,6 +1607,95 @@ func (s *ApprovalService) PendingCountForUser(ctx context.Context, callerID uuid
return n, nil
}
// UnseenInboxCountForUser returns the unified inbox badge count
// (t-paliad-249, Slice A):
//
// - pending approval_requests where the caller is qualified to
// approve (same predicate as PendingCountForUser); these count
// regardless of users.inbox_seen_at — pending approvals never
// fall behind the cursor.
// - project_events with event_type ∈ InboxProjectEventKinds whose
// created_at > users.inbox_seen_at (NULL cursor → every row is
// unseen) on visible projects, EXCLUDING the caller's own events
// (you don't get notified about your own actions) and excluding
// the `*_approval_*` audit duplicates of approval_request rows.
//
// One round-trip; UNION ALL across two SELECTs so the two halves can
// use their own indexes.
func (s *ApprovalService) UnseenInboxCountForUser(ctx context.Context, callerID uuid.UUID) (int, error) {
inboxKinds := InboxProjectEventKinds
q := `SELECT COALESCE(SUM(c), 0) FROM (
SELECT COUNT(*) AS c
FROM paliad.approval_requests ar
JOIN paliad.projects p ON p.id = ar.project_id
WHERE ar.status = 'pending'
AND ar.requested_by <> $1
AND ` + approvalEligibilitySQL + `
UNION ALL
SELECT COUNT(*) AS c
FROM paliad.project_events pe
JOIN paliad.projects p ON p.id = pe.project_id
LEFT JOIN paliad.users u ON u.id = $1
WHERE pe.event_type = ANY($2)
AND (pe.created_by IS DISTINCT FROM $1)
AND (u.inbox_seen_at IS NULL OR pe.created_at > u.inbox_seen_at)
AND ` + visibilityPredicatePositional("p", 1) + `
) sub`
var n int
if err := s.db.GetContext(ctx, &n, q, callerID, pq.Array(inboxKinds)); err != nil {
return 0, fmt.Errorf("unseen inbox count: %w", err)
}
return n, nil
}
// MarkInboxSeen advances the caller's inbox read cursor.
// If `upTo` is zero, advances to now(); otherwise advances to upTo
// (used by the client to pin to the newest visible row so a stray
// second tab doesn't lose items between the read and the click).
//
// The cursor only moves forward — calls with upTo < current are
// no-ops so a stale tab can't rewind.
func (s *ApprovalService) MarkInboxSeen(ctx context.Context, callerID uuid.UUID, upTo time.Time) error {
var (
q string
args []any
)
if upTo.IsZero() {
q = `UPDATE paliad.users
SET inbox_seen_at = now()
WHERE id = $1
AND (inbox_seen_at IS NULL OR inbox_seen_at < now())`
args = []any{callerID}
} else {
q = `UPDATE paliad.users
SET inbox_seen_at = $2
WHERE id = $1
AND (inbox_seen_at IS NULL OR inbox_seen_at < $2)`
args = []any{callerID, upTo.UTC()}
}
if _, err := s.db.ExecContext(ctx, q, args...); err != nil {
return fmt.Errorf("mark inbox seen: %w", err)
}
return nil
}
// InboxSeenAt returns the caller's current inbox read cursor, or nil
// if the user has never marked the inbox as seen. Used by the inbox
// run path to overlay the unread_only predicate (t-paliad-249, §3 of
// the design doc).
func (s *ApprovalService) InboxSeenAt(ctx context.Context, callerID uuid.UUID) (*time.Time, error) {
var t sql.NullTime
q := `SELECT inbox_seen_at FROM paliad.users WHERE id = $1`
if err := s.db.GetContext(ctx, &t, q, callerID); err != nil {
return nil, fmt.Errorf("inbox seen lookup: %w", err)
}
if !t.Valid {
return nil, nil
}
v := t.Time
return &v, nil
}
// ============================================================================
// Policy CRUD — paliad.approval_policies (t-paliad-138 + t-paliad-154).
//

View File

@@ -44,12 +44,20 @@ var AllSources = []DataSource{
const SpecVersion = 1
// FilterSpec is the top-level filter description.
//
// UnreadOnly (t-paliad-249) is an inbox-specific overlay: when true,
// project_event rows older than the caller's inbox_seen_at cursor are
// dropped. Pending approval_request rows always survive (the cursor
// can't bury an in-flight approval, per the design doc §3 carve-out).
// Set by the bar's `unread_only` axis on /inbox; other surfaces leave
// it false and the spec is a no-op.
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"`
UnreadOnly bool `json:"unread_only,omitempty"`
}
// ScopeSpec narrows which projects contribute rows. Resolved at query
@@ -192,11 +200,58 @@ var KnownProjectEventKinds = []string{
"deadline_created",
"deadline_completed",
"deadline_reopened",
"deadline_updated",
"deadline_deleted",
"deadlines_imported",
"appointment_created",
"appointment_updated",
"appointment_deleted",
"approval_decided",
"member_role_changed",
"note_created",
"our_side_changed",
}
// InboxProjectEventKinds is the curated sub-list surfaced by default on
// /inbox (t-paliad-249, Slice A; head pick Q1=A on 2026-05-25).
//
// What's in:
// - Lifecycle moves the team should notice: project_archived,
// project_reparented, project_type_changed.
// - Deadline / appointment authoring across the visible scope.
// - Notes (`note_created`) and party-side flips
// (`our_side_changed`).
// - `member_role_changed` — Slice A surfaces it for everyone who can
// see the project; Slice B narrows to "role change affects the
// viewer or someone above them in the project tree" (head's
// refinement #1).
//
// What's out:
// - All `*_approval_*` event_types — duplicates of approval_request
// rows. View-service drops them automatically when ApprovalRequest
// is also in spec.Sources (see view_service.allowedProjectEventKinds).
// - `status_changed`, `project_created` — too granular / authoring
// noise.
// - `checklist_*` — low signal; surfaces on the project's checklist
// tab instead.
//
// Design ref: docs/design-inbox-overhaul-2026-05-25.md §2 + §12.
var InboxProjectEventKinds = []string{
"project_archived",
"project_reparented",
"project_type_changed",
"deadline_created",
"deadline_completed",
"deadline_reopened",
"deadline_updated",
"deadline_deleted",
"deadlines_imported",
"appointment_created",
"appointment_updated",
"appointment_deleted",
"note_created",
"our_side_changed",
"member_role_changed",
}
// validApprovalStatuses are the legal values for entity-side approval_status

View File

@@ -124,6 +124,7 @@ const (
RowActionNavigate ListRowAction = "navigate"
RowActionCompleteToggle ListRowAction = "complete_toggle"
RowActionApprove ListRowAction = "approve"
RowActionInbox ListRowAction = "inbox"
RowActionNone ListRowAction = "none"
)
@@ -134,6 +135,7 @@ var KnownRowActions = []ListRowAction{
RowActionNavigate,
RowActionCompleteToggle,
RowActionApprove,
RowActionInbox,
RowActionNone,
}

View File

@@ -100,40 +100,48 @@ func EventsSystemView() SystemView {
}
}
// InboxSystemView returns the SystemView definition for /inbox — the
// 4-eye approval surface. The bar's approval_viewer_role chip
// cluster narrows to "Zur Genehmigung" / "Eigene Anfragen" /
// "Alle sichtbaren". Default is "any_visible" so the page lands on
// a populated view for every user (m's 2026-05-08 22:08 dogfood:
// "the inbox somehow does not show nothing no more" — the prior
// default was approver_eligible, which is empty for users who only
// SUBMIT requests and have nothing to approve themselves).
// InboxSystemView returns the SystemView definition for /inbox.
//
// RowAction = RowActionApprove → shape-list.ts renders the approval
// row layout (entity title + diff + approve/reject/revoke buttons)
// and the surface wires action handlers via the rendered data-attrs.
// t-paliad-249 (Slice A, 2026-05-25) widened the inbox from
// approval-requests-only to a project-events feed PLUS approval
// requests. Sources is [ApprovalRequest, ProjectEvent]; the project
// rail is narrowed to InboxProjectEventKinds (curated set, head pick
// Q1=A). The `*_approval_*` audit events are de-duplicated against
// the approval_request rows by view_service.allowedProjectEventKinds.
//
// Time window defaults to last 30 days; the bar's time-axis chip
// can widen or narrow. Sort is newest-first — different from the
// pre-249 ascending default; m's inbox metaphor is "what just
// happened", not "what's coming up".
//
// RowAction = RowActionInbox → shape-list.ts dispatches per
// row.kind: approval rows get the approve/reject/revoke layout,
// project_event rows get a navigate-style stream row.
func InboxSystemView() SystemView {
return SystemView{
Slug: "inbox",
Name: "Inbox",
Filter: FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceApprovalRequest},
Sources: []DataSource{SourceApprovalRequest, SourceProjectEvent},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonAny, Field: FieldAuto},
Time: TimeSpec{Horizon: HorizonPast30d, Field: FieldAuto},
Predicates: map[DataSource]Predicates{
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
ViewerRole: "any_visible",
Status: []string{"pending"},
}},
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{
EventTypes: InboxProjectEventKinds,
}},
},
},
Render: RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Density: DensityComfortable,
Sort: SortDateAsc,
RowAction: RowActionApprove,
Sort: SortDateDesc,
RowAction: RowActionInbox,
},
},
}

View File

@@ -1,6 +1,9 @@
package services
import "testing"
import (
"slices"
"testing"
)
// Pure-Go tests for the SystemView registry. Each system view's specs
// must self-validate; the slugs must be reserved.
@@ -45,3 +48,63 @@ func TestReservedSlugs_NonReservedAccepted(t *testing.T) {
}
}
}
// ----------------------------------------------------------------------
// InboxSystemView shape — t-paliad-249
// ----------------------------------------------------------------------
func TestInboxSystemView_Sources(t *testing.T) {
sv := InboxSystemView()
if !slices.Contains(sv.Filter.Sources, SourceApprovalRequest) {
t.Errorf("InboxSystemView must include SourceApprovalRequest, got %v", sv.Filter.Sources)
}
if !slices.Contains(sv.Filter.Sources, SourceProjectEvent) {
t.Errorf("InboxSystemView must include SourceProjectEvent, got %v", sv.Filter.Sources)
}
}
func TestInboxSystemView_DefaultsToPast30d(t *testing.T) {
sv := InboxSystemView()
if sv.Filter.Time.Horizon != HorizonPast30d {
t.Errorf("default horizon must be past_30d, got %q", sv.Filter.Time.Horizon)
}
}
func TestInboxSystemView_RowActionInbox(t *testing.T) {
sv := InboxSystemView()
if sv.Render.List == nil {
t.Fatal("InboxSystemView must define a list config")
}
if sv.Render.List.RowAction != RowActionInbox {
t.Errorf("row_action must be inbox, got %q", sv.Render.List.RowAction)
}
}
func TestInboxSystemView_CuratedProjectEventKinds(t *testing.T) {
sv := InboxSystemView()
preds := sv.Filter.Predicates[SourceProjectEvent]
if preds.ProjectEvent == nil {
t.Fatal("InboxSystemView must narrow project_event predicates")
}
got := preds.ProjectEvent.EventTypes
if len(got) != len(InboxProjectEventKinds) {
t.Errorf("expected %d curated kinds, got %d", len(InboxProjectEventKinds), len(got))
}
for _, k := range got {
if slices.Contains([]string{"status_changed", "project_created"}, k) {
t.Errorf("inbox must NOT include noisy kind %q", k)
}
// No *_approval_* audit duplicates either — view_service dedups
// at query time but the curated list shouldn't carry them.
if isApprovalAuditKind(k) {
t.Errorf("inbox curated list must not include audit-dup %q", k)
}
}
}
func TestInboxSystemView_NewestFirst(t *testing.T) {
sv := InboxSystemView()
if sv.Render.List == nil || sv.Render.List.Sort != SortDateDesc {
t.Errorf("inbox must sort newest-first by default, got %q", sv.Render.List.Sort)
}
}

View File

@@ -83,6 +83,15 @@ func (s *EventService) RunSpec(ctx context.Context, userID uuid.UUID, spec Filte
rows := make([]ViewRow, 0, 256)
bounds := computeViewSpecBounds(time.Now().UTC(), spec.Time)
if spec.UnreadOnly && approval != nil {
cursor, err := approval.InboxSeenAt(ctx, userID)
if err != nil {
return nil, fmt.Errorf("inbox cursor lookup: %w", err)
}
bounds.unreadOnly = true
bounds.inboxSeenAt = cursor
}
for _, src := range spec.Sources {
switch src {
case SourceDeadline:
@@ -148,9 +157,16 @@ func (s *EventService) RunSpec(ctx context.Context, userID uuid.UUID, spec Filte
// viewSpecBounds carries the resolved [from, to) window the spec
// translates into. Either bound can be nil (open-ended).
//
// inboxSeenAt is set by RunSpec when spec.UnreadOnly=true: the caller's
// users.inbox_seen_at cursor pre-resolved once so each source-runner can
// overlay it without re-querying the users table. nil means "never
// visited" — every row is unread.
type viewSpecBounds struct {
from *time.Time
to *time.Time
from *time.Time
to *time.Time
unreadOnly bool
inboxSeenAt *time.Time
}
func computeViewSpecBounds(now time.Time, ts TimeSpec) viewSpecBounds {
@@ -345,6 +361,11 @@ func (s *EventService) runAppointments(ctx context.Context, userID uuid.UUID, sp
// 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.
//
// Inbox semantics (t-paliad-249) kick in when bounds.unreadOnly is set:
// the caller's own events are excluded (you don't notify yourself) and
// rows older than bounds.inboxSeenAt are dropped. Cursor lookup happens
// once in RunSpec — runProjectEvents only consumes the resolved value.
func (s *EventService) runProjectEvents(ctx context.Context, userID uuid.UUID, spec FilterSpec, bounds viewSpecBounds) ([]ViewRow, error) {
conds := []string{visibilityPredicatePositional("p", 1)}
args := []any{userID}
@@ -366,6 +387,14 @@ func (s *EventService) runProjectEvents(ctx context.Context, userID uuid.UUID, s
args = append(args, spec.Scope.Projects.IDs)
conds = append(conds, fmt.Sprintf("pe.project_id = ANY($%d)", len(args)))
}
if bounds.unreadOnly {
// Inbox mode: hide the caller's own actions (no self-notify).
conds = append(conds, "pe.created_by IS DISTINCT FROM $1")
if bounds.inboxSeenAt != nil {
args = append(args, *bounds.inboxSeenAt)
conds = append(conds, fmt.Sprintf("pe.created_at > $%d", len(args)))
}
}
q := `
SELECT pe.id, pe.project_id, pe.event_type, pe.title, pe.description,
@@ -520,6 +549,15 @@ func (s *EventService) runApprovalRequests(ctx context.Context, userID uuid.UUID
continue
}
// Inbox unread-only carve-out (t-paliad-249, design §3):
// pending requests always survive; decided rows are subject to
// the cursor like any other audit-style item.
if bounds.unreadOnly && r.Status != RequestStatusPending {
if bounds.inboxSeenAt != nil && !eventDate.After(*bounds.inboxSeenAt) {
continue
}
}
title := approvalRowTitle(r)
subtitle := approvalRowSubtitle(r)
detail, _ := json.Marshal(r) // request view already carries everything the UI needs
@@ -646,15 +684,46 @@ func allowedAppointmentTypes(spec FilterSpec) map[string]bool {
// allowedProjectEventKinds returns the slice of project_event.event_type
// values the spec narrows to, or nil for "all known kinds".
//
// Inbox de-dup (t-paliad-249): when the spec also fans out
// SourceApprovalRequest, every `*_approval_*` audit event is dropped
// — the approval_request row itself is the canonical signal, and we
// don't want both rows showing up side-by-side. The drop applies to
// both the explicit caller list and the implicit "all kinds" path.
func allowedProjectEventKinds(spec FilterSpec) []string {
preds, ok := spec.Predicates[SourceProjectEvent]
if !ok || preds.ProjectEvent == nil {
dedupApprovals := slices.Contains(spec.Sources, SourceApprovalRequest)
var requested []string
switch {
case ok && preds.ProjectEvent != nil && len(preds.ProjectEvent.EventTypes) > 0:
requested = preds.ProjectEvent.EventTypes
case dedupApprovals:
// No explicit narrowing, but ApprovalRequest is in sources —
// rebuild the implicit "all" list so we can subtract approvals.
requested = KnownProjectEventKinds
default:
return nil
}
if len(preds.ProjectEvent.EventTypes) == 0 {
return nil
if !dedupApprovals {
return requested
}
return preds.ProjectEvent.EventTypes
filtered := make([]string, 0, len(requested))
for _, k := range requested {
if isApprovalAuditKind(k) {
continue
}
filtered = append(filtered, k)
}
return filtered
}
// isApprovalAuditKind matches the `*_approval_*` audit event_types that
// every approval mutation emits alongside the approval_request row.
// Dropped from inbox project_event reads (see allowedProjectEventKinds).
func isApprovalAuditKind(kind string) bool {
return strings.Contains(kind, "_approval_") || kind == "approval_decided"
}
// allowedRequestStatuses returns nil for "no narrowing" (or "single value

View File

@@ -0,0 +1,111 @@
package services
import (
"slices"
"testing"
)
// t-paliad-249 — Slice A. Ensures the *_approval_* audit kinds are
// dropped from the project_event read path when ApprovalRequest is
// also fanning out, so the same fact isn't shown twice in /inbox.
func TestAllowedProjectEventKinds_DedupsApprovalAudits(t *testing.T) {
spec := FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceApprovalRequest, SourceProjectEvent},
Predicates: map[DataSource]Predicates{
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{
EventTypes: []string{
"deadline_created",
"deadline_approval_requested",
"appointment_approval_approved",
"approval_decided",
"note_created",
},
}},
},
}
got := allowedProjectEventKinds(spec)
if slices.Contains(got, "deadline_approval_requested") {
t.Errorf("must drop deadline_approval_requested, got %v", got)
}
if slices.Contains(got, "appointment_approval_approved") {
t.Errorf("must drop appointment_approval_approved, got %v", got)
}
if slices.Contains(got, "approval_decided") {
t.Errorf("must drop approval_decided, got %v", got)
}
if !slices.Contains(got, "deadline_created") {
t.Errorf("must keep deadline_created, got %v", got)
}
if !slices.Contains(got, "note_created") {
t.Errorf("must keep note_created, got %v", got)
}
}
func TestAllowedProjectEventKinds_NoDedupWhenApprovalsAbsent(t *testing.T) {
spec := FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceProjectEvent},
Predicates: map[DataSource]Predicates{
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{
EventTypes: []string{
"deadline_created",
"deadline_approval_requested",
},
}},
},
}
got := allowedProjectEventKinds(spec)
if !slices.Contains(got, "deadline_approval_requested") {
t.Errorf("Verlauf-style spec without approvals source should keep audit kinds, got %v", got)
}
}
func TestAllowedProjectEventKinds_NilWhenNoPredicateAndNoDedup(t *testing.T) {
spec := FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceProjectEvent},
}
if got := allowedProjectEventKinds(spec); got != nil {
t.Errorf("expected nil (all kinds), got %v", got)
}
}
func TestAllowedProjectEventKinds_FillsImplicitListWhenDedup(t *testing.T) {
// When approvals are in sources but the caller didn't explicitly
// narrow EventTypes, the helper must materialise the curated set
// minus audit duplicates so the WHERE clause filters them out.
spec := FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceApprovalRequest, SourceProjectEvent},
}
got := allowedProjectEventKinds(spec)
if got == nil {
t.Fatal("expected explicit kind list, got nil")
}
for _, k := range got {
if isApprovalAuditKind(k) {
t.Errorf("audit kind %q leaked through dedup", k)
}
}
}
func TestIsApprovalAuditKind(t *testing.T) {
cases := map[string]bool{
"deadline_approval_requested": true,
"appointment_approval_approved": true,
"appointment_approval_rejected": true,
"deadline_approval_changes_suggested": true,
"approval_decided": true,
"deadline_created": false,
"note_created": false,
"our_side_changed": false,
"project_archived": false,
}
for kind, want := range cases {
if got := isApprovalAuditKind(kind); got != want {
t.Errorf("isApprovalAuditKind(%q) = %v, want %v", kind, got, want)
}
}
}