From 81faba2aba38ac178fd43fed8e53c9763d4f3072 Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Fri, 19 Sep 2025 08:18:23 +1000 Subject: [PATCH] add admin mail analytics (#240) --- .../admin/email-analytics/constants.ts | 4 + .../admin/email-analytics/page.tsx | 182 ++++++++++++++++++ apps/web/src/app/(dashboard)/admin/layout.tsx | 5 + apps/web/src/server/api/routers/admin.ts | 88 +++++++++ 4 files changed, 279 insertions(+) create mode 100644 apps/web/src/app/(dashboard)/admin/email-analytics/constants.ts create mode 100644 apps/web/src/app/(dashboard)/admin/email-analytics/page.tsx diff --git a/apps/web/src/app/(dashboard)/admin/email-analytics/constants.ts b/apps/web/src/app/(dashboard)/admin/email-analytics/constants.ts new file mode 100644 index 0000000..9bc22f8 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/email-analytics/constants.ts @@ -0,0 +1,4 @@ +export const timeframeOptions = [ + { label: "Today", value: "today" }, + { label: "This month", value: "thisMonth" }, +] as const; diff --git a/apps/web/src/app/(dashboard)/admin/email-analytics/page.tsx b/apps/web/src/app/(dashboard)/admin/email-analytics/page.tsx new file mode 100644 index 0000000..2864d8e --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/email-analytics/page.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@usesend/ui/src/card"; +import { Label } from "@usesend/ui/src/label"; +import { Switch } from "@usesend/ui/src/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@usesend/ui/src/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@usesend/ui/src/table"; +import Spinner from "@usesend/ui/src/spinner"; +import { api } from "~/trpc/react"; +import { isCloud } from "~/utils/common"; +import { timeframeOptions } from "./constants"; + +export default function AdminEmailAnalyticsPage() { + const isCloudEnv = isCloud(); + const [timeframe, setTimeframe] = useState< + (typeof timeframeOptions)[number]["value"] + >("today"); + const [paidOnly, setPaidOnly] = useState(false); + + const analyticsQuery = api.admin.getEmailAnalytics.useQuery( + { + timeframe, + paidOnly, + }, + { keepPreviousData: true, enabled: isCloudEnv } + ); + + const data = analyticsQuery.data; + + const totals = data?.totals ?? { + sent: 0, + delivered: 0, + opened: 0, + clicked: 0, + bounced: 0, + complained: 0, + hardBounced: 0, + }; + + const rows = useMemo(() => data?.rows ?? [], [data]); + + if (!isCloudEnv) { + return ( +
+ Email analytics are available only in the cloud deployment. +
+ ); + } + + return ( +
+

Email analytics

+
+
+ + +
+
+
+
+ +
+ + + + + + + +
+ + + +
+ Usage by team + {data ? ( +

+ Since {data.timeframe === "today" ? "today" : data.periodStart} +

+ ) : null} +
+ {analyticsQuery.isLoading ? ( + + ) : null} +
+ + + + + Team + Plan + Sent + Delivered + Opened + Clicked + Bounced + Complained + Hard bounced + + + + {analyticsQuery.isLoading ? ( + + + + + + ) : rows.length === 0 ? ( + + + No email activity found for this period. + + + ) : ( + rows.map((row) => ( + + {row.name} + {row.plan} + {row.sent} + {row.delivered} + {row.opened} + {row.clicked} + {row.bounced} + {row.complained} + {row.hardBounced} + + )) + )} + +
+
+
+
+ ); +} + +function SummaryCard({ label, value }: { label: string; value: number }) { + return ( + + + + {label} + + + +

{value.toLocaleString()}

+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/admin/layout.tsx b/apps/web/src/app/(dashboard)/admin/layout.tsx index 7d53f7d..86a0013 100644 --- a/apps/web/src/app/(dashboard)/admin/layout.tsx +++ b/apps/web/src/app/(dashboard)/admin/layout.tsx @@ -20,6 +20,11 @@ export default function AdminLayout({ Teams ) : null} + {isCloud() ? ( + + Email analytics + + ) : null} {isCloud() ? ( Waitlist diff --git a/apps/web/src/server/api/routers/admin.ts b/apps/web/src/server/api/routers/admin.ts index 52b826f..fe2c8dc 100644 --- a/apps/web/src/server/api/routers/admin.ts +++ b/apps/web/src/server/api/routers/admin.ts @@ -1,3 +1,4 @@ +import { Prisma, type Plan } from "@prisma/client"; import { z } from "zod"; import { env } from "~/env"; @@ -289,4 +290,91 @@ export const adminRouter = createTRPCRouter({ return updatedTeam; }), + + getEmailAnalytics: adminProcedure + .input( + z.object({ + timeframe: z.enum(["today", "thisMonth"]), + paidOnly: z.boolean().optional(), + }) + ) + .query(async ({ input }) => { + const timeframe = input.timeframe; + const paidOnly = input.paidOnly ?? false; + + const now = new Date(); + const today = now.toISOString().slice(0, 10); + const monthStartDate = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1) + ); + const monthStart = monthStartDate.toISOString().slice(0, 10); + + type EmailAnalyticsRow = { + teamId: number; + name: string; + plan: Plan; + sent: number; + delivered: number; + opened: number; + clicked: number; + bounced: number; + complained: number; + hardBounced: number; + }; + + const rows = await db.$queryRaw>` + SELECT + d."teamId" AS "teamId", + t."name" AS name, + t."plan" AS plan, + SUM(d.sent)::integer AS sent, + SUM(d.delivered)::integer AS delivered, + SUM(d.opened)::integer AS opened, + SUM(d.clicked)::integer AS clicked, + SUM(d.bounced)::integer AS bounced, + SUM(d.complained)::integer AS complained, + SUM(d."hardBounced")::integer AS "hardBounced" + FROM "DailyEmailUsage" d + INNER JOIN "Team" t ON t.id = d."teamId" + WHERE 1 = 1 + ${ + timeframe === "today" + ? Prisma.sql`AND d."date" = ${today}` + : Prisma.sql`AND d."date" >= ${monthStart}` + } + ${paidOnly ? Prisma.sql`AND t."plan" = 'BASIC'` : Prisma.sql``} + GROUP BY d."teamId", t."name", t."plan" + ORDER BY sent DESC + `; + + const totals = rows.reduce( + (acc, row) => { + acc.sent += row.sent; + acc.delivered += row.delivered; + acc.opened += row.opened; + acc.clicked += row.clicked; + acc.bounced += row.bounced; + acc.complained += row.complained; + acc.hardBounced += row.hardBounced; + return acc; + }, + { + sent: 0, + delivered: 0, + opened: 0, + clicked: 0, + bounced: 0, + complained: 0, + hardBounced: 0, + } + ); + + return { + rows, + totals, + timeframe, + paidOnly, + periodStart: timeframe === "today" ? today : monthStart, + }; + }), });