add payment, trust badge, handle race condition, fix booking schema

This commit is contained in:
arifal
2026-04-20 23:57:31 +07:00
parent ba5f64ae0e
commit fcdca34460
33 changed files with 1781 additions and 138 deletions
+23
View File
@@ -213,6 +213,29 @@ Database
---
## Phase 3+ (SeTrip saat ini — booking, detail, trust)
Alur data mengikuti pola yang sama: **UI (`app/`) → server actions (`features/*/actions.ts`) → service (`server/services`) → repository (`server/repositories`)**.
### Booking & pembayaran manual (`features/booking/`)
- **Peserta:** tombol *Saya sudah bayar* menulis `TripParticipant.markedPaidAt` (komitmen transfer manual).
- **Organizer:** panel *Konfirmasi pembayaran* memanggil service yang mengisi `paymentConfirmedAt`.
- Tetap **tanpa payment gateway**; bukti transfer bisa di luar app (WA) sesuai kebutuhan.
### Detail trip kuat (`Trip` + halaman detail)
- Field terstruktur: `meetingPoint`, `itinerary`, `whatsIncluded`, `whatsExcluded` (teks bebas / bullet).
- Form buat trip: `features/trip/schemas.ts` + `app/create-trip/page.tsx`.
### Trust & organizer (`server/services/trust.service.ts`)
- **Verified:** kolom `User.isVerified` (default false; set manual / seed / admin ke depan).
- **Trip leader:** heuristik `jumlah trip dibuat ≥ TRIP_LEADER_MIN_TRIPS` (`lib/trust.ts`).
- **Jumlah trip dibuat & rating organizer:** dihitung agregat dari DB (rating = rata-rata `TripReview` pada semua trip sang organizer).
---
# 🧠 Final Principle
> Build fast → validate → refactor later
+79 -29
View File
@@ -9,43 +9,93 @@ Stack: [Next.js](https://nextjs.org) (App Router), NextAuth, Prisma (PostgreSQL)
### 1. Autentikasi
- Pengguna baru mendaftar di `/register` (nama, email, password disimpan di database).
- Login di `/login` melalui NextAuth; sesi dipakai di server action dan di halaman client (misalnya navbar, form buat trip).
- Login di `/login` melalui NextAuth; sesi dipakai di server action dan di halaman client (navbar, form buat trip, join).
Tanpa login, pengguna tetap bisa melihat daftar trip dan detail trip, tetapi tidak bisa membuat trip atau join.
### 2. Organizer: membuat trip
1. Setelah login, organizer membuka **Buat Trip** (`/create-trip`) dari navbar, halaman `/trips`, beranda, atau tombol mengambang (+).
2. Halaman form (`app/create-trip/page.tsx`) memvalidasi sesi di client; jika belum login, ditampilkan ajakan login.
3. Organizer mengisi judul, gunung, lokasi, deskripsi (opsional), rentang tanggal (DatePicker), maks peserta, harga (format Rupiah), dan URL gambar opsional (`ImageUrlInput`).
4. Submit memanggil server action `createTripAction` (`features/trip/actions.ts`):
- Memastikan ada sesi.
- Mem-parse dan memvalidasi input dengan Zod (`features/trip/schemas.ts`).
- `tripService.createTrip` menulis trip baru ke database lewat `tripRepo.create`, menghubungkan `organizerId` ke user yang login, dan menyimpan gambar jika ada.
5. Trip baru berstatus **OPEN** (default schema), lalu pengguna diarahkan ke detail trip `/trips/[id]`.
1. Setelah login, organizer membuka **Buat Trip** (`/create-trip`) dari navbar, `/trips`, beranda, atau tombol (+).
2. Form memvalidasi sesi; jika belum login, ditampilkan ajakan login.
3. Organizer mengisi judul, gunung, lokasi, deskripsi (opsional), **meeting point**, **itinerary**, **termasuk / tidak termasuk** (opsional), rentang tanggal berangkatpulang, maks peserta, harga (Rupiah), dan URL gambar opsional.
4. Submit `createTripAction` → validasi Zod → `tripService.createTrip` menulis `Trip` ke database (status default **OPEN**) beserta gambar jika ada.
5. Pengguna diarahkan ke detail trip `/trips/[id]`.
Organizer **tidak** bisa join trip sendiri; di detail trip tombol join diganti pesan bahwa user adalah organizer.
Organizer **tidak** bisa join trip sendiri; di detail trip ditampilkan bahwa dia adalah organizer trip ini.
### 3. Peserta: mencari trip dan join
### 3. Peserta: mencari trip
1. **Beranda** (`/`) dan **Open Trip** (`/trips`) menampilkan trip dengan status **OPEN** dan tanggal berangkat tidak di masa lalu (`tripService.getOpenTrips` + filter di repository).
2. Filter pencarian (`TripFilter`) mengirim query string; daftar trip disaring di server.
3. Dari kartu trip (`TripCard`), pengguna membuka **detail** `/trips/[id]` (`app/trips/[id]/page.tsx`):
- Peserta aktif = baris `TripParticipant` yang statusnya bukan `CANCELLED`.
- Slot tersisa dan progress bar memakai jumlah peserta aktif tersebut.
4. **Join** (`JoinTripButton` + `joinTripAction`):
- Jika belum login: tautan ke `/login`.
- Jika trip bukan `OPEN` dan user belum join: pendaftaran ditutup (kecuali user sudah terdaftar dan ingin membatalkan, mengikuti logika UI).
- `tripService.joinTrip` memeriksa: trip ada, status `OPEN`, bukan organizer, belum terdaftar aktif, kapasitas belum penuh; lalu menambah atau mengaktifkan kembali partisipasi (lihat bagian perbaikan bug di bawah).
5. Jika jumlah peserta aktif mencapai `maxParticipants`, status trip diperbarui menjadi **FULL**.
6. **Batal ikut** memanggil `cancelJoinAction``tripService.cancelJoin`: partisipasi ditandai `CANCELLED`; jika trip sebelumnya `FULL` dan setelah batal slot kosong lagi, status dikembalikan ke **OPEN**.
1. **Beranda** (`/`) dan **Open Trip** (`/trips`) menampilkan trip **OPEN** dengan tanggal berangkat yang masih relevan (`tripService.getOpenTrips`).
2. Filter pencarian (`TripFilter`) mengirim query string (`q`, `from`, `to`); penyaringan dilakukan di server.
3. Dari **TripCard**, pengguna membuka **detail** `/trips/[id]`: melihat info trip, kuota, panel kepercayaan organizer (jumlah trip, rating, badge jika ada), daftar peserta yang sudah disetujui, dan ulasan.
### 4. Ringkasan peran data
---
### 4. Alur lengkap: dari join hingga pembayaran sukses
Alur ini menggambarkan satu peserta dari pertama kali mendaftar sampai pembayaran dianggap selesai di aplikasi (pembayaran **manual** / transfer di luar gateway).
```text
[Peserta] [Sistem] [Organizer]
| | |
|-- buka detail trip ------>| |
| | |
|-- "Join Trip" ------------>| status partisipasi: PENDING |
| | (mengisi slot kuota trip) |
|<-- menunggu persetujuan ---| |
| |<-- lihat "Permintaan join" ------|
| | |
| |<-- "Setujui" atau "Tolak" -------|
| | Setujui -> CONFIRMED |
| | Tolak -> CANCELLED |
|<-- terkonfirmasi ikut -----| (jika disetujui) |
| | |
|-- transfer uang (WA/rek) --| (di luar app) |
| | |
|-- "Saya sudah bayar" ----->| markedPaidAt = sekarang |
|<-- menunggu konfirmasi ----| |
| | |
| |<-- "Konfirmasi pembayaran" ------|
| | paymentConfirmedAt = sekarang |
|<-- pembayaran dikonfirmasi-| |
```
**Langkah per langkah**
1. **Join trip**
Di halaman detail, peserta login menekan **Join Trip Sekarang**`joinTripAction``tripService.joinTrip`.
Dibuat atau diaktifkan kembali baris `TripParticipant` dengan status **`PENDING`**. Kuota trip (peserta aktif: PENDING + CONFIRMED) bertambah; trip bisa menjadi **FULL** jika slot habis.
2. **Menunggu organizer**
Peserta melihat status bahwa permintaan **menunggu persetujuan organizer**. Dia bisa **Batal ikut** selama tanggal berangkat belum lewat (status menjadi `CANCELLED`, slot bisa longgar lagi).
3. **Organizer menyetujui atau menolak**
Di blok **Permintaan join**, organizer menekan **Setujui** → status partisipasi menjadi **`CONFIRMED`**, atau **Tolak****`CANCELLED`**.
Jika ada peserta yang sudah menandai bayar sebelum disetujui, penolakan juga membersihkan data tandai bayar pada baris itu.
4. **Peserta terkonfirmasi**
Setelah **CONFIRMED**, peserta dianggap bagian dari daftar “peserta terkonfirmasi” di halaman trip. Fitur ulasan trip setelah selesai hanya relevan untuk partisipasi terkonfirmasi (sesuai aturan di service).
5. **Menandai sudah membayar**
Peserta mentransfer sesuai instruksi organizer (di luar app). Di app dia menekan **Saya sudah bayar** → kolom **`markedPaidAt`** diisi. Tombol ini tidak muncul jika pembayaran sudah dikonfirmasi atau tanggal trip sudah lewat.
Aksi ini **atomik** (aman dari double klik).
6. **Organizer mengonfirmasi pembayaran**
Di blok **Konfirmasi pembayaran**, organizer mengecek mutasi/bukti lalu menekan **Konfirmasi pembayaran** untuk peserta bersangkutan → kolom **`paymentConfirmedAt`** diisi.
Ini **idempoten** (konfirmasi ganda tidak mengubah hasil akhir yang salah).
7. **Selesai (sukses payment di app)**
Peserta melihat bahwa pembayaran **sudah dikonfirmasi organizer**. Di sini alur “commit” peserta + uang dalam konteks SeTrip dianggap lengkap dari sisi data aplikasi.
**Catatan produk:** tidak ada payment gateway; bukti transfer dan nominal final tetap komunikasi organizerpeserta (misalnya WA).
### 5. Ringkasan peran data
| Konsep | Penyimpanan |
|--------|-------------|
| Trip | Model `Trip` (judul, gunung, lokasi, tanggal, kuota, harga, status, relasi ke organizer) |
| Peserta | `TripParticipant` unik per `(tripId, userId)` dengan status `CONFIRMED` / `CANCELLED` (default schema juga mengenal `PENDING`; alur UI saat ini memakai `CONFIRMED` saat join) |
| Trip | `Trip`: judul, gunung, lokasi, tanggal, kuota, harga, status trip (`OPEN` / `FULL` / …), meeting point, itinerary, termasuk/tidak termasuk, relasi ke organizer |
| Peserta | `TripParticipant` unik per `(tripId, userId)`: status **`PENDING`** / **`CONFIRMED`** / **`CANCELLED`**, serta **`markedPaidAt`** & **`paymentConfirmedAt`** untuk alur bayar manual |
| Organizer (kepercayaan) | `User.isVerified`; agregat rating & jumlah trip dibuat dihitung dari data ulasan & trip |
## Menjalankan secara lokal
@@ -63,13 +113,13 @@ Buka [http://localhost:3000](http://localhost:3000).
## Perbaikan bug (yang relevan dengan join & listing)
1. **Join lagi setelah “Batal ikut”**
Satu user hanya boleh satu baris partisipasi per trip (`@@unique([tripId, userId])`). Kode lama mencoba `create` lagi setelah status `CANCELLED`, sehingga bisa gagal dengan pelanggaran unik. Sekarang jika sudah ada baris `CANCELLED`, partisipasi **diaktifkan kembali** (`CONFIRMED`) lewat `participantRepo.reactivate`, bukan insert baru.
Satu user hanya satu baris per trip (`@@unique([tripId, userId])`). Jika baris sudah `CANCELLED`, join berikutnya **mengaktifkan kembali** partisipasi ke **`PENDING`** (bukan insert baru).
2. **Jumlah peserta di kartu / daftar**
`_count.participants` di query listing sebelumnya menghitung semua baris termasuk yang `CANCELLED`, sehingga “slot tersisa” di `TripCard` bisa salah. Count sekarang hanya menghitung peserta dengan status **bukan** `CANCELLED`.
`_count.participants` hanya menghitung status **bukan** `CANCELLED`, agar slot dan label “penuh” konsisten.
3. **Segar halaman setelah join/batal/buat trip**
Setelah aksi trip, cache halaman `/trips` dan `/` ikut di-`revalidatePath` agar jumlah slot dan daftar di beranda konsisten tanpa harus refresh manual.
3. **Segar halaman setelah aksi**
`revalidatePath` dipanggil setelah join, batal, buat trip, setujui/tolak peserta, dan konfirmasi pembayaran agar daftar dan detail konsisten.
## Learn More
+56 -9
View File
@@ -73,7 +73,7 @@ export default function CreateTripPage() {
setLoading(true);
const formData = new FormData(e.currentTarget);
// Hari kalender lokal → YYYY-MM-DD (bukan toISOString, supaya tidak geser ke UTC)
// Tanggal dari picker → string tanggal untuk server action
formData.set("date", formatLocalCalendarYmd(startDate));
if (endDate) {
const startYmd = formatLocalCalendarYmd(startDate);
@@ -215,10 +215,63 @@ export default function CreateTripPage() {
name="description"
rows={4}
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="Detail trip, itinerary, meeting point, fasilitas..."
placeholder="Ringkasan trip, vibe, level kesulitan..."
/>
</div>
<div>
<label htmlFor="meetingPoint" className="mb-1.5 block text-sm font-semibold text-neutral-700">
Meeting point
</label>
<input
id="meetingPoint"
name="meetingPoint"
type="text"
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="contoh: Alfamart Cicaheum, 05:00 WIB"
/>
</div>
<div>
<label htmlFor="itinerary" className="mb-1.5 block text-sm font-semibold text-neutral-700">
Itinerary
</label>
<textarea
id="itinerary"
name="itinerary"
rows={5}
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: …"}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="whatsIncluded" className="mb-1.5 block text-sm font-semibold text-neutral-700">
Termasuk
</label>
<textarea
id="whatsIncluded"
name="whatsIncluded"
rows={4}
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="Transport, konsumsi, tenda, …"
/>
</div>
<div>
<label htmlFor="whatsExcluded" className="mb-1.5 block text-sm font-semibold text-neutral-700">
Tidak termasuk
</label>
<textarea
id="whatsExcluded"
name="whatsExcluded"
rows={4}
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="Tiket masuk TN, sleeping bag, …"
/>
</div>
</div>
<ImageUrlInput />
{/* Date Range & Participants & Price */}
@@ -226,14 +279,8 @@ export default function CreateTripPage() {
{/* Date Range Picker */}
<div>
<label className="mb-1.5 block text-sm font-semibold text-neutral-700">
Tanggal Berangkat Pulang
Tanggal berangkat pulang
</label>
<p className="mb-1.5 text-[11px] leading-snug text-neutral-500 sm:text-xs">
Pilih satu tanggal untuk trip <span className="font-medium">satu hari</span>
. Pilih rentang untuk trip <span className="font-medium">lebih dari satu hari</span>
. Tanggal disimpan sebagai hari kalender yang kamu klik; filter Open Trip memakai{" "}
<span className="font-medium">UTC</span> yang sama.
</p>
<div className="relative">
<span className="absolute left-3 top-1/2 z-10 -translate-y-1/2 text-neutral-400">
<svg
+26
View File
@@ -44,6 +44,11 @@ export type StringNullableFilter<$PrismaModel = never> = {
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type BoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}
export type DateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
@@ -96,6 +101,14 @@ export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type BoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBoolFilter<$PrismaModel>
_max?: Prisma.NestedBoolFilter<$PrismaModel>
}
export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
@@ -224,6 +237,11 @@ export type NestedStringNullableFilter<$PrismaModel = never> = {
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type NestedBoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}
export type NestedDateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
@@ -291,6 +309,14 @@ export type NestedIntNullableFilter<$PrismaModel = never> = {
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
}
export type NestedBoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBoolFilter<$PrismaModel>
_max?: Prisma.NestedBoolFilter<$PrismaModel>
}
export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
File diff suppressed because one or more lines are too long
@@ -823,6 +823,7 @@ export const UserScalarFieldEnum = {
email: 'email',
password: 'password',
image: 'image',
isVerified: 'isVerified',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
@@ -836,6 +837,10 @@ export const TripScalarFieldEnum = {
description: 'description',
mountain: 'mountain',
location: 'location',
meetingPoint: 'meetingPoint',
itinerary: 'itinerary',
whatsIncluded: 'whatsIncluded',
whatsExcluded: 'whatsExcluded',
date: 'date',
endDate: 'endDate',
maxParticipants: 'maxParticipants',
@@ -877,6 +882,8 @@ export const TripParticipantScalarFieldEnum = {
id: 'id',
status: 'status',
createdAt: 'createdAt',
markedPaidAt: 'markedPaidAt',
paymentConfirmedAt: 'paymentConfirmedAt',
tripId: 'tripId',
userId: 'userId'
} as const
@@ -928,6 +935,13 @@ export type ListStringFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaMod
/**
* Reference to a field of type 'Boolean'
*/
export type BooleanFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Boolean'>
/**
* Reference to a field of type 'DateTime'
*/
@@ -80,6 +80,7 @@ export const UserScalarFieldEnum = {
email: 'email',
password: 'password',
image: 'image',
isVerified: 'isVerified',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
@@ -93,6 +94,10 @@ export const TripScalarFieldEnum = {
description: 'description',
mountain: 'mountain',
location: 'location',
meetingPoint: 'meetingPoint',
itinerary: 'itinerary',
whatsIncluded: 'whatsIncluded',
whatsExcluded: 'whatsExcluded',
date: 'date',
endDate: 'endDate',
maxParticipants: 'maxParticipants',
@@ -134,6 +139,8 @@ export const TripParticipantScalarFieldEnum = {
id: 'id',
status: 'status',
createdAt: 'createdAt',
markedPaidAt: 'markedPaidAt',
paymentConfirmedAt: 'paymentConfirmedAt',
tripId: 'tripId',
userId: 'userId'
} as const
+201 -1
View File
@@ -42,6 +42,10 @@ export type TripMinAggregateOutputType = {
description: string | null
mountain: string | null
location: string | null
meetingPoint: string | null
itinerary: string | null
whatsIncluded: string | null
whatsExcluded: string | null
date: Date | null
endDate: Date | null
maxParticipants: number | null
@@ -58,6 +62,10 @@ export type TripMaxAggregateOutputType = {
description: string | null
mountain: string | null
location: string | null
meetingPoint: string | null
itinerary: string | null
whatsIncluded: string | null
whatsExcluded: string | null
date: Date | null
endDate: Date | null
maxParticipants: number | null
@@ -74,6 +82,10 @@ export type TripCountAggregateOutputType = {
description: number
mountain: number
location: number
meetingPoint: number
itinerary: number
whatsIncluded: number
whatsExcluded: number
date: number
endDate: number
maxParticipants: number
@@ -102,6 +114,10 @@ export type TripMinAggregateInputType = {
description?: true
mountain?: true
location?: true
meetingPoint?: true
itinerary?: true
whatsIncluded?: true
whatsExcluded?: true
date?: true
endDate?: true
maxParticipants?: true
@@ -118,6 +134,10 @@ export type TripMaxAggregateInputType = {
description?: true
mountain?: true
location?: true
meetingPoint?: true
itinerary?: true
whatsIncluded?: true
whatsExcluded?: true
date?: true
endDate?: true
maxParticipants?: true
@@ -134,6 +154,10 @@ export type TripCountAggregateInputType = {
description?: true
mountain?: true
location?: true
meetingPoint?: true
itinerary?: true
whatsIncluded?: true
whatsExcluded?: true
date?: true
endDate?: true
maxParticipants?: true
@@ -237,6 +261,10 @@ export type TripGroupByOutputType = {
description: string | null
mountain: string
location: string
meetingPoint: string | null
itinerary: string | null
whatsIncluded: string | null
whatsExcluded: string | null
date: Date
endDate: Date | null
maxParticipants: number
@@ -276,6 +304,10 @@ export type TripWhereInput = {
description?: Prisma.StringNullableFilter<"Trip"> | string | null
mountain?: Prisma.StringFilter<"Trip"> | string
location?: Prisma.StringFilter<"Trip"> | string
meetingPoint?: Prisma.StringNullableFilter<"Trip"> | string | null
itinerary?: Prisma.StringNullableFilter<"Trip"> | string | null
whatsIncluded?: Prisma.StringNullableFilter<"Trip"> | string | null
whatsExcluded?: Prisma.StringNullableFilter<"Trip"> | string | null
date?: Prisma.DateTimeFilter<"Trip"> | Date | string
endDate?: Prisma.DateTimeNullableFilter<"Trip"> | Date | string | null
maxParticipants?: Prisma.IntFilter<"Trip"> | number
@@ -296,6 +328,10 @@ export type TripOrderByWithRelationInput = {
description?: Prisma.SortOrderInput | Prisma.SortOrder
mountain?: Prisma.SortOrder
location?: Prisma.SortOrder
meetingPoint?: Prisma.SortOrderInput | Prisma.SortOrder
itinerary?: Prisma.SortOrderInput | Prisma.SortOrder
whatsIncluded?: Prisma.SortOrderInput | Prisma.SortOrder
whatsExcluded?: Prisma.SortOrderInput | Prisma.SortOrder
date?: Prisma.SortOrder
endDate?: Prisma.SortOrderInput | Prisma.SortOrder
maxParticipants?: Prisma.SortOrder
@@ -319,6 +355,10 @@ export type TripWhereUniqueInput = Prisma.AtLeast<{
description?: Prisma.StringNullableFilter<"Trip"> | string | null
mountain?: Prisma.StringFilter<"Trip"> | string
location?: Prisma.StringFilter<"Trip"> | string
meetingPoint?: Prisma.StringNullableFilter<"Trip"> | string | null
itinerary?: Prisma.StringNullableFilter<"Trip"> | string | null
whatsIncluded?: Prisma.StringNullableFilter<"Trip"> | string | null
whatsExcluded?: Prisma.StringNullableFilter<"Trip"> | string | null
date?: Prisma.DateTimeFilter<"Trip"> | Date | string
endDate?: Prisma.DateTimeNullableFilter<"Trip"> | Date | string | null
maxParticipants?: Prisma.IntFilter<"Trip"> | number
@@ -339,6 +379,10 @@ export type TripOrderByWithAggregationInput = {
description?: Prisma.SortOrderInput | Prisma.SortOrder
mountain?: Prisma.SortOrder
location?: Prisma.SortOrder
meetingPoint?: Prisma.SortOrderInput | Prisma.SortOrder
itinerary?: Prisma.SortOrderInput | Prisma.SortOrder
whatsIncluded?: Prisma.SortOrderInput | Prisma.SortOrder
whatsExcluded?: Prisma.SortOrderInput | Prisma.SortOrder
date?: Prisma.SortOrder
endDate?: Prisma.SortOrderInput | Prisma.SortOrder
maxParticipants?: Prisma.SortOrder
@@ -363,6 +407,10 @@ export type TripScalarWhereWithAggregatesInput = {
description?: Prisma.StringNullableWithAggregatesFilter<"Trip"> | string | null
mountain?: Prisma.StringWithAggregatesFilter<"Trip"> | string
location?: Prisma.StringWithAggregatesFilter<"Trip"> | string
meetingPoint?: Prisma.StringNullableWithAggregatesFilter<"Trip"> | string | null
itinerary?: Prisma.StringNullableWithAggregatesFilter<"Trip"> | string | null
whatsIncluded?: Prisma.StringNullableWithAggregatesFilter<"Trip"> | string | null
whatsExcluded?: Prisma.StringNullableWithAggregatesFilter<"Trip"> | string | null
date?: Prisma.DateTimeWithAggregatesFilter<"Trip"> | Date | string
endDate?: Prisma.DateTimeNullableWithAggregatesFilter<"Trip"> | Date | string | null
maxParticipants?: Prisma.IntWithAggregatesFilter<"Trip"> | number
@@ -379,6 +427,10 @@ export type TripCreateInput = {
description?: string | null
mountain: string
location: string
meetingPoint?: string | null
itinerary?: string | null
whatsIncluded?: string | null
whatsExcluded?: string | null
date: Date | string
endDate?: Date | string | null
maxParticipants: number
@@ -398,6 +450,10 @@ export type TripUncheckedCreateInput = {
description?: string | null
mountain: string
location: string
meetingPoint?: string | null
itinerary?: string | null
whatsIncluded?: string | null
whatsExcluded?: string | null
date: Date | string
endDate?: Date | string | null
maxParticipants: number
@@ -417,6 +473,10 @@ export type TripUpdateInput = {
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
mountain?: Prisma.StringFieldUpdateOperationsInput | string
location?: Prisma.StringFieldUpdateOperationsInput | string
meetingPoint?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
itinerary?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
whatsIncluded?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
whatsExcluded?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
date?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
endDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
maxParticipants?: Prisma.IntFieldUpdateOperationsInput | number
@@ -436,6 +496,10 @@ export type TripUncheckedUpdateInput = {
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
mountain?: Prisma.StringFieldUpdateOperationsInput | string
location?: Prisma.StringFieldUpdateOperationsInput | string
meetingPoint?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
itinerary?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
whatsIncluded?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
whatsExcluded?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
date?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
endDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
maxParticipants?: Prisma.IntFieldUpdateOperationsInput | number
@@ -455,6 +519,10 @@ export type TripCreateManyInput = {
description?: string | null
mountain: string
location: string
meetingPoint?: string | null
itinerary?: string | null
whatsIncluded?: string | null
whatsExcluded?: string | null
date: Date | string
endDate?: Date | string | null
maxParticipants: number
@@ -471,6 +539,10 @@ export type TripUpdateManyMutationInput = {
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
mountain?: Prisma.StringFieldUpdateOperationsInput | string
location?: Prisma.StringFieldUpdateOperationsInput | string
meetingPoint?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
itinerary?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
whatsIncluded?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
whatsExcluded?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
date?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
endDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
maxParticipants?: Prisma.IntFieldUpdateOperationsInput | number
@@ -486,6 +558,10 @@ export type TripUncheckedUpdateManyInput = {
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
mountain?: Prisma.StringFieldUpdateOperationsInput | string
location?: Prisma.StringFieldUpdateOperationsInput | string
meetingPoint?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
itinerary?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
whatsIncluded?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
whatsExcluded?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
date?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
endDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
maxParticipants?: Prisma.IntFieldUpdateOperationsInput | number
@@ -512,6 +588,10 @@ export type TripCountOrderByAggregateInput = {
description?: Prisma.SortOrder
mountain?: Prisma.SortOrder
location?: Prisma.SortOrder
meetingPoint?: Prisma.SortOrder
itinerary?: Prisma.SortOrder
whatsIncluded?: Prisma.SortOrder
whatsExcluded?: Prisma.SortOrder
date?: Prisma.SortOrder
endDate?: Prisma.SortOrder
maxParticipants?: Prisma.SortOrder
@@ -533,6 +613,10 @@ export type TripMaxOrderByAggregateInput = {
description?: Prisma.SortOrder
mountain?: Prisma.SortOrder
location?: Prisma.SortOrder
meetingPoint?: Prisma.SortOrder
itinerary?: Prisma.SortOrder
whatsIncluded?: Prisma.SortOrder
whatsExcluded?: Prisma.SortOrder
date?: Prisma.SortOrder
endDate?: Prisma.SortOrder
maxParticipants?: Prisma.SortOrder
@@ -549,6 +633,10 @@ export type TripMinOrderByAggregateInput = {
description?: Prisma.SortOrder
mountain?: Prisma.SortOrder
location?: Prisma.SortOrder
meetingPoint?: Prisma.SortOrder
itinerary?: Prisma.SortOrder
whatsIncluded?: Prisma.SortOrder
whatsExcluded?: Prisma.SortOrder
date?: Prisma.SortOrder
endDate?: Prisma.SortOrder
maxParticipants?: Prisma.SortOrder
@@ -675,6 +763,10 @@ export type TripCreateWithoutOrganizerInput = {
description?: string | null
mountain: string
location: string
meetingPoint?: string | null
itinerary?: string | null
whatsIncluded?: string | null
whatsExcluded?: string | null
date: Date | string
endDate?: Date | string | null
maxParticipants: number
@@ -693,6 +785,10 @@ export type TripUncheckedCreateWithoutOrganizerInput = {
description?: string | null
mountain: string
location: string
meetingPoint?: string | null
itinerary?: string | null
whatsIncluded?: string | null
whatsExcluded?: string | null
date: Date | string
endDate?: Date | string | null
maxParticipants: number
@@ -740,6 +836,10 @@ export type TripScalarWhereInput = {
description?: Prisma.StringNullableFilter<"Trip"> | string | null
mountain?: Prisma.StringFilter<"Trip"> | string
location?: Prisma.StringFilter<"Trip"> | string
meetingPoint?: Prisma.StringNullableFilter<"Trip"> | string | null
itinerary?: Prisma.StringNullableFilter<"Trip"> | string | null
whatsIncluded?: Prisma.StringNullableFilter<"Trip"> | string | null
whatsExcluded?: Prisma.StringNullableFilter<"Trip"> | string | null
date?: Prisma.DateTimeFilter<"Trip"> | Date | string
endDate?: Prisma.DateTimeNullableFilter<"Trip"> | Date | string | null
maxParticipants?: Prisma.IntFilter<"Trip"> | number
@@ -756,6 +856,10 @@ export type TripCreateWithoutReviewsInput = {
description?: string | null
mountain: string
location: string
meetingPoint?: string | null
itinerary?: string | null
whatsIncluded?: string | null
whatsExcluded?: string | null
date: Date | string
endDate?: Date | string | null
maxParticipants: number
@@ -774,6 +878,10 @@ export type TripUncheckedCreateWithoutReviewsInput = {
description?: string | null
mountain: string
location: string
meetingPoint?: string | null
itinerary?: string | null
whatsIncluded?: string | null
whatsExcluded?: string | null
date: Date | string
endDate?: Date | string | null
maxParticipants: number
@@ -808,6 +916,10 @@ export type TripUpdateWithoutReviewsInput = {
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
mountain?: Prisma.StringFieldUpdateOperationsInput | string
location?: Prisma.StringFieldUpdateOperationsInput | string
meetingPoint?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
itinerary?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
whatsIncluded?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
whatsExcluded?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
date?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
endDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
maxParticipants?: Prisma.IntFieldUpdateOperationsInput | number
@@ -826,6 +938,10 @@ export type TripUncheckedUpdateWithoutReviewsInput = {
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
mountain?: Prisma.StringFieldUpdateOperationsInput | string
location?: Prisma.StringFieldUpdateOperationsInput | string
meetingPoint?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
itinerary?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
whatsIncluded?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
whatsExcluded?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
date?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
endDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
maxParticipants?: Prisma.IntFieldUpdateOperationsInput | number
@@ -844,6 +960,10 @@ export type TripCreateWithoutImagesInput = {
description?: string | null
mountain: string
location: string
meetingPoint?: string | null
itinerary?: string | null
whatsIncluded?: string | null
whatsExcluded?: string | null
date: Date | string
endDate?: Date | string | null
maxParticipants: number
@@ -862,6 +982,10 @@ export type TripUncheckedCreateWithoutImagesInput = {
description?: string | null
mountain: string
location: string
meetingPoint?: string | null
itinerary?: string | null
whatsIncluded?: string | null
whatsExcluded?: string | null
date: Date | string
endDate?: Date | string | null
maxParticipants: number
@@ -896,6 +1020,10 @@ export type TripUpdateWithoutImagesInput = {
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
mountain?: Prisma.StringFieldUpdateOperationsInput | string
location?: Prisma.StringFieldUpdateOperationsInput | string
meetingPoint?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
itinerary?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
whatsIncluded?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
whatsExcluded?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
date?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
endDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
maxParticipants?: Prisma.IntFieldUpdateOperationsInput | number
@@ -914,6 +1042,10 @@ export type TripUncheckedUpdateWithoutImagesInput = {
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
mountain?: Prisma.StringFieldUpdateOperationsInput | string
location?: Prisma.StringFieldUpdateOperationsInput | string
meetingPoint?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
itinerary?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
whatsIncluded?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
whatsExcluded?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
date?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
endDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
maxParticipants?: Prisma.IntFieldUpdateOperationsInput | number
@@ -932,6 +1064,10 @@ export type TripCreateWithoutParticipantsInput = {
description?: string | null
mountain: string
location: string
meetingPoint?: string | null
itinerary?: string | null
whatsIncluded?: string | null
whatsExcluded?: string | null
date: Date | string
endDate?: Date | string | null
maxParticipants: number
@@ -950,6 +1086,10 @@ export type TripUncheckedCreateWithoutParticipantsInput = {
description?: string | null
mountain: string
location: string
meetingPoint?: string | null
itinerary?: string | null
whatsIncluded?: string | null
whatsExcluded?: string | null
date: Date | string
endDate?: Date | string | null
maxParticipants: number
@@ -984,6 +1124,10 @@ export type TripUpdateWithoutParticipantsInput = {
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
mountain?: Prisma.StringFieldUpdateOperationsInput | string
location?: Prisma.StringFieldUpdateOperationsInput | string
meetingPoint?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
itinerary?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
whatsIncluded?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
whatsExcluded?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
date?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
endDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
maxParticipants?: Prisma.IntFieldUpdateOperationsInput | number
@@ -1002,6 +1146,10 @@ export type TripUncheckedUpdateWithoutParticipantsInput = {
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
mountain?: Prisma.StringFieldUpdateOperationsInput | string
location?: Prisma.StringFieldUpdateOperationsInput | string
meetingPoint?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
itinerary?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
whatsIncluded?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
whatsExcluded?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
date?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
endDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
maxParticipants?: Prisma.IntFieldUpdateOperationsInput | number
@@ -1020,6 +1168,10 @@ export type TripCreateManyOrganizerInput = {
description?: string | null
mountain: string
location: string
meetingPoint?: string | null
itinerary?: string | null
whatsIncluded?: string | null
whatsExcluded?: string | null
date: Date | string
endDate?: Date | string | null
maxParticipants: number
@@ -1035,6 +1187,10 @@ export type TripUpdateWithoutOrganizerInput = {
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
mountain?: Prisma.StringFieldUpdateOperationsInput | string
location?: Prisma.StringFieldUpdateOperationsInput | string
meetingPoint?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
itinerary?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
whatsIncluded?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
whatsExcluded?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
date?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
endDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
maxParticipants?: Prisma.IntFieldUpdateOperationsInput | number
@@ -1053,6 +1209,10 @@ export type TripUncheckedUpdateWithoutOrganizerInput = {
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
mountain?: Prisma.StringFieldUpdateOperationsInput | string
location?: Prisma.StringFieldUpdateOperationsInput | string
meetingPoint?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
itinerary?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
whatsIncluded?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
whatsExcluded?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
date?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
endDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
maxParticipants?: Prisma.IntFieldUpdateOperationsInput | number
@@ -1071,6 +1231,10 @@ export type TripUncheckedUpdateManyWithoutOrganizerInput = {
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
mountain?: Prisma.StringFieldUpdateOperationsInput | string
location?: Prisma.StringFieldUpdateOperationsInput | string
meetingPoint?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
itinerary?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
whatsIncluded?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
whatsExcluded?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
date?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
endDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
maxParticipants?: Prisma.IntFieldUpdateOperationsInput | number
@@ -1135,6 +1299,10 @@ export type TripSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
description?: boolean
mountain?: boolean
location?: boolean
meetingPoint?: boolean
itinerary?: boolean
whatsIncluded?: boolean
whatsExcluded?: boolean
date?: boolean
endDate?: boolean
maxParticipants?: boolean
@@ -1156,6 +1324,10 @@ export type TripSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensio
description?: boolean
mountain?: boolean
location?: boolean
meetingPoint?: boolean
itinerary?: boolean
whatsIncluded?: boolean
whatsExcluded?: boolean
date?: boolean
endDate?: boolean
maxParticipants?: boolean
@@ -1173,6 +1345,10 @@ export type TripSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensio
description?: boolean
mountain?: boolean
location?: boolean
meetingPoint?: boolean
itinerary?: boolean
whatsIncluded?: boolean
whatsExcluded?: boolean
date?: boolean
endDate?: boolean
maxParticipants?: boolean
@@ -1190,6 +1366,10 @@ export type TripSelectScalar = {
description?: boolean
mountain?: boolean
location?: boolean
meetingPoint?: boolean
itinerary?: boolean
whatsIncluded?: boolean
whatsExcluded?: boolean
date?: boolean
endDate?: boolean
maxParticipants?: boolean
@@ -1200,7 +1380,7 @@ export type TripSelectScalar = {
organizerId?: boolean
}
export type TripOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "title" | "description" | "mountain" | "location" | "date" | "endDate" | "maxParticipants" | "price" | "status" | "createdAt" | "updatedAt" | "organizerId", ExtArgs["result"]["trip"]>
export type TripOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "title" | "description" | "mountain" | "location" | "meetingPoint" | "itinerary" | "whatsIncluded" | "whatsExcluded" | "date" | "endDate" | "maxParticipants" | "price" | "status" | "createdAt" | "updatedAt" | "organizerId", ExtArgs["result"]["trip"]>
export type TripInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
organizer?: boolean | Prisma.UserDefaultArgs<ExtArgs>
participants?: boolean | Prisma.Trip$participantsArgs<ExtArgs>
@@ -1229,6 +1409,22 @@ export type $TripPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
description: string | null
mountain: string
location: string
/**
* Titik kumpul / meeting point (teks bebas)
*/
meetingPoint: string | null
/**
* Itinerary hari per hari (teks bebas, bullet OK)
*/
itinerary: string | null
/**
* Yang termasuk harga (teks bebas)
*/
whatsIncluded: string | null
/**
* Yang tidak termasuk (teks bebas)
*/
whatsExcluded: string | null
date: Date
endDate: Date | null
maxParticipants: number
@@ -1669,6 +1865,10 @@ export interface TripFieldRefs {
readonly description: Prisma.FieldRef<"Trip", 'String'>
readonly mountain: Prisma.FieldRef<"Trip", 'String'>
readonly location: Prisma.FieldRef<"Trip", 'String'>
readonly meetingPoint: Prisma.FieldRef<"Trip", 'String'>
readonly itinerary: Prisma.FieldRef<"Trip", 'String'>
readonly whatsIncluded: Prisma.FieldRef<"Trip", 'String'>
readonly whatsExcluded: Prisma.FieldRef<"Trip", 'String'>
readonly date: Prisma.FieldRef<"Trip", 'DateTime'>
readonly endDate: Prisma.FieldRef<"Trip", 'DateTime'>
readonly maxParticipants: Prisma.FieldRef<"Trip", 'Int'>
+89 -1
View File
@@ -28,6 +28,8 @@ export type TripParticipantMinAggregateOutputType = {
id: string | null
status: $Enums.ParticipantStatus | null
createdAt: Date | null
markedPaidAt: Date | null
paymentConfirmedAt: Date | null
tripId: string | null
userId: string | null
}
@@ -36,6 +38,8 @@ export type TripParticipantMaxAggregateOutputType = {
id: string | null
status: $Enums.ParticipantStatus | null
createdAt: Date | null
markedPaidAt: Date | null
paymentConfirmedAt: Date | null
tripId: string | null
userId: string | null
}
@@ -44,6 +48,8 @@ export type TripParticipantCountAggregateOutputType = {
id: number
status: number
createdAt: number
markedPaidAt: number
paymentConfirmedAt: number
tripId: number
userId: number
_all: number
@@ -54,6 +60,8 @@ export type TripParticipantMinAggregateInputType = {
id?: true
status?: true
createdAt?: true
markedPaidAt?: true
paymentConfirmedAt?: true
tripId?: true
userId?: true
}
@@ -62,6 +70,8 @@ export type TripParticipantMaxAggregateInputType = {
id?: true
status?: true
createdAt?: true
markedPaidAt?: true
paymentConfirmedAt?: true
tripId?: true
userId?: true
}
@@ -70,6 +80,8 @@ export type TripParticipantCountAggregateInputType = {
id?: true
status?: true
createdAt?: true
markedPaidAt?: true
paymentConfirmedAt?: true
tripId?: true
userId?: true
_all?: true
@@ -151,6 +163,8 @@ export type TripParticipantGroupByOutputType = {
id: string
status: $Enums.ParticipantStatus
createdAt: Date
markedPaidAt: Date | null
paymentConfirmedAt: Date | null
tripId: string
userId: string
_count: TripParticipantCountAggregateOutputType | null
@@ -180,6 +194,8 @@ export type TripParticipantWhereInput = {
id?: Prisma.StringFilter<"TripParticipant"> | string
status?: Prisma.EnumParticipantStatusFilter<"TripParticipant"> | $Enums.ParticipantStatus
createdAt?: Prisma.DateTimeFilter<"TripParticipant"> | Date | string
markedPaidAt?: Prisma.DateTimeNullableFilter<"TripParticipant"> | Date | string | null
paymentConfirmedAt?: Prisma.DateTimeNullableFilter<"TripParticipant"> | Date | string | null
tripId?: Prisma.StringFilter<"TripParticipant"> | string
userId?: Prisma.StringFilter<"TripParticipant"> | string
trip?: Prisma.XOR<Prisma.TripScalarRelationFilter, Prisma.TripWhereInput>
@@ -190,6 +206,8 @@ export type TripParticipantOrderByWithRelationInput = {
id?: Prisma.SortOrder
status?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
markedPaidAt?: Prisma.SortOrderInput | Prisma.SortOrder
paymentConfirmedAt?: Prisma.SortOrderInput | Prisma.SortOrder
tripId?: Prisma.SortOrder
userId?: Prisma.SortOrder
trip?: Prisma.TripOrderByWithRelationInput
@@ -204,6 +222,8 @@ export type TripParticipantWhereUniqueInput = Prisma.AtLeast<{
NOT?: Prisma.TripParticipantWhereInput | Prisma.TripParticipantWhereInput[]
status?: Prisma.EnumParticipantStatusFilter<"TripParticipant"> | $Enums.ParticipantStatus
createdAt?: Prisma.DateTimeFilter<"TripParticipant"> | Date | string
markedPaidAt?: Prisma.DateTimeNullableFilter<"TripParticipant"> | Date | string | null
paymentConfirmedAt?: Prisma.DateTimeNullableFilter<"TripParticipant"> | Date | string | null
tripId?: Prisma.StringFilter<"TripParticipant"> | string
userId?: Prisma.StringFilter<"TripParticipant"> | string
trip?: Prisma.XOR<Prisma.TripScalarRelationFilter, Prisma.TripWhereInput>
@@ -214,6 +234,8 @@ export type TripParticipantOrderByWithAggregationInput = {
id?: Prisma.SortOrder
status?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
markedPaidAt?: Prisma.SortOrderInput | Prisma.SortOrder
paymentConfirmedAt?: Prisma.SortOrderInput | Prisma.SortOrder
tripId?: Prisma.SortOrder
userId?: Prisma.SortOrder
_count?: Prisma.TripParticipantCountOrderByAggregateInput
@@ -228,6 +250,8 @@ export type TripParticipantScalarWhereWithAggregatesInput = {
id?: Prisma.StringWithAggregatesFilter<"TripParticipant"> | string
status?: Prisma.EnumParticipantStatusWithAggregatesFilter<"TripParticipant"> | $Enums.ParticipantStatus
createdAt?: Prisma.DateTimeWithAggregatesFilter<"TripParticipant"> | Date | string
markedPaidAt?: Prisma.DateTimeNullableWithAggregatesFilter<"TripParticipant"> | Date | string | null
paymentConfirmedAt?: Prisma.DateTimeNullableWithAggregatesFilter<"TripParticipant"> | Date | string | null
tripId?: Prisma.StringWithAggregatesFilter<"TripParticipant"> | string
userId?: Prisma.StringWithAggregatesFilter<"TripParticipant"> | string
}
@@ -236,6 +260,8 @@ export type TripParticipantCreateInput = {
id?: string
status?: $Enums.ParticipantStatus
createdAt?: Date | string
markedPaidAt?: Date | string | null
paymentConfirmedAt?: Date | string | null
trip: Prisma.TripCreateNestedOneWithoutParticipantsInput
user: Prisma.UserCreateNestedOneWithoutParticipationsInput
}
@@ -244,6 +270,8 @@ export type TripParticipantUncheckedCreateInput = {
id?: string
status?: $Enums.ParticipantStatus
createdAt?: Date | string
markedPaidAt?: Date | string | null
paymentConfirmedAt?: Date | string | null
tripId: string
userId: string
}
@@ -252,6 +280,8 @@ export type TripParticipantUpdateInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumParticipantStatusFieldUpdateOperationsInput | $Enums.ParticipantStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
markedPaidAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
paymentConfirmedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
trip?: Prisma.TripUpdateOneRequiredWithoutParticipantsNestedInput
user?: Prisma.UserUpdateOneRequiredWithoutParticipationsNestedInput
}
@@ -260,6 +290,8 @@ export type TripParticipantUncheckedUpdateInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumParticipantStatusFieldUpdateOperationsInput | $Enums.ParticipantStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
markedPaidAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
paymentConfirmedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
tripId?: Prisma.StringFieldUpdateOperationsInput | string
userId?: Prisma.StringFieldUpdateOperationsInput | string
}
@@ -268,6 +300,8 @@ export type TripParticipantCreateManyInput = {
id?: string
status?: $Enums.ParticipantStatus
createdAt?: Date | string
markedPaidAt?: Date | string | null
paymentConfirmedAt?: Date | string | null
tripId: string
userId: string
}
@@ -276,12 +310,16 @@ export type TripParticipantUpdateManyMutationInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumParticipantStatusFieldUpdateOperationsInput | $Enums.ParticipantStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
markedPaidAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
paymentConfirmedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
}
export type TripParticipantUncheckedUpdateManyInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumParticipantStatusFieldUpdateOperationsInput | $Enums.ParticipantStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
markedPaidAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
paymentConfirmedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
tripId?: Prisma.StringFieldUpdateOperationsInput | string
userId?: Prisma.StringFieldUpdateOperationsInput | string
}
@@ -305,6 +343,8 @@ export type TripParticipantCountOrderByAggregateInput = {
id?: Prisma.SortOrder
status?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
markedPaidAt?: Prisma.SortOrder
paymentConfirmedAt?: Prisma.SortOrder
tripId?: Prisma.SortOrder
userId?: Prisma.SortOrder
}
@@ -313,6 +353,8 @@ export type TripParticipantMaxOrderByAggregateInput = {
id?: Prisma.SortOrder
status?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
markedPaidAt?: Prisma.SortOrder
paymentConfirmedAt?: Prisma.SortOrder
tripId?: Prisma.SortOrder
userId?: Prisma.SortOrder
}
@@ -321,6 +363,8 @@ export type TripParticipantMinOrderByAggregateInput = {
id?: Prisma.SortOrder
status?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
markedPaidAt?: Prisma.SortOrder
paymentConfirmedAt?: Prisma.SortOrder
tripId?: Prisma.SortOrder
userId?: Prisma.SortOrder
}
@@ -417,6 +461,8 @@ export type TripParticipantCreateWithoutUserInput = {
id?: string
status?: $Enums.ParticipantStatus
createdAt?: Date | string
markedPaidAt?: Date | string | null
paymentConfirmedAt?: Date | string | null
trip: Prisma.TripCreateNestedOneWithoutParticipantsInput
}
@@ -424,6 +470,8 @@ export type TripParticipantUncheckedCreateWithoutUserInput = {
id?: string
status?: $Enums.ParticipantStatus
createdAt?: Date | string
markedPaidAt?: Date | string | null
paymentConfirmedAt?: Date | string | null
tripId: string
}
@@ -460,6 +508,8 @@ export type TripParticipantScalarWhereInput = {
id?: Prisma.StringFilter<"TripParticipant"> | string
status?: Prisma.EnumParticipantStatusFilter<"TripParticipant"> | $Enums.ParticipantStatus
createdAt?: Prisma.DateTimeFilter<"TripParticipant"> | Date | string
markedPaidAt?: Prisma.DateTimeNullableFilter<"TripParticipant"> | Date | string | null
paymentConfirmedAt?: Prisma.DateTimeNullableFilter<"TripParticipant"> | Date | string | null
tripId?: Prisma.StringFilter<"TripParticipant"> | string
userId?: Prisma.StringFilter<"TripParticipant"> | string
}
@@ -468,6 +518,8 @@ export type TripParticipantCreateWithoutTripInput = {
id?: string
status?: $Enums.ParticipantStatus
createdAt?: Date | string
markedPaidAt?: Date | string | null
paymentConfirmedAt?: Date | string | null
user: Prisma.UserCreateNestedOneWithoutParticipationsInput
}
@@ -475,6 +527,8 @@ export type TripParticipantUncheckedCreateWithoutTripInput = {
id?: string
status?: $Enums.ParticipantStatus
createdAt?: Date | string
markedPaidAt?: Date | string | null
paymentConfirmedAt?: Date | string | null
userId: string
}
@@ -508,6 +562,8 @@ export type TripParticipantCreateManyUserInput = {
id?: string
status?: $Enums.ParticipantStatus
createdAt?: Date | string
markedPaidAt?: Date | string | null
paymentConfirmedAt?: Date | string | null
tripId: string
}
@@ -515,6 +571,8 @@ export type TripParticipantUpdateWithoutUserInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumParticipantStatusFieldUpdateOperationsInput | $Enums.ParticipantStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
markedPaidAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
paymentConfirmedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
trip?: Prisma.TripUpdateOneRequiredWithoutParticipantsNestedInput
}
@@ -522,6 +580,8 @@ export type TripParticipantUncheckedUpdateWithoutUserInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumParticipantStatusFieldUpdateOperationsInput | $Enums.ParticipantStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
markedPaidAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
paymentConfirmedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
tripId?: Prisma.StringFieldUpdateOperationsInput | string
}
@@ -529,6 +589,8 @@ export type TripParticipantUncheckedUpdateManyWithoutUserInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumParticipantStatusFieldUpdateOperationsInput | $Enums.ParticipantStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
markedPaidAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
paymentConfirmedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
tripId?: Prisma.StringFieldUpdateOperationsInput | string
}
@@ -536,6 +598,8 @@ export type TripParticipantCreateManyTripInput = {
id?: string
status?: $Enums.ParticipantStatus
createdAt?: Date | string
markedPaidAt?: Date | string | null
paymentConfirmedAt?: Date | string | null
userId: string
}
@@ -543,6 +607,8 @@ export type TripParticipantUpdateWithoutTripInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumParticipantStatusFieldUpdateOperationsInput | $Enums.ParticipantStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
markedPaidAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
paymentConfirmedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
user?: Prisma.UserUpdateOneRequiredWithoutParticipationsNestedInput
}
@@ -550,6 +616,8 @@ export type TripParticipantUncheckedUpdateWithoutTripInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumParticipantStatusFieldUpdateOperationsInput | $Enums.ParticipantStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
markedPaidAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
paymentConfirmedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
userId?: Prisma.StringFieldUpdateOperationsInput | string
}
@@ -557,6 +625,8 @@ export type TripParticipantUncheckedUpdateManyWithoutTripInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumParticipantStatusFieldUpdateOperationsInput | $Enums.ParticipantStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
markedPaidAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
paymentConfirmedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
userId?: Prisma.StringFieldUpdateOperationsInput | string
}
@@ -566,6 +636,8 @@ export type TripParticipantSelect<ExtArgs extends runtime.Types.Extensions.Inter
id?: boolean
status?: boolean
createdAt?: boolean
markedPaidAt?: boolean
paymentConfirmedAt?: boolean
tripId?: boolean
userId?: boolean
trip?: boolean | Prisma.TripDefaultArgs<ExtArgs>
@@ -576,6 +648,8 @@ export type TripParticipantSelectCreateManyAndReturn<ExtArgs extends runtime.Typ
id?: boolean
status?: boolean
createdAt?: boolean
markedPaidAt?: boolean
paymentConfirmedAt?: boolean
tripId?: boolean
userId?: boolean
trip?: boolean | Prisma.TripDefaultArgs<ExtArgs>
@@ -586,6 +660,8 @@ export type TripParticipantSelectUpdateManyAndReturn<ExtArgs extends runtime.Typ
id?: boolean
status?: boolean
createdAt?: boolean
markedPaidAt?: boolean
paymentConfirmedAt?: boolean
tripId?: boolean
userId?: boolean
trip?: boolean | Prisma.TripDefaultArgs<ExtArgs>
@@ -596,11 +672,13 @@ export type TripParticipantSelectScalar = {
id?: boolean
status?: boolean
createdAt?: boolean
markedPaidAt?: boolean
paymentConfirmedAt?: boolean
tripId?: boolean
userId?: boolean
}
export type TripParticipantOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "status" | "createdAt" | "tripId" | "userId", ExtArgs["result"]["tripParticipant"]>
export type TripParticipantOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "status" | "createdAt" | "markedPaidAt" | "paymentConfirmedAt" | "tripId" | "userId", ExtArgs["result"]["tripParticipant"]>
export type TripParticipantInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
trip?: boolean | Prisma.TripDefaultArgs<ExtArgs>
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
@@ -624,6 +702,14 @@ export type $TripParticipantPayload<ExtArgs extends runtime.Types.Extensions.Int
id: string
status: $Enums.ParticipantStatus
createdAt: Date
/**
* Peserta menekan "Saya sudah bayar" (pembayaran manual)
*/
markedPaidAt: Date | null
/**
* Organizer mengonfirmasi uang sudah masuk
*/
paymentConfirmedAt: Date | null
tripId: string
userId: string
}, ExtArgs["result"]["tripParticipant"]>
@@ -1054,6 +1140,8 @@ export interface TripParticipantFieldRefs {
readonly id: Prisma.FieldRef<"TripParticipant", 'String'>
readonly status: Prisma.FieldRef<"TripParticipant", 'ParticipantStatus'>
readonly createdAt: Prisma.FieldRef<"TripParticipant", 'DateTime'>
readonly markedPaidAt: Prisma.FieldRef<"TripParticipant", 'DateTime'>
readonly paymentConfirmedAt: Prisma.FieldRef<"TripParticipant", 'DateTime'>
readonly tripId: Prisma.FieldRef<"TripParticipant", 'String'>
readonly userId: Prisma.FieldRef<"TripParticipant", 'String'>
}
+48 -1
View File
@@ -30,6 +30,7 @@ export type UserMinAggregateOutputType = {
email: string | null
password: string | null
image: string | null
isVerified: boolean | null
createdAt: Date | null
updatedAt: Date | null
}
@@ -40,6 +41,7 @@ export type UserMaxAggregateOutputType = {
email: string | null
password: string | null
image: string | null
isVerified: boolean | null
createdAt: Date | null
updatedAt: Date | null
}
@@ -50,6 +52,7 @@ export type UserCountAggregateOutputType = {
email: number
password: number
image: number
isVerified: number
createdAt: number
updatedAt: number
_all: number
@@ -62,6 +65,7 @@ export type UserMinAggregateInputType = {
email?: true
password?: true
image?: true
isVerified?: true
createdAt?: true
updatedAt?: true
}
@@ -72,6 +76,7 @@ export type UserMaxAggregateInputType = {
email?: true
password?: true
image?: true
isVerified?: true
createdAt?: true
updatedAt?: true
}
@@ -82,6 +87,7 @@ export type UserCountAggregateInputType = {
email?: true
password?: true
image?: true
isVerified?: true
createdAt?: true
updatedAt?: true
_all?: true
@@ -165,6 +171,7 @@ export type UserGroupByOutputType = {
email: string
password: string
image: string | null
isVerified: boolean
createdAt: Date
updatedAt: Date
_count: UserCountAggregateOutputType | null
@@ -196,6 +203,7 @@ export type UserWhereInput = {
email?: Prisma.StringFilter<"User"> | string
password?: Prisma.StringFilter<"User"> | string
image?: Prisma.StringNullableFilter<"User"> | string | null
isVerified?: Prisma.BoolFilter<"User"> | boolean
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
trips?: Prisma.TripListRelationFilter
@@ -209,6 +217,7 @@ export type UserOrderByWithRelationInput = {
email?: Prisma.SortOrder
password?: Prisma.SortOrder
image?: Prisma.SortOrderInput | Prisma.SortOrder
isVerified?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
trips?: Prisma.TripOrderByRelationAggregateInput
@@ -225,6 +234,7 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
name?: Prisma.StringFilter<"User"> | string
password?: Prisma.StringFilter<"User"> | string
image?: Prisma.StringNullableFilter<"User"> | string | null
isVerified?: Prisma.BoolFilter<"User"> | boolean
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
trips?: Prisma.TripListRelationFilter
@@ -238,6 +248,7 @@ export type UserOrderByWithAggregationInput = {
email?: Prisma.SortOrder
password?: Prisma.SortOrder
image?: Prisma.SortOrderInput | Prisma.SortOrder
isVerified?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
_count?: Prisma.UserCountOrderByAggregateInput
@@ -254,6 +265,7 @@ export type UserScalarWhereWithAggregatesInput = {
email?: Prisma.StringWithAggregatesFilter<"User"> | string
password?: Prisma.StringWithAggregatesFilter<"User"> | string
image?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
isVerified?: Prisma.BoolWithAggregatesFilter<"User"> | boolean
createdAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string
}
@@ -264,6 +276,7 @@ export type UserCreateInput = {
email: string
password: string
image?: string | null
isVerified?: boolean
createdAt?: Date | string
updatedAt?: Date | string
trips?: Prisma.TripCreateNestedManyWithoutOrganizerInput
@@ -277,6 +290,7 @@ export type UserUncheckedCreateInput = {
email: string
password: string
image?: string | null
isVerified?: boolean
createdAt?: Date | string
updatedAt?: Date | string
trips?: Prisma.TripUncheckedCreateNestedManyWithoutOrganizerInput
@@ -290,6 +304,7 @@ export type UserUpdateInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
isVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
trips?: Prisma.TripUpdateManyWithoutOrganizerNestedInput
@@ -303,6 +318,7 @@ export type UserUncheckedUpdateInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
isVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
trips?: Prisma.TripUncheckedUpdateManyWithoutOrganizerNestedInput
@@ -316,6 +332,7 @@ export type UserCreateManyInput = {
email: string
password: string
image?: string | null
isVerified?: boolean
createdAt?: Date | string
updatedAt?: Date | string
}
@@ -326,6 +343,7 @@ export type UserUpdateManyMutationInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
isVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
@@ -336,6 +354,7 @@ export type UserUncheckedUpdateManyInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
isVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
@@ -346,6 +365,7 @@ export type UserCountOrderByAggregateInput = {
email?: Prisma.SortOrder
password?: Prisma.SortOrder
image?: Prisma.SortOrder
isVerified?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
}
@@ -356,6 +376,7 @@ export type UserMaxOrderByAggregateInput = {
email?: Prisma.SortOrder
password?: Prisma.SortOrder
image?: Prisma.SortOrder
isVerified?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
}
@@ -366,6 +387,7 @@ export type UserMinOrderByAggregateInput = {
email?: Prisma.SortOrder
password?: Prisma.SortOrder
image?: Prisma.SortOrder
isVerified?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
}
@@ -383,6 +405,10 @@ export type NullableStringFieldUpdateOperationsInput = {
set?: string | null
}
export type BoolFieldUpdateOperationsInput = {
set?: boolean
}
export type DateTimeFieldUpdateOperationsInput = {
set?: Date | string
}
@@ -435,6 +461,7 @@ export type UserCreateWithoutTripsInput = {
email: string
password: string
image?: string | null
isVerified?: boolean
createdAt?: Date | string
updatedAt?: Date | string
participations?: Prisma.TripParticipantCreateNestedManyWithoutUserInput
@@ -447,6 +474,7 @@ export type UserUncheckedCreateWithoutTripsInput = {
email: string
password: string
image?: string | null
isVerified?: boolean
createdAt?: Date | string
updatedAt?: Date | string
participations?: Prisma.TripParticipantUncheckedCreateNestedManyWithoutUserInput
@@ -475,6 +503,7 @@ export type UserUpdateWithoutTripsInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
isVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
participations?: Prisma.TripParticipantUpdateManyWithoutUserNestedInput
@@ -487,6 +516,7 @@ export type UserUncheckedUpdateWithoutTripsInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
isVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
participations?: Prisma.TripParticipantUncheckedUpdateManyWithoutUserNestedInput
@@ -499,6 +529,7 @@ export type UserCreateWithoutTripReviewsInput = {
email: string
password: string
image?: string | null
isVerified?: boolean
createdAt?: Date | string
updatedAt?: Date | string
trips?: Prisma.TripCreateNestedManyWithoutOrganizerInput
@@ -511,6 +542,7 @@ export type UserUncheckedCreateWithoutTripReviewsInput = {
email: string
password: string
image?: string | null
isVerified?: boolean
createdAt?: Date | string
updatedAt?: Date | string
trips?: Prisma.TripUncheckedCreateNestedManyWithoutOrganizerInput
@@ -539,6 +571,7 @@ export type UserUpdateWithoutTripReviewsInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
isVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
trips?: Prisma.TripUpdateManyWithoutOrganizerNestedInput
@@ -551,6 +584,7 @@ export type UserUncheckedUpdateWithoutTripReviewsInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
isVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
trips?: Prisma.TripUncheckedUpdateManyWithoutOrganizerNestedInput
@@ -563,6 +597,7 @@ export type UserCreateWithoutParticipationsInput = {
email: string
password: string
image?: string | null
isVerified?: boolean
createdAt?: Date | string
updatedAt?: Date | string
trips?: Prisma.TripCreateNestedManyWithoutOrganizerInput
@@ -575,6 +610,7 @@ export type UserUncheckedCreateWithoutParticipationsInput = {
email: string
password: string
image?: string | null
isVerified?: boolean
createdAt?: Date | string
updatedAt?: Date | string
trips?: Prisma.TripUncheckedCreateNestedManyWithoutOrganizerInput
@@ -603,6 +639,7 @@ export type UserUpdateWithoutParticipationsInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
isVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
trips?: Prisma.TripUpdateManyWithoutOrganizerNestedInput
@@ -615,6 +652,7 @@ export type UserUncheckedUpdateWithoutParticipationsInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
isVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
trips?: Prisma.TripUncheckedUpdateManyWithoutOrganizerNestedInput
@@ -676,6 +714,7 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
email?: boolean
password?: boolean
image?: boolean
isVerified?: boolean
createdAt?: boolean
updatedAt?: boolean
trips?: boolean | Prisma.User$tripsArgs<ExtArgs>
@@ -690,6 +729,7 @@ export type UserSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensio
email?: boolean
password?: boolean
image?: boolean
isVerified?: boolean
createdAt?: boolean
updatedAt?: boolean
}, ExtArgs["result"]["user"]>
@@ -700,6 +740,7 @@ export type UserSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensio
email?: boolean
password?: boolean
image?: boolean
isVerified?: boolean
createdAt?: boolean
updatedAt?: boolean
}, ExtArgs["result"]["user"]>
@@ -710,11 +751,12 @@ export type UserSelectScalar = {
email?: boolean
password?: boolean
image?: boolean
isVerified?: boolean
createdAt?: boolean
updatedAt?: boolean
}
export type UserOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name" | "email" | "password" | "image" | "createdAt" | "updatedAt", ExtArgs["result"]["user"]>
export type UserOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name" | "email" | "password" | "image" | "isVerified" | "createdAt" | "updatedAt", ExtArgs["result"]["user"]>
export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
trips?: boolean | Prisma.User$tripsArgs<ExtArgs>
participations?: boolean | Prisma.User$participationsArgs<ExtArgs>
@@ -737,6 +779,10 @@ export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
email: string
password: string
image: string | null
/**
* Akun diverifikasi tim SeTrip (manual / admin) — tampil sebagai badge kepercayaan
*/
isVerified: boolean
createdAt: Date
updatedAt: Date
}, ExtArgs["result"]["user"]>
@@ -1170,6 +1216,7 @@ export interface UserFieldRefs {
readonly email: Prisma.FieldRef<"User", 'String'>
readonly password: Prisma.FieldRef<"User", 'String'>
readonly image: Prisma.FieldRef<"User", 'String'>
readonly isVerified: Prisma.FieldRef<"User", 'Boolean'>
readonly createdAt: Prisma.FieldRef<"User", 'DateTime'>
readonly updatedAt: Prisma.FieldRef<"User", 'DateTime'>
}
+12 -2
View File
@@ -173,8 +173,18 @@ export default async function ProfilePage() {
date={t.date}
endDate={t.endDate}
rightSlot={
<span className="text-neutral-400">
{p.status === "CONFIRMED" ? "Terdaftar" : p.status}
<span
className={
p.status === "PENDING"
? "font-medium text-amber-700"
: "text-neutral-400"
}
>
{p.status === "CONFIRMED"
? "Terkonfirmasi"
: p.status === "PENDING"
? "Menunggu organizer"
: p.status}
</span>
}
/>
+94 -8
View File
@@ -3,8 +3,14 @@ import { getServerSession } from "next-auth";
import Link from "next/link";
import { authOptions } from "@/lib/auth";
import { tripService } from "@/server/services/trip.service";
import { formatRupiah, formatDateRange } from "@/lib/utils";
import { trustService } from "@/server/services/trust.service";
import { formatRupiah } from "@/lib/utils";
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
import { JoinTripButton } from "@/features/trip/components/join-trip-button";
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 {
@@ -27,10 +33,21 @@ export default async function TripDetailPage({
notFound();
}
const organizerTrust = await trustService.getOrganizerTrust(
trip.organizerId
);
const activeParticipants = trip.participants.filter(
(p) => p.status !== "CANCELLED"
);
const confirmedParticipants = activeParticipants.filter(
(p) => p.status === "CONFIRMED"
);
const pendingParticipants = activeParticipants.filter(
(p) => p.status === "PENDING"
);
const participantCount = activeParticipants.length;
const confirmedCount = confirmedParticipants.length;
const spotsLeft = trip.maxParticipants - participantCount;
const fillPercent = Math.min(
(participantCount / trip.maxParticipants) * 100,
@@ -63,6 +80,10 @@ export default async function TripDetailPage({
) / 10
: null;
const paymentPendingParticipants = activeParticipants.filter(
(p) => p.markedPaidAt && !p.paymentConfirmedAt
);
return (
<div className="mx-auto max-w-3xl px-4 py-4 sm:py-8">
{/* Breadcrumb */}
@@ -123,7 +144,7 @@ export default async function TripDetailPage({
<div className="min-w-0">
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Tanggal</p>
<p className="truncate text-xs font-semibold text-neutral-800 sm:text-sm">
{formatDateRange(trip.date, trip.endDate)}
{formatTripCalendarDateRangeLong(trip.date, trip.endDate)}
</p>
</div>
</div>
@@ -153,6 +174,12 @@ export default async function TripDetailPage({
</div>
</div>
<OrganizerTrustPanel
name={trip.organizer.name}
image={trip.organizer.image}
trust={organizerTrust}
/>
{/* Participant Progress */}
<div className="rounded-xl border border-neutral-200 p-3 sm:p-4">
<div className="mb-2 flex items-center justify-between">
@@ -179,12 +206,30 @@ export default async function TripDetailPage({
/>
</div>
<p className="mt-1.5 text-[11px] text-neutral-500 sm:text-xs">
Maksimal {trip.maxParticipants} orang. Saat ini {participantCount}{" "}
mendaftar, {confirmedCount} sudah disetujui organizer.
</p>
<p className="mt-1 text-[11px] text-neutral-500 sm:text-xs">
{spotsLeft > 0
? `${spotsLeft} slot tersisa — yuk gabung!`
? `Masih ada ${spotsLeft} tempat — yuk gabung!`
: "Trip sudah penuh"}
{confirmedCount < participantCount && (
<>
{" "}
· {participantCount - confirmedCount} menunggu persetujuan
organizer
</>
)}
</p>
</div>
<TripProgramBlock
meetingPoint={trip.meetingPoint}
itinerary={trip.itinerary}
whatsIncluded={trip.whatsIncluded}
whatsExcluded={trip.whatsExcluded}
/>
{/* Description */}
{trip.description && (
<div>
@@ -197,12 +242,50 @@ export default async function TripDetailPage({
</div>
)}
{isOrganizer && pendingParticipants.length > 0 && (
<OrganizerJoinRequests
tripId={trip.id}
pending={pendingParticipants.map((p) => ({
id: p.id,
user: p.user,
markedPaidAt: p.markedPaidAt,
}))}
/>
)}
{isOrganizer && paymentPendingParticipants.length > 0 && (
<OrganizerPaymentQueue
tripId={trip.id}
items={paymentPendingParticipants.map((p) => ({
id: p.id,
user: p.user,
joinStatus:
p.status === "PENDING" ? ("PENDING" as const) : ("CONFIRMED" as const),
}))}
/>
)}
{/* Action */}
<JoinTripButton
tripId={trip.id}
isLoggedIn={!!session?.user}
isOrganizer={isOrganizer}
isJoined={!!currentParticipation}
participationStatus={
currentParticipation?.status === "PENDING" ||
currentParticipation?.status === "CONFIRMED"
? currentParticipation.status
: null
}
participantPayment={
currentParticipation
? {
markedPaidAt: currentParticipation.markedPaidAt,
paymentConfirmedAt:
currentParticipation.paymentConfirmedAt,
}
: null
}
isFull={spotsLeft <= 0}
tripStatus={trip.status}
isDeparturePast={isDeparturePast}
@@ -226,18 +309,21 @@ export default async function TripDetailPage({
}
/>
{/* Participants List */}
{/* Peserta yang sudah disetujui organizer (publik) */}
<div>
<h2 className="mb-3 text-xs font-bold text-neutral-700 sm:text-sm">
Peserta ({participantCount})
Peserta terkonfirmasi ({confirmedCount})
</h2>
{participantCount === 0 ? (
{confirmedCount === 0 ? (
<p className="text-xs text-neutral-400 sm:text-sm">
Belum ada peserta. Jadilah yang pertama! 🎒
Belum ada peserta yang dikonfirmasi.{" "}
{pendingParticipants.length > 0
? "Cek permintaan join di atas untuk menyetujui peserta."
: "Jadilah yang pertama mendaftar! 🎒"}
</p>
) : (
<div className="flex flex-wrap gap-1.5 sm:gap-2">
{activeParticipants.map((p) => (
{confirmedParticipants.map((p) => (
<div
key={p.id}
className="flex items-center gap-1.5 rounded-full bg-neutral-100 px-2.5 py-1 sm:gap-2 sm:px-3 sm:py-1.5"
+49
View File
@@ -0,0 +1,49 @@
"use server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { tripService } from "@/server/services/trip.service";
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 async function confirmParticipantPaymentAction(
tripId: string,
participantId: string
) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return { error: "Kamu harus login terlebih dahulu" };
}
try {
await tripService.confirmParticipantPayment(
tripId,
participantId,
session.user.id
);
revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips");
revalidatePath("/");
revalidatePath("/profile");
return { success: true };
} catch (err) {
return { error: (err as Error).message };
}
}
@@ -0,0 +1,102 @@
"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<string | null>(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 (
<div className="rounded-xl border border-primary-200 bg-primary-50/60 p-4 sm:p-5">
<h2 className="text-sm font-bold text-primary-950 sm:text-base">
Konfirmasi pembayaran ({items.length})
</h2>
<p className="mt-1 text-xs text-primary-900/85 sm:text-sm">
Peserta sudah menandai pembayaran. Cek rekening atau bukti transfer,
lalu konfirmasi.
</p>
{error && (
<p className="mt-3 rounded-lg bg-red-50 px-3 py-2 text-xs font-medium text-red-700 sm:text-sm">
{error}
</p>
)}
<ul className="mt-4 space-y-3">
{items.map((p) => (
<li
key={p.id}
className="flex flex-col gap-3 rounded-xl border border-primary-100 bg-white/95 p-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex min-w-0 items-center gap-3">
{p.user.image ? (
<Image
src={p.user.image}
alt=""
width={40}
height={40}
className="h-10 w-10 shrink-0 rounded-full object-cover"
/>
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary-600 text-sm font-bold text-white">
{p.user.name.charAt(0).toUpperCase()}
</div>
)}
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-neutral-800">
{p.user.name}
</p>
<p className="text-xs text-primary-800/90">
Menunggu konfirmasi pembayaran
{p.joinStatus === "PENDING" && (
<span className="text-neutral-500">
{" "}
· belum disetujui ikut trip
</span>
)}
</p>
</div>
</div>
<button
type="button"
disabled={loadingId !== null}
onClick={() => confirm(p.id)}
className="shrink-0 rounded-lg bg-primary-600 px-4 py-2 text-xs font-semibold text-white shadow-sm hover:bg-primary-700 disabled:opacity-50 sm:text-sm"
>
{loadingId === p.id ? "Memproses…" : "Konfirmasi pembayaran"}
</button>
</li>
))}
</ul>
</div>
);
}
@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { formatDateRange } from "@/lib/utils";
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
interface ProfileTripRowProps {
href: string;
@@ -28,7 +28,7 @@ export function ProfileTripRow({
<p className="truncate text-sm font-semibold text-neutral-800">{title}</p>
<p className="truncate text-xs text-neutral-500">{mountain}</p>
<p className="mt-0.5 text-[11px] text-neutral-400 sm:text-xs">
{formatDateRange(date, endDate)}
{formatTripCalendarDateRangeLong(date, endDate)}
</p>
</div>
{rightSlot && (
+67 -1
View File
@@ -18,6 +18,10 @@ export async function createTripAction(formData: FormData) {
description: formData.get("description") as string,
mountain: formData.get("mountain") 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,
endDate: (formData.get("endDate") as string) || undefined,
maxParticipants: formData.get("maxParticipants") as string,
@@ -50,8 +54,20 @@ export async function createTripAction(formData: FormData) {
}
try {
const {
meetingPoint,
itinerary,
whatsIncluded,
whatsExcluded,
...tripCore
} = result.data;
const trip = await tripService.createTrip({
...result.data,
...tripCore,
meetingPoint,
itinerary,
whatsIncluded,
whatsExcluded,
date,
endDate,
organizerId: session.user.id,
@@ -101,3 +117,53 @@ export async function cancelJoinAction(tripId: string) {
return { error: (err as Error).message };
}
}
export async function confirmParticipantAction(
tripId: string,
participantId: string
) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return { error: "Kamu harus login terlebih dahulu" };
}
try {
await tripService.confirmParticipant(
tripId,
participantId,
session.user.id
);
revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips");
revalidatePath("/");
revalidatePath("/profile");
return { success: true };
} catch (err) {
return { error: (err as Error).message };
}
}
export async function rejectParticipantAction(
tripId: string,
participantId: string
) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return { error: "Kamu harus login terlebih dahulu" };
}
try {
await tripService.rejectParticipant(
tripId,
participantId,
session.user.id
);
revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips");
revalidatePath("/");
revalidatePath("/profile");
return { success: true };
} catch (err) {
return { error: (err as Error).message };
}
}
+70 -1
View File
@@ -4,15 +4,23 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { joinTripAction, cancelJoinAction } from "@/features/trip/actions";
import { markParticipantPaidAction } from "@/features/booking/actions";
interface JoinTripButtonProps {
tripId: string;
isLoggedIn: boolean;
isOrganizer: boolean;
isJoined: boolean;
/** Status partisipasi user saat isJoined (bukan organizer) */
participationStatus?: "PENDING" | "CONFIRMED" | null;
/** Status pembayaran manual (peserta) */
participantPayment?: {
markedPaidAt: string | Date | null;
paymentConfirmedAt: string | Date | null;
} | null;
isFull: boolean;
tripStatus: string;
/** Tanggal berangkat sudah lewat (hari kalender UTC) */
/** Tanggal berangkat trip sudah lewat */
isDeparturePast?: boolean;
}
@@ -21,6 +29,8 @@ export function JoinTripButton({
isLoggedIn,
isOrganizer,
isJoined,
participationStatus,
participantPayment,
isFull,
tripStatus,
isDeparturePast,
@@ -98,6 +108,29 @@ export function JoinTripButton({
}
}
async function handleMarkPaid() {
setLoading(true);
setError("");
const result = await markParticipantPaidAction(tripId);
setLoading(false);
if (result.error) {
setError(result.error);
} else {
router.refresh();
}
}
const pay = participantPayment;
const showMarkPaid =
isJoined &&
pay &&
!pay.paymentConfirmedAt &&
!pay.markedPaidAt &&
!isDeparturePast;
const waitingPaymentConfirm =
isJoined && pay && pay.markedPaidAt && !pay.paymentConfirmedAt;
const paymentDone = isJoined && pay && pay.paymentConfirmedAt;
return (
<div>
{error && (
@@ -105,6 +138,42 @@ export function JoinTripButton({
{error}
</div>
)}
{isJoined && participationStatus === "PENDING" && (
<div className="mb-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium leading-relaxed text-amber-900">
Permintaan ikut trip kamu{" "}
<span className="font-semibold">menunggu persetujuan organizer</span>.
Kamu bisa membatalkan kapan saja sebelum disetujui.
</div>
)}
{isJoined && participationStatus === "CONFIRMED" && (
<div className="mb-3 rounded-xl border border-secondary-200 bg-secondary-50 px-4 py-3 text-sm font-medium text-secondary-900">
Kamu sudah{" "}
<span className="font-semibold">terkonfirmasi</span> sebagai peserta
trip ini.
</div>
)}
{waitingPaymentConfirm && (
<div className="mb-3 rounded-xl border border-primary-200 bg-primary-50 px-4 py-3 text-sm font-medium leading-relaxed text-primary-950">
Kamu sudah menandai <span className="font-semibold">sudah bayar</span>.
Tunggu organizer mengonfirmasi pembayaran.
</div>
)}
{paymentDone && (
<div className="mb-3 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-900">
Pembayaran kamu sudah{" "}
<span className="font-semibold">dikonfirmasi organizer</span>.
</div>
)}
{showMarkPaid && (
<button
type="button"
onClick={handleMarkPaid}
disabled={loading}
className="mb-3 w-full rounded-xl border-2 border-primary-500 bg-white py-3 text-sm font-bold text-primary-700 transition-colors hover:bg-primary-50 disabled:opacity-50"
>
{loading ? "Memproses..." : "Saya sudah bayar"}
</button>
)}
{isJoined ? (
<button
onClick={handleCancel}
@@ -0,0 +1,119 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import {
confirmParticipantAction,
rejectParticipantAction,
} from "@/features/trip/actions";
export interface PendingJoinRequest {
id: string;
user: { name: string; image: string | null };
/** Peserta sudah menekan &quot;Saya sudah bayar&quot; */
markedPaidAt?: string | Date | null;
}
interface OrganizerJoinRequestsProps {
tripId: string;
pending: PendingJoinRequest[];
}
export function OrganizerJoinRequests({
tripId,
pending,
}: OrganizerJoinRequestsProps) {
const router = useRouter();
const [loadingId, setLoadingId] = useState<string | null>(null);
const [error, setError] = useState("");
async function run(
participantId: string,
action: "confirm" | "reject"
) {
setLoadingId(participantId);
setError("");
const result =
action === "confirm"
? await confirmParticipantAction(tripId, participantId)
: await rejectParticipantAction(tripId, participantId);
setLoadingId(null);
if (result.error) {
setError(result.error);
return;
}
router.refresh();
}
return (
<div className="rounded-xl border border-amber-200 bg-amber-50/70 p-4 sm:p-5">
<h2 className="text-sm font-bold text-amber-950 sm:text-base">
Permintaan join ({pending.length})
</h2>
<p className="mt-1 text-xs text-amber-900/80 sm:text-sm">
Setujui atau tolak siapa yang boleh ikut trip ini.
</p>
{error && (
<p className="mt-3 rounded-lg bg-red-50 px-3 py-2 text-xs font-medium text-red-700 sm:text-sm">
{error}
</p>
)}
<ul className="mt-4 space-y-3">
{pending.map((p) => (
<li
key={p.id}
className="flex flex-col gap-3 rounded-xl border border-amber-100 bg-white/90 p-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex min-w-0 items-center gap-3">
{p.user.image ? (
<Image
src={p.user.image}
alt=""
width={40}
height={40}
className="h-10 w-10 shrink-0 rounded-full object-cover"
/>
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary-600 text-sm font-bold text-white">
{p.user.name.charAt(0).toUpperCase()}
</div>
)}
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-neutral-800">
{p.user.name}
</p>
<div className="flex flex-wrap items-center gap-1.5">
<p className="text-xs text-amber-800/80">Menunggu persetujuan</p>
{p.markedPaidAt ? (
<span className="rounded-full bg-primary-100 px-2 py-0.5 text-[10px] font-bold text-primary-800">
Sudah tandai bayar
</span>
) : null}
</div>
</div>
</div>
<div className="flex shrink-0 gap-2">
<button
type="button"
disabled={loadingId !== null}
onClick={() => run(p.id, "reject")}
className="rounded-lg border border-neutral-200 bg-white px-3 py-2 text-xs font-semibold text-neutral-600 hover:bg-neutral-50 disabled:opacity-50 sm:text-sm"
>
{loadingId === p.id ? "…" : "Tolak"}
</button>
<button
type="button"
disabled={loadingId !== null}
onClick={() => run(p.id, "confirm")}
className="rounded-lg bg-primary-600 px-3 py-2 text-xs font-semibold text-white shadow-sm hover:bg-primary-700 disabled:opacity-50 sm:text-sm"
>
{loadingId === p.id ? "Memproses…" : "Setujui"}
</button>
</div>
</li>
))}
</ul>
</div>
);
}
@@ -0,0 +1,79 @@
import Image from "next/image";
import type { OrganizerTrust } from "@/server/services/trust.service";
interface OrganizerTrustPanelProps {
name: string;
image: string | null;
trust: OrganizerTrust;
}
export function OrganizerTrustPanel({
name,
image,
trust,
}: OrganizerTrustPanelProps) {
return (
<div className="rounded-xl border border-neutral-200 bg-linear-to-br from-white to-neutral-50 p-4 sm:p-5">
<h2 className="mb-3 text-xs font-bold text-neutral-700 sm:text-sm">
Organizer & kepercayaan
</h2>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
<div className="flex items-center gap-3">
{image ? (
<Image
src={image}
alt=""
width={48}
height={48}
className="h-12 w-12 shrink-0 rounded-full object-cover ring-2 ring-white shadow"
/>
) : (
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-primary-600 text-lg font-bold text-white shadow">
{name.charAt(0).toUpperCase()}
</div>
)}
<div className="min-w-0">
<p className="truncate text-sm font-bold text-neutral-900 sm:text-base">
{name}
</p>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{trust.isVerified && (
<span className="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-blue-800 sm:text-xs">
Verified
</span>
)}
{trust.isTripLeader && (
<span className="inline-flex items-center rounded-full bg-secondary-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-secondary-900 sm:text-xs">
Trip leader
</span>
)}
</div>
</div>
</div>
<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">
<p className="text-[10px] font-medium text-neutral-500 sm:text-xs">
Trip dibuat
</p>
<p className="text-lg font-bold text-neutral-800">
{trust.tripsCreated}
</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">
Rating organizer
</p>
<p className="text-lg font-bold text-amber-700">
{trust.avgRating != null ? `${trust.avgRating}` : "—"}
</p>
{trust.reviewCount > 0 && (
<p className="text-[10px] text-neutral-400">
dari {trust.reviewCount} ulasan trip
</p>
)}
</div>
</div>
</div>
</div>
);
}
+3 -2
View File
@@ -1,6 +1,7 @@
import Image from "next/image";
import Link from "next/link";
import { formatRupiah, formatDateRange } from "@/lib/utils";
import { formatRupiah } from "@/lib/utils";
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
interface TripCardProps {
id: string;
@@ -80,7 +81,7 @@ export function TripCard({
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-secondary-500">📅</span>{" "}
{formatDateRange(date, endDate)}
{formatTripCalendarDateRangeLong(date, endDate)}
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-secondary-500">👤</span>{" "}
+4 -7
View File
@@ -4,6 +4,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import { formatLocalCalendarYmd } from "@/lib/trip-dates";
export function TripFilter() {
const router = useRouter();
@@ -35,8 +36,8 @@ export function TripFilter() {
e.preventDefault();
const params = new URLSearchParams();
if (query.trim()) params.set("q", query.trim());
if (startDate) params.set("from", startDate.toISOString().split("T")[0]);
if (endDate) params.set("to", endDate.toISOString().split("T")[0]);
if (startDate) params.set("from", formatLocalCalendarYmd(startDate));
if (endDate) params.set("to", formatLocalCalendarYmd(endDate));
const qs = params.toString();
router.push(`/trips${qs ? `?${qs}` : ""}`);
@@ -90,12 +91,8 @@ export function TripFilter() {
{/* Date range */}
<div className="sm:w-64">
<label className="mb-1.5 block text-xs font-medium text-neutral-500">
Rentang tanggal (UTC)
Tanggal
</label>
<p className="mb-1 text-[10px] leading-snug text-neutral-400 sm:text-xs">
Menampilkan trip yang jadwalnya overlap rentang ini: multi hari pakai
tanggal pulang; satu hari pakai tanggal berangkat saja.
</p>
<div className="relative">
<span className="absolute left-3 top-1/2 z-10 -translate-y-1/2 text-neutral-400">
<svg
@@ -0,0 +1,72 @@
interface TripProgramBlockProps {
meetingPoint: string | null;
itinerary: string | null;
whatsIncluded: string | null;
whatsExcluded: string | null;
}
export function TripProgramBlock({
meetingPoint,
itinerary,
whatsIncluded,
whatsExcluded,
}: TripProgramBlockProps) {
const hasAny =
meetingPoint || itinerary || whatsIncluded || whatsExcluded;
if (!hasAny) return null;
return (
<div className="space-y-4 rounded-xl border border-neutral-200 bg-neutral-50/50 p-4 sm:p-5">
<h2 className="text-xs font-bold text-neutral-800 sm:text-sm">
Detail perjalanan
</h2>
{meetingPoint && (
<div>
<h3 className="mb-1 text-[11px] font-bold uppercase tracking-wide text-primary-700 sm:text-xs">
Meeting point
</h3>
<p className="whitespace-pre-wrap text-xs leading-relaxed text-neutral-700 sm:text-sm">
{meetingPoint}
</p>
</div>
)}
{itinerary && (
<div>
<h3 className="mb-1 text-[11px] font-bold uppercase tracking-wide text-primary-700 sm:text-xs">
Itinerary
</h3>
<p className="whitespace-pre-wrap text-xs leading-relaxed text-neutral-700 sm:text-sm">
{itinerary}
</p>
</div>
)}
{(whatsIncluded || whatsExcluded) && (
<div className="grid gap-4 sm:grid-cols-2">
{whatsIncluded && (
<div className="rounded-lg border border-secondary-200 bg-white p-3">
<h3 className="mb-2 text-[11px] font-bold uppercase tracking-wide text-secondary-800 sm:text-xs">
Termasuk
</h3>
<p className="whitespace-pre-wrap text-xs leading-relaxed text-neutral-700 sm:text-sm">
{whatsIncluded}
</p>
</div>
)}
{whatsExcluded && (
<div className="rounded-lg border border-neutral-200 bg-white p-3">
<h3 className="mb-2 text-[11px] font-bold uppercase tracking-wide text-neutral-600 sm:text-xs">
Tidak termasuk
</h3>
<p className="whitespace-pre-wrap text-xs leading-relaxed text-neutral-700 sm:text-sm">
{whatsExcluded}
</p>
</div>
)}
</div>
)}
</div>
);
}
+56
View File
@@ -84,6 +84,62 @@ export const createTripSchema = z
LIMITS.MAX_PRICE_IDR,
`Harga maksimal Rp ${LIMITS.MAX_PRICE_IDR.toLocaleString("id-ID")}`
),
meetingPoint: z.preprocess(
(val) => {
if (val == null) return undefined;
const s = String(val).trim();
return s === "" ? undefined : s;
},
z
.string()
.max(
LIMITS.MAX_MEETING_POINT_LENGTH,
`Meeting point maksimal ${LIMITS.MAX_MEETING_POINT_LENGTH} karakter`
)
.optional()
),
itinerary: z.preprocess(
(val) => {
if (val == null) return undefined;
const s = String(val).trim();
return s === "" ? undefined : s;
},
z
.string()
.max(
LIMITS.MAX_TRIP_ITINERARY_LENGTH,
`Itinerary maksimal ${LIMITS.MAX_TRIP_ITINERARY_LENGTH} karakter`
)
.optional()
),
whatsIncluded: z.preprocess(
(val) => {
if (val == null) return undefined;
const s = String(val).trim();
return s === "" ? undefined : s;
},
z
.string()
.max(
LIMITS.MAX_TRIP_BULLET_SECTION_LENGTH,
`Bagian 'Termasuk' maksimal ${LIMITS.MAX_TRIP_BULLET_SECTION_LENGTH} karakter`
)
.optional()
),
whatsExcluded: z.preprocess(
(val) => {
if (val == null) return undefined;
const s = String(val).trim();
return s === "" ? undefined : s;
},
z
.string()
.max(
LIMITS.MAX_TRIP_BULLET_SECTION_LENGTH,
`Bagian 'Tidak termasuk' maksimal ${LIMITS.MAX_TRIP_BULLET_SECTION_LENGTH} karakter`
)
.optional()
),
})
.superRefine((data, ctx) => {
const dep = tripStoredInstantFromYmd(data.date);
+4
View File
@@ -9,6 +9,10 @@ export const LIMITS = {
MAX_MOUNTAIN_LENGTH: 100,
MAX_LOCATION_LENGTH: 120,
MAX_DESCRIPTION_LENGTH: 5000,
/** Meeting point & tiap blok include/exclude */
MAX_MEETING_POINT_LENGTH: 500,
MAX_TRIP_ITINERARY_LENGTH: 8000,
MAX_TRIP_BULLET_SECTION_LENGTH: 4000,
MAX_REVIEW_COMMENT: 500,
MAX_IMAGE_URLS: 5,
MAX_URL_LENGTH: 2048,
+27
View File
@@ -81,3 +81,30 @@ export function isPastTripLastDayForReview(
);
return Date.now() > endMs;
}
/**
* Tampilkan tanggal trip untuk UI: pakai **kalender UTC** (sama dengan
* `tripStoredInstantFromYmd` & filter Open Trip), supaya tidak bergeser
* karena timezone runtime (server vs browser).
*/
export function formatTripCalendarDateLong(
d: Date | string,
locale = "id-ID"
): string {
const date = typeof d === "string" ? new Date(d) : d;
return new Intl.DateTimeFormat(locale, {
dateStyle: "long",
timeZone: "UTC",
}).format(date);
}
export function formatTripCalendarDateRangeLong(
start: Date | string,
end?: Date | string | null,
locale = "id-ID"
): string {
const startStr = formatTripCalendarDateLong(start, locale);
if (!end) return startStr;
const endStr = formatTripCalendarDateLong(end, locale);
return `${startStr}${endStr}`;
}
+2
View File
@@ -0,0 +1,2 @@
/** Minimal trip sebagai organizer untuk badge "Trip leader" (heuristik MVP). */
export const TRIP_LEADER_MIN_TRIPS = 2;
@@ -0,0 +1,12 @@
-- AlterTable
ALTER TABLE "Trip" ADD COLUMN "itinerary" TEXT,
ADD COLUMN "meetingPoint" TEXT,
ADD COLUMN "whatsExcluded" TEXT,
ADD COLUMN "whatsIncluded" TEXT;
-- AlterTable
ALTER TABLE "TripParticipant" ADD COLUMN "markedPaidAt" TIMESTAMP(3),
ADD COLUMN "paymentConfirmedAt" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "User" ADD COLUMN "isVerified" BOOLEAN NOT NULL DEFAULT false;
+21 -7
View File
@@ -8,13 +8,15 @@ datasource db {
}
model User {
id String @id @default(cuid())
name String
email String @unique
password String
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
name String
email String @unique
password String
image String?
/// Akun diverifikasi tim SeTrip (manual / admin) — tampil sebagai badge kepercayaan
isVerified Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
trips Trip[]
participations TripParticipant[]
@@ -27,6 +29,14 @@ model Trip {
description String?
mountain String
location String
/// Titik kumpul / meeting point (teks bebas)
meetingPoint String?
/// Itinerary hari per hari (teks bebas, bullet OK)
itinerary String?
/// Yang termasuk harga (teks bebas)
whatsIncluded String?
/// Yang tidak termasuk (teks bebas)
whatsExcluded String?
date DateTime
endDate DateTime?
maxParticipants Int
@@ -73,6 +83,10 @@ model TripParticipant {
id String @id @default(cuid())
status ParticipantStatus @default(PENDING)
createdAt DateTime @default(now())
/// Peserta menekan "Saya sudah bayar" (pembayaran manual)
markedPaidAt DateTime?
/// Organizer mengonfirmasi uang sudah masuk
paymentConfirmedAt DateTime?
tripId String
trip Trip @relation(fields: [tripId], references: [id])
+22 -6
View File
@@ -28,6 +28,7 @@ async function main() {
name: "Dede Inoen",
email: "dede.inoen@setrip.id",
password,
isVerified: true,
},
});
@@ -36,6 +37,7 @@ async function main() {
name: "Panji Petualang",
email: "panji@setrip.id",
password,
isVerified: true,
},
});
@@ -111,13 +113,27 @@ async function main() {
title: "Open Trip Papandayan Weekend",
description: `Pendakian santai ke Gunung Papandayan, cocok untuk pemula!
📍 Meeting Point: Alun-alun Garut, 05:00 WIB
🎒 Fasilitas: Transport PP, guide, tenda, makan 3x
⚠️ Bawa: Sleeping bag, jaket, headlamp, air 2L
⚠️ Bawa: Sleeping bag, jaket, headlamp, air 2L`,
meetingPoint:
"Alun-alun Garut (depan pendopo), Sabtu 05:00 WIB — detail grup WA.",
itinerary: `Sabtu
• 05:00 Meeting & briefing
• 07:00 Berangkat menuju basecamp
• 12:00 Makan siang trail
• 15:00 Camp area Pondok Salada
Itinerary:
- Sabtu: Berangkat → Basecamp → Summit → Camp
- Minggu: Sunrise → Turun → Pulang`,
Minggu
• 04:00 Summit attack
• 08:00 Sarap & packing
• 11:00 Turun
• 16:00 Estimasi kembali ke Garut`,
whatsIncluded: `• Transport PP Garutbasecamp
• Guide lokal
• Tenda tim (kapasitas sesuai muatan)
• Konsumsi: makan 3x + snack`,
whatsExcluded: `• Tiket masuk TNGGP
• Sleeping bag & matras pribadi
• Asuransi perjalanan`,
mountain: "Gunung Papandayan",
location: "Garut, Jawa Barat",
date: utc(2026, 3, 23, 8, 0),
+77 -3
View File
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/prisma";
import type { ParticipantStatus } from "@/app/generated/prisma/client";
export const participantRepo = {
async findByTripAndUser(tripId: string, userId: string) {
@@ -7,9 +8,31 @@ export const participantRepo = {
});
},
async findById(id: string) {
return prisma.tripParticipant.findUnique({ where: { id } });
},
async create(tripId: string, userId: string) {
return prisma.tripParticipant.create({
data: { tripId, userId, status: "CONFIRMED" },
data: { tripId, userId, status: "PENDING" },
});
},
async setStatus(id: string, status: ParticipantStatus) {
return prisma.tripParticipant.update({
where: { id },
data: { status },
});
},
async setStatusAndClearPayment(id: string, status: ParticipantStatus) {
return prisma.tripParticipant.update({
where: { id },
data: {
status,
markedPaidAt: null,
paymentConfirmedAt: null,
},
});
},
@@ -22,14 +45,65 @@ export const participantRepo = {
async cancel(tripId: string, userId: string) {
return prisma.tripParticipant.update({
where: { tripId_userId: { tripId, userId } },
data: { status: "CANCELLED" },
data: {
status: "CANCELLED",
markedPaidAt: null,
paymentConfirmedAt: null,
},
});
},
async reactivate(tripId: string, userId: string) {
return prisma.tripParticipant.update({
where: { tripId_userId: { tripId, userId } },
data: { status: "CONFIRMED" },
data: {
status: "PENDING",
markedPaidAt: null,
paymentConfirmedAt: null,
},
});
},
async markPaidByUser(tripId: string, userId: string) {
return prisma.tripParticipant.update({
where: { tripId_userId: { tripId, userId } },
data: { markedPaidAt: new Date() },
});
},
/**
* Satu baris update atomik — aman dari double-submit / race saat tandai bayar.
*/
async tryMarkPaidByUser(tripId: string, userId: string) {
return prisma.tripParticipant.updateMany({
where: {
tripId,
userId,
status: { not: "CANCELLED" },
markedPaidAt: null,
paymentConfirmedAt: null,
},
data: { markedPaidAt: new Date() },
});
},
async confirmPaymentByOrganizer(participantId: string) {
return prisma.tripParticipant.update({
where: { id: participantId },
data: { paymentConfirmedAt: new Date() },
});
},
/** Konfirmasi pembayaran hanya jika masih eligible — idempotent terhadap double klik. */
async tryConfirmPaymentByOrganizer(participantId: string) {
return prisma.tripParticipant.updateMany({
where: {
id: participantId,
markedPaidAt: { not: null },
paymentConfirmedAt: null,
status: { not: "CANCELLED" },
},
data: { paymentConfirmedAt: new Date() },
});
},
+9 -1
View File
@@ -92,7 +92,15 @@ export const tripRepo = {
return prisma.trip.findUnique({
where: { id },
include: {
organizer: { select: { id: true, name: true, email: true, image: true } },
organizer: {
select: {
id: true,
name: true,
email: true,
image: true,
isVerified: true,
},
},
images: { orderBy: { order: "asc" } },
participants: {
include: { user: { select: { id: true, name: true, image: true } } },
+292 -53
View File
@@ -1,13 +1,30 @@
import { Prisma } from "@/app/generated/prisma/client";
import { prisma } from "@/lib/prisma";
import { tripRepo } from "@/server/repositories/trip.repo";
import { participantRepo } from "@/server/repositories/participant.repo";
import { LIMITS } from "@/lib/limits";
import { utcStartOfDay, isTripDepartureDayPast } from "@/lib/trip-dates";
const SERIAL_TX_ATTEMPTS = 6;
function isSerializationConflict(err: unknown): boolean {
return (
typeof err === "object" &&
err !== null &&
"code" in err &&
(err as { code: string }).code === "P2034"
);
}
interface CreateTripInput {
title: string;
description?: string;
mountain: string;
location: string;
meetingPoint?: string;
itinerary?: string;
whatsIncluded?: string;
whatsExcluded?: string;
date: Date;
endDate?: Date;
maxParticipants: number;
@@ -34,17 +51,6 @@ export const tripService = {
},
async createTrip(input: CreateTripInput) {
const since = utcStartOfDay(new Date());
const todayCount = await tripRepo.countByOrganizerSince(
input.organizerId,
since
);
if (todayCount >= LIMITS.MAX_TRIPS_PER_ORGANIZER_PER_DAY) {
throw new Error(
`Batas harian: maksimal ${LIMITS.MAX_TRIPS_PER_ORGANIZER_PER_DAY} trip per hari (UTC). Coba lagi besok.`
);
}
if (isTripDepartureDayPast(input.date)) {
throw new Error("Tanggal berangkat tidak boleh di masa lalu");
}
@@ -53,68 +59,156 @@ export const tripService = {
throw new Error("Tanggal pulang tidak boleh sebelum tanggal berangkat");
}
const since = utcStartOfDay(new Date());
const images = input.imageUrls?.length
? {
create: input.imageUrls.map((url, i) => ({ url, order: i })),
}
: undefined;
return tripRepo.create({
const tripData = {
title: input.title,
description: input.description,
mountain: input.mountain,
location: input.location,
meetingPoint: input.meetingPoint,
itinerary: input.itinerary,
whatsIncluded: input.whatsIncluded,
whatsExcluded: input.whatsExcluded,
date: input.date,
endDate: input.endDate,
maxParticipants: input.maxParticipants,
price: input.price,
organizer: { connect: { id: input.organizerId } },
images,
});
} satisfies Prisma.TripCreateInput;
let lastErr: unknown;
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
try {
return await prisma.$transaction(
async (tx) => {
const todayCount = await tx.trip.count({
where: {
organizerId: input.organizerId,
createdAt: { gte: since },
},
});
if (todayCount >= LIMITS.MAX_TRIPS_PER_ORGANIZER_PER_DAY) {
throw new Error(
`Batas harian: maksimal ${LIMITS.MAX_TRIPS_PER_ORGANIZER_PER_DAY} trip per hari. Coba lagi besok.`
);
}
return tx.trip.create({ data: tripData });
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
maxWait: 5000,
timeout: 15000,
}
);
} catch (e) {
lastErr = e;
if (isSerializationConflict(e) && attempt < SERIAL_TX_ATTEMPTS - 1) {
continue;
}
throw e;
}
}
throw lastErr instanceof Error
? lastErr
: new Error("Gagal membuat trip. Coba lagi sebentar.");
},
async joinTrip(tripId: string, userId: string) {
const trip = await tripRepo.findById(tripId);
if (!trip) {
throw new Error("Trip tidak ditemukan");
let lastErr: unknown;
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
try {
return await prisma.$transaction(
async (tx) => {
const trip = await tx.trip.findUnique({
where: { id: tripId },
select: {
id: true,
status: true,
date: true,
organizerId: true,
maxParticipants: true,
},
});
if (!trip) {
throw new Error("Trip tidak ditemukan");
}
if (trip.status !== "OPEN") {
throw new Error("Trip tidak tersedia untuk pendaftaran");
}
if (isTripDepartureDayPast(trip.date)) {
throw new Error(
"Trip sudah melewati tanggal berangkat, tidak bisa mendaftar"
);
}
if (trip.organizerId === userId) {
throw new Error("Organizer tidak bisa join trip sendiri");
}
const existing = await tx.tripParticipant.findUnique({
where: { tripId_userId: { tripId, userId } },
});
if (existing && existing.status !== "CANCELLED") {
throw new Error("Kamu sudah terdaftar di trip ini");
}
const participantCount = await tx.tripParticipant.count({
where: { tripId, status: { not: "CANCELLED" } },
});
if (participantCount >= trip.maxParticipants) {
throw new Error("Trip sudah penuh");
}
const participant =
existing?.status === "CANCELLED"
? await tx.tripParticipant.update({
where: { tripId_userId: { tripId, userId } },
data: {
status: "PENDING",
markedPaidAt: null,
paymentConfirmedAt: null,
},
})
: await tx.tripParticipant.create({
data: { tripId, userId, status: "PENDING" },
});
const newCount = await tx.tripParticipant.count({
where: { tripId, status: { not: "CANCELLED" } },
});
if (newCount >= trip.maxParticipants) {
await tx.trip.update({
where: { id: tripId },
data: { status: "FULL" },
});
}
return participant;
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
maxWait: 5000,
timeout: 15000,
}
);
} catch (e) {
lastErr = e;
if (isSerializationConflict(e) && attempt < SERIAL_TX_ATTEMPTS - 1) {
continue;
}
throw e;
}
}
if (trip.status !== "OPEN") {
throw new Error("Trip tidak tersedia untuk pendaftaran");
}
if (isTripDepartureDayPast(trip.date)) {
throw new Error(
"Trip sudah melewati tanggal berangkat, tidak bisa mendaftar"
);
}
if (trip.organizerId === userId) {
throw new Error("Organizer tidak bisa join trip sendiri");
}
const existing = await participantRepo.findByTripAndUser(tripId, userId);
if (existing && existing.status !== "CANCELLED") {
throw new Error("Kamu sudah terdaftar di trip ini");
}
const participantCount = await participantRepo.countByTrip(tripId);
if (participantCount >= trip.maxParticipants) {
await tripRepo.updateStatus(tripId, "FULL");
throw new Error("Trip sudah penuh");
}
const participant =
existing?.status === "CANCELLED"
? await participantRepo.reactivate(tripId, userId)
: await participantRepo.create(tripId, userId);
const newCount = await participantRepo.countByTrip(tripId);
if (newCount >= trip.maxParticipants) {
await tripRepo.updateStatus(tripId, "FULL");
}
return participant;
throw lastErr instanceof Error
? lastErr
: new Error("Pendaftaran sedang ramai. Coba lagi sebentar.");
},
async cancelJoin(tripId: string, userId: string) {
@@ -145,4 +239,149 @@ export const tripService = {
return result;
},
async confirmParticipant(
tripId: string,
participantId: string,
organizerId: string
) {
const trip = await tripRepo.findById(tripId);
if (!trip) {
throw new Error("Trip tidak ditemukan");
}
if (trip.organizerId !== organizerId) {
throw new Error("Hanya organizer trip ini yang bisa mengonfirmasi peserta");
}
const participant = await participantRepo.findById(participantId);
if (!participant || participant.tripId !== tripId) {
throw new Error("Peserta tidak ditemukan");
}
if (participant.status !== "PENDING") {
throw new Error("Peserta ini tidak dalam status menunggu persetujuan");
}
return participantRepo.setStatus(participantId, "CONFIRMED");
},
async rejectParticipant(
tripId: string,
participantId: string,
organizerId: string
) {
const trip = await tripRepo.findById(tripId);
if (!trip) {
throw new Error("Trip tidak ditemukan");
}
if (trip.organizerId !== organizerId) {
throw new Error("Hanya organizer trip ini yang bisa menolak permintaan bergabung");
}
const participant = await participantRepo.findById(participantId);
if (!participant || participant.tripId !== tripId) {
throw new Error("Peserta tidak ditemukan");
}
if (participant.status !== "PENDING") {
throw new Error("Hanya permintaan yang masih menunggu yang bisa ditolak");
}
await participantRepo.setStatusAndClearPayment(
participantId,
"CANCELLED"
);
if (trip.status === "FULL") {
const count = await participantRepo.countByTrip(tripId);
if (count < trip.maxParticipants) {
await tripRepo.updateStatus(tripId, "OPEN");
}
}
return { ok: true as const };
},
async markParticipantPayment(tripId: string, userId: string) {
const trip = await tripRepo.findById(tripId);
if (!trip) {
throw new Error("Trip tidak ditemukan");
}
if (isTripDepartureDayPast(trip.date)) {
throw new Error("Trip sudah lewat — pembayaran tidak perlu ditandai");
}
const p = await participantRepo.findByTripAndUser(tripId, userId);
if (!p || p.status === "CANCELLED") {
throw new Error("Kamu tidak terdaftar di trip ini");
}
if (p.paymentConfirmedAt) {
throw new Error("Pembayaran kamu sudah dikonfirmasi organizer");
}
if (p.markedPaidAt) {
throw new Error("Kamu sudah menandai sudah bayar — tunggu konfirmasi organizer");
}
const updated = await participantRepo.tryMarkPaidByUser(tripId, userId);
if (updated.count === 0) {
const again = await participantRepo.findByTripAndUser(tripId, userId);
if (!again || again.status === "CANCELLED") {
throw new Error("Kamu tidak terdaftar di trip ini");
}
if (again.paymentConfirmedAt) {
throw new Error("Pembayaran kamu sudah dikonfirmasi organizer");
}
if (again.markedPaidAt) {
throw new Error(
"Kamu sudah menandai sudah bayar — tunggu konfirmasi organizer"
);
}
throw new Error("Tidak bisa menandai pembayaran. Coba lagi sebentar.");
}
const row = await participantRepo.findByTripAndUser(tripId, userId);
if (!row) {
throw new Error("Data peserta tidak ditemukan setelah update");
}
return row;
},
async confirmParticipantPayment(
tripId: string,
participantId: string,
organizerId: string
) {
const trip = await tripRepo.findById(tripId);
if (!trip) {
throw new Error("Trip tidak ditemukan");
}
if (trip.organizerId !== organizerId) {
throw new Error("Hanya organizer yang bisa mengonfirmasi pembayaran");
}
const participant = await participantRepo.findById(participantId);
if (!participant || participant.tripId !== tripId) {
throw new Error("Peserta tidak ditemukan");
}
if (participant.status === "CANCELLED") {
throw new Error("Peserta sudah tidak aktif");
}
if (!participant.markedPaidAt) {
throw new Error("Peserta belum menandai sudah membayar");
}
if (participant.paymentConfirmedAt) {
throw new Error("Pembayaran peserta ini sudah dikonfirmasi");
}
const updated = await participantRepo.tryConfirmPaymentByOrganizer(
participantId
);
if (updated.count === 0) {
throw new Error(
"Konfirmasi tidak diproses — mungkin sudah dikonfirmasi atau pembayaran belum ditandai peserta."
);
}
return participantRepo.findById(participantId);
},
};
+39
View File
@@ -0,0 +1,39 @@
import { prisma } from "@/lib/prisma";
import { TRIP_LEADER_MIN_TRIPS } from "@/lib/trust";
export type OrganizerTrust = {
isVerified: boolean;
tripsCreated: number;
avgRating: number | null;
reviewCount: number;
isTripLeader: boolean;
};
export const trustService = {
async getOrganizerTrust(organizerId: string): Promise<OrganizerTrust> {
const [user, tripsCreated, reviewAgg] = await Promise.all([
prisma.user.findUnique({
where: { id: organizerId },
select: { isVerified: true },
}),
prisma.trip.count({ where: { organizerId } }),
prisma.tripReview.aggregate({
where: {
trip: { organizerId },
},
_avg: { rating: true },
_count: { _all: true },
}),
]);
const avg = reviewAgg._avg.rating;
return {
isVerified: user?.isVerified ?? false,
tripsCreated,
avgRating:
avg != null ? Math.round(Number(avg) * 10) / 10 : null,
reviewCount: reviewAgg._count._all,
isTripLeader: tripsCreated >= TRIP_LEADER_MIN_TRIPS,
};
},
};