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/resume-campaign",
"api-reference/campaigns/delete-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 { z } from "zod";
import { createTRPCRouter, teamProcedure } from "~/server/api/trpc"; import { createTRPCRouter, teamProcedure } from "~/server/api/trpc";
import { db } from "~/server/db"; import { emailTimeSeries, reputationMetricsData } from "~/server/service/dashboard-service";
export const dashboardRouter = createTRPCRouter({ export const dashboardRouter = createTRPCRouter({
emailTimeSeries: teamProcedure emailTimeSeries: teamProcedure
@@ -15,88 +12,10 @@ export const dashboardRouter = createTRPCRouter({
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const { team } = ctx; const { team } = ctx;
const days = input.days !== 7 ? 30 : 7;
const startDate = new Date(); const response = await emailTimeSeries({team, days: input.days, domain: input.domain})
startDate.setDate(startDate.getDate() - days);
const isoStartDate = startDate.toISOString().split("T")[0];
type DailyEmailUsage = { return response
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 };
}), }),
reputationMetricsData: teamProcedure reputationMetricsData: teamProcedure
@@ -107,34 +26,8 @@ export const dashboardRouter = createTRPCRouter({
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const { team } = ctx; const { team } = ctx;
const response = await reputationMetricsData({team, domain: input.domain})
const reputations = await db.cumulatedMetrics.findMany({ return response;
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;
}), }),
}); });
@@ -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 getContactBook from "./api/contacts/get-contact-book";
import updateContactBook from "./api/contacts/update-contact-book"; import updateContactBook from "./api/contacts/update-contact-book";
import deleteContactBook from "./api/contacts/delete-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 bulkAddContactsHandle from "./api/contacts/bulk-add-contacts";
import bulkDeleteContacts from "./api/contacts/bulk-delete-contacts"; import bulkDeleteContacts from "./api/contacts/bulk-delete-contacts";
export const app = getApp(); export const app = getApp();
/**Domain related APIs */ /**Domain related APIs */
@@ -74,4 +77,8 @@ pauseCampaignHandle(app);
resumeCampaignHandle(app); resumeCampaignHandle(app);
deleteCampaignHandle(app); deleteCampaignHandle(app);
/**Analytics related APIs */
emailTimeSeries(app);
reputationMetricsData(app);
export default 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;
}
+56
View File
@@ -0,0 +1,56 @@
import { paths } from "../types/schema";
import { ErrorResponse } from "../types";
import { UseSend } from "./usesend";
type EmailTimeSeriesQuery =
paths["/v1/analytics/email-time-series"]["get"]["parameters"]["query"];
type EmailTimeSeriesResponseSuccess =
paths["/v1/analytics/email-time-series"]["get"]["responses"]["200"]["content"]["application/json"];
type EmailTimeSeriesResponse = {
data: EmailTimeSeriesResponseSuccess | null;
error: ErrorResponse | null;
};
type ReputationMetricsQuery =
paths["/v1/analytics/reputation-metrics"]["get"]["parameters"]["query"];
type ReputationMetricsResponseSuccess =
paths["/v1/analytics/reputation-metrics"]["get"]["responses"]["200"]["content"]["application/json"];
type ReputationMetricsResponse = {
data: ReputationMetricsResponseSuccess | null;
error: ErrorResponse | null;
};
export class Analytics {
constructor(private readonly usesend: UseSend) {
this.usesend = usesend;
}
async emailTimeSeries(
query?: EmailTimeSeriesQuery,
): Promise<EmailTimeSeriesResponse> {
const params = new URLSearchParams();
if (query?.days) params.set("days", query.days);
if (query?.domainId) params.set("domainId", query.domainId);
const qs = params.toString();
const path = `/analytics/email-time-series${qs ? `?${qs}` : ""}`;
return this.usesend.get<EmailTimeSeriesResponseSuccess>(path);
}
async reputationMetrics(
query?: ReputationMetricsQuery,
): Promise<ReputationMetricsResponse> {
const params = new URLSearchParams();
if (query?.domainId) params.set("domainId", query.domainId);
const qs = params.toString();
const path = `/analytics/reputation-metrics${qs ? `?${qs}` : ""}`;
return this.usesend.get<ReputationMetricsResponseSuccess>(path);
}
}
+2
View File
@@ -4,6 +4,7 @@ import { ContactBooks } from "./contactBook";
import { Emails } from "./email"; import { Emails } from "./email";
import { Domains } from "./domain"; import { Domains } from "./domain";
import { Campaigns } from "./campaign"; import { Campaigns } from "./campaign";
import { Analytics } from "./analytics";
import { Webhooks } from "./webhooks"; import { Webhooks } from "./webhooks";
const defaultBaseUrl = "https://app.usesend.com"; const defaultBaseUrl = "https://app.usesend.com";
@@ -26,6 +27,7 @@ export class UseSend {
readonly contacts = new Contacts(this); readonly contacts = new Contacts(this);
readonly contactBooks = new ContactBooks(this); readonly contactBooks = new ContactBooks(this);
readonly campaigns = new Campaigns(this); readonly campaigns = new Campaigns(this);
readonly analytics = new Analytics(this);
url = baseUrl; url = baseUrl;
constructor( constructor(
+102
View File
@@ -1715,6 +1715,108 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/v1/analytics/email-time-series": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: {
/** @description Number of days to retrieve data for (default: 30) */
days?: "7" | "30";
/** @description Filter by domain ID */
domainId?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Retrieve email time series data */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
result: {
date: string;
sent: number;
delivered: number;
opened: number;
clicked: number;
bounced: number;
complained: number;
}[];
totalCounts: {
sent: number;
delivered: number;
opened: number;
clicked: number;
bounced: number;
complained: number;
};
};
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/analytics/reputation-metrics": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: {
/** @description Filter by domain ID */
domainId?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Retrieve reputation metrics data */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
delivered: number;
hardBounced: number;
complained: number;
bounceRate: number;
complaintRate: number;
};
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
} }
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {