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
+63
View File
@@ -73,6 +73,69 @@ export const organizerRepo = {
});
},
/**
* Phase 2: minta organizer upload ulang field tertentu. Reset status ke
* PENDING tapi sengaja TIDAK clear data lama (organizer ganti field saat
* submit ulang via /verify). Service `submitVerification` auto-clear flag.
*/
async requestReupload(
id: string,
data: { fields: string[]; note: string }
) {
return prisma.organizerVerification.update({
where: { id },
data: {
status: "PENDING",
reuploadRequested: true,
reuploadFields: data.fields,
reuploadNote: data.note,
// Clear review state supaya muncul lagi di tab PENDING.
reviewedById: null,
reviewedAt: null,
},
});
},
/**
* Phase 4: bikin verifikasi APPROVED tanpa upload KYC (manual override admin).
* Placeholder NIK & no image keys — `isManualOverride = true` jadi marker.
*/
async createManualOverride(input: {
userId: string;
adminId: string;
note: string;
fullName: string;
nikEncrypted: string;
nikHash: string;
bankName: string;
bankAccountNumber: string;
bankAccountName: string;
verifiedAt: Date;
}) {
return prisma.organizerVerification.create({
data: {
userId: input.userId,
fullName: input.fullName,
nikEncrypted: input.nikEncrypted,
nikHash: input.nikHash,
birthDate: new Date("1970-01-01"),
address: "(manual override — tidak diisi)",
ktpImageKey: "(manual override)",
livenessKey: "(manual override)",
bankName: input.bankName,
bankAccountNumber: input.bankAccountNumber,
bankAccountName: input.bankAccountName,
status: "APPROVED",
verifiedAt: input.verifiedAt,
reviewedById: input.adminId,
reviewedAt: input.verifiedAt,
isManualOverride: true,
manualOverrideById: input.adminId,
manualOverrideNote: input.note,
},
});
},
/**
* Reopen pengajuan REJECTED ke PENDING. Simpan rejection reason lama
* sebagai catatan history (di-overwrite kalau di-reject lagi nanti).
+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,
};
},
};