Módulo 25: Proyecto final

Objetivos del módulo

  • Construir de principio a fin la aplicación EMPRESA full stack: una tienda pública rápida y en caché, y una zona privada de gestión con inicio de sesión y roles
  • Juntar en un solo proyecto lo que los módulos anteriores enseñaron por separado: base de datos con Drizzle (módulo 16), sesión propia con jose (módulo 17), Cache Components (módulo 09), Server Actions con Zod (módulos 10 y 11), Route Handlers (módulo 12), metadatos (módulo 07), errores (módulo 14), pruebas (módulo 21) y Docker (módulo 23)
  • Decidir con criterio qué va en caché, qué se calcula en cada petición y qué etiqueta renueva cada escritura
  • Escribir Server Actions completas: autorización, validación, traducción de errores de la base de datos, renovación de la caché y redirect final
  • Comprobar el resultado con pruebas de la capa de datos sobre SQLite en memoria y pruebas E2E con Playwright contra el build de producción
  • Empaquetar la aplicación en una imagen Docker con output: 'standalone'
  • Saber, con datos comprobados, qué cambia si tu proyecto sigue en Next.js 15

🔄 Cambia en Next.js 16.0. El proyecto se apoya en Cache Components, updateTag, proxy.ts y ensureStatic, que no existen en Next.js 15. Si usas Next.js 15: el apartado 11 recoge, comprobado en Next.js 15.5.28, cada cambio (unstable_cache, revalidateTag, middleware.ts, reset) y lo que se pierde: el armazón estático de la zona de gestión.

💡 Consejo: Todo el código de este módulo es el del proyecto descargable recursos/empresa-nextjs.zip. Se ha comprobado descomprimiéndolo en una carpeta vacía, con una instalación real (next@16.4 resolvió a 16.4.4, el último parche), y ejecutando npm run db:preparar, npm run lint, npm run build, las 19 pruebas de Vitest, las 13 pruebas de Playwright y la imagen Docker. Las salidas que ves son las reales. La versión para Next.js 15 se ha compilado y probado en Next.js 15.5.28. Instala siempre el último parche de Next.js 16.4.

📘 Concepto: Este módulo no presenta APIs nuevas: cada pieza se explicó en su módulo, y aquí se enlaza. Lo nuevo es cómo encajan. Muchas decisiones que en un ejemplo aislado no importan (qué etiqueta de caché caduca una acción, si una página lee la sesión arriba o dentro de <Suspense>, qué pasa con un <select> cuando el formulario se reinicia) aparecen en cuanto hay varias pantallas que comparten datos. Esas decisiones son el tema de este módulo.


1. Qué vas a construir

1.1. Las pantallas

La aplicación tiene dos partes con necesidades opuestas:

  • La tienda la ve cualquiera, sin iniciar sesión. Debe cargar al instante y aguantar muchas visitas: se prerenderiza en el build y se sirve desde la caché.
  • La gestión solo la ven los empleados que inician sesión. Cada usuario ve datos distintos según su rol, y los datos cambian constantemente: se genera en cada petición, con un armazón estático para que la navegación sea rápida.
Ruta Quién Qué hace Tipo en el build
/ Todos Catálogo agrupado por categoría ○ estática, 1h
/productos/[id] Todos Ficha del producto con disponibilidad en tiempo real ◐ armazón en caché y stock dinámico
/api/productos Todos El catálogo en JSON ○ en caché
/api/salud Docker Comprueba que la base de datos responde ƒ
/entrar Todos Formulario de inicio de sesión ◐
/gestion ADMIN, USER Panel con el resumen de pedidos ◐
/gestion/empleados ADMIN, USER Lista; ADMIN puede editar y borrar ◐
/gestion/empleados/nuevo y /[id] ADMIN Alta y edición ◐
/gestion/departamentos y /[id] ADMIN, USER Lista, alta, edición y baja ◐
/gestion/productos ADMIN, USER Precio, stock y descatalogado (solo ADMIN edita) ◐
/gestion/pedidos, /nuevo y /[id] ADMIN, USER Lista, alta y cambio de estado ◐

Las reglas de autorización son sencillas: ADMIN puede todo; USER consulta empleados, departamentos y productos, y gestiona pedidos. Los usuarios de prueba son los del módulo 17, Ana García (ADMIN) y Luis Martínez (USER), con la contraseña secreto en ambos para que te resulte cómodo probar.

Catálogo de la tienda con el título Catálogo, el texto 19 productos disponibles y tarjetas de productos agrupadas por categorías como Audio, Electrónica e Informática

Figura 1. La portada de la tienda: estática, servida desde la caché y renovada solo cuando un administrador cambia un producto.

1.2. La arquitectura

Diagrama de capas: el navegador con la cookie de sesión; a la izquierda la tienda pública con ensureStatic y use cache; a la derecha el proxy optimista y la zona de gestión con Suspense y acciones; debajo, la capa de servidor con la DAL y lib/db; al fondo, empresa.db

Figura 2. Las dos mitades de la aplicación comparten la capa de servidor y la base de datos, pero no la estrategia de caché.

Tres ideas sostienen todo el diseño:

  1. La tienda no lee nunca la sesión. El layout raíz no llama a cookies(), y las páginas de la tienda tampoco. Si una sola lo hiciera, dejaría de poder servirse desde la caché. Para que nadie lo rompa sin darse cuenta, ensureStatic convierte esa regla en un error del build (apartado 5).
  2. Toda lectura de la base de datos pasa por lib/db, y cada función declara cómo se comporta: con 'use cache' y una etiqueta, o con await connection() si el dato debe ser del momento. Es la regla práctica del módulo 16.
  3. Toda escritura es una Server Action con la misma forma: quién llama, qué envía, escribir, renovar la caché, redirigir. La verás repetida en cuatro pantallas (apartado 6).

1.3. De dónde sale cada pieza

