Skip to content

Reservas con Node + MySQL ​

Proyecto full-stack completo: un sistema de citas/reservas con API en Node.js + Express + MySQL y frontend en HTML, CSS y JavaScript puro. Sirve para consultorios, barberías, estéticas, talleres, asesorías, estudios de tatuaje o cualquier negocio que trabaje con citas.

Lo que vas a construir ​

Para los clientes (/)

  1. Eligen un servicio (con duración y precio).
  2. Eligen una fecha y ven sólo los horarios realmente disponibles.
  3. Dejan sus datos y reciben un código de reserva.

Para el negocio (/admin.html)

  • Login con JWT.
  • Resumen del día (citas de hoy, pendientes, próximas).
  • Tabla de citas filtrable por fecha y estado.
  • Confirmar, completar o cancelar citas.

En el backend

  • Cálculo de disponibilidad a partir de horarios por día de la semana, días bloqueados (vacaciones), duración de cada servicio y citas existentes.
  • Creación de citas dentro de una transacción con bloqueo para que dos personas no puedan reservar el mismo horario al mismo tiempo.
  • Validación con zod, errores centralizados, helmet, CORS y rate limit.

Requisitos ​

  • Node.js 20 o superior.
  • MySQL 8 (o MariaDB 10.5+).
  • Conocimientos de Express y SQL.

Estructura de archivos ​

txt
reservas/
├── package.json
├── .env.example
├── schema.sql
├── src/
│   ├── server.js
│   ├── app.js
│   ├── db.js
│   ├── schemas.js                 ← validaciones zod
│   ├── utils/
│   │   ├── AppError.js
│   │   ├── asyncHandler.js
│   │   └── tiempo.js              ← helpers de horas y fechas
│   ├── middlewares/
│   │   ├── auth.js
│   │   ├── validar.js
│   │   └── errores.js
│   ├── controllers/
│   │   ├── publico.controller.js  ← servicios, disponibilidad, crear cita
│   │   └── admin.controller.js    ← login, listado, cambiar estado
│   ├── routes/
│   │   ├── publico.routes.js
│   │   └── admin.routes.js
│   └── scripts/
│       └── crear-admin.js
└── public/
    ├── index.html                 ← reservar
    ├── app.js
    ├── admin.html                 ← panel
    ├── admin.js
    └── estilos.css

Paso 1: base de datos ​

También puedes descargarlo: schema.sql.

sql
CREATE DATABASE IF NOT EXISTS reservas CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE reservas;

CREATE TABLE servicios (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  nombre VARCHAR(100) NOT NULL,
  descripcion VARCHAR(255) NULL,
  duracion_min SMALLINT UNSIGNED NOT NULL,          -- múltiplo de 15 recomendado
  precio DECIMAL(10,2) NOT NULL DEFAULT 0,
  activo TINYINT(1) NOT NULL DEFAULT 1
) ENGINE=InnoDB;

-- Horario de atención por día de la semana (0 = domingo ... 6 = sábado).
-- Puede haber varios bloques por día (mañana y tarde).
CREATE TABLE horarios (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  dia_semana TINYINT UNSIGNED NOT NULL,
  hora_inicio TIME NOT NULL,
  hora_fin TIME NOT NULL,
  KEY idx_dia (dia_semana),
  CONSTRAINT chk_dia CHECK (dia_semana BETWEEN 0 AND 6),
  CONSTRAINT chk_horas CHECK (hora_fin > hora_inicio)
) ENGINE=InnoDB;

-- Días sin servicio (vacaciones, festivos)
CREATE TABLE dias_bloqueados (
  fecha DATE PRIMARY KEY,
  motivo VARCHAR(100) NULL
) ENGINE=InnoDB;

CREATE TABLE clientes (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  nombre VARCHAR(100) NOT NULL,
  email VARCHAR(150) NOT NULL,
  telefono VARCHAR(20) NOT NULL,
  creado_en TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uq_cliente_email (email)
) ENGINE=InnoDB;

CREATE TABLE citas (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  codigo CHAR(8) NOT NULL UNIQUE,                    -- código que ve el cliente
  servicio_id INT UNSIGNED NOT NULL,
  cliente_id INT UNSIGNED NOT NULL,
  fecha DATE NOT NULL,
  hora_inicio TIME NOT NULL,
  hora_fin TIME NOT NULL,
  estado ENUM('pendiente','confirmada','completada','cancelada') NOT NULL DEFAULT 'pendiente',
  notas VARCHAR(500) NULL,
  creado_en TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  -- Columna auxiliar: 1 si la cita ocupa el horario, NULL si está cancelada.
  -- El índice único impide dos citas activas que empiecen a la misma hora.
  ocupa TINYINT AS (IF(estado IN ('pendiente','confirmada'), 1, NULL)) STORED,
  UNIQUE KEY uq_cita_activa (fecha, hora_inicio, ocupa),
  KEY idx_citas_fecha (fecha, estado),
  FOREIGN KEY (servicio_id) REFERENCES servicios(id),
  FOREIGN KEY (cliente_id) REFERENCES clientes(id)
) ENGINE=InnoDB;

CREATE TABLE administradores (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  nombre VARCHAR(100) NOT NULL,
  email VARCHAR(150) NOT NULL UNIQUE,
  password_hash VARCHAR(255) NOT NULL
) ENGINE=InnoDB;

-- Datos de ejemplo
INSERT INTO servicios (nombre, descripcion, duracion_min, precio) VALUES
  ('Corte de cabello', 'Corte con lavado y peinado', 30, 180),
  ('Corte + barba', 'Corte completo y arreglo de barba', 60, 300),
  ('Tinte', 'Aplicación de color completo', 90, 650),
  ('Valoración', 'Primera consulta sin costo', 15, 0);

-- Lunes a viernes 9:00-14:00 y 16:00-19:00, sábado 9:00-14:00
INSERT INTO horarios (dia_semana, hora_inicio, hora_fin) VALUES
  (1,'09:00','14:00'),(1,'16:00','19:00'),
  (2,'09:00','14:00'),(2,'16:00','19:00'),
  (3,'09:00','14:00'),(3,'16:00','19:00'),
  (4,'09:00','14:00'),(4,'16:00','19:00'),
  (5,'09:00','14:00'),(5,'16:00','19:00'),
  (6,'09:00','14:00');

INSERT INTO dias_bloqueados (fecha, motivo) VALUES ('2026-12-25', 'Navidad'), ('2027-01-01', 'Año Nuevo');

Columnas generadas

ocupa se calcula sola a partir de estado. Como MySQL permite varios NULL en un índice único, puedes tener muchas citas canceladas a la misma hora, pero sólo una activa. Es una red de seguridad adicional a la transacción.

