Files
vacaciones/server.js
T
juavillo 15fc6dac3b Vacaciones: app de peticiones de vacaciones para restaurante
Cargos configurables por ronda (por defecto Camarero/a, Encargado, Cocina),
calendario con días recuadrados, recuento de compañeros del mismo cargo por
día y minileyenda por mes. Panel de administración con login, rondas y
exportación a Excel. Node + Express + SQLite, listo para Docker/Coolify.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 16:45:36 +02:00

379 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const express = require('express');
const cookieParser = require('cookie-parser');
const Database = require('better-sqlite3');
const ExcelJS = require('exceljs');
const crypto = require('crypto');
const path = require('path');
const fs = require('fs');
const PORT = process.env.PORT || 3000;
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin';
if (!process.env.ADMIN_PASSWORD) {
console.warn('⚠ ADMIN_PASSWORD no está definida. Usando la contraseña por defecto "admin". Defínela en producción.');
}
fs.mkdirSync(DATA_DIR, { recursive: true });
const db = new Database(path.join(DATA_DIR, 'vacaciones.db'));
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS rounds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
year INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
roles TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS workers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
round_id INTEGER NOT NULL REFERENCES rounds(id) ON DELETE CASCADE,
name TEXT NOT NULL,
role TEXT NOT NULL,
secret TEXT UNIQUE NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS requests (
worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE,
date TEXT NOT NULL,
PRIMARY KEY (worker_id, date)
);
CREATE INDEX IF NOT EXISTS idx_workers_round ON workers(round_id);
CREATE INDEX IF NOT EXISTS idx_requests_date ON requests(date);
`);
// Migración para bases de datos creadas antes de los cargos configurables.
if (!db.prepare("PRAGMA table_info('rounds')").all().some((c) => c.name === 'roles')) {
db.exec('ALTER TABLE rounds ADD COLUMN roles TEXT');
}
// Cargos por defecto que se proponen al crear una ronda nueva.
const DEFAULT_ROLES = {
camarero: 'Camarero/a',
encargado: 'Encargado',
cocina: 'Cocina',
};
// Cargos de las rondas antiguas que no tienen cargos guardados (la clave
// histórica "jefe_rango" se conserva para no romper sus peticiones, pero se
// muestra ya como "Encargado").
const LEGACY_ROLES = {
camarero: 'Camarero/a',
jefe_rango: 'Encargado',
cocina: 'Cocina',
};
// Cargos de una ronda concreta (mapa clave → etiqueta).
function roundRoles(round) {
if (round.roles) {
try {
const parsed = JSON.parse(round.roles);
if (parsed && typeof parsed === 'object' && Object.keys(parsed).length) return parsed;
} catch { /* cae al valor por defecto */ }
}
return LEGACY_ROLES;
}
// Convierte una etiqueta ("Camarero/a") en una clave estable ("camarero_a").
const slugify = (s) =>
String(s)
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
// A partir de una lista de etiquetas crea el mapa {clave: etiqueta} con claves únicas.
function buildRoles(labels) {
const out = {};
labels.forEach((label, i) => {
const base = slugify(label) || `rol_${i + 1}`;
let key = base;
let n = 2;
while (out[key]) key = `${base}_${n++}`;
out[key] = label;
});
return out;
}
const app = express();
app.disable('x-powered-by');
app.use(express.json());
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
// ---------- helpers ----------
const newToken = (bytes = 9) => crypto.randomBytes(bytes).toString('base64url');
const getRound = db.prepare('SELECT * FROM rounds WHERE token = ?');
function getWorkerFromCookie(req, round) {
const secret = req.cookies[`vac_${round.token}`];
if (!secret) return null;
return db
.prepare('SELECT * FROM workers WHERE secret = ? AND round_id = ?')
.get(secret, round.id) || null;
}
function roundCounts(roundId, excludeWorkerId) {
// Recuento por fecha y cargo, excluyendo al propio trabajador.
const rows = db
.prepare(
`SELECT r.date, w.role, COUNT(*) AS n
FROM requests r JOIN workers w ON w.id = r.worker_id
WHERE w.round_id = ? AND w.id != ?
GROUP BY r.date, w.role`
)
.all(roundId, excludeWorkerId ?? -1);
const counts = {};
for (const row of rows) {
(counts[row.date] ??= {})[row.role] = row.n;
}
return counts;
}
function workerDates(workerId) {
return db
.prepare('SELECT date FROM requests WHERE worker_id = ? ORDER BY date')
.all(workerId)
.map((r) => r.date);
}
const isValidDate = (s, year) => {
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false;
const d = new Date(s + 'T00:00:00Z');
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s && d.getUTCFullYear() === year;
};
// ---------- sesiones de administración (en memoria) ----------
const adminSessions = new Set();
function requireAdmin(req, res, next) {
const sid = req.cookies.admin_session;
if (sid && adminSessions.has(sid)) return next();
res.status(401).json({ error: 'No autorizado' });
}
// Comparación en tiempo constante para no filtrar la contraseña.
function passwordOk(given) {
const a = crypto.createHash('sha256').update(String(given ?? '')).digest();
const b = crypto.createHash('sha256').update(ADMIN_PASSWORD).digest();
return crypto.timingSafeEqual(a, b);
}
// ---------- páginas ----------
app.get('/', (_req, res) => res.redirect('/admin'));
app.get('/admin', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'admin.html')));
app.get('/r/:token', (req, res) => {
if (!getRound.get(req.params.token)) {
return res.status(404).sendFile(path.join(__dirname, 'public', '404.html'));
}
res.sendFile(path.join(__dirname, 'public', 'round.html'));
});
// ---------- API trabajadores ----------
app.get('/api/round/:token', (req, res) => {
const round = getRound.get(req.params.token);
if (!round) return res.status(404).json({ error: 'Ronda no encontrada' });
const worker = getWorkerFromCookie(req, round);
res.json({
round: { name: round.name, year: round.year, status: round.status },
roles: roundRoles(round),
me: worker
? { name: worker.name, role: worker.role, dates: workerDates(worker.id) }
: null,
counts: roundCounts(round.id, worker?.id),
});
});
app.post('/api/round/:token/join', (req, res) => {
const round = getRound.get(req.params.token);
if (!round) return res.status(404).json({ error: 'Ronda no encontrada' });
if (round.status !== 'open') return res.status(403).json({ error: 'La ronda está cerrada' });
if (getWorkerFromCookie(req, round)) return res.status(409).json({ error: 'Ya estás registrado en esta ronda' });
const name = String(req.body.name ?? '').trim();
const role = String(req.body.role ?? '');
if (name.length < 2 || name.length > 60) return res.status(400).json({ error: 'Escribe tu nombre (260 caracteres)' });
if (!roundRoles(round)[role]) return res.status(400).json({ error: 'Elige un cargo válido' });
const secret = newToken(24);
db.prepare('INSERT INTO workers (round_id, name, role, secret) VALUES (?, ?, ?, ?)').run(
round.id, name, role, secret
);
res.cookie(`vac_${round.token}`, secret, {
httpOnly: true,
sameSite: 'lax',
maxAge: 400 * 24 * 3600 * 1000,
path: '/',
});
res.json({ ok: true, me: { name, role, dates: [] } });
});
app.put('/api/round/:token/requests', (req, res) => {
const round = getRound.get(req.params.token);
if (!round) return res.status(404).json({ error: 'Ronda no encontrada' });
if (round.status !== 'open') return res.status(403).json({ error: 'La ronda está cerrada, ya no se puede modificar' });
const worker = getWorkerFromCookie(req, round);
if (!worker) return res.status(401).json({ error: 'No estás registrado en esta ronda' });
const dates = req.body.dates;
if (!Array.isArray(dates) || dates.length > 366) return res.status(400).json({ error: 'Petición no válida' });
const unique = [...new Set(dates.map(String))];
if (!unique.every((d) => isValidDate(d, round.year))) {
return res.status(400).json({ error: `Las fechas deben ser días válidos de ${round.year}` });
}
const replace = db.transaction((ds) => {
db.prepare('DELETE FROM requests WHERE worker_id = ?').run(worker.id);
const ins = db.prepare('INSERT INTO requests (worker_id, date) VALUES (?, ?)');
for (const d of ds) ins.run(worker.id, d);
});
replace(unique);
res.json({ ok: true, dates: unique.sort(), counts: roundCounts(round.id, worker.id) });
});
// ---------- API administración ----------
app.post('/api/admin/login', (req, res) => {
if (!passwordOk(req.body.password)) {
return res.status(401).json({ error: 'Contraseña incorrecta' });
}
const sid = newToken(24);
adminSessions.add(sid);
res.cookie('admin_session', sid, { httpOnly: true, sameSite: 'lax', maxAge: 30 * 24 * 3600 * 1000 });
res.json({ ok: true });
});
app.post('/api/admin/logout', requireAdmin, (req, res) => {
adminSessions.delete(req.cookies.admin_session);
res.clearCookie('admin_session');
res.json({ ok: true });
});
app.get('/api/admin/me', requireAdmin, (_req, res) => res.json({ ok: true }));
app.get('/api/admin/rounds', requireAdmin, (_req, res) => {
const rounds = db
.prepare(
`SELECT r.*,
(SELECT COUNT(*) FROM workers w WHERE w.round_id = r.id) AS workers,
(SELECT COUNT(*) FROM requests q JOIN workers w ON w.id = q.worker_id WHERE w.round_id = r.id) AS days
FROM rounds r ORDER BY r.created_at DESC`
)
.all();
res.json({ rounds });
});
app.post('/api/admin/rounds', requireAdmin, (req, res) => {
const name = String(req.body.name ?? '').trim();
const year = Number(req.body.year);
if (!name || name.length > 80) return res.status(400).json({ error: 'Indica el nombre del local' });
if (!Number.isInteger(year) || year < 2020 || year > 2100) return res.status(400).json({ error: 'Año no válido' });
let roles = DEFAULT_ROLES;
if (req.body.roles != null) {
const labels = (Array.isArray(req.body.roles) ? req.body.roles : String(req.body.roles).split(','))
.map((s) => String(s).trim())
.filter(Boolean);
if (labels.length < 1 || labels.length > 8) return res.status(400).json({ error: 'Indica entre 1 y 8 cargos' });
if (labels.some((l) => l.length > 40)) return res.status(400).json({ error: 'Cada cargo puede tener como máximo 40 caracteres' });
roles = buildRoles(labels);
}
const token = newToken();
const info = db
.prepare('INSERT INTO rounds (token, name, year, roles) VALUES (?, ?, ?, ?)')
.run(token, name, year, JSON.stringify(roles));
res.json({ ok: true, round: { id: info.lastInsertRowid, token, name, year, status: 'open' } });
});
app.post('/api/admin/rounds/:id/status', requireAdmin, (req, res) => {
const status = req.body.status === 'open' ? 'open' : 'closed';
const info = db.prepare('UPDATE rounds SET status = ? WHERE id = ?').run(status, req.params.id);
if (!info.changes) return res.status(404).json({ error: 'Ronda no encontrada' });
res.json({ ok: true, status });
});
app.delete('/api/admin/rounds/:id', requireAdmin, (req, res) => {
const info = db.prepare('DELETE FROM rounds WHERE id = ?').run(req.params.id);
if (!info.changes) return res.status(404).json({ error: 'Ronda no encontrada' });
res.json({ ok: true });
});
app.get('/api/admin/rounds/:id/excel', requireAdmin, async (req, res) => {
const round = db.prepare('SELECT * FROM rounds WHERE id = ?').get(req.params.id);
if (!round) return res.status(404).json({ error: 'Ronda no encontrada' });
const roles = roundRoles(round);
const workers = db
.prepare('SELECT * FROM workers WHERE round_id = ? ORDER BY role, name COLLATE NOCASE')
.all(round.id);
const wb = new ExcelJS.Workbook();
wb.creator = 'Vacaciones';
// Hoja 1: una fila por persona con sus fechas
const ws = wb.addWorksheet('Peticiones');
ws.columns = [
{ header: 'Nombre', key: 'name', width: 28 },
{ header: 'Cargo', key: 'role', width: 16 },
{ header: 'Nº días', key: 'n', width: 9 },
{ header: 'Fechas pedidas', key: 'dates', width: 90 },
];
ws.getRow(1).font = { bold: true };
for (const w of workers) {
const dates = workerDates(w.id);
ws.addRow({ name: w.name, role: roles[w.role] ?? w.role, n: dates.length, dates: dates.join(', ') });
}
// Hoja 2: recuento por día y cargo
const byDay = db
.prepare(
`SELECT q.date, w.role, COUNT(*) AS n
FROM requests q JOIN workers w ON w.id = q.worker_id
WHERE w.round_id = ? GROUP BY q.date, w.role ORDER BY q.date`
)
.all(round.id);
const dayMap = new Map();
for (const row of byDay) {
if (!dayMap.has(row.date)) dayMap.set(row.date, {});
dayMap.get(row.date)[row.role] = row.n;
}
const roleKeys = Object.keys(roles);
const ws2 = wb.addWorksheet('Por día');
ws2.columns = [
{ header: 'Fecha', key: 'date', width: 14 },
...roleKeys.map((k) => ({ header: roles[k], key: k, width: 16 })),
{ header: 'Total', key: 'total', width: 9 },
];
ws2.getRow(1).font = { bold: true };
for (const [date, byRole] of dayMap) {
const row = { date };
let total = 0;
for (const k of roleKeys) {
row[k] = byRole[k] ?? 0;
total += row[k];
}
row.total = total;
ws2.addRow(row);
}
const safe = round.name.replace(/[^\p{L}\p{N} _-]/gu, '').trim() || 'ronda';
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="vacaciones-${safe}-${round.year}.xlsx"`);
await wb.xlsx.write(res);
res.end();
});
app.listen(PORT, () => console.log(`Vacaciones escuchando en http://localhost:${PORT}`));