@checkstack/notification-common 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,205 @@
1
1
  # @checkstack/notification-common
2
2
 
3
+ ## 1.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - 32d52c6: feat: notification target pattern + per-spec subscriptions
8
+
9
+ Replaces the all-or-nothing catalog system/group notification model with a
10
+ platform-level target pattern. Each notification-emitting plugin declares
11
+ _subscription specs_ against typed _target_ objects exported from the
12
+ target's owning plugin (catalog ships `catalogSystemTarget` and
13
+ `catalogGroupTarget`). Notification-backend handles every per-resource
14
+ group lifecycle, parent-edge inheritance, and legacy-subscription seeding
15
+ — plugins never author groupId helpers, lifecycle hooks, or migration
16
+ code again.
17
+
18
+ **Plugin-author surface area is now ~12 lines per emitter:**
19
+
20
+ ```ts
21
+ // <plugin>-common
22
+ const { defineSubscription } = createSubscriptionFactory(pluginMetadata);
23
+ export const fooSystemSubscription = defineSubscription({
24
+ localId: "system",
25
+ target: catalogSystemTarget,
26
+ display: { title: "Foo Alerts", description: "...", iconName: "Bell" },
27
+ });
28
+
29
+ // <plugin>-backend register()
30
+ env.registerSubscriptionSpecs([fooSystemSubscription]);
31
+ // ^ feeds the plugin loader's dependency sorter — each spec's
32
+ // target.ownerPlugin becomes an implicit init-order dep, so this
33
+ // plugin automatically waits for catalog (the target owner) to
34
+ // finish init + afterPluginsReady before its own runs.
35
+
36
+ // <plugin>-backend afterPluginsReady
37
+ await notificationClient.registerSubscriptionSpec(
38
+ specToRegistration(fooSystemSubscription)
39
+ );
40
+ // dispatch
41
+ await notificationClient.notifyForSubscription({
42
+ specId: fooSystemSubscription.specId,
43
+ resourceKeys: [systemId],
44
+ title,
45
+ body,
46
+ importance,
47
+ action,
48
+ collapseKey,
49
+ subjects,
50
+ });
51
+
52
+ // <plugin>-frontend
53
+ createNotificationSubscriptionExtension({ spec: fooSystemSubscription });
54
+ ```
55
+
56
+ **Migrated plugins**: anomaly, incident, maintenance, healthcheck,
57
+ dependency. Each lost its bespoke `notification-groups.ts`,
58
+ `bootstrap*NotificationGroups`, `ensure*Group`, and inheritance walk —
59
+ all of that is now centralized in notification-backend's
60
+ `subscription-engine`.
61
+
62
+ **Plugin loader change** (`@checkstack/backend-api`,
63
+ `@checkstack/backend`): the register-time API gains
64
+ `env.registerSubscriptionSpecs([...specs])`. The dependency sorter
65
+ walks `spec.target.ownerPlugin` for every declared spec and adds the
66
+ target owner as an init-order dependency of the emitting plugin. This
67
+ guarantees that catalog (the owner of the platform's `system` and
68
+ `group` targets) completes init + afterPluginsReady before any
69
+ emitting plugin tries to register its specs against the notification
70
+ service — no string-prefix heuristics, no manual `dependsOnPlugins`
71
+ list, no stub rows. Plugins that fail to declare their specs at
72
+ register time get a clear `Target type X is not registered. Did the
73
+ emitting plugin declare this spec via env.registerSubscriptionSpecs?`
74
+ error from the dispatcher.
75
+
76
+ **Removed** (no backwards compat):
77
+
78
+ - `catalogClient.notifySystemSubscribers` and
79
+ `catalogClient.notifyManySystemSubscribers`
80
+ - `notificationClient.notifyUsers` and `notificationClient.notifyGroups`
81
+ as direct dispatch primitives — replaced by spec-bound
82
+ `notifyForSubscription`
83
+ - catalog's `bootstrapNotificationGroups` (replaced by
84
+ `bootstrapNotificationTargets`)
85
+
86
+ **Enforcement**: the dispatcher rejects calls referencing unregistered
87
+ specIds, specs owned by other plugins, or resourceKeys that haven't been
88
+ pushed via `upsertNotificationResource`. Display metadata for any
89
+ groupId is recoverable via the spec registry, so audit lists render
90
+ correct labels even when an emitter's frontend isn't loaded.
91
+
92
+ **Per-field anomaly mute** keeps working — it now lives inside the
93
+ generic SubscriptionRow's optional `SubControls` panel
94
+ (`AnomalyFieldMuteList`), exposed through the catalog system detail
95
+ page's notifications card.
96
+
97
+ The catalog system detail page renders a "Notifications" card hosting
98
+ `SystemNotificationSubscriptionsSlot`. The matching group surface is
99
+ not yet rendered — group-level subscriptions are wired end-to-end on
100
+ the backend; a follow-up will add the host UI.
101
+
102
+ **Migration of existing subscribers**: target types declare a
103
+ `legacyGroupIdTemplate`; on first registration of each spec,
104
+ notification-backend reads subscribers from the legacy
105
+ `catalog.system.<id>` / `catalog.group.<id>` groups and seeds the new
106
+ spec groups exactly once per (spec × resource) pair, tracked in
107
+ `subscription_migrations`. Anomaly stays opt-in (its target also
108
+ declares the template, but the user-explicit nature of the original
109
+ opt-in flow means the seeding produces the same set of subscribers
110
+ they already had).
111
+
112
+ ### Minor Changes
113
+
114
+ - 32d52c6: feat(anomaly): per-system and per-field notification mute
115
+
116
+ Anomaly notifications now flow through their own subscription group
117
+ (`anomaly.system.<systemId>`) instead of the shared catalog system group, so
118
+ users can opt out of anomaly noise without losing incident or healthcheck
119
+ alerts for the same system. On first deploy, existing subscribers of each
120
+ `catalog.system.<id>` group are seeded onto the new anomaly group so no one
121
+ silently stops getting alerts.
122
+
123
+ A new mute table (`anomaly_notification_mutes`) backs two granularities:
124
+
125
+ - **Per-field**: silence a single noisy metric on one system.
126
+ - **Per-system**: silence every anomaly for one system in one click.
127
+
128
+ The system anomaly widget now exposes a bell icon on each anomaly row plus a
129
+ `Mute all` toggle in the card header. Mutes are user-scoped and persist
130
+ across sessions.
131
+
132
+ Catalog gains a `systemCreated` hook so anomaly (and any future plugin) can
133
+ provision per-system state on creation rather than waiting for a restart.
134
+ The notification service gains a `bulkSubscribe` service-RPC used by the
135
+ one-time migration described above.
136
+
137
+ - 32d52c6: Bulk notifications affecting multiple systems and collapse lifecycle events into a single card.
138
+
139
+ Notifications now carry an optional `subjects` array (the entities they affect) and an optional `collapseKey` (so related notifications collapse into one row per recipient). Incidents, maintenances, anomalies, healthchecks, and dependency-impact events route through these new fields, so an incident affecting three systems produces one in-app notification + one external send per subscriber instead of three. Lifecycle updates for the same entity (created → updated → resolved) also collapse, with an expandable "+N updates" timeline.
140
+
141
+ Subject kinds are namespaced as `<pluginId>.<localKind>` and built via type-safe helpers exported from each domain's common package (`createSystemSubject`, `incidentCollapseKey`, etc.). The frontend kind registry (`registerSubjectKind`) lets plugins bind icon + label for their kinds; unknown kinds fall back to a generic chip.
142
+
143
+ All notification strategies (SMTP, Slack, Discord, Teams, Telegram, Pushover, Gotify, Webex, Backstage) render the affected subjects natively in their format (HTML cards, Slack blocks, Discord embed fields, adaptive cards, markdown lists, etc.).
144
+
145
+ - 32d52c6: feat: unified notification-subscription manager dialog driven by spec registry
146
+
147
+ Replaces the bell-toggle UX (which only managed a single legacy
148
+ catalog group) with a modal that lists every notification type
149
+ registered against a target — system or group — and exposes both
150
+ per-type toggles and a bulk "Subscribe to all / Unsubscribe from all"
151
+ action. Both surfaces (system detail page header bell, dashboard group
152
+ header bell) now open the same `NotificationSubscriptionsManager`
153
+ component.
154
+
155
+ **Key change vs. the prior slot-based approach**: rows are now driven
156
+ by `notificationClient.listSubscriptionSpecs` — the backend's spec
157
+ registry is the single source of truth. Previously, a row only
158
+ appeared if a frontend plugin had remembered to register a
159
+ `createNotificationSubscriptionExtension`; this caused silent drift
160
+ (healthcheck and dependency registered backend specs without frontend
161
+ extensions, so the dialog counted them but never rendered rows). Now,
162
+ every spec the platform knows about renders a row using the spec's
163
+ `display` metadata (title, description, iconName resolved via
164
+ `DynamicIcon`).
165
+
166
+ **Sub-controls registry** (`@checkstack/notification-frontend`):
167
+ plugins that want sub-granularity (anomaly's per-field mute list,
168
+ future severity / channel filters) call
169
+ `registerSubscriptionSubControls(spec, Component)` at module load —
170
+ the manager looks the component up by `specId` when expanding a row.
171
+
172
+ **Removed (no compat)**:
173
+
174
+ - `createNotificationSubscriptionExtension` (replaced by the
175
+ spec-driven manager + the SubControls registry)
176
+ - `target.slot` field on `NotificationTarget` and the
177
+ `NotificationTargetInput.slot` parameter on
178
+ `defineNotificationTarget`
179
+ - `SystemNotificationSubscriptionsSlot` and
180
+ `GroupNotificationSubscriptionsSlot` from `@checkstack/catalog-common`
181
+ - `SystemNotificationsCard` from the system detail page's main column
182
+ - `SubscribeButton` wiring on dashboard group cards and the system
183
+ detail page header
184
+
185
+ **Migrated frontends**: anomaly (now registers `AnomalyFieldMuteList`
186
+ via the SubControls registry), incident, maintenance — all dropped
187
+ their `createNotificationSubscriptionExtension` calls. healthcheck and
188
+ dependency now show up automatically via the spec registry — no
189
+ frontend changes needed for them to render.
190
+
191
+ The trigger button reflects aggregate state — filled bell when at
192
+ least one spec is subscribed for the resource, ghost bell when none.
193
+
194
+ ### Patch Changes
195
+
196
+ - 32d52c6: Fix and improve password reset flow + email branding:
197
+
198
+ - **Fix**: password reset emails were failing with "Malformed password reset URL: missing token parameter". Better-auth puts the reset token in the URL path (`/reset-password/{token}`), not as a `?token=` query param, so the previous URL-parsing logic always failed. Now uses the `token` argument better-auth passes to `sendResetPassword` directly.
199
+ - **UX**: the reset password page now validates the token on load via a new anonymous `validateResetToken` endpoint, so users see "Invalid Link" / "Link Expired" before typing a password rather than after submitting. Tokens are 24-char nanoid-style values (~143 bits of entropy), so exposing validity does not enable enumeration.
200
+ - **Fix**: transactional notifications were hardcoded to `importance: "critical"`, causing password reset emails to display a misleading "CRITICAL" badge. The `sendTransactional` contract now accepts an optional `importance` field that defaults to `"info"`.
201
+ - **Branding**: redesigned the email layout (`wrapInEmailLayout`) with a Checkstack-style engineering aesthetic — dark header with grid pattern, monospace importance badge, hardened CTA button (Outlook VML fallback + explicit text color), and force-light color scheme to prevent client auto-inversion from breaking text legibility.
202
+
3
203
  ## 0.3.0
