Saltar a contenido

📚 Unidad ISS-07 · Feature Product — capa 🧠 APRENDER

🛠 Construir este bloque → · ✅ Condiciones de cierre (GATE) · 📝 Evaluación

Capa Página Para qué
🧠 Aprender esta página comprender, explicar y relacionar
🛠 Construir Feature Product ejecutar, programar y verificar
✅ GATE Cierre de la unidad condición para pasar al bloque siguiente

Mapa de correspondencias. Cada fila enlaza el mismo tema en las dos capas de la unidad; los enlaces apuntan a secciones reales del material (anclas de MkDocs, sin acentos).

Tema 🧠 Aprender (esta página) 🛠 Construir (ISS técnico)
Ruta y ficha de la unidad Ruta de aprendizaje · Ficha del ISS Contenido de la unidad
Mapas y estructura Mapa mental · Mapa del backend · Árbol de archivos Contenido de la unidad
Recorrido y comandos Comandos explicados · Recorrido paso a paso Contenido de la unidad
Diagnóstico Diagnóstico Criterios de aceptación
Evaluación y cierre Criterios · Evaluación · GATE Condiciones de cierre · Cierre

🖥 Presentación del ISS

Presentación de la unidad. Diapositivas de ISS-07 — Feature Product (8 diapositivas). Se visualiza aquí, dentro del sitio; no hace falta descargar nada para estudiarla.

⛶ Ver presentación completa ⬇ Archivo editable (.pptx)

8 diapositivas · se visualiza dentro del sitio (archivo editable .pptx como opción secundaria).


🎬 Video explicativo

Recorrido audiovisual de la unidad. Este video explica, bloque por bloque, el CRUD de Product del ISS-07 y su clave foránea hacia el tipo de producto. findByIdForUpdate queda creado y sin uso hasta la venta.

10:18 · narración en español · subtítulos activables desde el reproductor.


ISS-07 — Cuaderno de aprendizaje visual

Tema

Feature Product (productos) + asociaciones: construir el tercer feature —products/— con el CRUD completo por capas, la FK product_type_id y, por primera vez, la relación Sequelize Product ↔ ProductType. Un feature deja de ser una isla y pasa a relacionarse con otro.

Fuente técnica autoritativa

Archivo fuente ../manual/08-ISS-07-product.md
Nombre literal del archivo 08-ISS-07-product.md
Estado Solo lectura — este cuaderno no modifica el ISS
Alcance 6 sub-ítems (12.1 … 12.6), incluida la relación obligatoria (12.5)
Feature / tabla products → src/features/business/products/

Este cuaderno es una capa pedagógica sobre ese archivo: todo su contenido técnico (código, comandos, criterios) aparece aquí íntegro y verbatim. El ISS manda; el cuaderno explica.

Pregunta que responde: ¿dónde está la fuente autoritativa de este ISS y qué alcance tiene?

Regla del ISS

Objetivo: CRUD de Product con FK product_type_id. Bloqueado por: ISS-06. API: /api/productos — SIN AUTH.

Y una norma que el ISS marca como obligatoria:

12.5 Relación ProductType ↔ Product (obligatorio al cerrar la tabla Product) Cuando una tabla nueva se relaciona con una ya existente, al final se agrega este paso: archivo de asociaciones + PARCHE en config para cargarlo (side-effect).

La condición que el propio ISS exige para darse por terminado es doble: (1) el CRUD de Product funciona con la FK product_type_id, validando que el tipo exista y esté activo; y (2) la relación queda registrada con products.associations.ts e importada en config. Al cerrar, npm run dev arranca sin error.

Cómo leer este cuaderno

Cada concepto se presenta tres veces, desde tres ángulos distintos:

                 CONCEPTO
                    │
        ┌───────────┼───────────┐
        ▼           ▼           ▼
   EXPLICACIÓN    CÓDIGO      VISUAL
        │           │           │
    ¿qué es?    ¿dónde está?  ¿cómo lo
    ¿por qué?   ¿qué hace?     visualizo?
    ¿para qué?  ¿cómo opera?  ¿con qué
                               se relaciona?

Pregunta que responde: ¿cómo está organizado este cuaderno y qué espero encontrar en cada parte?

El recorrido de lectura es siempre el mismo:

EXPLICACIÓN
    ↓
CÓDIGO
    ↓
VISUAL

Pregunta que responde: ¿en qué orden debo leer el cuaderno?

Y cada cuaderno contiene los mismos seis componentes:

Componente Dónde vive Para qué sirve
Texto todas las secciones entender el por qué
Código Recorrido del ISS (verbatim del ISS) ver el qué exacto
Diagramas Mapa mental, Mapa del backend, Flujos, Asociaciones ver el cómo se conecta
Preguntas debajo de cada diagrama comprobar que entendiste
Evaluación Evaluación practicar y autoevaluarte
GATE GATE saber si puedes pasar al siguiente ISS

En este ISS añade un matiz: el concepto nuevo no es un archivo más, es una relación. Por eso hay una sección entera, ## Asociaciones: el salto conceptual, dedicada a ella.

Ruta de aprendizaje

Esta ruta es específica de este ISS. Los cinco primeros pasos son una repetición del patrón de product-types (y de clients antes): la novedad empieza en el paso 6.

Reutilizar el patrón ya replicado en product-types (ISS-06)
    ↓
Modelo Product con FK product_type_id
    ↓
DTO → Repository → Service (inyecta ProductTypesRepository)
    ↓
Controller → Routes
    ↓
HTTP + Cableado (routes/config)
    ↓
Relación Product ↔ ProductType (products.associations.ts)  ← salto nuevo
    ↓
Seeder + Swagger
    ↓
Verificar (GATE)

Pregunta que responde: ¿qué pasos concretos debo seguir y dónde está la novedad de este ISS?

Los pasos 1–5 deberían sentirse mecánicos: si tienes que pensar en cada uno, no has interiorizado el patrón. El paso 6 es el que merece toda tu atención: es la primera vez que un feature mira a otro.

Índice

  1. Ficha del ISS
  2. Mapa mental del ISS
  3. Mapa del backend
  4. Árbol de archivos
  5. Anatomía del código
  6. Comandos explicados
  7. Flujos
  8. Asociaciones: el salto conceptual
  9. Recorrido del ISS, paso a paso
  10. Diagnóstico
  11. Conexión con el resto del curso
  12. Glosario
  13. Criterios de aceptación
  14. Evaluación
  15. GATE

Ficha del ISS

Campo Valor
ISS ISS-07
Título Feature Product (productos)
Objetivo CRUD de Product con FK product_type_id
Fase Fase I — Business
Tecnología principal Express 5 + TypeScript + Sequelize (CRUD por capas + asociaciones)
Depende de ISS-06 — Feature ProductType
Habilita ISS-08 — Feature Sale + ProductSale
Archivos creados Modelo Product, dto/ (5 archivos), repository, service, controller, routes, products.associations.ts, http/ (4 archivos), seeder y swagger de products/
Archivos parcheados src/routes/index.ts, src/config/index.ts (modelo, rutas y asociaciones), src/database/seeders/counts.ts, src/database/seeders/index.ts, src/swagger/index.ts
Componentes incorporados 3.er feature (products), validación de FK activa en el service y asociaciones Sequelize (products.associations)
Verificación principal curl de create + curl de getAll sobre /api/productos, y npm run dev arrancando sin error
Resultado esperado CRUD de productos con product_type_id validado, tabla products poblada y relación registrada en Sequelize
GATE npm run dev arranca sin error (con los seis criterios, incluida la relación 12.5)

Qué implementamos AHORA

El tercer feature de negocio y, sobre todo, la primera relación entre features. En concreto:

  • La tabla products con su modelo (name, brand, price, min_stock, quantity, product_type_id, status, timestamps).
  • El CRUD completo por capas: getAll, getOne, create, update (PUT y PATCH) y delete (físico y lógico).
  • Una regla de negocio nueva: ProductsService valida que el product_type_id exista (404) y esté activo (400), reutilizando el ProductTypesRepository de ISS-06.
  • El archivo products.associations.ts con Product.belongsTo(ProductType) y ProductType.hasMany(Product).
  • Su cableado en routes/config (incluida la importación de las asociaciones), seeder y Swagger.

Qué todavía NO implementamos

Para que no confundas el estado actual con el futuro, esto no está en este ISS:

No se implementa aquí Llega en
sales y product_sales (ventas y su detalle) ISS-08
Transacciones y bloqueos (findByIdForUpdate, withTransaction) en uso real ISS-08
La relación Sale ↔ Client y Sale ↔ ProductSale ISS-08
El descuento/restauración de stock ISS-08
Cualquier autenticación o rol (las rutas son SIN AUTH) ISS-09 … ISS-13
include/eager loading del tipo dentro de la respuesta de producto No se pide en este ISS

Ojo con la trampa habitual: el ProductsRepository ya declara findByIdForUpdate y acepta un transaction opcional en algunos métodos. Son costuras para ISS-08 (ventas), no se usan todavía. Que existan no significa que este ISS haga transacciones.

Mapa mental del ISS

