📚 Unidad ISS-06 · Feature ProductType — 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 ProductType 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-06 — Feature ProductType (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 ProductType del ISS-06: modelo, DTO, repository, service y rutas. La desactivación real es
PATCH /:id/deactivate; el comentario que habla de DELETE no es la ruta.
10:01 · narración en español · subtítulos activables desde el reproductor.
ISS-06 — Cuaderno de aprendizaje visual
Tema
Feature ProductType (tipos de producto): construir el segundo feature del backend —product-types/— con el CRUD completo por capas, su seeder de datos falsos y su documentación Swagger, replicando el patrón que ya viste nacer en clients. Todavía sin claves foráneas.
Fuente técnica autoritativa
| Archivo fuente | ../manual/07-ISS-06-product-type.md |
| Nombre literal del archivo | 07-ISS-06-product-type.md |
| Estado | Solo lectura — este cuaderno no modifica el ISS |
| Alcance | 6 sub-ítems (11.1 … 11.6), API /api/tipos-producto |
| Feature / tabla | product_types → src/features/business/product-types/ |
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 + seeder + swagger de ProductType (sin FK). Bloqueado por: ISS-05. API:
/api/tipos-producto— SIN AUTH. Patrón: mismo que Client (ISS-03-A…E + 04 + 05).
La condición que el propio ISS exige para darse por terminado es replicar el patrón de un feature completo: los seis criterios de aceptación en verde —modelo, CRUD por capas, HTTP, cableado, seeder y Swagger— y, en el cierre, que npm run dev arranque sin error. La palabra clave del ISS es segundo: no se inventa arquitectura nueva, se repite conscientemente la que ya existe y funciona.
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 |
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 |
Ruta de aprendizaje
Esta ruta es específica de este ISS: el objetivo no es aprender una técnica nueva, sino repetir con criterio el patrón de feature que ya conoces. Fíjate en que cada paso es una copia consciente del recorrido de clients, no una casualidad.
Releer el patrón de Client (ISS-03 A…E)
↓
Copiar el esqueleto a product-types/
↓
Modelo ProductType (status + timestamps)
↓
DTO → Repository → Service → Controller → Routes
↓
HTTP (REST Client) en el mismo orden
↓
Cablear routes/index.ts + config
↓
Seeder + registro en SeedersRunner / counts
↓
Swagger + registro en src/swagger
↓
Verificar (GATE)
Pregunta que responde: ¿qué pasos concretos debo seguir, en orden, y cuál es la idea central de este ISS?
La idea central es esta: un feature ya no se piensa desde cero, se ensambla. Si al terminar no puedes nombrar las siete piezas de product-types/ sin mirar, vuelve al ## Árbol de archivos.
Índice
- Ficha del ISS
- Mapa mental del ISS
- Mapa del backend
- Árbol de archivos
- Anatomía del código
- Comandos explicados
- Flujos
- 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-06 |
| Título | Feature ProductType (tipos de producto) |
| Objetivo | CRUD + seeder + swagger de ProductType (sin FK) |
| Fase | Fase I — Business |
| Tecnología principal | Express 5 + TypeScript + Sequelize (patrón repository/service/controller) |
| Depende de | ISS-05 — Swagger / OpenAPI |
| Habilita | ISS-07 — Feature Product (la FK product_type_id apunta aquí) |
| Archivos creados | Modelo, dto/ (5 archivos), repository, service, controller, routes, http/ (4 archivos), seeder y swagger de product-types/ |
| Archivos parcheados | src/routes/index.ts, src/config/index.ts, src/database/seeders/counts.ts, src/database/seeders/index.ts, src/swagger/index.ts |
| Componentes incorporados | 2.º feature de negocio (product_types), su seeder (25 filas por defecto) y su módulo Swagger |
| Verificación principal | curl de create + curl de getAll sobre /api/tipos-producto, y npm run dev arrancando sin error |
| Resultado esperado | CRUD completo en /api/tipos-producto, tabla product_types poblada por el seeder y endpoints visibles en /api/docs |
| GATE | npm run dev arranca sin error (con los seis criterios) |
Qué implementamos AHORA
Un feature completo de negocio, el segundo del proyecto. En concreto:
- La tabla
product_typescon su modelo Sequelize (status+timestamps: true). - El CRUD entero por capas:
getAll,getOne,create,update(PUT y PATCH) ydelete(físico y lógico). - Los archivos
.httppara probarlo con el REST Client. - El cableado en el agregador de rutas y en
config. - El seeder idempotente con Faker y su registro en el
SeedersRunner/counts. - La documentación Swagger del feature, registrada en el registry.
Lo importante: no aparece ninguna relación con otras tablas. ProductType es hoy una isla, igual que Client en su momento.
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 |
|---|---|
El feature products (productos) |
ISS-07 |
La FK products.product_type_id |
ISS-07 |
La asociación Product.belongsTo(ProductType) / ProductType.hasMany(Product) |
ISS-07 (sub-ítem 12.5) |
sales y product_sales (ventas y detalle) |
ISS-08 |
| Cualquier autenticación o rol (las rutas son SIN AUTH) | ISS-09 … ISS-13 |
Validación de que un tipo referenciado exista y esté active |
ISS-07 (assertActiveProductType) |
Ojo con la trampa habitual:
ProductTypequeda «suelto». Que todavía no tenga relación no es un olvido: es el orden correcto. Primero se crea el catálogo (product_types), después la entidad que lo referencia (products). La relación se cierra «al cerrar la tabla Product», y eso es ISS-07.
Mapa mental del ISS
mindmap
root((ISS-06<br/>ProductType))
Objetivo
Segundo feature
SIN claves foraneas
Replicar patrón de Client
Piezas
Modelo
DTO
Repository
Service
Controller
Routes
HTTP
Extras
Seeder con Faker
Swagger del feature
Cableado
routes index
config
seeders counts
seeders index
swagger index
Verificación
curl create
curl getAll
npm run dev
GATE
Seis criterios en verde
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-06 la cadena completa de un feature ya existe (nació en ISS-03) y ahora se duplica.
IMPLEMENTADO HASTA ESTE ISS
───────────────────────────
HTTP
↓
Routes ✅ src/routes/index.ts (agregador de features)
↓
Controller ✅ BaseController.run() / paramId()
↓
Service ✅ AppError + reglas de negocio
↓
Repository ✅ única capa que toca Sequelize
↓
Model ✅ clients · product_types
↓
Sequelize ✅
↓
BD ✅
Features de negocio:
✅ clients (/api/clientes) ← ISS-03
✅ product-types (/api/tipos-producto) ← ISS-06 ◄── NUEVO
🎯 products (/api/productos) ← ISS-07
🎯 sales (/api/ventas) ← ISS-08
🎯 product-sales (/api/detalle-ventas) ← 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
Asociaciones Sequelize:
🎯 Product ↔ ProductType ← ISS-07
🎯 Sale ↔ Client / ProductSale ← ISS-08
🎯 ProductSale ↔ Product ← ISS-08
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?
Fíjate en el contraste: la cadena de capas ya está entera y probada; lo que avanza ISS a ISS es el número de features que la reutilizan. ISS-06 añade un feature; no añade ninguna capa nueva.
Árbol de archivos
Estructura antes
src/
├── config/
├── database/
│ └── seeders/{counts,index}.ts
├── routes/index.ts
├── shared/
│ ├── errors/app-error.ts
│ └── http/{base-controller,error-response,swagger-security}.ts
├── features/business/
│ └── clients/ # feature completo (ISS-03/04/05)
├── 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/product-types/
├── product-type.model.ts # Model (tabla product_types)
├── dto/
│ ├── create-product-type.dto.ts # POST /api/tipos-producto
│ ├── update-product-type.dto.ts # PUT /api/tipos-producto/:id
│ ├── patch-product-type.dto.ts # PATCH /api/tipos-producto/:id
│ ├── product-type-response.dto.ts # salida de la API
│ └── index.ts # barrel
├── product-types.repository.ts # acceso a Sequelize
├── product-types.service.ts # reglas de negocio
├── product-types.controller.ts # solo HTTP
├── product-types.routes.ts # /api/tipos-producto
├── product-types.seeder.ts # datos falsos (Faker)
├── product-types.swagger.ts # OpenAPI del feature
└── http/
├── product-types.get.http
├── product-types.create.http
├── product-types.update.http
└── product-types.delete.http
△ src/routes/index.ts # + ProductTypesRoutes
△ src/config/index.ts # + import del modelo y + routes()
△ src/database/seeders/counts.ts # + product_types
△ src/database/seeders/index.ts # + seedProductTypes(...)
△ src/swagger/index.ts # + productTypesSwagger
Leyenda: ★ archivo creado · △ archivo existente parcheado.
Pregunta que responde: ¿qué archivos toca exactamente este ISS?
Estructura después
src/
├── config/ # △ ahora importa y arranca el 2.º feature
├── database/
│ └── seeders/{counts,index}.ts # △ product_types entra en el runner
├── routes/index.ts # △ 2 features registrados
├── shared/ # (sin cambios)
├── features/business/
│ ├── clients/
│ └── product-types/ # ★ feature nuevo, 13 archivos
├── swagger/index.ts # △ 2 módulos Swagger
└── server.ts
Pregunta que responde: ¿cómo queda la estructura del proyecto al terminar?
El árbol crece en una sola rama (features/business/product-types/) y deja cinco marcas △ diseminadas por archivos que ya existían. Esos parches son el 80 % de los errores de este ISS: un import olvidado y el feature existe pero nadie lo monta.
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: product-types/product-type.model.ts
Propósito
Definir la tabla product_types para Sequelize: columnas, tipos y comportamiento. Es la representación en código de la tabla, no de la API.
Explicación
- Declara la interfaz
ProductTypeI(la forma del registro) y la claseProductType(la instancia Sequelize). - Columnas:
name(obligatorio),description(opcional/null),status(ENUM("active","inactive")). - El
defaultValuedestatuses"inactive": es un 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". timestamps: trueañadecreatedAtyupdatedAt; eltableNamereal esproduct_types(plural snake_case).
Se conecta con
- Entrada: el
Repository(lo instancia y consulta) y elSeeder(hacebulkCreate). - Salida:
sequelize(la conexión compartida). No conoce HTTP ni reglas de negocio.
Archivo: product-types/dto/
Propósito
Ser el contrato de entrada y salida de la API. No es una capa: es la forma de los datos que cruzan routes, controller y service.
Explicación
Un archivo por operación, más un barrel:
| Archivo | Operación | Idea clave |
|---|---|---|
create-product-type.dto.ts |
POST |
name obligatorio; status opcional (active por defecto) |
update-product-type.dto.ts |
PUT |
reemplazo completo; status no está a propósito |
patch-product-type.dto.ts |
PATCH |
es Partial<UpdateProductTypeDto> |
product-type-response.dto.ts |
salida | ProductTypeResponseDto + mapper toProductTypeResponse() |
index.ts |
— | barrel: export * from "./…" |
status se excluye de Update/Patch porque el estado solo cambia con el borrado lógico. El mapper devuelve un objeto plano (toJSON) y es el único punto donde se decide qué se expone.
Se conecta con
- Entrada: el controller tipa
req.bodycon estos DTOs. - Salida: el service los consume y produce
<X>ResponseDto.
Archivo: product-types/product-types.repository.ts
Propósito
Ser la única capa que habla con el modelo ProductType.
Explicación
Expone cinco métodos: findAllActive() (filtra status: "active"), findById(id), create(data), update(instancia, data) y delete(instancia). No contiene reglas de negocio: eso es del service. Trabaja con tipos del modelo, no con DTOs.
Se conecta con
- Entrada: el service.
- Salida: el modelo
ProductType/ Sequelize.
Archivo: product-types/product-types.service.ts
Propósito
Concentrar las reglas de negocio del feature y devolver siempre DTOs de respuesta.
Explicación
- Métodos:
getAll,getOne,create,updatePut,updatePatch,deletePhysical,deleteLogical. createcopia campo a campo (evita mass assignment) y aplicastatus ?? "active".deleteLogicalcambiastatusa"inactive";deletePhysicaldestruye la fila (y admite purgar un registro ya desactivado cononlyActive: false).findOrFail(id, onlyActive = true)es el único lugar donde se define «no existe»: si falta o estáinactive, lanzaAppError(404, …).- No conoce
req/resni escribe Sequelize directamente.
Se conecta con
- Entrada: el controller (con DTOs).
- Salida: el repository (para persistir) y el mapper (para responder).
Archivo: product-types/product-types.controller.ts
Propósito
Traducir HTTP ↔ service. Solo lee req, llama al service y arma res.
Explicación
- Extiende
BaseController; cada handler se envuelve enthis.run(res, …), que centraliza eltry/catchy la traducción deAppErrora código HTTP. - El
:idse lee conthis.paramId(req)(si no es entero ≥ 1, responde 400). - Códigos:
200para lecturas y updates,201para create. Las respuestas envuelven el dato ({ product_types },{ product_type }). - No decide reglas de negocio.
Se conecta con
- Entrada: las routes (
/api/tipos-producto…). - Salida: el service.
Archivo: product-types/product-types.routes.ts
Propósito
Declarar las seis rutas del feature sobre una Application de Express.
Explicación
Mapea: GET /api/tipos-producto (getAll), GET /api/tipos-producto/:id (getOne), POST /api/tipos-producto (create), PUT y PATCH /api/tipos-producto/:id, DELETE /api/tipos-producto/:id (físico) y PATCH /api/tipos-producto/:id/deactivate (lógico). En este ISS las rutas son SIN AUTH: no llevan middlewares JWT.
Se conecta con
- Entrada: la instancia de la
Application, vía el agregadorroutes/index.ts. - Salida: el controller de ProductTypes.
Archivo: product-types/http/ (4 archivos .http)
Propósito
Guardar peticiones listas para ejecutar con el REST Client del editor.
Explicación
get, create, update y delete en el mismo orden que los criterios del ISS. Aunque las rutas son SIN AUTH, los ejemplos incluyen un login previo (/api/sesion/login) y Authorization: Bearer: es el formato que reutilizará la Fase II.
Se conecta con
- Entrada: el servidor en marcha (
npm run dev). - Salida: la API
/api/tipos-producto.
Archivo: product-types/product-types.seeder.ts
Propósito
Poblar la tabla con datos falsos de forma idempotente.
Explicación
seedProductTypes(count): si count <= 0 se omite; si ya hay filas, se omite (ProductType.count()); si no, genera count filas con Faker y las inserta con bulkCreate, todas status: "active". Se invoca desde src/database/seeders (el SeedersRunner), no desde la App.
Se conecta con
- Entrada: el
SeedersRunner. - Salida: el modelo
ProductType.
Archivo: product-types/product-types.swagger.ts
Propósito
Describir el feature en OpenAPI 3 (tags, paths, schemas).
Explicación
Exporta productTypesSwagger con el tag TiposProducto, los paths de /api/tipos-producto y los components.schemas (ProductType, ProductTypeCreate, ProductTypeUpdate, ProductTypePatch). Se agrega desde src/swagger (registry externo); no se monta desde el feature. Reutiliza bearerSecurity, unauthorizedResponse y forbiddenResponse de shared/http/swagger-security.
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 + productTypesRoutes y this.routePrv.productTypesRoutes.routes(this.app); |
monta las rutas del feature |
src/config/index.ts |
import "../features/business/product-types/product-type.model"; |
registra el modelo antes del sync |
src/database/seeders/counts.ts |
product_types: number + product_types: 25 + lectura de SEED_PRODUCT_TYPES |
permite configurar cuántas filas sembrar |
src/database/seeders/index.ts |
import de seedProductTypes + await seedProductTypes(counts.product_types) |
encadena el seeder en el runner |
src/swagger/index.ts |
import de productTypesSwagger + entrada en featureSwaggerModules |
publica el feature en /api/docs |
Se conecta con
- Entrada: los archivos creados del feature.
- Salida: el arranque de la app (
config) y el runner de seeders.
Comandos explicados
Ningún comando va sin explicación. En ISS-06 hay tres grupos: crear la carpeta, generar archivos y verificar.
mkdir -p src/features/business/product-types/http
COMANDO
↓
mkdir -p src/features/business/product-types/http
↓
QUÉ HACE
Crea la carpeta del feature y la subcarpeta http/, y crea también
los directorios intermedios si faltan.
↓
POR QUÉ SE NECESITA
El patrón de feature exige la carpeta http/ para los archivos .http.
Crearla antes evita que los heredoc fallen al escribir la ruta.
↓
QUÉ CREA O MODIFICA
src/features/business/product-types/ y .../http/ (vacías).
↓
RESULTADO ESPERADO
El comando no imprime nada y termina con código 0.
↓
CÓMO VERIFICARLO
ls src/features/business/product-types → 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
El ISS 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 $, backticks y demás.
↓
POR QUÉ SE NECESITA
Garantiza que el archivo se crea desde cero (sin restos) y con
exactamente el contenido del ISS, sin depender del editor.
↓
QUÉ CREA O MODIFICA
El archivo indicado en cada sub-ítem (modelo, dto, capas, 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 lo que verás en `## Recorrido del ISS`;
o `npx tsc --noEmit` al terminar todo.
npm run dev
COMANDO
↓
npm run dev
↓
QUÉ HACE
Arranca el servidor en modo desarrollo (ts-node). Al arrancar, `config`
importa los modelos (incluido el nuevo) y ejecuta la conexión y el sync.
↓
POR QUÉ SE NECESITA
Es el cierre del ISS: si el feature está mal cableado, el arranque falla.
↓
QUÉ CREA O MODIFICA
Levanta el proceso del servidor; con el sync puede crear/ajustar la tabla
product_types en la BD.
↓
RESULTADO ESPERADO
El servidor arranca SIN ERROR (y responde en el puerto 4000).
↓
CÓMO VERIFICARLO
Ver la salida de arranque y, luego, probar las rutas con curl o .http.
Detener con Ctrl+C.
Verificación con curl
COMANDO
↓
curl -s -X POST http://localhost:4000/api/tipos-producto … # crear
curl -s http://localhost:4000/api/tipos-producto # listar
↓
QUÉ HACE
Envía una petición HTTP sin navegador: un POST con JSON y un GET.
↓
POR QUÉ SE NECESITA
Es la comprobación funcional del CRUD: que el tipo se cree y vuelva en la lista.
↓
QUÉ CREA O MODIFICA
La primera inserta una fila; la segunda solo lee.
↓
RESULTADO ESPERADO
El POST responde con el objeto creado; el GET responde `{ "product_types": [...] }`.
↓
CÓMO VERIFICARLO
Que la respuesta incluya `name` y `status: "active"`.
Flujos
El recorrido de una petición en este feature
sequenceDiagram
autonumber
participant U as Cliente HTTP
participant R as Routes
participant C as Controller
participant S as Service
participant P as Repository
participant M as Model ProductType
participant DB as Base de datos
U->>R: POST /api/tipos-producto
R->>C: create(req, res)
C->>S: create(CreateProductTypeDto)
S->>P: create(data)
P->>M: ProductType.create
M->>DB: INSERT INTO product_types
DB-->>M: fila insertada
M-->>P: instancia ProductType
P-->>S: instancia ProductType
S-->>C: toProductTypeResponse
C-->>U: 201 con product_type
Pregunta que responde: ¿qué capa toca cada paso cuando llega un POST al feature?
Observa que la petición nunca salta una capa: el controller no escribe Sequelize, el service no lee req, y el repository no sabe de códigos HTTP.
Cómo se replica el patrón
flowchart TD
A["Repasar el patrón de Client (ISS-03 A…E)"] --> B["mkdir product-types/http"]
B --> C["Modelo product-type.model.ts"]
C --> D["dto/ + repository + service + controller + routes"]
D --> E["http/ (REST Client)"]
E --> F["Cablear routes/index.ts y config"]
F --> G["Seeder + counts + SeedersRunner"]
G --> H["Swagger + registro en src/swagger"]
H --> I["Verificar con npm run dev"]
Pregunta que responde: ¿en qué orden se ensambla un feature nuevo?
Las capas y sus dependencias
classDiagram
direction LR
class BaseController {
+run(res, fn)
+paramId(req)
}
class ProductTypesController
class ProductTypesService
class ProductTypesRepository
class ProductType
BaseController <|-- ProductTypesController
ProductTypesController --> ProductTypesService : usa
ProductTypesService --> ProductTypesRepository : usa
ProductTypesRepository --> ProductType : solo aquí vive Sequelize
Pregunta que responde: ¿quién depende de quién dentro del feature?
Nota que la flecha va siempre «hacia abajo»: nada de capas inferiores llama a superiores.
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-06 — Feature ProductType (tipos de producto)
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 ProductType (tipos de producto) Feature / tabla product_types→product-types/API /api/tipos-productoDepende de ISS-05 — Swagger / OpenAPI Habilita ISS-07 — Feature Product
Contenido de este ISS
- 11.1 Modelo ProductType
- 11.2 DTO + Repository + Service + Controller + routes (CRUD completo)
- 11.3 HTTP (REST Client)
- 11.4 Cableado Routes + Config
- 11.5 Seeder ProductType
- 11.6 Swagger ProductType
Objetivo: CRUD + seeder + swagger de ProductType (sin FK).
Bloqueado por: ISS-05.
API: /api/tipos-producto — SIN AUTH.
Patrón: mismo que Client (ISS-03-A…E + 04 + 05).
Criterios de aceptación (ISS-06)
- [ ] 11.1 Modelo
product-type.model.ts(status+timestamps: true) - [ ] 11.2 DTOs (
dto/) + Repository + Service + Controller + routes en este orden: getAll, getOne, create, update PUT/PATCH, delete físico y lógico - [ ] 11.3 Carpeta
http/en el mismo orden: get, create, update, delete - [ ] 11.4 Cableado en
routes/index.ts+config(import model + route) - [ ] 11.5 Seeder + registro en SeedersRunner / counts
- [ ] 11.6 Swagger + registro en
src/swagger
11.1 Modelo ProductType
: > src/features/business/product-types/product-type.model.ts
cat >> src/features/business/product-types/product-type.model.ts << 'EOF'
import { DataTypes, Model } from "sequelize";
import { sequelize } from "../../../database/db";
export interface ProductTypeI {
id?: number;
name: string;
description?: string | null;
status: "active" | "inactive";
createdAt?: Date;
updatedAt?: Date;
}
export class ProductType extends Model {
public id!: number;
public name!: string;
public description!: string | null;
public status!: "active" | "inactive";
public readonly createdAt!: Date;
public readonly updatedAt!: Date;
}
ProductType.init(
{
name: {
type: DataTypes.STRING,
allowNull: false,
},
description: {
type: DataTypes.STRING,
allowNull: true,
},
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: "ProductType",
tableName: "product_types",
timestamps: true,
}
);
EOF
: > src/features/business/product-types/product-type.model.ts
cat >> src/features/business/product-types/product-type.model.ts << 'EOF'
import { DataTypes, Model } from "sequelize";
import { sequelize } from "../../../database/db";
export interface ProductTypeI {
id?: number;
name: string;
description?: string | null;
status: "active" | "inactive";
createdAt?: Date;
updatedAt?: Date;
}
export class ProductType extends Model {
public id!: number;
public name!: string;
public description!: string | null;
public status!: "active" | "inactive";
public readonly createdAt!: Date;
public readonly updatedAt!: Date;
}
ProductType.init(
{
name: {
type: DataTypes.STRING,
allowNull: false,
},
description: {
type: DataTypes.STRING,
allowNull: true,
},
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: "ProductType",
tableName: "product_types",
timestamps: true,
}
);
EOF
11.2 DTO + Repository + Service + Controller + routes (CRUD completo)
Recordemos el flujo por capas del feature:
DTOs — carpeta dto/ (un archivo por operación)
dto/create-product-type.dto.ts
: > src/features/business/product-types/dto/create-product-type.dto.ts
cat >> src/features/business/product-types/dto/create-product-type.dto.ts << 'EOF'
/** Datos de entrada de `POST /api/tipos-producto`. */
export interface CreateProductTypeDto {
name: string;
description?: string | null;
/** Opcional: por defecto `active`. Tras crearlo, el estado sólo cambia con el borrado lógico. */
status?: "active" | "inactive";
}
EOF
dto/update-product-type.dto.ts
: > src/features/business/product-types/dto/update-product-type.dto.ts
cat >> src/features/business/product-types/dto/update-product-type.dto.ts << 'EOF'
/**
* Datos de entrada de `PUT /api/tipos-producto/:id` (reemplazo completo).
*
* `status` **no** está aquí a propósito: el estado sólo cambia con el borrado
* lógico (`DELETE /api/tipos-producto/:id/deactivate`).
*/
export interface UpdateProductTypeDto {
name: string;
description?: string | null;
}
EOF
dto/patch-product-type.dto.ts
: > src/features/business/product-types/dto/patch-product-type.dto.ts
cat >> src/features/business/product-types/dto/patch-product-type.dto.ts << 'EOF'
import { UpdateProductTypeDto } from "./update-product-type.dto";
/** Datos de entrada de `PATCH /api/tipos-producto/:id` (actualización parcial). */
export type PatchProductTypeDto = Partial<UpdateProductTypeDto>;
EOF
dto/product-type-response.dto.ts
: > src/features/business/product-types/dto/product-type-response.dto.ts
cat >> src/features/business/product-types/dto/product-type-response.dto.ts << 'EOF'
import { ProductType, ProductTypeI } from "../product-type.model";
/**
* Respuesta HTTP de un tipo de producto. Lo usan `GET /api/tipos-producto`,
* `GET /api/tipos-producto/: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.
* `ProductType` no guarda campos internos, por eso el contrato coincide hoy con
* el modelo. Si mañana aparece uno, la proyección se vuelve explícita aquí
* (`Omit<ProductTypeI, "...">`) y el mapper lo omite.
*/
export type ProductTypeResponseDto = ProductTypeI;
/** Mapper modelo -> DTO de respuesta (objeto plano, sin métodos de Sequelize). */
export function toProductTypeResponse(productType: ProductType): ProductTypeResponseDto {
return productType.toJSON() as ProductTypeResponseDto;
}
EOF
dto/index.ts
: > src/features/business/product-types/dto/index.ts
cat >> src/features/business/product-types/dto/index.ts << 'EOF'
export * from "./create-product-type.dto";
export * from "./update-product-type.dto";
export * from "./patch-product-type.dto";
export * from "./product-type-response.dto";
EOF
product-types.repository.ts→ única capa que habla con el modeloProductType.product-types.service.ts→ reglas de negocio (statuspor defecto,descriptionnormalizada, 404 si no existe).product-types.controller.ts→ solo HTTP (req/res).
Repository
: > src/features/business/product-types/product-types.repository.ts
cat >> src/features/business/product-types/product-types.repository.ts << 'EOF'
import { CreationAttributes } from "sequelize";
import { ProductType, ProductTypeI } from "./product-type.model";
/**
* Capa Repository del feature ProductTypes.
*
* Única responsable de hablar con Sequelize (el modelo `ProductType`).
*/
export class ProductTypesRepository {
/** Todos los tipos activos. */
public async findAllActive(): Promise<ProductType[]> {
return ProductType.findAll({ where: { status: "active" } });
}
/** Un tipo por PK (o `null`). */
public async findById(id: number): Promise<ProductType | null> {
return ProductType.findByPk(id);
}
/** Inserta un tipo de producto. */
public async create(data: CreationAttributes<ProductType>): Promise<ProductType> {
return ProductType.create(data);
}
/** Persiste cambios sobre una instancia existente. */
public async update(
productType: ProductType,
data: Partial<ProductTypeI>
): Promise<ProductType> {
return productType.update(data);
}
/** Elimina físicamente una instancia. */
public async delete(productType: ProductType): Promise<void> {
await productType.destroy();
}
}
EOF
Service
: > src/features/business/product-types/product-types.service.ts
cat >> src/features/business/product-types/product-types.service.ts << 'EOF'
import {
CreateProductTypeDto,
PatchProductTypeDto,
ProductTypeResponseDto,
UpdateProductTypeDto,
toProductTypeResponse,
} from "./dto";
import { ProductTypesRepository } from "./product-types.repository";
import { ProductType } from "./product-type.model";
import { AppError } from "../../../shared/errors/app-error";
/**
* Capa Service del feature ProductTypes.
*
* Reglas de negocio: default de `status`, política de borrado lógico y borrado
* físico. No conoce `req`/`res` ni escribe Sequelize: delega en el repository y
* devuelve **DTOs** (carpeta `dto/`).
*/
export class ProductTypesService {
public constructor(
private readonly repository: ProductTypesRepository = new ProductTypesRepository()
) {}
// ================== READ ==================
public async getAll(): Promise<ProductTypeResponseDto[]> {
const productTypes = await this.repository.findAllActive();
return productTypes.map((productType) => toProductTypeResponse(productType));
}
public async getOne(id: number): Promise<ProductTypeResponseDto> {
return toProductTypeResponse(await this.findOrFail(id));
}
// ================== CREATE ==================
public async create(body: CreateProductTypeDto): Promise<ProductTypeResponseDto> {
// Copia campo a campo a propósito (evita *mass assignment*).
const productType = await this.repository.create({
name: body.name,
description: body.description ?? null,
status: body.status ?? "active",
});
return toProductTypeResponse(productType);
}
// ================== UPDATE ==================
public async updatePut(
id: number,
body: UpdateProductTypeDto
): Promise<ProductTypeResponseDto> {
const productType = await this.findOrFail(id);
await this.repository.update(productType, {
name: body.name,
description: body.description ?? null,
});
return toProductTypeResponse(productType);
}
public async updatePatch(
id: number,
body: PatchProductTypeDto
): Promise<ProductTypeResponseDto> {
const productType = await this.findOrFail(id);
await this.repository.update(productType, body);
return toProductTypeResponse(productType);
}
// ================== DELETE ==================
/** Eliminación física. */
public async deletePhysical(id: number): Promise<void> {
// `onlyActive: false` -> también permite purgar un registro ya desactivado.
const productType = await this.findOrFail(id, false);
await this.repository.delete(productType);
}
/** Eliminación lógica -> `status = inactive`. */
public async deleteLogical(id: number): Promise<ProductTypeResponseDto> {
const productType = await this.findOrFail(id);
await this.repository.update(productType, { status: "inactive" });
return toProductTypeResponse(productType);
}
// ================== 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<ProductType> {
const productType = await this.repository.findById(id);
if (!productType || (onlyActive && productType.status !== "active")) {
throw new AppError(404, "Product type not found");
}
return productType;
}
}
EOF
Controller
: > src/features/business/product-types/product-types.controller.ts
cat >> src/features/business/product-types/product-types.controller.ts << 'EOF'
import { Request, Response } from "express";
import { BaseController } from "../../../shared/http/base-controller";
import {
CreateProductTypeDto,
PatchProductTypeDto,
UpdateProductTypeDto,
} from "./dto";
import { ProductTypesService } from "./product-types.service";
/**
* Capa Controller del feature ProductTypes.
* Solo HTTP: lee `req`, llama al service y arma la respuesta.
* El manejo de errores se delega en `run()` (ver `BaseController`).
*/
export class ProductTypesController extends BaseController {
public constructor(
private readonly service: ProductTypesService = new ProductTypesService()
) {
super();
}
// ================== READ ==================
public async getAll(_req: Request, res: Response): Promise<void> {
await this.run(res, async () => {
const product_types = await this.service.getAll();
res.status(200).json({ product_types });
});
}
public async getOne(req: Request, res: Response): Promise<void> {
await this.run(res, async () => {
const product_type = await this.service.getOne(this.paramId(req));
res.status(200).json({ product_type });
});
}
// ================== CREATE ==================
public async create(req: Request, res: Response): Promise<void> {
await this.run(res, async () => {
const product_type = await this.service.create(
req.body as CreateProductTypeDto
);
res.status(201).json({ product_type });
});
}
// ================== UPDATE ==================
public async updatePut(req: Request, res: Response): Promise<void> {
await this.run(res, async () => {
const product_type = await this.service.updatePut(
this.paramId(req),
req.body as UpdateProductTypeDto
);
res.status(200).json({ product_type });
});
}
public async updatePatch(req: Request, res: Response): Promise<void> {
await this.run(res, async () => {
const product_type = await this.service.updatePatch(
this.paramId(req),
req.body as PatchProductTypeDto
);
res.status(200).json({ product_type });
});
}
// ================== 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 type 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_type = await this.service.deleteLogical(this.paramId(req));
res.status(200).json({
message: "Product type deactivated (logical delete)",
product_type,
});
});
}
}
EOF
: > src/features/business/product-types/product-types.routes.ts
cat >> src/features/business/product-types/product-types.routes.ts << 'EOF'
import { Application } from "express";
import { ProductTypesController } from "./product-types.controller";
export class ProductTypesRoutes {
public productTypesController: ProductTypesController = new ProductTypesController();
public routes(app: Application): void {
// ================== RUTAS SIN AUTENTICACIÓN / SIN MIDDLEWARE JWT ==================
// getAll
app
.route("/api/tipos-producto")
.get(this.productTypesController.getAll.bind(this.productTypesController));
// getOne
app
.route("/api/tipos-producto/:id")
.get(this.productTypesController.getOne.bind(this.productTypesController));
// create
app
.route("/api/tipos-producto")
.post(this.productTypesController.create.bind(this.productTypesController));
// update (PUT / PATCH)
app
.route("/api/tipos-producto/:id")
.put(this.productTypesController.updatePut.bind(this.productTypesController))
.patch(this.productTypesController.updatePatch.bind(this.productTypesController));
// delete físico
app
.route("/api/tipos-producto/:id")
.delete(this.productTypesController.deletePhysical.bind(this.productTypesController));
// delete lógico
app
.route("/api/tipos-producto/:id/deactivate")
.patch(this.productTypesController.deleteLogical.bind(this.productTypesController));
}
}
EOF
11.3 HTTP (REST Client)
: > src/features/business/product-types/http/product-types.get.http
cat >> src/features/business/product-types/http/product-types.get.http << 'EOF'
### Feature ProductType — 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 getAllProductTypes
GET {{baseUrl}}/api/tipos-producto
Authorization: Bearer {{token}}
###
# @name getOneProductType
GET {{baseUrl}}/api/tipos-producto/{{id}}
Authorization: Bearer {{token}}
### 401 — sin token
GET {{baseUrl}}/api/tipos-producto
EOF
: > src/features/business/product-types/http/product-types.create.http
cat >> src/features/business/product-types/http/product-types.create.http << 'EOF'
### Feature ProductType — 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 createProductType
POST {{baseUrl}}/api/tipos-producto
Authorization: Bearer {{token}}
Content-Type: application/json
{
"name": "Electrónica",
"description": "Dispositivos y accesorios",
"status": "active"
}
EOF
: > src/features/business/product-types/http/product-types.update.http
cat >> src/features/business/product-types/http/product-types.update.http << 'EOF'
### Feature ProductType — UPDATE (PUT) / UPDATE (PATCH)
### Modalidad JWT + RBAC. `status` no se envía: el estado solo cambia con
### PATCH {{baseUrl}}/api/tipos-producto/{{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 updateProductTypePut
PUT {{baseUrl}}/api/tipos-producto/{{id}}
Authorization: Bearer {{token}}
Content-Type: application/json
{
"name": "Electrónica Actualizada",
"description": "Categoría renovada"
}
###
# @name updateProductTypePatch
PATCH {{baseUrl}}/api/tipos-producto/{{id}}
Authorization: Bearer {{token}}
Content-Type: application/json
{
"description": "Descripción parcial"
}
EOF
: > src/features/business/product-types/http/product-types.delete.http
cat >> src/features/business/product-types/http/product-types.delete.http << 'EOF'
### Feature ProductType — 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 deleteProductTypePhysical
DELETE {{baseUrl}}/api/tipos-producto/{{id}}
Authorization: Bearer {{token}}
###
# @name deleteProductTypeLogical
PATCH {{baseUrl}}/api/tipos-producto/{{id}}/deactivate
Authorization: Bearer {{token}}
EOF
11.4 Cableado Routes + Config
PARCHE — src/routes/index.ts ya existe.
- Debajo de
import { ClientsRoutes } ..., añadir:
- Dentro de
export class Routes, debajo declientsRoutes, añadir:
PARCHE — src/config/index.ts ya existe.
- Debajo de
import "../features/business/clients/client.model";, añadir:
- Dentro de
routes(), debajo dethis.routePrv.clientsRoutes.routes(this.app);, añadir:
Verificación
curl -s -X POST http://localhost:4000/api/tipos-producto -H 'Content-Type: application/json' \
-d '{"name":"Bebidas","description":"Refrescos","status":"active"}'
curl -s http://localhost:4000/api/tipos-producto
11.5 Seeder ProductType
: > src/features/business/product-types/product-types.seeder.ts
cat >> src/features/business/product-types/product-types.seeder.ts << 'EOF'
import { faker } from "@faker-js/faker";
import { ProductType } from "./product-type.model";
/**
* Seeder del feature ProductType (datos falsos con @faker-js/faker).
* Se invoca desde `src/database/seeders` (SeedersRunner), no desde la App.
*
* Idempotente: si ya hay filas, no vuelve a insertar.
*/
export async function seedProductTypes(count: number): Promise<number> {
if (count <= 0) {
console.log("⏭️ product_types: count=0, se omite");
return 0;
}
const existing = await ProductType.count();
if (existing > 0) {
console.log(`⏭️ product_types: ya hay ${existing} registro(s), se omite seeder`);
return 0;
}
const rows = Array.from({ length: count }, () => ({
name: faker.commerce.department(),
description: faker.commerce.productDescription(),
status: "active" as const,
}));
await ProductType.bulkCreate(rows);
console.log(`✅ product_types: insertados ${count} registro(s) falsos`);
return count;
}
EOF
src/database/seeders/counts.ts ya existe.
- Dentro de
SeedCounts, añadirproduct_types: number; - Dentro de
DEFAULT_SEED_COUNTS, añadirproduct_types: 25, - Dentro de la resolución por env, añadir lectura de
SEED_PRODUCT_TYPES(ver ISS-08 si consolidás).
PARCHE — src/database/seeders/index.ts ya existe.
- Debajo de imports de client, añadir import de
seedProductTypes. - Debajo de
await seedClients(...), añadirawait seedProductTypes(counts.product_types);
11.6 Swagger ProductType
: > src/features/business/product-types/product-types.swagger.ts
cat >> src/features/business/product-types/product-types.swagger.ts << 'EOF'
import {
bearerSecurity,
forbiddenResponse,
unauthorizedResponse,
} from "../../../shared/http/swagger-security";
/**
* Documentación OpenAPI del feature ProductType.
* 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 productTypesSwagger = {
tags: [
{
name: "TiposProducto",
description: "CRUD de tipos de producto — **JWT + RBAC** (authenticate + authorize)",
},
],
paths: {
"/api/tipos-producto": {
get: {
tags: ["TiposProducto"],
summary: "Listar tipos de producto activos",
description: "JWT + RBAC — retorna tipos con status=active",
security: bearerSecurity,
responses: {
"401": unauthorizedResponse,
"403": forbiddenResponse,
"200": {
description: "Lista de tipos de producto",
content: {
"application/json": {
schema: {
type: "object",
properties: {
product_types: {
type: "array",
items: { $ref: "#/components/schemas/ProductType" },
},
},
},
},
},
},
},
},
post: {
tags: ["TiposProducto"],
summary: "Crear tipo de producto",
description: "JWT + RBAC",
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ProductTypeCreate" },
},
},
},
security: bearerSecurity,
responses: {
"401": unauthorizedResponse,
"403": forbiddenResponse,
"201": {
description: "Tipo de producto creado",
content: {
"application/json": {
schema: {
type: "object",
properties: {
product_type: { $ref: "#/components/schemas/ProductType" },
},
},
},
},
},
},
},
},
"/api/tipos-producto/{id}": {
get: {
tags: ["TiposProducto"],
summary: "Obtener tipo de 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: "Tipo de producto encontrado",
content: {
"application/json": {
schema: {
type: "object",
properties: {
product_type: { $ref: "#/components/schemas/ProductType" },
},
},
},
},
},
"400": { description: "id inválido (debe ser un entero positivo)" },
"404": { description: "No encontrado" },
},
},
put: {
tags: ["TiposProducto"],
summary: "Actualizar tipo de producto (PUT — reemplazo)",
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/ProductTypeUpdate" },
},
},
},
security: bearerSecurity,
responses: {
"401": unauthorizedResponse,
"403": forbiddenResponse,
"200": { description: "Actualizado" },
"400": { description: "id inválido (debe ser un entero positivo)" },
"404": { description: "No encontrado" },
},
},
patch: {
tags: ["TiposProducto"],
summary: "Actualizar tipo de 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/ProductTypePatch" },
},
},
},
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: ["TiposProducto"],
summary: "Eliminar tipo de 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/tipos-producto/{id}/deactivate": {
patch: {
tags: ["TiposProducto"],
summary: "Eliminar tipo de 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: {
ProductType: {
type: "object",
properties: {
id: { type: "integer", example: 1 },
name: { type: "string", example: "Electrónica" },
description: { type: "string", example: "Dispositivos y accesorios", nullable: true },
status: { type: "string", enum: ["active", "inactive"], example: "active" },
createdAt: { type: "string", format: "date-time" },
updatedAt: { type: "string", format: "date-time" },
},
},
ProductTypeCreate: {
type: "object",
required: ["name"],
properties: {
name: { type: "string" },
description: { type: "string" },
status: { type: "string", enum: ["active", "inactive"], default: "active" },
},
},
ProductTypeUpdate: {
type: "object",
required: ["name"],
properties: {
name: { type: "string" },
description: { type: "string" },
},
},
ProductTypePatch: {
type: "object",
properties: {
name: { type: "string" },
description: { type: "string" },
},
},
},
},
};
EOF
src/swagger/index.ts ya existe.
- Debajo de
import { clientsSwagger } ..., añadir import deproductTypesSwagger. - Dentro de
featureSwaggerModules, debajo declientsSwagger,, añadirproductTypesSwagger,.
Cierre del ISS
El servidor debe arrancar sin error. Detenerlo con Ctrl+C antes de continuar.
Diagnóstico
| Síntoma | Causa probable | Solución |
|---|---|---|
curl a /api/tipos-producto responde 404 |
El feature no está cableado en routes/index.ts |
Añadir el import, la propiedad productTypesRoutes y la llamada en routes() |
| El arranque falla con «relation product_types does not exist» | El modelo no se importa en config/index.ts antes del sync |
Añadir import "../features/business/product-types/product-type.model"; |
| El seeder inserta 0 filas | counts.product_types mal escrito o el import de seedProductTypes ausente en seeders/index.ts |
Revisar el parche de counts.ts y del runner |
El endpoint existe pero no aparece en /api/docs |
Falta registrar productTypesSwagger en src/swagger/index.ts |
Añadir el import y la entrada en featureSwaggerModules |
status llega null o la fila no aparece en getAll |
Se insertó sin estado explícito (default inactive, fail-safe) |
Enviar status: "active" o dejar que el service lo aplique |
El PUT «no actualiza el estado» |
Es intencional: status no viaja en UpdateProductTypeDto |
Usar PATCH /api/tipos-producto/:id/deactivate |
npx tsc --noEmit marca un import roto |
Ruta relativa mal ajustada (el feature está en features/business/…) |
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 es un punto de inflexión: hasta ahora cada técnica era nueva; a partir de aquí, el trabajo consiste en reutilizar.
- Lo que reutiliza:
- de ISS-03: el patrón de feature completo (HTTP → Controller → Service → Repository → Model),
BaseController.run/paramId,AppErroryfindOrFail, y la convención de nombres (plural para el feature, singular para el modelo); - de ISS-04: el
SeedersRunner, el archivocounts.tsy la idea de seeder idempotente; - de ISS-05: el registry de Swagger y los helpers
swagger-security(bearerSecurity,unauthorizedResponse,forbiddenResponse). - Lo que habilita:
- ISS-07 crea
productscon la FKproduct_type_id, y su service usará elProductTypesRepositoryde este feature para exigir que el tipo exista y esté activo; - ISS-08 construirá ventas sobre esos productos.
- Qué NO se toca: las rutas siguen SIN AUTH. En ISS-13 se actualizarán a JWT + RBAC sin cambiar la estructura del feature.
Pregunta que responde: ¿qué aprendido antes me sirve aquí y qué habilita este ISS después?
Glosario
| Término | Significado |
|---|---|
| Feature | Unidad vertical de la arquitectura: una carpeta features/business/<plural>/ con todas las capas de una entidad |
| DTO | Data Transfer Object: contrato de entrada/salida de la API. No es una capa |
| Barrel | Archivo index.ts que reexporta los DTOs; se importa from "./dto" |
| Borrado lógico | Marcar status = "inactive" en vez de borrar la fila |
| Borrado físico | DELETE real sobre la fila |
fail-safe de status |
El default del modelo es "inactive": sin estado explícito, la fila no es visible |
findOrFail |
Helper del service que lanza AppError(404) si el registro no existe o está inactivo |
AppError |
Error de negocio con código HTTP; BaseController.run lo traduce a respuesta |
| Seeder idempotente | Seeder que no vuelve a insertar si ya hay filas |
SeedersRunner |
Script npm run db:seed que ejecuta los seeders, fuera del recorrido HTTP |
| Registry de Swagger | src/swagger/index.ts: agrega los módulos de cada feature en un solo documento OpenAPI |
| Fail-safe | Comportamiento por defecto seguro delante de una omisión |
.http |
Archivo de peticiones para el REST Client del editor |
Pregunta que responde: ¿qué vocabulario nuevo debo manejar al terminar este ISS?
Criterios de aceptación
Los del ISS, textuales, como checklist:
- [ ] 11.1 Modelo
product-type.model.ts(status+timestamps: true) - [ ] 11.2 DTOs (
dto/) + Repository + Service + Controller + routes en este orden: getAll, getOne, create, update PUT/PATCH, delete físico y lógico - [ ] 11.3 Carpeta
http/en el mismo orden: get, create, update, delete - [ ] 11.4 Cableado en
routes/index.ts+config(import model + route) - [ ] 11.5 Seeder + registro en SeedersRunner / counts
- [ ] 11.6 Swagger + registro en
src/swagger
Evaluación
Preguntas de comprensión
-
¿Por qué el modelo usa
defaultValue: "inactive"si la API siempre crea con"active"? Porque es un fail-safe. El único camino «normal» de creación es la API, que envía"active"explícitamente. Pero si alguien inserta una fila por otra vía (un script, unINSERTmanual), sin ese default la fila tendríastatusnulo o inesperado y podría aparecer como visible. Con el defaultinactive, un olvido no expone datos: la fila queda invisible hasta que se active a propósito. -
¿Por qué
statusno aparece enUpdateProductTypeDtoni enPatchProductTypeDto? Porque hay una sola forma de cambiar el estado: el borrado lógico (PATCH /api/tipos-producto/:id/deactivate). Sistatusviajara en el update, existirían dos caminos (PUT y deactivate), podría «resucitarse» un registroinactivecon un PUT, y las reglas se volverían ambiguas. -
¿Qué devuelve
getAlly por qué no incluye los tiposinactive? Devuelve un array deProductTypeResponseDto. El repository filtra conwhere: { status: "active" }, así que los registros con borrado lógico no salen. Esto es coherente confindOrFail, que también considera «no existe» a un registro inactivo. -
¿Por qué este ISS es «el segundo feature» y no introduce ninguna capa nueva? Porque la arquitectura (la cadena HTTP → Controller → Service → Repository → Model → Sequelize → BD) ya se construyó completa en ISS-03. ISS-06 solo instancia esa arquitectura para otra entidad. Ese es precisamente el valor del patrón: un feature nuevo se ensambla, no se diseña.
-
¿Qué diferencia hay entre el borrado físico y el lógico, y por qué existen los dos? El lógico pone
status = "inactive"y preserva la fila (es la baja funcional: deja de ser visible para la API). El físico ejecutaDELETEy elimina la fila, incluso una ya desactivada (onlyActive: false). Existen los dos porque se necesita poder «retirar» sin destruir, y también poder purgar de verdad cuando corresponda. En ISS-07, el físico quedará limitado por la FKRESTRICTcuando existan productos que referencien al tipo. -
¿Qué papel juega
src/swagger/index.tsy por qué el feature no «monta» su propia doc? El feature exporta un módulo (productTypesSwagger) que el registry externo agrega al documento OpenAPI. Separar la definición (en el feature) del montaje (en el registry) mantiene una única fuente del documento y permite añadir o quitar features sin tocar la app. El feature no se monta solo. -
Si borras la línea que importa el modelo en
config/index.ts, ¿qué se rompe y por qué? Se rompe elsync:configimporta los modelos para que Sequelize los registre antes de sincronizar. Sin ese import, Sequelize no conoceProductTypey la tablaproduct_typesno existe para la app (aunque el resto del código compile). Es el error de cableado más típico de este ISS.
Ejercicios
Ejercicio 1 — Reconstruir la tabla de rutas.
Sin mirar el ISS, escribe la tabla de las seis rutas de /api/tipos-producto (método, path, handler del controller). Después compárala con el archivo product-types.routes.ts.
Respuesta razonada
| Método | Path | Handler | |---|---|---| | GET | `/api/tipos-producto` | `getAll` | | GET | `/api/tipos-producto/:id` | `getOne` | | POST | `/api/tipos-producto` | `create` | | PUT | `/api/tipos-producto/:id` | `updatePut` | | PATCH | `/api/tipos-producto/:id` | `updatePatch` | | DELETE | `/api/tipos-producto/:id` | `deletePhysical` | | PATCH | `/api/tipos-producto/:id/deactivate` | `deleteLogical` | Son siete entradas para seis criterios porque «update» cubre PUT y PATCH, y «delete» cubre físico y lógico. Si tu tabla no separa el borrado lógico (`/deactivate`) del físico, revisa el ISS: son endpoints distintos con semántica distinta.Ejercicio 2 — Trazar el olvido.
Un compañero crea todos los archivos del feature, ejecuta npm run dev, el servidor arranca, pero curl http://localhost:4000/api/tipos-producto responde 404. Enumera tres causas posibles y el parche que las resuelve.
Respuesta razonada
1. **Falta el cableado en `src/routes/index.ts`.** Sin el import, la propiedad `productTypesRoutes` y la llamada `this.routePrv.productTypesRoutes.routes(this.app);`, Express no conoce las rutas → 404. Parchear las tres piezas. 2. **El cableado existe pero la llamada está fuera de `routes()`.** La propiedad se instancia, pero nunca se monta. Mover la llamada dentro del método. 3. **La app arrancó desde una versión anterior del código** (proceso zombi en el puerto 4000) o `npm run dev` no recompiló. Detener el proceso, arrancar de nuevo y volver a probar. La lección: un 404 de un feature nuevo casi nunca es de lógica de negocio; es de **cableado**.Ejercicio 3 — Decidir el DTO.
Te piden añadir un campo code (código corto, único) al tipo de producto. ¿En qué DTOs lo pondrías y por qué? ¿Lo pondrías en el modelo?
Respuesta razonada
En el **modelo** (una columna más, con su tipo y quizá `unique`) y en `CreateProductTypeDto`, `UpdateProductTypeDto` y `ProductTypePatch` (que deriva del update). En `product-type-response.dto.ts` aparecería automáticamente porque hoy es `ProductTypeI`; si el día de mañana debe ocultarse, se proyectaría con `Omit`. Lo que **no** cambiaría: `status` seguiría fuera de update/patch, y el mapper seguiría siendo el único punto que decide qué se expone. Y no añadirías validación en el controller: la regla (código único) iría en el service o en la BD.GATE
Para cerrar el ISS-06, ejecuta:
Resultado esperado: el servidor debe arrancar sin error (conexión OK, sync OK y la tabla product_types disponible). Detenerlo con Ctrl+C antes de continuar.
Verificación funcional del feature (con el servidor en marcha):
curl -s -X POST http://localhost:4000/api/tipos-producto -H 'Content-Type: application/json' \
-d '{"name":"Bebidas","description":"Refrescos","status":"active"}'
curl -s http://localhost:4000/api/tipos-producto
Resultado esperado: el POST devuelve el tipo creado y el GET devuelve { "product_types": [...] } incluyendo el nuevo registro.
Checklist de cierre:
- [ ] 11.1 Modelo
product-type.model.ts(status+timestamps: true) - [ ] 11.2 DTOs + Repository + Service + Controller + routes (getAll, getOne, create, update PUT/PATCH, delete físico y lógico)
- [ ] 11.3 Carpeta
http/en el mismo orden - [ ] 11.4 Cableado en
routes/index.ts+config - [ ] 11.5 Seeder + registro en SeedersRunner / counts
- [ ] 11.6 Swagger + registro en
src/swagger - [ ]
npm run devarranca sin error
Con los seis criterios en verde, el ISS-06 está cumplido y puedes pasar al ISS-07 — Feature Product, donde products nace con la FK product_type_id y el feature deja de ser una isla.
Navegación de la ruta: ← ISS-05 · 🛠 Construir · ↑ Ruta Express · → ISS-06 · 🛠 Construir