Plan de M1 — Productos y catálogo
Objetivo: implementar M1 según la
spec de diseño — tabla
productos, CRUD restringido a admin, invariantes en la base, catálogo
semilla y dos pantallas en web/.
Dominio: Productos y catálogo — RN-CAT-01 a 11.
Estado: ✅ Terminado (22-ago-2026) — las 8 tasks cerradas.
La lección de M0, que acá también aplica
Sección titulada «La lección de M0, que acá también aplica»Una task no está terminada porque sus tests pasen. Está terminada cuando se la vio funcionar de punta a punta, con todos los servicios arriba.
Los dos peores bugs de M0 —el header Origin y el logout faltante— vivían en
costuras: entre repos, o entre “el endpoint existe” y “alguien lo llama”.
Ninguna suite unitaria mira ahí, por diseño.
Cada task que produce endpoint cierra con una petición HTTP real. Cada task que produce pantalla cierra con un browser abierto.
Restricciones globales
Sección titulada «Restricciones globales»- Polyrepo.
api/yweb/son repos independientes con su propio remote. Nunca se commitea desde la raíz. - No se buildea después de cada cambio. El typecheck sí.
- Convención del proyecto: 2 espacios, comillas simples, sin punto y coma.
- Conventional commits, sin
Co-Authored-By. - Cada commit deja el proyecto en verde: tests + typecheck.
- Sin
TODOniPLACEHOLDERen código que se mergea. - Las migraciones son SQL explícito. No se autogeneran contra producción.
- Dinero siempre en
numeric, nuncafloat. - Los tests corren contra
aquazaku_test, jamás contraaquazaku_dev.
Grafo de dependencias
Sección titulada «Grafo de dependencias»T1 schema ─────┬─── T3 código ─── T4 servicio ─── T5 rutas ─┬─ T7 web │ │ │ └─── T6 seed │ └─ T8 bruno + docs │T2 matriz ──────────────────────────────┘- T1 y T2 son independientes: se pueden hacer en cualquier orden.
- T5 necesita T2 (los permisos) y T4 (el servicio).
- T8 cierra: sin T5 y T7 no hay nada que explorar ni que documentar.
Task 1 — Schema y migración
Sección titulada «Task 1 — Schema y migración»Archivos
- Modificar:
api/src/db/schema.ts - Crear:
api/src/db/migrations/0002_productos.sql - Crear:
api/src/db/__tests__/productos-schema.test.ts
Produce: la tabla productos con sus invariantes.
- Paso 1 — Enum y tabla en
schema.ts
Seguir el estilo del archivo: helper tstz, pgEnum arriba, comentario que
explique el por qué de lo que no es obvio.
export const presentacionEnum = pgEnum('presentacion', ['paca', 'botellon'])- Paso 2 — Columna generada para
litros
litros: numeric('litros', { precision: 10, scale: 3 }) .generatedAlwaysAs(sql`(contenido_ml::numeric * unidades) / 1000`),El botellón lleva unidades = 1 para que la fórmula sea la misma en los tres
productos. Con null haría falta un COALESCE y la columna dejaría de ser
trivial de leer.
- Paso 3 — Los tres
CHECK
check('precio_minimo_es_piso', sql` ${t.precioMinimo} <= ${t.precioResidencial} AND ${t.precioMinimo} <= ${t.precioComercial}`),check('precios_no_negativos', sql`${t.precioMinimo} >= 0`),check('unidades_positivas', sql`${t.unidades} >= 1`),- Paso 4 — Migración SQL explícita
Escribirla a mano, no generarla. Incluye el GRANT a aquazaku_app: el rol de
la aplicación no es dueño de las tablas, así que sin GRANT la app no puede
leer ni escribir.
GRANT SELECT, INSERT, UPDATE ON productos TO aquazaku_app;Sin DELETE. RN-CAT-02 dice que un producto no se borra
— y la forma más barata de garantizarlo es que el rol no tenga el privilegio.
- Paso 5 — Tests de integración
| Verifica | Cómo |
|---|---|
El CHECK rechaza de verdad |
INSERT con piso mayor al precio de lista → error de Postgres |
litros sale 12, 15 y 20 |
Insertar los tres productos, leer litros, nunca escribirlo |
| El código es único | Segundo INSERT con el mismo código → violación de unicidad |
aquazaku_app no puede borrar |
DELETE con el rol de app → insufficient_privilege |
Cierra cuando: los cuatro tests pasan y psql rechaza un UPDATE que viole
el piso, probado a mano.
Task 2 — Extender la matriz de permisos
Sección titulada «Task 2 — Extender la matriz de permisos»Archivos
- Modificar:
api/src/modules/authz/matrix.ts - Modificar:
docs/src/content/docs/dominio/roles-y-permisos.md - Modificar:
api/src/modules/authz/__tests__/matrix.test.ts
Produce: productos:crear, productos:editar, productos:desactivar.
- Paso 1 — Agregar
desactivaral tipoAction
Es una acción nueva en el sistema, no solo una celda nueva.
- Paso 2 — Tres celdas, solo para
admin
{ resource: 'productos', action: 'crear', scope: 'todo' },{ resource: 'productos', action: 'editar', scope: 'todo' },{ resource: 'productos', action: 'desactivar', scope: 'todo' },- Paso 3 — La misma tabla en el doc de dominio
- Paso 4 — Tests celda por celda
Que seller, pos y contador reciban false en las tres acciones nuevas.
No alcanza con probar que admin puede: hay que probar que los otros no.
Cierra cuando: matrix.test.ts verde y la tabla del doc coincide con el
código.
Task 3 — Generador de código
Sección titulada «Task 3 — Generador de código»Archivos
- Crear:
api/src/modules/productos/codigo.ts - Crear:
api/src/modules/productos/__tests__/codigo.test.ts
Produce: generarCodigo({ presentacion, contenidoMl, unidades }) →
P20U_600ML, BOT_20L.
- Paso 1 — El código lleva su unidad pegada
paca · 20 u · 600 ml → P20U_600MLpaca · 50 u · 300 ml → P50U_300MLbotellon · 1 u · 20 000 ml → BOT_20LCada número trae su unidad. Sin eso, PACA-20-600 obliga a saber cuál de los
dos es cuál; P20U_600ML se lee sin conocer la convención.
La paca se mide en mililitros y el botellón en litros porque así los nombra el negocio. Con la unidad explícita, la asimetría deja de ser ambigüedad.
- Paso 2 — El botellón cae a mililitros si no da litros exactos
20 000 ml → BOT_20L. Pero 20 500 ml → BOT_20500ML, no BOT_20.5L: un
punto decimal dentro de un código es una fuente de variantes al escribirlo a
mano.
Regla explícita y testeable: litros solo si el contenido es múltiplo exacto de 1000.
- Paso 3 — La colisión es la excepción, no el caso normal
Como el código lleva cantidad y contenido, dos productos distintos nunca
generan el mismo. P24U_600ML no choca con P20U_600ML.
Solo queda un caso real: un producto desactivado que se reintroduce idéntico.
Ahí el sufijo es _2, _3… y sí significa algo — es la segunda
encarnación.
- Paso 4 — El código no se recicla nunca
El generador consulta los existentes incluyendo los inactivos (RN-CAT-11). Reciclar el código de un producto desactivado haría que un comprobante viejo parezca referirse al nuevo.
Cierra cuando: los tests cubren los tres productos reales, el botellón no exacto, la paca de 24 que no colisiona, y la reintroducción de un desactivado.
Task 4 — Servicio
Sección titulada «Task 4 — Servicio»Archivos
- Crear:
api/src/modules/productos/service.ts - Crear:
api/src/modules/productos/__tests__/service.test.ts
Produce: listar, porId, crear, editar, editarPrecios,
desactivar, reactivar.
- Paso 1 — Validar el piso antes de tocar la base
El CHECK es la garantía; esta validación es la explicación. Sin ella el
usuario recibe un error de Postgres en vez de un mensaje que dice qué corregir.
if (minimo > residencial || minimo > comercial) { throw new ErrorDeNegocio('PRECIO_MINIMO_INVALIDO', 422, 'El precio mínimo no puede superar ningún precio de lista.')}- Paso 2 —
editarPreciosaudita con el antes y el después
await emit({ ...contexto, action: 'productos:editar_precios', resource: 'productos', resourceId: producto.id, result: 'ok', payload: { codigo: producto.codigo, antes: { residencial, comercial, minimo }, despues: { ...nuevos }, },})Sin el payload, la bitácora diría que alguien cambió un precio pero no de
cuánto a cuánto — que es exactamente lo que se va a querer saber.
- Paso 3 —
desactivares idempotente en la base, explícito en el servicio
Desactivar uno ya inactivo devuelve PRODUCTO_YA_INACTIVO (409). Es
información, no un fallo silencioso: si el admin creyó que estaba activo,
merece enterarse.
- Paso 4 — Sin
DELETEen el módulo
No se expone. No hay forma de llamarlo por accidente.
- Paso 5 — La deuda de RN-CAT-02, escrita en el código
// RN-CAT-02 exige que no queden unidades en stock para desactivar.// La tabla de stock es de M2: acá todavía no se puede verificar.// El criterio de aceptación de M2 incluye cerrar esta condición.Un comentario que cita la regla y dice cuándo se cierra. No un TODO suelto.
Cierra cuando: los tests cubren el piso inválido, la auditoría con payload,
la doble desactivación y el listado filtrando por activo.
Task 5 — Rutas y validación
Sección titulada «Task 5 — Rutas y validación»Archivos
- Crear:
api/src/modules/productos/validation.ts - Crear:
api/src/modules/productos/routes.ts - Crear:
api/src/modules/productos/__tests__/routes.test.ts - Modificar:
api/src/server.ts
Produce: los siete endpoints de la spec §8.
- Paso 1 — Esquemas Zod
Que web/ pueda importar el mismo esquema. La validación del cliente es
comodidad; la del servidor es la que manda — pero no tienen por qué diferir.
- Paso 2 —
requirePermissionen cada ruta
editarPrecios va con { auditaLaRuta: true }.
- Paso 3 — Errores con código estable
PRODUCTO_NO_ENCONTRADO · CODIGO_DUPLICADO · PRECIO_MINIMO_INVALIDO ·
PRODUCTO_YA_INACTIVO.
- Paso 4 — Tests E2E, no solo unitarios
| Verifica | Espera |
|---|---|
pos intenta crear |
403 |
seller intenta editar precios |
403 |
contador lista |
200, y no puede mutar nada |
admin crea y el código aparece |
201 con codigo |
| Piso inválido | 422 con mensaje legible |
Cierra cuando: los cinco casos pasan y una petición HTTP real devuelve lo esperado.
Task 6 — Catálogo semilla
Sección titulada «Task 6 — Catálogo semilla»Archivos
-
Modificar:
api/drizzle/seed.ts -
Modificar:
api/src/db/__tests__/seed.test.ts -
Paso 1 —
sembrarProductos(), idempotente
Igual que sembrarRoles(): si ya están, no hace nada y sale en 0.
| Código | Nombre | Presentación | Contenido | Unidades |
|---|---|---|---|---|
P20U_600ML |
Paca de 20 bolsas de 600 ml | paca |
600 | 20 |
P50U_300ML |
Paca de 50 bolsas de 300 ml | paca |
300 | 50 |
BOT_20L |
Recarga de botellón de 20 L | botellon |
20000 | 1 |
- Paso 2 — Solo
BOT_20Lcon precio real
$10.000, confirmado por Aquazaku (RN-CAT-08).
Las pacas arrancan en 0. Sus precios no están confirmados, y sembrar un
número plausible sería peor que sembrar un cero: un cero se ve; un número
inventado se confunde con un dato real y termina en una venta.
Cierra cuando: el seed corre dos veces sin duplicar y los litros calculados dan 12, 15 y 20.
Task 7 — Pantallas en web/
Sección titulada «Task 7 — Pantallas en web/»Archivos
-
Crear:
web/src/app/(app)/modulos/productos/page.tsx -
Crear:
web/src/app/(app)/modulos/productos/gestion/page.tsx -
Crear:
web/src/app/(app)/modulos/productos/actions.ts -
Crear:
web/src/components/productos/ -
Paso 1 — Catálogo de lectura, para los cuatro roles
Server Component que lee con apiServerFetch(). La única función que puede
hablar con api/ (ADR-0002).
- Paso 2 — Gestión, solo
admin
El guard del route group (app) ya resuelve la sesión. Ocultar el menú no es
seguridad (RN-ACC-02): el 403 lo da la API.
- Paso 3 — Mutaciones por Server Actions
Nunca fetch desde el cliente. La regla de ESLint lo prohíbe, y está para eso.
- Paso 4 — Sin TanStack Table
Con tres productos sería infraestructura sin uso. Entra cuando haya una tabla que lo pida.
- Paso 5 — Verificación manual en browser
Login como admin → crear producto → ver el código generado → cambiar un
precio → encontrar ese cambio en /admin/auditoria con el antes y el
después.
Después, login como pos: ver el catálogo, no ver el botón de gestión, y
—entrando a la URL a mano— recibir 403.
Cierra cuando: los dos recorridos se hicieron en un browser real.
Task 8 — Bruno, documentación y cierre
Sección titulada «Task 8 — Bruno, documentación y cierre»Archivos
-
Crear:
api/bruno/aquazaku/5-Productos/ -
Modificar:
docs/— backend, base de datos, frontend, roadmap -
Paso 1 — Colección Bruno con aserciones
No requests decorativos: cada uno con tests. Incluir el que debe fallar —
crear producto como pos y esperar 403.
- Paso 2 — Documentar lo implementado, no lo planeado
| Doc | Qué actualizar |
|---|---|
backend/index.md |
Endpoints y códigos de error |
base-de-datos/index.md |
Tabla productos, migración 0002, columna generada, los CHECK |
frontend/index.md |
Las dos pantallas |
arquitectura/roadmap.md |
M1 terminado, M2 por arrancar |
arquitectura/modulos.md |
Estado y qué quedó construido |
- Paso 3 — Barrido de
/docs
Links internos con anchors, frontmatter completo, cero mojibake.
- Paso 4 — Verificación final de punta a punta
Todos los servicios arriba, browser real, los diez criterios de la spec §13.
Cierra cuando: Bruno verde contra servidor real y /docs refleja lo que
existe.
Definition of Done de M1
Sección titulada «Definition of Done de M1»admincrea un producto desde la UI y le aparece con código generado.- Un
UPDATEdirecto enpsqlque viole el piso es rechazado por Postgres. litrosda 12, 15 y 20 sin que nadie lo haya escrito.posysellerven el catálogo y reciben 403 al intentar editarlo.- Un cambio de precio deja fila en
audit_logcon antes y después. - Un producto desactivado desaparece del listado por defecto y sigue existiendo.
- El seed corre dos veces sin duplicar nada.
api/yweb/verdes; Bruno verde contra servidor real./docsrefleja lo implementado.- Verificado en un browser real, con todos los servicios arriba.
Notas de ejecución
Sección titulada «Notas de ejecución»(Se completan mientras se implementa: qué hubo que corregir del plan y qué se descubrió construyendo. La sección equivalente del plan de M0 resultó ser la parte más útil del documento — es donde quedaron los bugs que ninguna suite miraba.)
