Saltar a contenido

📚 Unidad ISS-03 · Feature Client (CRUD por capas) — 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 Client (CRUD por capas) 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-03 — Feature Client (CRUD por capas) (12 diapositivas). Se visualiza aquí, dentro del sitio; no hace falta descargar nada para estudiarla.

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

12 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 Client del ISS-03: el recorrido por capas, lo implementado ahora frente al objetivo con JWT, AppError y BaseController, el modelo y los DTO, el repository, el service, las rutas y el GATE.

19:13 min · narración en español · subtítulos activables desde el reproductor.


ISS-03 — Cuaderno de aprendizaje visual

Tema

Feature Client (CRUD por capas: A…E): el ISS que instaura la arquitectura por capas —HTTP → Routes → Controller → Service → Repository → Model → Sequelize → BD— construyendo, paso a paso, el CRUD completo del feature clients sobre la ruta /api/clientes.

Fuente técnica autoritativa

Archivo fuente 04-ISS-03-client-crud.md
Estado Solo lectura — este cuaderno no modifica el ISS
Alcance 1.765 líneas · 122 bloques de código · 43 criterios de aceptación (variantes A–E + verificación final)
Variantes internas ISS-03-A fundación → ISS-03-B getAll/getOne → ISS-03-C create → ISS-03-D update PUT/PATCH → ISS-03-E delete físico/lógico

Este cuaderno es una capa pedagógica sobre ese archivo: el módulo técnico completo (comandos, código, criterios) viaja dentro de la sección Recorrido del ISS, paso a paso verbatim, encabezados degradados un nivel y enlaces reescritos a ../manual/. El ISS manda; el cuaderno explica.

Pregunta que responde: ¿qué archivo es la fuente autoritativa de este ISS y en qué estado lo trata el cuaderno?

Regla del ISS

Objetivo: dejar el feature listo para CRUD con la arquitectura por capas —HTTP → Controller → Service → Repository → Model → Sequelize → BD—: modelo, repository, service, controller, routes, carpeta http/ y cableado. Bloqueado por: ISS-02 (infraestructura de base de datos), y cada variante por la anterior: A → B → C → D → E. Criterio de cierre: npx tsc --noEmit sin errores de tipos, npm run dev con conexión OK y sync OK (tabla clients creada), y el checklist final de 7 criterios en verde.

La regla que hace singular a este ISS es la obligatoriedad del recorrido. Una petición nunca se salta una capa: el controller no consulta la base de datos, el service no conoce req/res, y el repository es la única capa que habla con Sequelize. Si esa disciplina se respeta, todos los features del curso se leen igual.

Cómo leer este cuaderno

Cada concepto se presenta tres veces, desde tres ángulos distintos: explicación, código exacto (que llega por el cuerpo incrustado) y representación visual.

                 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á construido este cuaderno y por qué cada concepto se enseña tres veces?

El recorrido de lectura es siempre el mismo:

EXPLICACIÓN
    ↓
CÓDIGO
    ↓
VISUAL

Pregunta que responde: ¿en qué orden debo leer cada apartado del cuaderno?

Y el cuaderno contiene estos seis componentes:

Componente Dónde vive Para qué sirve
Texto todas las secciones entender el por qué de cada decisión
Código Recorrido del ISS (verbatim) ver el qué exacto que hay que escribir
Diagramas Mapa mental, Mapa del backend, Flujos, Anatomía ver el cómo se conectan las capas
Preguntas Evaluación 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. Como es el módulo que instaura las capas, la ruta refleja el orden real de construcción del feature: primero el modelo, luego los contratos, después las capas de dentro hacia fuera (repository → service → controller), luego el enrutado y, al final, el cableado con la aplicación.

ISS-03-A · FUNDACIÓN (instaura las capas)
 │
 ├── src/shared/            AppError · BaseController · withTransaction
 │
 ├── 1. client.model.ts     tabla clients + hooks bcrypt + status
 │
 ├── 2. dto/                create · update · patch · response · index (barrel)
 │
 ├── 3. esqueletos          clients.repository.ts
 │                          clients.service.ts
 │                          clients.controller.ts
 │                          clients.routes.ts
 │
 ├── 4. http/               carpeta para peticiones REST Client
 │
 └── 5. cableado            routes/index.ts + parche a config/index.ts
        ↓
ISS-03-B · READ        getAll  + getOne   (findAllActive, findById, findOrFail)
        ↓
ISS-03-C · CREATE      create              (hook beforeCreate hashea password)
        ↓
ISS-03-D · UPDATE      updatePut + updatePatch  (password se conserva si no viene)
        ↓
ISS-03-E · DELETE      deletePhysical + deleteLogical  (status = inactive)
        ↓
VERIFICACIÓN FINAL      npx tsc --noEmit · npm run dev · curls del contrato

Fíjate en lo que no aparece: no hay auth, no hay middlewares JWT, no hay seeders ni Swagger. Todo eso llega más adelante; aquí solo se levanta la primera cadena completa de un feature.

Pregunta que responde: ¿en qué orden se construye el feature y qué dejo fuera en este ISS?

Índice

  1. Ficha del ISS · Qué implementamos AHORA · Qué todavía NO implementamos
  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-03
