fix email sender all flow
This commit is contained in:
@@ -58,6 +58,21 @@ async function notifySuspended(userId: string, reason: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/** E3.8 — kabari user kalau penangguhan akunnya sudah dicabut. */
|
||||
async function notifyUnsuspended(userId: string) {
|
||||
const target = await userRepo.findById(userId);
|
||||
if (!target) return;
|
||||
await emailService.send({
|
||||
to: target.email,
|
||||
// Sertakan timestamp supaya tiap siklus suspend→unsuspend dapat email baru.
|
||||
idempotencyKey: `account_unsuspended-${userId}-${Date.now()}`,
|
||||
template: {
|
||||
template: "account_unsuspended",
|
||||
data: { userName: target.name },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function unsuspendUserAction(userId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
@@ -75,6 +90,10 @@ export async function unsuspendUserAction(userId: string) {
|
||||
entityType: "User",
|
||||
entityId: userId,
|
||||
});
|
||||
|
||||
// Notif email user — kabari akun sudah aktif kembali.
|
||||
void notifyUnsuspended(userId);
|
||||
|
||||
revalidatePath("/admin/users");
|
||||
revalidatePath(`/admin/users/${userId}`);
|
||||
return { success: true as const };
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"use server";
|
||||
|
||||
import { getServerSession } from "next-auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { isAdminEmail } from "@/lib/admin";
|
||||
import { emailService } from "@/lib/email/send";
|
||||
import { auditLog } from "@/server/services/audit-log.service";
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user || !isAdminEmail(session.user.email)) {
|
||||
return null;
|
||||
}
|
||||
return session.user;
|
||||
}
|
||||
|
||||
/** E5.2 — admin retry satu EmailJob yang gagal/antri, kirim ulang langsung. */
|
||||
export async function retryEmailJobAction(jobId: string) {
|
||||
const admin = await requireAdmin();
|
||||
if (!admin) return { error: "Tidak memiliki akses admin" };
|
||||
if (!jobId) return { error: "jobId tidak valid" };
|
||||
|
||||
const result = await emailService.retryJob(jobId);
|
||||
if (!result.ok) {
|
||||
return { error: result.error ?? "Gagal mengirim ulang email" };
|
||||
}
|
||||
await auditLog.record({
|
||||
admin: { id: admin.id, email: admin.email },
|
||||
action: "EMAIL_JOB_RETRY",
|
||||
entityType: "EmailJob",
|
||||
entityId: jobId,
|
||||
});
|
||||
revalidatePath("/admin/emails");
|
||||
revalidatePath("/admin/system");
|
||||
return { success: true as const };
|
||||
}
|
||||
|
||||
/** E5.3 — admin resend email yang sudah pernah terkirim (mis. user lapor tidak terima). */
|
||||
export async function resendEmailAction(emailSentId: string) {
|
||||
const admin = await requireAdmin();
|
||||
if (!admin) return { error: "Tidak memiliki akses admin" };
|
||||
if (!emailSentId) return { error: "emailSentId tidak valid" };
|
||||
|
||||
const result = await emailService.resendEmail(emailSentId);
|
||||
if (!result.ok) {
|
||||
return { error: result.error ?? "Gagal mengirim ulang email" };
|
||||
}
|
||||
await auditLog.record({
|
||||
admin: { id: admin.id, email: admin.email },
|
||||
action: "EMAIL_RESEND",
|
||||
entityType: "EmailSent",
|
||||
entityId: emailSentId,
|
||||
});
|
||||
revalidatePath("/admin/emails");
|
||||
return { success: true as const };
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { retryEmailJobAction, resendEmailAction } from "@/features/email/actions";
|
||||
|
||||
const BTN_CLS =
|
||||
"rounded-lg border border-primary-200 bg-primary-50 px-2.5 py-1 text-[11px] font-semibold text-primary-700 transition-colors hover:bg-primary-100 disabled:cursor-not-allowed disabled:opacity-50";
|
||||
|
||||
/** E5.2 — tombol kirim ulang untuk satu EmailJob (antri / gagal). */
|
||||
export function RetryEmailButton({ jobId }: { jobId: string }) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleRetry() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const res = await retryEmailJobAction(jobId);
|
||||
setLoading(false);
|
||||
if ("error" in res) {
|
||||
setError(res.error ?? "Gagal");
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRetry}
|
||||
disabled={loading}
|
||||
className={BTN_CLS}
|
||||
>
|
||||
{loading ? "Mengirim…" : "Kirim ulang"}
|
||||
</button>
|
||||
{error && (
|
||||
<p className="mt-1 max-w-[200px] text-[10px] text-red-600">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** E5.3 — tombol resend untuk email yang sudah terkirim. */
|
||||
export function ResendEmailButton({
|
||||
emailSentId,
|
||||
disabled,
|
||||
}: {
|
||||
emailSentId: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
async function handleResend() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const res = await resendEmailAction(emailSentId);
|
||||
setLoading(false);
|
||||
if ("error" in res) {
|
||||
setError(res.error ?? "Gagal");
|
||||
return;
|
||||
}
|
||||
setDone(true);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<span
|
||||
className="text-[10px] text-neutral-400"
|
||||
title="Body email lama tidak tersimpan"
|
||||
>
|
||||
—
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResend}
|
||||
disabled={loading || done}
|
||||
className={BTN_CLS}
|
||||
>
|
||||
{loading ? "Mengirim…" : done ? "✓ Terkirim" : "Resend"}
|
||||
</button>
|
||||
{error && (
|
||||
<p className="mt-1 max-w-[200px] text-[10px] text-red-600">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { auditLog } from "@/server/services/audit-log.service";
|
||||
import { emailService } from "@/lib/email/send";
|
||||
import { organizerRepo } from "@/server/repositories/organizer.repo";
|
||||
import { userRepo } from "@/server/repositories/user.repo";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { submitVerificationSchema, reviewVerificationSchema } from "./schemas";
|
||||
|
||||
export async function submitVerificationAction(formData: FormData) {
|
||||
@@ -43,6 +44,7 @@ export async function submitVerificationAction(formData: FormData) {
|
||||
...result.data,
|
||||
birthDate: new Date(result.data.birthDate),
|
||||
});
|
||||
void notifyKycSubmitted(session.user.id);
|
||||
revalidatePath("/verify");
|
||||
revalidatePath("/profile");
|
||||
revalidatePath("/admin/verifications");
|
||||
@@ -137,6 +139,54 @@ async function notifyVerificationDecision(
|
||||
}
|
||||
}
|
||||
|
||||
/** E3.9 — kabari user kalau pengajuan verifikasi-nya sudah masuk antrian. */
|
||||
async function notifyKycSubmitted(userId: string) {
|
||||
const verification = await prisma.organizerVerification.findUnique({
|
||||
where: { userId },
|
||||
select: { submissionCount: true },
|
||||
});
|
||||
const user = await userRepo.findById(userId);
|
||||
if (!verification || !user) return;
|
||||
await emailService.send({
|
||||
to: user.email,
|
||||
idempotencyKey: `kyc_submitted-${userId}-${verification.submissionCount}`,
|
||||
template: {
|
||||
template: "kyc_submitted",
|
||||
data: { userName: user.name },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** E3.11 — kabari user kalau pengajuan verifikasi-nya dibuka kembali admin. */
|
||||
async function notifyKycReopened(verificationId: string) {
|
||||
const verification = await organizerRepo.findById(verificationId);
|
||||
if (!verification) return;
|
||||
const user = await userRepo.findById(verification.userId);
|
||||
if (!user) return;
|
||||
await emailService.send({
|
||||
to: user.email,
|
||||
idempotencyKey: `kyc_reopened-${verificationId}-${verification.submissionCount}`,
|
||||
template: {
|
||||
template: "kyc_reopened",
|
||||
data: { userName: user.name },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** E3.10 — kabari user kalau admin verifikasi dia secara manual override. */
|
||||
async function notifyKycManualOverride(userId: string, verificationId: string) {
|
||||
const user = await userRepo.findById(userId);
|
||||
if (!user) return;
|
||||
await emailService.send({
|
||||
to: user.email,
|
||||
idempotencyKey: `kyc_manual_override-${verificationId}`,
|
||||
template: {
|
||||
template: "kyc_manual_override",
|
||||
data: { userName: user.name },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin reopen pengajuan REJECTED ke PENDING — supaya organizer bisa
|
||||
* di-review ulang tanpa drop & recreate row. Note wajib min 10 char untuk audit.
|
||||
@@ -156,6 +206,7 @@ export async function reopenVerificationAction(
|
||||
adminId: session.user.id,
|
||||
note,
|
||||
});
|
||||
void notifyKycReopened(verificationId);
|
||||
await auditLog.record({
|
||||
admin: { id: session.user.id, email: session.user.email },
|
||||
action: "VERIFICATION_REOPEN",
|
||||
@@ -262,6 +313,7 @@ export async function manualOverrideVerificationAction(input: {
|
||||
bankAccountNumber: input.bankAccountNumber,
|
||||
bankAccountName: input.bankAccountName,
|
||||
});
|
||||
void notifyKycManualOverride(input.userId, result.id);
|
||||
await auditLog.record({
|
||||
admin: { id: session.user.id, email: session.user.email },
|
||||
action: "VERIFICATION_MANUAL_OVERRIDE",
|
||||
|
||||
@@ -5,7 +5,9 @@ import { revalidatePath } from "next/cache";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { isAdminEmail } from "@/lib/admin";
|
||||
import { payoutService } from "@/server/services/payout.service";
|
||||
import { payoutRepo } from "@/server/repositories/payout.repo";
|
||||
import { auditLog } from "@/server/services/audit-log.service";
|
||||
import { emailService } from "@/lib/email/send";
|
||||
import { payoutMarkPaidSchema } from "./schemas";
|
||||
|
||||
async function requireAdmin() {
|
||||
@@ -34,6 +36,7 @@ export async function markPayoutPaidAction(formData: FormData) {
|
||||
adminId: admin.id,
|
||||
adminNote: parsed.data.adminNote,
|
||||
});
|
||||
void notifyPayoutPaid(parsed.data.payoutId, parsed.data.adminNote);
|
||||
await auditLog.record({
|
||||
admin: { id: admin.id, email: admin.email },
|
||||
action: "PAYOUT_MARK_PAID",
|
||||
@@ -49,3 +52,22 @@ export async function markPayoutPaidAction(formData: FormData) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/** E3.7 — kabari organizer kalau payout-nya sudah ditransfer admin. */
|
||||
async function notifyPayoutPaid(payoutId: string, adminNote: string) {
|
||||
const payout = await payoutRepo.findById(payoutId);
|
||||
if (!payout) return;
|
||||
await emailService.send({
|
||||
to: payout.organizer.email,
|
||||
idempotencyKey: `payout_paid-${payout.id}`,
|
||||
template: {
|
||||
template: "payout_paid",
|
||||
data: {
|
||||
organizerName: payout.organizer.name,
|
||||
tripTitle: payout.trip.title,
|
||||
amount: payout.amount,
|
||||
adminNote,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -131,6 +131,7 @@ export async function joinTripAction(tripId: string) {
|
||||
try {
|
||||
await requireActiveUser(session.user.id);
|
||||
await tripService.joinTrip(tripId, session.user.id);
|
||||
void notifyJoinRequest(tripId, session.user.id);
|
||||
revalidatePath(`/trips/${tripId}`);
|
||||
revalidatePath("/trips");
|
||||
revalidatePath("/");
|
||||
@@ -212,6 +213,60 @@ async function notifyBookingApproved(participantId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/** E3.1 — kabari organizer ada peserta baru yang mengajukan join. */
|
||||
async function notifyJoinRequest(tripId: string, joinerId: string) {
|
||||
const [trip, joiner] = await Promise.all([
|
||||
prisma.trip.findUnique({
|
||||
where: { id: tripId },
|
||||
select: {
|
||||
title: true,
|
||||
organizer: { select: { email: true, name: true } },
|
||||
},
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: joinerId },
|
||||
select: { name: true },
|
||||
}),
|
||||
]);
|
||||
if (!trip || !joiner) return;
|
||||
await emailService.send({
|
||||
to: trip.organizer.email,
|
||||
idempotencyKey: `join_request-${tripId}-${joinerId}`,
|
||||
template: {
|
||||
template: "join_request",
|
||||
data: {
|
||||
organizerName: trip.organizer.name,
|
||||
joinerName: joiner.name,
|
||||
tripTitle: trip.title,
|
||||
tripId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** E3.2 — kabari peserta kalau permintaan join-nya ditolak organizer. */
|
||||
async function notifyJoinRejected(participantId: string) {
|
||||
const participant = await prisma.tripParticipant.findUnique({
|
||||
where: { id: participantId },
|
||||
select: {
|
||||
user: { select: { email: true, name: true } },
|
||||
trip: { select: { title: true } },
|
||||
},
|
||||
});
|
||||
if (!participant) return;
|
||||
await emailService.send({
|
||||
to: participant.user.email,
|
||||
idempotencyKey: `join_rejected-${participantId}`,
|
||||
template: {
|
||||
template: "join_rejected",
|
||||
data: {
|
||||
userName: participant.user.name,
|
||||
tripTitle: participant.trip.title,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function rejectParticipantAction(
|
||||
tripId: string,
|
||||
participantId: string
|
||||
@@ -227,6 +282,7 @@ export async function rejectParticipantAction(
|
||||
participantId,
|
||||
session.user.id
|
||||
);
|
||||
void notifyJoinRejected(participantId);
|
||||
revalidatePath(`/trips/${tripId}`);
|
||||
revalidatePath("/trips");
|
||||
revalidatePath("/");
|
||||
@@ -237,6 +293,66 @@ export async function rejectParticipantAction(
|
||||
}
|
||||
}
|
||||
|
||||
type CloseTripResult = Awaited<ReturnType<typeof tripService.closeTrip>>;
|
||||
|
||||
/**
|
||||
* E3.4 / E3.5 (+ E2.4) — kabari semua peserta aktif kalau trip dibatalkan.
|
||||
* Email organizer-cancel & admin-cancel beda template; admin-cancel juga
|
||||
* mengabari organizer. Refund block ikut di email (nominal dari `notify`).
|
||||
*/
|
||||
function notifyTripCancelled(
|
||||
tripId: string,
|
||||
notify: CloseTripResult["notify"],
|
||||
actor: { type: "ORGANIZER" } | { type: "ADMIN"; reason: string }
|
||||
) {
|
||||
for (const p of notify.participants) {
|
||||
if (actor.type === "ORGANIZER") {
|
||||
void emailService.send({
|
||||
to: p.email,
|
||||
idempotencyKey: `trip_cancelled_organizer-${tripId}-${p.userId}`,
|
||||
template: {
|
||||
template: "trip_cancelled_organizer",
|
||||
data: {
|
||||
userName: p.name,
|
||||
tripTitle: notify.tripTitle,
|
||||
refundAmount: p.refundAmount,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
void emailService.send({
|
||||
to: p.email,
|
||||
idempotencyKey: `trip_cancelled_admin-${tripId}-${p.userId}`,
|
||||
template: {
|
||||
template: "trip_cancelled_admin",
|
||||
data: {
|
||||
userName: p.name,
|
||||
tripTitle: notify.tripTitle,
|
||||
reason: actor.reason,
|
||||
refundAmount: p.refundAmount,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
// Admin force-cancel → organizer juga dikabari (E3.5).
|
||||
if (actor.type === "ADMIN") {
|
||||
void emailService.send({
|
||||
to: notify.organizer.email,
|
||||
idempotencyKey: `trip_cancelled_admin-${tripId}-organizer`,
|
||||
template: {
|
||||
template: "trip_cancelled_admin",
|
||||
data: {
|
||||
userName: notify.organizer.name,
|
||||
tripTitle: notify.tripTitle,
|
||||
reason: actor.reason,
|
||||
refundAmount: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelTripAction(tripId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
@@ -248,6 +364,7 @@ export async function cancelTripAction(tripId: string) {
|
||||
type: "ORGANIZER",
|
||||
userId: session.user.id,
|
||||
});
|
||||
notifyTripCancelled(tripId, result.notify, { type: "ORGANIZER" });
|
||||
revalidatePath(`/trips/${tripId}`);
|
||||
revalidatePath("/trips");
|
||||
revalidatePath("/");
|
||||
@@ -294,6 +411,10 @@ export async function adminCancelTripAction(tripId: string, reason: string) {
|
||||
adminId: session.user.id,
|
||||
reason: trimmedReason,
|
||||
});
|
||||
notifyTripCancelled(tripId, result.notify, {
|
||||
type: "ADMIN",
|
||||
reason: trimmedReason,
|
||||
});
|
||||
await auditLog.record({
|
||||
admin: { id: session.user.id, email: session.user.email },
|
||||
action: "TRIP_ADMIN_CANCEL",
|
||||
|
||||
Reference in New Issue
Block a user