Compare commits

..

2 Commits

Author SHA1 Message Date
arifal f0ce22bbb8 0.15.0 2026-05-18 20:25:54 +07:00
arifal bc4973a594 admin roadmap done, reupload request, submission history, manual override 2026-05-18 20:25:21 +07:00
22 changed files with 1257 additions and 124 deletions
+10 -1
View File
@@ -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=
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
View File
@@ -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}`.
+55 -4
View File
@@ -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">
+65 -5
View File
@@ -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 (
<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 &gt; 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 &gt; 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
&gt; 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
+11 -2
View File
@@ -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>
) : (
<SuspendUserButton userId={user.id} isSuspended={user.suspended} />
<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>
+16 -8
View File
@@ -44,14 +44,22 @@ 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">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
User Management
</h1>
<p className="mt-1 text-sm text-neutral-500">
Cari user, lihat history booking & trip, dan suspend akun yang
melakukan abuse (scam, harassment, TOS violation).
</p>
<header 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>
<p className="mt-1 text-sm text-neutral-500">
Cari user, lihat history booking & trip, dan suspend akun yang
melakukan abuse (scam, harassment, TOS violation).
</p>
</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">
+202
View File
@@ -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>
);
}
+34 -16
View File
@@ -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.
+8 -4
View File
@@ -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) |
+48 -16
View File
@@ -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 &quot;manual override&quot;.
</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>
);
}
+83 -1
View File
@@ -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 };
}
}
+136 -20
View File
@@ -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>
)}
+25
View File
@@ -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);
}
}
+6
View File
@@ -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 };
}
}
+2 -2
View File
@@ -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
View File
@@ -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;
+19
View File
@@ -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
}
+63
View File
@@ -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).
+170 -1
View File
@@ -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
+72
View File
@@ -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,
};
},
};