Files
setrip/prisma/schema.prisma
T

673 lines
22 KiB
Plaintext

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?
/// Suspended user diblokir sign-in dan aksi mutatif. Set oleh admin via panel.
suspended Boolean @default(false)
suspendedAt DateTime?
suspendedReason String?
suspendedById String?
suspendedBy User? @relation("UserSuspendedBy", fields: [suspendedById], references: [id], onDelete: SetNull)
suspendedUsers User[] @relation("UserSuspendedBy")
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")
manualOverrideVerifications OrganizerVerification[] @relation("OrganizerVerificationManualOverride")
reviewedRefunds Refund[] @relation("RefundReviewer")
/// Payout yang diterima user ini sebagai organizer (escrow trip selesai).
payouts Payout[] @relation("PayoutOrganizer")
/// Payout yang ditandai admin sebagai PAID/CANCELLED oleh user ini.
processedPayouts Payout[] @relation("PayoutProcessor")
/// Trip yang dibatalkan admin ini lewat panel admin (intervensi).
adminCancelledTrips Trip[] @relation("TripCancelledByAdmin")
/// Audit log polymorphic — semua aksi admin yang dilakukan oleh user ini.
adminActionLogs AdminActionLog[] @relation("AdminActionLogAdmin")
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/<id>.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?
/// Phase 2: admin minta organizer upload ulang field tertentu. Saat true,
/// organizer page /verify menampilkan banner kuning + highlight field
/// yang diminta. Auto-clear saat organizer submit ulang.
reuploadRequested Boolean @default(false)
reuploadFields String[] @default([])
reuploadNote String?
/// Phase 3: jumlah submission ulang + arsip rejection sebelumnya
/// (di-append saat REJECTED baru, supaya history tidak ke-overwrite).
submissionCount Int @default(1)
previousRejections Json?
/// Phase 4: admin verify manual tanpa upload KYC (mis. partner trusted).
isManualOverride Boolean @default(false)
manualOverrideById String?
manualOverrideBy User? @relation("OrganizerVerificationManualOverride", fields: [manualOverrideById], references: [id], onDelete: SetNull)
manualOverrideNote String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
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?
/// @deprecated — itinerary lama bentuk teks bebas, backward-compat untuk data
/// lama. Trip baru pakai `itineraryItems` (struktural per hari + jam).
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)
/// Admin yang membatalkan trip via panel admin (intervensi). NULL kalau
/// organizer yang cancel sendiri atau trip tidak dibatalkan.
cancelledByAdminId String?
cancelledByAdmin User? @relation("TripCancelledByAdmin", fields: [cancelledByAdminId], references: [id], onDelete: SetNull)
/// Alasan admin membatalkan trip — wajib diisi saat admin cancel.
cancelledReason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organizerId String
organizer User @relation(fields: [organizerId], references: [id])
participants TripParticipant[]
images TripImage[]
reviews TripReview[]
bookings Booking[]
payouts Payout[]
itineraryItems TripItineraryItem[]
@@index([category, status, date])
@@index([vibe, status, date])
}
/// Itinerary item terstruktur per hari + jam. Satu Trip punya banyak item;
/// dikelompokkan per `day` lalu diurutkan `order`. Format jam: "HH:mm" 24-jam.
model TripItineraryItem {
id String @id @default(cuid())
tripId String
trip Trip @relation(fields: [tripId], references: [id], onDelete: Cascade)
/// Hari ke-berapa, mulai dari 1.
day Int
/// Waktu mulai aktivitas, format "HH:mm".
startTime String
/// Waktu selesai (opsional), format "HH:mm".
endTime String?
/// Deskripsi aktivitas singkat.
activity String
/// Urutan dalam hari, untuk preserve order saat render.
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tripId, day, order])
}
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[]
refunds Refund[]
payout Payout?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/// Konsistensi: 1-1 ke participant via participantId, dan participant unique
/// per (tripId, userId). Constraint ini eksplisit + jadi index untuk query
/// `findByTripAndUser`.
@@unique([tripId, userId])
@@index([tripId, status])
}
enum BookingStatus {
PENDING
AWAITING_PAY
PAID
CANCELLED
REFUNDED
PARTIALLY_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-<bookingId>`.
/// Format MIDTRANS nanti: `midtrans-<bookingId>-<retry>`.
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?
refunds Refund[]
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
}
/// Refund = financial event terpisah dari Booking. Satu Booking bisa punya
/// banyak Refund (partial, multi-tahap). Setiap row auditable: kapan dibuat,
/// siapa melaporkan, siapa approve, kapan SUCCEEDED. Never delete — kalau
/// gagal, set status=FAILED + alasan.
///
/// Di MVP refund dimasukkan admin secara manual berdasarkan laporan dari
/// peserta atau organizer (via WhatsApp/email). Phase berikutnya akan
/// menambah self-service flow dari user dan organizer.
model Refund {
id String @id @default(cuid())
bookingId String
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Restrict)
/// Payment yang di-refund. Opsional di MVP (manual transfer bisa tidak
/// terikat ke Payment row tertentu); wajib saat integrasi Midtrans (R-4).
paymentId String?
payment Payment? @relation(fields: [paymentId], references: [id], onDelete: Restrict)
/// Nominal refund dalam satuan terkecil (IDR rupiah, integer). Boleh < total
/// payment untuk partial. Service layer enforce SUM(SUCCEEDED) <= payment.amount.
amount Int
currency String @default("IDR")
reason RefundReason
/// Siapa yang melaporkan kebutuhan refund ini ke admin.
reportedBy RefundReporter
/// Isi laporan dari peserta/organizer yang admin terima (mis. WA, email).
reportNote String
/// Pihak yang membuat record di sistem. Di MVP selalu ADMIN; saat self-service
/// nanti USER/ORGANIZER, dan SYSTEM untuk auto-trigger dari trip dibatalkan.
initiatedBy RefundInitiator @default(ADMIN)
status RefundStatus @default(PENDING)
/// Idempotency key, dipakai saat panggil Midtrans Refund API di R-4. Generate
/// sekali saat create supaya retry gateway tidak double-refund.
idempotencyKey String @unique
/// Catatan admin: alasan tolak, referensi transfer manual, dst. Bebas teks.
adminNote String?
/// Admin yang terakhir mengubah status (approve/reject/mark-succeeded/failed).
reviewedById String?
reviewedBy User? @relation("RefundReviewer", fields: [reviewedById], references: [id], onDelete: SetNull)
reviewedAt DateTime?
succeededAt DateTime?
failedAt DateTime?
/// ID refund di gateway (mis. Midtrans refund_id). Kosong untuk manual transfer.
externalRefundId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([bookingId, status])
@@index([status, createdAt])
}
/// Log append-only setiap email yang berhasil terkirim. `idempotencyKey`
/// UNIQUE cegah double-send saat webhook retry / cron rerun.
model EmailSent {
id String @id @default(cuid())
idempotencyKey String @unique
to String
template String
subject String
/// ID dari Resend (atau provider lain) untuk troubleshooting di dashboard mereka.
providerMessageId String?
sentAt DateTime @default(now())
@@index([to, sentAt(sort: Desc)])
@@index([template, sentAt(sort: Desc)])
}
/// Retry queue untuk email yang gagal saat sync send. Cron pick PENDING/FAILED
/// (attempts<5) → retry dengan exponential backoff. Idempotent via `idempotencyKey`.
model EmailJob {
id String @id @default(cuid())
idempotencyKey String
to String
template String
subject String
html String
status EmailJobStatus @default(PENDING)
attempts Int @default(0)
scheduledAt DateTime @default(now())
lastAttemptAt DateTime?
lastError String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, scheduledAt])
@@index([idempotencyKey])
}
enum EmailJobStatus {
PENDING
PROCESSING
SUCCESS
FAILED
}
/// Log polymorphic untuk admin actions lintas entity. Append-only — kalau
/// admin dihapus, `adminId` di-set NULL tapi `adminEmail` snapshot tetap.
/// Dipakai untuk compliance & investigasi (siapa approve/reject/cancel/
/// suspend, kapan, dengan payload apa).
model AdminActionLog {
id String @id @default(cuid())
adminId String?
admin User? @relation("AdminActionLogAdmin", fields: [adminId], references: [id], onDelete: SetNull)
/// Snapshot email admin saat action dijalankan — tetap ada meski admin dihapus.
adminEmail String
/// Nama aksi dalam SCREAMING_SNAKE, mis. `REFUND_APPROVE`, `TRIP_CANCEL`, `USER_SUSPEND`.
action String
/// Tipe entity yang di-target: `Refund` / `Payout` / `Trip` / `User` / `Verification` / `Payment`.
entityType String
entityId String
/// Payload bebas (input parameter, hasil, dst) untuk konteks investigasi.
payload Json?
createdAt DateTime @default(now())
@@index([adminId, createdAt(sort: Desc)])
@@index([entityType, entityId])
@@index([createdAt(sort: Desc)])
}
/// Log per cron run untuk observability admin. Append-only.
/// `runCron(jobName, fn)` di `lib/cron-runner.ts` otomatis create row RUNNING
/// → update SUCCESS/FAILED setelah selesai. Dipakai admin di `/admin/system`.
model CronRun {
id String @id @default(cuid())
jobName String
startedAt DateTime @default(now())
finishedAt DateTime?
status CronRunStatus @default(RUNNING)
errorMessage String?
/// Snapshot ringkas hasil run (mis. `{ completed: 5, ids: [...] }`).
payload Json?
createdAt DateTime @default(now())
@@index([jobName, startedAt(sort: Desc)])
@@index([startedAt(sort: Desc)])
}
enum CronRunStatus {
RUNNING
SUCCESS
FAILED
}
enum RefundReason {
/// Peserta cancel booking sendiri (mengikuti refund window policy).
USER_CANCELLATION
/// Organizer membatalkan trip — peserta dapat full refund.
ORGANIZER_CANCELLED
/// Masalah saat/setelah trip (mis. itinerary tidak sesuai).
TRIP_ISSUE
/// Penyesuaian dari admin (kompensasi, koreksi nominal, dll.).
ADMIN_ADJUSTMENT
/// Hasil resolusi dispute / chargeback bank.
DISPUTE_RESOLVED
OTHER
}
enum RefundStatus {
/// Baru dilaporkan, menunggu review admin.
PENDING
/// Admin sudah setujui, siap dieksekusi (manual transfer / gateway).
APPROVED
/// Admin tolak (alasan di `adminNote`).
REJECTED
/// (R-4) Request sudah dikirim ke gateway, menunggu callback.
PROCESSING
/// Uang sudah keluar dari kas Setrip / merchant gateway.
SUCCEEDED
/// Eksekusi gagal (alasan di `adminNote`). Record tidak dihapus.
FAILED
}
enum RefundInitiator {
USER
ORGANIZER
SYSTEM
ADMIN
}
enum RefundReporter {
PARTICIPANT
ORGANIZER
}
/// Escrow payout ke organizer. Uang peserta ditahan sejak Booking → PAID sampai
/// trip selesai + buffer beberapa hari, baru di-release untuk ditransfer admin.
///
/// State machine:
/// HELD → diciptakan saat booking PAID, heldUntil = endDate/date + 3 hari
/// RELEASED → cron flip setelah heldUntil lewat + trip COMPLETED
/// PAID → admin sudah transfer manual ke rekening organizer
/// CANCELLED → booking di-refund / trip dibatalkan; payout tidak jadi
///
/// Audit: 1-1 dengan Booking (unique). Refund SUCCEEDED mengurangi amount
/// (partial) atau membatalkan payout (full).
model Payout {
id String @id @default(cuid())
bookingId String @unique
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Restrict)
tripId String
trip Trip @relation(fields: [tripId], references: [id])
organizerId String
organizer User @relation("PayoutOrganizer", fields: [organizerId], references: [id])
/// Nominal yg organizer terima (IDR integer). Default = booking.amount saat
/// payout dibuat. Refund SUCCEEDED memotong nilai ini supaya total payout +
/// total refund = uang yang dibayar peserta.
amount Int
currency String @default("IDR")
status PayoutStatus @default(HELD)
/// Tanggal payout boleh di-release ke organizer
/// (= trip.endDate ?? trip.date + buffer days).
heldUntil DateTime
releasedAt DateTime?
paidAt DateTime?
cancelledAt DateTime?
/// Snapshot bank info organizer dari OrganizerVerification saat payout dibuat.
/// Disimpan inline supaya audit-friendly walau organizer ganti bank nanti.
bankName String?
bankAccountNumber String?
bankAccountName String?
/// Catatan admin: referensi transfer manual, alasan cancel, dst.
adminNote String?
/// Admin yang menandai PAID/CANCELLED.
processedById String?
processedBy User? @relation("PayoutProcessor", fields: [processedById], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizerId, status])
@@index([status, heldUntil])
}
enum PayoutStatus {
/// Menunggu trip selesai + buffer beberapa hari sebelum boleh ditransfer.
HELD
/// Buffer lewat & trip COMPLETED, siap di-transfer admin ke rekening organizer.
RELEASED
/// Admin sudah transfer ke rekening organizer.
PAID
/// Booking di-refund penuh / trip dibatalkan — uang tidak jadi ke organizer.
CANCELLED
}