feat(ADM-018): completed feature

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

View File

@@ -0,0 +1,151 @@
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 {
CreateCategory,
DeleteCategory,
GetCategoryBySlug,
ListCategoryTree,
UpdateCategory,
} from '../application/category-use-cases.js';
import type { Category, CategoryTreeNode } from '../domain/category.js';
import {
CategoryParentNotFoundError,
CategorySlugAlreadyExistsError,
CategoryTreeCycleError,
} from '../domain/errors.js';
import { PgCategoryRepository } from '../infrastructure/pg-category-repository.js';
export interface CategoriesRoutesDeps {
pool: pg.Pool;
authenticate: Authenticate;
}
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 newCategorySchema = z.object({
parentId: z.uuid().optional().nullable(),
name: z.string().min(1).max(200),
slug: slugSchema,
seoTitle: z.string().min(1).max(200).optional().nullable(),
seoDescription: z.string().min(1).max(500).optional().nullable(),
});
const categoryPatchSchema = newCategorySchema
.partial()
.refine((value) => Object.values(value).some((field) => field !== undefined), {
message: 'At least one category field is required',
});
export async function registerCategoriesRoutes(
app: FastifyInstance,
deps: CategoriesRoutesDeps,
): Promise<void> {
const repository = new PgCategoryRepository(deps.pool);
const getBySlug = new GetCategoryBySlug(repository);
const listTree = new ListCategoryTree(repository);
const createCategory = new CreateCategory(repository);
const updateCategory = new UpdateCategory(repository);
const deleteCategory = new DeleteCategory(repository);
app.get('/categories/tree', async (_request, reply) => {
const items = await listTree.execute();
return reply.send({ items: items.map(serializeTreeNode) });
});
app.get('/categoria/:slug', async (request, reply) => {
const { slug } = parseJson(slugParamSchema, request.params);
const category = await getBySlug.execute(slug);
if (!category) {
throw new AppError(404, 'NOT_FOUND', 'Category not found');
}
return reply.send(serializeCategory(category));
});
app.post('/categories', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const input = parseJson(newCategorySchema, request.body);
try {
const category = await createCategory.execute(input);
return reply.code(201).send(serializeCategory(category));
} catch (error) {
throw mapCategoryError(error);
}
});
app.patch('/categories/:id', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
const patch = parseJson(categoryPatchSchema, request.body);
try {
const category = await updateCategory.execute(id, patch);
if (!category) {
throw new AppError(404, 'NOT_FOUND', 'Category not found');
}
return reply.send(serializeCategory(category));
} catch (error) {
throw mapCategoryError(error);
}
});
app.delete('/categories/:id', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
const result = await deleteCategory.execute(id);
if (result === 'not_found') {
throw new AppError(404, 'NOT_FOUND', 'Category not found');
}
if (result === 'has_children') {
throw new AppError(409, 'CATEGORY_HAS_CHILDREN', 'Category has child categories');
}
return reply.code(204).send();
});
}
function mapCategoryError(error: unknown): Error {
if (error instanceof CategorySlugAlreadyExistsError) {
return new AppError(409, 'CATEGORY_SLUG_EXISTS', error.message);
}
if (error instanceof CategoryParentNotFoundError) {
return new AppError(422, 'CATEGORY_PARENT_NOT_FOUND', error.message);
}
if (error instanceof CategoryTreeCycleError) {
return new AppError(422, 'CATEGORY_TREE_CYCLE', error.message);
}
return error instanceof Error ? error : new Error('Unknown category error');
}
function serializeCategory(category: Category) {
return {
id: category.id,
parentId: category.parentId,
name: category.name,
slug: category.slug,
url: `/categoria/${category.slug}`,
seoTitle: category.seoTitle,
seoDescription: category.seoDescription,
createdAt: category.createdAt.toISOString(),
updatedAt: category.updatedAt.toISOString(),
};
}
function serializeTreeNode(category: CategoryTreeNode): ReturnType<typeof serializeCategory> & {
children: ReturnType<typeof serializeTreeNode>[];
} {
return {
...serializeCategory(category),
children: category.children.map(serializeTreeNode),
};
}

View File

