Compare commits
2 Commits
244a6da9bb
...
b844ebdfac
| Author | SHA1 | Date | |
|---|---|---|---|
| b844ebdfac | |||
| ea63f56e97 |
@@ -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
@@ -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.
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||
Payout Organizer
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
Uang peserta ditahan (escrow) sampai trip selesai + 3 hari. Setelah
|
||||
status <strong>Siap transfer</strong>, admin transfer manual ke
|
||||
rekening organizer lalu tandai sudah dibayar.
|
||||
</p>
|
||||
<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>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
Uang peserta ditahan (escrow) sampai trip selesai + 3 hari. Setelah
|
||||
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
|
||||
|
||||
@@ -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,16 +113,28 @@ 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">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||
Review Refund Manual
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
Tinjau laporan refund dari peserta dan organizer. Setiap refund harus
|
||||
melalui approval admin sebelum dieksekusi.
|
||||
</p>
|
||||
<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>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
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 />
|
||||
|
||||
@@ -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,16 +70,27 @@ 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">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||
Review Verifikasi Organizer
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
Periksa data KTP, foto liveness (memegang kertas SETRIP), dan rekening
|
||||
sebelum menyetujui.
|
||||
</p>
|
||||
<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>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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",
|
||||
});
|
||||
}
|
||||
Generated
+2
-2
@@ -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
@@ -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;
|
||||
@@ -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`.
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user