Título Feature Client (CRUD por capas: A…E)
Objetivo Instaurar la arquitectura por capas y construir el CRUD completo del feature clients sobre /api/clientes
Fase Fase I — Business
Tecnología principal Express 5 + TypeScript + Sequelize/MySQL y bcryptjs@^3.0.3
Depende de ISS-02 — Infraestructura de base de datos
Habilita ISS-04 — Seeders con Faker · ISS-05 — Swagger / OpenAPI
Archivos creados src/shared/{errors/app-error,http/base-controller,database/with-transaction}.ts; el feature clients/ (model, dto/, repository, service, controller, routes, http/*.http); src/routes/index.ts
Archivos parcheados src/config/index.ts
Componentes incorporados AppError, BaseController (run, paramId, handleError), withTransaction, DTOs + mapper toClientResponse, las capas Repository / Service / Controller / Routes y los hooks bcrypt del modelo
Verificación principal npx tsc --noEmit (0 errores) + npm run dev (sync OK) + curls del contrato de errores (400/404)
Resultado esperado CRUD de clients funcionando por capas, con 404/400 consistentes y password nunca expuesto
GATE Checklist final de 7 criterios en verde

Qué implementamos AHORA

En este ISS nace la primera cadena vertical completa del backend. No es un esqueleto: es un feature de verdad, con las cinco capas conectadas de punta a punta y verificación real con curl.

  • src/shared/ — la infraestructura transversal: AppError (errores de negocio con status), BaseController (un solo try/catch, validación de :id, traducción de errores a HTTP) y withTransaction (helper unit of work, se crea aquí aunque se use en ISS-08).
  • Modelo Client — client.model.ts con status (active/inactive), timestamps: true y hooks beforeCreate / beforeUpdate / beforeBulkCreate que hashean password con bcrypt.
  • DTOs — un archivo por operación (create, update, patch, response) más un index.ts barrel; el mapper toClientResponse oculta password.
  • Las capas del feature — clients.repository.ts, clients.service.ts, clients.controller.ts y clients.routes.ts, completadas en orden B → C → D → E.
  • Rutas /api/clientes — GET (colección y por id), POST, PUT, PATCH, PATCH /:id/deactivate y DELETE /:id.
  • Cableado — src/routes/index.ts agrega el feature y src/config/index.ts importa el modelo, conecta la base de datos, hace sync({ force: false, alter: true }) y después escucha HTTP.

Qué todavía NO implementamos

Este ISS parece "grande", así que conviene tener claro qué no trae, para no confundir la arquitectura actual con la futura:

No se implementa aquí Llega en
Seeders (SeedersRunner, counts, npm run db:seed) ISS-04
Swagger / OpenAPI en /api/docs ISS-05
Feature product-types (2.º feature) ISS-06
Feature products + asociaciones ISS-07
Features sales y product-sales con transacciones reales ISS-08
Uso efectivo de withTransaction (aquí solo se crea el helper) ISS-08
Autenticación JWT, authenticate, authorize y RBAC ISS-09 … ISS-15
Protección de las rutas de clients con middleware ISS-13

Ojo con la trampa habitual: los archivos .http del feature mencionan login, tokens y códigos 401/403, pero eso es la modalidad futura (JWT + RBAC). En ISS-03 las rutas van sin auth: las peticiones funcionan sin Authorization.

Pregunta que responde: ¿qué queda funcionando de verdad al terminar el ISS y qué es todavía promesa?

Mapa mental del ISS

mindmap
  root["ISS-03 Feature Client - CRUD por capas"]
    objetivo["Objetivo"]
      o1["Instaurar el recorrido HTTP a BD sin saltar capas"]
      o2["CRUD completo de clients"]
    feature["Feature y API"]
      f1["clients"]
      f2["API /api/clientes"]
    capas["Capas"]
      c1["Model"]
      c2["Repository"]
      c3["Service"]
      c4["Controller"]
      c5["Routes"]
      c6["DTO"]
    compartido["src/shared"]
      s1["AppError"]
      s2["BaseController"]
      s3["withTransaction"]
    variantes["Variantes A a E"]
      v1["A fundacion"]
      v2["B getAll y getOne"]
      v3["C create"]
      v4["D updatePut y updatePatch"]
      v5["E delete fisico y logico"]
    cierre["Cierre"]
      z1["npx tsc --noEmit"]
      z2["npm run dev con sync OK"]

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? Hasta ISS-02 solo había esqueleto HTTP y conexión a base de datos. Con ISS-03, el feature clients recorre por fin la cadena entera.

IMPLEMENTADO HASTA ESTE ISS  (ISS-03)
─────────────────────────────────────

  HTTP   GET/POST/PUT/PATCH/DELETE /api/clientes   ✅
    │
    ▼
  Routes      clients.routes.ts         ✅
    │
    ▼
  Controller  ClientsController          ✅
              + BaseController (run, paramId, handleError)  ✅
    │
    ▼
  Service     ClientsService             ✅
    │
    ▼
  Repository  ClientsRepository          ✅
    │
    ▼
  Model       client.model.ts (Client)   ✅
    │
    ▼
  DTO         create · update · patch · response · mapper  ✅
    │
    ▼
  Sequelize                              ✅
    │
    ▼
  BD          tabla clients              ✅

  Feature clients: cadena COMPLETA.
  Es el ÚNICO feature que existe.

OBJETIVO DE ARQUITECTURA (curso completo)
─────────────────────────────────────────

  HTTP
    ↓
  Routes de todos los features           🎯
    ↓
  Controller                             🎯
    ↓
  Service                                🎯
    ↓
  Repository                             🎯
    ↓
  Model                                  🎯
    ↓
  Sequelize                              ✅ (ya está en pie)
    ↓
  BD

  🎯 Seeders con Faker                (ISS-04)
  🎯 Swagger en /api/docs             (ISS-05)
  🎯 product-types                    (ISS-06)
  🎯 products + asociaciones          (ISS-07)
  🎯 sales + product-sales            (ISS-08)
  🎯 Auth JWT + RBAC                  (ISS-09 … ISS-15)

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

Árbol de archivos

Leyenda: ★ = creado o trabajado en este ISS · △ = existente y parcheado · (sin marca) = ya existía y no se toca.

Estructura antes

Al terminar ISS-02 el proyecto tiene esqueleto HTTP y conexión a base de datos, pero el feature clients/ está vacío: solo existen las carpetas que ISS-01 dejó creadas.

src/
├── config/
│   └── index.ts               (App esqueleto, ISS-01)
├── database/
│   └── db.ts                  (sequelize, testConnection, ISS-02)
├── routes/                    (carpeta vacía, ISS-01)
├── shared/                    (carpetas vacías, ISS-01)
│   ├── database/
│   ├── errors/
│   └── http/
├── features/
│   └── business/
│       └── clients/           (carpeta vacía, ISS-01)
└── server.ts                  (arranque, ISS-01)

Pregunta que responde: ¿de dónde partimos antes de escribir la primera capa?

Archivos creados / modificados en este ISS

★ src/shared/errors/app-error.ts
★ src/shared/http/base-controller.ts
★ src/shared/database/with-transaction.ts

★ src/features/business/clients/client.model.ts
★ src/features/business/clients/dto/create-client.dto.ts
★ src/features/business/clients/dto/update-client.dto.ts
★ src/features/business/clients/dto/patch-client.dto.ts
★ src/features/business/clients/dto/client-response.dto.ts
★ src/features/business/clients/dto/index.ts
★ src/features/business/clients/clients.repository.ts
★ src/features/business/clients/clients.service.ts
★ src/features/business/clients/clients.controller.ts
★ src/features/business/clients/clients.routes.ts
★ src/features/business/clients/http/clients.get.http
★ src/features/business/clients/http/clients.create.http
★ src/features/business/clients/http/clients.update.http
★ src/features/business/clients/http/clients.delete.http

★ src/routes/index.ts            (agregador de features)

△ src/config/index.ts            (importa modelo + db + routes; sync; listen)

+ dependencias: bcryptjs@^3.0.3  y  @types/bcryptjs@^3.0.0

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

Estructura después

src/
├── config/
│   └── index.ts                          △ (parcheado: modelo + db + routes + sync)
├── database/
│   └── db.ts
├── routes/
│   └── index.ts                          ★
├── shared/
│   ├── database/
│   │   └── with-transaction.ts           ★
│   ├── errors/
│   │   └── app-error.ts                  ★
│   └── http/
│       └── base-controller.ts            ★
├── features/
│   └── business/
│       └── clients/
│           ├── client.model.ts           ★
│           ├── dto/
│           │   ├── create-client.dto.ts  ★
│           │   ├── update-client.dto.ts  ★
│           │   ├── patch-client.dto.ts   ★
│           │   ├── client-response.dto.ts★
│           │   └── index.ts              ★
│           ├── clients.repository.ts     ★
│           ├── clients.service.ts        ★
│           ├── clients.controller.ts     ★
│           ├── clients.routes.ts         ★
│           └── http/
│               ├── clients.get.http      ★
│               ├── clients.create.http   ★
│               ├── clients.update.http   ★
│               └── clients.delete.http   ★
└── server.ts

Pregunta que responde: ¿cómo queda el proyecto después de completar el CRUD?

Anatomía del código

El código completo y verbatim de cada archivo llega en Recorrido del ISS, paso a paso. Aquí se explica por qué existe cada pieza y con quién se conecta, sin repetir el listado.

El recorrido de una petición es siempre este, y ninguna flecha se salta:

flowchart TD
  A["HTTP: /api/clientes"] --> B["ClientsRoutes.routes"]
  B --> C["ClientsController"]
  C --> D["ClientsService"]
  D --> E["ClientsRepository"]
  E --> F["Client (Model)"]
  F --> G["Sequelize"]
  G --> H["Tabla clients"]

Pregunta que responde: ¿qué capa llama a qué capa en una petición del feature?

Archivo: src/shared/errors/app-error.ts

Propósito

Definir el error de negocio. Los services no devuelven códigos HTTP: lanzan un AppError con un statusCode y un message, y el controller lo traduce a una respuesta HTTP.

Explicación

  • Es una clase que extiende Error y añade la propiedad statusCode.
  • Fija name = "AppError", lo que permite reconocerlo en el catch de BaseController.
  • Separa dos mundos: la capa de negocio razona en términos de «no encontrado» (404) o «estado inválido»; la capa HTTP es la única que decide cómo eso se convierte en bytes de respuesta.

Se conecta con

  • Entrada: lo lanzan los service (por ejemplo ClientsService.findOrFail y BaseController.paramId).
  • Salida: lo consume BaseController.handleError, que lee statusCode y message.

Archivo: src/shared/http/base-controller.ts

Propósito

Concentrar las tres responsabilidades puramente HTTP que, de otro modo, se repetirían en los ~35 métodos de todos los controllers del curso: ejecutar con try/catch, validar el :id y traducir errores a HTTP.

Explicación

  • run(res, work) — envuelve el camino feliz del handler y centraliza el catch. Aquí el try/catch existe una sola vez; cada controller solo lee, llama al service y responde.
  • paramId(req) — lee el :id, exige que sea un entero ≥ 1 y, si no, lanza AppError(400, …). Sin esta validación, GET /api/clientes/abc llegaría al repository como NaN y devolvería un 404 engañoso en vez de un 400.
  • handleError(res, error) — mapea AppError a su status y cualquier otro error a 500, para que un fallo inesperado no se confunda con una regla de negocio.
  • Es una clase abstracta: no se instancia, se hereda (ClientsController extends BaseController).

Se conecta con

  • Entrada: los controllers heredan de ella y llaman a run y paramId; el paramId usa AppError.
  • Salida: handleError escribe la respuesta HTTP y consulta AppError.statusCode.

Archivo: src/shared/database/with-transaction.ts

Propósito

Ofrecer un helper unit of work: ejecutar un bloque dentro de una transacción, con commit si termina bien y rollback si lanza. Se crea aquí para tener la infraestructura lista, aunque no se usa todavía.

Explicación

  • Abre sequelize.transaction() y pasa la transacción a work.
  • Si work termina, hace commit; si lanza, hace rollback (ignorando un posible fallo del rollback) y re-lanza el error para no silenciarlo.
  • Lo usarán los services que tocan varias tablas a la vez, como crear una venta (sales + product_sales + stock de products).

Se conecta con

  • Entrada: nadie lo llama en ISS-03; sí en ISS-08.
  • Salida: usa sequelize.transaction() de src/database/db.ts y el tipo Transaction de Sequelize.

Archivo: src/features/business/clients/client.model.ts

Propósito

Definir la tabla clients y sus reglas de entidad: atributos, validaciones, status y el hashing de password mediante hooks.

Explicación

  • Declara la interfaz ClientI (forma de la fila) y la clase Client extends Model (instancia).
  • Atributos: name, address, phone (con notEmpty), email (unique + isEmail), password y status.
  • status es ENUM("active", "inactive") con defaultValue: "inactive": es un fail-safe, una fila insertada sin estado explícito no queda visible en la API. La vía de creación por API siempre envía "active".
  • timestamps: true añade createdAt / updatedAt.
  • Los hooks beforeCreate, beforeUpdate y beforeBulkCreate hashean password con bcrypt.genSalt(10) + bcrypt.hash(...). El hashing vive en el modelo, no en el service.
  • beforeUpdate solo re-hashea si client.changed("password"), para no re-hashear un hash.

Se conecta con

  • Entrada: lo importa clients.repository.ts y lo registra el import lateral de config/index.ts.
  • Salida: usa la conexión sequelize de src/database/db.ts y bcryptjs.

Archivo: src/features/business/clients/dto/

Propósito

Fijar el contrato de entrada/salida del feature, un archivo por operación, y centralizar qué campos se exponen.

Explicación

  • create-client.dto.ts — campos obligatorios del POST; status es opcional (por defecto active).
  • update-client.dto.ts — reemplazo completo del PUT; no incluye status, porque el estado solo cambia con el borrado lógico; password es opcional (si no viene, se conserva el hash).
  • patch-client.dto.ts — Partial<UpdateClientDto>; actualización parcial del PATCH.
  • client-response.dto.ts — declara ClientResponseDto = Omit<ClientI, "password"> y el mapper toClientResponse(client), único lugar donde se decide qué se expone. Devuelve un objeto plano (client.toJSON()), sin metadatos del ORM y sin password.
  • index.ts — el barrel: todas las capas importan los DTOs desde ./dto, nunca desde el archivo suelto. Un único punto de importación.

Se conecta con

  • Entrada: los importa el service (entrada/salida) y el controller (casts de req.body).
  • Salida: el response.dto importa el modelo Client / ClientI para el Omit y el mapper.

Archivo: src/features/business/clients/clients.repository.ts

Propósito

Ser la única capa que habla con Sequelize. No contiene reglas de negocio ni conoce req/res.

Explicación

  • findAllActive() — Client.findAll({ where: { status: "active" } }).
  • findById(id, transaction?) — Client.findByPk(id, { transaction }); acepta transacción para futuros flujos de ventas.
  • create(data) — Client.create(data) con CreationAttributes<Client>.
  • update(client, data) — recibe la instancia ya cargada y solo la persiste; no sabe si es PUT, PATCH o borrado lógico.
  • delete(client) — client.destroy(), eliminación física.
  • El tipo de sus datos es del modelo (Partial<ClientI>, CreationAttributes<Client>), no un DTO: su contrato es la base de datos, no la API.

Se conecta con

  • Entrada: lo llama ClientsService.
  • Salida: usa el modelo Client y tipos de Sequelize (Transaction, CreationAttributes).

Archivo: src/features/business/clients/clients.service.ts

Propósito

Concentrar las reglas de negocio del feature: default de status, política de borrado lógico, borrado físico y saneamiento de la respuesta.

Explicación

  • getAll() — pide los activos y los proyecta con toClientResponse.
  • getOne(id) — delega en findOrFail(id) y proyecta.
  • create(body) — copia campo a campo lo que declara CreateClientDto (evita mass assignment) y aplica status ?? "active".
  • updatePut / updatePatch — cargan con findOrFail, actualizan; en el PUT password: body.password ?? client.password conserva el hash actual si no se envía.
  • deletePhysical(id) — usa findOrFail(id, false) para poder purgar también lo ya desactivado.
  • deleteLogical(id) — no es un DELETE: reutiliza update(client, { status: "inactive" }).
  • findOrFail(id, onlyActive = true) — el único sitio donde se decide qué es «no existe»: si el registro no existe o está inactive, lanza AppError(404, "Client not found"). Gracias a él, getOne, updatePut, updatePatch y deleteLogical quedan consistentes sin repetir la comprobación.
  • Devuelve siempre DTOs, nunca instancias de Sequelize.

Se conecta con

  • Entrada: lo llama ClientsController.
  • Salida: usa ClientsRepository, los DTOs (./dto), el modelo Client y AppError.

Archivo: src/features/business/clients/clients.controller.ts

Propósito

Traducir HTTP ↔ negocio: leer req, llamar al service y armar la respuesta con su código de estado. No tiene reglas de negocio ni consultas.

Explicación

  • Extiende BaseController e inyecta ClientsService por constructor (con valor por defecto).
  • Cada método envuelve su camino feliz en this.run(res, async () => { … }).
  • Usa this.paramId(req) para obtener el :id validado.
  • Códigos: getAll/getOne → 200, create → 201, updates → 200, deletePhysical → 200 con { message, id }, deleteLogical → 200 con { message, client }.
  • Los req.body se castean a los DTOs (CreateClientDto, UpdateClientDto, PatchClientDto).

Se conecta con

  • Entrada: lo llaman las rutas (clients.routes.ts).
  • Salida: hereda run / paramId de BaseController y llama a ClientsService.

Archivo: src/features/business/clients/clients.routes.ts

Propósito

Declarar las rutas HTTP del feature y enlazarlas con los métodos del controller.

Explicación

  • Instancia clientsController como propiedad pública.
  • routes(app) registra el CRUD completo en /api/clientes:
  • GET /api/clientes → getAll
  • GET /api/clientes/:id → getOne
  • POST /api/clientes → create
  • PUT y PATCH /api/clientes/:id → updatePut / updatePatch
  • DELETE /api/clientes/:id → deletePhysical
  • PATCH /api/clientes/:id/deactivate → deleteLogical
  • Cada handler se enlaza con .bind(this.clientsController) para no perder el this.
  • En ISS-03 el bloque está bajo el comentario «RUTAS SIN AUTENTICACIÓN / SIN MIDDLEWARE JWT».

Se conecta con

  • Entrada: lo llama src/routes/index.ts.
  • Salida: usa ClientsController.

Archivo: src/routes/index.ts

Propósito

Ser el agregador de features: un único punto donde se declaran las rutas de cada feature.

Explicación

  • La clase Routes expone clientsRoutes como propiedad.
  • Se crea aquí (la carpeta existía desde ISS-01, el archivo no).

Se conecta con

  • Entrada: lo importa src/config/index.ts.
  • Salida: importa ClientsRoutes.

Archivo: src/config/index.ts (parcheado)

Propósito

Conectar la aplicación con el feature: importar el modelo, registrar las rutas y sincronizar la base de datos antes de escuchar HTTP.

Explicación

  • Debajo de var cors = require("cors"); se añaden los imports de sequelize, getDatabaseInfo, testConnection, el import lateral del modelo Client y Routes.
  • Dentro de App se añade public routePrv: Routes = new Routes();.
  • En routes() se llama this.routePrv.clientsRoutes.routes(this.app);.
  • En dbConnection() se comprueba la conexión, se hace sequelize.sync({ force: false, alter: true }) y, si falla, process.exit(1).
  • alter: true existe para que sync añada columnas que falten (por ejemplo createdAt / updatedAt si la tabla se creó antes con timestamps: false). force: false no recrea tablas ni borra datos.
  • Orden de arranque: listen() hace await this.dbConnection() antes de app.listen(...). Así las sentencias DDL de sync no compiten con las peticiones y se evitan deadlocks y errores de FK intermitentes.

Se conecta con

  • Entrada: lo usa server.ts.
  • Salida: importa database/db.ts, client.model.ts y routes/index.ts.

Comandos explicados

Ningún comando va sin explicación. Se copian exactamente como están en el ISS.

npm install bcryptjs@^3.0.3 + npm install -D @types/bcryptjs@^3.0.0

COMANDO
   ↓
npm install bcryptjs@^3.0.3
npm install -D @types/bcryptjs@^3.0.0
   ↓
QUÉ HACE
   Instala bcryptjs (hash de contraseñas en JavaScript puro) como dependencia de
   producción y sus tipos TypeScript como dependencia de desarrollo.
   ↓
POR QUÉ SE NECESITA
   El modelo Client hashea `password` en los hooks; sin la librería, `import bcrypt`
   rompe la compilación. Y con `strict: true`, TypeScript exige los tipos.
   ↓
QUÉ CREA O MODIFICA
   Añade bcryptjs a `dependencies` y @types/bcryptjs a `devDependencies` (package.json).
   ↓
RESULTADO ESPERADO
   Mensaje de npm sin errores.
   ↓
CÓMO VERIFICARLO
   `node -e "console.log(require('bcryptjs/package.json').version)"` imprime una 3.x
   y `npx tsc --noEmit` no se queja de bcryptjs.

mkdir -p src/shared/errors src/shared/http src/shared/database

COMANDO
   ↓
mkdir -p src/shared/errors src/shared/http src/shared/database
   ↓
QUÉ HACE
   Crea (si no existen) las tres carpetas de la capa compartida.
   ↓
POR QUÉ SE NECESITA
   AppError, BaseController y withTransaction viven en src/shared y se reutilizan en
   todos los features. ISS-01 ya había dejado estas carpetas; el comando es idempotente.
   ↓
QUÉ CREA O MODIFICA
   Tres directorios bajo src/shared/.
   ↓
RESULTADO ESPERADO
   Los directorios existen.
   ↓
CÓMO VERIFICARLO
   `test -d src/shared/errors && echo OK`.

mkdir -p src/features/business/clients/http y src/features/business/clients/dto

COMANDO
   ↓
mkdir -p src/features/business/clients/http
mkdir -p src/features/business/clients/dto
   ↓
QUÉ HACE
   Crea las dos carpetas internas del feature: `http/` (peticiones REST Client .http)
   y `dto/` (un archivo por operación del CRUD más el barrel `index.ts`).
   ↓
POR QUÉ SE NECESITA
   `http/` es la verificación de ISS-03-A §4.4; `dto/` es el contrato del feature
   (§4.2). Sin ellas, los archivos correspondientes no tendrían dónde vivir.
   ↓
QUÉ CREA O MODIFICA
   Los directorios .../clients/http/ y .../clients/dto/.
   ↓
RESULTADO ESPERADO
   Ambos directorios existen.
   ↓
CÓMO VERIFICARLO
   `test -d src/features/business/clients/http && echo HTTP_FOLDER_OK`.

Creación de archivos con : > + cat >> … << 'EOF'

COMANDO
   ↓
: > ruta/archivo.ext
cat >> ruta/archivo.ext << 'EOF'
...
EOF
   ↓
QUÉ HACE
   `: >` trunca (crea vacío) el archivo; el heredoc `cat >>` le añade el contenido
   literal sin expandir variables (las comillas de 'EOF' lo garantizan).
   ↓
POR QUÉ SE NECESITA
   Es el mecanismo que el ISS usa para crear todos los archivos de forma reproducible.
   ↓
QUÉ CREA O MODIFICA
   El archivo indicado en cada bloque del ISS.
   ↓
RESULTADO ESPERADO
   El archivo queda con el contenido exacto del ISS.
   ↓
CÓMO VERIFICARLO
   `npx tsc --noEmit` (0 errores) y revisar que el archivo existe.

test -d src/features/business/clients/http && echo HTTP_FOLDER_OK

COMANDO
   ↓
test -d src/features/business/clients/http && echo HTTP_FOLDER_OK
   ↓
QUÉ HACE
   Comprueba que la carpeta existe y, si es así, imprime HTTP_FOLDER_OK.
   ↓
POR QUÉ SE NECESITA
   Es la verificación concreta de ISS-03-A (§4.4): la carpeta `http/` existe.
   ↓
QUÉ CREA O MODIFICA
   Nada (consulta de solo lectura).
   ↓
RESULTADO ESPERADO
   HTTP_FOLDER_OK.
   ↓
CÓMO VERIFICARLO
   Ver el propio mensaje en la terminal.

npm run dev

COMANDO
   ↓
npm run dev
   ↓
QUÉ HACE
   Arranca la app en modo desarrollo con nodemon + ts-node sobre src/server.ts.
   ↓
POR QUÉ SE NECESITA
   Es el cierre de ISS-03-A: prueba que la conexión a BD funciona, que sync crea la
   tabla `clients` y que las rutas responden.
   ↓
QUÉ CREA O MODIFICA
   Crea/actualiza la tabla `clients` en la base de datos (createdAt/updatedAt incluidos).
   ↓
RESULTADO ESPERADO
   Conexión OK + sync OK + servidor escuchando (en el laboratorio, puerto 4000).
   ↓
CÓMO VERIFICARLO
   Ver los logs de conexión y `sync` sin error; después probar los curls. Parar con Ctrl+C.

npx tsc --noEmit

COMANDO
   ↓
npx tsc --noEmit
   ↓
QUÉ HACE
   Ejecuta el compilador de TypeScript sin emitir archivos: solo verifica tipos.
   ↓
POR QUÉ SE NECESITA
   Es el criterio técnico de cierre: el proyecto debe compilar con 0 errores de tipos.
   ↓
QUÉ CREA O MODIFICA
   Nada (modo noEmit).
   ↓
RESULTADO ESPERADO
   Sin errores.
   ↓
CÓMO VERIFICARLO
   Salida vacía o sin líneas de error; el código de salida es 0.

Grupo de curl (verificación del contrato)

COMANDO
   ↓
curl -s http://localhost:4000/api/clientes
curl -s http://localhost:4000/api/clientes/1
curl -s -X POST http://localhost:4000/api/clientes -H 'Content-Type: application/json' -d '…'
curl -s -X PUT http://localhost:4000/api/clientes/1 -H 'Content-Type: application/json' -d '…'
curl -s -X PATCH http://localhost:4000/api/clientes/1 -H 'Content-Type: application/json' -d '…'
curl -s -X PATCH http://localhost:4000/api/clientes/1/deactivate
curl -s -X DELETE http://localhost:4000/api/clientes/1
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:4000/api/clientes/abc
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:4000/api/clientes/0
   ↓
QUÉ HACE
   Ejercita cada endpoint del CRUD y comprueba el contrato de errores.
   ↓
POR QUÉ SE NECESITA
   Demuestra extremo a extremo que la cadena HTTP → BD funciona y que 400/404 se
   comportan como exige el ISS.
   ↓
QUÉ CREA O MODIFICA
   Las peticiones de escritura crean/actualizan/borran filas de `clients`.
   ↓
RESULTADO ESPERADO
   getAll solo activos y sin `password`; `:id` no numérico o 0 → 400; id inexistente o
   inactive → 404; deactivate deja status inactive; DELETE borra la fila.
   ↓
CÓMO VERIFICARLO
   `-o /dev/null -w '%{http_code}\n'` imprime el código (400/404); los cuerpos JSON
   muestran clientes sin `password`.

Flujos

Lectura de un cliente por su id (GET /api/clientes/:id)

sequenceDiagram
  autonumber
  participant U as Cliente HTTP
  participant R as clients.routes
  participant C as ClientsController
  participant S as ClientsService
  participant P as ClientsRepository
  participant M as Cliente (Model)
  participant DB as Base de datos
  U->>R: GET /api/clientes/1
  R->>C: getOne(req, res)
  C->>C: paramId(req)
  C->>S: getOne(1)
  S->>S: findOrFail(1)
  S->>P: findById(1)
  P->>M: Client.findByPk(1)
  M->>DB: SELECT ... WHERE id = 1
  DB-->>M: fila o null
  M-->>S: instancia o null
  alt no existe o esta inactivo
    S-->>C: AppError 404
    C-->>U: 404 JSON con error
  else existe y esta activo
    S->>S: toClientResponse(client)
    S-->>C: ClientResponseDto
    C-->>U: 200 JSON con client
  end

Pregunta que responde: ¿qué recorre una petición de lectura desde el navegador hasta la base de datos y de vuelta?

Ciclo de vida del estado de un cliente

El estado no se edita con PUT ni con PATCH: solo cambia al desactivar y al eliminar.

stateDiagram-v2
  [*] --> active: POST con status active
  active --> inactive: PATCH deactivate
  active --> [*]: DELETE fisico
  inactive --> [*]: DELETE fisico

Pregunta que responde: ¿por qué caminos puede pasar un cliente entre active, inactive y su desaparición?

Transacción (unit of work) creada pero todavía sin usar

El helper withTransaction se crea en ISS-03, pero el CRUD de clients no lo usa: sus operaciones tocan una sola tabla. El flujo que implementa es este:

flowchart TD
  A["withTransaction(work)"] --> B["sequelize.transaction()"]
  B --> C["await work(transaction)"]
  C -->|termina bien| D["transaction.commit()"]
  C -->|lanza error| E["transaction.rollback()"]
  E --> F["re-lanza el error"]
  D --> G["devuelve el resultado"]

Pregunta que responde: ¿cómo garantiza withTransaction que varias escrituras se confirmen o se deshagan juntas?

Recorrido del ISS, paso a paso

A partir de aquí viene el contenido técnico completo del ISS, verbatim: criterios de aceptación, comandos y código de las cinco variantes (A–E). Se reproduce sin resumir y sin reformatear; 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/.

Fase I: Business — ISS-03 — Feature Client (CRUD por capas: A…E)

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 Client (CRUD por capas: A…E)
Feature / tabla clients → clients/
API /api/clientes
Depende de ISS-02 — Infraestructura de base de datos
Habilita ISS-04 — Seeders con Faker · ISS-05 — Swagger / OpenAPI
Variantes internas (en orden) ISS-03-A fundación → ISS-03-B getAll/getOne → ISS-03-C create → ISS-03-D update PUT/PATCH → ISS-03-E delete físico/lógico

Contenido de este ISS

  • ISS-03-A — Feature Client — fundación (capas, modelo, esqueleto, HTTP, cableado)
  • ISS-03-B — Feature Client — GetAll y GetOne
  • ISS-03-C — Feature Client — Crear cliente
  • ISS-03-D — Feature Client — Update (PUT) y Update (PATCH)
  • ISS-03-E — Feature Client — Eliminar (físico y lógico)

ISS-03-A — Feature Client — fundación (capas, modelo, esqueleto, HTTP, cableado)

Nombre recomendado: Feature Client — fundación
Objetivo: dejar el feature listo para CRUD con la arquitectura por capas — HTTP → Controller → Service → Repository → Model → Sequelize → BD —: modelo, repository, service, controller, routes, carpeta http/ y cableado.
Bloqueado por: ISS-02.

Criterios de aceptación (ISS-03-A)

  • [ ] 4.0 Capa compartida src/shared/ creada (AppError, BaseController, withTransaction)
  • [ ] 4.1 Modelo client.model.ts con status + timestamps: true + bcrypt
  • [ ] 4.2 Carpeta dto/ con un archivo por operación (create/update/patch/response) + index.ts
  • [ ] 4.3 Esqueletos clients.repository.ts, clients.service.ts, clients.controller.ts y clients.routes.ts
  • [ ] 4.4 Carpeta features/business/clients/http/ creada
  • [ ] 4.5 routes/index.ts + config importan modelo, conectan BD y hacen sync
  • [ ] Con BD: npm run dev → conexión OK + sync OK + tabla clients

4.0 Arquitectura por capas del feature

Cada feature de features/business/<plural>/ se organiza en capas. Una petición recorre siempre el mismo camino y nunca se salta una capa:

HTTP (routes)
   ↓
Controller   → solo HTTP: lee req, llama al service y arma res (status codes)
   ↓
Service      → reglas de negocio, validaciones, transacciones
   ↓
Repository   → acceso a datos: la única capa que usa Sequelize
   ↓
Model        → definición de la tabla (atributos, hooks, relaciones)
   ↓
Sequelize    → ORM
   ↓
BD
Capa Archivo Sabe de NO sabe de
Controller <plural>.controller.ts req, res, códigos HTTP negocio, Sequelize
Service <plural>.service.ts reglas de negocio, transacciones req/res, SQL
Repository <plural>.repository.ts modelo Sequelize (where, include, lock) HTTP, reglas de negocio
Model <singular>.model.ts atributos, hooks, nombre de tabla HTTP, reglas de negocio

Ventajas: cada capa se testea/entiende por separado; cambiar el ORM solo toca los repositories; cambiar un código HTTP solo toca los controllers; las reglas de negocio quedan en un único lugar (services).

Norma de nombres del feature

Pieza Convención Ejemplo
Carpeta del feature plural kebab-case clients/, product-types/, product-sales/
Archivos de capa plural + sufijo de capa clients.controller.ts, clients.service.ts, clients.repository.ts
Modelo (entidad) singular client.model.ts
Clase del modelo / interfaz singular PascalCase Client, ClientI
Tabla en BD plural snake_case clients, product_types

Es la convención recomendada por la documentación de NestJS y por la mayoría de guías de backend: el feature representa una colección (plural) y la entidad, un registro (singular).

DTO: el contrato de entrada/salida del feature

El DTO (Data Transfer Object) es la forma de los datos que entran y salen por la API. No es una capa más: es el contrato que comparten el controller y el service, y evita que los tipos anden sueltos por el código.

DTO Ruta que lo usa Forma
Create<X>Dto POST /… campos obligatorios de creación
Update<X>Dto PUT /…/:id reemplazo completo
Patch<X>Dto PATCH /…/:id Partial<Update<X>Dto>
<X>ResponseDto respuesta forma de salida (oculta campos sensibles)
Pieza Convención Ejemplo
Carpeta DTO dto/ dentro del feature clients/dto/
Archivo DTO uno por operación create-client.dto.ts, patch-client.dto.ts
DTO de entrada PascalCase + sufijo Dto CreateClientDto, PatchClientDto
DTO de respuesta PascalCase + ResponseDto ClientResponseDto
Mapper to<X>Response() toClientResponse(client)
Barrel dto/index.ts reexporta todos los DTOs del feature

Los DTOs viven en la carpeta dto/ del feature, un archivo por operación del CRUD:

features/business/clients/
├── client.model.ts               <- Model (entidad)
├── dto/                          <- contrato de la API
│   ├── create-client.dto.ts      -> POST   /api/clientes
│   ├── update-client.dto.ts      -> PUT    /api/clientes/:id
│   ├── patch-client.dto.ts       -> PATCH  /api/clientes/:id
│   ├── client-response.dto.ts    -> GET ALL, GET ONE y salidas de create/update/delete lógico
│   └── index.ts                  <- barrel: export * from "./…"
├── clients.repository.ts
├── clients.service.ts
├── clients.controller.ts
└── clients.routes.ts

Las consultas GET ALL y GET ONE no reciben cuerpo: comparten el DTO de respuesta. Las operaciones de escritura (POST, PUT, PATCH) sí tienen su propio DTO de entrada.

Ventajas: el controller ya no hace as ClientI con tipos genéricos; el service declara exactamente qué recibe y qué devuelve; los campos derivados (total de una venta) o sensibles (password) no están en los DTOs de entrada; y el mapper es el único lugar donde se decide qué se expone.

status no aparece en Update<X>Dto ni en Patch<X>Dto: el estado sólo cambia con el endpoint de borrado lógico (PATCH /api/<plural>/:id/deactivate). Así no hay dos formas de desactivar un registro y nadie «resucita» una fila inactive con un PUT.

El repository sigue trabajando con tipos del modelo (Partial<ClientI>), no con DTOs: su contrato es la base de datos, no la API.

Reglas transversales del CRUD (aplican a los 5 features)

Estas cinco reglas son las que mantienen la arquitectura sin redundancia. Si se respetan, todos los features se leen igual y el patrón se puede copiar tal cual a otro proyecto:

  1. Todo handler se envuelve en this.run(res, …). El try/catch existe una sola vez, en BaseController. El controller sólo expresa el camino feliz: leer la entrada → llamar al service → responder.
  2. Todo :id se lee con this.paramId(req). Si no es un entero ≥ 1, responde 400 y el service nunca recibe un id inválido. La validación no se repite en cada método.
  3. Todo método que recibe un id usa findOrFail(id). Si el registro no existe o está inactive, lanza AppError(404, …). Es el único lugar donde se define «no existe», y es lo que hace que el borrado lógico sea consistente entre getOne, updatePut, updatePatch y deleteLogical.
  4. status no viaja en Update<X>Dto ni en Patch<X>Dto. Sólo cambia con el borrado lógico (o al crear, donde es opcional y vale active por defecto). En el modelo, el defaultValue de status es "inactive" (fail-safe: una fila insertada sin estado explícito no queda visible en la API); la creación vía API siempre envía "active".
  5. El service devuelve DTOs de respuesta, nunca instancias de Sequelize. Siempre con to<X>Response(...): garantiza un objeto plano (sin metadatos del ORM) y oculta los campos sensibles.

Además, los DTOs se importan siempre desde el barrel del feature (from "./dto"), no desde el archivo suelto: cada capa tiene un único punto de importación.

Lo repetitivo… …vive en Veces en el proyecto
try/catch + traducción de errores BaseController.run 1
Validación de :id BaseController.paramId 1
«no existe / está inactivo» → 404 <feature>.service.findOrFail 1 por feature
Qué campos se exponen por la API <feature>-response.dto.ts (to<X>Response) 1 por feature
Acceso a Sequelize (where, include, lock) <feature>.repository.ts 1 por feature
4.0.1 src/shared/errors/app-error.ts

Los services no devuelven códigos HTTP: lanzan errores de negocio. Este error transporta el status que el controller traducirá.

mkdir -p src/shared/errors src/shared/http src/shared/database

Nuevo archivo

: > src/shared/errors/app-error.ts
cat >> src/shared/errors/app-error.ts << 'EOF'
/**
 * Error de aplicación con código HTTP asociado.
 *
 * Lo lanzan los **services** (capa de negocio) cuando una regla no se cumple
 * (no encontrado, estado inválido, stock insuficiente, etc.).
 * Los **controllers** lo traducen a una respuesta HTTP.
 */
export class AppError extends Error {
  public readonly statusCode: number;

  public constructor(statusCode: number, message: string) {
    super(message);
    this.name = "AppError";
    this.statusCode = statusCode;
  }
}
EOF
4.0.2 src/shared/http/base-controller.ts

Clase base de todos los controllers. Concentra las tres responsabilidades puramente HTTP, para que no se repitan en los ~35 métodos del proyecto:

  • run(res, work): ejecuta el handler y traduce cualquier error a HTTP (un solo try/catch).
  • paramId(req): lee el :id de la URL y lo valida como entero ≥ 1 (si no, 400).
  • handleError(res, error): mapea AppError a su status y lo demás a 500.

Gracias a esta clase, cada handler de controller sólo escribe el camino feliz de su endpoint.

Nuevo archivo

: > src/shared/http/base-controller.ts
cat >> src/shared/http/base-controller.ts << 'EOF'
import { Request, Response } from "express";
import { AppError } from "../errors/app-error";

/**
 * Base de los controllers HTTP.
 *
 * Aísla las tres responsabilidades puramente HTTP que, si no, se repetirían en
 * los 7 métodos de cada controller:
 *
 *  - `run`:            ejecuta el cuerpo del handler y traduce el error a HTTP.
 *  - `paramId`:        lee y valida el `:id` de la URL.
 *  - `handleError`:    mapea `AppError` a su status y lo demás a 500.
 *
 * La capa de negocio (service) no conoce `req`/`res`.
 */
export abstract class BaseController {
  /**
   * Ejecuta el cuerpo de un handler y centraliza el manejo de errores.
   *
   * Sin este helper, cada uno de los 35 métodos de los controllers tendría su
   * propio `try/catch`. Aquí el `catch` vive una sola vez.
   */
  protected async run(res: Response, work: () => Promise<void>): Promise<void> {
    try {
      await work();
    } catch (error) {
      this.handleError(res, error);
    }
  }

  /**
   * Lee el `:id` de la URL y lo valida como entero positivo.
   *
   * Sin la validación, `GET /api/clientes/abc` llegaría al repository como
   * `Number("abc") === NaN` y devolvería un 404 engañoso en vez de un 400.
   */
  protected paramId(req: Request): number {
    const raw = req.params.id;
    const value = Array.isArray(raw) ? raw[0] : raw;

    if (!value || !/^\d+$/.test(value) || Number(value) < 1) {
      throw new AppError(400, "Invalid id: must be a positive integer");
    }
    return Number(value);
  }

  /** Mapea errores: `AppError` -> su status; cualquier otro -> 500. */
  protected handleError(res: Response, error: unknown): void {
    if (error instanceof AppError) {
      res.status(error.statusCode).json({ error: error.message });
      return;
    }
    res.status(500).json({ error: "Internal server error", detail: String(error) });
  }
}
EOF
4.0.3 src/shared/database/with-transaction.ts

Helper unit of work: ejecuta un bloque dentro de una transacción (commit/rollback). Se usa en ISS-08 para ventas; conviene crearlo ahora.

Nuevo archivo

: > src/shared/database/with-transaction.ts
cat >> src/shared/database/with-transaction.ts << 'EOF'
import { Transaction } from "sequelize";
import { sequelize } from "../../database/db";

/**
 * Ejecuta `work` dentro de una transacción (patrón *unit of work*).
 *
 * - Commit si `work` termina bien.
 * - Rollback si `work` lanza (y re-lanza el error).
 *
 * Lo usan los services que tocan varias tablas a la vez
 * (ej. crear una venta = `sales` + `product_sales` + stock de `products`).
 */
export async function withTransaction<T>(
  work: (transaction: Transaction) => Promise<T>
): Promise<T> {
  const transaction = await sequelize.transaction();
  let committed = false;

  try {
    const result = await work(transaction);
    await transaction.commit();
    committed = true;
    return result;
  } catch (error) {
    if (!committed) {
      await transaction.rollback().catch(() => undefined);
    }
    throw error;
  }
}
EOF

4.1 Modelo Client

Criterios

  • [ ] src/features/business/clients/client.model.ts
  • [ ] Enum active/inactive, default inactive; timestamps: true
npm install bcryptjs@^3.0.3
npm install -D @types/bcryptjs@^3.0.0

Archivo del feature

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

export interface ClientI {
  id?: number;
  name: string;
  address: string;
  phone: string;
  email: string;
  password: string;
  status: "active" | "inactive";
  createdAt?: Date;
  updatedAt?: Date;
}

export class Client extends Model {
  public id!: number;
  public name!: string;
  public address!: string;
  public phone!: string;
  public email!: string;
  public password!: string;
  public status!: "active" | "inactive";
  public readonly createdAt!: Date;
  public readonly updatedAt!: Date;
}

Client.init(
  {
    name: {
      type: DataTypes.STRING,
      allowNull: true,
    },
    address: {
      type: DataTypes.STRING,
      allowNull: true,
    },
    phone: {
      type: DataTypes.STRING,
      allowNull: true,
      validate: {
        notEmpty: { msg: "Phone cannot be empty" },
      },
    },
    email: {
      type: DataTypes.STRING,
      allowNull: true,
      unique: true,
      validate: {
        isEmail: { msg: "Email must be a valid email address" },
      },
    },
    password: {
      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: "Client",
    tableName: "clients",
    timestamps: true,
    hooks: {
      beforeCreate: async (client: Client) => {
        if (client.password) {
          const salt = await bcrypt.genSalt(10);
          client.password = await bcrypt.hash(client.password, salt);
        }
      },
      beforeUpdate: async (client: Client) => {
        if (client.changed("password") && client.password) {
          const salt = await bcrypt.genSalt(10);
          client.password = await bcrypt.hash(client.password, salt);
        }
      },
      beforeBulkCreate: async (clients: Client[]) => {
        for (const client of clients) {
          if (client.password) {
            const salt = await bcrypt.genSalt(10);
            client.password = await bcrypt.hash(client.password, salt);
          }
        }
      },
    },
  }
);
EOF

4.2 DTO + esqueletos repository / service / controller / routes + carpeta HTTP

Criterios

  • [ ] Carpeta dto/ creada (create/update/patch/response + index.ts)
  • [ ] clients.repository.ts, clients.service.ts, clients.controller.ts y clients.routes.ts existen (esqueletos)
  • [ ] Carpeta src/features/business/clients/http/ existe

El DTO es el contrato del feature y se define completo desde el inicio. Las cuatro capas se completan en ISS-03-B…E en el orden getAll → getOne → create → update → delete. Observe cómo cada capa delega en la siguiente.

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

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

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

dto/create-client.dto.ts

: > src/features/business/clients/dto/create-client.dto.ts
cat >> src/features/business/clients/dto/create-client.dto.ts << 'EOF'
/** Datos de entrada de `POST /api/clientes`. */
export interface CreateClientDto {
  name: string;
  address: string;
  phone: string;
  email: string;
  password: string;
  /** Opcional: por defecto `active`. Tras crearlo, el estado sólo cambia con el borrado lógico. */
  status?: "active" | "inactive";
}
EOF

dto/update-client.dto.ts

: > src/features/business/clients/dto/update-client.dto.ts
cat >> src/features/business/clients/dto/update-client.dto.ts << 'EOF'
/**
 * Datos de entrada de `PUT /api/clientes/:id` (reemplazo completo).
 *
 * `status` **no** está aquí a propósito: el estado sólo cambia con el borrado
 * lógico (`DELETE /api/clientes/:id/deactivate`), que es una regla de negocio y
 * no un campo editable. Así se evita desactivar un registro por PUT/PATCH
 * saltándose el resto de reglas del caso de uso.
 */
export interface UpdateClientDto {
  name: string;
  address: string;
  phone: string;
  email: string;
  /** Si no se envía, el service conserva el hash actual. */
  password?: string;
}
EOF

dto/patch-client.dto.ts

: > src/features/business/clients/dto/patch-client.dto.ts
cat >> src/features/business/clients/dto/patch-client.dto.ts << 'EOF'
import { UpdateClientDto } from "./update-client.dto";

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

dto/client-response.dto.ts

: > src/features/business/clients/dto/client-response.dto.ts
cat >> src/features/business/clients/dto/client-response.dto.ts << 'EOF'
import { Client, ClientI } from "../client.model";

/**
 * Respuesta HTTP de un cliente. Lo usan `GET /api/clientes`,
 * `GET /api/clientes/: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 y
 * `password` nunca sale. La proyección es explícita (`Omit`) porque hay algo que
 * ocultar; en las entidades que no tienen campos internos el DTO coincide con el
 * modelo y basta con documentarlo.
 */
export type ClientResponseDto = Omit<ClientI, "password">;

/** Mapper modelo -> DTO de respuesta (objeto plano; elimina `password`). */
export function toClientResponse(client: Client): ClientResponseDto {
  const { password, ...safe } = client.toJSON() as ClientI & { password?: string };
  return safe;
}
EOF

dto/index.ts

: > src/features/business/clients/dto/index.ts
cat >> src/features/business/clients/dto/index.ts << 'EOF'
export * from "./create-client.dto";
export * from "./update-client.dto";
export * from "./patch-client.dto";
export * from "./client-response.dto";
EOF

Repository (esqueleto)

: > src/features/business/clients/clients.repository.ts
cat >> src/features/business/clients/clients.repository.ts << 'EOF'
import { Client } from "./client.model";

/**
 * Capa Repository del feature Clients.
 * Única responsable de hablar con Sequelize (el modelo `Client`).
 */
export class ClientsRepository {
  // ================== READ ==================
  // (rellenar en ISS-03-B) findAllActive, findById

  // ================== CREATE ==================
  // (rellenar en ISS-03-C) create

  // ================== UPDATE ==================
  // (rellenar en ISS-03-D) update

  // ================== DELETE ==================
  // (rellenar en ISS-03-E) delete
}
EOF

Service (esqueleto)

: > src/features/business/clients/clients.service.ts
cat >> src/features/business/clients/clients.service.ts << 'EOF'
import { ClientsRepository } from "./clients.repository";

/**
 * Capa Service del feature Clients.
 * Reglas de negocio; no conoce req/res ni Sequelize (delega en el repository).
 */
export class ClientsService {
  public constructor(
    private readonly repository: ClientsRepository = new ClientsRepository()
  ) {}

  // ================== READ ==================
  // (rellenar en ISS-03-B) getAll, getOne

  // ================== CREATE ==================
  // (rellenar en ISS-03-C) create

  // ================== UPDATE ==================
  // (rellenar en ISS-03-D) updatePut, updatePatch

  // ================== DELETE ==================
  // (rellenar en ISS-03-E) deletePhysical, deleteLogical
}
EOF

Controller (esqueleto)

: > src/features/business/clients/clients.controller.ts
cat >> src/features/business/clients/clients.controller.ts << 'EOF'
import { Request, Response } from "express";
import { BaseController } from "../../../shared/http/base-controller";
import { ClientsService } from "./clients.service";

/**
 * Capa Controller del feature Clients.
 * Solo HTTP: lee req, llama al service y arma res.
 */
export class ClientsController extends BaseController {
  public constructor(
    private readonly service: ClientsService = new ClientsService()
  ) {
    super();
  }

  // ================== READ ==================
  // (rellenar en ISS-03-B) getAll, getOne

  // ================== CREATE ==================
  // (rellenar en ISS-03-C) create

  // ================== UPDATE ==================
  // (rellenar en ISS-03-D) updatePut, updatePatch

  // ================== DELETE ==================
  // (rellenar en ISS-03-E) deletePhysical, deleteLogical
}
EOF

Routes (esqueleto)

: > src/features/business/clients/clients.routes.ts
cat >> src/features/business/clients/clients.routes.ts << 'EOF'
import { Application } from "express";
import { ClientsController } from "./clients.controller";

export class ClientsRoutes {
  public clientsController: ClientsController = new ClientsController();

  public routes(app: Application): void {
    // ================== RUTAS SIN AUTENTICACIÓN / SIN MIDDLEWARE JWT ==================
    // (rellenar en ISS-03-B…E)
  }
}
EOF

El CRUD se completa en ISS-03-B…E. Aquí se reserva también la carpeta http/ para archivos .http (REST Client) con leyenda SIN AUTH.


4.3 Agregador Routes + cableado en Config

Criterios

  • [ ] src/routes/index.ts con clientsRoutes
  • [ ] config importa modelo + dbConnection + routes
: > src/routes/index.ts
cat >> src/routes/index.ts << 'EOF'
import { ClientsRoutes } from "../features/business/clients/clients.routes";

export class Routes {
  public clientsRoutes: ClientsRoutes = new ClientsRoutes();
}
EOF

PARCHE — src/config/index.ts ya existe (ISS-01).

  1. Debajo de var cors = require("cors"); añadir:
import { sequelize, getDatabaseInfo, testConnection } from "../database/db";
import "../features/business/clients/client.model";
import { Routes } from "../routes/index";
  1. Dentro de export class App, debajo de public app: Application; añadir:
  public routePrv: Routes = new Routes();
  1. Dentro de routes(), reemplazar el comentario // ISS-03 §4.3 por:
    this.routePrv.clientsRoutes.routes(this.app);
  1. Dentro de dbConnection(), reemplazar el comentario // ISS-02 / ISS-03 por:
    try {
      // Mostrar información de la base de datos seleccionada
      const dbInfo = getDatabaseInfo();
      console.log(`🔗 Intentando conectar a: ${dbInfo.engine.toUpperCase()}`);

      // Probar la conexión
      const isConnected = await testConnection();

      if (!isConnected) {
        throw new Error(`No se pudo conectar a la base de datos ${dbInfo.engine.toUpperCase()}`);
      }

      // alter: true actualiza columnas faltantes (ej. createdAt/updatedAt tras timestamps: true).
      // force: false no recrea tablas; no borra datos. En producción preferir migraciones.
      await sequelize.sync({ force: false, alter: true });
      console.log(`📦 Base de datos sincronizada exitosamente`);
    } catch (error) {
      console.error("❌ Error al conectar con la base de datos:", error);
      process.exit(1); // Terminar la aplicación si no se puede conectar
    }

Importante (lab): si la tabla clients se creó antes con timestamps: false, sync({ force: false }) no añade createdAt/updatedAt. Por eso se usa alter: true.

Orden de arranque (importante): sync({ alter: true }) emite ALTER TABLE y DROP/ADD FOREIGN KEY, que toman metadata locks. Si el puerto ya está escuchando mientras el sync corre, esas sentencias DDL compiten con las peticiones en curso y aparecen deadlocks y errores de FK intermitentes. Por eso listen() hace await this.dbConnection() antes de app.listen(...): primero la BD, después HTTP.

Verificación ISS-03-A

test -d src/features/business/clients/http && echo HTTP_FOLDER_OK

Cierre del ISS

npm run dev

Sync OK y tabla clients (con createdAt / updatedAt). Detenerlo con Ctrl+C antes de continuar.


ISS-03-B — Feature Client — GetAll y GetOne

Objetivo: listar activos y obtener uno por id. Es el primer paso del feature: getAll, getOne, luego create, update y delete.
Bloqueado por: ISS-03-A.

Criterios de aceptación (ISS-03-B)

  • [ ] Repository: findAllActive (solo status: 'active') y, debajo, findById
  • [ ] Service: getAll y getOne (404 si no existe o está inactive) + mapper toClientResponse (sin password)
  • [ ] Service: helper privado findOrFail(id, onlyActive = true): la política de borrado lógico se decide una sola vez
  • [ ] Controller: getAll y, debajo, getOne (envueltos en run(), que ya traduce los errores)
  • [ ] Rutas GET /api/clientes y GET /api/clientes/:id — sin auth
  • [ ] http/clients.get.http con leyenda SIN AUTH

Repository — PARCHE clients.repository.ts (ya existe)

Arriba, junto al import del modelo, añadir el import de Transaction:

import { Transaction } from "sequelize";

Debajo de // ================== READ ================== (y encima de // ================== CREATE ==================), añadir primero findAllActive y después findById:

  /** Todos los clientes activos. */
  public async findAllActive(): Promise<Client[]> {
    return Client.findAll({ where: { status: "active" } });
  }

  /** Un cliente por PK (o `null`). Acepta transacción para flujos de ventas. */
  public async findById(id: number, transaction?: Transaction): Promise<Client | null> {
    return Client.findByPk(id, { transaction });
  }

Service — PARCHE clients.service.ts (ya existe)

Arriba, junto al import del repository, añadir el import del DTO, del modelo y de AppError:

import { ClientResponseDto, toClientResponse } from "./dto";
import { Client } from "./client.model";
import { AppError } from "../../../shared/errors/app-error";

Debajo de // ================== READ ================== (y encima de // ================== CREATE ==================), añadir getAll y getOne:

  public async getAll(): Promise<ClientResponseDto[]> {
    const clients = await this.repository.findAllActive();
    return clients.map((client) => toClientResponse(client));
  }

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

El saneamiento (ocultar password) ya no vive en el service: lo hace el mapper toClientResponse declarado en dto/client-response.dto.ts.

El service no arma respuestas HTTP: si no existe lanza AppError(404, …) y el BaseController lo traduce.

Al final de la clase, debajo de // ================== HELPERS ==================, añadir findOrFail. Es el único sitio donde se decide qué es «no existe»:

  private async findOrFail(id: number, onlyActive = true): Promise<Client> {
    const client = await this.repository.findById(id);
    if (!client || (onlyActive && client.status !== "active")) {
      throw new AppError(404, "Client not found");
    }
    return client;
  }

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. deletePhysical lo pasa en false para poder purgar también registros ya desactivados.

Con este helper, getOne, updatePut, updatePatch y deleteLogical quedan consistentes sin repetir la comprobación en cada método.

Controller — PARCHE clients.controller.ts (ya existe)

Debajo de // ================== READ ================== (y encima de // ================== CREATE ==================), añadir primero getAll y después getOne:

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

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

Rutas — PARCHE clients.routes.ts (ya existe)

Debajo de // ================== RUTAS SIN AUTENTICACIÓN / SIN MIDDLEWARE JWT ==================, añadir primero getAll y después getOne:

    // getAll
    app
      .route("/api/clientes")
      .get(this.clientsController.getAll.bind(this.clientsController));

    // getOne
    app
      .route("/api/clientes/:id")
      .get(this.clientsController.getOne.bind(this.clientsController));

HTTP — archivo nuevo

: > src/features/business/clients/http/clients.get.http
cat >> src/features/business/clients/http/clients.get.http << 'EOF'
### Feature Client — 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!"
}

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

{
  "identifier": "seller",
  "password": "Seller123!"
}

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

### GET ALL — recurso `GET /api/clientes` (ADMIN y SELLER lo tienen concedido)
# @name getAllClients
GET {{baseUrl}}/api/clientes
Authorization: Bearer {{token}}

### GET ONE — recurso `GET /api/clientes/:id`
# @name getOneClient
GET {{baseUrl}}/api/clientes/{{id}}
Authorization: Bearer {{token}}

### 401 — sin token (modalidad JWT no cumplida)
GET {{baseUrl}}/api/clientes

### 200 — SELLER sí tiene esta lectura concedida
GET {{baseUrl}}/api/clientes
Authorization: Bearer {{sellerToken}}
EOF

Verificación

Ver Verificación al final de ISS-03-E (más abajo, en este mismo archivo).


ISS-03-C — Feature Client — Crear cliente

Objetivo: create con status por defecto active y hash de password (hook del modelo).
Bloqueado por: ISS-03-B.

Criterios de aceptación (ISS-03-C)

  • [ ] Repository: create
  • [ ] Service: create (default de status) y respuesta sin password
  • [ ] Controller: create con 201
  • [ ] Ruta POST /api/clientes — sin auth
  • [ ] http/clients.create.http con leyenda SIN AUTH

Repository — PARCHE clients.repository.ts (ya existe)

Arriba, ampliar el import de sequelize para incluir CreationAttributes:

import { CreationAttributes, Transaction } from "sequelize";

Debajo de // ================== CREATE ==================, añadir:

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

Service — PARCHE clients.service.ts (ya existe)

Debajo de // ================== CREATE ==================, añadir:

  public async create(body: CreateClientDto): Promise<ClientResponseDto> {
    const client = await this.repository.create({
      name: body.name,
      address: body.address,
      phone: body.phone,
      email: body.email,
      password: body.password,
      status: body.status ?? "active",
    });
    return toClientResponse(client);
  }

La regla «si no viene status, usar active» vive en el service. El hashing del password es responsabilidad del modelo (beforeCreate). El CreateClientDto documenta qué acepta el POST.

Controller — PARCHE clients.controller.ts (ya existe)

Debajo de // ================== CREATE ==================, añadir:

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

En el controller, arriba, añade el import de los DTOs: import { CreateClientDto, PatchClientDto, UpdateClientDto } from "./dto";

Rutas — PARCHE clients.routes.ts (ya existe)

Debajo del bloque de getOne, añadir:

    // create
    app
      .route("/api/clientes")
      .post(this.clientsController.create.bind(this.clientsController));

HTTP — archivo nuevo

: > src/features/business/clients/http/clients.create.http
cat >> src/features/business/clients/http/clients.create.http << 'EOF'
### Feature Client — CREATE
### Modalidad JWT + RBAC. Recurso `POST /api/clientes`: solo lo concede ADMIN (SELLER no).
@baseUrl = http://localhost:4000

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

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

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

{
  "identifier": "seller",
  "password": "Seller123!"
}

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

### CREATE — admin -> 201
# @name createClient
POST {{baseUrl}}/api/clientes
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "name": "Ana Pérez",
  "address": "Calle 10 #20-30",
  "phone": "3001234567",
  "email": "ana.perez@example.com",
  "password": "Password123!",
  "status": "active"
}

