notifkit 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -122
- package/dist/index.d.mts +196 -132
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/{main-DtHWhueo.mjs → main-40zwq6b0.mjs} +28 -3
- package/dist/{main-DtHWhueo.mjs.map → main-40zwq6b0.mjs.map} +1 -1
- package/dist/{main-DyfbnJc3.mjs → main-BFre2-HQ.mjs} +2 -2
- package/dist/{main-DyfbnJc3.mjs.map → main-BFre2-HQ.mjs.map} +1 -1
- package/dist/{main-CAH0_Q6d.mjs → main-BNJtzY61.mjs} +3 -3
- package/dist/main-BNJtzY61.mjs.map +1 -0
- package/dist/{main-B561M1d3.mjs → main-BOPMYqsW.mjs} +2 -2
- package/dist/{main-B561M1d3.mjs.map → main-BOPMYqsW.mjs.map} +1 -1
- package/dist/{main-CCfc45ev.mjs → main-CiigNpsP.mjs} +7 -4
- package/dist/main-CiigNpsP.mjs.map +1 -0
- package/dist/{main-Ce9dcrsg.mjs → main-DeNFQ-UL.mjs} +6 -3
- package/dist/{main-Ce9dcrsg.mjs.map → main-DeNFQ-UL.mjs.map} +1 -1
- package/dist/{main-B-jwm8ED.mjs → main-DmCPcxOc.mjs} +2 -2
- package/dist/{main-B-jwm8ED.mjs.map → main-DmCPcxOc.mjs.map} +1 -1
- package/dist/{main-C45e7grq.mjs → main-DvgJSm11.mjs} +2 -2
- package/dist/{main-C45e7grq.mjs.map → main-DvgJSm11.mjs.map} +1 -1
- package/dist/{src-C-PfEDMY.mjs → src-vG79L-8m.mjs} +57 -26
- package/dist/src-vG79L-8m.mjs.map +1 -0
- package/drizzle/0002_wide_colleen_wing.sql +2 -0
- package/drizzle/0003_skinny_daimon_hellstrom.sql +1 -0
- package/drizzle/0004_pretty_bruce_banner.sql +1 -0
- package/drizzle/meta/0002_snapshot.json +1460 -0
- package/drizzle/meta/0003_snapshot.json +1460 -0
- package/drizzle/meta/0004_snapshot.json +1470 -0
- package/drizzle/meta/_journal.json +21 -0
- package/package.json +3 -2
- package/src/client.ts +412 -0
- package/src/config/index.ts +107 -0
- package/src/contracts/common.ts +28 -0
- package/src/contracts/envelope.ts +31 -0
- package/src/contracts/events/notification-ai-pending.ts +18 -0
- package/src/contracts/events/notification-canceled.ts +7 -0
- package/src/contracts/events/notification-created.ts +14 -0
- package/src/contracts/events/notification-delivered.ts +17 -0
- package/src/contracts/events/notification-dispatched.ts +45 -0
- package/src/contracts/events/notification-enriched.ts +46 -0
- package/src/contracts/events/notification-failed.ts +19 -0
- package/src/contracts/events/notification-requested.ts +36 -0
- package/src/contracts/events/notification-scheduled.ts +9 -0
- package/src/contracts/events/notification-skipped.ts +9 -0
- package/src/contracts/helpers.ts +21 -0
- package/src/contracts/index.ts +46 -0
- package/src/contracts/metadata.ts +10 -0
- package/src/contracts/registry.ts +88 -0
- package/src/contracts/sdk.ts +242 -0
- package/src/contracts/streams.ts +62 -0
- package/src/db/index.ts +69 -0
- package/src/db/schema.ts +412 -0
- package/src/idempotency/index.ts +50 -0
- package/src/index.ts +19 -0
- package/src/logger/index.ts +60 -0
- package/src/metrics/index.ts +53 -0
- package/src/queue/index.ts +501 -0
- package/src/rate-limiter/index.ts +210 -0
- package/src/redis/index.ts +89 -0
- package/src/repositories/index.ts +1246 -0
- package/src/server.ts +277 -0
- package/src/services/ai/main.ts +404 -0
- package/src/services/api/handlers.ts +1734 -0
- package/src/services/api/http.ts +64 -0
- package/src/services/api/main.ts +693 -0
- package/src/services/api/router.ts +82 -0
- package/src/services/delivery/main.ts +842 -0
- package/src/services/delivery/throttle.ts +71 -0
- package/src/services/engine/main.ts +827 -0
- package/src/services/enricher/main.ts +594 -0
- package/src/services/events/main.ts +365 -0
- package/src/services/scheduler/main.ts +319 -0
- package/src/services/workflow/main.ts +627 -0
- package/src/shared/batch-processor.ts +67 -0
- package/src/shared/cache.ts +47 -0
- package/src/shared/circuit-breaker.ts +74 -0
- package/src/shared/dataloader.ts +41 -0
- package/src/shared/events.ts +3 -0
- package/src/shared/index.ts +39 -0
- package/src/shared/semaphore.ts +33 -0
- package/src/shared/utils.ts +64 -0
- package/src/templates/cache.ts +32 -0
- package/src/templates/index.ts +69 -0
- package/src/templates/render.ts +128 -0
- package/src/transport/index.ts +96 -0
- package/src/unsubscribe/index.ts +127 -0
- package/src/workers/health.ts +31 -0
- package/src/workers/index.ts +266 -0
- package/src/workflows/index.ts +2 -0
- package/src/workflows/registry.ts +21 -0
- package/src/workflows/sdk.ts +106 -0
- package/dist/main-CAH0_Q6d.mjs.map +0 -1
- package/dist/main-CCfc45ev.mjs.map +0 -1
- package/dist/src-C-PfEDMY.mjs.map +0 -1
|
@@ -0,0 +1,1734 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
|
+
import type { Logger } from "@/logger/index.js";
|
|
4
|
+
import type { RedisClient } from "@/redis/index.js";
|
|
5
|
+
import type { StreamProducer } from "@/queue/index.js";
|
|
6
|
+
import type {
|
|
7
|
+
UserRepository,
|
|
8
|
+
ContactRepository,
|
|
9
|
+
TemplateRepository,
|
|
10
|
+
ProjectRepository,
|
|
11
|
+
WorkflowRepository,
|
|
12
|
+
SegmentRepository,
|
|
13
|
+
} from "@/repositories/index.js";
|
|
14
|
+
import type { Preferences } from "@/contracts/index.js";
|
|
15
|
+
import {
|
|
16
|
+
AddUserSchema,
|
|
17
|
+
UpdateUserSchema,
|
|
18
|
+
AddContactSchema,
|
|
19
|
+
SyncTemplatesSchema,
|
|
20
|
+
NotifyRequestSchema,
|
|
21
|
+
ContactChannelSchema,
|
|
22
|
+
PreferencesSchema,
|
|
23
|
+
type NotificationRequestedPayload,
|
|
24
|
+
type NotificationTarget,
|
|
25
|
+
buildStreamEvent,
|
|
26
|
+
CreateWorkflowSchema,
|
|
27
|
+
TriggerWorkflowSchema,
|
|
28
|
+
STREAMS,
|
|
29
|
+
PUBSUB_CHANNELS,
|
|
30
|
+
} from "@/contracts/index.js";
|
|
31
|
+
import { readJsonBody, sendJson, sendNoContent, sendValidationError } from "./http.js";
|
|
32
|
+
import type { RouteContext } from "./router.js";
|
|
33
|
+
import { globalEmitter, getPriorityBucket, normaliseTarget } from "@/shared/index.js";
|
|
34
|
+
import { metrics } from "@/metrics/index.js";
|
|
35
|
+
import { eq, desc, and, lt, lte, gte, sql, like, or, isNotNull } from "drizzle-orm";
|
|
36
|
+
import {
|
|
37
|
+
messageLogs,
|
|
38
|
+
workflowDefinitions,
|
|
39
|
+
scheduledPayloads,
|
|
40
|
+
suppressions,
|
|
41
|
+
users as dbUsers,
|
|
42
|
+
userTopicPreferences,
|
|
43
|
+
} from "@/db/schema.js";
|
|
44
|
+
import { readBaseConfig } from "@/config/index.js";
|
|
45
|
+
import { verifyUnsubscribeToken } from "@/unsubscribe/index.js";
|
|
46
|
+
import type { Db } from "@/db/index.js";
|
|
47
|
+
import { z } from "zod";
|
|
48
|
+
|
|
49
|
+
const IngestEventSchema = z.object({
|
|
50
|
+
name: z.string().min(1),
|
|
51
|
+
properties: z.record(z.string(), z.unknown()),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export interface Deps {
|
|
55
|
+
logger: Logger;
|
|
56
|
+
redis: RedisClient;
|
|
57
|
+
producers: Record<string, StreamProducer>;
|
|
58
|
+
userRepo: UserRepository;
|
|
59
|
+
contactRepo: ContactRepository;
|
|
60
|
+
templateRepo: TemplateRepository;
|
|
61
|
+
projectRepo: ProjectRepository;
|
|
62
|
+
workflowRepo: WorkflowRepository;
|
|
63
|
+
segmentRepo: SegmentRepository;
|
|
64
|
+
db: Db;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Inline user shape (email/phone/pushToken already normalised to string[] by Zod).
|
|
68
|
+
interface InlineUserLike {
|
|
69
|
+
id: string;
|
|
70
|
+
language?: string;
|
|
71
|
+
timezone?: string;
|
|
72
|
+
email?: string[];
|
|
73
|
+
phone?: string[];
|
|
74
|
+
pushToken?: string[];
|
|
75
|
+
segments?: string[];
|
|
76
|
+
preferences?: Preferences;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Map an inline user's contact arrays into (channel, target) pairs. */
|
|
80
|
+
function contactsOf(user: InlineUserLike): { channel: "email" | "sms" | "push"; target: string }[] {
|
|
81
|
+
return [
|
|
82
|
+
...(user.email ?? []).map((target) => ({ channel: "email" as const, target })),
|
|
83
|
+
...(user.phone ?? []).map((target) => ({ channel: "sms" as const, target })),
|
|
84
|
+
...(user.pushToken ?? []).map((target) => ({ channel: "push" as const, target })),
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Persist user records + their contacts in bulk. Shared by addUser and inline notify. */
|
|
89
|
+
async function persistUsers(deps: Deps, users: InlineUserLike[], projectId: string): Promise<void> {
|
|
90
|
+
const usersList = users.map((u) => ({
|
|
91
|
+
userId: u.id,
|
|
92
|
+
language: u.language ?? "en",
|
|
93
|
+
timezone: u.timezone ?? "UTC",
|
|
94
|
+
email: u.email?.[0] ?? null,
|
|
95
|
+
segments: u.segments ?? [],
|
|
96
|
+
preferences: u.preferences ?? {},
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
const contactsList: any[] = [];
|
|
100
|
+
for (const u of users) {
|
|
101
|
+
for (const c of contactsOf(u)) {
|
|
102
|
+
contactsList.push({
|
|
103
|
+
userId: u.id,
|
|
104
|
+
channel: c.channel,
|
|
105
|
+
target: c.target,
|
|
106
|
+
preferences: {},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
await deps.db.transaction(async (tx) => {
|
|
112
|
+
const txUserRepo =
|
|
113
|
+
deps.userRepo.constructor.name === "Object"
|
|
114
|
+
? deps.userRepo
|
|
115
|
+
: new (deps.userRepo.constructor as any)(tx);
|
|
116
|
+
const txContactRepo =
|
|
117
|
+
deps.contactRepo.constructor.name === "Object"
|
|
118
|
+
? deps.contactRepo
|
|
119
|
+
: new (deps.contactRepo.constructor as any)(tx);
|
|
120
|
+
await txUserRepo.upsertManyFull(projectId, usersList);
|
|
121
|
+
await txContactRepo.upsertMany(projectId, contactsList);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function getQueryParam(ctx: RouteContext, key: string): string | undefined {
|
|
126
|
+
if (!ctx.query) return undefined;
|
|
127
|
+
if (typeof ctx.query.get === "function") {
|
|
128
|
+
return ctx.query.get(key) ?? undefined;
|
|
129
|
+
}
|
|
130
|
+
return (ctx.query as any)[key] ?? undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function createHandlers(deps: Deps) {
|
|
134
|
+
const { logger } = deps;
|
|
135
|
+
|
|
136
|
+
// ── PUT /v1/templates — initializeApp({ templates }) ──────────────────────
|
|
137
|
+
async function syncTemplates(
|
|
138
|
+
req: IncomingMessage,
|
|
139
|
+
res: ServerResponse,
|
|
140
|
+
ctx: RouteContext,
|
|
141
|
+
): Promise<void> {
|
|
142
|
+
const parsed = SyncTemplatesSchema.safeParse(await readJsonBody(req));
|
|
143
|
+
if (!parsed.success) return sendValidationError(res, parsed.error);
|
|
144
|
+
|
|
145
|
+
const synced = await deps.templateRepo.upsertMany(
|
|
146
|
+
ctx.projectId!,
|
|
147
|
+
parsed.data.templates.map((t) => ({
|
|
148
|
+
id: t.id,
|
|
149
|
+
channel: t.channel,
|
|
150
|
+
topics: t.topic ?? [],
|
|
151
|
+
content: t.content,
|
|
152
|
+
aiPrompts: (t as any).aiPrompts,
|
|
153
|
+
})),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// Invalidate caches
|
|
157
|
+
for (const t of parsed.data.templates) {
|
|
158
|
+
await deps.redis.native.publish("template.invalidated", `${ctx.projectId}:${t.id}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
logger.info({ synced }, "templates synced");
|
|
162
|
+
sendJson(res, 200, { synced });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── POST /v1/users — addUser ──────────────────────────────────────────────
|
|
166
|
+
async function addUser(
|
|
167
|
+
req: IncomingMessage,
|
|
168
|
+
res: ServerResponse,
|
|
169
|
+
ctx: RouteContext,
|
|
170
|
+
): Promise<void> {
|
|
171
|
+
const parsed = AddUserSchema.safeParse(await readJsonBody(req));
|
|
172
|
+
if (!parsed.success) return sendValidationError(res, parsed.error);
|
|
173
|
+
|
|
174
|
+
await persistUsers(deps, [parsed.data], ctx.projectId!);
|
|
175
|
+
logger.info({ userId: parsed.data.id }, "user upserted");
|
|
176
|
+
sendJson(res, 201, { id: parsed.data.id });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── PATCH /v1/users/:id — updateUser ──────────────────────────────────────
|
|
180
|
+
async function updateUser(
|
|
181
|
+
req: IncomingMessage,
|
|
182
|
+
res: ServerResponse,
|
|
183
|
+
ctx: RouteContext,
|
|
184
|
+
): Promise<void> {
|
|
185
|
+
const userId = ctx.params.id!;
|
|
186
|
+
const parsed = UpdateUserSchema.safeParse(await readJsonBody(req));
|
|
187
|
+
if (!parsed.success) return sendValidationError(res, parsed.error);
|
|
188
|
+
const patch = parsed.data;
|
|
189
|
+
|
|
190
|
+
const updated = await deps.userRepo.updatePartial(ctx.projectId!, userId, {
|
|
191
|
+
language: patch.language,
|
|
192
|
+
timezone: patch.timezone,
|
|
193
|
+
email: patch.email?.[0],
|
|
194
|
+
segments: patch.segments,
|
|
195
|
+
preferences: patch.preferences,
|
|
196
|
+
});
|
|
197
|
+
if (!updated) return sendJson(res, 404, { error: "user_not_found", id: userId });
|
|
198
|
+
|
|
199
|
+
// New contact values in the patch are added (existing ones are kept).
|
|
200
|
+
for (const c of contactsOf({ id: userId, ...patch })) {
|
|
201
|
+
await deps.contactRepo.upsert(ctx.projectId!, userId, c.channel, c.target);
|
|
202
|
+
}
|
|
203
|
+
logger.info({ userId }, "user updated");
|
|
204
|
+
sendJson(res, 200, { id: userId });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── DELETE /v1/users/:id — deleteUser ─────────────────────────────────────
|
|
208
|
+
async function deleteUser(
|
|
209
|
+
_req: IncomingMessage,
|
|
210
|
+
res: ServerResponse,
|
|
211
|
+
ctx: RouteContext,
|
|
212
|
+
): Promise<void> {
|
|
213
|
+
const userId = ctx.params.id!;
|
|
214
|
+
const deleted = await deps.userRepo.delete(ctx.projectId!, userId);
|
|
215
|
+
if (!deleted) return sendJson(res, 404, { error: "user_not_found", id: userId });
|
|
216
|
+
logger.info({ userId }, "user deleted");
|
|
217
|
+
sendNoContent(res);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── POST /v1/users/:id/contacts — addUserContact ──────────────────────────
|
|
221
|
+
async function addContact(
|
|
222
|
+
req: IncomingMessage,
|
|
223
|
+
res: ServerResponse,
|
|
224
|
+
ctx: RouteContext,
|
|
225
|
+
): Promise<void> {
|
|
226
|
+
const userId = ctx.params.id!;
|
|
227
|
+
const parsed = AddContactSchema.safeParse(await readJsonBody(req));
|
|
228
|
+
if (!parsed.success) return sendValidationError(res, parsed.error);
|
|
229
|
+
|
|
230
|
+
const user = await deps.userRepo.findById(ctx.projectId!, userId);
|
|
231
|
+
if (!user) return sendJson(res, 404, { error: "user_not_found", id: userId });
|
|
232
|
+
|
|
233
|
+
await deps.contactRepo.upsert(
|
|
234
|
+
ctx.projectId!,
|
|
235
|
+
userId,
|
|
236
|
+
parsed.data.channel,
|
|
237
|
+
parsed.data.target,
|
|
238
|
+
parsed.data.preferences ?? {},
|
|
239
|
+
);
|
|
240
|
+
logger.info({ userId, channel: parsed.data.channel }, "contact added");
|
|
241
|
+
sendJson(res, 201, { userId, channel: parsed.data.channel, target: parsed.data.target });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── DELETE /v1/users/:id/contacts/:channel/:target — deleteUserContact ────
|
|
245
|
+
async function deleteContact(
|
|
246
|
+
_req: IncomingMessage,
|
|
247
|
+
res: ServerResponse,
|
|
248
|
+
ctx: RouteContext,
|
|
249
|
+
): Promise<void> {
|
|
250
|
+
const userId = ctx.params.id!;
|
|
251
|
+
const channelResult = ContactChannelSchema.safeParse(ctx.params.channel);
|
|
252
|
+
if (!channelResult.success)
|
|
253
|
+
return sendJson(res, 400, { error: "invalid_channel", channel: ctx.params.channel });
|
|
254
|
+
|
|
255
|
+
const deleted = await deps.contactRepo.delete(
|
|
256
|
+
ctx.projectId!,
|
|
257
|
+
userId,
|
|
258
|
+
channelResult.data,
|
|
259
|
+
ctx.params.target!,
|
|
260
|
+
);
|
|
261
|
+
if (!deleted) return sendJson(res, 404, { error: "contact_not_found" });
|
|
262
|
+
logger.info({ userId, channel: channelResult.data }, "contact deleted");
|
|
263
|
+
sendNoContent(res);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── POST /v1/notify — notify ──────────────────────────────────────────────
|
|
267
|
+
async function notify(
|
|
268
|
+
req: IncomingMessage,
|
|
269
|
+
res: ServerResponse,
|
|
270
|
+
ctx: RouteContext,
|
|
271
|
+
): Promise<void> {
|
|
272
|
+
const parsed = NotifyRequestSchema.safeParse(await readJsonBody(req));
|
|
273
|
+
if (!parsed.success) return sendValidationError(res, parsed.error);
|
|
274
|
+
const body = parsed.data;
|
|
275
|
+
|
|
276
|
+
// Resolve the (unresolved) target. Inline users are synced first.
|
|
277
|
+
let targets: NotificationTarget[] = [];
|
|
278
|
+
if (body.user !== undefined) {
|
|
279
|
+
const users = Array.isArray(body.user) ? body.user : [body.user];
|
|
280
|
+
const inlineUsersToSync: InlineUserLike[] = [];
|
|
281
|
+
for (const u of users) {
|
|
282
|
+
if (typeof u === "string") {
|
|
283
|
+
targets.push({ type: "user", userId: u });
|
|
284
|
+
} else {
|
|
285
|
+
inlineUsersToSync.push(u);
|
|
286
|
+
targets.push({ type: "user", userId: u.id });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (inlineUsersToSync.length > 0) {
|
|
291
|
+
try {
|
|
292
|
+
await persistUsers(deps, inlineUsersToSync, ctx.projectId!);
|
|
293
|
+
} catch (err) {
|
|
294
|
+
deps.logger.error({ err }, "inline user bulk sync failed");
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
} else if (body.segment !== undefined) {
|
|
298
|
+
targets.push({ type: "segment", segment: body.segment });
|
|
299
|
+
} else {
|
|
300
|
+
targets.push({ type: "topic", topic: body.topic! });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const baseNotificationId = (req.headers["x-idempotency-key"] as string) || randomUUID();
|
|
304
|
+
|
|
305
|
+
const priority = body.priority ?? "normal";
|
|
306
|
+
const p = getPriorityBucket(priority);
|
|
307
|
+
const producer = deps.producers[p] ?? deps.producers.normal!;
|
|
308
|
+
|
|
309
|
+
const messageIds: string[] = [];
|
|
310
|
+
let lastNotificationId = "";
|
|
311
|
+
|
|
312
|
+
const CHUNK_SIZE = 1000;
|
|
313
|
+
for (let chunkStart = 0; chunkStart < targets.length; chunkStart += CHUNK_SIZE) {
|
|
314
|
+
const chunk = targets.slice(chunkStart, chunkStart + CHUNK_SIZE);
|
|
315
|
+
|
|
316
|
+
const events = chunk.map((target, idx) => {
|
|
317
|
+
const i = chunkStart + idx;
|
|
318
|
+
const notificationId =
|
|
319
|
+
targets.length > 1 ? `${baseNotificationId}-${i}` : baseNotificationId;
|
|
320
|
+
lastNotificationId = notificationId;
|
|
321
|
+
|
|
322
|
+
const payload: NotificationRequestedPayload = {
|
|
323
|
+
projectId: ctx.projectId!,
|
|
324
|
+
target,
|
|
325
|
+
templateId: body.template,
|
|
326
|
+
priority,
|
|
327
|
+
channels: body.channels,
|
|
328
|
+
data: body.data ?? {},
|
|
329
|
+
aiPrompts: body.aiPrompts,
|
|
330
|
+
fallback: body.fallback ?? false,
|
|
331
|
+
scheduledAt: body.sendAt,
|
|
332
|
+
idempotencyKey: notificationId,
|
|
333
|
+
campaignId: body.campaign,
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
return buildStreamEvent(
|
|
337
|
+
"notification.requested",
|
|
338
|
+
payload as Record<string, unknown>,
|
|
339
|
+
"api",
|
|
340
|
+
notificationId,
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const { messageIds: chunkMessageIds } = await producer.publishBatch(events);
|
|
345
|
+
messageIds.push(...chunkMessageIds);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
logger.info({ count: targets.length, priority: p }, "notification requested");
|
|
349
|
+
metrics.messagesPublished.inc({ channel: "api", priority: p }, targets.length);
|
|
350
|
+
|
|
351
|
+
if (targets.length === 1) {
|
|
352
|
+
sendJson(res, 202, {
|
|
353
|
+
messageId: messageIds[0],
|
|
354
|
+
notificationId: lastNotificationId,
|
|
355
|
+
target: targets[0],
|
|
356
|
+
...(body.campaign ? { campaign: body.campaign } : {}),
|
|
357
|
+
});
|
|
358
|
+
} else {
|
|
359
|
+
sendJson(res, 202, {
|
|
360
|
+
messageIds,
|
|
361
|
+
notificationIdsBase: baseNotificationId,
|
|
362
|
+
batchSize: targets.length,
|
|
363
|
+
...(body.campaign ? { campaign: body.campaign } : {}),
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ── DELETE /v1/notifications/:taskId — cancelNotification ─────────────────
|
|
369
|
+
async function cancelNotification(
|
|
370
|
+
req: IncomingMessage,
|
|
371
|
+
res: ServerResponse,
|
|
372
|
+
ctx: RouteContext,
|
|
373
|
+
): Promise<void> {
|
|
374
|
+
const { taskId } = ctx.params;
|
|
375
|
+
if (!taskId) return sendJson(res, 400, { error: "missing_task_id" });
|
|
376
|
+
|
|
377
|
+
const rows = await deps.db
|
|
378
|
+
.select({ payload: scheduledPayloads.payload })
|
|
379
|
+
.from(scheduledPayloads)
|
|
380
|
+
.where(eq(scheduledPayloads.taskId, taskId))
|
|
381
|
+
.limit(1);
|
|
382
|
+
|
|
383
|
+
if (rows.length === 0) {
|
|
384
|
+
return sendJson(res, 404, {
|
|
385
|
+
error: "not_found",
|
|
386
|
+
message: "Task not found or already processed",
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const payload = rows[0]!.payload as any;
|
|
391
|
+
if (payload.projectId !== ctx.projectId) {
|
|
392
|
+
return sendJson(res, 404, {
|
|
393
|
+
error: "not_found",
|
|
394
|
+
message: "Task not found or already processed",
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const deletedRows = await deps.db
|
|
399
|
+
.delete(scheduledPayloads)
|
|
400
|
+
.where(eq(scheduledPayloads.taskId, taskId))
|
|
401
|
+
.returning({ taskId: scheduledPayloads.taskId });
|
|
402
|
+
|
|
403
|
+
if (deletedRows.length > 0) {
|
|
404
|
+
globalEmitter.emit("notification:canceled", { projectId: ctx.projectId, taskId });
|
|
405
|
+
sendJson(res, 200, { success: true });
|
|
406
|
+
} else {
|
|
407
|
+
sendJson(res, 404, { error: "not_found", message: "Task not found or already processed" });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ── POST /v1/workflows/trigger — triggerWorkflow ──────────────────────────
|
|
412
|
+
async function triggerWorkflow(
|
|
413
|
+
req: IncomingMessage,
|
|
414
|
+
res: ServerResponse,
|
|
415
|
+
ctx: RouteContext,
|
|
416
|
+
): Promise<void> {
|
|
417
|
+
const parsed = TriggerWorkflowSchema.safeParse(await readJsonBody(req));
|
|
418
|
+
if (!parsed.success) return sendValidationError(res, parsed.error);
|
|
419
|
+
|
|
420
|
+
const input: Record<string, unknown> = { ...(parsed.data.input ?? {}) };
|
|
421
|
+
if (parsed.data.user) {
|
|
422
|
+
if (typeof parsed.data.user === "string") {
|
|
423
|
+
input.user = {
|
|
424
|
+
id: parsed.data.user,
|
|
425
|
+
...(typeof input.user === "object" && input.user
|
|
426
|
+
? (input.user as Record<string, unknown>)
|
|
427
|
+
: {}),
|
|
428
|
+
};
|
|
429
|
+
} else {
|
|
430
|
+
try {
|
|
431
|
+
await persistUsers(deps, [parsed.data.user], ctx.projectId!);
|
|
432
|
+
} catch (err) {
|
|
433
|
+
deps.logger.error({ err }, "inline user sync for workflow failed");
|
|
434
|
+
}
|
|
435
|
+
input.user = {
|
|
436
|
+
id: parsed.data.user.id,
|
|
437
|
+
...(typeof input.user === "object" && input.user
|
|
438
|
+
? (input.user as Record<string, unknown>)
|
|
439
|
+
: {}),
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const instanceId = randomUUID();
|
|
445
|
+
const messageId = await deps.producers.workflow!.publish(
|
|
446
|
+
buildStreamEvent(
|
|
447
|
+
"workflow.triggered",
|
|
448
|
+
{
|
|
449
|
+
projectId: ctx.projectId!,
|
|
450
|
+
instanceId,
|
|
451
|
+
name: parsed.data.name,
|
|
452
|
+
input,
|
|
453
|
+
},
|
|
454
|
+
"api",
|
|
455
|
+
instanceId,
|
|
456
|
+
),
|
|
457
|
+
);
|
|
458
|
+
logger.info({ messageId, instanceId, workflowName: parsed.data.name }, "workflow triggered");
|
|
459
|
+
sendJson(res, 202, { messageId, instanceId });
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ── POST /v1/events — ingestEvent ─────────────────────────────────────────
|
|
463
|
+
async function ingestEvent(
|
|
464
|
+
req: IncomingMessage,
|
|
465
|
+
res: ServerResponse,
|
|
466
|
+
ctx: RouteContext,
|
|
467
|
+
): Promise<void> {
|
|
468
|
+
const tsStr = req.headers["x-timestamp"];
|
|
469
|
+
const expiryStr = req.headers["x-expiry"];
|
|
470
|
+
|
|
471
|
+
if (tsStr && expiryStr) {
|
|
472
|
+
const now = Date.now();
|
|
473
|
+
const eventTime = new Date(tsStr as string).getTime();
|
|
474
|
+
const expMs = parseInt(expiryStr as string, 10) * 1000;
|
|
475
|
+
|
|
476
|
+
if (now > eventTime + expMs) {
|
|
477
|
+
sendJson(res, 400, { error: "event_expired", message: "Webhook event is expired" });
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const parsed = IngestEventSchema.safeParse(await readJsonBody(req));
|
|
483
|
+
if (!parsed.success) return sendValidationError(res, parsed.error);
|
|
484
|
+
|
|
485
|
+
const eventId = randomUUID();
|
|
486
|
+
const messageId = await deps.producers.events!.publish(
|
|
487
|
+
buildStreamEvent(
|
|
488
|
+
"event.received",
|
|
489
|
+
{
|
|
490
|
+
projectId: ctx.projectId!,
|
|
491
|
+
eventName: parsed.data.name,
|
|
492
|
+
payload: parsed.data.properties,
|
|
493
|
+
},
|
|
494
|
+
"api",
|
|
495
|
+
eventId,
|
|
496
|
+
),
|
|
497
|
+
);
|
|
498
|
+
logger.info({ messageId, eventId, eventName: parsed.data.name }, "event received");
|
|
499
|
+
sendJson(res, 202, { messageId, eventId });
|
|
500
|
+
}
|
|
501
|
+
// ── GET /v1/events/stream — getEventsStream ──────────────────────────────────
|
|
502
|
+
async function getEventsStream(
|
|
503
|
+
req: IncomingMessage,
|
|
504
|
+
res: ServerResponse,
|
|
505
|
+
ctx: RouteContext,
|
|
506
|
+
): Promise<void> {
|
|
507
|
+
res.writeHead(200, {
|
|
508
|
+
"Content-Type": "text/event-stream",
|
|
509
|
+
"Cache-Control": "no-cache",
|
|
510
|
+
Connection: "keep-alive",
|
|
511
|
+
});
|
|
512
|
+
res.write("retry: 10000\n\n");
|
|
513
|
+
|
|
514
|
+
const onDelivered = (
|
|
515
|
+
taskId: string,
|
|
516
|
+
providerMessageId: string,
|
|
517
|
+
channel: string,
|
|
518
|
+
eventProjectId: string,
|
|
519
|
+
) => {
|
|
520
|
+
if (eventProjectId && eventProjectId !== ctx.projectId) return;
|
|
521
|
+
res.write(`event: delivery:delivered\n`);
|
|
522
|
+
res.write(`data: ${JSON.stringify({ taskId, providerMessageId, channel })}\n\n`);
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
const onFailed = (taskId: string, error: string, channel: string, eventProjectId: string) => {
|
|
526
|
+
if (eventProjectId && eventProjectId !== ctx.projectId) return;
|
|
527
|
+
res.write(`event: delivery:failed\n`);
|
|
528
|
+
res.write(`data: ${JSON.stringify({ taskId, error, channel })}\n\n`);
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
globalEmitter.on("delivery:delivered", onDelivered);
|
|
532
|
+
globalEmitter.on("delivery:failed", onFailed);
|
|
533
|
+
|
|
534
|
+
req.on("close", () => {
|
|
535
|
+
globalEmitter.off("delivery:delivered", onDelivered);
|
|
536
|
+
globalEmitter.off("delivery:failed", onFailed);
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ── GET /v1/notifications/logs — getNotificationLogs ────────────────────
|
|
541
|
+
async function getNotificationLogs(
|
|
542
|
+
req: IncomingMessage,
|
|
543
|
+
res: ServerResponse,
|
|
544
|
+
ctx: RouteContext,
|
|
545
|
+
): Promise<void> {
|
|
546
|
+
const limitParam = parseInt(ctx.query.get("limit") || "50", 10);
|
|
547
|
+
const limit = isNaN(limitParam) ? 50 : Math.min(limitParam, 100);
|
|
548
|
+
const cursor = ctx.query.get("cursor");
|
|
549
|
+
|
|
550
|
+
const templateId = getQueryParam(ctx, "templateId");
|
|
551
|
+
const workflowInstanceId = getQueryParam(ctx, "workflowInstanceId");
|
|
552
|
+
const channel = getQueryParam(ctx, "channel");
|
|
553
|
+
const status = getQueryParam(ctx, "status");
|
|
554
|
+
const taskId = getQueryParam(ctx, "taskId");
|
|
555
|
+
const campaign = getQueryParam(ctx, "campaign") || getQueryParam(ctx, "campaignId");
|
|
556
|
+
const search = getQueryParam(ctx, "search");
|
|
557
|
+
|
|
558
|
+
const conditions = [eq(messageLogs.projectId, ctx.projectId!)];
|
|
559
|
+
if (templateId) conditions.push(eq(messageLogs.templateId, templateId));
|
|
560
|
+
if (workflowInstanceId) conditions.push(eq(messageLogs.workflowInstanceId, workflowInstanceId));
|
|
561
|
+
if (channel) conditions.push(eq(messageLogs.channel, channel as any));
|
|
562
|
+
if (status) conditions.push(eq(messageLogs.status, status as any));
|
|
563
|
+
if (taskId) conditions.push(eq(messageLogs.taskId, taskId));
|
|
564
|
+
if (campaign) conditions.push(eq(messageLogs.campaignId, campaign));
|
|
565
|
+
if (search) {
|
|
566
|
+
conditions.push(
|
|
567
|
+
or(
|
|
568
|
+
like(messageLogs.taskId, `%${search}%`),
|
|
569
|
+
like(messageLogs.templateId, `%${search}%`),
|
|
570
|
+
like(messageLogs.providerMessageId, `%${search}%`),
|
|
571
|
+
like(messageLogs.campaignId, `%${search}%`),
|
|
572
|
+
)!,
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (cursor) {
|
|
577
|
+
const cursorDate = new Date(parseInt(cursor, 10));
|
|
578
|
+
conditions.push(lt(messageLogs.timestamp, cursorDate));
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const logs = await deps.db
|
|
582
|
+
.select()
|
|
583
|
+
.from(messageLogs)
|
|
584
|
+
.where(and(...conditions))
|
|
585
|
+
.orderBy(desc(messageLogs.timestamp))
|
|
586
|
+
.limit(limit * 2);
|
|
587
|
+
|
|
588
|
+
// Deduplicate by taskId preserving the latest status per taskId
|
|
589
|
+
const deduplicatedMap = new Map<string, (typeof logs)[0]>();
|
|
590
|
+
for (const log of logs) {
|
|
591
|
+
if (!deduplicatedMap.has(log.taskId)) {
|
|
592
|
+
deduplicatedMap.set(log.taskId, log);
|
|
593
|
+
} else {
|
|
594
|
+
const existing = deduplicatedMap.get(log.taskId)!;
|
|
595
|
+
if (existing.status === "dispatched" && log.status !== "dispatched") {
|
|
596
|
+
deduplicatedMap.set(log.taskId, log);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
const deduplicatedLogs = Array.from(deduplicatedMap.values()).slice(0, limit);
|
|
601
|
+
|
|
602
|
+
const nextCursor =
|
|
603
|
+
logs.length === limit * 2 ? logs[logs.length - 1]!.timestamp.getTime().toString() : null;
|
|
604
|
+
|
|
605
|
+
sendJson(res, 200, { logs: deduplicatedLogs, nextCursor });
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ── GET /v1/notifications/:taskId — getNotificationStatus ───────────────
|
|
609
|
+
async function getNotificationStatus(
|
|
610
|
+
req: IncomingMessage,
|
|
611
|
+
res: ServerResponse,
|
|
612
|
+
ctx: RouteContext,
|
|
613
|
+
): Promise<void> {
|
|
614
|
+
const { taskId } = ctx.params;
|
|
615
|
+
if (!taskId) return sendJson(res, 400, { error: "missing_task_id" });
|
|
616
|
+
|
|
617
|
+
const logs = await deps.db
|
|
618
|
+
.select()
|
|
619
|
+
.from(messageLogs)
|
|
620
|
+
.where(and(eq(messageLogs.taskId, taskId), eq(messageLogs.projectId, ctx.projectId!)))
|
|
621
|
+
.orderBy(desc(messageLogs.timestamp));
|
|
622
|
+
|
|
623
|
+
if (logs.length === 0) {
|
|
624
|
+
// It might still be pending in Redis or scheduled
|
|
625
|
+
return sendJson(res, 404, {
|
|
626
|
+
error: "not_found",
|
|
627
|
+
message: "Task not found or not processed yet",
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Newest first, so `status` is the latest known state rather than whichever
|
|
632
|
+
// row Postgres happened to return first.
|
|
633
|
+
sendJson(res, 200, { status: logs[0]?.status, logs });
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ── GET /v1/users/:id — getUser ───────────────────────────────────────────
|
|
637
|
+
async function getUser(
|
|
638
|
+
req: IncomingMessage,
|
|
639
|
+
res: ServerResponse,
|
|
640
|
+
ctx: RouteContext,
|
|
641
|
+
): Promise<void> {
|
|
642
|
+
const userId = ctx.params.id!;
|
|
643
|
+
const user = await deps.userRepo.findRecordById(ctx.projectId!, userId);
|
|
644
|
+
if (!user) return sendJson(res, 404, { error: "user_not_found", id: userId });
|
|
645
|
+
|
|
646
|
+
const contacts = await deps.contactRepo.findByUserId(ctx.projectId!, userId);
|
|
647
|
+
sendJson(res, 200, { ...user, contacts });
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// ── GET /v1/users/:id/preferences — getUserPreferences ────────────────────
|
|
651
|
+
async function getUserPreferences(
|
|
652
|
+
req: IncomingMessage,
|
|
653
|
+
res: ServerResponse,
|
|
654
|
+
ctx: RouteContext,
|
|
655
|
+
): Promise<void> {
|
|
656
|
+
const userId = ctx.params.id!;
|
|
657
|
+
const user = await deps.userRepo.findRecordById(ctx.projectId!, userId);
|
|
658
|
+
if (!user) return sendJson(res, 404, { error: "user_not_found", id: userId });
|
|
659
|
+
|
|
660
|
+
sendJson(res, 200, user.preferences);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// ── PATCH /v1/users/:id/preferences — updateUserPreferences ───────────────
|
|
664
|
+
async function updateUserPreferences(
|
|
665
|
+
req: IncomingMessage,
|
|
666
|
+
res: ServerResponse,
|
|
667
|
+
ctx: RouteContext,
|
|
668
|
+
): Promise<void> {
|
|
669
|
+
const userId = ctx.params.id!;
|
|
670
|
+
const parsed = PreferencesSchema.safeParse(await readJsonBody(req));
|
|
671
|
+
if (!parsed.success) return sendValidationError(res, parsed.error);
|
|
672
|
+
|
|
673
|
+
const updated = await deps.userRepo.updatePartial(ctx.projectId!, userId, {
|
|
674
|
+
preferences: parsed.data,
|
|
675
|
+
});
|
|
676
|
+
if (!updated) return sendJson(res, 404, { error: "user_not_found", id: userId });
|
|
677
|
+
|
|
678
|
+
logger.info({ userId }, "user preferences updated");
|
|
679
|
+
sendJson(res, 200, { id: userId, preferences: parsed.data });
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// ── POST /v1/workflows — createWorkflow ───────────────────────────────────
|
|
683
|
+
async function createWorkflow(
|
|
684
|
+
req: IncomingMessage,
|
|
685
|
+
res: ServerResponse,
|
|
686
|
+
ctx: RouteContext,
|
|
687
|
+
): Promise<void> {
|
|
688
|
+
const body = await readJsonBody(req);
|
|
689
|
+
const parsed = CreateWorkflowSchema.safeParse(body);
|
|
690
|
+
if (!parsed.success) return sendValidationError(res, parsed.error);
|
|
691
|
+
const input = parsed.data;
|
|
692
|
+
|
|
693
|
+
try {
|
|
694
|
+
await deps.db
|
|
695
|
+
.insert(workflowDefinitions)
|
|
696
|
+
.values({
|
|
697
|
+
projectId: ctx.projectId!,
|
|
698
|
+
name: input.name,
|
|
699
|
+
steps: input.steps,
|
|
700
|
+
})
|
|
701
|
+
.onConflictDoUpdate({
|
|
702
|
+
target: [workflowDefinitions.projectId, workflowDefinitions.name],
|
|
703
|
+
set: {
|
|
704
|
+
steps: input.steps,
|
|
705
|
+
},
|
|
706
|
+
});
|
|
707
|
+
sendJson(res, 201, { name: input.name });
|
|
708
|
+
} catch (error: any) {
|
|
709
|
+
deps.logger.error({ err: error }, "Failed to create workflow definition");
|
|
710
|
+
sendJson(res, 500, { error: "internal_error", message: error.message });
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// ── GET /v1/templates — listTemplates ─────────────────────────────────────
|
|
715
|
+
async function listTemplates(
|
|
716
|
+
req: IncomingMessage,
|
|
717
|
+
res: ServerResponse,
|
|
718
|
+
ctx: RouteContext,
|
|
719
|
+
): Promise<void> {
|
|
720
|
+
const limitParam = getQueryParam(ctx, "limit");
|
|
721
|
+
const limit = limitParam ? Math.min(parseInt(limitParam, 10), 100) : undefined;
|
|
722
|
+
const channel = getQueryParam(ctx, "channel");
|
|
723
|
+
const topic = getQueryParam(ctx, "topic");
|
|
724
|
+
let templates = await deps.templateRepo.list(ctx.projectId!);
|
|
725
|
+
if (channel) {
|
|
726
|
+
templates = templates.filter((t: any) => t.channel === channel);
|
|
727
|
+
}
|
|
728
|
+
if (topic) {
|
|
729
|
+
templates = templates.filter((t: any) => t.topics?.includes(topic));
|
|
730
|
+
}
|
|
731
|
+
if (limit) {
|
|
732
|
+
templates = templates.slice(0, limit);
|
|
733
|
+
}
|
|
734
|
+
sendJson(res, 200, { templates });
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ── GET /v1/templates/:id — getTemplate ───────────────────────────────────
|
|
738
|
+
async function getTemplate(
|
|
739
|
+
req: IncomingMessage,
|
|
740
|
+
res: ServerResponse,
|
|
741
|
+
ctx: RouteContext,
|
|
742
|
+
): Promise<void> {
|
|
743
|
+
const templateId = ctx.params.id!;
|
|
744
|
+
const template = await deps.templateRepo.findById(ctx.projectId!, templateId);
|
|
745
|
+
if (!template) return sendJson(res, 404, { error: "template_not_found", id: templateId });
|
|
746
|
+
|
|
747
|
+
sendJson(res, 200, template);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// ── DELETE /v1/templates/:id — deleteTemplate ───────────────────────────
|
|
751
|
+
async function deleteTemplate(
|
|
752
|
+
req: IncomingMessage,
|
|
753
|
+
res: ServerResponse,
|
|
754
|
+
ctx: RouteContext,
|
|
755
|
+
): Promise<void> {
|
|
756
|
+
const templateId = ctx.params.id!;
|
|
757
|
+
const deleted = await deps.templateRepo.delete(ctx.projectId!, templateId);
|
|
758
|
+
if (!deleted) return sendJson(res, 404, { error: "template_not_found", id: templateId });
|
|
759
|
+
|
|
760
|
+
await deps.redis.native.publish("template.invalidated", `${ctx.projectId}:${templateId}`);
|
|
761
|
+
logger.info({ templateId }, "template deleted");
|
|
762
|
+
sendNoContent(res);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// ── GET /v1/workflows — listWorkflows ─────────────────────────────────────
|
|
766
|
+
async function listWorkflows(
|
|
767
|
+
req: IncomingMessage,
|
|
768
|
+
res: ServerResponse,
|
|
769
|
+
ctx: RouteContext,
|
|
770
|
+
): Promise<void> {
|
|
771
|
+
const limitParam = getQueryParam(ctx, "limit");
|
|
772
|
+
const limit = limitParam ? Math.min(parseInt(limitParam, 10), 100) : undefined;
|
|
773
|
+
const search = getQueryParam(ctx, "search")?.trim().toLowerCase();
|
|
774
|
+
let workflows = await deps.workflowRepo.listDefinitions(ctx.projectId!);
|
|
775
|
+
if (search) {
|
|
776
|
+
workflows = workflows.filter((w: any) => w.name?.toLowerCase().includes(search));
|
|
777
|
+
}
|
|
778
|
+
if (limit) {
|
|
779
|
+
workflows = workflows.slice(0, limit);
|
|
780
|
+
}
|
|
781
|
+
sendJson(res, 200, { workflows });
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// ── GET /v1/workflows/instances/:id — getWorkflow ─────────────────────────
|
|
785
|
+
async function getWorkflow(
|
|
786
|
+
req: IncomingMessage,
|
|
787
|
+
res: ServerResponse,
|
|
788
|
+
ctx: RouteContext,
|
|
789
|
+
): Promise<void> {
|
|
790
|
+
const instanceId = ctx.params.id!;
|
|
791
|
+
const workflow = await deps.workflowRepo.getInstance(ctx.projectId!, instanceId);
|
|
792
|
+
if (!workflow) return sendJson(res, 404, { error: "workflow_not_found", id: instanceId });
|
|
793
|
+
|
|
794
|
+
sendJson(res, 200, workflow);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// ── DELETE /v1/workflows/instances/:id — cancelWorkflow ───────────────────
|
|
798
|
+
async function cancelWorkflow(
|
|
799
|
+
req: IncomingMessage,
|
|
800
|
+
res: ServerResponse,
|
|
801
|
+
ctx: RouteContext,
|
|
802
|
+
): Promise<void> {
|
|
803
|
+
const instanceId = ctx.params.id!;
|
|
804
|
+
const canceled = await deps.workflowRepo.cancelInstance(ctx.projectId!, instanceId);
|
|
805
|
+
if (!canceled) return sendJson(res, 400, { error: "workflow_not_cancelable", id: instanceId });
|
|
806
|
+
|
|
807
|
+
logger.info({ instanceId }, "workflow canceled");
|
|
808
|
+
sendNoContent(res);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// ── GET /v1/users — listUsers ─────────────────────────────────────────────
|
|
812
|
+
async function listUsers(
|
|
813
|
+
req: IncomingMessage,
|
|
814
|
+
res: ServerResponse,
|
|
815
|
+
ctx: RouteContext,
|
|
816
|
+
): Promise<void> {
|
|
817
|
+
const limitParam = parseInt(getQueryParam(ctx, "limit") || "50", 10);
|
|
818
|
+
const limit = isNaN(limitParam) ? 50 : Math.min(limitParam, 100);
|
|
819
|
+
const cursor = getQueryParam(ctx, "cursor");
|
|
820
|
+
const search = getQueryParam(ctx, "search");
|
|
821
|
+
const segment = getQueryParam(ctx, "segment");
|
|
822
|
+
const language = getQueryParam(ctx, "language");
|
|
823
|
+
const timezone = getQueryParam(ctx, "timezone");
|
|
824
|
+
const channel = getQueryParam(ctx, "channel");
|
|
825
|
+
|
|
826
|
+
const filters =
|
|
827
|
+
search || segment || language || timezone || channel
|
|
828
|
+
? { search, segment, language, timezone, channel }
|
|
829
|
+
: undefined;
|
|
830
|
+
|
|
831
|
+
const result = await deps.userRepo.list(ctx.projectId!, limit, cursor, filters);
|
|
832
|
+
sendJson(res, 200, result);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// ── GET /v1/users/:id/contacts — getUserContacts ──────────────────────────
|
|
836
|
+
async function getUserContacts(
|
|
837
|
+
req: IncomingMessage,
|
|
838
|
+
res: ServerResponse,
|
|
839
|
+
ctx: RouteContext,
|
|
840
|
+
): Promise<void> {
|
|
841
|
+
const userId = ctx.params.id!;
|
|
842
|
+
const contacts = await deps.contactRepo.findByUserId(ctx.projectId!, userId);
|
|
843
|
+
sendJson(res, 200, { contacts });
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// ── GET /v1/segments — listSegments ───────────────────────────────────────
|
|
847
|
+
async function listSegments(
|
|
848
|
+
req: IncomingMessage,
|
|
849
|
+
res: ServerResponse,
|
|
850
|
+
ctx: RouteContext,
|
|
851
|
+
): Promise<void> {
|
|
852
|
+
const segments = await deps.segmentRepo.listSegments(ctx.projectId!);
|
|
853
|
+
sendJson(res, 200, { segments });
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// ── GET /v1/projects — listProjects ───────────────────────────────────────
|
|
857
|
+
async function listProjects(
|
|
858
|
+
req: IncomingMessage,
|
|
859
|
+
res: ServerResponse,
|
|
860
|
+
_ctx: RouteContext,
|
|
861
|
+
): Promise<void> {
|
|
862
|
+
const projects = await deps.projectRepo.list();
|
|
863
|
+
sendJson(res, 200, { projects });
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// ── DELETE /v1/projects/:id — deleteProject ───────────────────────────────
|
|
867
|
+
async function deleteProject(
|
|
868
|
+
req: IncomingMessage,
|
|
869
|
+
res: ServerResponse,
|
|
870
|
+
ctx: RouteContext,
|
|
871
|
+
): Promise<void> {
|
|
872
|
+
const id = ctx.params.id!;
|
|
873
|
+
const deleted = await deps.projectRepo.delete(id);
|
|
874
|
+
if (!deleted) return sendJson(res, 404, { error: "project_not_found" });
|
|
875
|
+
|
|
876
|
+
sendNoContent(res);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// ── PATCH /v1/projects/:id — updateProject ────────────────────────────────
|
|
880
|
+
async function updateProject(
|
|
881
|
+
req: IncomingMessage,
|
|
882
|
+
res: ServerResponse,
|
|
883
|
+
ctx: RouteContext,
|
|
884
|
+
): Promise<void> {
|
|
885
|
+
const id = ctx.params.id!;
|
|
886
|
+
const parsed = z
|
|
887
|
+
.object({
|
|
888
|
+
rateLimitRpm: z.number().nullable().optional(),
|
|
889
|
+
throttleLimit: z.number().nullable().optional(),
|
|
890
|
+
throttleWindowHours: z.number().nullable().optional(),
|
|
891
|
+
})
|
|
892
|
+
.safeParse(await readJsonBody(req));
|
|
893
|
+
|
|
894
|
+
if (!parsed.success) return sendValidationError(res, parsed.error);
|
|
895
|
+
|
|
896
|
+
const updated = await deps.projectRepo.updateSettings(id, parsed.data);
|
|
897
|
+
if (!updated) return sendJson(res, 404, { error: "project_not_found" });
|
|
898
|
+
|
|
899
|
+
// Drop the engine's cached throttle overrides so the change applies to the
|
|
900
|
+
// next notification instead of waiting out the cache TTL.
|
|
901
|
+
await deps.redis.native.publish(PUBSUB_CHANNELS.PROJECT_INVALIDATED, id);
|
|
902
|
+
|
|
903
|
+
// rateLimitRpm is read through the API's auth cache, which is keyed by token
|
|
904
|
+
// hash rather than project — dropping the whole cache is the only way to
|
|
905
|
+
// pick up a new limit promptly. Project updates are rare admin operations.
|
|
906
|
+
if (parsed.data.rateLimitRpm !== undefined) {
|
|
907
|
+
await deps.redis.native.publish(PUBSUB_CHANNELS.API_KEY_INVALIDATED, "*");
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
logger.info({ projectId: id }, "project settings updated");
|
|
911
|
+
sendJson(res, 200, { id });
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// ── POST /v1/projects/:id/keys — createProjectKey ───────────────────
|
|
915
|
+
async function createProjectKey(
|
|
916
|
+
req: IncomingMessage,
|
|
917
|
+
res: ServerResponse,
|
|
918
|
+
ctx: RouteContext,
|
|
919
|
+
): Promise<void> {
|
|
920
|
+
const id = ctx.params.id!;
|
|
921
|
+
const parsed = z
|
|
922
|
+
.object({ role: z.enum(["admin", "read_only"]).default("admin") })
|
|
923
|
+
.safeParse(await readJsonBody(req));
|
|
924
|
+
const role = parsed.success ? parsed.data.role : "admin";
|
|
925
|
+
|
|
926
|
+
const { randomBytes, createHash } = await import("node:crypto");
|
|
927
|
+
const apiKey = `nk_live_${randomBytes(32).toString("hex")}`;
|
|
928
|
+
const apiKeyHash = createHash("sha256").update(apiKey).digest("hex");
|
|
929
|
+
|
|
930
|
+
const key = await deps.projectRepo.createApiKey(id, apiKeyHash, role);
|
|
931
|
+
sendJson(res, 201, { id: key.id, apiKey, role });
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// ── GET /v1/projects/:id/keys — listProjectKeys ─────────────────────
|
|
935
|
+
async function listProjectKeys(
|
|
936
|
+
req: IncomingMessage,
|
|
937
|
+
res: ServerResponse,
|
|
938
|
+
ctx: RouteContext,
|
|
939
|
+
): Promise<void> {
|
|
940
|
+
const id = ctx.params.id!;
|
|
941
|
+
const keys = await deps.projectRepo.listApiKeys(id);
|
|
942
|
+
sendJson(res, 200, { keys });
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// ── DELETE /v1/projects/:id/keys/:keyId — deleteProjectKey ──────────
|
|
946
|
+
async function deleteProjectKey(
|
|
947
|
+
req: IncomingMessage,
|
|
948
|
+
res: ServerResponse,
|
|
949
|
+
ctx: RouteContext,
|
|
950
|
+
): Promise<void> {
|
|
951
|
+
const { id, keyId } = ctx.params;
|
|
952
|
+
const deleted = await deps.projectRepo.deleteApiKey(id!, keyId!);
|
|
953
|
+
if (!deleted) return sendJson(res, 404, { error: "key_not_found" });
|
|
954
|
+
|
|
955
|
+
await deps.redis.native.publish("apikey.invalidated", "*");
|
|
956
|
+
sendJson(res, 204, {});
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// ── GET /v1/system/health — getSystemHealth ──────────────────────────────
|
|
960
|
+
async function getSystemHealth(
|
|
961
|
+
_req: IncomingMessage,
|
|
962
|
+
res: ServerResponse,
|
|
963
|
+
_ctx: RouteContext,
|
|
964
|
+
): Promise<void> {
|
|
965
|
+
const keys = ["enricher", "engine", "scheduler", "delivery", "ai", "workflow", "events"];
|
|
966
|
+
const workers: Record<string, any> = {};
|
|
967
|
+
let redisOk = false;
|
|
968
|
+
let dbOk = false;
|
|
969
|
+
let redisLatency = 0;
|
|
970
|
+
let dbLatency = 0;
|
|
971
|
+
|
|
972
|
+
const startRedis = Date.now();
|
|
973
|
+
try {
|
|
974
|
+
await deps.redis.native.ping();
|
|
975
|
+
redisOk = true;
|
|
976
|
+
redisLatency = Date.now() - startRedis;
|
|
977
|
+
} catch {
|
|
978
|
+
redisOk = false;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const startDb = Date.now();
|
|
982
|
+
try {
|
|
983
|
+
await deps.db.execute(sql`SELECT 1`);
|
|
984
|
+
dbOk = true;
|
|
985
|
+
dbLatency = Date.now() - startDb;
|
|
986
|
+
} catch {
|
|
987
|
+
dbOk = false;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
if (redisOk) {
|
|
991
|
+
try {
|
|
992
|
+
const vals = await deps.redis.native.mget(keys.map((k) => `notif:health:${k}`));
|
|
993
|
+
for (let i = 0; i < keys.length; i++) {
|
|
994
|
+
const key = keys[i] as string;
|
|
995
|
+
const val = vals[i];
|
|
996
|
+
if (val) {
|
|
997
|
+
workers[key] = JSON.parse(val);
|
|
998
|
+
} else {
|
|
999
|
+
workers[key] = { status: "unknown", message: "No heartbeat" };
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
} catch (err: any) {
|
|
1003
|
+
for (const key of keys) {
|
|
1004
|
+
workers[key] = { status: "error", error: err.message };
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
sendJson(res, 200, {
|
|
1010
|
+
status: redisOk && dbOk ? "healthy" : "degraded",
|
|
1011
|
+
redis: { ok: redisOk, latencyMs: redisLatency },
|
|
1012
|
+
db: { ok: dbOk, latencyMs: dbLatency },
|
|
1013
|
+
workers,
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// ── GET /v1/system/metrics — getSystemMetrics ────────────────────────────
|
|
1018
|
+
async function getSystemMetrics(
|
|
1019
|
+
_req: IncomingMessage,
|
|
1020
|
+
res: ServerResponse,
|
|
1021
|
+
ctx: RouteContext,
|
|
1022
|
+
): Promise<void> {
|
|
1023
|
+
const streamMap: Record<string, string> = {
|
|
1024
|
+
INBOUND_CRITICAL: STREAMS.INBOUND_CRITICAL,
|
|
1025
|
+
INBOUND_NORMAL: STREAMS.INBOUND_NORMAL,
|
|
1026
|
+
INBOUND_LOW: STREAMS.INBOUND_LOW,
|
|
1027
|
+
ENRICHED_NORMAL: STREAMS.ENRICHED_NORMAL,
|
|
1028
|
+
OUTBOUND_CRITICAL: STREAMS.OUTBOUND_CRITICAL,
|
|
1029
|
+
OUTBOUND_NORMAL: STREAMS.OUTBOUND_NORMAL,
|
|
1030
|
+
OUTBOUND_LOW: STREAMS.OUTBOUND_LOW,
|
|
1031
|
+
WORKFLOW_INBOUND: STREAMS.WORKFLOW_INBOUND,
|
|
1032
|
+
EVENTS_INBOUND: STREAMS.EVENTS_INBOUND,
|
|
1033
|
+
DEAD_LETTER: STREAMS.DEAD_LETTER,
|
|
1034
|
+
};
|
|
1035
|
+
|
|
1036
|
+
const streamDepths: Record<string, number> = {};
|
|
1037
|
+
for (const [key, realRedisKey] of Object.entries(streamMap)) {
|
|
1038
|
+
try {
|
|
1039
|
+
const len = await deps.redis.native.xlen(realRedisKey);
|
|
1040
|
+
streamDepths[key] = len;
|
|
1041
|
+
} catch {
|
|
1042
|
+
streamDepths[key] = 0;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// Scoped to the caller's project: an unscoped count reports every tenant's
|
|
1047
|
+
// traffic to whoever asks.
|
|
1048
|
+
const totalTasksRes = await deps.db
|
|
1049
|
+
.select({ count: sql<number>`count(distinct ${messageLogs.taskId})` })
|
|
1050
|
+
.from(messageLogs)
|
|
1051
|
+
.where(eq(messageLogs.projectId, ctx.projectId!));
|
|
1052
|
+
const deliveredTasksRes = await deps.db
|
|
1053
|
+
.select({ count: sql<number>`count(distinct ${messageLogs.taskId})` })
|
|
1054
|
+
.from(messageLogs)
|
|
1055
|
+
.where(and(eq(messageLogs.projectId, ctx.projectId!), eq(messageLogs.status, "delivered")));
|
|
1056
|
+
|
|
1057
|
+
const total = Number(totalTasksRes[0]?.count ?? 0);
|
|
1058
|
+
const delivered = Number(deliveredTasksRes[0]?.count ?? 0);
|
|
1059
|
+
const failed = streamDepths.DEAD_LETTER || 0;
|
|
1060
|
+
const successRate = total > 0 ? Number(((delivered / total) * 100).toFixed(2)) : 100;
|
|
1061
|
+
|
|
1062
|
+
sendJson(res, 200, {
|
|
1063
|
+
streams: streamDepths,
|
|
1064
|
+
deliveryStats: {
|
|
1065
|
+
total,
|
|
1066
|
+
delivered,
|
|
1067
|
+
failed,
|
|
1068
|
+
successRate,
|
|
1069
|
+
},
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// ── GET /v1/dlq — getDLQMessages ─────────────────────────────────────────
|
|
1074
|
+
async function getDLQMessages(
|
|
1075
|
+
_req: IncomingMessage,
|
|
1076
|
+
res: ServerResponse,
|
|
1077
|
+
_ctx: RouteContext,
|
|
1078
|
+
): Promise<void> {
|
|
1079
|
+
try {
|
|
1080
|
+
const rawEntries = await deps.redis.native.xrevrange(
|
|
1081
|
+
STREAMS.DEAD_LETTER,
|
|
1082
|
+
"+",
|
|
1083
|
+
"-",
|
|
1084
|
+
"COUNT",
|
|
1085
|
+
"50",
|
|
1086
|
+
);
|
|
1087
|
+
const messages = (rawEntries || []).map(([id, fields]: [string, string[]]) => {
|
|
1088
|
+
const fieldMap: Record<string, string> = {};
|
|
1089
|
+
for (let i = 0; i < fields.length; i += 2) {
|
|
1090
|
+
fieldMap[fields[i]!] = fields[i + 1]!;
|
|
1091
|
+
}
|
|
1092
|
+
return {
|
|
1093
|
+
id,
|
|
1094
|
+
eventType: fieldMap.eventType || fieldMap.event_type || "unknown",
|
|
1095
|
+
payload: fieldMap.payload ? JSON.parse(fieldMap.payload) : fieldMap,
|
|
1096
|
+
error: fieldMap.error || fieldMap.reason || "Dead letter payload",
|
|
1097
|
+
timestamp: fieldMap.timestamp || new Date().toISOString(),
|
|
1098
|
+
};
|
|
1099
|
+
});
|
|
1100
|
+
sendJson(res, 200, { messages });
|
|
1101
|
+
} catch {
|
|
1102
|
+
sendJson(res, 200, { messages: [] });
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// ── POST /v1/dlq/replay — replayDLQMessage ────────────────────────────────
|
|
1107
|
+
async function replayDLQMessage(
|
|
1108
|
+
req: IncomingMessage,
|
|
1109
|
+
res: ServerResponse,
|
|
1110
|
+
_ctx: RouteContext,
|
|
1111
|
+
): Promise<void> {
|
|
1112
|
+
const body = (await readJsonBody(req).catch(() => ({}))) as any;
|
|
1113
|
+
const messageId = body?.id;
|
|
1114
|
+
|
|
1115
|
+
if (!messageId) {
|
|
1116
|
+
sendJson(res, 400, { error: "missing_message_id" });
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
try {
|
|
1121
|
+
const rawEntries = await deps.redis.native.xrange(STREAMS.DEAD_LETTER, messageId, messageId);
|
|
1122
|
+
if (!rawEntries || rawEntries.length === 0 || !rawEntries[0]) {
|
|
1123
|
+
sendJson(res, 404, { error: "dlq_message_not_found" });
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
const fields = rawEntries[0][1];
|
|
1128
|
+
const fieldMap: Record<string, string> = {};
|
|
1129
|
+
for (let i = 0; i < fields.length; i += 2) {
|
|
1130
|
+
fieldMap[fields[i]!] = fields[i + 1]!;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
const priority = fieldMap.priority || "normal";
|
|
1134
|
+
const p = getPriorityBucket(priority);
|
|
1135
|
+
const targetStream =
|
|
1136
|
+
STREAMS[`INBOUND_${p.toUpperCase()}` as keyof typeof STREAMS] || STREAMS.INBOUND_NORMAL;
|
|
1137
|
+
|
|
1138
|
+
const xaddArgs: string[] = [];
|
|
1139
|
+
for (const [k, v] of Object.entries(fieldMap)) {
|
|
1140
|
+
xaddArgs.push(k, v);
|
|
1141
|
+
}
|
|
1142
|
+
await deps.redis.native.xadd(targetStream, "*", ...xaddArgs);
|
|
1143
|
+
await deps.redis.native.xdel(STREAMS.DEAD_LETTER, messageId);
|
|
1144
|
+
|
|
1145
|
+
sendJson(res, 200, { success: true, replayedId: messageId });
|
|
1146
|
+
} catch (err: any) {
|
|
1147
|
+
sendJson(res, 500, { error: "replay_failed", message: err.message });
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// ── DELETE /v1/dlq/:id — deleteDLQMessage ────────────────────────────────
|
|
1152
|
+
async function deleteDLQMessage(
|
|
1153
|
+
_req: IncomingMessage,
|
|
1154
|
+
res: ServerResponse,
|
|
1155
|
+
ctx: RouteContext,
|
|
1156
|
+
): Promise<void> {
|
|
1157
|
+
const messageId = ctx.params.id;
|
|
1158
|
+
if (!messageId) return sendJson(res, 400, { error: "missing_id" });
|
|
1159
|
+
|
|
1160
|
+
try {
|
|
1161
|
+
await deps.redis.native.xdel(STREAMS.DEAD_LETTER, messageId);
|
|
1162
|
+
sendJson(res, 200, { success: true });
|
|
1163
|
+
} catch (err: any) {
|
|
1164
|
+
sendJson(res, 500, { error: "delete_failed", message: err.message });
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// ── GET /v1/notifications/scheduled — getScheduledMessages ──────────────
|
|
1169
|
+
async function getScheduledMessages(
|
|
1170
|
+
_req: IncomingMessage,
|
|
1171
|
+
res: ServerResponse,
|
|
1172
|
+
ctx: RouteContext,
|
|
1173
|
+
): Promise<void> {
|
|
1174
|
+
const limitParam = getQueryParam(ctx, "limit");
|
|
1175
|
+
const limit = limitParam ? Math.min(parseInt(limitParam, 10), 100) : 50;
|
|
1176
|
+
const cursor = getQueryParam(ctx, "cursor");
|
|
1177
|
+
const channel = getQueryParam(ctx, "channel");
|
|
1178
|
+
|
|
1179
|
+
const conditions = [sql`(payload->>'projectId') = ${ctx.projectId!}`];
|
|
1180
|
+
if (cursor) {
|
|
1181
|
+
conditions.push(sql`task_id > ${cursor}`);
|
|
1182
|
+
}
|
|
1183
|
+
if (channel) {
|
|
1184
|
+
conditions.push(sql`(payload->>'channel') = ${channel}`);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
const rows = await deps.db
|
|
1188
|
+
.select()
|
|
1189
|
+
.from(scheduledPayloads)
|
|
1190
|
+
.where(and(...conditions))
|
|
1191
|
+
.orderBy(scheduledPayloads.taskId)
|
|
1192
|
+
.limit(limit + 1);
|
|
1193
|
+
|
|
1194
|
+
const hasNext = rows.length > limit;
|
|
1195
|
+
const scheduled = hasNext ? rows.slice(0, limit) : rows;
|
|
1196
|
+
const nextCursor =
|
|
1197
|
+
hasNext && scheduled.length > 0 ? scheduled[scheduled.length - 1]!.taskId : null;
|
|
1198
|
+
|
|
1199
|
+
sendJson(res, 200, { scheduled, nextCursor });
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// ── GET /v1/users/:id/details — getUserDetails ────────────────────────────
|
|
1203
|
+
async function getUserDetails(
|
|
1204
|
+
_req: IncomingMessage,
|
|
1205
|
+
res: ServerResponse,
|
|
1206
|
+
ctx: RouteContext,
|
|
1207
|
+
): Promise<void> {
|
|
1208
|
+
const userId = ctx.params.id!;
|
|
1209
|
+
const user = await deps.userRepo.findRecordById(ctx.projectId!, userId);
|
|
1210
|
+
if (!user) return sendJson(res, 404, { error: "user_not_found", id: userId });
|
|
1211
|
+
|
|
1212
|
+
const contacts = await deps.contactRepo.findByUserId(ctx.projectId!, userId);
|
|
1213
|
+
const logs = await deps.db
|
|
1214
|
+
.select()
|
|
1215
|
+
.from(messageLogs)
|
|
1216
|
+
.where(eq(messageLogs.projectId, ctx.projectId!))
|
|
1217
|
+
.orderBy(desc(messageLogs.timestamp))
|
|
1218
|
+
.limit(50);
|
|
1219
|
+
|
|
1220
|
+
sendJson(res, 200, { ...user, contacts, logs });
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// ── Unsubscribe ───────────────────────────────────────────────────────────
|
|
1224
|
+
//
|
|
1225
|
+
// Both routes are unauthenticated: they are reached from a mail client, which
|
|
1226
|
+
// has no API key. The signed token in the URL is the credential and it
|
|
1227
|
+
// authorises exactly one thing for one address.
|
|
1228
|
+
|
|
1229
|
+
function sendHtml(res: ServerResponse, status: number, body: string): void {
|
|
1230
|
+
res.writeHead(status, {
|
|
1231
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1232
|
+
// Nothing here should be cached by an inbox proxy or shared cache.
|
|
1233
|
+
"Cache-Control": "no-store",
|
|
1234
|
+
"X-Content-Type-Options": "nosniff",
|
|
1235
|
+
"Referrer-Policy": "no-referrer",
|
|
1236
|
+
});
|
|
1237
|
+
res.end(body);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
function escapeHtml(value: string): string {
|
|
1241
|
+
return value
|
|
1242
|
+
.replace(/&/g, "&")
|
|
1243
|
+
.replace(/</g, "<")
|
|
1244
|
+
.replace(/>/g, ">")
|
|
1245
|
+
.replace(/"/g, """);
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
function page(title: string, message: string): string {
|
|
1249
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
1250
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
1251
|
+
<title>${escapeHtml(title)}</title>
|
|
1252
|
+
<style>body{font:16px/1.6 system-ui,sans-serif;max-width:34rem;margin:12vh auto;padding:0 1.5rem;color:#111}
|
|
1253
|
+
h1{font-size:1.4rem;margin:0 0 .75rem}p{margin:0 0 1rem;color:#444}
|
|
1254
|
+
button{font:inherit;padding:.6rem 1.2rem;border:0;border-radius:6px;background:#111;color:#fff;cursor:pointer}
|
|
1255
|
+
code{background:#f4f4f5;padding:.1rem .35rem;border-radius:4px}</style>
|
|
1256
|
+
</head><body><h1>${escapeHtml(title)}</h1>${message}</body></html>`;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* GET — the page a human lands on after clicking the link in the mail body.
|
|
1261
|
+
*
|
|
1262
|
+
* Deliberately does not unsubscribe anything. Corporate mail scanners and
|
|
1263
|
+
* link prescanners issue a GET against every URL in a message; performing the
|
|
1264
|
+
* opt-out here would unsubscribe people who never clicked. The mutation lives
|
|
1265
|
+
* behind the POST.
|
|
1266
|
+
*/
|
|
1267
|
+
async function unsubscribePage(
|
|
1268
|
+
_req: IncomingMessage,
|
|
1269
|
+
res: ServerResponse,
|
|
1270
|
+
ctx: RouteContext,
|
|
1271
|
+
): Promise<void> {
|
|
1272
|
+
const cfg = readBaseConfig();
|
|
1273
|
+
const token = ctx.query.get("token") ?? "";
|
|
1274
|
+
if (!cfg.UNSUBSCRIBE_SECRET) {
|
|
1275
|
+
deps.logger.warn(
|
|
1276
|
+
"UNSUBSCRIBE_SECRET is not configured — unable to verify unsubscribe tokens",
|
|
1277
|
+
);
|
|
1278
|
+
}
|
|
1279
|
+
const claim = cfg.UNSUBSCRIBE_SECRET
|
|
1280
|
+
? verifyUnsubscribeToken(token, cfg.UNSUBSCRIBE_SECRET)
|
|
1281
|
+
: null;
|
|
1282
|
+
|
|
1283
|
+
if (!claim) {
|
|
1284
|
+
return sendHtml(
|
|
1285
|
+
res,
|
|
1286
|
+
400,
|
|
1287
|
+
page(
|
|
1288
|
+
"This link is not valid",
|
|
1289
|
+
"<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>",
|
|
1290
|
+
),
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
const what =
|
|
1295
|
+
claim.topics.length > 0
|
|
1296
|
+
? `<code>${claim.topics.map(escapeHtml).join("</code>, <code>")}</code> messages`
|
|
1297
|
+
: "all messages";
|
|
1298
|
+
|
|
1299
|
+
sendHtml(
|
|
1300
|
+
res,
|
|
1301
|
+
200,
|
|
1302
|
+
page(
|
|
1303
|
+
"Unsubscribe",
|
|
1304
|
+
`<p>Stop sending ${what} to <code>${escapeHtml(claim.target)}</code>?</p>
|
|
1305
|
+
<form method="post" action="/v1/unsubscribe?token=${encodeURIComponent(token)}">
|
|
1306
|
+
<button type="submit">Unsubscribe</button></form>`,
|
|
1307
|
+
),
|
|
1308
|
+
);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
/**
|
|
1312
|
+
* POST — the real opt-out.
|
|
1313
|
+
*
|
|
1314
|
+
* Serves two callers with one handler: a mail client doing RFC 8058 one-click
|
|
1315
|
+
* (body `List-Unsubscribe=One-Click`, token in the query string, no
|
|
1316
|
+
* confirmation possible), and the form on the page above. Neither can carry a
|
|
1317
|
+
* CSRF token, and the one-click spec forbids an interstitial, so the signed
|
|
1318
|
+
* token is the whole of the authorisation.
|
|
1319
|
+
*/
|
|
1320
|
+
async function unsubscribe(
|
|
1321
|
+
req: IncomingMessage,
|
|
1322
|
+
res: ServerResponse,
|
|
1323
|
+
ctx: RouteContext,
|
|
1324
|
+
): Promise<void> {
|
|
1325
|
+
// The one-click POST carries `List-Unsubscribe=One-Click` as its body and
|
|
1326
|
+
// the token in the query string, so there is nothing here worth reading —
|
|
1327
|
+
// but an unread body leaves bytes on a keep-alive socket. Discard it.
|
|
1328
|
+
req.resume();
|
|
1329
|
+
|
|
1330
|
+
const cfg = readBaseConfig();
|
|
1331
|
+
const token = ctx.query.get("token") ?? "";
|
|
1332
|
+
if (!cfg.UNSUBSCRIBE_SECRET) {
|
|
1333
|
+
deps.logger.warn(
|
|
1334
|
+
"UNSUBSCRIBE_SECRET is not configured — unable to verify unsubscribe tokens",
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
const claim = cfg.UNSUBSCRIBE_SECRET
|
|
1338
|
+
? verifyUnsubscribeToken(token, cfg.UNSUBSCRIBE_SECRET)
|
|
1339
|
+
: null;
|
|
1340
|
+
|
|
1341
|
+
if (!claim) {
|
|
1342
|
+
return sendHtml(
|
|
1343
|
+
res,
|
|
1344
|
+
400,
|
|
1345
|
+
page(
|
|
1346
|
+
"This link is not valid",
|
|
1347
|
+
"<p>It may have been altered in transit, or this server may have been reconfigured since the message was sent.</p>",
|
|
1348
|
+
),
|
|
1349
|
+
);
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
const target = normaliseTarget(claim.target);
|
|
1353
|
+
|
|
1354
|
+
try {
|
|
1355
|
+
const userRows = await deps.db
|
|
1356
|
+
.select({ id: dbUsers.id })
|
|
1357
|
+
.from(dbUsers)
|
|
1358
|
+
.where(and(eq(dbUsers.projectId, claim.projectId), eq(dbUsers.externalId, claim.userId)))
|
|
1359
|
+
.limit(1);
|
|
1360
|
+
const internalId = userRows[0]?.id;
|
|
1361
|
+
|
|
1362
|
+
if (claim.topics.length > 0 && internalId) {
|
|
1363
|
+
// Scoped opt-out: silence this kind of mail and leave the rest,
|
|
1364
|
+
// including anything transactional, working.
|
|
1365
|
+
await deps.db
|
|
1366
|
+
.insert(userTopicPreferences)
|
|
1367
|
+
.values(claim.topics.map((topic) => ({ userId: internalId, topic, enabled: false })))
|
|
1368
|
+
.onConflictDoUpdate({
|
|
1369
|
+
target: [userTopicPreferences.userId, userTopicPreferences.topic],
|
|
1370
|
+
set: { enabled: sql`excluded.enabled` },
|
|
1371
|
+
});
|
|
1372
|
+
} else {
|
|
1373
|
+
// No topic to scope to, or the user record is gone. Either way the
|
|
1374
|
+
// request has to be honoured, so fall back to suppressing the address
|
|
1375
|
+
// outright — over-honouring an unsubscribe is the safe direction.
|
|
1376
|
+
await deps.db
|
|
1377
|
+
.insert(suppressions)
|
|
1378
|
+
.values({
|
|
1379
|
+
projectId: claim.projectId,
|
|
1380
|
+
channel: claim.channel as any,
|
|
1381
|
+
target,
|
|
1382
|
+
reason: "unsubscribed",
|
|
1383
|
+
source: "unsubscribe-link",
|
|
1384
|
+
})
|
|
1385
|
+
.onConflictDoNothing();
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
logger.info(
|
|
1389
|
+
{
|
|
1390
|
+
projectId: claim.projectId,
|
|
1391
|
+
channel: claim.channel,
|
|
1392
|
+
topics: claim.topics,
|
|
1393
|
+
scoped: claim.topics.length > 0 && Boolean(internalId),
|
|
1394
|
+
},
|
|
1395
|
+
"unsubscribe honoured",
|
|
1396
|
+
);
|
|
1397
|
+
} catch (err) {
|
|
1398
|
+
// A 5xx here means the mail client reports failure and, worse, the person
|
|
1399
|
+
// believes they have opted out when they have not.
|
|
1400
|
+
logger.error({ err, projectId: claim.projectId }, "unsubscribe failed to apply");
|
|
1401
|
+
return sendHtml(
|
|
1402
|
+
res,
|
|
1403
|
+
500,
|
|
1404
|
+
page(
|
|
1405
|
+
"Something went wrong",
|
|
1406
|
+
"<p>We could not record that just now. Please try again in a moment.</p>",
|
|
1407
|
+
),
|
|
1408
|
+
);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
sendHtml(
|
|
1412
|
+
res,
|
|
1413
|
+
200,
|
|
1414
|
+
page(
|
|
1415
|
+
"You're unsubscribed",
|
|
1416
|
+
`<p>We've stopped sending ${
|
|
1417
|
+
claim.topics.length > 0 ? "these messages" : "messages"
|
|
1418
|
+
} to <code>${escapeHtml(claim.target)}</code>. It may take a few minutes to take effect for mail already on its way.</p>`,
|
|
1419
|
+
),
|
|
1420
|
+
);
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
// ── GET /v1/campaigns — listCampaigns ─────────────────────────────────────
|
|
1424
|
+
//
|
|
1425
|
+
// One row per campaign label, newest activity first, with support for filtering
|
|
1426
|
+
// by search keyword, channel, date range, and minimum message counts.
|
|
1427
|
+
async function listCampaigns(
|
|
1428
|
+
_req: IncomingMessage,
|
|
1429
|
+
res: ServerResponse,
|
|
1430
|
+
ctx: RouteContext,
|
|
1431
|
+
): Promise<void> {
|
|
1432
|
+
const limitParam = parseInt(getQueryParam(ctx, "limit") || "20", 10);
|
|
1433
|
+
const limit = isNaN(limitParam) ? 20 : Math.min(Math.max(limitParam, 1), 100);
|
|
1434
|
+
const search = getQueryParam(ctx, "search")?.trim();
|
|
1435
|
+
const channel = getQueryParam(ctx, "channel")?.trim();
|
|
1436
|
+
const since = getQueryParam(ctx, "since")?.trim();
|
|
1437
|
+
const until = getQueryParam(ctx, "until")?.trim();
|
|
1438
|
+
const minMessagesParam = parseInt(getQueryParam(ctx, "minMessages") || "", 10);
|
|
1439
|
+
const minMessages = isNaN(minMessagesParam) ? undefined : Math.max(minMessagesParam, 1);
|
|
1440
|
+
|
|
1441
|
+
const conditions = [
|
|
1442
|
+
eq(messageLogs.projectId, ctx.projectId!),
|
|
1443
|
+
isNotNull(messageLogs.campaignId),
|
|
1444
|
+
];
|
|
1445
|
+
|
|
1446
|
+
if (search) {
|
|
1447
|
+
conditions.push(like(messageLogs.campaignId, `%${search}%`));
|
|
1448
|
+
}
|
|
1449
|
+
if (channel) {
|
|
1450
|
+
conditions.push(eq(messageLogs.channel, channel as any));
|
|
1451
|
+
}
|
|
1452
|
+
if (since) {
|
|
1453
|
+
const sinceDate = new Date(since);
|
|
1454
|
+
if (!isNaN(sinceDate.getTime())) {
|
|
1455
|
+
conditions.push(gte(messageLogs.timestamp, sinceDate));
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
if (until) {
|
|
1459
|
+
const untilDate = new Date(until);
|
|
1460
|
+
if (!isNaN(untilDate.getTime())) {
|
|
1461
|
+
conditions.push(lte(messageLogs.timestamp, untilDate));
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
let query = deps.db
|
|
1466
|
+
.select({
|
|
1467
|
+
campaign: messageLogs.campaignId,
|
|
1468
|
+
messages: sql<number>`count(distinct ${messageLogs.taskId})`,
|
|
1469
|
+
firstSentAt: sql<string>`min(${messageLogs.timestamp})`,
|
|
1470
|
+
lastActivityAt: sql<string>`max(${messageLogs.timestamp})`,
|
|
1471
|
+
})
|
|
1472
|
+
.from(messageLogs)
|
|
1473
|
+
.where(and(...conditions))
|
|
1474
|
+
.groupBy(messageLogs.campaignId)
|
|
1475
|
+
.orderBy(desc(sql`max(${messageLogs.timestamp})`))
|
|
1476
|
+
.limit(limit);
|
|
1477
|
+
|
|
1478
|
+
if (minMessages !== undefined) {
|
|
1479
|
+
query = query.having(gte(sql`count(distinct ${messageLogs.taskId})`, minMessages)) as any;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
const rows = await query;
|
|
1483
|
+
|
|
1484
|
+
sendJson(res, 200, {
|
|
1485
|
+
campaigns: rows.map((r: any) => ({
|
|
1486
|
+
campaign: r.campaign,
|
|
1487
|
+
messages: Number(r.messages ?? 0),
|
|
1488
|
+
firstSentAt: r.firstSentAt,
|
|
1489
|
+
lastActivityAt: r.lastActivityAt,
|
|
1490
|
+
})),
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
// ── GET /v1/campaigns/:campaign/stats — getCampaignStats ──────────────────
|
|
1495
|
+
//
|
|
1496
|
+
// The delivery funnel for one campaign, in a single grouped scan.
|
|
1497
|
+
//
|
|
1498
|
+
// Every figure counts *distinct tasks*, not rows: a task accumulates a
|
|
1499
|
+
// dispatch row, an attempt row, and any number of engagement rows, so
|
|
1500
|
+
// counting rows would inflate every number by a different factor. Opening the
|
|
1501
|
+
// same email twice is one person who opened it.
|
|
1502
|
+
async function getCampaignStats(
|
|
1503
|
+
_req: IncomingMessage,
|
|
1504
|
+
res: ServerResponse,
|
|
1505
|
+
ctx: RouteContext,
|
|
1506
|
+
): Promise<void> {
|
|
1507
|
+
const campaign = ctx.params.campaign;
|
|
1508
|
+
if (!campaign) return sendJson(res, 400, { error: "missing_campaign" });
|
|
1509
|
+
|
|
1510
|
+
const rows = await deps.db
|
|
1511
|
+
.select({
|
|
1512
|
+
channel: messageLogs.channel,
|
|
1513
|
+
kind: messageLogs.kind,
|
|
1514
|
+
status: messageLogs.status,
|
|
1515
|
+
tasks: sql<number>`count(distinct ${messageLogs.taskId})`,
|
|
1516
|
+
})
|
|
1517
|
+
.from(messageLogs)
|
|
1518
|
+
.where(and(eq(messageLogs.projectId, ctx.projectId!), eq(messageLogs.campaignId, campaign)))
|
|
1519
|
+
.groupBy(messageLogs.channel, messageLogs.kind, messageLogs.status);
|
|
1520
|
+
|
|
1521
|
+
if (rows.length === 0) {
|
|
1522
|
+
return sendJson(res, 404, {
|
|
1523
|
+
error: "not_found",
|
|
1524
|
+
message: `No messages recorded for campaign '${campaign}'`,
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
const ENGAGEMENT = ["opened", "clicked", "bounced", "complained", "unsubscribed"] as const;
|
|
1529
|
+
type Engagement = (typeof ENGAGEMENT)[number];
|
|
1530
|
+
|
|
1531
|
+
const blank = () => ({
|
|
1532
|
+
sent: 0,
|
|
1533
|
+
delivered: 0,
|
|
1534
|
+
failed: 0,
|
|
1535
|
+
opened: 0,
|
|
1536
|
+
clicked: 0,
|
|
1537
|
+
bounced: 0,
|
|
1538
|
+
complained: 0,
|
|
1539
|
+
unsubscribed: 0,
|
|
1540
|
+
});
|
|
1541
|
+
|
|
1542
|
+
const byChannel = new Map<string, ReturnType<typeof blank>>();
|
|
1543
|
+
for (const row of rows as any[]) {
|
|
1544
|
+
const bucket = byChannel.get(row.channel) ?? blank();
|
|
1545
|
+
const n = Number(row.tasks ?? 0);
|
|
1546
|
+
|
|
1547
|
+
if (ENGAGEMENT.includes(row.kind as Engagement)) {
|
|
1548
|
+
bucket[row.kind as Engagement] += n;
|
|
1549
|
+
} else {
|
|
1550
|
+
// A delivery row. `sent` is every task that reached a provider at all,
|
|
1551
|
+
// which is the denominator a bounce rate is measured against.
|
|
1552
|
+
bucket.sent += n;
|
|
1553
|
+
if (row.status === "delivered") bucket.delivered += n;
|
|
1554
|
+
if (row.status === "failed") bucket.failed += n;
|
|
1555
|
+
}
|
|
1556
|
+
byChannel.set(row.channel, bucket);
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
const total = blank();
|
|
1560
|
+
for (const bucket of byChannel.values()) {
|
|
1561
|
+
for (const key of Object.keys(total) as (keyof ReturnType<typeof blank>)[]) {
|
|
1562
|
+
total[key] += bucket[key];
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
const pct = (n: number, d: number) => (d > 0 ? Number(((n / d) * 100).toFixed(2)) : null);
|
|
1567
|
+
|
|
1568
|
+
// Engagement only exists where a provider reports it back. Saying "0% open
|
|
1569
|
+
// rate" for an SMS blast, or for email with no webhook wired, reads as a
|
|
1570
|
+
// failed campaign rather than an untracked one.
|
|
1571
|
+
const channels = [...byChannel.keys()];
|
|
1572
|
+
const engagementCapable = channels.some((c) => c === "email");
|
|
1573
|
+
const sawEngagement = ENGAGEMENT.some((k) => total[k] > 0);
|
|
1574
|
+
|
|
1575
|
+
const warnings: string[] = [];
|
|
1576
|
+
if (!engagementCapable) {
|
|
1577
|
+
warnings.push(
|
|
1578
|
+
`Opens and clicks are not reported on ${channels.join(", ")} — those figures are not tracked, not zero.`,
|
|
1579
|
+
);
|
|
1580
|
+
} else if (!sawEngagement && total.delivered > 0) {
|
|
1581
|
+
warnings.push(
|
|
1582
|
+
"No engagement events recorded. If the provider webhook is not configured, opens and clicks cannot be tracked and will stay at zero.",
|
|
1583
|
+
);
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
sendJson(res, 200, {
|
|
1587
|
+
campaign,
|
|
1588
|
+
totals: {
|
|
1589
|
+
...total,
|
|
1590
|
+
deliveryRate: pct(total.delivered, total.sent),
|
|
1591
|
+
openRate: pct(total.opened, total.delivered),
|
|
1592
|
+
clickRate: pct(total.clicked, total.delivered),
|
|
1593
|
+
bounceRate: pct(total.bounced, total.sent),
|
|
1594
|
+
complaintRate: pct(total.complained, total.delivered),
|
|
1595
|
+
unsubscribeRate: pct(total.unsubscribed, total.delivered),
|
|
1596
|
+
},
|
|
1597
|
+
byChannel: Object.fromEntries(byChannel),
|
|
1598
|
+
engagementTracked: engagementCapable && sawEngagement,
|
|
1599
|
+
warnings,
|
|
1600
|
+
});
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// ── GET /v1/suppressions — listSuppressions ───────────────────────────────
|
|
1604
|
+
async function listSuppressions(
|
|
1605
|
+
_req: IncomingMessage,
|
|
1606
|
+
res: ServerResponse,
|
|
1607
|
+
ctx: RouteContext,
|
|
1608
|
+
): Promise<void> {
|
|
1609
|
+
const limitParam = parseInt(getQueryParam(ctx, "limit") || "100", 10);
|
|
1610
|
+
const limit = isNaN(limitParam) ? 100 : Math.min(Math.max(limitParam, 1), 500);
|
|
1611
|
+
const channel = getQueryParam(ctx, "channel");
|
|
1612
|
+
const reason = getQueryParam(ctx, "reason");
|
|
1613
|
+
const target = getQueryParam(ctx, "target");
|
|
1614
|
+
|
|
1615
|
+
const conditions = [eq(suppressions.projectId, ctx.projectId!)];
|
|
1616
|
+
if (channel) conditions.push(eq(suppressions.channel, channel as any));
|
|
1617
|
+
if (reason) conditions.push(eq(suppressions.reason, reason));
|
|
1618
|
+
if (target) conditions.push(like(suppressions.target, `%${target.toLowerCase().trim()}%`));
|
|
1619
|
+
|
|
1620
|
+
const rows = await deps.db
|
|
1621
|
+
.select()
|
|
1622
|
+
.from(suppressions)
|
|
1623
|
+
.where(and(...conditions))
|
|
1624
|
+
.orderBy(desc(suppressions.createdAt))
|
|
1625
|
+
.limit(limit);
|
|
1626
|
+
|
|
1627
|
+
sendJson(res, 200, { suppressions: rows });
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// ── POST /v1/suppressions — createSuppression ─────────────────────────────
|
|
1631
|
+
async function createSuppression(
|
|
1632
|
+
req: IncomingMessage,
|
|
1633
|
+
res: ServerResponse,
|
|
1634
|
+
ctx: RouteContext,
|
|
1635
|
+
): Promise<void> {
|
|
1636
|
+
const parsed = z
|
|
1637
|
+
.object({
|
|
1638
|
+
channel: ContactChannelSchema,
|
|
1639
|
+
target: z.string().min(1),
|
|
1640
|
+
reason: z.enum(["unsubscribed", "complained", "bounced", "manual"]).default("manual"),
|
|
1641
|
+
})
|
|
1642
|
+
.safeParse(await readJsonBody(req));
|
|
1643
|
+
if (!parsed.success) return sendValidationError(res, parsed.error);
|
|
1644
|
+
|
|
1645
|
+
const { channel, target, reason } = parsed.data;
|
|
1646
|
+
await deps.db
|
|
1647
|
+
.insert(suppressions)
|
|
1648
|
+
.values({
|
|
1649
|
+
projectId: ctx.projectId!,
|
|
1650
|
+
channel,
|
|
1651
|
+
target: normaliseTarget(target),
|
|
1652
|
+
reason,
|
|
1653
|
+
source: "api",
|
|
1654
|
+
})
|
|
1655
|
+
.onConflictDoNothing();
|
|
1656
|
+
|
|
1657
|
+
sendJson(res, 201, { channel, target: normaliseTarget(target), reason });
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
// ── DELETE /v1/suppressions/:channel/:target — deleteSuppression ──────────
|
|
1661
|
+
//
|
|
1662
|
+
// Re-enables sending to an address. Intentionally manual: a suppression
|
|
1663
|
+
// records that somebody asked not to be contacted, and undoing that should be
|
|
1664
|
+
// a decision rather than a retry.
|
|
1665
|
+
async function deleteSuppression(
|
|
1666
|
+
_req: IncomingMessage,
|
|
1667
|
+
res: ServerResponse,
|
|
1668
|
+
ctx: RouteContext,
|
|
1669
|
+
): Promise<void> {
|
|
1670
|
+
const { channel, target } = ctx.params;
|
|
1671
|
+
if (!channel || !target) return sendJson(res, 400, { error: "missing_channel_or_target" });
|
|
1672
|
+
|
|
1673
|
+
await deps.db
|
|
1674
|
+
.delete(suppressions)
|
|
1675
|
+
.where(
|
|
1676
|
+
and(
|
|
1677
|
+
eq(suppressions.projectId, ctx.projectId!),
|
|
1678
|
+
eq(suppressions.channel, channel as any),
|
|
1679
|
+
eq(suppressions.target, normaliseTarget(decodeURIComponent(target))),
|
|
1680
|
+
),
|
|
1681
|
+
);
|
|
1682
|
+
|
|
1683
|
+
sendNoContent(res);
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
return {
|
|
1687
|
+
syncTemplates,
|
|
1688
|
+
addUser,
|
|
1689
|
+
updateUser,
|
|
1690
|
+
deleteUser,
|
|
1691
|
+
addContact,
|
|
1692
|
+
deleteContact,
|
|
1693
|
+
notify,
|
|
1694
|
+
cancelNotification,
|
|
1695
|
+
triggerWorkflow,
|
|
1696
|
+
ingestEvent,
|
|
1697
|
+
getEventsStream,
|
|
1698
|
+
getNotificationLogs,
|
|
1699
|
+
getNotificationStatus,
|
|
1700
|
+
getUser,
|
|
1701
|
+
getUserDetails,
|
|
1702
|
+
unsubscribePage,
|
|
1703
|
+
unsubscribe,
|
|
1704
|
+
listCampaigns,
|
|
1705
|
+
getCampaignStats,
|
|
1706
|
+
listSuppressions,
|
|
1707
|
+
createSuppression,
|
|
1708
|
+
deleteSuppression,
|
|
1709
|
+
getUserPreferences,
|
|
1710
|
+
updateUserPreferences,
|
|
1711
|
+
listTemplates,
|
|
1712
|
+
getTemplate,
|
|
1713
|
+
deleteTemplate,
|
|
1714
|
+
createWorkflow,
|
|
1715
|
+
listWorkflows,
|
|
1716
|
+
getWorkflow,
|
|
1717
|
+
cancelWorkflow,
|
|
1718
|
+
listUsers,
|
|
1719
|
+
getUserContacts,
|
|
1720
|
+
listSegments,
|
|
1721
|
+
listProjects,
|
|
1722
|
+
deleteProject,
|
|
1723
|
+
updateProject,
|
|
1724
|
+
createProjectKey,
|
|
1725
|
+
listProjectKeys,
|
|
1726
|
+
deleteProjectKey,
|
|
1727
|
+
getSystemHealth,
|
|
1728
|
+
getSystemMetrics,
|
|
1729
|
+
getDLQMessages,
|
|
1730
|
+
replayDLQMessage,
|
|
1731
|
+
deleteDLQMessage,
|
|
1732
|
+
getScheduledMessages,
|
|
1733
|
+
};
|
|
1734
|
+
}
|