4
204
 
5
205
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/notification-common",
3
- "version": "0.3.0",
3
+ "version": "1.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@checkstack/common": "0.7.0",
13
- "@checkstack/signal-common": "0.1.10",
13
+ "@checkstack/signal-common": "0.2.0",
14
14
  "@orpc/contract": "^1.13.14",
15
15
  "zod": "^4.0.0"
16
16
  },
@@ -0,0 +1,82 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ createSubjectKindBuilder,
4
+ createCollapseKeyBuilder,
5
+ } from "./builders";
6
+ import { NotificationSubjectSchema } from "./schemas";
7
+
8
+ describe("createSubjectKindBuilder", () => {
9
+ const pluginMetadata = { pluginId: "catalog" };
10
+ const createSystemSubject = createSubjectKindBuilder(
11
+ pluginMetadata,
12
+ "system",
13
+ );
14
+
15
+ it("namespaces the kind as '<pluginId>.<localKind>'", () => {
16
+ const subject = createSystemSubject({ id: "sys-1", name: "Test" });
17
+ expect(subject.kind).toBe("catalog.system");
18
+ });
19
+
20
+ it("preserves all entity fields", () => {
21
+ const subject = createSystemSubject({
22
+ id: "sys-1",
23
+ name: "Test",
24
+ url: "/systems/sys-1",
25
+ status: "unhealthy",
26
+ });
27
+ expect(subject).toEqual({
28
+ kind: "catalog.system",
29
+ id: "sys-1",
30
+ name: "Test",
31
+ url: "/systems/sys-1",
32
+ status: "unhealthy",
33
+ });
34
+ });
35
+
36
+ it("produces values that pass NotificationSubjectSchema validation", () => {
37
+ const subject = createSystemSubject({ id: "sys-1", name: "Test" });
38
+ expect(() => NotificationSubjectSchema.parse(subject)).not.toThrow();
39
+ });
40
+
41
+ it("each builder is independent across plugins and local kinds", () => {
42
+ const incidentSubject = createSubjectKindBuilder(
43
+ { pluginId: "incident" },
44
+ "incident",
45
+ )({ id: "inc-1", name: "Outage" });
46
+ const groupSubject = createSubjectKindBuilder(
47
+ pluginMetadata,
48
+ "group",
49
+ )({ id: "grp-1", name: "Team" });
50
+
51
+ expect(incidentSubject.kind).toBe("incident.incident");
52
+ expect(groupSubject.kind).toBe("catalog.group");
53
+ });
54
+ });
55
+
56
+ describe("createCollapseKeyBuilder", () => {
57
+ const pluginMetadata = { pluginId: "incident" };
58
+ const incidentCollapseKey = createCollapseKeyBuilder(
59
+ pluginMetadata,
60
+ "incident",
61
+ );
62
+
63
+ it("returns '<pluginId>.<localKind>.<entityId>' for single ids", () => {
64
+ expect(incidentCollapseKey("inc-1")).toBe("incident.incident.inc-1");
65
+ });
66
+
67
+ it("joins multiple id components with dots", () => {
68
+ const anomalyKey = createCollapseKeyBuilder(
69
+ { pluginId: "anomaly" },
70
+ "anomaly",
71
+ );
72
+ expect(anomalyKey("sys-1", "cpu.usage")).toBe(
73
+ "anomaly.anomaly.sys-1.cpu.usage",
74
+ );
75
+ });
76
+
77
+ it("uses the same prefix for every call", () => {
78
+ const a = incidentCollapseKey("inc-1");
79
+ const b = incidentCollapseKey("inc-2");
80
+ expect(a.split(".").slice(0, 2)).toEqual(b.split(".").slice(0, 2));
81
+ });
82
+ });
@@ -0,0 +1,86 @@
1
+ import type { NotificationSubject } from "./schemas";
2
+
3
+ /**
4
+ * Plugin-metadata shape relied on by the builders below. Mirrors the
5
+ * `pluginId` field on the `definePluginMetadata` return type without
6
+ * importing the rest of `@checkstack/common` into this file.
7
+ */
8
+ interface PluginMetadataLike {
9
+ pluginId: string;
10
+ }
11
+
12
+ /**
13
+ * Returns a typed builder that produces `NotificationSubject` instances for
14
+ * a specific plugin's local entity kind. The resulting `kind` string is
15
+ * always namespaced as `<pluginId>.<localKind>`, so callers can never
16
+ * forget the prefix and the namespace updates automatically if a plugin's
17
+ * id changes.
18
+ *
19
+ * Example:
20
+ * ```ts
21
+ * import { createSubjectKindBuilder } from "@checkstack/notification-common";
22
+ * import { pluginMetadata } from "./plugin-metadata";
23
+ *
24
+ * export const createSystemSubject = createSubjectKindBuilder(
25
+ * pluginMetadata,
26
+ * "system",
27
+ * );
28
+ *
29
+ * // Elsewhere:
30
+ * const subject = createSystemSubject({
31
+ * id: "sys-1",
32
+ * name: "Production DB",
33
+ * url: "/system/sys-1",
34
+ * });
35
+ * // -> { kind: "catalog.system", id: "sys-1", name: "Production DB", url: ... }
36
+ * ```
37
+ */
38
+ export function createSubjectKindBuilder(
39
+ pluginMetadata: PluginMetadataLike,
40
+ localKind: string,
41
+ ): (props: {
42
+ id: string;
43
+ name: string;
44
+ url?: string;
45
+ status?: NotificationSubject["status"];
46
+ }) => NotificationSubject {
47
+ const kind = `${pluginMetadata.pluginId}.${localKind}`;
48
+ return (props) => ({
49
+ kind,
50
+ id: props.id,
51
+ name: props.name,
52
+ url: props.url,
53
+ status: props.status,
54
+ });
55
+ }
56
+
57
+ /**
58
+ * Returns a builder that produces collapse keys for a specific plugin's
59
+ * local entity kind. Output is `<pluginId>.<localKind>.<entityId>`, the
60
+ * convention used by the frontend to collapse related notifications into a
61
+ * single card.
62
+ *
63
+ * Example:
64
+ * ```ts
65
+ * import { createCollapseKeyBuilder } from "@checkstack/notification-common";
66
+ * import { pluginMetadata } from "./plugin-metadata";
67
+ *
68
+ * export const incidentCollapseKey = createCollapseKeyBuilder(
69
+ * pluginMetadata,
70
+ * "incident",
71
+ * );
72
+ *
73
+ * // Elsewhere:
74
+ * incidentCollapseKey("inc-42"); // -> "incident.incident.inc-42"
75
+ * ```
76
+ *
77
+ * The variadic suffix lets you scope keys further when the entity isn't a
78
+ * single id (e.g., `(systemId, fieldPath)` for an anomaly).
79
+ */
80
+ export function createCollapseKeyBuilder(
81
+ pluginMetadata: PluginMetadataLike,
82
+ localKind: string,
83
+ ): (...entityIds: [string, ...string[]]) => string {
84
+ const prefix = `${pluginMetadata.pluginId}.${localKind}`;
85
+ return (...entityIds) => `${prefix}.${entityIds.join(".")}`;
86
+ }
package/src/index.ts CHANGED
@@ -3,4 +3,7 @@ export * from "./access";
3
3
  export * from "./rpc-contract";
