Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5842b984b | |||
| bf5c97c442 |
+13
-1
@@ -44,4 +44,16 @@ CRON_SECRET=
|
|||||||
# 1. Discord channel internal → Edit Channel → Integrations → Webhooks → New
|
# 1. Discord channel internal → Edit Channel → Integrations → Webhooks → New
|
||||||
# 2. Copy "Webhook URL", paste di sini
|
# 2. Copy "Webhook URL", paste di sini
|
||||||
# Format: https://discord.com/api/webhooks/<id>/<token>
|
# Format: https://discord.com/api/webhooks/<id>/<token>
|
||||||
ADMIN_ALERT_WEBHOOK_URL=
|
ADMIN_ALERT_WEBHOOK_URL=
|
||||||
|
|
||||||
|
|
||||||
|
# === Email notifications (Resend) ===
|
||||||
|
# API key Resend untuk kirim email transaksional (KYC, refund, payment, suspend).
|
||||||
|
# Tanpa env, sync send di-skip dan semua email di-queue di DB (status PENDING).
|
||||||
|
# Setelah env di-set, cron `/api/cron/process-email-jobs` akan drain queue.
|
||||||
|
# Sign up: https://resend.com → API Keys
|
||||||
|
RESEND_API_KEY=
|
||||||
|
# Email sender — format RFC 5322 "Display Name <email@domain>".
|
||||||
|
# Domain harus diverifikasi di Resend dashboard (SPF + DKIM).
|
||||||
|
# Default `onboarding@resend.dev` cocok untuk dev/testing.
|
||||||
|
EMAIL_FROM="SeTrip <onboarding@resend.dev>"
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# Setrip — Email Notifications Roadmap
|
||||||
|
|
||||||
|
Status implementasi notifikasi email transaksional ke user & organizer. Pakai pola yang sama dengan refund/admin roadmap: per-phase checklist, idempotent, auditable.
|
||||||
|
|
||||||
|
> **Prinsip:**
|
||||||
|
> - **Transactional only di MVP** — KYC, refund, payment, account moderation. Marketing/reminder belakangan.
|
||||||
|
> - **Idempotent** — webhook retry / cron rerun tidak boleh double-send. Pakai `idempotencyKey` unique constraint di `EmailSent`.
|
||||||
|
> - **Non-blocking** — server action utama tidak boleh gagal kalau email gateway down. Pattern: try send sync; kalau gagal, enqueue `EmailJob` untuk retry cron.
|
||||||
|
> - **Audit-friendly** — semua email tercatat (sent atau queued) supaya admin bisa cek "kenapa user X belum dapat email Y?".
|
||||||
|
> - **Unsubscribe-aware** — transactional email (refund, payment, suspend) tetap dikirim. Marketing (reminder, social signal) opt-in dengan unsubscribe link.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Baseline (kondisi sekarang)
|
||||||
|
|
||||||
|
- ❌ Tidak ada email service terintegrasi.
|
||||||
|
- ❌ Tidak ada template engine.
|
||||||
|
- ❌ User & organizer hanya tahu state via UI — kalau tidak buka app, miss event penting (refund cair, KYC approve, dst).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Provider choice
|
||||||
|
|
||||||
|
**Resend** — alasan:
|
||||||
|
- Free tier 3000 email/bulan + 100/hari (cukup untuk MVP)
|
||||||
|
- React Email native (kalau mau upgrade dari plain HTML)
|
||||||
|
- API simple (POST `/emails`)
|
||||||
|
- DNS setup ringan (SPF + DKIM auto)
|
||||||
|
|
||||||
|
Alternatif: AWS SES (paling murah at scale), SendGrid, Mailgun. **Decision:** Resend untuk MVP, evaluate ulang saat lebih dari 50k email/bulan.
|
||||||
|
|
||||||
|
**Library dependency:** SKIP — pakai `fetch` ke `https://api.resend.com/emails` directly. Lebih ringkas, satu-satunya dependency-free option.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PR-E1 — Foundation: schema + service + cron (MVP wajib) ⏳
|
||||||
|
|
||||||
|
**Tujuan:** infrastructure kirim email yang idempotent + retry-able, tanpa nge-block server action.
|
||||||
|
|
||||||
|
**Keputusan asumsi:**
|
||||||
|
- 2 model baru:
|
||||||
|
- `EmailSent` — append-only log dengan `idempotencyKey @unique`. Cek di sini sebelum kirim → cegah double-send.
|
||||||
|
- `EmailJob` — retry queue untuk send yang gagal sync. Status `PENDING/PROCESSING/SUCCESS/FAILED`, attempt counter, max 5 retry exponential.
|
||||||
|
- Service `emailService.send({ to, template, data, idempotencyKey })`:
|
||||||
|
1. Cek `EmailSent` by idempotencyKey → kalau exist, return early.
|
||||||
|
2. Render template ke `{ subject, html }`.
|
||||||
|
3. POST ke Resend.
|
||||||
|
4. Sukses → insert `EmailSent` row.
|
||||||
|
5. Gagal → insert `EmailJob` row (status PENDING, attempts=1).
|
||||||
|
- Cron `/api/cron/process-email-jobs` setiap 5 menit — pick PENDING + FAILED (attempts<5), retry, mark SUCCESS atau bump attempts.
|
||||||
|
- Caller pattern: `void emailService.send(...)` (fire-and-forget) supaya tidak nge-block server action. Try/catch internal sudah handle error.
|
||||||
|
|
||||||
|
| # | Item | Status | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| E1.1 | Model `EmailSent { idempotencyKey @unique, to, template, sentAt, providerMessageId? }` + migration | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) |
|
||||||
|
| E1.2 | Model `EmailJob { idempotencyKey, to, subject, html, status, attempts, lastError?, scheduledAt }` + migration | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) |
|
||||||
|
| E1.3 | Service `lib/email/send.ts` — Resend client (raw fetch) + idempotency + enqueue on failure | ⏳ | `lib/email/send.ts` |
|
||||||
|
| E1.4 | Template registry `lib/email/templates/` — function per template return `{ subject, html }` | ⏳ | `lib/email/templates/` |
|
||||||
|
| E1.5 | Cron route `/api/cron/process-email-jobs` pakai `runCron` helper | ⏳ | `app/api/cron/process-email-jobs/route.ts` |
|
||||||
|
| E1.6 | Env: `RESEND_API_KEY`, `EMAIL_FROM` (mis. `"SeTrip <no-reply@setrip.id>"`) | ⏳ | `.env.example` |
|
||||||
|
|
||||||
|
**Tindakan manual ops:**
|
||||||
|
1. Buat akun Resend, verify domain `setrip.id` (atau pakai `onboarding@resend.dev` di dev).
|
||||||
|
2. Set DNS SPF + DKIM record di provider domain.
|
||||||
|
3. Generate API key, set env `RESEND_API_KEY` + `EMAIL_FROM` di production.
|
||||||
|
4. Daftarkan cron baru di system crontab: `*/5 * * * * curl ... /api/cron/process-email-jobs`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PR-E2 — Phase 1: Wire transactional emails (MVP wajib) ⏳
|
||||||
|
|
||||||
|
**Tujuan:** kirim email untuk event paling kritis (KYC, refund, payment, suspend) — yang kalau miss bikin user kehilangan uang atau bingung permanen.
|
||||||
|
|
||||||
|
| # | Trigger | Penerima | Template | Wire point | Status |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| E2.1 | KYC `VERIFICATION_APPROVE` | User | `kyc_approved` | `reviewVerificationAction` | ⏳ |
|
||||||
|
| E2.2 | KYC `VERIFICATION_REJECT` | User | `kyc_rejected` (sertakan reason) | `reviewVerificationAction` | ⏳ |
|
||||||
|
| E2.3 | KYC re-upload request | User | `kyc_reupload_request` (fields + note) | `requestReuploadAction` | ⏳ |
|
||||||
|
| E2.4 | Refund created (admin atau auto-trigger) | User | `refund_created` (amount + reason) | `createRefundAction` + `tripService.closeTrip` (loop semua peserta PAID) | ⏳ |
|
||||||
|
| E2.5 | Refund SUCCEEDED | User | `refund_succeeded` (amount, cek rekening) | `decideRefundAction` (decision=SUCCEEDED) | ⏳ |
|
||||||
|
| E2.6 | Refund FAILED | User | `refund_failed` (alasan + langkah next) | `decideRefundAction` (decision=FAILED) | ⏳ |
|
||||||
|
| E2.7 | Midtrans webhook PAID | User | `payment_paid` (terima kasih + detail booking) | `paymentService.applyGatewayStatus` (di branch PAID success) | ⏳ |
|
||||||
|
| E2.8 | Booking di-approve organizer (status → AWAITING_PAY) | User | `booking_approved` (link bayar + deadline) | `confirmParticipantAction` | ⏳ |
|
||||||
|
| E2.9 | Account suspend | User | `account_suspended` (reason + email support) | `suspendUserAction` | ⏳ |
|
||||||
|
|
||||||
|
**Format idempotencyKey:**
|
||||||
|
- `kyc_approved-<verificationId>`
|
||||||
|
- `refund_succeeded-<refundId>`
|
||||||
|
- `payment_paid-<paymentId>`
|
||||||
|
- `booking_approved-<bookingId>`
|
||||||
|
- `account_suspended-<userId>-<suspendedAt>` (allow re-suspend kalau diulang)
|
||||||
|
|
||||||
|
**Tindakan manual ops:**
|
||||||
|
1. Test setiap template di staging — render via Resend "Send test" atau preview HTML lokal.
|
||||||
|
2. Pastikan `EMAIL_FROM` domain match SPF/DKIM supaya tidak masuk spam.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PR-E3 — Phase 2: UX enhancement (post-MVP) ⏳
|
||||||
|
|
||||||
|
Email yang berguna tapi tidak critical kalau miss.
|
||||||
|
|
||||||
|
| # | Trigger | Penerima | Template | Wire point |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| E3.1 | User join trip (PENDING) | Organizer | `join_request` (siapa, link approve) | `joinTripAction` |
|
||||||
|
| E3.2 | Organizer reject join | User | `join_rejected` (singkat) | `rejectParticipantAction` |
|
||||||
|
| E3.3 | Payment EXPIRED / FAILED | User | `payment_expired` (suruh start ulang) | `paymentService.applyGatewayStatus` |
|
||||||
|
| E3.4 | Trip CLOSED (organizer cancel) | Semua peserta aktif | `trip_cancelled_organizer` (batch) | `tripService.closeTrip` (organizer actor) |
|
||||||
|
| E3.5 | Trip CLOSED (admin force-cancel) | Semua peserta + organizer | `trip_cancelled_admin` (reason) | `tripService.closeTrip` (admin actor) |
|
||||||
|
| E3.6 | Payout RELEASED | Organizer | `payout_released` (ETA transfer dari admin) | `payoutService.releaseEligible` |
|
||||||
|
| E3.7 | Payout PAID | Organizer | `payout_paid` (cek rekening + adminNote) | `markPayoutPaidAction` |
|
||||||
|
| E3.8 | Account unsuspended | User | `account_unsuspended` | `unsuspendUserAction` |
|
||||||
|
| E3.9 | KYC submitted | User | `kyc_submitted` (ETA review) | `submitVerificationAction` |
|
||||||
|
| E3.10 | KYC manual override | User | `kyc_manual_override` (admin verified) | `manualOverrideVerificationAction` |
|
||||||
|
| E3.11 | KYC reopened (admin) | User | `kyc_reopened` (boleh submit ulang) | `reopenVerificationAction` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PR-E4 — Phase 3: Marketing / reminder (post-MVP, opt-in) ⏳
|
||||||
|
|
||||||
|
Email engagement — perlu user preference + unsubscribe link.
|
||||||
|
|
||||||
|
| # | Trigger | Penerima | Template | Wire point |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| E4.1 | Welcome email saat signup | User | `welcome` | NextAuth `events.signIn` first time |
|
||||||
|
| E4.2 | Reminder H-3 keberangkatan | User | `trip_reminder_h3` (meeting point + itinerary) | Cron daily |
|
||||||
|
| E4.3 | Reminder H-1 keberangkatan | User | `trip_reminder_h1` | Cron daily |
|
||||||
|
| E4.4 | Trip selesai → minta review (H+1) | User CONFIRMED | `review_prompt` | Cron daily |
|
||||||
|
| E4.5 | Review baru diterima | Organizer | `new_review` | `createReviewAction` |
|
||||||
|
| E4.6 | Trip jadi FULL | Organizer | `trip_full` | `tripService.joinTrip` (saat FULL transition) |
|
||||||
|
|
||||||
|
**Prerequisite:** tabel `UserEmailPreference` dengan kategori `marketing` / `reminders` + unsubscribe token. Skip sampai Phase 4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PR-E5 — Admin UI: email log + queue retry (post-MVP) ⏳
|
||||||
|
|
||||||
|
Visibilitas admin untuk troubleshoot "kenapa user X tidak dapat email?".
|
||||||
|
|
||||||
|
| # | Item |
|
||||||
|
|---|---|
|
||||||
|
| E5.1 | Page `/admin/emails` — list `EmailSent` + `EmailJob` dengan filter (recipient, template, status, date) |
|
||||||
|
| E5.2 | Tombol "Retry now" untuk EmailJob FAILED |
|
||||||
|
| E5.3 | Tombol "Resend" untuk EmailSent (override idempotency, append `?retry=<n>` ke key) |
|
||||||
|
| E5.4 | Stats card di `/admin/system`: failed count 24h, queued count |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Skip / never (eksplisit)
|
||||||
|
|
||||||
|
- ❌ SMS / WhatsApp — beda regulatory, beda cost. Stick to email.
|
||||||
|
- ❌ Push notification (browser/mobile) — perlu PWA setup terpisah.
|
||||||
|
- ❌ In-app inbox — komplexitas tinggi, low ROI di MVP. Email cukup.
|
||||||
@@ -55,7 +55,7 @@ async function getJobSummary(jobName: string): Promise<JobSummary> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Daftar cron yang dipantau. Tambah entry baru saat menambah cron route handler.
|
// Daftar cron yang dipantau. Tambah entry baru saat menambah cron route handler.
|
||||||
const TRACKED_JOBS = ["auto-complete-trips"] as const;
|
const TRACKED_JOBS = ["auto-complete-trips", "process-email-jobs"] as const;
|
||||||
|
|
||||||
function healthOf(summary: JobSummary): "ok" | "stale" | "failed" {
|
function healthOf(summary: JobSummary): "ok" | "stale" | "failed" {
|
||||||
if (summary.lastRun?.status === "FAILED") return "failed";
|
if (summary.lastRun?.status === "FAILED") return "failed";
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { emailService } from "@/lib/email/send";
|
||||||
|
import { runCron } from "@/lib/cron-runner";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron — proses retry queue email (jobs status PENDING/FAILED dengan
|
||||||
|
* attempts<5 dan scheduledAt sudah lewat).
|
||||||
|
*
|
||||||
|
* Trigger setiap 5 menit via system crontab — lihat docs/CRON_SETUP.md.
|
||||||
|
* Header wajib: `Authorization: Bearer ${CRON_SECRET}`.
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const secret = process.env.CRON_SECRET;
|
||||||
|
if (!secret) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Server misconfigured (CRON_SECRET)" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const authHeader = req.headers.get("authorization");
|
||||||
|
if (authHeader !== `Bearer ${secret}`) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const outcome = await runCron("process-email-jobs", async () => {
|
||||||
|
return emailService.processQueue(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!outcome.ok) {
|
||||||
|
return NextResponse.json({ error: outcome.error }, { status: 500 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true, ...outcome.payload });
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ Aplikasi punya beberapa endpoint cron yang harus di-trigger periodik dari luar.
|
|||||||
| Endpoint | Schedule | Tujuan |
|
| Endpoint | Schedule | Tujuan |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GET /api/cron/auto-complete-trips` | `0 18 * * *` (18:00 UTC = 01:00 WIB) | Flip trip yang sudah lewat tanggal selesai dari `OPEN`/`FULL` ke `COMPLETED`. |
|
| `GET /api/cron/auto-complete-trips` | `0 18 * * *` (18:00 UTC = 01:00 WIB) | Flip trip yang sudah lewat tanggal selesai dari `OPEN`/`FULL` ke `COMPLETED`. |
|
||||||
|
| `GET /api/cron/process-email-jobs` | `*/5 * * * *` (setiap 5 menit) | Drain retry queue email — pick `EmailJob` status PENDING/FAILED (attempts<5), retry via Resend. |
|
||||||
|
|
||||||
## Setup di server
|
## Setup di server
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { authOptions } from "@/lib/auth";
|
|||||||
import { isAdminEmail } from "@/lib/admin";
|
import { isAdminEmail } from "@/lib/admin";
|
||||||
import { userService } from "@/server/services/user.service";
|
import { userService } from "@/server/services/user.service";
|
||||||
import { auditLog } from "@/server/services/audit-log.service";
|
import { auditLog } from "@/server/services/audit-log.service";
|
||||||
|
import { emailService } from "@/lib/email/send";
|
||||||
|
import { userRepo } from "@/server/repositories/user.repo";
|
||||||
|
|
||||||
export async function suspendUserAction(userId: string, reason: string) {
|
export async function suspendUserAction(userId: string, reason: string) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
@@ -29,6 +31,10 @@ export async function suspendUserAction(userId: string, reason: string) {
|
|||||||
entityId: userId,
|
entityId: userId,
|
||||||
payload: { reason: reason.trim() },
|
payload: { reason: reason.trim() },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Notif email user — due process: kasih tahu alasan + cara appeal.
|
||||||
|
void notifySuspended(userId, reason.trim());
|
||||||
|
|
||||||
revalidatePath("/admin/users");
|
revalidatePath("/admin/users");
|
||||||
revalidatePath(`/admin/users/${userId}`);
|
revalidatePath(`/admin/users/${userId}`);
|
||||||
return { success: true as const };
|
return { success: true as const };
|
||||||
@@ -37,6 +43,21 @@ export async function suspendUserAction(userId: string, reason: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function notifySuspended(userId: string, reason: string) {
|
||||||
|
const target = await userRepo.findById(userId);
|
||||||
|
if (!target) return;
|
||||||
|
await emailService.send({
|
||||||
|
to: target.email,
|
||||||
|
// Suspend bisa di-trigger berulang — sertakan timestamp supaya tiap suspend
|
||||||
|
// baru dapat email baru.
|
||||||
|
idempotencyKey: `account_suspended-${userId}-${Date.now()}`,
|
||||||
|
template: {
|
||||||
|
template: "account_suspended",
|
||||||
|
data: { userName: target.name, reason },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
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) {
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
type ReuploadField,
|
type ReuploadField,
|
||||||
} from "@/server/services/organizer.service";
|
} from "@/server/services/organizer.service";
|
||||||
import { auditLog } from "@/server/services/audit-log.service";
|
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 { submitVerificationSchema, reviewVerificationSchema } from "./schemas";
|
import { submitVerificationSchema, reviewVerificationSchema } from "./schemas";
|
||||||
|
|
||||||
export async function submitVerificationAction(formData: FormData) {
|
export async function submitVerificationAction(formData: FormData) {
|
||||||
@@ -86,6 +89,10 @@ export async function reviewVerificationAction(formData: FormData) {
|
|||||||
? { rejectionReason: result.data.rejectionReason ?? null }
|
? { rejectionReason: result.data.rejectionReason ?? null }
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Notif email — fire and forget, jangan blok response.
|
||||||
|
void notifyVerificationDecision(result.data.verificationId, result.data.decision, result.data.rejectionReason);
|
||||||
|
|
||||||
revalidatePath("/admin/verifications");
|
revalidatePath("/admin/verifications");
|
||||||
revalidatePath("/verify");
|
revalidatePath("/verify");
|
||||||
revalidatePath("/profile");
|
revalidatePath("/profile");
|
||||||
@@ -95,6 +102,41 @@ export async function reviewVerificationAction(formData: FormData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function notifyVerificationDecision(
|
||||||
|
verificationId: string,
|
||||||
|
decision: "APPROVED" | "REJECTED",
|
||||||
|
rejectionReason?: string
|
||||||
|
) {
|
||||||
|
const verification = await organizerRepo.findById(verificationId);
|
||||||
|
if (!verification) return;
|
||||||
|
const user = await userRepo.findById(verification.userId);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
if (decision === "APPROVED") {
|
||||||
|
await emailService.send({
|
||||||
|
to: user.email,
|
||||||
|
idempotencyKey: `kyc_approved-${verificationId}`,
|
||||||
|
template: {
|
||||||
|
template: "kyc_approved",
|
||||||
|
data: { userName: user.name },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await emailService.send({
|
||||||
|
to: user.email,
|
||||||
|
// submissionCount supaya kalau reject berulang masing-masing dapat email.
|
||||||
|
idempotencyKey: `kyc_rejected-${verificationId}-${verification.submissionCount}`,
|
||||||
|
template: {
|
||||||
|
template: "kyc_rejected",
|
||||||
|
data: {
|
||||||
|
userName: user.name,
|
||||||
|
rejectionReason: rejectionReason ?? "(tidak ada alasan tercatat)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
||||||
@@ -159,6 +201,10 @@ export async function requestReuploadAction(
|
|||||||
entityId: verificationId,
|
entityId: verificationId,
|
||||||
payload: { fields: valid, note: note.trim() },
|
payload: { fields: valid, note: note.trim() },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Notif email organizer — urgent, action required.
|
||||||
|
void notifyReuploadRequest(verificationId, valid, note.trim());
|
||||||
|
|
||||||
revalidatePath("/admin/verifications");
|
revalidatePath("/admin/verifications");
|
||||||
revalidatePath("/verify");
|
revalidatePath("/verify");
|
||||||
return { success: true as const };
|
return { success: true as const };
|
||||||
@@ -167,6 +213,30 @@ export async function requestReuploadAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function notifyReuploadRequest(
|
||||||
|
verificationId: string,
|
||||||
|
fields: ReuploadField[],
|
||||||
|
note: 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,
|
||||||
|
// Allow re-trigger kalau admin minta lagi setelah submit ulang.
|
||||||
|
idempotencyKey: `kyc_reupload_request-${verificationId}-${verification.submissionCount}`,
|
||||||
|
template: {
|
||||||
|
template: "kyc_reupload_request",
|
||||||
|
data: {
|
||||||
|
userName: user.name,
|
||||||
|
fields,
|
||||||
|
note,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase 4: admin verify user tanpa upload KYC (partner trusted referral).
|
* Phase 4: admin verify user tanpa upload KYC (partner trusted referral).
|
||||||
* Bikin row APPROVED dengan flag `isManualOverride = true`.
|
* Bikin row APPROVED dengan flag `isManualOverride = true`.
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { authOptions } from "@/lib/auth";
|
|||||||
import { isAdminEmail } from "@/lib/admin";
|
import { isAdminEmail } from "@/lib/admin";
|
||||||
import { refundService } from "@/server/services/refund.service";
|
import { refundService } from "@/server/services/refund.service";
|
||||||
import { auditLog } from "@/server/services/audit-log.service";
|
import { auditLog } from "@/server/services/audit-log.service";
|
||||||
|
import { emailService } from "@/lib/email/send";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
import { createRefundSchema, refundDecisionSchema } from "./schemas";
|
import { createRefundSchema, refundDecisionSchema } from "./schemas";
|
||||||
|
|
||||||
async function requireAdmin() {
|
async function requireAdmin() {
|
||||||
@@ -51,6 +53,9 @@ export async function createRefundAction(formData: FormData) {
|
|||||||
reason: parsed.data.reason,
|
reason: parsed.data.reason,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void notifyRefundCreated(refund.id);
|
||||||
|
|
||||||
revalidatePath("/admin/refunds");
|
revalidatePath("/admin/refunds");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -58,6 +63,77 @@ export async function createRefundAction(formData: FormData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function notifyRefundCreated(refundId: string) {
|
||||||
|
const refund = await prisma.refund.findUnique({
|
||||||
|
where: { id: refundId },
|
||||||
|
include: {
|
||||||
|
booking: {
|
||||||
|
include: {
|
||||||
|
user: { select: { email: true, name: true } },
|
||||||
|
trip: { select: { title: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!refund) return;
|
||||||
|
await emailService.send({
|
||||||
|
to: refund.booking.user.email,
|
||||||
|
idempotencyKey: `refund_created-${refund.id}`,
|
||||||
|
template: {
|
||||||
|
template: "refund_created",
|
||||||
|
data: {
|
||||||
|
userName: refund.booking.user.name,
|
||||||
|
tripTitle: refund.booking.trip.title,
|
||||||
|
amount: refund.amount,
|
||||||
|
reason: refund.reason,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notifyRefundDecision(
|
||||||
|
refundId: string,
|
||||||
|
decision: "SUCCEEDED" | "FAILED",
|
||||||
|
adminNote: string
|
||||||
|
) {
|
||||||
|
const refund = await prisma.refund.findUnique({
|
||||||
|
where: { id: refundId },
|
||||||
|
include: {
|
||||||
|
booking: {
|
||||||
|
include: {
|
||||||
|
user: { select: { email: true, name: true } },
|
||||||
|
trip: { select: { title: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!refund) return;
|
||||||
|
await emailService.send({
|
||||||
|
to: refund.booking.user.email,
|
||||||
|
idempotencyKey: `refund_${decision.toLowerCase()}-${refund.id}`,
|
||||||
|
template:
|
||||||
|
decision === "SUCCEEDED"
|
||||||
|
? {
|
||||||
|
template: "refund_succeeded",
|
||||||
|
data: {
|
||||||
|
userName: refund.booking.user.name,
|
||||||
|
tripTitle: refund.booking.trip.title,
|
||||||
|
amount: refund.amount,
|
||||||
|
adminNote,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
template: "refund_failed",
|
||||||
|
data: {
|
||||||
|
userName: refund.booking.user.name,
|
||||||
|
tripTitle: refund.booking.trip.title,
|
||||||
|
amount: refund.amount,
|
||||||
|
adminNote,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function decideRefundAction(formData: FormData) {
|
export async function decideRefundAction(formData: FormData) {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
if (!admin) return { error: "Tidak memiliki akses admin" };
|
if (!admin) return { error: "Tidak memiliki akses admin" };
|
||||||
@@ -110,6 +186,13 @@ export async function decideRefundAction(formData: FormData) {
|
|||||||
entityId: refundId,
|
entityId: refundId,
|
||||||
payload: adminNote ? { adminNote } : undefined,
|
payload: adminNote ? { adminNote } : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Notif email user kalau decision final (SUCCEEDED/FAILED) — APPROVE/REJECT
|
||||||
|
// intermediate, refund_created sudah dikirim sebelumnya.
|
||||||
|
if (decision === "SUCCEEDED" || decision === "FAILED") {
|
||||||
|
void notifyRefundDecision(refundId, decision, adminNote ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
revalidatePath("/admin/refunds");
|
revalidatePath("/admin/refunds");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { tripStoredInstantFromYmd } from "@/lib/trip-dates";
|
import { tripStoredInstantFromYmd } from "@/lib/trip-dates";
|
||||||
import { requireActiveUser } from "@/lib/auth-guards";
|
import { requireActiveUser } from "@/lib/auth-guards";
|
||||||
import { auditLog } from "@/server/services/audit-log.service";
|
import { auditLog } from "@/server/services/audit-log.service";
|
||||||
|
import { emailService } from "@/lib/email/send";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export async function createTripAction(formData: FormData) {
|
export async function createTripAction(formData: FormData) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
@@ -172,6 +174,9 @@ export async function confirmParticipantAction(
|
|||||||
participantId,
|
participantId,
|
||||||
session.user.id
|
session.user.id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
void notifyBookingApproved(participantId);
|
||||||
|
|
||||||
revalidatePath(`/trips/${tripId}`);
|
revalidatePath(`/trips/${tripId}`);
|
||||||
revalidatePath("/trips");
|
revalidatePath("/trips");
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
@@ -182,6 +187,31 @@ export async function confirmParticipantAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function notifyBookingApproved(participantId: string) {
|
||||||
|
const participant = await prisma.tripParticipant.findUnique({
|
||||||
|
where: { id: participantId },
|
||||||
|
include: {
|
||||||
|
user: { select: { email: true, name: true } },
|
||||||
|
trip: { select: { id: true, title: true } },
|
||||||
|
booking: { select: { id: true, amount: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!participant || !participant.booking) return;
|
||||||
|
await emailService.send({
|
||||||
|
to: participant.user.email,
|
||||||
|
idempotencyKey: `booking_approved-${participant.booking.id}`,
|
||||||
|
template: {
|
||||||
|
template: "booking_approved",
|
||||||
|
data: {
|
||||||
|
userName: participant.user.name,
|
||||||
|
tripTitle: participant.trip.title,
|
||||||
|
tripId: participant.trip.id,
|
||||||
|
amount: participant.booking.amount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function rejectParticipantAction(
|
export async function rejectParticipantAction(
|
||||||
tripId: string,
|
tripId: string,
|
||||||
participantId: string
|
participantId: string
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { Resend } from "resend";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { renderEmail, type EmailTemplate } from "@/lib/email/templates";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email sender — idempotent, dengan fallback retry queue.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Cek `EmailSent` by `idempotencyKey`. Kalau sudah terkirim, skip (return).
|
||||||
|
* 2. Render template → `{ subject, html }`.
|
||||||
|
* 3. Try sync send via Resend.
|
||||||
|
* 4. Sukses → insert `EmailSent`.
|
||||||
|
* 5. Gagal → insert `EmailJob` (cron retry).
|
||||||
|
*
|
||||||
|
* Caller pattern: `void emailService.send(...)` (fire-and-forget). Service ini
|
||||||
|
* tidak throw — semua error di-handle internal supaya server action tidak gagal.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface SendInput {
|
||||||
|
to: string;
|
||||||
|
idempotencyKey: string;
|
||||||
|
template: EmailTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _resend: Resend | null = null;
|
||||||
|
function getResend(): Resend | null {
|
||||||
|
const apiKey = process.env.RESEND_API_KEY;
|
||||||
|
if (!apiKey) return null;
|
||||||
|
if (!_resend) _resend = new Resend(apiKey);
|
||||||
|
return _resend;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emailFrom(): string {
|
||||||
|
return process.env.EMAIL_FROM ?? "SeTrip <onboarding@resend.dev>";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emailService = {
|
||||||
|
async send(input: SendInput): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 1. Idempotency check
|
||||||
|
const existing = await prisma.emailSent.findUnique({
|
||||||
|
where: { idempotencyKey: input.idempotencyKey },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) return;
|
||||||
|
|
||||||
|
// 2. Render
|
||||||
|
const rendered = renderEmail(input.template);
|
||||||
|
|
||||||
|
// 3. Try sync send
|
||||||
|
const resend = getResend();
|
||||||
|
if (!resend) {
|
||||||
|
// Env tidak di-set — enqueue saja supaya tetap ter-log.
|
||||||
|
await enqueueJob(input, rendered);
|
||||||
|
console.warn(
|
||||||
|
"[email] RESEND_API_KEY tidak di-set, email di-queue:",
|
||||||
|
input.template.template,
|
||||||
|
input.to
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await resend.emails.send({
|
||||||
|
from: emailFrom(),
|
||||||
|
to: input.to,
|
||||||
|
subject: rendered.subject,
|
||||||
|
html: rendered.html,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
throw new Error(result.error.message ?? "Resend send failed");
|
||||||
|
}
|
||||||
|
// 4. Mark sent
|
||||||
|
await prisma.emailSent.create({
|
||||||
|
data: {
|
||||||
|
idempotencyKey: input.idempotencyKey,
|
||||||
|
to: input.to,
|
||||||
|
template: input.template.template,
|
||||||
|
subject: rendered.subject,
|
||||||
|
providerMessageId: result.data?.id ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// 5. Enqueue retry
|
||||||
|
await enqueueJob(input, rendered);
|
||||||
|
console.error(
|
||||||
|
"[email] sync send failed, queued for retry:",
|
||||||
|
input.template.template,
|
||||||
|
input.to,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Catch-all — jangan biarkan email error ngerusak action utama.
|
||||||
|
console.error("[email] unexpected error:", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process pending/failed jobs di queue. Dipanggil dari cron handler.
|
||||||
|
* Max 5 attempts dengan exponential backoff (5min × 2^attempts).
|
||||||
|
*/
|
||||||
|
async processQueue(limit = 50): Promise<{
|
||||||
|
picked: number;
|
||||||
|
succeeded: number;
|
||||||
|
failed: number;
|
||||||
|
}> {
|
||||||
|
const now = new Date();
|
||||||
|
const jobs = await prisma.emailJob.findMany({
|
||||||
|
where: {
|
||||||
|
status: { in: ["PENDING", "FAILED"] },
|
||||||
|
attempts: { lt: 5 },
|
||||||
|
scheduledAt: { lte: now },
|
||||||
|
},
|
||||||
|
orderBy: { scheduledAt: "asc" },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
let succeeded = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
const resend = getResend();
|
||||||
|
if (!resend) {
|
||||||
|
console.warn("[email] processQueue: RESEND_API_KEY tidak di-set, skip");
|
||||||
|
return { picked: jobs.length, succeeded: 0, failed: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const job of jobs) {
|
||||||
|
// Re-check idempotency — bisa jadi email sudah terkirim oleh sync attempt sejak job di-enqueue.
|
||||||
|
const alreadySent = await prisma.emailSent.findUnique({
|
||||||
|
where: { idempotencyKey: job.idempotencyKey },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (alreadySent) {
|
||||||
|
await prisma.emailJob.update({
|
||||||
|
where: { id: job.id },
|
||||||
|
data: { status: "SUCCESS", lastAttemptAt: now },
|
||||||
|
});
|
||||||
|
succeeded++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark PROCESSING (best-effort lock)
|
||||||
|
await prisma.emailJob.update({
|
||||||
|
where: { id: job.id },
|
||||||
|
data: { status: "PROCESSING", attempts: job.attempts + 1, lastAttemptAt: now },
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
providerMessageId: result.data?.id ?? null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.emailJob.update({
|
||||||
|
where: { id: job.id },
|
||||||
|
data: { status: "SUCCESS" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
succeeded++;
|
||||||
|
} catch (err) {
|
||||||
|
const nextAttempt = job.attempts + 1;
|
||||||
|
const backoffMin = Math.min(60, 5 * Math.pow(2, nextAttempt - 1));
|
||||||
|
await prisma.emailJob.update({
|
||||||
|
where: { id: job.id },
|
||||||
|
data: {
|
||||||
|
status: "FAILED",
|
||||||
|
lastError: err instanceof Error ? err.message : String(err),
|
||||||
|
scheduledAt: new Date(now.getTime() + backoffMin * 60 * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { picked: jobs.length, succeeded, failed };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function enqueueJob(
|
||||||
|
input: SendInput,
|
||||||
|
rendered: { subject: string; html: string }
|
||||||
|
): Promise<void> {
|
||||||
|
await prisma.emailJob.create({
|
||||||
|
data: {
|
||||||
|
idempotencyKey: input.idempotencyKey,
|
||||||
|
to: input.to,
|
||||||
|
template: input.template.template,
|
||||||
|
subject: rendered.subject,
|
||||||
|
html: rendered.html,
|
||||||
|
status: "PENDING",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
import { siteUrl } from "@/lib/site";
|
||||||
|
import { formatRupiah } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry semua template email. Setiap entry punya:
|
||||||
|
* - param shape (kontrak data yang harus dipassing caller)
|
||||||
|
* - render function → `{ subject, html }`
|
||||||
|
*
|
||||||
|
* Style: plain HTML inline-style untuk kompatibilitas client email
|
||||||
|
* (Outlook, Gmail mobile, dst). Hindari CSS modern (flexbox, grid, var).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PRIMARY = "#0d9488"; // primary-600
|
||||||
|
const NEUTRAL_700 = "#404040";
|
||||||
|
const NEUTRAL_500 = "#737373";
|
||||||
|
const RED_600 = "#dc2626";
|
||||||
|
const AMBER_600 = "#d97706";
|
||||||
|
const EMERALD_600 = "#059669";
|
||||||
|
|
||||||
|
function shell(opts: { title: string; bodyHtml: string; ctaLabel?: string; ctaUrl?: string }): string {
|
||||||
|
const cta = opts.ctaLabel && opts.ctaUrl
|
||||||
|
? `<p style="margin: 24px 0;">
|
||||||
|
<a href="${opts.ctaUrl}" style="display:inline-block;background:${PRIMARY};color:#ffffff;text-decoration:none;padding:12px 24px;border-radius:8px;font-weight:600;font-size:14px;">${opts.ctaLabel}</a>
|
||||||
|
</p>`
|
||||||
|
: "";
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="utf-8"><title>${opts.title}</title></head>
|
||||||
|
<body style="margin:0;padding:0;background:#f5f5f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:${NEUTRAL_700};">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f5f5f5;padding:24px 0;">
|
||||||
|
<tr><td align="center">
|
||||||
|
<table width="560" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:12px;padding:32px;max-width:90%;">
|
||||||
|
<tr><td>
|
||||||
|
<p style="margin:0 0 8px;font-size:12px;font-weight:bold;color:${PRIMARY};letter-spacing:1px;">SETRIP</p>
|
||||||
|
${opts.bodyHtml}
|
||||||
|
${cta}
|
||||||
|
<hr style="border:none;border-top:1px solid #e5e5e5;margin:24px 0;">
|
||||||
|
<p style="margin:0;font-size:11px;color:${NEUTRAL_500};">
|
||||||
|
Email ini dikirim otomatis dari SeTrip. Kalau ada pertanyaan, balas email ini atau hubungi <a href="mailto:support@setrip.id" style="color:${PRIMARY};">support@setrip.id</a>.
|
||||||
|
</p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// KYC verification
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface KycApprovedData {
|
||||||
|
userName: string;
|
||||||
|
}
|
||||||
|
function kycApproved(d: KycApprovedData) {
|
||||||
|
return {
|
||||||
|
subject: "✅ Verifikasi organizer SeTrip kamu sudah disetujui",
|
||||||
|
html: shell({
|
||||||
|
title: "KYC Approved",
|
||||||
|
bodyHtml: `
|
||||||
|
<h1 style="margin:0 0 16px;font-size:22px;color:${EMERALD_600};">🎉 Selamat ${d.userName}!</h1>
|
||||||
|
<p style="margin:0 0 12px;">Pengajuan verifikasi organizer kamu sudah <strong>disetujui</strong>. Mulai sekarang kamu bisa:</p>
|
||||||
|
<ul style="margin:0 0 16px;padding-left:20px;">
|
||||||
|
<li>Membuat trip <strong>berbayar</strong></li>
|
||||||
|
<li>Menerima payout otomatis ke rekening setelah trip selesai</li>
|
||||||
|
<li>Mendapat badge ✅ Verified Organizer di profil publik</li>
|
||||||
|
</ul>
|
||||||
|
`,
|
||||||
|
ctaLabel: "Buat Trip Sekarang",
|
||||||
|
ctaUrl: `${siteUrl}/create-trip`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KycRejectedData {
|
||||||
|
userName: string;
|
||||||
|
rejectionReason: string;
|
||||||
|
}
|
||||||
|
function kycRejected(d: KycRejectedData) {
|
||||||
|
return {
|
||||||
|
subject: "❌ Pengajuan verifikasi SeTrip kamu ditolak",
|
||||||
|
html: shell({
|
||||||
|
title: "KYC Rejected",
|
||||||
|
bodyHtml: `
|
||||||
|
<h1 style="margin:0 0 16px;font-size:22px;color:${RED_600};">Pengajuan ditolak</h1>
|
||||||
|
<p style="margin:0 0 12px;">Halo ${d.userName}, pengajuan verifikasi organizer kamu belum bisa kami setujui.</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.rejectionReason)}</p>
|
||||||
|
</div>
|
||||||
|
<p style="margin:0 0 12px;">Perbaiki data sesuai catatan di atas lalu ajukan ulang lewat halaman verifikasi.</p>
|
||||||
|
`,
|
||||||
|
ctaLabel: "Perbaiki & Ajukan Ulang",
|
||||||
|
ctaUrl: `${siteUrl}/verify`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KycReuploadRequestData {
|
||||||
|
userName: string;
|
||||||
|
fields: string[];
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
function kycReuploadRequest(d: KycReuploadRequestData) {
|
||||||
|
const fieldList = d.fields
|
||||||
|
.map((f) => `<li>${escapeHtml(reuploadFieldLabel(f))}</li>`)
|
||||||
|
.join("");
|
||||||
|
return {
|
||||||
|
subject: "🔄 Admin minta kamu upload ulang data verifikasi",
|
||||||
|
html: shell({
|
||||||
|
title: "KYC Re-upload Request",
|
||||||
|
bodyHtml: `
|
||||||
|
<h1 style="margin:0 0 16px;font-size:22px;color:${AMBER_600};">Perlu upload ulang</h1>
|
||||||
|
<p style="margin:0 0 12px;">Halo ${d.userName}, admin meminta kamu mengirim ulang beberapa data verifikasi.</p>
|
||||||
|
<div style="background:#fffbeb;border-left:4px solid ${AMBER_600};padding:12px 16px;margin:16px 0;">
|
||||||
|
<p style="margin:0;font-weight:600;font-size:13px;">Field yang perlu di-upload ulang:</p>
|
||||||
|
<ul style="margin:8px 0 0;padding-left:20px;font-size:14px;">${fieldList}</ul>
|
||||||
|
<p style="margin:12px 0 0;font-weight:600;font-size:13px;">Catatan admin:</p>
|
||||||
|
<p style="margin:4px 0 0;font-size:14px;">${escapeHtml(d.note)}</p>
|
||||||
|
</div>
|
||||||
|
<p style="margin:0 0 12px;">Buka halaman verifikasi, perbaiki field yang diminta, lalu submit ulang. Banner akan otomatis hilang setelah submit.</p>
|
||||||
|
`,
|
||||||
|
ctaLabel: "Submit Ulang",
|
||||||
|
ctaUrl: `${siteUrl}/verify`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function reuploadFieldLabel(field: string): string {
|
||||||
|
switch (field) {
|
||||||
|
case "ktpImage":
|
||||||
|
return "Foto KTP";
|
||||||
|
case "liveness":
|
||||||
|
return "Foto liveness (pegang kertas SETRIP)";
|
||||||
|
case "nik":
|
||||||
|
return "NIK";
|
||||||
|
case "bankInfo":
|
||||||
|
return "Info rekening";
|
||||||
|
case "address":
|
||||||
|
return "Alamat";
|
||||||
|
default:
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Refund
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface RefundCreatedData {
|
||||||
|
userName: string;
|
||||||
|
tripTitle: string;
|
||||||
|
amount: number;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
function refundCreated(d: RefundCreatedData) {
|
||||||
|
return {
|
||||||
|
subject: `Pengajuan refund untuk "${d.tripTitle}" sudah kami terima`,
|
||||||
|
html: shell({
|
||||||
|
title: "Refund Created",
|
||||||
|
bodyHtml: `
|
||||||
|
<h1 style="margin:0 0 16px;font-size:22px;">Laporan refund diterima</h1>
|
||||||
|
<p style="margin:0 0 12px;">Halo ${d.userName}, kami sudah menerima laporan refund kamu.</p>
|
||||||
|
<table cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;margin:16px 0;background:#fafafa;border-radius:8px;">
|
||||||
|
<tr><td style="padding:12px 16px;color:${NEUTRAL_500};font-size:13px;">Trip</td><td style="padding:12px 16px;font-weight:600;">${escapeHtml(d.tripTitle)}</td></tr>
|
||||||
|
<tr><td style="padding:12px 16px;color:${NEUTRAL_500};font-size:13px;border-top:1px solid #e5e5e5;">Nominal refund</td><td style="padding:12px 16px;font-weight:600;border-top:1px solid #e5e5e5;">${formatRupiah(d.amount)}</td></tr>
|
||||||
|
<tr><td style="padding:12px 16px;color:${NEUTRAL_500};font-size:13px;border-top:1px solid #e5e5e5;">Reason</td><td style="padding:12px 16px;font-weight:600;border-top:1px solid #e5e5e5;">${escapeHtml(d.reason)}</td></tr>
|
||||||
|
</table>
|
||||||
|
<p style="margin:0;font-size:14px;color:${NEUTRAL_500};">Admin akan review dalam 1–3 hari kerja. Kami kabari lagi via email.</p>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefundSucceededData {
|
||||||
|
userName: string;
|
||||||
|
tripTitle: string;
|
||||||
|
amount: number;
|
||||||
|
adminNote: string;
|
||||||
|
}
|
||||||
|
function refundSucceeded(d: RefundSucceededData) {
|
||||||
|
return {
|
||||||
|
subject: `✅ Refund ${formatRupiah(d.amount)} sudah dikirim`,
|
||||||
|
html: shell({
|
||||||
|
title: "Refund Succeeded",
|
||||||
|
bodyHtml: `
|
||||||
|
<h1 style="margin:0 0 16px;font-size:22px;color:${EMERALD_600};">Refund sudah dikirim</h1>
|
||||||
|
<p style="margin:0 0 12px;">Halo ${d.userName}, refund 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 admin:</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). Kalau lebih dari 3 hari belum muncul, balas email ini.</p>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefundFailedData {
|
||||||
|
userName: string;
|
||||||
|
tripTitle: string;
|
||||||
|
amount: number;
|
||||||
|
adminNote: string;
|
||||||
|
}
|
||||||
|
function refundFailed(d: RefundFailedData) {
|
||||||
|
return {
|
||||||
|
subject: `⚠️ Refund untuk "${d.tripTitle}" gagal diproses`,
|
||||||
|
html: shell({
|
||||||
|
title: "Refund Failed",
|
||||||
|
bodyHtml: `
|
||||||
|
<h1 style="margin:0 0 16px;font-size:22px;color:${RED_600};">Refund gagal diproses</h1>
|
||||||
|
<p style="margin:0 0 12px;">Halo ${d.userName}, refund kamu untuk trip <strong>${escapeHtml(d.tripTitle)}</strong> sebesar <strong>${formatRupiah(d.amount)}</strong> gagal diproses.</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;">Catatan admin:</p>
|
||||||
|
<p style="margin:4px 0 0;font-size:14px;">${escapeHtml(d.adminNote)}</p>
|
||||||
|
</div>
|
||||||
|
<p style="margin:0;font-size:14px;">Balas email ini atau hubungi <a href="mailto:support@setrip.id" style="color:${PRIMARY};">support@setrip.id</a> untuk koordinasi lanjutan. Kami akan bantu proses ulang.</p>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Payment
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface PaymentPaidData {
|
||||||
|
userName: string;
|
||||||
|
tripTitle: string;
|
||||||
|
tripId: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
function paymentPaid(d: PaymentPaidData) {
|
||||||
|
return {
|
||||||
|
subject: `✅ Pembayaran ${formatRupiah(d.amount)} untuk "${d.tripTitle}" terkonfirmasi`,
|
||||||
|
html: shell({
|
||||||
|
title: "Payment Confirmed",
|
||||||
|
bodyHtml: `
|
||||||
|
<h1 style="margin:0 0 16px;font-size:22px;color:${EMERALD_600};">Pembayaran terkonfirmasi 🎉</h1>
|
||||||
|
<p style="margin:0 0 12px;">Halo ${d.userName}, pembayaran kamu untuk trip <strong>${escapeHtml(d.tripTitle)}</strong> sudah masuk.</p>
|
||||||
|
<p style="margin:0 0 12px;font-size:20px;font-weight:bold;">${formatRupiah(d.amount)}</p>
|
||||||
|
<p style="margin:0 0 12px;font-size:14px;">Sampai jumpa di trip! Detail meeting point + itinerary lengkap bisa dicek di halaman trip.</p>
|
||||||
|
`,
|
||||||
|
ctaLabel: "Lihat Detail Trip",
|
||||||
|
ctaUrl: `${siteUrl}/trips/${d.tripId}`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingApprovedData {
|
||||||
|
userName: string;
|
||||||
|
tripTitle: string;
|
||||||
|
tripId: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
function bookingApproved(d: BookingApprovedData) {
|
||||||
|
const isFree = d.amount <= 0;
|
||||||
|
return {
|
||||||
|
subject: isFree
|
||||||
|
? `✅ Kamu disetujui ikut "${d.tripTitle}"`
|
||||||
|
: `✅ Kamu disetujui ikut "${d.tripTitle}" — siap bayar`,
|
||||||
|
html: shell({
|
||||||
|
title: "Booking Approved",
|
||||||
|
bodyHtml: `
|
||||||
|
<h1 style="margin:0 0 16px;font-size:22px;color:${EMERALD_600};">Disetujui organizer!</h1>
|
||||||
|
<p style="margin:0 0 12px;">Halo ${d.userName}, organizer sudah <strong>menyetujui</strong> kamu untuk ikut trip <strong>${escapeHtml(d.tripTitle)}</strong>.</p>
|
||||||
|
${
|
||||||
|
isFree
|
||||||
|
? `<p style="margin:0 0 12px;font-size:14px;">Trip ini <strong>gratis</strong> — tidak perlu bayar. Cek detail meeting point + itinerary di halaman trip.</p>`
|
||||||
|
: `<p style="margin:0 0 12px;font-size:14px;">Untuk mengamankan slot, selesaikan pembayaran <strong>${formatRupiah(d.amount)}</strong> via Midtrans (BCA VA, GoPay, QRIS, dll). Dana ditahan SeTrip sampai trip selesai (escrow).</p>`
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
ctaLabel: isFree ? "Lihat Detail Trip" : "Bayar Sekarang",
|
||||||
|
ctaUrl: isFree
|
||||||
|
? `${siteUrl}/trips/${d.tripId}`
|
||||||
|
: `${siteUrl}/trips/${d.tripId}/payment`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Account moderation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface AccountSuspendedData {
|
||||||
|
userName: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
function accountSuspended(d: AccountSuspendedData) {
|
||||||
|
return {
|
||||||
|
subject: "⛔ Akun SeTrip kamu ditangguhkan",
|
||||||
|
html: shell({
|
||||||
|
title: "Account Suspended",
|
||||||
|
bodyHtml: `
|
||||||
|
<h1 style="margin:0 0 16px;font-size:22px;color:${RED_600};">Akun ditangguhkan</h1>
|
||||||
|
<p style="margin:0 0 12px;">Halo ${d.userName}, akun SeTrip kamu sedang dalam status <strong>ditangguhkan</strong>. Selama status ini berlaku, kamu tidak bisa login atau melakukan aksi (join trip, bikin trip, dll).</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>
|
||||||
|
<p style="margin:0;font-size:14px;">Kalau menurut kamu ini kesalahan atau ingin klarifikasi, balas email ini atau hubungi <a href="mailto:support@setrip.id" style="color:${PRIMARY};">support@setrip.id</a> dengan subject "Banding suspend akun".</p>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Registry — discriminated union supaya type-safe per template.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type EmailTemplate =
|
||||||
|
| { template: "kyc_approved"; data: KycApprovedData }
|
||||||
|
| { template: "kyc_rejected"; data: KycRejectedData }
|
||||||
|
| { template: "kyc_reupload_request"; data: KycReuploadRequestData }
|
||||||
|
| { template: "refund_created"; data: RefundCreatedData }
|
||||||
|
| { template: "refund_succeeded"; data: RefundSucceededData }
|
||||||
|
| { template: "refund_failed"; data: RefundFailedData }
|
||||||
|
| { template: "payment_paid"; data: PaymentPaidData }
|
||||||
|
| { template: "booking_approved"; data: BookingApprovedData }
|
||||||
|
| { template: "account_suspended"; data: AccountSuspendedData };
|
||||||
|
|
||||||
|
export function renderEmail(input: EmailTemplate): {
|
||||||
|
subject: string;
|
||||||
|
html: string;
|
||||||
|
} {
|
||||||
|
switch (input.template) {
|
||||||
|
case "kyc_approved":
|
||||||
|
return kycApproved(input.data);
|
||||||
|
case "kyc_rejected":
|
||||||
|
return kycRejected(input.data);
|
||||||
|
case "kyc_reupload_request":
|
||||||
|
return kycReuploadRequest(input.data);
|
||||||
|
case "refund_created":
|
||||||
|
return refundCreated(input.data);
|
||||||
|
case "refund_succeeded":
|
||||||
|
return refundSucceeded(input.data);
|
||||||
|
case "refund_failed":
|
||||||
|
return refundFailed(input.data);
|
||||||
|
case "payment_paid":
|
||||||
|
return paymentPaid(input.data);
|
||||||
|
case "booking_approved":
|
||||||
|
return bookingApproved(input.data);
|
||||||
|
case "account_suspended":
|
||||||
|
return accountSuspended(input.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape HTML supaya tidak ada XSS via data user (mis. tripTitle dari user input).
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
Generated
+61
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "setrip",
|
"name": "setrip",
|
||||||
"version": "0.15.0",
|
"version": "0.16.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "setrip",
|
"name": "setrip",
|
||||||
"version": "0.15.0",
|
"version": "0.16.0",
|
||||||
"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",
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-datepicker": "^9.1.0",
|
"react-datepicker": "^9.1.0",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"resend": "^6.12.3",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -2247,6 +2248,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@stablelib/base64": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -4869,6 +4876,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-sha256": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
||||||
@@ -7144,6 +7157,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postal-mime": {
|
||||||
|
"version": "2.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz",
|
||||||
|
"integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==",
|
||||||
|
"license": "MIT-0"
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.14",
|
"version": "8.5.14",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||||
@@ -7515,6 +7534,27 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/resend": {
|
||||||
|
"version": "6.12.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/resend/-/resend-6.12.3.tgz",
|
||||||
|
"integrity": "sha512-FkEi6YPnVL96/LvH8+QP7NaeaBy5brYXwlRqUCqZZeNL0/iyKij18IPmyPXYauT/2ODn1JG04qKz+qlJfzqzTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"postal-mime": "2.7.4",
|
||||||
|
"svix": "1.92.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@react-email/render": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@react-email/render": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "2.0.0-next.6",
|
"version": "2.0.0-next.6",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz",
|
||||||
@@ -7935,6 +7975,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/standardwebhooks": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@stablelib/base64": "^1.0.0",
|
||||||
|
"fast-sha256": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/std-env": {
|
"node_modules/std-env": {
|
||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||||
@@ -8140,6 +8190,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/svix": {
|
||||||
|
"version": "1.92.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/svix/-/svix-1.92.2.tgz",
|
||||||
|
"integrity": "sha512-ZmuA3UVvlnF9EgxlzmPtF7CKjQb64Z6OFlyfdDfU0sdcC7dJa+3aOYX5B9mA+RS6ch1AxBa4UP/l6KmqfGtWBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"standardwebhooks": "1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tabbable": {
|
"node_modules/tabbable": {
|
||||||
"version": "6.4.0",
|
"version": "6.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "setrip",
|
"name": "setrip",
|
||||||
"version": "0.15.0",
|
"version": "0.16.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-datepicker": "^9.1.0",
|
"react-datepicker": "^9.1.0",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"resend": "^6.12.3",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "EmailJobStatus" AS ENUM ('PENDING', 'PROCESSING', 'SUCCESS', 'FAILED');
|
||||||
|
|
||||||
|
-- CreateTable: log append-only setiap email yang berhasil terkirim.
|
||||||
|
-- `idempotencyKey` UNIQUE cegah double-send saat webhook retry / cron rerun.
|
||||||
|
CREATE TABLE "EmailSent" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"idempotencyKey" TEXT NOT NULL,
|
||||||
|
"to" TEXT NOT NULL,
|
||||||
|
"template" TEXT NOT NULL,
|
||||||
|
"subject" TEXT NOT NULL,
|
||||||
|
"providerMessageId" TEXT,
|
||||||
|
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "EmailSent_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "EmailSent_idempotencyKey_key" ON "EmailSent"("idempotencyKey");
|
||||||
|
CREATE INDEX "EmailSent_to_sentAt_idx" ON "EmailSent"("to", "sentAt" DESC);
|
||||||
|
CREATE INDEX "EmailSent_template_sentAt_idx" ON "EmailSent"("template", "sentAt" DESC);
|
||||||
|
|
||||||
|
-- CreateTable: retry queue untuk email yang gagal saat sync send.
|
||||||
|
-- Cron `/api/cron/process-email-jobs` pick PENDING/FAILED (attempts<5),
|
||||||
|
-- exponential backoff (scheduledAt bumped tiap retry).
|
||||||
|
CREATE TABLE "EmailJob" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"idempotencyKey" TEXT NOT NULL,
|
||||||
|
"to" TEXT NOT NULL,
|
||||||
|
"template" TEXT NOT NULL,
|
||||||
|
"subject" TEXT NOT NULL,
|
||||||
|
"html" TEXT NOT NULL,
|
||||||
|
"status" "EmailJobStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"scheduledAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"lastAttemptAt" TIMESTAMP(3),
|
||||||
|
"lastError" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "EmailJob_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "EmailJob_status_scheduledAt_idx" ON "EmailJob"("status", "scheduledAt");
|
||||||
|
CREATE INDEX "EmailJob_idempotencyKey_idx" ON "EmailJob"("idempotencyKey");
|
||||||
@@ -467,6 +467,51 @@ model Refund {
|
|||||||
@@index([status, createdAt])
|
@@index([status, createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Log append-only setiap email yang berhasil terkirim. `idempotencyKey`
|
||||||
|
/// UNIQUE cegah double-send saat webhook retry / cron rerun.
|
||||||
|
model EmailSent {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
idempotencyKey String @unique
|
||||||
|
to String
|
||||||
|
template String
|
||||||
|
subject String
|
||||||
|
/// ID dari Resend (atau provider lain) untuk troubleshooting di dashboard mereka.
|
||||||
|
providerMessageId String?
|
||||||
|
sentAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([to, sentAt(sort: Desc)])
|
||||||
|
@@index([template, sentAt(sort: Desc)])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retry queue untuk email yang gagal saat sync send. Cron pick PENDING/FAILED
|
||||||
|
/// (attempts<5) → retry dengan exponential backoff. Idempotent via `idempotencyKey`.
|
||||||
|
model EmailJob {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
idempotencyKey String
|
||||||
|
to String
|
||||||
|
template String
|
||||||
|
subject String
|
||||||
|
html String
|
||||||
|
status EmailJobStatus @default(PENDING)
|
||||||
|
attempts Int @default(0)
|
||||||
|
scheduledAt DateTime @default(now())
|
||||||
|
lastAttemptAt DateTime?
|
||||||
|
lastError String?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([status, scheduledAt])
|
||||||
|
@@index([idempotencyKey])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EmailJobStatus {
|
||||||
|
PENDING
|
||||||
|
PROCESSING
|
||||||
|
SUCCESS
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
/// Log polymorphic untuk admin actions lintas entity. Append-only — kalau
|
/// Log polymorphic untuk admin actions lintas entity. Append-only — kalau
|
||||||
/// admin dihapus, `adminId` di-set NULL tapi `adminEmail` snapshot tetap.
|
/// admin dihapus, `adminId` di-set NULL tapi `adminEmail` snapshot tetap.
|
||||||
/// Dipakai untuk compliance & investigasi (siapa approve/reject/cancel/
|
/// Dipakai untuk compliance & investigasi (siapa approve/reject/cancel/
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "@/lib/midtrans";
|
} from "@/lib/midtrans";
|
||||||
import { isTripDepartureDayPast } from "@/lib/trip-dates";
|
import { isTripDepartureDayPast } from "@/lib/trip-dates";
|
||||||
import { payoutService } from "@/server/services/payout.service";
|
import { payoutService } from "@/server/services/payout.service";
|
||||||
|
import { emailService } from "@/lib/email/send";
|
||||||
|
|
||||||
const SERIAL_TX_ATTEMPTS = 6;
|
const SERIAL_TX_ATTEMPTS = 6;
|
||||||
|
|
||||||
@@ -177,6 +178,12 @@ async function applyGatewayStatus(
|
|||||||
});
|
});
|
||||||
const isConflict =
|
const isConflict =
|
||||||
newStatus === "PAID" && finalBooking?.status !== "PAID";
|
newStatus === "PAID" && finalBooking?.status !== "PAID";
|
||||||
|
|
||||||
|
// Notif email user kalau payment benar-benar berhasil di-apply ke booking.
|
||||||
|
if (newStatus === "PAID" && !isConflict) {
|
||||||
|
void notifyPaymentPaid(payment.id);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
status: isConflict ? "booking_conflict" : "updated",
|
status: isConflict ? "booking_conflict" : "updated",
|
||||||
@@ -469,6 +476,34 @@ export const paymentService = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function notifyPaymentPaid(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_paid-${payment.id}`,
|
||||||
|
template: {
|
||||||
|
template: "payment_paid",
|
||||||
|
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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user