notifkit 0.1.3 → 0.1.5

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 (96) hide show
  1. package/README.md +179 -152
  2. package/dist/index.d.mts +193 -129
  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 +7 -1
  32. package/scripts/create-project.mjs +61 -0
  33. package/src/client.ts +412 -0
  34. package/src/config/index.ts +107 -0
  35. package/src/contracts/common.ts +28 -0
  36. package/src/contracts/envelope.ts +31 -0
  37. package/src/contracts/events/notification-ai-pending.ts +18 -0
  38. package/src/contracts/events/notification-canceled.ts +7 -0
  39. package/src/contracts/events/notification-created.ts +14 -0
  40. package/src/contracts/events/notification-delivered.ts +17 -0
  41. package/src/contracts/events/notification-dispatched.ts +45 -0
  42. package/src/contracts/events/notification-enriched.ts +46 -0
  43. package/src/contracts/events/notification-failed.ts +19 -0
  44. package/src/contracts/events/notification-requested.ts +36 -0
  45. package/src/contracts/events/notification-scheduled.ts +9 -0
  46. package/src/contracts/events/notification-skipped.ts +9 -0
  47. package/src/contracts/helpers.ts +21 -0
  48. package/src/contracts/index.ts +46 -0
  49. package/src/contracts/metadata.ts +10 -0
  50. package/src/contracts/registry.ts +88 -0
  51. package/src/contracts/sdk.ts +242 -0
  52. package/src/contracts/streams.ts +62 -0
  53. package/src/db/index.ts +69 -0
  54. package/src/db/schema.ts +412 -0
  55. package/src/idempotency/index.ts +50 -0
  56. package/src/index.ts +19 -0
  57. package/src/logger/index.ts +60 -0
  58. package/src/metrics/index.ts +53 -0
  59. package/src/queue/index.ts +501 -0
  60. package/src/rate-limiter/index.ts +210 -0
  61. package/src/redis/index.ts +89 -0
  62. package/src/repositories/index.ts +1246 -0
  63. package/src/server.ts +277 -0
  64. package/src/services/ai/main.ts +404 -0
  65. package/src/services/api/handlers.ts +1734 -0
  66. package/src/services/api/http.ts +64 -0
  67. package/src/services/api/main.ts +693 -0
  68. package/src/services/api/router.ts +82 -0
  69. package/src/services/delivery/main.ts +842 -0
  70. package/src/services/delivery/throttle.ts +71 -0
  71. package/src/services/engine/main.ts +827 -0
  72. package/src/services/enricher/main.ts +594 -0
  73. package/src/services/events/main.ts +365 -0
  74. package/src/services/scheduler/main.ts +319 -0
  75. package/src/services/workflow/main.ts +627 -0
  76. package/src/shared/batch-processor.ts +67 -0
  77. package/src/shared/cache.ts +47 -0
  78. package/src/shared/circuit-breaker.ts +74 -0
  79. package/src/shared/dataloader.ts +41 -0
  80. package/src/shared/events.ts +3 -0
  81. package/src/shared/index.ts +39 -0
  82. package/src/shared/semaphore.ts +33 -0
  83. package/src/shared/utils.ts +64 -0
  84. package/src/templates/cache.ts +32 -0
  85. package/src/templates/index.ts +69 -0
  86. package/src/templates/render.ts +128 -0
  87. package/src/transport/index.ts +96 -0
  88. package/src/unsubscribe/index.ts +127 -0
  89. package/src/workers/health.ts +31 -0
  90. package/src/workers/index.ts +266 -0
  91. package/src/workflows/index.ts +2 -0
  92. package/src/workflows/registry.ts +21 -0
  93. package/src/workflows/sdk.ts +106 -0
  94. package/dist/main-CAH0_Q6d.mjs.map +0 -1
  95. package/dist/main-CCfc45ev.mjs.map +0 -1
  96. package/dist/src-C-PfEDMY.mjs.map +0 -1
