@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,342 @@
1
+ import { contacts, emailPreferences } from "@hogsend/db";
2
+ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
3
+ import { and, count, desc, eq, isNull, sql } from "drizzle-orm";
4
+ import type { AppEnv } from "../../app.js";
5
+ import {
6
+ contactSearchFilter,
7
+ resolveContact,
8
+ serializePrefs,
9
+ } from "../../lib/contacts.js";
10
+
11
+ const contactSchema = z.object({
12
+ id: z.string(),
13
+ externalId: z.string(),
14
+ email: z.string().nullable(),
15
+ properties: z.record(z.string(), z.unknown()),
16
+ firstSeenAt: z.string(),
17
+ lastSeenAt: z.string(),
18
+ createdAt: z.string(),
19
+ updatedAt: z.string(),
20
+ });
21
+
22
+ const preferencesSchema = z
23
+ .object({
24
+ id: z.string(),
25
+ userId: z.string(),
26
+ email: z.string(),
27
+ unsubscribedAll: z.boolean(),
28
+ suppressed: z.boolean(),
29
+ bounceCount: z.number(),
30
+ categories: z.record(z.string(), z.boolean()),
31
+ })
32
+ .nullable();
33
+
34
+ const listRoute = createRoute({
35
+ method: "get",
36
+ path: "/",
37
+ tags: ["Admin"],
38
+ summary: "List contacts",
39
+ request: {
40
+ query: z.object({
41
+ limit: z.coerce.number().min(1).max(100).default(50),
42
+ offset: z.coerce.number().min(0).default(0),
43
+ search: z.string().optional(),
44
+ }),
45
+ },
46
+ responses: {
47
+ 200: {
48
+ content: {
49
+ "application/json": {
50
+ schema: z.object({
51
+ contacts: z.array(contactSchema),
52
+ total: z.number(),
53
+ limit: z.number(),
54
+ offset: z.number(),
55
+ }),
56
+ },
57
+ },
58
+ description: "Paginated contact list",
59
+ },
60
+ },
61
+ });
62
+
63
+ const getRoute = createRoute({
64
+ method: "get",
65
+ path: "/{id}",
66
+ tags: ["Admin"],
67
+ summary: "Get contact by ID or externalId",
68
+ request: {
69
+ params: z.object({ id: z.string() }),
70
+ },
71
+ responses: {
72
+ 200: {
73
+ content: {
74
+ "application/json": {
75
+ schema: z.object({
76
+ contact: contactSchema,
77
+ preferences: preferencesSchema,
78
+ }),
79
+ },
80
+ },
81
+ description: "Contact with preferences",
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
+ const createRoute_ = createRoute({
95
+ method: "post",
96
+ path: "/",
97
+ tags: ["Admin"],
98
+ summary: "Create a contact",
99
+ request: {
100
+ body: {
101
+ content: {
102
+ "application/json": {
103
+ schema: z.object({
104
+ externalId: z.string().min(1),
105
+ email: z.string().email().optional(),
106
+ properties: z.record(z.string(), z.unknown()).optional(),
107
+ }),
108
+ },
109
+ },
110
+ },
111
+ },
112
+ responses: {
113
+ 201: {
114
+ content: {
115
+ "application/json": {
116
+ schema: z.object({ contact: contactSchema }),
117
+ },
118
+ },
119
+ description: "Contact created",
120
+ },
121
+ 409: {
122
+ content: {
123
+ "application/json": {
124
+ schema: z.object({ error: z.string() }),
125
+ },
126
+ },
127
+ description: "Contact with this externalId already exists",
128
+ },
129
+ },
130
+ });
131
+
132
+ const updateRoute = createRoute({
133
+ method: "patch",
134
+ path: "/{id}",
135
+ tags: ["Admin"],
136
+ summary: "Update a contact",
137
+ request: {
138
+ params: z.object({ id: z.string() }),
139
+ body: {
140
+ content: {
141
+ "application/json": {
142
+ schema: z.object({
143
+ email: z.string().email().optional(),
144
+ properties: z.record(z.string(), z.unknown()).optional(),
145
+ }),
146
+ },
147
+ },
148
+ },
149
+ },
150
+ responses: {
151
+ 200: {
152
+ content: {
153
+ "application/json": {
154
+ schema: z.object({ contact: contactSchema }),
155
+ },
156
+ },
157
+ description: "Contact updated",
158
+ },
159
+ 404: {
160
+ content: {
161
+ "application/json": {
162
+ schema: z.object({ error: z.string() }),
163
+ },
164
+ },
165
+ description: "Contact not found",
166
+ },
167
+ },
168
+ });
169
+
170
+ const deleteRoute = createRoute({
171
+ method: "delete",
172
+ path: "/{id}",
173
+ tags: ["Admin"],
174
+ summary: "Delete a contact",
175
+ request: {
176
+ params: z.object({ id: z.string() }),
177
+ },
178
+ responses: {
179
+ 200: {
180
+ content: {
181
+ "application/json": {
182
+ schema: z.object({ deleted: z.boolean() }),
183
+ },
184
+ },
185
+ description: "Contact deleted",
186
+ },
187
+ 404: {
188
+ content: {
189
+ "application/json": {
190
+ schema: z.object({ error: z.string() }),
191
+ },
192
+ },
193
+ description: "Contact not found",
194
+ },
195
+ },
196
+ });
197
+
198
+ function serializeContact(row: typeof contacts.$inferSelect) {
199
+ return {
200
+ id: row.id,
201
+ externalId: row.externalId,
202
+ email: row.email,
203
+ properties: (row.properties ?? {}) as Record<string, unknown>,
204
+ firstSeenAt: row.firstSeenAt.toISOString(),
205
+ lastSeenAt: row.lastSeenAt.toISOString(),
206
+ createdAt: row.createdAt.toISOString(),
207
+ updatedAt: row.updatedAt.toISOString(),
208
+ };
209
+ }
210
+
211
+ export const contactsRouter = new OpenAPIHono<AppEnv>()
212
+ .openapi(listRoute, async (c) => {
213
+ const { db } = c.get("container");
214
+ const { limit, offset, search } = c.req.valid("query");
215
+
216
+ const searchFilter = search ? contactSearchFilter(search) : undefined;
217
+
218
+ const where = searchFilter
219
+ ? and(searchFilter, isNull(contacts.deletedAt))
220
+ : isNull(contacts.deletedAt);
221
+
222
+ const [rows, totalRows] = await Promise.all([
223
+ db
224
+ .select()
225
+ .from(contacts)
226
+ .where(where)
227
+ .orderBy(desc(contacts.lastSeenAt))
228
+ .limit(limit)
229
+ .offset(offset),
230
+ db.select({ count: count() }).from(contacts).where(where),
231
+ ]);
232
+
233
+ return c.json(
234
+ {
235
+ contacts: rows.map(serializeContact),
236
+ total: totalRows[0]?.count ?? 0,
237
+ limit,
238
+ offset,
239
+ },
240
+ 200,
241
+ );
242
+ })
243
+ .openapi(getRoute, async (c) => {
244
+ const { db } = c.get("container");
245
+ const { id } = c.req.valid("param");
246
+
247
+ const contact = await resolveContact({ db, id });
248
+ if (!contact) {
249
+ return c.json({ error: "Contact not found" }, 404);
250
+ }
251
+
252
+ const prefRows = await db
253
+ .select()
254
+ .from(emailPreferences)
255
+ .where(eq(emailPreferences.userId, contact.externalId))
256
+ .limit(1);
257
+
258
+ const prefs = prefRows[0] ? serializePrefs(prefRows[0]) : null;
259
+
260
+ return c.json(
261
+ { contact: serializeContact(contact), preferences: prefs },
262
+ 200,
263
+ );
264
+ })
265
+ .openapi(createRoute_, async (c) => {
266
+ const { db } = c.get("container");
267
+ const body = c.req.valid("json");
268
+
269
+ const existing = await db
270
+ .select({ id: contacts.id })
271
+ .from(contacts)
272
+ .where(eq(contacts.externalId, body.externalId))
273
+ .limit(1);
274
+
275
+ if (existing.length > 0) {
276
+ return c.json(
277
+ { error: "Contact with this externalId already exists" },
278
+ 409,
279
+ );
280
+ }
281
+
282
+ const [created] = await db
283
+ .insert(contacts)
284
+ .values({
285
+ externalId: body.externalId,
286
+ email: body.email ?? null,
287
+ properties: body.properties ?? {},
288
+ })
289
+ .returning();
290
+
291
+ if (!created) {
292
+ throw new Error("Failed to create contact");
293
+ }
294
+
295
+ return c.json({ contact: serializeContact(created) }, 201);
296
+ })
297
+ .openapi(updateRoute, async (c) => {
298
+ const { db } = c.get("container");
299
+ const { id } = c.req.valid("param");
300
+ const body = c.req.valid("json");
301
+
302
+ const current = await resolveContact({ db, id });
303
+ if (!current) {
304
+ return c.json({ error: "Contact not found" }, 404);
305
+ }
306
+
307
+ const [updated] = await db
308
+ .update(contacts)
309
+ .set({
310
+ ...(body.email !== undefined ? { email: body.email } : {}),
311
+ ...(body.properties
312
+ ? {
313
+ properties: sql`COALESCE(${contacts.properties}, '{}'::jsonb) || ${JSON.stringify(body.properties)}::jsonb`,
314
+ }
315
+ : {}),
316
+ updatedAt: new Date(),
317
+ })
318
+ .where(eq(contacts.id, current.id))
319
+ .returning();
320
+
321
+ if (!updated) {
322
+ throw new Error("Failed to update contact");
323
+ }
324
+
325
+ return c.json({ contact: serializeContact(updated) }, 200);
326
+ })
327
+ .openapi(deleteRoute, async (c) => {
328
+ const { db } = c.get("container");
329
+ const { id } = c.req.valid("param");
330
+
331
+ const contact = await resolveContact({ db, id });
332
+ if (!contact) {
333
+ return c.json({ error: "Contact not found" }, 404);
334
+ }
335
+
336
+ await db
337
+ .update(contacts)
338
+ .set({ deletedAt: new Date(), updatedAt: new Date() })
339
+ .where(eq(contacts.id, contact.id));
340
+
341
+ return c.json({ deleted: true }, 200);
342
+ });
@@ -0,0 +1,202 @@
1
+ import { deadLetterQueue } from "@hogsend/db";
2
+ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
3
+ import { and, count, desc, eq } from "drizzle-orm";
4
+ import type { AppEnv } from "../../app.js";
5
+ import { errorSchema } from "../../lib/schemas.js";
6
+
7
+ const dlqEntrySchema = z.object({
8
+ id: z.string(),
9
+ source: z.string(),
10
+ sourceId: z.string().nullable(),
11
+ payload: z.record(z.string(), z.unknown()),
12
+ error: z.string(),
13
+ retryCount: z.number(),
14
+ status: z.string(),
15
+ retriedAt: z.string().nullable(),
16
+ createdAt: z.string(),
17
+ });
18
+
19
+ const listRoute = createRoute({
20
+ method: "get",
21
+ path: "/",
22
+ tags: ["Admin — Dead Letter Queue"],
23
+ summary: "List dead letter queue entries",
24
+ request: {
25
+ query: z.object({
26
+ limit: z.coerce.number().min(1).max(100).default(50),
27
+ offset: z.coerce.number().min(0).default(0),
28
+ source: z.string().optional(),
29
+ status: z.enum(["pending", "retried", "discarded"]).optional(),
30
+ }),
31
+ },
32
+ responses: {
33
+ 200: {
34
+ content: {
35
+ "application/json": {
36
+ schema: z.object({
37
+ entries: z.array(dlqEntrySchema),
38
+ total: z.number(),
39
+ limit: z.number(),
40
+ offset: z.number(),
41
+ }),
42
+ },
43
+ },
44
+ description: "Paginated DLQ entries",
45
+ },
46
+ },
47
+ });
48
+
49
+ const retryRoute = createRoute({
50
+ method: "post",
51
+ path: "/{id}/retry",
52
+ tags: ["Admin — Dead Letter Queue"],
53
+ summary: "Retry a dead letter queue entry",
54
+ request: {
55
+ params: z.object({ id: z.string().uuid() }),
56
+ },
57
+ responses: {
58
+ 200: {
59
+ content: {
60
+ "application/json": {
61
+ schema: z.object({ retried: z.boolean() }),
62
+ },
63
+ },
64
+ description: "DLQ entry marked for retry",
65
+ },
66
+ 404: {
67
+ content: { "application/json": { schema: errorSchema } },
68
+ description: "DLQ entry not found",
69
+ },
70
+ 409: {
71
+ content: { "application/json": { schema: errorSchema } },
72
+ description: "DLQ entry not in pending state",
73
+ },
74
+ },
75
+ });
76
+
77
+ const discardRoute = createRoute({
78
+ method: "delete",
79
+ path: "/{id}",
80
+ tags: ["Admin — Dead Letter Queue"],
81
+ summary: "Discard a dead letter queue entry",
82
+ request: {
83
+ params: z.object({ id: z.string().uuid() }),
84
+ },
85
+ responses: {
86
+ 200: {
87
+ content: {
88
+ "application/json": {
89
+ schema: z.object({ discarded: z.boolean() }),
90
+ },
91
+ },
92
+ description: "DLQ entry discarded",
93
+ },
94
+ 404: {
95
+ content: { "application/json": { schema: errorSchema } },
96
+ description: "DLQ entry not found",
97
+ },
98
+ },
99
+ });
100
+
101
+ function serializeEntry(row: typeof deadLetterQueue.$inferSelect) {
102
+ return {
103
+ id: row.id,
104
+ source: row.source,
105
+ sourceId: row.sourceId,
106
+ payload: row.payload as Record<string, unknown>,
107
+ error: row.error,
108
+ retryCount: row.retryCount,
109
+ status: row.status,
110
+ retriedAt: row.retriedAt?.toISOString() ?? null,
111
+ createdAt: row.createdAt.toISOString(),
112
+ };
113
+ }
114
+
115
+ export const dlqRouter = new OpenAPIHono<AppEnv>()
116
+ .openapi(listRoute, async (c) => {
117
+ const { db } = c.get("container");
118
+ const { limit, offset, source, status } = c.req.valid("query");
119
+
120
+ const conditions = [];
121
+ if (source) {
122
+ conditions.push(eq(deadLetterQueue.source, source));
123
+ }
124
+ if (status) {
125
+ conditions.push(eq(deadLetterQueue.status, status));
126
+ }
127
+
128
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
129
+
130
+ const [rows, totalRows] = await Promise.all([
131
+ db
132
+ .select()
133
+ .from(deadLetterQueue)
134
+ .where(where)
135
+ .orderBy(desc(deadLetterQueue.createdAt))
136
+ .limit(limit)
137
+ .offset(offset),
138
+ db.select({ count: count() }).from(deadLetterQueue).where(where),
139
+ ]);
140
+
141
+ return c.json(
142
+ {
143
+ entries: rows.map(serializeEntry),
144
+ total: totalRows[0]?.count ?? 0,
145
+ limit,
146
+ offset,
147
+ },
148
+ 200,
149
+ );
150
+ })
151
+ .openapi(retryRoute, async (c) => {
152
+ const { db } = c.get("container");
153
+ const { id } = c.req.valid("param");
154
+
155
+ const rows = await db
156
+ .select()
157
+ .from(deadLetterQueue)
158
+ .where(eq(deadLetterQueue.id, id))
159
+ .limit(1);
160
+
161
+ const entry = rows[0];
162
+ if (!entry) {
163
+ return c.json({ error: "DLQ entry not found" }, 404);
164
+ }
165
+
166
+ if (entry.status !== "pending") {
167
+ return c.json({ error: "Entry is not in pending state" }, 409);
168
+ }
169
+
170
+ await db
171
+ .update(deadLetterQueue)
172
+ .set({
173
+ status: "retried",
174
+ retryCount: entry.retryCount + 1,
175
+ retriedAt: new Date(),
176
+ updatedAt: new Date(),
177
+ })
178
+ .where(eq(deadLetterQueue.id, id));
179
+
180
+ return c.json({ retried: true }, 200);
181
+ })
182
+ .openapi(discardRoute, async (c) => {
183
+ const { db } = c.get("container");
184
+ const { id } = c.req.valid("param");
185
+
186
+ const rows = await db
187
+ .select()
188
+ .from(deadLetterQueue)
189
+ .where(eq(deadLetterQueue.id, id))
190
+ .limit(1);
191
+
192
+ if (!rows[0]) {
193
+ return c.json({ error: "DLQ entry not found" }, 404);
194
+ }
195
+
196
+ await db
197
+ .update(deadLetterQueue)
198
+ .set({ status: "discarded", updatedAt: new Date() })
199
+ .where(eq(deadLetterQueue.id, id));
200
+
201
+ return c.json({ discarded: true }, 200);
202
+ });