@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 +4 -4
- package/src/app.ts +37 -0
- package/src/container.ts +23 -1
- package/src/env.ts +4 -0
- package/src/index.ts +1 -0
- package/src/lib/auth.ts +8 -1
- package/src/lib/email-service-types.ts +6 -0
- package/src/lib/email.ts +2 -0
- package/src/lib/mailer.ts +9 -1
- package/src/lib/metrics-sql.ts +17 -0
- package/src/lib/studio.ts +105 -0
- package/src/lib/tracked.ts +4 -0
- package/src/middleware/rate-limit.ts +1 -1
- package/src/middleware/require-admin.ts +26 -0
- package/src/routes/admin/emails.ts +155 -9
- package/src/routes/admin/index.ts +8 -2
- package/src/routes/admin/metrics.ts +31 -23
- package/src/routes/admin/reporting.ts +369 -0
- package/src/routes/admin/suppressions.ts +99 -0
- package/src/routes/admin/templates.ts +298 -0
|
@@ -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("*",
|
|
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:
|
|
298
|
-
unsubscribeRate:
|
|
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:
|
|
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(
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
+
);
|