@@ -0,0 +1,594 @@
1
+ import { loadEnv, readBaseConfig } from "@/index.js";
2
+ import { createLogger } from "@/index.js";
3
+ import { RedisClient } from "@/index.js";
4
+ import {
5
+ StreamConsumer,
6
+ PendingMessageScanner,
7
+ StreamProducer,
8
+ type StreamMessage,
9
+ } from "@/index.js";
10
+ import { BaseWorker } from "@/index.js";
11
+ import {
12
+ STREAMS,
13
+ INBOUND_STREAMS,
14
+ CONSUMER_GROUPS,
15
+ registry,
16
+ buildStreamEvent,
17
+ type NotificationCreatedPayload,
18
+ type NotificationEnrichedPayload,
19
+ } from "@/index.js";
20
+ import { type StreamName } from "@/contracts/streams.js";
21
+ import { IdempotencyGuard } from "@/index.js";
22
+ import { createDatabase } from "@/db/index.js";
23
+ import {
24
+ UserRepository,
25
+ PreferenceRepository,
26
+ TemplateRepository,
27
+ ContactRepository,
28
+ } from "@/index.js";
29
+ import { TemplateCache } from "@/templates/index.js";
30
+ import { getPriorityBucket, type WorkerOptions } from "@/shared/index.js";
31
+ import { startHealthReporter } from "@/workers/index.js";
32
+
33
+ // ─── Bootstrap ─────────────────────────────────────────────────────────────
34
+
35
+ loadEnv();
36
+ const config = readBaseConfig();
37
+
38
+ let logger: ReturnType<typeof createLogger>;
39
+ let redis: RedisClient;
40
+ let sql: any;
41
+ let db: any;
42
+
43
+ let consumer: StreamConsumer;
44
+ let pendingScanner: PendingMessageScanner;
45
+ let worker: BaseWorker;
46
+ let healthInterval: NodeJS.Timeout | null = null;
47
+
48
+ export interface EnricherWorkerOptions extends WorkerOptions {
49
+ producers: any;
50
+ idempotency: any;
51
+ userRepo: any;
52
+ prefRepo: any;
53
+ contactRepo: any;
54
+ templateCache: TemplateCache;
55
+ }
56
+
57
+ export class EnricherWorker extends BaseWorker {
58
+ private readonly producers: any;
59
+ private readonly idempotency: any;
60
+ private readonly userRepo: any;
61
+ private readonly prefRepo: any;
62
+ private readonly contactRepo: any;
63
+ private readonly templateCache: TemplateCache;
64
+
65
+ private userBatch: {
66
+ projectId: string;
67
+ userId: string;
68
+ resolve: (p: any) => void;
69
+ reject: (e: any) => void;
70
+ }[] = [];
71
+ private batchTimer: NodeJS.Timeout | null = null;
72
+ private eventBuffer: {
73
+ producer: any;
74
+ event: any;
75
+ resolve: () => void;
76
+ reject: (e: any) => void;
77
+ }[] = [];
78
+ private flushTimer: NodeJS.Timeout | null = null;
79
+
80
+ private contactBatch: {
81
+ projectId: string;
82
+ userIds: string[];
83
+ resolve: (c: Map<string, any[]>) => void;
84
+ reject: (e: any) => void;
85
+ }[] = [];
86
+ private contactBatchTimer: NodeJS.Timeout | null = null;
87
+
88
+ private async loadContacts(projectId: string, userIds: string[]): Promise<Map<string, any[]>> {
89
+ return new Promise((resolve, reject) => {
90
+ this.contactBatch.push({ projectId, userIds, resolve, reject });
91
+ if (this.contactBatch.length >= 500) {
92
+ if (this.contactBatchTimer) clearTimeout(this.contactBatchTimer);
93
+ void this.flushContactBatch();
94
+ } else if (!this.contactBatchTimer) {
95
+ this.contactBatchTimer = setTimeout(() => void this.flushContactBatch(), 10);
96
+ }
97
+ });
98
+ }
99
+
100
+ private async flushContactBatch(): Promise<void> {
101
+ const batch = this.contactBatch;
102
+ this.contactBatch = [];
103
+ this.contactBatchTimer = null;
104
+ if (batch.length === 0) return;
105
+
106
+ try {
107
+ const byProject = new Map<string, typeof batch>();
108
+ for (const b of batch) {
109
+ if (!byProject.has(b.projectId)) byProject.set(b.projectId, []);
110
+ byProject.get(b.projectId)!.push(b);
111
+ }
112
+
113
+ for (const [projectId, items] of byProject) {
114
+ const userIds = Array.from(new Set(items.flatMap((i) => i.userIds)));
115
+ const contactsMap = await this.contactRepo.findActiveByUserIds(projectId, userIds);
116
+ for (const item of items) {
117
+ item.resolve(contactsMap);
118
+ }
119
+ }
120
+ } catch (err) {
121
+ for (const b of batch) b.reject(err);
122
+ }
123
+ }
124
+
125
+ constructor(options: EnricherWorkerOptions) {
126
+ super(options);
127
+ this.producers = options.producers;
128
+ this.idempotency = options.idempotency;
129
+ this.userRepo = options.userRepo;
130
+ this.prefRepo = options.prefRepo;
131
+ this.contactRepo = options.contactRepo;
132
+ this.templateCache = options.templateCache;
133
+
134
+ this.flushTimer = setInterval(() => void this.flushWorkerBuffers(), 100);
135
+ }
136
+
137
+ override async stop(): Promise<void> {
138
+ if (this.batchTimer) {
139
+ clearTimeout(this.batchTimer);
140
+ this.batchTimer = null;
141
+ }
142
+ if (this.flushTimer) {
143
+ clearInterval(this.flushTimer);
144
+ this.flushTimer = null;
145
+ }
146
+ if (this.contactBatchTimer) {
147
+ clearTimeout(this.contactBatchTimer);
148
+ this.contactBatchTimer = null;
149
+ }
150
+ await this.flushContactBatch();
151
+ await this.flushUserBatch();
152
+ await this.flushWorkerBuffers();
153
+ await super.stop();
154
+ }
155
+
156
+ private async flushWorkerBuffers(): Promise<void> {
157
+ if (this.eventBuffer.length === 0) return;
158
+ const events = this.eventBuffer;
159
+ this.eventBuffer = [];
160
+
161
+ try {
162
+ const byProducer = new Map<any, typeof events>();
163
+ for (const e of events) {
164
+ if (!byProducer.has(e.producer)) byProducer.set(e.producer, []);
165
+ byProducer.get(e.producer)!.push(e);
166
+ }
167
+ for (const [producer, batch] of byProducer) {
168
+ await producer.publishBatch(batch.map((b) => b.event));
169
+ for (const b of batch) b.resolve();
170
+ }
171
+ } catch (err: any) {
172
+ this.logger.error({ err }, "failed to flush events in EnricherWorker");
173
+ for (const e of events) e.reject(err);
174
+ }
175
+ }
176
+
177
+ private async loadUser(projectId: string, userId: string): Promise<any> {
178
+ return new Promise((resolve, reject) => {
179
+ this.userBatch.push({ projectId, userId, resolve, reject });
180
+ if (this.userBatch.length >= 500) {
181
+ if (this.batchTimer) clearTimeout(this.batchTimer);
182
+ void this.flushUserBatch();
183
+ } else if (!this.batchTimer) {
184
+ this.batchTimer = setTimeout(() => {
185
+ void this.flushUserBatch();
186
+ }, 10);
187
+ }
188
+ });
189
+ }
190
+
191
+ private async flushUserBatch() {
192
+ const batch = this.userBatch;
193
+ this.userBatch = [];
194
+ this.batchTimer = null;
195
+
196
+ const byProject = new Map<string, typeof batch>();
197
+ for (const b of batch) {
198
+ if (!byProject.has(b.projectId)) byProject.set(b.projectId, []);
199
+ byProject.get(b.projectId)!.push(b);
200
+ }
201
+
202
+ for (const [projectId, reqs] of byProject.entries()) {
203
+ try {
204
+ const uniqueIds = Array.from(new Set(reqs.map((r) => r.userId)));
205
+ const profiles = await this.userRepo.findRecordsByIds(projectId, uniqueIds);
206
+ const profileMap = new Map(profiles.map((p: any) => [p.userId, p]));
207
+
208
+ for (const req of reqs) {
209
+ req.resolve(profileMap.get(req.userId) || null);
210
+ }
211
+ } catch (err) {
212
+ for (const req of reqs) req.reject(err);
213
+ }
214
+ }
215
+ }
216
+
217
+ async process(message: StreamMessage): Promise<void> {
218
+ const { event } = message;
219
+ const publishPromises: Promise<void>[] = [];
220
+
221
+ let isRequested = true;
222
+ const requestedResult = registry.safeParsePayload("notification.requested", event.payload);
223
+ let createdResult: any = null;
224
+ if (!requestedResult.success) {
225
+ createdResult = registry.safeParsePayload("notification.created", event.payload);
226
+ isRequested = false;
227
+ }
228
+
229
+ if (!isRequested && (!createdResult || !createdResult.success)) {
230
+ const issues = createdResult
231
+ ? createdResult.error.issues
232
+ : (requestedResult as any).error.issues;
233
+ this.logger.warn({ messageId: message.id, issues }, "invalid payload — skipping");
234
+ return;
235
+ }
236
+
237
+ // Handle legacy notification.created
238
+ if (!isRequested) {
239
+ const raw = createdResult.data as NotificationCreatedPayload;
240
+ const dedupeId = `${raw.projectId}:${raw.idempotencyKey ?? event.id}`;
241
+ if (!(await this.idempotency.checkAndMark(dedupeId, 60))) return;
242
+ try {
243
+ const profile = await this.userRepo.findRecordById(raw.projectId, raw.recipientId);
244
+ if (!profile) return;
245
+
246
+ const prefs = await this.prefRepo.findByUserId(raw.projectId, raw.recipientId);
247
+ const optedOutTypes = new Set(
248
+ prefs.filter((p: any) => !p.optedIn).map((p: any) => p.eventType),
249
+ );
250
+
251
+ const enrichedPayload: NotificationEnrichedPayload = {
252
+ projectId: raw.projectId,
253
+ rawEventId: event.id,
254
+ recipientId: raw.recipientId,
255
+ channel: raw.channel,
256
+ priority: raw.priority,
257
+ templateId: raw.templateId,
258
+ templateVariables: raw.payload,
259
+ recipient: {
260
+ id: profile.userId,
261
+ email: profile.email ?? undefined,
262
+ locale: profile.language ?? "en",
263
+ timezone: profile.timezone ?? "UTC",
264
+ preferences: {
265
+ optedOut:
266
+ optedOutTypes.has(event.type) || profile.preferences.topics?.[event.type] === false,
267
+ channels: Object.entries(profile.preferences.channels ?? {})
268
+ .filter(([_, enabled]) => !enabled)
269
+ .map(([channel]) => channel as any),
270
+ quietHours: profile.preferences.quietHours,
271
+ },
272
+ },
273
+ // No campaignId: this is the legacy `notification.created` path,
274
+ // which predates campaigns and carries no label to attribute to.
275
+ scheduledAt: raw.scheduledAt,
276
+ };
277
+
278
+ const p = getPriorityBucket(raw.priority);
279
+ const producer = this.producers[p] ?? this.producers["normal"]!;
280
+
281
+ publishPromises.push(
282
+ new Promise((resolve, reject) => {
283
+ this.eventBuffer.push({
284
+ producer,
285
+ event: buildStreamEvent(
286
+ "notification.enriched",
287
+ enrichedPayload as Record<string, unknown>,
288
+ "enricher",
289
+ event.metadata.traceId,
290
+ ),
291
+ resolve,
292
+ reject,
293
+ });
294
+ }),
295
+ );
296
+ this.logger.info(
297
+ { messageId: message.id, eventId: event.id, recipientId: raw.recipientId },
298
+ "event enriched",
299
+ );
300
+ } catch (err) {
301
+ throw err;
302
+ }
303
+ await Promise.all(publishPromises).catch(async (err) => {
304
+ await this.idempotency.unmark(dedupeId).catch(() => {});
305
+ throw err;
306
+ });
307
+ await this.idempotency.markProcessed(dedupeId);
308
+ return;
309
+ }
310
+
311
+ // Handle new notification.requested
312
+ const raw = (requestedResult as any).data;
313
+ const dedupeId = `${raw.projectId}:${raw.idempotencyKey ?? event.id}`;
314
+ if (!(await this.idempotency.checkAndMark(dedupeId, 60))) return;
315
+ try {
316
+ let userIds: string[] = [];
317
+ if (raw.target.type === "user") {
318
+ userIds = [raw.target.userId];
319
+ } else if (raw.target.type === "segment") {
320
+ userIds = await this.userRepo.findUsersBySegment(raw.projectId, raw.target.segment);
321
+ this.logger.info(
322
+ { segment: raw.target.segment, count: userIds.length },
323
+ "Resolved segment",
324
+ );
325
+ } else if (raw.target.type === "topic") {
326
+ userIds = await this.userRepo.findUsersByTopic(raw.projectId, raw.target.topic);
327
+ this.logger.info({ topic: raw.target.topic, count: userIds.length }, "Resolved topic");
328
+ } else {
329
+ this.logger.warn({ target: raw.target }, "Segment/topic resolution not fully implemented");
330
+ // Stub: maybe resolve later
331
+ }
332
+
333
+ const maxUsers = readBaseConfig().SEGMENT_MAX_USERS;
334
+ if (userIds.length > maxUsers) {
335
+ this.logger.error(
336
+ { count: userIds.length, max: maxUsers, projectId: raw.projectId, eventId: event.id },
337
+ "Segment fan-out exceeds maximum allowed limit",
338
+ );
339
+ const p = getPriorityBucket(raw.priority ?? "normal");
340
+ const producer = this.producers[p] ?? this.producers.normal;
341
+ await producer.publish(
342
+ buildStreamEvent(
343
+ "notification.failed",
344
+ {
345
+ projectId: raw.projectId,
346
+ rawEventId: event.id,
347
+ error: `Segment fan-out of ${userIds.length} exceeds limit of ${maxUsers}`,
348
+ },
349
+ "enricher",
350
+ event.metadata.traceId,
351
+ ),
352
+ );
353
+ return;
354
+ }
355
+
356
+ // Topic opt-outs are keyed on the TEMPLATE's topics, not the envelope type.
357
+ // `event.type` here is "notification.requested", which no user ever sets a
358
+ // preference against, so keying on it silently disabled every opt-out.
359
+ const template = raw.templateId
360
+ ? await this.templateCache.getCachedTemplate(raw.projectId, raw.templateId)
361
+ : null;
362
+ const topics: string[] = template?.topics ?? [];
363
+
364
+ const channels =
365
+ raw.channels && raw.channels.length > 0 ? raw.channels : (["email"] as any[]);
366
+ const isFallback = (raw as any).fallback === true;
367
+
368
+ // If fallback is true, we only emit the first channel, and pass the rest in fallbackChain.
369
+ // If fallback is false, we emit all channels concurrently.
370
+ const channelsToProcess = isFallback ? [channels[0]] : channels;
371
+ const fallbackChain = isFallback ? channels.slice(1) : undefined;
372
+
373
+ const chunkArray = <T>(arr: T[], size: number) =>
374
+ Array.from({ length: Math.ceil(arr.length / size) }, (v, i) =>
375
+ arr.slice(i * size, i * size + size),
376
+ );
377
+
378
+ const chunks = chunkArray(userIds, 500);
379
+
380
+ for (const chunk of chunks) {
381
+ const profiles = (
382
+ await Promise.all(chunk.map((id) => this.loadUser(raw.projectId, id)))
383
+ ).filter(Boolean);
384
+ const contactsByUser = await this.loadContacts(
385
+ raw.projectId,
386
+ profiles.map((profile: any) => profile.userId),
387
+ );
388
+
389
+ const batchedEvents: Record<
390
+ "critical" | "high" | "normal" | "low",
391
+ Omit<any, "id" | "timestamp">[]
392
+ > = {
393
+ critical: [],
394
+ high: [],
395
+ normal: [],
396
+ low: [],
397
+ };
398
+
399
+ for (const profile of profiles) {
400
+ for (const channel of channelsToProcess) {
401
+ const contacts = contactsByUser.get(profile.userId) ?? [];
402
+ const channelContacts = contacts.filter((contact: any) => contact.channel === channel);
403
+ // Push resolves its active tokens at send time so token invalidation
404
+ // remains current. Other channels need one task per address.
405
+ const destinations =
406
+ channel === "push" ? [undefined] : channelContacts.map((c: any) => c.target);
407
+ if (destinations.length === 0) {
408
+ this.logger.info(
409
+ { recipientId: profile.userId, channel },
410
+ "no active contact for channel",
411
+ );
412
+ continue;
413
+ }
414
+ for (const destination of destinations) {
415
+ const enrichedPayload: NotificationEnrichedPayload = {
416
+ projectId: raw.projectId,
417
+ rawEventId: event.id,
418
+ recipientId: profile.userId,
419
+ channel: channel,
420
+ priority: "normal",
421
+ templateId: raw.templateId,
422
+ templateVariables: raw.data,
423
+ aiPrompts: raw.aiPrompts,
424
+ recipient: {
425
+ id: profile.userId,
426
+ email:
427
+ channel === "email"
428
+ ? (destination ?? profile.email ?? undefined)
429
+ : (profile.email ?? undefined),
430
+ phone: channel === "sms" || channel === "whatsapp" ? destination : undefined,
431
+ webhook: channel === "webhook" ? destination : undefined,
432
+ telegram: channel === "telegram" ? destination : undefined,
433
+ discord: channel === "discord" ? destination : undefined,
434
+ slack: channel === "slack" ? destination : undefined,
435
+ locale: profile.language ?? "en",
436
+ timezone: profile.timezone ?? "UTC",
437
+ preferences: {
438
+ // Opted out if the user disabled ANY topic this template carries.
439
+ optedOut: topics.some((t) => profile.preferences.topics?.[t] === false),
440
+ channels: Object.entries(profile.preferences.channels ?? {})
441
+ .filter(([_, enabled]) => !enabled)
442
+ .map(([channel]) => channel as any),
443
+ quietHours: profile.preferences.quietHours,
444
+ },
445
+ },
446
+ scheduledAt: raw.scheduledAt,
447
+ fallbackChain: fallbackChain?.length ? fallbackChain : undefined,
448
+ campaignId: raw.campaignId,
449
+ };
450
+
451
+ const msgPriority = raw.priority ?? "normal";
452
+ const p = getPriorityBucket(msgPriority);
453
+
454
+ enrichedPayload.priority = msgPriority;
455
+
456
+ batchedEvents[p].push(
457
+ buildStreamEvent(
458
+ "notification.enriched",
459
+ enrichedPayload as Record<string, unknown>,
460
+ "enricher",
461
+ event.metadata.traceId,
462
+ ),
463
+ );
464
+ }
465
+ }
466
+ }
467
+
468
+ for (const p of ["critical", "normal", "low"] as const) {
469
+ if (batchedEvents[p].length > 0) {
470
+ const producer = this.producers[p] ?? this.producers.normal;
471
+ for (const ev of batchedEvents[p]) {
472
+ publishPromises.push(
473
+ new Promise((resolve, reject) => {
474
+ this.eventBuffer.push({ producer, event: ev, resolve, reject });
475
+ }),
476
+ );
477
+ }
478
+ }
479
+ }
480
+ }
481
+
482
+ this.logger.info(
483
+ {
484
+ messageId: message.id,
485
+ eventId: event.id,
486
+ target: raw.target.type,
487
+ traceId: event.metadata.traceId,
488
+ },
489
+ "event enriched",
490
+ );
491
+ } catch (err) {
492
+ throw err;
493
+ }
494
+
495
+ await Promise.all(publishPromises).catch(async (err) => {
496
+ await this.idempotency.unmark(dedupeId).catch(() => {});
497
+ throw err;
498
+ });
499
+
500
+ await this.idempotency.markProcessed(dedupeId);
501
+ }
502
+ }
503
+
504
+ export async function startEnricherWorker() {
505
+ logger = createLogger({ name: "enricher", level: config.LOG_LEVEL });
506
+ redis = new RedisClient({ url: config.REDIS_URL, name: "enricher", logger });
507
+ const dbData = createDatabase({ url: config.DATABASE_URL, applicationName: "enricher", logger });
508
+ sql = dbData.sql;
509
+ db = dbData.db;
510
+ consumer = new StreamConsumer({
511
+ redis: redis.native,
512
+ stream: INBOUND_STREAMS as unknown as StreamName[],
513
+ group: CONSUMER_GROUPS.ENRICHER,
514
+ consumer: `enricher-${process.pid}`,
515
+ dlqStream: STREAMS.DEAD_LETTER,
516
+ batchSize: config.WORKER_CONCURRENCY,
517
+ logger,
518
+ });
519
+
520
+ pendingScanner = new PendingMessageScanner({
521
+ redis: redis.native,
522
+ stream: INBOUND_STREAMS as unknown as StreamName[],
523
+ group: CONSUMER_GROUPS.ENRICHER,
524
+ consumer: `enricher-${process.pid}`,
525
+ logger,
526
+ });
527
+
528
+ const producers = {
529
+ critical: new StreamProducer({
530
+ redis: redis.native,
531
+ stream: STREAMS.ENRICHED_CRITICAL,
532
+ logger,
533
+ }),
534
+ normal: new StreamProducer({ redis: redis.native, stream: STREAMS.ENRICHED_NORMAL, logger }),
535
+ low: new StreamProducer({ redis: redis.native, stream: STREAMS.ENRICHED_LOW, logger }),
536
+ };
537
+
538
+ const idempotency = new IdempotencyGuard({
539
+ redis: redis.native,
540
+ keyPrefix: "notif:processed:enricher",
541
+ ttlSeconds: 86_400,
542
+ });
543
+
544
+ const userRepo = new UserRepository(db);
545
+ const prefRepo = new PreferenceRepository(db);
546
+ const contactRepo = new ContactRepository(db);
547
+ const templateCache = new TemplateCache(new TemplateRepository(db));
548
+
549
+ // ─── Stage 1: Context Enricher ──────────────────────────────────────────────
550
+ //
551
+ // Pipeline:
552
+ // 1. Parse payload as notification.created
553
+ // 2. Idempotency check — drop if already processed
554
+ // 3. Load user profile (language, timezone) from DB
555
+ // 4. Load all stored preferences for this user
556
+ // 5. Publish notification.enriched to ENRICHED stream
557
+
558
+ // 5. Publish notification.enriched to ENRICHED stream
559
+
560
+ worker = new EnricherWorker({
561
+ consumer,
562
+ pendingScanner,
563
+ logger,
564
+ maxRetriesBeforeDlq: 5,
565
+ concurrency: config.WORKER_CONCURRENCY,
566
+ producers,
567
+ idempotency,
568
+ userRepo,
569
+ prefRepo,
570
+ contactRepo,
571
+ templateCache,
572
+ });
573
+
574
+ // ─── Health check interval ──────────────────────────────────────────────────
575
+
576
+ healthInterval = startHealthReporter("enricher", worker, redis, logger);
577
+
578
+ logger.info({ env: config.NODE_ENV }, "enricher starting");
579
+ await worker.start();
580
+ }
581
+
582
+ // ─── Shutdown ──────────────────────────────────────────────────────────────
583
+
584
+ export async function stopEnricherWorker(): Promise<void> {
585
+ logger?.info("shutdown initiated");
586
+ if (healthInterval) {
587
+ clearInterval(healthInterval);
588
+ healthInterval = null;
589
+ }
590
+ if (worker) await worker.stop();
591
+ if (sql) await sql.end();
592
+ if (redis) await redis.disconnect();
593
+ logger?.info("enricher stopped");
594
+ }