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
+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;
}