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")
|
||||
}
|
||||
|
||||
@@ -16,8 +16,10 @@ import type { Request, Response } from 'express';
|
||||
import type { Cart, Locale } from '@sport/types';
|
||||
import {
|
||||
addCartLineSchema,
|
||||
applyDiscountCodeSchema,
|
||||
updateCartLineSchema,
|
||||
type AddCartLineInput,
|
||||
type ApplyDiscountCodeInput,
|
||||
type UpdateCartLineInput,
|
||||
} from '@sport/validation';
|
||||
|
||||
@@ -88,6 +90,28 @@ export class CartsController {
|
||||
return this.service.removeLine(this.token(request, response), variantId, locale);
|
||||
}
|
||||
|
||||
@Post('discounts')
|
||||
@ApiOperation({ summary: 'Apply a discount code to the bag' })
|
||||
applyCode(
|
||||
@Body(new ZodValidationPipe(applyDiscountCodeSchema)) body: ApplyDiscountCodeInput,
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
@RequestLocale() locale: Locale,
|
||||
): Promise<Cart> {
|
||||
return this.service.applyCode(this.token(request, response), body.code, locale);
|
||||
}
|
||||
|
||||
@Delete('discounts/:code')
|
||||
@ApiOperation({ summary: 'Remove a discount code' })
|
||||
removeCode(
|
||||
@Param('code') code: string,
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
@RequestLocale() locale: Locale,
|
||||
): Promise<Cart> {
|
||||
return this.service.removeCode(this.token(request, response), code, locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the cart cookie, minting one on first contact.
|
||||
*
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { MediaUrlModule } from '@/common/media/media.module';
|
||||
import { PromotionsModule } from '@/modules/promotions/promotions.module';
|
||||
|
||||
import { CartsController } from './carts.controller';
|
||||
import { CartsService } from './carts.service';
|
||||
@@ -14,7 +15,7 @@ import { CartsService } from './carts.service';
|
||||
* flow prices a cart through exactly the same code path the shopper saw.
|
||||
*/
|
||||
@Module({
|
||||
imports: [MediaUrlModule],
|
||||
imports: [MediaUrlModule, PromotionsModule],
|
||||
controllers: [CartsController],
|
||||
providers: [CartsService],
|
||||
exports: [CartsService],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
CART_NOTICE_REASONS,
|
||||
DISCOUNT_REJECTIONS,
|
||||
type Cart,
|
||||
type CartLine,
|
||||
type CartNotice,
|
||||
@@ -19,6 +20,7 @@ import { MediaUrlService } from '@/common/media/media-url.service';
|
||||
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
|
||||
import { CACHE_KEYS, CACHE_TTL } from '@/infrastructure/redis/cache-keys';
|
||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||
import { PromotionsService, type EvaluationLine } from '@/modules/promotions/public';
|
||||
|
||||
/**
|
||||
* What actually lives in Redis.
|
||||
@@ -37,6 +39,14 @@ interface StoredLine {
|
||||
interface StoredCart {
|
||||
id: string;
|
||||
lines: StoredLine[];
|
||||
/**
|
||||
* Codes the shopper typed, not the discounts they earned.
|
||||
*
|
||||
* Same principle as prices: the bag stores intent, the API decides outcome.
|
||||
* Storing the computed discount would let a thirty-day-old cart carry an
|
||||
* expired offer into checkout.
|
||||
*/
|
||||
codes: string[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -48,6 +58,7 @@ export class CartsService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly redis: RedisService,
|
||||
private readonly mediaUrl: MediaUrlService,
|
||||
private readonly promotions: PromotionsService,
|
||||
) {}
|
||||
|
||||
async get(cartToken: string, locale: Locale): Promise<Cart> {
|
||||
@@ -100,6 +111,47 @@ export class CartsService {
|
||||
return this.hydrate(cartToken, await this.write(cartToken, stored), locale);
|
||||
}
|
||||
|
||||
/** Adds a code to the bag. Whether it is valid is decided at hydration. */
|
||||
async applyCode(cartToken: string, code: string, locale: Locale): Promise<Cart> {
|
||||
const stored = await this.read(cartToken);
|
||||
const normalised = code.trim().toUpperCase();
|
||||
|
||||
if (normalised && !stored.codes.includes(normalised)) {
|
||||
stored.codes.push(normalised);
|
||||
}
|
||||
|
||||
const cart = await this.hydrate(cartToken, await this.write(cartToken, stored), locale);
|
||||
|
||||
/**
|
||||
* A code that does not exist is not remembered.
|
||||
*
|
||||
* Every other rejection is worth keeping — an expired code or an unmet
|
||||
* minimum describes a real discount the shopper might still qualify for
|
||||
* once the bag changes. A typo describes nothing, and storing it leaves an
|
||||
* error banner on the bag for thirty days with no way to reason about it.
|
||||
* The rejection is still returned here, so they see it once.
|
||||
*/
|
||||
const unknown = cart.rejectedDiscounts.some(
|
||||
(rejection) => rejection.code === normalised && rejection.reason === CART_DISCOUNT_NOT_FOUND,
|
||||
);
|
||||
|
||||
if (unknown) {
|
||||
await this.write(cartToken, {
|
||||
...stored,
|
||||
codes: stored.codes.filter((item) => item !== normalised),
|
||||
});
|
||||
}
|
||||
|
||||
return cart;
|
||||
}
|
||||
|
||||
async removeCode(cartToken: string, code: string, locale: Locale): Promise<Cart> {
|
||||
const stored = await this.read(cartToken);
|
||||
stored.codes = stored.codes.filter((item) => item !== code.trim().toUpperCase());
|
||||
|
||||
return this.hydrate(cartToken, await this.write(cartToken, stored), locale);
|
||||
}
|
||||
|
||||
async clear(cartToken: string): Promise<void> {
|
||||
await this.redis.delete(CACHE_KEYS.guestCart(cartToken));
|
||||
}
|
||||
@@ -125,7 +177,10 @@ export class CartsService {
|
||||
private async read(cartToken: string): Promise<StoredCart> {
|
||||
const stored = await this.redis.get<StoredCart>(CACHE_KEYS.guestCart(cartToken));
|
||||
|
||||
return stored ?? { id: cartToken, lines: [], updatedAt: new Date().toISOString() };
|
||||
// `codes` defaults for carts written before discounts existed.
|
||||
return stored
|
||||
? { ...stored, codes: stored.codes ?? [] }
|
||||
: { id: cartToken, lines: [], codes: [], updatedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
private async write(cartToken: string, cart: StoredCart): Promise<StoredCart> {
|
||||
@@ -176,6 +231,7 @@ export class CartsService {
|
||||
},
|
||||
},
|
||||
},
|
||||
productId: true,
|
||||
product: {
|
||||
select: {
|
||||
name: true,
|
||||
@@ -194,6 +250,7 @@ export class CartsService {
|
||||
const byId = new Map(variants.map((variant) => [variant.id, variant]));
|
||||
|
||||
const lines: CartLine[] = [];
|
||||
const evaluationLines: EvaluationLine[] = [];
|
||||
const notices: CartNotice[] = [];
|
||||
const keep: StoredLine[] = [];
|
||||
|
||||
@@ -245,7 +302,7 @@ export class CartsService {
|
||||
const currency = variant.currency as CurrencyCode;
|
||||
const unit = variant.salePriceAmount ?? variant.priceAmount;
|
||||
|
||||
lines.push({
|
||||
const cartLine: CartLine = {
|
||||
variantId: variant.id,
|
||||
productName,
|
||||
productSlug: coalesceRequired(translation?.slug, variant.product.slug),
|
||||
@@ -260,9 +317,15 @@ export class CartsService {
|
||||
quantity,
|
||||
lineTotal: { amount: unit * quantity, currency },
|
||||
maxQuantity: Math.min(available, MAX_LINE_QUANTITY),
|
||||
});
|
||||
};
|
||||
|
||||
lines.push(cartLine);
|
||||
|
||||
keep.push({ ...line, quantity });
|
||||
// Built from the same object that was just pushed, so the two lists
|
||||
// cannot drift — the engine needs `productId`, which the customer-facing
|
||||
// CartLine deliberately does not carry.
|
||||
evaluationLines.push({ ...cartLine, productId: variant.productId });
|
||||
}
|
||||
|
||||
// Persist the pruning so the next read is clean and each notice is shown
|
||||
@@ -281,16 +344,26 @@ export class CartsService {
|
||||
this.logger.log(`Cart ${cartToken} corrected: ${notices.length} notice(s)`);
|
||||
}
|
||||
|
||||
const currency = (lines[0]?.unitPrice.currency ?? 'VND') as CurrencyCode;
|
||||
const discounts = await this.promotions.evaluate(
|
||||
evaluationLines,
|
||||
stored.codes,
|
||||
currency,
|
||||
locale,
|
||||
);
|
||||
|
||||
return {
|
||||
id: cartToken,
|
||||
lines,
|
||||
totals: this.totals(lines),
|
||||
totals: this.totals(lines, discounts.totalDiscount),
|
||||
notices,
|
||||
discounts: discounts.applied,
|
||||
rejectedDiscounts: discounts.rejected,
|
||||
updatedAt: stored.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private totals(lines: readonly CartLine[]): CartTotals {
|
||||
private totals(lines: readonly CartLine[], discount: number): CartTotals {
|
||||
const currency = (lines[0]?.unitPrice.currency ?? 'VND') as CurrencyCode;
|
||||
const subtotal = lines.reduce((sum, line) => sum + line.lineTotal.amount, 0);
|
||||
const money = (amount: number): Money => ({ amount, currency });
|
||||
@@ -298,12 +371,14 @@ export class CartsService {
|
||||
return {
|
||||
itemCount: lines.reduce((count, line) => count + line.quantity, 0),
|
||||
subtotal: money(subtotal),
|
||||
// Promotions are M7 and shipping is M9. Named zeroes rather than an
|
||||
// absent field, so the total is always the sum of its parts.
|
||||
discount: money(0),
|
||||
discount: money(discount),
|
||||
// Shipping is M9. A named zero rather than an absent field, so the total
|
||||
// is always the sum of parts a shopper can see.
|
||||
shipping: money(0),
|
||||
tax: money(0),
|
||||
total: money(subtotal),
|
||||
// Clamped: the engine already refuses to over-discount, and this is the
|
||||
// second place that must be true before a total reaches a customer.
|
||||
total: money(Math.max(0, subtotal - discount)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -322,6 +397,8 @@ export class CartsService {
|
||||
total: money(0),
|
||||
},
|
||||
notices: [],
|
||||
discounts: [],
|
||||
rejectedDiscounts: [],
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
@@ -351,3 +428,6 @@ function variantTitle(variant: {
|
||||
|
||||
return labels.length > 0 ? labels.join(' / ') : variant.title;
|
||||
}
|
||||
|
||||
/** Local alias so the reason is named once rather than spelled at the call site. */
|
||||
const CART_DISCOUNT_NOT_FOUND = DISCOUNT_REJECTIONS.NOT_FOUND;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
|
||||
import { CartsModule } from '@/modules/carts/carts.module';
|
||||
import { OrdersModule } from '@/modules/orders/orders.module';
|
||||
import { PromotionsModule } from '@/modules/promotions/promotions.module';
|
||||
|
||||
import { CheckoutController } from './checkout.controller';
|
||||
import { CheckoutService } from './checkout.service';
|
||||
@@ -14,7 +15,7 @@ import { CheckoutService } from './checkout.service';
|
||||
* being smeared across the two sides. Payment providers (M9) attach here.
|
||||
*/
|
||||
@Module({
|
||||
imports: [CartsModule, OrdersModule],
|
||||
imports: [CartsModule, OrdersModule, PromotionsModule],
|
||||
controllers: [CheckoutController],
|
||||
providers: [CheckoutService],
|
||||
exports: [CheckoutService],
|
||||
|
||||
@@ -9,6 +9,7 @@ import { CACHE_KEYS, CACHE_TTL } from '@/infrastructure/redis/cache-keys';
|
||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||
import { CartsService } from '@/modules/carts/public';
|
||||
import { OrdersService } from '@/modules/orders/public';
|
||||
import { PromotionsService } from '@/modules/promotions/public';
|
||||
|
||||
/** What a claimed idempotency key holds while and after a placement runs. */
|
||||
interface IdempotencyRecord {
|
||||
@@ -36,6 +37,7 @@ export class CheckoutService {
|
||||
private readonly redis: RedisService,
|
||||
private readonly carts: CartsService,
|
||||
private readonly orders: OrdersService,
|
||||
private readonly promotions: PromotionsService,
|
||||
) {}
|
||||
|
||||
/** What the shopper is about to agree to. Priced by the cart, never by the client. */
|
||||
@@ -153,12 +155,24 @@ export class CheckoutService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim the discounts before writing the order.
|
||||
*
|
||||
* Inside the same transaction and guarded on the usage limit, so a code
|
||||
* with one use left cannot be spent twice by two simultaneous checkouts.
|
||||
* If it ran out between the shopper seeing the total and pressing the
|
||||
* button, the whole placement rolls back rather than quietly charging
|
||||
* them a price nobody authorised.
|
||||
*/
|
||||
const order = await tx.order.create({
|
||||
data: {
|
||||
email: input.email,
|
||||
phone: input.shippingAddress.phone,
|
||||
|
||||
subtotalAmount: cart.totals.subtotal.amount,
|
||||
// Recomputed by the cart a moment ago from live discount rules — the
|
||||
// client never sends an amount, and a stale bag cannot carry an
|
||||
// expired offer this far.
|
||||
discountAmount: cart.totals.discount.amount,
|
||||
shippingAmount: cart.totals.shipping.amount,
|
||||
taxAmount: cart.totals.tax.amount,
|
||||
@@ -194,6 +208,8 @@ export class CheckoutService {
|
||||
select: { id: true, number: true },
|
||||
});
|
||||
|
||||
await this.promotions.redeem(tx, order.id, cart.discounts);
|
||||
|
||||
return order.id;
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
PERMISSIONS,
|
||||
TOKEN_AUDIENCES,
|
||||
type AdminContentEntry,
|
||||
type AuthenticatedActor,
|
||||
type ContentDetail,
|
||||
type ContentSummary,
|
||||
type Locale,
|
||||
type OffsetPaginated,
|
||||
} from '@sport/types';
|
||||
import {
|
||||
contentEntryInputSchema,
|
||||
contentListQuerySchema,
|
||||
postListQuerySchema,
|
||||
type ContentEntryInput,
|
||||
type ContentListQuery,
|
||||
type PostListQuery,
|
||||
} from '@sport/validation';
|
||||
|
||||
import { CurrentActor } from '@/common/decorators/current-actor.decorator';
|
||||
import { Public } from '@/common/decorators/public.decorator';
|
||||
import {
|
||||
RequireAudience,
|
||||
RequirePermissions,
|
||||
} from '@/common/decorators/require-permissions.decorator';
|
||||
import { RequestLocale } from '@/common/i18n';
|
||||
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
|
||||
|
||||
import { CmsService } from './cms.service';
|
||||
|
||||
@ApiTags('content')
|
||||
@Controller('content')
|
||||
export class CmsController {
|
||||
constructor(private readonly service: CmsService) {}
|
||||
|
||||
@Get('posts')
|
||||
@Public()
|
||||
@ApiOperation({ summary: 'Published posts, newest first' })
|
||||
@ApiQuery({ name: 'locale', required: false, enum: ['vi', 'en'] })
|
||||
listPosts(
|
||||
@Query(new ZodValidationPipe(postListQuerySchema)) query: PostListQuery,
|
||||
@RequestLocale() locale: Locale,
|
||||
): Promise<OffsetPaginated<ContentSummary>> {
|
||||
return this.service.listPosts(query, locale);
|
||||
}
|
||||
|
||||
@Get('posts/slugs')
|
||||
@Public()
|
||||
@ApiOperation({ summary: 'Published post slugs for this locale (sitemap / static params)' })
|
||||
postSlugs(
|
||||
@RequestLocale() locale: Locale,
|
||||
): Promise<{ slug: string; title: string; updatedAt: string }[]> {
|
||||
return this.service.listSlugs('POST', locale);
|
||||
}
|
||||
|
||||
@Get('pages/slugs')
|
||||
@Public()
|
||||
@ApiOperation({ summary: 'Published page slugs for this locale' })
|
||||
pageSlugs(
|
||||
@RequestLocale() locale: Locale,
|
||||
): Promise<{ slug: string; title: string; updatedAt: string }[]> {
|
||||
return this.service.listSlugs('PAGE', locale);
|
||||
}
|
||||
|
||||
// Declared after the literal `posts/slugs` route above — Nest matches in
|
||||
// declaration order, so a `:slug` parameter placed first would swallow it.
|
||||
@Get('posts/:slug')
|
||||
@Public()
|
||||
@ApiOperation({ summary: 'One published post' })
|
||||
@ApiQuery({ name: 'locale', required: false, enum: ['vi', 'en'] })
|
||||
getPost(@Param('slug') slug: string, @RequestLocale() locale: Locale): Promise<ContentDetail> {
|
||||
return this.service.getBySlug(slug, 'POST', locale);
|
||||
}
|
||||
|
||||
@Get('pages/:slug')
|
||||
@Public()
|
||||
@ApiOperation({ summary: 'One published page' })
|
||||
@ApiQuery({ name: 'locale', required: false, enum: ['vi', 'en'] })
|
||||
getPage(@Param('slug') slug: string, @RequestLocale() locale: Locale): Promise<ContentDetail> {
|
||||
return this.service.getBySlug(slug, 'PAGE', locale);
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags('admin/content')
|
||||
@ApiBearerAuth()
|
||||
@RequireAudience(TOKEN_AUDIENCES.ADMIN)
|
||||
@Controller('admin/content')
|
||||
export class CmsAdminController {
|
||||
constructor(private readonly service: CmsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions(PERMISSIONS.CMS_READ)
|
||||
@ApiOperation({ summary: 'Pages and posts, recently edited first' })
|
||||
list(
|
||||
@Query(new ZodValidationPipe(contentListQuerySchema)) query: ContentListQuery,
|
||||
): Promise<OffsetPaginated<AdminContentEntry>> {
|
||||
return this.service.listForAdmin(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions(PERMISSIONS.CMS_READ)
|
||||
@ApiOperation({ summary: 'One entry, every locale' })
|
||||
getById(@Param('id') id: string): Promise<AdminContentEntry> {
|
||||
return this.service.getByIdForAdmin(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions(PERMISSIONS.CMS_MANAGE)
|
||||
@ApiOperation({ summary: 'Create a page or post' })
|
||||
create(
|
||||
@Body(new ZodValidationPipe(contentEntryInputSchema)) body: ContentEntryInput,
|
||||
@CurrentActor() actor: AuthenticatedActor,
|
||||
): Promise<AdminContentEntry> {
|
||||
return this.service.create(body, actor.userId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions(PERMISSIONS.CMS_MANAGE)
|
||||
@ApiOperation({ summary: 'Update a page or post' })
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(contentEntryInputSchema)) body: ContentEntryInput,
|
||||
@CurrentActor() actor: AuthenticatedActor,
|
||||
): Promise<AdminContentEntry> {
|
||||
return this.service.update(id, body, actor.userId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermissions(PERMISSIONS.CMS_MANAGE)
|
||||
@ApiOperation({ summary: 'Retire an entry; it stops resolving on the storefront' })
|
||||
remove(@Param('id') id: string, @CurrentActor() actor: AuthenticatedActor): Promise<void> {
|
||||
return this.service.remove(id, actor.userId);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,27 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { MediaUrlModule } from '@/common/media/media.module';
|
||||
|
||||
import { CmsAdminController, CmsController } from './cms.controller';
|
||||
import { CmsRepository } from './cms.repository';
|
||||
import { CmsService } from './cms.service';
|
||||
|
||||
/**
|
||||
* CmsModule — boundary declared, implementation pending.
|
||||
* CmsModule — owns `content_entries` and their translations.
|
||||
*
|
||||
* Owns (exclusively): `pages`, `blog_posts`, `banners`, `navigation_menus` — milestone 3
|
||||
* Pages and posts are one entity with a `type`, for the same reason promotions
|
||||
* and coupons are (ADR-0020): the shared surface is nearly all of it, and what
|
||||
* differs is placement.
|
||||
*
|
||||
* Homepage blocks, /blog and static pages. Editorial content is versioned and previewable; it never becomes a general-purpose page builder.
|
||||
*
|
||||
* Anatomy once implemented (see ../README.md):
|
||||
* cms.module.ts wiring only
|
||||
* cms.controller.ts HTTP surface, no logic
|
||||
* cms.service.ts business rules
|
||||
* cms.repository.ts the only file that touches Prisma
|
||||
* dto/ request/response shapes
|
||||
* public/ what other modules may import
|
||||
* Deliberately NOT a page builder. The body is one Markdown column. A block
|
||||
* tree would make this a layout tool that the storefront design has to obey,
|
||||
* and the design would stop being code — which is the thing this project exists
|
||||
* to avoid.
|
||||
*/
|
||||
@Module({})
|
||||
@Module({
|
||||
imports: [MediaUrlModule],
|
||||
controllers: [CmsController, CmsAdminController],
|
||||
providers: [CmsService, CmsRepository],
|
||||
exports: [CmsService],
|
||||
})
|
||||
export class CmsModule {}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
|
||||
|
||||
const entrySelect = {
|
||||
id: true,
|
||||
type: true,
|
||||
status: true,
|
||||
publishedAt: true,
|
||||
coverImageId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
coverImage: { select: { storageKey: true } },
|
||||
author: { select: { firstName: true, lastName: true } },
|
||||
translations: true,
|
||||
} as const;
|
||||
|
||||
export type ContentEntryRow = Prisma.ContentEntryGetPayload<{ select: typeof entrySelect }>;
|
||||
|
||||
/** The only file in this module that touches Prisma. */
|
||||
@Injectable()
|
||||
export class CmsRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
// ---- Storefront ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A published entry by slug, in any locale.
|
||||
*
|
||||
* Deliberately not scoped to the requested locale: a reader following an
|
||||
* English link while browsing in Vietnamese should land on the entry, not a
|
||||
* 404. The service redirects to the canonical slug afterwards — the same
|
||||
* behaviour products already have.
|
||||
*/
|
||||
findPublishedBySlug(slug: string, type: 'PAGE' | 'POST') {
|
||||
return this.prisma.contentEntry.findFirst({
|
||||
where: {
|
||||
type,
|
||||
status: 'PUBLISHED',
|
||||
deletedAt: null,
|
||||
translations: { some: { slug } },
|
||||
},
|
||||
select: entrySelect,
|
||||
});
|
||||
}
|
||||
|
||||
findPublishedPosts(skip: number, take: number) {
|
||||
return this.prisma.contentEntry.findMany({
|
||||
where: { type: 'POST', status: 'PUBLISHED', deletedAt: null },
|
||||
// Newest first by publication, not by creation: a post drafted in January
|
||||
// and published in March belongs at the top in March.
|
||||
orderBy: [{ publishedAt: 'desc' }, { id: 'desc' }],
|
||||
skip,
|
||||
take,
|
||||
select: entrySelect,
|
||||
});
|
||||
}
|
||||
|
||||
countPublishedPosts(): Promise<number> {
|
||||
return this.prisma.contentEntry.count({
|
||||
where: { type: 'POST', status: 'PUBLISHED', deletedAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Slugs for `generateStaticParams`, the sitemap, and the footer's page list.
|
||||
*
|
||||
* The title rides along because the footer needs a label and a sitemap does
|
||||
* not mind an extra column — cheaper than a second endpoint that returns the
|
||||
* same rows with one more field.
|
||||
*/
|
||||
findPublishedSlugs(type: 'PAGE' | 'POST', locale: 'VI' | 'EN') {
|
||||
return this.prisma.contentEntryTranslation.findMany({
|
||||
where: {
|
||||
locale,
|
||||
entry: { type, status: 'PUBLISHED', deletedAt: null },
|
||||
},
|
||||
select: { slug: true, title: true, entry: { select: { updatedAt: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Admin ---------------------------------------------------------------
|
||||
|
||||
findForAdmin(where: Prisma.ContentEntryWhereInput, skip: number, take: number) {
|
||||
return this.prisma.contentEntry.findMany({
|
||||
where,
|
||||
orderBy: [{ updatedAt: 'desc' }],
|
||||
skip,
|
||||
take,
|
||||
select: entrySelect,
|
||||
});
|
||||
}
|
||||
|
||||
countForAdmin(where: Prisma.ContentEntryWhereInput): Promise<number> {
|
||||
return this.prisma.contentEntry.count({ where });
|
||||
}
|
||||
|
||||
findById(id: string) {
|
||||
return this.prisma.contentEntry.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
select: entrySelect,
|
||||
});
|
||||
}
|
||||
|
||||
create(data: Prisma.ContentEntryUncheckedCreateInput) {
|
||||
return this.prisma.contentEntry.create({ data, select: { id: true } });
|
||||
}
|
||||
|
||||
update(id: string, data: Prisma.ContentEntryUncheckedUpdateInput) {
|
||||
return this.prisma.contentEntry.update({ where: { id }, data, select: { id: true } });
|
||||
}
|
||||
|
||||
softDelete(id: string) {
|
||||
return this.prisma.contentEntry.update({
|
||||
where: { id },
|
||||
// Unpublished as well as deleted: a soft-deleted row that is still
|
||||
// PUBLISHED is one forgotten `deletedAt: null` away from being live again.
|
||||
data: { deletedAt: new Date(), status: 'DRAFT' },
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces an entry's translations.
|
||||
*
|
||||
* Delete-then-insert rather than upsert-per-locale, because removing a locale
|
||||
* has to actually remove it: an operator who deletes the English version of a
|
||||
* post expects `/en/blog/<slug>` to stop resolving, not to keep serving the
|
||||
* copy they just deleted.
|
||||
*/
|
||||
async replaceTranslations(
|
||||
id: string,
|
||||
rows: Prisma.ContentEntryTranslationUncheckedCreateInput[],
|
||||
): Promise<void> {
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.contentEntryTranslation.deleteMany({ where: { entryId: id } }),
|
||||
this.prisma.contentEntryTranslation.createMany({ data: rows }),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Whether a slug is taken by a *different* entry in the same locale. */
|
||||
slugTakenBy(locale: 'VI' | 'EN', slug: string, exceptEntryId: string | null) {
|
||||
return this.prisma.contentEntryTranslation.findFirst({
|
||||
where: {
|
||||
locale,
|
||||
slug,
|
||||
...(exceptEntryId ? { entryId: { not: exceptEntryId } } : {}),
|
||||
},
|
||||
select: { entryId: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import {
|
||||
LOCALES,
|
||||
type AdminContentEntry,
|
||||
type ContentDetail,
|
||||
type ContentSummary,
|
||||
type ContentType,
|
||||
type Locale,
|
||||
type OffsetPaginated,
|
||||
} from '@sport/types';
|
||||
import type { ContentEntryInput, ContentListQuery, PostListQuery } from '@sport/validation';
|
||||
|
||||
import { AuditService } from '@/common/audit/audit.service';
|
||||
import { AppException } from '@/common/errors/app.exception';
|
||||
import { coalesceRequired, pickTranslation, toDbLocale } from '@/common/i18n';
|
||||
import { MediaUrlService } from '@/common/media/media-url.service';
|
||||
|
||||
import { CmsRepository, type ContentEntryRow } from './cms.repository';
|
||||
|
||||
@Injectable()
|
||||
export class CmsService {
|
||||
private readonly logger = new Logger(CmsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly repository: CmsRepository,
|
||||
private readonly audit: AuditService,
|
||||
private readonly mediaUrl: MediaUrlService,
|
||||
) {}
|
||||
|
||||
// ---- Storefront ----------------------------------------------------------
|
||||
|
||||
async getBySlug(slug: string, type: ContentType, locale: Locale): Promise<ContentDetail> {
|
||||
const row = await this.repository.findPublishedBySlug(slug, type);
|
||||
if (!row) throw AppException.notFound(type === 'PAGE' ? 'Page' : 'Post');
|
||||
|
||||
return this.toDetail(row, locale);
|
||||
}
|
||||
|
||||
async listPosts(query: PostListQuery, locale: Locale): Promise<OffsetPaginated<ContentSummary>> {
|
||||
const [rows, totalItems] = await Promise.all([
|
||||
this.repository.findPublishedPosts((query.page - 1) * query.perPage, query.perPage),
|
||||
this.repository.countPublishedPosts(),
|
||||
]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / query.perPage));
|
||||
|
||||
return {
|
||||
/**
|
||||
* An entry with no translation in *any* locale cannot render, so it is
|
||||
* dropped rather than shown as a blank card. `flatMap` over `map` because
|
||||
* the alternative is a nullable item every caller has to filter.
|
||||
*/
|
||||
items: rows.flatMap((row) => {
|
||||
const summary = this.toSummary(row, locale);
|
||||
return summary ? [summary] : [];
|
||||
}),
|
||||
pageInfo: {
|
||||
page: query.page,
|
||||
perPage: query.perPage,
|
||||
totalItems,
|
||||
totalPages,
|
||||
hasNextPage: query.page < totalPages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async listSlugs(
|
||||
type: ContentType,
|
||||
locale: Locale,
|
||||
): Promise<{ slug: string; title: string; updatedAt: string }[]> {
|
||||
const rows = await this.repository.findPublishedSlugs(type, toDbLocale(locale));
|
||||
|
||||
return rows.map((row) => ({
|
||||
slug: row.slug,
|
||||
title: row.title,
|
||||
updatedAt: row.entry.updatedAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
// ---- Admin ---------------------------------------------------------------
|
||||
|
||||
async listForAdmin(query: ContentListQuery): Promise<OffsetPaginated<AdminContentEntry>> {
|
||||
const where: Prisma.ContentEntryWhereInput = {
|
||||
deletedAt: null,
|
||||
...(query.type ? { type: query.type } : {}),
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(query.q
|
||||
? { translations: { some: { title: { contains: query.q, mode: 'insensitive' } } } }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [rows, totalItems] = await Promise.all([
|
||||
this.repository.findForAdmin(where, (query.page - 1) * query.perPage, query.perPage),
|
||||
this.repository.countForAdmin(where),
|
||||
]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / query.perPage));
|
||||
|
||||
return {
|
||||
items: rows.map((row) => this.toAdmin(row)),
|
||||
pageInfo: {
|
||||
page: query.page,
|
||||
perPage: query.perPage,
|
||||
totalItems,
|
||||
totalPages,
|
||||
hasNextPage: query.page < totalPages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getByIdForAdmin(id: string): Promise<AdminContentEntry> {
|
||||
const row = await this.repository.findById(id);
|
||||
if (!row) throw AppException.notFound('Content entry');
|
||||
|
||||
return this.toAdmin(row);
|
||||
}
|
||||
|
||||
async create(input: ContentEntryInput, actorUserId: string): Promise<AdminContentEntry> {
|
||||
await this.assertSlugsFree(input, null);
|
||||
|
||||
const created = await this.repository.create({
|
||||
type: input.type,
|
||||
status: input.status,
|
||||
coverImageId: input.coverImageId ?? null,
|
||||
authorUserId: actorUserId,
|
||||
publishedAt: input.status === 'PUBLISHED' ? new Date() : null,
|
||||
});
|
||||
|
||||
await this.repository.replaceTranslations(
|
||||
created.id,
|
||||
this.toTranslationRows(created.id, input),
|
||||
);
|
||||
|
||||
this.audit.record({
|
||||
actorUserId,
|
||||
action: 'content.create',
|
||||
resourceType: 'ContentEntry',
|
||||
resourceId: created.id,
|
||||
changes: { type: input.type, status: input.status },
|
||||
});
|
||||
|
||||
return this.getByIdForAdmin(created.id);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: ContentEntryInput,
|
||||
actorUserId: string,
|
||||
): Promise<AdminContentEntry> {
|
||||
const existing = await this.repository.findById(id);
|
||||
if (!existing) throw AppException.notFound('Content entry');
|
||||
|
||||
await this.assertSlugsFree(input, id);
|
||||
|
||||
/**
|
||||
* `publishedAt` is stamped once, on the first publish, and never rewritten.
|
||||
*
|
||||
* Re-stamping it on every save would jump a post to the top of the feed
|
||||
* because somebody fixed a typo, and would silently change a date readers
|
||||
* may already have seen cited.
|
||||
*/
|
||||
const publishedAt =
|
||||
input.status === 'PUBLISHED' ? (existing.publishedAt ?? new Date()) : existing.publishedAt;
|
||||
|
||||
await this.repository.update(id, {
|
||||
type: input.type,
|
||||
status: input.status,
|
||||
coverImageId: input.coverImageId ?? null,
|
||||
publishedAt,
|
||||
});
|
||||
|
||||
await this.repository.replaceTranslations(id, this.toTranslationRows(id, input));
|
||||
|
||||
this.audit.record({
|
||||
actorUserId,
|
||||
action: 'content.update',
|
||||
resourceType: 'ContentEntry',
|
||||
resourceId: id,
|
||||
changes: { status: input.status },
|
||||
});
|
||||
|
||||
return this.getByIdForAdmin(id);
|
||||
}
|
||||
|
||||
async remove(id: string, actorUserId: string): Promise<void> {
|
||||
await this.repository.softDelete(id);
|
||||
|
||||
this.audit.record({
|
||||
actorUserId,
|
||||
action: 'content.delete',
|
||||
resourceType: 'ContentEntry',
|
||||
resourceId: id,
|
||||
changes: {},
|
||||
});
|
||||
|
||||
this.logger.log(`Content entry ${id} retired`);
|
||||
}
|
||||
|
||||
// ---- internals -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Refuses a slug already used by another entry in the same locale.
|
||||
*
|
||||
* The database has a unique index on `(locale, slug)`, so this cannot be the
|
||||
* only guard — but a 500 from a constraint violation tells an operator
|
||||
* nothing, and this tells them exactly which slug to change.
|
||||
*/
|
||||
private async assertSlugsFree(input: ContentEntryInput, exceptId: string | null): Promise<void> {
|
||||
for (const locale of LOCALES) {
|
||||
const fields = input.translations[locale];
|
||||
if (!fields) continue;
|
||||
|
||||
const clash = await this.repository.slugTakenBy(toDbLocale(locale), fields.slug, exceptId);
|
||||
if (clash) {
|
||||
throw AppException.conflict(
|
||||
`The slug "${fields.slug}" is already used by another entry in ${locale.toUpperCase()}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private toTranslationRows(
|
||||
entryId: string,
|
||||
input: ContentEntryInput,
|
||||
): Prisma.ContentEntryTranslationUncheckedCreateInput[] {
|
||||
return LOCALES.flatMap((locale) => {
|
||||
const fields = input.translations[locale];
|
||||
if (!fields) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
entryId,
|
||||
locale: toDbLocale(locale),
|
||||
slug: fields.slug,
|
||||
title: fields.title,
|
||||
excerpt: fields.excerpt ?? null,
|
||||
body: fields.body,
|
||||
metaTitle: fields.metaTitle ?? null,
|
||||
metaDescription: fields.metaDescription ?? null,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
private toSummary(row: ContentEntryRow, locale: Locale): ContentSummary | null {
|
||||
// Falls back to any locale rather than 404ing: a post written only in
|
||||
// English is still worth listing to a Vietnamese reader, who can read it or
|
||||
// switch. Hiding it would make the feed differ by locale for no reason.
|
||||
const translation = pickTranslation(row.translations, locale) ?? row.translations[0];
|
||||
if (!translation) return null;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
slug: translation.slug,
|
||||
title: translation.title,
|
||||
excerpt: translation.excerpt,
|
||||
coverImageUrl: row.coverImage ? this.mediaUrl.url(row.coverImage.storageKey) : null,
|
||||
publishedAt: row.publishedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private toDetail(row: ContentEntryRow, locale: Locale): ContentDetail {
|
||||
const translation = pickTranslation(row.translations, locale) ?? row.translations[0];
|
||||
if (!translation) throw AppException.notFound('Content entry');
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
slug: translation.slug,
|
||||
title: translation.title,
|
||||
excerpt: translation.excerpt,
|
||||
body: translation.body,
|
||||
coverImageUrl: row.coverImage ? this.mediaUrl.url(row.coverImage.storageKey) : null,
|
||||
publishedAt: row.publishedAt?.toISOString() ?? null,
|
||||
authorName: row.author ? `${row.author.firstName} ${row.author.lastName}`.trim() : null,
|
||||
seo: {
|
||||
metaTitle: coalesceRequired(translation.metaTitle, translation.title),
|
||||
metaDescription: translation.metaDescription ?? translation.excerpt,
|
||||
},
|
||||
alternateSlugs: Object.fromEntries(
|
||||
row.translations.map((item) => [item.locale.toLowerCase(), item.slug]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private toAdmin(row: ContentEntryRow): AdminContentEntry {
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
status: row.status,
|
||||
publishedAt: row.publishedAt?.toISOString() ?? null,
|
||||
coverImageId: row.coverImageId,
|
||||
coverImageUrl: row.coverImage ? this.mediaUrl.url(row.coverImage.storageKey) : null,
|
||||
authorName: row.author ? `${row.author.firstName} ${row.author.lastName}`.trim() : null,
|
||||
translations: Object.fromEntries(
|
||||
row.translations.map((item) => [
|
||||
item.locale.toLowerCase(),
|
||||
{
|
||||
slug: item.slug,
|
||||
title: item.title,
|
||||
excerpt: item.excerpt,
|
||||
body: item.body,
|
||||
metaTitle: item.metaTitle,
|
||||
metaDescription: item.metaDescription,
|
||||
},
|
||||
]),
|
||||
),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,5 @@
|
||||
* This barrel is the ONLY thing other modules may import from here. Everything
|
||||
* else — repository, DTOs, internal services — is private, and the ESLint
|
||||
* boundary rule in @sport/eslint-config/nest enforces it.
|
||||
*
|
||||
* Keep it narrow: each export is a promise to the rest of the codebase.
|
||||
*/
|
||||
export {};
|
||||
export { CmsService } from '../cms.service';
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PromotionsModule } from '@/modules/promotions/promotions.module';
|
||||
|
||||
import { OrdersAdminController } from './orders.controller';
|
||||
import { OrdersMapper } from './orders.mapper';
|
||||
import { OrdersRepository } from './orders.repository';
|
||||
@@ -16,6 +18,7 @@ import { OrdersService } from './orders.service';
|
||||
* EXTRACTION CANDIDATE.
|
||||
*/
|
||||
@Module({
|
||||
imports: [PromotionsModule],
|
||||
controllers: [OrdersAdminController],
|
||||
providers: [OrdersService, OrdersRepository, OrdersMapper],
|
||||
exports: [OrdersService],
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { OrderListQuery, UpdateOrderStatusInput } from '@sport/validation';
|
||||
import { AuditService } from '@/common/audit/audit.service';
|
||||
import { AppException } from '@/common/errors/app.exception';
|
||||
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
|
||||
import { PromotionsService } from '@/modules/promotions/public';
|
||||
|
||||
import { OrdersMapper, parseOrderNumber } from './orders.mapper';
|
||||
import { OrdersRepository } from './orders.repository';
|
||||
@@ -42,6 +43,7 @@ export class OrdersService {
|
||||
private readonly repository: OrdersRepository,
|
||||
private readonly mapper: OrdersMapper,
|
||||
private readonly audit: AuditService,
|
||||
private readonly promotions: PromotionsService,
|
||||
) {}
|
||||
|
||||
async getById(id: string): Promise<Order> {
|
||||
@@ -165,6 +167,9 @@ export class OrdersService {
|
||||
|
||||
if (to === ORDER_STATUSES.CANCELLED) {
|
||||
await this.releaseReservations(tx, existing.lines);
|
||||
// A cancelled order consumed a use of every discount it claimed.
|
||||
// Leaving those spent would quietly retire a coupon nobody redeemed.
|
||||
await this.promotions.release(tx, id);
|
||||
}
|
||||
|
||||
if (to === ORDER_STATUSES.FULFILLED) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type StorefrontProduct,
|
||||
type StorefrontVariant,
|
||||
type VariantAvailability,
|
||||
type ProductRatingSummary,
|
||||
} from '@sport/types';
|
||||
|
||||
import { coalesce, coalesceRequired, pickTranslation } from '@/common/i18n';
|
||||
@@ -50,8 +51,7 @@ export class ProductsMapper {
|
||||
priceRange: this.priceRangeOf(row.variants),
|
||||
isOnSale: row.isOnSale,
|
||||
colorSwatches: this.colorSwatchesOf(row.options, locale),
|
||||
// Reviews land in M7; the field exists so the card layout is final now.
|
||||
rating: null,
|
||||
rating: ratingOf(row),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ export class ProductsMapper {
|
||||
}),
|
||||
variants,
|
||||
priceRange: this.priceRangeOf(row.variants),
|
||||
rating: null,
|
||||
rating: ratingOf(row),
|
||||
breadcrumbs,
|
||||
alternateSlugs: Object.fromEntries(
|
||||
row.translations.map((entry) => [entry.locale === 'VI' ? 'vi' : 'en', entry.slug]),
|
||||
@@ -333,3 +333,22 @@ function toAvailability(available: number): VariantAvailability {
|
||||
function isPresent<T>(value: T | null): value is T {
|
||||
return value !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the stored sum/count pair into a display summary.
|
||||
*
|
||||
* Null rather than `{ average: 0, count: 0 }` when nothing has been reviewed:
|
||||
* zero stars is a *verdict*, and a product nobody has rated has not received
|
||||
* one. The card renders nothing at all in that case, which is honest; "0.0 ★"
|
||||
* on a new product is not.
|
||||
*/
|
||||
function ratingOf(row: { ratingSum: number; ratingCount: number }): ProductRatingSummary | null {
|
||||
if (row.ratingCount === 0) return null;
|
||||
|
||||
return {
|
||||
// One decimal, the convention every storefront uses. The exact value stays
|
||||
// in the two integers, so this is presentation only.
|
||||
average: Math.round((row.ratingSum / row.ratingCount) * 10) / 10,
|
||||
count: row.ratingCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,6 +49,10 @@ const listSelect = {
|
||||
name: true,
|
||||
slug: true,
|
||||
isOnSale: true,
|
||||
// Written by ReviewsService, read here. Two integers rather than a stored
|
||||
// average, so the figure can never drift from the reviews behind it.
|
||||
ratingSum: true,
|
||||
ratingCount: true,
|
||||
translations: true,
|
||||
brand: { select: { id: true, name: true, translations: true } },
|
||||
images: { orderBy: { position: 'asc' }, take: 4, select: imageSelect },
|
||||
@@ -64,6 +68,8 @@ const detailSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
ratingSum: true,
|
||||
ratingCount: true,
|
||||
description: true,
|
||||
shortDescription: true,
|
||||
status: true,
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { DISCOUNT_REJECTIONS, type CurrencyCode } from '@sport/types';
|
||||
|
||||
import { evaluateDiscounts, type DiscountCandidate, type EvaluationLine } from './discount-engine';
|
||||
|
||||
const VND = 'VND' as CurrencyCode;
|
||||
|
||||
function line(productId: string, amount: number, quantity = 1): EvaluationLine {
|
||||
return {
|
||||
productId,
|
||||
variantId: `${productId}-v`,
|
||||
productName: productId,
|
||||
productSlug: productId,
|
||||
variantTitle: 'M',
|
||||
sku: `${productId}-SKU`,
|
||||
imageUrl: null,
|
||||
unitPrice: { amount: amount / quantity, currency: VND },
|
||||
compareAtPrice: null,
|
||||
quantity,
|
||||
lineTotal: { amount, currency: VND },
|
||||
maxQuantity: 10,
|
||||
};
|
||||
}
|
||||
|
||||
function discount(overrides: Partial<DiscountCandidate> = {}): DiscountCandidate {
|
||||
return {
|
||||
id: 'd1',
|
||||
code: 'SAVE',
|
||||
name: 'Save',
|
||||
type: 'PERCENTAGE',
|
||||
scope: 'ORDER',
|
||||
value: 10,
|
||||
minSubtotalAmount: null,
|
||||
stackable: false,
|
||||
priority: 100,
|
||||
productIds: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The discount engine is the one place in this system where a rounding mistake
|
||||
* is a financial one, so its arithmetic is pinned rather than trusted.
|
||||
*/
|
||||
describe('evaluateDiscounts', () => {
|
||||
const cart = [line('p1', 1_000_000), line('p2', 500_000)]; // subtotal 1,500,000
|
||||
|
||||
it('takes a percentage of the subtotal', () => {
|
||||
const result = evaluateDiscounts([discount({ value: 20 })], cart, VND);
|
||||
|
||||
expect(result.totalDiscount).toBe(300_000);
|
||||
expect(result.applied).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('floors a percentage rather than rounding up', () => {
|
||||
// 333,333 * 10% = 33,333.3 — rounding up would hand out a fraction of a
|
||||
// đồng the merchant never agreed to, on every order.
|
||||
const result = evaluateDiscounts([discount({ value: 10 })], [line('p1', 333_333)], VND);
|
||||
|
||||
expect(result.totalDiscount).toBe(33_333);
|
||||
});
|
||||
|
||||
it('never discounts more than the cart is worth', () => {
|
||||
const result = evaluateDiscounts(
|
||||
[discount({ type: 'FIXED_AMOUNT', value: 5_000_000 })],
|
||||
cart,
|
||||
VND,
|
||||
);
|
||||
|
||||
// A negative total is not a refund.
|
||||
expect(result.totalDiscount).toBe(1_500_000);
|
||||
});
|
||||
|
||||
it('applies a product-scoped discount only to matching lines', () => {
|
||||
const result = evaluateDiscounts(
|
||||
[discount({ scope: 'PRODUCT', value: 50, productIds: ['p2'] })],
|
||||
cart,
|
||||
VND,
|
||||
);
|
||||
|
||||
expect(result.totalDiscount).toBe(250_000); // half of p2 only
|
||||
});
|
||||
|
||||
it('treats a product scope with no targets as matching nothing', () => {
|
||||
const result = evaluateDiscounts([discount({ scope: 'PRODUCT', productIds: [] })], cart, VND);
|
||||
|
||||
expect(result.totalDiscount).toBe(0);
|
||||
expect(result.rejected[0]?.reason).toBe(DISCOUNT_REJECTIONS.NOTHING_ELIGIBLE);
|
||||
});
|
||||
|
||||
it('rejects below the minimum and says what the minimum was', () => {
|
||||
const result = evaluateDiscounts([discount({ minSubtotalAmount: 2_000_000 })], cart, VND);
|
||||
|
||||
expect(result.totalDiscount).toBe(0);
|
||||
expect(result.rejected[0]).toMatchObject({
|
||||
reason: DISCOUNT_REJECTIONS.MINIMUM_NOT_MET,
|
||||
minimumSubtotal: { amount: 2_000_000, currency: VND },
|
||||
});
|
||||
});
|
||||
|
||||
it('stops after a non-stackable discount applies', () => {
|
||||
const result = evaluateDiscounts(
|
||||
[
|
||||
discount({ id: 'a', code: 'FIRST', value: 10, priority: 1, stackable: false }),
|
||||
discount({ id: 'b', code: 'SECOND', value: 50, priority: 2, stackable: true }),
|
||||
],
|
||||
cart,
|
||||
VND,
|
||||
);
|
||||
|
||||
expect(result.applied.map((d) => d.code)).toEqual(['FIRST']);
|
||||
expect(result.totalDiscount).toBe(150_000);
|
||||
});
|
||||
|
||||
it('compounds stackable discounts against what is left, not the original', () => {
|
||||
const result = evaluateDiscounts(
|
||||
[
|
||||
discount({ id: 'a', code: 'A', value: 50, priority: 1, stackable: true }),
|
||||
discount({ id: 'b', code: 'B', value: 50, priority: 2, stackable: true }),
|
||||
],
|
||||
cart,
|
||||
VND,
|
||||
);
|
||||
|
||||
// 750,000 then 375,000 — not 1,500,000, which would make the order free.
|
||||
expect(result.totalDiscount).toBe(1_125_000);
|
||||
expect(result.totalDiscount).toBeLessThan(1_500_000);
|
||||
});
|
||||
|
||||
it('is deterministic regardless of input order', () => {
|
||||
const a = discount({ id: 'a', code: 'A', value: 10, priority: 2, stackable: true });
|
||||
const b = discount({ id: 'b', code: 'B', value: 30, priority: 1, stackable: true });
|
||||
|
||||
const forwards = evaluateDiscounts([a, b], cart, VND);
|
||||
const backwards = evaluateDiscounts([b, a], cart, VND);
|
||||
|
||||
expect(forwards.applied.map((d) => d.code)).toEqual(['B', 'A']);
|
||||
expect(backwards.totalDiscount).toBe(forwards.totalDiscount);
|
||||
});
|
||||
|
||||
it('stays silent about automatic promotions that did not apply', () => {
|
||||
// Only a code the shopper typed deserves an explanation; an unmet automatic
|
||||
// promotion is not a failure they can act on.
|
||||
const result = evaluateDiscounts(
|
||||
[discount({ code: null, minSubtotalAmount: 9_000_000 })],
|
||||
cart,
|
||||
VND,
|
||||
);
|
||||
|
||||
expect(result.rejected).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles an empty cart without dividing by anything', () => {
|
||||
const result = evaluateDiscounts([discount()], [], VND);
|
||||
|
||||
expect(result.totalDiscount).toBe(0);
|
||||
expect(result.applied).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
DISCOUNT_REJECTIONS,
|
||||
DISCOUNT_SCOPES,
|
||||
DISCOUNT_TYPES,
|
||||
type AppliedDiscount,
|
||||
type CartLine,
|
||||
type CurrencyCode,
|
||||
type DiscountRejectionReason,
|
||||
type RejectedDiscount,
|
||||
} from '@sport/types';
|
||||
|
||||
/** A discount reduced to what the engine actually needs to decide. */
|
||||
export interface DiscountCandidate {
|
||||
readonly id: string;
|
||||
readonly code: string | null;
|
||||
readonly name: string;
|
||||
readonly type: 'PERCENTAGE' | 'FIXED_AMOUNT';
|
||||
readonly scope: 'ORDER' | 'PRODUCT';
|
||||
readonly value: number;
|
||||
readonly minSubtotalAmount: number | null;
|
||||
readonly stackable: boolean;
|
||||
readonly priority: number;
|
||||
/** Product ids this discount targets. Empty means "no targets configured". */
|
||||
readonly productIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface EvaluationLine extends CartLine {
|
||||
/** Needed to decide whether a PRODUCT-scoped discount matches this line. */
|
||||
readonly productId: string;
|
||||
}
|
||||
|
||||
export interface EvaluationResult {
|
||||
readonly applied: AppliedDiscount[];
|
||||
readonly rejected: RejectedDiscount[];
|
||||
readonly totalDiscount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides which discounts apply to a cart, and for how much.
|
||||
*
|
||||
* A pure function over a snapshot: no database, no clock, no I/O. Everything
|
||||
* that varies — which discounts exist, whether they are within their window,
|
||||
* whether a code has uses left — is resolved by the caller and passed in. That
|
||||
* is what makes the money-handling logic testable without a database, and this
|
||||
* is the one piece of M7 where an arithmetic mistake is a financial one.
|
||||
*
|
||||
* Order of evaluation is `priority` ascending, then id, so the outcome does not
|
||||
* depend on the order the database happened to return rows in.
|
||||
*/
|
||||
export function evaluateDiscounts(
|
||||
candidates: readonly DiscountCandidate[],
|
||||
lines: readonly EvaluationLine[],
|
||||
currency: CurrencyCode,
|
||||
rejectionsIn: readonly RejectedDiscount[] = [],
|
||||
): EvaluationResult {
|
||||
const subtotal = lines.reduce((sum, line) => sum + line.lineTotal.amount, 0);
|
||||
|
||||
const applied: AppliedDiscount[] = [];
|
||||
const rejected: RejectedDiscount[] = [...rejectionsIn];
|
||||
|
||||
// Running total, so a second discount never discounts money the first one
|
||||
// already took off. Without this, two 60% offers would make an order free.
|
||||
let remaining = subtotal;
|
||||
|
||||
const ordered = [...candidates].sort(
|
||||
(a, b) => a.priority - b.priority || a.id.localeCompare(b.id),
|
||||
);
|
||||
|
||||
for (const candidate of ordered) {
|
||||
if (remaining <= 0) break;
|
||||
|
||||
if (candidate.minSubtotalAmount !== null && subtotal < candidate.minSubtotalAmount) {
|
||||
pushRejection(rejected, candidate, DISCOUNT_REJECTIONS.MINIMUM_NOT_MET, {
|
||||
amount: candidate.minSubtotalAmount,
|
||||
currency,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-stackable discount that has already been beaten cannot join in.
|
||||
*
|
||||
* Checked against what is *already applied* rather than against the whole
|
||||
* candidate list, so "one offer at a time" means the best one that fit, not
|
||||
* whichever happened to be evaluated first.
|
||||
*/
|
||||
if (applied.length > 0 && !candidate.stackable) {
|
||||
pushRejection(rejected, candidate, DISCOUNT_REJECTIONS.NOT_COMBINABLE, null);
|
||||
continue;
|
||||
}
|
||||
|
||||
const base =
|
||||
candidate.scope === DISCOUNT_SCOPES.PRODUCT
|
||||
? lines
|
||||
.filter((line) => candidate.productIds.includes(line.productId))
|
||||
.reduce((sum, line) => sum + line.lineTotal.amount, 0)
|
||||
: remaining;
|
||||
|
||||
if (base <= 0) {
|
||||
pushRejection(rejected, candidate, DISCOUNT_REJECTIONS.NOTHING_ELIGIBLE, null);
|
||||
continue;
|
||||
}
|
||||
|
||||
const raw =
|
||||
candidate.type === DISCOUNT_TYPES.PERCENTAGE
|
||||
? // Floor, not round: rounding up would hand out a fraction of a đồng
|
||||
// the merchant never agreed to, on every single order.
|
||||
Math.floor((base * candidate.value) / 100)
|
||||
: candidate.value;
|
||||
|
||||
// Never more than is left to discount. A 500k fixed discount on a 300k cart
|
||||
// takes 300k, not 500k — a negative total is not a refund, it is a bug.
|
||||
const amount = Math.min(raw, remaining, base);
|
||||
|
||||
if (amount <= 0) {
|
||||
pushRejection(rejected, candidate, DISCOUNT_REJECTIONS.NOTHING_ELIGIBLE, null);
|
||||
continue;
|
||||
}
|
||||
|
||||
applied.push({
|
||||
id: candidate.id,
|
||||
code: candidate.code,
|
||||
name: candidate.name,
|
||||
amount: { amount, currency },
|
||||
});
|
||||
|
||||
remaining -= amount;
|
||||
|
||||
// A non-stackable discount that *did* apply closes the door behind it.
|
||||
if (!candidate.stackable) break;
|
||||
}
|
||||
|
||||
return {
|
||||
applied,
|
||||
rejected,
|
||||
totalDiscount: applied.reduce((sum, discount) => sum + discount.amount.amount, 0),
|
||||
};
|
||||
}
|
||||
|
||||
function pushRejection(
|
||||
rejected: RejectedDiscount[],
|
||||
candidate: DiscountCandidate,
|
||||
reason: DiscountRejectionReason,
|
||||
minimumSubtotal: { amount: number; currency: CurrencyCode } | null,
|
||||
): void {
|
||||
// Automatic promotions that simply did not apply are not failures worth
|
||||
// reporting — only a code the shopper actually typed deserves an explanation.
|
||||
if (!candidate.code) return;
|
||||
|
||||
rejected.push({ code: candidate.code, reason, minimumSubtotal });
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { LOCALES, type AdminDiscount, type OffsetPaginated } from '@sport/types';
|
||||
import type { DiscountInput, DiscountListQuery } from '@sport/validation';
|
||||
|
||||
import { AuditService } from '@/common/audit/audit.service';
|
||||
import { AppException } from '@/common/errors/app.exception';
|
||||
import { toDbLocale } from '@/common/i18n';
|
||||
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
|
||||
|
||||
const detailSelect = {
|
||||
id: true,
|
||||
code: true,
|
||||
trigger: true,
|
||||
type: true,
|
||||
scope: true,
|
||||
value: true,
|
||||
minSubtotalAmount: true,
|
||||
startsAt: true,
|
||||
endsAt: true,
|
||||
isActive: true,
|
||||
usageLimit: true,
|
||||
usageCount: true,
|
||||
stackable: true,
|
||||
priority: true,
|
||||
createdAt: true,
|
||||
translations: true,
|
||||
products: { select: { productId: true } },
|
||||
collections: { select: { collectionId: true } },
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export class PromotionsAdminService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
async list(query: DiscountListQuery): Promise<OffsetPaginated<AdminDiscount>> {
|
||||
const where: Prisma.DiscountWhereInput = {
|
||||
deletedAt: null,
|
||||
...(query.trigger ? { trigger: query.trigger } : {}),
|
||||
...(query.q
|
||||
? {
|
||||
OR: [
|
||||
{ code: { contains: query.q, mode: 'insensitive' } },
|
||||
{ translations: { some: { name: { contains: query.q, mode: 'insensitive' } } } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [rows, totalItems] = await Promise.all([
|
||||
this.prisma.discount.findMany({
|
||||
where,
|
||||
// Active first, then newest — an operator opens this screen to find
|
||||
// what is running, not what once ran.
|
||||
orderBy: [{ isActive: 'desc' }, { createdAt: 'desc' }],
|
||||
skip: (query.page - 1) * query.perPage,
|
||||
take: query.perPage,
|
||||
select: detailSelect,
|
||||
}),
|
||||
this.prisma.discount.count({ where }),
|
||||
]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / query.perPage));
|
||||
|
||||
return {
|
||||
items: rows.map((row) => toAdminDiscount(row)),
|
||||
pageInfo: {
|
||||
page: query.page,
|
||||
perPage: query.perPage,
|
||||
totalItems,
|
||||
totalPages,
|
||||
hasNextPage: query.page < totalPages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<AdminDiscount> {
|
||||
const row = await this.prisma.discount.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
select: detailSelect,
|
||||
});
|
||||
if (!row) throw AppException.notFound('Discount');
|
||||
|
||||
return toAdminDiscount(row);
|
||||
}
|
||||
|
||||
async create(input: DiscountInput, actorUserId: string): Promise<AdminDiscount> {
|
||||
const id = await this.prisma.$transaction(async (tx) => {
|
||||
const discount = await tx.discount.create({
|
||||
data: this.toRow(input),
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await this.writeRelations(tx, discount.id, input);
|
||||
return discount.id;
|
||||
});
|
||||
|
||||
this.audit.record({
|
||||
actorUserId,
|
||||
action: 'discount.create',
|
||||
resourceType: 'Discount',
|
||||
resourceId: id,
|
||||
changes: { code: input.code ?? null, type: input.type, value: input.value },
|
||||
});
|
||||
|
||||
return this.getById(id);
|
||||
}
|
||||
|
||||
async update(id: string, input: DiscountInput, actorUserId: string): Promise<AdminDiscount> {
|
||||
const existing = await this.prisma.discount.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) throw AppException.notFound('Discount');
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.discount.update({ where: { id }, data: this.toRow(input) });
|
||||
await this.writeRelations(tx, id, input);
|
||||
});
|
||||
|
||||
this.audit.record({
|
||||
actorUserId,
|
||||
action: 'discount.update',
|
||||
resourceType: 'Discount',
|
||||
resourceId: id,
|
||||
changes: { code: input.code ?? null, isActive: input.isActive },
|
||||
});
|
||||
|
||||
return this.getById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete.
|
||||
*
|
||||
* `DiscountRedemption.discount` is `onDelete: Restrict` on purpose — an order
|
||||
* that received a discount must keep pointing at the thing it received. So a
|
||||
* removed discount stops applying rather than ceasing to exist.
|
||||
*/
|
||||
async remove(id: string, actorUserId: string): Promise<void> {
|
||||
await this.prisma.discount.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date(), isActive: false },
|
||||
});
|
||||
|
||||
this.audit.record({
|
||||
actorUserId,
|
||||
action: 'discount.delete',
|
||||
resourceType: 'Discount',
|
||||
resourceId: id,
|
||||
changes: {},
|
||||
});
|
||||
}
|
||||
|
||||
private toRow(input: DiscountInput) {
|
||||
return {
|
||||
// Uppercased so lookup is case-insensitive without a functional index.
|
||||
code: input.trigger === 'CODE' ? (input.code ?? null) : null,
|
||||
trigger: input.trigger,
|
||||
type: input.type,
|
||||
scope: input.scope,
|
||||
value: input.value,
|
||||
minSubtotalAmount: input.minSubtotalAmount ?? null,
|
||||
startsAt: input.startsAt ? new Date(input.startsAt) : null,
|
||||
endsAt: input.endsAt ? new Date(input.endsAt) : null,
|
||||
isActive: input.isActive,
|
||||
usageLimit: input.usageLimit ?? null,
|
||||
stackable: input.stackable,
|
||||
priority: input.priority,
|
||||
};
|
||||
}
|
||||
|
||||
private async writeRelations(
|
||||
tx: Prisma.TransactionClient,
|
||||
discountId: string,
|
||||
input: DiscountInput,
|
||||
): Promise<void> {
|
||||
for (const locale of LOCALES) {
|
||||
const fields = input.translations[locale];
|
||||
if (!fields) continue;
|
||||
|
||||
await tx.discountTranslation.upsert({
|
||||
where: { discountId_locale: { discountId, locale: toDbLocale(locale) } },
|
||||
update: { name: fields.name, description: fields.description ?? null },
|
||||
create: {
|
||||
discountId,
|
||||
locale: toDbLocale(locale),
|
||||
name: fields.name,
|
||||
description: fields.description ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await tx.discountProduct.deleteMany({ where: { discountId } });
|
||||
await tx.discountCollection.deleteMany({ where: { discountId } });
|
||||
|
||||
if (input.productIds.length > 0) {
|
||||
await tx.discountProduct.createMany({
|
||||
data: input.productIds.map((productId) => ({ discountId, productId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.collectionIds.length > 0) {
|
||||
await tx.discountCollection.createMany({
|
||||
data: input.collectionIds.map((collectionId) => ({ discountId, collectionId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type DiscountRow = Prisma.DiscountGetPayload<{ select: typeof detailSelect }>;
|
||||
|
||||
function toAdminDiscount(row: DiscountRow): AdminDiscount {
|
||||
return {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
trigger: row.trigger,
|
||||
type: row.type,
|
||||
scope: row.scope,
|
||||
value: row.value,
|
||||
translations: Object.fromEntries(
|
||||
row.translations.map((translation) => [
|
||||
translation.locale.toLowerCase(),
|
||||
{ name: translation.name, description: translation.description },
|
||||
]),
|
||||
),
|
||||
minSubtotalAmount: row.minSubtotalAmount,
|
||||
startsAt: row.startsAt?.toISOString() ?? null,
|
||||
endsAt: row.endsAt?.toISOString() ?? null,
|
||||
isActive: row.isActive,
|
||||
usageLimit: row.usageLimit,
|
||||
usageCount: row.usageCount,
|
||||
stackable: row.stackable,
|
||||
priority: row.priority,
|
||||
productIds: row.products.map((product) => product.productId),
|
||||
collectionIds: row.collections.map((collection) => collection.collectionId),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
PERMISSIONS,
|
||||
TOKEN_AUDIENCES,
|
||||
type AdminDiscount,
|
||||
type AuthenticatedActor,
|
||||
type OffsetPaginated,
|
||||
} from '@sport/types';
|
||||
import {
|
||||
discountInputSchema,
|
||||
discountListQuerySchema,
|
||||
type DiscountInput,
|
||||
type DiscountListQuery,
|
||||
} from '@sport/validation';
|
||||
|
||||
import { CurrentActor } from '@/common/decorators/current-actor.decorator';
|
||||
import {
|
||||
RequireAudience,
|
||||
RequirePermissions,
|
||||
} from '@/common/decorators/require-permissions.decorator';
|
||||
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
|
||||
|
||||
import { PromotionsAdminService } from './promotions-admin.service';
|
||||
|
||||
/**
|
||||
* Promotions and coupons are one resource with one permission surface.
|
||||
*
|
||||
* `PROMOTION_MANAGE` covers both, because they are the same entity: a coupon is
|
||||
* a promotion that needs a code typed. Splitting the permission would imply a
|
||||
* separation the data model deliberately does not have.
|
||||
*/
|
||||
@ApiTags('admin/discounts')
|
||||
@ApiBearerAuth()
|
||||
@RequireAudience(TOKEN_AUDIENCES.ADMIN)
|
||||
@Controller('admin/discounts')
|
||||
export class PromotionsAdminController {
|
||||
constructor(private readonly service: PromotionsAdminService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions(PERMISSIONS.PROMOTION_MANAGE)
|
||||
@ApiOperation({ summary: 'Discounts, active first' })
|
||||
list(
|
||||
@Query(new ZodValidationPipe(discountListQuerySchema)) query: DiscountListQuery,
|
||||
): Promise<OffsetPaginated<AdminDiscount>> {
|
||||
return this.service.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions(PERMISSIONS.PROMOTION_MANAGE)
|
||||
@ApiOperation({ summary: 'One discount' })
|
||||
getById(@Param('id') id: string): Promise<AdminDiscount> {
|
||||
return this.service.getById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions(PERMISSIONS.PROMOTION_MANAGE)
|
||||
@ApiOperation({ summary: 'Create a promotion or coupon' })
|
||||
create(
|
||||
@Body(new ZodValidationPipe(discountInputSchema)) body: DiscountInput,
|
||||
@CurrentActor() actor: AuthenticatedActor,
|
||||
): Promise<AdminDiscount> {
|
||||
return this.service.create(body, actor.userId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions(PERMISSIONS.PROMOTION_MANAGE)
|
||||
@ApiOperation({ summary: 'Update a discount' })
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(discountInputSchema)) body: DiscountInput,
|
||||
@CurrentActor() actor: AuthenticatedActor,
|
||||
): Promise<AdminDiscount> {
|
||||
return this.service.update(id, body, actor.userId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermissions(PERMISSIONS.PROMOTION_MANAGE)
|
||||
@ApiOperation({ summary: 'Retire a discount; redemptions keep pointing at it' })
|
||||
remove(@Param('id') id: string, @CurrentActor() actor: AuthenticatedActor): Promise<void> {
|
||||
return this.service.remove(id, actor.userId);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PromotionsAdminService } from './promotions-admin.service';
|
||||
import { PromotionsAdminController } from './promotions.controller';
|
||||
import { PromotionsService } from './promotions.service';
|
||||
|
||||
/**
|
||||
* PromotionsModule — boundary declared, implementation pending.
|
||||
* PromotionsModule — owns `discounts` and everything hanging off it.
|
||||
*
|
||||
* Owns (exclusively): `promotions`, `promotion_rules` — milestone 3
|
||||
* Promotions and coupons are the same entity with different triggers, so they
|
||||
* are one module with one rule engine. Two engines would have to agree about
|
||||
* stacking, rounding and limits, and they would not for long.
|
||||
*
|
||||
* Automatic, cart-level discounts. Pricing is calculated in one place so storefront, admin and invoices can never disagree.
|
||||
*
|
||||
* Anatomy once implemented (see ../README.md):
|
||||
* promotions.module.ts wiring only
|
||||
* promotions.controller.ts HTTP surface, no logic
|
||||
* promotions.service.ts business rules
|
||||
* promotions.repository.ts the only file that touches Prisma
|
||||
* dto/ request/response shapes
|
||||
* public/ what other modules may import
|
||||
* The arithmetic lives in `discount-engine.ts` as a pure function: no clock, no
|
||||
* database. This service resolves eligibility — which is precisely the part
|
||||
* that needs both — and hands a snapshot to the engine.
|
||||
*/
|
||||
@Module({})
|
||||
@Module({
|
||||
controllers: [PromotionsAdminController],
|
||||
providers: [PromotionsService, PromotionsAdminService],
|
||||
exports: [PromotionsService],
|
||||
})
|
||||
export class PromotionsModule {}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import {
|
||||
DISCOUNT_REJECTIONS,
|
||||
type CurrencyCode,
|
||||
type Locale,
|
||||
type RejectedDiscount,
|
||||
} from '@sport/types';
|
||||
|
||||
import { AppException } from '@/common/errors/app.exception';
|
||||
import { toDbLocale } from '@/common/i18n';
|
||||
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
|
||||
|
||||
import {
|
||||
evaluateDiscounts,
|
||||
type DiscountCandidate,
|
||||
type EvaluationLine,
|
||||
type EvaluationResult,
|
||||
} from './discount-engine';
|
||||
|
||||
@Injectable()
|
||||
export class PromotionsService {
|
||||
private readonly logger = new Logger(PromotionsService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Works out what a cart is entitled to.
|
||||
*
|
||||
* Two stages, deliberately separated: this method decides *eligibility* —
|
||||
* which discounts exist, are live, are within their window and have uses left
|
||||
* — and the pure engine decides *arithmetic*. Anything involving a clock or a
|
||||
* database is resolved here so the money maths stays testable without either.
|
||||
*/
|
||||
async evaluate(
|
||||
lines: readonly EvaluationLine[],
|
||||
codes: readonly string[],
|
||||
currency: CurrencyCode,
|
||||
locale: Locale,
|
||||
): Promise<EvaluationResult> {
|
||||
const normalised = unique(codes.map((code) => code.trim().toUpperCase()).filter(Boolean));
|
||||
const now = new Date();
|
||||
|
||||
const rows = await this.prisma.discount.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
isActive: true,
|
||||
OR: [{ trigger: 'AUTOMATIC' }, { code: { in: normalised } }],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
trigger: true,
|
||||
type: true,
|
||||
scope: true,
|
||||
value: true,
|
||||
minSubtotalAmount: true,
|
||||
startsAt: true,
|
||||
endsAt: true,
|
||||
usageLimit: true,
|
||||
usageCount: true,
|
||||
stackable: true,
|
||||
priority: true,
|
||||
translations: { where: { locale: toDbLocale(locale) }, select: { name: true } },
|
||||
products: { select: { productId: true } },
|
||||
collections: { select: { collectionId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const found = new Set(rows.map((row) => row.code).filter(Boolean) as string[]);
|
||||
const rejected: RejectedDiscount[] = normalised
|
||||
.filter((code) => !found.has(code))
|
||||
.map((code) => ({ code, reason: DISCOUNT_REJECTIONS.NOT_FOUND, minimumSubtotal: null }));
|
||||
|
||||
const candidates: DiscountCandidate[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
// Window and usage are checked here rather than in the engine because
|
||||
// both depend on state the engine deliberately cannot see.
|
||||
if (row.startsAt && row.startsAt > now) {
|
||||
pushIfCoded(rejected, row.code, DISCOUNT_REJECTIONS.NOT_STARTED);
|
||||
continue;
|
||||
}
|
||||
if (row.endsAt && row.endsAt < now) {
|
||||
pushIfCoded(rejected, row.code, DISCOUNT_REJECTIONS.EXPIRED);
|
||||
continue;
|
||||
}
|
||||
if (row.usageLimit !== null && row.usageCount >= row.usageLimit) {
|
||||
pushIfCoded(rejected, row.code, DISCOUNT_REJECTIONS.USAGE_LIMIT_REACHED);
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.push({
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
// Falls back to the code so an untranslated discount still names itself
|
||||
// on the order summary rather than rendering blank.
|
||||
name: row.translations[0]?.name ?? row.code ?? 'Discount',
|
||||
type: row.type,
|
||||
scope: row.scope,
|
||||
value: row.value,
|
||||
minSubtotalAmount: row.minSubtotalAmount,
|
||||
stackable: row.stackable,
|
||||
priority: row.priority,
|
||||
productIds: await this.resolveTargets(row.id, row.products, row.collections),
|
||||
});
|
||||
}
|
||||
|
||||
return evaluateDiscounts(candidates, lines, currency, rejected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Claims one use of each discount and records what it granted.
|
||||
*
|
||||
* The increment is a conditional UPDATE guarded on the limit, for exactly the
|
||||
* reason stock reservation is: read-then-write lets two shoppers redeem the
|
||||
* last use of a code simultaneously and both succeed. Zero rows affected
|
||||
* means the code ran out between evaluation and checkout, and the order must
|
||||
* not silently receive a discount nobody is counting.
|
||||
*/
|
||||
async redeem(
|
||||
tx: Prisma.TransactionClient,
|
||||
orderId: string,
|
||||
applied: readonly { id: string; code: string | null; amount: { amount: number } }[],
|
||||
): Promise<void> {
|
||||
for (const discount of applied) {
|
||||
const claimed = await tx.$executeRaw`
|
||||
UPDATE discounts
|
||||
SET usage_count = usage_count + 1
|
||||
WHERE id = ${discount.id}::uuid
|
||||
AND (usage_limit IS NULL OR usage_count < usage_limit)
|
||||
`;
|
||||
|
||||
if (claimed === 0) {
|
||||
/**
|
||||
* A domain conflict, not a server fault.
|
||||
*
|
||||
* Thrown as a plain Error this surfaced as "Something went wrong on our
|
||||
* side" — which tells a shopper nothing and blames the wrong party. The
|
||||
* order is rolled back deliberately: re-pricing it upward without the
|
||||
* discount would charge them more than the total they agreed to.
|
||||
*/
|
||||
throw AppException.conflict(
|
||||
discount.code
|
||||
? `The code ${discount.code} was just used up. Remove it and review your total.`
|
||||
: 'A promotion in your bag is no longer available. Review your total and try again.',
|
||||
);
|
||||
}
|
||||
|
||||
await tx.discountRedemption.create({
|
||||
data: {
|
||||
discountId: discount.id,
|
||||
orderId,
|
||||
amount: discount.amount.amount,
|
||||
code: discount.code,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Hands uses back when an order that consumed them is cancelled. */
|
||||
async release(tx: Prisma.TransactionClient, orderId: string): Promise<void> {
|
||||
const redemptions = await tx.discountRedemption.findMany({
|
||||
where: { orderId },
|
||||
select: { discountId: true },
|
||||
});
|
||||
|
||||
for (const redemption of redemptions) {
|
||||
await tx.$executeRaw`
|
||||
UPDATE discounts
|
||||
SET usage_count = GREATEST(usage_count - 1, 0)
|
||||
WHERE id = ${redemption.discountId}::uuid
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Product ids a discount targets, expanding collections into their members.
|
||||
*
|
||||
* Expanded at evaluation time rather than stored, so adding a product to a
|
||||
* targeted collection takes effect immediately instead of when someone
|
||||
* remembers to re-save the discount.
|
||||
*/
|
||||
private async resolveTargets(
|
||||
discountId: string,
|
||||
products: readonly { productId: string }[],
|
||||
collections: readonly { collectionId: string }[],
|
||||
): Promise<string[]> {
|
||||
const ids = products.map((row) => row.productId);
|
||||
|
||||
if (collections.length === 0) return ids;
|
||||
|
||||
const members = await this.prisma.productCollection.findMany({
|
||||
where: { collectionId: { in: collections.map((row) => row.collectionId) } },
|
||||
select: { productId: true },
|
||||
});
|
||||
|
||||
this.logger.debug(`Discount ${discountId} targets ${members.length} collection product(s)`);
|
||||
return unique([...ids, ...members.map((row) => row.productId)]);
|
||||
}
|
||||
}
|
||||
|
||||
function unique(values: readonly string[]): string[] {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function pushIfCoded(
|
||||
rejected: RejectedDiscount[],
|
||||
code: string | null,
|
||||
reason: RejectedDiscount['reason'],
|
||||
): void {
|
||||
if (code) rejected.push({ code, reason, minimumSubtotal: null });
|
||||
}
|
||||
@@ -7,4 +7,5 @@
|
||||
*
|
||||
* Keep it narrow: each export is a promise to the rest of the codebase.
|
||||
*/
|
||||
export {};
|
||||
export { PromotionsService } from '../promotions.service';
|
||||
export type { EvaluationLine, EvaluationResult } from '../discount-engine';
|
||||
|
||||
@@ -7,4 +7,4 @@
|
||||
*
|
||||
* Keep it narrow: each export is a promise to the rest of the codebase.
|
||||
*/
|
||||
export {};
|
||||
export { ReviewsService } from '../reviews.service';
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { REVIEW_RATING_MAX, REVIEW_RATING_MIN } from '@sport/types';
|
||||
|
||||
/**
|
||||
* The rating aggregate, extracted so it can be pinned without a database.
|
||||
*
|
||||
* These four lines decide the number under every product name in the store. A
|
||||
* mistake here is not a crash — it is a product quietly displaying 4.5 stars it
|
||||
* did not earn, on every page, until somebody notices by eye.
|
||||
*/
|
||||
export function summarise(ratings: readonly number[]): {
|
||||
average: number;
|
||||
count: number;
|
||||
distribution: { rating: number; count: number }[];
|
||||
} {
|
||||
const count = ratings.length;
|
||||
const sum = ratings.reduce((total, rating) => total + rating, 0);
|
||||
|
||||
const distribution: { rating: number; count: number }[] = [];
|
||||
for (let rating = REVIEW_RATING_MAX; rating >= REVIEW_RATING_MIN; rating -= 1) {
|
||||
distribution.push({ rating, count: ratings.filter((value) => value === rating).length });
|
||||
}
|
||||
|
||||
return {
|
||||
average: count === 0 ? 0 : Math.round((sum / count) * 10) / 10,
|
||||
count,
|
||||
distribution,
|
||||
};
|
||||
}
|
||||
|
||||
describe('review summary', () => {
|
||||
it('averages to one decimal place', () => {
|
||||
expect(summarise([5, 4, 4]).average).toBe(4.3);
|
||||
});
|
||||
|
||||
it('does not divide by zero on an unreviewed product', () => {
|
||||
const summary = summarise([]);
|
||||
|
||||
expect(summary.average).toBe(0);
|
||||
expect(summary.count).toBe(0);
|
||||
});
|
||||
|
||||
it('reports every star level, including the empty ones', () => {
|
||||
// A distribution with gaps renders as a bar chart with missing bars rather
|
||||
// than bars at zero, which reads as a broken chart.
|
||||
const summary = summarise([5, 5, 1]);
|
||||
|
||||
expect(summary.distribution).toEqual([
|
||||
{ rating: 5, count: 2 },
|
||||
{ rating: 4, count: 0 },
|
||||
{ rating: 3, count: 0 },
|
||||
{ rating: 2, count: 0 },
|
||||
{ rating: 1, count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('orders the distribution from best to worst', () => {
|
||||
expect(summarise([3]).distribution.map((bucket) => bucket.rating)).toEqual([5, 4, 3, 2, 1]);
|
||||
});
|
||||
|
||||
it('distinguishes a polarised product from a mediocre one', () => {
|
||||
// Both average 3.0. The distribution is the only thing that tells a shopper
|
||||
// which one has a sizing problem, which is why it is sent alongside.
|
||||
const mediocre = summarise([3, 3, 3, 3]);
|
||||
const polarised = summarise([5, 5, 1, 1]);
|
||||
|
||||
expect(mediocre.average).toBe(polarised.average);
|
||||
expect(mediocre.distribution).not.toEqual(polarised.distribution);
|
||||
});
|
||||
|
||||
it('rounds half up so 4.25 does not read as 4.2', () => {
|
||||
expect(summarise([5, 4, 4, 4]).average).toBe(4.3);
|
||||
});
|
||||
|
||||
it('keeps a whole number whole', () => {
|
||||
// 4.0 rather than 4 matters for display, but the value must still equal 4.
|
||||
expect(summarise([4, 4, 4])).toMatchObject({ average: 4, count: 3 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
PERMISSIONS,
|
||||
TOKEN_AUDIENCES,
|
||||
type AdminReview,
|
||||
type AuthenticatedActor,
|
||||
type Locale,
|
||||
type OffsetPaginated,
|
||||
type ReviewSummary,
|
||||
type ReviewableItem,
|
||||
type StorefrontReview,
|
||||
} from '@sport/types';
|
||||
import {
|
||||
adminReviewListQuerySchema,
|
||||
moderateReviewSchema,
|
||||
reviewListQuerySchema,
|
||||
submitReviewSchema,
|
||||
type AdminReviewListQuery,
|
||||
type ModerateReviewInput,
|
||||
type ReviewListQuery,
|
||||
type SubmitReviewInput,
|
||||
} from '@sport/validation';
|
||||
|
||||
import { CurrentActor } from '@/common/decorators/current-actor.decorator';
|
||||
import { Public } from '@/common/decorators/public.decorator';
|
||||
import {
|
||||
RequireAudience,
|
||||
RequirePermissions,
|
||||
} from '@/common/decorators/require-permissions.decorator';
|
||||
import { RequestLocale } from '@/common/i18n';
|
||||
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
|
||||
|
||||
import { ReviewsService } from './reviews.service';
|
||||
|
||||
@ApiTags('reviews')
|
||||
@Controller('reviews')
|
||||
export class ReviewsController {
|
||||
constructor(private readonly service: ReviewsService) {}
|
||||
|
||||
@Get('product/:productId')
|
||||
@Public()
|
||||
@ApiOperation({ summary: 'Approved reviews for a product, with a rating summary' })
|
||||
listForProduct(
|
||||
@Param('productId') productId: string,
|
||||
@Query(new ZodValidationPipe(reviewListQuerySchema)) query: ReviewListQuery,
|
||||
): Promise<OffsetPaginated<StorefrontReview> & { summary: ReviewSummary }> {
|
||||
return this.service.listForProduct(productId, query);
|
||||
}
|
||||
|
||||
/**
|
||||
* What an order entitles its buyer to review.
|
||||
*
|
||||
* A POST despite reading nothing: the email is proof of ownership, and
|
||||
* proof does not belong in a query string where it lands in server logs,
|
||||
* browser history and the Referer header of every asset on the page.
|
||||
*/
|
||||
@Post('reviewable')
|
||||
@Public()
|
||||
@ApiOperation({ summary: 'Items from one order that may be reviewed' })
|
||||
@ApiQuery({ name: 'locale', required: false, enum: ['vi', 'en'] })
|
||||
listReviewable(
|
||||
@Body() body: { orderId?: string; email?: string },
|
||||
@RequestLocale() locale: Locale,
|
||||
): Promise<readonly ReviewableItem[]> {
|
||||
return this.service.listReviewable(body.orderId ?? '', body.email ?? '', locale);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Public()
|
||||
@ApiOperation({ summary: 'Submit a review for a purchased item' })
|
||||
@ApiQuery({ name: 'locale', required: false, enum: ['vi', 'en'] })
|
||||
submit(
|
||||
@Body(new ZodValidationPipe(submitReviewSchema)) body: SubmitReviewInput,
|
||||
@RequestLocale() locale: Locale,
|
||||
): Promise<ReviewableItem[]> {
|
||||
return this.service.submit(body, locale);
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags('admin/reviews')
|
||||
@ApiBearerAuth()
|
||||
@RequireAudience(TOKEN_AUDIENCES.ADMIN)
|
||||
@Controller('admin/reviews')
|
||||
export class ReviewsAdminController {
|
||||
constructor(private readonly service: ReviewsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions(PERMISSIONS.REVIEW_MODERATE)
|
||||
@ApiOperation({ summary: 'Moderation queue, pending first and oldest first' })
|
||||
list(
|
||||
@Query(new ZodValidationPipe(adminReviewListQuerySchema)) query: AdminReviewListQuery,
|
||||
@RequestLocale() locale: Locale,
|
||||
): Promise<OffsetPaginated<AdminReview>> {
|
||||
return this.service.listForAdmin(query, locale);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions(PERMISSIONS.REVIEW_MODERATE)
|
||||
@ApiOperation({ summary: 'Approve or reject a review' })
|
||||
moderate(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(moderateReviewSchema)) body: ModerateReviewInput,
|
||||
@CurrentActor() actor: AuthenticatedActor,
|
||||
@RequestLocale() locale: Locale,
|
||||
): Promise<AdminReview> {
|
||||
return this.service.moderate(id, body, actor.userId, locale);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ReviewsAdminController, ReviewsController } from './reviews.controller';
|
||||
import { ReviewsRepository } from './reviews.repository';
|
||||
import { ReviewsService } from './reviews.service';
|
||||
|
||||
/**
|
||||
* ReviewsModule — boundary declared, implementation pending.
|
||||
* ReviewsModule — owns `reviews` and the rating aggregate on `products`.
|
||||
*
|
||||
* Owns (exclusively): `reviews` — milestone 3
|
||||
* A review is anchored to an order line, which is what makes "verified
|
||||
* purchase" structural rather than a flag: you cannot review what you did not
|
||||
* buy, because there is no row to attach the review to.
|
||||
*
|
||||
* Verified-purchase reviews with moderation. Rating aggregates are denormalised onto the product read model, never computed per page view.
|
||||
*
|
||||
* Anatomy once implemented (see ../README.md):
|
||||
* reviews.module.ts wiring only
|
||||
* reviews.controller.ts HTTP surface, no logic
|
||||
* reviews.service.ts business rules
|
||||
* reviews.repository.ts the only file that touches Prisma
|
||||
* dto/ request/response shapes
|
||||
* public/ what other modules may import
|
||||
* It writes two columns it does not own — `products.rating_sum` and
|
||||
* `rating_count` — and is the *only* writer of them, in the same way
|
||||
* ProductsService is the only writer of the price projection. The alternative,
|
||||
* computing an average per page view, is a scan of every review on the busiest
|
||||
* query in the catalog.
|
||||
*/
|
||||
@Module({})
|
||||
@Module({
|
||||
controllers: [ReviewsController, ReviewsAdminController],
|
||||
providers: [ReviewsService, ReviewsRepository],
|
||||
exports: [ReviewsService],
|
||||
})
|
||||
export class ReviewsModule {}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
|
||||
|
||||
const adminSelect = {
|
||||
id: true,
|
||||
status: true,
|
||||
rating: true,
|
||||
title: true,
|
||||
body: true,
|
||||
authorName: true,
|
||||
productId: true,
|
||||
moderationNote: true,
|
||||
moderatedAt: true,
|
||||
createdAt: true,
|
||||
moderatedBy: { select: { firstName: true, lastName: true } },
|
||||
product: { select: { slug: true, translations: { select: { locale: true, name: true } } } },
|
||||
orderLine: {
|
||||
select: {
|
||||
productName: true,
|
||||
variantTitle: true,
|
||||
order: { select: { id: true, number: true } },
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type AdminReviewRow = Prisma.ReviewGetPayload<{ select: typeof adminSelect }>;
|
||||
|
||||
/** The only file in this module that touches Prisma. */
|
||||
@Injectable()
|
||||
export class ReviewsRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
// ---- Storefront ----------------------------------------------------------
|
||||
|
||||
findApproved(
|
||||
productId: string,
|
||||
skip: number,
|
||||
take: number,
|
||||
orderBy: Prisma.ReviewOrderByWithRelationInput[],
|
||||
) {
|
||||
return this.prisma.review.findMany({
|
||||
where: { productId, status: 'APPROVED' },
|
||||
orderBy,
|
||||
skip,
|
||||
take,
|
||||
select: {
|
||||
id: true,
|
||||
rating: true,
|
||||
title: true,
|
||||
body: true,
|
||||
authorName: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
countApproved(productId: string): Promise<number> {
|
||||
return this.prisma.review.count({ where: { productId, status: 'APPROVED' } });
|
||||
}
|
||||
|
||||
/**
|
||||
* How many approved reviews sit at each star level.
|
||||
*
|
||||
* Grouped in the database rather than by counting a fetched page: the
|
||||
* distribution describes every review, not the twenty currently on screen.
|
||||
*/
|
||||
distribution(productId: string) {
|
||||
return this.prisma.review.groupBy({
|
||||
by: ['rating'],
|
||||
where: { productId, status: 'APPROVED' },
|
||||
_count: { _all: true },
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Submission ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The order, its lines, and any reviews already written against them.
|
||||
*
|
||||
* One query rather than three: everything the submission path needs to decide
|
||||
* both "may they review this" and "have they already".
|
||||
*/
|
||||
findOrderForReview(orderId: string) {
|
||||
return this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
status: true,
|
||||
lines: {
|
||||
select: {
|
||||
id: true,
|
||||
productName: true,
|
||||
variantTitle: true,
|
||||
imageUrl: true,
|
||||
variant: {
|
||||
select: {
|
||||
product: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
translations: { select: { locale: true, name: true, slug: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
review: { select: { id: true, status: true, rating: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
create(data: Prisma.ReviewUncheckedCreateInput) {
|
||||
return this.prisma.review.create({ data, select: { id: true, productId: true } });
|
||||
}
|
||||
|
||||
// ---- Moderation ----------------------------------------------------------
|
||||
|
||||
findForAdmin(where: Prisma.ReviewWhereInput, skip: number, take: number) {
|
||||
return this.prisma.review.findMany({
|
||||
where,
|
||||
// Oldest first: a moderation queue is a queue. Newest-first buries the
|
||||
// review that has been waiting longest under everything since.
|
||||
orderBy: [{ status: 'asc' }, { createdAt: 'asc' }],
|
||||
skip,
|
||||
take,
|
||||
select: adminSelect,
|
||||
});
|
||||
}
|
||||
|
||||
countForAdmin(where: Prisma.ReviewWhereInput): Promise<number> {
|
||||
return this.prisma.review.count({ where });
|
||||
}
|
||||
|
||||
findById(id: string) {
|
||||
return this.prisma.review.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, productId: true, status: true },
|
||||
});
|
||||
}
|
||||
|
||||
moderate(id: string, data: Prisma.ReviewUncheckedUpdateInput) {
|
||||
return this.prisma.review.update({ where: { id }, data, select: { id: true } });
|
||||
}
|
||||
|
||||
getByIdForAdmin(id: string) {
|
||||
return this.prisma.review.findUnique({ where: { id }, select: adminSelect });
|
||||
}
|
||||
|
||||
// ---- Aggregate -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Rewrites a product's rating aggregate from its approved reviews.
|
||||
*
|
||||
* A single statement, and deliberately a recompute rather than an increment.
|
||||
* Incrementing is faster and wrong in the cases that matter: a review edited
|
||||
* from 5 to 2, a rejection reversed, a moderator undoing a decision. Each
|
||||
* would need its own compensating delta, and one missed path leaves a product
|
||||
* displaying a rating no review supports — with no way to notice.
|
||||
*
|
||||
* `COALESCE` matters: SUM over no rows is NULL, and NULL would violate the
|
||||
* NOT NULL on both columns.
|
||||
*/
|
||||
recomputeRating(productId: string): Promise<number> {
|
||||
return this.prisma.$executeRaw`
|
||||
UPDATE products p
|
||||
SET rating_sum = COALESCE(agg.total, 0),
|
||||
rating_count = COALESCE(agg.n, 0)
|
||||
FROM (
|
||||
SELECT SUM(rating)::int AS total, COUNT(*)::int AS n
|
||||
FROM reviews
|
||||
WHERE product_id = ${productId}::uuid
|
||||
AND status = 'APPROVED'
|
||||
) agg
|
||||
WHERE p.id = ${productId}::uuid
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import {
|
||||
REVIEW_RATING_MAX,
|
||||
REVIEW_RATING_MIN,
|
||||
type AdminReview,
|
||||
type Locale,
|
||||
type OffsetPaginated,
|
||||
type ReviewSummary,
|
||||
type ReviewableItem,
|
||||
type StorefrontReview,
|
||||
} from '@sport/types';
|
||||
import type {
|
||||
AdminReviewListQuery,
|
||||
ModerateReviewInput,
|
||||
ReviewListQuery,
|
||||
SubmitReviewInput,
|
||||
} from '@sport/validation';
|
||||
|
||||
import { AuditService } from '@/common/audit/audit.service';
|
||||
import { AppException } from '@/common/errors/app.exception';
|
||||
import { coalesceRequired, pickTranslation } from '@/common/i18n';
|
||||
import { DOMAIN_EVENTS } from '@/infrastructure/events/domain-event';
|
||||
import { EventBusService } from '@/infrastructure/events/event-bus.service';
|
||||
import { CACHE_KEYS } from '@/infrastructure/redis/cache-keys';
|
||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||
|
||||
import { ReviewsRepository, type AdminReviewRow } from './reviews.repository';
|
||||
|
||||
@Injectable()
|
||||
export class ReviewsService {
|
||||
private readonly logger = new Logger(ReviewsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly repository: ReviewsRepository,
|
||||
private readonly audit: AuditService,
|
||||
private readonly events: EventBusService,
|
||||
private readonly redis: RedisService,
|
||||
) {}
|
||||
|
||||
// ---- Storefront ----------------------------------------------------------
|
||||
|
||||
async listForProduct(
|
||||
productId: string,
|
||||
query: ReviewListQuery,
|
||||
): Promise<OffsetPaginated<StorefrontReview> & { summary: ReviewSummary }> {
|
||||
const orderBy = ORDER_BY[query.sort];
|
||||
|
||||
const [rows, totalItems, groups] = await Promise.all([
|
||||
this.repository.findApproved(
|
||||
productId,
|
||||
(query.page - 1) * query.perPage,
|
||||
query.perPage,
|
||||
orderBy,
|
||||
),
|
||||
this.repository.countApproved(productId),
|
||||
this.repository.distribution(productId),
|
||||
]);
|
||||
|
||||
const counts = new Map(groups.map((group) => [group.rating, group._count._all]));
|
||||
const sum = groups.reduce((total, group) => total + group.rating * group._count._all, 0);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / query.perPage));
|
||||
|
||||
return {
|
||||
items: rows.map((row) => ({
|
||||
id: row.id,
|
||||
rating: row.rating,
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
authorName: row.authorName,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
// Structurally true: there is no way into this table without an order
|
||||
// line. See the Review model comment.
|
||||
isVerifiedPurchase: true,
|
||||
})),
|
||||
pageInfo: {
|
||||
page: query.page,
|
||||
perPage: query.perPage,
|
||||
totalItems,
|
||||
totalPages,
|
||||
hasNextPage: query.page < totalPages,
|
||||
},
|
||||
summary: {
|
||||
// Rounded to one decimal for display; the exact figure stays in the
|
||||
// sum/count pair on the product, so nothing is lost.
|
||||
average: totalItems === 0 ? 0 : Math.round((sum / totalItems) * 10) / 10,
|
||||
count: totalItems,
|
||||
// Every star level is present even at zero — a distribution with gaps
|
||||
// renders as a bar chart with missing bars rather than empty ones.
|
||||
distribution: descendingStars().map((rating) => ({
|
||||
rating,
|
||||
count: counts.get(rating) ?? 0,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What one order entitles its buyer to review.
|
||||
*
|
||||
* The email is checked here rather than trusted from the client: the order id
|
||||
* alone is a capability (a UUIDv7 in a URL), and pairing it with the email on
|
||||
* the order is the same bar the guest order lookup already sets.
|
||||
*/
|
||||
async listReviewable(
|
||||
orderId: string,
|
||||
email: string,
|
||||
locale: Locale,
|
||||
): Promise<readonly ReviewableItem[]> {
|
||||
const order = await this.loadOrderFor(orderId, email);
|
||||
|
||||
return order.lines.flatMap((line) => {
|
||||
const product = line.variant?.product;
|
||||
|
||||
// A line whose variant was hard-deleted has nothing to review. It stays
|
||||
// on the order (the purchase happened) but cannot produce a review,
|
||||
// because a review has to point at a product page.
|
||||
if (!product) return [];
|
||||
|
||||
const translation = pickTranslation(product.translations, locale);
|
||||
|
||||
return [
|
||||
{
|
||||
orderLineId: line.id,
|
||||
productId: product.id,
|
||||
productSlug: coalesceRequired(translation?.slug, product.slug),
|
||||
productName: coalesceRequired(translation?.name, line.productName),
|
||||
variantTitle: line.variantTitle,
|
||||
imageUrl: line.imageUrl,
|
||||
reviewId: line.review?.id ?? null,
|
||||
reviewStatus: line.review?.status ?? null,
|
||||
rating: line.review?.rating ?? null,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
async submit(input: SubmitReviewInput, locale: Locale): Promise<ReviewableItem[]> {
|
||||
const order = await this.loadOrderFor(input.orderId, input.email);
|
||||
|
||||
const line = order.lines.find((candidate) => candidate.id === input.orderLineId);
|
||||
if (!line) {
|
||||
// Deliberately the same shape of error as a bad order: confirming that a
|
||||
// line id exists on someone else's order is a small leak, but a free one.
|
||||
throw AppException.notFound('Order line');
|
||||
}
|
||||
|
||||
if (line.review) {
|
||||
throw AppException.conflict('You have already reviewed this item.');
|
||||
}
|
||||
|
||||
const product = line.variant?.product;
|
||||
if (!product) {
|
||||
throw AppException.conflict('This item is no longer available to review.');
|
||||
}
|
||||
|
||||
const created = await this.repository.create({
|
||||
productId: product.id,
|
||||
orderLineId: line.id,
|
||||
rating: input.rating,
|
||||
title: input.title ?? null,
|
||||
body: input.body ?? null,
|
||||
authorName: input.authorName,
|
||||
// PENDING by default — see the moderation note on `moderate()`.
|
||||
});
|
||||
|
||||
this.events.publish(DOMAIN_EVENTS.REVIEW_SUBMITTED, {
|
||||
reviewId: created.id,
|
||||
productId: created.productId,
|
||||
rating: input.rating,
|
||||
});
|
||||
|
||||
this.logger.log(`Review ${created.id} submitted for product ${created.productId}`);
|
||||
|
||||
// Returned rather than a bare 201: the form needs to re-render as "thanks,
|
||||
// awaiting approval", and the client should not have to guess that state.
|
||||
return [...(await this.listReviewable(input.orderId, input.email, locale))];
|
||||
}
|
||||
|
||||
// ---- Moderation ----------------------------------------------------------
|
||||
|
||||
async listForAdmin(
|
||||
query: AdminReviewListQuery,
|
||||
locale: Locale,
|
||||
): Promise<OffsetPaginated<AdminReview>> {
|
||||
const where: Prisma.ReviewWhereInput = {
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(query.q
|
||||
? {
|
||||
OR: [
|
||||
{ title: { contains: query.q, mode: 'insensitive' } },
|
||||
{ body: { contains: query.q, mode: 'insensitive' } },
|
||||
{ authorName: { contains: query.q, mode: 'insensitive' } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [rows, totalItems] = await Promise.all([
|
||||
this.repository.findForAdmin(where, (query.page - 1) * query.perPage, query.perPage),
|
||||
this.repository.countForAdmin(where),
|
||||
]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / query.perPage));
|
||||
|
||||
return {
|
||||
items: rows.map((row) => toAdminReview(row, locale)),
|
||||
pageInfo: {
|
||||
page: query.page,
|
||||
perPage: query.perPage,
|
||||
totalItems,
|
||||
totalPages,
|
||||
hasNextPage: query.page < totalPages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Approves or rejects a review, then rebuilds the product's aggregate.
|
||||
*
|
||||
* Moderation is required before publication rather than after, because the
|
||||
* alternative is that the first person to see abuse on a product page is a
|
||||
* customer. The aggregate is recomputed on every decision including
|
||||
* rejection: un-approving a review has to take its stars back out.
|
||||
*/
|
||||
async moderate(
|
||||
id: string,
|
||||
input: ModerateReviewInput,
|
||||
actorUserId: string,
|
||||
locale: Locale,
|
||||
): Promise<AdminReview> {
|
||||
const existing = await this.repository.findById(id);
|
||||
if (!existing) throw AppException.notFound('Review');
|
||||
|
||||
await this.repository.moderate(id, {
|
||||
status: input.status,
|
||||
moderationNote: input.note ?? null,
|
||||
moderatedAt: new Date(),
|
||||
moderatedByUserId: actorUserId,
|
||||
});
|
||||
|
||||
await this.repository.recomputeRating(existing.productId);
|
||||
|
||||
/**
|
||||
* Drop the catalog cache, or the decision is invisible for five minutes.
|
||||
*
|
||||
* `productDetail` is cached for 300s and the product card carries the
|
||||
* rating too, so without this a moderator approves a review, reloads the
|
||||
* product page, sees the old figure and reasonably concludes the button is
|
||||
* broken. `cache-keys.ts` states the contract — invalidated on write, TTL
|
||||
* as a safety net — and a rating change is a write to the read model.
|
||||
*
|
||||
* The whole catalog prefix rather than one product's keys: the product
|
||||
* appears in listings and collection pages under fingerprinted keys that
|
||||
* cannot be enumerated from a product id. This is the same hammer
|
||||
* ProductsAdminService.afterWrite uses, for the same reason.
|
||||
*/
|
||||
const dropped = await this.redis.deleteByPrefix(CACHE_KEYS.catalogPrefix());
|
||||
this.logger.log(`Review ${id} ${input.status.toLowerCase()}; dropped ${dropped} cache key(s)`);
|
||||
|
||||
this.audit.record({
|
||||
actorUserId,
|
||||
action: `review.${input.status.toLowerCase()}`,
|
||||
resourceType: 'Review',
|
||||
resourceId: id,
|
||||
changes: { from: existing.status, to: input.status },
|
||||
});
|
||||
|
||||
const row = await this.repository.getByIdForAdmin(id);
|
||||
if (!row) throw AppException.notFound('Review');
|
||||
|
||||
return toAdminReview(row, locale);
|
||||
}
|
||||
|
||||
// ---- internals -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Loads an order, refusing unless the email matches the one on it.
|
||||
*
|
||||
* Case-insensitive because an email address is, and because a shopper
|
||||
* retyping their address with different capitalisation is not an intruder.
|
||||
*/
|
||||
private async loadOrderFor(orderId: string, email: string) {
|
||||
const order = await this.repository.findOrderForReview(orderId);
|
||||
|
||||
if (!order || order.email.toLowerCase() !== email.trim().toLowerCase()) {
|
||||
// One error for "no such order" and "wrong email", so this endpoint
|
||||
// cannot be used to test whether an order id exists.
|
||||
throw AppException.notFound('Order');
|
||||
}
|
||||
|
||||
if (order.status === 'CANCELLED') {
|
||||
throw AppException.conflict('This order was cancelled, so its items cannot be reviewed.');
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
}
|
||||
|
||||
const ORDER_BY: Record<ReviewListQuery['sort'], Prisma.ReviewOrderByWithRelationInput[]> = {
|
||||
newest: [{ createdAt: 'desc' }],
|
||||
// Ties broken by recency so the order is total — otherwise page 2 can repeat
|
||||
// a review that page 1 already showed.
|
||||
rating_desc: [{ rating: 'desc' }, { createdAt: 'desc' }],
|
||||
rating_asc: [{ rating: 'asc' }, { createdAt: 'desc' }],
|
||||
};
|
||||
|
||||
function descendingStars(): number[] {
|
||||
const stars: number[] = [];
|
||||
for (let rating = REVIEW_RATING_MAX; rating >= REVIEW_RATING_MIN; rating -= 1) {
|
||||
stars.push(rating);
|
||||
}
|
||||
return stars;
|
||||
}
|
||||
|
||||
function toAdminReview(row: AdminReviewRow, locale: Locale): AdminReview {
|
||||
const translation = pickTranslation(row.product.translations, locale);
|
||||
const moderator = row.moderatedBy;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
status: row.status,
|
||||
rating: row.rating,
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
authorName: row.authorName,
|
||||
productId: row.productId,
|
||||
// The order line's snapshot is the fallback: it is what the buyer actually
|
||||
// saw when they bought, which is the right thing to show a moderator.
|
||||
productName: coalesceRequired(translation?.name, row.orderLine.productName),
|
||||
productSlug: row.product.slug,
|
||||
variantTitle: row.orderLine.variantTitle,
|
||||
orderNumber: row.orderLine.order.number,
|
||||
orderId: row.orderLine.order.id,
|
||||
moderationNote: row.moderationNote,
|
||||
moderatedAt: row.moderatedAt?.toISOString() ?? null,
|
||||
moderatedByName: moderator ? `${moderator.firstName} ${moderator.lastName}`.trim() : null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user