@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/engine",
3
- "version": "0.1.0",
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.1",
41
- "@hogsend/plugin-posthog": "^0.0.1",
42
- "@hogsend/plugin-resend": "^0.0.1"
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: opts.email?.templates ?? ({} as TemplateRegistry),
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
+ }
@@ -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 { and, count, desc, eq, gte, inArray, isNull, lte } from "drizzle-orm";
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(row: typeof emailSends.$inferSelect) {
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 { limit, offset, toEmail, templateKey, status, from, to } =
192
- c.req.valid("query");
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(desc(emailSends.createdAt))
300
+ .orderBy(orderBy)
209
301
  .limit(limit)
210
302
  .offset(offset),
211
- db.select({ count: count() }).from(emailSends).where(where),
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(serializeEmail),
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
  },