admin roadmap filter & search, user management, reopen rejected, system health
This commit is contained in:
+13
-15
@@ -1,5 +1,7 @@
|
||||
# Setrip — Admin Audit & Investigation Roadmap
|
||||
|
||||
> **Status keseluruhan:** 🚧 Partial — Phase 1 delivered, Phase 2-4 pending.
|
||||
|
||||
Admin perlu **mencari** lintas entity (booking/payment/refund/user/trip) dan **export** untuk compliance + investigasi dispute.
|
||||
|
||||
> **Skenario nyata:** auditor bertanya "tunjukkan semua refund yang di-approve admin X di bulan Juni 2026 dengan total lebih dari Rp 5 juta". Saat ini admin harus query DB manual atau ambil screenshot satu-satu. Tidak ada cara cari berdasarkan kombinasi reviewer + tanggal + nominal.
|
||||
@@ -18,27 +20,23 @@ Admin perlu **mencari** lintas entity (booking/payment/refund/user/trip) dan **e
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Filter & Search Enhancements ⏳
|
||||
|
||||
Sebelum bikin audit log baru, perbaiki dulu kemampuan cari & filter di list yang sudah ada.
|
||||
|
||||
**Keputusan asumsi:**
|
||||
- Pakai `searchParams` di Next.js — tidak perlu state client (server-render fast + shareable URL).
|
||||
- Default date range: 30 hari terakhir, supaya page tidak load semua history.
|
||||
- Reviewer dropdown sumber dari `ADMIN_EMAILS` env (sudah ada).
|
||||
## Phase 1 — Filter & Search Enhancements ✅ DELIVERED
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| 1.1 | Filter date range (`from`, `to`) di `/admin/refunds` | ⏳ | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
|
||||
| 1.2 | Filter `reviewedBy` (admin email dropdown) di `/admin/refunds` | ⏳ | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
|
||||
| 1.3 | Filter `reason` di `/admin/refunds` (lihat juga [ADMIN_PAYMENT_OPS_ROADMAP.md](ADMIN_PAYMENT_OPS_ROADMAP.md)) | ⏳ | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
|
||||
| 1.4 | Filter date range + `processedBy` di `/admin/payouts` | ⏳ | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) |
|
||||
| 1.5 | Filter date range + `reviewedBy` di `/admin/verifications` | ⏳ | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) |
|
||||
| 1.6 | Tampilkan kolom "reviewer email" + "reviewed at" di tabel/list (semua admin pages) | ⏳ | semua `app/admin/*/page.tsx` |
|
||||
| 1.7 | Repo helper: tambah optional filter params di `refundRepo.listByStatus`, `payoutRepo.listByStatus`, `organizerRepo.listByStatus` | ⏳ | `server/repositories/*.ts` |
|
||||
| 1.1 | Filter date range (`dateFrom`, `dateTo`) di `/admin/refunds` | ✅ | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
|
||||
| 1.2 | Filter `reviewer` (admin email dropdown) di `/admin/refunds` | ✅ | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
|
||||
| 1.3 | Filter `reason` di `/admin/refunds` | ✅ | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
|
||||
| 1.4 | Filter date range + `processor` di `/admin/payouts` | ✅ | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) |
|
||||
| 1.5 | Filter date range + `reviewer` di `/admin/verifications` | ✅ | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) |
|
||||
| 1.6 | Komponen reusable `AdminFilterBar` (date range + reviewer dropdown + optional reason) | ✅ | [features/admin/components/admin-filter-bar.tsx](features/admin/components/admin-filter-bar.tsx) |
|
||||
| 1.7 | Repo helper: filter params di `refundRepo.listByStatus`, `payoutRepo.listByStatus`, `organizerRepo.listByStatus` | ✅ | `server/repositories/*.ts` |
|
||||
| 1.8 | Helper `listAdminEmails()` untuk dropdown reviewer | ✅ | [lib/admin.ts](lib/admin.ts) |
|
||||
|
||||
**Tindakan manual:** tidak ada.
|
||||
|
||||
> _Catatan: reviewer email column di list belum ditambah — info sudah ada di refund/payout/verification card detail (`Diproses oleh ...`)._ Bisa ditambah saat dibutuhkan untuk skim cepat._
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Global Search ⏳
|
||||
|
||||
+32
-21
@@ -12,10 +12,14 @@ Status implementasi kemampuan admin agar admin **dapat mengontrol seluruh aplika
|
||||
|---|---|---|
|
||||
| Dashboard | View count: verifikasi PENDING, refund per status, payout per status | [app/admin/page.tsx](app/admin/page.tsx) |
|
||||
| **Trips** | List + search + detail; force-cancel dengan auto-refund (admin intervention) | [app/admin/trips/](app/admin/trips/) |
|
||||
| **Users** | List + search + filter (active/suspended); detail dengan trip + booking history; suspend/unsuspend | [app/admin/users/](app/admin/users/) |
|
||||
| **Bookings detail** | Timeline lintas Payment + Refund + Payout, raw callback viewer, Midtrans reconcile | [app/admin/bookings/[id]/page.tsx](app/admin/bookings/[id]/page.tsx) |
|
||||
| Verifikasi KYC | Approve / Reject organizer (KTP, liveness, bank) | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) |
|
||||
| Refund | Create manual, approve, reject, mark SUCCEEDED, mark FAILED + link ke booking timeline | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
|
||||
| Payout | View per status, mark PAID setelah transfer manual + link ke booking timeline | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) |
|
||||
| Verifikasi KYC | Approve / Reject / **Reopen REJECTED**; filter date range + reviewer | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) |
|
||||
| Refund | Create manual, approve, reject, mark SUCCEEDED/FAILED; filter date/reviewer/reason; link ke booking timeline | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
|
||||
| Payout | View per status, mark PAID; filter date/processor; link ke booking timeline | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) |
|
||||
| **System Health** | Status cron jobs (last run, last success, 7d stats), 20 recent runs, health badge | [app/admin/system/page.tsx](app/admin/system/page.tsx) |
|
||||
|
||||
**Aksi mutating yang diblokir untuk suspended user:** sign-in (NextAuth), `createTripAction`, `joinTripAction`. Trip public list otomatis sembunyikan organizer suspended.
|
||||
|
||||
Auth admin: env `ADMIN_EMAILS` → cek di [lib/admin.ts](lib/admin.ts), dipassing ke session via [lib/auth.ts](lib/auth.ts).
|
||||
|
||||
@@ -27,38 +31,45 @@ Auth admin: env `ADMIN_EMAILS` → cek di [lib/admin.ts](lib/admin.ts), dipassin
|
||||
|---|---|---|---|
|
||||
| Trip Operations (search, view, cancel manual) | 🔴 HIGH | ✅ **Delivered** | [docs/archive/ADMIN_TRIP_OPS_ROADMAP.md](docs/archive/ADMIN_TRIP_OPS_ROADMAP.md) |
|
||||
| Payment Operations (booking detail, reconcile, dispute) | 🔴 HIGH | ✅ **Delivered** | [docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md](docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md) |
|
||||
| Audit & Investigation (search, filter, export) | 🔴 HIGH | ⏳ 0% | [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) |
|
||||
| User Management (search, suspend/ban) | 🟡 MEDIUM | ⏳ 0% | [ADMIN_USER_MGMT_ROADMAP.md](ADMIN_USER_MGMT_ROADMAP.md) |
|
||||
| Verification (reopen, re-upload request) | 🟡 MEDIUM | ⏳ 0% | [ADMIN_VERIFICATION_ROADMAP.md](ADMIN_VERIFICATION_ROADMAP.md) |
|
||||
| System Health (cron monitor, stale state alerts) | 🟡 MEDIUM | ⏳ 0% | [ADMIN_SYSTEM_HEALTH_ROADMAP.md](ADMIN_SYSTEM_HEALTH_ROADMAP.md) |
|
||||
| Audit & Investigation (search, filter, export) | 🔴 HIGH | 🚧 Phase 1 done · Phase 2-4 pending | [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) |
|
||||
| User Management (search, suspend/ban) | 🟡 MEDIUM | ✅ **Delivered** | [docs/archive/ADMIN_USER_MGMT_ROADMAP.md](docs/archive/ADMIN_USER_MGMT_ROADMAP.md) |
|
||||
| Verification (reopen, re-upload request) | 🟡 MEDIUM | 🚧 Phase 1 done · Phase 2-4 deferred | [docs/archive/ADMIN_VERIFICATION_ROADMAP.md](docs/archive/ADMIN_VERIFICATION_ROADMAP.md) |
|
||||
| System Health (cron monitor, stale state alerts) | 🟡 MEDIUM | 🚧 Phase 1-2 done · Phase 3-4 deferred | [docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md](docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md) |
|
||||
|
||||
**Legend status:** ⏳ belum mulai · 🚧 partial · ✅ selesai (lihat archive untuk detail delivery)
|
||||
**Legend status:** ⏳ belum mulai · 🚧 partial · ✅ selesai
|
||||
|
||||
---
|
||||
|
||||
## Iterasi berikutnya (sisa HIGH + MEDIUM)
|
||||
## Sisa pekerjaan
|
||||
|
||||
Setelah Trip Ops + Payment Ops, urutan berikutnya:
|
||||
Hampir semua kapabilitas dasar admin sudah delivered. Yang tersisa hanya enhancement non-blocking:
|
||||
|
||||
1. **Audit & Investigation** (HIGH) — filter date range, search global, CSV export. Penting untuk compliance & investigasi dispute.
|
||||
2. **User Management** (MEDIUM) — search + suspend/ban. Butuh schema change (`User.suspended`).
|
||||
3. **System Health** (MEDIUM) — cron monitor + stale state alerts. Butuh model baru (`CronRun`).
|
||||
4. **Verification** (MEDIUM) — reopen REJECTED + re-upload request. Edge case rare tapi kecil scope.
|
||||
**Audit Phase 2-4** (lihat [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md)):
|
||||
- Phase 2 — Global Search (admin search bar resolve email/order_id/cuid)
|
||||
- Phase 3 — CSV Export untuk refunds/payouts/verifications
|
||||
- Phase 4 — Generic `AdminActionLog` model untuk audit action lintas entity
|
||||
|
||||
**Lainnya yang di-defer** (di archive masing-masing):
|
||||
- Verification: re-upload request flow, verification history, manual override
|
||||
- System Health: stale state alerts (Payment AWAITING > 25h, Payout HELD overdue), external alerting (Discord webhook)
|
||||
- User Mgmt: bulk analytics dashboard
|
||||
|
||||
---
|
||||
|
||||
## Tindakan manual setelah deploy
|
||||
|
||||
Untuk versi yang berisi delivery Trip Ops + Payment Ops:
|
||||
## Tindakan manual setelah deploy versi terakhir
|
||||
|
||||
```bash
|
||||
# Apply migration baru (add_trip_admin_cancel)
|
||||
# Apply 3 migration baru: add_trip_admin_cancel, add_user_suspension, add_cron_run
|
||||
npx prisma migrate deploy
|
||||
|
||||
# Restart Next.js / PM2 supaya Prisma client baru ter-load
|
||||
pm2 restart setrip --update-env
|
||||
```
|
||||
|
||||
Brief admin tentang dua kapabilitas baru:
|
||||
- **Force-cancel trip** di `/admin/trips/[id]` — pakai saat organizer unreachable / dispute, reason wajib min 10 karakter.
|
||||
- **Reconcile Midtrans** di `/admin/bookings/[id]` — pakai saat peserta lapor "sudah bayar tapi status belum update". Idempotent, aman diulang.
|
||||
Brief admin tentang kapabilitas baru:
|
||||
- **Force-cancel trip** di `/admin/trips/[id]` — pakai saat organizer unreachable / dispute, reason wajib min 10 char.
|
||||
- **Reconcile Midtrans** di `/admin/bookings/[id]` — pakai saat peserta lapor "sudah bayar tapi status belum update". Idempotent.
|
||||
- **Suspend user** di `/admin/users/[id]` — pakai untuk scam/harassment. Suspended user diblokir sign-in dan aksi mutatif.
|
||||
- **Reopen verification** di `/admin/verifications` (tab REJECTED) — saat organizer kirim ulang foto via email/WA.
|
||||
- **System status** di `/admin/system` — cek setiap pagi, pastikan cron jalan (🟢 OK).
|
||||
- **Filter date range + reviewer** di refunds/payouts/verifications — untuk investigasi & compliance.
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -1,8 +1,9 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { isAdminEmail } from "@/lib/admin";
|
||||
import { isAdminEmail, listAdminEmails } from "@/lib/admin";
|
||||
import { payoutRepo } from "@/server/repositories/payout.repo";
|
||||
import { AdminFilterBar } from "@/features/admin/components/admin-filter-bar";
|
||||
import {
|
||||
PayoutReviewCard,
|
||||
type PayoutCardData,
|
||||
@@ -18,7 +19,18 @@ const TABS: { key: Tab; label: string }[] = [
|
||||
];
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ tab?: string }>;
|
||||
searchParams: Promise<{
|
||||
tab?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
reviewer?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function parseDate(value: string | undefined): Date | undefined {
|
||||
if (!value) return undefined;
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
export default async function AdminPayoutsPage({ searchParams }: PageProps) {
|
||||
@@ -39,7 +51,11 @@ export default async function AdminPayoutsPage({ searchParams }: PageProps) {
|
||||
? (params.tab as Tab)
|
||||
: "RELEASED";
|
||||
|
||||
const rows = await payoutRepo.listByStatus(tab);
|
||||
const rows = await payoutRepo.listByStatus(tab, {
|
||||
dateFrom: parseDate(params.dateFrom),
|
||||
dateTo: parseDate(params.dateTo),
|
||||
processorEmail: params.reviewer || undefined,
|
||||
});
|
||||
const items: PayoutCardData[] = rows.map((p) => ({
|
||||
id: p.id,
|
||||
amount: p.amount,
|
||||
@@ -78,6 +94,18 @@ export default async function AdminPayoutsPage({ searchParams }: PageProps) {
|
||||
</p>
|
||||
</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">
|
||||
{TABS.map((t) => (
|
||||
<a
|
||||
@@ -96,7 +124,9 @@ export default async function AdminPayoutsPage({ searchParams }: PageProps) {
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center">
|
||||
<p className="text-sm text-neutral-500">Tidak ada payout pada status ini.</p>
|
||||
<p className="text-sm text-neutral-500">
|
||||
Tidak ada payout yang cocok dengan filter ini.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { isAdminEmail } from "@/lib/admin";
|
||||
import { isAdminEmail, listAdminEmails } from "@/lib/admin";
|
||||
import { refundRepo } from "@/server/repositories/refund.repo";
|
||||
import { CreateRefundForm } from "@/features/refund/components/create-refund-form";
|
||||
import { AdminFilterBar } from "@/features/admin/components/admin-filter-bar";
|
||||
import {
|
||||
RefundReviewCard,
|
||||
type RefundCardData,
|
||||
@@ -19,8 +20,30 @@ const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: "FAILED", label: "Gagal" },
|
||||
];
|
||||
|
||||
const REASON_OPTIONS = [
|
||||
{ value: "USER_CANCELLATION", label: "User cancel" },
|
||||
{ value: "ORGANIZER_CANCELLED", label: "Organizer cancel" },
|
||||
{ value: "TRIP_ISSUE", label: "Trip issue" },
|
||||
{ value: "ADMIN_ADJUSTMENT", label: "Admin adjustment" },
|
||||
{ value: "DISPUTE_RESOLVED", label: "Dispute resolved" },
|
||||
] as const;
|
||||
|
||||
type ReasonValue = (typeof REASON_OPTIONS)[number]["value"];
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ tab?: string }>;
|
||||
searchParams: Promise<{
|
||||
tab?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
reviewer?: string;
|
||||
reason?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function parseDate(value: string | undefined): Date | undefined {
|
||||
if (!value) return undefined;
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
export default async function AdminRefundsPage({ searchParams }: PageProps) {
|
||||
@@ -40,8 +63,19 @@ export default async function AdminRefundsPage({ searchParams }: PageProps) {
|
||||
const tab: Tab = TABS.some((t) => t.key === params.tab)
|
||||
? (params.tab as Tab)
|
||||
: "PENDING";
|
||||
const reason: ReasonValue | undefined = REASON_OPTIONS.some(
|
||||
(r) => r.value === params.reason
|
||||
)
|
||||
? (params.reason as ReasonValue)
|
||||
: undefined;
|
||||
|
||||
const rows = await refundRepo.listByStatus(tab, {
|
||||
dateFrom: parseDate(params.dateFrom),
|
||||
dateTo: parseDate(params.dateTo),
|
||||
reviewerEmail: params.reviewer || undefined,
|
||||
reason,
|
||||
});
|
||||
|
||||
const rows = await refundRepo.listByStatus(tab);
|
||||
const items: RefundCardData[] = rows.map((r) => ({
|
||||
id: r.id,
|
||||
amount: r.amount,
|
||||
@@ -92,6 +126,20 @@ export default async function AdminRefundsPage({ searchParams }: PageProps) {
|
||||
|
||||
<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">
|
||||
{TABS.map((t) => (
|
||||
<a
|
||||
@@ -111,7 +159,7 @@ export default async function AdminRefundsPage({ searchParams }: PageProps) {
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center">
|
||||
<p className="text-sm text-neutral-500">
|
||||
Tidak ada refund pada status ini.
|
||||
Tidak ada refund yang cocok dengan filter ini.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,27 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { isAdminEmail } from "@/lib/admin";
|
||||
import { isAdminEmail, listAdminEmails } from "@/lib/admin";
|
||||
import { organizerRepo } from "@/server/repositories/organizer.repo";
|
||||
import { organizerService } from "@/server/services/organizer.service";
|
||||
import { AdminFilterBar } from "@/features/admin/components/admin-filter-bar";
|
||||
import { ReviewCard } from "@/features/organizer/components/review-card";
|
||||
|
||||
type Tab = "PENDING" | "APPROVED" | "REJECTED";
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ tab?: string }>;
|
||||
searchParams: Promise<{
|
||||
tab?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
reviewer?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function parseDate(value: string | undefined): Date | undefined {
|
||||
if (!value) return undefined;
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
export default async function AdminVerificationsPage({ searchParams }: PageProps) {
|
||||
@@ -29,7 +41,11 @@ export default async function AdminVerificationsPage({ searchParams }: PageProps
|
||||
const tab: Tab =
|
||||
params.tab === "APPROVED" || params.tab === "REJECTED" ? params.tab : "PENDING";
|
||||
|
||||
const rows = await organizerRepo.listByStatus(tab);
|
||||
const rows = await organizerRepo.listByStatus(tab, {
|
||||
dateFrom: parseDate(params.dateFrom),
|
||||
dateTo: parseDate(params.dateTo),
|
||||
reviewerEmail: params.reviewer || undefined,
|
||||
});
|
||||
const items = rows.map((v) => ({
|
||||
id: v.id,
|
||||
fullName: v.fullName,
|
||||
@@ -65,6 +81,18 @@ export default async function AdminVerificationsPage({ searchParams }: PageProps
|
||||
</p>
|
||||
</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">
|
||||
{tabs.map((t) => (
|
||||
<a
|
||||
@@ -83,7 +111,9 @@ export default async function AdminVerificationsPage({ searchParams }: PageProps
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center">
|
||||
<p className="text-sm text-neutral-500">Tidak ada data.</p>
|
||||
<p className="text-sm text-neutral-500">
|
||||
Tidak ada data yang cocok dengan filter ini.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { tripService } from "@/server/services/trip.service";
|
||||
import { payoutService } from "@/server/services/payout.service";
|
||||
import { runCron } from "@/lib/cron-runner";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -30,27 +31,25 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const outcome = await runCron("auto-complete-trips", async () => {
|
||||
const result = await tripService.autoCompletePastTrips();
|
||||
// Setelah trip COMPLETED, payout yang sudah lewat heldUntil di-release
|
||||
// supaya admin bisa langsung transfer ke organizer. Idempotent.
|
||||
const releaseResult = await payoutService.releaseEligible();
|
||||
console.log("[cron/auto-complete-trips] selesai", {
|
||||
completed: result.count,
|
||||
ids: result.ids,
|
||||
payoutsReleased: releaseResult.releasedIds.length,
|
||||
});
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
return {
|
||||
completed: result.count,
|
||||
ids: result.ids,
|
||||
payoutsReleased: releaseResult.releasedIds,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[cron/auto-complete-trips] gagal", err);
|
||||
|
||||
if (!outcome.ok) {
|
||||
console.error("[cron/auto-complete-trips] gagal", outcome.error);
|
||||
return NextResponse.json(
|
||||
{ error: "Gagal menjalankan auto-complete" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
console.log("[cron/auto-complete-trips] selesai", outcome.payload);
|
||||
return NextResponse.json({ ok: true, ...outcome.payload });
|
||||
}
|
||||
|
||||
@@ -9,9 +9,11 @@ import { signOut } from "next-auth/react";
|
||||
const NAV_ITEMS: { href: string; label: string; icon: string }[] = [
|
||||
{ href: "/admin", label: "Dashboard", icon: "📊" },
|
||||
{ href: "/admin/trips", label: "Trips", icon: "🧭" },
|
||||
{ href: "/admin/users", label: "Users", icon: "👥" },
|
||||
{ href: "/admin/verifications", label: "Verifikasi", icon: "🪪" },
|
||||
{ href: "/admin/refunds", label: "Refund", icon: "↩️" },
|
||||
{ href: "/admin/payouts", label: "Payout", icon: "💸" },
|
||||
{ href: "/admin/system", label: "System", icon: "⚙️" },
|
||||
];
|
||||
|
||||
interface AdminSidebarProps {
|
||||
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -76,3 +76,31 @@ export async function reviewVerificationAction(formData: FormData) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin reopen pengajuan REJECTED ke PENDING — supaya organizer bisa
|
||||
* di-review ulang tanpa drop & recreate row. Note wajib min 10 char untuk audit.
|
||||
*/
|
||||
export async function reopenVerificationAction(
|
||||
verificationId: string,
|
||||
note: string
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user || !isAdminEmail(session.user.email)) {
|
||||
return { error: "Tidak memiliki akses admin" };
|
||||
}
|
||||
|
||||
try {
|
||||
await organizerService.reopenVerification({
|
||||
verificationId,
|
||||
adminId: session.user.id,
|
||||
note,
|
||||
});
|
||||
revalidatePath("/admin/verifications");
|
||||
revalidatePath("/verify");
|
||||
revalidatePath("/profile");
|
||||
return { success: true as const };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { reviewVerificationAction } from "@/features/organizer/actions";
|
||||
import {
|
||||
reopenVerificationAction,
|
||||
reviewVerificationAction,
|
||||
} from "@/features/organizer/actions";
|
||||
|
||||
type Verification = {
|
||||
id: string;
|
||||
@@ -33,7 +36,9 @@ function formatDate(d: Date): string {
|
||||
export function ReviewCard({ verification }: { verification: Verification }) {
|
||||
const router = useRouter();
|
||||
const [showReject, setShowReject] = useState(false);
|
||||
const [showReopen, setShowReopen] = useState(false);
|
||||
const [rejectionReason, setRejectionReason] = useState("");
|
||||
const [reopenNote, setReopenNote] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -55,6 +60,20 @@ export function ReviewCard({ verification }: { verification: Verification }) {
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function reopen() {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
const result = await reopenVerificationAction(verification.id, reopenNote);
|
||||
setLoading(false);
|
||||
if ("error" in result && result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
setShowReopen(false);
|
||||
setReopenNote("");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
@@ -110,6 +129,62 @@ export function ReviewCard({ verification }: { verification: Verification }) {
|
||||
</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" && (
|
||||
<div className="mt-5 border-t border-neutral-100 pt-4">
|
||||
{error && (
|
||||
|
||||
@@ -11,12 +11,18 @@ import { tripService } from "@/server/services/trip.service";
|
||||
import { organizerService } from "@/server/services/organizer.service";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { tripStoredInstantFromYmd } from "@/lib/trip-dates";
|
||||
import { requireActiveUser } from "@/lib/auth-guards";
|
||||
|
||||
export async function createTripAction(formData: FormData) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
return { error: "Kamu harus login terlebih dahulu" };
|
||||
}
|
||||
try {
|
||||
await requireActiveUser(session.user.id);
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
|
||||
const raw = {
|
||||
category: formData.get("category") as string,
|
||||
@@ -120,6 +126,7 @@ export async function joinTripAction(tripId: string) {
|
||||
}
|
||||
|
||||
try {
|
||||
await requireActiveUser(session.user.id);
|
||||
await tripService.joinTrip(tripId, session.user.id);
|
||||
revalidatePath(`/trips/${tripId}`);
|
||||
revalidatePath("/trips");
|
||||
|
||||
@@ -10,3 +10,8 @@ export function isAdminEmail(email: string | null | undefined): boolean {
|
||||
if (!email) return false;
|
||||
return adminEmails().includes(email.toLowerCase());
|
||||
}
|
||||
|
||||
/** Daftar email admin yang aman di-expose ke UI admin (dropdown filter). */
|
||||
export function listAdminEmails(): string[] {
|
||||
return adminEmails();
|
||||
}
|
||||
|
||||
@@ -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
@@ -63,6 +63,22 @@ export const authOptions: AuthOptions = {
|
||||
strategy: "jwt",
|
||||
},
|
||||
callbacks: {
|
||||
async signIn({ user }) {
|
||||
// Block suspended user dari sign-in (Credentials + OAuth).
|
||||
// Email-based lookup karena `user.id` belum tentu ada untuk first-time
|
||||
// OAuth sign-in sebelum adapter persist.
|
||||
const email = user.email;
|
||||
if (!email) return true;
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
select: { suspended: true },
|
||||
});
|
||||
if (existing?.suspended) {
|
||||
// NextAuth menerjemahkan return false jadi error "AccessDenied".
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
async jwt({ token, user, trigger }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -20,6 +20,13 @@ model User {
|
||||
acceptedTermsAndPrivacy Boolean @default(false)
|
||||
/// Waktu user menyetujui Syarat & Ketentuan dan Kebijakan Privasi
|
||||
acceptedAt DateTime?
|
||||
/// Suspended user diblokir sign-in dan aksi mutatif. Set oleh admin via panel.
|
||||
suspended Boolean @default(false)
|
||||
suspendedAt DateTime?
|
||||
suspendedReason String?
|
||||
suspendedById String?
|
||||
suspendedBy User? @relation("UserSuspendedBy", fields: [suspendedById], references: [id], onDelete: SetNull)
|
||||
suspendedUsers User[] @relation("UserSuspendedBy")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -438,6 +445,31 @@ model Refund {
|
||||
@@index([status, createdAt])
|
||||
}
|
||||
|
||||
/// Log per cron run untuk observability admin. Append-only.
|
||||
/// `runCron(jobName, fn)` di `lib/cron-runner.ts` otomatis create row RUNNING
|
||||
/// → update SUCCESS/FAILED setelah selesai. Dipakai admin di `/admin/system`.
|
||||
model CronRun {
|
||||
id String @id @default(cuid())
|
||||
jobName String
|
||||
startedAt DateTime @default(now())
|
||||
finishedAt DateTime?
|
||||
status CronRunStatus @default(RUNNING)
|
||||
errorMessage String?
|
||||
/// Snapshot ringkas hasil run (mis. `{ completed: 5, ids: [...] }`).
|
||||
payload Json?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([jobName, startedAt(sort: Desc)])
|
||||
@@index([startedAt(sort: Desc)])
|
||||
}
|
||||
|
||||
enum CronRunStatus {
|
||||
RUNNING
|
||||
SUCCESS
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum RefundReason {
|
||||
/// Peserta cancel booking sendiri (mengikuti refund window policy).
|
||||
USER_CANCELLATION
|
||||
|
||||
@@ -25,9 +25,27 @@ export const organizerRepo = {
|
||||
});
|
||||
},
|
||||
|
||||
async listByStatus(status?: "PENDING" | "APPROVED" | "REJECTED") {
|
||||
async listByStatus(
|
||||
status?: "PENDING" | "APPROVED" | "REJECTED",
|
||||
filters?: {
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
reviewerEmail?: string;
|
||||
}
|
||||
) {
|
||||
const where: Prisma.OrganizerVerificationWhereInput = {};
|
||||
if (status) where.status = status;
|
||||
if (filters?.dateFrom || filters?.dateTo) {
|
||||
where.createdAt = {
|
||||
...(filters.dateFrom && { gte: filters.dateFrom }),
|
||||
...(filters.dateTo && { lte: filters.dateTo }),
|
||||
};
|
||||
}
|
||||
if (filters?.reviewerEmail) {
|
||||
where.reviewedBy = { email: filters.reviewerEmail };
|
||||
}
|
||||
return prisma.organizerVerification.findMany({
|
||||
where: status ? { status } : undefined,
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
@@ -55,6 +73,24 @@ export const organizerRepo = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Reopen pengajuan REJECTED ke PENDING. Simpan rejection reason lama
|
||||
* sebagai catatan history (di-overwrite kalau di-reject lagi nanti).
|
||||
*/
|
||||
async reopen(id: string, reopenNote: string) {
|
||||
return prisma.organizerVerification.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: "PENDING",
|
||||
reviewedById: null,
|
||||
reviewedAt: null,
|
||||
verifiedAt: null,
|
||||
// Pertahankan rejectionReason lama di field, append note reopen.
|
||||
rejectionReason: `[Dibuka kembali admin: ${reopenNote}]`,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async updateReview(
|
||||
id: string,
|
||||
data: {
|
||||
|
||||
@@ -31,9 +31,26 @@ export const payoutRepo = {
|
||||
return client.payout.findUnique({ where: { bookingId } });
|
||||
},
|
||||
|
||||
async listByStatus(status: PayoutStatus) {
|
||||
async listByStatus(
|
||||
status: PayoutStatus,
|
||||
filters?: {
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
processorEmail?: string;
|
||||
}
|
||||
) {
|
||||
const where: Prisma.PayoutWhereInput = { status };
|
||||
if (filters?.dateFrom || filters?.dateTo) {
|
||||
where.createdAt = {
|
||||
...(filters.dateFrom && { gte: filters.dateFrom }),
|
||||
...(filters.dateTo && { lte: filters.dateTo }),
|
||||
};
|
||||
}
|
||||
if (filters?.processorEmail) {
|
||||
where.processedBy = { email: filters.processorEmail };
|
||||
}
|
||||
return prisma.payout.findMany({
|
||||
where: { status },
|
||||
where,
|
||||
orderBy: { heldUntil: "asc" },
|
||||
include: payoutListInclude,
|
||||
});
|
||||
|
||||
@@ -40,10 +40,35 @@ export const refundRepo = {
|
||||
},
|
||||
|
||||
async listByStatus(
|
||||
status?: "PENDING" | "APPROVED" | "REJECTED" | "PROCESSING" | "SUCCEEDED" | "FAILED"
|
||||
status?: "PENDING" | "APPROVED" | "REJECTED" | "PROCESSING" | "SUCCEEDED" | "FAILED",
|
||||
filters?: {
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
reviewerEmail?: string;
|
||||
reason?:
|
||||
| "USER_CANCELLATION"
|
||||
| "ORGANIZER_CANCELLED"
|
||||
| "TRIP_ISSUE"
|
||||
| "ADMIN_ADJUSTMENT"
|
||||
| "DISPUTE_RESOLVED";
|
||||
}
|
||||
) {
|
||||
const where: Prisma.RefundWhereInput = {};
|
||||
if (status) where.status = status;
|
||||
if (filters?.dateFrom || filters?.dateTo) {
|
||||
where.createdAt = {
|
||||
...(filters.dateFrom && { gte: filters.dateFrom }),
|
||||
...(filters.dateTo && { lte: filters.dateTo }),
|
||||
};
|
||||
}
|
||||
if (filters?.reviewerEmail) {
|
||||
where.reviewedBy = { email: filters.reviewerEmail };
|
||||
}
|
||||
if (filters?.reason) {
|
||||
where.reason = filters.reason;
|
||||
}
|
||||
return prisma.refund.findMany({
|
||||
where: status ? { status } : undefined,
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: refundListInclude,
|
||||
});
|
||||
|
||||
@@ -45,7 +45,11 @@ export const tripRepo = {
|
||||
async findOpen(filters?: TripFilters) {
|
||||
const todayStart = utcStartOfDay(new Date());
|
||||
|
||||
const andParts: Prisma.TripWhereInput[] = [{ status: "OPEN" }];
|
||||
const andParts: Prisma.TripWhereInput[] = [
|
||||
{ status: "OPEN" },
|
||||
// Sembunyikan trip dari organizer yang suspended — moderasi via panel admin.
|
||||
{ organizer: { suspended: false } },
|
||||
];
|
||||
|
||||
if (filters?.category) {
|
||||
andParts.push({ category: filters.category });
|
||||
|
||||
@@ -111,6 +111,135 @@ export const userRepo = {
|
||||
return prisma.user.create({ data });
|
||||
},
|
||||
|
||||
/**
|
||||
* Admin search: by email/name (case-insensitive contains) + filter
|
||||
* suspended status. Limit 100 supaya tidak load semua user di list page.
|
||||
*/
|
||||
async searchForAdmin(filters: { q?: string; suspended?: boolean }) {
|
||||
const where: Prisma.UserWhereInput = {};
|
||||
if (filters.q) {
|
||||
where.OR = [
|
||||
{ email: { contains: filters.q, mode: "insensitive" } },
|
||||
{ name: { contains: filters.q, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
if (typeof filters.suspended === "boolean") {
|
||||
where.suspended = filters.suspended;
|
||||
}
|
||||
return prisma.user.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
createdAt: true,
|
||||
suspended: true,
|
||||
suspendedAt: true,
|
||||
organizerVerification: { select: { status: true } },
|
||||
_count: {
|
||||
select: {
|
||||
trips: true,
|
||||
participations: { where: { status: { not: "CANCELLED" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Detail user untuk admin: full profile + booking history + organized
|
||||
* trips + verification. Tidak ekspos password atau OAuth token.
|
||||
*/
|
||||
async findByIdForAdmin(id: string) {
|
||||
return prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
createdAt: true,
|
||||
acceptedTermsAndPrivacy: true,
|
||||
acceptedAt: true,
|
||||
suspended: true,
|
||||
suspendedAt: true,
|
||||
suspendedReason: true,
|
||||
suspendedBy: { select: { id: true, name: true, email: true } },
|
||||
profile: true,
|
||||
organizerVerification: {
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
reviewedAt: true,
|
||||
rejectionReason: true,
|
||||
},
|
||||
},
|
||||
trips: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
destination: true,
|
||||
date: true,
|
||||
status: true,
|
||||
price: true,
|
||||
_count: {
|
||||
select: {
|
||||
participants: { where: { status: { not: "CANCELLED" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { date: "desc" },
|
||||
take: 50,
|
||||
},
|
||||
bookings: {
|
||||
select: {
|
||||
id: true,
|
||||
amount: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
trip: {
|
||||
select: { id: true, title: true, date: true, organizerId: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/** Cek cepat status suspended — dipakai auth guard di server actions. */
|
||||
async isSuspended(id: string): Promise<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`,
|
||||
* tidak overwrite `acceptedAt` (audit trail pertama tetap akurat).
|
||||
|
||||
@@ -78,6 +78,38 @@ export const organizerService = {
|
||||
return v?.status === "APPROVED";
|
||||
},
|
||||
|
||||
/**
|
||||
* Buka kembali verifikasi yang REJECTED ke PENDING. Dipakai admin saat
|
||||
* organizer kirim foto/data baru via email/WA dan ingin di-review ulang
|
||||
* tanpa drop & recreate row.
|
||||
*
|
||||
* - `rejectionReason` lama disimpan di field — kalau di-reject lagi nanti,
|
||||
* field-nya di-overwrite (audit trail via auditLog Phase 4 di roadmap).
|
||||
* - Reset `reviewedById`, `reviewedAt` supaya muncul lagi di tab PENDING.
|
||||
*/
|
||||
async reopenVerification(input: {
|
||||
verificationId: string;
|
||||
adminId: string;
|
||||
note: string;
|
||||
}) {
|
||||
const verification = await organizerRepo.findById(input.verificationId);
|
||||
if (!verification) {
|
||||
throw new Error("Pengajuan tidak ditemukan");
|
||||
}
|
||||
if (verification.status !== "REJECTED") {
|
||||
throw new Error("Hanya pengajuan REJECTED yang bisa dibuka kembali");
|
||||
}
|
||||
const trimmedNote = input.note.trim();
|
||||
if (trimmedNote.length < 10) {
|
||||
throw new Error("Catatan reopen wajib min 10 karakter untuk audit");
|
||||
}
|
||||
if (trimmedNote.length > 500) {
|
||||
throw new Error("Catatan reopen maksimal 500 karakter");
|
||||
}
|
||||
|
||||
return organizerRepo.reopen(input.verificationId, trimmedNote);
|
||||
},
|
||||
|
||||
/** Reveal NIK plaintext. Caller must enforce authorization (owner or admin). */
|
||||
decryptNik(nikEncrypted: string): string {
|
||||
return decryptString(nikEncrypted);
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user