Paso 2: proyecto y dependencias ​

bash
mkdir reservas && cd reservas
npm init -y
npm pkg set type=module
npm install express mysql2 dotenv zod cors helmet express-rate-limit bcrypt jsonwebtoken
npm install -D nodemon
json
{
  "name": "reservas",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "nodemon src/server.js",
    "start": "node src/server.js",
    "crear-admin": "node src/scripts/crear-admin.js"
  },
  "dependencies": {
    "bcrypt": "^5.1.1",
    "cors": "^2.8.5",
    "dotenv": "^16.4.5",
    "express": "^4.21.0",
    "express-rate-limit": "^7.4.0",
    "helmet": "^7.1.0",
    "jsonwebtoken": "^9.0.2",
    "mysql2": "^3.11.0",
    "zod": "^3.23.8"
  },
  "devDependencies": {
    "nodemon": "^3.1.4"
  }
}
env
NODE_ENV=development
PORT=3000
TZ=America/Mexico_City

DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=root
DB_PASS=
DB_NAME=reservas

# Genera uno con: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
JWT_SECRET=cambia-esto-por-un-valor-largo-y-aleatorio
JWT_EXPIRA=8h

# Intervalo entre horarios ofrecidos (minutos) y días máximos hacia adelante
INTERVALO_MIN=30
DIAS_MAXIMOS=60

CORS_ORIGINS=http://localhost:3000

Copia .env.example como .env y ajusta tus datos. La variable TZ hace que Node use la hora de México para calcular "hoy" y las horas ya pasadas.

Paso 3: utilidades y conexión ​

js
import mysql from 'mysql2/promise';
import 'dotenv/config';

export const pool = mysql.createPool({
  host: process.env.DB_HOST,
  port: Number(process.env.DB_PORT ?? 3306),
  user: process.env.DB_USER,
  password: process.env.DB_PASS,
  database: process.env.DB_NAME,
  connectionLimit: 10,
  dateStrings: true,     // DATE y TIME como texto: "2026-10-18", "09:30:00"
  decimalNumbers: true,  // DECIMAL como número
  timezone: 'local',
});

export async function query(sql, params = []) {
  const [filas] = await pool.execute(sql, params);
  return filas;
}

/** Ejecuta fn(conexion) dentro de una transacción */
export async function transaccion(fn) {
  const conn = await pool.getConnection();
  try {
    await conn.beginTransaction();
    const resultado = await fn(conn);
    await conn.commit();
    return resultado;
  } catch (err) {
    await conn.rollback();
    throw err;
  } finally {
    conn.release();
  }
}
js
export class AppError extends Error {
  constructor(mensaje, status = 400, detalles) {
    super(mensaje);
    this.status = status;
    this.detalles = detalles;
  }
}
js
// Express 4 no captura errores de funciones async: los mandamos a next()
export const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
js
/** "09:30" o "09:30:00" -> 570 (minutos desde medianoche) */
export const aMinutos = (hora) => {
  const [h, m] = hora.split(':').map(Number);
  return h * 60 + m;
};

/** 570 -> "09:30" */
export const aHora = (min) =>
  `${String(Math.floor(min / 60)).padStart(2, '0')}:${String(min % 60).padStart(2, '0')}`;

/** Fecha local de hoy como "AAAA-MM-DD" (respeta la variable TZ) */
export function hoyISO() {
  const d = new Date();
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}

/** Minutos transcurridos del día actual */
export function minutosAhora() {
  const d = new Date();
  return d.getHours() * 60 + d.getMinutes();
}

/** Día de la semana (0 = domingo) de una fecha "AAAA-MM-DD" sin problemas de zona horaria */
export function diaSemana(fechaISO) {
  const [a, m, d] = fechaISO.split('-').map(Number);
  return new Date(a, m - 1, d).getDay();
}

/** Diferencia en días entre hoy y una fecha */
export function diasDesdeHoy(fechaISO) {
  const [a, m, d] = fechaISO.split('-').map(Number);
  const [ha, hm, hd] = hoyISO().split('-').map(Number);
  return Math.round((new Date(a, m - 1, d) - new Date(ha, hm - 1, hd)) / 864e5);
}

/** ¿Se enciman dos rangos [inicio, fin)? */
export const seEnciman = (ini1, fin1, ini2, fin2) => ini1 < fin2 && fin1 > ini2;

Paso 4: validación, autenticación y errores ​

js
import { z } from 'zod';

const fecha = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato de fecha AAAA-MM-DD');
const hora = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'Formato de hora HH:MM');

export const disponibilidadQuery = z.object({
  servicioId: z.coerce.number().int().positive(),
  fecha,
});

export const crearCitaBody = z.object({
  servicioId: z.coerce.number().int().positive(),
  fecha,
  hora,
  nombre: z.string().trim().min(2, 'Escribe tu nombre').max(100),
  email: z.string().trim().toLowerCase().email('Correo no válido'),
  telefono: z.string().trim().regex(/^\d{10}$/, 'El teléfono debe tener 10 dígitos'),
  notas: z.string().trim().max(500).optional().default(''),
});

export const loginBody = z.object({
  email: z.string().trim().toLowerCase().email(),
  password: z.string().min(1),
});

export const listarCitasQuery = z.object({
  fecha: fecha.optional(),
  estado: z.enum(['pendiente', 'confirmada', 'completada', 'cancelada']).optional(),
});

export const cambiarEstadoBody = z.object({
  estado: z.enum(['pendiente', 'confirmada', 'completada', 'cancelada']),
});

export const idParam = z.object({ id: z.coerce.number().int().positive() });
export const codigoParam = z.object({ codigo: z.string().regex(/^[A-Z0-9]{8}$/) });
js
import { ZodError } from 'zod';

/** validar({ body, query, params }) — deja los datos limpios en req.datos */
export const validar = (esquemas) => (req, res, next) => {
  try {
    req.datos = {};
    for (const parte of ['params', 'query', 'body']) {
      if (esquemas[parte]) Object.assign(req.datos, esquemas[parte].parse(req[parte]));
    }
    next();
  } catch (err) {
    if (err instanceof ZodError) {
      return res.status(422).json({
        error: 'Revisa los datos enviados',
        detalles: err.issues.map((i) => ({ campo: i.path.join('.'), mensaje: i.message })),
      });
    }
    next(err);
  }
};
js
import jwt from 'jsonwebtoken';

export function soloAdmin(req, res, next) {
  const [tipo, token] = (req.headers.authorization ?? '').split(' ');
  if (tipo !== 'Bearer' || !token) return res.status(401).json({ error: 'Inicia sesión' });
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET);
    req.admin = { id: payload.sub, nombre: payload.nombre };
    next();
  } catch {
    res.status(401).json({ error: 'Sesión expirada, vuelve a iniciar sesión' });
  }
}
js
import { AppError } from '../utils/AppError.js';

