Add ses hook parser to capture all the events

This commit is contained in:
KMKoushik
2024-04-16 14:04:30 +10:00
parent 293277ed32
commit 9465960f0a
22 changed files with 712 additions and 105 deletions

View File

@@ -40,6 +40,7 @@
"prisma": "^5.11.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"recharts": "^2.12.5",
"server-only": "^0.0.1",
"superjson": "^2.2.1",
"tldts": "^6.1.16",

View File

@@ -137,14 +137,27 @@ model ApiKey {
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
}
enum EmailStatus {
SENT
OPENED
CLICKED
BOUNCED
COMPLAINED
DELIVERED
REJECTED
RENDERING_FAILURE
DELIVERY_DELAYED
}
model Email {
id String @id
id String @id @default(cuid())
sesEmailId String? @unique
to String
from String
subject String
text String?
html String?
latestStatus String?
latestStatus EmailStatus @default(SENT)
teamId Int
domainId Int?
createdAt DateTime @default(now())
@@ -155,10 +168,10 @@ model Email {
model EmailEvent {
emailId String
status String
status EmailStatus
data Json?
createdAt DateTime @default(now())
email Email @relation(fields: [emailId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
email Email @relation(fields: [emailId], references: [id], onDelete: Cascade)
@@unique([emailId, status])
}

View File

@@ -15,12 +15,15 @@ import {
import { api } from "~/trpc/react";
import { useState } from "react";
import { CheckIcon, ClipboardCopy, Plus } from "lucide-react";
import { toast } from "@unsend/ui/src/toaster";
export default function AddApiKey() {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [apiKey, setApiKey] = useState("");
const addDomainMutation = api.apiKey.createToken.useMutation();
const [isCopied, setIsCopied] = useState(false);
const utils = api.useUtils();
@@ -41,9 +44,18 @@ export default function AddApiKey() {
function handleCopy() {
navigator.clipboard.writeText(apiKey);
setIsCopied(true);
setTimeout(() => {
setIsCopied(false);
}, 2000);
}
function copyAndClose() {
handleCopy();
setApiKey("");
setName("");
setOpen(false);
toast.success("API key copied to clipboard");
}
return (
@@ -52,18 +64,34 @@ export default function AddApiKey() {
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
>
<DialogTrigger asChild>
<Button>Add API Key</Button>
<Button>
<Plus className="h-4 w-4 mr-1" />
Add API Key
</Button>
</DialogTrigger>
{apiKey ? (
<DialogContent>
<DialogHeader>
<DialogTitle>Copy API key</DialogTitle>
</DialogHeader>
<div className="py-2 bg-gray-200 rounded-lg">{apiKey}</div>
<div className="py-2 bg-muted rounded-lg px-2 flex items-center justify-between">
<p>{apiKey}</p>
<Button
variant="ghost"
className="hover:bg-transparent p-0 cursor-pointer group-hover:opacity-100"
onClick={handleCopy}
>
{isCopied ? (
<CheckIcon className="h-4 w-4 text-green-500" />
) : (
<ClipboardCopy className="h-4 w-4" />
)}
</Button>
</div>
<DialogFooter>
<Button
type="submit"
onClick={handleCopy}
onClick={copyAndClose}
disabled={addDomainMutation.isPending}
>
Close

View File

@@ -1,5 +1,14 @@
"use client";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@unsend/ui/src/table";
import { formatDistanceToNow } from "date-fns";
import Link from "next/link";
import { api } from "~/trpc/react";
@@ -8,18 +17,35 @@ export default function ApiList() {
return (
<div className="mt-10">
<div className="flex flex-col gap-2">
{!apiKeysQuery.isLoading && apiKeysQuery.data?.length ? (
apiKeysQuery.data?.map((apiKey) => (
<div className="p-2 px-4 border rounded-lg flex justify-between">
<p>{apiKey.name}</p>
<p>{apiKey.permission}</p>
<p>{apiKey.partialToken}</p>
</div>
))
) : (
<div>No API keys added</div>
)}
<div className="border rounded-xl">
<Table className="">
<TableHeader className="">
<TableRow className=" bg-muted/30">
<TableHead className="rounded-tl-xl">Name</TableHead>
<TableHead>Token</TableHead>
<TableHead>Permission</TableHead>
<TableHead>Last used</TableHead>
<TableHead className="rounded-tr-xl">Created at</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{apiKeysQuery.data?.map((apiKey) => (
<TableRow key={apiKey.id}>
<TableCell>{apiKey.name}</TableCell>
<TableCell>{apiKey.partialToken}</TableCell>
<TableCell>{apiKey.permission}</TableCell>
<TableCell>
{apiKey.lastUsed
? formatDistanceToNow(apiKey.lastUsed)
: "Never"}
</TableCell>
<TableCell>
{formatDistanceToNow(apiKey.createdAt, { addSuffix: true })}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);

View File

@@ -0,0 +1,92 @@
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
} from "recharts";
const data = [
{
name: "Page A",
uv: 4000,
pv: 2400,
amt: 2400,
},
{
name: "Page B",
uv: 3000,
pv: 1398,
amt: 2210,
},
{
name: "Page C",
uv: 2000,
pv: 9800,
amt: 2290,
},
{
name: "Page D",
uv: 2780,
pv: 3908,
amt: 2000,
},
{
name: "Page E",
uv: 1890,
pv: 4800,
amt: 2181,
},
{
name: "Page F",
uv: 2390,
pv: 3800,
amt: 2500,
},
{
name: "Page G",
uv: 3490,
pv: 4300,
amt: 2100,
},
];
export default function DashboardChart() {
return (
<AreaChart
width={900}
height={250}
data={data}
margin={{ top: 10, right: 30, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#8884d8" stopOpacity={0.8} />
<stop offset="95%" stopColor="#8884d8" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorPv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#82ca9d" stopOpacity={0.8} />
<stop offset="95%" stopColor="#82ca9d" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Area
type="monotone"
dataKey="uv"
stroke="#8884d8"
fillOpacity={1}
fill="url(#colorUv)"
/>
<Area
type="monotone"
dataKey="pv"
stroke="#82ca9d"
fillOpacity={1}
fill="url(#colorPv)"
/>
</AreaChart>
);
}

View File

@@ -1,3 +1,5 @@
"use client";
import Link from "next/link";
import {
Bell,
@@ -23,7 +25,15 @@ import {
} from "@unsend/ui/src/dropdown-menu";
import { Input } from "@unsend/ui/src/input";
import { Sheet, SheetContent, SheetTrigger } from "@unsend/ui/src/sheet";
import DashboardChart from "./dashboard-chart";
export default function Dashboard() {
return <div>Hello</div>;
return (
<div>
Dashboard
<div className="mx-auto flex justify-center item-center mt-[30vh]">
<DashboardChart />
</div>
</div>
);
}

View File

@@ -49,7 +49,7 @@ export const DeleteDomain: React.FC<{ domain: Domain }> = ({ domain }) => {
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
>
<DialogTrigger asChild>
<Button variant="destructive" className="w-[200px]">
<Button variant="destructive" className="w-[150px]" size="sm">
Delete domain
</Button>
</DialogTrigger>

View File

@@ -287,7 +287,7 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
</div>
<div className="flex flex-col gap-2">
<p className="font-semibold text-xl mt-2 text-destructive">Danger</p>
<p className="font-semibold text-lg text-destructive">Danger</p>
<p className="text-destructive text-sm font-semibold">
Deleting a domain will stop sending emails with this domain.

View File

@@ -15,6 +15,7 @@ import {
import { api } from "~/trpc/react";
import { useState } from "react";
import { Plus } from "lucide-react";
export default function AddDomain() {
const [open, setOpen] = useState(false);
@@ -43,7 +44,10 @@ export default function AddDomain() {
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
>
<DialogTrigger asChild>
<Button>Add domain</Button>
<Button>
<Plus className="h-4 w-4 mr-1" />
Add domain
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>

View File

@@ -20,7 +20,7 @@ export default function DomainsList() {
<DomainItem key={domain.id} domain={domain} />
))
) : (
<div>No domains</div>
<div className="text-center mt-20">No domains Added</div>
)}
</div>
</div>

View File

@@ -21,6 +21,7 @@ import {
MailX,
} from "lucide-react";
import { formatDistance, formatDistanceToNow } from "date-fns";
import { EmailStatus } from "@prisma/client";
export default function EmailsList() {
const emailsQuery = api.email.emails.useQuery();
@@ -65,40 +66,40 @@ export default function EmailsList() {
);
}
const EmailIcon: React.FC<{ status: string }> = ({ status }) => {
const EmailIcon: React.FC<{ status: EmailStatus }> = ({ status }) => {
switch (status) {
case "Send":
case "SENT":
return (
// <div className="border border-gray-400/60 p-2 rounded-lg bg-gray-400/10">
<Mail className="w-6 h-6 text-gray-500 " />
// </div>
);
case "Delivery":
case "Delayed":
case "DELIVERED":
return (
// <div className="border border-emerald-600/60 p-2 rounded-lg bg-emerald-500/10">
<MailCheck className="w-6 h-6 text-emerald-800" />
// </div>
);
case "Bounced":
case "BOUNCED":
return (
// <div className="border border-red-600/60 p-2 rounded-lg bg-red-500/10">
<MailX className="w-6 h-6 text-red-900" />
// </div>
);
case "Clicked":
case "CLICKED":
return (
// <div className="border border-cyan-600/60 p-2 rounded-lg bg-cyan-500/10">
<MailSearch className="w-6 h-6 text-cyan-700" />
// </div>
);
case "Opened":
case "OPENED":
return (
// <div className="border border-indigo-600/60 p-2 rounded-lg bg-indigo-500/10">
<MailOpen className="w-6 h-6 text-indigo-700" />
// </div>
);
case "Complained":
case "DELIVERY_DELAYED":
case "COMPLAINED":
return (
// <div className="border border-yellow-600/60 p-2 rounded-lg bg-yellow-500/10">
<MailWarning className="w-6 h-6 text-yellow-700" />
@@ -113,26 +114,27 @@ const EmailIcon: React.FC<{ status: string }> = ({ status }) => {
}
};
const EmailStatusBadge: React.FC<{ status: string }> = ({ status }) => {
const EmailStatusBadge: React.FC<{ status: EmailStatus }> = ({ status }) => {
let badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10"; // Default color
switch (status) {
case "Send":
case "SENT":
badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10";
break;
case "Delivery":
case "Delayed":
case "DELIVERED":
badgeColor = "bg-emerald-500/10 text-emerald-500 border-emerald-600/10";
break;
case "Bounced":
case "BOUNCED":
badgeColor = "bg-red-500/10 text-red-800 border-red-600/10";
break;
case "Clicked":
case "CLICKED":
badgeColor = "bg-cyan-500/10 text-cyan-600 border-cyan-600/10";
break;
case "Opened":
case "OPENED":
badgeColor = "bg-indigo-500/10 text-indigo-600 border-indigo-600/10";
break;
case "Complained":
case "DELIVERY_DELAYED":
badgeColor = "bg-yellow-500/10 text-yellow-600 border-yellow-600/10";
case "COMPLAINED":
badgeColor = "bg-yellow-500/10 text-yellow-600 border-yellow-600/10";
break;
default:
@@ -141,9 +143,9 @@ const EmailStatusBadge: React.FC<{ status: string }> = ({ status }) => {
return (
<div
className={`border text-center w-[120px] rounded-full py-0.5 ${badgeColor}`}
className={` text-center w-[130px] rounded capitalize py-0.5 text-xs ${badgeColor}`}
>
{status}
{status.toLowerCase().split("_").join(" ")}
</div>
);
};

View File

@@ -2,6 +2,7 @@ import Link from "next/link";
import { redirect } from "next/navigation";
import {
Bell,
BellRing,
CircleUser,
Globe,
Home,
@@ -10,6 +11,7 @@ import {
LineChart,
Mail,
Menu,
MessageSquareMore,
Package,
Package2,
Search,
@@ -85,6 +87,16 @@ export default async function AuthenticatedDashboardLayout({
Domains
</NavButton>
<NavButton href="/sms" comingSoon>
<MessageSquareMore className="h-4 w-4" />
SMS
</NavButton>
<NavButton href="/sms" comingSoon>
<BellRing className="h-4 w-4" />
Push notification
</NavButton>
<NavButton href="/api-keys">
<KeyRound className="h-4 w-4" />
API keys

View File

@@ -7,15 +7,31 @@ import React from "react";
export const NavButton: React.FC<{
href: string;
children: React.ReactNode;
}> = ({ href, children }) => {
comingSoon?: boolean;
}> = ({ href, children, comingSoon }) => {
const pathname = usePathname();
const isActive = pathname?.startsWith(href);
if (comingSoon) {
return (
<div className="flex items-center justify-between hover:text-primary cursor-not-allowed mt-1">
<div
className={`flex items-center gap-3 rounded-lg px-3 py-2 transition-all hover:text-primary cursor-not-allowed ${isActive ? " bg-secondary" : "text-muted-foreground"}`}
>
{children}
</div>
<div className="text-muted-foreground px-4 py-0.5 text-xs bg-muted rounded-full">
soon
</div>
</div>
);
}
return (
<Link
href={href}
className={`flex items-center gap-3 rounded-lg px-3 py-2 transition-all hover:text-primary ${isActive ? " bg-secondary" : "text-muted-foreground"}`}
className={`flex items-center mt-1 gap-3 rounded-lg px-3 py-2 transition-all hover:text-primary ${isActive ? " bg-secondary" : "text-muted-foreground"}`}
>
{children}
</Link>

View File

@@ -1,6 +1,7 @@
import { headers } from "next/headers";
import { hashToken } from "~/server/auth";
import { db } from "~/server/db";
import { parseSesHook } from "~/server/service/ses-hook-parser";
export async function GET(req: Request) {
console.log("GET", req);
@@ -10,67 +11,26 @@ export async function GET(req: Request) {
export async function POST(req: Request) {
const data = await req.json();
console.log(data, data.Message);
if (data.Type === "SubscriptionConfirmation") {
return handleSubscription(data);
}
console.log(data, data.Message);
let message = null;
try {
message = JSON.parse(data.Message || "{}");
const status = await parseSesHook(message);
if (!status) {
return Response.json({ data: "Error is parsing hook" }, { status: 400 });
}
return Response.json({ data: "Success" });
} catch (e) {
console.log(e);
console.error(e);
return Response.json({ data: "Error is parsing hook" }, { status: 400 });
}
const emailId = message?.mail.messageId;
console.log(emailId, message);
if (!emailId) {
return Response.json({ data: "Email not found" });
}
const email = await db.email.findUnique({
where: {
id: emailId,
},
});
if (!email || !message.mail) {
return Response.json({ data: "Email not found" });
}
console.log("FOund email", email);
await db.email.update({
where: {
id: email.id,
},
data: {
latestStatus: message.eventType,
},
});
await db.emailEvent.upsert({
where: {
emailId_status: {
emailId,
status: message.eventType,
},
},
update: {
data: message[message.eventType.toLowerCase()],
},
create: {
emailId,
status: message.eventType,
data: message[message.eventType.toLowerCase()],
},
});
return Response.json({ data: "Hello" });
}
async function handleSubscription(message: any) {
@@ -78,5 +38,5 @@ async function handleSubscription(message: any) {
method: "GET",
});
return Response.json({ data: "Hello" });
return Response.json({ data: "Success" });
}

View File

@@ -34,6 +34,7 @@ export const apiRouter = createTRPCRouter({
permission: true,
partialToken: true,
lastUsed: true,
createdAt: true,
},
});

View File

@@ -4,7 +4,7 @@ import { APP_SETTINGS } from "~/utils/constants";
import { createTopic, subscribeEndpoint } from "./sns";
import { env } from "~/env";
import { AppSettingsService } from "~/server/service/app-settings-service";
import { addWebhookConfiguration } from "../ses";
import { addWebhookConfiguration } from "./ses";
import { EventType } from "@aws-sdk/client-sesv2";
const GENERAL_EVENTS: EventType[] = [

View File

@@ -1,7 +1,7 @@
import dns from "dns";
import util from "util";
import * as tldts from "tldts";
import * as ses from "~/server/ses";
import * as ses from "~/server/aws/ses";
import { db } from "~/server/db";
const dnsResolveTxt = util.promisify(dns.resolveTxt);

View File

@@ -1,6 +1,6 @@
import { EmailContent } from "~/types";
import { db } from "../db";
import { sendEmailThroughSes } from "../ses";
import { sendEmailThroughSes } from "../aws/ses";
import { APP_SETTINGS } from "~/utils/constants";
export async function sendEmail(
@@ -45,7 +45,7 @@ export async function sendEmail(
subject,
text,
html,
id: messageId,
sesEmailId: messageId,
teamId,
domainId: domain.id,
},

View File

@@ -0,0 +1,91 @@
import { EmailStatus } from "@prisma/client";
import { SesEvent, SesEventDataKey, SesEventType } from "~/types/aws-types";
import { db } from "../db";
export async function parseSesHook(data: SesEvent) {
const mailStatus = getEmailStatus(data);
if (!mailStatus) {
console.error("Unknown email status", data);
return false;
}
const sesEmailId = data.mail.messageId;
const mailData = getEmailData(data);
const email = await db.email.findUnique({
where: {
sesEmailId,
},
});
if (!email) {
console.error("Email not found", data);
return false;
}
await db.email.update({
where: {
id: email.id,
},
data: {
latestStatus: mailStatus,
},
});
await db.emailEvent.upsert({
where: {
emailId_status: {
emailId: email.id,
status: mailStatus,
},
},
update: {
data: mailData as any,
},
create: {
emailId: email.id,
status: mailStatus,
data: mailData as any,
},
});
return true;
}
function getEmailStatus(data: SesEvent) {
const { eventType } = data;
if (eventType === "Send") {
return EmailStatus.SENT;
} else if (eventType === "Delivery") {
return EmailStatus.DELIVERED;
} else if (eventType === "Bounce") {
return EmailStatus.BOUNCED;
} else if (eventType === "Complaint") {
return EmailStatus.COMPLAINED;
} else if (eventType === "Reject") {
return EmailStatus.REJECTED;
} else if (eventType === "Open") {
return EmailStatus.OPENED;
} else if (eventType === "Click") {
return EmailStatus.CLICKED;
} else if (eventType === "Rendering Failure") {
return EmailStatus.RENDERING_FAILURE;
} else if (eventType === "DeliveryDelay") {
return EmailStatus.DELIVERY_DELAYED;
}
}
function getEmailData(data: SesEvent) {
const { eventType } = data;
if (eventType === "Rendering Failure") {
return data.renderingFailure;
} else if (eventType === "DeliveryDelay") {
return data.deliveryDelay;
} else {
return data[eventType.toLowerCase() as SesEventDataKey];
}
}

View File

@@ -0,0 +1,138 @@
export interface SnsNotificationMessage {
Type: string;
MessageId: string;
TopicArn: string;
Subject?: string;
Message: string; // This is a JSON string that needs to be parsed into one of the SES event types below
Timestamp: string;
SignatureVersion: string;
Signature: string;
SigningCertURL: string;
UnsubscribeURL: string;
}
export interface SesMail {
timestamp: string;
source: string;
messageId: string;
destination: string[];
headersTruncated: boolean;
headers: Array<{ name: string; value: string }>;
commonHeaders: {
from: string[];
to: string[];
messageId: string;
subject?: string;
};
tags: { [key: string]: string[] };
}
export interface SesBounce {
bounceType: string;
bounceSubType: string;
bouncedRecipients: Array<{
emailAddress: string;
action: string;
status: string;
diagnosticCode?: string;
}>;
timestamp: string;
feedbackId: string;
reportingMTA: string;
}
export interface SesComplaint {
complainedRecipients: Array<{
emailAddress: string;
}>;
timestamp: string;
feedbackId: string;
complaintFeedbackType: string;
userAgent: string;
complaintSubType?: string;
arrivedDate?: string;
}
export interface SesDelivery {
timestamp: string;
processingTimeMillis: number;
recipients: string[];
smtpResponse: string;
reportingMTA: string;
remoteMtaIp?: string;
}
export interface SesSend {
timestamp: string;
smtpResponse: string;
reportingMTA: string;
recipients: string[];
}
export interface SesReject {
reason: string;
timestamp: string;
}
export interface SesOpen {
ipAddress: string;
timestamp: string;
userAgent: string;
}
export interface SesClick {
ipAddress: string;
timestamp: string;
userAgent: string;
link: string;
linkTags: { [key: string]: string };
}
export interface SesRenderingFailure {
errorMessage: string;
templateName: string;
}
export interface SesDeliveryDelay {
delayType: string;
expirationTime: string;
delayedRecipients: string[];
timestamp: string;
}
export type SesEventType =
| "Bounce"
| "Complaint"
| "Delivery"
| "Send"
| "Reject"
| "Open"
| "Click"
| "Rendering Failure"
| "DeliveryDelay";
export type SesEventDataKey =
| "bounce"
| "complaint"
| "delivery"
| "send"
| "reject"
| "open"
| "click"
| "renderingFailure"
| "deliveryDelay";
export interface SesEvent {
eventType: SesEventType;
mail: SesMail;
bounce?: SesBounce;
complaint?: SesComplaint;
delivery?: SesDelivery;
send?: SesSend;
reject?: SesReject;
open?: SesOpen;
click?: SesClick;
renderingFailure?: SesRenderingFailure;
deliveryDelay?: SesDeliveryDelay;
// Additional fields for other event types can be added here
}

221
pnpm-lock.yaml generated
View File

@@ -154,6 +154,9 @@ importers:
react-dom:
specifier: 18.2.0
version: 18.2.0(react@18.2.0)
recharts:
specifier: ^2.12.5
version: 2.12.5(react-dom@18.2.0)(react@18.2.0)
server-only:
specifier: ^0.0.1
version: 0.0.1
@@ -2922,6 +2925,48 @@ packages:
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
dev: false
/@types/d3-array@3.2.1:
resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==}
dev: false
/@types/d3-color@3.1.3:
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
dev: false
/@types/d3-ease@3.0.2:
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
dev: false
/@types/d3-interpolate@3.0.4:
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
dependencies:
'@types/d3-color': 3.1.3
dev: false
/@types/d3-path@3.1.0:
resolution: {integrity: sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==}
dev: false
/@types/d3-scale@4.0.8:
resolution: {integrity: sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==}
dependencies:
'@types/d3-time': 3.0.3
dev: false
/@types/d3-shape@3.1.6:
resolution: {integrity: sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==}
dependencies:
'@types/d3-path': 3.1.0
dev: false
/@types/d3-time@3.0.3:
resolution: {integrity: sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==}
dev: false
/@types/d3-timer@3.0.2:
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
dev: false
/@types/eslint@8.56.5:
resolution: {integrity: sha512-u5/YPJHo1tvkSF2CE0USEkxon82Z5DBy2xR+qfyYNszpX9qcs4sT6uq2kBbj4BXY1+DBGDPnrhMZV3pKWGNukw==}
dependencies:
@@ -3836,6 +3881,77 @@ packages:
/csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
/d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
dependencies:
internmap: 2.0.3
dev: false
/d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
dev: false
/d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
dev: false
/d3-format@3.1.0:
resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==}
engines: {node: '>=12'}
dev: false
/d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
dependencies:
d3-color: 3.1.0
dev: false
/d3-path@3.1.0:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
dev: false
/d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
dependencies:
d3-array: 3.2.4
d3-format: 3.1.0
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
dev: false
/d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
dependencies:
d3-path: 3.1.0
dev: false
/d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
dependencies:
d3-time: 3.1.0
dev: false
/d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
dependencies:
d3-array: 3.2.4
dev: false
/d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
dev: false
/damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
dev: true
@@ -3867,6 +3983,10 @@ packages:
ms: 2.1.2
dev: true
/decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
dev: false
/deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
dev: true
@@ -3935,6 +4055,13 @@ packages:
esutils: 2.0.3
dev: true
/dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
dependencies:
'@babel/runtime': 7.24.0
csstype: 3.1.3
dev: false
/dotenv-cli@7.4.1:
resolution: {integrity: sha512-fE1aywjRrWGxV3miaiUr3d2zC/VAiuzEGghi+QzgIA9fEf/M5hLMaRSXb4IxbUAwGmaLi0IozdZddnVU96acag==}
hasBin: true
@@ -4208,7 +4335,7 @@ packages:
enhanced-resolve: 5.16.0
eslint: 8.57.0
eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-plugin-import: 2.29.1(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0)(eslint@8.57.0)
fast-glob: 3.3.2
get-tsconfig: 4.7.3
is-core-module: 2.13.1
@@ -4703,10 +4830,19 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
dev: false
/fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
dev: true
/fast-equals@5.0.1:
resolution: {integrity: sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==}
engines: {node: '>=6.0.0'}
dev: false
/fast-glob@3.3.2:
resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==}
engines: {node: '>=8.6.0'}
@@ -5106,6 +5242,11 @@ packages:
side-channel: 1.0.6
dev: true
/internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
dev: false
/invariant@2.2.4:
resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==}
dependencies:
@@ -5480,7 +5621,6 @@ packages:
/lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
dev: true
/loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
@@ -6172,7 +6312,6 @@ packages:
loose-envify: 1.4.0
object-assign: 4.1.1
react-is: 16.13.1
dev: true
/property-information@5.6.0:
resolution: {integrity: sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==}
@@ -6200,7 +6339,6 @@ packages:
/react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
dev: true
/react-remove-scroll-bar@2.3.6(@types/react@18.2.66)(react@18.2.0):
resolution: {integrity: sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==}
@@ -6237,6 +6375,19 @@ packages:
use-sidecar: 1.1.2(@types/react@18.2.66)(react@18.2.0)
dev: false
/react-smooth@4.0.1(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-OE4hm7XqR0jNOq3Qmk9mFLyd6p2+j6bvbPJ7qlB7+oo0eNcL2l7WQzG6MBnT3EXY6xzkLMUBec3AfewJdA0J8w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
fast-equals: 5.0.1
prop-types: 15.8.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-transition-group: 4.4.5(react-dom@18.2.0)(react@18.2.0)
dev: false
/react-style-singleton@2.2.1(@types/react@18.2.66)(react@18.2.0):
resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==}
engines: {node: '>=10'}
@@ -6267,6 +6418,20 @@ packages:
refractor: 3.6.0
dev: false
/react-transition-group@4.4.5(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
peerDependencies:
react: '>=16.6.0'
react-dom: '>=16.6.0'
dependencies:
'@babel/runtime': 7.24.0
dom-helpers: 5.2.1
loose-envify: 1.4.0
prop-types: 15.8.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/react@18.2.0:
resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==}
engines: {node: '>=0.10.0'}
@@ -6303,6 +6468,31 @@ packages:
dependencies:
picomatch: 2.3.1
/recharts-scale@0.4.5:
resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==}
dependencies:
decimal.js-light: 2.5.1
dev: false
/recharts@2.12.5(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-Cy+BkqrFIYTHJCyKHJEPvbHE2kVQEP6PKbOHJ8ztRGTAhvHuUnCwDaKVb13OwRFZ0QNUk1QvGTDdgWSMbuMtKw==}
engines: {node: '>=14'}
peerDependencies:
react: ^16.0.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0
dependencies:
clsx: 2.1.0
eventemitter3: 4.0.7
lodash: 4.17.21
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-is: 16.13.1
react-smooth: 4.0.1(react-dom@18.2.0)(react@18.2.0)
recharts-scale: 0.4.5
tiny-invariant: 1.3.3
victory-vendor: 36.9.2
dev: false
/reflect.getprototypeof@1.0.5:
resolution: {integrity: sha512-62wgfC8dJWrmxv44CA36pLDnP6KKl3Vhxb7PL+8+qrrFMMoJij4vgiMP8zV4O8+CBMXY1mHxI5fITGHXFHVmQQ==}
engines: {node: '>= 0.4'}
@@ -6783,6 +6973,10 @@ packages:
dependencies:
any-promise: 1.3.0
/tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
dev: false
/tldts-core@6.1.16:
resolution: {integrity: sha512-rxnuCux+zn3hMF57nBzr1m1qGZH7Od2ErbDZjVm04fk76cEynTg3zqvHjx5BsBl8lvRTjpzIhsEGMHDH/Hr2Vw==}
dev: false
@@ -7049,6 +7243,25 @@ packages:
spdx-expression-parse: 3.0.1
dev: true
/victory-vendor@36.9.2:
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
dependencies:
'@types/d3-array': 3.2.1
'@types/d3-ease': 3.0.2
'@types/d3-interpolate': 3.0.4
'@types/d3-scale': 4.0.8
'@types/d3-shape': 3.1.6
'@types/d3-time': 3.0.3
'@types/d3-timer': 3.0.2
d3-array: 3.2.4
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-shape: 3.2.0
d3-time: 3.1.0
d3-timer: 3.0.1
dev: false
/which-boxed-primitive@1.0.2:
resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==}
dependencies: