notifications

star 4.1k

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

latitude-dev By latitude-dev schedule Updated 6/11/2026

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.regressedincident.event (one-shot, endedAt = startedAt); issue.escalatingincident.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: ProjectDeletednotifications: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, 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 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: incidents, wrapped_reports, custom_messages, personal). 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/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 apps/api/src/routes/projects.ts and 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.
  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 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.

  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 ProjectDeleteddelete-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_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

Install via CLI
npx skills add https://github.com/latitude-dev/latitude-llm --skill notifications
Repository Details
star Stars 4,142
call_split Forks 329
navigation Branch main
article Path SKILL.md
More from Creator
latitude-dev
latitude-dev Explore all skills →