fix email sender all flow

This commit is contained in:
2026-05-20 15:25:32 +07:00
parent 306396ae43
commit cb03967deb
20 changed files with 1450 additions and 62 deletions
+50 -38
View File
@@ -9,6 +9,8 @@ Status implementasi notifikasi email transaksional ke user & organizer. Pakai po
> - **Audit-friendly** — semua email tercatat (sent atau queued) supaya admin bisa cek "kenapa user X belum dapat email Y?". > - **Audit-friendly** — semua email tercatat (sent atau queued) supaya admin bisa cek "kenapa user X belum dapat email Y?".
> - **Unsubscribe-aware** — transactional email (refund, payment, suspend) tetap dikirim. Marketing (reminder, social signal) opt-in dengan unsubscribe link. > - **Unsubscribe-aware** — transactional email (refund, payment, suspend) tetap dikirim. Marketing (reminder, social signal) opt-in dengan unsubscribe link.
**Progress (per 2026-05-20):** PR-E1, E2, E3, E5 ✅ — foundation, transactional email, notifikasi event Phase 2, dan admin email log + retry selesai. PR-E4 ⏳ (marketing/reminder) sengaja ditunda — belum dibutuhkan.
--- ---
## Baseline (kondisi sekarang) ## Baseline (kondisi sekarang)
@@ -33,7 +35,7 @@ Alternatif: AWS SES (paling murah at scale), SendGrid, Mailgun. **Decision:** Re
--- ---
## PR-E1 — Foundation: schema + service + cron (MVP wajib) ## PR-E1 — Foundation: schema + service + cron (MVP wajib)
**Tujuan:** infrastructure kirim email yang idempotent + retry-able, tanpa nge-block server action. **Tujuan:** infrastructure kirim email yang idempotent + retry-able, tanpa nge-block server action.
@@ -52,12 +54,12 @@ Alternatif: AWS SES (paling murah at scale), SendGrid, Mailgun. **Decision:** Re
| # | Item | Status | File | | # | Item | Status | File |
|---|---|---|---| |---|---|---|---|
| E1.1 | Model `EmailSent { idempotencyKey @unique, to, template, sentAt, providerMessageId? }` + migration | | [prisma/schema.prisma](prisma/schema.prisma) | | E1.1 | Model `EmailSent { idempotencyKey @unique, to, template, sentAt, providerMessageId? }` + migration | | [prisma/schema.prisma](prisma/schema.prisma) |
| E1.2 | Model `EmailJob { idempotencyKey, to, subject, html, status, attempts, lastError?, scheduledAt }` + migration | | [prisma/schema.prisma](prisma/schema.prisma) | | E1.2 | Model `EmailJob { idempotencyKey, to, subject, html, status, attempts, lastError?, scheduledAt }` + migration | | [prisma/schema.prisma](prisma/schema.prisma) |
| E1.3 | Service `lib/email/send.ts` — Resend client (raw fetch) + idempotency + enqueue on failure | | `lib/email/send.ts` | | E1.3 | Service `lib/email/send.ts` — Resend client (raw fetch) + idempotency + enqueue on failure | | `lib/email/send.ts` |
| E1.4 | Template registry `lib/email/templates/` — function per template return `{ subject, html }` | | `lib/email/templates/` | | E1.4 | Template registry — function per template return `{ subject, html }` | | `lib/email/templates.ts` |
| E1.5 | Cron route `/api/cron/process-email-jobs` pakai `runCron` helper | | `app/api/cron/process-email-jobs/route.ts` | | E1.5 | Cron route `/api/cron/process-email-jobs` pakai `runCron` helper | | `app/api/cron/process-email-jobs/route.ts` |
| E1.6 | Env: `RESEND_API_KEY`, `EMAIL_FROM` (mis. `"SeTrip <no-reply@setrip.id>"`) | | `.env.example` | | E1.6 | Env: `RESEND_API_KEY`, `EMAIL_FROM` (mis. `"SeTrip <no-reply@setrip.id>"`) | | `.env.example` |
**Tindakan manual ops:** **Tindakan manual ops:**
1. Buat akun Resend, verify domain `setrip.id` (atau pakai `onboarding@resend.dev` di dev). 1. Buat akun Resend, verify domain `setrip.id` (atau pakai `onboarding@resend.dev` di dev).
@@ -67,21 +69,23 @@ Alternatif: AWS SES (paling murah at scale), SendGrid, Mailgun. **Decision:** Re
--- ---
## PR-E2 — Phase 1: Wire transactional emails (MVP wajib) ## PR-E2 — Phase 1: Wire transactional emails (MVP wajib)
**Tujuan:** kirim email untuk event paling kritis (KYC, refund, payment, suspend) — yang kalau miss bikin user kehilangan uang atau bingung permanen. **Tujuan:** kirim email untuk event paling kritis (KYC, refund, payment, suspend) — yang kalau miss bikin user kehilangan uang atau bingung permanen.
| # | Trigger | Penerima | Template | Wire point | Status | | # | Trigger | Penerima | Template | Wire point | Status |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| E2.1 | KYC `VERIFICATION_APPROVE` | User | `kyc_approved` | `reviewVerificationAction` | | | E2.1 | KYC `VERIFICATION_APPROVE` | User | `kyc_approved` | `reviewVerificationAction` | |
| E2.2 | KYC `VERIFICATION_REJECT` | User | `kyc_rejected` (sertakan reason) | `reviewVerificationAction` | | | E2.2 | KYC `VERIFICATION_REJECT` | User | `kyc_rejected` (sertakan reason) | `reviewVerificationAction` | |
| E2.3 | KYC re-upload request | User | `kyc_reupload_request` (fields + note) | `requestReuploadAction` | | | E2.3 | KYC re-upload request | User | `kyc_reupload_request` (fields + note) | `requestReuploadAction` | |
| E2.4 | Refund created (admin atau auto-trigger) | User | `refund_created` (amount + reason) | `createRefundAction` + `tripService.closeTrip` (loop semua peserta PAID) | | | E2.4 | Refund created (admin atau auto-trigger) | User | `refund_created` (amount + reason) | `createRefundAction` + `tripService.closeTrip` (loop semua peserta PAID) | |
| E2.5 | Refund SUCCEEDED | User | `refund_succeeded` (amount, cek rekening) | `decideRefundAction` (decision=SUCCEEDED) | | | E2.5 | Refund SUCCEEDED | User | `refund_succeeded` (amount, cek rekening) | `decideRefundAction` (decision=SUCCEEDED) | |
| E2.6 | Refund FAILED | User | `refund_failed` (alasan + langkah next) | `decideRefundAction` (decision=FAILED) | | | E2.6 | Refund FAILED | User | `refund_failed` (alasan + langkah next) | `decideRefundAction` (decision=FAILED) | |
| E2.7 | Midtrans webhook PAID | User | `payment_paid` (terima kasih + detail booking) | `paymentService.applyGatewayStatus` (di branch PAID success) | | | E2.7 | Midtrans webhook PAID | User | `payment_paid` (terima kasih + detail booking) | `paymentService.applyGatewayStatus` (di branch PAID success) | |
| E2.8 | Booking di-approve organizer (status → AWAITING_PAY) | User | `booking_approved` (link bayar + deadline) | `confirmParticipantAction` | | | E2.8 | Booking di-approve organizer (status → AWAITING_PAY) | User | `booking_approved` (link bayar + deadline) | `confirmParticipantAction` | |
| E2.9 | Account suspend | User | `account_suspended` (reason + email support) | `suspendUserAction` | | | E2.9 | Account suspend | User | `account_suspended` (reason + email support) | `suspendUserAction` | |
> ️ **E2.4** — jalur admin (`createRefundAction`) kirim `refund_created`. Untuk auto-refund saat trip dibatalkan, peserta dikabari lewat email `trip_cancelled_organizer` / `trip_cancelled_admin` (E3.4/E3.5) yang sudah memuat blok nominal refund — satu email konsolidasi, bukan dua.
**Format idempotencyKey:** **Format idempotencyKey:**
- `kyc_approved-<verificationId>` - `kyc_approved-<verificationId>`
@@ -96,23 +100,25 @@ Alternatif: AWS SES (paling murah at scale), SendGrid, Mailgun. **Decision:** Re
--- ---
## PR-E3 — Phase 2: UX enhancement (post-MVP) ## PR-E3 — Phase 2: UX enhancement (post-MVP)
Email yang berguna tapi tidak critical kalau miss. Email yang berguna tapi tidak critical kalau miss.
| # | Trigger | Penerima | Template | Wire point | | # | Trigger | Penerima | Template | Wire point | Status |
|---|---|---|---|---| |---|---|---|---|---|---|
| E3.1 | User join trip (PENDING) | Organizer | `join_request` (siapa, link approve) | `joinTripAction` | | E3.1 | User join trip (PENDING) | Organizer | `join_request` (siapa, link approve) | `joinTripAction` | ✅ |
| E3.2 | Organizer reject join | User | `join_rejected` (singkat) | `rejectParticipantAction` | | E3.2 | Organizer reject join | User | `join_rejected` (singkat) | `rejectParticipantAction` | ✅ |
| E3.3 | Payment EXPIRED / FAILED | User | `payment_expired` (suruh start ulang) | `paymentService.applyGatewayStatus` | | E3.3 | Payment EXPIRED / FAILED | User | `payment_expired` (suruh start ulang) | `paymentService.applyGatewayStatus` | ✅ |
| E3.4 | Trip CLOSED (organizer cancel) | Semua peserta aktif | `trip_cancelled_organizer` (batch) | `tripService.closeTrip` (organizer actor) | | E3.4 | Trip CLOSED (organizer cancel) | Semua peserta aktif | `trip_cancelled_organizer` (batch) | `tripService.closeTrip` (organizer actor) | ✅ |
| E3.5 | Trip CLOSED (admin force-cancel) | Semua peserta + organizer | `trip_cancelled_admin` (reason) | `tripService.closeTrip` (admin actor) | | E3.5 | Trip CLOSED (admin force-cancel) | Semua peserta + organizer | `trip_cancelled_admin` (reason) | `tripService.closeTrip` (admin actor) | ✅ |
| E3.6 | Payout RELEASED | Organizer | `payout_released` (ETA transfer dari admin) | `payoutService.releaseEligible` | | E3.6 | Payout RELEASED | Organizer | `payout_released` (ETA transfer dari admin) | `payoutService.releaseEligible` | ✅ |
| E3.7 | Payout PAID | Organizer | `payout_paid` (cek rekening + adminNote) | `markPayoutPaidAction` | | E3.7 | Payout PAID | Organizer | `payout_paid` (cek rekening + adminNote) | `markPayoutPaidAction` | ✅ |
| E3.8 | Account unsuspended | User | `account_unsuspended` | `unsuspendUserAction` | | E3.8 | Account unsuspended | User | `account_unsuspended` | `unsuspendUserAction` | ✅ |
| E3.9 | KYC submitted | User | `kyc_submitted` (ETA review) | `submitVerificationAction` | | E3.9 | KYC submitted | User | `kyc_submitted` (ETA review) | `submitVerificationAction` | ✅ |
| E3.10 | KYC manual override | User | `kyc_manual_override` (admin verified) | `manualOverrideVerificationAction` | | E3.10 | KYC manual override | User | `kyc_manual_override` (admin verified) | `manualOverrideVerificationAction` | ✅ |
| E3.11 | KYC reopened (admin) | User | `kyc_reopened` (boleh submit ulang) | `reopenVerificationAction` | | E3.11 | KYC reopened (admin) | User | `kyc_reopened` (boleh submit ulang) | `reopenVerificationAction` | ✅ |
**Wire point email:** semua dikirim `void emailService.send(...)` (fire-and-forget, idempotent). Untuk batch trip-cancelled, `closeTrip` mengembalikan daftar penerima + nominal refund; email dikirim oleh action setelah transaksi commit (bukan di dalam tx).
--- ---
@@ -133,16 +139,22 @@ Email engagement — perlu user preference + unsubscribe link.
--- ---
## PR-E5 — Admin UI: email log + queue retry (post-MVP) ## PR-E5 — Admin UI: email log + queue retry (post-MVP)
Visibilitas admin untuk troubleshoot "kenapa user X tidak dapat email?". Visibilitas admin untuk troubleshoot "kenapa user X tidak dapat email?".
| # | Item | | # | Item | Status | File |
|---|---| |---|---|---|---|
| E5.1 | Page `/admin/emails` — list `EmailSent` + `EmailJob` dengan filter (recipient, template, status, date) | | E5.1 | Page `/admin/emails` — list `EmailSent` + `EmailJob`, filter recipient + template, status lewat tab | ✅ | `app/admin/emails/page.tsx` |
| E5.2 | Tombol "Retry now" untuk EmailJob FAILED | | E5.2 | Tombol "Kirim ulang" untuk EmailJob gagal/antri — retry sync langsung | ✅ | `features/email/components/email-row-actions.tsx` |
| E5.3 | Tombol "Resend" untuk EmailSent (override idempotency, append `?retry=<n>` ke key) | | E5.3 | Tombol "Resend" untuk EmailSent — key turunan `#resend-<ts>`, butuh `EmailSent.html` | ✅ | `features/email/actions.ts` |
| E5.4 | Stats card di `/admin/system`: failed count 24h, queued count | | E5.4 | Stats card di `/admin/system` + `/admin/emails`: antri, gagal 24 jam, perlu aksi manual | ✅ | `app/admin/system/page.tsx` |
**Tindakan manual ops:**
1. Run migration `20260520000000_add_email_sent_html` (kolom `EmailSent.html`) di staging → production. Tanpa ini, resend (E5.3) tidak tersedia untuk email yang dikirim sebelum migration.
2. Tambahkan `/admin/emails` ke admin nav — sudah dilakukan di `components/admin/admin-sidebar.tsx`.
> ️ Deviasi minor dari rencana awal: filter tanggal tidak diimplementasikan (list dibatasi 100 baris terbaru); filter status diwujudkan sebagai tab (Gagal / Antrian / Terkirim).
--- ---
+22 -20
View File
@@ -4,6 +4,8 @@ Status implementasi sistem refund yang dapat dipercaya dan auditable — dari sc
> **Prinsip:** refund = financial event, bukan flag boolean. Setiap rupiah yang keluar harus auditable, idempotent, dan punya state machine eksplisit. Untuk MVP: tetap simple, tapi anticipate partial refund + async gateway dari hari pertama. > **Prinsip:** refund = financial event, bukan flag boolean. Setiap rupiah yang keluar harus auditable, idempotent, dan punya state machine eksplisit. Untuk MVP: tetap simple, tapi anticipate partial refund + async gateway dari hari pertama.
**Progress (per 2026-05-20):** PR-R1, R2, R3 ✅ — MVP refund (schema + service, organizer-cancel auto-refund, self-service user cancel) selesai. PR-R4 / R5 / R6 ⏳ post-MVP belum dikerjakan.
--- ---
## Audit state sekarang (baseline) ## Audit state sekarang (baseline)
@@ -34,7 +36,7 @@ File baseline: [prisma/schema.prisma](prisma/schema.prisma), [server/services/bo
--- ---
## PR-R1 — Refund Schema + Service Stub (foundation) ## PR-R1 — Refund Schema + Service Stub (foundation)
Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDING` (belum panggil gateway). UI admin minimal: list + manual mark succeeded. Tanpa ini, semua PR berikutnya tidak bisa jalan. Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDING` (belum panggil gateway). UI admin minimal: list + manual mark succeeded. Tanpa ini, semua PR berikutnya tidak bisa jalan.
@@ -48,14 +50,14 @@ Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDIN
| # | Item | Status | File | | # | Item | Status | File |
|---|---|---|---| |---|---|---|---|
| R1.1 | Model `Refund` + enum `RefundReason`, `RefundStatus`, `RefundInitiator` | | [prisma/schema.prisma](prisma/schema.prisma) | | R1.1 | Model `Refund` + enum `RefundReason`, `RefundStatus`, `RefundInitiator` | | [prisma/schema.prisma](prisma/schema.prisma) |
| R1.2 | Tambah `BookingStatus.PARTIALLY_REFUNDED` | | [prisma/schema.prisma](prisma/schema.prisma) | | R1.2 | Tambah `BookingStatus.PARTIALLY_REFUNDED` | | [prisma/schema.prisma](prisma/schema.prisma) |
| R1.3 | Migration `add_refund_model` | | `prisma/migrations/` | | R1.3 | Migration `add_refund_model` | | `prisma/migrations/` |
| R1.4 | `refundRepo` (create, findById, findByBooking, listPending, updateStatus) | | `server/repositories/refund.repo.ts` | | R1.4 | `refundRepo` (create, findById, findByBooking, listPending, updateStatus) | | `server/repositories/refund.repo.ts` |
| R1.5 | `refundService.requestRefund(input)` — create Refund PENDING + validasi `amount <= payment.amount - totalRefunded` | | `server/services/refund.service.ts` | | R1.5 | `refundService.requestRefund(input)` — create Refund PENDING + validasi `amount <= payment.amount - totalRefunded` | | `server/services/refund.service.ts` |
| R1.6 | `refundService.approveRefund(refundId, adminId)` — PENDING → APPROVED | | `server/services/refund.service.ts` | | R1.6 | `refundService.approveRefund(refundId, adminId)` — PENDING → APPROVED | | `server/services/refund.service.ts` |
| R1.7 | `refundService.markSucceededManual(refundId, adminId)` — APPROVED → SUCCEEDED, update Booking/Payment status. Untuk MVP refund manual transfer (non-Midtrans channel). | | `server/services/refund.service.ts` | | R1.7 | `refundService.markSucceededManual(refundId, adminId)` — APPROVED → SUCCEEDED, update Booking/Payment status. Untuk MVP refund manual transfer (non-Midtrans channel). | | `server/services/refund.service.ts` |
| R1.8 | UI admin `/admin/refunds` — list PENDING + tombol approve & mark-succeeded | | `app/admin/refunds/page.tsx` | | R1.8 | UI admin `/admin/refunds` — list PENDING + tombol approve & mark-succeeded | | `app/admin/refunds/page.tsx` |
**Tindakan manual:** **Tindakan manual:**
1. Run migration di staging → smoke test → run di production. 1. Run migration di staging → smoke test → run di production.
@@ -63,7 +65,7 @@ Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDIN
--- ---
## PR-R2 — Auto-Trigger Refund saat Trip CLOSED (organizer cancel) ## PR-R2 — Auto-Trigger Refund saat Trip CLOSED (organizer cancel)
Saat organizer batalkan trip (`status = CLOSED`), semua peserta dengan `Booking.status = PAID` otomatis di-create `Refund` record dengan `reason = ORGANIZER_CANCELLED`, `initiatedBy = SYSTEM`, full amount. Saat organizer batalkan trip (`status = CLOSED`), semua peserta dengan `Booking.status = PAID` otomatis di-create `Refund` record dengan `reason = ORGANIZER_CANCELLED`, `initiatedBy = SYSTEM`, full amount.
@@ -75,16 +77,16 @@ Saat organizer batalkan trip (`status = CLOSED`), semua peserta dengan `Booking.
| # | Item | Status | File | | # | Item | Status | File |
|---|---|---|---| |---|---|---|---|
| R2.1 | `tripService.closeTrip(tripId, organizerId)` — set status CLOSED + auto-create refunds | | [server/services/trip.service.ts](server/services/trip.service.ts) | | R2.1 | `tripService.closeTrip(tripId, organizerId)` — set status CLOSED + auto-create refunds | | [server/services/trip.service.ts](server/services/trip.service.ts) |
| R2.2 | Auto-approve untuk SYSTEM refund dengan reason ORGANIZER_CANCELLED | | `server/services/refund.service.ts` | | R2.2 | Auto-approve untuk SYSTEM refund dengan reason ORGANIZER_CANCELLED | | `server/services/refund.service.ts` |
| R2.3 | UI organizer: tombol "Batalkan trip" di dashboard organizer dengan confirmation modal | | `features/trip/components/cancel-trip-button.tsx` | | R2.3 | UI organizer: tombol "Batalkan trip" di dashboard organizer dengan confirmation modal | | `features/trip/components/cancel-trip-button.tsx` |
| R2.4 | Server action `cancelTripAction` | | `features/trip/actions.ts` | | R2.4 | Server action `cancelTripAction` | | `features/trip/actions.ts` |
**Tindakan manual:** tidak ada. **Tindakan manual:** tidak ada.
--- ---
## PR-R3 — Self-Service User Cancel dengan Refund Window ## PR-R3 — Self-Service User Cancel dengan Refund Window
User bisa cancel booking sendiri, refund dihitung berdasarkan kebijakan default (hardcoded dulu, jangan polymorphic). User bisa cancel booking sendiri, refund dihitung berdasarkan kebijakan default (hardcoded dulu, jangan polymorphic).
@@ -100,11 +102,11 @@ User bisa cancel booking sendiri, refund dihitung berdasarkan kebijakan default
| # | Item | Status | File | | # | Item | Status | File |
|---|---|---|---| |---|---|---|---|
| R3.1 | `lib/refund-policy.ts``calculateRefundAmount(bookingAmount, daysUntilDeparture)` | | `lib/refund-policy.ts` | | R3.1 | `lib/refund-policy.ts``calculateRefundAmount(bookingAmount, daysUntilDeparture)` | | `lib/refund-policy.ts` |
| R3.2 | `refundService.requestUserCancellation(bookingId, userId)` — pakai policy + create Refund PENDING | | `server/services/refund.service.ts` | | R3.2 | `refundService.requestUserCancellation(bookingId, userId)` — pakai policy + create Refund PENDING | | `server/services/refund.service.ts` |
| R3.3 | UI user di trip detail: tombol "Cancel booking" + modal preview refund amount | | `features/booking/components/cancel-booking-button.tsx` | | R3.3 | UI user di trip detail: tombol "Cancel booking" + modal preview refund amount | | `features/booking/components/cancel-booking-button.tsx` |
| R3.4 | Server action `cancelBookingAction` | | `features/booking/actions.ts` | | R3.4 | Server action `cancelBookingAction` | | `features/booking/actions.ts` |
| R3.5 | Display refund policy di trip detail (di blok info atau accordion) — transparency | | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) | | R3.5 | Display refund policy di trip detail (di blok info atau accordion) — transparency | | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) |
**Tindakan manual:** **Tindakan manual:**
1. Tulis copy kebijakan refund untuk halaman Terms & Privacy. 1. Tulis copy kebijakan refund untuk halaman Terms & Privacy.
+17
View File
@@ -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;
}
+346
View File
@@ -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;
}
+80 -2
View File
@@ -5,6 +5,7 @@ import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail } from "@/lib/admin";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { systemHealthService } from "@/server/services/system-health.service"; import { systemHealthService } from "@/server/services/system-health.service";
import { emailRepo } from "@/server/repositories/email.repo";
interface JobSummary { interface JobSummary {
jobName: string; jobName: string;
@@ -80,20 +81,22 @@ export default async function AdminSystemPage() {
); );
} }
const [summaries, recentRuns, stale] = await Promise.all([ const [summaries, recentRuns, stale, emailStats] = await Promise.all([
Promise.all(TRACKED_JOBS.map(getJobSummary)), Promise.all(TRACKED_JOBS.map(getJobSummary)),
prisma.cronRun.findMany({ prisma.cronRun.findMany({
orderBy: { startedAt: "desc" }, orderBy: { startedAt: "desc" },
take: 20, take: 20,
}), }),
systemHealthService.detectStale(), systemHealthService.detectStale(),
emailRepo.stats(),
]); ]);
const hasAnyStale = const hasAnyStale =
stale.stalePaymentsCount > 0 || stale.stalePaymentsCount > 0 ||
stale.awaitingPayPastDepartureCount > 0 || stale.awaitingPayPastDepartureCount > 0 ||
stale.overduePayoutsCount > 0 || stale.overduePayoutsCount > 0 ||
stale.stuckRefundsCount > 0; stale.stuckRefundsCount > 0 ||
emailStats.deadLetter > 0;
return ( return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12"> <div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
@@ -152,6 +155,19 @@ export default async function AdminSystemPage() {
</Link> </Link>
</li> </li>
)} )}
{emailStats.deadLetter > 0 && (
<li>
<strong>{emailStats.deadLetter}</strong> email gagal kirim &
sudah habis 5 attempt cron berhenti retry, perlu retry
manual.{" "}
<Link
href="/admin/emails?tab=failed"
className="font-semibold text-amber-700 hover:underline"
>
Lihat email gagal
</Link>
</li>
)}
</ul> </ul>
</section> </section>
)} )}
@@ -236,6 +252,37 @@ export default async function AdminSystemPage() {
</div> </div>
</section> </section>
<section className="mb-8">
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
Email
</h2>
<div className="grid gap-3 sm:grid-cols-3">
<EmailStat
label="Antri dikirim"
value={emailStats.queued}
tone={emailStats.queued > 0 ? "amber" : "ok"}
/>
<EmailStat
label="Gagal 24 jam"
value={emailStats.failed24h}
tone={emailStats.failed24h > 0 ? "red" : "ok"}
/>
<EmailStat
label="Perlu aksi manual"
value={emailStats.deadLetter}
tone={emailStats.deadLetter > 0 ? "red" : "ok"}
/>
</div>
<p className="mt-2 text-xs text-neutral-500">
<Link
href="/admin/emails"
className="font-semibold text-primary-600 hover:underline"
>
Buka Email Log
</Link>
</p>
</section>
<section> <section>
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500"> <h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
Recent Runs (20 terakhir) Recent Runs (20 terakhir)
@@ -301,6 +348,37 @@ function truncate(s: string, max: number): string {
return s.length > max ? `${s.slice(0, max)}` : s; return s.length > max ? `${s.slice(0, max)}` : s;
} }
function EmailStat({
label,
value,
tone,
}: {
label: string;
value: number;
tone: "ok" | "amber" | "red";
}) {
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>
</div>
);
}
function StatusBadge({ value }: { value: string }) { function StatusBadge({ value }: { value: string }) {
const cls = const cls =
value === "SUCCESS" value === "SUCCESS"
+1
View File
@@ -14,6 +14,7 @@ const NAV_ITEMS: { href: string; label: string; icon: string }[] = [
{ href: "/admin/verifications", label: "Verifikasi", icon: "🪪" }, { href: "/admin/verifications", label: "Verifikasi", icon: "🪪" },
{ href: "/admin/refunds", label: "Refund", icon: "↩️" }, { href: "/admin/refunds", label: "Refund", icon: "↩️" },
{ href: "/admin/payouts", label: "Payout", icon: "💸" }, { href: "/admin/payouts", label: "Payout", icon: "💸" },
{ href: "/admin/emails", label: "Email", icon: "✉️" },
{ href: "/admin/audit-log", label: "Audit Log", icon: "📜" }, { href: "/admin/audit-log", label: "Audit Log", icon: "📜" },
{ href: "/admin/system", label: "System", icon: "⚙️" }, { href: "/admin/system", label: "System", icon: "⚙️" },
]; ];
+19
View File
@@ -58,6 +58,21 @@ async function notifySuspended(userId: string, reason: string) {
}); });
} }
/** E3.8 — kabari user kalau penangguhan akunnya sudah dicabut. */
async function notifyUnsuspended(userId: string) {
const target = await userRepo.findById(userId);
if (!target) return;
await emailService.send({
to: target.email,
// Sertakan timestamp supaya tiap siklus suspend→unsuspend dapat email baru.
idempotencyKey: `account_unsuspended-${userId}-${Date.now()}`,
template: {
template: "account_unsuspended",
data: { userName: target.name },
},
});
}
export async function unsuspendUserAction(userId: string) { export async function unsuspendUserAction(userId: string) {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user) { if (!session?.user) {
@@ -75,6 +90,10 @@ export async function unsuspendUserAction(userId: string) {
entityType: "User", entityType: "User",
entityId: userId, entityId: userId,
}); });
// Notif email user — kabari akun sudah aktif kembali.
void notifyUnsuspended(userId);
revalidatePath("/admin/users"); revalidatePath("/admin/users");
revalidatePath(`/admin/users/${userId}`); revalidatePath(`/admin/users/${userId}`);
return { success: true as const }; return { success: true as const };
+57
View File
@@ -0,0 +1,57 @@
"use server";
import { getServerSession } from "next-auth";
import { revalidatePath } from "next/cache";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { emailService } from "@/lib/email/send";
import { auditLog } from "@/server/services/audit-log.service";
async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session?.user || !isAdminEmail(session.user.email)) {
return null;
}
return session.user;
}
/** E5.2 — admin retry satu EmailJob yang gagal/antri, kirim ulang langsung. */
export async function retryEmailJobAction(jobId: string) {
const admin = await requireAdmin();
if (!admin) return { error: "Tidak memiliki akses admin" };
if (!jobId) return { error: "jobId tidak valid" };
const result = await emailService.retryJob(jobId);
if (!result.ok) {
return { error: result.error ?? "Gagal mengirim ulang email" };
}
await auditLog.record({
admin: { id: admin.id, email: admin.email },
action: "EMAIL_JOB_RETRY",
entityType: "EmailJob",
entityId: jobId,
});
revalidatePath("/admin/emails");
revalidatePath("/admin/system");
return { success: true as const };
}
/** E5.3 — admin resend email yang sudah pernah terkirim (mis. user lapor tidak terima). */
export async function resendEmailAction(emailSentId: string) {
const admin = await requireAdmin();
if (!admin) return { error: "Tidak memiliki akses admin" };
if (!emailSentId) return { error: "emailSentId tidak valid" };
const result = await emailService.resendEmail(emailSentId);
if (!result.ok) {
return { error: result.error ?? "Gagal mengirim ulang email" };
}
await auditLog.record({
admin: { id: admin.id, email: admin.email },
action: "EMAIL_RESEND",
entityType: "EmailSent",
entityId: emailSentId,
});
revalidatePath("/admin/emails");
return { success: true as const };
}
@@ -0,0 +1,97 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { retryEmailJobAction, resendEmailAction } from "@/features/email/actions";
const BTN_CLS =
"rounded-lg border border-primary-200 bg-primary-50 px-2.5 py-1 text-[11px] font-semibold text-primary-700 transition-colors hover:bg-primary-100 disabled:cursor-not-allowed disabled:opacity-50";
/** E5.2 — tombol kirim ulang untuk satu EmailJob (antri / gagal). */
export function RetryEmailButton({ jobId }: { jobId: string }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleRetry() {
setLoading(true);
setError("");
const res = await retryEmailJobAction(jobId);
setLoading(false);
if ("error" in res) {
setError(res.error ?? "Gagal");
return;
}
router.refresh();
}
return (
<div>
<button
type="button"
onClick={handleRetry}
disabled={loading}
className={BTN_CLS}
>
{loading ? "Mengirim…" : "Kirim ulang"}
</button>
{error && (
<p className="mt-1 max-w-[200px] text-[10px] text-red-600">{error}</p>
)}
</div>
);
}
/** E5.3 — tombol resend untuk email yang sudah terkirim. */
export function ResendEmailButton({
emailSentId,
disabled,
}: {
emailSentId: string;
disabled?: boolean;
}) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [done, setDone] = useState(false);
async function handleResend() {
setLoading(true);
setError("");
const res = await resendEmailAction(emailSentId);
setLoading(false);
if ("error" in res) {
setError(res.error ?? "Gagal");
return;
}
setDone(true);
router.refresh();
}
if (disabled) {
return (
<span
className="text-[10px] text-neutral-400"
title="Body email lama tidak tersimpan"
>
</span>
);
}
return (
<div>
<button
type="button"
onClick={handleResend}
disabled={loading || done}
className={BTN_CLS}
>
{loading ? "Mengirim…" : done ? "✓ Terkirim" : "Resend"}
</button>
{error && (
<p className="mt-1 max-w-[200px] text-[10px] text-red-600">{error}</p>
)}
</div>
);
}
+52
View File
@@ -13,6 +13,7 @@ import { auditLog } from "@/server/services/audit-log.service";
import { emailService } from "@/lib/email/send"; import { emailService } from "@/lib/email/send";
import { organizerRepo } from "@/server/repositories/organizer.repo"; import { organizerRepo } from "@/server/repositories/organizer.repo";
import { userRepo } from "@/server/repositories/user.repo"; import { userRepo } from "@/server/repositories/user.repo";
import { prisma } from "@/lib/prisma";
import { submitVerificationSchema, reviewVerificationSchema } from "./schemas"; import { submitVerificationSchema, reviewVerificationSchema } from "./schemas";
export async function submitVerificationAction(formData: FormData) { export async function submitVerificationAction(formData: FormData) {
@@ -43,6 +44,7 @@ export async function submitVerificationAction(formData: FormData) {
...result.data, ...result.data,
birthDate: new Date(result.data.birthDate), birthDate: new Date(result.data.birthDate),
}); });
void notifyKycSubmitted(session.user.id);
revalidatePath("/verify"); revalidatePath("/verify");
revalidatePath("/profile"); revalidatePath("/profile");
revalidatePath("/admin/verifications"); revalidatePath("/admin/verifications");
@@ -137,6 +139,54 @@ async function notifyVerificationDecision(
} }
} }
/** E3.9 — kabari user kalau pengajuan verifikasi-nya sudah masuk antrian. */
async function notifyKycSubmitted(userId: string) {
const verification = await prisma.organizerVerification.findUnique({
where: { userId },
select: { submissionCount: true },
});
const user = await userRepo.findById(userId);
if (!verification || !user) return;
await emailService.send({
to: user.email,
idempotencyKey: `kyc_submitted-${userId}-${verification.submissionCount}`,
template: {
template: "kyc_submitted",
data: { userName: user.name },
},
});
}
/** E3.11 — kabari user kalau pengajuan verifikasi-nya dibuka kembali admin. */
async function notifyKycReopened(verificationId: string) {
const verification = await organizerRepo.findById(verificationId);
if (!verification) return;
const user = await userRepo.findById(verification.userId);
if (!user) return;
await emailService.send({
to: user.email,
idempotencyKey: `kyc_reopened-${verificationId}-${verification.submissionCount}`,
template: {
template: "kyc_reopened",
data: { userName: user.name },
},
});
}
/** E3.10 — kabari user kalau admin verifikasi dia secara manual override. */
async function notifyKycManualOverride(userId: string, verificationId: string) {
const user = await userRepo.findById(userId);
if (!user) return;
await emailService.send({
to: user.email,
idempotencyKey: `kyc_manual_override-${verificationId}`,
template: {
template: "kyc_manual_override",
data: { userName: user.name },
},
});
}
/** /**
* Admin reopen pengajuan REJECTED ke PENDING — supaya organizer bisa * Admin reopen pengajuan REJECTED ke PENDING — supaya organizer bisa
* di-review ulang tanpa drop & recreate row. Note wajib min 10 char untuk audit. * di-review ulang tanpa drop & recreate row. Note wajib min 10 char untuk audit.
@@ -156,6 +206,7 @@ export async function reopenVerificationAction(
adminId: session.user.id, adminId: session.user.id,
note, note,
}); });
void notifyKycReopened(verificationId);
await auditLog.record({ await auditLog.record({
admin: { id: session.user.id, email: session.user.email }, admin: { id: session.user.id, email: session.user.email },
action: "VERIFICATION_REOPEN", action: "VERIFICATION_REOPEN",
@@ -262,6 +313,7 @@ export async function manualOverrideVerificationAction(input: {
bankAccountNumber: input.bankAccountNumber, bankAccountNumber: input.bankAccountNumber,
bankAccountName: input.bankAccountName, bankAccountName: input.bankAccountName,
}); });
void notifyKycManualOverride(input.userId, result.id);
await auditLog.record({ await auditLog.record({
admin: { id: session.user.id, email: session.user.email }, admin: { id: session.user.id, email: session.user.email },
action: "VERIFICATION_MANUAL_OVERRIDE", action: "VERIFICATION_MANUAL_OVERRIDE",
+22
View File
@@ -5,7 +5,9 @@ import { revalidatePath } from "next/cache";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin"; import { isAdminEmail } from "@/lib/admin";
import { payoutService } from "@/server/services/payout.service"; import { payoutService } from "@/server/services/payout.service";
import { payoutRepo } from "@/server/repositories/payout.repo";
import { auditLog } from "@/server/services/audit-log.service"; import { auditLog } from "@/server/services/audit-log.service";
import { emailService } from "@/lib/email/send";
import { payoutMarkPaidSchema } from "./schemas"; import { payoutMarkPaidSchema } from "./schemas";
async function requireAdmin() { async function requireAdmin() {
@@ -34,6 +36,7 @@ export async function markPayoutPaidAction(formData: FormData) {
adminId: admin.id, adminId: admin.id,
adminNote: parsed.data.adminNote, adminNote: parsed.data.adminNote,
}); });
void notifyPayoutPaid(parsed.data.payoutId, parsed.data.adminNote);
await auditLog.record({ await auditLog.record({
admin: { id: admin.id, email: admin.email }, admin: { id: admin.id, email: admin.email },
action: "PAYOUT_MARK_PAID", action: "PAYOUT_MARK_PAID",
@@ -49,3 +52,22 @@ export async function markPayoutPaidAction(formData: FormData) {
return { error: (err as Error).message }; return { error: (err as Error).message };
} }
} }
/** E3.7 — kabari organizer kalau payout-nya sudah ditransfer admin. */
async function notifyPayoutPaid(payoutId: string, adminNote: string) {
const payout = await payoutRepo.findById(payoutId);
if (!payout) return;
await emailService.send({
to: payout.organizer.email,
idempotencyKey: `payout_paid-${payout.id}`,
template: {
template: "payout_paid",
data: {
organizerName: payout.organizer.name,
tripTitle: payout.trip.title,
amount: payout.amount,
adminNote,
},
},
});
}
+121
View File
@@ -131,6 +131,7 @@ export async function joinTripAction(tripId: string) {
try { try {
await requireActiveUser(session.user.id); await requireActiveUser(session.user.id);
await tripService.joinTrip(tripId, session.user.id); await tripService.joinTrip(tripId, session.user.id);
void notifyJoinRequest(tripId, session.user.id);
revalidatePath(`/trips/${tripId}`); revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips"); revalidatePath("/trips");
revalidatePath("/"); revalidatePath("/");
@@ -212,6 +213,60 @@ async function notifyBookingApproved(participantId: string) {
}); });
} }
/** E3.1 — kabari organizer ada peserta baru yang mengajukan join. */
async function notifyJoinRequest(tripId: string, joinerId: string) {
const [trip, joiner] = await Promise.all([
prisma.trip.findUnique({
where: { id: tripId },
select: {
title: true,
organizer: { select: { email: true, name: true } },
},
}),
prisma.user.findUnique({
where: { id: joinerId },
select: { name: true },
}),
]);
if (!trip || !joiner) return;
await emailService.send({
to: trip.organizer.email,
idempotencyKey: `join_request-${tripId}-${joinerId}`,
template: {
template: "join_request",
data: {
organizerName: trip.organizer.name,
joinerName: joiner.name,
tripTitle: trip.title,
tripId,
},
},
});
}
/** E3.2 — kabari peserta kalau permintaan join-nya ditolak organizer. */
async function notifyJoinRejected(participantId: string) {
const participant = await prisma.tripParticipant.findUnique({
where: { id: participantId },
select: {
user: { select: { email: true, name: true } },
trip: { select: { title: true } },
},
});
if (!participant) return;
await emailService.send({
to: participant.user.email,
idempotencyKey: `join_rejected-${participantId}`,
template: {
template: "join_rejected",
data: {
userName: participant.user.name,
tripTitle: participant.trip.title,
},
},
});
}
export async function rejectParticipantAction( export async function rejectParticipantAction(
tripId: string, tripId: string,
participantId: string participantId: string
@@ -227,6 +282,7 @@ export async function rejectParticipantAction(
participantId, participantId,
session.user.id session.user.id
); );
void notifyJoinRejected(participantId);
revalidatePath(`/trips/${tripId}`); revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips"); revalidatePath("/trips");
revalidatePath("/"); revalidatePath("/");
@@ -237,6 +293,66 @@ export async function rejectParticipantAction(
} }
} }
type CloseTripResult = Awaited<ReturnType<typeof tripService.closeTrip>>;
/**
* E3.4 / E3.5 (+ E2.4) — kabari semua peserta aktif kalau trip dibatalkan.
* Email organizer-cancel & admin-cancel beda template; admin-cancel juga
* mengabari organizer. Refund block ikut di email (nominal dari `notify`).
*/
function notifyTripCancelled(
tripId: string,
notify: CloseTripResult["notify"],
actor: { type: "ORGANIZER" } | { type: "ADMIN"; reason: string }
) {
for (const p of notify.participants) {
if (actor.type === "ORGANIZER") {
void emailService.send({
to: p.email,
idempotencyKey: `trip_cancelled_organizer-${tripId}-${p.userId}`,
template: {
template: "trip_cancelled_organizer",
data: {
userName: p.name,
tripTitle: notify.tripTitle,
refundAmount: p.refundAmount,
},
},
});
} else {
void emailService.send({
to: p.email,
idempotencyKey: `trip_cancelled_admin-${tripId}-${p.userId}`,
template: {
template: "trip_cancelled_admin",
data: {
userName: p.name,
tripTitle: notify.tripTitle,
reason: actor.reason,
refundAmount: p.refundAmount,
},
},
});
}
}
// Admin force-cancel → organizer juga dikabari (E3.5).
if (actor.type === "ADMIN") {
void emailService.send({
to: notify.organizer.email,
idempotencyKey: `trip_cancelled_admin-${tripId}-organizer`,
template: {
template: "trip_cancelled_admin",
data: {
userName: notify.organizer.name,
tripTitle: notify.tripTitle,
reason: actor.reason,
refundAmount: 0,
},
},
});
}
}
export async function cancelTripAction(tripId: string) { export async function cancelTripAction(tripId: string) {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user) { if (!session?.user) {
@@ -248,6 +364,7 @@ export async function cancelTripAction(tripId: string) {
type: "ORGANIZER", type: "ORGANIZER",
userId: session.user.id, userId: session.user.id,
}); });
notifyTripCancelled(tripId, result.notify, { type: "ORGANIZER" });
revalidatePath(`/trips/${tripId}`); revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips"); revalidatePath("/trips");
revalidatePath("/"); revalidatePath("/");
@@ -294,6 +411,10 @@ export async function adminCancelTripAction(tripId: string, reason: string) {
adminId: session.user.id, adminId: session.user.id,
reason: trimmedReason, reason: trimmedReason,
}); });
notifyTripCancelled(tripId, result.notify, {
type: "ADMIN",
reason: trimmedReason,
});
await auditLog.record({ await auditLog.record({
admin: { id: session.user.id, email: session.user.email }, admin: { id: session.user.id, email: session.user.email },
action: "TRIP_ADMIN_CANCEL", action: "TRIP_ADMIN_CANCEL",
+121
View File
@@ -77,6 +77,7 @@ export const emailService = {
to: input.to, to: input.to,
template: input.template.template, template: input.template.template,
subject: rendered.subject, subject: rendered.subject,
html: rendered.html,
providerMessageId: result.data?.id ?? null, providerMessageId: result.data?.id ?? null,
}, },
}); });
@@ -162,6 +163,7 @@ export const emailService = {
to: job.to, to: job.to,
template: job.template, template: job.template,
subject: job.subject, subject: job.subject,
html: job.html,
providerMessageId: result.data?.id ?? null, providerMessageId: result.data?.id ?? null,
}, },
}), }),
@@ -188,6 +190,125 @@ export const emailService = {
return { picked: jobs.length, succeeded, failed }; return { picked: jobs.length, succeeded, failed };
}, },
/**
* Admin "Retry now" untuk satu EmailJob — kirim ulang langsung tanpa
* menunggu cron. Idempotent: kalau email sudah tercatat terkirim, job
* ditandai SUCCESS tanpa kirim ulang.
*/
async retryJob(jobId: string): Promise<{ ok: boolean; error?: string }> {
const job = await prisma.emailJob.findUnique({ where: { id: jobId } });
if (!job) return { ok: false, error: "Email job tidak ditemukan" };
const alreadySent = await prisma.emailSent.findUnique({
where: { idempotencyKey: job.idempotencyKey },
select: { id: true },
});
if (alreadySent) {
await prisma.emailJob.update({
where: { id: jobId },
data: { status: "SUCCESS", lastAttemptAt: new Date() },
});
return { ok: true };
}
const resend = getResend();
if (!resend) return { ok: false, error: "RESEND_API_KEY belum di-set" };
const now = new Date();
try {
const result = await resend.emails.send({
from: emailFrom(),
to: job.to,
subject: job.subject,
html: job.html,
});
if (result.error) throw new Error(result.error.message ?? "Resend failed");
await prisma.$transaction([
prisma.emailSent.create({
data: {
idempotencyKey: job.idempotencyKey,
to: job.to,
template: job.template,
subject: job.subject,
html: job.html,
providerMessageId: result.data?.id ?? null,
},
}),
prisma.emailJob.update({
where: { id: jobId },
data: {
status: "SUCCESS",
attempts: job.attempts + 1,
lastAttemptAt: now,
},
}),
]);
return { ok: true };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await prisma.emailJob.update({
where: { id: jobId },
data: {
status: "FAILED",
attempts: job.attempts + 1,
lastAttemptAt: now,
lastError: message,
},
});
return { ok: false, error: message };
}
},
/**
* Admin "Resend" untuk EmailSent yang sudah pernah terkirim — mis. user
* lapor tidak menerima. Pakai idempotencyKey turunan supaya tidak bentrok
* dengan email asli. Butuh `html` tersimpan (row lama tidak bisa di-resend).
*/
async resendEmail(
emailSentId: string
): Promise<{ ok: boolean; error?: string }> {
const original = await prisma.emailSent.findUnique({
where: { id: emailSentId },
});
if (!original) return { ok: false, error: "Email tidak ditemukan" };
if (!original.html) {
return {
ok: false,
error:
"Body email lama tidak tersimpan — tidak bisa di-resend (dikirim sebelum fitur ini ada).",
};
}
const resend = getResend();
if (!resend) return { ok: false, error: "RESEND_API_KEY belum di-set" };
try {
const result = await resend.emails.send({
from: emailFrom(),
to: original.to,
subject: original.subject,
html: original.html,
});
if (result.error) throw new Error(result.error.message ?? "Resend failed");
await prisma.emailSent.create({
data: {
idempotencyKey: `${original.idempotencyKey}#resend-${Date.now()}`,
to: original.to,
template: original.template,
subject: original.subject,
html: original.html,
providerMessageId: result.data?.id ?? null,
},
});
return { ok: true };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
},
}; };
async function enqueueJob( async function enqueueJob(
+272 -1
View File
@@ -301,6 +301,244 @@ function accountSuspended(d: AccountSuspendedData) {
}; };
} }
// ============================================================================
// PR-E3 — Phase 2: notifikasi event (join, trip dibatalkan, payout, KYC)
// ============================================================================
/** Blok refund untuk email pembatalan trip. Pesan beda kalau tidak ada refund. */
function refundBlock(refundAmount: number): string {
if (refundAmount <= 0) {
return `<p style="margin:0 0 12px;font-size:14px;">Booking kamu untuk trip ini tidak punya pembayaran yang perlu dikembalikan.</p>`;
}
return `<div style="background:#f0fdf4;border-left:4px solid ${EMERALD_600};padding:12px 16px;margin:16px 0;">
<p style="margin:0;font-weight:600;font-size:13px;">Refund kamu sedang diproses</p>
<p style="margin:8px 0 0;font-size:18px;font-weight:bold;color:${EMERALD_600};">${formatRupiah(refundAmount)}</p>
<p style="margin:8px 0 0;font-size:13px;">Admin SeTrip akan memproses transfer ke rekening kamu. Kami kabari lagi via email saat refund selesai.</p>
</div>`;
}
export interface JoinRequestData {
organizerName: string;
joinerName: string;
tripTitle: string;
tripId: string;
}
function joinRequest(d: JoinRequestData) {
return {
subject: `👋 ${d.joinerName} mau gabung trip "${d.tripTitle}"`,
html: shell({
title: "Join Request",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;">Ada yang mau gabung trip kamu</h1>
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.organizerName)}, <strong>${escapeHtml(d.joinerName)}</strong> mengajukan diri untuk ikut trip <strong>${escapeHtml(d.tripTitle)}</strong>.</p>
<p style="margin:0 0 12px;font-size:14px;">Buka halaman trip untuk meninjau profil peserta lalu setujui atau tolak permintaannya.</p>
`,
ctaLabel: "Tinjau Permintaan",
ctaUrl: `${siteUrl}/trips/${d.tripId}`,
}),
};
}
export interface JoinRejectedData {
userName: string;
tripTitle: string;
}
function joinRejected(d: JoinRejectedData) {
return {
subject: `Update permintaan gabung trip "${d.tripTitle}"`,
html: shell({
title: "Join Rejected",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;">Permintaan gabung belum diterima</h1>
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.userName)}, organizer belum bisa menerima permintaan kamu untuk ikut trip <strong>${escapeHtml(d.tripTitle)}</strong> kali ini.</p>
<p style="margin:0 0 12px;font-size:14px;">Jangan berkecil hati — masih banyak trip seru lain yang bisa kamu ikuti.</p>
`,
ctaLabel: "Cari Trip Lain",
ctaUrl: `${siteUrl}/trips`,
}),
};
}
export interface PaymentExpiredData {
userName: string;
tripTitle: string;
tripId: string;
amount: number;
}
function paymentExpired(d: PaymentExpiredData) {
return {
subject: `⏰ Pembayaran trip "${d.tripTitle}" belum selesai`,
html: shell({
title: "Payment Expired",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${AMBER_600};">Pembayaran belum selesai</h1>
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.userName)}, pembayaran kamu untuk trip <strong>${escapeHtml(d.tripTitle)}</strong> sebesar <strong>${formatRupiah(d.amount)}</strong> kadaluarsa atau gagal — slot kamu belum aman.</p>
<p style="margin:0 0 12px;font-size:14px;">Selama trip belum penuh dan belum berangkat, kamu masih bisa mengulang pembayaran.</p>
`,
ctaLabel: "Coba Bayar Lagi",
ctaUrl: `${siteUrl}/trips/${d.tripId}/payment`,
}),
};
}
export interface TripCancelledOrganizerData {
userName: string;
tripTitle: string;
refundAmount: number;
}
function tripCancelledOrganizer(d: TripCancelledOrganizerData) {
return {
subject: `❌ Trip "${d.tripTitle}" dibatalkan`,
html: shell({
title: "Trip Cancelled",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${RED_600};">Trip dibatalkan</h1>
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.userName)}, mohon maaf — organizer membatalkan trip <strong>${escapeHtml(d.tripTitle)}</strong>. Partisipasi kamu di trip ini otomatis dibatalkan.</p>
${refundBlock(d.refundAmount)}
`,
ctaLabel: "Cari Trip Lain",
ctaUrl: `${siteUrl}/trips`,
}),
};
}
export interface TripCancelledAdminData {
userName: string;
tripTitle: string;
reason: string;
refundAmount: number;
}
function tripCancelledAdmin(d: TripCancelledAdminData) {
return {
subject: `❌ Trip "${d.tripTitle}" dibatalkan SeTrip`,
html: shell({
title: "Trip Cancelled by Admin",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${RED_600};">Trip dibatalkan SeTrip</h1>
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.userName)}, trip <strong>${escapeHtml(d.tripTitle)}</strong> dibatalkan oleh tim admin SeTrip.</p>
<div style="background:#fef2f2;border-left:4px solid ${RED_600};padding:12px 16px;margin:16px 0;">
<p style="margin:0;font-weight:600;font-size:13px;">Alasan:</p>
<p style="margin:8px 0 0;font-size:14px;">${escapeHtml(d.reason)}</p>
</div>
${refundBlock(d.refundAmount)}
`,
}),
};
}
export interface PayoutReleasedData {
organizerName: string;
tripTitle: string;
amount: number;
}
function payoutReleased(d: PayoutReleasedData) {
return {
subject: `💰 Payout trip "${d.tripTitle}" masuk antrian transfer`,
html: shell({
title: "Payout Released",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${EMERALD_600};">Payout siap ditransfer</h1>
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.organizerName)}, masa tahan (escrow) untuk trip <strong>${escapeHtml(d.tripTitle)}</strong> sudah berakhir.</p>
<p style="margin:0 0 12px;font-size:24px;font-weight:bold;color:${EMERALD_600};">${formatRupiah(d.amount)}</p>
<p style="margin:0;font-size:14px;">Dana ini masuk antrian transfer — admin SeTrip akan memprosesnya ke rekening kamu. Kami kabari lagi saat sudah ditransfer.</p>
`,
}),
};
}
export interface PayoutPaidData {
organizerName: string;
tripTitle: string;
amount: number;
adminNote: string;
}
function payoutPaid(d: PayoutPaidData) {
return {
subject: `✅ Payout ${formatRupiah(d.amount)} sudah ditransfer`,
html: shell({
title: "Payout Paid",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${EMERALD_600};">Payout sudah ditransfer</h1>
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.organizerName)}, payout untuk trip <strong>${escapeHtml(d.tripTitle)}</strong> sudah kami transfer.</p>
<p style="margin:0 0 12px;font-size:24px;font-weight:bold;color:${EMERALD_600};">${formatRupiah(d.amount)}</p>
${d.adminNote ? `<div style="background:#f0fdf4;border-left:4px solid ${EMERALD_600};padding:12px 16px;margin:16px 0;"><p style="margin:0;font-weight:600;font-size:13px;">Catatan / referensi transfer:</p><p style="margin:4px 0 0;font-size:14px;">${escapeHtml(d.adminNote)}</p></div>` : ""}
<p style="margin:0;font-size:14px;">Cek rekening kamu — biasanya masuk dalam 1×24 jam tergantung bank.</p>
`,
}),
};
}
export interface AccountUnsuspendedData {
userName: string;
}
function accountUnsuspended(d: AccountUnsuspendedData) {
return {
subject: "✅ Akun SeTrip kamu aktif kembali",
html: shell({
title: "Account Unsuspended",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${EMERALD_600};">Akun aktif kembali</h1>
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.userName)}, penangguhan akun SeTrip kamu sudah dicabut. Kamu bisa login dan beraktivitas seperti biasa lagi.</p>
`,
ctaLabel: "Buka SeTrip",
ctaUrl: siteUrl,
}),
};
}
export interface KycSubmittedData {
userName: string;
}
function kycSubmitted(d: KycSubmittedData) {
return {
subject: "📋 Pengajuan verifikasi organizer kamu sudah masuk",
html: shell({
title: "KYC Submitted",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;">Pengajuan diterima</h1>
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.userName)}, pengajuan verifikasi organizer kamu sudah masuk dan sedang antri di-review tim admin.</p>
<p style="margin:0 0 12px;font-size:14px;">Estimasi review <strong>13 hari kerja</strong>. Hasilnya kami kabari lewat email — tidak perlu submit ulang selama belum ada keputusan.</p>
`,
}),
};
}
export interface KycManualOverrideData {
userName: string;
}
function kycManualOverride(d: KycManualOverrideData) {
return {
subject: "✅ Kamu jadi organizer terverifikasi SeTrip",
html: shell({
title: "KYC Manual Override",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${EMERALD_600};">🎉 Selamat ${escapeHtml(d.userName)}!</h1>
<p style="margin:0 0 12px;">Tim admin SeTrip sudah memverifikasi kamu sebagai organizer. Mulai sekarang kamu bisa membuat trip berbayar dan menerima payout.</p>
`,
ctaLabel: "Buat Trip Sekarang",
ctaUrl: `${siteUrl}/create-trip`,
}),
};
}
export interface KycReopenedData {
userName: string;
}
function kycReopened(d: KycReopenedData) {
return {
subject: "🔄 Pengajuan verifikasi kamu dibuka kembali",
html: shell({
title: "KYC Reopened",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${AMBER_600};">Pengajuan dibuka kembali</h1>
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.userName)}, tim admin membuka kembali pengajuan verifikasi organizer kamu. Kamu bisa memperbaiki data lalu mengajukannya ulang.</p>
`,
ctaLabel: "Buka Halaman Verifikasi",
ctaUrl: `${siteUrl}/verify`,
}),
};
}
// ============================================================================ // ============================================================================
// Registry — discriminated union supaya type-safe per template. // Registry — discriminated union supaya type-safe per template.
// ============================================================================ // ============================================================================
@@ -314,7 +552,18 @@ export type EmailTemplate =
| { template: "refund_failed"; data: RefundFailedData } | { template: "refund_failed"; data: RefundFailedData }
| { template: "payment_paid"; data: PaymentPaidData } | { template: "payment_paid"; data: PaymentPaidData }
| { template: "booking_approved"; data: BookingApprovedData } | { template: "booking_approved"; data: BookingApprovedData }
| { template: "account_suspended"; data: AccountSuspendedData }; | { template: "account_suspended"; data: AccountSuspendedData }
| { template: "join_request"; data: JoinRequestData }
| { template: "join_rejected"; data: JoinRejectedData }
| { template: "payment_expired"; data: PaymentExpiredData }
| { template: "trip_cancelled_organizer"; data: TripCancelledOrganizerData }
| { template: "trip_cancelled_admin"; data: TripCancelledAdminData }
| { template: "payout_released"; data: PayoutReleasedData }
| { template: "payout_paid"; data: PayoutPaidData }
| { template: "account_unsuspended"; data: AccountUnsuspendedData }
| { template: "kyc_submitted"; data: KycSubmittedData }
| { template: "kyc_manual_override"; data: KycManualOverrideData }
| { template: "kyc_reopened"; data: KycReopenedData };
export function renderEmail(input: EmailTemplate): { export function renderEmail(input: EmailTemplate): {
subject: string; subject: string;
@@ -339,6 +588,28 @@ export function renderEmail(input: EmailTemplate): {
return bookingApproved(input.data); return bookingApproved(input.data);
case "account_suspended": case "account_suspended":
return accountSuspended(input.data); return accountSuspended(input.data);
case "join_request":
return joinRequest(input.data);
case "join_rejected":
return joinRejected(input.data);
case "payment_expired":
return paymentExpired(input.data);
case "trip_cancelled_organizer":
return tripCancelledOrganizer(input.data);
case "trip_cancelled_admin":
return tripCancelledAdmin(input.data);
case "payout_released":
return payoutReleased(input.data);
case "payout_paid":
return payoutPaid(input.data);
case "account_unsuspended":
return accountUnsuspended(input.data);
case "kyc_submitted":
return kycSubmitted(input.data);
case "kyc_manual_override":
return kycManualOverride(input.data);
case "kyc_reopened":
return kycReopened(input.data);
} }
} }
@@ -0,0 +1,3 @@
-- AlterTable: simpan body HTML email terkirim supaya admin bisa resend
-- (PR-E5) dan investigasi isi email. Nullable — row lama tidak punya body.
ALTER TABLE "EmailSent" ADD COLUMN "html" TEXT;
+4
View File
@@ -475,6 +475,10 @@ model EmailSent {
to String to String
template String template String
subject String subject String
/// Body HTML email yang dikirim — disimpan supaya admin bisa resend (PR-E5)
/// & investigasi isi email. Nullable: row lama (sebelum kolom ini ada) tidak
/// punya body sehingga tidak bisa di-resend.
html String?
/// ID dari Resend (atau provider lain) untuk troubleshooting di dashboard mereka. /// ID dari Resend (atau provider lain) untuk troubleshooting di dashboard mereka.
providerMessageId String? providerMessageId String?
sentAt DateTime @default(now()) sentAt DateTime @default(now())
+77
View File
@@ -0,0 +1,77 @@
import { prisma } from "@/lib/prisma";
import { Prisma } from "@/app/generated/prisma/client";
import type { EmailJobStatus } from "@/app/generated/prisma/enums";
/** Filter untuk halaman admin email log. Keduanya opsional, match `contains`. */
export interface EmailLogFilters {
to?: string;
template?: string;
}
const LIST_LIMIT = 100;
function buildWhere<T extends { to?: unknown; template?: unknown }>(
filters: EmailLogFilters
): T {
const where = {} as T;
if (filters.to) {
(where as { to?: unknown }).to = {
contains: filters.to,
mode: "insensitive",
};
}
if (filters.template) {
(where as { template?: unknown }).template = {
contains: filters.template,
mode: "insensitive",
};
}
return where;
}
export const emailRepo = {
/** EmailJob (retry queue) per status — terbaru dulu. */
async listJobs(statuses: EmailJobStatus[], filters: EmailLogFilters) {
const where = buildWhere<Prisma.EmailJobWhereInput>(filters);
where.status = { in: statuses };
return prisma.emailJob.findMany({
where,
orderBy: { updatedAt: "desc" },
take: LIST_LIMIT,
});
},
/** EmailSent (log email berhasil terkirim) — terbaru dulu. */
async listSent(filters: EmailLogFilters) {
const where = buildWhere<Prisma.EmailSentWhereInput>(filters);
return prisma.emailSent.findMany({
where,
orderBy: { sentAt: "desc" },
take: LIST_LIMIT,
});
},
/**
* Statistik kesehatan pengiriman email — dipakai kartu ringkasan
* `/admin/emails` dan `/admin/system`.
* - `queued` : job menunggu dikirim (PENDING/PROCESSING).
* - `failed24h` : job gagal dalam 24 jam terakhir.
* - `deadLetter` : job gagal yang sudah habis 5 attempt — cron berhenti
* retry, butuh aksi manual admin.
*/
async stats() {
const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
const [queued, failed24h, deadLetter] = await Promise.all([
prisma.emailJob.count({
where: { status: { in: ["PENDING", "PROCESSING"] } },
}),
prisma.emailJob.count({
where: { status: "FAILED", updatedAt: { gte: since24h } },
}),
prisma.emailJob.count({
where: { status: "FAILED", attempts: { gte: 5 } },
}),
]);
return { queued, failed24h, deadLetter };
},
};
+33
View File
@@ -183,6 +183,10 @@ async function applyGatewayStatus(
if (newStatus === "PAID" && !isConflict) { if (newStatus === "PAID" && !isConflict) {
void notifyPaymentPaid(payment.id); void notifyPaymentPaid(payment.id);
} }
// E3.3 — pembayaran kadaluarsa/gagal: kabari user supaya bisa retry.
if (newStatus === "EXPIRED" || newStatus === "FAILED") {
void notifyPaymentFailed(payment.id);
}
return { return {
ok: true, ok: true,
@@ -504,6 +508,35 @@ async function notifyPaymentPaid(paymentId: string) {
}); });
} }
/** E3.3 — kabari user kalau pembayaran expired/gagal supaya bisa retry. */
async function notifyPaymentFailed(paymentId: string) {
const payment = await prisma.payment.findUnique({
where: { id: paymentId },
include: {
booking: {
include: {
user: { select: { email: true, name: true } },
trip: { select: { id: true, title: true } },
},
},
},
});
if (!payment) return;
await emailService.send({
to: payment.booking.user.email,
idempotencyKey: `payment_expired-${payment.id}`,
template: {
template: "payment_expired",
data: {
userName: payment.booking.user.name,
tripTitle: payment.booking.trip.title,
tripId: payment.booking.trip.id,
amount: payment.amount,
},
},
});
}
// Re-export untuk testing langsung kalau perlu (tetap private dari modul lain). // Re-export untuk testing langsung kalau perlu (tetap private dari modul lain).
export const _internal = { applyGatewayStatus }; export const _internal = { applyGatewayStatus };
export type { MidtransTransactionStatus }; export type { MidtransTransactionStatus };
+28
View File
@@ -1,6 +1,7 @@
import { Prisma } from "@/app/generated/prisma/client"; import { Prisma } from "@/app/generated/prisma/client";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { payoutRepo } from "@/server/repositories/payout.repo"; import { payoutRepo } from "@/server/repositories/payout.repo";
import { emailService } from "@/lib/email/send";
const SERIAL_TX_ATTEMPTS = 6; const SERIAL_TX_ATTEMPTS = 6;
@@ -134,6 +135,33 @@ export const payoutService = {
where: { id: { in: ids }, status: "HELD" }, where: { id: { in: ids }, status: "HELD" },
data: { status: "RELEASED", releasedAt: now }, data: { status: "RELEASED", releasedAt: now },
}); });
// E3.6 — kabari organizer payout-nya sudah lepas hold & masuk antrian
// transfer. Pin ke `releasedAt: now` supaya hanya yang baru di-release.
const released = await prisma.payout.findMany({
where: { id: { in: ids }, status: "RELEASED", releasedAt: now },
select: {
id: true,
amount: true,
organizer: { select: { email: true, name: true } },
trip: { select: { title: true } },
},
});
for (const p of released) {
void emailService.send({
to: p.organizer.email,
idempotencyKey: `payout_released-${p.id}`,
template: {
template: "payout_released",
data: {
organizerName: p.organizer.name,
tripTitle: p.trip.title,
amount: p.amount,
},
},
});
}
return { releasedIds: ids }; return { releasedIds: ids };
}, },
+28 -1
View File
@@ -458,7 +458,14 @@ export const tripService = {
return runSerializable(async (tx) => { return runSerializable(async (tx) => {
const trip = await tx.trip.findUnique({ const trip = await tx.trip.findUnique({
where: { id: tripId }, where: { id: tripId },
select: { id: true, status: true, organizerId: true, date: true }, select: {
id: true,
status: true,
organizerId: true,
date: true,
title: true,
organizer: { select: { id: true, email: true, name: true } },
},
}); });
if (!trip) { if (!trip) {
throw new Error("Trip tidak ditemukan"); throw new Error("Trip tidak ditemukan");
@@ -500,6 +507,8 @@ export const tripService = {
const refundsCreated: string[] = []; const refundsCreated: string[] = [];
const cancelledBookings: string[] = []; const cancelledBookings: string[] = [];
const skippedBookings: string[] = []; const skippedBookings: string[] = [];
// userId → nominal refund yang dibuat untuk dia (untuk email pembatalan).
const refundByUser = new Map<string, number>();
for (const b of bookings) { for (const b of bookings) {
if (b.status === "CANCELLED" || b.status === "EXPIRED") { if (b.status === "CANCELLED" || b.status === "EXPIRED") {
@@ -540,6 +549,7 @@ export const tripService = {
} }
); );
refundsCreated.push(refund.id); refundsCreated.push(refund.id);
refundByUser.set(b.userId, remaining);
// Trip dibatalkan → cancel payout HELD/RELEASED organizer untuk // Trip dibatalkan → cancel payout HELD/RELEASED organizer untuk
// booking ini. Payout PAID di-flag clawback otomatis. // booking ini. Payout PAID di-flag clawback otomatis.
@@ -560,6 +570,12 @@ export const tripService = {
} }
} }
// Snapshot peserta aktif SEBELUM di-cancel — untuk notifikasi email.
const activeParticipants = await tx.tripParticipant.findMany({
where: { tripId, status: { not: "CANCELLED" } },
select: { user: { select: { id: true, email: true, name: true } } },
});
// Semua participant aktif → CANCELLED (apapun status booking-nya). // Semua participant aktif → CANCELLED (apapun status booking-nya).
await tx.tripParticipant.updateMany({ await tx.tripParticipant.updateMany({
where: { tripId, status: { not: "CANCELLED" } }, where: { tripId, status: { not: "CANCELLED" } },
@@ -586,6 +602,17 @@ export const tripService = {
refundsCreated, refundsCreated,
cancelledBookings, cancelledBookings,
skippedBookings, skippedBookings,
// Data penerima notifikasi — email dikirim oleh action setelah tx commit.
notify: {
tripTitle: trip.title,
organizer: trip.organizer,
participants: activeParticipants.map((p) => ({
userId: p.user.id,
email: p.user.email,
name: p.user.name,
refundAmount: refundByUser.get(p.user.id) ?? 0,
})),
},
}; };
}, "Gagal membatalkan trip. Coba lagi sebentar."); }, "Gagal membatalkan trip. Coba lagi sebentar.");
}, },