feat(ADM-018): completed feature

This commit is contained in:
chattie
2026-08-17 22:23:10 +02:00
parent cf1c69fc8b
commit d595b4871f
871 changed files with 47411 additions and 281 deletions

View File

@@ -0,0 +1,479 @@
import { performance } from 'node:perf_hooks';
import type { FastifyInstance } from 'fastify';
import type pg from 'pg';
import { z } from 'zod';
import { requireRole, type Authenticate } from '../../../shared/auth.js';
import { AppError } from '../../../shared/errors.js';
import { parseJson } from '../../../shared/http-input.js';
import {
AttachProductImage,
DetachProductImage,
ListProductImages,
ReorderProductImages,
} from '../application/image-use-cases.js';
import {
CreateProduct,
GetActiveProductBySlug,
SearchProducts,
SuggestCorrections,
UpdateProduct,
} from '../application/product-use-cases.js';
import {
CreateProductVariant,
ListProductVariants,
UpdateProductVariant,
UpsertProductRichData,
} from '../application/variant-use-cases.js';
import {
ProductBrandNotFoundError,
ProductCategoryNotFoundError,
ProductImageMainAlreadyExistsError,
ProductImageVariantMismatchError,
ProductSlugAlreadyExistsError,
ProductVariantCodeAlreadyExistsError,
} from '../domain/errors.js';
import type { ProductImage } from '../domain/image.js';
import { PRODUCT_IMAGE_ROLES } from '../domain/image.js';
import type { Product } from '../domain/product.js';
import { PRODUCT_ATTRIBUTES, PRODUCT_STATES } from '../domain/product.js';
import type { ProductRichData, ProductVariant } from '../domain/variant.js';
import { NUTRITION_SOURCES } from '../domain/variant.js';
import { LocalProductImageStorage } from '../infrastructure/local-product-image-storage.js';
import { PgProductImageRepository } from '../infrastructure/pg-product-image-repository.js';
import { PgProductRepository } from '../infrastructure/pg-product-repository.js';
import { PgProductSearchRepository } from '../infrastructure/pg-product-search-repository.js';
import { PgProductRichDataRepository } from '../infrastructure/pg-rich-data-repository.js';
import { PgProductVariantRepository } from '../infrastructure/pg-variant-repository.js';
interface CatalogSearchLogger {
info(payload: Record<string, unknown>, message: string): void;
}
export interface CatalogRoutesDeps {
pool: pg.Pool;
authenticate: Authenticate;
logger?: CatalogSearchLogger;
}
const slugSchema = z
.string()
.min(1)
.max(160)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/);
const slugParamSchema = z.object({ slug: slugSchema });
const idParamSchema = z.object({ id: z.uuid() });
const variantParamSchema = z.object({ id: z.uuid(), variantId: z.uuid() });
const imageParamSchema = z.object({ id: z.uuid(), imageId: z.uuid() });
const searchQuerySchema = z.object({
q: z.string().min(1).max(200).optional(),
limit: z.coerce.number().int().min(1).max(100).optional(),
offset: z.coerce.number().int().min(0).max(10_000).optional(),
brandSlug: slugSchema.optional(),
categorySlug: slugSchema.optional(),
});
const jsonRecordSchema = z
.record(z.string(), z.unknown())
.refine((value) => JSON.stringify(value).length <= 10_000, {
message: 'JSON payload is too large',
});
const newProductSchema = z.object({
name: z.string().min(1).max(200),
slug: slugSchema,
description: z.string().min(1).max(2_000).optional().nullable(),
state: z.enum(PRODUCT_STATES).optional(),
channels: z.enum(['online', 'offline', 'all']).optional(),
featured: z.boolean().optional(),
attributes: z.array(z.enum(PRODUCT_ATTRIBUTES)).optional(),
seoTitle: z.string().min(1).max(200).optional().nullable(),
seoDescription: z.string().min(1).max(500).optional().nullable(),
categoryIds: z.array(z.uuid()).max(50).optional(),
brandId: z.uuid().optional().nullable(),
});
const productPatchSchema = newProductSchema
.partial()
.refine((value) => Object.values(value).some((field) => field !== undefined), {
message: 'At least one product field is required',
});
const newVariantSchema = z.object({
sku: z.string().min(1).max(100),
ean: z.string().min(1).max(32).optional().nullable(),
attributes: jsonRecordSchema.optional(),
});
const variantPatchSchema = newVariantSchema
.partial()
.refine((value) => Object.values(value).some((field) => field !== undefined), {
message: 'At least one variant field is required',
});
const imageUrlSchema = z
.string()
.trim()
.min(1)
.max(2_000)
.refine((value) => value.startsWith('/') || URL.canParse(value), {
message: 'Image URL must be an absolute local path or a valid URL',
});
const newImageSchema = z.object({
url: imageUrlSchema,
altText: z.string().trim().min(1).max(300),
role: z.enum(PRODUCT_IMAGE_ROLES),
variantId: z.uuid().optional().nullable(),
position: z.number().int().min(0).optional(),
});
const reorderImagesSchema = z.object({
items: z
.array(z.object({ imageId: z.uuid(), position: z.number().int().min(0) }))
.min(1)
.max(100),
});
const richDataSchema = z
.object({
ingredients: z.string().min(1).max(5_000).optional().nullable(),
allergens: z.array(z.string().min(1).max(100)).max(100).optional(),
nutrition: jsonRecordSchema.optional().nullable(),
nutritionSource: z.enum(NUTRITION_SOURCES).optional(),
isOrganic: z.boolean().optional(),
organicCertification: z.string().min(1).max(300).optional().nullable(),
})
.refine(
(value) =>
value.nutrition === undefined ||
value.nutrition === null ||
value.nutritionSource !== undefined,
{
message: 'nutritionSource is required when nutrition is provided',
},
)
.refine((value) => Object.values(value).some((field) => field !== undefined), {
message: 'At least one rich data field is required',
});
export async function registerCatalogRoutes(
app: FastifyInstance,
deps: CatalogRoutesDeps,
): Promise<void> {
const repository = new PgProductRepository(deps.pool);
const searchRepository = new PgProductSearchRepository(deps.pool);
const variants = new PgProductVariantRepository(deps.pool);
const richData = new PgProductRichDataRepository(deps.pool);
const images = new PgProductImageRepository(deps.pool, new LocalProductImageStorage());
const getBySlug = new GetActiveProductBySlug(repository);
const searchProducts = new SearchProducts(searchRepository);
const suggestCorrections = new SuggestCorrections(searchRepository);
const createProduct = new CreateProduct(repository);
const updateProduct = new UpdateProduct(repository);
const listVariants = new ListProductVariants(variants);
const createVariant = new CreateProductVariant(repository, variants);
const updateVariant = new UpdateProductVariant(variants);
const upsertRichData = new UpsertProductRichData(repository, richData);
const listImages = new ListProductImages(images);
const attachImage = new AttachProductImage(repository, images);
const detachImage = new DetachProductImage(images);
const reorderImages = new ReorderProductImages(images);
// Admin: list all products (any state)
app.get('/catalog/products', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const q = (request.query as { q?: string }).q;
const limit = parseInt((request.query as { limit?: string }).limit ?? '20', 10);
const offset = parseInt((request.query as { offset?: string }).offset ?? '0', 10);
const result = await repository.listAll({ limit, offset, q });
return reply.send({ items: result.items.map((p) => serializeProduct(p)), total: result.total });
});
app.get('/productos/:slug', async (request, reply) => {
const { slug } = parseJson(slugParamSchema, request.params);
const product = await getBySlug.execute(slug);
if (!product) {
throw new AppError(404, 'NOT_FOUND', 'Product not found');
}
const productImages = await listImages.execute(product.id);
return reply.send(serializeProduct(product, productImages));
});
// Admin: get product by ID (for editor)
app.get('/products/:id', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
const product = await repository.findById(id);
if (!product) {
throw new AppError(404, 'NOT_FOUND', 'Product not found');
}
const productImages = await listImages.execute(product.id);
return reply.send(serializeProduct(product, productImages));
});
app.get('/products/search', async (request, reply) => {
const input = parseJson(searchQuerySchema, request.query);
const startedAt = performance.now();
const items = await searchProducts.execute(input);
deps.logger?.info(
{
event: 'catalog_search',
queryPresent: input.q !== undefined,
query: sanitizeSearchTelemetryQuery(input.q),
brandSlug: input.brandSlug,
categorySlug: input.categorySlug,
limit: input.limit ?? 20,
offset: input.offset ?? 0,
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
resultCount: items.length,
},
'catalog search completed',
);
return reply.send({ items: items.map((product) => serializeProduct(product)) });
});
app.get('/products/suggest', async (request, reply) => {
const { q } = parseJson(
z.object({ q: z.string().min(2).max(200) }),
request.query,
);
const suggestions = await suggestCorrections.execute(q);
return reply.send({ suggestions });
});
app.post('/products', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const input = parseJson(newProductSchema, request.body);
try {
const product = await createProduct.execute(input);
return reply.code(201).send(serializeProduct(product));
} catch (error) {
throw mapProductError(error);
}
});
app.get('/products/:id/variants', async (request, reply) => {
const { id } = parseJson(idParamSchema, request.params);
const items = await listVariants.execute(id);
return reply.send({ items: items.map(serializeVariant) });
});
app.get('/products/:id/images', async (request, reply) => {
const { id } = parseJson(idParamSchema, request.params);
const items = await listImages.execute(id);
return reply.send({ items: items.map(serializeImage) });
});
app.post('/products/:id/images', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
const input = parseJson(newImageSchema, request.body);
try {
const image = await attachImage.execute(id, input);
if (!image) {
throw new AppError(404, 'NOT_FOUND', 'Product not found');
}
return reply.code(201).send(serializeImage(image));
} catch (error) {
throw mapProductError(error);
}
});
app.delete('/products/:id/images/:imageId', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id, imageId } = parseJson(imageParamSchema, request.params);
const deleted = await detachImage.execute(id, imageId);
if (!deleted) {
throw new AppError(404, 'NOT_FOUND', 'Image not found');
}
return reply.code(204).send();
});
app.patch('/products/:id/images/reorder', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
const { items } = parseJson(reorderImagesSchema, request.body);
const ordered = await reorderImages.execute(id, items);
return reply.send({ items: ordered.map(serializeImage) });
});
app.post('/products/:id/variants', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
const input = parseJson(newVariantSchema, request.body);
try {
const variant = await createVariant.execute(id, input);
if (!variant) {
throw new AppError(404, 'NOT_FOUND', 'Product not found');
}
return reply.code(201).send(serializeVariant(variant));
} catch (error) {
throw mapProductError(error);
}
});
app.patch('/products/:id/variants/:variantId', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id, variantId } = parseJson(variantParamSchema, request.params);
const patch = parseJson(variantPatchSchema, request.body);
try {
const variant = await updateVariant.execute(id, variantId, patch);
if (!variant) {
throw new AppError(404, 'NOT_FOUND', 'Variant not found');
}
return reply.send(serializeVariant(variant));
} catch (error) {
throw mapProductError(error);
}
});
app.patch('/products/:id/rich-data', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
const patch = parseJson(richDataSchema, request.body);
const data = await upsertRichData.execute(id, patch);
if (!data) {
throw new AppError(404, 'NOT_FOUND', 'Product not found');
}
return reply.send(serializeRichData(data));
});
app.patch('/products/:id', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
const patch = parseJson(productPatchSchema, request.body);
try {
const product = await updateProduct.execute(id, patch);
if (!product) {
throw new AppError(404, 'NOT_FOUND', 'Product not found');
}
return reply.send(serializeProduct(product));
} catch (error) {
throw mapProductError(error);
}
});
// PATCH /products/:id/state — change product state
app.patch('/products/:id/state', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
const { state } = parseJson(z.object({ state: z.enum(PRODUCT_STATES) }), request.body);
const product = await updateProduct.execute(id, { state });
if (!product) {
throw new AppError(404, 'NOT_FOUND', 'Product not found');
}
return reply.send(serializeProduct(product));
});
// DELETE /products/:id
app.delete('/products/:id', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
await repository.delete(id);
return reply.code(204).send();
});
}
function mapProductError(error: unknown): Error {
if (error instanceof ProductSlugAlreadyExistsError) {
return new AppError(409, 'PRODUCT_SLUG_EXISTS', error.message);
}
if (error instanceof ProductCategoryNotFoundError) {
return new AppError(422, 'PRODUCT_CATEGORY_NOT_FOUND', error.message);
}
if (error instanceof ProductBrandNotFoundError) {
return new AppError(422, 'PRODUCT_BRAND_NOT_FOUND', error.message);
}
if (error instanceof ProductVariantCodeAlreadyExistsError) {
return new AppError(409, 'PRODUCT_VARIANT_CODE_EXISTS', error.message);
}
if (error instanceof ProductImageVariantMismatchError) {
return new AppError(422, 'PRODUCT_IMAGE_VARIANT_MISMATCH', error.message);
}
if (error instanceof ProductImageMainAlreadyExistsError) {
return new AppError(409, 'PRODUCT_IMAGE_MAIN_EXISTS', error.message);
}
return error instanceof Error ? error : new Error('Unknown product error');
}
function sanitizeSearchTelemetryQuery(query: string | undefined): string | undefined {
if (query === undefined) {
return undefined;
}
return query
.trim()
.slice(0, 200)
.replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, '[redacted-email]')
.replace(/\b(?:sk|pk)_(?:live|test)_[A-Za-z0-9_]+\b/g, '[redacted-key]')
.replace(/\b[A-Za-z0-9_-]{32,}\b/g, '[redacted-token]');
}
function serializeProduct(product: Product, images: ProductImage[] = []) {
return {
id: product.id,
name: product.name,
slug: product.slug,
url: `/productos/${product.slug}`,
images: images.map(serializeImage),
description: product.description,
state: product.state,
seoTitle: product.seoTitle,
seoDescription: product.seoDescription,
categoryIds: product.categoryIds,
brandId: product.brandId,
createdAt: product.createdAt.toISOString(),
updatedAt: product.updatedAt.toISOString(),
};
}
function serializeImage(image: ProductImage) {
return {
id: image.id,
productId: image.productId,
variantId: image.variantId,
url: image.url,
altText: image.altText,
position: image.position,
role: image.role,
createdAt: image.createdAt.toISOString(),
updatedAt: image.updatedAt.toISOString(),
};
}
function serializeVariant(variant: ProductVariant) {
return {
id: variant.id,
productId: variant.productId,
sku: variant.sku,
ean: variant.ean,
attributes: variant.attributes,
createdAt: variant.createdAt.toISOString(),
updatedAt: variant.updatedAt.toISOString(),
};
}
function serializeRichData(data: ProductRichData) {
return {
productId: data.productId,
ingredients: data.ingredients,
allergens: data.allergens,
nutrition: data.nutrition,
nutritionSource: data.nutritionSource,
isOrganic: data.isOrganic,
organicCertification: data.organicCertification,
createdAt: data.createdAt.toISOString(),
updatedAt: data.updatedAt.toISOString(),
};
}

