notifkit 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +130 -122
  2. package/dist/index.d.mts +196 -132
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/dist/index.mjs.map +1 -1
  6. package/dist/{main-DtHWhueo.mjs → main-40zwq6b0.mjs} +28 -3
  7. package/dist/{main-DtHWhueo.mjs.map → main-40zwq6b0.mjs.map} +1 -1
  8. package/dist/{main-DyfbnJc3.mjs → main-BFre2-HQ.mjs} +2 -2
  9. package/dist/{main-DyfbnJc3.mjs.map → main-BFre2-HQ.mjs.map} +1 -1
  10. package/dist/{main-CAH0_Q6d.mjs → main-BNJtzY61.mjs} +3 -3
  11. package/dist/main-BNJtzY61.mjs.map +1 -0
  12. package/dist/{main-B561M1d3.mjs → main-BOPMYqsW.mjs} +2 -2
  13. package/dist/{main-B561M1d3.mjs.map → main-BOPMYqsW.mjs.map} +1 -1
  14. package/dist/{main-CCfc45ev.mjs → main-CiigNpsP.mjs} +7 -4
  15. package/dist/main-CiigNpsP.mjs.map +1 -0
  16. package/dist/{main-Ce9dcrsg.mjs → main-DeNFQ-UL.mjs} +6 -3
  17. package/dist/{main-Ce9dcrsg.mjs.map → main-DeNFQ-UL.mjs.map} +1 -1
  18. package/dist/{main-B-jwm8ED.mjs → main-DmCPcxOc.mjs} +2 -2
  19. package/dist/{main-B-jwm8ED.mjs.map → main-DmCPcxOc.mjs.map} +1 -1
  20. package/dist/{main-C45e7grq.mjs → main-DvgJSm11.mjs} +2 -2
  21. package/dist/{main-C45e7grq.mjs.map → main-DvgJSm11.mjs.map} +1 -1
  22. package/dist/{src-C-PfEDMY.mjs → src-vG79L-8m.mjs} +57 -26
  23. package/dist/src-vG79L-8m.mjs.map +1 -0
  24. package/drizzle/0002_wide_colleen_wing.sql +2 -0
  25. package/drizzle/0003_skinny_daimon_hellstrom.sql +1 -0
  26. package/drizzle/0004_pretty_bruce_banner.sql +1 -0
  27. package/drizzle/meta/0002_snapshot.json +1460 -0
  28. package/drizzle/meta/0003_snapshot.json +1460 -0
  29. package/drizzle/meta/0004_snapshot.json +1470 -0
  30. package/drizzle/meta/_journal.json +21 -0
  31. package/package.json +3 -2
  32. package/src/client.ts +412 -0
  33. package/src/config/index.ts +107 -0
  34. package/src/contracts/common.ts +28 -0
  35. package/src/contracts/envelope.ts +31 -0
  36. package/src/contracts/events/notification-ai-pending.ts +18 -0
  37. package/src/contracts/events/notification-canceled.ts +7 -0
  38. package/src/contracts/events/notification-created.ts +14 -0
  39. package/src/contracts/events/notification-delivered.ts +17 -0
  40. package/src/contracts/events/notification-dispatched.ts +45 -0
  41. package/src/contracts/events/notification-enriched.ts +46 -0
  42. package/src/contracts/events/notification-failed.ts +19 -0
  43. package/src/contracts/events/notification-requested.ts +36 -0
  44. package/src/contracts/events/notification-scheduled.ts +9 -0
  45. package/src/contracts/events/notification-skipped.ts +9 -0
  46. package/src/contracts/helpers.ts +21 -0
  47. package/src/contracts/index.ts +46 -0
  48. package/src/contracts/metadata.ts +10 -0
  49. package/src/contracts/registry.ts +88 -0
  50. package/src/contracts/sdk.ts +242 -0
  51. package/src/contracts/streams.ts +62 -0
  52. package/src/db/index.ts +69 -0
  53. package/src/db/schema.ts +412 -0
  54. package/src/idempotency/index.ts +50 -0
  55. package/src/index.ts +19 -0
  56. package/src/logger/index.ts +60 -0
  57. package/src/metrics/index.ts +53 -0
  58. package/src/queue/index.ts +501 -0
  59. package/src/rate-limiter/index.ts +210 -0
  60. package/src/redis/index.ts +89 -0
  61. package/src/repositories/index.ts +1246 -0
  62. package/src/server.ts +277 -0
  63. package/src/services/ai/main.ts +404 -0
  64. package/src/services/api/handlers.ts +1734 -0
  65. package/src/services/api/http.ts +64 -0
  66. package/src/services/api/main.ts +693 -0
  67. package/src/services/api/router.ts +82 -0
  68. package/src/services/delivery/main.ts +842 -0
  69. package/src/services/delivery/throttle.ts +71 -0
  70. package/src/services/engine/main.ts +827 -0
  71. package/src/services/enricher/main.ts +594 -0
  72. package/src/services/events/main.ts +365 -0
  73. package/src/services/scheduler/main.ts +319 -0
  74. package/src/services/workflow/main.ts +627 -0
  75. package/src/shared/batch-processor.ts +67 -0
  76. package/src/shared/cache.ts +47 -0
  77. package/src/shared/circuit-breaker.ts +74 -0
  78. package/src/shared/dataloader.ts +41 -0
  79. package/src/shared/events.ts +3 -0
  80. package/src/shared/index.ts +39 -0
  81. package/src/shared/semaphore.ts +33 -0
  82. package/src/shared/utils.ts +64 -0
  83. package/src/templates/cache.ts +32 -0
  84. package/src/templates/index.ts +69 -0
  85. package/src/templates/render.ts +128 -0
  86. package/src/transport/index.ts +96 -0
  87. package/src/unsubscribe/index.ts +127 -0
  88. package/src/workers/health.ts +31 -0
  89. package/src/workers/index.ts +266 -0
  90. package/src/workflows/index.ts +2 -0
  91. package/src/workflows/registry.ts +21 -0
  92. package/src/workflows/sdk.ts +106 -0
  93. package/dist/main-CAH0_Q6d.mjs.map +0 -1
  94. package/dist/main-CCfc45ev.mjs.map +0 -1
  95. package/dist/src-C-PfEDMY.mjs.map +0 -1
