@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,677 @@
1
+ import {
2
+ type Database,
3
+ journeyConfigs,
4
+ journeyLogs,
5
+ journeyStates,
6
+ } from "@hogsend/db";
7
+ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
8
+ import { and, count, desc, eq, inArray, isNull } from "drizzle-orm";
9
+ import type { AppEnv } from "../../app.js";
10
+ import { ingestEvent } from "../../lib/ingestion.js";
11
+
12
+ const journeySchema = z.object({
13
+ id: z.string(),
14
+ name: z.string(),
15
+ description: z.string().optional(),
16
+ enabled: z.boolean(),
17
+ trigger: z.object({
18
+ event: z.string(),
19
+ }),
20
+ entryLimit: z.enum(["once", "once_per_period", "unlimited"]),
21
+ counts: z.object({
22
+ active: z.number(),
23
+ waiting: z.number(),
24
+ completed: z.number(),
25
+ failed: z.number(),
26
+ exited: z.number(),
27
+ }),
28
+ });
29
+
30
+ const stateSchema = z.object({
31
+ id: z.string(),
32
+ userId: z.string(),
33
+ userEmail: z.string(),
34
+ journeyId: z.string(),
35
+ currentNodeId: z.string(),
36
+ status: z.string(),
37
+ hatchetRunId: z.string().nullable(),
38
+ context: z.record(z.string(), z.unknown()),
39
+ errorMessage: z.string().nullable(),
40
+ entryCount: z.number(),
41
+ completedAt: z.string().nullable(),
42
+ exitedAt: z.string().nullable(),
43
+ createdAt: z.string(),
44
+ updatedAt: z.string(),
45
+ });
46
+
47
+ const errorSchema = z.object({ error: z.string() });
48
+
49
+ function serializeState(row: typeof journeyStates.$inferSelect) {
50
+ return {
51
+ ...row,
52
+ context: (row.context ?? {}) as Record<string, unknown>,
53
+ completedAt: row.completedAt?.toISOString() ?? null,
54
+ exitedAt: row.exitedAt?.toISOString() ?? null,
55
+ createdAt: row.createdAt.toISOString(),
56
+ updatedAt: row.updatedAt.toISOString(),
57
+ };
58
+ }
59
+
60
+ function serializeLog(row: typeof journeyLogs.$inferSelect) {
61
+ return {
62
+ id: row.id,
63
+ fromNodeId: row.fromNodeId,
64
+ toNodeId: row.toNodeId,
65
+ action: row.action,
66
+ detail: (row.detail ?? null) as Record<string, unknown> | null,
67
+ createdAt: row.createdAt.toISOString(),
68
+ };
69
+ }
70
+
71
+ async function fetchState(db: Database, journeyId: string, stateId: string) {
72
+ return db
73
+ .select()
74
+ .from(journeyStates)
75
+ .where(
76
+ and(
77
+ eq(journeyStates.id, stateId),
78
+ eq(journeyStates.journeyId, journeyId),
79
+ isNull(journeyStates.deletedAt),
80
+ ),
81
+ )
82
+ .limit(1)
83
+ .then((rows) => rows[0] ?? null);
84
+ }
85
+
86
+ const emptyCounts = {
87
+ active: 0,
88
+ waiting: 0,
89
+ completed: 0,
90
+ failed: 0,
91
+ exited: 0,
92
+ };
93
+
94
+ // --- Route definitions ---
95
+
96
+ const listRoute = createRoute({
97
+ method: "get",
98
+ path: "/",
99
+ tags: ["Admin — Journeys"],
100
+ summary: "List all journeys",
101
+ request: {
102
+ query: z.object({
103
+ limit: z.coerce.number().min(1).max(100).default(50),
104
+ offset: z.coerce.number().min(0).default(0),
105
+ enabled: z.enum(["true", "false"]).optional(),
106
+ }),
107
+ },
108
+ responses: {
109
+ 200: {
110
+ content: {
111
+ "application/json": {
112
+ schema: z.object({
113
+ journeys: z.array(journeySchema),
114
+ total: z.number(),
115
+ limit: z.number(),
116
+ offset: z.number(),
117
+ }),
118
+ },
119
+ },
120
+ description: "Paginated journey list",
121
+ },
122
+ },
123
+ });
124
+
125
+ const getRoute = createRoute({
126
+ method: "get",
127
+ path: "/{id}",
128
+ tags: ["Admin — Journeys"],
129
+ summary: "Get journey detail",
130
+ request: {
131
+ params: z.object({ id: z.string() }),
132
+ },
133
+ responses: {
134
+ 200: {
135
+ content: {
136
+ "application/json": {
137
+ schema: z.object({
138
+ journey: journeySchema.extend({
139
+ trigger: z.object({
140
+ event: z.string(),
141
+ where: z.array(z.record(z.string(), z.unknown())).optional(),
142
+ }),
143
+ exitOn: z
144
+ .array(
145
+ z.object({
146
+ event: z.string(),
147
+ where: z
148
+ .array(z.record(z.string(), z.unknown()))
149
+ .optional(),
150
+ }),
151
+ )
152
+ .optional(),
153
+ suppress: z.record(z.string(), z.number()),
154
+ recentStates: z.array(stateSchema),
155
+ }),
156
+ }),
157
+ },
158
+ },
159
+ description: "Journey detail with counts and recent states",
160
+ },
161
+ 404: {
162
+ content: { "application/json": { schema: errorSchema } },
163
+ description: "Journey not found",
164
+ },
165
+ },
166
+ });
167
+
168
+ const patchRoute = createRoute({
169
+ method: "patch",
170
+ path: "/{id}",
171
+ tags: ["Admin — Journeys"],
172
+ summary: "Enable or disable a journey",
173
+ request: {
174
+ params: z.object({ id: z.string() }),
175
+ body: {
176
+ content: {
177
+ "application/json": {
178
+ schema: z.object({ enabled: z.boolean() }),
179
+ },
180
+ },
181
+ },
182
+ },
183
+ responses: {
184
+ 200: {
185
+ content: {
186
+ "application/json": {
187
+ schema: z.object({
188
+ journey: z.object({
189
+ id: z.string(),
190
+ name: z.string(),
191
+ enabled: z.boolean(),
192
+ updatedAt: z.string(),
193
+ }),
194
+ }),
195
+ },
196
+ },
197
+ description: "Journey updated",
198
+ },
199
+ 404: {
200
+ content: { "application/json": { schema: errorSchema } },
201
+ description: "Journey not found",
202
+ },
203
+ },
204
+ });
205
+
206
+ const listStatesRoute = createRoute({
207
+ method: "get",
208
+ path: "/{id}/states",
209
+ tags: ["Admin — Journeys"],
210
+ summary: "List journey instances",
211
+ request: {
212
+ params: z.object({ id: z.string() }),
213
+ query: z.object({
214
+ limit: z.coerce.number().min(1).max(100).default(50),
215
+ offset: z.coerce.number().min(0).default(0),
216
+ status: z
217
+ .enum(["active", "waiting", "completed", "failed", "exited"])
218
+ .optional(),
219
+ userId: z.string().optional(),
220
+ }),
221
+ },
222
+ responses: {
223
+ 200: {
224
+ content: {
225
+ "application/json": {
226
+ schema: z.object({
227
+ states: z.array(stateSchema),
228
+ total: z.number(),
229
+ limit: z.number(),
230
+ offset: z.number(),
231
+ }),
232
+ },
233
+ },
234
+ description: "Paginated journey states",
235
+ },
236
+ 404: {
237
+ content: { "application/json": { schema: errorSchema } },
238
+ description: "Journey not found",
239
+ },
240
+ },
241
+ });
242
+
243
+ const getStateRoute = createRoute({
244
+ method: "get",
245
+ path: "/{id}/states/{stateId}",
246
+ tags: ["Admin — Journeys"],
247
+ summary: "Get journey instance detail with logs",
248
+ request: {
249
+ params: z.object({
250
+ id: z.string(),
251
+ stateId: z.string().uuid(),
252
+ }),
253
+ },
254
+ responses: {
255
+ 200: {
256
+ content: {
257
+ "application/json": {
258
+ schema: z.object({
259
+ state: stateSchema,
260
+ logs: z.array(
261
+ z.object({
262
+ id: z.string(),
263
+ fromNodeId: z.string().nullable(),
264
+ toNodeId: z.string().nullable(),
265
+ action: z.string(),
266
+ detail: z.record(z.string(), z.unknown()).nullable(),
267
+ createdAt: z.string(),
268
+ }),
269
+ ),
270
+ }),
271
+ },
272
+ },
273
+ description: "Journey instance with logs",
274
+ },
275
+ 404: {
276
+ content: { "application/json": { schema: errorSchema } },
277
+ description: "State not found",
278
+ },
279
+ },
280
+ });
281
+
282
+ const cancelStateRoute = createRoute({
283
+ method: "delete",
284
+ path: "/{id}/states/{stateId}",
285
+ tags: ["Admin — Journeys"],
286
+ summary: "Cancel a journey instance",
287
+ request: {
288
+ params: z.object({
289
+ id: z.string(),
290
+ stateId: z.string().uuid(),
291
+ }),
292
+ },
293
+ responses: {
294
+ 200: {
295
+ content: {
296
+ "application/json": {
297
+ schema: z.object({
298
+ state: z.object({
299
+ id: z.string(),
300
+ status: z.literal("exited"),
301
+ exitedAt: z.string(),
302
+ }),
303
+ hatchetCancelled: z.boolean(),
304
+ }),
305
+ },
306
+ },
307
+ description: "Journey instance cancelled",
308
+ },
309
+ 404: {
310
+ content: { "application/json": { schema: errorSchema } },
311
+ description: "State not found",
312
+ },
313
+ 409: {
314
+ content: { "application/json": { schema: errorSchema } },
315
+ description: "State already in terminal status",
316
+ },
317
+ },
318
+ });
319
+
320
+ const enrollRoute = createRoute({
321
+ method: "post",
322
+ path: "/{id}/enroll",
323
+ tags: ["Admin — Journeys"],
324
+ summary: "Manually enroll a user in a journey",
325
+ request: {
326
+ params: z.object({ id: z.string() }),
327
+ body: {
328
+ content: {
329
+ "application/json": {
330
+ schema: z.object({
331
+ userId: z.string().min(1),
332
+ userEmail: z.string().email(),
333
+ properties: z.record(z.string(), z.unknown()).optional(),
334
+ }),
335
+ },
336
+ },
337
+ },
338
+ },
339
+ responses: {
340
+ 202: {
341
+ content: {
342
+ "application/json": {
343
+ schema: z.object({
344
+ enrolled: z.boolean(),
345
+ event: z.string(),
346
+ userId: z.string(),
347
+ }),
348
+ },
349
+ },
350
+ description: "Enrollment event dispatched",
351
+ },
352
+ 404: {
353
+ content: { "application/json": { schema: errorSchema } },
354
+ description: "Journey not found",
355
+ },
356
+ },
357
+ });
358
+
359
+ // --- Handlers ---
360
+
361
+ export const journeysRouter = new OpenAPIHono<AppEnv>()
362
+ .openapi(listRoute, async (c) => {
363
+ const { db, registry } = c.get("container");
364
+ const { limit, offset, enabled } = c.req.valid("query");
365
+
366
+ const allJourneys = registry.getAll();
367
+
368
+ const journeyIds = allJourneys.map((j) => j.id);
369
+
370
+ const [configs, statusCounts] = await Promise.all([
371
+ journeyIds.length > 0
372
+ ? db
373
+ .select()
374
+ .from(journeyConfigs)
375
+ .where(inArray(journeyConfigs.journeyId, journeyIds))
376
+ : Promise.resolve([]),
377
+ journeyIds.length > 0
378
+ ? db
379
+ .select({
380
+ journeyId: journeyStates.journeyId,
381
+ status: journeyStates.status,
382
+ count: count(),
383
+ })
384
+ .from(journeyStates)
385
+ .where(
386
+ and(
387
+ inArray(journeyStates.journeyId, journeyIds),
388
+ isNull(journeyStates.deletedAt),
389
+ ),
390
+ )
391
+ .groupBy(journeyStates.journeyId, journeyStates.status)
392
+ : Promise.resolve([]),
393
+ ]);
394
+
395
+ const configMap = new Map(configs.map((c) => [c.journeyId, c.enabled]));
396
+ const countsMap = new Map<string, typeof emptyCounts>();
397
+ for (const row of statusCounts) {
398
+ const existing = countsMap.get(row.journeyId) ?? { ...emptyCounts };
399
+ existing[row.status as keyof typeof emptyCounts] = row.count;
400
+ countsMap.set(row.journeyId, existing);
401
+ }
402
+
403
+ const result = allJourneys.map((j) => {
404
+ const dbEnabled = configMap.get(j.id);
405
+ const effectiveEnabled = dbEnabled !== undefined ? dbEnabled : j.enabled;
406
+ return {
407
+ id: j.id,
408
+ name: j.name,
409
+ description: j.description,
410
+ enabled: effectiveEnabled,
411
+ trigger: { event: j.trigger.event },
412
+ entryLimit: j.entryLimit,
413
+ counts: countsMap.get(j.id) ?? { ...emptyCounts },
414
+ };
415
+ });
416
+
417
+ const filtered =
418
+ enabled !== undefined
419
+ ? result.filter((j) => j.enabled === (enabled === "true"))
420
+ : result;
421
+
422
+ const total = filtered.length;
423
+ const paged = filtered.slice(offset, offset + limit);
424
+
425
+ return c.json({ journeys: paged, total, limit, offset }, 200);
426
+ })
427
+ .openapi(getRoute, async (c) => {
428
+ const { db, registry } = c.get("container");
429
+ const { id } = c.req.valid("param");
430
+
431
+ const meta = registry.get(id);
432
+ if (!meta) {
433
+ return c.json({ error: "Journey not found" }, 404);
434
+ }
435
+
436
+ const [configs, statusCounts, recentRows] = await Promise.all([
437
+ db
438
+ .select()
439
+ .from(journeyConfigs)
440
+ .where(eq(journeyConfigs.journeyId, id))
441
+ .limit(1),
442
+ db
443
+ .select({
444
+ status: journeyStates.status,
445
+ count: count(),
446
+ })
447
+ .from(journeyStates)
448
+ .where(
449
+ and(eq(journeyStates.journeyId, id), isNull(journeyStates.deletedAt)),
450
+ )
451
+ .groupBy(journeyStates.status),
452
+ db
453
+ .select()
454
+ .from(journeyStates)
455
+ .where(
456
+ and(eq(journeyStates.journeyId, id), isNull(journeyStates.deletedAt)),
457
+ )
458
+ .orderBy(desc(journeyStates.updatedAt))
459
+ .limit(10),
460
+ ]);
461
+
462
+ const dbEnabled = configs[0]?.enabled;
463
+ const effectiveEnabled = dbEnabled !== undefined ? dbEnabled : meta.enabled;
464
+
465
+ const counts = { ...emptyCounts };
466
+ for (const row of statusCounts) {
467
+ counts[row.status as keyof typeof emptyCounts] = row.count;
468
+ }
469
+
470
+ return c.json(
471
+ {
472
+ journey: {
473
+ id: meta.id,
474
+ name: meta.name,
475
+ description: meta.description,
476
+ enabled: effectiveEnabled,
477
+ trigger: {
478
+ event: meta.trigger.event,
479
+ where: meta.trigger.where as Record<string, unknown>[] | undefined,
480
+ },
481
+ entryLimit: meta.entryLimit,
482
+ exitOn: meta.exitOn?.map((e) => ({
483
+ event: e.event,
484
+ where: e.where as Record<string, unknown>[] | undefined,
485
+ })),
486
+ suppress: meta.suppress as Record<string, number>,
487
+ counts,
488
+ recentStates: recentRows.map(serializeState),
489
+ },
490
+ },
491
+ 200,
492
+ );
493
+ })
494
+ .openapi(patchRoute, async (c) => {
495
+ const { db, registry } = c.get("container");
496
+ const { id } = c.req.valid("param");
497
+ const body = c.req.valid("json");
498
+
499
+ const meta = registry.get(id);
500
+ if (!meta) {
501
+ return c.json({ error: "Journey not found" }, 404);
502
+ }
503
+
504
+ const [config] = await db
505
+ .insert(journeyConfigs)
506
+ .values({ journeyId: id, enabled: body.enabled })
507
+ .onConflictDoUpdate({
508
+ target: [journeyConfigs.journeyId],
509
+ set: { enabled: body.enabled, updatedAt: new Date() },
510
+ })
511
+ .returning();
512
+
513
+ if (!config) {
514
+ throw new Error("Failed to upsert journey config");
515
+ }
516
+
517
+ return c.json(
518
+ {
519
+ journey: {
520
+ id: meta.id,
521
+ name: meta.name,
522
+ enabled: config.enabled,
523
+ updatedAt: config.updatedAt.toISOString(),
524
+ },
525
+ },
526
+ 200,
527
+ );
528
+ })
529
+ .openapi(listStatesRoute, async (c) => {
530
+ const { db, registry } = c.get("container");
531
+ const { id } = c.req.valid("param");
532
+ const { limit, offset, status, userId } = c.req.valid("query");
533
+
534
+ if (!registry.has(id)) {
535
+ return c.json({ error: "Journey not found" }, 404);
536
+ }
537
+
538
+ const conditions = [
539
+ eq(journeyStates.journeyId, id),
540
+ isNull(journeyStates.deletedAt),
541
+ ];
542
+ if (status) {
543
+ conditions.push(eq(journeyStates.status, status));
544
+ }
545
+ if (userId) {
546
+ conditions.push(eq(journeyStates.userId, userId));
547
+ }
548
+
549
+ const where = and(...conditions);
550
+
551
+ const [rows, totalRows] = await Promise.all([
552
+ db
553
+ .select()
554
+ .from(journeyStates)
555
+ .where(where)
556
+ .orderBy(desc(journeyStates.createdAt))
557
+ .limit(limit)
558
+ .offset(offset),
559
+ db.select({ count: count() }).from(journeyStates).where(where),
560
+ ]);
561
+
562
+ return c.json(
563
+ {
564
+ states: rows.map(serializeState),
565
+ total: totalRows[0]?.count ?? 0,
566
+ limit,
567
+ offset,
568
+ },
569
+ 200,
570
+ );
571
+ })
572
+ .openapi(getStateRoute, async (c) => {
573
+ const { db } = c.get("container");
574
+ const { id, stateId } = c.req.valid("param");
575
+
576
+ const state = await fetchState(db, id, stateId);
577
+ if (!state) {
578
+ return c.json({ error: "State not found" }, 404);
579
+ }
580
+
581
+ const logs = await db
582
+ .select()
583
+ .from(journeyLogs)
584
+ .where(eq(journeyLogs.journeyStateId, stateId))
585
+ .orderBy(journeyLogs.createdAt);
586
+
587
+ return c.json(
588
+ {
589
+ state: serializeState(state),
590
+ logs: logs.map(serializeLog),
591
+ },
592
+ 200,
593
+ );
594
+ })
595
+ .openapi(cancelStateRoute, async (c) => {
596
+ const { db, hatchet } = c.get("container");
597
+ const { id, stateId } = c.req.valid("param");
598
+
599
+ const state = await fetchState(db, id, stateId);
600
+ if (!state) {
601
+ return c.json({ error: "State not found" }, 404);
602
+ }
603
+
604
+ if (!["active", "waiting"].includes(state.status)) {
605
+ return c.json(
606
+ {
607
+ error: `Cannot cancel journey in '${state.status}' status`,
608
+ },
609
+ 409,
610
+ );
611
+ }
612
+
613
+ const exitedAt = new Date();
614
+
615
+ await db
616
+ .update(journeyStates)
617
+ .set({
618
+ status: "exited",
619
+ exitedAt,
620
+ updatedAt: exitedAt,
621
+ })
622
+ .where(eq(journeyStates.id, stateId));
623
+
624
+ let hatchetCancelled = false;
625
+ if (state.hatchetRunId) {
626
+ try {
627
+ await hatchet.runs.cancel({ ids: [state.hatchetRunId] });
628
+ hatchetCancelled = true;
629
+ } catch {
630
+ // Best-effort: run may have already finished
631
+ }
632
+ }
633
+
634
+ return c.json(
635
+ {
636
+ state: {
637
+ id: stateId,
638
+ status: "exited" as const,
639
+ exitedAt: exitedAt.toISOString(),
640
+ },
641
+ hatchetCancelled,
642
+ },
643
+ 200,
644
+ );
645
+ })
646
+ .openapi(enrollRoute, async (c) => {
647
+ const { db, registry, hatchet, logger } = c.get("container");
648
+ const { id } = c.req.valid("param");
649
+ const body = c.req.valid("json");
650
+
651
+ const meta = registry.get(id);
652
+ if (!meta) {
653
+ return c.json({ error: "Journey not found" }, 404);
654
+ }
655
+
656
+ await ingestEvent({
657
+ db,
658
+ registry,
659
+ hatchet,
660
+ logger,
661
+ event: {
662
+ event: meta.trigger.event,
663
+ userId: body.userId,
664
+ userEmail: body.userEmail,
665
+ properties: body.properties ?? {},
666
+ },
667
+ });
668
+
669
+ return c.json(
670
+ {
671
+ enrolled: true,
672
+ event: meta.trigger.event,
673
+ userId: body.userId,
674
+ },
675
+ 202,
676
+ );
677
+ });