export function noEncontrado(req, res) {
  res.status(404).json({ error: `No existe ${req.method} ${req.originalUrl}` });
}

// eslint-disable-next-line no-unused-vars
export function manejadorErrores(err, req, res, next) {
  if (err instanceof AppError) {
    return res.status(err.status).json({ error: err.message, ...(err.detalles && { detalles: err.detalles }) });
  }
  if (err.code === 'ER_DUP_ENTRY') {
    return res.status(409).json({ error: 'Ese horario acaba de ser reservado. Elige otro.' });
  }
  if (err.type === 'entity.parse.failed') {
    return res.status(400).json({ error: 'JSON no válido' });
  }
  console.error(err);
  res.status(500).json({
    error: 'Error interno del servidor',
    ...(process.env.NODE_ENV !== 'production' && { detalle: err.message }),
  });
}

Paso 5: lógica pública (servicios, disponibilidad y crear cita) ​

Este es el corazón del sistema. La función calcularHorarios se usa tanto para mostrar disponibilidad como para volver a verificar dentro de la transacción antes de guardar.

js
import crypto from 'node:crypto';
import { pool, query, transaccion } from '../db.js';
import { asyncHandler } from '../utils/asyncHandler.js';
import { AppError } from '../utils/AppError.js';
import { aMinutos, aHora, hoyISO, minutosAhora, diaSemana, diasDesdeHoy, seEnciman } from '../utils/tiempo.js';

const INTERVALO = Number(process.env.INTERVALO_MIN ?? 30);
const DIAS_MAXIMOS = Number(process.env.DIAS_MAXIMOS ?? 60);
const ANTICIPACION_MIN = 60; // no se puede reservar con menos de 1 hora de anticipación

/**
 * Devuelve los horarios libres ("HH:MM") para un servicio en una fecha.
 * @param {object} db  pool o conexión (dentro de transacción)
 */
async function calcularHorarios(db, servicio, fecha) {
  const dias = diasDesdeHoy(fecha);
  if (dias < 0 || dias > DIAS_MAXIMOS) return [];

  const ejecutar = async (sql, params) => (await db.execute(sql, params))[0];

  const [bloqueado] = await ejecutar('SELECT 1 FROM dias_bloqueados WHERE fecha = ?', [fecha]);
  if (bloqueado) return [];

  const bloques = await ejecutar(
    'SELECT hora_inicio, hora_fin FROM horarios WHERE dia_semana = ? ORDER BY hora_inicio',
    [diaSemana(fecha)]
  );
  if (!bloques.length) return [];

  const ocupadas = (await ejecutar(
    "SELECT hora_inicio, hora_fin FROM citas WHERE fecha = ? AND estado IN ('pendiente','confirmada')",
    [fecha]
  )).map((c) => [aMinutos(c.hora_inicio), aMinutos(c.hora_fin)]);

  const limiteHoy = fecha === hoyISO() ? minutosAhora() + ANTICIPACION_MIN : -1;
  const libres = [];

  for (const b of bloques) {
    const inicioBloque = aMinutos(b.hora_inicio);
    const finBloque = aMinutos(b.hora_fin);
    // Recorre el bloque en pasos de INTERVALO; el servicio debe terminar antes del cierre
    for (let ini = inicioBloque; ini + servicio.duracion_min <= finBloque; ini += INTERVALO) {
      const fin = ini + servicio.duracion_min;
      if (ini < limiteHoy) continue;
      if (ocupadas.some(([oIni, oFin]) => seEnciman(ini, fin, oIni, oFin))) continue;
      libres.push(aHora(ini));
    }
  }
  return libres;
}

async function obtenerServicio(db, id) {
  const [[servicio]] = await db.execute('SELECT * FROM servicios WHERE id = ? AND activo = 1', [id]);
  if (!servicio) throw new AppError('El servicio no existe', 404);
  return servicio;
}

// GET /api/servicios
export const listarServicios = asyncHandler(async (req, res) => {
  const servicios = await query(
    'SELECT id, nombre, descripcion, duracion_min, precio FROM servicios WHERE activo = 1 ORDER BY id'
  );
  res.json({ data: servicios });
});

// GET /api/disponibilidad?servicioId=1&fecha=2026-10-20
export const disponibilidad = asyncHandler(async (req, res) => {
  const { servicioId, fecha } = req.datos;
  const servicio = await obtenerServicio(pool, servicioId);
  const horarios = await calcularHorarios(pool, servicio, fecha);
  res.json({ data: { fecha, servicio: servicio.nombre, horarios } });
});

// POST /api/citas
export const crearCita = asyncHandler(async (req, res) => {
  const { servicioId, fecha, hora, nombre, email, telefono, notas } = req.datos;

  const cita = await transaccion(async (conn) => {
    // 1) BLOQUEO: serializa todas las reservas del mismo día de la semana.
    //    Otra petición para ese día esperará aquí hasta que terminemos (COMMIT/ROLLBACK).
    await conn.execute('SELECT id FROM horarios WHERE dia_semana = ? FOR UPDATE', [diaSemana(fecha)]);

    // 2) Vuelve a calcular la disponibilidad YA con el bloqueo puesto
    const servicio = await obtenerServicio(conn, servicioId);
    const libres = await calcularHorarios(conn, servicio, fecha);
    if (!libres.includes(hora)) {
      throw new AppError('Ese horario ya no está disponible. Por favor elige otro.', 409);
    }

    // 3) Cliente: crea o actualiza por correo
    await conn.execute(
      `INSERT INTO clientes (nombre, email, telefono) VALUES (?, ?, ?)
       ON DUPLICATE KEY UPDATE nombre = VALUES(nombre), telefono = VALUES(telefono), id = LAST_INSERT_ID(id)`,
      [nombre, email, telefono]
    );
    const [[{ clienteId }]] = await conn.execute('SELECT LAST_INSERT_ID() AS clienteId');

    // 4) Guarda la cita con un código legible (sin 0/O ni 1/I para evitar confusiones)
    const alfabeto = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
    const codigo = Array.from(crypto.randomBytes(8), (b) => alfabeto[b % alfabeto.length]).join('');
    const horaFin = aHora(aMinutos(hora) + servicio.duracion_min);

    await conn.execute(
      `INSERT INTO citas (codigo, servicio_id, cliente_id, fecha, hora_inicio, hora_fin, notas)
       VALUES (?, ?, ?, ?, ?, ?, ?)`,
      [codigo, servicio.id, clienteId, fecha, hora, horaFin, notas || null]
    );

    return { codigo, servicio: servicio.nombre, fecha, hora, horaFin, precio: servicio.precio };
  });

  res.status(201).json({ data: cita, mensaje: '¡Tu cita fue registrada!' });
});