View File

@@ -0,0 +1,51 @@
import { ProductImageVariantMismatchError } from '../domain/errors.js';
import type { NewProductImage, ProductImage, ProductImageOrderItem } from '../domain/image.js';
import type { ProductImageRepository, ProductRepository } from '../domain/ports.js';
export class ListProductImages {
constructor(private readonly images: ProductImageRepository) {}
async execute(productId: string, variantId?: string | null): Promise<ProductImage[]> {
return this.images.listByProductId(productId, variantId ?? null);
}
}
export class AttachProductImage {
constructor(
private readonly products: ProductRepository,
private readonly images: ProductImageRepository,
) {}
async execute(productId: string, input: NewProductImage): Promise<ProductImage | undefined> {
if (!(await this.products.findById(productId))) {
return undefined;
}
if (
input.variantId !== undefined &&
input.variantId !== null &&
!(await this.images.variantBelongsToProduct(productId, input.variantId))
) {
throw new ProductImageVariantMismatchError();
}
return this.images.attach(productId, input);
}
}
export class DetachProductImage {
constructor(private readonly images: ProductImageRepository) {}
async execute(productId: string, imageId: string): Promise<boolean> {
return this.images.detach(productId, imageId);
}
}
export class ReorderProductImages {
constructor(private readonly images: ProductImageRepository) {}
async execute(
productId: string,
items: readonly ProductImageOrderItem[],
): Promise<ProductImage[]> {
return this.images.reorder(productId, items);
}
}

