This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:22 +07:00
parent 3e5d38ec18
commit 3d6b0e0d4e
145 changed files with 7817 additions and 801 deletions
@@ -0,0 +1,32 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import type { Brand, Locale } from '@sport/types';
import { Public } from '@/common/decorators/public.decorator';
import { RequestLocale } from '@/common/i18n';
import { BrandsService } from './brands.service';
@ApiTags('catalog')
@Controller('brands')
export class BrandsController {
constructor(private readonly brandsService: BrandsService) {}
@Public()
@Get()
@ApiOperation({ summary: 'List active brands' })
@ApiQuery({ name: 'locale', required: false, enum: ['vi', 'en'] })
@ApiOkResponse({ description: 'Brands, localised.' })
list(@RequestLocale() locale: Locale): Promise<Brand[]> {
return this.brandsService.list(locale);
}
@Public()
@Get(':slug')
@ApiOperation({ summary: 'Get one brand by its (translated or canonical) slug' })
@ApiQuery({ name: 'locale', required: false, enum: ['vi', 'en'] })
getBySlug(@Param('slug') slug: string, @RequestLocale() locale: Locale): Promise<Brand> {
return this.brandsService.getBySlug(slug, locale);
}
}
@@ -0,0 +1,37 @@
import { Injectable } from '@nestjs/common';
import type { Brand, Locale } from '@sport/types';
import { coalesce, coalesceRequired, pickTranslation } from '@/common/i18n';
import { MediaUrlService } from '@/common/media/media-url.service';
import type { BrandRow } from './brands.repository';
/**
* Prisma row → API type.
*
* Mappers exist so Prisma's generated types never leak past the module
* boundary. The API contract is `@sport/types`, and a schema change that does
* not change the contract should not ripple outward.
*/
@Injectable()
export class BrandsMapper {
constructor(private readonly mediaUrl: MediaUrlService) {}
toBrand(row: BrandRow, locale: Locale): Brand {
const translation = pickTranslation(row.translations, locale);
return {
id: row.id,
name: coalesceRequired(translation?.name, row.name),
slug: coalesceRequired(translation?.slug, row.slug),
description: coalesce(translation?.description, row.description),
logo: this.mediaUrl.toImageRef(row.logo),
isActive: row.isActive,
seo: {
metaTitle: coalesce(translation?.metaTitle, row.metaTitle),
metaDescription: coalesce(translation?.metaDescription, row.metaDescription),
},
};
}
}
+13 -13
View File
@@ -1,19 +1,19 @@
import { Module } from '@nestjs/common';
import { BrandsController } from './brands.controller';
import { BrandsMapper } from './brands.mapper';
import { BrandsRepository } from './brands.repository';
import { BrandsService } from './brands.service';
/**
* BrandsModule — boundary declared, implementation pending.
* BrandsModule — owns `brands` and `brand_translations`.
*
* Owns (exclusively): `brands`
*
* Deliberately thin. Kept separate anyway because brand pages, filters and (later) brand-level commercial terms all hang off it.
*
* Anatomy once implemented (see ../README.md):
* brands.module.ts wiring only
* brands.controller.ts HTTP surface, no logic
* brands.service.ts business rules
* brands.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
* Deliberately thin. Kept as its own module anyway because brand pages, the
* brand filter facet and (later) brand-level commercial terms all hang off it.
*/
@Module({})
@Module({
controllers: [BrandsController],
providers: [BrandsService, BrandsRepository, BrandsMapper],
exports: [BrandsService],
})
export class BrandsModule {}
@@ -0,0 +1,64 @@
import { Injectable } from '@nestjs/common';
import type { Locale } from '@sport/types';
import { toDbLocale } from '@/common/i18n';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
/** Shared shape so the mapper has one input type regardless of the query used. */
const brandSelect = {
id: true,
name: true,
slug: true,
description: true,
isActive: true,
metaTitle: true,
metaDescription: true,
logo: {
select: {
id: true,
storageKey: true,
altText: true,
width: true,
height: true,
blurDataUrl: true,
},
},
translations: true,
} as const;
/**
* The only file in this module that touches Prisma. Services depend on it, so
* they stay unit-testable without a database.
*/
@Injectable()
export class BrandsRepository {
constructor(private readonly prisma: PrismaService) {}
findActive(locale: Locale) {
return this.prisma.brand.findMany({
where: { isActive: true },
select: { ...brandSelect, translations: { where: { locale: toDbLocale(locale) } } },
orderBy: { name: 'asc' },
});
}
/**
* Resolves either a translated slug or the canonical one.
*
* Both are accepted so that a link created before a translation existed keeps
* working — old URLs breaking is an SEO and support cost that a single extra
* OR clause avoids.
*/
findBySlug(slug: string, locale: Locale) {
return this.prisma.brand.findFirst({
where: {
isActive: true,
OR: [{ translations: { some: { locale: toDbLocale(locale), slug } } }, { slug }],
},
select: { ...brandSelect, translations: { where: { locale: toDbLocale(locale) } } },
});
}
}
export type BrandRow = NonNullable<Awaited<ReturnType<BrandsRepository['findBySlug']>>>;
@@ -0,0 +1,49 @@
import { Injectable } from '@nestjs/common';
import { API_ERROR_CODES, type Brand, type Locale } from '@sport/types';
import { AppException } from '@/common/errors/app.exception';
import { CACHE_KEYS, CACHE_TTL } from '@/infrastructure/redis/cache-keys';
import { RedisService } from '@/infrastructure/redis/redis.service';
import { BrandsMapper } from './brands.mapper';
import { BrandsRepository } from './brands.repository';
@Injectable()
export class BrandsService {
constructor(
private readonly repository: BrandsRepository,
private readonly mapper: BrandsMapper,
private readonly redis: RedisService,
) {}
/**
* Brands change perhaps monthly and are read on every filter rail, which
* makes this the cheapest cache in the system to justify.
*/
async list(locale: Locale): Promise<Brand[]> {
return this.redis.getOrSet(CACHE_KEYS.brandList(locale), CACHE_TTL.brandList, async () => {
const rows = await this.repository.findActive(locale);
return rows.map((row) => this.mapper.toBrand(row, locale));
});
}
async getBySlug(slug: string, locale: Locale): Promise<Brand> {
const cached = await this.redis.getOrSet(
CACHE_KEYS.brandBySlug(locale, slug),
CACHE_TTL.brandList,
async () => {
const row = await this.repository.findBySlug(slug, locale);
// `null` is cached too: a bot hammering nonexistent slugs must not
// become a stream of database queries.
return row ? this.mapper.toBrand(row, locale) : null;
},
);
if (!cached) {
throw AppException.notFound('Brand', API_ERROR_CODES.NOT_FOUND);
}
return cached;
}
}
+3 -6
View File
@@ -1,10 +1,7 @@
/**
* Public surface of BrandsModule.
*
* This barrel is the ONLY thing other modules may import from here. Everything
* else — repository, DTOs, internal services — is private, and the ESLint
* boundary rule in @sport/eslint-config/nest enforces it.
*
* Keep it narrow: each export is a promise to the rest of the codebase.
* Other modules import from here and nowhere else — the repository and mapper
* are private, and the ESLint boundary rule enforces it.
*/
export {};
export { BrandsService } from '../brands.service';
@@ -0,0 +1,43 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import type { Category, CategoryNode, Locale, NavigationMenu } from '@sport/types';
import { Public } from '@/common/decorators/public.decorator';
import { RequestLocale } from '@/common/i18n';
import { CategoriesService } from './categories.service';
@ApiTags('catalog')
@Controller()
export class CategoriesController {
constructor(private readonly categoriesService: CategoriesService) {}
@Public()
@Get('categories')
@ApiOperation({ summary: 'The full active category tree' })
@ApiQuery({ name: 'locale', required: false, enum: ['vi', 'en'] })
getTree(@RequestLocale() locale: Locale): Promise<CategoryNode[]> {
return this.categoriesService.getTree(locale);
}
@Public()
@Get('categories/:slug')
@ApiOperation({ summary: 'Get one category by slug' })
@ApiQuery({ name: 'locale', required: false, enum: ['vi', 'en'] })
getBySlug(@Param('slug') slug: string, @RequestLocale() locale: Locale): Promise<Category> {
return this.categoriesService.getBySlug(slug, locale);
}
/**
* Sits outside `/categories` because it is a composed view rather than a
* category resource — it also carries collections.
*/
@Public()
@Get('navigation')
@ApiOperation({ summary: 'Header and footer navigation, fully localised' })
@ApiQuery({ name: 'locale', required: false, enum: ['vi', 'en'] })
getNavigation(@RequestLocale() locale: Locale): Promise<NavigationMenu> {
return this.categoriesService.getNavigation(locale);
}
}
@@ -0,0 +1,63 @@
import { Injectable } from '@nestjs/common';
import type { Category, CategoryNode, Locale } from '@sport/types';
import { coalesce, coalesceRequired, pickTranslation } from '@/common/i18n';
import { MediaUrlService } from '@/common/media/media-url.service';
import type { CategoryRow } from './categories.repository';
@Injectable()
export class CategoriesMapper {
constructor(private readonly mediaUrl: MediaUrlService) {}
toCategory(row: CategoryRow, locale: Locale): Category {
const translation = pickTranslation(row.translations, locale);
return {
id: row.id,
parentId: row.parentId,
name: coalesceRequired(translation?.name, row.name),
slug: coalesceRequired(translation?.slug, row.slug),
description: coalesce(translation?.description, row.description),
image: this.mediaUrl.toImageRef(row.image),
path: row.path,
depth: row.depth,
position: row.position,
isActive: row.isActive,
seo: {
metaTitle: coalesce(translation?.metaTitle, row.metaTitle),
metaDescription: coalesce(translation?.metaDescription, row.metaDescription),
},
};
}
/**
* Assembles a flat list into a tree in one pass.
*
* Rows arrive ordered by depth, so a parent is always placed before its
* children and no second pass or recursion is needed.
*/
toTree(rows: readonly CategoryRow[], locale: Locale): CategoryNode[] {
const nodes = new Map<string, CategoryNode & { children: CategoryNode[] }>();
const roots: CategoryNode[] = [];
for (const row of rows) {
nodes.set(row.id, { ...this.toCategory(row, locale), children: [] });
}
for (const row of rows) {
const node = nodes.get(row.id);
if (!node) continue;
const parent = row.parentId ? nodes.get(row.parentId) : undefined;
if (parent) {
parent.children.push(node);
} else {
roots.push(node);
}
}
return roots;
}
}
@@ -1,19 +1,25 @@
import { Module } from '@nestjs/common';
import { CollectionsModule } from '@/modules/collections/collections.module';
import { CategoriesController } from './categories.controller';
import { CategoriesMapper } from './categories.mapper';
import { CategoriesRepository } from './categories.repository';
import { CategoriesService } from './categories.service';
/**
* CategoriesModule — boundary declared, implementation pending.
* CategoriesModule — owns `categories` and `category_translations`.
*
* Owns (exclusively): `categories`
* The hierarchical merchandising tree plus the navigation menu it feeds.
* Heavy read, near-zero write, so everything here is Redis-cached.
*
* The hierarchical merchandising tree and the navigation menu it feeds. Heavy read, near-zero write — the first thing that should be Redis-cached.
*
* Anatomy once implemented (see ../README.md):
* categories.module.ts wiring only
* categories.controller.ts HTTP surface, no logic
* categories.service.ts business rules
* categories.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
* Imports CollectionsModule explicitly: the dependency is visible in the module
* graph rather than hidden behind an ambient global.
*/
@Module({})
@Module({
imports: [CollectionsModule],
controllers: [CategoriesController],
providers: [CategoriesService, CategoriesRepository, CategoriesMapper],
exports: [CategoriesService],
})
export class CategoriesModule {}
@@ -0,0 +1,87 @@
import { Injectable } from '@nestjs/common';
import type { Locale } from '@sport/types';
import { toDbLocale } from '@/common/i18n';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
const categorySelect = {
id: true,
parentId: true,
name: true,
slug: true,
path: true,
depth: true,
position: true,
description: true,
isActive: true,
metaTitle: true,
metaDescription: true,
image: {
select: {
id: true,
storageKey: true,
altText: true,
width: true,
height: true,
blurDataUrl: true,
},
},
translations: true,
} as const;
@Injectable()
export class CategoriesRepository {
constructor(private readonly prisma: PrismaService) {}
/**
* Fetches the whole active tree in one query and lets the service assemble it
* in memory.
*
* A category tree is tens of rows, not thousands — one indexed query plus an
* O(n) build beats a recursive CTE or N queries per level, and the whole
* result is cached anyway.
*/
findAllActive(locale: Locale) {
return this.prisma.category.findMany({
where: { isActive: true },
select: { ...categorySelect, translations: { where: { locale: toDbLocale(locale) } } },
orderBy: [{ depth: 'asc' }, { position: 'asc' }, { name: 'asc' }],
});
}
findBySlug(slug: string, locale: Locale) {
return this.prisma.category.findFirst({
where: {
isActive: true,
OR: [{ translations: { some: { locale: toDbLocale(locale), slug } } }, { slug }],
},
select: { ...categorySelect, translations: { where: { locale: toDbLocale(locale) } } },
});
}
/**
* Ancestors of a materialised path, for breadcrumbs.
*
* `men/running/shoes` → the paths `men` and `men/running`. Turning the path
* into an exact `IN` list keeps this a single index lookup instead of a
* `LIKE` scan or a recursive walk.
*/
findByPaths(paths: readonly string[], locale: Locale) {
if (paths.length === 0) return Promise.resolve([]);
return this.prisma.category.findMany({
where: { isActive: true, path: { in: [...paths] } },
select: { ...categorySelect, translations: { where: { locale: toDbLocale(locale) } } },
orderBy: { depth: 'asc' },
});
}
}
export type CategoryRow = NonNullable<Awaited<ReturnType<CategoriesRepository['findBySlug']>>>;
/** `men/running/shoes` → `['men', 'men/running']` (excludes the node itself). */
export function ancestorPaths(path: string): string[] {
const segments = path.split('/').filter(Boolean);
return segments.slice(0, -1).map((_, index) => segments.slice(0, index + 1).join('/'));
}
@@ -0,0 +1,168 @@
import { Injectable } from '@nestjs/common';
import {
API_ERROR_CODES,
type Breadcrumb,
type Category,
type CategoryNode,
type Locale,
type NavigationMenu,
} from '@sport/types';
import { AppException } from '@/common/errors/app.exception';
import { CACHE_KEYS, CACHE_TTL } from '@/infrastructure/redis/cache-keys';
import { RedisService } from '@/infrastructure/redis/redis.service';
import { CollectionsService } from '@/modules/collections/public';
import { CategoriesMapper } from './categories.mapper';
import { ancestorPaths, CategoriesRepository } from './categories.repository';
/**
* Top-level categories that have a dedicated storefront route. Everything
* deeper is reached as a filter on those routes.
*
* This mapping is the seam between the merchandising tree (data) and the URL
* structure (code). It lives in one place so a route change is one edit.
*/
const ROOT_ROUTES: Readonly<Record<string, string>> = {
men: '/men',
women: '/women',
};
@Injectable()
export class CategoriesService {
constructor(
private readonly repository: CategoriesRepository,
private readonly mapper: CategoriesMapper,
private readonly redis: RedisService,
// Cross-module access through the public surface, never the repository.
private readonly collectionsService: CollectionsService,
) {}
async getTree(locale: Locale): Promise<CategoryNode[]> {
return this.redis.getOrSet(
CACHE_KEYS.categoryTree(locale),
CACHE_TTL.categoryTree,
async () => {
const rows = await this.repository.findAllActive(locale);
return this.mapper.toTree(rows, locale);
},
);
}
async getBySlug(slug: string, locale: Locale): Promise<Category> {
const cached = await this.redis.getOrSet(
CACHE_KEYS.categoryBySlug(locale, slug),
CACHE_TTL.categoryTree,
async () => {
const row = await this.repository.findBySlug(slug, locale);
return row ? this.mapper.toCategory(row, locale) : null;
},
);
if (!cached) {
throw AppException.notFound('Category', API_ERROR_CODES.NOT_FOUND);
}
return cached;
}
/**
* Resolves a slug to its materialised path, or null.
*
* Non-throwing on purpose: `ProductsModule` uses this to translate a
* `?category=` filter into a subtree match, and an unknown slug there should
* narrow to nothing, not 404 the whole listing.
*/
async resolvePath(slug: string, locale: Locale): Promise<string | null> {
const row = await this.repository.findBySlug(slug, locale);
return row?.path ?? null;
}
/**
* Breadcrumbs for a category, resolved from its materialised path.
* Returns ancestors first, then the category itself.
*/
async getBreadcrumbs(path: string, locale: Locale): Promise<Breadcrumb[]> {
const rows = await this.repository.findByPaths([...ancestorPaths(path), path], locale);
return rows.map((row) => {
const category = this.mapper.toCategory(row, locale);
return {
id: category.id,
label: category.name,
slug: category.slug,
href: this.hrefFor(category),
};
});
}
/**
* The header menu: root categories with their immediate children, plus live
* collections.
*
* Assembled server-side and cached as one payload because navigation renders
* on every single page — it is the highest-traffic read in the system, and
* three round-trips per page view would be three too many.
*
* Sport entries are deliberately absent: they come from a fixed enum, so the
* storefront renders them from its own message catalog. Database content is
* translated in the database; code-level enums are translated in the UI.
*/
async getNavigation(locale: Locale): Promise<NavigationMenu> {
return this.redis.getOrSet(
CACHE_KEYS.navigationMenu(locale),
CACHE_TTL.navigation,
async () => {
const [tree, collections] = await Promise.all([
this.getTree(locale),
this.collectionsService.list(locale),
]);
const primary = tree
.filter((node) => ROOT_ROUTES[node.path] !== undefined)
.map((node) => ({
id: node.id,
label: node.name,
slug: node.slug,
href: this.hrefFor(node),
children: node.children.map((child) => ({
id: child.id,
label: child.name,
slug: child.slug,
href: this.hrefFor(child),
children: [],
})),
}));
return {
primary,
featuredCollections: collections.slice(0, 4).map((collection) => ({
id: collection.id,
label: collection.name,
slug: collection.slug,
href: `/collections/${collection.slug}`,
})),
};
},
);
}
/**
* A root category maps to its own route; a child is a filter on the nearest
* ancestor route. Keeping category browsing on the listing routes preserves
* the URL structure the storefront was designed around.
*/
private hrefFor(category: Pick<Category, 'path' | 'slug'>): string {
const rootSegment = category.path.split('/')[0] ?? '';
const rootRoute = ROOT_ROUTES[rootSegment];
if (!rootRoute) {
return `/search?category=${encodeURIComponent(category.slug)}`;
}
return category.path === rootSegment
? rootRoute
: `${rootRoute}?category=${encodeURIComponent(category.slug)}`;
}
}
@@ -1,10 +1,7 @@
/**
* Public surface of CategoriesModule.
*
* This barrel is the ONLY thing other modules may import from here. Everything
* else — repository, DTOs, internal services — is private, and the ESLint
* boundary rule in @sport/eslint-config/nest enforces it.
*
* Keep it narrow: each export is a promise to the rest of the codebase.
* `ProductsModule` uses this for breadcrumbs and to resolve a category slug to
* its subtree path when filtering.
*/
export {};
export { CategoriesService } from '../categories.service';
@@ -0,0 +1,31 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import type { Collection, Locale } from '@sport/types';
import { Public } from '@/common/decorators/public.decorator';
import { RequestLocale } from '@/common/i18n';
import { CollectionsService } from './collections.service';
@ApiTags('catalog')
@Controller('collections')
export class CollectionsController {
constructor(private readonly collectionsService: CollectionsService) {}
@Public()
@Get()
@ApiOperation({ summary: 'List live collections' })
@ApiQuery({ name: 'locale', required: false, enum: ['vi', 'en'] })
list(@RequestLocale() locale: Locale): Promise<Collection[]> {
return this.collectionsService.list(locale);
}
@Public()
@Get(':slug')
@ApiOperation({ summary: 'Get one collection by slug' })
@ApiQuery({ name: 'locale', required: false, enum: ['vi', 'en'] })
getBySlug(@Param('slug') slug: string, @RequestLocale() locale: Locale): Promise<Collection> {
return this.collectionsService.getBySlug(slug, locale);
}
}
@@ -0,0 +1,33 @@
import { Injectable } from '@nestjs/common';
import type { Collection, CollectionType, Locale } from '@sport/types';
import { coalesce, coalesceRequired, pickTranslation } from '@/common/i18n';
import { MediaUrlService } from '@/common/media/media-url.service';
import type { CollectionRow } from './collections.repository';
@Injectable()
export class CollectionsMapper {
constructor(private readonly mediaUrl: MediaUrlService) {}
toCollection(row: CollectionRow, locale: Locale): Collection {
const translation = pickTranslation(row.translations, locale);
return {
id: row.id,
name: coalesceRequired(translation?.name, row.name),
slug: coalesceRequired(translation?.slug, row.slug),
type: row.type as CollectionType,
description: coalesce(translation?.description, row.description),
banner: this.mediaUrl.toImageRef(row.banner),
startsAt: row.startsAt?.toISOString() ?? null,
endsAt: row.endsAt?.toISOString() ?? null,
isActive: row.isActive,
seo: {
metaTitle: coalesce(translation?.metaTitle, row.metaTitle),
metaDescription: coalesce(translation?.metaDescription, row.metaDescription),
},
};
}
}
@@ -1,19 +1,19 @@
import { Module } from '@nestjs/common';
import { CollectionsController } from './collections.controller';
import { CollectionsMapper } from './collections.mapper';
import { CollectionsRepository } from './collections.repository';
import { CollectionsService } from './collections.service';
/**
* CollectionsModule — boundary declared, implementation pending.
* CollectionsModule — owns `collections`, `collection_translations` and
* `product_collections`.
*
* Owns (exclusively): `collections`, `product_collections`
*
* Editorial and campaign groupings, including rule evaluation for AUTOMATED collections.
*
* Anatomy once implemented (see ../README.md):
* collections.module.ts wiring only
* collections.controller.ts HTTP surface, no logic
* collections.service.ts business rules
* collections.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
* Editorial and campaign groupings, including their scheduling window.
*/
@Module({})
@Module({
controllers: [CollectionsController],
providers: [CollectionsService, CollectionsRepository, CollectionsMapper],
exports: [CollectionsService],
})
export class CollectionsModule {}
@@ -0,0 +1,71 @@
import { Injectable } from '@nestjs/common';
import type { Locale } from '@sport/types';
import { toDbLocale } from '@/common/i18n';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
const collectionSelect = {
id: true,
name: true,
slug: true,
type: true,
description: true,
startsAt: true,
endsAt: true,
isActive: true,
position: true,
metaTitle: true,
metaDescription: true,
banner: {
select: {
id: true,
storageKey: true,
altText: true,
width: true,
height: true,
blurDataUrl: true,
},
},
translations: true,
} as const;
/**
* A collection is live when it is active AND inside its scheduling window.
* Expressing that once, here, keeps a campaign from leaking early because one
* query forgot the date check.
*/
function liveWindow(now: Date) {
return {
isActive: true,
AND: [
{ OR: [{ startsAt: null }, { startsAt: { lte: now } }] },
{ OR: [{ endsAt: null }, { endsAt: { gte: now } }] },
],
};
}
@Injectable()
export class CollectionsRepository {
constructor(private readonly prisma: PrismaService) {}
findLive(locale: Locale, now = new Date()) {
return this.prisma.collection.findMany({
where: liveWindow(now),
select: { ...collectionSelect, translations: { where: { locale: toDbLocale(locale) } } },
orderBy: [{ position: 'asc' }, { name: 'asc' }],
});
}
findBySlug(slug: string, locale: Locale, now = new Date()) {
return this.prisma.collection.findFirst({
where: {
...liveWindow(now),
OR: [{ translations: { some: { locale: toDbLocale(locale), slug } } }, { slug }],
},
select: { ...collectionSelect, translations: { where: { locale: toDbLocale(locale) } } },
});
}
}
export type CollectionRow = NonNullable<Awaited<ReturnType<CollectionsRepository['findBySlug']>>>;
@@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import { API_ERROR_CODES, type Collection, type Locale } from '@sport/types';
import { AppException } from '@/common/errors/app.exception';
import { CACHE_KEYS, CACHE_TTL } from '@/infrastructure/redis/cache-keys';
import { RedisService } from '@/infrastructure/redis/redis.service';
import { CollectionsMapper } from './collections.mapper';
import { CollectionsRepository } from './collections.repository';
@Injectable()
export class CollectionsService {
constructor(
private readonly repository: CollectionsRepository,
private readonly mapper: CollectionsMapper,
private readonly redis: RedisService,
) {}
/**
* TTL is short (5 min) rather than long, because collections are
* time-boxed: a campaign that starts at 09:00 should not be delayed by a
* 15-minute cache. Scheduling correctness beats a marginal hit-rate gain.
*/
async list(locale: Locale): Promise<Collection[]> {
return this.redis.getOrSet(
CACHE_KEYS.collectionList(locale),
CACHE_TTL.collectionList,
async () => {
const rows = await this.repository.findLive(locale);
return rows.map((row) => this.mapper.toCollection(row, locale));
},
);
}
async getBySlug(slug: string, locale: Locale): Promise<Collection> {
const cached = await this.redis.getOrSet(
CACHE_KEYS.collectionBySlug(locale, slug),
CACHE_TTL.collectionList,
async () => {
const row = await this.repository.findBySlug(slug, locale);
return row ? this.mapper.toCollection(row, locale) : null;
},
);
if (!cached) {
throw AppException.notFound('Collection', API_ERROR_CODES.NOT_FOUND);
}
return cached;
}
}
@@ -1,10 +1,7 @@
/**
* Public surface of CollectionsModule.
*
* This barrel is the ONLY thing other modules may import from here. Everything
* else — repository, DTOs, internal services — is private, and the ESLint
* boundary rule in @sport/eslint-config/nest enforces it.
*
* Keep it narrow: each export is a promise to the rest of the codebase.
* `CategoriesModule` consumes this to build the navigation menu — the only
* sanctioned way for it to read collection data.
*/
export {};
export { CollectionsService } from '../collections.service';
@@ -0,0 +1,79 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import type { Locale, ProductListResult, StorefrontProduct } from '@sport/types';
import { productFilterSchema, type ProductFilter } from '@sport/validation';
import { Public } from '@/common/decorators/public.decorator';
import { RequestLocale } from '@/common/i18n';
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
import { ProductsService } from './products.service';
@ApiTags('catalog')
@Controller('products')
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
/**
* The listing endpoint behind every browse surface. One contract, many
* presets — /men is this with `gender=MEN`, /sports/running is this with
* `sport=RUNNING`, /search is this with `q`.
*/
@Public()
@Get()
@ApiOperation({ summary: 'List products with filters, facets and cursor pagination' })
@ApiQuery({ name: 'locale', required: false, enum: ['vi', 'en'] })
@ApiQuery({ name: 'q', required: false })
@ApiQuery({ name: 'categorySlug', required: false })
@ApiQuery({ name: 'collectionSlug', required: false })
@ApiQuery({ name: 'brandSlugs', required: false, description: 'Comma-separated' })
@ApiQuery({
name: 'gender',
required: false,
description: 'Comma-separated: MEN,WOMEN,KIDS,UNISEX',
})
@ApiQuery({ name: 'sport', required: false, description: 'Comma-separated sport types' })
@ApiQuery({ name: 'colors', required: false, description: 'Comma-separated option values' })
@ApiQuery({ name: 'sizes', required: false, description: 'Comma-separated option values' })
@ApiQuery({ name: 'minPrice', required: false, type: Number })
@ApiQuery({ name: 'maxPrice', required: false, type: Number })
@ApiQuery({ name: 'onSale', required: false, type: Boolean })
@ApiQuery({ name: 'inStockOnly', required: false, type: Boolean })
@ApiQuery({
name: 'sort',
required: false,
enum: ['newest', 'price_asc', 'price_desc', 'best_selling', 'relevance'],
})
@ApiQuery({ name: 'cursor', required: false })
@ApiQuery({ name: 'limit', required: false, type: Number })
list(
@Query(new ZodValidationPipe(productFilterSchema)) filter: ProductFilter,
@RequestLocale() locale: Locale,
): Promise<ProductListResult> {
return this.productsService.list(filter, locale);
}
@Public()
@Get('slugs')
@ApiOperation({ summary: 'All product slugs for the locale (sitemap / static params)' })
@ApiQuery({ name: 'locale', required: false, enum: ['vi', 'en'] })
listSlugs(@RequestLocale() locale: Locale): Promise<{ slug: string; updatedAt: string }[]> {
return this.productsService.listSlugs(locale);
}
/**
* Declared after `slugs` so the literal route is matched first — otherwise
* `/products/slugs` would resolve as a product with the slug "slugs".
*/
@Public()
@Get(':slug')
@ApiOperation({ summary: 'Product detail with options, variants and stock' })
@ApiQuery({ name: 'locale', required: false, enum: ['vi', 'en'] })
getBySlug(
@Param('slug') slug: string,
@RequestLocale() locale: Locale,
): Promise<StorefrontProduct> {
return this.productsService.getBySlug(slug, locale);
}
}
@@ -0,0 +1,302 @@
import { Injectable } from '@nestjs/common';
import {
VARIANT_AVAILABILITY,
type Breadcrumb,
type ColorSwatch,
type CurrencyCode,
type GenderTarget,
type Locale,
type Money,
type PriceRange,
type ProductAttribute,
type ProductImage,
type ProductListItem,
type ProductOption,
type SportType,
type StorefrontProduct,
type StorefrontVariant,
type VariantAvailability,
} from '@sport/types';
import { coalesce, coalesceRequired, pickTranslation } from '@/common/i18n';
import { MediaUrlService } from '@/common/media/media-url.service';
import type { ProductDetailRow, ProductListRow } from './products.repository';
/** Below this, the PDP shows "only N left" instead of a plain in-stock badge. */
const LOW_STOCK_THRESHOLD = 5;
@Injectable()
export class ProductsMapper {
constructor(private readonly mediaUrl: MediaUrlService) {}
// ---- Listing -------------------------------------------------------------
toListItem(row: ProductListRow, locale: Locale): ProductListItem {
const translation = pickTranslation(row.translations, locale);
const brandTranslation = pickTranslation(row.brand?.translations, locale);
const images = row.images.map((image) => this.toProductImage(image)).filter(isPresent);
return {
id: row.id,
name: coalesceRequired(translation?.name, row.name),
slug: coalesceRequired(translation?.slug, row.slug),
brandName: row.brand ? coalesce(brandTranslation?.name, row.brand.name) : null,
primaryImage: images[0] ?? null,
// The second image is the hover state — a standard fashion-grid pattern
// that shows the garment from another angle without a click.
hoverImage: images[1] ?? null,
priceRange: this.priceRangeOf(row.variants),
isOnSale: row.isOnSale,
colorSwatches: this.colorSwatchesOf(row.options, locale),
// Reviews land in M7; the field exists so the card layout is final now.
rating: null,
};
}
// ---- Detail --------------------------------------------------------------
toStorefrontProduct(
row: ProductDetailRow,
locale: Locale,
breadcrumbs: readonly Breadcrumb[],
): StorefrontProduct {
const translation = pickTranslation(row.translations, locale);
const options = row.options.map((option) => this.toOption(option, locale));
const variants = row.variants.map((variant) => this.toStorefrontVariant(variant, locale));
return {
id: row.id,
name: coalesceRequired(translation?.name, row.name),
slug: coalesceRequired(translation?.slug, row.slug),
description: coalesce(translation?.description, row.description),
shortDescription: coalesce(translation?.shortDescription, row.shortDescription),
publishedAt: row.publishedAt?.toISOString() ?? null,
brandId: row.brand?.id ?? null,
primaryCategoryId: row.primaryCategory?.id ?? null,
genderTargets: row.genderTargets as GenderTarget[],
sportTypes: row.sportTypes as SportType[],
options,
images: row.images.map((image) => this.toProductImage(image)).filter(isPresent),
attributes: row.attributes.map((attribute): ProductAttribute => ({
id: attribute.id,
key: attribute.key,
label: coalesceRequired(
pickTranslation(attribute.translations, locale)?.label,
attribute.label,
),
value: coalesceRequired(
pickTranslation(attribute.translations, locale)?.value,
attribute.value,
),
group: attribute.group,
position: attribute.position,
isFilterable: attribute.isFilterable,
})),
brand: row.brand
? {
id: row.brand.id,
name: coalesceRequired(
pickTranslation(row.brand.translations, locale)?.name,
row.brand.name,
),
slug: coalesceRequired(
pickTranslation(row.brand.translations, locale)?.slug,
row.brand.slug,
),
description: coalesce(
pickTranslation(row.brand.translations, locale)?.description,
row.brand.description,
),
logo: this.mediaUrl.toImageRef(row.brand.logo),
isActive: row.brand.isActive,
seo: {
metaTitle: coalesce(
pickTranslation(row.brand.translations, locale)?.metaTitle,
row.brand.metaTitle,
),
metaDescription: coalesce(
pickTranslation(row.brand.translations, locale)?.metaDescription,
row.brand.metaDescription,
),
},
}
: null,
// The full Category object is not needed on a PDP — breadcrumbs carry
// everything the page renders, and fetching it would be a wasted join.
primaryCategory: null,
collections: row.collections.map((link) => {
const collectionTranslation = pickTranslation(link.collection.translations, locale);
return {
id: link.collection.id,
name: coalesceRequired(collectionTranslation?.name, link.collection.name),
slug: coalesceRequired(collectionTranslation?.slug, link.collection.slug),
type: 'MANUAL' as const,
description: null,
banner: null,
startsAt: null,
endsAt: null,
isActive: true,
seo: { metaTitle: null, metaDescription: null },
};
}),
variants,
priceRange: this.priceRangeOf(row.variants),
rating: null,
breadcrumbs,
alternateSlugs: Object.fromEntries(
row.translations.map((entry) => [entry.locale === 'VI' ? 'vi' : 'en', entry.slug]),
),
seo: {
metaTitle: coalesce(translation?.metaTitle, row.metaTitle),
metaDescription: coalesce(translation?.metaDescription, row.metaDescription),
},
};
}
// ---- Variants ------------------------------------------------------------
/**
* A variant is the purchasable unit: its own SKU, its own price, its own
* stock. `effectivePrice` resolves the sale price once, here, so no consumer
* re-implements "which price applies".
*/
private toStorefrontVariant(
variant: ProductDetailRow['variants'][number],
locale: Locale,
): StorefrontVariant {
const currency = variant.currency as CurrencyCode;
const price = money(variant.priceAmount, currency);
const salePrice =
variant.salePriceAmount !== null ? money(variant.salePriceAmount, currency) : null;
const available = variant.stockLevels.reduce(
(total, level) => total + (level.onHand - level.reserved),
0,
);
// Sorted by the product's own option order (Colour, then Size). Prisma
// returns join rows in no guaranteed order, which otherwise produced
// "XS / Black" on one request and "Black / XS" on the next.
const optionValues = [...variant.optionValues]
.sort((a, b) => a.option.position - b.option.position)
.map((link) => ({
optionId: link.optionId,
optionKey: link.option.key,
optionValueId: link.optionValueId,
label: coalesceRequired(
pickTranslation(link.optionValue.translations, locale)?.label,
link.optionValue.label,
),
}));
return {
id: variant.id,
productId: variant.productId,
sku: variant.sku,
barcode: variant.barcode,
// Composed from the translated option values rather than the stored
// `title`. The stored one is the canonical internal label kept for order
// snapshots; a Vietnamese shopper must not see "Black / M".
title: optionValues.length > 0 ? optionValues.map((v) => v.label).join(' / ') : variant.title,
price,
salePrice,
compareAtPrice:
variant.compareAtAmount !== null ? money(variant.compareAtAmount, currency) : null,
weightGrams: variant.weightGrams,
dimensions:
variant.lengthMm !== null && variant.widthMm !== null && variant.heightMm !== null
? { lengthMm: variant.lengthMm, widthMm: variant.widthMm, heightMm: variant.heightMm }
: null,
optionValues,
status: 'ACTIVE',
position: variant.position,
effectivePrice: salePrice ?? price,
isOnSale: salePrice !== null,
availability: toAvailability(available),
};
}
private toOption(option: ProductDetailRow['options'][number], locale: Locale): ProductOption {
return {
id: option.id,
name: coalesceRequired(pickTranslation(option.translations, locale)?.name, option.name),
key: option.key,
position: option.position,
values: option.values.map((value) => ({
id: value.id,
optionId: value.optionId,
label: coalesceRequired(pickTranslation(value.translations, locale)?.label, value.label),
value: value.value,
position: value.position,
swatchHex: value.swatchHex,
swatchImageUrl: value.swatchImage ? this.mediaUrl.url(value.swatchImage.storageKey) : null,
})),
};
}
// ---- Shared helpers ------------------------------------------------------
private colorSwatchesOf(options: ProductListRow['options'], locale: Locale): ColorSwatch[] {
const colourOption = options.find((option) => option.key === 'colour');
if (!colourOption) return [];
return colourOption.values.map((value) => ({
optionValueId: value.id,
label: coalesceRequired(pickTranslation(value.translations, locale)?.label, value.label),
swatchHex: value.swatchHex,
swatchImageUrl: value.swatchImage ? this.mediaUrl.url(value.swatchImage.storageKey) : null,
}));
}
/**
* Computed from the variants already loaded rather than from the denormalised
* projection columns. Those exist for WHERE and ORDER BY; display always
* comes from the real rows, so a stale projection can never show a wrong
* price on a page.
*/
private priceRangeOf(
variants: readonly {
currency: string;
priceAmount: number;
salePriceAmount: number | null;
compareAtAmount: number | null;
}[],
): PriceRange {
const currency = (variants[0]?.currency ?? 'VND') as CurrencyCode;
if (variants.length === 0) {
return { min: money(0, currency), max: money(0, currency), compareAtMax: null };
}
const effective = variants.map((variant) => variant.salePriceAmount ?? variant.priceAmount);
const compareAt = variants
.map((variant) => variant.compareAtAmount)
.filter((amount): amount is number => amount !== null);
return {
min: money(Math.min(...effective), currency),
max: money(Math.max(...effective), currency),
compareAtMax: compareAt.length > 0 ? money(Math.max(...compareAt), currency) : null,
};
}
private toProductImage(row: ProductListRow['images'][number]): ProductImage | null {
return this.mediaUrl.toProductImage(row);
}
}
function money(amount: number, currency: CurrencyCode): Money {
return { amount, currency };
}
function toAvailability(available: number): VariantAvailability {
if (available <= 0) return VARIANT_AVAILABILITY.OUT_OF_STOCK;
if (available <= LOW_STOCK_THRESHOLD) return VARIANT_AVAILABILITY.LOW_STOCK;
return VARIANT_AVAILABILITY.IN_STOCK;
}
function isPresent<T>(value: T | null): value is T {
return value !== null;
}
@@ -1,19 +1,23 @@
import { Module } from '@nestjs/common';
import { CategoriesModule } from '@/modules/categories/categories.module';
import { ProductsController } from './products.controller';
import { ProductsMapper } from './products.mapper';
import { ProductsRepository } from './products.repository';
import { ProductsService } from './products.service';
/**
* ProductsModule — boundary declared, implementation pending.
* ProductsModule — owns `products`, `product_translations`, `product_options`,
* `product_option_values`, `product_images` and `product_attributes`.
*
* Owns (exclusively): `products`, `product_options`, `product_option_values`, `product_images`, `product_attributes`
*
* The catalog aggregate root. Every other module references a product by id and reads through this module’s public service.
*
* Anatomy once implemented (see ../README.md):
* products.module.ts wiring only
* products.controller.ts HTTP surface, no logic
* products.service.ts business rules
* products.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
* The catalog aggregate root. Other modules reference a product by id and read
* through this module's public service.
*/
@Module({})
@Module({
imports: [CategoriesModule],
controllers: [ProductsController],
providers: [ProductsService, ProductsRepository, ProductsMapper],
exports: [ProductsService],
})
export class ProductsModule {}
@@ -0,0 +1,536 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type { Locale } from '@sport/types';
import type { ProductFilter } from '@sport/validation';
import { toDbLocale } from '@/common/i18n';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
const imageSelect = {
position: true,
optionValueId: true,
media: {
select: {
id: true,
storageKey: true,
altText: true,
width: true,
height: true,
blurDataUrl: true,
},
},
} as const;
const optionSelect = {
id: true,
name: true,
key: true,
position: true,
translations: true,
values: {
orderBy: { position: 'asc' },
select: {
id: true,
optionId: true,
label: true,
value: true,
position: true,
swatchHex: true,
translations: true,
swatchImage: { select: { storageKey: true } },
},
},
} as const;
/** Card payload: small on purpose — it is fetched 24 at a time. */
const listSelect = {
id: true,
name: true,
slug: true,
isOnSale: true,
translations: true,
brand: { select: { id: true, name: true, translations: true } },
images: { orderBy: { position: 'asc' }, take: 4, select: imageSelect },
options: { where: { key: 'colour' }, select: optionSelect },
variants: {
where: { status: 'ACTIVE', deletedAt: null },
select: { currency: true, priceAmount: true, salePriceAmount: true, compareAtAmount: true },
},
} as const;
/** Full PDP payload. */
const detailSelect = {
id: true,
name: true,
slug: true,
description: true,
shortDescription: true,
status: true,
publishedAt: true,
genderTargets: true,
sportTypes: true,
metaTitle: true,
metaDescription: true,
translations: true,
brand: {
select: {
id: true,
name: true,
slug: true,
description: true,
isActive: true,
metaTitle: true,
metaDescription: true,
translations: true,
logo: {
select: {
id: true,
storageKey: true,
altText: true,
width: true,
height: true,
blurDataUrl: true,
},
},
},
},
primaryCategory: { select: { id: true, path: true } },
collections: {
select: {
collection: {
select: { id: true, name: true, slug: true, translations: true },
},
},
},
images: { orderBy: { position: 'asc' }, select: imageSelect },
options: { orderBy: { position: 'asc' }, select: optionSelect },
attributes: { orderBy: { position: 'asc' }, include: { translations: true } },
variants: {
where: { status: 'ACTIVE', deletedAt: null },
orderBy: { position: 'asc' },
select: {
id: true,
productId: true,
sku: true,
barcode: true,
title: true,
currency: true,
priceAmount: true,
salePriceAmount: true,
compareAtAmount: true,
weightGrams: true,
lengthMm: true,
widthMm: true,
heightMm: true,
status: true,
position: true,
createdAt: true,
updatedAt: true,
optionValues: {
select: {
optionId: true,
optionValueId: true,
option: { select: { key: true, position: true } },
optionValue: { select: { label: true, translations: true } },
},
},
// Availability is derived, never stored: available = onHand - reserved.
stockLevels: { select: { onHand: true, reserved: true } },
},
},
} as const;
export interface ResolvedFilter extends ProductFilter {
/** Materialised path of the requested category, if it resolved. */
categoryPath?: string | null;
}
@Injectable()
export class ProductsRepository {
constructor(private readonly prisma: PrismaService) {}
/**
* Only products that are ACTIVE, not soft-deleted and actually published.
* Every catalog read starts here — a scheduled product leaking early because
* one query forgot `publishedAt` is exactly the bug this prevents.
*/
private visible(): Prisma.ProductWhereInput {
return {
status: 'ACTIVE',
deletedAt: null,
OR: [{ publishedAt: null }, { publishedAt: { lte: new Date() } }],
};
}
/**
* Context filters — the ones that define "which shelf am I looking at".
* Facet counts are computed against these ONLY, so selecting "Black" still
* shows how many White items exist. Counting against the refinements too
* produces a filter UI that dead-ends the moment you use it.
*/
buildContextWhere(filter: ResolvedFilter, locale: Locale): Prisma.ProductWhereInput {
const and: Prisma.ProductWhereInput[] = [this.visible()];
if (filter.q) {
and.push({
OR: [
{ name: { contains: filter.q, mode: 'insensitive' } },
{
translations: {
some: {
locale: toDbLocale(locale),
name: { contains: filter.q, mode: 'insensitive' },
},
},
},
],
});
}
if (filter.categoryPath) {
// Materialised path: one indexed prefix match covers the whole subtree.
and.push({ primaryCategory: { path: { startsWith: filter.categoryPath } } });
} else if (filter.categorySlug) {
// Slug given but unresolvable — narrow to nothing rather than ignore it.
and.push({ id: { in: [] } });
}
if (filter.collectionSlug) {
and.push({
collections: {
some: {
collection: {
OR: [
{ slug: filter.collectionSlug },
{
translations: {
some: { locale: toDbLocale(locale), slug: filter.collectionSlug },
},
},
],
},
},
},
});
}
if (filter.gender?.length) {
and.push({ genderTargets: { hasSome: filter.gender } });
}
if (filter.sport?.length) {
and.push({ sportTypes: { hasSome: filter.sport } });
}
return { AND: and };
}
/** Context filters plus the refinements the shopper ticked. */
buildWhere(filter: ResolvedFilter, locale: Locale): Prisma.ProductWhereInput {
const and: Prisma.ProductWhereInput[] = [this.buildContextWhere(filter, locale)];
if (filter.brandSlugs?.length) {
and.push({
brand: {
OR: [
{ slug: { in: [...filter.brandSlugs] } },
{
translations: {
some: { locale: toDbLocale(locale), slug: { in: [...filter.brandSlugs] } },
},
},
],
},
});
}
if (filter.colors?.length) {
and.push(this.optionValueFilter('colour', filter.colors));
}
if (filter.sizes?.length) {
and.push(this.optionValueFilter('size', filter.sizes));
}
if (filter.onSale) {
and.push({ isOnSale: true });
}
if (filter.inStockOnly) {
and.push({ inStock: true });
}
// Overlap, not containment: a product priced 200k–900k matches a
// 300k–500k filter because it has something in that band.
if (filter.minPrice !== undefined) {
and.push({ maxPriceAmount: { gte: filter.minPrice } });
}
if (filter.maxPrice !== undefined) {
and.push({ minPriceAmount: { lte: filter.maxPrice } });
}
return { AND: and };
}
private optionValueFilter(key: string, values: readonly string[]): Prisma.ProductWhereInput {
return {
options: {
some: { key, values: { some: { value: { in: [...values] } } } },
},
};
}
/**
* `id` is always the final sort key. Cursor pagination needs a total order —
* without the tiebreaker, two products at the same price can be returned
* twice or skipped entirely as pages advance.
*/
private buildOrderBy(sort: ProductFilter['sort']): Prisma.ProductOrderByWithRelationInput[] {
switch (sort) {
case 'price_asc':
return [{ minPriceAmount: 'asc' }, { id: 'asc' }];
case 'price_desc':
return [{ minPriceAmount: 'desc' }, { id: 'desc' }];
// `best_selling` has no order data yet (M5) and `relevance` has no
// ranking yet (M6). Both fall back to newest, which is honest; pretending
// to rank would not be.
case 'best_selling':
case 'relevance':
case 'newest':
default:
return [{ publishedAt: 'desc' }, { id: 'desc' }];
}
}
findList(where: Prisma.ProductWhereInput, filter: ProductFilter, 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) } },
},
},
},
},
},
orderBy: this.buildOrderBy(filter.sort),
take: filter.limit + 1,
...(filter.cursor ? { cursor: { id: filter.cursor }, skip: 1 } : {}),
});
}
count(where: Prisma.ProductWhereInput) {
return this.prisma.product.count({ where });
}
findBySlug(slug: string, locale: Locale) {
const dbLocale = toDbLocale(locale);
return this.prisma.product.findFirst({
where: {
...this.visible(),
// Matches the slug in ANY locale, not just the requested one, so an
// `/en/products/<vietnamese-slug>` link resolves instead of 404ing.
// The service then redirects to the canonical URL for the locale.
OR: [{ translations: { some: { slug } } }, { slug }],
},
select: {
...detailSelect,
// Unfiltered: two rows per product, and both are needed for
// `alternateSlugs` (hreflang + the language switcher).
translations: true,
brand: {
select: {
...detailSelect.brand.select,
translations: { where: { locale: dbLocale } },
},
},
collections: {
select: {
collection: {
select: {
id: true,
name: true,
slug: true,
translations: { where: { locale: dbLocale } },
},
},
},
},
options: {
orderBy: { position: 'asc' },
select: {
...optionSelect,
translations: { where: { locale: dbLocale } },
values: {
orderBy: { position: 'asc' },
select: {
...optionSelect.values.select,
translations: { where: { locale: dbLocale } },
},
},
},
},
attributes: {
orderBy: { position: 'asc' },
include: { translations: { where: { locale: dbLocale } } },
},
variants: {
...detailSelect.variants,
select: {
...detailSelect.variants.select,
optionValues: {
select: {
optionId: true,
optionValueId: true,
option: { select: { key: true, position: true } },
optionValue: {
select: { label: true, translations: { where: { locale: dbLocale } } },
},
},
},
},
},
},
});
}
/** All translated slugs for a locale — feeds `generateStaticParams`/sitemaps. */
findAllSlugs(locale: Locale) {
return this.prisma.productTranslation.findMany({
where: { locale: toDbLocale(locale), product: this.visible() },
select: { slug: true, product: { select: { updatedAt: true } } },
orderBy: { slug: 'asc' },
});
}
// ---- Facets --------------------------------------------------------------
brandFacet(where: Prisma.ProductWhereInput) {
return this.prisma.product.groupBy({
by: ['brandId'],
where: { AND: [where, { brandId: { not: null } }] },
_count: { _all: true },
});
}
brandsByIds(ids: readonly string[], locale: Locale) {
return this.prisma.brand.findMany({
where: { id: { in: [...ids] } },
select: {
id: true,
name: true,
slug: true,
translations: { where: { locale: toDbLocale(locale) } },
},
});
}
/**
* Counts products offering each value of an option.
*
* One `product_option_values` row exists per product per value, so a row
* count IS the product count — no DISTINCT and no large intermediate fetch.
*/
optionValueFacet(where: Prisma.ProductWhereInput, key: string) {
return this.prisma.productOptionValue.groupBy({
by: ['value'],
where: { option: { key, product: where } },
_count: { _all: true },
orderBy: { _count: { value: 'desc' } },
take: 50,
});
}
/** Representative rows so each facet bucket gets a translated label + swatch. */
optionValueSamples(
where: Prisma.ProductWhereInput,
key: string,
values: readonly string[],
locale: Locale,
) {
return this.prisma.productOptionValue.findMany({
where: { option: { key, product: where }, value: { in: [...values] } },
distinct: ['value'],
select: {
value: true,
label: true,
position: true,
swatchHex: true,
translations: { where: { locale: toDbLocale(locale) } },
},
orderBy: { position: 'asc' },
});
}
priceRangeFacet(where: Prisma.ProductWhereInput) {
return this.prisma.product.aggregate({
where,
_min: { minPriceAmount: true },
_max: { maxPriceAmount: true },
});
}
// ---- Read-model maintenance ---------------------------------------------
/**
* Recomputes the denormalised pricing/stock projection on Product.
*
* The single writer for `minPriceAmount`, `maxPriceAmount`, `isOnSale` and
* `inStock`. Every variant or stock mutation in M3 must call this; treat any
* other write to those columns as a bug.
*/
async recomputePricing(productId: string): Promise<void> {
const variants = await this.prisma.productVariant.findMany({
where: { productId, status: 'ACTIVE', deletedAt: null },
select: {
priceAmount: true,
salePriceAmount: true,
stockLevels: { select: { onHand: true, reserved: true } },
},
});
if (variants.length === 0) {
await this.prisma.product.update({
where: { id: productId },
data: { minPriceAmount: null, maxPriceAmount: null, isOnSale: false, inStock: false },
});
return;
}
const effective = variants.map((v) => v.salePriceAmount ?? v.priceAmount);
await this.prisma.product.update({
where: { id: productId },
data: {
minPriceAmount: Math.min(...effective),
maxPriceAmount: Math.max(...effective),
isOnSale: variants.some((v) => v.salePriceAmount !== null),
inStock: variants.some((v) =>
v.stockLevels.some((level) => level.onHand - level.reserved > 0),
),
},
});
}
}
export type ProductListRow = Awaited<ReturnType<ProductsRepository['findList']>>[number];
export type ProductDetailRow = NonNullable<Awaited<ReturnType<ProductsRepository['findBySlug']>>>;
@@ -0,0 +1,244 @@
import { createHash } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import {
API_ERROR_CODES,
type Locale,
type ProductFacets,
type ProductListResult,
type StorefrontProduct,
} from '@sport/types';
import type { ProductFilter } from '@sport/validation';
import { AppException } from '@/common/errors/app.exception';
import { coalesceRequired, pickTranslation } from '@/common/i18n';
import { CACHE_KEYS, CACHE_TTL } from '@/infrastructure/redis/cache-keys';
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';
@Injectable()
export class ProductsService {
constructor(
private readonly repository: ProductsRepository,
private readonly mapper: ProductsMapper,
private readonly redis: RedisService,
private readonly categoriesService: CategoriesService,
) {}
/**
* The one query behind every listing page: /men, /women, /sports/*,
* /collections/* and /search are the same call with different presets.
*
* Cached for 60s only. Listings must reflect a product going out of stock
* reasonably quickly, and at listing volumes a short TTL still absorbs the
* overwhelming majority of load.
*/
async list(filter: ProductFilter, locale: Locale): Promise<ProductListResult> {
const resolved = await this.resolveFilter(filter, locale);
return this.redis.getOrSet(
CACHE_KEYS.productListing(locale, fingerprint(resolved)),
CACHE_TTL.productListing,
async () => {
const where = this.repository.buildWhere(resolved, locale);
const contextWhere = this.repository.buildContextWhere(resolved, locale);
const [rows, totalCount, facets] = await Promise.all([
this.repository.findList(where, resolved, locale),
this.repository.count(where),
this.buildFacets(contextWhere, locale),
]);
// One row over the limit was fetched purely to answer hasNextPage
// without a second count query.
const hasNextPage = rows.length > resolved.limit;
const page = hasNextPage ? rows.slice(0, resolved.limit) : rows;
return {
items: page.map((row) => this.mapper.toListItem(row, locale)),
pageInfo: {
hasNextPage,
nextCursor: hasNextPage ? (page[page.length - 1]?.id ?? null) : null,
},
totalCount,
facets,
};
},
);
}
async getBySlug(slug: string, locale: Locale): Promise<StorefrontProduct> {
const cached = await this.redis.getOrSet(
CACHE_KEYS.productBySlug(locale, slug),
CACHE_TTL.productDetail,
async () => {
const row = await this.repository.findBySlug(slug, locale);
if (!row) return null;
const breadcrumbs = row.primaryCategory
? await this.categoriesService.getBreadcrumbs(row.primaryCategory.path, locale)
: [];
return this.mapper.toStorefrontProduct(row, locale, breadcrumbs);
},
);
if (!cached) {
throw AppException.notFound('Product', API_ERROR_CODES.PRODUCT_NOT_FOUND);
}
return cached;
}
/** Slugs + last-modified for `generateStaticParams` and the sitemap. */
async listSlugs(locale: Locale): Promise<{ slug: string; updatedAt: string }[]> {
const rows = await this.repository.findAllSlugs(locale);
return rows.map((row) => ({ slug: row.slug, updatedAt: row.product.updatedAt.toISOString() }));
}
/** See ProductsRepository.recomputePricing — the single writer for the projection. */
recomputePricing(productId: string): Promise<void> {
return this.repository.recomputePricing(productId);
}
// ---- internals -----------------------------------------------------------
/**
* Turns a category slug into a materialised path before the query runs, so
* the repository never has to reach into another module's tables.
*/
private async resolveFilter(filter: ProductFilter, locale: Locale): Promise<ResolvedFilter> {
if (!filter.categorySlug) return filter;
return {
...filter,
categoryPath: await this.categoriesService.resolvePath(filter.categorySlug, locale),
};
}
private async buildFacets(
contextWhere: Parameters<ProductsRepository['brandFacet']>[0],
locale: Locale,
): Promise<ProductFacets> {
const [brandGroups, colorGroups, sizeGroups, priceAggregate] = await Promise.all([
this.repository.brandFacet(contextWhere),
this.repository.optionValueFacet(contextWhere, 'colour'),
this.repository.optionValueFacet(contextWhere, 'size'),
this.repository.priceRangeFacet(contextWhere),
]);
const brandIds = brandGroups
.map((group) => group.brandId)
.filter((id): id is string => id !== null);
const [brands, colorSamples, sizeSamples] = await Promise.all([
this.repository.brandsByIds(brandIds, locale),
this.repository.optionValueSamples(
contextWhere,
'colour',
colorGroups.map((group) => group.value),
locale,
),
this.repository.optionValueSamples(
contextWhere,
'size',
sizeGroups.map((group) => group.value),
locale,
),
]);
const brandCounts = new Map(brandGroups.map((group) => [group.brandId, group._count._all]));
const colorCounts = new Map(colorGroups.map((group) => [group.value, group._count._all]));
const sizeCounts = new Map(sizeGroups.map((group) => [group.value, group._count._all]));
const min = priceAggregate._min.minPriceAmount;
const max = priceAggregate._max.maxPriceAmount;
return {
brands: brands
.map((brand) => {
const translation = pickTranslation(brand.translations, locale);
return {
value: coalesceRequired(translation?.slug, brand.slug),
label: coalesceRequired(translation?.name, brand.name),
count: brandCounts.get(brand.id) ?? 0,
};
})
.sort((a, b) => b.count - a.count),
colors: colorSamples.map((sample) => ({
value: sample.value,
label: coalesceRequired(pickTranslation(sample.translations, locale)?.label, sample.label),
count: colorCounts.get(sample.value) ?? 0,
swatchHex: sample.swatchHex,
})),
// Sizes are ordered by size, never by count. `position` cannot be used:
// it is per-product, so a listing mixing apparel and footwear yields
// nonsense like "36, XS, 39, S". See `compareSizes`.
sizes: sizeSamples
.map((sample) => ({
value: sample.value,
label: coalesceRequired(
pickTranslation(sample.translations, locale)?.label,
sample.label,
),
count: sizeCounts.get(sample.value) ?? 0,
}))
.sort((a, b) => compareSizes(a.value, b.value)),
priceRange:
min !== null && max !== null
? { min: { amount: min, currency: 'VND' }, max: { amount: max, currency: 'VND' } }
: null,
};
}
}
/** Canonical apparel progression. Anything not listed is treated as numeric. */
const APPAREL_SIZE_ORDER = ['xxs', 'xs', 's', 'm', 'l', 'xl', 'xxl', '2xl', '3xl', '4xl'];
/**
* Orders a mixed size facet the way a human expects: apparel sizes in their
* canonical progression first, then numeric (footwear) sizes ascending.
*
* Sorting alphabetically gives "L, M, S, XL" and sorting by popularity gives a
* jumble — both make a size selector unusable, which is why this is explicit.
*/
function compareSizes(a: string, b: string): number {
const indexA = APPAREL_SIZE_ORDER.indexOf(a.toLowerCase());
const indexB = APPAREL_SIZE_ORDER.indexOf(b.toLowerCase());
if (indexA !== -1 && indexB !== -1) return indexA - indexB;
if (indexA !== -1) return -1;
if (indexB !== -1) return 1;
const numA = Number.parseFloat(a);
const numB = Number.parseFloat(b);
if (!Number.isNaN(numA) && !Number.isNaN(numB)) return numA - numB;
return a.localeCompare(b);
}
/**
* Stable cache key for a filter combination.
*
* Keys are sorted before hashing so `?sort=newest&onSale=true` and
* `?onSale=true&sort=newest` share one cache entry instead of two.
*/
function fingerprint(filter: ResolvedFilter): string {
const normalised = Object.entries(filter)
.filter(([, value]) => value !== undefined && value !== null)
.sort(([a], [b]) => a.localeCompare(b))
.map(
([key, value]) =>
`${key}=${Array.isArray(value) ? [...value].sort().join('|') : String(value)}`,
)
.join('&');
return createHash('sha1').update(normalised).digest('hex').slice(0, 16);
}
@@ -1,10 +1,8 @@
/**
* Public surface of ProductsModule.
*
* This barrel is the ONLY thing other modules may import from here. Everything
* else — repository, DTOs, internal services — is private, and the ESLint
* boundary rule in @sport/eslint-config/nest enforces it.
*
* Keep it narrow: each export is a promise to the rest of the codebase.
* Note what is NOT exported: the repository, the mapper and every Prisma row
* type. Cart, checkout and order modules get `ProductsService` and the types
* from `@sport/types` — nothing that would couple them to the schema.
*/
export {};
export { ProductsService } from '../products.service';