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 (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, 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}`));