Compare commits

...

2 Commits

Author SHA1 Message Date
arifal 244a6da9bb 0.13.0 2026-05-18 19:46:02 +07:00
arifal 6e02f2f0d7 admin roadmap filter & search, user management, reopen rejected, system health 2026-05-18 19:45:14 +07:00
38 changed files with 2016 additions and 342 deletions
+13 -15
View File
@@ -1,5 +1,7 @@
# Setrip — Admin Audit & Investigation Roadmap # 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. 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. > **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 ## Phase 1 — Filter & Search Enhancements ✅ DELIVERED
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).
| # | Item | Status | File | | # | Item | Status | File |
|---|---|---|---| |---|---|---|---|
| 1.1 | Filter date range (`from`, `to`) di `/admin/refunds` | | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) | | 1.1 | Filter date range (`dateFrom`, `dateTo`) 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.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` (lihat juga [ADMIN_PAYMENT_OPS_ROADMAP.md](ADMIN_PAYMENT_OPS_ROADMAP.md)) | | [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 + `processedBy` di `/admin/payouts` | | [app/admin/payouts/page.tsx](app/admin/payouts/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 + `reviewedBy` di `/admin/verifications` | | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) | | 1.5 | Filter date range + `reviewer` 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.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: tambah optional filter params di `refundRepo.listByStatus`, `payoutRepo.listByStatus`, `organizerRepo.listByStatus` | | `server/repositories/*.ts` | | 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. **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 ⏳ ## Phase 2 — Global Search ⏳
+32 -21
View File
@@ -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) | | 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/) | | **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) | | **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) | | 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, mark FAILED + link ke booking timeline | [app/admin/refunds/page.tsx](app/admin/refunds/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 setelah transfer manual + link ke booking timeline | [app/admin/payouts/page.tsx](app/admin/payouts/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). 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) | | 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) | | 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) | | 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 | ⏳ 0% | [ADMIN_USER_MGMT_ROADMAP.md](ADMIN_USER_MGMT_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 | ⏳ 0% | [ADMIN_VERIFICATION_ROADMAP.md](ADMIN_VERIFICATION_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 | ⏳ 0% | [ADMIN_SYSTEM_HEALTH_ROADMAP.md](ADMIN_SYSTEM_HEALTH_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. **Audit Phase 2-4** (lihat [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md)):
2. **User Management** (MEDIUM) — search + suspend/ban. Butuh schema change (`User.suspended`). - Phase 2 — Global Search (admin search bar resolve email/order_id/cuid)
3. **System Health** (MEDIUM) — cron monitor + stale state alerts. Butuh model baru (`CronRun`). - Phase 3 — CSV Export untuk refunds/payouts/verifications
4. **Verification** (MEDIUM) — reopen REJECTED + re-upload request. Edge case rare tapi kecil scope. - 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 ## Tindakan manual setelah deploy versi terakhir
Untuk versi yang berisi delivery Trip Ops + Payment Ops:
```bash ```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 npx prisma migrate deploy
# Restart Next.js / PM2 supaya Prisma client baru ter-load # Restart Next.js / PM2 supaya Prisma client baru ter-load
pm2 restart setrip --update-env pm2 restart setrip --update-env
``` ```
Brief admin tentang dua kapabilitas baru: Brief admin tentang kapabilitas baru:
- **Force-cancel trip** di `/admin/trips/[id]` — pakai saat organizer unreachable / dispute, reason wajib min 10 karakter. - **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, aman diulang. - **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.
-103
View File
@@ -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.
-81
View File
@@ -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).
-88
View File
@@ -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).
+34 -4
View File
@@ -1,8 +1,9 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/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 { payoutRepo } from "@/server/repositories/payout.repo";
import { AdminFilterBar } from "@/features/admin/components/admin-filter-bar";
import { import {
PayoutReviewCard, PayoutReviewCard,
type PayoutCardData, type PayoutCardData,
@@ -18,7 +19,18 @@ const TABS: { key: Tab; label: string }[] = [
]; ];
interface PageProps { 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) { export default async function AdminPayoutsPage({ searchParams }: PageProps) {
@@ -39,7 +51,11 @@ export default async function AdminPayoutsPage({ searchParams }: PageProps) {
? (params.tab as Tab) ? (params.tab as Tab)
: "RELEASED"; : "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) => ({ const items: PayoutCardData[] = rows.map((p) => ({
id: p.id, id: p.id,
amount: p.amount, amount: p.amount,
@@ -78,6 +94,18 @@ export default async function AdminPayoutsPage({ searchParams }: PageProps) {
</p> </p>
</header> </header>
<AdminFilterBar
action="/admin/payouts"
values={{
tab,
dateFrom: params.dateFrom,
dateTo: params.dateTo,
reviewer: params.reviewer,
}}
reviewerOptions={listAdminEmails()}
reviewerLabel="Processor"
/>
<div className="mb-6 flex flex-wrap gap-2"> <div className="mb-6 flex flex-wrap gap-2">
{TABS.map((t) => ( {TABS.map((t) => (
<a <a
@@ -96,7 +124,9 @@ export default async function AdminPayoutsPage({ searchParams }: PageProps) {
{items.length === 0 ? ( {items.length === 0 ? (
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center"> <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 payout pada status ini.</p> <p className="text-sm text-neutral-500">
Tidak ada payout yang cocok dengan filter ini.
</p>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
+52 -4
View File
@@ -1,9 +1,10 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/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 { refundRepo } from "@/server/repositories/refund.repo";
import { CreateRefundForm } from "@/features/refund/components/create-refund-form"; import { CreateRefundForm } from "@/features/refund/components/create-refund-form";
import { AdminFilterBar } from "@/features/admin/components/admin-filter-bar";
import { import {
RefundReviewCard, RefundReviewCard,
type RefundCardData, type RefundCardData,
@@ -19,8 +20,30 @@ const TABS: { key: Tab; label: string }[] = [
{ key: "FAILED", label: "Gagal" }, { 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 { 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) { 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) const tab: Tab = TABS.some((t) => t.key === params.tab)
? (params.tab as Tab) ? (params.tab as Tab)
: "PENDING"; : "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) => ({ const items: RefundCardData[] = rows.map((r) => ({
id: r.id, id: r.id,
amount: r.amount, amount: r.amount,
@@ -92,6 +126,20 @@ export default async function AdminRefundsPage({ searchParams }: PageProps) {
<CreateRefundForm /> <CreateRefundForm />
<AdminFilterBar
action="/admin/refunds"
values={{
tab,
dateFrom: params.dateFrom,
dateTo: params.dateTo,
reviewer: params.reviewer,
reason: params.reason,
}}
reviewerOptions={listAdminEmails()}
reviewerLabel="Reviewer"
reasonOptions={[...REASON_OPTIONS]}
/>
<div className="mb-6 flex flex-wrap gap-2"> <div className="mb-6 flex flex-wrap gap-2">
{TABS.map((t) => ( {TABS.map((t) => (
<a <a
@@ -111,7 +159,7 @@ export default async function AdminRefundsPage({ searchParams }: PageProps) {
{items.length === 0 ? ( {items.length === 0 ? (
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center"> <div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center">
<p className="text-sm text-neutral-500"> <p className="text-sm text-neutral-500">
Tidak ada refund pada status ini. Tidak ada refund yang cocok dengan filter ini.
</p> </p>
</div> </div>
) : ( ) : (
+258
View File
@@ -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<JobSummary> {
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 (
<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 summaries = await Promise.all(TRACKED_JOBS.map(getJobSummary));
const recentRuns = await prisma.cronRun.findMany({
orderBy: { startedAt: "desc" },
take: 20,
});
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">
System Health
</h1>
<p className="mt-1 text-sm text-neutral-500">
Status cron job otomatis. Refresh halaman ini setelah trigger cron
manual atau saat investigasi.
</p>
</header>
<section className="mb-8">
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
Cron Jobs
</h2>
<div className="grid gap-3 sm:grid-cols-2">
{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 (
<div
key={s.jobName}
className={`rounded-2xl border p-4 shadow-sm sm:p-5 ${cls}`}
>
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
Job
</p>
<p className="font-mono text-sm font-bold text-neutral-800">
{s.jobName}
</p>
</div>
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${badge.cls}`}
>
{badge.label}
</span>
</div>
<dl className="mt-3 space-y-1 text-xs text-neutral-700">
<div>
<dt className="inline font-semibold">Last run:</dt>{" "}
<dd className="inline">
{s.lastRun
? `${formatDateTime(s.lastRun.at)} · ${s.lastRun.status}`
: "Belum pernah"}
</dd>
</div>
<div>
<dt className="inline font-semibold">Last success:</dt>{" "}
<dd className="inline">
{s.lastSuccess
? formatDateTime(s.lastSuccess)
: "Belum pernah"}
</dd>
</div>
<div>
<dt className="inline font-semibold">7 hari terakhir:</dt>{" "}
<dd className="inline">
{s.totalRuns7d} run, {s.failedRuns7d} failed
</dd>
</div>
{s.lastRun?.errorMessage && (
<div className="mt-2 rounded-lg bg-red-100 p-2 text-[11px] text-red-800">
Error terakhir: {s.lastRun.errorMessage}
</div>
)}
</dl>
</div>
);
})}
</div>
</section>
<section>
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
Recent Runs (20 terakhir)
</h2>
{recentRuns.length === 0 ? (
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-8 text-center">
<p className="text-sm text-neutral-500">
Belum ada cron run tercatat. Setelah cron berikutnya jalan, baris
pertama akan muncul di sini.
</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">Job</th>
<th className="px-3 py-2 text-left">Started</th>
<th className="px-3 py-2 text-left">Finished</th>
<th className="px-3 py-2 text-left">Status</th>
<th className="px-3 py-2 text-left">Note</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100 text-xs text-neutral-700">
{recentRuns.map((r) => (
<tr key={r.id}>
<td className="px-3 py-2 font-mono">{r.jobName}</td>
<td className="px-3 py-2">{formatDateTime(r.startedAt)}</td>
<td className="px-3 py-2">
{r.finishedAt ? formatDateTime(r.finishedAt) : "—"}
</td>
<td className="px-3 py-2">
<StatusBadge value={r.status} />
</td>
<td className="px-3 py-2 text-neutral-500">
{r.errorMessage ??
(r.payload
? truncate(JSON.stringify(r.payload), 80)
: "—")}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}
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 (
<span
className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}
>
{value}
</span>
);
}
+349
View File
@@ -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 (
<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 { 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 (
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
<div className="mb-4 text-xs text-neutral-500">
<Link href="/admin/users" className="hover:text-primary-600">
Kembali ke list users
</Link>
</div>
<header
className={`mb-6 rounded-2xl border p-5 shadow-sm sm:p-6 ${
user.suspended
? "border-red-300 bg-red-50/60"
: "border-neutral-200 bg-white"
}`}
>
<div className="flex flex-wrap items-start gap-4">
{user.image ? (
<Image
src={user.image}
alt=""
width={64}
height={64}
className="h-16 w-16 shrink-0 rounded-full object-cover"
/>
) : (
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-primary-600 text-xl font-bold text-white">
{user.name.charAt(0).toUpperCase()}
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-bold text-neutral-900 sm:text-2xl">
{user.name}
</h1>
{user.suspended && (
<span className="rounded-full bg-red-100 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-red-800">
Suspended
</span>
)}
{user.organizerVerification?.status === "APPROVED" && (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-emerald-800">
Verified Organizer
</span>
)}
</div>
<p className="mt-0.5 text-sm text-neutral-600">{user.email}</p>
<p className="mt-0.5 text-[11px] text-neutral-500">
User ID:{" "}
<code className="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-neutral-700">
{user.id}
</code>
</p>
<p className="mt-1 text-xs text-neutral-500">
Bergabung{" "}
{user.createdAt.toLocaleDateString("id-ID", {
day: "numeric",
month: "long",
year: "numeric",
})}
{user.acceptedAt && (
<>
{" "}
· Setuju T&C{" "}
{user.acceptedAt.toLocaleDateString("id-ID", {
day: "numeric",
month: "short",
year: "numeric",
})}
</>
)}
</p>
</div>
</div>
</header>
<section className="mb-6 grid gap-3 sm:grid-cols-3">
<StatCard label="Trip dibuat" value={String(user.trips.length)} />
<StatCard label="Booking aktif" value={String(user.bookings.length)} />
<StatCard
label="Total spent (PAID)"
value={formatRupiah(totalSpent)}
accent="emerald"
/>
</section>
{user.suspended && (
<section className="mb-6 rounded-2xl border border-red-300 bg-red-50 p-4 sm:p-5">
<h2 className="text-sm font-bold text-red-900">
Akun ditangguhkan
</h2>
<p className="mt-1 text-xs text-red-900/80">
{user.suspendedReason ?? "Tidak ada alasan tercatat."}
</p>
{user.suspendedBy && (
<p className="mt-2 text-[11px] text-red-900/70">
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",
})}
</>
)}
</p>
)}
</section>
)}
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-5">
<h2 className="mb-3 text-sm font-bold text-neutral-900">
Aksi Admin
</h2>
{isSelf ? (
<p className="text-xs text-neutral-500">
Tidak bisa suspend akun sendiri.
</p>
) : (
<SuspendUserButton userId={user.id} isSuspended={user.suspended} />
)}
</section>
{user.profile && (
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-5">
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
Profil Sosial
</h2>
<dl className="grid gap-3 text-sm sm:grid-cols-2">
{user.profile.bio && (
<div className="sm:col-span-2">
<dt className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
Bio
</dt>
<dd className="whitespace-pre-wrap text-neutral-700">
{user.profile.bio}
</dd>
</div>
)}
{user.profile.city && (
<div>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
Kota
</dt>
<dd className="text-neutral-700">{user.profile.city}</dd>
</div>
)}
{user.profile.vibe && (
<div>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
Vibe
</dt>
<dd className="text-neutral-700">{user.profile.vibe}</dd>
</div>
)}
{user.profile.interests.length > 0 && (
<div className="sm:col-span-2">
<dt className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
Minat
</dt>
<dd className="mt-0.5 flex flex-wrap gap-1.5">
{user.profile.interests.map((tag) => (
<span
key={tag}
className="rounded-full bg-secondary-50 px-2 py-0.5 text-[11px] font-medium text-secondary-700"
>
#{tag}
</span>
))}
</dd>
</div>
)}
{user.profile.instagram && (
<div>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
Instagram
</dt>
<dd className="text-neutral-700">@{user.profile.instagram}</dd>
</div>
)}
</dl>
</section>
)}
{user.organizerVerification && (
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-5">
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
Verifikasi Organizer
</h2>
<p className="text-sm text-neutral-700">
Status:{" "}
<span className="font-semibold">
{user.organizerVerification.status}
</span>
{" · "}
<Link
href={`/admin/verifications?tab=${user.organizerVerification.status}`}
className="text-secondary-700 hover:text-secondary-900"
>
Buka di /admin/verifications
</Link>
</p>
{user.organizerVerification.rejectionReason && (
<p className="mt-1 text-xs text-red-700">
Reason: {user.organizerVerification.rejectionReason}
</p>
)}
</section>
)}
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-5">
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
Trip yang dibuat ({user.trips.length})
</h2>
{user.trips.length === 0 ? (
<p className="text-xs text-neutral-500">
User ini belum pernah membuat trip.
</p>
) : (
<ul className="divide-y divide-neutral-100">
{user.trips.map((t) => (
<li key={t.id} className="py-2.5">
<Link
href={`/admin/trips/${t.id}`}
className="flex items-center justify-between gap-3 text-sm hover:text-primary-700"
>
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-neutral-800">
{t.title}
</p>
<p className="text-[11px] text-neutral-500">
{t.destination} ·{" "}
{t.date.toLocaleDateString("id-ID", {
day: "numeric",
month: "short",
year: "numeric",
})}{" "}
· {t.status}
</p>
</div>
<p className="shrink-0 text-xs font-semibold text-primary-700">
{formatRupiah(t.price)}
</p>
</Link>
</li>
))}
</ul>
)}
</section>
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-5">
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
Booking sebagai peserta ({user.bookings.length})
</h2>
{user.bookings.length === 0 ? (
<p className="text-xs text-neutral-500">Belum ada booking.</p>
) : (
<ul className="divide-y divide-neutral-100">
{user.bookings.map((b) => (
<li key={b.id} className="py-2.5">
<Link
href={`/admin/bookings/${b.id}`}
className="flex items-center justify-between gap-3 text-sm hover:text-primary-700"
>
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-neutral-800">
{b.trip.title}
</p>
<p className="text-[11px] text-neutral-500">
{b.trip.date.toLocaleDateString("id-ID", {
day: "numeric",
month: "short",
year: "numeric",
})}{" "}
· status: <span className="font-semibold">{b.status}</span>
</p>
</div>
<p className="shrink-0 text-xs font-semibold text-primary-700">
{formatRupiah(b.amount)}
</p>
</Link>
</li>
))}
</ul>
)}
</section>
</div>
);
}
function StatCard({
label,
value,
accent = "primary",
}: {
label: string;
value: string;
accent?: "primary" | "emerald";
}) {
const cls = accent === "emerald" ? "text-emerald-700" : "text-primary-700";
return (
<div className="rounded-xl border border-neutral-200 bg-white p-3 sm:p-4">
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
{label}
</p>
<p className={`mt-0.5 text-lg font-bold sm:text-xl ${cls}`}>{value}</p>
</div>
);
}
+167
View File
@@ -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 (
<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 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 (
<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">
User Management
</h1>
<p className="mt-1 text-sm text-neutral-500">
Cari user, lihat history booking & trip, dan suspend akun yang
melakukan abuse (scam, harassment, TOS violation).
</p>
</header>
<form method="get" className="mb-4 flex gap-2">
<input type="hidden" name="tab" value={tab} />
<input
name="q"
defaultValue={q}
placeholder="Cari email atau nama..."
className="flex-1 rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-primary-400"
/>
<button
type="submit"
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700"
>
Cari
</button>
{q && (
<Link
href={`/admin/users?tab=${tab}`}
className="rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm font-semibold text-neutral-600 hover:bg-neutral-50"
>
Reset
</Link>
)}
</form>
<div className="mb-6 flex flex-wrap gap-2">
{TABS.map((t) => (
<Link
key={t.key}
href={`/admin/users?tab=${t.key}${q ? `&q=${encodeURIComponent(q)}` : ""}`}
className={`rounded-full px-4 py-1.5 text-sm font-semibold transition-colors ${
tab === t.key
? "bg-primary-600 text-white"
: "bg-neutral-100 text-neutral-600 hover:bg-neutral-200"
}`}
>
{t.label}
</Link>
))}
</div>
{users.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">
{q
? `Tidak ada user yang cocok dengan "${q}".`
: "Tidak ada user pada tab ini."}
</p>
</div>
) : (
<ul className="space-y-2">
{users.map((u) => (
<li
key={u.id}
className={`rounded-2xl border bg-white p-3 shadow-sm transition-shadow hover:shadow-md sm:p-4 ${
u.suspended ? "border-red-200" : "border-neutral-200"
}`}
>
<Link
href={`/admin/users/${u.id}`}
className="flex items-center gap-3"
>
{u.image ? (
<Image
src={u.image}
alt=""
width={40}
height={40}
className="h-10 w-10 shrink-0 rounded-full object-cover"
/>
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary-600 text-sm font-bold text-white">
{u.name.charAt(0).toUpperCase()}
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate text-sm font-semibold text-neutral-800">
{u.name}
</p>
{u.suspended && (
<span className="rounded-full bg-red-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-red-800">
Suspended
</span>
)}
{u.organizerVerification?.status === "APPROVED" && (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-emerald-800">
Organizer
</span>
)}
</div>
<p className="truncate text-xs text-neutral-500">{u.email}</p>
<p className="mt-0.5 text-[11px] text-neutral-400">
Bergabung{" "}
{u.createdAt.toLocaleDateString("id-ID", {
day: "numeric",
month: "short",
year: "numeric",
})}
{" · "}
{u._count.trips} trip dibuat, {u._count.participations}{" "}
booking
</p>
</div>
</Link>
</li>
))}
</ul>
)}
</div>
);
}
+34 -4
View File
@@ -1,15 +1,27 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/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 { organizerRepo } from "@/server/repositories/organizer.repo";
import { organizerService } from "@/server/services/organizer.service"; import { organizerService } from "@/server/services/organizer.service";
import { AdminFilterBar } from "@/features/admin/components/admin-filter-bar";
import { ReviewCard } from "@/features/organizer/components/review-card"; import { ReviewCard } from "@/features/organizer/components/review-card";
type Tab = "PENDING" | "APPROVED" | "REJECTED"; type Tab = "PENDING" | "APPROVED" | "REJECTED";
interface PageProps { 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) { export default async function AdminVerificationsPage({ searchParams }: PageProps) {
@@ -29,7 +41,11 @@ export default async function AdminVerificationsPage({ searchParams }: PageProps
const tab: Tab = const tab: Tab =
params.tab === "APPROVED" || params.tab === "REJECTED" ? params.tab : "PENDING"; 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) => ({ const items = rows.map((v) => ({
id: v.id, id: v.id,
fullName: v.fullName, fullName: v.fullName,
@@ -65,6 +81,18 @@ export default async function AdminVerificationsPage({ searchParams }: PageProps
</p> </p>
</header> </header>
<AdminFilterBar
action="/admin/verifications"
values={{
tab,
dateFrom: params.dateFrom,
dateTo: params.dateTo,
reviewer: params.reviewer,
}}
reviewerOptions={listAdminEmails()}
reviewerLabel="Reviewer"
/>
<div className="mb-6 flex gap-2"> <div className="mb-6 flex gap-2">
{tabs.map((t) => ( {tabs.map((t) => (
<a <a
@@ -83,7 +111,9 @@ export default async function AdminVerificationsPage({ searchParams }: PageProps
{items.length === 0 ? ( {items.length === 0 ? (
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center"> <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 data.</p> <p className="text-sm text-neutral-500">
Tidak ada data yang cocok dengan filter ini.
</p>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
+10 -11
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { tripService } from "@/server/services/trip.service"; import { tripService } from "@/server/services/trip.service";
import { payoutService } from "@/server/services/payout.service"; import { payoutService } from "@/server/services/payout.service";
import { runCron } from "@/lib/cron-runner";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -30,27 +31,25 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
try { const outcome = await runCron("auto-complete-trips", async () => {
const result = await tripService.autoCompletePastTrips(); const result = await tripService.autoCompletePastTrips();
// Setelah trip COMPLETED, payout yang sudah lewat heldUntil di-release // Setelah trip COMPLETED, payout yang sudah lewat heldUntil di-release
// supaya admin bisa langsung transfer ke organizer. Idempotent. // supaya admin bisa langsung transfer ke organizer. Idempotent.
const releaseResult = await payoutService.releaseEligible(); const releaseResult = await payoutService.releaseEligible();
console.log("[cron/auto-complete-trips] selesai", { return {
completed: result.count,
ids: result.ids,
payoutsReleased: releaseResult.releasedIds.length,
});
return NextResponse.json({
ok: true,
completed: result.count, completed: result.count,
ids: result.ids, ids: result.ids,
payoutsReleased: releaseResult.releasedIds, 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( return NextResponse.json(
{ error: "Gagal menjalankan auto-complete" }, { error: "Gagal menjalankan auto-complete" },
{ status: 500 } { status: 500 }
); );
} }
console.log("[cron/auto-complete-trips] selesai", outcome.payload);
return NextResponse.json({ ok: true, ...outcome.payload });
} }
+2
View File
@@ -9,9 +9,11 @@ import { signOut } from "next-auth/react";
const NAV_ITEMS: { href: string; label: string; icon: string }[] = [ const NAV_ITEMS: { href: string; label: string; icon: string }[] = [
{ href: "/admin", label: "Dashboard", icon: "📊" }, { href: "/admin", label: "Dashboard", icon: "📊" },
{ href: "/admin/trips", label: "Trips", icon: "🧭" }, { href: "/admin/trips", label: "Trips", icon: "🧭" },
{ href: "/admin/users", label: "Users", icon: "👥" },
{ href: "/admin/verifications", label: "Verifikasi", icon: "🪪" }, { href: "/admin/verifications", label: "Verifikasi", icon: "🪪" },
{ href: "/admin/refunds", label: "Refund", icon: "↩️" }, { href: "/admin/refunds", label: "Refund", icon: "↩️" },
{ href: "/admin/payouts", label: "Payout", icon: "💸" }, { href: "/admin/payouts", label: "Payout", icon: "💸" },
{ href: "/admin/system", label: "System", icon: "⚙️" },
]; ];
interface AdminSidebarProps { interface AdminSidebarProps {
@@ -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).
+55
View File
@@ -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).
@@ -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).
+49
View File
@@ -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 };
}
}
@@ -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 (
<form
method="get"
action={action}
className="mb-4 rounded-2xl border border-neutral-200 bg-white p-3 shadow-sm sm:p-4"
>
{/* Preserve tab via hidden input */}
{values.tab && <input type="hidden" name="tab" value={values.tab} />}
<div className="grid gap-3 sm:grid-cols-4">
<div>
<label
htmlFor="filter-dateFrom"
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
>
Dari tanggal
</label>
<input
id="filter-dateFrom"
name="dateFrom"
type="date"
defaultValue={values.dateFrom ?? ""}
className="w-full rounded-lg border border-neutral-200 bg-neutral-50 px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400 focus:bg-white"
/>
</div>
<div>
<label
htmlFor="filter-dateTo"
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
>
Sampai tanggal
</label>
<input
id="filter-dateTo"
name="dateTo"
type="date"
defaultValue={values.dateTo ?? ""}
className="w-full rounded-lg border border-neutral-200 bg-neutral-50 px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400 focus:bg-white"
/>
</div>
<div>
<label
htmlFor="filter-reviewer"
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
>
{reviewerLabel}
</label>
<select
id="filter-reviewer"
name="reviewer"
defaultValue={values.reviewer ?? ""}
className="w-full rounded-lg border border-neutral-200 bg-neutral-50 px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400 focus:bg-white"
>
<option value="">Semua</option>
{reviewerOptions.map((email) => (
<option key={email} value={email}>
{email}
</option>
))}
</select>
</div>
{reasonOptions ? (
<div>
<label
htmlFor="filter-reason"
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
>
Reason
</label>
<select
id="filter-reason"
name="reason"
defaultValue={values.reason ?? ""}
className="w-full rounded-lg border border-neutral-200 bg-neutral-50 px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400 focus:bg-white"
>
<option value="">Semua</option>
{reasonOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
) : (
<div className="flex items-end">
<button
type="submit"
className="w-full rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700"
>
Terapkan
</button>
</div>
)}
</div>
{reasonOptions && (
<div className="mt-3 flex gap-2">
<button
type="submit"
className="rounded-lg bg-primary-600 px-4 py-1.5 text-sm font-semibold text-white hover:bg-primary-700"
>
Terapkan
</button>
<a
href={`${action}${values.tab ? `?tab=${values.tab}` : ""}`}
className="rounded-lg border border-neutral-200 bg-white px-4 py-1.5 text-sm font-semibold text-neutral-600 hover:bg-neutral-50"
>
Reset
</a>
</div>
)}
</form>
);
}
@@ -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 (
<div className="space-y-2">
<button
type="button"
onClick={handleUnsuspend}
disabled={loading}
className="rounded-xl border border-emerald-300 bg-white px-4 py-2 text-sm font-bold text-emerald-700 hover:bg-emerald-50 disabled:opacity-50"
>
{loading ? "Memproses..." : "Buka Suspend"}
</button>
{error && (
<div className="rounded-lg bg-red-50 px-3 py-2 text-xs font-medium text-red-700">
{error}
</div>
)}
</div>
);
}
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white shadow-sm hover:bg-red-700"
>
Suspend User
</button>
);
}
return (
<div className="space-y-3 rounded-xl border border-red-200 bg-red-50/60 p-4">
<div>
<label
htmlFor="suspend-reason"
className="mb-1 block text-xs font-semibold text-red-900"
>
Alasan suspend (wajib min 10 karakter untuk audit)
</label>
<textarea
id="suspend-reason"
rows={3}
value={reason}
onChange={(e) => setReason(e.target.value)}
maxLength={500}
placeholder="contoh: User membuat 5 trip palsu dengan alias, lapor masuk dari peserta korban (ticket #123)."
className="w-full rounded-xl border border-red-200 bg-white px-3 py-2 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-red-400"
/>
<p className="mt-1 text-[11px] text-red-900/70">
{reason.trim().length}/500 karakter
</p>
</div>
{error && (
<div className="rounded-lg bg-red-100 px-3 py-2 text-xs font-medium text-red-700">
{error}
</div>
)}
<div className="flex gap-2">
<button
type="button"
onClick={handleSuspend}
disabled={loading || reason.trim().length < 10}
className="rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white shadow-sm hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? "Memproses..." : "Konfirmasi Suspend"}
</button>
<button
type="button"
onClick={() => {
setOpen(false);
setReason("");
setError("");
}}
disabled={loading}
className="rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
>
Batal
</button>
</div>
</div>
);
}
+28
View File
@@ -76,3 +76,31 @@ export async function reviewVerificationAction(formData: FormData) {
return { error: (err as Error).message }; 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 };
}
}
+76 -1
View File
@@ -2,7 +2,10 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { reviewVerificationAction } from "@/features/organizer/actions"; import {
reopenVerificationAction,
reviewVerificationAction,
} from "@/features/organizer/actions";
type Verification = { type Verification = {
id: string; id: string;
@@ -33,7 +36,9 @@ function formatDate(d: Date): string {
export function ReviewCard({ verification }: { verification: Verification }) { export function ReviewCard({ verification }: { verification: Verification }) {
const router = useRouter(); const router = useRouter();
const [showReject, setShowReject] = useState(false); const [showReject, setShowReject] = useState(false);
const [showReopen, setShowReopen] = useState(false);
const [rejectionReason, setRejectionReason] = useState(""); const [rejectionReason, setRejectionReason] = useState("");
const [reopenNote, setReopenNote] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -55,6 +60,20 @@ export function ReviewCard({ verification }: { verification: Verification }) {
router.refresh(); 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 ( return (
<article className="rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6"> <article className="rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
<header className="mb-4 flex flex-wrap items-start justify-between gap-3 border-b border-neutral-100 pb-4"> <header className="mb-4 flex flex-wrap items-start justify-between gap-3 border-b border-neutral-100 pb-4">
@@ -110,6 +129,62 @@ export function ReviewCard({ verification }: { verification: Verification }) {
</p> </p>
)} )}
{verification.status === "REJECTED" && (
<div className="mt-5 border-t border-neutral-100 pt-4">
{error && (
<div className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-xs text-red-600">
{error}
</div>
)}
{!showReopen ? (
<button
type="button"
onClick={() => setShowReopen(true)}
disabled={loading}
className="rounded-xl border border-amber-300 bg-white px-4 py-2 text-sm font-bold text-amber-700 hover:bg-amber-50 disabled:opacity-50"
>
🔄 Buka kembali ke PENDING
</button>
) : (
<div className="space-y-2 rounded-xl border border-amber-200 bg-amber-50/60 p-3">
<label className="block text-xs font-semibold text-amber-900">
Catatan reopen (min 10 karakter akan disimpan di rejection
reason sebagai history)
</label>
<textarea
value={reopenNote}
onChange={(e) => setReopenNote(e.target.value)}
rows={2}
maxLength={500}
placeholder="contoh: Organizer kirim ulang foto KTP jelas via email, siap di-review ulang."
className="w-full rounded-xl border border-amber-200 bg-white px-3 py-2 text-sm focus:bg-white focus:border-amber-400"
/>
<div className="flex gap-2">
<button
type="button"
onClick={reopen}
disabled={loading || reopenNote.trim().length < 10}
className="rounded-xl bg-amber-600 px-4 py-2 text-sm font-bold text-white hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? "Memproses..." : "Konfirmasi Reopen"}
</button>
<button
type="button"
onClick={() => {
setShowReopen(false);
setReopenNote("");
}}
disabled={loading}
className="rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-50 disabled:opacity-50"
>
Batal
</button>
</div>
</div>
)}
</div>
)}
{verification.status === "PENDING" && ( {verification.status === "PENDING" && (
<div className="mt-5 border-t border-neutral-100 pt-4"> <div className="mt-5 border-t border-neutral-100 pt-4">
{error && ( {error && (
+7
View File
@@ -11,12 +11,18 @@ import { tripService } from "@/server/services/trip.service";
import { organizerService } from "@/server/services/organizer.service"; import { organizerService } from "@/server/services/organizer.service";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { tripStoredInstantFromYmd } from "@/lib/trip-dates"; import { tripStoredInstantFromYmd } from "@/lib/trip-dates";
import { requireActiveUser } from "@/lib/auth-guards";
export async function createTripAction(formData: FormData) { export async function createTripAction(formData: FormData) {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user) { if (!session?.user) {
return { error: "Kamu harus login terlebih dahulu" }; return { error: "Kamu harus login terlebih dahulu" };
} }
try {
await requireActiveUser(session.user.id);
} catch (err) {
return { error: (err as Error).message };
}
const raw = { const raw = {
category: formData.get("category") as string, category: formData.get("category") as string,
@@ -120,6 +126,7 @@ export async function joinTripAction(tripId: string) {
} }
try { try {
await requireActiveUser(session.user.id);
await tripService.joinTrip(tripId, session.user.id); await tripService.joinTrip(tripId, session.user.id);
revalidatePath(`/trips/${tripId}`); revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips"); revalidatePath("/trips");
+5
View File
@@ -10,3 +10,8 @@ export function isAdminEmail(email: string | null | undefined): boolean {
if (!email) return false; if (!email) return false;
return adminEmails().includes(email.toLowerCase()); return adminEmails().includes(email.toLowerCase());
} }
/** Daftar email admin yang aman di-expose ke UI admin (dropdown filter). */
export function listAdminEmails(): string[] {
return adminEmails();
}
+18
View File
@@ -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<void> {
const suspended = await userRepo.isSuspended(userId);
if (suspended) {
throw new Error(
"Akun kamu sedang ditangguhkan. Hubungi support@setrip.id untuk klarifikasi."
);
}
}
+16
View File
@@ -63,6 +63,22 @@ export const authOptions: AuthOptions = {
strategy: "jwt", strategy: "jwt",
}, },
callbacks: { 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 }) { async jwt({ token, user, trigger }) {
if (user) { if (user) {
token.id = user.id; token.id = user.id;
+67
View File
@@ -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<T>(
jobName: string,
fn: () => Promise<T>
): 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 };
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "setrip", "name": "setrip",
"version": "0.12.1", "version": "0.13.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "setrip", "name": "setrip",
"version": "0.12.1", "version": "0.13.0",
"dependencies": { "dependencies": {
"@next-auth/prisma-adapter": "^1.0.7", "@next-auth/prisma-adapter": "^1.0.7",
"@prisma/adapter-pg": "^7.7.0", "@prisma/adapter-pg": "^7.7.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "setrip", "name": "setrip",
"version": "0.12.1", "version": "0.13.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -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");
@@ -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);
+32
View File
@@ -20,6 +20,13 @@ model User {
acceptedTermsAndPrivacy Boolean @default(false) acceptedTermsAndPrivacy Boolean @default(false)
/// Waktu user menyetujui Syarat & Ketentuan dan Kebijakan Privasi /// Waktu user menyetujui Syarat & Ketentuan dan Kebijakan Privasi
acceptedAt DateTime? 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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -438,6 +445,31 @@ model Refund {
@@index([status, createdAt]) @@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 { enum RefundReason {
/// Peserta cancel booking sendiri (mengikuti refund window policy). /// Peserta cancel booking sendiri (mengikuti refund window policy).
USER_CANCELLATION USER_CANCELLATION
+38 -2
View File
@@ -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({ return prisma.organizerVerification.findMany({
where: status ? { status } : undefined, where,
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
include: { include: {
user: { select: { id: true, name: true, email: true } }, 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( async updateReview(
id: string, id: string,
data: { data: {
+19 -2
View File
@@ -31,9 +31,26 @@ export const payoutRepo = {
return client.payout.findUnique({ where: { bookingId } }); 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({ return prisma.payout.findMany({
where: { status }, where,
orderBy: { heldUntil: "asc" }, orderBy: { heldUntil: "asc" },
include: payoutListInclude, include: payoutListInclude,
}); });
+27 -2
View File
@@ -40,10 +40,35 @@ export const refundRepo = {
}, },
async listByStatus( 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({ return prisma.refund.findMany({
where: status ? { status } : undefined, where,
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
include: refundListInclude, include: refundListInclude,
}); });
+5 -1
View File
@@ -45,7 +45,11 @@ export const tripRepo = {
async findOpen(filters?: TripFilters) { async findOpen(filters?: TripFilters) {
const todayStart = utcStartOfDay(new Date()); 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) { if (filters?.category) {
andParts.push({ category: filters.category }); andParts.push({ category: filters.category });
+129
View File
@@ -111,6 +111,135 @@ export const userRepo = {
return prisma.user.create({ data }); 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<boolean> {
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`, * Tandai user sudah accept T&C/Privacy. Idempotent: kalau sudah `true`,
* tidak overwrite `acceptedAt` (audit trail pertama tetap akurat). * tidak overwrite `acceptedAt` (audit trail pertama tetap akurat).
+32
View File
@@ -78,6 +78,38 @@ export const organizerService = {
return v?.status === "APPROVED"; 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). */ /** Reveal NIK plaintext. Caller must enforce authorization (owner or admin). */
decryptNik(nikEncrypted: string): string { decryptNik(nikEncrypted: string): string {
return decryptString(nikEncrypted); return decryptString(nikEncrypted);
+54
View File
@@ -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 });
},
};