Skip to content

Commit ce8b780

Browse files
authored
feat: add dashboard analytics to sdk and public api (#353)
1 parent 991fcab commit ce8b780

12 files changed

Lines changed: 555 additions & 112 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
---
2+
openapi: get /v1/analytics/email-time-series
3+
---
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
---
2+
openapi: get /v1/analytics/reputation-metrics
3+
---

apps/docs/api-reference/openapi.json

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2430,6 +2430,127 @@
24302430
}
24312431
}
24322432
}
2433+
},
2434+
"/v1/analytics/email-time-series": {
2435+
"get": {
2436+
"parameters": [
2437+
{
2438+
"schema": {
2439+
"type": "string",
2440+
"enum": ["7", "30"],
2441+
"example": "30"
2442+
},
2443+
"required": false,
2444+
"name": "days",
2445+
"in": "query",
2446+
"description": "Number of days to retrieve data for (default: 30)"
2447+
},
2448+
{
2449+
"schema": { "type": "string" },
2450+
"required": false,
2451+
"name": "domainId",
2452+
"in": "query",
2453+
"description": "Filter by domain ID"
2454+
}
2455+
],
2456+
"responses": {
2457+
"200": {
2458+
"description": "Retrieve email time series data",
2459+
"content": {
2460+
"application/json": {
2461+
"schema": {
2462+
"type": "object",
2463+
"properties": {
2464+
"result": {
2465+
"type": "array",
2466+
"items": {
2467+
"type": "object",
2468+
"properties": {
2469+
"date": { "type": "string" },
2470+
"sent": { "type": "integer" },
2471+
"delivered": { "type": "integer" },
2472+
"opened": { "type": "integer" },
2473+
"clicked": { "type": "integer" },
2474+
"bounced": { "type": "integer" },
2475+
"complained": { "type": "integer" }
2476+
},
2477+
"required": [
2478+
"date",
2479+
"sent",
2480+
"delivered",
2481+
"opened",
2482+
"clicked",
2483+
"bounced",
2484+
"complained"
2485+
]
2486+
}
2487+
},
2488+
"totalCounts": {
2489+
"type": "object",
2490+
"properties": {
2491+
"sent": { "type": "integer" },
2492+
"delivered": { "type": "integer" },
2493+
"opened": { "type": "integer" },
2494+
"clicked": { "type": "integer" },
2495+
"bounced": { "type": "integer" },
2496+
"complained": { "type": "integer" }
2497+
},
2498+
"required": [
2499+
"sent",
2500+
"delivered",
2501+
"opened",
2502+
"clicked",
2503+
"bounced",
2504+
"complained"
2505+
]
2506+
}
2507+
},
2508+
"required": ["result", "totalCounts"]
2509+
}
2510+
}
2511+
}
2512+
}
2513+
}
2514+
}
2515+
},
2516+
"/v1/analytics/reputation-metrics": {
2517+
"get": {
2518+
"parameters": [
2519+
{
2520+
"schema": { "type": "string" },
2521+
"required": false,
2522+
"name": "domainId",
2523+
"in": "query",
2524+
"description": "Filter by domain ID"
2525+
}
2526+
],
2527+
"responses": {
2528+
"200": {
2529+
"description": "Retrieve reputation metrics data",
2530+
"content": {
2531+
"application/json": {
2532+
"schema": {
2533+
"type": "object",
2534+
"properties": {
2535+
"delivered": { "type": "integer" },
2536+
"hardBounced": { "type": "integer" },
2537+
"complained": { "type": "integer" },
2538+
"bounceRate": { "type": "number" },
2539+
"complaintRate": { "type": "number" }
2540+
},
2541+
"required": [
2542+
"delivered",
2543+
"hardBounced",
2544+
"complained",
2545+
"bounceRate",
2546+
"complaintRate"
2547+
]
2548+
}
2549+
}
2550+
}
2551+
}
2552+
}
2553+
}
24332554
}
24342555
}
24352556
}

apps/docs/docs.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,13 @@
105105
"api-reference/campaigns/resume-campaign",
106106
"api-reference/campaigns/delete-campaign"
107107
]
108+
},
109+
{
110+
"group": "Analytics",
111+
"pages": [
112+
"api-reference/analytics/email-time-series",
113+
"api-reference/analytics/reputation-metrics"
114+
]
108115
}
109116
]
110117
},
Lines changed: 5 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
1-
import { Prisma } from "@prisma/client";
2-
import { format, subDays } from "date-fns";
31
import { z } from "zod";
4-
52
import { createTRPCRouter, teamProcedure } from "~/server/api/trpc";
6-
import { db } from "~/server/db";
3+
import { emailTimeSeries, reputationMetricsData } from "~/server/service/dashboard-service";
74

