📚 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.
🎬 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.
findByIdForUpdatequeda 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
configpara 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:
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
- Ficha del ISS
- Mapa mental del ISS
- Mapa del backend
- Árbol de archivos
- Anatomía del código
- Comandos explicados
- Flujos
- Asociaciones: el salto conceptual
- Recorrido del ISS, paso a paso
- Diagnóstico
- Conexión con el resto del curso
- Glosario
- Criterios de aceptación
- Evaluación
- 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
productscon 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) ydelete(físico y lógico). - Una regla de negocio nueva:
ProductsServicevalida que elproduct_type_idexista (404) y esté activo (400), reutilizando elProductTypesRepositoryde ISS-06. - El archivo
products.associations.tsconProduct.belongsTo(ProductType)yProductType.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
ProductsRepositoryya declarafindByIdForUpdatey acepta untransactionopcional 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:
nameybrand(obligatorias),price(DECIMAL(12,2)),min_stockyquantity(INTEGER, default0), yproduct_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;tableNamerealproducts.- El modelo declara la columna, pero no declara la relación: eso vive aparte, en
products.associations.ts.
Se conecta con
- Entrada: el
Repositoryy elSeeder. - Salida:
sequelize. Al importar las asociaciones, queda vinculado conProductType.
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.bodycon 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?)ydelete(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ámetrostransactionopcionales. 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) yProductTypesRepository(de otro feature). Así el service orquesta una regla que cruza entidades sin romper las capas: no importa el modeloProductTypedirectamente, usa el repository de su feature. createyupdatePutllaman aassertActiveProductType(body.product_type_id)antes de persistir.updatePatchsolo valida el tipo si el cuerpo traeproduct_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) yProductTypesRepository(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íaroutes/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
foreignKeyes el nombre de la columna que ya existe enproducts; elases el alias que Sequelize usará para nombrar la relación. - El archivo no exporta nada: su efecto es el side-effect de ejecutar
belongsTo/hasManyal importarlo. Por esoconfiglo importa a propósito antes desync.
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
ProductyProductType.
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.associationsenconfigocurre antes delsync; 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íneabelongsTo, unahasManyy 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, FKRESTRICT, í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/productosDepende 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 (usandoProductTypesRepository) - [ ] 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
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
: > 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:
DTOs — carpeta dto/ (un archivo por operación)
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 aProduct(incluye variantes contransaction/lockpara ventas).products.service.ts→ reglas de negocio:statuspor defecto y el tipo de producto debe existir y estar activo (leeProductTypesRepository, 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
ProductsServiceinyecta dos repositories: el propioProductsRepositoryy elProductTypesRepositoryde 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ñadirproductsRoutes.
PARCHE — src/config/index.ts:
- Debajo de import product-type.model, añadir
import "../features/business/products/product.model"; - Dentro de
routes(), añadirthis.routePrv.productsRoutes.routes(this.app);
12.5 Relación ProductType ↔ Product (obligatorio al cerrar la tabla Product)
Norma FK:
product_type_id(tablaproduct_types→ singularproduct_type+_id).Cuando una tabla nueva se relaciona con una ya existente, al final se agrega este paso: archivo de asociaciones + PARCHE en
configpara 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
src/config/index.ts ya existe.
Debajo de los imports de modelos Product / ProductType (y encima de import { Routes }), añadir:
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
products (default 15), seedProducts, productsSwagger (mismo patrón que ISS-06).
Cierre del ISS
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 enProductsServicepara validar la referencia; y el mismo esquema de seeder y Swagger; - de ISS-04 e ISS-05: el
SeedersRunner/countsy 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 (usandoProductTypesRepository) - [ ] 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
-
¿Por qué
ProductsServiceinyecta DOS repositories en vez de importar el modeloProductTypedirectamente? Porque cada repository es la única puerta a su modelo. SiProductsServiceusaraProductTypedirectamente, se saltaría las capas del featureproduct-typesy la regla «el repository es la única capa que toca Sequelize» quedaría rota. InyectarProductTypesRepositorypermite orquestar una regla entre entidades sin acoplar los modelos. Es la forma de relacionar features sin fusionarlos. -
¿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.
-
¿Por qué
products.associations.tsno 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 esoconfiglo importa antes delsync: la relación debe existir cuando Sequelize crea las tablas. -
¿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_idexiste), pero Sequelize no conoce la relación: no puedes usarinclude/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. -
La relación es 1:N, ¿quién es el «1» y quién es el «N»? Explica con
as. El «1» esProductType(un tipo), el «N» sonProduct(muchos productos). En el código:Product.belongsTo(ProductType, { as: "product_type" })(cada producto tiene unproduct_type) yProductType.hasMany(Product, { as: "products" })(cada tipo tiene muchosproducts). Ambas declaraciones describen la misma relación desde los dos lados. -
¿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. -
¿Por qué el
updatePatchsolo validaproduct_type_id«si viene en el cuerpo»? Porque un PATCH es parcial: puede actualizar soloprice, sin tocar el tipo. Validar unproduct_type_idque no se envió no tiene sentido y podría fallar conundefined. La condiciónbody.product_type_id !== undefineddistingue «no lo envíes» de «envíalo para cambiarlo». -
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
includeen el repository (aprovechando que la asociación ya existe) y ajustarías el mappertoProductResponsepara 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:
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 devarranca 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