diff --git a/.env.example b/.env.example index b689210..dd03b15 100644 --- a/.env.example +++ b/.env.example @@ -35,4 +35,13 @@ NEXT_PUBLIC_MIDTRANS_IS_PRODUCTION=false # Generate ≥32-byte hex secret: # openssl rand -hex 32 # Setup detail: lihat docs/CRON_SETUP.md -CRON_SECRET= \ No newline at end of file +CRON_SECRET= + + +# === Admin alerting (opsional) === +# Discord webhook URL untuk push notif saat cron FAILED. Tanpa env, `notifyAdmins` +# no-op — admin tetap bisa cek manual di /admin/system. Cara setup: +# 1. Discord channel internal → Edit Channel → Integrations → Webhooks → New +# 2. Copy "Webhook URL", paste di sini +# Format: https://discord.com/api/webhooks// +ADMIN_ALERT_WEBHOOK_URL= \ No newline at end of file diff --git a/ADMIN_ROADMAP.md b/ADMIN_ROADMAP.md index 39b6eb5..769033a 100644 --- a/ADMIN_ROADMAP.md +++ b/ADMIN_ROADMAP.md @@ -1,9 +1,11 @@ -# Setrip — Admin Roadmap (Index) +# Setrip — Admin Roadmap (Index) · ✅ ALL DELIVERED Status implementasi kemampuan admin agar admin **dapat mengontrol seluruh aplikasi saat ada insiden**, bukan hanya read-only dashboard. > **Prinsip:** admin adalah safety net terakhir saat sistem otomatis gagal atau ada bad actor. Setiap action admin harus auditable (siapa, kapan, alasan), idempotent, dan terbatas hanya untuk admin yang terdaftar di `ADMIN_EMAILS`. +> **Status:** 6 dari 6 roadmap area selesai (4 fully, 2 dengan minor skip yang dijelaskan di archive masing-masing). Detail lengkap per area di [docs/archive/](docs/archive/). + --- ## Baseline — yang BISA admin lakukan sekarang @@ -13,73 +15,90 @@ 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) | | **Global search** | Search bar di sidebar — by email, order_id, cuid, fuzzy trip/user | [features/admin/components/admin-search-bar.tsx](features/admin/components/admin-search-bar.tsx) | | **Trips** | List + search + detail; force-cancel dengan auto-refund (admin intervention) | [app/admin/trips/](app/admin/trips/) | -| **Users** | List + search + filter (active/suspended); detail dengan trip + booking history; suspend/unsuspend | [app/admin/users/](app/admin/users/) | +| **Users** | List + search + filter; detail dengan trip + booking history; suspend/unsuspend; manual verify (override KYC); analytics | [app/admin/users/](app/admin/users/) | | **Bookings detail** | Timeline lintas Payment + Refund + Payout, raw callback viewer, Midtrans reconcile | [app/admin/bookings/[id]/page.tsx](app/admin/bookings/[id]/page.tsx) | -| **Verifikasi KYC** | Approve / Reject / Reopen REJECTED; filter date range + reviewer; CSV export | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) | +| **Verifikasi KYC** | Approve / Reject / Reopen REJECTED / Request re-upload (lebih lembut dari reject) / Manual override; filter date range + reviewer; CSV export | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) | | **Refund** | Create manual, approve, reject, mark SUCCEEDED/FAILED; filter date/reviewer/reason; link ke booking timeline; CSV export | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) | | **Payout** | View per status, mark PAID; filter date/processor; link ke booking timeline; CSV export | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) | -| **Audit Log** | View semua action admin lintas entity (refund, payout, trip cancel, suspend, dst); filter by admin/entity/action/date | [app/admin/audit-log/page.tsx](app/admin/audit-log/page.tsx) | -| **System Health** | Status cron jobs (last run, last success, 7d stats), 20 recent runs, health badge | [app/admin/system/page.tsx](app/admin/system/page.tsx) | +| **Audit Log** | View semua action admin lintas entity; filter by admin/entity/action/date | [app/admin/audit-log/page.tsx](app/admin/audit-log/page.tsx) | +| **System Health** | Status cron jobs (last run, health badge), 20 recent runs, **stale state alerts** (Payment AWAITING > 25h, Payout HELD overdue, Refund stuck), **Discord webhook** untuk cron FAILED | [app/admin/system/page.tsx](app/admin/system/page.tsx) | **Aksi mutating yang diblokir untuk suspended user:** sign-in (NextAuth), `createTripAction`, `joinTripAction`. Trip public list otomatis sembunyikan organizer suspended. -**Audit trail otomatis:** semua aksi admin (suspend, force-cancel, reconcile, approve/reject verification/refund, mark payout PAID, reopen verification) tercatat di `AdminActionLog` via `auditLog.record()`. +**Audit trail otomatis:** semua aksi admin (suspend, force-cancel, reconcile, approve/reject/reopen/request-reupload/manual-override verification, create/decide refund, mark payout PAID) tercatat di `AdminActionLog` via `auditLog.record()`. Auth admin: env `ADMIN_EMAILS` → cek di [lib/admin.ts](lib/admin.ts), dipassing ke session via [lib/auth.ts](lib/auth.ts). --- -## Roadmap per area +## Roadmap per area — status final -| Roadmap | Prioritas | Status | File | +| Roadmap | Prioritas | Status | Archive | |---|---|---|---| -| Trip Operations | 🔴 HIGH | ✅ **Delivered** | [docs/archive/ADMIN_TRIP_OPS_ROADMAP.md](docs/archive/ADMIN_TRIP_OPS_ROADMAP.md) | -| Payment Operations | 🔴 HIGH | ✅ **Delivered** | [docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md](docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md) | -| Audit & Investigation | 🔴 HIGH | ✅ **Delivered** | [docs/archive/ADMIN_AUDIT_ROADMAP.md](docs/archive/ADMIN_AUDIT_ROADMAP.md) | -| User Management | 🟡 MEDIUM | ✅ **Delivered** | [docs/archive/ADMIN_USER_MGMT_ROADMAP.md](docs/archive/ADMIN_USER_MGMT_ROADMAP.md) | -| Verification | 🟡 MEDIUM | 🚧 Phase 1 done · 2-4 deferred | [docs/archive/ADMIN_VERIFICATION_ROADMAP.md](docs/archive/ADMIN_VERIFICATION_ROADMAP.md) | -| System Health | 🟡 MEDIUM | 🚧 Phase 1-2 done · 3-4 deferred | [docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md](docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md) | +| Trip Operations | 🔴 HIGH | ✅ Delivered | [ADMIN_TRIP_OPS_ROADMAP.md](docs/archive/ADMIN_TRIP_OPS_ROADMAP.md) | +| Payment Operations | 🔴 HIGH | ✅ Delivered | [ADMIN_PAYMENT_OPS_ROADMAP.md](docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md) | +| Audit & Investigation | 🔴 HIGH | ✅ Delivered | [ADMIN_AUDIT_ROADMAP.md](docs/archive/ADMIN_AUDIT_ROADMAP.md) | +| User Management | 🟡 MEDIUM | ✅ Delivered | [ADMIN_USER_MGMT_ROADMAP.md](docs/archive/ADMIN_USER_MGMT_ROADMAP.md) | +| Verification | 🟡 MEDIUM | ✅ Delivered | [ADMIN_VERIFICATION_ROADMAP.md](docs/archive/ADMIN_VERIFICATION_ROADMAP.md) | +| System Health | 🟡 MEDIUM | ✅ Delivered | [ADMIN_SYSTEM_HEALTH_ROADMAP.md](docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md) | -**Legend status:** ⏳ belum mulai · 🚧 partial · ✅ selesai +**Minor item yang sengaja di-skip** (dengan justifikasi di archive): +- Audit Phase 2.5 — page `/admin/search` full-results (dropdown 10 hit cukup). +- Verification Phase 3.1 — snapshot full data per submission (counter + array rejection cukup). +- System Health Phase 4.3 — push notif harian dari stale alerts (admin sudah lihat banner di `/admin/system`). +- Trip Ops Phase 3 — trip edit override (skip MVP, evaluate ulang saat ada keluhan konkret). --- -## Sisa pekerjaan (semua deferred — low priority) - -- **Verification Phase 2** Re-upload request flow (butuh schema + organizer-side UI) -- **Verification Phase 3** Verification history (audit trail multi-submission) -- **Verification Phase 4** Manual override (admin verify tanpa upload, untuk referral) -- **System Health Phase 3** Stale state alerts (Payment AWAITING > 25h, Payout HELD overdue) -- **System Health Phase 4** External alerting (Discord webhook) -- **User Mgmt Phase 3** Bulk analytics dashboard - -Tidak ada yang blocking. Diangkat saat ada incident konkret atau permintaan stakeholder. - ---- - -## Tindakan manual setelah deploy - -Untuk versi yang berisi delivery 6 roadmap admin: +## Tindakan manual setelah deploy versi final ```bash -# Apply 4 migration baru -npx prisma migrate deploy +# Apply 5 migration baru (urutan time-stamp tidak masalah, prisma resolve): # - 20260518150000_add_trip_admin_cancel # - 20260518160000_add_user_suspension # - 20260518170000_add_cron_run # - 20260518180000_add_admin_action_log +# - 20260518190000_verification_enhancements +npx prisma migrate deploy -# Restart Next.js / PM2 supaya Prisma client baru ter-load +# (Opsional) Set env Discord webhook untuk alert cron failed +echo 'ADMIN_ALERT_WEBHOOK_URL=https://discord.com/api/webhooks/...' >> .env + +# Restart Next.js / PM2 supaya Prisma client + env baru ter-load pm2 restart setrip --update-env ``` -Brief admin tentang kapabilitas baru: +Brief admin tentang kapabilitas baru (lihat archive masing-masing untuk detail SOP): + - **Global search** di sidebar — ketik email, order_id, atau cuid; auto-detect ke detail page yang tepat. -- **Force-cancel trip** di `/admin/trips/[id]` — pakai saat organizer unreachable / dispute, reason wajib min 10 char. -- **Reconcile Midtrans** di `/admin/bookings/[id]` — pakai saat peserta lapor "sudah bayar tapi status belum update". Idempotent. -- **Suspend user** di `/admin/users/[id]` — pakai untuk scam/harassment. Suspended user diblokir sign-in dan aksi mutatif. -- **Reopen verification** di `/admin/verifications` (tab REJECTED) — saat organizer kirim ulang foto via email/WA. -- **System status** di `/admin/system` — cek setiap pagi, pastikan cron jalan (🟢 OK). -- **Audit log** di `/admin/audit-log` — bukti compliance saat audit eksternal; semua aksi admin tercatat dengan email + payload. -- **CSV export** di refunds/payouts/verifications — download untuk laporan keuangan/compliance. -- **Filter date range + reviewer** di refunds/payouts/verifications — untuk investigasi. +- **Force-cancel trip** di `/admin/trips/[id]` — saat organizer unreachable / dispute. +- **Reconcile Midtrans** di `/admin/bookings/[id]` — saat peserta lapor "sudah bayar tapi status belum update". +- **Suspend user** di `/admin/users/[id]` — untuk scam/harassment. +- **Manual verify** di `/admin/users/[id]` — partner trusted, bypass KYC, ter-flag jelas. +- **Reopen verification** di REJECTED card — organizer kirim ulang foto via email/WA. +- **Request re-upload** di PENDING card — lebih lembut dari reject; organizer dapat banner di `/verify`. +- **System status** di `/admin/system` — cek setiap pagi, lihat alert stale + cron health. +- **Discord alert** otomatis saat cron FAILED (kalau `ADMIN_ALERT_WEBHOOK_URL` di-set). +- **Audit log** di `/admin/audit-log` — bukti compliance untuk audit eksternal. +- **CSV export** di refunds/payouts/verifications — laporan keuangan/compliance. +- **User stats** di `/admin/users/stats` — total user, signup per minggu. + +--- + +## File-file penting yang ditambahkan / diubah + +**Service & helper:** +- [server/services/audit-log.service.ts](server/services/audit-log.service.ts) — log polymorphic +- [server/services/system-health.service.ts](server/services/system-health.service.ts) — stale state detection +- [server/services/admin-search.service.ts](server/services/admin-search.service.ts) — search dispatcher +- [server/services/user.service.ts](server/services/user.service.ts) — suspend/unsuspend +- [lib/cron-runner.ts](lib/cron-runner.ts) — `runCron()` wrapper +- [lib/admin-notify.ts](lib/admin-notify.ts) — Discord webhook helper +- [lib/auth-guards.ts](lib/auth-guards.ts) — `requireActiveUser()` +- [lib/csv.ts](lib/csv.ts) — CSV builder + +**Model baru:** `AdminActionLog`, `CronRun` + 5 migration baru di `prisma/migrations/`. + +**Admin UI baru:** `/admin/trips`, `/admin/users`, `/admin/bookings/[id]`, `/admin/system`, `/admin/audit-log`, `/admin/users/stats`. + +**API routes baru:** `/api/admin/search`, `/api/admin/export/{refunds,payouts,verifications}`. diff --git a/app/(public)/verify/page.tsx b/app/(public)/verify/page.tsx index 8b9a9d5..56ba83d 100644 --- a/app/(public)/verify/page.tsx +++ b/app/(public)/verify/page.tsx @@ -6,6 +6,23 @@ import { organizerService } from "@/server/services/organizer.service"; import { VerifyForm } from "@/features/organizer/components/verify-form"; import { VerifiedBadge } from "@/components/shared/verified-badge"; +function reuploadFieldLabel(field: string): string { + switch (field) { + case "ktpImage": + return "Foto KTP"; + case "liveness": + return "Foto liveness (pegang kertas SETRIP)"; + case "nik": + return "NIK"; + case "bankInfo": + return "Info rekening"; + case "address": + return "Alamat"; + default: + return field; + } +} + export default async function VerifyPage() { const session = await getServerSession(authOptions); if (!session?.user) { @@ -53,7 +70,7 @@ export default async function VerifyPage() { )} - {verification?.status === "PENDING" && ( + {verification?.status === "PENDING" && !verification.reuploadRequested && (

