Ir al contenido

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.


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.


  • Polyrepo. api/ y web/ 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 TODO ni PLACEHOLDER en código que se mergea.
  • Las migraciones son SQL explícito. No se autogeneran contra producción.
  • Dinero siempre en numeric, nunca float.
  • Los tests corren contra aquazaku_test, jamás contra aquazaku_dev.

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.

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.


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 desactivar al tipo Action

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.


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_600ML
paca · 50 u · 300 ml → P50U_300ML
botellon · 1 u · 20 000 ml → BOT_20L

Cada 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.

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 — editarPrecios audita 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 — desactivar es 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 DELETE en 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.


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 — requirePermission en 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.


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_20L con 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.


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.


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.


  1. admin crea un producto desde la UI y le aparece con código generado.
  2. Un UPDATE directo en psql que viole el piso es rechazado por Postgres.
  3. litros da 12, 15 y 20 sin que nadie lo haya escrito.
  4. pos y seller ven el catálogo y reciben 403 al intentar editarlo.
  5. Un cambio de precio deja fila en audit_log con antes y después.
  6. Un producto desactivado desaparece del listado por defecto y sigue existiendo.
  7. El seed corre dos veces sin duplicar nada.
  8. api/ y web/ verdes; Bruno verde contra servidor real.
  9. /docs refleja lo implementado.
  10. Verificado en un browser real, con todos los servicios arriba.

(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.)