85
export const dashboardRouter = createTRPCRouter({
96
emailTimeSeries: teamProcedure
@@ -15,88 +12,10 @@ export const dashboardRouter = createTRPCRouter({
1512
)
1613
.query(async ({ ctx, input }) => {
1714
const { team } = ctx;
18-
const days = input.days !== 7 ? 30 : 7;
19-
20-
const startDate = new Date();
21-
startDate.setDate(startDate.getDate() - days);
22-
const isoStartDate = startDate.toISOString().split("T")[0];
23-
24-
type DailyEmailUsage = {
25-
date: string;
26-
sent: number;
27-
delivered: number;
28-
opened: number;
29-
clicked: number;
30-
bounced: number;
31-
complained: number;
32-
};
33-
34-
const result = await db.$queryRaw<Array<DailyEmailUsage>>`
35-
SELECT
36-
date,
37-
SUM(sent)::integer AS sent,
38-
SUM(delivered)::integer AS delivered,
39-
SUM(opened)::integer AS opened,
40-
SUM(clicked)::integer AS clicked,
41-
SUM(bounced)::integer AS bounced,
42-
SUM(complained)::integer AS complained
43-
FROM "DailyEmailUsage"
44-
WHERE "teamId" = ${team.id}
45-
AND "date" >= ${isoStartDate}
46-
${input.domain ? Prisma.sql`AND "domainId" = ${input.domain}` : Prisma.sql``}
47-
GROUP BY "date"
48-
ORDER BY "date" ASC
49-
`;
50-
51-
// Fill in any missing dates with 0 values
52-
const filledResult: DailyEmailUsage[] = [];
53-
const endDateObj = new Date();
5415

55-
for (let i = days; i > -1; i--) {
56-
const dateStr = subDays(endDateObj, i)
57-
.toISOString()
58-
.split("T")[0] as string;
59-
const existingData = result.find((r) => r.date === dateStr);
16+
const response = await emailTimeSeries({team, days: input.days, domain: input.domain})
6017

61-
if (existingData) {
62-
filledResult.push({
63-
...existingData,
64-
date: format(dateStr, "MMM dd"),
65-
});
66-
} else {
67-
filledResult.push({
68-
date: format(dateStr, "MMM dd"),
69-
sent: 0,
70-
delivered: 0,
71-
opened: 0,
72-
clicked: 0,
73-
bounced: 0,
74-
complained: 0,
75-
});
76-
}
77-
}
78-
79-
const totalCounts = result.reduce(
80-
(acc, curr) => {
81-
acc.sent += curr.sent;
82-
acc.delivered += curr.delivered;
83-
acc.opened += curr.opened;
84-
acc.clicked += curr.clicked;
85-
acc.bounced += curr.bounced;
86-
acc.complained += curr.complained;
87-
return acc;
88-
},
89-
{
90-
sent: 0,
91-
delivered: 0,
92-
opened: 0,
93-
clicked: 0,
94-
bounced: 0,
95-
complained: 0,
96-
}
97-
);
98-
99-
return { result: filledResult, totalCounts };
18+
return response
10019
}),
10120

10221
reputationMetricsData: teamProcedure
@@ -107,34 +26,8 @@ export const dashboardRouter = createTRPCRouter({
10726
)
10827
.query(async ({ ctx, input }) => {
10928
const { team } = ctx;
29+
const response = await reputationMetricsData({team, domain: input.domain})
11030

111-
const reputations = await db.cumulatedMetrics.findMany({
112-
where: {
113-
teamId: team.id,
114-
...(input.domain ? { domainId: input.domain } : {}),
115-
},
116-
});
117-
118-
const results = reputations.reduce(
119-
(acc, curr) => {
120-
acc.delivered += Number(curr.delivered);
121-
acc.hardBounced += Number(curr.hardBounced);
122-
acc.complained += Number(curr.complained);
123-
return acc;
124-
},
125-
{ delivered: 0, hardBounced: 0, complained: 0 }
126-
);
127-
128-
const resultWithRates = {
129-
...results,
130-
bounceRate: results.delivered
131-
? (results.hardBounced / results.delivered) * 100
132-
: 0,
133-
complaintRate: results.delivered
134-
? (results.complained / results.delivered) * 100
135-
: 0,
136-
};
137-
138-
return resultWithRates;
31+
return response;
13932
}),
14033
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { createRoute, z } from "@hono/zod-openapi";
2+
import { PublicAPIApp } from "~/server/public-api/hono";
3+
import { emailTimeSeries as emailTimeSeriesService } from "~/server/service/dashboard-service";
4+
5+
const route = createRoute({
6+
method: "get",
7+
path: "/v1/analytics/email-time-series",
8+
request: {
9+
query: z.object({
10+
days: z.enum(["7", "30"]).optional().openapi({
11+
description: "Number of days to retrieve data for (default: 30)",
12+
example: "30",
13+
}),
14+
domainId: z.string().optional().openapi({
15+
description: "Filter by domain ID",
16+
}),
17+
}),
18+
},
19+
responses: {
20+
200: {
21+
description: "Retrieve email time series data",
22+
content: {
23+
"application/json": {
24+
schema: z.object({
25+
result: z.array(
26+
z.object({
27+
date: z.string(),
28+
sent: z.number().int(),
29+
delivered: z.number().int(),
30+
opened: z.number().int(),
31+
clicked: z.number().int(),
32+
bounced: z.number().int(),
33+
complained: z.number().int(),
34+
})
35+
),
36+
totalCounts: z.object({
37+
sent: z.number().int(),
38+
delivered: z.number().int(),
39+
opened: z.number().int(),
40+
clicked: z.number().int(),
41+
bounced: z.number().int(),
42+
complained: z.number().int(),
43+
}),
44+
}),
45+
},
46+
},
47+
},
48+
},
49+
});
50+
51+
function emailTimeSeries(app: PublicAPIApp) {
52+
app.openapi(route, async (c) => {
53+
const team = c.var.team;
54+
const daysParam = c.req.query("days");
55+
const domainIdParam = c.req.query("domainId");
56+
57+
const days = daysParam ? Number(daysParam) : undefined;
58+
const domain =
59+
team.apiKey.domainId ??
60+
(domainIdParam ? Number(domainIdParam) : undefined);
61+
62+
const data = await emailTimeSeriesService({ days, domain, team });
63+
64+
return c.json(data);
65+
});
66+
}
67+
68+
export default emailTimeSeries;

0 commit comments

Comments
 (0)