Stage M1
This commit is contained in:
+215
-51
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user