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