diff --git a/ADMIN_AUDIT_ROADMAP.md b/ADMIN_AUDIT_ROADMAP.md index a32251f..4baaf40 100644 --- a/ADMIN_AUDIT_ROADMAP.md +++ b/ADMIN_AUDIT_ROADMAP.md @@ -1,5 +1,7 @@ # Setrip โ€” Admin Audit & Investigation Roadmap +> **Status keseluruhan:** ๐Ÿšง Partial โ€” Phase 1 delivered, Phase 2-4 pending. + Admin perlu **mencari** lintas entity (booking/payment/refund/user/trip) dan **export** untuk compliance + investigasi dispute. > **Skenario nyata:** auditor bertanya "tunjukkan semua refund yang di-approve admin X di bulan Juni 2026 dengan total lebih dari Rp 5 juta". Saat ini admin harus query DB manual atau ambil screenshot satu-satu. Tidak ada cara cari berdasarkan kombinasi reviewer + tanggal + nominal. @@ -18,27 +20,23 @@ Admin perlu **mencari** lintas entity (booking/payment/refund/user/trip) dan **e --- -## Phase 1 โ€” Filter & Search Enhancements โณ - -Sebelum bikin audit log baru, perbaiki dulu kemampuan cari & filter di list yang sudah ada. - -**Keputusan asumsi:** -- Pakai `searchParams` di Next.js โ€” tidak perlu state client (server-render fast + shareable URL). -- Default date range: 30 hari terakhir, supaya page tidak load semua history. -- Reviewer dropdown sumber dari `ADMIN_EMAILS` env (sudah ada). +## Phase 1 โ€” Filter & Search Enhancements โœ… DELIVERED | # | Item | Status | File | |---|---|---|---| -| 1.1 | Filter date range (`from`, `to`) di `/admin/refunds` | โณ | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) | -| 1.2 | Filter `reviewedBy` (admin email dropdown) di `/admin/refunds` | โณ | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) | -| 1.3 | Filter `reason` di `/admin/refunds` (lihat juga [ADMIN_PAYMENT_OPS_ROADMAP.md](ADMIN_PAYMENT_OPS_ROADMAP.md)) | โณ | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) | -| 1.4 | Filter date range + `processedBy` di `/admin/payouts` | โณ | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) | -| 1.5 | Filter date range + `reviewedBy` di `/admin/verifications` | โณ | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) | -| 1.6 | Tampilkan kolom "reviewer email" + "reviewed at" di tabel/list (semua admin pages) | โณ | semua `app/admin/*/page.tsx` | -| 1.7 | Repo helper: tambah optional filter params di `refundRepo.listByStatus`, `payoutRepo.listByStatus`, `organizerRepo.listByStatus` | โณ | `server/repositories/*.ts` | +| 1.1 | Filter date range (`dateFrom`, `dateTo`) di `/admin/refunds` | โœ… | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) | +| 1.2 | Filter `reviewer` (admin email dropdown) di `/admin/refunds` | โœ… | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) | +| 1.3 | Filter `reason` di `/admin/refunds` | โœ… | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) | +| 1.4 | Filter date range + `processor` di `/admin/payouts` | โœ… | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) | +| 1.5 | Filter date range + `reviewer` di `/admin/verifications` | โœ… | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) | +| 1.6 | Komponen reusable `AdminFilterBar` (date range + reviewer dropdown + optional reason) | โœ… | [features/admin/components/admin-filter-bar.tsx](features/admin/components/admin-filter-bar.tsx) | +| 1.7 | Repo helper: filter params di `refundRepo.listByStatus`, `payoutRepo.listByStatus`, `organizerRepo.listByStatus` | โœ… | `server/repositories/*.ts` | +| 1.8 | Helper `listAdminEmails()` untuk dropdown reviewer | โœ… | [lib/admin.ts](lib/admin.ts) | **Tindakan manual:** tidak ada. +> _Catatan: reviewer email column di list belum ditambah โ€” info sudah ada di refund/payout/verification card detail (`Diproses oleh ...`)._ Bisa ditambah saat dibutuhkan untuk skim cepat._ + --- ## Phase 2 โ€” Global Search โณ diff --git a/ADMIN_ROADMAP.md b/ADMIN_ROADMAP.md index ec260ba..13ee075 100644 --- a/ADMIN_ROADMAP.md +++ b/ADMIN_ROADMAP.md @@ -12,10 +12,14 @@ Status implementasi kemampuan admin agar admin **dapat mengontrol seluruh aplika |---|---|---| | Dashboard | View count: verifikasi PENDING, refund per status, payout per status | [app/admin/page.tsx](app/admin/page.tsx) | | **Trips** | List + search + detail; force-cancel dengan auto-refund (admin intervention) | [app/admin/trips/](app/admin/trips/) | +| **Users** | List + search + filter (active/suspended); detail dengan trip + booking history; suspend/unsuspend | [app/admin/users/](app/admin/users/) | | **Bookings detail** | Timeline lintas Payment + Refund + Payout, raw callback viewer, Midtrans reconcile | [app/admin/bookings/[id]/page.tsx](app/admin/bookings/[id]/page.tsx) | -| Verifikasi KYC | Approve / Reject organizer (KTP, liveness, bank) | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) | -| Refund | Create manual, approve, reject, mark SUCCEEDED, mark FAILED + link ke booking timeline | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) | -| Payout | View per status, mark PAID setelah transfer manual + link ke booking timeline | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) | +| Verifikasi KYC | Approve / Reject / **Reopen REJECTED**; filter date range + reviewer | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) | +| Refund | Create manual, approve, reject, mark SUCCEEDED/FAILED; filter date/reviewer/reason; link ke booking timeline | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) | +| Payout | View per status, mark PAID; filter date/processor; link ke booking timeline | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) | +| **System Health** | Status cron jobs (last run, last success, 7d stats), 20 recent runs, health badge | [app/admin/system/page.tsx](app/admin/system/page.tsx) | + +**Aksi mutating yang diblokir untuk suspended user:** sign-in (NextAuth), `createTripAction`, `joinTripAction`. Trip public list otomatis sembunyikan organizer suspended. Auth admin: env `ADMIN_EMAILS` โ†’ cek di [lib/admin.ts](lib/admin.ts), dipassing ke session via [lib/auth.ts](lib/auth.ts). @@ -27,38 +31,45 @@ Auth admin: env `ADMIN_EMAILS` โ†’ cek di [lib/admin.ts](lib/admin.ts), dipassin |---|---|---|---| | Trip Operations (search, view, cancel manual) | ๐Ÿ”ด HIGH | โœ… **Delivered** | [docs/archive/ADMIN_TRIP_OPS_ROADMAP.md](docs/archive/ADMIN_TRIP_OPS_ROADMAP.md) | | Payment Operations (booking detail, reconcile, dispute) | ๐Ÿ”ด HIGH | โœ… **Delivered** | [docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md](docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md) | -| Audit & Investigation (search, filter, export) | ๐Ÿ”ด HIGH | โณ 0% | [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) | -| User Management (search, suspend/ban) | ๐ŸŸก MEDIUM | โณ 0% | [ADMIN_USER_MGMT_ROADMAP.md](ADMIN_USER_MGMT_ROADMAP.md) | -| Verification (reopen, re-upload request) | ๐ŸŸก MEDIUM | โณ 0% | [ADMIN_VERIFICATION_ROADMAP.md](ADMIN_VERIFICATION_ROADMAP.md) | -| System Health (cron monitor, stale state alerts) | ๐ŸŸก MEDIUM | โณ 0% | [ADMIN_SYSTEM_HEALTH_ROADMAP.md](ADMIN_SYSTEM_HEALTH_ROADMAP.md) | +| Audit & Investigation (search, filter, export) | ๐Ÿ”ด HIGH | ๐Ÿšง Phase 1 done ยท Phase 2-4 pending | [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) | +| User Management (search, suspend/ban) | ๐ŸŸก MEDIUM | โœ… **Delivered** | [docs/archive/ADMIN_USER_MGMT_ROADMAP.md](docs/archive/ADMIN_USER_MGMT_ROADMAP.md) | +| Verification (reopen, re-upload request) | ๐ŸŸก MEDIUM | ๐Ÿšง Phase 1 done ยท Phase 2-4 deferred | [docs/archive/ADMIN_VERIFICATION_ROADMAP.md](docs/archive/ADMIN_VERIFICATION_ROADMAP.md) | +| System Health (cron monitor, stale state alerts) | ๐ŸŸก MEDIUM | ๐Ÿšง Phase 1-2 done ยท Phase 3-4 deferred | [docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md](docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md) | -**Legend status:** โณ belum mulai ยท ๐Ÿšง partial ยท โœ… selesai (lihat archive untuk detail delivery) +**Legend status:** โณ belum mulai ยท ๐Ÿšง partial ยท โœ… selesai --- -## Iterasi berikutnya (sisa HIGH + MEDIUM) +## Sisa pekerjaan -Setelah Trip Ops + Payment Ops, urutan berikutnya: +Hampir semua kapabilitas dasar admin sudah delivered. Yang tersisa hanya enhancement non-blocking: -1. **Audit & Investigation** (HIGH) โ€” filter date range, search global, CSV export. Penting untuk compliance & investigasi dispute. -2. **User Management** (MEDIUM) โ€” search + suspend/ban. Butuh schema change (`User.suspended`). -3. **System Health** (MEDIUM) โ€” cron monitor + stale state alerts. Butuh model baru (`CronRun`). -4. **Verification** (MEDIUM) โ€” reopen REJECTED + re-upload request. Edge case rare tapi kecil scope. +**Audit Phase 2-4** (lihat [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md)): +- Phase 2 โ€” Global Search (admin search bar resolve email/order_id/cuid) +- Phase 3 โ€” CSV Export untuk refunds/payouts/verifications +- Phase 4 โ€” Generic `AdminActionLog` model untuk audit action lintas entity + +**Lainnya yang di-defer** (di archive masing-masing): +- Verification: re-upload request flow, verification history, manual override +- System Health: stale state alerts (Payment AWAITING > 25h, Payout HELD overdue), external alerting (Discord webhook) +- User Mgmt: bulk analytics dashboard --- -## Tindakan manual setelah deploy - -Untuk versi yang berisi delivery Trip Ops + Payment Ops: +## Tindakan manual setelah deploy versi terakhir ```bash -# Apply migration baru (add_trip_admin_cancel) +# Apply 3 migration baru: add_trip_admin_cancel, add_user_suspension, add_cron_run npx prisma migrate deploy # Restart Next.js / PM2 supaya Prisma client baru ter-load pm2 restart setrip --update-env ``` -Brief admin tentang dua kapabilitas baru: -- **Force-cancel trip** di `/admin/trips/[id]` โ€” pakai saat organizer unreachable / dispute, reason wajib min 10 karakter. -- **Reconcile Midtrans** di `/admin/bookings/[id]` โ€” pakai saat peserta lapor "sudah bayar tapi status belum update". Idempotent, aman diulang. +Brief admin tentang kapabilitas baru: +- **Force-cancel trip** di `/admin/trips/[id]` โ€” pakai saat organizer unreachable / dispute, reason wajib min 10 char. +- **Reconcile Midtrans** di `/admin/bookings/[id]` โ€” pakai saat peserta lapor "sudah bayar tapi status belum update". Idempotent. +- **Suspend user** di `/admin/users/[id]` โ€” pakai untuk scam/harassment. Suspended user diblokir sign-in dan aksi mutatif. +- **Reopen verification** di `/admin/verifications` (tab REJECTED) โ€” saat organizer kirim ulang foto via email/WA. +- **System status** di `/admin/system` โ€” cek setiap pagi, pastikan cron jalan (๐ŸŸข OK). +- **Filter date range + reviewer** di refunds/payouts/verifications โ€” untuk investigasi & compliance. diff --git a/ADMIN_SYSTEM_HEALTH_ROADMAP.md b/ADMIN_SYSTEM_HEALTH_ROADMAP.md deleted file mode 100644 index 40d62e1..0000000 --- a/ADMIN_SYSTEM_HEALTH_ROADMAP.md +++ /dev/null @@ -1,103 +0,0 @@ -# Setrip โ€” Admin System Health Roadmap - -Admin perlu visibilitas atas job otomatis (cron) dan deteksi state yang nyangkut (Payment stale, Payout overdue, Refund mandek). - -> **Skenario nyata:** cron auto-complete trip crash karena env variable rusak. 50 trip yang sudah lewat tanggalnya tetap `OPEN` selama 3 hari sampai peserta komplain "kenapa belum bisa kasih review". Admin tidak punya cara cek status cron tanpa SSH ke server. - ---- - -## Baseline - -- โœ… Cron infra ada (system crontab + `CRON_SECRET`) โ€” lihat [docs/CRON_SETUP.md](docs/CRON_SETUP.md). -- โœ… Cron jobs aktif: `app/api/cron/auto-complete-trips/route.ts`, payout release (lewat `payoutService`), kemungkinan refund timeout. -- โŒ Tidak ada log/audit per cron run (success/fail/error). -- โŒ Tidak ada page `/admin/system` untuk lihat status. -- โŒ Tidak ada alert deteksi state stale (Payment AWAITING > 24h, Payout HELD past `heldUntil`, Refund APPROVED > 7d). - ---- - -## Phase 1 โ€” Cron Run Log โณ - -Tabel `CronRun` yang dicatat setiap kali cron jalan. Foundation untuk semua observability. - -**Keputusan asumsi:** -- Append-only model. Retention: keep all (tabel kecil, ~365 rows/year/cron). Cleanup nanti kalau perlu. -- Wrap existing cron handler dengan helper `runCron(name, fn)` yang otomatis log start/finish/error. -- Tidak pakai job library (BullMQ/Inngest) โ€” overkill. Tetap pakai system cron + Next route handler. - -| # | Item | Status | File | -|---|---|---|---| -| 1.1 | Model `CronRun { id, jobName, startedAt, finishedAt?, status (RUNNING/SUCCESS/FAILED), errorMessage?, payload? Json }` + migration | โณ | [prisma/schema.prisma](prisma/schema.prisma) | -| 1.2 | Helper `runCron(jobName, fn)` โ€” wrap handler, otomatis create RUNNING row โ†’ SUCCESS/FAILED | โณ | `lib/cron-runner.ts` | -| 1.3 | Wire `runCron` di `app/api/cron/auto-complete-trips/route.ts` | โณ | `app/api/cron/auto-complete-trips/route.ts` | -| 1.4 | Wire `runCron` di cron payout release (kalau sudah ada โ€” kalau belum, daftar sebagai gap) | โณ | TBD | -| 1.5 | Wire `runCron` di cron lain (refund sweep, dst) | โณ | TBD | - -**Tindakan manual:** tidak ada. - ---- - -## Phase 2 โ€” System Status Page โณ - -Page `/admin/system` yang tampilkan kondisi terkini. - -**Keputusan asumsi:** -- Tabel per cron job: last run, last success, total runs (7d), error count (7d). -- Refresh manual (tombol "Refresh") โ€” bukan auto-poll. Cukup untuk admin. -- Health badge: ๐ŸŸข OK (last success < 25 jam untuk daily), ๐ŸŸก STALE (> 25 jam), ๐Ÿ”ด FAILED (last run = FAILED). -- Tampilkan 20 cron run terbaru di table bawah untuk drill-down. - -| # | Item | Status | File | -|---|---|---|---| -| 2.1 | `cronRepo.getJobSummary(jobName)` โ€” last run, last success, count 7d | โณ | `server/repositories/cron.repo.ts` | -| 2.2 | `cronRepo.listRecent(limit)` โ€” 20 run terakhir lintas job | โณ | `server/repositories/cron.repo.ts` | -| 2.3 | Page `/admin/system` โ€” tabel job summary + tabel recent runs | โณ | `app/admin/system/page.tsx` | -| 2.4 | Health badge logic (helper) | โณ | `lib/cron-health.ts` | -| 2.5 | Link "System" di admin navbar | โณ | [app/admin/layout.tsx](app/admin/layout.tsx) | - -**Tindakan manual:** -1. Set ekspektasi SLA per cron (mis. `auto-complete-trips` harus jalan setiap hari sebelum jam 06:00 WIB). -2. Brief admin: cek `/admin/system` minimal sekali per hari pagi sebelum mulai kerja. - ---- - -## Phase 3 โ€” Stale State Alerts โณ - -Deteksi entity yang nyangkut di state non-final terlalu lama. Tampilkan sebagai banner di `/admin/system`. - -**Keputusan asumsi:** -- Stale thresholds (review dengan stakeholder, ini draft): - - Payment status `PENDING` > 1 jam โ†’ suspect: gagal create Snap token, perlu manual cleanup - - Payment status `AWAITING` > 25 jam (lebih dari expiresAt) โ†’ suspect: webhook gagal, expire belum di-set, perlu reconcile - - Booking status `AWAITING_PAY` + trip date < today โ†’ suspect: peserta lupa bayar, butuh cleanup - - Payout status `HELD` + `heldUntil < now` > 1 hari โ†’ suspect: cron release tidak jalan, perlu trigger manual - - Refund status `APPROVED` > 7 hari โ†’ suspect: admin lupa proses, atau Midtrans refund gagal -- Compute via query parameter pada page load โ€” tidak perlu materialized view. -- Setiap kategori tampilkan jumlah + link ke filtered list page yang relevan. - -| # | Item | Status | File | -|---|---|---|---| -| 3.1 | `systemHealthService.detectStale()` return `{ stalePayments, expiredAwaiting, awaitingPayPastDeparture, overduePayouts, stuckRefunds }` | โณ | `server/services/system-health.service.ts` | -| 3.2 | Banner alerts di `/admin/system` kalau ada count > 0 | โณ | `app/admin/system/page.tsx` | -| 3.3 | Link tiap alert ke filtered list (pakai filter di [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) Phase 1) | โณ | `app/admin/system/page.tsx` | -| 3.4 | Stat card di dashboard utama `/admin` kalau ada alert | โณ | [app/admin/page.tsx](app/admin/page.tsx) | - -**Tindakan manual:** -1. Tuning threshold setelah jalan 1-2 minggu (false positive vs miss). -2. SOP per alert: action apa yang admin harus ambil saat banner muncul. - ---- - -## Phase 4 โ€” External Alerting (opsional) โณ - -Push notif ke channel eksternal (Discord/Telegram/email) saat ada cron FAILED atau stale state critical. Skip kecuali admin sering miss banner. - -| # | Item | Status | File | -|---|---|---|---| -| 4.1 | Helper `notifyAdmins(message)` โ€” POST ke Discord webhook URL dari env | โณ | `lib/admin-notify.ts` | -| 4.2 | Trigger notify di `runCron` saat FAILED | โณ | `lib/cron-runner.ts` | -| 4.3 | Trigger notify dari `systemHealthService.detectStale` (rate-limited, max 1x/hari per kategori) | โณ | `server/services/system-health.service.ts` | - -**Tindakan manual:** -1. Buat channel Discord internal + webhook URL โ†’ set env `ADMIN_ALERT_WEBHOOK_URL`. -2. Test alert dengan trigger fake fail. diff --git a/ADMIN_USER_MGMT_ROADMAP.md b/ADMIN_USER_MGMT_ROADMAP.md deleted file mode 100644 index 5ee65f3..0000000 --- a/ADMIN_USER_MGMT_ROADMAP.md +++ /dev/null @@ -1,81 +0,0 @@ -# Setrip โ€” Admin User Management Roadmap - -Admin perlu bisa cari user dan **suspend** akun yang melakukan abuse (scam, harassment, fake review). - -> **Skenario nyata:** organizer scam berkali-kali bikin trip palsu pakai alias berbeda. Peserta lapor harassment dari user lain di grup WA trip. Saat ini admin cuma bisa refund korban โ€” pelaku tetap bisa lanjut bikin trip baru / join trip lain. - ---- - -## Baseline - -- โœ… `userRepo.findByEmail()` dan `userRepo.findById()` ada di [server/repositories/user.repo.ts](server/repositories/user.repo.ts). -- โœ… `User` model lengkap dengan relasi ke `trips`, `participations`, `bookings`, `tripReviews`, `organizerVerification`, `payouts`. -- โŒ Tidak ada page `/admin/users`. -- โŒ Tidak ada field `suspended` di `User`. -- โŒ Tidak ada guard di auth/server actions yang reject suspended user. -- โŒ Tidak ada stats user (total signups, organizer aktif, peserta aktif). - ---- - -## Phase 1 โ€” User List & Detail View โณ - -Baseline visibility. Sama pola dengan trip ops โ€” list + search + detail. - -**Keputusan asumsi:** -- Search by email exact match dulu (paling sering dipakai admin saat ada laporan); kalau perlu, tambah name LIKE search nanti. -- Detail page tampilkan: profile, verification status, booking history (sebagai peserta), trip history (sebagai organizer), payout history (sebagai organizer), review yang dibuat & diterima. -- Sensitive info (password hash, OAuth tokens) **tidak** ditampilkan. - -| # | Item | Status | File | -|---|---|---|---| -| 1.1 | `userRepo.searchForAdmin({ q?, role?, suspended? })` โ€” q match email atau name (case insensitive) | โณ | [server/repositories/user.repo.ts](server/repositories/user.repo.ts) | -| 1.2 | Page `/admin/users` โ€” list + search bar + filter (organizer/participant/suspended) | โณ | `app/admin/users/page.tsx` | -| 1.3 | Page `/admin/users/[id]` โ€” profil + tabs (Bookings, Trips Dibuat, Reviews, Verification) | โณ | `app/admin/users/[id]/page.tsx` | -| 1.4 | Stats card di top: total bookings, total spent, total revenue (kalau organizer), verification status | โณ | `app/admin/users/[id]/page.tsx` | -| 1.5 | Link "Users" di admin navbar | โณ | [app/admin/layout.tsx](app/admin/layout.tsx) | - -**Tindakan manual:** tidak ada. - ---- - -## Phase 2 โ€” User Suspension โณ - -Toggle suspend yang mencegah suspended user login + melakukan aksi mutatif. - -**Keputusan asumsi:** -- Tambah 4 kolom di `User`: `suspended Boolean @default(false)`, `suspendedAt DateTime?`, `suspendedReason String?`, `suspendedBy String?` (FK User admin). -- **Block sign-in** di NextAuth callbacks (`signIn` callback return false kalau `user.suspended`). Untuk JWT session sudah aktif, cek `suspended` di `session` callback lalu invalidate. -- **Block mutating actions** via helper `requireActiveUser(session)` yang dipanggil di awal setiap server action mutating (joinTrip, createTrip, addReview, dst). -- Suspended user **tetap bisa** lihat data sendiri (refund history, dll) โ€” tidak hard-delete supaya audit trail terjaga. -- Suspended organizer otomatis sembunyikan trip OPEN/FULL miliknya dari public list โ€” tambah filter di `tripRepo.findOpen` (`organizer: { suspended: false }`). -- Unsuspend = toggle false + clear field โ€” tetap simpan history via [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) Phase 4. - -| # | Item | Status | File | -|---|---|---|---| -| 2.1 | Migration: tambah `suspended`, `suspendedAt`, `suspendedReason`, `suspendedBy` di `User` | โณ | `prisma/migrations/` | -| 2.2 | `userService.suspendUser(userId, adminId, reason)` + `unsuspendUser(userId, adminId)` | โณ | `server/services/user.service.ts` | -| 2.3 | Block sign-in di NextAuth `signIn` callback | โณ | [lib/auth.ts](lib/auth.ts) | -| 2.4 | Helper `requireActiveUser(session)` throw kalau suspended | โณ | `lib/auth-guards.ts` | -| 2.5 | Wire `requireActiveUser` di semua mutating server action (joinTripAction, createTripAction, createReviewAction, dst) | โณ | `features/*/actions.ts` | -| 2.6 | Filter trip public list: organizer tidak suspended | โณ | [server/repositories/trip.repo.ts](server/repositories/trip.repo.ts) | -| 2.7 | UI: tombol "Suspend" / "Unsuspend" di `/admin/users/[id]` + modal reason wajib | โณ | `app/admin/users/[id]/page.tsx` | -| 2.8 | Badge "SUSPENDED" di user list + detail header (visual jelas) | โณ | `app/admin/users/[id]/page.tsx` | -| 2.9 | Server action `suspendUserAction` + `unsuspendUserAction` (guard isAdmin) | โณ | `features/admin/actions.ts` (baru) atau `features/user/actions.ts` | - -**Tindakan manual:** -1. Brief admin: kriteria suspend (3 kategori: scam, harassment, repeated TOS violation). Hindari subjective suspend. -2. Tulis halaman info "Akun ditangguhkan" yang ditampilkan saat suspended user coba login (jelaskan kenapa & cara appeal via email). -3. Pertimbangkan suspended user di Midtrans webhook โ€” saat ada payment masuk untuk suspended user's booking, tetap di-PAID (uang tetap diterima, refund proses normal). - ---- - -## Phase 3 โ€” User Analytics (low priority, skip MVP) โณ - -Dashboard stats untuk growth tracking. Skip sampai ada kebutuhan konkret. - -| # | Item | Status | File | -|---|---|---|---| -| 3.1 | Stats endpoint: total user, signup per minggu (4 minggu terakhir), organizer aktif (yang punya OPEN/FULL trip), peserta aktif (booking PAID) | โณ | `app/admin/users/stats/page.tsx` | -| 3.2 | Chart sederhana (HTML/SVG inline, no chart library) | โณ | `app/admin/users/stats/page.tsx` | - -**Tindakan manual:** tidak ada (skip phase ini). diff --git a/ADMIN_VERIFICATION_ROADMAP.md b/ADMIN_VERIFICATION_ROADMAP.md deleted file mode 100644 index e99fdce..0000000 --- a/ADMIN_VERIFICATION_ROADMAP.md +++ /dev/null @@ -1,88 +0,0 @@ -# Setrip โ€” Admin Verification Roadmap - -Enhancement KYC organizer verification: reopen REJECTED, request re-upload, audit override. - -> **Skenario nyata:** organizer terverifikasi dengan KTP buram โ†’ admin REJECT. Organizer foto ulang dan kirim via email. Admin sekarang harus edit DB manual karena `OrganizerVerification.status = REJECTED` tidak bisa kembali ke `PENDING` lewat UI. - ---- - -## Baseline - -- โœ… Approve / Reject ada di [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx). -- โœ… `OrganizerVerification` model lengkap dengan `reviewedBy`, `reviewedAt`, `rejectionReason`. -- โœ… NIK encrypted (decrypt via `organizerService.decryptNik` saat di-render). -- โŒ Tidak ada cara reopen REJECTED โ†’ kembali ke PENDING. -- โŒ Tidak ada flow "request re-upload" (admin minta organizer upload ulang field tertentu tanpa harus reject penuh). -- โŒ Tidak ada history per verification โ€” kalau organizer ajukan ulang setelah reject, history sebelumnya hilang (di-overwrite). - ---- - -## Phase 1 โ€” Reopen Rejected Verification โณ - -Tombol di REJECTED detail untuk reset ke PENDING supaya admin/organizer bisa coba lagi tanpa harus drop & recreate. - -**Keputusan asumsi:** -- Tidak hapus `rejectionReason` saat reopen โ€” simpan untuk history (rename field jadi `lastRejectionReason`). Sebenarnya rejectionReason cuma string, kalau di-reopen lalu di-reject lagi otomatis di-overwrite. Untuk MVP cukup itu. -- Service method baru `reopenVerification(verifId, adminId, note)` โ€” set `status = PENDING`, clear `reviewedBy/reviewedAt`, log via [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) Phase 4 `auditLog.record`. -- UI: tombol "Buka kembali" di REJECTED card dengan modal note wajib. -- Tidak otomatis kirim notif ke organizer di MVP โ€” admin coordinate via email/WA. - -| # | Item | Status | File | -|---|---|---|---| -| 1.1 | `organizerService.reopenVerification(verifId, adminId, note)` โ€” set PENDING + clear review fields | โณ | [server/services/organizer.service.ts](server/services/organizer.service.ts) | -| 1.2 | Server action `reopenVerificationAction(verifId, note)` (guard isAdmin) | โณ | [features/organizer/actions.ts](features/organizer/actions.ts) | -| 1.3 | UI: tombol "Buka kembali" di tab REJECTED dengan modal note | โณ | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) atau [features/organizer/components/review-card.tsx](features/organizer/components/review-card.tsx) | -| 1.4 | Tampilkan `lastRejectionReason` di tab PENDING juga (sebagai konteks "ini submission ke-N") | โณ | [features/organizer/components/review-card.tsx](features/organizer/components/review-card.tsx) | - -**Tindakan manual:** -1. Brief admin: jangan reopen tanpa konfirmasi organizer sudah siap upload ulang. Note wajib menjelaskan alasan reopen. - ---- - -## Phase 2 โ€” Re-upload Request Flow โณ - -Daripada reject penuh, admin bisa request specific field di-update (KTP buram, foto liveness terlalu gelap). - -**Keputusan asumsi:** -- Tambah `OrganizerVerification.reuploadRequested Boolean @default(false)` + `reuploadFields String[]` + `reuploadNote String?`. -- Saat di-request, set `status = PENDING` (atau status baru `NEEDS_REUPLOAD`). Pakai `PENDING` saja supaya tidak nambah enum (organizer-facing copy bedakan via `reuploadRequested` flag). -- Organizer page `/verify` baca flag โ†’ tampilkan banner kuning + highlight field yang di-request. -- Setelah organizer submit ulang, flag auto-clear โ†’ status tetap PENDING menunggu admin review. - -| # | Item | Status | File | -|---|---|---|---| -| 2.1 | Migration: tambah `reuploadRequested`, `reuploadFields`, `reuploadNote` di `OrganizerVerification` | โณ | `prisma/migrations/` | -| 2.2 | `organizerService.requestReupload(verifId, adminId, fields[], note)` | โณ | [server/services/organizer.service.ts](server/services/organizer.service.ts) | -| 2.3 | Server action + UI tombol "Request re-upload" di admin detail (checkbox per field + textarea note) | โณ | [features/organizer/actions.ts](features/organizer/actions.ts) + admin page | -| 2.4 | Banner kuning di `/verify` saat `reuploadRequested = true` + highlight field-nya | โณ | [app/(public)/verify/page.tsx](app/(public)/verify/page.tsx) + [features/organizer/components/verify-form.tsx](features/organizer/components/verify-form.tsx) | -| 2.5 | Auto-clear `reuploadRequested` saat organizer submit ulang | โณ | [features/organizer/actions.ts](features/organizer/actions.ts) (di `submitVerificationAction`) | - -**Tindakan manual:** tidak ada. - ---- - -## Phase 3 โ€” Verification History (opsional) โณ - -Kalau audit butuh trace "berapa kali organizer ini coba verify", tambah tabel history. Skip untuk MVP. - -| # | Item | Status | File | -|---|---|---|---| -| 3.1 | Model `OrganizerVerificationHistory` (snapshot per submission) | โณ | [prisma/schema.prisma](prisma/schema.prisma) | -| 3.2 | Trigger create snapshot saat submit ulang | โณ | [server/services/organizer.service.ts](server/services/organizer.service.ts) | -| 3.3 | Tab "History" di admin verification detail | โณ | admin page | - -**Tindakan manual:** tidak ada (skip phase). - ---- - -## Phase 4 โ€” Manual Override (super-low priority) โณ - -Admin verifikasi organizer tanpa upload (referral dari partner trusted). Skip kecuali ada use case nyata. - -| # | Item | Status | File | -|---|---|---|---| -| 4.1 | `organizerService.adminCreateVerification(userId, adminId, note)` โ€” buat row APPROVED langsung dengan flag `isManualOverride` | โณ | [server/services/organizer.service.ts](server/services/organizer.service.ts) | -| 4.2 | Migration: tambah `isManualOverride Boolean @default(false)` | โณ | `prisma/migrations/` | -| 4.3 | UI: tombol "Verify manually" di `/admin/users/[id]` (organizer tab) | โณ | `app/admin/users/[id]/page.tsx` | - -**Tindakan manual:** tidak ada (skip phase, evaluate ulang setelah ada partnership program). diff --git a/app/admin/payouts/page.tsx b/app/admin/payouts/page.tsx index e533663..27f97fd 100644 --- a/app/admin/payouts/page.tsx +++ b/app/admin/payouts/page.tsx @@ -1,8 +1,9 @@ import { redirect } from "next/navigation"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; -import { isAdminEmail } from "@/lib/admin"; +import { isAdminEmail, listAdminEmails } from "@/lib/admin"; import { payoutRepo } from "@/server/repositories/payout.repo"; +import { AdminFilterBar } from "@/features/admin/components/admin-filter-bar"; import { PayoutReviewCard, type PayoutCardData, @@ -18,7 +19,18 @@ const TABS: { key: Tab; label: string }[] = [ ]; interface PageProps { - searchParams: Promise<{ tab?: string }>; + searchParams: Promise<{ + tab?: string; + dateFrom?: string; + dateTo?: string; + reviewer?: string; + }>; +} + +function parseDate(value: string | undefined): Date | undefined { + if (!value) return undefined; + const d = new Date(value); + return Number.isNaN(d.getTime()) ? undefined : d; } export default async function AdminPayoutsPage({ searchParams }: PageProps) { @@ -39,7 +51,11 @@ export default async function AdminPayoutsPage({ searchParams }: PageProps) { ? (params.tab as Tab) : "RELEASED"; - const rows = await payoutRepo.listByStatus(tab); + const rows = await payoutRepo.listByStatus(tab, { + dateFrom: parseDate(params.dateFrom), + dateTo: parseDate(params.dateTo), + processorEmail: params.reviewer || undefined, + }); const items: PayoutCardData[] = rows.map((p) => ({ id: p.id, amount: p.amount, @@ -78,6 +94,18 @@ export default async function AdminPayoutsPage({ searchParams }: PageProps) {