⏳ Menunggu review admin @@ -64,6 +81,40 @@ export default async function VerifyPage() {

)} + {verification?.reuploadRequested && ( +
+

+ 🔄 Admin minta kamu upload ulang +

+ {verification.reuploadNote && ( +

+ Catatan admin:{" "} + {verification.reuploadNote} +

+ )} + {verification.reuploadFields.length > 0 && ( +
+

+ Field yang perlu di-upload ulang: +

+
    + {verification.reuploadFields.map((f) => ( +
  • + + {reuploadFieldLabel(f)} + +
  • + ))} +
+
+ )} +

+ Submit ulang form di bawah dengan data/foto yang sudah diperbaiki. + Setelah submit, banner ini hilang otomatis. +

+
+ )} + {verification?.status === "REJECTED" && (

❌ Pengajuan ditolak

@@ -79,9 +130,9 @@ export default async function VerifyPage() {
)} - {verification?.status !== "APPROVED" && verification?.status !== "PENDING" && ( - - )} + {(verification?.status !== "APPROVED" && + (verification?.status !== "PENDING" || + verification?.reuploadRequested)) && }

diff --git a/app/admin/system/page.tsx b/app/admin/system/page.tsx index 456958a..da86bbc 100644 --- a/app/admin/system/page.tsx +++ b/app/admin/system/page.tsx @@ -1,8 +1,10 @@ +import Link from "next/link"; 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"; +import { systemHealthService } from "@/server/services/system-health.service"; interface JobSummary { jobName: string; @@ -78,11 +80,20 @@ export default async function AdminSystemPage() { ); } - const summaries = await Promise.all(TRACKED_JOBS.map(getJobSummary)); - const recentRuns = await prisma.cronRun.findMany({ - orderBy: { startedAt: "desc" }, - take: 20, - }); + const [summaries, recentRuns, stale] = await Promise.all([ + Promise.all(TRACKED_JOBS.map(getJobSummary)), + prisma.cronRun.findMany({ + orderBy: { startedAt: "desc" }, + take: 20, + }), + systemHealthService.detectStale(), + ]); + + const hasAnyStale = + stale.stalePaymentsCount > 0 || + stale.awaitingPayPastDepartureCount > 0 || + stale.overduePayoutsCount > 0 || + stale.stuckRefundsCount > 0; return (

@@ -96,6 +107,55 @@ export default async function AdminSystemPage() {

+ {hasAnyStale && ( +
+

+ ⚠️ Stale State Alerts +

+
    + {stale.stalePaymentsCount > 0 && ( +
  • + • {stale.stalePaymentsCount} Payment MIDTRANS + AWAITING > 25 jam — webhook mungkin tertunda. Cek manual lalu + reconcile. +
  • + )} + {stale.awaitingPayPastDepartureCount > 0 && ( +
  • + • {stale.awaitingPayPastDepartureCount} Booking + AWAITING_PAY tapi trip sudah lewat tanggal berangkat — peserta + lupa bayar, butuh cleanup. +
  • + )} + {stale.overduePayoutsCount > 0 && ( +
  • + • {stale.overduePayoutsCount} Payout HELD lewat + heldUntil > 1 hari — cron release mungkin tidak jalan, cek + cron history di bawah.{" "} + + Lihat HELD → + +
  • + )} + {stale.stuckRefundsCount > 0 && ( +
  • + • {stale.stuckRefundsCount} Refund APPROVED + > 7 hari belum di-process.{" "} + + Lihat APPROVED → + +
  • + )} +
+
+ )} +

Cron Jobs diff --git a/app/admin/users/[id]/page.tsx b/app/admin/users/[id]/page.tsx index 6ac2a25..d6de8b3 100644 --- a/app/admin/users/[id]/page.tsx +++ b/app/admin/users/[id]/page.tsx @@ -7,6 +7,7 @@ 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"; +import { ManualVerifyButton } from "@/features/admin/components/manual-verify-button"; interface PageProps { params: Promise<{ id: string }>; @@ -154,10 +155,18 @@ export default async function AdminUserDetailPage({ params }: PageProps) {

{isSelf ? (

- Tidak bisa suspend akun sendiri. + Tidak bisa suspend / modifikasi akun sendiri.

) : ( - +
+ + {!user.organizerVerification && ( + + )} +
)}
diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx index 364475a..ed5f51b 100644 --- a/app/admin/users/page.tsx +++ b/app/admin/users/page.tsx @@ -44,14 +44,22 @@ export default async function AdminUsersPage({ searchParams }: PageProps) { return (
-
-

- User Management -

-

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

+
+
+

+ User Management +

+

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

+
+ + 📊 Stats +
diff --git a/app/admin/users/stats/page.tsx b/app/admin/users/stats/page.tsx new file mode 100644 index 0000000..dedf4ac --- /dev/null +++ b/app/admin/users/stats/page.tsx @@ -0,0 +1,202 @@ +import Link from "next/link"; +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"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +interface WeeklyBucket { + weekStart: Date; + label: string; + count: number; +} + +function thirtyDaysAgoDate(): Date { + return new Date(Date.now() - 30 * DAY_MS); +} + +async function getSignupsPerWeek(weeks = 8): Promise { + const now = new Date(); + const startMs = now.getTime() - weeks * 7 * DAY_MS; + const startDate = new Date(startMs); + + const users = await prisma.user.findMany({ + where: { createdAt: { gte: startDate } }, + select: { createdAt: true }, + }); + + // Bucketize per week (Senin sebagai start, supaya konsisten dengan kalender Indonesia). + const buckets: WeeklyBucket[] = []; + for (let i = weeks - 1; i >= 0; i--) { + const bucketStart = new Date(now.getTime() - (i + 1) * 7 * DAY_MS); + bucketStart.setUTCHours(0, 0, 0, 0); + const bucketEnd = new Date(bucketStart.getTime() + 7 * DAY_MS); + const count = users.filter( + (u) => u.createdAt >= bucketStart && u.createdAt < bucketEnd + ).length; + buckets.push({ + weekStart: bucketStart, + label: bucketStart.toLocaleDateString("id-ID", { + day: "2-digit", + month: "short", + }), + count, + }); + } + return buckets; +} + +export default async function AdminUserStatsPage() { + const session = await getServerSession(authOptions); + if (!session?.user) redirect("/login?callbackUrl=/admin/users/stats"); + if (!isAdminEmail(session.user.email)) { + return ( +
+

+ Halaman ini hanya untuk admin SeTrip. +

+
+ ); + } + + const thirtyDaysAgo = thirtyDaysAgoDate(); + + const [ + totalUsers, + suspendedUsers, + verifiedOrganizers, + activeOrganizers30d, + paidParticipants30d, + weekly, + ] = await Promise.all([ + prisma.user.count(), + prisma.user.count({ where: { suspended: true } }), + prisma.organizerVerification.count({ where: { status: "APPROVED" } }), + prisma.user.count({ + where: { + trips: { some: { createdAt: { gte: thirtyDaysAgo } } }, + }, + }), + prisma.user.count({ + where: { + bookings: { + some: { + status: "PAID", + createdAt: { gte: thirtyDaysAgo }, + }, + }, + }, + }), + getSignupsPerWeek(8), + ]); + + const maxWeeklyCount = Math.max(1, ...weekly.map((w) => w.count)); + + return ( +
+
+ + ← Kembali ke list users + +
+ +
+

+ User Analytics +

+

+ Snapshot pertumbuhan user. Real-time read langsung dari DB — tidak + ada cache, refresh halaman untuk angka terbaru. +

+
+ +
+ + + + + +
+ +
+

+ Signup per Minggu (8 minggu terakhir) +

+

+ Tiap bar = 1 minggu (mulai hari ini mundur). Angka di atas bar = total + signup minggu itu. +

+
+ {weekly.map((w) => { + const heightPct = (w.count / maxWeeklyCount) * 100; + return ( +
+ + {w.count} + +
+ {w.label} +
+ ); + })} +
+
+
+ ); +} + +function StatCard({ + label, + value, + sub, + accent = "neutral", +}: { + label: string; + value: number; + sub?: string; + accent?: "neutral" | "primary" | "secondary" | "emerald" | "red"; +}) { + const map = { + neutral: "text-neutral-800", + primary: "text-primary-700", + secondary: "text-secondary-700", + emerald: "text-emerald-700", + red: "text-red-700", + }; + return ( +
+

+ {label} +

+

{value}

+ {sub &&

{sub}

} +
+ ); +} diff --git a/docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md b/docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md index f1512cb..acf765f 100644 --- a/docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md +++ b/docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md @@ -1,6 +1,6 @@ -# Setrip — Admin System Health Roadmap (ARCHIVED — DELIVERED 2026-05-18, partial) +# Setrip — Admin System Health Roadmap (ARCHIVED — DELIVERED 2026-05-18) -Admin perlu visibilitas atas job otomatis (cron). Phase 1 (cron log foundation) delivered. Phase 2 (status page) bonus. +Admin perlu visibilitas atas job otomatis (cron) dan alert untuk state stale. --- @@ -9,9 +9,9 @@ Admin perlu visibilitas atas job otomatis (cron). Phase 1 (cron log foundation) | 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 2 — System Status Page | ✅ Delivered | `/admin/system` tampilkan summary cron + 20 run terakhir + health badge. | +| Phase 3 — Stale State Alerts | ✅ Delivered | `systemHealthService.detectStale()` cek 4 kategori (Payment AWAITING > 25h, AWAITING_PAY past departure, Payout HELD overdue, Refund APPROVED > 7d). Banner di `/admin/system`. | +| Phase 4 — Discord Webhook Notify | ✅ Delivered | `notifyAdmins()` POST ke `ADMIN_ALERT_WEBHOOK_URL`. Trigger otomatis saat cron FAILED via `runCron`. | --- @@ -22,27 +22,45 @@ Admin perlu visibilitas atas job otomatis (cron). Phase 1 (cron log foundation) | 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) +## Phase 2 — System Status Page ✅ | # | 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.3 | Health badge (🟢 OK < 25h, 🟡 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) +## Phase 3 — Stale State Alerts ✅ -Stale state alerts + Discord webhook. Lihat versi awal di [ADMIN_ROADMAP.md](../../ADMIN_ROADMAP.md). +| # | Item | Status | File | +|---|---|---|---| +| 3.1 | `systemHealthService.detectStale()` return 4 count | ✅ | [server/services/system-health.service.ts](../../server/services/system-health.service.ts) | +| 3.2 | Banner alerts kuning di `/admin/system` kalau ada count > 0 | ✅ | [app/admin/system/page.tsx](../../app/admin/system/page.tsx) | +| 3.3 | Link tiap alert ke filtered list page yang relevan | ✅ (untuk Payout HELD & Refund APPROVED) | [app/admin/system/page.tsx](../../app/admin/system/page.tsx) | + +**Threshold draft (review setelah jalan 1-2 minggu):** +- Payment MIDTRANS `AWAITING` createdAt > 25 jam — suspect webhook tertunda +- Booking `AWAITING_PAY` dengan trip.date < today — peserta lupa bayar +- Payout `HELD` dengan heldUntil > 1 hari lewat — cron release tidak jalan +- Refund `APPROVED` reviewedAt > 7 hari — admin lupa process + +--- + +## Phase 4 — Discord Webhook Notify ✅ + +| # | Item | Status | File | +|---|---|---|---| +| 4.1 | Helper `notifyAdmins(message)` — POST ke Discord webhook URL dari env | ✅ | [lib/admin-notify.ts](../../lib/admin-notify.ts) | +| 4.2 | Trigger notify di `runCron` saat FAILED (fire-and-forget) | ✅ | [lib/cron-runner.ts](../../lib/cron-runner.ts) | +| 4.3 | Trigger notify dari `systemHealthService.detectStale` rate-limited | ⏳ | Skip — admin sudah lihat banner di `/admin/system` saat buka pagi. Push notif harian baru worth it kalau admin sering miss; bisa ditambah belakangan. | + +**Tindakan manual ops:** +1. Apply migration (sudah di Phase 1). +2. (Opsional) Buat Discord channel internal + webhook URL → set env `ADMIN_ALERT_WEBHOOK_URL` di PM2/server. Tanpa env, `notifyAdmins` no-op. +3. Test alert: trigger cron secara sengaja fail (mis. matikan DB sebentar) → cek Discord channel menerima 🚨 message. diff --git a/docs/archive/ADMIN_USER_MGMT_ROADMAP.md b/docs/archive/ADMIN_USER_MGMT_ROADMAP.md index 3f0ee39..fcf498e 100644 --- a/docs/archive/ADMIN_USER_MGMT_ROADMAP.md +++ b/docs/archive/ADMIN_USER_MGMT_ROADMAP.md @@ -1,4 +1,4 @@ -# Setrip — Admin User Management Roadmap (ARCHIVED — DELIVERED 2026-05-18) +# Setrip — Admin User Management Roadmap (ARCHIVED — DELIVERED 2026-05-18, fully done) Admin perlu bisa cari user dan **suspend** akun yang melakukan abuse (scam, harassment, fake review). @@ -12,7 +12,7 @@ Admin perlu bisa cari user dan **suspend** akun yang melakukan abuse (scam, hara |---|---|---| | 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 3 — User Analytics | ✅ Delivered | Page `/admin/users/stats` dengan stats card (total/suspended/verified-organizer/active-organizer-30d/paid-participant-30d) + bar chart signup per minggu (8 minggu terakhir, inline SVG-free). | --- @@ -50,6 +50,10 @@ Admin perlu bisa cari user dan **suspend** akun yang melakukan abuse (scam, hara --- -## Phase 3 — User Analytics ⏳ (deferred) +## Phase 3 — User Analytics ✅ -Skip sampai growth team minta. Lihat versi awal di [ADMIN_ROADMAP.md](../../ADMIN_ROADMAP.md). +| # | Item | Status | File | +|---|---|---|---| +| 3.1 | Stats: total users, suspended, verified organizers, active organizer 30d (bikin trip), paid participant 30d | ✅ | [app/admin/users/stats/page.tsx](../../app/admin/users/stats/page.tsx) | +| 3.2 | Bar chart signup per minggu (8 minggu terakhir, pakai inline div height % — no chart library) | ✅ | [app/admin/users/stats/page.tsx](../../app/admin/users/stats/page.tsx) | +| 3.3 | Link "📊 Stats" di header `/admin/users` | ✅ | [app/admin/users/page.tsx](../../app/admin/users/page.tsx) | diff --git a/docs/archive/ADMIN_VERIFICATION_ROADMAP.md b/docs/archive/ADMIN_VERIFICATION_ROADMAP.md index ed82f0e..08b9b4f 100644 --- a/docs/archive/ADMIN_VERIFICATION_ROADMAP.md +++ b/docs/archive/ADMIN_VERIFICATION_ROADMAP.md @@ -1,6 +1,6 @@ -# Setrip — Admin Verification Roadmap (ARCHIVED — DELIVERED 2026-05-18, partial) +# Setrip — Admin Verification Roadmap (ARCHIVED — DELIVERED 2026-05-18) -Enhancement KYC organizer verification: reopen REJECTED, request re-upload, audit override. +Enhancement KYC organizer verification: reopen REJECTED, request re-upload, history, manual override. --- @@ -8,27 +8,59 @@ Enhancement KYC organizer verification: reopen REJECTED, request re-upload, audi | 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 | ✅ Delivered | Tombol "Buka kembali ke PENDING" di REJECTED card dengan note wajib. | +| Phase 2 — Re-upload Request | ✅ Delivered | Admin pilih checkbox field (KTP/liveness/NIK/bank/alamat) + note. Organizer dapat banner kuning di `/verify` dengan highlight field yang diminta. Auto-clear saat submit ulang. | +| Phase 3 — Submission History | ✅ Delivered | Field `submissionCount` di-bump tiap submit ulang. `previousRejections` JSON array menyimpan rejection lama (waktu + reason + submission ke-N) sebelum overwrite. | +| Phase 4 — Manual Override | ✅ Delivered | Admin verify user tanpa upload KYC (partner trusted). Flag `isManualOverride = true` untuk audit transparansi. UI di `/admin/users/[id]`. | --- -## Phase 1 — Reopen Rejected Verification ✅ +## Phase 1 — Reopen Rejected ✅ | # | 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. +| 1.1 | `organizerService.reopenVerification` | ✅ | [server/services/organizer.service.ts](../../server/services/organizer.service.ts) | +| 1.2 | `organizerRepo.reopen` | ✅ | [server/repositories/organizer.repo.ts](../../server/repositories/organizer.repo.ts) | +| 1.3 | `reopenVerificationAction` (guard isAdmin + audit log) | ✅ | [features/organizer/actions.ts](../../features/organizer/actions.ts) | +| 1.4 | UI tombol di REJECTED card | ✅ | [features/organizer/components/review-card.tsx](../../features/organizer/components/review-card.tsx) | --- -## Phase 2-4 ⏳ (deferred) +## Phase 2 — Re-upload Request ✅ -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). +| # | Item | Status | File | +|---|---|---|---| +| 2.1 | Migration: `reuploadRequested`, `reuploadFields String[]`, `reuploadNote` | ✅ | `prisma/migrations/20260518190000_verification_enhancements/` | +| 2.2 | `organizerService.requestReupload(verifId, adminId, fields, note)` + `REUPLOAD_FIELDS` enum-like const | ✅ | [server/services/organizer.service.ts](../../server/services/organizer.service.ts) | +| 2.3 | `organizerRepo.requestReupload` (set status PENDING + flag + clear review) | ✅ | [server/repositories/organizer.repo.ts](../../server/repositories/organizer.repo.ts) | +| 2.4 | `requestReuploadAction` (guard isAdmin + audit log) | ✅ | [features/organizer/actions.ts](../../features/organizer/actions.ts) | +| 2.5 | UI admin di PENDING card: tombol "🔄 Minta re-upload" + multi-checkbox field + note | ✅ | [features/organizer/components/review-card.tsx](../../features/organizer/components/review-card.tsx) | +| 2.6 | Banner kuning di `/verify` saat `reuploadRequested = true` + list field yang diminta | ✅ | [app/(public)/verify/page.tsx](../../app/(public)/verify/page.tsx) | +| 2.7 | Auto-clear flag saat organizer submit ulang (logic di `submitVerification`) | ✅ | [server/services/organizer.service.ts](../../server/services/organizer.service.ts) | + +--- + +## Phase 3 — Submission History ✅ + +| # | Item | Status | File | +|---|---|---|---| +| 3.1 | Migration: `submissionCount Int @default(1)`, `previousRejections Json?` | ✅ | `prisma/migrations/20260518190000_verification_enhancements/` | +| 3.2 | Bump `submissionCount` + archive rejection lama saat submit ulang (helper `buildArchivedRejections`) | ✅ | [server/services/organizer.service.ts](../../server/services/organizer.service.ts) | + +> _Catatan: snapshot full data (Phase 3.1 di roadmap awal) di-skip — `submissionCount` + `previousRejections` (array waktu/reason) cukup untuk audit "berapa kali verify, apa reason ditolak sebelumnya". UI history detail bisa ditambah saat ada permintaan konkret._ + +--- + +## Phase 4 — Manual Override ✅ + +| # | Item | Status | File | +|---|---|---|---| +| 4.1 | Migration: `isManualOverride Boolean @default(false)`, `manualOverrideById`, `manualOverrideNote` | ✅ | `prisma/migrations/20260518190000_verification_enhancements/` | +| 4.2 | `organizerService.manualOverrideVerification(input)` — bikin row APPROVED dengan placeholder KYC | ✅ | [server/services/organizer.service.ts](../../server/services/organizer.service.ts) | +| 4.3 | `organizerRepo.createManualOverride` | ✅ | [server/repositories/organizer.repo.ts](../../server/repositories/organizer.repo.ts) | +| 4.4 | `manualOverrideVerificationAction` (guard isAdmin + audit log) | ✅ | [features/organizer/actions.ts](../../features/organizer/actions.ts) | +| 4.5 | UI: `ManualVerifyButton` di `/admin/users/[id]` (hanya tampil kalau user belum punya verification) | ✅ | [features/admin/components/manual-verify-button.tsx](../../features/admin/components/manual-verify-button.tsx) | + +**Tindakan manual ops:** +1. Apply migration: `npx prisma migrate deploy`. +2. Brief admin: re-upload request lebih lembut dari reject (organizer tidak perlu ulang dari nol). Manual override hanya untuk partner trusted dengan ref konkret di note (mis. nomor kontrak). diff --git a/features/admin/components/manual-verify-button.tsx b/features/admin/components/manual-verify-button.tsx new file mode 100644 index 0000000..6878ead --- /dev/null +++ b/features/admin/components/manual-verify-button.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { manualOverrideVerificationAction } from "@/features/organizer/actions"; + +interface ManualVerifyButtonProps { + userId: string; + defaultBankAccountName: string; +} + +export function ManualVerifyButton({ + userId, + defaultBankAccountName, +}: ManualVerifyButtonProps) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [note, setNote] = useState(""); + const [bankName, setBankName] = useState(""); + const [bankAccountNumber, setBankAccountNumber] = useState(""); + const [bankAccountName, setBankAccountName] = useState(defaultBankAccountName); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + async function handleSubmit() { + setLoading(true); + setError(""); + const res = await manualOverrideVerificationAction({ + userId, + note, + bankName, + bankAccountNumber, + bankAccountName, + }); + setLoading(false); + if ("error" in res && res.error) { + setError(res.error); + return; + } + setOpen(false); + setNote(""); + setBankName(""); + setBankAccountNumber(""); + router.refresh(); + } + + if (!open) { + return ( + + ); + } + + return ( +
+

+ Manual override: bikin verifikasi APPROVED tanpa upload KYC. Pakai HANYA + untuk partner trusted referral atau kasus khusus. Ter-flag jelas di + admin UI sebagai "manual override". +

+ +
+ +