### 403 — el SELLER NO tiene concedido `POST /api/clientes` (deny by default)
POST {{baseUrl}}/api/clientes
Authorization: Bearer {{sellerToken}}
Content-Type: application/json

{
  "name": "Prueba RBAC",
  "phone": "3000000000",
  "email": "rbac.demo@example.com",
  "password": "Password123!"
}

### 401 — sin token
POST {{baseUrl}}/api/clientes
Content-Type: application/json

{
  "name": "Sin token",
  "phone": "3000000001",
  "email": "sin.token@example.com",
  "password": "Password123!"
}
EOF

Verificación

Ver Verificación al final de ISS-03-E (más abajo, en este mismo archivo).


ISS-03-D — Feature Client — Update (PUT) y Update (PATCH)

Objetivo: updatePut (reemplazo completo) y updatePatch (parcial).
Bloqueado por: ISS-03-C.

Criterios de aceptación (ISS-03-D)

  • [ ] Repository: update
  • [ ] Service: updatePut (conserva password si no viene) y updatePatch
  • [ ] Service: status no se edita por PUT/PATCH: sólo cambia con el borrado lógico
  • [ ] Controller: updatePut y updatePatch
  • [ ] Rutas PUT /api/clientes/:id y PATCH /api/clientes/:id — sin auth
  • [ ] http/clients.update.http con leyenda SIN AUTH