@@ -0,0 +1,105 @@
import type { Category, CategoryPatch, CategoryTreeNode, NewCategory } from '../domain/category.js';
import { CategoryParentNotFoundError, CategoryTreeCycleError } from '../domain/errors.js';
import type { CategoryRepository } from '../domain/ports.js';
export class GetCategoryBySlug {
constructor(private readonly categories: CategoryRepository) {}
async execute(slug: string): Promise<Category | undefined> {
return this.categories.findBySlug(slug);
}
}
export class ListCategoryTree {
constructor(private readonly categories: CategoryRepository) {}
async execute(): Promise<CategoryTreeNode[]> {
return buildTree(await this.categories.list());
}
}
export class CreateCategory {
constructor(private readonly categories: CategoryRepository) {}
async execute(input: NewCategory): Promise<Category> {
await this.assertParentExists(input.parentId);
return this.categories.create(input);
}
private async assertParentExists(parentId: string | null | undefined): Promise<void> {
if (parentId === undefined || parentId === null) {
return;
}
const parent = await this.categories.findById(parentId);
if (!parent) {
throw new CategoryParentNotFoundError();
}
}
}
export class UpdateCategory {
constructor(private readonly categories: CategoryRepository) {}
async execute(id: string, patch: CategoryPatch): Promise<Category | undefined> {
if (patch.parentId !== undefined) {
await this.assertValidParent(id, patch.parentId);
}
return this.categories.update(id, patch);
}
private async assertValidParent(id: string, parentId: string | null): Promise<void> {
if (parentId === null) {
return;
}
if (parentId === id) {
throw new CategoryTreeCycleError();
}
const parent = await this.categories.findById(parentId);
if (!parent) {
throw new CategoryParentNotFoundError();
}
const parentIsDescendant = await this.categories.isDescendant(id, parentId);
if (parentIsDescendant) {
throw new CategoryTreeCycleError();
}
}
}
export type DeleteCategoryResult = 'deleted' | 'not_found' | 'has_children';
export class DeleteCategory {
constructor(private readonly categories: CategoryRepository) {}
async execute(id: string): Promise<DeleteCategoryResult> {
const category = await this.categories.findById(id);
if (!category) {
return 'not_found';
}
if (await this.categories.hasChildren(id)) {
return 'has_children';
}
return (await this.categories.delete(id)) ? 'deleted' : 'not_found';
}
}
function buildTree(categories: Category[]): CategoryTreeNode[] {
const nodes = new Map<string, CategoryTreeNode>();
for (const category of categories) {
nodes.set(category.id, { ...category, children: [] });
}
const roots: CategoryTreeNode[] = [];
for (const node of nodes.values()) {
if (node.parentId === null) {
roots.push(node);
continue;
}
const parent = nodes.get(node.parentId);
if (parent) {
parent.children.push(node);
} else {
roots.push(node);
}
}
return roots;
}

View File

