# Notifications

> Multi-channel notifications. Adding a new notification kind, group, or channel; in-app + email delivery; per-user prefs; project-level gates; idempotency.

- Skill: `latitude-dev/notifications` (Agent Skill)
- Install (CLI): `npx skillmds@latest add latitude-dev/notifications`
- Raw SKILL.md: https://api.skillmd.com/api/skills/latitude-dev/notifications/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Productivity
- Author: latitude-dev (https://skillmd.com/u/latitude-dev)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/latitude-dev/notifications

---


# 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`, `custom.message` | `NOTIFICATION_KIND_META` in `@domain/notifications` |
| **Group** | user-visible category | `signals`, `monitors`, `wrapped_reports`, `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-<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)

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/<kind>.tsx` + entry in `notification-item.tsx`'s dispatch.
   - Email: `packages/domain/email/src/templates/notifications/<kind>/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-<kind>-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`, `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].<channel>` is true. Add a `shouldSend<Channel>(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 `<Img>` 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.

