@checkstack/notification-backend 1.1.0 → 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/CHANGELOG.md +166 -0
- package/drizzle/0007_funny_hobgoblin.sql +14 -0
- package/drizzle/meta/0007_snapshot.json +644 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +8 -8
- package/src/delivery-attempts.test.ts +482 -0
- package/src/delivery-attempts.ts +146 -0
- package/src/router.test.ts +1021 -25
- package/src/router.ts +81 -9
- package/src/schema.ts +52 -0
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
);
|