@@ -0,0 +1,28 @@
/**
* Category domain model. Public storefront URLs use slug; id is internal.
*/
export interface Category {
id: string;
parentId: string | null;
name: string;
slug: string;
seoTitle: string | null;
seoDescription: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface NewCategory {
parentId?: string | null;
name: string;
slug: string;
seoTitle?: string | null;
seoDescription?: string | null;
}
/** Fields a category update may set. Undefined = leave unchanged; parentId null = move to root. */
export type CategoryPatch = Partial<NewCategory>;
export interface CategoryTreeNode extends Category {
children: CategoryTreeNode[];
}

View File

@@ -0,0 +1,20 @@
export class CategorySlugAlreadyExistsError extends Error {
constructor() {
super('Category slug already exists');
this.name = 'CategorySlugAlreadyExistsError';
}
}
export class CategoryParentNotFoundError extends Error {
constructor() {
super('Category parent not found');
this.name = 'CategoryParentNotFoundError';
}
}
export class CategoryTreeCycleError extends Error {
constructor() {
super('Category parent would create a cycle');
this.name = 'CategoryTreeCycleError';
}
}

View File

@@ -0,0 +1,12 @@
import type { Category, CategoryPatch, NewCategory } from './category.js';
export interface CategoryRepository {
list(): Promise<Category[]>;
findById(id: string): Promise<Category | undefined>;
findBySlug(slug: string): Promise<Category | undefined>;
create(input: NewCategory): Promise<Category>;
update(id: string, patch: CategoryPatch): Promise<Category | undefined>;
delete(id: string): Promise<boolean>;
hasChildren(id: string): Promise<boolean>;
isDescendant(candidateAncestorId: string, candidateDescendantId: string): Promise<boolean>;
}

View File

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

View File

@@ -0,0 +1,156 @@
import type pg from 'pg';
import type { Category, CategoryPatch, NewCategory } from '../domain/category.js';
import { CategorySlugAlreadyExistsError } from '../domain/errors.js';
import type { CategoryRepository } from '../domain/ports.js';
interface CategoryRow {
id: string;
parent_id: string | null;
name: string;
slug: string;
seo_title: string | null;
seo_description: string | null;
created_at: Date;
updated_at: Date;
}
const UNIQUE_VIOLATION = '23505';
const UPDATABLE: ReadonlyArray<[keyof CategoryPatch, string]> = [
['parentId', 'parent_id'],
['name', 'name'],
['slug', 'slug'],
['seoTitle', 'seo_title'],
['seoDescription', 'seo_description'],
];
export class PgCategoryRepository implements CategoryRepository {
constructor(private readonly pool: pg.Pool) {}
async list(): Promise<Category[]> {
const result = await this.pool.query<CategoryRow>(
`SELECT * FROM categories_categories ORDER BY parent_id NULLS FIRST, name, created_at`,
);
return result.rows.map(toCategory);
}
async findById(id: string): Promise<Category | undefined> {
const result = await this.pool.query<CategoryRow>(
'SELECT * FROM categories_categories WHERE id = $1',
[id],
);
const row = result.rows[0];
return row ? toCategory(row) : undefined;
}
async findBySlug(slug: string): Promise<Category | undefined> {
const result = await this.pool.query<CategoryRow>(
'SELECT * FROM categories_categories WHERE slug = $1',
[slug],
);
const row = result.rows[0];
return row ? toCategory(row) : undefined;
}
async create(input: NewCategory): Promise<Category> {
try {
const result = await this.pool.query<CategoryRow>(
`INSERT INTO categories_categories (parent_id, name, slug, seo_title, seo_description)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[
input.parentId ?? null,
input.name,
input.slug,
input.seoTitle ?? null,
input.seoDescription ?? null,
],
);
const row = result.rows[0];
if (!row) {
throw new Error('categories_categories INSERT returned no row');
}
return toCategory(row);
} catch (error) {
if (isPgError(error, UNIQUE_VIOLATION)) {
throw new CategorySlugAlreadyExistsError();
}
throw error;
}
}
async update(id: string, patch: CategoryPatch): Promise<Category | 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.findById(id);
}
values.push(id);
try {
const result = await this.pool.query<CategoryRow>(
`UPDATE categories_categories SET ${setClauses.join(', ')}, updated_at = now()
WHERE id = $${values.length}
RETURNING *`,
values,
);
const row = result.rows[0];
return row ? toCategory(row) : undefined;
} catch (error) {
if (isPgError(error, UNIQUE_VIOLATION)) {
throw new CategorySlugAlreadyExistsError();
}
throw error;
}
}
async delete(id: string): Promise<boolean> {
const result = await this.pool.query('DELETE FROM categories_categories WHERE id = $1', [id]);
return (result.rowCount ?? 0) > 0;
}
async hasChildren(id: string): Promise<boolean> {
const result = await this.pool.query<{ exists: boolean }>(
'SELECT EXISTS (SELECT 1 FROM categories_categories WHERE parent_id = $1)',
[id],
);
return result.rows[0]?.exists ?? false;
}
async isDescendant(candidateAncestorId: string, candidateDescendantId: string): Promise<boolean> {
const result = await this.pool.query<{ exists: boolean }>(
`WITH RECURSIVE descendants AS (
SELECT id FROM categories_categories WHERE parent_id = $1
UNION ALL
SELECT c.id FROM categories_categories c
INNER JOIN descendants d ON c.parent_id = d.id
)
SELECT EXISTS (SELECT 1 FROM descendants WHERE id = $2)`,
[candidateAncestorId, candidateDescendantId],
);
return result.rows[0]?.exists ?? false;
}
}
function toCategory(row: CategoryRow): Category {
return {
id: row.id,
parentId: row.parent_id,
name: row.name,
slug: row.slug,
seoTitle: row.seo_title,
seoDescription: row.seo_description,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function isPgError(error: unknown, code: string): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
}

View File

@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest';
import {
CreateCategory,
ListCategoryTree,
UpdateCategory,
} from '../application/category-use-cases.js';
import type { Category, CategoryPatch, NewCategory } from '../domain/category.js';
import { CategoryParentNotFoundError, CategoryTreeCycleError } from '../domain/errors.js';
import type { CategoryRepository } from '../domain/ports.js';
function category(input: Partial<Category> & Pick<Category, 'id' | 'name' | 'slug'>): Category {
return {
parentId: null,
seoTitle: null,
seoDescription: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
...input,
};
}
class FakeCategoryRepository implements CategoryRepository {
constructor(private readonly categories: Category[]) {}
async list(): Promise<Category[]> {
return this.categories;
}
async findById(id: string): Promise<Category | undefined> {
return this.categories.find((item) => item.id === id);
}
async findBySlug(slug: string): Promise<Category | undefined> {
return this.categories.find((item) => item.slug === slug);
}
async create(input: NewCategory): Promise<Category> {
const created = category({
id: `cat-${this.categories.length + 1}`,
parentId: input.parentId ?? null,
name: input.name,
slug: input.slug,
seoTitle: input.seoTitle ?? null,
seoDescription: input.seoDescription ?? null,
});
this.categories.push(created);
return created;
}
async update(id: string, patch: CategoryPatch): Promise<Category | undefined> {
const current = await this.findById(id);
if (!current) {
return undefined;
}
Object.assign(current, patch);
return current;
}
async delete(id: string): Promise<boolean> {
const index = this.categories.findIndex((item) => item.id === id);
if (index === -1) {
return false;
}
this.categories.splice(index, 1);
return true;
}
async hasChildren(id: string): Promise<boolean> {
return this.categories.some((item) => item.parentId === id);
}
async isDescendant(candidateAncestorId: string, candidateDescendantId: string): Promise<boolean> {
let current = await this.findById(candidateDescendantId);
while (current?.parentId) {
if (current.parentId === candidateAncestorId) {
return true;
}
current = await this.findById(current.parentId);
}
return false;
}
}
describe('category use cases', () => {
it('builds a parent/child tree', async () => {
const repo = new FakeCategoryRepository([
category({ id: 'root', name: 'Alimentación', slug: 'alimentacion' }),
category({ id: 'child', parentId: 'root', name: 'Aceites', slug: 'aceites' }),
]);
const tree = await new ListCategoryTree(repo).execute();
expect(tree).toHaveLength(1);
expect(tree[0]?.slug).toBe('alimentacion');
expect(tree[0]?.children[0]?.slug).toBe('aceites');
});
it('rejects unknown parent on create', async () => {
const repo = new FakeCategoryRepository([]);
await expect(
new CreateCategory(repo).execute({ parentId: 'missing', name: 'Aceites', slug: 'aceites' }),
).rejects.toBeInstanceOf(CategoryParentNotFoundError);
});
it('rejects self-parent and descendant-as-parent updates', async () => {
const repo = new FakeCategoryRepository([
category({ id: 'root', name: 'Alimentación', slug: 'alimentacion' }),
category({ id: 'child', parentId: 'root', name: 'Aceites', slug: 'aceites' }),
category({ id: 'grandchild', parentId: 'child', name: 'Oliva', slug: 'oliva' }),
]);
const update = new UpdateCategory(repo);
await expect(update.execute('root', { parentId: 'root' })).rejects.toBeInstanceOf(
CategoryTreeCycleError,
);
await expect(update.execute('root', { parentId: 'grandchild' })).rejects.toBeInstanceOf(
CategoryTreeCycleError,
);
});
});