mindmap
  root((ISS-07<br/>Product))
    Objetivo
      Tercer feature
      FK product_type_id
      Validar tipo activo
    Piezas
      Modelo
      DTO
      Repository
      Service
      Controller
      Routes
      HTTP
    Nuevo
      Asociaciones Sequelize
      belongsTo
      hasMany
      Import side-effect en config
    Extras
      Seeder con Faker
      Swagger
    Verificación
      curl create
      curl getAll
      npm run dev
    GATE
      Seis criterios y relación

Pregunta que responde: ¿de qué trata este ISS y qué piezas lo componen?

Mapa del backend

Esta representación responde siempre a la misma pregunta: ¿en qué parte del backend estamos? En ISS-07 el tercer feature se suma a los dos anteriores y, por primera vez, aparece una flecha entre features.

IMPLEMENTADO HASTA ESTE ISS
───────────────────────────

   HTTP
     ↓
   Routes        ✅  src/routes/index.ts (3 features registrados)
     ↓
   Controller    ✅  BaseController.run() / paramId()
     ↓
   Service       ✅  AppError + reglas de negocio
     ↓
   Repository    ✅  única capa que toca Sequelize
     ↓
   Model         ✅  clients · product_types · products
     ↓
   Sequelize     ✅
     ↓
   BD            ✅

   Features de negocio:
     ✅ clients        (/api/clientes)        ← ISS-03
     ✅ product-types  (/api/tipos-producto)  ← ISS-06
     ✅ products       (/api/productos)        ← ISS-07  ◄── NUEVO
     🎯 sales          (/api/ventas)           ← ISS-08
     🎯 product-sales  (/api/detalle-ventas)   ← ISS-08

   Asociaciones Sequelize:
     ✅ Product.belongsTo(ProductType)  +  ProductType.hasMany(Product)  ← ISS-07  ◄── NUEVO
     🎯 Sale ↔ Client / Sale ↔ ProductSale / ProductSale ↔ Product       ← ISS-08

   Transversal ya existente:
     ✅ SeedersRunner + counts  (ISS-04)
     ✅ Swagger UI /api/docs    (ISS-05)

OBJETIVO DE ARQUITECTURA
────────────────────────

   5 features de negocio completos:
     🎯 clients · product-types · products · sales · product-sales

   Todas las relaciones del dominio:
     ✅ Product ↔ ProductType
     🎯 Sale ↔ Client / ProductSale ↔ Product / Sale ↔ ProductSale

   Fase II — Auth con RBAC:
     ⬜ users · roles · resources · role_users · resource_roles · refresh_tokens
     ⬜ middlewares authenticate / authorize

Pregunta que responde: ¿qué capas del backend existen ya y cuáles son todavía objetivo de arquitectura?

Compara con el mapa de ISS-06: las capas siguen siendo las mismas. Lo que cambia es que ahora, además de una tabla nueva, hay una relación entre dos tablas existentes. Ese es el verdadero avance de este ISS.

Árbol de archivos

Estructura antes

src/
├── config/
├── database/
│   └── seeders/{counts,index}.ts
├── routes/index.ts
├── shared/
├── features/business/
│   ├── clients/          # feature completo (ISS-03/04/05)
│   └── product-types/    # feature completo (ISS-06)
├── swagger/index.ts
└── server.ts

Pregunta que responde: ¿qué existía antes de empezar este ISS?

Archivos creados / modificados en este ISS

★ src/features/business/products/
    ├── product.model.ts                  # Model (tabla products)
    ├── dto/
    │   ├── create-product.dto.ts         # POST   /api/productos
    │   ├── update-product.dto.ts         # PUT    /api/productos/:id
    │   ├── patch-product.dto.ts          # PATCH  /api/productos/:id
    │   ├── product-response.dto.ts       # salida de la API
    │   └── index.ts                      # barrel
    ├── products.repository.ts            # acceso a Sequelize (+ costuras para ventas)
    ├── products.service.ts               # reglas de negocio (+ valida tipo activo)
    ├── products.controller.ts            # solo HTTP
    ├── products.routes.ts                # /api/productos
    ├── products.associations.ts          # relación Product ↔ ProductType  ◄── 12.5
    ├── products.seeder.ts                # datos falsos (Faker)
    ├── products.swagger.ts               # OpenAPI del feature
    └── http/
        ├── products.get.http
        ├── products.create.http
        ├── products.update.http
        └── products.delete.http

△ src/routes/index.ts                     # + ProductsRoutes
△ src/config/index.ts                     # + modelo, + routes(), + import de associations
△ src/database/seeders/counts.ts          # + products (default 15)
△ src/database/seeders/index.ts           # + seedProducts(...)
△ src/swagger/index.ts                    # + productsSwagger

Leyenda: ★ archivo creado · △ archivo existente parcheado.

Pregunta que responde: ¿qué archivos toca exactamente este ISS?

Estructura después

src/
├── config/                              # △ importa modelo + asociaciones + arranca el 3.er feature
├── database/
│   └── seeders/{counts,index}.ts        # △ products entra en el runner
├── routes/index.ts                      # △ 3 features registrados
├── shared/                              # (sin cambios)
├── features/business/
│   ├── clients/
│   ├── product-types/
│   └── products/                        # ★ feature nuevo, 14 archivos
├── swagger/index.ts                     # △ 3 módulos Swagger
└── server.ts

Pregunta que responde: ¿cómo queda la estructura del proyecto al terminar?

Fíjate en products.associations.ts: es un archivo que no existía en los features anteriores. Su sola presencia marca el momento en que el proyecto pasa de «features sueltos» a «dominio relacionado».

Anatomía del código

Aquí despiezamos, archivo por archivo, qué hace cada pieza y con quién se conecta. El código completo y verbatim aparece más abajo, en ## Recorrido del ISS, paso a paso; aquí no se repite.

Archivo: products/product.model.ts

Propósito

Definir la tabla products para Sequelize: columnas, tipos y comportamiento.

Explicación

  • Columnas de negocio: name y brand (obligatorias), price (DECIMAL(12,2)), min_stock y quantity (INTEGER, default 0), y product_type_id (INTEGER, obligatoria) — la FK lógica al catálogo.
  • status (ENUM("active","inactive")) con default "inactive", el mismo fail-safe que ya viste en los otros modelos.
  • timestamps: true; tableName real products.
  • El modelo declara la columna, pero no declara la relación: eso vive aparte, en products.associations.ts.

Se conecta con

  • Entrada: el Repository y el Seeder.
  • Salida: sequelize. Al importar las asociaciones, queda vinculado con ProductType.

Archivo: products/dto/

Propósito

Ser el contrato de entrada/salida de la API de productos.

Explicación

Mismo esquema que en product-types: create-product.dto.ts (con product_type_id obligatorio y status opcional), update-product.dto.ts (reemplazo completo, sin status), patch-product.dto.ts (Partial<UpdateProductDto>) y product-response.dto.ts (ProductResponseDto + toProductResponse). El index.ts es el barrel.

Detalle didáctico del ISS: el comentario de product-response.dto.ts muestra cómo se proyectaría un campo interno si apareciera — por ejemplo un cost — usando Omit<ProductI, "cost">. Hoy el contrato coincide con el modelo.

Se conecta con

  • Entrada: el controller tipa req.body con estos DTOs.
  • Salida: el service los consume y produce ProductResponseDto.

Archivo: products/products.repository.ts

Propósito

Ser la única capa que habla con el modelo Product.

Explicación

  • findAllActive(), findById(id, transaction?), create(data), update(instancia, data, transaction?) y delete(instancia).
  • Añade dos costuras que todavía no se usan en este ISS: findByIdForUpdate(id, transaction) (bloqueo de fila, SELECT … FOR UPDATE) y los parámetros transaction opcionales. Están pensadas para los flujos de ventas de ISS-08.
  • No contiene reglas de negocio.

Se conecta con

  • Entrada: el service.
  • Salida: el modelo Product. En ISS-08 también lo usará el feature de ventas con transacciones.

Archivo: products/products.service.ts

Propósito

Concentrar las reglas de negocio del feature y coordinar dos repositories.

Explicación

  • Este es el archivo clave del ISS. El constructor inyecta dos repositories: ProductsRepository (el propio) y ProductTypesRepository (de otro feature). Así el service orquesta una regla que cruza entidades sin romper las capas: no importa el modelo ProductType directamente, usa el repository de su feature.
  • create y updatePut llaman a assertActiveProductType(body.product_type_id) antes de persistir.
  • updatePatch solo valida el tipo si el cuerpo trae product_type_id (!== undefined).
  • assertActiveProductType(id): si el tipo no existe → AppError(404, "Product type not found"); si existe pero no está activo → AppError(400, "Product type must be active"). El 400 es intencional: el recurso de la URL sí existe, lo que falla es la referencia.
  • Mantiene el patrón del resto: findOrFail (404), deleteLogical (status inactive), deletePhysical (onlyActive: false) y devuelve DTOs con el mapper.

Se conecta con

  • Entrada: el controller.
  • Salida: ProductsRepository (para persistir) y ProductTypesRepository (para validar la referencia).

Archivo: products/products.controller.ts

Propósito

Traducir HTTP ↔ service. Solo HTTP.

Explicación

Idéntico en forma a los controllers anteriores: extiende BaseController, envuelve cada handler en this.run(res, …), lee el :id con this.paramId(req) y responde 200/201 con { products } o { product }. No decide reglas de negocio; delega toda la validación de FK en el service.

