diff --git a/EMAIL_NOTIFICATIONS_ROADMAP.md b/EMAIL_NOTIFICATIONS_ROADMAP.md index b2bd93c..3c75460 100644 --- a/EMAIL_NOTIFICATIONS_ROADMAP.md +++ b/EMAIL_NOTIFICATIONS_ROADMAP.md @@ -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?". > - **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) @@ -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. @@ -52,12 +54,12 @@ Alternatif: AWS SES (paling murah at scale), SendGrid, Mailgun. **Decision:** Re | # | Item | Status | File | |---|---|---|---| -| 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.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.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 "`) | ⏳ | `.env.example` | +| 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.3 | Service `lib/email/send.ts` — Resend client (raw fetch) + idempotency + enqueue on failure | ✅ | `lib/email/send.ts` | +| 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.6 | Env: `RESEND_API_KEY`, `EMAIL_FROM` (mis. `"SeTrip "`) | ✅ | `.env.example` | **Tindakan manual ops:** 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. | # | Trigger | Penerima | Template | Wire point | Status | |---|---|---|---|---|---| -| E2.1 | KYC `VERIFICATION_APPROVE` | User | `kyc_approved` | `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.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.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.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.1 | KYC `VERIFICATION_APPROVE` | User | `kyc_approved` | `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.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.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.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.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:** - `kyc_approved-` @@ -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. -| # | Trigger | Penerima | Template | Wire point | -|---|---|---|---|---| -| E3.1 | User join trip (PENDING) | Organizer | `join_request` (siapa, link approve) | `joinTripAction` | -| E3.2 | Organizer reject join | User | `join_rejected` (singkat) | `rejectParticipantAction` | -| 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.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.7 | Payout PAID | Organizer | `payout_paid` (cek rekening + adminNote) | `markPayoutPaidAction` | -| E3.8 | Account unsuspended | User | `account_unsuspended` | `unsuspendUserAction` | -| E3.9 | KYC submitted | User | `kyc_submitted` (ETA review) | `submitVerificationAction` | -| E3.10 | KYC manual override | User | `kyc_manual_override` (admin verified) | `manualOverrideVerificationAction` | -| E3.11 | KYC reopened (admin) | User | `kyc_reopened` (boleh submit ulang) | `reopenVerificationAction` | +| # | Trigger | Penerima | Template | Wire point | Status | +|---|---|---|---|---|---| +| E3.1 | User join trip (PENDING) | Organizer | `join_request` (siapa, link approve) | `joinTripAction` | ✅ | +| E3.2 | Organizer reject join | User | `join_rejected` (singkat) | `rejectParticipantAction` | ✅ | +| 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.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.7 | Payout PAID | Organizer | `payout_paid` (cek rekening + adminNote) | `markPayoutPaidAction` | ✅ | +| E3.8 | Account unsuspended | User | `account_unsuspended` | `unsuspendUserAction` | ✅ | +| E3.9 | KYC submitted | User | `kyc_submitted` (ETA review) | `submitVerificationAction` | ✅ | +| E3.10 | KYC manual override | User | `kyc_manual_override` (admin verified) | `manualOverrideVerificationAction` | ✅ | +| 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?". -| # | Item | -|---|---| -| E5.1 | Page `/admin/emails` — list `EmailSent` + `EmailJob` dengan filter (recipient, template, status, date) | -| E5.2 | Tombol "Retry now" untuk EmailJob FAILED | -| E5.3 | Tombol "Resend" untuk EmailSent (override idempotency, append `?retry=` ke key) | -| E5.4 | Stats card di `/admin/system`: failed count 24h, queued count | +| # | Item | Status | File | +|---|---|---|---| +| E5.1 | Page `/admin/emails` — list `EmailSent` + `EmailJob`, filter recipient + template, status lewat tab | ✅ | `app/admin/emails/page.tsx` | +| 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 — key turunan `#resend-`, butuh `EmailSent.html` | ✅ | `features/email/actions.ts` | +| 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). --- diff --git a/REFUND_ROADMAP.md b/REFUND_ROADMAP.md index 1499fdb..5909128 100644 --- a/REFUND_ROADMAP.md +++ b/REFUND_ROADMAP.md @@ -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. +**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) @@ -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. @@ -48,14 +50,14 @@ Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDIN | # | Item | Status | File | |---|---|---|---| -| 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.3 | Migration `add_refund_model` | ⏳ | `prisma/migrations/` | -| 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.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.8 | UI admin `/admin/refunds` — list PENDING + tombol approve & mark-succeeded | ⏳ | `app/admin/refunds/page.tsx` | +| 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.3 | Migration `add_refund_model` | ✅ | `prisma/migrations/` | +| 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.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.8 | UI admin `/admin/refunds` — list PENDING + tombol approve & mark-succeeded | ✅ | `app/admin/refunds/page.tsx` | **Tindakan manual:** 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. @@ -75,16 +77,16 @@ Saat organizer batalkan trip (`status = CLOSED`), semua peserta dengan `Booking. | # | 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.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.4 | Server action `cancelTripAction` | ⏳ | `features/trip/actions.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.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` | **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). @@ -100,11 +102,11 @@ User bisa cancel booking sendiri, refund dihitung berdasarkan kebijakan default | # | Item | Status | File | |---|---|---|---| -| 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.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.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.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.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.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:** 1. Tulis copy kebijakan refund untuk halaman Terms & Privacy. diff --git a/app/admin/emails/layout.tsx b/app/admin/emails/layout.tsx new file mode 100644 index 0000000..c2521a6 --- /dev/null +++ b/app/admin/emails/layout.tsx @@ -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; +} diff --git a/app/admin/emails/page.tsx b/app/admin/emails/page.tsx new file mode 100644 index 0000000..4817ad2 --- /dev/null +++ b/app/admin/emails/page.tsx @@ -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 ( +
+

