admin roadmap filter & search, user management, reopen rejected, system health

This commit is contained in:
2026-05-18 19:45:14 +07:00
parent c52b12daad
commit 6e02f2f0d7
36 changed files with 2013 additions and 339 deletions
+38 -2
View File
@@ -25,9 +25,27 @@ export const organizerRepo = {
});
},
async listByStatus(status?: "PENDING" | "APPROVED" | "REJECTED") {
async listByStatus(
status?: "PENDING" | "APPROVED" | "REJECTED",
filters?: {
dateFrom?: Date;
dateTo?: Date;
reviewerEmail?: string;
}
) {
const where: Prisma.OrganizerVerificationWhereInput = {};
if (status) where.status = status;
if (filters?.dateFrom || filters?.dateTo) {
where.createdAt = {
...(filters.dateFrom && { gte: filters.dateFrom }),
...(filters.dateTo && { lte: filters.dateTo }),
};
}
if (filters?.reviewerEmail) {
where.reviewedBy = { email: filters.reviewerEmail };
}
return prisma.organizerVerification.findMany({
where: status ? { status } : undefined,
where,
orderBy: { createdAt: "desc" },
include: {
user: { select: { id: true, name: true, email: true } },
@@ -55,6 +73,24 @@ export const organizerRepo = {
});
},
/**
* Reopen pengajuan REJECTED ke PENDING. Simpan rejection reason lama
* sebagai catatan history (di-overwrite kalau di-reject lagi nanti).
*/
async reopen(id: string, reopenNote: string) {
return prisma.organizerVerification.update({
where: { id },
data: {
status: "PENDING",
reviewedById: null,
reviewedAt: null,
verifiedAt: null,
// Pertahankan rejectionReason lama di field, append note reopen.
rejectionReason: `[Dibuka kembali admin: ${reopenNote}]`,
},
});
},
async updateReview(
id: string,
data: {
+19 -2
View File
@@ -31,9 +31,26 @@ export const payoutRepo = {
return client.payout.findUnique({ where: { bookingId } });
},
async listByStatus(status: PayoutStatus) {
async listByStatus(
status: PayoutStatus,
filters?: {
dateFrom?: Date;
dateTo?: Date;
processorEmail?: string;
}
) {
const where: Prisma.PayoutWhereInput = { status };
if (filters?.dateFrom || filters?.dateTo) {
where.createdAt = {
...(filters.dateFrom && { gte: filters.dateFrom }),
...(filters.dateTo && { lte: filters.dateTo }),
};
}
if (filters?.processorEmail) {
where.processedBy = { email: filters.processorEmail };
}
return prisma.payout.findMany({
where: { status },
where,
orderBy: { heldUntil: "asc" },
include: payoutListInclude,
});
+27 -2
View File
@@ -40,10 +40,35 @@ export const refundRepo = {
},
async listByStatus(
status?: "PENDING" | "APPROVED" | "REJECTED" | "PROCESSING" | "SUCCEEDED" | "FAILED"
status?: "PENDING" | "APPROVED" | "REJECTED" | "PROCESSING" | "SUCCEEDED" | "FAILED",
filters?: {
dateFrom?: Date;
dateTo?: Date;
reviewerEmail?: string;
reason?:
| "USER_CANCELLATION"
| "ORGANIZER_CANCELLED"
| "TRIP_ISSUE"
| "ADMIN_ADJUSTMENT"
| "DISPUTE_RESOLVED";
}
) {
const where: Prisma.RefundWhereInput = {};
if (status) where.status = status;
if (filters?.dateFrom || filters?.dateTo) {
where.createdAt = {
...(filters.dateFrom && { gte: filters.dateFrom }),
...(filters.dateTo && { lte: filters.dateTo }),
};
}
if (filters?.reviewerEmail) {
where.reviewedBy = { email: filters.reviewerEmail };
}
if (filters?.reason) {
where.reason = filters.reason;
}
return prisma.refund.findMany({
where: status ? { status } : undefined,
where,
orderBy: { createdAt: "desc" },
include: refundListInclude,
});
+5 -1
View File
@@ -45,7 +45,11 @@ export const tripRepo = {
async findOpen(filters?: TripFilters) {
const todayStart = utcStartOfDay(new Date());
const andParts: Prisma.TripWhereInput[] = [{ status: "OPEN" }];
const andParts: Prisma.TripWhereInput[] = [
{ status: "OPEN" },
// Sembunyikan trip dari organizer yang suspended — moderasi via panel admin.
{ organizer: { suspended: false } },
];
if (filters?.category) {
andParts.push({ category: filters.category });
+129
View File
@@ -111,6 +111,135 @@ export const userRepo = {
return prisma.user.create({ data });
},
/**
* Admin search: by email/name (case-insensitive contains) + filter
* suspended status. Limit 100 supaya tidak load semua user di list page.
*/
async searchForAdmin(filters: { q?: string; suspended?: boolean }) {
const where: Prisma.UserWhereInput = {};
if (filters.q) {
where.OR = [
{ email: { contains: filters.q, mode: "insensitive" } },
{ name: { contains: filters.q, mode: "insensitive" } },
];
}
if (typeof filters.suspended === "boolean") {
where.suspended = filters.suspended;
}
return prisma.user.findMany({
where,
select: {
id: true,
name: true,
email: true,
image: true,
createdAt: true,
suspended: true,
suspendedAt: true,
organizerVerification: { select: { status: true } },
_count: {
select: {
trips: true,
participations: { where: { status: { not: "CANCELLED" } } },
},
},
},
orderBy: { createdAt: "desc" },
take: 100,
});
},
/**
* Detail user untuk admin: full profile + booking history + organized
* trips + verification. Tidak ekspos password atau OAuth token.
*/
async findByIdForAdmin(id: string) {
return prisma.user.findUnique({
where: { id },
select: {
id: true,
name: true,
email: true,
image: true,
createdAt: true,
acceptedTermsAndPrivacy: true,
acceptedAt: true,
suspended: true,
suspendedAt: true,
suspendedReason: true,
suspendedBy: { select: { id: true, name: true, email: true } },
profile: true,
organizerVerification: {
select: {
id: true,
status: true,
createdAt: true,
reviewedAt: true,
rejectionReason: true,
},
},
trips: {
select: {
id: true,
title: true,
destination: true,
date: true,
status: true,
price: true,
_count: {
select: {
participants: { where: { status: { not: "CANCELLED" } } },
},
},
},
orderBy: { date: "desc" },
take: 50,
},
bookings: {
select: {
id: true,
amount: true,
status: true,
createdAt: true,
trip: {
select: { id: true, title: true, date: true, organizerId: true },
},
},
orderBy: { createdAt: "desc" },
take: 50,
},
},
});
},
/** Cek cepat status suspended — dipakai auth guard di server actions. */
async isSuspended(id: string): Promise<boolean> {
const u = await prisma.user.findUnique({
where: { id },
select: { suspended: true },
});
return u?.suspended === true;
},
async setSuspension(
id: string,
data: {
suspended: boolean;
suspendedById?: string | null;
suspendedReason?: string | null;
}
) {
return prisma.user.update({
where: { id },
data: {
suspended: data.suspended,
suspendedAt: data.suspended ? new Date() : null,
suspendedById: data.suspended ? data.suspendedById ?? null : null,
suspendedReason: data.suspended ? data.suspendedReason ?? null : null,
},
});
},
/**
* Tandai user sudah accept T&C/Privacy. Idempotent: kalau sudah `true`,
* tidak overwrite `acceptedAt` (audit trail pertama tetap akurat).
+32
View File
@@ -78,6 +78,38 @@ export const organizerService = {
return v?.status === "APPROVED";
},
/**
* 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);
+54
View File
@@ -0,0 +1,54 @@
import { userRepo } from "@/server/repositories/user.repo";
export const userService = {
/**
* Suspend user oleh admin. Idempotent — kalau sudah suspended, tolak supaya
* admin tahu (cegah race condition multiple admin suspend sekaligus).
* Wajib reason untuk audit (min 10 char).
*/
async suspendUser(input: {
userId: string;
adminId: string;
reason: string;
}) {
if (input.userId === input.adminId) {
throw new Error("Tidak bisa suspend akun sendiri");
}
const trimmedReason = input.reason.trim();
if (trimmedReason.length < 10) {
throw new Error("Alasan suspend wajib min 10 karakter untuk audit");
}
if (trimmedReason.length > 500) {
throw new Error("Alasan suspend maksimal 500 karakter");
}
const target = await userRepo.findById(input.userId);
if (!target) {
throw new Error("User tidak ditemukan");
}
if (target.suspended) {
throw new Error("User sudah dalam status suspended");
}
return userRepo.setSuspension(input.userId, {
suspended: true,
suspendedById: input.adminId,
suspendedReason: trimmedReason,
});
},
async unsuspendUser(input: { userId: string; adminId: string }) {
if (input.userId === input.adminId) {
throw new Error("Tidak bisa modifikasi akun sendiri");
}
const target = await userRepo.findById(input.userId);
if (!target) {
throw new Error("User tidak ditemukan");
}
if (!target.suspended) {
throw new Error("User tidak dalam status suspended");
}
return userRepo.setSuspension(input.userId, { suspended: false });
},
};