Files
web_sport/apps/api/prisma/schema.prisma
T
2026-08-13 23:20:23 +07:00

1349 lines
49 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ---------------------------------------------------------------------------
// 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[]
/// Reviews this operator approved or rejected.
moderatedReviews Review[]
/// Pages and posts this operator wrote.
authoredContent ContentEntry[]
@@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")
contentCovers ContentEntry[] @relation("ContentCover")
@@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[]
discounts DiscountCollection[]
@@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")
/// Rating aggregate, stored as sum + count rather than an average.
///
/// Two integers cannot drift the way a stored float can: approving one more
/// 4-star review is `sum + 4, count + 1`, which is exact and reversible.
/// A stored average would have to be recomputed from scratch to stay honest,
/// and would quietly accumulate rounding error if it were not.
///
/// Derived, never authored — `ReviewsService.recomputeRating()` is the only
/// writer. Treat a manual UPDATE of these columns as a bug.
ratingSum Int @default(0) @map("rating_sum")
ratingCount Int @default(0) @map("rating_count")
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[]
reviews Review[]
discounts DiscountProduct[]
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[]
redemptions DiscountRedemption[]
@@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)
review Review?
@@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")
}
// ---------------------------------------------------------------------------
// Discounts (M7)
//
// One entity, two entry points. A promotion applies itself when its conditions
// match; a coupon does the same but only once someone types a code. Modelling
// them separately would mean two rule engines that must agree about stacking,
// limits and rounding — and they would not, for long.
// ---------------------------------------------------------------------------
enum DiscountTrigger {
/// Applies on its own when conditions match.
AUTOMATIC
/// Requires the shopper to enter a code.
CODE
}
enum DiscountType {
/// `value` is whole percent, 1–100.
PERCENTAGE
/// `value` is an amount in minor units (ADR-0011).
FIXED_AMOUNT
}
enum DiscountScope {
/// Applies against the whole order subtotal.
ORDER
/// Applies only to the lines this discount targets.
PRODUCT
}
model Discount {
id String @id @default(uuid(7)) @db.Uuid
/// Null for automatic promotions. Uppercased on write so lookup is
/// case-insensitive without a functional index.
code String? @unique @db.VarChar(40)
trigger DiscountTrigger
type DiscountType
scope DiscountScope @default(ORDER)
/// Percent (1–100) or minor units, depending on `type`.
value Int
/// ---- Conditions --------------------------------------------------------
minSubtotalAmount Int? @map("min_subtotal_amount")
startsAt DateTime? @map("starts_at") @db.Timestamptz(3)
endsAt DateTime? @map("ends_at") @db.Timestamptz(3)
isActive Boolean @default(true) @map("is_active")
/// ---- Limits ------------------------------------------------------------
///
/// `usageCount` is incremented by a conditional UPDATE guarded on the limit,
/// never read-then-written — the same lesson stock reservation learned the
/// hard way. Two shoppers redeeming the last use of a code must not both win.
usageLimit Int? @map("usage_limit")
usageCount Int @default(0) @map("usage_count")
/// ---- Combination -------------------------------------------------------
///
/// Lower `priority` is evaluated first. A non-stackable discount that applies
/// ends evaluation, so "best single offer" and "stack everything" are both
/// expressible without a second engine.
stackable Boolean @default(false)
priority Int @default(100)
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)
translations DiscountTranslation[]
products DiscountProduct[]
collections DiscountCollection[]
redemptions DiscountRedemption[]
@@index([trigger, isActive])
@@index([code])
@@map("discounts")
}
/// The customer-facing name, per locale. "Giảm 20%" is not a translation of an
/// internal label — it is what appears on the order summary.
model DiscountTranslation {
discountId String @map("discount_id") @db.Uuid
locale Locale
name String @db.VarChar(120)
description String? @db.VarChar(500)
discount Discount @relation(fields: [discountId], references: [id], onDelete: Cascade)
@@id([discountId, locale])
@@map("discount_translations")
}
/// Targets for a PRODUCT-scoped discount. An empty target set on a PRODUCT
/// scope means it matches nothing — safer than matching everything.
model DiscountProduct {
discountId String @map("discount_id") @db.Uuid
productId String @map("product_id") @db.Uuid
discount Discount @relation(fields: [discountId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@id([discountId, productId])
@@index([productId])
@@map("discount_products")
}
model DiscountCollection {
discountId String @map("discount_id") @db.Uuid
collectionId String @map("collection_id") @db.Uuid
discount Discount @relation(fields: [discountId], references: [id], onDelete: Cascade)
collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade)
@@id([discountId, collectionId])
@@index([collectionId])
@@map("discount_collections")
}
/// One row per discount actually applied to an order.
///
/// This is the record of what was granted, not a projection of it. The amount
/// is snapshot for the same reason order lines are (ADR-0018): changing a
/// discount's value later must not rewrite what a customer already received.
model DiscountRedemption {
id String @id @default(uuid(7)) @db.Uuid
discountId String @map("discount_id") @db.Uuid
orderId String @map("order_id") @db.Uuid
/// Snapshot, in minor units.
amount Int
/// Snapshot of the code as typed, so a renamed code stays traceable.
code String? @db.VarChar(40)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
discount Discount @relation(fields: [discountId], references: [id], onDelete: Restrict)
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@unique([orderId, discountId])
@@index([discountId])
@@map("discount_redemptions")
}
// ---------------------------------------------------------------------------
// Reviews (M7)
//
// Owned exclusively by ReviewsModule.
// ---------------------------------------------------------------------------
enum ReviewStatus {
PENDING
APPROVED
REJECTED
}
/// One review of one purchased item.
///
/// Anchored to an OrderLine rather than to a product + email pair, which is the
/// whole design. "Verified purchase" is then not a flag somebody sets, or a
/// lookup that can be spoofed by typing a stranger's email — it is the primary
/// key relationship. You cannot review what you did not buy, because there is
/// no row to hang the review on, and the unique constraint gives "one review
/// per item purchased" for free rather than as a rule someone must enforce.
///
/// The cost is that a shopper cannot review a product they own but bought
/// elsewhere. That is the correct trade for a store this size: the alternative
/// is an open submission endpoint, which is a spam surface that needs a
/// moderation team rather than a moderation screen.
model Review {
id String @id @default(uuid(7)) @db.Uuid
productId String @map("product_id") @db.Uuid
/// The proof of purchase. Unique: one review per item bought.
orderLineId String @unique @map("order_line_id") @db.Uuid
/// 1..5. Constrained in the database too — a 7-star review would silently
/// corrupt every aggregate that sums this column.
rating Int
title String? @db.VarChar(140)
body String? @db.VarChar(2000)
/// Snapshot, like the order's address. The reviewer may later change their
/// account name, or never have had one; the byline on a published review
/// must not change underneath it.
authorName String @map("author_name") @db.VarChar(120)
status ReviewStatus @default(PENDING)
/// ---- Moderation --------------------------------------------------------
moderatedAt DateTime? @map("moderated_at") @db.Timestamptz(3)
moderatedByUserId String? @map("moderated_by_user_id") @db.Uuid
/// Why it was rejected. Internal — never rendered to the shopper.
moderationNote String? @map("moderation_note") @db.VarChar(500)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
orderLine OrderLine @relation(fields: [orderLineId], references: [id], onDelete: Cascade)
moderatedBy User? @relation(fields: [moderatedByUserId], references: [id], onDelete: SetNull)
/// The storefront query: approved reviews for one product, newest first.
@@index([productId, status, createdAt])
/// The moderation queue: oldest pending first, so nothing waits forever.
@@index([status, createdAt])
@@map("reviews")
}
// ---------------------------------------------------------------------------
// Content (M7)
//
// Owned exclusively by CmsModule.
// ---------------------------------------------------------------------------
enum ContentType {
PAGE
POST
}
enum ContentStatus {
DRAFT
PUBLISHED
}
/// A page or a post.
///
/// One entity with a `type` rather than `pages` and `blog_posts` side by side,
/// for the same reason promotions and coupons are one (ADR-0020): the shared
/// surface — per-locale slug, title, body, SEO, publish state, soft delete — is
/// nearly all of it, and two tables means two admin screens and two sets of
/// publishing rules that must agree forever.
///
/// What actually differs is placement, and that is what `type` says: a POST is
/// listed in a feed newest-first and carries an excerpt and a cover; a PAGE is
/// addressed directly from the footer and is never listed.
///
/// Deliberately NOT a page builder. The body is Markdown, one column. The
/// moment this grows a block tree it becomes a layout tool that the storefront
/// design has to obey, and the design stops being code.
model ContentEntry {
id String @id @default(uuid(7)) @db.Uuid
type ContentType
status ContentStatus @default(DRAFT)
/// When it went live. Set on the first publish and kept afterwards, so
/// un-publishing and re-publishing does not reorder the feed or rewrite a
/// date readers may already have seen.
publishedAt DateTime? @map("published_at") @db.Timestamptz(3)
coverImageId String? @map("cover_image_id") @db.Uuid
authorUserId String? @map("author_user_id") @db.Uuid
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)
coverImage MediaAsset? @relation("ContentCover", fields: [coverImageId], references: [id], onDelete: SetNull)
author User? @relation(fields: [authorUserId], references: [id], onDelete: SetNull)
translations ContentEntryTranslation[]
/// The feed query: published posts, newest first.
@@index([type, status, publishedAt])
@@map("content_entries")
}
/// Per-locale slugs, for the same reason products have them: `/en/blog/
/// how-to-layer` and `/vi/blog/cach-phoi-do-mua-lanh` are the SEO surface.
model ContentEntryTranslation {
entryId String @map("entry_id") @db.Uuid
locale Locale
slug String @db.VarChar(200)
title String @db.VarChar(255)
/// Feed summary. Posts only in practice; nullable rather than required so a
/// page is not forced to invent one.
excerpt String? @db.VarChar(500)
/// Markdown. Rendered to React elements, never injected as HTML — see the
/// storefront's <Markdown> component.
body String
metaTitle String? @map("meta_title") @db.VarChar(255)
metaDescription String? @map("meta_description")
entry ContentEntry @relation(fields: [entryId], references: [id], onDelete: Cascade)
@@id([entryId, locale])
@@unique([locale, slug])
@@map("content_entry_translations")
}