View File

@@ -0,0 +1,102 @@
import { ProductBrandNotFoundError, ProductCategoryNotFoundError } from '../domain/errors.js';
import type { NewProduct, Product, ProductPatch } from '../domain/product.js';
import type { ProductRepository, ProductSearchRepository } from '../domain/ports.js';
export class GetActiveProductBySlug {
constructor(private readonly products: ProductRepository) {}
async execute(slug: string): Promise<Product | undefined> {
return this.products.findActiveBySlug(slug);
}
}
export interface SearchProductsInput {
q?: string;
limit?: number;
offset?: number;
brandSlug?: string;
categorySlug?: string;
}
export class SearchProducts {
constructor(private readonly searchRepository: ProductSearchRepository) {}
async execute(input: SearchProductsInput = {}): Promise<Product[]> {
return this.searchRepository.search({
q: input.q,
limit: input.limit ?? 20,
offset: input.offset ?? 0,
activeOnly: true,
brandSlug: input.brandSlug,
categorySlug: input.categorySlug,
});
}
}
export class SuggestCorrections {
constructor(private readonly searchRepository: ProductSearchRepository) {}
async execute(term: string): Promise<string[]> {
return this.searchRepository.suggestCorrections(term);
}
}
export class CreateProduct {
constructor(private readonly products: ProductRepository) {}
async execute(input: NewProduct): Promise<Product> {
await this.assertCategoriesExist(input.categoryIds ?? []);
await this.assertBrandExists(input.brandId);
return this.products.create({ ...input, state: input.state ?? 'draft' });
}
private async assertCategoriesExist(categoryIds: readonly string[]): Promise<void> {
if (categoryIds.length === 0) {
return;
}
if (!(await this.products.categoriesExist(categoryIds))) {
throw new ProductCategoryNotFoundError();
}
}
private async assertBrandExists(brandId: string | null | undefined): Promise<void> {
if (brandId === undefined || brandId === null) {
return;
}
if (!(await this.products.brandExists(brandId))) {
throw new ProductBrandNotFoundError();
}
}
}
export class UpdateProduct {
constructor(private readonly products: ProductRepository) {}
async execute(id: string, patch: ProductPatch): Promise<Product | undefined> {
if (patch.categoryIds !== undefined) {
await this.assertCategoriesExist(patch.categoryIds);
}
if (patch.brandId !== undefined) {
await this.assertBrandExists(patch.brandId);
}
return this.products.update(id, patch);
}
private async assertCategoriesExist(categoryIds: readonly string[]): Promise<void> {
if (categoryIds.length === 0) {
return;
}
if (!(await this.products.categoriesExist(categoryIds))) {
throw new ProductCategoryNotFoundError();
}
}
private async assertBrandExists(brandId: string | null): Promise<void> {
if (brandId === null) {
return;
}
if (!(await this.products.brandExists(brandId))) {
throw new ProductBrandNotFoundError();
}
}
}

View File

@@ -0,0 +1,78 @@
import type {
ProductRepository,
ProductRichDataRepository,
ProductVariantRepository,
} from '../domain/ports.js';
import type {
NewProductVariant,
ProductRichData,
ProductVariant,
ProductVariantPatch,
RichDataPatch,
} from '../domain/variant.js';
export class ListProductVariants {
constructor(private readonly variants: ProductVariantRepository) {}
async execute(productId: string): Promise<ProductVariant[]> {
return this.variants.listByProductId(productId);
}
}
export class CreateProductVariant {
constructor(
private readonly products: ProductRepository,
private readonly variants: ProductVariantRepository,
) {}
async execute(productId: string, input: NewProductVariant): Promise<ProductVariant | undefined> {
if (!(await this.products.findById(productId))) {
return undefined;
}
return this.variants.create(productId, input);
}
}
export class UpdateProductVariant {
constructor(private readonly variants: ProductVariantRepository) {}
async execute(
productId: string,
variantId: string,
patch: ProductVariantPatch,
): Promise<ProductVariant | undefined> {
return this.variants.update(productId, variantId, patch);
}
}
export class UpsertProductRichData {
constructor(
private readonly products: ProductRepository,
private readonly richData: ProductRichDataRepository,
) {}
async execute(productId: string, patch: RichDataPatch): Promise<ProductRichData | undefined> {
if (!(await this.products.findById(productId))) {
return undefined;
}
const current = await this.richData.findByProductId(productId);
const protectedPatch = protectManualNutrition(current, patch);
return this.richData.upsert(productId, protectedPatch);
}
}
export function protectManualNutrition(
current: ProductRichData | undefined,
patch: RichDataPatch,
): RichDataPatch {
if (
current?.nutritionSource === 'manual' &&
patch.nutrition !== undefined &&
patch.nutritionSource !== undefined &&
patch.nutritionSource !== 'manual'
) {
const { nutrition: _nutrition, nutritionSource: _nutritionSource, ...rest } = patch;
return rest;
}
return patch;
}

View File

@@ -0,0 +1,41 @@
export class ProductSlugAlreadyExistsError extends Error {
constructor() {
super('Product slug already exists');
this.name = 'ProductSlugAlreadyExistsError';
}
}
export class ProductCategoryNotFoundError extends Error {
constructor() {
super('Product category not found');
this.name = 'ProductCategoryNotFoundError';
}
}
export class ProductBrandNotFoundError extends Error {
constructor() {
super('Product brand not found');
this.name = 'ProductBrandNotFoundError';
}
}
export class ProductVariantCodeAlreadyExistsError extends Error {
constructor() {
super('Product variant SKU or EAN already exists');
this.name = 'ProductVariantCodeAlreadyExistsError';
}
}
export class ProductImageVariantMismatchError extends Error {
constructor() {
super('Product image variant does not belong to product');
this.name = 'ProductImageVariantMismatchError';
}
}
export class ProductImageMainAlreadyExistsError extends Error {
constructor() {
super('Main product image already exists for this scope');
this.name = 'ProductImageMainAlreadyExistsError';
}
}

View File

@@ -0,0 +1,33 @@
/** Product image domain model. */
export type ProductImageRole = 'main' | 'gallery';
export const PRODUCT_IMAGE_ROLES: readonly ProductImageRole[] = ['main', 'gallery'];
export interface ProductImage {
id: string;
productId: string;
variantId: string | null;
url: string;
altText: string;
position: number;
role: ProductImageRole;
createdAt: Date;
updatedAt: Date;
}
export interface NewProductImage {
url: string;
altText: string;
role: ProductImageRole;
variantId?: string | null;
position?: number;
}
export interface ProductImageOrderItem {
imageId: string;
position: number;
}
export interface ProductImageStorage {
normalizeUrl(url: string): string;
}

View File

