notifkit 0.1.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.
@@ -0,0 +1,3424 @@
1
+ import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
+ import { registerTransport } from "./index.mjs";
3
+ import { config } from "dotenv";
4
+ import { resolve } from "node:path";
5
+ import { z } from "zod";
6
+ import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
7
+ import postgres from "postgres";
8
+ import { drizzle } from "drizzle-orm/postgres-js";
9
+ import { boolean, check, index, integer, jsonb, pgEnum, pgTable, primaryKey, text, time, timestamp, unique, uuid, varchar } from "drizzle-orm/pg-core";
10
+ import { and, desc, eq, inArray, sql } from "drizzle-orm";
11
+ import { createInsertSchema, createSelectSchema } from "drizzle-zod";
12
+ import { migrate } from "drizzle-orm/postgres-js/migrator";
13
+ import { fileURLToPath } from "url";
14
+ import path from "path";
15
+ import fs from "fs";
16
+ import pino from "pino";
17
+ import promClient from "prom-client";
18
+ import { EventEmitter } from "node:events";
19
+ import { randomUUID as randomUUID$1 } from "crypto";
20
+ import { Redis } from "ioredis";
21
+ //#region src/config/index.ts
22
+ var config_exports = /* @__PURE__ */ __exportAll({
23
+ AI_DEFAULTS: () => AI_DEFAULTS,
24
+ baseConfigSchema: () => baseConfigSchema,
25
+ getAiConfig: () => getAiConfig,
26
+ loadEnv: () => loadEnv,
27
+ parseConfig: () => parseConfig,
28
+ readBaseConfig: () => readBaseConfig,
29
+ setAiConfig: () => setAiConfig,
30
+ setGlobalConfig: () => setGlobalConfig
31
+ });
32
+ function loadEnv(path) {
33
+ const envPath = path ?? resolve(process.cwd(), ".env");
34
+ config({
35
+ path: envPath,
36
+ override: false
37
+ });
38
+ }
39
+ function parseConfig(schema, data) {
40
+ const result = schema.safeParse(data);
41
+ if (!result.success) {
42
+ const fields = {};
43
+ for (const issue of result.error.issues) {
44
+ const key = issue.path.join(".");
45
+ fields[key] ??= [];
46
+ fields[key].push(issue.message);
47
+ }
48
+ throw new ValidationError("Configuration validation failed", fields);
49
+ }
50
+ return result.data;
51
+ }
52
+ const baseConfigSchema = z.object({
53
+ NODE_ENV: z.enum([
54
+ "development",
55
+ "test",
56
+ "production"
57
+ ]).default("development"),
58
+ LOG_LEVEL: z.enum([
59
+ "fatal",
60
+ "error",
61
+ "warn",
62
+ "info",
63
+ "debug",
64
+ "trace",
65
+ "silent"
66
+ ]).default("info"),
67
+ PORT: z.coerce.number().int().min(1).max(65535).default(3e3),
68
+ HOST: z.string().default("127.0.0.1"),
69
+ REDIS_URL: z.string().url().default("redis://localhost:6379"),
70
+ DATABASE_URL: z.string().url().default("postgres://platform:platform@localhost:5432/notifkit"),
71
+ ADMIN_API_KEY: z.string().optional(),
72
+ WORKER_CONCURRENCY: z.coerce.number().int().min(1).default(10),
73
+ QUEUE_MAX_LEN: z.coerce.number().int().min(1).default(1e7),
74
+ DB_MAX_CONNECTIONS: z.coerce.number().int().min(1).default(2),
75
+ LOG_FLUSH_INTERVAL_MS: z.coerce.number().int().min(50).default(500),
76
+ LOG_BUFFER_MAX_SIZE: z.coerce.number().int().min(100).default(5e3),
77
+ SEGMENT_MAX_USERS: z.coerce.number().int().min(1).default(1e4),
78
+ /**
79
+ * Externally reachable base URL of this API. Unsubscribe links are built from
80
+ * it, so it must be what an inbox can actually reach — not `HOST`/`PORT`,
81
+ * which describe the bind address behind your proxy.
82
+ */
83
+ PUBLIC_URL: z.string().url().optional(),
84
+ /**
85
+ * Signing key for unsubscribe tokens. Rotating it invalidates every
86
+ * unsubscribe link already sitting in someone's inbox, so treat it as
87
+ * permanent: a dead link means the recipient reaches for the spam button
88
+ * instead, which costs far more than the key ever protected.
89
+ */
90
+ UNSUBSCRIBE_SECRET: z.string().min(16).optional()
91
+ });
92
+ let globalConfig = null;
93
+ function setGlobalConfig(config) {
94
+ globalConfig = config;
95
+ }
96
+ function readBaseConfig(data = process.env) {
97
+ if (globalConfig) return globalConfig;
98
+ return parseConfig(baseConfigSchema, data);
99
+ }
100
+ const AI_DEFAULTS = {
101
+ maxOutputTokens: 1e3,
102
+ timeoutMs: 3e4,
103
+ maxPromptsPerNotification: 5
104
+ };
105
+ let globalAiConfig = {};
106
+ function setAiConfig(config) {
107
+ globalAiConfig = config;
108
+ }
109
+ function getAiConfig() {
110
+ return globalAiConfig;
111
+ }
112
+ //#endregion
113
+ //#region src/contracts/common.ts
114
+ const NotificationChannelSchema = z.enum([
115
+ "email",
116
+ "sms",
117
+ "push",
118
+ "webhook",
119
+ "in-app"
120
+ ]);
121
+ const NotificationPrioritySchema = z.enum([
122
+ "low",
123
+ "normal",
124
+ "high",
125
+ "critical"
126
+ ]);
127
+ const NotificationStatusSchema = z.enum([
128
+ "pending",
129
+ "queued",
130
+ "processing",
131
+ "delivered",
132
+ "failed",
133
+ "bounced",
134
+ "suppressed"
135
+ ]);
136
+ //#endregion
137
+ //#region src/contracts/metadata.ts
138
+ const EventMetadataSchema = z.object({
139
+ traceId: z.string(),
140
+ source: z.string(),
141
+ retryCount: z.number().int().nonnegative().default(0),
142
+ correlationId: z.string().optional(),
143
+ causationId: z.string().optional()
144
+ });
145
+ //#endregion
146
+ //#region src/contracts/registry.ts
147
+ var EventRegistry = class {
148
+ schemas = /* @__PURE__ */ new Map();
149
+ define(type, schema) {
150
+ if (this.schemas.has(type)) throw new Error(`Event type "${type}" is already registered`);
151
+ this.schemas.set(type, schema);
152
+ }
153
+ getSchema(type) {
154
+ return this.schemas.get(type);
155
+ }
156
+ has(type) {
157
+ return this.schemas.has(type);
158
+ }
159
+ types() {
160
+ return [...this.schemas.keys()];
161
+ }
162
+ parsePayload(type, payload) {
163
+ const schema = this.schemas.get(type);
164
+ if (!schema) throw new Error(`Unknown event type: "${type}"`);
165
+ return schema.parse(payload);
166
+ }
167
+ safeParsePayload(type, payload) {
168
+ const schema = this.schemas.get(type);
169
+ if (!schema) return {
170
+ success: false,
171
+ error: new z.ZodError([{
172
+ code: "custom",
173
+ message: `Unknown event type: "${type}"`,
174
+ path: []
175
+ }])
176
+ };
177
+ const result = schema.safeParse(payload);
178
+ if (result.success) return {
179
+ success: true,
180
+ data: result.data
181
+ };
182
+ return {
183
+ success: false,
184
+ error: result.error
185
+ };
186
+ }
187
+ };
188
+ const registry = new EventRegistry();
189
+ //#endregion
190
+ //#region src/contracts/envelope.ts
191
+ /**
192
+ * Wire format written to Redis Streams. The payload field is an opaque record
193
+ * at the envelope level — use registry.parsePayload() to get a typed payload.
194
+ */
195
+ const EventEnvelopeSchema = z.object({
196
+ id: z.string().uuid(),
197
+ type: z.string(),
198
+ timestamp: z.string().datetime(),
199
+ payload: z.record(z.string(), z.unknown()),
200
+ metadata: EventMetadataSchema
201
+ });
202
+ const StreamEventSchema = EventEnvelopeSchema;
203
+ const StreamEventMetadataSchema = EventMetadataSchema;
204
+ //#endregion
205
+ //#region src/contracts/streams.ts
206
+ const STREAMS = {
207
+ INBOUND_CRITICAL: "notifkit:stream:inbound:critical",
208
+ INBOUND_NORMAL: "notifkit:stream:inbound:normal",
209
+ INBOUND_LOW: "notifkit:stream:inbound:low",
210
+ ENRICHED_CRITICAL: "notifkit:stream:enriched:critical",
211
+ ENRICHED_NORMAL: "notifkit:stream:enriched:normal",
212
+ ENRICHED_LOW: "notifkit:stream:enriched:low",
213
+ AI_PENDING: "notifkit:stream:ai:pending",
214
+ SCHEDULED: "notifkit:stream:scheduled",
215
+ OUTBOUND_CRITICAL: "notifkit:stream:outbound:critical",
216
+ OUTBOUND_NORMAL: "notifkit:stream:outbound:normal",
217
+ OUTBOUND_LOW: "notifkit:stream:outbound:low",
218
+ DEAD_LETTER: "notifkit:stream:dlq",
219
+ WORKFLOW_INBOUND: "notifkit:stream:workflow:inbound",
220
+ EVENTS_INBOUND: "notifkit:stream:events:inbound"
221
+ };
222
+ const INBOUND_STREAMS = [
223
+ STREAMS.INBOUND_CRITICAL,
224
+ STREAMS.INBOUND_NORMAL,
225
+ STREAMS.INBOUND_LOW
226
+ ];
227
+ const ENRICHED_STREAMS = [
228
+ STREAMS.ENRICHED_CRITICAL,
229
+ STREAMS.ENRICHED_NORMAL,
230
+ STREAMS.ENRICHED_LOW
231
+ ];
232
+ const OUTBOUND_STREAMS = [
233
+ STREAMS.OUTBOUND_CRITICAL,
234
+ STREAMS.OUTBOUND_NORMAL,
235
+ STREAMS.OUTBOUND_LOW
236
+ ];
237
+ /**
238
+ * Redis pub/sub channels used to drop cached state across every process.
239
+ *
240
+ * Each cache also carries a TTL, so these only shorten the window in which a
241
+ * worker can act on stale data — they are not the sole correctness mechanism.
242
+ */
243
+ const PUBSUB_CHANNELS = {
244
+ /** Payload: `{projectId}:{templateId}`. */
245
+ TEMPLATE_INVALIDATED: "template.invalidated",
246
+ /** Payload: `{projectId}`. Published when project settings change. */
247
+ PROJECT_INVALIDATED: "project.invalidated",
248
+ /** Payload: a token hash, or `*` for the whole cache. */
249
+ API_KEY_INVALIDATED: "apikey.invalidated"
250
+ };
251
+ const CONSUMER_GROUPS = {
252
+ ENRICHER: "notifkit:group:enricher",
253
+ ENGINE: "notifkit:group:engine",
254
+ DELIVERY: "notifkit:group:delivery",
255
+ SCHEDULER: "notifkit:group:scheduler",
256
+ AI: "notifkit:group:ai",
257
+ WORKFLOW: "notifkit:group:workflow",
258
+ EVENTS: "notifkit:group:events"
259
+ };
260
+ //#endregion
261
+ //#region src/contracts/helpers.ts
262
+ function buildStreamEvent(type, payload, source, traceId) {
263
+ return {
264
+ type,
265
+ payload,
266
+ metadata: {
267
+ traceId: traceId ?? randomUUID(),
268
+ source,
269
+ retryCount: 0
270
+ }
271
+ };
272
+ }
273
+ //#endregion
274
+ //#region src/contracts/sdk.ts
275
+ const QuietHoursSchema = z.object({
276
+ start: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "expected HH:MM (24h, UTC)"),
277
+ end: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "expected HH:MM (24h, UTC)")
278
+ });
279
+ const PreferencesSchema = z.object({
280
+ channels: z.record(z.string(), z.boolean()).optional(),
281
+ topics: z.record(z.string(), z.boolean()).optional(),
282
+ quietHours: z.array(QuietHoursSchema).optional()
283
+ });
284
+ /** Channels that carry an addressable target (email address, phone, push token, url). */
285
+ const ContactChannelSchema = z.enum([
286
+ "email",
287
+ "sms",
288
+ "push",
289
+ "webhook"
290
+ ]);
291
+ /** Accept a single value or an array; always normalise to a non-empty array. */
292
+ const stringOrArray = z.union([z.string().min(1), z.array(z.string().min(1))]).transform((v) => Array.isArray(v) ? v : [v]);
293
+ /**
294
+ * addUser({ id, email, phone, pushToken, segments, preferences })
295
+ * email / phone / pushToken accept a single string or an array.
296
+ */
297
+ const AddUserSchema = z.object({
298
+ id: z.string().min(1),
299
+ language: z.string().optional(),
300
+ timezone: z.string().optional(),
301
+ email: stringOrArray.optional(),
302
+ phone: stringOrArray.optional(),
303
+ pushToken: stringOrArray.optional(),
304
+ segments: z.array(z.string().min(1)).optional(),
305
+ preferences: PreferencesSchema.optional()
306
+ });
307
+ /** updateUser(id, patch) — every field optional; id comes from the path. */
308
+ const UpdateUserSchema = z.object({
309
+ language: z.string().optional(),
310
+ timezone: z.string().optional(),
311
+ email: stringOrArray.optional(),
312
+ phone: stringOrArray.optional(),
313
+ pushToken: stringOrArray.optional(),
314
+ segments: z.array(z.string().min(1)).optional(),
315
+ preferences: PreferencesSchema.optional()
316
+ });
317
+ /** addUserContact(userId, channel, { target, preferences }) — channel carried in body. */
318
+ const AddContactSchema = z.object({
319
+ channel: ContactChannelSchema,
320
+ target: z.string().min(1),
321
+ preferences: PreferencesSchema.optional()
322
+ });
323
+ const TemplateSchema = z.object({
324
+ id: z.string().min(1),
325
+ channel: NotificationChannelSchema,
326
+ topic: z.union([z.string().min(1), z.array(z.string().min(1))]).transform((v) => Array.isArray(v) ? v : [v]).optional(),
327
+ content: z.record(z.string(), z.unknown()),
328
+ aiPrompts: z.record(z.string(), z.string()).optional()
329
+ });
330
+ const SyncTemplatesSchema = z.object({ templates: z.array(TemplateSchema).min(1) });
331
+ /** Inline user object accepted by notify({ user: {...} }). */
332
+ const InlineUserSchema = z.object({
333
+ id: z.string().min(1),
334
+ language: z.string().optional(),
335
+ timezone: z.string().optional(),
336
+ email: stringOrArray.optional(),
337
+ phone: stringOrArray.optional(),
338
+ pushToken: stringOrArray.optional(),
339
+ segments: z.array(z.string().min(1)).optional(),
340
+ preferences: PreferencesSchema.optional()
341
+ });
342
+ /**
343
+ * notify(...) request body.
344
+ *
345
+ * Exactly one of `user` / `segment` / `topic` must be provided.
346
+ */
347
+ /**
348
+ * The field set shared by `notify()` and a workflow's `notify` step. The two
349
+ * differ only in whether naming a target is mandatory, so the fields live here
350
+ * once and each schema layers its own target rule on top.
351
+ */
352
+ const NotifyRequestFields = z.object({
353
+ user: z.union([
354
+ z.string().min(1),
355
+ InlineUserSchema,
356
+ z.array(z.union([z.string().min(1), InlineUserSchema])).nonempty()
357
+ ]).optional(),
358
+ segment: z.string().min(1).optional(),
359
+ topic: z.string().min(1).optional(),
360
+ template: z.string().min(1),
361
+ data: z.record(z.string(), z.unknown()).optional(),
362
+ aiPrompts: z.record(z.string(), z.string()).optional(),
363
+ priority: NotificationPrioritySchema.optional(),
364
+ channels: z.array(NotificationChannelSchema).nonempty().optional(),
365
+ fallback: z.boolean().optional(),
366
+ sendAt: z.string().datetime().optional(),
367
+ /**
368
+ * A label grouping every message this call produces, so the send can be
369
+ * reported on later via `/v1/campaigns/:id/stats`. Free-form, but reusing one
370
+ * label across calls merges them into a single campaign — which is either
371
+ * what you want (a send split into batches) or a reporting bug.
372
+ */
373
+ campaign: z.string().min(1).max(128).optional()
374
+ });
375
+ function countTargets(val) {
376
+ return [
377
+ val.user,
378
+ val.segment,
379
+ val.topic
380
+ ].filter((t) => t !== void 0).length;
381
+ }
382
+ const NotifyRequestSchema = NotifyRequestFields.superRefine((val, ctx) => {
383
+ const targets = countTargets(val);
384
+ if (targets === 0) ctx.addIssue({
385
+ code: z.ZodIssueCode.custom,
386
+ message: "one of `user`, `segment`, or `topic` is required",
387
+ path: ["user"]
388
+ });
389
+ else if (targets > 1) ctx.addIssue({
390
+ code: z.ZodIssueCode.custom,
391
+ message: "provide exactly one of `user`, `segment`, or `topic`",
392
+ path: ["user"]
393
+ });
394
+ });
395
+ /**
396
+ * The payload of a workflow `notify` step — every field `notify()` takes.
397
+ *
398
+ * The one difference is that a target is optional here: a step naming none
399
+ * inherits the instance's own user, which is the ordinary case. Naming one
400
+ * overrides that, so a step can notify a different user, a segment, or a topic.
401
+ */
402
+ const WorkflowNotifyPayloadSchema = NotifyRequestFields.superRefine((val, ctx) => {
403
+ if (countTargets(val) > 1) ctx.addIssue({
404
+ code: z.ZodIssueCode.custom,
405
+ message: "provide at most one of `user`, `segment`, or `topic`",
406
+ path: ["user"]
407
+ });
408
+ });
409
+ const TriggerWorkflowSchema = z.object({
410
+ name: z.string().min(1),
411
+ input: z.record(z.string(), z.unknown()).optional(),
412
+ user: z.union([z.string().min(1), InlineUserSchema]).optional()
413
+ });
414
+ const IngestEventSchema = z.object({
415
+ name: z.string().min(1),
416
+ properties: z.record(z.string(), z.unknown())
417
+ });
418
+ const WorkflowStepSchema = z.discriminatedUnion("action", [
419
+ z.object({
420
+ action: z.literal("notify"),
421
+ payload: WorkflowNotifyPayloadSchema
422
+ }),
423
+ z.object({
424
+ action: z.literal("wait"),
425
+ duration: z.string()
426
+ }),
427
+ z.object({
428
+ action: z.literal("waitForEvent"),
429
+ event: z.string(),
430
+ options: z.object({ timeout: z.string().optional() }).optional()
431
+ })
432
+ ]);
433
+ const CreateWorkflowSchema = z.object({
434
+ name: z.string().min(1),
435
+ steps: z.array(WorkflowStepSchema).min(1)
436
+ });
437
+ const UpdateProjectSchema = z.object({
438
+ rateLimitRpm: z.number().nullable().optional(),
439
+ throttleLimit: z.number().nullable().optional(),
440
+ throttleWindowHours: z.number().nullable().optional()
441
+ });
442
+ //#endregion
443
+ //#region src/contracts/events/notification-requested.ts
444
+ /**
445
+ * A high-level notification request as issued by the SDK's `notify()` call.
446
+ *
447
+ * Unlike `notification.created` (which targets a single resolved recipient),
448
+ * this event carries the *unresolved* target — a user id, a segment, or a
449
+ * topic. A downstream resolver stage fans it out into one
450
+ * `notification.created` per matching recipient, applying preference filters.
451
+ */
452
+ const NotificationTargetSchema = z.discriminatedUnion("type", [
453
+ z.object({
454
+ type: z.literal("user"),
455
+ userId: z.string().min(1)
456
+ }),
457
+ z.object({
458
+ type: z.literal("segment"),
459
+ segment: z.string().min(1)
460
+ }),
461
+ z.object({
462
+ type: z.literal("topic"),
463
+ topic: z.string().min(1)
464
+ })
465
+ ]);
466
+ const NotificationRequestedPayloadSchema = z.object({
467
+ projectId: z.string().uuid(),
468
+ target: NotificationTargetSchema,
469
+ templateId: z.string().min(1),
470
+ priority: NotificationPrioritySchema.default("normal"),
471
+ channels: z.array(NotificationChannelSchema).nonempty().optional(),
472
+ data: z.record(z.string(), z.unknown()).default({}),
473
+ fallback: z.boolean().default(false),
474
+ aiPrompts: z.record(z.string(), z.string()).optional(),
475
+ scheduledAt: z.string().datetime().optional(),
476
+ idempotencyKey: z.string().optional(),
477
+ /**
478
+ * Groups every message this request fans out into, so the send can be
479
+ * reported on afterwards. Carried unchanged to the delivery log.
480
+ */
481
+ campaignId: z.string().min(1).max(128).optional()
482
+ });
483
+ //#endregion
484
+ //#region src/contracts/events/notification-created.ts
485
+ const NotificationCreatedPayloadSchema = z.object({
486
+ projectId: z.string().uuid(),
487
+ recipientId: z.string().min(1),
488
+ channel: NotificationChannelSchema,
489
+ priority: NotificationPrioritySchema.default("normal"),
490
+ templateId: z.string().min(1).optional(),
491
+ payload: z.record(z.string(), z.unknown()),
492
+ scheduledAt: z.string().datetime().optional(),
493
+ idempotencyKey: z.string().optional()
494
+ });
495
+ //#endregion
496
+ //#region src/contracts/events/notification-enriched.ts
497
+ const RecipientProfileSchema = z.object({
498
+ id: z.string(),
499
+ email: z.string().email().optional(),
500
+ phone: z.string().optional(),
501
+ webhook: z.string().url().optional(),
502
+ pushTokens: z.array(z.string()).optional(),
503
+ pushToken: z.string().optional(),
504
+ locale: z.string().default("en"),
505
+ timezone: z.string().default("UTC"),
506
+ preferences: z.object({
507
+ optedOut: z.boolean().default(false),
508
+ channels: z.array(NotificationChannelSchema).default([]),
509
+ quietHours: z.array(z.object({
510
+ start: z.string(),
511
+ end: z.string()
512
+ })).optional()
513
+ })
514
+ });
515
+ const NotificationEnrichedPayloadSchema = z.object({
516
+ projectId: z.string().uuid(),
517
+ rawEventId: z.string().uuid(),
518
+ recipientId: z.string().min(1),
519
+ channel: NotificationChannelSchema,
520
+ priority: NotificationPrioritySchema,
521
+ templateId: z.string().min(1).optional(),
522
+ templateVariables: z.record(z.string(), z.unknown()),
523
+ recipient: RecipientProfileSchema,
524
+ aiPrompts: z.record(z.string(), z.string()).optional(),
525
+ scheduledAt: z.string().datetime().optional(),
526
+ fallbackChain: z.array(NotificationChannelSchema).optional(),
527
+ /** Campaign this message belongs to, carried from the originating request. */
528
+ campaignId: z.string().min(1).max(128).optional()
529
+ });
530
+ //#endregion
531
+ //#region src/contracts/events/notification-scheduled.ts
532
+ const NotificationScheduledPayloadSchema = z.object({
533
+ projectId: z.string().uuid(),
534
+ enrichedEventId: z.string().uuid(),
535
+ taskId: z.string().min(1),
536
+ scheduledAt: z.string().datetime()
537
+ });
538
+ //#endregion
539
+ //#region src/contracts/events/notification-dispatched.ts
540
+ const RenderedContentSchema = z.object({
541
+ content: z.record(z.string(), z.unknown()),
542
+ attachments: z.array(z.object({
543
+ name: z.string(),
544
+ contentType: z.string(),
545
+ url: z.string().url()
546
+ })).optional()
547
+ });
548
+ const DeliveryOptionsSchema = z.object({
549
+ maxAttempts: z.number().int().positive().default(3),
550
+ timeoutMs: z.number().int().positive().default(1e4),
551
+ headers: z.record(z.string(), z.string()).optional()
552
+ });
553
+ const NotificationDispatchedPayloadSchema = z.object({
554
+ projectId: z.string().uuid(),
555
+ taskId: z.string().min(1),
556
+ enrichedEventId: z.string().uuid(),
557
+ recipientId: z.string().min(1),
558
+ channel: NotificationChannelSchema,
559
+ priority: NotificationPrioritySchema,
560
+ templateId: z.string().min(1).optional(),
561
+ templateVariables: z.record(z.string(), z.unknown()).default({}),
562
+ aiPrompts: z.record(z.string(), z.string()).optional(),
563
+ recipient: RecipientProfileSchema.optional(),
564
+ renderedContent: RenderedContentSchema,
565
+ destination: z.string().min(1).optional(),
566
+ deliveryOptions: DeliveryOptionsSchema,
567
+ fallbackChain: z.array(NotificationChannelSchema).optional(),
568
+ throttleAttemptCount: z.number().int().nonnegative().optional(),
569
+ /** Campaign this message belongs to, carried from the originating request. */
570
+ campaignId: z.string().min(1).max(128).optional()
571
+ });
572
+ //#endregion
573
+ //#region src/contracts/events/notification-delivered.ts
574
+ const NotificationDeliveredPayloadSchema = z.object({
575
+ projectId: z.string().uuid(),
576
+ taskId: z.string().min(1),
577
+ enrichedEventId: z.string().uuid(),
578
+ channel: NotificationChannelSchema,
579
+ deliveredAt: z.string().datetime(),
580
+ providerMessageId: z.string().optional(),
581
+ providerResponse: z.record(z.string(), z.unknown()).optional(),
582
+ templateId: z.string().uuid().optional(),
583
+ workflowInstanceId: z.string().uuid().optional(),
584
+ /** Campaign this message belongs to, carried from the originating request. */
585
+ campaignId: z.string().min(1).max(128).optional()
586
+ });
587
+ //#endregion
588
+ //#region src/contracts/events/notification-failed.ts
589
+ const NotificationFailedPayloadSchema = z.object({
590
+ projectId: z.string().uuid(),
591
+ taskId: z.string().min(1),
592
+ enrichedEventId: z.string().uuid(),
593
+ channel: NotificationChannelSchema,
594
+ failureReason: z.string(),
595
+ failureCode: z.string(),
596
+ retryable: z.boolean(),
597
+ attempt: z.number().int().positive(),
598
+ providerResponse: z.record(z.string(), z.unknown()).optional(),
599
+ templateId: z.string().uuid().optional(),
600
+ workflowInstanceId: z.string().uuid().optional(),
601
+ /** Campaign this message belongs to, carried from the originating request. */
602
+ campaignId: z.string().min(1).max(128).optional()
603
+ });
604
+ //#endregion
605
+ //#region src/contracts/events/notification-skipped.ts
606
+ const NotificationSkippedPayloadSchema = z.object({
607
+ projectId: z.string().uuid(),
608
+ eventId: z.string().uuid(),
609
+ recipientId: z.string(),
610
+ reason: z.string()
611
+ });
612
+ //#endregion
613
+ //#region src/contracts/events/notification-canceled.ts
614
+ const NotificationCanceledPayloadSchema = z.object({
615
+ projectId: z.string().uuid(),
616
+ taskId: z.string().min(1)
617
+ });
618
+ //#endregion
619
+ //#region src/contracts/events/notification-ai-pending.ts
620
+ const NotificationAiPendingPayloadSchema = z.object({
621
+ projectId: z.string().uuid(),
622
+ enrichedEventId: z.string().uuid(),
623
+ recipientId: z.string().min(1),
624
+ channel: NotificationChannelSchema,
625
+ priority: NotificationPrioritySchema,
626
+ templateId: z.string().min(1).optional(),
627
+ templateVariables: z.record(z.string(), z.unknown()),
628
+ recipient: RecipientProfileSchema,
629
+ aiPrompts: z.record(z.string(), z.string()),
630
+ scheduledAt: z.string().datetime().optional(),
631
+ fallbackChain: z.array(NotificationChannelSchema).optional()
632
+ });
633
+ //#endregion
634
+ //#region src/contracts/index.ts
635
+ registry.define("notification.requested", NotificationRequestedPayloadSchema);
636
+ registry.define("notification.created", NotificationCreatedPayloadSchema);
637
+ registry.define("notification.enriched", NotificationEnrichedPayloadSchema);
638
+ registry.define("notification.scheduled", NotificationScheduledPayloadSchema);
639
+ registry.define("notification.dispatched", NotificationDispatchedPayloadSchema);
640
+ registry.define("notification.delivered", NotificationDeliveredPayloadSchema);
641
+ registry.define("notification.failed", NotificationFailedPayloadSchema);
642
+ registry.define("notification.skipped", NotificationSkippedPayloadSchema);
643
+ registry.define("notification.canceled", NotificationCanceledPayloadSchema);
644
+ registry.define("notification.ai_pending", NotificationAiPendingPayloadSchema);
645
+ //#endregion
646
+ //#region src/db/schema.ts
647
+ var schema_exports = /* @__PURE__ */ __exportAll({
648
+ apiKeyRoleEnum: () => apiKeyRoleEnum,
649
+ channelEnum: () => channelEnum,
650
+ contactTopicPreferences: () => contactTopicPreferences,
651
+ deliveryOutbox: () => deliveryOutbox,
652
+ insertDeliveryOutboxSchema: () => insertDeliveryOutboxSchema,
653
+ insertMessageLogSchema: () => insertMessageLogSchema,
654
+ insertProjectApiKeySchema: () => insertProjectApiKeySchema,
655
+ insertProjectSchema: () => insertProjectSchema,
656
+ insertScheduledPayloadSchema: () => insertScheduledPayloadSchema,
657
+ insertSuppressionSchema: () => insertSuppressionSchema,
658
+ insertUserChannelPreferenceSchema: () => insertUserChannelPreferenceSchema,
659
+ insertUserContactSchema: () => insertUserContactSchema,
660
+ insertUserSchema: () => insertUserSchema,
661
+ insertUserSegmentSchema: () => insertUserSegmentSchema,
662
+ insertUserTopicPreferenceSchema: () => insertUserTopicPreferenceSchema,
663
+ insertWorkflowInstanceSchema: () => insertWorkflowInstanceSchema,
664
+ insertWorkflowStepSchema: () => insertWorkflowStepSchema,
665
+ insertWorkflowWaiterSchema: () => insertWorkflowWaiterSchema,
666
+ messageLogs: () => messageLogs,
667
+ projectApiKeys: () => projectApiKeys,
668
+ projects: () => projects,
669
+ quietHours: () => quietHours,
670
+ scheduledPayloads: () => scheduledPayloads,
671
+ selectDeliveryOutboxSchema: () => selectDeliveryOutboxSchema,
672
+ selectMessageLogSchema: () => selectMessageLogSchema,
673
+ selectProjectApiKeySchema: () => selectProjectApiKeySchema,
674
+ selectProjectSchema: () => selectProjectSchema,
675
+ selectScheduledPayloadSchema: () => selectScheduledPayloadSchema,
676
+ selectSuppressionSchema: () => selectSuppressionSchema,
677
+ selectUserChannelPreferenceSchema: () => selectUserChannelPreferenceSchema,
678
+ selectUserContactSchema: () => selectUserContactSchema,
679
+ selectUserSchema: () => selectUserSchema,
680
+ selectUserSegmentSchema: () => selectUserSegmentSchema,
681
+ selectUserTopicPreferenceSchema: () => selectUserTopicPreferenceSchema,
682
+ selectWorkflowInstanceSchema: () => selectWorkflowInstanceSchema,
683
+ selectWorkflowStepSchema: () => selectWorkflowStepSchema,
684
+ selectWorkflowWaiterSchema: () => selectWorkflowWaiterSchema,
685
+ suppressions: () => suppressions,
686
+ templates: () => templates,
687
+ userChannelPreferences: () => userChannelPreferences,
688
+ userContacts: () => userContacts,
689
+ userSegments: () => userSegments,
690
+ userTopicPreferences: () => userTopicPreferences,
691
+ users: () => users,
692
+ workflowDefinitions: () => workflowDefinitions,
693
+ workflowInstances: () => workflowInstances,
694
+ workflowStatusEnum: () => workflowStatusEnum,
695
+ workflowSteps: () => workflowSteps,
696
+ workflowWaiters: () => workflowWaiters
697
+ });
698
+ const channelEnum = pgEnum("channel", [
699
+ "email",
700
+ "sms",
701
+ "push",
702
+ "webhook",
703
+ "in-app"
704
+ ]);
705
+ const projects = pgTable("projects", {
706
+ id: uuid("id").primaryKey().defaultRandom(),
707
+ name: varchar("name").notNull(),
708
+ rateLimitRpm: integer("rate_limit_rpm"),
709
+ throttleLimit: integer("throttle_limit"),
710
+ throttleWindowHours: integer("throttle_window_hours"),
711
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull()
712
+ });
713
+ const users = pgTable("users", {
714
+ id: uuid("id").primaryKey().defaultRandom(),
715
+ projectId: uuid("project_id").notNull(),
716
+ externalId: text("external_id").notNull(),
717
+ attributes: jsonb("attributes").notNull().default({}),
718
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
719
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull()
720
+ }, (table) => ({ unq: unique().on(table.projectId, table.externalId) }));
721
+ const apiKeyRoleEnum = pgEnum("api_key_role", ["admin", "read_only"]);
722
+ const projectApiKeys = pgTable("project_api_keys", {
723
+ id: uuid("id").primaryKey().defaultRandom(),
724
+ projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
725
+ keyHash: varchar("key_hash").notNull().unique(),
726
+ role: apiKeyRoleEnum("role").notNull().default("admin"),
727
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull()
728
+ });
729
+ const userSegments = pgTable("user_segments", {
730
+ id: uuid("id").primaryKey().defaultRandom(),
731
+ userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
732
+ segment: varchar("segment").notNull()
733
+ }, (table) => ({
734
+ unq: unique().on(table.userId, table.segment),
735
+ segmentIdx: index("segment_idx").on(table.segment)
736
+ }));
737
+ const userContacts = pgTable("user_contacts", {
738
+ id: uuid("id").primaryKey().defaultRandom(),
739
+ userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
740
+ channel: channelEnum("channel").notNull(),
741
+ target: text("target").notNull(),
742
+ label: text("label"),
743
+ isPrimary: boolean("is_primary").notNull().default(false),
744
+ enabled: boolean("enabled").notNull().default(true),
745
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull()
746
+ }, (table) => ({ unq: unique().on(table.userId, table.channel, table.target) }));
747
+ const userChannelPreferences = pgTable("user_channel_preferences", {
748
+ userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
749
+ channel: channelEnum("channel").notNull(),
750
+ enabled: boolean("enabled").notNull()
751
+ }, (table) => ({ pk: primaryKey({ columns: [table.userId, table.channel] }) }));
752
+ const userTopicPreferences = pgTable("user_topic_preferences", {
753
+ id: uuid("id").primaryKey().defaultRandom(),
754
+ userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
755
+ topic: varchar("topic").notNull(),
756
+ enabled: boolean("enabled").notNull()
757
+ }, (table) => ({ unq: unique().on(table.userId, table.topic) }));
758
+ const contactTopicPreferences = pgTable("contact_topic_preferences", {
759
+ id: uuid("id").primaryKey().defaultRandom(),
760
+ contactId: uuid("contact_id").notNull().references(() => userContacts.id, { onDelete: "cascade" }),
761
+ topic: varchar("topic").notNull(),
762
+ enabled: boolean("enabled").notNull()
763
+ }, (table) => ({ unq: unique().on(table.contactId, table.topic) }));
764
+ const quietHours = pgTable("quiet_hours", {
765
+ id: uuid("id").primaryKey().defaultRandom(),
766
+ userId: uuid("user_id").references(() => users.id, { onDelete: "cascade" }),
767
+ contactId: uuid("contact_id").references(() => userContacts.id, { onDelete: "cascade" }),
768
+ startTime: time("start_time").notNull(),
769
+ endTime: time("end_time").notNull()
770
+ }, (table) => ({
771
+ checkOwner: check("check_owner", sql`num_nonnulls(user_id, contact_id) = 1`),
772
+ userIdIdx: index("quiet_hours_user_idx").on(table.userId)
773
+ }));
774
+ const templates = pgTable("templates", {
775
+ projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
776
+ id: varchar("id").notNull(),
777
+ channel: channelEnum("channel").notNull(),
778
+ topics: text("topics").array().notNull(),
779
+ content: jsonb("content").notNull(),
780
+ aiPrompts: jsonb("ai_prompts"),
781
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull()
782
+ }, (table) => ({ pk: primaryKey({ columns: [table.projectId, table.id] }) }));
783
+ const messageLogs = pgTable("message_logs", {
784
+ id: uuid("id").primaryKey().defaultRandom(),
785
+ projectId: uuid("project_id").notNull(),
786
+ taskId: varchar("task_id").notNull(),
787
+ providerMessageId: varchar("provider_message_id"),
788
+ templateId: varchar("template_id"),
789
+ workflowInstanceId: uuid("workflow_instance_id"),
790
+ channel: channelEnum("channel").notNull(),
791
+ attempt: integer("attempt").default(1).notNull(),
792
+ /**
793
+ * Discriminates a delivery attempt ("attempt") from a provider engagement
794
+ * event ("opened", "clicked", "bounced", …). Without it an engagement row
795
+ * collides with the delivery row for the same (task, channel, attempt).
796
+ */
797
+ kind: varchar("kind").notNull().default("attempt"),
798
+ status: varchar("status").notNull(),
799
+ /**
800
+ * Groups every message produced by one `notify()` call. Null for sends that
801
+ * did not name a campaign, which is every send made before this column
802
+ * existed — treat null as "unattributed", not as a campaign of its own.
803
+ */
804
+ campaignId: varchar("campaign_id"),
805
+ /**
806
+ * Provider-specific detail that would otherwise be discarded: the clicked
807
+ * URL on a click event, the bounce subtype on a bounce. Deliberately loose
808
+ * — every provider reports these differently.
809
+ */
810
+ metadata: jsonb("metadata"),
811
+ timestamp: timestamp("timestamp", { withTimezone: true }).defaultNow().notNull()
812
+ }, (table) => ({
813
+ projectIdx: index("message_logs_project_idx").on(table.projectId),
814
+ taskIdx: index("task_idx").on(table.taskId),
815
+ projectIdTaskIdIdx: index("message_logs_project_task_idx").on(table.projectId, table.taskId),
816
+ providerMsgIdx: index("provider_msg_idx").on(table.providerMessageId),
817
+ projectTimeIdx: index("msg_log_proj_time_idx").on(table.projectId, table.timestamp),
818
+ templateIdx: index("msg_log_template_idx").on(table.projectId, table.templateId),
819
+ workflowIdx: index("msg_log_workflow_idx").on(table.projectId, table.workflowInstanceId),
820
+ campaignIdx: index("msg_log_campaign_idx").on(table.projectId, table.campaignId),
821
+ taskChannelAttemptUidx: unique("task_channel_attempt_uidx").on(table.taskId, table.channel, table.attempt, table.kind)
822
+ }));
823
+ /**
824
+ * Addresses that must not be contacted again on a given channel.
825
+ *
826
+ * Rows are written from provider webhooks (an unsubscribe, a spam complaint, a
827
+ * hard bounce) and by hand through the API. The engine consults this table
828
+ * before dispatching, so a suppression is a hard stop rather than a preference
829
+ * — `priority: "critical"` does not override it. Removing a row is the only way
830
+ * back, and that is deliberately a manual act.
831
+ */
832
+ const suppressions = pgTable("suppressions", {
833
+ id: uuid("id").primaryKey().defaultRandom(),
834
+ projectId: uuid("project_id").notNull(),
835
+ channel: channelEnum("channel").notNull(),
836
+ /** The address itself, normalised: email is lower-cased, others stored verbatim. */
837
+ target: varchar("target").notNull(),
838
+ /** `unsubscribed` | `complained` | `bounced` | `manual`. */
839
+ reason: varchar("reason").notNull(),
840
+ /** Where it came from — a provider name, or `api` for a manual entry. */
841
+ source: varchar("source"),
842
+ /** The delivery that triggered it, when a provider webhook is the origin. */
843
+ taskId: varchar("task_id"),
844
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull()
845
+ }, (table) => ({
846
+ projectChannelTargetUidx: unique("suppression_project_channel_target_uidx").on(table.projectId, table.channel, table.target),
847
+ projectIdx: index("suppression_project_idx").on(table.projectId),
848
+ lookupIdx: index("suppression_lookup_idx").on(table.projectId, table.channel, table.target)
849
+ }));
850
+ const workflowStatusEnum = pgEnum("workflow_status", [
851
+ "pending",
852
+ "running",
853
+ "completed",
854
+ "failed"
855
+ ]);
856
+ const workflowDefinitions = pgTable("workflow_definitions", {
857
+ id: uuid("id").primaryKey().defaultRandom(),
858
+ projectId: uuid("project_id").notNull(),
859
+ name: varchar("name").notNull(),
860
+ steps: jsonb("steps").notNull(),
861
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull()
862
+ }, (table) => ({ nameUnq: unique().on(table.projectId, table.name) }));
863
+ const workflowInstances = pgTable("workflow_instances", {
864
+ id: uuid("id").primaryKey().defaultRandom(),
865
+ projectId: uuid("project_id").notNull(),
866
+ name: varchar("name").notNull(),
867
+ status: workflowStatusEnum("status").notNull().default("pending"),
868
+ input: jsonb("input").default({}),
869
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
870
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull()
871
+ }, (table) => ({ nameIdx: index("workflow_name_idx").on(table.name) }));
872
+ const workflowSteps = pgTable("workflow_steps", {
873
+ id: uuid("id").primaryKey().defaultRandom(),
874
+ projectId: uuid("project_id").notNull(),
875
+ instanceId: uuid("instance_id").notNull().references(() => workflowInstances.id, { onDelete: "cascade" }),
876
+ stepIndex: varchar("step_index").notNull(),
877
+ action: varchar("action").notNull(),
878
+ output: jsonb("output"),
879
+ error: text("error"),
880
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull()
881
+ }, (table) => ({ unq: unique().on(table.instanceId, table.stepIndex) }));
882
+ const workflowWaiters = pgTable("workflow_waiters", {
883
+ id: uuid("id").primaryKey().defaultRandom(),
884
+ projectId: uuid("project_id").notNull(),
885
+ instanceId: uuid("instance_id").notNull().references(() => workflowInstances.id, { onDelete: "cascade" }),
886
+ eventName: varchar("event_name").notNull(),
887
+ matchCriteria: jsonb("match_criteria").notNull(),
888
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
889
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull()
890
+ }, (table) => ({
891
+ eventIdx: index("waiter_event_idx").on(table.eventName),
892
+ instanceIdx: index("waiter_instance_idx").on(table.instanceId),
893
+ compositeWaitIdx: index("waiter_comp_idx").on(table.eventName, table.projectId, table.expiresAt),
894
+ matchCriteriaIdx: index("waiter_match_idx").using("gin", table.matchCriteria)
895
+ }));
896
+ const deliveryOutbox = pgTable("delivery_outbox", {
897
+ taskId: varchar("task_id").notNull(),
898
+ channel: channelEnum("channel").notNull(),
899
+ destination: text("destination").notNull(),
900
+ providerMessageId: varchar("provider_message_id"),
901
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull()
902
+ }, (table) => ({ pk: primaryKey({ columns: [
903
+ table.taskId,
904
+ table.channel,
905
+ table.destination
906
+ ] }) }));
907
+ const scheduledPayloads = pgTable("scheduled_payloads", {
908
+ taskId: varchar("task_id").primaryKey(),
909
+ payload: jsonb("payload").notNull(),
910
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull()
911
+ });
912
+ const insertProjectSchema = createInsertSchema(projects);
913
+ const selectProjectSchema = createSelectSchema(projects);
914
+ const insertProjectApiKeySchema = createInsertSchema(projectApiKeys);
915
+ const selectProjectApiKeySchema = createSelectSchema(projectApiKeys);
916
+ const insertUserSchema = createInsertSchema(users);
917
+ const selectUserSchema = createSelectSchema(users);
918
+ const insertUserSegmentSchema = createInsertSchema(userSegments);
919
+ const selectUserSegmentSchema = createSelectSchema(userSegments);
920
+ const insertUserContactSchema = createInsertSchema(userContacts);
921
+ const selectUserContactSchema = createSelectSchema(userContacts);
922
+ const insertUserChannelPreferenceSchema = createInsertSchema(userChannelPreferences);
923
+ const selectUserChannelPreferenceSchema = createSelectSchema(userChannelPreferences);
924
+ const insertUserTopicPreferenceSchema = createInsertSchema(userTopicPreferences);
925
+ const selectUserTopicPreferenceSchema = createSelectSchema(userTopicPreferences);
926
+ const insertMessageLogSchema = createInsertSchema(messageLogs);
927
+ const selectMessageLogSchema = createSelectSchema(messageLogs);
928
+ const insertSuppressionSchema = createInsertSchema(suppressions);
929
+ const selectSuppressionSchema = createSelectSchema(suppressions);
930
+ const insertWorkflowInstanceSchema = createInsertSchema(workflowInstances);
931
+ const selectWorkflowInstanceSchema = createSelectSchema(workflowInstances);
932
+ const insertWorkflowStepSchema = createInsertSchema(workflowSteps);
933
+ const selectWorkflowStepSchema = createSelectSchema(workflowSteps);
934
+ const insertWorkflowWaiterSchema = createInsertSchema(workflowWaiters);
935
+ const selectWorkflowWaiterSchema = createSelectSchema(workflowWaiters);
936
+ const insertDeliveryOutboxSchema = createInsertSchema(deliveryOutbox);
937
+ const selectDeliveryOutboxSchema = createSelectSchema(deliveryOutbox);
938
+ const insertScheduledPayloadSchema = createInsertSchema(scheduledPayloads);
939
+ const selectScheduledPayloadSchema = createSelectSchema(scheduledPayloads);
940
+ //#endregion
941
+ //#region src/db/index.ts
942
+ var db_exports = /* @__PURE__ */ __exportAll({
943
+ createDatabase: () => createDatabase,
944
+ runMigrations: () => runMigrations
945
+ });
946
+ /**
947
+ * Create a postgres.js connection pool and initialize Drizzle ORM.
948
+ * Call once at application startup; pass db into repositories.
949
+ * Call sql.end() during graceful shutdown.
950
+ */
951
+ function createDatabase({ url, applicationName = "notifkit", maxConnections, idleTimeoutSeconds = 30, logger }) {
952
+ const finalMaxConnections = maxConnections ?? readBaseConfig().DB_MAX_CONNECTIONS;
953
+ const sql = postgres(url, {
954
+ max: finalMaxConnections,
955
+ idle_timeout: idleTimeoutSeconds,
956
+ connection: {
957
+ application_name: applicationName,
958
+ statement_timeout: 1e4
959
+ },
960
+ onnotice: (notice) => {
961
+ logger?.debug({ notice }, "postgres notice");
962
+ }
963
+ });
964
+ return {
965
+ sql,
966
+ db: drizzle(sql, { schema: schema_exports })
967
+ };
968
+ }
969
+ async function runMigrations(db) {
970
+ const __filename = fileURLToPath(import.meta.url);
971
+ const __dirname = path.dirname(__filename);
972
+ let migrationsFolder = path.resolve(__dirname, "../../drizzle");
973
+ if (!fs.existsSync(migrationsFolder)) migrationsFolder = path.resolve(__dirname, "../drizzle");
974
+ await migrate(db, { migrationsFolder });
975
+ }
976
+ //#endregion
977
+ //#region src/idempotency/index.ts
978
+ /**
979
+ * SETNX-based idempotency guard.
980
+ * Returns true from checkAndMark() only the first time a given ID is seen
981
+ * within the TTL window; subsequent calls return false (duplicate / retry).
982
+ */
983
+ var IdempotencyGuard = class {
984
+ redis;
985
+ keyPrefix;
986
+ ttlSeconds;
987
+ constructor({ redis, keyPrefix, ttlSeconds = 86400 }) {
988
+ this.redis = redis;
989
+ this.keyPrefix = keyPrefix;
990
+ this.ttlSeconds = ttlSeconds;
991
+ }
992
+ key(id) {
993
+ return `${this.keyPrefix}:${id}`;
994
+ }
995
+ /** Atomically mark id as processed. Returns true on first call; false if already seen. */
996
+ async checkAndMark(id, customTtlSeconds) {
997
+ const ttl = customTtlSeconds ?? this.ttlSeconds;
998
+ return await this.redis.set(this.key(id), "1", "EX", ttl, "NX") === "OK";
999
+ }
1000
+ /** Unconditionally mark id as processed (e.g. to upgrade a short-lived lock). */
1001
+ async markProcessed(id, customTtlSeconds) {
1002
+ const ttl = customTtlSeconds ?? this.ttlSeconds;
1003
+ await this.redis.set(this.key(id), "1", "EX", ttl);
1004
+ }
1005
+ async isProcessed(id) {
1006
+ return await this.redis.get(this.key(id)) !== null;
1007
+ }
1008
+ /** Remove the idempotency marker (useful in tests or manual rollbacks). */
1009
+ async unmark(id) {
1010
+ await this.redis.del(this.key(id));
1011
+ }
1012
+ };
1013
+ //#endregion
1014
+ //#region src/logger/index.ts
1015
+ function createLogger({ name, level = "info", pretty, context }) {
1016
+ const usePretty = pretty ?? process.env["NODE_ENV"] !== "production";
1017
+ const options = {
1018
+ name,
1019
+ level,
1020
+ formatters: { level(label) {
1021
+ return { level: label };
1022
+ } },
1023
+ timestamp: pino.stdTimeFunctions.isoTime,
1024
+ serializers: {
1025
+ err: pino.stdSerializers.err,
1026
+ error: pino.stdSerializers.err,
1027
+ req: pino.stdSerializers.req,
1028
+ res: pino.stdSerializers.res
1029
+ },
1030
+ base: {
1031
+ service: name,
1032
+ ...context
1033
+ }
1034
+ };
1035
+ if (usePretty) options.transport = {
1036
+ target: "pino-pretty",
1037
+ options: {
1038
+ colorize: true,
1039
+ translateTime: "SYS:standard",
1040
+ ignore: "pid,hostname",
1041
+ messageFormat: "{service} | {msg}"
1042
+ }
1043
+ };
1044
+ return pino(options);
1045
+ }
1046
+ function withRequestId(logger, requestId) {
1047
+ return logger.child({ requestId });
1048
+ }
1049
+ function withContext(logger, context) {
1050
+ return logger.child(context);
1051
+ }
1052
+ function childLogger(logger, bindings) {
1053
+ return logger.child(bindings);
1054
+ }
1055
+ //#endregion
1056
+ //#region src/metrics/index.ts
1057
+ const register = new promClient.Registry();
1058
+ promClient.collectDefaultMetrics({ register });
1059
+ const metrics = {
1060
+ messagesPublished: new promClient.Counter({
1061
+ name: "notifkit_messages_published_total",
1062
+ help: "Total messages published to inbound streams",
1063
+ labelNames: ["channel", "priority"],
1064
+ registers: [register]
1065
+ }),
1066
+ messagesProcessed: new promClient.Counter({
1067
+ name: "notifkit_messages_processed_total",
1068
+ help: "Total messages processed by workers",
1069
+ labelNames: ["worker", "status"],
1070
+ registers: [register]
1071
+ }),
1072
+ deliverySuccess: new promClient.Counter({
1073
+ name: "notifkit_delivery_success_total",
1074
+ help: "Total successful deliveries",
1075
+ labelNames: ["channel"],
1076
+ registers: [register]
1077
+ }),
1078
+ deliveryFailed: new promClient.Counter({
1079
+ name: "notifkit_delivery_failed_total",
1080
+ help: "Total failed deliveries",
1081
+ labelNames: ["channel", "reason"],
1082
+ registers: [register]
1083
+ }),
1084
+ workerActiveTasks: new promClient.Gauge({
1085
+ name: "notifkit_worker_active_tasks",
1086
+ help: "Number of currently active tasks per worker",
1087
+ labelNames: ["worker"],
1088
+ registers: [register]
1089
+ }),
1090
+ queueSize: new promClient.Gauge({
1091
+ name: "notifkit_queue_size",
1092
+ help: "Current size of streams",
1093
+ labelNames: ["stream"],
1094
+ registers: [register]
1095
+ }),
1096
+ pendingAcks: new promClient.Gauge({
1097
+ name: "notifkit_pending_acks",
1098
+ help: "Number of pending un-acked messages per group",
1099
+ labelNames: ["group"],
1100
+ registers: [register]
1101
+ })
1102
+ };
1103
+ function getMetricsRegistry() {
1104
+ return register;
1105
+ }
1106
+ //#endregion
1107
+ //#region src/queue/index.ts
1108
+ function parseMessage(id, fields, logger) {
1109
+ if (!fields) return null;
1110
+ const dataIndex = fields.indexOf("data");
1111
+ if (dataIndex === -1) return null;
1112
+ const raw = fields[dataIndex + 1];
1113
+ if (!raw) return null;
1114
+ let decoded;
1115
+ try {
1116
+ decoded = JSON.parse(raw);
1117
+ } catch (err) {
1118
+ logger?.warn({
1119
+ id,
1120
+ err
1121
+ }, "failed to parse stream event JSON");
1122
+ return null;
1123
+ }
1124
+ const parsed = StreamEventSchema.safeParse(decoded);
1125
+ if (!parsed.success) {
1126
+ logger?.warn({
1127
+ id,
1128
+ error: parsed.error.issues
1129
+ }, "failed to parse stream event");
1130
+ return null;
1131
+ }
1132
+ return {
1133
+ id,
1134
+ event: parsed.data
1135
+ };
1136
+ }
1137
+ var StreamProducer = class {
1138
+ redis;
1139
+ stream;
1140
+ logger;
1141
+ maxLen;
1142
+ constructor({ redis, stream, logger, maxLen }) {
1143
+ this.redis = redis;
1144
+ this.stream = stream;
1145
+ this.logger = logger;
1146
+ this.maxLen = maxLen ?? readBaseConfig().QUEUE_MAX_LEN;
1147
+ }
1148
+ async publish(partial) {
1149
+ const event = {
1150
+ ...partial,
1151
+ id: crypto.randomUUID(),
1152
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1153
+ };
1154
+ const messageId = await this.redis.xadd(this.stream, "MAXLEN", "~", String(this.maxLen), "*", "data", JSON.stringify(event));
1155
+ if (!messageId) throw new Error(`XADD to ${this.stream} returned null`);
1156
+ this.logger?.debug({
1157
+ stream: this.stream,
1158
+ messageId,
1159
+ eventType: event.type,
1160
+ eventId: event.id
1161
+ }, "event published");
1162
+ return messageId;
1163
+ }
1164
+ async monitorMaxLen(stream) {
1165
+ if (Math.random() < .05) try {
1166
+ const len = await this.redis.xlen(stream);
1167
+ metrics.queueSize.set({ stream }, len);
1168
+ if (len > this.maxLen * .8) this.logger?.warn({
1169
+ stream,
1170
+ len,
1171
+ maxLen: this.maxLen
1172
+ }, "stream is nearing MAXLEN limit (80%+)");
1173
+ const dlqLen = await this.redis.xlen(STREAMS.DEAD_LETTER);
1174
+ metrics.queueSize.set({ stream: STREAMS.DEAD_LETTER }, dlqLen);
1175
+ } catch (err) {
1176
+ this.logger?.debug({ err }, "failed to monitor stream length");
1177
+ }
1178
+ }
1179
+ async publishBatch(partials) {
1180
+ if (partials.length === 0) return {
1181
+ messageIds: [],
1182
+ eventIds: []
1183
+ };
1184
+ const pipeline = this.redis.pipeline();
1185
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
1186
+ const eventIds = [];
1187
+ for (const partial of partials) {
1188
+ const id = crypto.randomUUID();
1189
+ eventIds.push(id);
1190
+ const event = {
1191
+ ...partial,
1192
+ id,
1193
+ timestamp
1194
+ };
1195
+ pipeline.xadd(this.stream, "MAXLEN", "~", String(this.maxLen), "*", "data", JSON.stringify(event));
1196
+ }
1197
+ const results = await pipeline.exec();
1198
+ if (!results) throw new Error(`Pipeline execution failed for ${this.stream}`);
1199
+ const messageIds = [];
1200
+ for (let i = 0; i < results.length; i++) {
1201
+ const result = results[i];
1202
+ if (!result) throw new Error("Pipeline result is undefined");
1203
+ const [err, msgId] = result;
1204
+ if (err) throw err;
1205
+ messageIds.push(msgId);
1206
+ }
1207
+ this.logger?.debug({
1208
+ stream: this.stream,
1209
+ count: partials.length
1210
+ }, "batch events published");
1211
+ this.monitorMaxLen(this.stream).catch(() => {});
1212
+ return {
1213
+ messageIds,
1214
+ eventIds
1215
+ };
1216
+ }
1217
+ };
1218
+ var StreamConsumer = class {
1219
+ redis;
1220
+ blockingRedis;
1221
+ streams;
1222
+ group;
1223
+ consumer;
1224
+ dlqStream;
1225
+ logger;
1226
+ batchSize;
1227
+ blockMs;
1228
+ running = false;
1229
+ constructor({ redis, stream, group, consumer, dlqStream, logger, batchSize = 10, blockMs = 5e3 }) {
1230
+ this.redis = redis;
1231
+ this.blockingRedis = redis.duplicate();
1232
+ this.streams = Array.isArray(stream) ? stream : [stream];
1233
+ this.group = group;
1234
+ this.consumer = consumer;
1235
+ this.dlqStream = dlqStream;
1236
+ this.logger = logger;
1237
+ this.batchSize = batchSize;
1238
+ this.blockMs = blockMs;
1239
+ }
1240
+ async ensureGroup() {
1241
+ for (const s of this.streams) try {
1242
+ await this.redis.xgroup("CREATE", s, this.group, "0", "MKSTREAM");
1243
+ this.logger?.info({
1244
+ stream: s,
1245
+ group: this.group
1246
+ }, "consumer group created");
1247
+ } catch (err) {
1248
+ if (err instanceof Error && err.message.includes("BUSYGROUP")) {
1249
+ this.logger?.debug({
1250
+ stream: s,
1251
+ group: this.group
1252
+ }, "consumer group already exists");
1253
+ continue;
1254
+ }
1255
+ throw err;
1256
+ }
1257
+ }
1258
+ async *readBatch() {
1259
+ this.running = true;
1260
+ let retryDelay = 1e3;
1261
+ while (this.running) try {
1262
+ let currentStreams = [...this.streams];
1263
+ if (currentStreams.length > 1 && Math.random() < .1) {
1264
+ const offset = Math.floor(Math.random() * (currentStreams.length - 1)) + 1;
1265
+ for (let i = 0; i < offset; i++) currentStreams.push(currentStreams.shift());
1266
+ }
1267
+ let results;
1268
+ let deadConnectionTimer;
1269
+ try {
1270
+ results = await Promise.race([this.blockingRedis.xreadgroup("GROUP", this.group, this.consumer, "COUNT", String(this.batchSize), "BLOCK", String(this.blockMs), "STREAMS", ...currentStreams, ...currentStreams.map(() => ">")), new Promise((_, reject) => {
1271
+ deadConnectionTimer = setTimeout(() => reject(/* @__PURE__ */ new Error("XREADGROUP_TIMEOUT_DEAD_CONNECTION")), this.blockMs + 5e3);
1272
+ })]);
1273
+ } catch (err) {
1274
+ if (err.message === "XREADGROUP_TIMEOUT_DEAD_CONNECTION") {
1275
+ this.logger?.warn("XREADGROUP took too long, assuming dead connection. Disconnecting...");
1276
+ this.blockingRedis.disconnect();
1277
+ throw err;
1278
+ }
1279
+ throw err;
1280
+ } finally {
1281
+ clearTimeout(deadConnectionTimer);
1282
+ }
1283
+ retryDelay = 1e3;
1284
+ if (!results) continue;
1285
+ const batch = [];
1286
+ for (const [streamName, messages] of results) for (const [id, fields] of messages) {
1287
+ const msg = parseMessage(id, fields, this.logger);
1288
+ if (!msg) {
1289
+ await this.redis.xack(streamName, this.group, id);
1290
+ continue;
1291
+ }
1292
+ msg.stream = streamName;
1293
+ batch.push(msg);
1294
+ }
1295
+ if (batch.length > 0) yield batch;
1296
+ } catch (err) {
1297
+ if (!this.running && err instanceof Error && err?.message?.toLowerCase?.().includes("connection is closed")) break;
1298
+ this.logger?.error({ err }, "error reading from stream");
1299
+ await new Promise((resolve) => setTimeout(resolve, retryDelay));
1300
+ retryDelay = Math.min(retryDelay * 2, 3e4);
1301
+ }
1302
+ }
1303
+ async ack(messageId, stream) {
1304
+ const s = stream ?? this.streams[0];
1305
+ const ids = Array.isArray(messageId) ? messageId : [messageId];
1306
+ if (ids.length === 0) return;
1307
+ await this.redis.xack(s, this.group, ...ids);
1308
+ this.logger?.debug({
1309
+ stream: s,
1310
+ count: ids.length
1311
+ }, "messages acknowledged");
1312
+ }
1313
+ async nack(messageId, event, stream) {
1314
+ const s = stream ?? this.streams[0];
1315
+ if (this.dlqStream) {
1316
+ const dlqId = await this.redis.xadd(this.dlqStream, "*", "data", JSON.stringify({
1317
+ ...event,
1318
+ dlq: {
1319
+ originalStream: s,
1320
+ ackedAt: (/* @__PURE__ */ new Date()).toISOString()
1321
+ }
1322
+ }));
1323
+ if (!dlqId) throw new Error(`XADD to dead-letter stream ${this.dlqStream} returned null`);
1324
+ await this.redis.xack(s, this.group, messageId);
1325
+ this.logger?.warn({
1326
+ stream: s,
1327
+ dlqStream: this.dlqStream,
1328
+ messageId,
1329
+ eventId: event.id,
1330
+ dlqId
1331
+ }, "message moved to dead-letter queue and acked");
1332
+ } else await this.ack(messageId, s);
1333
+ }
1334
+ async stop() {
1335
+ this.running = false;
1336
+ try {
1337
+ await this.blockingRedis.quit();
1338
+ } catch (err) {
1339
+ if (!err?.message?.toLowerCase?.().includes("connection is closed")) throw err;
1340
+ }
1341
+ }
1342
+ };
1343
+ var PendingMessageScanner = class {
1344
+ redis;
1345
+ streams;
1346
+ group;
1347
+ consumer;
1348
+ logger;
1349
+ constructor({ redis, stream, group, consumer, logger }) {
1350
+ this.redis = redis;
1351
+ this.streams = Array.isArray(stream) ? stream : [stream];
1352
+ this.group = group;
1353
+ this.consumer = consumer;
1354
+ this.logger = logger;
1355
+ }
1356
+ async getPendingCount() {
1357
+ let total = 0;
1358
+ for (const s of this.streams) {
1359
+ const summary = await this.redis.xpending(s, this.group);
1360
+ if (Array.isArray(summary) && summary.length > 0) {
1361
+ const count = summary[0];
1362
+ if (typeof count === "number") total += count;
1363
+ }
1364
+ }
1365
+ return total;
1366
+ }
1367
+ /** Pending entries for one stream, or across all of them when `stream` is omitted. */
1368
+ async getPendingEntries(limit = 100, stream) {
1369
+ const streams = stream ? [stream] : this.streams;
1370
+ const allEntries = [];
1371
+ for (const s of streams) {
1372
+ const result = await this.redis.xpending(s, this.group, "-", "+", limit);
1373
+ if (Array.isArray(result)) {
1374
+ for (const item of result) if (Array.isArray(item)) allEntries.push({
1375
+ id: item[0],
1376
+ consumer: item[1],
1377
+ idleMs: item[2],
1378
+ deliveryCount: item[3],
1379
+ stream: s
1380
+ });
1381
+ }
1382
+ if (allEntries.length >= limit) break;
1383
+ }
1384
+ return allEntries.slice(0, limit);
1385
+ }
1386
+ async autoclaim(minIdleMs, limit = 10) {
1387
+ const recovered = [];
1388
+ for (const s of this.streams) {
1389
+ if (recovered.length >= limit) break;
1390
+ const toClaim = (await this.getPendingEntries(limit * 2, s)).filter((p) => p.idleMs > minIdleMs * Math.pow(2, p.deliveryCount - 1)).slice(0, limit - recovered.length);
1391
+ if (toClaim.length === 0) continue;
1392
+ const ids = toClaim.map((p) => p.id);
1393
+ const result = await this.redis.xclaim(s, this.group, this.consumer, minIdleMs, ...ids);
1394
+ for (const raw of result) {
1395
+ if (!raw) continue;
1396
+ const [id, fields] = raw;
1397
+ const msg = parseMessage(id, fields, this.logger);
1398
+ if (msg) {
1399
+ msg.stream = s;
1400
+ recovered.push(msg);
1401
+ }
1402
+ }
1403
+ }
1404
+ if (recovered.length > 0) this.logger?.info({
1405
+ group: this.group,
1406
+ count: recovered.length
1407
+ }, "autoclaimed pending messages");
1408
+ return recovered;
1409
+ }
1410
+ };
1411
+ //#endregion
1412
+ //#region src/shared/events.ts
1413
+ const globalEmitter = new EventEmitter();
1414
+ //#endregion
1415
+ //#region src/shared/cache.ts
1416
+ var LRUCache = class {
1417
+ cache = /* @__PURE__ */ new Map();
1418
+ maxSize;
1419
+ defaultTtlMs;
1420
+ constructor(maxSize = 1e3, defaultTtlMs = 3e5) {
1421
+ this.maxSize = maxSize;
1422
+ this.defaultTtlMs = defaultTtlMs;
1423
+ }
1424
+ get(key) {
1425
+ const item = this.cache.get(key);
1426
+ if (!item) return void 0;
1427
+ if (Date.now() > item.expiresAt) {
1428
+ this.cache.delete(key);
1429
+ return;
1430
+ }
1431
+ this.cache.delete(key);
1432
+ this.cache.set(key, item);
1433
+ return item.value;
1434
+ }
1435
+ set(key, value, ttlMs = this.defaultTtlMs) {
1436
+ if (this.cache.has(key)) this.cache.delete(key);
1437
+ else if (this.cache.size >= this.maxSize) {
1438
+ const oldestKey = this.cache.keys().next().value;
1439
+ if (oldestKey !== void 0) this.cache.delete(oldestKey);
1440
+ }
1441
+ this.cache.set(key, {
1442
+ value,
1443
+ expiresAt: Date.now() + ttlMs
1444
+ });
1445
+ }
1446
+ delete(key) {
1447
+ this.cache.delete(key);
1448
+ }
1449
+ clear() {
1450
+ this.cache.clear();
1451
+ }
1452
+ };
1453
+ //#endregion
1454
+ //#region src/shared/utils.ts
1455
+ function getPriorityBucket(priority) {
1456
+ const p = priority || "normal";
1457
+ return p === "critical" || p === "high" ? "critical" : p === "low" ? "low" : "normal";
1458
+ }
1459
+ /**
1460
+ * Canonical form of a destination, for suppression lookups.
1461
+ *
1462
+ * Both the writer (the provider webhook) and the reader (the engine's
1463
+ * pre-dispatch gate) must agree on this, or an unsubscribe recorded as
1464
+ * `Bob@Example.com` will not match a send addressed to `bob@example.com` and
1465
+ * the person keeps receiving mail. Case folding is safe for email domains and
1466
+ * for the local part in every mailbox provider in practice; phone numbers and
1467
+ * push tokens are case-sensitive and are only trimmed.
1468
+ */
1469
+ function normaliseTarget(target) {
1470
+ const trimmed = target.trim();
1471
+ return trimmed.includes("@") ? trimmed.toLowerCase() : trimmed;
1472
+ }
1473
+ const LUA_SCHEDULER_POLL = `
1474
+ local key = KEYS[1]
1475
+ local maxScore = tonumber(ARGV[1])
1476
+ local limit = tonumber(ARGV[2])
1477
+ local visibilityTimeout = tonumber(ARGV[3]) or 0
1478
+ local tasks = redis.call('ZRANGE', key, 0, maxScore, 'BYSCORE', 'LIMIT', 0, limit)
1479
+ if #tasks > 0 then
1480
+ for i, task in ipairs(tasks) do
1481
+ redis.call('ZADD', key, maxScore + visibilityTimeout, task)
1482
+ end
1483
+ end
1484
+ return tasks
1485
+ `;
1486
+ const LUA_SCHEDULER_CLAIM = `
1487
+ local payloadKey = KEYS[1]
1488
+ local claimedKey = KEYS[2]
1489
+
1490
+ if redis.call('EXISTS', payloadKey) == 1 then
1491
+ redis.call('RENAME', payloadKey, claimedKey)
1492
+ return redis.call('GET', claimedKey)
1493
+ elseif redis.call('EXISTS', claimedKey) == 1 then
1494
+ return redis.call('GET', claimedKey)
1495
+ else
1496
+ return nil
1497
+ end
1498
+ `;
1499
+ /** Release a lock only if we still hold it (value matches our token). */
1500
+ const LUA_RELEASE_LOCK = `
1501
+ if redis.call('GET', KEYS[1]) == ARGV[1] then
1502
+ return redis.call('DEL', KEYS[1])
1503
+ end
1504
+ return 0
1505
+ `;
1506
+ /** Extend a lock's TTL only if we still hold it. */
1507
+ const LUA_RENEW_LOCK = `
1508
+ if redis.call('GET', KEYS[1]) == ARGV[1] then
1509
+ return redis.call('EXPIRE', KEYS[1], ARGV[2])
1510
+ end
1511
+ return 0
1512
+ `;
1513
+ //#endregion
1514
+ //#region src/shared/semaphore.ts
1515
+ var AsyncSemaphore = class {
1516
+ max;
1517
+ count = 0;
1518
+ queue = [];
1519
+ constructor(max) {
1520
+ this.max = max;
1521
+ }
1522
+ async acquire() {
1523
+ if (this.count < this.max) {
1524
+ this.count++;
1525
+ return Promise.resolve();
1526
+ }
1527
+ return new Promise((resolve) => {
1528
+ this.queue.push(resolve);
1529
+ });
1530
+ }
1531
+ release() {
1532
+ if (this.queue.length > 0) this.queue.shift()();
1533
+ else if (this.count > 0) this.count--;
1534
+ }
1535
+ get activeCount() {
1536
+ return this.count;
1537
+ }
1538
+ };
1539
+ //#endregion
1540
+ //#region src/shared/batch-processor.ts
1541
+ var BatchProcessor = class {
1542
+ maxSize;
1543
+ maxWaitMs;
1544
+ flushFn;
1545
+ buffer = [];
1546
+ timer = null;
1547
+ isFlushing = false;
1548
+ constructor(maxSize, maxWaitMs, flushFn) {
1549
+ this.maxSize = maxSize;
1550
+ this.maxWaitMs = maxWaitMs;
1551
+ this.flushFn = flushFn;
1552
+ }
1553
+ add(item) {
1554
+ return new Promise((resolve, reject) => {
1555
+ this.buffer.push({
1556
+ item,
1557
+ resolve,
1558
+ reject
1559
+ });
1560
+ if (this.buffer.length >= this.maxSize && !this.isFlushing) {
1561
+ if (this.timer) {
1562
+ clearTimeout(this.timer);
1563
+ this.timer = null;
1564
+ }
1565
+ this.flush();
1566
+ } else if (!this.timer && !this.isFlushing) this.timer = setTimeout(() => {
1567
+ this.timer = null;
1568
+ this.flush();
1569
+ }, this.maxWaitMs);
1570
+ });
1571
+ }
1572
+ async flush() {
1573
+ if (this.isFlushing || this.buffer.length === 0) return;
1574
+ this.isFlushing = true;
1575
+ const batch = this.buffer;
1576
+ this.buffer = [];
1577
+ try {
1578
+ const items = batch.map((b) => b.item);
1579
+ const results = await this.flushFn(items);
1580
+ for (let i = 0; i < batch.length; i++) batch[i].resolve(results[i]);
1581
+ } catch (err) {
1582
+ for (const b of batch) b.reject(err);
1583
+ } finally {
1584
+ this.isFlushing = false;
1585
+ if (this.buffer.length > 0 && !this.timer) if (this.buffer.length >= this.maxSize) this.flush();
1586
+ else this.timer = setTimeout(() => {
1587
+ this.timer = null;
1588
+ this.flush();
1589
+ }, this.maxWaitMs);
1590
+ }
1591
+ }
1592
+ };
1593
+ //#endregion
1594
+ //#region src/shared/circuit-breaker.ts
1595
+ var CircuitBreaker = class {
1596
+ state = "CLOSED";
1597
+ failures = 0;
1598
+ nextAttemptAt = 0;
1599
+ /** True while one caller is testing whether the dependency has recovered. */
1600
+ probeInFlight = false;
1601
+ threshold;
1602
+ timeout;
1603
+ constructor(options) {
1604
+ this.threshold = options.failureThreshold;
1605
+ this.timeout = options.resetTimeoutMs;
1606
+ }
1607
+ async execute(action) {
1608
+ let isProbe = false;
1609
+ if (this.state === "OPEN") if (Date.now() > this.nextAttemptAt && !this.probeInFlight) {
1610
+ this.state = "HALF_OPEN";
1611
+ this.probeInFlight = true;
1612
+ isProbe = true;
1613
+ } else throw new Error("Circuit breaker is OPEN");
1614
+ else if (this.state === "HALF_OPEN") {
1615
+ if (this.probeInFlight) throw new Error("Circuit breaker is OPEN");
1616
+ this.probeInFlight = true;
1617
+ isProbe = true;
1618
+ }
1619
+ try {
1620
+ const result = await action();
1621
+ this.onSuccess();
1622
+ return result;
1623
+ } catch (err) {
1624
+ this.onFailure();
1625
+ throw err;
1626
+ } finally {
1627
+ if (isProbe) this.probeInFlight = false;
1628
+ }
1629
+ }
1630
+ onSuccess() {
1631
+ this.failures = 0;
1632
+ this.state = "CLOSED";
1633
+ }
1634
+ onFailure() {
1635
+ this.failures++;
1636
+ if (this.failures >= this.threshold) {
1637
+ this.state = "OPEN";
1638
+ this.nextAttemptAt = Date.now() + this.timeout;
1639
+ }
1640
+ }
1641
+ getState() {
1642
+ return this.state;
1643
+ }
1644
+ };
1645
+ //#endregion
1646
+ //#region src/shared/dataloader.ts
1647
+ var DataLoader = class {
1648
+ batchLoadFn;
1649
+ keys = [];
1650
+ promises = [];
1651
+ currentTick = null;
1652
+ constructor(batchLoadFn) {
1653
+ this.batchLoadFn = batchLoadFn;
1654
+ }
1655
+ load(key) {
1656
+ return new Promise((resolve, reject) => {
1657
+ this.keys.push(key);
1658
+ this.promises.push({ resolve: (value) => {
1659
+ if (value instanceof Error) reject(value);
1660
+ else resolve(value);
1661
+ } });
1662
+ if (!this.currentTick) this.currentTick = Promise.resolve().then(() => {
1663
+ const keysToLoad = this.keys;
1664
+ const currentPromises = this.promises;
1665
+ this.keys = [];
1666
+ this.promises = [];
1667
+ this.currentTick = null;
1668
+ this.batchLoadFn(keysToLoad).then((results) => {
1669
+ for (let i = 0; i < currentPromises.length; i++) currentPromises[i].resolve(results[i]);
1670
+ }).catch((err) => {
1671
+ for (const p of currentPromises) p.resolve(err);
1672
+ });
1673
+ });
1674
+ });
1675
+ }
1676
+ };
1677
+ //#endregion
1678
+ //#region src/shared/index.ts
1679
+ function generateId() {
1680
+ return randomUUID();
1681
+ }
1682
+ function sleep(ms) {
1683
+ return new Promise((resolve) => setTimeout(resolve, ms));
1684
+ }
1685
+ var AppError = class extends Error {
1686
+ code;
1687
+ constructor(message, code, options) {
1688
+ super(message, options);
1689
+ this.name = "AppError";
1690
+ this.code = code;
1691
+ }
1692
+ };
1693
+ var ValidationError = class extends AppError {
1694
+ fields;
1695
+ constructor(message, fields, options) {
1696
+ super(message, "VALIDATION_ERROR", options);
1697
+ this.name = "ValidationError";
1698
+ this.fields = fields;
1699
+ }
1700
+ };
1701
+ //#endregion
1702
+ //#region src/rate-limiter/index.ts
1703
+ const NO_OVERRIDES = {
1704
+ throttleLimit: null,
1705
+ throttleWindowHours: null
1706
+ };
1707
+ /**
1708
+ * Caches per-project throttle overrides for the engine.
1709
+ *
1710
+ * The throttle check runs once per notification, so an uncached lookup here
1711
+ * would put a Postgres round trip on the hot path. Projects with no overrides
1712
+ * are cached as well — the common case must not cost a query per message.
1713
+ *
1714
+ * The TTL bounds staleness on its own; `invalidate()` exists so a settings
1715
+ * change published over pub/sub applies immediately rather than at expiry.
1716
+ */
1717
+ var ProjectSettingsCache = class {
1718
+ load;
1719
+ cache;
1720
+ /**
1721
+ * Lookups already on the wire, keyed by project.
1722
+ *
1723
+ * The cache only fills once a query has come back, so without this a cold
1724
+ * project at the start of a campaign puts one query per in-flight message on
1725
+ * Postgres before the first answer lands — exactly when the database is
1726
+ * busiest. Followers wait on the leader's promise instead.
1727
+ */
1728
+ inFlight = /* @__PURE__ */ new Map();
1729
+ constructor(load, { maxSize = 1e3, ttlMs = 6e4 } = {}) {
1730
+ this.load = load;
1731
+ this.cache = new LRUCache(maxSize, ttlMs);
1732
+ }
1733
+ /**
1734
+ * Throws whatever the loader throws. The caller decides whether a settings
1735
+ * lookup failure should drop the message or fall back to defaults.
1736
+ */
1737
+ async get(projectId) {
1738
+ const cached = this.cache.get(projectId);
1739
+ if (cached) return cached;
1740
+ const existing = this.inFlight.get(projectId);
1741
+ if (existing) return existing;
1742
+ let pending;
1743
+ pending = this.load(projectId).then((settings) => {
1744
+ const resolved = settings ?? NO_OVERRIDES;
1745
+ if (this.inFlight.get(projectId) === pending) this.cache.set(projectId, resolved);
1746
+ return resolved;
1747
+ }).finally(() => {
1748
+ if (this.inFlight.get(projectId) === pending) this.inFlight.delete(projectId);
1749
+ });
1750
+ this.inFlight.set(projectId, pending);
1751
+ return pending;
1752
+ }
1753
+ invalidate(projectId) {
1754
+ this.cache.delete(projectId);
1755
+ this.inFlight.delete(projectId);
1756
+ }
1757
+ clear() {
1758
+ this.cache.clear();
1759
+ this.inFlight.clear();
1760
+ }
1761
+ };
1762
+ /** Reject stored values that would make the window meaningless. */
1763
+ function positiveOrNull(value) {
1764
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
1765
+ }
1766
+ /** A limit of 0 is a legitimate kill switch, so zero is allowed here. */
1767
+ function nonNegativeOrNull(value) {
1768
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
1769
+ }
1770
+ var UserThrottle = class {
1771
+ redis;
1772
+ maxPerHour;
1773
+ windowHours;
1774
+ constructor({ redis, maxPerHour = 3, windowHours = 1 }) {
1775
+ this.redis = redis;
1776
+ this.maxPerHour = maxPerHour;
1777
+ this.windowHours = windowHours;
1778
+ }
1779
+ /**
1780
+ * @param projectId Tenant that owns `userId`. User ids are caller-supplied
1781
+ * external ids, so they collide across tenants and MUST be namespaced —
1782
+ * otherwise one tenant's traffic throttles another's.
1783
+ * @param options Per-project overrides. Values that are absent, null, or
1784
+ * nonsensical fall back to this instance's defaults.
1785
+ */
1786
+ async check(projectId, userId, priority, options = {}) {
1787
+ const limit = nonNegativeOrNull(options.limit) ?? this.maxPerHour;
1788
+ const windowHours = positiveOrNull(options.windowHours) ?? this.windowHours;
1789
+ if (priority === "critical") return {
1790
+ allowed: true,
1791
+ count: 0,
1792
+ limit
1793
+ };
1794
+ const windowMs = windowHours * 36e5;
1795
+ const key = `throttle:${projectId}:user:${userId}`;
1796
+ const targetTime = options.scheduledAt ? new Date(options.scheduledAt).getTime() : Date.now();
1797
+ const windowStart = targetTime - windowMs;
1798
+ const memberId = randomUUID$1();
1799
+ const LUA_THROTTLE = `
1800
+ redis.call("ZREMRANGEBYSCORE", KEYS[1], "-inf", ARGV[1])
1801
+ local count = redis.call("ZCARD", KEYS[1])
1802
+ if tonumber(count) < tonumber(ARGV[2]) then
1803
+ redis.call("ZADD", KEYS[1], tonumber(ARGV[3]), ARGV[4])
1804
+ redis.call("EXPIRE", KEYS[1], tonumber(ARGV[5]))
1805
+ return tonumber(count) + 1
1806
+ end
1807
+ return tonumber(count) + 1
1808
+ `;
1809
+ const windowSeconds = Math.ceil(windowMs / 1e3);
1810
+ const ttlSeconds = Math.max(windowSeconds, Math.ceil((targetTime - Date.now()) / 1e3) + windowSeconds);
1811
+ const count = await this.redis.eval(LUA_THROTTLE, 1, key, windowStart, limit, targetTime, memberId, ttlSeconds);
1812
+ return {
1813
+ allowed: count <= limit,
1814
+ count,
1815
+ limit
1816
+ };
1817
+ }
1818
+ };
1819
+ //#endregion
1820
+ //#region src/redis/index.ts
1821
+ var RedisClient = class {
1822
+ native;
1823
+ logger;
1824
+ isClosing = false;
1825
+ constructor({ url, name = "notifkit", logger, redisOptions }) {
1826
+ this.logger = logger;
1827
+ this.native = new Redis(url, {
1828
+ maxRetriesPerRequest: null,
1829
+ enableReadyCheck: true,
1830
+ lazyConnect: false,
1831
+ connectionName: name,
1832
+ ...redisOptions
1833
+ });
1834
+ this.native.on("connect", () => {
1835
+ this.logger?.info({ url: redactUrl(url) }, "redis connected");
1836
+ });
1837
+ this.native.on("ready", () => {
1838
+ this.logger?.debug("redis ready");
1839
+ });
1840
+ this.native.on("error", (err) => {
1841
+ this.logger?.error({ err }, "redis client error");
1842
+ });
1843
+ this.native.on("close", () => {
1844
+ if (!this.isClosing) this.logger?.warn("redis connection closed unexpectedly");
1845
+ });
1846
+ this.native.on("reconnecting", () => {
1847
+ this.logger?.warn("redis reconnecting");
1848
+ });
1849
+ }
1850
+ async healthCheck() {
1851
+ try {
1852
+ return await this.native.ping() === "PONG";
1853
+ } catch {
1854
+ return false;
1855
+ }
1856
+ }
1857
+ async disconnect() {
1858
+ this.isClosing = true;
1859
+ this.logger?.info("disconnecting redis");
1860
+ await this.native.quit();
1861
+ this.logger?.info("redis disconnected");
1862
+ }
1863
+ };
1864
+ function redactUrl(url) {
1865
+ try {
1866
+ const parsed = new URL(url);
1867
+ if (parsed.password) parsed.password = "***";
1868
+ return parsed.toString();
1869
+ } catch {
1870
+ return "[invalid-url]";
1871
+ }
1872
+ }
1873
+ //#endregion
1874
+ //#region src/repositories/index.ts
1875
+ var UserRepository = class {
1876
+ db;
1877
+ constructor(db) {
1878
+ this.db = db;
1879
+ }
1880
+ async findById(projectId, userId) {
1881
+ const rows = await this.db.select().from(users).where(and(eq(users.externalId, userId), eq(users.projectId, projectId))).limit(1);
1882
+ if (!rows[0]) return null;
1883
+ const attrs = rows[0].attributes;
1884
+ return {
1885
+ userId: rows[0].externalId,
1886
+ language: attrs.language,
1887
+ timezone: attrs.timezone,
1888
+ email: attrs.email
1889
+ };
1890
+ }
1891
+ async findRecordById(projectId, userId) {
1892
+ const userRows = await this.db.select().from(users).where(and(eq(users.externalId, userId), eq(users.projectId, projectId)));
1893
+ if (!userRows[0]) return null;
1894
+ const userRow = userRows[0];
1895
+ const internalId = userRow.id;
1896
+ const attrs = userRow.attributes;
1897
+ const [segmentRows, topicRows, channelRows, qhRows] = await Promise.all([
1898
+ this.db.select().from(userSegments).where(eq(userSegments.userId, internalId)),
1899
+ this.db.select().from(userTopicPreferences).where(eq(userTopicPreferences.userId, internalId)),
1900
+ this.db.select().from(userChannelPreferences).where(eq(userChannelPreferences.userId, internalId)),
1901
+ this.db.select().from(quietHours).where(eq(quietHours.userId, internalId))
1902
+ ]);
1903
+ const segments = segmentRows.map((r) => r.segment);
1904
+ const topics = {};
1905
+ for (const r of topicRows) topics[r.topic] = r.enabled;
1906
+ const channels = {};
1907
+ for (const r of channelRows) channels[r.channel] = r.enabled;
1908
+ const quietHoursList = qhRows.map((r) => ({
1909
+ start: r.startTime.slice(0, 5),
1910
+ end: r.endTime.slice(0, 5)
1911
+ }));
1912
+ return {
1913
+ userId: userRow.externalId,
1914
+ language: attrs.language,
1915
+ timezone: attrs.timezone,
1916
+ email: attrs.email,
1917
+ segments,
1918
+ preferences: {
1919
+ channels,
1920
+ topics,
1921
+ quietHours: quietHoursList.length > 0 ? quietHoursList : void 0
1922
+ }
1923
+ };
1924
+ }
1925
+ async findRecordsByIds(projectId, userIds) {
1926
+ if (userIds.length === 0) return [];
1927
+ const usersRows = await this.db.select().from(users).where(and(inArray(users.externalId, userIds), eq(users.projectId, projectId)));
1928
+ if (usersRows.length === 0) return [];
1929
+ const internalIds = usersRows.map((r) => r.id);
1930
+ const [segmentRows, topicRows, channelRows, qhRows] = await Promise.all([
1931
+ this.db.select().from(userSegments).where(inArray(userSegments.userId, internalIds)),
1932
+ this.db.select().from(userTopicPreferences).where(inArray(userTopicPreferences.userId, internalIds)),
1933
+ this.db.select().from(userChannelPreferences).where(inArray(userChannelPreferences.userId, internalIds)),
1934
+ this.db.select().from(quietHours).where(inArray(quietHours.userId, internalIds))
1935
+ ]);
1936
+ const segmentsByUserId = /* @__PURE__ */ new Map();
1937
+ for (const r of segmentRows) {
1938
+ if (!segmentsByUserId.has(r.userId)) segmentsByUserId.set(r.userId, []);
1939
+ segmentsByUserId.get(r.userId).push(r.segment);
1940
+ }
1941
+ const topicsByUserId = /* @__PURE__ */ new Map();
1942
+ for (const r of topicRows) {
1943
+ if (!topicsByUserId.has(r.userId)) topicsByUserId.set(r.userId, {});
1944
+ topicsByUserId.get(r.userId)[r.topic] = r.enabled;
1945
+ }
1946
+ const channelsByUserId = /* @__PURE__ */ new Map();
1947
+ for (const r of channelRows) {
1948
+ if (!channelsByUserId.has(r.userId)) channelsByUserId.set(r.userId, {});
1949
+ channelsByUserId.get(r.userId)[r.channel] = r.enabled;
1950
+ }
1951
+ const qhByUserId = /* @__PURE__ */ new Map();
1952
+ for (const r of qhRows) {
1953
+ if (r.userId == null) continue;
1954
+ if (!qhByUserId.has(r.userId)) qhByUserId.set(r.userId, []);
1955
+ qhByUserId.get(r.userId).push({
1956
+ start: r.startTime.slice(0, 5),
1957
+ end: r.endTime.slice(0, 5)
1958
+ });
1959
+ }
1960
+ const userRecords = [];
1961
+ for (const userRow of usersRows) {
1962
+ const attrs = userRow.attributes;
1963
+ const internalId = userRow.id;
1964
+ userRecords.push({
1965
+ userId: userRow.externalId,
1966
+ language: attrs.language,
1967
+ timezone: attrs.timezone,
1968
+ email: attrs.email,
1969
+ segments: segmentsByUserId.get(internalId) || [],
1970
+ preferences: {
1971
+ channels: channelsByUserId.get(internalId) || {},
1972
+ topics: topicsByUserId.get(internalId) || {},
1973
+ quietHours: qhByUserId.get(internalId)
1974
+ }
1975
+ });
1976
+ }
1977
+ return userRecords;
1978
+ }
1979
+ async upsertFull(projectId, user) {
1980
+ await this.db.transaction(async (tx) => {
1981
+ await tx.insert(users).values({
1982
+ projectId,
1983
+ externalId: user.userId,
1984
+ attributes: {
1985
+ language: user.language,
1986
+ timezone: user.timezone,
1987
+ email: user.email
1988
+ }
1989
+ }).onConflictDoUpdate({
1990
+ target: [users.projectId, users.externalId],
1991
+ set: {
1992
+ attributes: {
1993
+ language: user.language,
1994
+ timezone: user.timezone,
1995
+ email: user.email
1996
+ },
1997
+ updatedAt: /* @__PURE__ */ new Date()
1998
+ }
1999
+ });
2000
+ const internalId = (await tx.select({ id: users.id }).from(users).where(and(eq(users.externalId, user.userId), eq(users.projectId, projectId))))[0]?.id;
2001
+ if (!internalId) return;
2002
+ if (user.segments && user.segments.length > 0) await tx.insert(userSegments).values(user.segments.map((s) => ({
2003
+ userId: internalId,
2004
+ segment: s
2005
+ }))).onConflictDoNothing();
2006
+ if (user.preferences && user.preferences.topics) {
2007
+ const topicInserts = Object.entries(user.preferences.topics).map(([topic, enabled]) => ({
2008
+ userId: internalId,
2009
+ topic,
2010
+ enabled
2011
+ }));
2012
+ if (topicInserts.length > 0) await tx.insert(userTopicPreferences).values(topicInserts).onConflictDoUpdate({
2013
+ target: [userTopicPreferences.userId, userTopicPreferences.topic],
2014
+ set: { enabled: sql`excluded.enabled` }
2015
+ });
2016
+ }
2017
+ if (user.preferences?.channels) {
2018
+ const channelInserts = Object.entries(user.preferences.channels).map(([channel, enabled]) => ({
2019
+ userId: internalId,
2020
+ channel,
2021
+ enabled
2022
+ }));
2023
+ if (channelInserts.length > 0) await tx.insert(userChannelPreferences).values(channelInserts).onConflictDoUpdate({
2024
+ target: [userChannelPreferences.userId, userChannelPreferences.channel],
2025
+ set: { enabled: sql`excluded.enabled` }
2026
+ });
2027
+ }
2028
+ if (user.preferences?.quietHours !== void 0) {
2029
+ await tx.delete(quietHours).where(eq(quietHours.userId, internalId));
2030
+ if (user.preferences.quietHours.length > 0) await tx.insert(quietHours).values(user.preferences.quietHours.map((window) => ({
2031
+ userId: internalId,
2032
+ startTime: window.start,
2033
+ endTime: window.end
2034
+ })));
2035
+ }
2036
+ });
2037
+ }
2038
+ async upsertManyFull(projectId, usersList) {
2039
+ if (usersList.length === 0) return;
2040
+ let attempts = 0;
2041
+ while (attempts < 3) try {
2042
+ await this.db.transaction(async (tx) => {
2043
+ await tx.insert(users).values(usersList.map((u) => ({
2044
+ projectId,
2045
+ externalId: u.userId,
2046
+ attributes: {
2047
+ language: u.language,
2048
+ timezone: u.timezone,
2049
+ email: u.email
2050
+ }
2051
+ }))).onConflictDoUpdate({
2052
+ target: [users.projectId, users.externalId],
2053
+ set: {
2054
+ attributes: sql`excluded.attributes`,
2055
+ updatedAt: /* @__PURE__ */ new Date()
2056
+ }
2057
+ });
2058
+ const internalIdRows = await tx.select({
2059
+ id: users.id,
2060
+ externalId: users.externalId
2061
+ }).from(users).where(and(inArray(users.externalId, usersList.map((u) => u.userId)), eq(users.projectId, projectId)));
2062
+ const idMap = new Map(internalIdRows.map((r) => [r.externalId, r.id]));
2063
+ const segmentInserts = [];
2064
+ const topicInserts = [];
2065
+ const channelInserts = [];
2066
+ const quietHoursInserts = [];
2067
+ for (const u of usersList) {
2068
+ const internalId = idMap.get(u.userId);
2069
+ if (!internalId) continue;
2070
+ if (u.segments && u.segments.length > 0) for (const s of u.segments) segmentInserts.push({
2071
+ userId: internalId,
2072
+ segment: s
2073
+ });
2074
+ if (u.preferences?.topics) for (const [topic, enabled] of Object.entries(u.preferences.topics)) topicInserts.push({
2075
+ userId: internalId,
2076
+ topic,
2077
+ enabled
2078
+ });
2079
+ if (u.preferences?.channels) for (const [channel, enabled] of Object.entries(u.preferences.channels)) channelInserts.push({
2080
+ userId: internalId,
2081
+ channel,
2082
+ enabled
2083
+ });
2084
+ if (u.preferences?.quietHours && u.preferences.quietHours.length > 0) for (const window of u.preferences.quietHours) quietHoursInserts.push({
2085
+ userId: internalId,
2086
+ startTime: window.start,
2087
+ endTime: window.end
2088
+ });
2089
+ }
2090
+ const segmentSet = /* @__PURE__ */ new Set();
2091
+ const dedupedSegmentInserts = [];
2092
+ for (const s of segmentInserts) {
2093
+ const key = `${s.userId}:${s.segment}`;
2094
+ if (!segmentSet.has(key)) {
2095
+ segmentSet.add(key);
2096
+ dedupedSegmentInserts.push(s);
2097
+ }
2098
+ }
2099
+ const topicMap = /* @__PURE__ */ new Map();
2100
+ for (const t of topicInserts) topicMap.set(`${t.userId}:${t.topic}`, t);
2101
+ const dedupedTopicInserts = Array.from(topicMap.values());
2102
+ const channelMap = /* @__PURE__ */ new Map();
2103
+ for (const c of channelInserts) channelMap.set(`${c.userId}:${c.channel}`, c);
2104
+ const dedupedChannelInserts = Array.from(channelMap.values());
2105
+ if (dedupedSegmentInserts.length > 0) await tx.insert(userSegments).values(dedupedSegmentInserts).onConflictDoNothing();
2106
+ if (dedupedTopicInserts.length > 0) await tx.insert(userTopicPreferences).values(dedupedTopicInserts).onConflictDoUpdate({
2107
+ target: [userTopicPreferences.userId, userTopicPreferences.topic],
2108
+ set: { enabled: sql`excluded.enabled` }
2109
+ });
2110
+ if (dedupedChannelInserts.length > 0) await tx.insert(userChannelPreferences).values(dedupedChannelInserts).onConflictDoUpdate({
2111
+ target: [userChannelPreferences.userId, userChannelPreferences.channel],
2112
+ set: { enabled: sql`excluded.enabled` }
2113
+ });
2114
+ const internalIdsToClearQuietHours = usersList.filter((u) => u.preferences?.quietHours !== void 0).map((u) => idMap.get(u.userId)).filter(Boolean);
2115
+ if (internalIdsToClearQuietHours.length > 0) await tx.delete(quietHours).where(inArray(quietHours.userId, internalIdsToClearQuietHours));
2116
+ if (quietHoursInserts.length > 0) await tx.insert(quietHours).values(quietHoursInserts);
2117
+ });
2118
+ return;
2119
+ } catch (err) {
2120
+ attempts++;
2121
+ if (attempts >= 3 || err.code !== "40001" && err.code !== "40P01") throw err;
2122
+ await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempts) * 100));
2123
+ }
2124
+ }
2125
+ async updatePartial(projectId, userId, patch) {
2126
+ const existing = await this.findById(projectId, userId);
2127
+ if (!existing) return false;
2128
+ const attrs = {
2129
+ language: patch.language ?? existing.language,
2130
+ timezone: patch.timezone ?? existing.timezone,
2131
+ email: patch.email ?? existing.email
2132
+ };
2133
+ await this.db.transaction(async (tx) => {
2134
+ await tx.update(users).set({
2135
+ attributes: attrs,
2136
+ updatedAt: /* @__PURE__ */ new Date()
2137
+ }).where(and(eq(users.externalId, userId), eq(users.projectId, projectId)));
2138
+ const internalId = (await tx.select({ id: users.id }).from(users).where(and(eq(users.externalId, userId), eq(users.projectId, projectId))))[0]?.id;
2139
+ if (!internalId) return;
2140
+ if (patch.segments) {
2141
+ await tx.delete(userSegments).where(eq(userSegments.userId, internalId));
2142
+ if (patch.segments.length > 0) await tx.insert(userSegments).values(patch.segments.map((s) => ({
2143
+ userId: internalId,
2144
+ segment: s
2145
+ }))).onConflictDoNothing();
2146
+ }
2147
+ if (patch.preferences && patch.preferences.topics) {
2148
+ const topicInserts = Object.entries(patch.preferences.topics).map(([topic, enabled]) => ({
2149
+ userId: internalId,
2150
+ topic,
2151
+ enabled
2152
+ }));
2153
+ if (topicInserts.length > 0) await tx.insert(userTopicPreferences).values(topicInserts).onConflictDoUpdate({
2154
+ target: [userTopicPreferences.userId, userTopicPreferences.topic],
2155
+ set: { enabled: sql`excluded.enabled` }
2156
+ });
2157
+ }
2158
+ if (patch.preferences?.channels) {
2159
+ const channelInserts = Object.entries(patch.preferences.channels).map(([channel, enabled]) => ({
2160
+ userId: internalId,
2161
+ channel,
2162
+ enabled
2163
+ }));
2164
+ if (channelInserts.length > 0) await tx.insert(userChannelPreferences).values(channelInserts).onConflictDoUpdate({
2165
+ target: [userChannelPreferences.userId, userChannelPreferences.channel],
2166
+ set: { enabled: sql`excluded.enabled` }
2167
+ });
2168
+ }
2169
+ if (patch.preferences?.quietHours !== void 0) {
2170
+ await tx.delete(quietHours).where(eq(quietHours.userId, internalId));
2171
+ if (patch.preferences.quietHours.length > 0) await tx.insert(quietHours).values(patch.preferences.quietHours.map((window) => ({
2172
+ userId: internalId,
2173
+ startTime: window.start,
2174
+ endTime: window.end
2175
+ })));
2176
+ }
2177
+ });
2178
+ return true;
2179
+ }
2180
+ async delete(projectId, userId) {
2181
+ return (await this.db.delete(users).where(and(eq(users.externalId, userId), eq(users.projectId, projectId))).returning()).length > 0;
2182
+ }
2183
+ async list(projectId, limit, cursor) {
2184
+ let query = this.db.select().from(users).where(eq(users.projectId, projectId)).orderBy(desc(users.createdAt)).limit(limit);
2185
+ if (cursor) {
2186
+ const cursorDate = new Date(parseInt(cursor, 10));
2187
+ query = this.db.select().from(users).where(and(eq(users.projectId, projectId), sql`${users.createdAt} < ${cursorDate.toISOString()}`)).orderBy(desc(users.createdAt)).limit(limit);
2188
+ }
2189
+ const items = (await query).map((r) => {
2190
+ const attrs = r.attributes;
2191
+ return {
2192
+ userId: r.externalId,
2193
+ language: attrs.language,
2194
+ timezone: attrs.timezone,
2195
+ email: attrs.email,
2196
+ createdAt: r.createdAt.getTime()
2197
+ };
2198
+ });
2199
+ return {
2200
+ users: items,
2201
+ nextCursor: items.length === limit ? items[items.length - 1].createdAt.toString() : null
2202
+ };
2203
+ }
2204
+ async findUsersBySegment(projectId, segmentName) {
2205
+ return (await this.db.select({ externalId: users.externalId }).from(users).innerJoin(userSegments, eq(users.id, userSegments.userId)).where(and(eq(userSegments.segment, segmentName), eq(users.projectId, projectId)))).map((r) => r.externalId);
2206
+ }
2207
+ async findUsersByTopic(projectId, topicName) {
2208
+ return (await this.db.select({ externalId: users.externalId }).from(users).innerJoin(userTopicPreferences, eq(users.id, userTopicPreferences.userId)).where(and(eq(userTopicPreferences.topic, topicName), eq(userTopicPreferences.enabled, true), eq(users.projectId, projectId)))).map((r) => r.externalId);
2209
+ }
2210
+ };
2211
+ var PreferenceRepository = class {
2212
+ db;
2213
+ constructor(db) {
2214
+ this.db = db;
2215
+ }
2216
+ async isOptedIn(projectId, userId, eventType) {
2217
+ const pref = (await this.findByUserId(projectId, userId)).find((p) => p.eventType === eventType);
2218
+ return pref ? pref.optedIn : true;
2219
+ }
2220
+ async findByUserId(projectId, userId) {
2221
+ const internalId = (await this.db.select({ id: users.id }).from(users).where(and(eq(users.externalId, userId), eq(users.projectId, projectId))))[0]?.id;
2222
+ if (!internalId) return [];
2223
+ return (await this.db.select().from(userTopicPreferences).where(eq(userTopicPreferences.userId, internalId))).map((r) => ({
2224
+ userId,
2225
+ eventType: r.topic,
2226
+ optedIn: r.enabled
2227
+ }));
2228
+ }
2229
+ };
2230
+ var ContactRepository = class {
2231
+ db;
2232
+ constructor(db) {
2233
+ this.db = db;
2234
+ }
2235
+ async findByUserId(projectId, userId) {
2236
+ const internalId = (await this.db.select({ id: users.id }).from(users).where(and(eq(users.externalId, userId), eq(users.projectId, projectId))))[0]?.id;
2237
+ if (!internalId) return [];
2238
+ const rows = await this.db.select().from(userContacts).where(eq(userContacts.userId, internalId));
2239
+ if (rows.length === 0) return [];
2240
+ const topicRows = await this.db.select().from(contactTopicPreferences).where(inArray(contactTopicPreferences.contactId, rows.map((r) => r.id)));
2241
+ const topicsByContact = /* @__PURE__ */ new Map();
2242
+ for (const t of topicRows) {
2243
+ if (!topicsByContact.has(t.contactId)) topicsByContact.set(t.contactId, {});
2244
+ topicsByContact.get(t.contactId)[t.topic] = t.enabled;
2245
+ }
2246
+ return rows.map((r) => ({
2247
+ id: r.id,
2248
+ userId,
2249
+ channel: r.channel,
2250
+ target: r.target,
2251
+ preferences: { topics: topicsByContact.get(r.id) ?? {} },
2252
+ active: r.enabled
2253
+ }));
2254
+ }
2255
+ /** Resolve active addressable contacts for a batch without an N+1 query. */
2256
+ async findActiveByUserIds(projectId, userIds) {
2257
+ const byUser = /* @__PURE__ */ new Map();
2258
+ if (userIds.length === 0) return byUser;
2259
+ const rows = await this.db.select({
2260
+ userId: users.externalId,
2261
+ id: userContacts.id,
2262
+ channel: userContacts.channel,
2263
+ target: userContacts.target,
2264
+ enabled: userContacts.enabled
2265
+ }).from(users).innerJoin(userContacts, eq(users.id, userContacts.userId)).where(and(eq(users.projectId, projectId), inArray(users.externalId, userIds), eq(userContacts.enabled, true)));
2266
+ for (const row of rows) {
2267
+ const contacts = byUser.get(row.userId) ?? [];
2268
+ contacts.push({
2269
+ id: row.id,
2270
+ userId: row.userId,
2271
+ channel: row.channel,
2272
+ target: row.target,
2273
+ preferences: {},
2274
+ active: row.enabled
2275
+ });
2276
+ byUser.set(row.userId, contacts);
2277
+ }
2278
+ return byUser;
2279
+ }
2280
+ async upsert(projectId, userId, channel, target, preferences = {}) {
2281
+ const internalId = (await this.db.select({ id: users.id }).from(users).where(and(eq(users.externalId, userId), eq(users.projectId, projectId))))[0]?.id;
2282
+ if (!internalId) return;
2283
+ const contactId = (await this.db.insert(userContacts).values({
2284
+ userId: internalId,
2285
+ channel,
2286
+ target,
2287
+ enabled: true
2288
+ }).onConflictDoUpdate({
2289
+ target: [
2290
+ userContacts.userId,
2291
+ userContacts.channel,
2292
+ userContacts.target
2293
+ ],
2294
+ set: { enabled: true }
2295
+ }).returning({ id: userContacts.id }))[0]?.id;
2296
+ if (!contactId) return;
2297
+ const topics = Object.entries(preferences.topics ?? {});
2298
+ if (topics.length > 0) await this.db.insert(contactTopicPreferences).values(topics.map(([topic, enabled]) => ({
2299
+ contactId,
2300
+ topic,
2301
+ enabled
2302
+ }))).onConflictDoUpdate({
2303
+ target: [contactTopicPreferences.contactId, contactTopicPreferences.topic],
2304
+ set: { enabled: sql`excluded.enabled` }
2305
+ });
2306
+ }
2307
+ async upsertMany(projectId, contactsList) {
2308
+ if (contactsList.length === 0) return;
2309
+ const internalUserIdRows = await this.db.select({
2310
+ id: users.id,
2311
+ externalId: users.externalId
2312
+ }).from(users).where(and(inArray(users.externalId, contactsList.map((c) => c.userId)), eq(users.projectId, projectId)));
2313
+ const idMap = new Map(internalUserIdRows.map((r) => [r.externalId, r.id]));
2314
+ const validContacts = contactsList.filter((c) => idMap.has(c.userId));
2315
+ if (validContacts.length === 0) return;
2316
+ const inserted = await this.db.insert(userContacts).values(validContacts.map((c) => ({
2317
+ userId: idMap.get(c.userId),
2318
+ channel: c.channel,
2319
+ target: c.target,
2320
+ enabled: true
2321
+ }))).onConflictDoUpdate({
2322
+ target: [
2323
+ userContacts.userId,
2324
+ userContacts.channel,
2325
+ userContacts.target
2326
+ ],
2327
+ set: { enabled: true }
2328
+ }).returning({
2329
+ id: userContacts.id,
2330
+ userId: userContacts.userId,
2331
+ channel: userContacts.channel,
2332
+ target: userContacts.target
2333
+ });
2334
+ const contactIdMap = /* @__PURE__ */ new Map();
2335
+ for (const row of inserted) contactIdMap.set(`${row.userId}:${row.channel}:${row.target}`, row.id);
2336
+ const topicInserts = [];
2337
+ for (const c of validContacts) {
2338
+ const internalId = idMap.get(c.userId);
2339
+ const contactId = contactIdMap.get(`${internalId}:${c.channel}:${c.target}`);
2340
+ if (!contactId || !c.preferences?.topics) continue;
2341
+ for (const [topic, enabled] of Object.entries(c.preferences.topics)) topicInserts.push({
2342
+ contactId,
2343
+ topic,
2344
+ enabled
2345
+ });
2346
+ }
2347
+ if (topicInserts.length > 0) await this.db.insert(contactTopicPreferences).values(topicInserts).onConflictDoUpdate({
2348
+ target: [contactTopicPreferences.contactId, contactTopicPreferences.topic],
2349
+ set: { enabled: sql`excluded.enabled` }
2350
+ });
2351
+ }
2352
+ /**
2353
+ * Mark a contact unusable without destroying it — used when a provider
2354
+ * reports an invalid push token. Deleting the row would lose the user's
2355
+ * device permanently on what is often a transient provider response.
2356
+ */
2357
+ async deactivate(projectId, userId, channel, target) {
2358
+ const internalId = (await this.db.select({ id: users.id }).from(users).where(and(eq(users.externalId, userId), eq(users.projectId, projectId))))[0]?.id;
2359
+ if (!internalId) return false;
2360
+ return (await this.db.update(userContacts).set({ enabled: false }).where(and(eq(userContacts.userId, internalId), eq(userContacts.channel, channel), eq(userContacts.target, target))).returning()).length > 0;
2361
+ }
2362
+ async delete(projectId, userId, channel, target) {
2363
+ const internalId = (await this.db.select({ id: users.id }).from(users).where(and(eq(users.externalId, userId), eq(users.projectId, projectId))))[0]?.id;
2364
+ if (!internalId) return false;
2365
+ return (await this.db.delete(userContacts).where(and(eq(userContacts.userId, internalId), eq(userContacts.channel, channel), eq(userContacts.target, target))).returning()).length > 0;
2366
+ }
2367
+ };
2368
+ var TemplateRepository = class {
2369
+ db;
2370
+ constructor(db) {
2371
+ this.db = db;
2372
+ }
2373
+ async findById(projectId, id) {
2374
+ const rows = await this.db.select().from(templates).where(and(eq(templates.id, id), eq(templates.projectId, projectId))).limit(1);
2375
+ if (!rows[0]) return null;
2376
+ return {
2377
+ id: rows[0].id,
2378
+ channel: rows[0].channel,
2379
+ content: rows[0].content,
2380
+ topics: rows[0].topics ?? [],
2381
+ aiPrompts: rows[0].aiPrompts
2382
+ };
2383
+ }
2384
+ async list(projectId) {
2385
+ return (await this.db.select().from(templates).where(eq(templates.projectId, projectId))).map((r) => ({
2386
+ id: r.id,
2387
+ channel: r.channel,
2388
+ content: r.content,
2389
+ topics: r.topics ?? [],
2390
+ aiPrompts: r.aiPrompts
2391
+ }));
2392
+ }
2393
+ async upsertMany(projectId, templateList) {
2394
+ if (templateList.length === 0) return 0;
2395
+ const values = templateList.map((t) => ({
2396
+ projectId,
2397
+ id: t.id,
2398
+ channel: t.channel,
2399
+ topics: t.topics ?? [],
2400
+ content: t.content,
2401
+ aiPrompts: t.aiPrompts
2402
+ }));
2403
+ await this.db.insert(templates).values(values).onConflictDoUpdate({
2404
+ target: [templates.projectId, templates.id],
2405
+ set: {
2406
+ channel: sql`excluded.channel`,
2407
+ topics: sql`excluded.topics`,
2408
+ content: sql`excluded.content`,
2409
+ aiPrompts: sql`excluded.ai_prompts`,
2410
+ updatedAt: /* @__PURE__ */ new Date()
2411
+ }
2412
+ });
2413
+ return templateList.length;
2414
+ }
2415
+ async delete(projectId, id) {
2416
+ return (await this.db.delete(templates).where(and(eq(templates.id, id), eq(templates.projectId, projectId))).returning()).length > 0;
2417
+ }
2418
+ };
2419
+ var ProjectRepository = class {
2420
+ db;
2421
+ constructor(db) {
2422
+ this.db = db;
2423
+ }
2424
+ async list() {
2425
+ return this.db.select({
2426
+ id: projects.id,
2427
+ name: projects.name,
2428
+ rateLimitRpm: projects.rateLimitRpm,
2429
+ throttleLimit: projects.throttleLimit,
2430
+ throttleWindowHours: projects.throttleWindowHours,
2431
+ createdAt: projects.createdAt
2432
+ }).from(projects).orderBy(desc(projects.createdAt));
2433
+ }
2434
+ async delete(id) {
2435
+ return await this.db.transaction(async (tx) => {
2436
+ const userIds = (await tx.select({ id: users.id }).from(users).where(eq(users.projectId, id))).map((u) => u.id);
2437
+ if (userIds.length > 0) {
2438
+ await tx.delete(userContacts).where(inArray(userContacts.userId, userIds));
2439
+ await tx.delete(userSegments).where(inArray(userSegments.userId, userIds));
2440
+ await tx.delete(userTopicPreferences).where(inArray(userTopicPreferences.userId, userIds));
2441
+ await tx.delete(userChannelPreferences).where(inArray(userChannelPreferences.userId, userIds));
2442
+ await tx.delete(quietHours).where(inArray(quietHours.userId, userIds));
2443
+ await tx.delete(users).where(eq(users.projectId, id));
2444
+ }
2445
+ await tx.delete(suppressions).where(eq(suppressions.projectId, id));
2446
+ await tx.delete(messageLogs).where(eq(messageLogs.projectId, id));
2447
+ await tx.delete(workflowInstances).where(eq(workflowInstances.projectId, id));
2448
+ return (await tx.delete(projects).where(eq(projects.id, id)).returning()).length > 0;
2449
+ });
2450
+ }
2451
+ /**
2452
+ * Throttle overrides only. Kept narrow because the engine calls this once per
2453
+ * notification (behind a cache) and has no use for the rest of the row.
2454
+ */
2455
+ async findThrottleSettings(id) {
2456
+ return (await this.db.select({
2457
+ throttleLimit: projects.throttleLimit,
2458
+ throttleWindowHours: projects.throttleWindowHours
2459
+ }).from(projects).where(eq(projects.id, id)).limit(1))[0] ?? null;
2460
+ }
2461
+ async updateSettings(id, settings) {
2462
+ return (await this.db.update(projects).set(settings).where(eq(projects.id, id)).returning()).length > 0;
2463
+ }
2464
+ async createApiKey(projectId, keyHash, role = "admin") {
2465
+ return { id: (await this.db.insert(projectApiKeys).values({
2466
+ projectId,
2467
+ keyHash,
2468
+ role
2469
+ }).returning())[0].id };
2470
+ }
2471
+ async listApiKeys(projectId) {
2472
+ return this.db.select({
2473
+ id: projectApiKeys.id,
2474
+ role: projectApiKeys.role,
2475
+ createdAt: projectApiKeys.createdAt
2476
+ }).from(projectApiKeys).where(eq(projectApiKeys.projectId, projectId)).orderBy(desc(projectApiKeys.createdAt));
2477
+ }
2478
+ async deleteApiKey(projectId, keyId) {
2479
+ return (await this.db.delete(projectApiKeys).where(and(eq(projectApiKeys.id, keyId), eq(projectApiKeys.projectId, projectId))).returning()).length > 0;
2480
+ }
2481
+ };
2482
+ var WorkflowRepository = class {
2483
+ db;
2484
+ constructor(db) {
2485
+ this.db = db;
2486
+ }
2487
+ async listDefinitions(projectId) {
2488
+ return this.db.select().from(workflowDefinitions).where(eq(workflowDefinitions.projectId, projectId)).orderBy(desc(workflowDefinitions.createdAt));
2489
+ }
2490
+ async getInstance(projectId, instanceId) {
2491
+ const instances = await this.db.select().from(workflowInstances).where(and(eq(workflowInstances.id, instanceId), eq(workflowInstances.projectId, projectId))).limit(1);
2492
+ if (!instances[0]) return null;
2493
+ const steps = await this.db.select().from(workflowSteps).where(eq(workflowSteps.instanceId, instanceId)).orderBy(workflowSteps.createdAt);
2494
+ const waiters = await this.db.select().from(workflowWaiters).where(eq(workflowWaiters.instanceId, instanceId));
2495
+ return {
2496
+ ...instances[0],
2497
+ steps,
2498
+ waiters
2499
+ };
2500
+ }
2501
+ async cancelInstance(projectId, instanceId) {
2502
+ return await this.db.transaction(async (tx) => {
2503
+ if ((await tx.update(workflowInstances).set({ status: "canceled" }).where(and(eq(workflowInstances.id, instanceId), eq(workflowInstances.projectId, projectId), inArray(workflowInstances.status, ["pending", "running"]))).returning()).length === 0) return false;
2504
+ await tx.delete(workflowWaiters).where(eq(workflowWaiters.instanceId, instanceId));
2505
+ return true;
2506
+ });
2507
+ }
2508
+ };
2509
+ var SegmentRepository = class {
2510
+ db;
2511
+ constructor(db) {
2512
+ this.db = db;
2513
+ }
2514
+ async listSegments(projectId) {
2515
+ return (await this.db.execute(sql`
2516
+ SELECT DISTINCT s.segment
2517
+ FROM user_segments s
2518
+ JOIN users u ON u.id = s.user_id
2519
+ WHERE u.project_id = ${projectId}
2520
+ `)).map((r) => r.segment);
2521
+ }
2522
+ };
2523
+ //#endregion
2524
+ //#region src/templates/render.ts
2525
+ function escapeHtml(unsafe) {
2526
+ return String(unsafe).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
2527
+ }
2528
+ /** Strip CR/LF so an interpolated value cannot inject extra headers. */
2529
+ function escapeHeader(unsafe) {
2530
+ return String(unsafe).replace(/[\r\n]+/g, " ").trim();
2531
+ }
2532
+ function interpolate(tmpl, variables, sanitize = true) {
2533
+ return tmpl.replace(/\{\{(\w+)\}\}/g, (_, k) => {
2534
+ const val = String(variables[k] ?? "");
2535
+ return sanitize ? escapeHtml(val) : val;
2536
+ });
2537
+ }
2538
+ const HTML_FIELDS = /* @__PURE__ */ new Set([
2539
+ "html",
2540
+ "htmlbody",
2541
+ "bodyhtml",
2542
+ "htmlcontent"
2543
+ ]);
2544
+ const HEADER_FIELDS = /* @__PURE__ */ new Set([
2545
+ "subject",
2546
+ "title",
2547
+ "from",
2548
+ "replyto",
2549
+ "cc",
2550
+ "bcc",
2551
+ "preheader",
2552
+ "preview"
2553
+ ]);
2554
+ function escapeModeFor(key, inherited) {
2555
+ const k = key.toLowerCase().replace(/[-_]/g, "");
2556
+ if (HTML_FIELDS.has(k)) return "html";
2557
+ if (HEADER_FIELDS.has(k)) return "header";
2558
+ return inherited;
2559
+ }
2560
+ function applyEscape(value, mode) {
2561
+ if (mode === "html") return escapeHtml(value);
2562
+ if (mode === "header") return escapeHeader(value);
2563
+ return value;
2564
+ }
2565
+ /**
2566
+ * Interpolate `{{var}}` placeholders in a single leaf string.
2567
+ *
2568
+ * Escaping is applied to the SUBSTITUTED VALUE only — never to the surrounding
2569
+ * template — so template authors keep their own markup while caller-supplied
2570
+ * data cannot break out of it.
2571
+ */
2572
+ function interpolateLeaf(tmpl, variables, mode) {
2573
+ return tmpl.replace(/\{\{(\w+)\}\}/g, (_, k) => {
2574
+ const raw = variables[k];
2575
+ if (raw === void 0 || raw === null) return "";
2576
+ return applyEscape(typeof raw === "string" ? raw : JSON.stringify(raw), mode);
2577
+ });
2578
+ }
2579
+ /**
2580
+ * Walk a template content tree and interpolate every leaf string in place.
2581
+ *
2582
+ * Values are substituted into the already-parsed structure. Interpolating into
2583
+ * serialised JSON and re-parsing (the previous approach) let a value containing
2584
+ * a quote either break JSON.parse outright or forge sibling fields such as
2585
+ * `htmlBody`.
2586
+ */
2587
+ function renderNode(node, variables, mode) {
2588
+ if (typeof node === "string") return interpolateLeaf(node, variables, mode);
2589
+ if (Array.isArray(node)) return node.map((item) => renderNode(item, variables, mode));
2590
+ if (node && typeof node === "object") {
2591
+ const out = {};
2592
+ for (const [key, value] of Object.entries(node)) out[key] = renderNode(value, variables, escapeModeFor(key, mode));
2593
+ return out;
2594
+ }
2595
+ return node;
2596
+ }
2597
+ function renderWithTemplate(dbTemplate, templateVariables) {
2598
+ const vars = templateVariables ?? {};
2599
+ if (dbTemplate) return { content: renderNode(dbTemplate.content ?? {}, vars, "text") };
2600
+ return { content: {
2601
+ subject: "Notification",
2602
+ body: JSON.stringify(vars, null, 2)
2603
+ } };
2604
+ }
2605
+ //#endregion
2606
+ //#region src/templates/cache.ts
2607
+ var TemplateCache = class {
2608
+ templateRepo;
2609
+ cache = new LRUCache(1e3, 3e5);
2610
+ constructor(templateRepo) {
2611
+ this.templateRepo = templateRepo;
2612
+ }
2613
+ async getCachedTemplate(projectId, id) {
2614
+ const key = `${projectId}:${id}`;
2615
+ const cached = this.cache.get(key);
2616
+ if (cached) return cached;
2617
+ const dbTemplate = await this.templateRepo.findById(projectId, id);
2618
+ if (dbTemplate) this.cache.set(key, dbTemplate);
2619
+ return dbTemplate;
2620
+ }
2621
+ invalidate(projectId, id) {
2622
+ this.cache.delete(`${projectId}:${id}`);
2623
+ }
2624
+ invalidateKey(key) {
2625
+ this.cache.delete(key);
2626
+ }
2627
+ clear() {
2628
+ this.cache.clear();
2629
+ }
2630
+ };
2631
+ //#endregion
2632
+ //#region src/templates/index.ts
2633
+ /** Coerce a template variable to a non-empty string, or undefined. */
2634
+ function asText(value) {
2635
+ return typeof value === "string" && value.length > 0 ? value : void 0;
2636
+ }
2637
+ var TemplateRegistry = class {
2638
+ renderers = /* @__PURE__ */ new Map();
2639
+ /**
2640
+ * Register a custom renderer for an event type.
2641
+ * Overwrites any previous registration for the same type.
2642
+ */
2643
+ register(eventType, renderer) {
2644
+ this.renderers.set(eventType, renderer);
2645
+ }
2646
+ /** Render content for the given context, falling back to the i18n table. */
2647
+ render(ctx) {
2648
+ const renderer = this.renderers.get(ctx.eventType);
2649
+ if (renderer) return renderer(ctx);
2650
+ return defaultRenderer(ctx);
2651
+ }
2652
+ has(eventType) {
2653
+ return this.renderers.has(eventType);
2654
+ }
2655
+ registeredTypes() {
2656
+ return [...this.renderers.keys()];
2657
+ }
2658
+ };
2659
+ function defaultRenderer(ctx) {
2660
+ const vars = ctx.templateVariables;
2661
+ return { content: {
2662
+ subject: asText(vars.subject ?? vars.title),
2663
+ body: asText(vars.body ?? vars.message) ?? `Notification: ${ctx.eventType}`
2664
+ } };
2665
+ }
2666
+ const templateRegistry = new TemplateRegistry();
2667
+ function renderTemplate(ctx) {
2668
+ return templateRegistry.render(ctx);
2669
+ }
2670
+ //#endregion
2671
+ //#region src/unsubscribe/index.ts
2672
+ function b64url(input) {
2673
+ return Buffer.from(input).toString("base64url");
2674
+ }
2675
+ /**
2676
+ * Sign a claim into a URL-safe token.
2677
+ *
2678
+ * The signature covers the exact encoded payload rather than a re-serialisation
2679
+ * of it, so a verifier never has to reproduce this function's JSON key order to
2680
+ * get a matching MAC.
2681
+ */
2682
+ function signUnsubscribeToken(claim, secret) {
2683
+ const wire = {
2684
+ p: claim.projectId,
2685
+ u: claim.userId,
2686
+ c: claim.channel,
2687
+ t: claim.target,
2688
+ k: claim.topics
2689
+ };
2690
+ const payload = b64url(JSON.stringify(wire));
2691
+ return `${payload}.${b64url(createHmac("sha256", secret).update(payload).digest())}`;
2692
+ }
2693
+ /**
2694
+ * Verify and decode a token. Returns null for anything not signed by `secret`.
2695
+ *
2696
+ * Every failure returns the same null rather than a reason: the caller is an
2697
+ * unauthenticated endpoint, and distinguishing "malformed" from "bad signature"
2698
+ * hands an attacker a probe.
2699
+ */
2700
+ function verifyUnsubscribeToken(token, secret) {
2701
+ const dot = token.indexOf(".");
2702
+ if (dot <= 0 || dot === token.length - 1) return null;
2703
+ const payload = token.slice(0, dot);
2704
+ const provided = Buffer.from(token.slice(dot + 1), "base64url");
2705
+ const expected = createHmac("sha256", secret).update(payload).digest();
2706
+ if (provided.length !== expected.length) return null;
2707
+ if (!timingSafeEqual(provided, expected)) return null;
2708
+ try {
2709
+ const wire = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
2710
+ if (typeof wire.p !== "string" || typeof wire.u !== "string" || typeof wire.c !== "string" || typeof wire.t !== "string" || !Array.isArray(wire.k)) return null;
2711
+ return {
2712
+ projectId: wire.p,
2713
+ userId: wire.u,
2714
+ channel: wire.c,
2715
+ target: wire.t,
2716
+ topics: wire.k.filter((t) => typeof t === "string")
2717
+ };
2718
+ } catch {
2719
+ return null;
2720
+ }
2721
+ }
2722
+ /**
2723
+ * The two headers that make an inbox render a real unsubscribe button.
2724
+ *
2725
+ * `List-Unsubscribe-Post` is what upgrades the link from "open this URL" to
2726
+ * one-click: the mail client POSTs directly and never shows the recipient a
2727
+ * landing page. Sending the URL without it means the recipient has to click
2728
+ * through and confirm, which mailbox providers do not count as compliant.
2729
+ */
2730
+ function buildUnsubscribeHeaders(options) {
2731
+ const token = signUnsubscribeToken(options.claim, options.secret);
2732
+ return {
2733
+ "List-Unsubscribe": `<${`${options.publicUrl.replace(/\/$/, "")}/v1/unsubscribe?token=${encodeURIComponent(token)}`}>`,
2734
+ "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
2735
+ };
2736
+ }
2737
+ //#endregion
2738
+ //#region src/workers/health.ts
2739
+ function startHealthReporter(serviceName, worker, redis, logger, intervalMs = 1e3) {
2740
+ return setInterval(() => {
2741
+ (async () => {
2742
+ try {
2743
+ const redisOk = await redis.healthCheck();
2744
+ await redis.native.set(`notif:health:${serviceName}`, JSON.stringify({
2745
+ service: serviceName,
2746
+ redis: redisOk,
2747
+ ...worker.health(),
2748
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2749
+ }), "EX", 15);
2750
+ } catch {}
2751
+ })();
2752
+ }, intervalMs);
2753
+ }
2754
+ //#endregion
2755
+ //#region src/workers/index.ts
2756
+ var NonRetryableError = class extends Error {
2757
+ nonRetryable = true;
2758
+ constructor(message) {
2759
+ super(message);
2760
+ this.name = "NonRetryableError";
2761
+ }
2762
+ };
2763
+ var BaseWorker = class {
2764
+ logger;
2765
+ consumer;
2766
+ pendingScanner;
2767
+ concurrency;
2768
+ recoveryIntervalMs;
2769
+ maxRetriesBeforeDlq;
2770
+ state = "idle";
2771
+ stopping = false;
2772
+ processedCount = 0;
2773
+ errorCount = 0;
2774
+ lastProcessedAt = null;
2775
+ lastErrorAt = null;
2776
+ recoveryTimer = null;
2777
+ lastPendingCount = null;
2778
+ active = /* @__PURE__ */ new Set();
2779
+ semaphore;
2780
+ runLoop;
2781
+ constructor({ consumer, pendingScanner, logger, concurrency = 10, recoveryIntervalMs = 6e4, maxRetriesBeforeDlq = 3 }) {
2782
+ this.consumer = consumer;
2783
+ this.pendingScanner = pendingScanner;
2784
+ this.logger = logger.child({ component: this.constructor.name });
2785
+ this.concurrency = concurrency;
2786
+ this.recoveryIntervalMs = recoveryIntervalMs;
2787
+ this.maxRetriesBeforeDlq = maxRetriesBeforeDlq;
2788
+ this.semaphore = new AsyncSemaphore(concurrency);
2789
+ }
2790
+ async start() {
2791
+ if (this.state !== "idle") throw new Error(`Worker cannot start from state: ${this.state}`);
2792
+ this.state = "running";
2793
+ this.logger.info({ concurrency: this.concurrency }, "worker starting");
2794
+ await this.consumer.ensureGroup();
2795
+ this.startRecoveryLoop();
2796
+ this.runLoop = this.consume();
2797
+ }
2798
+ async consume() {
2799
+ for await (const batch of this.consumer.readBatch()) {
2800
+ if (this.stopping) break;
2801
+ for (const message of batch) {
2802
+ if (this.stopping) break;
2803
+ await this.semaphore.acquire();
2804
+ const task = this.processWithTracking(message).finally(() => {
2805
+ this.active.delete(task);
2806
+ this.semaphore.release();
2807
+ });
2808
+ this.active.add(task);
2809
+ }
2810
+ }
2811
+ await Promise.allSettled([...this.active]);
2812
+ this.state = "stopped";
2813
+ this.logger.info("worker stopped");
2814
+ }
2815
+ async stop() {
2816
+ if (this.stopping || this.state !== "running") return;
2817
+ this.logger.info("worker stopping");
2818
+ this.stopping = true;
2819
+ this.state = "stopping";
2820
+ await this.consumer.stop();
2821
+ this.stopRecoveryLoop();
2822
+ if (this.runLoop) await Promise.race([this.runLoop, new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("Worker stop timeout")), 3e4))]).catch((err) => this.logger.warn({ err }, "Worker shutdown timeout or error"));
2823
+ this.logger.info("worker shutdown complete");
2824
+ }
2825
+ async recover() {
2826
+ this.logger.debug("scanning for stale pending messages");
2827
+ const pendingCount = await this.pendingScanner.getPendingCount();
2828
+ this.lastPendingCount = pendingCount;
2829
+ if (pendingCount === 0) return;
2830
+ this.logger.info({ pendingCount }, "found pending messages, attempting autoclaim");
2831
+ const BATCH_SIZE = 1e3;
2832
+ while (!this.stopping) {
2833
+ const messages = await this.pendingScanner.autoclaim(this.recoveryIntervalMs, BATCH_SIZE);
2834
+ if (messages.length === 0) break;
2835
+ for (const message of messages) {
2836
+ if (this.stopping) break;
2837
+ await this.semaphore.acquire();
2838
+ const task = this.processWithTracking(message).finally(() => {
2839
+ this.active.delete(task);
2840
+ this.semaphore.release();
2841
+ });
2842
+ this.active.add(task);
2843
+ }
2844
+ }
2845
+ }
2846
+ health() {
2847
+ return {
2848
+ state: this.state,
2849
+ processedCount: this.processedCount,
2850
+ errorCount: this.errorCount,
2851
+ lastProcessedAt: this.lastProcessedAt,
2852
+ lastErrorAt: this.lastErrorAt,
2853
+ pendingCount: this.lastPendingCount
2854
+ };
2855
+ }
2856
+ async processWithTracking(message) {
2857
+ const start = Date.now();
2858
+ const stream = message.stream;
2859
+ const retryKey = `notif:worker:retries:${this.constructor.name}:${stream ?? "default"}:${message.id}`;
2860
+ try {
2861
+ const retryCount = (await this.consumer.redis.multi().incr(retryKey).expire(retryKey, 7200).exec())?.[0]?.[1] ?? 1;
2862
+ if (retryCount > this.maxRetriesBeforeDlq) {
2863
+ this.logger.warn({
2864
+ messageId: message.id,
2865
+ retryCount
2866
+ }, "max retries exceeded, moving to dead-letter queue");
2867
+ await this.consumer.nack(message.id, message.event, stream);
2868
+ await this.consumer.redis.del(retryKey);
2869
+ globalEmitter.emit("notification:failed", message.id, "Poison pill: max retries exceeded", message.event.type);
2870
+ return;
2871
+ }
2872
+ await this.process(message, retryCount);
2873
+ await this.consumer.ack(message.id, stream);
2874
+ await this.consumer.redis.del(retryKey);
2875
+ this.processedCount += 1;
2876
+ this.lastProcessedAt = (/* @__PURE__ */ new Date()).toISOString();
2877
+ metrics.messagesProcessed.inc({
2878
+ worker: this.constructor.name,
2879
+ status: "success"
2880
+ });
2881
+ this.logger.debug({
2882
+ messageId: message.id,
2883
+ eventType: message.event.type,
2884
+ durationMs: Date.now() - start
2885
+ }, "message processed");
2886
+ } catch (err) {
2887
+ this.errorCount += 1;
2888
+ this.lastErrorAt = (/* @__PURE__ */ new Date()).toISOString();
2889
+ metrics.messagesProcessed.inc({
2890
+ worker: this.constructor.name,
2891
+ status: "error"
2892
+ });
2893
+ if (err instanceof NonRetryableError || err?.nonRetryable) {
2894
+ this.logger.warn({
2895
+ err,
2896
+ messageId: message.id
2897
+ }, "non-retryable error encountered, immediately moving to dead-letter queue without retry loop");
2898
+ await this.consumer.nack(message.id, message.event, stream);
2899
+ await this.consumer.redis.del(retryKey);
2900
+ globalEmitter.emit("notification:failed", message.id, err.message, message.event.type);
2901
+ return;
2902
+ }
2903
+ this.logger.error({
2904
+ err,
2905
+ messageId: message.id,
2906
+ eventType: message.event.type
2907
+ }, "failed to process message");
2908
+ }
2909
+ }
2910
+ startRecoveryLoop() {
2911
+ this.recoveryTimer = setInterval(() => {
2912
+ if (this.state === "running") this.recover().catch((err) => {
2913
+ this.logger.error({ err }, "recovery loop error");
2914
+ });
2915
+ }, this.recoveryIntervalMs);
2916
+ }
2917
+ stopRecoveryLoop() {
2918
+ if (this.recoveryTimer) {
2919
+ clearInterval(this.recoveryTimer);
2920
+ this.recoveryTimer = null;
2921
+ }
2922
+ }
2923
+ };
2924
+ //#endregion
2925
+ //#region src/workflows/sdk.ts
2926
+ var SuspendExecutionError = class extends Error {
2927
+ reason;
2928
+ payload;
2929
+ constructor(reason, payload) {
2930
+ super(`Execution suspended for ${reason}`);
2931
+ this.reason = reason;
2932
+ this.payload = payload;
2933
+ this.name = "SuspendExecutionError";
2934
+ }
2935
+ };
2936
+ /**
2937
+ * Works out who a `step.notify()` call is for.
2938
+ *
2939
+ * A target named in the step payload wins. Only when the step names none does
2940
+ * the notification fall back to the instance's own user — the common case, and
2941
+ * the reason most steps carry no target at all.
2942
+ */
2943
+ function resolveStepTarget(args, instanceInput) {
2944
+ if (args.segment !== void 0) return {
2945
+ type: "segment",
2946
+ segment: args.segment
2947
+ };
2948
+ if (args.topic !== void 0) return {
2949
+ type: "topic",
2950
+ topic: args.topic
2951
+ };
2952
+ if (args.user !== void 0) {
2953
+ if (Array.isArray(args.user)) throw new Error("step.notify() takes a single `user`. To reach several people from one workflow, use one notify step each, or target a `segment`.");
2954
+ return {
2955
+ type: "user",
2956
+ userId: typeof args.user === "string" ? args.user : args.user.id
2957
+ };
2958
+ }
2959
+ const inherited = instanceInput?.user?.id;
2960
+ if (!inherited) throw new Error("step.notify() has no target: the step payload names no `user`, `segment` or `topic`, and the workflow instance was triggered without `input.user.id`.");
2961
+ return {
2962
+ type: "user",
2963
+ userId: inherited
2964
+ };
2965
+ }
2966
+ /**
2967
+ * Maps a `step.notify()` payload onto the wire event the pipeline consumes.
2968
+ *
2969
+ * The field names differ either side of the boundary — `template` becomes
2970
+ * `templateId`, `sendAt` becomes `scheduledAt` — so this translation is
2971
+ * deliberate rather than a spread, and the return type keeps it honest.
2972
+ */
2973
+ function buildStepNotifyPayload(args, instanceInput, projectId, idempotencyKey) {
2974
+ return {
2975
+ projectId,
2976
+ target: resolveStepTarget(args, instanceInput),
2977
+ templateId: args.template,
2978
+ priority: args.priority ?? "normal",
2979
+ channels: args.channels,
2980
+ data: args.data ?? {},
2981
+ aiPrompts: args.aiPrompts,
2982
+ fallback: args.fallback ?? false,
2983
+ scheduledAt: args.sendAt,
2984
+ idempotencyKey
2985
+ };
2986
+ }
2987
+ //#endregion
2988
+ //#region src/workflows/registry.ts
2989
+ var WorkflowRegistry = class {
2990
+ workflows = /* @__PURE__ */ new Map();
2991
+ register(name, handler) {
2992
+ this.workflows.set(name, handler);
2993
+ }
2994
+ get(name) {
2995
+ return this.workflows.get(name);
2996
+ }
2997
+ };
2998
+ const workflowRegistry = new WorkflowRegistry();
2999
+ function workflow(name, handler) {
3000
+ workflowRegistry.register(name, handler);
3001
+ }
3002
+ //#endregion
3003
+ //#region src/client.ts
3004
+ var NotifkitClient = class {
3005
+ options;
3006
+ baseUrl;
3007
+ headers;
3008
+ constructor(options) {
3009
+ this.options = options;
3010
+ this.baseUrl = options.baseUrl.replace(/\/$/, "");
3011
+ this.headers = {
3012
+ "Content-Type": "application/json",
3013
+ ...options.apiKey ? { Authorization: `Bearer ${options.apiKey}` } : {},
3014
+ ...options.headers
3015
+ };
3016
+ }
3017
+ async request(path, method, body) {
3018
+ const url = `${this.baseUrl}${path}`;
3019
+ const res = await fetch(url, {
3020
+ method,
3021
+ headers: this.headers,
3022
+ body: body ? JSON.stringify(body) : void 0
3023
+ });
3024
+ if (res.status === 204) return;
3025
+ const data = await res.json();
3026
+ if (!res.ok) {
3027
+ const errorMsg = data.message || data.error || `Request failed with status ${res.status}`;
3028
+ throw new Error(errorMsg);
3029
+ }
3030
+ return data;
3031
+ }
3032
+ /** Sync templates with the server. */
3033
+ async syncTemplates(input) {
3034
+ return this.request("/v1/templates", "PUT", input);
3035
+ }
3036
+ /** Create/upsert a user profile and contacts. */
3037
+ async addUser(input) {
3038
+ return this.request("/v1/users", "POST", input);
3039
+ }
3040
+ /** Update user profile. */
3041
+ async updateUser(id, input) {
3042
+ return this.request(`/v1/users/${id}`, "PATCH", input);
3043
+ }
3044
+ /** Delete user profile. */
3045
+ async deleteUser(id) {
3046
+ return this.request(`/v1/users/${id}`, "DELETE");
3047
+ }
3048
+ /** Add contact targets to user profile. */
3049
+ async addContact(userId, input) {
3050
+ return this.request(`/v1/users/${userId}/contacts`, "POST", input);
3051
+ }
3052
+ /** Delete a specific contact channel target. */
3053
+ async deleteContact(userId, channel, target) {
3054
+ return this.request(`/v1/users/${userId}/contacts/${channel}/${target}`, "DELETE");
3055
+ }
3056
+ /** Request a notification dispatch. */
3057
+ async notify(input) {
3058
+ return this.request("/v1/notify", "POST", input);
3059
+ }
3060
+ /** Trigger a registered background workflow. */
3061
+ async triggerWorkflow(input) {
3062
+ return this.request("/v1/workflows/trigger", "POST", input);
3063
+ }
3064
+ /** Create a dynamic JSON workflow definition. */
3065
+ async createWorkflow(input) {
3066
+ return this.request("/v1/workflows", "POST", input);
3067
+ }
3068
+ /** Ingest an external event into the system to resume workflows or trigger automations. */
3069
+ async ingestEvent(input) {
3070
+ return this.request("/v1/events", "POST", input);
3071
+ }
3072
+ /** Sync templates configured on the client options to the server. */
3073
+ async sync() {
3074
+ if (!this.options.templates || this.options.templates.length === 0) return { synced: 0 };
3075
+ return this.syncTemplates({ templates: this.options.templates });
3076
+ }
3077
+ /** List registered workflow definitions. */
3078
+ async listWorkflows() {
3079
+ return this.request("/v1/workflows", "GET");
3080
+ }
3081
+ /** Get a workflow instance by ID. */
3082
+ async getWorkflow(instanceId) {
3083
+ return this.request(`/v1/workflows/instances/${instanceId}`, "GET");
3084
+ }
3085
+ /** Cancel a running/suspended workflow instance. */
3086
+ async cancelWorkflow(instanceId) {
3087
+ return this.request(`/v1/workflows/instances/${instanceId}`, "DELETE");
3088
+ }
3089
+ /** Get notification logs for the project. */
3090
+ async getNotificationLogs(options) {
3091
+ let url = "/v1/notifications/logs";
3092
+ if (options) {
3093
+ const params = new URLSearchParams();
3094
+ if (options.limit !== void 0) params.append("limit", options.limit.toString());
3095
+ if (options.cursor) params.append("cursor", options.cursor);
3096
+ if (options.templateId) params.append("templateId", options.templateId);
3097
+ if (options.workflowInstanceId) params.append("workflowInstanceId", options.workflowInstanceId);
3098
+ if (options.channel) params.append("channel", options.channel);
3099
+ if (options.status) params.append("status", options.status);
3100
+ const str = params.toString();
3101
+ if (str) url += `?${str}`;
3102
+ }
3103
+ return this.request(url, "GET");
3104
+ }
3105
+ /** List/paginate users. */
3106
+ async listUsers(options) {
3107
+ const params = new URLSearchParams();
3108
+ if (options?.limit) params.set("limit", options.limit.toString());
3109
+ if (options?.cursor) params.set("cursor", options.cursor);
3110
+ const qs = params.toString();
3111
+ return this.request(`/v1/users${qs ? `?${qs}` : ""}`, "GET");
3112
+ }
3113
+ /** Delete a template. */
3114
+ async deleteTemplate(id) {
3115
+ return this.request(`/v1/templates/${id}`, "DELETE");
3116
+ }
3117
+ /** Get a user's contacts. */
3118
+ async getUserContacts(userId) {
3119
+ return this.request(`/v1/users/${userId}/contacts`, "GET");
3120
+ }
3121
+ /** List projects (Admin only). */
3122
+ async listProjects() {
3123
+ return this.request("/v1/projects", "GET");
3124
+ }
3125
+ /** Delete a project (Admin only). */
3126
+ async deleteProject(id) {
3127
+ return this.request(`/v1/projects/${id}`, "DELETE");
3128
+ }
3129
+ /** Create a new project API key (Admin only). */
3130
+ async createProjectKey(id, input) {
3131
+ return this.request(`/v1/projects/${id}/keys`, "POST", input || {});
3132
+ }
3133
+ /** List project API keys (Admin only). */
3134
+ async listProjectKeys(id) {
3135
+ return this.request(`/v1/projects/${id}/keys`, "GET");
3136
+ }
3137
+ /** Delete a project API key (Admin only). */
3138
+ async deleteProjectKey(id, keyId) {
3139
+ return this.request(`/v1/projects/${id}/keys/${keyId}`, "DELETE");
3140
+ }
3141
+ /** Update project settings (Admin only). */
3142
+ async updateProject(id, input) {
3143
+ return this.request(`/v1/projects/${id}`, "PATCH", input);
3144
+ }
3145
+ /** List unique segment tags. */
3146
+ async listSegments() {
3147
+ return this.request("/v1/segments", "GET");
3148
+ }
3149
+ /** List campaign labels seen in the delivery log, most recent activity first. */
3150
+ async listCampaigns(options) {
3151
+ const qs = options?.limit ? `?limit=${options.limit}` : "";
3152
+ return this.request(`/v1/campaigns${qs}`, "GET");
3153
+ }
3154
+ /** Delivery and engagement funnel for one campaign. */
3155
+ async getCampaignStats(campaign) {
3156
+ return this.request(`/v1/campaigns/${encodeURIComponent(campaign)}/stats`, "GET");
3157
+ }
3158
+ /** List suppressed destinations. */
3159
+ async listSuppressions(options) {
3160
+ const params = new URLSearchParams();
3161
+ if (options?.limit) params.set("limit", options.limit.toString());
3162
+ if (options?.channel) params.set("channel", options.channel);
3163
+ if (options?.reason) params.set("reason", options.reason);
3164
+ const qs = params.toString();
3165
+ return this.request(`/v1/suppressions${qs ? `?${qs}` : ""}`, "GET");
3166
+ }
3167
+ /** Suppress a destination by hand. */
3168
+ async createSuppression(input) {
3169
+ return this.request("/v1/suppressions", "POST", input);
3170
+ }
3171
+ /** Remove a suppression, re-enabling sends to that destination. */
3172
+ async deleteSuppression(channel, target) {
3173
+ return this.request(`/v1/suppressions/${encodeURIComponent(channel)}/${encodeURIComponent(target)}`, "DELETE");
3174
+ }
3175
+ /** Get real-time status and delivery logs for a specific notification task. */
3176
+ async getNotificationStatus(taskId) {
3177
+ return this.request(`/v1/notifications/${encodeURIComponent(taskId)}`, "GET");
3178
+ }
3179
+ /** Cancel a scheduled notification task. */
3180
+ async cancelNotification(taskId) {
3181
+ return this.request(`/v1/notifications/${encodeURIComponent(taskId)}`, "DELETE");
3182
+ }
3183
+ /** List pending scheduled messages. */
3184
+ async getScheduledMessages() {
3185
+ return this.request("/v1/notifications/scheduled", "GET");
3186
+ }
3187
+ /** Get user profile and contacts by ID. */
3188
+ async getUser(id) {
3189
+ return this.request(`/v1/users/${encodeURIComponent(id)}`, "GET");
3190
+ }
3191
+ /** Get user details including contacts and recent message logs. */
3192
+ async getUserDetails(id) {
3193
+ return this.request(`/v1/users/${encodeURIComponent(id)}/details`, "GET");
3194
+ }
3195
+ /** Get user preferences. */
3196
+ async getUserPreferences(id) {
3197
+ return this.request(`/v1/users/${encodeURIComponent(id)}/preferences`, "GET");
3198
+ }
3199
+ /** Update user preferences. */
3200
+ async updateUserPreferences(id, preferences) {
3201
+ return this.request(`/v1/users/${encodeURIComponent(id)}/preferences`, "PATCH", preferences);
3202
+ }
3203
+ /** List all templates for the project. */
3204
+ async listTemplates() {
3205
+ return this.request("/v1/templates", "GET");
3206
+ }
3207
+ /** Get a template by ID. */
3208
+ async getTemplate(id) {
3209
+ return this.request(`/v1/templates/${encodeURIComponent(id)}`, "GET");
3210
+ }
3211
+ /** Get system health and worker status. */
3212
+ async getSystemHealth() {
3213
+ return this.request("/v1/system/health", "GET");
3214
+ }
3215
+ /** Get system metrics and queue lengths. */
3216
+ async getSystemMetrics() {
3217
+ return this.request("/v1/system/metrics", "GET");
3218
+ }
3219
+ /** Get dead-letter queue messages. */
3220
+ async getDLQMessages() {
3221
+ return this.request("/v1/dlq", "GET");
3222
+ }
3223
+ /** Replay a dead-letter queue message. */
3224
+ async replayDLQMessage(id) {
3225
+ return this.request("/v1/dlq/replay", "POST", { id });
3226
+ }
3227
+ /** Delete a dead-letter queue message. */
3228
+ async deleteDLQMessage(id) {
3229
+ return this.request(`/v1/dlq/${encodeURIComponent(id)}`, "DELETE");
3230
+ }
3231
+ };
3232
+ //#endregion
3233
+ //#region src/server.ts
3234
+ var NotifkitServer = class extends EventEmitter {
3235
+ options;
3236
+ pgContainer = null;
3237
+ redisContainer = null;
3238
+ eventCleanupFns = [];
3239
+ signalHandlersAttached = false;
3240
+ logger;
3241
+ constructor(options) {
3242
+ super();
3243
+ this.options = options;
3244
+ this.logger = createLogger({
3245
+ name: "server",
3246
+ level: options.logLevel || process.env.LOG_LEVEL || "info"
3247
+ });
3248
+ for (const name of [
3249
+ "delivery:delivered",
3250
+ "delivery:failed",
3251
+ "notification:throttled",
3252
+ "notification:failed",
3253
+ "notification:skipped",
3254
+ "notification:canceled"
3255
+ ]) {
3256
+ const listener = (...args) => {
3257
+ this.emit(name, ...args);
3258
+ };
3259
+ globalEmitter.on(name, listener);
3260
+ this.eventCleanupFns.push(() => {
3261
+ globalEmitter.off(name, listener);
3262
+ });
3263
+ }
3264
+ }
3265
+ async start() {
3266
+ const { setGlobalConfig, readBaseConfig } = await Promise.resolve().then(() => config_exports);
3267
+ if (this.options.port) process.env.PORT = String(this.options.port);
3268
+ if (this.options.logLevel) process.env.LOG_LEVEL = this.options.logLevel;
3269
+ if (this.options.nodeEnv) process.env.NODE_ENV = this.options.nodeEnv;
3270
+ if (this.options.workerConcurrency) process.env.WORKER_CONCURRENCY = String(this.options.workerConcurrency);
3271
+ if (this.options.redisOptions?.maxQueueLength) process.env.QUEUE_MAX_LEN = String(this.options.redisOptions.maxQueueLength);
3272
+ if (this.options.dbOptions?.maxConnections) process.env.DB_MAX_CONNECTIONS = String(this.options.dbOptions.maxConnections);
3273
+ const isProduction = process.env.NODE_ENV === "production";
3274
+ if (!this.options.redisUrl) if (process.env.REDIS_URL) this.options.redisUrl = process.env.REDIS_URL;
3275
+ else if (!isProduction) {
3276
+ this.logger.info("No redisUrl provided, spinning up Redis container for development/testing...");
3277
+ const { RedisContainer } = await import("@testcontainers/redis");
3278
+ this.redisContainer = await new RedisContainer("redis:alpine").start();
3279
+ this.options.redisUrl = this.redisContainer.getConnectionUrl();
3280
+ } else throw new Error("Missing required configuration: REDIS_URL must be provided when running in production mode.");
3281
+ if (!this.options.databaseUrl) if (process.env.DATABASE_URL) this.options.databaseUrl = process.env.DATABASE_URL;
3282
+ else if (!isProduction) {
3283
+ this.logger.info("No databaseUrl provided, spinning up PostgreSQL container for development/testing...");
3284
+ const { PostgreSqlContainer } = await import("@testcontainers/postgresql");
3285
+ this.pgContainer = await new PostgreSqlContainer("postgres:15-alpine").start();
3286
+ this.options.databaseUrl = this.pgContainer.getConnectionUri();
3287
+ } else throw new Error("Missing required configuration: DATABASE_URL must be provided when running in production mode.");
3288
+ if (this.options.redisUrl) process.env.REDIS_URL = this.options.redisUrl;
3289
+ if (this.options.databaseUrl) process.env.DATABASE_URL = this.options.databaseUrl;
3290
+ setGlobalConfig(readBaseConfig());
3291
+ if (this.options.aiModel) {
3292
+ const { setAiConfig } = await Promise.resolve().then(() => config_exports);
3293
+ setAiConfig({ aiModel: this.options.aiModel });
3294
+ }
3295
+ if (this.options.autoMigrate !== false) {
3296
+ this.logger.info("Running database migrations...");
3297
+ const { createDatabase, runMigrations } = await Promise.resolve().then(() => db_exports);
3298
+ const { db, sql } = createDatabase({ url: this.options.databaseUrl });
3299
+ await runMigrations(db);
3300
+ await sql.end();
3301
+ this.logger.info("Database migrations complete");
3302
+ }
3303
+ const services = this.options.services.includes("all") ? [
3304
+ "api",
3305
+ "delivery",
3306
+ "engine",
3307
+ "enricher",
3308
+ "scheduler",
3309
+ "ai",
3310
+ "workflow",
3311
+ "events"
3312
+ ] : this.options.services;
3313
+ if (this.options.providers) {
3314
+ for (const provider of this.options.providers) registerTransport(provider);
3315
+ this.logger.info(`Registered ${this.options.providers.length} custom providers`);
3316
+ }
3317
+ const startupPromises = [];
3318
+ if (services.includes("api")) {
3319
+ const { startApiServer } = await import("./main-Ok9cQJ7q.mjs");
3320
+ startupPromises.push(startApiServer());
3321
+ }
3322
+ if (services.includes("delivery")) {
3323
+ const { startDeliveryWorker } = await import("./main-ClEeP5qw.mjs");
3324
+ startupPromises.push(startDeliveryWorker());
3325
+ }
3326
+ if (services.includes("engine")) {
3327
+ const { startEngineWorker } = await import("./main-Dlfy9mWs.mjs");
3328
+ startupPromises.push(startEngineWorker());
3329
+ }
3330
+ if (services.includes("enricher")) {
3331
+ const { startEnricherWorker } = await import("./main-BIcKzWHE.mjs");
3332
+ startupPromises.push(startEnricherWorker());
3333
+ }
3334
+ if (services.includes("scheduler")) {
3335
+ const { startSchedulerWorker } = await import("./main-BHYZfBBq.mjs");
3336
+ startupPromises.push(startSchedulerWorker());
3337
+ }
3338
+ if (services.includes("ai")) {
3339
+ const { startAiWorker } = await import("./main-Dztc2dqR.mjs");
3340
+ startupPromises.push(startAiWorker());
3341
+ }
3342
+ if (services.includes("workflow")) {
3343
+ const { startWorkflowWorker } = await import("./main-4H6vNXvy.mjs");
3344
+ startupPromises.push(startWorkflowWorker());
3345
+ }
3346
+ if (services.includes("events")) {
3347
+ const { startEventWorker } = await import("./main-D-oWWzR3.mjs");
3348
+ startupPromises.push(startEventWorker());
3349
+ }
3350
+ const handleSignal = async (signal) => {
3351
+ this.logger.info(`Received ${signal}, starting graceful shutdown...`);
3352
+ await this.stop();
3353
+ process.exit(0);
3354
+ };
3355
+ if (!this.signalHandlersAttached) {
3356
+ process.once("SIGINT", () => {
3357
+ handleSignal("SIGINT");
3358
+ });
3359
+ process.once("SIGTERM", () => {
3360
+ handleSignal("SIGTERM");
3361
+ });
3362
+ this.signalHandlersAttached = true;
3363
+ }
3364
+ await Promise.all(startupPromises);
3365
+ }
3366
+ async stop() {
3367
+ for (const cleanup of this.eventCleanupFns) cleanup();
3368
+ this.eventCleanupFns = [];
3369
+ const services = this.options.services.includes("all") ? [
3370
+ "api",
3371
+ "delivery",
3372
+ "engine",
3373
+ "enricher",
3374
+ "scheduler",
3375
+ "ai",
3376
+ "workflow",
3377
+ "events"
3378
+ ] : this.options.services;
3379
+ if (services.includes("api")) {
3380
+ const { stopApiServer } = await import("./main-Ok9cQJ7q.mjs");
3381
+ await stopApiServer();
3382
+ }
3383
+ if (services.includes("delivery")) {
3384
+ const { stopDeliveryWorker } = await import("./main-ClEeP5qw.mjs");
3385
+ await stopDeliveryWorker();
3386
+ }
3387
+ if (services.includes("engine")) {
3388
+ const { stopEngineWorker } = await import("./main-Dlfy9mWs.mjs");
3389
+ await stopEngineWorker();
3390
+ }
3391
+ if (services.includes("enricher")) {
3392
+ const { stopEnricherWorker } = await import("./main-BIcKzWHE.mjs");
3393
+ await stopEnricherWorker();
3394
+ }
3395
+ if (services.includes("scheduler")) {
3396
+ const { stopSchedulerWorker } = await import("./main-BHYZfBBq.mjs");
3397
+ await stopSchedulerWorker();
3398
+ }
3399
+ if (services.includes("ai")) {
3400
+ const { stopAiWorker } = await import("./main-Dztc2dqR.mjs");
3401
+ await stopAiWorker();
3402
+ }
3403
+ if (services.includes("workflow")) {
3404
+ const { stopWorkflowWorker } = await import("./main-4H6vNXvy.mjs");
3405
+ await stopWorkflowWorker();
3406
+ }
3407
+ if (services.includes("events")) {
3408
+ const { stopEventWorker } = await import("./main-D-oWWzR3.mjs");
3409
+ await stopEventWorker();
3410
+ }
3411
+ if (this.pgContainer) {
3412
+ this.logger.info("Stopping PostgreSQL container...");
3413
+ await this.pgContainer.stop();
3414
+ }
3415
+ if (this.redisContainer) {
3416
+ this.logger.info("Stopping Redis container...");
3417
+ await this.redisContainer.stop();
3418
+ }
3419
+ }
3420
+ };
3421
+ //#endregion
3422
+ export { childLogger as $, PUBSUB_CHANNELS as $t, ProjectSettingsCache as A, NotificationRequestedPayloadSchema as At, LUA_RELEASE_LOCK as B, QuietHoursSchema as Bt, ProjectRepository as C, DeliveryOptionsSchema as Ct, WorkflowRepository as D, NotificationEnrichedPayloadSchema as Dt, UserRepository as E, NotificationScheduledPayloadSchema as Et, sleep as F, CreateWorkflowSchema as Ft, normaliseTarget as G, UpdateUserSchema as Gt, LUA_SCHEDULER_CLAIM as H, TemplateSchema as Ht, DataLoader as I, IngestEventSchema as It, PendingMessageScanner as J, buildStreamEvent as Jt, LRUCache as K, WorkflowNotifyPayloadSchema as Kt, CircuitBreaker as L, InlineUserSchema as Lt, AppError as M, AddContactSchema as Mt, ValidationError as N, AddUserSchema as Nt, Redis as O, RecipientProfileSchema as Ot, generateId as P, ContactChannelSchema as Pt, metrics as Q, OUTBOUND_STREAMS as Qt, BatchProcessor as R, NotifyRequestSchema as Rt, PreferenceRepository as S, NotificationDeliveredPayloadSchema as St, TemplateRepository as T, RenderedContentSchema as Tt, LUA_SCHEDULER_POLL as U, TriggerWorkflowSchema as Ut, LUA_RENEW_LOCK as V, SyncTemplatesSchema as Vt, getPriorityBucket as W, UpdateProjectSchema as Wt, StreamProducer as X, ENRICHED_STREAMS as Xt, StreamConsumer as Y, CONSUMER_GROUPS as Yt, getMetricsRegistry as Z, INBOUND_STREAMS as Zt, escapeHeader as _, setGlobalConfig as _n, workflowWaiters as _t, SuspendExecutionError as a, registry as an, runMigrations as at, renderWithTemplate as b, NotificationSkippedPayloadSchema as bt, BaseWorker as c, NotificationPrioritySchema as cn, projectApiKeys as ct, buildUnsubscribeHeaders as d, baseConfigSchema as dn, suppressions as dt, STREAMS as en, createLogger as et, signUnsubscribeToken as f, getAiConfig as fn, userTopicPreferences as ft, TemplateCache as g, setAiConfig as gn, workflowSteps as gt, templateRegistry as h, readBaseConfig as hn, workflowInstances as ht, workflowRegistry as i, EventRegistry as in, createDatabase as it, UserThrottle as j, NotificationTargetSchema as jt, RedisClient as k, NotificationCreatedPayloadSchema as kt, NonRetryableError as l, NotificationStatusSchema as ln, projects as lt, renderTemplate as m, parseConfig as mn, workflowDefinitions as mt, NotifkitClient as n, StreamEventMetadataSchema as nn, withRequestId as nt, buildStepNotifyPayload as o, EventMetadataSchema as on, deliveryOutbox as ot, verifyUnsubscribeToken as p, loadEnv as pn, users as pt, globalEmitter as q, WorkflowStepSchema as qt, workflow as r, StreamEventSchema as rn, IdempotencyGuard as rt, resolveStepTarget as s, NotificationChannelSchema as sn, messageLogs as st, NotifkitServer as t, EventEnvelopeSchema as tn, withContext as tt, startHealthReporter as u, AI_DEFAULTS as un, scheduledPayloads as ut, escapeHtml as v, NotificationAiPendingPayloadSchema as vt, SegmentRepository as w, NotificationDispatchedPayloadSchema as wt, ContactRepository as x, NotificationFailedPayloadSchema as xt, interpolate as y, NotificationCanceledPayloadSchema as yt, AsyncSemaphore as z, PreferencesSchema as zt };
3423
+
3424
+ //# sourceMappingURL=src-DrSN2wCg.mjs.map