Repository — PARCHE clients.repository.ts (ya existe)

Debajo de // ================== UPDATE ==================, añadir:

  /** Persiste cambios sobre una instancia existente. */
  public async update(client: Client, data: Partial<ClientI>): Promise<Client> {
    return client.update(data);
  }

El repository recibe la instancia ya cargada (client) y solo la persiste: no sabe si es un PUT, un PATCH o un borrado lógico.

Service — PARCHE clients.service.ts (ya existe)

Debajo de // ================== UPDATE ==================, añadir updatePut y después updatePatch:

  public async updatePut(id: number, body: UpdateClientDto): Promise<ClientResponseDto> {
    const client = await this.findOrFail(id);

    await this.repository.update(client, {
      name: body.name,
      address: body.address,
      phone: body.phone,
      email: body.email,
      password: body.password ?? client.password,
    });
    return toClientResponse(client);
  }

  public async updatePatch(id: number, body: PatchClientDto): Promise<ClientResponseDto> {
    const client = await this.findOrFail(id);

    await this.repository.update(client, body);
    return toClientResponse(client);
  }

Controller — PARCHE clients.controller.ts (ya existe)

Debajo de // ================== UPDATE ==================, añadir updatePut y después updatePatch:

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

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

Rutas — PARCHE clients.routes.ts (ya existe)

