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 for the full picture before editing. This skill is the action-oriented summary.
Vocabulary (and what NOT to confuse)
Three orthogonal axes — keep them straight:
| Axis | Type | Examples | Lives in |
|---|---|---|---|
| Kind | flat enum (event-type) | incident.event, incident.opened, incident.closed, wrapped.report, custom.message |
NOTIFICATION_KIND_META in @domain/notifications |
| Group | user-visible category | incidents, wrapped_reports, custom_messages |
NOTIFICATION_GROUPS in @domain/shared |
| Channel | delivery surface | email, (later: slack, ...) |
per-channel worker + registry |
AlertIncidentKind (issue.new / issue.regressed / issue.escalating) is a fourth 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-<group>-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)
- Add the kind to
NOTIFICATION_KIND_META(packages/domain/notifications/src/entities/notification.ts) with{ group, payload }. - Define the payload schema in the same file. Keep it flat — no nested
eventdiscriminator. - Extend
buildIdempotencyKey(helpers/idempotency-key.ts) with the new kind. Pattern:${kind}:${naturalEntityId}if there is one, else${kind}:${generateId()}. - Add per-channel renderers (TS will fail the build until each is present):
- In-app:
apps/web/src/routes/_authenticated/-components/notifications/renderers/<kind>.tsx+ entry innotification-item.tsx's dispatch. - Email:
packages/domain/email/src/templates/notifications/<kind>/index.tsx+ entry inregistry.ts. The renderer is anEffect— it canyield*any services it needs (e.g.WrappedReportRepositoryforwrapped.report). If the renderer needs services beyondSqlClient, wire the matching*Livelayer into the email worker'srendererLayerinapps/workers/src/workers/notification-emailer.ts. Renderers that only need payload + context useEffect.tryPromise(() => buildHtml(...)).
- In-app:
- If the kind has its own source flow (not just wrapping an existing one):
- Add a
request-<kind>-notificationstask to thenotificationsqueue topic. - Write
requestXxxNotificationsUseCasein@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.
- Add a
- If the kind is tied to a project, set
projectIdon each request so theProjectDeletedcascade cleans it up. - 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 group
A new group adds a new user-visible preferences toggle and (optionally) a new project-level gate.
- Add the group to
NOTIFICATION_GROUPSandNOTIFICATION_GROUP_METAinpackages/domain/shared/src/notification-preferences.ts(groups today:incidents,wrapped_reports,custom_messages,personal).notificationPreferencesSchemais built fromNOTIFICATION_GROUPSand auto-extends. SetslackRoutableon 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. - The user-prefs settings page (
apps/web/src/routes/_authenticated/settings/account.tsx) iteratesNOTIFICATION_GROUPSto render toggles — the new group appears automatically with its label/description from the meta. - Add at least one kind to the new group (use the "Adding a new kind" steps).
- Project-level gate (optional) — only if the new group should be opt-out-able per project:
- Add a slot to
notificationsSettingSchemainpackages/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
isIncidentNotificationEnabledand call it from the new producer use case before fan-out. - Update the API
ProjectSettingsSchemainapps/api/src/routes/projects.tsand regenerate openapi/mcp: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.
- Add a slot to
- Tests: extend
request-*-notifications.test.tspatterns; 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, ...)
- New queue topic in
packages/domain/queue/src/topic-registry.ts(e.g.notification-slackwithsend). - New per-kind renderer registry alongside the channel adapter, keyed on
NotificationKind(exhaustiveRecord). - Extend
channelPreferencesSchemain@domain/shared/notification-preferences.tswith the new channel key (jsonb — no migration). - Update the creator step in
apps/workers/src/workers/notifications.tsto also publish the new channel'ssendtask whenprefs[group].<channel>is true. Add ashouldSend<Channel>(prefs, kind)helper alongsideshouldSendEmailif it grows non-trivial. - New worker file mirroring
notification-emailer.ts. Register it inapps/workers/src/server.ts. - Settings UI in
apps/web/src/routes/_authenticated/settings/account.tsx: extend the per-group block to show one switch per channel.
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/api/src/routes/charts/incident-trend.ts — useful when a new kind wants a richer email visual than HTML/CSS can produce.
- URL: build at render time from a stable id (notification id). The
buildChartUrlhelper in@domain/emailembeds 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. - Render: TanStack Start file route under
apps/web/src/routes/api/(project convention for machine-facing routes inapps/web— seeapi/health.ts,api/auth/…). Usesatori(JSX → SVG) +@resvg/resvg-js(SVG → PNG). Already inapps/web's deps because the wrapped OG card uses the same pipeline. Keeping all PNG-rendering routes inapps/webkeepsapps/apistrictly to the authenticated public + MCP surface. - Auth: unauthenticated. The route uses the admin Postgres client (RLS bypass — no org context until the row is loaded). Read via
getAdminPostgresClient()fromapps/web/src/server/clients.ts. - Fallback: missing id, row gone, wrong kind, unparseable payload, or render failure → 200 with a 1×1 transparent PNG so the
<Img>keeps rendering an element. A broken inbox image is worse than a missing one. - Cache:
Cache-Control: public, max-age=31536000, immutable. Mail-client image proxies cache the response. - Email side: build the URL inside the renderer Effect via
buildChartUrlfrom@domain/email.NotificationEmailRenderContextcarriesnotificationId+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-projectis naturally idempotent (DELETE … RETURNINGreturns 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.eventdescribes what happened, not who needs to know. - ❌ Snapshotting live entity attributes (project name, issue name, project slug) in payloads. Use the row-level
projectIdandpayload.sourceIdand resolve display info downstream (bell: live query / projects collection; email:IssueRepositoryyielded 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 viaProjectDeleted→delete-by-project. Per the database-postgres 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_GROUPSentries 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 — full reference (concepts, file index, pipeline, defaults, all the details).
- specs/notifications-multi-channel.md — design spec (why these decisions).
- async-jobs-and-events — queue/worker conventions, domain-event naming.
- database-postgres — Drizzle, RLS, no-FK rule.