@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.
@@ -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
+ );