Debajo del bloque de create, añadir:

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

HTTP — archivo nuevo

: > src/features/business/clients/http/clients.update.http
cat >> src/features/business/clients/http/clients.update.http << 'EOF'
### Feature Client — UPDATE (PUT) / UPDATE (PATCH)
### Modalidad JWT + RBAC. `status` no se envía: el estado solo cambia con
### PATCH {{baseUrl}}/api/clientes/{{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 updateClientPut
PUT {{baseUrl}}/api/clientes/{{id}}
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "name": "Ana Pérez Actualizada",
  "address": "Carrera 15 #40-10",
  "phone": "3009876543",
  "email": "ana.perez@example.com",
  "password": "Password123!"
}

###

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

{
  "phone": "3011112233",
  "address": "Nueva dirección parcial"
}
EOF

Verificación

Ver Verificación al final de ISS-03-E (más abajo, en este mismo archivo).


ISS-03-E — Feature Client — Eliminar (físico y lógico)

Objetivo: deletePhysical (DELETE) y deleteLogical (status = inactive).
Bloqueado por: ISS-03-D.

Criterios de aceptación (ISS-03-E)

  • [ ] Repository: delete
  • [ ] Service: deletePhysical y deleteLogical
  • [ ] Controller: deletePhysical y deleteLogical
  • [ ] Rutas DELETE /api/clientes/:id y PATCH /api/clientes/:id/deactivate — sin auth
  • [ ] http/clients.delete.http con leyenda SIN AUTH

