Files
vacaciones/server.js
T
juavillo 9c9d2d25f9 Límites por día/cargo, aprobación de días y calendario de admin
- Límite de solicitudes por día y cargo (modelo reserva: pendientes + aprobadas
  ocupan hueco, rechazar lo libera). Días completos, pasados o el de hoy no se
  pueden elegir; validado también en el servidor.
- Estados de aprobación en las peticiones (pendiente/aprobado/rechazado) visibles
  para el trabajador con color; solo puede editar pendientes o añadir nuevas.
- Calendario de admin con barra lateral por empleado: aprobar/rechazar por día y
  en bloque, añadir/quitar días, y editar los límites por cargo.
- Excel con días por estado y ocupación por día/cargo.
- Migraciones automáticas no destructivas (columnas rounds.limits y requests.status;
  las peticiones existentes pasan a "pendiente").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:15:31 +02:00

571 lines
21 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,
limits 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,
status TEXT NOT NULL DEFAULT 'pending',
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);
`);
// Migraciones para bases de datos creadas antes de algunas columnas.
const hasColumn = (table, col) =>
db.prepare(`PRAGMA table_info('${table}')`).all().some((c) => c.name === col);
// Cargos configurables por ronda.
if (!hasColumn('rounds', 'roles')) db.exec('ALTER TABLE rounds ADD COLUMN roles TEXT');
// Límite de solicitudes por día y cargo (JSON {clave: máximo}).
if (!hasColumn('rounds', 'limits')) db.exec('ALTER TABLE rounds ADD COLUMN limits TEXT');
// Estado de aprobación de cada solicitud: pending | approved | rejected.
if (!hasColumn('requests', 'status')) {
db.exec("ALTER TABLE requests ADD COLUMN status TEXT NOT NULL DEFAULT 'pending'");
}
// 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;
}
// Límite de solicitudes por día y cargo (mapa clave → entero). Una clave
// ausente significa "sin límite". Solo se devuelven límites de cargos vigentes.
function roundLimits(round) {
const roles = roundRoles(round);
const out = {};
if (round.limits) {
try {
const parsed = JSON.parse(round.limits);
if (parsed && typeof parsed === 'object') {
for (const [k, v] of Object.entries(parsed)) {
const n = Number(v);
if (roles[k] && Number.isInteger(n) && n > 0) out[k] = n;
}
}
} catch { /* sin límites */ }
}
return out;
}
// 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;
}
// Interpreta los cargos que llegan al crear una ronda. Acepta una lista de
// etiquetas ("Camarero, Cocina") o de objetos {label, max} con el límite por
// día. Devuelve {roles: {clave: etiqueta}, limits: {clave: máximo}}.
function parseRolesInput(input) {
const arr = Array.isArray(input) ? input : String(input).split(',');
const items = arr
.map((x) =>
x && typeof x === 'object'
? { label: String(x.label ?? '').trim(), max: x.max }
: { label: String(x).trim(), max: null }
)
.filter((it) => it.label);
if (items.length < 1 || items.length > 8) throw new Error('Indica entre 1 y 8 cargos');
if (items.some((it) => it.label.length > 40)) throw new Error('Cada cargo puede tener como máximo 40 caracteres');
const roles = buildRoles(items.map((it) => it.label));
const keys = Object.keys(roles);
const limits = {};
items.forEach((it, i) => {
const n = Number(it.max);
if (Number.isInteger(n) && n > 0 && n <= 99) limits[keys[i]] = n;
});
return { roles, limits };
}
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) {
// Solicitudes que ocupan hueco por fecha y cargo (pendientes + aprobadas,
// nunca rechazadas), 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 != ? AND r.status != 'rejected'
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;
}
// Solicitudes de un trabajador con su estado: [{date, status}].
function workerRequests(workerId) {
return db
.prepare('SELECT date, status FROM requests WHERE worker_id = ? ORDER BY date')
.all(workerId);
}
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;
};
// Fecha de hoy (YYYY-MM-DD) en la zona del restaurante, sea cual sea la zona
// horaria del servidor. Así "pasado / hoy" se calcula igual aquí y en el móvil.
const todayISO = () =>
new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Madrid' }).format(new Date());
// ---------- 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),
limits: roundLimits(round),
today: todayISO(),
me: worker
? { name: worker.name, role: worker.role, requests: workerRequests(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, requests: [] } });
});
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 wanted = [...new Set(dates.map(String))];
if (!wanted.every((d) => isValidDate(d, round.year))) {
return res.status(400).json({ error: `Las fechas deben ser días válidos de ${round.year}` });
}
const existing = workerRequests(worker.id);
const locked = new Set(existing.filter((r) => r.status !== 'pending').map((r) => r.date));
const existingPending = new Set(existing.filter((r) => r.status === 'pending').map((r) => r.date));
// El trabajador solo controla sus pendientes: los días aprobados o rechazados
// se conservan tal cual aunque vengan (o no) en la selección.
const desiredPending = wanted.filter((d) => !locked.has(d));
const added = desiredPending.filter((d) => !existingPending.has(d));
// Los días nuevos no pueden ser pasados ni el día de hoy.
const today = todayISO();
if (added.some((d) => d <= today)) {
return res.status(400).json({ error: 'No puedes elegir días pasados ni el día de hoy.' });
}
// Los días nuevos no pueden superar el límite de tu cargo (cuentan las
// solicitudes de los demás que no estén rechazadas).
const limit = roundLimits(round)[worker.role];
if (limit) {
const others = roundCounts(round.id, worker.id);
if (added.some((d) => (others[d]?.[worker.role] ?? 0) >= limit)) {
return res.status(409).json({
error: 'Algún día que elegiste ya se ha completado para tu cargo. Recarga la página y vuelve a intentarlo.',
});
}
}
const apply = db.transaction((keep) => {
const keepSet = new Set(keep);
const del = db.prepare("DELETE FROM requests WHERE worker_id = ? AND status = 'pending' AND date = ?");
for (const d of existingPending) if (!keepSet.has(d)) del.run(worker.id, d);
const ins = db.prepare("INSERT OR IGNORE INTO requests (worker_id, date, status) VALUES (?, ?, 'pending')");
for (const d of keep) if (!existingPending.has(d)) ins.run(worker.id, d);
});
apply(desiredPending);
res.json({
ok: true,
requests: workerRequests(worker.id),
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,
(SELECT COUNT(*) FROM requests q JOIN workers w ON w.id = q.worker_id WHERE w.round_id = r.id AND q.status = 'pending') AS pending
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;
let limits = {};
if (req.body.roles != null) {
try {
({ roles, limits } = parseRolesInput(req.body.roles));
} catch (ex) {
return res.status(400).json({ error: ex.message });
}
}
const token = newToken();
const info = db
.prepare('INSERT INTO rounds (token, name, year, roles, limits) VALUES (?, ?, ?, ?, ?)')
.run(token, name, year, JSON.stringify(roles), JSON.stringify(limits));
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 });
});
// Editar los límites por día y cargo de una ronda (ampliar o reducir).
app.put('/api/admin/rounds/:id/limits', requireAdmin, (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' });
if (req.body.limits == null || typeof req.body.limits !== 'object') {
return res.status(400).json({ error: 'Límites no válidos' });
}
const limits = {};
for (const key of Object.keys(roundRoles(round))) {
const n = Number(req.body.limits[key]);
if (Number.isInteger(n) && n > 0 && n <= 99) limits[key] = n;
}
db.prepare('UPDATE rounds SET limits = ? WHERE id = ?').run(JSON.stringify(limits), round.id);
res.json({ ok: true, limits });
});
// Vista de calendario del administrador: empleados con sus solicitudes (y estado),
// más los cargos y límites de la ronda.
app.get('/api/admin/rounds/:id/calendar', requireAdmin, (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 workers = db
.prepare('SELECT id, name, role FROM workers WHERE round_id = ? ORDER BY role, name COLLATE NOCASE')
.all(round.id)
.map((w) => ({ ...w, requests: workerRequests(w.id) }));
res.json({
round: { id: round.id, name: round.name, year: round.year, status: round.status },
roles: roundRoles(round),
limits: roundLimits(round),
today: todayISO(),
workers,
});
});
// Aprobar / rechazar / volver a pendiente / añadir / quitar días de un empleado.
app.post('/api/admin/rounds/:id/requests/set', requireAdmin, (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 worker = db
.prepare('SELECT * FROM workers WHERE id = ? AND round_id = ?')
.get(req.body.worker_id, round.id);
if (!worker) return res.status(404).json({ error: 'Empleado no encontrado' });
const status = req.body.status;
if (!['approved', 'rejected', 'pending', 'none'].includes(status)) {
return res.status(400).json({ error: 'Estado no válido' });
}
const dates = req.body.dates;
if (!Array.isArray(dates) || !dates.length || dates.length > 366) {
return res.status(400).json({ error: 'Fechas no válidas' });
}
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 apply = db.transaction((ds) => {
if (status === 'none') {
const del = db.prepare('DELETE FROM requests WHERE worker_id = ? AND date = ?');
for (const d of ds) del.run(worker.id, d);
} else {
const up = db.prepare(
`INSERT INTO requests (worker_id, date, status) VALUES (?, ?, ?)
ON CONFLICT(worker_id, date) DO UPDATE SET status = excluded.status`
);
for (const d of ds) up.run(worker.id, d, status);
}
});
apply(unique);
res.json({ ok: true, requests: workerRequests(worker.id) });
});
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 limits = roundLimits(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 días separados por estado.
const ws = wb.addWorksheet('Por persona');
ws.columns = [
{ header: 'Nombre', key: 'name', width: 28 },
{ header: 'Cargo', key: 'role', width: 16 },
{ header: 'Aprobados', key: 'na', width: 11 },
{ header: 'Pendientes', key: 'np', width: 11 },
{ header: 'Rechazados', key: 'nr', width: 11 },
{ header: 'Días aprobados', key: 'da', width: 60 },
{ header: 'Días pendientes', key: 'dp', width: 60 },
{ header: 'Días rechazados', key: 'dr', width: 40 },
];
ws.getRow(1).font = { bold: true };
for (const w of workers) {
const reqs = workerRequests(w.id);
const by = { approved: [], pending: [], rejected: [] };
for (const r of reqs) (by[r.status] ??= []).push(r.date);
ws.addRow({
name: w.name,
role: roles[w.role] ?? w.role,
na: by.approved.length,
np: by.pending.length,
nr: by.rejected.length,
da: by.approved.join(', '),
dp: by.pending.join(', '),
dr: by.rejected.join(', '),
});
}
// Hoja 2: ocupación por día y cargo (solicitudes no rechazadas, las que
// cuentan para el límite).
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 = ? AND q.status != 'rejected'
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: limits[k] ? `${roles[k]} (máx ${limits[k]})` : 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}`));