8.7 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.
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 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 <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 |
⏳ |
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 |
|---|---|---|---|---|
| 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=<n> 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.