@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,221 @@
1
+ import { emailSends, journeyStates, userEvents } from "@hogsend/db";
2
+ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
3
+ import { and, count, desc, eq, isNull } from "drizzle-orm";
4
+ import type { AppEnv } from "../../app.js";
5
+ import { resolveContact } from "../../lib/contacts.js";
6
+
7
+ const timelineEntrySchema = z.object({
8
+ type: z.enum(["event", "journey", "email"]),
9
+ timestamp: z.string(),
10
+ data: z.record(z.string(), z.unknown()),
11
+ });
12
+
13
+ import { errorSchema } from "../../lib/schemas.js";
14
+
15
+ const listRoute = createRoute({
16
+ method: "get",
17
+ path: "/{id}/timeline",
18
+ tags: ["Admin — Timeline"],
19
+ summary: "Get contact activity timeline",
20
+ request: {
21
+ params: z.object({ id: z.string() }),
22
+ query: z.object({
23
+ limit: z.coerce.number().min(1).max(100).default(50),
24
+ offset: z.coerce.number().min(0).default(0),
25
+ type: z.enum(["event", "journey", "email"]).optional(),
26
+ }),
27
+ },
28
+ responses: {
29
+ 200: {
30
+ content: {
31
+ "application/json": {
32
+ schema: z.object({
33
+ timeline: z.array(timelineEntrySchema),
34
+ total: z.number(),
35
+ limit: z.number(),
36
+ offset: z.number(),
37
+ }),
38
+ },
39
+ },
40
+ description: "Chronological activity timeline",
41
+ },
42
+ 404: {
43
+ content: { "application/json": { schema: errorSchema } },
44
+ description: "Contact not found",
45
+ },
46
+ },
47
+ });
48
+
49
+ type TimelineEntry = {
50
+ type: "event" | "journey" | "email";
51
+ timestamp: string;
52
+ data: Record<string, unknown>;
53
+ };
54
+
55
+ export const timelineRouter = new OpenAPIHono<AppEnv>().openapi(
56
+ listRoute,
57
+ async (c) => {
58
+ const { db } = c.get("container");
59
+ const { id } = c.req.valid("param");
60
+ const { limit, offset, type } = c.req.valid("query");
61
+
62
+ const contact = await resolveContact({ db, id });
63
+ if (!contact) {
64
+ return c.json({ error: "Contact not found" }, 404);
65
+ }
66
+
67
+ const externalId = contact.externalId;
68
+ const entries: TimelineEntry[] = [];
69
+
70
+ const shouldFetch = (t: string) => !type || type === t;
71
+ const fetchLimit = limit + offset;
72
+
73
+ const [
74
+ eventRows,
75
+ journeyRows,
76
+ emailRows,
77
+ eventCount,
78
+ journeyCount,
79
+ emailCount,
80
+ ] = await Promise.all([
81
+ shouldFetch("event")
82
+ ? db
83
+ .select()
84
+ .from(userEvents)
85
+ .where(eq(userEvents.userId, externalId))
86
+ .orderBy(desc(userEvents.occurredAt))
87
+ .limit(fetchLimit)
88
+ : Promise.resolve([]),
89
+ shouldFetch("journey")
90
+ ? db
91
+ .select()
92
+ .from(journeyStates)
93
+ .where(
94
+ and(
95
+ eq(journeyStates.userId, externalId),
96
+ isNull(journeyStates.deletedAt),
97
+ ),
98
+ )
99
+ .orderBy(desc(journeyStates.createdAt))
100
+ .limit(fetchLimit)
101
+ : Promise.resolve([]),
102
+ shouldFetch("email")
103
+ ? db
104
+ .select({
105
+ id: emailSends.id,
106
+ templateKey: emailSends.templateKey,
107
+ subject: emailSends.subject,
108
+ status: emailSends.status,
109
+ toEmail: emailSends.toEmail,
110
+ fromEmail: emailSends.fromEmail,
111
+ sentAt: emailSends.sentAt,
112
+ deliveredAt: emailSends.deliveredAt,
113
+ openedAt: emailSends.openedAt,
114
+ createdAt: emailSends.createdAt,
115
+ })
116
+ .from(emailSends)
117
+ .innerJoin(
118
+ journeyStates,
119
+ eq(emailSends.journeyStateId, journeyStates.id),
120
+ )
121
+ .where(
122
+ and(
123
+ eq(journeyStates.userId, externalId),
124
+ isNull(journeyStates.deletedAt),
125
+ ),
126
+ )
127
+ .orderBy(desc(emailSends.createdAt))
128
+ .limit(fetchLimit)
129
+ : Promise.resolve([]),
130
+ shouldFetch("event")
131
+ ? db
132
+ .select({ count: count() })
133
+ .from(userEvents)
134
+ .where(eq(userEvents.userId, externalId))
135
+ .then((r) => r[0]?.count ?? 0)
136
+ : Promise.resolve(0),
137
+ shouldFetch("journey")
138
+ ? db
139
+ .select({ count: count() })
140
+ .from(journeyStates)
141
+ .where(
142
+ and(
143
+ eq(journeyStates.userId, externalId),
144
+ isNull(journeyStates.deletedAt),
145
+ ),
146
+ )
147
+ .then((r) => r[0]?.count ?? 0)
148
+ : Promise.resolve(0),
149
+ shouldFetch("email")
150
+ ? db
151
+ .select({ count: count() })
152
+ .from(emailSends)
153
+ .innerJoin(
154
+ journeyStates,
155
+ eq(emailSends.journeyStateId, journeyStates.id),
156
+ )
157
+ .where(
158
+ and(
159
+ eq(journeyStates.userId, externalId),
160
+ isNull(journeyStates.deletedAt),
161
+ ),
162
+ )
163
+ .then((r) => r[0]?.count ?? 0)
164
+ : Promise.resolve(0),
165
+ ]);
166
+
167
+ for (const row of eventRows) {
168
+ entries.push({
169
+ type: "event",
170
+ timestamp: row.occurredAt.toISOString(),
171
+ data: {
172
+ id: row.id,
173
+ event: row.event,
174
+ properties: row.properties ?? {},
175
+ },
176
+ });
177
+ }
178
+
179
+ for (const row of journeyRows) {
180
+ entries.push({
181
+ type: "journey",
182
+ timestamp: row.createdAt.toISOString(),
183
+ data: {
184
+ id: row.id,
185
+ journeyId: row.journeyId,
186
+ status: row.status,
187
+ currentNodeId: row.currentNodeId,
188
+ completedAt: row.completedAt?.toISOString() ?? null,
189
+ exitedAt: row.exitedAt?.toISOString() ?? null,
190
+ },
191
+ });
192
+ }
193
+
194
+ for (const row of emailRows) {
195
+ entries.push({
196
+ type: "email",
197
+ timestamp: row.createdAt.toISOString(),
198
+ data: {
199
+ id: row.id,
200
+ templateKey: row.templateKey,
201
+ subject: row.subject,
202
+ status: row.status,
203
+ toEmail: row.toEmail,
204
+ sentAt: row.sentAt?.toISOString() ?? null,
205
+ deliveredAt: row.deliveredAt?.toISOString() ?? null,
206
+ openedAt: row.openedAt?.toISOString() ?? null,
207
+ },
208
+ });
209
+ }
210
+
211
+ entries.sort(
212
+ (a, b) =>
213
+ new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
214
+ );
215
+
216
+ const total = eventCount + journeyCount + emailCount;
217
+ const paged = entries.slice(offset, offset + limit);
218
+
219
+ return c.json({ timeline: paged, total, limit, offset }, 200);
220
+ },
221
+ );
@@ -0,0 +1,8 @@
1
+ import { OpenAPIHono } from "@hono/zod-openapi";
2
+ import type { AppEnv } from "../../app.js";
3
+ import { preferencesRouter } from "./preferences.js";
4
+ import { unsubscribeRouter } from "./unsubscribe.js";
5
+
6
+ export const emailRouter = new OpenAPIHono<AppEnv>();
7
+ emailRouter.route("/", unsubscribeRouter);
8
+ emailRouter.route("/", preferencesRouter);
@@ -0,0 +1,144 @@
1
+ import { emailPreferences } from "@hogsend/db";
2
+ import type { UnsubscribeTokenPayload } from "@hogsend/email";
3
+ import {
4
+ generateUnsubscribeToken,
5
+ InvalidTokenError,
6
+ validateUnsubscribeToken,
7
+ } from "@hogsend/email";
8
+ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
9
+ import { and, eq } from "drizzle-orm";
10
+ import type { AppEnv } from "../../app.js";
11
+ import { htmlPage } from "../../lib/html.js";
12
+
13
+ const EMAIL_CATEGORIES = [
14
+ { id: "journey", label: "Journey & lifecycle emails" },
15
+ ] as const;
16
+
17
+ const preferencesRoute = createRoute({
18
+ method: "get",
19
+ path: "/preferences",
20
+ tags: ["Email"],
21
+ summary: "Email preference center",
22
+ request: {
23
+ query: z.object({
24
+ token: z.string().min(1),
25
+ }),
26
+ },
27
+ responses: {
28
+ 200: {
29
+ content: { "text/html": { schema: z.string() } },
30
+ description: "Preference center page",
31
+ },
32
+ 400: {
33
+ content: { "text/html": { schema: z.string() } },
34
+ description: "Invalid or expired token",
35
+ },
36
+ },
37
+ });
38
+
39
+ export const preferencesRouter = new OpenAPIHono<AppEnv>().openapi(
40
+ preferencesRoute,
41
+ async (c) => {
42
+ const { token } = c.req.valid("query");
43
+ const { env, db } = c.get("container");
44
+
45
+ let payload: UnsubscribeTokenPayload;
46
+ try {
47
+ payload = validateUnsubscribeToken({
48
+ token,
49
+ secret: env.BETTER_AUTH_SECRET,
50
+ });
51
+ } catch (err) {
52
+ const message =
53
+ err instanceof InvalidTokenError ? err.message : "Invalid token";
54
+ return c.html(
55
+ htmlPage({
56
+ title: "Invalid Link",
57
+ body: `<h1>This link is no longer valid</h1><p>${message}. Please check your email for a newer link.</p>`,
58
+ }),
59
+ 400,
60
+ );
61
+ }
62
+
63
+ const { externalId, email } = payload;
64
+
65
+ const rows = await db
66
+ .select()
67
+ .from(emailPreferences)
68
+ .where(
69
+ and(
70
+ eq(emailPreferences.userId, externalId),
71
+ eq(emailPreferences.email, email),
72
+ ),
73
+ )
74
+ .limit(1);
75
+
76
+ const prefs = rows[0];
77
+ const categories = (prefs?.categories ?? {}) as Record<string, boolean>;
78
+ const globalUnsub = prefs?.unsubscribedAll ?? false;
79
+
80
+ function makeActionUrl(
81
+ action: "unsubscribe" | "resubscribe",
82
+ category?: string,
83
+ ): string {
84
+ const actionToken = generateUnsubscribeToken({
85
+ secret: env.BETTER_AUTH_SECRET,
86
+ externalId,
87
+ email,
88
+ action,
89
+ category,
90
+ });
91
+ return `${env.API_PUBLIC_URL}/v1/email/unsubscribe?token=${encodeURIComponent(actionToken)}`;
92
+ }
93
+
94
+ let categoryRows = "";
95
+ for (const cat of EMAIL_CATEGORIES) {
96
+ const isSubscribed = categories[cat.id] !== false && !globalUnsub;
97
+ const statusClass = isSubscribed ? "subscribed" : "unsubscribed";
98
+ const statusText = isSubscribed ? "Subscribed" : "Unsubscribed";
99
+ const actionLabel = isSubscribed ? "Unsubscribe" : "Resubscribe";
100
+ const actionUrl = isSubscribed
101
+ ? makeActionUrl("unsubscribe", cat.id)
102
+ : makeActionUrl("resubscribe", cat.id);
103
+
104
+ categoryRows += `
105
+ <div class="pref-row">
106
+ <div>
107
+ <div class="pref-label">${cat.label}</div>
108
+ <div class="pref-status ${statusClass}">${statusText}</div>
109
+ </div>
110
+ <a href="${actionUrl}">${actionLabel}</a>
111
+ </div>`;
112
+ }
113
+
114
+ const globalStatusClass = globalUnsub ? "unsubscribed" : "subscribed";
115
+ const globalStatusText = globalUnsub
116
+ ? "Unsubscribed from all"
117
+ : "Receiving emails";
118
+ const globalActionLabel = globalUnsub
119
+ ? "Resubscribe to all"
120
+ : "Unsubscribe from all";
121
+ const globalActionUrl = globalUnsub
122
+ ? makeActionUrl("resubscribe")
123
+ : makeActionUrl("unsubscribe");
124
+
125
+ return c.html(
126
+ htmlPage({
127
+ title: "Email Preferences",
128
+ body: `<h1>Email Preferences</h1>
129
+ <p>Manage which emails you receive at <strong>${email}</strong>.</p>
130
+ ${categoryRows}
131
+ <div class="global-row">
132
+ <div class="pref-row" style="border-bottom: none;">
133
+ <div>
134
+ <div class="pref-label">All emails</div>
135
+ <div class="pref-status ${globalStatusClass}">${globalStatusText}</div>
136
+ </div>
137
+ <a href="${globalActionUrl}">${globalActionLabel}</a>
138
+ </div>
139
+ </div>`,
140
+ }),
141
+ 200,
142
+ );
143
+ },
144
+ );
@@ -0,0 +1,161 @@
1
+ import type { Database } from "@hogsend/db";
2
+ import { emailPreferences } from "@hogsend/db";
3
+ import type { UnsubscribeTokenPayload } from "@hogsend/email";
4
+ import {
5
+ generatePreferenceCenterUrl,
6
+ InvalidTokenError,
7
+ validateUnsubscribeToken,
8
+ } from "@hogsend/email";
9
+ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
10
+ import { sql } from "drizzle-orm";
11
+ import type { AppEnv } from "../../app.js";
12
+ import { htmlPage } from "../../lib/html.js";
13
+
14
+ const unsubscribeRoute = createRoute({
15
+ method: "get",
16
+ path: "/unsubscribe",
17
+ tags: ["Email"],
18
+ summary: "Unsubscribe from emails",
19
+ request: {
20
+ query: z.object({
21
+ token: z.string().min(1),
22
+ }),
23
+ },
24
+ responses: {
25
+ 200: {
26
+ content: { "text/html": { schema: z.string() } },
27
+ description: "Unsubscribe confirmation",
28
+ },
29
+ 400: {
30
+ content: { "text/html": { schema: z.string() } },
31
+ description: "Invalid or expired token",
32
+ },
33
+ },
34
+ });
35
+
36
+ async function upsertPreference(
37
+ db: Database,
38
+ externalId: string,
39
+ email: string,
40
+ update: {
41
+ unsubscribedAll?: boolean;
42
+ categoryKey?: string;
43
+ categoryValue?: boolean;
44
+ },
45
+ ) {
46
+ const setClause: Record<string, unknown> = { updatedAt: new Date() };
47
+
48
+ if (update.unsubscribedAll !== undefined) {
49
+ setClause.unsubscribedAll = update.unsubscribedAll;
50
+ }
51
+ if (update.categoryKey !== undefined) {
52
+ const jsonValue = update.categoryValue ? "true" : "false";
53
+ setClause.categories = sql`jsonb_set(COALESCE(${emailPreferences.categories}, '{}'::jsonb), ${`{${update.categoryKey}}`}, ${jsonValue}::jsonb)`;
54
+ }
55
+
56
+ await db
57
+ .insert(emailPreferences)
58
+ .values({
59
+ userId: externalId,
60
+ email,
61
+ ...(update.unsubscribedAll !== undefined
62
+ ? { unsubscribedAll: update.unsubscribedAll }
63
+ : {}),
64
+ ...(update.categoryKey !== undefined
65
+ ? {
66
+ categories: { [update.categoryKey]: update.categoryValue ?? false },
67
+ }
68
+ : {}),
69
+ })
70
+ .onConflictDoUpdate({
71
+ target: [emailPreferences.userId, emailPreferences.email],
72
+ set: setClause,
73
+ });
74
+ }
75
+
76
+ export const unsubscribeRouter = new OpenAPIHono<AppEnv>().openapi(
77
+ unsubscribeRoute,
78
+ async (c) => {
79
+ const { token } = c.req.valid("query");
80
+ const { env, db } = c.get("container");
81
+
82
+ let payload: UnsubscribeTokenPayload;
83
+ try {
84
+ payload = validateUnsubscribeToken({
85
+ token,
86
+ secret: env.BETTER_AUTH_SECRET,
87
+ });
88
+ } catch (err) {
89
+ const message =
90
+ err instanceof InvalidTokenError ? err.message : "Invalid token";
91
+ return c.html(
92
+ htmlPage({
93
+ title: "Invalid Link",
94
+ body: `<h1>This link is no longer valid</h1><p>${message}. Please check your email for a newer link.</p>`,
95
+ }),
96
+ 400,
97
+ );
98
+ }
99
+
100
+ const { externalId, email, category, action } = payload;
101
+
102
+ if (category && !/^[a-z0-9_-]+$/i.test(category)) {
103
+ return c.html(
104
+ htmlPage({
105
+ title: "Invalid Link",
106
+ body: "<h1>Invalid category</h1><p>This link is malformed.</p>",
107
+ }),
108
+ 400,
109
+ );
110
+ }
111
+
112
+ if (action === "resubscribe") {
113
+ await upsertPreference(
114
+ db,
115
+ externalId,
116
+ email,
117
+ category
118
+ ? {
119
+ categoryKey: category,
120
+ categoryValue: true,
121
+ unsubscribedAll: false,
122
+ }
123
+ : { unsubscribedAll: false },
124
+ );
125
+
126
+ return c.html(
127
+ htmlPage({
128
+ title: "Resubscribed",
129
+ body: `<h1>You're back!</h1><p>You've been resubscribed${category ? ` to <strong>${category}</strong> emails` : ""}.</p>`,
130
+ }),
131
+ 200,
132
+ );
133
+ }
134
+
135
+ await upsertPreference(
136
+ db,
137
+ externalId,
138
+ email,
139
+ category
140
+ ? { categoryKey: category, categoryValue: false }
141
+ : { unsubscribedAll: true },
142
+ );
143
+
144
+ const preferenceCenterUrl = generatePreferenceCenterUrl({
145
+ baseUrl: env.API_PUBLIC_URL,
146
+ secret: env.BETTER_AUTH_SECRET,
147
+ externalId,
148
+ email,
149
+ });
150
+
151
+ return c.html(
152
+ htmlPage({
153
+ title: "Unsubscribed",
154
+ body: `<h1>You've been unsubscribed</h1>
155
+ <p>You won't receive ${category ? `<strong>${category}</strong>` : "any more"} emails from us.</p>
156
+ <p><a href="${preferenceCenterUrl}">Manage your email preferences</a></p>`,
157
+ }),
158
+ 200,
159
+ );
160
+ },
161
+ );
@@ -0,0 +1,131 @@
1
+ import { getClientSchemaVersion, getEngineSchemaVersion } from "@hogsend/db";
2
+ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
3
+ import { sql } from "drizzle-orm";
4
+ import type { AppEnv } from "../app.js";
5
+ import { API_VERSION } from "../env.js";
6
+ import { getRedis } from "../lib/redis.js";
7
+
8
+ const componentSchema = z.object({
9
+ status: z.enum(["up", "down"]),
10
+ latencyMs: z.number().optional(),
11
+ });
12
+
13
+ // Per-track schema version block. Two tracks: `engine` (bundled @hogsend/db
14
+ // migrations) and `client` (the client repo's own migrations). See
15
+ // docs/UPGRADING.md "Two-track migrations".
16
+ const trackSchema = z.object({
17
+ applied: z.string().nullable(),
18
+ required: z.string().nullable(),
19
+ inSync: z.boolean(),
20
+ pending: z.array(z.string()),
21
+ });
22
+
23
+ const healthResponseSchema = z.object({
24
+ status: z.enum(["healthy", "degraded", "migration_pending"]),
25
+ uptime: z.number(),
26
+ timestamp: z.string(),
27
+ version: z.string(),
28
+ components: z.object({
29
+ database: componentSchema,
30
+ redis: componentSchema,
31
+ }),
32
+ schema: z.object({
33
+ engine: trackSchema,
34
+ client: trackSchema,
35
+ }),
36
+ });
37
+
38
+ const healthRoute = createRoute({
39
+ method: "get",
40
+ path: "/",
41
+ tags: ["Health"],
42
+ summary: "Health check with component status",
43
+ responses: {
44
+ 200: {
45
+ content: {
46
+ "application/json": { schema: healthResponseSchema },
47
+ },
48
+ description: "Service health status",
49
+ },
50
+ },
51
+ });
52
+
53
+ async function checkComponent(
54
+ fn: () => Promise<void>,
55
+ ): Promise<{ status: "up" | "down"; latencyMs: number }> {
56
+ const start = performance.now();
57
+ try {
58
+ await fn();
59
+ return {
60
+ status: "up",
61
+ latencyMs: Math.round(performance.now() - start),
62
+ };
63
+ } catch {
64
+ return {
65
+ status: "down",
66
+ latencyMs: Math.round(performance.now() - start),
67
+ };
68
+ }
69
+ }
70
+
71
+ export const healthRouter = new OpenAPIHono<AppEnv>().openapi(
72
+ healthRoute,
73
+ async (c) => {
74
+ const { db, clientJournal } = c.get("container");
75
+
76
+ const [dbCheck, redisCheck, engine, client] = await Promise.all([
77
+ checkComponent(async () => {
78
+ await db.execute(sql`SELECT 1`);
79
+ }),
80
+ checkComponent(async () => {
81
+ // Actively probe: getRedis() lazily creates + connects the client (with
82
+ // family:0 for Railway IPv6). The old getRedisIfConnected() only returned
83
+ // a client if something had ALREADY created one — which nothing does when
84
+ // PostHog is disabled — so redis always read "down" even though it was
85
+ // reachable. ioredis buffers the ping until connected (or rejects if the
86
+ // host is genuinely unreachable → a truthful "down").
87
+ await getRedis().ping();
88
+ }),
89
+ getEngineSchemaVersion(db),
90
+ getClientSchemaVersion(db, clientJournal ?? { entries: [] }),
91
+ ]);
92
+
93
+ // `migration_pending` if EITHER track is behind. The engine track also gates
94
+ // boot (fatal); the client track surfaces here non-fatally (client-owned).
95
+ const inSync = engine.inSync && client.inSync;
96
+ const allUp = dbCheck.status === "up" && redisCheck.status === "up";
97
+ const status = !inSync
98
+ ? ("migration_pending" as const)
99
+ : allUp
100
+ ? ("healthy" as const)
101
+ : ("degraded" as const);
102
+
103
+ return c.json(
104
+ {
105
+ status,
106
+ schema: {
107
+ engine: {
108
+ applied: engine.applied,
109
+ required: engine.required,
110
+ inSync: engine.inSync,
111
+ pending: engine.pending,
112
+ },
113
+ client: {
114
+ applied: client.applied,
115
+ required: client.required,
116
+ inSync: client.inSync,
117
+ pending: client.pending,
118
+ },
119
+ },
120
+ uptime: process.uptime(),
121
+ timestamp: new Date().toISOString(),
122
+ version: API_VERSION,
123
+ components: {
124
+ database: dbCheck,
125
+ redis: redisCheck,
126
+ },
127
+ },
128
+ 200,
129
+ );
130
+ },
131
+ );
@@ -0,0 +1,32 @@
1
+ import { OpenAPIHono } from "@hono/zod-openapi";
2
+ import type { AppEnv } from "../app.js";
3
+ import type { DefinedWebhookSource } from "../webhook-sources/define-webhook-source.js";
4
+ import { adminRouter } from "./admin/index.js";
5
+ import { emailRouter } from "./email/index.js";
6
+ import { healthRouter } from "./health.js";
7
+ import { ingestRouter } from "./ingest.js";
8
+ import { trackingRouter } from "./tracking/index.js";
9
+ import { registerWebhookRoutes } from "./webhooks/index.js";
10
+
11
+ export interface RegisterRoutesOptions {
12
+ webhookSources: DefinedWebhookSource[];
13
+ }
14
+
15
+ export function registerRoutes(
16
+ app: OpenAPIHono<AppEnv>,
17
+ opts: RegisterRoutesOptions,
18
+ ) {
19
+ const v1 = new OpenAPIHono<AppEnv>();
20
+
21
+ v1.route("/health", healthRouter);
22
+ v1.route("/ingest", ingestRouter);
23
+ v1.route("/email", emailRouter);
24
+ v1.route("/admin", adminRouter);
25
+ v1.route("/t", trackingRouter);
26
+
27
+ app.route("/v1", v1);
28
+
29
+ // Webhooks (built-in Resend + injected content sources) are registered on the
30
+ // app at absolute paths.
31
+ registerWebhookRoutes(app, { webhookSources: opts.webhookSources });
32
+ }