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,1636 @@
1
+ import { $t as PUBSUB_CHANNELS, C as ProjectRepository, D as WorkflowRepository, E as UserRepository, Ft as CreateWorkflowSchema, G as normaliseTarget, Gt as UpdateUserSchema, Jt as buildStreamEvent, K as LRUCache, Mt as AddContactSchema, Nt as AddUserSchema, Pt as ContactChannelSchema, Q as metrics, Rt as NotifyRequestSchema, T as TemplateRepository, Ut as TriggerWorkflowSchema, Vt as SyncTemplatesSchema, W as getPriorityBucket, X as StreamProducer, Z as getMetricsRegistry, ct as projectApiKeys, dt as suppressions, en as STREAMS, et as createLogger, ft as userTopicPreferences, hn as readBaseConfig, it as createDatabase, k as RedisClient, lt as projects, mt as workflowDefinitions, p as verifyUnsubscribeToken, pn as loadEnv, pt as users, q as globalEmitter, st as messageLogs, ut as scheduledPayloads, w as SegmentRepository, x as ContactRepository, zt as PreferencesSchema } from "./src-DrSN2wCg.mjs";
2
+ import { z } from "zod";
3
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
4
+ import { and, desc, eq, inArray, isNotNull, like, lt, or, sql } from "drizzle-orm";
5
+ import { createServer } from "node:http";
6
+ //#region src/services/api/http.ts
7
+ async function readRawBody(req) {
8
+ return await new Promise((resolve, reject) => {
9
+ const chunks = [];
10
+ let totalLength = 0;
11
+ const MAX_PAYLOAD_SIZE = 5242880;
12
+ req.on("data", (chunk) => {
13
+ totalLength += chunk.length;
14
+ if (totalLength > MAX_PAYLOAD_SIZE) {
15
+ req.destroy();
16
+ reject(new HttpError(413, "payload_too_large"));
17
+ return;
18
+ }
19
+ chunks.push(chunk);
20
+ });
21
+ req.on("end", () => resolve(Buffer.concat(chunks).toString()));
22
+ req.on("error", reject);
23
+ });
24
+ }
25
+ async function readJsonBody(req) {
26
+ const raw = await readRawBody(req);
27
+ if (raw.trim() === "") return void 0;
28
+ try {
29
+ return JSON.parse(raw);
30
+ } catch {
31
+ throw new HttpError(400, "malformed_json");
32
+ }
33
+ }
34
+ function sendJson(res, status, body) {
35
+ const payload = JSON.stringify(body);
36
+ res.writeHead(status, {
37
+ "Content-Type": "application/json",
38
+ "Content-Length": Buffer.byteLength(payload)
39
+ });
40
+ res.end(payload);
41
+ }
42
+ function sendNoContent(res) {
43
+ res.writeHead(204).end();
44
+ }
45
+ /** Turn a Zod error into a 400 response with field-level issues. */
46
+ function sendValidationError(res, error) {
47
+ sendJson(res, 400, {
48
+ error: "validation_error",
49
+ issues: error.issues.map((i) => ({
50
+ path: i.path.join("."),
51
+ message: i.message
52
+ }))
53
+ });
54
+ }
55
+ /** A thrown HttpError short-circuits a handler with a specific status/body. */
56
+ var HttpError = class extends Error {
57
+ status;
58
+ code;
59
+ constructor(status, code, message) {
60
+ super(message ?? code);
61
+ this.status = status;
62
+ this.code = code;
63
+ }
64
+ };
65
+ //#endregion
66
+ //#region src/services/api/router.ts
67
+ /**
68
+ * Minimal path router with `:param` capture — enough for the REST surface here
69
+ * without pulling in a framework. First match wins.
70
+ */
71
+ var Router = class {
72
+ routes = [];
73
+ add(method, pattern, handler) {
74
+ this.routes.push({
75
+ method,
76
+ segments: split(pattern),
77
+ handler
78
+ });
79
+ return this;
80
+ }
81
+ get(p, h) {
82
+ return this.add("GET", p, h);
83
+ }
84
+ post(p, h) {
85
+ return this.add("POST", p, h);
86
+ }
87
+ patch(p, h) {
88
+ return this.add("PATCH", p, h);
89
+ }
90
+ put(p, h) {
91
+ return this.add("PUT", p, h);
92
+ }
93
+ delete(p, h) {
94
+ return this.add("DELETE", p, h);
95
+ }
96
+ /** Returns the matched handler + captured params, or null. */
97
+ match(method, pathname) {
98
+ const parts = split(pathname);
99
+ for (const route of this.routes) {
100
+ if (route.method !== method) continue;
101
+ if (route.segments.length !== parts.length) continue;
102
+ const params = {};
103
+ let ok = true;
104
+ for (let i = 0; i < route.segments.length; i++) {
105
+ const seg = route.segments[i];
106
+ const part = parts[i];
107
+ if (seg.startsWith(":")) params[seg.slice(1)] = decodeURIComponent(part);
108
+ else if (seg !== part) {
109
+ ok = false;
110
+ break;
111
+ }
112
+ }
113
+ if (ok) return {
114
+ handler: route.handler,
115
+ params
116
+ };
117
+ }
118
+ return null;
119
+ }
120
+ };
121
+ function split(path) {
122
+ return path.split("/").filter((s) => s.length > 0);
123
+ }
124
+ //#endregion
125
+ //#region src/services/api/handlers.ts
126
+ const IngestEventSchema = z.object({
127
+ name: z.string().min(1),
128
+ properties: z.record(z.string(), z.unknown())
129
+ });
130
+ /** Map an inline user's contact arrays into (channel, target) pairs. */
131
+ function contactsOf(user) {
132
+ return [
133
+ ...(user.email ?? []).map((target) => ({
134
+ channel: "email",
135
+ target
136
+ })),
137
+ ...(user.phone ?? []).map((target) => ({
138
+ channel: "sms",
139
+ target
140
+ })),
141
+ ...(user.pushToken ?? []).map((target) => ({
142
+ channel: "push",
143
+ target
144
+ }))
145
+ ];
146
+ }
147
+ /** Persist user records + their contacts in bulk. Shared by addUser and inline notify. */
148
+ async function persistUsers(deps, users, projectId) {
149
+ const usersList = users.map((u) => ({
150
+ userId: u.id,
151
+ language: u.language ?? "en",
152
+ timezone: u.timezone ?? "UTC",
153
+ email: u.email?.[0] ?? null,
154
+ segments: u.segments ?? [],
155
+ preferences: u.preferences ?? {}
156
+ }));
157
+ const contactsList = [];
158
+ for (const u of users) for (const c of contactsOf(u)) contactsList.push({
159
+ userId: u.id,
160
+ channel: c.channel,
161
+ target: c.target,
162
+ preferences: {}
163
+ });
164
+ await deps.db.transaction(async (tx) => {
165
+ const txUserRepo = deps.userRepo.constructor.name === "Object" ? deps.userRepo : new deps.userRepo.constructor(tx);
166
+ const txContactRepo = deps.contactRepo.constructor.name === "Object" ? deps.contactRepo : new deps.contactRepo.constructor(tx);
167
+ await txUserRepo.upsertManyFull(projectId, usersList);
168
+ await txContactRepo.upsertMany(projectId, contactsList);
169
+ });
170
+ }
171
+ function getQueryParam(ctx, key) {
172
+ if (!ctx.query) return void 0;
173
+ if (typeof ctx.query.get === "function") return ctx.query.get(key) ?? void 0;
174
+ return ctx.query[key] ?? void 0;
175
+ }
176
+ function createHandlers(deps) {
177
+ const { logger } = deps;
178
+ async function syncTemplates(req, res, ctx) {
179
+ const parsed = SyncTemplatesSchema.safeParse(await readJsonBody(req));
180
+ if (!parsed.success) return sendValidationError(res, parsed.error);
181
+ const synced = await deps.templateRepo.upsertMany(ctx.projectId, parsed.data.templates.map((t) => ({
182
+ id: t.id,
183
+ channel: t.channel,
184
+ topics: t.topic ?? [],
185
+ content: t.content,
186
+ aiPrompts: t.aiPrompts
187
+ })));
188
+ for (const t of parsed.data.templates) await deps.redis.native.publish("template.invalidated", `${ctx.projectId}:${t.id}`);
189
+ logger.info({ synced }, "templates synced");
190
+ sendJson(res, 200, { synced });
191
+ }
192
+ async function addUser(req, res, ctx) {
193
+ const parsed = AddUserSchema.safeParse(await readJsonBody(req));
194
+ if (!parsed.success) return sendValidationError(res, parsed.error);
195
+ await persistUsers(deps, [parsed.data], ctx.projectId);
196
+ logger.info({ userId: parsed.data.id }, "user upserted");
197
+ sendJson(res, 201, { id: parsed.data.id });
198
+ }
199
+ async function updateUser(req, res, ctx) {
200
+ const userId = ctx.params.id;
201
+ const parsed = UpdateUserSchema.safeParse(await readJsonBody(req));
202
+ if (!parsed.success) return sendValidationError(res, parsed.error);
203
+ const patch = parsed.data;
204
+ if (!await deps.userRepo.updatePartial(ctx.projectId, userId, {
205
+ language: patch.language,
206
+ timezone: patch.timezone,
207
+ email: patch.email?.[0],
208
+ segments: patch.segments,
209
+ preferences: patch.preferences
210
+ })) return sendJson(res, 404, {
211
+ error: "user_not_found",
212
+ id: userId
213
+ });
214
+ for (const c of contactsOf({
215
+ id: userId,
216
+ ...patch
217
+ })) await deps.contactRepo.upsert(ctx.projectId, userId, c.channel, c.target);
218
+ logger.info({ userId }, "user updated");
219
+ sendJson(res, 200, { id: userId });
220
+ }
221
+ async function deleteUser(_req, res, ctx) {
222
+ const userId = ctx.params.id;
223
+ if (!await deps.userRepo.delete(ctx.projectId, userId)) return sendJson(res, 404, {
224
+ error: "user_not_found",
225
+ id: userId
226
+ });
227
+ logger.info({ userId }, "user deleted");
228
+ sendNoContent(res);
229
+ }
230
+ async function addContact(req, res, ctx) {
231
+ const userId = ctx.params.id;
232
+ const parsed = AddContactSchema.safeParse(await readJsonBody(req));
233
+ if (!parsed.success) return sendValidationError(res, parsed.error);
234
+ if (!await deps.userRepo.findById(ctx.projectId, userId)) return sendJson(res, 404, {
235
+ error: "user_not_found",
236
+ id: userId
237
+ });
238
+ await deps.contactRepo.upsert(ctx.projectId, userId, parsed.data.channel, parsed.data.target, parsed.data.preferences ?? {});
239
+ logger.info({
240
+ userId,
241
+ channel: parsed.data.channel
242
+ }, "contact added");
243
+ sendJson(res, 201, {
244
+ userId,
245
+ channel: parsed.data.channel,
246
+ target: parsed.data.target
247
+ });
248
+ }
249
+ async function deleteContact(_req, res, ctx) {
250
+ const userId = ctx.params.id;
251
+ const channelResult = ContactChannelSchema.safeParse(ctx.params.channel);
252
+ if (!channelResult.success) return sendJson(res, 400, {
253
+ error: "invalid_channel",
254
+ channel: ctx.params.channel
255
+ });
256
+ if (!await deps.contactRepo.delete(ctx.projectId, userId, channelResult.data, ctx.params.target)) return sendJson(res, 404, { error: "contact_not_found" });
257
+ logger.info({
258
+ userId,
259
+ channel: channelResult.data
260
+ }, "contact deleted");
261
+ sendNoContent(res);
262
+ }
263
+ async function notify(req, res, ctx) {
264
+ const parsed = NotifyRequestSchema.safeParse(await readJsonBody(req));
265
+ if (!parsed.success) return sendValidationError(res, parsed.error);
266
+ const body = parsed.data;
267
+ let targets = [];
268
+ if (body.user !== void 0) {
269
+ const users = Array.isArray(body.user) ? body.user : [body.user];
270
+ const inlineUsersToSync = [];
271
+ for (const u of users) if (typeof u === "string") targets.push({
272
+ type: "user",
273
+ userId: u
274
+ });
275
+ else {
276
+ inlineUsersToSync.push(u);
277
+ targets.push({
278
+ type: "user",
279
+ userId: u.id
280
+ });
281
+ }
282
+ if (inlineUsersToSync.length > 0) try {
283
+ await persistUsers(deps, inlineUsersToSync, ctx.projectId);
284
+ } catch (err) {
285
+ deps.logger.error({ err }, "inline user bulk sync failed");
286
+ }
287
+ } else if (body.segment !== void 0) targets.push({
288
+ type: "segment",
289
+ segment: body.segment
290
+ });
291
+ else targets.push({
292
+ type: "topic",
293
+ topic: body.topic
294
+ });
295
+ const baseNotificationId = req.headers["x-idempotency-key"] || randomUUID();
296
+ const priority = body.priority ?? "normal";
297
+ const p = getPriorityBucket(priority);
298
+ const producer = deps.producers[p] ?? deps.producers.normal;
299
+ const messageIds = [];
300
+ let lastNotificationId = "";
301
+ const CHUNK_SIZE = 1e3;
302
+ for (let chunkStart = 0; chunkStart < targets.length; chunkStart += CHUNK_SIZE) {
303
+ const events = targets.slice(chunkStart, chunkStart + CHUNK_SIZE).map((target, idx) => {
304
+ const i = chunkStart + idx;
305
+ const notificationId = targets.length > 1 ? `${baseNotificationId}-${i}` : baseNotificationId;
306
+ lastNotificationId = notificationId;
307
+ const payload = {
308
+ projectId: ctx.projectId,
309
+ target,
310
+ templateId: body.template,
311
+ priority,
312
+ channels: body.channels,
313
+ data: body.data ?? {},
314
+ aiPrompts: body.aiPrompts,
315
+ fallback: body.fallback ?? false,
316
+ scheduledAt: body.sendAt,
317
+ idempotencyKey: notificationId,
318
+ campaignId: body.campaign
319
+ };
320
+ return buildStreamEvent("notification.requested", payload, "api", notificationId);
321
+ });
322
+ const { messageIds: chunkMessageIds } = await producer.publishBatch(events);
323
+ messageIds.push(...chunkMessageIds);
324
+ }
325
+ logger.info({
326
+ count: targets.length,
327
+ priority: p
328
+ }, "notification requested");
329
+ metrics.messagesPublished.inc({
330
+ channel: "api",
331
+ priority: p
332
+ }, targets.length);
333
+ if (targets.length === 1) sendJson(res, 202, {
334
+ messageId: messageIds[0],
335
+ notificationId: lastNotificationId,
336
+ target: targets[0],
337
+ ...body.campaign ? { campaign: body.campaign } : {}
338
+ });
339
+ else sendJson(res, 202, {
340
+ messageIds,
341
+ notificationIdsBase: baseNotificationId,
342
+ batchSize: targets.length,
343
+ ...body.campaign ? { campaign: body.campaign } : {}
344
+ });
345
+ }
346
+ async function cancelNotification(req, res, ctx) {
347
+ const { taskId } = ctx.params;
348
+ if (!taskId) return sendJson(res, 400, { error: "missing_task_id" });
349
+ const rows = await deps.db.select({ payload: scheduledPayloads.payload }).from(scheduledPayloads).where(eq(scheduledPayloads.taskId, taskId)).limit(1);
350
+ if (rows.length === 0) return sendJson(res, 404, {
351
+ error: "not_found",
352
+ message: "Task not found or already processed"
353
+ });
354
+ if (rows[0].payload.projectId !== ctx.projectId) return sendJson(res, 404, {
355
+ error: "not_found",
356
+ message: "Task not found or already processed"
357
+ });
358
+ if ((await deps.db.delete(scheduledPayloads).where(eq(scheduledPayloads.taskId, taskId)).returning({ taskId: scheduledPayloads.taskId })).length > 0) {
359
+ globalEmitter.emit("notification:canceled", {
360
+ projectId: ctx.projectId,
361
+ taskId
362
+ });
363
+ sendJson(res, 200, { success: true });
364
+ } else sendJson(res, 404, {
365
+ error: "not_found",
366
+ message: "Task not found or already processed"
367
+ });
368
+ }
369
+ async function triggerWorkflow(req, res, ctx) {
370
+ const parsed = TriggerWorkflowSchema.safeParse(await readJsonBody(req));
371
+ if (!parsed.success) return sendValidationError(res, parsed.error);
372
+ const input = { ...parsed.data.input ?? {} };
373
+ if (parsed.data.user) if (typeof parsed.data.user === "string") input.user = {
374
+ id: parsed.data.user,
375
+ ...typeof input.user === "object" && input.user ? input.user : {}
376
+ };
377
+ else {
378
+ try {
379
+ await persistUsers(deps, [parsed.data.user], ctx.projectId);
380
+ } catch (err) {
381
+ deps.logger.error({ err }, "inline user sync for workflow failed");
382
+ }
383
+ input.user = {
384
+ id: parsed.data.user.id,
385
+ ...typeof input.user === "object" && input.user ? input.user : {}
386
+ };
387
+ }
388
+ const instanceId = randomUUID();
389
+ const messageId = await deps.producers.workflow.publish(buildStreamEvent("workflow.triggered", {
390
+ projectId: ctx.projectId,
391
+ instanceId,
392
+ name: parsed.data.name,
393
+ input
394
+ }, "api", instanceId));
395
+ logger.info({
396
+ messageId,
397
+ instanceId,
398
+ workflowName: parsed.data.name
399
+ }, "workflow triggered");
400
+ sendJson(res, 202, {
401
+ messageId,
402
+ instanceId
403
+ });
404
+ }
405
+ async function ingestEvent(req, res, ctx) {
406
+ const tsStr = req.headers["x-timestamp"];
407
+ const expiryStr = req.headers["x-expiry"];
408
+ if (tsStr && expiryStr) {
409
+ if (Date.now() > new Date(tsStr).getTime() + parseInt(expiryStr, 10) * 1e3) {
410
+ sendJson(res, 400, {
411
+ error: "event_expired",
412
+ message: "Webhook event is expired"
413
+ });
414
+ return;
415
+ }
416
+ }
417
+ const parsed = IngestEventSchema.safeParse(await readJsonBody(req));
418
+ if (!parsed.success) return sendValidationError(res, parsed.error);
419
+ const eventId = randomUUID();
420
+ const messageId = await deps.producers.events.publish(buildStreamEvent("event.received", {
421
+ projectId: ctx.projectId,
422
+ eventName: parsed.data.name,
423
+ payload: parsed.data.properties
424
+ }, "api", eventId));
425
+ logger.info({
426
+ messageId,
427
+ eventId,
428
+ eventName: parsed.data.name
429
+ }, "event received");
430
+ sendJson(res, 202, {
431
+ messageId,
432
+ eventId
433
+ });
434
+ }
435
+ async function getEventsStream(req, res, ctx) {
436
+ res.writeHead(200, {
437
+ "Content-Type": "text/event-stream",
438
+ "Cache-Control": "no-cache",
439
+ Connection: "keep-alive"
440
+ });
441
+ res.write("retry: 10000\n\n");
442
+ const onDelivered = (taskId, providerMessageId, channel, eventProjectId) => {
443
+ if (eventProjectId && eventProjectId !== ctx.projectId) return;
444
+ res.write(`event: delivery:delivered\n`);
445
+ res.write(`data: ${JSON.stringify({
446
+ taskId,
447
+ providerMessageId,
448
+ channel
449
+ })}\n\n`);
450
+ };
451
+ const onFailed = (taskId, error, channel, eventProjectId) => {
452
+ if (eventProjectId && eventProjectId !== ctx.projectId) return;
453
+ res.write(`event: delivery:failed\n`);
454
+ res.write(`data: ${JSON.stringify({
455
+ taskId,
456
+ error,
457
+ channel
458
+ })}\n\n`);
459
+ };
460
+ globalEmitter.on("delivery:delivered", onDelivered);
461
+ globalEmitter.on("delivery:failed", onFailed);
462
+ req.on("close", () => {
463
+ globalEmitter.off("delivery:delivered", onDelivered);
464
+ globalEmitter.off("delivery:failed", onFailed);
465
+ });
466
+ }
467
+ async function getNotificationLogs(req, res, ctx) {
468
+ const limitParam = parseInt(ctx.query.get("limit") || "50", 10);
469
+ const limit = isNaN(limitParam) ? 50 : Math.min(limitParam, 100);
470
+ const cursor = ctx.query.get("cursor");
471
+ const templateId = ctx.query.get("templateId");
472
+ const workflowInstanceId = ctx.query.get("workflowInstanceId");
473
+ const channel = ctx.query.get("channel");
474
+ const status = ctx.query.get("status");
475
+ const taskId = ctx.query.get("taskId");
476
+ const search = ctx.query.get("search");
477
+ const conditions = [eq(messageLogs.projectId, ctx.projectId)];
478
+ if (templateId) conditions.push(eq(messageLogs.templateId, templateId));
479
+ if (workflowInstanceId) conditions.push(eq(messageLogs.workflowInstanceId, workflowInstanceId));
480
+ if (channel) conditions.push(eq(messageLogs.channel, channel));
481
+ if (status) conditions.push(eq(messageLogs.status, status));
482
+ if (taskId) conditions.push(eq(messageLogs.taskId, taskId));
483
+ if (search) conditions.push(or(like(messageLogs.taskId, `%${search}%`), like(messageLogs.templateId, `%${search}%`), like(messageLogs.providerMessageId, `%${search}%`)));
484
+ if (cursor) {
485
+ const cursorDate = new Date(parseInt(cursor, 10));
486
+ conditions.push(lt(messageLogs.timestamp, cursorDate));
487
+ }
488
+ const logs = await deps.db.select().from(messageLogs).where(and(...conditions)).orderBy(desc(messageLogs.timestamp)).limit(limit * 2);
489
+ const deduplicatedMap = /* @__PURE__ */ new Map();
490
+ for (const log of logs) if (!deduplicatedMap.has(log.taskId)) deduplicatedMap.set(log.taskId, log);
491
+ else if (deduplicatedMap.get(log.taskId).status === "dispatched" && log.status !== "dispatched") deduplicatedMap.set(log.taskId, log);
492
+ sendJson(res, 200, {
493
+ logs: Array.from(deduplicatedMap.values()).slice(0, limit),
494
+ nextCursor: logs.length === limit * 2 ? logs[logs.length - 1].timestamp.getTime().toString() : null
495
+ });
496
+ }
497
+ async function getNotificationStatus(req, res, ctx) {
498
+ const { taskId } = ctx.params;
499
+ if (!taskId) return sendJson(res, 400, { error: "missing_task_id" });
500
+ const logs = await deps.db.select().from(messageLogs).where(and(eq(messageLogs.taskId, taskId), eq(messageLogs.projectId, ctx.projectId))).orderBy(desc(messageLogs.timestamp));
501
+ if (logs.length === 0) return sendJson(res, 404, {
502
+ error: "not_found",
503
+ message: "Task not found or not processed yet"
504
+ });
505
+ sendJson(res, 200, {
506
+ status: logs[0]?.status,
507
+ logs
508
+ });
509
+ }
510
+ async function getUser(req, res, ctx) {
511
+ const userId = ctx.params.id;
512
+ const user = await deps.userRepo.findRecordById(ctx.projectId, userId);
513
+ if (!user) return sendJson(res, 404, {
514
+ error: "user_not_found",
515
+ id: userId
516
+ });
517
+ const contacts = await deps.contactRepo.findByUserId(ctx.projectId, userId);
518
+ sendJson(res, 200, {
519
+ ...user,
520
+ contacts
521
+ });
522
+ }
523
+ async function getUserPreferences(req, res, ctx) {
524
+ const userId = ctx.params.id;
525
+ const user = await deps.userRepo.findRecordById(ctx.projectId, userId);
526
+ if (!user) return sendJson(res, 404, {
527
+ error: "user_not_found",
528
+ id: userId
529
+ });
530
+ sendJson(res, 200, user.preferences);
531
+ }
532
+ async function updateUserPreferences(req, res, ctx) {
533
+ const userId = ctx.params.id;
534
+ const parsed = PreferencesSchema.safeParse(await readJsonBody(req));
535
+ if (!parsed.success) return sendValidationError(res, parsed.error);
536
+ if (!await deps.userRepo.updatePartial(ctx.projectId, userId, { preferences: parsed.data })) return sendJson(res, 404, {
537
+ error: "user_not_found",
538
+ id: userId
539
+ });
540
+ logger.info({ userId }, "user preferences updated");
541
+ sendJson(res, 200, {
542
+ id: userId,
543
+ preferences: parsed.data
544
+ });
545
+ }
546
+ async function createWorkflow(req, res, ctx) {
547
+ const body = await readJsonBody(req);
548
+ const parsed = CreateWorkflowSchema.safeParse(body);
549
+ if (!parsed.success) return sendValidationError(res, parsed.error);
550
+ const input = parsed.data;
551
+ try {
552
+ await deps.db.insert(workflowDefinitions).values({
553
+ projectId: ctx.projectId,
554
+ name: input.name,
555
+ steps: input.steps
556
+ }).onConflictDoUpdate({
557
+ target: [workflowDefinitions.projectId, workflowDefinitions.name],
558
+ set: { steps: input.steps }
559
+ });
560
+ sendJson(res, 201, { name: input.name });
561
+ } catch (error) {
562
+ deps.logger.error({ err: error }, "Failed to create workflow definition");
563
+ sendJson(res, 500, {
564
+ error: "internal_error",
565
+ message: error.message
566
+ });
567
+ }
568
+ }
569
+ async function listTemplates(req, res, ctx) {
570
+ const limitParam = getQueryParam(ctx, "limit");
571
+ const limit = limitParam ? Math.min(parseInt(limitParam, 10), 100) : void 0;
572
+ const channel = getQueryParam(ctx, "channel");
573
+ const topic = getQueryParam(ctx, "topic");
574
+ let templates = await deps.templateRepo.list(ctx.projectId);
575
+ if (channel) templates = templates.filter((t) => t.channel === channel);
576
+ if (topic) templates = templates.filter((t) => t.topics?.includes(topic));
577
+ if (limit) templates = templates.slice(0, limit);
578
+ sendJson(res, 200, { templates });
579
+ }
580
+ async function getTemplate(req, res, ctx) {
581
+ const templateId = ctx.params.id;
582
+ const template = await deps.templateRepo.findById(ctx.projectId, templateId);
583
+ if (!template) return sendJson(res, 404, {
584
+ error: "template_not_found",
585
+ id: templateId
586
+ });
587
+ sendJson(res, 200, template);
588
+ }
589
+ async function deleteTemplate(req, res, ctx) {
590
+ const templateId = ctx.params.id;
591
+ if (!await deps.templateRepo.delete(ctx.projectId, templateId)) return sendJson(res, 404, {
592
+ error: "template_not_found",
593
+ id: templateId
594
+ });
595
+ await deps.redis.native.publish("template.invalidated", `${ctx.projectId}:${templateId}`);
596
+ logger.info({ templateId }, "template deleted");
597
+ sendNoContent(res);
598
+ }
599
+ async function listWorkflows(req, res, ctx) {
600
+ const limitParam = getQueryParam(ctx, "limit");
601
+ const limit = limitParam ? Math.min(parseInt(limitParam, 10), 100) : void 0;
602
+ let workflows = await deps.workflowRepo.listDefinitions(ctx.projectId);
603
+ if (limit) workflows = workflows.slice(0, limit);
604
+ sendJson(res, 200, { workflows });
605
+ }
606
+ async function getWorkflow(req, res, ctx) {
607
+ const instanceId = ctx.params.id;
608
+ const workflow = await deps.workflowRepo.getInstance(ctx.projectId, instanceId);
609
+ if (!workflow) return sendJson(res, 404, {
610
+ error: "workflow_not_found",
611
+ id: instanceId
612
+ });
613
+ sendJson(res, 200, workflow);
614
+ }
615
+ async function cancelWorkflow(req, res, ctx) {
616
+ const instanceId = ctx.params.id;
617
+ if (!await deps.workflowRepo.cancelInstance(ctx.projectId, instanceId)) return sendJson(res, 400, {
618
+ error: "workflow_not_cancelable",
619
+ id: instanceId
620
+ });
621
+ logger.info({ instanceId }, "workflow canceled");
622
+ sendNoContent(res);
623
+ }
624
+ async function listUsers(req, res, ctx) {
625
+ const limitParam = parseInt(ctx.query.get("limit") || "50", 10);
626
+ const limit = isNaN(limitParam) ? 50 : Math.min(limitParam, 100);
627
+ const cursor = ctx.query.get("cursor") ?? void 0;
628
+ sendJson(res, 200, await deps.userRepo.list(ctx.projectId, limit, cursor));
629
+ }
630
+ async function getUserContacts(req, res, ctx) {
631
+ const userId = ctx.params.id;
632
+ sendJson(res, 200, { contacts: await deps.contactRepo.findByUserId(ctx.projectId, userId) });
633
+ }
634
+ async function listSegments(req, res, ctx) {
635
+ sendJson(res, 200, { segments: await deps.segmentRepo.listSegments(ctx.projectId) });
636
+ }
637
+ async function listProjects(req, res, _ctx) {
638
+ sendJson(res, 200, { projects: await deps.projectRepo.list() });
639
+ }
640
+ async function deleteProject(req, res, ctx) {
641
+ const id = ctx.params.id;
642
+ if (!await deps.projectRepo.delete(id)) return sendJson(res, 404, { error: "project_not_found" });
643
+ sendNoContent(res);
644
+ }
645
+ async function updateProject(req, res, ctx) {
646
+ const id = ctx.params.id;
647
+ const parsed = z.object({
648
+ rateLimitRpm: z.number().nullable().optional(),
649
+ throttleLimit: z.number().nullable().optional(),
650
+ throttleWindowHours: z.number().nullable().optional()
651
+ }).safeParse(await readJsonBody(req));
652
+ if (!parsed.success) return sendValidationError(res, parsed.error);
653
+ if (!await deps.projectRepo.updateSettings(id, parsed.data)) return sendJson(res, 404, { error: "project_not_found" });
654
+ await deps.redis.native.publish(PUBSUB_CHANNELS.PROJECT_INVALIDATED, id);
655
+ if (parsed.data.rateLimitRpm !== void 0) await deps.redis.native.publish(PUBSUB_CHANNELS.API_KEY_INVALIDATED, "*");
656
+ logger.info({ projectId: id }, "project settings updated");
657
+ sendJson(res, 200, { id });
658
+ }
659
+ async function createProjectKey(req, res, ctx) {
660
+ const id = ctx.params.id;
661
+ const parsed = z.object({ role: z.enum(["admin", "read_only"]).default("admin") }).safeParse(await readJsonBody(req));
662
+ const role = parsed.success ? parsed.data.role : "admin";
663
+ const { randomBytes, createHash } = await import("node:crypto");
664
+ const apiKey = `nk_live_${randomBytes(32).toString("hex")}`;
665
+ const apiKeyHash = createHash("sha256").update(apiKey).digest("hex");
666
+ sendJson(res, 201, {
667
+ id: (await deps.projectRepo.createApiKey(id, apiKeyHash, role)).id,
668
+ apiKey,
669
+ role
670
+ });
671
+ }
672
+ async function listProjectKeys(req, res, ctx) {
673
+ const id = ctx.params.id;
674
+ sendJson(res, 200, { keys: await deps.projectRepo.listApiKeys(id) });
675
+ }
676
+ async function deleteProjectKey(req, res, ctx) {
677
+ const { id, keyId } = ctx.params;
678
+ if (!await deps.projectRepo.deleteApiKey(id, keyId)) return sendJson(res, 404, { error: "key_not_found" });
679
+ await deps.redis.native.publish("apikey.invalidated", "*");
680
+ sendJson(res, 204, {});
681
+ }
682
+ async function getSystemHealth(_req, res, _ctx) {
683
+ const keys = [
684
+ "enricher",
685
+ "engine",
686
+ "scheduler",
687
+ "delivery",
688
+ "ai",
689
+ "workflow",
690
+ "events"
691
+ ];
692
+ const workers = {};
693
+ let redisOk = false;
694
+ let dbOk = false;
695
+ let redisLatency = 0;
696
+ let dbLatency = 0;
697
+ const startRedis = Date.now();
698
+ try {
699
+ await deps.redis.native.ping();
700
+ redisOk = true;
701
+ redisLatency = Date.now() - startRedis;
702
+ } catch {
703
+ redisOk = false;
704
+ }
705
+ const startDb = Date.now();
706
+ try {
707
+ await deps.db.execute(sql`SELECT 1`);
708
+ dbOk = true;
709
+ dbLatency = Date.now() - startDb;
710
+ } catch {
711
+ dbOk = false;
712
+ }
713
+ if (redisOk) try {
714
+ const vals = await deps.redis.native.mget(keys.map((k) => `notif:health:${k}`));
715
+ for (let i = 0; i < keys.length; i++) {
716
+ const key = keys[i];
717
+ const val = vals[i];
718
+ if (val) workers[key] = JSON.parse(val);
719
+ else workers[key] = {
720
+ status: "unknown",
721
+ message: "No heartbeat"
722
+ };
723
+ }
724
+ } catch (err) {
725
+ for (const key of keys) workers[key] = {
726
+ status: "error",
727
+ error: err.message
728
+ };
729
+ }
730
+ sendJson(res, 200, {
731
+ status: redisOk && dbOk ? "healthy" : "degraded",
732
+ redis: {
733
+ ok: redisOk,
734
+ latencyMs: redisLatency
735
+ },
736
+ db: {
737
+ ok: dbOk,
738
+ latencyMs: dbLatency
739
+ },
740
+ workers
741
+ });
742
+ }
743
+ async function getSystemMetrics(_req, res, ctx) {
744
+ const streamMap = {
745
+ INBOUND_CRITICAL: STREAMS.INBOUND_CRITICAL,
746
+ INBOUND_NORMAL: STREAMS.INBOUND_NORMAL,
747
+ INBOUND_LOW: STREAMS.INBOUND_LOW,
748
+ ENRICHED_NORMAL: STREAMS.ENRICHED_NORMAL,
749
+ OUTBOUND_CRITICAL: STREAMS.OUTBOUND_CRITICAL,
750
+ OUTBOUND_NORMAL: STREAMS.OUTBOUND_NORMAL,
751
+ OUTBOUND_LOW: STREAMS.OUTBOUND_LOW,
752
+ WORKFLOW_INBOUND: STREAMS.WORKFLOW_INBOUND,
753
+ EVENTS_INBOUND: STREAMS.EVENTS_INBOUND,
754
+ DEAD_LETTER: STREAMS.DEAD_LETTER
755
+ };
756
+ const streamDepths = {};
757
+ for (const [key, realRedisKey] of Object.entries(streamMap)) try {
758
+ streamDepths[key] = await deps.redis.native.xlen(realRedisKey);
759
+ } catch {
760
+ streamDepths[key] = 0;
761
+ }
762
+ const totalTasksRes = await deps.db.select({ count: sql`count(distinct ${messageLogs.taskId})` }).from(messageLogs).where(eq(messageLogs.projectId, ctx.projectId));
763
+ const deliveredTasksRes = await deps.db.select({ count: sql`count(distinct ${messageLogs.taskId})` }).from(messageLogs).where(and(eq(messageLogs.projectId, ctx.projectId), eq(messageLogs.status, "delivered")));
764
+ const total = Number(totalTasksRes[0]?.count ?? 0);
765
+ const delivered = Number(deliveredTasksRes[0]?.count ?? 0);
766
+ sendJson(res, 200, {
767
+ streams: streamDepths,
768
+ deliveryStats: {
769
+ total,
770
+ delivered,
771
+ failed: streamDepths.DEAD_LETTER || 0,
772
+ successRate: total > 0 ? Number((delivered / total * 100).toFixed(2)) : 100
773
+ }
774
+ });
775
+ }
776
+ async function getDLQMessages(_req, res, _ctx) {
777
+ try {
778
+ sendJson(res, 200, { messages: (await deps.redis.native.xrevrange(STREAMS.DEAD_LETTER, "+", "-", "COUNT", "50") || []).map(([id, fields]) => {
779
+ const fieldMap = {};
780
+ for (let i = 0; i < fields.length; i += 2) fieldMap[fields[i]] = fields[i + 1];
781
+ return {
782
+ id,
783
+ eventType: fieldMap.eventType || fieldMap.event_type || "unknown",
784
+ payload: fieldMap.payload ? JSON.parse(fieldMap.payload) : fieldMap,
785
+ error: fieldMap.error || fieldMap.reason || "Dead letter payload",
786
+ timestamp: fieldMap.timestamp || (/* @__PURE__ */ new Date()).toISOString()
787
+ };
788
+ }) });
789
+ } catch {
790
+ sendJson(res, 200, { messages: [] });
791
+ }
792
+ }
793
+ async function replayDLQMessage(req, res, _ctx) {
794
+ const messageId = (await readJsonBody(req).catch(() => ({})))?.id;
795
+ if (!messageId) {
796
+ sendJson(res, 400, { error: "missing_message_id" });
797
+ return;
798
+ }
799
+ try {
800
+ const rawEntries = await deps.redis.native.xrange(STREAMS.DEAD_LETTER, messageId, messageId);
801
+ if (!rawEntries || rawEntries.length === 0 || !rawEntries[0]) {
802
+ sendJson(res, 404, { error: "dlq_message_not_found" });
803
+ return;
804
+ }
805
+ const fields = rawEntries[0][1];
806
+ const fieldMap = {};
807
+ for (let i = 0; i < fields.length; i += 2) fieldMap[fields[i]] = fields[i + 1];
808
+ const priority = fieldMap.priority || "normal";
809
+ const p = getPriorityBucket(priority);
810
+ const targetStream = STREAMS[`INBOUND_${p.toUpperCase()}`] || STREAMS.INBOUND_NORMAL;
811
+ const xaddArgs = [];
812
+ for (const [k, v] of Object.entries(fieldMap)) xaddArgs.push(k, v);
813
+ await deps.redis.native.xadd(targetStream, "*", ...xaddArgs);
814
+ await deps.redis.native.xdel(STREAMS.DEAD_LETTER, messageId);
815
+ sendJson(res, 200, {
816
+ success: true,
817
+ replayedId: messageId
818
+ });
819
+ } catch (err) {
820
+ sendJson(res, 500, {
821
+ error: "replay_failed",
822
+ message: err.message
823
+ });
824
+ }
825
+ }
826
+ async function deleteDLQMessage(_req, res, ctx) {
827
+ const messageId = ctx.params.id;
828
+ if (!messageId) return sendJson(res, 400, { error: "missing_id" });
829
+ try {
830
+ await deps.redis.native.xdel(STREAMS.DEAD_LETTER, messageId);
831
+ sendJson(res, 200, { success: true });
832
+ } catch (err) {
833
+ sendJson(res, 500, {
834
+ error: "delete_failed",
835
+ message: err.message
836
+ });
837
+ }
838
+ }
839
+ async function getScheduledMessages(_req, res, ctx) {
840
+ const limitParam = getQueryParam(ctx, "limit");
841
+ const limit = limitParam ? Math.min(parseInt(limitParam, 10), 100) : 50;
842
+ const cursor = getQueryParam(ctx, "cursor");
843
+ const channel = getQueryParam(ctx, "channel");
844
+ const conditions = [sql`(payload->>'projectId') = ${ctx.projectId}`];
845
+ if (cursor) conditions.push(sql`task_id > ${cursor}`);
846
+ if (channel) conditions.push(sql`(payload->>'channel') = ${channel}`);
847
+ const rows = await deps.db.select().from(scheduledPayloads).where(and(...conditions)).orderBy(scheduledPayloads.taskId).limit(limit + 1);
848
+ const hasNext = rows.length > limit;
849
+ const scheduled = hasNext ? rows.slice(0, limit) : rows;
850
+ sendJson(res, 200, {
851
+ scheduled,
852
+ nextCursor: hasNext && scheduled.length > 0 ? scheduled[scheduled.length - 1].taskId : null
853
+ });
854
+ }
855
+ async function getUserDetails(_req, res, ctx) {
856
+ const userId = ctx.params.id;
857
+ const user = await deps.userRepo.findRecordById(ctx.projectId, userId);
858
+ if (!user) return sendJson(res, 404, {
859
+ error: "user_not_found",
860
+ id: userId
861
+ });
862
+ const contacts = await deps.contactRepo.findByUserId(ctx.projectId, userId);
863
+ const logs = await deps.db.select().from(messageLogs).where(eq(messageLogs.projectId, ctx.projectId)).orderBy(desc(messageLogs.timestamp)).limit(50);
864
+ sendJson(res, 200, {
865
+ ...user,
866
+ contacts,
867
+ logs
868
+ });
869
+ }
870
+ function sendHtml(res, status, body) {
871
+ res.writeHead(status, {
872
+ "Content-Type": "text/html; charset=utf-8",
873
+ "Cache-Control": "no-store",
874
+ "X-Content-Type-Options": "nosniff",
875
+ "Referrer-Policy": "no-referrer"
876
+ });
877
+ res.end(body);
878
+ }
879
+ function escapeHtml(value) {
880
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
881
+ }
882
+ function page(title, message) {
883
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8">
884
+ <meta name="viewport" content="width=device-width,initial-scale=1">
885
+ <title>${escapeHtml(title)}</title>
886
+ <style>body{font:16px/1.6 system-ui,sans-serif;max-width:34rem;margin:12vh auto;padding:0 1.5rem;color:#111}
887
+ h1{font-size:1.4rem;margin:0 0 .75rem}p{margin:0 0 1rem;color:#444}
888
+ button{font:inherit;padding:.6rem 1.2rem;border:0;border-radius:6px;background:#111;color:#fff;cursor:pointer}
889
+ code{background:#f4f4f5;padding:.1rem .35rem;border-radius:4px}</style>
890
+ </head><body><h1>${escapeHtml(title)}</h1>${message}</body></html>`;
891
+ }
892
+ /**
893
+ * GET — the page a human lands on after clicking the link in the mail body.
894
+ *
895
+ * Deliberately does not unsubscribe anything. Corporate mail scanners and
896
+ * link prescanners issue a GET against every URL in a message; performing the
897
+ * opt-out here would unsubscribe people who never clicked. The mutation lives
898
+ * behind the POST.
899
+ */
900
+ async function unsubscribePage(_req, res, ctx) {
901
+ const cfg = readBaseConfig();
902
+ const token = ctx.query.get("token") ?? "";
903
+ if (!cfg.UNSUBSCRIBE_SECRET) deps.logger.warn("UNSUBSCRIBE_SECRET is not configured — unable to verify unsubscribe tokens");
904
+ const claim = cfg.UNSUBSCRIBE_SECRET ? verifyUnsubscribeToken(token, cfg.UNSUBSCRIBE_SECRET) : null;
905
+ if (!claim) return sendHtml(res, 400, page("This link is not valid", "<p>It may have been altered in transit, or this server may have been reconfigured since the message was sent. Replying to the message and asking to be removed will still work.</p>"));
906
+ sendHtml(res, 200, page("Unsubscribe", `<p>Stop sending ${claim.topics.length > 0 ? `<code>${claim.topics.map(escapeHtml).join("</code>, <code>")}</code> messages` : "all messages"} to <code>${escapeHtml(claim.target)}</code>?</p>
907
+ <form method="post" action="/v1/unsubscribe?token=${encodeURIComponent(token)}">
908
+ <button type="submit">Unsubscribe</button></form>`));
909
+ }
910
+ /**
911
+ * POST — the real opt-out.
912
+ *
913
+ * Serves two callers with one handler: a mail client doing RFC 8058 one-click
914
+ * (body `List-Unsubscribe=One-Click`, token in the query string, no
915
+ * confirmation possible), and the form on the page above. Neither can carry a
916
+ * CSRF token, and the one-click spec forbids an interstitial, so the signed
917
+ * token is the whole of the authorisation.
918
+ */
919
+ async function unsubscribe(req, res, ctx) {
920
+ req.resume();
921
+ const cfg = readBaseConfig();
922
+ const token = ctx.query.get("token") ?? "";
923
+ if (!cfg.UNSUBSCRIBE_SECRET) deps.logger.warn("UNSUBSCRIBE_SECRET is not configured — unable to verify unsubscribe tokens");
924
+ const claim = cfg.UNSUBSCRIBE_SECRET ? verifyUnsubscribeToken(token, cfg.UNSUBSCRIBE_SECRET) : null;
925
+ if (!claim) return sendHtml(res, 400, page("This link is not valid", "<p>It may have been altered in transit, or this server may have been reconfigured since the message was sent.</p>"));
926
+ const target = normaliseTarget(claim.target);
927
+ try {
928
+ const internalId = (await deps.db.select({ id: users.id }).from(users).where(and(eq(users.projectId, claim.projectId), eq(users.externalId, claim.userId))).limit(1))[0]?.id;
929
+ if (claim.topics.length > 0 && internalId) await deps.db.insert(userTopicPreferences).values(claim.topics.map((topic) => ({
930
+ userId: internalId,
931
+ topic,
932
+ enabled: false
933
+ }))).onConflictDoUpdate({
934
+ target: [userTopicPreferences.userId, userTopicPreferences.topic],
935
+ set: { enabled: sql`excluded.enabled` }
936
+ });
937
+ else await deps.db.insert(suppressions).values({
938
+ projectId: claim.projectId,
939
+ channel: claim.channel,
940
+ target,
941
+ reason: "unsubscribed",
942
+ source: "unsubscribe-link"
943
+ }).onConflictDoNothing();
944
+ logger.info({
945
+ projectId: claim.projectId,
946
+ channel: claim.channel,
947
+ topics: claim.topics,
948
+ scoped: claim.topics.length > 0 && Boolean(internalId)
949
+ }, "unsubscribe honoured");
950
+ } catch (err) {
951
+ logger.error({
952
+ err,
953
+ projectId: claim.projectId
954
+ }, "unsubscribe failed to apply");
955
+ return sendHtml(res, 500, page("Something went wrong", "<p>We could not record that just now. Please try again in a moment.</p>"));
956
+ }
957
+ sendHtml(res, 200, page("You're unsubscribed", `<p>We've stopped sending ${claim.topics.length > 0 ? "these messages" : "messages"} to <code>${escapeHtml(claim.target)}</code>. It may take a few minutes to take effect for mail already on its way.</p>`));
958
+ }
959
+ async function listCampaigns(_req, res, ctx) {
960
+ const limitParam = parseInt(ctx.query.get("limit") || "20", 10);
961
+ const limit = isNaN(limitParam) ? 20 : Math.min(Math.max(limitParam, 1), 100);
962
+ sendJson(res, 200, { campaigns: (await deps.db.select({
963
+ campaign: messageLogs.campaignId,
964
+ messages: sql`count(distinct ${messageLogs.taskId})`,
965
+ firstSentAt: sql`min(${messageLogs.timestamp})`,
966
+ lastActivityAt: sql`max(${messageLogs.timestamp})`
967
+ }).from(messageLogs).where(and(eq(messageLogs.projectId, ctx.projectId), isNotNull(messageLogs.campaignId))).groupBy(messageLogs.campaignId).orderBy(desc(sql`max(${messageLogs.timestamp})`)).limit(limit)).map((r) => ({
968
+ campaign: r.campaign,
969
+ messages: Number(r.messages ?? 0),
970
+ firstSentAt: r.firstSentAt,
971
+ lastActivityAt: r.lastActivityAt
972
+ })) });
973
+ }
974
+ async function getCampaignStats(_req, res, ctx) {
975
+ const campaign = ctx.params.campaign;
976
+ if (!campaign) return sendJson(res, 400, { error: "missing_campaign" });
977
+ const rows = await deps.db.select({
978
+ channel: messageLogs.channel,
979
+ kind: messageLogs.kind,
980
+ status: messageLogs.status,
981
+ tasks: sql`count(distinct ${messageLogs.taskId})`
982
+ }).from(messageLogs).where(and(eq(messageLogs.projectId, ctx.projectId), eq(messageLogs.campaignId, campaign))).groupBy(messageLogs.channel, messageLogs.kind, messageLogs.status);
983
+ if (rows.length === 0) return sendJson(res, 404, {
984
+ error: "not_found",
985
+ message: `No messages recorded for campaign '${campaign}'`
986
+ });
987
+ const ENGAGEMENT = [
988
+ "opened",
989
+ "clicked",
990
+ "bounced",
991
+ "complained",
992
+ "unsubscribed"
993
+ ];
994
+ const blank = () => ({
995
+ sent: 0,
996
+ delivered: 0,
997
+ failed: 0,
998
+ opened: 0,
999
+ clicked: 0,
1000
+ bounced: 0,
1001
+ complained: 0,
1002
+ unsubscribed: 0
1003
+ });
1004
+ const byChannel = /* @__PURE__ */ new Map();
1005
+ for (const row of rows) {
1006
+ const bucket = byChannel.get(row.channel) ?? blank();
1007
+ const n = Number(row.tasks ?? 0);
1008
+ if (ENGAGEMENT.includes(row.kind)) bucket[row.kind] += n;
1009
+ else {
1010
+ bucket.sent += n;
1011
+ if (row.status === "delivered") bucket.delivered += n;
1012
+ if (row.status === "failed") bucket.failed += n;
1013
+ }
1014
+ byChannel.set(row.channel, bucket);
1015
+ }
1016
+ const total = blank();
1017
+ for (const bucket of byChannel.values()) for (const key of Object.keys(total)) total[key] += bucket[key];
1018
+ const pct = (n, d) => d > 0 ? Number((n / d * 100).toFixed(2)) : null;
1019
+ const channels = [...byChannel.keys()];
1020
+ const engagementCapable = channels.some((c) => c === "email");
1021
+ const sawEngagement = ENGAGEMENT.some((k) => total[k] > 0);
1022
+ const warnings = [];
1023
+ if (!engagementCapable) warnings.push(`Opens and clicks are not reported on ${channels.join(", ")} — those figures are not tracked, not zero.`);
1024
+ else if (!sawEngagement && total.delivered > 0) warnings.push("No engagement events recorded. If the provider webhook is not configured, opens and clicks cannot be tracked and will stay at zero.");
1025
+ sendJson(res, 200, {
1026
+ campaign,
1027
+ totals: {
1028
+ ...total,
1029
+ deliveryRate: pct(total.delivered, total.sent),
1030
+ openRate: pct(total.opened, total.delivered),
1031
+ clickRate: pct(total.clicked, total.delivered),
1032
+ bounceRate: pct(total.bounced, total.sent),
1033
+ complaintRate: pct(total.complained, total.delivered),
1034
+ unsubscribeRate: pct(total.unsubscribed, total.delivered)
1035
+ },
1036
+ byChannel: Object.fromEntries(byChannel),
1037
+ engagementTracked: engagementCapable && sawEngagement,
1038
+ warnings
1039
+ });
1040
+ }
1041
+ async function listSuppressions(_req, res, ctx) {
1042
+ const limitParam = parseInt(ctx.query.get("limit") || "100", 10);
1043
+ const limit = isNaN(limitParam) ? 100 : Math.min(Math.max(limitParam, 1), 500);
1044
+ const channel = ctx.query.get("channel");
1045
+ const reason = ctx.query.get("reason");
1046
+ const conditions = [eq(suppressions.projectId, ctx.projectId)];
1047
+ if (channel) conditions.push(eq(suppressions.channel, channel));
1048
+ if (reason) conditions.push(eq(suppressions.reason, reason));
1049
+ sendJson(res, 200, { suppressions: await deps.db.select().from(suppressions).where(and(...conditions)).orderBy(desc(suppressions.createdAt)).limit(limit) });
1050
+ }
1051
+ async function createSuppression(req, res, ctx) {
1052
+ const parsed = z.object({
1053
+ channel: ContactChannelSchema,
1054
+ target: z.string().min(1),
1055
+ reason: z.enum([
1056
+ "unsubscribed",
1057
+ "complained",
1058
+ "bounced",
1059
+ "manual"
1060
+ ]).default("manual")
1061
+ }).safeParse(await readJsonBody(req));
1062
+ if (!parsed.success) return sendValidationError(res, parsed.error);
1063
+ const { channel, target, reason } = parsed.data;
1064
+ await deps.db.insert(suppressions).values({
1065
+ projectId: ctx.projectId,
1066
+ channel,
1067
+ target: normaliseTarget(target),
1068
+ reason,
1069
+ source: "api"
1070
+ }).onConflictDoNothing();
1071
+ sendJson(res, 201, {
1072
+ channel,
1073
+ target: normaliseTarget(target),
1074
+ reason
1075
+ });
1076
+ }
1077
+ async function deleteSuppression(_req, res, ctx) {
1078
+ const { channel, target } = ctx.params;
1079
+ if (!channel || !target) return sendJson(res, 400, { error: "missing_channel_or_target" });
1080
+ await deps.db.delete(suppressions).where(and(eq(suppressions.projectId, ctx.projectId), eq(suppressions.channel, channel), eq(suppressions.target, normaliseTarget(decodeURIComponent(target)))));
1081
+ sendNoContent(res);
1082
+ }
1083
+ return {
1084
+ syncTemplates,
1085
+ addUser,
1086
+ updateUser,
1087
+ deleteUser,
1088
+ addContact,
1089
+ deleteContact,
1090
+ notify,
1091
+ cancelNotification,
1092
+ triggerWorkflow,
1093
+ ingestEvent,
1094
+ getEventsStream,
1095
+ getNotificationLogs,
1096
+ getNotificationStatus,
1097
+ getUser,
1098
+ getUserDetails,
1099
+ unsubscribePage,
1100
+ unsubscribe,
1101
+ listCampaigns,
1102
+ getCampaignStats,
1103
+ listSuppressions,
1104
+ createSuppression,
1105
+ deleteSuppression,
1106
+ getUserPreferences,
1107
+ updateUserPreferences,
1108
+ listTemplates,
1109
+ getTemplate,
1110
+ deleteTemplate,
1111
+ createWorkflow,
1112
+ listWorkflows,
1113
+ getWorkflow,
1114
+ cancelWorkflow,
1115
+ listUsers,
1116
+ getUserContacts,
1117
+ listSegments,
1118
+ listProjects,
1119
+ deleteProject,
1120
+ updateProject,
1121
+ createProjectKey,
1122
+ listProjectKeys,
1123
+ deleteProjectKey,
1124
+ getSystemHealth,
1125
+ getSystemMetrics,
1126
+ getDLQMessages,
1127
+ replayDLQMessage,
1128
+ deleteDLQMessage,
1129
+ getScheduledMessages
1130
+ };
1131
+ }
1132
+ //#endregion
1133
+ //#region src/services/api/main.ts
1134
+ /** Pub/sub channel used to drop a cached API key across every API process. */
1135
+ const API_KEY_INVALIDATION_CHANNEL = "apikey.invalidated";
1136
+ loadEnv();
1137
+ const config = readBaseConfig();
1138
+ let logger;
1139
+ let redis;
1140
+ let sql$1;
1141
+ let db;
1142
+ let producers;
1143
+ let deps;
1144
+ let h;
1145
+ const AUTH_CACHE_TTL_MS = 6e4;
1146
+ let authCache = new LRUCache(1e3, AUTH_CACHE_TTL_MS);
1147
+ let authSubscriber = null;
1148
+ let router;
1149
+ function extractAuthToken(req) {
1150
+ const authHeader = req.headers["authorization"];
1151
+ if (typeof authHeader === "string" && authHeader.toLowerCase().startsWith("bearer ")) return authHeader.slice(7).trim();
1152
+ const apiKeyHeader = req.headers["x-api-key"];
1153
+ if (typeof apiKeyHeader === "string") return apiKeyHeader.trim();
1154
+ }
1155
+ async function handleCreateProject(req, res) {
1156
+ const parsed = z.object({ name: z.string().min(1) }).safeParse(await readJsonBody(req));
1157
+ if (!parsed.success) {
1158
+ sendJson(res, 400, {
1159
+ error: "validation_error",
1160
+ issues: parsed.error.issues
1161
+ });
1162
+ return;
1163
+ }
1164
+ const apiKey = `nk_live_${randomBytes(32).toString("hex")}`;
1165
+ const apiKeyHash = createHash("sha256").update(apiKey).digest("hex");
1166
+ const projectId = (await db.insert(projects).values({ name: parsed.data.name }).returning())[0].id;
1167
+ await db.insert(projectApiKeys).values({
1168
+ projectId,
1169
+ keyHash: apiKeyHash,
1170
+ role: "admin"
1171
+ });
1172
+ sendJson(res, 201, {
1173
+ id: projectId,
1174
+ apiKey
1175
+ });
1176
+ }
1177
+ let cachedHealth = null;
1178
+ async function handleHealth(_req, res) {
1179
+ if (cachedHealth && cachedHealth.expiresAt > Date.now()) {
1180
+ sendJson(res, cachedHealth.statusCode, cachedHealth.response);
1181
+ return;
1182
+ }
1183
+ const [redisOk, dbOk] = await Promise.all([redis.healthCheck(), dbHealthCheck()]);
1184
+ const workers = {};
1185
+ let overallOk = redisOk && dbOk;
1186
+ if (redisOk) {
1187
+ const keys = [
1188
+ "enricher",
1189
+ "engine",
1190
+ "scheduler",
1191
+ "delivery",
1192
+ "ai",
1193
+ "workflow",
1194
+ "events"
1195
+ ];
1196
+ try {
1197
+ const vals = await redis.native.mget(keys.map((k) => `notif:health:${k}`));
1198
+ for (let i = 0; i < keys.length; i++) {
1199
+ const key = keys[i];
1200
+ const val = vals[i];
1201
+ if (val) {
1202
+ const parsed = JSON.parse(val);
1203
+ workers[key] = parsed;
1204
+ if (parsed.redis === false || parsed.state === "error") overallOk = false;
1205
+ } else workers[key] = {
1206
+ status: "unknown",
1207
+ message: "No report received from worker"
1208
+ };
1209
+ }
1210
+ } catch (err) {
1211
+ for (const key of keys) workers[key] = {
1212
+ status: "error",
1213
+ error: err instanceof Error ? err.message : String(err)
1214
+ };
1215
+ }
1216
+ }
1217
+ const statusCode = overallOk ? 200 : 503;
1218
+ const response = {
1219
+ service: "api",
1220
+ status: overallOk ? "ok" : "degraded",
1221
+ redis: redisOk,
1222
+ database: dbOk,
1223
+ workers
1224
+ };
1225
+ cachedHealth = {
1226
+ response,
1227
+ statusCode,
1228
+ expiresAt: Date.now() + 1e3
1229
+ };
1230
+ sendJson(res, statusCode, response);
1231
+ }
1232
+ async function handleMetrics(_req, res) {
1233
+ const registry = getMetricsRegistry();
1234
+ res.writeHead(200, { "Content-Type": registry.contentType });
1235
+ res.end(await registry.metrics());
1236
+ }
1237
+ async function handleLive(_req, res) {
1238
+ sendJson(res, 200, { status: "ok" });
1239
+ }
1240
+ async function handleReady(_req, res) {
1241
+ if (cachedHealth && cachedHealth.expiresAt > Date.now()) {
1242
+ sendJson(res, cachedHealth.statusCode === 200 ? 200 : 503, { status: cachedHealth.statusCode === 200 ? "ready" : "unready" });
1243
+ return;
1244
+ }
1245
+ const [redisOk, dbOk] = await Promise.all([redis.healthCheck(), dbHealthCheck()]);
1246
+ const isReady = redisOk && dbOk;
1247
+ sendJson(res, isReady ? 200 : 503, { status: isReady ? "ready" : "unready" });
1248
+ }
1249
+ async function dbHealthCheck() {
1250
+ try {
1251
+ await sql$1`SELECT 1`;
1252
+ return true;
1253
+ } catch {
1254
+ return false;
1255
+ }
1256
+ }
1257
+ let server;
1258
+ async function startApiServer() {
1259
+ logger = createLogger({
1260
+ name: "api",
1261
+ level: config.LOG_LEVEL
1262
+ });
1263
+ redis = new RedisClient({
1264
+ url: config.REDIS_URL,
1265
+ name: "api",
1266
+ logger
1267
+ });
1268
+ const dbData = createDatabase({
1269
+ url: config.DATABASE_URL,
1270
+ applicationName: "api",
1271
+ logger
1272
+ });
1273
+ sql$1 = dbData.sql;
1274
+ db = dbData.db;
1275
+ producers = {
1276
+ critical: new StreamProducer({
1277
+ redis: redis.native,
1278
+ stream: STREAMS.INBOUND_CRITICAL,
1279
+ logger
1280
+ }),
1281
+ normal: new StreamProducer({
1282
+ redis: redis.native,
1283
+ stream: STREAMS.INBOUND_NORMAL,
1284
+ logger
1285
+ }),
1286
+ low: new StreamProducer({
1287
+ redis: redis.native,
1288
+ stream: STREAMS.INBOUND_LOW,
1289
+ logger
1290
+ }),
1291
+ workflow: new StreamProducer({
1292
+ redis: redis.native,
1293
+ stream: STREAMS.WORKFLOW_INBOUND,
1294
+ logger
1295
+ }),
1296
+ events: new StreamProducer({
1297
+ redis: redis.native,
1298
+ stream: STREAMS.EVENTS_INBOUND,
1299
+ logger
1300
+ })
1301
+ };
1302
+ deps = {
1303
+ logger,
1304
+ redis,
1305
+ producers,
1306
+ userRepo: new UserRepository(db),
1307
+ contactRepo: new ContactRepository(db),
1308
+ templateRepo: new TemplateRepository(db),
1309
+ projectRepo: new ProjectRepository(db),
1310
+ workflowRepo: new WorkflowRepository(db),
1311
+ segmentRepo: new SegmentRepository(db),
1312
+ db
1313
+ };
1314
+ h = createHandlers(deps);
1315
+ router = new Router();
1316
+ router.put("/v1/templates", h.syncTemplates).get("/v1/templates", h.listTemplates).get("/v1/templates/:id", h.getTemplate).delete("/v1/templates/:id", h.deleteTemplate).post("/v1/users", h.addUser).get("/v1/users", h.listUsers).get("/v1/users/:id", h.getUser).get("/v1/users/:id/details", h.getUserDetails).patch("/v1/users/:id", h.updateUser).delete("/v1/users/:id", h.deleteUser).post("/v1/users/:id/contacts", h.addContact).get("/v1/users/:id/contacts", h.getUserContacts).delete("/v1/users/:id/contacts/:channel/:target", h.deleteContact).get("/v1/users/:id/preferences", h.getUserPreferences).patch("/v1/users/:id/preferences", h.updateUserPreferences).post("/v1/notify", h.notify).get("/v1/notifications/scheduled", h.getScheduledMessages).get("/v1/notifications/logs", h.getNotificationLogs).get("/v1/notifications/:taskId", h.getNotificationStatus).delete("/v1/notifications/:taskId", h.cancelNotification).get("/v1/unsubscribe", h.unsubscribePage).post("/v1/unsubscribe", h.unsubscribe).get("/v1/campaigns", h.listCampaigns).get("/v1/campaigns/:campaign/stats", h.getCampaignStats).get("/v1/suppressions", h.listSuppressions).post("/v1/suppressions", h.createSuppression).delete("/v1/suppressions/:channel/:target", h.deleteSuppression).get("/v1/system/health", h.getSystemHealth).get("/v1/system/metrics", h.getSystemMetrics).get("/v1/dlq", h.getDLQMessages).post("/v1/dlq/replay", h.replayDLQMessage).delete("/v1/dlq/:id", h.deleteDLQMessage).post("/v1/workflows", h.createWorkflow).get("/v1/workflows", h.listWorkflows).get("/v1/workflows/instances/:id", h.getWorkflow).delete("/v1/workflows/instances/:id", h.cancelWorkflow).post("/v1/workflows/trigger", h.triggerWorkflow).get("/v1/segments", h.listSegments).post("/v1/events", h.ingestEvent).get("/v1/events/stream", h.getEventsStream).get("/v1/projects", h.listProjects).post("/v1/projects", handleCreateProject).delete("/v1/projects/:id", h.deleteProject).patch("/v1/projects/:id", h.updateProject).post("/v1/projects/:id/keys", h.createProjectKey).get("/v1/projects/:id/keys", h.listProjectKeys).delete("/v1/projects/:id/keys/:keyId", h.deleteProjectKey).get("/health", handleHealth).get("/metrics", handleMetrics).get("/live", handleLive).get("/ready", handleReady);
1317
+ server = createServer((req, res) => {
1318
+ handleRequest(req, res);
1319
+ });
1320
+ server.requestTimeout = 3e4;
1321
+ server.headersTimeout = 1e4;
1322
+ server.keepAliveTimeout = 5e3;
1323
+ async function handleRequest(req, res) {
1324
+ res.setHeader("Access-Control-Allow-Origin", "*");
1325
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
1326
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, x-api-key, x-project-id");
1327
+ if (req.method === "OPTIONS") {
1328
+ res.writeHead(204);
1329
+ res.end();
1330
+ return;
1331
+ }
1332
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
1333
+ let projectId = void 0;
1334
+ let projectRateLimitRpm = 600;
1335
+ let keyRole = "admin";
1336
+ const isProjectManagement = url.pathname === "/v1/projects" || url.pathname.startsWith("/v1/projects/");
1337
+ const isPublicUnsubscribe = url.pathname === "/v1/unsubscribe";
1338
+ if (url.pathname.startsWith("/v1/") && !isProjectManagement && !isPublicUnsubscribe) {
1339
+ let token = extractAuthToken(req);
1340
+ if (!token && url.searchParams.has("token")) token = url.searchParams.get("token") || void 0;
1341
+ if (!token) {
1342
+ sendJson(res, 401, {
1343
+ error: "unauthorized",
1344
+ message: "Invalid or missing API key"
1345
+ });
1346
+ return;
1347
+ }
1348
+ let isAdminToken = false;
1349
+ if (config.ADMIN_API_KEY) {
1350
+ const expectedBuffer = Buffer.from(config.ADMIN_API_KEY);
1351
+ const providedBuffer = Buffer.from(token);
1352
+ if (expectedBuffer.length === providedBuffer.length && timingSafeEqual(expectedBuffer, providedBuffer)) isAdminToken = true;
1353
+ }
1354
+ if (isAdminToken) {
1355
+ const headerProjectId = req.headers["x-project-id"] || url.searchParams.get("projectId") || void 0;
1356
+ if (!headerProjectId) {
1357
+ sendJson(res, 400, {
1358
+ error: "bad_request",
1359
+ message: "x-project-id header or projectId query param required when using admin token"
1360
+ });
1361
+ return;
1362
+ }
1363
+ projectId = headerProjectId;
1364
+ projectRateLimitRpm = 6e3;
1365
+ keyRole = "admin";
1366
+ } else {
1367
+ const tokenHash = createHash("sha256").update(token).digest("hex");
1368
+ const cached = authCache.get(tokenHash);
1369
+ if (cached) {
1370
+ const parts = cached.split(":");
1371
+ projectId = parts[0];
1372
+ projectRateLimitRpm = parseInt(parts[1] || "600", 10);
1373
+ keyRole = parts[2];
1374
+ } else {
1375
+ const rows = await db.select({
1376
+ id: projects.id,
1377
+ rateLimitRpm: projects.rateLimitRpm,
1378
+ role: projectApiKeys.role
1379
+ }).from(projectApiKeys).innerJoin(projects, eq(projects.id, projectApiKeys.projectId)).where(eq(projectApiKeys.keyHash, tokenHash)).limit(1);
1380
+ if (!rows.length) {
1381
+ sendJson(res, 401, {
1382
+ error: "unauthorized",
1383
+ message: "Invalid or missing API key"
1384
+ });
1385
+ return;
1386
+ }
1387
+ projectId = rows[0].id;
1388
+ projectRateLimitRpm = rows[0].rateLimitRpm ?? 600;
1389
+ keyRole = rows[0].role;
1390
+ authCache.set(tokenHash, `${projectId}:${projectRateLimitRpm}:${keyRole}`);
1391
+ }
1392
+ }
1393
+ if (keyRole === "read_only") {
1394
+ if (req.method !== "GET" && req.method !== "OPTIONS") {
1395
+ sendJson(res, 403, {
1396
+ error: "forbidden",
1397
+ message: "API key is read-only"
1398
+ });
1399
+ return;
1400
+ }
1401
+ }
1402
+ const LUA_LIMIT = `
1403
+ local key = KEYS[1]
1404
+ local now = tonumber(ARGV[1])
1405
+ local window = tonumber(ARGV[2])
1406
+ local maxReqs = tonumber(ARGV[3])
1407
+ local cutoff = now - window
1408
+ redis.call("ZREMRANGEBYSCORE", key, "-inf", cutoff)
1409
+ local count = redis.call("ZCARD", key)
1410
+ if count < maxReqs then
1411
+ redis.call("ZADD", key, now, now .. "-" .. ARGV[4])
1412
+ redis.call("EXPIRE", key, math.ceil(window / 1000))
1413
+ return count + 1
1414
+ end
1415
+ return -1
1416
+ `;
1417
+ const rlKey = `rate-limit:api:req:${projectId}`;
1418
+ const nowMs = Date.now();
1419
+ if (await redis.native.eval(LUA_LIMIT, 1, rlKey, nowMs, 6e4, projectRateLimitRpm, randomBytes(4).toString("hex")) === -1) {
1420
+ if (!res.headersSent) {
1421
+ res.setHeader("Retry-After", "60");
1422
+ sendJson(res, 429, {
1423
+ error: "too_many_requests",
1424
+ message: `Project rate limit exceeded (max ${projectRateLimitRpm} req/min)`
1425
+ });
1426
+ }
1427
+ return;
1428
+ }
1429
+ } else if (isProjectManagement) {
1430
+ const token = extractAuthToken(req);
1431
+ if (!config.ADMIN_API_KEY) {
1432
+ sendJson(res, 403, {
1433
+ error: "forbidden",
1434
+ message: "Project management disabled (no ADMIN_API_KEY set)"
1435
+ });
1436
+ return;
1437
+ }
1438
+ if (!token) {
1439
+ sendJson(res, 401, {
1440
+ error: "unauthorized",
1441
+ message: "Missing admin token"
1442
+ });
1443
+ return;
1444
+ }
1445
+ const expectedBuffer = Buffer.from(config.ADMIN_API_KEY);
1446
+ const providedBuffer = Buffer.from(token);
1447
+ if (expectedBuffer.length !== providedBuffer.length || !timingSafeEqual(expectedBuffer, providedBuffer)) {
1448
+ sendJson(res, 401, {
1449
+ error: "unauthorized",
1450
+ message: "Invalid admin token"
1451
+ });
1452
+ return;
1453
+ }
1454
+ }
1455
+ if (isPublicUnsubscribe) {
1456
+ const clientIp = req.headers["x-forwarded-for"]?.split(",")[0]?.trim() || req.socket.remoteAddress || "unknown";
1457
+ try {
1458
+ const key = `rate-limit:api:unsub:${clientIp}`;
1459
+ const count = await redis.native.incr(key);
1460
+ if (count === 1) await redis.native.expire(key, 60);
1461
+ if (count > 60) {
1462
+ res.setHeader("Retry-After", "60");
1463
+ sendJson(res, 429, { error: "too_many_requests" });
1464
+ return;
1465
+ }
1466
+ } catch (err) {
1467
+ logger.warn({ err }, "unsubscribe rate limit unavailable — allowing request");
1468
+ }
1469
+ }
1470
+ const route = router.match(req.method ?? "GET", url.pathname);
1471
+ if (!route) {
1472
+ sendJson(res, 404, { error: "not_found" });
1473
+ return;
1474
+ }
1475
+ const ctx = {
1476
+ params: route.params,
1477
+ query: url.searchParams,
1478
+ projectId,
1479
+ role: keyRole
1480
+ };
1481
+ Promise.resolve(route.handler(req, res, ctx)).catch((err) => {
1482
+ if (err instanceof HttpError) {
1483
+ if (!res.headersSent) sendJson(res, err.status, {
1484
+ error: err.code,
1485
+ message: err.message
1486
+ });
1487
+ return;
1488
+ }
1489
+ logger.error({
1490
+ err,
1491
+ path: url.pathname
1492
+ }, "unhandled request error");
1493
+ if (!res.headersSent) sendJson(res, 500, { error: "internal_error" });
1494
+ });
1495
+ }
1496
+ authSubscriber = redis.native.duplicate();
1497
+ await authSubscriber.subscribe(API_KEY_INVALIDATION_CHANNEL);
1498
+ authSubscriber.on("message", (channel, tokenHash) => {
1499
+ if (channel !== "apikey.invalidated") return;
1500
+ if (tokenHash === "*") authCache.clear();
1501
+ else authCache.delete(tokenHash);
1502
+ logger.info({ tokenHash }, "api key cache invalidated");
1503
+ });
1504
+ const { transportRegistry } = await import("./index.mjs").then((n) => n.t);
1505
+ for (const channel of transportRegistry.registeredChannels()) {
1506
+ const transport = transportRegistry.get(channel);
1507
+ if (transport?.webhookPath && transport?.parseWebhook) {
1508
+ router.post(transport.webhookPath, async (req, res) => {
1509
+ try {
1510
+ await Promise.race([(async () => {
1511
+ const rawBody = await readRawBody(req);
1512
+ if (!transport.verifyWebhook) {
1513
+ if (!res.headersSent) sendJson(res, 501, {
1514
+ error: "not_implemented",
1515
+ message: "Webhook signature verification is not implemented for this provider"
1516
+ });
1517
+ return;
1518
+ }
1519
+ if (!await transport.verifyWebhook(rawBody, req.headers)) {
1520
+ if (!res.headersSent) sendJson(res, 401, {
1521
+ error: "unauthorized",
1522
+ message: "Invalid webhook signature"
1523
+ });
1524
+ return;
1525
+ }
1526
+ let body;
1527
+ try {
1528
+ body = rawBody ? JSON.parse(rawBody) : {};
1529
+ } catch {
1530
+ body = {};
1531
+ }
1532
+ const events = await transport.parseWebhook(body, rawBody, req.headers);
1533
+ if (events.length > 0) {
1534
+ const providerIds = events.map((e) => e.providerMessageId).filter(Boolean);
1535
+ const logsToProject = /* @__PURE__ */ new Map();
1536
+ const logsToTask = /* @__PURE__ */ new Map();
1537
+ const logsToCampaign = /* @__PURE__ */ new Map();
1538
+ if (providerIds.length > 0) {
1539
+ const existing = await db.select({
1540
+ providerMessageId: messageLogs.providerMessageId,
1541
+ projectId: messageLogs.projectId,
1542
+ taskId: messageLogs.taskId,
1543
+ campaignId: messageLogs.campaignId
1544
+ }).from(messageLogs).where(inArray(messageLogs.providerMessageId, providerIds));
1545
+ for (const row of existing) if (row.providerMessageId) {
1546
+ logsToProject.set(row.providerMessageId, row.projectId);
1547
+ logsToTask.set(row.providerMessageId, row.taskId);
1548
+ logsToCampaign.set(row.providerMessageId, row.campaignId);
1549
+ }
1550
+ }
1551
+ const attributable = events.filter((e) => e.providerMessageId && logsToProject.has(e.providerMessageId));
1552
+ const rows = attributable.map((e) => ({
1553
+ projectId: logsToProject.get(e.providerMessageId),
1554
+ taskId: logsToTask.get(e.providerMessageId),
1555
+ providerMessageId: e.providerMessageId,
1556
+ channel: transport.channel,
1557
+ attempt: 0,
1558
+ kind: e.status,
1559
+ status: e.status,
1560
+ campaignId: logsToCampaign.get(e.providerMessageId) ?? null,
1561
+ metadata: e.metadata ?? null
1562
+ }));
1563
+ const skipped = events.length - rows.length;
1564
+ if (skipped > 0) logger.warn({
1565
+ channel,
1566
+ skipped
1567
+ }, "webhook events skipped: unknown providerMessageId");
1568
+ if (rows.length > 0) await db.insert(messageLogs).values(rows).onConflictDoNothing();
1569
+ const suppressionRows = attributable.filter((e) => e.recipient && (e.status === "unsubscribed" || e.status === "complained" || e.status === "bounced" && e.bounceType === "hard")).map((e) => ({
1570
+ projectId: logsToProject.get(e.providerMessageId),
1571
+ channel: transport.channel,
1572
+ target: normaliseTarget(e.recipient),
1573
+ reason: e.status,
1574
+ source: channel,
1575
+ taskId: logsToTask.get(e.providerMessageId)
1576
+ }));
1577
+ if (suppressionRows.length > 0) {
1578
+ await db.insert(suppressions).values(suppressionRows).onConflictDoNothing();
1579
+ logger.info({
1580
+ channel,
1581
+ count: suppressionRows.length
1582
+ }, "addresses suppressed from provider webhook");
1583
+ }
1584
+ const unsuppressable = attributable.filter((e) => !e.recipient && (e.status === "unsubscribed" || e.status === "complained" || e.status === "bounced")).length;
1585
+ if (unsuppressable > 0) logger.warn({
1586
+ channel,
1587
+ count: unsuppressable
1588
+ }, "suppression events carried no recipient address — transport must set WebhookEvent.recipient");
1589
+ }
1590
+ if (!res.headersSent) sendJson(res, 200, { success: true });
1591
+ })(), new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("Webhook processing timeout")), 15e3))]);
1592
+ } catch (err) {
1593
+ logger.error({
1594
+ err,
1595
+ channel
1596
+ }, "webhook processing error or timeout");
1597
+ if (!res.headersSent) sendJson(res, 500, {
1598
+ error: "internal_error",
1599
+ message: err.message
1600
+ });
1601
+ }
1602
+ });
1603
+ logger.info(`Mounted webhook for ${channel} at ${transport.webhookPath}`);
1604
+ }
1605
+ }
1606
+ server.listen(config.PORT, config.HOST, () => {
1607
+ logger.info({
1608
+ port: config.PORT,
1609
+ host: config.HOST,
1610
+ env: config.NODE_ENV
1611
+ }, "api server listening");
1612
+ });
1613
+ }
1614
+ async function stopApiServer() {
1615
+ logger?.info("api shutdown initiated");
1616
+ if (server) {
1617
+ await new Promise((resolve, reject) => {
1618
+ server.close((err) => err ? reject(err) : resolve());
1619
+ });
1620
+ server.closeIdleConnections?.();
1621
+ server = null;
1622
+ }
1623
+ if (authSubscriber) {
1624
+ authSubscriber.disconnect();
1625
+ authSubscriber = null;
1626
+ }
1627
+ authCache = new LRUCache(1e3, AUTH_CACHE_TTL_MS);
1628
+ cachedHealth = null;
1629
+ if (sql$1) await sql$1.end();
1630
+ if (redis) await redis.disconnect();
1631
+ logger?.info("api stopped");
1632
+ }
1633
+ //#endregion
1634
+ export { startApiServer, stopApiServer };
1635
+
1636
+ //# sourceMappingURL=main-Ok9cQJ7q.mjs.map