859 lines
30 KiB
Plaintext
859 lines
30 KiB
Plaintext
// ---------------------------------------------------------------------------
|
|
// Sport Store — Prisma schema
|
|
//
|
|
// SCOPE OF THIS FILE (milestones 0-1)
|
|
// Identity + RBAC, catalog (Product / ProductVariant / options / images /
|
|
// attributes), taxonomy (brand / category / collection), per-locale content
|
|
// translations, media metadata and the inventory ledger.
|
|
//
|
|
// Cart, checkout, order, payment, promotion and review tables are milestone 5.
|
|
// They are intentionally absent so the migration history 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")
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Localisation
|
|
// ===========================================================================
|
|
|
|
enum Locale {
|
|
VI
|
|
EN
|
|
}
|
|
|
|
// Translation tables follow one shape throughout: a composite (entityId, locale)
|
|
// primary key, plus a per-locale unique slug where the entity has a URL.
|
|
//
|
|
// Typed tables rather than a generic (entityType, field, value) table: a generic
|
|
// table cannot enforce "slug is unique within a locale", cannot be indexed
|
|
// usefully, and returns `string | undefined` for everything. Six small tables
|
|
// with real constraints beat one clever one.
|
|
//
|
|
// The base row keeps the canonical values. A missing translation falls back to
|
|
// them field by field, so an untranslated description never renders as blank.
|
|
|
|
// ===========================================================================
|
|
// 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[]
|
|
translations BrandTranslation[]
|
|
|
|
@@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[]
|
|
translations CategoryTranslation[]
|
|
|
|
@@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[]
|
|
translations CollectionTranslation[]
|
|
|
|
@@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?
|
|
|
|
/// ---- Read-model projection, derived from this product's variants --------
|
|
///
|
|
/// Denormalised deliberately. Sorting a listing by price and rendering a
|
|
/// price facet both need MIN/MAX across variants, and no ORM can express
|
|
/// "order by the minimum price of a related collection" — the alternatives
|
|
/// were raw SQL for every listing query or a search index we do not need yet
|
|
/// (ADR-0012).
|
|
///
|
|
/// These columns are ALWAYS derived, never authored. `ProductsService
|
|
/// .recomputePricing()` is the single writer; every variant mutation in M3
|
|
/// must call it. Treat a manual UPDATE of these columns as a bug.
|
|
minPriceAmount Int? @map("min_price_amount")
|
|
maxPriceAmount Int? @map("max_price_amount")
|
|
isOnSale Boolean @default(false) @map("is_on_sale")
|
|
inStock Boolean @default(false) @map("in_stock")
|
|
|
|
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[]
|
|
translations ProductTranslation[]
|
|
|
|
@@index([status, publishedAt])
|
|
@@index([status, minPriceAmount])
|
|
@@index([status, inStock])
|
|
@@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[]
|
|
translations ProductOptionTranslation[]
|
|
|
|
@@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[]
|
|
translations ProductOptionValueTranslation[]
|
|
|
|
@@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)
|
|
translations ProductAttributeTranslation[]
|
|
|
|
@@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")
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Translations
|
|
// ===========================================================================
|
|
|
|
model BrandTranslation {
|
|
brandId String @map("brand_id") @db.Uuid
|
|
locale Locale
|
|
|
|
name String @db.VarChar(160)
|
|
slug String @db.VarChar(180)
|
|
description String?
|
|
metaTitle String? @map("meta_title") @db.VarChar(255)
|
|
metaDescription String? @map("meta_description")
|
|
|
|
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([brandId, locale])
|
|
@@unique([locale, slug])
|
|
@@map("brand_translations")
|
|
}
|
|
|
|
model CategoryTranslation {
|
|
categoryId String @map("category_id") @db.Uuid
|
|
locale Locale
|
|
|
|
name String @db.VarChar(160)
|
|
slug String @db.VarChar(180)
|
|
description String?
|
|
metaTitle String? @map("meta_title") @db.VarChar(255)
|
|
metaDescription String? @map("meta_description")
|
|
|
|
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([categoryId, locale])
|
|
@@unique([locale, slug])
|
|
@@map("category_translations")
|
|
}
|
|
|
|
model CollectionTranslation {
|
|
collectionId String @map("collection_id") @db.Uuid
|
|
locale Locale
|
|
|
|
name String @db.VarChar(160)
|
|
slug String @db.VarChar(180)
|
|
description String?
|
|
metaTitle String? @map("meta_title") @db.VarChar(255)
|
|
metaDescription String? @map("meta_description")
|
|
|
|
collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([collectionId, locale])
|
|
@@unique([locale, slug])
|
|
@@map("collection_translations")
|
|
}
|
|
|
|
/// Per-locale slugs matter here more than anywhere else: `/en/products/
|
|
/// mens-running-tee` and `/vi/products/ao-chay-bo-nam` are the actual SEO
|
|
/// surface of the store.
|
|
model ProductTranslation {
|
|
productId String @map("product_id") @db.Uuid
|
|
locale Locale
|
|
|
|
name String @db.VarChar(255)
|
|
slug String @db.VarChar(280)
|
|
shortDescription String? @map("short_description") @db.VarChar(500)
|
|
description String?
|
|
metaTitle String? @map("meta_title") @db.VarChar(255)
|
|
metaDescription String? @map("meta_description")
|
|
|
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([productId, locale])
|
|
@@unique([locale, slug])
|
|
@@index([locale, name])
|
|
@@map("product_translations")
|
|
}
|
|
|
|
model ProductOptionTranslation {
|
|
optionId String @map("option_id") @db.Uuid
|
|
locale Locale
|
|
|
|
name String @db.VarChar(60)
|
|
|
|
option ProductOption @relation(fields: [optionId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([optionId, locale])
|
|
@@map("product_option_translations")
|
|
}
|
|
|
|
/// Colour names are the most visible translated strings on a listing page —
|
|
/// a grid of swatches labelled "Black" in a Vietnamese store reads as broken.
|
|
model ProductOptionValueTranslation {
|
|
optionValueId String @map("option_value_id") @db.Uuid
|
|
locale Locale
|
|
|
|
label String @db.VarChar(80)
|
|
|
|
optionValue ProductOptionValue @relation(fields: [optionValueId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([optionValueId, locale])
|
|
@@map("product_option_value_translations")
|
|
}
|
|
|
|
/// Spec-table rows are user-visible content ("Chất liệu: 92% Polyester"), so
|
|
/// both the label and the value need translating — a Vietnamese PDP showing an
|
|
/// English spec table is a half-finished translation.
|
|
model ProductAttributeTranslation {
|
|
attributeId String @map("attribute_id") @db.Uuid
|
|
locale Locale
|
|
|
|
label String @db.VarChar(120)
|
|
value String @db.VarChar(500)
|
|
|
|
attribute ProductAttribute @relation(fields: [attributeId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([attributeId, locale])
|
|
@@map("product_attribute_translations")
|
|
}
|