# notification-inbox

## Overview

The Notification Inbox is the per-user, recipient-scoped surface that exposes a user's own Notification records as an in-app feed. It owns the read-side queries (paged feed ordered by `createdAt` descending, plus an unread count) and the recipient-driven mutations on the `engagementStatuses` set (the second axis of the Notification lifecycle, see notification-delivery for the two-axis model). Two distinct user-engagement signals are tracked: **SEEN** is a passive addition applied as a side-effect when the inbox feed is opened (the user has had the rows surfaced to them), while **READ** is an explicit addition recorded only when the user clicks a specific notification or invokes mark-all-read. SEEN does not decrement the unread count; only READ does. **ARCHIVED** is the user-driven hide signal that removes a row from the default feed regardless of its prior read state. Members are added independently and never removed — a row may carry any subset of `{SEEN, READ, ARCHIVED}` simultaneously, and adding `READ` always implies `SEEN` (the inbox feature ensures `SEEN` is present alongside `READ`).

**The inbox is the IN_APP surface only.** Every feed, unread-count, and bulk predicate in this feature carries an implicit `channel is IN_APP` filter: rows dispatched on other channels (e.g. EMAIL, which terminates at `SENT`) are delivered by their adapter and never appear in the feed, never count toward the unread badge, and are never touched by mark-all-read — otherwise EMAIL rows would inflate the badge with no way to clear it.

**Real-time updates are an application-layer concern.** This feature exposes pull-style queries only (feed + unread count); it does not own a push surface. How the badge and feed refresh without a manual reload — polling cadence, websocket/SSE fan-out, or CDC on the `Notification` table — is decided and built by the host application; the module guarantees only that every feed/count query reflects the persisted state at query time.

**Not a work queue.** The inbox is an informational feed of "what happened", distinct from actionable work-queue surfaces such as the approval module's "My Inbox" (`listApprovalRequestsForApprover`). A host app composing both modules should keep the two surfaces separate: an approval-assignment event fanned out through this module produces an informational row here *in addition to* the actionable item in the approval queue, and reading or archiving the notification has no effect on the approval request's state (and vice versa).

A user may only see and transition their own Notifications; every inbox operation enforces `Notification.recipientUserId == caller.userId`, and a Notification owned by another user is reported exactly like a missing one (`NOTIFICATION_NOT_FOUND` / null) so foreign notification ids cannot be enumerated (no existence oracle). Operator-owned `deliveryStatus` values are excluded from the inbox surface entirely: rows whose `deliveryStatus IN {QUEUED, FAILED, BOUNCED}` (not yet dispatched, dispatcher-side error, or async bounce — owned by `notification-delivery` and the delivery audit) never appear in the feed and are not user-actionable here. The default feed shows rows where `channel is IN_APP`, `deliveryStatus IN {SENT, DELIVERED}`, and `ARCHIVED ∉ engagementStatuses`. This keeps the inbox a clean engagement surface for the recipient and leaves operational triage to delivery audit tooling.

## Business Purpose

- Provide each user with a single, unified in-app feed of every notification routed to them across all source modules (announcements, tasks, RFQs, POs, invoices) without each transactional module growing its own inbox surface
- Distinguish passive surfacing (`SEEN` membership) from explicit engagement (`READ` membership) so the unread count badge reflects only items the user has not yet acted on, matching the standard Knock / Odoo / SAP inbox pattern
- Let users dismiss handled notifications by adding `ARCHIVED` to keep the active feed focused on outstanding items, while preserving archived rows for retrieval and audit
- Support a mark-all-read bulk action so users with backlogs can clear the unread count in one operation without clicking through every row
- Strictly enforce recipient-scoping so users cannot see or mutate another user's notifications, regardless of organizational role
- Keep operator-owned `deliveryStatus` values (QUEUED, FAILED, BOUNCED) out of the user-facing feed so recipients are not exposed to dispatch internals and so delivery triage stays in the audit surface

## Process Flow

