Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/docs/api-reference/analytics/email-time-series.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
---
openapi: get /v1/analytics/email-time-series
---
3 changes: 3 additions & 0 deletions apps/docs/api-reference/analytics/reputation-metrics.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
---
openapi: get /v1/analytics/reputation-metrics
---
121 changes: 121 additions & 0 deletions apps/docs/api-reference/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 7 additions & 0 deletions apps/docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
]
},
Expand Down
117 changes: 5 additions & 112 deletions apps/web/src/server/api/routers/dashboard.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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];

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);
const response = await emailTimeSeries({team, days: input.days, domain: input.domain})

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
Expand All @@ -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;
}),
});
68 changes: 68 additions & 0 deletions apps/web/src/server/public-api/api/analytics/email-time-series.ts
Original file line number Diff line number Diff line change
@@ -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;
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: domainId is validated only as a string, but the handler converts raw query params with Number(), which can yield NaN and pass it to the service. Use validated query data and/or coerce domainId to a number in the schema to prevent NaN inputs.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/server/public-api/api/analytics/email-time-series.ts, line 57:

<comment>domainId is validated only as a string, but the handler converts raw query params with Number(), which can yield NaN and pass it to the service. Use validated query data and/or coerce domainId to a number in the schema to prevent NaN inputs.</comment>

<file context>
@@ -0,0 +1,68 @@
+    const daysParam = c.req.query("days");
+    const domainIdParam = c.req.query("domainId");
+
+    const days = daysParam ? Number(daysParam) : undefined;
+    const domain =
+      team.apiKey.domainId ??
</file context>
Fix with Cubic

const domain =
team.apiKey.domainId ??
(domainIdParam ? Number(domainIdParam) : undefined);

const data = await emailTimeSeriesService({ days, domain, team });

return c.json(data);
});
}

export default emailTimeSeries;
Loading