Skip to content

۰۸ — مدل داده (Prisma Schema)

ORM: Prisma v6 روی PostgreSQL 15
فایل اصلی: prisma/schema.prisma
آخرین sync: Jun 2026


۱. schema.prisma (کامل و بروز)

prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// ─── Enums ───────────────────────────────────────────

enum UserStatus {
  active
  suspended
}

enum VendorStatus {
  pending
  approved
  suspended
  rejected
}

enum StaffRole {
  owner
  staff
}

enum BillingType {
  flat_monthly
  flat_yearly
  commission
  hybrid
}

enum BoxStatus {
  draft
  active
  sold_out
  expired
  cancelled
}

enum BoxCategory {
  bread
  meal
  pastry
  mixed
  other
}

enum PaymentMethod {
  gateway
  pay_at_pickup
}

enum PaymentStatus {
  pending
  paid
  failed
  refunded
}

enum OrderStatus {
  pending          // سفارش ثبت — منتظر پرداخت gateway
  awaiting_vendor  // پرداخت OK — وندور باید تأیید کند (state جدید v1.1)
  confirmed        // وندور تأیید کرد — کد pickup فعال
  ready            // ← v2 — الان استفاده نمی‌شود، در schema برای آینده
  picked_up        // تحویل موفق — terminal success
  cancelled        // لغو شده
  expired          // باکس منقضی شد قبل از تحویل
  no_show          // مشتری نیامد بعد از pickup_end
}

enum DiscountScope {
  platform
  vendor
}

enum DiscountType {
  percent
  fixed
}

enum NotifChannel {
  sms
}

enum NotifStatus {
  queued
  sent
  failed
}

enum OtpPurpose {
  login_client
  login_vendor
  login_admin  // ← در کد استفاده نمی‌شود (admin با email/password لاگین می‌کند)
}

enum AdminRole {
  superadmin
  support
}

// ─── Models ──────────────────────────────────────────

model User {
  id        String     @id @default(uuid())
  phone     String     @unique
  name      String?
  status    UserStatus @default(active)
  createdAt DateTime   @default(now()) @map("created_at")
  updatedAt DateTime   @updatedAt @map("updated_at")

  orders        Order[]
  notifications Notification[]

  @@map("users")
}

model Vendor {
  id                 String       @id @default(uuid())
  name               String
  slug               String       @unique
  category           String
  description        String?
  address            String
  lat                Decimal      @db.Decimal(9, 6)
  lng                Decimal      @db.Decimal(9, 6)
  phone              String
  logoUrl            String?      @map("logo_url")
  status             VendorStatus @default(pending)
  packageId          String?      @map("package_id")
  commissionOverride Decimal?     @map("commission_override") @db.Decimal(5, 4)
  createdAt          DateTime     @default(now()) @map("created_at")
  approvedAt         DateTime?    @map("approved_at")

  package   Package?      @relation(fields: [packageId], references: [id])
  staff     VendorStaff[]
  boxes     SurpriseBox[]
  orders    Order[]
  discounts DiscountCode[]

  @@index([status])
  @@map("vendors")
}

model VendorStaff {
  id        String    @id @default(uuid())
  vendorId  String    @map("vendor_id")
  phone     String
  role      StaffRole @default(staff)
  name      String?
  createdAt DateTime  @default(now()) @map("created_at")

  vendor Vendor @relation(fields: [vendorId], references: [id])

  @@unique([vendorId, phone])
  @@map("vendor_staff")
}

model Package {
  id             String      @id @default(uuid())
  name           String
  billingType    BillingType @map("billing_type")
  price          Decimal?    @db.Decimal(12, 2)
  commissionRate Decimal?    @map("commission_rate") @db.Decimal(5, 4)
  isActive       Boolean     @default(true) @map("is_active")
  featuresJson   Json?       @map("features_json")
  createdAt      DateTime    @default(now()) @map("created_at")

  vendors Vendor[]

  @@map("packages")
}

