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