Pieza Fichero Módulo donde se explicó
Esquema, migraciones y carga de datos db/schema.ts, drizzle/, scripts/cargar-datos.ts 16 (recurso empresa-db.zip)
Cliente de la base de datos y errores de SQLite lib/db/index.ts, lib/db/errores.ts 16, apartados 7 y 8
Catálogo, departamentos y pedidos lib/db/catalogo.ts, departamentos.ts, pedidos.ts 16, apartados 8 a 10 y ejercicio 5
Usuarios, contraseñas, sesión, DAL y proxy lib/claves.ts, lib/sesion.ts, lib/dal/auth.ts, proxy.ts 17
ensureStatic, 'use cache', updateTag Tienda y acciones 09
Formularios con useActionState y redirect app/gestion/** 10 y 11
Imágenes Open Graph, sitemap, robots app/opengraph-image.tsx… 07
error.tsx, not-found.tsx, forbidden.tsx app/ 14
Arreglo de la regla de Tailwind next.config.ts 06
Vitest y Playwright tests/, e2e/ 21
Dockerfile con standalone Dockerfile 23

Lo que es nuevo en este módulo: la edición de empleados, departamentos y productos, el alta de pedidos desde la interfaz, los avisos en la URL, la ficha con stock dinámico, las imágenes Open Graph por producto y la imagen Docker con la base de datos en un volumen.


2. Puesta en marcha

2.1. Descargar e instalar

Descarga recursos/empresa-nextjs.zip, descomprímelo y entra en la carpeta:

cd empresa-nextjs
npm install

El package.json fija next en 16.4.4, el último parche de la 16.4 cuando se escribió este módulo. En el equipo de pruebas, la instalación desde cero (420 paquetes) tardó casi siete minutos:

added 420 packages in 7m

💡 Consejo: Si npm install se queda colgado (pasa en equipos con poca memoria o con el antivirus revisando cada fichero), interrúmpelo y prueba con pnpm, que no necesitas instalar: npx pnpm install. Antes de cada curso o proyecto nuevo, comprueba el último parche con npm view next@16.4 version y actualiza el package.json (next y eslint-config-next deben ir a la par).

2.2. Variables de entorno

El proyecto trae un .env.example:

# Copia este fichero como .env.local y cambia el secreto:
#   node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
SESSION_SECRET=cambia-esto-por-32-caracteres-aleatorios-o-mas
DATABASE_URL=file:empresa.db
URL_PUBLICA=http://localhost:3001

Cópialo como .env.local y genera un secreto de verdad con la orden del comentario. URL_PUBLICA es la dirección con la que la aplicación se ve desde fuera: la usan los metadatos (metadataBase), el sitemap.xml y el robots.txt para escribir URL absolutas. El puerto 3001 es el que usa el curso desde el módulo 08, para no chocar con la API EMPRESA del 3000.

2.3. package.json

{
  "name": "empresa-nextjs",
  "version": "1.0.0",
  "private": true,
  "description": "Aplicación EMPRESA full stack: proyecto final del Curso de Next.js",
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint",
    "db:generar": "drizzle-kit generate",
    "db:migrar": "drizzle-kit migrate",
    "db:cargar": "tsx scripts/cargar-datos.ts",
    "db:usuarios": "tsx scripts/crear-usuarios.ts",
    "db:comprobar": "tsx scripts/comprobar.ts",
    "db:preparar": "drizzle-kit migrate && tsx scripts/cargar-datos.ts && tsx scripts/crear-usuarios.ts",
    "test": "vitest",
    "test:run": "vitest run",
    "test:e2e": "playwright test"
  },
  "dependencies": {
    "@libsql/client": "^0.18.0",
    "drizzle-orm": "^0.45.4",
    "jose": "^6.2.15",
    "next": "16.4.4",
    "react": "19.3.0",
    "react-dom": "19.3.0",
    "server-only": "^0.0.1",
    "zod": "^4.6.7"
  },
  "devDependencies": {
    "@playwright/test": "^1.64.1",
    "@tailwindcss/turbopack": "^4",
    "@types/node": "^24",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "drizzle-kit": "^0.31.11",
    "eslint": "^9",
    "eslint-config-next": "16.4.4",
    "tailwindcss": "^4",
    "tsx": "^4.23.15",
    "typescript": "^5",
    "vitest": "^5.0.5"
  }
}

Fíjate en lo que no está: ni Better Auth (la sesión es la propia del módulo 17), ni jsdom ni Testing Library (las pruebas de este proyecto son de servidor), ni @vitejs/plugin-react. Cada dependencia que no usas es tiempo de instalación, superficie de ataque y un aviso más de npm audit. @types/node@24 es obligatorio: con el ^20 de la plantilla, Vitest 5 no se instala (módulo 21).

2.4. next.config.ts

import type { NextConfig } from 'next'

const tailwind = '@tailwindcss/turbopack'
const esModulo = { path: /\.module\.css$/ }

const nextConfig: NextConfig = {
  output: 'standalone', // carpeta autosuficiente para Docker (módulo 23)
  cacheComponents: true,
  partialPrefetching: true,
  experimental: {
    authInterrupts: true, // forbidden() y unauthorized() (módulos 14 y 17)
  },
  turbopack: {
    rules: {
      // Arreglo A del módulo 06: la regla de la plantilla convertiría
      // los *.module.css en CSS global sin avisar
      '*.css': [
        { condition: { not: esModulo }, loaders: [tailwind], as: '*.css' },
        { condition: esModulo, loaders: [tailwind], as: '*.module.css' },
      ],
    },
  },
}

export default nextConfig
Opción Por qué
output: 'standalone' La imagen Docker del apartado 9 solo copia lo imprescindible
cacheComponents y partialPrefetching El modelo de caché del curso; la plantilla 16.4 ya las trae
experimental.authInterrupts Activa forbidden(), que usa exigirRol('ADMIN')
turbopack.rules La regla *.css de create-next-app 16.4 pasa también los *.module.css por Tailwind y los convierte en CSS global: el import devuelve {} sin ningún error (módulo 06). Este proyecto solo usa Tailwind, pero si mañana alguien añade un CSS Module, funcionará

2.5. Estructura de carpetas

empresa-nextjs/
├── app/
│   ├── layout.tsx, globals.css         layout raíz (sin sesión) y tema de Tailwind
│   ├── not-found.tsx, forbidden.tsx    404 y 403
│   ├── opengraph-image.tsx             imagen para compartir la tienda
│   ├── robots.ts, sitemap.ts
│   ├── (tienda)/                       grupo de rutas públicas: ensureStatic
│   │   ├── layout.tsx, page.tsx        portada
│   │   └── productos/[id]/             ficha y su imagen Open Graph
│   ├── api/productos/route.ts          catálogo en JSON
│   ├── api/salud/route.ts              comprobación de salud
│   ├── entrar/                         login: página, formulario y acciones
│   └── gestion/                        zona privada
│       ├── layout.tsx, page.tsx, error.tsx
│       ├── Aviso.tsx, comun.ts         piezas compartidas por las pantallas
│       ├── empleados/  departamentos/  productos/  pedidos/
├── db/schema.ts                        10 tablas (9 del módulo 16 + usuarios)
├── drizzle/                            migraciones 0000 y 0001
├── lib/
│   ├── db/                             capa de acceso a datos
│   ├── dal/auth.ts                     quién es el usuario y qué puede hacer
│   ├── sesion.ts, claves.ts            JWT y contraseñas
│   ├── validacion.ts                   esquemas de Zod
│   ├── avisos.ts, formato.ts
├── scripts/                            cargar-datos, crear-usuarios, comprobar
├── tests/                              Vitest (servidor)
├── e2e/                                Playwright
├── proxy.ts
├── Dockerfile, .dockerignore
├── vitest.config.mts, playwright.config.ts
└── next.config.ts, package.json, tsconfig.json, eslint.config.mjs

Dos convenciones de carpetas que conviene repasar (módulo 04):

  • (tienda) es un grupo: los paréntesis hacen que no forme parte de la URL. app/(tienda)/page.tsx es /, y app/(tienda)/productos/[id]/page.tsx es /productos/3. El grupo existe para tener un layout propio de la tienda, con su garantía ensureStatic, sin afectar a /gestion.
  • Ficheros que no son rutas (Aviso.tsx, comun.ts, FormularioEmpleado.tsx) viven junto a las páginas que los usan. Solo page.tsx y route.ts crean URL, así que no hace falta una carpeta privada _componentes.

2.6. La base de datos

El esquema es el del recurso empresa-db.zip del módulo 16 con la tabla usuarios del módulo 17 añadida al final de db/schema.ts. No se repite aquí el esquema de las nueve tablas (está completo en el apartado 3.7 del módulo 16); esto es lo que se añade:

// db/schema.ts (al final del fichero del módulo 16)

// ---------------------------------------------------------------------------
// Usuarios de la aplicación (añadido en el módulo 17)
// ---------------------------------------------------------------------------

export const ROLES = ['ADMIN', 'USER'] as const
export type Rol = (typeof ROLES)[number]

export const usuarios = sqliteTable(
  'usuarios',
  {
    usuarioId: integer('usuario_id').primaryKey(),
    email: text('email').notNull().unique(),
    nombre: text('nombre').notNull(),
    hashClave: text('hash_clave').notNull(), // nunca la contraseña en claro
    rol: text('rol', { enum: ROLES }).notNull().default('USER'),
    empId: integer('emp_id').references(() => empleados.empId, { onDelete: 'set null' }),
    versionSesion: integer('version_sesion').notNull().default(1),
    creadoEn: integer('creado_en', { mode: 'timestamp' })
      .notNull()
      .default(sql`(unixepoch())`),
  },
  (t) => [check('usuarios_rol_valido', sql`${t.rol} in ('ADMIN', 'USER')`)],
)

export const usuariosRelaciones = relations(usuarios, ({ one }) => ({
  empleado: one(empleados, { fields: [usuarios.empId], references: [empleados.empId] }),
}))

export type Usuario = typeof usuarios.$inferSelect
export type NuevoUsuario = typeof usuarios.$inferInsert

La migración se generó con npx drizzle-kit generate --name usuarios y es la misma drizzle/0001_usuarios.sql del módulo 17. El script que crea los usuarios de prueba es el del módulo 17 con la contraseña común:

// scripts/crear-usuarios.ts
// Crea (o vuelve a crear) los usuarios de prueba con sus contraseñas cifradas.
// Ejecútalo DESPUÉS de db:cargar (que vacía empleados).
//   npm run db:usuarios
import { createClient } from '@libsql/client'
import { drizzle } from 'drizzle-orm/libsql'
import * as schema from '../db/schema'
import { cifrarClave } from '../lib/claves'

const url = process.env.DATABASE_URL ?? 'file:empresa.db'
const cliente = createClient({ url })
const db = drizzle(cliente, { schema })

// Contraseña de prueba común: "secreto". Nunca en producción.
const USUARIOS = [
  { email: 'ana.garcia@empresa.es', nombre: 'Ana García', clave: 'secreto', rol: 'ADMIN', empId: 100 },
  { email: 'luis.martinez@empresa.es', nombre: 'Luis Martínez', clave: 'secreto', rol: 'USER', empId: 101 },
] as const

async function crear() {
  const filas = await Promise.all(
    USUARIOS.map(async ({ clave, ...resto }) => ({ ...resto, hashClave: await cifrarClave(clave) })),
  )
  await db.transaction(async (tx) => {
    await tx.delete(schema.usuarios)
    await tx.insert(schema.usuarios).values(filas)
  })
  const creados = await db
    .select({ email: schema.usuarios.email, rol: schema.usuarios.rol })
    .from(schema.usuarios)
  for (const u of creados) console.log(`  ${u.rol.padEnd(5)} ${u.email}`)
}

crear()
  .catch((error) => {
    console.error(error)
    process.exitCode = 1
  })
  .finally(() => cliente.close())

db:preparar encadena las tres cosas en el orden correcto: migrar, cargar datos.json y crear los usuarios (que dependen de que existan los empleados 100 y 101). La salida real en la carpeta limpia:

[✓] migrations applied successfully!Cargando datos en file:empresa.db
  departamentos    7 filas
  empleados       24 filas
  clientes        14 filas
  categorias      11 filas
  productos       20 filas
  pedidos         24 filas
  lineasPedido    51 filas
  proyectos        5 filas
  asignaciones    14 filas
Carga terminada.
  ADMIN ana.garcia@empresa.es
  USER  luis.martinez@empresa.es

⚠️ Importante: next build necesita la base de datos. La portada, la API del catálogo, las fichas de producto, el sitemap.xml y las imágenes Open Graph se prerenderizan leyendo empresa.db. Si construyes antes de db:preparar, el build falla con no such table: productos (módulo 16, apartado 9.6). Y lo que se prerenderiza lleva los datos de esa base de datos: por eso las pruebas E2E y la imagen Docker preparan la suya antes de construir.


3. La capa de acceso a datos

Todo acceso a SQLite pasa por lib/db. Cada fichero empieza por import 'server-only', así que si un Client Component lo importara por error, el build fallaría (módulo 16, apartado 8.3).

3.1. El cliente y los errores

Estos dos ficheros son exactamente los del módulo 16:

// lib/db/index.ts
import 'server-only'
import { createClient, type Client } from '@libsql/client'
import { drizzle } from 'drizzle-orm/libsql'
import * as schema from '@/db/schema'

// En desarrollo, cada cambio de código vuelve a evaluar este módulo.
// Guardamos el cliente en globalThis para no abrir una conexión nueva cada vez.
const globalConDb = globalThis as typeof globalThis & { clienteEmpresa?: Client }

const cliente =
  globalConDb.clienteEmpresa ??
  createClient({ url: process.env.DATABASE_URL ?? 'file:empresa.db' })

if (process.env.NODE_ENV !== 'production') globalConDb.clienteEmpresa = cliente

export const db = drizzle(cliente, { schema })
export type BaseDeDatos = typeof db
// lib/db/errores.ts
import 'server-only'
import { LibsqlError } from '@libsql/client'

// Drizzle envuelve los fallos en DrizzleQueryError; el error de SQLite
// (LibsqlError) va en error.cause.
export function codigoSqlite(error: unknown): string | undefined {
  const causa = error instanceof Error ? error.cause : undefined
  if (causa instanceof LibsqlError) return causa.extendedCode ?? causa.code
  return undefined
}

export function mensajeDeError(error: unknown): string {
  switch (codigoSqlite(error)) {
    case 'SQLITE_CONSTRAINT_UNIQUE':
      return 'Ya existe un registro con ese valor.'
    case 'SQLITE_CONSTRAINT_FOREIGNKEY':
      return 'No se puede: hay otros datos que dependen de este registro.'
    case 'SQLITE_CONSTRAINT_CHECK':
      return 'Algún valor no cumple las reglas de la tabla.'
    case 'SQLITE_CONSTRAINT_NOTNULL':
      return 'Falta un dato obligatorio.'
    default:
      return 'Error inesperado en la base de datos.'
  }
}

Y un formateador de importes:

// lib/formato.ts
const euros = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' })

export function formatoEuros(valor: number | null) {
  return valor === null ? 'Sin asignar' : euros.format(valor)
}

3.2. El catálogo: dos públicos, dos estrategias

lib/db/catalogo.ts sirve a la tienda y a la gestión. Las tres primeras funciones son las del módulo 16; las demás se añaden en este proyecto:

// lib/db/catalogo.ts
import 'server-only'
import { and, asc, eq, sql } from 'drizzle-orm'
import { cacheLife, cacheTag } from 'next/cache'
import { connection } from 'next/server'
import { db } from '.'
import { categorias, productos } from '@/db/schema'

// DTO: solo lo que la tienda pública necesita ver
export type ProductoCatalogo = {
  productoId: number
  nombre: string
  precio: number
  stock: number
  categoria: string
}

// ---------------------------------------------------------------------------
// Tienda pública: en caché, compartida por todos los visitantes
// ---------------------------------------------------------------------------

export async function listarCatalogo(): Promise<ProductoCatalogo[]> {
  'use cache'
  cacheTag('productos')
  cacheLife('hours')

  console.log(`[db] listarCatalogo ${new Date().toISOString()}`)
  return db
    .select({
      productoId: productos.productoId,
      nombre: productos.nombre,
      precio: productos.precio,
      stock: productos.stock,
      categoria: categorias.nombre,
    })
    .from(productos)
    .innerJoin(categorias, eq(productos.categoriaId, categorias.categoriaId))
    .where(eq(productos.descatalogado, false))
    .orderBy(asc(categorias.nombre), asc(productos.nombre))
}

export async function obtenerProducto(productoId: number) {
  'use cache'
  cacheTag('productos', `producto-${productoId}`)
  cacheLife('hours')

  const producto = await db.query.productos.findFirst({
    where: and(eq(productos.productoId, productoId), eq(productos.descatalogado, false)),
    columns: { productoId: true, nombre: true, descripcion: true, precio: true, stock: true },
    with: { categoria: { columns: { nombre: true }, with: { padre: { columns: { nombre: true } } } } },
  })
  return producto ?? null
}

export async function idsDeProductos() {
  return db
    .select({ id: sql<string>`cast(${productos.productoId} as text)` })
    .from(productos)
    .where(eq(productos.descatalogado, false))
}

// Sin caché: el stock de la ficha es el del momento (hueco dinámico)
export async function stockActual(productoId: number) {
  await connection()
  const [fila] = await db
    .select({ stock: productos.stock })
    .from(productos)
    .where(eq(productos.productoId, productoId))
  return fila?.stock ?? 0
}

// ---------------------------------------------------------------------------
// Zona de gestión: datos actuales, también los descatalogados
// ---------------------------------------------------------------------------

export async function listarProductosGestion() {
  await connection()
  return db
    .select({
      productoId: productos.productoId,
      nombre: productos.nombre,
      precio: productos.precio,
      stock: productos.stock,
      descatalogado: productos.descatalogado,
      categoria: categorias.nombre,
    })
    .from(productos)
    .innerJoin(categorias, eq(productos.categoriaId, categorias.categoriaId))
    .orderBy(asc(productos.productoId))
}

export async function actualizarProducto(
  productoId: number,
  datos: { precio: number; stock: number; descatalogado: boolean },
) {
  const [fila] = await db
    .update(productos)
    .set(datos)
    .where(eq(productos.productoId, productoId))
    .returning({ productoId: productos.productoId, nombre: productos.nombre })
  return fila ?? null
}

El mismo dato, el stock, aparece en tres funciones con tres comportamientos distintos, y cada uno tiene su motivo:

Función Caché Quién la usa Por qué
listarCatalogo 'use cache', etiqueta productos, una hora Portada, API JSON, formulario de pedido Lo ven todos; cambia cuando un ADMIN guarda un producto, y entonces se caduca la etiqueta
stockActual Ninguna: await connection() Ficha del producto “Quedan 3” tiene que ser verdad ahora, no hace una hora
listarProductosGestion Ninguna: await connection() /gestion/productos Quien edita debe ver el valor real, también de los descatalogados

⚠️ Importante: Sin await connection(), stockActual no tendría ni 'use cache' ni ninguna API de la petición, y Next la ejecutaría durante el build: SQLite responde en microsegundos, el prerender la da por terminada y la ficha quedaría congelada con el stock de ese momento (módulo 16, apartado 9.4). La regla del curso se cumple en todo el proyecto: cada función de lectura lleva 'use cache' o await connection(). idsDeProductos es la única excepción, porque solo la llaman generateStaticParams y sitemap, que se ejecutan en el build a propósito.

3.3. Departamentos

// lib/db/departamentos.ts
import 'server-only'
import { asc, count, eq } from 'drizzle-orm'
import { cacheLife, cacheTag } from 'next/cache'
import { db } from '.'
import { departamentos, empleados, type NuevoDepartamento } from '@/db/schema'

export async function listarDepartamentos() {
  'use cache'
  cacheTag('departamentos')
  cacheLife('days')

  console.log(`[db] listarDepartamentos ${new Date().toISOString()}`)
  return db
    .select({
      deptId: departamentos.deptId,
      nombre: departamentos.nombre,
      localidad: departamentos.localidad,
      presupuesto: departamentos.presupuesto,
      empleados: count(empleados.empId),
    })
    .from(departamentos)
    .leftJoin(empleados, eq(empleados.deptId, departamentos.deptId))
    .groupBy(departamentos.deptId)
    .orderBy(asc(departamentos.deptId))
}

export async function obtenerDepartamento(deptId: number) {
  'use cache'
  cacheTag('departamentos', `departamento-${deptId}`)
  cacheLife('days')

  const dept = await db.query.departamentos.findFirst({
    where: eq(departamentos.deptId, deptId),
    with: {
      empleados: {
        columns: { empId: true, nombre: true, apellidos: true, oficio: true },
        orderBy: [asc(empleados.apellidos)],
      },
    },
  })
  return dept ?? null
}

export async function crearDepartamento(datos: NuevoDepartamento) {
  const [nuevo] = await db.insert(departamentos).values(datos).returning()
  return nuevo
}

// Añadida en el proyecto final: la edición
export async function actualizarDepartamento(deptId: number, datos: Omit<NuevoDepartamento, 'deptId'>) {
  const [fila] = await db
    .update(departamentos)
    .set(datos)
    .where(eq(departamentos.deptId, deptId))
    .returning()
  return fila ?? null
}

export async function eliminarDepartamento(deptId: number) {
  const borrados = await db
    .delete(departamentos)
    .where(eq(departamentos.deptId, deptId))
    .returning({ deptId: departamentos.deptId })
  return borrados.length > 0
}

La zona de gestión lee departamentos de la caché aunque sea privada. Es el patrón “autorizar fuera, leer de la caché dentro” del módulo 17 (apartado 11.2): la página comprueba la sesión con verificarSesion() y luego llama a listarDepartamentos(), que no sabe nada de cookies. La lista es la misma para todos los empleados, así que compartirla en caché es correcto, y cada escritura la caduca con updateTag('departamentos').

3.4. Empleados

// lib/db/empleados.ts
import 'server-only'
import { asc, eq } from 'drizzle-orm'
import { cacheLife, cacheTag } from 'next/cache'
import { db } from '.'
import { departamentos, empleados, type NuevoEmpleado } from '@/db/schema'

// Lista para la zona de gestión: sin salarios (los ve también un USER)
export async function listarEmpleados() {
  'use cache'
  cacheTag('empleados')
  cacheLife('hours')

  console.log(`[db] listarEmpleados ${new Date().toISOString()}`)
  return db
    .select({
      empId: empleados.empId,
      nombre: empleados.nombre,
      apellidos: empleados.apellidos,
      email: empleados.email,
      oficio: empleados.oficio,
      departamento: departamentos.nombre,
    })
    .from(empleados)
    .leftJoin(departamentos, eq(empleados.deptId, departamentos.deptId))
    .orderBy(asc(empleados.empId))
}

// Ficha completa para el formulario de edición (solo ADMIN: lo comprueba la página)
export async function obtenerEmpleado(empId: number) {
  'use cache'
  cacheTag('empleados', `empleado-${empId}`)
  cacheLife('hours')

  const empleado = await db.query.empleados.findFirst({ where: eq(empleados.empId, empId) })
  return empleado ?? null
}

export async function crearEmpleado(datos: NuevoEmpleado) {
  const [nuevo] = await db.insert(empleados).values(datos).returning({ empId: empleados.empId })
  return nuevo
}

export async function actualizarEmpleado(empId: number, datos: Omit<NuevoEmpleado, 'empId'>) {
  const [fila] = await db
    .update(empleados)
    .set(datos)
    .where(eq(empleados.empId, empId))
    .returning({ empId: empleados.empId })
  return fila ?? null
}

export async function eliminarEmpleado(empId: number) {
  const borrados = await db
    .delete(empleados)
    .where(eq(empleados.empId, empId))
    .returning({ empId: empleados.empId })
  return borrados.length > 0
}

listarEmpleados es un DTO pensado para que lo vea cualquier empleado: nombre, email, oficio y departamento, sin salario ni comisión. obtenerEmpleado sí devuelve la fila entera, porque el formulario de edición la necesita, y solo la llaman páginas que antes han exigido el rol ADMIN.

⚠️ Importante: ¿Por qué obtenerEmpleado no comprueba el rol ella misma, como listarNominas en el módulo 17? Porque una función con 'use cache' no puede leer cookies (módulo 17, apartado 11.1). Si quieres que la comprobación viva en la capa de datos, envuélvela como allí: una función privada con 'use cache' y otra pública que llama a exigirRol('ADMIN') y luego a la privada. En este proyecto la página es la única que la usa y comprueba el rol en la línea anterior; en uno más grande, el envoltorio evita que alguien la llame desde otra página olvidando la comprobación.

3.5. Pedidos

// lib/db/pedidos.ts
import 'server-only'
import { asc, desc, eq, sql } from 'drizzle-orm'
import { cacheLife, cacheTag } from 'next/cache'
import { connection } from 'next/server'
import { db } from '.'
import {
  clientes,
  lineasPedido,
  pedidos,
  productos,
  type EstadoPedido,
} from '@/db/schema'

// Importe de una línea con su descuento (porcentaje)
const importeLinea = sql<number>`${lineasPedido.cantidad} * ${lineasPedido.precioUnitario} * (1 - ${lineasPedido.descuento} / 100.0)`

// Sin 'use cache': la zona de gestión quiere ver siempre el dato actual
export async function listarPedidos() {
  await connection()
  return db
    .select({
      pedidoId: pedidos.pedidoId,
      fechaPedido: pedidos.fechaPedido,
      estado: pedidos.estado,
      cliente: clientes.nombre,
      total: sql<number>`round(coalesce(sum(${importeLinea}), 0), 2)`.mapWith(Number),
    })
    .from(pedidos)
    .innerJoin(clientes, eq(pedidos.clienteId, clientes.clienteId))
    .leftJoin(lineasPedido, eq(lineasPedido.pedidoId, pedidos.pedidoId))
    .groupBy(pedidos.pedidoId)
    .orderBy(desc(pedidos.fechaPedido), desc(pedidos.pedidoId))
}

export async function obtenerPedido(pedidoId: number) {
  await connection()
  const pedido = await db.query.pedidos.findFirst({
    where: eq(pedidos.pedidoId, pedidoId),
    with: {
      cliente: { columns: { nombre: true, ciudad: true } },
      lineas: {
        columns: { numLinea: true, cantidad: true, precioUnitario: true, descuento: true },
        with: { producto: { columns: { nombre: true } } },
        orderBy: [asc(lineasPedido.numLinea)],
      },
    },
  })
  return pedido ?? null
}

export async function cambiarEstado(pedidoId: number, estado: EstadoPedido) {
  const hoy = new Date().toISOString().slice(0, 10)
  const [fila] = await db
    .update(pedidos)
    .set({
      estado,
      // Al marcarlo como enviado se rellena la fecha de envío si no la tenía
      fechaEnvio: estado === 'ENVIADO' ? sql`coalesce(${pedidos.fechaEnvio}, ${hoy})` : undefined,
    })
    .where(eq(pedidos.pedidoId, pedidoId))
    .returning({ pedidoId: pedidos.pedidoId, estado: pedidos.estado, fechaEnvio: pedidos.fechaEnvio })
  return fila ?? null
}

type LineaNueva = { productoId: number; cantidad: number }

// Módulo 16, ejercicio 5: todo o nada. El CHECK (stock >= 0) impide vender lo que no hay.
export async function crearPedido(clienteId: number, lineas: LineaNueva[], empleadoId: number | null = null) {
  return db.transaction(async (tx) => {
    const [pedido] = await tx
      .insert(pedidos)
      .values({ clienteId, empleadoId, fechaPedido: new Date().toISOString().slice(0, 10) })
      .returning({ pedidoId: pedidos.pedidoId })

    for (const [i, linea] of lineas.entries()) {
      const [producto] = await tx
        .update(productos)
        .set({ stock: sql`${productos.stock} - ${linea.cantidad}` })
        .where(eq(productos.productoId, linea.productoId))
        .returning({ precio: productos.precio })
      if (!producto) throw new Error(`No existe el producto ${linea.productoId}`)

      await tx.insert(lineasPedido).values({
        pedidoId: pedido.pedidoId,
        numLinea: i + 1,
        productoId: linea.productoId,
        cantidad: linea.cantidad,
        precioUnitario: producto.precio,
      })
    }
    return pedido.pedidoId
  })
}

// Para el <select> del formulario de pedido: cambia poco, va en caché
export async function listarClientes() {
  'use cache'
  cacheTag('clientes')
  cacheLife('days')
  return db
    .select({ clienteId: clientes.clienteId, nombre: clientes.nombre })
    .from(clientes)
    .orderBy(asc(clientes.nombre))
}

crearPedido es la solución del ejercicio 5 del módulo 16, con un parámetro más: el empleado que registra el pedido. La transacción hace que no se pueda vender lo que no hay sin escribir una sola comprobación: la restricción CHECK (stock >= 0) de SQLite rechaza el UPDATE y la transacción deshace también el pedido ya insertado. En el apartado 8 lo comprobarás con una prueba.

3.6. Los esquemas de validación

Todos los formularios se validan con Zod 4 (módulo 11). Los esquemas viven en un solo fichero, sin dependencias de Next ni de la base de datos, para que las acciones y las pruebas usen los mismos:

// lib/validacion.ts
// Esquemas de Zod de todos los formularios. Los usan las Server Actions
// y las pruebas; no dependen de Next ni de la base de datos.
import { z } from 'zod'
import { ESTADOS_PEDIDO } from '@/db/schema'

// '' (campo vacío del formulario) se convierte en null
const textoOpcional = z
  .string()
  .trim()
  .transform((v) => (v === '' ? null : v))

const numeroOpcional = z
  .string()
  .trim()
  .transform((v) => (v === '' ? null : Number(v)))
  .pipe(z.number('Escribe un número').nonnegative('No puede ser negativo').nullable())

const idOpcional = z
  .string()
  .transform((v) => (v === '' ? null : Number(v)))
  .pipe(z.number().int().positive().nullable())

export const esquemaDepartamento = z.object({
  nombre: z.string().trim().min(2, 'Mínimo 2 caracteres').max(40).toUpperCase(),
  localidad: z.string().trim().min(2, 'Mínimo 2 caracteres'),
  presupuesto: numeroOpcional,
})

export const esquemaEmpleado = z.object({
  nombre: z.string().trim().min(2, 'Mínimo 2 caracteres').max(40),
  apellidos: z.string().trim().min(2, 'Mínimo 2 caracteres').max(60),
  email: z.string().trim().toLowerCase().pipe(z.email('Email no válido')),
  telefono: textoOpcional,
  fechaAlta: z.iso.date('Fecha no válida (AAAA-MM-DD)'),
  oficio: z.string().trim().min(2, 'Mínimo 2 caracteres').toUpperCase(),
  salario: z.coerce
    .number('Escribe un número')
    .positive('Debe ser mayor que 0')
    .max(20000, 'Máximo 20000'),
  comision: numeroOpcional,
  deptId: idOpcional,
  jefeId: idOpcional,
})

export const esquemaProducto = z.object({
  productoId: z.coerce.number().int().positive(),
  precio: z.coerce.number('Escribe un número').nonnegative('No puede ser negativo'),
  stock: z.coerce.number().int('Debe ser entero').min(0, 'No puede ser negativo'),
  // Una casilla sin marcar no se envía: falta la clave
  descatalogado: z
    .string()
    .optional()
    .transform((v) => v === 'on'),
})

export const esquemaPedido = z.object({
  clienteId: z.coerce.number().int().positive('Elige un cliente'),
  productoId: z.coerce.number().int().positive('Elige un producto'),
  cantidad: z.coerce.number().int('Debe ser entero').min(1, 'Mínimo 1').max(1000, 'Máximo 1000'),
})

export const esquemaEstado = z.object({
  pedidoId: z.coerce.number().int().positive(),
  estado: z.enum(ESTADOS_PEDIDO),
})

export const esquemaId = z.coerce.number().int().positive()

Detalles que evitan errores sutiles:

  • Los nulos reales. Un campo vacío llega como ''. Para la comisión, el teléfono o el departamento, '' significa “no tiene” y debe guardarse como NULL, no como 0 ni como cadena vacía. La comisión null (no cobra comisión) y la comisión 0 (cobra, pero este mes nada) son datos distintos en EMPRESA (módulo 16, apartado 3.2).
  • toUpperCase() antes de la base de datos. Normaliza oficios y nombres de departamento como los datos originales, y hace que UNIQUE funcione: “ventas” choca con “VENTAS”.
  • z.email() después de trim().toLowerCase(), con pipe: primero se limpia, luego se valida. El email se guarda siempre en minúsculas, igual que lo busca comprobarCredenciales.
  • La casilla de verificación. Un <input type="checkbox"> sin marcar no se envía: la clave descatalogado no aparece en el FormData. Por eso es optional() y se transforma en v === 'on'.
  • z.enum(ESTADOS_PEDIDO) usa la misma lista que la columna y la restricción CHECK. Un estado inventado (PERDIDO) no llega nunca a SQLite.

4. Sesión y autorización

La autenticación es la sesión propia del módulo 17, sin cambios de fondo. Aquí está el código completo; las explicaciones detalladas (por qué scrypt, qué va dentro del token, por qué la DAL consulta la base de datos en cada petición) están allí.

4.1. Contraseñas con scrypt

// lib/claves.ts
// Cifrado de contraseñas con scrypt, incluido en Node (node:crypto).
// Formato guardado: scrypt$N$r$p$sal$hash (todo en base64url salvo los números)
import { randomBytes, scrypt, timingSafeEqual, type ScryptOptions } from 'node:crypto'

const N = 16384 // coste de CPU y memoria (2^14)
const r = 8
const p = 1
const LONGITUD = 64

function derivar(clave: string, sal: Buffer, opciones: ScryptOptions): Promise<Buffer> {
  return new Promise((resolve, reject) => {
    scrypt(clave.normalize('NFKC'), sal, LONGITUD, opciones, (error, clave) =>
      error ? reject(error) : resolve(clave),
    )
  })
}

export async function cifrarClave(clave: string): Promise<string> {
  const sal = randomBytes(16)
  const hash = await derivar(clave, sal, { N, r, p })
  return ['scrypt', N, r, p, sal.toString('base64url'), hash.toString('base64url')].join('$')
}

export async function compararClave(clave: string, guardado: string): Promise<boolean> {
  const [algoritmo, n, rr, pp, salTexto, hashTexto] = guardado.split('$')
  if (algoritmo !== 'scrypt' || !salTexto || !hashTexto) return false
  const esperado = Buffer.from(hashTexto, 'base64url')
  const calculado = await derivar(clave, Buffer.from(salTexto, 'base64url'), {
    N: Number(n),
    r: Number(rr),
    p: Number(pp),
  })
  // timingSafeEqual tarda lo mismo acierte o falle: no da pistas por tiempo
  return calculado.length === esperado.length && timingSafeEqual(calculado, esperado)
}

Este fichero no lleva server-only porque también lo usa scripts/crear-usuarios.ts, que se ejecuta con tsx fuera de Next. No hay riesgo: node:crypto no existe en el navegador, así que un Client Component que lo importara fallaría al compilar igualmente.

4.2. La sesión con jose

// lib/sesion.ts
import 'server-only'
import { SignJWT, jwtVerify } from 'jose'
import { cookies } from 'next/headers'
import type { Rol } from '@/db/schema'

export const COOKIE_SESION = 'sesion'
const DURACION_SEGUNDOS = 60 * 60 * 8 // una jornada: 8 horas

// Lo mínimo para reconocer al usuario: nada de email, nombre ni datos personales
export type DatosSesion = {
  usuarioId: number
  rol: Rol
  version: number // versión de sesión del usuario (para revocar)
}

const secreto = process.env.SESSION_SECRET
if (!secreto || secreto.length < 32) {
  throw new Error('Falta SESSION_SECRET (32 caracteres o más) en .env.local')
}
const clave = new TextEncoder().encode(secreto)

export async function cifrarSesion(datos: DatosSesion, duracion = DURACION_SEGUNDOS) {
  return new SignJWT(datos)
    .setProtectedHeader({ alg: 'HS256' })
    .setSubject(String(datos.usuarioId))
    .setIssuedAt()
    .setExpirationTime(Math.floor(Date.now() / 1000) + duracion)
    .sign(clave)
}

export async function descifrarSesion(token: string | undefined): Promise<DatosSesion | null> {
  if (!token) return null
  try {
    const { payload } = await jwtVerify<DatosSesion>(token, clave, { algorithms: ['HS256'] })
    if (typeof payload.usuarioId !== 'number') return null
    if (payload.rol !== 'ADMIN' && payload.rol !== 'USER') return null
    return { usuarioId: payload.usuarioId, rol: payload.rol, version: payload.version }
  } catch (error) {
    // Los errores de jose traen un código: ERR_JWT_EXPIRED, ERR_JWS_SIGNATURE_VERIFICATION_FAILED...
    console.log('[sesion] token rechazado:', (error as { code?: string }).code)
    return null
  }
}

export async function crearSesion(datos: DatosSesion) {
  const token = await cifrarSesion(datos)
  const almacen = await cookies()
  almacen.set(COOKIE_SESION, token, {
    httpOnly: true, // JavaScript del navegador no puede leerla
    secure: process.env.NODE_ENV === 'production', // solo por HTTPS (localhost es la excepción)
    sameSite: 'lax', // no viaja en peticiones POST desde otros sitios
    path: '/',
    maxAge: DURACION_SEGUNDOS,
  })
}

export async function leerSesion(): Promise<DatosSesion | null> {
  const almacen = await cookies()
  return descifrarSesion(almacen.get(COOKIE_SESION)?.value)
}

export async function borrarSesion() {
  const almacen = await cookies()
  almacen.delete(COOKIE_SESION)
}

⚠️ Importante: El módulo comprueba SESSION_SECRET al cargarse, y lo cargan páginas que se prerenderizan. Por eso el secreto hace falta también durante next build, no solo al arrancar. Lo verás en el Dockerfile (apartado 9), que pasa un secreto solo para el build y otro distinto al ejecutar el contenedor.

4.3. La DAL: quién es el usuario

// lib/dal/auth.ts
import 'server-only'
import { cache } from 'react'
import { eq, sql } from 'drizzle-orm'
import { forbidden, redirect } from 'next/navigation'
import { db } from '@/lib/db'
import { usuarios, type Rol } from '@/db/schema'
import { leerSesion } from '@/lib/sesion'
import { cifrarClave, compararClave } from '@/lib/claves'

// DTO: lo que el resto de la aplicación sabe del usuario que ha entrado
export type UsuarioActual = {
  usuarioId: number
  nombre: string
  rol: Rol
  empId: number | null
}

// Lee la cookie, comprueba la firma y busca al usuario en la base de datos.
// cache() hace que, en una misma petición, solo se ejecute una vez
// aunque la llamen el layout, la página y tres componentes.
export const obtenerUsuario = cache(async (): Promise<UsuarioActual | null> => {
  const sesion = await leerSesion()
  if (!sesion) return null

  const [usuario] = await db
    .select({
      usuarioId: usuarios.usuarioId,
      nombre: usuarios.nombre,
      rol: usuarios.rol,
      empId: usuarios.empId,
      versionSesion: usuarios.versionSesion,
    })
    .from(usuarios)
    .where(eq(usuarios.usuarioId, sesion.usuarioId))

  // Usuario borrado, o sesiones revocadas después de emitir este token
  if (!usuario || usuario.versionSesion !== sesion.version) return null

  // El rol sale de la base de datos, no del token
  return {
    usuarioId: usuario.usuarioId,
    nombre: usuario.nombre,
    rol: usuario.rol,
    empId: usuario.empId,
  }
})

// Para páginas: sin sesión, a la página de entrada
export async function verificarSesion(): Promise<UsuarioActual> {
  const usuario = await obtenerUsuario()
  if (!usuario) redirect('/entrar')
  return usuario
}

// Para páginas: sin el rol pedido, la página 403 (forbidden.tsx)
export async function exigirRol(rol: Rol): Promise<UsuarioActual> {
  const usuario = await verificarSesion()
  if (usuario.rol !== rol) forbidden()
  return usuario
}

// Hash de una contraseña cualquiera: se compara contra él cuando el email
// no existe, para que la respuesta tarde lo mismo exista o no el usuario.
const hashFicticio = cifrarClave('contraseña-que-nadie-usa')

export async function comprobarCredenciales(email: string, clave: string) {
  const [usuario] = await db
    .select({
      usuarioId: usuarios.usuarioId,
      rol: usuarios.rol,
      hashClave: usuarios.hashClave,
      versionSesion: usuarios.versionSesion,
    })
    .from(usuarios)
    .where(eq(usuarios.email, email.trim().toLowerCase()))

  const valida = await compararClave(clave, usuario?.hashClave ?? (await hashFicticio))
  if (!usuario || !valida) return null
  return { usuarioId: usuario.usuarioId, rol: usuario.rol, version: usuario.versionSesion }
}

// "Cerrar sesión en todos los dispositivos": invalida todos los tokens emitidos
export async function revocarSesiones(usuarioId: number) {
  await db
    .update(usuarios)
    .set({ versionSesion: sql`${usuarios.versionSesion} + 1` })
    .where(eq(usuarios.usuarioId, usuarioId))
}

Las tres funciones que usa el resto del proyecto, y dónde:

Función Sin sesión Sin el rol Se usa en
obtenerUsuario() null (no mira el rol) Todas las Server Actions, a través de usuarioDeAccion() (apartado 6.2)
verificarSesion() redirect('/entrar') (no mira el rol) Páginas de gestión que puede ver un USER
exigirRol('ADMIN') redirect('/entrar') forbidden() Alta y edición de empleados y departamentos

4.4. El proxy: la primera barrera

// proxy.ts
import { NextResponse, type NextRequest } from 'next/server'
import { COOKIE_SESION, descifrarSesion } from '@/lib/sesion'

// Primera barrera, OPTIMISTA: solo mira la cookie (firma y caducidad),
// sin tocar la base de datos. La comprobación de verdad está en la DAL.
export async function proxy(request: NextRequest) {
  const { pathname, search } = request.nextUrl
  const sesion = await descifrarSesion(request.cookies.get(COOKIE_SESION)?.value)

  if (pathname.startsWith('/gestion') && !sesion) {
    const url = new URL('/entrar', request.url)
    url.searchParams.set('desde', pathname + search)
    return NextResponse.redirect(url)
  }

  if (pathname === '/entrar' && sesion) {
    return NextResponse.redirect(new URL('/gestion', request.url))
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/gestion/:path*', '/entrar'],
}

Es el proxy del módulo 17 sin la parte de Better Auth. Comprobado con curl contra next start:

HTTP/1.1 307 Temporary Redirect
location: /entrar?desde=%2Fgestion%2Fpedidos

El proxy no protege nada por sí solo: cada página y cada acción vuelven a comprobar la sesión con la DAL. El módulo 17 (apartado 10.1) explica por qué, con la vulnerabilidad CVE-2025-29927 como ejemplo de lo que pasa cuando el middleware es la única defensa.

4.5. Iniciar y cerrar sesión

La acción, el formulario y la página son los del módulo 17:

// app/entrar/acciones.ts
'use server'

import { redirect } from 'next/navigation'
import { z } from 'zod'
import { comprobarCredenciales, obtenerUsuario, revocarSesiones } from '@/lib/dal/auth'
import { borrarSesion, crearSesion } from '@/lib/sesion'

export type EstadoEntrar = { error?: string; email?: string }

const esquemaEntrar = z.object({
  email: z.email('Escribe un email válido'),
  clave: z.string().min(1, 'Escribe la contraseña'),
  desde: z.string().optional(),
})

// Solo rutas de esta misma web: "/gestion" sí; "//malo.example" o "https://..." no
function destinoSeguro(desde: string | undefined) {
  if (desde && desde.startsWith('/') && !desde.startsWith('//') && !desde.startsWith('/\\')) {
    return desde
  }
  return '/gestion'
}

export async function entrar(_previo: EstadoEntrar, formData: FormData): Promise<EstadoEntrar> {
  const datos = esquemaEntrar.safeParse(Object.fromEntries(formData))
  if (!datos.success) {
    return { error: datos.error.issues[0].message, email: String(formData.get('email') ?? '') }
  }

  const usuario = await comprobarCredenciales(datos.data.email, datos.data.clave)
  if (!usuario) {
    // El mismo mensaje si falla el email o la contraseña: no damos pistas
    return { error: 'Email o contraseña incorrectos', email: datos.data.email }
  }

  await crearSesion(usuario)
  redirect(destinoSeguro(datos.data.desde)) // fuera de cualquier try/catch
}

export async function salir() {
  await borrarSesion()
  redirect('/entrar')
}

export async function salirDeTodo() {
  const usuario = await obtenerUsuario()
  if (usuario) await revocarSesiones(usuario.usuarioId)
  await borrarSesion()
  redirect('/entrar')
}
// app/entrar/FormularioEntrar.tsx
'use client'

import { useActionState } from 'react'
import { entrar, type EstadoEntrar } from './acciones'

export function FormularioEntrar({ desde }: { desde?: string }) {
  const [estado, accion, pendiente] = useActionState<EstadoEntrar, FormData>(entrar, {})

  return (
    <form action={accion} className="flex w-80 flex-col gap-3 rounded-lg bg-white p-6 shadow">
      <label className="flex flex-col gap-1">
        Email
        <input
          name="email"
          type="email"
          required
          autoComplete="username"
          defaultValue={estado.email}
          className="rounded border px-2 py-1"
        />
      </label>
      <label className="flex flex-col gap-1">
        Contraseña
        <input
          name="clave"
          type="password"
          required
          autoComplete="current-password"
          className="rounded border px-2 py-1"
        />
      </label>
      {desde && <input type="hidden" name="desde" value={desde} />}
      {estado.error && (
        <p role="alert" className="text-sm text-red-700">
          {estado.error}
        </p>
      )}
      <button disabled={pendiente} className="rounded bg-marca-700 py-2 text-white disabled:opacity-50">
        {pendiente ? 'Comprobando...' : 'Entrar'}
      </button>
    </form>
  )
}
// app/entrar/page.tsx
import { Suspense } from 'react'
import { FormularioEntrar } from './FormularioEntrar'

export const metadata = { title: 'Iniciar sesión', robots: { index: false } }

export default function PaginaEntrar({ searchParams }: PageProps<'/entrar'>) {
  return (
    <main className="flex flex-col items-center gap-4 p-10">
      <h1 className="text-2xl font-bold text-marca-900">Iniciar sesión</h1>
      <Suspense fallback={<FormularioEntrar />}>
        <FormularioConDestino searchParams={searchParams} />
      </Suspense>
    </main>
  )
}

// searchParams es un dato de la petición: se lee dentro de <Suspense>
async function FormularioConDestino({ searchParams }: Pick<PageProps<'/entrar'>, 'searchParams'>) {
  const { desde } = await searchParams
  return <FormularioEntrar desde={typeof desde === 'string' ? desde : undefined} />
}

El fallback de <Suspense> es el mismo formulario sin desde. Esto tiene una consecuencia que las pruebas E2E sacaron a la luz: durante un instante hay dos formularios en la página, el del fallback (visible) y el definitivo (todavía oculto, esperando a que React lo coloque). Lo verás en el apartado 8.3, y sin JavaScript en el apartado 7.


5. La tienda pública

5.1. El layout raíz y el tema

// app/layout.tsx
import type { Metadata } from 'next'
import Link from 'next/link'
import './globals.css'

export const metadata: Metadata = {
  // Base para las URL absolutas de Open Graph (cámbiala por tu dominio)
  metadataBase: new URL(process.env.URL_PUBLICA ?? 'http://localhost:3001'),
  title: { default: 'EMPRESA: tienda y gestión', template: '%s | EMPRESA' },
  description: 'Catálogo de productos y zona de gestión de la aplicación EMPRESA.',
}

// El layout raíz no lee la sesión: así la tienda puede ser estática
export default function LayoutRaiz({ children }: LayoutProps<'/'>) {
  return (
    <html lang="es">
      <body className="min-h-screen">
        <header className="bg-marca-900 text-white">
          <nav className="mx-auto flex max-w-6xl items-center gap-6 px-4 py-3">
            <Link href="/" className="text-lg font-bold tracking-wide">
              EMPRESA
            </Link>
            <Link href="/" className="hover:underline">
              Tienda
            </Link>
            <Link href="/gestion" className="ml-auto rounded bg-acento px-3 py-1 font-semibold text-marca-900">
              Gestión
            </Link>
          </nav>
        </header>
        {children}
      </body>
    </html>
  )
}
/* app/globals.css */
@import "tailwindcss";

