# notification-subscriptions

## Overview

Notification Subscriptions provide follower-style relationships between users and arbitrary domain records — the same model as Odoo's chatter "followers", Knock's Subscriptions, and Novu's Topics. A subscriber explicitly registers interest in a `(sourceType, sourceId)` pair via `subscribe(userId, sourceType, sourceId)`, which inserts a `NotificationSubscription { userId, sourceType, sourceId, subscribedAt }` row, scoped to one record per user per source. The operation is idempotent: subscribing to the same source twice returns the existing row without creating a duplicate. `unsubscribe(userId, sourceType, sourceId)` removes the row, and is similarly idempotent — unsubscribing when no row exists is a no-op success. The polymorphic `sourceType + sourceId` reference mirrors the decoupling pattern used elsewhere in this module (Notification, NotificationDeliveryAudit): the subscription does not validate that the source record exists, store any source content, or hold a foreign key back to the emitting module's schema.

This feature owns the subscribe / unsubscribe / list surface. **The audience union itself is performed inside the dispatcher** (`dispatchNotification`), not pushed onto every emitter. At dispatch time the dispatcher reads `NotificationSubscription` by the event's `(sourceType, sourceId)` to resolve the **SUBSCRIBED watchers**, then unions that set with the emitter-supplied **naturally-interested set** — the reason-tagged role-holders (`ASSIGNED`, `AUTHOR`, `MENTION`) that only the emitting module can compute, because different sources have different role semantics (an RFQ has invitees, a PO has a buyer + supplier counterpart, an invoice has an approver). The union is deduped per `userId` with a **reason precedence** (a more specific role-holder reason beats a plain `SUBSCRIBED` watcher row), and the **actor is suppressed** from the audience unless the emitter sets `notifySelf`. The emitter therefore supplies only its own role-holders as a `recipients: { userId, reason }[]` hint list; it never has to fetch and merge the watcher list itself. The auto-subscribe convention (e.g., Odoo's "comment on a record → auto-follow") is still an emitter-side policy: this feature exposes `subscribe()`, but whether and when to call it on behalf of the actor is decided by the emitting module.

> **Watcher resolution lives in the dispatcher.** The emitter is never expected to pre-resolve the entire audience including watchers. The dispatcher owns the `(sourceType, sourceId) → SUBSCRIBED watchers` lookup, the union with the emitter's reason-tagged set, reason-precedence dedup, and actor suppression. See [DispatchNotification](../feature/notification-delivery.md) for the full input contract and resolution order.

## Business Purpose

- Enable opt-in following of records of interest ("watch this RFQ", "follow this PO change request") so users receive notifications about updates without being formally assigned a role
- Provide transparency into who is being notified for a given source — a tenant admin can list all subscribers on a record for audit, debugging, or troubleshooting "why didn't I get notified" questions
- Support chatter-style collaboration patterns where commenting, mentioning, or otherwise engaging with a record auto-subscribes the actor (policy owned by the emitting module)
- Keep the subscription schema decoupled from every emitting module by storing polymorphic `sourceType + sourceId` rather than typed foreign keys, so new subscribable record types can be introduced without schema migrations here
- Provide an idempotent subscribe/unsubscribe contract so repeat clicks ("follow" pressed twice, race conditions, retried calls) do not produce duplicates or errors
- Enforce self-management: a user can only subscribe or unsubscribe themselves; cross-user subscribe/unsubscribe attempts are rejected

## Process Flow