```mermaid
flowchart TD
    A[User opens inbox feed] --> B[Filter Notifications by recipientUserId == caller.userId AND channel is IN_APP]
    B --> C[Exclude rows where deliveryStatus IN QUEUED, FAILED, BOUNCED, OR engagementStatuses contains ARCHIVED]
    C --> D[Order by createdAt desc, apply paging]
    D --> E{Any rows where SEEN not in engagementStatuses?}
    E -->|Yes| F[Bulk add SEEN to engagementStatuses on those rows, stamp seenAt]
    E -->|No| G[Skip auto-mark]
    F --> H[Compute unreadCount: rows where channel is IN_APP AND deliveryStatus IN SENT, DELIVERED AND READ not in engagementStatuses AND ARCHIVED not in engagementStatuses]
    G --> H
    H --> I[Return paged feed and unreadCount]

    J[User clicks notification] --> K{Row exists AND recipientUserId == caller.userId?}
    K -->|No, missing or foreign-owned| L[Error: NOTIFICATION_NOT_FOUND]
    K -->|Yes| M{READ already in engagementStatuses OR ARCHIVED already in engagementStatuses?}
    M -->|Yes| N[Idempotent no-op, return current row]
    M -->|No| O[Add READ and SEEN to engagementStatuses, stamp readAt and seenAt if absent]
    O --> P[Return updated row]

    Q[User invokes mark-all-read] --> R[Filter recipientUserId == caller.userId AND channel is IN_APP AND deliveryStatus IN SENT, DELIVERED AND READ not in engagementStatuses AND ARCHIVED not in engagementStatuses]
    R --> S[Bulk add READ and SEEN to engagementStatuses on matching rows, stamp readAt]
    S --> T[Return count of transitioned rows]

    U[User archives notification] --> V{Row exists AND recipientUserId == caller.userId?}
    V -->|No, missing or foreign-owned| L
    V -->|Yes| W{ARCHIVED already in engagementStatuses?}
    W -->|Yes| X[Idempotent no-op, return current row]
    W -->|No| Y[Add ARCHIVED to engagementStatuses, stamp archivedAt]
    Y --> Z[Row removed from default feed, return updated row]
```

## Scenario Patterns

- **First feed open with unread backlog**: A user with N IN_APP Notifications where `deliveryStatus IN {SENT, DELIVERED}` and no `SEEN` in `engagementStatuses` opens the inbox; all N rows have `SEEN` added as a side-effect, the feed returns those N rows plus any prior already-SEEN/READ rows in `createdAt desc` order, and `unreadCount` equals N (`SEEN` is not `READ` — the badge only clears when the user explicitly reads or archives)
- **Subsequent feed open with no new arrivals**: The same user re-opens the inbox; every row already has `SEEN`, so the auto-add is a no-op, and the feed returns the same content with `unreadCount` unchanged
- **Click-through read**: User clicks a single notification; `READ` is added to `engagementStatuses` (and `SEEN` if absent), `readAt` is stamped, and `unreadCount` decrements by one on the next feed query
- **Mark-all-read**: User invokes mark-all-read; every **IN_APP** row where `deliveryStatus IN {SENT, DELIVERED}` and `READ ∉ engagementStatuses` and `ARCHIVED ∉ engagementStatuses` for that recipient has `READ` (and `SEEN`) added in one bulk operation, `unreadCount` becomes zero, and rows already containing `READ` or `ARCHIVED` — and rows dispatched on other channels (e.g. EMAIL) — are not touched
- **Archive an unread notification**: User archives a row that does not yet contain `READ`; `ARCHIVED` is added to `engagementStatuses` (note: `READ` is not auto-added, but the row is removed from the default feed and excluded from `unreadCount` because the unread predicate also excludes ARCHIVED)
- **Archive a read notification**: User archives a row already containing `READ`; `ARCHIVED` is added so `engagementStatuses` becomes `{SEEN, READ, ARCHIVED}`; the row is removed from the default feed
- **Cross-user mark attempt**: User A attempts to mark or archive a Notification owned by User B; rejected with NOTIFICATION_NOT_FOUND — indistinguishable from a missing id, so existence cannot be probed — with no state change
- **Pre-dispatch invisibility**: A Notification with `deliveryStatus = QUEUED` (created by `notification-delivery` but not yet sent) is excluded from the feed and not counted in `unreadCount`
- **Failed-delivery invisibility**: A Notification with `deliveryStatus IN {FAILED, BOUNCED}` is excluded from the feed and not counted; these are owned by delivery audit and never surfaced as user-actionable inbox rows
- **Idempotent mark-read**: Calling mark-read on a Notification already containing `READ` returns the existing row without re-stamping `readAt` or emitting state-change side-effects
- **Idempotent archive**: Calling archive on a Notification already containing `ARCHIVED` returns the existing row without re-stamping `archivedAt`
- **Empty inbox**: A user with no Notifications (or whose preferences caused all upstream events to be suppressed) sees an empty feed with `unreadCount` equal to zero
- **Mixed-engagement feed listing**: The default feed returns rows with any subset of `{SEEN, READ}` in `engagementStatuses` interleaved by `createdAt desc`; rows containing `ARCHIVED` are excluded from the default feed and are retrievable only via an explicit archived filter

