Add ses hook parser to capture all the events
This commit is contained in:
@@ -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
|
||||
|
@@ -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>
|
||||
);
|
||||
|
92
apps/web/src/app/(dashboard)/dashboard/dashboard-chart.tsx
Normal file
92
apps/web/src/app/(dashboard)/dashboard/dashboard-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
@@ -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>
|
||||
|
@@ -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.
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
);
|
||||
};
|
||||
|
@@ -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
|
||||
|
@@ -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>
|
||||
|
@@ -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" });
|
||||
}
|
||||
|
Reference in New Issue
Block a user