model SurpriseBox {
  id                    String      @id @default(uuid())
  vendorId              String      @map("vendor_id")
  title                 String
  description           String?
  category              BoxCategory @default(mixed)
  imageUrl              String?     @map("image_url")
  originalValueEstimate Int         @map("original_value_estimate")
  salePrice             Int         @map("sale_price")
  quantityTotal         Int         @map("quantity_total")
  quantityRemaining     Int         @map("quantity_remaining")
  pickupStart           DateTime    @map("pickup_start")
  pickupEnd             DateTime    @map("pickup_end")
  status                BoxStatus   @default(draft)
  publishedAt           DateTime?   @map("published_at")
  createdAt             DateTime    @default(now()) @map("created_at")

  vendor Vendor  @relation(fields: [vendorId], references: [id])
  orders Order[]

  @@index([vendorId, status])
  @@index([status, pickupStart, pickupEnd])
  @@map("surprise_boxes")
}

model Order {
  id             String        @id @default(uuid())
  userId         String        @map("user_id")
  vendorId       String        @map("vendor_id")
  surpriseBoxId  String        @map("surprise_box_id")
  quantity       Int           @default(1)
  unitPrice      Int           @map("unit_price")
  discountAmount Int           @default(0) @map("discount_amount")
  totalPrice     Int           @map("total_price")
  discountCodeId String?       @map("discount_code_id")
  paymentMethod  PaymentMethod @map("payment_method")
  paymentStatus  PaymentStatus @default(pending) @map("payment_status")
  orderStatus    OrderStatus   @default(pending) @map("order_status")
  pickupCode     String        @unique @map("pickup_code")
  createdAt      DateTime      @default(now()) @map("created_at")
  confirmedAt    DateTime?     @map("confirmed_at")
  pickedUpAt     DateTime?     @map("picked_up_at")
  cancelledAt    DateTime?     @map("cancelled_at")
  cancelReason   String?       @map("cancel_reason")

  user         User          @relation(fields: [userId], references: [id])
  vendor       Vendor        @relation(fields: [vendorId], references: [id])
  box          SurpriseBox   @relation(fields: [surpriseBoxId], references: [id])
  discountCode DiscountCode? @relation(fields: [discountCodeId], references: [id])
  payment      Payment?

  @@index([userId])
  @@index([vendorId])
  @@index([pickupCode])
  @@map("orders")
}

model Payment {
  id          String        @id @default(uuid())
  orderId     String        @unique @map("order_id")
  gateway     String        @default("zarinpal")
  gatewayRef  String?       @map("gateway_ref")
  amount      Int
  status      PaymentStatus @default(pending)
  rawCallback Json?         @map("raw_callback")
  paidAt      DateTime?     @map("paid_at")
  settledAt   DateTime?     @map("settled_at")
  createdAt   DateTime      @default(now()) @map("created_at")

  order Order @relation(fields: [orderId], references: [id])

  @@map("payments")
}

model DiscountCode {
  id                String        @id @default(uuid())
  code              String        @unique
  scope             DiscountScope
  vendorId          String?       @map("vendor_id")
  type              DiscountType
  value             Decimal       @db.Decimal(10, 2)
  minOrderAmount    Int?          @map("min_order_amount")
  maxDiscountAmount Int?          @map("max_discount_amount")
  usageLimitTotal   Int?          @map("usage_limit_total")
  usageCount        Int           @default(0) @map("usage_count")
  usageLimitPerUser Int?          @map("usage_limit_per_user")
  validFrom         DateTime      @map("valid_from")
  validTo           DateTime      @map("valid_to")
  isActive          Boolean       @default(true) @map("is_active")
  createdAt         DateTime      @default(now()) @map("created_at")

  vendor Vendor?  @relation(fields: [vendorId], references: [id])
  orders Order[]

  @@index([code])
  @@index([isActive, validFrom, validTo])
  @@map("discount_codes")
}

model Notification {
  id          String       @id @default(uuid())
  userId      String?      @map("user_id")
  phone       String
  channel     NotifChannel @default(sms)
  template    String
  payload     Json         @default("{}")
  status      NotifStatus  @default(queued)
  providerRef String?      @map("provider_ref")
  sentAt      DateTime?    @map("sent_at")
  createdAt   DateTime     @default(now()) @map("created_at")

  user User? @relation(fields: [userId], references: [id])

  @@index([status])
  @@map("notifications")
}

