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
}
