@checkstack/notification-backend 1.0.5 → 1.2.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/src/router.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { implement, ORPCError } from "@orpc/server";
2
- import { and, eq, inArray, sql } from "drizzle-orm";
2
+ import { and, count, desc, eq, inArray, sql } from "drizzle-orm";
3
3
  import {
4
4
  autoAuthMiddleware,
5
+ correlationMiddleware,
5
6
  type RpcContext,
6
7
  type RealUser,
7
8
  type ConfigService,
@@ -48,6 +49,7 @@ import {
48
49
  createStrategyService,
49
50
  type StrategyService,
50
51
  } from "./strategy-service";
52
+ import { dispatchWithAttempt } from "./delivery-attempts";
51
53
  import { extractErrorMessage } from "@checkstack/common";
52
54
 
53
55
  /**
@@ -156,6 +158,7 @@ export const createNotificationRouter = (
156
158
  */
157
159
  const sendToExternalChannels = async (
158
160
  userId: string,
161
+ notificationId: string,
159
162
  notification: {
160
163
  title: string;
161
164
  body?: string;
@@ -282,15 +285,20 @@ export const createNotificationRouter = (
282
285
  logger,
283
286
  };
284
287
 
285
- // Send (fire-and-forget, don't block on errors)
288
+ // Send (fire-and-forget, don't block on errors). Wrap with a
289
+ // duration measurement + per-attempt persistence so admins can
290
+ // see silent failures without grepping logs. The attempt
291
+ // insert itself is best-effort — see `dispatchWithAttempt`.
286
292
  logger.debug(
287
293
  `[external-delivery] Sending to ${strategy.qualifiedId} with contact ${contact}`
288
294
  );
289
- const result = await strategy.send(sendContext);
290
- logger.debug(
291
- `[external-delivery] Send result for ${strategy.qualifiedId}:`,
292
- result
293
- );
295
+ await dispatchWithAttempt({
296
+ database,
297
+ logger,
298
+ strategy,
299
+ sendContext,
300
+ notificationId,
301
+ });
294
302
  } catch (error) {
295
303
  // Log error but continue - external delivery shouldn't block in-app
296
304
  logger.error(
@@ -304,6 +312,7 @@ export const createNotificationRouter = (
304
312
  // Create contract implementer with context type AND auto auth middleware
305
313
  const os = implement(notificationContract)
306
314
  .$context<RpcContext>()
315
+ .use(correlationMiddleware)
307
316
  .use(autoAuthMiddleware);
308
317
 
309
318
  return os.router({
@@ -325,7 +334,7 @@ export const createNotificationRouter = (
325
334
  });
326
335
 
327
336
  return {
328
- notifications: result.notifications.map((n) => ({
337
+ items: result.notifications.map((n) => ({
329
338
  id: n.id,
330
339
  userId: n.userId,
331
340
  title: n.title,
@@ -338,6 +347,8 @@ export const createNotificationRouter = (
338
347
  createdAt: n.createdAt,
339
348
  })),
340
349
  total: result.total,
350
+ limit: input.limit,
351
+ offset: input.offset,
341
352
  };
342
353
  });
343
354
  }
@@ -471,6 +482,51 @@ export const createNotificationRouter = (
471
482
  );
472
483
  }),
473
484
 
485
+ /**
486
+ * Read-only admin endpoint listing recent per-channel delivery
487
+ * attempts. Fixed shape: paginated newest-first with an optional
488
+ * `notificationId` filter — we deliberately do NOT accept
489
+ * user-supplied SQL filters or order-by clauses here.
490
+ */
491
+ getDeliveryAttempts: os.getDeliveryAttempts.handler(async ({ input }) => {
492
+ const whereClause = input.notificationId
493
+ ? eq(
494
+ schema.notificationDeliveryAttempts.notificationId,
495
+ input.notificationId,
496
+ )
497
+ : undefined;
498
+
499
+ const rowsQuery = database
500
+ .select()
501
+ .from(schema.notificationDeliveryAttempts)
502
+ .orderBy(desc(schema.notificationDeliveryAttempts.attemptedAt))
503
+ .limit(input.limit)
504
+ .offset(input.offset);
505
+ const totalQuery = database
506
+ .select({ value: count() })
507
+ .from(schema.notificationDeliveryAttempts);
508
+
509
+ const [rows, totalRows] = await Promise.all([
510
+ whereClause ? rowsQuery.where(whereClause) : rowsQuery,
511
+ whereClause ? totalQuery.where(whereClause) : totalQuery,
512
+ ]);
513
+
514
+ return {
515
+ items: rows.map((row) => ({
516
+ id: row.id,
517
+ notificationId: row.notificationId,
518
+ strategyQualifiedId: row.strategyQualifiedId,
519
+ attemptedAt: row.attemptedAt,
520
+ status: row.status,
521
+ errorMessage: row.errorMessage,
522
+ durationMs: row.durationMs,
523
+ })),
524
+ total: totalRows[0]?.value ?? 0,
525
+ limit: input.limit,
526
+ offset: input.offset,
527
+ };
528
+ }),
529
+
474
530
  // ==========================================================================
475
531
  // BACKEND-TO-BACKEND GROUP MANAGEMENT
476
532
  // Contract meta: userType: "service"
@@ -1069,8 +1125,24 @@ export const createNotificationRouter = (
1069
1125
  },
1070
1126
  );
1071
1127
  }
1128
+ // Map userId -> notificationId so each external-delivery
1129
+ // attempt links back to the recipient's own notification row
1130
+ // (used by `getDeliveryAttempts` to filter).
1131
+ const notificationIdByUser = new Map(
1132
+ inserted.map((n) => [n.userId, n.id]),
1133
+ );
1072
1134
  for (const userId of recipients) {
1073
- void sendToExternalChannels(userId, {
1135
+ const notificationId = notificationIdByUser.get(userId);
1136
+ if (!notificationId) {
1137
+ // Insert returned nothing for this user — should not happen
1138
+ // (recipients drive the insert), but guard anyway so we
1139
+ // never record an attempt with a fabricated id.
1140
+ logger.error(
1141
+ `[external-delivery] No notification row for user ${userId}, skipping external send`,
1142
+ );
1143
+ continue;
1144
+ }
1145
+ void sendToExternalChannels(userId, notificationId, {
1074
1146
  title,
1075
1147
  body,
1076
1148
  importance: importance ?? "info",
package/src/schema.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  pgTable,
3
+ pgEnum,
3
4
  text,
4
5
  boolean,
5
6
  uuid,
@@ -7,6 +8,7 @@ import {
7
8
  jsonb,
8
9
  primaryKey,
9
10
  index,
11
+ integer,
10
12
  } from "drizzle-orm/pg-core";
11
13
  import type {
12
14
  NotificationAction,
@@ -193,3 +195,53 @@ export const subscriptionMigrations = pgTable(
193
195
  pk: primaryKey({ columns: [t.specId, t.resourceKey] }),
194
196
  }),
195
197
  );
198
+
199
+ /**
200
+ * Status enum for per-channel delivery attempts. Each attempted send
201
+ * via an external delivery strategy resolves to exactly one of these
202
+ * states. Visibility-only — retries are NOT implemented yet (deferred
203
+ * to v1.1); a failure here is a final, surfaced outcome admins can
204
+ * action manually.
205
+ */
206
+ export const notificationDeliveryStatusEnum = pgEnum(
207
+ "notification_delivery_status",
208
+ ["success", "failure"],
209
+ );
210
+
211
+ /**
212
+ * Per-channel delivery attempt log. Written best-effort by the dispatch
213
+ * loop on every `strategy.send(...)` call (success or failure). Surfaces
214
+ * silent external-delivery failures to admins so they can detect a
215
+ * misconfigured webhook / dead channel without grepping logs.
216
+ *
217
+ * - `notificationId` cascades on delete so retention sweeps that drop
218
+ * notifications also drop their attempt rows.
219
+ * - `errorMessage` MUST flow through `extractErrorMessage` so secrets
220
+ * embedded in raw error objects (webhook URLs, tokens) are not
221
+ * persisted verbatim.
222
+ * - `durationMs` captures wall-clock time of the `send()` call only —
223
+ * not contact resolution or config loading.
224
+ */
225
+ export const notificationDeliveryAttempts = pgTable(
226
+ "notification_delivery_attempts",
227
+ {
228
+ id: uuid("id").primaryKey().defaultRandom(),
229
+ notificationId: uuid("notification_id")
230
+ .notNull()
231
+ .references(() => notifications.id, { onDelete: "cascade" }),
232
+ /** Qualified strategy id, e.g. `notification-discord.send`. */
233
+ strategyQualifiedId: text("strategy_qualified_id").notNull(),
234
+ attemptedAt: timestamp("attempted_at").defaultNow().notNull(),
235
+ status: notificationDeliveryStatusEnum("status").notNull(),
236
+ errorMessage: text("error_message"),
237
+ durationMs: integer("duration_ms").notNull(),
238
+ },
239
+ (t) => ({
240
+ notificationIdx: index("notification_delivery_attempts_notification_idx").on(
241
+ t.notificationId,
242
+ ),
243
+ attemptedAtIdx: index("notification_delivery_attempts_attempted_at_idx").on(
244
+ t.attemptedAt,
245
+ ),
246
+ }),
247
+ );