Repository — PARCHE clients.repository.ts (ya existe)

Debajo de // ================== DELETE ==================, añadir:

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

Service — PARCHE clients.service.ts (ya existe)

Debajo de // ================== DELETE ==================, añadir deletePhysical y después deleteLogical:

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

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

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

El borrado lógico no es un DELETE: es un UPDATE ... status = inactive reutilizando el mismo método update del repository.

Controller — PARCHE clients.controller.ts (ya existe)

Debajo de // ================== DELETE ==================, añadir deletePhysical y después deleteLogical:

  /** 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: "Client permanently deleted", id });
    });
  }

  /** Eliminación lógica -> `status = inactive`. */
  public async deleteLogical(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const client = await this.service.deleteLogical(this.paramId(req));
      res.status(200).json({ message: "Client deactivated (logical delete)", client });
    });
  }

Rutas — PARCHE clients.routes.ts (ya existe)

Debajo del bloque de update, añadir:

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

    // delete lógico
    app
      .route("/api/clientes/:id/deactivate")
      .patch(this.clientsController.deleteLogical.bind(this.clientsController));

HTTP — archivo nuevo

: > src/features/business/clients/http/clients.delete.http
cat >> src/features/business/clients/http/clients.delete.http << 'EOF'
### Feature Client — 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 deleteClientPhysical
DELETE {{baseUrl}}/api/clientes/{{id}}
Authorization: Bearer {{token}}

###

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

Estado final Client (CRUD completo) — archivos consolidados

Estos son los archivos definitivos del feature (equivalentes a aplicar todos los PARCHE anteriores):

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

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

dto/create-client.dto.ts

: > src/features/business/clients/dto/create-client.dto.ts
cat >> src/features/business/clients/dto/create-client.dto.ts << 'EOF'
/** Datos de entrada de `POST /api/clientes`. */
export interface CreateClientDto {
  name: string;
  address: string;
  phone: string;
  email: string;
  password: string;
  /** Opcional: por defecto `active`. Tras crearlo, el estado sólo cambia con el borrado lógico. */
  status?: "active" | "inactive";
}
EOF

dto/update-client.dto.ts

: > src/features/business/clients/dto/update-client.dto.ts
cat >> src/features/business/clients/dto/update-client.dto.ts << 'EOF'
/**
 * Datos de entrada de `PUT /api/clientes/:id` (reemplazo completo).
 *
 * `status` **no** está aquí a propósito: el estado sólo cambia con el borrado
 * lógico (`DELETE /api/clientes/:id/deactivate`), que es una regla de negocio y
 * no un campo editable. Así se evita desactivar un registro por PUT/PATCH
 * saltándose el resto de reglas del caso de uso.
 */
export interface UpdateClientDto {
  name: string;
  address: string;
  phone: string;
  email: string;
  /** Si no se envía, el service conserva el hash actual. */
  password?: string;
}
EOF

dto/patch-client.dto.ts

: > src/features/business/clients/dto/patch-client.dto.ts
cat >> src/features/business/clients/dto/patch-client.dto.ts << 'EOF'
import { UpdateClientDto } from "./update-client.dto";

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

dto/client-response.dto.ts

: > src/features/business/clients/dto/client-response.dto.ts
cat >> src/features/business/clients/dto/client-response.dto.ts << 'EOF'
import { Client, ClientI } from "../client.model";

