From b5ebd002e52d158326c919c459102ea2238e10f4 Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Sun, 11 May 2025 23:34:21 +1000 Subject: [PATCH] add analytics (#162) --- .../migration.sql | 10 + .../migration.sql | 27 ++ .../web/prisma/migrations/migration_lock.toml | 2 +- apps/web/prisma/schema.prisma | 10 + .../(dashboard)/dashboard/dashboard-chart.tsx | 315 -------------- .../dashboard/dashboard-filters.tsx | 62 +++ .../app/(dashboard)/dashboard/email-chart.tsx | 265 ++++++++++++ .../(dashboard)/dashboard/hooks/useColors.ts | 27 ++ .../src/app/(dashboard)/dashboard/page.tsx | 25 +- .../dashboard/reputation-metrics.tsx | 402 ++++++++++++++++++ apps/web/src/lib/constants/colors.ts | 18 + apps/web/src/lib/constants/index.ts | 6 + apps/web/src/server/api/root.ts | 2 + apps/web/src/server/api/routers/dashboard.ts | 140 ++++++ apps/web/src/server/api/routers/email.ts | 93 ---- .../web/src/server/service/ses-hook-parser.ts | 27 +- packages/tailwind-config/tailwind.config.ts | 4 + packages/ui/src/tabs.tsx | 2 +- packages/ui/src/tooltip.tsx | 2 +- packages/ui/styles/globals.css | 10 +- 20 files changed, 1031 insertions(+), 418 deletions(-) create mode 100644 apps/web/prisma/migrations/20250510052850_add_cumulated_metrics/migration.sql create mode 100644 apps/web/prisma/migrations/20250510235405_compute_cumulated_metrics/migration.sql delete mode 100644 apps/web/src/app/(dashboard)/dashboard/dashboard-chart.tsx create mode 100644 apps/web/src/app/(dashboard)/dashboard/dashboard-filters.tsx create mode 100644 apps/web/src/app/(dashboard)/dashboard/email-chart.tsx create mode 100644 apps/web/src/app/(dashboard)/dashboard/hooks/useColors.ts create mode 100644 apps/web/src/app/(dashboard)/dashboard/reputation-metrics.tsx create mode 100644 apps/web/src/lib/constants/colors.ts create mode 100644 apps/web/src/server/api/routers/dashboard.ts diff --git a/apps/web/prisma/migrations/20250510052850_add_cumulated_metrics/migration.sql b/apps/web/prisma/migrations/20250510052850_add_cumulated_metrics/migration.sql new file mode 100644 index 0000000..c83f1c2 --- /dev/null +++ b/apps/web/prisma/migrations/20250510052850_add_cumulated_metrics/migration.sql @@ -0,0 +1,10 @@ +-- CreateTable +CREATE TABLE "CumulatedMetrics" ( + "teamId" INTEGER NOT NULL, + "domainId" INTEGER NOT NULL, + "delivered" BIGINT NOT NULL DEFAULT 0, + "hardBounced" BIGINT NOT NULL DEFAULT 0, + "complained" BIGINT NOT NULL DEFAULT 0, + + CONSTRAINT "CumulatedMetrics_pkey" PRIMARY KEY ("teamId","domainId") +); diff --git a/apps/web/prisma/migrations/20250510235405_compute_cumulated_metrics/migration.sql b/apps/web/prisma/migrations/20250510235405_compute_cumulated_metrics/migration.sql new file mode 100644 index 0000000..83d30e4 --- /dev/null +++ b/apps/web/prisma/migrations/20250510235405_compute_cumulated_metrics/migration.sql @@ -0,0 +1,27 @@ +BEGIN; + +-- 1) Populate or update cumulated totals +INSERT INTO "CumulatedMetrics" ( + "teamId", + "domainId", + "delivered", + "hardBounced", + "complained" +) +SELECT + du."teamId", + du."domainId", + SUM(du.delivered)::BIGINT AS delivered, + SUM(du."hardBounced")::BIGINT AS hardBounced, + SUM(du.complained)::BIGINT AS complained +FROM public."DailyEmailUsage" du +GROUP BY + du."teamId", + du."domainId" +ON CONFLICT ("teamId","domainId") DO UPDATE + SET + "delivered" = EXCLUDED."delivered", + "hardBounced" = EXCLUDED."hardBounced", + "complained" = EXCLUDED."complained"; + +COMMIT; \ No newline at end of file diff --git a/apps/web/prisma/migrations/migration_lock.toml b/apps/web/prisma/migrations/migration_lock.toml index 648c57f..044d57c 100644 --- a/apps/web/prisma/migrations/migration_lock.toml +++ b/apps/web/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually # It should be added in your version-control system (e.g., Git) -provider = "postgresql" \ No newline at end of file +provider = "postgresql" diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 1941472..0e814bf 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -375,3 +375,13 @@ model DailyEmailUsage { @@id([teamId, domainId, date, type]) } + +model CumulatedMetrics { + teamId Int + domainId Int + delivered BigInt @default(0) + hardBounced BigInt @default(0) + complained BigInt @default(0) + + @@id([teamId, domainId]) +} diff --git a/apps/web/src/app/(dashboard)/dashboard/dashboard-chart.tsx b/apps/web/src/app/(dashboard)/dashboard/dashboard-chart.tsx deleted file mode 100644 index c2f4e24..0000000 --- a/apps/web/src/app/(dashboard)/dashboard/dashboard-chart.tsx +++ /dev/null @@ -1,315 +0,0 @@ -import React from "react"; -import { - BarChart, - Bar, - XAxis, - YAxis, - Tooltip, - ResponsiveContainer, - CartesianGrid, -} from "recharts"; -import { EmailStatusIcon } from "../emails/email-status-badge"; -import { EmailStatus } from "@prisma/client"; -import { api } from "~/trpc/react"; -import Spinner from "@unsend/ui/src/spinner"; -import { Tabs, TabsList, TabsTrigger } from "@unsend/ui/src/tabs"; -import { useUrlState } from "~/hooks/useUrlState"; -import { useTheme } from "@unsend/ui"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, -} from "@unsend/ui/src/select"; - -export default function DashboardChart() { - const [days, setDays] = useUrlState("days", "7"); - const [domain, setDomain] = useUrlState("domain"); - const { resolvedTheme } = useTheme(); - - const domainId = domain ? Number(domain) : undefined; - const statusQuery = api.email.dashboard.useQuery({ - days: Number(days), - domain: domainId, - }); - const { data: domainsQuery } = api.domain.domains.useQuery(); - - const handleDomain = (val: string) => { - setDomain(val === "All Domain" ? null : val); - }; - - const lightColors = { - delivered: "#40a02bcc", - bounced: "#d20f39cc", - complained: "#df8e1dcc", - opened: "#8839efcc", - clicked: "#04a5e5cc", - }; - - const darkColors = { - delivered: "#a6e3a1", - bounced: "#f38ba8", - complained: "#F9E2AF", - opened: "#cba6f7", - clicked: "#93c5fd", - }; - - const currentColors = resolvedTheme === "dark" ? darkColors : lightColors; - - return ( -
-
-

Dashboard

-
- - setDays(value)}> - - 7 Days - 30 Days - - -
-
- -
-
- {!statusQuery.isLoading && statusQuery.data ? ( - <> - - - - - - - - ) : ( - <> -
- -
-
- -
-
- -
-
- -
-
- -
- - )} -
- {!statusQuery.isLoading && statusQuery.data ? ( -
- - - - - - { - const data = payload?.[0]?.payload as Record< - | "sent" - | "delivered" - | "opened" - | "clicked" - | "bounced" - | "complained", - number - > & { date: string }; - - if (!data || data.sent === 0) return null; - - return ( -
-

- {data.date} -

- {data.delivered ? ( -
-
-

- Delivered -

-

- {data.delivered} -

-
- ) : null} - {data.bounced ? ( -
-
-

- Bounced -

-

{data.bounced}

-
- ) : null} - {data.complained ? ( -
-
-

- Complained -

-

- {data.complained} -

-
- ) : null} - {data.opened ? ( -
-
-

- Opened -

-

{data.opened}

-
- ) : null} - {data.clicked ? ( -
-
-

- Clicked -

-

{data.clicked}

-
- ) : null} -
- ); - }} - cursor={false} - /> - {/* */} - - - - - -
-
-
- ) : null} -
-
- ); -} - -type DashboardItemCardProps = { - status: EmailStatus | "total"; - count: number; - percentage: number; -}; - -const DashboardItemCard: React.FC = ({ - status, - count, - percentage, -}) => { - return ( -
-
- {status !== "total" ? : null} -
{status.toLowerCase()}
-
-
-
- {count} -
- {status !== "total" ? ( -
- {count > 0 ? (percentage * 100).toFixed(0) : 0}% -
- ) : null} -
-
- ); -}; diff --git a/apps/web/src/app/(dashboard)/dashboard/dashboard-filters.tsx b/apps/web/src/app/(dashboard)/dashboard/dashboard-filters.tsx new file mode 100644 index 0000000..97bfbb9 --- /dev/null +++ b/apps/web/src/app/(dashboard)/dashboard/dashboard-filters.tsx @@ -0,0 +1,62 @@ +import React from "react"; +import { Tabs, TabsList, TabsTrigger } from "@unsend/ui/src/tabs"; +import { useUrlState } from "~/hooks/useUrlState"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from "@unsend/ui/src/select"; +import { api } from "~/trpc/react"; + +interface DashboardFiltersProps { + days: string; + setDays: (days: string) => void; + domain: string | null; + setDomain: (domain: string | null) => void; +} + +export default function DashboardFilters({ + days, + setDays, + domain, + setDomain, +}: DashboardFiltersProps) { + const { data: domainsQuery } = api.domain.domains.useQuery(); + + const handleDomain = (val: string) => { + setDomain(val === "All Domain" ? null : val); + }; + + return ( +
+ + setDays(value)}> + + 7 Days + 30 Days + + +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/dashboard/email-chart.tsx b/apps/web/src/app/(dashboard)/dashboard/email-chart.tsx new file mode 100644 index 0000000..87e293d --- /dev/null +++ b/apps/web/src/app/(dashboard)/dashboard/email-chart.tsx @@ -0,0 +1,265 @@ +import React from "react"; +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + CartesianGrid, + AreaChart, + Area, +} from "recharts"; +import { EmailStatusIcon } from "../emails/email-status-badge"; +import { EmailStatus } from "@prisma/client"; +import { api } from "~/trpc/react"; +import Spinner from "@unsend/ui/src/spinner"; +import { useTheme } from "@unsend/ui"; +import { EMAIL_COLORS } from "~/lib/constants/colors"; +import { useColors } from "./hooks/useColors"; + +interface EmailChartProps { + days: number; + domain: string | null; +} + +export default function EmailChart({ days, domain }: EmailChartProps) { + const domainId = domain ? Number(domain) : undefined; + const statusQuery = api.dashboard.emailTimeSeries.useQuery({ + days: days, + domain: domainId, + }); + + const currentColors = useColors(); + + return ( +
+ {!statusQuery.isLoading && statusQuery.data ? ( +
+
+ {/*
Emails
*/} + +
+ + + + + + +
+
+ + + + {/* */} + { + const data = payload?.[0]?.payload as Record< + | "sent" + | "delivered" + | "opened" + | "clicked" + | "bounced" + | "complained", + number + > & { date: string }; + + if (!data || data.sent === 0) return null; + + return ( +
+

+ {data.date} +

+ {data.delivered ? ( +
+
+

+ Delivered +

+

{data.delivered}

+
+ ) : null} + {data.bounced ? ( +
+
+

+ Bounced +

+

{data.bounced}

+
+ ) : null} + {data.complained ? ( +
+
+

+ Complained +

+

{data.complained}

+
+ ) : null} + {data.opened ? ( +
+
+

+ Opened +

+

{data.opened}

+
+ ) : null} + {data.clicked ? ( +
+
+

+ Clicked +

+

{data.clicked}

+
+ ) : null} +
+ ); + }} + cursor={false} + /> + {/* */} + + + + + +
+
+
+ ) : ( +
+ )} +
+ ); +} + +type DashboardItemCardProps = { + status: EmailStatus | "total"; + count: number; + percentage: number; +}; + +const DashboardItemCard: React.FC = ({ + status, + count, + percentage, +}) => { + return ( +
+
+ {status !== "total" ? : null} +
{status.toLowerCase()}
+
+
+
+ {count} +
+ {status !== "total" ? ( +
+ {count > 0 ? (percentage * 100).toFixed(0) : 0}% +
+ ) : null} +
+
+ ); +}; + +const EmailChartItem: React.FC = ({ + status, + count, + percentage, +}) => { + const color = EMAIL_COLORS[status]; + + return ( +
+ {/*
*/} + +
+
+
+ {/*
*/} + +
+ {status.toLowerCase()} +
+
+
+ {count} + + {status !== "total" + ? `(${count > 0 ? (percentage * 100).toFixed(0) : 0}%)` + : null} + +
+
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/dashboard/hooks/useColors.ts b/apps/web/src/app/(dashboard)/dashboard/hooks/useColors.ts new file mode 100644 index 0000000..9d3ba09 --- /dev/null +++ b/apps/web/src/app/(dashboard)/dashboard/hooks/useColors.ts @@ -0,0 +1,27 @@ +import { useTheme } from "@unsend/ui"; + +export function useColors() { + const { resolvedTheme } = useTheme(); + + const lightColors = { + delivered: "#40a02b", + bounced: "#d20f39", + complained: "#df8e1d", + opened: "#8839ef", + clicked: "#04a5e5", + xaxis: "#6D6F84", + }; + + const darkColors = { + delivered: "#a6e3a1", + bounced: "#f38ba8", + complained: "#F9E2AF", + opened: "#cba6f7", + clicked: "#93c5fd", + xaxis: "#AAB1CD", + }; + + const currentColors = resolvedTheme === "dark" ? darkColors : lightColors; + + return currentColors; +} diff --git a/apps/web/src/app/(dashboard)/dashboard/page.tsx b/apps/web/src/app/(dashboard)/dashboard/page.tsx index 01e78b8..203f4e8 100644 --- a/apps/web/src/app/(dashboard)/dashboard/page.tsx +++ b/apps/web/src/app/(dashboard)/dashboard/page.tsx @@ -1,12 +1,31 @@ "use client"; -import DashboardChart from "./dashboard-chart"; +import EmailChart from "./email-chart"; +import DashboardFilters from "./dashboard-filters"; +import { useUrlState } from "~/hooks/useUrlState"; +import { ReputationMetrics } from "./reputation-metrics"; export default function Dashboard() { + const [days, setDays] = useUrlState("days", "7"); + const [domain, setDomain] = useUrlState("domain"); + return (
-
- +
+
+

Analytics

+ +
+
+ + + +
); diff --git a/apps/web/src/app/(dashboard)/dashboard/reputation-metrics.tsx b/apps/web/src/app/(dashboard)/dashboard/reputation-metrics.tsx new file mode 100644 index 0000000..1072312 --- /dev/null +++ b/apps/web/src/app/(dashboard)/dashboard/reputation-metrics.tsx @@ -0,0 +1,402 @@ +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@unsend/ui/src/tooltip"; +import { + CheckCircle2, + CheckCircle2Icon, + InfoIcon, + OctagonAlertIcon, + TriangleAlertIcon, +} from "lucide-react"; +import { + Bar, + BarChart, + ReferenceLine, + ResponsiveContainer, + Tooltip as RechartsTooltip, + CartesianGrid, + YAxis, +} from "recharts"; +import { + HARD_BOUNCE_RISK_RATE, + HARD_BOUNCE_WARNING_RATE, + COMPLAINED_WARNING_RATE, + COMPLAINED_RISK_RATE, +} from "~/lib/constants"; +import { api } from "~/trpc/react"; +import { useColors } from "./hooks/useColors"; + +interface ReputationMetricsProps { + days: number; + domain: string | null; +} + +enum ACCOUNT_STATUS { + HEALTHY = "HEALTHY", + WARNING = "WARNING", + RISK = "RISK", +} + +const CustomLabel = ({ value, stroke }: { value: string; stroke: string }) => { + return ( + + {value} + + ); +}; + +export function ReputationMetrics({ days, domain }: ReputationMetricsProps) { + const { data: metrics, isLoading } = + api.dashboard.reputationMetricsData.useQuery({ + domain: domain ? Number(domain) : undefined, + }); + + const colors = useColors(); + + const bouncedMetric = metrics + ? [ + { + name: "Bounce Rate", + value: metrics.bounceRate, + }, + ] + : []; + + const complaintMetric = metrics + ? [ + { + name: "Complaint Rate", + value: metrics.complaintRate, + }, + ] + : []; + + const bounceStatus = + (metrics?.bounceRate ?? 0) > HARD_BOUNCE_RISK_RATE + ? ACCOUNT_STATUS.RISK + : (metrics?.bounceRate ?? 0) > HARD_BOUNCE_WARNING_RATE + ? ACCOUNT_STATUS.WARNING + : ACCOUNT_STATUS.HEALTHY; + + const complaintStatus = + (metrics?.complaintRate ?? 0) > COMPLAINED_RISK_RATE + ? ACCOUNT_STATUS.RISK + : (metrics?.complaintRate ?? 0) > COMPLAINED_WARNING_RATE + ? ACCOUNT_STATUS.WARNING + : ACCOUNT_STATUS.HEALTHY; + + return ( + +
+
+
+
+
Bounce Rate
+ + + + + + The percentage of emails sent from your account that resulted + in a hard bounce. + + +
+
+
+
+
{metrics?.bounceRate}%
+ +
+ {/*
+ +
*/} + + + `${value}%`} + hide={false} + axisLine={false} + tickLine={false} + interval={0} + /> + + + + {/* */} + {/* */} + + {/* */} + + + { + const data = payload?.[0]?.payload as { + name: string; + value: number; + }; + + if (!data) return null; + + return ( +
+

+ {data.name} +

+
+
+

+ Current +

+

{data.value}%

+
+
+
+

+ Warning at +

+

+ {HARD_BOUNCE_WARNING_RATE}% +

+
+
+
+

+ Risk at +

+

+ {HARD_BOUNCE_RISK_RATE}% +

+
+
+ ); + }} + cursor={false} + /> + +
+
+
+
+
+
Complaint Rate
+ + + + + + The percentage of emails sent from your account that resulted in + recipients reporting them as spam. + + +
+
+
{metrics?.complaintRate}%
+ +
+ + + `${value}%`} + hide={false} + axisLine={false} + interval={0} + /> + + + + + {/* */} + + + { + const data = payload?.[0]?.payload as { + name: string; + value: number; + }; + + if (!data) return null; + + return ( +
+

+ {data.name} +

+
+
+

+ Current +

+

{data.value}%

+
+
+
+

+ Warning at +

+

+ {COMPLAINED_WARNING_RATE}% +

+
+
+
+

+ Risk at +

+

+ {COMPLAINED_RISK_RATE}% +

+
+
+ ); + }} + cursor={false} + /> + +
+
+
+
+
+ ); +} + +export const StatusBadge: React.FC<{ status: ACCOUNT_STATUS }> = ({ + status, +}) => { + const className = + status === "HEALTHY" + ? " text-success border-success" + : status === "WARNING" + ? " text-warning border-warning" + : " text-destructive border-destructive"; + + const StatusIcon = + status === "HEALTHY" + ? CheckCircle2Icon + : status === "WARNING" + ? TriangleAlertIcon + : OctagonAlertIcon; + + return ( +
+ + {status.toLowerCase()} +
+ ); +}; diff --git a/apps/web/src/lib/constants/colors.ts b/apps/web/src/lib/constants/colors.ts new file mode 100644 index 0000000..031320f --- /dev/null +++ b/apps/web/src/lib/constants/colors.ts @@ -0,0 +1,18 @@ +import { EmailStatus } from "@prisma/client"; + +export const EMAIL_COLORS: Record = { + total: "bg-gray-400 dark:bg-gray-400", + DELIVERED: "bg-[#40a02b] dark:bg-[#a6e3a1]", + BOUNCED: "bg-[#d20f39] dark:bg-[#f38ba8]", + FAILED: "bg-[#d20f39] dark:bg-[#f38ba8]", + CLICKED: "bg-[#04a5e5] dark:bg-[#93c5fd]", + OPENED: "bg-[#8839ef] dark:bg-[#cba6f7]", + COMPLAINED: "bg-[#df8e1d] dark:bg-[#F9E2AF]", + DELIVERY_DELAYED: "bg-[#df8e1d] dark:bg-[#F9E2AF]", + SENT: "bg-gray-200 dark:bg-gray-400", + SCHEDULED: "bg-gray-200 dark:bg-gray-400", + QUEUED: "bg-gray-200 dark:bg-gray-400", + REJECTED: "bg-[#d20f39] dark:bg-[#f38ba8]", + RENDERING_FAILURE: "bg-[#d20f39] dark:bg-[#f38ba8]", + CANCELLED: "bg-gray-200 dark:bg-gray-400", +}; diff --git a/apps/web/src/lib/constants/index.ts b/apps/web/src/lib/constants/index.ts index 61fa128..07db034 100644 --- a/apps/web/src/lib/constants/index.ts +++ b/apps/web/src/lib/constants/index.ts @@ -1 +1,7 @@ export const DEFAULT_QUERY_LIMIT = 30; + +/* Reputation constants */ +export const HARD_BOUNCE_WARNING_RATE = 5; +export const HARD_BOUNCE_RISK_RATE = 10; +export const COMPLAINED_WARNING_RATE = 0.1; +export const COMPLAINED_RISK_RATE = 0.5; diff --git a/apps/web/src/server/api/root.ts b/apps/web/src/server/api/root.ts index 07c80e8..68c37ad 100644 --- a/apps/web/src/server/api/root.ts +++ b/apps/web/src/server/api/root.ts @@ -9,6 +9,7 @@ import { campaignRouter } from "./routers/campaign"; import { templateRouter } from "./routers/template"; import { billingRouter } from "./routers/billing"; import { invitationRouter } from "./routers/invitiation"; +import { dashboardRouter } from "./routers/dashboard"; /** * This is the primary router for your server. @@ -26,6 +27,7 @@ export const appRouter = createTRPCRouter({ template: templateRouter, billing: billingRouter, invitation: invitationRouter, + dashboard: dashboardRouter, }); // export type definition of API diff --git a/apps/web/src/server/api/routers/dashboard.ts b/apps/web/src/server/api/routers/dashboard.ts new file mode 100644 index 0000000..9e1e7c4 --- /dev/null +++ b/apps/web/src/server/api/routers/dashboard.ts @@ -0,0 +1,140 @@ +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"; + +export const dashboardRouter = createTRPCRouter({ + emailTimeSeries: teamProcedure + .input( + z.object({ + days: z.number().optional(), + domain: z.number().optional(), + }) + ) + .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>` + 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 + .input( + z.object({ + domain: z.number().optional(), + }) + ) + .query(async ({ ctx, input }) => { + const { team } = ctx; + + 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; + }), +}); diff --git a/apps/web/src/server/api/routers/email.ts b/apps/web/src/server/api/routers/email.ts index aac665b..28391f9 100644 --- a/apps/web/src/server/api/routers/email.ts +++ b/apps/web/src/server/api/routers/email.ts @@ -61,99 +61,6 @@ export const emailRouter = createTRPCRouter({ return { emails }; }), - dashboard: teamProcedure - .input( - z.object({ - days: z.number().optional(), - domain: z.number().optional(), - }) - ) - .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>` - 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 }; - }), - getEmail: emailProcedure.query(async ({ input }) => { const email = await db.email.findUnique({ where: { diff --git a/apps/web/src/server/service/ses-hook-parser.ts b/apps/web/src/server/service/ses-hook-parser.ts index 4a772ce..39acf18 100644 --- a/apps/web/src/server/service/ses-hook-parser.ts +++ b/apps/web/src/server/service/ses-hook-parser.ts @@ -66,8 +66,6 @@ export async function parseSesHook(data: SesEvent) { mailStatus === EmailStatus.BOUNCED && (mailData as SesBounce).bounceType === "Permanent"; - console.log("mailStatus", mailStatus, "isHardBounced", isHardBounced); - if ( [ "DELIVERED", @@ -109,6 +107,31 @@ export async function parseSesHook(data: SesEvent) { ...(isHardBounced ? { hardBounced: { increment: 1 } } : {}), }, }); + + if ( + isHardBounced || + updateField === "complained" || + updateField === "delivered" + ) { + await db.cumulatedMetrics.upsert({ + where: { + teamId_domainId: { + teamId: email.teamId, + domainId: email.domainId ?? 0, + }, + }, + update: { + [updateField]: { + increment: BigInt(1), + }, + }, + create: { + teamId: email.teamId, + domainId: email.domainId ?? 0, + [updateField]: BigInt(1), + }, + }); + } } if (email.campaignId) { diff --git a/packages/tailwind-config/tailwind.config.ts b/packages/tailwind-config/tailwind.config.ts index a211282..75b1acb 100644 --- a/packages/tailwind-config/tailwind.config.ts +++ b/packages/tailwind-config/tailwind.config.ts @@ -61,6 +61,10 @@ const config = { DEFAULT: "hsl(var(--success))", foreground: "hsl(var(--success-foreground))", }, + warning: { + DEFAULT: "hsl(var(--warning))", + foreground: "hsl(var(--warning-foreground))", + }, }, borderRadius: { lg: "var(--radius)", diff --git a/packages/ui/src/tabs.tsx b/packages/ui/src/tabs.tsx index 35e1ef8..363d71b 100644 --- a/packages/ui/src/tabs.tsx +++ b/packages/ui/src/tabs.tsx @@ -14,7 +14,7 @@ const TabsList = React.forwardRef<