# README

## Overview

The notification module is the single fan-in target for host-app domain events that need to reach people. It is built on a domain-agnostic, port-based delivery engine and adds two capabilities on top of plain per-user delivery: **DESTINATION broadcast** (one post to a shared channel — e.g. a Slack channel — bound to a source entity) and **entity-driven recipient resolution** (watchers, reason tags, critical bypass).

Events enter asynchronously. An emitting resolver in the host app (e.g. a purchase module's `issuePurchaseOrder` or an invoice module's `approveInvoice`) calls the host-app `emitNotificationEvent` helper, which appends an idempotent `NotificationEvent` envelope. Delivery then follows an **outbox split**: a CDC `recordCreatedTrigger` executor runs the dispatcher's **plan phase** in-transaction to write outbox rows (`Notification` at `QUEUED`, `DestinationDeliveryLog` at `PENDING`) — no external delivery — and, after the transaction commits, a **delivery worker** drains those rows outside any transaction so a slow or failing provider never holds the event transaction's locks or rolls back the plan. A low-frequency cron is the insurance: it re-plans events stuck in `PENDING` (a dropped CDC fire) and drains stranded QUEUED / PENDING outbox rows whose plan committed but whose delivery never completed. The plan phase runs two independent stages keyed off each `NotificationChannel.kind`:

- **PERSONAL** (`IN_APP`, and the optionally-wired `EMAIL`): resolves the audience — the emitter's reason-tagged hints (`ASSIGNED` / `AUTHOR` / `MENTION`) unioned with `SUBSCRIBED` watchers from `NotificationSubscription`, actor-suppressed and deduped by highest-precedence reason — applies per-recipient preferences (with the **critical bypass** for `ASSIGNED` / `MENTION`), renders the channel template, and queues one `Notification` row per recipient at `QUEUED` with a two-axis lifecycle (`deliveryStatus` + `engagementStatuses`); the delivery worker later invokes the channel adapter and advances `deliveryStatus`.
- **DESTINATION** (e.g. `SLACK`): independent of the recipient set, resolves each active `ChannelRoutingBinding` for the event's `(sourceType, sourceId)` and plans one `PENDING` `DestinationDeliveryLog` per binding with the rendered content; the delivery worker posts exactly one message per binding to its `externalChannelRef` via the injected `DestinationAdapter`, passing the log id as an idempotency key — the concrete provider integration (Slack) is bundled but **opt-in** (see [slack-workspace-integration](./docs/feature/slack-workspace-integration.md)).

The module stores only polymorphic `(sourceType, sourceId)` references — it never owns the source domain content (e.g. purchase orders, invoices, RFQs in an ERP host app). DESTINATION delivery is a **shared-channel broadcast, never a per-user DM**. Recipient identity and the destination posting medium are injected at composition time through DI ports, so the delivery engine itself stays free of any dependency on user-management internals, provider SDKs, or host-app domain modules. The Slack-specific state and code (workspace connection, OAuth handshake, API client) are bundled but created **only** when the host app enables the `slack` option on `defineModule` — an app that does not deliver to Slack carries no Slack table or command (see [slack-workspace-integration](./docs/feature/slack-workspace-integration.md)).

The engine also carries a generic EMAIL delivery path inherited from its reusable base. It remains available even when the host app leaves it unwired (e.g. a host app that delivers `IN_APP` + `SLACK` only).

## Key Features

