// --------------------------------------------------------------------------- // Sport Store — Prisma schema // // SCOPE OF THIS FILE (milestones 0-1) // Identity + RBAC, catalog (Product / ProductVariant / options / images / // attributes), taxonomy (brand / category / collection), per-locale content // translations, media metadata and the inventory ledger. // // Cart, checkout, order, payment, promotion and review tables are milestone 5. // They are intentionally absent so the migration history stays reviewable. // // CONVENTIONS // - Table names: snake_case plural (@@map). Prisma models: PascalCase singular. // - Ids: UUID v7 — time-sortable, so they index like a sequence but leak no // row counts and stay safe to expose in URLs. // - Money: INTEGER in the currency's minor unit. Never Float, never Decimal // round-trips through JS. VND has no minor unit, so 250000 means ₫250.000. // - Timestamps: `timestamptz`. The database always stores UTC. // - Soft delete only where history matters (products, variants); everywhere // else a hard delete is correct and simpler. // --------------------------------------------------------------------------- generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } // =========================================================================== // Identity & access control // =========================================================================== enum UserType { CUSTOMER STAFF ADMIN SUPER_ADMIN } enum UserStatus { ACTIVE INVITED SUSPENDED } /// Every human in the system — shoppers and operators alike — is a User row. /// The `type` column decides which token audience the account may authenticate /// against; RBAC decides what it may then do. model User { id String @id @default(uuid(7)) @db.Uuid /// Always normalised to lowercase before write (see @sport/validation), so a /// plain unique index is enough and no citext extension is required. email String @unique @db.VarChar(255) passwordHash String? @map("password_hash") type UserType status UserStatus @default(ACTIVE) firstName String? @map("first_name") @db.VarChar(80) lastName String? @map("last_name") @db.VarChar(80) phone String? @db.VarChar(20) avatarId String? @map("avatar_id") @db.Uuid emailVerifiedAt DateTime? @map("email_verified_at") @db.Timestamptz(3) lastLoginAt DateTime? @map("last_login_at") @db.Timestamptz(3) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3) avatar MediaAsset? @relation("UserAvatar", fields: [avatarId], references: [id], onDelete: SetNull) roles UserRole[] sessions Session[] customer Customer? auditLogs AuditLog[] @@index([type, status]) @@index([createdAt]) @@map("users") } /// Roles are DATA: a SUPER_ADMIN can create "Warehouse Supervisor" at runtime /// without a deploy. Only `isSystem` roles are protected from deletion. model Role { id String @id @default(uuid(7)) @db.Uuid key String @unique @db.VarChar(64) name String @db.VarChar(120) description String? isSystem Boolean @default(false) @map("is_system") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) permissions RolePermission[] users UserRole[] @@map("roles") } /// Permissions are CODE: the catalog in @sport/types is the source of truth and /// the seed reconciles this table against it. Nothing creates permissions at /// runtime — that would let the database drift from the guards. model Permission { id String @id @default(uuid(7)) @db.Uuid key String @unique @db.VarChar(64) resource String @db.VarChar(40) action String @db.VarChar(40) description String? roles RolePermission[] @@index([resource]) @@map("permissions") } model RolePermission { roleId String @map("role_id") @db.Uuid permissionId String @map("permission_id") @db.Uuid role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade) @@id([roleId, permissionId]) @@index([permissionId]) @@map("role_permissions") } model UserRole { userId String @map("user_id") @db.Uuid roleId String @map("role_id") @db.Uuid assignedAt DateTime @default(now()) @map("assigned_at") @db.Timestamptz(3) user User @relation(fields: [userId], references: [id], onDelete: Cascade) role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) @@id([userId, roleId]) @@index([roleId]) @@map("user_roles") } /// One row per refresh-token family (i.e. per signed-in device). /// /// The token itself is never stored — only a SHA-256 hash. On refresh the row /// is rotated: `replacedById` points at the successor. Presenting a token whose /// row is already replaced means the token leaked, so the entire family is /// revoked. This is why the column exists at all. model Session { id String @id @default(uuid(7)) @db.Uuid userId String @map("user_id") @db.Uuid refreshTokenHash String @unique @map("refresh_token_hash") @db.VarChar(64) familyId String @map("family_id") @db.Uuid replacedById String? @unique @map("replaced_by_id") @db.Uuid expiresAt DateTime @map("expires_at") @db.Timestamptz(3) revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3) userAgent String? @map("user_agent") ipAddress String? @map("ip_address") @db.VarChar(45) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) user User @relation(fields: [userId], references: [id], onDelete: Cascade) replacedBy Session? @relation("SessionRotation", fields: [replacedById], references: [id], onDelete: SetNull) replaces Session? @relation("SessionRotation") @@index([userId, revokedAt]) @@index([familyId]) @@index([expiresAt]) @@map("sessions") } model AuditLog { id String @id @default(uuid(7)) @db.Uuid actorUserId String? @map("actor_user_id") @db.Uuid action String @db.VarChar(80) resourceType String @map("resource_type") @db.VarChar(60) resourceId String? @map("resource_id") /// Before/after snapshot. Append-only; never updated. changes Json? ipAddress String? @map("ip_address") @db.VarChar(45) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) actor User? @relation(fields: [actorUserId], references: [id], onDelete: SetNull) @@index([resourceType, resourceId]) @@index([actorUserId, createdAt]) @@map("audit_logs") } // =========================================================================== // Customers // =========================================================================== /// Shopper-specific profile, split from User so that back-office accounts carry /// none of it and customer data can later move behind a stricter access policy. model Customer { id String @id @default(uuid(7)) @db.Uuid userId String @unique @map("user_id") @db.Uuid acceptsMarketing Boolean @default(false) @map("accepts_marketing") dateOfBirth DateTime? @map("date_of_birth") @db.Date note String? createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) user User @relation(fields: [userId], references: [id], onDelete: Cascade) addresses Address[] @@map("customers") } model Address { id String @id @default(uuid(7)) @db.Uuid customerId String @map("customer_id") @db.Uuid fullName String @map("full_name") @db.VarChar(160) phone String @db.VarChar(20) line1 String @db.VarChar(255) line2 String? @db.VarChar(255) /// Vietnamese administrative divisions. Codes are kept alongside names so a /// later shipping-provider integration can map them without re-collecting. ward String? @db.VarChar(120) wardCode String? @map("ward_code") @db.VarChar(20) district String? @db.VarChar(120) districtCode String? @map("district_code") @db.VarChar(20) province String @db.VarChar(120) provinceCode String? @map("province_code") @db.VarChar(20) countryCode String @default("VN") @map("country_code") @db.Char(2) postalCode String? @map("postal_code") @db.VarChar(20) isDefaultShipping Boolean @default(false) @map("is_default_shipping") isDefaultBilling Boolean @default(false) @map("is_default_billing") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade) @@index([customerId]) @@map("addresses") } // =========================================================================== // Media // =========================================================================== enum MediaKind { IMAGE VIDEO DOCUMENT } /// Metadata only. The bytes live in R2/S3 under `storageKey`; PostgreSQL never /// stores binary. Public URLs are composed at read time from STORAGE_PUBLIC_URL /// + storageKey, so changing CDN or bucket is config, not a data migration. model MediaAsset { id String @id @default(uuid(7)) @db.Uuid kind MediaKind @default(IMAGE) storageKey String @unique @map("storage_key") @db.VarChar(512) mimeType String @map("mime_type") @db.VarChar(120) sizeBytes Int @map("size_bytes") width Int? height Int? /// Base64 LQIP, a few hundred bytes. Cheap enough to store inline. blurDataUrl String? @map("blur_data_url") altText String? @map("alt_text") @db.VarChar(255) /// Free-form: original filename, uploader, EXIF subset, … metadata Json? uploadedByUserId String? @map("uploaded_by_user_id") @db.Uuid createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) productImages ProductImage[] brandLogos Brand[] @relation("BrandLogo") categoryImages Category[] @relation("CategoryImage") collectionBanners Collection[] @relation("CollectionBanner") optionValueSwatches ProductOptionValue[] @relation("OptionValueSwatch") userAvatars User[] @relation("UserAvatar") @@index([kind, createdAt]) @@map("media_assets") } // =========================================================================== // Localisation // =========================================================================== enum Locale { VI EN } // Translation tables follow one shape throughout: a composite (entityId, locale) // primary key, plus a per-locale unique slug where the entity has a URL. // // Typed tables rather than a generic (entityType, field, value) table: a generic // table cannot enforce "slug is unique within a locale", cannot be indexed // usefully, and returns `string | undefined` for everything. Six small tables // with real constraints beat one clever one. // // The base row keeps the canonical values. A missing translation falls back to // them field by field, so an untranslated description never renders as blank. // =========================================================================== // Taxonomy // =========================================================================== model Brand { id String @id @default(uuid(7)) @db.Uuid name String @db.VarChar(160) slug String @unique @db.VarChar(180) description String? logoId String? @map("logo_id") @db.Uuid isActive Boolean @default(true) @map("is_active") metaTitle String? @map("meta_title") @db.VarChar(255) metaDescription String? @map("meta_description") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) logo MediaAsset? @relation("BrandLogo", fields: [logoId], references: [id], onDelete: SetNull) products Product[] translations BrandTranslation[] @@map("brands") } /// Hierarchical merchandising tree (Men > Running > Shoes). /// /// `path` is a materialised path ("men/running/shoes") so an entire subtree is /// one indexed `LIKE 'men/running%'` query instead of a recursive CTE per page /// view. `depth` lets navigation queries stop at the level they render. model Category { id String @id @default(uuid(7)) @db.Uuid parentId String? @map("parent_id") @db.Uuid name String @db.VarChar(160) slug String @db.VarChar(180) path String @unique @db.VarChar(512) depth Int @default(0) position Int @default(0) description String? imageId String? @map("image_id") @db.Uuid isActive Boolean @default(true) @map("is_active") metaTitle String? @map("meta_title") @db.VarChar(255) metaDescription String? @map("meta_description") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: Restrict) children Category[] @relation("CategoryTree") image MediaAsset? @relation("CategoryImage", fields: [imageId], references: [id], onDelete: SetNull) products Product[] translations CategoryTranslation[] @@unique([parentId, slug]) @@index([path]) @@index([parentId, position]) @@map("categories") } enum CollectionType { MANUAL AUTOMATED } model Collection { id String @id @default(uuid(7)) @db.Uuid name String @db.VarChar(160) slug String @unique @db.VarChar(180) type CollectionType @default(MANUAL) description String? bannerId String? @map("banner_id") @db.Uuid /// Rule set for AUTOMATED collections, evaluated by the catalog module. rules Json? startsAt DateTime? @map("starts_at") @db.Timestamptz(3) endsAt DateTime? @map("ends_at") @db.Timestamptz(3) isActive Boolean @default(true) @map("is_active") position Int @default(0) metaTitle String? @map("meta_title") @db.VarChar(255) metaDescription String? @map("meta_description") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) banner MediaAsset? @relation("CollectionBanner", fields: [bannerId], references: [id], onDelete: SetNull) products ProductCollection[] translations CollectionTranslation[] @@index([isActive, startsAt, endsAt]) @@map("collections") } model ProductCollection { productId String @map("product_id") @db.Uuid collectionId String @map("collection_id") @db.Uuid position Int @default(0) product Product @relation(fields: [productId], references: [id], onDelete: Cascade) collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade) @@id([productId, collectionId]) @@index([collectionId, position]) @@map("product_collections") } // =========================================================================== // Catalog: Product / ProductVariant // =========================================================================== enum ProductStatus { DRAFT ACTIVE ARCHIVED } enum VariantStatus { ACTIVE ARCHIVED } enum Currency { VND USD } enum GenderTarget { MEN WOMEN KIDS UNISEX } enum SportType { RUNNING FOOTBALL TRAINING GYM BADMINTON LIFESTYLE } /// The marketing entity: it has a name, a URL and a page. It deliberately has /// NO sku, NO price and NO stock — those belong to ProductVariant, because in /// reality "Black / M" and "White / L" are different physical goods. model Product { id String @id @default(uuid(7)) @db.Uuid name String @db.VarChar(255) slug String @unique @db.VarChar(280) description String? shortDescription String? @map("short_description") @db.VarChar(500) status ProductStatus @default(DRAFT) publishedAt DateTime? @map("published_at") @db.Timestamptz(3) brandId String? @map("brand_id") @db.Uuid primaryCategoryId String? @map("primary_category_id") @db.Uuid /// Facets driving /men, /women and /sports/*. Arrays rather than join tables: /// they are small, bounded, always fetched with the product and filtered with /// a GIN index — a join table would buy nothing here. genderTargets GenderTarget[] @map("gender_targets") sportTypes SportType[] @map("sport_types") metaTitle String? @map("meta_title") @db.VarChar(255) metaDescription String? @map("meta_description") metadata Json? /// Seeds generated variant SKUs, e.g. "VEL-NOC" -> "VEL-NOC-BLACK-M". /// /// Stored rather than re-derived, because adding a colourway a year later /// must extend the same SKU family. Falling back to the slug produced /// "AO-GIO-CHAY-DEM-NOCTURNE-BLACK-XL" sitting next to "VEL-NOC-BLACK-M" on /// the same product. skuPrefix String? @map("sku_prefix") @db.VarChar(24) /// ---- Read-model projection, derived from this product's variants -------- /// /// Denormalised deliberately. Sorting a listing by price and rendering a /// price facet both need MIN/MAX across variants, and no ORM can express /// "order by the minimum price of a related collection" — the alternatives /// were raw SQL for every listing query or a search index we do not need yet /// (ADR-0012). /// /// These columns are ALWAYS derived, never authored. `ProductsService /// .recomputePricing()` is the single writer; every variant mutation in M3 /// must call it. Treat a manual UPDATE of these columns as a bug. minPriceAmount Int? @map("min_price_amount") maxPriceAmount Int? @map("max_price_amount") isOnSale Boolean @default(false) @map("is_on_sale") inStock Boolean @default(false) @map("in_stock") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3) 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[] @@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") }