model OtpSession {
  id        String     @id @default(uuid())
  phone     String
  codeHash  String     @map("code_hash")
  purpose   OtpPurpose
  expiresAt DateTime   @map("expires_at")
  attempts  Int        @default(0)
  createdAt DateTime   @default(now()) @map("created_at")

  @@index([phone, purpose])
  @@map("otp_sessions")
}

model AdminUser {
  id           String    @id @default(uuid())
  email        String    @unique
  passwordHash String    @map("password_hash")
  role         AdminRole @default(support)
  createdAt    DateTime  @default(now()) @map("created_at")

  @@map("admin_users")
}

۲. ER diagram (خلاصه)

User ──────< Order >────── SurpriseBox ──── Vendor
                │                             │
                └── Payment                  ├── VendorStaff
                └── DiscountCode             └── Package

Platform-wide: DiscountCode (scope=platform)

۳. نکات مهم

Order lifecycle (state machine واقعی)

pending
  │ gateway callback → payment OK

awaiting_vendor  ← vendor SMS دریافت می‌کند
  │ POST /api/vendor/orders/[id]/confirm

confirmed        ← customer SMS دریافت می‌کند، کد فعال
  │ POST /api/vendor/orders/confirm-pickup

picked_up        ← terminal success

Timeout: اگه وندور تا box.pickupStart تأیید نکند → expireUnconfirmedOrders() در cron آن را cancelled می‌کند.

Inventory race condition

در POST /orders، با transaction + atomic updateMany جلوگیری می‌شود:

ts
// src/app/api/orders/route.ts
await prisma.$transaction(async (tx) => {
  // atomic check + decrement در یک query
  const updated = await tx.surpriseBox.updateMany({
    where: { id: surpriseBoxId, quantityRemaining: { gte: quantity } },
    data: { quantityRemaining: { decrement: quantity } },
  });
  if (updated.count === 0) throw new Error("RACE_CONDITION");

  await tx.order.create({
    data: {
      orderStatus: paymentMethod === "pay_at_pickup" ? "awaiting_vendor" : "pending",
      // ...
    },
  });
});

نکته: پس از awaiting_vendor (نه confirmed) inventory کم می‌شود.

Discount usageCount

usageCount در POST /api/orders/[id]/apply-discount increment می‌شود — قبل از payment.
این عمدی است: کد را «مصرف» می‌کند تا از race condition روی کدهای محدود جلوگیری شود.
اگه پرداخت fail شود، ممکن است usageCount یک واحد اضافه باشد — قابل قبول برای MVP.

ready status

ready در schema وجود دارد اما در MVP استفاده نمی‌شود.
ذخیره شده برای v2 که وندور آماده‌سازی را جدا از تأیید سفارش track کند.

login_admin OTP purpose

OtpPurpose.login_admin در schema هست اما هیچ‌جا استفاده نمی‌شود.
ادمین با email/password لاگین می‌کند (POST /api/auth/admin/login).
این مقدار برای آینده (OTP دو‌مرحله‌ای برای ادمین) نگه داشته شده.

MVP: one active box per vendor per day

این قانون در application layer (API Route) enforce می‌شود — Prisma constraint نیست.

Pickup code

ts
import { randomInt } from "crypto";
const pickupCode = String(randomInt(100000, 999999)); // 6 digit numeric

۴. Migrations

bash
npx prisma migrate dev --name <name>
npx prisma generate

مهم (Windows): قبل از migrate dev، همه node processes را kill کن (DLL lock).


۵. Seed (dev)

ts
// prisma/seed.ts — pickup times همیشه در آینده
await prisma.package.create({
  data: { name: "Beta", billingType: "commission", commissionRate: 0.12, isActive: true }
});
await prisma.adminUser.create({
  data: { email: "admin@TakeEat.ir", passwordHash: "...", role: "superadmin" }
});
// vendor ها، باکس‌ها، با pickupStart/End دینامیک...