Se conecta con

  • Entrada: las routes (/api/productos…).
  • Salida: el service.

Archivo: products/products.routes.ts

Propósito

Declarar las rutas del feature sobre una Application de Express.

Explicación

Mapea GET /api/productos, GET /api/productos/:id, POST /api/productos, PUT/PATCH /api/productos/:id, DELETE /api/productos/:id (físico) y PATCH /api/productos/:id/deactivate (lógico). SIN AUTH en este ISS.

Se conecta con

  • Entrada: la Application, vía routes/index.ts.
  • Salida: el controller de Products.

Archivo: products/products.associations.ts (la pieza nueva)

Propósito

Registrar en Sequelize la relación entre Product y ProductType.

Explicación

  • Product.belongsTo(ProductType, { foreignKey: "product_type_id", as: "product_type" }): cada producto pertenece a un tipo.
  • ProductType.hasMany(Product, { foreignKey: "product_type_id", as: "products" }): cada tipo tiene muchos productos.
  • Son las dos caras de la misma relación 1:N. El foreignKey es el nombre de la columna que ya existe en products; el as es el alias que Sequelize usará para nombrar la relación.
  • El archivo no exporta nada: su efecto es el side-effect de ejecutar belongsTo/hasMany al importarlo. Por eso config lo importa a propósito antes de sync.

Se conecta con

  • Entrada: src/config/index.ts (import de side-effect).
  • Salida: la metadata de Sequelize, que así conoce la relación antes de sincronizar.

Archivo: products/http/ (4 archivos .http)

Propósito

Guardar peticiones listas para el REST Client.

Explicación

get, create, update y delete en el orden del ISS. El create incluye product_type_id en el cuerpo: es el campo que el service validará.

Se conecta con

  • Entrada: el servidor en marcha.
  • Salida: la API /api/productos.

Archivo: products/products.seeder.ts

Propósito

Poblar products con datos falsos, de forma idempotente y dependiente de los tipos.

Explicación

seedProducts(count): se omite si count <= 0 o si ya hay filas; luego busca tipos de producto activos (ProductType.findAll({ where: { status: "active" } })). Si no hay ninguno, se omite (no puede crear un producto sin tipo). Si los hay, elige uno al azar por fila y genera nombre, marca, precio, min_stock y quantity con Faker, todo status: "active".

Esto introduce una dependencia de orden: el seeder de productos presupone que el de tipos ya corrió.

Se conecta con

  • Entrada: el SeedersRunner.
  • Salida: los modelos Product y ProductType.

Archivo: products/products.swagger.ts

Propósito

Describir el feature en OpenAPI 3.

Explicación

Exporta productsSwagger con el tag Productos, los paths, y los schemas Product, ProductCreate, ProductUpdate, ProductPatch. Documenta explícitamente el 400 («Tipo de producto inactivo») y el 404 («Tipo de producto no encontrado») del create/put: la documentación refleja la regla del service.

Se conecta con

  • Entrada: el registry src/swagger/index.ts.
  • Salida: la UI /api/docs.

Parches: los archivos que ya existían

Archivo Qué se le añade Por qué
src/routes/index.ts import + productsRoutes y su llamada en routes() monta las rutas del feature
src/config/index.ts import del modelo product.model; llamada a productsRoutes.routes(...); e import de products.associations (encima de import { Routes }) registra el modelo, monta rutas y carga la relación antes del sync
src/database/seeders/counts.ts products: number + products: 15 + lectura por env configurar cuántas filas sembrar
src/database/seeders/index.ts import de seedProducts + llamada tras seedProductTypes encadenar el seeder en el orden correcto
src/swagger/index.ts import de productsSwagger + entrada en featureSwaggerModules publicar el feature en /api/docs

Se conecta con

  • Entrada: los archivos creados del feature.
  • Salida: el arranque de la app y el runner de seeders.

Comandos explicados

Ningún comando va sin explicación. En ISS-07 hay los mismos grupos que en ISS-06, más la carga de asociaciones y la verificación de la FK.

mkdir -p src/features/business/products/http

COMANDO
   ↓
mkdir -p src/features/business/products/http
   ↓
QUÉ HACE
   Crea la carpeta del feature products/ y su subcarpeta http/.
   ↓
POR QUÉ SE NECESITA
   El patrón de feature exige la carpeta http/. Crearla antes evita que
   los heredoc fallen al escribir la ruta.
   ↓
QUÉ CREA O MODIFICA
   src/features/business/products/ y .../http/ (vacías).
   ↓
RESULTADO ESPERADO
   El comando no imprime nada y termina con código 0.
   ↓
CÓMO VERIFICARLO
   ls src/features/business/products  → debe listar http (y lo que vayas creando).

El patrón : > ruta + cat >> ruta << 'EOF' … EOF

COMANDO
   ↓
: > ruta                                  # vacía/crea el archivo
cat >> ruta << 'EOF' … EOF                # escribe el contenido
   ↓
QUÉ HACE
   Crea cada archivo de forma reproducible: primero lo deja vacío y
   después le anexa el contenido. El delimitador 'EOF' entre comillas
   evita que el shell interprete $ y backticks.
   ↓
POR QUÉ SE NECESITA
   Garantiza que el archivo se crea desde cero y con exactamente el
   contenido del ISS, sin depender del editor.
   ↓
QUÉ CREA O MODIFICA
   Cada archivo de products/ (modelo, dto, capas, associations, seeder, swagger).
   ↓
RESULTADO ESPERADO
   Cada comando termina con código 0 y el archivo queda escrito.
   ↓
CÓMO VERIFICARLO
   Abre el archivo y compara con el `## Recorrido del ISS`; al final, `npx tsc --noEmit`.

npm run dev

COMANDO
   ↓
npm run dev
   ↓
QUÉ HACE
   Arranca el servidor en modo desarrollo. Al arrancar, `config` importa los
   modelos, carga las asociaciones y ejecuta la conexión y el sync.
   ↓
POR QUÉ SE NECESITA
   Es el cierre del ISS: si falta el cableado o la asociación, el arranque lo delata.
   ↓
QUÉ CREA O MODIFICA
   Levanta el servidor; con el sync puede crear/ajustar la tabla products y
   su relación con product_types.
   ↓
RESULTADO ESPERADO
   El servidor arranca SIN ERROR (y responde en el puerto 4000).
   ↓
CÓMO VERIFICARLO
   Ver la salida de arranque y probar las rutas con curl o los archivos .http.
   Detener con Ctrl+C.

Verificación de la FK con curl

COMANDO
   ↓
curl -s -X POST http://localhost:4000/api/productos …   # crear con product_type_id
curl -s http://localhost:4000/api/productos             # listar
   ↓
QUÉ HACE
   Envía un POST de producto y un GET de la colección.
   ↓
POR QUÉ SE NECESITA
   Comprueba que el service acepta un tipo activo y que el producto vuelve en la lista.
   ↓
QUÉ CREA O MODIFICA
   El POST inserta una fila en products; el GET solo lee.
   ↓
RESULTADO ESPERADO
   El POST responde con el producto creado; el GET con `{ "products": [...] }`.
   ↓
CÓMO VERIFICARLO
   Que el producto incluya `product_type_id` y `status: "active"`.
   Prueba también con un tipo inactivo: debe responder 400.

Flujos

El recorrido de un create, con la validación cruzada

sequenceDiagram
    autonumber
    participant U as Cliente HTTP
    participant C as ProductsController
    participant S as ProductsService
    participant TR as ProductTypesRepository
    participant PR as ProductsRepository
    participant DB as Base de datos

    U->>C: POST /api/productos
    C->>S: create(CreateProductDto)
    S->>TR: findById(product_type_id)
    TR-->>S: ProductType o null
    alt tipo inexistente
        S-->>C: AppError 404
        C-->>U: error controlado
    else tipo inactivo
        S-->>C: AppError 400
        C-->>U: error controlado
    else tipo activo
        S->>PR: create(data)
        PR->>DB: INSERT INTO products
        DB-->>S: fila insertada
        S-->>C: toProductResponse
        C-->>U: 201 con product
    end

Pregunta que responde: ¿por qué un create de producto puede fallar antes de tocar la base de datos?

Este es el flujo más rico del ISS: el service consulta otro feature antes de escribir. Si el tipo no existe → 404; si está inactivo → 400; si está activo → recién entonces se inserta.

Cómo un feature deja de ser una isla

flowchart TD
    A["Antes: product-types es una isla"] --> B["ISS-07: products nace con FK product_type_id"]
    B --> C["products.associations.ts define el vínculo"]
    C --> D["Product.belongsTo(ProductType) as product_type"]
    C --> E["ProductType.hasMany(Product) as products"]
    D --> F["config importa las asociaciones (side-effect)"]
    E --> F
    F --> G["Sequelize conoce la relación antes del sync"]

Pregunta que responde: ¿qué pasos convierten dos tablas sueltas en una relación?

Las capas, con dos repositories

classDiagram
    direction LR
    class ProductsController
    class ProductsService {
        +productTypesRepository
    }
    class ProductsRepository
    class ProductTypesRepository
    class Product
    class ProductType
    ProductsController --> ProductsService : usa
    ProductsService --> ProductsRepository : persiste
    ProductsService --> ProductTypesRepository : valida tipo activo
    ProductsRepository --> Product : solo aquí vive Sequelize
    ProductTypesRepository --> ProductType : reutilizado de ISS-06
    ProductType "1" --> "0..*" Product : clasifica

