1349 lines
49 KiB
Plaintext
1349 lines
49 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[]
|
||
/// 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")
|
||
}
|