Compare commits

...

3 Commits

Author SHA1 Message Date
arifal 5d095151e4 0.16.5 2026-05-20 15:26:45 +07:00
arifal db71159613 0.16.4 2026-05-20 15:25:48 +07:00
arifal cb03967deb fix email sender all flow 2026-05-20 15:25:32 +07:00
22 changed files with 1453 additions and 65 deletions
+50 -38
View File
@@ -9,6 +9,8 @@ Status implementasi notifikasi email transaksional ke user & organizer. Pakai po
> - **Audit-friendly** — semua email tercatat (sent atau queued) supaya admin bisa cek "kenapa user X belum dapat email Y?".
> - **Unsubscribe-aware** — transactional email (refund, payment, suspend) tetap dikirim. Marketing (reminder, social signal) opt-in dengan unsubscribe link.
**Progress (per 2026-05-20):** PR-E1, E2, E3, E5 ✅ — foundation, transactional email, notifikasi event Phase 2, dan admin email log + retry selesai. PR-E4 ⏳ (marketing/reminder) sengaja ditunda — belum dibutuhkan.
---
## Baseline (kondisi sekarang)
@@ -33,7 +35,7 @@ Alternatif: AWS SES (paling murah at scale), SendGrid, Mailgun. **Decision:** Re
---
## PR-E1 — Foundation: schema + service + cron (MVP wajib)
## PR-E1 — Foundation: schema + service + cron (MVP wajib)
**Tujuan:** infrastructure kirim email yang idempotent + retry-able, tanpa nge-block server action.
@@ -52,12 +54,12 @@ Alternatif: AWS SES (paling murah at scale), SendGrid, Mailgun. **Decision:** Re
| # | Item | Status | File |
|---|---|---|---|
| E1.1 | Model `EmailSent { idempotencyKey @unique, to, template, sentAt, providerMessageId? }` + migration | | [prisma/schema.prisma](prisma/schema.prisma) |
| E1.2 | Model `EmailJob { idempotencyKey, to, subject, html, status, attempts, lastError?, scheduledAt }` + migration | | [prisma/schema.prisma](prisma/schema.prisma) |
| E1.3 | Service `lib/email/send.ts` — Resend client (raw fetch) + idempotency + enqueue on failure | | `lib/email/send.ts` |
| E1.4 | Template registry `lib/email/templates/` — function per template return `{ subject, html }` | | `lib/email/templates/` |
| E1.5 | Cron route `/api/cron/process-email-jobs` pakai `runCron` helper | | `app/api/cron/process-email-jobs/route.ts` |
| E1.6 | Env: `RESEND_API_KEY`, `EMAIL_FROM` (mis. `"SeTrip <no-reply@setrip.id>"`) | | `.env.example` |
| E1.1 | Model `EmailSent { idempotencyKey @unique, to, template, sentAt, providerMessageId? }` + migration | | [prisma/schema.prisma](prisma/schema.prisma) |
| E1.2 | Model `EmailJob { idempotencyKey, to, subject, html, status, attempts, lastError?, scheduledAt }` + migration | | [prisma/schema.prisma](prisma/schema.prisma) |
| E1.3 | Service `lib/email/send.ts` — Resend client (raw fetch) + idempotency + enqueue on failure | | `lib/email/send.ts` |
| E1.4 | Template registry — function per template return `{ subject, html }` | | `lib/email/templates.ts` |
| E1.5 | Cron route `/api/cron/process-email-jobs` pakai `runCron` helper | | `app/api/cron/process-email-jobs/route.ts` |
| E1.6 | Env: `RESEND_API_KEY`, `EMAIL_FROM` (mis. `"SeTrip <no-reply@setrip.id>"`) | | `.env.example` |
**Tindakan manual ops:**
1. Buat akun Resend, verify domain `setrip.id` (atau pakai `onboarding@resend.dev` di dev).
@@ -67,21 +69,23 @@ Alternatif: AWS SES (paling murah at scale), SendGrid, Mailgun. **Decision:** Re
---
## PR-E2 — Phase 1: Wire transactional emails (MVP wajib)
## PR-E2 — Phase 1: Wire transactional emails (MVP wajib)
**Tujuan:** kirim email untuk event paling kritis (KYC, refund, payment, suspend) — yang kalau miss bikin user kehilangan uang atau bingung permanen.
| # | Trigger | Penerima | Template | Wire point | Status |
|---|---|---|---|---|---|
| E2.1 | KYC `VERIFICATION_APPROVE` | User | `kyc_approved` | `reviewVerificationAction` | |
| E2.2 | KYC `VERIFICATION_REJECT` | User | `kyc_rejected` (sertakan reason) | `reviewVerificationAction` | |
| E2.3 | KYC re-upload request | User | `kyc_reupload_request` (fields + note) | `requestReuploadAction` | |
| E2.4 | Refund created (admin atau auto-trigger) | User | `refund_created` (amount + reason) | `createRefundAction` + `tripService.closeTrip` (loop semua peserta PAID) | |
| E2.5 | Refund SUCCEEDED | User | `refund_succeeded` (amount, cek rekening) | `decideRefundAction` (decision=SUCCEEDED) | |
| E2.6 | Refund FAILED | User | `refund_failed` (alasan + langkah next) | `decideRefundAction` (decision=FAILED) | |
| E2.7 | Midtrans webhook PAID | User | `payment_paid` (terima kasih + detail booking) | `paymentService.applyGatewayStatus` (di branch PAID success) | |
| E2.8 | Booking di-approve organizer (status → AWAITING_PAY) | User | `booking_approved` (link bayar + deadline) | `confirmParticipantAction` | |
| E2.9 | Account suspend | User | `account_suspended` (reason + email support) | `suspendUserAction` | |
| E2.1 | KYC `VERIFICATION_APPROVE` | User | `kyc_approved` | `reviewVerificationAction` | |
| E2.2 | KYC `VERIFICATION_REJECT` | User | `kyc_rejected` (sertakan reason) | `reviewVerificationAction` | |
| E2.3 | KYC re-upload request | User | `kyc_reupload_request` (fields + note) | `requestReuploadAction` | |
| E2.4 | Refund created (admin atau auto-trigger) | User | `refund_created` (amount + reason) | `createRefundAction` + `tripService.closeTrip` (loop semua peserta PAID) | |
| E2.5 | Refund SUCCEEDED | User | `refund_succeeded` (amount, cek rekening) | `decideRefundAction` (decision=SUCCEEDED) | |
| E2.6 | Refund FAILED | User | `refund_failed` (alasan + langkah next) | `decideRefundAction` (decision=FAILED) | |
| E2.7 | Midtrans webhook PAID | User | `payment_paid` (terima kasih + detail booking) | `paymentService.applyGatewayStatus` (di branch PAID success) | |
| E2.8 | Booking di-approve organizer (status → AWAITING_PAY) | User | `booking_approved` (link bayar + deadline) | `confirmParticipantAction` | |
| E2.9 | Account suspend | User | `account_suspended` (reason + email support) | `suspendUserAction` | |
> ️ **E2.4** — jalur admin (`createRefundAction`) kirim `refund_created`. Untuk auto-refund saat trip dibatalkan, peserta dikabari lewat email `trip_cancelled_organizer` / `trip_cancelled_admin` (E3.4/E3.5) yang sudah memuat blok nominal refund — satu email konsolidasi, bukan dua.
**Format idempotencyKey:**
- `kyc_approved-<verificationId>`
@@ -96,23 +100,25 @@ Alternatif: AWS SES (paling murah at scale), SendGrid, Mailgun. **Decision:** Re
---
## PR-E3 — Phase 2: UX enhancement (post-MVP)
## PR-E3 — Phase 2: UX enhancement (post-MVP)
Email yang berguna tapi tidak critical kalau miss.
| # | Trigger | Penerima | Template | Wire point |
|---|---|---|---|---|
| E3.1 | User join trip (PENDING) | Organizer | `join_request` (siapa, link approve) | `joinTripAction` |
| E3.2 | Organizer reject join | User | `join_rejected` (singkat) | `rejectParticipantAction` |
| E3.3 | Payment EXPIRED / FAILED | User | `payment_expired` (suruh start ulang) | `paymentService.applyGatewayStatus` |
| E3.4 | Trip CLOSED (organizer cancel) | Semua peserta aktif | `trip_cancelled_organizer` (batch) | `tripService.closeTrip` (organizer actor) |
| E3.5 | Trip CLOSED (admin force-cancel) | Semua peserta + organizer | `trip_cancelled_admin` (reason) | `tripService.closeTrip` (admin actor) |
| E3.6 | Payout RELEASED | Organizer | `payout_released` (ETA transfer dari admin) | `payoutService.releaseEligible` |
| E3.7 | Payout PAID | Organizer | `payout_paid` (cek rekening + adminNote) | `markPayoutPaidAction` |
| E3.8 | Account unsuspended | User | `account_unsuspended` | `unsuspendUserAction` |
| E3.9 | KYC submitted | User | `kyc_submitted` (ETA review) | `submitVerificationAction` |
| E3.10 | KYC manual override | User | `kyc_manual_override` (admin verified) | `manualOverrideVerificationAction` |
| E3.11 | KYC reopened (admin) | User | `kyc_reopened` (boleh submit ulang) | `reopenVerificationAction` |
| # | Trigger | Penerima | Template | Wire point | Status |
|---|---|---|---|---|---|
| E3.1 | User join trip (PENDING) | Organizer | `join_request` (siapa, link approve) | `joinTripAction` | ✅ |
| E3.2 | Organizer reject join | User | `join_rejected` (singkat) | `rejectParticipantAction` | ✅ |
| E3.3 | Payment EXPIRED / FAILED | User | `payment_expired` (suruh start ulang) | `paymentService.applyGatewayStatus` | ✅ |
| E3.4 | Trip CLOSED (organizer cancel) | Semua peserta aktif | `trip_cancelled_organizer` (batch) | `tripService.closeTrip` (organizer actor) | ✅ |
| E3.5 | Trip CLOSED (admin force-cancel) | Semua peserta + organizer | `trip_cancelled_admin` (reason) | `tripService.closeTrip` (admin actor) | ✅ |
| E3.6 | Payout RELEASED | Organizer | `payout_released` (ETA transfer dari admin) | `payoutService.releaseEligible` | ✅ |
| E3.7 | Payout PAID | Organizer | `payout_paid` (cek rekening + adminNote) | `markPayoutPaidAction` | ✅ |
| E3.8 | Account unsuspended | User | `account_unsuspended` | `unsuspendUserAction` | ✅ |
| E3.9 | KYC submitted | User | `kyc_submitted` (ETA review) | `submitVerificationAction` | ✅ |
| E3.10 | KYC manual override | User | `kyc_manual_override` (admin verified) | `manualOverrideVerificationAction` | ✅ |
| E3.11 | KYC reopened (admin) | User | `kyc_reopened` (boleh submit ulang) | `reopenVerificationAction` | ✅ |
**Wire point email:** semua dikirim `void emailService.send(...)` (fire-and-forget, idempotent). Untuk batch trip-cancelled, `closeTrip` mengembalikan daftar penerima + nominal refund; email dikirim oleh action setelah transaksi commit (bukan di dalam tx).
---
@@ -133,16 +139,22 @@ Email engagement — perlu user preference + unsubscribe link.
---
## PR-E5 — Admin UI: email log + queue retry (post-MVP)
## PR-E5 — Admin UI: email log + queue retry (post-MVP)
Visibilitas admin untuk troubleshoot "kenapa user X tidak dapat email?".
| # | Item |
|---|---|
| E5.1 | Page `/admin/emails` — list `EmailSent` + `EmailJob` dengan filter (recipient, template, status, date) |
| E5.2 | Tombol "Retry now" untuk EmailJob FAILED |
| E5.3 | Tombol "Resend" untuk EmailSent (override idempotency, append `?retry=<n>` ke key) |
| E5.4 | Stats card di `/admin/system`: failed count 24h, queued count |
| # | Item | Status | File |
|---|---|---|---|
| E5.1 | Page `/admin/emails` — list `EmailSent` + `EmailJob`, filter recipient + template, status lewat tab | ✅ | `app/admin/emails/page.tsx` |
| E5.2 | Tombol "Kirim ulang" untuk EmailJob gagal/antri — retry sync langsung | ✅ | `features/email/components/email-row-actions.tsx` |
| E5.3 | Tombol "Resend" untuk EmailSent — key turunan `#resend-<ts>`, butuh `EmailSent.html` | ✅ | `features/email/actions.ts` |
| E5.4 | Stats card di `/admin/system` + `/admin/emails`: antri, gagal 24 jam, perlu aksi manual | ✅ | `app/admin/system/page.tsx` |
**Tindakan manual ops:**
1. Run migration `20260520000000_add_email_sent_html` (kolom `EmailSent.html`) di staging → production. Tanpa ini, resend (E5.3) tidak tersedia untuk email yang dikirim sebelum migration.
2. Tambahkan `/admin/emails` ke admin nav — sudah dilakukan di `components/admin/admin-sidebar.tsx`.
> ️ Deviasi minor dari rencana awal: filter tanggal tidak diimplementasikan (list dibatasi 100 baris terbaru); filter status diwujudkan sebagai tab (Gagal / Antrian / Terkirim).
---
+22 -20
View File
@@ -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.
**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)
@@ -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.
@@ -48,14 +50,14 @@ Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDIN
| # | Item | Status | File |
|---|---|---|---|
| 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.3 | Migration `add_refund_model` | | `prisma/migrations/` |
| 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.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.8 | UI admin `/admin/refunds` — list PENDING + tombol approve & mark-succeeded | | `app/admin/refunds/page.tsx` |
| 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.3 | Migration `add_refund_model` | | `prisma/migrations/` |
| 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.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.8 | UI admin `/admin/refunds` — list PENDING + tombol approve & mark-succeeded | | `app/admin/refunds/page.tsx` |
**Tindakan manual:**
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.
@@ -75,16 +77,16 @@ Saat organizer batalkan trip (`status = CLOSED`), semua peserta dengan `Booking.
| # | 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.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.4 | Server action `cancelTripAction` | | `features/trip/actions.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.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` |
**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).
@@ -100,11 +102,11 @@ User bisa cancel booking sendiri, refund dihitung berdasarkan kebijakan default
| # | Item | Status | File |
|---|---|---|---|
| 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.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.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.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.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.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:**
1. Tulis copy kebijakan refund untuk halaman Terms & Privacy.
+17
View File
@@ -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;
}
+346
View File
@@ -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;
}
+80 -2
View File
@@ -5,6 +5,7 @@ import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { prisma } from "@/lib/prisma";
import { systemHealthService } from "@/server/services/system-health.service";
import { emailRepo } from "@/server/repositories/email.repo";
interface JobSummary {
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)),
prisma.cronRun.findMany({
orderBy: { startedAt: "desc" },
take: 20,
}),
systemHealthService.detectStale(),
emailRepo.stats(),
]);
const hasAnyStale =
stale.stalePaymentsCount > 0 ||
stale.awaitingPayPastDepartureCount > 0 ||
stale.overduePayoutsCount > 0 ||
stale.stuckRefundsCount > 0;
stale.stuckRefundsCount > 0 ||
emailStats.deadLetter > 0;
return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
@@ -152,6 +155,19 @@ export default async function AdminSystemPage() {
</Link>
</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>
</section>
)}
@@ -236,6 +252,37 @@ export default async function AdminSystemPage() {
</div>
</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>
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
Recent Runs (20 terakhir)
@@ -301,6 +348,37 @@ function truncate(s: string, max: number): string {
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 }) {
const cls =
value === "SUCCESS"
+1
View File
@@ -14,6 +14,7 @@ const NAV_ITEMS: { href: string; label: string; icon: string }[] = [
{ href: "/admin/verifications", label: "Verifikasi", icon: "🪪" },
{ href: "/admin/refunds", label: "Refund", icon: "↩️" },
{ href: "/admin/payouts", label: "Payout", icon: "💸" },
{ href: "/admin/emails", label: "Email", icon: "✉️" },
{ href: "/admin/audit-log", label: "Audit Log", icon: "📜" },
{ href: "/admin/system", label: "System", icon: "⚙️" },
];
+19
View File
@@ -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) {
const session = await getServerSession(authOptions);
if (!session?.user) {
@@ -75,6 +90,10 @@ export async function unsuspendUserAction(userId: string) {
entityType: "User",
entityId: userId,
});
// Notif email user — kabari akun sudah aktif kembali.
void notifyUnsuspended(userId);
revalidatePath("/admin/users");
revalidatePath(`/admin/users/${userId}`);
return { success: true as const };
+57
View File
@@ -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>
);
}
+52
View File
@@ -13,6 +13,7 @@ import { auditLog } from "@/server/services/audit-log.service";
import { emailService } from "@/lib/email/send";
import { organizerRepo } from "@/server/repositories/organizer.repo";
import { userRepo } from "@/server/repositories/user.repo";
import { prisma } from "@/lib/prisma";
import { submitVerificationSchema, reviewVerificationSchema } from "./schemas";
export async function submitVerificationAction(formData: FormData) {
@@ -43,6 +44,7 @@ export async function submitVerificationAction(formData: FormData) {
...result.data,
birthDate: new Date(result.data.birthDate),
});
void notifyKycSubmitted(session.user.id);
revalidatePath("/verify");
revalidatePath("/profile");
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
* 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,
note,
});
void notifyKycReopened(verificationId);
await auditLog.record({
admin: { id: session.user.id, email: session.user.email },
action: "VERIFICATION_REOPEN",
@@ -262,6 +313,7 @@ export async function manualOverrideVerificationAction(input: {
bankAccountNumber: input.bankAccountNumber,
bankAccountName: input.bankAccountName,
});
void notifyKycManualOverride(input.userId, result.id);
await auditLog.record({
admin: { id: session.user.id, email: session.user.email },
action: "VERIFICATION_MANUAL_OVERRIDE",
+22
View File
@@ -5,7 +5,9 @@ import { revalidatePath } from "next/cache";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { payoutService } from "@/server/services/payout.service";
import { payoutRepo } from "@/server/repositories/payout.repo";
import { auditLog } from "@/server/services/audit-log.service";
import { emailService } from "@/lib/email/send";
import { payoutMarkPaidSchema } from "./schemas";
async function requireAdmin() {
@@ -34,6 +36,7 @@ export async function markPayoutPaidAction(formData: FormData) {
adminId: admin.id,
adminNote: parsed.data.adminNote,
});
void notifyPayoutPaid(parsed.data.payoutId, parsed.data.adminNote);
await auditLog.record({
admin: { id: admin.id, email: admin.email },
action: "PAYOUT_MARK_PAID",
@@ -49,3 +52,22 @@ export async function markPayoutPaidAction(formData: FormData) {
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,
},
},
});
}
+121
View File
@@ -131,6 +131,7 @@ export async function joinTripAction(tripId: string) {
try {
await requireActiveUser(session.user.id);
await tripService.joinTrip(tripId, session.user.id);
void notifyJoinRequest(tripId, session.user.id);
revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips");
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(
tripId: string,
participantId: string
@@ -227,6 +282,7 @@ export async function rejectParticipantAction(
participantId,
session.user.id
);
void notifyJoinRejected(participantId);
revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips");
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) {
const session = await getServerSession(authOptions);
if (!session?.user) {
@@ -248,6 +364,7 @@ export async function cancelTripAction(tripId: string) {
type: "ORGANIZER",
userId: session.user.id,
});
notifyTripCancelled(tripId, result.notify, { type: "ORGANIZER" });
revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips");
revalidatePath("/");
@@ -294,6 +411,10 @@ export async function adminCancelTripAction(tripId: string, reason: string) {
adminId: session.user.id,
reason: trimmedReason,
});
notifyTripCancelled(tripId, result.notify, {
type: "ADMIN",
reason: trimmedReason,
});
await auditLog.record({
admin: { id: session.user.id, email: session.user.email },
action: "TRIP_ADMIN_CANCEL",
+121
View File
@@ -77,6 +77,7 @@ export const emailService = {
to: input.to,
template: input.template.template,
subject: rendered.subject,
html: rendered.html,
providerMessageId: result.data?.id ?? null,
},
});
@@ -162,6 +163,7 @@ export const emailService = {
to: job.to,
template: job.template,
subject: job.subject,
html: job.html,
providerMessageId: result.data?.id ?? null,
},
}),
@@ -188,6 +190,125 @@ export const emailService = {
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(
+272 -1
View File
@@ -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>13 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.
// ============================================================================
@@ -314,7 +552,18 @@ export type EmailTemplate =
| { template: "refund_failed"; data: RefundFailedData }
| { template: "payment_paid"; data: PaymentPaidData }
| { 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): {
subject: string;
@@ -339,6 +588,28 @@ export function renderEmail(input: EmailTemplate): {
return bookingApproved(input.data);
case "account_suspended":
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);
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "setrip",
"version": "0.16.3",
"version": "0.16.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "setrip",
"version": "0.16.3",
"version": "0.16.5",
"dependencies": {
"@next-auth/prisma-adapter": "^1.0.7",
"@prisma/adapter-pg": "^7.7.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "setrip",
"version": "0.16.3",
"version": "0.16.5",
"private": true,
"scripts": {
"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;
+4
View File
@@ -475,6 +475,10 @@ model EmailSent {
to String
template 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.
providerMessageId String?
sentAt DateTime @default(now())
+77
View File
@@ -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 };
},
};
+33
View File
@@ -183,6 +183,10 @@ async function applyGatewayStatus(
if (newStatus === "PAID" && !isConflict) {
void notifyPaymentPaid(payment.id);
}
// E3.3 — pembayaran kadaluarsa/gagal: kabari user supaya bisa retry.
if (newStatus === "EXPIRED" || newStatus === "FAILED") {
void notifyPaymentFailed(payment.id);
}
return {
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).
export const _internal = { applyGatewayStatus };
export type { MidtransTransactionStatus };
+28
View File
@@ -1,6 +1,7 @@
import { Prisma } from "@/app/generated/prisma/client";
import { prisma } from "@/lib/prisma";
import { payoutRepo } from "@/server/repositories/payout.repo";
import { emailService } from "@/lib/email/send";
const SERIAL_TX_ATTEMPTS = 6;
@@ -134,6 +135,33 @@ export const payoutService = {
where: { id: { in: ids }, status: "HELD" },
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 };
},
+28 -1
View File
@@ -458,7 +458,14 @@ export const tripService = {
return runSerializable(async (tx) => {
const trip = await tx.trip.findUnique({
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) {
throw new Error("Trip tidak ditemukan");
@@ -500,6 +507,8 @@ export const tripService = {
const refundsCreated: string[] = [];
const cancelledBookings: 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) {
if (b.status === "CANCELLED" || b.status === "EXPIRED") {
@@ -540,6 +549,7 @@ export const tripService = {
}
);
refundsCreated.push(refund.id);
refundByUser.set(b.userId, remaining);
// Trip dibatalkan → cancel payout HELD/RELEASED organizer untuk
// 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).
await tx.tripParticipant.updateMany({
where: { tripId, status: { not: "CANCELLED" } },
@@ -586,6 +602,17 @@ export const tripService = {
refundsCreated,
cancelledBookings,
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.");
},