# notification-preferences

## Overview

Notification Preferences groups individual event types into user-visible NotificationCategory buckets — for example "PO updates", "RFQ updates", "Task assignments", "Announcements", "Compliance documents", "Onboarding", "Supplier lifecycle", "Supplier profile changes". Each category carries an `optOutAllowed` boolean that distinguishes optional categories (broadcasts, reminders, digests) from transactional categories (invoice approval, PO change requests, supplier suspension, sensitive-field change governance) that compliance and operational continuity require to always reach the recipient. Categories are seeded by the host application (see the Event Catalog contract in [notification-delivery](./notification-delivery.md)) and cannot be created by end users.

Per-user opt-in/opt-out is recorded in the NotificationPreference entity keyed by `(userId, categoryId, channelId)` and carrying `allowed: boolean`. The model is opt-in-by-default: when no row exists for a given `(user, category, channel)` triple, the dispatcher treats it as `allowed = true`. A row is only persisted when the user explicitly diverges from the default — typically by toggling an optional category off for a specific channel (e.g. "PO updates" email off, in-app on). Toggle commands targeting a category whose `optOutAllowed = false` are rejected at the command boundary with `CATEGORY_NOT_OPT_OUT_ABLE`, so that no `allowed = false` row can ever be persisted for a transactional category.

At dispatch time the resolution order is fixed: (1) if the category has `optOutAllowed = false` (or the binding is flagged transactional), allow immediately regardless of any preference row; otherwise (2) read the recipient's NotificationPreference row for `(user, category, channel)`, and if it says `allowed = false`, skip — **unless the recipient's resolved reason is `ASSIGNED` or `MENTION`, in which case the critical bypass forces delivery past the mute** (see below); otherwise (3) the dispatch is allowed by default opt-in and the Notification is dispatched immediately.

**Critical bypass.** A notification system that lets a user mute everything is one that fails the one delivery that actually mattered. When the dispatcher resolves a recipient (see [DispatchNotification](../feature/notification-delivery.md)) it carries a **reason tag** (`SUBSCRIBED`, `ASSIGNED`, `AUTHOR`, `MENTION`). The two reasons that mark the user as personally accountable for the source — `ASSIGNED` (directly assigned) and `MENTION` (@-mentioned) — **bypass the user's NotificationPreference mute** so a `allowed = false` row never suppresses a direct ping. The bypass is intentionally narrow: it overrides **only** the per-user preference check (step 2). `SUBSCRIBED` and `AUTHOR` reasons do **not** bypass: a watcher who muted the category stays muted. This mirrors the transactional-category invariant at the per-recipient level: transactional categories force delivery for the whole audience, the critical bypass forces it for the individually-accountable recipients of an otherwise-optional category.

## Business Purpose

- Satisfy GDPR Article 7 (consent withdrawal) and CAN-SPAM-equivalent regulations by giving every user a documented mechanism to opt out of optional notification categories per channel
- Reduce notification fatigue and inbox noise so users stay engaged with the categories that actually matter to their role
- Preserve the transactional non-opt-out invariant — financial, compliance, and lifecycle events always reach their recipients regardless of preference state
- Keep new categories safe-by-default — when the catalog grows, existing users are implicitly opted in rather than silently opted out
- Support bulk preference editing so a user can configure their entire matrix in one save instead of toggling each cell individually

## Process Flow

Dispatch-time preference resolution:

```mermaid
flowchart TD
    A[Dispatch event arrives] --> B[Resolve eventType -> NotificationCategory]
    B --> C{category.optOutAllowed = false?}
    C -->|Yes, transactional| D[Allow immediately, bypass user preference]
    C -->|No| G{NotificationPreference allowed = false for user + category + channel?}
    G -->|Yes| CB{Resolved reason in ASSIGNED, MENTION?}
    CB -->|Yes, critical bypass| H
    CB -->|No| F[Skip dispatch for this user + channel]
    G -->|No| H[Allow immediate dispatch]
    D --> I[Render and send immediately]
    H --> I
```

User updates a preference:

```mermaid
flowchart TD
    A[User submits preference toggle] --> B{Target user = caller?}
    B -->|No| C[Error: FORBIDDEN]
    B -->|Yes| D[Resolve category]
    D --> E{category.optOutAllowed = false AND allowed = false?}
    E -->|Yes| F[Error: CATEGORY_NOT_OPT_OUT_ABLE]
    E -->|No| G{Existing NotificationPreference row?}
    G -->|Yes| H[Update allowed flag]
    G -->|No| I[Insert NotificationPreference row]
    H --> J[Return updated preference]
    I --> J
```

