React + TypeScript
Semana 8 de 9·8h

Backend CRUD completo con Express y Prisma

Endpoints POST, PUT y DELETE tipados con Express + Prisma + PostgreSQL, arquitectura MVC de 4 capas y subida de archivos con multer.

ExpressPrismaPostgreSQLmulterTypeScript
Objetivos de aprendizaje
  • Implementar los endpoints POST, PUT y DELETE en Express con TypeScript
  • Organizar el backend en 4 capas: routes → controllers → services → database
  • Crear consultas Prisma tipadas para crear, actualizar y eliminar artículos
  • Manejar errores de Prisma con PrismaClientKnownRequestError (P2002, P2025)
  • Configurar multer para recibir imágenes y devolver el filename

🎯 Objetivo de la semana: Al terminar tendrás una API REST completa — GET, POST, PUT, DELETE y upload de archivos — organizada en arquitectura MVC de 4 capas con Prisma y conectada a PostgreSQL.

🔑 Concepto clave: Arquitectura MVC de 4 capas en Express — v1/routes/ (URL+verbo), controllers/ (req/res + validación), services/ (lógica de negocio), database/ (consultas Prisma). Cada capa es testeable de forma independiente.

🛠 Tarea práctica: Completar la API del CMS con los endpoints de escritura: POST /api/v1/articulos, PUT /api/v1/articulos/:slug, DELETE /api/v1/articulos/:id y POST /api/v1/upload. Crear ApiError, sendSuccess/sendError y el middleware errorHandler.

📋 Entregable: Desde Postman puedes crear un artículo con imagen, editarlo y eliminarlo. El backend responde con los códigos HTTP correctos (201, 200, 204, 400, 404, 409) y el formato { status, code, message, data }.


1. Arquitectura MVC en Express

En las semanas anteriores pusiste las consultas SQL directamente dentro de las rutas — funciona, pero cuando la API crece se vuelve difícil de mantener: un router de 300 líneas mezcla HTTP, validación y SQL. El patrón MVC (Model-View-Controller) separa estas responsabilidades:

code
src/
├── v1/routes/
│   └── articulos.ts        ← conecta URL + verbo con el controller
├── controllers/
│   └── articulosController.ts  ← valida input, llama al service, responde
├── services/
│   └── articulosService.ts     ← lógica de negocio (slug, reglas)
├── database/
│   └── articuloQueries.ts      ← consultas Prisma tipadas
├── middlewares/
│   ├── upload.ts               ← configuración de multer
│   └── errorHandler.ts         ← convierte ApiError/PrismaError a HTTP
└── utils/
    ├── ApiError.ts             ← error de negocio con statusCode
    └── apiResponse.ts          ← sendSuccess() y sendError()

La ventaja práctica: si mañana cambias la lógica de negocio, solo cambias services/. Si cambias la base de datos, solo cambias database/. Las rutas y controllers no tocan.

Utilidades: ApiError y sendSuccess/sendError

Antes de implementar los endpoints, necesitas los bloques de construcción que garantizan respuestas consistentes:

typescript
// src/utils/ApiError.ts
export class ApiError extends Error {
  statusCode: number
  details: unknown

  constructor(statusCode: number, message: string, details: unknown = null) {
    super(message)
    this.name = 'ApiError'
    this.statusCode = statusCode
    this.details = details
  }
}
typescript
// src/utils/apiResponse.ts
import type { Response } from 'express'

export const sendSuccess = ({
  res, statusCode = 200, message = 'Operación exitosa', data = null, meta = undefined,
}: { res: Response; statusCode?: number; message?: string; data?: unknown; meta?: unknown }) => {
  return res.status(statusCode).json({ status: 'success', code: statusCode, message, data, ...(meta ? { meta } : {}) })
}

export const sendError = ({
  res, statusCode = 500, message = 'Error interno',
}: { res: Response; statusCode?: number; message?: string }) => {
  return res.status(statusCode).json({ status: 'error', code: statusCode, message })
}

La capa database: consultas Prisma tipadas

typescript
// src/database/articuloQueries.ts
import { prisma } from '../config/db'
import { ApiError } from '../utils/ApiError'
import type { EstadoArticulo } from '../generated/prisma/client'

