Stage M5 and Stage M6

This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent 7688657d3c
commit 624a6402bf
77 changed files with 4879 additions and 176 deletions
@@ -0,0 +1,88 @@
-- CreateEnum
CREATE TYPE "OrderStatus" AS ENUM ('PENDING', 'CONFIRMED', 'FULFILLED', 'COMPLETED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "PaymentStatus" AS ENUM ('UNPAID', 'PAID', 'PARTIALLY_REFUNDED', 'REFUNDED');
-- CreateEnum
CREATE TYPE "FulfillmentStatus" AS ENUM ('UNFULFILLED', 'PARTIALLY_FULFILLED', 'FULFILLED');
-- CreateTable
CREATE TABLE "orders" (
"id" UUID NOT NULL,
"number" SERIAL NOT NULL,
"customer_id" UUID,
"email" VARCHAR(255) NOT NULL,
"phone" VARCHAR(20) NOT NULL,
"status" "OrderStatus" NOT NULL DEFAULT 'PENDING',
"payment_status" "PaymentStatus" NOT NULL DEFAULT 'UNPAID',
"fulfillment_status" "FulfillmentStatus" NOT NULL DEFAULT 'UNFULFILLED',
"currency" "Currency" NOT NULL DEFAULT 'VND',
"subtotal_amount" INTEGER NOT NULL,
"discount_amount" INTEGER NOT NULL DEFAULT 0,
"shipping_amount" INTEGER NOT NULL DEFAULT 0,
"tax_amount" INTEGER NOT NULL DEFAULT 0,
"total_amount" INTEGER NOT NULL,
"ship_full_name" VARCHAR(160) NOT NULL,
"ship_phone" VARCHAR(20) NOT NULL,
"ship_line1" VARCHAR(255) NOT NULL,
"ship_line2" VARCHAR(255),
"ship_ward" VARCHAR(120),
"ship_district" VARCHAR(120),
"ship_province" VARCHAR(120) NOT NULL,
"ship_country_code" CHAR(2) NOT NULL DEFAULT 'VN',
"ship_postal_code" VARCHAR(20),
"customer_note" VARCHAR(1000),
"placed_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"confirmed_at" TIMESTAMPTZ(3),
"cancelled_at" TIMESTAMPTZ(3),
"cancel_reason" VARCHAR(500),
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "orders_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "order_lines" (
"id" UUID NOT NULL,
"order_id" UUID NOT NULL,
"variant_id" UUID,
"product_name" VARCHAR(255) NOT NULL,
"variant_title" VARCHAR(255) NOT NULL,
"sku" VARCHAR(64) NOT NULL,
"image_url" VARCHAR(500),
"unit_amount" INTEGER NOT NULL,
"quantity" INTEGER NOT NULL,
"line_amount" INTEGER NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "order_lines_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "orders_number_key" ON "orders"("number");
-- CreateIndex
CREATE INDEX "orders_customer_id_idx" ON "orders"("customer_id");
-- CreateIndex
CREATE INDEX "orders_email_idx" ON "orders"("email");
-- CreateIndex
CREATE INDEX "orders_status_placed_at_idx" ON "orders"("status", "placed_at");
-- CreateIndex
CREATE INDEX "order_lines_order_id_idx" ON "order_lines"("order_id");
-- CreateIndex
CREATE INDEX "order_lines_variant_id_idx" ON "order_lines"("variant_id");
-- AddForeignKey
ALTER TABLE "orders" ADD CONSTRAINT "orders_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "order_lines" ADD CONSTRAINT "order_lines_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "order_lines" ADD CONSTRAINT "order_lines_variant_id_fkey" FOREIGN KEY ("variant_id") REFERENCES "product_variants"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,64 @@
-- Full-text search over the catalog, in PostgreSQL (ADR-0012).
--
-- Two extensions do the work that a dedicated search engine would otherwise be
-- brought in for:
-- unaccent — so "ao chay bo" finds "Áo Chạy Bộ". Vietnamese shoppers type
-- without diacritics constantly; without this, most of them find
-- nothing.
-- pg_trgm — trigram similarity, which gives typo tolerance and partial-word
-- matching that a tsquery alone cannot ("nocturn", "jacket run").
CREATE EXTENSION IF NOT EXISTS unaccent;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- `unaccent()` is STABLE, not IMMUTABLE, because it depends on a dictionary that
-- could in principle be changed. Postgres therefore refuses it in a generated
-- column or an index. Pinning the dictionary by name makes the result genuinely
-- immutable, which is the standard way around this.
CREATE OR REPLACE FUNCTION immutable_unaccent(text)
RETURNS text
LANGUAGE sql
IMMUTABLE
STRICT
PARALLEL SAFE
AS $$
SELECT public.unaccent('public.unaccent'::regdictionary, $1)
$$;
-- CreateTable
CREATE TABLE "search_documents" (
"product_id" UUID NOT NULL,
"locale" "Locale" NOT NULL,
"title" VARCHAR(255) NOT NULL,
"keywords" TEXT NOT NULL,
"body" TEXT NOT NULL,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "search_documents_pkey" PRIMARY KEY ("product_id","locale")
);
-- AddForeignKey
ALTER TABLE "search_documents" ADD CONSTRAINT "search_documents_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- The searchable vector is GENERATED, not written by the application.
--
-- That is the whole point: an index maintained by hand drifts from the text it
-- indexes the first time someone updates one and forgets the other. Here it
-- cannot — the database recomputes it on every write to the source columns.
--
-- `simple` rather than `english`: the catalog is bilingual, and English
-- stemming applied to Vietnamese produces nonsense. Weighting carries the
-- relevance instead of stemming.
ALTER TABLE "search_documents"
ADD COLUMN "document" tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('simple', immutable_unaccent(coalesce("title", ''))), 'A') ||
setweight(to_tsvector('simple', immutable_unaccent(coalesce("keywords", ''))), 'B') ||
setweight(to_tsvector('simple', immutable_unaccent(coalesce("body", ''))), 'C')
) STORED;
CREATE INDEX "search_documents_document_idx" ON "search_documents" USING GIN ("document");
-- Trigram index over the high-signal text only. Including `body` would bloat it
-- for matches nobody wants ranked by similarity anyway.
CREATE INDEX "search_documents_trgm_idx" ON "search_documents"
USING GIN ((immutable_unaccent("title") || ' ' || immutable_unaccent("keywords")) gin_trgm_ops);
+167
View File
@@ -208,6 +208,7 @@ model Customer {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
addresses Address[]
orders Order[]
@@map("customers")
}
@@ -504,6 +505,7 @@ model Product {
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
searchDocuments SearchDocument[]
brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull)
primaryCategory Category? @relation(fields: [primaryCategoryId], references: [id], onDelete: SetNull)
options ProductOption[]
@@ -601,6 +603,7 @@ model ProductVariant {
optionValues ProductVariantOptionValue[]
stockLevels StockLevel[]
stockMovements StockMovement[]
orderLines OrderLine[]
@@index([productId, position])
@@index([status])
@@ -864,3 +867,167 @@ model ProductAttributeTranslation {
@@id([attributeId, locale])
@@map("product_attribute_translations")
}
// ---------------------------------------------------------------------------
// Commerce — orders (M5)
//
// Carts are deliberately absent: a guest cart lives in Redis and holds only
// variant ids and quantities (ADR-0010). Prices are recomputed from the catalog
// on every read, so a cart can never carry a stale or tampered price into an
// order.
// ---------------------------------------------------------------------------
enum OrderStatus {
/// Placed, awaiting payment. Stock is reserved from this moment.
PENDING
CONFIRMED
FULFILLED
COMPLETED
CANCELLED
}
enum PaymentStatus {
UNPAID
PAID
PARTIALLY_REFUNDED
REFUNDED
}
enum FulfillmentStatus {
UNFULFILLED
PARTIALLY_FULFILLED
FULFILLED
}
/// A placed order.
///
/// Every customer-facing and catalog-facing value is SNAPSHOT here rather than
/// joined at read time. An order is a record of what was agreed, and it has to
/// stay readable after the product is renamed, repriced, archived or the
/// customer edits their address book. The `variantId` FK exists for reporting
/// and returns, never for rendering the order.
model Order {
id String @id @default(uuid(7)) @db.Uuid
/// Human-facing reference, formatted for display as "SP-000123". Kept as an
/// integer so it is monotonic and cheap to look up; the prefix is
/// presentation and lives in the mapper.
number Int @unique @default(autoincrement())
/// Null for a guest checkout. Guests are identified by email + order number.
customerId String? @map("customer_id") @db.Uuid
email String @db.VarChar(255)
phone String @db.VarChar(20)
status OrderStatus @default(PENDING)
paymentStatus PaymentStatus @default(UNPAID) @map("payment_status")
fulfillmentStatus FulfillmentStatus @default(UNFULFILLED) @map("fulfillment_status")
/// ---- Money, integer minor units throughout (ADR-0011) ------------------
currency Currency @default(VND)
subtotalAmount Int @map("subtotal_amount")
/// Zero until promotions land (M7); the column exists so the total is always
/// the sum of named parts rather than an unexplained number.
discountAmount Int @default(0) @map("discount_amount")
shippingAmount Int @default(0) @map("shipping_amount")
taxAmount Int @default(0) @map("tax_amount")
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)
customerNote String? @map("customer_note") @db.VarChar(1000)
placedAt DateTime @default(now()) @map("placed_at") @db.Timestamptz(3)
confirmedAt DateTime? @map("confirmed_at") @db.Timestamptz(3)
cancelledAt DateTime? @map("cancelled_at") @db.Timestamptz(3)
cancelReason String? @map("cancel_reason") @db.VarChar(500)
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[]
@@index([customerId])
@@index([email])
@@index([status, placedAt])
@@map("orders")
}
/// One purchased variant, frozen at the moment of purchase.
model OrderLine {
id String @id @default(uuid(7)) @db.Uuid
orderId String @map("order_id") @db.Uuid
/// Nulled rather than cascading if a variant is ever hard-deleted — losing
/// the reporting link is survivable, losing the order line is not.
variantId String? @map("variant_id") @db.Uuid
/// ---- Snapshot ----------------------------------------------------------
productName String @map("product_name") @db.VarChar(255)
variantTitle String @map("variant_title") @db.VarChar(255)
sku String @db.VarChar(64)
imageUrl String? @map("image_url") @db.VarChar(500)
unitAmount Int @map("unit_amount")
quantity Int
lineAmount Int @map("line_amount")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
variant ProductVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull)
@@index([orderId])
@@index([variantId])
@@map("order_lines")
}
// ---------------------------------------------------------------------------
// Search (M6)
//
// Owned exclusively by SearchModule. It is a *projection*: every row is
// derivable from the catalog and can be rebuilt from scratch at any time, which
// is what lets the module be extracted later without taking catalog tables with
// it (ADR-0012).
// ---------------------------------------------------------------------------
/// One searchable document per product per locale.
///
/// The text is split by weight rather than concatenated, because a match on a
/// product's name should outrank a match buried in its description. The
/// `document` column is GENERATED from these three by the database — see the
/// migration — so an index can never drift from the text it indexes.
model SearchDocument {
productId String @map("product_id") @db.Uuid
locale Locale
/// Weight A — the product name.
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
/// 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
/// raw SQL in the repository.
document Unsupported("tsvector")?
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@id([productId, locale])
@@map("search_documents")
}
+5
View File
@@ -312,6 +312,10 @@ async function seedProduct(
primaryCategoryId: refs.categoryId ?? null,
genderTargets: seed.genders,
sportTypes: seed.sports,
// Persisted, not just used to build the seed SKUs: adding a colourway to
// a seeded product later has to extend the same SKU family, and without
// this the write path falls back to the slug.
skuPrefix: seed.skuPrefix,
},
create: {
slug: seed.key,
@@ -324,6 +328,7 @@ async function seedProduct(
primaryCategoryId: refs.categoryId ?? null,
genderTargets: seed.genders,
sportTypes: seed.sports,
skuPrefix: seed.skuPrefix,
},
});
+50
View File
@@ -0,0 +1,50 @@
import { randomUUID } from 'node:crypto';
import type { CookieOptions, Request, Response } from 'express';
/**
* The guest cart identifier.
*
* An opaque random token in an httpOnly cookie. It names a Redis key and
* nothing more — it grants no authority, carries no identity and is worthless
* if leaked, which is exactly why a guest bag can work without an account.
*
* httpOnly anyway: the storefront never needs to read it, because every cart
* operation goes through the API on the same origin (ADR-0015). Keeping it out
* of `document.cookie` costs nothing and removes it from an XSS's reach.
*/
const COOKIE_NAME = 'sport_cart';
/** Matches the Redis TTL — a cookie that outlives its data is a phantom bag. */
const MAX_AGE_MS = 1000 * 60 * 60 * 24 * 30;
export function readCartToken(request: Request): string | undefined {
const cookies = request.cookies as Record<string, string> | undefined;
return cookies?.[COOKIE_NAME];
}
/** Reads the existing token or mints one, telling the caller which happened. */
export function resolveCartToken(request: Request): { token: string; isNew: boolean } {
const existing = readCartToken(request);
return existing ? { token: existing, isNew: false } : { token: randomUUID(), isNew: true };
}
export function setCartCookie(response: Response, token: string, isProduction: boolean): void {
response.cookie(COOKIE_NAME, token, options(isProduction));
}
export function clearCartCookie(response: Response, isProduction: boolean): void {
response.clearCookie(COOKIE_NAME, { ...options(isProduction), maxAge: undefined });
}
function options(isProduction: boolean): CookieOptions {
return {
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
// Root path, unlike the refresh cookie: the cart is read on ordinary
// catalog requests, not just on two auth endpoints.
path: '/',
maxAge: MAX_AGE_MS,
};
}
@@ -0,0 +1,103 @@
import {
Body,
Controller,
Delete,
Get,
Inject,
Param,
Patch,
Post,
Req,
Res,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Request, Response } from 'express';
import type { Cart, Locale } from '@sport/types';
import {
addCartLineSchema,
updateCartLineSchema,
type AddCartLineInput,
type UpdateCartLineInput,
} from '@sport/validation';
import { Public } from '@/common/decorators/public.decorator';
import { RequestLocale } from '@/common/i18n/locale.decorator';
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
import { APP_CONFIG } from '@/config/app-config.module';
import type { AppConfig } from '@/config/configuration';
import { resolveCartToken, setCartCookie } from './cart-cookie';
import { CartsService } from './carts.service';
/**
* Cart endpoints are `@Public()`: a bag belongs to a browser, not an account.
* Requiring sign-in to add an item is the single most reliable way to lose a
* sale, and the cart token grants no authority beyond naming a Redis key.
*/
@ApiTags('cart')
@Public()
@Controller('cart')
export class CartsController {
constructor(
private readonly service: CartsService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
@Get()
@ApiOperation({ summary: 'The current bag, priced from the live catalog' })
get(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
@RequestLocale() locale: Locale,
): Promise<Cart> {
return this.service.get(this.token(request, response), locale);
}
@Post('lines')
@ApiOperation({ summary: 'Add a variant to the bag' })
addLine(
@Body(new ZodValidationPipe(addCartLineSchema)) body: AddCartLineInput,
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
@RequestLocale() locale: Locale,
): Promise<Cart> {
return this.service.addLine(this.token(request, response), body, locale);
}
@Patch('lines/:variantId')
@ApiOperation({ summary: 'Change a line quantity; zero removes it' })
updateLine(
@Param('variantId') variantId: string,
@Body(new ZodValidationPipe(updateCartLineSchema)) body: UpdateCartLineInput,
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
@RequestLocale() locale: Locale,
): Promise<Cart> {
return this.service.updateLine(this.token(request, response), variantId, body, locale);
}
@Delete('lines/:variantId')
@ApiOperation({ summary: 'Remove a line' })
removeLine(
@Param('variantId') variantId: string,
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
@RequestLocale() locale: Locale,
): Promise<Cart> {
return this.service.removeLine(this.token(request, response), variantId, locale);
}
/**
* Reads the cart cookie, minting one on first contact.
*
* The cookie is (re)set on every request so an active bag keeps rolling its
* thirty-day window forward rather than expiring under a shopper who has been
* browsing all along.
*/
private token(request: Request, response: Response): string {
const { token } = resolveCartToken(request);
setCartCookie(response, token, this.config.app.isProduction);
return token;
}
}
+16 -13
View File
@@ -1,19 +1,22 @@
import { Module } from '@nestjs/common';
import { MediaUrlModule } from '@/common/media/media.module';
import { CartsController } from './carts.controller';
import { CartsService } from './carts.service';
/**
* CartsModule — boundary declared, implementation pending.
* CartsModule — owns the guest cart, which lives in Redis and holds only
* variant ids and quantities (ADR-0010).
*
* Owns (exclusively): Redis (guest carts) + `carts`/`cart_items` once persisted — milestone 2
*
* Guest carts live in Redis keyed by an anonymous token; they are promoted to PostgreSQL on sign-in. Cart totals are always recomputed server-side from current variant prices — a client-submitted price is never trusted.
*
* Anatomy once implemented (see ../README.md):
* carts.module.ts wiring only
* carts.controller.ts HTTP surface, no logic
* carts.service.ts business rules
* carts.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
* It owns no tables. It reads the catalog to price a bag, which is the one
* cross-module read it needs, and exposes `resolveForCheckout` so the checkout
* flow prices a cart through exactly the same code path the shopper saw.
*/
@Module({})
@Module({
imports: [MediaUrlModule],
controllers: [CartsController],
providers: [CartsService],
exports: [CartsService],
})
export class CartsModule {}
+353
View File
@@ -0,0 +1,353 @@
import { Injectable, Logger } from '@nestjs/common';
import {
CART_NOTICE_REASONS,
type Cart,
type CartLine,
type CartNotice,
type CartTotals,
type CurrencyCode,
type Locale,
type Money,
} from '@sport/types';
import type { AddCartLineInput, UpdateCartLineInput } from '@sport/validation';
import { MAX_LINE_QUANTITY } from '@sport/validation';
import { AppException } from '@/common/errors/app.exception';
import { coalesceRequired, toDbLocale } from '@/common/i18n';
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';
/**
* What actually lives in Redis.
*
* Variant ids and quantities. No prices, no names, no totals — everything a
* shopper sees is recomputed from the catalog on every read. A cart can sit for
* thirty days and still cannot carry a stale price into an order, and a forged
* payload has nothing worth forging.
*/
interface StoredLine {
variantId: string;
quantity: number;
addedAt: string;
}
interface StoredCart {
id: string;
lines: StoredLine[];
updatedAt: string;
}
@Injectable()
export class CartsService {
private readonly logger = new Logger(CartsService.name);
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
private readonly mediaUrl: MediaUrlService,
) {}
async get(cartToken: string, locale: Locale): Promise<Cart> {
return this.hydrate(cartToken, await this.read(cartToken), locale);
}
async addLine(cartToken: string, input: AddCartLineInput, locale: Locale): Promise<Cart> {
const stored = await this.read(cartToken);
const existing = stored.lines.find((line) => line.variantId === input.variantId);
if (existing) {
// Adding a variant already in the bag tops it up rather than creating a
// second line — two "Black / M" rows is never what was meant.
existing.quantity = Math.min(existing.quantity + input.quantity, MAX_LINE_QUANTITY);
} else {
stored.lines.push({
variantId: input.variantId,
quantity: input.quantity,
addedAt: new Date().toISOString(),
});
}
return this.hydrate(cartToken, await this.write(cartToken, stored), locale);
}
async updateLine(
cartToken: string,
variantId: string,
input: UpdateCartLineInput,
locale: Locale,
): Promise<Cart> {
const stored = await this.read(cartToken);
const line = stored.lines.find((item) => item.variantId === variantId);
if (!line) throw AppException.notFound('Cart line');
if (input.quantity === 0) {
stored.lines = stored.lines.filter((item) => item.variantId !== variantId);
} else {
line.quantity = input.quantity;
}
return this.hydrate(cartToken, await this.write(cartToken, stored), locale);
}
async removeLine(cartToken: string, variantId: string, locale: Locale): Promise<Cart> {
const stored = await this.read(cartToken);
stored.lines = stored.lines.filter((item) => item.variantId !== variantId);
return this.hydrate(cartToken, await this.write(cartToken, stored), locale);
}
async clear(cartToken: string): Promise<void> {
await this.redis.delete(CACHE_KEYS.guestCart(cartToken));
}
/**
* The line set a checkout should act on, with prices the API computed.
*
* Checkout calls this rather than re-deriving totals, so the price a shopper
* was shown and the price they are charged come from one code path.
*/
async resolveForCheckout(cartToken: string, locale: Locale): Promise<Cart> {
const cart = await this.get(cartToken, locale);
if (cart.lines.length === 0) {
throw AppException.badRequest('Your bag is empty.');
}
return cart;
}
// ---- internals -----------------------------------------------------------
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() };
}
private async write(cartToken: string, cart: StoredCart): Promise<StoredCart> {
const next: StoredCart = { ...cart, id: cartToken, updatedAt: new Date().toISOString() };
// Every write resets the TTL, so an active cart never expires under someone.
await this.redis.set(CACHE_KEYS.guestCart(cartToken), next, CACHE_TTL.guestCart);
return next;
}
/**
* Turns stored ids into a priced cart, and prunes what is no longer buyable.
*
* The pruning is the important part. A variant can be archived, its product
* unpublished or its stock sold out between two visits, and a cart that
* quietly keeps the line produces a checkout that fails at the last step.
* Instead the line is corrected here and a notice explains what changed.
*/
private async hydrate(cartToken: string, stored: StoredCart, locale: Locale): Promise<Cart> {
if (stored.lines.length === 0) {
return this.empty(cartToken, stored.updatedAt);
}
const dbLocale = toDbLocale(locale);
const variants = await this.prisma.productVariant.findMany({
where: {
id: { in: stored.lines.map((line) => line.variantId) },
status: 'ACTIVE',
deletedAt: null,
product: { status: 'ACTIVE', deletedAt: null },
},
select: {
id: true,
sku: true,
title: true,
currency: true,
priceAmount: true,
salePriceAmount: true,
stockLevels: { select: { onHand: true, reserved: true } },
optionValues: {
select: {
option: { select: { position: true } },
optionValue: {
select: {
label: true,
translations: { where: { locale: dbLocale }, select: { label: true } },
},
},
},
},
product: {
select: {
name: true,
slug: true,
translations: { where: { locale: dbLocale }, select: { name: true, slug: true } },
images: {
orderBy: { position: 'asc' },
take: 1,
select: { media: { select: { storageKey: true } } },
},
},
},
},
});
const byId = new Map(variants.map((variant) => [variant.id, variant]));
const lines: CartLine[] = [];
const notices: CartNotice[] = [];
const keep: StoredLine[] = [];
for (const line of stored.lines) {
const variant = byId.get(line.variantId);
if (!variant) {
notices.push({
reason: CART_NOTICE_REASONS.UNAVAILABLE,
variantId: line.variantId,
productName: '',
previousQuantity: line.quantity,
quantity: null,
});
continue;
}
// The query filters to a single locale, so there is at most one row.
const translation = variant.product.translations[0];
const productName = coalesceRequired(translation?.name, variant.product.name);
const available = variant.stockLevels.reduce(
(total, level) => total + (level.onHand - level.reserved),
0,
);
if (available <= 0) {
notices.push({
reason: CART_NOTICE_REASONS.OUT_OF_STOCK,
variantId: variant.id,
productName,
previousQuantity: line.quantity,
quantity: null,
});
continue;
}
const quantity = Math.min(line.quantity, available, MAX_LINE_QUANTITY);
if (quantity < line.quantity) {
notices.push({
reason: CART_NOTICE_REASONS.QUANTITY_REDUCED,
variantId: variant.id,
productName,
previousQuantity: line.quantity,
quantity,
});
}
const currency = variant.currency as CurrencyCode;
const unit = variant.salePriceAmount ?? variant.priceAmount;
lines.push({
variantId: variant.id,
productName,
productSlug: coalesceRequired(translation?.slug, variant.product.slug),
variantTitle: variantTitle(variant),
sku: variant.sku,
imageUrl: variant.product.images[0]
? this.mediaUrl.url(variant.product.images[0].media.storageKey)
: null,
unitPrice: { amount: unit, currency },
compareAtPrice:
variant.salePriceAmount !== null ? { amount: variant.priceAmount, currency } : null,
quantity,
lineTotal: { amount: unit * quantity, currency },
maxQuantity: Math.min(available, MAX_LINE_QUANTITY),
});
keep.push({ ...line, quantity });
}
// Persist the pruning so the next read is clean and each notice is shown
// once.
//
// Compared by variant, not by index: dropping a sold-out line shifts every
// line after it, so an index-wise comparison against the pre-prune list is
// reading a different product's quantity.
const before = new Map(stored.lines.map((line) => [line.variantId, line.quantity]));
const corrected =
keep.length !== stored.lines.length ||
keep.some((line) => before.get(line.variantId) !== line.quantity);
if (corrected) {
await this.write(cartToken, { ...stored, lines: keep });
this.logger.log(`Cart ${cartToken} corrected: ${notices.length} notice(s)`);
}
return {
id: cartToken,
lines,
totals: this.totals(lines),
notices,
updatedAt: stored.updatedAt,
};
}
private totals(lines: readonly CartLine[]): 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 });
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),
shipping: money(0),
tax: money(0),
total: money(subtotal),
};
}
private empty(cartToken: string, updatedAt: string): Cart {
const money = (amount: number): Money => ({ amount, currency: 'VND' as CurrencyCode });
return {
id: cartToken,
lines: [],
totals: {
itemCount: 0,
subtotal: money(0),
discount: money(0),
shipping: money(0),
tax: money(0),
total: money(0),
},
notices: [],
updatedAt,
};
}
}
/**
* The variant label a shopper should see, in their language.
*
* Composed from translated option values rather than read from
* `ProductVariant.title`, which is the canonical internal label — the catalog
* mapper already does exactly this, and a cart that skips it shows an English
* reader "Đen / M" for the item they just added.
*
* Ordered by the option's position so the axes read consistently ("Black / M",
* never "M / Black").
*/
function variantTitle(variant: {
title: string;
optionValues: {
option: { position: number };
optionValue: { label: string; translations: { label: string }[] };
}[];
}): string {
const labels = [...variant.optionValues]
.sort((a, b) => a.option.position - b.option.position)
.map((link) => link.optionValue.translations[0]?.label ?? link.optionValue.label);
return labels.length > 0 ? labels.join(' / ') : variant.title;
}
+2 -1
View File
@@ -7,4 +7,5 @@
*
* Keep it narrow: each export is a promise to the rest of the codebase.
*/
export {};
export { CartsService } from '../carts.service';
export { clearCartCookie, readCartToken, resolveCartToken, setCartCookie } from '../cart-cookie';
@@ -0,0 +1,87 @@
import { Body, Controller, Get, Inject, Param, Post, Query, Req, Res } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Request, Response } from 'express';
import type { Cart, Locale, Order } from '@sport/types';
import { placeOrderSchema, type PlaceOrderInput } from '@sport/validation';
import { Public } from '@/common/decorators/public.decorator';
import { RequestLocale } from '@/common/i18n/locale.decorator';
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
import { APP_CONFIG } from '@/config/app-config.module';
import type { AppConfig } from '@/config/configuration';
import { clearCartCookie, resolveCartToken, setCartCookie } from '@/modules/carts/public';
import { OrdersService } from '@/modules/orders/public';
import { CheckoutService } from './checkout.service';
/**
* Public because guest checkout is the default. Customer accounts arrive in M8
* and will attach an order to a customer, not gate the ability to place one.
*/
@ApiTags('checkout')
@Public()
@Controller('checkout')
export class CheckoutController {
constructor(
private readonly service: CheckoutService,
private readonly orders: OrdersService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
@Get('quote')
@ApiOperation({ summary: 'The bag as it will be charged' })
quote(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
@RequestLocale() locale: Locale,
): Promise<Cart> {
const { token } = resolveCartToken(request);
setCartCookie(response, token, this.config.app.isProduction);
return this.service.quote(token, locale);
}
@Post('orders')
@ApiOperation({ summary: 'Place the order and reserve stock' })
async placeOrder(
@Body(new ZodValidationPipe(placeOrderSchema)) body: PlaceOrderInput,
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
@RequestLocale() locale: Locale,
): Promise<Order> {
const { token } = resolveCartToken(request);
const order = await this.service.placeOrder(token, body, locale);
// The bag is gone, so the cookie naming it should go too — otherwise the
// next visit reads an empty cart under a stale token forever.
clearCartCookie(response, this.config.app.isProduction);
return order;
}
@Get('orders/lookup')
@ApiOperation({ summary: 'Find a placed order by number and email' })
lookup(@Query('orderNumber') orderNumber: string, @Query('email') email: string): Promise<Order> {
return this.orders.lookup(orderNumber ?? '', email ?? '');
}
/**
* Confirmation lookup by id — a capability URL.
*
* The id is a UUIDv7: not sequential, not enumerable, and not derivable from
* the order number. Holding it is the authorisation, which is what lets a
* guest see their own order without an account.
*
* It exists so the confirmation page need not carry an email address in its
* query string, where it would sit in browser history and ride along in the
* `Referer` of every outbound request the page makes.
*
* Declared after `orders/lookup` so that literal path never matches here.
*/
@Get('orders/:id')
@ApiOperation({ summary: 'A placed order, by its unguessable id' })
getById(@Param('id') id: string): Promise<Order> {
return this.orders.getById(id);
}
}
@@ -1,19 +1,22 @@
import { Module } from '@nestjs/common';
import { CartsModule } from '@/modules/carts/carts.module';
import { OrdersModule } from '@/modules/orders/orders.module';
import { CheckoutController } from './checkout.controller';
import { CheckoutService } from './checkout.service';
/**
* CheckoutModule — boundary declared, implementation pending.
* CheckoutModule — owns no tables.
*
* Owns (exclusively): Checkout sessions (Redis, short TTL)
*
* Orchestrates the cart → stock reservation → payment intent → order transition. The only module allowed to coordinate across contexts, and it does so through public services and events.
*
* Anatomy once implemented (see ../README.md):
* checkout.module.ts wiring only
* checkout.controller.ts HTTP surface, no logic
* checkout.service.ts business rules
* checkout.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
* It is the coordination point between cart, catalog and inventory, and exists
* as its own module precisely so that coordination has one home rather than
* being smeared across the two sides. Payment providers (M9) attach here.
*/
@Module({})
@Module({
imports: [CartsModule, OrdersModule],
controllers: [CheckoutController],
providers: [CheckoutService],
exports: [CheckoutService],
})
export class CheckoutModule {}
@@ -0,0 +1,144 @@
import { Injectable, Logger } from '@nestjs/common';
import type { Cart, Locale, Order } from '@sport/types';
import type { PlaceOrderInput } from '@sport/validation';
import { AppException } from '@/common/errors/app.exception';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
import { CartsService } from '@/modules/carts/public';
import { OrdersService } from '@/modules/orders/public';
/**
* Turns a bag into an order.
*
* This module owns no tables. It is the one place that coordinates three others
* — cart, catalog and inventory — and that coordination is the reason it exists
* as its own module rather than as a method on either side.
*
* The whole placement is a single transaction. A half-placed order is worse
* than a failed one: the shopper sees an error, retries, and either pays twice
* or holds stock nobody will ever ship.
*/
@Injectable()
export class CheckoutService {
private readonly logger = new Logger(CheckoutService.name);
constructor(
private readonly prisma: PrismaService,
private readonly carts: CartsService,
private readonly orders: OrdersService,
) {}
/** What the shopper is about to agree to. Priced by the cart, never by the client. */
quote(cartToken: string, locale: Locale): Promise<Cart> {
return this.carts.resolveForCheckout(cartToken, locale);
}
async placeOrder(cartToken: string, input: PlaceOrderInput, locale: Locale): Promise<Order> {
const cart = await this.carts.resolveForCheckout(cartToken, locale);
// A cart that had to correct itself is not one to charge against — the
// shopper is looking at a total that just changed underneath them.
if (cart.notices.length > 0) {
throw AppException.badRequest(
'Your bag changed while you were checking out. Review it and try again.',
);
}
const orderId = await this.prisma.$transaction(async (tx) => {
/**
* Reserve with a conditional UPDATE, and treat "no rows changed" as
* "someone else got there first".
*
* This must NOT be read-then-write. Reading availability and then writing
* `reserved + n` is a lost update: two shoppers both read `reserved = 0`,
* both write `1`, and a single unit of stock is sold twice with the
* reservation count showing one. That is not theoretical — it was
* reproduced with two concurrent checkouts against one unit, and both
* orders were created.
*
* A single statement carrying its own guard is safe under Postgres's
* default READ COMMITTED: the second writer blocks on the row lock, then
* re-evaluates `on_hand - reserved >= n` against the row the first writer
* committed, and matches nothing.
*/
for (const line of cart.lines) {
const level = await tx.stockLevel.findFirst({
where: { variantId: line.variantId },
select: { variantId: true, locationId: true, onHand: true, reserved: true },
});
// Picking *which* location to draw from stays a plain read — multi-
// location allocation is an open question (see docs/adr/README.md).
// What has to be atomic is the reservation itself.
const reserved = level
? await tx.$executeRaw`
UPDATE stock_levels
SET reserved = reserved + ${line.quantity}
WHERE variant_id = ${level.variantId}::uuid
AND location_id = ${level.locationId}::uuid
AND on_hand - reserved >= ${line.quantity}
`
: 0;
if (reserved === 0) {
const available = level ? Math.max(0, level.onHand - level.reserved) : 0;
throw AppException.conflict(
`${line.productName} (${line.variantTitle}) only has ${available} left.`,
);
}
}
const order = await tx.order.create({
data: {
email: input.email,
phone: input.shippingAddress.phone,
subtotalAmount: cart.totals.subtotal.amount,
discountAmount: cart.totals.discount.amount,
shippingAmount: cart.totals.shipping.amount,
taxAmount: cart.totals.tax.amount,
totalAmount: cart.totals.total.amount,
shipFullName: input.shippingAddress.fullName,
shipPhone: input.shippingAddress.phone,
shipLine1: input.shippingAddress.line1,
shipLine2: input.shippingAddress.line2 ?? null,
shipWard: input.shippingAddress.ward ?? null,
shipDistrict: input.shippingAddress.district ?? null,
shipProvince: input.shippingAddress.province,
shipCountryCode: input.shippingAddress.countryCode,
shipPostalCode: input.shippingAddress.postalCode ?? null,
customerNote: input.customerNote ?? null,
lines: {
create: cart.lines.map((line) => ({
variantId: line.variantId,
// Snapshot. The order must stay readable after the catalog moves
// on — renamed, repriced, archived or all three.
productName: line.productName,
variantTitle: line.variantTitle,
sku: line.sku,
imageUrl: line.imageUrl,
unitAmount: line.unitPrice.amount,
quantity: line.quantity,
lineAmount: line.lineTotal.amount,
})),
},
},
select: { id: true, number: true },
});
return order.id;
});
// Only once the order is durably committed. Clearing first would lose a
// shopper's bag to a failed transaction.
await this.carts.clear(cartToken);
this.logger.log(`Order ${orderId} placed with ${cart.lines.length} line(s)`);
return this.orders.getById(orderId);
}
}
@@ -7,4 +7,4 @@
*
* Keep it narrow: each export is a promise to the rest of the codebase.
*/
export {};
export { CheckoutService } from '../checkout.service';
@@ -0,0 +1,46 @@
import { ORDER_STATUSES, type OrderStatus } from '@sport/types';
/**
* The transition table, asserted as a table.
*
* It lives in `orders.service.ts` and is duplicated in the admin UI to decide
* which buttons to render. Two copies of a rule need the rule written down
* somewhere that fails loudly when one of them drifts — and a wrong transition
* is not cosmetic: `CANCELLED` releases reserved stock and `FULFILLED` ships it,
* so a path that should not exist moves real inventory.
*/
const ALLOWED: Record<OrderStatus, readonly OrderStatus[]> = {
PENDING: [ORDER_STATUSES.CONFIRMED, ORDER_STATUSES.CANCELLED],
CONFIRMED: [ORDER_STATUSES.FULFILLED, ORDER_STATUSES.CANCELLED],
FULFILLED: [ORDER_STATUSES.COMPLETED],
COMPLETED: [],
CANCELLED: [],
};
const ALL = Object.values(ORDER_STATUSES);
describe('order status transitions', () => {
it('never lets a terminal order move again', () => {
expect(ALLOWED.COMPLETED).toHaveLength(0);
expect(ALLOWED.CANCELLED).toHaveLength(0);
});
it('only reaches FULFILLED from CONFIRMED', () => {
const sources = ALL.filter((from) => ALLOWED[from].includes(ORDER_STATUSES.FULFILLED));
// Stock is shipped on this edge; more than one way in means more than one
// place that has to get the ledger right.
expect(sources).toEqual([ORDER_STATUSES.CONFIRMED]);
});
it('allows cancelling only while nothing has shipped', () => {
const sources = ALL.filter((from) => ALLOWED[from].includes(ORDER_STATUSES.CANCELLED));
expect(sources.sort()).toEqual([ORDER_STATUSES.CONFIRMED, ORDER_STATUSES.PENDING].sort());
});
it('has no self-transitions and no cycles back to PENDING', () => {
for (const from of ALL) {
expect(ALLOWED[from]).not.toContain(from);
expect(ALLOWED[from]).not.toContain(ORDER_STATUSES.PENDING);
}
});
});
@@ -0,0 +1,61 @@
import { Body, Controller, Get, Param, Patch, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
PERMISSIONS,
TOKEN_AUDIENCES,
type AuthenticatedActor,
type OffsetPaginated,
type Order,
type OrderListItem,
} from '@sport/types';
import {
orderListQuerySchema,
updateOrderStatusSchema,
type OrderListQuery,
type UpdateOrderStatusInput,
} 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 { OrdersService } from './orders.service';
@ApiTags('admin/orders')
@ApiBearerAuth()
@RequireAudience(TOKEN_AUDIENCES.ADMIN)
@Controller('admin/orders')
export class OrdersAdminController {
constructor(private readonly service: OrdersService) {}
@Get()
@RequirePermissions(PERMISSIONS.ORDER_READ)
@ApiOperation({ summary: 'Orders, newest first' })
list(
@Query(new ZodValidationPipe(orderListQuerySchema)) query: OrderListQuery,
): Promise<OffsetPaginated<OrderListItem>> {
return this.service.list(query);
}
@Get(':id')
@RequirePermissions(PERMISSIONS.ORDER_READ)
@ApiOperation({ summary: 'One order with its lines' })
getById(@Param('id') id: string): Promise<Order> {
return this.service.getById(id);
}
@Patch(':id/status')
@RequirePermissions(PERMISSIONS.ORDER_UPDATE)
@ApiOperation({ summary: 'Move an order through its lifecycle' })
updateStatus(
@Param('id') id: string,
@Body(new ZodValidationPipe(updateOrderStatusSchema)) body: UpdateOrderStatusInput,
@CurrentActor() actor: AuthenticatedActor,
): Promise<Order> {
return this.service.updateStatus(id, body, actor.userId);
}
}
@@ -0,0 +1,39 @@
import { formatOrderNumber, parseOrderNumber } from './orders.mapper';
/**
* The display number and the stored integer must round-trip.
*
* `parseOrderNumber` is what the admin search and the guest lookup both run on
* whatever a human typed, and a lookup that silently fails to parse looks
* exactly like "that order does not exist" — the same response the API gives
* for a wrong email, deliberately. So the parsing has to be right, because
* nothing downstream can tell you it was wrong.
*/
describe('order numbers', () => {
it('formats with a stable prefix and width', () => {
expect(formatOrderNumber(1)).toBe('SP-000001');
expect(formatOrderNumber(123456)).toBe('SP-123456');
});
it('keeps growing past the padding rather than truncating', () => {
expect(formatOrderNumber(1234567)).toBe('SP-1234567');
});
it('accepts every form a customer might paste back', () => {
for (const input of ['SP-000123', 'sp-000123', 'SP123', '123', ' SP-123 ']) {
expect(parseOrderNumber(input)).toBe(123);
}
});
it('rejects anything that is not a positive order number', () => {
for (const input of ['', 'SP-', 'abc', '0', '-5', 'SP-abc']) {
expect(parseOrderNumber(input)).toBeNull();
}
});
it('round-trips', () => {
for (const value of [1, 42, 999, 1_000_000]) {
expect(parseOrderNumber(formatOrderNumber(value))).toBe(value);
}
});
});
@@ -0,0 +1,103 @@
import { Injectable } from '@nestjs/common';
import type {
CurrencyCode,
FulfillmentStatus,
Money,
Order,
OrderLine,
OrderListItem,
OrderStatus,
PaymentStatus,
} from '@sport/types';
import type { OrderDetailRow, OrderListRow } from './orders.repository';
/** Display prefix. Stored as an integer so it stays monotonic and cheap. */
export function formatOrderNumber(value: number): string {
return `SP-${String(value).padStart(6, '0')}`;
}
/** Parses "SP-000123", "sp-123" or "123" back to the stored integer. */
export function parseOrderNumber(value: string): number | null {
const digits = value.trim().replace(/^SP-?/i, '');
const parsed = Number.parseInt(digits, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
@Injectable()
export class OrdersMapper {
toOrder(row: OrderDetailRow): Order {
const currency = row.currency as CurrencyCode;
const money = (amount: number): Money => ({ amount, currency });
return {
id: row.id,
orderNumber: formatOrderNumber(row.number),
status: row.status as OrderStatus,
paymentStatus: row.paymentStatus as PaymentStatus,
fulfillmentStatus: row.fulfillmentStatus as FulfillmentStatus,
email: row.email,
phone: row.phone,
shippingAddress: {
fullName: row.shipFullName,
phone: row.shipPhone,
line1: row.shipLine1,
line2: row.shipLine2,
ward: row.shipWard,
district: row.shipDistrict,
province: row.shipProvince,
countryCode: row.shipCountryCode,
postalCode: row.shipPostalCode,
},
customerNote: row.customerNote,
currency,
subtotal: money(row.subtotalAmount),
discount: money(row.discountAmount),
shipping: money(row.shippingAmount),
tax: money(row.taxAmount),
total: money(row.totalAmount),
lines: row.lines.map((line) => this.toLine(line, currency)),
placedAt: row.placedAt.toISOString(),
confirmedAt: row.confirmedAt?.toISOString() ?? null,
cancelledAt: row.cancelledAt?.toISOString() ?? null,
cancelReason: row.cancelReason,
};
}
toListItem(row: OrderListRow): OrderListItem {
const currency = row.currency as CurrencyCode;
return {
id: row.id,
orderNumber: formatOrderNumber(row.number),
status: row.status as OrderStatus,
paymentStatus: row.paymentStatus as PaymentStatus,
fulfillmentStatus: row.fulfillmentStatus as FulfillmentStatus,
email: row.email,
customerName: row.shipFullName,
itemCount: row.lines.reduce((count, line) => count + line.quantity, 0),
total: { amount: row.totalAmount, currency },
placedAt: row.placedAt.toISOString(),
};
}
private toLine(line: OrderDetailRow['lines'][number], currency: CurrencyCode): OrderLine {
return {
id: line.id,
variantId: line.variantId,
productName: line.productName,
variantTitle: line.variantTitle,
sku: line.sku,
imageUrl: line.imageUrl,
unitPrice: { amount: line.unitAmount, currency },
quantity: line.quantity,
lineTotal: { amount: line.lineAmount, currency },
};
}
}
+16 -16
View File
@@ -1,23 +1,23 @@
import { Module } from '@nestjs/common';
import { OrdersAdminController } from './orders.controller';
import { OrdersMapper } from './orders.mapper';
import { OrdersRepository } from './orders.repository';
import { OrdersService } from './orders.service';
/**
* OrdersModule — boundary declared, implementation pending.
* OrdersModule — owns `orders` and `order_lines`.
*
* Owns (exclusively): `orders`, `order_items`, `order_status_history` — milestone 2
* Every catalog value on an order is a snapshot, so this module reads no other
* module's tables to render one. It writes stock levels only through the
* lifecycle transitions that legitimately move stock (cancel releases, fulfil
* ships), and every such write lands in the inventory ledger.
*
* Order lines snapshot product name, variant title and price at purchase time. Never join to the live catalog for historical orders: yesterday’s receipt must not change when a price does.
*
* EXTRACTION CANDIDATE: designed so it could become its own service. It must
* therefore never read another module’s tables directly, and it communicates
* outward through domain events.
*
* Anatomy once implemented (see ../README.md):
* orders.module.ts wiring only
* orders.controller.ts HTTP surface, no logic
* orders.service.ts business rules
* orders.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
* EXTRACTION CANDIDATE.
*/
@Module({})
@Module({
controllers: [OrdersAdminController],
providers: [OrdersService, OrdersRepository, OrdersMapper],
exports: [OrdersService],
})
export class OrdersModule {}
@@ -0,0 +1,100 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
const lineSelect = {
id: true,
variantId: true,
productName: true,
variantTitle: true,
sku: true,
imageUrl: true,
unitAmount: true,
quantity: true,
lineAmount: true,
} as const;
const detailSelect = {
id: true,
number: true,
status: true,
paymentStatus: true,
fulfillmentStatus: true,
email: true,
phone: true,
currency: true,
subtotalAmount: true,
discountAmount: true,
shippingAmount: true,
taxAmount: true,
totalAmount: true,
shipFullName: true,
shipPhone: true,
shipLine1: true,
shipLine2: true,
shipWard: true,
shipDistrict: true,
shipProvince: true,
shipCountryCode: true,
shipPostalCode: true,
customerNote: true,
placedAt: true,
confirmedAt: true,
cancelledAt: true,
cancelReason: true,
lines: { orderBy: { createdAt: 'asc' }, select: lineSelect },
} as const;
const listSelect = {
id: true,
number: true,
status: true,
paymentStatus: true,
fulfillmentStatus: true,
email: true,
shipFullName: true,
currency: true,
totalAmount: true,
placedAt: true,
lines: { select: { quantity: true } },
} as const;
@Injectable()
export class OrdersRepository {
constructor(private readonly prisma: PrismaService) {}
findById(id: string) {
return this.prisma.order.findUnique({ where: { id }, select: detailSelect });
}
/**
* Guest lookup: order number AND the email it was placed with.
*
* Two factors on purpose. An order number alone is guessable — they are
* sequential — and an order contains a name, a phone number and a home
* address.
*/
findByNumberAndEmail(number: number, email: string) {
return this.prisma.order.findFirst({
where: { number, email: email.trim().toLowerCase() },
select: detailSelect,
});
}
list(where: Prisma.OrderWhereInput, skip: number, take: number) {
return Promise.all([
this.prisma.order.findMany({
where,
orderBy: { placedAt: 'desc' },
skip,
take,
select: listSelect,
}),
this.prisma.order.count({ where }),
]);
}
}
export type OrderDetailRow = NonNullable<Awaited<ReturnType<OrdersRepository['findById']>>>;
export type OrderListRow = Awaited<ReturnType<OrdersRepository['list']>>[0][number];
@@ -0,0 +1,253 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
ORDER_STATUSES,
type OffsetPaginated,
type Order,
type OrderListItem,
type OrderStatus,
} from '@sport/types';
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 { OrdersMapper, parseOrderNumber } from './orders.mapper';
import { OrdersRepository } from './orders.repository';
/**
* Which transitions are legal.
*
* Written out rather than left to `if` statements at each call site: an order's
* status drives stock, refunds and what the customer is told, and "how did this
* order get from CANCELLED back to CONFIRMED?" is a question worth making
* unanswerable by construction.
*/
const ALLOWED_TRANSITIONS: Record<OrderStatus, readonly OrderStatus[]> = {
PENDING: [ORDER_STATUSES.CONFIRMED, ORDER_STATUSES.CANCELLED],
CONFIRMED: [ORDER_STATUSES.FULFILLED, ORDER_STATUSES.CANCELLED],
FULFILLED: [ORDER_STATUSES.COMPLETED],
COMPLETED: [],
CANCELLED: [],
};
@Injectable()
export class OrdersService {
constructor(
private readonly prisma: PrismaService,
private readonly repository: OrdersRepository,
private readonly mapper: OrdersMapper,
private readonly audit: AuditService,
) {}
async getById(id: string): Promise<Order> {
const row = await this.repository.findById(id);
if (!row) throw AppException.notFound('Order');
return this.mapper.toOrder(row);
}
/** Guest order lookup — see `findByNumberAndEmail` for why both are required. */
async lookup(orderNumber: string, email: string): Promise<Order> {
const number = parseOrderNumber(orderNumber);
if (number === null) throw AppException.notFound('Order');
const row = await this.repository.findByNumberAndEmail(number, email);
// Deliberately the same error as a bad number: distinguishing "wrong email"
// from "no such order" would turn this into an order-number oracle.
if (!row) throw AppException.notFound('Order');
return this.mapper.toOrder(row);
}
async list(query: OrderListQuery): Promise<OffsetPaginated<OrderListItem>> {
const where: Prisma.OrderWhereInput = {
...(query.status ? { status: query.status } : {}),
...(query.q
? {
OR: [
{ email: { contains: query.q, mode: 'insensitive' } },
{ shipFullName: { contains: query.q, mode: 'insensitive' } },
{ shipPhone: { contains: query.q } },
...(parseOrderNumber(query.q) !== null
? [{ number: parseOrderNumber(query.q) as number }]
: []),
],
}
: {}),
};
const [rows, totalItems] = await this.repository.list(
where,
(query.page - 1) * query.perPage,
query.perPage,
);
const totalPages = Math.max(1, Math.ceil(totalItems / query.perPage));
return {
items: rows.map((row) => this.mapper.toListItem(row)),
pageInfo: {
page: query.page,
perPage: query.perPage,
totalItems,
totalPages,
hasNextPage: query.page < totalPages,
},
};
}
/**
* Moves an order through its lifecycle, releasing stock when it dies.
*
* Cancelling is the case that matters. The reservation taken at checkout is
* held against `StockLevel.reserved`, and an order that ends without ever
* shipping has to give those units back — otherwise every abandoned order
* permanently shrinks sellable stock.
*/
async updateStatus(
id: string,
input: UpdateOrderStatusInput,
actorUserId: string,
): Promise<Order> {
const existing = await this.prisma.order.findUnique({
where: { id },
select: {
id: true,
status: true,
lines: { select: { variantId: true, quantity: true } },
},
});
if (!existing) throw AppException.notFound('Order');
const from = existing.status as OrderStatus;
const to = input.status;
if (from === to) return this.getById(id);
if (!ALLOWED_TRANSITIONS[from].includes(to)) {
throw AppException.badRequest(`An order cannot go from ${from} to ${to}.`);
}
if (to === ORDER_STATUSES.CANCELLED && !input.reason?.trim()) {
throw AppException.badRequest('Give a reason when cancelling an order.');
}
await this.prisma.$transaction(async (tx) => {
/**
* Compare-and-set on the status we validated against.
*
* The transition was checked from a row read outside this transaction, so
* two operators clicking "Cancel" at once would both pass that check and
* both release the reservation — returning twice the stock that was ever
* held. Scoping the update to `status: from` means exactly one of them
* matches a row.
*/
const changed = await tx.order.updateMany({
where: { id, status: from },
data: {
status: to,
...(to === ORDER_STATUSES.CONFIRMED ? { confirmedAt: new Date() } : {}),
...(to === ORDER_STATUSES.CANCELLED
? { cancelledAt: new Date(), cancelReason: input.reason?.trim() ?? null }
: {}),
},
});
if (changed.count === 0) {
throw AppException.conflict('This order was already updated by someone else.');
}
if (to === ORDER_STATUSES.CANCELLED) {
await this.releaseReservations(tx, existing.lines);
}
if (to === ORDER_STATUSES.FULFILLED) {
await this.commitReservations(tx, id, existing.lines, actorUserId);
}
});
this.audit.record({
actorUserId,
action: `order.${to.toLowerCase()}`,
resourceType: 'Order',
resourceId: id,
changes: { from, to, reason: input.reason ?? null },
});
return this.getById(id);
}
// ---- internals -----------------------------------------------------------
/**
* Hands reserved units back without touching `onHand` — nothing shipped.
*
* Arithmetic in SQL, not in JavaScript, for the same reason checkout reserves
* that way: read-then-write loses concurrent updates. `GREATEST(…, 0)` keeps
* a double-release from manufacturing stock out of nothing.
*/
private async releaseReservations(
tx: Prisma.TransactionClient,
lines: readonly { variantId: string | null; quantity: number }[],
): Promise<void> {
for (const line of lines) {
if (!line.variantId) continue;
await tx.$executeRaw`
UPDATE stock_levels
SET reserved = GREATEST(reserved - ${line.quantity}, 0)
WHERE variant_id = ${line.variantId}::uuid
`;
}
}
/**
* Turns a reservation into a shipment: `onHand` falls, `reserved` falls with
* it, and the ledger records why.
*
* This is the only place stock leaves the building, and it writes a
* `StockMovement` because the level is a projection of that ledger — a
* decrement without an entry is exactly the discrepancy the ledger exists to
* make answerable.
*/
private async commitReservations(
tx: Prisma.TransactionClient,
orderId: string,
lines: readonly { variantId: string | null; quantity: number }[],
actorUserId: string,
): Promise<void> {
for (const line of lines) {
if (!line.variantId) continue;
const level = await tx.stockLevel.findFirst({
where: { variantId: line.variantId },
select: { variantId: true, locationId: true },
});
if (!level) continue;
// Both counters move in one statement, computed from the row's own
// current values rather than from ones read a moment ago.
await tx.$executeRaw`
UPDATE stock_levels
SET on_hand = GREATEST(on_hand - ${line.quantity}, 0),
reserved = GREATEST(reserved - ${line.quantity}, 0)
WHERE variant_id = ${level.variantId}::uuid
AND location_id = ${level.locationId}::uuid
`;
await tx.stockMovement.create({
data: {
variantId: level.variantId,
locationId: level.locationId,
reason: 'SALE',
quantityDelta: -line.quantity,
referenceId: orderId,
createdByUserId: actorUserId,
},
});
}
}
}
+2 -1
View File
@@ -7,4 +7,5 @@
*
* Keep it narrow: each export is a promise to the rest of the codebase.
*/
export {};
export { OrdersService } from '../orders.service';
export { formatOrderNumber, parseOrderNumber } from '../orders.mapper';
@@ -20,6 +20,8 @@ import type {
import { AuditService } from '@/common/audit/audit.service';
import { AppException } from '@/common/errors/app.exception';
import { toDbLocale } from '@/common/i18n';
import { DOMAIN_EVENTS } from '@/infrastructure/events/domain-event';
import { EventBusService } from '@/infrastructure/events/event-bus.service';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
import { CACHE_KEYS } from '@/infrastructure/redis/cache-keys';
import { RedisService } from '@/infrastructure/redis/redis.service';
@@ -38,6 +40,7 @@ export class ProductsAdminService {
private readonly mapper: ProductsAdminMapper,
private readonly redis: RedisService,
private readonly audit: AuditService,
private readonly events: EventBusService,
) {}
// ---- Reads ---------------------------------------------------------------
@@ -807,6 +810,20 @@ export class ProductsAdminService {
const dropped = await this.redis.deleteByPrefix(CACHE_KEYS.catalogPrefix());
this.logger.log(`${action} on ${productId}; dropped ${dropped} catalog cache key(s)`);
/**
* Announce the change; search reindexes itself.
*
* An event rather than a direct call, because calling SearchModule from
* here would make ProductsModule depend on it while SearchModule already
* depends on ProductsModule — a cycle Nest cannot wire. It is also the rule
* this codebase already states: needing an *answer* is a service call,
* needing something to *react* is an event (see infrastructure/events).
*
* The consequence is honest eventual consistency: the index trails a write
* by a tick.
*/
this.events.publish(DOMAIN_EVENTS.PRODUCT_UPDATED, { productId });
this.audit.record({
actorUserId,
action,
@@ -144,6 +144,8 @@ const detailSelect = {
export interface ResolvedFilter extends ProductFilter {
/** Materialised path of the requested category, if it resolved. */
categoryPath?: string | null;
/** Restricts the result set to a specific id list — used by ranked search. */
ids?: string[];
}
@Injectable()
@@ -215,6 +217,10 @@ export class ProductsRepository {
});
}
if (filter.ids) {
and.push({ id: { in: filter.ids } });
}
if (filter.gender?.length) {
and.push({ genderTargets: { hasSome: filter.gender } });
}
@@ -351,6 +357,38 @@ export class ProductsRepository {
});
}
/** Same payload as `findList`, without paging — the caller already has the order. */
findByIds(where: Prisma.ProductWhereInput, locale: Locale) {
return this.prisma.product.findMany({
where,
select: {
...listSelect,
translations: { where: { locale: toDbLocale(locale) } },
brand: {
select: {
id: true,
name: true,
translations: { where: { locale: toDbLocale(locale) } },
},
},
options: {
where: { key: 'colour' },
select: {
...optionSelect,
translations: { where: { locale: toDbLocale(locale) } },
values: {
orderBy: { position: 'asc' },
select: {
...optionSelect.values.select,
translations: { where: { locale: toDbLocale(locale) } },
},
},
},
},
},
});
}
count(where: Prisma.ProductWhereInput) {
return this.prisma.product.count({ where });
}
@@ -18,7 +18,11 @@ import { RedisService } from '@/infrastructure/redis/redis.service';
import { CategoriesService } from '@/modules/categories/public';
import { ProductsMapper } from './products.mapper';
import { ProductsRepository, type ResolvedFilter } from './products.repository';
import {
ProductsRepository,
type ProductListRow,
type ResolvedFilter,
} from './products.repository';
@Injectable()
export class ProductsService {
@@ -94,6 +98,93 @@ export class ProductsService {
return cached;
}
/**
* Renders a ranked id list as a listing.
*
* Search owns the ranking; the catalog owns what a product looks like. This
* is the join between the two, and it preserves the given order unless the
* caller explicitly asked for a different one.
*
* The visibility rules still apply: an id that search returns for a product
* which has since been unpublished simply does not come back.
*/
async listByIds(
ids: readonly string[],
filter: ProductFilter,
locale: Locale,
): Promise<ProductListResult> {
if (ids.length === 0) {
return {
items: [],
pageInfo: { nextCursor: null, hasNextPage: false },
totalCount: 0,
facets: { brands: [], colors: [], sizes: [], priceRange: null },
};
}
const resolved = await this.resolveFilter(filter, locale);
/**
* `q` is dropped, deliberately.
*
* The search provider has already applied it — with diacritic folding,
* trigram tolerance, and matching across brand, SKU and colourway. Letting
* the catalog re-apply it as a plain `name CONTAINS q` intersects all of
* that away: searching "velocity" found the right products by brand and
* then discarded every one of them, because the brand is not in the name.
*/
const scoped = { ...resolved, q: undefined, ids: [...ids] };
const where = this.repository.buildWhere(scoped, locale);
const contextWhere = this.repository.buildContextWhere(scoped, locale);
const [rows, totalCount, facets] = await Promise.all([
this.repository.findByIds(where, locale),
this.repository.count(where),
this.buildFacets(contextWhere, locale),
]);
/**
* Relevance by default, but an explicit sort still wins.
*
* The database returned these unordered, so the ranking has to be restored
* here. If the shopper picked "price: low to high" on a search result page,
* honouring the ranking anyway would leave the sort control visibly lying —
* it would change the URL and nothing else.
*/
const rank = new Map(ids.map((id, index) => [id, index]));
const byRank = (a: ProductListRow, b: ProductListRow) =>
(rank.get(a.id) ?? Infinity) - (rank.get(b.id) ?? Infinity);
const cheapest = (row: ProductListRow) =>
Math.min(
...row.variants.map((variant) => variant.salePriceAmount ?? variant.priceAmount),
Infinity,
);
const comparators: Record<string, (a: ProductListRow, b: ProductListRow) => number> = {
price_asc: (a, b) => cheapest(a) - cheapest(b) || byRank(a, b),
price_desc: (a, b) => cheapest(b) - cheapest(a) || byRank(a, b),
};
const ordered = [...rows].sort(comparators[filter.sort ?? ''] ?? byRank);
const limit = filter.limit;
const page = ordered.slice(0, limit);
return {
items: page.map((row) => this.mapper.toListItem(row, locale)),
pageInfo: {
// Ranked results are a single page: a cursor into a relevance ordering
// means nothing once the ranking is recomputed.
hasNextPage: false,
nextCursor: null,
},
totalCount,
facets,
};
}
/** Slugs + last-modified for `generateStaticParams` and the sitemap. */
async listSlugs(locale: Locale): Promise<{ slug: string; updatedAt: string }[]> {
const rows = await this.repository.findAllSlugs(locale);
@@ -0,0 +1,146 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type { Locale } from '@sport/types';
import { toDbLocale } from '@/common/i18n';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
import type {
SearchHit,
SearchIndexDocument,
SearchProvider,
SearchSuggestion,
} from './search.provider';
/**
* Below this, a trigram match is noise rather than a typo.
*
* Tuned against the real catalog: "jaket" scores 0.44 against every jacket,
* "nocturn" scores 0.88 against Nocturne, and nonsense scores 0. Anything
* higher than 0.4 loses the single-character typos this exists for.
*/
const TRIGRAM_THRESHOLD = 0.4;
/**
* PostgreSQL full-text search (ADR-0012).
*
* Two matching strategies, combined rather than chosen between:
*
* 1. `websearch_to_tsquery` against the weighted `document` column. This is
* the precise path — it understands quoted phrases and `-exclusions`, and
* it ranks a name match above a description match because the vector was
* built with weights.
* 2. Trigram similarity on title + keywords. This is the forgiving path —
* it survives typos and partial words, which a tsquery does not.
*
* A product matching either is a hit; the score is the better of the two,
* scaled so they are comparable. Running only the first means "nocturn" finds
* nothing; running only the second ranks a description mention as highly as a
* name.
*
* The trigram comparison is a function call rather than the `<%` operator, so
* it does not use the GIN index and degrades to a sequential scan. That is a
* deliberate trade at this catalog size — the operator's threshold is a session
* GUC, which is awkward to set per request. It is the first thing to change if
* the catalog outgrows ADR-0012's ~50k estimate.
*/
@Injectable()
export class PostgresSearchProvider implements SearchProvider {
private readonly logger = new Logger(PostgresSearchProvider.name);
constructor(private readonly prisma: PrismaService) {}
async search(query: string, locale: Locale, limit: number): Promise<SearchHit[]> {
const term = query.trim();
if (term.length === 0) return [];
const rows = await this.prisma.$queryRaw<{ product_id: string; score: number }[]>`
WITH q AS (
SELECT websearch_to_tsquery('simple', immutable_unaccent(${term})) AS tsq,
immutable_unaccent(${term}) AS raw
)
SELECT d.product_id,
GREATEST(
-- ts_rank_cd rewards term density and honours the A/B/C weights.
ts_rank_cd(d.document, q.tsq, 32) * 4,
-- word_similarity, not plain similarity: the latter compares
-- whole strings, so a short query against a long document scores
-- near zero however well it matches. "nocturn" against the
-- Nocturne document scored 0.125, below any usable threshold.
-- This scores the best-matching word extent instead (0.875 for
-- the same pair), which is the question actually being asked.
word_similarity(q.raw, immutable_unaccent(d.title) || ' ' || immutable_unaccent(d.keywords))
)::float8 AS score
FROM search_documents d, q
WHERE d.locale = ${toDbLocale(locale)}::"Locale"
AND (
d.document @@ q.tsq
OR word_similarity(
q.raw,
immutable_unaccent(d.title) || ' ' || immutable_unaccent(d.keywords)
) > ${TRIGRAM_THRESHOLD}
)
ORDER BY score DESC, d.title ASC
LIMIT ${limit}
`;
return rows.map((row) => ({ productId: row.product_id, score: row.score }));
}
async suggest(query: string, locale: Locale, limit: number): Promise<SearchSuggestion[]> {
const term = query.trim();
if (term.length === 0) return [];
// Suggestions match on the *title* only. Offering "Aero Run Tee" because
// the word appears in its description reads as a broken autocomplete.
const rows = await this.prisma.$queryRaw<{ title: string; slug: string; score: number }[]>`
SELECT d.title,
COALESCE(t.slug, p.slug) AS slug,
word_similarity(immutable_unaccent(${term}), immutable_unaccent(d.title))::float8 AS score
FROM search_documents d
JOIN products p ON p.id = d.product_id
LEFT JOIN product_translations t
ON t.product_id = d.product_id AND t.locale = d.locale
WHERE d.locale = ${toDbLocale(locale)}::"Locale"
AND p.status = 'ACTIVE'
AND p.deleted_at IS NULL
AND (
immutable_unaccent(d.title) ILIKE '%' || immutable_unaccent(${term}) || '%'
OR word_similarity(immutable_unaccent(${term}), immutable_unaccent(d.title)) > ${TRIGRAM_THRESHOLD}
)
ORDER BY score DESC, length(d.title) ASC
LIMIT ${limit}
`;
return rows.map((row) => ({ text: row.title, productSlug: row.slug }));
}
/**
* Upserts documents. `document` is never written — Postgres generates it from
* the columns below, so the vector cannot drift from its own source text.
*/
async index(documents: readonly SearchIndexDocument[]): Promise<void> {
if (documents.length === 0) return;
const values = documents.map(
(doc) =>
Prisma.sql`(${doc.productId}::uuid, ${toDbLocale(doc.locale)}::"Locale", ${doc.title}, ${doc.keywords}, ${doc.body}, NOW())`,
);
await this.prisma.$executeRaw`
INSERT INTO search_documents (product_id, locale, title, keywords, body, updated_at)
VALUES ${Prisma.join(values)}
ON CONFLICT (product_id, locale) DO UPDATE
SET title = EXCLUDED.title,
keywords = EXCLUDED.keywords,
body = EXCLUDED.body,
updated_at = NOW()
`;
}
async remove(productId: string): Promise<void> {
await this.prisma.searchDocument.deleteMany({ where: { productId } });
this.logger.debug(`Removed ${productId} from the search index`);
}
}
+2 -1
View File
@@ -7,4 +7,5 @@
*
* Keep it narrow: each export is a promise to the rest of the codebase.
*/
export {};
export { SearchService } from '../search.service';
export { SearchIndexerService } from '../search-indexer.service';
@@ -0,0 +1,52 @@
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import { DOMAIN_EVENTS } from '@/infrastructure/events/domain-event';
import { EventBusService } from '@/infrastructure/events/event-bus.service';
import { SearchIndexerService } from './search-indexer.service';
/**
* Keeps the index in step with the catalog, by subscription rather than by call.
*
* This is the direction the dependency has to run: SearchModule knows about
* products, products knows nothing about search. When this module is extracted,
* this file is the only thing that changes — an in-process subscription becomes
* a queue consumer, and the producer never learns the difference.
*
* Failures are logged, not rethrown. A search index that missed one update is a
* degraded search; an exception escaping here would take down the write that
* triggered it, which is a far worse trade.
*/
@Injectable()
export class SearchIndexSubscriber implements OnModuleInit {
private readonly logger = new Logger(SearchIndexSubscriber.name);
constructor(
private readonly events: EventBusService,
private readonly indexer: SearchIndexerService,
) {}
onModuleInit(): void {
for (const event of [
DOMAIN_EVENTS.PRODUCT_UPDATED,
DOMAIN_EVENTS.PRODUCT_PUBLISHED,
DOMAIN_EVENTS.PRODUCT_ARCHIVED,
]) {
this.events.on<{ productId: string }>(event).subscribe((message) => {
void this.reindex(message.payload.productId, event);
});
}
}
private async reindex(productId: string, event: string): Promise<void> {
try {
await this.indexer.reindexProduct(productId);
} catch (error) {
this.logger.error(
`Reindex failed for ${productId} after ${event}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
}
@@ -0,0 +1,144 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { LOCALES } from '@sport/types';
import { toDbLocale } from '@/common/i18n';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
import { SEARCH_PROVIDER, type SearchIndexDocument, type SearchProvider } from './search.provider';
/**
* Builds searchable documents from the catalog.
*
* This is the one place in SearchModule that reads product tables, and it does
* so to *project* them — the output is a flat document, never a product
* payload. When this module is extracted, this class becomes a consumer of
* catalog events rather than a reader of catalog tables, and nothing above it
* changes.
*
* Only ACTIVE, published products are indexed. A draft product appearing in
* search is a leak, not a feature.
*/
@Injectable()
export class SearchIndexerService {
private readonly logger = new Logger(SearchIndexerService.name);
constructor(
private readonly prisma: PrismaService,
@Inject(SEARCH_PROVIDER) private readonly provider: SearchProvider,
) {}
/** Rebuilds one product's documents, or removes them if it is no longer sellable. */
async reindexProduct(productId: string): Promise<void> {
const documents = await this.buildDocuments({ id: productId });
if (documents.length === 0) {
// Unpublished, archived or deleted since the event fired — the right
// response is removal, not a stale row nobody will notice.
await this.provider.remove(productId);
return;
}
await this.provider.index(documents);
}
/** Full rebuild. Safe to run at any time — the index is pure projection. */
async reindexAll(): Promise<number> {
const documents = await this.buildDocuments({});
await this.provider.index(documents);
this.logger.log(`Reindexed ${documents.length} document(s)`);
return documents.length;
}
private async buildDocuments(where: { id?: string }): Promise<SearchIndexDocument[]> {
const products = await this.prisma.product.findMany({
where: {
...where,
status: 'ACTIVE',
deletedAt: null,
OR: [{ publishedAt: null }, { publishedAt: { lte: new Date() } }],
},
select: {
id: true,
name: true,
description: true,
shortDescription: true,
genderTargets: true,
sportTypes: true,
translations: true,
brand: { select: { name: true, translations: true } },
primaryCategory: { select: { name: true, translations: true } },
variants: {
where: { status: 'ACTIVE', deletedAt: null },
select: { sku: true },
},
options: {
select: {
values: {
select: { label: true, translations: true },
},
},
},
},
});
const documents: SearchIndexDocument[] = [];
for (const product of products) {
for (const locale of LOCALES) {
const dbLocale = toDbLocale(locale);
const translation = product.translations.find((row) => row.locale === dbLocale);
// Falls back to the canonical field, matching how the storefront
// renders an untranslated product — the index must find what the page
// actually shows.
const title = translation?.name || product.name;
const optionLabels = product.options.flatMap((option) =>
option.values.map(
(value) =>
value.translations.find((row) => row.locale === dbLocale)?.label || value.label,
),
);
const brand =
product.brand?.translations.find((row) => row.locale === dbLocale)?.name ||
product.brand?.name ||
'';
const category =
product.primaryCategory?.translations.find((row) => row.locale === dbLocale)?.name ||
product.primaryCategory?.name ||
'';
documents.push({
productId: product.id,
locale,
title,
// Short, high-signal terms. SKUs are in here because staff and
// returning customers search by them constantly.
keywords: unique([
brand,
category,
...optionLabels,
...product.variants.map((variant) => variant.sku),
...product.genderTargets,
...product.sportTypes,
]).join(' '),
body: [
translation?.shortDescription || product.shortDescription || '',
translation?.description || product.description || '',
]
.filter(Boolean)
.join(' '),
});
}
}
return documents;
}
}
function unique(values: readonly string[]): string[] {
return [...new Set(values.filter((value) => value.trim().length > 0))];
}
@@ -0,0 +1,74 @@
import { Controller, Get, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
PERMISSIONS,
TOKEN_AUDIENCES,
type Locale,
type ProductListResult,
type SearchSuggestions,
} from '@sport/types';
import { productFilterSchema, type ProductFilter } from '@sport/validation';
import { Public } from '@/common/decorators/public.decorator';
import {
RequireAudience,
RequirePermissions,
} from '@/common/decorators/require-permissions.decorator';
import { RequestLocale } from '@/common/i18n/locale.decorator';
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
import { SearchIndexerService } from './search-indexer.service';
import { SearchService } from './search.service';
/** Enough to fill a suggestion dropdown; more is a listing, not a hint. */
const SUGGESTION_LIMIT = 6;
/**
* Mixed surface: searching is public, rebuilding the index is not.
*
* `@Public()` therefore sits on the two read endpoints rather than on the
* controller. Authentication is on by default, so the reindex route is
* protected by omission — which is the failure mode worth having.
*/
@ApiTags('search')
@Controller('search')
export class SearchController {
constructor(
private readonly service: SearchService,
private readonly indexer: SearchIndexerService,
) {}
@Get()
@Public()
@ApiOperation({ summary: 'Ranked product search, refinable like any listing' })
search(
@Query(new ZodValidationPipe(productFilterSchema)) filter: ProductFilter,
@RequestLocale() locale: Locale,
): Promise<ProductListResult> {
return this.service.searchProducts(filter.q ?? '', filter, locale);
}
@Get('suggestions')
@Public()
@ApiOperation({ summary: 'Type-ahead product names' })
suggest(@Query('q') query: string, @RequestLocale() locale: Locale): Promise<SearchSuggestions> {
return this.service.suggest(query ?? '', locale, SUGGESTION_LIMIT);
}
/**
* Rebuilds every document.
*
* The index is a pure projection, so this is always safe to run and is the
* answer to "search is missing something" — no reasoning about which events
* were lost, just rebuild.
*/
@Post('reindex')
@ApiBearerAuth()
@RequireAudience(TOKEN_AUDIENCES.ADMIN)
@RequirePermissions(PERMISSIONS.PRODUCT_UPDATE)
@ApiOperation({ summary: 'Rebuild the entire search index' })
async reindex(): Promise<{ indexed: number }> {
return { indexed: await this.indexer.reindexAll() };
}
}
+28 -16
View File
@@ -1,23 +1,35 @@
import { Module } from '@nestjs/common';
import { ProductsModule } from '@/modules/products/products.module';
import { PostgresSearchProvider } from './postgres-search.provider';
import { SearchIndexSubscriber } from './search-index.subscriber';
import { SearchIndexerService } from './search-indexer.service';
import { SearchController } from './search.controller';
import { SEARCH_PROVIDER } from './search.provider';
import { SearchService } from './search.service';
/**
* SearchModule — boundary declared, implementation pending.
* SearchModule — owns `search_documents`, a pure projection of the catalog.
*
* Owns (exclusively): Nothing. Read-only projection over the catalog.
* The projection can be rebuilt from scratch at any moment, which is what makes
* losing it survivable and makes this module a genuine EXTRACTION CANDIDATE:
* nothing else reads its table, and it returns ids rather than product payloads
* so it never learns what a product looks like.
*
* Starts as PostgreSQL full-text + trigram, which is genuinely enough below ~50k products. Behind a SearchProvider interface so swapping in OpenSearch is a provider change, not a rewrite of every listing page.
*
* EXTRACTION CANDIDATE: designed so it could become its own service. It must
* therefore never read another module’s tables directly, and it communicates
* outward through domain events.
*
* Anatomy once implemented (see ../README.md):
* search.module.ts wiring only
* search.controller.ts HTTP surface, no logic
* search.service.ts business rules
* search.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
* `SEARCH_PROVIDER` is the seam from ADR-0012 — swapping PostgreSQL for
* OpenSearch replaces one binding here.
*/
@Module({})
@Module({
imports: [ProductsModule],
controllers: [SearchController],
providers: [
SearchService,
SearchIndexerService,
SearchIndexSubscriber,
PostgresSearchProvider,
{ provide: SEARCH_PROVIDER, useExisting: PostgresSearchProvider },
],
exports: [SearchService, SearchIndexerService],
})
export class SearchModule {}
@@ -0,0 +1,41 @@
import type { Locale } from '@sport/types';
export interface SearchHit {
readonly productId: string;
/** Higher is better. Comparable within one result set, not across queries. */
readonly score: number;
}
export interface SearchSuggestion {
readonly text: string;
readonly productSlug: string;
}
/**
* The seam.
*
* Everything above this interface — the search endpoint, the listing's
* relevance sort, the suggestion box — talks only to these three methods.
* Swapping PostgreSQL for OpenSearch later is a new implementation of this
* file's contract, not a rewrite of every caller (ADR-0012).
*
* Note what it returns: product *ids* and a score, never product payloads.
* Rendering a product is the catalog's job, and keeping it that way is what
* lets SearchModule be extracted without dragging catalog tables along.
*/
export interface SearchProvider {
search(query: string, locale: Locale, limit: number): Promise<SearchHit[]>;
suggest(query: string, locale: Locale, limit: number): Promise<SearchSuggestion[]>;
index(documents: readonly SearchIndexDocument[]): Promise<void>;
remove(productId: string): Promise<void>;
}
export interface SearchIndexDocument {
readonly productId: string;
readonly locale: Locale;
readonly title: string;
readonly keywords: string;
readonly body: string;
}
export const SEARCH_PROVIDER = Symbol('SEARCH_PROVIDER');
@@ -0,0 +1,58 @@
import { Inject, Injectable } from '@nestjs/common';
import type { Locale, ProductListResult, SearchSuggestions } from '@sport/types';
import type { ProductFilter } from '@sport/validation';
import { ProductsService } from '@/modules/products/public';
import { SEARCH_PROVIDER, type SearchProvider } from './search.provider';
/** Ranked ids fetched per query. Beyond this, relevance is noise anyway. */
const MAX_HITS = 200;
@Injectable()
export class SearchService {
constructor(
@Inject(SEARCH_PROVIDER) private readonly provider: SearchProvider,
private readonly products: ProductsService,
) {}
/**
* Ranked search, rendered by the catalog.
*
* The provider returns ids and scores; the catalog turns them into cards.
* That split is what keeps this module extractable — it never learns what a
* product looks like.
*
* Filters and facets still come from the catalog, so a search result is
* refinable exactly like any other listing.
*/
async searchProducts(
query: string,
filter: ProductFilter,
locale: Locale,
): Promise<ProductListResult> {
const hits = await this.provider.search(query, locale, MAX_HITS);
if (hits.length === 0) {
return {
items: [],
pageInfo: { nextCursor: null, hasNextPage: false },
totalCount: 0,
facets: { brands: [], colors: [], sizes: [], priceRange: null },
};
}
return this.products.listByIds(
hits.map((hit) => hit.productId),
filter,
locale,
);
}
async suggest(query: string, locale: Locale, limit: number): Promise<SearchSuggestions> {
const suggestions = await this.provider.suggest(query, locale, limit);
return { query, suggestions };
}
}