admin roadmap filter & search, user management, reopen rejected, system health
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user