@hogsend/engine 0.1.0 → 0.1.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/package.json +4 -4
- package/src/app.ts +37 -0
- package/src/container.ts +23 -1
- package/src/env.ts +4 -0
- package/src/index.ts +1 -0
- package/src/lib/auth.ts +8 -1
- package/src/lib/email-service-types.ts +6 -0
- package/src/lib/email.ts +2 -0
- package/src/lib/mailer.ts +9 -1
- package/src/lib/metrics-sql.ts +17 -0
- package/src/lib/studio.ts +105 -0
- package/src/lib/tracked.ts +4 -0
- package/src/middleware/rate-limit.ts +1 -1
- package/src/middleware/require-admin.ts +26 -0
- package/src/routes/admin/emails.ts +155 -9
- package/src/routes/admin/index.ts +8 -2
- package/src/routes/admin/metrics.ts +31 -23
- package/src/routes/admin/reporting.ts +369 -0
- package/src/routes/admin/suppressions.ts +99 -0
- package/src/routes/admin/templates.ts +298 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/engine",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -37,9 +37,9 @@
|
|
|
37
37
|
"zod": "^4.4.3",
|
|
38
38
|
"@hogsend/core": "^0.1.0",
|
|
39
39
|
"@hogsend/db": "^0.1.0",
|
|
40
|
-
"@hogsend/email": "^0.0
|
|
41
|
-
"@hogsend/plugin-posthog": "^0.0
|
|
42
|
-
"@hogsend/plugin-resend": "^0.0
|
|
40
|
+
"@hogsend/email": "^0.1.0",
|
|
41
|
+
"@hogsend/plugin-posthog": "^0.1.0",
|
|
42
|
+
"@hogsend/plugin-resend": "^0.1.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@types/node": "^22.15.3",
|
package/src/app.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { user } from "@hogsend/db";
|
|
1
2
|
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
3
|
import { apiReference } from "@scalar/hono-api-reference";
|
|
3
4
|
import { compress } from "hono/compress";
|
|
@@ -8,6 +9,7 @@ import type { ErrorHandler, MiddlewareHandler } from "hono/types";
|
|
|
8
9
|
import type { HogsendClient } from "./container.js";
|
|
9
10
|
import { API_VERSION } from "./env.js";
|
|
10
11
|
import type { Auth } from "./lib/auth.js";
|
|
12
|
+
import { mountStudio } from "./lib/studio.js";
|
|
11
13
|
import type { ApiKeyContext } from "./middleware/api-key.js";
|
|
12
14
|
import { errorHandler } from "./middleware/error-handler.js";
|
|
13
15
|
import { requestLogger } from "./middleware/request-logger.js";
|
|
@@ -64,13 +66,48 @@ export function createApp(
|
|
|
64
66
|
return c.json({ error: "Not Found" }, 404);
|
|
65
67
|
});
|
|
66
68
|
|
|
69
|
+
// Closed signup: the first user may register (first-load "create admin");
|
|
70
|
+
// once any user exists, sign-up is blocked. This is the security control that
|
|
71
|
+
// lets `requireAdmin` trust any authenticated session in a single-tenant app.
|
|
72
|
+
app.use("/api/auth/sign-up/*", async (c, next) => {
|
|
73
|
+
if (c.req.method === "POST") {
|
|
74
|
+
const { db } = c.get("container");
|
|
75
|
+
const existing = await db.select({ id: user.id }).from(user).limit(1);
|
|
76
|
+
if (existing.length > 0) {
|
|
77
|
+
return c.json(
|
|
78
|
+
{ error: "Sign-ups are closed. An admin already exists." },
|
|
79
|
+
403,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return next();
|
|
84
|
+
});
|
|
85
|
+
|
|
67
86
|
app.on(["POST", "GET"], "/api/auth/*", (c) => {
|
|
68
87
|
const { auth } = c.get("container");
|
|
69
88
|
return auth.handler(c.req.raw);
|
|
70
89
|
});
|
|
71
90
|
|
|
91
|
+
// Public bootstrap probe: tells the Studio whether to show the first-run
|
|
92
|
+
// "create admin" screen (no users yet) instead of the login screen.
|
|
93
|
+
app.get("/v1/auth/status", async (c) => {
|
|
94
|
+
const { db } = c.get("container");
|
|
95
|
+
const existing = await db.select({ id: user.id }).from(user).limit(1);
|
|
96
|
+
return c.json({ needsSetup: existing.length === 0 });
|
|
97
|
+
});
|
|
98
|
+
|
|
72
99
|
registerRoutes(app, { webhookSources: opts.webhookSources ?? [] });
|
|
73
100
|
|
|
101
|
+
// Serve the Studio SPA at /studio/* (static layer, no auth — the SPA gates
|
|
102
|
+
// itself via /v1/auth/status + login; data endpoints stay behind requireAdmin).
|
|
103
|
+
// No-op when no built dist is present, so an unbuilt studio never crashes boot.
|
|
104
|
+
const studio = mountStudio(app);
|
|
105
|
+
if (studio.mounted) {
|
|
106
|
+
container.logger.info(
|
|
107
|
+
`Studio mounted at /studio (dist: ${studio.distPath})`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
74
111
|
opts.routes?.(app);
|
|
75
112
|
|
|
76
113
|
if (container.env.NODE_ENV !== "production") {
|
package/src/container.ts
CHANGED
|
@@ -49,6 +49,13 @@ export interface HogsendClient {
|
|
|
49
49
|
auth: Auth;
|
|
50
50
|
email: Resend;
|
|
51
51
|
emailService: EmailService;
|
|
52
|
+
/**
|
|
53
|
+
* The app's template registry (key → component + subject + category +
|
|
54
|
+
* optional preview/examples). Same object threaded into the engine mailer;
|
|
55
|
+
* exposed here so admin preview/catalog routes can enumerate keys and render
|
|
56
|
+
* templates without going through a send. Empty when no templates are wired.
|
|
57
|
+
*/
|
|
58
|
+
templates: TemplateRegistry;
|
|
52
59
|
analytics?: PostHogService;
|
|
53
60
|
registry: JourneyRegistry;
|
|
54
61
|
hatchet: HatchetClient;
|
|
@@ -154,6 +161,18 @@ export function createHogsendClient(
|
|
|
154
161
|
db,
|
|
155
162
|
secret: env.BETTER_AUTH_SECRET,
|
|
156
163
|
baseURL: env.BETTER_AUTH_URL,
|
|
164
|
+
// Always trust the public API origin; add any explicitly configured ones
|
|
165
|
+
// (e.g. a remote Studio origin) on top. baseURL is trusted automatically.
|
|
166
|
+
trustedOrigins: Array.from(
|
|
167
|
+
new Set(
|
|
168
|
+
[
|
|
169
|
+
env.API_PUBLIC_URL,
|
|
170
|
+
...(env.BETTER_AUTH_TRUSTED_ORIGINS?.split(",") ?? []),
|
|
171
|
+
]
|
|
172
|
+
.map((o) => o.trim())
|
|
173
|
+
.filter(Boolean),
|
|
174
|
+
),
|
|
175
|
+
),
|
|
157
176
|
});
|
|
158
177
|
|
|
159
178
|
const email = createResendClient({ apiKey: env.RESEND_API_KEY });
|
|
@@ -183,12 +202,14 @@ export function createHogsendClient(
|
|
|
183
202
|
sendWindow: defaults.sendWindow,
|
|
184
203
|
});
|
|
185
204
|
|
|
205
|
+
const templates = opts.email?.templates ?? ({} as TemplateRegistry);
|
|
206
|
+
|
|
186
207
|
const emailService =
|
|
187
208
|
opts.overrides?.mailer ??
|
|
188
209
|
createTrackedMailer(
|
|
189
210
|
{
|
|
190
211
|
defaultFrom: env.RESEND_FROM_EMAIL,
|
|
191
|
-
templates
|
|
212
|
+
templates,
|
|
192
213
|
db,
|
|
193
214
|
webhookSecret: env.RESEND_WEBHOOK_SECRET,
|
|
194
215
|
bounceThreshold: 3,
|
|
@@ -216,6 +237,7 @@ export function createHogsendClient(
|
|
|
216
237
|
auth,
|
|
217
238
|
email,
|
|
218
239
|
emailService,
|
|
240
|
+
templates,
|
|
219
241
|
analytics,
|
|
220
242
|
registry,
|
|
221
243
|
hatchet: opts.overrides?.hatchet ?? hatchet,
|
package/src/env.ts
CHANGED
|
@@ -16,6 +16,10 @@ export const env = createEnv({
|
|
|
16
16
|
REDIS_URL: z.string().min(1).default("redis://localhost:6379"),
|
|
17
17
|
BETTER_AUTH_SECRET: z.string().min(1),
|
|
18
18
|
BETTER_AUTH_URL: z.string().url().default("http://localhost:3002"),
|
|
19
|
+
// Extra origins allowed to call the auth endpoints (beyond BETTER_AUTH_URL),
|
|
20
|
+
// comma-separated. Needed when the Studio is served from a different origin
|
|
21
|
+
// than the API — e.g. the `hogsend studio` CLI pointing at a remote instance.
|
|
22
|
+
BETTER_AUTH_TRUSTED_ORIGINS: z.string().optional(),
|
|
19
23
|
RESEND_API_KEY: z.string().min(1),
|
|
20
24
|
RESEND_FROM_EMAIL: z.string().email().default("noreply@hogsend.com"),
|
|
21
25
|
// Hatchet connection contract. The @hatchet-dev SDK also reads these straight
|
package/src/index.ts
CHANGED
|
@@ -86,6 +86,7 @@ export { createLogger, type Logger } from "./lib/logger.js";
|
|
|
86
86
|
export { createTrackedMailer } from "./lib/mailer.js";
|
|
87
87
|
export { getPostHog } from "./lib/posthog.js";
|
|
88
88
|
export { getRedisIfConnected } from "./lib/redis.js";
|
|
89
|
+
export { type MountStudioResult, mountStudio } from "./lib/studio.js";
|
|
89
90
|
export {
|
|
90
91
|
type ResolveTimezoneInput,
|
|
91
92
|
type ResolveTimezoneResult,
|
package/src/lib/auth.ts
CHANGED
|
@@ -8,12 +8,19 @@ export function createAuth(opts: {
|
|
|
8
8
|
db: Database;
|
|
9
9
|
secret: string;
|
|
10
10
|
baseURL: string;
|
|
11
|
+
/**
|
|
12
|
+
* Extra origins allowed to call auth endpoints, beyond `baseURL` (which is
|
|
13
|
+
* always trusted). Needed when the Studio is served from a different origin
|
|
14
|
+
* than the API (e.g. the `hogsend studio` CLI against a remote instance).
|
|
15
|
+
*/
|
|
16
|
+
trustedOrigins?: string[];
|
|
11
17
|
}) {
|
|
12
|
-
const { db, secret, baseURL } = opts;
|
|
18
|
+
const { db, secret, baseURL, trustedOrigins } = opts;
|
|
13
19
|
return betterAuth({
|
|
14
20
|
basePath: "/api/auth",
|
|
15
21
|
secret,
|
|
16
22
|
baseURL,
|
|
23
|
+
...(trustedOrigins && trustedOrigins.length > 0 ? { trustedOrigins } : {}),
|
|
17
24
|
database: drizzleAdapter(db, {
|
|
18
25
|
provider: "pg",
|
|
19
26
|
schema,
|
|
@@ -35,6 +35,9 @@ export interface SendTrackedEmailOptions<
|
|
|
35
35
|
to: string;
|
|
36
36
|
subject?: string;
|
|
37
37
|
journeyStateId?: string;
|
|
38
|
+
/** Denormalized recipient identity, persisted on the email_sends row for reporting. */
|
|
39
|
+
userId?: string;
|
|
40
|
+
userEmail?: string;
|
|
38
41
|
category?: string;
|
|
39
42
|
tags?: Array<{ name: string; value: string }>;
|
|
40
43
|
headers?: Record<string, string>;
|
|
@@ -108,6 +111,9 @@ export interface EmailServiceSendOptions<
|
|
|
108
111
|
from?: string;
|
|
109
112
|
subject?: string;
|
|
110
113
|
journeyStateId?: string;
|
|
114
|
+
/** Denormalized recipient identity, persisted on the email_sends row for reporting. */
|
|
115
|
+
userId?: string;
|
|
116
|
+
userEmail?: string;
|
|
111
117
|
category?: string;
|
|
112
118
|
tags?: Array<{ name: string; value: string }>;
|
|
113
119
|
headers?: Record<string, string>;
|
package/src/lib/email.ts
CHANGED
|
@@ -76,6 +76,8 @@ export async function sendEmail(
|
|
|
76
76
|
to: opts.to,
|
|
77
77
|
subject: opts.subject,
|
|
78
78
|
journeyStateId: opts.journeyStateId,
|
|
79
|
+
userId: opts.userId,
|
|
80
|
+
userEmail: opts.to,
|
|
79
81
|
category: "journey",
|
|
80
82
|
tags: [
|
|
81
83
|
{ name: "journeyId", value: opts.journeyName ?? opts.template },
|
package/src/lib/mailer.ts
CHANGED
|
@@ -91,6 +91,8 @@ export function createTrackedMailer(
|
|
|
91
91
|
to: options.to,
|
|
92
92
|
subject: options.subject,
|
|
93
93
|
journeyStateId: options.journeyStateId,
|
|
94
|
+
userId: options.userId,
|
|
95
|
+
userEmail: options.userEmail,
|
|
94
96
|
category: options.category,
|
|
95
97
|
tags: options.tags,
|
|
96
98
|
headers: options.headers,
|
|
@@ -190,7 +192,10 @@ export function createTrackedMailer(
|
|
|
190
192
|
await updateEmailStatus(event.type, event.data.email_id);
|
|
191
193
|
break;
|
|
192
194
|
case "email.bounced":
|
|
193
|
-
await updateEmailStatus(event.type, event.data.email_id
|
|
195
|
+
await updateEmailStatus(event.type, event.data.email_id, {
|
|
196
|
+
bounceType: event.data.bounce?.type,
|
|
197
|
+
bounceReason: event.data.bounce?.message,
|
|
198
|
+
});
|
|
194
199
|
await handleBounce(event.data.to);
|
|
195
200
|
break;
|
|
196
201
|
case "email.complained":
|
|
@@ -247,6 +252,7 @@ export function createTrackedMailer(
|
|
|
247
252
|
async function updateEmailStatus(
|
|
248
253
|
eventType: WebhookEventType,
|
|
249
254
|
resendId: string,
|
|
255
|
+
extra?: { bounceType?: string; bounceReason?: string },
|
|
250
256
|
): Promise<void> {
|
|
251
257
|
if (!db) return;
|
|
252
258
|
|
|
@@ -259,6 +265,8 @@ export function createTrackedMailer(
|
|
|
259
265
|
.set({
|
|
260
266
|
status: status as typeof emailSends.$inferSelect.status,
|
|
261
267
|
[timestampField]: new Date(),
|
|
268
|
+
...(extra?.bounceType ? { bounceType: extra.bounceType } : {}),
|
|
269
|
+
...(extra?.bounceReason ? { bounceReason: extra.bounceReason } : {}),
|
|
262
270
|
updatedAt: new Date(),
|
|
263
271
|
})
|
|
264
272
|
.where(eq(emailSends.resendId, resendId));
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
|
|
3
|
+
// Shared SQL building blocks for the admin metrics + reporting routers.
|
|
4
|
+
|
|
5
|
+
/** Guarded divide, rounded to 4 decimal places. Returns 0 when denom <= 0. */
|
|
6
|
+
export const rate = (num: number, denom: number) =>
|
|
7
|
+
denom > 0 ? Math.round((num / denom) * 10000) / 10000 : 0;
|
|
8
|
+
|
|
9
|
+
/** `date_trunc` granularity literals, keyed by the API's period/granularity enum. */
|
|
10
|
+
export const TRUNC_SQL = {
|
|
11
|
+
hour: sql`'hour'`,
|
|
12
|
+
day: sql`'day'`,
|
|
13
|
+
week: sql`'week'`,
|
|
14
|
+
month: sql`'month'`,
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
export type TruncPeriod = keyof typeof TRUNC_SQL;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
6
|
+
import type { OpenAPIHono } from "@hono/zod-openapi";
|
|
7
|
+
import type { AppEnv } from "../app.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Where the built Studio SPA lives. The Studio (`@hogsend/studio`) is a separate
|
|
11
|
+
* Vite package that builds to a static `dist/` under base `/studio/`. The engine
|
|
12
|
+
* serves that `dist/` as static files at `/studio/*` with an SPA fallback.
|
|
13
|
+
*
|
|
14
|
+
* The Studio is NOT a runtime dependency of the engine — it ships as a built
|
|
15
|
+
* artifact and is optional. Mounting is best-effort: if no `dist/` is found, the
|
|
16
|
+
* mount is silently skipped so an unbuilt / studio-less deploy never crashes.
|
|
17
|
+
*
|
|
18
|
+
* Resolution order:
|
|
19
|
+
* 1. `STUDIO_DIST_PATH` env var (explicit override; absolute or cwd-relative).
|
|
20
|
+
* 2. `require.resolve("@hogsend/studio/package.json")` → sibling `dist/`
|
|
21
|
+
* (works when the studio package is installed/linked alongside the engine).
|
|
22
|
+
* 3. Monorepo source layout: walk up from this file to `packages/studio/dist`.
|
|
23
|
+
* 4. cwd-relative `packages/studio/dist` (dogfood app run from repo root).
|
|
24
|
+
*/
|
|
25
|
+
function resolveStudioDist(): string | null {
|
|
26
|
+
const candidates: string[] = [];
|
|
27
|
+
|
|
28
|
+
const envPath = process.env.STUDIO_DIST_PATH;
|
|
29
|
+
if (envPath && envPath.length > 0) {
|
|
30
|
+
candidates.push(resolve(process.cwd(), envPath));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const require = createRequire(import.meta.url);
|
|
34
|
+
try {
|
|
35
|
+
const pkgJson = require.resolve("@hogsend/studio/package.json");
|
|
36
|
+
candidates.push(join(dirname(pkgJson), "dist"));
|
|
37
|
+
} catch {
|
|
38
|
+
// Not resolvable as a module — fall through to layout-based guesses.
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Monorepo source layout: this file is packages/engine/src/lib/studio.ts, so
|
|
42
|
+
// the studio dist is ../../../studio/dist relative to here.
|
|
43
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
44
|
+
candidates.push(resolve(here, "../../../studio/dist"));
|
|
45
|
+
|
|
46
|
+
// cwd fallbacks for a repo-root process (apps/api dogfood, tests).
|
|
47
|
+
candidates.push(resolve(process.cwd(), "packages/studio/dist"));
|
|
48
|
+
candidates.push(resolve(process.cwd(), "../../packages/studio/dist"));
|
|
49
|
+
|
|
50
|
+
for (const dir of candidates) {
|
|
51
|
+
if (existsSync(join(dir, "index.html"))) {
|
|
52
|
+
return dir;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface MountStudioResult {
|
|
59
|
+
/** True when the SPA was mounted, false when no built dist was found. */
|
|
60
|
+
mounted: boolean;
|
|
61
|
+
/** Absolute path to the served dist directory, when mounted. */
|
|
62
|
+
distPath?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Mount the Studio SPA at `/studio/*` as static files, with an SPA fallback to
|
|
67
|
+
* `index.html` for client-side routes.
|
|
68
|
+
*
|
|
69
|
+
* IMPORTANT: this is intentionally OUTSIDE the `/v1/admin` auth guard at the
|
|
70
|
+
* static layer. The SPA itself gates access via `/v1/auth/status` + login; the
|
|
71
|
+
* actual data endpoints under `/v1/admin/*` stay protected by `requireAdmin`.
|
|
72
|
+
*
|
|
73
|
+
* No-op (returns `{ mounted: false }`) when no built `dist/` is found, so an
|
|
74
|
+
* unbuilt studio never crashes the server.
|
|
75
|
+
*/
|
|
76
|
+
export function mountStudio(app: OpenAPIHono<AppEnv>): MountStudioResult {
|
|
77
|
+
const distPath = resolveStudioDist();
|
|
78
|
+
if (!distPath) {
|
|
79
|
+
return { mounted: false };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// serveStatic resolves `path` relative to `root` (which is relative to cwd by
|
|
83
|
+
// default). We pass an absolute `root` and strip the `/studio` URL prefix so a
|
|
84
|
+
// request for `/studio/assets/x.js` maps to `<dist>/assets/x.js`.
|
|
85
|
+
const staticHandler = serveStatic({
|
|
86
|
+
root: distPath,
|
|
87
|
+
rewriteRequestPath: (path) => path.replace(/^\/studio/, "") || "/",
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Redirect the bare `/studio` to `/studio/` so relative/base assets resolve.
|
|
91
|
+
app.get("/studio", (c) => c.redirect("/studio/"));
|
|
92
|
+
|
|
93
|
+
// Static assets (js/css/images) under /studio/*.
|
|
94
|
+
app.use("/studio/*", staticHandler);
|
|
95
|
+
|
|
96
|
+
// SPA fallback: any /studio/* path that didn't resolve to a file serves
|
|
97
|
+
// index.html so client-side (TanStack Router) routes work on hard refresh.
|
|
98
|
+
const indexHandler = serveStatic({
|
|
99
|
+
root: distPath,
|
|
100
|
+
rewriteRequestPath: () => "/index.html",
|
|
101
|
+
});
|
|
102
|
+
app.get("/studio/*", indexHandler);
|
|
103
|
+
|
|
104
|
+
return { mounted: true, distPath };
|
|
105
|
+
}
|
package/src/lib/tracked.ts
CHANGED
|
@@ -66,6 +66,8 @@ export async function sendTrackedEmail<K extends TemplateName>(
|
|
|
66
66
|
subject: options.subject ?? "",
|
|
67
67
|
category: options.category,
|
|
68
68
|
journeyStateId: options.journeyStateId,
|
|
69
|
+
userId: options.userId,
|
|
70
|
+
userEmail: options.userEmail ?? options.to,
|
|
69
71
|
status: "failed",
|
|
70
72
|
})
|
|
71
73
|
.returning({ id: emailSends.id });
|
|
@@ -126,6 +128,8 @@ export async function sendTrackedEmail<K extends TemplateName>(
|
|
|
126
128
|
subject,
|
|
127
129
|
category: options.category ?? category,
|
|
128
130
|
journeyStateId: options.journeyStateId,
|
|
131
|
+
userId: options.userId,
|
|
132
|
+
userEmail: options.userEmail ?? options.to,
|
|
129
133
|
status: "queued",
|
|
130
134
|
})
|
|
131
135
|
.returning({ id: emailSends.id });
|
|
@@ -13,7 +13,7 @@ export const rateLimit = createMiddleware<AppEnv>(async (c, next) => {
|
|
|
13
13
|
if (process.env.NODE_ENV === "test") return next();
|
|
14
14
|
|
|
15
15
|
const apiKey = c.get("apiKey");
|
|
16
|
-
const keyId = apiKey?.id ?? "anonymous";
|
|
16
|
+
const keyId = apiKey?.id ?? c.get("user")?.id ?? "anonymous";
|
|
17
17
|
const now = Date.now();
|
|
18
18
|
|
|
19
19
|
let count: number;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createMiddleware } from "hono/factory";
|
|
2
|
+
import type { AppEnv } from "../app.js";
|
|
3
|
+
import { requireApiKey } from "./api-key.js";
|
|
4
|
+
|
|
5
|
+
// Authorizes admin routes via EITHER an API key (Bearer header, the
|
|
6
|
+
// programmatic/CLI path) OR a Better-Auth session (cookie, the Studio path).
|
|
7
|
+
// A Bearer header is always treated as an API key; otherwise we resolve the
|
|
8
|
+
// session. Role-based gating is deferred — the security control for the session
|
|
9
|
+
// path is closed signup (only the first user can register; see app.ts), so any
|
|
10
|
+
// authenticated session is an intended admin in this single-tenant model.
|
|
11
|
+
export const requireAdmin = createMiddleware<AppEnv>(async (c, next) => {
|
|
12
|
+
const header = c.req.header("authorization");
|
|
13
|
+
if (header?.startsWith("Bearer ")) {
|
|
14
|
+
return requireApiKey(c, next);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const { auth } = c.get("container");
|
|
18
|
+
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
19
|
+
if (!session?.user) {
|
|
20
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
c.set("user", session.user);
|
|
24
|
+
c.set("session", session.session);
|
|
25
|
+
return next();
|
|
26
|
+
});
|
|
@@ -6,7 +6,20 @@ import {
|
|
|
6
6
|
trackedLinks,
|
|
7
7
|
} from "@hogsend/db";
|
|
8
8
|
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
and,
|
|
11
|
+
asc,
|
|
12
|
+
count,
|
|
13
|
+
desc,
|
|
14
|
+
eq,
|
|
15
|
+
getTableColumns,
|
|
16
|
+
gte,
|
|
17
|
+
inArray,
|
|
18
|
+
isNotNull,
|
|
19
|
+
isNull,
|
|
20
|
+
lte,
|
|
21
|
+
or,
|
|
22
|
+
} from "drizzle-orm";
|
|
10
23
|
import type { AppEnv } from "../../app.js";
|
|
11
24
|
|
|
12
25
|
const emailSchema = z.object({
|
|
@@ -19,6 +32,8 @@ const emailSchema = z.object({
|
|
|
19
32
|
subject: z.string(),
|
|
20
33
|
category: z.string().nullable(),
|
|
21
34
|
status: z.string(),
|
|
35
|
+
userId: z.string().nullable(),
|
|
36
|
+
journeyId: z.string().nullable(),
|
|
22
37
|
sentAt: z.string().nullable(),
|
|
23
38
|
deliveredAt: z.string().nullable(),
|
|
24
39
|
openedAt: z.string().nullable(),
|
|
@@ -29,6 +44,14 @@ const emailSchema = z.object({
|
|
|
29
44
|
updatedAt: z.string(),
|
|
30
45
|
});
|
|
31
46
|
|
|
47
|
+
const eventSchema = z.object({
|
|
48
|
+
type: z.string(),
|
|
49
|
+
timestamp: z.string(),
|
|
50
|
+
url: z.string().optional(),
|
|
51
|
+
ipAddress: z.string().nullable().optional(),
|
|
52
|
+
userAgent: z.string().nullable().optional(),
|
|
53
|
+
});
|
|
54
|
+
|
|
32
55
|
const trackedLinkSchema = z.object({
|
|
33
56
|
id: z.string(),
|
|
34
57
|
originalUrl: z.string(),
|
|
@@ -54,7 +77,13 @@ const journeyContextSchema = z
|
|
|
54
77
|
|
|
55
78
|
import { errorSchema } from "../../lib/schemas.js";
|
|
56
79
|
|
|
57
|
-
function serializeEmail(
|
|
80
|
+
function serializeEmail(
|
|
81
|
+
row: typeof emailSends.$inferSelect,
|
|
82
|
+
identity: { userId: string | null; journeyId: string | null } = {
|
|
83
|
+
userId: null,
|
|
84
|
+
journeyId: null,
|
|
85
|
+
},
|
|
86
|
+
) {
|
|
58
87
|
return {
|
|
59
88
|
id: row.id,
|
|
60
89
|
journeyStateId: row.journeyStateId,
|
|
@@ -65,6 +94,8 @@ function serializeEmail(row: typeof emailSends.$inferSelect) {
|
|
|
65
94
|
subject: row.subject,
|
|
66
95
|
category: row.category,
|
|
67
96
|
status: row.status,
|
|
97
|
+
userId: identity.userId,
|
|
98
|
+
journeyId: identity.journeyId,
|
|
68
99
|
sentAt: row.sentAt?.toISOString() ?? null,
|
|
69
100
|
deliveredAt: row.deliveredAt?.toISOString() ?? null,
|
|
70
101
|
openedAt: row.openedAt?.toISOString() ?? null,
|
|
@@ -100,6 +131,16 @@ const listRoute = createRoute({
|
|
|
100
131
|
"failed",
|
|
101
132
|
])
|
|
102
133
|
.optional(),
|
|
134
|
+
journeyId: z.string().optional(),
|
|
135
|
+
userId: z.string().optional(),
|
|
136
|
+
category: z.string().optional(),
|
|
137
|
+
engagement: z
|
|
138
|
+
.enum(["opened", "clicked", "bounced", "complained"])
|
|
139
|
+
.optional(),
|
|
140
|
+
sort: z
|
|
141
|
+
.enum(["createdAt", "sentAt", "openedAt", "clickedAt"])
|
|
142
|
+
.default("createdAt"),
|
|
143
|
+
order: z.enum(["asc", "desc"]).default("desc"),
|
|
103
144
|
from: z.string().datetime().optional(),
|
|
104
145
|
to: z.string().datetime().optional(),
|
|
105
146
|
}),
|
|
@@ -135,6 +176,7 @@ const getRoute = createRoute({
|
|
|
135
176
|
"application/json": {
|
|
136
177
|
schema: z.object({
|
|
137
178
|
email: emailSchema,
|
|
179
|
+
events: z.array(eventSchema),
|
|
138
180
|
trackedLinks: z.array(trackedLinkSchema),
|
|
139
181
|
journeyContext: journeyContextSchema,
|
|
140
182
|
}),
|
|
@@ -188,32 +230,93 @@ async function fetchTrackedLinksWithClicks(db: Database, emailSendId: string) {
|
|
|
188
230
|
export const emailsRouter = new OpenAPIHono<AppEnv>()
|
|
189
231
|
.openapi(listRoute, async (c) => {
|
|
190
232
|
const { db } = c.get("container");
|
|
191
|
-
const {
|
|
192
|
-
|
|
233
|
+
const {
|
|
234
|
+
limit,
|
|
235
|
+
offset,
|
|
236
|
+
toEmail,
|
|
237
|
+
templateKey,
|
|
238
|
+
status,
|
|
239
|
+
journeyId,
|
|
240
|
+
userId,
|
|
241
|
+
category,
|
|
242
|
+
engagement,
|
|
243
|
+
sort,
|
|
244
|
+
order,
|
|
245
|
+
from,
|
|
246
|
+
to,
|
|
247
|
+
} = c.req.valid("query");
|
|
248
|
+
|
|
249
|
+
const engagementColumn = {
|
|
250
|
+
opened: emailSends.openedAt,
|
|
251
|
+
clicked: emailSends.clickedAt,
|
|
252
|
+
bounced: emailSends.bouncedAt,
|
|
253
|
+
complained: emailSends.complainedAt,
|
|
254
|
+
} as const;
|
|
255
|
+
|
|
256
|
+
const sortColumn = {
|
|
257
|
+
createdAt: emailSends.createdAt,
|
|
258
|
+
sentAt: emailSends.sentAt,
|
|
259
|
+
openedAt: emailSends.openedAt,
|
|
260
|
+
clickedAt: emailSends.clickedAt,
|
|
261
|
+
} as const;
|
|
193
262
|
|
|
194
263
|
const conditions = [];
|
|
195
264
|
if (toEmail) conditions.push(eq(emailSends.toEmail, toEmail));
|
|
196
265
|
if (templateKey) conditions.push(eq(emailSends.templateKey, templateKey));
|
|
197
266
|
if (status) conditions.push(eq(emailSends.status, status));
|
|
267
|
+
if (category) conditions.push(eq(emailSends.category, category));
|
|
268
|
+
if (journeyId) conditions.push(eq(journeyStates.journeyId, journeyId));
|
|
269
|
+
// Match the denormalized identity OR the journey-state join, so journeyless
|
|
270
|
+
// sends (which only carry the denormalized userId) are still filterable.
|
|
271
|
+
if (userId) {
|
|
272
|
+
conditions.push(
|
|
273
|
+
or(eq(emailSends.userId, userId), eq(journeyStates.userId, userId)),
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
if (engagement) conditions.push(isNotNull(engagementColumn[engagement]));
|
|
198
277
|
if (from) conditions.push(gte(emailSends.createdAt, new Date(from)));
|
|
199
278
|
if (to) conditions.push(lte(emailSends.createdAt, new Date(to)));
|
|
200
279
|
|
|
201
280
|
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
|
202
281
|
|
|
282
|
+
const orderBy =
|
|
283
|
+
order === "asc" ? asc(sortColumn[sort]) : desc(sortColumn[sort]);
|
|
284
|
+
|
|
285
|
+
const joinCondition = and(
|
|
286
|
+
eq(emailSends.journeyStateId, journeyStates.id),
|
|
287
|
+
isNull(journeyStates.deletedAt),
|
|
288
|
+
);
|
|
289
|
+
|
|
203
290
|
const [rows, totalRows] = await Promise.all([
|
|
204
291
|
db
|
|
205
|
-
.select(
|
|
292
|
+
.select({
|
|
293
|
+
...getTableColumns(emailSends),
|
|
294
|
+
identityUserId: journeyStates.userId,
|
|
295
|
+
identityJourneyId: journeyStates.journeyId,
|
|
296
|
+
})
|
|
206
297
|
.from(emailSends)
|
|
298
|
+
.leftJoin(journeyStates, joinCondition)
|
|
207
299
|
.where(where)
|
|
208
|
-
.orderBy(
|
|
300
|
+
.orderBy(orderBy)
|
|
209
301
|
.limit(limit)
|
|
210
302
|
.offset(offset),
|
|
211
|
-
db
|
|
303
|
+
db
|
|
304
|
+
.select({ count: count() })
|
|
305
|
+
.from(emailSends)
|
|
306
|
+
.leftJoin(journeyStates, joinCondition)
|
|
307
|
+
.where(where),
|
|
212
308
|
]);
|
|
213
309
|
|
|
214
310
|
return c.json(
|
|
215
311
|
{
|
|
216
|
-
emails: rows.map(
|
|
312
|
+
emails: rows.map(({ identityUserId, identityJourneyId, ...row }) =>
|
|
313
|
+
// Prefer the denormalized identity on the send row; fall back to the
|
|
314
|
+
// journey-state join (covers rows written before denormalization).
|
|
315
|
+
serializeEmail(row, {
|
|
316
|
+
userId: row.userId ?? identityUserId,
|
|
317
|
+
journeyId: identityJourneyId,
|
|
318
|
+
}),
|
|
319
|
+
),
|
|
217
320
|
total: totalRows[0]?.count ?? 0,
|
|
218
321
|
limit,
|
|
219
322
|
offset,
|
|
@@ -258,9 +361,52 @@ export const emailsRouter = new OpenAPIHono<AppEnv>()
|
|
|
258
361
|
: Promise.resolve(null),
|
|
259
362
|
]);
|
|
260
363
|
|
|
364
|
+
const events: z.infer<typeof eventSchema>[] = [];
|
|
365
|
+
if (row.createdAt)
|
|
366
|
+
events.push({ type: "queued", timestamp: row.createdAt.toISOString() });
|
|
367
|
+
if (row.sentAt)
|
|
368
|
+
events.push({ type: "sent", timestamp: row.sentAt.toISOString() });
|
|
369
|
+
if (row.deliveredAt)
|
|
370
|
+
events.push({
|
|
371
|
+
type: "delivered",
|
|
372
|
+
timestamp: row.deliveredAt.toISOString(),
|
|
373
|
+
});
|
|
374
|
+
if (row.openedAt)
|
|
375
|
+
events.push({ type: "opened", timestamp: row.openedAt.toISOString() });
|
|
376
|
+
if (row.bouncedAt)
|
|
377
|
+
events.push({ type: "bounced", timestamp: row.bouncedAt.toISOString() });
|
|
378
|
+
if (row.complainedAt)
|
|
379
|
+
events.push({
|
|
380
|
+
type: "complained",
|
|
381
|
+
timestamp: row.complainedAt.toISOString(),
|
|
382
|
+
});
|
|
383
|
+
if (row.status === "failed" && row.updatedAt)
|
|
384
|
+
events.push({ type: "failed", timestamp: row.updatedAt.toISOString() });
|
|
385
|
+
|
|
386
|
+
for (const link of links) {
|
|
387
|
+
for (const click of link.clicks) {
|
|
388
|
+
events.push({
|
|
389
|
+
type: "clicked",
|
|
390
|
+
timestamp: click.clickedAt,
|
|
391
|
+
url: link.originalUrl,
|
|
392
|
+
ipAddress: click.ipAddress,
|
|
393
|
+
userAgent: click.userAgent,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
events.sort(
|
|
399
|
+
(a, b) =>
|
|
400
|
+
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
|
401
|
+
);
|
|
402
|
+
|
|
261
403
|
return c.json(
|
|
262
404
|
{
|
|
263
|
-
email: serializeEmail(row
|
|
405
|
+
email: serializeEmail(row, {
|
|
406
|
+
userId: row.userId ?? journeyContext?.userId ?? null,
|
|
407
|
+
journeyId: journeyContext?.journeyId ?? null,
|
|
408
|
+
}),
|
|
409
|
+
events,
|
|
264
410
|
trackedLinks: links,
|
|
265
411
|
journeyContext,
|
|
266
412
|
},
|