```mermaid
flowchart TD
    A[User or emitter calls subscribe userId sourceType sourceId] --> B{Caller authorized to subscribe this userId?}
    B -->|No| C[Error: FORBIDDEN]
    B -->|Yes| D{Existing NotificationSubscription row for userId sourceType sourceId?}
    D -->|Yes| E[Return existing row — idempotent no-op]
    D -->|No| F[Insert NotificationSubscription with subscribedAt timestamp]
    F --> G[Return new row]

    H[User or emitter calls unsubscribe userId sourceType sourceId] --> I{Caller authorized to unsubscribe this userId?}
    I -->|No| C
    I -->|Yes| J{Existing row for userId sourceType sourceId?}
    J -->|No| K[Return success — idempotent no-op]
    J -->|Yes| L[Delete row]
    L --> M[Return success]

    N[Caller calls list sourceType sourceId] --> O{Caller is the source emitter or a tenant admin?}
    O -->|No| C
    O -->|Yes| P[Query NotificationSubscription where sourceType and sourceId match]
    P --> Q[Return userIds]
```

```mermaid
sequenceDiagram
    participant Emitter as Emitting module
    participant Disp as NotificationDispatcher
    participant Sub as NotificationSubscription store
    Emitter->>Disp: dispatch eventType sourceType sourceId recipients(reason-tagged) actorUserId payloadVars
    Disp->>Sub: read where sourceType sourceId (SUBSCRIBED watchers)
    Sub-->>Disp: watcher userIds
    Disp->>Disp: union watchers + emitter naturally-interested set, reason-precedence dedup, suppress actor
    Disp-->>Emitter: per-recipient delivery results
```

## Scenario Patterns

- **First subscribe**: User invokes `subscribe(userId, "rfq", rfqId)` for the first time; a NotificationSubscription row is created with `subscribedAt = now`
- **Repeat subscribe (idempotent)**: Same user invokes `subscribe` again for the same `(sourceType, sourceId)`; the existing row is returned, no duplicate is inserted, `subscribedAt` is not refreshed
- **Different user, same source**: A second user subscribes to the same source; a distinct row is created — subscriptions are scoped to one row per `(userId, sourceType, sourceId)` triple
- **Same user, different source**: The same user subscribes to a different source (different `sourceType` or different `sourceId`); a distinct row is created
- **Unsubscribe existing**: User invokes `unsubscribe(userId, sourceType, sourceId)` and a row exists; the row is deleted and success is returned
- **Unsubscribe non-existent (idempotent)**: User invokes `unsubscribe` when no row exists; success is returned and nothing is deleted
- **Resubscribe after unsubscribe**: User unsubscribes, then subscribes again to the same source; a new row is created with a fresh `subscribedAt` timestamp (the previous row was deleted, not soft-deleted)
- **Dispatcher resolves audience**: An emitting module invokes `dispatchNotification` with its own reason-tagged role-holders (e.g. `recipients: [{ userId: assignee, reason: "ASSIGNED" }]`); the dispatcher reads `NotificationSubscription` for `(sourceType, sourceId)` to add the `SUBSCRIBED` watchers, unions the two, dedups by `userId` keeping the higher-precedence reason, and suppresses the actor — the emitter no longer fetches or merges the watcher list itself
- **Watcher both subscribed and assigned**: A user who is a `SUBSCRIBED` watcher on a source and is also passed by the emitter as `ASSIGNED` resolves to a single recipient tagged `ASSIGNED` (the role-holder reason wins over the watcher reason), so critical-bypass behavior applies to that user
- **Tooling/admin `list`**: `NotificationSubscription.list({ sourceType, sourceId })` remains available for audit/debugging ("who is watching this record"); it is independent of the dispatcher's internal resolution and does not perform the union or actor suppression
- **Source record deleted, subscriptions dangle**: The source record (e.g., an RFQ) is hard-deleted by its owning module without cleanup; any future `list` call still returns the dangling subscriber rows, but no notifications are emitted because the source's emitter is gone — the dangling rows are harmless and can be cleaned up lazily or left in place
- **Tenant admin lists subscribers**: A tenant admin lists subscribers for a source and receives every subscriber on that source
- **Cross-source list returns no overlap**: A subscription on `(sourceType: "rfq", sourceId: X)` is not returned by a list query on `(sourceType: "po", sourceId: X)` — sourceType is part of the lookup key
- **Cross-user safety on subscribe**: A user cannot pass another user's `userId` into `subscribe`; the command rejects with FORBIDDEN — a buggy caller cannot register a subscription on someone else's behalf
- **Shared source, multiple subscriber roles**: When a buyer-side user and a supplier-side user both subscribe to a source visible to both (e.g., a PO record), two distinct subscription rows exist. `list({ sourceType, sourceId })` returns both; the emitter is responsible for any audience filtering it needs to perform before dispatch
- **Auto-subscribe on action (emitter convention)**: An emitting module configured to auto-follow on comment posts a comment on behalf of an actor, then calls `subscribe(actorUserId, sourceType, sourceId)` itself. From this feature's perspective the call is an ordinary `subscribe` — the auto-subscribe policy lives entirely in the emitter

