From 6e02f2f0d7ffa12fd519aa9ccbecb8d9b9be58eb Mon Sep 17 00:00:00 2001
From: arifal
Date: Mon, 18 May 2026 19:45:14 +0700
Subject: [PATCH] admin roadmap filter & search, user management, reopen
rejected, system health
---
ADMIN_AUDIT_ROADMAP.md | 28 +-
ADMIN_ROADMAP.md | 53 +--
ADMIN_SYSTEM_HEALTH_ROADMAP.md | 103 ------
ADMIN_USER_MGMT_ROADMAP.md | 81 ----
ADMIN_VERIFICATION_ROADMAP.md | 88 -----
app/admin/payouts/page.tsx | 38 +-
app/admin/refunds/page.tsx | 56 ++-
app/admin/system/page.tsx | 258 +++++++++++++
app/admin/users/[id]/page.tsx | 349 ++++++++++++++++++
app/admin/users/page.tsx | 167 +++++++++
app/admin/verifications/page.tsx | 38 +-
app/api/cron/auto-complete-trips/route.ts | 21 +-
components/admin/admin-sidebar.tsx | 2 +
docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md | 48 +++
docs/archive/ADMIN_USER_MGMT_ROADMAP.md | 55 +++
docs/archive/ADMIN_VERIFICATION_ROADMAP.md | 34 ++
features/admin/actions.ts | 49 +++
.../admin/components/admin-filter-bar.tsx | 147 ++++++++
.../admin/components/suspend-user-button.tsx | 139 +++++++
features/organizer/actions.ts | 28 ++
features/organizer/components/review-card.tsx | 77 +++-
features/trip/actions.ts | 7 +
lib/admin.ts | 5 +
lib/auth-guards.ts | 18 +
lib/auth.ts | 16 +
lib/cron-runner.ts | 67 ++++
.../migration.sql | 15 +
.../20260518170000_add_cron_run/migration.sql | 22 ++
prisma/schema.prisma | 32 ++
server/repositories/organizer.repo.ts | 40 +-
server/repositories/payout.repo.ts | 21 +-
server/repositories/refund.repo.ts | 29 +-
server/repositories/trip.repo.ts | 6 +-
server/repositories/user.repo.ts | 129 +++++++
server/services/organizer.service.ts | 32 ++
server/services/user.service.ts | 54 +++
36 files changed, 2013 insertions(+), 339 deletions(-)
delete mode 100644 ADMIN_SYSTEM_HEALTH_ROADMAP.md
delete mode 100644 ADMIN_USER_MGMT_ROADMAP.md
delete mode 100644 ADMIN_VERIFICATION_ROADMAP.md
create mode 100644 app/admin/system/page.tsx
create mode 100644 app/admin/users/[id]/page.tsx
create mode 100644 app/admin/users/page.tsx
create mode 100644 docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md
create mode 100644 docs/archive/ADMIN_USER_MGMT_ROADMAP.md
create mode 100644 docs/archive/ADMIN_VERIFICATION_ROADMAP.md
create mode 100644 features/admin/actions.ts
create mode 100644 features/admin/components/admin-filter-bar.tsx
create mode 100644 features/admin/components/suspend-user-button.tsx
create mode 100644 lib/auth-guards.ts
create mode 100644 lib/cron-runner.ts
create mode 100644 prisma/migrations/20260518160000_add_user_suspension/migration.sql
create mode 100644 prisma/migrations/20260518170000_add_cron_run/migration.sql
create mode 100644 server/services/user.service.ts
diff --git a/ADMIN_AUDIT_ROADMAP.md b/ADMIN_AUDIT_ROADMAP.md
index a32251f..4baaf40 100644
--- a/ADMIN_AUDIT_ROADMAP.md
+++ b/ADMIN_AUDIT_ROADMAP.md
@@ -1,5 +1,7 @@
# 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.
@@ -18,27 +20,23 @@ Admin perlu **mencari** lintas entity (booking/payment/refund/user/trip) dan **e
---
-## Phase 1 โ Filter & Search Enhancements โณ
-
-Sebelum bikin audit log baru, perbaiki dulu kemampuan cari & filter di list yang sudah ada.
-
-**Keputusan asumsi:**
-- Pakai `searchParams` di Next.js โ tidak perlu state client (server-render fast + shareable URL).
-- Default date range: 30 hari terakhir, supaya page tidak load semua history.
-- Reviewer dropdown sumber dari `ADMIN_EMAILS` env (sudah ada).
+## Phase 1 โ Filter & Search Enhancements โ
DELIVERED
| # | Item | Status | File |
|---|---|---|---|
-| 1.1 | Filter date range (`from`, `to`) di `/admin/refunds` | โณ | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
-| 1.2 | Filter `reviewedBy` (admin email dropdown) di `/admin/refunds` | โณ | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
-| 1.3 | Filter `reason` di `/admin/refunds` (lihat juga [ADMIN_PAYMENT_OPS_ROADMAP.md](ADMIN_PAYMENT_OPS_ROADMAP.md)) | โณ | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
-| 1.4 | Filter date range + `processedBy` di `/admin/payouts` | โณ | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) |
-| 1.5 | Filter date range + `reviewedBy` di `/admin/verifications` | โณ | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) |
-| 1.6 | Tampilkan kolom "reviewer email" + "reviewed at" di tabel/list (semua admin pages) | โณ | semua `app/admin/*/page.tsx` |
-| 1.7 | Repo helper: tambah optional filter params di `refundRepo.listByStatus`, `payoutRepo.listByStatus`, `organizerRepo.listByStatus` | โณ | `server/repositories/*.ts` |
+| 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 โณ
diff --git a/ADMIN_ROADMAP.md b/ADMIN_ROADMAP.md
index ec260ba..13ee075 100644
--- a/ADMIN_ROADMAP.md
+++ b/ADMIN_ROADMAP.md
@@ -12,10 +12,14 @@ Status implementasi kemampuan admin agar admin **dapat mengontrol seluruh aplika
|---|---|---|
| Dashboard | View count: verifikasi PENDING, refund per status, payout per status | [app/admin/page.tsx](app/admin/page.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 organizer (KTP, liveness, bank) | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) |
-| Refund | Create manual, approve, reject, mark SUCCEEDED, mark FAILED + link ke booking timeline | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
-| Payout | View per status, mark PAID setelah transfer manual + link ke booking timeline | [app/admin/payouts/page.tsx](app/admin/payouts/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) |
+| **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.
Auth admin: env `ADMIN_EMAILS` โ cek di [lib/admin.ts](lib/admin.ts), dipassing ke session via [lib/auth.ts](lib/auth.ts).
@@ -27,38 +31,45 @@ Auth admin: env `ADMIN_EMAILS` โ cek di [lib/admin.ts](lib/admin.ts), dipassin
|---|---|---|---|
| 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 | โณ 0% | [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) |
-| User Management (search, suspend/ban) | ๐ก MEDIUM | โณ 0% | [ADMIN_USER_MGMT_ROADMAP.md](ADMIN_USER_MGMT_ROADMAP.md) |
-| Verification (reopen, re-upload request) | ๐ก MEDIUM | โณ 0% | [ADMIN_VERIFICATION_ROADMAP.md](ADMIN_VERIFICATION_ROADMAP.md) |
-| System Health (cron monitor, stale state alerts) | ๐ก MEDIUM | โณ 0% | [ADMIN_SYSTEM_HEALTH_ROADMAP.md](ADMIN_SYSTEM_HEALTH_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) |
-**Legend status:** โณ belum mulai ยท ๐ง partial ยท โ
selesai (lihat archive untuk detail delivery)
+**Legend status:** โณ belum mulai ยท ๐ง partial ยท โ
selesai
---
-## Iterasi berikutnya (sisa HIGH + MEDIUM)
+## Sisa pekerjaan
-Setelah Trip Ops + Payment Ops, urutan berikutnya:
+Hampir semua kapabilitas dasar admin sudah delivered. Yang tersisa hanya enhancement non-blocking:
-1. **Audit & Investigation** (HIGH) โ filter date range, search global, CSV export. Penting untuk compliance & investigasi dispute.
-2. **User Management** (MEDIUM) โ search + suspend/ban. Butuh schema change (`User.suspended`).
-3. **System Health** (MEDIUM) โ cron monitor + stale state alerts. Butuh model baru (`CronRun`).
-4. **Verification** (MEDIUM) โ reopen REJECTED + re-upload request. Edge case rare tapi kecil scope.
+**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
---
-## Tindakan manual setelah deploy
-
-Untuk versi yang berisi delivery Trip Ops + Payment Ops:
+## Tindakan manual setelah deploy versi terakhir
```bash
-# Apply migration baru (add_trip_admin_cancel)
+# Apply 3 migration baru: add_trip_admin_cancel, add_user_suspension, add_cron_run
npx prisma migrate deploy
# Restart Next.js / PM2 supaya Prisma client baru ter-load
pm2 restart setrip --update-env
```
-Brief admin tentang dua kapabilitas baru:
-- **Force-cancel trip** di `/admin/trips/[id]` โ pakai saat organizer unreachable / dispute, reason wajib min 10 karakter.
-- **Reconcile Midtrans** di `/admin/bookings/[id]` โ pakai saat peserta lapor "sudah bayar tapi status belum update". Idempotent, aman diulang.
+Brief admin tentang kapabilitas baru:
+- **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.
diff --git a/ADMIN_SYSTEM_HEALTH_ROADMAP.md b/ADMIN_SYSTEM_HEALTH_ROADMAP.md
deleted file mode 100644
index 40d62e1..0000000
--- a/ADMIN_SYSTEM_HEALTH_ROADMAP.md
+++ /dev/null
@@ -1,103 +0,0 @@
-# Setrip โ Admin System Health Roadmap
-
-Admin perlu visibilitas atas job otomatis (cron) dan deteksi state yang nyangkut (Payment stale, Payout overdue, Refund mandek).
-
-> **Skenario nyata:** cron auto-complete trip crash karena env variable rusak. 50 trip yang sudah lewat tanggalnya tetap `OPEN` selama 3 hari sampai peserta komplain "kenapa belum bisa kasih review". Admin tidak punya cara cek status cron tanpa SSH ke server.
-
----
-
-## Baseline
-
-- โ
Cron infra ada (system crontab + `CRON_SECRET`) โ lihat [docs/CRON_SETUP.md](docs/CRON_SETUP.md).
-- โ
Cron jobs aktif: `app/api/cron/auto-complete-trips/route.ts`, payout release (lewat `payoutService`), kemungkinan refund timeout.
-- โ Tidak ada log/audit per cron run (success/fail/error).
-- โ Tidak ada page `/admin/system` untuk lihat status.
-- โ Tidak ada alert deteksi state stale (Payment AWAITING > 24h, Payout HELD past `heldUntil`, Refund APPROVED > 7d).
-
----
-
-## Phase 1 โ Cron Run Log โณ
-
-Tabel `CronRun` yang dicatat setiap kali cron jalan. Foundation untuk semua observability.
-
-**Keputusan asumsi:**
-- Append-only model. Retention: keep all (tabel kecil, ~365 rows/year/cron). Cleanup nanti kalau perlu.
-- Wrap existing cron handler dengan helper `runCron(name, fn)` yang otomatis log start/finish/error.
-- Tidak pakai job library (BullMQ/Inngest) โ overkill. Tetap pakai system cron + Next route handler.
-
-| # | Item | Status | File |
-|---|---|---|---|
-| 1.1 | Model `CronRun { id, jobName, startedAt, finishedAt?, status (RUNNING/SUCCESS/FAILED), errorMessage?, payload? Json }` + migration | โณ | [prisma/schema.prisma](prisma/schema.prisma) |
-| 1.2 | Helper `runCron(jobName, fn)` โ wrap handler, otomatis create RUNNING row โ SUCCESS/FAILED | โณ | `lib/cron-runner.ts` |
-| 1.3 | Wire `runCron` di `app/api/cron/auto-complete-trips/route.ts` | โณ | `app/api/cron/auto-complete-trips/route.ts` |
-| 1.4 | Wire `runCron` di cron payout release (kalau sudah ada โ kalau belum, daftar sebagai gap) | โณ | TBD |
-| 1.5 | Wire `runCron` di cron lain (refund sweep, dst) | โณ | TBD |
-
-**Tindakan manual:** tidak ada.
-
----
-
-## Phase 2 โ System Status Page โณ
-
-Page `/admin/system` yang tampilkan kondisi terkini.
-
-**Keputusan asumsi:**
-- Tabel per cron job: last run, last success, total runs (7d), error count (7d).
-- Refresh manual (tombol "Refresh") โ bukan auto-poll. Cukup untuk admin.
-- Health badge: ๐ข OK (last success < 25 jam untuk daily), ๐ก STALE (> 25 jam), ๐ด FAILED (last run = FAILED).
-- Tampilkan 20 cron run terbaru di table bawah untuk drill-down.
-
-| # | Item | Status | File |
-|---|---|---|---|
-| 2.1 | `cronRepo.getJobSummary(jobName)` โ last run, last success, count 7d | โณ | `server/repositories/cron.repo.ts` |
-| 2.2 | `cronRepo.listRecent(limit)` โ 20 run terakhir lintas job | โณ | `server/repositories/cron.repo.ts` |
-| 2.3 | Page `/admin/system` โ tabel job summary + tabel recent runs | โณ | `app/admin/system/page.tsx` |
-| 2.4 | Health badge logic (helper) | โณ | `lib/cron-health.ts` |
-| 2.5 | Link "System" di admin navbar | โณ | [app/admin/layout.tsx](app/admin/layout.tsx) |
-
-**Tindakan manual:**
-1. Set ekspektasi SLA per cron (mis. `auto-complete-trips` harus jalan setiap hari sebelum jam 06:00 WIB).
-2. Brief admin: cek `/admin/system` minimal sekali per hari pagi sebelum mulai kerja.
-
----
-
-## Phase 3 โ Stale State Alerts โณ
-
-Deteksi entity yang nyangkut di state non-final terlalu lama. Tampilkan sebagai banner di `/admin/system`.
-
-**Keputusan asumsi:**
-- Stale thresholds (review dengan stakeholder, ini draft):
- - Payment status `PENDING` > 1 jam โ suspect: gagal create Snap token, perlu manual cleanup
- - Payment status `AWAITING` > 25 jam (lebih dari expiresAt) โ suspect: webhook gagal, expire belum di-set, perlu reconcile
- - Booking status `AWAITING_PAY` + trip date < today โ suspect: peserta lupa bayar, butuh cleanup
- - Payout status `HELD` + `heldUntil < now` > 1 hari โ suspect: cron release tidak jalan, perlu trigger manual
- - Refund status `APPROVED` > 7 hari โ suspect: admin lupa proses, atau Midtrans refund gagal
-- Compute via query parameter pada page load โ tidak perlu materialized view.
-- Setiap kategori tampilkan jumlah + link ke filtered list page yang relevan.
-
-| # | Item | Status | File |
-|---|---|---|---|
-| 3.1 | `systemHealthService.detectStale()` return `{ stalePayments, expiredAwaiting, awaitingPayPastDeparture, overduePayouts, stuckRefunds }` | โณ | `server/services/system-health.service.ts` |
-| 3.2 | Banner alerts di `/admin/system` kalau ada count > 0 | โณ | `app/admin/system/page.tsx` |
-| 3.3 | Link tiap alert ke filtered list (pakai filter di [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) Phase 1) | โณ | `app/admin/system/page.tsx` |
-| 3.4 | Stat card di dashboard utama `/admin` kalau ada alert | โณ | [app/admin/page.tsx](app/admin/page.tsx) |
-
-**Tindakan manual:**
-1. Tuning threshold setelah jalan 1-2 minggu (false positive vs miss).
-2. SOP per alert: action apa yang admin harus ambil saat banner muncul.
-
----
-
-## Phase 4 โ External Alerting (opsional) โณ
-
-Push notif ke channel eksternal (Discord/Telegram/email) saat ada cron FAILED atau stale state critical. Skip kecuali admin sering miss banner.
-
-| # | Item | Status | File |
-|---|---|---|---|
-| 4.1 | Helper `notifyAdmins(message)` โ POST ke Discord webhook URL dari env | โณ | `lib/admin-notify.ts` |
-| 4.2 | Trigger notify di `runCron` saat FAILED | โณ | `lib/cron-runner.ts` |
-| 4.3 | Trigger notify dari `systemHealthService.detectStale` (rate-limited, max 1x/hari per kategori) | โณ | `server/services/system-health.service.ts` |
-
-**Tindakan manual:**
-1. Buat channel Discord internal + webhook URL โ set env `ADMIN_ALERT_WEBHOOK_URL`.
-2. Test alert dengan trigger fake fail.
diff --git a/ADMIN_USER_MGMT_ROADMAP.md b/ADMIN_USER_MGMT_ROADMAP.md
deleted file mode 100644
index 5ee65f3..0000000
--- a/ADMIN_USER_MGMT_ROADMAP.md
+++ /dev/null
@@ -1,81 +0,0 @@
-# Setrip โ Admin User Management Roadmap
-
-Admin perlu bisa cari user dan **suspend** akun yang melakukan abuse (scam, harassment, fake review).
-
-> **Skenario nyata:** organizer scam berkali-kali bikin trip palsu pakai alias berbeda. Peserta lapor harassment dari user lain di grup WA trip. Saat ini admin cuma bisa refund korban โ pelaku tetap bisa lanjut bikin trip baru / join trip lain.
-
----
-
-## Baseline
-
-- โ
`userRepo.findByEmail()` dan `userRepo.findById()` ada di [server/repositories/user.repo.ts](server/repositories/user.repo.ts).
-- โ
`User` model lengkap dengan relasi ke `trips`, `participations`, `bookings`, `tripReviews`, `organizerVerification`, `payouts`.
-- โ Tidak ada page `/admin/users`.
-- โ Tidak ada field `suspended` di `User`.
-- โ Tidak ada guard di auth/server actions yang reject suspended user.
-- โ Tidak ada stats user (total signups, organizer aktif, peserta aktif).
-
----
-
-## Phase 1 โ User List & Detail View โณ
-
-Baseline visibility. Sama pola dengan trip ops โ list + search + detail.
-
-**Keputusan asumsi:**
-- Search by email exact match dulu (paling sering dipakai admin saat ada laporan); kalau perlu, tambah name LIKE search nanti.
-- Detail page tampilkan: profile, verification status, booking history (sebagai peserta), trip history (sebagai organizer), payout history (sebagai organizer), review yang dibuat & diterima.
-- Sensitive info (password hash, OAuth tokens) **tidak** ditampilkan.
-
-| # | Item | Status | File |
-|---|---|---|---|
-| 1.1 | `userRepo.searchForAdmin({ q?, role?, suspended? })` โ q match email atau name (case insensitive) | โณ | [server/repositories/user.repo.ts](server/repositories/user.repo.ts) |
-| 1.2 | Page `/admin/users` โ list + search bar + filter (organizer/participant/suspended) | โณ | `app/admin/users/page.tsx` |
-| 1.3 | Page `/admin/users/[id]` โ profil + tabs (Bookings, Trips Dibuat, Reviews, Verification) | โณ | `app/admin/users/[id]/page.tsx` |
-| 1.4 | Stats card di top: total bookings, total spent, total revenue (kalau organizer), verification status | โณ | `app/admin/users/[id]/page.tsx` |
-| 1.5 | Link "Users" di admin navbar | โณ | [app/admin/layout.tsx](app/admin/layout.tsx) |
-
-**Tindakan manual:** tidak ada.
-
----
-
-## Phase 2 โ User Suspension โณ
-
-Toggle suspend yang mencegah suspended user login + melakukan aksi mutatif.
-
-**Keputusan asumsi:**
-- Tambah 4 kolom di `User`: `suspended Boolean @default(false)`, `suspendedAt DateTime?`, `suspendedReason String?`, `suspendedBy String?` (FK User admin).
-- **Block sign-in** di NextAuth callbacks (`signIn` callback return false kalau `user.suspended`). Untuk JWT session sudah aktif, cek `suspended` di `session` callback lalu invalidate.
-- **Block mutating actions** via helper `requireActiveUser(session)` yang dipanggil di awal setiap server action mutating (joinTrip, createTrip, addReview, dst).
-- Suspended user **tetap bisa** lihat data sendiri (refund history, dll) โ tidak hard-delete supaya audit trail terjaga.
-- Suspended organizer otomatis sembunyikan trip OPEN/FULL miliknya dari public list โ tambah filter di `tripRepo.findOpen` (`organizer: { suspended: false }`).
-- Unsuspend = toggle false + clear field โ tetap simpan history via [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) Phase 4.
-
-| # | Item | Status | File |
-|---|---|---|---|
-| 2.1 | Migration: tambah `suspended`, `suspendedAt`, `suspendedReason`, `suspendedBy` di `User` | โณ | `prisma/migrations/` |
-| 2.2 | `userService.suspendUser(userId, adminId, reason)` + `unsuspendUser(userId, adminId)` | โณ | `server/services/user.service.ts` |
-| 2.3 | Block sign-in di NextAuth `signIn` callback | โณ | [lib/auth.ts](lib/auth.ts) |
-| 2.4 | Helper `requireActiveUser(session)` throw kalau suspended | โณ | `lib/auth-guards.ts` |
-| 2.5 | Wire `requireActiveUser` di semua mutating server action (joinTripAction, createTripAction, createReviewAction, dst) | โณ | `features/*/actions.ts` |
-| 2.6 | Filter trip public list: organizer tidak suspended | โณ | [server/repositories/trip.repo.ts](server/repositories/trip.repo.ts) |
-| 2.7 | UI: tombol "Suspend" / "Unsuspend" di `/admin/users/[id]` + modal reason wajib | โณ | `app/admin/users/[id]/page.tsx` |
-| 2.8 | Badge "SUSPENDED" di user list + detail header (visual jelas) | โณ | `app/admin/users/[id]/page.tsx` |
-| 2.9 | Server action `suspendUserAction` + `unsuspendUserAction` (guard isAdmin) | โณ | `features/admin/actions.ts` (baru) atau `features/user/actions.ts` |
-
-**Tindakan manual:**
-1. Brief admin: kriteria suspend (3 kategori: scam, harassment, repeated TOS violation). Hindari subjective suspend.
-2. Tulis halaman info "Akun ditangguhkan" yang ditampilkan saat suspended user coba login (jelaskan kenapa & cara appeal via email).
-3. Pertimbangkan suspended user di Midtrans webhook โ saat ada payment masuk untuk suspended user's booking, tetap di-PAID (uang tetap diterima, refund proses normal).
-
----
-
-## Phase 3 โ User Analytics (low priority, skip MVP) โณ
-
-Dashboard stats untuk growth tracking. Skip sampai ada kebutuhan konkret.
-
-| # | Item | Status | File |
-|---|---|---|---|
-| 3.1 | Stats endpoint: total user, signup per minggu (4 minggu terakhir), organizer aktif (yang punya OPEN/FULL trip), peserta aktif (booking PAID) | โณ | `app/admin/users/stats/page.tsx` |
-| 3.2 | Chart sederhana (HTML/SVG inline, no chart library) | โณ | `app/admin/users/stats/page.tsx` |
-
-**Tindakan manual:** tidak ada (skip phase ini).
diff --git a/ADMIN_VERIFICATION_ROADMAP.md b/ADMIN_VERIFICATION_ROADMAP.md
deleted file mode 100644
index e99fdce..0000000
--- a/ADMIN_VERIFICATION_ROADMAP.md
+++ /dev/null
@@ -1,88 +0,0 @@
-# Setrip โ Admin Verification Roadmap
-
-Enhancement KYC organizer verification: reopen REJECTED, request re-upload, audit override.
-
-> **Skenario nyata:** organizer terverifikasi dengan KTP buram โ admin REJECT. Organizer foto ulang dan kirim via email. Admin sekarang harus edit DB manual karena `OrganizerVerification.status = REJECTED` tidak bisa kembali ke `PENDING` lewat UI.
-
----
-
-## Baseline
-
-- โ
Approve / Reject ada di [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx).
-- โ
`OrganizerVerification` model lengkap dengan `reviewedBy`, `reviewedAt`, `rejectionReason`.
-- โ
NIK encrypted (decrypt via `organizerService.decryptNik` saat di-render).
-- โ Tidak ada cara reopen REJECTED โ kembali ke PENDING.
-- โ Tidak ada flow "request re-upload" (admin minta organizer upload ulang field tertentu tanpa harus reject penuh).
-- โ Tidak ada history per verification โ kalau organizer ajukan ulang setelah reject, history sebelumnya hilang (di-overwrite).
-
----
-
-## Phase 1 โ Reopen Rejected Verification โณ
-
-Tombol di REJECTED detail untuk reset ke PENDING supaya admin/organizer bisa coba lagi tanpa harus drop & recreate.
-
-**Keputusan asumsi:**
-- Tidak hapus `rejectionReason` saat reopen โ simpan untuk history (rename field jadi `lastRejectionReason`). Sebenarnya rejectionReason cuma string, kalau di-reopen lalu di-reject lagi otomatis di-overwrite. Untuk MVP cukup itu.
-- Service method baru `reopenVerification(verifId, adminId, note)` โ set `status = PENDING`, clear `reviewedBy/reviewedAt`, log via [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) Phase 4 `auditLog.record`.
-- UI: tombol "Buka kembali" di REJECTED card dengan modal note wajib.
-- Tidak otomatis kirim notif ke organizer di MVP โ admin coordinate via email/WA.
-
-| # | Item | Status | File |
-|---|---|---|---|
-| 1.1 | `organizerService.reopenVerification(verifId, adminId, note)` โ set PENDING + clear review fields | โณ | [server/services/organizer.service.ts](server/services/organizer.service.ts) |
-| 1.2 | Server action `reopenVerificationAction(verifId, note)` (guard isAdmin) | โณ | [features/organizer/actions.ts](features/organizer/actions.ts) |
-| 1.3 | UI: tombol "Buka kembali" di tab REJECTED dengan modal note | โณ | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) atau [features/organizer/components/review-card.tsx](features/organizer/components/review-card.tsx) |
-| 1.4 | Tampilkan `lastRejectionReason` di tab PENDING juga (sebagai konteks "ini submission ke-N") | โณ | [features/organizer/components/review-card.tsx](features/organizer/components/review-card.tsx) |
-
-**Tindakan manual:**
-1. Brief admin: jangan reopen tanpa konfirmasi organizer sudah siap upload ulang. Note wajib menjelaskan alasan reopen.
-
----
-
-## Phase 2 โ Re-upload Request Flow โณ
-
-Daripada reject penuh, admin bisa request specific field di-update (KTP buram, foto liveness terlalu gelap).
-
-**Keputusan asumsi:**
-- Tambah `OrganizerVerification.reuploadRequested Boolean @default(false)` + `reuploadFields String[]` + `reuploadNote String?`.
-- Saat di-request, set `status = PENDING` (atau status baru `NEEDS_REUPLOAD`). Pakai `PENDING` saja supaya tidak nambah enum (organizer-facing copy bedakan via `reuploadRequested` flag).
-- Organizer page `/verify` baca flag โ tampilkan banner kuning + highlight field yang di-request.
-- Setelah organizer submit ulang, flag auto-clear โ status tetap PENDING menunggu admin review.
-
-| # | Item | Status | File |
-|---|---|---|---|
-| 2.1 | Migration: tambah `reuploadRequested`, `reuploadFields`, `reuploadNote` di `OrganizerVerification` | โณ | `prisma/migrations/` |
-| 2.2 | `organizerService.requestReupload(verifId, adminId, fields[], note)` | โณ | [server/services/organizer.service.ts](server/services/organizer.service.ts) |
-| 2.3 | Server action + UI tombol "Request re-upload" di admin detail (checkbox per field + textarea note) | โณ | [features/organizer/actions.ts](features/organizer/actions.ts) + admin page |
-| 2.4 | Banner kuning di `/verify` saat `reuploadRequested = true` + highlight field-nya | โณ | [app/(public)/verify/page.tsx](app/(public)/verify/page.tsx) + [features/organizer/components/verify-form.tsx](features/organizer/components/verify-form.tsx) |
-| 2.5 | Auto-clear `reuploadRequested` saat organizer submit ulang | โณ | [features/organizer/actions.ts](features/organizer/actions.ts) (di `submitVerificationAction`) |
-
-**Tindakan manual:** tidak ada.
-
----
-
-## Phase 3 โ Verification History (opsional) โณ
-
-Kalau audit butuh trace "berapa kali organizer ini coba verify", tambah tabel history. Skip untuk MVP.
-
-| # | Item | Status | File |
-|---|---|---|---|
-| 3.1 | Model `OrganizerVerificationHistory` (snapshot per submission) | โณ | [prisma/schema.prisma](prisma/schema.prisma) |
-| 3.2 | Trigger create snapshot saat submit ulang | โณ | [server/services/organizer.service.ts](server/services/organizer.service.ts) |
-| 3.3 | Tab "History" di admin verification detail | โณ | admin page |
-
-**Tindakan manual:** tidak ada (skip phase).
-
----
-
-## Phase 4 โ Manual Override (super-low priority) โณ
-
-Admin verifikasi organizer tanpa upload (referral dari partner trusted). Skip kecuali ada use case nyata.
-
-| # | Item | Status | File |
-|---|---|---|---|
-| 4.1 | `organizerService.adminCreateVerification(userId, adminId, note)` โ buat row APPROVED langsung dengan flag `isManualOverride` | โณ | [server/services/organizer.service.ts](server/services/organizer.service.ts) |
-| 4.2 | Migration: tambah `isManualOverride Boolean @default(false)` | โณ | `prisma/migrations/` |
-| 4.3 | UI: tombol "Verify manually" di `/admin/users/[id]` (organizer tab) | โณ | `app/admin/users/[id]/page.tsx` |
-
-**Tindakan manual:** tidak ada (skip phase, evaluate ulang setelah ada partnership program).
diff --git a/app/admin/payouts/page.tsx b/app/admin/payouts/page.tsx
index e533663..27f97fd 100644
--- a/app/admin/payouts/page.tsx
+++ b/app/admin/payouts/page.tsx
@@ -1,8 +1,9 @@
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
-import { isAdminEmail } from "@/lib/admin";
+import { isAdminEmail, listAdminEmails } from "@/lib/admin";
import { payoutRepo } from "@/server/repositories/payout.repo";
+import { AdminFilterBar } from "@/features/admin/components/admin-filter-bar";
import {
PayoutReviewCard,
type PayoutCardData,
@@ -18,7 +19,18 @@ const TABS: { key: Tab; label: string }[] = [
];
interface PageProps {
- searchParams: Promise<{ tab?: string }>;
+ searchParams: Promise<{
+ tab?: string;
+ dateFrom?: string;
+ dateTo?: string;
+ reviewer?: 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 AdminPayoutsPage({ searchParams }: PageProps) {
@@ -39,7 +51,11 @@ export default async function AdminPayoutsPage({ searchParams }: PageProps) {
? (params.tab as Tab)
: "RELEASED";
- const rows = await payoutRepo.listByStatus(tab);
+ const rows = await payoutRepo.listByStatus(tab, {
+ dateFrom: parseDate(params.dateFrom),
+ dateTo: parseDate(params.dateTo),
+ processorEmail: params.reviewer || undefined,
+ });
const items: PayoutCardData[] = rows.map((p) => ({
id: p.id,
amount: p.amount,
@@ -78,6 +94,18 @@ export default async function AdminPayoutsPage({ searchParams }: PageProps) {
+
+
) : (
diff --git a/app/admin/refunds/page.tsx b/app/admin/refunds/page.tsx
index 1688c8f..bd5c474 100644
--- a/app/admin/refunds/page.tsx
+++ b/app/admin/refunds/page.tsx
@@ -1,9 +1,10 @@
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
-import { isAdminEmail } from "@/lib/admin";
+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 {
RefundReviewCard,
type RefundCardData,
@@ -19,8 +20,30 @@ const TABS: { key: Tab; label: string }[] = [
{ key: "FAILED", label: "Gagal" },
];
+const REASON_OPTIONS = [
+ { value: "USER_CANCELLATION", label: "User cancel" },
+ { value: "ORGANIZER_CANCELLED", label: "Organizer cancel" },
+ { value: "TRIP_ISSUE", label: "Trip issue" },
+ { value: "ADMIN_ADJUSTMENT", label: "Admin adjustment" },
+ { value: "DISPUTE_RESOLVED", label: "Dispute resolved" },
+] as const;
+
+type ReasonValue = (typeof REASON_OPTIONS)[number]["value"];
+
interface PageProps {
- searchParams: Promise<{ tab?: string }>;
+ searchParams: Promise<{
+ tab?: string;
+ dateFrom?: string;
+ dateTo?: string;
+ reviewer?: string;
+ reason?: 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 AdminRefundsPage({ searchParams }: PageProps) {
@@ -40,8 +63,19 @@ export default async function AdminRefundsPage({ searchParams }: PageProps) {
const tab: Tab = TABS.some((t) => t.key === params.tab)
? (params.tab as Tab)
: "PENDING";
+ const reason: ReasonValue | undefined = REASON_OPTIONS.some(
+ (r) => r.value === params.reason
+ )
+ ? (params.reason as ReasonValue)
+ : undefined;
+
+ const rows = await refundRepo.listByStatus(tab, {
+ dateFrom: parseDate(params.dateFrom),
+ dateTo: parseDate(params.dateTo),
+ reviewerEmail: params.reviewer || undefined,
+ reason,
+ });
- const rows = await refundRepo.listByStatus(tab);
const items: RefundCardData[] = rows.map((r) => ({
id: r.id,
amount: r.amount,
@@ -92,6 +126,20 @@ export default async function AdminRefundsPage({ searchParams }: PageProps) {
+
+
) : (
diff --git a/app/admin/system/page.tsx b/app/admin/system/page.tsx
new file mode 100644
index 0000000..456958a
--- /dev/null
+++ b/app/admin/system/page.tsx
@@ -0,0 +1,258 @@
+import { redirect } from "next/navigation";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { isAdminEmail } from "@/lib/admin";
+import { prisma } from "@/lib/prisma";
+
+interface JobSummary {
+ jobName: string;
+ lastRun: { at: Date; status: string; errorMessage: string | null } | null;
+ lastSuccess: Date | null;
+ totalRuns7d: number;
+ failedRuns7d: number;
+}
+
+async function getJobSummary(jobName: string): Promise
{
+ const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
+ const [lastRun, lastSuccessRow, totalRuns7d, failedRuns7d] =
+ await Promise.all([
+ prisma.cronRun.findFirst({
+ where: { jobName },
+ orderBy: { startedAt: "desc" },
+ select: { startedAt: true, status: true, errorMessage: true },
+ }),
+ prisma.cronRun.findFirst({
+ where: { jobName, status: "SUCCESS" },
+ orderBy: { startedAt: "desc" },
+ select: { startedAt: true },
+ }),
+ prisma.cronRun.count({
+ where: { jobName, startedAt: { gte: sevenDaysAgo } },
+ }),
+ prisma.cronRun.count({
+ where: {
+ jobName,
+ status: "FAILED",
+ startedAt: { gte: sevenDaysAgo },
+ },
+ }),
+ ]);
+ return {
+ jobName,
+ lastRun: lastRun
+ ? {
+ at: lastRun.startedAt,
+ status: lastRun.status,
+ errorMessage: lastRun.errorMessage,
+ }
+ : null,
+ lastSuccess: lastSuccessRow?.startedAt ?? null,
+ totalRuns7d,
+ failedRuns7d,
+ };
+}
+
+// Daftar cron yang dipantau. Tambah entry baru saat menambah cron route handler.
+const TRACKED_JOBS = ["auto-complete-trips"] as const;
+
+function healthOf(summary: JobSummary): "ok" | "stale" | "failed" {
+ if (summary.lastRun?.status === "FAILED") return "failed";
+ if (!summary.lastSuccess) return "stale";
+ const hoursSince =
+ (Date.now() - summary.lastSuccess.getTime()) / (1000 * 60 * 60);
+ // Asumsi cron daily โ > 25 jam dianggap stale.
+ if (hoursSince > 25) return "stale";
+ return "ok";
+}
+
+export default async function AdminSystemPage() {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) redirect("/login?callbackUrl=/admin/system");
+ if (!isAdminEmail(session.user.email)) {
+ return (
+
+
+ Halaman ini hanya untuk admin SeTrip.
+
+
+ );
+ }
+
+ const summaries = await Promise.all(TRACKED_JOBS.map(getJobSummary));
+ const recentRuns = await prisma.cronRun.findMany({
+ orderBy: { startedAt: "desc" },
+ take: 20,
+ });
+
+ return (
+
+
+
+
+
+ Cron Jobs
+
+
+ {summaries.map((s) => {
+ const health = healthOf(s);
+ const cls =
+ health === "ok"
+ ? "border-emerald-200 bg-emerald-50/50"
+ : health === "stale"
+ ? "border-amber-200 bg-amber-50/50"
+ : "border-red-200 bg-red-50/50";
+ const badge =
+ health === "ok"
+ ? { label: "๐ข OK", cls: "bg-emerald-100 text-emerald-800" }
+ : health === "stale"
+ ? {
+ label: "๐ก STALE",
+ cls: "bg-amber-100 text-amber-800",
+ }
+ : {
+ label: "๐ด FAILED",
+ cls: "bg-red-100 text-red-800",
+ };
+ return (
+
+
+
+
+ Job
+
+
+ {s.jobName}
+
+
+
+ {badge.label}
+
+
+
+
+
- Last run:
{" "}
+ -
+ {s.lastRun
+ ? `${formatDateTime(s.lastRun.at)} ยท ${s.lastRun.status}`
+ : "Belum pernah"}
+
+
+
+
- Last success:
{" "}
+ -
+ {s.lastSuccess
+ ? formatDateTime(s.lastSuccess)
+ : "Belum pernah"}
+
+
+
+
- 7 hari terakhir:
{" "}
+ -
+ {s.totalRuns7d} run, {s.failedRuns7d} failed
+
+
+ {s.lastRun?.errorMessage && (
+
+ Error terakhir: {s.lastRun.errorMessage}
+
+ )}
+
+
+ );
+ })}
+
+
+
+
+
+ Recent Runs (20 terakhir)
+
+ {recentRuns.length === 0 ? (
+
+
+ Belum ada cron run tercatat. Setelah cron berikutnya jalan, baris
+ pertama akan muncul di sini.
+
+
+ ) : (
+
+
+
+
+ | Job |
+ Started |
+ Finished |
+ Status |
+ Note |
+
+
+
+ {recentRuns.map((r) => (
+
+ | {r.jobName} |
+ {formatDateTime(r.startedAt)} |
+
+ {r.finishedAt ? formatDateTime(r.finishedAt) : "โ"}
+ |
+
+
+ |
+
+ {r.errorMessage ??
+ (r.payload
+ ? truncate(JSON.stringify(r.payload), 80)
+ : "โ")}
+ |
+
+ ))}
+
+
+
+ )}
+
+
+ );
+}
+
+function formatDateTime(d: Date): string {
+ return d.toLocaleString("id-ID", {
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+function truncate(s: string, max: number): string {
+ return s.length > max ? `${s.slice(0, max)}โฆ` : s;
+}
+
+function StatusBadge({ value }: { value: string }) {
+ const cls =
+ value === "SUCCESS"
+ ? "bg-emerald-100 text-emerald-800"
+ : value === "FAILED"
+ ? "bg-red-100 text-red-800"
+ : "bg-amber-100 text-amber-800";
+ return (
+
+ {value}
+
+ );
+}
diff --git a/app/admin/users/[id]/page.tsx b/app/admin/users/[id]/page.tsx
new file mode 100644
index 0000000..6ac2a25
--- /dev/null
+++ b/app/admin/users/[id]/page.tsx
@@ -0,0 +1,349 @@
+import Link from "next/link";
+import Image from "next/image";
+import { notFound, redirect } from "next/navigation";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { isAdminEmail } from "@/lib/admin";
+import { userRepo } from "@/server/repositories/user.repo";
+import { formatRupiah } from "@/lib/utils";
+import { SuspendUserButton } from "@/features/admin/components/suspend-user-button";
+
+interface PageProps {
+ params: Promise<{ id: string }>;
+}
+
+export default async function AdminUserDetailPage({ params }: PageProps) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) redirect("/login?callbackUrl=/admin/users");
+ if (!isAdminEmail(session.user.email)) {
+ return (
+
+
+ Halaman ini hanya untuk admin SeTrip.
+
+
+ );
+ }
+
+ const { id } = await params;
+ const user = await userRepo.findByIdForAdmin(id);
+ if (!user) notFound();
+
+ const isSelf = user.id === session.user.id;
+ const totalSpent = user.bookings
+ .filter((b) => b.status === "PAID" || b.status === "PARTIALLY_REFUNDED")
+ .reduce((sum, b) => sum + b.amount, 0);
+
+ return (
+
+
+
+ โ Kembali ke list users
+
+
+
+
+
+
+
+ {user.suspended && (
+
+
+ โ Akun ditangguhkan
+
+
+ {user.suspendedReason ?? "Tidak ada alasan tercatat."}
+
+ {user.suspendedBy && (
+
+ Disuspend oleh {user.suspendedBy.email}
+ {user.suspendedAt && (
+ <>
+ {" "}
+ pada{" "}
+ {user.suspendedAt.toLocaleString("id-ID", {
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ })}
+ >
+ )}
+
+ )}
+
+ )}
+
+
+
+ Aksi Admin
+
+ {isSelf ? (
+
+ Tidak bisa suspend akun sendiri.
+
+ ) : (
+
+ )}
+
+
+ {user.profile && (
+
+
+ Profil Sosial
+
+
+ {user.profile.bio && (
+
+
-
+ Bio
+
+ -
+ {user.profile.bio}
+
+
+ )}
+ {user.profile.city && (
+
+
-
+ Kota
+
+ - {user.profile.city}
+
+ )}
+ {user.profile.vibe && (
+
+
-
+ Vibe
+
+ - {user.profile.vibe}
+
+ )}
+ {user.profile.interests.length > 0 && (
+
+
-
+ Minat
+
+ -
+ {user.profile.interests.map((tag) => (
+
+ #{tag}
+
+ ))}
+
+
+ )}
+ {user.profile.instagram && (
+
+
-
+ Instagram
+
+ - @{user.profile.instagram}
+
+ )}
+
+
+ )}
+
+ {user.organizerVerification && (
+
+
+ Verifikasi Organizer
+
+
+ Status:{" "}
+
+ {user.organizerVerification.status}
+
+ {" ยท "}
+
+ Buka di /admin/verifications โ
+
+
+ {user.organizerVerification.rejectionReason && (
+
+ Reason: {user.organizerVerification.rejectionReason}
+
+ )}
+
+ )}
+
+
+
+ Trip yang dibuat ({user.trips.length})
+
+ {user.trips.length === 0 ? (
+
+ User ini belum pernah membuat trip.
+
+ ) : (
+
+ )}
+
+
+
+
+ Booking sebagai peserta ({user.bookings.length})
+
+ {user.bookings.length === 0 ? (
+ Belum ada booking.
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function StatCard({
+ label,
+ value,
+ accent = "primary",
+}: {
+ label: string;
+ value: string;
+ accent?: "primary" | "emerald";
+}) {
+ const cls = accent === "emerald" ? "text-emerald-700" : "text-primary-700";
+ return (
+
+
+ {label}
+
+
{value}
+
+ );
+}
diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx
new file mode 100644
index 0000000..364475a
--- /dev/null
+++ b/app/admin/users/page.tsx
@@ -0,0 +1,167 @@
+import Link from "next/link";
+import Image from "next/image";
+import { redirect } from "next/navigation";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { isAdminEmail } from "@/lib/admin";
+import { userRepo } from "@/server/repositories/user.repo";
+
+type Tab = "ALL" | "ACTIVE" | "SUSPENDED";
+
+const TABS: { key: Tab; label: string }[] = [
+ { key: "ALL", label: "Semua" },
+ { key: "ACTIVE", label: "Aktif" },
+ { key: "SUSPENDED", label: "Suspended" },
+];
+
+interface PageProps {
+ searchParams: Promise<{ tab?: string; q?: string }>;
+}
+
+export default async function AdminUsersPage({ searchParams }: PageProps) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) redirect("/login?callbackUrl=/admin/users");
+ if (!isAdminEmail(session.user.email)) {
+ return (
+
+
+ Halaman ini hanya untuk admin SeTrip.
+
+
+ );
+ }
+
+ const params = await searchParams;
+ const tab: Tab = TABS.some((t) => t.key === params.tab)
+ ? (params.tab as Tab)
+ : "ALL";
+ const q = (params.q ?? "").trim();
+
+ const users = await userRepo.searchForAdmin({
+ q: q || undefined,
+ suspended: tab === "SUSPENDED" ? true : tab === "ACTIVE" ? false : undefined,
+ });
+
+ return (
+
+
+
+ User Management
+
+
+ Cari user, lihat history booking & trip, dan suspend akun yang
+ melakukan abuse (scam, harassment, TOS violation).
+
+
+
+
+
+
+ {TABS.map((t) => (
+
+ {t.label}
+
+ ))}
+
+
+ {users.length === 0 ? (
+
+
+ {q
+ ? `Tidak ada user yang cocok dengan "${q}".`
+ : "Tidak ada user pada tab ini."}
+
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/app/admin/verifications/page.tsx b/app/admin/verifications/page.tsx
index 10ed630..3caa285 100644
--- a/app/admin/verifications/page.tsx
+++ b/app/admin/verifications/page.tsx
@@ -1,15 +1,27 @@
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
-import { isAdminEmail } from "@/lib/admin";
+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 { ReviewCard } from "@/features/organizer/components/review-card";
type Tab = "PENDING" | "APPROVED" | "REJECTED";
interface PageProps {
- searchParams: Promise<{ tab?: string }>;
+ searchParams: Promise<{
+ tab?: string;
+ dateFrom?: string;
+ dateTo?: string;
+ reviewer?: 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 AdminVerificationsPage({ searchParams }: PageProps) {
@@ -29,7 +41,11 @@ export default async function AdminVerificationsPage({ searchParams }: PageProps
const tab: Tab =
params.tab === "APPROVED" || params.tab === "REJECTED" ? params.tab : "PENDING";
- const rows = await organizerRepo.listByStatus(tab);
+ const rows = await organizerRepo.listByStatus(tab, {
+ dateFrom: parseDate(params.dateFrom),
+ dateTo: parseDate(params.dateTo),
+ reviewerEmail: params.reviewer || undefined,
+ });
const items = rows.map((v) => ({
id: v.id,
fullName: v.fullName,
@@ -65,6 +81,18 @@ export default async function AdminVerificationsPage({ searchParams }: PageProps
+
+
) : (
diff --git a/app/api/cron/auto-complete-trips/route.ts b/app/api/cron/auto-complete-trips/route.ts
index f2c03f7..bf06bce 100644
--- a/app/api/cron/auto-complete-trips/route.ts
+++ b/app/api/cron/auto-complete-trips/route.ts
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { tripService } from "@/server/services/trip.service";
import { payoutService } from "@/server/services/payout.service";
+import { runCron } from "@/lib/cron-runner";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -30,27 +31,25 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
- try {
+ const outcome = await runCron("auto-complete-trips", async () => {
const result = await tripService.autoCompletePastTrips();
// Setelah trip COMPLETED, payout yang sudah lewat heldUntil di-release
// supaya admin bisa langsung transfer ke organizer. Idempotent.
const releaseResult = await payoutService.releaseEligible();
- console.log("[cron/auto-complete-trips] selesai", {
- completed: result.count,
- ids: result.ids,
- payoutsReleased: releaseResult.releasedIds.length,
- });
- return NextResponse.json({
- ok: true,
+ return {
completed: result.count,
ids: result.ids,
payoutsReleased: releaseResult.releasedIds,
- });
- } catch (err) {
- console.error("[cron/auto-complete-trips] gagal", err);
+ };
+ });
+
+ if (!outcome.ok) {
+ console.error("[cron/auto-complete-trips] gagal", outcome.error);
return NextResponse.json(
{ error: "Gagal menjalankan auto-complete" },
{ status: 500 }
);
}
+ console.log("[cron/auto-complete-trips] selesai", outcome.payload);
+ return NextResponse.json({ ok: true, ...outcome.payload });
}
diff --git a/components/admin/admin-sidebar.tsx b/components/admin/admin-sidebar.tsx
index e5da104..97237bd 100644
--- a/components/admin/admin-sidebar.tsx
+++ b/components/admin/admin-sidebar.tsx
@@ -9,9 +9,11 @@ import { signOut } from "next-auth/react";
const NAV_ITEMS: { href: string; label: string; icon: string }[] = [
{ href: "/admin", label: "Dashboard", icon: "๐" },
{ href: "/admin/trips", label: "Trips", icon: "๐งญ" },
+ { href: "/admin/users", label: "Users", icon: "๐ฅ" },
{ href: "/admin/verifications", label: "Verifikasi", icon: "๐ชช" },
{ href: "/admin/refunds", label: "Refund", icon: "โฉ๏ธ" },
{ href: "/admin/payouts", label: "Payout", icon: "๐ธ" },
+ { href: "/admin/system", label: "System", icon: "โ๏ธ" },
];
interface AdminSidebarProps {
diff --git a/docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md b/docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md
new file mode 100644
index 0000000..f1512cb
--- /dev/null
+++ b/docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md
@@ -0,0 +1,48 @@
+# Setrip โ Admin System Health Roadmap (ARCHIVED โ DELIVERED 2026-05-18, partial)
+
+Admin perlu visibilitas atas job otomatis (cron). Phase 1 (cron log foundation) delivered. Phase 2 (status page) bonus.
+
+---
+
+## Status delivery
+
+| Phase | Status | Catatan |
+|---|---|---|
+| Phase 1 โ Cron Run Log | โ
Delivered | Model `CronRun`, helper `runCron()`, wire ke cron existing. |
+| Phase 2 โ System Status Page | โ
Delivered (bonus) | `/admin/system` tampilkan summary cron + 20 run terakhir + health badge (ok/stale/failed). |
+| Phase 3 โ Stale State Alerts | โณ Deferred | Belum perlu sampai ada incident. |
+| Phase 4 โ External Alerting (Discord) | โณ Deferred | Skip kecuali admin sering miss banner. |
+
+---
+
+## Phase 1 โ Cron Run Log โ
+
+| # | Item | Status | File |
+|---|---|---|---|
+| 1.1 | Model `CronRun` + enum `CronRunStatus` + migration | โ
| [prisma/schema.prisma](../../prisma/schema.prisma) + `prisma/migrations/20260518170000_add_cron_run/` |
+| 1.2 | Helper `runCron(jobName, fn)` โ auto create RUNNING row โ SUCCESS/FAILED | โ
| [lib/cron-runner.ts](../../lib/cron-runner.ts) |
+| 1.3 | Wire `runCron` di `auto-complete-trips` cron | โ
| [app/api/cron/auto-complete-trips/route.ts](../../app/api/cron/auto-complete-trips/route.ts) |
+| 1.4 | Wire `runCron` di cron payout release | โณ | Defer โ `releaseEligible` saat ini di-call dari cron yang sama, sudah ter-wrap. |
+| 1.5 | Wire `runCron` di cron lain (refund sweep, dst) saat ditambah | โณ | Tidak ada cron lain saat ini. |
+
+---
+
+## Phase 2 โ System Status Page โ
(bonus)
+
+| # | Item | Status | File |
+|---|---|---|---|
+| 2.1 | Per-job summary: last run, last success, count 7d, error count 7d | โ
| [app/admin/system/page.tsx](../../app/admin/system/page.tsx) |
+| 2.2 | 20 cron run terakhir di table bawah | โ
| [app/admin/system/page.tsx](../../app/admin/system/page.tsx) |
+| 2.3 | Health badge (๐ข OK < 25 jam, ๐ก STALE, ๐ด FAILED) | โ
| [app/admin/system/page.tsx](../../app/admin/system/page.tsx) |
+| 2.4 | Link "System" di admin navbar | โ
| [components/admin/admin-sidebar.tsx](../../components/admin/admin-sidebar.tsx) |
+
+**Tindakan manual ops:**
+1. Apply migration: `npx prisma migrate deploy`.
+2. Setelah cron berikutnya jalan, cek `/admin/system` untuk lihat entry pertama.
+3. Saat menambah cron route handler baru, daftarkan jobName di `TRACKED_JOBS` di `app/admin/system/page.tsx`.
+
+---
+
+## Phase 3-4 โณ (deferred)
+
+Stale state alerts + Discord webhook. Lihat versi awal di [ADMIN_ROADMAP.md](../../ADMIN_ROADMAP.md).
diff --git a/docs/archive/ADMIN_USER_MGMT_ROADMAP.md b/docs/archive/ADMIN_USER_MGMT_ROADMAP.md
new file mode 100644
index 0000000..3f0ee39
--- /dev/null
+++ b/docs/archive/ADMIN_USER_MGMT_ROADMAP.md
@@ -0,0 +1,55 @@
+# Setrip โ Admin User Management Roadmap (ARCHIVED โ DELIVERED 2026-05-18)
+
+Admin perlu bisa cari user dan **suspend** akun yang melakukan abuse (scam, harassment, fake review).
+
+> **Skenario nyata:** organizer scam berkali-kali bikin trip palsu pakai alias berbeda. Peserta lapor harassment dari user lain di grup WA trip.
+
+---
+
+## Status delivery
+
+| Phase | Status | Catatan |
+|---|---|---|
+| Phase 1 โ User List & Detail | โ
Delivered | Search by email/name, filter tab (ALL/ACTIVE/SUSPENDED), stats (trip dibuat, booking, total spent). |
+| Phase 2 โ User Suspension | โ
Delivered | Schema baru `User.suspended`, auth gate sign-in + helper `requireActiveUser` di mutating actions, trip public list otomatis sembunyikan organizer suspended. |
+| Phase 3 โ User Analytics | โณ Deferred | Skip MVP โ tidak ada use case konkret. |
+
+---
+
+## Phase 1 โ User List & Detail โ
+
+| # | Item | Status | File |
+|---|---|---|---|
+| 1.1 | `userRepo.searchForAdmin({ q?, suspended? })` | โ
| [server/repositories/user.repo.ts](../../server/repositories/user.repo.ts) |
+| 1.2 | Page `/admin/users` โ list + search + tab filter (ALL/ACTIVE/SUSPENDED) | โ
| [app/admin/users/page.tsx](../../app/admin/users/page.tsx) |
+| 1.3 | Page `/admin/users/[id]` โ detail dengan trip dibuat + booking history + profile + verification | โ
| [app/admin/users/[id]/page.tsx](../../app/admin/users/[id]/page.tsx) |
+| 1.4 | Stats cards: trip dibuat, booking aktif, total spent (PAID) | โ
| [app/admin/users/[id]/page.tsx](../../app/admin/users/[id]/page.tsx) |
+| 1.5 | Link "Users" di admin navbar | โ
| [components/admin/admin-sidebar.tsx](../../components/admin/admin-sidebar.tsx) |
+
+---
+
+## Phase 2 โ User Suspension โ
+
+| # | Item | Status | File |
+|---|---|---|---|
+| 2.1 | Migration: `suspended Boolean`, `suspendedAt`, `suspendedReason`, `suspendedById` (FK User SET NULL) | โ
| `prisma/migrations/20260518160000_add_user_suspension/` |
+| 2.2 | `userService.suspendUser` + `unsuspendUser` (idempotent + cek tidak suspend diri sendiri + reason min 10 char) | โ
| [server/services/user.service.ts](../../server/services/user.service.ts) |
+| 2.3 | Block sign-in di NextAuth `signIn` callback (email-based, jalan untuk Credentials + OAuth) | โ
| [lib/auth.ts](../../lib/auth.ts) |
+| 2.4 | Helper `requireActiveUser(userId)` โ lookup fresh dari DB | โ
| [lib/auth-guards.ts](../../lib/auth-guards.ts) |
+| 2.5 | Wire `requireActiveUser` di `createTripAction` + `joinTripAction` | โ
| [features/trip/actions.ts](../../features/trip/actions.ts) |
+| 2.6 | Filter trip public list: `organizer: { suspended: false }` di `findOpen` | โ
| [server/repositories/trip.repo.ts](../../server/repositories/trip.repo.ts) |
+| 2.7 | UI: tombol Suspend/Unsuspend di `/admin/users/[id]` dengan modal reason wajib | โ
| [features/admin/components/suspend-user-button.tsx](../../features/admin/components/suspend-user-button.tsx) |
+| 2.8 | Badge "SUSPENDED" di user list + detail header (red border accent) | โ
| list & detail pages |
+| 2.9 | Server actions `suspendUserAction` + `unsuspendUserAction` (guard isAdmin) | โ
| [features/admin/actions.ts](../../features/admin/actions.ts) |
+
+**Tindakan manual ops:**
+1. Apply migration: `npx prisma migrate deploy`.
+2. Brief admin: kriteria suspend (scam, harassment, repeated TOS violation). Reason wajib min 10 char.
+3. Wire `requireActiveUser` ke action mutating lain saat dibuat (`createReviewAction`, dst).
+4. Pertimbangkan: bikin halaman info "Akun ditangguhkan" untuk UX saat suspended user coba login.
+
+---
+
+## Phase 3 โ User Analytics โณ (deferred)
+
+Skip sampai growth team minta. Lihat versi awal di [ADMIN_ROADMAP.md](../../ADMIN_ROADMAP.md).
diff --git a/docs/archive/ADMIN_VERIFICATION_ROADMAP.md b/docs/archive/ADMIN_VERIFICATION_ROADMAP.md
new file mode 100644
index 0000000..ed82f0e
--- /dev/null
+++ b/docs/archive/ADMIN_VERIFICATION_ROADMAP.md
@@ -0,0 +1,34 @@
+# Setrip โ Admin Verification Roadmap (ARCHIVED โ DELIVERED 2026-05-18, partial)
+
+Enhancement KYC organizer verification: reopen REJECTED, request re-upload, audit override.
+
+---
+
+## Status delivery
+
+| Phase | Status | Catatan |
+|---|---|---|
+| Phase 1 โ Reopen Rejected | โ
Delivered | Tombol "Buka kembali ke PENDING" di REJECTED card dengan note wajib min 10 char. |
+| Phase 2 โ Re-upload Request | โณ Deferred | Butuh schema + organizer-side UI; skip MVP. |
+| Phase 3 โ Verification History | โณ Deferred | Skip. |
+| Phase 4 โ Manual Override | โณ Deferred | Skip. |
+
+---
+
+## Phase 1 โ Reopen Rejected Verification โ
+
+| # | Item | Status | File |
+|---|---|---|---|
+| 1.1 | `organizerService.reopenVerification(verifId, adminId, note)` โ set PENDING, clear review fields, simpan note di rejectionReason | โ
| [server/services/organizer.service.ts](../../server/services/organizer.service.ts) |
+| 1.2 | `organizerRepo.reopen(id, note)` | โ
| [server/repositories/organizer.repo.ts](../../server/repositories/organizer.repo.ts) |
+| 1.3 | Server action `reopenVerificationAction` (guard isAdmin) | โ
| [features/organizer/actions.ts](../../features/organizer/actions.ts) |
+| 1.4 | UI: tombol "๐ Buka kembali ke PENDING" di REJECTED card + textarea note wajib | โ
| [features/organizer/components/review-card.tsx](../../features/organizer/components/review-card.tsx) |
+
+**Tindakan manual ops:**
+1. Brief admin: koordinasi dengan organizer dulu via email/WA sebelum reopen (pastikan organizer siap submit ulang foto/data). Note wajib menjelaskan alasan reopen untuk audit trail.
+
+---
+
+## Phase 2-4 โณ (deferred)
+
+Lihat versi awal di [ADMIN_ROADMAP.md](../../ADMIN_ROADMAP.md). Akan diangkat kembali kalau ada kebutuhan konkret (banyak re-upload, partnership program butuh manual override, dst).
diff --git a/features/admin/actions.ts b/features/admin/actions.ts
new file mode 100644
index 0000000..e515c25
--- /dev/null
+++ b/features/admin/actions.ts
@@ -0,0 +1,49 @@
+"use server";
+
+import { getServerSession } from "next-auth";
+import { revalidatePath } from "next/cache";
+import { authOptions } from "@/lib/auth";
+import { isAdminEmail } from "@/lib/admin";
+import { userService } from "@/server/services/user.service";
+
+export async function suspendUserAction(userId: string, reason: string) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return { error: "Kamu harus login terlebih dahulu" };
+ }
+ if (!isAdminEmail(session.user.email)) {
+ return { error: "Hanya admin yang bisa melakukan aksi ini" };
+ }
+
+ try {
+ await userService.suspendUser({
+ userId,
+ adminId: session.user.id,
+ reason,
+ });
+ revalidatePath("/admin/users");
+ revalidatePath(`/admin/users/${userId}`);
+ return { success: true as const };
+ } catch (err) {
+ return { error: (err as Error).message };
+ }
+}
+
+export async function unsuspendUserAction(userId: string) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return { error: "Kamu harus login terlebih dahulu" };
+ }
+ if (!isAdminEmail(session.user.email)) {
+ return { error: "Hanya admin yang bisa melakukan aksi ini" };
+ }
+
+ try {
+ await userService.unsuspendUser({ userId, adminId: session.user.id });
+ revalidatePath("/admin/users");
+ revalidatePath(`/admin/users/${userId}`);
+ return { success: true as const };
+ } catch (err) {
+ return { error: (err as Error).message };
+ }
+}
diff --git a/features/admin/components/admin-filter-bar.tsx b/features/admin/components/admin-filter-bar.tsx
new file mode 100644
index 0000000..95f7163
--- /dev/null
+++ b/features/admin/components/admin-filter-bar.tsx
@@ -0,0 +1,147 @@
+interface AdminFilterBarProps {
+ /** URL base (mis. `/admin/refunds`) yang menerima query params. */
+ action: string;
+ /** Nilai current dari searchParams. */
+ values: {
+ tab?: string;
+ dateFrom?: string;
+ dateTo?: string;
+ reviewer?: string;
+ reason?: string;
+ };
+ /** Daftar admin email untuk dropdown reviewer/processor. */
+ reviewerOptions: string[];
+ /** Label dropdown reviewer (mis. "Reviewer", "Processor"). */
+ reviewerLabel?: string;
+ /** Kalau diisi, tampilkan dropdown reason dengan opsi-opsi tersebut. */
+ reasonOptions?: { value: string; label: string }[];
+}
+
+/**
+ * Filter bar reusable untuk admin list pages. Pakai GET form supaya URL
+ * shareable dan tidak perlu state client.
+ */
+export function AdminFilterBar({
+ action,
+ values,
+ reviewerOptions,
+ reviewerLabel = "Reviewer",
+ reasonOptions,
+}: AdminFilterBarProps) {
+ return (
+
+ );
+}
diff --git a/features/admin/components/suspend-user-button.tsx b/features/admin/components/suspend-user-button.tsx
new file mode 100644
index 0000000..2a9984d
--- /dev/null
+++ b/features/admin/components/suspend-user-button.tsx
@@ -0,0 +1,139 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import {
+ suspendUserAction,
+ unsuspendUserAction,
+} from "@/features/admin/actions";
+
+interface SuspendUserButtonProps {
+ userId: string;
+ isSuspended: boolean;
+}
+
+export function SuspendUserButton({
+ userId,
+ isSuspended,
+}: SuspendUserButtonProps) {
+ const router = useRouter();
+ const [open, setOpen] = useState(false);
+ const [reason, setReason] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+
+ async function handleSuspend() {
+ setLoading(true);
+ setError("");
+ const res = await suspendUserAction(userId, reason);
+ setLoading(false);
+ if ("error" in res && res.error) {
+ setError(res.error);
+ return;
+ }
+ setOpen(false);
+ setReason("");
+ router.refresh();
+ }
+
+ async function handleUnsuspend() {
+ if (!confirm("Buka kembali akun ini? User akan langsung bisa login.")) {
+ return;
+ }
+ setLoading(true);
+ setError("");
+ const res = await unsuspendUserAction(userId);
+ setLoading(false);
+ if ("error" in res && res.error) {
+ setError(res.error);
+ return;
+ }
+ router.refresh();
+ }
+
+ if (isSuspended) {
+ return (
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+ }
+
+ if (!open) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/features/organizer/actions.ts b/features/organizer/actions.ts
index 83656a7..a911679 100644
--- a/features/organizer/actions.ts
+++ b/features/organizer/actions.ts
@@ -76,3 +76,31 @@ export async function reviewVerificationAction(formData: FormData) {
return { error: (err as Error).message };
}
}
+
+/**
+ * Admin reopen pengajuan REJECTED ke PENDING โ supaya organizer bisa
+ * di-review ulang tanpa drop & recreate row. Note wajib min 10 char untuk audit.
+ */
+export async function reopenVerificationAction(
+ verificationId: string,
+ note: string
+) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user || !isAdminEmail(session.user.email)) {
+ return { error: "Tidak memiliki akses admin" };
+ }
+
+ try {
+ await organizerService.reopenVerification({
+ verificationId,
+ adminId: session.user.id,
+ note,
+ });
+ revalidatePath("/admin/verifications");
+ revalidatePath("/verify");
+ revalidatePath("/profile");
+ return { success: true as const };
+ } catch (err) {
+ return { error: (err as Error).message };
+ }
+}
diff --git a/features/organizer/components/review-card.tsx b/features/organizer/components/review-card.tsx
index 5600a35..840665e 100644
--- a/features/organizer/components/review-card.tsx
+++ b/features/organizer/components/review-card.tsx
@@ -2,7 +2,10 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
-import { reviewVerificationAction } from "@/features/organizer/actions";
+import {
+ reopenVerificationAction,
+ reviewVerificationAction,
+} from "@/features/organizer/actions";
type Verification = {
id: string;
@@ -33,7 +36,9 @@ function formatDate(d: Date): string {
export function ReviewCard({ verification }: { verification: Verification }) {
const router = useRouter();
const [showReject, setShowReject] = useState(false);
+ const [showReopen, setShowReopen] = useState(false);
const [rejectionReason, setRejectionReason] = useState("");
+ const [reopenNote, setReopenNote] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
@@ -55,6 +60,20 @@ export function ReviewCard({ verification }: { verification: Verification }) {
router.refresh();
}
+ async function reopen() {
+ setError("");
+ setLoading(true);
+ const result = await reopenVerificationAction(verification.id, reopenNote);
+ setLoading(false);
+ if ("error" in result && result.error) {
+ setError(result.error);
+ return;
+ }
+ setShowReopen(false);
+ setReopenNote("");
+ router.refresh();
+ }
+
return (
@@ -110,6 +129,62 @@ export function ReviewCard({ verification }: { verification: Verification }) {
)}
+ {verification.status === "REJECTED" && (
+
+ {error && (
+
+ {error}
+
+ )}
+ {!showReopen ? (
+
+ ) : (
+
+
+
+ )}
+
+ )}
+
{verification.status === "PENDING" && (
{error && (
diff --git a/features/trip/actions.ts b/features/trip/actions.ts
index 8599fe8..72b4dc3 100644
--- a/features/trip/actions.ts
+++ b/features/trip/actions.ts
@@ -11,12 +11,18 @@ import { tripService } from "@/server/services/trip.service";
import { organizerService } from "@/server/services/organizer.service";
import { revalidatePath } from "next/cache";
import { tripStoredInstantFromYmd } from "@/lib/trip-dates";
+import { requireActiveUser } from "@/lib/auth-guards";
export async function createTripAction(formData: FormData) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return { error: "Kamu harus login terlebih dahulu" };
}
+ try {
+ await requireActiveUser(session.user.id);
+ } catch (err) {
+ return { error: (err as Error).message };
+ }
const raw = {
category: formData.get("category") as string,
@@ -120,6 +126,7 @@ export async function joinTripAction(tripId: string) {
}
try {
+ await requireActiveUser(session.user.id);
await tripService.joinTrip(tripId, session.user.id);
revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips");
diff --git a/lib/admin.ts b/lib/admin.ts
index 7b44911..715087e 100644
--- a/lib/admin.ts
+++ b/lib/admin.ts
@@ -10,3 +10,8 @@ export function isAdminEmail(email: string | null | undefined): boolean {
if (!email) return false;
return adminEmails().includes(email.toLowerCase());
}
+
+/** Daftar email admin yang aman di-expose ke UI admin (dropdown filter). */
+export function listAdminEmails(): string[] {
+ return adminEmails();
+}
diff --git a/lib/auth-guards.ts b/lib/auth-guards.ts
new file mode 100644
index 0000000..bab6572
--- /dev/null
+++ b/lib/auth-guards.ts
@@ -0,0 +1,18 @@
+import { userRepo } from "@/server/repositories/user.repo";
+
+/**
+ * Pastikan user session aktif (tidak suspended) sebelum lanjut aksi mutatif.
+ * Dipanggil di awal server action yang menulis ke DB (joinTrip, createTrip,
+ * createReview, dll). Lookup fresh dari DB โ jangan trust JWT karena suspend
+ * bisa di-trigger sejak token dibuat.
+ *
+ * Throw Error dengan pesan user-friendly kalau suspended.
+ */
+export async function requireActiveUser(userId: string): Promise {
+ const suspended = await userRepo.isSuspended(userId);
+ if (suspended) {
+ throw new Error(
+ "Akun kamu sedang ditangguhkan. Hubungi support@setrip.id untuk klarifikasi."
+ );
+ }
+}
diff --git a/lib/auth.ts b/lib/auth.ts
index 5f796dc..e24c8b4 100644
--- a/lib/auth.ts
+++ b/lib/auth.ts
@@ -63,6 +63,22 @@ export const authOptions: AuthOptions = {
strategy: "jwt",
},
callbacks: {
+ async signIn({ user }) {
+ // Block suspended user dari sign-in (Credentials + OAuth).
+ // Email-based lookup karena `user.id` belum tentu ada untuk first-time
+ // OAuth sign-in sebelum adapter persist.
+ const email = user.email;
+ if (!email) return true;
+ const existing = await prisma.user.findUnique({
+ where: { email },
+ select: { suspended: true },
+ });
+ if (existing?.suspended) {
+ // NextAuth menerjemahkan return false jadi error "AccessDenied".
+ return false;
+ }
+ return true;
+ },
async jwt({ token, user, trigger }) {
if (user) {
token.id = user.id;
diff --git a/lib/cron-runner.ts b/lib/cron-runner.ts
new file mode 100644
index 0000000..6bfcf33
--- /dev/null
+++ b/lib/cron-runner.ts
@@ -0,0 +1,67 @@
+import { prisma } from "@/lib/prisma";
+
+/**
+ * Wrapper untuk cron route handler โ otomatis log start/finish/error ke
+ * `CronRun`. Idempotent terhadap kegagalan: kalau row gagal dibuat (DB down),
+ * fn tetap jalan dan kegagalannya hanya hilang log.
+ *
+ * Pemakaian:
+ * ```ts
+ * return runCron("auto-complete-trips", async () => {
+ * const result = await tripService.autoCompletePastTrips();
+ * return { completed: result.count, ids: result.ids };
+ * });
+ * ```
+ *
+ * Caller bertanggung jawab untuk mengembalikan NextResponse โ `runCron`
+ * cuma menjalankan fn dan log; return value fn dipassthrough sebagai `payload`.
+ */
+export async function runCron(
+ jobName: string,
+ fn: () => Promise
+): Promise<{ ok: true; payload: T } | { ok: false; error: string }> {
+ let runId: string | null = null;
+ try {
+ const row = await prisma.cronRun.create({
+ data: { jobName, status: "RUNNING" },
+ select: { id: true },
+ });
+ runId = row.id;
+ } catch (err) {
+ console.error(`[cron-runner] gagal create row ${jobName}`, err);
+ // Lanjut tanpa log โ jangan blok cron karena DB log gagal.
+ }
+
+ try {
+ const payload = await fn();
+ if (runId) {
+ await prisma.cronRun
+ .update({
+ where: { id: runId },
+ data: {
+ status: "SUCCESS",
+ finishedAt: new Date(),
+ payload: payload as unknown as object,
+ },
+ })
+ .catch((e) => console.error(`[cron-runner] gagal update SUCCESS`, e));
+ }
+ return { ok: true, payload };
+ } catch (err) {
+ const message =
+ err instanceof Error ? err.message : "Unknown cron failure";
+ if (runId) {
+ await prisma.cronRun
+ .update({
+ where: { id: runId },
+ data: {
+ status: "FAILED",
+ finishedAt: new Date(),
+ errorMessage: message,
+ },
+ })
+ .catch((e) => console.error(`[cron-runner] gagal update FAILED`, e));
+ }
+ return { ok: false, error: message };
+ }
+}
diff --git a/prisma/migrations/20260518160000_add_user_suspension/migration.sql b/prisma/migrations/20260518160000_add_user_suspension/migration.sql
new file mode 100644
index 0000000..118d0d4
--- /dev/null
+++ b/prisma/migrations/20260518160000_add_user_suspension/migration.sql
@@ -0,0 +1,15 @@
+-- AlterTable: tambah kemampuan admin untuk suspend/ban user (moderasi scam,
+-- harassment, TOS violation). Suspended user diblokir sign-in dan dilarang
+-- melakukan aksi mutatif. Hard-delete dihindari supaya audit trail terjaga.
+ALTER TABLE "User" ADD COLUMN "suspended" BOOLEAN NOT NULL DEFAULT false;
+ALTER TABLE "User" ADD COLUMN "suspendedAt" TIMESTAMP(3);
+ALTER TABLE "User" ADD COLUMN "suspendedReason" TEXT;
+ALTER TABLE "User" ADD COLUMN "suspendedById" TEXT;
+
+-- AddForeignKey: admin yang suspend tetap auditable kalau admin dihapus.
+ALTER TABLE "User" ADD CONSTRAINT "User_suspendedById_fkey"
+ FOREIGN KEY ("suspendedById") REFERENCES "User"("id")
+ ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- CreateIndex: filter user list admin sering pakai suspended status.
+CREATE INDEX "User_suspended_idx" ON "User"("suspended");
diff --git a/prisma/migrations/20260518170000_add_cron_run/migration.sql b/prisma/migrations/20260518170000_add_cron_run/migration.sql
new file mode 100644
index 0000000..3101349
--- /dev/null
+++ b/prisma/migrations/20260518170000_add_cron_run/migration.sql
@@ -0,0 +1,22 @@
+-- CreateEnum
+CREATE TYPE "CronRunStatus" AS ENUM ('RUNNING', 'SUCCESS', 'FAILED');
+
+-- CreateTable: log per cron run untuk observability admin. Append-only.
+CREATE TABLE "CronRun" (
+ "id" TEXT NOT NULL,
+ "jobName" TEXT NOT NULL,
+ "startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "finishedAt" TIMESTAMP(3),
+ "status" "CronRunStatus" NOT NULL DEFAULT 'RUNNING',
+ "errorMessage" TEXT,
+ "payload" JSONB,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "CronRun_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex: query "last run per job" sering โ pakai composite index.
+CREATE INDEX "CronRun_jobName_startedAt_idx" ON "CronRun"("jobName", "startedAt" DESC);
+
+-- CreateIndex: query "recent runs across all jobs" untuk admin dashboard.
+CREATE INDEX "CronRun_startedAt_idx" ON "CronRun"("startedAt" DESC);
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index b6e7d4e..8218c22 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -20,6 +20,13 @@ model User {
acceptedTermsAndPrivacy Boolean @default(false)
/// Waktu user menyetujui Syarat & Ketentuan dan Kebijakan Privasi
acceptedAt DateTime?
+ /// Suspended user diblokir sign-in dan aksi mutatif. Set oleh admin via panel.
+ suspended Boolean @default(false)
+ suspendedAt DateTime?
+ suspendedReason String?
+ suspendedById String?
+ suspendedBy User? @relation("UserSuspendedBy", fields: [suspendedById], references: [id], onDelete: SetNull)
+ suspendedUsers User[] @relation("UserSuspendedBy")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -438,6 +445,31 @@ model Refund {
@@index([status, createdAt])
}
+/// 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`.
+model CronRun {
+ id String @id @default(cuid())
+ jobName String
+ startedAt DateTime @default(now())
+ finishedAt DateTime?
+ status CronRunStatus @default(RUNNING)
+ errorMessage String?
+ /// Snapshot ringkas hasil run (mis. `{ completed: 5, ids: [...] }`).
+ payload Json?
+
+ createdAt DateTime @default(now())
+
+ @@index([jobName, startedAt(sort: Desc)])
+ @@index([startedAt(sort: Desc)])
+}
+
+enum CronRunStatus {
+ RUNNING
+ SUCCESS
+ FAILED
+}
+
enum RefundReason {
/// Peserta cancel booking sendiri (mengikuti refund window policy).
USER_CANCELLATION
diff --git a/server/repositories/organizer.repo.ts b/server/repositories/organizer.repo.ts
index 849d21f..4e4f20a 100644
--- a/server/repositories/organizer.repo.ts
+++ b/server/repositories/organizer.repo.ts
@@ -25,9 +25,27 @@ export const organizerRepo = {
});
},
- async listByStatus(status?: "PENDING" | "APPROVED" | "REJECTED") {
+ async listByStatus(
+ status?: "PENDING" | "APPROVED" | "REJECTED",
+ filters?: {
+ dateFrom?: Date;
+ dateTo?: Date;
+ reviewerEmail?: string;
+ }
+ ) {
+ const where: Prisma.OrganizerVerificationWhereInput = {};
+ if (status) where.status = status;
+ if (filters?.dateFrom || filters?.dateTo) {
+ where.createdAt = {
+ ...(filters.dateFrom && { gte: filters.dateFrom }),
+ ...(filters.dateTo && { lte: filters.dateTo }),
+ };
+ }
+ if (filters?.reviewerEmail) {
+ where.reviewedBy = { email: filters.reviewerEmail };
+ }
return prisma.organizerVerification.findMany({
- where: status ? { status } : undefined,
+ where,
orderBy: { createdAt: "desc" },
include: {
user: { select: { id: true, name: true, email: true } },
@@ -55,6 +73,24 @@ export const organizerRepo = {
});
},
+ /**
+ * Reopen pengajuan REJECTED ke PENDING. Simpan rejection reason lama
+ * sebagai catatan history (di-overwrite kalau di-reject lagi nanti).
+ */
+ async reopen(id: string, reopenNote: string) {
+ return prisma.organizerVerification.update({
+ where: { id },
+ data: {
+ status: "PENDING",
+ reviewedById: null,
+ reviewedAt: null,
+ verifiedAt: null,
+ // Pertahankan rejectionReason lama di field, append note reopen.
+ rejectionReason: `[Dibuka kembali admin: ${reopenNote}]`,
+ },
+ });
+ },
+
async updateReview(
id: string,
data: {
diff --git a/server/repositories/payout.repo.ts b/server/repositories/payout.repo.ts
index 66108ce..349aae5 100644
--- a/server/repositories/payout.repo.ts
+++ b/server/repositories/payout.repo.ts
@@ -31,9 +31,26 @@ export const payoutRepo = {
return client.payout.findUnique({ where: { bookingId } });
},
- async listByStatus(status: PayoutStatus) {
+ async listByStatus(
+ status: PayoutStatus,
+ filters?: {
+ dateFrom?: Date;
+ dateTo?: Date;
+ processorEmail?: string;
+ }
+ ) {
+ const where: Prisma.PayoutWhereInput = { status };
+ if (filters?.dateFrom || filters?.dateTo) {
+ where.createdAt = {
+ ...(filters.dateFrom && { gte: filters.dateFrom }),
+ ...(filters.dateTo && { lte: filters.dateTo }),
+ };
+ }
+ if (filters?.processorEmail) {
+ where.processedBy = { email: filters.processorEmail };
+ }
return prisma.payout.findMany({
- where: { status },
+ where,
orderBy: { heldUntil: "asc" },
include: payoutListInclude,
});
diff --git a/server/repositories/refund.repo.ts b/server/repositories/refund.repo.ts
index f548091..c6cb558 100644
--- a/server/repositories/refund.repo.ts
+++ b/server/repositories/refund.repo.ts
@@ -40,10 +40,35 @@ export const refundRepo = {
},
async listByStatus(
- status?: "PENDING" | "APPROVED" | "REJECTED" | "PROCESSING" | "SUCCEEDED" | "FAILED"
+ status?: "PENDING" | "APPROVED" | "REJECTED" | "PROCESSING" | "SUCCEEDED" | "FAILED",
+ filters?: {
+ dateFrom?: Date;
+ dateTo?: Date;
+ reviewerEmail?: string;
+ reason?:
+ | "USER_CANCELLATION"
+ | "ORGANIZER_CANCELLED"
+ | "TRIP_ISSUE"
+ | "ADMIN_ADJUSTMENT"
+ | "DISPUTE_RESOLVED";
+ }
) {
+ const where: Prisma.RefundWhereInput = {};
+ if (status) where.status = status;
+ if (filters?.dateFrom || filters?.dateTo) {
+ where.createdAt = {
+ ...(filters.dateFrom && { gte: filters.dateFrom }),
+ ...(filters.dateTo && { lte: filters.dateTo }),
+ };
+ }
+ if (filters?.reviewerEmail) {
+ where.reviewedBy = { email: filters.reviewerEmail };
+ }
+ if (filters?.reason) {
+ where.reason = filters.reason;
+ }
return prisma.refund.findMany({
- where: status ? { status } : undefined,
+ where,
orderBy: { createdAt: "desc" },
include: refundListInclude,
});
diff --git a/server/repositories/trip.repo.ts b/server/repositories/trip.repo.ts
index 5bbbd36..24cea84 100644
--- a/server/repositories/trip.repo.ts
+++ b/server/repositories/trip.repo.ts
@@ -45,7 +45,11 @@ export const tripRepo = {
async findOpen(filters?: TripFilters) {
const todayStart = utcStartOfDay(new Date());
- const andParts: Prisma.TripWhereInput[] = [{ status: "OPEN" }];
+ const andParts: Prisma.TripWhereInput[] = [
+ { status: "OPEN" },
+ // Sembunyikan trip dari organizer yang suspended โ moderasi via panel admin.
+ { organizer: { suspended: false } },
+ ];
if (filters?.category) {
andParts.push({ category: filters.category });
diff --git a/server/repositories/user.repo.ts b/server/repositories/user.repo.ts
index 986007c..f135804 100644
--- a/server/repositories/user.repo.ts
+++ b/server/repositories/user.repo.ts
@@ -111,6 +111,135 @@ export const userRepo = {
return prisma.user.create({ data });
},
+ /**
+ * Admin search: by email/name (case-insensitive contains) + filter
+ * suspended status. Limit 100 supaya tidak load semua user di list page.
+ */
+ async searchForAdmin(filters: { q?: string; suspended?: boolean }) {
+ const where: Prisma.UserWhereInput = {};
+ if (filters.q) {
+ where.OR = [
+ { email: { contains: filters.q, mode: "insensitive" } },
+ { name: { contains: filters.q, mode: "insensitive" } },
+ ];
+ }
+ if (typeof filters.suspended === "boolean") {
+ where.suspended = filters.suspended;
+ }
+ return prisma.user.findMany({
+ where,
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ image: true,
+ createdAt: true,
+ suspended: true,
+ suspendedAt: true,
+ organizerVerification: { select: { status: true } },
+ _count: {
+ select: {
+ trips: true,
+ participations: { where: { status: { not: "CANCELLED" } } },
+ },
+ },
+ },
+ orderBy: { createdAt: "desc" },
+ take: 100,
+ });
+ },
+
+ /**
+ * Detail user untuk admin: full profile + booking history + organized
+ * trips + verification. Tidak ekspos password atau OAuth token.
+ */
+ async findByIdForAdmin(id: string) {
+ return prisma.user.findUnique({
+ where: { id },
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ image: true,
+ createdAt: true,
+ acceptedTermsAndPrivacy: true,
+ acceptedAt: true,
+ suspended: true,
+ suspendedAt: true,
+ suspendedReason: true,
+ suspendedBy: { select: { id: true, name: true, email: true } },
+ profile: true,
+ organizerVerification: {
+ select: {
+ id: true,
+ status: true,
+ createdAt: true,
+ reviewedAt: true,
+ rejectionReason: true,
+ },
+ },
+ trips: {
+ select: {
+ id: true,
+ title: true,
+ destination: true,
+ date: true,
+ status: true,
+ price: true,
+ _count: {
+ select: {
+ participants: { where: { status: { not: "CANCELLED" } } },
+ },
+ },
+ },
+ orderBy: { date: "desc" },
+ take: 50,
+ },
+ bookings: {
+ select: {
+ id: true,
+ amount: true,
+ status: true,
+ createdAt: true,
+ trip: {
+ select: { id: true, title: true, date: true, organizerId: true },
+ },
+ },
+ orderBy: { createdAt: "desc" },
+ take: 50,
+ },
+ },
+ });
+ },
+
+ /** Cek cepat status suspended โ dipakai auth guard di server actions. */
+ async isSuspended(id: string): Promise {
+ const u = await prisma.user.findUnique({
+ where: { id },
+ select: { suspended: true },
+ });
+ return u?.suspended === true;
+ },
+
+ async setSuspension(
+ id: string,
+ data: {
+ suspended: boolean;
+ suspendedById?: string | null;
+ suspendedReason?: string | null;
+ }
+ ) {
+ return prisma.user.update({
+ where: { id },
+ data: {
+ suspended: data.suspended,
+ suspendedAt: data.suspended ? new Date() : null,
+ suspendedById: data.suspended ? data.suspendedById ?? null : null,
+ suspendedReason: data.suspended ? data.suspendedReason ?? null : null,
+ },
+ });
+ },
+
/**
* Tandai user sudah accept T&C/Privacy. Idempotent: kalau sudah `true`,
* tidak overwrite `acceptedAt` (audit trail pertama tetap akurat).
diff --git a/server/services/organizer.service.ts b/server/services/organizer.service.ts
index 9682f4c..8dc1d9e 100644
--- a/server/services/organizer.service.ts
+++ b/server/services/organizer.service.ts
@@ -78,6 +78,38 @@ export const organizerService = {
return v?.status === "APPROVED";
},
+ /**
+ * Buka kembali verifikasi yang REJECTED ke PENDING. Dipakai admin saat
+ * organizer kirim foto/data baru via email/WA dan ingin di-review ulang
+ * tanpa drop & recreate row.
+ *
+ * - `rejectionReason` lama disimpan di field โ kalau di-reject lagi nanti,
+ * field-nya di-overwrite (audit trail via auditLog Phase 4 di roadmap).
+ * - Reset `reviewedById`, `reviewedAt` supaya muncul lagi di tab PENDING.
+ */
+ async reopenVerification(input: {
+ verificationId: string;
+ adminId: string;
+ note: string;
+ }) {
+ const verification = await organizerRepo.findById(input.verificationId);
+ if (!verification) {
+ throw new Error("Pengajuan tidak ditemukan");
+ }
+ if (verification.status !== "REJECTED") {
+ throw new Error("Hanya pengajuan REJECTED yang bisa dibuka kembali");
+ }
+ const trimmedNote = input.note.trim();
+ if (trimmedNote.length < 10) {
+ throw new Error("Catatan reopen wajib min 10 karakter untuk audit");
+ }
+ if (trimmedNote.length > 500) {
+ throw new Error("Catatan reopen maksimal 500 karakter");
+ }
+
+ return organizerRepo.reopen(input.verificationId, trimmedNote);
+ },
+
/** Reveal NIK plaintext. Caller must enforce authorization (owner or admin). */
decryptNik(nikEncrypted: string): string {
return decryptString(nikEncrypted);
diff --git a/server/services/user.service.ts b/server/services/user.service.ts
new file mode 100644
index 0000000..58b26b5
--- /dev/null
+++ b/server/services/user.service.ts
@@ -0,0 +1,54 @@
+import { userRepo } from "@/server/repositories/user.repo";
+
+export const userService = {
+ /**
+ * Suspend user oleh admin. Idempotent โ kalau sudah suspended, tolak supaya
+ * admin tahu (cegah race condition multiple admin suspend sekaligus).
+ * Wajib reason untuk audit (min 10 char).
+ */
+ async suspendUser(input: {
+ userId: string;
+ adminId: string;
+ reason: string;
+ }) {
+ if (input.userId === input.adminId) {
+ throw new Error("Tidak bisa suspend akun sendiri");
+ }
+ const trimmedReason = input.reason.trim();
+ if (trimmedReason.length < 10) {
+ throw new Error("Alasan suspend wajib min 10 karakter untuk audit");
+ }
+ if (trimmedReason.length > 500) {
+ throw new Error("Alasan suspend maksimal 500 karakter");
+ }
+
+ const target = await userRepo.findById(input.userId);
+ if (!target) {
+ throw new Error("User tidak ditemukan");
+ }
+ if (target.suspended) {
+ throw new Error("User sudah dalam status suspended");
+ }
+
+ return userRepo.setSuspension(input.userId, {
+ suspended: true,
+ suspendedById: input.adminId,
+ suspendedReason: trimmedReason,
+ });
+ },
+
+ async unsuspendUser(input: { userId: string; adminId: string }) {
+ if (input.userId === input.adminId) {
+ throw new Error("Tidak bisa modifikasi akun sendiri");
+ }
+ const target = await userRepo.findById(input.userId);
+ if (!target) {
+ throw new Error("User tidak ditemukan");
+ }
+ if (!target.suspended) {
+ throw new Error("User tidak dalam status suspended");
+ }
+
+ return userRepo.setSuspension(input.userId, { suspended: false });
+ },
+};