## Scenario Patterns

- **Optional category opt-out**: User toggles "PO updates" email off; on the next PO event, the email channel is skipped for that user while in-app delivery still occurs
- **Per-channel granularity**: User keeps "Announcements" in-app on but disables it on email; in-app inbox still receives announcements, only email is suppressed
- **Transactional category cannot be opted out**: User attempts to disable "Invoice approval" email; the toggle command is rejected with `CATEGORY_NOT_OPT_OUT_ABLE` and no preference row is written
- **Critical bypass on assignment**: User muted "Ticket updates" in-app (`allowed = false`), but is the assignee on the next ticket event; the dispatcher resolves their reason as `ASSIGNED` and delivers the in-app Notification anyway, past the mute
- **Critical bypass on mention**: User muted a category, but is @-mentioned in a comment; the resolved reason `MENTION` forces delivery despite the `allowed = false` row
- **Subscribed watcher stays muted**: User is only a `SUBSCRIBED` watcher (no assignment, no mention) and muted the category; the dispatch is suppressed — `SUBSCRIBED` does not bypass
- **Default opt-in when no row**: A user who has never touched their preferences receives every category over every channel because no `allowed = false` row exists
- **New category added, existing users unaffected**: A new optional category is seeded after launch; every existing user is implicitly opted in because they have no `allowed = false` row
- **Self-only access**: A user cannot read or modify another user's preference rows; cross-user access is rejected with `FORBIDDEN`
- **Bulk preference update**: User submits a single command containing the full `(category, channel, allowed)` matrix; preferences are upserted in one transaction
- **Reset to defaults**: User invokes the reset command; all preference rows for that user are deleted, and the dispatcher reverts to default opt-in for every cell
- **Idempotent toggle**: Submitting the same `(category, channel, allowed)` value twice produces the same end state without error

## Test Cases

- A user toggling an optional category off for the email channel should result in a NotificationPreference row with `allowed = false` and dispatch should skip email for the next event in that category
- A user toggling an optional category off for one channel should not affect the other channel for the same category
- A toggle command targeting a category with `optOutAllowed = false` should fail with `CATEGORY_NOT_OPT_OUT_ABLE` and no row should be persisted
- Dispatch for an event whose category has `optOutAllowed = false` should deliver to the recipient even if a stale `allowed = false` row exists for that user
- Dispatch for an optional category to a recipient resolved with reason `ASSIGNED` should deliver even when an `allowed = false` preference row exists (critical bypass over the mute)
- Dispatch for an optional category to a recipient resolved with reason `MENTION` should deliver despite an `allowed = false` row, identically to `ASSIGNED`
- Dispatch for an optional category to a recipient resolved with reason `SUBSCRIBED` (or `AUTHOR`) should be suppressed when an `allowed = false` row exists — these reasons do not bypass
- Dispatching to a user who has no NotificationPreference rows should treat every category-channel combo as allowed
- Adding a new NotificationCategory after users have set their preferences should leave existing users implicitly opted in for the new category on every channel
- A user attempting to read another user's preferences should be rejected with `FORBIDDEN`
- A user attempting to update another user's preferences should be rejected with `FORBIDDEN`
- A bulk preference update should persist every `(category, channel, allowed)` row in one transaction
- A bulk preference update containing an entry for a non-opt-out-able category with `allowed = false` should fail atomically without persisting any row
- Resetting preferences should delete all NotificationPreference rows for the calling user
- Submitting the same toggle twice should be idempotent and produce a single preference row in the final `allowed` state
- Toggling a preference back to its default value should either delete the row or store `allowed = true`; either way subsequent dispatch should deliver
- Dispatch resolution should evaluate category transactional flag first, then user preference, then default — tested as a parameterized matrix
- Reading the effective preference state for a user should report the resolved value (transactional override / user opt-out / default) per `(category, channel)` pair
- A NotificationPreference row referencing an archived or removed category should be ignored by dispatch and surfaced as resolvable cleanup data
- An eventType not mapped to any NotificationCategory should be rejected by the dispatcher's category resolution rather than silently delivered
- A user listing their own preferences should see only their own rows and only categories within the seeded catalog

## Reference Links

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