- CDC-driven event ingress with an outbox split: `logNotificationEvent` appends an idempotent (`eventType, sourceType, sourceId, payloadHash`) `NotificationEvent`; a `recordCreatedTrigger` executor plans outbox rows on insert (in-transaction) and delivers them after commit (out of transaction), with an insurance cron that re-plans stuck `PENDING` events and drains stranded outbox rows
- Two-topology dispatch from a single event: per-user `IN_APP` fan-out (PERSONAL) plus one shared-channel post per `ChannelRoutingBinding` (DESTINATION), delivered by a worker that passes a per-row idempotency key; for a provider that honors it a redrain re-send collapses to one delivery, otherwise delivery is **at-least-once** (e.g. Slack `chat.postMessage` has no idempotency parameter, so a crash after the post but before the status write can re-post — the redrain's grace window keeps this to genuine strandings, not the inline path); each stage independent of the other's failures; pairs suppressed before persistence are surfaced in the plan result's `skipped[]` ledger with reason codes
- Entity-driven recipient resolution: union of emitter reason hints (`ASSIGNED` / `AUTHOR` / `MENTION`) and `SUBSCRIBED` watchers, actor suppression, reason-precedence dedup, and a **critical bypass** that forces delivery to `ASSIGNED` / `MENTION` recipients past a preference mute
- Per-user notification preferences (per channel × category) resolved from module-owned `NotificationPreference` rows, opt-in by default
- In-app inbox with a two-axis lifecycle: delivery-owned `deliveryStatus` (`QUEUED` written by the plan phase, advanced through SENT/DELIVERED/FAILED by the delivery worker; BOUNCED reserved) and recipient-owned `engagementStatuses` (SEEN / READ / ARCHIVED), with unread-count and mark-read / mark-all-read / archive operations
- Provider-agnostic `DestinationAdapter` port: the delivery engine never depends on a provider SDK or stores provider credentials; the bundled **opt-in Slack destination** (`slack` option) implements this port and auto-wires it — when the option is omitted, no Slack schema, command, or adapter is created (see [slack-workspace-integration](./docs/feature/slack-workspace-integration.md))
- Channel routing bindings: generic `ChannelRoutingBinding(targetType, targetId, channelId, externalChannelRef)` mapping a source entity (e.g. a purchase order) to a destination channel (e.g. a Slack channel); bindings are seeded / inserted by the host app
- Two-track delivery audit with bounded retention: `NotificationDeliveryAudit` (per-user lifecycle events) and `DestinationDeliveryLog` (per shared-channel post attempt, with redacted provider response), both anonymized by the `runNotificationAuditRetentionSweep` command (set-based, default 90-day TTL; the host app wires it to a cron)
- Reserved generic EMAIL channel available even when the host app leaves it unwired

## Module Scope

### In Scope

- `NotificationEvent` append-only ingress envelope with idempotent dedup, plus the CDC plan-and-deliver executor and the insurance cron (re-plan stuck events + drain stranded outbox rows)
- `Notification` per-user delivery record with two-axis lifecycle, polymorphic `(sourceType, sourceId)` reference, resolved `reason` tag, rendered subject/body, and per-recipient idempotency key
- `NotificationChannel` registry with `kind` (PERSONAL / DESTINATION) — e.g. a host app may enable `IN_APP` (PERSONAL) and `SLACK` (DESTINATION)
- `NotificationCategory` + `EventCategoryBinding` catalog binding each host-app `eventType` (e.g. `PO_ISSUED`, `INVOICE_APPROVED`, `RFQ_PUBLISHED`) to a category, transactional flag, and default channels — seeded by the host app
- `NotificationTemplate` keyed by `(eventType, channelId, locale)` for `IN_APP` and `SLACK` rendering
- `NotificationSubscription` follower relationships and the in-dispatcher watcher resolution + reason tagging + actor suppression + critical bypass
- `NotificationPreference` per-user (category × channel) opt-in/opt-out, resolved from module-owned rows
- `ChannelRoutingBinding` generic destination routing and the `DestinationAdapter` one-post-per-binding contract
- `NotificationDeliveryAudit` (per-user lifecycle) and `DestinationDeliveryLog` (per shared-channel post attempt, redacted provider response), with retention sweep and gateway auto-CRUD closure on `DestinationDeliveryLog` (provider diagnostics are never gateway-readable)
- Inbox queries/commands scoped strictly to the recipient (list, unread count, mark-read, mark-all-read, archive)
- **Opt-in Slack destination** (`slack` option): the `SlackWorkspaceIntegration` model (workspace metadata only — the bot token lives in the host secret manager, not on the row — ACTIVE/REVOKED lifecycle, gateway auto-CRUD closed), the signed OAuth install handshake (`beginSlackWorkspaceInstall` / `completeSlackWorkspaceInstall`), the signature-verified uninstall webhook (`handleSlackAppUninstalled`), the redacted `getSlackWorkspaceIntegration` query, and the auto-wired Slack `DestinationAdapter` — all created only when the option is enabled (see [slack-workspace-integration](./docs/feature/slack-workspace-integration.md))

### Out of Scope

- Provider-specific destination integrations beyond the bundled Slack one — the Slack destination is opt-in via the `slack` option; future providers (e.g. Teams) would implement the same `DestinationAdapter` port, so the core delivery schema never grows per provider
- Per-user direct messages on a destination channel — DESTINATION delivery is a shared-channel broadcast only; per-user DMs (e.g. Slack DMs) are explicitly out of scope
- EMAIL, SMS, and push delivery — channel/adapter ports are reserved and the EMAIL engine is retained generically, but adapters are wired by the host app
- Time-driven schedules/reminders — a host app that needs "remind N days before X" runs its own cron that detects due conditions and emits through `emitNotificationEvent`, mirroring how major ERPs delegate due-date detection to job infrastructure rather than a notification-owned scheduler
- Per-user quiet-hours (time-of-day delivery deferral) and tenant-level per-(category × channel) policy overrides — out of scope; channel-wide governance is covered by `deactivateNotificationChannel`
- Source domain content (e.g. tickets, comments, engagements) — owned by the emitting host-app modules; this module stores only polymorphic references
- Digest/batching and snooze (per-source temporary mute)
- EMAIL deliverability webhook ingestion and dispatcher-side retry — the generic engine delegates deliverability to the email provider
- Rich destination-message authoring, threading, and reaction handling beyond a single post per event (e.g. Slack threading and reactions)
- Per-company scoping — the tenant is the whole deployment; records carry no company scope and the module has no dependency on an organization/Company model

### Scope Decision Rationale

The module is intentionally a fan-in target with no upstream dependency on the emitting modules. A host-app emitter (e.g. a purchase or invoice module) emits events through the stable `emitNotificationEvent` / `NotificationEventPayload` seam and never learns how delivery happens; the notification module never learns what a purchase order is. Polymorphic `(sourceType, sourceId)` references keep the schema decoupled from every emitter.

The defining requirement behind the second topology — a DESTINATION post to a **shared channel** (e.g. a Slack channel) bound to a source entity rather than per-user DMs — is why the engine carries two delivery topologies instead of one. PERSONAL fan-out and DESTINATION broadcast are structurally different (per-recipient addressing vs one-post-per-binding, per-user lifecycle audit vs per-attempt provider audit), so they are kept as separate stages and separate audit tracks (`NotificationDeliveryAudit` vs `DestinationDeliveryLog`) rather than forced into one model.

Recipient resolution is internalized (watchers + reason tags + critical bypass) because host apps need assignees and mentioned users to always get their direct ping even when they have muted a category — a guarantee the emitter cannot express by pre-resolving a flat recipient list.

The generic EMAIL capability is retained even when unwired: it is structural to the PERSONAL dispatch path (a first-class channel kind, not a feature cluster), so removing it would fork the reusable engine. By contrast, schedules/reminders, quiet-hours, and tenant policy were removed as dormant surface area: none is standard in major ERP notification platforms (time-driven reminders are conventionally owned by job infrastructure scanning date conditions, not by the notification subsystem), each was unwired in every known host app, and each can be reintroduced later — schedules as a new table plus host-app cron, quiet-hours and tenant policy as dispatch-loop gates — without disturbing the DI ports or the channel registry.

## Module Dependencies

- [user-management](../user-management/README.md) — the module's only module dependency, injected through `defineModule({ userManagement: { db: { user } }, adapters })`: user identity for `Notification.recipientUserId`, `NotificationPreference.userId`, `NotificationSubscription.userId`, and audit actor fields; recipient profiles (locale + channel addresses) are resolved through the `resolveRecipient` DI port wired at the app layer
- **Host-app integration points** (app-layer wiring, no compile-time dependency) — emitter modules post events through `emitNotificationEvent`; destination targets are referenced only as opaque `(targetType, targetId)` pairs on `ChannelRoutingBinding`; and right-to-erasure is triggered by the host app's GDPR flow invoking `anonymizeNotificationsForUser`, since user-management does not model user deletion (see [notification-delivery-audit](./docs/feature/notification-delivery-audit.md))
- **Slack Web API** (external, only when the `slack` option is enabled) — `oauth.v2.authorize` / `oauth.v2.access` for the install handshake, `chat.postMessage` for delivery, and the Events API `app_uninstalled` webhook for revocation (see [slack-workspace-integration](./docs/feature/slack-workspace-integration.md))