function generarSlug(titulo: string): string {
  return titulo.toLowerCase().normalize('NFD')
    .replace(/[\u0300-\u036f]/g, '')
    .replace(/[^a-z0-9\s-]/g, '').trim().replace(/\s+/g, '-')
}

export const articuloQueries = {
  crear: async (datos: { titulo: string; extracto?: string; contenido?: string;
                          imagen?: string; categoriaId: number; estado?: EstadoArticulo; autor?: string }) => {
    const slug = generarSlug(datos.titulo)
    return await prisma.articulo.create({
      data: { ...datos, slug, estado: datos.estado ?? 'borrador', autor: datos.autor ?? 'Administrador' },
      include: { categoria: true },
    })
  },

  actualizar: async (slug: string, cambios: Partial<{ titulo: string; extracto: string;
                                                       contenido: string; imagen: string;
                                                       categoriaId: number; estado: EstadoArticulo }>) => {
    return await prisma.articulo.update({
      where: { slug },
      data: cambios,
      include: { categoria: true },
    })
  },

  eliminar: async (id: number) => {
    return await prisma.articulo.delete({ where: { id } })
  },
}

Manejo de errores de Prisma: cuando una operación viola una restricción de la base de datos, Prisma lanza PrismaClientKnownRequestError con un código específico. El middleware errorHandler convierte esos códigos a respuestas HTTP correctas:

typescript
// src/middlewares/errorHandler.ts
import { PrismaClientKnownRequestError } from '../generated/prisma/runtime/library'
import { ApiError } from '../utils/ApiError'

export function errorHandler(err: unknown, req: Request, res: Response, _next: NextFunction) {
  if (err instanceof ApiError) {
    return res.status(err.statusCode).json({ status: 'error', code: err.statusCode, message: err.message })
  }
  if (err instanceof PrismaClientKnownRequestError) {
    if (err.code === 'P2002') // slug duplicado
      return res.status(409).json({ status: 'error', code: 409, message: 'Ya existe un artículo con ese slug' })
    if (err.code === 'P2025') // no encontrado en update/delete
      return res.status(404).json({ status: 'error', code: 404, message: 'Artículo no encontrado' })
    if (err.code === 'P2003') // categoriaId inexistente
      return res.status(400).json({ status: 'error', code: 400, message: 'La categoría indicada no existe' })
  }
  return res.status(500).json({ status: 'error', code: 500, message: 'Error interno del servidor' })
}

El controller: validación y respuesta HTTP

typescript
// src/controllers/articulosController.ts
import type { Request, Response, NextFunction } from 'express'
import { articulosService } from '../services/articulosService'
import { sendSuccess, sendError } from '../utils/apiResponse'

export const articulosController = {
  crear: async (req: Request, res: Response, next: NextFunction) => {
    try {
      const { titulo, extracto, contenido, imagen, categoriaId, estado, autor } = req.body

      // Validación en la frontera del sistema
      if (!titulo || !categoriaId) {
        return sendError({ res, statusCode: 400, message: 'titulo y categoriaId son obligatorios' })
      }

      const articulo = await articulosService.crear({ titulo, extracto, contenido, imagen, categoriaId, estado, autor })
      return sendSuccess({ res, statusCode: 201, message: 'Artículo creado.', data: articulo })
    } catch (error) { return next(error) }
  },

  actualizar: async (req: Request, res: Response, next: NextFunction) => {
    try {
      const articulo = await articulosService.actualizar(String(req.params.slug), req.body)
      return sendSuccess({ res, message: 'Artículo actualizado.', data: articulo })
    } catch (error) { return next(error) }
  },

  eliminar: async (req: Request, res: Response, next: NextFunction) => {
    try {
      await articulosService.eliminar(Number(req.params.id))
      return res.status(204).send()
    } catch (error) { return next(error) }
  },
}

La ruta: un archivo limpio de solo HTTP

typescript
// src/routes/articulos.routes.ts
import { Router } from "express";
import * as ctrl from "../controllers/articulos.controller";

const router = Router();