+ +
{TABS.map((t) => ( -

Tidak ada payout pada status ini.

+

+ Tidak ada payout yang cocok dengan filter ini. +

) : (
diff --git a/app/admin/refunds/page.tsx b/app/admin/refunds/page.tsx index 1688c8f..bd5c474 100644 --- a/app/admin/refunds/page.tsx +++ b/app/admin/refunds/page.tsx @@ -1,9 +1,10 @@ import { redirect } from "next/navigation"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; -import { isAdminEmail } from "@/lib/admin"; +import { isAdminEmail, listAdminEmails } from "@/lib/admin"; import { refundRepo } from "@/server/repositories/refund.repo"; import { CreateRefundForm } from "@/features/refund/components/create-refund-form"; +import { AdminFilterBar } from "@/features/admin/components/admin-filter-bar"; import { RefundReviewCard, type RefundCardData, @@ -19,8 +20,30 @@ const TABS: { key: Tab; label: string }[] = [ { key: "FAILED", label: "Gagal" }, ]; +const REASON_OPTIONS = [ + { value: "USER_CANCELLATION", label: "User cancel" }, + { value: "ORGANIZER_CANCELLED", label: "Organizer cancel" }, + { value: "TRIP_ISSUE", label: "Trip issue" }, + { value: "ADMIN_ADJUSTMENT", label: "Admin adjustment" }, + { value: "DISPUTE_RESOLVED", label: "Dispute resolved" }, +] as const; + +type ReasonValue = (typeof REASON_OPTIONS)[number]["value"]; + interface PageProps { - searchParams: Promise<{ tab?: string }>; + searchParams: Promise<{ + tab?: string; + dateFrom?: string; + dateTo?: string; + reviewer?: string; + reason?: string; + }>; +} + +function parseDate(value: string | undefined): Date | undefined { + if (!value) return undefined; + const d = new Date(value); + return Number.isNaN(d.getTime()) ? undefined : d; } export default async function AdminRefundsPage({ searchParams }: PageProps) { @@ -40,8 +63,19 @@ export default async function AdminRefundsPage({ searchParams }: PageProps) { const tab: Tab = TABS.some((t) => t.key === params.tab) ? (params.tab as Tab) : "PENDING"; + const reason: ReasonValue | undefined = REASON_OPTIONS.some( + (r) => r.value === params.reason + ) + ? (params.reason as ReasonValue) + : undefined; + + const rows = await refundRepo.listByStatus(tab, { + dateFrom: parseDate(params.dateFrom), + dateTo: parseDate(params.dateTo), + reviewerEmail: params.reviewer || undefined, + reason, + }); - const rows = await refundRepo.listByStatus(tab); const items: RefundCardData[] = rows.map((r) => ({ id: r.id, amount: r.amount, @@ -92,6 +126,20 @@ export default async function AdminRefundsPage({ searchParams }: PageProps) { + +
{TABS.map((t) => (

- Tidak ada refund pada status ini. + Tidak ada refund yang cocok dengan filter ini.

) : ( diff --git a/app/admin/system/page.tsx b/app/admin/system/page.tsx new file mode 100644 index 0000000..456958a --- /dev/null +++ b/app/admin/system/page.tsx @@ -0,0 +1,258 @@ +import { redirect } from "next/navigation"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { isAdminEmail } from "@/lib/admin"; +import { prisma } from "@/lib/prisma"; + +interface JobSummary { + jobName: string; + lastRun: { at: Date; status: string; errorMessage: string | null } | null; + lastSuccess: Date | null; + totalRuns7d: number; + failedRuns7d: number; +} + +async function getJobSummary(jobName: string): Promise { + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + const [lastRun, lastSuccessRow, totalRuns7d, failedRuns7d] = + await Promise.all([ + prisma.cronRun.findFirst({ + where: { jobName }, + orderBy: { startedAt: "desc" }, + select: { startedAt: true, status: true, errorMessage: true }, + }), + prisma.cronRun.findFirst({ + where: { jobName, status: "SUCCESS" }, + orderBy: { startedAt: "desc" }, + select: { startedAt: true }, + }), + prisma.cronRun.count({ + where: { jobName, startedAt: { gte: sevenDaysAgo } }, + }), + prisma.cronRun.count({ + where: { + jobName, + status: "FAILED", + startedAt: { gte: sevenDaysAgo }, + }, + }), + ]); + return { + jobName, + lastRun: lastRun + ? { + at: lastRun.startedAt, + status: lastRun.status, + errorMessage: lastRun.errorMessage, + } + : null, + lastSuccess: lastSuccessRow?.startedAt ?? null, + totalRuns7d, + failedRuns7d, + }; +} + +// Daftar cron yang dipantau. Tambah entry baru saat menambah cron route handler. +const TRACKED_JOBS = ["auto-complete-trips"] as const; + +function healthOf(summary: JobSummary): "ok" | "stale" | "failed" { + if (summary.lastRun?.status === "FAILED") return "failed"; + if (!summary.lastSuccess) return "stale"; + const hoursSince = + (Date.now() - summary.lastSuccess.getTime()) / (1000 * 60 * 60); + // Asumsi cron daily โ€” > 25 jam dianggap stale. + if (hoursSince > 25) return "stale"; + return "ok"; +} + +export default async function AdminSystemPage() { + const session = await getServerSession(authOptions); + if (!session?.user) redirect("/login?callbackUrl=/admin/system"); + if (!isAdminEmail(session.user.email)) { + return ( +
+

+ Halaman ini hanya untuk admin SeTrip. +

+
+ ); + } + + const summaries = await Promise.all(TRACKED_JOBS.map(getJobSummary)); + const recentRuns = await prisma.cronRun.findMany({ + orderBy: { startedAt: "desc" }, + take: 20, + }); + + return ( +
+
+

+ System Health +

+

+ Status cron job otomatis. Refresh halaman ini setelah trigger cron + manual atau saat investigasi. +

+
+ +
+

+ Cron Jobs +

+
+ {summaries.map((s) => { + const health = healthOf(s); + const cls = + health === "ok" + ? "border-emerald-200 bg-emerald-50/50" + : health === "stale" + ? "border-amber-200 bg-amber-50/50" + : "border-red-200 bg-red-50/50"; + const badge = + health === "ok" + ? { label: "๐ŸŸข OK", cls: "bg-emerald-100 text-emerald-800" } + : health === "stale" + ? { + label: "๐ŸŸก STALE", + cls: "bg-amber-100 text-amber-800", + } + : { + label: "๐Ÿ”ด FAILED", + cls: "bg-red-100 text-red-800", + }; + return ( +
+
+
+

+ Job +

+

+ {s.jobName} +

+
+ + {badge.label} + +
+
+
+
Last run:
{" "} +
+ {s.lastRun + ? `${formatDateTime(s.lastRun.at)} ยท ${s.lastRun.status}` + : "Belum pernah"} +
+
+
+
Last success:
{" "} +
+ {s.lastSuccess + ? formatDateTime(s.lastSuccess) + : "Belum pernah"} +
+
+
+
7 hari terakhir:
{" "} +
+ {s.totalRuns7d} run, {s.failedRuns7d} failed +
+
+ {s.lastRun?.errorMessage && ( +
+ Error terakhir: {s.lastRun.errorMessage} +
+ )} +
+
+ ); + })} +
+
+ +
+

+ Recent Runs (20 terakhir) +

+ {recentRuns.length === 0 ? ( +
+

+ Belum ada cron run tercatat. Setelah cron berikutnya jalan, baris + pertama akan muncul di sini. +

+
+ ) : ( +
+ + + + + + + + + + + + {recentRuns.map((r) => ( + + + + + + + + ))} + +
JobStartedFinishedStatusNote
{r.jobName}{formatDateTime(r.startedAt)} + {r.finishedAt ? formatDateTime(r.finishedAt) : "โ€”"} + + + + {r.errorMessage ?? + (r.payload + ? truncate(JSON.stringify(r.payload), 80) + : "โ€”")} +
+
+ )} +
+
+ ); +} + +function formatDateTime(d: Date): string { + return d.toLocaleString("id-ID", { + day: "2-digit", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function truncate(s: string, max: number): string { + return s.length > max ? `${s.slice(0, max)}โ€ฆ` : s; +} + +function StatusBadge({ value }: { value: string }) { + const cls = + value === "SUCCESS" + ? "bg-emerald-100 text-emerald-800" + : value === "FAILED" + ? "bg-red-100 text-red-800" + : "bg-amber-100 text-amber-800"; + return ( + + {value} + + ); +} diff --git a/app/admin/users/[id]/page.tsx b/app/admin/users/[id]/page.tsx new file mode 100644 index 0000000..6ac2a25 --- /dev/null +++ b/app/admin/users/[id]/page.tsx @@ -0,0 +1,349 @@ +import Link from "next/link"; +import Image from "next/image"; +import { notFound, redirect } from "next/navigation"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { isAdminEmail } from "@/lib/admin"; +import { userRepo } from "@/server/repositories/user.repo"; +import { formatRupiah } from "@/lib/utils"; +import { SuspendUserButton } from "@/features/admin/components/suspend-user-button"; + +interface PageProps { + params: Promise<{ id: string }>; +} + +export default async function AdminUserDetailPage({ params }: PageProps) { + const session = await getServerSession(authOptions); + if (!session?.user) redirect("/login?callbackUrl=/admin/users"); + if (!isAdminEmail(session.user.email)) { + return ( +
+

+ Halaman ini hanya untuk admin SeTrip. +

+
+ ); + } + + const { id } = await params; + const user = await userRepo.findByIdForAdmin(id); + if (!user) notFound(); + + const isSelf = user.id === session.user.id; + const totalSpent = user.bookings + .filter((b) => b.status === "PAID" || b.status === "PARTIALLY_REFUNDED") + .reduce((sum, b) => sum + b.amount, 0); + + return ( +
+
+ + โ† Kembali ke list users + +
+ +
+
+ {user.image ? ( + + ) : ( +
+ {user.name.charAt(0).toUpperCase()} +
+ )} +
+
+

+ {user.name} +

+ {user.suspended && ( + + Suspended + + )} + {user.organizerVerification?.status === "APPROVED" && ( + + โœ“ Verified Organizer + + )} +
+

{user.email}

+

+ User ID:{" "} + + {user.id} + +

+

+ 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", + })} + + )} +

+
+
+
+ +
+ + + +
+ + {user.suspended && ( +
+

+ โ›” Akun ditangguhkan +

+

+ {user.suspendedReason ?? "Tidak ada alasan tercatat."} +

+ {user.suspendedBy && ( +

+ Disuspend oleh {user.suspendedBy.email} + {user.suspendedAt && ( + <> + {" "} + pada{" "} + {user.suspendedAt.toLocaleString("id-ID", { + day: "2-digit", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + + )} +

+ )} +
+ )} + +
+

+ Aksi Admin +

+ {isSelf ? ( +

+ Tidak bisa suspend akun sendiri. +

+ ) : ( + + )} +
+ + {user.profile && ( +
+

+ Profil Sosial +

+
+ {user.profile.bio && ( +
+
+ Bio +
+
+ {user.profile.bio} +
+
+ )} + {user.profile.city && ( +
+
+ Kota +
+
{user.profile.city}
+
+ )} + {user.profile.vibe && ( +
+
+ Vibe +
+
{user.profile.vibe}
+
+ )} + {user.profile.interests.length > 0 && ( +
+
+ Minat +
+
+ {user.profile.interests.map((tag) => ( + + #{tag} + + ))} +
+
+ )} + {user.profile.instagram && ( +
+
+ Instagram +
+
@{user.profile.instagram}
+
+ )} +
+
+ )} + + {user.organizerVerification && ( +
+

+ Verifikasi Organizer +

+

+ Status:{" "} + + {user.organizerVerification.status} + + {" ยท "} + + Buka di /admin/verifications โ†’ + +

+ {user.organizerVerification.rejectionReason && ( +

+ Reason: {user.organizerVerification.rejectionReason} +

+ )} +
+ )} + +
+

+ Trip yang dibuat ({user.trips.length}) +

+ {user.trips.length === 0 ? ( +

+ User ini belum pernah membuat trip. +

+ ) : ( +
    + {user.trips.map((t) => ( +
  • + +
    +

    + {t.title} +

    +

    + {t.destination} ยท{" "} + {t.date.toLocaleDateString("id-ID", { + day: "numeric", + month: "short", + year: "numeric", + })}{" "} + ยท {t.status} +

    +
    +

    + {formatRupiah(t.price)} +

    + +
  • + ))} +
+ )} +
+ +
+

+ Booking sebagai peserta ({user.bookings.length}) +

+ {user.bookings.length === 0 ? ( +

Belum ada booking.

+ ) : ( +
    + {user.bookings.map((b) => ( +
  • + +
    +

    + {b.trip.title} +

    +

    + {b.trip.date.toLocaleDateString("id-ID", { + day: "numeric", + month: "short", + year: "numeric", + })}{" "} + ยท status: {b.status} +

    +
    +

    + {formatRupiah(b.amount)} +

    + +
  • + ))} +
+ )} +
+
+ ); +} + +function StatCard({ + label, + value, + accent = "primary", +}: { + label: string; + value: string; + accent?: "primary" | "emerald"; +}) { + const cls = accent === "emerald" ? "text-emerald-700" : "text-primary-700"; + return ( +
+

+ {label} +

+

{value}

+
+ ); +} diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx new file mode 100644 index 0000000..364475a --- /dev/null +++ b/app/admin/users/page.tsx @@ -0,0 +1,167 @@ +import Link from "next/link"; +import Image from "next/image"; +import { redirect } from "next/navigation"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { isAdminEmail } from "@/lib/admin"; +import { userRepo } from "@/server/repositories/user.repo"; + +type Tab = "ALL" | "ACTIVE" | "SUSPENDED"; + +const TABS: { key: Tab; label: string }[] = [ + { key: "ALL", label: "Semua" }, + { key: "ACTIVE", label: "Aktif" }, + { key: "SUSPENDED", label: "Suspended" }, +]; + +interface PageProps { + searchParams: Promise<{ tab?: string; q?: string }>; +} + +export default async function AdminUsersPage({ searchParams }: PageProps) { + const session = await getServerSession(authOptions); + if (!session?.user) redirect("/login?callbackUrl=/admin/users"); + if (!isAdminEmail(session.user.email)) { + return ( +
+

+ Halaman ini hanya untuk admin SeTrip. +

+
+ ); + } + + const params = await searchParams; + const tab: Tab = TABS.some((t) => t.key === params.tab) + ? (params.tab as Tab) + : "ALL"; + const q = (params.q ?? "").trim(); + + const users = await userRepo.searchForAdmin({ + q: q || undefined, + suspended: tab === "SUSPENDED" ? true : tab === "ACTIVE" ? false : undefined, + }); + + return ( +
+
+

+ User Management +

+

+ Cari user, lihat history booking & trip, dan suspend akun yang + melakukan abuse (scam, harassment, TOS violation). +

+
+ +
+ + + + {q && ( + + Reset + + )} +
+ +
+ {TABS.map((t) => ( + + {t.label} + + ))} +
+ + {users.length === 0 ? ( +
+

+ {q + ? `Tidak ada user yang cocok dengan "${q}".` + : "Tidak ada user pada tab ini."} +

+
+ ) : ( +
    + {users.map((u) => ( +
  • + + {u.image ? ( + + ) : ( +
    + {u.name.charAt(0).toUpperCase()} +
    + )} +
    +
    +

    + {u.name} +

    + {u.suspended && ( + + Suspended + + )} + {u.organizerVerification?.status === "APPROVED" && ( + + โœ“ Organizer + + )} +
    +

    {u.email}

    +

    + Bergabung{" "} + {u.createdAt.toLocaleDateString("id-ID", { + day: "numeric", + month: "short", + year: "numeric", + })} + {" ยท "} + {u._count.trips} trip dibuat, {u._count.participations}{" "} + booking +

    +
    + +
  • + ))} +
+ )} +
+ ); +} diff --git a/app/admin/verifications/page.tsx b/app/admin/verifications/page.tsx index 10ed630..3caa285 100644 --- a/app/admin/verifications/page.tsx +++ b/app/admin/verifications/page.tsx @@ -1,15 +1,27 @@ import { redirect } from "next/navigation"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; -import { isAdminEmail } from "@/lib/admin"; +import { isAdminEmail, listAdminEmails } from "@/lib/admin"; import { organizerRepo } from "@/server/repositories/organizer.repo"; import { organizerService } from "@/server/services/organizer.service"; +import { AdminFilterBar } from "@/features/admin/components/admin-filter-bar"; import { ReviewCard } from "@/features/organizer/components/review-card"; type Tab = "PENDING" | "APPROVED" | "REJECTED"; interface PageProps { - searchParams: Promise<{ tab?: string }>; + searchParams: Promise<{ + tab?: string; + dateFrom?: string; + dateTo?: string; + reviewer?: string; + }>; +} + +function parseDate(value: string | undefined): Date | undefined { + if (!value) return undefined; + const d = new Date(value); + return Number.isNaN(d.getTime()) ? undefined : d; } export default async function AdminVerificationsPage({ searchParams }: PageProps) { @@ -29,7 +41,11 @@ export default async function AdminVerificationsPage({ searchParams }: PageProps const tab: Tab = params.tab === "APPROVED" || params.tab === "REJECTED" ? params.tab : "PENDING"; - const rows = await organizerRepo.listByStatus(tab); + const rows = await organizerRepo.listByStatus(tab, { + dateFrom: parseDate(params.dateFrom), + dateTo: parseDate(params.dateTo), + reviewerEmail: params.reviewer || undefined, + }); const items = rows.map((v) => ({ id: v.id, fullName: v.fullName, @@ -65,6 +81,18 @@ export default async function AdminVerificationsPage({ searchParams }: PageProps

+ +
{tabs.map((t) => ( -

Tidak ada data.

+

+ Tidak ada data yang cocok dengan filter ini. +

) : (
diff --git a/app/api/cron/auto-complete-trips/route.ts b/app/api/cron/auto-complete-trips/route.ts index f2c03f7..bf06bce 100644 --- a/app/api/cron/auto-complete-trips/route.ts +++ b/app/api/cron/auto-complete-trips/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { tripService } from "@/server/services/trip.service"; import { payoutService } from "@/server/services/payout.service"; +import { runCron } from "@/lib/cron-runner"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -30,27 +31,25 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - try { + const outcome = await runCron("auto-complete-trips", async () => { const result = await tripService.autoCompletePastTrips(); // Setelah trip COMPLETED, payout yang sudah lewat heldUntil di-release // supaya admin bisa langsung transfer ke organizer. Idempotent. const releaseResult = await payoutService.releaseEligible(); - console.log("[cron/auto-complete-trips] selesai", { - completed: result.count, - ids: result.ids, - payoutsReleased: releaseResult.releasedIds.length, - }); - return NextResponse.json({ - ok: true, + return { completed: result.count, ids: result.ids, payoutsReleased: releaseResult.releasedIds, - }); - } catch (err) { - console.error("[cron/auto-complete-trips] gagal", err); + }; + }); + + if (!outcome.ok) { + console.error("[cron/auto-complete-trips] gagal", outcome.error); return NextResponse.json( { error: "Gagal menjalankan auto-complete" }, { status: 500 } ); } + console.log("[cron/auto-complete-trips] selesai", outcome.payload); + return NextResponse.json({ ok: true, ...outcome.payload }); } diff --git a/components/admin/admin-sidebar.tsx b/components/admin/admin-sidebar.tsx index e5da104..97237bd 100644 --- a/components/admin/admin-sidebar.tsx +++ b/components/admin/admin-sidebar.tsx @@ -9,9 +9,11 @@ import { signOut } from "next-auth/react"; const NAV_ITEMS: { href: string; label: string; icon: string }[] = [ { href: "/admin", label: "Dashboard", icon: "๐Ÿ“Š" }, { href: "/admin/trips", label: "Trips", icon: "๐Ÿงญ" }, + { href: "/admin/users", label: "Users", icon: "๐Ÿ‘ฅ" }, { href: "/admin/verifications", label: "Verifikasi", icon: "๐Ÿชช" }, { href: "/admin/refunds", label: "Refund", icon: "โ†ฉ๏ธ" }, { href: "/admin/payouts", label: "Payout", icon: "๐Ÿ’ธ" }, + { href: "/admin/system", label: "System", icon: "โš™๏ธ" }, ]; interface AdminSidebarProps { diff --git a/docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md b/docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md new file mode 100644 index 0000000..f1512cb --- /dev/null +++ b/docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md @@ -0,0 +1,48 @@ +# Setrip โ€” Admin System Health Roadmap (ARCHIVED โ€” DELIVERED 2026-05-18, partial) + +Admin perlu visibilitas atas job otomatis (cron). Phase 1 (cron log foundation) delivered. Phase 2 (status page) bonus. + +--- + +## Status delivery + +| Phase | Status | Catatan | +|---|---|---| +| Phase 1 โ€” Cron Run Log | โœ… Delivered | Model `CronRun`, helper `runCron()`, wire ke cron existing. | +| Phase 2 โ€” System Status Page | โœ… Delivered (bonus) | `/admin/system` tampilkan summary cron + 20 run terakhir + health badge (ok/stale/failed). | +| Phase 3 โ€” Stale State Alerts | โณ Deferred | Belum perlu sampai ada incident. | +| Phase 4 โ€” External Alerting (Discord) | โณ Deferred | Skip kecuali admin sering miss banner. | + +--- + +## Phase 1 โ€” Cron Run Log โœ… + +| # | Item | Status | File | +|---|---|---|---| +| 1.1 | Model `CronRun` + enum `CronRunStatus` + migration | โœ… | [prisma/schema.prisma](../../prisma/schema.prisma) + `prisma/migrations/20260518170000_add_cron_run/` | +| 1.2 | Helper `runCron(jobName, fn)` โ€” auto create RUNNING row โ†’ SUCCESS/FAILED | โœ… | [lib/cron-runner.ts](../../lib/cron-runner.ts) | +| 1.3 | Wire `runCron` di `auto-complete-trips` cron | โœ… | [app/api/cron/auto-complete-trips/route.ts](../../app/api/cron/auto-complete-trips/route.ts) | +| 1.4 | Wire `runCron` di cron payout release | โณ | Defer โ€” `releaseEligible` saat ini di-call dari cron yang sama, sudah ter-wrap. | +| 1.5 | Wire `runCron` di cron lain (refund sweep, dst) saat ditambah | โณ | Tidak ada cron lain saat ini. | + +--- + +## Phase 2 โ€” System Status Page โœ… (bonus) + +| # | Item | Status | File | +|---|---|---|---| +| 2.1 | Per-job summary: last run, last success, count 7d, error count 7d | โœ… | [app/admin/system/page.tsx](../../app/admin/system/page.tsx) | +| 2.2 | 20 cron run terakhir di table bawah | โœ… | [app/admin/system/page.tsx](../../app/admin/system/page.tsx) | +| 2.3 | Health badge (๐ŸŸข OK < 25 jam, ๐ŸŸก STALE, ๐Ÿ”ด FAILED) | โœ… | [app/admin/system/page.tsx](../../app/admin/system/page.tsx) | +| 2.4 | Link "System" di admin navbar | โœ… | [components/admin/admin-sidebar.tsx](../../components/admin/admin-sidebar.tsx) | + +**Tindakan manual ops:** +1. Apply migration: `npx prisma migrate deploy`. +2. Setelah cron berikutnya jalan, cek `/admin/system` untuk lihat entry pertama. +3. Saat menambah cron route handler baru, daftarkan jobName di `TRACKED_JOBS` di `app/admin/system/page.tsx`. + +--- + +## Phase 3-4 โณ (deferred) + +Stale state alerts + Discord webhook. Lihat versi awal di [ADMIN_ROADMAP.md](../../ADMIN_ROADMAP.md). diff --git a/docs/archive/ADMIN_USER_MGMT_ROADMAP.md b/docs/archive/ADMIN_USER_MGMT_ROADMAP.md new file mode 100644 index 0000000..3f0ee39 --- /dev/null +++ b/docs/archive/ADMIN_USER_MGMT_ROADMAP.md @@ -0,0 +1,55 @@ +# Setrip โ€” Admin User Management Roadmap (ARCHIVED โ€” DELIVERED 2026-05-18) + +Admin perlu bisa cari user dan **suspend** akun yang melakukan abuse (scam, harassment, fake review). + +> **Skenario nyata:** organizer scam berkali-kali bikin trip palsu pakai alias berbeda. Peserta lapor harassment dari user lain di grup WA trip. + +--- + +## Status delivery + +| Phase | Status | Catatan | +|---|---|---| +| Phase 1 โ€” User List & Detail | โœ… Delivered | Search by email/name, filter tab (ALL/ACTIVE/SUSPENDED), stats (trip dibuat, booking, total spent). | +| Phase 2 โ€” User Suspension | โœ… Delivered | Schema baru `User.suspended`, auth gate sign-in + helper `requireActiveUser` di mutating actions, trip public list otomatis sembunyikan organizer suspended. | +| Phase 3 โ€” User Analytics | โณ Deferred | Skip MVP โ€” tidak ada use case konkret. | + +--- + +## Phase 1 โ€” User List & Detail โœ… + +| # | Item | Status | File | +|---|---|---|---| +| 1.1 | `userRepo.searchForAdmin({ q?, suspended? })` | โœ… | [server/repositories/user.repo.ts](../../server/repositories/user.repo.ts) | +| 1.2 | Page `/admin/users` โ€” list + search + tab filter (ALL/ACTIVE/SUSPENDED) | โœ… | [app/admin/users/page.tsx](../../app/admin/users/page.tsx) | +| 1.3 | Page `/admin/users/[id]` โ€” detail dengan trip dibuat + booking history + profile + verification | โœ… | [app/admin/users/[id]/page.tsx](../../app/admin/users/[id]/page.tsx) | +| 1.4 | Stats cards: trip dibuat, booking aktif, total spent (PAID) | โœ… | [app/admin/users/[id]/page.tsx](../../app/admin/users/[id]/page.tsx) | +| 1.5 | Link "Users" di admin navbar | โœ… | [components/admin/admin-sidebar.tsx](../../components/admin/admin-sidebar.tsx) | + +--- + +## Phase 2 โ€” User Suspension โœ… + +| # | Item | Status | File | +|---|---|---|---| +| 2.1 | Migration: `suspended Boolean`, `suspendedAt`, `suspendedReason`, `suspendedById` (FK User SET NULL) | โœ… | `prisma/migrations/20260518160000_add_user_suspension/` | +| 2.2 | `userService.suspendUser` + `unsuspendUser` (idempotent + cek tidak suspend diri sendiri + reason min 10 char) | โœ… | [server/services/user.service.ts](../../server/services/user.service.ts) | +| 2.3 | Block sign-in di NextAuth `signIn` callback (email-based, jalan untuk Credentials + OAuth) | โœ… | [lib/auth.ts](../../lib/auth.ts) | +| 2.4 | Helper `requireActiveUser(userId)` โ€” lookup fresh dari DB | โœ… | [lib/auth-guards.ts](../../lib/auth-guards.ts) | +| 2.5 | Wire `requireActiveUser` di `createTripAction` + `joinTripAction` | โœ… | [features/trip/actions.ts](../../features/trip/actions.ts) | +| 2.6 | Filter trip public list: `organizer: { suspended: false }` di `findOpen` | โœ… | [server/repositories/trip.repo.ts](../../server/repositories/trip.repo.ts) | +| 2.7 | UI: tombol Suspend/Unsuspend di `/admin/users/[id]` dengan modal reason wajib | โœ… | [features/admin/components/suspend-user-button.tsx](../../features/admin/components/suspend-user-button.tsx) | +| 2.8 | Badge "SUSPENDED" di user list + detail header (red border accent) | โœ… | list & detail pages | +| 2.9 | Server actions `suspendUserAction` + `unsuspendUserAction` (guard isAdmin) | โœ… | [features/admin/actions.ts](../../features/admin/actions.ts) | + +**Tindakan manual ops:** +1. Apply migration: `npx prisma migrate deploy`. +2. Brief admin: kriteria suspend (scam, harassment, repeated TOS violation). Reason wajib min 10 char. +3. Wire `requireActiveUser` ke action mutating lain saat dibuat (`createReviewAction`, dst). +4. Pertimbangkan: bikin halaman info "Akun ditangguhkan" untuk UX saat suspended user coba login. + +--- + +## Phase 3 โ€” User Analytics โณ (deferred) + +Skip sampai growth team minta. Lihat versi awal di [ADMIN_ROADMAP.md](../../ADMIN_ROADMAP.md). diff --git a/docs/archive/ADMIN_VERIFICATION_ROADMAP.md b/docs/archive/ADMIN_VERIFICATION_ROADMAP.md new file mode 100644 index 0000000..ed82f0e --- /dev/null +++ b/docs/archive/ADMIN_VERIFICATION_ROADMAP.md @@ -0,0 +1,34 @@ +# Setrip โ€” Admin Verification Roadmap (ARCHIVED โ€” DELIVERED 2026-05-18, partial) + +Enhancement KYC organizer verification: reopen REJECTED, request re-upload, audit override. + +--- + +## Status delivery + +| Phase | Status | Catatan | +|---|---|---| +| Phase 1 โ€” Reopen Rejected | โœ… Delivered | Tombol "Buka kembali ke PENDING" di REJECTED card dengan note wajib min 10 char. | +| Phase 2 โ€” Re-upload Request | โณ Deferred | Butuh schema + organizer-side UI; skip MVP. | +| Phase 3 โ€” Verification History | โณ Deferred | Skip. | +| Phase 4 โ€” Manual Override | โณ Deferred | Skip. | + +--- + +## Phase 1 โ€” Reopen Rejected Verification โœ… + +| # | Item | Status | File | +|---|---|---|---| +| 1.1 | `organizerService.reopenVerification(verifId, adminId, note)` โ€” set PENDING, clear review fields, simpan note di rejectionReason | โœ… | [server/services/organizer.service.ts](../../server/services/organizer.service.ts) | +| 1.2 | `organizerRepo.reopen(id, note)` | โœ… | [server/repositories/organizer.repo.ts](../../server/repositories/organizer.repo.ts) | +| 1.3 | Server action `reopenVerificationAction` (guard isAdmin) | โœ… | [features/organizer/actions.ts](../../features/organizer/actions.ts) | +| 1.4 | UI: tombol "๐Ÿ”„ Buka kembali ke PENDING" di REJECTED card + textarea note wajib | โœ… | [features/organizer/components/review-card.tsx](../../features/organizer/components/review-card.tsx) | + +**Tindakan manual ops:** +1. Brief admin: koordinasi dengan organizer dulu via email/WA sebelum reopen (pastikan organizer siap submit ulang foto/data). Note wajib menjelaskan alasan reopen untuk audit trail. + +--- + +## Phase 2-4 โณ (deferred) + +Lihat versi awal di [ADMIN_ROADMAP.md](../../ADMIN_ROADMAP.md). Akan diangkat kembali kalau ada kebutuhan konkret (banyak re-upload, partnership program butuh manual override, dst). diff --git a/features/admin/actions.ts b/features/admin/actions.ts new file mode 100644 index 0000000..e515c25 --- /dev/null +++ b/features/admin/actions.ts @@ -0,0 +1,49 @@ +"use server"; + +import { getServerSession } from "next-auth"; +import { revalidatePath } from "next/cache"; +import { authOptions } from "@/lib/auth"; +import { isAdminEmail } from "@/lib/admin"; +import { userService } from "@/server/services/user.service"; + +export async function suspendUserAction(userId: string, reason: string) { + const session = await getServerSession(authOptions); + if (!session?.user) { + return { error: "Kamu harus login terlebih dahulu" }; + } + if (!isAdminEmail(session.user.email)) { + return { error: "Hanya admin yang bisa melakukan aksi ini" }; + } + + try { + await userService.suspendUser({ + userId, + adminId: session.user.id, + reason, + }); + revalidatePath("/admin/users"); + revalidatePath(`/admin/users/${userId}`); + return { success: true as const }; + } catch (err) { + return { error: (err as Error).message }; + } +} + +export async function unsuspendUserAction(userId: string) { + const session = await getServerSession(authOptions); + if (!session?.user) { + return { error: "Kamu harus login terlebih dahulu" }; + } + if (!isAdminEmail(session.user.email)) { + return { error: "Hanya admin yang bisa melakukan aksi ini" }; + } + + try { + await userService.unsuspendUser({ userId, adminId: session.user.id }); + revalidatePath("/admin/users"); + revalidatePath(`/admin/users/${userId}`); + return { success: true as const }; + } catch (err) { + return { error: (err as Error).message }; + } +} diff --git a/features/admin/components/admin-filter-bar.tsx b/features/admin/components/admin-filter-bar.tsx new file mode 100644 index 0000000..95f7163 --- /dev/null +++ b/features/admin/components/admin-filter-bar.tsx @@ -0,0 +1,147 @@ +interface AdminFilterBarProps { + /** URL base (mis. `/admin/refunds`) yang menerima query params. */ + action: string; + /** Nilai current dari searchParams. */ + values: { + tab?: string; + dateFrom?: string; + dateTo?: string; + reviewer?: string; + reason?: string; + }; + /** Daftar admin email untuk dropdown reviewer/processor. */ + reviewerOptions: string[]; + /** Label dropdown reviewer (mis. "Reviewer", "Processor"). */ + reviewerLabel?: string; + /** Kalau diisi, tampilkan dropdown reason dengan opsi-opsi tersebut. */ + reasonOptions?: { value: string; label: string }[]; +} + +/** + * Filter bar reusable untuk admin list pages. Pakai GET form supaya URL + * shareable dan tidak perlu state client. + */ +export function AdminFilterBar({ + action, + values, + reviewerOptions, + reviewerLabel = "Reviewer", + reasonOptions, +}: AdminFilterBarProps) { + return ( +
+ {/* Preserve tab via hidden input */} + {values.tab && } + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + {reasonOptions ? ( +
+ + +
+ ) : ( +
+ +
+ )} +
+ + {reasonOptions && ( +
+ + + Reset + +
+ )} +
+ ); +} diff --git a/features/admin/components/suspend-user-button.tsx b/features/admin/components/suspend-user-button.tsx new file mode 100644 index 0000000..2a9984d --- /dev/null +++ b/features/admin/components/suspend-user-button.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { + suspendUserAction, + unsuspendUserAction, +} from "@/features/admin/actions"; + +interface SuspendUserButtonProps { + userId: string; + isSuspended: boolean; +} + +export function SuspendUserButton({ + userId, + isSuspended, +}: SuspendUserButtonProps) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [reason, setReason] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + async function handleSuspend() { + setLoading(true); + setError(""); + const res = await suspendUserAction(userId, reason); + setLoading(false); + if ("error" in res && res.error) { + setError(res.error); + return; + } + setOpen(false); + setReason(""); + router.refresh(); + } + + async function handleUnsuspend() { + if (!confirm("Buka kembali akun ini? User akan langsung bisa login.")) { + return; + } + setLoading(true); + setError(""); + const res = await unsuspendUserAction(userId); + setLoading(false); + if ("error" in res && res.error) { + setError(res.error); + return; + } + router.refresh(); + } + + if (isSuspended) { + return ( +
+ + {error && ( +
+ {error} +
+ )} +
+ ); + } + + if (!open) { + return ( + + ); + } + + return ( +
+
+ +