Pregunta que responde: ¿quién depende de quién cuando un service necesita dos features?

El service depende de dos repositories, pero sigue sin tocar Sequelize ni ProductType directamente. La relación entre features se establece por repositorios, no por modelos.

Asociaciones: el salto conceptual

Hasta ISS-06, cada feature era autónomo: clients no sabía de product-types, y product-types no sabía de nada. Podías borrar un feature entero sin tocar otro.

ISS-07 rompe esa independencia a propósito. products.product_type_id apunta a product_types, y esa referencia necesita declararse en tres sitios distintos:

1. La COLUMNA      product_type_id en la tabla products        (modelo)
2. La REGLA        el tipo debe existir y estar activo          (service)
3. La RELACIÓN     belongsTo / hasMany                          (products.associations.ts)

La pieza que probablemente te sorprenda es la tercera, porque no la usas todavía: en este ISS la respuesta de un producto sigue trayendo product_type_id como número, sin incluir el objeto del tipo. Entonces, ¿para qué declarar la asociación?

  • Porque Sequelize no deduce las relaciones de que exista una columna llamada product_type_id. Hay que decírselo.
  • Porque la relación es la que habilita, en ISS-08, consultar datos relacionados (include) y, sobre todo, que la FK se materialice al sincronizar.
  • Porque el side-effect de importar products.associations en config ocurre antes del sync; si lo importas después (o no lo importas), la relación no existe cuando se crea la tabla.

La relación real del dominio es 1:N: un tipo de producto clasifica muchos productos, y cada producto pertenece a un tipo.