4
4
  export * from "./signals";
5
5
  export * from "./plugin-metadata";
6
+ export * from "./builders";
7
+ export * from "./targets";
8
+ export * from "./subscriptions";
6
9
  export { notificationRoutes } from "./routes";
@@ -8,8 +8,64 @@ import {
8
8
  EnrichedSubscriptionSchema,
9
9
  RetentionSettingsSchema,
10
10
  PaginationInputSchema,
11
+ NotificationSubjectSchema,
11
12
  } from "./schemas";
12
13
 
14
+ // Shared input fragments for the notify* procedures.
15
+ const NotificationActionInput = z
16
+ .object({
17
+ label: z.string(),
18
+ url: z.string(),
19
+ })
20
+ .optional();
21
+ const NotificationCollapseKeyInput = z
22
+ .string()
23
+ .min(1)
24
+ .optional()
25
+ .describe(
26
+ "Optional collapse key. Notifications with the same (userId, collapseKey) collapse into one card on the frontend. Convention: '<pluginId>.<entityKind>.<entityId>'.",
27
+ );
28
+ const NotificationSubjectsInput = z
29
+ .array(NotificationSubjectSchema)
30
+ .min(1)
31
+ .describe(
32
+ "Affected entities. Required so every dispatched notification can be cross-referenced with its subscription spec / resource. Renders as chips in-app and as native rich elements per strategy.",
33
+ );
34
+
35
+ // ─── Subscription-spec / target contract types ───────────────────────────────
36
+
37
+ const SubscriptionDisplaySchema = z.object({
38
+ title: z.string(),
39
+ description: z.string(),
40
+ iconName: z.string().optional(),
41
+ });
42
+
43
+ const SubscriptionSpecRecordSchema = z.object({
44
+ specId: z.string(),
45
+ ownerPlugin: z.string(),
46
+ localId: z.string(),
47
+ targetTypeId: z.string(),
48
+ display: SubscriptionDisplaySchema,
49
+ });
50
+
51
+ const NotificationTargetRecordSchema = z.object({
52
+ targetTypeId: z.string(),
53
+ ownerPlugin: z.string(),
54
+ resourceKind: z.string(),
55
+ parentTargetTypeId: z.string().optional(),
56
+ legacyGroupIdTemplate: z
57
+ .string()
58
+ .optional()
59
+ .describe(
60
+ "Template like 'catalog.system.{resourceKey}'. Backend substitutes {resourceKey} once per (spec × resource) to seed initial subscribers.",
61
+ ),
62
+ });
63
+
64
+ const NotificationResourceSchema = z.object({
65
+ resourceKey: z.string(),
66
+ displayLabel: z.string(),
67
+ });
68
+
13
69
  // Notification RPC Contract
