@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.
- package/LICENSE +93 -0
- package/README.md +18 -0
- package/package.json +58 -0
- package/src/app.ts +102 -0
- package/src/container.ts +172 -0
- package/src/env.ts +56 -0
- package/src/index.ts +114 -0
- package/src/journeys/define-journey.ts +188 -0
- package/src/journeys/journey-context.ts +179 -0
- package/src/journeys/registry-singleton.ts +21 -0
- package/src/journeys/registry.ts +53 -0
- package/src/lib/alerting.ts +205 -0
- package/src/lib/api-key-hash.ts +19 -0
- package/src/lib/auth.ts +39 -0
- package/src/lib/backfill.ts +84 -0
- package/src/lib/contacts.ts +68 -0
- package/src/lib/db.ts +13 -0
- package/src/lib/email-service-types.ts +115 -0
- package/src/lib/email-stats.ts +33 -0
- package/src/lib/email.ts +94 -0
- package/src/lib/enrollment-guards.ts +56 -0
- package/src/lib/hatchet.ts +20 -0
- package/src/lib/html.ts +25 -0
- package/src/lib/ingestion.ts +162 -0
- package/src/lib/logger.ts +32 -0
- package/src/lib/mailer.ts +266 -0
- package/src/lib/notifications.ts +61 -0
- package/src/lib/posthog.ts +19 -0
- package/src/lib/redis.ts +30 -0
- package/src/lib/schemas.ts +8 -0
- package/src/lib/tracked.ts +175 -0
- package/src/lib/tracking-event-names.ts +5 -0
- package/src/lib/tracking-events.ts +84 -0
- package/src/lib/tracking.ts +78 -0
- package/src/middleware/api-key.ts +129 -0
- package/src/middleware/audit.ts +47 -0
- package/src/middleware/auth.ts +24 -0
- package/src/middleware/error-handler.ts +22 -0
- package/src/middleware/rate-limit.ts +65 -0
- package/src/middleware/request-logger.ts +19 -0
- package/src/routes/admin/alerts.ts +347 -0
- package/src/routes/admin/api-keys.ts +211 -0
- package/src/routes/admin/audit-logs.ts +102 -0
- package/src/routes/admin/bulk.ts +503 -0
- package/src/routes/admin/contacts.ts +342 -0
- package/src/routes/admin/dlq.ts +202 -0
- package/src/routes/admin/emails.ts +269 -0
- package/src/routes/admin/events.ts +132 -0
- package/src/routes/admin/index.ts +36 -0
- package/src/routes/admin/journey-logs.ts +117 -0
- package/src/routes/admin/journeys.ts +677 -0
- package/src/routes/admin/metrics.ts +559 -0
- package/src/routes/admin/preferences.ts +165 -0
- package/src/routes/admin/timeline.ts +221 -0
- package/src/routes/email/index.ts +8 -0
- package/src/routes/email/preferences.ts +144 -0
- package/src/routes/email/unsubscribe.ts +161 -0
- package/src/routes/health.ts +131 -0
- package/src/routes/index.ts +32 -0
- package/src/routes/ingest.ts +71 -0
- package/src/routes/tracking/click.ts +103 -0
- package/src/routes/tracking/index.ts +9 -0
- package/src/routes/tracking/open.ts +71 -0
- package/src/routes/webhooks/index.ts +17 -0
- package/src/routes/webhooks/resend.ts +68 -0
- package/src/routes/webhooks/sources.ts +97 -0
- package/src/webhook-sources/define-webhook-source.ts +34 -0
- package/src/worker.ts +64 -0
- package/src/workflows/check-alerts.ts +24 -0
- package/src/workflows/import-contacts.ts +134 -0
- 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
|
+
}
|