1034 lines
37 KiB
Plaintext
1034 lines
37 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[]
|
|
orders Order[]
|
|
|
|
@@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?
|
|
|
|
/// Seeds generated variant SKUs, e.g. "VEL-NOC" -> "VEL-NOC-BLACK-M".
|
|
///
|
|
/// Stored rather than re-derived, because adding a colourway a year later
|
|
/// must extend the same SKU family. Falling back to the slug produced
|
|
/// "AO-GIO-CHAY-DEM-NOCTURNE-BLACK-XL" sitting next to "VEL-NOC-BLACK-M" on
|
|
/// the same product.
|
|
skuPrefix String? @map("sku_prefix") @db.VarChar(24)
|
|
|
|
/// ---- 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)
|
|
|
|
searchDocuments SearchDocument[]
|
|
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[]
|
|
orderLines OrderLine[]
|
|
|
|
@@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")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Commerce — orders (M5)
|
|
//
|
|
// Carts are deliberately absent: a guest cart lives in Redis and holds only
|
|
// variant ids and quantities (ADR-0010). Prices are recomputed from the catalog
|
|
// on every read, so a cart can never carry a stale or tampered price into an
|
|
// order.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
enum OrderStatus {
|
|
/// Placed, awaiting payment. Stock is reserved from this moment.
|
|
PENDING
|
|
CONFIRMED
|
|
FULFILLED
|
|
COMPLETED
|
|
CANCELLED
|
|
}
|
|
|
|
enum PaymentStatus {
|
|
UNPAID
|
|
PAID
|
|
PARTIALLY_REFUNDED
|
|
REFUNDED
|
|
}
|
|
|
|
enum FulfillmentStatus {
|
|
UNFULFILLED
|
|
PARTIALLY_FULFILLED
|
|
FULFILLED
|
|
}
|
|
|
|
/// A placed order.
|
|
///
|
|
/// Every customer-facing and catalog-facing value is SNAPSHOT here rather than
|
|
/// joined at read time. An order is a record of what was agreed, and it has to
|
|
/// stay readable after the product is renamed, repriced, archived or the
|
|
/// customer edits their address book. The `variantId` FK exists for reporting
|
|
/// and returns, never for rendering the order.
|
|
model Order {
|
|
id String @id @default(uuid(7)) @db.Uuid
|
|
|
|
/// Human-facing reference, formatted for display as "SP-000123". Kept as an
|
|
/// integer so it is monotonic and cheap to look up; the prefix is
|
|
/// presentation and lives in the mapper.
|
|
number Int @unique @default(autoincrement())
|
|
|
|
/// Null for a guest checkout. Guests are identified by email + order number.
|
|
customerId String? @map("customer_id") @db.Uuid
|
|
|
|
email String @db.VarChar(255)
|
|
phone String @db.VarChar(20)
|
|
|
|
status OrderStatus @default(PENDING)
|
|
paymentStatus PaymentStatus @default(UNPAID) @map("payment_status")
|
|
fulfillmentStatus FulfillmentStatus @default(UNFULFILLED) @map("fulfillment_status")
|
|
|
|
/// ---- Money, integer minor units throughout (ADR-0011) ------------------
|
|
currency Currency @default(VND)
|
|
subtotalAmount Int @map("subtotal_amount")
|
|
/// Zero until promotions land (M7); the column exists so the total is always
|
|
/// the sum of named parts rather than an unexplained number.
|
|
discountAmount Int @default(0) @map("discount_amount")
|
|
shippingAmount Int @default(0) @map("shipping_amount")
|
|
taxAmount Int @default(0) @map("tax_amount")
|
|
totalAmount Int @map("total_amount")
|
|
|
|
/// ---- Shipping address, snapshot ----------------------------------------
|
|
shipFullName String @map("ship_full_name") @db.VarChar(160)
|
|
shipPhone String @map("ship_phone") @db.VarChar(20)
|
|
shipLine1 String @map("ship_line1") @db.VarChar(255)
|
|
shipLine2 String? @map("ship_line2") @db.VarChar(255)
|
|
shipWard String? @map("ship_ward") @db.VarChar(120)
|
|
shipDistrict String? @map("ship_district") @db.VarChar(120)
|
|
shipProvince String @map("ship_province") @db.VarChar(120)
|
|
shipCountryCode String @default("VN") @map("ship_country_code") @db.Char(2)
|
|
shipPostalCode String? @map("ship_postal_code") @db.VarChar(20)
|
|
|
|
customerNote String? @map("customer_note") @db.VarChar(1000)
|
|
|
|
placedAt DateTime @default(now()) @map("placed_at") @db.Timestamptz(3)
|
|
confirmedAt DateTime? @map("confirmed_at") @db.Timestamptz(3)
|
|
cancelledAt DateTime? @map("cancelled_at") @db.Timestamptz(3)
|
|
cancelReason String? @map("cancel_reason") @db.VarChar(500)
|
|
|
|
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: SetNull)
|
|
lines OrderLine[]
|
|
|
|
@@index([customerId])
|
|
@@index([email])
|
|
@@index([status, placedAt])
|
|
@@map("orders")
|
|
}
|
|
|
|
/// One purchased variant, frozen at the moment of purchase.
|
|
model OrderLine {
|
|
id String @id @default(uuid(7)) @db.Uuid
|
|
orderId String @map("order_id") @db.Uuid
|
|
|
|
/// Nulled rather than cascading if a variant is ever hard-deleted — losing
|
|
/// the reporting link is survivable, losing the order line is not.
|
|
variantId String? @map("variant_id") @db.Uuid
|
|
|
|
/// ---- Snapshot ----------------------------------------------------------
|
|
productName String @map("product_name") @db.VarChar(255)
|
|
variantTitle String @map("variant_title") @db.VarChar(255)
|
|
sku String @db.VarChar(64)
|
|
imageUrl String? @map("image_url") @db.VarChar(500)
|
|
|
|
unitAmount Int @map("unit_amount")
|
|
quantity Int
|
|
lineAmount Int @map("line_amount")
|
|
|
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
|
|
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
|
variant ProductVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull)
|
|
|
|
@@index([orderId])
|
|
@@index([variantId])
|
|
@@map("order_lines")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Search (M6)
|
|
//
|
|
// Owned exclusively by SearchModule. It is a *projection*: every row is
|
|
// derivable from the catalog and can be rebuilt from scratch at any time, which
|
|
// is what lets the module be extracted later without taking catalog tables with
|
|
// it (ADR-0012).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// One searchable document per product per locale.
|
|
///
|
|
/// The text is split by weight rather than concatenated, because a match on a
|
|
/// product's name should outrank a match buried in its description. The
|
|
/// `document` column is GENERATED from these three by the database — see the
|
|
/// migration — so an index can never drift from the text it indexes.
|
|
model SearchDocument {
|
|
productId String @map("product_id") @db.Uuid
|
|
locale Locale
|
|
|
|
/// Weight A — the product name.
|
|
title String @db.VarChar(255)
|
|
/// Weight B — brand, category, colourways, sizes, SKUs. Short, high-signal.
|
|
keywords String
|
|
/// Weight C — descriptions. Long, low-signal, still worth matching.
|
|
body String
|
|
|
|
/// Maintained by Postgres from the columns above. Declared here only so
|
|
/// Prisma knows it exists and leaves it alone; it is read and written with
|
|
/// raw SQL in the repository.
|
|
document Unsupported("tsvector")?
|
|
|
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
|
|
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([productId, locale])
|
|
@@map("search_documents")
|
|
}
|