erDiagram
    PRODUCT_TYPES ||--o{ PRODUCTS : "clasifica"
    PRODUCT_TYPES {
        int id PK
        string name
        string status
    }
    PRODUCTS {
        int id PK
        string name
        string brand
        decimal price
        int min_stock
        int quantity
        int product_type_id FK
        string status
    }

Pregunta que responde: ¿qué relación existe entre product_types y products y cómo se lee el diagrama?

Se lee así: PRODUCT_TYPES ||--o{ PRODUCTS significa «un tipo (exactamente uno) tiene cero o muchos productos». La barra doble del lado de PRODUCT_TYPES es el «uno»; la pata de gallo o{ del lado de PRODUCTS es el «muchos». La etiqueta clasifica es el alias del as usado en el código.

Idea que debes retener: declarar una relación no cambia el código de tu CRUD. El modelo, el DTO y el controller son idénticos a los de product-types. Lo único nuevo es una línea belongsTo, una hasMany y su import. Ese es el tamaño real del salto: conceptual grande, mecánico pequeño.

Recorrido del ISS, paso a paso

A partir de aquí viene el contenido técnico completo del ISS, verbatim: cada bloque de código, cada comando y cada criterio, tal cual. Solo se han degradado los encabezados un nivel para que aniden bajo esta sección, y se han reescrito los enlaces relativos a ../manual/ para que abran bien desde docs/aprendizaje/. Tú no copias nada: el generador inserta el cuerpo en el marcador.

Fase I: Business — ISS-07 — Feature Product (productos)

Contexto mínimo (autosuficiente). Si abres este archivo aislado —o lo llevas a otra IA—, esto es lo que hay que saber antes de ejecutarlo. - Proyecto: app-storelab-express-ii — Express 5 + TypeScript + Sequelize, arquitectura por features, Fase I solo Business (sin auth ni roles). - Recorrido obligatorio de una petición: HTTP → Controller → Service → Repository → Model → Sequelize → BD. Ninguna capa se salta a la siguiente. - Diseño de datos (columnas, tipos, FK RESTRICT, índices, transacciones): ../bd-storelab.md, Fase I — Business. - Capas, convenciones y reglas transversales: 00-contexto.md.

Este ISS
Título Feature Product (productos)
Feature / tabla products → products/
API /api/productos
Depende de ISS-06 — Feature ProductType
Habilita ISS-08 — Feature Sale + ProductSale

Contenido de este ISS

  • 12.1 Modelo Product
  • 12.2 DTO + Repository + Service + Controller + routes
  • 12.3 HTTP
  • 12.4 Cableado
  • 12.5 Relación ProductType ↔ Product (obligatorio al cerrar la tabla Product)
  • 12.6 Seeder + Swagger Product

Objetivo: CRUD de Product con FK product_type_id.
Bloqueado por: ISS-06.
API: /api/productos — SIN AUTH.

Criterios de aceptación (ISS-07)

  • [ ] 12.1 Modelo Product con product_type_id
  • [ ] 12.2 DTOs (dto/) + service que valida tipo activo en create/updatePut (usando ProductTypesRepository)
  • [ ] 12.3 Routes + http/ en orden getAll, getOne, create, update PUT/PATCH, delete físico y lógico
  • [ ] 12.4 Cableado routes/config
  • [ ] 12.5 Relaciones Product ↔ ProductType (archivo associations + import)
  • [ ] 12.6 Seeder + swagger
mkdir -p src/features/business/products/http

12.1 Modelo Product

: > src/features/business/products/product.model.ts
cat >> src/features/business/products/product.model.ts << 'EOF'
import { DataTypes, Model } from "sequelize";
import { sequelize } from "../../../database/db";

export interface ProductI {
  id?: number;
  name: string;
  brand: string;
  price: number;
  min_stock: number;
  quantity: number;
  product_type_id: number;
  status: "active" | "inactive";
  createdAt?: Date;
  updatedAt?: Date;
}

export class Product extends Model {
  public id!: number;
  public name!: string;
  public brand!: string;
  public price!: number;
  public min_stock!: number;
  public quantity!: number;
  public product_type_id!: number;
  public status!: "active" | "inactive";
  public readonly createdAt!: Date;
  public readonly updatedAt!: Date;
}

Product.init(
  {
    name: {
      type: DataTypes.STRING,
      allowNull: false,
    },
    brand: {
      type: DataTypes.STRING,
      allowNull: false,
    },
    price: {
      type: DataTypes.DECIMAL(12, 2),
      allowNull: false,
    },
    min_stock: {
      type: DataTypes.INTEGER,
      allowNull: false,
      defaultValue: 0,
    },
    quantity: {
      type: DataTypes.INTEGER,
      allowNull: false,
      defaultValue: 0,
    },
    product_type_id: {
      type: DataTypes.INTEGER,
      allowNull: false,
    },
    status: {
      type: DataTypes.ENUM("active", "inactive"),
      // Fail-safe: una fila insertada sin estado explícito NO queda visible en la API.
      // La vía de creación de la API siempre envía "active".
      defaultValue: "inactive",
      allowNull: false,
    },
  },
  {
    sequelize,
    modelName: "Product",
    tableName: "products",
    timestamps: true,
  }
);
EOF

12.2 DTO + Repository + Service + Controller + routes

Recordemos el flujo por capas del feature:

HTTP (routes) -> Controller -> Service -> Repository -> Model -> Sequelize -> BD

DTOs — carpeta dto/ (un archivo por operación)

mkdir -p src/features/business/products/dto

dto/create-product.dto.ts

: > src/features/business/products/dto/create-product.dto.ts
cat >> src/features/business/products/dto/create-product.dto.ts << 'EOF'
/** Datos de entrada de `POST /api/productos`. */
export interface CreateProductDto {
  name: string;
  brand: string;
  price: number;
  min_stock: number;
  quantity: number;
  product_type_id: number;
  /** Opcional: por defecto `active`. Tras crearlo, el estado sólo cambia con el borrado lógico. */
  status?: "active" | "inactive";
}
EOF

dto/update-product.dto.ts

: > src/features/business/products/dto/update-product.dto.ts
cat >> src/features/business/products/dto/update-product.dto.ts << 'EOF'
/**
 * Datos de entrada de `PUT /api/productos/:id` (reemplazo completo).
 *
 * `status` **no** está aquí a propósito: el estado sólo cambia con el borrado
 * lógico (`DELETE /api/productos/:id/deactivate`).
 */
export interface UpdateProductDto {
  name: string;
  brand: string;
  price: number;
  min_stock: number;
  quantity: number;
  product_type_id: number;
}
EOF

dto/patch-product.dto.ts

: > src/features/business/products/dto/patch-product.dto.ts
cat >> src/features/business/products/dto/patch-product.dto.ts << 'EOF'
import { UpdateProductDto } from "./update-product.dto";

/** Datos de entrada de `PATCH /api/productos/:id` (actualización parcial). */
export type PatchProductDto = Partial<UpdateProductDto>;
EOF

dto/product-response.dto.ts

: > src/features/business/products/dto/product-response.dto.ts
cat >> src/features/business/products/dto/product-response.dto.ts << 'EOF'
import { Product, ProductI } from "../product.model";

/**
 * Respuesta HTTP de un producto. Lo usan `GET /api/productos`,
 * `GET /api/productos/:id` y la salida de create/update/delete lógico.
 *
 * Regla del DTO de respuesta: la API expone **sólo** lo que declara este tipo.
 * `Product` no guarda campos internos, por eso el contrato coincide hoy con el
 * modelo. Si mañana aparece uno (ej. `cost`), la proyección se vuelve explícita
 * aquí y el mapper lo omite:
 *
 * ```ts
 * export type ProductResponseDto = Omit<ProductI, "cost">;
 * ```
 */
export type ProductResponseDto = ProductI;

/** Mapper modelo -> DTO de respuesta (objeto plano, sin métodos de Sequelize). */
export function toProductResponse(product: Product): ProductResponseDto {
  return product.toJSON() as ProductResponseDto;
}
EOF

dto/index.ts

: > src/features/business/products/dto/index.ts
cat >> src/features/business/products/dto/index.ts << 'EOF'
export * from "./create-product.dto";
export * from "./update-product.dto";
export * from "./patch-product.dto";
export * from "./product-response.dto";
EOF
  • products.repository.ts → acceso a Product (incluye variantes con transaction/lock para ventas).
  • products.service.ts → reglas de negocio: status por defecto y el tipo de producto debe existir y estar activo (lee ProductTypesRepository, no el modelo directamente).
  • products.controller.ts → solo HTTP.

Repository

: > src/features/business/products/products.repository.ts
cat >> src/features/business/products/products.repository.ts << 'EOF'
import { CreationAttributes, Transaction } from "sequelize";
import { Product, ProductI } from "./product.model";

/**
 * Capa Repository del feature Products.
 *
 * Única responsable de hablar con Sequelize (el modelo `Product`).
 * Expone variantes con `transaction`/`lock` que usan los flujos de ventas.
 */
export class ProductsRepository {
  /** Todos los productos activos. */
  public async findAllActive(): Promise<Product[]> {
    return Product.findAll({ where: { status: "active" } });
  }

  /** Un producto por PK (o `null`). */
  public async findById(id: number, transaction?: Transaction): Promise<Product | null> {
    return Product.findByPk(id, { transaction });
  }

  /** Un producto por PK bloqueando la fila (`SELECT ... FOR UPDATE`). */
  public async findByIdForUpdate(
    id: number,
    transaction: Transaction
  ): Promise<Product | null> {
    return Product.findByPk(id, { transaction, lock: transaction.LOCK.UPDATE });
  }

  /** Inserta un producto. */
  public async create(data: CreationAttributes<Product>): Promise<Product> {
    return Product.create(data);
  }

  /** Persiste cambios sobre una instancia existente. */
  public async update(
    product: Product,
    data: Partial<ProductI>,
    transaction?: Transaction
  ): Promise<Product> {
    return product.update(data, { transaction });
  }

  /** Elimina físicamente una instancia. */
  public async delete(product: Product): Promise<void> {
    await product.destroy();
  }
}
EOF

Service

ProductsService inyecta dos repositories: el propio ProductsRepository y el ProductTypesRepository de otro feature. Así el service orquesta reglas entre entidades sin romper las capas.

: > src/features/business/products/products.service.ts
cat >> src/features/business/products/products.service.ts << 'EOF'
import {
  CreateProductDto,
  PatchProductDto,
  ProductResponseDto,
  UpdateProductDto,
  toProductResponse,
} from "./dto";
import { ProductsRepository } from "./products.repository";
import { ProductTypesRepository } from "../product-types/product-types.repository";
import { Product } from "./product.model";
import { AppError } from "../../../shared/errors/app-error";

/**
 * Capa Service del feature Products.
 *
 * Reglas de negocio: default de `status`, política de borrado lógico y
 * validación de que el tipo de producto referenciado exista y esté activo.
 *
 * Delega la persistencia en su repository y la lectura de tipos en el
 * repository de ProductTypes. Devuelve **DTOs** (carpeta `dto/`).
 */
export class ProductsService {
  public constructor(
    private readonly repository: ProductsRepository = new ProductsRepository(),
    private readonly productTypesRepository: ProductTypesRepository = new ProductTypesRepository()
  ) {}

  // ================== READ ==================
  public async getAll(): Promise<ProductResponseDto[]> {
    const products = await this.repository.findAllActive();
    return products.map((product) => toProductResponse(product));
  }

  public async getOne(id: number): Promise<ProductResponseDto> {
    return toProductResponse(await this.findOrFail(id));
  }

  // ================== CREATE ==================
  public async create(body: CreateProductDto): Promise<ProductResponseDto> {
    await this.assertActiveProductType(body.product_type_id);

    // Copia campo a campo a propósito (evita *mass assignment*).
    const product = await this.repository.create({
      name: body.name,
      brand: body.brand,
      price: body.price,
      min_stock: body.min_stock,
      quantity: body.quantity,
      product_type_id: body.product_type_id,
      status: body.status ?? "active",
    });
    return toProductResponse(product);
  }

  // ================== UPDATE ==================
  public async updatePut(id: number, body: UpdateProductDto): Promise<ProductResponseDto> {
    const product = await this.findOrFail(id);

    await this.assertActiveProductType(body.product_type_id);

    await this.repository.update(product, {
      name: body.name,
      brand: body.brand,
      price: body.price,
      min_stock: body.min_stock,
      quantity: body.quantity,
      product_type_id: body.product_type_id,
    });
    return toProductResponse(product);
  }

  public async updatePatch(id: number, body: PatchProductDto): Promise<ProductResponseDto> {
    const product = await this.findOrFail(id);

    if (body.product_type_id !== undefined) {
      await this.assertActiveProductType(body.product_type_id);
    }

    await this.repository.update(product, body);
    return toProductResponse(product);
  }

  // ================== DELETE ==================
  /** Eliminación física. */
  public async deletePhysical(id: number): Promise<void> {
    // `onlyActive: false` -> también permite purgar un registro ya desactivado.
    const product = await this.findOrFail(id, false);
    await this.repository.delete(product);
  }

  /** Eliminación lógica -> `status = inactive`. */
  public async deleteLogical(id: number): Promise<ProductResponseDto> {
    const product = await this.findOrFail(id);

    await this.repository.update(product, { status: "inactive" });
    return toProductResponse(product);
  }

  // ================== HELPERS ==================
  /**
   * Busca por PK y falla con 404 si no existe.
   *
   * `onlyActive` (por defecto `true`) aplica la **política de borrado lógico**:
   * un registro `inactive` deja de ser visible para la API, igual que en
   * `getAll`.
   */
  private async findOrFail(id: number, onlyActive = true): Promise<Product> {
    const product = await this.repository.findById(id);
    if (!product || (onlyActive && product.status !== "active")) {
      throw new AppError(404, "Product not found");
    }
    return product;
  }

  /**
   * Regla: el tipo de producto referenciado debe existir y estar activo.
   *
   * Aquí el 400 (y no el 404 de `findOrFail`) es intencional: el recurso de la
   * URL sí existe, lo que falla es la **referencia** que se quiere asignar.
   */
  private async assertActiveProductType(product_type_id: number): Promise<void> {
    const productType = await this.productTypesRepository.findById(product_type_id);
    if (!productType) {
      throw new AppError(404, "Product type not found");
    }
    if (productType.status !== "active") {
      throw new AppError(400, "Product type must be active");
    }
  }
}
EOF

Controller

: > src/features/business/products/products.controller.ts
cat >> src/features/business/products/products.controller.ts << 'EOF'
import { Request, Response } from "express";
import { BaseController } from "../../../shared/http/base-controller";
import { CreateProductDto, PatchProductDto, UpdateProductDto } from "./dto";
import { ProductsService } from "./products.service";

/**
 * Capa Controller del feature Products.
 * Solo HTTP: lee `req`, llama al service y arma la respuesta.
 * El manejo de errores se delega en `run()` (ver `BaseController`).
 */
export class ProductsController extends BaseController {
  public constructor(
    private readonly service: ProductsService = new ProductsService()
  ) {
    super();
  }

  // ================== READ ==================
  public async getAll(_req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const products = await this.service.getAll();
      res.status(200).json({ products });
    });
  }

  public async getOne(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const product = await this.service.getOne(this.paramId(req));
      res.status(200).json({ product });
    });
  }

  // ================== CREATE ==================
  public async create(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const product = await this.service.create(req.body as CreateProductDto);
      res.status(201).json({ product });
    });
  }

  // ================== UPDATE ==================
  public async updatePut(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const product = await this.service.updatePut(
        this.paramId(req),
        req.body as UpdateProductDto
      );
      res.status(200).json({ product });
    });
  }

  public async updatePatch(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const product = await this.service.updatePatch(
        this.paramId(req),
        req.body as PatchProductDto
      );
      res.status(200).json({ product });
    });
  }

  // ================== DELETE ==================
  /** Eliminación física. */
  public async deletePhysical(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const id = this.paramId(req);
      await this.service.deletePhysical(id);
      res.status(200).json({ message: "Product permanently deleted", id });
    });
  }

  /** Eliminación lógica -> `status = inactive`. */
  public async deleteLogical(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const product = await this.service.deleteLogical(this.paramId(req));
      res.status(200).json({
        message: "Product deactivated (logical delete)",
        product,
      });
    });
  }
}
EOF
: > src/features/business/products/products.routes.ts
cat >> src/features/business/products/products.routes.ts << 'EOF'
import { Application } from "express";
import { ProductsController } from "./products.controller";

