Files
setrip/server/services/organizer.service.ts
T

287 lines
9.2 KiB
TypeScript

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);
},
};