@theme {
  --color-marca-50: #e8eaf6;
  --color-marca-100: #c5cae9;
  --color-marca-700: #303f9f;
  --color-marca-900: #1a237e;
  --color-acento: #ff7043;
}

body {
  @apply bg-slate-50 text-slate-800 antialiased;
}

Con @theme de Tailwind 4 (Curso de Tailwind CSS), cada variable --color-* crea las utilidades correspondientes: bg-marca-900, text-acento, border-marca-100. Los colores de la marca se cambian en un único sitio.

Tentación habitual: poner en la cabecera “Hola, Ana” cuando hay sesión. Parece inofensivo, pero el layout raíz envuelve todas las páginas, también la tienda. Leer la cookie aquí haría que todas pasaran a depender de la petición. Si de verdad quieres el saludo, hazlo en un componente dentro de <Suspense> (como MenuUsuario en la gestión) y acepta que la portada pase a ◐, o, mejor, léelo en el cliente.

5.2. Una garantía para toda la tienda: ensureStatic 🆕 16.4

🆕 Desde Next.js 16.4. No existe en Next.js 15. Si usas Next.js 15: no hay equivalente por etapas; export const dynamic = 'error' en la portada hace que el build falle si se vuelve dinámica, pero no sirve para la ficha, que tiene un hueco dinámico. Comprueba a mano la tabla del build en cada cambio.

// app/(tienda)/layout.tsx
// Garantía para toda la tienda: el armazón de cada página sale del prerender.
// La portada exige aún más ('navigation'); la ficha tiene un hueco dinámico (el stock).
export const ensureStatic = 'shell'

export default function LayoutTienda({ children }: LayoutProps<'/'>) {
  return <main className="mx-auto max-w-6xl px-4 py-8">{children}</main>
}

ensureStatic (módulo 09, apartado 9) convierte una intención (“la tienda es estática”) en una comprobación del build. Aquí se usan dos niveles:

  • 'shell' en el layout del grupo: toda página de la tienda debe tener su armazón en el prerender. Un hueco dinámico dentro de <Suspense> está permitido. La ficha lo necesita para el stock.
  • 'navigation' en la portada: la página entera, sin huecos. Un hijo puede ser más estricto que su padre, nunca menos.

Si mañana alguien añade a la portada un componente que lee una cookie o un dato sin caché, el build se detiene con Next.js encountered uncached or runtime data on a route that must be fully static, en lugar de convertir la portada en dinámica sin que nadie lo note.

5.3. La portada

// app/(tienda)/page.tsx
import Link from 'next/link'
import { listarCatalogo, type ProductoCatalogo } from '@/lib/db/catalogo'
import { formatoEuros } from '@/lib/formato'

// La portada debe salir ENTERA del prerender: si alguien añade un dato
// sin caché, el build falla en lugar de volverla dinámica sin avisar.
export const ensureStatic = 'navigation'

export const metadata = { title: 'Tienda' }

export default async function Portada() {
  const productos = await listarCatalogo()

  // Agrupar por categoría (ya vienen ordenados por categoría y nombre)
  const porCategoria = new Map<string, ProductoCatalogo[]>()
  for (const p of productos) {
    porCategoria.set(p.categoria, [...(porCategoria.get(p.categoria) ?? []), p])
  }

  return (
    <>
      <h1 className="mb-1 text-3xl font-bold text-marca-900">Catálogo</h1>
      <p className="mb-8 text-slate-600">{productos.length} productos disponibles</p>
      {[...porCategoria].map(([categoria, lista]) => (
        <section key={categoria} className="mb-8">
          <h2 className="mb-3 border-b border-marca-100 pb-1 text-xl font-semibold">{categoria}</h2>
          <ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
            {lista.map((p) => (
              <li key={p.productoId}>
                <Link
                  href={`/productos/${p.productoId}`}
                  className="block rounded-lg bg-white p-4 shadow transition hover:shadow-md"
                >
                  <span className="block font-medium">{p.nombre}</span>
                  <span className="mt-2 block text-lg font-bold text-marca-700">{formatoEuros(p.precio)}</span>
                </Link>
              </li>
            ))}
          </ul>
        </section>
      ))}
    </>
  )
}

Es un Server Component asíncrono que lee directamente de la capa de datos, sin <Suspense>: como listarCatalogo está en caché, el build la resuelve y la página entera se prerenderiza. El agrupado se hace en JavaScript sobre la lista ya ordenada por la consulta; con 19 productos no merece la pena una consulta por categoría.

5.4. La ficha: armazón en caché y stock en tiempo real

