diff --git a/.env.example b/.env.example index dd03b15..f16fa27 100644 --- a/.env.example +++ b/.env.example @@ -44,4 +44,16 @@ CRON_SECRET= # 1. Discord channel internal → Edit Channel → Integrations → Webhooks → New # 2. Copy "Webhook URL", paste di sini # Format: https://discord.com/api/webhooks// -ADMIN_ALERT_WEBHOOK_URL= \ No newline at end of file +ADMIN_ALERT_WEBHOOK_URL= + + +# === Email notifications (Resend) === +# API key Resend untuk kirim email transaksional (KYC, refund, payment, suspend). +# Tanpa env, sync send di-skip dan semua email di-queue di DB (status PENDING). +# Setelah env di-set, cron `/api/cron/process-email-jobs` akan drain queue. +# Sign up: https://resend.com → API Keys +RESEND_API_KEY= +# Email sender — format RFC 5322 "Display Name ". +# Domain harus diverifikasi di Resend dashboard (SPF + DKIM). +# Default `onboarding@resend.dev` cocok untuk dev/testing. +EMAIL_FROM="SeTrip " \ No newline at end of file diff --git a/EMAIL_NOTIFICATIONS_ROADMAP.md b/EMAIL_NOTIFICATIONS_ROADMAP.md new file mode 100644 index 0000000..b2bd93c --- /dev/null +++ b/EMAIL_NOTIFICATIONS_ROADMAP.md @@ -0,0 +1,153 @@ +# Setrip — Email Notifications Roadmap + +Status implementasi notifikasi email transaksional ke user & organizer. Pakai pola yang sama dengan refund/admin roadmap: per-phase checklist, idempotent, auditable. + +> **Prinsip:** +> - **Transactional only di MVP** — KYC, refund, payment, account moderation. Marketing/reminder belakangan. +> - **Idempotent** — webhook retry / cron rerun tidak boleh double-send. Pakai `idempotencyKey` unique constraint di `EmailSent`. +> - **Non-blocking** — server action utama tidak boleh gagal kalau email gateway down. Pattern: try send sync; kalau gagal, enqueue `EmailJob` untuk retry cron. +> - **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. + +--- + +## Baseline (kondisi sekarang) + +- ❌ Tidak ada email service terintegrasi. +- ❌ Tidak ada template engine. +- ❌ User & organizer hanya tahu state via UI — kalau tidak buka app, miss event penting (refund cair, KYC approve, dst). + +--- + +## Provider choice + +**Resend** — alasan: +- Free tier 3000 email/bulan + 100/hari (cukup untuk MVP) +- React Email native (kalau mau upgrade dari plain HTML) +- API simple (POST `/emails`) +- DNS setup ringan (SPF + DKIM auto) + +Alternatif: AWS SES (paling murah at scale), SendGrid, Mailgun. **Decision:** Resend untuk MVP, evaluate ulang saat lebih dari 50k email/bulan. + +**Library dependency:** SKIP — pakai `fetch` ke `https://api.resend.com/emails` directly. Lebih ringkas, satu-satunya dependency-free option. + +--- + +## PR-E1 — Foundation: schema + service + cron (MVP wajib) ⏳ + +**Tujuan:** infrastructure kirim email yang idempotent + retry-able, tanpa nge-block server action. + +**Keputusan asumsi:** +- 2 model baru: + - `EmailSent` — append-only log dengan `idempotencyKey @unique`. Cek di sini sebelum kirim → cegah double-send. + - `EmailJob` — retry queue untuk send yang gagal sync. Status `PENDING/PROCESSING/SUCCESS/FAILED`, attempt counter, max 5 retry exponential. +- Service `emailService.send({ to, template, data, idempotencyKey })`: + 1. Cek `EmailSent` by idempotencyKey → kalau exist, return early. + 2. Render template ke `{ subject, html }`. + 3. POST ke Resend. + 4. Sukses → insert `EmailSent` row. + 5. Gagal → insert `EmailJob` row (status PENDING, attempts=1). +- Cron `/api/cron/process-email-jobs` setiap 5 menit — pick PENDING + FAILED (attempts<5), retry, mark SUCCESS atau bump attempts. +- Caller pattern: `void emailService.send(...)` (fire-and-forget) supaya tidak nge-block server action. Try/catch internal sudah handle error. + +| # | 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` | + +**Tindakan manual ops:** +1. Buat akun Resend, verify domain `setrip.id` (atau pakai `onboarding@resend.dev` di dev). +2. Set DNS SPF + DKIM record di provider domain. +3. Generate API key, set env `RESEND_API_KEY` + `EMAIL_FROM` di production. +4. Daftarkan cron baru di system crontab: `*/5 * * * * curl ... /api/cron/process-email-jobs`. + +--- + +## 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` | ⏳ | + +**Format idempotencyKey:** +- `kyc_approved-` +- `refund_succeeded-` +- `payment_paid-` +- `booking_approved-` +- `account_suspended--` (allow re-suspend kalau diulang) + +**Tindakan manual ops:** +1. Test setiap template di staging — render via Resend "Send test" atau preview HTML lokal. +2. Pastikan `EMAIL_FROM` domain match SPF/DKIM supaya tidak masuk spam. + +--- + +## 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` | + +--- + +## PR-E4 — Phase 3: Marketing / reminder (post-MVP, opt-in) ⏳ + +Email engagement — perlu user preference + unsubscribe link. + +| # | Trigger | Penerima | Template | Wire point | +|---|---|---|---|---| +| E4.1 | Welcome email saat signup | User | `welcome` | NextAuth `events.signIn` first time | +| E4.2 | Reminder H-3 keberangkatan | User | `trip_reminder_h3` (meeting point + itinerary) | Cron daily | +| E4.3 | Reminder H-1 keberangkatan | User | `trip_reminder_h1` | Cron daily | +| E4.4 | Trip selesai → minta review (H+1) | User CONFIRMED | `review_prompt` | Cron daily | +| E4.5 | Review baru diterima | Organizer | `new_review` | `createReviewAction` | +| E4.6 | Trip jadi FULL | Organizer | `trip_full` | `tripService.joinTrip` (saat FULL transition) | + +**Prerequisite:** tabel `UserEmailPreference` dengan kategori `marketing` / `reminders` + unsubscribe token. Skip sampai Phase 4. + +--- + +## 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 | + +--- + +## Skip / never (eksplisit) + +- ❌ SMS / WhatsApp — beda regulatory, beda cost. Stick to email. +- ❌ Push notification (browser/mobile) — perlu PWA setup terpisah. +- ❌ In-app inbox — komplexitas tinggi, low ROI di MVP. Email cukup. diff --git a/app/admin/system/page.tsx b/app/admin/system/page.tsx index da86bbc..faf54f8 100644 --- a/app/admin/system/page.tsx +++ b/app/admin/system/page.tsx @@ -55,7 +55,7 @@ async function getJobSummary(jobName: string): Promise { } // Daftar cron yang dipantau. Tambah entry baru saat menambah cron route handler. -const TRACKED_JOBS = ["auto-complete-trips"] as const; +const TRACKED_JOBS = ["auto-complete-trips", "process-email-jobs"] as const; function healthOf(summary: JobSummary): "ok" | "stale" | "failed" { if (summary.lastRun?.status === "FAILED") return "failed"; diff --git a/app/api/cron/process-email-jobs/route.ts b/app/api/cron/process-email-jobs/route.ts new file mode 100644 index 0000000..cfa2d19 --- /dev/null +++ b/app/api/cron/process-email-jobs/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { emailService } from "@/lib/email/send"; +import { runCron } from "@/lib/cron-runner"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * Cron — proses retry queue email (jobs status PENDING/FAILED dengan + * attempts<5 dan scheduledAt sudah lewat). + * + * Trigger setiap 5 menit via system crontab — lihat docs/CRON_SETUP.md. + * Header wajib: `Authorization: Bearer ${CRON_SECRET}`. + */ +export async function GET(req: NextRequest) { + const secret = process.env.CRON_SECRET; + if (!secret) { + return NextResponse.json( + { error: "Server misconfigured (CRON_SECRET)" }, + { status: 500 } + ); + } + const authHeader = req.headers.get("authorization"); + if (authHeader !== `Bearer ${secret}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const outcome = await runCron("process-email-jobs", async () => { + return emailService.processQueue(50); + }); + + if (!outcome.ok) { + return NextResponse.json({ error: outcome.error }, { status: 500 }); + } + return NextResponse.json({ ok: true, ...outcome.payload }); +} diff --git a/docs/CRON_SETUP.md b/docs/CRON_SETUP.md index 47677d9..f9cfe2b 100644 --- a/docs/CRON_SETUP.md +++ b/docs/CRON_SETUP.md @@ -7,6 +7,7 @@ Aplikasi punya beberapa endpoint cron yang harus di-trigger periodik dari luar. | Endpoint | Schedule | Tujuan | |---|---|---| | `GET /api/cron/auto-complete-trips` | `0 18 * * *` (18:00 UTC = 01:00 WIB) | Flip trip yang sudah lewat tanggal selesai dari `OPEN`/`FULL` ke `COMPLETED`. | +| `GET /api/cron/process-email-jobs` | `*/5 * * * *` (setiap 5 menit) | Drain retry queue email — pick `EmailJob` status PENDING/FAILED (attempts<5), retry via Resend. | ## Setup di server diff --git a/features/admin/actions.ts b/features/admin/actions.ts index dd95d92..4ba3b58 100644 --- a/features/admin/actions.ts +++ b/features/admin/actions.ts @@ -6,6 +6,8 @@ import { authOptions } from "@/lib/auth"; import { isAdminEmail } from "@/lib/admin"; import { userService } from "@/server/services/user.service"; import { auditLog } from "@/server/services/audit-log.service"; +import { emailService } from "@/lib/email/send"; +import { userRepo } from "@/server/repositories/user.repo"; export async function suspendUserAction(userId: string, reason: string) { const session = await getServerSession(authOptions); @@ -29,6 +31,10 @@ export async function suspendUserAction(userId: string, reason: string) { entityId: userId, payload: { reason: reason.trim() }, }); + + // Notif email user — due process: kasih tahu alasan + cara appeal. + void notifySuspended(userId, reason.trim()); + revalidatePath("/admin/users"); revalidatePath(`/admin/users/${userId}`); return { success: true as const }; @@ -37,6 +43,21 @@ export async function suspendUserAction(userId: string, reason: string) { } } +async function notifySuspended(userId: string, reason: string) { + const target = await userRepo.findById(userId); + if (!target) return; + await emailService.send({ + to: target.email, + // Suspend bisa di-trigger berulang — sertakan timestamp supaya tiap suspend + // baru dapat email baru. + idempotencyKey: `account_suspended-${userId}-${Date.now()}`, + template: { + template: "account_suspended", + data: { userName: target.name, reason }, + }, + }); +} + export async function unsuspendUserAction(userId: string) { const session = await getServerSession(authOptions); if (!session?.user) { diff --git a/features/organizer/actions.ts b/features/organizer/actions.ts index 81478b3..442a15b 100644 --- a/features/organizer/actions.ts +++ b/features/organizer/actions.ts @@ -10,6 +10,9 @@ import { type ReuploadField, } from "@/server/services/organizer.service"; 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 { submitVerificationSchema, reviewVerificationSchema } from "./schemas"; export async function submitVerificationAction(formData: FormData) { @@ -86,6 +89,10 @@ export async function reviewVerificationAction(formData: FormData) { ? { rejectionReason: result.data.rejectionReason ?? null } : undefined, }); + + // Notif email — fire and forget, jangan blok response. + void notifyVerificationDecision(result.data.verificationId, result.data.decision, result.data.rejectionReason); + revalidatePath("/admin/verifications"); revalidatePath("/verify"); revalidatePath("/profile"); @@ -95,6 +102,41 @@ export async function reviewVerificationAction(formData: FormData) { } } +async function notifyVerificationDecision( + verificationId: string, + decision: "APPROVED" | "REJECTED", + rejectionReason?: string +) { + const verification = await organizerRepo.findById(verificationId); + if (!verification) return; + const user = await userRepo.findById(verification.userId); + if (!user) return; + + if (decision === "APPROVED") { + await emailService.send({ + to: user.email, + idempotencyKey: `kyc_approved-${verificationId}`, + template: { + template: "kyc_approved", + data: { userName: user.name }, + }, + }); + } else { + await emailService.send({ + to: user.email, + // submissionCount supaya kalau reject berulang masing-masing dapat email. + idempotencyKey: `kyc_rejected-${verificationId}-${verification.submissionCount}`, + template: { + template: "kyc_rejected", + data: { + userName: user.name, + rejectionReason: rejectionReason ?? "(tidak ada alasan tercatat)", + }, + }, + }); + } +} + /** * Admin reopen pengajuan REJECTED ke PENDING — supaya organizer bisa * di-review ulang tanpa drop & recreate row. Note wajib min 10 char untuk audit. @@ -159,6 +201,10 @@ export async function requestReuploadAction( entityId: verificationId, payload: { fields: valid, note: note.trim() }, }); + + // Notif email organizer — urgent, action required. + void notifyReuploadRequest(verificationId, valid, note.trim()); + revalidatePath("/admin/verifications"); revalidatePath("/verify"); return { success: true as const }; @@ -167,6 +213,30 @@ export async function requestReuploadAction( } } +async function notifyReuploadRequest( + verificationId: string, + fields: ReuploadField[], + note: 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, + // Allow re-trigger kalau admin minta lagi setelah submit ulang. + idempotencyKey: `kyc_reupload_request-${verificationId}-${verification.submissionCount}`, + template: { + template: "kyc_reupload_request", + data: { + userName: user.name, + fields, + note, + }, + }, + }); +} + /** * Phase 4: admin verify user tanpa upload KYC (partner trusted referral). * Bikin row APPROVED dengan flag `isManualOverride = true`. diff --git a/features/refund/actions.ts b/features/refund/actions.ts index a978f70..11fa6b7 100644 --- a/features/refund/actions.ts +++ b/features/refund/actions.ts @@ -6,6 +6,8 @@ import { authOptions } from "@/lib/auth"; import { isAdminEmail } from "@/lib/admin"; import { refundService } from "@/server/services/refund.service"; import { auditLog } from "@/server/services/audit-log.service"; +import { emailService } from "@/lib/email/send"; +import { prisma } from "@/lib/prisma"; import { createRefundSchema, refundDecisionSchema } from "./schemas"; async function requireAdmin() { @@ -51,6 +53,9 @@ export async function createRefundAction(formData: FormData) { reason: parsed.data.reason, }, }); + + void notifyRefundCreated(refund.id); + revalidatePath("/admin/refunds"); return { success: true }; } catch (err) { @@ -58,6 +63,77 @@ export async function createRefundAction(formData: FormData) { } } +async function notifyRefundCreated(refundId: string) { + const refund = await prisma.refund.findUnique({ + where: { id: refundId }, + include: { + booking: { + include: { + user: { select: { email: true, name: true } }, + trip: { select: { title: true } }, + }, + }, + }, + }); + if (!refund) return; + await emailService.send({ + to: refund.booking.user.email, + idempotencyKey: `refund_created-${refund.id}`, + template: { + template: "refund_created", + data: { + userName: refund.booking.user.name, + tripTitle: refund.booking.trip.title, + amount: refund.amount, + reason: refund.reason, + }, + }, + }); +} + +async function notifyRefundDecision( + refundId: string, + decision: "SUCCEEDED" | "FAILED", + adminNote: string +) { + const refund = await prisma.refund.findUnique({ + where: { id: refundId }, + include: { + booking: { + include: { + user: { select: { email: true, name: true } }, + trip: { select: { title: true } }, + }, + }, + }, + }); + if (!refund) return; + await emailService.send({ + to: refund.booking.user.email, + idempotencyKey: `refund_${decision.toLowerCase()}-${refund.id}`, + template: + decision === "SUCCEEDED" + ? { + template: "refund_succeeded", + data: { + userName: refund.booking.user.name, + tripTitle: refund.booking.trip.title, + amount: refund.amount, + adminNote, + }, + } + : { + template: "refund_failed", + data: { + userName: refund.booking.user.name, + tripTitle: refund.booking.trip.title, + amount: refund.amount, + adminNote, + }, + }, + }); +} + export async function decideRefundAction(formData: FormData) { const admin = await requireAdmin(); if (!admin) return { error: "Tidak memiliki akses admin" }; @@ -110,6 +186,13 @@ export async function decideRefundAction(formData: FormData) { entityId: refundId, payload: adminNote ? { adminNote } : undefined, }); + + // Notif email user kalau decision final (SUCCEEDED/FAILED) — APPROVE/REJECT + // intermediate, refund_created sudah dikirim sebelumnya. + if (decision === "SUCCEEDED" || decision === "FAILED") { + void notifyRefundDecision(refundId, decision, adminNote ?? ""); + } + revalidatePath("/admin/refunds"); return { success: true }; } catch (err) { diff --git a/features/trip/actions.ts b/features/trip/actions.ts index 79be39c..bec0f5a 100644 --- a/features/trip/actions.ts +++ b/features/trip/actions.ts @@ -13,6 +13,8 @@ import { revalidatePath } from "next/cache"; import { tripStoredInstantFromYmd } from "@/lib/trip-dates"; import { requireActiveUser } from "@/lib/auth-guards"; import { auditLog } from "@/server/services/audit-log.service"; +import { emailService } from "@/lib/email/send"; +import { prisma } from "@/lib/prisma"; export async function createTripAction(formData: FormData) { const session = await getServerSession(authOptions); @@ -172,6 +174,9 @@ export async function confirmParticipantAction( participantId, session.user.id ); + + void notifyBookingApproved(participantId); + revalidatePath(`/trips/${tripId}`); revalidatePath("/trips"); revalidatePath("/"); @@ -182,6 +187,31 @@ export async function confirmParticipantAction( } } +async function notifyBookingApproved(participantId: string) { + const participant = await prisma.tripParticipant.findUnique({ + where: { id: participantId }, + include: { + user: { select: { email: true, name: true } }, + trip: { select: { id: true, title: true } }, + booking: { select: { id: true, amount: true } }, + }, + }); + if (!participant || !participant.booking) return; + await emailService.send({ + to: participant.user.email, + idempotencyKey: `booking_approved-${participant.booking.id}`, + template: { + template: "booking_approved", + data: { + userName: participant.user.name, + tripTitle: participant.trip.title, + tripId: participant.trip.id, + amount: participant.booking.amount, + }, + }, + }); +} + export async function rejectParticipantAction( tripId: string, participantId: string diff --git a/lib/email/send.ts b/lib/email/send.ts new file mode 100644 index 0000000..b128734 --- /dev/null +++ b/lib/email/send.ts @@ -0,0 +1,207 @@ +import { Resend } from "resend"; +import { prisma } from "@/lib/prisma"; +import { renderEmail, type EmailTemplate } from "@/lib/email/templates"; + +/** + * Email sender — idempotent, dengan fallback retry queue. + * + * Flow: + * 1. Cek `EmailSent` by `idempotencyKey`. Kalau sudah terkirim, skip (return). + * 2. Render template → `{ subject, html }`. + * 3. Try sync send via Resend. + * 4. Sukses → insert `EmailSent`. + * 5. Gagal → insert `EmailJob` (cron retry). + * + * Caller pattern: `void emailService.send(...)` (fire-and-forget). Service ini + * tidak throw — semua error di-handle internal supaya server action tidak gagal. + */ + +interface SendInput { + to: string; + idempotencyKey: string; + template: EmailTemplate; +} + +let _resend: Resend | null = null; +function getResend(): Resend | null { + const apiKey = process.env.RESEND_API_KEY; + if (!apiKey) return null; + if (!_resend) _resend = new Resend(apiKey); + return _resend; +} + +function emailFrom(): string { + return process.env.EMAIL_FROM ?? "SeTrip "; +} + +export const emailService = { + async send(input: SendInput): Promise { + try { + // 1. Idempotency check + const existing = await prisma.emailSent.findUnique({ + where: { idempotencyKey: input.idempotencyKey }, + select: { id: true }, + }); + if (existing) return; + + // 2. Render + const rendered = renderEmail(input.template); + + // 3. Try sync send + const resend = getResend(); + if (!resend) { + // Env tidak di-set — enqueue saja supaya tetap ter-log. + await enqueueJob(input, rendered); + console.warn( + "[email] RESEND_API_KEY tidak di-set, email di-queue:", + input.template.template, + input.to + ); + return; + } + + try { + const result = await resend.emails.send({ + from: emailFrom(), + to: input.to, + subject: rendered.subject, + html: rendered.html, + }); + if (result.error) { + throw new Error(result.error.message ?? "Resend send failed"); + } + // 4. Mark sent + await prisma.emailSent.create({ + data: { + idempotencyKey: input.idempotencyKey, + to: input.to, + template: input.template.template, + subject: rendered.subject, + providerMessageId: result.data?.id ?? null, + }, + }); + } catch (err) { + // 5. Enqueue retry + await enqueueJob(input, rendered); + console.error( + "[email] sync send failed, queued for retry:", + input.template.template, + input.to, + err + ); + } + } catch (err) { + // Catch-all — jangan biarkan email error ngerusak action utama. + console.error("[email] unexpected error:", err); + } + }, + + /** + * Process pending/failed jobs di queue. Dipanggil dari cron handler. + * Max 5 attempts dengan exponential backoff (5min × 2^attempts). + */ + async processQueue(limit = 50): Promise<{ + picked: number; + succeeded: number; + failed: number; + }> { + const now = new Date(); + const jobs = await prisma.emailJob.findMany({ + where: { + status: { in: ["PENDING", "FAILED"] }, + attempts: { lt: 5 }, + scheduledAt: { lte: now }, + }, + orderBy: { scheduledAt: "asc" }, + take: limit, + }); + + let succeeded = 0; + let failed = 0; + + const resend = getResend(); + if (!resend) { + console.warn("[email] processQueue: RESEND_API_KEY tidak di-set, skip"); + return { picked: jobs.length, succeeded: 0, failed: 0 }; + } + + for (const job of jobs) { + // Re-check idempotency — bisa jadi email sudah terkirim oleh sync attempt sejak job di-enqueue. + const alreadySent = await prisma.emailSent.findUnique({ + where: { idempotencyKey: job.idempotencyKey }, + select: { id: true }, + }); + if (alreadySent) { + await prisma.emailJob.update({ + where: { id: job.id }, + data: { status: "SUCCESS", lastAttemptAt: now }, + }); + succeeded++; + continue; + } + + // Mark PROCESSING (best-effort lock) + await prisma.emailJob.update({ + where: { id: job.id }, + data: { status: "PROCESSING", attempts: job.attempts + 1, lastAttemptAt: now }, + }); + + 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, + providerMessageId: result.data?.id ?? null, + }, + }), + prisma.emailJob.update({ + where: { id: job.id }, + data: { status: "SUCCESS" }, + }), + ]); + succeeded++; + } catch (err) { + const nextAttempt = job.attempts + 1; + const backoffMin = Math.min(60, 5 * Math.pow(2, nextAttempt - 1)); + await prisma.emailJob.update({ + where: { id: job.id }, + data: { + status: "FAILED", + lastError: err instanceof Error ? err.message : String(err), + scheduledAt: new Date(now.getTime() + backoffMin * 60 * 1000), + }, + }); + failed++; + } + } + + return { picked: jobs.length, succeeded, failed }; + }, +}; + +async function enqueueJob( + input: SendInput, + rendered: { subject: string; html: string } +): Promise { + await prisma.emailJob.create({ + data: { + idempotencyKey: input.idempotencyKey, + to: input.to, + template: input.template.template, + subject: rendered.subject, + html: rendered.html, + status: "PENDING", + }, + }); +} diff --git a/lib/email/templates.ts b/lib/email/templates.ts new file mode 100644 index 0000000..1ce772b --- /dev/null +++ b/lib/email/templates.ts @@ -0,0 +1,353 @@ +import { siteUrl } from "@/lib/site"; +import { formatRupiah } from "@/lib/utils"; + +/** + * Registry semua template email. Setiap entry punya: + * - param shape (kontrak data yang harus dipassing caller) + * - render function → `{ subject, html }` + * + * Style: plain HTML inline-style untuk kompatibilitas client email + * (Outlook, Gmail mobile, dst). Hindari CSS modern (flexbox, grid, var). + */ + +const PRIMARY = "#0d9488"; // primary-600 +const NEUTRAL_700 = "#404040"; +const NEUTRAL_500 = "#737373"; +const RED_600 = "#dc2626"; +const AMBER_600 = "#d97706"; +const EMERALD_600 = "#059669"; + +function shell(opts: { title: string; bodyHtml: string; ctaLabel?: string; ctaUrl?: string }): string { + const cta = opts.ctaLabel && opts.ctaUrl + ? `

