Files
web_sport/apps/api/prisma/schema.prisma
T

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")
}