This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent 1e356a2578
commit d9f159a8c3
84 changed files with 7992 additions and 180 deletions
@@ -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
View File
@@ -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")
}