Saltar a contenido

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

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

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


🎬 Video explicativo

Recorrido audiovisual de la unidad. Este video explica, bloque por bloque, el CRUD de 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:

EXPLICACIÓN
    ↓
CÓDIGO
    ↓
VISUAL

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

Y cada cuaderno contiene los mismos seis componentes:

Componente Dónde vive Para qué sirve
Texto todas las secciones entender el por qué
Código Recorrido del ISS (verbatim del ISS) ver el qué exacto
Diagramas Mapa mental, Mapa del backend, Flujos 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

  1. Ficha del ISS
  2. Mapa mental del ISS
  3. Mapa del backend
  4. Árbol de archivos
  5. Anatomía del código
  6. Comandos explicados
  7. Flujos
  8. Recorrido del ISS, paso a paso
  9. Diagnóstico
  10. Conexión con el resto del curso
  11. Glosario
  12. Criterios de aceptación
  13. Evaluación
  14. 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_types con su modelo Sequelize (status + timestamps: true).
  • El CRUD entero por capas: getAll, getOne, create, update (PUT y PATCH) y delete (físico y lógico).
  • Los archivos .http para 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: ProductType queda «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 clase ProductType (la instancia Sequelize).
  • Columnas: name (obligatorio), description (opcional/null), status (ENUM("active","inactive")).
  • El defaultValue de status es "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: true añade createdAt y updatedAt; el tableName real es product_types (plural snake_case).

Se conecta con

  • Entrada: el Repository (lo instancia y consulta) y el Seeder (hace bulkCreate).
  • 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.body con 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.
  • create copia campo a campo (evita mass assignment) y aplica status ?? "active".
  • deleteLogical cambia status a "inactive"; deletePhysical destruye la fila (y admite purgar un registro ya desactivado con onlyActive: false).
  • findOrFail(id, onlyActive = true) es el único lugar donde se define «no existe»: si falta o está inactive, lanza AppError(404, …).
  • No conoce req/res ni 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 en this.run(res, …), que centraliza el try/catch y la traducción de AppError a código HTTP.
  • El :id se lee con this.paramId(req) (si no es entero ≥ 1, responde 400).
  • Códigos: 200 para lecturas y updates, 201 para 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 agregador routes/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, FK RESTRICT, í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-producto
Depende 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
mkdir -p src/features/business/product-types/http

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

11.2 DTO + Repository + Service + Controller + routes (CRUD completo)

Recordemos el flujo por capas del feature:

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

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

mkdir -p src/features/business/product-types/dto

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 modelo ProductType.
  • product-types.service.ts → reglas de negocio (status por defecto, description normalizada, 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.

  1. Debajo de import { ClientsRoutes } ..., añadir:
import { ProductTypesRoutes } from "../features/business/product-types/product-types.routes";
  1. Dentro de export class Routes, debajo de clientsRoutes, añadir:
  public productTypesRoutes: ProductTypesRoutes = new ProductTypesRoutes();

PARCHE — src/config/index.ts ya existe.

  1. Debajo de import "../features/business/clients/client.model";, añadir:
import "../features/business/product-types/product-type.model";
  1. Dentro de routes(), debajo de this.routePrv.clientsRoutes.routes(this.app);, añadir:
    this.routePrv.productTypesRoutes.routes(this.app);

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
PARCHE — src/database/seeders/counts.ts ya existe.

  • Dentro de SeedCounts, añadir product_types: number;
  • Dentro de DEFAULT_SEED_COUNTS, añadir product_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.

  1. Debajo de imports de client, añadir import de seedProductTypes.
  2. Debajo de await seedClients(...), añadir await 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
PARCHE — src/swagger/index.ts ya existe.

  1. Debajo de import { clientsSwagger } ..., añadir import de productTypesSwagger.
  2. Dentro de featureSwaggerModules, debajo de clientsSwagger,, añadir productTypesSwagger,.

Cierre del ISS

npm run dev

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

Diagnóstico

Síntoma Causa probable Solución
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, AppError y findOrFail, y la convención de nombres (plural para el feature, singular para el modelo);
  • de ISS-04: el SeedersRunner, el archivo counts.ts y 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 products con la FK product_type_id, y su service usará el ProductTypesRepository de 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

  1. ¿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, un INSERT manual), sin ese default la fila tendría status nulo o inesperado y podría aparecer como visible. Con el default inactive, un olvido no expone datos: la fila queda invisible hasta que se active a propósito.

  2. ¿Por qué status no aparece en UpdateProductTypeDto ni en PatchProductTypeDto? Porque hay una sola forma de cambiar el estado: el borrado lógico (PATCH /api/tipos-producto/:id/deactivate). Si status viajara en el update, existirían dos caminos (PUT y deactivate), podría «resucitarse» un registro inactive con un PUT, y las reglas se volverían ambiguas.

  3. ¿Qué devuelve getAll y por qué no incluye los tipos inactive? Devuelve un array de ProductTypeResponseDto. El repository filtra con where: { status: "active" }, así que los registros con borrado lógico no salen. Esto es coherente con findOrFail, que también considera «no existe» a un registro inactivo.

  4. ¿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.

  5. ¿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 ejecuta DELETE y 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 FK RESTRICT cuando existan productos que referencien al tipo.

  6. ¿Qué papel juega src/swagger/index.ts y 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.

  7. Si borras la línea que importa el modelo en config/index.ts, ¿qué se rompe y por qué? Se rompe el sync: config importa los modelos para que Sequelize los registre antes de sincronizar. Sin ese import, Sequelize no conoce ProductType y la tabla product_types no 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:

npm run dev

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 dev arranca 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