@hogsend/engine 0.0.1 → 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.
@@ -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
  },
@@ -1,8 +1,8 @@
1
1
  import { OpenAPIHono } from "@hono/zod-openapi";
2
2
  import type { AppEnv } from "../../app.js";
3
- import { requireApiKey } from "../../middleware/api-key.js";
4
3
  import { auditMiddleware } from "../../middleware/audit.js";
5
4
  import { rateLimit } from "../../middleware/rate-limit.js";
5
+ import { requireAdmin } from "../../middleware/require-admin.js";
6
6
  import { alertsRouter } from "./alerts.js";
7
7
  import { apiKeysRouter } from "./api-keys.js";
8
8
  import { auditLogsRouter } from "./audit-logs.js";
@@ -15,10 +15,13 @@ import { journeyLogsRouter } from "./journey-logs.js";
15
15
  import { journeysRouter } from "./journeys.js";
16
16
  import { metricsRouter } from "./metrics.js";
17
17
  import { preferencesRouter } from "./preferences.js";
18
+ import { reportingRouter } from "./reporting.js";
19
+ import { suppressionsRouter } from "./suppressions.js";
20
+ import { templatesRouter } from "./templates.js";
18
21
  import { timelineRouter } from "./timeline.js";
19
22
 
20
23
  export const adminRouter = new OpenAPIHono<AppEnv>();
21
- adminRouter.use("*", requireApiKey);
24
+ adminRouter.use("*", requireAdmin);
22
25
  adminRouter.use("*", rateLimit);
23
26
  adminRouter.use("*", auditMiddleware);
24
27
  adminRouter.route("/", bulkRouter);
@@ -30,6 +33,9 @@ adminRouter.route("/events", eventsRouter);
30
33
  adminRouter.route("/emails", emailsRouter);
31
34
  adminRouter.route("/journey-logs", journeyLogsRouter);
32
35
  adminRouter.route("/metrics", metricsRouter);
36
+ adminRouter.route("/reporting", reportingRouter);
37
+ adminRouter.route("/templates", templatesRouter);
38
+ adminRouter.route("/suppressions", suppressionsRouter);
33
39
  adminRouter.route("/api-keys", apiKeysRouter);
34
40
  adminRouter.route("/audit-logs", auditLogsRouter);
35
41
  adminRouter.route("/alerts", alertsRouter);
@@ -18,15 +18,9 @@ import {
18
18
  sql,
19
19
  } from "drizzle-orm";
20
20
  import type { AppEnv } from "../../app.js";
21
+ import { rate, TRUNC_SQL } from "../../lib/metrics-sql.js";
21
22
  import { errorSchema } from "../../lib/schemas.js";
22
23
 
