generator client { provider = "prisma-client" output = "../app/generated/prisma" } datasource db { provider = "postgresql" } model User { id String @id @default(cuid()) name String email String @unique /// Hash bcrypt. Null untuk user yang sign-in via OAuth (mis. Google). password String? image String? /// Diisi PrismaAdapter NextAuth saat email diverifikasi provider OAuth (Google selalu sudah verified). emailVerified DateTime? /// Apakah user telah menyetujui Syarat & Ketentuan dan Kebijakan Privasi acceptedTermsAndPrivacy Boolean @default(false) /// Waktu user menyetujui Syarat & Ketentuan dan Kebijakan Privasi acceptedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt accounts Account[] trips Trip[] participations TripParticipant[] tripReviews TripReview[] bookings Booking[] organizerVerification OrganizerVerification? @relation("OrganizerVerificationOwner") reviewedVerifications OrganizerVerification[] @relation("OrganizerVerificationReviewer") profile UserProfile? } /// Profil sosial publik. Berisi info yang user pilih untuk dibagikan ke peserta lain /// (bio, kota, minat, vibe). Tidak menyimpan data sensitif — KYC tetap di OrganizerVerification. model UserProfile { id String @id @default(cuid()) userId String @unique user User @relation(fields: [userId], references: [id], onDelete: Cascade) /// Bio singkat, teks bebas bio String? /// Kota domisili (teks bebas, mis. "Bandung", "Jakarta Selatan") city String? /// Tag minat aktivitas (mis. ["hiking", "fotografi", "yoga"]) interests String[] @default([]) /// Username Instagram (tanpa @, opsional) instagram String? /// Gaya jalan / energi user — dipakai untuk matching teman dengan ritme serupa. vibe Vibe? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } enum Vibe { CHILL BALANCED HARDCORE } /// Tabel link akun OAuth pihak ketiga (Google, dst). Diisi oleh PrismaAdapter NextAuth. /// Session tidak pakai DB — kita pakai JWT, jadi Session/VerificationToken tidak perlu. model Account { id String @id @default(cuid()) userId String type String provider String providerAccountId String refresh_token String? access_token String? expires_at Int? token_type String? scope String? id_token String? session_state String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([provider, providerAccountId]) } model OrganizerVerification { id String @id @default(cuid()) userId String @unique user User @relation("OrganizerVerificationOwner", fields: [userId], references: [id], onDelete: Cascade) /// Nama lengkap sesuai KTP fullName String /// NIK terenkripsi (AES-256-GCM, base64). Plaintext tidak disimpan. nikEncrypted String /// HMAC-SHA256(NIK + pepper) untuk uniqueness lookup tanpa membuka plaintext. nikHash String @unique birthDate DateTime address String /// Storage key foto KTP (mis. `ktp/.jpg`). File disimpan terenkripsi di luar /public. ktpImageKey String /// Storage key foto liveness — user memegang kertas bertuliskan "SETRIP". /// (Sebelumnya: selfie memegang KTP. Diganti supaya user tidak perlu memajang KTP dua kali.) livenessKey String bankName String bankAccountNumber String bankAccountName String status VerificationStatus @default(PENDING) rejectionReason String? reviewedAt DateTime? reviewedById String? reviewedBy User? @relation("OrganizerVerificationReviewer", fields: [reviewedById], references: [id]) verifiedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } enum VerificationStatus { PENDING APPROVED REJECTED } model Trip { id String @id @default(cuid()) title String description String? /// Kategori aktivitas. category ActivityCategory @default(HIKING) /// Destinasi/subjek aktivitas — interpretasinya tergantung kategori (gunung untuk HIKING, spot untuk DIVING, venue untuk CONCERT, tema untuk WORKSHOP, dst). destination 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 price Int /// Ritme/energi trip — dipakai untuk matching dengan vibe user. vibe Vibe? status TripStatus @default(OPEN) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt organizerId String organizer User @relation(fields: [organizerId], references: [id]) participants TripParticipant[] images TripImage[] reviews TripReview[] bookings Booking[] @@index([category, status, date]) @@index([vibe, status, date]) } model TripReview { id String @id @default(cuid()) rating Int comment String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt tripId String trip Trip @relation(fields: [tripId], references: [id], onDelete: Cascade) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([tripId, userId]) } model TripImage { id String @id @default(cuid()) url String caption String? order Int @default(0) tripId String trip Trip @relation(fields: [tripId], references: [id], onDelete: Cascade) } model TripParticipant { id String @id @default(cuid()) status ParticipantStatus @default(PENDING) createdAt DateTime @default(now()) /// @deprecated — sumber kebenaran pindah ke Booking/Payment. Tetap di-update /// untuk backward-compat selama transisi UI lama. Akan dihapus PR berikutnya. markedPaidAt DateTime? /// @deprecated — sumber kebenaran pindah ke Booking/Payment. Tetap di-update /// untuk backward-compat selama transisi UI lama. Akan dihapus PR berikutnya. paymentConfirmedAt DateTime? tripId String trip Trip @relation(fields: [tripId], references: [id]) userId String user User @relation(fields: [userId], references: [id]) booking Booking? @@unique([tripId, userId]) } enum TripStatus { OPEN FULL CLOSED COMPLETED } enum ActivityCategory { HIKING CAMPING SNORKELING DIVING ISLAND_HOPPING CITY_TRIP CULINARY CONCERT WORKSHOP RETREAT } enum ParticipantStatus { PENDING CONFIRMED CANCELLED } /// Booking 1-1 ke TripParticipant. Lifecycle ikut peserta: /// - join → Booking PENDING (menunggu approve organizer) /// - organizer confirm → AWAITING_PAY (paid trip) atau PAID (free trip) /// - peserta + organizer rampungkan pembayaran → PAID /// - cancel/reject → CANCELLED /// `amount` adalah snapshot harga saat booking dibuat — protect dari perubahan trip.price. model Booking { id String @id @default(cuid()) tripId String trip Trip @relation(fields: [tripId], references: [id]) userId String user User @relation(fields: [userId], references: [id]) participantId String @unique participant TripParticipant @relation(fields: [participantId], references: [id], onDelete: Cascade) amount Int currency String @default("IDR") status BookingStatus @default(PENDING) payments Payment[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([tripId, status]) @@index([userId]) } enum BookingStatus { PENDING AWAITING_PAY PAID CANCELLED REFUNDED EXPIRED } /// Satu attempt pembayaran. Satu Booking bisa punya banyak Payment kalau retry /// (di Phase MIDTRANS nanti). Untuk MANUAL biasanya cukup 1 Payment. model Payment { id String @id @default(cuid()) bookingId String booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade) provider PaymentProvider /// order_id eksternal (unik per attempt). Format MANUAL: `manual-`. /// Format MIDTRANS nanti: `midtrans--`. externalOrderId String @unique /// transaction_id dari gateway. Kosong untuk MANUAL atau sebelum first callback. externalTxId String? /// Metode konkret: bca_va, gopay, qris, manual_transfer, dst. method String? amount Int status PaymentStatus @default(PENDING) /// Snapshot mentah callback gateway (untuk audit & dispute). rawCallback Json? /// Snap token Midtrans / redirect URL. snapToken String? /// Kapan attempt ini kadaluarsa (Midtrans default 24 jam). expiresAt DateTime? paidAt DateTime? failedAt DateTime? rejectionReason String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([bookingId, status]) @@index([provider, status]) } enum PaymentProvider { MANUAL MIDTRANS } enum PaymentStatus { PENDING AWAITING PAID FAILED EXPIRED CANCELLED REFUNDED }