admin roadmap done, reupload request, submission history, manual override

This commit is contained in:
2026-05-18 20:25:21 +07:00
parent b844ebdfac
commit bc4973a594
20 changed files with 1254 additions and 121 deletions
+170 -1
View File
@@ -1,6 +1,60 @@
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;
@@ -19,7 +73,14 @@ export const organizerService = {
if (existing && existing.status === "APPROVED") {
throw new Error("Akun kamu sudah terverifikasi");
}
if (existing && existing.status === "PENDING") {
// 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");
}
@@ -29,6 +90,10 @@ export const organizerService = {
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),
@@ -45,6 +110,14 @@ export const organizerService = {
reviewedAt: null,
reviewedById: null,
verifiedAt: null,
reuploadRequested: false,
reuploadFields: [],
reuploadNote: null,
submissionCount: nextCount,
previousRejections: archivedRejections,
isManualOverride: false,
manualOverrideById: null,
manualOverrideNote: null,
});
},
@@ -69,6 +142,18 @@ export const organizerService = {
});
},
/** 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);
},
@@ -78,6 +163,90 @@ export const organizerService = {
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
+72
View File
@@ -0,0 +1,72 @@
import { prisma } from "@/lib/prisma";
const HOUR_MS = 60 * 60 * 1000;
const DAY_MS = 24 * HOUR_MS;
export interface StaleSummary {
/** Payment MIDTRANS status AWAITING > 25 jam (lewat expiresAt) — webhook gagal? */
stalePaymentsCount: number;
/** Booking AWAITING_PAY tapi trip sudah lewat hari ini — peserta lupa bayar. */
awaitingPayPastDepartureCount: number;
/** Payout HELD tapi heldUntil sudah lebih 1 hari lewat — cron release tidak jalan? */
overduePayoutsCount: number;
/** Refund APPROVED > 7 hari belum di-process — admin lupa? */
stuckRefundsCount: number;
}
/**
* Deteksi entity yang nyangkut di state non-final terlalu lama. Dipanggil dari
* `/admin/system` page on-demand (bukan cron) supaya selalu show realtime.
*
* Threshold draft — review setelah jalan 1-2 minggu (false positive vs miss).
*/
export const systemHealthService = {
async detectStale(): Promise<StaleSummary> {
const now = new Date();
const twentyFiveHoursAgo = new Date(now.getTime() - 25 * HOUR_MS);
const oneDayAgo = new Date(now.getTime() - DAY_MS);
const sevenDaysAgo = new Date(now.getTime() - 7 * DAY_MS);
const todayStart = new Date(now);
todayStart.setUTCHours(0, 0, 0, 0);
const [
stalePayments,
awaitingPayPast,
overduePayouts,
stuckRefunds,
] = await Promise.all([
prisma.payment.count({
where: {
provider: "MIDTRANS",
status: "AWAITING",
createdAt: { lte: twentyFiveHoursAgo },
},
}),
prisma.booking.count({
where: {
status: "AWAITING_PAY",
trip: { date: { lt: todayStart } },
},
}),
prisma.payout.count({
where: {
status: "HELD",
heldUntil: { lte: oneDayAgo },
},
}),
prisma.refund.count({
where: {
status: "APPROVED",
reviewedAt: { lte: sevenDaysAgo },
},
}),
]);
return {
stalePaymentsCount: stalePayments,
awaitingPayPastDepartureCount: awaitingPayPast,
overduePayoutsCount: overduePayouts,
stuckRefundsCount: stuckRefunds,
};
},
};