notifications
GitHub处理通知系统的技能,涵盖新增通知类型、分组或渠道,支持应用内和邮件投递,管理用户偏好设置及项目级门控,确保幂等性。
Trigger Scenarios
Install
npx skills add latitude-dev/latitude-llm --skill notifications -g -y
SKILL.md
Frontmatter
{
"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)
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)
- 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,${kind}:${entityId}:${eventTimestamp}when the same entity can legitimately fire again (the unique index is permanent), 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 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: trueis the normal case — the topic behaves opt-out, matching every other default in the system.defaultEnabled: falsemakes it opt-in on both channels at once.admitsTopicis the single resolver behindshouldSendEmailand 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.reprioritizedfires on every priority increase).- Read
checkedin the UIs throughadmitsTopic(...), 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 —
updateSignalTriageUseCaseonly emitsSignalReprioritizedfor 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.
- Add the group to
NOTIFICATION_GROUPSandNOTIFICATION_GROUP_METAinpackages/domain/shared/src/notification-preferences.ts(groups today:signals,monitors,wrapped_reports,custom_messages,personal,destinations,billing).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/projects/$projectSlug/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
ProjectSettingsSchemainpackages/operations/src/operations/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 — 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 bychannelPreferencesSchema. An org-level routed channel (Slack) is not a user preference at all: it lives insettings/-components/slack-org-settings.tsx+slack-route-row.tsx, keyed onNOTIFICATION_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.
- 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.
Version History
- 2479822 Current 2026-08-20 10:36


