# NotificationEvent

## Description

NotificationEvent is the append-only ingress record for the notification pipeline. Source modules write one row per interesting domain change immediately after they commit it, carrying the `eventType`, a polymorphic `(sourceType, sourceId)` reference (mirroring the same polymorphic convention used by Notification and ChannelRoutingBinding), the acting user, and a `payload` JSON snapshot used downstream for recipient hints and template variables. The row is the durable hand-off point between the synchronous source transaction and the asynchronous dispatch that follows.

The event is the **trigger surface** for delivery, not the delivery engine itself. Insertion of a `PENDING` row is observed by the `dispatch-notification-events` executor (a CDC `recordCreatedTrigger` on this type) which then invokes the `dispatchNotification` command — the single fan-in engine that owns recipient fan-out, preference filtering, template rendering, persistence of per-recipient `Notification` rows, and the DESTINATION resolution loop. This decouples the source module's commit latency from delivery work and lets a slow or failing channel adapter never block the originating command. See the notification-delivery feature for the end-to-end trigger topology (CDC primary / cron secondary).

The module uses `(eventType, sourceType, sourceId, payloadHash)` as the idempotency boundary so that a retried emit (the same business change re-submitted by an at-least-once caller) does not enqueue a second dispatch. Because the CDC trigger is itself at-least-once, dispatch additionally guards on `status = PENDING` before doing any work, so even a re-fired trigger on the same row is a safe no-op.

## Domain Model Definitions

### Model type

Stateful

#### State Transitions

```mermaid
stateDiagram-v2
    [*] --> PENDING : logNotificationEvent
    PENDING --> DISPATCHED : dispatch-notification-events executor (deliveries produced)
    PENDING --> NO_DELIVERY : dispatch-notification-events executor (zero deliveries)
```

| Operation          | From    | To          | Command                                                            |
| ------------------ | ------- | ----------- | ------------------------------------------------------------------ |
| dispatch           | PENDING | DISPATCHED  | dispatch-notification-events executor (CDC `recordCreatedTrigger`) |
| dispatchNoDelivery | PENDING | NO_DELIVERY | dispatch-notification-events executor (CDC `recordCreatedTrigger`) |

The `PENDING → DISPATCHED|NO_DELIVERY` transition is performed by the executor, not by a user-facing command — there is no module command that mutates a NotificationEvent after creation. `DISPATCHED` means the dispatch engine produced at least one delivery (a `Notification` row and/or a DESTINATION post); `NO_DELIVERY` means dispatch ran to completion but every recipient/channel was filtered out (no recipients, all opted out, no matching binding). Both terminal states stamp `dispatchedAt`. A row stuck in `PENDING` past its expected dispatch window is re-drained by the insurance cron (see notification-delivery).

### Command Definitions

- [LogNotificationEvent](../command/LogNotificationEvent.md) - append a new event row when the idempotency key is not already present

### Query Definitions

None

### Models

- NotificationEvent
  - `id` - Unique identifier
  - `eventType` - Event discriminator resolved to a NotificationCategory at dispatch time (e.g. `PO_ISSUED`, `INVOICE_APPROVED`, `RFQ_PUBLISHED`); stored as a free string and validated against the Event Catalog at dispatch, not at log time
  - `sourceType` - Polymorphic source aggregate type (e.g. `PurchaseOrder`)
  - `sourceId` - Polymorphic source aggregate identifier
  - `actorUserId` - User who caused the event (optional; used for actor self-suppression during recipient resolution)
  - `payload` - Stringified JSON snapshot carrying recipient hints, title/body/deepLink, and template variables; stored in canonical form (object keys recursively sorted) so the serialization is independent of the caller's key order
  - `payloadHash` - Stable hash of the canonicalized payload used for the idempotency boundary
  - `status` - Enum: `PENDING`, `DISPATCHED`, `NO_DELIVERY`
  - `dispatchedAt` - Timestamp when dispatch completed (null while `PENDING`)
  - `createdAt` / `updatedAt` - Lifecycle timestamps

### Invariants

- `eventType`, `sourceType`, `sourceId`, and `payloadHash` are required.
- The uniqueness boundary is `(eventType, sourceType, sourceId, payloadHash)`.
- `payload` is immutable after create; the row is append-only and is never updated except for the single `status`/`dispatchedAt` stamp at dispatch time.
- `status = DISPATCHED` or `NO_DELIVERY` implies `dispatchedAt` is non-null.
- Dispatch only acts on a row while `status = PENDING`; a row in any other status is a no-op for the executor (at-least-once CDC safety).
- Event rows are never deleted or mutated in a way that changes the original business payload.

### Relationships

- **References actor User**: `actorUserId` points to the acting user in user-management.
- **Referenced By Notification**: one dispatched event can produce many per-recipient in-app Notification rows.
- **Drives ChannelRoutingBinding resolution**: at dispatch the event's `(sourceType, sourceId)` is matched against `ChannelRoutingBinding.(targetType, targetId)` for DESTINATION delivery (e.g. Slack), independent of the recipient set.
