@hogsend/engine 0.0.1

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 (71) hide show
  1. package/LICENSE +93 -0
  2. package/README.md +18 -0
  3. package/package.json +58 -0
  4. package/src/app.ts +102 -0
  5. package/src/container.ts +172 -0
  6. package/src/env.ts +56 -0
  7. package/src/index.ts +114 -0
  8. package/src/journeys/define-journey.ts +188 -0
  9. package/src/journeys/journey-context.ts +179 -0
  10. package/src/journeys/registry-singleton.ts +21 -0
  11. package/src/journeys/registry.ts +53 -0
  12. package/src/lib/alerting.ts +205 -0
  13. package/src/lib/api-key-hash.ts +19 -0
  14. package/src/lib/auth.ts +39 -0
  15. package/src/lib/backfill.ts +84 -0
  16. package/src/lib/contacts.ts +68 -0
  17. package/src/lib/db.ts +13 -0
  18. package/src/lib/email-service-types.ts +115 -0
  19. package/src/lib/email-stats.ts +33 -0
  20. package/src/lib/email.ts +94 -0
  21. package/src/lib/enrollment-guards.ts +56 -0
  22. package/src/lib/hatchet.ts +20 -0
  23. package/src/lib/html.ts +25 -0
  24. package/src/lib/ingestion.ts +162 -0
  25. package/src/lib/logger.ts +32 -0
  26. package/src/lib/mailer.ts +266 -0
  27. package/src/lib/notifications.ts +61 -0
  28. package/src/lib/posthog.ts +19 -0
  29. package/src/lib/redis.ts +30 -0
  30. package/src/lib/schemas.ts +8 -0
  31. package/src/lib/tracked.ts +175 -0
  32. package/src/lib/tracking-event-names.ts +5 -0
  33. package/src/lib/tracking-events.ts +84 -0
  34. package/src/lib/tracking.ts +78 -0
  35. package/src/middleware/api-key.ts +129 -0
  36. package/src/middleware/audit.ts +47 -0
  37. package/src/middleware/auth.ts +24 -0
  38. package/src/middleware/error-handler.ts +22 -0
  39. package/src/middleware/rate-limit.ts +65 -0
  40. package/src/middleware/request-logger.ts +19 -0
  41. package/src/routes/admin/alerts.ts +347 -0
  42. package/src/routes/admin/api-keys.ts +211 -0
  43. package/src/routes/admin/audit-logs.ts +102 -0
  44. package/src/routes/admin/bulk.ts +503 -0
  45. package/src/routes/admin/contacts.ts +342 -0
  46. package/src/routes/admin/dlq.ts +202 -0
  47. package/src/routes/admin/emails.ts +269 -0
  48. package/src/routes/admin/events.ts +132 -0
  49. package/src/routes/admin/index.ts +36 -0
  50. package/src/routes/admin/journey-logs.ts +117 -0
  51. package/src/routes/admin/journeys.ts +677 -0
  52. package/src/routes/admin/metrics.ts +559 -0
  53. package/src/routes/admin/preferences.ts +165 -0
  54. package/src/routes/admin/timeline.ts +221 -0
  55. package/src/routes/email/index.ts +8 -0
  56. package/src/routes/email/preferences.ts +144 -0
  57. package/src/routes/email/unsubscribe.ts +161 -0
  58. package/src/routes/health.ts +131 -0
  59. package/src/routes/index.ts +32 -0
  60. package/src/routes/ingest.ts +71 -0
  61. package/src/routes/tracking/click.ts +103 -0
  62. package/src/routes/tracking/index.ts +9 -0
  63. package/src/routes/tracking/open.ts +71 -0
  64. package/src/routes/webhooks/index.ts +17 -0
  65. package/src/routes/webhooks/resend.ts +68 -0
  66. package/src/routes/webhooks/sources.ts +97 -0
  67. package/src/webhook-sources/define-webhook-source.ts +34 -0
  68. package/src/worker.ts +64 -0
  69. package/src/workflows/check-alerts.ts +24 -0
  70. package/src/workflows/import-contacts.ts +134 -0
  71. package/src/workflows/send-email.ts +54 -0