// GET /api/citas/:codigo  (el cliente consulta su cita)
export const consultarCita = asyncHandler(async (req, res) => {
  const [cita] = await query(
    `SELECT c.codigo, c.fecha, LEFT(c.hora_inicio, 5) AS hora, c.estado, s.nombre AS servicio, cl.nombre
     FROM citas c JOIN servicios s ON s.id = c.servicio_id JOIN clientes cl ON cl.id = c.cliente_id
     WHERE c.codigo = ?`,
    [req.datos.codigo]
  );
  if (!cita) throw new AppError('No encontramos una cita con ese código', 404);
  res.json({ data: cita });
});

¿Por qué bloquear antes de verificar?

Sin el FOR UPDATE, dos clientes podrían consultar al mismo tiempo, ver el horario de las 10:00 libre y guardar ambos. Con el bloqueo, la segunda petición espera a que la primera termine; cuando por fin verifica, el horario ya aparece ocupado y recibe un 409. Es el patrón "bloquear → verificar → escribir" que debes usar siempre que haya cupo limitado (citas, inventario, boletos).

Paso 6: lógica del administrador ​

js
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { query } from '../db.js';
import { asyncHandler } from '../utils/asyncHandler.js';
import { AppError } from '../utils/AppError.js';
import { hoyISO } from '../utils/tiempo.js';

const HASH_FALSO = bcrypt.hashSync('relleno-para-tiempo-constante', 10);

// POST /api/admin/login
export const login = asyncHandler(async (req, res) => {
  const { email, password } = req.datos;
  const [admin] = await query('SELECT * FROM administradores WHERE email = ?', [email]);
  const ok = await bcrypt.compare(password, admin?.password_hash ?? HASH_FALSO);
  if (!admin || !ok) throw new AppError('Correo o contraseña incorrectos', 401);

  const token = jwt.sign({ sub: admin.id, nombre: admin.nombre }, process.env.JWT_SECRET, {
    expiresIn: process.env.JWT_EXPIRA ?? '8h',
  });
  res.json({ token, nombre: admin.nombre });
});

// GET /api/admin/resumen
export const resumen = asyncHandler(async (req, res) => {
  const hoy = hoyISO();
  const [fila] = await query(
    `SELECT
       SUM(fecha = ? AND estado IN ('pendiente','confirmada')) AS hoy,
       SUM(estado = 'pendiente' AND fecha >= ?)               AS pendientes,
       SUM(fecha > ? AND estado IN ('pendiente','confirmada')) AS proximas,
       SUM(estado = 'completada' AND fecha >= DATE_FORMAT(?, '%Y-%m-01')) AS completadas_mes
     FROM citas`,
    [hoy, hoy, hoy, hoy]
  );
  res.json({ data: Object.fromEntries(Object.entries(fila).map(([k, v]) => [k, Number(v ?? 0)])) });
});

// GET /api/admin/citas?fecha=&estado=
export const listarCitas = asyncHandler(async (req, res) => {
  const { fecha, estado } = req.datos;
  const where = [];
  const params = [];
  if (fecha) { where.push('c.fecha = ?'); params.push(fecha); }
  else { where.push('c.fecha >= ?'); params.push(hoyISO()); } // por defecto: de hoy en adelante
  if (estado) { where.push('c.estado = ?'); params.push(estado); }

  const citas = await query(
    `SELECT c.id, c.codigo, c.fecha, LEFT(c.hora_inicio, 5) AS hora, LEFT(c.hora_fin, 5) AS hora_fin,
            c.estado, c.notas, s.nombre AS servicio, s.precio,
            cl.nombre AS cliente, cl.email, cl.telefono
     FROM citas c
     JOIN servicios s ON s.id = c.servicio_id
     JOIN clientes cl ON cl.id = c.cliente_id
     WHERE ${where.join(' AND ')}
     ORDER BY c.fecha, c.hora_inicio
     LIMIT 500`,
    params
  );
  res.json({ data: citas });
});

// PATCH /api/admin/citas/:id  { estado }
export const cambiarEstado = asyncHandler(async (req, res) => {
  const { id, estado } = req.datos;
  const r = await query('UPDATE citas SET estado = ? WHERE id = ?', [estado, id]);
  if (!r.affectedRows) throw new AppError('Cita no encontrada', 404);
  res.json({ data: { id, estado } });
});

Reactivar una cita cancelada

Si un administrador vuelve a poner como pendiente una cita cancelada y ese horario ya se ocupó, el índice único uq_cita_activa lanza ER_DUP_ENTRY y el manejador de errores responde 409. Así nunca quedan dos citas activas a la misma hora.

Paso 7: rutas, app y servidor ​

js
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import { validar } from '../middlewares/validar.js';
import * as s from '../schemas.js';
import * as ctrl from '../controllers/publico.controller.js';

const router = Router();

// Máximo 10 reservas por hora por IP (evita spam de citas falsas)
const limiteReservas = rateLimit({ windowMs: 60 * 60 * 1000, limit: 10, message: { error: 'Demasiadas reservas. Intenta más tarde.' } });

router.get('/servicios', ctrl.listarServicios);
router.get('/disponibilidad', validar({ query: s.disponibilidadQuery }), ctrl.disponibilidad);
router.post('/citas', limiteReservas, validar({ body: s.crearCitaBody }), ctrl.crearCita);
router.get('/citas/:codigo', validar({ params: s.codigoParam }), ctrl.consultarCita);

export default router;
js
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import { validar } from '../middlewares/validar.js';
import { soloAdmin } from '../middlewares/auth.js';
import * as s from '../schemas.js';
import * as ctrl from '../controllers/admin.controller.js';

const router = Router();

const limiteLogin = rateLimit({ windowMs: 15 * 60 * 1000, limit: 5, skipSuccessfulRequests: true, message: { error: 'Demasiados intentos. Espera 15 minutos.' } });

router.post('/login', limiteLogin, validar({ body: s.loginBody }), ctrl.login);

// Todo lo de abajo requiere token
router.use(soloAdmin);
router.get('/resumen', ctrl.resumen);
router.get('/citas', validar({ query: s.listarCitasQuery }), ctrl.listarCitas);
router.patch('/citas/:id', validar({ params: s.idParam, body: s.cambiarEstadoBody }), ctrl.cambiarEstado);

export default router;
js
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
import publicoRoutes from './routes/publico.routes.js';
import adminRoutes from './routes/admin.routes.js';
import { noEncontrado, manejadorErrores } from './middlewares/errores.js';

