feat(F-103): completed feature

This commit is contained in:
chattie
2026-08-21 07:55:18 +02:00
parent 5177a851aa
commit c07776822d
40 changed files with 886 additions and 156 deletions

View File

@@ -21,6 +21,7 @@ import {
CategoryTreeCycleError,
} from '../domain/errors.js';
import { PgCategoryRepository } from '../infrastructure/pg-category-repository.js';
import { generateWithModel, loadAiSettings, requireAiConfigured } from '../../../shared/ai-content.js';
export interface CategoriesRoutesDeps {
pool: pg.Pool;
@@ -41,9 +42,12 @@ const newCategorySchema = z.object({
slug: slugSchema,
seoTitle: z.string().min(1).max(200).optional().nullable(),
seoDescription: z.string().min(1).max(500).optional().nullable(),
description: z.string().min(1).max(4000).optional().nullable(),
isParent: z.boolean().optional(),
emoji: z.string().max(10).optional().nullable(),
color: z.string().max(200).optional().nullable(),
bgColor: z.string().max(20).optional().nullable(),
textColor: z.string().max(20).optional().nullable(),
});
const categoryPatchSchema = newCategorySchema
@@ -156,6 +160,81 @@ export async function registerCategoriesRoutes(
}
return reply.code(204).send();
});
const generateCatSeoSchema: FastifySchema = {
tags: ['Categories'],
summary: 'Generate category content with AI (admin)',
description:
'Completa con IA la descripción, el título SEO y la descripción SEO de la categoría cuando están vacíos.',
params: {
type: 'object',
required: ['id'],
properties: { id: { type: 'string', format: 'uuid' } },
},
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema, 422: errorSchema },
};
app.post('/categories/:id/generate-seo', { schema: generateCatSeoSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
const existing = await repository.findById(id);
if (!existing) throw new AppError(404, 'NOT_FOUND', 'Category not found');
const settings = await loadAiSettings(deps.pool, [
'ai_category_description_prompt',
'ai_category_seo_title_prompt',
'ai_category_seo_description_prompt',
]);
const { baseUrl, model, apiKey } = requireAiConfigured(settings);
const replacements: Record<string, string> = { name: existing.name, description: existing.description ?? '' };
const promptFor = (template: string | undefined, fallback: string) =>
(template || fallback).replace(/\{\{(name|description)\}\}/g, (_, key: string) => replacements[key] ?? '');
const patch: { description?: string; seoTitle?: string; seoDescription?: string } = {};
if (!existing.description?.trim()) {
const raw = await generateWithModel(
baseUrl,
model,
apiKey,
promptFor(
settings.ai_category_description_prompt,
'Escribe una descripción breve y atractiva en español para esta categoría de una tienda de productos naturales y ecológicos: {{name}}. Devuelve solo la descripción, sin formato.',
),
);
patch.description = raw.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 1000);
}
if (!existing.seoTitle?.trim()) {
const raw = await generateWithModel(
baseUrl,
model,
apiKey,
promptFor(
settings.ai_category_seo_title_prompt,
'Genera un título SEO breve para esta categoría: {{name}}. Devuelve solo el título.',
),
);
patch.seoTitle = raw.replace(/<[^>]*>/g, '').trim().slice(0, 200);
}
if (!existing.seoDescription?.trim()) {
const raw = await generateWithModel(
baseUrl,
model,
apiKey,
promptFor(
settings.ai_category_seo_description_prompt,
'Genera una meta descripción SEO en español para esta categoría: {{name}}. Devuelve solo la descripción.',
),
);
patch.seoDescription = raw.replace(/<[^>]*>/g, '').trim().slice(0, 500);
}
if (Object.keys(patch).length === 0) {
return reply.send(serializeCategory(existing));
}
const updated = await repository.update(id, patch);
return reply.send(serializeCategory(updated ?? existing));
});
}
function mapCategoryError(error: unknown): Error {
@@ -183,9 +262,12 @@ function serializeCategory(category: Category) {
url: `/categoria/${category.slug}`,
seoTitle: category.seoTitle,
seoDescription: category.seoDescription,
description: category.description,
isParent: category.isParent,
emoji: category.emoji,
color: category.color,
bgColor: category.bgColor,
textColor: category.textColor,
createdAt: category.createdAt.toISOString(),
updatedAt: category.updatedAt.toISOString(),
};

View File

@@ -8,10 +8,13 @@ export interface Category {
slug: string;
seoTitle: string | null;
seoDescription: string | null;
description?: string | null;
/** FIX-19: true = contenedor (puede tener hijos); false = hoja (child). */
isParent: boolean;
emoji?: string | null;
color?: string | null;
bgColor?: string | null;
textColor?: string | null;
createdAt: Date;
updatedAt: Date;
}
@@ -22,9 +25,12 @@ export interface NewCategory {
slug: string;
seoTitle?: string | null;
seoDescription?: string | null;
description?: string | null;
isParent?: boolean;
emoji?: string | null;
color?: string | null;
bgColor?: string | null;
textColor?: string | null;
}
/** Fields a category update may set. Undefined = leave unchanged; parentId null = move to root. */

View File

@@ -10,9 +10,12 @@ interface CategoryRow {
slug: string;
seo_title: string | null;
seo_description: string | null;
description: string | null;
is_parent: boolean;
emoji: string | null;
color: string | null;
bg_color: string | null;
text_color: string | null;
created_at: Date;
updated_at: Date;
}
@@ -25,9 +28,12 @@ const UPDATABLE: ReadonlyArray<[keyof CategoryPatch, string]> = [
['slug', 'slug'],
['seoTitle', 'seo_title'],
['seoDescription', 'seo_description'],
['description', 'description'],
['isParent', 'is_parent'],
['emoji', 'emoji'],
['color', 'color'],
['bgColor', 'bg_color'],
['textColor', 'text_color'],
];
export class PgCategoryRepository implements CategoryRepository {
@@ -61,8 +67,8 @@ export class PgCategoryRepository implements CategoryRepository {
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, is_parent, emoji, color)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`INSERT INTO categories_categories (parent_id, name, slug, seo_title, seo_description, description, is_parent, emoji, color, bg_color, text_color)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *`,
[
input.parentId ?? null,
@@ -70,9 +76,12 @@ export class PgCategoryRepository implements CategoryRepository {
input.slug,
input.seoTitle ?? null,
input.seoDescription ?? null,
input.description ?? null,
input.isParent ?? false,
input.emoji ?? null,
input.color ?? null,
input.bgColor ?? null,
input.textColor ?? null,
],
);
const row = result.rows[0];
@@ -155,9 +164,12 @@ function toCategory(row: CategoryRow): Category {
slug: row.slug,
seoTitle: row.seo_title,
seoDescription: row.seo_description,
description: row.description,
isParent: row.is_parent,
emoji: row.emoji,
color: row.color,
bgColor: row.bg_color,
textColor: row.text_color,
createdAt: row.created_at,
updatedAt: row.updated_at,
};