154 lines
8.7 KiB
Markdown
154 lines
8.7 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.
|
|
|
|
---
|
|
|
|
## 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 <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` | ⏳ |
|
|
|
|
**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 |
|
|
|---|---|---|---|---|
|
|
| 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.
|