# notification-templates

## Overview

Notification Templates own the `NotificationTemplate` entity, which stores the channel-specific message content used by the dispatcher to materialize each delivery. Each template is uniquely keyed by the triple `(eventType, channelId, locale)` and carries a `subject`, a plain-text `body`, an optional `htmlBody` (used by EMAIL and any other rich channels), and a declared **variable schema** describing the variables the template expects at render time. The notification module is portal-agnostic: deep links and any other surface-specific URLs are resolved by the application layer and passed in as variables — templates never construct portal URLs.

Templates are rendered at dispatch time. The dispatcher invokes `render(eventType, channelId, locale, payloadVars)` and the template feature looks up a row by `(eventType, channelId, locale)`. If no exact-locale row exists the lookup falls back to `(eventType, channelId, defaultLocale)`; if that row is also missing the call fails with `TEMPLATE_NOT_FOUND`. The `locale` argument depends on the delivery topology: for PERSONAL deliveries the event-supplied locale wins, falling back to the recipient's resolved profile locale and then `defaultLocale` (`en-US`); for DESTINATION posts — which have no recipient — it is the event-supplied locale, defaulting to `defaultLocale` directly (see [notification-destination-delivery](./notification-destination-delivery.md)). When a template is found, `payloadVars` is validated against the template's declared variable schema (missing required vars → `TEMPLATE_VAR_VALIDATION_FAILED`), then the renderer performs simple `{{variable}}` substitution and returns `{ subject, body, htmlBody? }` to the dispatcher.

The templating language is intentionally minimal: `{{variable}}` substitution only — **no conditional logic, no loops, no filters**. Transactional notification copy of this shape (announcement headline, task title, RFQ subject) does not justify the operational and security cost of a full Liquid/Handlebars engine, and locking down to flat substitution keeps the variable schema, validation, and channel-specific escaping rules trivially reasonable. PII is handled by holding only the **structural template** in the template store — the `NotificationTemplate` row itself never contains recipient data. Variable values are resolved per dispatch, and the rendered output is captured immutably on the per-recipient `Notification` row (`subject` / `body` / `htmlBody`) alongside the input `payloadVars`: the inbox feed displays that captured copy and the delivery audit relies on it. Because rendered content interpolates the same personal data as the input variables, GDPR erasure scrubs both the rendered fields and `payloadVars` (see [anonymizeNotificationsForUser](../command/AnonymizeNotificationsForUser.md)).

## Business Purpose

- **One source of truth for transactional copy** — every channel of every eventType reads from the same row keyed by `(eventType, channelId, locale)`, so admins edit copy in one place and every future dispatch picks it up
- **Channel-specific bodies** — IN_APP renders a short subject + body for the inbox feed; EMAIL renders the same fields plus an optional `htmlBody` for rich rendering, without forcing every channel into the same shape
- **Localization with safe fallback** — recipient locale drives selection, but a missing locale row gracefully falls back to the configured default locale instead of failing the dispatch, so partial translation rollouts never break delivery in covered locales
- **Declarative variable contract** — each template publishes the variables it consumes as a structural schema, so emitters know exactly what `payloadVars` to pass and missing required vars are caught at dispatch time rather than producing broken strings
- **Portal-agnostic** — the notification module never constructs portal URLs; deep links are computed by the application layer and passed in as a `deepLink` variable, keeping the module reusable across buyer and supplier surfaces
- **Bounded templating risk** — `{{variable}}` substitution only; no logic in templates means template authors cannot accidentally introduce loops, recursion, or template-injection vectors and the renderer stays small and auditable
- **PII minimization** — templates store structure, never recipient data; recipient-specific values exist solely on the dispatched `Notification` row (rendered `subject` / `body` / `htmlBody` plus input `payloadVars`), giving GDPR erasure a single per-recipient scrub target

## Process Flow