const app = express();
app.set('trust proxy', 1);          // detrás de Nginx / Render / Railway
app.disable('x-powered-by');

app.use(helmet());
app.use(cors({ origin: (process.env.CORS_ORIGINS ?? '').split(',').filter(Boolean) }));
app.use(express.json({ limit: '20kb' }));
app.use('/api', rateLimit({ windowMs: 15 * 60 * 1000, limit: 300 }));

app.use(express.static('public'));  // frontend
app.get('/health', (req, res) => res.json({ ok: true }));
app.use('/api', publicoRoutes);
app.use('/api/admin', adminRoutes);

app.use('/api', noEncontrado);
app.use(manejadorErrores);

export default app;
js
import 'dotenv/config';
import app from './app.js';
import { pool } from './db.js';

await pool.query('SELECT 1');       // falla rápido si la base de datos no responde
console.log('✅ MySQL conectado');

const port = Number(process.env.PORT ?? 3000);
const server = app.listen(port, () => console.log(`🚀 Reservas en http://localhost:${port}`));

process.on('SIGTERM', () => server.close(() => pool.end().then(() => process.exit(0))));
js
// Uso: npm run crear-admin -- "Laura Méndez" laura@negocio.mx "ContraseñaSegura123"
import 'dotenv/config';
import bcrypt from 'bcrypt';
import { pool } from '../db.js';

const [nombre, email, password] = process.argv.slice(2);
if (!nombre || !email || !password || password.length < 10) {
  console.error('Uso: npm run crear-admin -- "Nombre" correo@ejemplo.com "contraseña de 10+ caracteres"');
  process.exit(1);
}

const hash = await bcrypt.hash(password, 12);
await pool.execute(
  'INSERT INTO administradores (nombre, email, password_hash) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash), nombre = VALUES(nombre)',
  [nombre, email.toLowerCase(), hash]
);
console.log(`✅ Administrador ${email} listo`);
await pool.end();

Helmet y los scripts del frontend

helmet() activa una Content Security Policy que sólo permite scripts del mismo origen. Por eso el frontend usa archivos .js externos (app.js, admin.js) en lugar de código dentro de etiquetas script en el HTML, y no usa atributos onclick.

Paso 8: frontend para reservar ​

html
<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Reserva tu cita | Estudio Bigote</title>
  <link rel="stylesheet" href="/estilos.css">
</head>
<body>
  <main class="contenedor">
    <header class="cabecera">
      <h1>Reserva tu cita</h1>
      <p>Elige el servicio, el día y la hora que prefieras. Te confirmamos por WhatsApp.</p>
    </header>

    <ol class="pasos" id="indicador">
      <li class="activo">Servicio</li><li>Fecha y hora</li><li>Tus datos</li>
    </ol>

    <!-- Paso 1 -->
    <section class="paso" id="paso-1">
      <h2>1. ¿Qué servicio necesitas?</h2>
      <div class="servicios" id="servicios"><p class="cargando">Cargando servicios…</p></div>
    </section>

    <!-- Paso 2 -->
    <section class="paso oculto" id="paso-2">
      <h2>2. Elige fecha y hora</h2>
      <p class="resumen-sel" id="sel-servicio"></p>
      <label class="campo">Fecha <input type="date" id="fecha"></label>
      <div class="horarios" id="horarios"></div>
      <div class="acciones"><button class="btn-sec" data-ir="1">← Cambiar servicio</button></div>
    </section>

    <!-- Paso 3 -->
    <section class="paso oculto" id="paso-3">
      <h2>3. Tus datos</h2>
      <p class="resumen-sel" id="sel-completa"></p>
      <form id="form-datos" novalidate>
        <label class="campo">Nombre completo <input name="nombre" required autocomplete="name"></label>
        <label class="campo">Correo <input name="email" type="email" required autocomplete="email"></label>
        <label class="campo">Teléfono (10 dígitos) <input name="telefono" type="tel" required inputmode="numeric" autocomplete="tel"></label>
        <label class="campo">Notas (opcional) <textarea name="notas" rows="2" maxlength="500"></textarea></label>
        <p class="error" id="error-form" role="alert"></p>
        <div class="acciones">
          <button type="button" class="btn-sec" data-ir="2">← Cambiar horario</button>
          <button class="btn" id="btn-confirmar">Confirmar cita</button>
        </div>
      </form>
    </section>

    <!-- Confirmación -->
    <section class="paso oculto exito" id="paso-4">
      <div class="icono-exito">✓</div>
      <h2>¡Cita registrada!</h2>
      <p id="detalle-cita"></p>
      <p>Tu código de reserva es</p>
      <p class="codigo" id="codigo"></p>
      <p class="nota">Guárdalo para consultar o cambiar tu cita.</p>
      <button class="btn" id="nueva">Reservar otra cita</button>
    </section>
  </main>
  <script src="/app.js" type="module"></script>
</body>
</html>
js
const $ = (s) => document.querySelector(s);
const mxn = new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
const estado = { servicio: null, fecha: '', hora: '' };

/** fetch con JSON y errores legibles */
async function api(url, opciones = {}) {
  const res = await fetch(url, {
    ...opciones,
    headers: { 'Content-Type': 'application/json', ...opciones.headers },
  });
  const datos = await res.json().catch(() => ({}));
  if (!res.ok) {
    const err = new Error(datos.error ?? 'Error de conexión');
    err.detalles = datos.detalles;
    err.status = res.status;
    throw err;
  }
  return datos;
}

const fechaLarga = (iso) => {
  const [a, m, d] = iso.split('-').map(Number);
  return new Date(a, m - 1, d).toLocaleDateString('es-MX', { weekday: 'long', day: 'numeric', month: 'long' });
};
const hoyISO = () => {
  const d = new Date();
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};

function irAPaso(n) {
  [1, 2, 3, 4].forEach((i) => $(`#paso-${i}`).classList.toggle('oculto', i !== n));
  document.querySelectorAll('#indicador li').forEach((li, i) => li.classList.toggle('activo', i < Math.min(n, 3)));
  window.scrollTo({ top: 0, behavior: 'smooth' });
}

