notifkit 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -122
- package/dist/index.d.mts +196 -132
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/{main-DtHWhueo.mjs → main-40zwq6b0.mjs} +28 -3
- package/dist/{main-DtHWhueo.mjs.map → main-40zwq6b0.mjs.map} +1 -1
- package/dist/{main-DyfbnJc3.mjs → main-BFre2-HQ.mjs} +2 -2
- package/dist/{main-DyfbnJc3.mjs.map → main-BFre2-HQ.mjs.map} +1 -1
- package/dist/{main-CAH0_Q6d.mjs → main-BNJtzY61.mjs} +3 -3
- package/dist/main-BNJtzY61.mjs.map +1 -0
- package/dist/{main-B561M1d3.mjs → main-BOPMYqsW.mjs} +2 -2
- package/dist/{main-B561M1d3.mjs.map → main-BOPMYqsW.mjs.map} +1 -1
- package/dist/{main-CCfc45ev.mjs → main-CiigNpsP.mjs} +7 -4
- package/dist/main-CiigNpsP.mjs.map +1 -0
- package/dist/{main-Ce9dcrsg.mjs → main-DeNFQ-UL.mjs} +6 -3
- package/dist/{main-Ce9dcrsg.mjs.map → main-DeNFQ-UL.mjs.map} +1 -1
- package/dist/{main-B-jwm8ED.mjs → main-DmCPcxOc.mjs} +2 -2
- package/dist/{main-B-jwm8ED.mjs.map → main-DmCPcxOc.mjs.map} +1 -1
- package/dist/{main-C45e7grq.mjs → main-DvgJSm11.mjs} +2 -2
- package/dist/{main-C45e7grq.mjs.map → main-DvgJSm11.mjs.map} +1 -1
- package/dist/{src-C-PfEDMY.mjs → src-vG79L-8m.mjs} +57 -26
- package/dist/src-vG79L-8m.mjs.map +1 -0
- package/drizzle/0002_wide_colleen_wing.sql +2 -0
- package/drizzle/0003_skinny_daimon_hellstrom.sql +1 -0
- package/drizzle/0004_pretty_bruce_banner.sql +1 -0
- package/drizzle/meta/0002_snapshot.json +1460 -0
- package/drizzle/meta/0003_snapshot.json +1460 -0
- package/drizzle/meta/0004_snapshot.json +1470 -0
- package/drizzle/meta/_journal.json +21 -0
- package/package.json +3 -2
- package/src/client.ts +412 -0
- package/src/config/index.ts +107 -0
- package/src/contracts/common.ts +28 -0
- package/src/contracts/envelope.ts +31 -0
- package/src/contracts/events/notification-ai-pending.ts +18 -0
- package/src/contracts/events/notification-canceled.ts +7 -0
- package/src/contracts/events/notification-created.ts +14 -0
- package/src/contracts/events/notification-delivered.ts +17 -0
- package/src/contracts/events/notification-dispatched.ts +45 -0
- package/src/contracts/events/notification-enriched.ts +46 -0
- package/src/contracts/events/notification-failed.ts +19 -0
- package/src/contracts/events/notification-requested.ts +36 -0
- package/src/contracts/events/notification-scheduled.ts +9 -0
- package/src/contracts/events/notification-skipped.ts +9 -0
- package/src/contracts/helpers.ts +21 -0
- package/src/contracts/index.ts +46 -0
- package/src/contracts/metadata.ts +10 -0
- package/src/contracts/registry.ts +88 -0
- package/src/contracts/sdk.ts +242 -0
- package/src/contracts/streams.ts +62 -0
- package/src/db/index.ts +69 -0
- package/src/db/schema.ts +412 -0
- package/src/idempotency/index.ts +50 -0
- package/src/index.ts +19 -0
- package/src/logger/index.ts +60 -0
- package/src/metrics/index.ts +53 -0
- package/src/queue/index.ts +501 -0
- package/src/rate-limiter/index.ts +210 -0
- package/src/redis/index.ts +89 -0
- package/src/repositories/index.ts +1246 -0
- package/src/server.ts +277 -0
- package/src/services/ai/main.ts +404 -0
- package/src/services/api/handlers.ts +1734 -0
- package/src/services/api/http.ts +64 -0
- package/src/services/api/main.ts +693 -0
- package/src/services/api/router.ts +82 -0
- package/src/services/delivery/main.ts +842 -0
- package/src/services/delivery/throttle.ts +71 -0
- package/src/services/engine/main.ts +827 -0
- package/src/services/enricher/main.ts +594 -0
- package/src/services/events/main.ts +365 -0
- package/src/services/scheduler/main.ts +319 -0
- package/src/services/workflow/main.ts +627 -0
- package/src/shared/batch-processor.ts +67 -0
- package/src/shared/cache.ts +47 -0
- package/src/shared/circuit-breaker.ts +74 -0
- package/src/shared/dataloader.ts +41 -0
- package/src/shared/events.ts +3 -0
- package/src/shared/index.ts +39 -0
- package/src/shared/semaphore.ts +33 -0
- package/src/shared/utils.ts +64 -0
- package/src/templates/cache.ts +32 -0
- package/src/templates/index.ts +69 -0
- package/src/templates/render.ts +128 -0
- package/src/transport/index.ts +96 -0
- package/src/unsubscribe/index.ts +127 -0
- package/src/workers/health.ts +31 -0
- package/src/workers/index.ts +266 -0
- package/src/workflows/index.ts +2 -0
- package/src/workflows/registry.ts +21 -0
- package/src/workflows/sdk.ts +106 -0
- package/dist/main-CAH0_Q6d.mjs.map +0 -1
- package/dist/main-CCfc45ev.mjs.map +0 -1
- package/dist/src-C-PfEDMY.mjs.map +0 -1
|
@@ -0,0 +1,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
|
+
}
|