14
70
  export const notificationContract = {
15
71
  // ==========================================================================
@@ -95,6 +151,22 @@ export const notificationContract = {
95
151
  .input(z.object({ groupId: z.string() }))
96
152
  .output(z.void()),
97
153
 
154
+ /**
155
+ * Bulk subscription-status lookup for the current user. Used by the
156
+ * generic `<SubscriptionRow>` component when several specs from
157
+ * different plugins render against the same resource — each spec's row
158
+ * needs to know "am I subscribed to this groupId?" but doing N
159
+ * roundtrips would be wasteful. Pass every candidate groupId in one
160
+ * call and receive a map back.
161
+ */
162
+ getMySubscriptionStatus: proc({
163
+ operationType: "query",
164
+ userType: "user",
165
+ access: [],
166
+ })
167
+ .input(z.object({ groupIds: z.array(z.string()) }))
168
+ .output(z.record(z.string(), z.boolean())),
169
+
98
170
  // ==========================================================================
99
171
  // ADMIN SETTINGS ENDPOINTS (userType: "user" with admin access)
100
172
  // ==========================================================================
@@ -179,49 +251,195 @@ export const notificationContract = {
179
251
  )
180
252
  .output(z.object({ userIds: z.array(z.string()) })),
181
253
 
182
- // Send notifications to a list of users (deduplicated by caller)
183
- notifyUsers: proc({
254
+ /**
255
+ * Subscribe a batch of users to a group in one call. Used by plugins
256
+ * during bootstrap/migration when establishing default subscribers for
257
+ * a newly-introduced group (e.g. mirroring existing catalog system
258
+ * subscribers onto a derived anomaly group). Idempotent.
259
+ */
260
+ bulkSubscribe: proc({
184
261
  operationType: "mutation",
185
262
  userType: "service",
186
263
  access: [],
187
264
  })
188
265
  .input(
189
266
  z.object({
267
+ groupId: z.string().describe("Full namespaced group ID"),
190
268
  userIds: z.array(z.string()),
191
- title: z.string(),
192
- body: z.string().describe("Notification body (supports markdown)"),
193
- importance: z.enum(["info", "warning", "critical"]).optional(),
194
- action: z
195
- .object({
196
- label: z.string(),
197
- url: z.string(),
198
- })
199
- .optional(),
200
269
  })
201
270
  )
202
- .output(z.object({ notifiedCount: z.number() })),
271
+ .output(z.object({ subscribedCount: z.number() })),
272
+
273
+ /**
274
+ * Register (or update) a notification target type. Target owners call
275
+ * this on startup. notification-backend persists the metadata + tracks
276
+ * registered targets so it can route resource lifecycle events and
277
+ * resolve dispatch parents. Idempotent.
278
+ */
279
+ registerNotificationTarget: proc({
280
+ operationType: "mutation",
281
+ userType: "service",
282
+ access: [],
283
+ })
284
+ .input(NotificationTargetRecordSchema)
285
+ .output(z.object({ success: z.boolean() })),
286
+
287
+ /** Lists every registered target type — used by audit/settings UIs. */
288
+ listNotificationTargets: proc({
289
+ operationType: "query",
290
+ userType: "authenticated",
291
+ access: [],
292
+ }).output(z.array(NotificationTargetRecordSchema)),
293
+
294
+ /**
295
+ * Push (or refresh) a single resource of a target type. Owners call
296
+ * this on resource creation and on rename. notification-backend
297
+ * provisions a notification group for every registered spec whose
298
+ * target matches, runs the legacy-migration seed if declared, and
299
+ * stores the display label for audit UIs.
300
+ */
301
+ upsertNotificationResource: proc({
302
+ operationType: "mutation",
303
+ userType: "service",
304
+ access: [],
305
+ })
306
+ .input(
307
+ z.object({
308
+ targetTypeId: z.string(),
309
+ resource: NotificationResourceSchema,
310
+ }),
311
+ )
312
+ .output(z.object({ success: z.boolean() })),
313
+
314
+ /**
315
+ * Bulk variant. Used by target owners on platform startup to seed all
316
+ * existing resources at once without N round-trips.
317
+ */
318
+ upsertNotificationResources: proc({
319
+ operationType: "mutation",
320
+ userType: "service",
321
+ access: [],
322
+ })
323
+ .input(
324
+ z.object({
325
+ targetTypeId: z.string(),
326
+ resources: z.array(NotificationResourceSchema),
327
+ }),
328
+ )
329
+ .output(z.object({ upserted: z.number() })),
330
+
331
+ /**
332
+ * Remove a resource — notification-backend deletes every group derived
333
+ * from it across every registered spec whose target matches.
334
+ */
335
+ removeNotificationResource: proc({
336
+ operationType: "mutation",
337
+ userType: "service",
338
+ access: [],
339
+ })
340
+ .input(
341
+ z.object({
342
+ targetTypeId: z.string(),
343
+ resourceKey: z.string(),
344
+ }),
345
+ )
346
+ .output(z.object({ removedGroups: z.number() })),
347
+
348
+ /**
349
+ * Replace the full parent set for a child resource. Owners call this
350
+ * whenever a child's parents change — catalog calls it on
351
+ * `addSystemToGroup` / `removeSystemFromGroup` / system create. The
352
+ * dispatcher reads these edges (plus the spec→target mapping) at
353
+ * dispatch time to compute inherited group ids without re-walking
354
+ * through the owner.
355
+ */
356
+ setNotificationResourceParents: proc({
357
+ operationType: "mutation",
358
+ userType: "service",
359
+ access: [],
360
+ })
361
+ .input(
362
+ z.object({
363
+ childTargetTypeId: z.string(),
364
+ childResourceKey: z.string(),
365
+ parents: z.array(
366
+ z.object({
367
+ parentTargetTypeId: z.string(),
368
+ parentResourceKey: z.string(),
369
+ }),
370
+ ),
371
+ }),
372
+ )
373
+ .output(z.object({ success: z.boolean() })),
374
+
375
+ /**
376
+ * List known resources for a target type. Read-only convenience used
377
+ * by the settings page audit and by the spec-registration flow during
378
+ * group provisioning.
379
+ */
380
+ listNotificationResources: proc({
381
+ operationType: "query",
382
+ userType: "authenticated",
383
+ access: [],
384
+ })
385
+ .input(z.object({ targetTypeId: z.string() }))
386
+ .output(z.array(NotificationResourceSchema)),
387
+
388
+ /**
389
+ * Register (or update) a notification subscription spec. Plugins call
390
+ * this on startup once per spec they own. notification-backend joins
391
+ * the spec against every existing resource of `targetTypeId` and
392
+ * provisions per-resource groups. Idempotent.
393
+ */
394
+ registerSubscriptionSpec: proc({
395
+ operationType: "mutation",
396
+ userType: "service",
397
+ access: [],
398
+ })
399
+ .input(SubscriptionSpecRecordSchema)
400
+ .output(z.object({ success: z.boolean() })),
203
401
 
204
- // Notify all subscribers of multiple groups (deduplicates internally)
205
- notifyGroups: proc({
402
+ /**
403
+ * Returns every currently-registered spec. Used by the settings UI to
404
+ * decorate subscription rows with display metadata even for plugins
405
+ * whose frontend isn't loaded.
406
+ */
407
+ listSubscriptionSpecs: proc({
408
+ operationType: "query",
409
+ userType: "authenticated",
410
+ access: [],
411
+ }).output(z.array(SubscriptionSpecRecordSchema)),
412
+
413
+ /**
414
+ * The sanctioned dispatch path. Caller supplies a registered specId
415
+ * and one or more resource keys; notification-backend resolves
416
+ * primary group ids, walks the target's parent chain to compute
417
+ * inherited group ids (joined against the same plugin's specs whose
418
+ * target matches the parent target), unions subscribers, applies
419
+ * `excludeUserIds`, and delivers.
420
+ *
421
+ * Enforcement:
422
+ * - specId must exist and be owned by the calling service plugin.
423
+ * - resourceKeys must reference resources currently registered for
424
+ * the spec's target — backend rejects unknown keys.
425
+ */
426
+ notifyForSubscription: proc({
206
427
  operationType: "mutation",
207
428
  userType: "service",
208
429
  access: [],
209
430
  })
210
431
  .input(
211
432
  z.object({
212
- groupIds: z
213
- .array(z.string())
214
- .describe("Full namespaced group IDs to notify"),
433
+ specId: z.string(),
434
+ resourceKeys: z.array(z.string()).min(1),
435
+ excludeUserIds: z.array(z.string()).optional(),
215
436
  title: z.string(),
216
437
  body: z.string().describe("Notification body (supports markdown)"),
217
438
  importance: z.enum(["info", "warning", "critical"]).optional(),
218
- action: z
219
- .object({
220
- label: z.string(),
221
- url: z.string(),
222
- })
223
- .optional(),
224
- })
439
+ action: NotificationActionInput,
440
+ collapseKey: NotificationCollapseKeyInput,
441
+ subjects: NotificationSubjectsInput,
442
+ }),
225
443
  )
226
444
  .output(z.object({ notifiedCount: z.number() })),
227
445
 
@@ -237,6 +455,10 @@ export const notificationContract = {
237
455
  notification: z.object({
238
456
  title: z.string(),
239
457
  body: z.string().describe("Notification body (supports markdown)"),
458
+ importance: z
459
+ .enum(["info", "warning", "critical"])
460
+ .optional()
461
+ .describe("Severity of the message; defaults to 'info'"),
240
462
  action: z
241
463
  .object({
242
464
  label: z.string(),
package/src/schemas.ts CHANGED
@@ -11,6 +11,47 @@ export const NotificationActionSchema = z.object({
11
11
  });
12
12
  export type NotificationAction = z.infer<typeof NotificationActionSchema>;
13
13
 
14
+ /**
15
+ * An entity affected by a notification (e.g., a system impacted by an
16
+ * incident, or a system on which a shared healthcheck is failing).
17
+ *
18
+ * Subjects render as chips in-app and as native rich elements per
19
+ * notification strategy (e.g., Slack section, Discord embed field, SMTP
20
+ * card). When a notification has no canonical parent CTA (`action` is
21
+ * null), the subjects' URLs are the user's only navigation path.
22
+ *
23
+ * `kind` is an open string so plugins can introduce their own subject
24
+ * types. To prevent clashes between plugins, kinds MUST be namespaced as
25
+ * `<pluginId>.<localKind>` (e.g., `catalog.system`, `incident.incident`,
26
+ * `healthcheck.healthcheck`). Frontend rendering metadata (icon, label)
27
+ * is provided via the plugin-extensible subject-kind registry in
28
+ * `@checkstack/notification-frontend`; unknown kinds fall back to a
29
+ * generic chip with the subject's `name`.
30
+ */
31
+ const NAMESPACED_KIND_PATTERN = /^[a-z][a-z0-9_-]*\.[a-z][a-z0-9_-]*$/;
32
+
33
+ export const NotificationSubjectSchema = z.object({
34
+ /**
35
+ * Plugin-namespaced discriminator: `<pluginId>.<localKind>`.
36
+ * Plugins register icon/label for their kinds in the frontend registry.
37
+ */
38
+ kind: z.string().regex(NAMESPACED_KIND_PATTERN, {
39
+ message: "Subject kind must be namespaced as '<pluginId>.<localKind>'",
40
+ }),
41
+ /** Stable identifier for the subject within its kind. */
42
+ id: z.string(),
43
+ /** Human-readable display name. */
44
+ name: z.string(),
45
+ /** Optional deep link to the subject. Recipients without UI access (text channels) just show the name. */
46
+ url: z.string().optional(),
47
+ /**
48
+ * Optional health/status hint, used to color the chip and add an icon.
49
+ * Strategies that cannot render color simply ignore it.
50
+ */
51
+ status: z.enum(["healthy", "unhealthy", "degraded", "unknown"]).optional(),
52
+ });
53
+ export type NotificationSubject = z.infer<typeof NotificationSubjectSchema>;
54
+
14
55
  // Core notification schema
15
56
  export const NotificationSchema = z.object({
16
57
  id: z.string().uuid(),
@@ -22,7 +63,14 @@ export const NotificationSchema = z.object({
22
63
  action: NotificationActionSchema.optional(),
23
64
  importance: ImportanceSchema,
24
65
  isRead: z.boolean(),
25
- groupId: z.string().optional(),
66
+ /**
67
+ * Collapse key shared by related notifications. Frontend collapses rows
68
+ * with the same (userId, collapseKey) into one card with a "+N updates"
69
+ * badge. Examples: "incident.<id>", "healthcheck.<id>".
70
+ */
71
+ collapseKey: z.string().optional(),
72
+ /** Affected entities (renders as chips / native rich elements). */
73
+ subjects: z.array(NotificationSubjectSchema).optional(),
26
74
  createdAt: z.coerce.date(),
27
75
  });
28
76
  export type Notification = z.infer<typeof NotificationSchema>;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Cross-plugin notification-subscription pattern.
3
+ *
4
+ * A subscription spec describes one *kind* of notification a plugin
5
+ * offers (e.g. anomaly's "alert me about this system"). The spec
6
+ * references a `NotificationTarget` — a typed handle on a resource type
7
+ * (e.g. `catalogSystemTarget`) — and supplies display metadata. The
8
+ * platform handles everything else:
9
+ *
10
+ * - notification-backend materializes one notification group per
11
+ * (registered spec × known resource of the spec's target type),
12
+ * keyed `<ownerPlugin>.<spec.localId>.<resourceKey>`.
13
+ * - The frontend extension factory derives the slot from
14
+ * `spec.target.slot` so plugin authors never re-pass it.
15
+ * - At dispatch time, callers supply `(specId, resourceKeys)`;
16
+ * notification-backend resolves group ids, walks the target's
17
+ * `parents` chain for inheritance, and delivers.
18
+ *
19
+ * Plugin-author surface area is intentionally minimal: a `target`
20
+ * reference, a `localId`, and display metadata. No groupIdFor, no
21
+ * resourceKind (carried by the target), no slot at the registration
22
+ * site, no per-plugin lifecycle code.
23
+ */
24
+
25
+ import type { NotificationTarget } from "./targets";
26
+
27
+ interface PluginMetadataLike {
28
+ pluginId: string;
29
+ }
30
+
31
+ export interface SubscriptionDisplayMeta {
32
+ title: string;
33
+ description: string;
34
+ iconName?: string;
35
+ }
36
+
37
+ /**
38
+ * Declarative description of a subscription a plugin offers for the
39
+ * resources of a given target. Group ids are derived from the
40
+ * convention `<ownerPlugin>.<spec.localId>.<resourceKey>` — there is no
41
+ * escape hatch. Plugins that need an exotic group structure should
42
+ * register a different target type instead.
43
+ */
44
+ export interface NotificationSubscriptionSpec<TResource> {
45
+ /** `<ownerPlugin>.<localId>` — namespaced like the target ids. */
46
+ readonly specId: string;
47
+ readonly ownerPlugin: string;
48
+ readonly localId: string;
49
+ readonly target: NotificationTarget<TResource>;
50
+ readonly display: SubscriptionDisplayMeta;
51
+ }
52
+
53
+ /**
54
+ * Derive the namespaced groupId for a given spec + resourceKey. Single
55
+ * source of truth — both notification-backend and dispatch callers go
56
+ * through this helper. Defined here in common so plugin code never
57
+ * computes it.
58
+ */
59
+ export function subscriptionGroupId(
60
+ spec: { ownerPlugin: string; localId: string },
61
+ resourceKey: string,
62
+ ): string {
63
+ return `${spec.ownerPlugin}.${spec.localId}.${resourceKey}`;
64
+ }
65
+
66
+ export interface SubscriptionSpecInput<TResource> {
67
+ localId: string;
68
+ target: NotificationTarget<TResource>;
69
+ display: SubscriptionDisplayMeta;
70
+ }
71
+
72
+ /**
73
+ * Bind a plugin's id once and return a `defineSubscription` helper that
74
+ * stamps every spec with `<pluginId>.<localId>`. Mirrors the existing
75
+ * factory pattern in `builders.ts`.
76
+ */
77
+ export function createSubscriptionFactory(pluginMetadata: PluginMetadataLike) {
78
+ const pluginId = pluginMetadata.pluginId;
79
+
80
+ function defineSubscription<TResource>(
81
+ input: SubscriptionSpecInput<TResource>,
82
+ ): NotificationSubscriptionSpec<TResource> {
83
+ if (input.localId.includes(".")) {
84
+ throw new Error(
85
+ `Subscription localId must not contain '.', got ${JSON.stringify(input.localId)}`,
86
+ );
87
+ }
88
+ return {
89
+ specId: `${pluginId}.${input.localId}`,
90
+ ownerPlugin: pluginId,
91
+ localId: input.localId,
92
+ target: input.target,
93
+ display: input.display,
94
+ };
95
+ }
96
+
97
+ return { defineSubscription };
98
+ }
99
+
100
+ /**
101
+ * Wire-format for spec registration. Includes the target type id so the
102
+ * backend can join known resources of that target onto the spec when
103
+ * provisioning groups, without re-walking type info.
104
+ */
105
+ export interface RegisteredSubscriptionSpecRecord {
106
+ specId: string;
107
+ ownerPlugin: string;
108
+ localId: string;
109
+ targetTypeId: string;
110
+ display: SubscriptionDisplayMeta;
111
+ }
112
+
113
+ export function specToRegistration<TResource>(
114
+ spec: NotificationSubscriptionSpec<TResource>,
115
+ ): RegisteredSubscriptionSpecRecord {
116
+ return {
117
+ specId: spec.specId,
118
+ ownerPlugin: spec.ownerPlugin,
119
+ localId: spec.localId,
120
+ targetTypeId: spec.target.targetTypeId,
121
+ display: spec.display,
122
+ };
123
+ }
package/src/targets.ts ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Notification target types — the platform abstraction that lets emitting
3
+ * plugins declare *what kind of resource* their subscriptions are about
4
+ * without dealing with notification-group lifecycle themselves.
5
+ *
6
+ * A target type ("system", "group", future kinds…) is owned by exactly
7
+ * one plugin. The owner:
8
+ * 1. Defines + exports the target object via `defineNotificationTarget`.
9
+ * 2. Tells notification-backend about every resource of this type that
10
+ * exists (`upsertNotificationResource` on creation/rename,
11
+ * `removeNotificationResource` on deletion).
12
+ * 3. Renders an extension slot on the resource's detail page so emitting
13
+ * plugins can drop their subscription rows in.
14
+ *
15
+ * notification-backend does the rest:
16
+ * - Materializes one notification group per (registered spec × known
17
+ * resource), keyed `<emitterPluginId>.<spec.localId>.<resourceKey>`.
18
+ * - Provisions/cleans up these groups automatically as resources and
19
+ * specs come and go.
20
+ * - At dispatch time, resolves spec + resourceKey(s) into the actual
21
+ * group ids (primary + inherited via the target's `parents` chain),
22
+ * unions subscribers, and delivers.
23
+ *
24
+ * Emitting plugins (anomaly, incident, maintenance, healthcheck, …) stop
25
+ * carrying their own per-system / per-group lifecycle — they reference
26
+ * a target object and supply display metadata. Convention-driven, typed,
27
+ * and centralized.
28
+ */
29
+
30
+ interface PluginMetadataLike {
31
+ pluginId: string;
32
+ }
33
+
34
+ /**
35
+ * Declarative parent reference. The target owner declares what type
36
+ * its parent resources have ("catalogSystemTarget's parents are
37
+ * catalogGroupTarget"); the actual parent edges live in
38
+ * notification-backend's `notification_resource_parents` table and are
39
+ * kept in sync by the target owner via `setNotificationResourceParents`
40
+ * (called whenever a child's parent set changes — e.g. addSystemToGroup
41
+ * in catalog).
42
+ *
43
+ * No callback at dispatch time — notification-backend reads the edges
44
+ * from its own DB. Keeps dispatch fully server-local.
45
+ */
46
+ export interface NotificationTargetParents {
47
+ targetTypeId: string;
48
+ }
49
+
50
+ /**
51
+ * Backwards-compatibility hook. If users were already subscribed to a
52
+ * legacy notification group (e.g. catalog's pre-pattern
53
+ * `catalog.system.<id>` group), the target type declares the template.
54
+ * notification-backend substitutes `{resourceKey}` and seeds new spec
55
+ * groups from the legacy group's subscribers — exactly once per
56
+ * (spec × resource) pair, tracked in `subscription_migrations`.
57
+ *
58
+ * Encoded as a string so the backend can run it without a callback to
59
+ * the target owner.
60
+ */
61
+ export interface NotificationTargetLegacyMigration {
62
+ /** e.g. `"catalog.system.{resourceKey}"`. */
63
+ legacyGroupIdTemplate: string;
64
+ }
65
+
66
+ /**
67
+ * The on-disk representation of a known resource. Plugins owning a
68
+ * target type push these to notification-backend; the backend persists
69
+ * them so the audit/settings UI can render display labels and dispatch
70
+ * can iterate resources without round-tripping the owner.
71
+ */
72
+ export interface NotificationTargetResourceRecord {
73
+ /** Stable id within the target type. */
74
+ resourceKey: string;
75
+ /** Human-friendly label shown in subscription UIs. */
76
+ displayLabel: string;
77
+ }
78
+
79
+ /**
80
+ * The full shape of a notification target. Plugins consume this via
81
+ * named imports and reference it from their subscription specs — never
82
+ * by string id.
83
+ */
84
+ export interface NotificationTarget<TResource> {
85
+ /** `<ownerPluginId>.<localId>` — namespacing prevents collisions. */
86
+ readonly targetTypeId: string;
87
+ readonly ownerPlugin: string;
88
+ readonly localId: string;
89
+ readonly resourceKind: string;
90
+ /** Pulls the stable key out of a resource shape — used everywhere. */
91
+ keyOf(resource: TResource): string;
92
+ /**
93
+ * Pulls a human-friendly label out of a resource shape — used for
94
+ * upsertNotificationResource calls and the settings audit page.
95
+ */
96
+ labelOf(resource: TResource): string;
97
+ /** Optional parent target. Edges populated via setNotificationResourceParents. */
98
+ readonly parents?: NotificationTargetParents;
99
+ /** Optional legacy-migration declaration. Read once at startup. */
100
+ readonly legacy?: NotificationTargetLegacyMigration;
101
+ }
102
+
103
+ /**
104
+ * Plugin-bound input to `defineNotificationTarget`. The plugin author
105
+ * supplies a *local* id; the factory namespaces it with the plugin's
106
+ * id, mirroring `createSubscriptionFactory` and the existing
107
+ * `createCollapseKeyBuilder` pattern.
108
+ */
109
+ export interface NotificationTargetInput<TResource> {
110
+ pluginMetadata: PluginMetadataLike;
111
+ localId: string;
112
+ resourceKind: string;
113
+ keyOf: NotificationTarget<TResource>["keyOf"];
114
+ labelOf: NotificationTarget<TResource>["labelOf"];
115
+ parents?: NotificationTargetParents;
116
+ legacy?: NotificationTargetLegacyMigration;
117
+ }
118
+
119
+ export function defineNotificationTarget<TResource>(
120
+ input: NotificationTargetInput<TResource>,
121
+ ): NotificationTarget<TResource> {
122
+ if (input.localId.includes(".")) {
123
+ throw new Error(
124
+ `Notification target localId must not contain '.', got ${JSON.stringify(input.localId)}`,
125
+ );
126
+ }
127
+ return {
128
+ targetTypeId: `${input.pluginMetadata.pluginId}.${input.localId}`,
129
+ ownerPlugin: input.pluginMetadata.pluginId,
130
+ localId: input.localId,
131
+ resourceKind: input.resourceKind,
132
+ keyOf: input.keyOf,
133
+ labelOf: input.labelOf,
134
+ parents: input.parents,
135
+ legacy: input.legacy,
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Wire-format used when a target owner registers its target type with
141
+ * notification-backend on startup. Excludes the live functions
142
+ * (`keyOf` / `labelOf` / `parents.resolve` / `legacy.legacyGroupIdFor`)
143
+ * because those run in the owner's process — backend only stores
144
+ * metadata.
145
+ */
146
+ export interface RegisteredNotificationTargetRecord {
147
+ targetTypeId: string;
148
+ ownerPlugin: string;
149
+ resourceKind: string;
150
+ parentTargetTypeId?: string;
151
+ legacyGroupIdTemplate?: string;
152
+ }
153
+
154
+ export function targetToRegistration<TResource>(
155
+ target: NotificationTarget<TResource>,
156
+ ): RegisteredNotificationTargetRecord {
157
+ return {
158
+ targetTypeId: target.targetTypeId,
159
+ ownerPlugin: target.ownerPlugin,
160
+ resourceKind: target.resourceKind,
161
+ parentTargetTypeId: target.parents?.targetTypeId,
162
+ legacyGroupIdTemplate: target.legacy?.legacyGroupIdTemplate,
163
+ };
164
+ }