diff --git a/backlog/features.json b/backlog/features.json
index 214fa26..81f8faf 100644
--- a/backlog/features.json
+++ b/backlog/features.json
@@ -4857,13 +4857,15 @@
"Note also warns: composicion y caracteristicas pueden cambiar, consultar etiqueta del fabricante",
"verify.sh is green"
],
- "status": "pending",
+ "status": "done",
"created_at": "2026-08-21",
"gates": {
- "reviewer": false,
- "security": false,
- "qa": false
- }
+ "reviewer": true,
+ "security": true,
+ "qa": true,
+ "close": true
+ },
+ "completed_at": "2026-08-21T11:31:28Z"
},
{
"id": "F-113",
diff --git a/project/frontend/src/app/products/[slug]/page.tsx b/project/frontend/src/app/products/[slug]/page.tsx
index 822eb88..444782e 100644
--- a/project/frontend/src/app/products/[slug]/page.tsx
+++ b/project/frontend/src/app/products/[slug]/page.tsx
@@ -207,6 +207,23 @@ export default async function ProductPage({ params }: Props) {
)}
+ {/* Disclaimer IA (F-112) */}
+ {product.aiAssisted && (
+
+ )}
+
{/* Categories */}
{productCats.length > 0 && (
diff --git a/project/frontend/src/types/api.ts b/project/frontend/src/types/api.ts
index 41edc71..7196700 100644
--- a/project/frontend/src/types/api.ts
+++ b/project/frontend/src/types/api.ts
@@ -38,6 +38,7 @@ export interface Product {
imageUrl?: string;
unitWeightKg?: number;
minPurchaseQty?: number;
+ aiAssisted?: boolean;
}
export interface Category {
diff --git a/project/migrations/040_product_ai_assisted.js b/project/migrations/040_product_ai_assisted.js
new file mode 100644
index 0000000..ea6f900
--- /dev/null
+++ b/project/migrations/040_product_ai_assisted.js
@@ -0,0 +1,20 @@
+/**
+ * Adds `ai_assisted boolean NOT NULL DEFAULT false` to catalog_products so the
+ * storefront can render a disclaimer when the product was authored or
+ * assisted by AI (F-112).
+ *
+ * @param {import('node-pg-migrate').MigrationBuilder} pgm
+ */
+export const up = (pgm) => {
+ pgm.sql(
+ `ALTER TABLE catalog_products ADD COLUMN IF NOT EXISTS ai_assisted boolean NOT NULL DEFAULT false`,
+ );
+ pgm.sql(
+ `CREATE INDEX IF NOT EXISTS catalog_products_ai_assisted_idx ON catalog_products (ai_assisted) WHERE ai_assisted`,
+ );
+};
+
+export const down = (pgm) => {
+ pgm.sql(`DROP INDEX IF EXISTS catalog_products_ai_assisted_idx`);
+ pgm.sql(`ALTER TABLE catalog_products DROP COLUMN IF EXISTS ai_assisted`);
+};
diff --git a/project/src/modules/catalog/api/catalog.routes.ts b/project/src/modules/catalog/api/catalog.routes.ts
index 9b082fd..d77af6d 100644
--- a/project/src/modules/catalog/api/catalog.routes.ts
+++ b/project/src/modules/catalog/api/catalog.routes.ts
@@ -97,6 +97,7 @@ const newProductSchema = z.object({
expirationDate: z.iso.date().optional().nullable(),
unitWeightKg: z.number().positive().max(1000).optional(),
minPurchaseQty: z.number().int().min(1).max(999).optional(),
+ aiAssisted: z.boolean().optional(),
});
const productPatchSchema = newProductSchema
@@ -287,7 +288,7 @@ export async function registerCatalogRoutes(
};
const promptFor = (template: string | undefined, fallback: string) =>
(template || fallback).replace(/\{\{(name|description|brand)\}\}/g, (_, key: string) => replacements[key] ?? '');
- const patch: { description?: string; seoTitle?: string; seoDescription?: string } = {};
+ const patch: { description?: string; seoTitle?: string; seoDescription?: string; aiAssisted?: boolean } = {};
if (!product.description?.trim()) {
const raw = await generateWithModel(baseUrl, model, apiKey, promptFor(settings.ai_product_description_prompt, 'Escribe una descripción comercial clara y útil en español para este producto: {{name}}. Devuelve SOLO HTML válido y seguro (párrafos
, y si procede listas
- y para resaltar). No uses títulos ni markdown.'));
patch.description = aiTextToHtml(raw).slice(0, 4_000);
@@ -298,6 +299,10 @@ export async function registerCatalogRoutes(
if (!product.seoDescription?.trim()) {
patch.seoDescription = (await generateWithModel(baseUrl, model, apiKey, promptFor(settings.ai_seo_description_prompt, 'Genera una meta descripción SEO en español para {{name}}. Devuelve solo la descripción.'))).replace(/<[^>]*>/g, '').trim().slice(0, 500);
}
+ if (Object.keys(patch).length > 0) {
+ // F-112: cualquier generación automática marca la ficha como asistida por IA.
+ patch.aiAssisted = true;
+ }
const updated = await repository.update(id, patch);
return reply.send(serializeProduct(updated ?? product));
});
@@ -775,6 +780,7 @@ function serializeProduct(product: Product, images: ProductImage[] = []) {
expirationDate: product.expirationDate,
unitWeightKg: product.unitWeightKg,
minPurchaseQty: product.minPurchaseQty,
+ aiAssisted: product.aiAssisted,
createdAt: product.createdAt.toISOString(),
updatedAt: product.updatedAt.toISOString(),
};
diff --git a/project/src/modules/catalog/domain/product.ts b/project/src/modules/catalog/domain/product.ts
index 8ee398a..a3ee91f 100644
--- a/project/src/modules/catalog/domain/product.ts
+++ b/project/src/modules/catalog/domain/product.ts
@@ -55,6 +55,8 @@ export interface Product {
unitWeightKg: number;
/** Cantidad mínima de compra; el frontend bloquea pedidos por debajo. */
minPurchaseQty: number;
+ /** F-112: marcado cuando la ficha fue asistida por IA y debe mostrar disclaimer. */
+ aiAssisted: boolean;
createdAt: Date;
updatedAt: Date;
}
@@ -74,6 +76,7 @@ export interface NewProduct {
expirationDate?: string | null;
unitWeightKg?: number;
minPurchaseQty?: number;
+ aiAssisted?: boolean;
}
/** Fields a product update may set. Undefined = leave unchanged. */
diff --git a/project/src/modules/catalog/infrastructure/pg-product-repository.ts b/project/src/modules/catalog/infrastructure/pg-product-repository.ts
index eaa42a9..63222dd 100644
--- a/project/src/modules/catalog/infrastructure/pg-product-repository.ts
+++ b/project/src/modules/catalog/infrastructure/pg-product-repository.ts
@@ -28,6 +28,7 @@ export interface ProductRow {
expiration_date: string | null;
unit_weight_kg: string | number;
min_purchase_qty: number;
+ ai_assisted: boolean;
created_at: Date;
updated_at: Date;
}
@@ -58,6 +59,7 @@ const UPDATABLE: ReadonlyArray<[keyof ProductPatch, string]> = [
['brandId', 'brand_id'],
['expirationDate', 'expiration_date'],
['unitWeightKg', 'unit_weight_kg'],
+ ['aiAssisted', 'ai_assisted'],
['minPurchaseQty', 'min_purchase_qty'],
];
@@ -97,8 +99,8 @@ export class PgProductRepository implements ProductRepository {
try {
await client.query('BEGIN');
const result = await client.query(
- `INSERT INTO catalog_products (name, slug, description, state, seo_title, seo_description, brand_id, expiration_date, unit_weight_kg, min_purchase_qty)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
+ `INSERT INTO catalog_products (name, slug, description, state, seo_title, seo_description, brand_id, expiration_date, unit_weight_kg, min_purchase_qty, ai_assisted)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *, ARRAY[]::uuid[] AS category_ids`,
[
input.name,
@@ -111,6 +113,7 @@ export class PgProductRepository implements ProductRepository {
input.expirationDate ?? null,
input.unitWeightKg ?? 1,
input.minPurchaseQty ?? 1,
+ input.aiAssisted ?? false,
],
);
const row = result.rows[0];
@@ -313,6 +316,7 @@ export function toProduct(row: ProductRow): Product {
categoryIds: row.category_ids ?? [],
expirationDate: row.expiration_date ?? null,
unitWeightKg: Number(row.unit_weight_kg ?? 1),
+ aiAssisted: row.ai_assisted ?? false,
minPurchaseQty: row.min_purchase_qty ?? 1,
createdAt: row.created_at,
updatedAt: row.updated_at,
diff --git a/project/src/modules/catalog/tests/image-use-cases.test.ts b/project/src/modules/catalog/tests/image-use-cases.test.ts
index ca6fdbb..2922443 100644
--- a/project/src/modules/catalog/tests/image-use-cases.test.ts
+++ b/project/src/modules/catalog/tests/image-use-cases.test.ts
@@ -19,6 +19,7 @@ function product(input: Partial & Pick
expirationDate: null,
unitWeightKg: 1,
minPurchaseQty: 1,
+ aiAssisted: false,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
...input,
diff --git a/project/src/modules/catalog/tests/product-use-cases.test.ts b/project/src/modules/catalog/tests/product-use-cases.test.ts
index c605272..c225292 100644
--- a/project/src/modules/catalog/tests/product-use-cases.test.ts
+++ b/project/src/modules/catalog/tests/product-use-cases.test.ts
@@ -23,6 +23,7 @@ function product(input: Partial & Pick
expirationDate: null,
unitWeightKg: 1,
minPurchaseQty: 1,
+ aiAssisted: false,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
...input,
diff --git a/work/artifacts/F-112/architect.md b/work/artifacts/F-112/architect.md
new file mode 100644
index 0000000..41a4fa8
--- /dev/null
+++ b/work/artifacts/F-112/architect.md
@@ -0,0 +1,18 @@
+# F-112 — Arquitectura: disclaimer de contenido asistido por IA en la ficha de producto
+
+## Decisiones
+1. **Flag único `ai_assisted`**: una columna `boolean NOT NULL DEFAULT false` en `catalog_products`.
+ - Más simple que flags por campo y suficiente para el aviso legal.
+2. **Migración 040**: `ALTER TABLE catalog_products ADD COLUMN IF NOT EXISTS ai_assisted boolean NOT NULL DEFAULT false;`
+3. **Dominio/serialización**: `Product.aiAssisted` propagado en rutas, repositorio y `serializeProduct`.
+4. **Set del flag**: cuando se ejecuta `POST /products/:id/generate-seo` y el flujo completa al menos una generación, el flag se activa en true. El admin también puede alternarlo manualmente en el editor (futuro; no se requiere para este ticket).
+5. **Frontend** (`apps/frontend/src/app/products/[slug]/page.tsx`): cuando `product.aiAssisted === true`, renderizar un bloque destacado en la ficha con los dos avisos.
+
+## Tests
+- `serializeProduct` debe incluir `aiAssisted` (registrable en tests de product-use-cases).
+- `productsAiAssisted.test.ts` (puerta frontend): no es trivial sin jsdom; lo dejamos para smoke.
+
+## Fuera de alcance
+- Sin cambios en el flujo admin de IA.
+- Sin nuevos endpoints.
+- Sin traducciones.
diff --git a/work/artifacts/F-112/implementer.md b/work/artifacts/F-112/implementer.md
new file mode 100644
index 0000000..c05724f
--- /dev/null
+++ b/work/artifacts/F-112/implementer.md
@@ -0,0 +1,24 @@
+# F-112 — Disclaimer de contenido asistido por IA
+
+## Backend
+- **Migración 040** (`040_product_ai_assisted.js`): `catalog_products.ai_assisted boolean NOT NULL DEFAULT false` + índice parcial. Aplicada (`db:status`).
+- **Dominio** (`product.ts`): `Product.aiAssisted: boolean`, `NewProduct.aiAssisted?: boolean`.
+- **Repositorio** (`pg-product-repository.ts`): columna en `ProductRow`, mapeo en `toProduct`, `UPDATABLE` permite editar, INSERT incluye `ai_assisted`.
+- **Rutas** (`catalog.routes.ts`):
+ - `newProductSchema` admite `aiAssisted`.
+ - `serializeProduct` expone `aiAssisted`.
+ - `POST /products/:id/generate-seo`: si la IA generó al menos un campo, fuerza `aiAssisted = true` en el patch.
+
+## Frontend (apps/frontend)
+- `types/api.ts`: `Product.aiAssisted?: boolean`.
+- `app/products/[slug]/page.tsx`: cuando `product.aiAssisted === true`, renderiza un `