```mermaid
flowchart TD
    subgraph Dispatch["Dispatch-time render"]
        A[Dispatcher calls render(eventType, channelId, locale, payloadVars)] --> B[Look up template by (eventType, channelId, locale)]
        B -->|Found| F[Validate payloadVars against template variable schema]
        B -->|Not found and locale != defaultLocale| C[Look up template by (eventType, channelId, defaultLocale)]
        C -->|Found| F
        C -->|Not found| D[Error: TEMPLATE_NOT_FOUND]
        B -->|Not found and locale == defaultLocale| D
        F -->|Required var missing or wrong type| E[Error: TEMPLATE_VAR_VALIDATION_FAILED]
        F -->|Valid| G[Interpolate `{{variable}}` placeholders in subject and body]
        G --> H{Template has htmlBody?}
        H -->|Yes| I[Interpolate htmlBody with channel-appropriate escaping]
        H -->|No| J[Return rendered subject and body, htmlBody undefined]
        I --> K[Return rendered subject, body, htmlBody]
    end

    subgraph Admin["Admin authoring"]
        AA[Tenant admin opens template editor] --> AB{Existing template for (eventType, channelId, locale)?}
        AB -->|Yes| AC[Update subject / body / htmlBody / variable schema]
        AB -->|No| AD[Create new template row]
        AC --> AE[Save - future dispatches use new content]
        AD --> AE
        AE --> AF[Existing already-rendered Notifications are unaffected]
    end
```

## Scenario Patterns

- **Standard render, all variables present (in-app, en)**: `(ANNOUNCEMENT_PUBLISHED, IN_APP, en)` template with subject `New announcement: {{title}}` and body `{{publisherName}} just published an update`. `payloadVars = { title: "Q2 Pricing Update", publisherName: "Acme Buying Team", deepLink: "..." }`. Renderer returns the subject and body with placeholders substituted; `htmlBody` undefined because the template did not declare one
- **Standard render, all variables present (email, en)**: `(ANNOUNCEMENT_PUBLISHED, EMAIL, en)` declares both `body` (text) and `htmlBody`. The renderer substitutes `{{title}}` and `{{deepLink}}` into the text body and into the HTML body's anchor (`<a href="{{deepLink}}">`); both forms are returned to the dispatcher
- **Locale fallback to default**: Recipient locale is `ja`; only `(TASK_ASSIGNED, IN_APP, en)` exists. Lookup misses on `ja`, falls back to default locale `en`, renders the English template, and dispatch proceeds normally
- **Exact locale wins over fallback**: Both `(TASK_ASSIGNED, IN_APP, ja)` and `(TASK_ASSIGNED, IN_APP, en)` exist; recipient locale `ja` selects the Japanese row directly, default-locale fallback is not consulted
- **No template at all**: Neither `(eventType, channelId, recipientLocale)` nor `(eventType, channelId, defaultLocale)` exist. Renderer fails with `TEMPLATE_NOT_FOUND`; the dispatcher records the failure for that `(recipient, channel)` pair without affecting other channels for the same recipient
- **Missing required variable**: Template declares `title` and `dueDate` as required; emitter passes only `title` in `payloadVars`. Variable schema validation fails with `TEMPLATE_VAR_VALIDATION_FAILED` before any interpolation runs; nothing is rendered
- **Wrong-type variable**: Template declares `dueDate: string`; emitter passes a number. Schema validation fails with `TEMPLATE_VAR_VALIDATION_FAILED`
- **Extra variables ignored**: Emitter passes `{ title, publisherName, deepLink, internalDebugId }` but the template only declares `title`, `publisherName`, `deepLink`. The extra `internalDebugId` is silently ignored — extras are not an error, since emitters often share a single payload across multiple templates
- **Email template with htmlBody**: The renderer returns `{ subject, body, htmlBody }`; HTML body interpolation HTML-escapes user-supplied variable values, while the plain-text body interpolation does not escape (channel-appropriate escaping responsibility documented but escape implementation is deferred to the renderer)
- **IN_APP template with no htmlBody**: The renderer returns `{ subject, body, htmlBody: undefined }`; the dispatcher's IN_APP path consumes only `subject` and `body`
- **Variable used in multiple positions**: A `{{title}}` appearing in both the subject and the body is substituted in both positions with the same value; substitution is positional, not single-use
- **Variable name with no matching `payloadVars` key but declared optional in schema**: Optional variable, no value provided → renders as an empty string (not an error). Documenting this stance keeps templates resilient to optional context fields
- **Admin creates a new template**: Tenant admin creates `(ANNOUNCEMENT_PUBLISHED, EMAIL, ja)` for the first time; the next dispatch with `locale=ja` selects the new row instead of falling back to the default-locale row
- **Admin updates an existing template**: Tenant admin edits the body of `(TASK_ASSIGNED, IN_APP, en)`; **already-persisted Notifications keep their previously-rendered content** (rendered output is captured immutably on each Notification row at dispatch time), and the next dispatch picks up the new body. Existing inbox previews on prior Notifications are unaffected
- **Admin updates the variable schema**: Admin adds a new required variable `assigneeName` to a template's schema. Future dispatches whose emitter has not yet been updated to supply `assigneeName` will fail variable validation; this is the intended forcing function so variable contracts stay honest
- **Channel-aware escaping for HTML**: A user-supplied variable value contains `<script>...`. In `htmlBody` the value is HTML-escaped to `&lt;script&gt;...`; in the plain-text `body` it is inserted verbatim. The notification module documents this responsibility on the renderer; the actual escape strategy is an implementation detail

