This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:22 +07:00
parent 3e5d38ec18
commit 3d6b0e0d4e
145 changed files with 7817 additions and 801 deletions
+215 -51
View File
@@ -1,13 +1,13 @@
// ---------------------------------------------------------------------------
// Sport Store — Prisma schema
//
// SCOPE OF THIS FILE (milestone 0)
// SCOPE OF THIS FILE (milestones 0-1)
// Identity + RBAC, catalog (Product / ProductVariant / options / images /
// attributes), taxonomy (brand / category / collection), media metadata and
// the inventory ledger.
// 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 1.
// They are intentionally absent so the first migration stays reviewable.
// 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.
@@ -50,11 +50,11 @@ enum UserStatus {
/// 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
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")
email String @unique @db.VarChar(255)
passwordHash String? @map("password_hash")
type UserType
status UserStatus @default(ACTIVE)
@@ -70,7 +70,7 @@ model User {
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)
avatar MediaAsset? @relation("UserAvatar", fields: [avatarId], references: [id], onDelete: SetNull)
roles UserRole[]
sessions Session[]
customer Customer?
@@ -216,20 +216,20 @@ 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)
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)
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)
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)
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")
@@ -257,16 +257,16 @@ enum MediaKind {
/// 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?
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)
blurDataUrl String? @map("blur_data_url")
altText String? @map("alt_text") @db.VarChar(255)
/// Free-form: original filename, uploader, EXIF subset, …
metadata Json?
@@ -274,17 +274,37 @@ model MediaAsset {
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
productImages ProductImage[]
brandLogos Brand[] @relation("BrandLogo")
categoryImages Category[] @relation("CategoryImage")
collectionBanners Collection[] @relation("CollectionBanner")
productImages ProductImage[]
brandLogos Brand[] @relation("BrandLogo")
categoryImages Category[] @relation("CategoryImage")
collectionBanners Collection[] @relation("CollectionBanner")
optionValueSwatches ProductOptionValue[] @relation("OptionValueSwatch")
userAvatars User[] @relation("UserAvatar")
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
// ===========================================================================
@@ -303,8 +323,9 @@ model Brand {
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[]
logo MediaAsset? @relation("BrandLogo", fields: [logoId], references: [id], onDelete: SetNull)
products Product[]
translations BrandTranslation[]
@@map("brands")
}
@@ -333,10 +354,11 @@ model Category {
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[]
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])
@@ -371,8 +393,9 @@ model Collection {
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[]
banner MediaAsset? @relation("CollectionBanner", fields: [bannerId], references: [id], onDelete: SetNull)
products ProductCollection[]
translations CollectionTranslation[]
@@index([isActive, startsAt, endsAt])
@@map("collections")
@@ -453,19 +476,38 @@ model Product {
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)
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)
@@ -486,6 +528,7 @@ model ProductOption {
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
values ProductOptionValue[]
variantLinks ProductVariantOptionValue[]
translations ProductOptionTranslation[]
@@unique([productId, key])
@@index([productId, position])
@@ -504,10 +547,11 @@ model ProductOptionValue {
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)
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])
@@ -525,13 +569,13 @@ model ProductVariant {
/// Denormalised "Black / M" for display and for order-line snapshots.
title String @db.VarChar(255)
currency Currency @default(VND)
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")
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")
costAmount Int? @map("cost_amount")
weightGrams Int? @map("weight_grams")
lengthMm Int? @map("length_mm")
@@ -606,7 +650,8 @@ model ProductAttribute {
position Int @default(0)
isFilterable Boolean @default(false) @map("is_filterable")
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
translations ProductAttributeTranslation[]
@@unique([productId, key])
@@index([key, value])
@@ -692,3 +737,122 @@ model StockMovement {
@@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")
}