Basic Architecture of Sport Web
This commit is contained in:
@@ -0,0 +1,694 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sport Store — Prisma schema
|
||||
//
|
||||
// SCOPE OF THIS FILE (milestone 0)
|
||||
// Identity + RBAC, catalog (Product / ProductVariant / options / images /
|
||||
// attributes), taxonomy (brand / category / collection), media metadata and
|
||||
// the inventory ledger.
|
||||
//
|
||||
// Cart, checkout, order, payment, promotion and review tables are milestone 1.
|
||||
// They are intentionally absent so the first migration stays reviewable.
|
||||
//
|
||||
// CONVENTIONS
|
||||
// - Table names: snake_case plural (@@map). Prisma models: PascalCase singular.
|
||||
// - Ids: UUID v7 — time-sortable, so they index like a sequence but leak no
|
||||
// row counts and stay safe to expose in URLs.
|
||||
// - Money: INTEGER in the currency's minor unit. Never Float, never Decimal
|
||||
// round-trips through JS. VND has no minor unit, so 250000 means ₫250.000.
|
||||
// - Timestamps: `timestamptz`. The database always stores UTC.
|
||||
// - Soft delete only where history matters (products, variants); everywhere
|
||||
// else a hard delete is correct and simpler.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Identity & access control
|
||||
// ===========================================================================
|
||||
|
||||
enum UserType {
|
||||
CUSTOMER
|
||||
STAFF
|
||||
ADMIN
|
||||
SUPER_ADMIN
|
||||
}
|
||||
|
||||
enum UserStatus {
|
||||
ACTIVE
|
||||
INVITED
|
||||
SUSPENDED
|
||||
}
|
||||
|
||||
/// Every human in the system — shoppers and operators alike — is a User row.
|
||||
/// The `type` column decides which token audience the account may authenticate
|
||||
/// against; RBAC decides what it may then do.
|
||||
model User {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
/// Always normalised to lowercase before write (see @sport/validation), so a
|
||||
/// plain unique index is enough and no citext extension is required.
|
||||
email String @unique @db.VarChar(255)
|
||||
passwordHash String? @map("password_hash")
|
||||
type UserType
|
||||
status UserStatus @default(ACTIVE)
|
||||
|
||||
firstName String? @map("first_name") @db.VarChar(80)
|
||||
lastName String? @map("last_name") @db.VarChar(80)
|
||||
phone String? @db.VarChar(20)
|
||||
avatarId String? @map("avatar_id") @db.Uuid
|
||||
|
||||
emailVerifiedAt DateTime? @map("email_verified_at") @db.Timestamptz(3)
|
||||
lastLoginAt DateTime? @map("last_login_at") @db.Timestamptz(3)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
|
||||
|
||||
avatar MediaAsset? @relation("UserAvatar", fields: [avatarId], references: [id], onDelete: SetNull)
|
||||
roles UserRole[]
|
||||
sessions Session[]
|
||||
customer Customer?
|
||||
auditLogs AuditLog[]
|
||||
|
||||
@@index([type, status])
|
||||
@@index([createdAt])
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
/// Roles are DATA: a SUPER_ADMIN can create "Warehouse Supervisor" at runtime
|
||||
/// without a deploy. Only `isSystem` roles are protected from deletion.
|
||||
model Role {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
key String @unique @db.VarChar(64)
|
||||
name String @db.VarChar(120)
|
||||
description String?
|
||||
isSystem Boolean @default(false) @map("is_system")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
permissions RolePermission[]
|
||||
users UserRole[]
|
||||
|
||||
@@map("roles")
|
||||
}
|
||||
|
||||
/// Permissions are CODE: the catalog in @sport/types is the source of truth and
|
||||
/// the seed reconciles this table against it. Nothing creates permissions at
|
||||
/// runtime — that would let the database drift from the guards.
|
||||
model Permission {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
key String @unique @db.VarChar(64)
|
||||
resource String @db.VarChar(40)
|
||||
action String @db.VarChar(40)
|
||||
description String?
|
||||
|
||||
roles RolePermission[]
|
||||
|
||||
@@index([resource])
|
||||
@@map("permissions")
|
||||
}
|
||||
|
||||
model RolePermission {
|
||||
roleId String @map("role_id") @db.Uuid
|
||||
permissionId String @map("permission_id") @db.Uuid
|
||||
|
||||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([roleId, permissionId])
|
||||
@@index([permissionId])
|
||||
@@map("role_permissions")
|
||||
}
|
||||
|
||||
model UserRole {
|
||||
userId String @map("user_id") @db.Uuid
|
||||
roleId String @map("role_id") @db.Uuid
|
||||
|
||||
assignedAt DateTime @default(now()) @map("assigned_at") @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([userId, roleId])
|
||||
@@index([roleId])
|
||||
@@map("user_roles")
|
||||
}
|
||||
|
||||
/// One row per refresh-token family (i.e. per signed-in device).
|
||||
///
|
||||
/// The token itself is never stored — only a SHA-256 hash. On refresh the row
|
||||
/// is rotated: `replacedById` points at the successor. Presenting a token whose
|
||||
/// row is already replaced means the token leaked, so the entire family is
|
||||
/// revoked. This is why the column exists at all.
|
||||
model Session {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
refreshTokenHash String @unique @map("refresh_token_hash") @db.VarChar(64)
|
||||
familyId String @map("family_id") @db.Uuid
|
||||
replacedById String? @unique @map("replaced_by_id") @db.Uuid
|
||||
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
|
||||
revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3)
|
||||
|
||||
userAgent String? @map("user_agent")
|
||||
ipAddress String? @map("ip_address") @db.VarChar(45)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
replacedBy Session? @relation("SessionRotation", fields: [replacedById], references: [id], onDelete: SetNull)
|
||||
replaces Session? @relation("SessionRotation")
|
||||
|
||||
@@index([userId, revokedAt])
|
||||
@@index([familyId])
|
||||
@@index([expiresAt])
|
||||
@@map("sessions")
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
actorUserId String? @map("actor_user_id") @db.Uuid
|
||||
action String @db.VarChar(80)
|
||||
resourceType String @map("resource_type") @db.VarChar(60)
|
||||
resourceId String? @map("resource_id")
|
||||
/// Before/after snapshot. Append-only; never updated.
|
||||
changes Json?
|
||||
ipAddress String? @map("ip_address") @db.VarChar(45)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
|
||||
actor User? @relation(fields: [actorUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([resourceType, resourceId])
|
||||
@@index([actorUserId, createdAt])
|
||||
@@map("audit_logs")
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Customers
|
||||
// ===========================================================================
|
||||
|
||||
/// Shopper-specific profile, split from User so that back-office accounts carry
|
||||
/// none of it and customer data can later move behind a stricter access policy.
|
||||
model Customer {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
userId String @unique @map("user_id") @db.Uuid
|
||||
|
||||
acceptsMarketing Boolean @default(false) @map("accepts_marketing")
|
||||
dateOfBirth DateTime? @map("date_of_birth") @db.Date
|
||||
note String?
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
addresses Address[]
|
||||
|
||||
@@map("customers")
|
||||
}
|
||||
|
||||
model Address {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
customerId String @map("customer_id") @db.Uuid
|
||||
|
||||
fullName String @map("full_name") @db.VarChar(160)
|
||||
phone String @db.VarChar(20)
|
||||
line1 String @db.VarChar(255)
|
||||
line2 String? @db.VarChar(255)
|
||||
/// Vietnamese administrative divisions. Codes are kept alongside names so a
|
||||
/// later shipping-provider integration can map them without re-collecting.
|
||||
ward String? @db.VarChar(120)
|
||||
wardCode String? @map("ward_code") @db.VarChar(20)
|
||||
district String? @db.VarChar(120)
|
||||
districtCode String? @map("district_code") @db.VarChar(20)
|
||||
province String @db.VarChar(120)
|
||||
provinceCode String? @map("province_code") @db.VarChar(20)
|
||||
countryCode String @default("VN") @map("country_code") @db.Char(2)
|
||||
postalCode String? @map("postal_code") @db.VarChar(20)
|
||||
|
||||
isDefaultShipping Boolean @default(false) @map("is_default_shipping")
|
||||
isDefaultBilling Boolean @default(false) @map("is_default_billing")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([customerId])
|
||||
@@map("addresses")
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Media
|
||||
// ===========================================================================
|
||||
|
||||
enum MediaKind {
|
||||
IMAGE
|
||||
VIDEO
|
||||
DOCUMENT
|
||||
}
|
||||
|
||||
/// Metadata only. The bytes live in R2/S3 under `storageKey`; PostgreSQL never
|
||||
/// stores binary. Public URLs are composed at read time from STORAGE_PUBLIC_URL
|
||||
/// + storageKey, so changing CDN or bucket is config, not a data migration.
|
||||
model MediaAsset {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
kind MediaKind @default(IMAGE)
|
||||
storageKey String @unique @map("storage_key") @db.VarChar(512)
|
||||
mimeType String @map("mime_type") @db.VarChar(120)
|
||||
sizeBytes Int @map("size_bytes")
|
||||
width Int?
|
||||
height Int?
|
||||
/// Base64 LQIP, a few hundred bytes. Cheap enough to store inline.
|
||||
blurDataUrl String? @map("blur_data_url")
|
||||
altText String? @map("alt_text") @db.VarChar(255)
|
||||
/// Free-form: original filename, uploader, EXIF subset, …
|
||||
metadata Json?
|
||||
|
||||
uploadedByUserId String? @map("uploaded_by_user_id") @db.Uuid
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
|
||||
productImages ProductImage[]
|
||||
brandLogos Brand[] @relation("BrandLogo")
|
||||
categoryImages Category[] @relation("CategoryImage")
|
||||
collectionBanners Collection[] @relation("CollectionBanner")
|
||||
optionValueSwatches ProductOptionValue[] @relation("OptionValueSwatch")
|
||||
userAvatars User[] @relation("UserAvatar")
|
||||
|
||||
@@index([kind, createdAt])
|
||||
@@map("media_assets")
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Taxonomy
|
||||
// ===========================================================================
|
||||
|
||||
model Brand {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
name String @db.VarChar(160)
|
||||
slug String @unique @db.VarChar(180)
|
||||
description String?
|
||||
logoId String? @map("logo_id") @db.Uuid
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
|
||||
metaTitle String? @map("meta_title") @db.VarChar(255)
|
||||
metaDescription String? @map("meta_description")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
logo MediaAsset? @relation("BrandLogo", fields: [logoId], references: [id], onDelete: SetNull)
|
||||
products Product[]
|
||||
|
||||
@@map("brands")
|
||||
}
|
||||
|
||||
/// Hierarchical merchandising tree (Men > Running > Shoes).
|
||||
///
|
||||
/// `path` is a materialised path ("men/running/shoes") so an entire subtree is
|
||||
/// one indexed `LIKE 'men/running%'` query instead of a recursive CTE per page
|
||||
/// view. `depth` lets navigation queries stop at the level they render.
|
||||
model Category {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
parentId String? @map("parent_id") @db.Uuid
|
||||
|
||||
name String @db.VarChar(160)
|
||||
slug String @db.VarChar(180)
|
||||
path String @unique @db.VarChar(512)
|
||||
depth Int @default(0)
|
||||
position Int @default(0)
|
||||
description String?
|
||||
imageId String? @map("image_id") @db.Uuid
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
|
||||
metaTitle String? @map("meta_title") @db.VarChar(255)
|
||||
metaDescription String? @map("meta_description")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: Restrict)
|
||||
children Category[] @relation("CategoryTree")
|
||||
image MediaAsset? @relation("CategoryImage", fields: [imageId], references: [id], onDelete: SetNull)
|
||||
products Product[]
|
||||
|
||||
@@unique([parentId, slug])
|
||||
@@index([path])
|
||||
@@index([parentId, position])
|
||||
@@map("categories")
|
||||
}
|
||||
|
||||
enum CollectionType {
|
||||
MANUAL
|
||||
AUTOMATED
|
||||
}
|
||||
|
||||
model Collection {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
name String @db.VarChar(160)
|
||||
slug String @unique @db.VarChar(180)
|
||||
type CollectionType @default(MANUAL)
|
||||
description String?
|
||||
bannerId String? @map("banner_id") @db.Uuid
|
||||
|
||||
/// Rule set for AUTOMATED collections, evaluated by the catalog module.
|
||||
rules Json?
|
||||
|
||||
startsAt DateTime? @map("starts_at") @db.Timestamptz(3)
|
||||
endsAt DateTime? @map("ends_at") @db.Timestamptz(3)
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
position Int @default(0)
|
||||
|
||||
metaTitle String? @map("meta_title") @db.VarChar(255)
|
||||
metaDescription String? @map("meta_description")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
banner MediaAsset? @relation("CollectionBanner", fields: [bannerId], references: [id], onDelete: SetNull)
|
||||
products ProductCollection[]
|
||||
|
||||
@@index([isActive, startsAt, endsAt])
|
||||
@@map("collections")
|
||||
}
|
||||
|
||||
model ProductCollection {
|
||||
productId String @map("product_id") @db.Uuid
|
||||
collectionId String @map("collection_id") @db.Uuid
|
||||
position Int @default(0)
|
||||
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([productId, collectionId])
|
||||
@@index([collectionId, position])
|
||||
@@map("product_collections")
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Catalog: Product / ProductVariant
|
||||
// ===========================================================================
|
||||
|
||||
enum ProductStatus {
|
||||
DRAFT
|
||||
ACTIVE
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
enum VariantStatus {
|
||||
ACTIVE
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
enum Currency {
|
||||
VND
|
||||
USD
|
||||
}
|
||||
|
||||
enum GenderTarget {
|
||||
MEN
|
||||
WOMEN
|
||||
KIDS
|
||||
UNISEX
|
||||
}
|
||||
|
||||
enum SportType {
|
||||
RUNNING
|
||||
FOOTBALL
|
||||
TRAINING
|
||||
GYM
|
||||
BADMINTON
|
||||
LIFESTYLE
|
||||
}
|
||||
|
||||
/// The marketing entity: it has a name, a URL and a page. It deliberately has
|
||||
/// NO sku, NO price and NO stock — those belong to ProductVariant, because in
|
||||
/// reality "Black / M" and "White / L" are different physical goods.
|
||||
model Product {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
name String @db.VarChar(255)
|
||||
slug String @unique @db.VarChar(280)
|
||||
description String?
|
||||
shortDescription String? @map("short_description") @db.VarChar(500)
|
||||
|
||||
status ProductStatus @default(DRAFT)
|
||||
publishedAt DateTime? @map("published_at") @db.Timestamptz(3)
|
||||
|
||||
brandId String? @map("brand_id") @db.Uuid
|
||||
primaryCategoryId String? @map("primary_category_id") @db.Uuid
|
||||
|
||||
/// Facets driving /men, /women and /sports/*. Arrays rather than join tables:
|
||||
/// they are small, bounded, always fetched with the product and filtered with
|
||||
/// a GIN index — a join table would buy nothing here.
|
||||
genderTargets GenderTarget[] @map("gender_targets")
|
||||
sportTypes SportType[] @map("sport_types")
|
||||
|
||||
metaTitle String? @map("meta_title") @db.VarChar(255)
|
||||
metaDescription String? @map("meta_description")
|
||||
metadata Json?
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
|
||||
|
||||
brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull)
|
||||
primaryCategory Category? @relation(fields: [primaryCategoryId], references: [id], onDelete: SetNull)
|
||||
options ProductOption[]
|
||||
variants ProductVariant[]
|
||||
images ProductImage[]
|
||||
attributes ProductAttribute[]
|
||||
collections ProductCollection[]
|
||||
|
||||
@@index([status, publishedAt])
|
||||
@@index([brandId])
|
||||
@@index([primaryCategoryId])
|
||||
@@index([genderTargets], type: Gin)
|
||||
@@index([sportTypes], type: Gin)
|
||||
@@map("products")
|
||||
}
|
||||
|
||||
/// An axis of variation for one product: "Colour", "Size".
|
||||
model ProductOption {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
productId String @map("product_id") @db.Uuid
|
||||
|
||||
name String @db.VarChar(60)
|
||||
/// Stable machine key (`colour`, `size`) used by URLs and integrations.
|
||||
key String @db.VarChar(40)
|
||||
position Int @default(0)
|
||||
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
values ProductOptionValue[]
|
||||
variantLinks ProductVariantOptionValue[]
|
||||
|
||||
@@unique([productId, key])
|
||||
@@index([productId, position])
|
||||
@@map("product_options")
|
||||
}
|
||||
|
||||
/// One allowed value on an axis: "Black", "M".
|
||||
model ProductOptionValue {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
optionId String @map("option_id") @db.Uuid
|
||||
|
||||
label String @db.VarChar(80)
|
||||
value String @db.VarChar(80)
|
||||
position Int @default(0)
|
||||
|
||||
swatchHex String? @map("swatch_hex") @db.VarChar(9)
|
||||
swatchImageId String? @map("swatch_image_id") @db.Uuid
|
||||
|
||||
option ProductOption @relation(fields: [optionId], references: [id], onDelete: Cascade)
|
||||
swatchImage MediaAsset? @relation("OptionValueSwatch", fields: [swatchImageId], references: [id], onDelete: SetNull)
|
||||
variantLinks ProductVariantOptionValue[]
|
||||
images ProductImage[]
|
||||
|
||||
@@unique([optionId, value])
|
||||
@@index([optionId, position])
|
||||
@@map("product_option_values")
|
||||
}
|
||||
|
||||
/// The purchasable unit. Everything downstream — cart lines, order lines, stock
|
||||
/// movements, marketplace listings — references THIS id, never a Product id.
|
||||
model ProductVariant {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
productId String @map("product_id") @db.Uuid
|
||||
|
||||
sku String @unique @db.VarChar(64)
|
||||
barcode String? @unique @db.VarChar(64)
|
||||
/// Denormalised "Black / M" for display and for order-line snapshots.
|
||||
title String @db.VarChar(255)
|
||||
|
||||
currency Currency @default(VND)
|
||||
/// All amounts are integers in the currency's minor unit.
|
||||
priceAmount Int @map("price_amount")
|
||||
salePriceAmount Int? @map("sale_price_amount")
|
||||
compareAtAmount Int? @map("compare_at_amount")
|
||||
/// Landed cost — admin only, never serialised to the storefront.
|
||||
costAmount Int? @map("cost_amount")
|
||||
|
||||
weightGrams Int? @map("weight_grams")
|
||||
lengthMm Int? @map("length_mm")
|
||||
widthMm Int? @map("width_mm")
|
||||
heightMm Int? @map("height_mm")
|
||||
|
||||
status VariantStatus @default(ACTIVE)
|
||||
position Int @default(0)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
|
||||
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
optionValues ProductVariantOptionValue[]
|
||||
stockLevels StockLevel[]
|
||||
stockMovements StockMovement[]
|
||||
|
||||
@@index([productId, position])
|
||||
@@index([status])
|
||||
@@map("product_variants")
|
||||
}
|
||||
|
||||
/// Resolves a variant to exactly one value per product option.
|
||||
///
|
||||
/// The (variantId, optionId) primary key is what enforces "a variant cannot
|
||||
/// have two colours" at the database level rather than in application code.
|
||||
model ProductVariantOptionValue {
|
||||
variantId String @map("variant_id") @db.Uuid
|
||||
optionId String @map("option_id") @db.Uuid
|
||||
optionValueId String @map("option_value_id") @db.Uuid
|
||||
|
||||
variant ProductVariant @relation(fields: [variantId], references: [id], onDelete: Cascade)
|
||||
option ProductOption @relation(fields: [optionId], references: [id], onDelete: Cascade)
|
||||
optionValue ProductOptionValue @relation(fields: [optionValueId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@id([variantId, optionId])
|
||||
@@index([optionValueId])
|
||||
@@map("product_variant_option_values")
|
||||
}
|
||||
|
||||
model ProductImage {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
productId String @map("product_id") @db.Uuid
|
||||
mediaId String @map("media_id") @db.Uuid
|
||||
|
||||
/// When set, the gallery swaps to these images once the shopper picks that
|
||||
/// option value (in practice: the colour).
|
||||
optionValueId String? @map("option_value_id") @db.Uuid
|
||||
position Int @default(0)
|
||||
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
media MediaAsset @relation(fields: [mediaId], references: [id], onDelete: Restrict)
|
||||
optionValue ProductOptionValue? @relation(fields: [optionValueId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([productId, mediaId, optionValueId])
|
||||
@@index([productId, position])
|
||||
@@map("product_images")
|
||||
}
|
||||
|
||||
/// Spec rows ("Material: 92% polyester"). Free-form on purpose: merchandisers
|
||||
/// add specs without a migration. Anything that must be *filtered* on graduates
|
||||
/// to a real column or a facet instead.
|
||||
model ProductAttribute {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
productId String @map("product_id") @db.Uuid
|
||||
|
||||
key String @db.VarChar(60)
|
||||
label String @db.VarChar(120)
|
||||
value String @db.VarChar(500)
|
||||
group String? @db.VarChar(60)
|
||||
position Int @default(0)
|
||||
isFilterable Boolean @default(false) @map("is_filterable")
|
||||
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([productId, key])
|
||||
@@index([key, value])
|
||||
@@map("product_attributes")
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Inventory
|
||||
// ===========================================================================
|
||||
|
||||
/// Stock is per (variant, location) from day one. A single warehouse today is
|
||||
/// just one row here; adding a second store or a 3PL later needs no migration
|
||||
/// of historical data.
|
||||
model InventoryLocation {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
name String @db.VarChar(120)
|
||||
code String @unique @db.VarChar(40)
|
||||
isDefault Boolean @default(false) @map("is_default")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
stockLevels StockLevel[]
|
||||
stockMovements StockMovement[]
|
||||
|
||||
@@map("inventory_locations")
|
||||
}
|
||||
|
||||
/// Current position. `available` is NOT stored — it is always onHand - reserved,
|
||||
/// computed at read time so the two numbers can never disagree.
|
||||
model StockLevel {
|
||||
variantId String @map("variant_id") @db.Uuid
|
||||
locationId String @map("location_id") @db.Uuid
|
||||
|
||||
onHand Int @default(0) @map("on_hand")
|
||||
/// Held by in-flight checkouts. Released on payment failure or expiry.
|
||||
reserved Int @default(0)
|
||||
reorderPoint Int? @map("reorder_point")
|
||||
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
variant ProductVariant @relation(fields: [variantId], references: [id], onDelete: Cascade)
|
||||
location InventoryLocation @relation(fields: [locationId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@id([variantId, locationId])
|
||||
@@index([locationId])
|
||||
@@map("stock_levels")
|
||||
}
|
||||
|
||||
enum StockMovementReason {
|
||||
PURCHASE_RECEIPT
|
||||
SALE
|
||||
RETURN
|
||||
MANUAL_ADJUSTMENT
|
||||
STOCK_TAKE
|
||||
TRANSFER_IN
|
||||
TRANSFER_OUT
|
||||
DAMAGE
|
||||
}
|
||||
|
||||
/// Append-only ledger. StockLevel is a projection of these rows, which is what
|
||||
/// makes "why is this number wrong?" an answerable question — and what makes a
|
||||
/// future extraction of Inventory into its own service straightforward.
|
||||
model StockMovement {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
variantId String @map("variant_id") @db.Uuid
|
||||
locationId String @map("location_id") @db.Uuid
|
||||
|
||||
/// Signed: negative for outbound.
|
||||
quantityDelta Int @map("quantity_delta")
|
||||
reason StockMovementReason
|
||||
referenceId String? @map("reference_id")
|
||||
note String?
|
||||
createdByUserId String? @map("created_by_user_id") @db.Uuid
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
|
||||
variant ProductVariant @relation(fields: [variantId], references: [id], onDelete: Restrict)
|
||||
location InventoryLocation @relation(fields: [locationId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([variantId, createdAt])
|
||||
@@index([referenceId])
|
||||
@@map("stock_movements")
|
||||
}
|
||||
Reference in New Issue
Block a user