import { Prisma } from "@/app/generated/prisma/client"; import { prisma } from "@/lib/prisma"; import { organizerRepo } from "@/server/repositories/organizer.repo"; import { decryptString, encryptString, hmacHex } from "@/lib/crypto"; /** Field-field yang admin boleh minta upload ulang (Phase 2). */ export const REUPLOAD_FIELDS = [ "ktpImage", "liveness", "nik", "bankInfo", "address", ] as const; export type ReuploadField = (typeof REUPLOAD_FIELDS)[number]; export function isReuploadField(value: string): value is ReuploadField { return (REUPLOAD_FIELDS as readonly string[]).includes(value); } /** * Build array history rejection — append rejection sekarang (kalau ada) ke * `previousRejections` lama. Dipanggil saat organizer submit ulang. */ type RejectionEntry = { at: string; reason: string; submission: number }; function buildArchivedRejections( existing: | { status: string; rejectionReason: string | null; reviewedAt: Date | null; submissionCount: number; previousRejections: Prisma.JsonValue | null; } | null ): RejectionEntry[] { if (!existing) return []; const prior = Array.isArray(existing.previousRejections) ? (existing.previousRejections as unknown as RejectionEntry[]) : []; if ( existing.status === "REJECTED" && existing.rejectionReason && existing.reviewedAt ) { return [ ...prior, { at: existing.reviewedAt.toISOString(), reason: existing.rejectionReason, submission: existing.submissionCount, }, ]; } return prior; } type SubmitInput = { fullName: string; nik: string; birthDate: Date; address: string; ktpImageKey: string; livenessKey: string; bankName: string; bankAccountNumber: string; bankAccountName: string; }; export const organizerService = { async submitVerification(userId: string, data: SubmitInput) { const existing = await organizerRepo.findByUserId(userId); if (existing && existing.status === "APPROVED") { throw new Error("Akun kamu sudah terverifikasi"); } // Phase 2: kalau status PENDING dengan reuploadRequested = true, allow // submit ulang (re-upload flow). Kalau PENDING biasa (belum di-review), // tolak supaya tidak overwrite submission yang sedang antri. if ( existing && existing.status === "PENDING" && !existing.reuploadRequested ) { throw new Error("Pengajuan kamu masih dalam proses review"); } const nikHash = hmacHex(data.nik); const dupNik = await organizerRepo.findByNikHash(nikHash); if (dupNik && dupNik.userId !== userId) { throw new Error("NIK ini sudah dipakai akun lain"); } // Phase 3: bump submission count + arsip rejection lama kalau ada. const nextCount = existing ? existing.submissionCount + 1 : 1; const archivedRejections = buildArchivedRejections(existing); return organizerRepo.upsertForUser(userId, { fullName: data.fullName, nikEncrypted: encryptString(data.nik), nikHash, birthDate: data.birthDate, address: data.address, ktpImageKey: data.ktpImageKey, livenessKey: data.livenessKey, bankName: data.bankName, bankAccountNumber: data.bankAccountNumber, bankAccountName: data.bankAccountName, status: "PENDING", rejectionReason: null, reviewedAt: null, reviewedById: null, verifiedAt: null, reuploadRequested: false, reuploadFields: [], reuploadNote: null, submissionCount: nextCount, previousRejections: archivedRejections, isManualOverride: false, manualOverrideById: null, manualOverrideNote: null, }); }, async reviewVerification(input: { verificationId: string; decision: "APPROVED" | "REJECTED"; rejectionReason?: string; reviewerId: string; }) { const verification = await organizerRepo.findById(input.verificationId); if (!verification) { throw new Error("Pengajuan tidak ditemukan"); } if (verification.status !== "PENDING") { throw new Error("Pengajuan ini sudah diproses"); } return organizerRepo.updateReview(input.verificationId, { status: input.decision, rejectionReason: input.decision === "REJECTED" ? input.rejectionReason ?? null : null, reviewedById: input.reviewerId, }); }, /** Append rejection reason ke `previousRejections` JSON array — dipakai * saat organizer submit ulang (Phase 3 history). */ async _archiveCurrentRejection(verificationId: string) { const v = await organizerRepo.findById(verificationId); if (!v || v.status !== "REJECTED" || !v.rejectionReason) return; const archived = buildArchivedRejections(v); await prisma.organizerVerification.update({ where: { id: verificationId }, data: { previousRejections: archived as Prisma.InputJsonValue }, }); }, async getStatusForUser(userId: string) { return organizerRepo.findByUserId(userId); }, async isApproved(userId: string) { const v = await organizerRepo.findByUserId(userId); return v?.status === "APPROVED"; }, /** * Phase 2: admin minta organizer upload ulang field tertentu (KTP buram, * liveness gelap, dst). Set flag tanpa drop submission — organizer melihat * banner kuning di /verify dan submit ulang dengan auto-clear flag. */ async requestReupload(input: { verificationId: string; adminId: string; fields: ReuploadField[]; note: string; }) { const verification = await organizerRepo.findById(input.verificationId); if (!verification) { throw new Error("Pengajuan tidak ditemukan"); } if (verification.status === "APPROVED") { throw new Error("Verifikasi sudah disetujui — tidak perlu re-upload"); } if (input.fields.length === 0) { throw new Error("Pilih minimal 1 field yang perlu di-upload ulang"); } const invalid = input.fields.filter((f) => !isReuploadField(f)); if (invalid.length > 0) { throw new Error(`Field tidak valid: ${invalid.join(", ")}`); } const trimmedNote = input.note.trim(); if (trimmedNote.length < 10) { throw new Error("Catatan re-upload wajib min 10 karakter"); } if (trimmedNote.length > 500) { throw new Error("Catatan re-upload maksimal 500 karakter"); } return organizerRepo.requestReupload(input.verificationId, { fields: input.fields, note: trimmedNote, }); }, /** * Phase 4: admin verify organizer tanpa upload KYC (mis. partner trusted * referral). Buat row APPROVED dengan flag `isManualOverride = true` supaya * audit trail jelas — visible di admin UI dan tidak campur dengan KYC normal. */ async manualOverrideVerification(input: { userId: string; adminId: string; note: string; bankName: string; bankAccountNumber: string; bankAccountName: string; }) { const existing = await organizerRepo.findByUserId(input.userId); if (existing) { throw new Error( "User sudah punya pengajuan verifikasi — pakai approve biasa atau reopen REJECTED." ); } const trimmedNote = input.note.trim(); if (trimmedNote.length < 10) { throw new Error( "Catatan manual override wajib min 10 karakter (alasan + ref partnership)" ); } const now = new Date(); // Placeholder NIK encrypted — tetap valid format tapi marker khusus. // Catatan: tidak ada NIK real, jadi nikHash juga placeholder unik per user // (pakai user.id sebagai HMAC input supaya tidak collide dengan NIK real). const placeholderNik = `OVERRIDE-${input.userId}`; return organizerRepo.createManualOverride({ userId: input.userId, adminId: input.adminId, note: trimmedNote, fullName: input.bankAccountName, nikEncrypted: encryptString(placeholderNik), nikHash: hmacHex(placeholderNik), bankName: input.bankName, bankAccountNumber: input.bankAccountNumber, bankAccountName: input.bankAccountName, verifiedAt: now, }); }, /** * Buka kembali verifikasi yang REJECTED ke PENDING. Dipakai admin saat * organizer kirim foto/data baru via email/WA dan ingin di-review ulang * tanpa drop & recreate row. * * - `rejectionReason` lama disimpan di field — kalau di-reject lagi nanti, * field-nya di-overwrite (audit trail via auditLog Phase 4 di roadmap). * - Reset `reviewedById`, `reviewedAt` supaya muncul lagi di tab PENDING. */ async reopenVerification(input: { verificationId: string; adminId: string; note: string; }) { const verification = await organizerRepo.findById(input.verificationId); if (!verification) { throw new Error("Pengajuan tidak ditemukan"); } if (verification.status !== "REJECTED") { throw new Error("Hanya pengajuan REJECTED yang bisa dibuka kembali"); } const trimmedNote = input.note.trim(); if (trimmedNote.length < 10) { throw new Error("Catatan reopen wajib min 10 karakter untuk audit"); } if (trimmedNote.length > 500) { throw new Error("Catatan reopen maksimal 500 karakter"); } return organizerRepo.reopen(input.verificationId, trimmedNote); }, /** Reveal NIK plaintext. Caller must enforce authorization (owner or admin). */ decryptNik(nikEncrypted: string): string { return decryptString(nikEncrypted); }, };