## Test Cases

- Calling `subscribe(userId, "rfq", rfqId)` for a user with no existing subscription on that source should insert a NotificationSubscription row with `subscribedAt` set to the current timestamp
- Calling `subscribe` a second time with the same `(userId, sourceType, sourceId)` should return the existing row without creating a duplicate, and `subscribedAt` should be unchanged from the first call
- Calling `subscribe` for two different users on the same source should create two distinct rows
- Calling `subscribe` for the same user on two different `sourceId` values under the same `sourceType` should create two distinct rows
- Calling `subscribe` for the same user on the same `sourceId` but different `sourceType` values should create two distinct rows
- Calling `unsubscribe(userId, sourceType, sourceId)` when a matching row exists should delete the row and return success
- Calling `unsubscribe` when no matching row exists should return success and persist zero changes
- Calling `subscribe` after a prior `unsubscribe` for the same user and source should create a new row with a fresh `subscribedAt` timestamp
- Calling `list({ sourceType, sourceId })` should return every NotificationSubscription matching that exact `(sourceType, sourceId)` pair across all subscribers
- Calling `list({ sourceType, sourceId })` on a source with zero subscribers should return an empty list (not an error)
- Calling `list({ sourceType, sourceId })` should not return subscriptions for a different `sourceType` even when the `sourceId` collides
- Calling `subscribe` where `userId` is a different user than the caller should fail with FORBIDDEN and persist zero rows
- Calling `unsubscribe` where `userId` is a different user than the caller should fail with FORBIDDEN
- A non-admin user calling `list` for a source they did not create or are not subscribed to should be denied unless the caller is a tenant admin or the source emitter
- A tenant admin calling `list` should see every subscription on the source
- Subscriptions are not validated against the existence of the source record: calling `subscribe` with a `sourceId` that does not correspond to any existing record should still succeed and persist a row
- Deleting the source record (out of band, by its owning module) should leave existing subscription rows in place; a subsequent `list` should still return them
- Re-emitting the same `subscribe` call concurrently (race condition) should result in exactly one row, not two — the unique constraint on `(userId, sourceType, sourceId)` enforces idempotency at the storage layer
- The dispatcher resolving an event whose `(sourceType, sourceId)` has two `SUBSCRIBED` watchers and one emitter-supplied `ASSIGNED` recipient (overlapping one watcher) should produce three deduplicated recipients, with the overlapping user tagged `ASSIGNED` (role-holder reason wins over `SUBSCRIBED`)
- The dispatcher should suppress the actor (`actorUserId`) from the resolved audience unless `notifySelf` is set, even when the actor is also a `SUBSCRIBED` watcher on the source
- The `list` query should continue to return subscribers as-is for admin/audit use, independent of the dispatcher's internal union and actor suppression

## Reference Links

- See module README
- [notification-delivery](./notification-delivery.md) — the dispatcher reads `NotificationSubscription` internally to resolve `SUBSCRIBED` watchers during dispatch
- [DispatchNotification](../feature/notification-delivery.md) — input contract (`recipients` reason-tagged set + `actorUserId` + `notifySelf`), watcher resolution, reason-precedence dedup, and critical bypass
- [notification-preferences](./notification-preferences.md) — `ASSIGNED` / `MENTION` reasons trigger the critical bypass over the user's preference mute