router.get("/",       ctrl.listar);
router.get("/:slug",  ctrl.obtener);
router.post("/",      ctrl.crear);
router.put("/:slug",  ctrl.actualizar);
router.delete("/:id", ctrl.eliminar);

export default router;

2. Manejo de errores de PostgreSQL

Cuando el INSERT falla porque el slug ya existe (violación UNIQUE) o porque la categoria_id no existe (violación FK), PostgreSQL lanza un error con un código específico. Si no lo capturas, el servidor devuelve un 500 genérico. El error handler en Express recibe ese error en el último middleware:

typescript
// src/middleware/errorHandler.ts
import type { Request, Response, NextFunction } from "express";

interface PgError extends Error {
  code?: string;    // código de error de PostgreSQL
  detail?: string;  // detalle legible del error
}

export function errorHandler(
  err: PgError,
  _req: Request,
  res: Response,
  _next: NextFunction
) {
  // 23505 = unique_violation (slug duplicado)
  if (err.code === "23505") {
    return res.status(409).json({
      error: "Ya existe un artículo con ese título",
      detail: err.detail,
    });
  }
  // 23503 = foreign_key_violation (categoria_id no existe)
  if (err.code === "23503") {
    return res.status(400).json({
      error: "La categoría indicada no existe",
      detail: err.detail,
    });
  }

  console.error("Error no controlado:", err);
  res.status(500).json({ error: "Error interno del servidor" });
}

Registra el error handler después de todas las rutas en app.ts:

typescript
// src/app.ts — el orden importa
app.use("/api/articulos", articulosRouter);
app.use("/api/upload",    uploadRouter);
app.use(errorHandler);   // ← siempre al final

3. Subida de archivos con multer

Un formulario que envía una imagen no usa Content-Type: application/json — usa multipart/form-data. express.json() no puede procesarlo. multer es el middleware que intercepta ese tipo de petición, extrae el archivo y lo guarda en disco.

bash
cd api
npm install multer
npm install -D @types/multer

Configuración de multer

typescript
// src/middleware/upload.ts
import multer from "multer";
import path from "path";
import crypto from "crypto";
import type { Request } from "express";

// Directorio de uploads configurable por variable de entorno
export const UPLOADS_DIR =
  process.env.UPLOADS_DIR ??
  path.join(__dirname, "../../../frontend/public/uploads");

const storage = multer.diskStorage({
  destination: (_req, _file, cb) => cb(null, UPLOADS_DIR),
  filename: (_req, file, cb) => {
    // Nombre único: timestamp + 10 bytes aleatorios + extensión original
    const ext = path.extname(file.originalname).toLowerCase();
    const nombre = `${Date.now()}-${crypto.randomBytes(10).toString("hex")}${ext}`;
    cb(null, nombre);
  },
});

function fileFilter(
  _req: Request,
  file: Express.Multer.File,
  cb: multer.FileFilterCallback
) {
  const allowed = ["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif"];
  if (allowed.includes(file.mimetype)) {
    cb(null, true);
  } else {
    cb(new Error(`Tipo de archivo no permitido: ${file.mimetype}`));
  }
}

export const upload = multer({
  storage,
  limits: { fileSize: 5 * 1024 * 1024 }, // máximo 5 MB
  fileFilter,
});

Endpoint POST /api/upload

typescript
// src/routes/upload.routes.ts
import { Router } from "express";
import { upload } from "../middleware/upload";
import type { Request, Response, NextFunction } from "express";
import multer from "multer";

const router = Router();

router.post("/", upload.single("imagen"), (req: Request, res: Response) => {
  if (!req.file) {
    return res.status(400).json({ error: "No se recibió ningún archivo" });
  }
  // Solo devuelve el nombre del archivo — el frontend construye la URL completa
  res.json({
    data: { filename: req.file.filename },
    mensaje: "Archivo subido correctamente",
    status: 200,
  });
});

// Errores de multer (tamaño excedido, tipo inválido)
router.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
  if (err instanceof multer.MulterError) {
    return res.status(400).json({ error: `Error de upload: ${err.message}` });
  }
  res.status(400).json({ error: err.message });
});

export default router;

Servir las imágenes como archivos estáticos

