"use server"; import { getServerSession } from "next-auth"; import { revalidatePath } from "next/cache"; import { authOptions } from "@/lib/auth"; import { isAdminEmail } from "@/lib/admin"; import { isReuploadField, organizerService, 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 { prisma } from "@/lib/prisma"; import { submitVerificationSchema, reviewVerificationSchema } from "./schemas"; export async function submitVerificationAction(formData: FormData) { const session = await getServerSession(authOptions); if (!session?.user) { return { error: "Kamu harus login terlebih dahulu" }; } const raw = { fullName: formData.get("fullName") as string, nik: formData.get("nik") as string, birthDate: formData.get("birthDate") as string, address: formData.get("address") as string, ktpImageKey: formData.get("ktpImageKey") as string, livenessKey: formData.get("livenessKey") as string, bankName: formData.get("bankName") as string, bankAccountNumber: formData.get("bankAccountNumber") as string, bankAccountName: formData.get("bankAccountName") as string, }; const result = submitVerificationSchema.safeParse(raw); if (!result.success) { return { error: result.error.issues[0].message }; } try { await organizerService.submitVerification(session.user.id, { ...result.data, birthDate: new Date(result.data.birthDate), }); void notifyKycSubmitted(session.user.id); revalidatePath("/verify"); revalidatePath("/profile"); revalidatePath("/admin/verifications"); return { success: true }; } catch (err) { return { error: (err as Error).message }; } } export async function reviewVerificationAction(formData: FormData) { const session = await getServerSession(authOptions); if (!session?.user || !isAdminEmail(session.user.email)) { return { error: "Tidak memiliki akses admin" }; } const raw = { verificationId: formData.get("verificationId") as string, decision: formData.get("decision") as string, rejectionReason: (formData.get("rejectionReason") as string) || undefined, }; const result = reviewVerificationSchema.safeParse(raw); if (!result.success) { return { error: result.error.issues[0].message }; } try { await organizerService.reviewVerification({ verificationId: result.data.verificationId, decision: result.data.decision, rejectionReason: result.data.rejectionReason, reviewerId: session.user.id, }); await auditLog.record({ admin: { id: session.user.id, email: session.user.email }, action: result.data.decision === "APPROVED" ? "VERIFICATION_APPROVE" : "VERIFICATION_REJECT", entityType: "OrganizerVerification", entityId: result.data.verificationId, payload: result.data.decision === "REJECTED" ? { 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"); return { success: true }; } catch (err) { return { error: (err as Error).message }; } } 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)", }, }, }); } } /** 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. */ export async function reopenVerificationAction( verificationId: string, note: string ) { const session = await getServerSession(authOptions); if (!session?.user || !isAdminEmail(session.user.email)) { return { error: "Tidak memiliki akses admin" }; } try { await organizerService.reopenVerification({ verificationId, adminId: session.user.id, note, }); void notifyKycReopened(verificationId); await auditLog.record({ admin: { id: session.user.id, email: session.user.email }, action: "VERIFICATION_REOPEN", entityType: "OrganizerVerification", entityId: verificationId, payload: { note: note.trim() }, }); revalidatePath("/admin/verifications"); revalidatePath("/verify"); revalidatePath("/profile"); return { success: true as const }; } catch (err) { return { error: (err as Error).message }; } } /** * Phase 2: admin minta organizer upload ulang field tertentu — daripada * reject penuh, set flag `reuploadRequested` + daftar field + note. */ export async function requestReuploadAction( verificationId: string, fields: string[], note: string ) { const session = await getServerSession(authOptions); if (!session?.user || !isAdminEmail(session.user.email)) { return { error: "Tidak memiliki akses admin" }; } const valid = fields.filter(isReuploadField) as ReuploadField[]; try { await organizerService.requestReupload({ verificationId, adminId: session.user.id, fields: valid, note, }); await auditLog.record({ admin: { id: session.user.id, email: session.user.email }, action: "VERIFICATION_REQUEST_REUPLOAD", entityType: "OrganizerVerification", 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 }; } catch (err) { return { error: (err as Error).message }; } } 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`. */ export async function manualOverrideVerificationAction(input: { userId: string; note: string; bankName: string; bankAccountNumber: string; bankAccountName: string; }) { const session = await getServerSession(authOptions); if (!session?.user || !isAdminEmail(session.user.email)) { return { error: "Tidak memiliki akses admin" }; } try { const result = await organizerService.manualOverrideVerification({ userId: input.userId, adminId: session.user.id, note: input.note, bankName: input.bankName, 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", entityType: "OrganizerVerification", entityId: result.id, payload: { userId: input.userId, note: input.note.trim() }, }); revalidatePath("/admin/verifications"); revalidatePath(`/admin/users/${input.userId}`); revalidatePath("/verify"); return { success: true as const, verificationId: result.id }; } catch (err) { return { error: (err as Error).message }; } }