166 lines
10 KiB
Markdown
166 lines
10 KiB
Markdown
# 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 <no-reply@setrip.id>"`) | ✅ | `.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-<verificationId>`
|
||
- `refund_succeeded-<refundId>`
|
||
- `payment_paid-<paymentId>`
|
||
- `booking_approved-<bookingId>`
|
||
- `account_suspended-<userId>-<suspendedAt>` (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-<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:**
|
||
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.
|