diff --git a/env.example b/.env.example similarity index 100% rename from env.example rename to .env.example diff --git a/.gitignore b/.gitignore index b8d6f77..01328fe 100644 --- a/.gitignore +++ b/.gitignore @@ -31,7 +31,10 @@ yarn-error.log* .pnpm-debug.log* # env files (can opt-in for committing if needed) -.env* +.env +.env.production +.env.development +.env.local # private uploads (KYC: KTP / liveness). Never serve directly. /uploads/ diff --git a/ADMIN_AUDIT_ROADMAP.md b/ADMIN_AUDIT_ROADMAP.md new file mode 100644 index 0000000..a32251f --- /dev/null +++ b/ADMIN_AUDIT_ROADMAP.md @@ -0,0 +1,109 @@ +# Setrip — Admin Audit & Investigation Roadmap + +Admin perlu **mencari** lintas entity (booking/payment/refund/user/trip) dan **export** untuk compliance + investigasi dispute. + +> **Skenario nyata:** auditor bertanya "tunjukkan semua refund yang di-approve admin X di bulan Juni 2026 dengan total lebih dari Rp 5 juta". Saat ini admin harus query DB manual atau ambil screenshot satu-satu. Tidak ada cara cari berdasarkan kombinasi reviewer + tanggal + nominal. + +--- + +## Baseline + +- ✅ Data audit sudah ada di schema: `Refund.reviewedBy/reviewedAt/adminNote`, `Payout.processedBy/processedAt/adminNote`, `OrganizerVerification.reviewedBy/reviewedAt/rejectionReason`. +- ✅ Existing list pages (`/admin/refunds`, `/admin/payouts`, `/admin/verifications`) sudah grouping by status tab. +- ❌ Tidak ada filter date range / reviewer / amount / reason. +- ❌ Tidak ada kolom "reviewer email" di list — harus klik detail. +- ❌ Tidak ada global search (cari berdasarkan email user, order id, trip id). +- ❌ Tidak ada CSV export. +- ❌ Tidak ada audit log untuk action admin di entity lain (User suspension, Trip force-cancel, Verification reopen). + +--- + +## Phase 1 — Filter & Search Enhancements ⏳ + +Sebelum bikin audit log baru, perbaiki dulu kemampuan cari & filter di list yang sudah ada. + +**Keputusan asumsi:** +- Pakai `searchParams` di Next.js — tidak perlu state client (server-render fast + shareable URL). +- Default date range: 30 hari terakhir, supaya page tidak load semua history. +- Reviewer dropdown sumber dari `ADMIN_EMAILS` env (sudah ada). + +| # | Item | Status | File | +|---|---|---|---| +| 1.1 | Filter date range (`from`, `to`) di `/admin/refunds` | ⏳ | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) | +| 1.2 | Filter `reviewedBy` (admin email dropdown) di `/admin/refunds` | ⏳ | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) | +| 1.3 | Filter `reason` di `/admin/refunds` (lihat juga [ADMIN_PAYMENT_OPS_ROADMAP.md](ADMIN_PAYMENT_OPS_ROADMAP.md)) | ⏳ | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) | +| 1.4 | Filter date range + `processedBy` di `/admin/payouts` | ⏳ | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) | +| 1.5 | Filter date range + `reviewedBy` di `/admin/verifications` | ⏳ | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) | +| 1.6 | Tampilkan kolom "reviewer email" + "reviewed at" di tabel/list (semua admin pages) | ⏳ | semua `app/admin/*/page.tsx` | +| 1.7 | Repo helper: tambah optional filter params di `refundRepo.listByStatus`, `payoutRepo.listByStatus`, `organizerRepo.listByStatus` | ⏳ | `server/repositories/*.ts` | + +**Tindakan manual:** tidak ada. + +--- + +## Phase 2 — Global Search ⏳ + +Satu search box yang resolve ke entity detail page paling relevan. + +**Keputusan asumsi:** +- Input string user, prefix-based dispatch: + - Email format (`@`) → user search → redirect ke `/admin/users/[id]` + - Mulai `midtrans-` / `manual-` → payment lookup by `externalOrderId` → `/admin/bookings/[bookingId]` + - Mulai `cm` (cuid pattern) + length 25 → coba lookup berurutan: trip → booking → user + - Else: full-text search di trip title/destination +- Pakai server action atau route handler `/api/admin/search` — return list hasil + jenis entity. +- UI: searchbar di admin layout (top-right) yang dropdown hasil. + +| # | Item | Status | File | +|---|---|---|---| +| 2.1 | `adminSearchService.resolve(query)` — dispatch ke repo lookup yang tepat | ⏳ | `server/services/admin-search.service.ts` | +| 2.2 | Route handler `/api/admin/search?q=...` (GET, guard isAdmin) | ⏳ | `app/api/admin/search/route.ts` | +| 2.3 | Component `AdminSearchBar` di admin layout — debounced, dropdown hasil | ⏳ | `features/admin/components/admin-search-bar.tsx` | +| 2.4 | Page `/admin/search?q=...` untuk full results kalau dropdown limit terlampaui | ⏳ | `app/admin/search/page.tsx` | + +**Tindakan manual:** tidak ada. + +--- + +## Phase 3 — CSV Export ⏳ + +Export untuk laporan keuangan & compliance. + +**Keputusan asumsi:** +- Stream CSV via route handler — jangan load semua ke memory. +- Pakai filter yang sama dengan list page — admin pakai URL filter lalu klik "Export". +- Header CSV: human-readable bahasa Indonesia (mis. "Tanggal Approve", "Email Peserta", "Nominal Refund"). +- Tidak ada Excel/xlsx — CSV cukup, mudah dibuka di Sheets/Excel. + +| # | Item | Status | File | +|---|---|---|---| +| 3.1 | Helper `lib/csv.ts` — `streamCsv(headers, rows)` return Response | ⏳ | `lib/csv.ts` | +| 3.2 | Route `/api/admin/export/refunds` — pakai filter dari query string | ⏳ | `app/api/admin/export/refunds/route.ts` | +| 3.3 | Route `/api/admin/export/payouts` | ⏳ | `app/api/admin/export/payouts/route.ts` | +| 3.4 | Route `/api/admin/export/verifications` (tanpa NIK / KTP — hanya metadata) | ⏳ | `app/api/admin/export/verifications/route.ts` | +| 3.5 | Tombol "Export CSV" di tiap admin list page | ⏳ | semua `app/admin/*/page.tsx` | + +**Tindakan manual:** +1. Test export di staging — pastikan tidak leak data sensitif (NIK harus tetap encrypted/excluded). +2. Update kebijakan privasi: data export hanya untuk internal compliance. + +--- + +## Phase 4 — Generic Admin Audit Log ⏳ + +Tabel `AdminActionLog` untuk action di entity yang belum punya audit field (User suspend, Trip force-cancel, Verification reopen, dst). + +**Keputusan asumsi:** +- Single tabel polymorphic: `AdminActionLog { adminId, action, entityType, entityId, payload Json?, createdAt }`. +- Append-only, never update/delete. +- Service helper `auditLog.record(...)` dipanggil eksplisit di setiap action admin (tidak via Prisma middleware — terlalu magic). +- View page `/admin/audit-log` dengan filter `adminId`, `entityType`, `action`, date range. + +| # | Item | Status | File | +|---|---|---|---| +| 4.1 | Model `AdminActionLog` + migration | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) | +| 4.2 | Helper `auditLog.record({ adminId, action, entityType, entityId, payload? })` | ⏳ | `server/services/audit-log.service.ts` | +| 4.3 | Wire `auditLog.record` di semua admin server action existing (refund approve/reject/mark, payout markPaid, verification approve/reject) | ⏳ | `features/*/actions.ts` | +| 4.4 | Page `/admin/audit-log` dengan filter + pagination | ⏳ | `app/admin/audit-log/page.tsx` | + +**Tindakan manual:** tidak ada. diff --git a/ADMIN_PAYMENT_OPS_ROADMAP.md b/ADMIN_PAYMENT_OPS_ROADMAP.md new file mode 100644 index 0000000..e9b9316 --- /dev/null +++ b/ADMIN_PAYMENT_OPS_ROADMAP.md @@ -0,0 +1,89 @@ +# Setrip — Admin Payment Operations Roadmap + +Admin perlu visibilitas + kontrol penuh atas alur uang: payment Midtrans, refund, payout. Saat webhook gagal atau status mismatch, admin harus bisa reconcile tanpa edit DB. + +> **Skenario nyata:** webhook Midtrans drop di production. `Booking.status = AWAITING_PAY` padahal user sudah bayar (confirm email dari Midtrans). User komplain via WhatsApp. Saat ini admin harus query DB manual lalu update via Prisma Studio. + +--- + +## Baseline + +- ✅ `paymentService.reconcileFromGateway(orderId, userId)` di [server/services/payment.service.ts](server/services/payment.service.ts) sudah call Midtrans Core API + apply state-machine. Tapi `userId` check membatasi penggunaan ke user pemilik booking — admin perlu variant. +- ✅ `paymentService.handleMidtransWebhook` ada + idempotent via `applyGatewayStatus` helper. +- ✅ `Payment.rawCallback` simpan snapshot mentah untuk audit. +- ✅ `Refund` + `Payout` model lengkap dengan `reviewedBy`/`processedBy`/`adminNote`. +- ❌ Tidak ada page `/admin/bookings/[id]` untuk drill-down per-booking timeline. +- ❌ Tidak ada admin variant `reconcileFromGateway` (yang tidak butuh userId check). +- ❌ Tidak ada UI yang tampilkan `Payment.rawCallback` JSON. +- ❌ Tidak ada filter refund per `reason` (mis. cari semua DISPUTE_RESOLVED). +- ❌ Tidak ada bulk reconcile untuk stale PENDING/AWAITING payments. + +--- + +## Phase 1 — Booking + Payment Detail View ⏳ + +Admin perlu satu halaman yang tampilkan **seluruh** event uang untuk satu booking: payment attempts (Midtrans + manual legacy), refund history, payout status, raw callback. + +**Keputusan asumsi:** +- Drill-down dari `/admin/trips/[id]`, `/admin/refunds`, `/admin/payouts`, dan global search nanti. +- Tampilkan **timeline chronological** semua event Payment + Refund + Payout untuk booking — bukan tabel terpisah. +- `Payment.rawCallback` ditampilkan sebagai collapsible JSON viewer (tidak default expanded — verbose). +- Show juga `Booking.status` history kalau ada (tidak ada saat ini — `updatedAt` jadi proxy). + +| # | Item | Status | File | +|---|---|---|---| +| 1.1 | `bookingRepo.findByIdForAdmin(id)` — include payments (with raw), refunds, payout, trip, user, participant | ⏳ | [server/repositories/booking.repo.ts](server/repositories/booking.repo.ts) | +| 1.2 | Page `/admin/bookings/[id]` — header (trip, user, amount, status), timeline events | ⏳ | `app/admin/bookings/[id]/page.tsx` | +| 1.3 | Component `PaymentTimelineAdmin` — render Payment + Refund + Payout sorted by `createdAt` | ⏳ | `features/booking/components/payment-timeline-admin.tsx` | +| 1.4 | Component `RawCallbackViewer` — collapsible `
` block dengan JSON pretty-printed | ⏳ | `features/booking/components/raw-callback-viewer.tsx` | +| 1.5 | Link "Lihat detail" dari `/admin/refunds` ke `/admin/bookings/[id]` | ⏳ | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) | +| 1.6 | Link "Lihat detail" dari `/admin/payouts` ke `/admin/bookings/[id]` | ⏳ | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) | + +**Tindakan manual:** tidak ada. + +--- + +## Phase 2 — Admin Midtrans Reconciliation UI ⏳ + +Tombol di booking detail page yang panggil Midtrans Core API + apply update. Admin variant tidak butuh `userId` check. + +**Keputusan asumsi:** +- **Reuse internal helper `applyGatewayStatus`** di [server/services/payment.service.ts](server/services/payment.service.ts) — sudah ekstrak. +- Buat `paymentService.adminReconcile(orderId, adminId)` — sama dengan `reconcileFromGateway` tapi: + - Skip ownership check (admin bypass). + - Log `adminId` di `Payment.rawCallback` snapshot (tambah field `_reconciledByAdminId`). +- Server action `adminReconcileMidtransAction(orderId)` guard `isAdmin`. +- UI: tombol per Payment row di timeline. Disable kalau Payment sudah final (PAID/FAILED/EXPIRED/CANCELLED/REFUNDED) tapi tetap show last-reconciled-at. +- Tampilkan toast hasil: "Updated to PAID" / "Already PAID, no change" / "Amount mismatch (audit)". + +| # | Item | Status | File | +|---|---|---|---| +| 2.1 | `paymentService.adminReconcile(orderId, adminId)` — variant tanpa ownership check | ⏳ | [server/services/payment.service.ts](server/services/payment.service.ts) | +| 2.2 | Server action `adminReconcileMidtransAction(orderId)` | ⏳ | `features/booking/actions.ts` (atau `features/admin/actions.ts` baru) | +| 2.3 | Tombol "Reconcile dari Midtrans" di tiap Payment Midtrans di timeline | ⏳ | `features/booking/components/payment-timeline-admin.tsx` | +| 2.4 | Tampilkan `Payment.rejectionReason` (untuk amount mismatch log) di card payment | ⏳ | `features/booking/components/payment-timeline-admin.tsx` | +| 2.5 | (Optional) Bulk reconcile: `/admin/payments/stale` — list Payment status PENDING/AWAITING > 6 jam | ⏳ | `app/admin/payments/stale/page.tsx` | + +**Tindakan manual:** +1. Brief admin: kapan pakai reconcile (peserta lapor "sudah bayar tapi status belum update"). Jangan dipakai untuk PAID booking (idempotent tapi noise). + +--- + +## Phase 3 — Dispute & Chargeback Tracking ⏳ + +`RefundReason.DISPUTE_RESOLVED` sudah ada di enum tapi tidak ada flow khusus. + +**Keputusan asumsi:** +- Tidak buat tabel baru. Filter di refund list page cukup. +- Tambah "Chargeback note" field di Refund kalau perlu (skip untuk MVP — pakai `adminNote` saja). +- Highlight visual: badge merah untuk DISPUTE_RESOLVED supaya admin treat khusus. + +| # | Item | Status | File | +|---|---|---|---| +| 3.1 | Tab/filter `reason` di `/admin/refunds` — dropdown semua nilai `RefundReason` | ⏳ | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) | +| 3.2 | Badge khusus untuk `DISPUTE_RESOLVED` di refund card | ⏳ | [features/refund/components/refund-review-card.tsx](features/refund/components/refund-review-card.tsx) | +| 3.3 | Dokumentasi SOP: kapan pakai `DISPUTE_RESOLVED` vs reason lain | ⏳ | `docs/admin/refund-reasons.md` (baru) | + +**Tindakan manual:** +1. Tulis SOP dispute handling (alur bank → admin → refund creation). +2. Brief admin: `DISPUTE_RESOLVED` hanya untuk chargeback yang sudah resolve via bank. diff --git a/ADMIN_ROADMAP.md b/ADMIN_ROADMAP.md new file mode 100644 index 0000000..fe7a087 --- /dev/null +++ b/ADMIN_ROADMAP.md @@ -0,0 +1,48 @@ +# Setrip — Admin Roadmap (Index) + +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`. + +--- + +## Baseline (yang BISA admin lakukan sekarang) + +| Area | Fungsi | File | +|---|---|---| +| Dashboard | View count: verifikasi PENDING, refund per status, payout per status | [app/admin/page.tsx](app/admin/page.tsx) | +| Verifikasi KYC | Approve / Reject organizer (KTP, liveness, bank) | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) | +| Refund | Create manual, approve, reject, mark SUCCEEDED, mark FAILED | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) | +| Payout | View per status, mark PAID setelah transfer manual | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) | + +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 | Prioritas | Status | File | +|---|---|---|---| +| Trip Operations (search, view, cancel manual) | 🔴 HIGH | ⏳ 0% | [ADMIN_TRIP_OPS_ROADMAP.md](ADMIN_TRIP_OPS_ROADMAP.md) | +| Payment Operations (booking detail, reconcile, dispute) | 🔴 HIGH | 🚧 ~15% | [ADMIN_PAYMENT_OPS_ROADMAP.md](ADMIN_PAYMENT_OPS_ROADMAP.md) | +| Audit & Investigation (search, filter, export) | 🔴 HIGH | ⏳ 0% | [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) | +| User Management (search, suspend/ban) | 🟡 MEDIUM | ⏳ 0% | [ADMIN_USER_MGMT_ROADMAP.md](ADMIN_USER_MGMT_ROADMAP.md) | +| Verification (reopen, re-upload request) | 🟡 MEDIUM | ⏳ 0% | [ADMIN_VERIFICATION_ROADMAP.md](ADMIN_VERIFICATION_ROADMAP.md) | +| System Health (cron monitor, stale state alerts) | 🟡 MEDIUM | ⏳ 0% | [ADMIN_SYSTEM_HEALTH_ROADMAP.md](ADMIN_SYSTEM_HEALTH_ROADMAP.md) | + +**Legend status:** ⏳ belum mulai · 🚧 partial · ✅ selesai + +--- + +## Urutan implementasi yang direkomendasikan + +Berdasarkan ROI (frekuensi kebutuhan × dampak insiden): + +1. **Trip Ops** — paling sering dibutuhkan, infrastruktur service sudah lengkap (`tripService.closeTrip`) +2. **Payment Ops** — kritikal saat webhook gagal; setengah infra sudah ada (`reconcileFromGateway`) +3. **Audit** — compliance + investigasi dispute; data sudah lengkap (`reviewedBy`, `processedBy`, `adminNote`), tinggal UI filter/export +4. **User Management** — moderation; butuh schema change (`User.suspended`) +5. **Verification** — edge case rare; cuma butuh 1 service method + tombol +6. **System Health** — operational visibility; butuh model baru (`CronRun`) + +Tiga roadmap pertama menutup ~90% skenario "admin powerless when shit hits the fan". diff --git a/ADMIN_SYSTEM_HEALTH_ROADMAP.md b/ADMIN_SYSTEM_HEALTH_ROADMAP.md new file mode 100644 index 0000000..40d62e1 --- /dev/null +++ b/ADMIN_SYSTEM_HEALTH_ROADMAP.md @@ -0,0 +1,103 @@ +# Setrip — Admin System Health Roadmap + +Admin perlu visibilitas atas job otomatis (cron) dan deteksi state yang nyangkut (Payment stale, Payout overdue, Refund mandek). + +> **Skenario nyata:** cron auto-complete trip crash karena env variable rusak. 50 trip yang sudah lewat tanggalnya tetap `OPEN` selama 3 hari sampai peserta komplain "kenapa belum bisa kasih review". Admin tidak punya cara cek status cron tanpa SSH ke server. + +--- + +## Baseline + +- ✅ Cron infra ada (system crontab + `CRON_SECRET`) — lihat [docs/CRON_SETUP.md](docs/CRON_SETUP.md). +- ✅ Cron jobs aktif: `app/api/cron/auto-complete-trips/route.ts`, payout release (lewat `payoutService`), kemungkinan refund timeout. +- ❌ Tidak ada log/audit per cron run (success/fail/error). +- ❌ Tidak ada page `/admin/system` untuk lihat status. +- ❌ Tidak ada alert deteksi state stale (Payment AWAITING > 24h, Payout HELD past `heldUntil`, Refund APPROVED > 7d). + +--- + +## Phase 1 — Cron Run Log ⏳ + +Tabel `CronRun` yang dicatat setiap kali cron jalan. Foundation untuk semua observability. + +**Keputusan asumsi:** +- Append-only model. Retention: keep all (tabel kecil, ~365 rows/year/cron). Cleanup nanti kalau perlu. +- Wrap existing cron handler dengan helper `runCron(name, fn)` yang otomatis log start/finish/error. +- Tidak pakai job library (BullMQ/Inngest) — overkill. Tetap pakai system cron + Next route handler. + +| # | Item | Status | File | +|---|---|---|---| +| 1.1 | Model `CronRun { id, jobName, startedAt, finishedAt?, status (RUNNING/SUCCESS/FAILED), errorMessage?, payload? Json }` + migration | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) | +| 1.2 | Helper `runCron(jobName, fn)` — wrap handler, otomatis create RUNNING row → SUCCESS/FAILED | ⏳ | `lib/cron-runner.ts` | +| 1.3 | Wire `runCron` di `app/api/cron/auto-complete-trips/route.ts` | ⏳ | `app/api/cron/auto-complete-trips/route.ts` | +| 1.4 | Wire `runCron` di cron payout release (kalau sudah ada — kalau belum, daftar sebagai gap) | ⏳ | TBD | +| 1.5 | Wire `runCron` di cron lain (refund sweep, dst) | ⏳ | TBD | + +**Tindakan manual:** tidak ada. + +--- + +## Phase 2 — System Status Page ⏳ + +Page `/admin/system` yang tampilkan kondisi terkini. + +**Keputusan asumsi:** +- Tabel per cron job: last run, last success, total runs (7d), error count (7d). +- Refresh manual (tombol "Refresh") — bukan auto-poll. Cukup untuk admin. +- Health badge: 🟢 OK (last success < 25 jam untuk daily), 🟡 STALE (> 25 jam), 🔴 FAILED (last run = FAILED). +- Tampilkan 20 cron run terbaru di table bawah untuk drill-down. + +| # | Item | Status | File | +|---|---|---|---| +| 2.1 | `cronRepo.getJobSummary(jobName)` — last run, last success, count 7d | ⏳ | `server/repositories/cron.repo.ts` | +| 2.2 | `cronRepo.listRecent(limit)` — 20 run terakhir lintas job | ⏳ | `server/repositories/cron.repo.ts` | +| 2.3 | Page `/admin/system` — tabel job summary + tabel recent runs | ⏳ | `app/admin/system/page.tsx` | +| 2.4 | Health badge logic (helper) | ⏳ | `lib/cron-health.ts` | +| 2.5 | Link "System" di admin navbar | ⏳ | [app/admin/layout.tsx](app/admin/layout.tsx) | + +**Tindakan manual:** +1. Set ekspektasi SLA per cron (mis. `auto-complete-trips` harus jalan setiap hari sebelum jam 06:00 WIB). +2. Brief admin: cek `/admin/system` minimal sekali per hari pagi sebelum mulai kerja. + +--- + +## Phase 3 — Stale State Alerts ⏳ + +Deteksi entity yang nyangkut di state non-final terlalu lama. Tampilkan sebagai banner di `/admin/system`. + +**Keputusan asumsi:** +- Stale thresholds (review dengan stakeholder, ini draft): + - Payment status `PENDING` > 1 jam → suspect: gagal create Snap token, perlu manual cleanup + - Payment status `AWAITING` > 25 jam (lebih dari expiresAt) → suspect: webhook gagal, expire belum di-set, perlu reconcile + - Booking status `AWAITING_PAY` + trip date < today → suspect: peserta lupa bayar, butuh cleanup + - Payout status `HELD` + `heldUntil < now` > 1 hari → suspect: cron release tidak jalan, perlu trigger manual + - Refund status `APPROVED` > 7 hari → suspect: admin lupa proses, atau Midtrans refund gagal +- Compute via query parameter pada page load — tidak perlu materialized view. +- Setiap kategori tampilkan jumlah + link ke filtered list page yang relevan. + +| # | Item | Status | File | +|---|---|---|---| +| 3.1 | `systemHealthService.detectStale()` return `{ stalePayments, expiredAwaiting, awaitingPayPastDeparture, overduePayouts, stuckRefunds }` | ⏳ | `server/services/system-health.service.ts` | +| 3.2 | Banner alerts di `/admin/system` kalau ada count > 0 | ⏳ | `app/admin/system/page.tsx` | +| 3.3 | Link tiap alert ke filtered list (pakai filter di [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) Phase 1) | ⏳ | `app/admin/system/page.tsx` | +| 3.4 | Stat card di dashboard utama `/admin` kalau ada alert | ⏳ | [app/admin/page.tsx](app/admin/page.tsx) | + +**Tindakan manual:** +1. Tuning threshold setelah jalan 1-2 minggu (false positive vs miss). +2. SOP per alert: action apa yang admin harus ambil saat banner muncul. + +--- + +## Phase 4 — External Alerting (opsional) ⏳ + +Push notif ke channel eksternal (Discord/Telegram/email) saat ada cron FAILED atau stale state critical. Skip kecuali admin sering miss banner. + +| # | Item | Status | File | +|---|---|---|---| +| 4.1 | Helper `notifyAdmins(message)` — POST ke Discord webhook URL dari env | ⏳ | `lib/admin-notify.ts` | +| 4.2 | Trigger notify di `runCron` saat FAILED | ⏳ | `lib/cron-runner.ts` | +| 4.3 | Trigger notify dari `systemHealthService.detectStale` (rate-limited, max 1x/hari per kategori) | ⏳ | `server/services/system-health.service.ts` | + +**Tindakan manual:** +1. Buat channel Discord internal + webhook URL → set env `ADMIN_ALERT_WEBHOOK_URL`. +2. Test alert dengan trigger fake fail. diff --git a/ADMIN_TRIP_OPS_ROADMAP.md b/ADMIN_TRIP_OPS_ROADMAP.md new file mode 100644 index 0000000..a952471 --- /dev/null +++ b/ADMIN_TRIP_OPS_ROADMAP.md @@ -0,0 +1,77 @@ +# Setrip — Admin Trip Operations Roadmap + +Admin perlu visibilitas penuh atas trip dan bisa intervensi (cancel + auto-refund) saat organizer unreachable atau ada masalah safety. + +> **Skenario nyata:** peserta lapor trip berjalan tidak sesuai itinerary. Organizer tidak responsif. Hari berikutnya peserta minta refund. Saat ini admin harus refund satu-satu manual via `/admin/refunds` tanpa konteks trip atau cara cancel trip-nya. + +--- + +## Baseline + +- ✅ `tripService.closeTrip(tripId, organizerId)` di [server/services/trip.service.ts](server/services/trip.service.ts) sudah handle cancel + auto-refund semua booking PAID atomically. Hanya menerima `organizerId` — perlu varian admin. +- ✅ `tripRepo.findAll()` dan `tripRepo.findById()` ada — siap dipakai untuk admin list/detail. +- ❌ Tidak ada page `/admin/trips`. +- ❌ Tidak ada UI search/filter trip untuk admin. +- ❌ Tidak ada UI view detail trip dari sisi admin (kondisi participant, booking, payment). + +--- + +## Phase 1 — Trip List + Detail View (admin read-only) ⏳ + +Foundation. Tanpa cara cari & lihat trip, admin tidak tahu apa yang mau di-intervene. + +**Keputusan asumsi:** +- Reuse `tripRepo.findAll()` tapi tambah filter param: `status`, `organizerId`, `q` (search title/destination). +- Detail page reuse `tripService.getTripById()` yang sudah include `participants`, `images`, `reviews`, `itineraryItems`. +- Tampilkan **semua participant** (PENDING/CONFIRMED/CANCELLED) — admin perlu konteks lengkap. +- Drill-down ke booking detail (lihat [ADMIN_PAYMENT_OPS_ROADMAP.md](ADMIN_PAYMENT_OPS_ROADMAP.md)) untuk lihat payment timeline. + +| # | Item | Status | File | +|---|---|---|---| +| 1.1 | `tripRepo.searchForAdmin({ q?, status?, organizerId?, dateFrom?, dateTo? })` | ⏳ | [server/repositories/trip.repo.ts](server/repositories/trip.repo.ts) | +| 1.2 | Page `/admin/trips` — list + tab status (OPEN/FULL/CLOSED/COMPLETED) + search bar | ⏳ | `app/admin/trips/page.tsx` | +| 1.3 | Filter: tanggal berangkat range, organizer (dropdown), kategori | ⏳ | `app/admin/trips/page.tsx` | +| 1.4 | Page `/admin/trips/[id]` — full detail (trip core + itinerary items + participants + bookings ringkasan) | ⏳ | `app/admin/trips/[id]/page.tsx` | +| 1.5 | Badge metrics di detail: peserta PAID/AWAITING/PENDING, total revenue (sum amount PAID), refund total | ⏳ | `app/admin/trips/[id]/page.tsx` | +| 1.6 | Tambah link "Trips" di admin navbar | ⏳ | [app/admin/layout.tsx](app/admin/layout.tsx) | + +**Tindakan manual:** tidak ada. + +--- + +## Phase 2 — Admin Force-Cancel Trip dengan Auto-Refund ⏳ + +Tombol "Cancel trip" di admin detail page yang setara dengan organizer cancel, tapi dilakukan oleh admin untuk emergency intervention. + +**Keputusan asumsi:** +- **Tidak buat method baru di service**. Refactor `tripService.closeTrip` agar terima `actor: { type: "ORGANIZER", id } | { type: "ADMIN", id, reason }`. Atomic dalam satu serializable transaction (sama seperti existing). +- Refund yang dibuat pakai `RefundReason.ORGANIZER_CANCELLED` (tetap, karena dari perspektif peserta sama saja). Tambah `adminNote` di refund record kalau actor ADMIN supaya audit trail jelas. +- Tambah kolom `Trip.cancelledByAdminId` (nullable) + `Trip.cancelledReason` di schema — bukan kolom umum, hanya saat admin yang cancel. +- Modal konfirmasi wajib tampilkan: jumlah booking PAID yang akan auto-refund, total nominal. Kalau organizer yang biasa cancel sudah ada confirm modal di [cancel-trip-button.tsx](features/trip/components/cancel-trip-button.tsx) — reuse pola. +- Idempotent: kalau trip sudah CLOSED, tolak dengan pesan jelas. + +| # | Item | Status | File | +|---|---|---|---| +| 2.1 | Migration: tambah `cancelledByAdminId` (FK User) + `cancelledReason` di `Trip` | ⏳ | `prisma/migrations/` | +| 2.2 | Refactor `tripService.closeTrip` terima `actor` discriminated union | ⏳ | [server/services/trip.service.ts](server/services/trip.service.ts) | +| 2.3 | Server action `adminCancelTripAction(tripId, reason)` — guard `isAdmin`, panggil closeTrip dengan actor ADMIN | ⏳ | `features/trip/actions.ts` | +| 2.4 | UI: tombol "Cancel trip (admin)" di `/admin/trips/[id]` dengan modal konfirmasi + textarea reason wajib | ⏳ | `app/admin/trips/[id]/page.tsx` (atau component terpisah) | +| 2.5 | Tampilkan badge "Dibatalkan admin" + reason di trip detail saat `cancelledByAdminId` not null | ⏳ | [app/(public)/trips/[id]/page.tsx](app/(public)/trips/[id]/page.tsx) — opsional, transparansi | + +**Tindakan manual:** +1. Setelah deploy, brief admin tentang kapan boleh pakai (kriteria: organizer unreachable >7 hari, dispute peserta tidak terselesaikan, safety issue). +2. Tulis SOP internal: kategori reason yang valid + template komunikasi ke peserta. + +--- + +## Phase 3 — Trip Edit Override (opsional, low priority) ⏳ + +Admin bisa edit field non-critical (description, meetingPoint, itinerary) atas request organizer saat organizer tidak bisa login. Skip untuk MVP. + +| # | Item | Status | File | +|---|---|---|---| +| 3.1 | `tripService.adminUpdateTrip(tripId, partial, adminId, reason)` — whitelist field | ⏳ | [server/services/trip.service.ts](server/services/trip.service.ts) | +| 3.2 | UI form edit di `/admin/trips/[id]/edit` | ⏳ | `app/admin/trips/[id]/edit/page.tsx` | +| 3.3 | Audit log entry untuk setiap edit (siapa, field apa, before/after) | ⏳ | TBD (lihat [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md)) | + +**Tindakan manual:** tidak ada (skip phase ini sampai ada keluhan konkret). diff --git a/ADMIN_USER_MGMT_ROADMAP.md b/ADMIN_USER_MGMT_ROADMAP.md new file mode 100644 index 0000000..5ee65f3 --- /dev/null +++ b/ADMIN_USER_MGMT_ROADMAP.md @@ -0,0 +1,81 @@ +# Setrip — Admin User Management Roadmap + +Admin perlu bisa cari user dan **suspend** akun yang melakukan abuse (scam, harassment, fake review). + +> **Skenario nyata:** organizer scam berkali-kali bikin trip palsu pakai alias berbeda. Peserta lapor harassment dari user lain di grup WA trip. Saat ini admin cuma bisa refund korban — pelaku tetap bisa lanjut bikin trip baru / join trip lain. + +--- + +## Baseline + +- ✅ `userRepo.findByEmail()` dan `userRepo.findById()` ada di [server/repositories/user.repo.ts](server/repositories/user.repo.ts). +- ✅ `User` model lengkap dengan relasi ke `trips`, `participations`, `bookings`, `tripReviews`, `organizerVerification`, `payouts`. +- ❌ Tidak ada page `/admin/users`. +- ❌ Tidak ada field `suspended` di `User`. +- ❌ Tidak ada guard di auth/server actions yang reject suspended user. +- ❌ Tidak ada stats user (total signups, organizer aktif, peserta aktif). + +--- + +## Phase 1 — User List & Detail View ⏳ + +Baseline visibility. Sama pola dengan trip ops — list + search + detail. + +**Keputusan asumsi:** +- Search by email exact match dulu (paling sering dipakai admin saat ada laporan); kalau perlu, tambah name LIKE search nanti. +- Detail page tampilkan: profile, verification status, booking history (sebagai peserta), trip history (sebagai organizer), payout history (sebagai organizer), review yang dibuat & diterima. +- Sensitive info (password hash, OAuth tokens) **tidak** ditampilkan. + +| # | Item | Status | File | +|---|---|---|---| +| 1.1 | `userRepo.searchForAdmin({ q?, role?, suspended? })` — q match email atau name (case insensitive) | ⏳ | [server/repositories/user.repo.ts](server/repositories/user.repo.ts) | +| 1.2 | Page `/admin/users` — list + search bar + filter (organizer/participant/suspended) | ⏳ | `app/admin/users/page.tsx` | +| 1.3 | Page `/admin/users/[id]` — profil + tabs (Bookings, Trips Dibuat, Reviews, Verification) | ⏳ | `app/admin/users/[id]/page.tsx` | +| 1.4 | Stats card di top: total bookings, total spent, total revenue (kalau organizer), verification status | ⏳ | `app/admin/users/[id]/page.tsx` | +| 1.5 | Link "Users" di admin navbar | ⏳ | [app/admin/layout.tsx](app/admin/layout.tsx) | + +**Tindakan manual:** tidak ada. + +--- + +## Phase 2 — User Suspension ⏳ + +Toggle suspend yang mencegah suspended user login + melakukan aksi mutatif. + +**Keputusan asumsi:** +- Tambah 4 kolom di `User`: `suspended Boolean @default(false)`, `suspendedAt DateTime?`, `suspendedReason String?`, `suspendedBy String?` (FK User admin). +- **Block sign-in** di NextAuth callbacks (`signIn` callback return false kalau `user.suspended`). Untuk JWT session sudah aktif, cek `suspended` di `session` callback lalu invalidate. +- **Block mutating actions** via helper `requireActiveUser(session)` yang dipanggil di awal setiap server action mutating (joinTrip, createTrip, addReview, dst). +- Suspended user **tetap bisa** lihat data sendiri (refund history, dll) — tidak hard-delete supaya audit trail terjaga. +- Suspended organizer otomatis sembunyikan trip OPEN/FULL miliknya dari public list — tambah filter di `tripRepo.findOpen` (`organizer: { suspended: false }`). +- Unsuspend = toggle false + clear field — tetap simpan history via [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) Phase 4. + +| # | Item | Status | File | +|---|---|---|---| +| 2.1 | Migration: tambah `suspended`, `suspendedAt`, `suspendedReason`, `suspendedBy` di `User` | ⏳ | `prisma/migrations/` | +| 2.2 | `userService.suspendUser(userId, adminId, reason)` + `unsuspendUser(userId, adminId)` | ⏳ | `server/services/user.service.ts` | +| 2.3 | Block sign-in di NextAuth `signIn` callback | ⏳ | [lib/auth.ts](lib/auth.ts) | +| 2.4 | Helper `requireActiveUser(session)` throw kalau suspended | ⏳ | `lib/auth-guards.ts` | +| 2.5 | Wire `requireActiveUser` di semua mutating server action (joinTripAction, createTripAction, createReviewAction, dst) | ⏳ | `features/*/actions.ts` | +| 2.6 | Filter trip public list: organizer tidak suspended | ⏳ | [server/repositories/trip.repo.ts](server/repositories/trip.repo.ts) | +| 2.7 | UI: tombol "Suspend" / "Unsuspend" di `/admin/users/[id]` + modal reason wajib | ⏳ | `app/admin/users/[id]/page.tsx` | +| 2.8 | Badge "SUSPENDED" di user list + detail header (visual jelas) | ⏳ | `app/admin/users/[id]/page.tsx` | +| 2.9 | Server action `suspendUserAction` + `unsuspendUserAction` (guard isAdmin) | ⏳ | `features/admin/actions.ts` (baru) atau `features/user/actions.ts` | + +**Tindakan manual:** +1. Brief admin: kriteria suspend (3 kategori: scam, harassment, repeated TOS violation). Hindari subjective suspend. +2. Tulis halaman info "Akun ditangguhkan" yang ditampilkan saat suspended user coba login (jelaskan kenapa & cara appeal via email). +3. Pertimbangkan suspended user di Midtrans webhook — saat ada payment masuk untuk suspended user's booking, tetap di-PAID (uang tetap diterima, refund proses normal). + +--- + +## Phase 3 — User Analytics (low priority, skip MVP) ⏳ + +Dashboard stats untuk growth tracking. Skip sampai ada kebutuhan konkret. + +| # | Item | Status | File | +|---|---|---|---| +| 3.1 | Stats endpoint: total user, signup per minggu (4 minggu terakhir), organizer aktif (yang punya OPEN/FULL trip), peserta aktif (booking PAID) | ⏳ | `app/admin/users/stats/page.tsx` | +| 3.2 | Chart sederhana (HTML/SVG inline, no chart library) | ⏳ | `app/admin/users/stats/page.tsx` | + +**Tindakan manual:** tidak ada (skip phase ini). diff --git a/ADMIN_VERIFICATION_ROADMAP.md b/ADMIN_VERIFICATION_ROADMAP.md new file mode 100644 index 0000000..e99fdce --- /dev/null +++ b/ADMIN_VERIFICATION_ROADMAP.md @@ -0,0 +1,88 @@ +# Setrip — Admin Verification Roadmap + +Enhancement KYC organizer verification: reopen REJECTED, request re-upload, audit override. + +> **Skenario nyata:** organizer terverifikasi dengan KTP buram → admin REJECT. Organizer foto ulang dan kirim via email. Admin sekarang harus edit DB manual karena `OrganizerVerification.status = REJECTED` tidak bisa kembali ke `PENDING` lewat UI. + +--- + +## Baseline + +- ✅ Approve / Reject ada di [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx). +- ✅ `OrganizerVerification` model lengkap dengan `reviewedBy`, `reviewedAt`, `rejectionReason`. +- ✅ NIK encrypted (decrypt via `organizerService.decryptNik` saat di-render). +- ❌ Tidak ada cara reopen REJECTED → kembali ke PENDING. +- ❌ Tidak ada flow "request re-upload" (admin minta organizer upload ulang field tertentu tanpa harus reject penuh). +- ❌ Tidak ada history per verification — kalau organizer ajukan ulang setelah reject, history sebelumnya hilang (di-overwrite). + +--- + +## Phase 1 — Reopen Rejected Verification ⏳ + +Tombol di REJECTED detail untuk reset ke PENDING supaya admin/organizer bisa coba lagi tanpa harus drop & recreate. + +**Keputusan asumsi:** +- Tidak hapus `rejectionReason` saat reopen — simpan untuk history (rename field jadi `lastRejectionReason`). Sebenarnya rejectionReason cuma string, kalau di-reopen lalu di-reject lagi otomatis di-overwrite. Untuk MVP cukup itu. +- Service method baru `reopenVerification(verifId, adminId, note)` — set `status = PENDING`, clear `reviewedBy/reviewedAt`, log via [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) Phase 4 `auditLog.record`. +- UI: tombol "Buka kembali" di REJECTED card dengan modal note wajib. +- Tidak otomatis kirim notif ke organizer di MVP — admin coordinate via email/WA. + +| # | Item | Status | File | +|---|---|---|---| +| 1.1 | `organizerService.reopenVerification(verifId, adminId, note)` — set PENDING + clear review fields | ⏳ | [server/services/organizer.service.ts](server/services/organizer.service.ts) | +| 1.2 | Server action `reopenVerificationAction(verifId, note)` (guard isAdmin) | ⏳ | [features/organizer/actions.ts](features/organizer/actions.ts) | +| 1.3 | UI: tombol "Buka kembali" di tab REJECTED dengan modal note | ⏳ | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) atau [features/organizer/components/review-card.tsx](features/organizer/components/review-card.tsx) | +| 1.4 | Tampilkan `lastRejectionReason` di tab PENDING juga (sebagai konteks "ini submission ke-N") | ⏳ | [features/organizer/components/review-card.tsx](features/organizer/components/review-card.tsx) | + +**Tindakan manual:** +1. Brief admin: jangan reopen tanpa konfirmasi organizer sudah siap upload ulang. Note wajib menjelaskan alasan reopen. + +--- + +## Phase 2 — Re-upload Request Flow ⏳ + +Daripada reject penuh, admin bisa request specific field di-update (KTP buram, foto liveness terlalu gelap). + +**Keputusan asumsi:** +- Tambah `OrganizerVerification.reuploadRequested Boolean @default(false)` + `reuploadFields String[]` + `reuploadNote String?`. +- Saat di-request, set `status = PENDING` (atau status baru `NEEDS_REUPLOAD`). Pakai `PENDING` saja supaya tidak nambah enum (organizer-facing copy bedakan via `reuploadRequested` flag). +- Organizer page `/verify` baca flag → tampilkan banner kuning + highlight field yang di-request. +- Setelah organizer submit ulang, flag auto-clear → status tetap PENDING menunggu admin review. + +| # | Item | Status | File | +|---|---|---|---| +| 2.1 | Migration: tambah `reuploadRequested`, `reuploadFields`, `reuploadNote` di `OrganizerVerification` | ⏳ | `prisma/migrations/` | +| 2.2 | `organizerService.requestReupload(verifId, adminId, fields[], note)` | ⏳ | [server/services/organizer.service.ts](server/services/organizer.service.ts) | +| 2.3 | Server action + UI tombol "Request re-upload" di admin detail (checkbox per field + textarea note) | ⏳ | [features/organizer/actions.ts](features/organizer/actions.ts) + admin page | +| 2.4 | Banner kuning di `/verify` saat `reuploadRequested = true` + highlight field-nya | ⏳ | [app/(public)/verify/page.tsx](app/(public)/verify/page.tsx) + [features/organizer/components/verify-form.tsx](features/organizer/components/verify-form.tsx) | +| 2.5 | Auto-clear `reuploadRequested` saat organizer submit ulang | ⏳ | [features/organizer/actions.ts](features/organizer/actions.ts) (di `submitVerificationAction`) | + +**Tindakan manual:** tidak ada. + +--- + +## Phase 3 — Verification History (opsional) ⏳ + +Kalau audit butuh trace "berapa kali organizer ini coba verify", tambah tabel history. Skip untuk MVP. + +| # | Item | Status | File | +|---|---|---|---| +| 3.1 | Model `OrganizerVerificationHistory` (snapshot per submission) | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) | +| 3.2 | Trigger create snapshot saat submit ulang | ⏳ | [server/services/organizer.service.ts](server/services/organizer.service.ts) | +| 3.3 | Tab "History" di admin verification detail | ⏳ | admin page | + +**Tindakan manual:** tidak ada (skip phase). + +--- + +## Phase 4 — Manual Override (super-low priority) ⏳ + +Admin verifikasi organizer tanpa upload (referral dari partner trusted). Skip kecuali ada use case nyata. + +| # | Item | Status | File | +|---|---|---|---| +| 4.1 | `organizerService.adminCreateVerification(userId, adminId, note)` — buat row APPROVED langsung dengan flag `isManualOverride` | ⏳ | [server/services/organizer.service.ts](server/services/organizer.service.ts) | +| 4.2 | Migration: tambah `isManualOverride Boolean @default(false)` | ⏳ | `prisma/migrations/` | +| 4.3 | UI: tombol "Verify manually" di `/admin/users/[id]` (organizer tab) | ⏳ | `app/admin/users/[id]/page.tsx` | + +**Tindakan manual:** tidak ada (skip phase, evaluate ulang setelah ada partnership program). diff --git a/app/(public)/trips/[id]/page.tsx b/app/(public)/trips/[id]/page.tsx index 201d7a7..9f8ec9c 100644 --- a/app/(public)/trips/[id]/page.tsx +++ b/app/(public)/trips/[id]/page.tsx @@ -17,7 +17,6 @@ import { CancelBookingButton } from "@/features/booking/components/cancel-bookin import { OrganizerJoinRequests } from "@/features/trip/components/organizer-join-requests"; import { OrganizerTrustPanel } from "@/features/trip/components/organizer-trust-panel"; import { TripProgramBlock } from "@/features/trip/components/trip-program-block"; -import { OrganizerPaymentQueue } from "@/features/booking/components/organizer-payment-queue"; import { ImageGallery } from "@/features/trip/components/image-gallery"; import { TripReviewSection } from "@/features/review/components/trip-review-section"; import { RefundPolicySection } from "@/features/refund/components/refund-policy-section"; @@ -135,13 +134,6 @@ export default async function TripDetailPage({ const tripIsFree = isFreeTrip(trip); - // Antrian konfirmasi pembayaran: source dari Booking + Payment (B9). - // Hanya organizer yang butuh data ini, dan hanya untuk trip berbayar. - const paymentPendingBookings = - !tripIsFree && isOrganizer - ? await bookingService.getAwaitingManualForTrip(trip.id) - : []; - // Booking peserta saat ini — dipakai untuk render CancelBookingButton vs // tombol "Batal Ikut" biasa. Hanya untuk non-organizer yang ikut trip. const myBooking = @@ -447,6 +439,13 @@ export default async function TripDetailPage({ ({ + day: i.day, + startTime: i.startTime, + endTime: i.endTime, + activity: i.activity, + order: i.order, + }))} whatsIncluded={trip.whatsIncluded} whatsExcluded={trip.whatsExcluded} /> @@ -469,18 +468,6 @@ export default async function TripDetailPage({ pending={pendingParticipants.map((p) => ({ id: p.id, user: p.user, - markedPaidAt: p.markedPaidAt, - }))} - /> - )} - - {isOrganizer && paymentPendingBookings.length > 0 && ( - ({ - id: b.participantId, - user: { name: b.user.name, image: b.user.image }, - joinStatus: "CONFIRMED" as const, }))} /> )} @@ -498,15 +485,7 @@ export default async function TripDetailPage({ ? currentParticipation.status : null } - participantPayment={ - currentParticipation - ? { - markedPaidAt: currentParticipation.markedPaidAt, - paymentConfirmedAt: - currentParticipation.paymentConfirmedAt, - } - : null - } + bookingStatus={myBooking?.status ?? null} isFull={spotsLeft <= 0} tripStatus={trip.status} isDeparturePast={isDeparturePast} diff --git a/app/(public)/trips/[id]/payment/page.tsx b/app/(public)/trips/[id]/payment/page.tsx index 764284e..620c2ff 100644 --- a/app/(public)/trips/[id]/payment/page.tsx +++ b/app/(public)/trips/[id]/payment/page.tsx @@ -4,15 +4,13 @@ import { notFound, redirect } from "next/navigation"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { tripService } from "@/server/services/trip.service"; -import { organizerService } from "@/server/services/organizer.service"; import { bookingService } from "@/server/services/booking.service"; +import { paymentService } from "@/server/services/payment.service"; import { formatRupiah } from "@/lib/utils"; import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates"; import { isFreeTrip } from "@/lib/trip-pricing"; import { categoryMeta } from "@/lib/activity-category"; -import { MarkPaidButton } from "@/features/booking/components/mark-paid-button"; import { MidtransPayButton } from "@/features/booking/components/midtrans-pay-button"; -import { CopyButton } from "@/features/booking/components/copy-button"; export const metadata: Metadata = { title: "Detail Pembayaran", @@ -21,9 +19,10 @@ export const metadata: Metadata = { interface PageProps { params: Promise<{ id: string }>; + searchParams: Promise>; } -export default async function PaymentPage({ params }: PageProps) { +export default async function PaymentPage({ params, searchParams }: PageProps) { const { id } = await params; const session = await getServerSession(authOptions); if (!session?.user) { @@ -37,11 +36,26 @@ export default async function PaymentPage({ params }: PageProps) { notFound(); } - // Organizer trip-nya sendiri tidak butuh halaman pembayaran. if (trip.organizerId === session.user.id) { redirect(`/trips/${id}`); } + // Saat user kembali dari Snap, Midtrans append `order_id` (+ status_code + + // transaction_status) ke finishUrl. Tarik status terkini dari Core API + // sebelum render supaya UI sinkron tanpa menunggu webhook — penting di dev + // (localhost) dan saat webhook tertunda. + const sp = await searchParams; + const orderIdParam = sp.order_id; + const orderId = Array.isArray(orderIdParam) ? orderIdParam[0] : orderIdParam; + if (orderId) { + try { + await paymentService.reconcileFromGateway(orderId, session.user.id); + } catch { + // Jangan blokir render kalau gateway tidak responsif — webhook tetap + // sumber kebenaran jangka panjang. Status di UI akan apa adanya dari DB. + } + } + const booking = await bookingService.getByTripAndUser( trip.id, session.user.id @@ -51,15 +65,10 @@ export default async function PaymentPage({ params }: PageProps) { return ; } - const latestManualPayment = booking.payments.find( - (p) => p.provider === "MANUAL" - ); - const tripIsFree = isFreeTrip(trip); const catMeta = categoryMeta(trip.category); const dateRange = formatTripCalendarDateRangeLong(trip.date, trip.endDate); - // Header info — sama untuk free vs paid const tripHeader = (
@@ -104,29 +113,19 @@ export default async function PaymentPage({ params }: PageProps) {

{tripIsFree ? "Trip ini gratis — kamu tidak perlu transfer apa-apa." - : "Transfer manual ke rekening organizer di bawah, lalu tandai sebagai sudah bayar."} + : "Bayar lewat Midtrans untuk mengamankan slot kamu. Pembayaran akan ter-konfirmasi otomatis."}

