trust roadmap
This commit is contained in:
@@ -2,7 +2,10 @@
|
|||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"WebFetch(domain:unsplash.com)",
|
"WebFetch(domain:unsplash.com)",
|
||||||
"Bash(npx prisma *)"
|
"Bash(npx prisma *)",
|
||||||
|
"Bash(Get-ChildItem -Path \"c:\\\\development\\\\DIOS\\\\weekly-project\\\\setrip\" -Force)",
|
||||||
|
"Bash(Select-Object Name, PSIsContainer)",
|
||||||
|
"Bash(npx tsc *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,265 +0,0 @@
|
|||||||
# Setrip — Payment Roadmap
|
|
||||||
|
|
||||||
Rencana implementasi flow pembayaran: handle trip gratis, halaman detail payment, dan integrasi Midtrans.
|
|
||||||
|
|
||||||
> **Prinsip:** Sumber kebenaran pembayaran = record di tabel khusus, bukan timestamp di `TripParticipant`. Manual transfer & Midtrans = dua provider dari pipeline yang sama. Trip gratis = first-class case (bukan paid trip dengan amount 0).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Audit state sekarang (baseline)
|
|
||||||
|
|
||||||
- Field `TripParticipant.markedPaidAt` + `paymentConfirmedAt` — dual-timestamp manual.
|
|
||||||
- Flow: user klik "Saya sudah bayar" → organizer klik "Konfirmasi pembayaran". Tidak ada record transaksi terpisah, tidak ada bukti transfer, tidak ada amount snapshot.
|
|
||||||
- Tidak ada halaman `/trips/[id]/payment`. Tombol bayar inline di trip detail.
|
|
||||||
- **Bug**: trip gratis (`price === 0`) tetap melewati flow yang sama — peserta tetap harus klik "Saya sudah bayar" dan organizer tetap harus konfirmasi.
|
|
||||||
|
|
||||||
File terkait: [server/services/trip.service.ts](server/services/trip.service.ts) (`markParticipantPayment`, `confirmParticipantPayment`), [features/booking/actions.ts](features/booking/actions.ts), [features/booking/components/organizer-payment-queue.tsx](features/booking/components/organizer-payment-queue.tsx), [features/trip/components/join-trip-button.tsx](features/trip/components/join-trip-button.tsx), [server/repositories/participant.repo.ts](server/repositories/participant.repo.ts).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PR A — Free trip handling + halaman detail payment (manual flow) ✅
|
|
||||||
|
|
||||||
Selesai. `tsc --noEmit` lulus. Tanpa schema baru, tanpa migration.
|
|
||||||
|
|
||||||
**Keputusan akses control:** halaman `/trips/[id]/payment` boleh diakses peserta apapun yang aktif (PENDING/CONFIRMED). Peserta PENDING bisa lihat nominal + rekening untuk persiapan, tapi diberi notice "tunggu approve dulu sebelum transfer". Organizer trip-nya sendiri di-redirect ke trip detail.
|
|
||||||
|
|
||||||
| # | Item | Status | File |
|
|
||||||
|---|---|---|---|
|
|
||||||
| A1 | Helper `lib/trip-pricing.ts` dengan `isFreeTrip` & `isPaidTrip` | ✅ | [lib/trip-pricing.ts](lib/trip-pricing.ts) |
|
|
||||||
| A2 | Service guard: `markParticipantPayment` & `confirmParticipantPayment` reject trip gratis | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) |
|
|
||||||
| A3 | UI gate di JoinTripButton: hide flow pembayaran kalau gratis | ✅ | [features/trip/components/join-trip-button.tsx](features/trip/components/join-trip-button.tsx) |
|
|
||||||
| A4 | UI gate di trip detail: skip `OrganizerPaymentQueue` kalau gratis | ✅ | [app/trips/[id]/page.tsx](app/trips/[id]/page.tsx) |
|
|
||||||
| A5 | Halaman `/trips/[id]/payment` (server component dengan akses kontrol) | ✅ | [app/trips/[id]/payment/page.tsx](app/trips/%5Bid%5D/payment/page.tsx) |
|
|
||||||
| A6 | Konten halaman trip gratis: banner 🎉 + status keikutsertaan + CTA | ✅ | `FreeTripSection` di [page.tsx](app/trips/%5Bid%5D/payment/page.tsx) |
|
|
||||||
| A7 | Konten halaman trip berbayar: timeline + rekening organizer + tombol "Saya sudah bayar" + status | ✅ | `PaidTripSection`, `PaymentTimeline`, `BankRow` di [page.tsx](app/trips/%5Bid%5D/payment/page.tsx) |
|
|
||||||
| A8 | Link dari trip detail → `/trips/[id]/payment` (replace tombol inline) | ✅ | `showPaymentLink` di [join-trip-button.tsx](features/trip/components/join-trip-button.tsx) |
|
|
||||||
| A9 | Metadata + `robots: noindex` halaman payment | ✅ | [page.tsx](app/trips/%5Bid%5D/payment/page.tsx) — `metadata.robots: { index: false, follow: false }` |
|
|
||||||
| A+ | `MarkPaidButton` (client component) untuk action "Saya sudah bayar" | ✅ | [features/booking/components/mark-paid-button.tsx](features/booking/components/mark-paid-button.tsx) |
|
|
||||||
| A+ | `CopyButton` (client component) untuk copy nomor rekening / nominal | ✅ | [features/booking/components/copy-button.tsx](features/booking/components/copy-button.tsx) |
|
|
||||||
|
|
||||||
**Tindakan manual:** tidak ada. Tidak ada migration di PR A.
|
|
||||||
|
|
||||||
**Catatan edge case yang sudah dihandle:**
|
|
||||||
- Organizer trip-nya sendiri buka halaman → redirect ke trip detail.
|
|
||||||
- User belum login → redirect ke login dengan `callbackUrl`.
|
|
||||||
- User belum join / sudah cancel → tampil notice "kamu belum terdaftar".
|
|
||||||
- Trip dengan organizer yang belum APPROVED verifikasinya → tampil notice "rekening belum tersedia, hubungi organizer langsung" (tidak crash).
|
|
||||||
- Peserta PENDING di-warning "tunggu approve dulu sebelum transfer".
|
|
||||||
- Tombol "Saya sudah bayar" hanya muncul untuk CONFIRMED + belum mark + bank tersedia.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PR B — Refactor schema ke `Booking` + `Payment` (provider MANUAL only) ✅
|
|
||||||
|
|
||||||
Selesai. `tsc --noEmit` lulus. Pondasi untuk Midtrans sudah siap.
|
|
||||||
|
|
||||||
### Schema (target)
|
|
||||||
|
|
||||||
```prisma
|
|
||||||
model Booking {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
tripId String
|
|
||||||
trip Trip @relation(fields: [tripId], references: [id])
|
|
||||||
userId String
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
|
||||||
participantId String @unique
|
|
||||||
participant TripParticipant @relation(fields: [participantId], references: [id])
|
|
||||||
|
|
||||||
/// Snapshot harga saat booking dibuat — protect dari perubahan trip.price.
|
|
||||||
amount Int
|
|
||||||
currency String @default("IDR")
|
|
||||||
status BookingStatus @default(PENDING)
|
|
||||||
|
|
||||||
payments Payment[]
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@index([tripId, status])
|
|
||||||
@@index([userId])
|
|
||||||
}
|
|
||||||
|
|
||||||
enum BookingStatus {
|
|
||||||
PENDING // join belum approve atau belum bayar
|
|
||||||
AWAITING_PAY // approved, tinggal bayar
|
|
||||||
PAID // lunas (manual confirm atau gateway settlement)
|
|
||||||
CANCELLED
|
|
||||||
REFUNDED
|
|
||||||
EXPIRED
|
|
||||||
}
|
|
||||||
|
|
||||||
model Payment {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
bookingId String
|
|
||||||
booking Booking @relation(fields: [bookingId], references: [id])
|
|
||||||
|
|
||||||
provider PaymentProvider
|
|
||||||
/// order_id eksternal — untuk MANUAL referensi internal, untuk MIDTRANS dikirim ke gateway. Unik per attempt.
|
|
||||||
externalOrderId String @unique
|
|
||||||
externalTxId String?
|
|
||||||
method String? // bca_va, gopay, qris, manual_transfer, dst
|
|
||||||
amount Int
|
|
||||||
status PaymentStatus @default(PENDING)
|
|
||||||
|
|
||||||
rawCallback Json?
|
|
||||||
snapToken String?
|
|
||||||
expiresAt DateTime?
|
|
||||||
|
|
||||||
paidAt DateTime?
|
|
||||||
failedAt DateTime?
|
|
||||||
rejectionReason String?
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@index([bookingId, status])
|
|
||||||
@@index([provider, status])
|
|
||||||
}
|
|
||||||
|
|
||||||
enum PaymentProvider {
|
|
||||||
MANUAL
|
|
||||||
MIDTRANS
|
|
||||||
}
|
|
||||||
|
|
||||||
enum PaymentStatus {
|
|
||||||
PENDING
|
|
||||||
AWAITING
|
|
||||||
PAID
|
|
||||||
FAILED
|
|
||||||
EXPIRED
|
|
||||||
CANCELLED
|
|
||||||
REFUNDED
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tugas
|
|
||||||
|
|
||||||
| # | Item | Status | File |
|
|
||||||
|---|---|---|---|
|
|
||||||
| B1 | Schema: tambah `Booking`, `Payment`, 3 enum (`BookingStatus`, `PaymentProvider`, `PaymentStatus`) + relasi di `User`, `Trip`, `TripParticipant` | ✅ | [prisma/schema.prisma](prisma/schema.prisma) |
|
|
||||||
| B2 | Migration `add_booking_payment` (CreateEnum + CreateTable + Index + FK) | ✅ | [prisma/migrations/20260508150000_add_booking_payment/migration.sql](prisma/migrations/20260508150000_add_booking_payment/migration.sql) |
|
|
||||||
| B3 | Backfill script TS (idempotent, skip baris yang sudah punya Booking) | ✅ | [prisma/backfill-bookings.ts](prisma/backfill-bookings.ts) |
|
|
||||||
| B4 | `bookingRepo` + `paymentRepo` | ✅ | [server/repositories/booking.repo.ts](server/repositories/booking.repo.ts), [server/repositories/payment.repo.ts](server/repositories/payment.repo.ts) |
|
|
||||||
| B5 | `bookingService` — `markPaidManual`, `confirmPaidManual`, `getByTripAndUser`, `getAwaitingManualForTrip` (idempotent, transactional dengan retry serialisasi) | ✅ | [server/services/booking.service.ts](server/services/booking.service.ts) |
|
|
||||||
| B6 | `tripService.markParticipantPayment` → delegate ke `bookingService.markPaidManual`. Tetap update `TripParticipant.markedPaidAt` untuk backcompat. | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) |
|
|
||||||
| B7 | `tripService.confirmParticipantPayment` → delegate ke `bookingService.confirmPaidManual`. Tetap update `paymentConfirmedAt`. | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) |
|
|
||||||
| B+ | `tripService.joinTrip` → upsert Booking PENDING (handle re-join dari CANCELLED) | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) |
|
|
||||||
| B+ | `tripService.confirmParticipant` → transition Booking PENDING → AWAITING_PAY (paid) atau PAID (free) | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) |
|
|
||||||
| B+ | `tripService.cancelJoin` & `rejectParticipant` → Booking → CANCELLED | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) |
|
|
||||||
| B8 | Halaman `/trips/[id]/payment` baca dari Booking + Payment (bukan timestamp lama) | ✅ | [app/trips/[id]/payment/page.tsx](app/trips/%5Bid%5D/payment/page.tsx) |
|
|
||||||
| B9 | `OrganizerPaymentQueue` di trip detail dapat data dari `bookingService.getAwaitingManualForTrip` | ✅ | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) |
|
|
||||||
| B10 | Deprecate `TripParticipant.markedPaidAt` + `paymentConfirmedAt` (komen `@deprecated`, tetap di-update untuk backcompat) | ✅ | [prisma/schema.prisma](prisma/schema.prisma) |
|
|
||||||
| B11 | Index optimization (`@@index([tripId, status])` di Booking, `@@index([provider, status])` di Payment, `@@index([userId])` di Booking) | ✅ | [prisma/schema.prisma](prisma/schema.prisma) |
|
|
||||||
|
|
||||||
**Tindakan manual (urutan penting):**
|
|
||||||
1. `npx prisma migrate deploy` — apply schema migration `20260508150000_add_booking_payment`.
|
|
||||||
2. `npx tsx prisma/backfill-bookings.ts` — populate Booking + Payment dari `TripParticipant` lama. Idempotent, aman dijalankan ulang.
|
|
||||||
3. Verifikasi: jumlah Booking aktif = jumlah TripParticipant aktif setelah backfill.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PR C — Midtrans integration (Snap + webhook) ✅
|
|
||||||
|
|
||||||
Selesai. `tsc --noEmit` lulus. Belum test live ke sandbox Midtrans — perlu env diisi + tunneling kalau dev lokal.
|
|
||||||
|
|
||||||
### Persiapan akun & env
|
|
||||||
|
|
||||||
| Env | Keterangan |
|
|
||||||
|---|---|
|
|
||||||
| `MIDTRANS_SERVER_KEY` | Server key dari dashboard Midtrans (sandbox/production sesuai mode). Rahasia. Server-side only. |
|
|
||||||
| `NEXT_PUBLIC_MIDTRANS_CLIENT_KEY` | Client key untuk Snap.js. Aman di-expose ke frontend (NEXT_PUBLIC_). |
|
|
||||||
| `NEXT_PUBLIC_MIDTRANS_IS_PRODUCTION` | `true` untuk production, `false` (atau kosong) untuk sandbox. NEXT_PUBLIC_ supaya client tahu URL Snap.js yang benar. |
|
|
||||||
|
|
||||||
`MIDTRANS_NOTIFICATION_URL` **tidak** di env — diset langsung di dashboard Midtrans ke `<NEXT_PUBLIC_SITE_URL>/api/webhooks/midtrans`.
|
|
||||||
|
|
||||||
Sudah ditambah ke [env.example](env.example).
|
|
||||||
|
|
||||||
### Tugas
|
|
||||||
|
|
||||||
| # | Item | Status | File |
|
|
||||||
|---|---|---|---|
|
|
||||||
| C1 | Update [env.example](env.example) + 3 env baru + komentar webhook URL | ✅ | [env.example](env.example) |
|
|
||||||
| C2 | `lib/midtrans.ts` — `createSnapTransaction`, `verifyMidtransSignature` (timing-safe compare), `MIDTRANS` config helper | ✅ | [lib/midtrans.ts](lib/midtrans.ts) |
|
|
||||||
| C3 | Status mapping `mapMidtransStatus(transaction_status, fraud_status)` → `PaymentStatus` | ✅ | [lib/midtrans.ts](lib/midtrans.ts) |
|
|
||||||
| C4 | `paymentService.startMidtransPayment(bookingId, userId)` — validate, reuse Payment AWAITING aktif (idempotent re-attempt), atau buat Payment baru + call Snap API + simpan token + expiresAt | ✅ | [server/services/payment.service.ts](server/services/payment.service.ts) |
|
|
||||||
| C5 | Halaman payment: tombol "Bayar online via Midtrans" + divider "atau" + tombol manual lama | ✅ | [app/trips/[id]/payment/page.tsx](app/trips/%5Bid%5D/payment/page.tsx) |
|
|
||||||
| C6 | `MidtransPayButton` client component — load Snap.js dengan `data-client-key` dinamis, `window.snap.pay(token, callbacks)`, refresh page setelah Snap close | ✅ | [features/booking/components/midtrans-pay-button.tsx](features/booking/components/midtrans-pay-button.tsx) |
|
|
||||||
| C7 | Webhook endpoint `app/api/webhooks/midtrans/route.ts` — POST, verify signature, lookup, idempotent, return 200/401 sesuai outcome | ✅ | [app/api/webhooks/midtrans/route.ts](app/api/webhooks/midtrans/route.ts) |
|
|
||||||
| C8 | `paymentService.handleMidtransWebhook` — verifikasi signature, amount check, transaction (`Payment` + `Booking` + `TripParticipant.paymentConfirmedAt` backcompat) | ✅ | [server/services/payment.service.ts](server/services/payment.service.ts) |
|
|
||||||
| C10 | Anti-replay: skip update kalau Payment sudah final (PAID/FAILED/EXPIRED/CANCELLED/REFUNDED) | ✅ | [payment.service.ts](server/services/payment.service.ts) |
|
|
||||||
| C11 | Simpan callback mentah ke `Payment.rawCallback` (audit & dispute), termasuk untuk callback yang di-skip | ✅ | [payment.service.ts](server/services/payment.service.ts) |
|
|
||||||
| C+ | Server action `startMidtransPaymentAction` (resolve booking dari tripId, bridge ke client) | ✅ | [features/booking/actions.ts](features/booking/actions.ts) |
|
|
||||||
| C+ | Retry handling: Payment row baru dengan `midtrans-{bookingId}-{retryN}` kalau attempt lama expired/failed; idempotent reuse kalau masih AWAITING | ✅ | [payment.service.ts](server/services/payment.service.ts) |
|
|
||||||
| C9 | Cron expire Payment lama | ⏸️ skipped | Housekeeping di-handle saat user start payment (auto-expire attempt yang lewat `expiresAt`). Cron formal bisa ditambah kalau perlu cleanup massal. |
|
|
||||||
| C12 | Test scenario sandbox (settlement, deny, expire) | ⏸️ manual | Perlu env Midtrans diisi + tunneling untuk dev lokal (ngrok/cloudflared). Tidak bisa otomatis dari sini. |
|
|
||||||
| C13 | Status badge real-time | ⏸️ partial | Page refresh setelah Snap close + halaman SSR pull state baru tiap reload. Polling otomatis belum diimplementasi. |
|
|
||||||
| C14 | Email/in-app notification setelah PAID | ⏳ pending | Diluar scope PR C — masuk Phase berikutnya. |
|
|
||||||
|
|
||||||
### Mapping `transaction_status` Midtrans → `PaymentStatus`
|
|
||||||
|
|
||||||
| Midtrans | Trigger lain | `PaymentStatus` |
|
|
||||||
|---|---|---|
|
|
||||||
| `capture` | `fraud_status === "accept"` | PAID |
|
|
||||||
| `capture` | `fraud_status === "challenge"` | AWAITING (review manual di dashboard Midtrans) |
|
|
||||||
| `settlement` | — | PAID |
|
|
||||||
| `pending` | — | AWAITING |
|
|
||||||
| `deny` | — | FAILED |
|
|
||||||
| `expire` | — | EXPIRED |
|
|
||||||
| `cancel` | — | CANCELLED |
|
|
||||||
| `refund` / `partial_refund` | — | REFUNDED |
|
|
||||||
|
|
||||||
### Webhook checklist (security)
|
|
||||||
|
|
||||||
1. Verify signature: `sha512(order_id + status_code + gross_amount + SERVER_KEY) === signature_key`. Mismatch → 401, log.
|
|
||||||
2. Cek `gross_amount` cocok dengan `payment.amount` — kalau tidak sama, log anomaly, jangan PAID.
|
|
||||||
3. Lookup `Payment.externalOrderId === order_id`. Tidak ada → 200 OK + log (jangan biarkan Midtrans retry forever).
|
|
||||||
4. Idempotent: kalau status sudah final, skip update tapi tetap return 200.
|
|
||||||
5. Pakai DB transaction untuk update Payment + Booking + TripParticipant bersamaan.
|
|
||||||
6. Selalu return 200 kalau request valid (mismatch signature → 401, sisanya → 200 + log).
|
|
||||||
|
|
||||||
### Hardening pasca-audit (sebelum Midtrans live) ✅
|
|
||||||
|
|
||||||
Empat fix tambahan dari audit security/correctness:
|
|
||||||
|
|
||||||
| Fix | Issue | Solusi | File |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | Webhook bisa overwrite Booking CANCELLED/REFUNDED/EXPIRED jadi PAID | Re-fetch Booking di dalam serializable transaction; kalau state konflik, Payment tetap PAID (uang masuk) tapi Booking tidak di-update + `Payment.rejectionReason` di-flag untuk manual review/refund. Webhook outcome `booking_conflict` di-log warning. | [payment.service.ts](server/services/payment.service.ts), [route.ts](app/api/webhooks/midtrans/route.ts) |
|
|
||||||
| 2 | `startMidtransPayment` lupa cek trip departure date | Tambah `isTripDepartureDayPast` guard, juga di `bookingService.markPaidManual` untuk konsistensi | [payment.service.ts](server/services/payment.service.ts), [booking.service.ts](server/services/booking.service.ts) |
|
|
||||||
| 3 | `Booking` tidak punya constraint `(tripId, userId)` unique | Tambah `@@unique([tripId, userId])` + migration `20260508160000_booking_unique_trip_user`. `findByTripAndUser` switch dari `findFirst` ke `findUnique` (lebih efisien) | [schema.prisma](prisma/schema.prisma), [migration](prisma/migrations/20260508160000_booking_unique_trip_user/migration.sql), [booking.repo.ts](server/repositories/booking.repo.ts) |
|
|
||||||
| 4 | Webhook payload tidak schema-validated | Zod `midtransWebhookSchema` (passthrough untuk forward-compat). Webhook route `safeParse` → 400 kalau shape invalid. Service signature pakai type yang inferred dari schema. | [lib/midtrans.ts](lib/midtrans.ts), [route.ts](app/api/webhooks/midtrans/route.ts), [payment.service.ts](server/services/payment.service.ts) |
|
|
||||||
|
|
||||||
### Edge cases yang gampang lupa
|
|
||||||
|
|
||||||
- **Quota race**: dua user bayar bersamaan untuk slot terakhir → slot di-hold saat Booking dibuat (status AWAITING_PAY masih hitung kuota via `TripParticipant.status`). Release belum otomatis saat Payment EXPIRED — kalau perlu, tambah cron (lihat C9 yang di-skip).
|
|
||||||
- **Trip dibatalkan organizer setelah peserta bayar** → `Booking.status = REFUNDED` setelah dana balik. Implementasi refund Midtrans = PR terpisah (tidak di scope PR C ini).
|
|
||||||
- **User retry pembayaran setelah gagal** → bikin Payment baru, `externalOrderId` baru (`midtrans-{bookingId}-{retryN}`). Reuse kalau masih AWAITING & belum expired.
|
|
||||||
- **Webhook duplicate**: Midtrans bisa kirim notifikasi yang sama 2-3 kali. Idempotent: skip update kalau Payment sudah final, tapi tetap simpan callback ke `rawCallback` untuk audit.
|
|
||||||
- **Sandbox vs production**: webhook URL diset di dashboard Midtrans = `<NEXT_PUBLIC_SITE_URL>/api/webhooks/midtrans`. Dev lokal perlu tunneling (ngrok / cloudflared) supaya endpoint bisa di-reach Midtrans.
|
|
||||||
- **Booking belum approved (`PENDING`) tapi user coba bayar** — `paymentService.startMidtransPayment` reject dengan pesan jelas. UI sudah hide tombol di state ini.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ❌ Anti-list (yang harus DITOLAK kalau muncul)
|
|
||||||
|
|
||||||
- **Halaman payment yang berubah jadi marketplace checkout**: upsell ("trip serupa lebih murah"), pricing comparison, "harga turun" — semua menarik framing OTA. Halaman payment fokus pada satu transaksi: lunas atau belum.
|
|
||||||
- **Multi-method abstraksi prematur**: jangan bikin "PaymentProvider" generic untuk Stripe/Xendit/Doku sekaligus sebelum salah satu jalan. Mulai dari MANUAL + MIDTRANS, baru tambah kalau perlu.
|
|
||||||
- **Auto-refund logic kompleks** sebelum manual refund di dashboard Midtrans dipakai. Refund jarang, manual cukup di Phase awal.
|
|
||||||
- **Payment retry otomatis** dari sisi server. User harus eksplisit klik "bayar lagi" untuk attempt baru — supaya tidak ambigu siapa yang trigger.
|
|
||||||
- **Multi-currency** sebelum ada permintaan eksplisit. `currency` di schema sudah default IDR, tapi tidak perlu UI selector.
|
|
||||||
- **Saving credit card / tokenization** tanpa kebutuhan jelas. PCI scope naik drastis. Snap sudah handle tanpa simpan kartu di sisi kita.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Saran phasing
|
|
||||||
|
|
||||||
PR berurutan. Setiap PR mandiri (siap di-deploy):
|
|
||||||
|
|
||||||
1. **PR A** — Free trip handling + halaman payment (manual). Cepat, low-risk, no migration. **Mulai dari sini.**
|
|
||||||
2. **PR B** — Refactor ke `Booking` + `Payment`. Migration + backfill data lama. UI tetap mirip.
|
|
||||||
3. **PR C** — Tambah Midtrans Snap. Test di sandbox dulu sebelum production.
|
|
||||||
|
|
||||||
Pertanyaan terbuka sebelum mulai:
|
|
||||||
|
|
||||||
1. **Akses halaman payment**: hanya peserta CONFIRMED, atau juga PENDING (yang belum disetujui organizer)? Saya rekomendasi CONFIRMED only — peserta PENDING belum perlu lihat detail bayar.
|
|
||||||
2. **Snapshot amount di Booking**: kalau organizer ubah `trip.price` setelah booking dibuat, booking lama pakai harga lama atau baru? Saya rekomendasi tetap pakai snapshot lama (audit-friendly).
|
|
||||||
3. **Manual + Midtrans co-exist**: user pilih satu provider per booking, atau bisa retry dengan provider berbeda? Saya rekomendasi pilih satu — kalau gagal di Midtrans, bisa cancel dan buat Payment baru dengan provider MANUAL.
|
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# Setrip — Trust & Trip Detail Roadmap
|
||||||
|
|
||||||
|
Status implementasi yang menaikkan kepercayaan calon peserta — trip detail experience yang meyakinkan + sistem reputasi organizer yang transparan.
|
||||||
|
|
||||||
|
> **Prinsip:** trust = fungsi dari (a) kelengkapan informasi trip dan (b) reputasi organizer yang transparan. Setiap fitur dievaluasi: apakah memberi calon peserta alasan obyektif untuk percaya?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Audit state sekarang (baseline)
|
||||||
|
|
||||||
|
**Trip detail (~80% sudah ada):**
|
||||||
|
- ✅ Itinerary, include/exclude, meeting point — schema lengkap di `Trip`, ditampilkan via `TripProgramBlock`.
|
||||||
|
- ✅ Participant preview (kartu confirmed peserta dengan avatar, kota, interests).
|
||||||
|
- ✅ Slot tersisa (X / Y) dengan progress bar berwarna.
|
||||||
|
- ⚠️ Urgency message ada tapi tidak mencolok saat slot menipis.
|
||||||
|
- ⚠️ Itinerary text bebas — organizer butuh hint format supaya konsisten isi lengkap.
|
||||||
|
|
||||||
|
**Review/trust (~40% sudah ada):**
|
||||||
|
- ✅ Model `TripReview` (rating + comment per trip+user, unique).
|
||||||
|
- ✅ Trust panel di trip detail (verified badge, trips created, avg rating, review count).
|
||||||
|
- ❌ Profil organizer publik `/u/[id]` belum tampilkan rating/review aggregate.
|
||||||
|
- ❌ Belum ada total participants served, completion rate, rating breakdown.
|
||||||
|
- ❌ Belum ada list ulasan terkumpul per organizer.
|
||||||
|
- ❌ `TripStatus.COMPLETED` enum-nya ada tapi tidak pernah di-set.
|
||||||
|
|
||||||
|
File baseline: [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx), [server/services/trust.service.ts](server/services/trust.service.ts), [server/services/review.service.ts](server/services/review.service.ts), [app/u/[id]/page.tsx](app/u/%5Bid%5D/page.tsx).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PR-1 — Trip Detail Polish (UI only) ⏳
|
||||||
|
|
||||||
|
Cosmetic. Tidak ada migration, tidak ada perubahan service/repo.
|
||||||
|
|
||||||
|
| # | Item | Status | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1.1 | Urgency badge mencolok saat `spotsLeft <= 3` ("⚡ Tinggal X spot!") di header progress bar | ⏳ | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) |
|
||||||
|
| 1.2 | Participant preview ringkas di blok progress ("👥 Sudah join: Andi, Rina, Budi +4") — first impression tanpa scroll | ⏳ | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) |
|
||||||
|
| 1.3 | Hint deskriptif + placeholder lebih konkret di field itinerary form create-trip | ⏳ | [features/trip/components/create-trip-form.tsx](features/trip/components/create-trip-form.tsx) |
|
||||||
|
|
||||||
|
**Tindakan manual:** tidak ada.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PR-2 — Organizer Trust Aggregates (service + UI, tanpa migration) ✅
|
||||||
|
|
||||||
|
Selesai. `tsc --noEmit` lulus. Tanpa schema baru, tanpa migration.
|
||||||
|
|
||||||
|
**Keputusan asumsi yang dipakai:**
|
||||||
|
- `tripsCompleted` ≠ `Trip.status = COMPLETED` (status itu tidak pernah di-set). Pakai `endDate < now()` (fallback `date < now()`) AND `status != CLOSED`.
|
||||||
|
- `tripsCancelled` = `Trip.status = CLOSED` (organizer batalkan trip eksplisit).
|
||||||
|
- `completionRate` butuh sample ≥ 3 (`COMPLETION_RATE_MIN_SAMPLE` di [lib/trust.ts](lib/trust.ts)) supaya tidak menyesatkan organizer baru.
|
||||||
|
- Rating breakdown di-render sebagai bar chart kecil (visual cue lebih kuat dari angka mentah).
|
||||||
|
- `OrganizerStatsPanel` di profil publik tidak di-render untuk user yang murni peserta — query Prisma juga di-skip kalau `organizedTrips.length === 0 && !isVerifiedOrganizer`.
|
||||||
|
- Trip detail: stat box "Trip dibuat" diganti jadi **"Trip selesai"** (lebih meaningful) + tambah **"Peserta dilayani"**. Total 3 stat box, masih kompak.
|
||||||
|
|
||||||
|
| # | Item | Status | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 2.1 | Extend `OrganizerTrust` type: `tripsCompleted`, `tripsCancelled`, `totalParticipantsServed`, `completionRate`, `ratingBreakdown` | ✅ | [server/services/trust.service.ts](server/services/trust.service.ts) |
|
||||||
|
| 2.2 | `tripsCompleted` di-derive dari `endDate < now()` (fallback `date < now()`) AND `status != CLOSED` | ✅ | [server/services/trust.service.ts](server/services/trust.service.ts) |
|
||||||
|
| 2.3 | `totalParticipantsServed` = count `TripParticipant CONFIRMED` di trip yang sudah lewat & tidak dibatalkan | ✅ | [server/services/trust.service.ts](server/services/trust.service.ts) |
|
||||||
|
| 2.4 | `completionRate` = `tripsCompleted / (tripsCompleted + tripsCancelled)`. Null bila sample < 3 | ✅ | [server/services/trust.service.ts](server/services/trust.service.ts), [lib/trust.ts](lib/trust.ts) |
|
||||||
|
| 2.5 | `ratingBreakdown` via `prisma.tripReview.groupBy({ by: ['rating'] })` | ✅ | [server/services/trust.service.ts](server/services/trust.service.ts) |
|
||||||
|
| 2.6 | Komponen `OrganizerStatsPanel` (badges + 4 stat box + bar chart breakdown) | ✅ | [features/profile/components/organizer-stats-panel.tsx](features/profile/components/organizer-stats-panel.tsx) |
|
||||||
|
| 2.7 | Update `OrganizerTrustPanel` di trip detail — Trip selesai (+ subtitle "berjalan"), Peserta dilayani, Rating | ✅ | [features/trip/components/organizer-trust-panel.tsx](features/trip/components/organizer-trust-panel.tsx) |
|
||||||
|
| 2.8 | Render `OrganizerStatsPanel` di `/u/[id]` (skip query untuk non-organizer) | ✅ | [app/u/[id]/page.tsx](app/u/%5Bid%5D/page.tsx) |
|
||||||
|
|
||||||
|
**Tindakan manual:** tidak ada.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PR-3 — Organizer Reviews Aggregator (service + UI, tanpa migration) ✅
|
||||||
|
|
||||||
|
Selesai. `tsc --noEmit` lulus. Tanpa schema baru, tanpa migration.
|
||||||
|
|
||||||
|
**Keputusan asumsi yang dipakai:**
|
||||||
|
- Default limit 20 ulasan terbaru — cukup untuk MVP, tidak perlu pagination dulu.
|
||||||
|
- Komponen RSC (server component, no `"use client"`) — pure render, tidak ada interaktivitas.
|
||||||
|
- Tipe `OrganizerReviewItem` di-extract dari `Awaited<ReturnType<typeof reviewRepo.findByOrganizer>>[number]` supaya schema repo = sumber kebenaran tanpa duplikasi tipe.
|
||||||
|
- Fetch trust + reviews via single `Promise.all` di `/u/[id]` (paralel, hemat 1 round-trip).
|
||||||
|
- Komponen di-skip kalau `reviews.length === 0` — biar tidak makin "kosong" di profil organizer baru. Stats panel sudah punya pesan "Belum ada ulasan".
|
||||||
|
- Rating ditampilkan sebagai bintang penuh (`★★★★☆`) bukan angka — visual cue lebih kuat untuk testimoni.
|
||||||
|
- Header section: "X terbaru dari Y ulasan" kalau di-limit, atau cuma "Y ulasan" kalau seluruh list ditampilkan.
|
||||||
|
|
||||||
|
| # | Item | Status | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 3.1 | `reviewService.getReviewsByOrganizer(organizerId, limit?)` + tipe `OrganizerReviewItem` | ✅ | [server/services/review.service.ts](server/services/review.service.ts) |
|
||||||
|
| 3.2 | Repo helper `findByOrganizer` (default limit 20, urut newest, include user + trip) | ✅ | [server/repositories/review.repo.ts](server/repositories/review.repo.ts) |
|
||||||
|
| 3.3 | Komponen `OrganizerReviewsList` (avatar + name + bintang + trip link + tanggal + comment) | ✅ | [features/review/components/organizer-reviews-list.tsx](features/review/components/organizer-reviews-list.tsx) |
|
||||||
|
| 3.4 | Render di `/u/[id]` di bawah `OrganizerStatsPanel`, fetch via `Promise.all` paralel | ✅ | [app/u/[id]/page.tsx](app/u/%5Bid%5D/page.tsx) |
|
||||||
|
|
||||||
|
**Tindakan manual:** tidak ada.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PR-4 — Trip Completion Mechanism (opsional, butuh diskusi) ⏳
|
||||||
|
|
||||||
|
Saat ini `Trip.status = COMPLETED` tidak pernah di-set oleh kode mana pun. PR ini hanya perlu kalau ingin pakai `status` sebagai sumber kebenaran formal (bukan computed-from-`endDate`).
|
||||||
|
|
||||||
|
Opsi:
|
||||||
|
- **A. Manual** — organizer klik "Tandai trip selesai" pasca-pulang. Pro: kontrol di organizer. Con: gampang lupa, `status` tidak accurate kalau organizer pasif.
|
||||||
|
- **B. Cron job** — daily job set `status = COMPLETED` untuk trip dengan `endDate < today() AND status IN ('OPEN','FULL')`. Pro: otomatis akurat. Con: butuh infra cron (belum ada di project).
|
||||||
|
- **C. Skip — biarkan computed-from-`endDate`** di service layer. Pro: paling sederhana, sejalan dengan PR-2 yang juga compute on-the-fly. Con: field `status` jadi sebagian "live" (OPEN/FULL/CLOSED murni, COMPLETED computed).
|
||||||
|
|
||||||
|
**Rekomendasi:** **C** dulu sampai ada kebutuhan riil untuk transisi formal (mis. trigger payout organizer pasca-trip atau notif post-trip continuity di Phase C SOCIAL_ROADMAP).
|
||||||
|
|
||||||
|
| # | Item | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| 4.1 | Pilih opsi A/B/C | ⏳ |
|
||||||
|
| 4.2 | Implementasi sesuai pilihan (atau dokumentasikan keputusan kalau C) | ⏳ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❌ Anti-list (yang harus DITOLAK kalau muncul)
|
||||||
|
|
||||||
|
- **Model `OrganizerReview` terpisah** — `TripReview` sudah cukup, 1 trip = 1 organizer. Bikin model baru = duplikasi data + sumber kebenaran ambigu.
|
||||||
|
- **Denormalisasi cache** (mis. `User.cachedAvgRating`) sebelum aggregate query terbukti lambat. Premature optimization → drift jadi tech debt cepat.
|
||||||
|
- **Auto-hapus review buruk** atau organizer "respon" review (untuk MVP). Bisa nanti — fokus dulu menampilkan data jujur.
|
||||||
|
- **"Trust score" gabungan satu angka** — kasih breakdown agar calon peserta evaluasi sendiri. Single number gampang dimanipulasi & menyesatkan.
|
||||||
|
- **Review user/peserta** (no-show, kooperatif?) — itu C6 di [SOCIAL_ROADMAP.md](SOCIAL_ROADMAP.md). Scope berbeda, jangan campur.
|
||||||
|
- **Rating dengan setengah bintang / kustom 1-10** — tetap 1-5 integer (sudah di schema). Granularitas lebih halus tidak meningkatkan trust, hanya menambah noise.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Saran phasing
|
||||||
|
|
||||||
|
PR berurutan. Setiap PR mandiri (siap di-deploy):
|
||||||
|
|
||||||
|
1. **PR-1** — Trip detail polish. Cepat, low-risk, no migration. **Mulai dari sini.**
|
||||||
|
2. **PR-2** — Trust aggregates di service + UI. Read-only, no migration.
|
||||||
|
3. **PR-3** — Reviews list per organizer di service + UI. Read-only, no migration.
|
||||||
|
4. **PR-4** — Diskusi opsi completion mechanism (atau skip kalau opsi C dipilih).
|
||||||
|
|
||||||
|
**Pertanyaan terbuka sebelum PR-2:**
|
||||||
|
1. Apakah `tripsCompleted` count termasuk trip dengan `status = CLOSED` yang `endDate < now()`? Saran: tidak — CLOSED = dibatalkan, dipisah ke `tripsCancelled`.
|
||||||
|
2. Threshold minimum supaya `completionRate` ditampilkan? Saran: min 3 trip selesai supaya angka tidak menyesatkan (1 trip dibatalkan dari 1 trip = 0% looks bad untuk organizer baru).
|
||||||
|
3. Tampilkan rating breakdown sebagai bar chart atau hanya angka? Saran: bar chart kecil — visual cue lebih kuat untuk credibility.
|
||||||
+30
-1
@@ -344,10 +344,22 @@ export default async function TripDetailPage({
|
|||||||
|
|
||||||
{/* Participant Progress */}
|
{/* Participant Progress */}
|
||||||
<div className="rounded-xl border border-neutral-200 p-3 sm:p-4">
|
<div className="rounded-xl border border-neutral-200 p-3 sm:p-4">
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs font-semibold text-neutral-700 sm:text-sm">
|
<span className="text-xs font-semibold text-neutral-700 sm:text-sm">
|
||||||
Peserta
|
Peserta
|
||||||
</span>
|
</span>
|
||||||
|
{spotsLeft > 0 && spotsLeft <= 3 && (
|
||||||
|
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-bold text-amber-800 sm:text-[11px]">
|
||||||
|
⚡ Tinggal {spotsLeft} spot!
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{spotsLeft <= 0 && (
|
||||||
|
<span className="rounded-full bg-neutral-200 px-2 py-0.5 text-[10px] font-bold text-neutral-700 sm:text-[11px]">
|
||||||
|
Penuh
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<span className="text-xs font-bold text-neutral-800 sm:text-sm">
|
<span className="text-xs font-bold text-neutral-800 sm:text-sm">
|
||||||
{participantCount}{" "}
|
{participantCount}{" "}
|
||||||
<span className="font-normal text-neutral-400">
|
<span className="font-normal text-neutral-400">
|
||||||
@@ -383,6 +395,23 @@ export default async function TripDetailPage({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
{confirmedCount > 0 && (
|
||||||
|
<p className="mt-2 text-[11px] text-neutral-600 sm:text-xs">
|
||||||
|
<span aria-hidden>👥</span> Sudah join:{" "}
|
||||||
|
<span className="font-medium text-neutral-800">
|
||||||
|
{confirmedParticipants
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((p) => p.user.name.split(" ")[0])
|
||||||
|
.join(", ")}
|
||||||
|
</span>
|
||||||
|
{confirmedCount > 3 && (
|
||||||
|
<span className="text-neutral-500">
|
||||||
|
{" "}
|
||||||
|
+{confirmedCount - 3} lainnya
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TripProgramBlock
|
<TripProgramBlock
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ import Link from "next/link";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { profileService } from "@/server/services/profile.service";
|
import { profileService } from "@/server/services/profile.service";
|
||||||
|
import { trustService } from "@/server/services/trust.service";
|
||||||
|
import { reviewService } from "@/server/services/review.service";
|
||||||
import { TripCard } from "@/features/trip/components/trip-card";
|
import { TripCard } from "@/features/trip/components/trip-card";
|
||||||
import { ProfileTripRow } from "@/features/profile/components/profile-trip-row";
|
import { ProfileTripRow } from "@/features/profile/components/profile-trip-row";
|
||||||
|
import { OrganizerStatsPanel } from "@/features/profile/components/organizer-stats-panel";
|
||||||
|
import { OrganizerReviewsList } from "@/features/review/components/organizer-reviews-list";
|
||||||
import { siteConfig } from "@/lib/site";
|
import { siteConfig } from "@/lib/site";
|
||||||
import { vibeMeta } from "@/lib/vibe";
|
import { vibeMeta } from "@/lib/vibe";
|
||||||
|
|
||||||
@@ -45,6 +49,16 @@ export default async function PublicProfilePage({ params }: PageProps) {
|
|||||||
year: "numeric",
|
year: "numeric",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Trust panel hanya relevan untuk user yang berperan organizer.
|
||||||
|
// Hindari query Prisma yang nggak perlu untuk user yang murni peserta.
|
||||||
|
const isOrganizerProfile = organizedTrips.length > 0 || isVerifiedOrganizer;
|
||||||
|
const [organizerTrust, organizerReviews] = isOrganizerProfile
|
||||||
|
? await Promise.all([
|
||||||
|
trustService.getOrganizerTrust(user.id),
|
||||||
|
reviewService.getReviewsByOrganizer(user.id),
|
||||||
|
])
|
||||||
|
: [null, []];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl px-4 py-6 sm:py-8">
|
<div className="mx-auto max-w-4xl px-4 py-6 sm:py-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -156,6 +170,15 @@ export default async function PublicProfilePage({ params }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{organizerTrust && <OrganizerStatsPanel trust={organizerTrust} />}
|
||||||
|
|
||||||
|
{organizerTrust && organizerReviews.length > 0 && (
|
||||||
|
<OrganizerReviewsList
|
||||||
|
reviews={organizerReviews}
|
||||||
|
totalCount={organizerTrust.reviewCount}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Empty profile hint */}
|
{/* Empty profile hint */}
|
||||||
{!profile && (
|
{!profile && (
|
||||||
<p className="mt-5 rounded-xl border border-dashed border-neutral-200 bg-neutral-50 px-4 py-3 text-center text-xs text-neutral-500">
|
<p className="mt-5 rounded-xl border border-dashed border-neutral-200 bg-neutral-50 px-4 py-3 text-center text-xs text-neutral-500">
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import type { OrganizerTrust } from "@/server/services/trust.service";
|
||||||
|
|
||||||
|
interface OrganizerStatsPanelProps {
|
||||||
|
trust: OrganizerTrust;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panel reputasi organizer untuk halaman profil publik /u/[id].
|
||||||
|
* Tidak render kalau user belum punya history sebagai organizer.
|
||||||
|
*/
|
||||||
|
export function OrganizerStatsPanel({ trust }: OrganizerStatsPanelProps) {
|
||||||
|
const {
|
||||||
|
isVerified,
|
||||||
|
isTripLeader,
|
||||||
|
tripsCreated,
|
||||||
|
tripsCompleted,
|
||||||
|
totalParticipantsServed,
|
||||||
|
completionRate,
|
||||||
|
avgRating,
|
||||||
|
reviewCount,
|
||||||
|
ratingBreakdown,
|
||||||
|
} = trust;
|
||||||
|
|
||||||
|
if (tripsCreated === 0 && reviewCount === 0 && !isVerified) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxBreakdown = Math.max(
|
||||||
|
ratingBreakdown[1],
|
||||||
|
ratingBreakdown[2],
|
||||||
|
ratingBreakdown[3],
|
||||||
|
ratingBreakdown[4],
|
||||||
|
ratingBreakdown[5],
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mt-5 rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
|
||||||
|
<h2 className="mb-3 text-sm font-bold text-neutral-700 sm:text-base">
|
||||||
|
Reputasi sebagai organizer
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{(isVerified || isTripLeader) && (
|
||||||
|
<div className="mb-4 flex flex-wrap gap-2">
|
||||||
|
{isVerified && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-0.5 text-[11px] font-bold uppercase tracking-wide text-primary-800"
|
||||||
|
title="Identitas organizer telah diverifikasi (KTP & rekening)"
|
||||||
|
>
|
||||||
|
✅ Verified Organizer
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isTripLeader && (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-secondary-100 px-2.5 py-0.5 text-[11px] font-bold uppercase tracking-wide text-secondary-900">
|
||||||
|
Trip leader
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-4">
|
||||||
|
<Stat
|
||||||
|
label="Trip selesai"
|
||||||
|
value={tripsCompleted.toString()}
|
||||||
|
tone="primary"
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Peserta dilayani"
|
||||||
|
value={totalParticipantsServed.toString()}
|
||||||
|
tone="secondary"
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Completion rate"
|
||||||
|
value={
|
||||||
|
completionRate != null
|
||||||
|
? `${Math.round(completionRate * 100)}%`
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
subtitle={
|
||||||
|
completionRate == null ? "Belum cukup data" : undefined
|
||||||
|
}
|
||||||
|
tone="neutral"
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Rating"
|
||||||
|
value={avgRating != null ? `${avgRating} ★` : "—"}
|
||||||
|
subtitle={
|
||||||
|
reviewCount > 0
|
||||||
|
? `${reviewCount} ulasan`
|
||||||
|
: "Belum ada ulasan"
|
||||||
|
}
|
||||||
|
tone="amber"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{reviewCount > 0 && (
|
||||||
|
<div className="mt-4 border-t border-neutral-100 pt-4">
|
||||||
|
<h3 className="mb-2 text-xs font-semibold text-neutral-600">
|
||||||
|
Distribusi rating
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{([5, 4, 3, 2, 1] as const).map((star) => {
|
||||||
|
const count = ratingBreakdown[star];
|
||||||
|
const percent = (count / maxBreakdown) * 100;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={star}
|
||||||
|
className="flex items-center gap-2 text-xs"
|
||||||
|
>
|
||||||
|
<span className="w-8 shrink-0 font-medium text-neutral-600">
|
||||||
|
{star} ★
|
||||||
|
</span>
|
||||||
|
<div className="h-2 flex-1 overflow-hidden rounded-full bg-neutral-100">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-amber-400 transition-all"
|
||||||
|
style={{ width: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="w-8 shrink-0 text-right text-neutral-500">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TONE_CLASSES = {
|
||||||
|
primary: { bg: "bg-primary-50", value: "text-primary-700" },
|
||||||
|
secondary: { bg: "bg-secondary-50", value: "text-secondary-700" },
|
||||||
|
neutral: { bg: "bg-neutral-50", value: "text-neutral-800" },
|
||||||
|
amber: { bg: "bg-amber-50", value: "text-amber-700" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface StatProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
subtitle?: string;
|
||||||
|
tone: keyof typeof TONE_CLASSES;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value, subtitle, tone }: StatProps) {
|
||||||
|
const cls = TONE_CLASSES[tone];
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl px-3 py-2.5 ${cls.bg}`}>
|
||||||
|
<p className="text-[10px] font-medium uppercase tracking-wide text-neutral-500 sm:text-[11px]">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p className={`mt-0.5 text-lg font-bold sm:text-xl ${cls.value}`}>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="text-[10px] text-neutral-400">{subtitle}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import type { OrganizerReviewItem } from "@/server/services/review.service";
|
||||||
|
|
||||||
|
interface OrganizerReviewsListProps {
|
||||||
|
reviews: OrganizerReviewItem[];
|
||||||
|
/// Total review keseluruhan organizer (bisa lebih besar dari `reviews.length`
|
||||||
|
/// kalau di-limit di service). Kalau tidak diberikan, fallback ke `reviews.length`.
|
||||||
|
totalCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrganizerReviewsList({
|
||||||
|
reviews,
|
||||||
|
totalCount,
|
||||||
|
}: OrganizerReviewsListProps) {
|
||||||
|
if (reviews.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = totalCount ?? reviews.length;
|
||||||
|
const showingMore = total > reviews.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mt-5 rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
|
||||||
|
<div className="mb-4 flex flex-wrap items-baseline justify-between gap-2">
|
||||||
|
<h2 className="text-sm font-bold text-neutral-700 sm:text-base">
|
||||||
|
Ulasan dari peserta
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-neutral-500">
|
||||||
|
{showingMore
|
||||||
|
? `${reviews.length} terbaru dari ${total} ulasan`
|
||||||
|
: `${total} ulasan`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{reviews.map((r) => (
|
||||||
|
<li
|
||||||
|
key={r.id}
|
||||||
|
className="rounded-xl border border-neutral-100 bg-neutral-50/60 px-3 py-3 sm:px-4 sm:py-3.5"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{r.user.image ? (
|
||||||
|
<Image
|
||||||
|
src={r.user.image}
|
||||||
|
alt=""
|
||||||
|
width={36}
|
||||||
|
height={36}
|
||||||
|
className="h-9 w-9 shrink-0 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary-600 text-xs font-bold text-white">
|
||||||
|
{r.user.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5">
|
||||||
|
<Link
|
||||||
|
href={`/u/${r.user.id}`}
|
||||||
|
className="text-xs font-semibold text-neutral-800 hover:text-primary-700 sm:text-sm"
|
||||||
|
>
|
||||||
|
{r.user.name}
|
||||||
|
</Link>
|
||||||
|
<span className="text-xs font-bold text-amber-600">
|
||||||
|
{"★".repeat(r.rating)}
|
||||||
|
<span className="text-neutral-300">
|
||||||
|
{"★".repeat(5 - r.rating)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[11px] text-neutral-500 sm:text-xs">
|
||||||
|
via{" "}
|
||||||
|
<Link
|
||||||
|
href={`/trips/${r.trip.id}`}
|
||||||
|
className="text-neutral-600 underline-offset-2 hover:text-primary-700 hover:underline"
|
||||||
|
>
|
||||||
|
{r.trip.title}
|
||||||
|
</Link>
|
||||||
|
<span className="text-neutral-400">
|
||||||
|
{" "}
|
||||||
|
· {formatReviewDate(r.createdAt)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{r.comment && (
|
||||||
|
<p className="mt-2 whitespace-pre-wrap text-xs leading-relaxed text-neutral-700 sm:text-sm">
|
||||||
|
{r.comment}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatReviewDate(date: Date): string {
|
||||||
|
return new Date(date).toLocaleDateString("id-ID", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -248,15 +248,21 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="itinerary" className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
<label htmlFor="itinerary" className="mb-1 block text-sm font-semibold text-neutral-700">
|
||||||
Itinerary
|
Itinerary
|
||||||
</label>
|
</label>
|
||||||
|
<p className="mb-1.5 text-[11px] text-neutral-500">
|
||||||
|
Tulis per hari supaya peserta tahu alur — itinerary lengkap bikin
|
||||||
|
trust naik drastis.
|
||||||
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
id="itinerary"
|
id="itinerary"
|
||||||
name="itinerary"
|
name="itinerary"
|
||||||
rows={5}
|
rows={6}
|
||||||
className="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"
|
className="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"
|
||||||
placeholder={"Hari 1: …\nHari 2: …"}
|
placeholder={
|
||||||
|
"Hari 1: 05:00 kumpul di meeting point\n07:00 berangkat\n12:00 ishoma di rest area\n16:00 sampai basecamp, briefing\n\nHari 2: 04:00 summit attack\n08:00 kembali ke basecamp\n..."
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -56,10 +56,23 @@ export function OrganizerTrustPanel({
|
|||||||
<div className="flex flex-1 flex-wrap gap-3 border-t border-neutral-100 pt-3 sm:border-t-0 sm:border-l sm:pl-4 sm:pt-0">
|
<div className="flex flex-1 flex-wrap gap-3 border-t border-neutral-100 pt-3 sm:border-t-0 sm:border-l sm:pl-4 sm:pt-0">
|
||||||
<div className="min-w-[100px] rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100">
|
<div className="min-w-[100px] rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100">
|
||||||
<p className="text-[10px] font-medium text-neutral-500 sm:text-xs">
|
<p className="text-[10px] font-medium text-neutral-500 sm:text-xs">
|
||||||
Trip dibuat
|
Trip selesai
|
||||||
</p>
|
</p>
|
||||||
<p className="text-lg font-bold text-neutral-800">
|
<p className="text-lg font-bold text-primary-700">
|
||||||
{trust.tripsCreated}
|
{trust.tripsCompleted}
|
||||||
|
</p>
|
||||||
|
{trust.tripsCreated > trust.tripsCompleted && (
|
||||||
|
<p className="text-[10px] text-neutral-400">
|
||||||
|
+ {trust.tripsCreated - trust.tripsCompleted} berjalan
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[100px] rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100">
|
||||||
|
<p className="text-[10px] font-medium text-neutral-500 sm:text-xs">
|
||||||
|
Peserta dilayani
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold text-secondary-700">
|
||||||
|
{trust.totalParticipantsServed}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-[100px] rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100">
|
<div className="min-w-[100px] rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100">
|
||||||
|
|||||||
+11
-1
@@ -162,7 +162,17 @@ export function verifyMidtransSignature(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Map kombinasi `transaction_status` + `fraud_status` Midtrans ke `PaymentStatus` internal.
|
* Map kombinasi `transaction_status` + `fraud_status` Midtrans ke `PaymentStatus` internal.
|
||||||
* Tabel rujukan ada di PAYMENT_ROADMAP.md PR C.
|
*
|
||||||
|
* | Midtrans | fraud_status | PaymentStatus |
|
||||||
|
* |---------------------|--------------|---------------|
|
||||||
|
* | capture | accept | PAID |
|
||||||
|
* | capture | challenge | AWAITING |
|
||||||
|
* | settlement | — | PAID |
|
||||||
|
* | pending | — | AWAITING |
|
||||||
|
* | deny | — | FAILED |
|
||||||
|
* | expire | — | EXPIRED |
|
||||||
|
* | cancel | — | CANCELLED |
|
||||||
|
* | refund / partial | — | REFUNDED |
|
||||||
*/
|
*/
|
||||||
export function mapMidtransStatus(
|
export function mapMidtransStatus(
|
||||||
transactionStatus: string,
|
transactionStatus: string,
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
/** Minimal trip sebagai organizer untuk badge "Trip leader" (heuristik MVP). */
|
/** Minimal trip sebagai organizer untuk badge "Trip leader" (heuristik MVP). */
|
||||||
export const TRIP_LEADER_MIN_TRIPS = 2;
|
export const TRIP_LEADER_MIN_TRIPS = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal sample (trip selesai + trip dibatalkan) sebelum completion rate
|
||||||
|
* ditampilkan ke publik. Mencegah angka menyesatkan untuk organizer baru:
|
||||||
|
* mis. 1 trip dibatalkan dari 1 trip = 0% — tidak fair sebagai sinyal trust.
|
||||||
|
*/
|
||||||
|
export const COMPLETION_RATE_MIN_SAMPLE = 3;
|
||||||
|
|
||||||
/** Bentuk data minimal untuk cek status verifikasi organizer. */
|
/** Bentuk data minimal untuk cek status verifikasi organizer. */
|
||||||
type WithOrganizerVerification = {
|
type WithOrganizerVerification = {
|
||||||
organizerVerification?: { status: "PENDING" | "APPROVED" | "REJECTED" } | null;
|
organizerVerification?: { status: "PENDING" | "APPROVED" | "REJECTED" } | null;
|
||||||
|
|||||||
@@ -23,4 +23,24 @@ export const reviewRepo = {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Semua review untuk trip yang di-host organizer ini, terbaru di depan.
|
||||||
|
* Dipakai di profil publik organizer untuk menampilkan testimoni.
|
||||||
|
*/
|
||||||
|
async findByOrganizer(organizerId: string, limit = 20) {
|
||||||
|
return prisma.tripReview.findMany({
|
||||||
|
where: { trip: { organizerId } },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: limit,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
rating: true,
|
||||||
|
comment: true,
|
||||||
|
createdAt: true,
|
||||||
|
user: { select: { id: true, name: true, image: true } },
|
||||||
|
trip: { select: { id: true, title: true, destination: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { participantRepo } from "@/server/repositories/participant.repo";
|
|||||||
import { reviewRepo } from "@/server/repositories/review.repo";
|
import { reviewRepo } from "@/server/repositories/review.repo";
|
||||||
import { isPastTripLastDayForReview } from "@/lib/trip-dates";
|
import { isPastTripLastDayForReview } from "@/lib/trip-dates";
|
||||||
|
|
||||||
|
export type OrganizerReviewItem = Awaited<
|
||||||
|
ReturnType<typeof reviewRepo.findByOrganizer>
|
||||||
|
>[number];
|
||||||
|
|
||||||
export const reviewService = {
|
export const reviewService = {
|
||||||
async upsertReview(
|
async upsertReview(
|
||||||
tripId: string,
|
tripId: string,
|
||||||
@@ -41,4 +45,11 @@ export const reviewService = {
|
|||||||
comment: input.comment ?? null,
|
comment: input.comment ?? null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getReviewsByOrganizer(
|
||||||
|
organizerId: string,
|
||||||
|
limit?: number
|
||||||
|
): Promise<OrganizerReviewItem[]> {
|
||||||
|
return reviewRepo.findByOrganizer(organizerId, limit);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,38 +1,124 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { TRIP_LEADER_MIN_TRIPS } from "@/lib/trust";
|
import {
|
||||||
|
COMPLETION_RATE_MIN_SAMPLE,
|
||||||
|
TRIP_LEADER_MIN_TRIPS,
|
||||||
|
} from "@/lib/trust";
|
||||||
|
|
||||||
|
export type RatingBreakdown = Record<1 | 2 | 3 | 4 | 5, number>;
|
||||||
|
|
||||||
export type OrganizerTrust = {
|
export type OrganizerTrust = {
|
||||||
isVerified: boolean;
|
isVerified: boolean;
|
||||||
tripsCreated: number;
|
tripsCreated: number;
|
||||||
|
/// Trip yang sudah lewat tanggal selesai-nya AND tidak dibatalkan (status != CLOSED).
|
||||||
|
/// Dihitung on-the-fly dari endDate (fallback ke date kalau endDate null) — tidak
|
||||||
|
/// bergantung pada Trip.status = COMPLETED yang saat ini belum pernah di-set.
|
||||||
|
tripsCompleted: number;
|
||||||
|
/// Trip dengan status = CLOSED (dibatalkan organizer).
|
||||||
|
tripsCancelled: number;
|
||||||
|
/// Akumulasi peserta CONFIRMED di seluruh trip yang sudah selesai.
|
||||||
|
totalParticipantsServed: number;
|
||||||
|
/// tripsCompleted / (tripsCompleted + tripsCancelled). Null kalau sample
|
||||||
|
/// < COMPLETION_RATE_MIN_SAMPLE — mencegah angka menyesatkan untuk
|
||||||
|
/// organizer yang masih sedikit history-nya.
|
||||||
|
completionRate: number | null;
|
||||||
avgRating: number | null;
|
avgRating: number | null;
|
||||||
reviewCount: number;
|
reviewCount: number;
|
||||||
|
ratingBreakdown: RatingBreakdown;
|
||||||
isTripLeader: boolean;
|
isTripLeader: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const trustService = {
|
export const trustService = {
|
||||||
async getOrganizerTrust(organizerId: string): Promise<OrganizerTrust> {
|
async getOrganizerTrust(organizerId: string): Promise<OrganizerTrust> {
|
||||||
const [tripsCreated, reviewAgg, organizerVerification] = await Promise.all([
|
const now = new Date();
|
||||||
|
|
||||||
|
// Filter "trip yang sudah lewat": endDate < now, atau (endDate null AND date < now).
|
||||||
|
// Trip multi-hari pakai endDate; trip 1 hari biasanya endDate null jadi fallback ke date.
|
||||||
|
const pastTripFilter = {
|
||||||
|
OR: [
|
||||||
|
{ endDate: { lt: now } },
|
||||||
|
{ AND: [{ endDate: null }, { date: { lt: now } }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const [
|
||||||
|
tripsCreated,
|
||||||
|
tripsCompleted,
|
||||||
|
tripsCancelled,
|
||||||
|
totalParticipantsServed,
|
||||||
|
reviewAgg,
|
||||||
|
ratingGroups,
|
||||||
|
organizerVerification,
|
||||||
|
] = await Promise.all([
|
||||||
prisma.trip.count({ where: { organizerId } }),
|
prisma.trip.count({ where: { organizerId } }),
|
||||||
prisma.tripReview.aggregate({
|
prisma.trip.count({
|
||||||
where: {
|
where: {
|
||||||
trip: { organizerId },
|
organizerId,
|
||||||
|
status: { not: "CLOSED" },
|
||||||
|
...pastTripFilter,
|
||||||
},
|
},
|
||||||
|
}),
|
||||||
|
prisma.trip.count({
|
||||||
|
where: { organizerId, status: "CLOSED" },
|
||||||
|
}),
|
||||||
|
prisma.tripParticipant.count({
|
||||||
|
where: {
|
||||||
|
status: "CONFIRMED",
|
||||||
|
trip: {
|
||||||
|
organizerId,
|
||||||
|
status: { not: "CLOSED" },
|
||||||
|
...pastTripFilter,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.tripReview.aggregate({
|
||||||
|
where: { trip: { organizerId } },
|
||||||
_avg: { rating: true },
|
_avg: { rating: true },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
}),
|
}),
|
||||||
|
prisma.tripReview.groupBy({
|
||||||
|
by: ["rating"],
|
||||||
|
where: { trip: { organizerId } },
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
prisma.organizerVerification.findUnique({
|
prisma.organizerVerification.findUnique({
|
||||||
where: { userId: organizerId },
|
where: { userId: organizerId },
|
||||||
select: { status: true },
|
select: { status: true },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const ratingBreakdown: RatingBreakdown = {
|
||||||
|
1: 0,
|
||||||
|
2: 0,
|
||||||
|
3: 0,
|
||||||
|
4: 0,
|
||||||
|
5: 0,
|
||||||
|
};
|
||||||
|
for (const row of ratingGroups) {
|
||||||
|
const r = row.rating;
|
||||||
|
if (r >= 1 && r <= 5) {
|
||||||
|
ratingBreakdown[r as 1 | 2 | 3 | 4 | 5] = row._count._all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const completionSample = tripsCompleted + tripsCancelled;
|
||||||
|
const completionRate =
|
||||||
|
completionSample >= COMPLETION_RATE_MIN_SAMPLE
|
||||||
|
? tripsCompleted / completionSample
|
||||||
|
: null;
|
||||||
|
|
||||||
const avg = reviewAgg._avg.rating;
|
const avg = reviewAgg._avg.rating;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isVerified: organizerVerification?.status === "APPROVED",
|
isVerified: organizerVerification?.status === "APPROVED",
|
||||||
tripsCreated,
|
tripsCreated,
|
||||||
|
tripsCompleted,
|
||||||
|
tripsCancelled,
|
||||||
|
totalParticipantsServed,
|
||||||
|
completionRate,
|
||||||
avgRating:
|
avgRating:
|
||||||
avg != null ? Math.round(Number(avg) * 10) / 10 : null,
|
avg != null ? Math.round(Number(avg) * 10) / 10 : null,
|
||||||
reviewCount: reviewAgg._count._all,
|
reviewCount: reviewAgg._count._all,
|
||||||
|
ratingBreakdown,
|
||||||
isTripLeader: tripsCreated >= TRIP_LEADER_MIN_TRIPS,
|
isTripLeader: tripsCreated >= TRIP_LEADER_MIN_TRIPS,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user