@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,269 @@
1
+ import {
2
+ type Database,
3
+ emailSends,
4
+ journeyStates,
5
+ linkClicks,
6
+ trackedLinks,
7
+ } from "@hogsend/db";
8
+ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
9
+ import { and, count, desc, eq, gte, inArray, isNull, lte } from "drizzle-orm";
10
+ import type { AppEnv } from "../../app.js";
11
+
12
+ const emailSchema = z.object({
13
+ id: z.string(),
14
+ journeyStateId: z.string().nullable(),
15
+ templateKey: z.string().nullable(),
16
+ resendId: z.string().nullable(),
17
+ fromEmail: z.string(),
18
+ toEmail: z.string(),
19
+ subject: z.string(),
20
+ category: z.string().nullable(),
21
+ status: z.string(),
22
+ sentAt: z.string().nullable(),
23
+ deliveredAt: z.string().nullable(),
24
+ openedAt: z.string().nullable(),
25
+ clickedAt: z.string().nullable(),
26
+ bouncedAt: z.string().nullable(),
27
+ complainedAt: z.string().nullable(),
28
+ createdAt: z.string(),
29
+ updatedAt: z.string(),
30
+ });
31
+
32
+ const trackedLinkSchema = z.object({
33
+ id: z.string(),
34
+ originalUrl: z.string(),
35
+ clickCount: z.number(),
36
+ clicks: z.array(
37
+ z.object({
38
+ id: z.string(),
39
+ clickedAt: z.string(),
40
+ ipAddress: z.string().nullable(),
41
+ userAgent: z.string().nullable(),
42
+ }),
43
+ ),
44
+ });
45
+
46
+ const journeyContextSchema = z
47
+ .object({
48
+ journeyId: z.string(),
49
+ userId: z.string(),
50
+ status: z.string(),
51
+ currentNodeId: z.string(),
52
+ })
53
+ .nullable();
54
+
55
+ import { errorSchema } from "../../lib/schemas.js";
56
+
57
+ function serializeEmail(row: typeof emailSends.$inferSelect) {
58
+ return {
59
+ id: row.id,
60
+ journeyStateId: row.journeyStateId,
61
+ templateKey: row.templateKey,
62
+ resendId: row.resendId,
63
+ fromEmail: row.fromEmail,
64
+ toEmail: row.toEmail,
65
+ subject: row.subject,
66
+ category: row.category,
67
+ status: row.status,
68
+ sentAt: row.sentAt?.toISOString() ?? null,
69
+ deliveredAt: row.deliveredAt?.toISOString() ?? null,
70
+ openedAt: row.openedAt?.toISOString() ?? null,
71
+ clickedAt: row.clickedAt?.toISOString() ?? null,
72
+ bouncedAt: row.bouncedAt?.toISOString() ?? null,
73
+ complainedAt: row.complainedAt?.toISOString() ?? null,
74
+ createdAt: row.createdAt.toISOString(),
75
+ updatedAt: row.updatedAt.toISOString(),
76
+ };
77
+ }
78
+
79
+ const listRoute = createRoute({
80
+ method: "get",
81
+ path: "/",
82
+ tags: ["Admin — Emails"],
83
+ summary: "List email sends",
84
+ request: {
85
+ query: z.object({
86
+ limit: z.coerce.number().min(1).max(100).default(50),
87
+ offset: z.coerce.number().min(0).default(0),
88
+ toEmail: z.string().optional(),
89
+ templateKey: z.string().optional(),
90
+ status: z
91
+ .enum([
92
+ "queued",
93
+ "rendered",
94
+ "sent",
95
+ "delivered",
96
+ "opened",
97
+ "clicked",
98
+ "bounced",
99
+ "complained",
100
+ "failed",
101
+ ])
102
+ .optional(),
103
+ from: z.string().datetime().optional(),
104
+ to: z.string().datetime().optional(),
105
+ }),
106
+ },
107
+ responses: {
108
+ 200: {
109
+ content: {
110
+ "application/json": {
111
+ schema: z.object({
112
+ emails: z.array(emailSchema),
113
+ total: z.number(),
114
+ limit: z.number(),
115
+ offset: z.number(),
116
+ }),
117
+ },
118
+ },
119
+ description: "Paginated email send list",
120
+ },
121
+ },
122
+ });
123
+
124
+ const getRoute = createRoute({
125
+ method: "get",
126
+ path: "/{id}",
127
+ tags: ["Admin — Emails"],
128
+ summary: "Get email detail with delivery timeline and link clicks",
129
+ request: {
130
+ params: z.object({ id: z.string().uuid() }),
131
+ },
132
+ responses: {
133
+ 200: {
134
+ content: {
135
+ "application/json": {
136
+ schema: z.object({
137
+ email: emailSchema,
138
+ trackedLinks: z.array(trackedLinkSchema),
139
+ journeyContext: journeyContextSchema,
140
+ }),
141
+ },
142
+ },
143
+ description: "Email detail with tracked links and journey context",
144
+ },
145
+ 404: {
146
+ content: { "application/json": { schema: errorSchema } },
147
+ description: "Email not found",
148
+ },
149
+ },
150
+ });
151
+
152
+ async function fetchTrackedLinksWithClicks(db: Database, emailSendId: string) {
153
+ const links = await db
154
+ .select()
155
+ .from(trackedLinks)
156
+ .where(eq(trackedLinks.emailSendId, emailSendId))
157
+ .orderBy(trackedLinks.createdAt);
158
+
159
+ if (links.length === 0) return [];
160
+
161
+ const linkIds = links.map((l) => l.id);
162
+ const clicks = await db
163
+ .select()
164
+ .from(linkClicks)
165
+ .where(inArray(linkClicks.trackedLinkId, linkIds))
166
+ .orderBy(linkClicks.clickedAt);
167
+
168
+ const clicksByLink = new Map<string, (typeof clicks)[number][]>();
169
+ for (const click of clicks) {
170
+ const arr = clicksByLink.get(click.trackedLinkId) ?? [];
171
+ arr.push(click);
172
+ clicksByLink.set(click.trackedLinkId, arr);
173
+ }
174
+
175
+ return links.map((link) => ({
176
+ id: link.id,
177
+ originalUrl: link.originalUrl,
178
+ clickCount: link.clickCount,
179
+ clicks: (clicksByLink.get(link.id) ?? []).map((click) => ({
180
+ id: click.id,
181
+ clickedAt: click.clickedAt.toISOString(),
182
+ ipAddress: click.ipAddress,
183
+ userAgent: click.userAgent,
184
+ })),
185
+ }));
186
+ }
187
+
188
+ export const emailsRouter = new OpenAPIHono<AppEnv>()
189
+ .openapi(listRoute, async (c) => {
190
+ const { db } = c.get("container");
191
+ const { limit, offset, toEmail, templateKey, status, from, to } =
192
+ c.req.valid("query");
193
+
194
+ const conditions = [];
195
+ if (toEmail) conditions.push(eq(emailSends.toEmail, toEmail));
196
+ if (templateKey) conditions.push(eq(emailSends.templateKey, templateKey));
197
+ if (status) conditions.push(eq(emailSends.status, status));
198
+ if (from) conditions.push(gte(emailSends.createdAt, new Date(from)));
199
+ if (to) conditions.push(lte(emailSends.createdAt, new Date(to)));
200
+
201
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
202
+
203
+ const [rows, totalRows] = await Promise.all([
204
+ db
205
+ .select()
206
+ .from(emailSends)
207
+ .where(where)
208
+ .orderBy(desc(emailSends.createdAt))
209
+ .limit(limit)
210
+ .offset(offset),
211
+ db.select({ count: count() }).from(emailSends).where(where),
212
+ ]);
213
+
214
+ return c.json(
215
+ {
216
+ emails: rows.map(serializeEmail),
217
+ total: totalRows[0]?.count ?? 0,
218
+ limit,
219
+ offset,
220
+ },
221
+ 200,
222
+ );
223
+ })
224
+ .openapi(getRoute, async (c) => {
225
+ const { db } = c.get("container");
226
+ const { id } = c.req.valid("param");
227
+
228
+ const rows = await db
229
+ .select()
230
+ .from(emailSends)
231
+ .where(eq(emailSends.id, id))
232
+ .limit(1);
233
+
234
+ const row = rows[0];
235
+ if (!row) {
236
+ return c.json({ error: "Email not found" }, 404);
237
+ }
238
+
239
+ const [links, journeyContext] = await Promise.all([
240
+ fetchTrackedLinksWithClicks(db, id),
241
+ row.journeyStateId
242
+ ? db
243
+ .select({
244
+ journeyId: journeyStates.journeyId,
245
+ userId: journeyStates.userId,
246
+ status: journeyStates.status,
247
+ currentNodeId: journeyStates.currentNodeId,
248
+ })
249
+ .from(journeyStates)
250
+ .where(
251
+ and(
252
+ eq(journeyStates.id, row.journeyStateId),
253
+ isNull(journeyStates.deletedAt),
254
+ ),
255
+ )
256
+ .limit(1)
257
+ .then((rows) => rows[0] ?? null)
258
+ : Promise.resolve(null),
259
+ ]);
260
+
261
+ return c.json(
262
+ {
263
+ email: serializeEmail(row),
264
+ trackedLinks: links,
265
+ journeyContext,
266
+ },
267
+ 200,
268
+ );
269
+ });
@@ -0,0 +1,132 @@
1
+ import { userEvents } from "@hogsend/db";
2
+ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
3
+ import { and, count, desc, eq, gte, lte } from "drizzle-orm";
4
+ import type { AppEnv } from "../../app.js";
5
+
6
+ const eventSchema = z.object({
7
+ id: z.string(),
8
+ userId: z.string(),
9
+ event: z.string(),
10
+ properties: z.record(z.string(), z.unknown()).nullable(),
11
+ occurredAt: z.string(),
12
+ });
13
+
14
+ const errorSchema = z.object({ error: z.string() });
15
+
16
+ function serializeEvent(row: typeof userEvents.$inferSelect) {
17
+ return {
18
+ id: row.id,
19
+ userId: row.userId,
20
+ event: row.event,
21
+ properties: (row.properties ?? null) as Record<string, unknown> | null,
22
+ occurredAt: row.occurredAt.toISOString(),
23
+ };
24
+ }
25
+
26
+ const listRoute = createRoute({
27
+ method: "get",
28
+ path: "/",
29
+ tags: ["Admin — Events"],
30
+ summary: "List events",
31
+ request: {
32
+ query: z.object({
33
+ limit: z.coerce.number().min(1).max(100).default(50),
34
+ offset: z.coerce.number().min(0).default(0),
35
+ userId: z.string().optional(),
36
+ event: z.string().optional(),
37
+ from: z.string().datetime().optional(),
38
+ to: z.string().datetime().optional(),
39
+ }),
40
+ },
41
+ responses: {
42
+ 200: {
43
+ content: {
44
+ "application/json": {
45
+ schema: z.object({
46
+ events: z.array(eventSchema),
47
+ total: z.number(),
48
+ limit: z.number(),
49
+ offset: z.number(),
50
+ }),
51
+ },
52
+ },
53
+ description: "Paginated event list",
54
+ },
55
+ },
56
+ });
57
+
58
+ const getRoute = createRoute({
59
+ method: "get",
60
+ path: "/{id}",
61
+ tags: ["Admin — Events"],
62
+ summary: "Get event detail",
63
+ request: {
64
+ params: z.object({ id: z.string().uuid() }),
65
+ },
66
+ responses: {
67
+ 200: {
68
+ content: {
69
+ "application/json": {
70
+ schema: z.object({ event: eventSchema }),
71
+ },
72
+ },
73
+ description: "Event detail",
74
+ },
75
+ 404: {
76
+ content: { "application/json": { schema: errorSchema } },
77
+ description: "Event not found",
78
+ },
79
+ },
80
+ });
81
+
82
+ export const eventsRouter = new OpenAPIHono<AppEnv>()
83
+ .openapi(listRoute, async (c) => {
84
+ const { db } = c.get("container");
85
+ const { limit, offset, userId, event, from, to } = c.req.valid("query");
86
+
87
+ const conditions = [];
88
+ if (userId) conditions.push(eq(userEvents.userId, userId));
89
+ if (event) conditions.push(eq(userEvents.event, event));
90
+ if (from) conditions.push(gte(userEvents.occurredAt, new Date(from)));
91
+ if (to) conditions.push(lte(userEvents.occurredAt, new Date(to)));
92
+
93
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
94
+
95
+ const [rows, totalRows] = await Promise.all([
96
+ db
97
+ .select()
98
+ .from(userEvents)
99
+ .where(where)
100
+ .orderBy(desc(userEvents.occurredAt))
101
+ .limit(limit)
102
+ .offset(offset),
103
+ db.select({ count: count() }).from(userEvents).where(where),
104
+ ]);
105
+
106
+ return c.json(
107
+ {
108
+ events: rows.map(serializeEvent),
109
+ total: totalRows[0]?.count ?? 0,
110
+ limit,
111
+ offset,
112
+ },
113
+ 200,
114
+ );
115
+ })
116
+ .openapi(getRoute, async (c) => {
117
+ const { db } = c.get("container");
118
+ const { id } = c.req.valid("param");
119
+
120
+ const rows = await db
121
+ .select()
122
+ .from(userEvents)
123
+ .where(eq(userEvents.id, id))
124
+ .limit(1);
125
+
126
+ const row = rows[0];
127
+ if (!row) {
128
+ return c.json({ error: "Event not found" }, 404);
129
+ }
130
+
131
+ return c.json({ event: serializeEvent(row) }, 200);
132
+ });
@@ -0,0 +1,36 @@
1
+ import { OpenAPIHono } from "@hono/zod-openapi";
2
+ import type { AppEnv } from "../../app.js";
3
+ import { requireApiKey } from "../../middleware/api-key.js";
4
+ import { auditMiddleware } from "../../middleware/audit.js";
5
+ import { rateLimit } from "../../middleware/rate-limit.js";
6
+ import { alertsRouter } from "./alerts.js";
7
+ import { apiKeysRouter } from "./api-keys.js";
8
+ import { auditLogsRouter } from "./audit-logs.js";
9
+ import { bulkRouter } from "./bulk.js";
10
+ import { contactsRouter } from "./contacts.js";
11
+ import { dlqRouter } from "./dlq.js";
12
+ import { emailsRouter } from "./emails.js";
13
+ import { eventsRouter } from "./events.js";
14
+ import { journeyLogsRouter } from "./journey-logs.js";
15
+ import { journeysRouter } from "./journeys.js";
16
+ import { metricsRouter } from "./metrics.js";
17
+ import { preferencesRouter } from "./preferences.js";
18
+ import { timelineRouter } from "./timeline.js";
19
+
20
+ export const adminRouter = new OpenAPIHono<AppEnv>();
21
+ adminRouter.use("*", requireApiKey);
22
+ adminRouter.use("*", rateLimit);
23
+ adminRouter.use("*", auditMiddleware);
24
+ adminRouter.route("/", bulkRouter);
25
+ adminRouter.route("/contacts", contactsRouter);
26
+ adminRouter.route("/contacts", preferencesRouter);
27
+ adminRouter.route("/contacts", timelineRouter);
28
+ adminRouter.route("/journeys", journeysRouter);
29
+ adminRouter.route("/events", eventsRouter);
30
+ adminRouter.route("/emails", emailsRouter);
31
+ adminRouter.route("/journey-logs", journeyLogsRouter);
32
+ adminRouter.route("/metrics", metricsRouter);
33
+ adminRouter.route("/api-keys", apiKeysRouter);
34
+ adminRouter.route("/audit-logs", auditLogsRouter);
35
+ adminRouter.route("/alerts", alertsRouter);
36
+ adminRouter.route("/dlq", dlqRouter);
@@ -0,0 +1,117 @@
1
+ import { journeyLogs, journeyStates } from "@hogsend/db";
2
+ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
3
+ import { and, eq, isNull } from "drizzle-orm";
4
+ import type { AppEnv } from "../../app.js";
5
+
6
+ const stateSchema = z.object({
7
+ id: z.string(),
8
+ userId: z.string(),
9
+ userEmail: z.string(),
10
+ journeyId: z.string(),
11
+ currentNodeId: z.string(),
12
+ status: z.string(),
13
+ hatchetRunId: z.string().nullable(),
14
+ context: z.record(z.string(), z.unknown()),
15
+ errorMessage: z.string().nullable(),
16
+ entryCount: z.number(),
17
+ completedAt: z.string().nullable(),
18
+ exitedAt: z.string().nullable(),
19
+ createdAt: z.string(),
20
+ updatedAt: z.string(),
21
+ });
22
+
23
+ const logSchema = z.object({
24
+ id: z.string(),
25
+ fromNodeId: z.string().nullable(),
26
+ toNodeId: z.string().nullable(),
27
+ action: z.string(),
28
+ detail: z.record(z.string(), z.unknown()).nullable(),
29
+ createdAt: z.string(),
30
+ });
31
+
32
+ import { errorSchema } from "../../lib/schemas.js";
33
+
34
+ function serializeState(row: typeof journeyStates.$inferSelect) {
35
+ return {
36
+ ...row,
37
+ context: (row.context ?? {}) as Record<string, unknown>,
38
+ completedAt: row.completedAt?.toISOString() ?? null,
39
+ exitedAt: row.exitedAt?.toISOString() ?? null,
40
+ createdAt: row.createdAt.toISOString(),
41
+ updatedAt: row.updatedAt.toISOString(),
42
+ };
43
+ }
44
+
45
+ function serializeLog(row: typeof journeyLogs.$inferSelect) {
46
+ return {
47
+ id: row.id,
48
+ fromNodeId: row.fromNodeId,
49
+ toNodeId: row.toNodeId,
50
+ action: row.action,
51
+ detail: (row.detail ?? null) as Record<string, unknown> | null,
52
+ createdAt: row.createdAt.toISOString(),
53
+ };
54
+ }
55
+
56
+ const getRoute = createRoute({
57
+ method: "get",
58
+ path: "/{stateId}",
59
+ tags: ["Admin — Journey Logs"],
60
+ summary: "Get full log sequence for a journey instance",
61
+ request: {
62
+ params: z.object({ stateId: z.string().uuid() }),
63
+ },
64
+ responses: {
65
+ 200: {
66
+ content: {
67
+ "application/json": {
68
+ schema: z.object({
69
+ state: stateSchema,
70
+ logs: z.array(logSchema),
71
+ }),
72
+ },
73
+ },
74
+ description: "Journey instance with full log sequence",
75
+ },
76
+ 404: {
77
+ content: { "application/json": { schema: errorSchema } },
78
+ description: "Journey state not found",
79
+ },
80
+ },
81
+ });
82
+
83
+ export const journeyLogsRouter = new OpenAPIHono<AppEnv>().openapi(
84
+ getRoute,
85
+ async (c) => {
86
+ const { db } = c.get("container");
87
+ const { stateId } = c.req.valid("param");
88
+
89
+ const [stateRows, logs] = await Promise.all([
90
+ db
91
+ .select()
92
+ .from(journeyStates)
93
+ .where(
94
+ and(eq(journeyStates.id, stateId), isNull(journeyStates.deletedAt)),
95
+ )
96
+ .limit(1),
97
+ db
98
+ .select()
99
+ .from(journeyLogs)
100
+ .where(eq(journeyLogs.journeyStateId, stateId))
101
+ .orderBy(journeyLogs.createdAt),
102
+ ]);
103
+
104
+ const state = stateRows[0];
105
+ if (!state) {
106
+ return c.json({ error: "Journey state not found" }, 404);
107
+ }
108
+
109
+ return c.json(
110
+ {
111
+ state: serializeState(state),
112
+ logs: logs.map(serializeLog),
113
+ },
114
+ 200,
115
+ );
116
+ },
117
+ );