## Test Cases

- Listing the inbox feed for a user returns only IN_APP-channel Notifications where `recipientUserId` matches the caller; rows owned by other users are not present
- Listing the inbox feed excludes rows where `deliveryStatus IN {QUEUED, FAILED, BOUNCED}` or `ARCHIVED ∈ engagementStatuses` by default
- Listing the inbox feed excludes rows dispatched on non-IN_APP channels (e.g. EMAIL) regardless of their `deliveryStatus`
- Listing the inbox feed orders rows by `createdAt` descending and respects `limit` / `offset` (or cursor) paging parameters
- Opening the inbox feed adds `SEEN` to `engagementStatuses` for every caller-owned IN_APP row where `deliveryStatus IN {SENT, DELIVERED}` and `SEEN ∉ engagementStatuses`, and stamps `seenAt`
- Opening the inbox feed when every visible row already contains `SEEN` performs no state changes (idempotent no-op for the SEEN auto-add)
- The SEEN auto-add does not change `unreadCount`: a feed first-opened with N rows lacking SEEN still reports `unreadCount == N` after the SEEN auto-add
- `unreadCount` returned by the feed equals the number of caller-owned IN_APP-channel Notifications where `deliveryStatus IN {SENT, DELIVERED}` and `READ ∉ engagementStatuses` and `ARCHIVED ∉ engagementStatuses`
- `unreadCount` excludes rows where `READ ∈ engagementStatuses` or `ARCHIVED ∈ engagementStatuses` or `deliveryStatus IN {QUEUED, FAILED, BOUNCED}` or the channel is not IN_APP (e.g. EMAIL rows terminal at `SENT` never count toward the badge, so mark-all-read can always zero it)
- Marking a single notification (with `READ ∉ engagementStatuses` and `ARCHIVED ∉ engagementStatuses`) as read adds `READ` (and `SEEN` if absent) to `engagementStatuses`, stamps `readAt`, and decrements `unreadCount` by one on the next feed query
- Marking a notification already containing `READ` returns the existing row without modifying `readAt` (idempotent)
- Marking a notification containing `ARCHIVED` returns the row unchanged (idempotent — archived rows are terminal for engagement writes, even when `READ` is absent)
- Marking another user's Notification as read fails with NOTIFICATION_NOT_FOUND (same response as a missing id) and does not modify the row
- Mark-all-read adds `READ` (and `SEEN` if absent) to every caller-owned IN_APP row where `deliveryStatus IN {SENT, DELIVERED}` and `READ ∉ engagementStatuses` and `ARCHIVED ∉ engagementStatuses`, and stamps `readAt` on each
- Mark-all-read does not modify rows already containing `READ` or `ARCHIVED`, rows where `deliveryStatus IN {QUEUED, FAILED, BOUNCED}`, or rows dispatched on non-IN_APP channels (e.g. EMAIL)
- Mark-all-read invoked on an already-empty unread set is a no-op and returns a transitioned-row count of zero
- Archiving a notification with no prior `READ` adds `ARCHIVED` to `engagementStatuses`, stamps `archivedAt`, removes it from the default feed, and decrements `unreadCount` (because the unread predicate also excludes ARCHIVED)
- Archiving a notification already containing `READ` adds `ARCHIVED` (resulting in `engagementStatuses` of `{SEEN, READ, ARCHIVED}`), stamps `archivedAt`, and does not affect `unreadCount`
- Archiving a notification already containing `ARCHIVED` returns the existing row without modifying `archivedAt` (idempotent)
- Archiving another user's Notification fails with NOTIFICATION_NOT_FOUND (same response as a missing id) and does not modify the row
- A Notification with `deliveryStatus = QUEUED` is not returned by the feed listing and cannot be marked read or archived by the recipient
- A Notification with `deliveryStatus IN {FAILED, BOUNCED}` is not returned by the feed listing and cannot be marked read or archived by the recipient
- All engagement-axis additions update the corresponding timestamp fields (`seenAt`, `readAt`, `archivedAt`) only on the first addition of that member and never overwrite them on subsequent idempotent calls

## Reference Links

- [Notification module README](../../README.md)
- [notification-delivery feature](./notification-delivery.md)