@@ -0,0 +1,1246 @@
1
+ import { eq, and, sql as drizzleSql, inArray, desc } from "drizzle-orm";
2
+ import type { Db } from "@/index.js";
3
+ import type { Preferences, ContactChannel } from "@/contracts/index.js";
4
+ import {
5
+ users,
6
+ userSegments,
7
+ userContacts,
8
+ userChannelPreferences,
9
+ userTopicPreferences,
10
+ contactTopicPreferences,
11
+ quietHours,
12
+ templates,
13
+ projects,
14
+ workflowInstances,
15
+ workflowSteps,
16
+ workflowWaiters,
17
+ workflowDefinitions,
18
+ projectApiKeys,
19
+ suppressions,
20
+ messageLogs,
21
+ } from "@/db/schema.js";
22
+
23
+ // ─── Domain types ───────────────────────────────────────────────────────────
24
+
25
+ export interface UserProfile {
26
+ userId: string; // Maps to externalId
27
+ language?: string;
28
+ timezone?: string;
29
+ email?: string | null;
30
+ }
31
+
32
+ export interface UserRecord extends UserProfile {
33
+ segments: string[];
34
+ preferences: Preferences;
35
+ }
36
+
37
+ export interface UserContact {
38
+ id: string; // uuid
39
+ userId: string; // externalId
40
+ channel: ContactChannel;
41
+ target: string;
42
+ preferences: Preferences;
43
+ active: boolean;
44
+ }
45
+
46
+ export interface TemplateRecord {
47
+ id: string;
48
+ channel: string;
49
+ topics: string[];
50
+ content: unknown;
51
+ /** Template-level AI prompts, merged with per-request ones by the engine. */
52
+ aiPrompts?: Record<string, string> | null;
53
+ }
54
+
55
+ export type DevicePlatform = "fcm" | "apns" | "web";
56
+
57
+ export interface DeviceToken {
58
+ id: string;
59
+ userId: string;
60
+ deviceToken: string;
61
+ platform: DevicePlatform;
62
+ active: boolean;
63
+ }
64
+
65
+ export interface NotificationPreference {
66
+ userId: string;
67
+ eventType: string;
68
+ optedIn: boolean;
69
+ }
70
+
71
+ // ─── UserRepository ─────────────────────────────────────────────────────────
72
+
73
+ export class UserRepository {
74
+ constructor(private readonly db: Db) {}
75
+
76
+ async findById(projectId: string, userId: string): Promise<UserProfile | null> {
77
+ const rows = await this.db
78
+ .select()
79
+ .from(users)
80
+ .where(and(eq(users.externalId, userId), eq(users.projectId, projectId)))
81
+ .limit(1);
82
+
83
+ if (!rows[0]) return null;
84
+ const attrs = rows[0].attributes as any;
85
+ return {
86
+ userId: rows[0].externalId,
87
+ language: attrs.language,
88
+ timezone: attrs.timezone,
89
+ email: attrs.email,
90
+ };
91
+ }
92
+
93
+ async findRecordById(projectId: string, userId: string): Promise<UserRecord | null> {
94
+ const userRows = await this.db
95
+ .select()
96
+ .from(users)
97
+ .where(and(eq(users.externalId, userId), eq(users.projectId, projectId)));
98
+ if (!userRows[0]) return null;
99
+
100
+ const userRow = userRows[0];
101
+ const internalId = userRow.id;
102
+ const attrs = userRow.attributes as any;
103
+
104
+ const [segmentRows, topicRows, channelRows, qhRows] = await Promise.all([
105
+ this.db.select().from(userSegments).where(eq(userSegments.userId, internalId)),
106
+ this.db
107
+ .select()
108
+ .from(userTopicPreferences)
109
+ .where(eq(userTopicPreferences.userId, internalId)),
110
+ this.db
111
+ .select()
112
+ .from(userChannelPreferences)
113
+ .where(eq(userChannelPreferences.userId, internalId)),
114
+ this.db.select().from(quietHours).where(eq(quietHours.userId, internalId)),
115
+ ]);
116
+
117
+ const segments = segmentRows.map((r) => r.segment);
118
+
119
+ const topics: Record<string, boolean> = {};
120
+ for (const r of topicRows) {
121
+ topics[r.topic] = r.enabled;
122
+ }
123
+
124
+ const channels: Record<string, boolean> = {};
125
+ for (const r of channelRows) {
126
+ channels[r.channel] = r.enabled;
127
+ }
128
+
129
+ const quietHoursList = qhRows.map((r) => ({
130
+ start: r.startTime.slice(0, 5),
131
+ end: r.endTime.slice(0, 5),
132
+ }));
133
+
134
+ return {
135
+ userId: userRow.externalId,
136
+ language: attrs.language,
137
+ timezone: attrs.timezone,
138
+ email: attrs.email,
139
+ segments,
140
+ preferences: {
141
+ channels,
142
+ topics,
143
+ quietHours: quietHoursList.length > 0 ? quietHoursList : undefined,
144
+ },
145
+ };
146
+ }
147
+
148
+ async findRecordsByIds(projectId: string, userIds: string[]): Promise<UserRecord[]> {
149
+ if (userIds.length === 0) return [];
150
+
151
+ const usersRows = await this.db
152
+ .select()
153
+ .from(users)
154
+ .where(and(inArray(users.externalId, userIds), eq(users.projectId, projectId)));
155
+
156
+ if (usersRows.length === 0) return [];
157
+
158
+ const internalIds = usersRows.map((r) => r.id);
159
+
160
+ const [segmentRows, topicRows, channelRows, qhRows] = await Promise.all([
161
+ this.db.select().from(userSegments).where(inArray(userSegments.userId, internalIds)),
162
+ this.db
163
+ .select()
164
+ .from(userTopicPreferences)
165
+ .where(inArray(userTopicPreferences.userId, internalIds)),
166
+ this.db
167
+ .select()
168
+ .from(userChannelPreferences)
169
+ .where(inArray(userChannelPreferences.userId, internalIds)),
170
+ this.db.select().from(quietHours).where(inArray(quietHours.userId, internalIds)),
171
+ ]);
172
+
173
+ const segmentsByUserId = new Map<string, string[]>();
174
+ for (const r of segmentRows) {
175
+ if (!segmentsByUserId.has(r.userId)) segmentsByUserId.set(r.userId, []);
176
+ segmentsByUserId.get(r.userId)!.push(r.segment);
177
+ }
178
+
179
+ const topicsByUserId = new Map<string, Record<string, boolean>>();
180
+ for (const r of topicRows) {
181
+ if (!topicsByUserId.has(r.userId)) topicsByUserId.set(r.userId, {});
182
+ topicsByUserId.get(r.userId)![r.topic] = r.enabled;
183
+ }
184
+
185
+ const channelsByUserId = new Map<string, Record<string, boolean>>();
186
+ for (const r of channelRows) {
187
+ if (!channelsByUserId.has(r.userId)) channelsByUserId.set(r.userId, {});
188
+ channelsByUserId.get(r.userId)![r.channel] = r.enabled;
189
+ }
190
+
191
+ const qhByUserId = new Map<string, any[]>();
192
+ for (const r of qhRows) {
193
+ if (r.userId == null) continue;
194
+ if (!qhByUserId.has(r.userId)) qhByUserId.set(r.userId, []);
195
+ qhByUserId
196
+ .get(r.userId)!
197
+ .push({ start: r.startTime.slice(0, 5), end: r.endTime.slice(0, 5) });
198
+ }
199
+
200
+ const userRecords: UserRecord[] = [];
201
+
202
+ for (const userRow of usersRows) {
203
+ const attrs = userRow.attributes as any;
204
+ const internalId = userRow.id;
205
+
206
+ userRecords.push({
207
+ userId: userRow.externalId,
208
+ language: attrs.language,
209
+ timezone: attrs.timezone,
210
+ email: attrs.email,
211
+ segments: segmentsByUserId.get(internalId) || [],
212
+ preferences: {
213
+ channels: channelsByUserId.get(internalId) || {},
214
+ topics: topicsByUserId.get(internalId) || {},
215
+ quietHours: qhByUserId.get(internalId),
216
+ },
217
+ });
218
+ }
219
+
220
+ return userRecords;
221
+ }
222
+
223
+ async upsertFull(
224
+ projectId: string,
225
+ user: {
226
+ userId: string;
227
+ language?: string;
228
+ timezone?: string;
229
+ email?: string | null;
230
+ segments: string[];
231
+ preferences: Preferences;
232
+ },
233
+ ): Promise<void> {
234
+ await this.db.transaction(async (tx) => {
235
+ await tx
236
+ .insert(users)
237
+ .values({
238
+ projectId,
239
+ externalId: user.userId,
240
+ attributes: {
241
+ language: user.language,
242
+ timezone: user.timezone,
243
+ email: user.email,
244
+ },
245
+ })
246
+ .onConflictDoUpdate({
247
+ target: [users.projectId, users.externalId],
248
+ set: {
249
+ attributes: {
250
+ language: user.language,
251
+ timezone: user.timezone,
252
+ email: user.email,
253
+ },
254
+ updatedAt: new Date(),
255
+ },
256
+ });
257
+
258
+ const internalIdRows = await tx
259
+ .select({ id: users.id })
260
+ .from(users)
261
+ .where(and(eq(users.externalId, user.userId), eq(users.projectId, projectId)));
262
+ const internalId = internalIdRows[0]?.id;
263
+ if (!internalId) return;
264
+
265
+ if (user.segments && user.segments.length > 0) {
266
+ await tx
267
+ .insert(userSegments)
268
+ .values(user.segments.map((s) => ({ userId: internalId, segment: s })))
269
+ .onConflictDoNothing();
270
+ }
271
+
272
+ if (user.preferences && user.preferences.topics) {
273
+ const topicInserts = Object.entries(user.preferences.topics).map(([topic, enabled]) => ({
274
+ userId: internalId,
275
+ topic,
276
+ enabled,
277
+ }));
278
+ if (topicInserts.length > 0) {
279
+ await tx
280
+ .insert(userTopicPreferences)
281
+ .values(topicInserts)
282
+ .onConflictDoUpdate({
283
+ target: [userTopicPreferences.userId, userTopicPreferences.topic],
284
+ set: { enabled: drizzleSql`excluded.enabled` },
285
+ });
286
+ }
287
+ }
288
+
289
+ if (user.preferences?.channels) {
290
+ const channelInserts = Object.entries(user.preferences.channels).map(
291
+ ([channel, enabled]) => ({
292
+ userId: internalId,
293
+ channel: channel as ContactChannel,
294
+ enabled,
295
+ }),
296
+ );
297
+ if (channelInserts.length > 0) {
298
+ await tx
299
+ .insert(userChannelPreferences)
300
+ .values(channelInserts)
301
+ .onConflictDoUpdate({
302
+ target: [userChannelPreferences.userId, userChannelPreferences.channel],
303
+ set: { enabled: drizzleSql`excluded.enabled` },
304
+ });
305
+ }
306
+ }
307
+
308
+ if (user.preferences?.quietHours !== undefined) {
309
+ await tx.delete(quietHours).where(eq(quietHours.userId, internalId));
310
+ if (user.preferences.quietHours.length > 0) {
311
+ await tx.insert(quietHours).values(
312
+ user.preferences.quietHours.map((window) => ({
313
+ userId: internalId,
314
+ startTime: window.start,
315
+ endTime: window.end,
316
+ })),
317
+ );
318
+ }
319
+ }
320
+ });
321
+ }
322
+
323
+ async upsertManyFull(
324
+ projectId: string,
325
+ usersList: Array<{
326
+ userId: string;
327
+ language?: string;
328
+ timezone?: string;
329
+ email?: string | null;
330
+ segments: string[];
331
+ preferences: Preferences;
332
+ }>,
333
+ ): Promise<void> {
334
+ if (usersList.length === 0) return;
335
+
336
+ let attempts = 0;
337
+ while (attempts < 3) {
338
+ try {
339
+ await this.db.transaction(async (tx) => {
340
+ await tx
341
+ .insert(users)
342
+ .values(
343
+ usersList.map((u) => ({
344
+ projectId,
345
+ externalId: u.userId,
346
+ attributes: {
347
+ language: u.language,
348
+ timezone: u.timezone,
349
+ email: u.email,
350
+ },
351
+ })),
352
+ )
353
+ .onConflictDoUpdate({
354
+ target: [users.projectId, users.externalId],
355
+ set: {
356
+ attributes: drizzleSql`excluded.attributes`,
357
+ updatedAt: new Date(),
358
+ },
359
+ });
360
+
361
+ const internalIdRows = await tx
362
+ .select({ id: users.id, externalId: users.externalId })
363
+ .from(users)
364
+ .where(
365
+ and(
366
+ inArray(
367
+ users.externalId,
368
+ usersList.map((u) => u.userId),
369
+ ),
370
+ eq(users.projectId, projectId),
371
+ ),
372
+ );
373
+
374
+ const idMap = new Map(internalIdRows.map((r) => [r.externalId, r.id]));
375
+
376
+ const segmentInserts: any[] = [];
377
+ const topicInserts: any[] = [];
378
+ const channelInserts: any[] = [];
379
+ const quietHoursInserts: any[] = [];
380
+
381
+ for (const u of usersList) {
382
+ const internalId = idMap.get(u.userId);
383
+ if (!internalId) continue;
384
+
385
+ if (u.segments && u.segments.length > 0) {
386
+ for (const s of u.segments) {
387
+ segmentInserts.push({ userId: internalId, segment: s });
388
+ }
389
+ }
390
+
391
+ if (u.preferences?.topics) {
392
+ for (const [topic, enabled] of Object.entries(u.preferences.topics)) {
393
+ topicInserts.push({ userId: internalId, topic, enabled });
394
+ }
395
+ }
396
+
397
+ if (u.preferences?.channels) {
398
+ for (const [channel, enabled] of Object.entries(u.preferences.channels)) {
399
+ channelInserts.push({
400
+ userId: internalId,
401
+ channel: channel as ContactChannel,
402
+ enabled,
403
+ });
404
+ }
405
+ }
406
+
407
+ if (u.preferences?.quietHours && u.preferences.quietHours.length > 0) {
408
+ for (const window of u.preferences.quietHours) {
409
+ quietHoursInserts.push({
410
+ userId: internalId,
411
+ startTime: window.start,
412
+ endTime: window.end,
413
+ });
414
+ }
415
+ }
416
+ }
417
+
418
+ const segmentSet = new Set<string>();
419
+ const dedupedSegmentInserts: any[] = [];
420
+ for (const s of segmentInserts) {
421
+ const key = `${s.userId}:${s.segment}`;
422
+ if (!segmentSet.has(key)) {
423
+ segmentSet.add(key);
424
+ dedupedSegmentInserts.push(s);
425
+ }
426
+ }
427
+
428
+ const topicMap = new Map<string, any>();
429
+ for (const t of topicInserts) {
430
+ topicMap.set(`${t.userId}:${t.topic}`, t);
431
+ }
432
+ const dedupedTopicInserts = Array.from(topicMap.values());
433
+
434
+ const channelMap = new Map<string, any>();
435
+ for (const c of channelInserts) {
436
+ channelMap.set(`${c.userId}:${c.channel}`, c);
437
+ }
438
+ const dedupedChannelInserts = Array.from(channelMap.values());
439
+
440
+ if (dedupedSegmentInserts.length > 0) {
441
+ await tx.insert(userSegments).values(dedupedSegmentInserts).onConflictDoNothing();
442
+ }
443
+
444
+ if (dedupedTopicInserts.length > 0) {
445
+ await tx
446
+ .insert(userTopicPreferences)
447
+ .values(dedupedTopicInserts)
448
+ .onConflictDoUpdate({
449
+ target: [userTopicPreferences.userId, userTopicPreferences.topic],
450
+ set: { enabled: drizzleSql`excluded.enabled` },
451
+ });
452
+ }
453
+
454
+ if (dedupedChannelInserts.length > 0) {
455
+ await tx
456
+ .insert(userChannelPreferences)
457
+ .values(dedupedChannelInserts)
458
+ .onConflictDoUpdate({
459
+ target: [userChannelPreferences.userId, userChannelPreferences.channel],
460
+ set: { enabled: drizzleSql`excluded.enabled` },
461
+ });
462
+ }
463
+
464
+ const usersWithQuietHours = usersList.filter(
465
+ (u) => u.preferences?.quietHours !== undefined,
466
+ );
467
+ const internalIdsToClearQuietHours = usersWithQuietHours
468
+ .map((u) => idMap.get(u.userId))
469
+ .filter(Boolean) as string[];
470
+
471
+ if (internalIdsToClearQuietHours.length > 0) {
472
+ await tx
473
+ .delete(quietHours)
474
+ .where(inArray(quietHours.userId, internalIdsToClearQuietHours));
475
+ }
476
+ if (quietHoursInserts.length > 0) {
477
+ await tx.insert(quietHours).values(quietHoursInserts);
478
+ }
479
+ });
480
+ return;
481
+ } catch (err: any) {
482
+ attempts++;
483
+ if (attempts >= 3 || (err.code !== "40001" && err.code !== "40P01")) {
484
+ throw err;
485
+ }
486
+ await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempts) * 100));
487
+ }
488
+ }
489
+ }
490
+
491
+ async updatePartial(
492
+ projectId: string,
493
+ userId: string,
494
+ patch: {
495
+ language?: string;
496
+ timezone?: string;
497
+ email?: string | null;
498
+ segments?: string[];
499
+ preferences?: Preferences;
500
+ },
501
+ ): Promise<boolean> {
502
+ const existing = await this.findById(projectId, userId);
503
+ if (!existing) return false;
504
+
505
+ const attrs = {
506
+ language: patch.language ?? existing.language,
507
+ timezone: patch.timezone ?? existing.timezone,
508
+ email: patch.email ?? existing.email,
509
+ };
510
+
511
+ await this.db.transaction(async (tx) => {
512
+ await tx
513
+ .update(users)
514
+ .set({ attributes: attrs, updatedAt: new Date() })
515
+ .where(and(eq(users.externalId, userId), eq(users.projectId, projectId)));
516
+
517
+ const internalIdRows = await tx
518
+ .select({ id: users.id })
519
+ .from(users)
520
+ .where(and(eq(users.externalId, userId), eq(users.projectId, projectId)));
521
+ const internalId = internalIdRows[0]?.id;
522
+ if (!internalId) return;
523
+
524
+ if (patch.segments) {
525
+ await tx.delete(userSegments).where(eq(userSegments.userId, internalId));
526
+ if (patch.segments.length > 0) {
527
+ await tx
528
+ .insert(userSegments)
529
+ .values(patch.segments.map((s) => ({ userId: internalId, segment: s })))
530
+ .onConflictDoNothing();
531
+ }
532
+ }
533
+
534
+ if (patch.preferences && patch.preferences.topics) {
535
+ const topicInserts = Object.entries(patch.preferences.topics).map(([topic, enabled]) => ({
536
+ userId: internalId,
537
+ topic,
538
+ enabled,
539
+ }));
540
+ if (topicInserts.length > 0) {
541
+ await tx
542
+ .insert(userTopicPreferences)
543
+ .values(topicInserts)
544
+ .onConflictDoUpdate({
545
+ target: [userTopicPreferences.userId, userTopicPreferences.topic],
546
+ set: { enabled: drizzleSql`excluded.enabled` },
547
+ });
548
+ }
549
+ }
550
+
551
+ if (patch.preferences?.channels) {
552
+ const channelInserts = Object.entries(patch.preferences.channels).map(
553
+ ([channel, enabled]) => ({
554
+ userId: internalId,
555
+ channel: channel as ContactChannel,
556
+ enabled,
557
+ }),
558
+ );
559
+ if (channelInserts.length > 0) {
560
+ await tx
561
+ .insert(userChannelPreferences)
562
+ .values(channelInserts)
563
+ .onConflictDoUpdate({
564
+ target: [userChannelPreferences.userId, userChannelPreferences.channel],
565
+ set: { enabled: drizzleSql`excluded.enabled` },
566
+ });
567
+ }
568
+ }
569
+
570
+ if (patch.preferences?.quietHours !== undefined) {
571
+ await tx.delete(quietHours).where(eq(quietHours.userId, internalId));
572
+ if (patch.preferences.quietHours.length > 0) {
573
+ await tx.insert(quietHours).values(
574
+ patch.preferences.quietHours.map((window) => ({
575
+ userId: internalId,
576
+ startTime: window.start,
577
+ endTime: window.end,
578
+ })),
579
+ );
580
+ }
581
+ }
582
+ });
583
+
584
+ return true;
585
+ }
586
+
587
+ async delete(projectId: string, userId: string): Promise<boolean> {
588
+ const result = await this.db
589
+ .delete(users)
590
+ .where(and(eq(users.externalId, userId), eq(users.projectId, projectId)))
591
+ .returning();
592
+ return result.length > 0;
593
+ }
594
+
595
+ async list(
596
+ projectId: string,
597
+ limit: number,
598
+ cursor?: string,
599
+ filters?: {
600
+ search?: string;
601
+ segment?: string;
602
+ language?: string;
603
+ timezone?: string;
604
+ channel?: string;
605
+ },
606
+ ): Promise<{ users: UserProfile[]; nextCursor: string | null }> {
607
+ const conditions = [eq(users.projectId, projectId)];
608
+
609
+ if (cursor) {
610
+ const cursorDate = new Date(parseInt(cursor, 10));
611
+ if (!isNaN(cursorDate.getTime())) {
612
+ conditions.push(drizzleSql`${users.createdAt} < ${cursorDate.toISOString()}`);
613
+ }
614
+ }
615
+
616
+ if (filters?.language) {
617
+ conditions.push(drizzleSql`(${users.attributes}->>'language') = ${filters.language}`);
618
+ }
619
+
620
+ if (filters?.timezone) {
621
+ conditions.push(drizzleSql`(${users.attributes}->>'timezone') = ${filters.timezone}`);
622
+ }
623
+
624
+ if (filters?.search) {
625
+ const term = `%${filters.search.trim()}%`;
626
+ conditions.push(
627
+ drizzleSql`(${users.externalId} ILIKE ${term} OR (${users.attributes}->>'email') ILIKE ${term})`,
628
+ );
629
+ }
630
+
631
+ if (filters?.segment) {
632
+ conditions.push(
633
+ drizzleSql`EXISTS (SELECT 1 FROM ${userSegments} WHERE ${userSegments.userId} = ${users.id} AND ${userSegments.segment} = ${filters.segment})`,
634
+ );
635
+ }
636
+
637
+ if (filters?.channel) {
638
+ conditions.push(
639
+ drizzleSql`EXISTS (SELECT 1 FROM ${userContacts} WHERE ${userContacts.userId} = ${users.id} AND ${userContacts.channel} = ${filters.channel})`,
640
+ );
641
+ }
642
+
643
+ const rows = await this.db
644
+ .select()
645
+ .from(users)
646
+ .where(and(...conditions))
647
+ .orderBy(desc(users.createdAt))
648
+ .limit(limit);
649
+
650
+ const items = rows.map((r) => {
651
+ const attrs = r.attributes as any;
652
+ return {
653
+ userId: r.externalId,
654
+ language: attrs.language,
655
+ timezone: attrs.timezone,
656
+ email: attrs.email,
657
+ createdAt: r.createdAt.getTime(),
658
+ };
659
+ });
660
+
661
+ const nextCursor =
662
+ items.length === limit ? items[items.length - 1]!.createdAt.toString() : null;
663
+ return { users: items, nextCursor };
664
+ }
665
+
666
+ async findUsersBySegment(projectId: string, segmentName: string): Promise<string[]> {
667
+ const rows = await this.db
668
+ .select({ externalId: users.externalId })
669
+ .from(users)
670
+ .innerJoin(userSegments, eq(users.id, userSegments.userId))
671
+ .where(and(eq(userSegments.segment, segmentName), eq(users.projectId, projectId)));
672
+
673
+ return rows.map((r) => r.externalId);
674
+ }
675
+
676
+ async findUsersByTopic(projectId: string, topicName: string): Promise<string[]> {
677
+ const rows = await this.db
678
+ .select({ externalId: users.externalId })
679
+ .from(users)
680
+ .innerJoin(userTopicPreferences, eq(users.id, userTopicPreferences.userId))
681
+ .where(
682
+ and(
683
+ eq(userTopicPreferences.topic, topicName),
684
+ eq(userTopicPreferences.enabled, true),
685
+ eq(users.projectId, projectId),
686
+ ),
687
+ );
688
+
689
+ return rows.map((r) => r.externalId);
690
+ }
691
+ }
692
+
693
+ // ─── PreferenceRepository ────────────────────────────────────────────────────
694
+
695
+ export class PreferenceRepository {
696
+ constructor(private readonly db: Db) {}
697
+
698
+ async isOptedIn(projectId: string, userId: string, eventType: string): Promise<boolean> {
699
+ const prefs = await this.findByUserId(projectId, userId);
700
+ const pref = prefs.find((p) => p.eventType === eventType);
701
+ return pref ? pref.optedIn : true;
702
+ }
703
+
704
+ async findByUserId(projectId: string, userId: string): Promise<NotificationPreference[]> {
705
+ const internalUserIdRows = await this.db
706
+ .select({ id: users.id })
707
+ .from(users)
708
+ .where(and(eq(users.externalId, userId), eq(users.projectId, projectId)));
709
+ const internalId = internalUserIdRows[0]?.id;
710
+ if (!internalId) return [];
711
+
712
+ const rows = await this.db
713
+ .select()
714
+ .from(userTopicPreferences)
715
+ .where(eq(userTopicPreferences.userId, internalId));
716
+
717
+ return rows.map((r) => ({
718
+ userId,
719
+ eventType: r.topic,
720
+ optedIn: r.enabled,
721
+ }));
722
+ }
723
+ }
724
+
725
+ // ─── ContactRepository ───────────────────────────────────────────────────────
726
+
727
+ export class ContactRepository {
728
+ constructor(private readonly db: Db) {}
729
+
730
+ async findByUserId(projectId: string, userId: string): Promise<UserContact[]> {
731
+ const internalUserIdRows = await this.db
732
+ .select({ id: users.id })
733
+ .from(users)
734
+ .where(and(eq(users.externalId, userId), eq(users.projectId, projectId)));
735
+ const internalId = internalUserIdRows[0]?.id;
736
+ if (!internalId) return [];
737
+
738
+ const rows = await this.db
739
+ .select()
740
+ .from(userContacts)
741
+ .where(eq(userContacts.userId, internalId));
742
+ if (rows.length === 0) return [];
743
+
744
+ const topicRows = await this.db
745
+ .select()
746
+ .from(contactTopicPreferences)
747
+ .where(
748
+ inArray(
749
+ contactTopicPreferences.contactId,
750
+ rows.map((r) => r.id),
751
+ ),
752
+ );
753
+
754
+ const topicsByContact = new Map<string, Record<string, boolean>>();
755
+ for (const t of topicRows) {
756
+ if (!topicsByContact.has(t.contactId)) topicsByContact.set(t.contactId, {});
757
+ topicsByContact.get(t.contactId)![t.topic] = t.enabled;
758
+ }
759
+
760
+ return rows.map((r) => ({
761
+ id: r.id,
762
+ userId,
763
+ channel: r.channel as ContactChannel,
764
+ target: r.target,
765
+ preferences: { topics: topicsByContact.get(r.id) ?? {} },
766
+ active: r.enabled,
767
+ }));
768
+ }
769
+
770
+ /** Resolve active addressable contacts for a batch without an N+1 query. */
771
+ async findActiveByUserIds(
772
+ projectId: string,
773
+ userIds: string[],
774
+ ): Promise<Map<string, UserContact[]>> {
775
+ const byUser = new Map<string, UserContact[]>();
776
+ if (userIds.length === 0) return byUser;
777
+
778
+ const rows = await this.db
779
+ .select({
780
+ userId: users.externalId,
781
+ id: userContacts.id,
782
+ channel: userContacts.channel,
783
+ target: userContacts.target,
784
+ enabled: userContacts.enabled,
785
+ })
786
+ .from(users)
787
+ .innerJoin(userContacts, eq(users.id, userContacts.userId))
788
+ .where(
789
+ and(
790
+ eq(users.projectId, projectId),
791
+ inArray(users.externalId, userIds),
792
+ eq(userContacts.enabled, true),
793
+ ),
794
+ );
795
+
796
+ for (const row of rows) {
797
+ const contacts = byUser.get(row.userId) ?? [];
798
+ contacts.push({
799
+ id: row.id,
800
+ userId: row.userId,
801
+ channel: row.channel as ContactChannel,
802
+ target: row.target,
803
+ preferences: {},
804
+ active: row.enabled,
805
+ });
806
+ byUser.set(row.userId, contacts);
807
+ }
808
+ return byUser;
809
+ }
810
+
811
+ async upsert(
812
+ projectId: string,
813
+ userId: string,
814
+ channel: ContactChannel,
815
+ target: string,
816
+ preferences: Preferences = {},
817
+ ): Promise<void> {
818
+ const internalUserIdRows = await this.db
819
+ .select({ id: users.id })
820
+ .from(users)
821
+ .where(and(eq(users.externalId, userId), eq(users.projectId, projectId)));
822
+ const internalId = internalUserIdRows[0]?.id;
823
+ if (!internalId) return;
824
+
825
+ const inserted = await this.db
826
+ .insert(userContacts)
827
+ .values({
828
+ userId: internalId,
829
+ channel: channel as any,
830
+ target,
831
+ enabled: true,
832
+ })
833
+ .onConflictDoUpdate({
834
+ target: [userContacts.userId, userContacts.channel, userContacts.target],
835
+ // Re-adding a contact re-enables it; this is also how a device token
836
+ // deactivated by an invalid-token response comes back.
837
+ set: { enabled: true },
838
+ })
839
+ .returning({ id: userContacts.id });
840
+
841
+ const contactId = inserted[0]?.id;
842
+ if (!contactId) return;
843
+
844
+ // Contact-level topic preferences were previously accepted by the API and
845
+ // silently discarded.
846
+ const topics = Object.entries(preferences.topics ?? {});
847
+ if (topics.length > 0) {
848
+ await this.db
849
+ .insert(contactTopicPreferences)
850
+ .values(topics.map(([topic, enabled]) => ({ contactId, topic, enabled })))
851
+ .onConflictDoUpdate({
852
+ target: [contactTopicPreferences.contactId, contactTopicPreferences.topic],
853
+ set: { enabled: drizzleSql`excluded.enabled` },
854
+ });
855
+ }
856
+ }
857
+
858
+ async upsertMany(
859
+ projectId: string,
860
+ contactsList: Array<{
861
+ userId: string;
862
+ channel: ContactChannel;
863
+ target: string;
864
+ preferences?: Preferences;
865
+ }>,
866
+ ): Promise<void> {
867
+ if (contactsList.length === 0) return;
868
+
869
+ const internalUserIdRows = await this.db
870
+ .select({ id: users.id, externalId: users.externalId })
871
+ .from(users)
872
+ .where(
873
+ and(
874
+ inArray(
875
+ users.externalId,
876
+ contactsList.map((c) => c.userId),
877
+ ),
878
+ eq(users.projectId, projectId),
879
+ ),
880
+ );
881
+
882
+ const idMap = new Map(internalUserIdRows.map((r) => [r.externalId, r.id]));
883
+
884
+ const validContacts = contactsList.filter((c) => idMap.has(c.userId));
885
+ if (validContacts.length === 0) return;
886
+
887
+ const inserted = await this.db
888
+ .insert(userContacts)
889
+ .values(
890
+ validContacts.map((c) => ({
891
+ userId: idMap.get(c.userId)!,
892
+ channel: c.channel as any,
893
+ target: c.target,
894
+ enabled: true,
895
+ })),
896
+ )
897
+ .onConflictDoUpdate({
898
+ target: [userContacts.userId, userContacts.channel, userContacts.target],
899
+ set: { enabled: true },
900
+ })
901
+ .returning({
902
+ id: userContacts.id,
903
+ userId: userContacts.userId,
904
+ channel: userContacts.channel,
905
+ target: userContacts.target,
906
+ });
907
+
908
+ const contactIdMap = new Map();
909
+ for (const row of inserted) {
910
+ contactIdMap.set(`${row.userId}:${row.channel}:${row.target}`, row.id);
911
+ }
912
+
913
+ const topicInserts: any[] = [];
914
+ for (const c of validContacts) {
915
+ const internalId = idMap.get(c.userId)!;
916
+ const contactId = contactIdMap.get(`${internalId}:${c.channel}:${c.target}`);
917
+ if (!contactId || !c.preferences?.topics) continue;
918
+
919
+ for (const [topic, enabled] of Object.entries(c.preferences.topics)) {
920
+ topicInserts.push({ contactId, topic, enabled });
921
+ }
922
+ }
923
+
924
+ if (topicInserts.length > 0) {
925
+ await this.db
926
+ .insert(contactTopicPreferences)
927
+ .values(topicInserts)
928
+ .onConflictDoUpdate({
929
+ target: [contactTopicPreferences.contactId, contactTopicPreferences.topic],
930
+ set: { enabled: drizzleSql`excluded.enabled` },
931
+ });
932
+ }
933
+ }
934
+
935
+ /**
936
+ * Mark a contact unusable without destroying it — used when a provider
937
+ * reports an invalid push token. Deleting the row would lose the user's
938
+ * device permanently on what is often a transient provider response.
939
+ */
940
+ async deactivate(
941
+ projectId: string,
942
+ userId: string,
943
+ channel: ContactChannel,
944
+ target: string,
945
+ ): Promise<boolean> {
946
+ const internalUserIdRows = await this.db
947
+ .select({ id: users.id })
948
+ .from(users)
949
+ .where(and(eq(users.externalId, userId), eq(users.projectId, projectId)));
950
+ const internalId = internalUserIdRows[0]?.id;
951
+ if (!internalId) return false;
952
+
953
+ const result = await this.db
954
+ .update(userContacts)
955
+ .set({ enabled: false })
956
+ .where(
957
+ and(
958
+ eq(userContacts.userId, internalId),
959
+ eq(userContacts.channel, channel as any),
960
+ eq(userContacts.target, target),
961
+ ),
962
+ )
963
+ .returning();
964
+ return result.length > 0;
965
+ }
966
+
967
+ async delete(
968
+ projectId: string,
969
+ userId: string,
970
+ channel: ContactChannel,
971
+ target: string,
972
+ ): Promise<boolean> {
973
+ const internalUserIdRows = await this.db
974
+ .select({ id: users.id })
975
+ .from(users)
976
+ .where(and(eq(users.externalId, userId), eq(users.projectId, projectId)));
977
+ const internalId = internalUserIdRows[0]?.id;
978
+ if (!internalId) return false;
979
+
980
+ const result = await this.db
981
+ .delete(userContacts)
982
+ .where(
983
+ and(
984
+ eq(userContacts.userId, internalId),
985
+ eq(userContacts.channel, channel as any),
986
+ eq(userContacts.target, target),
987
+ ),
988
+ )
989
+ .returning();
990
+ return result.length > 0;
991
+ }
992
+ }
993
+
994
+ // ─── TemplateRepository ──────────────────────────────────────────────────────
995
+
996
+ export class TemplateRepository {
997
+ constructor(private readonly db: Db) {}
998
+
999
+ async findById(projectId: string, id: string): Promise<TemplateRecord | null> {
1000
+ const rows = await this.db
1001
+ .select()
1002
+ .from(templates)
1003
+ .where(and(eq(templates.id, id), eq(templates.projectId, projectId)))
1004
+ .limit(1);
1005
+ if (!rows[0]) return null;
1006
+ return {
1007
+ id: rows[0].id,
1008
+ channel: rows[0].channel,
1009
+ content: rows[0].content,
1010
+ topics: (rows[0].topics ?? []) as string[],
1011
+ aiPrompts: rows[0].aiPrompts as Record<string, string> | null,
1012
+ };
1013
+ }
1014
+
1015
+ async list(projectId: string): Promise<TemplateRecord[]> {
1016
+ const rows = await this.db.select().from(templates).where(eq(templates.projectId, projectId));
1017
+ return rows.map((r) => ({
1018
+ id: r.id,
1019
+ channel: r.channel,
1020
+ content: r.content,
1021
+ topics: (r.topics ?? []) as string[],
1022
+ aiPrompts: r.aiPrompts as Record<string, string> | null,
1023
+ }));
1024
+ }
1025
+
1026
+ async upsertMany(projectId: string, templateList: any[]): Promise<number> {
1027
+ if (templateList.length === 0) return 0;
1028
+
1029
+ const values = templateList.map((t) => ({
1030
+ projectId,
1031
+ id: t.id,
1032
+ channel: t.channel as any,
1033
+ topics: t.topics ?? [],
1034
+ content: t.content,
1035
+ aiPrompts: t.aiPrompts,
1036
+ }));
1037
+
1038
+ await this.db
1039
+ .insert(templates)
1040
+ .values(values)
1041
+ .onConflictDoUpdate({
1042
+ target: [templates.projectId, templates.id],
1043
+ set: {
1044
+ channel: drizzleSql`excluded.channel`,
1045
+ topics: drizzleSql`excluded.topics`,
1046
+ content: drizzleSql`excluded.content`,
1047
+ aiPrompts: drizzleSql`excluded.ai_prompts`,
1048
+ updatedAt: new Date(),
1049
+ },
1050
+ });
1051
+
1052
+ return templateList.length;
1053
+ }
1054
+
1055
+ async delete(projectId: string, id: string): Promise<boolean> {
1056
+ const result = await this.db
1057
+ .delete(templates)
1058
+ .where(and(eq(templates.id, id), eq(templates.projectId, projectId)))
1059
+ .returning();
1060
+ return result.length > 0;
1061
+ }
1062
+ }
1063
+
1064
+ // ─── ProjectRepository ───────────────────────────────────────────────────────
1065
+
1066
+ export class ProjectRepository {
1067
+ constructor(private readonly db: Db) {}
1068
+
1069
+ async list(): Promise<any[]> {
1070
+ return this.db
1071
+ .select({
1072
+ id: projects.id,
1073
+ name: projects.name,
1074
+ rateLimitRpm: projects.rateLimitRpm,
1075
+ throttleLimit: projects.throttleLimit,
1076
+ throttleWindowHours: projects.throttleWindowHours,
1077
+ createdAt: projects.createdAt,
1078
+ })
1079
+ .from(projects)
1080
+ .orderBy(desc(projects.createdAt));
1081
+ }
1082
+
1083
+ async delete(id: string): Promise<boolean> {
1084
+ return await this.db.transaction(async (tx) => {
1085
+ const userRows = await tx.select({ id: users.id }).from(users).where(eq(users.projectId, id));
1086
+ const userIds = userRows.map((u) => u.id);
1087
+ if (userIds.length > 0) {
1088
+ await tx.delete(userContacts).where(inArray(userContacts.userId, userIds));
1089
+ await tx.delete(userSegments).where(inArray(userSegments.userId, userIds));
1090
+ await tx.delete(userTopicPreferences).where(inArray(userTopicPreferences.userId, userIds));
1091
+ await tx
1092
+ .delete(userChannelPreferences)
1093
+ .where(inArray(userChannelPreferences.userId, userIds));
1094
+ await tx.delete(quietHours).where(inArray(quietHours.userId, userIds));
1095
+ await tx.delete(users).where(eq(users.projectId, id));
1096
+ }
1097
+ await tx.delete(suppressions).where(eq(suppressions.projectId, id));
1098
+ await tx.delete(messageLogs).where(eq(messageLogs.projectId, id));
1099
+ await tx.delete(workflowInstances).where(eq(workflowInstances.projectId, id));
1100
+
1101
+ const result = await tx.delete(projects).where(eq(projects.id, id)).returning();
1102
+ return result.length > 0;
1103
+ });
1104
+ }
1105
+
1106
+ /**
1107
+ * Throttle overrides only. Kept narrow because the engine calls this once per
1108
+ * notification (behind a cache) and has no use for the rest of the row.
1109
+ */
1110
+ async findThrottleSettings(
1111
+ id: string,
1112
+ ): Promise<{ throttleLimit: number | null; throttleWindowHours: number | null } | null> {
1113
+ const rows = await this.db
1114
+ .select({
1115
+ throttleLimit: projects.throttleLimit,
1116
+ throttleWindowHours: projects.throttleWindowHours,
1117
+ })
1118
+ .from(projects)
1119
+ .where(eq(projects.id, id))
1120
+ .limit(1);
1121
+
1122
+ return rows[0] ?? null;
1123
+ }
1124
+
1125
+ async updateSettings(
1126
+ id: string,
1127
+ settings: {
1128
+ rateLimitRpm?: number | null;
1129
+ throttleLimit?: number | null;
1130
+ throttleWindowHours?: number | null;
1131
+ },
1132
+ ): Promise<boolean> {
1133
+ const result = await this.db
1134
+ .update(projects)
1135
+ .set(settings)
1136
+ .where(eq(projects.id, id))
1137
+ .returning();
1138
+ return result.length > 0;
1139
+ }
1140
+
1141
+ async createApiKey(
1142
+ projectId: string,
1143
+ keyHash: string,
1144
+ role: "admin" | "read_only" = "admin",
1145
+ ): Promise<{ id: string }> {
1146
+ const result = await this.db
1147
+ .insert(projectApiKeys)
1148
+ .values({ projectId, keyHash, role })
1149
+ .returning();
1150
+ return { id: result[0]!.id };
1151
+ }
1152
+
1153
+ async listApiKeys(projectId: string): Promise<any[]> {
1154
+ return this.db
1155
+ .select({
1156
+ id: projectApiKeys.id,
1157
+ role: projectApiKeys.role,
1158
+ createdAt: projectApiKeys.createdAt,
1159
+ })
1160
+ .from(projectApiKeys)
1161
+ .where(eq(projectApiKeys.projectId, projectId))
1162
+ .orderBy(desc(projectApiKeys.createdAt));
1163
+ }
1164
+
1165
+ async deleteApiKey(projectId: string, keyId: string): Promise<boolean> {
1166
+ const result = await this.db
1167
+ .delete(projectApiKeys)
1168
+ .where(and(eq(projectApiKeys.id, keyId), eq(projectApiKeys.projectId, projectId)))
1169
+ .returning();
1170
+ return result.length > 0;
1171
+ }
1172
+ }
1173
+
1174
+ // ─── WorkflowRepository ──────────────────────────────────────────────────────
1175
+
1176
+ export class WorkflowRepository {
1177
+ constructor(private readonly db: Db) {}
1178
+
1179
+ async listDefinitions(projectId: string): Promise<any[]> {
1180
+ return this.db
1181
+ .select()
1182
+ .from(workflowDefinitions)
1183
+ .where(eq(workflowDefinitions.projectId, projectId))
1184
+ .orderBy(desc(workflowDefinitions.createdAt));
1185
+ }
1186
+
1187
+ async getInstance(projectId: string, instanceId: string): Promise<any | null> {
1188
+ const instances = await this.db
1189
+ .select()
1190
+ .from(workflowInstances)
1191
+ .where(and(eq(workflowInstances.id, instanceId), eq(workflowInstances.projectId, projectId)))
1192
+ .limit(1);
1193
+ if (!instances[0]) return null;
1194
+
1195
+ const steps = await this.db
1196
+ .select()
1197
+ .from(workflowSteps)
1198
+ .where(eq(workflowSteps.instanceId, instanceId))
1199
+ .orderBy(workflowSteps.createdAt);
1200
+ const waiters = await this.db
1201
+ .select()
1202
+ .from(workflowWaiters)
1203
+ .where(eq(workflowWaiters.instanceId, instanceId));
1204
+
1205
+ return {
1206
+ ...instances[0],
1207
+ steps,
1208
+ waiters,
1209
+ };
1210
+ }
1211
+
1212
+ async cancelInstance(projectId: string, instanceId: string): Promise<boolean> {
1213
+ return await this.db.transaction(async (tx) => {
1214
+ const result = await tx
1215
+ .update(workflowInstances)
1216
+ .set({ status: "canceled" as any })
1217
+ .where(
1218
+ and(
1219
+ eq(workflowInstances.id, instanceId),
1220
+ eq(workflowInstances.projectId, projectId),
1221
+ inArray(workflowInstances.status, ["pending", "running"]),
1222
+ ),
1223
+ )
1224
+ .returning();
1225
+ if (result.length === 0) return false;
1226
+ await tx.delete(workflowWaiters).where(eq(workflowWaiters.instanceId, instanceId));
1227
+ return true;
1228
+ });
1229
+ }
1230
+ }
1231
+
1232
+ // ─── SegmentRepository ───────────────────────────────────────────────────────
1233
+
1234
+ export class SegmentRepository {
1235
+ constructor(private readonly db: Db) {}
1236
+
1237
+ async listSegments(projectId: string): Promise<string[]> {
1238
+ const rows = await this.db.execute(drizzleSql`
1239
+ SELECT DISTINCT s.segment
1240
+ FROM user_segments s
1241
+ JOIN users u ON u.id = s.user_id
1242
+ WHERE u.project_id = ${projectId}
1243
+ `);
1244
+ return (rows as any[]).map((r) => r.segment);
1245
+ }
1246
+ }