diff --git a/ADMIN_AUDIT_ROADMAP.md b/ADMIN_AUDIT_ROADMAP.md
deleted file mode 100644
index 4baaf40..0000000
--- a/ADMIN_AUDIT_ROADMAP.md
+++ /dev/null
@@ -1,107 +0,0 @@
-# Setrip โ Admin Audit & Investigation Roadmap
-
-> **Status keseluruhan:** ๐ง Partial โ Phase 1 delivered, Phase 2-4 pending.
-
-Admin perlu **mencari** lintas entity (booking/payment/refund/user/trip) dan **export** untuk compliance + investigasi dispute.
-
-> **Skenario nyata:** auditor bertanya "tunjukkan semua refund yang di-approve admin X di bulan Juni 2026 dengan total lebih dari Rp 5 juta". Saat ini admin harus query DB manual atau ambil screenshot satu-satu. Tidak ada cara cari berdasarkan kombinasi reviewer + tanggal + nominal.
-
----
-
-## Baseline
-
-- โ
Data audit sudah ada di schema: `Refund.reviewedBy/reviewedAt/adminNote`, `Payout.processedBy/processedAt/adminNote`, `OrganizerVerification.reviewedBy/reviewedAt/rejectionReason`.
-- โ
Existing list pages (`/admin/refunds`, `/admin/payouts`, `/admin/verifications`) sudah grouping by status tab.
-- โ Tidak ada filter date range / reviewer / amount / reason.
-- โ Tidak ada kolom "reviewer email" di list โ harus klik detail.
-- โ Tidak ada global search (cari berdasarkan email user, order id, trip id).
-- โ Tidak ada CSV export.
-- โ Tidak ada audit log untuk action admin di entity lain (User suspension, Trip force-cancel, Verification reopen).
-
----
-
-## Phase 1 โ Filter & Search Enhancements โ
DELIVERED
-
-| # | Item | Status | File |
-|---|---|---|---|
-| 1.1 | Filter date range (`dateFrom`, `dateTo`) di `/admin/refunds` | โ
| [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
-| 1.2 | Filter `reviewer` (admin email dropdown) di `/admin/refunds` | โ
| [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
-| 1.3 | Filter `reason` di `/admin/refunds` | โ
| [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
-| 1.4 | Filter date range + `processor` di `/admin/payouts` | โ
| [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) |
-| 1.5 | Filter date range + `reviewer` di `/admin/verifications` | โ
| [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) |
-| 1.6 | Komponen reusable `AdminFilterBar` (date range + reviewer dropdown + optional reason) | โ
| [features/admin/components/admin-filter-bar.tsx](features/admin/components/admin-filter-bar.tsx) |
-| 1.7 | Repo helper: filter params di `refundRepo.listByStatus`, `payoutRepo.listByStatus`, `organizerRepo.listByStatus` | โ
| `server/repositories/*.ts` |
-| 1.8 | Helper `listAdminEmails()` untuk dropdown reviewer | โ
| [lib/admin.ts](lib/admin.ts) |
-
-**Tindakan manual:** tidak ada.
-
-> _Catatan: reviewer email column di list belum ditambah โ info sudah ada di refund/payout/verification card detail (`Diproses oleh ...`)._ Bisa ditambah saat dibutuhkan untuk skim cepat._
-
----
-
-## Phase 2 โ Global Search โณ
-
-Satu search box yang resolve ke entity detail page paling relevan.
-
-**Keputusan asumsi:**
-- Input string user, prefix-based dispatch:
- - Email format (`@`) โ user search โ redirect ke `/admin/users/[id]`
- - Mulai `midtrans-` / `manual-` โ payment lookup by `externalOrderId` โ `/admin/bookings/[bookingId]`
- - Mulai `cm` (cuid pattern) + length 25 โ coba lookup berurutan: trip โ booking โ user
- - Else: full-text search di trip title/destination
-- Pakai server action atau route handler `/api/admin/search` โ return list hasil + jenis entity.
-- UI: searchbar di admin layout (top-right) yang dropdown hasil.
-
-| # | Item | Status | File |
-|---|---|---|---|
-| 2.1 | `adminSearchService.resolve(query)` โ dispatch ke repo lookup yang tepat | โณ | `server/services/admin-search.service.ts` |
-| 2.2 | Route handler `/api/admin/search?q=...` (GET, guard isAdmin) | โณ | `app/api/admin/search/route.ts` |
-| 2.3 | Component `AdminSearchBar` di admin layout โ debounced, dropdown hasil | โณ | `features/admin/components/admin-search-bar.tsx` |
-| 2.4 | Page `/admin/search?q=...` untuk full results kalau dropdown limit terlampaui | โณ | `app/admin/search/page.tsx` |
-
-**Tindakan manual:** tidak ada.
-
----
-
-## Phase 3 โ CSV Export โณ
-
-Export untuk laporan keuangan & compliance.
-
-**Keputusan asumsi:**
-- Stream CSV via route handler โ jangan load semua ke memory.
-- Pakai filter yang sama dengan list page โ admin pakai URL filter lalu klik "Export".
-- Header CSV: human-readable bahasa Indonesia (mis. "Tanggal Approve", "Email Peserta", "Nominal Refund").
-- Tidak ada Excel/xlsx โ CSV cukup, mudah dibuka di Sheets/Excel.
-
-| # | Item | Status | File |
-|---|---|---|---|
-| 3.1 | Helper `lib/csv.ts` โ `streamCsv(headers, rows)` return Response | โณ | `lib/csv.ts` |
-| 3.2 | Route `/api/admin/export/refunds` โ pakai filter dari query string | โณ | `app/api/admin/export/refunds/route.ts` |
-| 3.3 | Route `/api/admin/export/payouts` | โณ | `app/api/admin/export/payouts/route.ts` |
-| 3.4 | Route `/api/admin/export/verifications` (tanpa NIK / KTP โ hanya metadata) | โณ | `app/api/admin/export/verifications/route.ts` |
-| 3.5 | Tombol "Export CSV" di tiap admin list page | โณ | semua `app/admin/*/page.tsx` |
-
-**Tindakan manual:**
-1. Test export di staging โ pastikan tidak leak data sensitif (NIK harus tetap encrypted/excluded).
-2. Update kebijakan privasi: data export hanya untuk internal compliance.
-
----
-
-## Phase 4 โ Generic Admin Audit Log โณ
-
-Tabel `AdminActionLog` untuk action di entity yang belum punya audit field (User suspend, Trip force-cancel, Verification reopen, dst).
-
-**Keputusan asumsi:**
-- Single tabel polymorphic: `AdminActionLog { adminId, action, entityType, entityId, payload Json?, createdAt }`.
-- Append-only, never update/delete.
-- Service helper `auditLog.record(...)` dipanggil eksplisit di setiap action admin (tidak via Prisma middleware โ terlalu magic).
-- View page `/admin/audit-log` dengan filter `adminId`, `entityType`, `action`, date range.
-
-| # | Item | Status | File |
-|---|---|---|---|
-| 4.1 | Model `AdminActionLog` + migration | โณ | [prisma/schema.prisma](prisma/schema.prisma) |
-| 4.2 | Helper `auditLog.record({ adminId, action, entityType, entityId, payload? })` | โณ | `server/services/audit-log.service.ts` |
-| 4.3 | Wire `auditLog.record` di semua admin server action existing (refund approve/reject/mark, payout markPaid, verification approve/reject) | โณ | `features/*/actions.ts` |
-| 4.4 | Page `/admin/audit-log` dengan filter + pagination | โณ | `app/admin/audit-log/page.tsx` |
-
-**Tindakan manual:** tidak ada.
diff --git a/ADMIN_ROADMAP.md b/ADMIN_ROADMAP.md
index 13ee075..39b6eb5 100644
--- a/ADMIN_ROADMAP.md
+++ b/ADMIN_ROADMAP.md
@@ -6,21 +6,25 @@ Status implementasi kemampuan admin agar admin **dapat mengontrol seluruh aplika
---
-## Baseline (yang BISA admin lakukan sekarang)
+## Baseline โ yang BISA admin lakukan sekarang
| Area | Fungsi | File |
|---|---|---|
-| Dashboard | View count: verifikasi PENDING, refund per status, payout per status | [app/admin/page.tsx](app/admin/page.tsx) |
+| **Dashboard** | View count: verifikasi PENDING, refund per status, payout per status | [app/admin/page.tsx](app/admin/page.tsx) |
+| **Global search** | Search bar di sidebar โ by email, order_id, cuid, fuzzy trip/user | [features/admin/components/admin-search-bar.tsx](features/admin/components/admin-search-bar.tsx) |
| **Trips** | List + search + detail; force-cancel dengan auto-refund (admin intervention) | [app/admin/trips/](app/admin/trips/) |
| **Users** | List + search + filter (active/suspended); detail dengan trip + booking history; suspend/unsuspend | [app/admin/users/](app/admin/users/) |
| **Bookings detail** | Timeline lintas Payment + Refund + Payout, raw callback viewer, Midtrans reconcile | [app/admin/bookings/[id]/page.tsx](app/admin/bookings/[id]/page.tsx) |
-| Verifikasi KYC | Approve / Reject / **Reopen REJECTED**; filter date range + reviewer | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) |
-| Refund | Create manual, approve, reject, mark SUCCEEDED/FAILED; filter date/reviewer/reason; link ke booking timeline | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
-| Payout | View per status, mark PAID; filter date/processor; link ke booking timeline | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) |
+| **Verifikasi KYC** | Approve / Reject / Reopen REJECTED; filter date range + reviewer; CSV export | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) |
+| **Refund** | Create manual, approve, reject, mark SUCCEEDED/FAILED; filter date/reviewer/reason; link ke booking timeline; CSV export | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
+| **Payout** | View per status, mark PAID; filter date/processor; link ke booking timeline; CSV export | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) |
+| **Audit Log** | View semua action admin lintas entity (refund, payout, trip cancel, suspend, dst); filter by admin/entity/action/date | [app/admin/audit-log/page.tsx](app/admin/audit-log/page.tsx) |
| **System Health** | Status cron jobs (last run, last success, 7d stats), 20 recent runs, health badge | [app/admin/system/page.tsx](app/admin/system/page.tsx) |
**Aksi mutating yang diblokir untuk suspended user:** sign-in (NextAuth), `createTripAction`, `joinTripAction`. Trip public list otomatis sembunyikan organizer suspended.
+**Audit trail otomatis:** semua aksi admin (suspend, force-cancel, reconcile, approve/reject verification/refund, mark payout PAID, reopen verification) tercatat di `AdminActionLog` via `auditLog.record()`.
+
Auth admin: env `ADMIN_EMAILS` โ cek di [lib/admin.ts](lib/admin.ts), dipassing ke session via [lib/auth.ts](lib/auth.ts).
---
@@ -29,47 +33,53 @@ Auth admin: env `ADMIN_EMAILS` โ cek di [lib/admin.ts](lib/admin.ts), dipassin
| Roadmap | Prioritas | Status | File |
|---|---|---|---|
-| Trip Operations (search, view, cancel manual) | ๐ด HIGH | โ
**Delivered** | [docs/archive/ADMIN_TRIP_OPS_ROADMAP.md](docs/archive/ADMIN_TRIP_OPS_ROADMAP.md) |
-| Payment Operations (booking detail, reconcile, dispute) | ๐ด HIGH | โ
**Delivered** | [docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md](docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md) |
-| Audit & Investigation (search, filter, export) | ๐ด HIGH | ๐ง Phase 1 done ยท Phase 2-4 pending | [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) |
-| User Management (search, suspend/ban) | ๐ก MEDIUM | โ
**Delivered** | [docs/archive/ADMIN_USER_MGMT_ROADMAP.md](docs/archive/ADMIN_USER_MGMT_ROADMAP.md) |
-| Verification (reopen, re-upload request) | ๐ก MEDIUM | ๐ง Phase 1 done ยท Phase 2-4 deferred | [docs/archive/ADMIN_VERIFICATION_ROADMAP.md](docs/archive/ADMIN_VERIFICATION_ROADMAP.md) |
-| System Health (cron monitor, stale state alerts) | ๐ก MEDIUM | ๐ง Phase 1-2 done ยท Phase 3-4 deferred | [docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md](docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md) |
+| Trip Operations | ๐ด HIGH | โ
**Delivered** | [docs/archive/ADMIN_TRIP_OPS_ROADMAP.md](docs/archive/ADMIN_TRIP_OPS_ROADMAP.md) |
+| Payment Operations | ๐ด HIGH | โ
**Delivered** | [docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md](docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md) |
+| Audit & Investigation | ๐ด HIGH | โ
**Delivered** | [docs/archive/ADMIN_AUDIT_ROADMAP.md](docs/archive/ADMIN_AUDIT_ROADMAP.md) |
+| User Management | ๐ก MEDIUM | โ
**Delivered** | [docs/archive/ADMIN_USER_MGMT_ROADMAP.md](docs/archive/ADMIN_USER_MGMT_ROADMAP.md) |
+| Verification | ๐ก MEDIUM | ๐ง Phase 1 done ยท 2-4 deferred | [docs/archive/ADMIN_VERIFICATION_ROADMAP.md](docs/archive/ADMIN_VERIFICATION_ROADMAP.md) |
+| System Health | ๐ก MEDIUM | ๐ง Phase 1-2 done ยท 3-4 deferred | [docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md](docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md) |
**Legend status:** โณ belum mulai ยท ๐ง partial ยท โ
selesai
---
-## Sisa pekerjaan
+## Sisa pekerjaan (semua deferred โ low priority)
-Hampir semua kapabilitas dasar admin sudah delivered. Yang tersisa hanya enhancement non-blocking:
+- **Verification Phase 2** Re-upload request flow (butuh schema + organizer-side UI)
+- **Verification Phase 3** Verification history (audit trail multi-submission)
+- **Verification Phase 4** Manual override (admin verify tanpa upload, untuk referral)
+- **System Health Phase 3** Stale state alerts (Payment AWAITING > 25h, Payout HELD overdue)
+- **System Health Phase 4** External alerting (Discord webhook)
+- **User Mgmt Phase 3** Bulk analytics dashboard
-**Audit Phase 2-4** (lihat [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md)):
-- Phase 2 โ Global Search (admin search bar resolve email/order_id/cuid)
-- Phase 3 โ CSV Export untuk refunds/payouts/verifications
-- Phase 4 โ Generic `AdminActionLog` model untuk audit action lintas entity
-
-**Lainnya yang di-defer** (di archive masing-masing):
-- Verification: re-upload request flow, verification history, manual override
-- System Health: stale state alerts (Payment AWAITING > 25h, Payout HELD overdue), external alerting (Discord webhook)
-- User Mgmt: bulk analytics dashboard
+Tidak ada yang blocking. Diangkat saat ada incident konkret atau permintaan stakeholder.
---
-## Tindakan manual setelah deploy versi terakhir
+## Tindakan manual setelah deploy
+
+Untuk versi yang berisi delivery 6 roadmap admin:
```bash
-# Apply 3 migration baru: add_trip_admin_cancel, add_user_suspension, add_cron_run
+# Apply 4 migration baru
npx prisma migrate deploy
+# - 20260518150000_add_trip_admin_cancel
+# - 20260518160000_add_user_suspension
+# - 20260518170000_add_cron_run
+# - 20260518180000_add_admin_action_log
# Restart Next.js / PM2 supaya Prisma client baru ter-load
pm2 restart setrip --update-env
```
Brief admin tentang kapabilitas baru:
+- **Global search** di sidebar โ ketik email, order_id, atau cuid; auto-detect ke detail page yang tepat.
- **Force-cancel trip** di `/admin/trips/[id]` โ pakai saat organizer unreachable / dispute, reason wajib min 10 char.
- **Reconcile Midtrans** di `/admin/bookings/[id]` โ pakai saat peserta lapor "sudah bayar tapi status belum update". Idempotent.
- **Suspend user** di `/admin/users/[id]` โ pakai untuk scam/harassment. Suspended user diblokir sign-in dan aksi mutatif.
- **Reopen verification** di `/admin/verifications` (tab REJECTED) โ saat organizer kirim ulang foto via email/WA.
- **System status** di `/admin/system` โ cek setiap pagi, pastikan cron jalan (๐ข OK).
-- **Filter date range + reviewer** di refunds/payouts/verifications โ untuk investigasi & compliance.
+- **Audit log** di `/admin/audit-log` โ bukti compliance saat audit eksternal; semua aksi admin tercatat dengan email + payload.
+- **CSV export** di refunds/payouts/verifications โ download untuk laporan keuangan/compliance.
+- **Filter date range + reviewer** di refunds/payouts/verifications โ untuk investigasi.
diff --git a/app/admin/audit-log/page.tsx b/app/admin/audit-log/page.tsx
new file mode 100644
index 0000000..cceb907
--- /dev/null
+++ b/app/admin/audit-log/page.tsx
@@ -0,0 +1,243 @@
+import Link from "next/link";
+import { redirect } from "next/navigation";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { isAdminEmail, listAdminEmails } from "@/lib/admin";
+import { prisma } from "@/lib/prisma";
+import type { Prisma } from "@/app/generated/prisma/client";
+import { AdminFilterBar } from "@/features/admin/components/admin-filter-bar";
+
+const ENTITY_TYPES = [
+ "Refund",
+ "Payout",
+ "Trip",
+ "User",
+ "OrganizerVerification",
+ "Payment",
+] as const;
+
+interface PageProps {
+ searchParams: Promise<{
+ entityType?: string;
+ action?: string;
+ reviewer?: string;
+ dateFrom?: string;
+ dateTo?: string;
+ }>;
+}
+
+function parseDate(value: string | undefined): Date | undefined {
+ if (!value) return undefined;
+ const d = new Date(value);
+ return Number.isNaN(d.getTime()) ? undefined : d;
+}
+
+export default async function AdminAuditLogPage({ searchParams }: PageProps) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) redirect("/login?callbackUrl=/admin/audit-log");
+ if (!isAdminEmail(session.user.email)) {
+ return (
+
+
+ Halaman ini hanya untuk admin SeTrip.
+
+
+ );
+ }
+
+ const params = await searchParams;
+ const dateFrom = parseDate(params.dateFrom);
+ const dateTo = parseDate(params.dateTo);
+
+ const where: Prisma.AdminActionLogWhereInput = {};
+ if (params.entityType && ENTITY_TYPES.includes(params.entityType as never)) {
+ where.entityType = params.entityType;
+ }
+ if (params.action) {
+ where.action = { contains: params.action, mode: "insensitive" };
+ }
+ if (params.reviewer) {
+ where.adminEmail = params.reviewer;
+ }
+ if (dateFrom || dateTo) {
+ where.createdAt = {
+ ...(dateFrom && { gte: dateFrom }),
+ ...(dateTo && { lte: dateTo }),
+ };
+ }
+
+ const logs = await prisma.adminActionLog.findMany({
+ where,
+ orderBy: { createdAt: "desc" },
+ take: 200,
+ });
+
+ return (
+
+
+
+ Audit Log
+
+
+ Catatan semua aksi admin lintas entity (refund, payout, trip cancel,
+ user suspend, dst). Append-only. Maksimal 200 baris terbaru per query
+ โ pakai filter untuk drill-down.
+
+
+
+
+
+
+
+ {logs.length === 0 ? (
+
+
+ Tidak ada audit log yang cocok dengan filter ini.
+
+
+ ) : (
+
+
+
+
+ Waktu
+ Admin
+ Action
+ Entity
+ Entity ID
+ Payload
+
+
+
+ {logs.map((row) => (
+
+
+ {row.createdAt.toLocaleString("id-ID", {
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ })}
+
+
+ {row.adminEmail}
+ {!row.adminId && (
+
+ (deleted)
+
+ )}
+
+
+
+ {row.action}
+
+
+
+ {row.entityType}
+
+
+
+
+
+ {row.payload ? (
+
+ {JSON.stringify(row.payload)}
+
+ ) : (
+ "โ"
+ )}
+
+
+ ))}
+
+
+
+ )}
+
+ );
+}
+
+function EntityIdLink({
+ entityType,
+ entityId,
+}: {
+ entityType: string;
+ entityId: string;
+}) {
+ const short = `${entityId.slice(0, 8)}โฆ`;
+ let href: string | null = null;
+ if (entityType === "Trip") href = `/admin/trips/${entityId}`;
+ if (entityType === "User") href = `/admin/users/${entityId}`;
+ if (href) {
+ return (
+
+ {short}
+
+ );
+ }
+ return {short} ;
+}
diff --git a/app/admin/payouts/page.tsx b/app/admin/payouts/page.tsx
index 27f97fd..67500a9 100644
--- a/app/admin/payouts/page.tsx
+++ b/app/admin/payouts/page.tsx
@@ -4,6 +4,7 @@ import { authOptions } from "@/lib/auth";
import { isAdminEmail, listAdminEmails } from "@/lib/admin";
import { payoutRepo } from "@/server/repositories/payout.repo";
import { AdminFilterBar } from "@/features/admin/components/admin-filter-bar";
+import { ExportCsvLink } from "@/features/admin/components/export-csv-link";
import {
PayoutReviewCard,
type PayoutCardData,
@@ -81,17 +82,28 @@ export default async function AdminPayoutsPage({ searchParams }: PageProps) {
processedBy: p.processedBy,
}));
+ const exportQuery = new URLSearchParams({ status: tab });
+ if (params.dateFrom) exportQuery.set("dateFrom", params.dateFrom);
+ if (params.dateTo) exportQuery.set("dateTo", params.dateTo);
+ if (params.reviewer) exportQuery.set("reviewer", params.reviewer);
+
return (
+
+
{NAV_ITEMS.map((item) => {
diff --git a/docs/archive/ADMIN_AUDIT_ROADMAP.md b/docs/archive/ADMIN_AUDIT_ROADMAP.md
new file mode 100644
index 0000000..abd301d
--- /dev/null
+++ b/docs/archive/ADMIN_AUDIT_ROADMAP.md
@@ -0,0 +1,84 @@
+# Setrip โ Admin Audit & Investigation Roadmap (ARCHIVED โ DELIVERED 2026-05-18)
+
+Admin perlu **mencari** lintas entity (booking/payment/refund/user/trip) dan **export** untuk compliance + investigasi dispute.
+
+---
+
+## Status delivery
+
+| Phase | Status | Catatan |
+|---|---|---|
+| Phase 1 โ Filter & Search Enhancements | โ
Delivered | Filter date range + reviewer di refunds/payouts/verifications via `AdminFilterBar` reusable. Reason filter di refunds. |
+| Phase 2 โ Global Search | โ
Delivered | Search bar di sidebar admin dispatch by pattern (email/order_id/cuid/fuzzy). Endpoint `/api/admin/search`. |
+| Phase 3 โ CSV Export | โ
Delivered | 3 endpoint export (refunds/payouts/verifications) dengan UTF-8 BOM untuk Excel. Tombol "โฌ๏ธ Export CSV" di tiap halaman list. |
+| Phase 4 โ Generic Admin Audit Log | โ
Delivered | Model `AdminActionLog` (polymorphic, append-only). Helper `auditLog.record()` di-wire ke semua admin server action. Halaman `/admin/audit-log` dengan filter. |
+
+---
+
+## Phase 1 โ Filter & Search Enhancements โ
+
+| # | Item | Status | File |
+|---|---|---|---|
+| 1.1 | Filter date range (`dateFrom`, `dateTo`) di `/admin/refunds` | โ
| [app/admin/refunds/page.tsx](../../app/admin/refunds/page.tsx) |
+| 1.2 | Filter `reviewer` (admin email dropdown) di `/admin/refunds` | โ
| [app/admin/refunds/page.tsx](../../app/admin/refunds/page.tsx) |
+| 1.3 | Filter `reason` di `/admin/refunds` | โ
| [app/admin/refunds/page.tsx](../../app/admin/refunds/page.tsx) |
+| 1.4 | Filter date range + `processor` di `/admin/payouts` | โ
| [app/admin/payouts/page.tsx](../../app/admin/payouts/page.tsx) |
+| 1.5 | Filter date range + `reviewer` di `/admin/verifications` | โ
| [app/admin/verifications/page.tsx](../../app/admin/verifications/page.tsx) |
+| 1.6 | Komponen reusable `AdminFilterBar` | โ
| [features/admin/components/admin-filter-bar.tsx](../../features/admin/components/admin-filter-bar.tsx) |
+| 1.7 | Filter params di `refundRepo`/`payoutRepo`/`organizerRepo` `listByStatus` | โ
| `server/repositories/*.ts` |
+| 1.8 | Helper `listAdminEmails()` untuk dropdown reviewer | โ
| [lib/admin.ts](../../lib/admin.ts) |
+
+---
+
+## Phase 2 โ Global Search โ
+
+| # | Item | Status | File |
+|---|---|---|---|
+| 2.1 | `adminSearchService.resolve(q)` โ dispatch by pattern (email exact, order_id prefix, cuid, fuzzy) | โ
| [server/services/admin-search.service.ts](../../server/services/admin-search.service.ts) |
+| 2.2 | Route handler `/api/admin/search?q=...` (guard isAdmin) | โ
| [app/api/admin/search/route.ts](../../app/api/admin/search/route.ts) |
+| 2.3 | Component `AdminSearchBar` โ debounced 250ms, dropdown hasil dengan type badge | โ
| [features/admin/components/admin-search-bar.tsx](../../features/admin/components/admin-search-bar.tsx) |
+| 2.4 | Wire di admin sidebar (di bawah logo header) | โ
| [components/admin/admin-sidebar.tsx](../../components/admin/admin-sidebar.tsx) |
+| 2.5 | Page `/admin/search?q=...` full results | โณ | Skip โ dropdown limit 10 hit cukup; jarang butuh full page. |
+
+---
+
+## Phase 3 โ CSV Export โ
+
+| # | Item | Status | File |
+|---|---|---|---|
+| 3.1 | Helper `lib/csv.ts` โ `buildCsv`, `escapeCsvCell`, `csvResponse` dengan UTF-8 BOM | โ
| [lib/csv.ts](../../lib/csv.ts) |
+| 3.2 | Route `/api/admin/export/refunds` โ pakai filter dari query string | โ
| [app/api/admin/export/refunds/route.ts](../../app/api/admin/export/refunds/route.ts) |
+| 3.3 | Route `/api/admin/export/payouts` | โ
| [app/api/admin/export/payouts/route.ts](../../app/api/admin/export/payouts/route.ts) |
+| 3.4 | Route `/api/admin/export/verifications` โ TANPA NIK/KTP key/bank account number (privasi) | โ
| [app/api/admin/export/verifications/route.ts](../../app/api/admin/export/verifications/route.ts) |
+| 3.5 | Komponen `ExportCsvLink` + tombol di tiap admin list page (filter preserved) | โ
| [features/admin/components/export-csv-link.tsx](../../features/admin/components/export-csv-link.tsx) |
+
+**Tindakan manual:** test di staging dulu โ pastikan tidak ada data sensitif yang ter-leak (NIK plaintext, foto KYC key, dst).
+
+---
+
+## Phase 4 โ Generic Admin Audit Log โ
+
+| # | Item | Status | File |
+|---|---|---|---|
+| 4.1 | Model `AdminActionLog` (polymorphic, append-only) + migration | โ
| [prisma/schema.prisma](../../prisma/schema.prisma) + `prisma/migrations/20260518180000_add_admin_action_log/` |
+| 4.2 | Helper `auditLog.record({ admin, action, entityType, entityId, payload? })` | โ
| [server/services/audit-log.service.ts](../../server/services/audit-log.service.ts) |
+| 4.3 | Wire di semua admin server action: refund approve/reject/mark/create, payout markPaid, verification approve/reject/reopen, trip admin-cancel, payment reconcile, user suspend/unsuspend | โ
| `features/*/actions.ts` |
+| 4.4 | Page `/admin/audit-log` dengan filter (date range, admin email, entity type, action contains) + pagination basic (max 200) | โ
| [app/admin/audit-log/page.tsx](../../app/admin/audit-log/page.tsx) |
+| 4.5 | Link "Audit Log" di sidebar | โ
| [components/admin/admin-sidebar.tsx](../../components/admin/admin-sidebar.tsx) |
+
+**Daftar action yang ter-log:**
+
+| Action | Entity | Source |
+|---|---|---|
+| `USER_SUSPEND` / `USER_UNSUSPEND` | User | [features/admin/actions.ts](../../features/admin/actions.ts) |
+| `TRIP_ADMIN_CANCEL` | Trip | [features/trip/actions.ts](../../features/trip/actions.ts) |
+| `PAYMENT_RECONCILE` | Payment (orderId) | [features/booking/actions.ts](../../features/booking/actions.ts) |
+| `VERIFICATION_APPROVE` / `VERIFICATION_REJECT` / `VERIFICATION_REOPEN` | OrganizerVerification | [features/organizer/actions.ts](../../features/organizer/actions.ts) |
+| `REFUND_CREATE` / `REFUND_APPROVE` / `REFUND_REJECT` / `REFUND_SUCCEEDED` / `REFUND_FAILED` | Refund | [features/refund/actions.ts](../../features/refund/actions.ts) |
+| `PAYOUT_MARK_PAID` | Payout | [features/payout/actions.ts](../../features/payout/actions.ts) |
+
+`adminId` nullable + `adminEmail` snapshot โ log entry tetap auditable kalau admin dihapus.
+
+**Tindakan manual ops:**
+1. Apply migration: `npx prisma migrate deploy`.
+2. Brief admin: setiap aksi mereka di panel akan tercatat di `/admin/audit-log` dengan email mereka โ pakai sebagai bukti compliance saat audit eksternal.
diff --git a/features/admin/actions.ts b/features/admin/actions.ts
index e515c25..dd95d92 100644
--- a/features/admin/actions.ts
+++ b/features/admin/actions.ts
@@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { userService } from "@/server/services/user.service";
+import { auditLog } from "@/server/services/audit-log.service";
export async function suspendUserAction(userId: string, reason: string) {
const session = await getServerSession(authOptions);
@@ -21,6 +22,13 @@ export async function suspendUserAction(userId: string, reason: string) {
adminId: session.user.id,
reason,
});
+ await auditLog.record({
+ admin: { id: session.user.id, email: session.user.email },
+ action: "USER_SUSPEND",
+ entityType: "User",
+ entityId: userId,
+ payload: { reason: reason.trim() },
+ });
revalidatePath("/admin/users");
revalidatePath(`/admin/users/${userId}`);
return { success: true as const };
@@ -40,6 +48,12 @@ export async function unsuspendUserAction(userId: string) {
try {
await userService.unsuspendUser({ userId, adminId: session.user.id });
+ await auditLog.record({
+ admin: { id: session.user.id, email: session.user.email },
+ action: "USER_UNSUSPEND",
+ entityType: "User",
+ entityId: userId,
+ });
revalidatePath("/admin/users");
revalidatePath(`/admin/users/${userId}`);
return { success: true as const };
diff --git a/features/admin/components/admin-search-bar.tsx b/features/admin/components/admin-search-bar.tsx
new file mode 100644
index 0000000..9b40236
--- /dev/null
+++ b/features/admin/components/admin-search-bar.tsx
@@ -0,0 +1,131 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import Link from "next/link";
+
+interface Hit {
+ type: "user" | "trip" | "booking";
+ id: string;
+ title: string;
+ subtitle: string;
+ href: string;
+}
+
+/**
+ * Search bar global untuk admin sidebar. Debounced 250ms supaya tidak spam
+ * server. Hits dispatch berdasarkan pola input โ lihat
+ * `adminSearchService.resolve` di server.
+ */
+export function AdminSearchBar() {
+ const [query, setQuery] = useState("");
+ const [hits, setHits] = useState([]);
+ const [open, setOpen] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const wrapperRef = useRef(null);
+
+ // Debounced fetch โ guard inside async block supaya tidak setState langsung
+ // di effect synchronous (react-hooks/set-state-in-effect).
+ useEffect(() => {
+ const q = query.trim();
+ const controller = new AbortController();
+ const timer = setTimeout(() => {
+ if (q.length < 2) {
+ setHits([]);
+ setLoading(false);
+ return;
+ }
+ setLoading(true);
+ fetch(`/api/admin/search?q=${encodeURIComponent(q)}`, {
+ signal: controller.signal,
+ })
+ .then((res) => (res.ok ? res.json() : { hits: [] }))
+ .then((json: { hits: Hit[] }) => {
+ setHits(json.hits ?? []);
+ })
+ .catch(() => setHits([]))
+ .finally(() => setLoading(false));
+ }, 250);
+ return () => {
+ clearTimeout(timer);
+ controller.abort();
+ };
+ }, [query]);
+
+ // Close dropdown on outside click
+ useEffect(() => {
+ function onClick(e: MouseEvent) {
+ if (
+ wrapperRef.current &&
+ !wrapperRef.current.contains(e.target as Node)
+ ) {
+ setOpen(false);
+ }
+ }
+ document.addEventListener("mousedown", onClick);
+ return () => document.removeEventListener("mousedown", onClick);
+ }, []);
+
+ return (
+
+
{
+ setQuery(e.target.value);
+ setOpen(true);
+ }}
+ onFocus={() => setOpen(true)}
+ placeholder="Cari email, ID, order_id, judul..."
+ className="w-full rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 text-xs text-neutral-800 placeholder:text-neutral-400 focus:border-primary-400 focus:bg-white"
+ />
+ {open && query.trim().length >= 2 && (
+
+ {loading && (
+
Mencari...
+ )}
+ {!loading && hits.length === 0 && (
+
+ Tidak ada hasil.
+
+ )}
+ {!loading && hits.length > 0 && (
+
+ {hits.map((h) => (
+
+ {
+ setOpen(false);
+ setQuery("");
+ }}
+ className="flex items-center gap-2 px-3 py-2 hover:bg-neutral-50"
+ >
+
+ {h.type}
+
+
+
+ {h.title}
+
+
+ {h.subtitle}
+
+
+
+
+ ))}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/features/admin/components/export-csv-link.tsx b/features/admin/components/export-csv-link.tsx
new file mode 100644
index 0000000..15fca4e
--- /dev/null
+++ b/features/admin/components/export-csv-link.tsx
@@ -0,0 +1,29 @@
+interface ExportCsvLinkProps {
+ /** URL endpoint export, mis. `/api/admin/export/refunds`. */
+ href: string;
+ /** Query string current filter (tanpa leading `?`). */
+ query?: string;
+ label?: string;
+}
+
+/**
+ * Tombol download CSV โ anchor biasa supaya browser tangani download via
+ * `Content-Disposition: attachment` header dari server.
+ */
+export function ExportCsvLink({
+ href,
+ query,
+ label = "Export CSV",
+}: ExportCsvLinkProps) {
+ const url = query ? `${href}?${query}` : href;
+ return (
+
+ โฌ๏ธ
+ {label}
+
+ );
+}
diff --git a/features/booking/actions.ts b/features/booking/actions.ts
index c30d227..7155beb 100644
--- a/features/booking/actions.ts
+++ b/features/booking/actions.ts
@@ -81,6 +81,14 @@ export async function adminReconcileMidtransAction(orderId: string) {
}
return { error: "Status pembayaran tidak cocok dengan tagihan" };
}
+ const { auditLog } = await import("@/server/services/audit-log.service");
+ await auditLog.record({
+ admin: { id: session.user.id, email: session.user.email },
+ action: "PAYMENT_RECONCILE",
+ entityType: "Payment",
+ entityId: orderId,
+ payload: { outcome: result.status },
+ });
return { success: true as const, status: result.status };
} catch (err) {
return { error: (err as Error).message };
diff --git a/features/organizer/actions.ts b/features/organizer/actions.ts
index a911679..d254917 100644
--- a/features/organizer/actions.ts
+++ b/features/organizer/actions.ts
@@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { organizerService } from "@/server/services/organizer.service";
+import { auditLog } from "@/server/services/audit-log.service";
import { submitVerificationSchema, reviewVerificationSchema } from "./schemas";
export async function submitVerificationAction(formData: FormData) {
@@ -68,6 +69,19 @@ export async function reviewVerificationAction(formData: FormData) {
rejectionReason: result.data.rejectionReason,
reviewerId: session.user.id,
});
+ await auditLog.record({
+ admin: { id: session.user.id, email: session.user.email },
+ action:
+ result.data.decision === "APPROVED"
+ ? "VERIFICATION_APPROVE"
+ : "VERIFICATION_REJECT",
+ entityType: "OrganizerVerification",
+ entityId: result.data.verificationId,
+ payload:
+ result.data.decision === "REJECTED"
+ ? { rejectionReason: result.data.rejectionReason ?? null }
+ : undefined,
+ });
revalidatePath("/admin/verifications");
revalidatePath("/verify");
revalidatePath("/profile");
@@ -96,6 +110,13 @@ export async function reopenVerificationAction(
adminId: session.user.id,
note,
});
+ await auditLog.record({
+ admin: { id: session.user.id, email: session.user.email },
+ action: "VERIFICATION_REOPEN",
+ entityType: "OrganizerVerification",
+ entityId: verificationId,
+ payload: { note: note.trim() },
+ });
revalidatePath("/admin/verifications");
revalidatePath("/verify");
revalidatePath("/profile");
diff --git a/features/payout/actions.ts b/features/payout/actions.ts
index 90dc114..5b4aec8 100644
--- a/features/payout/actions.ts
+++ b/features/payout/actions.ts
@@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { payoutService } from "@/server/services/payout.service";
+import { auditLog } from "@/server/services/audit-log.service";
import { payoutMarkPaidSchema } from "./schemas";
async function requireAdmin() {
@@ -33,6 +34,13 @@ export async function markPayoutPaidAction(formData: FormData) {
adminId: admin.id,
adminNote: parsed.data.adminNote,
});
+ await auditLog.record({
+ admin: { id: admin.id, email: admin.email },
+ action: "PAYOUT_MARK_PAID",
+ entityType: "Payout",
+ entityId: parsed.data.payoutId,
+ payload: { adminNote: parsed.data.adminNote },
+ });
revalidatePath("/admin/payouts");
revalidatePath("/admin");
revalidatePath("/profile");
diff --git a/features/refund/actions.ts b/features/refund/actions.ts
index 7fa460c..a978f70 100644
--- a/features/refund/actions.ts
+++ b/features/refund/actions.ts
@@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { refundService } from "@/server/services/refund.service";
+import { auditLog } from "@/server/services/audit-log.service";
import { createRefundSchema, refundDecisionSchema } from "./schemas";
async function requireAdmin() {
@@ -31,7 +32,7 @@ export async function createRefundAction(formData: FormData) {
}
try {
- await refundService.requestRefund({
+ const refund = await refundService.requestRefund({
bookingId: parsed.data.bookingId,
reason: parsed.data.reason,
reportedBy: parsed.data.reportedBy,
@@ -39,6 +40,17 @@ export async function createRefundAction(formData: FormData) {
amount: parsed.data.amount,
initiatedByAdminId: admin.id,
});
+ await auditLog.record({
+ admin: { id: admin.id, email: admin.email },
+ action: "REFUND_CREATE",
+ entityType: "Refund",
+ entityId: refund.id,
+ payload: {
+ bookingId: parsed.data.bookingId,
+ amount: parsed.data.amount,
+ reason: parsed.data.reason,
+ },
+ });
revalidatePath("/admin/refunds");
return { success: true };
} catch (err) {
@@ -91,6 +103,13 @@ export async function decideRefundAction(formData: FormData) {
adminNote: adminNote!,
});
}
+ await auditLog.record({
+ admin: { id: admin.id, email: admin.email },
+ action: `REFUND_${decision}`,
+ entityType: "Refund",
+ entityId: refundId,
+ payload: adminNote ? { adminNote } : undefined,
+ });
revalidatePath("/admin/refunds");
return { success: true };
} catch (err) {
diff --git a/features/trip/actions.ts b/features/trip/actions.ts
index 72b4dc3..79be39c 100644
--- a/features/trip/actions.ts
+++ b/features/trip/actions.ts
@@ -12,6 +12,7 @@ import { organizerService } from "@/server/services/organizer.service";
import { revalidatePath } from "next/cache";
import { tripStoredInstantFromYmd } from "@/lib/trip-dates";
import { requireActiveUser } from "@/lib/auth-guards";
+import { auditLog } from "@/server/services/audit-log.service";
export async function createTripAction(formData: FormData) {
const session = await getServerSession(authOptions);
@@ -263,6 +264,18 @@ export async function adminCancelTripAction(tripId: string, reason: string) {
adminId: session.user.id,
reason: trimmedReason,
});
+ await auditLog.record({
+ admin: { id: session.user.id, email: session.user.email },
+ action: "TRIP_ADMIN_CANCEL",
+ entityType: "Trip",
+ entityId: tripId,
+ payload: {
+ reason: trimmedReason,
+ refundsCreated: result.refundsCreated.length,
+ cancelledBookings: result.cancelledBookings.length,
+ skippedBookings: result.skippedBookings.length,
+ },
+ });
revalidatePath(`/trips/${tripId}`);
revalidatePath(`/admin/trips/${tripId}`);
revalidatePath("/admin/trips");
diff --git a/lib/csv.ts b/lib/csv.ts
new file mode 100644
index 0000000..4673e86
--- /dev/null
+++ b/lib/csv.ts
@@ -0,0 +1,67 @@
+/**
+ * CSV helpers untuk admin export. Simple string-building โ bukan streaming โ
+ * karena admin export jarang lebih dari 10k row di MVP.
+ *
+ * Escape rule (RFC 4180):
+ * - Field yang berisi koma, quote, CR, atau LF โ bungkus quote, escape quote
+ * internal dengan dobel quote.
+ * - Field lain biarkan apa adanya.
+ */
+
+/** Escape satu cell sesuai aturan RFC 4180. */
+export function escapeCsvCell(value: unknown): string {
+ if (value == null) return "";
+ const str = String(value);
+ if (/[",\r\n]/.test(str)) {
+ return `"${str.replace(/"/g, '""')}"`;
+ }
+ return str;
+}
+
+/**
+ * Bangun string CSV lengkap dari headers + rows. Pakai CRLF (RFC 4180) supaya
+ * Excel di Windows happy.
+ */
+export function buildCsv(headers: string[], rows: unknown[][]): string {
+ const lines = [headers.map(escapeCsvCell).join(",")];
+ for (const row of rows) {
+ lines.push(row.map(escapeCsvCell).join(","));
+ }
+ return lines.join("\r\n") + "\r\n";
+}
+
+/**
+ * Bikin Response CSV siap pakai dari Next route handler.
+ * BOM ditambahkan supaya Excel auto-detect UTF-8 untuk karakter non-ASCII
+ * (mis. nama Indonesia dengan diakritik).
+ */
+export function csvResponse(filename: string, csv: string): Response {
+ const bom = "๏ปฟ";
+ return new Response(bom + csv, {
+ status: 200,
+ headers: {
+ "Content-Type": "text/csv; charset=utf-8",
+ "Content-Disposition": `attachment; filename="${filename}"`,
+ "Cache-Control": "no-store",
+ },
+ });
+}
+
+/** Format ISO untuk CSV (UTC, sortable). */
+export function csvDate(d: Date | null | undefined): string {
+ if (!d) return "";
+ return d.toISOString();
+}
+
+/** Tanggal Jakarta yang readable di Excel. */
+export function csvDateJakarta(d: Date | null | undefined): string {
+ if (!d) return "";
+ return d.toLocaleString("id-ID", {
+ day: "2-digit",
+ month: "2-digit",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ timeZone: "Asia/Jakarta",
+ });
+}
diff --git a/prisma/migrations/20260518180000_add_admin_action_log/migration.sql b/prisma/migrations/20260518180000_add_admin_action_log/migration.sql
new file mode 100644
index 0000000..e0f550d
--- /dev/null
+++ b/prisma/migrations/20260518180000_add_admin_action_log/migration.sql
@@ -0,0 +1,29 @@
+-- CreateTable: log polymorphic untuk admin actions lintas entity. Append-only,
+-- never update/delete. Dipakai untuk compliance & investigasi (siapa
+-- approve/reject/cancel/suspend, kapan, dengan payload apa).
+--
+-- `adminId` nullable + `adminEmail` snapshot supaya kalau admin dihapus,
+-- log entry tetap auditable (siapa via email, kapan, payload apa). FK
+-- ON DELETE SET NULL menjamin adminId di-clear tanpa cascade ke baris log.
+CREATE TABLE "AdminActionLog" (
+ "id" TEXT NOT NULL,
+ "adminId" TEXT,
+ "adminEmail" TEXT NOT NULL,
+ "action" TEXT NOT NULL,
+ "entityType" TEXT NOT NULL,
+ "entityId" TEXT NOT NULL,
+ "payload" JSONB,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "AdminActionLog_pkey" PRIMARY KEY ("id")
+);
+
+-- Index: filter "all actions by admin X" + "all actions on entity Y".
+CREATE INDEX "AdminActionLog_adminId_createdAt_idx" ON "AdminActionLog"("adminId", "createdAt" DESC);
+CREATE INDEX "AdminActionLog_entityType_entityId_idx" ON "AdminActionLog"("entityType", "entityId");
+CREATE INDEX "AdminActionLog_createdAt_idx" ON "AdminActionLog"("createdAt" DESC);
+
+-- FK: admin user โ SET NULL kalau admin dihapus supaya log tidak hilang.
+ALTER TABLE "AdminActionLog" ADD CONSTRAINT "AdminActionLog_adminId_fkey"
+ FOREIGN KEY ("adminId") REFERENCES "User"("id")
+ ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 8218c22..bf26b7c 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -49,6 +49,9 @@ model User {
/// Trip yang dibatalkan admin ini lewat panel admin (intervensi).
adminCancelledTrips Trip[] @relation("TripCancelledByAdmin")
+ /// Audit log polymorphic โ semua aksi admin yang dilakukan oleh user ini.
+ adminActionLogs AdminActionLog[] @relation("AdminActionLogAdmin")
+
profile UserProfile?
}
@@ -445,6 +448,31 @@ model Refund {
@@index([status, createdAt])
}
+/// Log polymorphic untuk admin actions lintas entity. Append-only โ kalau
+/// admin dihapus, `adminId` di-set NULL tapi `adminEmail` snapshot tetap.
+/// Dipakai untuk compliance & investigasi (siapa approve/reject/cancel/
+/// suspend, kapan, dengan payload apa).
+model AdminActionLog {
+ id String @id @default(cuid())
+ adminId String?
+ admin User? @relation("AdminActionLogAdmin", fields: [adminId], references: [id], onDelete: SetNull)
+ /// Snapshot email admin saat action dijalankan โ tetap ada meski admin dihapus.
+ adminEmail String
+ /// Nama aksi dalam SCREAMING_SNAKE, mis. `REFUND_APPROVE`, `TRIP_CANCEL`, `USER_SUSPEND`.
+ action String
+ /// Tipe entity yang di-target: `Refund` / `Payout` / `Trip` / `User` / `Verification` / `Payment`.
+ entityType String
+ entityId String
+ /// Payload bebas (input parameter, hasil, dst) untuk konteks investigasi.
+ payload Json?
+
+ createdAt DateTime @default(now())
+
+ @@index([adminId, createdAt(sort: Desc)])
+ @@index([entityType, entityId])
+ @@index([createdAt(sort: Desc)])
+}
+
/// Log per cron run untuk observability admin. Append-only.
/// `runCron(jobName, fn)` di `lib/cron-runner.ts` otomatis create row RUNNING
/// โ update SUCCESS/FAILED setelah selesai. Dipakai admin di `/admin/system`.
diff --git a/server/services/admin-search.service.ts b/server/services/admin-search.service.ts
new file mode 100644
index 0000000..3d7a2bc
--- /dev/null
+++ b/server/services/admin-search.service.ts
@@ -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--` / `manual-` โ 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 {
+ 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((u) => ({
+ type: "user",
+ id: u.id,
+ title: u.name,
+ subtitle: u.email,
+ href: `/admin/users/${u.id}`,
+ })),
+ ...trips.map((t) => ({
+ type: "trip",
+ id: t.id,
+ title: t.title,
+ subtitle: `${t.destination} ยท ${t.location}`,
+ href: `/admin/trips/${t.id}`,
+ })),
+ ].slice(0, limit);
+ },
+};
diff --git a/server/services/audit-log.service.ts b/server/services/audit-log.service.ts
new file mode 100644
index 0000000..67d4dc7
--- /dev/null
+++ b/server/services/audit-log.service.ts
@@ -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;
+ }): Promise {
+ 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,
+ });
+ }
+ },
+};