📚 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.
🎬 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,
AppErroryBaseController, 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 --noEmitsin errores de tipos,npm run devcon conexión OK ysyncOK (tablaclientscreada), 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:
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
- Ficha del ISS · Qué implementamos AHORA · Qué todavía NO implementamos
- Mapa mental del ISS
- Mapa del backend
- Árbol de archivos
- Anatomía del código
- Comandos explicados
- Flujos
- Recorrido del ISS, paso a paso
- Diagnóstico
- Conexión con el resto del curso
- Glosario
- Criterios de aceptación
- Evaluación
- GATE
Ficha del ISS
| Campo | Valor |
|---|---|
| ISS | ISS-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 solotry/catch, validación de:id, traducción de errores a HTTP) ywithTransaction(helper unit of work, se crea aquí aunque se use en ISS-08).- Modelo
Client—client.model.tsconstatus(active/inactive),timestamps: truey hooksbeforeCreate/beforeUpdate/beforeBulkCreateque hasheanpasswordcon bcrypt. - DTOs — un archivo por operación (
create,update,patch,response) más unindex.tsbarrel; el mappertoClientResponseocultapassword. - Las capas del feature —
clients.repository.ts,clients.service.ts,clients.controller.tsyclients.routes.ts, completadas en orden B → C → D → E. - Rutas
/api/clientes—GET(colección y por id),POST,PUT,PATCH,PATCH /:id/deactivateyDELETE /:id. - Cableado —
src/routes/index.tsagrega el feature ysrc/config/index.tsimporta el modelo, conecta la base de datos, hacesync({ 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
.httpdel feature mencionanlogin, 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 sinAuthorization.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
Errory añade la propiedadstatusCode. - Fija
name = "AppError", lo que permite reconocerlo en elcatchdeBaseController. - 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 ejemploClientsService.findOrFailyBaseController.paramId). - Salida: lo consume
BaseController.handleError, que leestatusCodeymessage.
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 elcatch. Aquí eltry/catchexiste una sola vez; cada controller solo lee, llama al service y responde.paramId(req)— lee el:id, exige que sea un entero≥ 1y, si no, lanzaAppError(400, …). Sin esta validación,GET /api/clientes/abcllegaría al repository comoNaNy devolvería un 404 engañoso en vez de un 400.handleError(res, error)— mapeaAppErrora 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
runyparamId; elparamIdusaAppError. - Salida:
handleErrorescribe la respuesta HTTP y consultaAppError.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 awork. - Si
worktermina, hacecommit; si lanza, hacerollback(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 deproducts).
Se conecta con
- Entrada: nadie lo llama en ISS-03; sí en ISS-08.
- Salida: usa
sequelize.transaction()desrc/database/db.tsy el tipoTransactionde 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 claseClient extends Model(instancia). - Atributos:
name,address,phone(connotEmpty),email(unique+isEmail),passwordystatus. statusesENUM("active", "inactive")condefaultValue: "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: trueañadecreatedAt/updatedAt.- Los hooks
beforeCreate,beforeUpdateybeforeBulkCreatehasheanpasswordconbcrypt.genSalt(10)+bcrypt.hash(...). El hashing vive en el modelo, no en el service. beforeUpdatesolo re-hashea siclient.changed("password"), para no re-hashear un hash.
Se conecta con
- Entrada: lo importa
clients.repository.tsy lo registra el import lateral deconfig/index.ts. - Salida: usa la conexión
sequelizedesrc/database/db.tsybcryptjs.
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 delPOST;statuses opcional (por defectoactive).update-client.dto.ts— reemplazo completo delPUT; no incluyestatus, porque el estado solo cambia con el borrado lógico;passwordes opcional (si no viene, se conserva el hash).patch-client.dto.ts—Partial<UpdateClientDto>; actualización parcial delPATCH.client-response.dto.ts— declaraClientResponseDto = Omit<ClientI, "password">y el mappertoClientResponse(client), único lugar donde se decide qué se expone. Devuelve un objeto plano (client.toJSON()), sin metadatos del ORM y sinpassword.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 elcontroller(casts dereq.body). - Salida: el
response.dtoimporta el modeloClient/ClientIpara elOmity 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)conCreationAttributes<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
Clienty 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 contoClientResponse.getOne(id)— delega enfindOrFail(id)y proyecta.create(body)— copia campo a campo lo que declaraCreateClientDto(evita mass assignment) y aplicastatus ?? "active".updatePut/updatePatch— cargan confindOrFail, actualizan; en el PUTpassword: body.password ?? client.passwordconserva el hash actual si no se envía.deletePhysical(id)— usafindOrFail(id, false)para poder purgar también lo ya desactivado.deleteLogical(id)— no es unDELETE: reutilizaupdate(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, lanzaAppError(404, "Client not found"). Gracias a él,getOne,updatePut,updatePatchydeleteLogicalquedan 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 modeloClientyAppError.
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
BaseControllere inyectaClientsServicepor constructor (con valor por defecto). - Cada método envuelve su camino feliz en
this.run(res, async () => { … }). - Usa
this.paramId(req)para obtener el:idvalidado. - Códigos:
getAll/getOne→ 200,create→ 201, updates → 200,deletePhysical→ 200 con{ message, id },deleteLogical→ 200 con{ message, client }. - Los
req.bodyse castean a los DTOs (CreateClientDto,UpdateClientDto,PatchClientDto).
Se conecta con
- Entrada: lo llaman las rutas (
clients.routes.ts). - Salida: hereda
run/paramIddeBaseControllery llama aClientsService.
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
clientsControllercomo propiedad pública. routes(app)registra el CRUD completo en/api/clientes:GET /api/clientes→getAllGET /api/clientes/:id→getOnePOST /api/clientes→createPUTyPATCH /api/clientes/:id→updatePut/updatePatchDELETE /api/clientes/:id→deletePhysicalPATCH /api/clientes/:id/deactivate→deleteLogical- Cada handler se enlaza con
.bind(this.clientsController)para no perder elthis. - 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
RoutesexponeclientsRoutescomo 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 desequelize,getDatabaseInfo,testConnection, el import lateral del modeloClientyRoutes. - Dentro de
Appse añadepublic routePrv: Routes = new Routes();. - En
routes()se llamathis.routePrv.clientsRoutes.routes(this.app);. - En
dbConnection()se comprueba la conexión, se hacesequelize.sync({ force: false, alter: true })y, si falla,process.exit(1). alter: trueexiste para quesyncañada columnas que falten (por ejemplocreatedAt/updatedAtsi la tabla se creó antes contimestamps: false).force: falseno recrea tablas ni borra datos.- Orden de arranque:
listen()haceawait this.dbConnection()antes deapp.listen(...). Así las sentencias DDL desyncno 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.tsyroutes/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, FKRESTRICT, í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/clientesDepende de ISS-02 — Infraestructura de base de datos Habilita ISS-04 — Seeders con Faker · ISS-05 — Swagger / OpenAPI Variantes internas (en orden) ISS-03-Afundación →ISS-03-BgetAll/getOne →ISS-03-Ccreate →ISS-03-Dupdate PUT/PATCH →ISS-03-Edelete 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.tsconstatus+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.tsyclients.routes.ts - [ ] 4.4 Carpeta
features/business/clients/http/creada - [ ] 4.5
routes/index.ts+configimportan modelo, conectan BD y hacensync - [ ] Con BD:
npm run dev→ conexión OK + sync OK + tablaclients
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.
statusno aparece enUpdate<X>Dtoni enPatch<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 filainactivecon 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:
- Todo handler se envuelve en
this.run(res, …). Eltry/catchexiste una sola vez, enBaseController. El controller sólo expresa el camino feliz: leer la entrada → llamar al service → responder. - Todo
:idse lee conthis.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. - Todo método que recibe un
idusafindOrFail(id). Si el registro no existe o estáinactive, lanzaAppError(404, …). Es el único lugar donde se define «no existe», y es lo que hace que el borrado lógico sea consistente entregetOne,updatePut,updatePatchydeleteLogical. statusno viaja enUpdate<X>Dtoni enPatch<X>Dto. Sólo cambia con el borrado lógico (o al crear, donde es opcional y valeactivepor defecto). En el modelo, eldefaultValuedestatuses"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".- 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á.
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 solotry/catch).paramId(req): lee el:idde la URL y lo valida como entero≥ 1(si no, 400).handleError(res, error): mapeaAppErrora 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, defaultinactive;timestamps: true
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.tsyclients.routes.tsexisten (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.
DTOs — carpeta dto/ (un archivo por operación)
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.tsconclientsRoutes - [ ]
configimporta 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).
- 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";
- Dentro de
export class App, debajo depublic app: Application;añadir:
- Dentro de
routes(), reemplazar el comentario// ISS-03 §4.3por:
- Dentro de
dbConnection(), reemplazar el comentario// ISS-02 / ISS-03por:
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
clientsse creó antes contimestamps: false,sync({ force: false })no añadecreatedAt/updatedAt. Por eso se usaalter: true.Orden de arranque (importante):
sync({ alter: true })emiteALTER TABLEyDROP/ADD FOREIGN KEY, que toman metadata locks. Si el puerto ya está escuchando mientras elsynccorre, esas sentencias DDL compiten con las peticiones en curso y aparecen deadlocks y errores de FK intermitentes. Por esolisten()haceawait this.dbConnection()antes deapp.listen(...): primero la BD, después HTTP.
Verificación ISS-03-A
Cierre del ISS
Sync OK y tabla
clients(concreatedAt/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(solostatus: 'active') y, debajo,findById - [ ] Service:
getAllygetOne(404 si no existe o estáinactive) + mappertoClientResponse(sinpassword) - [ ] Service: helper privado
findOrFail(id, onlyActive = true): la política de borrado lógico se decide una sola vez - [ ] Controller:
getAlly, debajo,getOne(envueltos enrun(), que ya traduce los errores) - [ ] Rutas
GET /api/clientesyGET /api/clientes/:id— sin auth - [ ]
http/clients.get.httpcon leyenda SIN AUTH
Repository — PARCHE clients.repository.ts (ya existe)
Arriba, junto al import del modelo, añadir el import de Transaction:
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 mappertoClientResponsedeclarado endto/client-response.dto.ts.El service no arma respuestas HTTP: si no existe lanza
AppError(404, …)y elBaseControllerlo 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 defectotrue) aplica la política de borrado lógico: un registroinactivedeja de ser visible para la API, igual que engetAll.deletePhysicallo pasa enfalsepara poder purgar también registros ya desactivados.Con este helper,
getOne,updatePut,updatePatchydeleteLogicalquedan 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 destatus) y respuesta sinpassword - [ ] Controller:
createcon201 - [ ] Ruta
POST /api/clientes— sin auth - [ ]
http/clients.create.httpcon leyenda SIN AUTH
Repository — PARCHE clients.repository.ts (ya existe)
Arriba, ampliar el import de sequelize para incluir CreationAttributes:
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, usaractive» vive en el service. El hashing delpasswordes responsabilidad del modelo (beforeCreate). ElCreateClientDtodocumenta qué acepta elPOST.
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(conservapasswordsi no viene) yupdatePatch - [ ] Service:
statusno se edita por PUT/PATCH: sólo cambia con el borrado lógico - [ ] Controller:
updatePutyupdatePatch - [ ] Rutas
PUT /api/clientes/:idyPATCH /api/clientes/:id— sin auth - [ ]
http/clients.update.httpcon 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:
deletePhysicalydeleteLogical - [ ] Controller:
deletePhysicalydeleteLogical - [ ] Rutas
DELETE /api/clientes/:idyPATCH /api/clientes/:id/deactivate— sin auth - [ ]
http/clients.delete.httpcon 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 = inactivereutilizando el mismo métodoupdatedel 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)
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"}'
- [ ]
getAlldevuelve solostatus = activey nunca el campopassword - [ ]
getOnecon id inexistente o constatus = inactivedevuelve 404 (la misma regla quegetAll) - [ ] Un
:idno numérico o0devuelve 400 (lo validaparamIdenBaseController, no el service) - [ ]
PUTconservapasswordsi no se envía;PATCHactualiza solo lo enviado - [ ]
statusno se puede cambiar conPUT/PATCH: si se envía, se ignora; sólo cambia condeactivate - [ ]
deactivatedejastatus = inactive;DELETEborra 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,testConnectiony elsyncque este ISS extiende conalter: true. - Lo que este ISS habilita:
- ISS-04 — Seeders con Faker: los seeders llenarán la
tabla
clientsque aquí nace, reutilizando el modelo y sus hooks de bcrypt. - ISS-05 — Swagger / OpenAPI: documentará los endpoints
/api/clientesque aquí se definen. - Piezas que se reutilizan en todos los features siguientes:
AppError,BaseController(run,paramId,handleError),withTransaction, el patróndto/+ mapper, el helperfindOrFail, 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
clientsse protegerán conauthenticate/authorize. Los.httpdel 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.tsconstatus+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.tsyclients.routes.ts - [ ] 4.4 Carpeta
features/business/clients/http/creada - [ ] 4.5
routes/index.ts+configimportan modelo, conectan BD y hacensync - [ ] Con BD:
npm run dev→ conexión OK + sync OK + tablaclients
ISS-03-B — GetAll y GetOne
- [ ] Repository:
findAllActive(solostatus: 'active') y, debajo,findById - [ ] Service:
getAllygetOne(404 si no existe o estáinactive) + mappertoClientResponse(sinpassword) - [ ] Service: helper privado
findOrFail(id, onlyActive = true): la política de borrado lógico se decide una sola vez - [ ] Controller:
getAlly, debajo,getOne(envueltos enrun(), que ya traduce los errores) - [ ] Rutas
GET /api/clientesyGET /api/clientes/:id— sin auth - [ ]
http/clients.get.httpcon leyenda SIN AUTH
ISS-03-C — Crear cliente
- [ ] Repository:
create - [ ] Service:
create(default destatus) y respuesta sinpassword - [ ] Controller:
createcon201 - [ ] Ruta
POST /api/clientes— sin auth - [ ]
http/clients.create.httpcon leyenda SIN AUTH
ISS-03-D — Update (PUT) y Update (PATCH)
- [ ] Repository:
update - [ ] Service:
updatePut(conservapasswordsi no viene) yupdatePatch - [ ] Service:
statusno se edita por PUT/PATCH: sólo cambia con el borrado lógico - [ ] Controller:
updatePutyupdatePatch - [ ] Rutas
PUT /api/clientes/:idyPATCH /api/clientes/:id— sin auth - [ ]
http/clients.update.httpcon leyenda SIN AUTH
ISS-03-E — Eliminar (físico y lógico)
- [ ] Repository:
delete - [ ] Service:
deletePhysicalydeleteLogical - [ ] Controller:
deletePhysicalydeleteLogical - [ ] Rutas
DELETE /api/clientes/:idyPATCH /api/clientes/:id/deactivate— sin auth - [ ]
http/clients.delete.httpcon leyenda SIN AUTH
Verificación final
- [ ]
getAlldevuelve solostatus = activey nunca el campopassword - [ ]
getOnecon id inexistente o constatus = inactivedevuelve 404 (la misma regla quegetAll) - [ ] Un
:idno numérico o0devuelve 400 (lo validaparamIdenBaseController, no el service) - [ ]
PUTconservapasswordsi no se envía;PATCHactualiza solo lo enviado - [ ]
statusno se puede cambiar conPUT/PATCH: si se envía, se ignora; sólo cambia condeactivate - [ ]
deactivatedejastatus = inactive;DELETEborra la fila - [ ] Las respuestas son objetos planos (DTOs de respuesta), sin campos internos de Sequelize
Evaluación
Preguntas de comprensión
-
¿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. -
¿Por qué el
try/catchvive enBaseControllery no en cada método del controller? Porque repetirlo en los ~35 métodos del proyecto es ruido y una fuente de inconsistencias.runcentraliza el manejo del error y deja que cada handler exprese solo su camino feliz: leer, llamar al service, responder. -
¿Por qué la validación del
:id(400) no la hace el service? Porque el:ides un dato de la URL, no una regla de negocio.paramIdes 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/abcllegaría comoNaNy produciría un 404 engañoso. -
¿Por qué
findOrFailes 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,updatePatchydeleteLogicalquedan automáticamente consistentes con el borrado lógico. -
¿Por qué
statusno está enUpdateClientDtoni enPatchClientDto? Porque el estado es una regla de negocio, no un campo editable. Sistatusviajara en el body, unPUTpodría «resucitar» una filainactiveo desactivarla saltándose el caso de uso. El estado solo cambia con el endpoint de borrado lógico (PATCH /:id/deactivate). -
¿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íaPOST. 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. -
¿Por qué el borrado lógico reutiliza
repository.updateen vez de tener su propio método? Porque «desactivar» no es un DELETE: es unUPDATE ... 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. -
¿Por qué
withTransactionse crea en ISS-03 si no se usa hasta ISS-08? Porquesrc/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 declientsno 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
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
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 + tablaclientsconcreatedAt/updatedAt - [ ]
getAlldevuelve solostatus = activey nuncapassword - [ ]
getOnecon id inexistente oinactive→ 404 - [ ]
:idno numérico o0→ 400 (lo validaparamId) - [ ]
PUTconservapasswordsi no viene;PATCHactualiza solo lo enviado - [ ]
statusse ignora enPUT/PATCH; solo cambia condeactivate - [ ]
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