@@ -0,0 +1,52 @@
import type { NewProductImage, ProductImage, ProductImageOrderItem } from './image.js';
import type { NewProduct, Product, ProductPatch, ProductSearchCriteria } from './product.js';
import type {
NewProductVariant,
ProductRichData,
ProductVariant,
ProductVariantPatch,
RichDataPatch,
} from './variant.js';
export interface ProductRepository {
findById(id: string): Promise<Product | undefined>;
findActiveBySlug(slug: string): Promise<Product | undefined>;
create(input: NewProduct): Promise<Product>;
update(id: string, patch: ProductPatch): Promise<Product | undefined>;
delete(id: string): Promise<void>;
listAll(options?: { limit?: number; offset?: number; q?: string }): Promise<{ items: Product[]; total: number }>;
categoriesExist(categoryIds: readonly string[]): Promise<boolean>;
brandExists(brandId: string): Promise<boolean>;
}
export interface ProductSearchRepository {
search(input: ProductSearchCriteria): Promise<Product[]>;
/**
* Returns suggested term corrections using trigram similarity.
* Falls back to brand/category names when no products match.
*/
suggestCorrections(term: string): Promise<string[]>;
}
export interface ProductVariantRepository {
listByProductId(productId: string): Promise<ProductVariant[]>;
create(productId: string, input: NewProductVariant): Promise<ProductVariant>;
update(
productId: string,
variantId: string,
patch: ProductVariantPatch,
): Promise<ProductVariant | undefined>;
}
export interface ProductRichDataRepository {
findByProductId(productId: string): Promise<ProductRichData | undefined>;
upsert(productId: string, patch: RichDataPatch): Promise<ProductRichData>;
}
export interface ProductImageRepository {
listByProductId(productId: string, variantId?: string | null): Promise<ProductImage[]>;
attach(productId: string, input: NewProductImage): Promise<ProductImage>;
detach(productId: string, imageId: string): Promise<boolean>;
reorder(productId: string, items: readonly ProductImageOrderItem[]): Promise<ProductImage[]>;
variantBelongsToProduct(productId: string, variantId: string): Promise<boolean>;
}

View File