+ ${opts.ctaLabel} +

` + : ""; + return ` +${opts.title} + + + +
+ + +
+

SETRIP

+ ${opts.bodyHtml} + ${cta} +
+

+ Email ini dikirim otomatis dari SeTrip. Kalau ada pertanyaan, balas email ini atau hubungi support@setrip.id. +

+
+
+`; +} + +// ============================================================================ +// KYC verification +// ============================================================================ + +export interface KycApprovedData { + userName: string; +} +function kycApproved(d: KycApprovedData) { + return { + subject: "✅ Verifikasi organizer SeTrip kamu sudah disetujui", + html: shell({ + title: "KYC Approved", + bodyHtml: ` +

🎉 Selamat ${d.userName}!

+

Pengajuan verifikasi organizer kamu sudah disetujui. Mulai sekarang kamu bisa:

+
    +
  • Membuat trip berbayar
  • +
  • Menerima payout otomatis ke rekening setelah trip selesai
  • +
  • Mendapat badge ✅ Verified Organizer di profil publik
  • +
+ `, + ctaLabel: "Buat Trip Sekarang", + ctaUrl: `${siteUrl}/create-trip`, + }), + }; +} + +export interface KycRejectedData { + userName: string; + rejectionReason: string; +} +function kycRejected(d: KycRejectedData) { + return { + subject: "❌ Pengajuan verifikasi SeTrip kamu ditolak", + html: shell({ + title: "KYC Rejected", + bodyHtml: ` +

