feat(F-048): completed feature

This commit is contained in:
chattie
2026-08-19 07:17:14 +02:00
parent 8ee1938af9
commit 835ab66eda
187 changed files with 12361 additions and 1065 deletions

View File

@@ -1,10 +1,12 @@
import { performance } from 'node:perf_hooks';
import type { FastifyInstance } from 'fastify';
import type { FastifySchema } 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 { errorSchema } from '../../../shared/swagger.js';
import {
AttachProductImage,
DetachProductImage,
@@ -121,7 +123,7 @@ const imageUrlSchema = z
const newImageSchema = z.object({
url: imageUrlSchema,
altText: z.string().trim().min(1).max(300),
altText: z.string().trim().max(300).optional().default(''),
role: z.enum(PRODUCT_IMAGE_ROLES),
variantId: z.uuid().optional().nullable(),
position: z.number().int().min(0).optional(),
@@ -180,7 +182,21 @@ export async function registerCatalogRoutes(
const reorderImages = new ReorderProductImages(images);
// Admin: list all products (any state)
app.get('/catalog/products', async (request, reply) => {
const catalogListSchema: FastifySchema = {
tags: ['Admin Products'],
summary: 'List all products (admin)',
description: 'Lista todos los productos incluyendo los no activos. Requiere rol admin.',
querystring: {
type: 'object',
properties: {
q: { type: 'string', description: 'Búsqueda por nombre' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
offset: { type: 'integer', minimum: 0, default: 0 },
},
},
response: { 401: errorSchema, 403: errorSchema },
};
app.get('/catalog/products', { schema: catalogListSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const q = (request.query as { q?: string }).q;
@@ -190,7 +206,18 @@ export async function registerCatalogRoutes(
return reply.send({ items: result.items.map((p) => serializeProduct(p)), total: result.total });
});
app.get('/productos/:slug', async (request, reply) => {
const publicProductSchema: FastifySchema = {
tags: ['Catalog'],
summary: 'Get product by slug (público)',
description: 'Devuelve un producto activo con todas sus imágenes.',
params: {
type: 'object',
required: ['slug'],
properties: { slug: { type: 'string', description: 'URL slug del producto' } },
},
response: { 404: errorSchema },
};
app.get('/productos/:slug', { schema: publicProductSchema }, async (request, reply) => {
const { slug } = parseJson(slugParamSchema, request.params);
const product = await getBySlug.execute(slug);
if (!product) {
@@ -200,8 +227,18 @@ export async function registerCatalogRoutes(
return reply.send(serializeProduct(product, productImages));
});
// Admin: get product by ID (for editor)
app.get('/products/:id', async (request, reply) => {
const adminGetProductSchema: FastifySchema = {
tags: ['Admin Products'],
summary: 'Get product by ID (admin)',
description: 'Obtiene un producto por su UUID (incluye no activos). Requiere admin.',
params: {
type: 'object',
required: ['id'],
properties: { id: { type: 'string', format: 'uuid' } },
},
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
};
app.get('/products/:id', { schema: adminGetProductSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
@@ -213,7 +250,23 @@ export async function registerCatalogRoutes(
return reply.send(serializeProduct(product, productImages));
});
app.get('/products/search', async (request, reply) => {
const searchSchema: FastifySchema = {
tags: ['Catalog'],
summary: 'Search products (público)',
description: 'Búsqueda full-text con filtros opcionales por marca y categoría.',
querystring: {
type: 'object',
properties: {
q: { type: 'string', maxLength: 200, description: 'Consulta de búsqueda' },
brandSlug: { type: 'string', description: 'Slug de marca' },
categorySlug: { type: 'string', description: 'Slug de categoría' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
offset: { type: 'integer', minimum: 0, default: 0 },
},
},
response: { 200: { type: 'object', properties: { items: { type: 'array' } } } },
};
app.get('/products/search', { schema: searchSchema }, async (request, reply) => {
const input = parseJson(searchQuerySchema, request.query);
const startedAt = performance.now();
const items = await searchProducts.execute(input);
@@ -234,16 +287,36 @@ export async function registerCatalogRoutes(
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 suggestSchema: FastifySchema = {
tags: ['Catalog'],
summary: 'Search suggestions',
description: 'Devuelve correcciones sugeridas para la consulta de búsqueda.',
querystring: {
type: 'object',
required: ['q'],
properties: { q: { type: 'string', minLength: 2, maxLength: 200 } },
},
response: {
200: {
type: 'object',
properties: { suggestions: { type: 'array', items: { type: 'string' } } },
},
},
};
app.get('/products/suggest', { schema: suggestSchema }, 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 createProductSchema: FastifySchema = {
tags: ['Admin Products'],
summary: 'Create product',
description: 'Crea un nuevo producto. Requiere rol admin.',
body: { type: 'object' },
response: { 201: { type: 'object' }, 401: errorSchema, 403: errorSchema },
};
app.post('/products', { schema: createProductSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const input = parseJson(newProductSchema, request.body);
@@ -255,19 +328,49 @@ export async function registerCatalogRoutes(
}
});
app.get('/products/:id/variants', async (request, reply) => {
const variantsSchema: FastifySchema = {
tags: ['Admin Products'],
summary: 'List product variants',
description: 'Lista las variantes de un producto.',
params: {
type: 'object',
required: ['id'],
properties: { id: { type: 'string', format: 'uuid' } },
},
};
app.get('/products/:id/variants', { schema: variantsSchema }, 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 imagesSchema: FastifySchema = {
tags: ['Admin Products'],
summary: 'List product images',
params: {
type: 'object',
required: ['id'],
properties: { id: { type: 'string', format: 'uuid' } },
},
};
app.get('/products/:id/images', { schema: imagesSchema }, 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 attachImageSchema: FastifySchema = {
tags: ['Admin Products'],
summary: 'Attach image to product',
params: {
type: 'object',
required: ['id'],
properties: { id: { type: 'string', format: 'uuid' } },
},
body: { type: 'object' },
response: { 201: { type: 'object' }, 401: errorSchema, 403: errorSchema, 404: errorSchema },
};
app.post('/products/:id/images', { schema: attachImageSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
@@ -283,27 +386,70 @@ export async function registerCatalogRoutes(
}
});
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();
});
const deleteImageSchema: FastifySchema = {
tags: ['Admin Products'],
summary: 'Delete product image',
params: {
type: 'object',
required: ['id', 'imageId'],
properties: {
id: { type: 'string', format: 'uuid' },
imageId: { type: 'string', format: 'uuid' },
},
},
response: { 204: { type: 'null' }, 401: errorSchema, 403: errorSchema, 404: errorSchema },
};
app.delete(
'/products/:id/images/:imageId',
{ schema: deleteImageSchema },
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) });
});
const reorderImagesSwaggerSchema: FastifySchema = {
tags: ['Admin Products'],
summary: 'Reorder product images',
params: {
type: 'object',
required: ['id'],
properties: { id: { type: 'string', format: 'uuid' } },
},
body: { type: 'object' },
response: { 401: errorSchema, 403: errorSchema },
};
app.patch(
'/products/:id/images/reorder',
{ schema: reorderImagesSwaggerSchema },
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 createVariantSchema: FastifySchema = {
tags: ['Admin Products'],
summary: 'Create product variant',
params: {
type: 'object',
required: ['id'],
properties: { id: { type: 'string', format: 'uuid' } },
},
body: { type: 'object' },
response: { 201: { type: 'object' }, 401: errorSchema, 403: errorSchema },
};
app.post('/products/:id/variants', { schema: createVariantSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
@@ -319,23 +465,53 @@ export async function registerCatalogRoutes(
}
});
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');
const updateVariantSchema: FastifySchema = {
tags: ['Admin Products'],
summary: 'Update product variant',
params: {
type: 'object',
required: ['id', 'variantId'],
properties: {
id: { type: 'string', format: 'uuid' },
variantId: { type: 'string', format: 'uuid' },
},
},
body: { type: 'object' },
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
};
app.patch(
'/products/:id/variants/:variantId',
{ schema: updateVariantSchema },
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);
}
return reply.send(serializeVariant(variant));
} catch (error) {
throw mapProductError(error);
}
});
},
);
app.patch('/products/:id/rich-data', async (request, reply) => {
const richDataSchema2: FastifySchema = {
tags: ['Admin Products'],
summary: 'Set product rich data',
description: 'Establece datos ricos: ingredientes, alérgenos, nutrición, orgánico.',
params: {
type: 'object',
required: ['id'],
properties: { id: { type: 'string', format: 'uuid' } },
},
body: { type: 'object' },
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
};
app.patch('/products/:id/rich-data', { schema: richDataSchema2 }, async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
@@ -347,7 +523,18 @@ export async function registerCatalogRoutes(
return reply.send(serializeRichData(data));
});
app.patch('/products/:id', async (request, reply) => {
const updateProductSchema: FastifySchema = {
tags: ['Admin Products'],
summary: 'Update product',
params: {
type: 'object',
required: ['id'],
properties: { id: { type: 'string', format: 'uuid' } },
},
body: { type: 'object' },
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
};
app.patch('/products/:id', { schema: updateProductSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
@@ -363,8 +550,25 @@ export async function registerCatalogRoutes(
}
});
// PATCH /products/:id/state — change product state
app.patch('/products/:id/state', async (request, reply) => {
const setStateSchema: FastifySchema = {
tags: ['Admin Products'],
summary: 'Change product state',
description: 'Cambia el estado del producto (draft, active, archived).',
params: {
type: 'object',
required: ['id'],
properties: { id: { type: 'string', format: 'uuid' } },
},
body: {
type: 'object',
required: ['state'],
properties: {
state: { type: 'string', enum: ['draft', 'active', 'archived', 'pending_review'] },
},
},
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
};
app.patch('/products/:id/state', { schema: setStateSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
@@ -376,8 +580,17 @@ export async function registerCatalogRoutes(
return reply.send(serializeProduct(product));
});
// DELETE /products/:id
app.delete('/products/:id', async (request, reply) => {
const deleteProductSchema: FastifySchema = {
tags: ['Admin Products'],
summary: 'Delete product',
params: {
type: 'object',
required: ['id'],
properties: { id: { type: 'string', format: 'uuid' } },
},
response: { 204: { type: 'null' }, 401: errorSchema, 403: errorSchema },
};
app.delete('/products/:id', { schema: deleteProductSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);

View File

@@ -14,7 +14,11 @@ export interface ProductRepository {
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 }>;
listAll(options?: {
limit?: number;
offset?: number;
q?: string;
}): Promise<{ items: Product[]; total: number }>;
categoriesExist(categoryIds: readonly string[]): Promise<boolean>;
brandExists(brandId: string): Promise<boolean>;
}

View File

@@ -28,7 +28,7 @@ export const PRODUCT_ATTRIBUTES = [
'zero-waste',
] as const;
export type ProductAttribute = typeof PRODUCT_ATTRIBUTES[number];
export type ProductAttribute = (typeof PRODUCT_ATTRIBUTES)[number];
export interface Product {
id: string;

View File

@@ -1,6 +1,12 @@
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 {
NewProduct,
Product,
ProductAttribute,
ProductPatch,
ProductState,
} from '../domain/product.js';
import type { ProductRepository } from '../domain/ports.js';
export interface ProductRow {
@@ -239,12 +245,27 @@ export function toProduct(row: ProductRow): Product {
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),
? (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,

View File

@@ -46,7 +46,7 @@ export class PgProductSearchRepository implements ProductSearchRepository {
// 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})`)
.map(() => `(name ILIKE $${ilikeParam} OR description ILIKE $${ilikeParam})`)
.join(' AND ');
const wordFallback = words.length > 0 ? `(${wordConditions})` : 'TRUE';
searchWhere.push(

View File

@@ -76,11 +76,16 @@ class FakeProductRepository implements ProductRepository {
// noop for tests
}
async listAll(options?: { limit?: number; offset?: number; q?: string }): Promise<{ items: Product[]; total: number }> {
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()));
if (options?.q)
items = items.filter((p) => p.name.toLowerCase().includes(options.q!.toLowerCase()));
return { items: items.slice(offset, offset + limit), total: items.length };
}
}

View File

@@ -77,11 +77,16 @@ class FakeProductRepository implements ProductRepository {
// noop for tests
}
async listAll(options?: { limit?: number; offset?: number; q?: string }): Promise<{ items: Product[]; total: number }> {
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()));
if (options?.q)
items = items.filter((p) => p.name.toLowerCase().includes(options.q!.toLowerCase()));
return { items: items.slice(offset, offset + limit), total: items.length };
}
}