{tripHeader} {tripIsFree ? ( - + ) : ( )}
@@ -153,19 +152,21 @@ function NotJoinedNotice({ tripId, title }: { tripId: string; title: string }) { ); } +type BookingStatus = + | "PENDING" + | "AWAITING_PAY" + | "PAID" + | "CANCELLED" + | "REFUNDED" + | "PARTIALLY_REFUNDED" + | "EXPIRED"; + function FreeTripSection({ tripId, bookingStatus, }: { tripId: string; - bookingStatus: - | "PENDING" - | "AWAITING_PAY" - | "PAID" - | "CANCELLED" - | "REFUNDED" - | "PARTIALLY_REFUNDED" - | "EXPIRED"; + bookingStatus: BookingStatus; }) { return (
@@ -202,146 +203,59 @@ function FreeTripSection({ ); } -async function PaidTripSection({ +function PaidTripSection({ tripId, - organizerId, organizerName, price, bookingStatus, - paymentMarkedAt, - paymentPaidAt, }: { tripId: string; - organizerId: string; organizerName: string; price: number; - bookingStatus: - | "PENDING" - | "AWAITING_PAY" - | "PAID" - | "CANCELLED" - | "REFUNDED" - | "PARTIALLY_REFUNDED" - | "EXPIRED"; - paymentMarkedAt: Date | null; - paymentPaidAt: Date | null; + bookingStatus: BookingStatus; }) { - const verification = await organizerService.getStatusForUser(organizerId); - const bankAvailable = verification?.status === "APPROVED"; - - const isApproved = bookingStatus === "AWAITING_PAY" || bookingStatus === "PAID"; + const isApproved = + bookingStatus === "AWAITING_PAY" || bookingStatus === "PAID"; const isPendingApproval = bookingStatus === "PENDING"; - const hasMarkedPaid = !!paymentMarkedAt || !!paymentPaidAt; const isFullyPaid = bookingStatus === "PAID"; - const canMarkPaid = bookingStatus === "AWAITING_PAY" && !paymentMarkedAt; + const canPay = bookingStatus === "AWAITING_PAY"; return (
- + - {!bankAvailable && ( -
-

Rekening organizer belum tersedia

-

- Organizer trip ini belum melengkapi data verifikasi (bank). Hubungi - organizer langsung lewat profilnya untuk koordinasi pembayaran. +

+
+

+ Total Pembayaran +

+

+ {formatRupiah(price)}

- )} - - {bankAvailable && ( -
-

- Transfer ke rekening organizer -

-

- Pastikan nominal persis seperti tercantum supaya organizer mudah - mencocokkan. -

- -
- - - -
- -
-
- -
    -
  • • Transfer dengan nominal pas, jangan dibulatkan.
  • -
  • • Simpan bukti transfer untuk jaga-jaga jika ada konfirmasi.
  • -
  • - • Setelah transfer, tekan tombol Saya sudah bayar di bawah - supaya organizer tahu dan bisa konfirmasi. -
  • -
-
- )} +

+ Pembayaran diproses oleh Midtrans (BCA VA, GoPay, QRIS, kartu, dll). + Dana ditahan SeTrip sampai trip selesai — bukan transfer langsung + ke organizer. +

+
{isPendingApproval && (
Kamu belum disetujui organizer untuk ikut trip ini. Tunggu persetujuan - dulu sebelum transfer — supaya tidak perlu refund kalau ditolak. + dulu sebelum bayar — supaya tidak perlu refund kalau ditolak.
)} - {canMarkPaid && ( -
- {bankAvailable && ( - <> - -
- - - atau - - -
- - )} - -
- )} + {canPay && } - {hasMarkedPaid && ( -
- {isFullyPaid ? ( -

- ✅ Pembayaran kamu sudah dikonfirmasi oleh{" "} - - {organizerName} - - . Sampai jumpa di trip! -

- ) : ( -

- ⏳ Kamu sudah menandai sudah bayar. Menunggu organizer mengecek - dan mengonfirmasi. -

- )} + {isFullyPaid && ( +
+

+ ✅ Pembayaran kamu sudah terkonfirmasi. Sampai jumpa di trip + bareng{" "} + {organizerName}! +

)} @@ -359,17 +273,14 @@ async function PaidTripSection({ function PaymentTimeline({ approved, - markedPaid, confirmedPaid, }: { approved: boolean; - markedPaid: boolean; confirmedPaid: boolean; }) { const steps = [ { label: "Disetujui organizer", done: approved }, - { label: "Kamu menandai sudah bayar", done: markedPaid }, - { label: "Organizer konfirmasi pembayaran", done: confirmedPaid }, + { label: "Pembayaran terkonfirmasi Midtrans", done: confirmedPaid }, ]; return ( @@ -404,37 +315,3 @@ function PaymentTimeline({
); } - -function BankRow({ - label, - value, - mono, - strong, - copyable, - copyValue, -}: { - label: string; - value: string; - mono?: boolean; - strong?: boolean; - copyable?: boolean; - copyValue?: string; -}) { - return ( -
-
-

- {label} -

-

- {value} -

-
- {copyable && } -
- ); -} diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index ef05f44..621fb6b 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import Link from "next/link"; import { redirect } from "next/navigation"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; @@ -37,12 +38,12 @@ export default async function AdminLayout({

Akun kamu tidak punya akses ke panel admin SeTrip.

- Kembali ke beranda - + ); diff --git a/docs/RELEASE_WORKFLOW.md b/docs/RELEASE_WORKFLOW.md new file mode 100644 index 0000000..eccfcea --- /dev/null +++ b/docs/RELEASE_WORKFLOW.md @@ -0,0 +1,245 @@ +# Release Workflow + +Panduan rilis: commit perubahan, naikan versi, push. Konsisten dengan pola history repo (single feature commit + version commit terpisah). + +--- + +## Aturan versi (semver untuk 0.x) + +Project masih `0.x.y` — API belum stabil, semver yang dipakai: + +| Tipe perubahan | Bump | Contoh | +|---|---|---| +| **MAJOR** `0.x.y → 1.0.0` | hanya saat siap rilis publik / API stabil | nanti | +| **MINOR** `0.11.0 → 0.12.0` | fitur baru, breaking change, schema/migration baru, removal API | midtrans-only flow, structured itinerary | +| **PATCH** `0.10.2 → 0.10.3` | bugfix, dependency upgrade, copy/UI tweaks tanpa schema | upgrade lib vulnerability, fix hydration | + +**Aturan praktis:** kalau perlu jalankan `prisma migrate deploy` setelah pull → minor. Kalau cuma `git pull && pm2 restart` → patch. + +--- + +## Pre-flight check (wajib sebelum commit) + +```bash +# 1. Type check (filter cache stale Next.js) +npx tsc --noEmit 2>&1 | grep -v "\.next" + +# 2. Lint +npm run lint + +# 3. (Opsional) test seed kalau ubah schema/seed +npm run seed +``` + +Kalau ada error di TS atau ESLint, **jangan commit**. Fix dulu. + +--- + +## Standard flow (rekomendasi) + +Pola dari history repo: **1 commit fitur** + **1 commit versi** terpisah. + +### 1. Verify status + +```bash +git status +git diff --stat +``` + +Pastikan tidak ada file sensitif (`.env`, `*.key`, upload KYC, `app/generated/prisma/`) ter-track. + +### 2. Stage perubahan + +Default — semua perubahan logis: + +```bash +git add -A +``` + +Kalau ada file yang sengaja dipisah commit-nya, pakai selective: + +```bash +git add path/to/file1 path/to/file2 +``` + +### 3. Commit fitur + +Pakai pesan singkat, lowercase, deskriptif. Pola history: + +- ✅ `midtrans-only payment + reconcile, structured itinerary items, admin roadmap` +- ✅ `add payment and integration with midtrans` +- ✅ `create public layout and admin and fix escrow and refund` +- ✅ `chore: remove generated prisma client from repository` + +Prefix `chore:`, `fix:` boleh dipakai tapi tidak wajib. Yang penting: deskriptif & ringkas. + +```bash +git commit -m "deskripsi singkat perubahan utama" +``` + +### 4. Bump versi + +Edit manual `package.json` di field `"version"`, atau pakai npm: + +```bash +# Bump tanpa auto-commit & tag (kita commit manual) +npm version 0.12.0 --no-git-tag-version +``` + +`--no-git-tag-version` penting — repo ini **tidak pakai git tag**, cuma commit dengan pesan = nomor versi. + +### 5. Commit versi (terpisah) + +```bash +git add package.json +git commit -m "0.12.0" +``` + +Pesan = nomor versi saja, tanpa prefix/kata lain. Konsisten dengan history (`0.11.0`, `0.10.3`, `0.10.2`, ...). + +### 6. Push + +```bash +git push origin main +``` + +--- + +## Post-deploy actions + +Setelah merge ke main + auto-deploy / `git pull` di server: + +### Wajib kalau ada migration baru + +```bash +# Cek dulu migration belum applied +npx prisma migrate status + +# Apply +npx prisma migrate deploy + +# Restart PM2 supaya Prisma client re-load +pm2 restart setrip --update-env +``` + +### Wajib kalau ubah field di env + +```bash +# Edit .env di server, lalu +pm2 restart setrip --update-env +``` + +### Opsional — seed (hanya untuk dev/staging, JANGAN production) + +```bash +npm run seed +``` + +⚠️ **Production**: seed wipe seluruh data. Jangan dijalankan di production. + +--- + +## Skenario umum + +### A. Bug fix kecil (patch) + +```bash +npx tsc --noEmit 2>&1 | grep -v "\.next" && npm run lint + +git add path/to/fix +git commit -m "fix: deskripsi bug" + +npm version patch --no-git-tag-version # 0.11.0 → 0.11.1 +git add package.json +git commit -m "0.11.1" + +git push origin main +``` + +### B. Fitur baru tanpa schema change (minor) + +Sama dengan A, ganti `patch` jadi `minor`: + +```bash +npm version minor --no-git-tag-version # 0.11.0 → 0.12.0 +``` + +### C. Fitur baru DENGAN schema/migration (minor) + +```bash +# 1. Buat migration +npx prisma migrate dev --name nama_migration + +# 2. Smoke test +npm run seed +npx tsc --noEmit 2>&1 | grep -v "\.next" + +# 3. Commit fitur + migration sekaligus +git add -A +git commit -m "deskripsi fitur" + +# 4. Bump versi minor +npm version minor --no-git-tag-version +git add package.json +git commit -m "$(node -p "require('./package.json').version")" + +git push origin main + +# 5. Di production, setelah git pull: +npx prisma migrate deploy +pm2 restart setrip --update-env +``` + +### D. Multiple perubahan logis di branch yang sama (split commits) + +Pisahkan jadi commit kecil per topik supaya history bersih: + +```bash +# Commit 1: foundation (mis. schema + service) +git add prisma/ server/ +git commit -m "add X service" + +# Commit 2: UI yang konsumsi +git add features/ app/ +git commit -m "wire X to UI" + +# Commit 3: docs +git add docs/ *.md +git commit -m "docs: X usage guide" + +# Commit 4: version bump +npm version minor --no-git-tag-version +git add package.json +git commit -m "$(node -p "require('./package.json').version")" + +git push origin main +``` + +--- + +## Kesalahan umum & cara recovery + +| Kesalahan | Recovery | +|---|---| +| Commit pesan typo, **belum push** | `git commit --amend -m "pesan baru"` | +| Commit pesan typo, **sudah push** | jangan amend (force-push hilangin history kolaborator). Bikin commit baru `git commit --allow-empty -m "fix: pesan sebelumnya typo"` atau biarkan | +| Lupa bump versi sebelum push | bikin commit versi baru di atasnya — bukan amend | +| Bump versi salah angka (mis. 0.12.0 padahal harusnya patch 0.11.1) | revisi `package.json`, bikin commit baru `chore: revert version to 0.11.1` | +| Commit termasuk file sensitif (`.env`, upload) | jangan push. `git reset --soft HEAD~1`, un-stage file sensitif, tambah ke `.gitignore`, commit ulang | +| Sudah push dengan file sensitif | rotate secret yang ke-leak, lalu pakai `git filter-repo` atau hubungi maintainer git history | + +--- + +## Cheatsheet (one-liner) + +Untuk update biasa (fitur kecil tanpa schema): + +```bash +npx tsc --noEmit 2>&1 | grep -v "\.next" && npm run lint && \ + git add -A && git commit -m "deskripsi" && \ + npm version minor --no-git-tag-version && \ + git add package.json && git commit -m "$(node -p "require('./package.json').version")" && \ + git push origin main +``` + +Ganti `minor` → `patch` untuk bug fix. Jangan jalankan kalau ada step yang minta keputusan manual (mis. conflict, migration baru). diff --git a/features/booking/actions.ts b/features/booking/actions.ts index 2856055..9273bd5 100644 --- a/features/booking/actions.ts +++ b/features/booking/actions.ts @@ -2,30 +2,12 @@ import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; -import { tripService } from "@/server/services/trip.service"; import { paymentService } from "@/server/services/payment.service"; import { bookingService } from "@/server/services/booking.service"; import { refundService } from "@/server/services/refund.service"; +import { absoluteUrl } from "@/lib/site"; import { revalidatePath } from "next/cache"; -export async function markParticipantPaidAction(tripId: string) { - const session = await getServerSession(authOptions); - if (!session?.user) { - return { error: "Kamu harus login terlebih dahulu" }; - } - - try { - await tripService.markParticipantPayment(tripId, session.user.id); - revalidatePath(`/trips/${tripId}`); - revalidatePath("/trips"); - revalidatePath("/"); - revalidatePath("/profile"); - return { success: true }; - } catch (err) { - return { error: (err as Error).message }; - } -} - export type StartMidtransResponse = | { error: string } | { @@ -33,6 +15,7 @@ export type StartMidtransResponse = snapToken: string; snapJsUrl: string; clientKey: string; + orderId: string; }; /** @@ -58,39 +41,51 @@ export async function startMidtransPaymentAction( const result = await paymentService.startMidtransPayment( booking.id, - session.user.id + session.user.id, + { finishUrl: absoluteUrl(`/trips/${tripId}/payment`) } ); return { success: true, snapToken: result.snapToken, snapJsUrl: result.snapJsUrl, clientKey: result.clientKey, + orderId: result.orderId, }; } catch (err) { return { error: (err as Error).message }; } } -export async function confirmParticipantPaymentAction( - tripId: string, - participantId: string -) { +/** + * Tarik status terkini dari Midtrans untuk satu order, lalu sinkron ke DB. + * Dipakai oleh payment page saat user kembali dari Snap (redirect bawa + * `?order_id=...`), dan oleh `MidtransPayButton` di callback `onSuccess`/ + * `onPending`/`onClose` agar UI ter-update tanpa menunggu webhook. + */ +export async function reconcileMidtransPaymentAction(orderId: string) { const session = await getServerSession(authOptions); if (!session?.user) { return { error: "Kamu harus login terlebih dahulu" }; } + if (!orderId || typeof orderId !== "string") { + return { error: "order_id tidak valid" }; + } try { - await tripService.confirmParticipantPayment( - tripId, - participantId, + const result = await paymentService.reconcileFromGateway( + orderId, session.user.id ); - revalidatePath(`/trips/${tripId}`); - revalidatePath("/trips"); - revalidatePath("/"); - revalidatePath("/profile"); - return { success: true }; + if (!result.ok) { + if (result.reason === "forbidden") { + return { error: "Order ini bukan milikmu" }; + } + if (result.reason === "not_found") { + return { error: "Order tidak ditemukan" }; + } + return { error: "Status pembayaran tidak cocok dengan tagihan" }; + } + return { success: true as const, status: result.status }; } catch (err) { return { error: (err as Error).message }; } diff --git a/features/booking/components/mark-paid-button.tsx b/features/booking/components/mark-paid-button.tsx deleted file mode 100644 index 4130c74..0000000 --- a/features/booking/components/mark-paid-button.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import { markParticipantPaidAction } from "@/features/booking/actions"; - -interface MarkPaidButtonProps { - tripId: string; - disabled?: boolean; -} - -export function MarkPaidButton({ tripId, disabled }: MarkPaidButtonProps) { - const router = useRouter(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); - - async function handleClick() { - setLoading(true); - setError(""); - const result = await markParticipantPaidAction(tripId); - setLoading(false); - if (result.error) { - setError(result.error); - return; - } - router.refresh(); - } - - return ( -
- {error && ( -
- {error} -
- )} - -

- Tekan setelah kamu transfer ke rekening di atas. Organizer akan cek & - konfirmasi pembayaran kamu. -

-
- ); -} diff --git a/features/booking/components/midtrans-pay-button.tsx b/features/booking/components/midtrans-pay-button.tsx index a4b1325..2b7d42c 100644 --- a/features/booking/components/midtrans-pay-button.tsx +++ b/features/booking/components/midtrans-pay-button.tsx @@ -2,7 +2,10 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; -import { startMidtransPaymentAction } from "@/features/booking/actions"; +import { + reconcileMidtransPaymentAction, + startMidtransPaymentAction, +} from "@/features/booking/actions"; interface SnapCallbacks { onSuccess?: (result: unknown) => void; @@ -86,23 +89,25 @@ export function MidtransPayButton({ tripId }: MidtransPayButtonProps) { return; } + const orderId = result.orderId; + // Tarik status terkini dari Midtrans server-side, lalu refresh halaman. + // Tidak menunggu webhook supaya UI ter-update saat webhook belum sampai + // (mis. di localhost) atau redirect flow di mana popup tidak dipakai. + async function reconcileAndRefresh() { + await reconcileMidtransPaymentAction(orderId); + router.refresh(); + } + window.snap.pay(result.snapToken, { - onSuccess: () => { - // Webhook server akan tetap jadi sumber kebenaran. Refresh page untuk pull state baru. - router.refresh(); - }, - onPending: () => router.refresh(), + onSuccess: reconcileAndRefresh, + onPending: reconcileAndRefresh, onError: () => { setError( "Pembayaran gagal diproses. Coba lagi atau pakai metode lain." ); - router.refresh(); - }, - onClose: () => { - // User menutup popup tanpa menyelesaikan. Refresh saja, kalau status berubah - // (mis. user sudah bayar VA) callback dari Midtrans akan datang ke webhook. - router.refresh(); + void reconcileAndRefresh(); }, + onClose: reconcileAndRefresh, }); setLoading(false); diff --git a/features/booking/components/organizer-payment-queue.tsx b/features/booking/components/organizer-payment-queue.tsx deleted file mode 100644 index 4515b59..0000000 --- a/features/booking/components/organizer-payment-queue.tsx +++ /dev/null @@ -1,102 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import Image from "next/image"; -import { confirmParticipantPaymentAction } from "@/features/booking/actions"; - -export interface PaymentPendingParticipant { - id: string; - user: { name: string; image: string | null }; - /** PENDING atau CONFIRMED (join) — keduanya bisa sudah tandai bayar */ - joinStatus: "PENDING" | "CONFIRMED"; -} - -interface OrganizerPaymentQueueProps { - tripId: string; - items: PaymentPendingParticipant[]; -} - -export function OrganizerPaymentQueue({ - tripId, - items, -}: OrganizerPaymentQueueProps) { - const router = useRouter(); - const [loadingId, setLoadingId] = useState(null); - const [error, setError] = useState(""); - - async function confirm(participantId: string) { - setLoadingId(participantId); - setError(""); - const result = await confirmParticipantPaymentAction(tripId, participantId); - setLoadingId(null); - if (result.error) { - setError(result.error); - return; - } - router.refresh(); - } - - return ( -
-

- Konfirmasi pembayaran ({items.length}) -

-

- Peserta sudah menandai pembayaran. Cek rekening atau bukti transfer, - lalu konfirmasi. -

- {error && ( -

- {error} -

- )} -
    - {items.map((p) => ( -
  • -
    - {p.user.image ? ( - - ) : ( -
    - {p.user.name.charAt(0).toUpperCase()} -
    - )} -
    -

    - {p.user.name} -

    -

    - Menunggu konfirmasi pembayaran - {p.joinStatus === "PENDING" && ( - - {" "} - · belum disetujui ikut trip - - )} -

    -
    -
    - -
  • - ))} -
-
- ); -} diff --git a/features/profile/components/profile-trip-row.tsx b/features/profile/components/profile-trip-row.tsx index 44f2f34..ca7764d 100644 --- a/features/profile/components/profile-trip-row.tsx +++ b/features/profile/components/profile-trip-row.tsx @@ -20,20 +20,17 @@ export function ProfileTripRow({ rightSlot, }: ProfileTripRowProps) { return ( - -
+
+

{title}

{destination}

{formatTripCalendarDateRangeLong(date, endDate)}

-
+ {rightSlot && (
{rightSlot}
)} - +
); } diff --git a/features/trip/actions.ts b/features/trip/actions.ts index 9fd88b2..cb048a0 100644 --- a/features/trip/actions.ts +++ b/features/trip/actions.ts @@ -2,7 +2,11 @@ import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; -import { createTripSchema, tripImageUrlsSchema } from "./schemas"; +import { + createTripSchema, + itineraryItemsSchema, + tripImageUrlsSchema, +} from "./schemas"; import { tripService } from "@/server/services/trip.service"; import { organizerService } from "@/server/services/organizer.service"; import { revalidatePath } from "next/cache"; @@ -21,7 +25,6 @@ export async function createTripAction(formData: FormData) { destination: formData.get("destination") as string, location: formData.get("location") as string, meetingPoint: formData.get("meetingPoint") as string, - itinerary: formData.get("itinerary") as string, whatsIncluded: formData.get("whatsIncluded") as string, whatsExcluded: formData.get("whatsExcluded") as string, date: formData.get("date") as string, @@ -36,6 +39,22 @@ export async function createTripAction(formData: FormData) { return { error: result.error.issues[0].message }; } + const itineraryJson = formData.get("itineraryItems"); + let itineraryItems: ReturnType = []; + if (typeof itineraryJson === "string" && itineraryJson.trim().length > 0) { + let parsed: unknown; + try { + parsed = JSON.parse(itineraryJson); + } catch { + return { error: "Format itinerary tidak valid" }; + } + const itineraryParsed = itineraryItemsSchema.safeParse(parsed); + if (!itineraryParsed.success) { + return { error: itineraryParsed.error.issues[0].message }; + } + itineraryItems = itineraryParsed.data; + } + if (result.data.price > 0) { const approved = await organizerService.isApproved(session.user.id); if (!approved) { @@ -69,7 +88,6 @@ export async function createTripAction(formData: FormData) { try { const { meetingPoint, - itinerary, whatsIncluded, whatsExcluded, ...tripCore @@ -78,13 +96,13 @@ export async function createTripAction(formData: FormData) { const trip = await tripService.createTrip({ ...tripCore, meetingPoint, - itinerary, whatsIncluded, whatsExcluded, date, endDate, organizerId: session.user.id, imageUrls: imageUrls.length > 0 ? imageUrls : undefined, + itineraryItems: itineraryItems.length > 0 ? itineraryItems : undefined, }); revalidatePath("/trips"); revalidatePath("/"); diff --git a/features/trip/components/create-trip-form.tsx b/features/trip/components/create-trip-form.tsx index 1baf6d6..86efe2c 100644 --- a/features/trip/components/create-trip-form.tsx +++ b/features/trip/components/create-trip-form.tsx @@ -1,19 +1,96 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import DatePicker from "react-datepicker"; import "react-datepicker/dist/react-datepicker.css"; import { createTripAction } from "@/features/trip/actions"; import { ImageUrlInput } from "@/features/trip/components/image-url-input"; import { formatLocalCalendarYmd } from "@/lib/trip-dates"; -import { - ACTIVITY_CATEGORIES, - categoryMeta, -} from "@/lib/activity-category"; +import { ACTIVITY_CATEGORIES, categoryMeta } from "@/lib/activity-category"; import { VIBES, vibeMeta } from "@/lib/vibe"; +import { LIMITS } from "@/lib/limits"; +import { + isValidTimeFormat, + timeToMinutes, + type ItineraryItemInput, +} from "@/lib/itinerary"; import type { ActivityCategory, Vibe } from "@/app/generated/prisma/enums"; +type Step = 1 | 2 | 3 | 4; + +type DraftItineraryItem = { + startTime: string; + endTime: string; + activity: string; +}; + +type ItineraryDays = DraftItineraryItem[][]; + +type FormState = { + category: ActivityCategory; + vibe: Vibe | null; + title: string; + destination: string; + location: string; + description: string; + meetingPoint: string; + itineraryDays: ItineraryDays; + whatsIncluded: string; + whatsExcluded: string; + imageUrls: string[]; + maxParticipants: string; + priceDisplay: string; +}; + +const INITIAL_STATE: FormState = { + category: "HIKING", + vibe: null, + title: "", + destination: "", + location: "", + description: "", + meetingPoint: "", + itineraryDays: [], + whatsIncluded: "", + whatsExcluded: "", + imageUrls: [""], + maxParticipants: "", + priceDisplay: "", +}; + +function flattenItinerary(days: ItineraryDays): ItineraryItemInput[] { + const out: ItineraryItemInput[] = []; + days.forEach((dayItems, dayIdx) => { + dayItems.forEach((item) => { + if ( + !item.startTime && + !item.endTime && + item.activity.trim().length === 0 + ) { + return; + } + out.push({ + day: dayIdx + 1, + startTime: item.startTime, + endTime: item.endTime ? item.endTime : null, + activity: item.activity, + }); + }); + }); + return out; +} + +const STEPS: { id: Step; label: string; subtitle: string }[] = [ + { id: 1, label: "Vibe", subtitle: "Jenis aktivitas & vibe" }, + { id: 2, label: "Info", subtitle: "Judul, destinasi & lokasi" }, + { id: 3, label: "Detail", subtitle: "Itinerary & foto" }, + { id: 4, label: "Jadwal", subtitle: "Tanggal, peserta & harga" }, +]; + +const INPUT_CLS = + "w-full rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-2.5 text-sm text-neutral-800 placeholder:text-neutral-400 focus:bg-white"; + function formatRupiahInput(value: string): string { const num = value.replace(/\D/g, ""); return num.replace(/\B(?=(\d{3})+(?!\d))/g, "."); @@ -29,382 +106,1127 @@ interface CreateTripFormProps { export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) { const router = useRouter(); - const [error, setError] = useState(""); + const [step, setStep] = useState(1); + const [maxStepReached, setMaxStepReached] = useState(1); + const [stepError, setStepError] = useState(""); + const [submitError, setSubmitError] = useState(""); const [loading, setLoading] = useState(false); - const [category, setCategory] = useState("HIKING"); - const [vibe, setVibe] = useState(null); + const [state, setState] = useState(INITIAL_STATE); const [startDate, setStartDate] = useState(null); const [endDate, setEndDate] = useState(null); - const [priceDisplay, setPriceDisplay] = useState(""); - const meta = categoryMeta(category); - const priceNumber = Number(parseRupiahInput(priceDisplay) || "0"); + const meta = categoryMeta(state.category); + const priceNumber = useMemo( + () => Number(parseRupiahInput(state.priceDisplay) || "0"), + [state.priceDisplay] + ); const isPaidTrip = priceNumber > 0; const blockedByVerification = isPaidTrip && !isVerifiedOrganizer; + function update(key: K, value: FormState[K]) { + setState((prev) => ({ ...prev, [key]: value })); + } + + function validateStep(target: Step): string | null { + if (target === 1) { + if (!state.category) return "Pilih kategori aktivitas dulu"; + return null; + } + if (target === 2) { + if (state.title.trim().length < 3) return "Judul minimal 3 karakter"; + if (state.title.trim().length > LIMITS.MAX_TITLE_LENGTH) { + return `Judul maksimal ${LIMITS.MAX_TITLE_LENGTH} karakter`; + } + if (state.destination.trim().length < 2) { + return `${meta.destinationLabel} harus diisi`; + } + if (state.location.trim().length < 2) return "Lokasi harus diisi"; + return null; + } + if (target === 3) { + const hasInvalidUrl = state.imageUrls + .map((u) => u.trim()) + .filter(Boolean) + .some((u) => { + try { + const parsed = new URL(u); + return parsed.protocol !== "http:" && parsed.protocol !== "https:"; + } catch { + return true; + } + }); + if (hasInvalidUrl) return "Ada URL foto yang tidak valid (harus http/https)"; + + for (let d = 0; d < state.itineraryDays.length; d++) { + const dayItems = state.itineraryDays[d]; + for (const item of dayItems) { + const hasAnyInput = + item.startTime.trim() || + item.endTime.trim() || + item.activity.trim(); + if (!hasAnyInput) continue; + if (!isValidTimeFormat(item.startTime)) { + return `Hari ${d + 1}: jam mulai harus HH:mm`; + } + if (item.endTime && !isValidTimeFormat(item.endTime)) { + return `Hari ${d + 1}: jam selesai harus HH:mm`; + } + if ( + item.endTime && + timeToMinutes(item.endTime) < timeToMinutes(item.startTime) + ) { + return `Hari ${d + 1}: jam selesai tidak boleh sebelum jam mulai`; + } + if (item.activity.trim().length === 0) { + return `Hari ${d + 1}: deskripsi aktivitas harus diisi`; + } + if ( + item.activity.trim().length > LIMITS.MAX_ITINERARY_ACTIVITY_LENGTH + ) { + return `Aktivitas maksimal ${LIMITS.MAX_ITINERARY_ACTIVITY_LENGTH} karakter`; + } + } + } + const totalItems = flattenItinerary(state.itineraryDays).length; + if (totalItems > LIMITS.MAX_ITINERARY_ITEMS) { + return `Maksimal ${LIMITS.MAX_ITINERARY_ITEMS} aktivitas total`; + } + return null; + } + if (target === 4) { + if (!startDate) return "Tanggal berangkat harus diisi"; + const max = Number(state.maxParticipants); + if (!Number.isFinite(max) || max < LIMITS.MIN_PARTICIPANTS) { + return `Minimal ${LIMITS.MIN_PARTICIPANTS} peserta`; + } + if (max > LIMITS.MAX_PARTICIPANTS) { + return `Maksimal ${LIMITS.MAX_PARTICIPANTS} peserta`; + } + if (priceNumber < 0) return "Harga tidak valid"; + if (priceNumber > LIMITS.MAX_PRICE_IDR) { + return `Harga maksimal Rp ${LIMITS.MAX_PRICE_IDR.toLocaleString("id-ID")}`; + } + if (blockedByVerification) { + return "Trip berbayar butuh verifikasi organizer terlebih dahulu"; + } + return null; + } + return null; + } + + function goNext() { + const err = validateStep(step); + if (err) { + setStepError(err); + return; + } + setStepError(""); + const next = Math.min(step + 1, STEPS.length) as Step; + setStep(next); + if (next > maxStepReached) setMaxStepReached(next); + } + + function goBack() { + setStepError(""); + setStep((s) => (Math.max(1, s - 1) as Step)); + } + + function jumpTo(target: Step) { + if (target > maxStepReached) return; + setStepError(""); + setStep(target); + } + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); - setError(""); + setSubmitError(""); - if (!startDate) { - setError("Tanggal berangkat harus diisi"); - return; + for (const s of [1, 2, 3, 4] as Step[]) { + const err = validateStep(s); + if (err) { + setStep(s); + setStepError(err); + if (s > maxStepReached) setMaxStepReached(s); + return; + } } setLoading(true); - const formData = new FormData(e.currentTarget); - formData.set("date", formatLocalCalendarYmd(startDate)); - if (endDate) { + const fd = new FormData(); + fd.set("category", state.category); + fd.set("title", state.title.trim()); + fd.set("destination", state.destination.trim()); + fd.set("location", state.location.trim()); + fd.set("description", state.description.trim()); + fd.set("meetingPoint", state.meetingPoint.trim()); + fd.set("whatsIncluded", state.whatsIncluded.trim()); + fd.set("whatsExcluded", state.whatsExcluded.trim()); + fd.set("maxParticipants", state.maxParticipants); + fd.set("price", parseRupiahInput(state.priceDisplay) || "0"); + if (state.vibe) fd.set("vibe", state.vibe); + + if (startDate) fd.set("date", formatLocalCalendarYmd(startDate)); + if (endDate && startDate) { const startYmd = formatLocalCalendarYmd(startDate); const endYmd = formatLocalCalendarYmd(endDate); - if (endYmd !== startYmd) { - formData.set("endDate", endYmd); - } + if (endYmd !== startYmd) fd.set("endDate", endYmd); } - formData.set("price", parseRupiahInput(priceDisplay)); - if (vibe) formData.set("vibe", vibe); - const result = await createTripAction(formData); + for (const url of state.imageUrls.map((u) => u.trim()).filter(Boolean)) { + fd.append("imageUrls", url); + } + const itineraryItems = flattenItinerary(state.itineraryDays); + if (itineraryItems.length > 0) { + fd.set("itineraryItems", JSON.stringify(itineraryItems)); + } + + const result = await createTripAction(fd); setLoading(false); if (result.error) { - setError(result.error); - } else if (result.tripId) { + setSubmitError(result.error); + return; + } + if (result.tripId) { router.push(`/trips/${result.tripId}`); } } - function handleDateChange(dates: [Date | null, Date | null]) { - const [start, end] = dates; - setStartDate(start); - setEndDate(end); - } - - function handlePriceChange(e: React.ChangeEvent) { - const raw = e.target.value.replace(/\D/g, ""); - setPriceDisplay(raw ? formatRupiahInput(raw) : ""); - } + const isLastStep = step === STEPS.length; return ( -
- {error && ( -
- {error} -
- )} +
+ -
- {/* Category Chips */} -
- -
- {ACTIVITY_CATEGORIES.map((c) => { - const m = categoryMeta(c); - const active = c === category; - return ( - - ); - })} + + {step === 1 && ( + update("category", c)} + onVibeChange={(v) => update("vibe", v)} + /> + )} + + {step === 2 && ( + + )} + + {step === 3 && ( + + )} + + {step === 4 && ( + { + setStartDate(s); + setEndDate(e); + }} + onMaxParticipantsChange={(v) => update("maxParticipants", v)} + onPriceChange={(v) => update("priceDisplay", v)} + summary={{ + ...state, + startDate, + endDate, + priceNumber, + }} + /> + )} + + {(stepError || submitError) && ( +
+ {stepError || submitError}
- -
+ )} - {/* Vibe Chips */} -
- -

- Bantu calon peserta menilai apakah ritmenya cocok dengan mereka. -

-
+
+ + + {isLastStep ? ( + + ) : ( - {VIBES.map((v) => { - const m = vibeMeta(v); - const active = v === vibe; - return ( - - ); - })} -
- {vibe && ( -

- {vibeMeta(vibe).description} -

)}
- -
- - -
- -
-
- - -
-
- - -
-
- -
- -