+ Halaman ini hanya untuk admin SeTrip. +

+
+ ); + } + + 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 ( +
+
+

+ Email Log +

+

+ Pantau pengiriman email transaksional. Email yang gagal dikirim bisa + di-retry manual; email terkirim bisa di-resend kalau peserta lapor + tidak menerima. +

+
+ + {/* Kartu ringkasan */} +
+ 0 ? "amber" : "ok"} + hint="Job menunggu cron / retry" + /> + 0 ? "red" : "ok"} + hint="Job gagal dalam sehari terakhir" + /> + 0 ? "red" : "ok"} + hint="Gagal & habis 5 attempt — cron berhenti retry" + /> +
+ + {/* Tabs */} +
+ {TABS.map((t) => ( + + {t.label} + + ))} +
+ + {/* Filter */} +
+ +
+ + +
+
+ + +
+ + {(filters.to || filters.template) && ( + + Reset + + )} +
+ + {tab === "sent" ? ( + + ) : ( + + )} +
+ ); +} + +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 ( +
+

+ {label} +

+

{value}

+

{hint}

+
+ ); +} + +function JobTable({ + rows, + tab, +}: { + rows: Awaited>; + tab: "failed" | "queue"; +}) { + if (rows.length === 0) { + return ( + + ); + } + return ( +
+ + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + ))} + +
PenerimaTemplateStatusAttempt + {tab === "failed" ? "Error terakhir" : "Dijadwalkan"} + Aksi
{r.to}{r.template} + + + {r.attempts} + {r.attempts >= 5 && ( + + (mati) + + )} + + {tab === "failed" + ? r.lastError + ? truncate(r.lastError, 90) + : "—" + : formatDateTime(r.scheduledAt)} + + +
+
+ ); +} + +function SentTable({ + rows, +}: { + rows: Awaited>; +}) { + if (rows.length === 0) { + return ; + } + return ( +
+ + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + ))} + +
PenerimaTemplateSubjectTerkirimAksi
{r.to}{r.template}{truncate(r.subject, 60)} + {formatDateTime(r.sentAt)} + + +
+
+ ); +} + +function EmptyState({ message }: { message: string }) { + return ( +
+

{message}

+
+ ); +} + +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 ( + + {value} + + ); +} + +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; +} diff --git a/app/admin/system/page.tsx b/app/admin/system/page.tsx index faf54f8..ff5420a 100644 --- a/app/admin/system/page.tsx +++ b/app/admin/system/page.tsx @@ -5,6 +5,7 @@ import { authOptions } from "@/lib/auth"; import { isAdminEmail } from "@/lib/admin"; import { prisma } from "@/lib/prisma"; import { systemHealthService } from "@/server/services/system-health.service"; +import { emailRepo } from "@/server/repositories/email.repo"; interface JobSummary { 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)), prisma.cronRun.findMany({ orderBy: { startedAt: "desc" }, take: 20, }), systemHealthService.detectStale(), + emailRepo.stats(), ]); const hasAnyStale = stale.stalePaymentsCount > 0 || stale.awaitingPayPastDepartureCount > 0 || stale.overduePayoutsCount > 0 || - stale.stuckRefundsCount > 0; + stale.stuckRefundsCount > 0 || + emailStats.deadLetter > 0; return (
@@ -152,6 +155,19 @@ export default async function AdminSystemPage() { )} + {emailStats.deadLetter > 0 && ( +
  • + • {emailStats.deadLetter} email gagal kirim & + sudah habis 5 attempt — cron berhenti retry, perlu retry + manual.{" "} + + Lihat email gagal → + +
  • + )} )} @@ -236,6 +252,37 @@ export default async function AdminSystemPage() {
    +
    +

    + Email +

    +
    + 0 ? "amber" : "ok"} + /> + 0 ? "red" : "ok"} + /> + 0 ? "red" : "ok"} + /> +
    +

    + + Buka Email Log → + +

    +
    +

    Recent Runs (20 terakhir) @@ -301,6 +348,37 @@ function truncate(s: string, max: number): string { 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 ( +
    +

    + {label} +

    +

    {value}

    +
    + ); +} + function StatusBadge({ value }: { value: string }) { const cls = value === "SUCCESS" diff --git a/components/admin/admin-sidebar.tsx b/components/admin/admin-sidebar.tsx index 63abc1f..eee9d7e 100644 --- a/components/admin/admin-sidebar.tsx +++ b/components/admin/admin-sidebar.tsx @@ -14,6 +14,7 @@ const NAV_ITEMS: { href: string; label: string; icon: string }[] = [ { href: "/admin/verifications", label: "Verifikasi", icon: "🪪" }, { href: "/admin/refunds", label: "Refund", icon: "↩️" }, { href: "/admin/payouts", label: "Payout", icon: "💸" }, + { href: "/admin/emails", label: "Email", icon: "✉️" }, { href: "/admin/audit-log", label: "Audit Log", icon: "📜" }, { href: "/admin/system", label: "System", icon: "⚙️" }, ]; diff --git a/features/admin/actions.ts b/features/admin/actions.ts index 4ba3b58..9667259 100644 --- a/features/admin/actions.ts +++ b/features/admin/actions.ts @@ -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) { const session = await getServerSession(authOptions); if (!session?.user) { @@ -75,6 +90,10 @@ export async function unsuspendUserAction(userId: string) { entityType: "User", entityId: userId, }); + + // Notif email user — kabari akun sudah aktif kembali. + void notifyUnsuspended(userId); + revalidatePath("/admin/users"); revalidatePath(`/admin/users/${userId}`); return { success: true as const }; diff --git a/features/email/actions.ts b/features/email/actions.ts new file mode 100644 index 0000000..55f0c02 --- /dev/null +++ b/features/email/actions.ts @@ -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 }; +} diff --git a/features/email/components/email-row-actions.tsx b/features/email/components/email-row-actions.tsx new file mode 100644 index 0000000..41ea9a0 --- /dev/null +++ b/features/email/components/email-row-actions.tsx @@ -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 ( +
    + + {error && ( +

    {error}

    + )} +
    + ); +} + +/** 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 ( + + — + + ); + } + + return ( +
    + + {error && ( +

    {error}

    + )} +
    + ); +} diff --git a/features/organizer/actions.ts b/features/organizer/actions.ts index 442a15b..188dd66 100644 --- a/features/organizer/actions.ts +++ b/features/organizer/actions.ts @@ -13,6 +13,7 @@ import { auditLog } from "@/server/services/audit-log.service"; import { emailService } from "@/lib/email/send"; import { organizerRepo } from "@/server/repositories/organizer.repo"; import { userRepo } from "@/server/repositories/user.repo"; +import { prisma } from "@/lib/prisma"; import { submitVerificationSchema, reviewVerificationSchema } from "./schemas"; export async function submitVerificationAction(formData: FormData) { @@ -43,6 +44,7 @@ export async function submitVerificationAction(formData: FormData) { ...result.data, birthDate: new Date(result.data.birthDate), }); + void notifyKycSubmitted(session.user.id); revalidatePath("/verify"); revalidatePath("/profile"); 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 * 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, note, }); + void notifyKycReopened(verificationId); await auditLog.record({ admin: { id: session.user.id, email: session.user.email }, action: "VERIFICATION_REOPEN", @@ -262,6 +313,7 @@ export async function manualOverrideVerificationAction(input: { bankAccountNumber: input.bankAccountNumber, bankAccountName: input.bankAccountName, }); + void notifyKycManualOverride(input.userId, result.id); await auditLog.record({ admin: { id: session.user.id, email: session.user.email }, action: "VERIFICATION_MANUAL_OVERRIDE", diff --git a/features/payout/actions.ts b/features/payout/actions.ts index 5b4aec8..6b93481 100644 --- a/features/payout/actions.ts +++ b/features/payout/actions.ts @@ -5,7 +5,9 @@ import { revalidatePath } from "next/cache"; import { authOptions } from "@/lib/auth"; import { isAdminEmail } from "@/lib/admin"; import { payoutService } from "@/server/services/payout.service"; +import { payoutRepo } from "@/server/repositories/payout.repo"; import { auditLog } from "@/server/services/audit-log.service"; +import { emailService } from "@/lib/email/send"; import { payoutMarkPaidSchema } from "./schemas"; async function requireAdmin() { @@ -34,6 +36,7 @@ export async function markPayoutPaidAction(formData: FormData) { adminId: admin.id, adminNote: parsed.data.adminNote, }); + void notifyPayoutPaid(parsed.data.payoutId, parsed.data.adminNote); await auditLog.record({ admin: { id: admin.id, email: admin.email }, action: "PAYOUT_MARK_PAID", @@ -49,3 +52,22 @@ export async function markPayoutPaidAction(formData: FormData) { 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, + }, + }, + }); +} diff --git a/features/trip/actions.ts b/features/trip/actions.ts index bec0f5a..4187720 100644 --- a/features/trip/actions.ts +++ b/features/trip/actions.ts @@ -131,6 +131,7 @@ export async function joinTripAction(tripId: string) { try { await requireActiveUser(session.user.id); await tripService.joinTrip(tripId, session.user.id); + void notifyJoinRequest(tripId, session.user.id); revalidatePath(`/trips/${tripId}`); revalidatePath("/trips"); 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( tripId: string, participantId: string @@ -227,6 +282,7 @@ export async function rejectParticipantAction( participantId, session.user.id ); + void notifyJoinRejected(participantId); revalidatePath(`/trips/${tripId}`); revalidatePath("/trips"); revalidatePath("/"); @@ -237,6 +293,66 @@ export async function rejectParticipantAction( } } +type CloseTripResult = Awaited>; + +/** + * 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) { const session = await getServerSession(authOptions); if (!session?.user) { @@ -248,6 +364,7 @@ export async function cancelTripAction(tripId: string) { type: "ORGANIZER", userId: session.user.id, }); + notifyTripCancelled(tripId, result.notify, { type: "ORGANIZER" }); revalidatePath(`/trips/${tripId}`); revalidatePath("/trips"); revalidatePath("/"); @@ -294,6 +411,10 @@ export async function adminCancelTripAction(tripId: string, reason: string) { adminId: session.user.id, reason: trimmedReason, }); + notifyTripCancelled(tripId, result.notify, { + type: "ADMIN", + reason: trimmedReason, + }); await auditLog.record({ admin: { id: session.user.id, email: session.user.email }, action: "TRIP_ADMIN_CANCEL", diff --git a/lib/email/send.ts b/lib/email/send.ts index b128734..9697a4f 100644 --- a/lib/email/send.ts +++ b/lib/email/send.ts @@ -77,6 +77,7 @@ export const emailService = { to: input.to, template: input.template.template, subject: rendered.subject, + html: rendered.html, providerMessageId: result.data?.id ?? null, }, }); @@ -162,6 +163,7 @@ export const emailService = { to: job.to, template: job.template, subject: job.subject, + html: job.html, providerMessageId: result.data?.id ?? null, }, }), @@ -188,6 +190,125 @@ export const emailService = { 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( diff --git a/lib/email/templates.ts b/lib/email/templates.ts index 1ce772b..a0fc5f5 100644 --- a/lib/email/templates.ts +++ b/lib/email/templates.ts @@ -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 `

    Booking kamu untuk trip ini tidak punya pembayaran yang perlu dikembalikan.

    `; + } + return `
    +

    Refund kamu sedang diproses

    +

    ${formatRupiah(refundAmount)}

    +

    Admin SeTrip akan memproses transfer ke rekening kamu. Kami kabari lagi via email saat refund selesai.

    +
    `; +} + +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: ` +

    Ada yang mau gabung trip kamu

    +

    Halo ${escapeHtml(d.organizerName)}, ${escapeHtml(d.joinerName)} mengajukan diri untuk ikut trip ${escapeHtml(d.tripTitle)}.

    +

    Buka halaman trip untuk meninjau profil peserta lalu setujui atau tolak permintaannya.

    + `, + 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: ` +

    Permintaan gabung belum diterima

    +

    Halo ${escapeHtml(d.userName)}, organizer belum bisa menerima permintaan kamu untuk ikut trip ${escapeHtml(d.tripTitle)} kali ini.

    +

    Jangan berkecil hati — masih banyak trip seru lain yang bisa kamu ikuti.

    + `, + 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: ` +

    Pembayaran belum selesai

    +

    Halo ${escapeHtml(d.userName)}, pembayaran kamu untuk trip ${escapeHtml(d.tripTitle)} sebesar ${formatRupiah(d.amount)} kadaluarsa atau gagal — slot kamu belum aman.

    +

    Selama trip belum penuh dan belum berangkat, kamu masih bisa mengulang pembayaran.

    + `, + 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: ` +

    Trip dibatalkan

    +

    Halo ${escapeHtml(d.userName)}, mohon maaf — organizer membatalkan trip ${escapeHtml(d.tripTitle)}. Partisipasi kamu di trip ini otomatis dibatalkan.

    + ${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: ` +

    Trip dibatalkan SeTrip

    +

    Halo ${escapeHtml(d.userName)}, trip ${escapeHtml(d.tripTitle)} dibatalkan oleh tim admin SeTrip.

    +
    +

    Alasan:

    +

    ${escapeHtml(d.reason)}

    +
    + ${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: ` +

    Payout siap ditransfer

    +

    Halo ${escapeHtml(d.organizerName)}, masa tahan (escrow) untuk trip ${escapeHtml(d.tripTitle)} sudah berakhir.

    +

    ${formatRupiah(d.amount)}

    +

    Dana ini masuk antrian transfer — admin SeTrip akan memprosesnya ke rekening kamu. Kami kabari lagi saat sudah ditransfer.

    + `, + }), + }; +} + +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: ` +

    Payout sudah ditransfer

    +

    Halo ${escapeHtml(d.organizerName)}, payout untuk trip ${escapeHtml(d.tripTitle)} sudah kami transfer.

    +

    ${formatRupiah(d.amount)}

    + ${d.adminNote ? `

    Catatan / referensi transfer:

    ${escapeHtml(d.adminNote)}

    ` : ""} +

    Cek rekening kamu — biasanya masuk dalam 1×24 jam tergantung bank.

    + `, + }), + }; +} + +export interface AccountUnsuspendedData { + userName: string; +} +function accountUnsuspended(d: AccountUnsuspendedData) { + return { + subject: "✅ Akun SeTrip kamu aktif kembali", + html: shell({ + title: "Account Unsuspended", + bodyHtml: ` +

    Akun aktif kembali

    +

    Halo ${escapeHtml(d.userName)}, penangguhan akun SeTrip kamu sudah dicabut. Kamu bisa login dan beraktivitas seperti biasa lagi.

    + `, + 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: ` +

    Pengajuan diterima

    +

    Halo ${escapeHtml(d.userName)}, pengajuan verifikasi organizer kamu sudah masuk dan sedang antri di-review tim admin.

    +

    Estimasi review 1–3 hari kerja. Hasilnya kami kabari lewat email — tidak perlu submit ulang selama belum ada keputusan.

    + `, + }), + }; +} + +export interface KycManualOverrideData { + userName: string; +} +function kycManualOverride(d: KycManualOverrideData) { + return { + subject: "✅ Kamu jadi organizer terverifikasi SeTrip", + html: shell({ + title: "KYC Manual Override", + bodyHtml: ` +

    🎉 Selamat ${escapeHtml(d.userName)}!

    +

    Tim admin SeTrip sudah memverifikasi kamu sebagai organizer. Mulai sekarang kamu bisa membuat trip berbayar dan menerima payout.

    + `, + 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: ` +

    Pengajuan dibuka kembali

    +

    Halo ${escapeHtml(d.userName)}, tim admin membuka kembali pengajuan verifikasi organizer kamu. Kamu bisa memperbaiki data lalu mengajukannya ulang.

    + `, + ctaLabel: "Buka Halaman Verifikasi", + ctaUrl: `${siteUrl}/verify`, + }), + }; +} + // ============================================================================ // Registry — discriminated union supaya type-safe per template. // ============================================================================ @@ -314,7 +552,18 @@ export type EmailTemplate = | { template: "refund_failed"; data: RefundFailedData } | { template: "payment_paid"; data: PaymentPaidData } | { 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): { subject: string; @@ -339,6 +588,28 @@ export function renderEmail(input: EmailTemplate): { return bookingApproved(input.data); case "account_suspended": 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); } } diff --git a/prisma/migrations/20260520000000_add_email_sent_html/migration.sql b/prisma/migrations/20260520000000_add_email_sent_html/migration.sql new file mode 100644 index 0000000..c60bbab --- /dev/null +++ b/prisma/migrations/20260520000000_add_email_sent_html/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9fd99e8..13065ce 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -475,6 +475,10 @@ model EmailSent { to String template 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. providerMessageId String? sentAt DateTime @default(now()) diff --git a/server/repositories/email.repo.ts b/server/repositories/email.repo.ts new file mode 100644 index 0000000..3dcea6f --- /dev/null +++ b/server/repositories/email.repo.ts @@ -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( + 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(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(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 }; + }, +}; diff --git a/server/services/payment.service.ts b/server/services/payment.service.ts index 39b7e5d..37b980d 100644 --- a/server/services/payment.service.ts +++ b/server/services/payment.service.ts @@ -183,6 +183,10 @@ async function applyGatewayStatus( if (newStatus === "PAID" && !isConflict) { void notifyPaymentPaid(payment.id); } + // E3.3 — pembayaran kadaluarsa/gagal: kabari user supaya bisa retry. + if (newStatus === "EXPIRED" || newStatus === "FAILED") { + void notifyPaymentFailed(payment.id); + } return { 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). export const _internal = { applyGatewayStatus }; export type { MidtransTransactionStatus }; diff --git a/server/services/payout.service.ts b/server/services/payout.service.ts index d9bb13c..a09bbdf 100644 --- a/server/services/payout.service.ts +++ b/server/services/payout.service.ts @@ -1,6 +1,7 @@ import { Prisma } from "@/app/generated/prisma/client"; import { prisma } from "@/lib/prisma"; import { payoutRepo } from "@/server/repositories/payout.repo"; +import { emailService } from "@/lib/email/send"; const SERIAL_TX_ATTEMPTS = 6; @@ -134,6 +135,33 @@ export const payoutService = { where: { id: { in: ids }, status: "HELD" }, 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 }; }, diff --git a/server/services/trip.service.ts b/server/services/trip.service.ts index 111ea31..52f3aad 100644 --- a/server/services/trip.service.ts +++ b/server/services/trip.service.ts @@ -458,7 +458,14 @@ export const tripService = { return runSerializable(async (tx) => { const trip = await tx.trip.findUnique({ 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) { throw new Error("Trip tidak ditemukan"); @@ -500,6 +507,8 @@ export const tripService = { const refundsCreated: string[] = []; const cancelledBookings: string[] = []; const skippedBookings: string[] = []; + // userId → nominal refund yang dibuat untuk dia (untuk email pembatalan). + const refundByUser = new Map(); for (const b of bookings) { if (b.status === "CANCELLED" || b.status === "EXPIRED") { @@ -540,6 +549,7 @@ export const tripService = { } ); refundsCreated.push(refund.id); + refundByUser.set(b.userId, remaining); // Trip dibatalkan → cancel payout HELD/RELEASED organizer untuk // 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). await tx.tripParticipant.updateMany({ where: { tripId, status: { not: "CANCELLED" } }, @@ -586,6 +602,17 @@ export const tripService = { refundsCreated, cancelledBookings, 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."); },