feat: add dashboard analytics to sdk and public api (#353)

This commit is contained in:
Dave Stockley
2026-03-04 11:06:21 +00:00
committed by GitHub
parent 991fcab764
commit ce8b780155
12 changed files with 555 additions and 112 deletions
@@ -0,0 +1,3 @@
---
openapi: get /v1/analytics/email-time-series
---
@@ -0,0 +1,3 @@
---
openapi: get /v1/analytics/reputation-metrics
---
+121
View File
@@ -2430,6 +2430,127 @@
}
}
}
},
"/v1/analytics/email-time-series": {
"get": {
"parameters": [
{
"schema": {
"type": "string",
"enum": ["7", "30"],
"example": "30"
},
"required": false,
"name": "days",
"in": "query",
"description": "Number of days to retrieve data for (default: 30)"
},
{
"schema": { "type": "string" },
"required": false,
"name": "domainId",
"in": "query",
"description": "Filter by domain ID"
}
],
"responses": {
"200": {
"description": "Retrieve email time series data",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"result": {
"type": "array",
"items": {
"type": "object",
"properties": {
"date": { "type": "string" },
"sent": { "type": "integer" },
"delivered": { "type": "integer" },
"opened": { "type": "integer" },
"clicked": { "type": "integer" },
"bounced": { "type": "integer" },
"complained": { "type": "integer" }
},
"required": [
"date",
"sent",
"delivered",
"opened",
"clicked",
"bounced",
"complained"
]
}
},
"totalCounts": {
"type": "object",
"properties": {
"sent": { "type": "integer" },
"delivered": { "type": "integer" },
"opened": { "type": "integer" },
"clicked": { "type": "integer" },
"bounced": { "type": "integer" },
"complained": { "type": "integer" }
},
"required": [
"sent",
"delivered",
"opened",
"clicked",
"bounced",
"complained"
]
}
},
"required": ["result", "totalCounts"]
}
}
}
}
}
}
},
"/v1/analytics/reputation-metrics": {
"get": {
"parameters": [
{
"schema": { "type": "string" },
"required": false,
"name": "domainId",
"in": "query",
"description": "Filter by domain ID"
}
],
"responses": {
"200": {
"description": "Retrieve reputation metrics data",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"delivered": { "type": "integer" },
"hardBounced": { "type": "integer" },
"complained": { "type": "integer" },
"bounceRate": { "type": "number" },
"complaintRate": { "type": "number" }
},
"required": [
"delivered",
"hardBounced",
"complained",
"bounceRate",
"complaintRate"
]
}
}
}
}
}
}
}
}
}
+7
View File
@@ -105,6 +105,13 @@
"api-reference/campaigns/resume-campaign",
"api-reference/campaigns/delete-campaign"
]
},
{
"group": "Analytics",
"pages": [
"api-reference/analytics/email-time-series",
"api-reference/analytics/reputation-metrics"
]
}
]
},
+5 -112
View File
@@ -1,9 +1,6 @@
import { Prisma } from "@prisma/client";
import { format, subDays } from "date-fns";
import { z } from "zod";
import { createTRPCRouter, teamProcedure } from "~/server/api/trpc";
import { db } from "~/server/db";
import { emailTimeSeries, reputationMetricsData } from "~/server/service/dashboard-service";
export const dashboardRouter = createTRPCRouter({
emailTimeSeries: teamProcedure
@@ -15,88 +12,10 @@ export const dashboardRouter = createTRPCRouter({
)
.query(async ({ ctx, input }) => {
const { team } = ctx;
const days = input.days !== 7 ? 30 : 7;
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
const isoStartDate = startDate.toISOString().split("T")[0];
const response = await emailTimeSeries({team, days: input.days, domain: input.domain})
type DailyEmailUsage = {
date: string;
sent: number;
delivered: number;
opened: number;
clicked: number;
bounced: number;
complained: number;
};
const result = await db.$queryRaw<Array<DailyEmailUsage>>`
SELECT
date,
SUM(sent)::integer AS sent,
SUM(delivered)::integer AS delivered,
SUM(opened)::integer AS opened,
SUM(clicked)::integer AS clicked,
SUM(bounced)::integer AS bounced,
SUM(complained)::integer AS complained
FROM "DailyEmailUsage"
WHERE "teamId" = ${team.id}
AND "date" >= ${isoStartDate}
${input.domain ? Prisma.sql`AND "domainId" = ${input.domain}` : Prisma.sql``}
GROUP BY "date"
ORDER BY "date" ASC
`;
// Fill in any missing dates with 0 values
const filledResult: DailyEmailUsage[] = [];
const endDateObj = new Date();
for (let i = days; i > -1; i--) {
const dateStr = subDays(endDateObj, i)
.toISOString()
.split("T")[0] as string;
const existingData = result.find((r) => r.date === dateStr);
if (existingData) {
filledResult.push({
...existingData,
date: format(dateStr, "MMM dd"),
});
} else {
filledResult.push({
date: format(dateStr, "MMM dd"),
sent: 0,
delivered: 0,
opened: 0,
clicked: 0,
bounced: 0,
complained: 0,
});
}
}
const totalCounts = result.reduce(
(acc, curr) => {
acc.sent += curr.sent;
acc.delivered += curr.delivered;
acc.opened += curr.opened;
acc.clicked += curr.clicked;
acc.bounced += curr.bounced;
acc.complained += curr.complained;
return acc;
},
{
sent: 0,
delivered: 0,
opened: 0,
clicked: 0,
bounced: 0,
complained: 0,
}
);
return { result: filledResult, totalCounts };
return response
}),
reputationMetricsData: teamProcedure
@@ -107,34 +26,8 @@ export const dashboardRouter = createTRPCRouter({
)
.query(async ({ ctx, input }) => {
const { team } = ctx;
const response = await reputationMetricsData({team, domain: input.domain})
const reputations = await db.cumulatedMetrics.findMany({
where: {
teamId: team.id,
...(input.domain ? { domainId: input.domain } : {}),
},
});
const results = reputations.reduce(
(acc, curr) => {
acc.delivered += Number(curr.delivered);
acc.hardBounced += Number(curr.hardBounced);
acc.complained += Number(curr.complained);
return acc;
},
{ delivered: 0, hardBounced: 0, complained: 0 }
);
const resultWithRates = {
...results,
bounceRate: results.delivered
? (results.hardBounced / results.delivered) * 100
: 0,
complaintRate: results.delivered
? (results.complained / results.delivered) * 100
: 0,
};
return resultWithRates;
return response;
}),
});
@@ -0,0 +1,68 @@
import { createRoute, z } from "@hono/zod-openapi";
import { PublicAPIApp } from "~/server/public-api/hono";
import { emailTimeSeries as emailTimeSeriesService } from "~/server/service/dashboard-service";
const route = createRoute({
method: "get",
path: "/v1/analytics/email-time-series",
request: {
query: z.object({
days: z.enum(["7", "30"]).optional().openapi({
description: "Number of days to retrieve data for (default: 30)",
example: "30",
}),
domainId: z.string().optional().openapi({
description: "Filter by domain ID",
}),
}),
},
responses: {
200: {
description: "Retrieve email time series data",
content: {
"application/json": {
schema: z.object({
result: z.array(
z.object({
date: z.string(),
sent: z.number().int(),
delivered: z.number().int(),
opened: z.number().int(),
clicked: z.number().int(),
bounced: z.number().int(),
complained: z.number().int(),
})
),
totalCounts: z.object({
sent: z.number().int(),
delivered: z.number().int(),
opened: z.number().int(),
clicked: z.number().int(),
bounced: z.number().int(),
complained: z.number().int(),
}),
}),
},
},
},
},
});
function emailTimeSeries(app: PublicAPIApp) {
app.openapi(route, async (c) => {
const team = c.var.team;
const daysParam = c.req.query("days");
const domainIdParam = c.req.query("domainId");
const days = daysParam ? Number(daysParam) : undefined;
const domain =
team.apiKey.domainId ??
(domainIdParam ? Number(domainIdParam) : undefined);
const data = await emailTimeSeriesService({ days, domain, team });
return c.json(data);
});
}
export default emailTimeSeries;
@@ -0,0 +1,48 @@
import { createRoute, z } from "@hono/zod-openapi";
import { PublicAPIApp } from "~/server/public-api/hono";
import { reputationMetricsData as reputationMetricsDataService } from "~/server/service/dashboard-service";
const route = createRoute({
method: "get",
path: "/v1/analytics/reputation-metrics",
request: {
query: z.object({
domainId: z.string().optional().openapi({
description: "Filter by domain ID",
}),
}),
},
responses: {
200: {
description: "Retrieve reputation metrics data",
content: {
"application/json": {
schema: z.object({
delivered: z.number().int(),
hardBounced: z.number().int(),
complained: z.number().int(),
bounceRate: z.number(),
complaintRate: z.number(),
}),
},
},
},
},
});
function reputationMetricsData(app: PublicAPIApp) {
app.openapi(route, async (c) => {
const team = c.var.team;
const domainIdParam = c.req.query("domainId");
const domain =
team.apiKey.domainId ??
(domainIdParam ? Number(domainIdParam) : undefined);
const data = await reputationMetricsDataService({ domain, team });
return c.json(data);
});
}
export default reputationMetricsData;
+7
View File
@@ -28,9 +28,12 @@ import createContactBook from "./api/contacts/create-contact-book";
import getContactBook from "./api/contacts/get-contact-book";
import updateContactBook from "./api/contacts/update-contact-book";
import deleteContactBook from "./api/contacts/delete-contact-book";
import emailTimeSeries from "./api/analytics/email-time-series";
import reputationMetricsData from "./api/analytics/reputation-metrics-data";
import bulkAddContactsHandle from "./api/contacts/bulk-add-contacts";
import bulkDeleteContacts from "./api/contacts/bulk-delete-contacts";
export const app = getApp();
/**Domain related APIs */
@@ -74,4 +77,8 @@ pauseCampaignHandle(app);
resumeCampaignHandle(app);
deleteCampaignHandle(app);
/**Analytics related APIs */
emailTimeSeries(app);
reputationMetricsData(app);
export default app;
@@ -0,0 +1,133 @@
import { db } from "~/server/db";
import { format, subDays } from "date-fns";
import { Prisma, Team } from "@prisma/client";
type EmailTimeSeries = {
days?: number;
domain?: number
team: Team
};
export async function emailTimeSeries(input: EmailTimeSeries) {
const days = input.days !== 7 ? 30 : 7;
const { domain, team } = input
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
const isoStartDate = startDate.toISOString().split("T")[0];
type DailyEmailUsage = {
date: string;
sent: number;
delivered: number;
opened: number;
clicked: number;
bounced: number;
complained: number;
};
const result = await db.$queryRaw<Array<DailyEmailUsage>>`
SELECT
date,
SUM(sent)::integer AS sent,
SUM(delivered)::integer AS delivered,
SUM(opened)::integer AS opened,
SUM(clicked)::integer AS clicked,
SUM(bounced)::integer AS bounced,
SUM(complained)::integer AS complained
FROM "DailyEmailUsage"
WHERE "teamId" = ${team.id}
AND "date" >= ${isoStartDate}
${domain ? Prisma.sql`AND "domainId" = ${domain}` : Prisma.sql``}
GROUP BY "date"
ORDER BY "date" ASC
`;
// Fill in any missing dates with 0 values
const filledResult: DailyEmailUsage[] = [];
const endDateObj = new Date();
for (let i = days; i > -1; i--) {
const dateStr = subDays(endDateObj, i)
.toISOString()
.split("T")[0] as string;
const existingData = result.find((r) => r.date === dateStr);
if (existingData) {
filledResult.push({
...existingData,
date: format(dateStr, "MMM dd"),
});
} else {
filledResult.push({
date: format(dateStr, "MMM dd"),
sent: 0,
delivered: 0,
opened: 0,
clicked: 0,
bounced: 0,
complained: 0,
});
}
}
const totalCounts = result.reduce(
(acc, curr) => {
acc.sent += curr.sent;
acc.delivered += curr.delivered;
acc.opened += curr.opened;
acc.clicked += curr.clicked;
acc.bounced += curr.bounced;
acc.complained += curr.complained;
return acc;
},
{
sent: 0,
delivered: 0,
opened: 0,
clicked: 0,
bounced: 0,
complained: 0,
}
);
return { result: filledResult, totalCounts };
}
type ReputationMetricsData = {
domain?: number
team: Team
};
export async function reputationMetricsData(input: ReputationMetricsData) {
const { domain, team } = input
const reputations = await db.cumulatedMetrics.findMany({
where: {
teamId: team.id,
...(domain ? { domainId: domain } : {}),
},
});
const results = reputations.reduce(
(acc, curr) => {
acc.delivered += Number(curr.delivered);
acc.hardBounced += Number(curr.hardBounced);
acc.complained += Number(curr.complained);
return acc;
},
{ delivered: 0, hardBounced: 0, complained: 0 }
);
const resultWithRates = {
...results,
bounceRate: results.delivered
? (results.hardBounced / results.delivered) * 100
: 0,
complaintRate: results.delivered
? (results.complained / results.delivered) * 100
: 0,
};
return resultWithRates;
}