@checkstack/notification-backend 0.2.1 → 1.0.1

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/src/schema.ts CHANGED
@@ -6,23 +6,54 @@ import {
6
6
  timestamp,
7
7
  jsonb,
8
8
  primaryKey,
9
+ index,
9
10
  } from "drizzle-orm/pg-core";
10
- import type { NotificationAction } from "@checkstack/notification-common";
11
+ import type {
12
+ NotificationAction,
13
+ NotificationSubject,
14
+ } from "@checkstack/notification-common";
11
15
 
12
16
  // User notifications table
13
- export const notifications = pgTable("notifications", {
14
- id: uuid("id").primaryKey().defaultRandom(),
15
- userId: text("user_id").notNull(), // No FK - cross-schema limitation
16
- title: text("title").notNull(),
17
- /** Notification body content (supports markdown) */
18
- body: text("body").notNull(),
19
- /** Single primary action button */
20
- action: jsonb("action").$type<NotificationAction | null>(),
21
- importance: text("importance").notNull().default("info"), // 'info' | 'warning' | 'critical'
22
- isRead: boolean("is_read").notNull().default(false),
23
- groupId: text("group_id"), // Namespaced: "pluginId.groupName"
24
- createdAt: timestamp("created_at").defaultNow().notNull(),
25
- });
17
+ export const notifications = pgTable(
18
+ "notifications",
19
+ {
20
+ id: uuid("id").primaryKey().defaultRandom(),
21
+ userId: text("user_id").notNull(), // No FK - cross-schema limitation
22
+ title: text("title").notNull(),
23
+ /** Notification body content (supports markdown) */
24
+ body: text("body").notNull(),
25
+ /** Single primary action button */
26
+ action: jsonb("action").$type<NotificationAction | null>(),
27
+ importance: text("importance").notNull().default("info"), // 'info' | 'warning' | 'critical'
28
+ isRead: boolean("is_read").notNull().default(false),
29
+ /**
30
+ * Collapse key shared by related notifications. Notifications with the
31
+ * same (userId, collapseKey) collapse into one card on the frontend.
32
+ * Examples: "incident.incident.<id>", "healthcheck.healthcheck.<id>".
33
+ * Distinct from `notification_groups.id` (which is a subscription target).
34
+ * Kept as `group_id` at the column level for backwards compatibility; the
35
+ * TypeScript field name reflects its true purpose.
36
+ */
37
+ collapseKey: text("group_id"),
38
+ /**
39
+ * Affected entities. Each renders as a chip (in-app) or a link (in
40
+ * notification strategies). When `action` is null, these are the only
41
+ * navigation paths; when present, they supplement the primary CTA.
42
+ */
43
+ subjects: jsonb("subjects").$type<NotificationSubject[] | null>(),
44
+ createdAt: timestamp("created_at").defaultNow().notNull(),
45
+ },
46
+ (t) => ({
47
+ userCollapseIdx: index("notifications_user_collapse_idx").on(
48
+ t.userId,
49
+ t.collapseKey,
50
+ ),
51
+ userCreatedIdx: index("notifications_user_created_idx").on(
52
+ t.userId,
53
+ t.createdAt,
54
+ ),
55
+ }),
56
+ );
26
57
 
27
58
  // Notification groups (created by plugins)
28
59
  // ID is namespaced: "pluginId.groupName"
