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'); // Días bloqueados por el admin para toda la ronda (JSON array de fechas). if (!hasColumn('rounds', 'blocked')) db.exec('ALTER TABLE rounds ADD COLUMN blocked 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; } // Días bloqueados por el admin (no elegibles por ningún trabajador). Devuelve // una lista de fechas válidas del año de la ronda, sin duplicados y ordenadas. function roundBlocked(round) { if (!round.blocked) return []; try { const parsed = JSON.parse(round.blocked); if (Array.isArray(parsed)) { return [...new Set(parsed.map(String).filter((d) => isValidDate(d, round.year)))].sort(); } } catch { /* sin bloqueos */ } return []; } // 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), blocked: roundBlocked(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 (2–60 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 estar bloqueados por la administración. const blocked = new Set(roundBlocked(round)); if (added.some((d) => blocked.has(d))) { return res.status(409).json({ error: 'Algún día que elegiste está bloqueado por la administración. Recarga la página.', }); } // 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 }); }); // Editar el nombre de una ronda (igual que se editan los límites). app.put('/api/admin/rounds/:id/name', requireAdmin, (req, res) => { const name = String(req.body.name ?? '').trim(); if (!name || name.length > 80) return res.status(400).json({ error: 'Indica el nombre del local' }); const info = db.prepare('UPDATE rounds SET name = ? WHERE id = ?').run(name, req.params.id); if (!info.changes) return res.status(404).json({ error: 'Ronda no encontrada' }); res.json({ ok: true, name }); }); // Bloquear / desbloquear días de una ronda. Los días bloqueados no son elegibles // por ningún trabajador, pero el admin sí puede asignarlos manualmente. Recibe la // lista completa de días bloqueados y la guarda (reemplazo total). app.put('/api/admin/rounds/:id/blocked', 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 dates = req.body.dates; if (!Array.isArray(dates) || dates.length > 366) { return res.status(400).json({ error: 'Días no válidos' }); } const blocked = [...new Set(dates.map(String))].filter((d) => isValidDate(d, round.year)).sort(); db.prepare('UPDATE rounds SET blocked = ? WHERE id = ?').run(JSON.stringify(blocked), round.id); res.json({ ok: true, blocked }); }); // 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), blocked: roundBlocked(round), today: todayISO(), workers, }); }); // Aprobar / rechazar / volver a pendiente / añadir / quitar días de un empleado. // El admin manda: no se aplica el límite por día/cargo ni el bloqueo de días, // así que puede asignar cualquier día aunque esté completo o bloqueado. 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}`));