fix email sender all flow
This commit is contained in:
@@ -77,6 +77,7 @@ export const emailService = {
|
||||
to: input.to,
|
||||
template: input.template.template,
|
||||
subject: rendered.subject,
|
||||
html: rendered.html,
|
||||
providerMessageId: result.data?.id ?? null,
|
||||
},
|
||||
});
|
||||
@@ -162,6 +163,7 @@ export const emailService = {
|
||||
to: job.to,
|
||||
template: job.template,
|
||||
subject: job.subject,
|
||||
html: job.html,
|
||||
providerMessageId: result.data?.id ?? null,
|
||||
},
|
||||
}),
|
||||
@@ -188,6 +190,125 @@ export const emailService = {
|
||||
|
||||
return { picked: jobs.length, succeeded, failed };
|
||||
},
|
||||
|
||||
/**
|
||||
* Admin "Retry now" untuk satu EmailJob — kirim ulang langsung tanpa
|
||||
* menunggu cron. Idempotent: kalau email sudah tercatat terkirim, job
|
||||
* ditandai SUCCESS tanpa kirim ulang.
|
||||
*/
|
||||
async retryJob(jobId: string): Promise<{ ok: boolean; error?: string }> {
|
||||
const job = await prisma.emailJob.findUnique({ where: { id: jobId } });
|
||||
if (!job) return { ok: false, error: "Email job tidak ditemukan" };
|
||||
|
||||
const alreadySent = await prisma.emailSent.findUnique({
|
||||
where: { idempotencyKey: job.idempotencyKey },
|
||||
select: { id: true },
|
||||
});
|
||||
if (alreadySent) {
|
||||
await prisma.emailJob.update({
|
||||
where: { id: jobId },
|
||||
data: { status: "SUCCESS", lastAttemptAt: new Date() },
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const resend = getResend();
|
||||
if (!resend) return { ok: false, error: "RESEND_API_KEY belum di-set" };
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
const result = await resend.emails.send({
|
||||
from: emailFrom(),
|
||||
to: job.to,
|
||||
subject: job.subject,
|
||||
html: job.html,
|
||||
});
|
||||
if (result.error) throw new Error(result.error.message ?? "Resend failed");
|
||||
await prisma.$transaction([
|
||||
prisma.emailSent.create({
|
||||
data: {
|
||||
idempotencyKey: job.idempotencyKey,
|
||||
to: job.to,
|
||||
template: job.template,
|
||||
subject: job.subject,
|
||||
html: job.html,
|
||||
providerMessageId: result.data?.id ?? null,
|
||||
},
|
||||
}),
|
||||
prisma.emailJob.update({
|
||||
where: { id: jobId },
|
||||
data: {
|
||||
status: "SUCCESS",
|
||||
attempts: job.attempts + 1,
|
||||
lastAttemptAt: now,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await prisma.emailJob.update({
|
||||
where: { id: jobId },
|
||||
data: {
|
||||
status: "FAILED",
|
||||
attempts: job.attempts + 1,
|
||||
lastAttemptAt: now,
|
||||
lastError: message,
|
||||
},
|
||||
});
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Admin "Resend" untuk EmailSent yang sudah pernah terkirim — mis. user
|
||||
* lapor tidak menerima. Pakai idempotencyKey turunan supaya tidak bentrok
|
||||
* dengan email asli. Butuh `html` tersimpan (row lama tidak bisa di-resend).
|
||||
*/
|
||||
async resendEmail(
|
||||
emailSentId: string
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const original = await prisma.emailSent.findUnique({
|
||||
where: { id: emailSentId },
|
||||
});
|
||||
if (!original) return { ok: false, error: "Email tidak ditemukan" };
|
||||
if (!original.html) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
"Body email lama tidak tersimpan — tidak bisa di-resend (dikirim sebelum fitur ini ada).",
|
||||
};
|
||||
}
|
||||
|
||||
const resend = getResend();
|
||||
if (!resend) return { ok: false, error: "RESEND_API_KEY belum di-set" };
|
||||
|
||||
try {
|
||||
const result = await resend.emails.send({
|
||||
from: emailFrom(),
|
||||
to: original.to,
|
||||
subject: original.subject,
|
||||
html: original.html,
|
||||
});
|
||||
if (result.error) throw new Error(result.error.message ?? "Resend failed");
|
||||
await prisma.emailSent.create({
|
||||
data: {
|
||||
idempotencyKey: `${original.idempotencyKey}#resend-${Date.now()}`,
|
||||
to: original.to,
|
||||
template: original.template,
|
||||
subject: original.subject,
|
||||
html: original.html,
|
||||
providerMessageId: result.data?.id ?? null,
|
||||
},
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
async function enqueueJob(
|
||||
|
||||
+272
-1
@@ -301,6 +301,244 @@ function accountSuspended(d: AccountSuspendedData) {
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PR-E3 — Phase 2: notifikasi event (join, trip dibatalkan, payout, KYC)
|
||||
// ============================================================================
|
||||
|
||||
/** Blok refund untuk email pembatalan trip. Pesan beda kalau tidak ada refund. */
|
||||
function refundBlock(refundAmount: number): string {
|
||||
if (refundAmount <= 0) {
|
||||
return `<p style="margin:0 0 12px;font-size:14px;">Booking kamu untuk trip ini tidak punya pembayaran yang perlu dikembalikan.</p>`;
|
||||
}
|
||||
return `<div style="background:#f0fdf4;border-left:4px solid ${EMERALD_600};padding:12px 16px;margin:16px 0;">
|
||||
<p style="margin:0;font-weight:600;font-size:13px;">Refund kamu sedang diproses</p>
|
||||
<p style="margin:8px 0 0;font-size:18px;font-weight:bold;color:${EMERALD_600};">${formatRupiah(refundAmount)}</p>
|
||||
<p style="margin:8px 0 0;font-size:13px;">Admin SeTrip akan memproses transfer ke rekening kamu. Kami kabari lagi via email saat refund selesai.</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export interface JoinRequestData {
|
||||
organizerName: string;
|
||||
joinerName: string;
|
||||
tripTitle: string;
|
||||
tripId: string;
|
||||
}
|
||||
function joinRequest(d: JoinRequestData) {
|
||||
return {
|
||||
subject: `👋 ${d.joinerName} mau gabung trip "${d.tripTitle}"`,
|
||||
html: shell({
|
||||
title: "Join Request",
|
||||
bodyHtml: `
|
||||
<h1 style="margin:0 0 16px;font-size:22px;">Ada yang mau gabung trip kamu</h1>
|
||||
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.organizerName)}, <strong>${escapeHtml(d.joinerName)}</strong> mengajukan diri untuk ikut trip <strong>${escapeHtml(d.tripTitle)}</strong>.</p>
|
||||
<p style="margin:0 0 12px;font-size:14px;">Buka halaman trip untuk meninjau profil peserta lalu setujui atau tolak permintaannya.</p>
|
||||
`,
|
||||
ctaLabel: "Tinjau Permintaan",
|
||||
ctaUrl: `${siteUrl}/trips/${d.tripId}`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface JoinRejectedData {
|
||||
userName: string;
|
||||
tripTitle: string;
|
||||
}
|
||||
function joinRejected(d: JoinRejectedData) {
|
||||
return {
|
||||
subject: `Update permintaan gabung trip "${d.tripTitle}"`,
|
||||
html: shell({
|
||||
title: "Join Rejected",
|
||||
bodyHtml: `
|
||||
<h1 style="margin:0 0 16px;font-size:22px;">Permintaan gabung belum diterima</h1>
|
||||
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.userName)}, organizer belum bisa menerima permintaan kamu untuk ikut trip <strong>${escapeHtml(d.tripTitle)}</strong> kali ini.</p>
|
||||
<p style="margin:0 0 12px;font-size:14px;">Jangan berkecil hati — masih banyak trip seru lain yang bisa kamu ikuti.</p>
|
||||
`,
|
||||
ctaLabel: "Cari Trip Lain",
|
||||
ctaUrl: `${siteUrl}/trips`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface PaymentExpiredData {
|
||||
userName: string;
|
||||
tripTitle: string;
|
||||
tripId: string;
|
||||
amount: number;
|
||||
}
|
||||
function paymentExpired(d: PaymentExpiredData) {
|
||||
return {
|
||||
subject: `⏰ Pembayaran trip "${d.tripTitle}" belum selesai`,
|
||||
html: shell({
|
||||
title: "Payment Expired",
|
||||
bodyHtml: `
|
||||
<h1 style="margin:0 0 16px;font-size:22px;color:${AMBER_600};">Pembayaran belum selesai</h1>
|
||||
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.userName)}, pembayaran kamu untuk trip <strong>${escapeHtml(d.tripTitle)}</strong> sebesar <strong>${formatRupiah(d.amount)}</strong> kadaluarsa atau gagal — slot kamu belum aman.</p>
|
||||
<p style="margin:0 0 12px;font-size:14px;">Selama trip belum penuh dan belum berangkat, kamu masih bisa mengulang pembayaran.</p>
|
||||
`,
|
||||
ctaLabel: "Coba Bayar Lagi",
|
||||
ctaUrl: `${siteUrl}/trips/${d.tripId}/payment`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface TripCancelledOrganizerData {
|
||||
userName: string;
|
||||
tripTitle: string;
|
||||
refundAmount: number;
|
||||
}
|
||||
function tripCancelledOrganizer(d: TripCancelledOrganizerData) {
|
||||
return {
|
||||
subject: `❌ Trip "${d.tripTitle}" dibatalkan`,
|
||||
html: shell({
|
||||
title: "Trip Cancelled",
|
||||
bodyHtml: `
|
||||
<h1 style="margin:0 0 16px;font-size:22px;color:${RED_600};">Trip dibatalkan</h1>
|
||||
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.userName)}, mohon maaf — organizer membatalkan trip <strong>${escapeHtml(d.tripTitle)}</strong>. Partisipasi kamu di trip ini otomatis dibatalkan.</p>
|
||||
${refundBlock(d.refundAmount)}
|
||||
`,
|
||||
ctaLabel: "Cari Trip Lain",
|
||||
ctaUrl: `${siteUrl}/trips`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface TripCancelledAdminData {
|
||||
userName: string;
|
||||
tripTitle: string;
|
||||
reason: string;
|
||||
refundAmount: number;
|
||||
}
|
||||
function tripCancelledAdmin(d: TripCancelledAdminData) {
|
||||
return {
|
||||
subject: `❌ Trip "${d.tripTitle}" dibatalkan SeTrip`,
|
||||
html: shell({
|
||||
title: "Trip Cancelled by Admin",
|
||||
bodyHtml: `
|
||||
<h1 style="margin:0 0 16px;font-size:22px;color:${RED_600};">Trip dibatalkan SeTrip</h1>
|
||||
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.userName)}, trip <strong>${escapeHtml(d.tripTitle)}</strong> dibatalkan oleh tim admin SeTrip.</p>
|
||||
<div style="background:#fef2f2;border-left:4px solid ${RED_600};padding:12px 16px;margin:16px 0;">
|
||||
<p style="margin:0;font-weight:600;font-size:13px;">Alasan:</p>
|
||||
<p style="margin:8px 0 0;font-size:14px;">${escapeHtml(d.reason)}</p>
|
||||
</div>
|
||||
${refundBlock(d.refundAmount)}
|
||||
`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface PayoutReleasedData {
|
||||
organizerName: string;
|
||||
tripTitle: string;
|
||||
amount: number;
|
||||
}
|
||||
function payoutReleased(d: PayoutReleasedData) {
|
||||
return {
|
||||
subject: `💰 Payout trip "${d.tripTitle}" masuk antrian transfer`,
|
||||
html: shell({
|
||||
title: "Payout Released",
|
||||
bodyHtml: `
|
||||
<h1 style="margin:0 0 16px;font-size:22px;color:${EMERALD_600};">Payout siap ditransfer</h1>
|
||||
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.organizerName)}, masa tahan (escrow) untuk trip <strong>${escapeHtml(d.tripTitle)}</strong> sudah berakhir.</p>
|
||||
<p style="margin:0 0 12px;font-size:24px;font-weight:bold;color:${EMERALD_600};">${formatRupiah(d.amount)}</p>
|
||||
<p style="margin:0;font-size:14px;">Dana ini masuk antrian transfer — admin SeTrip akan memprosesnya ke rekening kamu. Kami kabari lagi saat sudah ditransfer.</p>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface PayoutPaidData {
|
||||
organizerName: string;
|
||||
tripTitle: string;
|
||||
amount: number;
|
||||
adminNote: string;
|
||||
}
|
||||
function payoutPaid(d: PayoutPaidData) {
|
||||
return {
|
||||
subject: `✅ Payout ${formatRupiah(d.amount)} sudah ditransfer`,
|
||||
html: shell({
|
||||
title: "Payout Paid",
|
||||
bodyHtml: `
|
||||
<h1 style="margin:0 0 16px;font-size:22px;color:${EMERALD_600};">Payout sudah ditransfer</h1>
|
||||
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.organizerName)}, payout untuk trip <strong>${escapeHtml(d.tripTitle)}</strong> sudah kami transfer.</p>
|
||||
<p style="margin:0 0 12px;font-size:24px;font-weight:bold;color:${EMERALD_600};">${formatRupiah(d.amount)}</p>
|
||||
${d.adminNote ? `<div style="background:#f0fdf4;border-left:4px solid ${EMERALD_600};padding:12px 16px;margin:16px 0;"><p style="margin:0;font-weight:600;font-size:13px;">Catatan / referensi transfer:</p><p style="margin:4px 0 0;font-size:14px;">${escapeHtml(d.adminNote)}</p></div>` : ""}
|
||||
<p style="margin:0;font-size:14px;">Cek rekening kamu — biasanya masuk dalam 1×24 jam tergantung bank.</p>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface AccountUnsuspendedData {
|
||||
userName: string;
|
||||
}
|
||||
function accountUnsuspended(d: AccountUnsuspendedData) {
|
||||
return {
|
||||
subject: "✅ Akun SeTrip kamu aktif kembali",
|
||||
html: shell({
|
||||
title: "Account Unsuspended",
|
||||
bodyHtml: `
|
||||
<h1 style="margin:0 0 16px;font-size:22px;color:${EMERALD_600};">Akun aktif kembali</h1>
|
||||
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.userName)}, penangguhan akun SeTrip kamu sudah dicabut. Kamu bisa login dan beraktivitas seperti biasa lagi.</p>
|
||||
`,
|
||||
ctaLabel: "Buka SeTrip",
|
||||
ctaUrl: siteUrl,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface KycSubmittedData {
|
||||
userName: string;
|
||||
}
|
||||
function kycSubmitted(d: KycSubmittedData) {
|
||||
return {
|
||||
subject: "📋 Pengajuan verifikasi organizer kamu sudah masuk",
|
||||
html: shell({
|
||||
title: "KYC Submitted",
|
||||
bodyHtml: `
|
||||
<h1 style="margin:0 0 16px;font-size:22px;">Pengajuan diterima</h1>
|
||||
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.userName)}, pengajuan verifikasi organizer kamu sudah masuk dan sedang antri di-review tim admin.</p>
|
||||
<p style="margin:0 0 12px;font-size:14px;">Estimasi review <strong>1–3 hari kerja</strong>. Hasilnya kami kabari lewat email — tidak perlu submit ulang selama belum ada keputusan.</p>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface KycManualOverrideData {
|
||||
userName: string;
|
||||
}
|
||||
function kycManualOverride(d: KycManualOverrideData) {
|
||||
return {
|
||||
subject: "✅ Kamu jadi organizer terverifikasi SeTrip",
|
||||
html: shell({
|
||||
title: "KYC Manual Override",
|
||||
bodyHtml: `
|
||||
<h1 style="margin:0 0 16px;font-size:22px;color:${EMERALD_600};">🎉 Selamat ${escapeHtml(d.userName)}!</h1>
|
||||
<p style="margin:0 0 12px;">Tim admin SeTrip sudah memverifikasi kamu sebagai organizer. Mulai sekarang kamu bisa membuat trip berbayar dan menerima payout.</p>
|
||||
`,
|
||||
ctaLabel: "Buat Trip Sekarang",
|
||||
ctaUrl: `${siteUrl}/create-trip`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface KycReopenedData {
|
||||
userName: string;
|
||||
}
|
||||
function kycReopened(d: KycReopenedData) {
|
||||
return {
|
||||
subject: "🔄 Pengajuan verifikasi kamu dibuka kembali",
|
||||
html: shell({
|
||||
title: "KYC Reopened",
|
||||
bodyHtml: `
|
||||
<h1 style="margin:0 0 16px;font-size:22px;color:${AMBER_600};">Pengajuan dibuka kembali</h1>
|
||||
<p style="margin:0 0 12px;">Halo ${escapeHtml(d.userName)}, tim admin membuka kembali pengajuan verifikasi organizer kamu. Kamu bisa memperbaiki data lalu mengajukannya ulang.</p>
|
||||
`,
|
||||
ctaLabel: "Buka Halaman Verifikasi",
|
||||
ctaUrl: `${siteUrl}/verify`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Registry — discriminated union supaya type-safe per template.
|
||||
// ============================================================================
|
||||
@@ -314,7 +552,18 @@ export type EmailTemplate =
|
||||
| { template: "refund_failed"; data: RefundFailedData }
|
||||
| { template: "payment_paid"; data: PaymentPaidData }
|
||||
| { template: "booking_approved"; data: BookingApprovedData }
|
||||
| { template: "account_suspended"; data: AccountSuspendedData };
|
||||
| { template: "account_suspended"; data: AccountSuspendedData }
|
||||
| { template: "join_request"; data: JoinRequestData }
|
||||
| { template: "join_rejected"; data: JoinRejectedData }
|
||||
| { template: "payment_expired"; data: PaymentExpiredData }
|
||||
| { template: "trip_cancelled_organizer"; data: TripCancelledOrganizerData }
|
||||
| { template: "trip_cancelled_admin"; data: TripCancelledAdminData }
|
||||
| { template: "payout_released"; data: PayoutReleasedData }
|
||||
| { template: "payout_paid"; data: PayoutPaidData }
|
||||
| { template: "account_unsuspended"; data: AccountUnsuspendedData }
|
||||
| { template: "kyc_submitted"; data: KycSubmittedData }
|
||||
| { template: "kyc_manual_override"; data: KycManualOverrideData }
|
||||
| { template: "kyc_reopened"; data: KycReopenedData };
|
||||
|
||||
export function renderEmail(input: EmailTemplate): {
|
||||
subject: string;
|
||||
@@ -339,6 +588,28 @@ export function renderEmail(input: EmailTemplate): {
|
||||
return bookingApproved(input.data);
|
||||
case "account_suspended":
|
||||
return accountSuspended(input.data);
|
||||
case "join_request":
|
||||
return joinRequest(input.data);
|
||||
case "join_rejected":
|
||||
return joinRejected(input.data);
|
||||
case "payment_expired":
|
||||
return paymentExpired(input.data);
|
||||
case "trip_cancelled_organizer":
|
||||
return tripCancelledOrganizer(input.data);
|
||||
case "trip_cancelled_admin":
|
||||
return tripCancelledAdmin(input.data);
|
||||
case "payout_released":
|
||||
return payoutReleased(input.data);
|
||||
case "payout_paid":
|
||||
return payoutPaid(input.data);
|
||||
case "account_unsuspended":
|
||||
return accountUnsuspended(input.data);
|
||||
case "kyc_submitted":
|
||||
return kycSubmitted(input.data);
|
||||
case "kyc_manual_override":
|
||||
return kycManualOverride(input.data);
|
||||
case "kyc_reopened":
|
||||
return kycReopened(input.data);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user