Skip to content
Open
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
91 changes: 91 additions & 0 deletions apps/web/app/api/v1/openapi/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ import {
replyTrackerQuerySchema,
replyTrackerResponseSchema,
} from "@/app/api/v1/reply-tracker/validation";
import {
summaryStatsQuerySchema,
summaryStatsResponseSchema,
statsByPeriodQuerySchema,
statsByPeriodResponseSchema,
newsletterStatsQuerySchema,
newsletterStatsResponseSchema,
dateRangeSchema,
emailActionsResponseSchema,
} from "@/app/api/v1/stats/validation";
import { API_KEY_HEADER } from "@/utils/api-auth";

extendZodWithOpenApi(z);
Expand Down Expand Up @@ -72,6 +82,87 @@ registry.registerPath({
},
});

// Stats endpoints
registry.registerPath({
method: "get",
path: "/stats/summary",
description: "Get email account summary statistics",
security: [{ ApiKeyAuth: [] }],
request: {
query: summaryStatsQuerySchema,
},
responses: {
200: {
description: "Summary statistics for email activity",
content: {
"application/json": {
schema: summaryStatsResponseSchema,
},
},
},
},
});

registry.registerPath({
method: "get",
path: "/stats/by-period",
description: "Get email statistics grouped by time period",
security: [{ ApiKeyAuth: [] }],
request: {
query: statsByPeriodQuerySchema,
},
responses: {
200: {
description: "Email statistics grouped by the specified period",
content: {
"application/json": {
schema: statsByPeriodResponseSchema,
},
},
},
},
});

registry.registerPath({
method: "get",
path: "/stats/newsletters",
description: "Get newsletter statistics",
security: [{ ApiKeyAuth: [] }],
request: {
query: newsletterStatsQuerySchema,
},
responses: {
200: {
description: "Newsletter sender statistics",
content: {
"application/json": {
schema: newsletterStatsResponseSchema,
},
},
},
},
});

registry.registerPath({
method: "get",
path: "/stats/email-actions",
description: "Get email actions (archived/deleted) statistics by day",
security: [{ ApiKeyAuth: [] }],
request: {
query: dateRangeSchema,
},
responses: {
200: {
description: "Email actions performed with Inbox Zero",
content: {
"application/json": {
schema: emailActionsResponseSchema,
},
},
},
},
});

export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const customHost = searchParams.get("host");
Expand Down
128 changes: 128 additions & 0 deletions apps/web/app/api/v1/stats/by-period/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { NextResponse } from "next/server";
import { withError } from "@/utils/middleware";
import { createScopedLogger } from "@/utils/logger";
import {
statsByPeriodQuerySchema,
type StatsByPeriodResponse,
} from "../validation";
import { validateApiKeyAndGetGmailClient } from "@/utils/api-auth";
import { getEmailAccountId } from "@/app/api/v1/helpers";
import prisma from "@/utils/prisma";
import { Prisma } from "@prisma/client";
import format from "date-fns/format";

const logger = createScopedLogger("api/v1/stats/by-period");

