fix email sender all flow
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Prisma } from "@/app/generated/prisma/client";
|
||||
import type { EmailJobStatus } from "@/app/generated/prisma/enums";
|
||||
|
||||
/** Filter untuk halaman admin email log. Keduanya opsional, match `contains`. */
|
||||
export interface EmailLogFilters {
|
||||
to?: string;
|
||||
template?: string;
|
||||
}
|
||||
|
||||
const LIST_LIMIT = 100;
|
||||
|
||||
function buildWhere<T extends { to?: unknown; template?: unknown }>(
|
||||
filters: EmailLogFilters
|
||||
): T {
|
||||
const where = {} as T;
|
||||
if (filters.to) {
|
||||
(where as { to?: unknown }).to = {
|
||||
contains: filters.to,
|
||||
mode: "insensitive",
|
||||
};
|
||||
}
|
||||
if (filters.template) {
|
||||
(where as { template?: unknown }).template = {
|
||||
contains: filters.template,
|
||||
mode: "insensitive",
|
||||
};
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
export const emailRepo = {
|
||||
/** EmailJob (retry queue) per status — terbaru dulu. */
|
||||
async listJobs(statuses: EmailJobStatus[], filters: EmailLogFilters) {
|
||||
const where = buildWhere<Prisma.EmailJobWhereInput>(filters);
|
||||
where.status = { in: statuses };
|
||||
return prisma.emailJob.findMany({
|
||||
where,
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: LIST_LIMIT,
|
||||
});
|
||||
},
|
||||
|
||||
/** EmailSent (log email berhasil terkirim) — terbaru dulu. */
|
||||
async listSent(filters: EmailLogFilters) {
|
||||
const where = buildWhere<Prisma.EmailSentWhereInput>(filters);
|
||||
return prisma.emailSent.findMany({
|
||||
where,
|
||||
orderBy: { sentAt: "desc" },
|
||||
take: LIST_LIMIT,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Statistik kesehatan pengiriman email — dipakai kartu ringkasan
|
||||
* `/admin/emails` dan `/admin/system`.
|
||||
* - `queued` : job menunggu dikirim (PENDING/PROCESSING).
|
||||
* - `failed24h` : job gagal dalam 24 jam terakhir.
|
||||
* - `deadLetter` : job gagal yang sudah habis 5 attempt — cron berhenti
|
||||
* retry, butuh aksi manual admin.
|
||||
*/
|
||||
async stats() {
|
||||
const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const [queued, failed24h, deadLetter] = await Promise.all([
|
||||
prisma.emailJob.count({
|
||||
where: { status: { in: ["PENDING", "PROCESSING"] } },
|
||||
}),
|
||||
prisma.emailJob.count({
|
||||
where: { status: "FAILED", updatedAt: { gte: since24h } },
|
||||
}),
|
||||
prisma.emailJob.count({
|
||||
where: { status: "FAILED", attempts: { gte: 5 } },
|
||||
}),
|
||||
]);
|
||||
return { queued, failed24h, deadLetter };
|
||||
},
|
||||
};
|
||||
@@ -183,6 +183,10 @@ async function applyGatewayStatus(
|
||||
if (newStatus === "PAID" && !isConflict) {
|
||||
void notifyPaymentPaid(payment.id);
|
||||
}
|
||||
// E3.3 — pembayaran kadaluarsa/gagal: kabari user supaya bisa retry.
|
||||
if (newStatus === "EXPIRED" || newStatus === "FAILED") {
|
||||
void notifyPaymentFailed(payment.id);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
@@ -504,6 +508,35 @@ async function notifyPaymentPaid(paymentId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/** E3.3 — kabari user kalau pembayaran expired/gagal supaya bisa retry. */
|
||||
async function notifyPaymentFailed(paymentId: string) {
|
||||
const payment = await prisma.payment.findUnique({
|
||||
where: { id: paymentId },
|
||||
include: {
|
||||
booking: {
|
||||
include: {
|
||||
user: { select: { email: true, name: true } },
|
||||
trip: { select: { id: true, title: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!payment) return;
|
||||
await emailService.send({
|
||||
to: payment.booking.user.email,
|
||||
idempotencyKey: `payment_expired-${payment.id}`,
|
||||
template: {
|
||||
template: "payment_expired",
|
||||
data: {
|
||||
userName: payment.booking.user.name,
|
||||
tripTitle: payment.booking.trip.title,
|
||||
tripId: payment.booking.trip.id,
|
||||
amount: payment.amount,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Re-export untuk testing langsung kalau perlu (tetap private dari modul lain).
|
||||
export const _internal = { applyGatewayStatus };
|
||||
export type { MidtransTransactionStatus };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Prisma } from "@/app/generated/prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { payoutRepo } from "@/server/repositories/payout.repo";
|
||||
import { emailService } from "@/lib/email/send";
|
||||
|
||||
const SERIAL_TX_ATTEMPTS = 6;
|
||||
|
||||
@@ -134,6 +135,33 @@ export const payoutService = {
|
||||
where: { id: { in: ids }, status: "HELD" },
|
||||
data: { status: "RELEASED", releasedAt: now },
|
||||
});
|
||||
|
||||
// E3.6 — kabari organizer payout-nya sudah lepas hold & masuk antrian
|
||||
// transfer. Pin ke `releasedAt: now` supaya hanya yang baru di-release.
|
||||
const released = await prisma.payout.findMany({
|
||||
where: { id: { in: ids }, status: "RELEASED", releasedAt: now },
|
||||
select: {
|
||||
id: true,
|
||||
amount: true,
|
||||
organizer: { select: { email: true, name: true } },
|
||||
trip: { select: { title: true } },
|
||||
},
|
||||
});
|
||||
for (const p of released) {
|
||||
void emailService.send({
|
||||
to: p.organizer.email,
|
||||
idempotencyKey: `payout_released-${p.id}`,
|
||||
template: {
|
||||
template: "payout_released",
|
||||
data: {
|
||||
organizerName: p.organizer.name,
|
||||
tripTitle: p.trip.title,
|
||||
amount: p.amount,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { releasedIds: ids };
|
||||
},
|
||||
|
||||
|
||||
@@ -458,7 +458,14 @@ export const tripService = {
|
||||
return runSerializable(async (tx) => {
|
||||
const trip = await tx.trip.findUnique({
|
||||
where: { id: tripId },
|
||||
select: { id: true, status: true, organizerId: true, date: true },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
organizerId: true,
|
||||
date: true,
|
||||
title: true,
|
||||
organizer: { select: { id: true, email: true, name: true } },
|
||||
},
|
||||
});
|
||||
if (!trip) {
|
||||
throw new Error("Trip tidak ditemukan");
|
||||
@@ -500,6 +507,8 @@ export const tripService = {
|
||||
const refundsCreated: string[] = [];
|
||||
const cancelledBookings: string[] = [];
|
||||
const skippedBookings: string[] = [];
|
||||
// userId → nominal refund yang dibuat untuk dia (untuk email pembatalan).
|
||||
const refundByUser = new Map<string, number>();
|
||||
|
||||
for (const b of bookings) {
|
||||
if (b.status === "CANCELLED" || b.status === "EXPIRED") {
|
||||
@@ -540,6 +549,7 @@ export const tripService = {
|
||||
}
|
||||
);
|
||||
refundsCreated.push(refund.id);
|
||||
refundByUser.set(b.userId, remaining);
|
||||
|
||||
// Trip dibatalkan → cancel payout HELD/RELEASED organizer untuk
|
||||
// booking ini. Payout PAID di-flag clawback otomatis.
|
||||
@@ -560,6 +570,12 @@ export const tripService = {
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot peserta aktif SEBELUM di-cancel — untuk notifikasi email.
|
||||
const activeParticipants = await tx.tripParticipant.findMany({
|
||||
where: { tripId, status: { not: "CANCELLED" } },
|
||||
select: { user: { select: { id: true, email: true, name: true } } },
|
||||
});
|
||||
|
||||
// Semua participant aktif → CANCELLED (apapun status booking-nya).
|
||||
await tx.tripParticipant.updateMany({
|
||||
where: { tripId, status: { not: "CANCELLED" } },
|
||||
@@ -586,6 +602,17 @@ export const tripService = {
|
||||
refundsCreated,
|
||||
cancelledBookings,
|
||||
skippedBookings,
|
||||
// Data penerima notifikasi — email dikirim oleh action setelah tx commit.
|
||||
notify: {
|
||||
tripTitle: trip.title,
|
||||
organizer: trip.organizer,
|
||||
participants: activeParticipants.map((p) => ({
|
||||
userId: p.user.id,
|
||||
email: p.user.email,
|
||||
name: p.user.name,
|
||||
refundAmount: refundByUser.get(p.user.id) ?? 0,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}, "Gagal membatalkan trip. Coba lagi sebentar.");
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user