@@ -52,3 +83,113 @@ export const notificationSubscriptions = pgTable(
52
83
  // Note: User notification preferences are now stored via ConfigService
53
84
  // using the user-pref.{userId}.{strategyId} pattern for automatic
54
85
  // secret encryption of OAuth tokens.
86
+
87
+ /**
88
+ * Notification target type registry. Owned by a plugin (catalog owns
89
+ * `catalog.system` and `catalog.group`). The owner pushes resource
90
+ * lifecycle events to notification-backend; the backend uses this
91
+ * registry to route subscription specs to known resources and to walk
92
+ * parent inheritance during dispatch.
93
+ */
94
+ export const notificationTargets = pgTable("notification_targets", {
95
+ targetTypeId: text("target_type_id").primaryKey(),
96
+ ownerPlugin: text("owner_plugin").notNull(),
97
+ resourceKind: text("resource_kind").notNull(),
98
+ parentTargetTypeId: text("parent_target_type_id"),
99
+ /**
100
+ * Template the backend substitutes `{resourceKey}` into to compute
101
+ * the legacy groupId for migration. Null means no legacy migration.
102
+ */
103
+ legacyGroupIdTemplate: text("legacy_group_id_template"),
104
+ registeredAt: timestamp("registered_at").defaultNow().notNull(),
105
+ });
106
+
107
+ /**
108
+ * Resources of a target type. Pushed by the target owner whenever a
109
+ * resource is created or renamed; removed on deletion. notification-
110
+ * backend keeps a notification group materialized for every
111
+ * (registered spec × resource) pair derived from this table.
112
+ */
113
+ export const notificationResources = pgTable(
114
+ "notification_resources",
115
+ {
116
+ targetTypeId: text("target_type_id")
117
+ .notNull()
118
+ .references(() => notificationTargets.targetTypeId, {
119
+ onDelete: "cascade",
120
+ }),
121
+ resourceKey: text("resource_key").notNull(),
122
+ displayLabel: text("display_label").notNull(),
123
+ upsertedAt: timestamp("upserted_at").defaultNow().notNull(),
124
+ },
125
+ (t) => ({
126
+ pk: primaryKey({ columns: [t.targetTypeId, t.resourceKey] }),
127
+ }),
128
+ );
129
+
130
+ /**
131
+ * Parent edges between resources. Populated by target owners via
132
+ * `setNotificationResourceParents` (catalog calls it on
133
+ * addSystemToGroup / removeSystemFromGroup / system create). Read at
134
+ * dispatch time to compute inherited group ids.
135
+ */
136
+ export const notificationResourceParents = pgTable(
137
+ "notification_resource_parents",
138
+ {
139
+ childTargetTypeId: text("child_target_type_id").notNull(),
140
+ childResourceKey: text("child_resource_key").notNull(),
141
+ parentTargetTypeId: text("parent_target_type_id").notNull(),
142
+ parentResourceKey: text("parent_resource_key").notNull(),
143
+ },
144
+ (t) => ({
145
+ pk: primaryKey({
146
+ columns: [
147
+ t.childTargetTypeId,
148
+ t.childResourceKey,
149
+ t.parentTargetTypeId,
150
+ t.parentResourceKey,
151
+ ],
152
+ }),
153
+ childIdx: index("notification_resource_parents_child_idx").on(
154
+ t.childTargetTypeId,
155
+ t.childResourceKey,
156
+ ),
157
+ }),
158
+ );
159
+
160
+ /**
161
+ * Subscription-spec registry. Every dispatch must reference a specId
162
+ * that exists here, owned by the calling plugin. notification-backend
163
+ * provisions one notification group per (spec × resource) pair where
164
+ * `resource.targetTypeId == spec.targetTypeId`.
165
+ */
166
+ export const subscriptionSpecs = pgTable("subscription_specs", {
167
+ specId: text("spec_id").primaryKey(),
168
+ ownerPlugin: text("owner_plugin").notNull(),
169
+ localId: text("local_id").notNull(),
170
+ targetTypeId: text("target_type_id")
171
+ .notNull()
172
+ .references(() => notificationTargets.targetTypeId),
173
+ displayTitle: text("display_title").notNull(),
174
+ displayDescription: text("display_description").notNull(),
175
+ displayIconName: text("display_icon_name"),
176
+ registeredAt: timestamp("registered_at").defaultNow().notNull(),
177
+ });
178
+
179
+ /**
180
+ * Tracks which legacy notification groups have already been migrated
181
+ * for a given (spec × resource) pair. Set after the one-shot seeding
182
+ * runs so re-registering a spec doesn't re-seed (which would silently
183
+ * resubscribe users who deliberately unsubscribed).
184
+ */
185
+ export const subscriptionMigrations = pgTable(
186
+ "subscription_migrations",
187
+ {
188
+ specId: text("spec_id").notNull(),
189
+ resourceKey: text("resource_key").notNull(),
190
+ migratedAt: timestamp("migrated_at").defaultNow().notNull(),
191
+ },
192
+ (t) => ({
193
+ pk: primaryKey({ columns: [t.specId, t.resourceKey] }),
194
+ }),
195
+ );
@@ -0,0 +1,257 @@
1
+ import { and, eq, inArray } from "drizzle-orm";
2
+ import type { SafeDatabase } from "@checkstack/backend-api";
3
+ import * as schema from "./schema";
4
+
5
+ /**
6
+ * Server-local logic shared by every spec / target / resource / dispatch
7
+ * RPC. Centralizes the auto-provisioning, parent-edge lookup, and
8
+ * group-id derivation so plugins never compute groupIds themselves.
9
+ *
10
+ * Convention:
11
+ * notification group id = `<spec.ownerPlugin>.<spec.localId>.<resourceKey>`
12
+ *
13
+ * That is the *only* way notification groups for spec subscriptions are
14
+ * named; there is no escape hatch.
15
+ */
16
+
17
+ type Db = SafeDatabase<typeof schema>;
18
+
19
+ export function deriveGroupId(opts: {
20
+ ownerPlugin: string;
21
+ localId: string;
22
+ resourceKey: string;
23
+ }): string {
24
+ return `${opts.ownerPlugin}.${opts.localId}.${opts.resourceKey}`;
25
+ }
26
+
27
+ /**
28
+ * Materialize a single notification group for a (spec × resource) pair.
29
+ * Idempotent — relies on the existing `notification_groups` upsert.
30
+ */
31
+ export async function ensureGroupForSpecAndResource(opts: {
32
+ db: Db;
33
+ spec: typeof schema.subscriptionSpecs.$inferSelect;
34
+ resource: typeof schema.notificationResources.$inferSelect;
35
+ }): Promise<{ groupId: string; created: boolean }> {
36
+ const { db, spec, resource } = opts;
37
+ const groupId = deriveGroupId({
38
+ ownerPlugin: spec.ownerPlugin,
39
+ localId: spec.localId,
40
+ resourceKey: resource.resourceKey,
41
+ });
42
+
43
+ // Group naming follows `<DisplayLabel> <SpecTitle>` ("API Gateway
44
+ // Anomaly Detection") so audit lists and the bell render labels that
45
+ // make sense without context.
46
+ const groupName = `${resource.displayLabel} · ${spec.displayTitle}`;
47
+ const description = spec.displayDescription;
48
+
49
+ const existing = await db
50
+ .select({ id: schema.notificationGroups.id })
51
+ .from(schema.notificationGroups)
52
+ .where(eq(schema.notificationGroups.id, groupId))
53
+ .limit(1);
54
+ const created = existing.length === 0;
55
+
56
+ await db
57
+ .insert(schema.notificationGroups)
58
+ .values({
59
+ id: groupId,
60
+ name: groupName,
61
+ description,
62
+ ownerPlugin: spec.ownerPlugin,
63
+ })
64
+ .onConflictDoUpdate({
65
+ target: [schema.notificationGroups.id],
66
+ set: { name: groupName, description },
67
+ });
68
+
69
+ return { groupId, created };
70
+ }
71
+
72
+ export async function deleteGroupForSpecAndResource(opts: {
73
+ db: Db;
74
+ spec: { ownerPlugin: string; localId: string };
75
+ resourceKey: string;
76
+ }): Promise<void> {
77
+ const { db, spec, resourceKey } = opts;
78
+ const groupId = deriveGroupId({
79
+ ownerPlugin: spec.ownerPlugin,
80
+ localId: spec.localId,
81
+ resourceKey,
82
+ });
83
+ await db
84
+ .delete(schema.notificationGroups)
85
+ .where(eq(schema.notificationGroups.id, groupId));
86
+ }
87
+
88
+ /**
89
+ * Provision groups for a newly-registered spec across every existing
90
+ * resource of its target. Also handles the one-shot legacy seeding when
91
+ * the target declared a legacy migration: subscribers of the legacy
92
+ * group are bulk-copied onto the new group, exactly once per
93
+ * (spec × resource), tracked in `subscription_migrations`.
94
+ */
95
+ export async function provisionGroupsForSpec(opts: {
96
+ db: Db;
97
+ spec: typeof schema.subscriptionSpecs.$inferSelect;
98
+ legacyGroupIdFor?: (resourceKey: string) => string;
99
+ }): Promise<void> {
100
+ const { db, spec, legacyGroupIdFor } = opts;
101
+
102
+ const resources = await db
103
+ .select()
104
+ .from(schema.notificationResources)
105
+ .where(eq(schema.notificationResources.targetTypeId, spec.targetTypeId));
106
+
107
+ for (const resource of resources) {
108
+ const { groupId } = await ensureGroupForSpecAndResource({
109
+ db,
110
+ spec,
111
+ resource,
112
+ });
113
+
114
+ if (!legacyGroupIdFor) continue;
115
+
116
+ const [migrated] = await db
117
+ .select()
118
+ .from(schema.subscriptionMigrations)
119
+ .where(
120
+ and(
121
+ eq(schema.subscriptionMigrations.specId, spec.specId),
122
+ eq(schema.subscriptionMigrations.resourceKey, resource.resourceKey),
123
+ ),
124
+ )
125
+ .limit(1);
126
+ if (migrated) continue;
127
+
128
+ const legacyGroupId = legacyGroupIdFor(resource.resourceKey);
129
+ const legacySubs = await db
130
+ .select({ userId: schema.notificationSubscriptions.userId })
131
+ .from(schema.notificationSubscriptions)
132
+ .where(eq(schema.notificationSubscriptions.groupId, legacyGroupId));
133
+
134
+ if (legacySubs.length > 0) {
135
+ await db
136
+ .insert(schema.notificationSubscriptions)
137
+ .values(
138
+ legacySubs.map((s) => ({ userId: s.userId, groupId })),
139
+ )
140
+ .onConflictDoNothing();
141
+ }
142
+
143
+ await db
144
+ .insert(schema.subscriptionMigrations)
145
+ .values({ specId: spec.specId, resourceKey: resource.resourceKey })
146
+ .onConflictDoNothing();
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Provision groups for a newly-upserted resource across every spec
152
+ * registered against its target. Used both on first push and on rename
153
+ * (rename re-runs the upsert which refreshes group display labels).
154
+ */
155
+ export async function provisionGroupsForResource(opts: {
156
+ db: Db;
157
+ targetTypeId: string;
158
+ resource: typeof schema.notificationResources.$inferSelect;
159
+ }): Promise<void> {
160
+ const { db, targetTypeId, resource } = opts;
161
+ const specs = await db
162
+ .select()
163
+ .from(schema.subscriptionSpecs)
164
+ .where(eq(schema.subscriptionSpecs.targetTypeId, targetTypeId));
165
+ for (const spec of specs) {
166
+ await ensureGroupForSpecAndResource({ db, spec, resource });
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Tear down all groups derived from a deleted resource across every
172
+ * matching spec. Returns how many were dropped.
173
+ */
174
+ export async function teardownGroupsForResource(opts: {
175
+ db: Db;
176
+ targetTypeId: string;
177
+ resourceKey: string;
178
+ }): Promise<number> {
179
+ const { db, targetTypeId, resourceKey } = opts;
180
+ const specs = await db
181
+ .select()
182
+ .from(schema.subscriptionSpecs)
183
+ .where(eq(schema.subscriptionSpecs.targetTypeId, targetTypeId));
184
+
185
+ if (specs.length === 0) return 0;
186
+
187
+ const groupIds = specs.map((spec) =>
188
+ deriveGroupId({
189
+ ownerPlugin: spec.ownerPlugin,
190
+ localId: spec.localId,
191
+ resourceKey,
192
+ }),
193
+ );
194
+ const deleted = await db
195
+ .delete(schema.notificationGroups)
196
+ .where(inArray(schema.notificationGroups.id, groupIds))
197
+ .returning({ id: schema.notificationGroups.id });
198
+ return deleted.length;
199
+ }
200
+
201
+ /**
202
+ * For dispatch: given a child (target, resourceKey), find every parent
203
+ * resource via `notification_resource_parents`, then map each parent to
204
+ * the same plugin's spec(s) whose `targetTypeId` equals the parent's
205
+ * target type. Inherited group ids are derived from those parent specs.
206
+ */
207
+ export async function resolveInheritedGroupIds(opts: {
208
+ db: Db;
209
+ spec: typeof schema.subscriptionSpecs.$inferSelect;
210
+ resourceKey: string;
211
+ }): Promise<string[]> {
212
+ const { db, spec, resourceKey } = opts;
213
+
214
+ const parents = await db
215
+ .select()
216
+ .from(schema.notificationResourceParents)
217
+ .where(
218
+ and(
219
+ eq(
220
+ schema.notificationResourceParents.childTargetTypeId,
221
+ spec.targetTypeId,
222
+ ),
223
+ eq(schema.notificationResourceParents.childResourceKey, resourceKey),
224
+ ),
225
+ );
226
+
227
+ if (parents.length === 0) return [];
228
+
229
+ const parentTargetTypeIds = [
230
+ ...new Set(parents.map((p) => p.parentTargetTypeId)),
231
+ ];
232
+
233
+ const parentSpecs = await db
234
+ .select()
235
+ .from(schema.subscriptionSpecs)
236
+ .where(
237
+ and(
238
+ eq(schema.subscriptionSpecs.ownerPlugin, spec.ownerPlugin),
239
+ inArray(schema.subscriptionSpecs.targetTypeId, parentTargetTypeIds),
240
+ ),
241
+ );
242
+
243
+ const out: string[] = [];
244
+ for (const parent of parents) {
245
+ for (const parentSpec of parentSpecs) {
246
+ if (parentSpec.targetTypeId !== parent.parentTargetTypeId) continue;
247
+ out.push(
248
+ deriveGroupId({
249
+ ownerPlugin: parentSpec.ownerPlugin,
250
+ localId: parentSpec.localId,
251
+ resourceKey: parent.parentResourceKey,
252
+ }),
253
+ );
254
+ }
255
+ }
256
+ return out;
257
+ }
package/tsconfig.json CHANGED
@@ -2,5 +2,40 @@
2
2
  "extends": "@checkstack/tsconfig/backend.json",
3
3
  "include": [
4
4
  "src"
5
+ ],
6
+ "references": [
7
+ {
8
+ "path": "../auth-backend"
9
+ },
10
+ {
11
+ "path": "../auth-common"
12
+ },
13
+ {
14
+ "path": "../backend-api"
15
+ },
16
+ {
17
+ "path": "../cache-api"
18
+ },
19
+ {
20
+ "path": "../cache-utils"
21
+ },
22
+ {
23
+ "path": "../common"
24
+ },
25
+ {
26
+ "path": "../drizzle-helper"
27
+ },
28
+ {
29
+ "path": "../notification-common"
30
+ },
31
+ {
32
+ "path": "../queue-api"
33
+ },
34
+ {
35
+ "path": "../signal-common"
36
+ },
37
+ {
38
+ "path": "../test-utils-backend"
39
+ }
5
40
  ]
6
- }
41
+ }