/**
 * Respuesta HTTP de un cliente. Lo usan `GET /api/clientes`,
 * `GET /api/clientes/: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 y
 * `password` nunca sale. La proyección es explícita (`Omit`) porque hay algo que
 * ocultar; en las entidades que no tienen campos internos el DTO coincide con el
 * modelo y basta con documentarlo.
 */
export type ClientResponseDto = Omit<ClientI, "password">;

/** Mapper modelo -> DTO de respuesta (objeto plano; elimina `password`). */
export function toClientResponse(client: Client): ClientResponseDto {
  const { password, ...safe } = client.toJSON() as ClientI & { password?: string };
  return safe;
}
EOF

dto/index.ts

: > src/features/business/clients/dto/index.ts
cat >> src/features/business/clients/dto/index.ts << 'EOF'
export * from "./create-client.dto";
export * from "./update-client.dto";
export * from "./patch-client.dto";
export * from "./client-response.dto";
EOF
: > src/features/business/clients/clients.repository.ts
cat >> src/features/business/clients/clients.repository.ts << 'EOF'
import { CreationAttributes, Transaction } from "sequelize";
import { Client, ClientI } from "./client.model";

/**
 * Capa Repository del feature Clients.
 *
 * Única responsable de hablar con Sequelize (el modelo `Client`).
 * No contiene reglas de negocio ni conoce `req`/`res`.
 */
export class ClientsRepository {
  /** Todos los clientes activos. */
  public async findAllActive(): Promise<Client[]> {
    return Client.findAll({ where: { status: "active" } });
  }

  /** Un cliente por PK (o `null`). Acepta transacción para flujos de ventas. */
  public async findById(id: number, transaction?: Transaction): Promise<Client | null> {
    return Client.findByPk(id, { transaction });
  }

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

  /** Persiste cambios sobre una instancia existente. */
  public async update(client: Client, data: Partial<ClientI>): Promise<Client> {
    return client.update(data);
  }

  /** Elimina físicamente una instancia. */
  public async delete(client: Client): Promise<void> {
    await client.destroy();
  }
}
EOF
: > src/features/business/clients/clients.service.ts
cat >> src/features/business/clients/clients.service.ts << 'EOF'
import {
  ClientResponseDto,
  CreateClientDto,
  PatchClientDto,
  UpdateClientDto,
  toClientResponse,
} from "./dto";
import { ClientsRepository } from "./clients.repository";
import { Client } from "./client.model";
import { AppError } from "../../../shared/errors/app-error";

/**
 * Capa Service del feature Clients.
 *
 * Reglas de negocio: default de `status`, política de borrado lógico, borrado
 * físico y saneamiento de la respuesta (oculta `password`).
 *
 * No conoce `req`/`res` ni escribe Sequelize directamente: delega en el
 * repository y devuelve **DTOs** (carpeta `dto/`), nunca instancias del modelo.
 */
export class ClientsService {
  public constructor(
    private readonly repository: ClientsRepository = new ClientsRepository()
  ) {}

  // ================== READ ==================
  public async getAll(): Promise<ClientResponseDto[]> {
    const clients = await this.repository.findAllActive();
    return clients.map((client) => toClientResponse(client));
  }

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

  // ================== CREATE ==================
  public async create(body: CreateClientDto): Promise<ClientResponseDto> {
    // Se copian los campos uno a uno a propósito: sólo lo que declara el DTO
    // llega al modelo (evita *mass assignment* de campos no permitidos).
    const client = await this.repository.create({
      name: body.name,
      address: body.address,
      phone: body.phone,
      email: body.email,
      password: body.password,
      status: body.status ?? "active",
    });
    return toClientResponse(client);
  }

  // ================== UPDATE ==================
  public async updatePut(id: number, body: UpdateClientDto): Promise<ClientResponseDto> {
    const client = await this.findOrFail(id);

    await this.repository.update(client, {
      name: body.name,
      address: body.address,
      phone: body.phone,
      email: body.email,
      password: body.password ?? client.password,
    });
    return toClientResponse(client);
  }

  public async updatePatch(id: number, body: PatchClientDto): Promise<ClientResponseDto> {
    const client = await this.findOrFail(id);

    await this.repository.update(client, body);
    return toClientResponse(client);
  }

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

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

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

  // ================== 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`. Es el único punto donde se decide, así que `getOne`, `updatePut`,
   * `updatePatch` y `deleteLogical` quedan automáticamente consistentes.
   *
   * `deletePhysical` lo desactiva (`onlyActive: false`) para poder purgar
   * también los registros que ya tienen borrado lógico.
   */
  private async findOrFail(id: number, onlyActive = true): Promise<Client> {
    const client = await this.repository.findById(id);
    if (!client || (onlyActive && client.status !== "active")) {
      throw new AppError(404, "Client not found");
    }
    return client;
  }
}
EOF
: > src/features/business/clients/clients.controller.ts
cat >> src/features/business/clients/clients.controller.ts << 'EOF'
import { Request, Response } from "express";
import { BaseController } from "../../../shared/http/base-controller";
import { CreateClientDto, PatchClientDto, UpdateClientDto } from "./dto";
import { ClientsService } from "./clients.service";

/**
 * Capa Controller del feature Clients.
 *
 * Traduce HTTP <-> negocio: lee `req`, llama al service y arma la respuesta.
 * No contiene reglas de negocio ni consultas a Sequelize.
 *
 * Cada método delega el manejo de errores en `run()` (ver `BaseController`):
 * así el `try/catch` no se repite en los 7 métodos.
 */
export class ClientsController extends BaseController {
  public constructor(
    private readonly service: ClientsService = new ClientsService()
  ) {
    super();
  }

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

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

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

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

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

  // ================== 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: "Client permanently deleted", id });
    });
  }

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

export class ClientsRoutes {
  public clientsController: ClientsController = new ClientsController();

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

    // getAll
    app
      .route("/api/clientes")
      .get(this.clientsController.getAll.bind(this.clientsController));

    // getOne
    app
      .route("/api/clientes/:id")
      .get(this.clientsController.getOne.bind(this.clientsController));

    // create
    app
      .route("/api/clientes")
      .post(this.clientsController.create.bind(this.clientsController));

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

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

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

Verificación

npx tsc --noEmit                 # 0 errores de tipos
npm run dev                      # sync OK

# getAll / getOne
curl -s http://localhost:4000/api/clientes | head -c 200
curl -s http://localhost:4000/api/clientes/1 | head -c 200

# create
curl -s -X POST http://localhost:4000/api/clientes \
  -H 'Content-Type: application/json' \
  -d '{"name":"Ana","address":"Calle 1","phone":"3001234567","email":"ana@example.com","password":"Password123!"}'

# update PUT / PATCH
curl -s -X PUT http://localhost:4000/api/clientes/1 \
  -H 'Content-Type: application/json' \
  -d '{"name":"Ana 2","address":"Calle 2","phone":"3007654321","email":"ana@example.com"}'
curl -s -X PATCH http://localhost:4000/api/clientes/1 \
  -H 'Content-Type: application/json' -d '{"address":"Calle 3"}'

# delete lógico y físico
curl -s -X PATCH http://localhost:4000/api/clientes/1/deactivate
curl -s -X DELETE http://localhost:4000/api/clientes/1

# contrato de errores (BaseController + findOrFail)
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:4000/api/clientes/abc   # 400 (:id no entero)
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:4000/api/clientes/0     # 400 (no positivo)
# con el id 1 ya desactivado, volver a leerlo debe dar 404 (el borrado lógico se filtra)
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:4000/api/clientes/1
# PUT con "status": el campo no está en el DTO, así que se ignora (sigue 'active')
curl -s -X PUT http://localhost:4000/api/clientes/2 \
  -H 'Content-Type: application/json' \
  -d '{"name":"Ana 2","address":"Calle 2","phone":"3007654321","email":"ana@example.com","status":"inactive"}'
  • [ ] getAll devuelve solo status = active y nunca el campo password
  • [ ] getOne con id inexistente o con status = inactive devuelve 404 (la misma regla que getAll)
  • [ ] Un :id no numérico o 0 devuelve 400 (lo valida paramId en BaseController, no el service)
  • [ ] PUT conserva password si no se envía; PATCH actualiza solo lo enviado
  • [ ] status no se puede cambiar con PUT/PATCH: si se envía, se ignora; sólo cambia con deactivate
  • [ ] deactivate deja status = inactive; DELETE borra la fila
  • [ ] Las respuestas son objetos planos (DTOs de respuesta), sin campos internos de Sequelize

Cierre del ISS

Client queda completo por capas: clients.routes.ts → clients.controller.ts → clients.service.ts → clients.repository.ts → client.model.ts.

Diagnóstico

Síntoma Causa probable Solución
Cannot find module 'bcryptjs' No se instaló la dependencia npm install bcryptjs@^3.0.3 y npm install -D @types/bcryptjs@^3.0.0
Property 'statusCode' does not exist on type 'Error' El catch no estrecha el tipo a AppError Usar if (error instanceof AppError); así lo hace BaseController.handleError
GET /api/clientes/abc devuelve 404 en vez de 400 No se usa paramId en el controller Leer el id con this.paramId(req) dentro del run
Un cliente inactive sigue apareciendo en getOne Se llamó a findOrFail con onlyActive = false Dejar el valor por defecto (true) salvo en deletePhysical
password aparece en las respuestas Se devolvió la instancia del modelo en vez del DTO Devolver siempre toClientResponse(client)
status cambia al enviarlo en un PUT Se incluyó status en el DTO de update status no va en Update<X>Dto/Patch<X>Dto: solo cambia con deactivate
create guarda status en inactive No se aplicó el default del service Usar status: body.status ?? "active"
La tabla clients no tiene createdAt/updatedAt Se creó antes con timestamps: false y el sync no altera Usar sequelize.sync({ force: false, alter: true })
Errores intermitentes de FK o deadlock al arrancar listen() empieza antes de terminar el sync Hacer await this.dbConnection() antes de app.listen(...)
TypeError: Cannot read properties of undefined (reading 'getAll') en una ruta Falta .bind(this.clientsController) Enlazar el handler con .bind(this.clientsController)
404 con una fila recién creada sin status El default del modelo es inactive (fail-safe) Enviar status: "active" o confiar en el default del service al crear vía API

Pregunta que responde: si algo falla al montar el CRUD, ¿por dónde empiezo a mirar?

Conexión con el resto del curso

  • Lo habilita: ISS-02 — Infraestructura de base de datos. De allí vienen sequelize, getDatabaseInfo, testConnection y el sync que este ISS extiende con alter: true.
  • Lo que este ISS habilita:
  • ISS-04 — Seeders con Faker: los seeders llenarán la tabla clients que aquí nace, reutilizando el modelo y sus hooks de bcrypt.
  • ISS-05 — Swagger / OpenAPI: documentará los endpoints /api/clientes que aquí se definen.
  • Piezas que se reutilizan en todos los features siguientes: AppError, BaseController (run, paramId, handleError), withTransaction, el patrón dto/ + mapper, el helper findOrFail, y la convención de nombres (carpeta plural, modelo singular).
  • Lo que cambia más adelante: a partir de ISS-09 llega la autenticación y, en ISS-13, las rutas de clients se protegerán con authenticate/authorize. Los .http del feature ya anticipan esa modalidad, pero en ISS-03 las rutas van sin auth.

Glosario

Término Significado en este ISS
Capa Responsabilidad separada del feature: controller (HTTP), service (negocio), repository (datos), model (entidad).
Recorrido obligatorio HTTP → Routes → Controller → Service → Repository → Model → Sequelize → BD; ninguna capa se salta a la siguiente.
DTO Data Transfer Object: forma de los datos que entran o salen por la API. No es una capa, es un contrato.
Mapper Función que convierte modelo → DTO de respuesta (toClientResponse).
Barrel Archivo index.ts que reexporta todo (export * from …) para un único punto de importación.
AppError Error de negocio con statusCode; lo lanzan los services y lo traduce el controller.
BaseController Clase base con run (try/catch único), paramId (valida :id) y handleError (mapea a HTTP).
findOrFail Helper del service que lanza 404 si el registro no existe o está inactivo.
Borrado lógico Desactivar un registro (status = inactive) sin borrar la fila; se hace con PATCH /:id/deactivate.
Borrado físico Eliminar la fila de la base de datos con DELETE /:id (client.destroy()).
fail-safe El defaultValue: "inactive" del modelo: una fila sin estado explícito no queda visible en la API.
mass assignment Copiar req.body entero al modelo; se evita copiando campo a campo lo que declara el DTO.
Hook Función del modelo que corre en un momento del ciclo (beforeCreate, beforeUpdate, beforeBulkCreate).
PK Primary Key; en clients es id, y se valida con paramId / se busca con findById.
unit of work Patrón de withTransaction: agrupar varias escrituras en una transacción con commit/rollback.
alter: true Opción de sync que añade/actualiza columnas sin recrear la tabla ni borrar datos.

Criterios de aceptación

Los del ISS, textuales:

ISS-03-A — Fundación