// app/(tienda)/productos/[id]/page.tsx
import type { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { Suspense } from 'react'
import { idsDeProductos, obtenerProducto, stockActual } from '@/lib/db/catalogo'
import { formatoEuros } from '@/lib/formato'

// Los productos que existen en el build se prerenderizan; los demás, en la primera visita
export async function generateStaticParams() {
  return idsDeProductos()
}

export async function generateMetadata({ params }: PageProps<'/productos/[id]'>): Promise<Metadata> {
  const { id } = await params
  const producto = await obtenerProducto(Number(id))
  if (!producto) return { title: 'Producto no encontrado' }
  return {
    title: producto.nombre,
    description: producto.descripcion || `${producto.nombre} en la tienda EMPRESA`,
    openGraph: { title: producto.nombre, type: 'website' },
  }
}

// No se hace await params aquí arriba: se pasa la promesa hacia abajo (módulo 04)
export default function PaginaProducto({ params }: PageProps<'/productos/[id]'>) {
  return (
    <>
      <Link href="/" className="text-sm text-marca-700 hover:underline">
        ← Volver al catálogo
      </Link>
      {/* Para un id que no estaba en el build, params es un dato de la petición */}
      <Suspense fallback={<p className="mt-4 text-slate-400">Cargando producto...</p>}>
        <Ficha params={params} />
      </Suspense>
    </>
  )
}

async function Ficha({ params }: Pick<PageProps<'/productos/[id]'>, 'params'>) {
  const { id } = await params
  const producto = await obtenerProducto(Number(id))
  if (!producto) notFound()

  const categoria = producto.categoria.padre
    ? `${producto.categoria.padre.nombre} › ${producto.categoria.nombre}`
    : producto.categoria.nombre

  return (
    <article className="mt-4 rounded-lg bg-white p-6 shadow">
      <p className="text-sm text-slate-500">{categoria}</p>
      <h1 className="text-3xl font-bold text-marca-900">{producto.nombre}</h1>
      <p className="my-4 max-w-prose text-slate-700">{producto.descripcion}</p>
      <p className="text-2xl font-bold text-marca-700">{formatoEuros(producto.precio)}</p>
      {/* El hueco dinámico: el armazón sale del prerender, el stock se calcula en cada petición */}
      <Suspense fallback={<p className="mt-2 text-sm text-slate-400">Consultando disponibilidad...</p>}>
        <Disponibilidad productoId={producto.productoId} />
      </Suspense>
    </article>
  )
}

async function Disponibilidad({ productoId }: { productoId: number }) {
  const stock = await stockActual(productoId)
  if (stock === 0) return <p className="mt-2 font-semibold text-red-700">Agotado</p>
  return (
    <p className="mt-2 text-green-700" data-stock={stock}>
      {stock < 5 ? `¡Solo quedan ${stock}!` : `En stock: ${stock} unidades`}
    </p>
  )
}

Ficha del producto Portátil Básico 15 con la categoría, la descripción, el precio 499 euros y el texto En stock: 40 unidades

Figura 3. La ficha: nombre, descripción y precio salen del prerender; “En stock: 40 unidades” se calcula en cada petición.

Lo que pasa al pedir /productos/3:

  1. El servidor envía al instante el HTML prerenderizado en el build: cabecera, nombre, descripción, precio y el texto “Consultando disponibilidad…”.
  2. En la misma respuesta, cuando stockActual(3) termina (con connection(), en cada petición), llega por streaming el fragmento “En stock: 40 unidades”, que sustituye al fallback.

Por eso la ruta sale como ◐ en la tabla del build. Tres detalles del código:

  • Dos <Suspense> anidados. El exterior protege la lectura de params (para un id que no estaba en generateStaticParams, params es un dato de la petición). El interior aísla el stock: así el armazón con el nombre y el precio se prerenderiza para los 19 productos conocidos.
  • generateMetadata sí hace await params. Los metadatos no se pintan dentro del cuerpo, y Next los resuelve aparte; el build no se quejó. Lo que rompe el build es hacer await params en el cuerpo de la página, fuera de <Suspense> (módulo 10, comprobado en el curso).
  • notFound() dentro de <Suspense>. Para un id que no existe, la primera petición responde 200 con <meta name="robots" content="noindex"> y la segunda 404. Comprobado con curl en este proyecto:
/productos/999 -> 200
<meta name="robots" content="noindex"
/productos/999 -> 404
<meta name="robots" content="noindex"

Es el comportamiento que ya vio el módulo 04: cuando notFound() se lanza después de empezar el streaming, la cabecera ya ha salido con 200. Para un buscador da igual (la etiqueta noindex le dice que no la indexe) y para el usuario también (ve la página 404).

5.5. Imágenes Open Graph, robots y sitemap

Cuando alguien comparte un enlace en una red social o en un chat, la plataforma lee las etiquetas og:* de la página para mostrar una tarjeta con título, descripción e imagen (módulo 07). La tienda tiene una imagen general y una por producto:

// app/opengraph-image.tsx
import { ImageResponse } from 'next/og'

// Imagen para compartir la tienda en redes (Open Graph). Se genera en el build.
export const alt = 'EMPRESA: catálogo de productos'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

export default function ImagenOG() {
  return new ImageResponse(
    (
      <div
        style={{
          width: '100%',
          height: '100%',
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'center',
          padding: 80,
          background: '#1a237e',
          color: 'white',
        }}
      >
        <div style={{ fontSize: 96, fontWeight: 700 }}>EMPRESA</div>
        <div style={{ fontSize: 44, color: '#ff7043' }}>Catálogo y gestión con Next.js</div>
      </div>
    ),
    size,
  )
}
// app/(tienda)/productos/[id]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { idsDeProductos, obtenerProducto } from '@/lib/db/catalogo'
import { formatoEuros } from '@/lib/formato'

export const alt = 'Producto de la tienda EMPRESA'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

// Una imagen por producto, generada en el build
export async function generateStaticParams() {
  return idsDeProductos()
}

export default async function ImagenProducto({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const producto = await obtenerProducto(Number(id))

  return new ImageResponse(
    (
      <div
        style={{
          width: '100%',
          height: '100%',
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'space-between',
          padding: 80,
          background: '#e8eaf6',
          color: '#1a237e',
        }}
      >
        <div style={{ fontSize: 36, color: '#3f51b5' }}>EMPRESA · Tienda</div>
        <div style={{ fontSize: 80, fontWeight: 700 }}>{producto?.nombre ?? 'Producto'}</div>
        <div style={{ fontSize: 56, color: '#ff7043' }}>
          {producto ? formatoEuros(producto.precio) : ''}
        </div>
      </div>
    ),
    size,
  )
}

Las etiquetas que Next escribe en /productos/3, leídas con curl:

<meta property="og:title" content="Portátil Básico 15"
<meta property="og:description" content="Portátil de 15 pulgadas para ofimática"
<meta property="og:image" content="http://127.0.0.1:5525/productos/3/opengraph-image-1ta0k6?8790027bbae68b49"
<meta property="og:image:type" content="image/png"
<meta property="og:image:width" content="1200"
<meta property="og:image:height" content="630"

y la imagen responde 200 image/png (37 338 bytes). Fíjate en dos cosas. La URL de la imagen no es /productos/3/opengraph-image, sino que lleva un sufijo (-1ta0k6) que Next añade a las imágenes de rutas dinámicas; nunca escribas esa URL a mano, deja que la ponga Next. Y la URL es absoluta gracias a metadataBase: las redes sociales no aceptan rutas relativas. La imagen lleva la etiqueta productos (a través de obtenerProducto), así que también se renueva cuando cambia el precio.

// app/robots.ts
import type { MetadataRoute } from 'next'

export default function robots(): MetadataRoute.Robots {
  return {
    rules: { userAgent: '*', allow: '/', disallow: ['/gestion', '/entrar', '/api'] },
    sitemap: `${process.env.URL_PUBLICA ?? 'http://localhost:3001'}/sitemap.xml`,
  }
}
// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { idsDeProductos } from '@/lib/db/catalogo'

const base = process.env.URL_PUBLICA ?? 'http://localhost:3001'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const ids = await idsDeProductos()
  return [
    { url: `${base}/`, changeFrequency: 'daily', priority: 1 },
    ...ids.map(({ id }) => ({ url: `${base}/productos/${id}`, changeFrequency: 'weekly' as const })),
  ]
}
User-Agent: *
Allow: /
Disallow: /gestion
Disallow: /entrar
Disallow: /api

Sitemap: http://127.0.0.1:5525/sitemap.xml

⚠️ Importante: robots.txt no protege nada: es una petición educada a los buscadores, y además anuncia a cualquiera que existe /gestion. La protección está en el proxy, la DAL y las acciones. El sitemap.xml se genera en el build con los productos de ese momento; un producto nuevo no aparece hasta el siguiente build (o hasta que hagas el sitemap dinámico, con connection()).

5.6. La API JSON y la comprobación de salud

// app/api/productos/route.ts
import { listarCatalogo } from '@/lib/db/catalogo'

// API pública de solo lectura: el mismo catálogo en caché que la tienda.
// Al cambiar un precio, updateTag('productos') renueva también esta respuesta.
export async function GET() {
  const productos = await listarCatalogo()
  return Response.json({ total: productos.length, productos })
}
// app/api/salud/route.ts
import { sql } from 'drizzle-orm'
import { connection } from 'next/server'
import { db } from '@/lib/db'

// Comprobación de salud para Docker (HEALTHCHECK): ¿responde la base de datos?
export async function GET() {
  await connection() // en cada petición, nunca prerenderizada
  try {
    await db.run(sql`select 1`)
    return Response.json({ ok: true, hora: new Date().toISOString() })
  } catch {
    return Response.json({ ok: false }, { status: 503 })
  }
}

Las respuestas reales:

GET /api/productos
{"total":19,"productos":[{"productoId":13,"nombre":"Auriculares Bluetooth","precio":59.9,"stock":80,"categoria":"Audio"},...
x-nextjs-cache: HIT
cache-control: s-maxage=3600, stale-while-revalidate=82800

GET /api/salud
{"ok":true,"hora":"2026-11-02T15:34:08.712Z"}

/api/productos sale ○ en el build: un GET que no lee nada de la petición y cuyos datos están en caché se prerenderiza como un fichero, con las mismas cabeceras de caché que la portada (módulo 12). /api/salud usa connection() para no prerenderizarse nunca: una comprobación de salud congelada diría “estoy bien” aunque la base de datos no respondiera (módulo 23, apartado 4.2).

5.7. Las páginas de error

// app/not-found.tsx
import Link from 'next/link'

export default function NoEncontrado() {
  return (
    <main className="mx-auto max-w-6xl px-4 py-16 text-center">
      <h1 className="text-3xl font-bold text-marca-900">404: no encontrado</h1>
      <p className="my-4 text-slate-600">La página o el producto que buscas no existe.</p>
      <Link href="/" className="text-marca-700 underline">
        Volver a la tienda
      </Link>
    </main>
  )
}
// app/forbidden.tsx
import Link from 'next/link'

export default function Prohibido() {
  return (
    <div className="p-6">
      <h2 className="text-xl font-bold text-red-800">403: no tienes permiso</h2>
      <p className="my-2">Esta página es solo para administración.</p>
      <Link href="/gestion" className="text-marca-700 underline">
        Volver a gestión
      </Link>
    </div>
  )
}

app/gestion/error.tsx, el límite de errores de la zona de gestión, está en el apartado 6.1.

5.8. La tabla del build

Este es el resultado real de npm run build en la carpeta limpia, con Next.js 16.4.4:

▲ Next.js 16.4.4 (Turbopack)
- Environments: .env.local
✓ Running next.config.ts took 184ms
- Cache Components enabled
- Partial Prefetching enabled
- Experiments (use with caution):
  ✓ authInterrupts
  Creating an optimized production build ...
✓ Compiled successfully in 14.3s
  Running TypeScript ...
  Finished TypeScript in 18.7s ...
  Collecting page data using 11 workers ...
  Generating static pages using 11 workers (0/59) ...
[db] listarCatalogo 2026-11-02T15:29:58.102Z
[db] listarCatalogo 2026-11-02T15:29:59.291Z
✓ Generating static pages using 11 workers (59/59) in 6.0s
  Finalizing page optimization ...
Route (app)                                 Revalidate  Expire
┌ ○ /                                               1h      1d
├ ○ /_not-found
├ ○ /api/productos                                  1h      1d
├ ƒ /api/salud
├ ◐ /entrar
├ ◐ /gestion
├ ◐ /gestion/departamentos
├   /gestion/departamentos/[id]
│ └ ◐ /gestion/departamentos/[id]
├ ◐ /gestion/empleados
├   /gestion/empleados/[id]
│ └ ◐ /gestion/empleados/[id]
├ ◐ /gestion/empleados/nuevo
├ ◐ /gestion/pedidos
├   /gestion/pedidos/[id]
│ └ ◐ /gestion/pedidos/[id]
├ ◐ /gestion/pedidos/nuevo
├ ◐ /gestion/productos
├ ○ /opengraph-image
├   /productos/[id]
│ ├ ◐ /productos/[id]
│ ├ ◐ /productos/1                                  1h      1d
│ ├ ◐ /productos/2                                  1h      1d
│ └ ◐ [+17 more paths]
├   /productos/[id]/opengraph-image-1ta0k6
│ ├ ● /productos/1/opengraph-image-1ta0k6           1h      1d
│ ├ ● /productos/2/opengraph-image-1ta0k6           1h      1d
│ ├ ● /productos/3/opengraph-image-1ta0k6           1h      1d
│ └ ● [+16 more paths]
├ ○ /robots.txt
└ ○ /sitemap.xml
ƒ Proxy (Middleware)

Léela como un informe del diseño:

  • La tienda es ○ y ◐ con 1h 1d: la caducidad sale del cacheLife('hours') de listarCatalogo y obtenerProducto (una hora para considerarse antigua, un día como máximo).
  • /productos/[id] sale dos veces: la plantilla genérica ◐ /productos/[id] (para identificadores que no existían en el build) y los 19 productos concretos.
  • Toda la gestión es ◐: armazón estático (menú, títulos, fallbacks) y el contenido en streaming. Ninguna página de gestión es ƒ: eso significa que en ninguna se lee la sesión fuera de <Suspense>.
  • listarCatalogo se ejecutó dos veces durante el build, aunque la usan muchas páginas: el resto la encontró en la caché.

6. La zona de gestión

6.1. Layout, panel y piezas comunes

// app/gestion/layout.tsx
import Link from 'next/link'
import { Suspense } from 'react'
import { verificarSesion } from '@/lib/dal/auth'
import { salir, salirDeTodo } from '@/app/entrar/acciones'

export const metadata = { title: { default: 'Gestión', template: '%s | Gestión EMPRESA' }, robots: { index: false } }

// El layout NO protege a sus páginas: cada página comprueba la sesión por su cuenta.
// Aquí solo se lee la sesión para pintar el menú, y dentro de <Suspense>.
export default function LayoutGestion({ children }: LayoutProps<'/gestion'>) {
  return (
    <div className="flex min-h-[calc(100vh-52px)]">
      <aside className="flex w-56 shrink-0 flex-col gap-2 border-r border-marca-100 bg-marca-50 p-4">
        <Suspense fallback={<p className="text-sm text-slate-500">Cargando usuario...</p>}>
          <MenuUsuario />
        </Suspense>
        <nav className="mt-4 flex flex-col gap-1">
          <Link href="/gestion" className="hover:underline">Panel</Link>
          <Link href="/gestion/empleados" className="hover:underline">Empleados</Link>
          <Link href="/gestion/departamentos" className="hover:underline">Departamentos</Link>
          <Link href="/gestion/productos" className="hover:underline">Productos</Link>
          <Link href="/gestion/pedidos" className="hover:underline">Pedidos</Link>
        </nav>
      </aside>
      <main className="flex-1 p-6">{children}</main>
    </div>
  )
}

async function MenuUsuario() {
  const usuario = await verificarSesion()
  return (
    <div className="text-sm">
      <p className="font-semibold">{usuario.nombre}</p>
      <p className="mt-1 inline-block rounded bg-marca-100 px-2 text-xs">{usuario.rol}</p>
      <form action={salir} className="mt-3">
        <button className="text-marca-700 underline">Cerrar sesión</button>
      </form>
      <form action={salirDeTodo}>
        <button className="text-xs text-marca-700 underline">Cerrar en todos los dispositivos</button>
      </form>
    </div>
  )
}
// app/gestion/page.tsx
import Link from 'next/link'
import { Suspense } from 'react'
import { verificarSesion } from '@/lib/dal/auth'
import { listarPedidos } from '@/lib/db/pedidos'

export const metadata = { title: 'Panel' }

export default function PaginaPanel() {
  return (
    <>
      <h1 className="mb-4 text-2xl font-bold text-marca-900">Panel de gestión</h1>
      <Suspense fallback={<p>Cargando...</p>}>
        <Resumen />
      </Suspense>
    </>
  )
}

async function Resumen() {
  const usuario = await verificarSesion() // la página se protege a sí misma
  const pedidos = await listarPedidos()
  const pendientes = pedidos.filter((p) => p.estado === 'PENDIENTE').length

  return (
    <div className="space-y-4">
      <p>
        Hola, {usuario.nombre}. Has entrado con el rol <strong>{usuario.rol}</strong>.
      </p>
      <ul className="grid max-w-xl grid-cols-2 gap-4">
        <li className="rounded bg-white p-4 shadow">
          <span className="block text-3xl font-bold">{pedidos.length}</span> pedidos
        </li>
        <li className="rounded bg-white p-4 shadow">
          <span className="block text-3xl font-bold text-acento">{pendientes}</span>
          <Link href="/gestion/pedidos" className="underline">pendientes</Link>
        </li>
      </ul>
      {usuario.rol === 'USER' && (
        <p className="text-sm text-slate-600">
          Como USER puedes consultar empleados y departamentos, y gestionar pedidos. Las altas, cambios
          y bajas de empleados, departamentos y productos son de administración.
        </p>
      )}
    </div>
  )
}

El límite de errores de la zona de gestión:

// app/gestion/error.tsx
'use client' // Los límites de error son Client Components

import { useEffect } from 'react'

export default function ErrorGestion({ error, retry }: { error: unknown; retry: () => void }) {
  useEffect(() => {
    console.error(error) // aquí iría tu servicio de registro de errores
  }, [error])

  return (
    <div role="alert" className="rounded border border-red-300 bg-red-50 p-4">
      <h2 className="font-bold text-red-800">Algo ha fallado en la zona de gestión</h2>
      <p className="my-2 text-sm">El error se ha registrado. Puedes volver a intentarlo.</p>
      <button onClick={() => retry()} className="rounded bg-red-700 px-3 py-1 text-white">
        Reintentar
      </button>
    </div>
  )
}

retry en error.tsx 🆕 16.3

🆕 Desde Next.js 16.3. La propiedad retry (que vuelve a pedir los datos y a renderizar) apareció como unstable_retry en 16.2 y es estable desde 16.3. Si usas Next.js 15: recibe reset en su lugar ({ error, reset }), que vuelve a renderizar el segmento sin volver a pedir los datos al servidor; si el fallo venía de los datos, combínalo con router.refresh() dentro de startTransition.

Como el layout de gestión está fuera del límite (el error.tsx envuelve a las páginas, no a su propio layout), un fallo en una página deja el menú en pie y el usuario puede seguir navegando.

Y las dos piezas que comparten todas las pantallas: el aviso que muestra el resultado de la última acción, y las utilidades de las acciones.

// lib/avisos.ts
// Mensajes que una acción deja en la URL al terminar con redirect (?aviso=...).
// En la URL solo viaja el código: nadie puede escribir un texto arbitrario en tu página.
export const AVISOS = {
  'empleado-creado': 'Empleado creado.',
  'empleado-guardado': 'Cambios del empleado guardados.',
  'empleado-borrado': 'Empleado eliminado.',
  'departamento-creado': 'Departamento creado.',
  'departamento-guardado': 'Cambios del departamento guardados.',
  'departamento-borrado': 'Departamento eliminado.',
  'producto-guardado': 'Producto guardado. La tienda ya muestra el cambio.',
  'pedido-creado': 'Pedido creado.',
  'estado-cambiado': 'Estado del pedido actualizado.',
  'error-fk': 'No se puede: hay otros datos que dependen de este registro.',
  'error-permiso': 'Solo administración puede hacer esto.',
  'error-no-existe': 'Ese registro ya no existe.',
  'error-datos': 'Los datos enviados no son válidos.',
  'error-bd': 'Error inesperado en la base de datos.',
} as const

export type CodigoAviso = keyof typeof AVISOS

export function textoAviso(codigo: unknown): { texto: string; error: boolean } | null {
  if (typeof codigo !== 'string' || !(codigo in AVISOS)) return null
  return { texto: AVISOS[codigo as CodigoAviso], error: codigo.startsWith('error-') }
}
// app/gestion/Aviso.tsx
import { textoAviso } from '@/lib/avisos'

// Muestra el aviso que dejó la última acción en la URL (?aviso=...).
// Lee searchParams: hay que usarlo dentro de <Suspense>.
export async function Aviso({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
  const aviso = textoAviso((await searchParams).aviso)
  if (!aviso) return null
  return (
    <p
      role="status"
      className={`mb-4 rounded px-3 py-2 text-sm ${aviso.error ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800'}`}
    >
      {aviso.texto}
    </p>
  )
}

¿Por qué un código en la URL y no el mensaje? Si la página mostrara lo que viene en ?aviso=..., cualquiera podría enviarte un enlace a /gestion/empleados?aviso=Tu sesión ha caducado, vuelve a escribir la contraseña en web-falsa.example, y tu propia aplicación lo mostraría con su aspecto oficial. React escaparía el HTML, pero el texto engañoso seguiría ahí. Con una lista cerrada de códigos, un valor desconocido no se muestra.

// app/gestion/comun.ts
import 'server-only'
import { redirect } from 'next/navigation'
import { obtenerUsuario } from '@/lib/dal/auth'
import { codigoSqlite } from '@/lib/db/errores'
import type { CodigoAviso } from '@/lib/avisos'

// Estado que devuelven las acciones de los formularios con useActionState
export type EstadoFormulario = {
  ok: boolean
  mensaje: string
  errores?: Record<string, string[] | undefined>
  valores?: Record<string, string> // lo que escribió el usuario, para no perderlo
}

// Primera línea de toda acción: ¿quién llama? Sin sesión, a /entrar.
export async function usuarioDeAccion() {
  const usuario = await obtenerUsuario()
  if (!usuario) redirect('/entrar')
  return usuario
}

// FormData -> objeto de cadenas (los ficheros no se usan en estos formularios)
export function valoresDe(formData: FormData): Record<string, string> {
  const valores: Record<string, string> = {}
  for (const [clave, valor] of formData) {
    if (typeof valor === 'string' && !clave.startsWith('$ACTION')) valores[clave] = valor
  }
  return valores
}

// Error de SQLite -> código de aviso para la URL
export function avisoDeError(error: unknown): CodigoAviso {
  return codigoSqlite(error) === 'SQLITE_CONSTRAINT_FOREIGNKEY' ? 'error-fk' : 'error-bd'
}
  • valoresDe descarta los campos $ACTION_...: cuando un formulario con useActionState se envía sin JavaScript, React añade campos ocultos con el identificador de la acción (módulo 10). No son datos del usuario y no deben volver al formulario ni llegar a Zod.
  • import type en los Client Components: los formularios importan el tipo EstadoFormulario de este fichero, que lleva server-only. Como es una importación solo de tipos, TypeScript la borra al compilar y el server-only no llega al navegador.

6.2. El patrón de toda acción

Todas las escrituras de la gestión siguen los mismos cinco pasos, en este orden:

1. Quién    usuarioDeAccion() + comprobar el rol         (sin sesión: redirect('/entrar'))
2. Qué      Zod: safeParse de TODO lo que llega          (errores por campo, sin tocar la BD)
3. Escribir lib/db dentro de try/catch                   (traducir UNIQUE, FOREIGN KEY, CHECK)
4. Renovar  updateTag(...) de cada etiqueta afectada     (solo si la escritura fue bien)
5. Salir    redirect('/...?aviso=codigo')                (303: funciona también sin JavaScript)

Y dos variantes según cómo se usa la acción:

Variante Firma Errores Se usa en
Formulario con useActionState (anterior: EstadoFormulario, formData) Devuelve el estado con los errores por campo y lo escrito Altas y ediciones: el usuario corrige y reenvía
Botón o formulario simple (formData) redirect con un código de error en la URL Borrar, guardar una fila de productos, cambiar el estado de un pedido

⚠️ Importante: redirect funciona lanzando una excepción. Si lo pones dentro del try, el catch se lo traga (módulo 10). En todas las acciones del proyecto el try rodea solo la llamada a la base de datos, y el redirect final va fuera. Dentro de un catch sí puedes llamar a redirect: la excepción sale del bloque catch y nadie la captura.

6.3. Departamentos

// app/gestion/departamentos/acciones.ts
'use server'

import { updateTag } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import { AVISOS } from '@/lib/avisos'
import {
  actualizarDepartamento,
  crearDepartamento,
  eliminarDepartamento,
} from '@/lib/db/departamentos'
import { mensajeDeError } from '@/lib/db/errores'
import { esquemaDepartamento, esquemaId } from '@/lib/validacion'
import { avisoDeError, usuarioDeAccion, valoresDe, type EstadoFormulario } from '../comun'

export async function guardarDepartamento(
  _anterior: EstadoFormulario,
  formData: FormData,
): Promise<EstadoFormulario> {
  const usuario = await usuarioDeAccion()
  if (usuario.rol !== 'ADMIN') return { ok: false, mensaje: AVISOS['error-permiso'] }

  const { deptId: textoId, ...valores } = valoresDe(formData)
  const deptId = textoId ? esquemaId.safeParse(textoId) : null
  if (deptId && !deptId.success) return { ok: false, mensaje: AVISOS['error-datos'] }

  const resultado = esquemaDepartamento.safeParse(valores)
  if (!resultado.success) {
    return {
      ok: false,
      mensaje: 'Revisa los campos marcados.',
      errores: z.flattenError(resultado.error).fieldErrors,
      valores,
    }
  }

  try {
    if (deptId) {
      const fila = await actualizarDepartamento(deptId.data, resultado.data)
      if (!fila) return { ok: false, mensaje: AVISOS['error-no-existe'] }
    } else {
      await crearDepartamento(resultado.data)
    }
  } catch (error) {
    console.error('[guardarDepartamento]', error)
    return { ok: false, mensaje: mensajeDeError(error), valores }
  }

  updateTag('departamentos')
  if (deptId) updateTag(`departamento-${deptId.data}`)
  updateTag('empleados') // la lista de empleados muestra el nombre del departamento
  redirect(`/gestion/departamentos?aviso=${deptId ? 'departamento-guardado' : 'departamento-creado'}`)
}

export async function borrarDepartamento(formData: FormData) {
  const usuario = await usuarioDeAccion()
  if (usuario.rol !== 'ADMIN') redirect('/gestion/departamentos?aviso=error-permiso')

  const deptId = esquemaId.safeParse(formData.get('deptId'))
  if (!deptId.success) redirect('/gestion/departamentos?aviso=error-datos')

  let borrado: boolean
  try {
    borrado = await eliminarDepartamento(deptId.data)
  } catch (error) {
    // Con empleados o proyectos, la clave ajena (ON DELETE NO ACTION) lo impide
    console.error('[borrarDepartamento]', (error as Error).cause ?? error)
    redirect(`/gestion/departamentos?aviso=${avisoDeError(error)}`)
  }
  if (!borrado) redirect('/gestion/departamentos?aviso=error-no-existe')

  updateTag('departamentos')
  updateTag(`departamento-${deptId.data}`)
  redirect('/gestion/departamentos?aviso=departamento-borrado')
}

Qué etiquetas caduca cada escritura, y por qué:

Acción Etiquetas Motivo
Alta de departamento departamentos, empleados La lista de departamentos tiene uno más; los <select> de empleados lo necesitan
Edición departamentos, departamento-N, empleados El nombre cambiado aparece en la lista de empleados
Baja departamentos, departamento-N Ningún empleado puede estar en un departamento que se borra (la clave ajena lo impide)

Olvidar una etiqueta no da ningún error: simplemente, una pantalla seguirá mostrando el dato viejo hasta que caduque su caché (horas o días). Es el fallo más difícil de ver de todo el proyecto, y por eso las pruebas comprueban qué etiquetas se piden (apartado 8).

El formulario, que sirve para el alta y la edición:

// app/gestion/departamentos/FormularioDepartamento.tsx
'use client'

import { useActionState } from 'react'
import type { EstadoFormulario } from '../comun'
import { guardarDepartamento } from './acciones'

const vacio: EstadoFormulario = { ok: true, mensaje: '' }
const CAMPOS = [
  { nombre: 'nombre', etiqueta: 'Nombre' },
  { nombre: 'localidad', etiqueta: 'Localidad' },
  { nombre: 'presupuesto', etiqueta: 'Presupuesto' },
] as const

export function FormularioDepartamento({
  deptId,
  inicial = {},
}: {
  deptId?: number
  inicial?: Partial<Record<string, string>>
}) {
  const [estado, accion, pendiente] = useActionState(guardarDepartamento, vacio)

  return (
    <form
      action={accion}
      aria-label={deptId ? 'Editar departamento' : 'Nuevo departamento'}
      className="space-y-2 rounded bg-white p-4 shadow"
    >
      {deptId && <input type="hidden" name="deptId" value={deptId} />}
      <div className="flex flex-wrap gap-3">
        {CAMPOS.map(({ nombre, etiqueta }) => (
          <label key={nombre} className="flex flex-col text-sm">
            {etiqueta}
            <input
              name={nombre}
              defaultValue={estado.valores?.[nombre] ?? inicial[nombre] ?? ''}
              inputMode={nombre === 'presupuesto' ? 'decimal' : undefined}
              className="rounded border px-2 py-1"
            />
            <span className="text-xs text-red-700">{estado.errores?.[nombre]?.[0]}</span>
          </label>
        ))}
      </div>
      <button disabled={pendiente} className="rounded bg-marca-700 px-3 py-1 text-white disabled:opacity-50">
        {pendiente ? 'Guardando...' : deptId ? 'Guardar' : 'Crear'}
      </button>
      {estado.mensaje && (
        <p role="status" className="text-sm text-red-700">{estado.mensaje}</p>
      )}
    </form>
  )
}

defaultValue={estado.valores?.[nombre] ?? inicial[nombre] ?? ''} resuelve el problema del módulo 10: tras una acción, React 19 reinicia el formulario y los campos vuelven a su defaultValue. Sin valores, el usuario que se equivocó en un campo perdería todo lo escrito. Con ellos, tras el error ve exactamente lo que envió, con el campo erróneo marcado.

Las páginas:

// app/gestion/departamentos/page.tsx
import Link from 'next/link'
import { Suspense } from 'react'
import { verificarSesion } from '@/lib/dal/auth'
import { listarDepartamentos } from '@/lib/db/departamentos'
import { formatoEuros } from '@/lib/formato'
import { Aviso } from '../Aviso'
import { borrarDepartamento } from './acciones'
import { FormularioDepartamento } from './FormularioDepartamento'

export const metadata = { title: 'Departamentos' }

export default function PaginaDepartamentos({ searchParams }: PageProps<'/gestion/departamentos'>) {
  return (
    <>
      <h1 className="mb-4 text-2xl font-bold text-marca-900">Departamentos</h1>
      <Suspense fallback={null}>
        <Aviso searchParams={searchParams} />
      </Suspense>
      <Suspense fallback={<p>Cargando departamentos...</p>}>
        <Contenido />
      </Suspense>
    </>
  )
}

async function Contenido() {
  const usuario = await verificarSesion()
  const departamentos = await listarDepartamentos()
  const esAdmin = usuario.rol === 'ADMIN'

  return (
    <div className="space-y-6">
      <table className="w-full bg-white text-sm shadow">
        <thead className="bg-marca-50 text-left">
          <tr>
            <th className="p-2">Id</th>
            <th className="p-2">Nombre</th>
            <th className="p-2">Localidad</th>
            <th className="p-2 text-right">Presupuesto</th>
            <th className="p-2 text-right">Empleados</th>
            {esAdmin && <th className="p-2">Acciones</th>}
          </tr>
        </thead>
        <tbody>
          {departamentos.map((d) => (
            <tr key={d.deptId} className="border-t">
              <td className="p-2">{d.deptId}</td>
              <td className="p-2">{d.nombre}</td>
              <td className="p-2">{d.localidad}</td>
              <td className="p-2 text-right">{formatoEuros(d.presupuesto)}</td>
              <td className="p-2 text-right">{d.empleados}</td>
              {esAdmin && (
                <td className="flex gap-3 p-2">
                  <Link href={`/gestion/departamentos/${d.deptId}`} className="text-marca-700 underline">
                    Editar
                  </Link>
                  <form action={borrarDepartamento}>
                    <input type="hidden" name="deptId" value={d.deptId} />
                    <button className="text-red-700 underline" aria-label={`Borrar ${d.nombre}`}>
                      Borrar
                    </button>
                  </form>
                </td>
              )}
            </tr>
          ))}
        </tbody>
      </table>
      {esAdmin && <FormularioDepartamento />}
    </div>
  )
}
// app/gestion/departamentos/[id]/page.tsx
import { notFound } from 'next/navigation'
import { Suspense } from 'react'
import { exigirRol } from '@/lib/dal/auth'
import { obtenerDepartamento } from '@/lib/db/departamentos'
import { esquemaId } from '@/lib/validacion'
import { FormularioDepartamento } from '../FormularioDepartamento'

export const metadata = { title: 'Editar departamento' }

export default function PaginaEditarDepartamento({ params }: PageProps<'/gestion/departamentos/[id]'>) {
  return (
    <>
      <h1 className="mb-4 text-2xl font-bold text-marca-900">Editar departamento</h1>
      <Suspense fallback={<p>Cargando...</p>}>
        <Editor params={params} />
      </Suspense>
    </>
  )
}

async function Editor({ params }: Pick<PageProps<'/gestion/departamentos/[id]'>, 'params'>) {
  await exigirRol('ADMIN')
  const id = esquemaId.safeParse((await params).id)
  if (!id.success) notFound()
  const dept = await obtenerDepartamento(id.data)
  if (!dept) notFound()

  return (
    <div className="space-y-4">
      <FormularioDepartamento
        deptId={dept.deptId}
        inicial={{
          nombre: dept.nombre,
          localidad: dept.localidad,
          presupuesto: dept.presupuesto === null ? '' : String(dept.presupuesto),
        }}
      />
      <h2 className="font-semibold">Empleados ({dept.empleados.length})</h2>
      <ul className="list-inside list-disc text-sm">
        {dept.empleados.map((e) => (
          <li key={e.empId}>
            {e.apellidos}, {e.nombre} ({e.oficio})
          </li>
        ))}
      </ul>
    </div>
  )
}

El botón Borrar es un <form> con la acción directa, sin useActionState: no hay campos que corregir, solo un resultado. Y lleva aria-label={Borrar ${d.nombre}}: siete botones que dicen “Borrar” son indistinguibles para un lector de pantalla (y para una prueba E2E); con la etiqueta, cada uno dice qué borra.

Página de departamentos de Ana con el aviso en rojo No se puede: hay otros datos que dependen de este registro, la tabla de los siete departamentos con su presupuesto y número de empleados, los enlaces Editar y Borrar, y el formulario de alta vacío

Figura 4. Tras pulsar “Borrar” en VENTAS, que tiene seis empleados: la clave ajena lo impide y la acción redirige con ?aviso=error-fk.

El registro del servidor en ese momento muestra el error real y su origen:

[borrarDepartamento] Error [LibsqlError]: SQLITE_CONSTRAINT: FOREIGN KEY constraint failed
  [cause]: SqliteError: FOREIGN KEY constraint failed

6.4. Empleados

// app/gestion/empleados/acciones.ts
'use server'

import { updateTag } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import { AVISOS } from '@/lib/avisos'
import { actualizarEmpleado, crearEmpleado, eliminarEmpleado } from '@/lib/db/empleados'
import { codigoSqlite, mensajeDeError } from '@/lib/db/errores'
import { esquemaEmpleado, esquemaId } from '@/lib/validacion'
import { usuarioDeAccion, valoresDe, type EstadoFormulario } from '../comun'

// Alta (sin empId) o edición (con empId) desde el mismo formulario
export async function guardarEmpleado(
  _anterior: EstadoFormulario,
  formData: FormData,
): Promise<EstadoFormulario> {
  // 1. Quién: una Server Action es un endpoint público
  const usuario = await usuarioDeAccion()
  if (usuario.rol !== 'ADMIN') return { ok: false, mensaje: AVISOS['error-permiso'] }

  // 2. Qué: validar todo lo que llega, también el empId oculto
  const { empId: textoId, ...valores } = valoresDe(formData)
  const empId = textoId ? esquemaId.safeParse(textoId) : null
  if (empId && !empId.success) return { ok: false, mensaje: AVISOS['error-datos'] }

  const resultado = esquemaEmpleado.safeParse(valores)
  if (!resultado.success) {
    return {
      ok: false,
      mensaje: 'Revisa los campos marcados.',
      errores: z.flattenError(resultado.error).fieldErrors,
      valores,
    }
  }

  // 3. Escribir: el try rodea solo la base de datos (redirect va fuera)
  let id: number
  try {
    if (empId) {
      const fila = await actualizarEmpleado(empId.data, resultado.data)
      if (!fila) return { ok: false, mensaje: AVISOS['error-no-existe'] }
      id = fila.empId
    } else {
      id = (await crearEmpleado(resultado.data)).empId
    }
  } catch (error) {
    console.error('[guardarEmpleado]', error)
    const codigo = codigoSqlite(error)
    if (codigo === 'SQLITE_CONSTRAINT_UNIQUE') {
      return { ok: false, mensaje: 'Revisa los campos marcados.', errores: { email: ['Ya hay un empleado con ese email'] }, valores }
    }
    if (codigo === 'SQLITE_CONSTRAINT_FOREIGNKEY') {
      // SQLite no dice qué clave ha fallado: solo puede ser una de las dos
      return { ok: false, mensaje: 'El departamento o el jefe elegido no existe.', valores }
    }
    return { ok: false, mensaje: mensajeDeError(error), valores }
  }

  // 4. Renovar la caché de lo que ha cambiado y salir con redirect (funciona sin JavaScript)
  updateTag('empleados')
  updateTag(`empleado-${id}`)
  updateTag('departamentos') // el recuento de empleados por departamento
  redirect(`/gestion/empleados?aviso=${empId ? 'empleado-guardado' : 'empleado-creado'}`)
}

export async function borrarEmpleado(formData: FormData) {
  const usuario = await usuarioDeAccion()
  if (usuario.rol !== 'ADMIN') redirect('/gestion/empleados?aviso=error-permiso')

  const empId = esquemaId.safeParse(formData.get('empId'))
  if (!empId.success) redirect('/gestion/empleados?aviso=error-datos')

  // jefe_id, comercial_id y empleado_id pasan a NULL (ON DELETE SET NULL);
  // sus asignaciones a proyectos se borran (CASCADE)
  const borrado = await eliminarEmpleado(empId.data)
  if (!borrado) redirect('/gestion/empleados?aviso=error-no-existe')

  updateTag('empleados')
  updateTag(`empleado-${empId.data}`)
  updateTag('departamentos')
  redirect('/gestion/empleados?aviso=empleado-borrado')
}

Los errores de SQLite se traducen a su campo, no solo a un mensaje general. El UNIQUE solo puede venir del email (es la única columna única de empleados), así que se marca ese campo. La clave ajena, en cambio, puede ser el departamento o el jefe, y SQLite no dice cuál (módulo 16, apartado 7.1): el mensaje lo reconoce. Con los <select> de la interfaz no puede pasar, pero sí con una petición manipulada que envíe deptId=99; la prueba del apartado 8 lo hace.

borrarEmpleado no tiene try/catch: en el esquema EMPRESA, ninguna clave ajena hacia empleados impide el borrado (jefe_id, comercial_id, empleado_id y usuarios.emp_id pasan a NULL; las asignaciones se borran en cascada). Que sea imposible que falle es una decisión del esquema, no un descuido de la acción. Si borras a Ana (empleado 100), su usuario sigue existiendo pero queda sin empleado asociado.

El formulario:

// app/gestion/empleados/FormularioEmpleado.tsx
'use client'

import { useActionState, type InputHTMLAttributes } from 'react'
import type { EstadoFormulario } from '../comun'
import { guardarEmpleado } from './acciones'

type Opcion = { id: number; texto: string }
type Inicial = Partial<Record<string, string>>

const vacio: EstadoFormulario = { ok: true, mensaje: '' }

export function FormularioEmpleado({
  empId,
  inicial = {},
  departamentos,
  jefes,
}: {
  empId?: number
  inicial?: Inicial
  departamentos: Opcion[]
  jefes: Opcion[]
}) {
  const [estado, accion, pendiente] = useActionState(guardarEmpleado, vacio)
  // Tras un error, lo que escribió el usuario; si no, los datos iniciales
  const valor = (campo: string) => estado.valores?.[campo] ?? inicial[campo] ?? ''
  const error = (campo: string) => estado.errores?.[campo]?.[0]

  const campo = (nombre: string, etiqueta: string, props: InputHTMLAttributes<HTMLInputElement> = {}) => (
    <label className="flex flex-col text-sm">
      {etiqueta}
      <input name={nombre} defaultValue={valor(nombre)} className="rounded border px-2 py-1" {...props} />
      <span className="text-xs text-red-700">{error(nombre)}</span>
    </label>
  )

  return (
    <form action={accion} aria-label="Empleado" className="grid max-w-2xl grid-cols-2 gap-3 rounded bg-white p-4 shadow">
      {empId && <input type="hidden" name="empId" value={empId} />}
      {campo('nombre', 'Nombre', { required: true })}
      {campo('apellidos', 'Apellidos', { required: true })}
      {campo('email', 'Email', { type: 'email', required: true })}
      {campo('telefono', 'Teléfono')}
      {campo('fechaAlta', 'Fecha de alta', { type: 'date', required: true })}
      {campo('oficio', 'Oficio', { required: true })}
      {campo('salario', 'Salario', { inputMode: 'decimal', required: true })}
      {campo('comision', 'Comisión', { inputMode: 'decimal' })}
      <label className="flex flex-col text-sm">
        Departamento
        {/* key: al reiniciar el formulario, un <select> vuelve a la opción con la que se montó */}
        <select key={valor('deptId')} name="deptId" defaultValue={valor('deptId')} className="rounded border px-2 py-1">
          <option value="">Sin departamento</option>
          {departamentos.map((d) => (
            <option key={d.id} value={d.id}>{d.texto}</option>
          ))}
        </select>
      </label>
      <label className="flex flex-col text-sm">
        Jefe
        <select key={valor('jefeId')} name="jefeId" defaultValue={valor('jefeId')} className="rounded border px-2 py-1">
          <option value="">Sin jefe</option>
          {jefes.map((j) => (
            <option key={j.id} value={j.id}>{j.texto}</option>
          ))}
        </select>
      </label>
      <div className="col-span-2 flex items-center gap-3">
        <button disabled={pendiente} className="rounded bg-marca-700 px-4 py-1 text-white disabled:opacity-50">
          {pendiente ? 'Guardando...' : 'Guardar'}
        </button>
        {estado.mensaje && (
          <p role="status" className="text-sm text-red-700">{estado.mensaje}</p>
        )}
      </div>
    </form>
  )
}

La trampa del <select> al reiniciar el formulario

La primera versión de este formulario no tenía el key en los <select>. Las pruebas E2E fallaron así: se elegía el departamento 30, se enviaba con un error en el salario, y tras corregirlo y volver a enviar… el empleado se creaba sin departamento. En el formulario de pedidos era peor: tras el error de stock, el segundo envío fallaba con “Elige un cliente”.

La causa: después de una acción, React reinicia el formulario (como form.reset()), y cada campo vuelve a su valor por defecto. En un <input>, React actualiza el valor por defecto cuando cambia defaultValue, así que el reinicio deja lo que venía en estado.valores. En un <select>, en cambio, la opción por defecto es la que estaba marcada cuando se montó, y cambiar defaultValue después no la actualiza. El reinicio lo devolvía a “Sin departamento”.

La solución es key={valor('deptId')}: si el valor cambia, React monta un <select> nuevo, que nace con la opción correcta como opción por defecto. La prueba E2E del apartado 8 comprueba que, tras dos errores seguidos, el departamento sigue siendo el 30.

Las opciones de los <select> y las páginas:

// app/gestion/empleados/opciones.ts
import 'server-only'
import { listarDepartamentos } from '@/lib/db/departamentos'
import { listarEmpleados } from '@/lib/db/empleados'

// Opciones de los <select> del formulario de empleado (ambas listas en caché)
export async function opcionesFormulario() {
  const [departamentos, empleados] = await Promise.all([listarDepartamentos(), listarEmpleados()])
  return {
    departamentos: departamentos.map((d) => ({ id: d.deptId, texto: `${d.deptId} ${d.nombre}` })),
    jefes: empleados.map((e) => ({ id: e.empId, texto: `${e.nombre} ${e.apellidos}` })),
  }
}
// app/gestion/empleados/page.tsx
import Link from 'next/link'
import { Suspense } from 'react'
import { verificarSesion } from '@/lib/dal/auth'
import { listarEmpleados } from '@/lib/db/empleados'
import { Aviso } from '../Aviso'
import { borrarEmpleado } from './acciones'

export const metadata = { title: 'Empleados' }

export default function PaginaEmpleados({ searchParams }: PageProps<'/gestion/empleados'>) {
  return (
    <>
      <div className="mb-4 flex items-center gap-4">
        <h1 className="text-2xl font-bold text-marca-900">Empleados</h1>
        <Link href="/gestion/empleados/nuevo" className="rounded bg-marca-700 px-3 py-1 text-sm text-white">
          Nuevo empleado
        </Link>
      </div>
      <Suspense fallback={null}>
        <Aviso searchParams={searchParams} />
      </Suspense>
      <Suspense fallback={<p>Cargando empleados...</p>}>
        <TablaEmpleados />
      </Suspense>
    </>
  )
}

async function TablaEmpleados() {
  const usuario = await verificarSesion() // autorizar fuera...
  const empleados = await listarEmpleados() // ...y leer de la caché dentro
  const esAdmin = usuario.rol === 'ADMIN'

  return (
    <table className="w-full bg-white text-sm shadow">
      <thead className="bg-marca-50 text-left">
        <tr>
          <th className="p-2">Id</th>
          <th className="p-2">Nombre</th>
          <th className="p-2">Email</th>
          <th className="p-2">Oficio</th>
          <th className="p-2">Departamento</th>
          {esAdmin && <th className="p-2">Acciones</th>}
        </tr>
      </thead>
      <tbody>
        {empleados.map((e) => (
          <tr key={e.empId} className="border-t">
            <td className="p-2">{e.empId}</td>
            <td className="p-2">{e.nombre} {e.apellidos}</td>
            <td className="p-2">{e.email}</td>
            <td className="p-2">{e.oficio}</td>
            <td className="p-2">{e.departamento ?? 'Sin departamento'}</td>
            {esAdmin && (
              <td className="flex gap-3 p-2">
                <Link href={`/gestion/empleados/${e.empId}`} className="text-marca-700 underline">
                  Editar
                </Link>
                {/* Formulario con la acción directa: funciona sin JavaScript */}
                <form action={borrarEmpleado}>
                  <input type="hidden" name="empId" value={e.empId} />
                  <button className="text-red-700 underline" aria-label={`Borrar ${e.empId}`}>
                    Borrar
                  </button>
                </form>
              </td>
            )}
          </tr>
        ))}
      </tbody>
    </table>
  )
}
// app/gestion/empleados/nuevo/page.tsx
import { Suspense } from 'react'
import { exigirRol } from '@/lib/dal/auth'
import { FormularioEmpleado } from '../FormularioEmpleado'
import { opcionesFormulario } from '../opciones'

export const metadata = { title: 'Nuevo empleado' }

export default function PaginaNuevoEmpleado() {
  return (
    <>
      <h1 className="mb-4 text-2xl font-bold text-marca-900">Nuevo empleado</h1>
      <Suspense fallback={<p>Cargando...</p>}>
        <Formulario />
      </Suspense>
    </>
  )
}

async function Formulario() {
  await exigirRol('ADMIN') // USER: forbidden()
  const opciones = await opcionesFormulario()
  const hoy = new Date().toISOString().slice(0, 10)
  return <FormularioEmpleado inicial={{ fechaAlta: hoy }} {...opciones} />
}
// app/gestion/empleados/[id]/page.tsx
import { notFound } from 'next/navigation'
import { Suspense } from 'react'
import { exigirRol } from '@/lib/dal/auth'
import { obtenerEmpleado } from '@/lib/db/empleados'
import { esquemaId } from '@/lib/validacion'
import { FormularioEmpleado } from '../FormularioEmpleado'
import { opcionesFormulario } from '../opciones'

export const metadata = { title: 'Editar empleado' }

export default function PaginaEditarEmpleado({ params }: PageProps<'/gestion/empleados/[id]'>) {
  return (
    <>
      <h1 className="mb-4 text-2xl font-bold text-marca-900">Editar empleado</h1>
      <Suspense fallback={<p>Cargando...</p>}>
        <Formulario params={params} />
      </Suspense>
    </>
  )
}

async function Formulario({ params }: Pick<PageProps<'/gestion/empleados/[id]'>, 'params'>) {
  await exigirRol('ADMIN')
  const id = esquemaId.safeParse((await params).id)
  if (!id.success) notFound()
  const empleado = await obtenerEmpleado(id.data)
  if (!empleado) notFound()

  // Los campos del formulario son texto: null se convierte en ''
  const inicial = Object.fromEntries(
    Object.entries(empleado).map(([clave, valor]) => [clave, valor === null ? '' : String(valor)]),
  )
  return <FormularioEmpleado empId={empleado.empId} inicial={inicial} {...await opcionesFormulario()} />
}

new Date() en nuevo/page.tsx está dentro del componente protegido por <Suspense>, después de exigirRol, que lee las cookies. Si se calculara arriba de la página, en el armazón prerenderizado, la fecha de alta por defecto sería la del día del build.

Formulario de nuevo empleado con el nombre E marcado con Mínimo 2 caracteres, el salario -5 marcado con Debe ser mayor que 0, el email de Ana, y el mensaje Revisa los campos marcados junto al botón Guardar

Figura 5. Errores de validación de Zod por campo. Lo escrito se conserva; el email repetido se detectará en el siguiente envío, cuando lo rechace el UNIQUE de SQLite.

Página con el título 403: no tienes permiso y un enlace para volver a gestión

Figura 6. Luis (USER) al abrir /gestion/empleados/nuevo: exigirRol('ADMIN') llama a forbidden() y se muestra app/forbidden.tsx.

6.5. Productos: el cambio que se ve en la tienda

// app/gestion/productos/acciones.ts
'use server'

import { updateTag } from 'next/cache'
import { redirect } from 'next/navigation'
import { actualizarProducto } from '@/lib/db/catalogo'
import { esquemaProducto } from '@/lib/validacion'
import { usuarioDeAccion, valoresDe } from '../comun'

export async function guardarProducto(formData: FormData) {
  const usuario = await usuarioDeAccion()
  if (usuario.rol !== 'ADMIN') redirect('/gestion/productos?aviso=error-permiso')

  const datos = esquemaProducto.safeParse(valoresDe(formData))
  if (!datos.success) redirect('/gestion/productos?aviso=error-datos')

  const { productoId, ...cambios } = datos.data
  const fila = await actualizarProducto(productoId, cambios)
  if (!fila) redirect('/gestion/productos?aviso=error-no-existe')

  // La tienda pública lee de la caché: hay que caducar sus etiquetas
  updateTag('productos') // portada, API JSON y todas las fichas
  updateTag(`producto-${productoId}`) // esta ficha (ya incluida en 'productos'; se pone por claridad)
  redirect('/gestion/productos?aviso=producto-guardado')
}
// app/gestion/productos/page.tsx
import Link from 'next/link'
import { Suspense } from 'react'
import { verificarSesion } from '@/lib/dal/auth'
import { listarProductosGestion } from '@/lib/db/catalogo'
import { formatoEuros } from '@/lib/formato'
import { Aviso } from '../Aviso'
import { guardarProducto } from './acciones'

export const metadata = { title: 'Productos' }

export default function PaginaProductos({ searchParams }: PageProps<'/gestion/productos'>) {
  return (
    <>
      <h1 className="mb-4 text-2xl font-bold text-marca-900">Productos</h1>
      <Suspense fallback={null}>
        <Aviso searchParams={searchParams} />
      </Suspense>
      <Suspense fallback={<p>Cargando productos...</p>}>
        <TablaProductos />
      </Suspense>
    </>
  )
}

async function TablaProductos() {
  const usuario = await verificarSesion()
  const productos = await listarProductosGestion()
  const esAdmin = usuario.rol === 'ADMIN'

  return (
    <table className="w-full bg-white text-sm shadow">
      <thead className="bg-marca-50 text-left">
        <tr>
          <th className="p-2">Id</th>
          <th className="p-2">Producto</th>
          <th className="p-2">Categoría</th>
          <th className="p-2">{esAdmin ? 'Precio, stock y estado' : 'Precio'}</th>
        </tr>
      </thead>
      <tbody>
        {productos.map((p) => (
          <tr key={p.productoId} className={`border-t ${p.descatalogado ? 'text-slate-400' : ''}`}>
            <td className="p-2">{p.productoId}</td>
            <td className="p-2">
              <Link href={`/productos/${p.productoId}`} className="underline">{p.nombre}</Link>
            </td>
            <td className="p-2">{p.categoria}</td>
            <td className="p-2">
              {esAdmin ? (
                <form action={guardarProducto} aria-label={`Producto ${p.productoId}`} className="flex items-center gap-2">
                  <input type="hidden" name="productoId" value={p.productoId} />
                  <input name="precio" aria-label="Precio" defaultValue={p.precio} inputMode="decimal" className="w-24 rounded border px-1" />
                  <input name="stock" aria-label="Stock" defaultValue={p.stock} inputMode="numeric" className="w-16 rounded border px-1" />
                  <label className="flex items-center gap-1 text-xs">
                    <input type="checkbox" name="descatalogado" defaultChecked={p.descatalogado} /> Descatalogado
                  </label>
                  <button className="rounded bg-marca-700 px-2 text-xs text-white">Guardar</button>
                </form>
              ) : (
                <>{formatoEuros(p.precio)} · stock {p.stock}</>
              )}
            </td>
          </tr>
        ))}
      </tbody>
    </table>
  )
}

Esta es la prueba de fuego de la caché. La prueba E2E del apartado 8 cambia el precio del producto 1 de 899 a 849 € y comprueba, en este orden, que la API JSON devuelve 849, que la portada muestra “849,00 €” y que la ficha también. Las tres leen de la caché (listarCatalogo y obtenerProducto, ambas con la etiqueta productos), y updateTag('productos') las caduca todas a la vez.

Para ver el efecto sin el navegador se hizo además una sonda: pedir con request.get la ficha, la API y la portada antes y justo después de cambiar el precio del producto 2 de 1499 a 1399 €, contando las apariciones de cada precio:

ANTES   /productos/2 1499:3 1399:0  /api/productos 1499:1 1399:0  / 1499:2 1399:0
DESPUES /productos/2 1499:0 1399:3  /api/productos 1499:0 1399:1  / 1499:0 1399:2

La primera petición después del cambio ya trae el precio nuevo en las tres URL, y el registro del servidor muestra una sola línea [db] listarCatalogo nueva: las tres páginas se regeneraron con una única consulta.

⚠️ Importante: En la primera versión de la prueba E2E, que visitaba la ficha justo después de guardar y antes que la API, la ficha mostró una vez el precio antiguo (899 €); al repetir la ejecución con el orden actual (API, portada, ficha) y en la sonda, siempre salió el precio nuevo, y tras reiniciar el servidor la ficha también mostraba 849 €. No se llegó a aislar la causa. Si una página ◐ de tu proyecto muestra una vez un dato viejo tras updateTag, recárgala antes de dar por roto el código, y comprueba la caché con una sonda como la de arriba.

6.6. Pedidos

// app/gestion/pedidos/acciones.ts
'use server'

import { updateTag } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import { cambiarEstado, crearPedido } from '@/lib/db/pedidos'
import { codigoSqlite, mensajeDeError } from '@/lib/db/errores'
import { esquemaEstado, esquemaPedido } from '@/lib/validacion'
import { usuarioDeAccion, valoresDe, type EstadoFormulario } from '../comun'

// Cualquier usuario con sesión (ADMIN o USER) puede crear pedidos
export async function nuevoPedido(
  _anterior: EstadoFormulario,
  formData: FormData,
): Promise<EstadoFormulario> {
  const usuario = await usuarioDeAccion()

  const valores = valoresDe(formData)
  const resultado = esquemaPedido.safeParse(valores)
  if (!resultado.success) {
    return {
      ok: false,
      mensaje: 'Revisa los campos marcados.',
      errores: z.flattenError(resultado.error).fieldErrors,
      valores,
    }
  }

  const { clienteId, productoId, cantidad } = resultado.data
  let pedidoId: number
  try {
    // El pedido queda a nombre del empleado que lo registra
    pedidoId = await crearPedido(clienteId, [{ productoId, cantidad }], usuario.empId)
  } catch (error) {
    console.error('[nuevoPedido]', (error as Error).cause ?? error)
    // CHECK (stock >= 0): la transacción se ha deshecho entera
    if (codigoSqlite(error) === 'SQLITE_CONSTRAINT_CHECK') {
      return { ok: false, mensaje: 'Revisa los campos marcados.', errores: { cantidad: ['No hay stock suficiente'] }, valores }
    }
    return { ok: false, mensaje: mensajeDeError(error), valores }
  }

  updateTag('productos') // el stock forma parte del catálogo en caché (API JSON)
  redirect(`/gestion/pedidos/${pedidoId}?aviso=pedido-creado`)
}

export async function guardarEstado(formData: FormData) {
  await usuarioDeAccion()
  const datos = esquemaEstado.safeParse(valoresDe(formData))
  if (!datos.success) redirect('/gestion/pedidos?aviso=error-datos')

  const fila = await cambiarEstado(datos.data.pedidoId, datos.data.estado)
  if (!fila) redirect('/gestion/pedidos?aviso=error-no-existe')

  // La lista de pedidos no está en caché: no hay etiqueta que caducar.
  // redirect (303) hace que el navegador pida la página otra vez, con o sin JavaScript.
  redirect(`/gestion/pedidos/${fila.pedidoId}?aviso=estado-cambiado`)
}

El pedido se crea a nombre de usuario.empId, el empleado asociado al usuario que ha iniciado sesión: nunca de un campo del formulario. Si el formulario tuviera un campo oculto empleadoId, cualquiera podría registrar pedidos a nombre de otro compañero (el error IDOR del módulo 17, apartado 7.4).

¿Por qué updateTag('productos') al crear un pedido, si la ficha lee el stock sin caché? Porque listarCatalogo incluye el stock en su DTO, y la API JSON lo publica. Sin esa línea, la API seguiría diciendo "stock":40 durante una hora mientras la ficha ya dice 37. Es un buen ejemplo de por qué conviene que los DTO no lleven más de lo que necesitan: si la tienda no necesitara el stock en la lista, esa etiqueta sobraría.

// app/gestion/pedidos/nuevo/FormularioPedido.tsx
'use client'

import { useActionState } from 'react'
import type { EstadoFormulario } from '../../comun'
import { nuevoPedido } from '../acciones'

type Opcion = { id: number; texto: string }
const vacio: EstadoFormulario = { ok: true, mensaje: '' }

export function FormularioPedido({ clientes, productos }: { clientes: Opcion[]; productos: Opcion[] }) {
  const [estado, accion, pendiente] = useActionState(nuevoPedido, vacio)
  const error = (campo: string) => estado.errores?.[campo]?.[0]

  const selector = (nombre: string, etiqueta: string, opciones: Opcion[]) => (
    <label className="flex flex-col text-sm">
      {etiqueta}
      {/* key: al reiniciar el formulario, un <select> vuelve a la opción con la que se montó */}
      <select key={estado.valores?.[nombre]} name={nombre} defaultValue={estado.valores?.[nombre] ?? ''} className="rounded border px-2 py-1">
        <option value="">Elige...</option>
        {opciones.map((o) => (
          <option key={o.id} value={o.id}>{o.texto}</option>
        ))}
      </select>
      <span className="text-xs text-red-700">{error(nombre)}</span>
    </label>
  )

  return (
    <form action={accion} aria-label="Nuevo pedido" className="flex max-w-xl flex-col gap-3 rounded bg-white p-4 shadow">
      {selector('clienteId', 'Cliente', clientes)}
      {selector('productoId', 'Producto', productos)}
      <label className="flex flex-col text-sm">
        Cantidad
        <input name="cantidad" defaultValue={estado.valores?.cantidad ?? '1'} inputMode="numeric" className="w-24 rounded border px-2 py-1" />
        <span className="text-xs text-red-700">{error('cantidad')}</span>
      </label>
      <button disabled={pendiente} className="self-start rounded bg-marca-700 px-4 py-1 text-white disabled:opacity-50">
        {pendiente ? 'Creando...' : 'Crear pedido'}
      </button>
      {estado.mensaje && <p role="status" className="text-sm text-red-700">{estado.mensaje}</p>}
    </form>
  )
}
// app/gestion/pedidos/nuevo/page.tsx
import { Suspense } from 'react'
import { verificarSesion } from '@/lib/dal/auth'
import { listarCatalogo } from '@/lib/db/catalogo'
import { listarClientes } from '@/lib/db/pedidos'
import { FormularioPedido } from './FormularioPedido'

export const metadata = { title: 'Nuevo pedido' }

export default function PaginaNuevoPedido() {
  return (
    <>
      <h1 className="mb-4 text-2xl font-bold text-marca-900">Nuevo pedido</h1>
      <Suspense fallback={<p>Cargando...</p>}>
        <Formulario />
      </Suspense>
    </>
  )
}

async function Formulario() {
  await verificarSesion()
  const [clientes, productos] = await Promise.all([listarClientes(), listarCatalogo()])
  return (
    <FormularioPedido
      clientes={clientes.map((c) => ({ id: c.clienteId, texto: c.nombre }))}
      productos={productos.map((p) => ({ id: p.productoId, texto: p.nombre }))}
    />
  )
}
// app/gestion/pedidos/page.tsx
import Link from 'next/link'
import { Suspense } from 'react'
import { verificarSesion } from '@/lib/dal/auth'
import { listarPedidos } from '@/lib/db/pedidos'
import { formatoEuros } from '@/lib/formato'
import { Aviso } from '../Aviso'

export const metadata = { title: 'Pedidos' }

const COLOR_ESTADO = {
  PENDIENTE: 'bg-amber-100 text-amber-800',
  ENVIADO: 'bg-sky-100 text-sky-800',
  ENTREGADO: 'bg-green-100 text-green-800',
  CANCELADO: 'bg-slate-200 text-slate-600',
} as const

export default function PaginaPedidos({ searchParams }: PageProps<'/gestion/pedidos'>) {
  return (
    <>
      <div className="mb-4 flex items-center gap-4">
        <h1 className="text-2xl font-bold text-marca-900">Pedidos</h1>
        <Link href="/gestion/pedidos/nuevo" className="rounded bg-marca-700 px-3 py-1 text-sm text-white">
          Nuevo pedido
        </Link>
      </div>
      <Suspense fallback={null}>
        <Aviso searchParams={searchParams} />
      </Suspense>
      <Suspense fallback={<p>Cargando pedidos...</p>}>
        <TablaPedidos />
      </Suspense>
    </>
  )
}

async function TablaPedidos() {
  await verificarSesion()
  const pedidos = await listarPedidos() // sin caché: siempre el dato actual
  return (
    <table className="w-full bg-white text-sm shadow">
      <thead className="bg-marca-50 text-left">
        <tr>
          <th className="p-2">Pedido</th>
          <th className="p-2">Fecha</th>
          <th className="p-2">Cliente</th>
          <th className="p-2 text-right">Total</th>
          <th className="p-2">Estado</th>
        </tr>
      </thead>
      <tbody>
        {pedidos.map((p) => (
          <tr key={p.pedidoId} className="border-t">
            <td className="p-2">
              <Link href={`/gestion/pedidos/${p.pedidoId}`} className="text-marca-700 underline">{p.pedidoId}</Link>
            </td>
            <td className="p-2">{p.fechaPedido}</td>
            <td className="p-2">{p.cliente}</td>
            <td className="p-2 text-right">{formatoEuros(p.total)}</td>
            <td className="p-2">
              <span className={`rounded px-2 py-0.5 text-xs ${COLOR_ESTADO[p.estado]}`}>{p.estado}</span>
            </td>
          </tr>
        ))}
      </tbody>
    </table>
  )
}

COLOR_ESTADO[p.estado] compila sin comprobaciones porque p.estado tiene el tipo 'PENDIENTE' | 'ENVIADO' | 'ENTREGADO' | 'CANCELADO', que sale de la columna con enum del esquema. Si un día se añade un estado, TypeScript marcará este objeto hasta que le pongas color.

// app/gestion/pedidos/[id]/page.tsx
import { notFound } from 'next/navigation'
import { Suspense } from 'react'
import { ESTADOS_PEDIDO } from '@/db/schema'
import { verificarSesion } from '@/lib/dal/auth'
import { obtenerPedido } from '@/lib/db/pedidos'
import { formatoEuros } from '@/lib/formato'
import { esquemaId } from '@/lib/validacion'
import { Aviso } from '../../Aviso'
import { guardarEstado } from '../acciones'

export const metadata = { title: 'Pedido' }

export default function PaginaPedido({ params, searchParams }: PageProps<'/gestion/pedidos/[id]'>) {
  return (
    <>
      <Suspense fallback={null}>
        <Aviso searchParams={searchParams} />
      </Suspense>
      <Suspense fallback={<p>Cargando pedido...</p>}>
        <DetallePedido params={params} />
      </Suspense>
    </>
  )
}

async function DetallePedido({ params }: Pick<PageProps<'/gestion/pedidos/[id]'>, 'params'>) {
  await verificarSesion()
  const id = esquemaId.safeParse((await params).id)
  if (!id.success) notFound()
  const pedido = await obtenerPedido(id.data)
  if (!pedido) notFound()

  const importe = (l: (typeof pedido.lineas)[number]) => l.cantidad * l.precioUnitario * (1 - l.descuento / 100)
  const total = pedido.lineas.reduce((suma, l) => suma + importe(l), 0)

  return (
    <div className="space-y-4">
      <h1 className="text-2xl font-bold text-marca-900">Pedido {pedido.pedidoId}</h1>
      <p>
        {pedido.cliente.nombre} ({pedido.cliente.ciudad}) · {pedido.fechaPedido}
        {pedido.fechaEnvio && <> · enviado el {pedido.fechaEnvio}</>}
      </p>
      <table className="bg-white text-sm shadow">
        <thead className="bg-marca-50 text-left">
          <tr>
            <th className="p-2">#</th>
            <th className="p-2">Producto</th>
            <th className="p-2 text-right">Cantidad</th>
            <th className="p-2 text-right">Precio</th>
            <th className="p-2 text-right">Dto.</th>
            <th className="p-2 text-right">Importe</th>
          </tr>
        </thead>
        <tbody>
          {pedido.lineas.map((l) => (
            <tr key={l.numLinea} className="border-t">
              <td className="p-2">{l.numLinea}</td>
              <td className="p-2">{l.producto.nombre}</td>
              <td className="p-2 text-right">{l.cantidad}</td>
              <td className="p-2 text-right">{formatoEuros(l.precioUnitario)}</td>
              <td className="p-2 text-right">{l.descuento} %</td>
              <td className="p-2 text-right">{formatoEuros(importe(l))}</td>
            </tr>
          ))}
        </tbody>
        <tfoot>
          <tr className="border-t font-bold">
            <td className="p-2" colSpan={5}>Total</td>
            <td className="p-2 text-right">{formatoEuros(Math.round(total * 100) / 100)}</td>
          </tr>
        </tfoot>
      </table>
      <form action={guardarEstado} aria-label="Estado del pedido" className="flex items-center gap-2">
        <input type="hidden" name="pedidoId" value={pedido.pedidoId} />
        <label className="text-sm">
          Estado{' '}
          <select name="estado" defaultValue={pedido.estado} className="rounded border px-1">
            {ESTADOS_PEDIDO.map((e) => (
              <option key={e}>{e}</option>
            ))}
          </select>
        </label>
        <button className="rounded bg-marca-700 px-3 py-1 text-sm text-white">Cambiar estado</button>
      </form>
    </div>
  )
}

Detalle del pedido 5025 de Talleres Martín S.L. en Valencia, enviado, con una línea de tres Portátil Básico 15 a 499 euros, el total de 1497 euros y el selector de estado en ENVIADO con el botón Cambiar estado

Figura 7. El pedido creado por Luis en la prueba E2E, ya marcado como enviado: cambiarEstado ha rellenado la fecha de envío.


7. Sin JavaScript: qué funciona y qué no

Todas las acciones de la gestión terminan con redirect, como recomienda el módulo 10: sin JavaScript, el navegador envía el formulario con un POST normal, recibe un 303 See Other y carga la página de destino. Una acción que solo devolviera un estado dejaría una página en blanco en una ruta ◐.

Pero eso solo es la mitad del problema. Se comprobó con Playwright y el JavaScript desactivado (javaScriptEnabled: false), y esto es lo que pasa:

  1. El login funciona. La página /entrar muestra el formulario del fallback, que forma parte del armazón prerenderizado. Al enviarlo, la acción entrar crea la cookie y redirige: el navegador llega a /gestion.
  2. Pero no a donde iba. El formulario del fallback no tiene el campo desde. El definitivo sí, pero está en el DOM oculto: React lo envía dentro de un <div hidden> y lo coloca en su sitio con un pequeño <script>. Sin JavaScript, ese script no se ejecuta. En la prueba había dos campos Email en la página, y solo uno visible.
  3. La gestión solo muestra los fallbacks. Por la misma razón, en /gestion/departamentos se ven el título y “Cargando departamentos…”, pero ni la tabla ni el formulario: todo lo que depende de la sesión está dentro de <Suspense> y llega oculto.

Es una consecuencia directa de la arquitectura ◐: el armazón es estático y lo dinámico llega por streaming, y el streaming de React necesita un mínimo de JavaScript para colocar cada fragmento. Que las acciones terminen con redirect sigue siendo necesario (es lo que hace que el login funcione sin JavaScript), pero no basta para que la gestión funcione sin JavaScript.

Si tu aplicación lo necesita de verdad (una intranet con navegadores muy limitados, por ejemplo), las páginas de gestión tienen que renderizarse completas antes de enviarse: sin <Suspense> alrededor de los datos y con export const instant = false en el layout de gestión, que permite rutas bloqueantes (módulo 09). Pierdes el armazón instantáneo a cambio de HTML completo. Es el ejercicio 9.


8. Pruebas

La estrategia es la del módulo 21: muchas pruebas rápidas de la capa de datos y de las acciones contra una base de datos real en memoria, y pocas pruebas E2E, una por flujo importante, contra el build de producción.

8.1. Vitest: configuración

// vitest.config.mts
import { defineConfig } from 'vitest/config'

// Solo pruebas de servidor (capa de datos y Server Actions) en Node:
// no hace falta jsdom. Si añades Client Components, usa los dos proyectos del módulo 21.
export default defineConfig({
  resolve: { tsconfigPaths: true }, // los alias @/ del tsconfig.json
  test: {
    environment: 'node',
    include: ['tests/**/*.test.ts'],
    setupFiles: ['tests/preparar.ts'],
  },
})
// tests/preparar.ts
import { afterEach, vi } from 'vitest'

// 1. Base de datos de pruebas: SQLite en memoria, nueva en cada fichero.
//    Se fija ANTES de que nadie importe lib/db.
process.env.DATABASE_URL = ':memory:'
process.env.SESSION_SECRET = 'secreto-de-pruebas-de-32-caracteres-como-minimo'

// 2. server-only lanza un error fuera de Next: en las pruebas no protege nada.
vi.mock('server-only', () => ({}))

// 3. Las funciones de caché de Next solo funcionan dentro de Next.
vi.mock('next/cache', () => ({
  cacheTag: vi.fn(),
  cacheLife: vi.fn(),
  updateTag: vi.fn(),
  revalidateTag: vi.fn(),
  refresh: vi.fn(),
}))

// 4. connection() exige una petición en curso.
vi.mock('next/server', async (importOriginal) => ({
  ...(await importOriginal<typeof import('next/server')>()),
  connection: vi.fn(async () => {}),
}))

// 5. redirect, notFound y forbidden lanzan una excepción especial dentro de Next.
//    Aquí lanzan un Error con la URL, para poder comprobar adónde se redirige.
vi.mock('next/navigation', () => ({
  redirect: vi.fn((url: string) => {
    throw new Error(`REDIRECT ${url}`)
  }),
  notFound: vi.fn(() => {
    throw new Error('NOT_FOUND')
  }),
  forbidden: vi.fn(() => {
    throw new Error('FORBIDDEN')
  }),
}))

afterEach(() => {
  vi.clearAllMocks()
})

Respecto al módulo 21 hay dos añadidos:

  • SESSION_SECRET: las acciones importan la DAL, que importa lib/sesion.ts, que comprueba el secreto al cargarse. Sin esta línea, todas las pruebas de acciones fallarían al importar.
  • next/navigation simulado: en Next, redirect lanza un error especial que el framework convierte en una respuesta 303. Fuera de Next, se sustituye por un Error cuyo mensaje lleva la URL. Así una prueba puede escribir await expect(accion(...)).rejects.toThrow('REDIRECT /gestion/empleados?aviso=empleado-creado'), que comprueba a la vez que la acción terminó y adónde envía al usuario.

La base de datos en memoria se prepara con las mismas migraciones de la aplicación y los datos de datos.json:

// tests/bd-prueba.ts
import { migrate } from 'drizzle-orm/libsql/migrator'
import { db } from '@/lib/db'
import * as schema from '@/db/schema'
import datos from '@/datos.json'

let migrada = false

// Las MISMAS migraciones que la aplicación (drizzle/) y los datos de datos.json
export async function reiniciarBaseDeDatos() {
  if (!migrada) {
    await migrate(db, { migrationsFolder: 'drizzle' })
    migrada = true
  }

  await db.batch([
    db.delete(schema.usuarios),
    db.delete(schema.lineasPedido),
    db.delete(schema.asignaciones),
    db.delete(schema.pedidos),
    db.delete(schema.proyectos),
    db.delete(schema.productos),
    db.delete(schema.categorias),
    db.delete(schema.clientes),
    db.delete(schema.empleados),
    db.delete(schema.departamentos),
    db.insert(schema.departamentos).values(datos.departamentos),
    db.insert(schema.empleados).values(datos.empleados),
    db.insert(schema.clientes).values(datos.clientes),
    db.insert(schema.categorias).values(datos.categorias),
    db.insert(schema.productos).values(datos.productos),
    db.insert(schema.pedidos).values(
      datos.pedidos.map((p) => ({ ...p, estado: p.estado as schema.EstadoPedido })),
    ),
    db.insert(schema.lineasPedido).values(datos.lineasPedido),
    db.insert(schema.proyectos).values(datos.proyectos),
    db.insert(schema.asignaciones).values(datos.asignaciones),
  ])
}

// Construye un FormData como el que envía un <form>
export function formulario(campos: Record<string, string>) {
  const fd = new FormData()
  for (const [clave, valor] of Object.entries(campos)) fd.append(clave, valor)
  return fd
}

8.2. Vitest: las pruebas

La capa de datos, con sus reglas de negocio:

// tests/datos.test.ts
import { eq } from 'drizzle-orm'
import { cacheTag } from 'next/cache'
import { connection } from 'next/server'
import { beforeEach, describe, expect, test } from 'vitest'
import { db } from '@/lib/db'
import { productos } from '@/db/schema'
import { listarCatalogo, obtenerProducto, stockActual } from '@/lib/db/catalogo'
import { listarDepartamentos } from '@/lib/db/departamentos'
import { crearPedido, listarPedidos } from '@/lib/db/pedidos'
import { reiniciarBaseDeDatos } from './bd-prueba'

beforeEach(async () => {
  await reiniciarBaseDeDatos()
})

describe('catálogo', () => {
  test('la tienda no muestra los productos descatalogados', async () => {
    const catalogo = await listarCatalogo()
    expect(catalogo).toHaveLength(19)
    expect(await db.$count(productos)).toBe(20)
    expect(cacheTag).toHaveBeenCalledWith('productos')
  })

  test('obtenerProducto devuelve null para un id que no existe', async () => {
    expect(await obtenerProducto(999)).toBeNull()
    const portatil = await obtenerProducto(1)
    expect(portatil?.categoria.padre?.nombre).toBeDefined()
  })

  test('el stock de la ficha espera a connection()', async () => {
    expect(await stockActual(3)).toBe(40)
    expect(connection).toHaveBeenCalledOnce()
  })
})

describe('departamentos', () => {
  test('cuenta los empleados, también los 0 del departamento 70', async () => {
    const lista = await listarDepartamentos()
    expect(lista.map((d) => [d.deptId, d.empleados])).toEqual([
      [10, 1], [20, 6], [30, 6], [40, 3], [50, 3], [60, 4], [70, 0],
    ])
  })
})

describe('crearPedido', () => {
  test('crea el pedido con el precio actual y descuenta el stock', async () => {
    const antes = await db.$count(productos)
    const pedidoId = await crearPedido(1001, [{ productoId: 3, cantidad: 2 }], 101)

    expect(pedidoId).toBe(5025)
    expect(await stockActual(3)).toBe(38)
    expect(await db.$count(productos)).toBe(antes)
    const pedido = (await listarPedidos()).find((p) => p.pedidoId === pedidoId)
    expect(pedido?.estado).toBe('PENDIENTE')
  })

  test('si no hay stock no queda nada: ni pedido ni stock descontado', async () => {
    const pedidosAntes = (await listarPedidos()).length
    await expect(
      crearPedido(1001, [{ productoId: 3, cantidad: 1 }, { productoId: 5, cantidad: 100000 }]),
    ).rejects.toThrow()

    expect(await stockActual(3)).toBe(40)
    expect((await listarPedidos()).length).toBe(pedidosAntes)
    const [p5] = await db.select({ stock: productos.stock }).from(productos).where(eq(productos.productoId, 5))
    expect(p5.stock).toBe(4)
  })
})

La última prueba es la más valiosa del proyecto: demuestra que la transacción deshace el descuento del producto 3 cuando falla el 5. Ninguna prueba con la base de datos simulada podría demostrarlo, porque lo que se comprueba es el comportamiento de SQLite.

Las Server Actions, con la sesión simulada:

// tests/acciones.test.ts
import { eq } from 'drizzle-orm'
import { updateTag } from 'next/cache'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { borrarDepartamento, guardarDepartamento } from '@/app/gestion/departamentos/acciones'
import { guardarEmpleado } from '@/app/gestion/empleados/acciones'
import { guardarEstado, nuevoPedido } from '@/app/gestion/pedidos/acciones'
import { guardarProducto } from '@/app/gestion/productos/acciones'
import { obtenerUsuario, type UsuarioActual } from '@/lib/dal/auth'
import { db } from '@/lib/db'
import { departamentos, empleados, pedidos, productos } from '@/db/schema'
import { formulario, reiniciarBaseDeDatos } from './bd-prueba'

// La sesión se simula: aquí se prueban las acciones, no las cookies
vi.mock('@/lib/dal/auth', () => ({ obtenerUsuario: vi.fn() }))

const ANA: UsuarioActual = { usuarioId: 1, nombre: 'Ana García', rol: 'ADMIN', empId: 100 }
const LUIS: UsuarioActual = { usuarioId: 2, nombre: 'Luis Martínez', rol: 'USER', empId: 101 }
const inicial = { ok: true, mensaje: '' }

const empleadoValido = {
  nombre: 'Elena',
  apellidos: 'Ruiz Soto',
  email: 'elena.ruiz@empresa.es',
  telefono: '',
  fechaAlta: '2026-11-02',
  oficio: 'analista',
  salario: '2600',
  comision: '',
  deptId: '30',
  jefeId: '',
}

beforeEach(async () => {
  await reiniciarBaseDeDatos()
  vi.mocked(obtenerUsuario).mockResolvedValue(ANA)
  vi.spyOn(console, 'error').mockImplementation(() => {}) // las acciones registran los fallos
})

describe('empleados', () => {
  test('ADMIN crea un empleado: renueva la caché y redirige con aviso', async () => {
    await expect(guardarEmpleado(inicial, formulario(empleadoValido))).rejects.toThrow(
      'REDIRECT /gestion/empleados?aviso=empleado-creado',
    )
    const fila = await db.query.empleados.findFirst({ where: eq(empleados.email, 'elena.ruiz@empresa.es') })
    expect(fila).toMatchObject({ oficio: 'ANALISTA', salario: 2600, comision: null, deptId: 30, jefeId: null })
    expect(updateTag).toHaveBeenCalledWith('empleados')
  })

  test('errores de validación por campo, sin tocar la base de datos', async () => {
    const estado = await guardarEmpleado(
      inicial,
      formulario({ ...empleadoValido, nombre: 'E', email: 'no-es-un-email', salario: '0' }),
    )
    expect(estado.ok).toBe(false)
    expect(estado.errores).toEqual({
      nombre: ['Mínimo 2 caracteres'],
      email: ['Email no válido'],
      salario: ['Debe ser mayor que 0'],
    })
    expect(estado.valores?.nombre).toBe('E') // lo escrito vuelve al formulario
    expect(await db.$count(empleados)).toBe(24)
  })

  test('email repetido (UNIQUE) se muestra en su campo', async () => {
    const [ana] = await db.select({ email: empleados.email }).from(empleados).where(eq(empleados.empId, 100))
    const estado = await guardarEmpleado(inicial, formulario({ ...empleadoValido, email: ana.email }))
    expect(estado.errores).toEqual({ email: ['Ya hay un empleado con ese email'] })
  })

  test('departamento inexistente (clave ajena)', async () => {
    const estado = await guardarEmpleado(inicial, formulario({ ...empleadoValido, deptId: '99' }))
    expect(estado.mensaje).toBe('El departamento o el jefe elegido no existe.')
    expect(await db.$count(empleados)).toBe(24)
  })

  test('un USER no puede crear empleados', async () => {
    vi.mocked(obtenerUsuario).mockResolvedValue(LUIS)
    const estado = await guardarEmpleado(inicial, formulario(empleadoValido))
    expect(estado).toEqual({ ok: false, mensaje: 'Solo administración puede hacer esto.' })
    expect(await db.$count(empleados)).toBe(24)
  })

  test('sin sesión, a /entrar', async () => {
    vi.mocked(obtenerUsuario).mockResolvedValue(null)
    await expect(guardarEmpleado(inicial, formulario(empleadoValido))).rejects.toThrow('REDIRECT /entrar')
  })
})

describe('departamentos', () => {
  test('nombre repetido: "ventas" se normaliza a VENTAS y choca con el UNIQUE', async () => {
    const estado = await guardarDepartamento(
      inicial,
      formulario({ nombre: 'ventas', localidad: 'Cuenca', presupuesto: '' }),
    )
    expect(estado.mensaje).toBe('Ya existe un registro con ese valor.')
  })

  test('borrar VENTAS (con empleados) redirige con el aviso de clave ajena', async () => {
    await expect(borrarDepartamento(formulario({ deptId: '20' }))).rejects.toThrow(
      'REDIRECT /gestion/departamentos?aviso=error-fk',
    )
    expect(await db.$count(departamentos)).toBe(7)
  })

  test('borrar el 70 (sin empleados ni proyectos) funciona', async () => {
    await expect(borrarDepartamento(formulario({ deptId: '70' }))).rejects.toThrow(
      'REDIRECT /gestion/departamentos?aviso=departamento-borrado',
    )
    expect(vi.mocked(updateTag).mock.calls).toEqual([['departamentos'], ['departamento-70']])
  })
})

describe('productos y pedidos', () => {
  test('cambiar el precio caduca la caché de la tienda', async () => {
    await expect(
      guardarProducto(formulario({ productoId: '1', precio: '999.5', stock: '10' })),
    ).rejects.toThrow('REDIRECT /gestion/productos?aviso=producto-guardado')
    const [p] = await db.select().from(productos).where(eq(productos.productoId, 1))
    expect(p).toMatchObject({ precio: 999.5, stock: 10, descatalogado: false })
    expect(updateTag).toHaveBeenCalledWith('productos')
  })

  test('un pedido sin stock suficiente marca el campo cantidad', async () => {
    const estado = await nuevoPedido(inicial, formulario({ clienteId: '1001', productoId: '5', cantidad: '999' }))
    expect(estado.errores).toEqual({ cantidad: ['No hay stock suficiente'] })
    expect(await db.$count(pedidos)).toBe(24)
  })

  test('un USER crea un pedido a su nombre y cambia su estado', async () => {
    vi.mocked(obtenerUsuario).mockResolvedValue(LUIS)
    await expect(
      nuevoPedido(inicial, formulario({ clienteId: '1001', productoId: '3', cantidad: '2' })),
    ).rejects.toThrow('REDIRECT /gestion/pedidos/5025?aviso=pedido-creado')

    await expect(guardarEstado(formulario({ pedidoId: '5025', estado: 'ENVIADO' }))).rejects.toThrow(
      'REDIRECT /gestion/pedidos/5025?aviso=estado-cambiado',
    )
    const pedido = await db.query.pedidos.findFirst({ where: eq(pedidos.pedidoId, 5025) })
    expect(pedido).toMatchObject({ empleadoId: 101, estado: 'ENVIADO' })
    expect(pedido?.fechaEnvio).toMatch(/^\d{4}-\d{2}-\d{2}$/)
  })

  test('un estado inventado no llega a la base de datos', async () => {
    await expect(guardarEstado(formulario({ pedidoId: '5001', estado: 'PERDIDO' }))).rejects.toThrow(
      'REDIRECT /gestion/pedidos?aviso=error-datos',
    )
  })
})
  • vi.mock('@/lib/dal/auth', ...) sustituye la DAL entera por una función simulada. Las acciones llaman a usuarioDeAccion() de comun.ts, que llama a obtenerUsuario(): la simulación llega hasta allí sin tocar el código de la aplicación. Así se prueban los tres casos de autorización (ADMIN, USER y sin sesión) sin cookies ni JWT; la sesión ya la probó el módulo 17.
  • Las pruebas de error comprueban que la base de datos no cambió ($count sigue en 24 empleados o 7 departamentos). Un mensaje de error correcto con un registro a medio guardar sería peor que no tener mensaje.
  • El pedido 5025: hay 24 pedidos (del 5001 al 5024) y la clave entera se autoincrementa. Como la base de datos se reinicia antes de cada prueba, el número es siempre el mismo.

La ejecución real en la carpeta limpia:

> empresa-nextjs@1.0.0 test:run
> vitest run

 RUN  v5.0.5 C:/proyectos/empresa-nextjs

 Test Files  2 passed (2)
      Tests  19 passed (19)
   Duration  4.81s (import 67%, transform 16%, tests 15%, setup 1%)

8.3. Playwright: configuración y ayudas

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

const puerto = Number(process.env.PUERTO_E2E ?? 3001)
const urlBase = `http://127.0.0.1:${puerto}`

export default defineConfig({
  testDir: './e2e',
  // Las pruebas cambian datos compartidos (precios, pedidos): en serie y en orden
  fullyParallel: false,
  workers: 1,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  reporter: [['list'], ['html', { open: 'never' }]],
  use: { baseURL: urlBase, trace: 'on-first-retry' },
  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
  webServer: {
    // Base de datos propia de E2E, build de producción y servidor de producción
    command: `npm run db:preparar && npm run build && npm run start -- -p ${puerto}`,
    url: `${urlBase}/api/salud`,
    env: {
      DATABASE_URL: 'file:e2e.db',
      SESSION_SECRET: 'secreto-solo-para-las-pruebas-e2e-0123456789',
    },
    reuseExistingServer: !process.env.CI,
    timeout: 300_000,
    stdout: 'pipe',
  },
})

Diferencias con la configuración del módulo 21, y su motivo:

  • workers: 1 y fullyParallel: false. Aquí las pruebas forman un recorrido: la del precio cambia el producto 1, la de pedidos crea el 5025 y espera que el stock baje de 40 a 37. En paralelo, se pisarían. Los ficheros llevan número (1-tienda, 2-acceso…) para fijar el orden. Es más lento, pero cada fallo es reproducible.
  • url: /api/salud. Playwright espera a que responda antes de empezar, y esa ruta comprueba además que la base de datos está accesible.
  • SESSION_SECRET en env. El build y el servidor lo necesitan; así las pruebas no dependen de tu .env.local.

⚠️ Importante: next start con output: 'standalone' escribe un aviso ("next start" does not work with "output: standalone" configuration), pero funciona: las pruebas E2E se ejecutan así. Para producción usa node .next/standalone/server.js copiando antes public y .next/static, como hace el Dockerfile (módulo 23, apartado 3).

// e2e/ayuda.ts
import { expect, type Page } from '@playwright/test'

export const ANA = 'ana.garcia@empresa.es'
export const LUIS = 'luis.martinez@empresa.es'

// /entrar pinta el formulario del fallback y, al llegar searchParams, el definitivo:
// durante un instante hay dos en el DOM (el nuevo, oculto). Se espera a que quede uno.
export async function formularioListo(page: Page) {
  await expect(page.getByLabel('Email')).toHaveCount(1)
}

export async function entrarComo(page: Page, email: string, destino = '/gestion') {
  await page.goto(`/entrar?desde=${encodeURIComponent(destino)}`)
  await formularioListo(page)
  await page.getByLabel('Email').fill(email)
  await page.getByLabel('Contraseña').fill('secreto')
  await page.getByRole('button', { name: 'Entrar' }).click()
  await expect(page).toHaveURL(destino)
}

// Filas de datos de una tabla (las que tienen <td>, no la cabecera)
export const filas = (page: Page) => page.getByRole('row').filter({ has: page.getByRole('cell') })

Lo que las primeras ejecuciones enseñaron

La primera ejecución de las E2E falló en 9 de 13 pruebas, y ninguno de los fallos era un error de la aplicación. Todos venían de elementos que existen en el DOM sin verse, algo que el modo estricto de Playwright no perdona (si un localizador encuentra dos elementos, la acción falla):

Mensaje de Playwright Causa Solución en las pruebas
getByLabel('Email') resolved to 2 elements en /entrar El formulario del fallback y el definitivo, todavía oculto, conviven un instante formularioListo: esperar a que quede uno. Rellenar antes sería peor: el formulario definitivo sustituiría al rellenado
getByRole('alert') resolved to 2 elements Next añade a cada página un anunciador de rutas para lectores de pantalla, también con role="alert" Filtrar por el texto
getByLabel('Localidad') resolved to 2 elements en la edición Next 16 con Cache Components oculta con <Activity> la página que abandonas en lugar de desmontarla (módulo 03): el formulario de alta de la lista sigue en el DOM Buscar dentro del formulario visible: getByRole('form', { name: 'Editar departamento' }). getByRole ignora lo oculto; getByLabel y getByText, no siempre
getByLabel('Estado') resolved to 2 elements El <select> se llama “Estado” y el formulario, “Estado del pedido”: getByLabel busca subcadenas y también lee aria-label getByRole('combobox', { name: 'Estado' })
getByRole('form', { name: 'Producto 1' }) encuentra 11 “Producto 1” es subcadena de “Producto 10” a “Producto 19” exact: true

Y dos fallos que sí eran de la aplicación: el del <select> que se reiniciaba (apartado 6.4) y el de la caché del precio (apartado 6.5).

8.4. Playwright: las pruebas

// e2e/1-tienda.spec.ts
import { expect, test } from '@playwright/test'

test.describe('Tienda pública', () => {
  test('el catálogo muestra 19 productos agrupados por categoría', async ({ page }) => {
    await page.goto('/')
    await expect(page.getByRole('heading', { level: 1 })).toHaveText('Catálogo')
    await expect(page.getByText('19 productos disponibles')).toBeVisible()
    await expect(page).toHaveTitle('Tienda | EMPRESA')
  })

  test('la ficha trae el stock en el hueco dinámico y sus metadatos', async ({ page }) => {
    await page.goto('/')
    await page.getByRole('link', { name: /Portátil Básico 15/ }).click()
    await expect(page).toHaveURL('/productos/3')
    await expect(page.getByRole('heading', { level: 1 })).toHaveText('Portátil Básico 15')
    await expect(page.getByText('En stock: 40 unidades')).toBeVisible()
    await expect(page.locator('meta[property="og:image"]')).toHaveAttribute('content', /\/productos\/3\/opengraph-image/)
  })

  test('un producto que no existe muestra la página 404', async ({ page }) => {
    await page.goto('/productos/999')
    await expect(page.getByRole('heading', { name: '404: no encontrado' })).toBeVisible()
  })

  test('la API JSON devuelve el catálogo', async ({ request }) => {
    const respuesta = await request.get('/api/productos')
    expect(respuesta.ok()).toBe(true)
    const json = await respuesta.json()
    expect(json.total).toBe(19)
  })
})
// e2e/2-acceso.spec.ts
import { expect, test } from '@playwright/test'
import { ANA, LUIS, entrarComo, formularioListo } from './ayuda'

test.describe('Acceso a la gestión', () => {
  test('sin sesión, el proxy envía a /entrar y después vuelve a la página pedida', async ({ page }) => {
    await page.goto('/gestion/empleados')
    await expect(page).toHaveURL('/entrar?desde=%2Fgestion%2Fempleados')
    await formularioListo(page)
    await page.getByLabel('Email').fill(ANA)
    await page.getByLabel('Contraseña').fill('secreto')
    await page.getByRole('button', { name: 'Entrar' }).click()
    await expect(page).toHaveURL('/gestion/empleados')
    await expect(page.getByText('Ana García', { exact: true })).toBeVisible()
  })

  test('una contraseña incorrecta no da pistas', async ({ page }) => {
    await page.goto('/entrar')
    await formularioListo(page)
    await page.getByLabel('Email').fill(ANA)
    await page.getByLabel('Contraseña').fill('otra')
    await page.getByRole('button', { name: 'Entrar' }).click()
    // Ojo: el anunciador de rutas de Next también tiene role="alert"
    await expect(page.getByRole('alert').filter({ hasText: 'incorrectos' })).toHaveText('Email o contraseña incorrectos')
  })

  test('un USER ve los empleados, pero no puede darlos de alta', async ({ page }) => {
    await entrarComo(page, LUIS)
    await expect(page.getByRole('complementary').getByText('USER', { exact: true })).toBeVisible()
    await page.goto('/gestion/empleados')
    await expect(page.getByRole('link', { name: 'Editar' })).toHaveCount(0)
    await page.goto('/gestion/empleados/nuevo')
    await expect(page.getByRole('heading', { name: '403: no tienes permiso' })).toBeVisible()
  })

  test('cerrar sesión vuelve a proteger la zona', async ({ page }) => {
    await entrarComo(page, LUIS)
    await page.getByRole('button', { name: 'Cerrar sesión' }).click()
    await expect(page).toHaveURL('/entrar')
    await page.goto('/gestion')
    await expect(page).toHaveURL(/\/entrar\?desde=/)
  })
})
// e2e/3-gestion.spec.ts
import { expect, test } from '@playwright/test'
import { ANA, entrarComo, filas } from './ayuda'

test.describe('CRUD de la zona de gestión (ADMIN)', () => {
  test.beforeEach(async ({ page }) => {
    await entrarComo(page, ANA)
  })

  test('departamentos: validación, UNIQUE, alta, edición y clave ajena', async ({ page }) => {
    await page.goto('/gestion/departamentos')
    const alta = page.getByRole('form', { name: 'Nuevo departamento' })

    // Validación con Zod
    await alta.getByRole('button', { name: 'Crear' }).click()
    await expect(alta.getByRole('status')).toHaveText('Revisa los campos marcados.')
    await expect(alta.getByText('Mínimo 2 caracteres')).toHaveCount(2)

    // UNIQUE: "ventas" se guarda como VENTAS, que ya existe
    await alta.getByLabel('Nombre').fill('ventas')
    await alta.getByLabel('Localidad').fill('Cuenca')
    await alta.getByRole('button', { name: 'Crear' }).click()
    await expect(alta.getByRole('status')).toHaveText('Ya existe un registro con ese valor.')
    await expect(alta.getByLabel('Localidad')).toHaveValue('Cuenca') // no se pierde lo escrito

    // Alta correcta: redirect con aviso y la lista renovada por updateTag
    await alta.getByLabel('Nombre').fill('calidad')
    await alta.getByRole('button', { name: 'Crear' }).click()
    await expect(page).toHaveURL('/gestion/departamentos?aviso=departamento-creado')
    await expect(page.getByRole('status')).toHaveText('Departamento creado.')
    await expect(filas(page)).toHaveCount(8)
    await expect(filas(page).last()).toContainText('CALIDAD')

    // Edición
    await filas(page).last().getByRole('link', { name: 'Editar' }).click()
    await expect(page).toHaveURL('/gestion/departamentos/71')
    // La lista sigue en el DOM, oculta por <Activity>: se busca dentro del formulario visible
    await page.getByRole('form', { name: 'Editar departamento' }).getByLabel('Localidad').fill('Toledo')
    await page.getByRole('button', { name: 'Guardar' }).click()
    await expect(page.getByRole('status')).toHaveText('Cambios del departamento guardados.')
    await expect(filas(page).last()).toContainText('Toledo')

    // Clave ajena: VENTAS tiene empleados
    await page.getByRole('button', { name: 'Borrar VENTAS' }).click()
    await expect(page).toHaveURL('/gestion/departamentos?aviso=error-fk')
    await expect(page.getByRole('status')).toHaveText('No se puede: hay otros datos que dependen de este registro.')

    // Borrado del nuevo, sin empleados
    await page.getByRole('button', { name: 'Borrar CALIDAD' }).click()
    await expect(page.getByRole('status')).toHaveText('Departamento eliminado.')
    await expect(filas(page)).toHaveCount(7)
  })

  test('empleados: errores por campo, alta, edición y baja', async ({ page }) => {
    await page.goto('/gestion/empleados/nuevo')
    const form = page.getByRole('form', { name: 'Empleado' })
    await form.getByLabel('Nombre').fill('Elena')
    await form.getByLabel('Apellidos').fill('Ruiz Soto')
    await form.getByLabel('Email').fill('ana.garcia@empresa.es') // ya existe
    await form.getByLabel('Oficio').fill('analista')
    await form.getByLabel('Salario').fill('-5')
    await form.getByLabel('Departamento').selectOption('30')
    await form.getByRole('button', { name: 'Guardar' }).click()
    await expect(form.getByText('Debe ser mayor que 0')).toBeVisible()

    await form.getByLabel('Salario').fill('2600')
    await form.getByRole('button', { name: 'Guardar' }).click()
    await expect(form.getByText('Ya hay un empleado con ese email')).toBeVisible()
    await expect(form.getByLabel('Departamento')).toHaveValue('30') // el <select> no se ha reiniciado

    await form.getByLabel('Email').fill('elena.ruiz@empresa.es')
    await form.getByRole('button', { name: 'Guardar' }).click()
    await expect(page).toHaveURL('/gestion/empleados?aviso=empleado-creado')
    const fila = filas(page).filter({ hasText: 'elena.ruiz@empresa.es' })
    await expect(fila).toContainText('ANALISTA')

    await fila.getByRole('link', { name: 'Editar' }).click()
    const edicion = page.getByRole('form', { name: 'Empleado' })
    await edicion.getByLabel('Oficio').fill('jefa de calidad')
    await edicion.getByRole('button', { name: 'Guardar' }).click()
    await expect(page.getByRole('status')).toHaveText('Cambios del empleado guardados.')
    await expect(fila).toContainText('JEFA DE CALIDAD')

    await fila.getByRole('button', { name: /Borrar/ }).click()
    await expect(page.getByRole('status')).toHaveText('Empleado eliminado.')
    await expect(fila).toHaveCount(0)
  })

  test('cambiar un precio se ve en la tienda: updateTag caduca la caché', async ({ page }) => {
    await page.goto('/productos/1')
    await expect(page.getByText('899,00 €')).toBeVisible()

    await page.goto('/gestion/productos')
    const form = page.getByRole('form', { name: 'Producto 1', exact: true })
    await form.getByLabel('Precio').fill('849')
    await form.getByRole('button', { name: 'Guardar' }).click()
    await expect(page.getByRole('status')).toHaveText('Producto guardado. La tienda ya muestra el cambio.')

    const api = await (await page.request.get('/api/productos')).json()
    expect(api.productos.find((p: { productoId: number }) => p.productoId === 1).precio).toBe(849)
    await page.goto('/')
    await expect(page.getByRole('link', { name: /UltraBook/ })).toContainText('849,00 €')
    await page.goto('/productos/1')
    await expect(page.getByText('849,00 €')).toBeVisible()
  })
})
// e2e/4-pedidos.spec.ts
import { expect, test } from '@playwright/test'
import { LUIS, entrarComo } from './ayuda'

test('un USER crea un pedido, cambia su estado y el stock de la tienda baja', async ({ page }) => {
  await entrarComo(page, LUIS)
  await page.goto('/gestion/pedidos/nuevo')
  const form = page.getByRole('form', { name: 'Nuevo pedido' })
  await form.getByLabel('Cliente').selectOption({ label: 'Talleres Martín S.L.' })
  await form.getByLabel('Producto').selectOption({ label: 'Portátil Básico 15' })

  // Más de lo que hay: el CHECK de SQLite deshace la transacción
  await form.getByLabel('Cantidad').fill('500')
  await form.getByRole('button', { name: 'Crear pedido' }).click()
  await expect(form.getByText('No hay stock suficiente')).toBeVisible()

  await form.getByLabel('Cantidad').fill('3')
  await form.getByRole('button', { name: 'Crear pedido' }).click()
  await expect(page).toHaveURL('/gestion/pedidos/5025?aviso=pedido-creado')
  await expect(page.getByRole('heading', { name: 'Pedido 5025' })).toBeVisible()

  await page.getByRole('combobox', { name: 'Estado' }).selectOption('ENVIADO')
  await page.getByRole('button', { name: 'Cambiar estado' }).click()
  await expect(page.getByRole('status')).toHaveText('Estado del pedido actualizado.')
  await expect(page.getByText(/enviado el \d{4}-\d{2}-\d{2}/)).toBeVisible()

  await page.goto('/gestion/pedidos')
  await expect(page.getByRole('row', { name: /5025/ })).toContainText('ENVIADO')

  // La ficha pública lee el stock en cada petición: 40 - 3
  await page.goto('/productos/3')
  await expect(page.getByText('En stock: 37 unidades')).toBeVisible()
})
// e2e/5-sin-js.spec.ts
import { expect, test } from '@playwright/test'
import { ANA } from './ayuda'

// Qué funciona con el JavaScript desactivado
test.use({ javaScriptEnabled: false })

test('sin JavaScript: el login funciona; la gestión solo muestra los fallbacks', async ({ page }) => {
  await page.goto('/entrar?desde=%2Fgestion%2Fdepartamentos')

  // El contenido de <Suspense> llega oculto y lo coloca un <script> que no se ejecuta:
  // quedan los dos formularios en el DOM y solo se ve el del fallback (sin "desde")
  await expect(page.getByLabel('Email')).toHaveCount(2)
  await page.getByLabel('Email').filter({ visible: true }).fill(ANA)
  await page.getByLabel('Contraseña').filter({ visible: true }).fill('secreto')
  await page.getByRole('button', { name: 'Entrar' }).click()

  // La acción termina con redirect: 303 y el navegador carga /gestion (no /gestion/departamentos)
  await expect(page).toHaveURL('/gestion')

  // El armazón estático sí se ve; los datos (dentro de <Suspense>) no
  await page.goto('/gestion/departamentos')
  await expect(page.getByRole('heading', { name: 'Departamentos' })).toBeVisible()
  await expect(page.getByText('Cargando departamentos...')).toBeVisible()
  await expect(page.getByRole('form', { name: 'Nuevo departamento' })).toHaveCount(0)
})

La ejecución completa en la carpeta limpia, con PUERTO_E2E=5525 CI=1 npx playwright test (la tabla del build aparece antes, con el prefijo [WebServer]):

Running 13 tests using 1 worker
  ok  1 [chromium] › e2e\1-tienda.spec.ts:4:7 › Tienda pública › el catálogo muestra 19 productos agrupados por categoría (1.2s)
  ok  2 [chromium] › e2e\1-tienda.spec.ts:11:7 › Tienda pública › la ficha trae el stock en el hueco dinámico y sus metadatos (1.8s)
  ok  3 [chromium] › e2e\1-tienda.spec.ts:20:7 › Tienda pública › un producto que no existe muestra la página 404 (1.1s)
  ok  4 [chromium] › e2e\1-tienda.spec.ts:25:7 › Tienda pública › la API JSON devuelve el catálogo (80ms)
  ok  5 [chromium] › e2e\2-acceso.spec.ts:5:7 › Acceso a la gestión › sin sesión, el proxy envía a /entrar y después vuelve a la página pedida (1.6s)
  ok  6 [chromium] › e2e\2-acceso.spec.ts:16:7 › Acceso a la gestión › una contraseña incorrecta no da pistas (1.1s)
  ok  7 [chromium] › e2e\2-acceso.spec.ts:26:7 › Acceso a la gestión › un USER ve los empleados, pero no puede darlos de alta (1.9s)
  ok  8 [chromium] › e2e\2-acceso.spec.ts:35:7 › Acceso a la gestión › cerrar sesión vuelve a proteger la zona (1.5s)
  ok  9 [chromium] › e2e\3-gestion.spec.ts:9:7 › CRUD de la zona de gestión (ADMIN) › departamentos: validación, UNIQUE, alta, edición y clave ajena (3.4s)
  ok 10 [chromium] › e2e\3-gestion.spec.ts:53:7 › CRUD de la zona de gestión (ADMIN) › empleados: errores por campo, alta, edición y baja (4.1s)
  ok 11 [chromium] › e2e\3-gestion.spec.ts:88:7 › CRUD de la zona de gestión (ADMIN) › cambiar un precio se ve en la tienda: updateTag caduca la caché (3.8s)
  ok 12 [chromium] › e2e\4-pedidos.spec.ts:4:5 › un USER crea un pedido, cambia su estado y el stock de la tienda baja (4.4s)
  ok 13 [chromium] › e2e\5-sin-js.spec.ts:7:5 › sin JavaScript: el login funciona; la gestión solo muestra los fallbacks (1.0s)
  13 passed (1.5m)

El registro del servidor durante las pruebas confirma que los errores llegan de SQLite tal como esperan las acciones:

[guardarDepartamento] Error: Failed query: insert into "departamentos" ("dept_id", "nombre", "localidad", "presupuesto") values (null, ?, ?, ?) returning "dept_id", "nombre", "localidad", "presupuesto"
  [cause]: Error [LibsqlError]: SQLITE_CONSTRAINT: UNIQUE constraint failed: departamentos.nombre
[borrarDepartamento] Error [LibsqlError]: SQLITE_CONSTRAINT: FOREIGN KEY constraint failed
[guardarEmpleado] Error: Failed query: insert into "empleados" (...) values (null, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) returning "emp_id"
  [cause]: Error [LibsqlError]: SQLITE_CONSTRAINT: UNIQUE constraint failed: empleados.email
[nuevoPedido] Error [LibsqlError]: SQLITE_CONSTRAINT: CHECK constraint failed: productos_stock_no_negativo

9. Despliegue con Docker

9.1. El Dockerfile

Es el del módulo 23 con dos cambios: la base de datos se prepara en la etapa de build (el build la necesita para prerenderizar) y se entrega en un volumen.

# syntax=docker/dockerfile:1
# Imagen de producción de EMPRESA con output: 'standalone' (módulo 23)

FROM node:24-alpine AS base

# 1) Dependencias: solo cambia si cambian package.json o package-lock.json
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

# 2) Build: crea la base de datos (el build prerenderiza el catálogo con ella) y compila
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1 \
    DATABASE_URL=file:/app/empresa.db
# Solo para el build: el de ejecución se pasa con -e SESSION_SECRET=...
RUN SESSION_SECRET=secreto-solo-para-el-build-0123456789abcdef \
    npm run db:preparar && \
    SESSION_SECRET=secreto-solo-para-el-build-0123456789abcdef \
    npm run build

# 3) Ejecución: standalone, estáticos, public y la base de datos inicial
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production \
    NEXT_TELEMETRY_DISABLED=1 \
    PORT=3000 \
    HOSTNAME=0.0.0.0 \
    DATABASE_URL=file:/datos/empresa.db
RUN addgroup -S -g 1001 nodejs && adduser -S -u 1001 -G nodejs nextjs \
 && mkdir /datos && chown nextjs:nodejs /datos
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Un volumen nuevo montado en /datos se rellena con este fichero la primera vez
COPY --from=builder --chown=nextjs:nodejs /app/empresa.db /datos/empresa.db
VOLUME /datos
USER nextjs
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
  CMD wget -qO- http://127.0.0.1:3000/api/salud || exit 1
CMD ["node", "server.js"]
# .dockerignore
node_modules
.next
out
.env*
!.env.example
*.db
*.db-*
test-results
playwright-report
Dockerfile*
.dockerignore
.git
npm-debug.log*
*.tsbuildinfo

Las decisiones de este fichero:

  • La base de datos se crea dentro de la imagen de build. .dockerignore excluye tu empresa.db (con tus datos de prueba y quizá datos reales), y la etapa builder crea una limpia con db:preparar. El build prerenderiza con ella.
  • Dos secretos distintos. El de la línea RUN solo existe durante la compilación (lo exige lib/sesion.ts al cargarse). Se escribe con RUN VAR=... orden, que solo afecta a esa orden, en lugar de con ENV, que quedaría en la configuración de la etapa (la imagen final no hereda nada de la etapa builder, pero así ni siquiera queda en la caché de capas como variable). El de verdad se pasa al ejecutar el contenedor. Un secreto de build filtrado no sirve para firmar sesiones en producción.
  • VOLUME /datos con la base de datos dentro. Cuando Docker monta un volumen con nombre nuevo y vacío sobre una carpeta de la imagen que ya tiene ficheros, copia esos ficheros al volumen. Así, la primera vez el volumen nace con la base de datos inicial; las siguientes, conserva los cambios aunque recrees el contenedor. Es la regla del módulo 23 (apartado 5.6): la base de datos no vive en la capa del contenedor, que se pierde al recrearlo.
  • public/.gitkeep. La carpeta public del proyecto está vacía salvo ese fichero. Sin él, Git no guarda la carpeta, y el COPY --from=builder /app/public fallaría.

9.2. Construir y ejecutar

docker build -t empresa-next .
docker run -d --name empresa -p 127.0.0.1:3001:3000 \
  -e SESSION_SECRET="$(node -e "console.log(require('crypto').randomBytes(32).toString('base64'))")" \
  -v empresa-datos:/datos empresa-next

En el equipo de pruebas (publicando en el puerto 5525 en lugar del 3001), la construcción desde cero tardó 7 min 50 s, casi todo npm ci, y la imagen ocupa 257 MB. Las comprobaciones reales:

$ curl http://127.0.0.1:5525/api/salud
{"ok":true,"hora":"2026-11-02T15:41:04.565Z"}

$ curl -s http://127.0.0.1:5525/productos/3 | grep -o "En stock: [0-9]* unidades"
En stock: 40 unidades

$ curl -s -o /dev/null -D - http://127.0.0.1:5525/gestion
HTTP/1.1 307 Temporary Redirect
location: /entrar?desde=%2Fgestion

$ docker inspect empresa --format '{{.State.Health.Status}}'
healthy

$ docker logs empresa
▲ Next.js 16.4.4
- Local:         http://localhost:3000
- Network:       http://0.0.0.0:3000
✓ Ready in 0ms

Además se ejecutaron contra el contenedor las pruebas E2E de acceso y de pedidos (PUERTO_E2E=5525 npx playwright test e2e/2-acceso.spec.ts e2e/4-pedidos.spec.ts, que reutiliza el servidor que ya responde): las cinco pasaron. El login funciona aunque la cookie lleve Secure (en el contenedor NODE_ENV es production) porque el navegador trata 127.0.0.1 como origen seguro; detrás de un dominio real, necesitarás HTTPS (módulo 17, apartado 4.3).

💡 Consejo: SQLite en un volumen sirve para una réplica. Si necesitas varias, cambia DATABASE_URL por una base de datos libSQL remota (Turso, con libsql://... y un token): el código de lib/db no cambia (módulo 16, apartado 1.1). Y recuerda que cada réplica tiene su propia caché de Next: updateTag en una no caduca las demás sin un manejador de caché compartido (módulo 23, apartado 6).


10. El recorrido completo, paso a paso

Para que puedas repetir a mano lo que hacen las pruebas, este es el recorrido completo que se comprobó en el navegador y con curl:

Paso Qué haces Qué debe pasar
1 Abre / 19 productos; ninguno descatalogado (el 14 no aparece)
2 Abre /productos/3 Portátil Básico 15, 499,00 €, “En stock: 40 unidades”
3 Abre /gestion/pedidos sin sesión El proxy te lleva a /entrar?desde=%2Fgestion%2Fpedidos
4 Entra como Luis (luis.martinez@empresa.es / secreto) Vuelves a /gestion/pedidos; el menú dice USER
5 Abre /gestion/empleados/nuevo 403: no tienes permiso
6 Crea un pedido de 500 portátiles básicos “No hay stock suficiente” en la cantidad; no se crea nada
7 Crea un pedido de 3 Llegas a /gestion/pedidos/5025 con “Pedido creado.”
8 Cambia el estado a ENVIADO “enviado el” con la fecha de hoy
9 Abre /productos/3 “En stock: 37 unidades”
10 Cierra sesión y entra como Ana (ana.garcia@empresa.es / secreto) El menú dice ADMIN
11 En departamentos, pulsa “Crear” vacío “Mínimo 2 caracteres” en nombre y localidad
12 Crea “ventas” en Cuenca “Ya existe un registro con ese valor.” y “Cuenca” sigue escrito
13 Crea “calidad” Departamento 71 CALIDAD en la lista
14 Borra VENTAS “No se puede: hay otros datos que dependen de este registro.”
15 En productos, cambia el precio del 1 a 849 En /, en /productos/1 y en /api/productos ya aparece 849

11. Si usas Next.js 15 🔄 16.0

🔄 Cambia en Next.js 16.0. El proyecto depende de Cache Components ('use cache', cacheTag, updateTag, ensureStatic), de proxy.ts y de retry en error.tsx, que no existen en Next.js 15. Si usas Next.js 15: cambia 'use cache' por unstable_cache, updateTag por revalidateTag con un argumento, proxy.ts por middleware.ts con jose directo, retry por reset, y quita ensureStatic, cacheComponents y partialPrefetching; el resto del código (DAL, acciones, Zod, Drizzle, pruebas) no cambia.

Se hizo de verdad: se copió el proyecto al laboratorio de Next.js 15.5.28, se aplicaron los cambios de esta tabla, se compiló y se ejecutaron contra él las pruebas E2E de la tienda, el acceso, la gestión y los pedidos.

En el proyecto (16.4) En Next.js 15 Fichero
'use cache' + cacheTag + cacheLife unstable_cache(fn, claves, { tags, revalidate }) lib/db/catalogo.ts
'use cache' en la gestión Sin caché: en 15, una página que lee cookies() ya es dinámica lib/db/departamentos.ts, empleados.ts, pedidos.ts
updateTag('productos') revalidateTag('productos') Todas las acciones
proxy.ts con export function proxy middleware.ts con export function middleware (Edge) Raíz
export const ensureStatic = ... Se borra app/(tienda)/layout.tsx y page.tsx
error.tsx con retry reset app/gestion/error.tsx
cacheComponents, partialPrefetching, turbopack.rules Se borran; Tailwind con postcss.config.mjs (plantilla de 15) next.config.ts

El catálogo en Next.js 15 (las funciones sin caché quedan igual):

// lib/db/catalogo.ts (Next.js 15, fragmento)
import 'server-only'
import { and, asc, eq } from 'drizzle-orm'
import { unstable_cache } from 'next/cache'
import { db } from '.'
import { categorias, productos } from '@/db/schema'

// (el tipo ProductoCatalogo y las funciones sin caché, igual que en la 16)

export const listarCatalogo = unstable_cache(
  async (): Promise<ProductoCatalogo[]> => {
    console.log(`[db] listarCatalogo ${new Date().toISOString()}`)
    return db
      .select({
        productoId: productos.productoId,
        nombre: productos.nombre,
        precio: productos.precio,
        stock: productos.stock,
        categoria: categorias.nombre,
      })
      .from(productos)
      .innerJoin(categorias, eq(productos.categoriaId, categorias.categoriaId))
      .where(eq(productos.descatalogado, false))
      .orderBy(asc(categorias.nombre), asc(productos.nombre))
  },
  ['listarCatalogo'],
  { tags: ['productos'], revalidate: 3600 },
)

// La etiqueta depende del argumento: se envuelve en cada llamada
export function obtenerProducto(productoId: number) {
  return unstable_cache(
    async () => {
      const producto = await db.query.productos.findFirst({
        where: and(eq(productos.productoId, productoId), eq(productos.descatalogado, false)),
        columns: { productoId: true, nombre: true, descripcion: true, precio: true, stock: true },
        with: { categoria: { columns: { nombre: true }, with: { padre: { columns: { nombre: true } } } } },
      })
      return producto ?? null
    },
    ['obtenerProducto', String(productoId)],
    { tags: ['productos', `producto-${productoId}`], revalidate: 3600 },
  )()
}

unstable_cache fija las etiquetas al envolver la función, no al llamarla. Para que cada producto tenga su etiqueta (producto-3), hay que envolver en cada llamada, con el identificador en las claves. Con 'use cache' esto era automático: el argumento forma parte de la clave de la caché y cacheTag se llama dentro.

// middleware.ts (Next.js 15): mismo papel que proxy.ts en la 16.
// Runtime Edge por defecto: se usa jose directamente, no lib/sesion.ts
// (que importa next/headers y server-only).
import { NextResponse, type NextRequest } from 'next/server'
import { jwtVerify } from 'jose'

const clave = new TextEncoder().encode(process.env.SESSION_SECRET)

async function haySesion(request: NextRequest) {
  const token = request.cookies.get('sesion')?.value
  if (!token) return false
  try {
    await jwtVerify(token, clave, { algorithms: ['HS256'] })
    return true
  } catch {
    return false
  }
}

export async function middleware(request: NextRequest) {
  const { pathname, search } = request.nextUrl
  const sesion = await haySesion(request)

  if (pathname.startsWith('/gestion') && !sesion) {
    const url = new URL('/entrar', request.url)
    url.searchParams.set('desde', pathname + search)
    return NextResponse.redirect(url)
  }
  if (pathname === '/entrar' && sesion) {
    return NextResponse.redirect(new URL('/gestion', request.url))
  }
  return NextResponse.next()
}

export const config = { matcher: ['/gestion/:path*', '/entrar'] }
// next.config.ts (Next.js 15)
import type { NextConfig } from 'next'

// Next.js 15: sin cacheComponents ni partialPrefetching; Tailwind con PostCSS
const nextConfig: NextConfig = {
  output: 'standalone',
  experimental: {
    authInterrupts: true,
  },
}

export default nextConfig

La tabla del build en Next.js 15.5.28:

Route (app)                                    Size  First Load JS  Revalidate  Expire
┌ ○ /                                         182 B         105 kB          1h      1y
├ ○ /_not-found                               146 B         102 kB
├ ƒ /api/productos                            146 B         102 kB
├ ƒ /api/salud                                146 B         102 kB
├ ƒ /entrar                                   830 B         103 kB
├ ƒ /gestion                                  182 B         105 kB
├ ƒ /gestion/departamentos                  1.01 kB         106 kB
├ ƒ /gestion/departamentos/[id]               999 B         103 kB
├ ƒ /gestion/empleados                        182 B         105 kB
...
├ ● /productos/[id]                           182 B         105 kB
├   ├ /productos/1
├   ├ /productos/2
├   ├ /productos/3
├   └ [+16 more paths]
├ ○ /robots.txt                               146 B         102 kB
└ ○ /sitemap.xml                              146 B         102 kB
ƒ Middleware                                39.1 kB

Lo que cambia, comparado con la tabla de 16.4:

  • Toda la gestión es ƒ: sin Partial Prerendering no hay armazón estático; cada página se genera entera en cada petición, y el menú lateral espera a la sesión igual que la tabla. Funciona, pero cada navegación es más lenta.
  • La ficha de producto es ● y no ◐: se prerenderiza en el build y el stock, que pide connection() dentro de <Suspense>, se resuelve en cada petición mediante streaming. La prueba E2E del stock (40, y 37 tras el pedido) pasó también en 15.
  • /api/productos es ƒ: en 15 los Route Handlers GET son dinámicos por defecto; los datos sí salen de unstable_cache.
  • Aparecen tamaños de JavaScript (First Load JS): Next 16 dejó de mostrarlos.
  • Expire 1y en la portada, frente al 1d de 16.4: sin cacheLife, el máximo lo pone la configuración por defecto.

Resultados de las pruebas E2E contra el servidor de 15:

  • Pasaron la tienda (4 pruebas), el acceso con el middleware (4), el cambio de precio con revalidateTag('productos') y los pedidos.
  • El alta y el borrado en la gestión guardaron bien los datos, pero la página siguiente mostró a veces el aviso y la tabla anteriores hasta recargar: en todas las ejecuciones, el borrado de empleados (la acción redirige a la misma ruta con otro ?aviso=), y en alguna, el alta de departamentos. Añadir revalidatePath('/gestion/empleados') antes del redirect no lo resolvió, y no se llegó a aislar la causa. En 16.4 no ocurrió en ninguna ejecución. Si mantienes la aplicación en 15, muestra el resultado de esas acciones con el estado de useActionState en lugar de con un aviso en la URL, o comprueba este caso en tu versión.
  • La caché de unstable_cache se guarda en disco (.next/cache) y sobrevive al reinicio de next start. Al restaurar la base de datos por detrás de Next (copiando el fichero original) sin volver a compilar, la ficha seguía mostrando el precio de la ejecución anterior. Es la misma lección del módulo 21 (apartado 10.7): cambia los datos solo a través de la aplicación, o vuelve a compilar.

⚠️ Importante: En el laboratorio de 15 el build con next build --turbopack (el script de la plantilla de 15) falló con Parsing ecmascript source code failed en @libsql/hrana-client/LICENSE: Turbopack de 15 intentó procesar el fichero de licencia como JavaScript. En ese laboratorio los paquetes estaban instalados en otra carpeta que Next, una configuración que no tendrás en tu proyecto, así que puede no pasarte. Con next build (webpack, el compilador por defecto de 15 sin la opción) compiló sin errores, y es lo que se probó.


12. Errores frecuentes

Síntoma Causa Solución
no such table: productos en next build Se construye sin base de datos npm run db:preparar antes de npm run build
Falta SESSION_SECRET al construir lib/sesion.ts lo comprueba al cargarse, y se carga en el prerender Crea .env.local; en Docker y en CI, pásalo también al build
Un precio cambiado no aparece en la tienda La acción no llama a updateTag de la etiqueta que usa la consulta, o la escribe distinta Revisa la tabla de etiquetas (apartado 6.3); una prueba con expect(updateTag).toHaveBeenCalledWith('productos') lo vigila
La ficha muestra siempre el mismo stock La función de stock no lleva await connection() y se congeló en el build Toda lectura lleva 'use cache' o connection()
El build falla con must be fully static en la portada Alguien añadió un dato sin caché o una lectura de la petición Es ensureStatic cumpliendo su función: mueve ese dato a otra página o ponlo en caché
Tras un error, el <select> vuelve a la primera opción El reinicio del formulario usa la opción con la que se montó key con el valor actual en el <select>
Tras un error, el formulario se vacía React 19 reinicia los formularios tras la acción Devuelve valores en el estado y úsalos en defaultValue
Un USER ve botones de ADMIN o puede usarlos La comprobación está solo en la interfaz Cada acción comprueba el rol en su primera línea (apartado 6.2)
redirect dentro de try no redirige El catch captura la excepción de redirect try solo alrededor de la base de datos; redirect fuera
Pedido a nombre de otro empleado El empleadoId venía del formulario Sácalo de la sesión (usuario.empId)
Playwright: resolved to 2 elements Formularios del fallback, páginas ocultas con <Activity>, el anunciador de rutas Localiza por rol dentro del contenedor visible (apartado 8.3)
Sin JavaScript, la gestión solo muestra “Cargando…” El contenido de <Suspense> llega oculto y lo coloca un script Es inherente a ◐; para gestión sin JavaScript, ver el ejercicio 9
COPY --from=builder /app/public falla en Docker public vacía no se guarda en Git Deja un fichero dentro (.gitkeep)
"next start" does not work with "output: standalone" Aviso al usar next start con standalone Funciona para pruebas; en producción, node .next/standalone/server.js

13. Ejercicios

Las soluciones de los ejercicios resueltos se han añadido a una copia del proyecto, compilado con next build y comprobado con Vitest o Playwright.

Ejercicio 1: Cancelar un pedido devuelve el stock

Ahora mismo, marcar un pedido como CANCELADO no devuelve las unidades al almacén. Modifica cambiarEstado para que, al pasar a CANCELADO, sume al stock las cantidades de sus líneas, sin sumarlas dos veces si se cancela un pedido ya cancelado. Un pedido cancelado no se puede reabrir. La acción debe avisar si alguien lo intenta.

Ver solución

Todo en una transacción: leer el pedido con sus líneas, devolver el stock si procede y cambiar el estado.

// lib/db/pedidos.ts (sustituye a cambiarEstado)
// Ejercicio 1: al cancelar, las unidades vuelven al stock (todo en una transacción)
export async function cambiarEstado(pedidoId: number, estado: EstadoPedido) {
  const hoy = new Date().toISOString().slice(0, 10)
  return db.transaction(async (tx) => {
    const actual = await tx.query.pedidos.findFirst({
      where: eq(pedidos.pedidoId, pedidoId),
      columns: { estado: true },
      with: { lineas: { columns: { productoId: true, cantidad: true } } },
    })
    if (!actual) return null
    if (actual.estado === 'CANCELADO' && estado !== 'CANCELADO') {
      throw new Error('Un pedido cancelado no se puede reabrir')
    }

    if (estado === 'CANCELADO' && actual.estado !== 'CANCELADO') {
      for (const linea of actual.lineas) {
        await tx
          .update(productos)
          .set({ stock: sql`${productos.stock} + ${linea.cantidad}` })
          .where(eq(productos.productoId, linea.productoId))
      }
    }

    const [fila] = await tx
      .update(pedidos)
      .set({
        estado,
        fechaEnvio: estado === 'ENVIADO' ? sql`coalesce(${pedidos.fechaEnvio}, ${hoy})` : undefined,
      })
      .where(eq(pedidos.pedidoId, pedidoId))
      .returning({ pedidoId: pedidos.pedidoId, estado: pedidos.estado, fechaEnvio: pedidos.fechaEnvio })
    return fila
  })
}

En la acción, el error de “reabrir” se convierte en un aviso, y el stock cambiado obliga a caducar el catálogo:

// app/gestion/pedidos/acciones.ts (guardarEstado)
export async function guardarEstado(formData: FormData) {
  await usuarioDeAccion()
  const datos = esquemaEstado.safeParse(valoresDe(formData))
  if (!datos.success) redirect('/gestion/pedidos?aviso=error-datos')

  let fila: Awaited<ReturnType<typeof cambiarEstado>>
  try {
    fila = await cambiarEstado(datos.data.pedidoId, datos.data.estado)
  } catch (error) {
    console.error('[guardarEstado]', (error as Error).message)
    redirect(`/gestion/pedidos/${datos.data.pedidoId}?aviso=error-reabrir`)
  }
  if (!fila) redirect('/gestion/pedidos?aviso=error-no-existe')

  updateTag('productos') // al cancelar ha cambiado el stock del catálogo
  redirect(`/gestion/pedidos/${fila.pedidoId}?aviso=estado-cambiado`)
}

Y en lib/avisos.ts, el código nuevo: 'error-reabrir': 'Un pedido cancelado no se puede reabrir.'.

La prueba, añadida a Vitest:

// tests/ejercicios.test.ts (fragmento)
describe('Ejercicio 1: cancelar devuelve el stock', () => {
  test('cancelar suma las unidades; cancelar otra vez no las suma de nuevo', async () => {
    const pedidoId = await crearPedido(1001, [{ productoId: 3, cantidad: 5 }])
    expect(await stockActual(3)).toBe(35)

    await cambiarEstado(pedidoId, 'CANCELADO')
    expect(await stockActual(3)).toBe(40)
    await cambiarEstado(pedidoId, 'CANCELADO')
    expect(await stockActual(3)).toBe(40)
  })

  test('un pedido cancelado no se reabre', async () => {
    const pedidoId = await crearPedido(1001, [{ productoId: 3, cantidad: 1 }])
    await cambiarEstado(pedidoId, 'CANCELADO')
    await expect(cambiarEstado(pedidoId, 'PENDIENTE')).rejects.toThrow('no se puede reabrir')
  })
})

Pasó (junto con la del ejercicio 4: Tests 3 passed (3)). Fíjate en que el estado anterior se lee dentro de la transacción: si dos personas cancelaran el mismo pedido a la vez, la segunda vería ya CANCELADO y no devolvería el stock otra vez. Leerlo fuera, antes de la transacción, abriría esa carrera.

Ejercicio 2: Buscador de empleados

Añade a /gestion/empleados un buscador que filtre por nombre, apellidos, email u oficio. La búsqueda debe quedar en la URL (?q=director) para poder compartirla o volver atrás, y no debe crear una entrada de caché por cada búsqueda.

Ver solución

Un <form> normal con método GET (el método por defecto) ya escribe ?q=... en la URL sin una línea de JavaScript. El filtro se hace sobre la lista que ya está en caché:

// app/gestion/empleados/page.tsx (cambios)
export default function PaginaEmpleados({ searchParams }: PageProps<'/gestion/empleados'>) {
  return (
    <>
      {/* ...título y <Aviso> como antes... */}
      {/* Ejercicio 2: un formulario GET normal; la URL guarda la búsqueda */}
      <form role="search" className="mb-4 flex gap-2">
        <input name="q" aria-label="Buscar empleado" placeholder="Nombre, email u oficio" className="rounded border px-2 py-1" />
        <button className="rounded bg-marca-700 px-3 py-1 text-sm text-white">Buscar</button>
      </form>
      <Suspense fallback={<p>Cargando empleados...</p>}>
        <TablaEmpleados searchParams={searchParams} />
      </Suspense>
    </>
  )
}

async function TablaEmpleados({ searchParams }: Pick<PageProps<'/gestion/empleados'>, 'searchParams'>) {
  const usuario = await verificarSesion() // autorizar fuera...
  const todos = await listarEmpleados() // ...y leer de la caché dentro
  const { q } = await searchParams
  const texto = typeof q === 'string' ? q.trim().toLowerCase() : ''
  // Se filtra la lista en caché: una sola entrada de caché sirve para todas las búsquedas
  const empleados = texto
    ? todos.filter((e) => `${e.nombre} ${e.apellidos} ${e.email} ${e.oficio}`.toLowerCase().includes(texto))
    : todos
  const esAdmin = usuario.rol === 'ADMIN'
  // ...la tabla, igual que antes
}

Comprobado con Playwright: al buscar “director”, la URL pasa a /gestion/empleados?q=director y la primera fila es un DIRECTOR (con los datos de EMPRESA, la búsqueda encuentra a los seis directores, del 100 al 105).

¿Por qué no pasar q a la consulta (listarEmpleados(q) con un like)? Con 'use cache', cada valor de q sería una entrada distinta de la caché: “d”, “di”, “dir”… Con 24 empleados, filtrar en memoria es instantáneo y la caché tiene una sola entrada. Con 100 000 empleados, la decisión sería la contraria: consulta con like y sin caché (connection()).

El campo aria-label y no type="search": el campo tiene rol textbox. En la primera versión de la prueba se buscó con getByRole('searchbox') y no lo encontró; un <input> solo tiene el rol searchbox con type="search".

Ejercicio 3: Un pedido en JSON

Crea GET /api/pedidos/[id], que devuelva un pedido con su cliente y sus líneas. Sin sesión, 401; con un identificador que no es un número, 400; si no existe, 404.

Ver solución
// app/api/pedidos/[id]/route.ts
import { obtenerUsuario } from '@/lib/dal/auth'
import { obtenerPedido } from '@/lib/db/pedidos'
import { esquemaId } from '@/lib/validacion'

// Ejercicio 3: un pedido en JSON, solo para usuarios con sesión
export async function GET(_request: Request, ctx: RouteContext<'/api/pedidos/[id]'>) {
  const usuario = await obtenerUsuario() // el proxy no cubre /api: el handler se protege solo
  if (!usuario) return Response.json({ error: 'Inicia sesión' }, { status: 401 })

  const id = esquemaId.safeParse((await ctx.params).id)
  if (!id.success) return Response.json({ error: 'Identificador no válido' }, { status: 400 })

  const pedido = await obtenerPedido(id.data)
  if (!pedido) return Response.json({ error: 'Ese pedido no existe' }, { status: 404 })
  return Response.json(pedido)
}

El build la lista como ƒ /api/pedidos/[id] (lee la cookie y params). La prueba E2E comprobó los cuatro códigos (401 sin sesión, 400 con abc, 404 con 9999 y 200 con 5001), y este es el principio real de la respuesta del pedido 5001:

{"pedidoId":5001,"clienteId":1001,"empleadoId":110,"fechaPedido":"2025-01-15","fechaEnvio":"2025-01-17","estado":"ENTREGADO","cliente":{"nombre":"Talleres Martín S.L.","ciudad":"Valencia"},"lineas":[{"numLinea":1,"cantidad":1,"precioUnitario":1499,"descuento":...

El matcher del proxy es /gestion/:path* y /entrar: no cubre /api. Si el handler no comprobara la sesión, cualquiera podría leer los pedidos. Es la lección del módulo 17 (apartado 9): cada puerta se cierra sola.

Ejercicio 4: Prueba de autorización de guardarProducto

Escribe una prueba de Vitest que demuestre que un USER no puede cambiar precios: la acción debe redirigir con error-permiso, el precio no debe cambiar y no debe caducarse ninguna etiqueta.

Ver solución
// tests/ejercicios.test.ts
import { eq } from 'drizzle-orm'
import { updateTag } from 'next/cache'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { guardarProducto } from '@/app/gestion/productos/acciones'
import { obtenerUsuario } from '@/lib/dal/auth'
import { db } from '@/lib/db'
import { stockActual } from '@/lib/db/catalogo'
import { cambiarEstado, crearPedido } from '@/lib/db/pedidos'
import { productos } from '@/db/schema'
import { formulario, reiniciarBaseDeDatos } from './bd-prueba'

vi.mock('@/lib/dal/auth', () => ({ obtenerUsuario: vi.fn() }))

beforeEach(async () => {
  await reiniciarBaseDeDatos()
})

// (aquí van las pruebas del ejercicio 1)

describe('Ejercicio 4: guardarProducto rechaza a un USER', () => {
  test('redirige con error-permiso y no cambia el precio', async () => {
    vi.mocked(obtenerUsuario).mockResolvedValue({ usuarioId: 2, nombre: 'Luis', rol: 'USER', empId: 101 })
    await expect(guardarProducto(formulario({ productoId: '1', precio: '1', stock: '1' }))).rejects.toThrow(
      'REDIRECT /gestion/productos?aviso=error-permiso',
    )
    const [p] = await db.select({ precio: productos.precio }).from(productos).where(eq(productos.productoId, 1))
    expect(p.precio).toBe(899)
    expect(updateTag).not.toHaveBeenCalled()
  })
})
 Test Files  1 passed (1)
      Tests  3 passed (3)

La prueba es valiosa precisamente porque la interfaz ya oculta el formulario a un USER: sin ella, nadie se daría cuenta si mañana alguien borra la comprobación de rol de la acción, y la acción seguiría siendo accesible con un POST manipulado (módulo 17, apartado 12.3).

Ejercicio 5: Descatalogar quita el producto de la tienda

Escribe una prueba E2E que descatalogue el producto 6 (Ratón inalámbrico) desde la gestión y compruebe que desaparece de la portada y de la API, y que su ficha responde con la página 404.

Ver solución
// e2e/6-ejercicios.spec.ts (fragmento)
test('Ejercicio 5: descatalogar quita el producto de la tienda', async ({ page }) => {
  await entrarComo(page, ANA, '/gestion/productos')
  const form = page.getByRole('form', { name: 'Producto 6', exact: true })
  await form.getByLabel('Descatalogado').check()
  await form.getByRole('button', { name: 'Guardar' }).click()
  await expect(page.getByRole('status')).toHaveText('Producto guardado. La tienda ya muestra el cambio.')

  await page.goto('/')
  await expect(page.getByText('18 productos disponibles')).toBeVisible()
  await expect(page.getByRole('link', { name: /Ratón inalámbrico/ })).toHaveCount(0)
  const api = await (await page.request.get('/api/productos')).json()
  expect(api.total).toBe(18)
  await page.goto('/productos/6')
  await expect(page.getByRole('heading', { name: '404: no encontrado' })).toBeVisible()
})
  ok 3 [chromium] › e2e\6-ejercicios.spec.ts:24:5 › Ejercicio 5: descatalogar quita el producto de la tienda (3.5s)
  3 passed (1.1m)

No ha hecho falta tocar el código de la aplicación: listarCatalogo y obtenerProducto ya filtran por descatalogado = false, y updateTag('productos') caduca las dos. La ficha del producto 6 se prerenderizó en el build, pero su entrada de caché lleva la etiqueta productos, así que la siguiente petición la regenera y obtenerProducto(6) devuelve null: notFound().

Ejercicio 6 (propuesto): Pedidos con varias líneas

crearPedido ya admite varias líneas, pero el formulario solo envía una. Permite añadir líneas en el formulario (un Client Component con una lista en el estado y campos productoId y cantidad repetidos, que leerás en la acción con formData.getAll). Valida la lista con z.array(...) y comprueba que, si una línea no tiene stock, no se guarda ninguna.

Ejercicio 7 (propuesto): Gestión de usuarios

Crea /gestion/usuarios, solo para ADMIN, para dar de alta usuarios (con su contraseña cifrada con cifrarClave), cambiarles el rol y revocar sus sesiones con revocarSesiones. Comprueba con dos navegadores que, al quitarle a alguien el rol ADMIN, deja de serlo en su siguiente petición aunque su token siga diciendo ADMIN (módulo 17, apartado 6.3). ¿Qué debe pasar si un ADMIN intenta quitarse el rol a sí mismo y es el último?

Ejercicio 8 (propuesto): Paginación de pedidos

La lista de pedidos crecerá. Añade paginación con ?pagina=2 usando limit y offset en listarPedidos, con enlaces “Anterior” y “Siguiente” que conserven los demás parámetros de la URL. Añade un filtro por estado (?estado=PENDIENTE) y comprueba con Playwright que los dos parámetros se combinan.

Ejercicio 9 (propuesto): Gestión sin JavaScript

Haz que /gestion/departamentos funcione por completo con el JavaScript desactivado: pon export const instant = false en app/gestion/layout.tsx, lee la sesión y los datos sin <Suspense> en esa página, y comprueba con la prueba 5-sin-js modificada que el formulario de alta se ve y funciona. Compara la tabla del build antes y después: ¿qué tipo de ruta sale ahora y qué has perdido?

Ejercicio 10 (propuesto): Base de datos remota y dos réplicas

Crea una base de datos en Turso, carga en ella los datos con DATABASE_URL=libsql://... npm run db:preparar (con su TURSO_AUTH_TOKEN; tendrás que pasar el token a createClient en lib/db/index.ts y en los scripts) y arranca dos contenedores de la imagen detrás de Nginx, como en el módulo 23. Cambia un precio en una réplica y observa la otra: ¿cuándo se entera? Añade el manejador de caché compartido de aquel módulo y repite.


Resumen

Concepto API Idea clave
Tienda estática 'use cache', cacheTag('productos'), cacheLife('hours') Lo que ven todos se prerenderiza y se comparte; el layout raíz nunca lee la sesión
Garantía estática ensureStatic = 'shell' en el grupo, 'navigation' en la portada Un dato dinámico añadido por error rompe el build en vez de la tienda
Hueco dinámico <Suspense> + await connection() El stock se calcula en cada petición; el resto de la ficha sale del prerender
Metadatos generateMetadata, opengraph-image.tsx, sitemap.ts, robots.ts URL absolutas con metadataBase; las imágenes OG llevan las mismas etiquetas de caché
Sesión jose (SignJWT, jwtVerify), cookie HttpOnly y SameSite=Lax El token lleva lo mínimo; la DAL toma el rol de la base de datos
Proxy proxy.ts con matcher Barrera optimista: solo la firma del token; no protege /api ni sustituye a la DAL
Autorización verificarSesion(), exigirRol('ADMIN'), forbidden() Cada página, acción y handler se protege a sí mismo
Acciones usuarioDeAccion → Zod → lib/db → updateTag → redirect Siempre en ese orden; try solo alrededor de la base de datos
Errores de SQLite codigoSqlite, mensajeDeError UNIQUE al campo, clave ajena con un mensaje honesto, CHECK como última defensa
Avisos ?aviso=codigo + lista cerrada Funcionan tras redirect y no permiten inyectar textos
Formularios useActionState, valores en defaultValue, key en <select> El usuario no pierde lo escrito tras un error
Errores de página error.tsx (retry), not-found.tsx, forbidden.tsx notFound() dentro de <Suspense>: 200 con noindex la primera vez, 404 después
Pruebas Vitest con SQLite :memory: y las migraciones; Playwright contra el build Las restricciones de SQLite se prueban de verdad; la caché, solo en E2E
Despliegue output: 'standalone', Dockerfile en tres etapas, VOLUME /datos El build necesita la base de datos y un secreto propio; la base de datos vive en un volumen
Next.js 15 unstable_cache, revalidateTag, middleware.ts, reset Funciona, pero sin armazón estático en la gestión y con la caché de datos en disco