@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.
@@ -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
 
@@ -0,0 +1,369 @@
1
+ import { emailSends } from "@hogsend/db";
2
+ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
3
+ import {
4
+ and,
5
+ count,
6
+ desc,
7
+ eq,
8
+ gte,
9
+ isNotNull,
10
+ lte,
11
+ or,
12
+ sql,
13
+ } from "drizzle-orm";
14
+ import type { AppEnv } from "../../app.js";
15
+ import { resolveContact } from "../../lib/contacts.js";
16
+ import { rate, TRUNC_SQL } from "../../lib/metrics-sql.js";
17
+ import { errorSchema } from "../../lib/schemas.js";
18
+
19
+ // Filtered-count fragments shared by the per-template totals + series queries.
20
+ const COUNTS = {
21
+ sent: sql<number>`count(*) filter (where ${emailSends.sentAt} is not null)`,
22
+ delivered: sql<number>`count(*) filter (where ${emailSends.status} = 'delivered' or ${emailSends.deliveredAt} is not null)`,
23
+ opened: sql<number>`count(*) filter (where ${emailSends.openedAt} is not null)`,
24
+ clicked: sql<number>`count(*) filter (where ${emailSends.clickedAt} is not null)`,
25
+ bounced: sql<number>`count(*) filter (where ${emailSends.bouncedAt} is not null)`,
26
+ complained: sql<number>`count(*) filter (where ${emailSends.complainedAt} is not null)`,
27
+ } as const;
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // GET /templates/{templateKey} — totals + time-series for one template
31
+ // ---------------------------------------------------------------------------
32
+
33
+ const templateSeriesRoute = createRoute({
34
+ method: "get",
35
+ path: "/templates/{templateKey}",
36
+ tags: ["Admin — Reporting"],
37
+ summary: "One template's totals + engagement time-series",
38
+ request: {
39
+ params: z.object({ templateKey: z.string() }),
40
+ query: z.object({
41
+ from: z.string().datetime().optional(),
42
+ to: z.string().datetime().optional(),
43
+ granularity: z.enum(["day", "week", "month"]).default("day"),
44
+ }),
45
+ },
46
+ responses: {
47
+ 200: {
48
+ content: {
49
+ "application/json": {
50
+ schema: z.object({
51
+ templateKey: z.string(),
52
+ window: z.object({
53
+ from: z.string().nullable(),
54
+ to: z.string().nullable(),
55
+ }),
56
+ totals: z.object({
57
+ sent: z.number(),
58
+ delivered: z.number(),
59
+ opened: z.number(),
60
+ clicked: z.number(),
61
+ bounced: z.number(),
62
+ complained: z.number(),
63
+ deliveryRate: z.number(),
64
+ openRate: z.number(),
65
+ clickRate: z.number(),
66
+ clickToDeliveryRate: z.number(),
67
+ }),
68
+ series: z.array(
69
+ z.object({
70
+ date: z.string(),
71
+ sent: z.number(),
72
+ delivered: z.number(),
73
+ opened: z.number(),
74
+ clicked: z.number(),
75
+ bounced: z.number(),
76
+ }),
77
+ ),
78
+ }),
79
+ },
80
+ },
81
+ description: "Template totals and time-series",
82
+ },
83
+ 404: {
84
+ content: { "application/json": { schema: errorSchema } },
85
+ description: "Template has never been sent",
86
+ },
87
+ },
88
+ });
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // GET /contacts/{id}/activity — per-send engagement rows for one contact
92
+ // ---------------------------------------------------------------------------
93
+
94
+ const contactActivityRoute = createRoute({
95
+ method: "get",
96
+ path: "/contacts/{id}/activity",
97
+ tags: ["Admin — Reporting"],
98
+ summary: "A contact's email sends with engagement",
99
+ request: {
100
+ params: z.object({ id: z.string() }),
101
+ query: z.object({
102
+ limit: z.coerce.number().min(1).max(100).default(50),
103
+ offset: z.coerce.number().min(0).default(0),
104
+ }),
105
+ },
106
+ responses: {
107
+ 200: {
108
+ content: {
109
+ "application/json": {
110
+ schema: z.object({
111
+ contact: z.object({
112
+ externalId: z.string(),
113
+ email: z.string().nullable(),
114
+ }),
115
+ sends: z.array(
116
+ z.object({
117
+ id: z.string(),
118
+ templateKey: z.string().nullable(),
119
+ subject: z.string(),
120
+ status: z.string(),
121
+ sentAt: z.string().nullable(),
122
+ deliveredAt: z.string().nullable(),
123
+ openedAt: z.string().nullable(),
124
+ clickedAt: z.string().nullable(),
125
+ bouncedAt: z.string().nullable(),
126
+ complainedAt: z.string().nullable(),
127
+ bounceType: z.string().nullable(),
128
+ createdAt: z.string(),
129
+ }),
130
+ ),
131
+ total: z.number(),
132
+ limit: z.number(),
133
+ offset: z.number(),
134
+ }),
135
+ },
136
+ },
137
+ description: "Per-contact email send activity",
138
+ },
139
+ 404: {
140
+ content: { "application/json": { schema: errorSchema } },
141
+ description: "Contact not found",
142
+ },
143
+ },
144
+ });
145
+
146
+ const MAX_EXPORT_ROWS = 50_000;
147
+
148
+ function csvCell(value: unknown): string {
149
+ if (value === null || value === undefined) return "";
150
+ const str = value instanceof Date ? value.toISOString() : String(value);
151
+ return /[",\n\r]/.test(str) ? `"${str.replace(/"/g, '""')}"` : str;
152
+ }
153
+
154
+ export const reportingRouter = new OpenAPIHono<AppEnv>()
155
+ .openapi(templateSeriesRoute, async (c) => {
156
+ const { db } = c.get("container");
157
+ const { templateKey } = c.req.valid("param");
158
+ const { from, to, granularity } = c.req.valid("query");
159
+
160
+ const existing = await db
161
+ .select({ id: emailSends.id })
162
+ .from(emailSends)
163
+ .where(eq(emailSends.templateKey, templateKey))
164
+ .limit(1);
165
+ if (existing.length === 0) {
166
+ return c.json({ error: "Template has never been sent" }, 404);
167
+ }
168
+
169
+ const conditions = [eq(emailSends.templateKey, templateKey)];
170
+ if (from) conditions.push(gte(emailSends.createdAt, new Date(from)));
171
+ if (to) conditions.push(lte(emailSends.createdAt, new Date(to)));
172
+ const where = and(...conditions);
173
+
174
+ const trunc = sql`date_trunc(${TRUNC_SQL[granularity]}, ${emailSends.createdAt})`;
175
+
176
+ const [totalsRows, seriesRows] = await Promise.all([
177
+ db.select(COUNTS).from(emailSends).where(where),
178
+ db
179
+ .select({
180
+ date: sql<string>`${trunc}::text`,
181
+ sent: COUNTS.sent,
182
+ delivered: COUNTS.delivered,
183
+ opened: COUNTS.opened,
184
+ clicked: COUNTS.clicked,
185
+ bounced: COUNTS.bounced,
186
+ })
187
+ .from(emailSends)
188
+ .where(where)
189
+ .groupBy(trunc)
190
+ .orderBy(trunc),
191
+ ]);
192
+
193
+ const t = totalsRows[0] ?? {
194
+ sent: 0,
195
+ delivered: 0,
196
+ opened: 0,
197
+ clicked: 0,
198
+ bounced: 0,
199
+ complained: 0,
200
+ };
201
+ const sent = Number(t.sent);
202
+ const delivered = Number(t.delivered);
203
+ const opened = Number(t.opened);
204
+ const clicked = Number(t.clicked);
205
+ const openDenominator = delivered > 0 ? delivered : sent;
206
+
207
+ return c.json(
208
+ {
209
+ templateKey,
210
+ window: { from: from ?? null, to: to ?? null },
211
+ totals: {
212
+ sent,
213
+ delivered,
214
+ opened,
215
+ clicked,
216
+ bounced: Number(t.bounced),
217
+ complained: Number(t.complained),
218
+ deliveryRate: rate(delivered, sent),
219
+ openRate: rate(opened, openDenominator),
220
+ clickRate: rate(clicked, opened),
221
+ clickToDeliveryRate: rate(clicked, delivered),
222
+ },
223
+ series: seriesRows.map((row) => ({
224
+ date: row.date,
225
+ sent: Number(row.sent),
226
+ delivered: Number(row.delivered),
227
+ opened: Number(row.opened),
228
+ clicked: Number(row.clicked),
229
+ bounced: Number(row.bounced),
230
+ })),
231
+ },
232
+ 200,
233
+ );
234
+ })
235
+ .openapi(contactActivityRoute, async (c) => {
236
+ const { db } = c.get("container");
237
+ const { id } = c.req.valid("param");
238
+ const { limit, offset } = c.req.valid("query");
239
+
240
+ const contact = await resolveContact({ db, id });
241
+ if (!contact) {
242
+ return c.json({ error: "Contact not found" }, 404);
243
+ }
244
+
245
+ // Denormalized identity makes this single-table; fall back to the contact's
246
+ // email so journeyless sends still surface.
247
+ const idConds = [eq(emailSends.userId, contact.externalId)];
248
+ if (contact.email) idConds.push(eq(emailSends.userEmail, contact.email));
249
+ const where = or(...idConds);
250
+
251
+ const [rows, totalRows] = await Promise.all([
252
+ db
253
+ .select()
254
+ .from(emailSends)
255
+ .where(where)
256
+ .orderBy(desc(emailSends.createdAt))
257
+ .limit(limit)
258
+ .offset(offset),
259
+ db.select({ count: count() }).from(emailSends).where(where),
260
+ ]);
261
+
262
+ return c.json(
263
+ {
264
+ contact: { externalId: contact.externalId, email: contact.email },
265
+ sends: rows.map((row) => ({
266
+ id: row.id,
267
+ templateKey: row.templateKey,
268
+ subject: row.subject,
269
+ status: row.status,
270
+ sentAt: row.sentAt?.toISOString() ?? null,
271
+ deliveredAt: row.deliveredAt?.toISOString() ?? null,
272
+ openedAt: row.openedAt?.toISOString() ?? null,
273
+ clickedAt: row.clickedAt?.toISOString() ?? null,
274
+ bouncedAt: row.bouncedAt?.toISOString() ?? null,
275
+ complainedAt: row.complainedAt?.toISOString() ?? null,
276
+ bounceType: row.bounceType,
277
+ createdAt: row.createdAt.toISOString(),
278
+ })),
279
+ total: totalRows[0]?.count ?? 0,
280
+ limit,
281
+ offset,
282
+ },
283
+ 200,
284
+ );
285
+ });
286
+
287
+ // CSV export — plain route (non-JSON response) under the same auth as the rest
288
+ // of /v1/admin. Bounded to MAX_EXPORT_ROWS; same filters as GET /admin/emails.
289
+ reportingRouter.get("/sends/export", async (c) => {
290
+ const { db } = c.get("container");
291
+ const q = c.req.query();
292
+
293
+ const engagementColumn = {
294
+ opened: emailSends.openedAt,
295
+ clicked: emailSends.clickedAt,
296
+ bounced: emailSends.bouncedAt,
297
+ complained: emailSends.complainedAt,
298
+ } as const;
299
+
300
+ const conditions = [];
301
+ if (q.templateKey) conditions.push(eq(emailSends.templateKey, q.templateKey));
302
+ if (q.status)
303
+ conditions.push(
304
+ eq(emailSends.status, q.status as typeof emailSends.$inferSelect.status),
305
+ );
306
+ if (q.category) conditions.push(eq(emailSends.category, q.category));
307
+ if (q.userId) conditions.push(eq(emailSends.userId, q.userId));
308
+ if (q.engagement && q.engagement in engagementColumn)
309
+ conditions.push(
310
+ isNotNull(
311
+ engagementColumn[q.engagement as keyof typeof engagementColumn],
312
+ ),
313
+ );
314
+ if (q.from) conditions.push(gte(emailSends.createdAt, new Date(q.from)));
315
+ if (q.to) conditions.push(lte(emailSends.createdAt, new Date(q.to)));
316
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
317
+
318
+ const rows = await db
319
+ .select()
320
+ .from(emailSends)
321
+ .where(where)
322
+ .orderBy(desc(emailSends.createdAt))
323
+ .limit(MAX_EXPORT_ROWS);
324
+
325
+ const header = [
326
+ "id",
327
+ "createdAt",
328
+ "templateKey",
329
+ "status",
330
+ "toEmail",
331
+ "userId",
332
+ "subject",
333
+ "sentAt",
334
+ "deliveredAt",
335
+ "openedAt",
336
+ "clickedAt",
337
+ "bouncedAt",
338
+ "complainedAt",
339
+ "bounceType",
340
+ ];
341
+ const lines = [header.join(",")];
342
+ for (const r of rows) {
343
+ lines.push(
344
+ [
345
+ r.id,
346
+ r.createdAt,
347
+ r.templateKey,
348
+ r.status,
349
+ r.toEmail,
350
+ r.userId,
351
+ r.subject,
352
+ r.sentAt,
353
+ r.deliveredAt,
354
+ r.openedAt,
355
+ r.clickedAt,
356
+ r.bouncedAt,
357
+ r.complainedAt,
358
+ r.bounceType,
359
+ ]
360
+ .map(csvCell)
361
+ .join(","),
362
+ );
363
+ }
364
+
365
+ return c.body(lines.join("\n"), 200, {
366
+ "Content-Type": "text/csv; charset=utf-8",
367
+ "Content-Disposition": 'attachment; filename="email-sends.csv"',
368
+ });
369
+ });
@@ -0,0 +1,99 @@
1
+ import { emailPreferences } from "@hogsend/db";
2
+ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
3
+ import { and, count, desc, eq, gt, type SQL } from "drizzle-orm";
4
+ import type { AppEnv } from "../../app.js";
5
+ import { serializePrefs } from "../../lib/contacts.js";
6
+
7
+ // Maps the requested suppression type to a predicate over email_preferences.
8
+ // `complained` has no dedicated column — a complaint sets `suppressed` without
9
+ // incrementing `bounceCount` (see mailer `handleComplaint`), so we identify it
10
+ // as suppressed-but-not-bounced.
11
+ function typeFilter(
12
+ type: "bounced" | "unsubscribed" | "complained" | undefined,
13
+ ): SQL | undefined {
14
+ switch (type) {
15
+ case "bounced":
16
+ return gt(emailPreferences.bounceCount, 0);
17
+ case "unsubscribed":
18
+ return eq(emailPreferences.unsubscribedAll, true);
19
+ case "complained":
20
+ return and(
21
+ eq(emailPreferences.suppressed, true),
22
+ eq(emailPreferences.bounceCount, 0),
23
+ );
24
+ default:
25
+ return undefined;
26
+ }
27
+ }
28
+
29
+ const suppressionSchema = z.object({
30
+ id: z.string(),
31
+ userId: z.string(),
32
+ email: z.string(),
33
+ unsubscribedAll: z.boolean(),
34
+ suppressed: z.boolean(),
35
+ bounceCount: z.number(),
36
+ categories: z.record(z.string(), z.boolean()),
37
+ suppressedAt: z.string().nullable(),
38
+ lastBounceAt: z.string().nullable(),
39
+ });
40
+
41
+ const listSuppressionsRoute = createRoute({
42
+ method: "get",
43
+ path: "/",
44
+ tags: ["Admin — Suppressions"],
45
+ summary: "List suppressed / bounced / unsubscribed recipients",
46
+ request: {
47
+ query: z.object({
48
+ type: z.enum(["bounced", "unsubscribed", "complained"]).optional(),
49
+ limit: z.coerce.number().min(1).max(200).default(50),
50
+ offset: z.coerce.number().min(0).default(0),
51
+ }),
52
+ },
53
+ responses: {
54
+ 200: {
55
+ content: {
56
+ "application/json": {
57
+ schema: z.object({
58
+ suppressions: z.array(suppressionSchema),
59
+ total: z.number(),
60
+ limit: z.number(),
61
+ offset: z.number(),
62
+ }),
63
+ },
64
+ },
65
+ description: "Suppression list",
66
+ },
67
+ },
68
+ });
69
+
70
+ export const suppressionsRouter = new OpenAPIHono<AppEnv>().openapi(
71
+ listSuppressionsRoute,
72
+ async (c) => {
73
+ const { db } = c.get("container");
74
+ const { type, limit, offset } = c.req.valid("query");
75
+
76
+ const where = typeFilter(type);
77
+
78
+ const [rows, totalRows] = await Promise.all([
79
+ db
80
+ .select()
81
+ .from(emailPreferences)
82
+ .where(where)
83
+ .orderBy(desc(emailPreferences.updatedAt))
84
+ .limit(limit)
85
+ .offset(offset),
86
+ db.select({ count: count() }).from(emailPreferences).where(where),
87
+ ]);
88
+
89
+ return c.json(
90
+ {
91
+ suppressions: rows.map((row) => serializePrefs(row)),
92
+ total: totalRows[0]?.count ?? 0,
93
+ limit,
94
+ offset,
95
+ },
96
+ 200,
97
+ );
98
+ },
99
+ );