@@ -0,0 +1,74 @@
/**
* Product domain model. Public storefront URLs use slug; id is internal.
*/
export type ProductState = 'draft' | 'active' | 'archived';
export const PRODUCT_STATES: readonly ProductState[] = ['draft', 'active', 'archived'];
/** Sales channels. */
export type SalesChannel = 'online' | 'offline' | 'all';
/** Product attribute tags (simple checkboxes). */
export const PRODUCT_ATTRIBUTES = [
'bio',
'comercio-justo',
'congelado',
'cruelty-free',
'de-temporada',
'demeter',
'fruta-verdura',
'keto',
'kosher',
'low-carb',
'raw-food',
'sin-azucar',
'sin-gluten',
'sin-lactosa',
'vegano',
'zero-waste',
] as const;
export type ProductAttribute = typeof PRODUCT_ATTRIBUTES[number];
export interface Product {
id: string;
name: string;
slug: string;
description: string | null;
state: ProductState;
channels: SalesChannel;
featured: boolean;
attributes: ProductAttribute[];
seoTitle: string | null;
seoDescription: string | null;
categoryIds: string[];
brandId: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface NewProduct {
name: string;
slug: string;
description?: string | null;
state?: ProductState;
channels?: SalesChannel;
featured?: boolean;
attributes?: ProductAttribute[];
seoTitle?: string | null;
seoDescription?: string | null;
categoryIds?: string[];
brandId?: string | null;
}
/** Fields a product update may set. Undefined = leave unchanged. */
export type ProductPatch = Partial<NewProduct>;
export interface ProductSearchCriteria {
q?: string;
limit: number;
offset: number;
activeOnly: boolean;
brandSlug?: string;
categorySlug?: string;
}

View File

@@ -0,0 +1,48 @@
/** Product variant and rich-data domain models. */
export type JsonRecord = Record<string, unknown>;
export type NutritionSource = 'manual' | 'manufacturer' | 'openfoodfacts';
export const NUTRITION_SOURCES: readonly NutritionSource[] = [
'manual',
'manufacturer',
'openfoodfacts',
];
export interface ProductVariant {
id: string;
productId: string;
sku: string;
ean: string | null;
attributes: JsonRecord;
createdAt: Date;
updatedAt: Date;
}
export interface NewProductVariant {
sku: string;
ean?: string | null;
attributes?: JsonRecord;
}
export type ProductVariantPatch = Partial<NewProductVariant>;
export interface ProductRichData {
productId: string;
ingredients: string | null;
allergens: string[];
nutrition: JsonRecord | null;
nutritionSource: NutritionSource | null;
isOrganic: boolean;
organicCertification: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface RichDataPatch {
ingredients?: string | null;
allergens?: string[];
nutrition?: JsonRecord | null;
nutritionSource?: NutritionSource;
isOrganic?: boolean;
organicCertification?: string | null;
}

View File

@@ -0,0 +1,2 @@
/** Public API of the catalog module. */
export { registerCatalogRoutes, type CatalogRoutesDeps } from './api/catalog.routes.js';

View File

@@ -0,0 +1,11 @@
import type { ProductImageStorage } from '../domain/image.js';
export class LocalProductImageStorage implements ProductImageStorage {
normalizeUrl(url: string): string {
const trimmed = url.trim();
if (trimmed.startsWith('/')) {
return trimmed;
}
return new URL(trimmed).toString();
}
}

View File

@@ -0,0 +1,151 @@
import type pg from 'pg';
import { ProductImageMainAlreadyExistsError } from '../domain/errors.js';
import type {
NewProductImage,
ProductImage,
ProductImageOrderItem,
ProductImageStorage,
} from '../domain/image.js';
import type { ProductImageRepository } from '../domain/ports.js';
interface ImageRow {
id: string;
product_id: string;
variant_id: string | null;
url: string;
alt_text: string;
position: number;
role: 'main' | 'gallery';
created_at: Date;
updated_at: Date;
}
const UNIQUE_VIOLATION = '23505';
export class PgProductImageRepository implements ProductImageRepository {
constructor(
private readonly pool: pg.Pool,
private readonly storage: ProductImageStorage,
) {}
async listByProductId(
productId: string,
variantId: string | null = null,
): Promise<ProductImage[]> {
const result = await this.pool.query<ImageRow>(
`SELECT * FROM catalog_product_images
WHERE product_id = $1 AND variant_id IS NOT DISTINCT FROM $2
ORDER BY position ASC, created_at ASC, id ASC`,
[productId, variantId],
);
return result.rows.map(toImage);
}
async attach(productId: string, input: NewProductImage): Promise<ProductImage> {
try {
const result = await this.pool.query<ImageRow>(
`INSERT INTO catalog_product_images (product_id, variant_id, url, alt_text, position, role)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[
productId,
input.variantId ?? null,
this.storage.normalizeUrl(input.url),
input.altText,
input.position ?? 0,
input.role,
],
);
const row = result.rows[0];
if (!row) {
throw new Error('catalog_product_images INSERT returned no row');
}
return toImage(row);
} catch (error) {
if (isPgError(error, UNIQUE_VIOLATION)) {
throw new ProductImageMainAlreadyExistsError();
}
throw error;
}
}
async detach(productId: string, imageId: string): Promise<boolean> {
const result = await this.pool.query(
'DELETE FROM catalog_product_images WHERE product_id = $1 AND id = $2',
[productId, imageId],
);
return (result.rowCount ?? 0) > 0;
}
async reorder(
productId: string,
items: readonly ProductImageOrderItem[],
): Promise<ProductImage[]> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const scope = await this.assertSingleScope(client, productId, items);
for (const item of items) {
await client.query(
`UPDATE catalog_product_images SET position = $1, updated_at = now()
WHERE product_id = $2 AND id = $3`,
[item.position, productId, item.imageId],
);
}
await client.query('COMMIT');
return this.listByProductId(productId, scope);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async variantBelongsToProduct(productId: string, variantId: string): Promise<boolean> {
const result = await this.pool.query<{ exists: boolean }>(
'SELECT EXISTS (SELECT 1 FROM catalog_product_variants WHERE id = $1 AND product_id = $2)',
[variantId, productId],
);
return result.rows[0]?.exists ?? false;
}
private async assertSingleScope(
client: pg.PoolClient,
productId: string,
items: readonly ProductImageOrderItem[],
): Promise<string | null> {
const imageIds = items.map((item) => item.imageId);
const result = await client.query<{ variant_id: string | null }>(
'SELECT variant_id FROM catalog_product_images WHERE product_id = $1 AND id = ANY($2::uuid[])',
[productId, imageIds],
);
if (result.rows.length !== imageIds.length) {
throw new Error('Product image reorder item not found');
}
const [first] = result.rows;
const scope = first?.variant_id ?? null;
if (!result.rows.every((row) => row.variant_id === scope)) {
throw new Error('Product image reorder items must belong to the same scope');
}
return scope;
}
}
function toImage(row: ImageRow): ProductImage {
return {
id: row.id,
productId: row.product_id,
variantId: row.variant_id,
url: row.url,
altText: row.alt_text,
position: row.position,
role: row.role,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function isPgError(error: unknown, code: string): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
}

View File

@@ -0,0 +1,271 @@
import type pg from 'pg';
import { ProductBrandNotFoundError, ProductSlugAlreadyExistsError } from '../domain/errors.js';
import type { NewProduct, Product, ProductAttribute, ProductPatch, ProductState } from '../domain/product.js';
import type { ProductRepository } from '../domain/ports.js';
export interface ProductRow {
id: string;
name: string;
slug: string;
description: string | null;
state: ProductState;
channels: 'online' | 'offline' | 'all';
featured: boolean;
// JSONB from PostgreSQL; parsed as string[] by pg driver
attributes: unknown;
seo_title: string | null;
seo_description: string | null;
brand_id: string | null;
category_ids: string[] | null;
created_at: Date;
updated_at: Date;
}
const UNIQUE_VIOLATION = '23505';
const FOREIGN_KEY_VIOLATION = '23503';
export const PRODUCT_COLUMNS = `
p.*,
COALESCE(
array_agg(pc.category_id ORDER BY pc.category_id) FILTER (WHERE pc.category_id IS NOT NULL),
ARRAY[]::uuid[]
) AS category_ids
`;
const UPDATABLE: ReadonlyArray<[keyof ProductPatch, string]> = [
['name', 'name'],
['slug', 'slug'],
['description', 'description'],
['state', 'state'],
['channels', 'channels'],
['featured', 'featured'],
['attributes', 'attributes'],
['seoTitle', 'seo_title'],
['seoDescription', 'seo_description'],
['brandId', 'brand_id'],
];
export class PgProductRepository implements ProductRepository {
constructor(private readonly pool: pg.Pool) {}
async findById(id: string): Promise<Product | undefined> {
const result = await this.pool.query<ProductRow>(
`SELECT ${PRODUCT_COLUMNS}
FROM catalog_products p
LEFT JOIN catalog_product_categories pc ON pc.product_id = p.id
WHERE p.id = $1
GROUP BY p.id`,
[id],
);
const row = result.rows[0];
return row ? toProduct(row) : undefined;
}
async findActiveBySlug(slug: string): Promise<Product | undefined> {
const result = await this.pool.query<ProductRow>(
`SELECT ${PRODUCT_COLUMNS}
FROM catalog_products p
LEFT JOIN catalog_product_categories pc ON pc.product_id = p.id
WHERE p.slug = $1 AND p.state = 'active'
GROUP BY p.id`,
[slug],
);
const row = result.rows[0];
return row ? toProduct(row) : undefined;
}
async create(input: NewProduct): Promise<Product> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const result = await client.query<ProductRow>(
`INSERT INTO catalog_products (name, slug, description, state, seo_title, seo_description, brand_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *, ARRAY[]::uuid[] AS category_ids`,
[
input.name,
input.slug,
input.description ?? null,
input.state ?? 'draft',
input.seoTitle ?? null,
input.seoDescription ?? null,
input.brandId ?? null,
],
);
const row = result.rows[0];
if (!row) {
throw new Error('catalog_products INSERT returned no row');
}
await this.replaceCategories(client, row.id, input.categoryIds ?? []);
await client.query('COMMIT');
return (await this.findById(row.id)) ?? toProduct(row);
} catch (error) {
await client.query('ROLLBACK');
throw mapPgError(error);
} finally {
client.release();
}
}
async update(id: string, patch: ProductPatch): Promise<Product | undefined> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const setClauses: string[] = [];
const values: unknown[] = [];
for (const [key, column] of UPDATABLE) {
if (key in patch) {
values.push(patch[key]);
setClauses.push(`${column} = $${values.length}`);
}
}
let exists = true;
if (setClauses.length > 0) {
values.push(id);
const result = await client.query(
`UPDATE catalog_products SET ${setClauses.join(', ')}, updated_at = now()
WHERE id = $${values.length}`,
values,
);
exists = (result.rowCount ?? 0) > 0;
} else {
const found = await client.query('SELECT 1 FROM catalog_products WHERE id = $1', [id]);
exists = (found.rowCount ?? 0) > 0;
}
if (!exists) {
await client.query('COMMIT');
return undefined;
}
if (patch.categoryIds !== undefined) {
await this.replaceCategories(client, id, patch.categoryIds);
}
await client.query('COMMIT');
return this.findById(id);
} catch (error) {
await client.query('ROLLBACK');
throw mapPgError(error);
} finally {
client.release();
}
}
async delete(id: string): Promise<void> {
await this.pool.query('DELETE FROM catalog_products WHERE id = $1', [id]);
}
async listAll(options?: {
limit?: number;
offset?: number;
q?: string;
}): Promise<{ items: Product[]; total: number }> {
const limit = options?.limit ?? 20;
const offset = options?.offset ?? 0;
const q = options?.q?.trim();
const countResult = await this.pool.query<{ count: string }>(
q
? `SELECT COUNT(*) FROM catalog_products WHERE name ILIKE $1`
: 'SELECT COUNT(*) FROM catalog_products',
q ? [`%${q}%`] : [],
);
const total = parseInt(countResult.rows[0]?.count ?? '0', 10);
const rows = await this.pool.query<ProductRow>(
q
? `SELECT ${PRODUCT_COLUMNS}
FROM catalog_products p
LEFT JOIN catalog_product_categories pc ON pc.product_id = p.id
WHERE p.name ILIKE $1
GROUP BY p.id
ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3`
: `SELECT ${PRODUCT_COLUMNS}
FROM catalog_products p
LEFT JOIN catalog_product_categories pc ON pc.product_id = p.id
GROUP BY p.id
ORDER BY p.created_at DESC
LIMIT $1 OFFSET $2`,
q ? [`%${q}%`, limit, offset] : [limit, offset],
);
return { items: rows.rows.map(toProduct), total };
}
async brandExists(brandId: string): Promise<boolean> {
const result = await this.pool.query<{ exists: boolean }>(
'SELECT EXISTS (SELECT 1 FROM brands_brands WHERE id = $1)',
[brandId],
);
return result.rows[0]?.exists ?? false;
}
async categoriesExist(categoryIds: readonly string[]): Promise<boolean> {
const uniqueIds = [...new Set(categoryIds)];
if (uniqueIds.length === 0) {
return true;
}
const result = await this.pool.query<{ count: string }>(
'SELECT count(*)::int AS count FROM categories_categories WHERE id = ANY($1::uuid[])',
[uniqueIds],
);
return Number(result.rows[0]?.count ?? 0) === uniqueIds.length;
}
private async replaceCategories(
client: pg.PoolClient,
productId: string,
categoryIds: readonly string[],
): Promise<void> {
await client.query('DELETE FROM catalog_product_categories WHERE product_id = $1', [productId]);
const uniqueIds = [...new Set(categoryIds)];
for (const categoryId of uniqueIds) {
await client.query(
'INSERT INTO catalog_product_categories (product_id, category_id) VALUES ($1, $2)',
[productId, categoryId],
);
}
}
}
export function toProduct(row: ProductRow): Product {
return {
id: row.id,
name: row.name,
slug: row.slug,
description: row.description,
state: row.state,
channels: row.channels,
featured: row.featured,
attributes: Array.isArray(row.attributes)
? (row.attributes as unknown[]).filter((a): a is ProductAttribute =>
typeof a === 'string' && [
'bio', 'comercio-justo', 'congelado', 'cruelty-free', 'de-temporada',
'demeter', 'fruta-verdura', 'keto', 'kosher', 'low-carb', 'raw-food',
'sin-azucar', 'sin-gluten', 'sin-lactosa', 'vegano', 'zero-waste',
].includes(a),
)
: [],
seoTitle: row.seo_title,
seoDescription: row.seo_description,
brandId: row.brand_id,
categoryIds: row.category_ids ?? [],
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function mapPgError(error: unknown): Error {
if (isPgError(error, UNIQUE_VIOLATION)) {
return new ProductSlugAlreadyExistsError();
}
if (isPgError(error, FOREIGN_KEY_VIOLATION)) {
return new ProductBrandNotFoundError();
}
return error instanceof Error ? error : new Error('Unknown product repository error');
}
function isPgError(error: unknown, code: string): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
}

View File

@@ -0,0 +1,124 @@
import type pg from 'pg';
import type { Product, ProductSearchCriteria } from '../domain/product.js';
import type { ProductSearchRepository } from '../domain/ports.js';
import { PRODUCT_COLUMNS, type ProductRow, toProduct } from './pg-product-repository.js';
export class PgProductSearchRepository implements ProductSearchRepository {
constructor(private readonly pool: pg.Pool) {}
async search(input: ProductSearchCriteria): Promise<Product[]> {
const clauses: string[] = [];
const values: unknown[] = [];
const q = input.q?.trim();
if (input.activeOnly) {
clauses.push(`p.state = 'active'`);
}
if (input.brandSlug !== undefined && input.brandSlug.trim() !== '') {
values.push(input.brandSlug.trim());
clauses.push(`b.slug = $${values.length}`);
}
if (input.categorySlug !== undefined && input.categorySlug.trim() !== '') {
values.push(input.categorySlug.trim());
clauses.push(
`EXISTS (
SELECT 1 FROM catalog_product_categories filter_pc
JOIN categories_categories filter_c ON filter_c.id = filter_pc.category_id
WHERE filter_pc.product_id = p.id AND filter_c.slug = $${values.length}
)`,
);
}
const baseWhere = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
const searchWhere: string[] = [];
let rankSelect = '0::real AS search_rank';
let orderBy = 'created_at DESC, name ASC, id ASC';
if (q !== undefined && q !== '') {
// ILIKE on name: handles partial stems (e.g. 'almen' vs 'almendra').
// tsquery adds full-word relevance ranking.
// Word-by-word ILIKE fallback for fuzzy partial matching (FE-048).
values.push(`%${q}%`);
const ilikeParam = values.length;
values.push(q);
const tsqueryParam = values.length;
const tsquery = `websearch_to_tsquery('spanish', $${tsqueryParam})`;
// Word-level fuzzy: split query into words, match each against name/description.
const words = q.split(/\s+/).filter((w) => w.length >= 2);
const wordConditions = words
.map((w) => `(name ILIKE $${ilikeParam} OR description ILIKE $${ilikeParam})`)
.join(' AND ');
const wordFallback = words.length > 0 ? `(${wordConditions})` : 'TRUE';
searchWhere.push(
`(name ILIKE $${ilikeParam} OR description ILIKE $${ilikeParam} OR search_doc @@ ${tsquery} OR ${wordFallback})`,
);
// Composite ranking: text relevance + starts-with bonus
rankSelect = `
ts_rank_cd(search_doc, ${tsquery}) +
(CASE WHEN name ILIKE $${ilikeParam} THEN 1.0 ELSE 0 END) +
(CASE WHEN name ILIKE $${ilikeParam} THEN 0.5 ELSE 0 END)
AS search_rank`;
orderBy = 'search_rank DESC, created_at DESC, name ASC, id ASC';
}
values.push(input.limit, input.offset);
const where = searchWhere.length > 0 ? `WHERE ${searchWhere.join(' AND ')}` : '';
const result = await this.pool.query<ProductRow>(
`WITH search_basis AS (
SELECT
${PRODUCT_COLUMNS},
setweight(to_tsvector('spanish', COALESCE(p.name, '')), 'A') ||
setweight(to_tsvector('spanish', COALESCE(p.description, '')), 'B') ||
setweight(to_tsvector('spanish', COALESCE(p.seo_title, '')), 'B') ||
setweight(to_tsvector('spanish', COALESCE(p.seo_description, '')), 'C') ||
setweight(to_tsvector('spanish', COALESCE(b.name, '')), 'A') ||
setweight(to_tsvector('spanish', COALESCE(b.slug, '')), 'B') ||
setweight(to_tsvector('spanish', COALESCE(b.seo_title, '')), 'B') ||
setweight(to_tsvector('spanish', COALESCE(b.seo_description, '')), 'C') ||
setweight(to_tsvector('spanish', COALESCE(string_agg(DISTINCT c.name, ' '), '')), 'A') ||
setweight(to_tsvector('spanish', COALESCE(string_agg(DISTINCT c.slug, ' '), '')), 'B') ||
setweight(to_tsvector('spanish', COALESCE(string_agg(DISTINCT c.seo_title, ' '), '')), 'B') ||
setweight(to_tsvector('spanish', COALESCE(string_agg(DISTINCT c.seo_description, ' '), '')), 'C')
AS search_doc
FROM catalog_products p
LEFT JOIN brands_brands b ON b.id = p.brand_id
LEFT JOIN catalog_product_categories pc ON pc.product_id = p.id
LEFT JOIN categories_categories c ON c.id = pc.category_id
${baseWhere}
GROUP BY p.id, b.id
)
SELECT *, ${rankSelect}
FROM search_basis
${where}
ORDER BY ${orderBy}
LIMIT $${values.length - 1} OFFSET $${values.length}`,
values,
);
return result.rows.map(toProduct);
}
/** FE-048: Suggest term corrections via word-level ILIKE fallback. */
async suggestCorrections(term: string): Promise<string[]> {
const q = term.trim();
if (q.length < 2) return [];
// Find distinct words from products, brands, categories that share prefix with term
const result = await this.pool.query<{ suggestion: string }>(
`SELECT DISTINCT suggestion FROM (
SELECT DISTINCT LOWER(unnest(string_to_array(name, ' '))) AS suggestion
FROM catalog_products WHERE state = 'active'
UNION ALL
SELECT DISTINCT LOWER(unnest(string_to_array(name, ' '))) AS suggestion
FROM brands_brands
UNION ALL
SELECT DISTINCT LOWER(unnest(string_to_array(name, ' '))) AS suggestion
FROM categories_categories
) t
WHERE suggestion LIKE $1 AND suggestion <> $2
ORDER BY suggestion
LIMIT 5`,
[`${q}%`, q],
);
return result.rows.map((r) => r.suggestion);
}
}

View File

@@ -0,0 +1,90 @@
import type pg from 'pg';
import type { ProductRichDataRepository } from '../domain/ports.js';
import type { NutritionSource, ProductRichData, RichDataPatch } from '../domain/variant.js';
interface RichDataRow {
product_id: string;
ingredients: string | null;
allergens: string[];
nutrition: Record<string, unknown> | null;
nutrition_source: NutritionSource | null;
is_organic: boolean;
organic_certification: string | null;
created_at: Date;
updated_at: Date;
}
export class PgProductRichDataRepository implements ProductRichDataRepository {
constructor(private readonly pool: pg.Pool) {}
async findByProductId(productId: string): Promise<ProductRichData | undefined> {
const result = await this.pool.query<RichDataRow>(
'SELECT * FROM catalog_product_rich_data WHERE product_id = $1',
[productId],
);
const row = result.rows[0];
return row ? toRichData(row) : undefined;
}
async upsert(productId: string, patch: RichDataPatch): Promise<ProductRichData> {
const current = await this.findByProductId(productId);
const next = {
ingredients:
patch.ingredients !== undefined ? patch.ingredients : (current?.ingredients ?? null),
allergens: patch.allergens !== undefined ? patch.allergens : (current?.allergens ?? []),
nutrition: patch.nutrition !== undefined ? patch.nutrition : (current?.nutrition ?? null),
nutritionSource:
patch.nutritionSource !== undefined
? patch.nutritionSource
: (current?.nutritionSource ?? null),
isOrganic: patch.isOrganic !== undefined ? patch.isOrganic : (current?.isOrganic ?? false),
organicCertification:
patch.organicCertification !== undefined
? patch.organicCertification
: (current?.organicCertification ?? null),
};
const result = await this.pool.query<RichDataRow>(
`INSERT INTO catalog_product_rich_data
(product_id, ingredients, allergens, nutrition, nutrition_source, is_organic, organic_certification)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (product_id) DO UPDATE SET
ingredients = EXCLUDED.ingredients,
allergens = EXCLUDED.allergens,
nutrition = EXCLUDED.nutrition,
nutrition_source = EXCLUDED.nutrition_source,
is_organic = EXCLUDED.is_organic,
organic_certification = EXCLUDED.organic_certification,
updated_at = now()
RETURNING *`,
[
productId,
next.ingredients,
next.allergens,
next.nutrition,
next.nutritionSource,
next.isOrganic,
next.organicCertification,
],
);
const row = result.rows[0];
if (!row) {
throw new Error('catalog_product_rich_data UPSERT returned no row');
}
return toRichData(row);
}
}
function toRichData(row: RichDataRow): ProductRichData {
return {
productId: row.product_id,
ingredients: row.ingredients,
allergens: row.allergens,
nutrition: row.nutrition,
nutritionSource: row.nutrition_source,
isOrganic: row.is_organic,
organicCertification: row.organic_certification,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}

View File

@@ -0,0 +1,118 @@
import type pg from 'pg';
import { ProductVariantCodeAlreadyExistsError } from '../domain/errors.js';
import type { ProductVariantRepository } from '../domain/ports.js';
import type { NewProductVariant, ProductVariant, ProductVariantPatch } from '../domain/variant.js';
interface VariantRow {
id: string;
product_id: string;
sku: string;
ean: string | null;
attributes: Record<string, unknown>;
created_at: Date;
updated_at: Date;
}
const UNIQUE_VIOLATION = '23505';
const UPDATABLE: ReadonlyArray<[keyof ProductVariantPatch, string]> = [
['sku', 'sku'],
['ean', 'ean'],
['attributes', 'attributes'],
];
export class PgProductVariantRepository implements ProductVariantRepository {
constructor(private readonly pool: pg.Pool) {}
async listByProductId(productId: string): Promise<ProductVariant[]> {
const result = await this.pool.query<VariantRow>(
'SELECT * FROM catalog_product_variants WHERE product_id = $1 ORDER BY created_at, sku',
[productId],
);
return result.rows.map(toVariant);
}
async create(productId: string, input: NewProductVariant): Promise<ProductVariant> {
try {
const result = await this.pool.query<VariantRow>(
`INSERT INTO catalog_product_variants (product_id, sku, ean, attributes)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[productId, input.sku, input.ean ?? null, input.attributes ?? {}],
);
const row = result.rows[0];
if (!row) {
throw new Error('catalog_product_variants INSERT returned no row');
}
return toVariant(row);
} catch (error) {
if (isPgError(error, UNIQUE_VIOLATION)) {
throw new ProductVariantCodeAlreadyExistsError();
}
throw error;
}
}
async update(
productId: string,
variantId: string,
patch: ProductVariantPatch,
): Promise<ProductVariant | undefined> {
const setClauses: string[] = [];
const values: unknown[] = [];
for (const [key, column] of UPDATABLE) {
if (key in patch) {
values.push(patch[key]);
setClauses.push(`${column} = $${values.length}`);
}
}
if (setClauses.length === 0) {
return this.findScoped(productId, variantId);
}
values.push(productId, variantId);
try {
const result = await this.pool.query<VariantRow>(
`UPDATE catalog_product_variants SET ${setClauses.join(', ')}, updated_at = now()
WHERE product_id = $${values.length - 1} AND id = $${values.length}
RETURNING *`,
values,
);
const row = result.rows[0];
return row ? toVariant(row) : undefined;
} catch (error) {
if (isPgError(error, UNIQUE_VIOLATION)) {
throw new ProductVariantCodeAlreadyExistsError();
}
throw error;
}
}
private async findScoped(
productId: string,
variantId: string,
): Promise<ProductVariant | undefined> {
const result = await this.pool.query<VariantRow>(
'SELECT * FROM catalog_product_variants WHERE product_id = $1 AND id = $2',
[productId, variantId],
);
const row = result.rows[0];
return row ? toVariant(row) : undefined;
}
}
function toVariant(row: VariantRow): ProductVariant {
return {
id: row.id,
productId: row.product_id,
sku: row.sku,
ean: row.ean,
attributes: row.attributes,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function isPgError(error: unknown, code: string): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
}

View File

@@ -0,0 +1,16 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('catalog domain purity', () => {
it('has zero database or HTTP imports (AC1)', () => {
const domainDir = new URL('../domain', import.meta.url);
const files = readdirSync(domainDir).filter((file) => file.endsWith('.ts'));
for (const file of files) {
const source = readFileSync(join(domainDir.pathname, file), 'utf8');
expect(source).not.toMatch(/from ['"](?:pg|fastify|node:http)/);
expect(source).not.toMatch(/from ['"].*(?:api|infrastructure)/);
}
});
});

View File

@@ -0,0 +1,187 @@
import { describe, expect, it } from 'vitest';
import { AttachProductImage, ListProductImages } from '../application/image-use-cases.js';
import { ProductImageVariantMismatchError } from '../domain/errors.js';
import type { NewProductImage, ProductImage, ProductImageOrderItem } from '../domain/image.js';
import type { NewProduct, Product, ProductPatch } from '../domain/product.js';
import type { ProductImageRepository, ProductRepository } from '../domain/ports.js';
function product(input: Partial<Product> & Pick<Product, 'id' | 'name' | 'slug'>): Product {
return {
description: null,
state: 'draft',
channels: 'all',
featured: false,
attributes: [],
seoTitle: null,
seoDescription: null,
categoryIds: [],
brandId: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
...input,
};
}
function image(
input: Partial<ProductImage> & Pick<ProductImage, 'id' | 'productId' | 'url'>,
): ProductImage {
return {
variantId: null,
altText: 'Alt text',
position: 0,
role: 'gallery',
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
...input,
};
}
class FakeProductRepository implements ProductRepository {
constructor(private readonly products: Product[]) {}
async findById(id: string): Promise<Product | undefined> {
return this.products.find((item) => item.id === id);
}
async findActiveBySlug(slug: string): Promise<Product | undefined> {
return this.products.find((item) => item.slug === slug && item.state === 'active');
}
async create(input: NewProduct): Promise<Product> {
const created = product({
id: `prod-${this.products.length + 1}`,
name: input.name,
slug: input.slug,
});
this.products.push(created);
return created;
}
async update(id: string, patch: ProductPatch): Promise<Product | undefined> {
const current = await this.findById(id);
if (!current) return undefined;
Object.assign(current, patch);
return current;
}
async categoriesExist(_categoryIds: readonly string[]): Promise<boolean> {
return true;
}
async brandExists(_brandId: string): Promise<boolean> {
return true;
}
async delete(_id: string): Promise<void> {
// noop for tests
}
async listAll(options?: { limit?: number; offset?: number; q?: string }): Promise<{ items: Product[]; total: number }> {
const limit = options?.limit ?? 20;
const offset = options?.offset ?? 0;
let items = [...this.products];
if (options?.q) items = items.filter((p) => p.name.toLowerCase().includes(options.q!.toLowerCase()));
return { items: items.slice(offset, offset + limit), total: items.length };
}
}
class FakeImageRepository implements ProductImageRepository {
constructor(
private readonly images: ProductImage[],
private readonly validVariants: readonly string[] = [],
) {}
async listByProductId(
productId: string,
variantId: string | null = null,
): Promise<ProductImage[]> {
return this.images
.filter((item) => item.productId === productId && item.variantId === variantId)
.sort((a, b) => a.position - b.position || a.id.localeCompare(b.id));
}
async attach(productId: string, input: NewProductImage): Promise<ProductImage> {
const created = image({
id: `img-${this.images.length + 1}`,
productId,
variantId: input.variantId ?? null,
url: input.url,
altText: input.altText,
position: input.position ?? 0,
role: input.role,
});
this.images.push(created);
return created;
}
async detach(productId: string, imageId: string): Promise<boolean> {
const index = this.images.findIndex(
(item) => item.productId === productId && item.id === imageId,
);
if (index < 0) return false;
this.images.splice(index, 1);
return true;
}
async reorder(
productId: string,
items: readonly ProductImageOrderItem[],
): Promise<ProductImage[]> {
for (const item of items) {
const current = this.images.find(
(imageItem) => imageItem.productId === productId && imageItem.id === item.imageId,
);
if (current) current.position = item.position;
}
return this.listByProductId(productId);
}
async variantBelongsToProduct(_productId: string, variantId: string): Promise<boolean> {
return this.validVariants.includes(variantId);
}
}
describe('product image use cases', () => {
it('lists product images in stable order with alt text', async () => {
const images = new FakeImageRepository([
image({ id: 'b', productId: 'prod', url: '/b.jpg', altText: 'Second', position: 2 }),
image({ id: 'a', productId: 'prod', url: '/a.jpg', altText: 'First', position: 1 }),
]);
const result = await new ListProductImages(images).execute('prod');
expect(result.map((item) => ({ url: item.url, altText: item.altText }))).toEqual([
{ url: '/a.jpg', altText: 'First' },
{ url: '/b.jpg', altText: 'Second' },
]);
});
it('returns undefined when attaching an image to a missing product', async () => {
const products = new FakeProductRepository([]);
const images = new FakeImageRepository([]);
const result = await new AttachProductImage(products, images).execute('missing', {
url: '/image.jpg',
altText: 'Image',
role: 'main',
});
expect(result).toBeUndefined();
});
it('rejects a variant image when the variant does not belong to the product', async () => {
const products = new FakeProductRepository([
product({ id: 'prod', name: 'Aceite', slug: 'aceite' }),
]);
const images = new FakeImageRepository([], ['variant-owned-by-prod']);
await expect(
new AttachProductImage(products, images).execute('prod', {
url: '/variant.jpg',
altText: 'Variant',
role: 'gallery',
variantId: 'other-variant',
}),
).rejects.toBeInstanceOf(ProductImageVariantMismatchError);
});
});

View File

@@ -0,0 +1,152 @@
import { describe, expect, it } from 'vitest';
import { CreateProduct, SearchProducts, UpdateProduct } from '../application/product-use-cases.js';
import { ProductCategoryNotFoundError } from '../domain/errors.js';
import type {
NewProduct,
Product,
ProductPatch,
ProductSearchCriteria,
} from '../domain/product.js';
import type { ProductRepository, ProductSearchRepository } from '../domain/ports.js';
function product(input: Partial<Product> & Pick<Product, 'id' | 'name' | 'slug'>): Product {
return {
description: null,
state: 'draft',
channels: 'all',
featured: false,
attributes: [],
seoTitle: null,
seoDescription: null,
categoryIds: [],
brandId: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
...input,
};
}
class FakeProductRepository implements ProductRepository {
constructor(
private readonly products: Product[],
private readonly knownCategoryIds: readonly string[] = [],
) {}
async findById(id: string): Promise<Product | undefined> {
return this.products.find((item) => item.id === id);
}
async findActiveBySlug(slug: string): Promise<Product | undefined> {
return this.products.find((item) => item.slug === slug && item.state === 'active');
}
async create(input: NewProduct): Promise<Product> {
const created = product({
id: `prod-${this.products.length + 1}`,
name: input.name,
slug: input.slug,
description: input.description ?? null,
state: input.state ?? 'draft',
seoTitle: input.seoTitle ?? null,
seoDescription: input.seoDescription ?? null,
categoryIds: input.categoryIds ?? [],
brandId: input.brandId ?? null,
});
this.products.push(created);
return created;
}
async update(id: string, patch: ProductPatch): Promise<Product | undefined> {
const current = await this.findById(id);
if (!current) {
return undefined;
}
Object.assign(current, patch);
return current;
}
async categoriesExist(categoryIds: readonly string[]): Promise<boolean> {
return categoryIds.every((id) => this.knownCategoryIds.includes(id));
}
async brandExists(brandId: string): Promise<boolean> {
return brandId === 'known-brand';
}
async delete(_id: string): Promise<void> {
// noop for tests
}
async listAll(options?: { limit?: number; offset?: number; q?: string }): Promise<{ items: Product[]; total: number }> {
const limit = options?.limit ?? 20;
const offset = options?.offset ?? 0;
let items = [...this.products];
if (options?.q) items = items.filter((p) => p.name.toLowerCase().includes(options.q!.toLowerCase()));
return { items: items.slice(offset, offset + limit), total: items.length };
}
}
class FakeProductSearchRepository implements ProductSearchRepository {
public lastInput: ProductSearchCriteria | undefined;
constructor(private readonly products: Product[]) {}
async search(input: ProductSearchCriteria): Promise<Product[]> {
this.lastInput = input;
return this.products.filter((item) => !input.activeOnly || item.state === 'active');
}
async suggestCorrections(_term: string): Promise<string[]> {
return [];
}
}
describe('product use cases', () => {
it('search returns only active products', async () => {
const repo = new FakeProductSearchRepository([
product({ id: 'draft', name: 'Draft', slug: 'draft', state: 'draft' }),
product({ id: 'active', name: 'Active', slug: 'active', state: 'active' }),
product({ id: 'archived', name: 'Archived', slug: 'archived', state: 'archived' }),
]);
const result = await new SearchProducts(repo).execute({
q: 'active',
limit: 10,
offset: 5,
brandSlug: 'marca',
categorySlug: 'categoria',
});
expect(result.map((item) => item.slug)).toEqual(['active']);
expect(repo.lastInput).toEqual({
q: 'active',
limit: 10,
offset: 5,
activeOnly: true,
brandSlug: 'marca',
categorySlug: 'categoria',
});
});
it('create defaults product state to draft', async () => {
const repo = new FakeProductRepository([]);
const created = await new CreateProduct(repo).execute({ name: 'Aceite', slug: 'aceite' });
expect(created.state).toBe('draft');
});
it('create and update reject unknown category ids', async () => {
const repo = new FakeProductRepository(
[product({ id: 'prod', name: 'Aceite', slug: 'aceite' })],
['known-category'],
);
await expect(
new CreateProduct(repo).execute({ name: 'Pan', slug: 'pan', categoryIds: ['missing'] }),
).rejects.toBeInstanceOf(ProductCategoryNotFoundError);
await expect(
new UpdateProduct(repo).execute('prod', { categoryIds: ['missing'] }),
).rejects.toBeInstanceOf(ProductCategoryNotFoundError);
});
});

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { protectManualNutrition } from '../application/variant-use-cases.js';
import type { ProductRichData } from '../domain/variant.js';
function richData(input: Partial<ProductRichData> = {}): ProductRichData {
return {
productId: 'product-id',
ingredients: null,
allergens: [],
nutrition: null,
nutritionSource: null,
isOrganic: false,
organicCertification: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
...input,
};
}
describe('rich data use cases', () => {
it('keeps manual nutrition when an external source tries to overwrite it', () => {
const current = richData({
nutrition: { calories: 100 },
nutritionSource: 'manual',
});
const patch = protectManualNutrition(current, {
nutrition: { calories: 200 },
nutritionSource: 'openfoodfacts',
ingredients: 'Updated ingredients',
});
expect(patch).toEqual({ ingredients: 'Updated ingredients' });
});
it('allows manual nutrition to overwrite external nutrition', () => {
const current = richData({
nutrition: { calories: 100 },
nutritionSource: 'openfoodfacts',
});
const patch = protectManualNutrition(current, {
nutrition: { calories: 200 },
nutritionSource: 'manual',
});
expect(patch).toEqual({ nutrition: { calories: 200 }, nutritionSource: 'manual' });
});
});