Tema
Proyecto final: API con auth
Es hora de juntar todo. Construirás TaskFlow API: una API REST de gestión de tareas lista para producción, con registro e inicio de sesión, cada usuario con sus propias tareas, roles, validación, seguridad, tests y despliegue. Es exactamente el tipo de backend que un cliente te pedirá.
Qué vas a construir
| Método | Ruta | Descripción | Auth |
|---|---|---|---|
POST | /api/auth/registro | Crear cuenta | — |
POST | /api/auth/login | Iniciar sesión (cookie httpOnly) | — |
POST | /api/auth/logout | Cerrar sesión | — |
GET | /api/auth/yo | Usuario actual | ✅ |
GET | /api/tareas | Mis tareas (filtros, búsqueda, paginación) | ✅ |
POST | /api/tareas | Crear tarea | ✅ |
GET | /api/tareas/:id | Ver una tarea mía | ✅ |
PATCH | /api/tareas/:id | Editar una tarea mía | ✅ |
DELETE | /api/tareas/:id | Borrar una tarea mía | ✅ |
GET | /api/admin/usuarios | Listar usuarios | ✅ admin |
Tecnologías: Node 22, Express 5, Prisma + PostgreSQL, zod, bcrypt, jsonwebtoken, cookie-parser, helmet, cors, express-rate-limit, Vitest + Supertest.
Estructura
txt
taskflow-api/
├── prisma/
│ └── schema.prisma
├── src/
│ ├── config/env.js
│ ├── db/prisma.js
│ ├── lib/
│ │ ├── HttpError.js
│ │ ├── jwt.js
│ │ └── password.js
│ ├── middlewares/
│ │ ├── auth.js
│ │ ├── validar.js
│ │ └── errores.js
│ ├── modules/
│ │ ├── auth/
│ │ │ ├── auth.schemas.js
│ │ │ ├── auth.service.js
│ │ │ ├── auth.controller.js
│ │ │ └── auth.routes.js
│ │ └── tareas/
│ │ ├── tareas.schemas.js
│ │ ├── tareas.service.js
│ │ ├── tareas.controller.js
│ │ └── tareas.routes.js
│ ├── app.js
│ └── index.js
├── tests/
│ ├── setup.js
│ └── api.test.js
├── .env.example
├── vitest.config.js
└── package.jsonOrganizamos por módulos (features): todo lo de tareas vive junto. Escala mejor que tener carpetas gigantes de controllers/ y routes/.
Paso 1: proyecto y dependencias
bash
mkdir taskflow-api && cd taskflow-api
npm init -y
npm pkg set type=module
npm i express zod bcrypt jsonwebtoken cookie-parser helmet cors express-rate-limit @prisma/client@6
npm i -D prisma@6 vitest supertest
# Base de datos local
docker run -d --name taskflow-db -e POSTGRES_PASSWORD=secreto -e POSTGRES_DB=taskflow -p 5432:5432 postgres:17json
{
"name": "taskflow-api",
"version": "1.0.0",
"type": "module",
"engines": { "node": ">=22" },
"scripts": {
"dev": "node --watch --env-file=.env src/index.js",
"start": "node src/index.js",
"build": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy",
"test": "vitest run"
}
}env
NODE_ENV=development
PORT=3000
DATABASE_URL="postgresql://postgres:secreto@localhost:5432/taskflow"
JWT_SECRET="genera-uno-con-crypto-randomBytes-de-al-menos-32-caracteres"
JWT_EXPIRES_IN=7d
CORS_ORIGIN=http://localhost:5173txt
node_modules/
.env
.env.test
coverage/Copia .env.example como .env y genera el secreto:
bash
node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'))"Paso 2: modelo de datos
txt
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Rol {
USUARIO
ADMIN
}
enum Prioridad {
BAJA
MEDIA
ALTA
}
model Usuario {
id Int @id @default(autoincrement())
nombre String
email String @unique
passwordHash String
rol Rol @default(USUARIO)
tareas Tarea[]
creadoEn DateTime @default(now())
}
model Tarea {
id Int @id @default(autoincrement())
titulo String @db.VarChar(120)
descripcion String?
completada Boolean @default(false)
prioridad Prioridad @default(MEDIA)
vence DateTime?
usuarioId Int
usuario Usuario @relation(fields: [usuarioId], references: [id], onDelete: Cascade)
creadoEn DateTime @default(now())
actualizado DateTime @updatedAt
@@index([usuarioId, completada])
}bash
npx prisma migrate dev --name initPaso 3: configuración y utilidades
js
import { z } from 'zod'
const schema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().min(1),
JWT_SECRET: z.string().min(32),
JWT_EXPIRES_IN: z.string().default('7d'),
CORS_ORIGIN: z.string().default('http://localhost:5173')
})
const r = schema.safeParse(process.env)
if (!r.success) {
console.error('❌ Variables de entorno inválidas:')
r.error.issues.forEach((i) => console.error(` - ${i.path.join('.')}: ${i.message}`))
process.exit(1)
}
export const env = Object.freeze(r.data)
export const esProd = env.NODE_ENV === 'production'js
import { PrismaClient } from '@prisma/client'
import { env } from '../config/env.js'
export const prisma = new PrismaClient({
log: env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error']
})js
export class HttpError extends Error {
constructor(status, mensaje, detalles) {
super(mensaje)
this.status = status
this.detalles = detalles
}
}
export const badRequest = (m, d) => new HttpError(400, m, d)
export const unauthorized = (m = 'No autenticado') => new HttpError(401, m)
export const forbidden = (m = 'Sin permiso') => new HttpError(403, m)
export const notFound = (m = 'No encontrado') => new HttpError(404, m)
export const conflict = (m) => new HttpError(409, m)js
import bcrypt from 'bcrypt'
import { env } from '../config/env.js'
// En tests usamos menos rondas para que sean rápidos
const RONDAS = env.NODE_ENV === 'test' ? 4 : 12
export const hashPassword = (plano) => bcrypt.hash(plano, RONDAS)
export const verificarPassword = (plano, hash) => bcrypt.compare(plano, hash)js
import jwt from 'jsonwebtoken'
import { env } from '../config/env.js'
export function firmarToken(usuario) {
return jwt.sign({ sub: String(usuario.id), rol: usuario.rol }, env.JWT_SECRET, {
expiresIn: env.JWT_EXPIRES_IN,
algorithm: 'HS256'
})
}
export function verificarToken(token) {
return jwt.verify(token, env.JWT_SECRET, { algorithms: ['HS256'] })
}Paso 4: middlewares
js
import { badRequest } from '../lib/HttpError.js'
export const validar = (schemas) => (req, res, next) => {
req.validado = {}
for (const parte of ['params', 'query', 'body']) {
if (!schemas[parte]) continue
const r = schemas[parte].safeParse(req[parte] ?? {})
if (!r.success) {
return next(
badRequest(
'Datos inválidos',
r.error.issues.map((i) => ({ campo: i.path.join('.'), mensaje: i.message }))
)
)
}
req.validado[parte] = r.data
}
next()
}js
import { verificarToken } from '../lib/jwt.js'
import { unauthorized, forbidden } from '../lib/HttpError.js'
export const COOKIE = 'taskflow_token'
export function requiereAuth(req, res, next) {
const header = req.headers.authorization
const token = req.cookies?.[COOKIE] ?? (header?.startsWith('Bearer ') ? header.slice(7) : null)
if (!token) return next(unauthorized())
try {
const { sub, rol } = verificarToken(token)
req.usuario = { id: Number(sub), rol }
next()
} catch {
next(unauthorized('Sesión inválida o expirada'))
}
}
export const requiereRol = (...roles) => (req, res, next) =>
roles.includes(req.usuario?.rol) ? next() : next(forbidden())js
import { Prisma } from '@prisma/client'
import { HttpError } from '../lib/HttpError.js'
import { esProd } from '../config/env.js'
export function noEncontrado(req, res) {
res.status(404).json({ error: `Ruta no encontrada: ${req.method} ${req.originalUrl}` })
}
// eslint-disable-next-line no-unused-vars
export function manejadorErrores(err, req, res, next) {
if (err.type === 'entity.parse.failed') {
return res.status(400).json({ error: 'JSON inválido' })
}
if (err instanceof HttpError) {
return res.status(err.status).json({ error: err.message, ...(err.detalles && { detalles: err.detalles }) })
}
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2002') return res.status(409).json({ error: 'Registro duplicado' })
if (err.code === 'P2025') return res.status(404).json({ error: 'No encontrado' })
}
console.error(err)
res.status(500).json({ error: esProd ? 'Error interno del servidor' : err.message })
}Paso 5: módulo de autenticación
js
import { z } from 'zod'
const email = z.email('Email inválido').transform((e) => e.trim().toLowerCase())
export const registroSchema = z.object({
nombre: z.string().trim().min(2, 'Mínimo 2 caracteres').max(60),
email,
password: z
.string()
.min(8, 'Mínimo 8 caracteres')
.max(72)
.regex(/[A-Z]/, 'Incluye una mayúscula')
.regex(/[0-9]/, 'Incluye un número')
})
export const loginSchema = z.object({
email,
password: z.string().min(1, 'La contraseña es obligatoria')
})js
import { prisma } from '../../db/prisma.js'
import { hashPassword, verificarPassword } from '../../lib/password.js'
import { conflict, unauthorized, notFound } from '../../lib/HttpError.js'
// Campos seguros para devolver (nunca passwordHash)
const publico = { id: true, nombre: true, email: true, rol: true, creadoEn: true }
export async function registrar({ nombre, email, password }) {
const existe = await prisma.usuario.findUnique({ where: { email } })
if (existe) throw conflict('El email ya está registrado')
return prisma.usuario.create({
data: { nombre, email, passwordHash: await hashPassword(password) },
select: publico
})
}
export async function login({ email, password }) {
const usuario = await prisma.usuario.findUnique({ where: { email } })
const ok = usuario && (await verificarPassword(password, usuario.passwordHash))
if (!ok) throw unauthorized('Credenciales incorrectas')
const { passwordHash, ...resto } = usuario
return resto
}
export async function obtenerPorId(id) {
const usuario = await prisma.usuario.findUnique({ where: { id }, select: publico })
if (!usuario) throw notFound('Usuario no encontrado')
return usuario
}
export function listarUsuarios() {
return prisma.usuario.findMany({
select: { ...publico, _count: { select: { tareas: true } } },
orderBy: { creadoEn: 'desc' }
})
}js
import * as servicio from './auth.service.js'
import { firmarToken } from '../../lib/jwt.js'
import { COOKIE } from '../../middlewares/auth.js'
import { esProd } from '../../config/env.js'
const cookieOpciones = {
httpOnly: true,
secure: esProd,
sameSite: esProd ? 'none' : 'lax', // 'none' si el frontend está en otro dominio (requiere secure)
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/'
}
function iniciarSesion(res, usuario) {
res.cookie(COOKIE, firmarToken(usuario), cookieOpciones)
}
export async function registro(req, res) {
const usuario = await servicio.registrar(req.validado.body)
iniciarSesion(res, usuario)
res.status(201).json({ usuario })
}
export async function login(req, res) {
const usuario = await servicio.login(req.validado.body)
iniciarSesion(res, usuario)
res.json({ usuario })
}
export function logout(req, res) {
const { maxAge, ...opciones } = cookieOpciones
res.clearCookie(COOKIE, opciones)
res.sendStatus(204)
}
export async function yo(req, res) {
res.json({ usuario: await servicio.obtenerPorId(req.usuario.id) })
}
export async function listarUsuarios(req, res) {
res.json(await servicio.listarUsuarios())
}js
import { Router } from 'express'
import { rateLimit } from 'express-rate-limit'
import * as ctrl from './auth.controller.js'
import { validar } from '../../middlewares/validar.js'
import { requiereAuth } from '../../middlewares/auth.js'
import { registroSchema, loginSchema } from './auth.schemas.js'
import { env } from '../../config/env.js'
const limiteAuth = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 10,
skipSuccessfulRequests: true,
skip: () => env.NODE_ENV === 'test',
message: { error: 'Demasiados intentos, espera 15 minutos' }
})
const router = Router()
router.post('/registro', limiteAuth, validar({ body: registroSchema }), ctrl.registro)
router.post('/login', limiteAuth, validar({ body: loginSchema }), ctrl.login)
router.post('/logout', ctrl.logout)
router.get('/yo', requiereAuth, ctrl.yo)
export default routerPaso 6: módulo de tareas
La clave de seguridad: toda consulta filtra por usuarioId. Así un usuario nunca puede ver ni modificar tareas ajenas (evita IDOR).
js
import { z } from 'zod'
const prioridad = z.enum(['BAJA', 'MEDIA', 'ALTA'])
export const crearTareaSchema = z.object({
titulo: z.string().trim().min(1, 'El título es obligatorio').max(120),
descripcion: z.string().trim().max(2000).optional(),
prioridad: prioridad.default('MEDIA'),
vence: z.coerce.date().optional()
})
export const actualizarTareaSchema = crearTareaSchema
.partial()
.extend({ completada: z.boolean().optional() })
.refine((d) => Object.keys(d).length > 0, 'Envía al menos un campo')
export const idSchema = z.object({ id: z.coerce.number().int().positive() })
export const listarSchema = z.object({
pagina: z.coerce.number().int().min(1).default(1),
limite: z.coerce.number().int().min(1).max(50).default(10),
estado: z.enum(['pendientes', 'completadas', 'todas']).default('todas'),
prioridad: prioridad.optional(),
q: z.string().trim().max(100).optional()
})js
import { prisma } from '../../db/prisma.js'
import { notFound } from '../../lib/HttpError.js'
export async function listar(usuarioId, { pagina, limite, estado, prioridad, q }) {
const where = {
usuarioId,
...(estado !== 'todas' && { completada: estado === 'completadas' }),
...(prioridad && { prioridad }),
...(q && { titulo: { contains: q, mode: 'insensitive' } })
}
const [datos, total] = await prisma.$transaction([
prisma.tarea.findMany({
where,
orderBy: [{ completada: 'asc' }, { vence: 'asc' }, { creadoEn: 'desc' }],
skip: (pagina - 1) * limite,
take: limite
}),
prisma.tarea.count({ where })
])
return { datos, paginacion: { pagina, limite, total, paginas: Math.ceil(total / limite) } }
}
export async function obtener(usuarioId, id) {
const tarea = await prisma.tarea.findFirst({ where: { id, usuarioId } })
if (!tarea) throw notFound('Tarea no encontrada')
return tarea
}
export function crear(usuarioId, datos) {
return prisma.tarea.create({ data: { ...datos, usuarioId } })
}
export async function actualizar(usuarioId, id, cambios) {
await obtener(usuarioId, id) // 404 si no existe o no es suya
return prisma.tarea.update({ where: { id }, data: cambios })
}
export async function eliminar(usuarioId, id) {
const { count } = await prisma.tarea.deleteMany({ where: { id, usuarioId } })
if (count === 0) throw notFound('Tarea no encontrada')
}js
import * as servicio from './tareas.service.js'
export const listar = async (req, res) =>
res.json(await servicio.listar(req.usuario.id, req.validado.query))
export const obtener = async (req, res) =>
res.json(await servicio.obtener(req.usuario.id, req.validado.params.id))
export const crear = async (req, res) =>
res.status(201).json(await servicio.crear(req.usuario.id, req.validado.body))
export const actualizar = async (req, res) =>
res.json(await servicio.actualizar(req.usuario.id, req.validado.params.id, req.validado.body))
export const eliminar = async (req, res) => {
await servicio.eliminar(req.usuario.id, req.validado.params.id)
res.sendStatus(204)
}js
import { Router } from 'express'
import * as ctrl from './tareas.controller.js'
import { validar } from '../../middlewares/validar.js'
import { requiereAuth } from '../../middlewares/auth.js'
import { crearTareaSchema, actualizarTareaSchema, idSchema, listarSchema } from './tareas.schemas.js'
const router = Router()
router.use(requiereAuth) // todas las rutas de tareas requieren sesión
router.get('/', validar({ query: listarSchema }), ctrl.listar)
router.post('/', validar({ body: crearTareaSchema }), ctrl.crear)
router.get('/:id', validar({ params: idSchema }), ctrl.obtener)
router.patch('/:id', validar({ params: idSchema, body: actualizarTareaSchema }), ctrl.actualizar)
router.delete('/:id', validar({ params: idSchema }), ctrl.eliminar)
export default routerPaso 7: la aplicación
js
import express from 'express'
import helmet from 'helmet'
import cors from 'cors'
import cookieParser from 'cookie-parser'
import { rateLimit } from 'express-rate-limit'
import { env } from './config/env.js'
import authRoutes from './modules/auth/auth.routes.js'
import tareasRoutes from './modules/tareas/tareas.routes.js'
import { listarUsuarios } from './modules/auth/auth.controller.js'
import { requiereAuth, requiereRol } from './middlewares/auth.js'
import { noEncontrado, manejadorErrores } from './middlewares/errores.js'
const app = express()
app.set('trust proxy', 1)
app.use(helmet())
app.use(cors({ origin: env.CORS_ORIGIN.split(','), credentials: true }))
app.use(
'/api',
rateLimit({ windowMs: 15 * 60 * 1000, limit: 500, skip: () => env.NODE_ENV === 'test' })
)
app.use(express.json({ limit: '50kb' }))
app.use(cookieParser())
app.get('/api/salud', (req, res) => res.json({ ok: true, uptime: process.uptime() }))
app.use('/api/auth', authRoutes)
app.use('/api/tareas', tareasRoutes)
app.get('/api/admin/usuarios', requiereAuth, requiereRol('ADMIN'), listarUsuarios)
app.use(noEncontrado)
app.use(manejadorErrores)
export default appjs
import { env } from './config/env.js'
import app from './app.js'
import { prisma } from './db/prisma.js'
const servidor = app.listen(env.PORT, () => {
console.log(`🚀 TaskFlow API [${env.NODE_ENV}] en http://localhost:${env.PORT}/api`)
})
async function apagar(senal) {
console.log(`${senal}: cerrando servidor...`)
servidor.close(async () => {
await prisma.$disconnect()
process.exit(0)
})
setTimeout(() => process.exit(1), 10_000).unref()
}
process.on('SIGTERM', apagar)
process.on('SIGINT', apagar)bash
npm run devPaso 8: tests de integración
Crea una base de datos de pruebas y un .env.test:
bash
docker exec -it taskflow-db psql -U postgres -c "CREATE DATABASE taskflow_test;"env
NODE_ENV=test
DATABASE_URL="postgresql://postgres:secreto@localhost:5432/taskflow_test"
JWT_SECRET="secreto-de-pruebas-con-mas-de-32-caracteres-ok"js
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
setupFiles: ['./tests/setup.js'],
fileParallelism: false
}
})js
import { execSync } from 'node:child_process'
import { beforeAll, beforeEach, afterAll } from 'vitest'
process.loadEnvFile('.env.test')
// Importamos prisma DESPUÉS de cargar las variables
const { prisma } = await import('../src/db/prisma.js')
beforeAll(() => {
// Aplica las migraciones a la base de pruebas
execSync('npx prisma migrate deploy', { stdio: 'ignore', env: process.env })
})
beforeEach(async () => {
await prisma.tarea.deleteMany()
await prisma.usuario.deleteMany()
})
afterAll(async () => {
await prisma.$disconnect()
})js
import { describe, it, expect } from 'vitest'
import request from 'supertest'
const { default: app } = await import('../src/app.js')
async function crearSesion(email = 'ana@test.com') {
const agente = request.agent(app)
await agente
.post('/api/auth/registro')
.send({ nombre: 'Ana', email, password: 'Secreta123' })
.expect(201)
return agente
}
describe('Auth', () => {
it('registra, devuelve el usuario sin hash y crea la cookie', async () => {
const res = await request(app)
.post('/api/auth/registro')
.send({ nombre: 'Ana', email: 'ANA@test.com', password: 'Secreta123' })
expect(res.status).toBe(201)
expect(res.body.usuario).toMatchObject({ email: 'ana@test.com', rol: 'USUARIO' })
expect(res.body.usuario.passwordHash).toBeUndefined()
expect(res.headers['set-cookie'][0]).toMatch(/HttpOnly/)
})
it('rechaza email duplicado con 409', async () => {
await crearSesion()
const res = await request(app)
.post('/api/auth/registro')
.send({ nombre: 'Otra', email: 'ana@test.com', password: 'Secreta123' })
expect(res.status).toBe(409)
})
it('rechaza credenciales incorrectas con 401', async () => {
await crearSesion()
await request(app)
.post('/api/auth/login')
.send({ email: 'ana@test.com', password: 'Incorrecta1' })
.expect(401)
})
it('protege /yo', async () => {
await request(app).get('/api/auth/yo').expect(401)
const agente = await crearSesion()
const res = await agente.get('/api/auth/yo').expect(200)
expect(res.body.usuario.nombre).toBe('Ana')
})
})
describe('Tareas', () => {
it('CRUD completo de una tarea propia', async () => {
const agente = await crearSesion()
const creada = await agente
.post('/api/tareas')
.send({ titulo: 'Entregar landing', prioridad: 'ALTA' })
.expect(201)
const id = creada.body.id
const lista = await agente.get('/api/tareas').expect(200)
expect(lista.body.datos).toHaveLength(1)
expect(lista.body.paginacion.total).toBe(1)
const editada = await agente.patch(`/api/tareas/${id}`).send({ completada: true }).expect(200)
expect(editada.body.completada).toBe(true)
await agente.delete(`/api/tareas/${id}`).expect(204)
await agente.get(`/api/tareas/${id}`).expect(404)
})
it('valida los datos de entrada', async () => {
const agente = await crearSesion()
const res = await agente.post('/api/tareas').send({ titulo: '' }).expect(400)
expect(res.body.detalles[0].campo).toBe('titulo')
})
it('un usuario NO puede ver ni borrar tareas de otro', async () => {
const ana = await crearSesion('ana@test.com')
const luis = await crearSesion('luis@test.com')
const { body: tarea } = await ana.post('/api/tareas').send({ titulo: 'Privada' }).expect(201)
await luis.get(`/api/tareas/${tarea.id}`).expect(404)
await luis.delete(`/api/tareas/${tarea.id}`).expect(404)
const lista = await luis.get('/api/tareas').expect(200)
expect(lista.body.datos).toHaveLength(0)
})
it('filtra por estado', async () => {
const agente = await crearSesion()
const { body: t1 } = await agente.post('/api/tareas').send({ titulo: 'Una' })
await agente.post('/api/tareas').send({ titulo: 'Dos' })
await agente.patch(`/api/tareas/${t1.id}`).send({ completada: true })
const res = await agente.get('/api/tareas?estado=pendientes').expect(200)
expect(res.body.datos.map((t) => t.titulo)).toEqual(['Dos'])
})
})¿Por qué await import(...)?
src/config/env.js valida las variables al importarse. Con imports dinámicos garantizamos que .env.test ya se cargó en setup.js antes de que se evalúe la app.
bash
npm testPaso 9: crear un administrador
js
// scripts/crear-admin.js
import { prisma } from '../src/db/prisma.js'
import { hashPassword } from '../src/lib/password.js'
const [, , email, password, nombre = 'Admin'] = process.argv
if (!email || !password) {
console.error('Uso: node --env-file=.env scripts/crear-admin.js <email> <password> [nombre]')
process.exit(1)
}
const admin = await prisma.usuario.upsert({
where: { email: email.toLowerCase() },
update: { rol: 'ADMIN' },
create: { email: email.toLowerCase(), nombre, rol: 'ADMIN', passwordHash: await hashPassword(password) }
})
console.log(`✅ ${admin.email} es ADMIN`)
await prisma.$disconnect()bash
node --env-file=.env scripts/crear-admin.js admin@taskflow.com "SuperSecreta123"Paso 10: desplegar
Sigue la lección de despliegue. En Render:
- Build:
npm ci && npm run build - Start:
npm run db:deploy && npm start - Variables:
NODE_ENV=production,DATABASE_URL,JWT_SECRET(generado),CORS_ORIGIN=https://tu-frontend.com - Health check:
/api/salud
Si el frontend está en otro dominio, la cookie usa sameSite: 'none' y secure: true (ya configurado para producción) y el frontend debe hacer fetch con credentials: 'include'.
Checklist profesional antes de entregar
- [ ] Tests en verde y GitHub Actions ejecutándolos.
- [ ]
.env.examplecompleto y README con instrucciones de instalación. - [ ] Documentación de endpoints (un archivo
api.http, una colección de Postman o OpenAPI/Swagger). - [ ] Logs de errores (Sentry) y monitoreo de
/api/salud. - [ ] Respaldos automáticos de la base de datos.
Retos para llevarlo más lejos
- Agrega etiquetas a las tareas (relación muchos a muchos
Tarea↔Etiqueta). - Implementa refresh tokens con rotación (lección 14).
- Agrega recuperación de contraseña por email (token de un solo uso con expiración, enviado con Resend o Nodemailer).
- Documenta la API con OpenAPI (
swagger-ui-express).
Ver solución del reto 1 (modelo y creación)
txt
// prisma/schema.prisma (agregar)
model Etiqueta {
id Int @id @default(autoincrement())
nombre String
usuarioId Int
tareas Tarea[]
@@unique([usuarioId, nombre])
}
// y en model Tarea:
// etiquetas Etiqueta[]js
// tareas.schemas.js: agregar al crearTareaSchema
// etiquetas: z.array(z.string().trim().min(1).max(30)).max(10).optional()
// tareas.service.js
export function crear(usuarioId, { etiquetas = [], ...datos }) {
return prisma.tarea.create({
data: {
...datos,
usuarioId,
etiquetas: {
connectOrCreate: etiquetas.map((nombre) => ({
where: { usuarioId_nombre: { usuarioId, nombre } },
create: { nombre, usuarioId }
}))
}
},
include: { etiquetas: true }
})
}Después ejecuta npx prisma migrate dev --name etiquetas.
Resumen
- Una API profesional se organiza por módulos con capas de rutas, controladores y servicios.
- Validación (zod), errores centralizados, seguridad (helmet, CORS, rate limit) y auth con cookies httpOnly.
- Toda consulta de datos privados filtra por el usuario autenticado.
- Tests de integración con una base de datos aislada y despliegue con migraciones automáticas.
- ¡Felicidades! Ya puedes construir el backend completo de un proyecto real. Continúa con React o Next.js para crear su frontend.