diff --git a/apps/web/app/(app)/[emailAccountId]/stats/MainStatChart.tsx b/apps/web/app/(app)/[emailAccountId]/stats/MainStatChart.tsx index 9db311c2ae..914d198f96 100644 --- a/apps/web/app/(app)/[emailAccountId]/stats/MainStatChart.tsx +++ b/apps/web/app/(app)/[emailAccountId]/stats/MainStatChart.tsx @@ -4,7 +4,7 @@ import * as React from "react"; import { parse, format } from "date-fns"; import { Card, CardContent } from "@/components/ui/card"; import type { ChartConfig } from "@/components/ui/chart"; -import type { StatsByWeekResponse } from "@/app/api/user/stats/by-period/route"; +import type { StatsByPeriodResponse } from "@/app/api/user/stats/by-period/controller"; import { BarChart } from "@/app/(app)/[emailAccountId]/stats/BarChart"; import { COLORS } from "@/utils/colors"; @@ -26,7 +26,7 @@ function getActiveChart(activChart: keyof typeof chartConfig): string[] { } export function MainStatChart(props: { - data: StatsByWeekResponse; + data: StatsByPeriodResponse; period: "day" | "week" | "month" | "year"; }) { const [activeChart, setActiveChart] = diff --git a/apps/web/app/(app)/[emailAccountId]/stats/ResponseTimeAnalytics.tsx b/apps/web/app/(app)/[emailAccountId]/stats/ResponseTimeAnalytics.tsx index 5d28ca7cdc..b33a946648 100644 --- a/apps/web/app/(app)/[emailAccountId]/stats/ResponseTimeAnalytics.tsx +++ b/apps/web/app/(app)/[emailAccountId]/stats/ResponseTimeAnalytics.tsx @@ -13,10 +13,8 @@ import { BarChart } from "./BarChart"; import type { ChartConfig } from "@/components/ui/chart"; import { COLORS } from "@/utils/colors"; import { cn } from "@/utils"; -import type { - GetResponseTimeResponse, - ResponseTimeParams, -} from "@/app/api/user/stats/response-time/route"; +import type { ResponseTimeQuery } from "@/app/api/user/stats/response-time/validation"; +import type { ResponseTimeResponse } from "@/app/api/user/stats/response-time/controller"; import { isDefined } from "@/utils/types"; import { pluralize } from "@/utils/string"; @@ -29,9 +27,9 @@ export function ResponseTimeAnalytics({ dateRange, refreshInterval, }: ResponseTimeAnalyticsProps) { - const params: ResponseTimeParams = getDateRangeParams(dateRange); + const params: ResponseTimeQuery = getDateRangeParams(dateRange); - const { data, isLoading, error } = useOrgSWR( + const { data, isLoading, error } = useOrgSWR( `/api/user/stats/response-time?${new URLSearchParams(params as Record)}`, { refreshInterval }, ); diff --git a/apps/web/app/(app)/[emailAccountId]/stats/StatsSummary.tsx b/apps/web/app/(app)/[emailAccountId]/stats/StatsSummary.tsx index 108fa81d1d..7becab4c37 100644 --- a/apps/web/app/(app)/[emailAccountId]/stats/StatsSummary.tsx +++ b/apps/web/app/(app)/[emailAccountId]/stats/StatsSummary.tsx @@ -4,10 +4,8 @@ import type { DateRange } from "react-day-picker"; import { useOrgSWR } from "@/hooks/useOrgSWR"; import { LoadingContent } from "@/components/LoadingContent"; import { Skeleton } from "@/components/ui/skeleton"; -import type { - StatsByWeekParams, - StatsByWeekResponse, -} from "@/app/api/user/stats/by-period/route"; +import type { StatsByPeriodQuery } from "@/app/api/user/stats/by-period/validation"; +import type { StatsByPeriodResponse } from "@/app/api/user/stats/by-period/controller"; import { getDateRangeParams } from "./params"; import { MainStatChart } from "@/app/(app)/[emailAccountId]/stats/MainStatChart"; @@ -18,13 +16,13 @@ export function StatsSummary(props: { }) { const { dateRange, period } = props; - const params: StatsByWeekParams = { + const params: StatsByPeriodQuery = { period, ...getDateRangeParams(dateRange), }; const { data, isLoading, error } = useOrgSWR< - StatsByWeekResponse, + StatsByPeriodResponse, { error: string } >( `/api/user/stats/by-period?${new URLSearchParams( diff --git a/apps/web/app/api/user/stats/by-period/controller.ts b/apps/web/app/api/user/stats/by-period/controller.ts new file mode 100644 index 0000000000..119ef8f242 --- /dev/null +++ b/apps/web/app/api/user/stats/by-period/controller.ts @@ -0,0 +1,96 @@ +import { format } from "date-fns/format"; +import sumBy from "lodash/sumBy"; +import prisma from "@/utils/prisma"; +import { Prisma } from "@/generated/prisma/client"; +import type { StatsByPeriodQuery } from "@/app/api/user/stats/by-period/validation"; + +export type StatsByPeriodResponse = Awaited< + ReturnType +>; + +async function getEmailStatsByPeriod( + options: StatsByPeriodQuery & { emailAccountId: string }, +) { + const { period, fromDate, toDate, emailAccountId } = options; + + // Build date conditions without starting with AND + const dateConditions: Prisma.Sql[] = []; + if (fromDate) { + dateConditions.push(Prisma.sql`date >= ${new Date(fromDate)}`); + } + if (toDate) { + dateConditions.push(Prisma.sql`date <= ${new Date(toDate)}`); + } + + // Using raw query with properly typed parameters + type StatsResult = { + startOfPeriod: Date; + totalCount: bigint; + inboxCount: bigint; + readCount: bigint; + sentCount: bigint; + unread: bigint; + notInbox: bigint; + }; + + // Create WHERE clause properly + const whereClause = Prisma.sql`WHERE "emailAccountId" = ${emailAccountId}`; + const dateClause = + dateConditions.length > 0 + ? Prisma.sql` AND ${Prisma.join(dateConditions, " AND ")}` + : Prisma.sql``; + + // Convert period and dateFormat to string literals in PostgreSQL + return prisma.$queryRaw` + SELECT + DATE_TRUNC(${Prisma.raw(`'${period}'`)}, date) AS "startOfPeriod", + COUNT(*) AS "totalCount", + SUM(CASE WHEN inbox = true THEN 1 ELSE 0 END) AS "inboxCount", + SUM(CASE WHEN inbox = false THEN 1 ELSE 0 END) AS "notInbox", + SUM(CASE WHEN read = true THEN 1 ELSE 0 END) AS "readCount", + SUM(CASE WHEN read = false THEN 1 ELSE 0 END) AS unread, + SUM(CASE WHEN sent = true THEN 1 ELSE 0 END) AS "sentCount" + FROM "EmailMessage" + ${whereClause}${dateClause} + GROUP BY "startOfPeriod" + ORDER BY "startOfPeriod" + `; +} + +export async function getStatsByPeriod( + options: StatsByPeriodQuery & { + emailAccountId: string; + }, +) { + // Get all stats in a single query + const stats = await getEmailStatsByPeriod(options); + + // Transform stats to match the expected format + const formattedStats = stats.map((stat) => { + const startOfPeriodFormatted = format(stat.startOfPeriod, "LLL dd, y"); + + return { + startOfPeriod: startOfPeriodFormatted, + All: Number(stat.totalCount), + Sent: Number(stat.sentCount), + Read: Number(stat.readCount), + Unread: Number(stat.unread), + Unarchived: Number(stat.inboxCount), + Archived: Number(stat.notInbox), + }; + }); + + // Calculate totals + const totalAll = sumBy(stats, (stat) => Number(stat.totalCount)); + const totalInbox = sumBy(stats, (stat) => Number(stat.inboxCount)); + const totalRead = sumBy(stats, (stat) => Number(stat.readCount)); + const totalSent = sumBy(stats, (stat) => Number(stat.sentCount)); + + return { + result: formattedStats, + allCount: totalAll, + inboxCount: totalInbox, + readCount: totalRead, + sentCount: totalSent, + }; +} diff --git a/apps/web/app/api/user/stats/by-period/route.ts b/apps/web/app/api/user/stats/by-period/route.ts index a84c536b85..69804150cc 100644 --- a/apps/web/app/api/user/stats/by-period/route.ts +++ b/apps/web/app/api/user/stats/by-period/route.ts @@ -1,113 +1,14 @@ import { NextResponse } from "next/server"; -import { format } from "date-fns/format"; -import { z } from "zod"; -import sumBy from "lodash/sumBy"; -import { zodPeriod } from "@inboxzero/tinybird"; import { withEmailAccount } from "@/utils/middleware"; -import prisma from "@/utils/prisma"; -import { Prisma } from "@/generated/prisma/client"; - -const statsByWeekParams = z.object({ - period: zodPeriod, - fromDate: z.coerce.number().nullish(), - toDate: z.coerce.number().nullish(), -}); -export type StatsByWeekParams = z.infer; -export type StatsByWeekResponse = Awaited>; - -async function getEmailStatsByPeriod( - options: StatsByWeekParams & { emailAccountId: string }, -) { - const { period, fromDate, toDate, emailAccountId } = options; - - // Build date conditions without starting with AND - const dateConditions: Prisma.Sql[] = []; - if (fromDate) { - dateConditions.push(Prisma.sql`date >= ${new Date(fromDate)}`); - } - if (toDate) { - dateConditions.push(Prisma.sql`date <= ${new Date(toDate)}`); - } - - // Using raw query with properly typed parameters - type StatsResult = { - startOfPeriod: Date; - totalCount: bigint; - inboxCount: bigint; - readCount: bigint; - sentCount: bigint; - unread: bigint; - notInbox: bigint; - }; - - // Create WHERE clause properly - const whereClause = Prisma.sql`WHERE "emailAccountId" = ${emailAccountId}`; - const dateClause = - dateConditions.length > 0 - ? Prisma.sql` AND ${Prisma.join(dateConditions, " AND ")}` - : Prisma.sql``; - - // Convert period and dateFormat to string literals in PostgreSQL - return prisma.$queryRaw` - SELECT - DATE_TRUNC(${Prisma.raw(`'${period}'`)}, date) AS "startOfPeriod", - COUNT(*) AS "totalCount", - SUM(CASE WHEN inbox = true THEN 1 ELSE 0 END) AS "inboxCount", - SUM(CASE WHEN inbox = false THEN 1 ELSE 0 END) AS "notInbox", - SUM(CASE WHEN read = true THEN 1 ELSE 0 END) AS "readCount", - SUM(CASE WHEN read = false THEN 1 ELSE 0 END) AS unread, - SUM(CASE WHEN sent = true THEN 1 ELSE 0 END) AS "sentCount" - FROM "EmailMessage" - ${whereClause}${dateClause} - GROUP BY "startOfPeriod" - ORDER BY "startOfPeriod" - `; -} - -async function getStatsByPeriod( - options: StatsByWeekParams & { - emailAccountId: string; - }, -) { - // Get all stats in a single query - const stats = await getEmailStatsByPeriod(options); - - // Transform stats to match the expected format - const formattedStats = stats.map((stat) => { - const startOfPeriodFormatted = format(stat.startOfPeriod, "LLL dd, y"); - - return { - startOfPeriod: startOfPeriodFormatted, - All: Number(stat.totalCount), - Sent: Number(stat.sentCount), - Read: Number(stat.readCount), - Unread: Number(stat.unread), - Unarchived: Number(stat.inboxCount), - Archived: Number(stat.notInbox), - }; - }); - - // Calculate totals - const totalAll = sumBy(stats, (stat) => Number(stat.totalCount)); - const totalInbox = sumBy(stats, (stat) => Number(stat.inboxCount)); - const totalRead = sumBy(stats, (stat) => Number(stat.readCount)); - const totalSent = sumBy(stats, (stat) => Number(stat.sentCount)); - - return { - result: formattedStats, - allCount: totalAll, - inboxCount: totalInbox, - readCount: totalRead, - sentCount: totalSent, - }; -} +import { getStatsByPeriod } from "./controller"; +import { statsByPeriodQuerySchema } from "@/app/api/user/stats/by-period/validation"; export const GET = withEmailAccount( async (request) => { const emailAccountId = request.auth.emailAccountId; const { searchParams } = new URL(request.url); - const params = statsByWeekParams.parse({ + const params = statsByPeriodQuerySchema.parse({ period: searchParams.get("period") || "week", fromDate: searchParams.get("fromDate"), toDate: searchParams.get("toDate"), diff --git a/apps/web/app/api/user/stats/by-period/validation.ts b/apps/web/app/api/user/stats/by-period/validation.ts new file mode 100644 index 0000000000..f235e529e4 --- /dev/null +++ b/apps/web/app/api/user/stats/by-period/validation.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; +import { zodPeriod } from "@inboxzero/tinybird"; + +export const statsByPeriodQuerySchema = z.object({ + period: zodPeriod, + fromDate: z.coerce.number().nullish(), + toDate: z.coerce.number().nullish(), +}); +export type StatsByPeriodQuery = z.infer; diff --git a/apps/web/app/api/user/stats/response-time/controller.ts b/apps/web/app/api/user/stats/response-time/controller.ts new file mode 100644 index 0000000000..0ae069aeee --- /dev/null +++ b/apps/web/app/api/user/stats/response-time/controller.ts @@ -0,0 +1,190 @@ +import type { EmailProvider } from "@/utils/email/types"; +import { format } from "date-fns/format"; +import { startOfWeek } from "date-fns/startOfWeek"; +import type { Logger } from "@/utils/logger"; +import prisma from "@/utils/prisma"; +import { + calculateResponseTimes, + calculateSummaryStats, + calculateDistribution, + calculateMedian, + type ResponseTimeEntry, + type SummaryStats, + type DistributionStats, +} from "./calculate"; +import type { ResponseTimeQuery } from "@/app/api/user/stats/response-time/validation"; + +const MAX_SENT_MESSAGES = 50; + +interface TrendEntry { + period: string; + periodDate: Date; + medianResponseTime: number; + count: number; +} + +export type ResponseTimeResponse = { + summary: SummaryStats; + distribution: DistributionStats; + trend: TrendEntry[]; + emailsAnalyzed: number; + maxEmailsCap: number; +}; + +export async function getResponseTimeStats({ + fromDate, + toDate, + emailAccountId, + emailProvider, + logger, +}: ResponseTimeQuery & { + emailAccountId: string; + emailProvider: EmailProvider; + logger: Logger; +}): Promise { + // 1. Fetch sent message IDs (lightweight - just id and threadId) + const sentMessages = await emailProvider.getSentMessageIds({ + maxResults: MAX_SENT_MESSAGES, + ...(fromDate ? { after: new Date(fromDate) } : {}), + ...(toDate ? { before: new Date(toDate) } : {}), + }); + + if (!sentMessages.length) { + return getEmptyStats(); + } + + const sentMessageIds = sentMessages.map((m) => m.id); + + // 2. Check which sent messages are already cached + const cachedEntries = await prisma.responseTime.findMany({ + where: { + emailAccountId, + sentMessageId: { in: sentMessageIds }, + }, + select: { + threadId: true, + sentMessageId: true, + receivedMessageId: true, + receivedAt: true, + sentAt: true, + responseTimeMins: true, + }, + }); + + const cachedSentMessageIds = new Set( + cachedEntries.map((e) => e.sentMessageId), + ); + + // 3. Filter to uncached sent messages + const uncachedMessages = sentMessages.filter( + (m) => !cachedSentMessageIds.has(m.id), + ); + + // 4. Calculate response times only for uncached messages + let newEntries: ResponseTimeEntry[] = []; + if (uncachedMessages.length > 0) { + const { responseTimes: calculated } = await calculateResponseTimes( + uncachedMessages, + emailProvider, + logger, + ); + + // 5. Store new calculations to DB + if (calculated.length > 0) { + await prisma.responseTime.createMany({ + data: calculated.map((rt) => ({ + emailAccountId, + threadId: rt.threadId, + sentMessageId: rt.sentMessageId, + receivedMessageId: rt.receivedMessageId, + receivedAt: rt.receivedAt, + sentAt: rt.sentAt, + responseTimeMins: rt.responseTimeMins, + })), + skipDuplicates: true, + }); + newEntries = calculated; + } + } + + // 6. Combine cached + new and filter to date range + const combinedEntries: ResponseTimeEntry[] = [ + ...cachedEntries, + ...newEntries, + ]; + + // Filter to only include response times within the requested date range + const allEntries = combinedEntries.filter((entry) => { + const sentTime = entry.sentAt.getTime(); + if (fromDate && sentTime < fromDate) return false; + if (toDate && sentTime > toDate) return false; + return true; + }); + + if (allEntries.length === 0) { + return getEmptyStats(); + } + + // 7. Calculate derived statistics + const summary = calculateSummaryStats(allEntries); + + const distribution = calculateDistribution(allEntries); + const trend = calculateTrend(allEntries); + + return { + summary, + distribution, + trend, + emailsAnalyzed: allEntries.length, + maxEmailsCap: MAX_SENT_MESSAGES, + }; +} + +function calculateTrend(responseTimes: ResponseTimeEntry[]): TrendEntry[] { + const trendMap = new Map(); + + for (const rt of responseTimes) { + const weekStart = startOfWeek(rt.sentAt); + const key = format(weekStart, "yyyy-MM-dd"); + + if (!trendMap.has(key)) { + trendMap.set(key, { values: [], date: weekStart }); + } + trendMap.get(key)!.values.push(rt.responseTimeMins); + } + + return Array.from(trendMap.entries()) + .map(([_, { values, date }]) => { + const median = calculateMedian(values); + + return { + period: format(date, "LLL dd, y"), + periodDate: date, + medianResponseTime: Math.round(median), + count: values.length, + }; + }) + .sort((a, b) => a.periodDate.getTime() - b.periodDate.getTime()); +} + +function getEmptyStats(): ResponseTimeResponse { + return { + summary: { + medianResponseTime: 0, + averageResponseTime: 0, + within1Hour: 0, + previousPeriodComparison: null, + }, + distribution: { + lessThan1Hour: 0, + oneToFourHours: 0, + fourTo24Hours: 0, + oneToThreeDays: 0, + threeToSevenDays: 0, + moreThan7Days: 0, + }, + trend: [], + emailsAnalyzed: 0, + maxEmailsCap: MAX_SENT_MESSAGES, + }; +} diff --git a/apps/web/app/api/user/stats/response-time/route.ts b/apps/web/app/api/user/stats/response-time/route.ts index cd1a71f7e7..199c62b965 100644 --- a/apps/web/app/api/user/stats/response-time/route.ts +++ b/apps/web/app/api/user/stats/response-time/route.ts @@ -1,43 +1,11 @@ import { NextResponse } from "next/server"; -import { z } from "zod"; import { withEmailProvider } from "@/utils/middleware"; -import type { EmailProvider } from "@/utils/email/types"; -import { format } from "date-fns/format"; -import { startOfWeek } from "date-fns/startOfWeek"; -import type { Logger } from "@/utils/logger"; -import prisma from "@/utils/prisma"; -import { - calculateResponseTimes, - calculateSummaryStats, - calculateDistribution, - calculateMedian, - type ResponseTimeEntry, - type SummaryStats, - type DistributionStats, -} from "./calculate"; - -const responseTimeSchema = z.object({ - fromDate: z.coerce.number().nullish(), - toDate: z.coerce.number().nullish(), -}); -export type ResponseTimeParams = z.infer; - -const MAX_SENT_MESSAGES = 50; - -interface TrendEntry { - period: string; - periodDate: Date; - medianResponseTime: number; - count: number; -} - -export type GetResponseTimeResponse = Awaited< - ReturnType ->; +import { getResponseTimeStats } from "./controller"; +import { responseTimeQuerySchema } from "@/app/api/user/stats/response-time/validation"; export const GET = withEmailProvider("response-time-stats", async (request) => { const { searchParams } = new URL(request.url); - const params = responseTimeSchema.parse({ + const params = responseTimeQuerySchema.parse({ fromDate: searchParams.get("fromDate"), toDate: searchParams.get("toDate"), }); @@ -51,167 +19,3 @@ export const GET = withEmailProvider("response-time-stats", async (request) => { return NextResponse.json(result); }); - -async function getResponseTimeStats({ - fromDate, - toDate, - emailAccountId, - emailProvider, - logger, -}: ResponseTimeParams & { - emailAccountId: string; - emailProvider: EmailProvider; - logger: Logger; -}): Promise<{ - summary: SummaryStats; - distribution: DistributionStats; - trend: TrendEntry[]; - emailsAnalyzed: number; - maxEmailsCap: number; -}> { - // 1. Fetch sent message IDs (lightweight - just id and threadId) - const sentMessages = await emailProvider.getSentMessageIds({ - maxResults: MAX_SENT_MESSAGES, - ...(fromDate ? { after: new Date(fromDate) } : {}), - ...(toDate ? { before: new Date(toDate) } : {}), - }); - - if (!sentMessages.length) { - return getEmptyStats(); - } - - const sentMessageIds = sentMessages.map((m) => m.id); - - // 2. Check which sent messages are already cached - const cachedEntries = await prisma.responseTime.findMany({ - where: { - emailAccountId, - sentMessageId: { in: sentMessageIds }, - }, - select: { - threadId: true, - sentMessageId: true, - receivedMessageId: true, - receivedAt: true, - sentAt: true, - responseTimeMins: true, - }, - }); - - const cachedSentMessageIds = new Set( - cachedEntries.map((e) => e.sentMessageId), - ); - - // 3. Filter to uncached sent messages - const uncachedMessages = sentMessages.filter( - (m) => !cachedSentMessageIds.has(m.id), - ); - - // 4. Calculate response times only for uncached messages - let newEntries: ResponseTimeEntry[] = []; - if (uncachedMessages.length > 0) { - const { responseTimes: calculated } = await calculateResponseTimes( - uncachedMessages, - emailProvider, - logger, - ); - - // 5. Store new calculations to DB - if (calculated.length > 0) { - await prisma.responseTime.createMany({ - data: calculated.map((rt) => ({ - emailAccountId, - threadId: rt.threadId, - sentMessageId: rt.sentMessageId, - receivedMessageId: rt.receivedMessageId, - receivedAt: rt.receivedAt, - sentAt: rt.sentAt, - responseTimeMins: rt.responseTimeMins, - })), - skipDuplicates: true, - }); - newEntries = calculated; - } - } - - // 6. Combine cached + new and filter to date range - const combinedEntries: ResponseTimeEntry[] = [ - ...cachedEntries, - ...newEntries, - ]; - - // Filter to only include response times within the requested date range - const allEntries = combinedEntries.filter((entry) => { - const sentTime = entry.sentAt.getTime(); - if (fromDate && sentTime < fromDate) return false; - if (toDate && sentTime > toDate) return false; - return true; - }); - - if (allEntries.length === 0) { - return getEmptyStats(); - } - - // 7. Calculate derived statistics - const summary = calculateSummaryStats(allEntries); - - const distribution = calculateDistribution(allEntries); - const trend = calculateTrend(allEntries); - - return { - summary, - distribution, - trend, - emailsAnalyzed: allEntries.length, - maxEmailsCap: MAX_SENT_MESSAGES, - }; -} - -function calculateTrend(responseTimes: ResponseTimeEntry[]): TrendEntry[] { - const trendMap = new Map(); - - for (const rt of responseTimes) { - const weekStart = startOfWeek(rt.sentAt); - const key = format(weekStart, "yyyy-MM-dd"); - - if (!trendMap.has(key)) { - trendMap.set(key, { values: [], date: weekStart }); - } - trendMap.get(key)!.values.push(rt.responseTimeMins); - } - - return Array.from(trendMap.entries()) - .map(([_, { values, date }]) => { - const median = calculateMedian(values); - - return { - period: format(date, "LLL dd, y"), - periodDate: date, - medianResponseTime: Math.round(median), - count: values.length, - }; - }) - .sort((a, b) => a.periodDate.getTime() - b.periodDate.getTime()); -} - -function getEmptyStats() { - return { - summary: { - medianResponseTime: 0, - averageResponseTime: 0, - within1Hour: 0, - previousPeriodComparison: null, - }, - distribution: { - lessThan1Hour: 0, - oneToFourHours: 0, - fourTo24Hours: 0, - oneToThreeDays: 0, - threeToSevenDays: 0, - moreThan7Days: 0, - }, - trend: [], - emailsAnalyzed: 0, - maxEmailsCap: MAX_SENT_MESSAGES, - }; -} diff --git a/apps/web/app/api/user/stats/response-time/validation.ts b/apps/web/app/api/user/stats/response-time/validation.ts new file mode 100644 index 0000000000..71c4c8926a --- /dev/null +++ b/apps/web/app/api/user/stats/response-time/validation.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const responseTimeQuerySchema = z.object({ + fromDate: z.coerce.number().nullish(), + toDate: z.coerce.number().nullish(), +}); +export type ResponseTimeQuery = z.infer; diff --git a/apps/web/app/api/v1/helpers.ts b/apps/web/app/api/v1/helpers.ts index 68c6e1d5f8..921c41e1da 100644 --- a/apps/web/app/api/v1/helpers.ts +++ b/apps/web/app/api/v1/helpers.ts @@ -2,9 +2,10 @@ import prisma from "@/utils/prisma"; /** * Gets the email account ID from the provided email or looks it up using the account ID - * @param email Optional email address - * @param accountId Account ID to look up the email if not provided - * @returns The email account ID or undefined if not found + * @param email Optional email address to look up + * @param accountId Optional account ID (external provider ID) to look up + * @param userId User ID to verify ownership + * @returns The email account ID (cuid) or undefined if not found */ export async function getEmailAccountId({ email, @@ -19,18 +20,18 @@ export async function getEmailAccountId({ // check user owns email account const emailAccount = await prisma.emailAccount.findUnique({ where: { email, userId }, - select: { email: true }, + select: { id: true }, }); - return emailAccount?.email; + return emailAccount?.id; } if (!accountId) return undefined; const emailAccount = await prisma.emailAccount.findUnique({ where: { accountId, userId }, - select: { email: true }, + select: { id: true }, }); - return emailAccount?.email; + return emailAccount?.id; } diff --git a/apps/web/app/api/v1/openapi/route.ts b/apps/web/app/api/v1/openapi/route.ts index 1231cae428..2fb7a5fe63 100644 --- a/apps/web/app/api/v1/openapi/route.ts +++ b/apps/web/app/api/v1/openapi/route.ts @@ -9,6 +9,14 @@ import { groupEmailsQuerySchema, groupEmailsResponseSchema, } from "@/app/api/v1/group/[groupId]/emails/validation"; +import { + statsByPeriodQuerySchema, + statsByPeriodResponseSchema, +} from "@/app/api/v1/stats/by-period/validation"; +import { + responseTimeQuerySchema, + responseTimeResponseSchema, +} from "@/app/api/v1/stats/response-time/validation"; import { API_KEY_HEADER } from "@/utils/api-auth"; extendZodWithOpenApi(z); @@ -48,6 +56,48 @@ registry.registerPath({ }, }); +registry.registerPath({ + method: "get", + path: "/stats/by-period", + description: + "Get email statistics grouped by time period. Returns counts of emails by status (all, sent, read, unread, archived, unarchived) for each period.", + security: [{ ApiKeyAuth: [] }], + request: { + query: statsByPeriodQuerySchema, + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: statsByPeriodResponseSchema, + }, + }, + }, + }, +}); + +registry.registerPath({ + method: "get", + path: "/stats/response-time", + description: + "Get email response time statistics. Returns summary stats, distribution, and trend data showing how quickly you respond to emails.", + security: [{ ApiKeyAuth: [] }], + request: { + query: responseTimeQuerySchema, + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: responseTimeResponseSchema, + }, + }, + }, + }, +}); + export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const customHost = searchParams.get("host"); diff --git a/apps/web/app/api/v1/stats/by-period/route.ts b/apps/web/app/api/v1/stats/by-period/route.ts new file mode 100644 index 0000000000..b56f344622 --- /dev/null +++ b/apps/web/app/api/v1/stats/by-period/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from "next/server"; +import { withError } from "@/utils/middleware"; +import { validateApiKeyAndGetEmailProvider } from "@/utils/api-auth"; +import { getEmailAccountId } from "@/app/api/v1/helpers"; +import { getStatsByPeriod } from "@/app/api/user/stats/by-period/controller"; +import { statsByPeriodQuerySchema } from "./validation"; + +export const GET = withError(async (request) => { + const { userId, accountId } = + await validateApiKeyAndGetEmailProvider(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 { period, fromDate, toDate, email } = queryResult.data; + + const emailAccountId = await getEmailAccountId({ + email, + accountId, + userId, + }); + + if (!emailAccountId) { + return NextResponse.json( + { error: "Email account not found" }, + { status: 400 }, + ); + } + + const result = await getStatsByPeriod({ + period, + fromDate, + toDate, + emailAccountId, + }); + + return NextResponse.json(result); +}); diff --git a/apps/web/app/api/v1/stats/by-period/validation.ts b/apps/web/app/api/v1/stats/by-period/validation.ts new file mode 100644 index 0000000000..8c45e16b54 --- /dev/null +++ b/apps/web/app/api/v1/stats/by-period/validation.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; +import { zodPeriod } from "@inboxzero/tinybird"; + +export const statsByPeriodQuerySchema = z.object({ + period: zodPeriod.optional().default("week"), + fromDate: z.coerce.number().optional(), + toDate: z.coerce.number().optional(), + email: z.string().optional(), +}); + +export const statsByPeriodResponseSchema = z.object({ + result: z.array( + z.object({ + startOfPeriod: z.string(), + All: z.number(), + Sent: z.number(), + Read: z.number(), + Unread: z.number(), + Unarchived: z.number(), + Archived: z.number(), + }), + ), + allCount: z.number(), + inboxCount: z.number(), + readCount: z.number(), + sentCount: z.number(), +}); + +export type StatsByPeriodResult = z.infer; diff --git a/apps/web/app/api/v1/stats/response-time/route.ts b/apps/web/app/api/v1/stats/response-time/route.ts new file mode 100644 index 0000000000..df9a7af742 --- /dev/null +++ b/apps/web/app/api/v1/stats/response-time/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server"; +import { withError } from "@/utils/middleware"; +import { validateApiKeyAndGetEmailProvider } from "@/utils/api-auth"; +import { getEmailAccountId } from "@/app/api/v1/helpers"; +import { getResponseTimeStats } from "@/app/api/user/stats/response-time/controller"; +import { responseTimeQuerySchema } from "./validation"; +import { createScopedLogger } from "@/utils/logger"; + +const logger = createScopedLogger("api/v1/stats/response-time"); + +export const GET = withError(async (request) => { + const { emailProvider, userId, accountId } = + await validateApiKeyAndGetEmailProvider(request); + + const { searchParams } = new URL(request.url); + const queryResult = responseTimeQuerySchema.safeParse( + Object.fromEntries(searchParams), + ); + + if (!queryResult.success) { + return NextResponse.json( + { error: "Invalid query parameters" }, + { status: 400 }, + ); + } + + const { fromDate, toDate, email } = queryResult.data; + + const emailAccountId = await getEmailAccountId({ + email, + accountId, + userId, + }); + + if (!emailAccountId) { + return NextResponse.json( + { error: "Email account not found" }, + { status: 400 }, + ); + } + + const result = await getResponseTimeStats({ + fromDate, + toDate, + emailAccountId, + emailProvider, + logger, + }); + + return NextResponse.json(result); +}); diff --git a/apps/web/app/api/v1/stats/response-time/validation.ts b/apps/web/app/api/v1/stats/response-time/validation.ts new file mode 100644 index 0000000000..1f31ef5833 --- /dev/null +++ b/apps/web/app/api/v1/stats/response-time/validation.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +export const responseTimeQuerySchema = z.object({ + fromDate: z.coerce.number().optional(), + toDate: z.coerce.number().optional(), + email: z.string().optional(), +}); + +export const responseTimeResponseSchema = z.object({ + summary: z.object({ + medianResponseTime: z.number(), + averageResponseTime: z.number(), + within1Hour: z.number(), + previousPeriodComparison: z + .object({ + medianResponseTime: z.number(), + percentChange: z.number(), + }) + .nullable(), + }), + distribution: z.object({ + lessThan1Hour: z.number(), + oneToFourHours: z.number(), + fourTo24Hours: z.number(), + oneToThreeDays: z.number(), + threeToSevenDays: z.number(), + moreThan7Days: z.number(), + }), + trend: z.array( + z.object({ + period: z.string(), + periodDate: z.coerce.date(), + medianResponseTime: z.number(), + count: z.number(), + }), + ), + emailsAnalyzed: z.number(), + maxEmailsCap: z.number(), +}); + +export type ResponseTimeResult = z.infer; diff --git a/apps/web/package.json b/apps/web/package.json index 7729c5ee32..9908148a7f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -25,7 +25,7 @@ "@ai-sdk/openai": "2.0.77", "@ai-sdk/provider": "2.0.0", "@ai-sdk/react": "2.0.106", - "@asteasolutions/zod-to-openapi": "8.1.0", + "@asteasolutions/zod-to-openapi": "7.3.4", "@better-auth/sso": "1.3.28", "@date-fns/tz": "1.4.1", "@dub/analytics": "0.0.32", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f11ebd1737..098e048325 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,8 +125,8 @@ importers: specifier: 2.0.106 version: 2.0.106(react@19.2.1)(zod@3.25.46) '@asteasolutions/zod-to-openapi': - specifier: 8.1.0 - version: 8.1.0(zod@3.25.46) + specifier: 7.3.4 + version: 7.3.4(zod@3.25.46) '@better-auth/sso': specifier: 1.3.28 version: 1.3.28(better-auth@1.4.5(next@15.5.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(svelte@5.38.6)(vue@3.5.20(typescript@5.9.3))) @@ -926,10 +926,10 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} - '@asteasolutions/zod-to-openapi@8.1.0': - resolution: {integrity: sha512-tQFxVs05J/6QXXqIzj6rTRk3nj1HFs4pe+uThwE95jL5II2JfpVXkK+CqkO7aT0Do5AYqO6LDrKpleLUFXgY+g==} + '@asteasolutions/zod-to-openapi@7.3.4': + resolution: {integrity: sha512-/2rThQ5zPi9OzVwes6U7lK1+Yvug0iXu25olp7S0XsYmOqnyMfxH7gdSQjn/+DSOHRg7wnotwGJSyL+fBKdnEA==} peerDependencies: - zod: ^4.0.0 + zod: ^3.20.2 '@authenio/xml-encryption@2.0.2': resolution: {integrity: sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg==} @@ -12730,7 +12730,7 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': optional: true - '@asteasolutions/zod-to-openapi@8.1.0(zod@3.25.46)': + '@asteasolutions/zod-to-openapi@7.3.4(zod@3.25.46)': dependencies: openapi3-ts: 4.5.0 zod: 3.25.46 diff --git a/version.txt b/version.txt index 43a8db3589..22b8db2cdf 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v2.21.61 +v2.21.62