Skip to content

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étodoRutaDescripciónAuth
POST/api/auth/registroCrear cuenta—
POST/api/auth/loginIniciar sesión (cookie httpOnly)—
POST/api/auth/logoutCerrar sesión—
GET/api/auth/yoUsuario actual✅
GET/api/tareasMis tareas (filtros, búsqueda, paginación)✅
POST/api/tareasCrear tarea✅
GET/api/tareas/:idVer una tarea mía✅
PATCH/api/tareas/:idEditar una tarea mía✅
DELETE/api/tareas/:idBorrar una tarea mía✅
GET/api/admin/usuariosListar 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.json

Organizamos 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:17
json
{
  "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:5173
txt
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 init

Paso 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 router

Paso 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 router

Paso 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 app
js
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 dev

Paso 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 test

Paso 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.example completo 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 ​

  1. Agrega etiquetas a las tareas (relación muchos a muchos Tarea ↔ Etiqueta).
  2. Implementa refresh tokens con rotación (lección 14).
  3. Agrega recuperación de contraseña por email (token de un solo uso con expiración, enviado con Resend o Nodemailer).
  4. 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.

Academia Web — aprende, copia y construye.