۰۸ — مدل داده (Prisma Schema)
ORM: Prisma v6 روی PostgreSQL 15
فایل اصلی:prisma/schema.prisma
آخرین sync: Jun 2026
۱. schema.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 successTimeout: اگه وندور تا box.pickupStart تأیید نکند → expireUnconfirmedOrders() در cron آن را cancelled میکند.
Inventory race condition
در POST /orders، با transaction + atomic updateMany جلوگیری میشود:
// 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
import { randomInt } from "crypto";
const pickupCode = String(randomInt(100000, 999999)); // 6 digit numeric۴. Migrations
npx prisma migrate dev --name <name>
npx prisma generateمهم (Windows): قبل از migrate dev، همه node processes را kill کن (DLL lock).
۵. Seed (dev)
// 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 دینامیک...