  • [ ] 4.0 Capa compartida src/shared/ creada (AppError, BaseController, withTransaction)
  • [ ] 4.1 Modelo client.model.ts con status + timestamps: true + bcrypt
  • [ ] 4.2 Carpeta dto/ con un archivo por operación (create/update/patch/response) + index.ts
  • [ ] 4.3 Esqueletos clients.repository.ts, clients.service.ts, clients.controller.ts y clients.routes.ts
  • [ ] 4.4 Carpeta features/business/clients/http/ creada
  • [ ] 4.5 routes/index.ts + config importan modelo, conectan BD y hacen sync
  • [ ] Con BD: npm run dev → conexión OK + sync OK + tabla clients

ISS-03-B — GetAll y GetOne

  • [ ] Repository: findAllActive (solo status: 'active') y, debajo, findById
  • [ ] Service: getAll y getOne (404 si no existe o está inactive) + mapper toClientResponse (sin password)
  • [ ] Service: helper privado findOrFail(id, onlyActive = true): la política de borrado lógico se decide una sola vez
  • [ ] Controller: getAll y, debajo, getOne (envueltos en run(), que ya traduce los errores)
  • [ ] Rutas GET /api/clientes y GET /api/clientes/:id — sin auth
  • [ ] http/clients.get.http con leyenda SIN AUTH

ISS-03-C — Crear cliente

  • [ ] Repository: create
  • [ ] Service: create (default de status) y respuesta sin password
  • [ ] Controller: create con 201
  • [ ] Ruta POST /api/clientes — sin auth
  • [ ] http/clients.create.http con leyenda SIN AUTH

ISS-03-D — Update (PUT) y Update (PATCH)

  • [ ] Repository: update
  • [ ] Service: updatePut (conserva password si no viene) y updatePatch
  • [ ] Service: status no se edita por PUT/PATCH: sólo cambia con el borrado lógico
  • [ ] Controller: updatePut y updatePatch
  • [ ] Rutas PUT /api/clientes/:id y PATCH /api/clientes/:id — sin auth
  • [ ] http/clients.update.http con leyenda SIN AUTH

ISS-03-E — Eliminar (físico y lógico)

  • [ ] Repository: delete
  • [ ] Service: deletePhysical y deleteLogical
  • [ ] Controller: deletePhysical y deleteLogical
  • [ ] Rutas DELETE /api/clientes/:id y PATCH /api/clientes/:id/deactivate — sin auth
  • [ ] http/clients.delete.http con leyenda SIN AUTH

Verificación final

  • [ ] getAll devuelve solo status = active y nunca el campo password
  • [ ] getOne con id inexistente o con status = inactive devuelve 404 (la misma regla que getAll)
  • [ ] Un :id no numérico o 0 devuelve 400 (lo valida paramId en BaseController, no el service)
  • [ ] PUT conserva password si no se envía; PATCH actualiza solo lo enviado
  • [ ] status no se puede cambiar con PUT/PATCH: si se envía, se ignora; sólo cambia con deactivate
  • [ ] deactivate deja status = inactive; DELETE borra la fila
  • [ ] Las respuestas son objetos planos (DTOs de respuesta), sin campos internos de Sequelize

Evaluación

Preguntas de comprensión

  1. ¿Por qué este ISS se llama «el que instaura las capas»? Porque antes de ISS-03 el proyecto solo tenía esqueleto HTTP (ISS-01) y conexión a base de datos (ISS-02). Aquí nace el recorrido completo de una petición —Routes → Controller → Service → Repository → Model → Sequelize → BD— dentro de un feature real (clients). Ese recorrido y sus reglas transversales se copian luego a todos los demás features.

  2. ¿Por qué el try/catch vive en BaseController y no en cada método del controller? Porque repetirlo en los ~35 métodos del proyecto es ruido y una fuente de inconsistencias. run centraliza el manejo del error y deja que cada handler exprese solo su camino feliz: leer, llamar al service, responder.

  3. ¿Por qué la validación del :id (400) no la hace el service? Porque el :id es un dato de la URL, no una regla de negocio. paramId es HTTP puro: si no es un entero ≥ 1, responde 400 antes de que el service vea nada. Si la validación estuviera en el service, GET /api/clientes/abc llegaría como NaN y produciría un 404 engañoso.

  4. ¿Por qué findOrFail es un helper y no una comprobación repetida? Porque define en un único lugar qué significa «no existe» (no encontrado o inactivo). Al tenerlo una sola vez, getOne, updatePut, updatePatch y deleteLogical quedan automáticamente consistentes con el borrado lógico.

  5. ¿Por qué status no está en UpdateClientDto ni en PatchClientDto? Porque el estado es una regla de negocio, no un campo editable. Si status viajara en el body, un PUT podría «resucitar» una fila inactive o desactivarla saltándose el caso de uso. El estado solo cambia con el endpoint de borrado lógico (PATCH /:id/deactivate).

  6. ¿Qué dos «defaults» conviven en este ISS y por qué son distintos? El del modelo es "inactive" (fail-safe: una fila insertada sin estado explícito no queda visible en la API) y el del service es "active" al crear vía POST. No se contradicen: la API decide explícitamente el estado al crear; la base de datos es conservadora por si algo se inserta por otra vía.

  7. ¿Por qué el borrado lógico reutiliza repository.update en vez de tener su propio método? Porque «desactivar» no es un DELETE: es un UPDATE ... status = inactive. El repository ya sabe persistir cambios sobre una instancia, así que no hace falta duplicar acceso a datos; la regla (qué significa desactivar) vive en el service.

  8. ¿Por qué withTransaction se crea en ISS-03 si no se usa hasta ISS-08? Porque src/shared/ es la infraestructura transversal que los features reutilizan. Tenerla lista desde la fundación evita añadirla a mitad de un ISS de ventas; en ISS-03 se documenta y se deja preparada, pero el CRUD de clients no la necesita (toca una sola tabla).

Ejercicios

Ejercicio 1 — Traza el recorrido de un POST. Explica, capa por capa, qué ocurre desde que llega POST /api/clientes con un status omitido hasta que se inserta la fila en clients. ¿En qué capa se aplica el active? ¿En cuál se hashea el password?

Respuesta razonada 1. `clients.routes.ts` recibe la petición y la envía a `ClientsController.create`. 2. El controller la envuelve en `this.run(res, …)` y castea `req.body as CreateClientDto`. 3. `ClientsService.create` copia campo a campo lo que declara el DTO y aplica `status: body.status ?? "active"` → **aquí se aplica el default `active`**. 4. `ClientsRepository.create` llama a `Client.create(data)`. 5. El **modelo** ejecuta el hook `beforeCreate`, que hashea `password` con bcrypt → **aquí se hashea**. También aplica el `defaultValue` del modelo si `status` faltara. 6. Sequelize emite el `INSERT` y la fila queda en `clients`. 7. De vuelta, el service proyecta con `toClientResponse` (sin `password`) y el controller responde `201` con `{ client }`.

Ejercicio 2 — El 404 del borrado lógico. Desactiva un cliente con PATCH /api/clientes/1/deactivate y luego intenta leerlo con GET /api/clientes/1. Explica por qué da 404 y qué pasaría si deleteLogical llamara a findOrFail(1, false).

Respuesta razonada
curl -s -X PATCH http://localhost:4000/api/clientes/1/deactivate
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:4000/api/clientes/1   # 404
Da 404 porque `getOne` usa `findOrFail(id)` con `onlyActive = true`: un registro `inactive` deja de ser visible para la API, igual que en `getAll`. Es la política de borrado lógico decidida en un único punto. Si `deleteLogical` usara `findOrFail(1, false)`, permitiría **volver a desactivar** un registro ya inactivo (idempotencia), pero ese no es el comportamiento pedido: para desactivar, el registro debe seguir siendo visible. `onlyActive: false` está reservado a `deletePhysical`, que sí debe poder purgar registros ya desactivados.

Ejercicio 3 — Ignorar status en un PUT. Envía un PUT /api/clientes/2 con "status": "inactive" en el body y comprueba con un GET que el cliente sigue activo. Explica por qué.

Respuesta razonada
curl -s -X PUT http://localhost:4000/api/clientes/2 \
  -H 'Content-Type: application/json' \
  -d '{"name":"Ana 2","address":"Calle 2","phone":"3007654321","email":"ana@example.com","status":"inactive"}'
curl -s http://localhost:4000/api/clientes/2
El `status` sigue `active` porque `UpdateClientDto` **no declara** ese campo y el service construye el objeto de actualización campo a campo (no copia `req.body` entero). Aunque el JSON lo traiga, nunca llega a `repository.update`. Es la defensa contra *mass assignment* y la garantía de que el estado solo cambia con `deactivate`.

GATE

Comandos exactos de cierre del ISS:

npx tsc --noEmit                 # 0 errores de tipos
npm run dev                      # sync OK

# getAll / getOne
curl -s http://localhost:4000/api/clientes | head -c 200
curl -s http://localhost:4000/api/clientes/1 | head -c 200

# create
curl -s -X POST http://localhost:4000/api/clientes \
  -H 'Content-Type: application/json' \
  -d '{"name":"Ana","address":"Calle 1","phone":"3001234567","email":"ana@example.com","password":"Password123!"}'

# update PUT / PATCH
curl -s -X PUT http://localhost:4000/api/clientes/1 \
  -H 'Content-Type: application/json' \
  -d '{"name":"Ana 2","address":"Calle 2","phone":"3007654321","email":"ana@example.com"}'
curl -s -X PATCH http://localhost:4000/api/clientes/1 \
  -H 'Content-Type: application/json' -d '{"address":"Calle 3"}'

# delete lógico y físico
curl -s -X PATCH http://localhost:4000/api/clientes/1/deactivate
curl -s -X DELETE http://localhost:4000/api/clientes/1

# contrato de errores (BaseController + findOrFail)
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:4000/api/clientes/abc   # 400 (:id no entero)
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:4000/api/clientes/0     # 400 (no positivo)
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:4000/api/clientes/1     # 404 si está desactivado

Resultado esperado: npx tsc --noEmit sin errores; npm run dev con conexión OK, sync OK y la tabla clients creada; GET devuelve solo activos y sin password; POST responde 201; PUT/PATCH 200; deactivate deja inactive; DELETE borra la fila; y el contrato de errores responde 400 para :id inválido y 404 para inexistente o inactivo. Detén el servidor con Ctrl+C.

Checklist de cierre:

  • [ ] test -d src/features/business/clients/http && echo HTTP_FOLDER_OK → HTTP_FOLDER_OK
  • [ ] npx tsc --noEmit → 0 errores de tipos
  • [ ] npm run dev → conexión OK + sync OK + tabla clients con createdAt / updatedAt
  • [ ] getAll devuelve solo status = active y nunca password
  • [ ] getOne con id inexistente o inactive → 404
  • [ ] :id no numérico o 0 → 400 (lo valida paramId)
  • [ ] PUT conserva password si no viene; PATCH actualiza solo lo enviado
  • [ ] status se ignora en PUT/PATCH; solo cambia con deactivate
  • [ ] deactivate → inactive; DELETE → borra la fila
  • [ ] Las respuestas son objetos planos (DTOs), sin campos internos de Sequelize

Con todo en verde, el feature clients queda completo por capas (clients.routes.ts → clients.controller.ts → clients.service.ts → clients.repository.ts → client.model.ts) y puedes pasar al ISS-04 — Seeders con Faker, donde se poblará la tabla que acabas de crear.


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