admin roadmap csv export, adminactionlog, global search

This commit is contained in:
2026-05-18 20:09:22 +07:00
parent 244a6da9bb
commit ea63f56e97
25 changed files with 1330 additions and 158 deletions
+179
View File
@@ -0,0 +1,179 @@
import { prisma } from "@/lib/prisma";
export type AdminSearchHit =
| {
type: "user";
id: string;
title: string;
subtitle: string;
href: string;
}
| {
type: "trip";
id: string;
title: string;
subtitle: string;
href: string;
}
| {
type: "booking";
id: string;
title: string;
subtitle: string;
href: string;
};
/**
* Resolve query admin search ke entity detail page yang paling relevan.
* Dispatch berdasarkan pola input:
* - email (`@`) → user lookup exact
* - `midtrans-<bookingId>-<n>` / `manual-<bookingId>` → booking detail
* - cuid (~25 char alphanumeric, mulai `cm`) → coba trip → booking → user
* - sisanya → fuzzy search trip title/destination + user name
*/
export const adminSearchService = {
async resolve(query: string, limit = 10): Promise<AdminSearchHit[]> {
const q = query.trim();
if (q.length < 2) return [];
// Email exact
if (q.includes("@")) {
const user = await prisma.user.findUnique({
where: { email: q.toLowerCase() },
select: { id: true, name: true, email: true },
});
if (user) {
return [
{
type: "user",
id: user.id,
title: user.name,
subtitle: user.email,
href: `/admin/users/${user.id}`,
},
];
}
}
// Midtrans / manual order_id pattern
if (q.startsWith("midtrans-") || q.startsWith("manual-")) {
const payment = await prisma.payment.findUnique({
where: { externalOrderId: q },
select: {
bookingId: true,
booking: {
select: {
trip: { select: { title: true } },
user: { select: { name: true, email: true } },
},
},
},
});
if (payment) {
return [
{
type: "booking",
id: payment.bookingId,
title: `Booking: ${payment.booking.trip.title}`,
subtitle: `${payment.booking.user.name} · ${payment.booking.user.email}`,
href: `/admin/bookings/${payment.bookingId}`,
},
];
}
}
// CUID pattern — try trip → booking → user
if (/^cm[a-z0-9]{10,}$/i.test(q)) {
const [trip, booking, user] = await Promise.all([
prisma.trip.findUnique({
where: { id: q },
select: { id: true, title: true, destination: true },
}),
prisma.booking.findUnique({
where: { id: q },
select: {
id: true,
trip: { select: { title: true } },
user: { select: { name: true, email: true } },
},
}),
prisma.user.findUnique({
where: { id: q },
select: { id: true, name: true, email: true },
}),
]);
const hits: AdminSearchHit[] = [];
if (trip) {
hits.push({
type: "trip",
id: trip.id,
title: trip.title,
subtitle: trip.destination,
href: `/admin/trips/${trip.id}`,
});
}
if (booking) {
hits.push({
type: "booking",
id: booking.id,
title: `Booking: ${booking.trip.title}`,
subtitle: `${booking.user.name} · ${booking.user.email}`,
href: `/admin/bookings/${booking.id}`,
});
}
if (user) {
hits.push({
type: "user",
id: user.id,
title: user.name,
subtitle: user.email,
href: `/admin/users/${user.id}`,
});
}
if (hits.length > 0) return hits;
}
// Fuzzy: trip + user
const [trips, users] = await Promise.all([
prisma.trip.findMany({
where: {
OR: [
{ title: { contains: q, mode: "insensitive" } },
{ destination: { contains: q, mode: "insensitive" } },
],
},
select: { id: true, title: true, destination: true, location: true },
take: limit,
orderBy: { date: "desc" },
}),
prisma.user.findMany({
where: {
OR: [
{ name: { contains: q, mode: "insensitive" } },
{ email: { contains: q, mode: "insensitive" } },
],
},
select: { id: true, name: true, email: true },
take: limit,
orderBy: { createdAt: "desc" },
}),
]);
return [
...users.map<AdminSearchHit>((u) => ({
type: "user",
id: u.id,
title: u.name,
subtitle: u.email,
href: `/admin/users/${u.id}`,
})),
...trips.map<AdminSearchHit>((t) => ({
type: "trip",
id: t.id,
title: t.title,
subtitle: `${t.destination} · ${t.location}`,
href: `/admin/trips/${t.id}`,
})),
].slice(0, limit);
},
};
+50
View File
@@ -0,0 +1,50 @@
import { Prisma } from "@/app/generated/prisma/client";
import { prisma } from "@/lib/prisma";
/**
* Helper untuk catat aksi admin ke `AdminActionLog`. Append-only, idempotent
* terhadap kegagalan: kalau insert log gagal, jangan blok action — cuma
* console.error supaya monitoring bisa pick up.
*
* Pemakaian (di akhir admin action, setelah success):
* ```ts
* await auditLog.record({
* admin: { id: session.user.id, email: session.user.email },
* action: "REFUND_APPROVE",
* entityType: "Refund",
* entityId: refundId,
* payload: { adminNote },
* });
* ```
*/
export const auditLog = {
async record(input: {
admin: { id: string; email: string };
action: string;
entityType: string;
entityId: string;
payload?: Record<string, unknown>;
}): Promise<void> {
try {
await prisma.adminActionLog.create({
data: {
adminId: input.admin.id,
adminEmail: input.admin.email,
action: input.action,
entityType: input.entityType,
entityId: input.entityId,
payload: input.payload
? (input.payload as Prisma.InputJsonValue)
: Prisma.JsonNull,
},
});
} catch (err) {
console.error("[audit-log] gagal record action", {
action: input.action,
entityType: input.entityType,
entityId: input.entityId,
err,
});
}
},
};