notifkit 0.1.3 → 0.1.4

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.
Files changed (95) hide show
  1. package/README.md +95 -79
  2. package/dist/index.d.mts +199 -135
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/dist/index.mjs.map +1 -1
  6. package/dist/{main-DtHWhueo.mjs → main-40zwq6b0.mjs} +28 -3
  7. package/dist/{main-DtHWhueo.mjs.map → main-40zwq6b0.mjs.map} +1 -1
  8. package/dist/{main-DyfbnJc3.mjs → main-BFre2-HQ.mjs} +2 -2
  9. package/dist/{main-DyfbnJc3.mjs.map → main-BFre2-HQ.mjs.map} +1 -1
  10. package/dist/{main-CAH0_Q6d.mjs → main-BNJtzY61.mjs} +3 -3
  11. package/dist/main-BNJtzY61.mjs.map +1 -0
  12. package/dist/{main-B561M1d3.mjs → main-BOPMYqsW.mjs} +2 -2
  13. package/dist/{main-B561M1d3.mjs.map → main-BOPMYqsW.mjs.map} +1 -1
  14. package/dist/{main-CCfc45ev.mjs → main-CiigNpsP.mjs} +7 -4
  15. package/dist/main-CiigNpsP.mjs.map +1 -0
  16. package/dist/{main-Ce9dcrsg.mjs → main-DeNFQ-UL.mjs} +6 -3
  17. package/dist/{main-Ce9dcrsg.mjs.map → main-DeNFQ-UL.mjs.map} +1 -1
  18. package/dist/{main-B-jwm8ED.mjs → main-DmCPcxOc.mjs} +2 -2
  19. package/dist/{main-B-jwm8ED.mjs.map → main-DmCPcxOc.mjs.map} +1 -1
  20. package/dist/{main-C45e7grq.mjs → main-DvgJSm11.mjs} +2 -2
  21. package/dist/{main-C45e7grq.mjs.map → main-DvgJSm11.mjs.map} +1 -1
  22. package/dist/{src-C-PfEDMY.mjs → src-vG79L-8m.mjs} +57 -26
  23. package/dist/src-vG79L-8m.mjs.map +1 -0
  24. package/drizzle/0002_wide_colleen_wing.sql +2 -0
  25. package/drizzle/0003_skinny_daimon_hellstrom.sql +1 -0
  26. package/drizzle/0004_pretty_bruce_banner.sql +1 -0
  27. package/drizzle/meta/0002_snapshot.json +1460 -0
  28. package/drizzle/meta/0003_snapshot.json +1460 -0
  29. package/drizzle/meta/0004_snapshot.json +1470 -0
  30. package/drizzle/meta/_journal.json +21 -0
  31. package/package.json +2 -1
  32. package/src/client.ts +412 -0
  33. package/src/config/index.ts +107 -0
  34. package/src/contracts/common.ts +28 -0
  35. package/src/contracts/envelope.ts +31 -0
  36. package/src/contracts/events/notification-ai-pending.ts +18 -0
  37. package/src/contracts/events/notification-canceled.ts +7 -0
  38. package/src/contracts/events/notification-created.ts +14 -0
  39. package/src/contracts/events/notification-delivered.ts +17 -0
  40. package/src/contracts/events/notification-dispatched.ts +45 -0
  41. package/src/contracts/events/notification-enriched.ts +46 -0
  42. package/src/contracts/events/notification-failed.ts +19 -0
  43. package/src/contracts/events/notification-requested.ts +36 -0
  44. package/src/contracts/events/notification-scheduled.ts +9 -0
  45. package/src/contracts/events/notification-skipped.ts +9 -0
  46. package/src/contracts/helpers.ts +21 -0
  47. package/src/contracts/index.ts +46 -0
  48. package/src/contracts/metadata.ts +10 -0
  49. package/src/contracts/registry.ts +88 -0
  50. package/src/contracts/sdk.ts +242 -0
  51. package/src/contracts/streams.ts +62 -0
  52. package/src/db/index.ts +69 -0
  53. package/src/db/schema.ts +412 -0
  54. package/src/idempotency/index.ts +50 -0
  55. package/src/index.ts +19 -0
  56. package/src/logger/index.ts +60 -0
  57. package/src/metrics/index.ts +53 -0
  58. package/src/queue/index.ts +501 -0
  59. package/src/rate-limiter/index.ts +210 -0
  60. package/src/redis/index.ts +89 -0
  61. package/src/repositories/index.ts +1246 -0
  62. package/src/server.ts +277 -0
  63. package/src/services/ai/main.ts +404 -0
  64. package/src/services/api/handlers.ts +1734 -0
  65. package/src/services/api/http.ts +64 -0
  66. package/src/services/api/main.ts +693 -0
  67. package/src/services/api/router.ts +82 -0
  68. package/src/services/delivery/main.ts +842 -0
  69. package/src/services/delivery/throttle.ts +71 -0
  70. package/src/services/engine/main.ts +827 -0
  71. package/src/services/enricher/main.ts +594 -0
  72. package/src/services/events/main.ts +365 -0
  73. package/src/services/scheduler/main.ts +319 -0
  74. package/src/services/workflow/main.ts +627 -0
  75. package/src/shared/batch-processor.ts +67 -0
  76. package/src/shared/cache.ts +47 -0
  77. package/src/shared/circuit-breaker.ts +74 -0
  78. package/src/shared/dataloader.ts +41 -0
  79. package/src/shared/events.ts +3 -0
  80. package/src/shared/index.ts +39 -0
  81. package/src/shared/semaphore.ts +33 -0
  82. package/src/shared/utils.ts +64 -0
  83. package/src/templates/cache.ts +32 -0
  84. package/src/templates/index.ts +69 -0
  85. package/src/templates/render.ts +128 -0
  86. package/src/transport/index.ts +96 -0
  87. package/src/unsubscribe/index.ts +127 -0
  88. package/src/workers/health.ts +31 -0
  89. package/src/workers/index.ts +266 -0
  90. package/src/workflows/index.ts +2 -0
  91. package/src/workflows/registry.ts +21 -0
  92. package/src/workflows/sdk.ts +106 -0
  93. package/dist/main-CAH0_Q6d.mjs.map +0 -1
  94. package/dist/main-CCfc45ev.mjs.map +0 -1
  95. package/dist/src-C-PfEDMY.mjs.map +0 -1
