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<