fix email sender all flow

This commit is contained in:
2026-05-20 15:25:32 +07:00
parent 306396ae43
commit cb03967deb
20 changed files with 1450 additions and 62 deletions
+50 -38
View File
@@ -9,6 +9,8 @@ Status implementasi notifikasi email transaksional ke user & organizer. Pakai po
> - **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)
@@ -33,7 +35,7 @@ Alternatif: AWS SES (paling murah at scale), SendGrid, Mailgun. **Decision:** Re
---
## PR-E1 — Foundation: schema + service + cron (MVP wajib)
## PR-E1 — Foundation: schema + service + cron (MVP wajib)
**Tujuan:** infrastructure kirim email yang idempotent + retry-able, tanpa nge-block server action.
@@ -52,12 +54,12 @@ Alternatif: AWS SES (paling murah at scale), SendGrid, Mailgun. **Decision:** Re
| # | 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` |
| 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).
@@ -67,21 +69,23 @@ Alternatif: AWS SES (paling murah at scale), SendGrid, Mailgun. **Decision:** Re
---
## PR-E2 — Phase 1: Wire transactional emails (MVP wajib)
## 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.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>`
@@ -96,23 +100,25 @@ Alternatif: AWS SES (paling murah at scale), SendGrid, Mailgun. **Decision:** Re
---
## PR-E3 — Phase 2: UX enhancement (post-MVP)
## 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` |
| # | 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).
---
@@ -133,16 +139,22 @@ Email engagement — perlu user preference + unsubscribe link.
---
## PR-E5 — Admin UI: email log + queue retry (post-MVP)
## 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 |
| # | 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).
---