@@ -0,0 +1,827 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { loadEnv, readBaseConfig } from "@/index.js";
3
+ import { createLogger } from "@/index.js";
4
+ import { RedisClient, type Redis } from "@/index.js";
5
+ import {
6
+ StreamConsumer,
7
+ PendingMessageScanner,
8
+ StreamProducer,
9
+ type StreamMessage,
10
+ } from "@/index.js";
11
+ import { BaseWorker } from "@/index.js";
12
+ import {
13
+ STREAMS,
14
+ ENRICHED_STREAMS,
15
+ CONSUMER_GROUPS,
16
+ PUBSUB_CHANNELS,
17
+ registry,
18
+ buildStreamEvent,
19
+ type NotificationEnrichedPayload,
20
+ type NotificationDispatchedPayload,
21
+ } from "@/index.js";
22
+ import { type StreamName } from "@/contracts/streams.js";
23
+ import { IdempotencyGuard } from "@/index.js";
24
+ import { UserThrottle, ProjectSettingsCache } from "@/index.js";
25
+ import { TemplateRepository, ContactRepository, ProjectRepository } from "@/index.js";
26
+ import { createDatabase } from "@/db/index.js";
27
+ import { scheduledPayloads, suppressions } from "@/db/schema.js";
28
+ import { and, eq } from "drizzle-orm";
29
+ import {
30
+ getPriorityBucket,
31
+ globalEmitter,
32
+ normaliseTarget,
33
+ type WorkerOptions,
34
+ DataLoader,
35
+ } from "@/shared/index.js";
36
+ import { renderWithTemplate, TemplateCache } from "@/templates/index.js";
37
+ import { buildUnsubscribeHeaders } from "@/unsubscribe/index.js";
38
+ import { startHealthReporter } from "@/workers/index.js";
39
+
40
+ function localTimeToUtc(
41
+ year: number,
42
+ month: number,
43
+ day: number,
44
+ hour: number,
45
+ minute: number,
46
+ second: number,
47
+ timezone: string,
48
+ ): Date {
49
+ let utcMs = Date.UTC(year, month - 1, day, hour, minute, second);
50
+ const formatter = new Intl.DateTimeFormat("en-US", {
51
+ timeZone: timezone,
52
+ year: "numeric",
53
+ month: "2-digit",
54
+ day: "2-digit",
55
+ hour: "2-digit",
56
+ minute: "2-digit",
57
+ second: "2-digit",
58
+ hour12: false,
59
+ });
60
+
61
+ for (let iter = 0; iter < 3; iter++) {
62
+ const parts = formatter.formatToParts(new Date(utcMs));
63
+ const pYear = parseInt(parts.find((p) => p.type === "year")?.value ?? "0", 10);
64
+ const pMonth = parseInt(parts.find((p) => p.type === "month")?.value ?? "0", 10);
65
+ const pDay = parseInt(parts.find((p) => p.type === "day")?.value ?? "0", 10);
66
+ const pHour = parseInt(parts.find((p) => p.type === "hour")?.value ?? "0", 10);
67
+ const pMin = parseInt(parts.find((p) => p.type === "minute")?.value ?? "0", 10);
68
+ const pSec = parseInt(parts.find((p) => p.type === "second")?.value ?? "0", 10);
69
+
70
+ const targetMs = Date.UTC(year, month - 1, day, hour, minute, second);
71
+ const actualMs = Date.UTC(pYear, pMonth - 1, pDay, pHour, pMin, pSec);
72
+ const diff = targetMs - actualMs;
73
+ if (diff === 0) break;
74
+ utcMs += diff;
75
+ }
76
+ return new Date(utcMs);
77
+ }
78
+
79
+ export function isInQuietHours(
80
+ timezone: string,
81
+ quietHours: { start: string; end: string }[],
82
+ fromDate: Date = new Date(),
83
+ ): { inQuietHours: boolean; nextActiveTime?: Date } {
84
+ if (!quietHours || quietHours.length === 0) {
85
+ return { inQuietHours: false };
86
+ }
87
+
88
+ const checkWindowAt = (date: Date) => {
89
+ let parts: Intl.DateTimeFormatPart[];
90
+ try {
91
+ const formatter = new Intl.DateTimeFormat("en-US", {
92
+ timeZone: timezone,
93
+ year: "numeric",
94
+ month: "2-digit",
95
+ day: "2-digit",
96
+ hour: "2-digit",
97
+ minute: "2-digit",
98
+ second: "2-digit",
99
+ hour12: false,
100
+ });
101
+ parts = formatter.formatToParts(date);
102
+ } catch {
103
+ return null;
104
+ }
105
+
106
+ const year = parseInt(parts.find((p) => p.type === "year")?.value ?? "0", 10);
107
+ const month = parseInt(parts.find((p) => p.type === "month")?.value ?? "0", 10);
108
+ const day = parseInt(parts.find((p) => p.type === "day")?.value ?? "0", 10);
109
+ const currentHour = parseInt(parts.find((p) => p.type === "hour")?.value ?? "0", 10);
110
+ const currentMin = parseInt(parts.find((p) => p.type === "minute")?.value ?? "0", 10);
111
+
112
+ const currentMinutes = currentHour * 60 + currentMin;
113
+
114
+ for (const window of quietHours) {
115
+ const [startH, startM] = window.start.split(":").map(Number);
116
+ const [endH, endM] = window.end.split(":").map(Number);
117
+
118
+ if (startH === undefined || startM === undefined || endH === undefined || endM === undefined)
119
+ continue;
120
+
121
+ const startMinutes = startH * 60 + startM;
122
+ const endMinutes = endH * 60 + endM;
123
+
124
+ let inWindow = false;
125
+ if (startMinutes <= endMinutes) {
126
+ inWindow = currentMinutes >= startMinutes && currentMinutes < endMinutes;
127
+ } else {
128
+ inWindow = currentMinutes >= startMinutes || currentMinutes < endMinutes;
129
+ }
130
+
131
+ if (inWindow) {
132
+ let targetYear = year;
133
+ let targetMonth = month;
134
+ let targetDay = day;
135
+
136
+ if (currentMinutes >= endMinutes) {
137
+ const nextDay = new Date(Date.UTC(year, month - 1, day + 1));
138
+ targetYear = nextDay.getUTCFullYear();
139
+ targetMonth = nextDay.getUTCMonth() + 1;
140
+ targetDay = nextDay.getUTCDate();
141
+ }
142
+
143
+ const nextActive = localTimeToUtc(
144
+ targetYear,
145
+ targetMonth,
146
+ targetDay,
147
+ endH,
148
+ endM,
149
+ 0,
150
+ timezone,
151
+ );
152
+ return { inQuietHours: true, nextActiveTime: nextActive };
153
+ }
154
+ }
155
+ return { inQuietHours: false };
156
+ };
157
+
158
+ let initialCheck = checkWindowAt(fromDate);
159
+ if (!initialCheck || !initialCheck.inQuietHours) {
160
+ return { inQuietHours: false };
161
+ }
162
+
163
+ // Chained / overlapping quiet hours intervals
164
+ let candidateTime = initialCheck.nextActiveTime!;
165
+ for (let i = 0; i < 10; i++) {
166
+ const subsequentCheck = checkWindowAt(candidateTime);
167
+ if (subsequentCheck && subsequentCheck.inQuietHours && subsequentCheck.nextActiveTime) {
168
+ if (subsequentCheck.nextActiveTime.getTime() <= candidateTime.getTime()) {
169
+ break;
170
+ }
171
+ candidateTime = subsequentCheck.nextActiveTime;
172
+ } else {
173
+ break;
174
+ }
175
+ }
176
+
177
+ return { inQuietHours: true, nextActiveTime: candidateTime };
178
+ }
179
+
180
+ // ─── Bootstrap ─────────────────────────────────────────────────────────────
181
+
182
+ loadEnv();
183
+ const config = readBaseConfig();
184
+
185
+ let logger: ReturnType<typeof createLogger>;
186
+ let redis: RedisClient;
187
+ let sql: any;
188
+ let db: any;
189
+ let templateRepo: TemplateRepository;
190
+
191
+ let templateCache: TemplateCache;
192
+
193
+ let consumer: StreamConsumer;
194
+ let pendingScanner: PendingMessageScanner;
195
+ let worker: BaseWorker;
196
+ let healthInterval: NodeJS.Timeout | null = null;
197
+
198
+ export interface EngineWorkerOptions extends WorkerOptions {
199
+ registry: any;
200
+ idempotency: any;
201
+ throttle: any;
202
+ projectSettings: ProjectSettingsCache;
203
+ redis: Redis;
204
+ templateCache: TemplateCache;
205
+ aiPendingProducer: any;
206
+ scheduledProducer: any;
207
+ outboundProducers: any;
208
+ globalEmitter: any;
209
+ contactRepo: any;
210
+ db: any;
211
+ }
212
+
213
+ export class EngineWorker extends BaseWorker {
214
+ private readonly registry: any;
215
+ private readonly idempotency: any;
216
+ private readonly throttle: any;
217
+ private readonly projectSettings: ProjectSettingsCache;
218
+ private readonly redisCli: Redis;
219
+ private readonly templateCache: TemplateCache;
220
+ private readonly aiPendingProducer: any;
221
+ private readonly scheduledProducer: any;
222
+ private readonly outboundProducers: any;
223
+ private readonly globalEmitter: any;
224
+ private readonly contactRepo: any;
225
+ private readonly db: any;
226
+
227
+ constructor(options: EngineWorkerOptions) {
228
+ super(options);
229
+ this.registry = options.registry;
230
+ this.idempotency = options.idempotency;
231
+ this.throttle = options.throttle;
232
+ this.projectSettings = options.projectSettings;
233
+ this.redisCli = options.redis;
234
+ this.templateCache = options.templateCache;
235
+ this.aiPendingProducer = options.aiPendingProducer;
236
+ this.scheduledProducer = options.scheduledProducer;
237
+ this.outboundProducers = options.outboundProducers;
238
+ this.globalEmitter = options.globalEmitter;
239
+ this.contactRepo = options.contactRepo;
240
+ this.db = options.db;
241
+ }
242
+
243
+ private readonly contactsLoader = new DataLoader<
244
+ { projectId: string; recipientId: string },
245
+ any[]
246
+ >(async (keys) => {
247
+ const byProject = new Map<string, string[]>();
248
+ for (const key of keys) {
249
+ if (!byProject.has(key.projectId)) byProject.set(key.projectId, []);
250
+ byProject.get(key.projectId)!.push(key.recipientId);
251
+ }
252
+
253
+ const resultsByProjectAndUser = new Map<string, Map<string, any[]>>();
254
+ for (const [projectId, userIds] of byProject) {
255
+ const activeContactsMap = await this.contactRepo.findActiveByUserIds(projectId, userIds);
256
+ resultsByProjectAndUser.set(projectId, activeContactsMap);
257
+ }
258
+
259
+ return keys.map((key) => {
260
+ const projectMap = resultsByProjectAndUser.get(key.projectId);
261
+ if (!projectMap) return [];
262
+ return projectMap.get(key.recipientId) || [];
263
+ });
264
+ });
265
+
266
+ /**
267
+ * Suppressed destinations for one (project, channel), as a normalised set.
268
+ *
269
+ * Loaded per project+channel rather than per address: a campaign is thousands
270
+ * of messages against one channel, so this collapses to a single query for
271
+ * the whole batch. Cached for the loader's lifetime of a tick, which means a
272
+ * suppression written mid-batch takes effect on the next batch — acceptable,
273
+ * since the webhook that writes it is itself minutes behind the send.
274
+ */
275
+ private readonly suppressionsLoader = new DataLoader<
276
+ { projectId: string; channel: string },
277
+ Set<string>
278
+ >(async (keys) => {
279
+ const results = new Map<string, Set<string>>();
280
+ for (const key of keys) {
281
+ const cacheKey = `${key.projectId}:${key.channel}`;
282
+ if (results.has(cacheKey)) continue;
283
+ try {
284
+ const rows = await this.db
285
+ .select({ target: suppressions.target })
286
+ .from(suppressions)
287
+ .where(
288
+ and(
289
+ eq(suppressions.projectId, key.projectId),
290
+ eq(suppressions.channel, key.channel as any),
291
+ ),
292
+ );
293
+ results.set(
294
+ cacheKey,
295
+ new Set(rows.map((r: { target: string }) => normaliseTarget(r.target))),
296
+ );
297
+ } catch (err) {
298
+ // A suppression lookup that fails must not silently become "nothing
299
+ // is suppressed" — that would resume mailing people who opted out.
300
+ this.logger.error(
301
+ { err, projectId: key.projectId, channel: key.channel },
302
+ "suppression lookup failed — holding message",
303
+ );
304
+ throw err;
305
+ }
306
+ }
307
+ return keys.map((key) => results.get(`${key.projectId}:${key.channel}`) ?? new Set<string>());
308
+ });
309
+
310
+ async process(message: StreamMessage): Promise<void> {
311
+ const { event } = message;
312
+
313
+ const payloadResult = this.registry.safeParsePayload("notification.enriched", event.payload);
314
+ if (!payloadResult.success) {
315
+ this.logger.warn(
316
+ { messageId: message.id, issues: payloadResult.error.issues },
317
+ "invalid notification.enriched payload — skipping",
318
+ );
319
+ return;
320
+ }
321
+
322
+ const enriched = payloadResult.data as NotificationEnrichedPayload;
323
+
324
+ // Idempotency
325
+ const idempotencyKey = `${enriched.rawEventId}:${enriched.recipientId}:${enriched.channel}`;
326
+ let customTtl: number | undefined;
327
+ if (enriched.scheduledAt) {
328
+ const msUntil = new Date(enriched.scheduledAt).getTime() - Date.now();
329
+ if (msUntil > 0) {
330
+ // base 24h (86400) + schedule time
331
+ customTtl = 86400 + Math.ceil(msUntil / 1000);
332
+ }
333
+ }
334
+
335
+ if (!(await this.idempotency.checkAndMark(idempotencyKey, 60))) {
336
+ this.logger.debug({ messageId: message.id, eventId: event.id }, "duplicate — skipping");
337
+ return;
338
+ }
339
+
340
+ try {
341
+ // Opt-in check
342
+ if (enriched.recipient.preferences.optedOut) {
343
+ this.logger.info(
344
+ { messageId: message.id, recipientId: enriched.recipientId, eventType: event.type },
345
+ "user opted out — dropping",
346
+ );
347
+ this.globalEmitter.emit("notification:skipped", {
348
+ projectId: enriched.projectId,
349
+ eventId: event.id,
350
+ recipientId: enriched.recipientId,
351
+ reason: "user_opted_out",
352
+ });
353
+ await this.idempotency.markProcessed(idempotencyKey, customTtl);
354
+ return;
355
+ }
356
+
357
+ if (enriched.recipient.preferences.channels?.includes(enriched.channel)) {
358
+ this.logger.info(
359
+ { messageId: message.id, recipientId: enriched.recipientId, channel: enriched.channel },
360
+ "user disabled notification channel — dropping",
361
+ );
362
+ this.globalEmitter.emit("notification:skipped", {
363
+ projectId: enriched.projectId,
364
+ eventId: event.id,
365
+ recipientId: enriched.recipientId,
366
+ reason: "channel_disabled",
367
+ });
368
+ await this.idempotency.markProcessed(idempotencyKey, customTtl);
369
+ return;
370
+ }
371
+
372
+ // Quiet hours check
373
+ const qh = enriched.recipient.preferences.quietHours;
374
+ if (qh && qh.length > 0 && enriched.priority !== "critical") {
375
+ const qhResult = isInQuietHours(enriched.recipient.timezone, qh);
376
+ if (qhResult.inQuietHours && qhResult.nextActiveTime) {
377
+ this.logger.info(
378
+ {
379
+ messageId: message.id,
380
+ recipientId: enriched.recipientId,
381
+ nextActiveTime: qhResult.nextActiveTime.toISOString(),
382
+ },
383
+ "user is in quiet hours — deferring notification",
384
+ );
385
+ enriched.scheduledAt = qhResult.nextActiveTime.toISOString();
386
+ }
387
+ }
388
+
389
+ // Rate limit. Per-project overrides win over the process-wide default;
390
+ // a lookup failure must not drop the notification, so fall back rather
391
+ // than propagate.
392
+ let projectThrottle: { throttleLimit: number | null; throttleWindowHours: number | null } = {
393
+ throttleLimit: null,
394
+ throttleWindowHours: null,
395
+ };
396
+ try {
397
+ projectThrottle = await this.projectSettings.get(enriched.projectId);
398
+ } catch (err) {
399
+ this.logger.warn(
400
+ { err, projectId: enriched.projectId },
401
+ "could not read project throttle settings — falling back to the global default",
402
+ );
403
+ }
404
+
405
+ const throttleResult = await this.throttle.check(
406
+ enriched.projectId,
407
+ enriched.recipientId,
408
+ enriched.priority,
409
+ {
410
+ limit: projectThrottle.throttleLimit,
411
+ windowHours: projectThrottle.throttleWindowHours,
412
+ scheduledAt: enriched.scheduledAt,
413
+ },
414
+ );
415
+ if (!throttleResult.allowed) {
416
+ this.logger.info(
417
+ {
418
+ messageId: message.id,
419
+ recipientId: enriched.recipientId,
420
+ count: throttleResult.count,
421
+ limit: throttleResult.limit,
422
+ priority: enriched.priority,
423
+ },
424
+ "user throttled — dropping",
425
+ );
426
+ this.globalEmitter.emit(
427
+ "notification:throttled",
428
+ enriched.recipientId,
429
+ throttleResult.count,
430
+ );
431
+ await this.idempotency.markProcessed(idempotencyKey, customTtl);
432
+ return;
433
+ }
434
+
435
+ // Gather AI Prompts
436
+ // Gather AI Prompts
437
+ let dbTemplate = null;
438
+ if (enriched.templateId) {
439
+ dbTemplate = await this.templateCache.getCachedTemplate(
440
+ enriched.projectId,
441
+ enriched.templateId,
442
+ );
443
+ if (!dbTemplate) {
444
+ this.logger.warn(
445
+ { messageId: message.id, templateId: enriched.templateId },
446
+ "template not found — dropping",
447
+ );
448
+ this.globalEmitter.emit("notification:skipped", {
449
+ projectId: enriched.projectId,
450
+ eventId: event.id,
451
+ recipientId: enriched.recipientId,
452
+ reason: "template_not_found",
453
+ });
454
+ await this.idempotency.markProcessed(idempotencyKey, customTtl);
455
+ return;
456
+ }
457
+ }
458
+ const aiPrompts = {
459
+ ...(dbTemplate?.aiPrompts ?? {}),
460
+ ...(enriched.aiPrompts ?? {}),
461
+ };
462
+
463
+ // Drives whether this message gets an unsubscribe header, and what the
464
+ // resulting opt-out applies to.
465
+ const templateTopics: string[] = dbTemplate?.topics ?? [];
466
+
467
+ if (Object.keys(aiPrompts).length > 0) {
468
+ const aiPendingPayload = {
469
+ projectId: enriched.projectId,
470
+ enrichedEventId: event.id,
471
+ recipientId: enriched.recipientId,
472
+ channel: enriched.channel,
473
+ priority: enriched.priority,
474
+ templateId: enriched.templateId,
475
+ templateVariables: enriched.templateVariables,
476
+ recipient: enriched.recipient,
477
+ aiPrompts,
478
+ scheduledAt: enriched.scheduledAt,
479
+ fallbackChain: enriched.fallbackChain,
480
+ };
481
+
482
+ const aiPendingEnvelope = buildStreamEvent(
483
+ "notification.ai_pending",
484
+ aiPendingPayload as Record<string, unknown>,
485
+ "engine",
486
+ event.metadata.traceId,
487
+ );
488
+
489
+ await this.aiPendingProducer.publish(aiPendingEnvelope);
490
+ this.logger.info(
491
+ {
492
+ messageId: message.id,
493
+ eventId: event.id,
494
+ recipientId: enriched.recipientId,
495
+ traceId: event.metadata.traceId,
496
+ },
497
+ "task routed to AI worker",
498
+ );
499
+ await this.idempotency.markProcessed(idempotencyKey, customTtl);
500
+ return;
501
+ }
502
+
503
+ // Render template
504
+ const rendered = renderWithTemplate(dbTemplate, enriched.templateVariables);
505
+
506
+ const allContacts = await this.contactsLoader.load({
507
+ projectId: enriched.projectId,
508
+ recipientId: enriched.recipientId,
509
+ });
510
+ const activeContacts = allContacts.filter(
511
+ (c: any) => c.channel === enriched.channel && c.active,
512
+ );
513
+
514
+ if (activeContacts.length === 0) {
515
+ this.logger.info(
516
+ { messageId: message.id, recipientId: enriched.recipientId, channel: enriched.channel },
517
+ "no active contacts for channel — dropping",
518
+ );
519
+ this.globalEmitter.emit("notification:skipped", {
520
+ projectId: enriched.projectId,
521
+ eventId: event.id,
522
+ recipientId: enriched.recipientId,
523
+ reason: "no_active_contacts",
524
+ });
525
+ await this.idempotency.markProcessed(idempotencyKey, customTtl);
526
+ return;
527
+ }
528
+
529
+ const suppressedTargets = await this.suppressionsLoader.load({
530
+ projectId: enriched.projectId,
531
+ channel: enriched.channel,
532
+ });
533
+
534
+ for (const contact of activeContacts) {
535
+ if (contact.preferences?.optedOut) {
536
+ continue;
537
+ }
538
+
539
+ // A suppression outranks every other gate, `critical` included: it
540
+ // records that the person asked us to stop, or that the address is
541
+ // dead. Sending anyway is what gets a domain blocked.
542
+ if (contact.target && suppressedTargets.has(normaliseTarget(contact.target))) {
543
+ this.logger.info(
544
+ {
545
+ messageId: message.id,
546
+ recipientId: enriched.recipientId,
547
+ channel: enriched.channel,
548
+ },
549
+ "destination suppressed — dropping",
550
+ );
551
+ this.globalEmitter.emit("notification:skipped", {
552
+ projectId: enriched.projectId,
553
+ eventId: event.id,
554
+ recipientId: enriched.recipientId,
555
+ reason: "suppressed",
556
+ });
557
+ continue;
558
+ }
559
+
560
+ const taskId = `${enriched.rawEventId}:${contact.id || randomUUID()}`;
561
+ const resolvedDestination = contact.target;
562
+
563
+ // One-click unsubscribe headers, but only on mail that should carry
564
+ // them. A template with no topic is transactional by this codebase's
565
+ // own convention — a password reset, a receipt — and putting an
566
+ // unsubscribe button on those invites people to switch off mail they
567
+ // actually need, with no topic to scope the opt-out to anyway.
568
+ if (
569
+ enriched.channel === "email" &&
570
+ templateTopics.length > 0 &&
571
+ !config.UNSUBSCRIBE_SECRET
572
+ ) {
573
+ this.logger.warn(
574
+ { projectId: enriched.projectId, recipientId: enriched.recipientId },
575
+ "UNSUBSCRIBE_SECRET is not configured — email sent without RFC 8058 one-click unsubscribe headers",
576
+ );
577
+ }
578
+
579
+ const unsubscribeHeaders =
580
+ enriched.channel === "email" &&
581
+ templateTopics.length > 0 &&
582
+ config.UNSUBSCRIBE_SECRET &&
583
+ config.PUBLIC_URL &&
584
+ resolvedDestination
585
+ ? buildUnsubscribeHeaders({
586
+ claim: {
587
+ projectId: enriched.projectId,
588
+ userId: enriched.recipientId,
589
+ channel: enriched.channel,
590
+ target: resolvedDestination,
591
+ topics: templateTopics,
592
+ },
593
+ secret: config.UNSUBSCRIBE_SECRET,
594
+ publicUrl: config.PUBLIC_URL,
595
+ })
596
+ : undefined;
597
+
598
+ const taskPayload: NotificationDispatchedPayload = {
599
+ projectId: enriched.projectId,
600
+ taskId,
601
+ enrichedEventId: event.id,
602
+ recipientId: enriched.recipientId,
603
+ channel: enriched.channel,
604
+ priority: enriched.priority,
605
+ templateId: enriched.templateId,
606
+ templateVariables: enriched.templateVariables,
607
+ aiPrompts: enriched.aiPrompts,
608
+ recipient: enriched.recipient,
609
+ renderedContent: rendered,
610
+ destination: resolvedDestination,
611
+ deliveryOptions: {
612
+ maxAttempts: 3,
613
+ timeoutMs: 10_000,
614
+ ...(unsubscribeHeaders ? { headers: unsubscribeHeaders } : {}),
615
+ },
616
+ fallbackChain: enriched.fallbackChain,
617
+ campaignId: enriched.campaignId,
618
+ };
619
+
620
+ const envelope = buildStreamEvent(
621
+ "notification.dispatched",
622
+ taskPayload as Record<string, unknown>,
623
+ "engine",
624
+ event.metadata.traceId,
625
+ );
626
+
627
+ // Route by scheduledAt
628
+ const now = Date.now();
629
+ const scheduledAt = enriched.scheduledAt ? new Date(enriched.scheduledAt).getTime() : now;
630
+
631
+ if (scheduledAt > now) {
632
+ await this.db
633
+ .insert(scheduledPayloads)
634
+ .values({
635
+ taskId,
636
+ payload: {
637
+ ...taskPayload,
638
+ scheduledAt: enriched.scheduledAt,
639
+ },
640
+ })
641
+ .onConflictDoNothing();
642
+
643
+ const scheduledEnvelope = buildStreamEvent(
644
+ "notification.scheduled",
645
+ {
646
+ projectId: enriched.projectId,
647
+ enrichedEventId: event.id,
648
+ taskId,
649
+ scheduledAt: enriched.scheduledAt!,
650
+ },
651
+ "engine",
652
+ event.metadata.traceId,
653
+ );
654
+
655
+ await this.scheduledProducer.publish(scheduledEnvelope);
656
+ this.logger.info(
657
+ {
658
+ messageId: message.id,
659
+ taskId,
660
+ scheduledAt: enriched.scheduledAt,
661
+ traceId: event.metadata.traceId,
662
+ },
663
+ "task scheduled and payload cached",
664
+ );
665
+ } else {
666
+ const p = getPriorityBucket(enriched.priority);
667
+ const outboundProducer = this.outboundProducers[p] ?? this.outboundProducers["normal"]!;
668
+
669
+ await outboundProducer.publish(envelope);
670
+ this.logger.info(
671
+ {
672
+ messageId: message.id,
673
+ taskId,
674
+ recipientId: enriched.recipientId,
675
+ traceId: event.metadata.traceId,
676
+ },
677
+ "task dispatched",
678
+ );
679
+ }
680
+ }
681
+ await this.idempotency.markProcessed(idempotencyKey, customTtl);
682
+ } catch (err) {
683
+ await this.idempotency.unmark(idempotencyKey).catch(() => {});
684
+ throw err;
685
+ }
686
+ }
687
+ }
688
+
689
+ let subscriber: any = null;
690
+
691
+ export async function startEngineWorker() {
692
+ logger = createLogger({ name: "engine", level: config.LOG_LEVEL });
693
+ redis = new RedisClient({ url: config.REDIS_URL, name: "engine", logger });
694
+ const dbData = createDatabase({ url: config.DATABASE_URL, applicationName: "engine", logger });
695
+ sql = dbData.sql;
696
+ db = dbData.db;
697
+ templateRepo = new TemplateRepository(db);
698
+ templateCache = new TemplateCache(templateRepo);
699
+ const contactRepo = new ContactRepository(db);
700
+ consumer = new StreamConsumer({
701
+ redis: redis.native,
702
+ stream: ENRICHED_STREAMS as unknown as StreamName[],
703
+ group: CONSUMER_GROUPS.ENGINE,
704
+ consumer: `engine-${process.pid}`,
705
+ dlqStream: STREAMS.DEAD_LETTER,
706
+ batchSize: config.WORKER_CONCURRENCY,
707
+ logger,
708
+ });
709
+
710
+ pendingScanner = new PendingMessageScanner({
711
+ redis: redis.native,
712
+ stream: ENRICHED_STREAMS as unknown as StreamName[],
713
+ group: CONSUMER_GROUPS.ENGINE,
714
+ consumer: `engine-${process.pid}`,
715
+ logger,
716
+ });
717
+
718
+ const outboundProducers = {
719
+ critical: new StreamProducer({
720
+ redis: redis.native,
721
+ stream: STREAMS.OUTBOUND_CRITICAL,
722
+ logger,
723
+ }),
724
+ normal: new StreamProducer({ redis: redis.native, stream: STREAMS.OUTBOUND_NORMAL, logger }),
725
+ low: new StreamProducer({ redis: redis.native, stream: STREAMS.OUTBOUND_LOW, logger }),
726
+ };
727
+
728
+ const scheduledProducer = new StreamProducer({
729
+ redis: redis.native,
730
+ stream: STREAMS.SCHEDULED,
731
+ logger,
732
+ });
733
+
734
+ const aiPendingProducer = new StreamProducer({
735
+ redis: redis.native,
736
+ stream: STREAMS.AI_PENDING,
737
+ logger,
738
+ });
739
+
740
+ const idempotency = new IdempotencyGuard({
741
+ redis: redis.native,
742
+ keyPrefix: "notif:processed:engine",
743
+ ttlSeconds: 86_400,
744
+ });
745
+
746
+ const throttle = new UserThrottle({
747
+ redis: redis.native,
748
+ maxPerHour: parseInt(process.env.RATE_LIMIT_PER_HOUR || "100", 10),
749
+ });
750
+
751
+ // Per-project throttle overrides, cached because this is read once per
752
+ // notification. Stale entries expire on their own; the pub/sub subscriber
753
+ // below only makes an admin's change take effect sooner.
754
+ const projectRepo = new ProjectRepository(db);
755
+ const projectSettings = new ProjectSettingsCache((projectId) =>
756
+ projectRepo.findThrottleSettings(projectId),
757
+ );
758
+
759
+ // ─── Stage 2: Decision Engine ───────────────────────────────────────────────
760
+ //
761
+ // Pipeline:
762
+ // 1. Parse payload as notification.enriched
763
+ // 2. Idempotency check
764
+ // 3. Check user opt-in (from enriched recipient.preferences)
765
+ // 4. Apply per-user hourly rate limit
766
+ // 5. Render template using user locale
767
+ // 6. Route: if scheduledAt is future → SCHEDULED; else → OUTBOUND
768
+
769
+ worker = new EngineWorker({
770
+ consumer,
771
+ pendingScanner,
772
+ logger,
773
+ concurrency: config.WORKER_CONCURRENCY,
774
+ registry,
775
+ idempotency,
776
+ throttle,
777
+ projectSettings,
778
+ redis: redis.native,
779
+ templateCache,
780
+ aiPendingProducer,
781
+ scheduledProducer,
782
+ outboundProducers,
783
+ globalEmitter,
784
+ contactRepo,
785
+ db,
786
+ });
787
+
788
+ subscriber = redis.native.duplicate();
789
+ await subscriber.subscribe(
790
+ PUBSUB_CHANNELS.TEMPLATE_INVALIDATED,
791
+ PUBSUB_CHANNELS.PROJECT_INVALIDATED,
792
+ );
793
+ subscriber.on("message", (channel: string, message: string) => {
794
+ if (channel === PUBSUB_CHANNELS.TEMPLATE_INVALIDATED) {
795
+ templateCache.invalidateKey(message);
796
+ logger.info({ cacheKey: message }, "invalidated template cache");
797
+ } else if (channel === PUBSUB_CHANNELS.PROJECT_INVALIDATED) {
798
+ projectSettings.invalidate(message);
799
+ logger.info({ projectId: message }, "invalidated project settings cache");
800
+ }
801
+ });
802
+
803
+ // ─── Health check interval ──────────────────────────────────────────────────
804
+
805
+ healthInterval = startHealthReporter("engine", worker, redis, logger);
806
+
807
+ logger.info({ env: config.NODE_ENV }, "engine starting");
808
+ await worker.start();
809
+ }
810
+
811
+ // ─── Shutdown ──────────────────────────────────────────────────────────────
812
+
813
+ export async function stopEngineWorker(): Promise<void> {
814
+ logger?.info("shutdown initiated");
815
+ if (healthInterval) {
816
+ clearInterval(healthInterval);
817
+ healthInterval = null;
818
+ }
819
+ if (subscriber) {
820
+ subscriber.disconnect();
821
+ subscriber = null;
822
+ }
823
+ if (worker) await worker.stop();
824
+ if (sql) await sql.end();
825
+ if (redis) await redis.disconnect();
826
+ logger?.info("engine stopped");
827
+ }