Compare commits

...

2 Commits

Author SHA1 Message Date
arifal d5842b984b 0.16.0 2026-05-18 20:47:31 +07:00
arifal bf5c97c442 email service and template using resend 2026-05-18 20:47:05 +07:00
16 changed files with 1155 additions and 5 deletions
+12
View File
@@ -45,3 +45,15 @@ CRON_SECRET=
# 2. Copy "Webhook URL", paste di sini
# Format: https://discord.com/api/webhooks/<id>/<token>
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>"
+153
View File
@@ -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.
+1 -1
View File
@@ -55,7 +55,7 @@ async function getJobSummary(jobName: string): Promise<JobSummary> {
}
// 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" {
if (summary.lastRun?.status === "FAILED") return "failed";
+36
View File
@@ -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 });
}
+1
View File
@@ -7,6 +7,7 @@ Aplikasi punya beberapa endpoint cron yang harus di-trigger periodik dari luar.
| 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/process-email-jobs` | `*/5 * * * *` (setiap 5 menit) | Drain retry queue email — pick `EmailJob` status PENDING/FAILED (attempts<5), retry via Resend. |
## Setup di server
+21
View File
@@ -6,6 +6,8 @@ import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { userService } from "@/server/services/user.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) {
const session = await getServerSession(authOptions);
@@ -29,6 +31,10 @@ export async function suspendUserAction(userId: string, reason: string) {
entityId: userId,
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/${userId}`);
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) {
const session = await getServerSession(authOptions);
if (!session?.user) {
+70
View File
@@ -10,6 +10,9 @@ import {
type ReuploadField,
} from "@/server/services/organizer.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";
export async function submitVerificationAction(formData: FormData) {
@@ -86,6 +89,10 @@ export async function reviewVerificationAction(formData: FormData) {
? { rejectionReason: result.data.rejectionReason ?? null }
: undefined,
});
// Notif email — fire and forget, jangan blok response.
void notifyVerificationDecision(result.data.verificationId, result.data.decision, result.data.rejectionReason);
revalidatePath("/admin/verifications");
revalidatePath("/verify");
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
* di-review ulang tanpa drop & recreate row. Note wajib min 10 char untuk audit.
@@ -159,6 +201,10 @@ export async function requestReuploadAction(
entityId: verificationId,
payload: { fields: valid, note: note.trim() },
});
// Notif email organizer — urgent, action required.
void notifyReuploadRequest(verificationId, valid, note.trim());
revalidatePath("/admin/verifications");
revalidatePath("/verify");
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).
* Bikin row APPROVED dengan flag `isManualOverride = true`.
+83
View File
@@ -6,6 +6,8 @@ import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { refundService } from "@/server/services/refund.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";
async function requireAdmin() {
@@ -51,6 +53,9 @@ export async function createRefundAction(formData: FormData) {
reason: parsed.data.reason,
},
});
void notifyRefundCreated(refund.id);
revalidatePath("/admin/refunds");
return { success: true };
} 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) {
const admin = await requireAdmin();
if (!admin) return { error: "Tidak memiliki akses admin" };
@@ -110,6 +186,13 @@ export async function decideRefundAction(formData: FormData) {
entityId: refundId,
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");
return { success: true };
} catch (err) {
+30
View File
@@ -13,6 +13,8 @@ import { revalidatePath } from "next/cache";
import { tripStoredInstantFromYmd } from "@/lib/trip-dates";
import { requireActiveUser } from "@/lib/auth-guards";
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) {
const session = await getServerSession(authOptions);
@@ -172,6 +174,9 @@ export async function confirmParticipantAction(
participantId,
session.user.id
);
void notifyBookingApproved(participantId);
revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips");
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(
tripId: string,
participantId: string
+207
View File
@@ -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",
},
});
}
+353
View File
@@ -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 13 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 &quot;Banding suspend akun&quot;.</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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
+61 -2
View File
@@ -1,12 +1,12 @@
{
"name": "setrip",
"version": "0.15.0",
"version": "0.16.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "setrip",
"version": "0.15.0",
"version": "0.16.0",
"dependencies": {
"@next-auth/prisma-adapter": "^1.0.7",
"@prisma/adapter-pg": "^7.7.0",
@@ -22,6 +22,7 @@
"react": "19.2.4",
"react-datepicker": "^9.1.0",
"react-dom": "19.2.4",
"resend": "^6.12.3",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -2247,6 +2248,12 @@
"dev": true,
"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": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -4869,6 +4876,12 @@
"dev": true,
"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": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
@@ -7144,6 +7157,12 @@
"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": {
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
@@ -7515,6 +7534,27 @@
"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": {
"version": "2.0.0-next.6",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz",
@@ -7935,6 +7975,16 @@
"dev": true,
"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": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
@@ -8140,6 +8190,15 @@
"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": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "setrip",
"version": "0.15.0",
"version": "0.16.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -27,6 +27,7 @@
"react": "19.2.4",
"react-datepicker": "^9.1.0",
"react-dom": "19.2.4",
"resend": "^6.12.3",
"zod": "^4.3.6"
},
"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");
+45
View File
@@ -467,6 +467,51 @@ model Refund {
@@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
/// admin dihapus, `adminId` di-set NULL tapi `adminEmail` snapshot tetap.
/// Dipakai untuk compliance & investigasi (siapa approve/reject/cancel/
+35
View File
@@ -11,6 +11,7 @@ import {
} from "@/lib/midtrans";
import { isTripDepartureDayPast } from "@/lib/trip-dates";
import { payoutService } from "@/server/services/payout.service";
import { emailService } from "@/lib/email/send";
const SERIAL_TX_ATTEMPTS = 6;
@@ -177,6 +178,12 @@ async function applyGatewayStatus(
});
const isConflict =
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 {
ok: true,
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).
export const _internal = { applyGatewayStatus };
export type { MidtransTransactionStatus };