This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent 1e356a2578
commit d9f159a8c3
84 changed files with 7992 additions and 180 deletions
@@ -0,0 +1,122 @@
-- CreateEnum
CREATE TYPE "DiscountTrigger" AS ENUM ('AUTOMATIC', 'CODE');
-- CreateEnum
CREATE TYPE "DiscountType" AS ENUM ('PERCENTAGE', 'FIXED_AMOUNT');
-- CreateEnum
CREATE TYPE "DiscountScope" AS ENUM ('ORDER', 'PRODUCT');
-- NOTE: Prisma wanted to drop `search_documents_document_idx` and strip the
-- default from `search_documents.document` here. Both were removed by hand.
--
-- `document` is a GENERATED column (see 20260812130938_add_search_documents),
-- which Prisma cannot express — it sees `Unsupported("tsvector")?` and assumes a
-- plain column that has drifted. Letting it "fix" that would drop the GIN index
-- search depends on and fail on the generated column anyway, which is exactly
-- what happened the first time this migration ran.
--
-- Expect this diff on every future `migrate dev`. Delete those two statements.
-- CreateTable
CREATE TABLE "discounts" (
"id" UUID NOT NULL,
"code" VARCHAR(40),
"trigger" "DiscountTrigger" NOT NULL,
"type" "DiscountType" NOT NULL,
"scope" "DiscountScope" NOT NULL DEFAULT 'ORDER',
"value" INTEGER NOT NULL,
"min_subtotal_amount" INTEGER,
"starts_at" TIMESTAMPTZ(3),
"ends_at" TIMESTAMPTZ(3),
"is_active" BOOLEAN NOT NULL DEFAULT true,
"usage_limit" INTEGER,
"usage_count" INTEGER NOT NULL DEFAULT 0,
"stackable" BOOLEAN NOT NULL DEFAULT false,
"priority" INTEGER NOT NULL DEFAULT 100,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
"deleted_at" TIMESTAMPTZ(3),
CONSTRAINT "discounts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "discount_translations" (
"discount_id" UUID NOT NULL,
"locale" "Locale" NOT NULL,
"name" VARCHAR(120) NOT NULL,
"description" VARCHAR(500),
CONSTRAINT "discount_translations_pkey" PRIMARY KEY ("discount_id","locale")
);
-- CreateTable
CREATE TABLE "discount_products" (
"discount_id" UUID NOT NULL,
"product_id" UUID NOT NULL,
CONSTRAINT "discount_products_pkey" PRIMARY KEY ("discount_id","product_id")
);
-- CreateTable
CREATE TABLE "discount_collections" (
"discount_id" UUID NOT NULL,
"collection_id" UUID NOT NULL,
CONSTRAINT "discount_collections_pkey" PRIMARY KEY ("discount_id","collection_id")
);
-- CreateTable
CREATE TABLE "discount_redemptions" (
"id" UUID NOT NULL,
"discount_id" UUID NOT NULL,
"order_id" UUID NOT NULL,
"amount" INTEGER NOT NULL,
"code" VARCHAR(40),
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "discount_redemptions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "discounts_code_key" ON "discounts"("code");
-- CreateIndex
CREATE INDEX "discounts_trigger_is_active_idx" ON "discounts"("trigger", "is_active");
-- CreateIndex
CREATE INDEX "discounts_code_idx" ON "discounts"("code");
-- CreateIndex
CREATE INDEX "discount_products_product_id_idx" ON "discount_products"("product_id");
-- CreateIndex
CREATE INDEX "discount_collections_collection_id_idx" ON "discount_collections"("collection_id");
-- CreateIndex
CREATE INDEX "discount_redemptions_discount_id_idx" ON "discount_redemptions"("discount_id");
-- CreateIndex
CREATE UNIQUE INDEX "discount_redemptions_order_id_discount_id_key" ON "discount_redemptions"("order_id", "discount_id");
-- AddForeignKey
ALTER TABLE "discount_translations" ADD CONSTRAINT "discount_translations_discount_id_fkey" FOREIGN KEY ("discount_id") REFERENCES "discounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "discount_products" ADD CONSTRAINT "discount_products_discount_id_fkey" FOREIGN KEY ("discount_id") REFERENCES "discounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "discount_products" ADD CONSTRAINT "discount_products_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "discount_collections" ADD CONSTRAINT "discount_collections_discount_id_fkey" FOREIGN KEY ("discount_id") REFERENCES "discounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "discount_collections" ADD CONSTRAINT "discount_collections_collection_id_fkey" FOREIGN KEY ("collection_id") REFERENCES "collections"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "discount_redemptions" ADD CONSTRAINT "discount_redemptions_discount_id_fkey" FOREIGN KEY ("discount_id") REFERENCES "discounts"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "discount_redemptions" ADD CONSTRAINT "discount_redemptions_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,57 @@
-- CreateEnum
CREATE TYPE "ReviewStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
-- NOTE: Prisma's diff wanted to DROP INDEX "search_documents_document_idx" and
-- ALTER "search_documents"."document" DROP DEFAULT here. Both are removed on
-- purpose and must be removed from every future migration too.
--
-- `document` is a GENERATED tsvector column created by raw SQL in the M6
-- migration. Prisma's schema cannot express a generated column, so it sees one
-- it does not know about and proposes undoing it. Applying that drops the GIN
-- index behind every search query and silently degrades /search to nothing.
-- AlterTable
ALTER TABLE "products" ADD COLUMN "rating_count" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "rating_sum" INTEGER NOT NULL DEFAULT 0;
-- CreateTable
CREATE TABLE "reviews" (
"id" UUID NOT NULL,
"product_id" UUID NOT NULL,
"order_line_id" UUID NOT NULL,
"rating" INTEGER NOT NULL,
"title" VARCHAR(140),
"body" VARCHAR(2000),
"author_name" VARCHAR(120) NOT NULL,
"status" "ReviewStatus" NOT NULL DEFAULT 'PENDING',
"moderated_at" TIMESTAMPTZ(3),
"moderated_by_user_id" UUID,
"moderation_note" VARCHAR(500),
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "reviews_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "reviews_order_line_id_key" ON "reviews"("order_line_id");
-- CreateIndex
CREATE INDEX "reviews_product_id_status_created_at_idx" ON "reviews"("product_id", "status", "created_at");
-- CreateIndex
CREATE INDEX "reviews_status_created_at_idx" ON "reviews"("status", "created_at");
-- AddForeignKey
ALTER TABLE "reviews" ADD CONSTRAINT "reviews_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "reviews" ADD CONSTRAINT "reviews_order_line_id_fkey" FOREIGN KEY ("order_line_id") REFERENCES "order_lines"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "reviews" ADD CONSTRAINT "reviews_moderated_by_user_id_fkey" FOREIGN KEY ("moderated_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Validation belongs in the schema as well as the DTO: `rating` feeds a stored
-- SUM, so one bad row silently skews a product's average forever. Zod checks
-- the request; this checks the table.
ALTER TABLE "reviews" ADD CONSTRAINT "reviews_rating_range" CHECK ("rating" BETWEEN 1 AND 5);
@@ -0,0 +1,55 @@
-- CreateEnum
CREATE TYPE "ContentType" AS ENUM ('PAGE', 'POST');
-- CreateEnum
CREATE TYPE "ContentStatus" AS ENUM ('DRAFT', 'PUBLISHED');
-- NOTE: Prisma's diff wanted to DROP INDEX "search_documents_document_idx" and
-- ALTER "search_documents"."document" DROP DEFAULT here. Removed on purpose —
-- `document` is a GENERATED tsvector column created by raw SQL in the M6
-- migration, which Prisma's schema cannot express, so it proposes undoing it in
-- every migration from now on. Applying it destroys full-text search.
-- CreateTable
CREATE TABLE "content_entries" (
"id" UUID NOT NULL,
"type" "ContentType" NOT NULL,
"status" "ContentStatus" NOT NULL DEFAULT 'DRAFT',
"published_at" TIMESTAMPTZ(3),
"cover_image_id" UUID,
"author_user_id" UUID,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
"deleted_at" TIMESTAMPTZ(3),
CONSTRAINT "content_entries_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "content_entry_translations" (
"entry_id" UUID NOT NULL,
"locale" "Locale" NOT NULL,
"slug" VARCHAR(200) NOT NULL,
"title" VARCHAR(255) NOT NULL,
"excerpt" VARCHAR(500),
"body" TEXT NOT NULL,
"meta_title" VARCHAR(255),
"meta_description" TEXT,
CONSTRAINT "content_entry_translations_pkey" PRIMARY KEY ("entry_id","locale")
);
-- CreateIndex
CREATE INDEX "content_entries_type_status_published_at_idx" ON "content_entries"("type", "status", "published_at");
-- CreateIndex
CREATE UNIQUE INDEX "content_entry_translations_locale_slug_key" ON "content_entry_translations"("locale", "slug");
-- AddForeignKey
ALTER TABLE "content_entries" ADD CONSTRAINT "content_entries_cover_image_id_fkey" FOREIGN KEY ("cover_image_id") REFERENCES "media_assets"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "content_entries" ADD CONSTRAINT "content_entries_author_user_id_fkey" FOREIGN KEY ("author_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "content_entry_translations" ADD CONSTRAINT "content_entry_translations_entry_id_fkey" FOREIGN KEY ("entry_id") REFERENCES "content_entries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+333 -18
View File
@@ -70,11 +70,15 @@ model User {
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
avatar MediaAsset? @relation("UserAvatar", fields: [avatarId], references: [id], onDelete: SetNull)
roles UserRole[]
sessions Session[]
customer Customer?
auditLogs AuditLog[]
avatar MediaAsset? @relation("UserAvatar", fields: [avatarId], references: [id], onDelete: SetNull)
roles UserRole[]
sessions Session[]
customer Customer?
auditLogs AuditLog[]
/// Reviews this operator approved or rejected.
moderatedReviews Review[]
/// Pages and posts this operator wrote.
authoredContent ContentEntry[]
@@index([type, status])
@@index([createdAt])
@@ -281,6 +285,7 @@ model MediaAsset {
collectionBanners Collection[] @relation("CollectionBanner")
optionValueSwatches ProductOptionValue[] @relation("OptionValueSwatch")
userAvatars User[] @relation("UserAvatar")
contentCovers ContentEntry[] @relation("ContentCover")
@@index([kind, createdAt])
@@map("media_assets")
@@ -397,6 +402,7 @@ model Collection {
banner MediaAsset? @relation("CollectionBanner", fields: [bannerId], references: [id], onDelete: SetNull)
products ProductCollection[]
translations CollectionTranslation[]
discounts DiscountCollection[]
@@index([isActive, startsAt, endsAt])
@@map("collections")
@@ -501,11 +507,25 @@ model Product {
isOnSale Boolean @default(false) @map("is_on_sale")
inStock Boolean @default(false) @map("in_stock")
/// Rating aggregate, stored as sum + count rather than an average.
///
/// Two integers cannot drift the way a stored float can: approving one more
/// 4-star review is `sum + 4, count + 1`, which is exact and reversible.
/// A stored average would have to be recomputed from scratch to stay honest,
/// and would quietly accumulate rounding error if it were not.
///
/// Derived, never authored — `ReviewsService.recomputeRating()` is the only
/// writer. Treat a manual UPDATE of these columns as a bug.
ratingSum Int @default(0) @map("rating_sum")
ratingCount Int @default(0) @map("rating_count")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
searchDocuments SearchDocument[]
reviews Review[]
discounts DiscountProduct[]
brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull)
primaryCategory Category? @relation(fields: [primaryCategoryId], references: [id], onDelete: SetNull)
options ProductOption[]
@@ -935,15 +955,15 @@ model Order {
totalAmount Int @map("total_amount")
/// ---- Shipping address, snapshot ----------------------------------------
shipFullName String @map("ship_full_name") @db.VarChar(160)
shipPhone String @map("ship_phone") @db.VarChar(20)
shipLine1 String @map("ship_line1") @db.VarChar(255)
shipLine2 String? @map("ship_line2") @db.VarChar(255)
shipWard String? @map("ship_ward") @db.VarChar(120)
shipDistrict String? @map("ship_district") @db.VarChar(120)
shipProvince String @map("ship_province") @db.VarChar(120)
shipCountryCode String @default("VN") @map("ship_country_code") @db.Char(2)
shipPostalCode String? @map("ship_postal_code") @db.VarChar(20)
shipFullName String @map("ship_full_name") @db.VarChar(160)
shipPhone String @map("ship_phone") @db.VarChar(20)
shipLine1 String @map("ship_line1") @db.VarChar(255)
shipLine2 String? @map("ship_line2") @db.VarChar(255)
shipWard String? @map("ship_ward") @db.VarChar(120)
shipDistrict String? @map("ship_district") @db.VarChar(120)
shipProvince String @map("ship_province") @db.VarChar(120)
shipCountryCode String @default("VN") @map("ship_country_code") @db.Char(2)
shipPostalCode String? @map("ship_postal_code") @db.VarChar(20)
customerNote String? @map("customer_note") @db.VarChar(1000)
@@ -955,8 +975,9 @@ model Order {
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
lines OrderLine[]
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
lines OrderLine[]
redemptions DiscountRedemption[]
@@index([customerId])
@@index([email])
@@ -987,6 +1008,7 @@ model OrderLine {
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
variant ProductVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull)
review Review?
@@index([orderId])
@@index([variantId])
@@ -1013,11 +1035,11 @@ model SearchDocument {
locale Locale
/// Weight A — the product name.
title String @db.VarChar(255)
title String @db.VarChar(255)
/// Weight B — brand, category, colourways, sizes, SKUs. Short, high-signal.
keywords String
/// Weight C — descriptions. Long, low-signal, still worth matching.
body String
body String
/// Maintained by Postgres from the columns above. Declared here only so
/// Prisma knows it exists and leaves it alone; it is read and written with
@@ -1031,3 +1053,296 @@ model SearchDocument {
@@id([productId, locale])
@@map("search_documents")
}
// ---------------------------------------------------------------------------
// Discounts (M7)
//
// One entity, two entry points. A promotion applies itself when its conditions
// match; a coupon does the same but only once someone types a code. Modelling
// them separately would mean two rule engines that must agree about stacking,
// limits and rounding — and they would not, for long.
// ---------------------------------------------------------------------------
enum DiscountTrigger {
/// Applies on its own when conditions match.
AUTOMATIC
/// Requires the shopper to enter a code.
CODE
}
enum DiscountType {
/// `value` is whole percent, 1–100.
PERCENTAGE
/// `value` is an amount in minor units (ADR-0011).
FIXED_AMOUNT
}
enum DiscountScope {
/// Applies against the whole order subtotal.
ORDER
/// Applies only to the lines this discount targets.
PRODUCT
}
model Discount {
id String @id @default(uuid(7)) @db.Uuid
/// Null for automatic promotions. Uppercased on write so lookup is
/// case-insensitive without a functional index.
code String? @unique @db.VarChar(40)
trigger DiscountTrigger
type DiscountType
scope DiscountScope @default(ORDER)
/// Percent (1–100) or minor units, depending on `type`.
value Int
/// ---- Conditions --------------------------------------------------------
minSubtotalAmount Int? @map("min_subtotal_amount")
startsAt DateTime? @map("starts_at") @db.Timestamptz(3)
endsAt DateTime? @map("ends_at") @db.Timestamptz(3)
isActive Boolean @default(true) @map("is_active")
/// ---- Limits ------------------------------------------------------------
///
/// `usageCount` is incremented by a conditional UPDATE guarded on the limit,
/// never read-then-written — the same lesson stock reservation learned the
/// hard way. Two shoppers redeeming the last use of a code must not both win.
usageLimit Int? @map("usage_limit")
usageCount Int @default(0) @map("usage_count")
/// ---- Combination -------------------------------------------------------
///
/// Lower `priority` is evaluated first. A non-stackable discount that applies
/// ends evaluation, so "best single offer" and "stack everything" are both
/// expressible without a second engine.
stackable Boolean @default(false)
priority Int @default(100)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
translations DiscountTranslation[]
products DiscountProduct[]
collections DiscountCollection[]
redemptions DiscountRedemption[]
@@index([trigger, isActive])
@@index([code])
@@map("discounts")
}
/// The customer-facing name, per locale. "Giảm 20%" is not a translation of an
/// internal label — it is what appears on the order summary.
model DiscountTranslation {
discountId String @map("discount_id") @db.Uuid
locale Locale
name String @db.VarChar(120)
description String? @db.VarChar(500)
discount Discount @relation(fields: [discountId], references: [id], onDelete: Cascade)
@@id([discountId, locale])
@@map("discount_translations")
}
/// Targets for a PRODUCT-scoped discount. An empty target set on a PRODUCT
/// scope means it matches nothing — safer than matching everything.
model DiscountProduct {
discountId String @map("discount_id") @db.Uuid
productId String @map("product_id") @db.Uuid
discount Discount @relation(fields: [discountId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@id([discountId, productId])
@@index([productId])
@@map("discount_products")
}
model DiscountCollection {
discountId String @map("discount_id") @db.Uuid
collectionId String @map("collection_id") @db.Uuid
discount Discount @relation(fields: [discountId], references: [id], onDelete: Cascade)
collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade)
@@id([discountId, collectionId])
@@index([collectionId])
@@map("discount_collections")
}
/// One row per discount actually applied to an order.
///
/// This is the record of what was granted, not a projection of it. The amount
/// is snapshot for the same reason order lines are (ADR-0018): changing a
/// discount's value later must not rewrite what a customer already received.
model DiscountRedemption {
id String @id @default(uuid(7)) @db.Uuid
discountId String @map("discount_id") @db.Uuid
orderId String @map("order_id") @db.Uuid
/// Snapshot, in minor units.
amount Int
/// Snapshot of the code as typed, so a renamed code stays traceable.
code String? @db.VarChar(40)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
discount Discount @relation(fields: [discountId], references: [id], onDelete: Restrict)
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@unique([orderId, discountId])
@@index([discountId])
@@map("discount_redemptions")
}
// ---------------------------------------------------------------------------
// Reviews (M7)
//
// Owned exclusively by ReviewsModule.
// ---------------------------------------------------------------------------
enum ReviewStatus {
PENDING
APPROVED
REJECTED
}
/// One review of one purchased item.
///
/// Anchored to an OrderLine rather than to a product + email pair, which is the
/// whole design. "Verified purchase" is then not a flag somebody sets, or a
/// lookup that can be spoofed by typing a stranger's email — it is the primary
/// key relationship. You cannot review what you did not buy, because there is
/// no row to hang the review on, and the unique constraint gives "one review
/// per item purchased" for free rather than as a rule someone must enforce.
///
/// The cost is that a shopper cannot review a product they own but bought
/// elsewhere. That is the correct trade for a store this size: the alternative
/// is an open submission endpoint, which is a spam surface that needs a
/// moderation team rather than a moderation screen.
model Review {
id String @id @default(uuid(7)) @db.Uuid
productId String @map("product_id") @db.Uuid
/// The proof of purchase. Unique: one review per item bought.
orderLineId String @unique @map("order_line_id") @db.Uuid
/// 1..5. Constrained in the database too — a 7-star review would silently
/// corrupt every aggregate that sums this column.
rating Int
title String? @db.VarChar(140)
body String? @db.VarChar(2000)
/// Snapshot, like the order's address. The reviewer may later change their
/// account name, or never have had one; the byline on a published review
/// must not change underneath it.
authorName String @map("author_name") @db.VarChar(120)
status ReviewStatus @default(PENDING)
/// ---- Moderation --------------------------------------------------------
moderatedAt DateTime? @map("moderated_at") @db.Timestamptz(3)
moderatedByUserId String? @map("moderated_by_user_id") @db.Uuid
/// Why it was rejected. Internal — never rendered to the shopper.
moderationNote String? @map("moderation_note") @db.VarChar(500)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
orderLine OrderLine @relation(fields: [orderLineId], references: [id], onDelete: Cascade)
moderatedBy User? @relation(fields: [moderatedByUserId], references: [id], onDelete: SetNull)
/// The storefront query: approved reviews for one product, newest first.
@@index([productId, status, createdAt])
/// The moderation queue: oldest pending first, so nothing waits forever.
@@index([status, createdAt])
@@map("reviews")
}
// ---------------------------------------------------------------------------
// Content (M7)
//
// Owned exclusively by CmsModule.
// ---------------------------------------------------------------------------
enum ContentType {
PAGE
POST
}
enum ContentStatus {
DRAFT
PUBLISHED
}
/// A page or a post.
///
/// One entity with a `type` rather than `pages` and `blog_posts` side by side,
/// for the same reason promotions and coupons are one (ADR-0020): the shared
/// surface — per-locale slug, title, body, SEO, publish state, soft delete — is
/// nearly all of it, and two tables means two admin screens and two sets of
/// publishing rules that must agree forever.
///
/// What actually differs is placement, and that is what `type` says: a POST is
/// listed in a feed newest-first and carries an excerpt and a cover; a PAGE is
/// addressed directly from the footer and is never listed.
///
/// Deliberately NOT a page builder. The body is Markdown, one column. The
/// moment this grows a block tree it becomes a layout tool that the storefront
/// design has to obey, and the design stops being code.
model ContentEntry {
id String @id @default(uuid(7)) @db.Uuid
type ContentType
status ContentStatus @default(DRAFT)
/// When it went live. Set on the first publish and kept afterwards, so
/// un-publishing and re-publishing does not reorder the feed or rewrite a
/// date readers may already have seen.
publishedAt DateTime? @map("published_at") @db.Timestamptz(3)
coverImageId String? @map("cover_image_id") @db.Uuid
authorUserId String? @map("author_user_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
coverImage MediaAsset? @relation("ContentCover", fields: [coverImageId], references: [id], onDelete: SetNull)
author User? @relation(fields: [authorUserId], references: [id], onDelete: SetNull)
translations ContentEntryTranslation[]
/// The feed query: published posts, newest first.
@@index([type, status, publishedAt])
@@map("content_entries")
}
/// Per-locale slugs, for the same reason products have them: `/en/blog/
/// how-to-layer` and `/vi/blog/cach-phoi-do-mua-lanh` are the SEO surface.
model ContentEntryTranslation {
entryId String @map("entry_id") @db.Uuid
locale Locale
slug String @db.VarChar(200)
title String @db.VarChar(255)
/// Feed summary. Posts only in practice; nullable rather than required so a
/// page is not forced to invent one.
excerpt String? @db.VarChar(500)
/// Markdown. Rendered to React elements, never injected as HTML — see the
/// storefront's <Markdown> component.
body String
metaTitle String? @map("meta_title") @db.VarChar(255)
metaDescription String? @map("meta_description")
entry ContentEntry @relation(fields: [entryId], references: [id], onDelete: Cascade)
@@id([entryId, locale])
@@unique([locale, slug])
@@map("content_entry_translations")
}
@@ -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.
*
+2 -1
View File
@@ -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],
+89 -9
View File
@@ -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;
});
+137
View File
@@ -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);
}
}
+20 -12
View File
@@ -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 {}
+153
View File
@@ -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 },
});
}
}
+315
View File
@@ -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(),
};
}
}
+1 -3
View File
@@ -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';
+1 -1
View File
@@ -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);
}
}
+18 -12
View File
@@ -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(),
};
}