Compare commits

...

2 Commits

Author SHA1 Message Date
arifal b844ebdfac 0.14.0 2026-05-18 20:09:51 +07:00
arifal ea63f56e97 admin roadmap csv export, adminactionlog, global search 2026-05-18 20:09:22 +07:00
27 changed files with 1333 additions and 161 deletions
-107
View File
@@ -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.
+35 -25
View File
@@ -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.
+243
View File
@@ -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 (
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
<p className="text-sm text-neutral-600">
Halaman ini hanya untuk admin SeTrip.
</p>
</div>
);
}
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 (
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
<header className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
Audit Log
</h1>
<p className="mt-1 text-sm text-neutral-500">
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.
</p>
</header>
<AdminFilterBar
action="/admin/audit-log"
values={{
dateFrom: params.dateFrom,
dateTo: params.dateTo,
reviewer: params.reviewer,
}}
reviewerOptions={listAdminEmails()}
reviewerLabel="Admin"
/>
<form method="get" className="mb-4 grid gap-3 sm:grid-cols-2">
<input type="hidden" name="dateFrom" value={params.dateFrom ?? ""} />
<input type="hidden" name="dateTo" value={params.dateTo ?? ""} />
<input type="hidden" name="reviewer" value={params.reviewer ?? ""} />
<div>
<label
htmlFor="filter-entity"
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
>
Entity type
</label>
<select
id="filter-entity"
name="entityType"
defaultValue={params.entityType ?? ""}
className="w-full rounded-lg border border-neutral-200 bg-white px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400"
>
<option value="">Semua</option>
{ENTITY_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
<div>
<label
htmlFor="filter-action"
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
>
Action (contains)
</label>
<div className="flex gap-2">
<input
id="filter-action"
name="action"
defaultValue={params.action ?? ""}
placeholder="mis. REFUND, SUSPEND, CANCEL"
className="flex-1 rounded-lg border border-neutral-200 bg-white px-2 py-1.5 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-primary-400"
/>
<button
type="submit"
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700"
>
Cari
</button>
</div>
</div>
</form>
{logs.length === 0 ? (
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center">
<p className="text-sm text-neutral-500">
Tidak ada audit log yang cocok dengan filter ini.
</p>
</div>
) : (
<div className="overflow-x-auto rounded-2xl border border-neutral-200 bg-white shadow-sm">
<table className="min-w-full divide-y divide-neutral-100 text-sm">
<thead className="bg-neutral-50 text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
<tr>
<th className="px-3 py-2 text-left">Waktu</th>
<th className="px-3 py-2 text-left">Admin</th>
<th className="px-3 py-2 text-left">Action</th>
<th className="px-3 py-2 text-left">Entity</th>
<th className="px-3 py-2 text-left">Entity ID</th>
<th className="px-3 py-2 text-left">Payload</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100 text-xs text-neutral-700">
{logs.map((row) => (
<tr key={row.id}>
<td className="whitespace-nowrap px-3 py-2 text-neutral-500">
{row.createdAt.toLocaleString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</td>
<td className="whitespace-nowrap px-3 py-2">
{row.adminEmail}
{!row.adminId && (
<span className="ml-1 text-[10px] text-amber-700">
(deleted)
</span>
)}
</td>
<td className="whitespace-nowrap px-3 py-2">
<span className="rounded bg-primary-50 px-1.5 py-0.5 font-mono text-[11px] font-semibold text-primary-800">
{row.action}
</span>
</td>
<td className="whitespace-nowrap px-3 py-2 font-medium">
{row.entityType}
</td>
<td className="px-3 py-2">
<EntityIdLink
entityType={row.entityType}
entityId={row.entityId}
/>
</td>
<td className="px-3 py-2 text-neutral-500">
{row.payload ? (
<code className="block max-w-md overflow-x-auto rounded bg-neutral-50 px-2 py-1 font-mono text-[10px]">
{JSON.stringify(row.payload)}
</code>
) : (
"—"
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
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 (
<Link
href={href}
className="font-mono text-[11px] text-secondary-700 hover:text-secondary-900"
>
{short}
</Link>
);
}
return <span className="font-mono text-[11px]">{short}</span>;
}
+13 -1
View File
@@ -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,9 +82,15 @@ 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 (
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
<header className="mb-6">
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
Payout Organizer
</h1>
@@ -92,6 +99,11 @@ export default async function AdminPayoutsPage({ searchParams }: PageProps) {
status <strong>Siap transfer</strong>, admin transfer manual ke
rekening organizer lalu tandai sudah dibayar.
</p>
</div>
<ExportCsvLink
href="/api/admin/export/payouts"
query={exportQuery.toString()}
/>
</header>
<AdminFilterBar
+14 -1
View File
@@ -5,6 +5,7 @@ import { isAdminEmail, listAdminEmails } from "@/lib/admin";
import { refundRepo } from "@/server/repositories/refund.repo";
import { CreateRefundForm } from "@/features/refund/components/create-refund-form";
import { AdminFilterBar } from "@/features/admin/components/admin-filter-bar";
import { ExportCsvLink } from "@/features/admin/components/export-csv-link";
import {
RefundReviewCard,
type RefundCardData,
@@ -112,9 +113,16 @@ export default async function AdminRefundsPage({ searchParams }: PageProps) {
},
}));
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);
if (reason) exportQuery.set("reason", reason);
return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
<header className="mb-6">
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
Review Refund Manual
</h1>
@@ -122,6 +130,11 @@ export default async function AdminRefundsPage({ searchParams }: PageProps) {
Tinjau laporan refund dari peserta dan organizer. Setiap refund harus
melalui approval admin sebelum dieksekusi.
</p>
</div>
<ExportCsvLink
href="/api/admin/export/refunds"
query={exportQuery.toString()}
/>
</header>
<CreateRefundForm />
+13 -1
View File
@@ -5,6 +5,7 @@ import { isAdminEmail, listAdminEmails } from "@/lib/admin";
import { organizerRepo } from "@/server/repositories/organizer.repo";
import { organizerService } from "@/server/services/organizer.service";
import { AdminFilterBar } from "@/features/admin/components/admin-filter-bar";
import { ExportCsvLink } from "@/features/admin/components/export-csv-link";
import { ReviewCard } from "@/features/organizer/components/review-card";
type Tab = "PENDING" | "APPROVED" | "REJECTED";
@@ -69,9 +70,15 @@ export default async function AdminVerificationsPage({ searchParams }: PageProps
{ key: "REJECTED", label: "Ditolak" },
];
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 (
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
<header className="mb-6">
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
Review Verifikasi Organizer
</h1>
@@ -79,6 +86,11 @@ export default async function AdminVerificationsPage({ searchParams }: PageProps
Periksa data KTP, foto liveness (memegang kertas SETRIP), dan rekening
sebelum menyetujui.
</p>
</div>
<ExportCsvLink
href="/api/admin/export/verifications"
query={exportQuery.toString()}
/>
</header>
<AdminFilterBar
+96
View File
@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { payoutRepo } from "@/server/repositories/payout.repo";
import { buildCsv, csvDateJakarta, csvResponse } from "@/lib/csv";
import type { PayoutStatus } from "@/app/generated/prisma/enums";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const VALID_STATUS = new Set<PayoutStatus>([
"HELD",
"RELEASED",
"PAID",
"CANCELLED",
]);
function parseDate(value: string | null): Date | undefined {
if (!value) return undefined;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? undefined : d;
}
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user || !isAdminEmail(session.user.email)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const params = req.nextUrl.searchParams;
const statusParam = params.get("status");
if (!statusParam || !VALID_STATUS.has(statusParam as PayoutStatus)) {
return NextResponse.json(
{ error: "status param wajib (HELD/RELEASED/PAID/CANCELLED)" },
{ status: 400 }
);
}
const status = statusParam as PayoutStatus;
const rows = await payoutRepo.listByStatus(status, {
dateFrom: parseDate(params.get("dateFrom")),
dateTo: parseDate(params.get("dateTo")),
processorEmail: params.get("reviewer") || undefined,
});
const csv = buildCsv(
[
"Payout ID",
"Status",
"Nominal (IDR)",
"Currency",
"Held until",
"Released at",
"Paid at",
"Cancelled at",
"Processor email",
"Admin note",
"Dibuat",
"Organizer nama",
"Organizer email",
"Bank nama",
"Bank rekening",
"Bank atas nama",
"Trip ID",
"Trip judul",
"Booking ID",
"Peserta nama",
],
rows.map((p) => [
p.id,
p.status,
p.amount,
p.currency,
csvDateJakarta(p.heldUntil),
csvDateJakarta(p.releasedAt),
csvDateJakarta(p.paidAt),
csvDateJakarta(p.cancelledAt),
p.processedBy?.email ?? "",
p.adminNote ?? "",
csvDateJakarta(p.createdAt),
p.organizer.name,
p.organizer.email,
p.bankName ?? "",
p.bankAccountNumber ?? "",
p.bankAccountName ?? "",
p.trip.id,
p.trip.title,
p.booking.id,
p.booking.user.name,
])
);
const today = new Date().toISOString().slice(0, 10);
return csvResponse(`payouts-${status}-${today}.csv`, csv);
}
+114
View File
@@ -0,0 +1,114 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { refundRepo } from "@/server/repositories/refund.repo";
import { buildCsv, csvDateJakarta, csvResponse } from "@/lib/csv";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const VALID_STATUS = new Set([
"PENDING",
"APPROVED",
"REJECTED",
"PROCESSING",
"SUCCEEDED",
"FAILED",
]);
const VALID_REASON = new Set([
"USER_CANCELLATION",
"ORGANIZER_CANCELLED",
"TRIP_ISSUE",
"ADMIN_ADJUSTMENT",
"DISPUTE_RESOLVED",
]);
function parseDate(value: string | null): Date | undefined {
if (!value) return undefined;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? undefined : d;
}
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user || !isAdminEmail(session.user.email)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const params = req.nextUrl.searchParams;
const statusParam = params.get("status");
const status =
statusParam && VALID_STATUS.has(statusParam)
? (statusParam as
| "PENDING"
| "APPROVED"
| "REJECTED"
| "PROCESSING"
| "SUCCEEDED"
| "FAILED")
: undefined;
const reasonParam = params.get("reason");
const reason =
reasonParam && VALID_REASON.has(reasonParam)
? (reasonParam as
| "USER_CANCELLATION"
| "ORGANIZER_CANCELLED"
| "TRIP_ISSUE"
| "ADMIN_ADJUSTMENT"
| "DISPUTE_RESOLVED")
: undefined;
const rows = await refundRepo.listByStatus(status, {
dateFrom: parseDate(params.get("dateFrom")),
dateTo: parseDate(params.get("dateTo")),
reviewerEmail: params.get("reviewer") || undefined,
reason,
});
const csv = buildCsv(
[
"Refund ID",
"Status",
"Reason",
"Nominal (IDR)",
"Dilaporkan oleh",
"Catatan laporan",
"Catatan admin",
"Dibuat",
"Reviewed at",
"Succeeded at",
"Failed at",
"Reviewer email",
"Booking ID",
"Peserta nama",
"Peserta email",
"Trip ID",
"Trip judul",
"Trip tanggal",
],
rows.map((r) => [
r.id,
r.status,
r.reason,
r.amount,
r.reportedBy,
r.reportNote,
r.adminNote ?? "",
csvDateJakarta(r.createdAt),
csvDateJakarta(r.reviewedAt),
csvDateJakarta(r.succeededAt),
csvDateJakarta(r.failedAt),
r.reviewedBy?.email ?? "",
r.booking.id,
r.booking.user.name,
r.booking.user.email,
r.booking.trip.id,
r.booking.trip.title,
csvDateJakarta(r.booking.trip.date),
])
);
const today = new Date().toISOString().slice(0, 10);
return csvResponse(`refunds-${status ?? "all"}-${today}.csv`, csv);
}
@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { organizerRepo } from "@/server/repositories/organizer.repo";
import { buildCsv, csvDateJakarta, csvResponse } from "@/lib/csv";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const VALID_STATUS = new Set(["PENDING", "APPROVED", "REJECTED"]);
function parseDate(value: string | null): Date | undefined {
if (!value) return undefined;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? undefined : d;
}
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user || !isAdminEmail(session.user.email)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const params = req.nextUrl.searchParams;
const statusParam = params.get("status");
const status =
statusParam && VALID_STATUS.has(statusParam)
? (statusParam as "PENDING" | "APPROVED" | "REJECTED")
: undefined;
const rows = await organizerRepo.listByStatus(status, {
dateFrom: parseDate(params.get("dateFrom")),
dateTo: parseDate(params.get("dateTo")),
reviewerEmail: params.get("reviewer") || undefined,
});
// SENGAJA tidak ekspor: NIK plaintext (encrypted), ktpImageKey, livenessKey,
// bankAccountNumber. Export ini hanya untuk metadata audit — KYC sensitive
// info tetap di DB & cuma diakses lewat admin UI dengan auth gate.
const csv = buildCsv(
[
"Verification ID",
"Status",
"Nama (KTP)",
"User nama",
"User email",
"Bank nama",
"Bank atas nama",
"Dibuat",
"Reviewed at",
"Verified at",
"Rejection reason",
"Reviewer email",
],
rows.map((v) => [
v.id,
v.status,
v.fullName,
v.user.name,
v.user.email,
v.bankName,
v.bankAccountName,
csvDateJakarta(v.createdAt),
csvDateJakarta(v.reviewedAt),
csvDateJakarta(v.verifiedAt),
v.rejectionReason ?? "",
v.reviewedBy?.email ?? "",
])
);
const today = new Date().toISOString().slice(0, 10);
return csvResponse(`verifications-${status ?? "all"}-${today}.csv`, csv);
}
+19
View File
@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { adminSearchService } from "@/server/services/admin-search.service";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user || !isAdminEmail(session.user.email)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const q = req.nextUrl.searchParams.get("q") ?? "";
const hits = await adminSearchService.resolve(q, 10);
return NextResponse.json({ hits });
}
+6
View File
@@ -5,6 +5,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import Image from "next/image";
import { signOut } from "next-auth/react";
import { AdminSearchBar } from "@/features/admin/components/admin-search-bar";
const NAV_ITEMS: { href: string; label: string; icon: string }[] = [
{ href: "/admin", label: "Dashboard", icon: "📊" },
@@ -13,6 +14,7 @@ const NAV_ITEMS: { href: string; label: string; icon: string }[] = [
{ href: "/admin/verifications", label: "Verifikasi", icon: "🪪" },
{ href: "/admin/refunds", label: "Refund", icon: "↩️" },
{ href: "/admin/payouts", label: "Payout", icon: "💸" },
{ href: "/admin/audit-log", label: "Audit Log", icon: "📜" },
{ href: "/admin/system", label: "System", icon: "⚙️" },
];
@@ -93,6 +95,10 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
</div>
</div>
<div className="border-b border-neutral-100 p-3">
<AdminSearchBar />
</div>
<nav className="flex-1 overflow-y-auto p-3">
<ul className="space-y-1">
{NAV_ITEMS.map((item) => {
+84
View File
@@ -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.
+14
View File
@@ -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 };
@@ -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<Hit[]>([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const wrapperRef = useRef<HTMLDivElement | null>(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 (
<div ref={wrapperRef} className="relative">
<input
type="search"
value={query}
onChange={(e) => {
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 && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-80 overflow-y-auto rounded-xl border border-neutral-200 bg-white shadow-xl">
{loading && (
<p className="px-3 py-2 text-[11px] text-neutral-500">Mencari...</p>
)}
{!loading && hits.length === 0 && (
<p className="px-3 py-2 text-[11px] text-neutral-500">
Tidak ada hasil.
</p>
)}
{!loading && hits.length > 0 && (
<ul className="py-1">
{hits.map((h) => (
<li key={`${h.type}-${h.id}`}>
<Link
href={h.href}
onClick={() => {
setOpen(false);
setQuery("");
}}
className="flex items-center gap-2 px-3 py-2 hover:bg-neutral-50"
>
<span
className={`rounded-full px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide ${
h.type === "user"
? "bg-primary-100 text-primary-700"
: h.type === "trip"
? "bg-secondary-100 text-secondary-700"
: "bg-amber-100 text-amber-700"
}`}
>
{h.type}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-semibold text-neutral-800">
{h.title}
</p>
<p className="truncate text-[10px] text-neutral-500">
{h.subtitle}
</p>
</div>
</Link>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
@@ -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 (
<a
href={url}
className="inline-flex items-center gap-1.5 rounded-xl border border-neutral-200 bg-white px-3 py-1.5 text-xs font-semibold text-neutral-700 hover:bg-neutral-50"
download
>
<span aria-hidden></span>
<span>{label}</span>
</a>
);
}
+8
View File
@@ -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 };
+21
View File
@@ -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");
+8
View File
@@ -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");
+20 -1
View File
@@ -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) {
+13
View File
@@ -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");
+67
View File
@@ -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",
});
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "setrip",
"version": "0.13.0",
"version": "0.14.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "setrip",
"version": "0.13.0",
"version": "0.14.0",
"dependencies": {
"@next-auth/prisma-adapter": "^1.0.7",
"@prisma/adapter-pg": "^7.7.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "setrip",
"version": "0.13.0",
"version": "0.14.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -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;
+28
View File
@@ -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`.
+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,
});
}
},
};