Compare commits
3 Commits
306396ae43
...
5d095151e4
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d095151e4 | |||
| db71159613 | |||
| cb03967deb |
@@ -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?".
|
> - **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.
|
> - **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)
|
## 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.
|
**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 |
|
| # | Item | Status | File |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| E1.1 | Model `EmailSent { idempotencyKey @unique, to, template, sentAt, providerMessageId? }` + migration | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) |
|
| 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.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.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.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.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.6 | Env: `RESEND_API_KEY`, `EMAIL_FROM` (mis. `"SeTrip <no-reply@setrip.id>"`) | ✅ | `.env.example` |
|
||||||
|
|
||||||
**Tindakan manual ops:**
|
**Tindakan manual ops:**
|
||||||
1. Buat akun Resend, verify domain `setrip.id` (atau pakai `onboarding@resend.dev` di dev).
|
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.
|
**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 |
|
| # | Trigger | Penerima | Template | Wire point | Status |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| E2.1 | KYC `VERIFICATION_APPROVE` | User | `kyc_approved` | `reviewVerificationAction` | ⏳ |
|
| E2.1 | KYC `VERIFICATION_APPROVE` | User | `kyc_approved` | `reviewVerificationAction` | ✅ |
|
||||||
| E2.2 | KYC `VERIFICATION_REJECT` | User | `kyc_rejected` (sertakan reason) | `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.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.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.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.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.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.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.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:**
|
**Format idempotencyKey:**
|
||||||
- `kyc_approved-<verificationId>`
|
- `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.
|
Email yang berguna tapi tidak critical kalau miss.
|
||||||
|
|
||||||
| # | Trigger | Penerima | Template | Wire point |
|
| # | Trigger | Penerima | Template | Wire point | Status |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| E3.1 | User join trip (PENDING) | Organizer | `join_request` (siapa, link approve) | `joinTripAction` |
|
| E3.1 | User join trip (PENDING) | Organizer | `join_request` (siapa, link approve) | `joinTripAction` | ✅ |
|
||||||
| E3.2 | Organizer reject join | User | `join_rejected` (singkat) | `rejectParticipantAction` |
|
| E3.2 | Organizer reject join | User | `join_rejected` (singkat) | `rejectParticipantAction` | ✅ |
|
||||||
| E3.3 | Payment EXPIRED / FAILED | User | `payment_expired` (suruh start ulang) | `paymentService.applyGatewayStatus` |
|
| 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.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.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.6 | Payout RELEASED | Organizer | `payout_released` (ETA transfer dari admin) | `payoutService.releaseEligible` | ✅ |
|
||||||
| E3.7 | Payout PAID | Organizer | `payout_paid` (cek rekening + adminNote) | `markPayoutPaidAction` |
|
| E3.7 | Payout PAID | Organizer | `payout_paid` (cek rekening + adminNote) | `markPayoutPaidAction` | ✅ |
|
||||||
| E3.8 | Account unsuspended | User | `account_unsuspended` | `unsuspendUserAction` |
|
| E3.8 | Account unsuspended | User | `account_unsuspended` | `unsuspendUserAction` | ✅ |
|
||||||
| E3.9 | KYC submitted | User | `kyc_submitted` (ETA review) | `submitVerificationAction` |
|
| E3.9 | KYC submitted | User | `kyc_submitted` (ETA review) | `submitVerificationAction` | ✅ |
|
||||||
| E3.10 | KYC manual override | User | `kyc_manual_override` (admin verified) | `manualOverrideVerificationAction` |
|
| E3.10 | KYC manual override | User | `kyc_manual_override` (admin verified) | `manualOverrideVerificationAction` | ✅ |
|
||||||
| E3.11 | KYC reopened (admin) | User | `kyc_reopened` (boleh submit ulang) | `reopenVerificationAction` |
|
| 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?".
|
Visibilitas admin untuk troubleshoot "kenapa user X tidak dapat email?".
|
||||||
|
|
||||||
| # | Item |
|
| # | Item | Status | File |
|
||||||
|---|---|
|
|---|---|---|---|
|
||||||
| E5.1 | Page `/admin/emails` — list `EmailSent` + `EmailJob` dengan filter (recipient, template, status, date) |
|
| E5.1 | Page `/admin/emails` — list `EmailSent` + `EmailJob`, filter recipient + template, status lewat tab | ✅ | `app/admin/emails/page.tsx` |
|
||||||
| E5.2 | Tombol "Retry now" untuk EmailJob FAILED |
|
| 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 (override idempotency, append `?retry=<n>` ke key) |
|
| E5.3 | Tombol "Resend" untuk EmailSent — key turunan `#resend-<ts>`, butuh `EmailSent.html` | ✅ | `features/email/actions.ts` |
|
||||||
| E5.4 | Stats card di `/admin/system`: failed count 24h, queued count |
|
| 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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+22
-20
@@ -4,6 +4,8 @@ Status implementasi sistem refund yang dapat dipercaya dan auditable — dari sc
|
|||||||
|
|
||||||
> **Prinsip:** refund = financial event, bukan flag boolean. Setiap rupiah yang keluar harus auditable, idempotent, dan punya state machine eksplisit. Untuk MVP: tetap simple, tapi anticipate partial refund + async gateway dari hari pertama.
|
> **Prinsip:** refund = financial event, bukan flag boolean. Setiap rupiah yang keluar harus auditable, idempotent, dan punya state machine eksplisit. Untuk MVP: tetap simple, tapi anticipate partial refund + async gateway dari hari pertama.
|
||||||
|
|
||||||
|
**Progress (per 2026-05-20):** PR-R1, R2, R3 ✅ — MVP refund (schema + service, organizer-cancel auto-refund, self-service user cancel) selesai. PR-R4 / R5 / R6 ⏳ post-MVP belum dikerjakan.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Audit state sekarang (baseline)
|
## Audit state sekarang (baseline)
|
||||||
@@ -34,7 +36,7 @@ File baseline: [prisma/schema.prisma](prisma/schema.prisma), [server/services/bo
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## PR-R1 — Refund Schema + Service Stub (foundation) ⏳
|
## PR-R1 — Refund Schema + Service Stub (foundation) ✅
|
||||||
|
|
||||||
Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDING` (belum panggil gateway). UI admin minimal: list + manual mark succeeded. Tanpa ini, semua PR berikutnya tidak bisa jalan.
|
Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDING` (belum panggil gateway). UI admin minimal: list + manual mark succeeded. Tanpa ini, semua PR berikutnya tidak bisa jalan.
|
||||||
|
|
||||||
@@ -48,14 +50,14 @@ Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDIN
|
|||||||
|
|
||||||
| # | Item | Status | File |
|
| # | Item | Status | File |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| R1.1 | Model `Refund` + enum `RefundReason`, `RefundStatus`, `RefundInitiator` | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) |
|
| R1.1 | Model `Refund` + enum `RefundReason`, `RefundStatus`, `RefundInitiator` | ✅ | [prisma/schema.prisma](prisma/schema.prisma) |
|
||||||
| R1.2 | Tambah `BookingStatus.PARTIALLY_REFUNDED` | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) |
|
| R1.2 | Tambah `BookingStatus.PARTIALLY_REFUNDED` | ✅ | [prisma/schema.prisma](prisma/schema.prisma) |
|
||||||
| R1.3 | Migration `add_refund_model` | ⏳ | `prisma/migrations/` |
|
| R1.3 | Migration `add_refund_model` | ✅ | `prisma/migrations/` |
|
||||||
| R1.4 | `refundRepo` (create, findById, findByBooking, listPending, updateStatus) | ⏳ | `server/repositories/refund.repo.ts` |
|
| R1.4 | `refundRepo` (create, findById, findByBooking, listPending, updateStatus) | ✅ | `server/repositories/refund.repo.ts` |
|
||||||
| R1.5 | `refundService.requestRefund(input)` — create Refund PENDING + validasi `amount <= payment.amount - totalRefunded` | ⏳ | `server/services/refund.service.ts` |
|
| R1.5 | `refundService.requestRefund(input)` — create Refund PENDING + validasi `amount <= payment.amount - totalRefunded` | ✅ | `server/services/refund.service.ts` |
|
||||||
| R1.6 | `refundService.approveRefund(refundId, adminId)` — PENDING → APPROVED | ⏳ | `server/services/refund.service.ts` |
|
| R1.6 | `refundService.approveRefund(refundId, adminId)` — PENDING → APPROVED | ✅ | `server/services/refund.service.ts` |
|
||||||
| R1.7 | `refundService.markSucceededManual(refundId, adminId)` — APPROVED → SUCCEEDED, update Booking/Payment status. Untuk MVP refund manual transfer (non-Midtrans channel). | ⏳ | `server/services/refund.service.ts` |
|
| R1.7 | `refundService.markSucceededManual(refundId, adminId)` — APPROVED → SUCCEEDED, update Booking/Payment status. Untuk MVP refund manual transfer (non-Midtrans channel). | ✅ | `server/services/refund.service.ts` |
|
||||||
| R1.8 | UI admin `/admin/refunds` — list PENDING + tombol approve & mark-succeeded | ⏳ | `app/admin/refunds/page.tsx` |
|
| R1.8 | UI admin `/admin/refunds` — list PENDING + tombol approve & mark-succeeded | ✅ | `app/admin/refunds/page.tsx` |
|
||||||
|
|
||||||
**Tindakan manual:**
|
**Tindakan manual:**
|
||||||
1. Run migration di staging → smoke test → run di production.
|
1. Run migration di staging → smoke test → run di production.
|
||||||
@@ -63,7 +65,7 @@ Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDIN
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## PR-R2 — Auto-Trigger Refund saat Trip CLOSED (organizer cancel) ⏳
|
## PR-R2 — Auto-Trigger Refund saat Trip CLOSED (organizer cancel) ✅
|
||||||
|
|
||||||
Saat organizer batalkan trip (`status = CLOSED`), semua peserta dengan `Booking.status = PAID` otomatis di-create `Refund` record dengan `reason = ORGANIZER_CANCELLED`, `initiatedBy = SYSTEM`, full amount.
|
Saat organizer batalkan trip (`status = CLOSED`), semua peserta dengan `Booking.status = PAID` otomatis di-create `Refund` record dengan `reason = ORGANIZER_CANCELLED`, `initiatedBy = SYSTEM`, full amount.
|
||||||
|
|
||||||
@@ -75,16 +77,16 @@ Saat organizer batalkan trip (`status = CLOSED`), semua peserta dengan `Booking.
|
|||||||
|
|
||||||
| # | Item | Status | File |
|
| # | Item | Status | File |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| R2.1 | `tripService.closeTrip(tripId, organizerId)` — set status CLOSED + auto-create refunds | ⏳ | [server/services/trip.service.ts](server/services/trip.service.ts) |
|
| R2.1 | `tripService.closeTrip(tripId, organizerId)` — set status CLOSED + auto-create refunds | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) |
|
||||||
| R2.2 | Auto-approve untuk SYSTEM refund dengan reason ORGANIZER_CANCELLED | ⏳ | `server/services/refund.service.ts` |
|
| R2.2 | Auto-approve untuk SYSTEM refund dengan reason ORGANIZER_CANCELLED | ✅ | `server/services/refund.service.ts` |
|
||||||
| R2.3 | UI organizer: tombol "Batalkan trip" di dashboard organizer dengan confirmation modal | ⏳ | `features/trip/components/cancel-trip-button.tsx` |
|
| R2.3 | UI organizer: tombol "Batalkan trip" di dashboard organizer dengan confirmation modal | ✅ | `features/trip/components/cancel-trip-button.tsx` |
|
||||||
| R2.4 | Server action `cancelTripAction` | ⏳ | `features/trip/actions.ts` |
|
| R2.4 | Server action `cancelTripAction` | ✅ | `features/trip/actions.ts` |
|
||||||
|
|
||||||
**Tindakan manual:** tidak ada.
|
**Tindakan manual:** tidak ada.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## PR-R3 — Self-Service User Cancel dengan Refund Window ⏳
|
## PR-R3 — Self-Service User Cancel dengan Refund Window ✅
|
||||||
|
|
||||||
User bisa cancel booking sendiri, refund dihitung berdasarkan kebijakan default (hardcoded dulu, jangan polymorphic).
|
User bisa cancel booking sendiri, refund dihitung berdasarkan kebijakan default (hardcoded dulu, jangan polymorphic).
|
||||||
|
|
||||||
@@ -100,11 +102,11 @@ User bisa cancel booking sendiri, refund dihitung berdasarkan kebijakan default
|
|||||||
|
|
||||||
| # | Item | Status | File |
|
| # | Item | Status | File |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| R3.1 | `lib/refund-policy.ts` — `calculateRefundAmount(bookingAmount, daysUntilDeparture)` | ⏳ | `lib/refund-policy.ts` |
|
| R3.1 | `lib/refund-policy.ts` — `calculateRefundAmount(bookingAmount, daysUntilDeparture)` | ✅ | `lib/refund-policy.ts` |
|
||||||
| R3.2 | `refundService.requestUserCancellation(bookingId, userId)` — pakai policy + create Refund PENDING | ⏳ | `server/services/refund.service.ts` |
|
| R3.2 | `refundService.requestUserCancellation(bookingId, userId)` — pakai policy + create Refund PENDING | ✅ | `server/services/refund.service.ts` |
|
||||||
| R3.3 | UI user di trip detail: tombol "Cancel booking" + modal preview refund amount | ⏳ | `features/booking/components/cancel-booking-button.tsx` |
|
| R3.3 | UI user di trip detail: tombol "Cancel booking" + modal preview refund amount | ✅ | `features/booking/components/cancel-booking-button.tsx` |
|
||||||
| R3.4 | Server action `cancelBookingAction` | ⏳ | `features/booking/actions.ts` |
|
| R3.4 | Server action `cancelBookingAction` | ✅ | `features/booking/actions.ts` |
|
||||||
| R3.5 | Display refund policy di trip detail (di blok info atau accordion) — transparency | ⏳ | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) |
|
| R3.5 | Display refund policy di trip detail (di blok info atau accordion) — transparency | ✅ | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) |
|
||||||
|
|
||||||
**Tindakan manual:**
|
**Tindakan manual:**
|
||||||
1. Tulis copy kebijakan refund untuk halaman Terms & Privacy.
|
1. Tulis copy kebijakan refund untuk halaman Terms & Privacy.
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Admin · Email Log",
|
||||||
|
description:
|
||||||
|
"Halaman admin untuk memantau pengiriman email — antrian, kegagalan, dan retry.",
|
||||||
|
alternates: { canonical: "/admin/emails" },
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminEmailsLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { isAdminEmail } from "@/lib/admin";
|
||||||
|
import { emailRepo } from "@/server/repositories/email.repo";
|
||||||
|
import {
|
||||||
|
RetryEmailButton,
|
||||||
|
ResendEmailButton,
|
||||||
|
} from "@/features/email/components/email-row-actions";
|
||||||
|
|
||||||
|
type Tab = "failed" | "queue" | "sent";
|
||||||
|
|
||||||
|
const TABS: { key: Tab; label: string }[] = [
|
||||||
|
{ key: "failed", label: "Gagal" },
|
||||||
|
{ key: "queue", label: "Antrian" },
|
||||||
|
{ key: "sent", label: "Terkirim" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
searchParams: Promise<{ tab?: string; to?: string; template?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminEmailsPage({ searchParams }: PageProps) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) redirect("/login?callbackUrl=/admin/emails");
|
||||||
|
if (!isAdminEmail(session.user.email)) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
|
||||||
|
<p className="text-sm text-neutral-600">
|
||||||
|
Halaman ini hanya untuk admin SeTrip.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = await searchParams;
|
||||||
|
const tab: Tab = TABS.some((t) => t.key === params.tab)
|
||||||
|
? (params.tab as Tab)
|
||||||
|
: "failed";
|
||||||
|
const filters = {
|
||||||
|
to: params.to?.trim() || undefined,
|
||||||
|
template: params.template?.trim() || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = await emailRepo.stats();
|
||||||
|
const jobs =
|
||||||
|
tab === "sent"
|
||||||
|
? []
|
||||||
|
: await emailRepo.listJobs(
|
||||||
|
tab === "failed" ? ["FAILED"] : ["PENDING", "PROCESSING"],
|
||||||
|
filters
|
||||||
|
);
|
||||||
|
const sent = tab === "sent" ? await emailRepo.listSent(filters) : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
|
||||||
|
<header className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||||
|
Email Log
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-neutral-500">
|
||||||
|
Pantau pengiriman email transaksional. Email yang gagal dikirim bisa
|
||||||
|
di-retry manual; email terkirim bisa di-resend kalau peserta lapor
|
||||||
|
tidak menerima.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Kartu ringkasan */}
|
||||||
|
<div className="mb-6 grid gap-3 sm:grid-cols-3">
|
||||||
|
<StatCard
|
||||||
|
label="Antri dikirim"
|
||||||
|
value={stats.queued}
|
||||||
|
tone={stats.queued > 0 ? "amber" : "ok"}
|
||||||
|
hint="Job menunggu cron / retry"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Gagal 24 jam"
|
||||||
|
value={stats.failed24h}
|
||||||
|
tone={stats.failed24h > 0 ? "red" : "ok"}
|
||||||
|
hint="Job gagal dalam sehari terakhir"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Perlu aksi manual"
|
||||||
|
value={stats.deadLetter}
|
||||||
|
tone={stats.deadLetter > 0 ? "red" : "ok"}
|
||||||
|
hint="Gagal & habis 5 attempt — cron berhenti retry"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="mb-4 flex flex-wrap gap-2">
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<a
|
||||||
|
key={t.key}
|
||||||
|
href={`/admin/emails?tab=${t.key}`}
|
||||||
|
className={`rounded-full px-4 py-1.5 text-sm font-semibold transition-colors ${
|
||||||
|
tab === t.key
|
||||||
|
? "bg-primary-600 text-white"
|
||||||
|
: "bg-neutral-100 text-neutral-600 hover:bg-neutral-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter */}
|
||||||
|
<form
|
||||||
|
method="get"
|
||||||
|
action="/admin/emails"
|
||||||
|
className="mb-4 flex flex-wrap items-end gap-2 rounded-2xl border border-neutral-200 bg-white p-3 shadow-sm"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="tab" value={tab} />
|
||||||
|
<div className="min-w-[180px] flex-1">
|
||||||
|
<label
|
||||||
|
htmlFor="filter-to"
|
||||||
|
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
|
||||||
|
>
|
||||||
|
Penerima (email)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="filter-to"
|
||||||
|
name="to"
|
||||||
|
defaultValue={params.to ?? ""}
|
||||||
|
placeholder="user@email.com"
|
||||||
|
className="w-full rounded-lg border border-neutral-200 bg-neutral-50 px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400 focus:bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[160px] flex-1">
|
||||||
|
<label
|
||||||
|
htmlFor="filter-template"
|
||||||
|
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
|
||||||
|
>
|
||||||
|
Template
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="filter-template"
|
||||||
|
name="template"
|
||||||
|
defaultValue={params.template ?? ""}
|
||||||
|
placeholder="mis. refund_succeeded"
|
||||||
|
className="w-full rounded-lg border border-neutral-200 bg-neutral-50 px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400 focus:bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-lg bg-primary-600 px-4 py-1.5 text-sm font-semibold text-white hover:bg-primary-700"
|
||||||
|
>
|
||||||
|
Cari
|
||||||
|
</button>
|
||||||
|
{(filters.to || filters.template) && (
|
||||||
|
<a
|
||||||
|
href={`/admin/emails?tab=${tab}`}
|
||||||
|
className="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-500 hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{tab === "sent" ? (
|
||||||
|
<SentTable rows={sent} />
|
||||||
|
) : (
|
||||||
|
<JobTable rows={jobs} tab={tab} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tone,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
tone: "ok" | "amber" | "red";
|
||||||
|
hint: string;
|
||||||
|
}) {
|
||||||
|
const cls =
|
||||||
|
tone === "red"
|
||||||
|
? "border-red-200 bg-red-50/60"
|
||||||
|
: tone === "amber"
|
||||||
|
? "border-amber-200 bg-amber-50/60"
|
||||||
|
: "border-emerald-200 bg-emerald-50/50";
|
||||||
|
const valueCls =
|
||||||
|
tone === "red"
|
||||||
|
? "text-red-700"
|
||||||
|
: tone === "amber"
|
||||||
|
? "text-amber-700"
|
||||||
|
: "text-emerald-700";
|
||||||
|
return (
|
||||||
|
<div className={`rounded-2xl border p-4 shadow-sm ${cls}`}>
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p className={`mt-1 text-2xl font-bold ${valueCls}`}>{value}</p>
|
||||||
|
<p className="mt-0.5 text-[11px] text-neutral-500">{hint}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function JobTable({
|
||||||
|
rows,
|
||||||
|
tab,
|
||||||
|
}: {
|
||||||
|
rows: Awaited<ReturnType<typeof emailRepo.listJobs>>;
|
||||||
|
tab: "failed" | "queue";
|
||||||
|
}) {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
message={
|
||||||
|
tab === "failed"
|
||||||
|
? "Tidak ada email gagal — semua pengiriman lancar. 🎉"
|
||||||
|
: "Tidak ada email yang sedang antri."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto rounded-2xl border border-neutral-200 bg-white shadow-sm">
|
||||||
|
<table className="min-w-full divide-y divide-neutral-100 text-sm">
|
||||||
|
<thead className="bg-neutral-50 text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left">Penerima</th>
|
||||||
|
<th className="px-3 py-2 text-left">Template</th>
|
||||||
|
<th className="px-3 py-2 text-left">Status</th>
|
||||||
|
<th className="px-3 py-2 text-left">Attempt</th>
|
||||||
|
<th className="px-3 py-2 text-left">
|
||||||
|
{tab === "failed" ? "Error terakhir" : "Dijadwalkan"}
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-left">Aksi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-neutral-100 text-xs text-neutral-700">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.id}>
|
||||||
|
<td className="px-3 py-2">{r.to}</td>
|
||||||
|
<td className="px-3 py-2 font-mono">{r.template}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<EmailBadge value={r.status} />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{r.attempts}
|
||||||
|
{r.attempts >= 5 && (
|
||||||
|
<span className="ml-1 text-[10px] font-semibold text-red-600">
|
||||||
|
(mati)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-neutral-500">
|
||||||
|
{tab === "failed"
|
||||||
|
? r.lastError
|
||||||
|
? truncate(r.lastError, 90)
|
||||||
|
: "—"
|
||||||
|
: formatDateTime(r.scheduledAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<RetryEmailButton jobId={r.id} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SentTable({
|
||||||
|
rows,
|
||||||
|
}: {
|
||||||
|
rows: Awaited<ReturnType<typeof emailRepo.listSent>>;
|
||||||
|
}) {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return <EmptyState message="Belum ada email terkirim yang cocok." />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto rounded-2xl border border-neutral-200 bg-white shadow-sm">
|
||||||
|
<table className="min-w-full divide-y divide-neutral-100 text-sm">
|
||||||
|
<thead className="bg-neutral-50 text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left">Penerima</th>
|
||||||
|
<th className="px-3 py-2 text-left">Template</th>
|
||||||
|
<th className="px-3 py-2 text-left">Subject</th>
|
||||||
|
<th className="px-3 py-2 text-left">Terkirim</th>
|
||||||
|
<th className="px-3 py-2 text-left">Aksi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-neutral-100 text-xs text-neutral-700">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.id}>
|
||||||
|
<td className="px-3 py-2">{r.to}</td>
|
||||||
|
<td className="px-3 py-2 font-mono">{r.template}</td>
|
||||||
|
<td className="px-3 py-2">{truncate(r.subject, 60)}</td>
|
||||||
|
<td className="px-3 py-2 text-neutral-500">
|
||||||
|
{formatDateTime(r.sentAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<ResendEmailButton emailSentId={r.id} disabled={!r.html} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center">
|
||||||
|
<p className="text-sm text-neutral-500">{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmailBadge({ value }: { value: string }) {
|
||||||
|
const cls =
|
||||||
|
value === "SUCCESS"
|
||||||
|
? "bg-emerald-100 text-emerald-800"
|
||||||
|
: value === "FAILED"
|
||||||
|
? "bg-red-100 text-red-800"
|
||||||
|
: "bg-amber-100 text-amber-800";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(d: Date): string {
|
||||||
|
return d.toLocaleString("id-ID", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(s: string, max: number): string {
|
||||||
|
return s.length > max ? `${s.slice(0, max)}…` : s;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { authOptions } from "@/lib/auth";
|
|||||||
import { isAdminEmail } from "@/lib/admin";
|
import { isAdminEmail } from "@/lib/admin";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { systemHealthService } from "@/server/services/system-health.service";
|
import { systemHealthService } from "@/server/services/system-health.service";
|
||||||
|
import { emailRepo } from "@/server/repositories/email.repo";
|
||||||
|
|
||||||
interface JobSummary {
|
interface JobSummary {
|
||||||
jobName: string;
|
jobName: string;
|
||||||
@@ -80,20 +81,22 @@ export default async function AdminSystemPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [summaries, recentRuns, stale] = await Promise.all([
|
const [summaries, recentRuns, stale, emailStats] = await Promise.all([
|
||||||
Promise.all(TRACKED_JOBS.map(getJobSummary)),
|
Promise.all(TRACKED_JOBS.map(getJobSummary)),
|
||||||
prisma.cronRun.findMany({
|
prisma.cronRun.findMany({
|
||||||
orderBy: { startedAt: "desc" },
|
orderBy: { startedAt: "desc" },
|
||||||
take: 20,
|
take: 20,
|
||||||
}),
|
}),
|
||||||
systemHealthService.detectStale(),
|
systemHealthService.detectStale(),
|
||||||
|
emailRepo.stats(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const hasAnyStale =
|
const hasAnyStale =
|
||||||
stale.stalePaymentsCount > 0 ||
|
stale.stalePaymentsCount > 0 ||
|
||||||
stale.awaitingPayPastDepartureCount > 0 ||
|
stale.awaitingPayPastDepartureCount > 0 ||
|
||||||
stale.overduePayoutsCount > 0 ||
|
stale.overduePayoutsCount > 0 ||
|
||||||
stale.stuckRefundsCount > 0;
|
stale.stuckRefundsCount > 0 ||
|
||||||
|
emailStats.deadLetter > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
|
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
|
||||||
@@ -152,6 +155,19 @@ export default async function AdminSystemPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
|
{emailStats.deadLetter > 0 && (
|
||||||
|
<li>
|
||||||
|
• <strong>{emailStats.deadLetter}</strong> email gagal kirim &
|
||||||
|
sudah habis 5 attempt — cron berhenti retry, perlu retry
|
||||||
|
manual.{" "}
|
||||||
|
<Link
|
||||||
|
href="/admin/emails?tab=failed"
|
||||||
|
className="font-semibold text-amber-700 hover:underline"
|
||||||
|
>
|
||||||
|
Lihat email gagal →
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
@@ -236,6 +252,37 @@ export default async function AdminSystemPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
|
||||||
|
Email
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<EmailStat
|
||||||
|
label="Antri dikirim"
|
||||||
|
value={emailStats.queued}
|
||||||
|
tone={emailStats.queued > 0 ? "amber" : "ok"}
|
||||||
|
/>
|
||||||
|
<EmailStat
|
||||||
|
label="Gagal 24 jam"
|
||||||
|
value={emailStats.failed24h}
|
||||||
|
tone={emailStats.failed24h > 0 ? "red" : "ok"}
|
||||||
|
/>
|
||||||
|
<EmailStat
|
||||||
|
label="Perlu aksi manual"
|
||||||
|
value={emailStats.deadLetter}
|
||||||
|
tone={emailStats.deadLetter > 0 ? "red" : "ok"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-neutral-500">
|
||||||
|
<Link
|
||||||
|
href="/admin/emails"
|
||||||
|
className="font-semibold text-primary-600 hover:underline"
|
||||||
|
>
|
||||||
|
Buka Email Log →
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
|
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
|
||||||
Recent Runs (20 terakhir)
|
Recent Runs (20 terakhir)
|
||||||
@@ -301,6 +348,37 @@ function truncate(s: string, max: number): string {
|
|||||||
return s.length > max ? `${s.slice(0, max)}…` : s;
|
return s.length > max ? `${s.slice(0, max)}…` : s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EmailStat({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
tone: "ok" | "amber" | "red";
|
||||||
|
}) {
|
||||||
|
const cls =
|
||||||
|
tone === "red"
|
||||||
|
? "border-red-200 bg-red-50/60"
|
||||||
|
: tone === "amber"
|
||||||
|
? "border-amber-200 bg-amber-50/60"
|
||||||
|
: "border-emerald-200 bg-emerald-50/50";
|
||||||
|
const valueCls =
|
||||||
|
tone === "red"
|
||||||
|
? "text-red-700"
|
||||||
|
: tone === "amber"
|
||||||
|
? "text-amber-700"
|
||||||
|
: "text-emerald-700";
|
||||||
|
return (
|
||||||
|
<div className={`rounded-2xl border p-4 shadow-sm ${cls}`}>
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p className={`mt-1 text-2xl font-bold ${valueCls}`}>{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function StatusBadge({ value }: { value: string }) {
|
function StatusBadge({ value }: { value: string }) {
|
||||||
const cls =
|
const cls =
|
||||||
value === "SUCCESS"
|
value === "SUCCESS"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const NAV_ITEMS: { href: string; label: string; icon: string }[] = [
|
|||||||
{ href: "/admin/verifications", label: "Verifikasi", icon: "🪪" },
|
{ href: "/admin/verifications", label: "Verifikasi", icon: "🪪" },
|
||||||
{ href: "/admin/refunds", label: "Refund", icon: "↩️" },
|
{ href: "/admin/refunds", label: "Refund", icon: "↩️" },
|
||||||
{ href: "/admin/payouts", label: "Payout", icon: "💸" },
|
{ href: "/admin/payouts", label: "Payout", icon: "💸" },
|
||||||
|
{ href: "/admin/emails", label: "Email", icon: "✉️" },
|
||||||
{ href: "/admin/audit-log", label: "Audit Log", icon: "📜" },
|
{ href: "/admin/audit-log", label: "Audit Log", icon: "📜" },
|
||||||
{ href: "/admin/system", label: "System", icon: "⚙️" },
|
{ href: "/admin/system", label: "System", icon: "⚙️" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -58,6 +58,21 @@ async function notifySuspended(userId: string, reason: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** E3.8 — kabari user kalau penangguhan akunnya sudah dicabut. */
|
||||||
|
async function notifyUnsuspended(userId: string) {
|
||||||
|
const target = await userRepo.findById(userId);
|
||||||
|
if (!target) return;
|
||||||
|
await emailService.send({
|
||||||
|
to: target.email,
|
||||||
|
// Sertakan timestamp supaya tiap siklus suspend→unsuspend dapat email baru.
|
||||||
|
idempotencyKey: `account_unsuspended-${userId}-${Date.now()}`,
|
||||||
|
template: {
|
||||||
|
template: "account_unsuspended",
|
||||||
|
data: { userName: target.name },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function unsuspendUserAction(userId: string) {
|
export async function unsuspendUserAction(userId: string) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
@@ -75,6 +90,10 @@ export async function unsuspendUserAction(userId: string) {
|
|||||||
entityType: "User",
|
entityType: "User",
|
||||||
entityId: userId,
|
entityId: userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Notif email user — kabari akun sudah aktif kembali.
|
||||||
|
void notifyUnsuspended(userId);
|
||||||
|
|
||||||
revalidatePath("/admin/users");
|
revalidatePath("/admin/users");
|
||||||
revalidatePath(`/admin/users/${userId}`);
|
revalidatePath(`/admin/users/${userId}`);
|
||||||
return { success: true as const };
|
return { success: true as const };
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { isAdminEmail } from "@/lib/admin";
|
||||||
|
import { emailService } from "@/lib/email/send";
|
||||||
|
import { auditLog } from "@/server/services/audit-log.service";
|
||||||
|
|
||||||
|
async function requireAdmin() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user || !isAdminEmail(session.user.email)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return session.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** E5.2 — admin retry satu EmailJob yang gagal/antri, kirim ulang langsung. */
|
||||||
|
export async function retryEmailJobAction(jobId: string) {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!admin) return { error: "Tidak memiliki akses admin" };
|
||||||
|
if (!jobId) return { error: "jobId tidak valid" };
|
||||||
|
|
||||||
|
const result = await emailService.retryJob(jobId);
|
||||||
|
if (!result.ok) {
|
||||||
|
return { error: result.error ?? "Gagal mengirim ulang email" };
|
||||||
|
}
|
||||||
|
await auditLog.record({
|
||||||
|
admin: { id: admin.id, email: admin.email },
|
||||||
|
action: "EMAIL_JOB_RETRY",
|
||||||
|
entityType: "EmailJob",
|
||||||
|
entityId: jobId,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/emails");
|
||||||
|
revalidatePath("/admin/system");
|
||||||
|
return { success: true as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** E5.3 — admin resend email yang sudah pernah terkirim (mis. user lapor tidak terima). */
|
||||||
|
export async function resendEmailAction(emailSentId: string) {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!admin) return { error: "Tidak memiliki akses admin" };
|
||||||
|
if (!emailSentId) return { error: "emailSentId tidak valid" };
|
||||||
|
|
||||||
|
const result = await emailService.resendEmail(emailSentId);
|
||||||
|
if (!result.ok) {
|
||||||
|
return { error: result.error ?? "Gagal mengirim ulang email" };
|
||||||
|
}
|
||||||
|
await auditLog.record({
|
||||||
|
admin: { id: admin.id, email: admin.email },
|
||||||
|
action: "EMAIL_RESEND",
|
||||||
|
entityType: "EmailSent",
|
||||||
|
entityId: emailSentId,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/emails");
|
||||||
|
return { success: true as const };
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { retryEmailJobAction, resendEmailAction } from "@/features/email/actions";
|
||||||
|
|
||||||
|
const BTN_CLS =
|
||||||
|
"rounded-lg border border-primary-200 bg-primary-50 px-2.5 py-1 text-[11px] font-semibold text-primary-700 transition-colors hover:bg-primary-100 disabled:cursor-not-allowed disabled:opacity-50";
|
||||||
|
|
||||||
|
/** E5.2 — tombol kirim ulang untuk satu EmailJob (antri / gagal). */
|
||||||
|
export function RetryEmailButton({ jobId }: { jobId: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function handleRetry() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
const res = await retryEmailJobAction(jobId);
|
||||||
|
setLoading(false);
|
||||||
|
if ("error" in res) {
|
||||||
|
setError(res.error ?? "Gagal");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRetry}
|
||||||
|
disabled={loading}
|
||||||
|
className={BTN_CLS}
|
||||||
|
>
|
||||||
|
{loading ? "Mengirim…" : "Kirim ulang"}
|
||||||
|
</button>
|
||||||
|
{error && (
|
||||||
|
<p className="mt-1 max-w-[200px] text-[10px] text-red-600">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** E5.3 — tombol resend untuk email yang sudah terkirim. */
|
||||||
|
export function ResendEmailButton({
|
||||||
|
emailSentId,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
emailSentId: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
async function handleResend() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
const res = await resendEmailAction(emailSentId);
|
||||||
|
setLoading(false);
|
||||||
|
if ("error" in res) {
|
||||||
|
setError(res.error ?? "Gagal");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDone(true);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disabled) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="text-[10px] text-neutral-400"
|
||||||
|
title="Body email lama tidak tersimpan"
|
||||||
|
>
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResend}
|
||||||
|
disabled={loading || done}
|
||||||
|
className={BTN_CLS}
|
||||||
|
>
|
||||||
|
{loading ? "Mengirim…" : done ? "✓ Terkirim" : "Resend"}
|
||||||
|
</button>
|
||||||
|
{error && (
|
||||||
|
<p className="mt-1 max-w-[200px] text-[10px] text-red-600">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { auditLog } from "@/server/services/audit-log.service";
|
|||||||
import { emailService } from "@/lib/email/send";
|
import { emailService } from "@/lib/email/send";
|
||||||
import { organizerRepo } from "@/server/repositories/organizer.repo";
|
import { organizerRepo } from "@/server/repositories/organizer.repo";
|
||||||
import { userRepo } from "@/server/repositories/user.repo";
|
import { userRepo } from "@/server/repositories/user.repo";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
import { submitVerificationSchema, reviewVerificationSchema } from "./schemas";
|
import { submitVerificationSchema, reviewVerificationSchema } from "./schemas";
|
||||||
|
|
||||||
export async function submitVerificationAction(formData: FormData) {
|
export async function submitVerificationAction(formData: FormData) {
|
||||||
@@ -43,6 +44,7 @@ export async function submitVerificationAction(formData: FormData) {
|
|||||||
...result.data,
|
...result.data,
|
||||||
birthDate: new Date(result.data.birthDate),
|
birthDate: new Date(result.data.birthDate),
|
||||||
});
|
});
|
||||||
|
void notifyKycSubmitted(session.user.id);
|
||||||
revalidatePath("/verify");
|
revalidatePath("/verify");
|
||||||
revalidatePath("/profile");
|
revalidatePath("/profile");
|
||||||
revalidatePath("/admin/verifications");
|
revalidatePath("/admin/verifications");
|
||||||
@@ -137,6 +139,54 @@ async function notifyVerificationDecision(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** E3.9 — kabari user kalau pengajuan verifikasi-nya sudah masuk antrian. */
|
||||||
|
async function notifyKycSubmitted(userId: string) {
|
||||||
|
const verification = await prisma.organizerVerification.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
select: { submissionCount: true },
|
||||||
|
});
|
||||||
|
const user = await userRepo.findById(userId);
|
||||||
|
if (!verification || !user) return;
|
||||||
|
await emailService.send({
|
||||||
|
to: user.email,
|
||||||
|
idempotencyKey: `kyc_submitted-${userId}-${verification.submissionCount}`,
|
||||||
|
template: {
|
||||||
|
template: "kyc_submitted",
|
||||||
|
data: { userName: user.name },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** E3.11 — kabari user kalau pengajuan verifikasi-nya dibuka kembali admin. */
|
||||||
|
async function notifyKycReopened(verificationId: string) {
|
||||||
|
const verification = await organizerRepo.findById(verificationId);
|
||||||
|
if (!verification) return;
|
||||||
|
const user = await userRepo.findById(verification.userId);
|
||||||
|
if (!user) return;
|
||||||
|
await emailService.send({
|
||||||
|
to: user.email,
|
||||||
|
idempotencyKey: `kyc_reopened-${verificationId}-${verification.submissionCount}`,
|
||||||
|
template: {
|
||||||
|
template: "kyc_reopened",
|
||||||
|
data: { userName: user.name },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** E3.10 — kabari user kalau admin verifikasi dia secara manual override. */
|
||||||
|
async function notifyKycManualOverride(userId: string, verificationId: string) {
|
||||||
|
const user = await userRepo.findById(userId);
|
||||||
|
if (!user) return;
|
||||||
|
await emailService.send({
|
||||||
|
to: user.email,
|
||||||
|
idempotencyKey: `kyc_manual_override-${verificationId}`,
|
||||||
|
template: {
|
||||||
|
template: "kyc_manual_override",
|
||||||
|
data: { userName: user.name },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin reopen pengajuan REJECTED ke PENDING — supaya organizer bisa
|
* Admin reopen pengajuan REJECTED ke PENDING — supaya organizer bisa
|
||||||
* di-review ulang tanpa drop & recreate row. Note wajib min 10 char untuk audit.
|
* di-review ulang tanpa drop & recreate row. Note wajib min 10 char untuk audit.
|
||||||
@@ -156,6 +206,7 @@ export async function reopenVerificationAction(
|
|||||||
adminId: session.user.id,
|
adminId: session.user.id,
|
||||||
note,
|
note,
|
||||||
});
|
});
|
||||||
|
void notifyKycReopened(verificationId);
|
||||||
await auditLog.record({
|
await auditLog.record({
|
||||||
admin: { id: session.user.id, email: session.user.email },
|
admin: { id: session.user.id, email: session.user.email },
|
||||||
action: "VERIFICATION_REOPEN",
|
action: "VERIFICATION_REOPEN",
|
||||||
@@ -262,6 +313,7 @@ export async function manualOverrideVerificationAction(input: {
|
|||||||
bankAccountNumber: input.bankAccountNumber,
|
bankAccountNumber: input.bankAccountNumber,
|
||||||
bankAccountName: input.bankAccountName,
|
bankAccountName: input.bankAccountName,
|
||||||
});
|
});
|
||||||
|
void notifyKycManualOverride(input.userId, result.id);
|
||||||
await auditLog.record({
|
await auditLog.record({
|
||||||
admin: { id: session.user.id, email: session.user.email },
|
admin: { id: session.user.id, email: session.user.email },
|
||||||
action: "VERIFICATION_MANUAL_OVERRIDE",
|
action: "VERIFICATION_MANUAL_OVERRIDE",
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { isAdminEmail } from "@/lib/admin";
|
import { isAdminEmail } from "@/lib/admin";
|
||||||
import { payoutService } from "@/server/services/payout.service";
|
import { payoutService } from "@/server/services/payout.service";
|
||||||
|
import { payoutRepo } from "@/server/repositories/payout.repo";
|
||||||
import { auditLog } from "@/server/services/audit-log.service";
|
import { auditLog } from "@/server/services/audit-log.service";
|
||||||
|
import { emailService } from "@/lib/email/send";
|
||||||
import { payoutMarkPaidSchema } from "./schemas";
|
import { payoutMarkPaidSchema } from "./schemas";
|
||||||
|
|
||||||
async function requireAdmin() {
|
async function requireAdmin() {
|
||||||
@@ -34,6 +36,7 @@ export async function markPayoutPaidAction(formData: FormData) {
|
|||||||
adminId: admin.id,
|
adminId: admin.id,
|
||||||
adminNote: parsed.data.adminNote,
|
adminNote: parsed.data.adminNote,
|
||||||
});
|
});
|
||||||
|
void notifyPayoutPaid(parsed.data.payoutId, parsed.data.adminNote);
|
||||||
await auditLog.record({
|
await auditLog.record({
|
||||||
admin: { id: admin.id, email: admin.email },
|
admin: { id: admin.id, email: admin.email },
|
||||||
action: "PAYOUT_MARK_PAID",
|
action: "PAYOUT_MARK_PAID",
|
||||||
@@ -49,3 +52,22 @@ export async function markPayoutPaidAction(formData: FormData) {
|
|||||||
return { error: (err as Error).message };
|
return { error: (err as Error).message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** E3.7 — kabari organizer kalau payout-nya sudah ditransfer admin. */
|
||||||
|
async function notifyPayoutPaid(payoutId: string, adminNote: string) {
|
||||||
|
const payout = await payoutRepo.findById(payoutId);
|
||||||
|
if (!payout) return;
|
||||||
|
await emailService.send({
|
||||||
|
to: payout.organizer.email,
|
||||||
|
idempotencyKey: `payout_paid-${payout.id}`,
|
||||||
|
template: {
|
||||||
|
template: "payout_paid",
|
||||||
|
data: {
|
||||||
|
organizerName: payout.organizer.name,
|
||||||
|
tripTitle: payout.trip.title,
|
||||||
|
amount: payout.amount,
|
||||||
|
adminNote,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ export async function joinTripAction(tripId: string) {
|
|||||||
try {
|
try {
|
||||||
await requireActiveUser(session.user.id);
|
await requireActiveUser(session.user.id);
|
||||||
await tripService.joinTrip(tripId, session.user.id);
|
await tripService.joinTrip(tripId, session.user.id);
|
||||||
|
void notifyJoinRequest(tripId, session.user.id);
|
||||||
revalidatePath(`/trips/${tripId}`);
|
revalidatePath(`/trips/${tripId}`);
|
||||||
revalidatePath("/trips");
|
revalidatePath("/trips");
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
@@ -212,6 +213,60 @@ async function notifyBookingApproved(participantId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** E3.1 — kabari organizer ada peserta baru yang mengajukan join. */
|
||||||
|
async function notifyJoinRequest(tripId: string, joinerId: string) {
|
||||||
|
const [trip, joiner] = await Promise.all([
|
||||||
|
prisma.trip.findUnique({
|
||||||
|
where: { id: tripId },
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
organizer: { select: { email: true, name: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.user.findUnique({
|
||||||
|
where: { id: joinerId },
|
||||||
|
select: { name: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (!trip || !joiner) return;
|
||||||
|
await emailService.send({
|
||||||
|
to: trip.organizer.email,
|
||||||
|
idempotencyKey: `join_request-${tripId}-${joinerId}`,
|
||||||
|
template: {
|
||||||
|
template: "join_request",
|
||||||
|
data: {
|
||||||
|
organizerName: trip.organizer.name,
|
||||||
|
joinerName: joiner.name,
|
||||||
|
tripTitle: trip.title,
|
||||||
|
tripId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** E3.2 — kabari peserta kalau permintaan join-nya ditolak organizer. */
|
||||||
|
async function notifyJoinRejected(participantId: string) {
|
||||||
|
const participant = await prisma.tripParticipant.findUnique({
|
||||||
|
where: { id: participantId },
|
||||||
|
select: {
|
||||||
|
user: { select: { email: true, name: true } },
|
||||||
|
trip: { select: { title: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!participant) return;
|
||||||
|
await emailService.send({
|
||||||
|
to: participant.user.email,
|
||||||
|
idempotencyKey: `join_rejected-${participantId}`,
|
||||||
|
template: {
|
||||||
|
template: "join_rejected",
|
||||||
|
data: {
|
||||||
|
userName: participant.user.name,
|
||||||
|
tripTitle: participant.trip.title,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function rejectParticipantAction(
|
export async function rejectParticipantAction(
|
||||||
tripId: string,
|
tripId: string,
|
||||||
participantId: string
|
participantId: string
|
||||||
@@ -227,6 +282,7 @@ export async function rejectParticipantAction(
|
|||||||
participantId,
|
participantId,
|
||||||
session.user.id
|
session.user.id
|
||||||
);
|
);
|
||||||
|
void notifyJoinRejected(participantId);
|
||||||
revalidatePath(`/trips/${tripId}`);
|
revalidatePath(`/trips/${tripId}`);
|
||||||
revalidatePath("/trips");
|
revalidatePath("/trips");
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
@@ -237,6 +293,66 @@ export async function rejectParticipantAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CloseTripResult = Awaited<ReturnType<typeof tripService.closeTrip>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E3.4 / E3.5 (+ E2.4) — kabari semua peserta aktif kalau trip dibatalkan.
|
||||||
|
* Email organizer-cancel & admin-cancel beda template; admin-cancel juga
|
||||||
|
* mengabari organizer. Refund block ikut di email (nominal dari `notify`).
|
||||||
|
*/
|
||||||
|
function notifyTripCancelled(
|
||||||
|
tripId: string,
|
||||||
|
notify: CloseTripResult["notify"],
|
||||||
|
actor: { type: "ORGANIZER" } | { type: "ADMIN"; reason: string }
|
||||||
|
) {
|
||||||
|
for (const p of notify.participants) {
|
||||||
|
if (actor.type === "ORGANIZER") {
|
||||||
|
void emailService.send({
|
||||||
|
to: p.email,
|
||||||
|
idempotencyKey: `trip_cancelled_organizer-${tripId}-${p.userId}`,
|
||||||
|
template: {
|
||||||
|
template: "trip_cancelled_organizer",
|
||||||
|
data: {
|
||||||
|
userName: p.name,
|
||||||
|
tripTitle: notify.tripTitle,
|
||||||
|
refundAmount: p.refundAmount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
void emailService.send({
|
||||||
|
to: p.email,
|
||||||
|
idempotencyKey: `trip_cancelled_admin-${tripId}-${p.userId}`,
|
||||||
|
template: {
|
||||||
|
template: "trip_cancelled_admin",
|
||||||
|
data: {
|
||||||
|
userName: p.name,
|
||||||
|
tripTitle: notify.tripTitle,
|
||||||
|
reason: actor.reason,
|
||||||
|
refundAmount: p.refundAmount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Admin force-cancel → organizer juga dikabari (E3.5).
|
||||||
|
if (actor.type === "ADMIN") {
|
||||||
|
void emailService.send({
|
||||||
|
to: notify.organizer.email,
|
||||||
|
idempotencyKey: `trip_cancelled_admin-${tripId}-organizer`,
|
||||||
|
template: {
|
||||||
|
template: "trip_cancelled_admin",
|
||||||
|
data: {
|
||||||
|
userName: notify.organizer.name,
|
||||||
|
tripTitle: notify.tripTitle,
|
||||||
|
reason: actor.reason,
|
||||||
|
refundAmount: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function cancelTripAction(tripId: string) {
|
export async function cancelTripAction(tripId: string) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
@@ -248,6 +364,7 @@ export async function cancelTripAction(tripId: string) {
|
|||||||
type: "ORGANIZER",
|
type: "ORGANIZER",
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
});
|
});
|
||||||
|
notifyTripCancelled(tripId, result.notify, { type: "ORGANIZER" });
|
||||||
revalidatePath(`/trips/${tripId}`);
|
revalidatePath(`/trips/${tripId}`);
|
||||||
revalidatePath("/trips");
|
revalidatePath("/trips");
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
@@ -294,6 +411,10 @@ export async function adminCancelTripAction(tripId: string, reason: string) {
|
|||||||
adminId: session.user.id,
|
adminId: session.user.id,
|
||||||
reason: trimmedReason,
|
reason: trimmedReason,
|
||||||
});
|
});
|
||||||
|
notifyTripCancelled(tripId, result.notify, {
|
||||||
|
type: "ADMIN",
|
||||||
|
reason: trimmedReason,
|
||||||
|
});
|
||||||
await auditLog.record({
|
await auditLog.record({
|
||||||
admin: { id: session.user.id, email: session.user.email },
|
admin: { id: session.user.id, email: session.user.email },
|
||||||
action: "TRIP_ADMIN_CANCEL",
|
action: "TRIP_ADMIN_CANCEL",
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export const emailService = {
|
|||||||
to: input.to,
|
to: input.to,
|
||||||
template: input.template.template,
|
template: input.template.template,
|
||||||
subject: rendered.subject,
|
subject: rendered.subject,
|
||||||
|
html: rendered.html,
|
||||||
providerMessageId: result.data?.id ?? null,
|
providerMessageId: result.data?.id ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -162,6 +163,7 @@ export const emailService = {
|
|||||||
to: job.to,
|
to: job.to,
|
||||||
template: job.template,
|
template: job.template,
|
||||||
subject: job.subject,
|
subject: job.subject,
|
||||||
|
html: job.html,
|
||||||
providerMessageId: result.data?.id ?? null,
|
providerMessageId: result.data?.id ?? null,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -188,6 +190,125 @@ export const emailService = {
|
|||||||
|
|
||||||
return { picked: jobs.length, succeeded, failed };
|
return { picked: jobs.length, succeeded, failed };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin "Retry now" untuk satu EmailJob — kirim ulang langsung tanpa
|
||||||
|
* menunggu cron. Idempotent: kalau email sudah tercatat terkirim, job
|
||||||
|
* ditandai SUCCESS tanpa kirim ulang.
|
||||||
|
*/
|
||||||
|
async retryJob(jobId: string): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
const job = await prisma.emailJob.findUnique({ where: { id: jobId } });
|
||||||
|
if (!job) return { ok: false, error: "Email job tidak ditemukan" };
|
||||||
|
|
||||||
|
const alreadySent = await prisma.emailSent.findUnique({
|
||||||
|
where: { idempotencyKey: job.idempotencyKey },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (alreadySent) {
|
||||||
|
await prisma.emailJob.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: { status: "SUCCESS", lastAttemptAt: new Date() },
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const resend = getResend();
|
||||||
|
if (!resend) return { ok: false, error: "RESEND_API_KEY belum di-set" };
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
try {
|
||||||
|
const result = await resend.emails.send({
|
||||||
|
from: emailFrom(),
|
||||||
|
to: job.to,
|
||||||
|
subject: job.subject,
|
||||||
|
html: job.html,
|
||||||
|
});
|
||||||
|
if (result.error) throw new Error(result.error.message ?? "Resend failed");
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.emailSent.create({
|
||||||
|
data: {
|
||||||
|
idempotencyKey: job.idempotencyKey,
|
||||||
|
to: job.to,
|
||||||
|
template: job.template,
|
||||||
|
subject: job.subject,
|
||||||
|
html: job.html,
|
||||||
|
providerMessageId: result.data?.id ?? null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.emailJob.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: {
|
||||||
|
status: "SUCCESS",
|
||||||
|
attempts: job.attempts + 1,
|
||||||
|
lastAttemptAt: now,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
await prisma.emailJob.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: {
|
||||||
|
status: "FAILED",
|
||||||
|
attempts: job.attempts + 1,
|
||||||
|
lastAttemptAt: now,
|
||||||
|
lastError: message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { ok: false, error: message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin "Resend" untuk EmailSent yang sudah pernah terkirim — mis. user
|
||||||
|
* lapor tidak menerima. Pakai idempotencyKey turunan supaya tidak bentrok
|
||||||
|
* dengan email asli. Butuh `html` tersimpan (row lama tidak bisa di-resend).
|
||||||
|
*/
|
||||||
|
async resendEmail(
|
||||||
|
emailSentId: string
|
||||||
|
): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
const original = await prisma.emailSent.findUnique({
|
||||||
|
where: { id: emailSentId },
|
||||||
|
});
|
||||||
|
if (!original) return { ok: false, error: "Email tidak ditemukan" };
|
||||||
|
if (!original.html) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
"Body email lama tidak tersimpan — tidak bisa di-resend (dikirim sebelum fitur ini ada).",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const resend = getResend();
|
||||||
|
if (!resend) return { ok: false, error: "RESEND_API_KEY belum di-set" };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await resend.emails.send({
|
||||||
|
from: emailFrom(),
|
||||||
|
to: original.to,
|
||||||
|
subject: original.subject,
|
||||||
|
html: original.html,
|
||||||
|
});
|
||||||
|
if (result.error) throw new Error(result.error.message ?? "Resend failed");
|
||||||
|
await prisma.emailSent.create({
|
||||||
|
data: {
|
||||||
|
idempotencyKey: `${original.idempotencyKey}#resend-${Date.now()}`,
|
||||||
|
to: original.to,
|
||||||
|
template: original.template,
|
||||||
|
subject: original.subject,
|
||||||
|
html: original.html,
|
||||||
|
providerMessageId: result.data?.id ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
async function enqueueJob(
|
async function enqueueJob(
|
||||||
|
|||||||
+272
-1
@@ -301,6 +301,244 @@ function accountSuspended(d: AccountSuspendedData) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PR-E3 — Phase 2: notifikasi event (join, trip dibatalkan, payout, KYC)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** Blok refund untuk email pembatalan trip. Pesan beda kalau tidak ada refund. */
|
||||||
|
function refundBlock(refundAmount: number): string {
|
||||||
|
if (refundAmount <= 0) {
|
||||||
|
return `<p style="margin:0 0 12px;font-size:14px;">Booking kamu untuk trip ini tidak punya pembayaran yang perlu dikembalikan.</p>`;
|
||||||
|
}
|
||||||
|
return `<div style="background:#f0fdf4;border-left:4px solid ${EMERALD_600};padding:12px 16px;margin:16px 0;">
|
||||||
|
<p style="margin:0;font-weight:600;font-size:13px;">Refund kamu sedang diproses</p>
|
||||||
|
<p style="margin:8px 0 0;font-size:18px;font-weight:bold;color:${EMERALD_600};">${formatRupiah(refundAmount)}</p>
|
||||||
|
<p style="margin:8px 0 0;font-size:13px;">Admin SeTrip akan memproses transfer ke rekening kamu. Kami kabari lagi via email saat refund selesai.</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JoinRequestData {
|
||||||
|
organizerName: string;
|
||||||
|
joinerName: string;
|
||||||
|
tripTitle: string;
|
||||||
|
tripId: string;
|
||||||
|
}
|
||||||
|
function joinRequest(d: JoinRequestData) {
|
||||||
|
return {
|
||||||
|
subject: `👋 ${d.joinerName} mau gabung trip "${d.tripTitle}"`,
|
||||||
|
html: shell({
|
||||||
|
title: "Join Request",
|
||||||
|
bodyHtml: `
|
||||||
|
<h1 style="margin:0 0 16px;font-size:22px;">Ada yang mau gabung trip kamu</h1>
|
||||||
|
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.organizerName)}, <strong>${escapeHtml(d.joinerName)}</strong> mengajukan diri untuk ikut trip <strong>${escapeHtml(d.tripTitle)}</strong>.</p>
|
||||||
|
<p style="margin:0 0 12px;font-size:14px;">Buka halaman trip untuk meninjau profil peserta lalu setujui atau tolak permintaannya.</p>
|
||||||
|
`,
|
||||||
|
ctaLabel: "Tinjau Permintaan",
|
||||||
|
ctaUrl: `${siteUrl}/trips/${d.tripId}`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JoinRejectedData {
|
||||||
|
userName: string;
|
||||||
|
tripTitle: string;
|
||||||
|
}
|
||||||
|
function joinRejected(d: JoinRejectedData) {
|
||||||
|
return {
|
||||||
|
subject: `Update permintaan gabung trip "${d.tripTitle}"`,
|
||||||
|
html: shell({
|
||||||
|
title: "Join Rejected",
|
||||||
|
bodyHtml: `
|
||||||
|
<h1 style="margin:0 0 16px;font-size:22px;">Permintaan gabung belum diterima</h1>
|
||||||
|
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.userName)}, organizer belum bisa menerima permintaan kamu untuk ikut trip <strong>${escapeHtml(d.tripTitle)}</strong> kali ini.</p>
|
||||||
|
<p style="margin:0 0 12px;font-size:14px;">Jangan berkecil hati — masih banyak trip seru lain yang bisa kamu ikuti.</p>
|
||||||
|
`,
|
||||||
|
ctaLabel: "Cari Trip Lain",
|
||||||
|
ctaUrl: `${siteUrl}/trips`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentExpiredData {
|
||||||
|
userName: string;
|
||||||
|
tripTitle: string;
|
||||||
|
tripId: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
function paymentExpired(d: PaymentExpiredData) {
|
||||||
|
return {
|
||||||
|
subject: `⏰ Pembayaran trip "${d.tripTitle}" belum selesai`,
|
||||||
|
html: shell({
|
||||||
|
title: "Payment Expired",
|
||||||
|
bodyHtml: `
|
||||||
|
<h1 style="margin:0 0 16px;font-size:22px;color:${AMBER_600};">Pembayaran belum selesai</h1>
|
||||||
|
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.userName)}, pembayaran kamu untuk trip <strong>${escapeHtml(d.tripTitle)}</strong> sebesar <strong>${formatRupiah(d.amount)}</strong> kadaluarsa atau gagal — slot kamu belum aman.</p>
|
||||||
|
<p style="margin:0 0 12px;font-size:14px;">Selama trip belum penuh dan belum berangkat, kamu masih bisa mengulang pembayaran.</p>
|
||||||
|
`,
|
||||||
|
ctaLabel: "Coba Bayar Lagi",
|
||||||
|
ctaUrl: `${siteUrl}/trips/${d.tripId}/payment`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TripCancelledOrganizerData {
|
||||||
|
userName: string;
|
||||||
|
tripTitle: string;
|
||||||
|
refundAmount: number;
|
||||||
|
}
|
||||||
|
function tripCancelledOrganizer(d: TripCancelledOrganizerData) {
|
||||||
|
return {
|
||||||
|
subject: `❌ Trip "${d.tripTitle}" dibatalkan`,
|
||||||
|
html: shell({
|
||||||
|
title: "Trip Cancelled",
|
||||||
|
bodyHtml: `
|
||||||
|
<h1 style="margin:0 0 16px;font-size:22px;color:${RED_600};">Trip dibatalkan</h1>
|
||||||
|
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.userName)}, mohon maaf — organizer membatalkan trip <strong>${escapeHtml(d.tripTitle)}</strong>. Partisipasi kamu di trip ini otomatis dibatalkan.</p>
|
||||||
|
${refundBlock(d.refundAmount)}
|
||||||
|
`,
|
||||||
|
ctaLabel: "Cari Trip Lain",
|
||||||
|
ctaUrl: `${siteUrl}/trips`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TripCancelledAdminData {
|
||||||
|
userName: string;
|
||||||
|
tripTitle: string;
|
||||||
|
reason: string;
|
||||||
|
refundAmount: number;
|
||||||
|
}
|
||||||
|
function tripCancelledAdmin(d: TripCancelledAdminData) {
|
||||||
|
return {
|
||||||
|
subject: `❌ Trip "${d.tripTitle}" dibatalkan SeTrip`,
|
||||||
|
html: shell({
|
||||||
|
title: "Trip Cancelled by Admin",
|
||||||
|
bodyHtml: `
|
||||||
|
<h1 style="margin:0 0 16px;font-size:22px;color:${RED_600};">Trip dibatalkan SeTrip</h1>
|
||||||
|
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.userName)}, trip <strong>${escapeHtml(d.tripTitle)}</strong> dibatalkan oleh tim admin SeTrip.</p>
|
||||||
|
<div style="background:#fef2f2;border-left:4px solid ${RED_600};padding:12px 16px;margin:16px 0;">
|
||||||
|
<p style="margin:0;font-weight:600;font-size:13px;">Alasan:</p>
|
||||||
|
<p style="margin:8px 0 0;font-size:14px;">${escapeHtml(d.reason)}</p>
|
||||||
|
</div>
|
||||||
|
${refundBlock(d.refundAmount)}
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PayoutReleasedData {
|
||||||
|
organizerName: string;
|
||||||
|
tripTitle: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
function payoutReleased(d: PayoutReleasedData) {
|
||||||
|
return {
|
||||||
|
subject: `💰 Payout trip "${d.tripTitle}" masuk antrian transfer`,
|
||||||
|
html: shell({
|
||||||
|
title: "Payout Released",
|
||||||
|
bodyHtml: `
|
||||||
|
<h1 style="margin:0 0 16px;font-size:22px;color:${EMERALD_600};">Payout siap ditransfer</h1>
|
||||||
|
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.organizerName)}, masa tahan (escrow) untuk trip <strong>${escapeHtml(d.tripTitle)}</strong> sudah berakhir.</p>
|
||||||
|
<p style="margin:0 0 12px;font-size:24px;font-weight:bold;color:${EMERALD_600};">${formatRupiah(d.amount)}</p>
|
||||||
|
<p style="margin:0;font-size:14px;">Dana ini masuk antrian transfer — admin SeTrip akan memprosesnya ke rekening kamu. Kami kabari lagi saat sudah ditransfer.</p>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PayoutPaidData {
|
||||||
|
organizerName: string;
|
||||||
|
tripTitle: string;
|
||||||
|
amount: number;
|
||||||
|
adminNote: string;
|
||||||
|
}
|
||||||
|
function payoutPaid(d: PayoutPaidData) {
|
||||||
|
return {
|
||||||
|
subject: `✅ Payout ${formatRupiah(d.amount)} sudah ditransfer`,
|
||||||
|
html: shell({
|
||||||
|
title: "Payout Paid",
|
||||||
|
bodyHtml: `
|
||||||
|
<h1 style="margin:0 0 16px;font-size:22px;color:${EMERALD_600};">Payout sudah ditransfer</h1>
|
||||||
|
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.organizerName)}, payout untuk trip <strong>${escapeHtml(d.tripTitle)}</strong> sudah kami transfer.</p>
|
||||||
|
<p style="margin:0 0 12px;font-size:24px;font-weight:bold;color:${EMERALD_600};">${formatRupiah(d.amount)}</p>
|
||||||
|
${d.adminNote ? `<div style="background:#f0fdf4;border-left:4px solid ${EMERALD_600};padding:12px 16px;margin:16px 0;"><p style="margin:0;font-weight:600;font-size:13px;">Catatan / referensi transfer:</p><p style="margin:4px 0 0;font-size:14px;">${escapeHtml(d.adminNote)}</p></div>` : ""}
|
||||||
|
<p style="margin:0;font-size:14px;">Cek rekening kamu — biasanya masuk dalam 1×24 jam tergantung bank.</p>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountUnsuspendedData {
|
||||||
|
userName: string;
|
||||||
|
}
|
||||||
|
function accountUnsuspended(d: AccountUnsuspendedData) {
|
||||||
|
return {
|
||||||
|
subject: "✅ Akun SeTrip kamu aktif kembali",
|
||||||
|
html: shell({
|
||||||
|
title: "Account Unsuspended",
|
||||||
|
bodyHtml: `
|
||||||
|
<h1 style="margin:0 0 16px;font-size:22px;color:${EMERALD_600};">Akun aktif kembali</h1>
|
||||||
|
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.userName)}, penangguhan akun SeTrip kamu sudah dicabut. Kamu bisa login dan beraktivitas seperti biasa lagi.</p>
|
||||||
|
`,
|
||||||
|
ctaLabel: "Buka SeTrip",
|
||||||
|
ctaUrl: siteUrl,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KycSubmittedData {
|
||||||
|
userName: string;
|
||||||
|
}
|
||||||
|
function kycSubmitted(d: KycSubmittedData) {
|
||||||
|
return {
|
||||||
|
subject: "📋 Pengajuan verifikasi organizer kamu sudah masuk",
|
||||||
|
html: shell({
|
||||||
|
title: "KYC Submitted",
|
||||||
|
bodyHtml: `
|
||||||
|
<h1 style="margin:0 0 16px;font-size:22px;">Pengajuan diterima</h1>
|
||||||
|
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.userName)}, pengajuan verifikasi organizer kamu sudah masuk dan sedang antri di-review tim admin.</p>
|
||||||
|
<p style="margin:0 0 12px;font-size:14px;">Estimasi review <strong>1–3 hari kerja</strong>. Hasilnya kami kabari lewat email — tidak perlu submit ulang selama belum ada keputusan.</p>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KycManualOverrideData {
|
||||||
|
userName: string;
|
||||||
|
}
|
||||||
|
function kycManualOverride(d: KycManualOverrideData) {
|
||||||
|
return {
|
||||||
|
subject: "✅ Kamu jadi organizer terverifikasi SeTrip",
|
||||||
|
html: shell({
|
||||||
|
title: "KYC Manual Override",
|
||||||
|
bodyHtml: `
|
||||||
|
<h1 style="margin:0 0 16px;font-size:22px;color:${EMERALD_600};">🎉 Selamat ${escapeHtml(d.userName)}!</h1>
|
||||||
|
<p style="margin:0 0 12px;">Tim admin SeTrip sudah memverifikasi kamu sebagai organizer. Mulai sekarang kamu bisa membuat trip berbayar dan menerima payout.</p>
|
||||||
|
`,
|
||||||
|
ctaLabel: "Buat Trip Sekarang",
|
||||||
|
ctaUrl: `${siteUrl}/create-trip`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KycReopenedData {
|
||||||
|
userName: string;
|
||||||
|
}
|
||||||
|
function kycReopened(d: KycReopenedData) {
|
||||||
|
return {
|
||||||
|
subject: "🔄 Pengajuan verifikasi kamu dibuka kembali",
|
||||||
|
html: shell({
|
||||||
|
title: "KYC Reopened",
|
||||||
|
bodyHtml: `
|
||||||
|
<h1 style="margin:0 0 16px;font-size:22px;color:${AMBER_600};">Pengajuan dibuka kembali</h1>
|
||||||
|
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.userName)}, tim admin membuka kembali pengajuan verifikasi organizer kamu. Kamu bisa memperbaiki data lalu mengajukannya ulang.</p>
|
||||||
|
`,
|
||||||
|
ctaLabel: "Buka Halaman Verifikasi",
|
||||||
|
ctaUrl: `${siteUrl}/verify`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Registry — discriminated union supaya type-safe per template.
|
// Registry — discriminated union supaya type-safe per template.
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -314,7 +552,18 @@ export type EmailTemplate =
|
|||||||
| { template: "refund_failed"; data: RefundFailedData }
|
| { template: "refund_failed"; data: RefundFailedData }
|
||||||
| { template: "payment_paid"; data: PaymentPaidData }
|
| { template: "payment_paid"; data: PaymentPaidData }
|
||||||
| { template: "booking_approved"; data: BookingApprovedData }
|
| { template: "booking_approved"; data: BookingApprovedData }
|
||||||
| { template: "account_suspended"; data: AccountSuspendedData };
|
| { template: "account_suspended"; data: AccountSuspendedData }
|
||||||
|
| { template: "join_request"; data: JoinRequestData }
|
||||||
|
| { template: "join_rejected"; data: JoinRejectedData }
|
||||||
|
| { template: "payment_expired"; data: PaymentExpiredData }
|
||||||
|
| { template: "trip_cancelled_organizer"; data: TripCancelledOrganizerData }
|
||||||
|
| { template: "trip_cancelled_admin"; data: TripCancelledAdminData }
|
||||||
|
| { template: "payout_released"; data: PayoutReleasedData }
|
||||||
|
| { template: "payout_paid"; data: PayoutPaidData }
|
||||||
|
| { template: "account_unsuspended"; data: AccountUnsuspendedData }
|
||||||
|
| { template: "kyc_submitted"; data: KycSubmittedData }
|
||||||
|
| { template: "kyc_manual_override"; data: KycManualOverrideData }
|
||||||
|
| { template: "kyc_reopened"; data: KycReopenedData };
|
||||||
|
|
||||||
export function renderEmail(input: EmailTemplate): {
|
export function renderEmail(input: EmailTemplate): {
|
||||||
subject: string;
|
subject: string;
|
||||||
@@ -339,6 +588,28 @@ export function renderEmail(input: EmailTemplate): {
|
|||||||
return bookingApproved(input.data);
|
return bookingApproved(input.data);
|
||||||
case "account_suspended":
|
case "account_suspended":
|
||||||
return accountSuspended(input.data);
|
return accountSuspended(input.data);
|
||||||
|
case "join_request":
|
||||||
|
return joinRequest(input.data);
|
||||||
|
case "join_rejected":
|
||||||
|
return joinRejected(input.data);
|
||||||
|
case "payment_expired":
|
||||||
|
return paymentExpired(input.data);
|
||||||
|
case "trip_cancelled_organizer":
|
||||||
|
return tripCancelledOrganizer(input.data);
|
||||||
|
case "trip_cancelled_admin":
|
||||||
|
return tripCancelledAdmin(input.data);
|
||||||
|
case "payout_released":
|
||||||
|
return payoutReleased(input.data);
|
||||||
|
case "payout_paid":
|
||||||
|
return payoutPaid(input.data);
|
||||||
|
case "account_unsuspended":
|
||||||
|
return accountUnsuspended(input.data);
|
||||||
|
case "kyc_submitted":
|
||||||
|
return kycSubmitted(input.data);
|
||||||
|
case "kyc_manual_override":
|
||||||
|
return kycManualOverride(input.data);
|
||||||
|
case "kyc_reopened":
|
||||||
|
return kycReopened(input.data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "setrip",
|
"name": "setrip",
|
||||||
"version": "0.16.3",
|
"version": "0.16.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "setrip",
|
"name": "setrip",
|
||||||
"version": "0.16.3",
|
"version": "0.16.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next-auth/prisma-adapter": "^1.0.7",
|
"@next-auth/prisma-adapter": "^1.0.7",
|
||||||
"@prisma/adapter-pg": "^7.7.0",
|
"@prisma/adapter-pg": "^7.7.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "setrip",
|
"name": "setrip",
|
||||||
"version": "0.16.3",
|
"version": "0.16.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable: simpan body HTML email terkirim supaya admin bisa resend
|
||||||
|
-- (PR-E5) dan investigasi isi email. Nullable — row lama tidak punya body.
|
||||||
|
ALTER TABLE "EmailSent" ADD COLUMN "html" TEXT;
|
||||||
@@ -475,6 +475,10 @@ model EmailSent {
|
|||||||
to String
|
to String
|
||||||
template String
|
template String
|
||||||
subject String
|
subject String
|
||||||
|
/// Body HTML email yang dikirim — disimpan supaya admin bisa resend (PR-E5)
|
||||||
|
/// & investigasi isi email. Nullable: row lama (sebelum kolom ini ada) tidak
|
||||||
|
/// punya body sehingga tidak bisa di-resend.
|
||||||
|
html String?
|
||||||
/// ID dari Resend (atau provider lain) untuk troubleshooting di dashboard mereka.
|
/// ID dari Resend (atau provider lain) untuk troubleshooting di dashboard mereka.
|
||||||
providerMessageId String?
|
providerMessageId String?
|
||||||
sentAt DateTime @default(now())
|
sentAt DateTime @default(now())
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { Prisma } from "@/app/generated/prisma/client";
|
||||||
|
import type { EmailJobStatus } from "@/app/generated/prisma/enums";
|
||||||
|
|
||||||
|
/** Filter untuk halaman admin email log. Keduanya opsional, match `contains`. */
|
||||||
|
export interface EmailLogFilters {
|
||||||
|
to?: string;
|
||||||
|
template?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIST_LIMIT = 100;
|
||||||
|
|
||||||
|
function buildWhere<T extends { to?: unknown; template?: unknown }>(
|
||||||
|
filters: EmailLogFilters
|
||||||
|
): T {
|
||||||
|
const where = {} as T;
|
||||||
|
if (filters.to) {
|
||||||
|
(where as { to?: unknown }).to = {
|
||||||
|
contains: filters.to,
|
||||||
|
mode: "insensitive",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (filters.template) {
|
||||||
|
(where as { template?: unknown }).template = {
|
||||||
|
contains: filters.template,
|
||||||
|
mode: "insensitive",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return where;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emailRepo = {
|
||||||
|
/** EmailJob (retry queue) per status — terbaru dulu. */
|
||||||
|
async listJobs(statuses: EmailJobStatus[], filters: EmailLogFilters) {
|
||||||
|
const where = buildWhere<Prisma.EmailJobWhereInput>(filters);
|
||||||
|
where.status = { in: statuses };
|
||||||
|
return prisma.emailJob.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take: LIST_LIMIT,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** EmailSent (log email berhasil terkirim) — terbaru dulu. */
|
||||||
|
async listSent(filters: EmailLogFilters) {
|
||||||
|
const where = buildWhere<Prisma.EmailSentWhereInput>(filters);
|
||||||
|
return prisma.emailSent.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { sentAt: "desc" },
|
||||||
|
take: LIST_LIMIT,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statistik kesehatan pengiriman email — dipakai kartu ringkasan
|
||||||
|
* `/admin/emails` dan `/admin/system`.
|
||||||
|
* - `queued` : job menunggu dikirim (PENDING/PROCESSING).
|
||||||
|
* - `failed24h` : job gagal dalam 24 jam terakhir.
|
||||||
|
* - `deadLetter` : job gagal yang sudah habis 5 attempt — cron berhenti
|
||||||
|
* retry, butuh aksi manual admin.
|
||||||
|
*/
|
||||||
|
async stats() {
|
||||||
|
const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
const [queued, failed24h, deadLetter] = await Promise.all([
|
||||||
|
prisma.emailJob.count({
|
||||||
|
where: { status: { in: ["PENDING", "PROCESSING"] } },
|
||||||
|
}),
|
||||||
|
prisma.emailJob.count({
|
||||||
|
where: { status: "FAILED", updatedAt: { gte: since24h } },
|
||||||
|
}),
|
||||||
|
prisma.emailJob.count({
|
||||||
|
where: { status: "FAILED", attempts: { gte: 5 } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return { queued, failed24h, deadLetter };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -183,6 +183,10 @@ async function applyGatewayStatus(
|
|||||||
if (newStatus === "PAID" && !isConflict) {
|
if (newStatus === "PAID" && !isConflict) {
|
||||||
void notifyPaymentPaid(payment.id);
|
void notifyPaymentPaid(payment.id);
|
||||||
}
|
}
|
||||||
|
// E3.3 — pembayaran kadaluarsa/gagal: kabari user supaya bisa retry.
|
||||||
|
if (newStatus === "EXPIRED" || newStatus === "FAILED") {
|
||||||
|
void notifyPaymentFailed(payment.id);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -504,6 +508,35 @@ async function notifyPaymentPaid(paymentId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** E3.3 — kabari user kalau pembayaran expired/gagal supaya bisa retry. */
|
||||||
|
async function notifyPaymentFailed(paymentId: string) {
|
||||||
|
const payment = await prisma.payment.findUnique({
|
||||||
|
where: { id: paymentId },
|
||||||
|
include: {
|
||||||
|
booking: {
|
||||||
|
include: {
|
||||||
|
user: { select: { email: true, name: true } },
|
||||||
|
trip: { select: { id: true, title: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!payment) return;
|
||||||
|
await emailService.send({
|
||||||
|
to: payment.booking.user.email,
|
||||||
|
idempotencyKey: `payment_expired-${payment.id}`,
|
||||||
|
template: {
|
||||||
|
template: "payment_expired",
|
||||||
|
data: {
|
||||||
|
userName: payment.booking.user.name,
|
||||||
|
tripTitle: payment.booking.trip.title,
|
||||||
|
tripId: payment.booking.trip.id,
|
||||||
|
amount: payment.amount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Re-export untuk testing langsung kalau perlu (tetap private dari modul lain).
|
// Re-export untuk testing langsung kalau perlu (tetap private dari modul lain).
|
||||||
export const _internal = { applyGatewayStatus };
|
export const _internal = { applyGatewayStatus };
|
||||||
export type { MidtransTransactionStatus };
|
export type { MidtransTransactionStatus };
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Prisma } from "@/app/generated/prisma/client";
|
import { Prisma } from "@/app/generated/prisma/client";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { payoutRepo } from "@/server/repositories/payout.repo";
|
import { payoutRepo } from "@/server/repositories/payout.repo";
|
||||||
|
import { emailService } from "@/lib/email/send";
|
||||||
|
|
||||||
const SERIAL_TX_ATTEMPTS = 6;
|
const SERIAL_TX_ATTEMPTS = 6;
|
||||||
|
|
||||||
@@ -134,6 +135,33 @@ export const payoutService = {
|
|||||||
where: { id: { in: ids }, status: "HELD" },
|
where: { id: { in: ids }, status: "HELD" },
|
||||||
data: { status: "RELEASED", releasedAt: now },
|
data: { status: "RELEASED", releasedAt: now },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// E3.6 — kabari organizer payout-nya sudah lepas hold & masuk antrian
|
||||||
|
// transfer. Pin ke `releasedAt: now` supaya hanya yang baru di-release.
|
||||||
|
const released = await prisma.payout.findMany({
|
||||||
|
where: { id: { in: ids }, status: "RELEASED", releasedAt: now },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
amount: true,
|
||||||
|
organizer: { select: { email: true, name: true } },
|
||||||
|
trip: { select: { title: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const p of released) {
|
||||||
|
void emailService.send({
|
||||||
|
to: p.organizer.email,
|
||||||
|
idempotencyKey: `payout_released-${p.id}`,
|
||||||
|
template: {
|
||||||
|
template: "payout_released",
|
||||||
|
data: {
|
||||||
|
organizerName: p.organizer.name,
|
||||||
|
tripTitle: p.trip.title,
|
||||||
|
amount: p.amount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { releasedIds: ids };
|
return { releasedIds: ids };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -458,7 +458,14 @@ export const tripService = {
|
|||||||
return runSerializable(async (tx) => {
|
return runSerializable(async (tx) => {
|
||||||
const trip = await tx.trip.findUnique({
|
const trip = await tx.trip.findUnique({
|
||||||
where: { id: tripId },
|
where: { id: tripId },
|
||||||
select: { id: true, status: true, organizerId: true, date: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
|
organizerId: true,
|
||||||
|
date: true,
|
||||||
|
title: true,
|
||||||
|
organizer: { select: { id: true, email: true, name: true } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!trip) {
|
if (!trip) {
|
||||||
throw new Error("Trip tidak ditemukan");
|
throw new Error("Trip tidak ditemukan");
|
||||||
@@ -500,6 +507,8 @@ export const tripService = {
|
|||||||
const refundsCreated: string[] = [];
|
const refundsCreated: string[] = [];
|
||||||
const cancelledBookings: string[] = [];
|
const cancelledBookings: string[] = [];
|
||||||
const skippedBookings: string[] = [];
|
const skippedBookings: string[] = [];
|
||||||
|
// userId → nominal refund yang dibuat untuk dia (untuk email pembatalan).
|
||||||
|
const refundByUser = new Map<string, number>();
|
||||||
|
|
||||||
for (const b of bookings) {
|
for (const b of bookings) {
|
||||||
if (b.status === "CANCELLED" || b.status === "EXPIRED") {
|
if (b.status === "CANCELLED" || b.status === "EXPIRED") {
|
||||||
@@ -540,6 +549,7 @@ export const tripService = {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
refundsCreated.push(refund.id);
|
refundsCreated.push(refund.id);
|
||||||
|
refundByUser.set(b.userId, remaining);
|
||||||
|
|
||||||
// Trip dibatalkan → cancel payout HELD/RELEASED organizer untuk
|
// Trip dibatalkan → cancel payout HELD/RELEASED organizer untuk
|
||||||
// booking ini. Payout PAID di-flag clawback otomatis.
|
// booking ini. Payout PAID di-flag clawback otomatis.
|
||||||
@@ -560,6 +570,12 @@ export const tripService = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Snapshot peserta aktif SEBELUM di-cancel — untuk notifikasi email.
|
||||||
|
const activeParticipants = await tx.tripParticipant.findMany({
|
||||||
|
where: { tripId, status: { not: "CANCELLED" } },
|
||||||
|
select: { user: { select: { id: true, email: true, name: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
// Semua participant aktif → CANCELLED (apapun status booking-nya).
|
// Semua participant aktif → CANCELLED (apapun status booking-nya).
|
||||||
await tx.tripParticipant.updateMany({
|
await tx.tripParticipant.updateMany({
|
||||||
where: { tripId, status: { not: "CANCELLED" } },
|
where: { tripId, status: { not: "CANCELLED" } },
|
||||||
@@ -586,6 +602,17 @@ export const tripService = {
|
|||||||
refundsCreated,
|
refundsCreated,
|
||||||
cancelledBookings,
|
cancelledBookings,
|
||||||
skippedBookings,
|
skippedBookings,
|
||||||
|
// Data penerima notifikasi — email dikirim oleh action setelah tx commit.
|
||||||
|
notify: {
|
||||||
|
tripTitle: trip.title,
|
||||||
|
organizer: trip.organizer,
|
||||||
|
participants: activeParticipants.map((p) => ({
|
||||||
|
userId: p.user.id,
|
||||||
|
email: p.user.email,
|
||||||
|
name: p.user.name,
|
||||||
|
refundAmount: refundByUser.get(p.user.id) ?? 0,
|
||||||
|
})),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}, "Gagal membatalkan trip. Coba lagi sebentar.");
|
}, "Gagal membatalkan trip. Coba lagi sebentar.");
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user