@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,503 @@
1
+ import { contacts, emailSends, importJobs, userEvents } from "@hogsend/db";
2
+ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
3
+ import { and, desc, eq, gte, inArray, isNull, lte } from "drizzle-orm";
4
+ import type { AppEnv } from "../../app.js";
5
+ import { contactSearchFilter } from "../../lib/contacts.js";
6
+ import { ingestEvent } from "../../lib/ingestion.js";
7
+ import { errorSchema } from "../../lib/schemas.js";
8
+ import { importContactsTask } from "../../workflows/import-contacts.js";
9
+
10
+ // --- Import ---
11
+
12
+ const importRoute = createRoute({
13
+ method: "post",
14
+ path: "/contacts/import",
15
+ tags: ["Admin — Bulk"],
16
+ summary: "Bulk import contacts",
17
+ request: {
18
+ body: {
19
+ content: {
20
+ "application/json": {
21
+ schema: z.object({
22
+ format: z.enum(["csv", "json"]),
23
+ data: z.string().min(1),
24
+ fileName: z.string().optional(),
25
+ }),
26
+ },
27
+ },
28
+ },
29
+ },
30
+ responses: {
31
+ 202: {
32
+ content: {
33
+ "application/json": {
34
+ schema: z.object({
35
+ jobId: z.string(),
36
+ status: z.string(),
37
+ }),
38
+ },
39
+ },
40
+ description: "Import job queued",
41
+ },
42
+ },
43
+ });
44
+
45
+ const importStatusRoute = createRoute({
46
+ method: "get",
47
+ path: "/contacts/import/{jobId}",
48
+ tags: ["Admin — Bulk"],
49
+ summary: "Get import job status",
50
+ request: {
51
+ params: z.object({ jobId: z.string().uuid() }),
52
+ },
53
+ responses: {
54
+ 200: {
55
+ content: {
56
+ "application/json": {
57
+ schema: z.object({
58
+ id: z.string(),
59
+ status: z.string(),
60
+ totalRows: z.number().nullable(),
61
+ processedRows: z.number(),
62
+ failedRows: z.number(),
63
+ errors: z
64
+ .array(z.object({ row: z.number(), error: z.string() }))
65
+ .nullable(),
66
+ createdAt: z.string(),
67
+ updatedAt: z.string(),
68
+ }),
69
+ },
70
+ },
71
+ description: "Import job details",
72
+ },
73
+ 404: {
74
+ content: { "application/json": { schema: errorSchema } },
75
+ description: "Import job not found",
76
+ },
77
+ },
78
+ });
79
+
80
+ // --- Export ---
81
+
82
+ const exportRoute = createRoute({
83
+ method: "get",
84
+ path: "/contacts/export",
85
+ tags: ["Admin — Bulk"],
86
+ summary: "Export contacts as CSV or JSON",
87
+ request: {
88
+ query: z.object({
89
+ format: z.enum(["csv", "json"]).default("json"),
90
+ search: z.string().optional(),
91
+ limit: z.coerce.number().min(1).max(100000).default(10000),
92
+ }),
93
+ },
94
+ responses: {
95
+ 200: {
96
+ content: {
97
+ "application/json": {
98
+ schema: z.object({
99
+ contacts: z.array(z.record(z.string(), z.unknown())),
100
+ }),
101
+ },
102
+ },
103
+ description: "Exported contacts",
104
+ },
105
+ },
106
+ });
107
+
108
+ // --- Replay ---
109
+
110
+ const replayRoute = createRoute({
111
+ method: "post",
112
+ path: "/events/replay",
113
+ tags: ["Admin — Bulk"],
114
+ summary: "Replay events through ingestion pipeline",
115
+ request: {
116
+ body: {
117
+ content: {
118
+ "application/json": {
119
+ schema: z.object({
120
+ eventIds: z.array(z.string().uuid()).optional(),
121
+ filter: z
122
+ .object({
123
+ event: z.string().optional(),
124
+ userId: z.string().optional(),
125
+ from: z.string().datetime().optional(),
126
+ to: z.string().datetime().optional(),
127
+ })
128
+ .optional(),
129
+ limit: z.number().min(1).max(1000).default(100),
130
+ }),
131
+ },
132
+ },
133
+ },
134
+ },
135
+ responses: {
136
+ 200: {
137
+ content: {
138
+ "application/json": {
139
+ schema: z.object({
140
+ replayed: z.number(),
141
+ errors: z.number(),
142
+ }),
143
+ },
144
+ },
145
+ description: "Replay results",
146
+ },
147
+ },
148
+ });
149
+
150
+ // --- Resend Email ---
151
+
152
+ const resendRoute = createRoute({
153
+ method: "post",
154
+ path: "/emails/{id}/resend",
155
+ tags: ["Admin — Bulk"],
156
+ summary: "Resend a failed email",
157
+ request: {
158
+ params: z.object({ id: z.string().uuid() }),
159
+ },
160
+ responses: {
161
+ 202: {
162
+ content: {
163
+ "application/json": {
164
+ schema: z.object({
165
+ emailId: z.string(),
166
+ status: z.string(),
167
+ }),
168
+ },
169
+ },
170
+ description: "Email resend queued",
171
+ },
172
+ 404: {
173
+ content: { "application/json": { schema: errorSchema } },
174
+ description: "Email not found",
175
+ },
176
+ 409: {
177
+ content: { "application/json": { schema: errorSchema } },
178
+ description: "Email not in a resendable state",
179
+ },
180
+ },
181
+ });
182
+
183
+ // --- Batch Enroll ---
184
+
185
+ const batchEnrollRoute = createRoute({
186
+ method: "post",
187
+ path: "/journeys/{id}/enroll/batch",
188
+ tags: ["Admin — Bulk"],
189
+ summary: "Batch enroll users into a journey",
190
+ request: {
191
+ params: z.object({ id: z.string() }),
192
+ body: {
193
+ content: {
194
+ "application/json": {
195
+ schema: z.object({
196
+ users: z
197
+ .array(
198
+ z.object({
199
+ userId: z.string().min(1),
200
+ userEmail: z.string().email(),
201
+ properties: z.record(z.string(), z.unknown()).optional(),
202
+ }),
203
+ )
204
+ .min(1)
205
+ .max(500),
206
+ }),
207
+ },
208
+ },
209
+ },
210
+ },
211
+ responses: {
212
+ 200: {
213
+ content: {
214
+ "application/json": {
215
+ schema: z.object({
216
+ enrolled: z.number(),
217
+ skipped: z.number(),
218
+ results: z.array(
219
+ z.object({
220
+ userId: z.string(),
221
+ enrolled: z.boolean(),
222
+ }),
223
+ ),
224
+ }),
225
+ },
226
+ },
227
+ description: "Batch enrollment results",
228
+ },
229
+ 404: {
230
+ content: { "application/json": { schema: errorSchema } },
231
+ description: "Journey not found",
232
+ },
233
+ },
234
+ });
235
+
236
+ export const bulkRouter = new OpenAPIHono<AppEnv>()
237
+ .openapi(importRoute, async (c) => {
238
+ const { db } = c.get("container");
239
+ const body = c.req.valid("json");
240
+
241
+ const [job] = await db
242
+ .insert(importJobs)
243
+ .values({
244
+ format: body.format,
245
+ fileName: body.fileName ?? null,
246
+ })
247
+ .returning();
248
+
249
+ if (!job) throw new Error("Failed to create import job");
250
+
251
+ await importContactsTask.run({
252
+ jobId: job.id,
253
+ data: body.data,
254
+ format: body.format,
255
+ });
256
+
257
+ return c.json({ jobId: job.id, status: "pending" }, 202);
258
+ })
259
+ .openapi(importStatusRoute, async (c) => {
260
+ const { db } = c.get("container");
261
+ const { jobId } = c.req.valid("param");
262
+
263
+ const rows = await db
264
+ .select()
265
+ .from(importJobs)
266
+ .where(eq(importJobs.id, jobId))
267
+ .limit(1);
268
+
269
+ const job = rows[0];
270
+ if (!job) {
271
+ return c.json({ error: "Import job not found" }, 404);
272
+ }
273
+
274
+ return c.json(
275
+ {
276
+ id: job.id,
277
+ status: job.status,
278
+ totalRows: job.totalRows,
279
+ processedRows: job.processedRows,
280
+ failedRows: job.failedRows,
281
+ errors: job.errors as Array<{ row: number; error: string }> | null,
282
+ createdAt: job.createdAt.toISOString(),
283
+ updatedAt: job.updatedAt.toISOString(),
284
+ },
285
+ 200,
286
+ );
287
+ })
288
+ .openapi(exportRoute, async (c) => {
289
+ const { db } = c.get("container");
290
+ const { format, search, limit } = c.req.valid("query");
291
+
292
+ const conditions = [isNull(contacts.deletedAt)];
293
+ if (search) {
294
+ const filter = contactSearchFilter(search);
295
+ if (filter) conditions.push(filter);
296
+ }
297
+
298
+ const where = and(...conditions);
299
+
300
+ const rows = await db
301
+ .select()
302
+ .from(contacts)
303
+ .where(where)
304
+ .orderBy(desc(contacts.createdAt))
305
+ .limit(limit);
306
+
307
+ if (format === "csv") {
308
+ const header = "externalId,email,properties,firstSeenAt,lastSeenAt";
309
+ const csvRows = rows.map(
310
+ (r) =>
311
+ `${r.externalId},${r.email ?? ""},${JSON.stringify(r.properties ?? {}).replace(/,/g, ";")},${r.firstSeenAt.toISOString()},${r.lastSeenAt.toISOString()}`,
312
+ );
313
+ const csv = [header, ...csvRows].join("\n");
314
+
315
+ return new Response(csv, {
316
+ status: 200,
317
+ headers: {
318
+ "Content-Type": "text/csv",
319
+ "Content-Disposition": 'attachment; filename="contacts.csv"',
320
+ },
321
+ }) as never;
322
+ }
323
+
324
+ return c.json(
325
+ {
326
+ contacts: rows.map((r) => ({
327
+ id: r.id,
328
+ externalId: r.externalId,
329
+ email: r.email,
330
+ properties: r.properties ?? {},
331
+ firstSeenAt: r.firstSeenAt.toISOString(),
332
+ lastSeenAt: r.lastSeenAt.toISOString(),
333
+ createdAt: r.createdAt.toISOString(),
334
+ })),
335
+ },
336
+ 200,
337
+ );
338
+ })
339
+ .openapi(replayRoute, async (c) => {
340
+ const { db, registry, hatchet, logger } = c.get("container");
341
+ const body = c.req.valid("json");
342
+
343
+ let events: Array<typeof userEvents.$inferSelect>;
344
+
345
+ if (body.eventIds?.length) {
346
+ events = await db
347
+ .select()
348
+ .from(userEvents)
349
+ .where(inArray(userEvents.id, body.eventIds));
350
+ } else {
351
+ const conditions = [];
352
+ if (body.filter?.event) {
353
+ conditions.push(eq(userEvents.event, body.filter.event));
354
+ }
355
+ if (body.filter?.userId) {
356
+ conditions.push(eq(userEvents.userId, body.filter.userId));
357
+ }
358
+ if (body.filter?.from) {
359
+ conditions.push(gte(userEvents.occurredAt, new Date(body.filter.from)));
360
+ }
361
+ if (body.filter?.to) {
362
+ conditions.push(lte(userEvents.occurredAt, new Date(body.filter.to)));
363
+ }
364
+
365
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
366
+
367
+ events = await db
368
+ .select()
369
+ .from(userEvents)
370
+ .where(where)
371
+ .orderBy(desc(userEvents.occurredAt))
372
+ .limit(body.limit ?? 100);
373
+ }
374
+
375
+ let replayed = 0;
376
+ let errors = 0;
377
+
378
+ const BATCH_SIZE = 25;
379
+ for (let i = 0; i < events.length; i += BATCH_SIZE) {
380
+ const batch = events.slice(i, i + BATCH_SIZE);
381
+ const results = await Promise.allSettled(
382
+ batch.map((event) =>
383
+ ingestEvent({
384
+ db,
385
+ registry,
386
+ hatchet,
387
+ logger,
388
+ event: {
389
+ event: event.event,
390
+ userId: event.userId,
391
+ userEmail: "",
392
+ properties: (event.properties as Record<string, unknown>) ?? {},
393
+ },
394
+ }),
395
+ ),
396
+ );
397
+ for (const result of results) {
398
+ if (result.status === "fulfilled") replayed++;
399
+ else errors++;
400
+ }
401
+ }
402
+
403
+ return c.json({ replayed, errors }, 200);
404
+ })
405
+ .openapi(resendRoute, async (c) => {
406
+ const { db } = c.get("container");
407
+ const { id } = c.req.valid("param");
408
+
409
+ const rows = await db
410
+ .select()
411
+ .from(emailSends)
412
+ .where(eq(emailSends.id, id))
413
+ .limit(1);
414
+
415
+ const email = rows[0];
416
+ if (!email) {
417
+ return c.json({ error: "Email not found" }, 404);
418
+ }
419
+
420
+ if (!["failed", "bounced"].includes(email.status)) {
421
+ return c.json({ error: "Email is not in a resendable state" }, 409);
422
+ }
423
+
424
+ if (!email.templateKey) {
425
+ return c.json(
426
+ { error: "Cannot resend: no template key to re-render from" },
427
+ 409,
428
+ );
429
+ }
430
+
431
+ const [newSend] = await db
432
+ .insert(emailSends)
433
+ .values({
434
+ journeyStateId: email.journeyStateId,
435
+ templateKey: email.templateKey,
436
+ fromEmail: email.fromEmail,
437
+ toEmail: email.toEmail,
438
+ subject: email.subject,
439
+ category: email.category,
440
+ status: "queued",
441
+ })
442
+ .returning();
443
+
444
+ if (!newSend) throw new Error("Failed to create email send");
445
+
446
+ const { sendEmailTask } = await import("../../workflows/send-email.js");
447
+ await sendEmailTask.run({
448
+ to: email.toEmail,
449
+ subject: email.subject,
450
+ html: "",
451
+ from: email.fromEmail,
452
+ });
453
+
454
+ return c.json({ emailId: newSend.id, status: "queued" }, 202);
455
+ })
456
+ .openapi(batchEnrollRoute, async (c) => {
457
+ const { db, registry, hatchet, logger } = c.get("container");
458
+ const { id } = c.req.valid("param");
459
+ const body = c.req.valid("json");
460
+
461
+ const journey = registry.get(id);
462
+ if (!journey) {
463
+ return c.json({ error: "Journey not found" }, 404);
464
+ }
465
+
466
+ const results: Array<{ userId: string; enrolled: boolean }> = [];
467
+ let enrolled = 0;
468
+ let skipped = 0;
469
+
470
+ const BATCH_SIZE = 25;
471
+ for (let i = 0; i < body.users.length; i += BATCH_SIZE) {
472
+ const batch = body.users.slice(i, i + BATCH_SIZE);
473
+ const settled = await Promise.allSettled(
474
+ batch.map((user) =>
475
+ ingestEvent({
476
+ db,
477
+ registry,
478
+ hatchet,
479
+ logger,
480
+ event: {
481
+ event: journey.trigger.event,
482
+ userId: user.userId,
483
+ userEmail: user.userEmail,
484
+ properties: user.properties ?? {},
485
+ },
486
+ }),
487
+ ),
488
+ );
489
+ for (const [j, result] of settled.entries()) {
490
+ const user = batch[j];
491
+ if (!user) continue;
492
+ if (result.status === "fulfilled") {
493
+ enrolled++;
494
+ results.push({ userId: user.userId, enrolled: true });
495
+ } else {
496
+ skipped++;
497
+ results.push({ userId: user.userId, enrolled: false });
498
+ }
499
+ }
500
+ }
501
+
502
+ return c.json({ enrolled, skipped, results }, 200);
503
+ });