Stage M5 and Stage M6
This commit is contained in:
@@ -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);
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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() };
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user