@hogsend/engine 0.1.0 → 0.2.0

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.
@@ -1,4 +1,5 @@
1
1
  import {
2
+ bucketMemberships,
2
3
  contacts,
3
4
  emailPreferences,
4
5
  emailSends,
@@ -18,15 +19,9 @@ import {
18
19
  sql,
19
20
  } from "drizzle-orm";
20
21
  import type { AppEnv } from "../../app.js";
22
+ import { rate, TRUNC_SQL } from "../../lib/metrics-sql.js";
21
23
  import { errorSchema } from "../../lib/schemas.js";
22
24
 
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
25
  const overviewRoute = createRoute({
31
26
  method: "get",
32
27
  path: "/overview",
@@ -39,6 +34,8 @@ const overviewRoute = createRoute({
39
34
  schema: z.object({
40
35
  totalContacts: z.number(),
41
36
  activeJourneys: z.number(),
37
+ activeBuckets: z.number(),
38
+ bucketMembers: z.number(),
42
39
  emailsSent24h: z.number(),
43
40
  emailsSent7d: z.number(),
44
41
  emailsSent30d: z.number(),
@@ -123,6 +120,13 @@ const emailMetricsRoute = createRoute({
123
120
  path: "/emails",
124
121
  tags: ["Admin — Metrics"],
125
122
  summary: "Per-template email metrics",
123
+ request: {
124
+ query: z.object({
125
+ from: z.string().datetime().optional(),
126
+ to: z.string().datetime().optional(),
127
+ includeUntemplated: z.enum(["true", "false"]).default("false"),
128
+ }),
129
+ },
126
130
  responses: {
127
131
  200: {
128
132
  content: {
@@ -139,6 +143,7 @@ const emailMetricsRoute = createRoute({
139
143
  deliveryRate: z.number(),
140
144
  openRate: z.number(),
141
145
  clickRate: z.number(),
146
+ clickToDeliveryRate: z.number(),
142
147
  }),
143
148
  ),
144
149
  }),
@@ -216,6 +221,75 @@ const eventVolumeRoute = createRoute({
216
221
  },
217
222
  });
218
223
 
224
+ const bucketsMetricsRoute = createRoute({
225
+ method: "get",
226
+ path: "/buckets",
227
+ tags: ["Admin — Metrics"],
228
+ summary: "Per-bucket membership metrics",
229
+ responses: {
230
+ 200: {
231
+ content: {
232
+ "application/json": {
233
+ schema: z.object({
234
+ buckets: z.array(
235
+ z.object({
236
+ bucketId: z.string(),
237
+ name: z.string(),
238
+ size: z.number(),
239
+ entered: z.number(),
240
+ left: z.number(),
241
+ avgDwellSecs: z.number().nullable(),
242
+ }),
243
+ ),
244
+ }),
245
+ },
246
+ },
247
+ description: "Per-bucket size, entered/left totals, and average dwell",
248
+ },
249
+ },
250
+ });
251
+
252
+ const bucketTrendRoute = createRoute({
253
+ method: "get",
254
+ path: "/buckets/{id}",
255
+ tags: ["Admin — Metrics"],
256
+ summary: "Single bucket size-over-time and entered/left trend",
257
+ request: {
258
+ params: z.object({ id: z.string() }),
259
+ query: z.object({
260
+ period: z.enum(["day", "week", "month"]).default("day"),
261
+ from: z.string().datetime().optional(),
262
+ to: z.string().datetime().optional(),
263
+ }),
264
+ },
265
+ responses: {
266
+ 200: {
267
+ content: {
268
+ "application/json": {
269
+ schema: z.object({
270
+ bucketId: z.string(),
271
+ size: z.number(),
272
+ points: z.array(
273
+ z.object({
274
+ date: z.string(),
275
+ entered: z.number(),
276
+ left: z.number(),
277
+ }),
278
+ ),
279
+ }),
280
+ },
281
+ },
282
+ description: "Bucket entered/left time-series with current size",
283
+ },
284
+ 404: {
285
+ content: {
286
+ "application/json": { schema: errorSchema },
287
+ },
288
+ description: "Bucket not found",
289
+ },
290
+ },
291
+ });
292
+
219
293
  export const metricsRouter = new OpenAPIHono<AppEnv>()
220
294
  .openapi(overviewRoute, async (c) => {
221
295
  const { db } = c.get("container");
@@ -228,6 +302,8 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
228
302
  const [
229
303
  contactsTotal,
230
304
  activeJourneys,
305
+ activeBucketsRows,
306
+ bucketMembers,
231
307
  emails24h,
232
308
  emails7d,
233
309
  emails30d,
@@ -250,6 +326,27 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
250
326
  ),
251
327
  )
252
328
  .then((r) => r[0]?.count ?? 0),
329
+ // Distinct buckets with at least one active member.
330
+ db
331
+ .selectDistinct({ bucketId: bucketMemberships.bucketId })
332
+ .from(bucketMemberships)
333
+ .where(
334
+ and(
335
+ inArray(bucketMemberships.status, ["active"]),
336
+ isNull(bucketMemberships.deletedAt),
337
+ ),
338
+ ),
339
+ // Total active memberships across all buckets.
340
+ db
341
+ .select({ count: count() })
342
+ .from(bucketMemberships)
343
+ .where(
344
+ and(
345
+ inArray(bucketMemberships.status, ["active"]),
346
+ isNull(bucketMemberships.deletedAt),
347
+ ),
348
+ )
349
+ .then((r) => r[0]?.count ?? 0),
253
350
  db
254
351
  .select({ count: count() })
255
352
  .from(emailSends)
@@ -284,18 +381,18 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
284
381
  ]);
285
382
 
286
383
  const sent30d = emails30d;
287
- const bounceRate30d = sent30d > 0 ? bounced30d / sent30d : 0;
288
- const unsubscribeRate = totalPrefs > 0 ? unsubscribed / totalPrefs : 0;
289
384
 
290
385
  return c.json(
291
386
  {
292
387
  totalContacts: contactsTotal,
293
388
  activeJourneys,
389
+ activeBuckets: activeBucketsRows.length,
390
+ bucketMembers,
294
391
  emailsSent24h: emails24h,
295
392
  emailsSent7d: emails7d,
296
393
  emailsSent30d: sent30d,
297
- bounceRate30d: Math.round(bounceRate30d * 10000) / 10000,
298
- unsubscribeRate: Math.round(unsubscribeRate * 10000) / 10000,
394
+ bounceRate30d: rate(bounced30d, sent30d),
395
+ unsubscribeRate: rate(unsubscribed, totalPrefs),
299
396
  },
300
397
  200,
301
398
  );
@@ -352,7 +449,6 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
352
449
  (counts.failed ?? 0) +
353
450
  (counts.exited ?? 0);
354
451
  const completed = counts.completed ?? 0;
355
- const completionRate = enrolled > 0 ? completed / enrolled : 0;
356
452
 
357
453
  return {
358
454
  journeyId: j.id,
@@ -362,7 +458,7 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
362
458
  failed: counts.failed ?? 0,
363
459
  exited: counts.exited ?? 0,
364
460
  active: (counts.active ?? 0) + (counts.waiting ?? 0),
365
- completionRate: Math.round(completionRate * 10000) / 10000,
461
+ completionRate: rate(completed, enrolled),
366
462
  avgDurationSecs: durationMap.has(j.id)
367
463
  ? Math.round(durationMap.get(j.id) ?? 0)
368
464
  : null,
@@ -437,6 +533,15 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
437
533
  })
438
534
  .openapi(emailMetricsRoute, async (c) => {
439
535
  const { db } = c.get("container");
536
+ const { from, to, includeUntemplated } = c.req.valid("query");
537
+
538
+ const conditions = [];
539
+ if (includeUntemplated !== "true")
540
+ conditions.push(isNotNull(emailSends.templateKey));
541
+ if (from) conditions.push(gte(emailSends.createdAt, new Date(from)));
542
+ if (to) conditions.push(lte(emailSends.createdAt, new Date(to)));
543
+
544
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
440
545
 
441
546
  const rows = await db
442
547
  .select({
@@ -448,7 +553,7 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
448
553
  bounced: sql<number>`count(*) filter (where ${emailSends.status} = 'bounced')`,
449
554
  })
450
555
  .from(emailSends)
451
- .where(isNotNull(emailSends.templateKey))
556
+ .where(where)
452
557
  .groupBy(emailSends.templateKey);
453
558
 
454
559
  const templates = rows.map((row) => {
@@ -456,19 +561,20 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
456
561
  const delivered = Number(row.delivered);
457
562
  const opened = Number(row.opened);
458
563
  const clicked = Number(row.clicked);
564
+ // Open-rate denominator falls back to sent when delivered-webhooks aren't
565
+ // firing, so opens aren't silently zeroed.
566
+ const openDenominator = delivered > 0 ? delivered : sent;
459
567
  return {
460
- templateKey: row.templateKey ?? "",
568
+ templateKey: row.templateKey ?? "(none)",
461
569
  sent,
462
570
  delivered,
463
571
  opened,
464
572
  clicked,
465
573
  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,
574
+ deliveryRate: rate(delivered, sent),
575
+ openRate: rate(opened, openDenominator),
576
+ clickRate: rate(clicked, opened),
577
+ clickToDeliveryRate: rate(clicked, delivered),
472
578
  };
473
579
  });
474
580
 
@@ -510,8 +616,7 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
510
616
  delivered,
511
617
  bounced: Number(row.bounced),
512
618
  complained: Number(row.complained),
513
- deliveryRate:
514
- total > 0 ? Math.round((delivered / total) * 10000) / 10000 : 0,
619
+ deliveryRate: rate(delivered, total),
515
620
  };
516
621
  });
517
622
 
@@ -556,4 +661,162 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
556
661
  }));
557
662
 
558
663
  return c.json({ events }, 200);
664
+ })
665
+ .openapi(bucketsMetricsRoute, async (c) => {
666
+ const { db, bucketRegistry } = c.get("container");
667
+
668
+ const [sizes, totals, dwell] = await Promise.all([
669
+ // Current size = active, non-deleted memberships per bucket.
670
+ db
671
+ .select({
672
+ bucketId: bucketMemberships.bucketId,
673
+ size: count(),
674
+ })
675
+ .from(bucketMemberships)
676
+ .where(
677
+ and(
678
+ eq(bucketMemberships.status, "active"),
679
+ isNull(bucketMemberships.deletedAt),
680
+ ),
681
+ )
682
+ .groupBy(bucketMemberships.bucketId),
683
+ // Total entered = all rows; total left = rows that have flipped to "left".
684
+ db
685
+ .select({
686
+ bucketId: bucketMemberships.bucketId,
687
+ entered: count(),
688
+ left: sql<number>`count(*) filter (where ${bucketMemberships.status} = 'left')`,
689
+ })
690
+ .from(bucketMemberships)
691
+ .where(isNull(bucketMemberships.deletedAt))
692
+ .groupBy(bucketMemberships.bucketId),
693
+ // Average dwell — seconds between entry and leave (or now, if still active).
694
+ db
695
+ .select({
696
+ bucketId: bucketMemberships.bucketId,
697
+ avgDwell: sql<number>`avg(extract(epoch from (coalesce(${bucketMemberships.leftAt}, now()) - ${bucketMemberships.enteredAt})))`,
698
+ })
699
+ .from(bucketMemberships)
700
+ .where(isNull(bucketMemberships.deletedAt))
701
+ .groupBy(bucketMemberships.bucketId),
702
+ ]);
703
+
704
+ const sizeMap = new Map(sizes.map((r) => [r.bucketId, r.size]));
705
+ const totalsMap = new Map(
706
+ totals.map((r) => [
707
+ r.bucketId,
708
+ { entered: Number(r.entered), left: Number(r.left) },
709
+ ]),
710
+ );
711
+ const dwellMap = new Map<string, number>();
712
+ for (const row of dwell) {
713
+ if (row.avgDwell != null) {
714
+ dwellMap.set(row.bucketId, Number(row.avgDwell));
715
+ }
716
+ }
717
+
718
+ const buckets = bucketRegistry.getAll().map((b) => {
719
+ const t = totalsMap.get(b.id) ?? { entered: 0, left: 0 };
720
+ return {
721
+ bucketId: b.id,
722
+ name: b.name,
723
+ size: sizeMap.get(b.id) ?? 0,
724
+ entered: t.entered,
725
+ left: t.left,
726
+ avgDwellSecs: dwellMap.has(b.id)
727
+ ? Math.round(dwellMap.get(b.id) ?? 0)
728
+ : null,
729
+ };
730
+ });
731
+
732
+ return c.json({ buckets }, 200);
733
+ })
734
+ .openapi(bucketTrendRoute, async (c) => {
735
+ const { db, bucketRegistry } = c.get("container");
736
+ const { id } = c.req.valid("param");
737
+ const { period, from, to } = c.req.valid("query");
738
+
739
+ if (!bucketRegistry.has(id)) {
740
+ return c.json({ error: "Bucket not found" }, 404);
741
+ }
742
+
743
+ const now = new Date();
744
+ const defaultFrom = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
745
+ const fromDate = from ? new Date(from) : defaultFrom;
746
+ const toDate = to ? new Date(to) : now;
747
+
748
+ const [size, enteredRows, leftRows] = await Promise.all([
749
+ db
750
+ .select({ count: count() })
751
+ .from(bucketMemberships)
752
+ .where(
753
+ and(
754
+ eq(bucketMemberships.bucketId, id),
755
+ eq(bucketMemberships.status, "active"),
756
+ isNull(bucketMemberships.deletedAt),
757
+ ),
758
+ )
759
+ .then((r) => r[0]?.count ?? 0),
760
+ // Joins over time, bucketed on enteredAt.
761
+ db
762
+ .select({
763
+ date: sql<string>`date_trunc(${TRUNC_SQL[period]}, ${bucketMemberships.enteredAt})::text`,
764
+ count: count(),
765
+ })
766
+ .from(bucketMemberships)
767
+ .where(
768
+ and(
769
+ eq(bucketMemberships.bucketId, id),
770
+ isNull(bucketMemberships.deletedAt),
771
+ gte(bucketMemberships.enteredAt, fromDate),
772
+ lte(bucketMemberships.enteredAt, toDate),
773
+ ),
774
+ )
775
+ .groupBy(
776
+ sql`date_trunc(${TRUNC_SQL[period]}, ${bucketMemberships.enteredAt})`,
777
+ )
778
+ .orderBy(
779
+ sql`date_trunc(${TRUNC_SQL[period]}, ${bucketMemberships.enteredAt})`,
780
+ ),
781
+ // Leaves over time, bucketed on leftAt (only flipped rows have a leftAt).
782
+ db
783
+ .select({
784
+ date: sql<string>`date_trunc(${TRUNC_SQL[period]}, ${bucketMemberships.leftAt})::text`,
785
+ count: count(),
786
+ })
787
+ .from(bucketMemberships)
788
+ .where(
789
+ and(
790
+ eq(bucketMemberships.bucketId, id),
791
+ isNull(bucketMemberships.deletedAt),
792
+ isNotNull(bucketMemberships.leftAt),
793
+ gte(bucketMemberships.leftAt, fromDate),
794
+ lte(bucketMemberships.leftAt, toDate),
795
+ ),
796
+ )
797
+ .groupBy(
798
+ sql`date_trunc(${TRUNC_SQL[period]}, ${bucketMemberships.leftAt})`,
799
+ )
800
+ .orderBy(
801
+ sql`date_trunc(${TRUNC_SQL[period]}, ${bucketMemberships.leftAt})`,
802
+ ),
803
+ ]);
804
+
805
+ const pointMap = new Map<string, { entered: number; left: number }>();
806
+ for (const row of enteredRows) {
807
+ const entry = pointMap.get(row.date) ?? { entered: 0, left: 0 };
808
+ entry.entered = row.count;
809
+ pointMap.set(row.date, entry);
810
+ }
811
+ for (const row of leftRows) {
812
+ const entry = pointMap.get(row.date) ?? { entered: 0, left: 0 };
813
+ entry.left = row.count;
814
+ pointMap.set(row.date, entry);
815
+ }
816
+
817
+ const points = Array.from(pointMap.entries())
818
+ .map(([date, v]) => ({ date, entered: v.entered, left: v.left }))
819
+ .sort((a, b) => a.date.localeCompare(b.date));
820
+
821
+ return c.json({ bucketId: id, size, points }, 200);
559
822
  });