Stage M7
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DiscountTrigger" AS ENUM ('AUTOMATIC', 'CODE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DiscountType" AS ENUM ('PERCENTAGE', 'FIXED_AMOUNT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DiscountScope" AS ENUM ('ORDER', 'PRODUCT');
|
||||
|
||||
-- NOTE: Prisma wanted to drop `search_documents_document_idx` and strip the
|
||||
-- default from `search_documents.document` here. Both were removed by hand.
|
||||
--
|
||||
-- `document` is a GENERATED column (see 20260812130938_add_search_documents),
|
||||
-- which Prisma cannot express — it sees `Unsupported("tsvector")?` and assumes a
|
||||
-- plain column that has drifted. Letting it "fix" that would drop the GIN index
|
||||
-- search depends on and fail on the generated column anyway, which is exactly
|
||||
-- what happened the first time this migration ran.
|
||||
--
|
||||
-- Expect this diff on every future `migrate dev`. Delete those two statements.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "discounts" (
|
||||
"id" UUID NOT NULL,
|
||||
"code" VARCHAR(40),
|
||||
"trigger" "DiscountTrigger" NOT NULL,
|
||||
"type" "DiscountType" NOT NULL,
|
||||
"scope" "DiscountScope" NOT NULL DEFAULT 'ORDER',
|
||||
"value" INTEGER NOT NULL,
|
||||
"min_subtotal_amount" INTEGER,
|
||||
"starts_at" TIMESTAMPTZ(3),
|
||||
"ends_at" TIMESTAMPTZ(3),
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"usage_limit" INTEGER,
|
||||
"usage_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"stackable" BOOLEAN NOT NULL DEFAULT false,
|
||||
"priority" INTEGER NOT NULL DEFAULT 100,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
"deleted_at" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "discounts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "discount_translations" (
|
||||
"discount_id" UUID NOT NULL,
|
||||
"locale" "Locale" NOT NULL,
|
||||
"name" VARCHAR(120) NOT NULL,
|
||||
"description" VARCHAR(500),
|
||||
|
||||
CONSTRAINT "discount_translations_pkey" PRIMARY KEY ("discount_id","locale")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "discount_products" (
|
||||
"discount_id" UUID NOT NULL,
|
||||
"product_id" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "discount_products_pkey" PRIMARY KEY ("discount_id","product_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "discount_collections" (
|
||||
"discount_id" UUID NOT NULL,
|
||||
"collection_id" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "discount_collections_pkey" PRIMARY KEY ("discount_id","collection_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "discount_redemptions" (
|
||||
"id" UUID NOT NULL,
|
||||
"discount_id" UUID NOT NULL,
|
||||
"order_id" UUID NOT NULL,
|
||||
"amount" INTEGER NOT NULL,
|
||||
"code" VARCHAR(40),
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "discount_redemptions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "discounts_code_key" ON "discounts"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "discounts_trigger_is_active_idx" ON "discounts"("trigger", "is_active");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "discounts_code_idx" ON "discounts"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "discount_products_product_id_idx" ON "discount_products"("product_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "discount_collections_collection_id_idx" ON "discount_collections"("collection_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "discount_redemptions_discount_id_idx" ON "discount_redemptions"("discount_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "discount_redemptions_order_id_discount_id_key" ON "discount_redemptions"("order_id", "discount_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "discount_translations" ADD CONSTRAINT "discount_translations_discount_id_fkey" FOREIGN KEY ("discount_id") REFERENCES "discounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "discount_products" ADD CONSTRAINT "discount_products_discount_id_fkey" FOREIGN KEY ("discount_id") REFERENCES "discounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "discount_products" ADD CONSTRAINT "discount_products_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "discount_collections" ADD CONSTRAINT "discount_collections_discount_id_fkey" FOREIGN KEY ("discount_id") REFERENCES "discounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "discount_collections" ADD CONSTRAINT "discount_collections_collection_id_fkey" FOREIGN KEY ("collection_id") REFERENCES "collections"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "discount_redemptions" ADD CONSTRAINT "discount_redemptions_discount_id_fkey" FOREIGN KEY ("discount_id") REFERENCES "discounts"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "discount_redemptions" ADD CONSTRAINT "discount_redemptions_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,57 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ReviewStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
|
||||
|
||||
-- NOTE: Prisma's diff wanted to DROP INDEX "search_documents_document_idx" and
|
||||
-- ALTER "search_documents"."document" DROP DEFAULT here. Both are removed on
|
||||
-- purpose and must be removed from every future migration too.
|
||||
--
|
||||
-- `document` is a GENERATED tsvector column created by raw SQL in the M6
|
||||
-- migration. Prisma's schema cannot express a generated column, so it sees one
|
||||
-- it does not know about and proposes undoing it. Applying that drops the GIN
|
||||
-- index behind every search query and silently degrades /search to nothing.
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "products" ADD COLUMN "rating_count" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "rating_sum" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "reviews" (
|
||||
"id" UUID NOT NULL,
|
||||
"product_id" UUID NOT NULL,
|
||||
"order_line_id" UUID NOT NULL,
|
||||
"rating" INTEGER NOT NULL,
|
||||
"title" VARCHAR(140),
|
||||
"body" VARCHAR(2000),
|
||||
"author_name" VARCHAR(120) NOT NULL,
|
||||
"status" "ReviewStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"moderated_at" TIMESTAMPTZ(3),
|
||||
"moderated_by_user_id" UUID,
|
||||
"moderation_note" VARCHAR(500),
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "reviews_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "reviews_order_line_id_key" ON "reviews"("order_line_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "reviews_product_id_status_created_at_idx" ON "reviews"("product_id", "status", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "reviews_status_created_at_idx" ON "reviews"("status", "created_at");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "reviews" ADD CONSTRAINT "reviews_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "reviews" ADD CONSTRAINT "reviews_order_line_id_fkey" FOREIGN KEY ("order_line_id") REFERENCES "order_lines"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "reviews" ADD CONSTRAINT "reviews_moderated_by_user_id_fkey" FOREIGN KEY ("moderated_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- Validation belongs in the schema as well as the DTO: `rating` feeds a stored
|
||||
-- SUM, so one bad row silently skews a product's average forever. Zod checks
|
||||
-- the request; this checks the table.
|
||||
ALTER TABLE "reviews" ADD CONSTRAINT "reviews_rating_range" CHECK ("rating" BETWEEN 1 AND 5);
|
||||
@@ -0,0 +1,55 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ContentType" AS ENUM ('PAGE', 'POST');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ContentStatus" AS ENUM ('DRAFT', 'PUBLISHED');
|
||||
|
||||
-- NOTE: Prisma's diff wanted to DROP INDEX "search_documents_document_idx" and
|
||||
-- ALTER "search_documents"."document" DROP DEFAULT here. Removed on purpose —
|
||||
-- `document` is a GENERATED tsvector column created by raw SQL in the M6
|
||||
-- migration, which Prisma's schema cannot express, so it proposes undoing it in
|
||||
-- every migration from now on. Applying it destroys full-text search.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "content_entries" (
|
||||
"id" UUID NOT NULL,
|
||||
"type" "ContentType" NOT NULL,
|
||||
"status" "ContentStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"published_at" TIMESTAMPTZ(3),
|
||||
"cover_image_id" UUID,
|
||||
"author_user_id" UUID,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
"deleted_at" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "content_entries_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "content_entry_translations" (
|
||||
"entry_id" UUID NOT NULL,
|
||||
"locale" "Locale" NOT NULL,
|
||||
"slug" VARCHAR(200) NOT NULL,
|
||||
"title" VARCHAR(255) NOT NULL,
|
||||
"excerpt" VARCHAR(500),
|
||||
"body" TEXT NOT NULL,
|
||||
"meta_title" VARCHAR(255),
|
||||
"meta_description" TEXT,
|
||||
|
||||
CONSTRAINT "content_entry_translations_pkey" PRIMARY KEY ("entry_id","locale")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "content_entries_type_status_published_at_idx" ON "content_entries"("type", "status", "published_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "content_entry_translations_locale_slug_key" ON "content_entry_translations"("locale", "slug");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "content_entries" ADD CONSTRAINT "content_entries_cover_image_id_fkey" FOREIGN KEY ("cover_image_id") REFERENCES "media_assets"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "content_entries" ADD CONSTRAINT "content_entries_author_user_id_fkey" FOREIGN KEY ("author_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "content_entry_translations" ADD CONSTRAINT "content_entry_translations_entry_id_fkey" FOREIGN KEY ("entry_id") REFERENCES "content_entries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+333
-18
@@ -70,11 +70,15 @@ model User {
|
||||
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[]
|
||||
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])
|
||||
@@ -281,6 +285,7 @@ model MediaAsset {
|
||||
collectionBanners Collection[] @relation("CollectionBanner")
|
||||
optionValueSwatches ProductOptionValue[] @relation("OptionValueSwatch")
|
||||
userAvatars User[] @relation("UserAvatar")
|
||||
contentCovers ContentEntry[] @relation("ContentCover")
|
||||
|
||||
@@index([kind, createdAt])
|
||||
@@map("media_assets")
|
||||
@@ -397,6 +402,7 @@ model Collection {
|
||||
banner MediaAsset? @relation("CollectionBanner", fields: [bannerId], references: [id], onDelete: SetNull)
|
||||
products ProductCollection[]
|
||||
translations CollectionTranslation[]
|
||||
discounts DiscountCollection[]
|
||||
|
||||
@@index([isActive, startsAt, endsAt])
|
||||
@@map("collections")
|
||||
@@ -501,11 +507,25 @@ model Product {
|
||||
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[]
|
||||
@@ -935,15 +955,15 @@ model Order {
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -955,8 +975,9 @@ model Order {
|
||||
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[]
|
||||
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
|
||||
lines OrderLine[]
|
||||
redemptions DiscountRedemption[]
|
||||
|
||||
@@index([customerId])
|
||||
@@index([email])
|
||||
@@ -987,6 +1008,7 @@ model OrderLine {
|
||||
|
||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||
variant ProductVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull)
|
||||
review Review?
|
||||
|
||||
@@index([orderId])
|
||||
@@index([variantId])
|
||||
@@ -1013,11 +1035,11 @@ model SearchDocument {
|
||||
locale Locale
|
||||
|
||||
/// Weight A — the product name.
|
||||
title String @db.VarChar(255)
|
||||
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
|
||||
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
|
||||
@@ -1031,3 +1053,296 @@ model SearchDocument {
|
||||
@@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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user