Después de varias semanas, el backend está completo. Tiene:
- Clientes: registro, login, verificación por email, autogestión (perfil, contraseña, desactivación), roles (admin/cliente)
- Productos: CRUD, búsqueda dinámica, soft delete, solo admin puede escribir
- Facturas: ciclo completo (draft → confirm → deliver → paid/cancel), stock reservado y real, transacciones, separación por roles
- JWT no guarda estado, pero podés consultar la DB para validar cosas mutables (status)
- El orden de las rutas en Express importa (las específicas antes que las dinámicas)
- Las transacciones no son magia, son
BEGIN,COMMIT,ROLLBACK reserved_stockes clave en un sistema mayorista- Documentar el proceso (devlog) ayuda a ordenar las ideas
- Frontend en React para consumir esta API
- Paginación real
- Webhook de MercadoPago
Terminé de organizar el enrutador de clients. Quedó todo prolijo, con los middlewares bien puestos y cada endpoint en su lugar.
-
Rutas públicas (sin autenticar):
POST /→ registroGET /me/verify/:token→ verificación de emailPOST /login→ login (devuelve solo el token)
-
Rutas de cliente autenticado (requieren
authMiddleware):GET /me→ perfilPATCH /me→ actualizar datos no sensiblesPATCH /me/change-password→ cambiar contraseñaGET /me/invoices→ listar facturas propiasGET /me/invoices/active→ ver carrito activoPATCH /me/deactivate→ desactivar cuentaPOST /me/reactivate→ pedir reactivación por emailPATCH /me/reactivate/:token→ reactivar con token
-
Rutas de admin (requieren
authMiddleware+adminOnly):GET /all→ listar todos los clientesGET /search→ búsqueda dinámicaPATCH /:id/toggle→ cambiar estado (active/inactive/confirmed)GET /:id→ obtener cliente por ID
- El
statusya no se guarda en el JWT. Se consulta en cada request desde la DB. - El middleware de autenticación valida el token y después busca el estado actual del cliente.
authMiddleware: verifica token, busca el status en DB y armareq.clientadminOnly: verificareq.client.is_adminy rechaza con 403 si no lo es
- El middleware
adminOnlyno estaba capturando bien los errores y devolvía HTML en vez de JSON. Se encapsuló con try/catch y ahora responde como corresponde. - El orden de las rutas estaba haciendo que
/:idse comiera otras rutas como/allo/search. Se reordenaron y ahora funciona todo.
- Pasar por el mismo proceso de seguridad a los routers de products e invoices
- Probar bien edge cases y errores
- Desplegar el backend y grabar una demo
Terminé el módulo de autogestión de clientes. Ahora pueden ver y actualizar su perfil, cambiar contraseña, ver sus facturas y manejar su estado (activar/desactivar cuenta).
| Método | Endpoint | Qué hace |
|---|---|---|
| GET | /me |
Ver mi perfil |
| PATCH | /me |
Actualizar mi perfil (phone, address, contact_name, contact_phone) |
| PATCH | /me/change-password |
Cambiar contraseña |
| GET | /me/invoices |
Ver todas mis facturas |
| GET | /me/invoices/active |
Ver mi carrito activo (draft) |
| PATCH | /me/deactivate |
Desactivar mi cuenta |
| POST | /me/reactivate |
Solicitar reactivación (envía email) |
| PATCH | /me/reactivate/:token |
Reactivar cuenta con token |
- El cliente desactiva su cuenta →
status = 'inactive', se genera unverification_token - Solicita reactivación → se envía un email con un link que contiene el token
- Clickea el link → frontend llama al endpoint de reactivación
- El token se valida y la cuenta vuelve a
status = 'active'
| Estado | Qué significa |
|---|---|
pending |
Registró pero no verificó email |
confirmed |
Verificó email, espera aprobación de admin |
active |
Cuenta habilitada para operar |
inactive |
Desactivada por el cliente, puede reactivarse por email |
- Ya no devuelve datos del cliente, solo el token
- El frontend usa
/mepara obtener el perfil después de loguear - Separa responsabilidades: login solo autentica,
/meda la info
src/handlers/clientHandlers/getMyData.js→ getMyProfile, getMyInvoices, getMyActiveInvoicesrc/handlers/clientHandlers/updateMyProfile.js→ updateMyProfilesrc/handlers/clientHandlers/changeMyPassword.js→ changeMyPassword (antes updatePassword, ahora usa req.client.id)src/handlers/clientHandlers/deactivateMySelf.js→ desactivar cuentasrc/handlers/clientHandlers/reactivateAccount.js→ verifyMail, sendReactivationMail, reactivateMyAccount
- Modificado
statusENUM: ahora acepta'pending','confirmed','active','inactive' verification_tokense reutiliza para reactivaciónemail_verified_atse setea cuando confirma el email
- Separar
clientsRouterenclientRoutes.jsyadminRoutes.js - Proteger rutas de admin con
authMiddleware + adminOnly - Implementar Nodemailer para envío real de emails
Terminé los tres endpoints que faltaban para completar el ciclo completo del invoice:
- Archivo:
src/handlers/invoiceHandlers/deliverInvoice.js - Cambia status de
confirmedadelivered - Descuenta
stockyreserved_stockde cada producto - Setea
delivered_at = CURRENT_TIMESTAMP - Usa transacción con
CASEpara batch update de productos
- Archivo:
src/handlers/invoiceHandlers/paidInvoice.js - Cambia status a
paid - Setea
paid_at = CURRENT_TIMESTAMP - Valida que el invoice esté en
confirmedodelivered - Sin transacción (solo un UPDATE simple)
- Archivo:
src/handlers/invoiceHandlers/cancelInvoice.js - Solo permite cancelar si status es
confirmed - Libera
reserved_stock(resta la cantidad reservada) - Cambia status a
cancelled - Usa transacción para liberar stock atómicamente
- Todos usan
getInvoiceWithItemspara traer el invoice con sus productos - Los que modifican stock usan
CASE WHEN id = ? THEN ?para batch update - Todos tienen
validateIdal inicio - Manejo de errores consistente con código y timestamp
400 INVOICE_NOT_CONFIRMED→ intentar entregar algo que no está confirmado400 CANNOT_CANCEL_AN_UNCONFIRMED_INVOICE→ cancelar algo que no está confirmado400 INVOICE_NOT_DELIVERED→ pagar algo que no fue entregado (o confirmado)409 INSUFFICIENT_STOCK→ stock insuficiente al entregar409 INCONSISTENT_RESERVED_STOCK→ stock reservado inconsistente al cancelar500 COULDNT_UPDATE_INVOICE→ fallo en el commit final
deliverycancelusan transacción porque modifican múltiples productospaides simple porque solo toca el invoice- Todos los placeholders con
?(SQL injection safe) connection.release()enfinallylibera la conexión al pool
| Endpoint | Estado |
|---|---|
POST /invoices |
✅ |
GET /invoices/all |
✅ |
GET /invoices/search |
✅ |
GET /invoices/:id |
✅ |
PATCH /invoices/:id |
✅ |
POST /invoices/:id/confirm |
✅ |
POST /invoices/:id/deliver |
✅ |
POST /invoices/:id/paid |
✅ |
POST /invoices/:id/cancel |
✅ |
Módulo de invoices completado. Ahora toca JWT y middlewares de autenticación.
- Archivo:
src/handlers/invoiceHandlers/confirmInvoice.js - Endpoint:
POST /invoices/:id/confirm - Body:
{ payment_terms, notes }
-
Validaciones iniciales
- UUID válido en
:id payment_termspermitido (30, 60, 90, 120)
- UUID válido en
-
Transacción atómica
BEGIN TRANSACTIONantes de cualquier modificaciónCOMMITsolo si todo el proceso es exitosoROLLBACKante cualquier error
-
Reserva de stock
- Por cada item en la factura:
- Calcula
newReservedStock = reserved_stock + quantity - Valida que no supere el stock físico disponible
- Construye
CASE WHEN id = ? THEN ?para batch update
- Calcula
- Una sola query actualiza
reserved_stockde todos los productos afectados
- Por cada item en la factura:
-
Generación de número de factura
- Formato:
INV-YYYYMMDD-RRRR(ej:INV-20260401-0470) - Fecha actual + número aleatorio de 4 dígitos
- Sin query extra a la BD
- Formato:
-
Actualización del invoice
status→confirmedinvoice_number→ generadoissue_date→CURDATE()(fecha de confirmación)due_date→DATE_ADD(CURDATE(), INTERVAL payment_terms DAY)payment_terms→ valor recibidototal→ suma de subtotales de todos los itemsnotes→ opcional
-
Manejo de errores
404 INVOICE_NOT_FOUND→ invoice no existe409 INSUFFICIENT_STOCK→ stock insuficiente en algún producto500 COULDNT_UPDATE_INVOICE→ fallo en el commit final
- Todos los placeholders con
?(SQL injection safe) connection.beginTransaction()+commit()+rollback()garantizan atomicidadconnection.release()enfinallylibera la conexión al poolaffectedRowsverificado después del últimoUPDATE
-
POST /invoices/:id/deliver→ descontar stock físico -
POST /invoices/:id/paid→ registrar fecha de pago -
POST /invoices/:id/cancel→ liberar stock reservado
-
Archivos en
src/handlers/invoiceHandlers/:postInvoice.js→ creación de invoice en draft con primer itemgetInvoices.js→getAllInvoices,getInvoiceById,getInvoicesByQueryupdateInvoice.js→updateInvoice(batch upsert + delete on quantity 0)
-
Helpers en
src/utils/queryBuilder.js:invoiceByQueryBuilder→ construcción dinámica de WHERE clause con rangos y filtros exactos
| Método | Endpoint | Descripción |
|---|---|---|
| POST | /invoices |
Crear invoice en draft con primer item |
| GET | /invoices/all |
Listar todos los invoices |
| GET | /invoices/search?client_id=&status=&total_min=&total_max=&issue_date_from=&issue_date_to= |
Búsqueda con filtros exactos y rangos |
| GET | /invoices/:id |
Obtener invoice por ID con sus items |
| PATCH | /invoices/:id |
Batch update: insert/update items (quantity > 0), delete items (quantity = 0) |
Exactos: client_id, status, payment_terms, invoice_number
Rangos numéricos: total_min, total_max
Rangos de fechas: issue_date_from, issue_date_to, due_date_from, due_date_to, paid_at_from, paid_at_to
400 INVALID_ID_FORMAT→ UUID inválido400 MISSING_SEARCH_PARAMETERS→ búsqueda sin filtros400 INVALID_STATUS→ status no permitido400 INVALID_PAYMENT_TERMS→ payment_terms no permitido404 INVOICE_NOT_FOUND→ invoice no existe
- Carrito = invoice en estado
draft - Un cliente puede tener un solo
drafta la vez - Cantidad 0 en update → elimina el item del carrito
- Búsqueda con rangos usa
BETWEEN(si vienen ambos límites) o>=/<=(si viene solo uno)
-
POST /invoices/:id/confirm→ reservar stock, generar número/fechas -
POST /invoices/:id/deliver→ descontar stock real -
POST /invoices/:id/paid→ marcar como pagado -
POST /invoices/:id/cancel→ liberar stock reservado
- Archivos:
src/handlers/invoiceHandlers/postInvoice- Necesitamos el id del cliente y el id del producto para crear las primeras relacionales
- Cliente>Invoice (one-to-many) y crear la primera entrada de la tabla relacional Invoice>Products (invoice_items)
- [PATCH] /:id → Modificar el invoice existente: Quitar/agregar/modificar items.
- [POST] /:id/confirm → Confirmar el invoice. Crear invoice_id, due_date, agregar reserved_stock a cada producto involucrado.
- [DELETE] /:id → Cuando pasa de draft a cancelled, al no haber ningún cambio en DDBB simplemente se borra.
- [POST] /:id/deliver → Cuando es retirado de depósito. Descontar stock real de cada producto, cambiar estado del invoice.
- [POST] /:id/cancel → Después de confirmado, al cancelar hay que descontar el reserved_stock de los productos y archivar.
- [PATCH] /:id/toggle-invoice → SOFT Delete
-
Archivos:
src/handlers/productHandlerspostProduct.js→ creación de productosgetProducts.js→getAllProducts,getProductById,getProductsByQueryupdateProduct.js→updateProduct,toggleProduct
-
Archivo:
src/utils/queryBuilder.jsproductQueryBuilder→ arma columnas y valores para POSTsearchProductByQuery→ arma conditions y values para búsqueda dinámicaupdateProductQuery→ arma conditions y values para actualizaciones
-
Endpoints:
POST /productsGET /products/allGET /products/search?sku=&name=&category=&is_active=GET /products/:idPATCH /products/:idPATCH /products/:id/toggle-active
-
Campos permitidos:
- Creación:
sku,name,description,category,unit_price,stock,reserved_stock,is_active - Actualización:
name,description,unit_price,stock,reserved_stock - Búsqueda:
sku(exacta),name(parcial),category(parcial),is_active(exacta)
- Creación:
-
Manejo de errores:
400 MISSING_KEY_INFORMATION→ faltan datos obligatorios400 INVALID_ID_FORMAT→ UUID inválido400 MISSING_SEARCHING_PARAMETERS→ búsqueda sin filtros404 PRODUCT_NOT_FOUND→ producto no existe409→ sku duplicado
skues único en la tabla- Soft delete mediante
is_active - Todas las queries usan placeholders (SQL injection safe)
- Búsqueda con
LIKEparanameycategory
- Archivo:
src/handlers/clientHandlers/updateClients.js - Endpoint:
PATCH /clients/:id/toggle-active - Soft delete / reactivación de clientes
- Motivo: borrar físicamente eliminaría facturas, pagos e historial asociado
- Implementación:
UPDATE clients SET is_active = NOT is_active WHERE id = ? - Retorna mensaje de éxito
- Archivo:
src/handlers/clientHandlers/updateClients.js - Endpoints:
- [PATCH] /:id
- [PATCH] /:id/change-password
- Para actualizar datos generales del cliente tenemos una lista de "fields autorizados".
- Checkeamos que esté intentando de cambiar algo autorizado y lo sumamos al query
- En caso de ser la contraseña, tenemos una ruta específica para eso:
- Validamos el formato de la nueva contraseña
- Comparamos con la anterior para evitar reemplazar con lo mismo
- Verificamos que la contraseña anterior sea correcta
- Hasheamos la contraseña nueva (bcrypt.hash)
- Enviamos el UPDATE SET para actualizar
- Archivo:
src/handlers/clientHandlers/verifyClient.js - Endpoint:
GET /clients/verify/:verification_token- Validación de formato del token (hexadecimal de 64 caracteres)
- Búsqueda por token y actualización en una sola query usando
affectedRows - Actualizaciones:
verification_token = NULLverified_at = NOW()is_active = true
[Manejo de errores]
400 INVALID_TOKEN_FORMAT→ token no cumple el formato esperado400 INVALID_OR_ALREADY_VERIFIED→ token no existe o cuenta ya activada
[Optimización]
- Uso de
affectedRowspara evitar unSELECTprevio
- Podemos buscar por varios query a la vez
- Implementé una forma más dinámica para concatenar clausulas WHERE y sus valores
- Archivo:
src/handlers/clientHandlers/postClient.js - Terminé el endpoint para creación de
clientes:- Verificamos que nos llegó la información obligatoria (name, password, email...)
- Validamos formato de email y contraseña recibidos (RegExp)
- Hasheamos la contraseña antes de seguir con el proceso
- Preparamos un query dependiendo la información que nos llegó por body
- Insertamos el nuevo registro, traemos el nuevo registro de DDBB sacandole contraseña y token de verificación
- Devolvemos el nuevo registro.
- Archivo:
src/services/validations.js - Contiene funciones reutilizables para validar:
- UUID
- Password
- Separación de responsabilidades: los handlers manejan la lógica de request/response, las validaciones se extraen a servicios para mantener el código limpio y testeable.
- PATCH
/clients/verify→ verificar token y actualizar is_active: true - GET
/clients→ Traer todos los registros de clientes - GET
/clients/:id→ Traer clientes usando ID o - POST
/clients/login→ Comparar contraseña, actualizar last_login, devolver datos del cliente y a futuro manejar JWT.
-
Saqué la tabla
users, creéclientsen su lugar pensando en:- Simular un negocio real de mayoreo
- Agregar verificación por correo electrónico
- Darle a futuro un dashboard para revisar sus facturas y preferencias, o incluso pagar desde la app.
-
Borré todas las tablas y arranqué de cero. Decisión consciente para evitar deuda técnica temprana y construir con una arquitectura más planeada.
-
Estoy priorizando un enfoque más profesional/real de la app: voy a ir creando un CRUD a la vez, integrando las tablas de a poco, y chequeando que todo avance de manera armoniosa.
- POST
/clients/register→ bcrypt + token de verificación - GET
/clients/verify→ activar cuenta - POST
/clients/login→ autenticación - GET
/clients(con filtros y paginación) - PATCH
/clients/:id - Modelo de
products
- Usando queries puras de MySQL, sin ORM.
- Una vez termine CLIENTS por completo (edge cases, errores, regexp) avanzo a la siguiente tabla.
- Todo el código se va a ir subiendo por partes, con commits claros y documentación paralela.
