--- name: notifications description: Multi-channel notifications. Adding a new notification kind, group, or channel; in-app + email delivery; per-user prefs; project-level gates; idempotency. --- # Notifications **When to use:** Adding a notification kind / group / channel, touching the `notifications` table or `users.notification_preferences`, wiring a new source event into the notification pipeline, or debugging in-app / email delivery. Always read [dev-docs/notifications.md](../../../dev-docs/notifications.md) for the full picture before editing. This skill is the action-oriented summary. ## Vocabulary (and what NOT to confuse) Four orthogonal axes — keep them straight: | Axis | Type | Examples | Lives in | | --- | --- | --- | --- | | **Kind** | flat enum (event-type) | `incident.event`, `incident.opened`, `incident.closed`, `wrapped.report`, `agent-score.weekly-digest`, `custom.message` | `NOTIFICATION_KIND_META` in `@domain/notifications` | | **Group** | user-visible category | `signals`, `monitors`, `wrapped_reports`, `agent_score`, `custom_messages`, `personal`, `destinations`, `billing` | `NOTIFICATION_GROUPS` in `@domain/shared` | | **Topic** | sub-toggle inside a group | `signal.discovered`, `signal.escalating`, `signal.regressed`, `signal.reprioritized` | `NOTIFICATION_TOPICS` in `@domain/shared` | | **Channel** | delivery surface | `email`, `slack` | per-channel worker + registry | `AlertIncidentKind` (`issue.new` / `issue.regressed` / `issue.escalating`) is a **fifth** axis — it lives inside the `incident.*` payload and gates the producer step at the project level. It is **not** a `NotificationKind`. The mapping today: `issue.new` and `issue.regressed` → `incident.event` (one-shot, `endedAt = startedAt`); `issue.escalating` → `incident.opened` + later `incident.closed` (sustained, `endedAt` transitions from null). The producer derives the notification kind from `incident.endedAt`, so adding a new sustained or eventful alert kind is purely a `@domain/alerts` change. ## Pipeline at a glance ``` source domain event → domain-events worker → notifications:request--notifications → notifications:create-notification (one per recipient) → notification-email:send (if user prefs allow) ``` Project deletion cascades via a separate path: `ProjectDeleted` → `notifications:delete-by-project`. Producers compute everything; consumers act idempotently. See dev-doc for details. ## Adding a new kind (existing group) 1. Add the kind to `NOTIFICATION_KIND_META` (`packages/domain/notifications/src/entities/notification.ts`) with `{ group, payload }`. 2. Define the payload schema in the same file. Keep it flat — no nested `event` discriminator. 3. Extend `buildIdempotencyKey` (`helpers/idempotency-key.ts`) with the new kind. Pattern: `${kind}:${naturalEntityId}` if there is one, `${kind}:${entityId}:${eventTimestamp}` when the same entity can legitimately fire again (the unique index is permanent), else `${kind}:${generateId()}`. 4. Add per-channel renderers (TS will fail the build until each is present): - In-app: `apps/web/src/routes/_authenticated/-components/notifications/renderers/.tsx` + entry in `notification-item.tsx`'s dispatch. - Email: `packages/domain/email/src/templates/notifications//index.tsx` + entry in `registry.ts`. The renderer is an `Effect` — it can `yield*` any services it needs (e.g. `WrappedReportRepository` for `wrapped.report`). If the renderer needs services beyond `SqlClient`, wire the matching `*Live` layer into the email worker's `rendererLayer` in `apps/workers/src/workers/notification-emailer.ts`. Renderers that only need payload + context use `Effect.tryPromise(() => buildHtml(...))`. 5. If the kind has its own source flow (not just wrapping an existing one): - Add a `request--notifications` task to the `notifications` queue topic. - Write `requestXxxNotificationsUseCase` in `@domain/notifications`. - Route the source domain event in `apps/workers/src/workers/domain-events.ts`. - Add a handler in `apps/workers/src/workers/notifications.ts`. 6. If the kind is tied to a project, set `projectId` on each request so the `ProjectDeleted` cascade cleans it up. 7. Tests alongside each use case + each renderer. **No user-preferences UI change needed.** The new kind inherits the group's existing toggle. ## Adding a new topic (sub-toggle inside a group) Reach for a topic when a group's existing switch is too coarse — the recipient wants this group but not this slice of it. A topic is one entry in `NOTIFICATION_TOPICS` + `NOTIFICATION_TOPIC_META` and one entry in the owning group's `topics` list (all in `packages/domain/shared/src/notification-preferences.ts`); both settings UIs render it from that meta with no further edits. - `defaultEnabled: true` is the normal case — the topic behaves opt-out, matching every other default in the system. - `defaultEnabled: false` makes it **opt-in on both channels at once**. `admitsTopic` is the single resolver behind `shouldSendEmail` and the worker's Slack fan-out, so one flag covers email, Slack, and both settings screens. Use it for topics that fire on routine activity a busy project would read as spam (`signal.reprioritized` fires on every priority increase). - Read `checked` in the UIs through `admitsTopic(...)`, never `?? true` — a hardcoded fallback silently shows an opt-in topic as on. - A topic only filters delivery. The in-app bell row is always written, so a muted topic still shows up in the feed. - A topic filter is the *last* line of defence, not the first. If a whole class of source events is never worth announcing, guard the outbox write instead — `updateSignalTriageUseCase` only emits `SignalReprioritized` for priority *increases*, so a downgrade costs no outbox row, no queue hop, and no producer run. Filtering downstream would burn all three to reach the same silence. ## Adding a new group A new group adds a new user-visible preferences toggle and (optionally) a new project-level gate. 1. Add the group to `NOTIFICATION_GROUPS` and `NOTIFICATION_GROUP_META` in `packages/domain/shared/src/notification-preferences.ts` (groups today: `signals`, `monitors`, `wrapped_reports`, `agent_score`, `custom_messages`, `personal`, `destinations`, `billing`). `notificationPreferencesSchema` is built from `NOTIFICATION_GROUPS` and auto-extends. Set `slackRoutable` on the meta: non-routable groups (e.g. `personal` — single-recipient kinds) are hidden from the Slack routes settings, rejected by the route-config server fns, and skipped by the worker's Slack fan-out; the Slack renderer registry still needs a (stub) entry because it is exhaustive. 2. The user-prefs settings page (`apps/web/src/routes/_authenticated/projects/$projectSlug/settings/account.tsx`) iterates `NOTIFICATION_GROUPS` to render toggles — the new group appears **automatically** with its label/description from the meta. 3. Add at least one kind to the new group (use the "Adding a new kind" steps). 4. **Project-level gate (optional)** — only if the new group should be opt-out-able per project: - Add a slot to `notificationsSettingSchema` in `packages/domain/shared/src/settings.ts`. - Define the inner shape (per-kind, per-target, simple boolean — whatever's useful at the project level). - Add a helper next to `isIncidentNotificationEnabled` and call it from the new producer use case before fan-out. - Update the API `ProjectSettingsSchema` in `packages/operations/src/operations/projects.ts` and regenerate openapi/mcp: ```sh pnpm --filter @app/api openapi:emit pnpm --filter @app/api mcp:emit ``` - Wire the new toggles into `apps/web/src/routes/_authenticated/projects/$projectSlug/settings.tsx`. 5. Tests: extend `request-*-notifications.test.ts` patterns; add a cross-group preference test (group X off, group Y still on). Group keys are persisted in `users.notification_preferences` jsonb — picking a stable group key matters more than a stable label (the label is `NOTIFICATION_GROUP_META[group].label` and can change freely). ## Adding a new channel (Slack, SMS, ...) 1. New queue topic in `packages/domain/queue/src/topic-registry.ts` (e.g. `notification-slack` with `send`). 2. New per-kind renderer registry alongside the channel adapter, keyed on `NotificationKind` (exhaustive `Record`). 3. Extend `channelPreferencesSchema` in `@domain/shared/notification-preferences.ts` with the new channel key (jsonb — no migration). 4. Update the creator step in `apps/workers/src/workers/notifications.ts` to also publish the new channel's `send` task when `prefs[group].` is true. Add a `shouldSend(prefs, kind)` helper alongside `shouldSendEmail` if it grows non-trivial. 5. New worker file mirroring `notification-emailer.ts`. Register it in `apps/workers/src/server.ts`. 6. Settings UI — which surface depends on how the channel is addressed. A **per-user** channel (email, SMS) is a switch on the per-group block in `apps/web/src/routes/_authenticated/projects/$projectSlug/settings/account.tsx`, driven by `channelPreferencesSchema`. An **org-level routed** channel (Slack) is not a user preference at all: it lives in `settings/-components/slack-org-settings.tsx` + `slack-route-row.tsx`, keyed on `NOTIFICATION_GROUP_META[group].slackRoutable`, and skips steps 3–4 entirely. Source events, the producer step, the in-app feed, and the kind registry are all unchanged. ## Embedding server-rendered images in emails Pattern lives in `apps/web/src/routes/api/notifications/$nid/incident-trend[.]png.ts` — useful when a new kind wants a richer email visual than HTML/CSS can produce. 1. **URL**: build at render time from a stable id (notification id). The `buildChartUrl` helper in `@domain/email` embeds the id as a path param. No signing today — the CUID is unguessable and the chart payload is project-internal trend data. If you're embedding more sensitive data (PII, credentials, content the recipient shouldn't see), HMAC-sign the id first; the chart route's TODO points at the contained change. 2. **Render**: TanStack Start file route under `apps/web/src/routes/api/` (project convention for machine-facing routes in `apps/web` — see `api/health.ts`, `api/auth/…`). Use `satori` (JSX → SVG) + `@resvg/resvg-js` (SVG → PNG). Already in `apps/web`'s deps because the wrapped OG card uses the same pipeline. Keeping all PNG-rendering routes in `apps/web` keeps `apps/api` strictly to the authenticated public + MCP surface. 3. **Auth**: unauthenticated. The route uses the admin Postgres client (RLS bypass — no org context until the row is loaded). Read via `getAdminPostgresClient()` from `apps/web/src/server/clients.ts`. 4. **Fallback**: missing id, row gone, wrong kind, unparseable payload, or render failure → 200 with a 1×1 transparent PNG so the `` keeps rendering an element. A broken inbox image is worse than a missing one. 5. **Cache**: `Cache-Control: public, max-age=31536000, immutable`. Mail-client image proxies cache the response. 6. **Email side**: build the URL inside the renderer Effect via `buildChartUrl` from `@domain/email`. `NotificationEmailRenderContext` carries `notificationId` + `webAppUrl`, both resolved once at email-worker boot. ## Idempotency rules - Producers publish with deterministic `dedupeKey`. The queue layer drops duplicate emits. - The creator step inserts via `ON CONFLICT (organization_id, user_id, idempotency_key) DO NOTHING ... RETURNING`. Only the "wrote it" branch publishes downstream channel jobs. - The emailer claims the row via `markEmailed` (`UPDATE … WHERE emailed_at IS NULL RETURNING id`) **before** sending. SMTP failures post-claim are lost emails — the trade-off is zero duplicates, which the design picked over zero misses. - `delete-by-project` is naturally idempotent (`DELETE … RETURNING` returns zero on re-runs). If you change ordering (e.g. send-then-stamp): you'll get duplicate emails. Don't. ## Anti-patterns - ❌ Filtering inside renderers ("don't send if X"). The producer/creator already decided — renderers just render. - ❌ Putting routing info in the kind name. `incident.event` describes what happened, not who needs to know. - ❌ Snapshotting live entity attributes (project name, issue name, project slug) in payloads. Use the row-level `projectId` and `payload.sourceId` and resolve display info downstream (bell: live query / projects collection; email: `IssueRepository` yielded by the renderer + `ctx.project`). Snapshotting **derived** point-in-time facts (trend buckets, breach numbers) is fine and encouraged. - ❌ Reading user prefs in the producer step. Prefs are per-channel and belong in the creator's "should I publish this channel's send task" decision. - ❌ FK constraint on `project_id`. Use the application-layer cascade via `ProjectDeleted` → `delete-by-project`. Per the [database-postgres](../database-postgres/SKILL.md) skill. - ❌ Deduping by source entity id alone. Use `buildIdempotencyKey` — the key must be per-occurrence, not per-entity (multiple incidents on the same issue = multiple notifications). - ❌ Mutating settings keys in place. `NOTIFICATION_GROUPS` entries are persisted in jsonb; renaming a group orphans existing user prefs. Add new groups; deprecate old ones with a no-op renderer if needed. ## See also - [dev-docs/notifications.md](../../../dev-docs/notifications.md) — full reference (concepts, file index, pipeline, defaults, all the details). - [specs/notifications-multi-channel.md](../../../specs/notifications-multi-channel.md) — design spec (why these decisions). - [async-jobs-and-events](../async-jobs-and-events/SKILL.md) — queue/worker conventions, domain-event naming. - [database-postgres](../database-postgres/SKILL.md) — Drizzle, RLS, no-FK rule.