Stage M1
This commit is contained in:
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user