246 lines
6.7 KiB
TypeScript
246 lines
6.7 KiB
TypeScript
import express, { Request, Response } from 'express';
|
|
import cors from 'cors';
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
import dotenv from 'dotenv';
|
|
import { initDatabase, getAllVotes, castVote, deleteVote, getDatabaseHealth } from './db';
|
|
|
|
dotenv.config();
|
|
|
|
const app = express();
|
|
const PORT = parseInt(process.env.PORT || '3000', 10);
|
|
const ADMIN_SECRET = process.env.ADMIN_SECRET || 'RESETEOE3';
|
|
|
|
app.use(cors());
|
|
app.use(express.json());
|
|
app.use(express.urlencoded({ extended: true }));
|
|
|
|
// Clientes SSE conectados para transmisión en vivo
|
|
interface SseClient {
|
|
id: number;
|
|
res: Response;
|
|
}
|
|
let sseClients: SseClient[] = [];
|
|
let nextClientId = 1;
|
|
|
|
/**
|
|
* Transmite la actualización de votos a todos los navegadores conectados en tiempo real
|
|
*/
|
|
async function broadcastUpdate() {
|
|
if (sseClients.length === 0) return;
|
|
try {
|
|
const votes = await getAllVotes();
|
|
const payload = JSON.stringify({
|
|
type: 'VOTES_UPDATED',
|
|
timestamp: Date.now(),
|
|
votes,
|
|
total: Object.keys(votes).length,
|
|
});
|
|
|
|
sseClients.forEach((client) => {
|
|
try {
|
|
client.res.write(`event: votes\ndata: ${payload}\n\n`);
|
|
} catch (err) {
|
|
// Ignorar clientes cerrados silenciosamente
|
|
}
|
|
});
|
|
} catch (err) {
|
|
console.error('Error transmitiendo evento SSE:', err);
|
|
}
|
|
}
|
|
|
|
// Keep-alive heartbeat para mantener abiertas las conexiones SSE a través de proxys/Cloudflare
|
|
setInterval(() => {
|
|
sseClients.forEach((client) => {
|
|
try {
|
|
client.res.write(': heartbeat\n\n');
|
|
} catch {
|
|
// ignore
|
|
}
|
|
});
|
|
}, 15000);
|
|
|
|
// ==========================================
|
|
// ENDPOINTS DE LA API
|
|
// ==========================================
|
|
|
|
/**
|
|
* Health check & diagnóstico
|
|
*/
|
|
app.get('/api/health', async (_req: Request, res: Response) => {
|
|
const dbHealth = await getDatabaseHealth();
|
|
res.json({
|
|
status: 'ok',
|
|
service: 'encuesta-votacion-e3',
|
|
version: '1.0.0',
|
|
timestamp: new Date().toISOString(),
|
|
database: dbHealth,
|
|
activeStreamClients: sseClients.length,
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Stream de Server-Sent Events (SSE) para actualizaciones en vivo cero-latencia
|
|
*/
|
|
app.get('/api/stream', async (req: Request, res: Response) => {
|
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
res.setHeader('Connection', 'keep-alive');
|
|
res.setHeader('X-Accel-Buffering', 'no'); // Deshabilita buffering en NGINX/Traefik
|
|
|
|
const clientId = nextClientId++;
|
|
const newClient: SseClient = { id: clientId, res };
|
|
sseClients.push(newClient);
|
|
|
|
// Enviar estado actual de bienvenida
|
|
const initialVotes = await getAllVotes();
|
|
res.write(
|
|
`event: initial\ndata: ${JSON.stringify({
|
|
type: 'INIT',
|
|
timestamp: Date.now(),
|
|
votes: initialVotes,
|
|
total: Object.keys(initialVotes).length,
|
|
})}\n\n`
|
|
);
|
|
|
|
req.on('close', () => {
|
|
sseClients = sseClients.filter((c) => c.id !== clientId);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Obtener todos los votos actuales
|
|
*/
|
|
app.get('/api/votes', async (_req: Request, res: Response) => {
|
|
try {
|
|
const votes = await getAllVotes();
|
|
res.json({
|
|
success: true,
|
|
votes,
|
|
total: Object.keys(votes).length,
|
|
timestamp: Date.now(),
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Error al obtener votos',
|
|
detail: (error as Error).message,
|
|
});
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Emitir o actualizar un voto
|
|
*/
|
|
app.post('/api/vote', async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const { voter, candidate } = req.body;
|
|
|
|
if (!voter || typeof voter !== 'string' || voter.trim().length < 2) {
|
|
res.status(400).json({ success: false, error: 'Nombre de votante inválido o muy corto.' });
|
|
return;
|
|
}
|
|
|
|
if (!candidate || typeof candidate !== 'string' || candidate.trim().length === 0) {
|
|
res.status(400).json({ success: false, error: 'Debe seleccionar un candidato válido.' });
|
|
return;
|
|
}
|
|
|
|
const normVoter = voter.trim().toUpperCase();
|
|
const normCand = candidate.trim().toUpperCase();
|
|
|
|
// Validación anti-autovoto
|
|
const voterWords = normVoter.split(/\s+/);
|
|
if (normVoter === normCand || voterWords.includes(normCand)) {
|
|
res.status(400).json({ success: false, error: 'No está permitido votar por uno mismo.' });
|
|
return;
|
|
}
|
|
|
|
const result = await castVote(normVoter, normCand);
|
|
|
|
// Disparar actualización en vivo para todos los clientes
|
|
broadcastUpdate().catch(console.error);
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'Voto registrado exitosamente.',
|
|
data: result,
|
|
});
|
|
} catch (error) {
|
|
console.error('Error en POST /api/vote:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Error al registrar el voto en PostgreSQL.',
|
|
detail: (error as Error).message,
|
|
});
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Restablecer / eliminar un voto (Administrador)
|
|
*/
|
|
app.delete('/api/vote/:voter', async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const voter = req.params.voter;
|
|
const providedKey = req.headers['x-admin-key'] || req.body?.password || req.query?.key;
|
|
|
|
if (providedKey !== ADMIN_SECRET) {
|
|
res.status(401).json({ success: false, error: 'Clave de administración incorrecta.' });
|
|
return;
|
|
}
|
|
|
|
if (!voter) {
|
|
res.status(400).json({ success: false, error: 'Debe especificar el nombre del votante a restablecer.' });
|
|
return;
|
|
}
|
|
|
|
const deleted = await deleteVote(voter);
|
|
|
|
// Disparar actualización en vivo
|
|
broadcastUpdate().catch(console.error);
|
|
|
|
res.json({
|
|
success: true,
|
|
message: `Voto de "${voter}" restablecido exitosamente.`,
|
|
deleted,
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Error al eliminar el voto.',
|
|
detail: (error as Error).message,
|
|
});
|
|
}
|
|
});
|
|
|
|
// ==========================================
|
|
// SERVIR FRONTEND ESTÁTICO EN PRODUCCIÓN
|
|
// ==========================================
|
|
const distPath = path.resolve(process.cwd(), 'dist');
|
|
if (fs.existsSync(distPath)) {
|
|
app.use(express.static(distPath));
|
|
app.get('*', (req: Request, res: Response) => {
|
|
if (req.path.startsWith('/api')) {
|
|
res.status(404).json({ error: 'Not Found' });
|
|
return;
|
|
}
|
|
res.sendFile(path.join(distPath, 'index.html'));
|
|
});
|
|
}
|
|
|
|
// Iniciar base de datos y servidor
|
|
async function start() {
|
|
await initDatabase();
|
|
|
|
app.listen(PORT, '0.0.0.0', () => {
|
|
console.log(`🚀 Servidor de Votación E3 corriendo en http://0.0.0.0:${PORT}`);
|
|
console.log(`📊 API disponible en http://0.0.0.0:${PORT}/api/votes`);
|
|
console.log(`⚡ Stream en tiempo real en http://0.0.0.0:${PORT}/api/stream`);
|
|
});
|
|
}
|
|
|
|
start().catch((err) => {
|
|
console.error('Fatal error iniciando el servidor:', err);
|
|
process.exit(1);
|
|
});
|