23
- const TRUNC_SQL = {
24
- hour: sql`'hour'`,
25
- day: sql`'day'`,
26
- week: sql`'week'`,
27
- month: sql`'month'`,
28
- } as const;
29
-
30
24
  const overviewRoute = createRoute({
31
25
  method: "get",
32
26
  path: "/overview",
@@ -123,6 +117,13 @@ const emailMetricsRoute = createRoute({
123
117
  path: "/emails",
124
118
  tags: ["Admin — Metrics"],
125
119
  summary: "Per-template email metrics",
120
+ request: {
121
+ query: z.object({
122
+ from: z.string().datetime().optional(),
123
+ to: z.string().datetime().optional(),
124
+ includeUntemplated: z.enum(["true", "false"]).default("false"),
125
+ }),
126
+ },
126
127
  responses: {
127
128
  200: {
128
129
  content: {
@@ -139,6 +140,7 @@ const emailMetricsRoute = createRoute({
139
140
  deliveryRate: z.number(),
140
141
  openRate: z.number(),
141
142
  clickRate: z.number(),
143
+ clickToDeliveryRate: z.number(),
142
144
  }),
143
145
  ),
144
146
  }),
@@ -284,8 +286,6 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
284
286
  ]);
285
287
 
286
288
  const sent30d = emails30d;
287
- const bounceRate30d = sent30d > 0 ? bounced30d / sent30d : 0;
288
- const unsubscribeRate = totalPrefs > 0 ? unsubscribed / totalPrefs : 0;
289
289
 
290
290
  return c.json(
291
291
  {
@@ -294,8 +294,8 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
294
294
  emailsSent24h: emails24h,
295
295
  emailsSent7d: emails7d,
296
296
  emailsSent30d: sent30d,
297
- bounceRate30d: Math.round(bounceRate30d * 10000) / 10000,
298
- unsubscribeRate: Math.round(unsubscribeRate * 10000) / 10000,
297
+ bounceRate30d: rate(bounced30d, sent30d),
298
+ unsubscribeRate: rate(unsubscribed, totalPrefs),
299
299
  },
300
300
  200,
301
301
  );
@@ -352,7 +352,6 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
352
352
  (counts.failed ?? 0) +
353
353
  (counts.exited ?? 0);
354
354
  const completed = counts.completed ?? 0;
355
- const completionRate = enrolled > 0 ? completed / enrolled : 0;
356
355
 
357
356
  return {
358
357
  journeyId: j.id,
@@ -362,7 +361,7 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
362
361
  failed: counts.failed ?? 0,
363
362
  exited: counts.exited ?? 0,
364
363
  active: (counts.active ?? 0) + (counts.waiting ?? 0),
365
- completionRate: Math.round(completionRate * 10000) / 10000,
364
+ completionRate: rate(completed, enrolled),
366
365
  avgDurationSecs: durationMap.has(j.id)
367
366
  ? Math.round(durationMap.get(j.id) ?? 0)
368
367
  : null,
@@ -437,6 +436,15 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
437
436
  })
438
437
  .openapi(emailMetricsRoute, async (c) => {
439
438
  const { db } = c.get("container");
439
+ const { from, to, includeUntemplated } = c.req.valid("query");
440
+
441
+ const conditions = [];
442
+ if (includeUntemplated !== "true")
443
+ conditions.push(isNotNull(emailSends.templateKey));
444
+ if (from) conditions.push(gte(emailSends.createdAt, new Date(from)));
445
+ if (to) conditions.push(lte(emailSends.createdAt, new Date(to)));
446
+
447
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
440
448
 
441
449
  const rows = await db
442
450
  .select({
@@ -448,7 +456,7 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
448
456
  bounced: sql<number>`count(*) filter (where ${emailSends.status} = 'bounced')`,
449
457
  })
450
458
  .from(emailSends)
451
- .where(isNotNull(emailSends.templateKey))
459
+ .where(where)
452
460
  .groupBy(emailSends.templateKey);
453
461
 
454
462
  const templates = rows.map((row) => {
@@ -456,19 +464,20 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
456
464
  const delivered = Number(row.delivered);
457
465
  const opened = Number(row.opened);
458
466
  const clicked = Number(row.clicked);
467
+ // Open-rate denominator falls back to sent when delivered-webhooks aren't
468
+ // firing, so opens aren't silently zeroed.
469
+ const openDenominator = delivered > 0 ? delivered : sent;
459
470
  return {
460
- templateKey: row.templateKey ?? "",
471
+ templateKey: row.templateKey ?? "(none)",
461
472
  sent,
462
473
  delivered,
463
474
  opened,
464
475
  clicked,
465
476
  bounced: Number(row.bounced),
466
- deliveryRate:
467
- sent > 0 ? Math.round((delivered / sent) * 10000) / 10000 : 0,
468
- openRate:
469
- delivered > 0 ? Math.round((opened / delivered) * 10000) / 10000 : 0,
470
- clickRate:
471
- opened > 0 ? Math.round((clicked / opened) * 10000) / 10000 : 0,
477
+ deliveryRate: rate(delivered, sent),
478
+ openRate: rate(opened, openDenominator),
479
+ clickRate: rate(clicked, opened),
480
+ clickToDeliveryRate: rate(clicked, delivered),
472
481
  };
473
482
  });
474
483
 
@@ -510,8 +519,7 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
510
519
  delivered,
511
520
  bounced: Number(row.bounced),
512
521
  complained: Number(row.complained),
513
- deliveryRate:
514
- total > 0 ? Math.round((delivered / total) * 10000) / 10000 : 0,
522
+ deliveryRate: rate(delivered, total),
515
523
  };
516
524
  });
517
525