Compare commits
2 Commits
b844ebdfac
...
f0ce22bbb8
| Author | SHA1 | Date | |
|---|---|---|---|
| f0ce22bbb8 | |||
| bc4973a594 |
@@ -36,3 +36,12 @@ NEXT_PUBLIC_MIDTRANS_IS_PRODUCTION=false
|
||||
# openssl rand -hex 32
|
||||
# Setup detail: lihat docs/CRON_SETUP.md
|
||||
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/<id>/<token>
|
||||
ADMIN_ALERT_WEBHOOK_URL=
|
||||
+62
-43
@@ -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}`.
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{verification?.status === "PENDING" && (
|
||||
{verification?.status === "PENDING" && !verification.reuploadRequested && (
|
||||
<div className="mb-6 rounded-2xl border border-amber-200 bg-amber-50 p-5">
|
||||
<p className="mb-1 text-sm font-bold text-amber-800">
|
||||
⏳ Menunggu review admin
|
||||
@@ -64,6 +81,40 @@ export default async function VerifyPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{verification?.reuploadRequested && (
|
||||
<div className="mb-6 rounded-2xl border-2 border-amber-400 bg-amber-50 p-5">
|
||||
<p className="mb-1 text-sm font-bold text-amber-900">
|
||||
🔄 Admin minta kamu upload ulang
|
||||
</p>
|
||||
{verification.reuploadNote && (
|
||||
<p className="mb-3 text-sm text-neutral-700">
|
||||
<span className="font-semibold">Catatan admin:</span>{" "}
|
||||
{verification.reuploadNote}
|
||||
</p>
|
||||
)}
|
||||
{verification.reuploadFields.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<p className="mb-1 text-xs font-semibold text-amber-900">
|
||||
Field yang perlu di-upload ulang:
|
||||
</p>
|
||||
<ul className="ml-4 list-disc text-xs text-neutral-700">
|
||||
{verification.reuploadFields.map((f) => (
|
||||
<li key={f}>
|
||||
<span className="font-semibold">
|
||||
{reuploadFieldLabel(f)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-neutral-700">
|
||||
Submit ulang form di bawah dengan data/foto yang sudah diperbaiki.
|
||||
Setelah submit, banner ini hilang otomatis.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{verification?.status === "REJECTED" && (
|
||||
<div className="mb-6 rounded-2xl border border-red-200 bg-red-50 p-5">
|
||||
<p className="mb-1 text-sm font-bold text-red-800">❌ Pengajuan ditolak</p>
|
||||
@@ -79,9 +130,9 @@ export default async function VerifyPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{verification?.status !== "APPROVED" && verification?.status !== "PENDING" && (
|
||||
<VerifyForm initial={initial} />
|
||||
)}
|
||||
{(verification?.status !== "APPROVED" &&
|
||||
(verification?.status !== "PENDING" ||
|
||||
verification?.reuploadRequested)) && <VerifyForm initial={initial} />}
|
||||
|
||||
<p className="mt-6 text-center text-sm text-neutral-500">
|
||||
<Link href="/profile" className="hover:text-primary-600">
|
||||
|
||||
@@ -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({
|
||||
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 (
|
||||
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
|
||||
@@ -96,6 +107,55 @@ export default async function AdminSystemPage() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{hasAnyStale && (
|
||||
<section className="mb-6 rounded-2xl border border-amber-300 bg-amber-50 p-4 sm:p-5">
|
||||
<h2 className="mb-2 text-sm font-bold text-amber-900">
|
||||
⚠️ Stale State Alerts
|
||||
</h2>
|
||||
<ul className="space-y-1 text-xs text-amber-900">
|
||||
{stale.stalePaymentsCount > 0 && (
|
||||
<li>
|
||||
• <strong>{stale.stalePaymentsCount}</strong> Payment MIDTRANS
|
||||
AWAITING > 25 jam — webhook mungkin tertunda. Cek manual lalu
|
||||
reconcile.
|
||||
</li>
|
||||
)}
|
||||
{stale.awaitingPayPastDepartureCount > 0 && (
|
||||
<li>
|
||||
• <strong>{stale.awaitingPayPastDepartureCount}</strong> Booking
|
||||
AWAITING_PAY tapi trip sudah lewat tanggal berangkat — peserta
|
||||
lupa bayar, butuh cleanup.
|
||||
</li>
|
||||
)}
|
||||
{stale.overduePayoutsCount > 0 && (
|
||||
<li>
|
||||
• <strong>{stale.overduePayoutsCount}</strong> Payout HELD lewat
|
||||
heldUntil > 1 hari — cron release mungkin tidak jalan, cek
|
||||
cron history di bawah.{" "}
|
||||
<Link
|
||||
href="/admin/payouts?tab=HELD"
|
||||
className="font-semibold text-amber-700 hover:underline"
|
||||
>
|
||||
Lihat HELD →
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{stale.stuckRefundsCount > 0 && (
|
||||
<li>
|
||||
• <strong>{stale.stuckRefundsCount}</strong> Refund APPROVED
|
||||
> 7 hari belum di-process.{" "}
|
||||
<Link
|
||||
href="/admin/refunds?tab=APPROVED"
|
||||
className="font-semibold text-amber-700 hover:underline"
|
||||
>
|
||||
Lihat APPROVED →
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
|
||||
Cron Jobs
|
||||
|
||||
@@ -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) {
|
||||
</h2>
|
||||
{isSelf ? (
|
||||
<p className="text-xs text-neutral-500">
|
||||
Tidak bisa suspend akun sendiri.
|
||||
Tidak bisa suspend / modifikasi akun sendiri.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-start gap-3">
|
||||
<SuspendUserButton userId={user.id} isSuspended={user.suspended} />
|
||||
{!user.organizerVerification && (
|
||||
<ManualVerifyButton
|
||||
userId={user.id}
|
||||
defaultBankAccountName={user.name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
|
||||
@@ -44,7 +44,8 @@ export default async function AdminUsersPage({ searchParams }: PageProps) {
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
|
||||
<header className="mb-6">
|
||||
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||
User Management
|
||||
</h1>
|
||||
@@ -52,6 +53,13 @@ export default async function AdminUsersPage({ searchParams }: PageProps) {
|
||||
Cari user, lihat history booking & trip, dan suspend akun yang
|
||||
melakukan abuse (scam, harassment, TOS violation).
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/users/stats"
|
||||
className="rounded-xl border border-neutral-200 bg-white px-3 py-1.5 text-xs font-semibold text-neutral-700 hover:bg-neutral-50"
|
||||
>
|
||||
📊 Stats
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<form method="get" className="mb-4 flex gap-2">
|
||||
|
||||
@@ -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<WeeklyBucket[]> {
|
||||
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 (
|
||||
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
|
||||
<p className="text-sm text-neutral-600">
|
||||
Halaman ini hanya untuk admin SeTrip.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const 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 (
|
||||
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
|
||||
<div className="mb-4 text-xs text-neutral-500">
|
||||
<Link href="/admin/users" className="hover:text-primary-600">
|
||||
← Kembali ke list users
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||
User Analytics
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
Snapshot pertumbuhan user. Real-time read langsung dari DB — tidak
|
||||
ada cache, refresh halaman untuk angka terbaru.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="mb-8 grid gap-3 sm:grid-cols-3">
|
||||
<StatCard label="Total Users" value={totalUsers} />
|
||||
<StatCard
|
||||
label="Suspended"
|
||||
value={suspendedUsers}
|
||||
accent="red"
|
||||
/>
|
||||
<StatCard
|
||||
label="Verified Organizers"
|
||||
value={verifiedOrganizers}
|
||||
accent="emerald"
|
||||
/>
|
||||
<StatCard
|
||||
label="Organizer Aktif (30 hari)"
|
||||
value={activeOrganizers30d}
|
||||
accent="secondary"
|
||||
sub="Bikin trip baru"
|
||||
/>
|
||||
<StatCard
|
||||
label="Peserta Aktif (30 hari)"
|
||||
value={paidParticipants30d}
|
||||
accent="primary"
|
||||
sub="Booking PAID"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-6">
|
||||
<h2 className="mb-1 text-sm font-bold text-neutral-900">
|
||||
Signup per Minggu (8 minggu terakhir)
|
||||
</h2>
|
||||
<p className="mb-4 text-xs text-neutral-500">
|
||||
Tiap bar = 1 minggu (mulai hari ini mundur). Angka di atas bar = total
|
||||
signup minggu itu.
|
||||
</p>
|
||||
<div className="flex h-48 items-end gap-2">
|
||||
{weekly.map((w) => {
|
||||
const heightPct = (w.count / maxWeeklyCount) * 100;
|
||||
return (
|
||||
<div
|
||||
key={w.weekStart.toISOString()}
|
||||
className="flex flex-1 flex-col items-center gap-1"
|
||||
>
|
||||
<span className="text-[10px] font-semibold text-neutral-700">
|
||||
{w.count}
|
||||
</span>
|
||||
<div
|
||||
className="w-full rounded-t-md bg-primary-500/80"
|
||||
style={{ height: `${Math.max(heightPct, 2)}%` }}
|
||||
title={`${w.count} signup minggu ${w.label}`}
|
||||
/>
|
||||
<span className="text-[10px] text-neutral-500">{w.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="rounded-xl border border-neutral-200 bg-white p-4 shadow-sm">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||
{label}
|
||||
</p>
|
||||
<p className={`mt-0.5 text-2xl font-bold ${map[accent]}`}>{value}</p>
|
||||
{sub && <p className="text-[11px] text-neutral-500">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="rounded-xl border border-secondary-300 bg-white px-4 py-2 text-sm font-bold text-secondary-700 hover:bg-secondary-50"
|
||||
>
|
||||
🔒 Manual verify (tanpa KYC)
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-xl border border-secondary-200 bg-secondary-50/60 p-4">
|
||||
<p className="text-xs text-secondary-900">
|
||||
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".
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-secondary-900">
|
||||
Alasan / referensi (min 10 char)
|
||||
</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
maxLength={500}
|
||||
placeholder="contoh: Partner referral dari acara X, kontrak signed #PR-2026-15."
|
||||
className="w-full rounded-xl border border-secondary-200 bg-white px-3 py-2 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-secondary-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<input
|
||||
type="text"
|
||||
value={bankName}
|
||||
onChange={(e) => setBankName(e.target.value)}
|
||||
placeholder="Nama bank"
|
||||
className="rounded-lg border border-secondary-200 bg-white px-3 py-1.5 text-sm focus:border-secondary-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={bankAccountNumber}
|
||||
onChange={(e) => setBankAccountNumber(e.target.value)}
|
||||
placeholder="Nomor rekening"
|
||||
className="rounded-lg border border-secondary-200 bg-white px-3 py-1.5 text-sm focus:border-secondary-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={bankAccountName}
|
||||
onChange={(e) => setBankAccountName(e.target.value)}
|
||||
placeholder="Atas nama"
|
||||
className="rounded-lg border border-secondary-200 bg-white px-3 py-1.5 text-sm focus:border-secondary-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-100 px-3 py-2 text-xs font-medium text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
loading ||
|
||||
note.trim().length < 10 ||
|
||||
!bankName.trim() ||
|
||||
!bankAccountNumber.trim() ||
|
||||
!bankAccountName.trim()
|
||||
}
|
||||
className="rounded-xl bg-secondary-600 px-4 py-2 text-sm font-bold text-white hover:bg-secondary-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Memproses..." : "Konfirmasi Manual Verify"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setNote("");
|
||||
}}
|
||||
className="rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-50"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,11 @@ import { getServerSession } from "next-auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { isAdminEmail } from "@/lib/admin";
|
||||
import { organizerService } from "@/server/services/organizer.service";
|
||||
import {
|
||||
isReuploadField,
|
||||
organizerService,
|
||||
type ReuploadField,
|
||||
} from "@/server/services/organizer.service";
|
||||
import { auditLog } from "@/server/services/audit-log.service";
|
||||
import { submitVerificationSchema, reviewVerificationSchema } from "./schemas";
|
||||
|
||||
@@ -125,3 +129,81 @@ export async function reopenVerificationAction(
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2: admin minta organizer upload ulang field tertentu — daripada
|
||||
* reject penuh, set flag `reuploadRequested` + daftar field + note.
|
||||
*/
|
||||
export async function requestReuploadAction(
|
||||
verificationId: string,
|
||||
fields: string[],
|
||||
note: string
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user || !isAdminEmail(session.user.email)) {
|
||||
return { error: "Tidak memiliki akses admin" };
|
||||
}
|
||||
const valid = fields.filter(isReuploadField) as ReuploadField[];
|
||||
|
||||
try {
|
||||
await organizerService.requestReupload({
|
||||
verificationId,
|
||||
adminId: session.user.id,
|
||||
fields: valid,
|
||||
note,
|
||||
});
|
||||
await auditLog.record({
|
||||
admin: { id: session.user.id, email: session.user.email },
|
||||
action: "VERIFICATION_REQUEST_REUPLOAD",
|
||||
entityType: "OrganizerVerification",
|
||||
entityId: verificationId,
|
||||
payload: { fields: valid, note: note.trim() },
|
||||
});
|
||||
revalidatePath("/admin/verifications");
|
||||
revalidatePath("/verify");
|
||||
return { success: true as const };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 4: admin verify user tanpa upload KYC (partner trusted referral).
|
||||
* Bikin row APPROVED dengan flag `isManualOverride = true`.
|
||||
*/
|
||||
export async function manualOverrideVerificationAction(input: {
|
||||
userId: string;
|
||||
note: string;
|
||||
bankName: string;
|
||||
bankAccountNumber: string;
|
||||
bankAccountName: string;
|
||||
}) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user || !isAdminEmail(session.user.email)) {
|
||||
return { error: "Tidak memiliki akses admin" };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await organizerService.manualOverrideVerification({
|
||||
userId: input.userId,
|
||||
adminId: session.user.id,
|
||||
note: input.note,
|
||||
bankName: input.bankName,
|
||||
bankAccountNumber: input.bankAccountNumber,
|
||||
bankAccountName: input.bankAccountName,
|
||||
});
|
||||
await auditLog.record({
|
||||
admin: { id: session.user.id, email: session.user.email },
|
||||
action: "VERIFICATION_MANUAL_OVERRIDE",
|
||||
entityType: "OrganizerVerification",
|
||||
entityId: result.id,
|
||||
payload: { userId: input.userId, note: input.note.trim() },
|
||||
});
|
||||
revalidatePath("/admin/verifications");
|
||||
revalidatePath(`/admin/users/${input.userId}`);
|
||||
revalidatePath("/verify");
|
||||
return { success: true as const, verificationId: result.id };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,18 @@ import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
reopenVerificationAction,
|
||||
requestReuploadAction,
|
||||
reviewVerificationAction,
|
||||
} from "@/features/organizer/actions";
|
||||
|
||||
const REUPLOAD_FIELD_LABELS: { value: string; label: string }[] = [
|
||||
{ value: "ktpImage", label: "Foto KTP" },
|
||||
{ value: "liveness", label: "Foto liveness (pegang kertas SETRIP)" },
|
||||
{ value: "nik", label: "NIK" },
|
||||
{ value: "bankInfo", label: "Info rekening" },
|
||||
{ value: "address", label: "Alamat" },
|
||||
];
|
||||
|
||||
type Verification = {
|
||||
id: string;
|
||||
fullName: string;
|
||||
@@ -37,11 +46,41 @@ export function ReviewCard({ verification }: { verification: Verification }) {
|
||||
const router = useRouter();
|
||||
const [showReject, setShowReject] = useState(false);
|
||||
const [showReopen, setShowReopen] = useState(false);
|
||||
const [showReupload, setShowReupload] = useState(false);
|
||||
const [rejectionReason, setRejectionReason] = useState("");
|
||||
const [reopenNote, setReopenNote] = useState("");
|
||||
const [reuploadNote, setReuploadNote] = useState("");
|
||||
const [reuploadFields, setReuploadFields] = useState<string[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
function toggleReuploadField(value: string) {
|
||||
setReuploadFields((prev) =>
|
||||
prev.includes(value)
|
||||
? prev.filter((v) => v !== value)
|
||||
: [...prev, value]
|
||||
);
|
||||
}
|
||||
|
||||
async function requestReupload() {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
const result = await requestReuploadAction(
|
||||
verification.id,
|
||||
reuploadFields,
|
||||
reuploadNote
|
||||
);
|
||||
setLoading(false);
|
||||
if ("error" in result && result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
setShowReupload(false);
|
||||
setReuploadFields([]);
|
||||
setReuploadNote("");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function decide(decision: "APPROVED" | "REJECTED") {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
@@ -192,26 +231,7 @@ export function ReviewCard({ verification }: { verification: Verification }) {
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!showReject ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => decide("APPROVED")}
|
||||
disabled={loading}
|
||||
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
✅ Setujui
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowReject(true)}
|
||||
disabled={loading}
|
||||
className="rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
❌ Tolak
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
{showReject ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={rejectionReason}
|
||||
@@ -241,6 +261,102 @@ export function ReviewCard({ verification }: { verification: Verification }) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : showReupload ? (
|
||||
<div className="space-y-3 rounded-xl border border-amber-200 bg-amber-50/60 p-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-amber-900">
|
||||
Field yang perlu di-upload ulang (min 1)
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{REUPLOAD_FIELD_LABELS.map((f) => {
|
||||
const checked = reuploadFields.includes(f.value);
|
||||
return (
|
||||
<label
|
||||
key={f.value}
|
||||
className={`cursor-pointer rounded-full border px-3 py-1 text-xs font-medium ${
|
||||
checked
|
||||
? "border-amber-600 bg-amber-600 text-white"
|
||||
: "border-amber-200 bg-white text-amber-800 hover:bg-amber-100"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleReuploadField(f.value)}
|
||||
className="sr-only"
|
||||
/>
|
||||
{f.label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-amber-900">
|
||||
Catatan untuk organizer (min 10 char — akan dilihat user)
|
||||
</label>
|
||||
<textarea
|
||||
value={reuploadNote}
|
||||
onChange={(e) => setReuploadNote(e.target.value)}
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
placeholder="contoh: Foto KTP terlalu buram, tolong foto ulang dengan pencahayaan lebih baik."
|
||||
className="w-full rounded-xl border border-amber-200 bg-white px-3 py-2 text-sm focus:border-amber-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={requestReupload}
|
||||
disabled={
|
||||
loading ||
|
||||
reuploadFields.length === 0 ||
|
||||
reuploadNote.trim().length < 10
|
||||
}
|
||||
className="rounded-xl bg-amber-600 px-4 py-2 text-sm font-bold text-white hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Memproses..." : "Kirim Permintaan"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowReupload(false);
|
||||
setReuploadFields([]);
|
||||
setReuploadNote("");
|
||||
}}
|
||||
className="rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-50"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => decide("APPROVED")}
|
||||
disabled={loading}
|
||||
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
✅ Setujui
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowReupload(true)}
|
||||
disabled={loading}
|
||||
className="rounded-xl border border-amber-300 bg-white px-4 py-2 text-sm font-bold text-amber-700 hover:bg-amber-50 disabled:opacity-50"
|
||||
>
|
||||
🔄 Minta re-upload
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowReject(true)}
|
||||
disabled={loading}
|
||||
className="rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
❌ Tolak
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Push notif eksternal untuk admin saat ada cron FAILED atau alert critical.
|
||||
* Saat ini support Discord webhook (paling simple). Fail silent — kalau env
|
||||
* tidak di-set, no-op. Kalau request gagal, console.error tapi tidak throw.
|
||||
*
|
||||
* Env `ADMIN_ALERT_WEBHOOK_URL` — Discord channel webhook URL.
|
||||
*/
|
||||
|
||||
export async function notifyAdmins(message: string): Promise<void> {
|
||||
const webhookUrl = process.env.ADMIN_ALERT_WEBHOOK_URL;
|
||||
if (!webhookUrl) return;
|
||||
|
||||
try {
|
||||
await fetch(webhookUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
// Format Discord webhook: `content` adalah body message plaintext/markdown.
|
||||
content: `🚨 **SeTrip Admin Alert**\n${message}`,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[admin-notify] gagal kirim ke webhook", err);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { notifyAdmins } from "@/lib/admin-notify";
|
||||
|
||||
/**
|
||||
* Wrapper untuk cron route handler — otomatis log start/finish/error ke
|
||||
@@ -62,6 +63,11 @@ export async function runCron<T>(
|
||||
})
|
||||
.catch((e) => console.error(`[cron-runner] gagal update FAILED`, e));
|
||||
}
|
||||
// Fire-and-forget — jangan blok response cron route handler. Notif gagal
|
||||
// di-log sendiri di `notifyAdmins`.
|
||||
void notifyAdmins(
|
||||
`Cron \`${jobName}\` FAILED: ${message}`
|
||||
);
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "setrip",
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "setrip",
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0",
|
||||
"dependencies": {
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
"@prisma/adapter-pg": "^7.7.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "setrip",
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
-- AlterTable: tambah dukungan re-upload request, submission history,
|
||||
-- dan manual override admin untuk OrganizerVerification.
|
||||
--
|
||||
-- Phase 2 (re-upload request): admin minta organizer upload ulang field
|
||||
-- tertentu tanpa drop & recreate row.
|
||||
ALTER TABLE "OrganizerVerification"
|
||||
ADD COLUMN "reuploadRequested" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "reuploadFields" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
ADD COLUMN "reuploadNote" TEXT;
|
||||
|
||||
-- Phase 3 (submission history): track jumlah submission + arsip rejection
|
||||
-- reason setiap kali REJECTED (sebelum di-overwrite saat submit ulang).
|
||||
-- `previousRejections` = array string `[{"at": "...", "reason": "..."}]`
|
||||
-- yang di-append manual oleh service saat reject baru terjadi.
|
||||
ALTER TABLE "OrganizerVerification"
|
||||
ADD COLUMN "submissionCount" INTEGER NOT NULL DEFAULT 1,
|
||||
ADD COLUMN "previousRejections" JSONB;
|
||||
|
||||
-- Phase 4 (manual override): flag bahwa verifikasi dibuat admin tanpa
|
||||
-- upload KYC (mis. referral dari partner trusted). Untuk audit transparansi.
|
||||
ALTER TABLE "OrganizerVerification"
|
||||
ADD COLUMN "isManualOverride" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "manualOverrideById" TEXT,
|
||||
ADD COLUMN "manualOverrideNote" TEXT;
|
||||
|
||||
-- FK admin yang manual-override — SET NULL kalau admin dihapus.
|
||||
ALTER TABLE "OrganizerVerification" ADD CONSTRAINT
|
||||
"OrganizerVerification_manualOverrideById_fkey"
|
||||
FOREIGN KEY ("manualOverrideById") REFERENCES "User"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -38,6 +38,7 @@ model User {
|
||||
|
||||
organizerVerification OrganizerVerification? @relation("OrganizerVerificationOwner")
|
||||
reviewedVerifications OrganizerVerification[] @relation("OrganizerVerificationReviewer")
|
||||
manualOverrideVerifications OrganizerVerification[] @relation("OrganizerVerificationManualOverride")
|
||||
|
||||
reviewedRefunds Refund[] @relation("RefundReviewer")
|
||||
|
||||
@@ -135,6 +136,24 @@ model OrganizerVerification {
|
||||
reviewedBy User? @relation("OrganizerVerificationReviewer", fields: [reviewedById], references: [id])
|
||||
verifiedAt DateTime?
|
||||
|
||||
/// Phase 2: admin minta organizer upload ulang field tertentu. Saat true,
|
||||
/// organizer page /verify menampilkan banner kuning + highlight field
|
||||
/// yang diminta. Auto-clear saat organizer submit ulang.
|
||||
reuploadRequested Boolean @default(false)
|
||||
reuploadFields String[] @default([])
|
||||
reuploadNote String?
|
||||
|
||||
/// Phase 3: jumlah submission ulang + arsip rejection sebelumnya
|
||||
/// (di-append saat REJECTED baru, supaya history tidak ke-overwrite).
|
||||
submissionCount Int @default(1)
|
||||
previousRejections Json?
|
||||
|
||||
/// Phase 4: admin verify manual tanpa upload KYC (mis. partner trusted).
|
||||
isManualOverride Boolean @default(false)
|
||||
manualOverrideById String?
|
||||
manualOverrideBy User? @relation("OrganizerVerificationManualOverride", fields: [manualOverrideById], references: [id], onDelete: SetNull)
|
||||
manualOverrideNote String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
@@ -73,6 +73,69 @@ export const organizerRepo = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Phase 2: minta organizer upload ulang field tertentu. Reset status ke
|
||||
* PENDING tapi sengaja TIDAK clear data lama (organizer ganti field saat
|
||||
* submit ulang via /verify). Service `submitVerification` auto-clear flag.
|
||||
*/
|
||||
async requestReupload(
|
||||
id: string,
|
||||
data: { fields: string[]; note: string }
|
||||
) {
|
||||
return prisma.organizerVerification.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: "PENDING",
|
||||
reuploadRequested: true,
|
||||
reuploadFields: data.fields,
|
||||
reuploadNote: data.note,
|
||||
// Clear review state supaya muncul lagi di tab PENDING.
|
||||
reviewedById: null,
|
||||
reviewedAt: null,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Phase 4: bikin verifikasi APPROVED tanpa upload KYC (manual override admin).
|
||||
* Placeholder NIK & no image keys — `isManualOverride = true` jadi marker.
|
||||
*/
|
||||
async createManualOverride(input: {
|
||||
userId: string;
|
||||
adminId: string;
|
||||
note: string;
|
||||
fullName: string;
|
||||
nikEncrypted: string;
|
||||
nikHash: string;
|
||||
bankName: string;
|
||||
bankAccountNumber: string;
|
||||
bankAccountName: string;
|
||||
verifiedAt: Date;
|
||||
}) {
|
||||
return prisma.organizerVerification.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
fullName: input.fullName,
|
||||
nikEncrypted: input.nikEncrypted,
|
||||
nikHash: input.nikHash,
|
||||
birthDate: new Date("1970-01-01"),
|
||||
address: "(manual override — tidak diisi)",
|
||||
ktpImageKey: "(manual override)",
|
||||
livenessKey: "(manual override)",
|
||||
bankName: input.bankName,
|
||||
bankAccountNumber: input.bankAccountNumber,
|
||||
bankAccountName: input.bankAccountName,
|
||||
status: "APPROVED",
|
||||
verifiedAt: input.verifiedAt,
|
||||
reviewedById: input.adminId,
|
||||
reviewedAt: input.verifiedAt,
|
||||
isManualOverride: true,
|
||||
manualOverrideById: input.adminId,
|
||||
manualOverrideNote: input.note,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Reopen pengajuan REJECTED ke PENDING. Simpan rejection reason lama
|
||||
* sebagai catatan history (di-overwrite kalau di-reject lagi nanti).
|
||||
|
||||
@@ -1,6 +1,60 @@
|
||||
import { Prisma } from "@/app/generated/prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { organizerRepo } from "@/server/repositories/organizer.repo";
|
||||
import { decryptString, encryptString, hmacHex } from "@/lib/crypto";
|
||||
|
||||
/** Field-field yang admin boleh minta upload ulang (Phase 2). */
|
||||
export const REUPLOAD_FIELDS = [
|
||||
"ktpImage",
|
||||
"liveness",
|
||||
"nik",
|
||||
"bankInfo",
|
||||
"address",
|
||||
] as const;
|
||||
export type ReuploadField = (typeof REUPLOAD_FIELDS)[number];
|
||||
|
||||
export function isReuploadField(value: string): value is ReuploadField {
|
||||
return (REUPLOAD_FIELDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build array history rejection — append rejection sekarang (kalau ada) ke
|
||||
* `previousRejections` lama. Dipanggil saat organizer submit ulang.
|
||||
*/
|
||||
type RejectionEntry = { at: string; reason: string; submission: number };
|
||||
|
||||
function buildArchivedRejections(
|
||||
existing:
|
||||
| {
|
||||
status: string;
|
||||
rejectionReason: string | null;
|
||||
reviewedAt: Date | null;
|
||||
submissionCount: number;
|
||||
previousRejections: Prisma.JsonValue | null;
|
||||
}
|
||||
| null
|
||||
): RejectionEntry[] {
|
||||
if (!existing) return [];
|
||||
const prior = Array.isArray(existing.previousRejections)
|
||||
? (existing.previousRejections as unknown as RejectionEntry[])
|
||||
: [];
|
||||
if (
|
||||
existing.status === "REJECTED" &&
|
||||
existing.rejectionReason &&
|
||||
existing.reviewedAt
|
||||
) {
|
||||
return [
|
||||
...prior,
|
||||
{
|
||||
at: existing.reviewedAt.toISOString(),
|
||||
reason: existing.rejectionReason,
|
||||
submission: existing.submissionCount,
|
||||
},
|
||||
];
|
||||
}
|
||||
return prior;
|
||||
}
|
||||
|
||||
type SubmitInput = {
|
||||
fullName: string;
|
||||
nik: string;
|
||||
@@ -19,7 +73,14 @@ export const organizerService = {
|
||||
if (existing && existing.status === "APPROVED") {
|
||||
throw new Error("Akun kamu sudah terverifikasi");
|
||||
}
|
||||
if (existing && existing.status === "PENDING") {
|
||||
// Phase 2: kalau status PENDING dengan reuploadRequested = true, allow
|
||||
// submit ulang (re-upload flow). Kalau PENDING biasa (belum di-review),
|
||||
// tolak supaya tidak overwrite submission yang sedang antri.
|
||||
if (
|
||||
existing &&
|
||||
existing.status === "PENDING" &&
|
||||
!existing.reuploadRequested
|
||||
) {
|
||||
throw new Error("Pengajuan kamu masih dalam proses review");
|
||||
}
|
||||
|
||||
@@ -29,6 +90,10 @@ export const organizerService = {
|
||||
throw new Error("NIK ini sudah dipakai akun lain");
|
||||
}
|
||||
|
||||
// Phase 3: bump submission count + arsip rejection lama kalau ada.
|
||||
const nextCount = existing ? existing.submissionCount + 1 : 1;
|
||||
const archivedRejections = buildArchivedRejections(existing);
|
||||
|
||||
return organizerRepo.upsertForUser(userId, {
|
||||
fullName: data.fullName,
|
||||
nikEncrypted: encryptString(data.nik),
|
||||
@@ -45,6 +110,14 @@ export const organizerService = {
|
||||
reviewedAt: null,
|
||||
reviewedById: null,
|
||||
verifiedAt: null,
|
||||
reuploadRequested: false,
|
||||
reuploadFields: [],
|
||||
reuploadNote: null,
|
||||
submissionCount: nextCount,
|
||||
previousRejections: archivedRejections,
|
||||
isManualOverride: false,
|
||||
manualOverrideById: null,
|
||||
manualOverrideNote: null,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -69,6 +142,18 @@ export const organizerService = {
|
||||
});
|
||||
},
|
||||
|
||||
/** Append rejection reason ke `previousRejections` JSON array — dipakai
|
||||
* saat organizer submit ulang (Phase 3 history). */
|
||||
async _archiveCurrentRejection(verificationId: string) {
|
||||
const v = await organizerRepo.findById(verificationId);
|
||||
if (!v || v.status !== "REJECTED" || !v.rejectionReason) return;
|
||||
const archived = buildArchivedRejections(v);
|
||||
await prisma.organizerVerification.update({
|
||||
where: { id: verificationId },
|
||||
data: { previousRejections: archived as Prisma.InputJsonValue },
|
||||
});
|
||||
},
|
||||
|
||||
async getStatusForUser(userId: string) {
|
||||
return organizerRepo.findByUserId(userId);
|
||||
},
|
||||
@@ -78,6 +163,90 @@ export const organizerService = {
|
||||
return v?.status === "APPROVED";
|
||||
},
|
||||
|
||||
/**
|
||||
* Phase 2: admin minta organizer upload ulang field tertentu (KTP buram,
|
||||
* liveness gelap, dst). Set flag tanpa drop submission — organizer melihat
|
||||
* banner kuning di /verify dan submit ulang dengan auto-clear flag.
|
||||
*/
|
||||
async requestReupload(input: {
|
||||
verificationId: string;
|
||||
adminId: string;
|
||||
fields: ReuploadField[];
|
||||
note: string;
|
||||
}) {
|
||||
const verification = await organizerRepo.findById(input.verificationId);
|
||||
if (!verification) {
|
||||
throw new Error("Pengajuan tidak ditemukan");
|
||||
}
|
||||
if (verification.status === "APPROVED") {
|
||||
throw new Error("Verifikasi sudah disetujui — tidak perlu re-upload");
|
||||
}
|
||||
if (input.fields.length === 0) {
|
||||
throw new Error("Pilih minimal 1 field yang perlu di-upload ulang");
|
||||
}
|
||||
const invalid = input.fields.filter((f) => !isReuploadField(f));
|
||||
if (invalid.length > 0) {
|
||||
throw new Error(`Field tidak valid: ${invalid.join(", ")}`);
|
||||
}
|
||||
const trimmedNote = input.note.trim();
|
||||
if (trimmedNote.length < 10) {
|
||||
throw new Error("Catatan re-upload wajib min 10 karakter");
|
||||
}
|
||||
if (trimmedNote.length > 500) {
|
||||
throw new Error("Catatan re-upload maksimal 500 karakter");
|
||||
}
|
||||
|
||||
return organizerRepo.requestReupload(input.verificationId, {
|
||||
fields: input.fields,
|
||||
note: trimmedNote,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Phase 4: admin verify organizer tanpa upload KYC (mis. partner trusted
|
||||
* referral). Buat row APPROVED dengan flag `isManualOverride = true` supaya
|
||||
* audit trail jelas — visible di admin UI dan tidak campur dengan KYC normal.
|
||||
*/
|
||||
async manualOverrideVerification(input: {
|
||||
userId: string;
|
||||
adminId: string;
|
||||
note: string;
|
||||
bankName: string;
|
||||
bankAccountNumber: string;
|
||||
bankAccountName: string;
|
||||
}) {
|
||||
const existing = await organizerRepo.findByUserId(input.userId);
|
||||
if (existing) {
|
||||
throw new Error(
|
||||
"User sudah punya pengajuan verifikasi — pakai approve biasa atau reopen REJECTED."
|
||||
);
|
||||
}
|
||||
const trimmedNote = input.note.trim();
|
||||
if (trimmedNote.length < 10) {
|
||||
throw new Error(
|
||||
"Catatan manual override wajib min 10 karakter (alasan + ref partnership)"
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
// Placeholder NIK encrypted — tetap valid format tapi marker khusus.
|
||||
// Catatan: tidak ada NIK real, jadi nikHash juga placeholder unik per user
|
||||
// (pakai user.id sebagai HMAC input supaya tidak collide dengan NIK real).
|
||||
const placeholderNik = `OVERRIDE-${input.userId}`;
|
||||
return organizerRepo.createManualOverride({
|
||||
userId: input.userId,
|
||||
adminId: input.adminId,
|
||||
note: trimmedNote,
|
||||
fullName: input.bankAccountName,
|
||||
nikEncrypted: encryptString(placeholderNik),
|
||||
nikHash: hmacHex(placeholderNik),
|
||||
bankName: input.bankName,
|
||||
bankAccountNumber: input.bankAccountNumber,
|
||||
bankAccountName: input.bankAccountName,
|
||||
verifiedAt: now,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Buka kembali verifikasi yang REJECTED ke PENDING. Dipakai admin saat
|
||||
* organizer kirim foto/data baru via email/WA dan ingin di-review ulang
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
|
||||
export interface StaleSummary {
|
||||
/** Payment MIDTRANS status AWAITING > 25 jam (lewat expiresAt) — webhook gagal? */
|
||||
stalePaymentsCount: number;
|
||||
/** Booking AWAITING_PAY tapi trip sudah lewat hari ini — peserta lupa bayar. */
|
||||
awaitingPayPastDepartureCount: number;
|
||||
/** Payout HELD tapi heldUntil sudah lebih 1 hari lewat — cron release tidak jalan? */
|
||||
overduePayoutsCount: number;
|
||||
/** Refund APPROVED > 7 hari belum di-process — admin lupa? */
|
||||
stuckRefundsCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deteksi entity yang nyangkut di state non-final terlalu lama. Dipanggil dari
|
||||
* `/admin/system` page on-demand (bukan cron) supaya selalu show realtime.
|
||||
*
|
||||
* Threshold draft — review setelah jalan 1-2 minggu (false positive vs miss).
|
||||
*/
|
||||
export const systemHealthService = {
|
||||
async detectStale(): Promise<StaleSummary> {
|
||||
const now = new Date();
|
||||
const twentyFiveHoursAgo = new Date(now.getTime() - 25 * HOUR_MS);
|
||||
const oneDayAgo = new Date(now.getTime() - DAY_MS);
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * DAY_MS);
|
||||
const todayStart = new Date(now);
|
||||
todayStart.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const [
|
||||
stalePayments,
|
||||
awaitingPayPast,
|
||||
overduePayouts,
|
||||
stuckRefunds,
|
||||
] = await Promise.all([
|
||||
prisma.payment.count({
|
||||
where: {
|
||||
provider: "MIDTRANS",
|
||||
status: "AWAITING",
|
||||
createdAt: { lte: twentyFiveHoursAgo },
|
||||
},
|
||||
}),
|
||||
prisma.booking.count({
|
||||
where: {
|
||||
status: "AWAITING_PAY",
|
||||
trip: { date: { lt: todayStart } },
|
||||
},
|
||||
}),
|
||||
prisma.payout.count({
|
||||
where: {
|
||||
status: "HELD",
|
||||
heldUntil: { lte: oneDayAgo },
|
||||
},
|
||||
}),
|
||||
prisma.refund.count({
|
||||
where: {
|
||||
status: "APPROVED",
|
||||
reviewedAt: { lte: sevenDaysAgo },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
stalePaymentsCount: stalePayments,
|
||||
awaitingPayPastDepartureCount: awaitingPayPast,
|
||||
overduePayoutsCount: overduePayouts,
|
||||
stuckRefundsCount: stuckRefunds,
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user