export class ProductsRoutes {
  public productsController: ProductsController = new ProductsController();

  public routes(app: Application): void {
    // ================== RUTAS SIN AUTENTICACIÓN / SIN MIDDLEWARE JWT ==================

    // getAll
    app
      .route("/api/productos")
      .get(this.productsController.getAll.bind(this.productsController));

    // getOne
    app
      .route("/api/productos/:id")
      .get(this.productsController.getOne.bind(this.productsController));

    // create
    app
      .route("/api/productos")
      .post(this.productsController.create.bind(this.productsController));

    // update (PUT / PATCH)
    app
      .route("/api/productos/:id")
      .put(this.productsController.updatePut.bind(this.productsController))
      .patch(this.productsController.updatePatch.bind(this.productsController));

    // delete físico
    app
      .route("/api/productos/:id")
      .delete(this.productsController.deletePhysical.bind(this.productsController));

    // delete lógico
    app
      .route("/api/productos/:id/deactivate")
      .patch(this.productsController.deleteLogical.bind(this.productsController));
  }
}
EOF


12.3 HTTP

: > src/features/business/products/http/products.get.http
cat >> src/features/business/products/http/products.get.http << 'EOF'
### Feature Product — GET ALL / GET ONE
### Modalidad JWT + RBAC: `authenticate` (401 sin token) + `authorize` (403 sin concesión).
@baseUrl = http://localhost:4000

# @name loginAdmin
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json

{
  "identifier": "admin",
  "password": "Admin123!"
}

@token = {{loginAdmin.response.body.$.access_token}}
@id = 1

# @name getAllProducts
GET {{baseUrl}}/api/productos
Authorization: Bearer {{token}}

###

# @name getOneProduct
GET {{baseUrl}}/api/productos/{{id}}
Authorization: Bearer {{token}}

### 401 — sin token
GET {{baseUrl}}/api/productos
EOF

: > src/features/business/products/http/products.create.http
cat >> src/features/business/products/http/products.create.http << 'EOF'
### Feature Product — CREATE
### Modalidad JWT + RBAC.
@baseUrl = http://localhost:4000

# @name loginAdmin
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json

{
  "identifier": "admin",
  "password": "Admin123!"
}

@token = {{loginAdmin.response.body.$.access_token}}

# @name createProduct
POST {{baseUrl}}/api/productos
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "name": "Laptop Pro",
  "brand": "TechBrand",
  "price": 1299.99,
  "min_stock": 5,
  "quantity": 50,
  "product_type_id": 1,
  "status": "active"
}
EOF
: > src/features/business/products/http/products.update.http
cat >> src/features/business/products/http/products.update.http << 'EOF'
### Feature Product — UPDATE (PUT) / UPDATE (PATCH)
### Modalidad JWT + RBAC. `status` no se envía: el estado solo cambia con
### PATCH {{baseUrl}}/api/productos/{{id}}/deactivate (borrado lógico).
@baseUrl = http://localhost:4000

# @name loginAdmin
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json

{
  "identifier": "admin",
  "password": "Admin123!"
}

@token = {{loginAdmin.response.body.$.access_token}}
@id = 1

# @name updateProductPut
PUT {{baseUrl}}/api/productos/{{id}}
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "name": "Laptop Pro Max",
  "brand": "TechBrand",
  "price": 1499.99,
  "min_stock": 5,
  "quantity": 40,
  "product_type_id": 1
}

###

# @name updateProductPatch
PATCH {{baseUrl}}/api/productos/{{id}}
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "price": 1399.99,
  "quantity": 45
}
EOF
: > src/features/business/products/http/products.delete.http
cat >> src/features/business/products/http/products.delete.http << 'EOF'
### Feature Product — DELETE físico / DELETE lógico (status = inactive)
### Modalidad JWT + RBAC.
@baseUrl = http://localhost:4000

# @name loginAdmin
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json

{
  "identifier": "admin",
  "password": "Admin123!"
}

@token = {{loginAdmin.response.body.$.access_token}}
@id = 1

# @name deleteProductPhysical
DELETE {{baseUrl}}/api/productos/{{id}}
Authorization: Bearer {{token}}

###

# @name deleteProductLogical
PATCH {{baseUrl}}/api/productos/{{id}}/deactivate
Authorization: Bearer {{token}}
EOF


12.4 Cableado

PARCHE — src/routes/index.ts:

  • Debajo de import ProductTypesRoutes, añadir ProductsRoutes.
  • Dentro de Routes, añadir productsRoutes.

PARCHE — src/config/index.ts:

  • Debajo de import product-type.model, añadir import "../features/business/products/product.model";
  • Dentro de routes(), añadir this.routePrv.productsRoutes.routes(this.app);

12.5 Relación ProductType ↔ Product (obligatorio al cerrar la tabla Product)

Norma FK: product_type_id (tabla product_types → singular product_type + _id).

Cuando una tabla nueva se relaciona con una ya existente, al final se agrega este paso: archivo de asociaciones + PARCHE en config para cargarlo (side-effect).

: > src/features/business/products/products.associations.ts
cat >> src/features/business/products/products.associations.ts << 'EOF'
import { Product } from "./product.model";
import { ProductType } from "../product-types/product-type.model";

Product.belongsTo(ProductType, { foreignKey: "product_type_id", as: "product_type" });
ProductType.hasMany(Product, { foreignKey: "product_type_id", as: "products" });
EOF
PARCHE — src/config/index.ts ya existe.

Debajo de los imports de modelos Product / ProductType (y encima de import { Routes }), añadir:

import "../features/business/products/products.associations";

Archivo nuevo (lab — alinea FK camelCase → snake_case antes del sync):

PARCHE — src/config/index.ts: debajo de import { sequelize, getDatabaseInfo, testConnection } from "../database/db";, añadir:


Dentro de dbConnection(), reemplazar el bloque de sequelize.sync(...) por el de src/config/index.ts del repo (SET FOREIGN_KEY_CHECKS en MySQL, y opcional DB_SYNC_FORCE=true). Con BD limpia no hace falta rename legacy.

Esto registra en Sequelize:

  • Product.belongsTo(ProductType, { foreignKey: "product_type_id", as: "product_type" })
  • ProductType.hasMany(Product, { foreignKey: "product_type_id", as: "products" })

Verificación relación

curl -s -X POST http://localhost:4000/api/productos -H 'Content-Type: application/json' \
  -d '{"name":"Cola","brand":"ACME","price":2.5,"min_stock":5,"quantity":100,"product_type_id":1,"status":"active"}'
curl -s http://localhost:4000/api/productos

12.6 Seeder + Swagger Product

: > src/features/business/products/products.seeder.ts
cat >> src/features/business/products/products.seeder.ts << 'EOF'
import { faker } from "@faker-js/faker";
import { Product } from "./product.model";
import { ProductType } from "../product-types/product-type.model";

/**
 * Seeder del feature Product (datos falsos con @faker-js/faker).
 * Se invoca desde `src/database/seeders` (SeedersRunner), no desde la App.
 *
 * Requiere tipos de producto activos. Idempotente: si ya hay filas, no inserta.
 */
export async function seedProducts(count: number): Promise<number> {
  if (count <= 0) {
    console.log("⏭️  products: count=0, se omite");
    return 0;
  }

  const existing = await Product.count();
  if (existing > 0) {
    console.log(`⏭️  products: ya hay ${existing} registro(s), se omite seeder`);
    return 0;
  }

  const types = await ProductType.findAll({ where: { status: "active" } });
  if (types.length === 0) {
    console.log("⏭️  products: no hay tipos de producto activos, se omite seeder");
    return 0;
  }

  const rows = Array.from({ length: count }, () => {
    const type = types[Math.floor(Math.random() * types.length)];
    return {
      name: faker.commerce.productName(),
      brand: faker.company.name(),
      price: Number(faker.commerce.price({ min: 5, max: 500, dec: 2 })),
      min_stock: faker.number.int({ min: 1, max: 10 }),
      quantity: faker.number.int({ min: 20, max: 100 }),
      product_type_id: type.id,
      status: "active" as const,
    };
  });

  await Product.bulkCreate(rows);
  console.log(`✅ products: insertados ${count} registro(s) falsos`);
  return count;
}
EOF
: > src/features/business/products/products.swagger.ts
cat >> src/features/business/products/products.swagger.ts << 'EOF'
import {
  bearerSecurity,
  forbiddenResponse,
  unauthorizedResponse,
} from "../../../shared/http/swagger-security";

/**
 * Documentación OpenAPI del feature Product.
 * Se agrega desde `src/swagger` (registry externo), no se monta aquí.
 *
 * Leyenda: todos los endpoints son **JWT + RBAC** (`authenticate` + `authorize`).
 * La modalidad se declara por operación: aquí heredan el `security` global del documento.
 */