// ---------- Paso 1: servicios ----------
async function cargarServicios() {
  try {
    const { data } = await api('/api/servicios');
    $('#servicios').innerHTML = '';
    data.forEach((s) => {
      const btn = document.createElement('button');
      btn.className = 'servicio';
      btn.innerHTML = `<strong></strong><span class="desc"></span><span class="meta">⏱ ${s.duracion_min} min · ${s.precio > 0 ? mxn.format(s.precio) : 'Gratis'}</span>`;
      btn.querySelector('strong').textContent = s.nombre;        // textContent: sin riesgo de XSS
      btn.querySelector('.desc').textContent = s.descripcion ?? '';
      btn.addEventListener('click', () => {
        estado.servicio = s;
        $('#sel-servicio').textContent = `${s.nombre} · ${s.duracion_min} min`;
        irAPaso(2);
        if (estado.fecha) cargarHorarios();
      });
      $('#servicios').append(btn);
    });
  } catch (err) {
    $('#servicios').innerHTML = `<p class="error">No se pudieron cargar los servicios. ${err.message}</p>`;
  }
}

// ---------- Paso 2: fecha y horarios ----------
const inputFecha = $('#fecha');
inputFecha.min = hoyISO();
const max = new Date(); max.setDate(max.getDate() + 60);
inputFecha.max = `${max.getFullYear()}-${String(max.getMonth() + 1).padStart(2, '0')}-${String(max.getDate()).padStart(2, '0')}`;

inputFecha.addEventListener('change', () => {
  estado.fecha = inputFecha.value;
  cargarHorarios();
});

async function cargarHorarios() {
  const cont = $('#horarios');
  if (!estado.fecha) return;
  cont.innerHTML = '<p class="cargando">Buscando horarios…</p>';
  try {
    const { data } = await api(`/api/disponibilidad?servicioId=${estado.servicio.id}&fecha=${estado.fecha}`);
    if (!data.horarios.length) {
      cont.innerHTML = '<p class="vacio">No hay horarios disponibles ese día. Prueba con otra fecha.</p>';
      return;
    }
    cont.innerHTML = data.horarios.map((h) => `<button class="hora" data-hora="${h}">${h}</button>`).join('');
  } catch (err) {
    cont.innerHTML = `<p class="error">${err.message}</p>`;
  }
}

$('#horarios').addEventListener('click', (e) => {
  const b = e.target.closest('[data-hora]');
  if (!b) return;
  estado.hora = b.dataset.hora;
  $('#sel-completa').textContent = `${estado.servicio.nombre} · ${fechaLarga(estado.fecha)} a las ${estado.hora}`;
  irAPaso(3);
});

// ---------- Paso 3: datos y confirmación ----------
$('#form-datos').addEventListener('submit', async (e) => {
  e.preventDefault();
  const form = e.target;
  const btn = $('#btn-confirmar');
  $('#error-form').textContent = '';
  btn.disabled = true;
  btn.textContent = 'Reservando…';

  try {
    const { data } = await api('/api/citas', {
      method: 'POST',
      body: JSON.stringify({
        servicioId: estado.servicio.id,
        fecha: estado.fecha,
        hora: estado.hora,
        ...Object.fromEntries(new FormData(form)),
      }),
    });
    $('#codigo').textContent = data.codigo;
    $('#detalle-cita').textContent = `${data.servicio} · ${fechaLarga(data.fecha)} de ${data.hora} a ${data.horaFin}`;
    form.reset();
    irAPaso(4);
  } catch (err) {
    if (err.status === 409) {
      // Alguien ganó ese horario: volvemos al paso 2 con horarios actualizados
      alert(err.message);
      irAPaso(2);
      cargarHorarios();
    } else {
      $('#error-form').textContent = err.detalles?.map((d) => d.mensaje).join(' · ') ?? err.message;
    }
  } finally {
    btn.disabled = false;
    btn.textContent = 'Confirmar cita';
  }
});

document.querySelectorAll('[data-ir]').forEach((b) => b.addEventListener('click', () => irAPaso(Number(b.dataset.ir))));
$('#nueva').addEventListener('click', () => { Object.assign(estado, { servicio: null, fecha: '', hora: '' }); inputFecha.value = ''; $('#horarios').innerHTML = ''; irAPaso(1); });

cargarServicios();

Paso 9: panel de administración ​

html
<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta name="robots" content="noindex">
  <title>Panel de citas</title>
  <link rel="stylesheet" href="/estilos.css">
</head>
<body>
  <!-- Login -->
  <main class="contenedor estrecho" id="vista-login">
    <form id="form-login" class="tarjeta">
      <h1>Panel de citas</h1>
      <label class="campo">Correo <input name="email" type="email" required autofocus></label>
      <label class="campo">Contraseña <input name="password" type="password" required></label>
      <p class="error" id="error-login" role="alert"></p>
      <button class="btn">Entrar</button>
    </form>
  </main>

  <!-- Panel -->
  <main class="contenedor oculto" id="vista-panel">
    <header class="barra-admin">
      <h1>Citas</h1>
      <span id="bienvenida"></span>
      <button class="btn-sec" id="salir">Cerrar sesión</button>
    </header>

    <section class="kpis" id="kpis"></section>

    <section class="filtros">
      <label class="campo">Fecha <input type="date" id="filtro-fecha"></label>
      <label class="campo">Estado
        <select id="filtro-estado">
          <option value="">Todos</option>
          <option value="pendiente">Pendiente</option>
          <option value="confirmada">Confirmada</option>
          <option value="completada">Completada</option>
          <option value="cancelada">Cancelada</option>
        </select>
      </label>
      <button class="btn-sec" id="limpiar">Próximas citas</button>
    </section>

    <div class="tabla-scroll">
      <table class="tabla">
        <thead><tr><th>Fecha</th><th>Hora</th><th>Cliente</th><th>Servicio</th><th>Estado</th><th>Acciones</th></tr></thead>
        <tbody id="tabla-citas"></tbody>
      </table>
    </div>
  </main>
  <script src="/admin.js" type="module"></script>
</body>
</html>
js
const $ = (s) => document.querySelector(s);
const CLAVE = 'reservas_token';

// El token vive en sessionStorage: se borra al cerrar la pestaña
const leerToken = () => { try { return sessionStorage.getItem(CLAVE); } catch { return null; } };
const guardarToken = (t) => { try { t ? sessionStorage.setItem(CLAVE, t) : sessionStorage.removeItem(CLAVE); } catch {} };

async function api(url, opciones = {}) {
  const token = leerToken();
  const res = await fetch(url, {
    ...opciones,
    headers: { 'Content-Type': 'application/json', ...(token && { Authorization: `Bearer ${token}` }) },
  });
  const datos = await res.json().catch(() => ({}));
  if (res.status === 401 && token) { salir(); throw new Error('Tu sesión expiró'); }
  if (!res.ok) throw new Error(datos.error ?? 'Error de conexión');
  return datos;
}