typescript
// src/app.ts
import express from "express";
import { UPLOADS_DIR } from "./middleware/upload";

const app = express();

// http://localhost:3001/uploads/nombre.jpg sirve el archivo desde UPLOADS_DIR
app.use("/uploads", express.static(UPLOADS_DIR));

// ... resto de middlewares y rutas

4. Tipos del backend

typescript
// src/types/index.ts
export interface Articulo {
  id: number;
  titulo: string;
  slug: string;
  extracto: string | null;
  contenido: string | null;
  imagen: string | null;
  categoria_id: number;
  autor: string;
  tiempo_lectura: number;
  estado: "borrador" | "publicado" | "archivado";
  fecha_publicacion: string | null;
  created_at: string;
  updated_at: string;
  categoria_nombre?: string;
  categoria_slug?: string;
  categoria_color?: string;
}

export interface NuevoArticulo {
  titulo: string;
  extracto?: string;
  contenido?: string;
  imagen?: string;
  categoria_id: number;
  estado?: "borrador" | "publicado" | "archivado";
  autor?: string;
}

// Partial para que PUT solo requiera los campos que cambian
export type ActualizarArticulo = Partial<
  Omit<NuevoArticulo, "titulo"> & { titulo: string }
>;

export interface ApiResponse<T> {
  data: T;
  status: number;
  mensaje?: string;
}

Actividades prácticas

Actividad 1 — Arquitectura MVC (60 min) Reorganizar el servidor en carpetas routes/, controllers/, models/. Verificar que GET /api/articulos sigue funcionando después de la refactorización.

Actividad 2 — POST con validación (60 min) Implementar POST /api/articulos. Probar con Postman: enviar body sin titulo (debe dar 400), con título duplicado (debe dar 409) y con datos válidos (debe dar 201 con el artículo creado).

Actividad 3 — PUT dinámico (45 min) Implementar PUT /api/articulos/:slug con campos opcionales. Verificar que si solo envías { "estado": "publicado" } solo cambia ese campo.

Actividad 4 — multer upload (60 min) Configurar multer y POST /api/upload. Probar con Postman en modo form-data: subir una imagen .jpg, verificar que aparece en uploads/ con nombre único y que la respuesta incluye filename.


🛠 Proyecto CMS — Semana 8: API CRUD completa + subida de imágenes

📁 Archivos de esta semana

code
backend/src/
├── v1/
│   └── routes/
│       └── articulos.ts                ← NUEVO: rutas versionadas /api/v1/articulos
├── controllers/
│   └── articulosController.ts          ← NUEVO: valida input, llama al service
├── services/
│   └── articulosService.ts             ← NUEVO: lógica de negocio (generarSlug...)
├── database/
│   └── articuloQueries.ts              ← NUEVO: consultas Prisma tipadas (CRUD)
├── middlewares/
│   ├── upload.ts                       ← NUEVO: multer → guarda en frontend/public/uploads/
│   ├── errorHandler.ts                 ← NUEVO: ApiError + Prisma errors → HTTP
│   ├── cacheHeaders.ts                 ← NUEVO: Cache-Control para GET
│   └── requestInfo.ts                  ← NUEVO: log de peticiones (opcional)
├── utils/
│   ├── ApiError.ts                     ← NUEVO: error de negocio con statusCode
│   └── apiResponse.ts                  ← NUEVO: sendSuccess() y sendError()
├── config/
│   └── db.ts                           (semana 7)
└── app.ts                              ← ACTUALIZADO: registra rutas MVC + errorHandler

Flujo de una petición: Request → v1/routes/ → controllers/ → services/ → database/ → PostgreSQL

Regla de respuestas: Usa siempre sendSuccess() y sendError() — nunca res.json() directo. Formato garantizado: { status, code, message, data }.

Upload de imágenes: las imágenes se guardan en frontend/public/uploads/. Configura multer con dest: path.join(...) apuntando a esa carpeta. El endpoint devuelve solo el filename, no la URL completa — resolverImagen() en el frontend construye la URL.

Paso 1 — Reorganizar en MVC

