admin roadmap done, reupload request, submission history, manual override
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user