Pengajuan ditolak

+

Halo ${d.userName}, pengajuan verifikasi organizer kamu belum bisa kami setujui.

+
+

Alasan:

+

${escapeHtml(d.rejectionReason)}

+
+

Perbaiki data sesuai catatan di atas lalu ajukan ulang lewat halaman verifikasi.

+ `, + ctaLabel: "Perbaiki & Ajukan Ulang", + ctaUrl: `${siteUrl}/verify`, + }), + }; +} + +export interface KycReuploadRequestData { + userName: string; + fields: string[]; + note: string; +} +function kycReuploadRequest(d: KycReuploadRequestData) { + const fieldList = d.fields + .map((f) => `
  • ${escapeHtml(reuploadFieldLabel(f))}
  • `) + .join(""); + return { + subject: "🔄 Admin minta kamu upload ulang data verifikasi", + html: shell({ + title: "KYC Re-upload Request", + bodyHtml: ` +

    Perlu upload ulang

    +

    Halo ${d.userName}, admin meminta kamu mengirim ulang beberapa data verifikasi.

    +
    +

    Field yang perlu di-upload ulang:

    +
      ${fieldList}
    +

    Catatan admin:

    +

    ${escapeHtml(d.note)}

    +
    +

    Buka halaman verifikasi, perbaiki field yang diminta, lalu submit ulang. Banner akan otomatis hilang setelah submit.

    + `, + ctaLabel: "Submit Ulang", + ctaUrl: `${siteUrl}/verify`, + }), + }; +} + +function reuploadFieldLabel(field: string): string { + switch (field) { + case "ktpImage": + return "Foto KTP"; + case "liveness": + return "Foto liveness (pegang kertas SETRIP)"; + case "nik": + return "NIK"; + case "bankInfo": + return "Info rekening"; + case "address": + return "Alamat"; + default: + return field; + } +} + +// ============================================================================ +// Refund +// ============================================================================ + +export interface RefundCreatedData { + userName: string; + tripTitle: string; + amount: number; + reason: string; +} +function refundCreated(d: RefundCreatedData) { + return { + subject: `Pengajuan refund untuk "${d.tripTitle}" sudah kami terima`, + html: shell({ + title: "Refund Created", + bodyHtml: ` +

    Laporan refund diterima

    +

    Halo ${d.userName}, kami sudah menerima laporan refund kamu.

    + + + + +
    Trip${escapeHtml(d.tripTitle)}
    Nominal refund${formatRupiah(d.amount)}
    Reason${escapeHtml(d.reason)}
    +

    Admin akan review dalam 1–3 hari kerja. Kami kabari lagi via email.

    + `, + }), + }; +} + +export interface RefundSucceededData { + userName: string; + tripTitle: string; + amount: number; + adminNote: string; +} +function refundSucceeded(d: RefundSucceededData) { + return { + subject: `✅ Refund ${formatRupiah(d.amount)} sudah dikirim`, + html: shell({ + title: "Refund Succeeded", + bodyHtml: ` +

    Refund sudah dikirim

    +

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

    +

    ${formatRupiah(d.amount)}

    + ${d.adminNote ? `

    Catatan admin:

    ${escapeHtml(d.adminNote)}

    ` : ""} +

    Cek rekening kamu — biasanya masuk dalam 1×24 jam (tergantung bank). Kalau lebih dari 3 hari belum muncul, balas email ini.

    + `, + }), + }; +} + +export interface RefundFailedData { + userName: string; + tripTitle: string; + amount: number; + adminNote: string; +} +function refundFailed(d: RefundFailedData) { + return { + subject: `⚠️ Refund untuk "${d.tripTitle}" gagal diproses`, + html: shell({ + title: "Refund Failed", + bodyHtml: ` +

    Refund gagal diproses

    +

    Halo ${d.userName}, refund kamu untuk trip ${escapeHtml(d.tripTitle)} sebesar ${formatRupiah(d.amount)} gagal diproses.

    +
    +

    Catatan admin:

    +

    ${escapeHtml(d.adminNote)}

    +
    +

    Balas email ini atau hubungi support@setrip.id untuk koordinasi lanjutan. Kami akan bantu proses ulang.

    + `, + }), + }; +} + +// ============================================================================ +// Payment +// ============================================================================ + +export interface PaymentPaidData { + userName: string; + tripTitle: string; + tripId: string; + amount: number; +} +function paymentPaid(d: PaymentPaidData) { + return { + subject: `✅ Pembayaran ${formatRupiah(d.amount)} untuk "${d.tripTitle}" terkonfirmasi`, + html: shell({ + title: "Payment Confirmed", + bodyHtml: ` +

    Pembayaran terkonfirmasi 🎉

    +

    Halo ${d.userName}, pembayaran kamu untuk trip ${escapeHtml(d.tripTitle)} sudah masuk.

    +

    ${formatRupiah(d.amount)}

    +

    Sampai jumpa di trip! Detail meeting point + itinerary lengkap bisa dicek di halaman trip.

    + `, + ctaLabel: "Lihat Detail Trip", + ctaUrl: `${siteUrl}/trips/${d.tripId}`, + }), + }; +} + +export interface BookingApprovedData { + userName: string; + tripTitle: string; + tripId: string; + amount: number; +} +function bookingApproved(d: BookingApprovedData) { + const isFree = d.amount <= 0; + return { + subject: isFree + ? `✅ Kamu disetujui ikut "${d.tripTitle}"` + : `✅ Kamu disetujui ikut "${d.tripTitle}" — siap bayar`, + html: shell({ + title: "Booking Approved", + bodyHtml: ` +

    Disetujui organizer!

    +

    Halo ${d.userName}, organizer sudah menyetujui kamu untuk ikut trip ${escapeHtml(d.tripTitle)}.

    + ${ + isFree + ? `

    Trip ini gratis — tidak perlu bayar. Cek detail meeting point + itinerary di halaman trip.

    ` + : `

    Untuk mengamankan slot, selesaikan pembayaran ${formatRupiah(d.amount)} via Midtrans (BCA VA, GoPay, QRIS, dll). Dana ditahan SeTrip sampai trip selesai (escrow).

    ` + } + `, + ctaLabel: isFree ? "Lihat Detail Trip" : "Bayar Sekarang", + ctaUrl: isFree + ? `${siteUrl}/trips/${d.tripId}` + : `${siteUrl}/trips/${d.tripId}/payment`, + }), + }; +} + +// ============================================================================ +// Account moderation +// ============================================================================ + +export interface AccountSuspendedData { + userName: string; + reason: string; +} +function accountSuspended(d: AccountSuspendedData) { + return { + subject: "⛔ Akun SeTrip kamu ditangguhkan", + html: shell({ + title: "Account Suspended", + bodyHtml: ` +

    Akun ditangguhkan

    +

    Halo ${d.userName}, akun SeTrip kamu sedang dalam status ditangguhkan. Selama status ini berlaku, kamu tidak bisa login atau melakukan aksi (join trip, bikin trip, dll).

    +
    +

    Alasan:

    +

    ${escapeHtml(d.reason)}

    +
    +

    Kalau menurut kamu ini kesalahan atau ingin klarifikasi, balas email ini atau hubungi support@setrip.id dengan subject "Banding suspend akun".

    + `, + }), + }; +} + +// ============================================================================ +// Registry — discriminated union supaya type-safe per template. +// ============================================================================ + +export type EmailTemplate = + | { template: "kyc_approved"; data: KycApprovedData } + | { template: "kyc_rejected"; data: KycRejectedData } + | { template: "kyc_reupload_request"; data: KycReuploadRequestData } + | { template: "refund_created"; data: RefundCreatedData } + | { template: "refund_succeeded"; data: RefundSucceededData } + | { template: "refund_failed"; data: RefundFailedData } + | { template: "payment_paid"; data: PaymentPaidData } + | { template: "booking_approved"; data: BookingApprovedData } + | { template: "account_suspended"; data: AccountSuspendedData }; + +export function renderEmail(input: EmailTemplate): { + subject: string; + html: string; +} { + switch (input.template) { + case "kyc_approved": + return kycApproved(input.data); + case "kyc_rejected": + return kycRejected(input.data); + case "kyc_reupload_request": + return kycReuploadRequest(input.data); + case "refund_created": + return refundCreated(input.data); + case "refund_succeeded": + return refundSucceeded(input.data); + case "refund_failed": + return refundFailed(input.data); + case "payment_paid": + return paymentPaid(input.data); + case "booking_approved": + return bookingApproved(input.data); + case "account_suspended": + return accountSuspended(input.data); + } +} + +// Escape HTML supaya tidak ada XSS via data user (mis. tripTitle dari user input). +function escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/package-lock.json b/package-lock.json index 0df84a7..4f81732 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "react": "19.2.4", "react-datepicker": "^9.1.0", "react-dom": "19.2.4", + "resend": "^6.12.3", "zod": "^4.3.6" }, "devDependencies": { @@ -2247,6 +2248,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -4869,6 +4876,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", @@ -7144,6 +7157,12 @@ "node": ">= 0.4" } }, + "node_modules/postal-mime": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", + "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", @@ -7515,6 +7534,27 @@ "node": ">=0.10.0" } }, + "node_modules/resend": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.12.3.tgz", + "integrity": "sha512-FkEi6YPnVL96/LvH8+QP7NaeaBy5brYXwlRqUCqZZeNL0/iyKij18IPmyPXYauT/2ODn1JG04qKz+qlJfzqzTw==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.4", + "svix": "1.92.2" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve": { "version": "2.0.0-next.6", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", @@ -7935,6 +7975,16 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -8140,6 +8190,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svix": { + "version": "1.92.2", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.92.2.tgz", + "integrity": "sha512-ZmuA3UVvlnF9EgxlzmPtF7CKjQb64Z6OFlyfdDfU0sdcC7dJa+3aOYX5B9mA+RS6ch1AxBa4UP/l6KmqfGtWBQ==", + "license": "MIT", + "dependencies": { + "standardwebhooks": "1.0.0" + } + }, "node_modules/tabbable": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", diff --git a/package.json b/package.json index 023f5b9..ef820be 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "react": "19.2.4", "react-datepicker": "^9.1.0", "react-dom": "19.2.4", + "resend": "^6.12.3", "zod": "^4.3.6" }, "devDependencies": { diff --git a/prisma/migrations/20260518200000_add_email_notifications/migration.sql b/prisma/migrations/20260518200000_add_email_notifications/migration.sql new file mode 100644 index 0000000..89906e7 --- /dev/null +++ b/prisma/migrations/20260518200000_add_email_notifications/migration.sql @@ -0,0 +1,44 @@ +-- CreateEnum +CREATE TYPE "EmailJobStatus" AS ENUM ('PENDING', 'PROCESSING', 'SUCCESS', 'FAILED'); + +-- CreateTable: log append-only setiap email yang berhasil terkirim. +-- `idempotencyKey` UNIQUE cegah double-send saat webhook retry / cron rerun. +CREATE TABLE "EmailSent" ( + "id" TEXT NOT NULL, + "idempotencyKey" TEXT NOT NULL, + "to" TEXT NOT NULL, + "template" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "providerMessageId" TEXT, + "sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "EmailSent_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "EmailSent_idempotencyKey_key" ON "EmailSent"("idempotencyKey"); +CREATE INDEX "EmailSent_to_sentAt_idx" ON "EmailSent"("to", "sentAt" DESC); +CREATE INDEX "EmailSent_template_sentAt_idx" ON "EmailSent"("template", "sentAt" DESC); + +-- CreateTable: retry queue untuk email yang gagal saat sync send. +-- Cron `/api/cron/process-email-jobs` pick PENDING/FAILED (attempts<5), +-- exponential backoff (scheduledAt bumped tiap retry). +CREATE TABLE "EmailJob" ( + "id" TEXT NOT NULL, + "idempotencyKey" TEXT NOT NULL, + "to" TEXT NOT NULL, + "template" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "html" TEXT NOT NULL, + "status" "EmailJobStatus" NOT NULL DEFAULT 'PENDING', + "attempts" INTEGER NOT NULL DEFAULT 0, + "scheduledAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastAttemptAt" TIMESTAMP(3), + "lastError" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "EmailJob_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "EmailJob_status_scheduledAt_idx" ON "EmailJob"("status", "scheduledAt"); +CREATE INDEX "EmailJob_idempotencyKey_idx" ON "EmailJob"("idempotencyKey"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f2c8372..9fd99e8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -467,6 +467,51 @@ model Refund { @@index([status, createdAt]) } +/// Log append-only setiap email yang berhasil terkirim. `idempotencyKey` +/// UNIQUE cegah double-send saat webhook retry / cron rerun. +model EmailSent { + id String @id @default(cuid()) + idempotencyKey String @unique + to String + template String + subject String + /// ID dari Resend (atau provider lain) untuk troubleshooting di dashboard mereka. + providerMessageId String? + sentAt DateTime @default(now()) + + @@index([to, sentAt(sort: Desc)]) + @@index([template, sentAt(sort: Desc)]) +} + +/// Retry queue untuk email yang gagal saat sync send. Cron pick PENDING/FAILED +/// (attempts<5) → retry dengan exponential backoff. Idempotent via `idempotencyKey`. +model EmailJob { + id String @id @default(cuid()) + idempotencyKey String + to String + template String + subject String + html String + status EmailJobStatus @default(PENDING) + attempts Int @default(0) + scheduledAt DateTime @default(now()) + lastAttemptAt DateTime? + lastError String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status, scheduledAt]) + @@index([idempotencyKey]) +} + +enum EmailJobStatus { + PENDING + PROCESSING + SUCCESS + FAILED +} + /// Log polymorphic untuk admin actions lintas entity. Append-only — kalau /// admin dihapus, `adminId` di-set NULL tapi `adminEmail` snapshot tetap. /// Dipakai untuk compliance & investigasi (siapa approve/reject/cancel/ diff --git a/server/services/payment.service.ts b/server/services/payment.service.ts index 09bd524..39b7e5d 100644 --- a/server/services/payment.service.ts +++ b/server/services/payment.service.ts @@ -11,6 +11,7 @@ import { } from "@/lib/midtrans"; import { isTripDepartureDayPast } from "@/lib/trip-dates"; import { payoutService } from "@/server/services/payout.service"; +import { emailService } from "@/lib/email/send"; const SERIAL_TX_ATTEMPTS = 6; @@ -177,6 +178,12 @@ async function applyGatewayStatus( }); const isConflict = newStatus === "PAID" && finalBooking?.status !== "PAID"; + + // Notif email user kalau payment benar-benar berhasil di-apply ke booking. + if (newStatus === "PAID" && !isConflict) { + void notifyPaymentPaid(payment.id); + } + return { ok: true, status: isConflict ? "booking_conflict" : "updated", @@ -469,6 +476,34 @@ export const paymentService = { }, }; +async function notifyPaymentPaid(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_paid-${payment.id}`, + template: { + template: "payment_paid", + 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 };