export const productsSwagger = {
  tags: [
    {
      name: "Productos",
      description: "CRUD de productos — **JWT + RBAC** (authenticate + authorize)",
    },
  ],
  paths: {
    "/api/productos": {
      get: {
        tags: ["Productos"],
        summary: "Listar productos activos",
        description: "JWT + RBAC — retorna productos con status=active",
        security: bearerSecurity,
        responses: {
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "200": {
            description: "Lista de productos",
            content: {
              "application/json": {
                schema: {
                  type: "object",
                  properties: {
                    products: {
                      type: "array",
                      items: { $ref: "#/components/schemas/Product" },
                    },
                  },
                },
              },
            },
          },
        },
      },
      post: {
        tags: ["Productos"],
        summary: "Crear producto",
        description: "JWT + RBAC — product_type_id debe existir y estar active",
        requestBody: {
          required: true,
          content: {
            "application/json": {
              schema: { $ref: "#/components/schemas/ProductCreate" },
            },
          },
        },
        security: bearerSecurity,
        responses: {
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "201": {
            description: "Producto creado",
            content: {
              "application/json": {
                schema: {
                  type: "object",
                  properties: {
                    product: { $ref: "#/components/schemas/Product" },
                  },
                },
              },
            },
          },
          "400": { description: "Tipo de producto inactivo" },
          "404": { description: "Tipo de producto no encontrado" },
        },
      },
    },
    "/api/productos/{id}": {
      get: {
        tags: ["Productos"],
        summary: "Obtener producto por id",
        description: "JWT + RBAC — 404 si no existe o tiene borrado lógico",
        parameters: [
          {
            name: "id",
            in: "path",
            required: true,
            description: "id numérico (> 0). Si no lo es, la API responde 400.",
            schema: { type: "integer", minimum: 1 },
          },
        ],
        security: bearerSecurity,
        responses: {
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "200": {
            description: "Producto encontrado",
            content: {
              "application/json": {
                schema: {
                  type: "object",
                  properties: {
                    product: { $ref: "#/components/schemas/Product" },
                  },
                },
              },
            },
          },
          "400": { description: "id inválido (debe ser un entero positivo)" },
          "404": { description: "No encontrado" },
        },
      },
      put: {
        tags: ["Productos"],
        summary: "Actualizar producto (PUT — reemplazo)",
        description: "JWT + RBAC — product_type_id debe existir y estar active",
        parameters: [
          {
            name: "id",
            in: "path",
            required: true,
            description: "id numérico (> 0). Si no lo es, la API responde 400.",
            schema: { type: "integer", minimum: 1 },
          },
        ],
        requestBody: {
          required: true,
          content: {
            "application/json": {
              schema: { $ref: "#/components/schemas/ProductUpdate" },
            },
          },
        },
        security: bearerSecurity,
        responses: {
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "200": { description: "Actualizado" },
          "400": { description: "id inválido (entero positivo) o tipo de producto inexistente/inactivo" },
          "404": { description: "No encontrado" },
        },
      },
      patch: {
        tags: ["Productos"],
        summary: "Actualizar producto (PATCH — parcial)",
        description: "JWT + RBAC",
        parameters: [
          {
            name: "id",
            in: "path",
            required: true,
            description: "id numérico (> 0). Si no lo es, la API responde 400.",
            schema: { type: "integer", minimum: 1 },
          },
        ],
        requestBody: {
          required: true,
          content: {
            "application/json": {
              schema: { $ref: "#/components/schemas/ProductPatch" },
            },
          },
        },
        security: bearerSecurity,
        responses: {
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "200": { description: "Actualizado" },
          "400": { description: "id inválido (debe ser un entero positivo)" },
          "404": { description: "No encontrado" },
        },
      },
      delete: {
        tags: ["Productos"],
        summary: "Eliminar producto (físico)",
        description: "JWT + RBAC — borra la fila (también si tiene borrado lógico)",
        parameters: [
          {
            name: "id",
            in: "path",
            required: true,
            description: "id numérico (> 0). Si no lo es, la API responde 400.",
            schema: { type: "integer", minimum: 1 },
          },
        ],
        security: bearerSecurity,
        responses: {
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "200": { description: "Eliminado" },
          "400": { description: "id inválido (debe ser un entero positivo)" },
          "404": { description: "No encontrado" },
        },
      },
    },
    "/api/productos/{id}/deactivate": {
      patch: {
        tags: ["Productos"],
        summary: "Eliminar producto (lógico)",
        description: "JWT + RBAC — status = inactive",
        parameters: [
          {
            name: "id",
            in: "path",
            required: true,
            description: "id numérico (> 0). Si no lo es, la API responde 400.",
            schema: { type: "integer", minimum: 1 },
          },
        ],
        security: bearerSecurity,
        responses: {
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "200": { description: "Desactivado" },
          "400": { description: "id inválido (debe ser un entero positivo)" },
          "404": { description: "No encontrado" },
        },
      },
    },
  },
  components: {
    schemas: {
      Product: {
        type: "object",
        properties: {
          id: { type: "integer", example: 1 },
          name: { type: "string", example: "Laptop Pro" },
          brand: { type: "string", example: "TechBrand" },
          price: { type: "number", example: 1299.99 },
          min_stock: { type: "integer", example: 5 },
          quantity: { type: "integer", example: 50 },
          product_type_id: { type: "integer", example: 1 },
          status: { type: "string", enum: ["active", "inactive"], example: "active" },
          createdAt: { type: "string", format: "date-time" },
          updatedAt: { type: "string", format: "date-time" },
        },
      },
      ProductCreate: {
        type: "object",
        required: ["name", "brand", "price", "min_stock", "quantity", "product_type_id"],
        properties: {
          name: { type: "string" },
          brand: { type: "string" },
          price: { type: "number" },
          min_stock: { type: "integer" },
          quantity: { type: "integer" },
          product_type_id: { type: "integer" },
          status: { type: "string", enum: ["active", "inactive"], default: "active" },
        },
      },
      ProductUpdate: {
        type: "object",
        required: ["name", "brand", "price", "min_stock", "quantity", "product_type_id"],
        properties: {
          name: { type: "string" },
          brand: { type: "string" },
          price: { type: "number" },
          min_stock: { type: "integer" },
          quantity: { type: "integer" },
          product_type_id: { type: "integer" },
        },
      },
      ProductPatch: {
        type: "object",
        properties: {
          name: { type: "string" },
          brand: { type: "string" },
          price: { type: "number" },
          min_stock: { type: "integer" },
          quantity: { type: "integer" },
          product_type_id: { type: "integer" },
        },
      },
    },
  },
};
EOF
PARCHE counts / SeedersRunner / swagger registry: añadir products (default 15), seedProducts, productsSwagger (mismo patrón que ISS-06).

Cierre del ISS

npm run dev

El servidor debe arrancar sin error. Detenerlo con Ctrl+C antes de continuar.

Diagnóstico

Síntoma Causa probable Solución
Crear producto con un tipo activo devuelve 404 El product_type_id no existe (o llega mal) Verificar el id con GET /api/tipos-producto/:id
Crear producto con un tipo inactivo devuelve 404 en vez de 400 El tipo tiene borrado lógico: findById lo devuelve, pero... revisa el orden de las comprobaciones El 404 es si no existe; el 400, si existe pero no está activo. Comprobar ambos casos
curl /api/productos responde 404 Falta el cableado en routes/index.ts Añadir import, propiedad productsRoutes y su llamada en routes()
Error al arrancar por la relación/FK No se importó products.associations en config antes del sync Añadir el import de side-effect
El seeder de productos inserta 0 filas No hay tipos activos en product_types Sembrar primero los tipos (o revisar que el seeder de tipos corrió)
ProductType es undefined en products.associations.ts Import circular o ruta de import incorrecta Importar desde ../product-types/product-type.model
El PATCH de un producto no revalida el tipo El cuerpo no trae product_type_id Es intencional: solo se valida si el campo viene (!== undefined)
npx tsc --noEmit falla por un símbolo del modelo Ruta relativa mal ajustada Revisar la profundidad de ../../../

Pregunta que responde: si algo falla al terminar este ISS, ¿por dónde empiezo a mirar?

Conexión con el resto del curso

Este ISS cierra el trío clients → product-types → products y prepara el terreno de las ventas.

  • Lo que reutiliza:
  • de ISS-03: todo el patrón de feature (capas, BaseController.run/paramId, AppError/findOrFail, DTOs y mapper, convención de nombres). Los pasos 1–5 de la ruta son, literalmente, ese patrón aplicado otra vez;
  • de ISS-06: el ProductTypesRepository, que aquí se inyecta en ProductsService para validar la referencia; y el mismo esquema de seeder y Swagger;
  • de ISS-04 e ISS-05: el SeedersRunner/counts y el registry de Swagger.
  • Lo que introduce y habilitará:
  • las asociaciones (products.associations.ts), que en ISS-08 se multiplican (Sale ↔ Client, ProductSale ↔ Product, Sale ↔ ProductSale) y se combinan con transacciones;
  • las costuras de ProductsRepository (transaction, findByIdForUpdate), que ISS-08 usará de verdad para mover stock;
  • la validación de referencias activas (404 vs 400), el mismo criterio que ISS-08 aplicará al cliente de una venta.
  • Qué NO se toca: las rutas siguen SIN AUTH. En ISS-13 pasarán a JWT + RBAC conservando la estructura del feature.

Pregunta que responde: ¿qué aprendido antes me sirve aquí y qué habilita este ISS después?

Glosario