const escapar = (t) => String(t ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const fechaCorta = (iso) => {
  const [a, m, d] = iso.split('-').map(Number);
  return new Date(a, m - 1, d).toLocaleDateString('es-MX', { weekday: 'short', day: 'numeric', month: 'short' });
};

// ---------- Sesión ----------
function mostrarPanel(nombre) {
  $('#vista-login').classList.add('oculto');
  $('#vista-panel').classList.remove('oculto');
  if (nombre) $('#bienvenida').textContent = `Hola, ${nombre}`;
  cargarTodo();
}

function salir() {
  guardarToken(null);
  $('#vista-panel').classList.add('oculto');
  $('#vista-login').classList.remove('oculto');
}

$('#form-login').addEventListener('submit', async (e) => {
  e.preventDefault();
  $('#error-login').textContent = '';
  try {
    const { token, nombre } = await api('/api/admin/login', {
      method: 'POST',
      body: JSON.stringify(Object.fromEntries(new FormData(e.target))),
    });
    guardarToken(token);
    e.target.reset();
    mostrarPanel(nombre);
  } catch (err) {
    $('#error-login').textContent = err.message;
  }
});
$('#salir').addEventListener('click', salir);

// ---------- Datos ----------
async function cargarResumen() {
  const { data } = await api('/api/admin/resumen');
  const items = [['Hoy', data.hoy], ['Pendientes', data.pendientes], ['Próximas', data.proximas], ['Completadas (mes)', data.completadas_mes]];
  $('#kpis').innerHTML = items.map(([t, v]) => `<div class="kpi"><strong>${v}</strong>${t}</div>`).join('');
}

async function cargarCitas() {
  const params = new URLSearchParams();
  if ($('#filtro-fecha').value) params.set('fecha', $('#filtro-fecha').value);
  if ($('#filtro-estado').value) params.set('estado', $('#filtro-estado').value);
  const tbody = $('#tabla-citas');
  tbody.innerHTML = '<tr><td colspan="6">Cargando…</td></tr>';
  try {
    const { data } = await api('/api/admin/citas?' + params);
    tbody.innerHTML = data.length ? data.map((c) => `
      <tr>
        <td>${fechaCorta(c.fecha)}</td>
        <td><strong>${c.hora}</strong>–${c.hora_fin}</td>
        <td>${escapar(c.cliente)}<small>${escapar(c.telefono)} · ${escapar(c.email)}</small>${c.notas ? `<small>📝 ${escapar(c.notas)}</small>` : ''}</td>
        <td>${escapar(c.servicio)}<small>Código ${c.codigo}</small></td>
        <td><span class="estado ${c.estado}">${c.estado}</span></td>
        <td class="acciones">
          ${c.estado === 'pendiente' ? `<button data-id="${c.id}" data-estado="confirmada">Confirmar</button>` : ''}
          ${c.estado === 'confirmada' ? `<button data-id="${c.id}" data-estado="completada">Completar</button>` : ''}
          ${['pendiente', 'confirmada'].includes(c.estado) ? `<button class="peligro" data-id="${c.id}" data-estado="cancelada">Cancelar</button>` : ''}
          <a href="https://wa.me/52${encodeURIComponent(c.telefono)}" target="_blank" rel="noopener">WhatsApp</a>
        </td>
      </tr>`).join('') : '<tr><td colspan="6">No hay citas con esos filtros.</td></tr>';
  } catch (err) {
    tbody.innerHTML = `<tr><td colspan="6" class="error">${escapar(err.message)}</td></tr>`;
  }
}

const cargarTodo = () => Promise.all([cargarResumen(), cargarCitas()]).catch(() => {});

$('#tabla-citas').addEventListener('click', async (e) => {
  const b = e.target.closest('button[data-id]');
  if (!b) return;
  if (b.dataset.estado === 'cancelada' && !confirm('¿Cancelar esta cita?')) return;
  b.disabled = true;
  try {
    await api(`/api/admin/citas/${b.dataset.id}`, { method: 'PATCH', body: JSON.stringify({ estado: b.dataset.estado }) });
    cargarTodo();
  } catch (err) {
    alert(err.message);
    b.disabled = false;
  }
});

$('#filtro-fecha').addEventListener('change', cargarCitas);
$('#filtro-estado').addEventListener('change', cargarCitas);
$('#limpiar').addEventListener('click', () => { $('#filtro-fecha').value = ''; $('#filtro-estado').value = ''; cargarCitas(); });

// Si ya hay token (recarga de página), entra directo
if (leerToken()) mostrarPanel();

Token en sessionStorage

Para un panel interno pequeño, guardar el JWT en sessionStorage es aceptable y simple. Para algo más expuesto, usa el esquema de access + refresh token con cookie httpOnly.

Paso 10: estilos ​

css
:root {
  --primario: #0f766e;
  --primario-claro: #ccfbf1;
  --texto: #1f2937;
  --suave: #6b7280;
  --fondo: #f8fafc;
  --borde: #e5e7eb;
  --rojo: #dc2626;
  --radio: 12px;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; background: var(--fondo); color: var(--texto); line-height: 1.5; }
.contenedor { width: min(900px, 100% - 32px); margin: 32px auto; }
.contenedor.estrecho { width: min(400px, 100% - 32px); margin-top: 12vh; }
.oculto { display: none !important; }
.cabecera { text-align: center; margin-bottom: 24px; }
.cabecera p, .nota, .cargando, .vacio { color: var(--suave); }
.error { color: var(--rojo); }
.tarjeta, .paso { background: #fff; border: 1px solid var(--borde); border-radius: var(--radio); padding: 24px; }
.btn, .btn-sec { border: 0; border-radius: 10px; padding: 11px 20px; font: inherit; font-weight: 600; cursor: pointer; }
.btn { background: var(--primario); color: #fff; }
.btn:disabled { opacity: .6; cursor: wait; }
.btn-sec { background: #fff; border: 1px solid var(--borde); color: var(--texto); }
.campo { display: grid; gap: 6px; font-weight: 600; font-size: 14px; margin-bottom: 14px; }
.campo input, .campo select, .campo textarea { padding: 10px 12px; border: 1px solid var(--borde); border-radius: 8px; font: inherit; font-weight: 400; }
.acciones { display: flex; gap: 10px; justify-content: space-between; flex-wrap: wrap; margin-top: 10px; }

/* Indicador de pasos */
.pasos { display: flex; gap: 8px; list-style: none; padding: 0; margin: 0 0 18px; counter-reset: p; }
.pasos li { flex: 1; text-align: center; padding: 8px; border-radius: 8px; background: #fff; border: 1px solid var(--borde); color: var(--suave); font-size: 14px; counter-increment: p; }
.pasos li::before { content: counter(p) '. '; }
.pasos li.activo { background: var(--primario-claro); color: var(--primario); border-color: var(--primario); font-weight: 700; }

/* Servicios y horarios */
.servicios { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
.servicio { text-align: left; background: #fff; border: 1.5px solid var(--borde); border-radius: var(--radio); padding: 16px; cursor: pointer; font: inherit; display: grid; gap: 4px; transition: border-color .2s, transform .2s; }
.servicio:hover { border-color: var(--primario); transform: translateY(-2px); }
.servicio .desc { color: var(--suave); font-size: 14px; }
.servicio .meta { color: var(--primario); font-weight: 600; font-size: 14px; }
.resumen-sel { background: var(--primario-claro); color: var(--primario); padding: 10px 14px; border-radius: 8px; font-weight: 600; }
.horarios { display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 8px; margin: 10px 0; }
.hora { padding: 10px; border: 1.5px solid var(--borde); background: #fff; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer; }
.hora:hover { border-color: var(--primario); background: var(--primario-claro); }

/* Éxito */
.exito { text-align: center; }
.icono-exito { width: 64px; height: 64px; border-radius: 50%; background: var(--primario); color: #fff; font-size: 32px; display: grid; place-items: center; margin: 0 auto; }
.codigo { font-family: ui-monospace, monospace; font-size: 30px; font-weight: 800; letter-spacing: .2em; color: var(--primario); }

/* Panel */
.barra-admin { display: flex; align-items: center; gap: 16px; margin-bottom: 18px; }
.barra-admin h1 { margin: 0; flex: 1; }
.kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 18px; }
.kpi { background: #fff; border: 1px solid var(--borde); border-radius: var(--radio); padding: 16px; color: var(--suave); font-size: 14px; }
.kpi strong { display: block; font-size: 28px; color: var(--texto); }
.filtros { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
.tabla-scroll { overflow-x: auto; }
.tabla { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid var(--borde); border-radius: var(--radio); min-width: 720px; }
.tabla th, .tabla td { padding: 12px; border-bottom: 1px solid var(--borde); text-align: left; vertical-align: top; }
.tabla th { background: #f3f4f6; font-size: 13px; color: var(--suave); }
.tabla small { display: block; color: var(--suave); font-size: 12px; }
.estado { font-size: 12px; font-weight: 700; padding: 3px 10px; border-radius: 999px; text-transform: capitalize; }
.estado.pendiente { background: #fef3c7; color: #92400e; }
.estado.confirmada { background: #dbeafe; color: #1e40af; }
.estado.completada { background: #dcfce7; color: #166534; }
.estado.cancelada { background: #fee2e2; color: #991b1b; }
.tabla .acciones { justify-content: flex-start; margin: 0; gap: 6px; }
.tabla .acciones button, .tabla .acciones a { font-size: 13px; padding: 5px 10px; border-radius: 6px; border: 1px solid var(--borde); background: #fff; cursor: pointer; color: var(--texto); text-decoration: none; }
.tabla .acciones .peligro { color: var(--rojo); }

@media (max-width: 640px) {
  .kpis { grid-template-columns: repeat(2, 1fr); }
  .pasos li { font-size: 12px; }
}

Paso 11: ejecutar en local ​

bash
# 1. Base de datos
mysql -u root -p < schema.sql

# 2. Variables de entorno
cp .env.example .env      # en Windows: copy .env.example .env

# 3. Crear el administrador
npm run crear-admin -- "Laura Méndez" laura@negocio.mx "MiContraseñaSegura2026"

# 4. Arrancar en modo desarrollo
npm run dev
  • Reservas: http://localhost:3000
  • Panel: http://localhost:3000/admin.html

Prueba la protección contra dobles reservas abriendo dos pestañas, eligiendo el mismo horario en ambas y confirmando: la segunda recibirá "Ese horario ya no está disponible".

Paso 12: desplegar ​

Opción A: VPS (DigitalOcean, Hetzner, Contabo...) con PM2 + Nginx ​

bash
# En el servidor (Ubuntu)
sudo apt update && sudo apt install -y nginx mysql-server
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - && sudo apt install -y nodejs
sudo npm install -g pm2

git clone https://github.com/tu-usuario/reservas.git && cd reservas
npm ci --omit=dev
cp .env.example .env && nano .env        # NODE_ENV=production, credenciales, JWT_SECRET nuevo
mysql -u root -p < schema.sql
npm run crear-admin -- "Admin" admin@negocio.mx "ContraseñaLargaYSegura"

pm2 start src/server.js --name reservas
pm2 save && pm2 startup                   # se reinicia sola si el servidor se reinicia
nginx
# /etc/nginx/sites-available/reservas
server {
    listen 80;
    server_name citas.tunegocio.mx;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
bash
sudo ln -s /etc/nginx/sites-available/reservas /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d citas.tunegocio.mx    # HTTPS gratis con Let's Encrypt

Opción B: Render o Railway (sin administrar servidores) ​

  1. Sube el proyecto a GitHub (con .env en .gitignore).
  2. Crea una base de datos MySQL administrada (Railway la ofrece integrada; con Render puedes usar Aiven, PlanetScale o Railway).
  3. Crea un Web Service apuntando al repositorio: Build npm ci, Start npm start.
  4. Configura las variables de entorno del .env en el panel (incluye TZ=America/Mexico_City y CORS_ORIGINS con tu dominio).
  5. Ejecuta schema.sql en la base remota y crea el admin desde la consola del servicio.

Checklist antes de entregar ​

✔Revisión
☐JWT_SECRET largo y aleatorio, distinto al de desarrollo
☐NODE_ENV=production (oculta detalles de errores)
☐TZ correcta para el negocio
☐Horarios, servicios y días bloqueados cargados con los datos reales
☐HTTPS activo
☐Respaldo diario de la base de datos (mysqldump en cron o respaldos del proveedor)
☐Probado en celular: la mayoría de clientes reservará desde ahí

Mejoras sugeridas ​

  1. Correo de confirmación y recordatorio 24 h antes con nodemailer y un cron (node-cron).
  2. Varios profesionales: tabla profesionales, relación con servicios y horarios por profesional; el bloqueo se hace por profesional.
  3. Cancelación por el cliente con su código + correo, hasta X horas antes.
  4. Pago de anticipo con Stripe o Mercado Pago para reducir inasistencias.
  5. Pantallas de administración para editar servicios, horarios y días bloqueados (hoy se editan en la base de datos).
  6. Sincronizar con Google Calendar usando su API.
  7. Diseño del panel con la plantilla Dashboard admin.

Úsalo como producto

Este sistema se puede vender a muchos negocios cambiando sólo colores, servicios y horarios. Con varios clientes, conviene evolucionarlo a multi-negocio (una columna negocio_id en cada tabla) y cobrar una mensualidad: así nace un SaaS.

Academia Web — aprende, copia y construye.