10 KiB
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
idempotencyKeyunique constraint diEmailSent.- Non-blocking — server action utama tidak boleh gagal kalau email gateway down. Pattern: try send sync; kalau gagal, enqueue
EmailJobuntuk 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 denganidempotencyKey @unique. Cek di sini sebelum kirim → cegah double-send.EmailJob— retry queue untuk send yang gagal sync. StatusPENDING/PROCESSING/SUCCESS/FAILED, attempt counter, max 5 retry exponential.
- Service
emailService.send({ to, template, data, idempotencyKey }):- Cek
EmailSentby idempotencyKey → kalau exist, return early. - Render template ke
{ subject, html }. - POST ke Resend.
- Sukses → insert
EmailSentrow. - Gagal → insert
EmailJobrow (status PENDING, attempts=1).
- Cek
- Cron
/api/cron/process-email-jobssetiap 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 |
| E1.2 | Model EmailJob { idempotencyKey, to, subject, html, status, attempts, lastError?, scheduledAt } + migration |
✅ | 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 <no-reply@setrip.id>") |
✅ | .env.example |
Tindakan manual ops:
- Buat akun Resend, verify domain
setrip.id(atau pakaionboarding@resend.devdi dev). - Set DNS SPF + DKIM record di provider domain.
- Generate API key, set env
RESEND_API_KEY+EMAIL_FROMdi production. - 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) kirimrefund_created. Untuk auto-refund saat trip dibatalkan, peserta dikabari lewat emailtrip_cancelled_organizer/trip_cancelled_admin(E3.4/E3.5) yang sudah memuat blok nominal refund — satu email konsolidasi, bukan dua.
Format idempotencyKey:
kyc_approved-<verificationId>refund_succeeded-<refundId>payment_paid-<paymentId>booking_approved-<bookingId>account_suspended-<userId>-<suspendedAt>(allow re-suspend kalau diulang)
Tindakan manual ops:
- Test setiap template di staging — render via Resend "Send test" atau preview HTML lokal.
- Pastikan
EMAIL_FROMdomain 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-<ts>, 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:
- Run migration
20260520000000_add_email_sent_html(kolomEmailSent.html) di staging → production. Tanpa ini, resend (E5.3) tidak tersedia untuk email yang dikirim sebelum migration. - Tambahkan
/admin/emailske admin nav — sudah dilakukan dicomponents/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.