feat: migración a PostgreSQL, tiempo real SSE, Dockerfile y compose para Coolify
This commit is contained in:
+198
@@ -0,0 +1,198 @@
|
||||
import pg from 'pg';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
// Configuración flexible para PostgreSQL (DATABASE_URL o variables individuales)
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
|
||||
let pool: pg.Pool | null = null;
|
||||
let isPostgresConnected = false;
|
||||
let inMemoryVotes: Record<string, string> = {};
|
||||
|
||||
if (connectionString || process.env.POSTGRES_HOST || process.env.PGHOST) {
|
||||
const sslConfig = process.env.NODE_ENV === 'production' && process.env.DATABASE_SSL === 'true'
|
||||
? { rejectUnauthorized: false }
|
||||
: false;
|
||||
|
||||
pool = new Pool(
|
||||
connectionString
|
||||
? {
|
||||
connectionString,
|
||||
ssl: sslConfig,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
}
|
||||
: {
|
||||
host: process.env.POSTGRES_HOST || process.env.PGHOST || 'localhost',
|
||||
port: parseInt(process.env.POSTGRES_PORT || process.env.PGPORT || '5432', 10),
|
||||
user: process.env.POSTGRES_USER || process.env.PGUSER || 'postgres',
|
||||
password: process.env.POSTGRES_PASSWORD || process.env.PGPASSWORD || 'postgres',
|
||||
database: process.env.POSTGRES_DB || process.env.PGDATABASE || 'encuesta_e3',
|
||||
ssl: sslConfig,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
}
|
||||
);
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('⚠️ Error inesperado en el pool de PostgreSQL:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Inicializa la tabla de votos si no existe y verifica la conexión.
|
||||
*/
|
||||
export async function initDatabase(): Promise<boolean> {
|
||||
if (!pool) {
|
||||
console.warn('⚠️ No se proporcionó configuración de PostgreSQL. Usando almacenamiento en memoria.');
|
||||
isPostgresConnected = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS votes (
|
||||
voter VARCHAR(255) PRIMARY KEY,
|
||||
candidate VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
await client.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_votes_candidate ON votes(candidate);
|
||||
`);
|
||||
|
||||
isPostgresConnected = true;
|
||||
console.log('✅ Conexión con PostgreSQL establecida exitosamente y schema verificado.');
|
||||
return true;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error conectando a PostgreSQL durante la inicialización:', (error as Error).message);
|
||||
console.warn('⚠️ La aplicación continuará funcionando en modo híbrido/resiliente.');
|
||||
isPostgresConnected = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene todos los votos registrados (mapa de votante -> candidato)
|
||||
*/
|
||||
export async function getAllVotes(): Promise<Record<string, string>> {
|
||||
if (pool && isPostgresConnected) {
|
||||
try {
|
||||
const res = await pool.query('SELECT voter, candidate FROM votes ORDER BY updated_at ASC');
|
||||
const votes: Record<string, string> = {};
|
||||
for (const row of res.rows) {
|
||||
if (row.voter && row.candidate) {
|
||||
votes[String(row.voter).trim().toUpperCase()] = String(row.candidate).trim().toUpperCase();
|
||||
}
|
||||
}
|
||||
// Actualizar réplica en memoria
|
||||
inMemoryVotes = { ...votes };
|
||||
return votes;
|
||||
} catch (err) {
|
||||
console.error('Error al consultar PostgreSQL getAllVotes:', (err as Error).message);
|
||||
return inMemoryVotes;
|
||||
}
|
||||
}
|
||||
return inMemoryVotes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserta o actualiza un voto atómicamente (UPSERT)
|
||||
*/
|
||||
export async function castVote(voter: string, candidate: string): Promise<{ voter: string; candidate: string; isNew: boolean }> {
|
||||
const normVoter = voter.trim().toUpperCase();
|
||||
const normCandidate = candidate.trim().toUpperCase();
|
||||
|
||||
if (!normVoter || !normCandidate) {
|
||||
throw new Error('Nombre de votante y candidato son requeridos.');
|
||||
}
|
||||
|
||||
if (pool && isPostgresConnected) {
|
||||
try {
|
||||
const query = `
|
||||
INSERT INTO votes (voter, candidate, updated_at)
|
||||
VALUES ($1, $2, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (voter)
|
||||
DO UPDATE SET
|
||||
candidate = EXCLUDED.candidate,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
RETURNING (xmax = 0) AS is_new, voter, candidate;
|
||||
`;
|
||||
const res = await pool.query(query, [normVoter, normCandidate]);
|
||||
const row = res.rows[0];
|
||||
inMemoryVotes[normVoter] = normCandidate;
|
||||
return {
|
||||
voter: row.voter,
|
||||
candidate: row.candidate,
|
||||
isNew: Boolean(row.is_new),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error al insertar voto en PostgreSQL:', (err as Error).message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback memoria
|
||||
const isNew = !inMemoryVotes[normVoter];
|
||||
inMemoryVotes[normVoter] = normCandidate;
|
||||
return { voter: normVoter, candidate: normCandidate, isNew };
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina un voto (para reseteo administrativo)
|
||||
*/
|
||||
export async function deleteVote(voter: string): Promise<boolean> {
|
||||
const normVoter = voter.trim().toUpperCase();
|
||||
if (!normVoter) {
|
||||
throw new Error('Nombre de votante requerido para restablecer.');
|
||||
}
|
||||
|
||||
if (pool && isPostgresConnected) {
|
||||
try {
|
||||
const res = await pool.query('DELETE FROM votes WHERE UPPER(voter) = $1', [normVoter]);
|
||||
delete inMemoryVotes[normVoter];
|
||||
return (res.rowCount ?? 0) > 0;
|
||||
} catch (err) {
|
||||
console.error('Error al eliminar voto en PostgreSQL:', (err as Error).message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const existed = Boolean(inMemoryVotes[normVoter]);
|
||||
delete inMemoryVotes[normVoter];
|
||||
return existed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnóstico de salud de la base de datos
|
||||
*/
|
||||
export async function getDatabaseHealth(): Promise<{ connected: boolean; provider: string; error?: string }> {
|
||||
if (!pool) {
|
||||
return { connected: false, provider: 'in-memory-fallback' };
|
||||
}
|
||||
try {
|
||||
const res = await pool.query('SELECT NOW() as now, COUNT(*) as count FROM votes');
|
||||
return {
|
||||
connected: true,
|
||||
provider: 'postgresql',
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
connected: false,
|
||||
provider: 'postgresql-error',
|
||||
error: (err as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user