export const GET = withError(async (request) => {
const { userId, accountId } = await validateApiKeyAndGetGmailClient(request);

const { searchParams } = new URL(request.url);
const queryResult = statsByPeriodQuerySchema.safeParse(
Object.fromEntries(searchParams),
);

if (!queryResult.success) {
return NextResponse.json(
{ error: "Invalid query parameters" },
{ status: 400 },
);
}

const emailAccountId = await getEmailAccountId({
accountId,
userId,
});

if (!emailAccountId) {
return NextResponse.json(
{ error: "Email account not found" },
{ status: 400 },
);
}

try {
const { fromDate, toDate, period } = queryResult.data;

// Build date conditions
const dateConditions: Prisma.Sql[] = [];
if (fromDate) {
dateConditions.push(Prisma.sql`date >= ${new Date(fromDate)}`);
}
if (toDate) {
dateConditions.push(Prisma.sql`date <= ${new Date(toDate)}`);
}

const dateFormat =
period === "day"
? "YYYY-MM-DD"
: period === "week"
? "YYYY-WW"
: period === "month"
? "YYYY-MM"
: "YYYY";

// Query for periodic stats
type StatsResult = {
period_group: string;
totalCount: bigint;
inboxCount: bigint;
readCount: bigint;
sentCount: bigint;
unread: bigint;
};

const baseQuery = Prisma.sql`
SELECT
to_char(date, ${dateFormat}) as period_group,
SUM("totalCount") as "totalCount",
SUM("inboxCount") as "inboxCount",
SUM("readCount") as "readCount",
SUM("sentCount") as "sentCount",
SUM("totalCount" - "readCount") as unread
FROM "EmailStat"
WHERE "emailAccountId" = ${emailAccountId}
${dateConditions.length > 0 ? Prisma.sql`AND ${Prisma.join(dateConditions, " AND ")}` : Prisma.empty}
GROUP BY period_group
ORDER BY period_group DESC
`;

const stats = await prisma.$queryRaw<StatsResult[]>(baseQuery);

// Calculate summary
const summary = {
received: stats.reduce((sum, stat) => sum + Number(stat.totalCount), 0),
read: stats.reduce((sum, stat) => sum + Number(stat.readCount), 0),
archived: stats.reduce((sum, stat) => sum + Number(stat.totalCount) - Number(stat.inboxCount), 0),
sent: stats.reduce((sum, stat) => sum + Number(stat.sentCount), 0),
};

const response: StatsByPeriodResponse = {
stats: stats.map((stat) => ({
date: stat.period_group,
received: Number(stat.totalCount),
read: Number(stat.readCount),
archived: Number(stat.totalCount) - Number(stat.inboxCount),
sent: Number(stat.sentCount),
unread: Number(stat.unread),
})),
summary,
};

logger.info("Retrieved stats by period", {
userId,
emailAccountId,
period,
});

return NextResponse.json(response);
} catch (error) {
logger.error("Error retrieving stats by period", {
userId,
error,
});
return NextResponse.json(
{ error: "Failed to retrieve stats" },
{ status: 500 },
);
}
});
76 changes: 76 additions & 0 deletions apps/web/app/api/v1/stats/email-actions/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { NextResponse } from "next/server";
import { withError } from "@/utils/middleware";
import { createScopedLogger } from "@/utils/logger";
import {
dateRangeSchema,
type EmailActionsResponse,
} from "../validation";
import { validateApiKeyAndGetGmailClient } from "@/utils/api-auth";
import { getEmailAccountId } from "@/app/api/v1/helpers";
import { getEmailActionsByDay } from "@inboxzero/tinybird";

const logger = createScopedLogger("api/v1/stats/email-actions");

export const GET = withError(async (request) => {
const { userId, accountId } = await validateApiKeyAndGetGmailClient(request);

const { searchParams } = new URL(request.url);
const queryResult = dateRangeSchema.safeParse(
Object.fromEntries(searchParams),
);

if (!queryResult.success) {
return NextResponse.json(
{ error: "Invalid query parameters" },
{ status: 400 },
);
}

const emailAccountId = await getEmailAccountId({
accountId,
userId,
});

if (!emailAccountId) {
return NextResponse.json(
{ error: "Email account not found" },
{ status: 400 },
);
}

try {
const { fromDate, toDate } = queryResult.data;

// Get email actions from TinyBird
const result = await getEmailActionsByDay({
ownerEmail: emailAccountId,
fromDate: fromDate ? new Date(fromDate).getTime() : undefined,
toDate: toDate ? new Date(toDate).getTime() : undefined,
});

const response: EmailActionsResponse = {
actions: result.data.map((d) => ({
date: d.date,
archived: d.archive_count,
deleted: d.delete_count,
})),
};

logger.info("Retrieved email actions stats", {
userId,
emailAccountId,
count: response.actions.length,
});

return NextResponse.json(response);
} catch (error) {
logger.error("Error retrieving email actions stats", {
userId,
error,
});
return NextResponse.json(
{ error: "Failed to retrieve email actions stats" },
{ status: 500 },
);
}
});
Loading
Loading