## Test Cases

- Looking up `(eventType, channelId, locale)` returns the exact row when one exists for that triple
- Looking up `(eventType, channelId, locale)` falls back to `(eventType, channelId, defaultLocale)` when no exact-locale row exists
- Looking up with `locale == defaultLocale` and no row present fails with `TEMPLATE_NOT_FOUND` (no second fallback)
- Looking up when neither the requested locale nor the default-locale row exists fails with `TEMPLATE_NOT_FOUND`
- An exact locale match takes precedence over a default-locale fallback when both rows exist
- Rendering with `payloadVars` that satisfies the template variable schema returns `{ subject, body }` with all `{{variable}}` placeholders substituted
- Rendering an EMAIL template that declares an `htmlBody` returns `{ subject, body, htmlBody }` with substitutions applied to all three fields
- Rendering an IN_APP template that does not declare an `htmlBody` returns `htmlBody` as undefined (or absent) in the result
- Rendering with `payloadVars` missing a required declared variable fails with `TEMPLATE_VAR_VALIDATION_FAILED` and produces no rendered output
- Rendering with `payloadVars` containing a wrong-type value for a declared variable fails with `TEMPLATE_VAR_VALIDATION_FAILED`
- Rendering with `payloadVars` containing variables not declared by the schema ignores the extras and renders successfully
- A `{{variable}}` appearing multiple times in `subject` and `body` is substituted at every position with the same value
- An optional declared variable that is not supplied renders as empty string in interpolated positions (no error)
- Templating supports `{{variable}}` substitution only — payloads containing constructs that look like loops or conditionals (e.g. `{{#if}}`, `{% for %}`) are treated as literal text and not interpreted
- Variable interpolation in a plain-text `body` does not HTML-escape values (verbatim insertion)
- Variable interpolation in an `htmlBody` HTML-escapes user-supplied variable values so embedded markup cannot break the document or inject script tags
- Creating a new template for `(eventType, channelId, locale)` makes that triple resolvable on the next render call
- Updating an existing template's `subject` / `body` / `htmlBody` is picked up by subsequent renders without redeploying the module
- Updating a template does **not** mutate the rendered content of `Notification` rows already persisted before the update
- Updating a template's variable schema is picked up immediately so future renders validate against the new schema
- Two updates of the same template applied in sequence converge on the latest content (admin edits are idempotent in the sense that re-saving the same content produces the same row)
- A template's `(eventType, channelId, locale)` triple is unique — creating a second row with the same triple is rejected
- Deleting (or otherwise removing) a template causes subsequent renders for that triple to fall back to the default-locale row, or fail with `TEMPLATE_NOT_FOUND` if the default-locale row is also absent
- A template referencing a variable in `subject` / `body` / `htmlBody` that is not declared in its variable schema is treated as a template-authoring error at template save time (the template feature owns this validation; the dispatcher does not need to defend against it at render time)

## Reference Links

- See module README
- [notification-delivery](./notification-delivery.md) — calls `render(eventType, channelId, locale, payloadVars)` at dispatch time and consumes the returned `{ subject, body, htmlBody? }`
- [notification-channels](./notification-channels.md) — defines `channelId` values used as part of the template lookup key
- [notification-preferences](./notification-preferences.md) — recipient locale is resolved alongside preferences and feeds the locale arg passed to `render`
