ワンクリックで
notifications
// Multi-channel notifications. Adding a new notification kind, group, or channel; in-app + email delivery; per-user prefs; project-level gates; idempotency.
// Multi-channel notifications. Adding a new notification kind, group, or channel; in-app + email delivery; per-user prefs; project-level gates; idempotency.
Preparing a production release, pushing a vX.Y.Z release tag, running scripts/release.sh, or updating CHANGELOG.md with the changes that are about to be deployed to production.
apps/web UI — routes, @repo/ui, TanStack Start server functions and collections, navigation (Link vs useNavigate), forms (useForm + createFormSubmitHandler + fieldErrorsAsStrings for Zod field errors), Tailwind layout rules, design-system updates, and useEffect / useMountEffect policy.
Review the current conversation context and git changes, then persist durable repository knowledge into `dev-docs/*.md` by domain and into `AGENTS.md` for cross-cutting repo rules. Use after features, fixes, refactors, architecture changes, schema changes, or when the user mentions docs, documentation, design, architecture, business logic, conventions, or `AGENTS.md`.
ClickHouse queries, Goose migrations, chdb test schema, or telemetry storage paths.
Adding or changing routes in `apps/api`. One source of truth (`defineApiEndpoint` + a Zod schema) becomes an HTTP endpoint, an OpenAPI operation, an MCP tool, and a TS SDK method — descriptions and contracts must be written with all four readers in mind.
Drizzle schema, repositories, RLS, SqlClient wiring, Postgres migrations, psql / reset, or platform mappers (toDomain* / toInsertRow).
| 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. |
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.
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.
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.
NOTIFICATION_KIND_META (packages/domain/notifications/src/entities/notification.ts) with { group, payload }.event discriminator.buildIdempotencyKey (helpers/idempotency-key.ts) with the new kind. Pattern: ${kind}:${naturalEntityId} if there is one, else ${kind}:${generateId()}.apps/web/src/routes/_authenticated/-components/notifications/renderers/<kind>.tsx + entry in notification-item.tsx's dispatch.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(...)).request-<kind>-notifications task to the notifications queue topic.requestXxxNotificationsUseCase in @domain/notifications.apps/workers/src/workers/domain-events.ts.apps/workers/src/workers/notifications.ts.projectId on each request so the ProjectDeleted cascade cleans it up.No user-preferences UI change needed. The new kind inherits the group's existing toggle.
A new group adds a new user-visible preferences toggle and (optionally) a new project-level gate.
NOTIFICATION_GROUPS and NOTIFICATION_GROUP_META in packages/domain/shared/src/notification-preferences.ts. notificationPreferencesSchema is built from NOTIFICATION_GROUPS and auto-extends.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.notificationsSettingSchema in packages/domain/shared/src/settings.ts.isIncidentNotificationEnabled and call it from the new producer use case before fan-out.ProjectSettingsSchema in apps/api/src/routes/projects.ts and regenerate openapi/mcp:
pnpm --filter @app/api openapi:emit
pnpm --filter @app/api mcp:emit
apps/web/src/routes/_authenticated/projects/$projectSlug/settings.tsx.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).
packages/domain/queue/src/topic-registry.ts (e.g. notification-slack with send).NotificationKind (exhaustive Record).channelPreferencesSchema in @domain/shared/notification-preferences.ts with the new channel key (jsonb — no migration).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.notification-emailer.ts. Register it in apps/workers/src/server.ts.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.
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.
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.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.getAdminPostgresClient() from apps/web/src/server/clients.ts.<Img> keeps rendering an element. A broken inbox image is worse than a missing one.Cache-Control: public, max-age=31536000, immutable. Mail-client image proxies cache the response.buildChartUrl from @domain/email. NotificationEmailRenderContext carries notificationId + webAppUrl, both resolved once at email-worker boot.dedupeKey. The queue layer drops duplicate emits.ON CONFLICT (organization_id, user_id, idempotency_key) DO NOTHING ... RETURNING. Only the "wrote it" branch publishes downstream channel jobs.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.
incident.event describes what happened, not who needs to know.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.project_id. Use the application-layer cascade via ProjectDeleted → delete-by-project. Per the database-postgres skill.buildIdempotencyKey — the key must be per-occurrence, not per-entity (multiple incidents on the same issue = multiple notifications).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.