@@ -0,0 +1,559 @@
1
+ import {
2
+ contacts,
3
+ emailPreferences,
4
+ emailSends,
5
+ journeyStates,
6
+ userEvents,
7
+ } from "@hogsend/db";
8
+ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
9
+ import {
10
+ and,
11
+ count,
12
+ eq,
13
+ gte,
14
+ inArray,
15
+ isNotNull,
16
+ isNull,
17
+ lte,
18
+ sql,
19
+ } from "drizzle-orm";
20
+ import type { AppEnv } from "../../app.js";
21
+ import { errorSchema } from "../../lib/schemas.js";
22
+
23
+ const TRUNC_SQL = {
24
+ hour: sql`'hour'`,
25
+ day: sql`'day'`,
26
+ week: sql`'week'`,
27
+ month: sql`'month'`,
28
+ } as const;
29
+
30
+ const overviewRoute = createRoute({
31
+ method: "get",
32
+ path: "/overview",
33
+ tags: ["Admin — Metrics"],
34
+ summary: "System-wide overview metrics",
35
+ responses: {
36
+ 200: {
37
+ content: {
38
+ "application/json": {
39
+ schema: z.object({
40
+ totalContacts: z.number(),
41
+ activeJourneys: z.number(),
42
+ emailsSent24h: z.number(),
43
+ emailsSent7d: z.number(),
44
+ emailsSent30d: z.number(),
45
+ bounceRate30d: z.number(),
46
+ unsubscribeRate: z.number(),
47
+ }),
48
+ },
49
+ },
50
+ description: "System-wide summary metrics",
51
+ },
52
+ },
53
+ });
54
+
55
+ const journeysMetricsRoute = createRoute({
56
+ method: "get",
57
+ path: "/journeys",
58
+ tags: ["Admin — Metrics"],
59
+ summary: "Per-journey performance metrics",
60
+ responses: {
61
+ 200: {
62
+ content: {
63
+ "application/json": {
64
+ schema: z.object({
65
+ journeys: z.array(
66
+ z.object({
67
+ journeyId: z.string(),
68
+ name: z.string(),
69
+ enrolled: z.number(),
70
+ completed: z.number(),
71
+ failed: z.number(),
72
+ exited: z.number(),
73
+ active: z.number(),
74
+ completionRate: z.number(),
75
+ avgDurationSecs: z.number().nullable(),
76
+ }),
77
+ ),
78
+ }),
79
+ },
80
+ },
81
+ description: "Per-journey metrics with completion rates",
82
+ },
83
+ },
84
+ });
85
+
86
+ const journeyFunnelRoute = createRoute({
87
+ method: "get",
88
+ path: "/journeys/{id}",
89
+ tags: ["Admin — Metrics"],
90
+ summary: "Single journey funnel metrics",
91
+ request: {
92
+ params: z.object({ id: z.string() }),
93
+ },
94
+ responses: {
95
+ 200: {
96
+ content: {
97
+ "application/json": {
98
+ schema: z.object({
99
+ journeyId: z.string(),
100
+ enrolled: z.number(),
101
+ emailSent: z.number(),
102
+ emailOpened: z.number(),
103
+ emailClicked: z.number(),
104
+ completed: z.number(),
105
+ failed: z.number(),
106
+ exited: z.number(),
107
+ }),
108
+ },
109
+ },
110
+ description: "Journey funnel with drop-off at each step",
111
+ },
112
+ 404: {
113
+ content: {
114
+ "application/json": { schema: errorSchema },
115
+ },
116
+ description: "Journey not found",
117
+ },
118
+ },
119
+ });
120
+
121
+ const emailMetricsRoute = createRoute({
122
+ method: "get",
123
+ path: "/emails",
124
+ tags: ["Admin — Metrics"],
125
+ summary: "Per-template email metrics",
126
+ responses: {
127
+ 200: {
128
+ content: {
129
+ "application/json": {
130
+ schema: z.object({
131
+ templates: z.array(
132
+ z.object({
133
+ templateKey: z.string(),
134
+ sent: z.number(),
135
+ delivered: z.number(),
136
+ opened: z.number(),
137
+ clicked: z.number(),
138
+ bounced: z.number(),
139
+ deliveryRate: z.number(),
140
+ openRate: z.number(),
141
+ clickRate: z.number(),
142
+ }),
143
+ ),
144
+ }),
145
+ },
146
+ },
147
+ description: "Per-template email performance metrics",
148
+ },
149
+ },
150
+ });
151
+
152
+ const deliverabilityRoute = createRoute({
153
+ method: "get",
154
+ path: "/emails/deliverability",
155
+ tags: ["Admin — Metrics"],
156
+ summary: "Email deliverability trends over time",
157
+ request: {
158
+ query: z.object({
159
+ period: z.enum(["day", "week", "month"]).default("day"),
160
+ from: z.string().datetime().optional(),
161
+ to: z.string().datetime().optional(),
162
+ }),
163
+ },
164
+ responses: {
165
+ 200: {
166
+ content: {
167
+ "application/json": {
168
+ schema: z.object({
169
+ points: z.array(
170
+ z.object({
171
+ date: z.string(),
172
+ total: z.number(),
173
+ delivered: z.number(),
174
+ bounced: z.number(),
175
+ complained: z.number(),
176
+ deliveryRate: z.number(),
177
+ }),
178
+ ),
179
+ }),
180
+ },
181
+ },
182
+ description: "Time-series deliverability data",
183
+ },
184
+ },
185
+ });
186
+
187
+ const eventVolumeRoute = createRoute({
188
+ method: "get",
189
+ path: "/events",
190
+ tags: ["Admin — Metrics"],
191
+ summary: "Event volume by name over time",
192
+ request: {
193
+ query: z.object({
194
+ granularity: z.enum(["hour", "day", "week"]).default("day"),
195
+ from: z.string().datetime().optional(),
196
+ to: z.string().datetime().optional(),
197
+ }),
198
+ },
199
+ responses: {
200
+ 200: {
201
+ content: {
202
+ "application/json": {
203
+ schema: z.object({
204
+ events: z.array(
205
+ z.object({
206
+ event: z.string(),
207
+ date: z.string(),
208
+ count: z.number(),
209
+ }),
210
+ ),
211
+ }),
212
+ },
213
+ },
214
+ description: "Event volume time-series data",
215
+ },
216
+ },
217
+ });
218
+
219
+ export const metricsRouter = new OpenAPIHono<AppEnv>()
220
+ .openapi(overviewRoute, async (c) => {
221
+ const { db } = c.get("container");
222
+
223
+ const now = new Date();
224
+ const h24 = new Date(now.getTime() - 24 * 60 * 60 * 1000);
225
+ const d7 = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
226
+ const d30 = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
227
+
228
+ const [
229
+ contactsTotal,
230
+ activeJourneys,
231
+ emails24h,
232
+ emails7d,
233
+ emails30d,
234
+ bounced30d,
235
+ totalPrefs,
236
+ unsubscribed,
237
+ ] = await Promise.all([
238
+ db
239
+ .select({ count: count() })
240
+ .from(contacts)
241
+ .where(isNull(contacts.deletedAt))
242
+ .then((r) => r[0]?.count ?? 0),
243
+ db
244
+ .select({ count: count() })
245
+ .from(journeyStates)
246
+ .where(
247
+ and(
248
+ inArray(journeyStates.status, ["active", "waiting"]),
249
+ isNull(journeyStates.deletedAt),
250
+ ),
251
+ )
252
+ .then((r) => r[0]?.count ?? 0),
253
+ db
254
+ .select({ count: count() })
255
+ .from(emailSends)
256
+ .where(and(isNotNull(emailSends.sentAt), gte(emailSends.sentAt, h24)))
257
+ .then((r) => r[0]?.count ?? 0),
258
+ db
259
+ .select({ count: count() })
260
+ .from(emailSends)
261
+ .where(and(isNotNull(emailSends.sentAt), gte(emailSends.sentAt, d7)))
262
+ .then((r) => r[0]?.count ?? 0),
263
+ db
264
+ .select({ count: count() })
265
+ .from(emailSends)
266
+ .where(and(isNotNull(emailSends.sentAt), gte(emailSends.sentAt, d30)))
267
+ .then((r) => r[0]?.count ?? 0),
268
+ db
269
+ .select({ count: count() })
270
+ .from(emailSends)
271
+ .where(
272
+ and(eq(emailSends.status, "bounced"), gte(emailSends.createdAt, d30)),
273
+ )
274
+ .then((r) => r[0]?.count ?? 0),
275
+ db
276
+ .select({ count: count() })
277
+ .from(emailPreferences)
278
+ .then((r) => r[0]?.count ?? 0),
279
+ db
280
+ .select({ count: count() })
281
+ .from(emailPreferences)
282
+ .where(eq(emailPreferences.unsubscribedAll, true))
283
+ .then((r) => r[0]?.count ?? 0),
284
+ ]);
285
+
286
+ const sent30d = emails30d;
287
+ const bounceRate30d = sent30d > 0 ? bounced30d / sent30d : 0;
288
+ const unsubscribeRate = totalPrefs > 0 ? unsubscribed / totalPrefs : 0;
289
+
290
+ return c.json(
291
+ {
292
+ totalContacts: contactsTotal,
293
+ activeJourneys,
294
+ emailsSent24h: emails24h,
295
+ emailsSent7d: emails7d,
296
+ emailsSent30d: sent30d,
297
+ bounceRate30d: Math.round(bounceRate30d * 10000) / 10000,
298
+ unsubscribeRate: Math.round(unsubscribeRate * 10000) / 10000,
299
+ },
300
+ 200,
301
+ );
302
+ })
303
+ .openapi(journeysMetricsRoute, async (c) => {
304
+ const { db, registry } = c.get("container");
305
+
306
+ const [statusCounts, durations] = await Promise.all([
307
+ db
308
+ .select({
309
+ journeyId: journeyStates.journeyId,
310
+ status: journeyStates.status,
311
+ count: count(),
312
+ })
313
+ .from(journeyStates)
314
+ .where(isNull(journeyStates.deletedAt))
315
+ .groupBy(journeyStates.journeyId, journeyStates.status),
316
+ db
317
+ .select({
318
+ journeyId: journeyStates.journeyId,
319
+ avgDuration: sql<number>`avg(extract(epoch from (coalesce(${journeyStates.completedAt}, ${journeyStates.exitedAt}) - ${journeyStates.createdAt})))`,
320
+ })
321
+ .from(journeyStates)
322
+ .where(
323
+ and(
324
+ inArray(journeyStates.status, ["completed", "exited"]),
325
+ isNull(journeyStates.deletedAt),
326
+ ),
327
+ )
328
+ .groupBy(journeyStates.journeyId),
329
+ ]);
330
+
331
+ const countMap = new Map<string, Record<string, number>>();
332
+ for (const row of statusCounts) {
333
+ const entry = countMap.get(row.journeyId) ?? {};
334
+ entry[row.status] = row.count;
335
+ countMap.set(row.journeyId, entry);
336
+ }
337
+
338
+ const durationMap = new Map<string, number>();
339
+ for (const row of durations) {
340
+ if (row.avgDuration != null) {
341
+ durationMap.set(row.journeyId, row.avgDuration);
342
+ }
343
+ }
344
+
345
+ const allJourneys = registry.getAll();
346
+ const journeys = allJourneys.map((j) => {
347
+ const counts = countMap.get(j.id) ?? {};
348
+ const enrolled =
349
+ (counts.active ?? 0) +
350
+ (counts.waiting ?? 0) +
351
+ (counts.completed ?? 0) +
352
+ (counts.failed ?? 0) +
353
+ (counts.exited ?? 0);
354
+ const completed = counts.completed ?? 0;
355
+ const completionRate = enrolled > 0 ? completed / enrolled : 0;
356
+
357
+ return {
358
+ journeyId: j.id,
359
+ name: j.name,
360
+ enrolled,
361
+ completed,
362
+ failed: counts.failed ?? 0,
363
+ exited: counts.exited ?? 0,
364
+ active: (counts.active ?? 0) + (counts.waiting ?? 0),
365
+ completionRate: Math.round(completionRate * 10000) / 10000,
366
+ avgDurationSecs: durationMap.has(j.id)
367
+ ? Math.round(durationMap.get(j.id) ?? 0)
368
+ : null,
369
+ };
370
+ });
371
+
372
+ return c.json({ journeys }, 200);
373
+ })
374
+ .openapi(journeyFunnelRoute, async (c) => {
375
+ const { db, registry } = c.get("container");
376
+ const { id } = c.req.valid("param");
377
+
378
+ const journey = registry.get(id);
379
+ if (!journey) {
380
+ return c.json({ error: "Journey not found" }, 404);
381
+ }
382
+
383
+ const [stateCounts, emailCounts] = await Promise.all([
384
+ db
385
+ .select({
386
+ status: journeyStates.status,
387
+ count: count(),
388
+ })
389
+ .from(journeyStates)
390
+ .where(
391
+ and(eq(journeyStates.journeyId, id), isNull(journeyStates.deletedAt)),
392
+ )
393
+ .groupBy(journeyStates.status),
394
+ db
395
+ .select({
396
+ sent: sql<number>`count(*) filter (where ${emailSends.sentAt} is not null)`,
397
+ opened: sql<number>`count(*) filter (where ${emailSends.openedAt} is not null)`,
398
+ clicked: sql<number>`count(*) filter (where ${emailSends.clickedAt} is not null)`,
399
+ })
400
+ .from(emailSends)
401
+ .innerJoin(
402
+ journeyStates,
403
+ eq(emailSends.journeyStateId, journeyStates.id),
404
+ )
405
+ .where(
406
+ and(eq(journeyStates.journeyId, id), isNull(journeyStates.deletedAt)),
407
+ ),
408
+ ]);
409
+
410
+ const counts: Record<string, number> = {};
411
+ for (const row of stateCounts) {
412
+ counts[row.status] = row.count;
413
+ }
414
+
415
+ const enrolled =
416
+ (counts.active ?? 0) +
417
+ (counts.waiting ?? 0) +
418
+ (counts.completed ?? 0) +
419
+ (counts.failed ?? 0) +
420
+ (counts.exited ?? 0);
421
+
422
+ const emails = emailCounts[0] ?? { sent: 0, opened: 0, clicked: 0 };
423
+
424
+ return c.json(
425
+ {
426
+ journeyId: id,
427
+ enrolled,
428
+ emailSent: Number(emails.sent),
429
+ emailOpened: Number(emails.opened),
430
+ emailClicked: Number(emails.clicked),
431
+ completed: counts.completed ?? 0,
432
+ failed: counts.failed ?? 0,
433
+ exited: counts.exited ?? 0,
434
+ },
435
+ 200,
436
+ );
437
+ })
438
+ .openapi(emailMetricsRoute, async (c) => {
439
+ const { db } = c.get("container");
440
+
441
+ const rows = await db
442
+ .select({
443
+ templateKey: emailSends.templateKey,
444
+ sent: sql<number>`count(*) filter (where ${emailSends.sentAt} is not null)`,
445
+ delivered: sql<number>`count(*) filter (where ${emailSends.status} = 'delivered' or ${emailSends.deliveredAt} is not null)`,
446
+ opened: sql<number>`count(*) filter (where ${emailSends.openedAt} is not null)`,
447
+ clicked: sql<number>`count(*) filter (where ${emailSends.clickedAt} is not null)`,
448
+ bounced: sql<number>`count(*) filter (where ${emailSends.status} = 'bounced')`,
449
+ })
450
+ .from(emailSends)
451
+ .where(isNotNull(emailSends.templateKey))
452
+ .groupBy(emailSends.templateKey);
453
+
454
+ const templates = rows.map((row) => {
455
+ const sent = Number(row.sent);
456
+ const delivered = Number(row.delivered);
457
+ const opened = Number(row.opened);
458
+ const clicked = Number(row.clicked);
459
+ return {
460
+ templateKey: row.templateKey ?? "",
461
+ sent,
462
+ delivered,
463
+ opened,
464
+ clicked,
465
+ bounced: Number(row.bounced),
466
+ deliveryRate:
467
+ sent > 0 ? Math.round((delivered / sent) * 10000) / 10000 : 0,
468
+ openRate:
469
+ delivered > 0 ? Math.round((opened / delivered) * 10000) / 10000 : 0,
470
+ clickRate:
471
+ opened > 0 ? Math.round((clicked / opened) * 10000) / 10000 : 0,
472
+ };
473
+ });
474
+
475
+ return c.json({ templates }, 200);
476
+ })
477
+ .openapi(deliverabilityRoute, async (c) => {
478
+ const { db } = c.get("container");
479
+ const { period, from, to } = c.req.valid("query");
480
+
481
+ const now = new Date();
482
+ const defaultFrom = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
483
+ const fromDate = from ? new Date(from) : defaultFrom;
484
+ const toDate = to ? new Date(to) : now;
485
+
486
+ const rows = await db
487
+ .select({
488
+ date: sql<string>`date_trunc(${TRUNC_SQL[period]}, ${emailSends.createdAt})::text`,
489
+ total: count(),
490
+ delivered: sql<number>`count(*) filter (where ${emailSends.status} = 'delivered' or ${emailSends.deliveredAt} is not null)`,
491
+ bounced: sql<number>`count(*) filter (where ${emailSends.status} = 'bounced')`,
492
+ complained: sql<number>`count(*) filter (where ${emailSends.status} = 'complained')`,
493
+ })
494
+ .from(emailSends)
495
+ .where(
496
+ and(
497
+ gte(emailSends.createdAt, fromDate),
498
+ lte(emailSends.createdAt, toDate),
499
+ ),
500
+ )
501
+ .groupBy(sql`date_trunc(${TRUNC_SQL[period]}, ${emailSends.createdAt})`)
502
+ .orderBy(sql`date_trunc(${TRUNC_SQL[period]}, ${emailSends.createdAt})`);
503
+
504
+ const points = rows.map((row) => {
505
+ const total = row.total;
506
+ const delivered = Number(row.delivered);
507
+ return {
508
+ date: row.date,
509
+ total,
510
+ delivered,
511
+ bounced: Number(row.bounced),
512
+ complained: Number(row.complained),
513
+ deliveryRate:
514
+ total > 0 ? Math.round((delivered / total) * 10000) / 10000 : 0,
515
+ };
516
+ });
517
+
518
+ return c.json({ points }, 200);
519
+ })
520
+ .openapi(eventVolumeRoute, async (c) => {
521
+ const { db } = c.get("container");
522
+ const { granularity, from, to } = c.req.valid("query");
523
+
524
+ const now = new Date();
525
+ const defaultFrom = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
526
+ const fromDate = from ? new Date(from) : defaultFrom;
527
+ const toDate = to ? new Date(to) : now;
528
+
529
+ const rows = await db
530
+ .select({
531
+ event: userEvents.event,
532
+ date: sql<string>`date_trunc(${TRUNC_SQL[granularity]}, ${userEvents.occurredAt})::text`,
533
+ count: count(),
534
+ })
535
+ .from(userEvents)
536
+ .where(
537
+ and(
538
+ gte(userEvents.occurredAt, fromDate),
539
+ lte(userEvents.occurredAt, toDate),
540
+ ),
541
+ )
542
+ .groupBy(
543
+ userEvents.event,
544
+ sql`date_trunc(${TRUNC_SQL[granularity]}, ${userEvents.occurredAt})`,
545
+ )
546
+ .orderBy(
547
+ sql`date_trunc(${TRUNC_SQL[granularity]}, ${userEvents.occurredAt})`,
548
+ userEvents.event,
549
+ )
550
+ .limit(10000);
551
+
552
+ const events = rows.map((row) => ({
553
+ event: row.event,
554
+ date: row.date,
555
+ count: row.count,
556
+ }));
557
+
558
+ return c.json({ events }, 200);
559
+ });
@@ -0,0 +1,165 @@
1
+ import { emailPreferences } from "@hogsend/db";
2
+ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
3
+ import { eq } from "drizzle-orm";
4
+ import type { AppEnv } from "../../app.js";
5
+ import { resolveContact, serializePrefs } from "../../lib/contacts.js";
6
+
7
+ const preferencesResponseSchema = z.object({
8
+ id: z.string(),
9
+ userId: z.string(),
10
+ email: z.string(),
11
+ unsubscribedAll: z.boolean(),
12
+ suppressed: z.boolean(),
13
+ bounceCount: z.number(),
14
+ categories: z.record(z.string(), z.boolean()),
15
+ suppressedAt: z.string().nullable(),
16
+ lastBounceAt: z.string().nullable(),
17
+ });
18
+
19
+ const getPrefsRoute = createRoute({
20
+ method: "get",
21
+ path: "/{contactId}/preferences",
22
+ tags: ["Admin"],
23
+ summary: "Get email preferences for a contact",
24
+ request: {
25
+ params: z.object({ contactId: z.string() }),
26
+ },
27
+ responses: {
28
+ 200: {
29
+ content: {
30
+ "application/json": {
31
+ schema: z.object({ preferences: preferencesResponseSchema }),
32
+ },
33
+ },
34
+ description: "Email preferences",
35
+ },
36
+ 404: {
37
+ content: {
38
+ "application/json": {
39
+ schema: z.object({ error: z.string() }),
40
+ },
41
+ },
42
+ description: "Contact or preferences not found",
43
+ },
44
+ },
45
+ });
46
+
47
+ const updatePrefsRoute = createRoute({
48
+ method: "put",
49
+ path: "/{contactId}/preferences",
50
+ tags: ["Admin"],
51
+ summary: "Update email preferences for a contact",
52
+ request: {
53
+ params: z.object({ contactId: z.string() }),
54
+ body: {
55
+ content: {
56
+ "application/json": {
57
+ schema: z.object({
58
+ unsubscribedAll: z.boolean().optional(),
59
+ suppressed: z.boolean().optional(),
60
+ categories: z.record(z.string(), z.boolean()).optional(),
61
+ }),
62
+ },
63
+ },
64
+ },
65
+ },
66
+ responses: {
67
+ 200: {
68
+ content: {
69
+ "application/json": {
70
+ schema: z.object({ preferences: preferencesResponseSchema }),
71
+ },
72
+ },
73
+ description: "Preferences updated",
74
+ },
75
+ 400: {
76
+ content: {
77
+ "application/json": {
78
+ schema: z.object({ error: z.string() }),
79
+ },
80
+ },
81
+ description: "Bad request",
82
+ },
83
+ 404: {
84
+ content: {
85
+ "application/json": {
86
+ schema: z.object({ error: z.string() }),
87
+ },
88
+ },
89
+ description: "Contact not found",
90
+ },
91
+ },
92
+ });
93
+
94
+ export const preferencesRouter = new OpenAPIHono<AppEnv>()
95
+ .openapi(getPrefsRoute, async (c) => {
96
+ const { db } = c.get("container");
97
+ const { contactId } = c.req.valid("param");
98
+
99
+ const contact = await resolveContact({ db, id: contactId });
100
+ if (!contact) {
101
+ return c.json({ error: "Contact not found" }, 404);
102
+ }
103
+
104
+ const rows = await db
105
+ .select()
106
+ .from(emailPreferences)
107
+ .where(eq(emailPreferences.userId, contact.externalId))
108
+ .limit(1);
109
+
110
+ if (rows.length === 0) {
111
+ return c.json({ error: "No preferences found for this contact" }, 404);
112
+ }
113
+
114
+ const prefs = rows[0] as typeof emailPreferences.$inferSelect;
115
+ return c.json({ preferences: serializePrefs(prefs) }, 200);
116
+ })
117
+ .openapi(updatePrefsRoute, async (c) => {
118
+ const { db } = c.get("container");
119
+ const { contactId } = c.req.valid("param");
120
+ const body = c.req.valid("json");
121
+
122
+ const contact = await resolveContact({ db, id: contactId });
123
+ if (!contact) {
124
+ return c.json({ error: "Contact not found" }, 404);
125
+ }
126
+
127
+ if (!contact.email) {
128
+ return c.json({ error: "Contact has no email address" }, 400);
129
+ }
130
+
131
+ const [upserted] = await db
132
+ .insert(emailPreferences)
133
+ .values({
134
+ userId: contact.externalId,
135
+ email: contact.email,
136
+ unsubscribedAll: body.unsubscribedAll ?? false,
137
+ suppressed: body.suppressed ?? false,
138
+ categories: body.categories ?? {},
139
+ })
140
+ .onConflictDoUpdate({
141
+ target: [emailPreferences.userId, emailPreferences.email],
142
+ set: {
143
+ ...(body.unsubscribedAll !== undefined
144
+ ? { unsubscribedAll: body.unsubscribedAll }
145
+ : {}),
146
+ ...(body.suppressed !== undefined
147
+ ? {
148
+ suppressed: body.suppressed,
149
+ suppressedAt: body.suppressed ? new Date() : null,
150
+ }
151
+ : {}),
152
+ ...(body.categories !== undefined
153
+ ? { categories: body.categories }
154
+ : {}),
155
+ updatedAt: new Date(),
156
+ },
157
+ })
158
+ .returning();
159
+
160
+ if (!upserted) {
161
+ throw new Error("Failed to upsert preferences");
162
+ }
163
+
164
+ return c.json({ preferences: serializePrefs(upserted) }, 200);
165
+ });