feat(ADM-018): completed feature
This commit is contained in:
479
project/src/modules/catalog/api/catalog.routes.ts
Normal file
479
project/src/modules/catalog/api/catalog.routes.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
51
project/src/modules/catalog/application/image-use-cases.ts
Normal file
51
project/src/modules/catalog/application/image-use-cases.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
102
project/src/modules/catalog/application/product-use-cases.ts
Normal file
102
project/src/modules/catalog/application/product-use-cases.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
78
project/src/modules/catalog/application/variant-use-cases.ts
Normal file
78
project/src/modules/catalog/application/variant-use-cases.ts
Normal 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;
|
||||
}
|
||||
41
project/src/modules/catalog/domain/errors.ts
Normal file
41
project/src/modules/catalog/domain/errors.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
33
project/src/modules/catalog/domain/image.ts
Normal file
33
project/src/modules/catalog/domain/image.ts
Normal 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;
|
||||
}
|
||||
52
project/src/modules/catalog/domain/ports.ts
Normal file
52
project/src/modules/catalog/domain/ports.ts
Normal 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>;
|
||||
}
|
||||
74
project/src/modules/catalog/domain/product.ts
Normal file
74
project/src/modules/catalog/domain/product.ts
Normal 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;
|
||||
}
|
||||
48
project/src/modules/catalog/domain/variant.ts
Normal file
48
project/src/modules/catalog/domain/variant.ts
Normal 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;
|
||||
}
|
||||
2
project/src/modules/catalog/index.ts
Normal file
2
project/src/modules/catalog/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** Public API of the catalog module. */
|
||||
export { registerCatalogRoutes, type CatalogRoutesDeps } from './api/catalog.routes.js';
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
16
project/src/modules/catalog/tests/domain-purity.test.ts
Normal file
16
project/src/modules/catalog/tests/domain-purity.test.ts
Normal 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)/);
|
||||
}
|
||||
});
|
||||
});
|
||||
187
project/src/modules/catalog/tests/image-use-cases.test.ts
Normal file
187
project/src/modules/catalog/tests/image-use-cases.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
152
project/src/modules/catalog/tests/product-use-cases.test.ts
Normal file
152
project/src/modules/catalog/tests/product-use-cases.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user