# 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. **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) - ❌ 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 — 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). 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` | ✅ | > ℹ️ **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-` - `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 | 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). --- ## 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 | 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). --- ## 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.