Término Significado
FK (clave foránea) Columna que referencia la PK de otra tabla; aquí products.product_type_id → product_types.id
belongsTo Asociación Sequelize «pertenece a»: se declara en el modelo hijo (Product)
hasMany Asociación Sequelize «tiene muchos»: se declara en el modelo padre (ProductType)
as (alias) Nombre con el que Sequelize expone la relación (product_type, products)
Archivo de asociaciones products.associations.ts: no exporta nada; su efecto es registrar las relaciones
Import de side-effect Import cuyo único propósito es ejecutar el código del módulo (registrar las asociaciones)
onDelete RESTRICT Regla de FK: no se borra un padre con hijos. La define el esquema de datos
404 vs 400 (referencia) 404 = la referencia no existe; 400 = existe pero está inactiva
assertActiveProductType Helper del service que aplica la regla anterior
Idempotente Que no repite el efecto: un seeder que no reinserta si ya hay filas
findByIdForUpdate Variante del repository que bloquea la fila (SELECT … FOR UPDATE); costura para ISS-08
Transacción Operación atómica de varias escrituras; en este ISS solo aparece como parámetro opcional
Feature en isla Feature sin relaciones con otros; clients y product-types lo eran

Pregunta que responde: ¿qué vocabulario nuevo debo manejar al terminar este ISS?

Criterios de aceptación

Los del ISS, textuales, como checklist:

  • [ ] 12.1 Modelo Product con product_type_id
  • [ ] 12.2 DTOs (dto/) + service que valida tipo activo en create/updatePut (usando ProductTypesRepository)
  • [ ] 12.3 Routes + http/ en orden getAll, getOne, create, update PUT/PATCH, delete físico y lógico
  • [ ] 12.4 Cableado routes/config
  • [ ] 12.5 Relaciones Product ↔ ProductType (archivo associations + import)
  • [ ] 12.6 Seeder + swagger

Evaluación

Preguntas de comprensión

  1. ¿Por qué ProductsService inyecta DOS repositories en vez de importar el modelo ProductType directamente? Porque cada repository es la única puerta a su modelo. Si ProductsService usara ProductType directamente, se saltaría las capas del feature product-types y la regla «el repository es la única capa que toca Sequelize» quedaría rota. Inyectar ProductTypesRepository permite orquestar una regla entre entidades sin acoplar los modelos. Es la forma de relacionar features sin fusionarlos.

  2. ¿Por qué el tipo inactivo produce un 400 y no un 404, si el service «no lo encuentra activo»? Porque son situaciones distintas. El recurso de la URL (el producto a crear) sí es válido; lo que falla es la referencia que se intenta asignar. Si el tipo no existe en absoluto → 404 («no encontrado»). Si existe pero está inactivo → 400 («la petición referencia algo que no puede usar»). Devolver 404 en ambos casos ocultaría la diferencia y confundiría al cliente.

  3. ¿Por qué products.associations.ts no exporta nada y aun así hay que importarlo? Porque el código que registra la relación (belongsTo/hasMany) se ejecuta al cargar el módulo. El valor no es lo que exporta, sino el efecto de haberlo importado. Por eso se llama import de side-effect y por eso config lo importa antes del sync: la relación debe existir cuando Sequelize crea las tablas.

  4. ¿Qué pasa si olvidas el import de asociaciones pero sí creas el modelo y el CRUD? Que el CRUD básico puede funcionar (la columna product_type_id existe), pero Sequelize no conoce la relación: no puedes usar include/as, y la integridad de la FK al sincronizar no queda registrada. Es un fallo silencioso: nada revienta, simplemente la relación «no está». Por eso 12.5 es obligatorio.

  5. La relación es 1:N, ¿quién es el «1» y quién es el «N»? Explica con as. El «1» es ProductType (un tipo), el «N» son Product (muchos productos). En el código: Product.belongsTo(ProductType, { as: "product_type" }) (cada producto tiene un product_type) y ProductType.hasMany(Product, { as: "products" }) (cada tipo tiene muchos products). Ambas declaraciones describen la misma relación desde los dos lados.

  6. ¿Por qué el seeder de productos se omite si no hay tipos activos, en lugar de crear el tipo automáticamente? Porque el seeder respeta el dominio: un producto necesita un tipo válido. Crear el tipo «a escondidas» duplicaría la responsabilidad del seeder de tipos y podría generar datos incoherentes. Omitirse es una decisión segura y explícita (console.log) que revela un problema de orden: hay que sembrar los tipos primero.

  7. ¿Por qué el updatePatch solo valida product_type_id «si viene en el cuerpo»? Porque un PATCH es parcial: puede actualizar solo price, sin tocar el tipo. Validar un product_type_id que no se envió no tiene sentido y podría fallar con undefined. La condición body.product_type_id !== undefined distingue «no lo envíes» de «envíalo para cambiarlo».

  8. Si mañana quieres que la respuesta de un producto incluya el objeto del tipo, ¿dónde tocarías? No bastaría con el DTO: harías un include en el repository (aprovechando que la asociación ya existe) y ajustarías el mapper toProductResponse para exponer el tipo. Este ISS no lo pide, pero deja la relación lista precisamente para habilitarlo. Es un buen ejemplo de por qué conviene declarar la asociación aunque no se use todavía.

Ejercicios

Ejercicio 1 — Traza los códigos de error. Enumera, para un POST /api/productos, qué código HTTP esperas en cada caso: (a) tipo inexistente, (b) tipo existente pero inactivo, (c) tipo activo, (d) :id no entero en un PUT. Justifica cada uno.

Respuesta razonada | Caso | Código | Por qué | |---|---|---| | (a) tipo inexistente | **404** | `assertActiveProductType` no encuentra el tipo (`Product type not found`) | | (b) tipo inactivo | **400** | el tipo existe, pero no se puede referenciar (`Product type must be active`) | | (c) tipo activo | **201** | se crea el producto (`{ product }`) | | (d) `:id` no entero | **400** | lo detecta `paramId()` antes de llegar al service | La clave es distinguir «recurso no encontrado» (404) de «petición inválida» (400). Y notar que (d) ni siquiera toca la lógica de negocio: `paramId` corta antes.

Ejercicio 2 — La relación, en tres lugares. Explica los tres sitios donde se materializa la relación Product ↔ ProductType y qué aporta cada uno.

Respuesta razonada 1. **La columna** `product_type_id` en el modelo `Product`: sin ella, no hay dónde guardar la referencia. 2. **La regla** en `ProductsService.assertActiveProductType`: sin ella, se podrían guardar referencias a tipos inexistentes o inactivos (integridad **de negocio**). 3. **La asociación** en `products.associations.ts` + su import en `config`: sin ella, Sequelize no conoce la relación (integridad **a nivel de ORM** y base para `include`). Faltar cualquiera de las tres deja la relación incompleta: columna sin regla → datos basura; columna sin asociación → no hay relación ORM; regla sin columna → no compila.

Ejercicio 3 — Diagnostica el seeder mudo. Ejecutas npm run db:seed y ves ⏭️ products: no hay tipos de producto activos, se omite seeder. El CRUD de productos funciona manual. ¿Qué pasó y cómo lo arreglas?

Respuesta razonada El seeder de productos se ejecutó antes (o en una corrida donde) no había tipos activos. El mensaje no es un error, es una omisión segura: el seeder **se niega** a crear productos sin tipo. Causas: el seeder de tipos no corrió, se ejecutó después, o los tipos quedaron `inactive`. Solución: sembrar/verificar los tipos primero (`product_types` con `status: "active"`), confirmar con `GET /api/tipos-producto`, y volver a correr `npm run db:seed`. Recuerda que ambos seeders son idempotentes: si `products` ya tiene filas, el de productos se omitirá otra vez por otra razón distinta, y el log te dirá cuál.

GATE

Para cerrar el ISS-07, ejecuta:

npm run dev

Resultado esperado: el servidor debe arrancar sin error (conexión OK, sync OK, tabla products creada y relación registrada). Detenerlo con Ctrl+C antes de continuar.

Verificación funcional del feature y de la relación (con el servidor en marcha):

curl -s -X POST http://localhost:4000/api/productos -H 'Content-Type: application/json' \
  -d '{"name":"Cola","brand":"ACME","price":2.5,"min_stock":5,"quantity":100,"product_type_id":1,"status":"active"}'
curl -s http://localhost:4000/api/productos

Resultado esperado: el POST devuelve el producto creado (con su product_type_id) y el GET devuelve { "products": [...] }. Si envías un product_type_id inexistente, debes recibir 404; si el tipo está inactivo, 400.

Checklist de cierre:

  • [ ] 12.1 Modelo Product con product_type_id
  • [ ] 12.2 DTOs + service que valida tipo activo en create/updatePut (usando ProductTypesRepository)
  • [ ] 12.3 Routes + http/ en orden getAll, getOne, create, update PUT/PATCH, delete físico y lógico
  • [ ] 12.4 Cableado routes/config
  • [ ] 12.5 Relaciones Product ↔ ProductType (archivo associations + import)
  • [ ] 12.6 Seeder + swagger
  • [ ] npm run dev arranca sin error

Con los seis criterios —y la relación— en verde, el ISS-07 está cumplido y puedes pasar al ISS-08 — Feature Sale + ProductSale, donde las asociaciones se multiplican y aparecen las transacciones que mueven el stock.


Navegación de la ruta: ← ISS-06 · 🛠 Construir · ↑ Ruta Express · → ISS-07 · 🛠 Construir