218 lines
6.6 KiB
TypeScript
218 lines
6.6 KiB
TypeScript
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;
|
|
}
|
|
|
|
/**
|
|
* Elimina todos los votos (para vaciado/reset total de pruebas)
|
|
*/
|
|
export async function deleteAllVotes(): Promise<number> {
|
|
if (pool && isPostgresConnected) {
|
|
try {
|
|
const res = await pool.query('DELETE FROM votes');
|
|
inMemoryVotes = {};
|
|
return res.rowCount ?? 0;
|
|
} catch (err) {
|
|
console.error('Error al vaciar votos en PostgreSQL:', (err as Error).message);
|
|
throw err;
|
|
}
|
|
}
|
|
const count = Object.keys(inMemoryVotes).length;
|
|
inMemoryVotes = {};
|
|
return count;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
};
|
|
}
|
|
}
|