code
api/src/
├── controllers/
│   └── articulos.controller.ts
├── models/
│   └── articulos.model.ts
├── routes/
│   ├── articulos.routes.ts
│   └── upload.routes.ts
├── middleware/
│   ├── upload.ts
│   └── errorHandler.ts
├── db/
│   └── pool.ts
├── types/
│   └── index.ts
├── app.ts
└── server.ts

Mueve las rutas existentes a esta estructura. Ejecuta npm run dev y verifica que GET /api/articulos sigue devolviendo datos.

Paso 2 — Añadir tipos NuevoArticulo y ActualizarArticulo

Actualiza src/types/index.ts con las interfaces de la sección 4. Importa y usa estos tipos en los models.

Paso 3 — Implementar models de escritura

Añade insertarArticulo, actualizarArticulo y eliminarArticulo en articulos.model.ts. Incluye la función generarSlug.

Paso 4 — Controllers y rutas de escritura

Añade crear, actualizar y eliminar al controller. Registra POST /, PUT /:slug y DELETE /:id en las rutas.

Paso 5 — Error handler global

Crea errorHandler.ts con los códigos 23505 y 23503. Regístralo en app.ts después de todas las rutas.

Paso 6 — multer + upload endpoint

Instala multer. Crea middleware/upload.ts con diskStorage y fileFilter. Crea routes/upload.routes.ts. Añade app.use("/uploads", express.static(UPLOADS_DIR)) en app.ts.

Paso 7 — Verificar con curl o Postman

bash
# Crear artículo
curl -X POST http://localhost:3001/api/articulos \
  -H "Content-Type: application/json" \
  -d '{"titulo":"Mi primer post","categoria_id":1,"estado":"borrador"}'

# Editar solo el estado
curl -X PUT http://localhost:3001/api/articulos/mi-primer-post \
  -H "Content-Type: application/json" \
  -d '{"estado":"publicado"}'

# Eliminar
curl -X DELETE http://localhost:3001/api/articulos/1

# Subir imagen (multipart/form-data)
curl -X POST http://localhost:3001/api/upload \
  -F "imagen=@/ruta/a/tu/foto.jpg"

Para la próxima semana (Semana 9): Con la API completa, en la Semana 9 conectarás el panel admin del frontend a estas rutas — formularios de creación y edición conectados a la API, subida de imágenes desde el navegador, confirmación con SweetAlert2 y sincronización del store Zustand.


📋 Entregable de la semana

  • Arquitectura MVC: Código separado en controllers/, models/, routes/ y middleware/. Cada archivo tiene una responsabilidad única.
  • POST /api/articulos: Crea artículo, genera slug automático, devuelve 201. Body sin titulo devuelve 400. Título duplicado devuelve 409.
  • PUT /api/articulos/:slug: Actualiza solo los campos enviados. Slug inexistente devuelve 404.
  • DELETE /api/articulos/:id: Elimina y devuelve 204. ID inexistente devuelve 404.
  • POST /api/upload: Recibe multipart/form-data con campo imagen, guarda el archivo con nombre único y devuelve { data: { filename } }. Archivos no-imagen devuelven 400.
  • Archivos estáticos: Una imagen subida es accesible en http://localhost:3001/uploads/nombre-del-archivo.jpg.
  • Error handler: Los errores de PostgreSQL y de multer devuelven mensajes descriptivos con el código HTTP correcto (no 500 genérico).

📥 Descarga el entregable de esta semana

¿No lograste completar la semana o quieres comparar tu código con la solución? Descarga el estado del proyecto al terminar la Semana 8:

⬇️ Descargar entregable Semana 8 (.zip)

Qué incluye: backend completo en arquitectura MVC de 4 capas — v1/routes/, controllers/, services/, database/ con queries Prisma, middlewares/ (upload, errorHandler, cacheHeaders) y utils/ (ApiError, apiResponse). Todos los endpoints: GET, POST, PUT, DELETE y /upload.

Setup después de descomprimir:

bash
cd blog-cms
npm install
cd backend && pnpm install
# Crea backend/.env con: PORT=3001 y DATABASE_URL="postgresql://..."
npx prisma migrate dev
pnpm seed
cd ..
npm run dev