fix email sender all flow
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Admin · Email Log",
|
||||
description:
|
||||
"Halaman admin untuk memantau pengiriman email — antrian, kegagalan, dan retry.",
|
||||
alternates: { canonical: "/admin/emails" },
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function AdminEmailsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { isAdminEmail } from "@/lib/admin";
|
||||
import { emailRepo } from "@/server/repositories/email.repo";
|
||||
import {
|
||||
RetryEmailButton,
|
||||
ResendEmailButton,
|
||||
} from "@/features/email/components/email-row-actions";
|
||||
|
||||
type Tab = "failed" | "queue" | "sent";
|
||||
|
||||
const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: "failed", label: "Gagal" },
|
||||
{ key: "queue", label: "Antrian" },
|
||||
{ key: "sent", label: "Terkirim" },
|
||||
];
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ tab?: string; to?: string; template?: string }>;
|
||||
}
|
||||
|
||||
export default async function AdminEmailsPage({ searchParams }: PageProps) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) redirect("/login?callbackUrl=/admin/emails");
|
||||
if (!isAdminEmail(session.user.email)) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
|
||||
<p className="text-sm text-neutral-600">
|
||||
Halaman ini hanya untuk admin SeTrip.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const params = await searchParams;
|
||||
const tab: Tab = TABS.some((t) => t.key === params.tab)
|
||||
? (params.tab as Tab)
|
||||
: "failed";
|
||||
const filters = {
|
||||
to: params.to?.trim() || undefined,
|
||||
template: params.template?.trim() || undefined,
|
||||
};
|
||||
|
||||
const stats = await emailRepo.stats();
|
||||
const jobs =
|
||||
tab === "sent"
|
||||
? []
|
||||
: await emailRepo.listJobs(
|
||||
tab === "failed" ? ["FAILED"] : ["PENDING", "PROCESSING"],
|
||||
filters
|
||||
);
|
||||
const sent = tab === "sent" ? await emailRepo.listSent(filters) : [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||
Email Log
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
Pantau pengiriman email transaksional. Email yang gagal dikirim bisa
|
||||
di-retry manual; email terkirim bisa di-resend kalau peserta lapor
|
||||
tidak menerima.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Kartu ringkasan */}
|
||||
<div className="mb-6 grid gap-3 sm:grid-cols-3">
|
||||
<StatCard
|
||||
label="Antri dikirim"
|
||||
value={stats.queued}
|
||||
tone={stats.queued > 0 ? "amber" : "ok"}
|
||||
hint="Job menunggu cron / retry"
|
||||
/>
|
||||
<StatCard
|
||||
label="Gagal 24 jam"
|
||||
value={stats.failed24h}
|
||||
tone={stats.failed24h > 0 ? "red" : "ok"}
|
||||
hint="Job gagal dalam sehari terakhir"
|
||||
/>
|
||||
<StatCard
|
||||
label="Perlu aksi manual"
|
||||
value={stats.deadLetter}
|
||||
tone={stats.deadLetter > 0 ? "red" : "ok"}
|
||||
hint="Gagal & habis 5 attempt — cron berhenti retry"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{TABS.map((t) => (
|
||||
<a
|
||||
key={t.key}
|
||||
href={`/admin/emails?tab=${t.key}`}
|
||||
className={`rounded-full px-4 py-1.5 text-sm font-semibold transition-colors ${
|
||||
tab === t.key
|
||||
? "bg-primary-600 text-white"
|
||||
: "bg-neutral-100 text-neutral-600 hover:bg-neutral-200"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<form
|
||||
method="get"
|
||||
action="/admin/emails"
|
||||
className="mb-4 flex flex-wrap items-end gap-2 rounded-2xl border border-neutral-200 bg-white p-3 shadow-sm"
|
||||
>
|
||||
<input type="hidden" name="tab" value={tab} />
|
||||
<div className="min-w-[180px] flex-1">
|
||||
<label
|
||||
htmlFor="filter-to"
|
||||
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
|
||||
>
|
||||
Penerima (email)
|
||||
</label>
|
||||
<input
|
||||
id="filter-to"
|
||||
name="to"
|
||||
defaultValue={params.to ?? ""}
|
||||
placeholder="user@email.com"
|
||||
className="w-full rounded-lg border border-neutral-200 bg-neutral-50 px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400 focus:bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-[160px] flex-1">
|
||||
<label
|
||||
htmlFor="filter-template"
|
||||
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
|
||||
>
|
||||
Template
|
||||
</label>
|
||||
<input
|
||||
id="filter-template"
|
||||
name="template"
|
||||
defaultValue={params.template ?? ""}
|
||||
placeholder="mis. refund_succeeded"
|
||||
className="w-full rounded-lg border border-neutral-200 bg-neutral-50 px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400 focus:bg-white"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-lg bg-primary-600 px-4 py-1.5 text-sm font-semibold text-white hover:bg-primary-700"
|
||||
>
|
||||
Cari
|
||||
</button>
|
||||
{(filters.to || filters.template) && (
|
||||
<a
|
||||
href={`/admin/emails?tab=${tab}`}
|
||||
className="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-500 hover:bg-neutral-50"
|
||||
>
|
||||
Reset
|
||||
</a>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{tab === "sent" ? (
|
||||
<SentTable rows={sent} />
|
||||
) : (
|
||||
<JobTable rows={jobs} tab={tab} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
hint,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone: "ok" | "amber" | "red";
|
||||
hint: string;
|
||||
}) {
|
||||
const cls =
|
||||
tone === "red"
|
||||
? "border-red-200 bg-red-50/60"
|
||||
: tone === "amber"
|
||||
? "border-amber-200 bg-amber-50/60"
|
||||
: "border-emerald-200 bg-emerald-50/50";
|
||||
const valueCls =
|
||||
tone === "red"
|
||||
? "text-red-700"
|
||||
: tone === "amber"
|
||||
? "text-amber-700"
|
||||
: "text-emerald-700";
|
||||
return (
|
||||
<div className={`rounded-2xl border p-4 shadow-sm ${cls}`}>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||
{label}
|
||||
</p>
|
||||
<p className={`mt-1 text-2xl font-bold ${valueCls}`}>{value}</p>
|
||||
<p className="mt-0.5 text-[11px] text-neutral-500">{hint}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JobTable({
|
||||
rows,
|
||||
tab,
|
||||
}: {
|
||||
rows: Awaited<ReturnType<typeof emailRepo.listJobs>>;
|
||||
tab: "failed" | "queue";
|
||||
}) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
message={
|
||||
tab === "failed"
|
||||
? "Tidak ada email gagal — semua pengiriman lancar. 🎉"
|
||||
: "Tidak ada email yang sedang antri."
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-2xl border border-neutral-200 bg-white shadow-sm">
|
||||
<table className="min-w-full divide-y divide-neutral-100 text-sm">
|
||||
<thead className="bg-neutral-50 text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Penerima</th>
|
||||
<th className="px-3 py-2 text-left">Template</th>
|
||||
<th className="px-3 py-2 text-left">Status</th>
|
||||
<th className="px-3 py-2 text-left">Attempt</th>
|
||||
<th className="px-3 py-2 text-left">
|
||||
{tab === "failed" ? "Error terakhir" : "Dijadwalkan"}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100 text-xs text-neutral-700">
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="px-3 py-2">{r.to}</td>
|
||||
<td className="px-3 py-2 font-mono">{r.template}</td>
|
||||
<td className="px-3 py-2">
|
||||
<EmailBadge value={r.status} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{r.attempts}
|
||||
{r.attempts >= 5 && (
|
||||
<span className="ml-1 text-[10px] font-semibold text-red-600">
|
||||
(mati)
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-neutral-500">
|
||||
{tab === "failed"
|
||||
? r.lastError
|
||||
? truncate(r.lastError, 90)
|
||||
: "—"
|
||||
: formatDateTime(r.scheduledAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<RetryEmailButton jobId={r.id} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SentTable({
|
||||
rows,
|
||||
}: {
|
||||
rows: Awaited<ReturnType<typeof emailRepo.listSent>>;
|
||||
}) {
|
||||
if (rows.length === 0) {
|
||||
return <EmptyState message="Belum ada email terkirim yang cocok." />;
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-2xl border border-neutral-200 bg-white shadow-sm">
|
||||
<table className="min-w-full divide-y divide-neutral-100 text-sm">
|
||||
<thead className="bg-neutral-50 text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Penerima</th>
|
||||
<th className="px-3 py-2 text-left">Template</th>
|
||||
<th className="px-3 py-2 text-left">Subject</th>
|
||||
<th className="px-3 py-2 text-left">Terkirim</th>
|
||||
<th className="px-3 py-2 text-left">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100 text-xs text-neutral-700">
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="px-3 py-2">{r.to}</td>
|
||||
<td className="px-3 py-2 font-mono">{r.template}</td>
|
||||
<td className="px-3 py-2">{truncate(r.subject, 60)}</td>
|
||||
<td className="px-3 py-2 text-neutral-500">
|
||||
{formatDateTime(r.sentAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<ResendEmailButton emailSentId={r.id} disabled={!r.html} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center">
|
||||
<p className="text-sm text-neutral-500">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmailBadge({ value }: { value: string }) {
|
||||
const cls =
|
||||
value === "SUCCESS"
|
||||
? "bg-emerald-100 text-emerald-800"
|
||||
: value === "FAILED"
|
||||
? "bg-red-100 text-red-800"
|
||||
: "bg-amber-100 text-amber-800";
|
||||
return (
|
||||
<span
|
||||
className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateTime(d: Date): string {
|
||||
return d.toLocaleString("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
return s.length > max ? `${s.slice(0, max)}…` : s;
|
||||
}
|
||||
Reference in New Issue
Block a user