Snippets reales para los devs · listos para copiar-pegar-ejecutar · cubre infraestructura, BD, API, IA, WhatsApp
AWS · Provisión inicial (Terraform)
Archivo: infra/aws/main.tf · Ejecutar: terraform init && terraform apply
terraform {
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
}
provider "aws" {
region = "us-east-1"
}
# VPC
resource "aws_vpc" "warroom" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
tags = { Name = "warroom-adn-vpc" }
}
# Subnets públicas (2 AZs para alta disponibilidad)
resource "aws_subnet" "public_a" {
vpc_id = aws_vpc.warroom.id
cidr_block = "10.0.1.0/24"
availability_zone = "us-east-1a"
map_public_ip_on_launch = true
}
resource "aws_subnet" "public_b" {
vpc_id = aws_vpc.warroom.id
cidr_block = "10.0.2.0/24"
availability_zone = "us-east-1b"
map_public_ip_on_launch = true
}
# EC2 principal · API server
resource "aws_instance" "api_server" {
ami = "ami-0c7217cdde317cfec" # Ubuntu 22.04 LTS
instance_type = "t3.large" # 2 vCPU · 8 GB RAM
subnet_id = aws_subnet.public_a.id
key_name = "warroom-prod"
vpc_security_group_ids = [aws_security_group.api.id]
user_data = file("scripts/bootstrap-api.sh")
tags = { Name = "warroom-api-prod", Role = "api" }
}
# RDS PostgreSQL 16
resource "aws_db_instance" "main" {
identifier = "warroom-pg-prod"
engine = "postgres"
engine_version = "16.2"
instance_class = "db.m5.large"
allocated_storage = 100
storage_type = "gp3"
db_name = "warroom"
username = "adn_admin"
password = var.db_password # via TF_VAR_db_password env var
backup_retention_period = 7
backup_window = "03:00-04:00"
multi_az = false # true en FULL phase
vpc_security_group_ids = [aws_security_group.db.id]
db_subnet_group_name = aws_db_subnet_group.main.name
deletion_protection = true
skip_final_snapshot = false
tags = { Name = "warroom-pg-prod" }
}
Azure · App Service + SQL réplica (Bicep)
Archivo: infra/azure/main.bicep · Ejecutar: az deployment group create --resource-group warroom-rg --template-file main.bicep
param location string = 'eastus'
param appName string = 'warroom-adn'
// App Service Plan
resource appPlan 'Microsoft.Web/serverfarms@2023-01-01' = {
name: '${appName}-plan'
location: location
sku: {
name: 'S1' // Standard 1 core · 1.75 GB RAM
tier: 'Standard'
}
kind: 'linux'
properties: { reserved: true }
}
// App Service (API móvil + failover)
resource webApp 'Microsoft.Web/sites@2023-01-01' = {
name: '${appName}-mobile-api'
location: location
properties: {
serverFarmId: appPlan.id
siteConfig: {
linuxFxVersion: 'NODE|20-lts'
appSettings: [
{ name: 'NODE_ENV', value: 'production' }
{ name: 'DB_HOST', value: 'warroom-sql-replica.database.windows.net' }
]
}
httpsOnly: true
}
}
// SQL Database réplica
resource sqlServer 'Microsoft.Sql/servers@2023-05-01-preview' = {
name: '${appName}-sql-srv'
location: location
properties: {
administratorLogin: 'adn_admin'
administratorLoginPassword: sqlPassword // via Key Vault
version: '12.0'
}
}
resource sqlDB 'Microsoft.Sql/servers/databases@2023-05-01-preview' = {
parent: sqlServer
name: 'warroom-replica'
location: location
sku: {
name: 'S2' // 50 DTU
tier: 'Standard'
}
}
Bootstrap del servidor API (bash)
Archivo: scripts/bootstrap-api.sh · Ejecutado por user_data en EC2
#!/bin/bash
set -euo pipefail
# Actualizar sistema
apt-get update && apt-get upgrade -y
# Instalar Node.js 20 LTS
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
# Instalar PM2 (process manager)
npm install -g pm2
# Instalar nginx
apt-get install -y nginx certbot python3-certbot-nginx
# Clonar repo (usa deploy key)
mkdir -p /opt/warroom
cd /opt/warroom
git clone git@github.com:adn-ec/warroom-backend.git .
# Instalar deps
npm ci --production
# Variables de entorno (las inyecta GitHub Actions en deploy)
cp .env.example .env
# IMPORTANTE: editar /opt/warroom/.env antes del primer start
# Iniciar con PM2
pm2 start ecosystem.config.js
pm2 save
pm2 startup systemd -u ubuntu --hp /home/ubuntu
# Configurar nginx
cat > /etc/nginx/sites-available/warroom <<'NGINX_CONF'
server {
listen 80;
server_name api.warroom-adn.ec;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
NGINX_CONF
ln -s /etc/nginx/sites-available/warroom /etc/nginx/sites-enabled/
nginx -t && systemctl restart nginx
# SSL con Let's Encrypt
certbot --nginx -d api.warroom-adn.ec --non-interactive --agree-tos -m alberto@adn-ec.com
Schema principal · PostgreSQL 16
Archivo: db/migrations/001_initial_schema.sql
-- =============================================================
-- WarRoom ADN 2026 · Schema inicial · v1
-- =============================================================
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- búsqueda por similitud
-- Roles del sistema (14 roles RBAC)
CREATE TYPE rol_sistema AS ENUM (
'master', 'estratega_general', 'gerente_territorial',
'candidato', 'direccion_adn', 'gerencia_funcional',
'provincial', 'tecnologia_provincial', 'canton',
'zona', 'parroquia', 'jefe_recinto',
'brigada', 'delegado'
);
-- Cantones
CREATE TABLE cantones (
id SERIAL PRIMARY KEY,
codigo VARCHAR(10) UNIQUE NOT NULL,
nombre VARCHAR(100) NOT NULL,
padron INTEGER NOT NULL DEFAULT 0,
coord_user UUID,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Parroquias (95 totales: 65 DMQ Quito + 30 otros cantones)
CREATE TABLE parroquias (
id SERIAL PRIMARY KEY,
canton_id INTEGER NOT NULL REFERENCES cantones(id),
zona_admin VARCHAR(50), -- solo DMQ Quito tiene zona admin
codigo VARCHAR(20) UNIQUE NOT NULL,
nombre VARCHAR(100) NOT NULL,
padron INTEGER NOT NULL DEFAULT 0,
meta_votos INTEGER NOT NULL DEFAULT 0,
status_swing VARCHAR(20) DEFAULT 'normal', -- defender|ganar|recuperar|normal
coord_user UUID,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_parroquias_canton ON parroquias(canton_id);
-- Recintos electorales
CREATE TABLE recintos (
id SERIAL PRIMARY KEY,
parroquia_id INTEGER NOT NULL REFERENCES parroquias(id),
codigo_cne VARCHAR(20) UNIQUE NOT NULL,
nombre VARCHAR(200) NOT NULL,
direccion TEXT,
num_jrv INTEGER DEFAULT 0,
geo_lat NUMERIC(10,7),
geo_lng NUMERIC(10,7),
jefe_user UUID,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- JRV (Juntas Receptoras del Voto · ~7,143)
CREATE TABLE jrv (
id SERIAL PRIMARY KEY,
recinto_id INTEGER NOT NULL REFERENCES recintos(id),
numero INTEGER NOT NULL,
tipo VARCHAR(20) DEFAULT 'mixta', -- hombres|mujeres|mixta
delegado_user UUID,
suplente_user UUID,
UNIQUE(recinto_id, numero)
);
-- Padrón electoral CNE (2,4MM registros provincia + 2,09MM Quito)
CREATE TABLE padron (
cedula VARCHAR(10) PRIMARY KEY,
nombres VARCHAR(150) NOT NULL,
apellidos VARCHAR(150) NOT NULL,
jrv_id INTEGER REFERENCES jrv(id),
recinto_id INTEGER REFERENCES recintos(id),
parroquia_id INTEGER REFERENCES parroquias(id),
edad SMALLINT,
sexo CHAR(1)
);
CREATE INDEX idx_padron_recinto ON padron(recinto_id);
CREATE INDEX idx_padron_parroquia ON padron(parroquia_id);
-- Búsqueda full-text por nombre (cuando brigadista no tiene cédula)
CREATE INDEX idx_padron_nombre_trgm
ON padron USING gin ((nombres || ' ' || apellidos) gin_trgm_ops);
-- Usuarios del sistema
CREATE TABLE usuarios (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
cedula VARCHAR(10) UNIQUE NOT NULL,
nombre_completo VARCHAR(200) NOT NULL,
telefono VARCHAR(20),
email VARCHAR(150),
password_hash VARCHAR(255) NOT NULL,
rol rol_sistema NOT NULL,
-- Asignaciones territoriales (NULL si no aplica al rol)
canton_id INTEGER REFERENCES cantones(id),
zona_admin VARCHAR(50),
parroquia_id INTEGER REFERENCES parroquias(id),
recinto_id INTEGER REFERENCES recintos(id),
jrv_id INTEGER REFERENCES jrv(id),
activo BOOLEAN DEFAULT TRUE,
ultimo_login TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_usuarios_rol ON usuarios(rol);
CREATE INDEX idx_usuarios_parroquia ON usuarios(parroquia_id);
-- Actividades territoriales
CREATE TABLE actividades (
id BIGSERIAL PRIMARY KEY,
usuario_id UUID NOT NULL REFERENCES usuarios(id),
tipo VARCHAR(50) NOT NULL, -- contacto_puerta_puerta | reunion | etc
parroquia_id INTEGER REFERENCES parroquias(id),
cedula_target VARCHAR(10) REFERENCES padron(cedula),
nivel_compr SMALLINT, -- 1=duro 2=blando 3=indeciso 4=nulo recup
notas TEXT,
geo_lat NUMERIC(10,7),
geo_lng NUMERIC(10,7),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_actividades_usuario ON actividades(usuario_id);
CREATE INDEX idx_actividades_fecha ON actividades(created_at DESC);
-- Audit log (todo se registra)
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
usuario_id UUID REFERENCES usuarios(id),
accion VARCHAR(100) NOT NULL,
entidad VARCHAR(50),
entidad_id VARCHAR(50),
ip INET,
user_agent TEXT,
payload JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_audit_usuario ON audit_log(usuario_id);
CREATE INDEX idx_audit_fecha ON audit_log(created_at DESC);
-- Particionar por mes (rotación)
-- CREATE TABLE audit_log_y2026m06 PARTITION OF audit_log
-- FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
Validación cédula ecuatoriana (dígito verificador)
Función SQL · validación oficial CNE
CREATE OR REPLACE FUNCTION ecuador_cedula_valida(p_cedula VARCHAR)
RETURNS BOOLEAN AS $$
DECLARE
v_provincia INT;
v_tercer INT;
v_suma INT := 0;
v_dv INT;
v_calc INT;
v_dig INT;
v_mult INT;
i INT;
BEGIN
-- Debe tener exactamente 10 dígitos
IF p_cedula !~ '^[0-9]{10}$' THEN
RETURN FALSE;
END IF;
-- 2 primeros: código de provincia (01-24)
v_provincia := substring(p_cedula, 1, 2)::INT;
IF v_provincia < 1 OR v_provincia > 24 THEN
RETURN FALSE;
END IF;
-- 3er dígito: 0-5 para personas naturales
v_tercer := substring(p_cedula, 3, 1)::INT;
IF v_tercer > 5 THEN
RETURN FALSE;
END IF;
-- Algoritmo Módulo 10
FOR i IN 1..9 LOOP
v_dig := substring(p_cedula, i, 1)::INT;
v_mult := CASE WHEN i % 2 = 1 THEN 2 ELSE 1 END;
v_calc := v_dig * v_mult;
IF v_calc >= 10 THEN
v_calc := v_calc - 9;
END IF;
v_suma := v_suma + v_calc;
END LOOP;
v_dv := substring(p_cedula, 10, 1)::INT;
v_calc := (10 - (v_suma % 10)) % 10;
RETURN v_calc = v_dv;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- Test rápido
-- SELECT ecuador_cedula_valida('1712345678'); -- según dv
-- SELECT ecuador_cedula_valida('1234567890'); -- false
Carga masiva del padrón CNE (Python)
Archivo: scripts/cargar_padron.py · Tiempo estimado: ~45 min para 2,4MM registros
"""
Carga masiva del padrón electoral CNE en PostgreSQL.
Espera CSV con columnas: cedula, nombres, apellidos, jrv_codigo, recinto_codigo
"""
import csv
import psycopg2
from psycopg2.extras import execute_batch
import os
DB_URL = os.environ['DATABASE_URL']
CSV_PATH = '/tmp/padron_cne_2026.csv'
BATCH_SIZE = 5000
def main():
conn = psycopg2.connect(DB_URL)
conn.autocommit = False
cur = conn.cursor()
# Resolver mapeo recinto_codigo -> recinto_id en memoria
cur.execute("SELECT codigo_cne, id, parroquia_id FROM recintos")
recinto_map = {r[0]: (r[1], r[2]) for r in cur.fetchall()}
cur.execute("SELECT recinto_id, numero, id FROM jrv")
jrv_map = {(j[0], j[1]): j[2] for j in cur.fetchall()}
insert_sql = """
INSERT INTO padron (cedula, nombres, apellidos, jrv_id, recinto_id, parroquia_id)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (cedula) DO NOTHING
"""
with open(CSV_PATH, encoding='utf-8') as f:
reader = csv.DictReader(f)
batch = []
total = 0
for row in reader:
recinto = recinto_map.get(row['recinto_codigo'])
if not recinto:
continue
recinto_id, parroquia_id = recinto
jrv_id = jrv_map.get((recinto_id, int(row['jrv_codigo'])))
batch.append((
row['cedula'],
row['nombres'].strip().upper(),
row['apellidos'].strip().upper(),
jrv_id,
recinto_id,
parroquia_id,
))
if len(batch) >= BATCH_SIZE:
execute_batch(cur, insert_sql, batch, page_size=1000)
conn.commit()
total += len(batch)
print(f"Insertados: {total:}")
batch = []
# Final batch
if batch:
execute_batch(cur, insert_sql, batch, page_size=1000)
conn.commit()
total += len(batch)
print(f"\nTotal registros cargados: {total:}")
cur.close()
conn.close()
if __name__ == '__main__':
main()
API base · Node.js + Express + TypeScript
Archivo: backend/src/server.ts
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
import { authRouter } from './routes/auth';
import { brigadaRouter } from './routes/brigadistas';
import { padronRouter } from './routes/padron';
import { messagingRouter } from './routes/messaging';
import { aiRouter } from './routes/ai';
import { errorHandler } from './middleware/errors';
const app = express();
// Seguridad
app.use(helmet({
contentSecurityPolicy: { directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", 'cdn.jsdelivr.net']}},
crossOriginEmbedderPolicy: false}));
app.use(cors({
origin: ['https://warroom-adn.ec', 'https://app.warroom-adn.ec'],
credentials: true}));
// Rate limiting (proteger del spam)
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 300, // 300 req / 15min por IP
message: 'Demasiadas peticiones. Intentá en unos minutos.'});
app.use('/api/', apiLimiter);
// Rate limit más estricto para login
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
skipSuccessfulRequests: true});
app.use('/api/auth/login', loginLimiter);
app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: true }));
// Health check
app.get('/health', (_, res) => res.json({ status: 'ok', uptime: process.uptime() }));
// Rutas
app.use('/api/auth', authRouter);
app.use('/api/brigadistas', brigadaRouter);
app.use('/api/padron', padronRouter);
app.use('/api/messaging', messagingRouter);
app.use('/api/ai', aiRouter);
// Error handler
app.use(errorHandler);
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
// console.log(`API ADN escuchando en :${PORT}`);
});
Auth + JWT + RBAC middleware
// backend/src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { db } from '../db';
const JWT_SECRET = process.env.JWT_SECRET!;
export interface AuthRequest extends Request {
user?: {
id: string;
cedula: string;
rol: string;
parroquia_id?: number;
canton_id?: number;
};
}
export const requireAuth = (req: AuthRequest, res: Response, next: NextFunction) => {
const auth = req.headers.authorization;
if (!auth?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Token requerido' });
}
try {
const payload = jwt.verify(auth.slice(7), JWT_SECRET) as any;
req.user = payload;
next();
} catch (err) {
res.status(401).json({ error: 'Token inválido o expirado' });
}
};
export const requireRole = (...allowed: string[]) => {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user || !allowed.includes(req.user.rol)) {
return res.status(403).json({ error: 'Permisos insuficientes' });
}
next();
};
};
// Uso en endpoints:
// router.get('/padron/buscar',
// requireAuth,
// requireRole('master', 'brigada', 'parroquia'),
// buscarEnPadron);
Endpoint /api/auth/login + 2FA WhatsApp
// backend/src/routes/auth.ts
import { Router } from 'express';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { db } from '../db';
import { sendWhatsAppOTP } from '../integrations/whatsapp';
import { redis } from '../redis';
export const authRouter = Router();
authRouter.post('/login', async (req, res) => {
const { cedula, password } = req.body;
if (!cedula || !password) {
return res.status(400).json({ error: 'cedula y password requeridos' });
}
// Buscar usuario
const result = await db.query(
'SELECT id, password_hash, rol, telefono, activo FROM usuarios WHERE cedula = $1',
[cedula]
);
if (result.rows.length === 0) {
await db.query('INSERT INTO audit_log (accion, payload) VALUES ($1, $2)',
['login_falla_cedula_no_existe', { cedula, ip: req.ip }]);
return res.status(401).json({ error: 'Credenciales inválidas' });
}
const user = result.rows[0];
if (!user.activo) {
return res.status(403).json({ error: 'Usuario desactivado' });
}
// Verificar password
const ok = await bcrypt.compare(password, user.password_hash);
if (!ok) {
await db.query('INSERT INTO audit_log (usuario_id, accion, ip) VALUES ($1, $2, $3)',
[user.id, 'login_falla_password', req.ip]);
return res.status(401).json({ error: 'Credenciales inválidas' });
}
// Generar OTP de 6 dígitos para 2FA
const otp = Math.floor(100000 + Math.random() * 900000).toString();
await redis.setex(`2fa:${user.id}`, 300, otp); // 5 min ventana
// Enviar OTP por WhatsApp
await sendWhatsAppOTP(user.telefono, otp);
// Token temporal (15 min) que solo permite /verify-2fa
const pendingToken = jwt.sign(
{ id: user.id, pending2fa: true },
process.env.JWT_SECRET!,
{ expiresIn: '15m' }
);
res.json({
requires_2fa: true,
pending_token: pendingToken});
});
authRouter.post('/verify-2fa', async (req, res) => {
const { pending_token, otp } = req.body;
try {
const payload = jwt.verify(pending_token, process.env.JWT_SECRET!) as any;
if (!payload.pending2fa) {
return res.status(400).json({ error: 'Token inválido' });
}
const stored = await redis.get(`2fa:${payload.id}`);
if (!stored || stored !== otp) {
return res.status(401).json({ error: 'OTP inválido o expirado' });
}
await redis.del(`2fa:${payload.id}`);
// Generar token completo (24h)
const userInfo = await db.query(
'SELECT id, cedula, rol, parroquia_id, canton_id FROM usuarios WHERE id = $1',
[payload.id]
);
const token = jwt.sign(userInfo.rows[0], process.env.JWT_SECRET!, { expiresIn: '24h' });
// Audit
await db.query('INSERT INTO audit_log (usuario_id, accion, ip) VALUES ($1, $2, $3)',
[payload.id, 'login_exitoso', req.ip]);
await db.query('UPDATE usuarios SET ultimo_login = NOW() WHERE id = $1', [payload.id]);
res.json({ token, user: userInfo.rows[0] });
} catch (err) {
res.status(401).json({ error: 'Token inválido o expirado' });
}
});
Cliente Claude · 3 modelos (Opus/Sonnet/Haiku)
// backend/src/integrations/claude.ts
import Anthropic from '@anthropic-ai/sdk';
import { db } from '../db';
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY!});
export type ClaudeModel = 'opus' | 'sonnet' | 'haiku';
const MODEL_MAP: Record<ClaudeModel, string> = {
opus: 'claude-opus-4-7',
sonnet: 'claude-sonnet-4-6',
haiku: 'claude-haiku-4-5-20251001'};
interface CallClaudeOpts {
model: ClaudeModel;
systemPrompt: string;
userMessage: string;
maxTokens?: number;
usuarioId?: string; // para audit + costos
modulo?: string; // 'briefing' | 'ondemand' | 'clasificacion' | etc
}
export async function callClaude(opts: CallClaudeOpts) {
const start = Date.now();
const response = await anthropic.messages.create({
model: MODEL_MAP[opts.model],
max_tokens: opts.maxTokens ?? 2000,
system: opts.systemPrompt,
messages: [{ role: 'user', content: opts.userMessage }]});
const text = response.content[0].type === 'text' ? response.content[0].text : '';
const elapsedMs = Date.now() - start;
// Audit + costo (para tracking en IA Reports Center)
const costoEstimado = calcularCosto(
opts.model,
response.usage.input_tokens,
response.usage.output_tokens,
);
await db.query(`
INSERT INTO ia_audit (usuario_id, modulo, modelo, tokens_in, tokens_out, costo_usd, elapsed_ms, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
`, [
opts.usuarioId ?? null,
opts.modulo ?? 'general',
opts.model,
response.usage.input_tokens,
response.usage.output_tokens,
costoEstimado,
elapsedMs]);
return {
text,
tokensIn: response.usage.input_tokens,
tokensOut: response.usage.output_tokens,
costoUsd: costoEstimado,
elapsedMs};
}
function calcularCosto(model: ClaudeModel, tokIn: number, tokOut: number): number {
const rates = {
opus: { in: 5/1_000_000, out: 25/1_000_000 },
sonnet: { in: 3/1_000_000, out: 15/1_000_000 },
haiku: { in: 1/1_000_000, out: 5/1_000_000 }};
const r = rates[model];
return Number((tokIn * r.in + tokOut * r.out).toFixed(4));
}
Briefing diario automático · 6:30 AM
// backend/src/jobs/briefingDiario.ts
import { callClaude } from '../integrations/claude';
import { db } from '../db';
import { sendTelegramToGroup, sendEmail } from '../integrations/messaging';
import { generarPdfBriefing } from '../utils/pdf';
const SYSTEM_PROMPT = `Eres Claude, asistente estratégico del War Room ADN Pichincha 2026.
Tu rol: generar el briefing diario ejecutivo para Giovanna Ubidia (candidata Alcaldía Quito),
Harold Burbano (candidato Prefectura Pichincha), Alberto Buchi y Gabriela Avendaño.
REGLAS:
- Tono: ecuatoriano, directo, profesional. Estilo Buchi.
- Extensión: máx 600 palabras · 7 secciones numeradas.
- Estructura fija:
1. Estado operativo (brigadistas, recintos, fidelización)
2. Zona crítica del día
3. Movimiento del adversario (RC + Pabel Muñoz)
4. Highlight Meta (top contenido)
5. WhatsApp Business (volumen + sentiment)
6. Mensaje del día sugerido
7. Decisión urgente requerida
- Si hay decisión urgente: marcala con "DECISIÓN URGENTE:"
- Si hay riesgo crítico: marcalo con "RIESGO:"
- Datos sin invenciones. Si no hay dato, decí "Sin datos disponibles".`;
export async function generarBriefingDiario() {
// 1. Recolectar datos del día
const stats = await db.query(`
WITH stats AS (
SELECT
(SELECT COUNT(*) FROM usuarios WHERE rol = 'brigada' AND activo = TRUE) AS brigadistas_activos,
(SELECT COUNT(*) FROM usuarios WHERE rol = 'brigada') AS brigadistas_total,
(SELECT COUNT(DISTINCT recinto_id) FROM jrv WHERE delegado_user IS NOT NULL) AS recintos_cubiertos,
(SELECT COUNT(*) FROM recintos) AS recintos_total,
(SELECT COUNT(*) FROM actividades WHERE created_at > NOW() - INTERVAL '7 days') AS contactos_semana
) SELECT * FROM stats;
`);
const datos = stats.rows[0];
// 2. Recolectar datos Meta + Opp Research (otros queries)
// ... (omitido por brevedad)
const userMessage = `
DATOS DEL DÍA · ${new Date().toLocaleDateString('es-AR')}:
Operativo:
- Brigadistas activos: ${datos.brigadistas_activos}/${datos.brigadistas_total}
- Recintos cubiertos: ${datos.recintos_cubiertos}/${datos.recintos_total}
- Contactos esta semana: ${datos.contactos_semana}
(... resto de datos)
Generá el briefing diario para hoy.
`;
// 3. Llamar a Claude
const result = await callClaude({
model: 'sonnet',
systemPrompt: SYSTEM_PROMPT,
userMessage,
maxTokens: 2000,
modulo: 'briefing_diario'});
// 4. Generar PDF
const pdfPath = await generarPdfBriefing(result.text);
// 5. Enviar a destinatarios
const COMANDO_TELEGRAM_GROUP = process.env.COMANDO_TG_GROUP_ID!;
await sendTelegramToGroup(COMANDO_TELEGRAM_GROUP, result.text);
const destinatarios = ['giovanna@adn-ec.com', 'harold@adn-ec.com', 'alberto@adn-ec.com', 'gabriela@adn-ec.com'];
for (const email of destinatarios) {
await sendEmail({
to: email,
subject: `Briefing ADN · ${new Date().toLocaleDateString('es-AR')}`,
text: result.text,
attachments: [{ filename: 'briefing.pdf', path: pdfPath }]});
}
// console.log(`Briefing enviado · costo $${result.costoUsd} · ${result.elapsedMs}ms`);
}
// Cron: todos los días a las 06:30 AM (hora Ecuador GMT-5)
// 0 30 11 * * * (UTC equivalente)
Análisis on-demand · Claude responde sobre datos del War Room
// backend/src/routes/ai.ts
import { Router } from 'express';
import { requireAuth, requireRole } from '../middleware/auth';
import { callClaude } from '../integrations/claude';
import { db } from '../db';
export const aiRouter = Router();
aiRouter.post('/ask',
requireAuth,
requireRole('master', 'estratega_general', 'gerente_territorial', 'candidato', 'direccion_adn'),
async (req, res) => {
const { question } = req.body;
if (!question) return res.status(400).json({ error: 'question requerido' });
// Contexto del War Room: query agregadas + recientes
const ctx = await construirContextoWarRoom();
const systemPrompt = `Eres Claude, asistente estratégico del War Room ADN Pichincha 2026.
Respondé preguntas del comando con datos reales del sistema.
NUNCA inventes números. Si no tienes el dato, decí "No tengo ese dato en el sistema".
Tono: directo, ejecutivo, ecuatoriano.
CONTEXTO ACTUAL DEL WAR ROOM:
${ctx}`;
const result = await callClaude({
model: 'sonnet',
systemPrompt,
userMessage: question,
maxTokens: 1500,
usuarioId: req.user!.id,
modulo: 'ondemand'});
// Persistir conversación
await db.query(`
INSERT INTO ia_conversations (usuario_id, pregunta, respuesta, modelo, tokens_in, tokens_out, costo_usd)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`, [req.user!.id, question, result.text, 'sonnet', result.tokensIn, result.tokensOut, result.costoUsd]);
res.json({
respuesta: result.text,
elapsed_ms: result.elapsedMs,
costo_usd: result.costoUsd});
}
);
async function construirContextoWarRoom(): Promise<string> {
// Stats actualizadas
const r = await db.query(`
SELECT
json_build_object(
'brigadistas_activos', (SELECT COUNT(*) FROM usuarios WHERE rol = 'brigada' AND activo),
'recintos_cubiertos', (SELECT COUNT(DISTINCT recinto_id) FROM jrv WHERE delegado_user IS NOT NULL),
'fidelizacion_total', (SELECT COUNT(DISTINCT cedula_target) FROM actividades),
'fidelizacion_por_parroquia', (
SELECT json_object_agg(p.nombre, sub.contactos)
FROM (SELECT parroquia_id, COUNT(*) AS contactos FROM actividades GROUP BY parroquia_id) sub
JOIN parroquias p ON p.id = sub.parroquia_id
)
) AS ctx;
`);
return JSON.stringify(r.rows[0].ctx, null, 2);
}
WhatsApp Business · Cloud API (envío)
// backend/src/integrations/whatsapp.ts
import axios from 'axios';
const WA_PHONE_ID = process.env.WA_PHONE_NUMBER_ID!;
const WA_TOKEN = process.env.WA_ACCESS_TOKEN!;
const WA_API = `https://graph.facebook.com/v20.0/${WA_PHONE_ID}`;
interface SendTemplateOpts {
to: string; // 593987654321 (sin +)
template: string; // 'mensaje_del_dia'
language: string; // 'es'
components?: any[]; // variables {{nombre}}, etc
}
export async function sendWhatsAppTemplate(opts: SendTemplateOpts) {
const payload = {
messaging_product: 'whatsapp',
to: opts.to,
type: 'template',
template: {
name: opts.template,
language: { code: opts.language },
components: opts.components ?? []}};
const { data } = await axios.post(
`${WA_API}/messages`,
payload,
{ headers: { Authorization: `Bearer ${WA_TOKEN}`, 'Content-Type': 'application/json' } },
);
return data;
}
// Helper específico para OTP de 2FA
export async function sendWhatsAppOTP(phone: string, otp: string) {
return sendWhatsAppTemplate({
to: phone,
template: 'otp_verificacion',
language: 'es',
components: [
{
type: 'body',
parameters: [{ type: 'text', text: otp }]}]});
}
// Envío masivo (con throttling para no superar rate limits Meta)
export async function sendBulkWhatsApp(
recipients: Array<{ phone: string, params: Record<string, string> }>,
templateName: string,
) {
const BATCH = 50;
const DELAY_MS = 1000; // ~50 msg/seg · Meta soporta hasta 80/seg
const resultados = { ok: 0, falla: 0, errores: [] as any[] };
for (let i = 0; i < recipients.length; i += BATCH) {
const lote = recipients.slice(i, i + BATCH);
const promesas = lote.map(r =>
sendWhatsAppTemplate({
to: r.phone,
template: templateName,
language: 'es',
components: [{
type: 'body',
parameters: Object.values(r.params).map(v => ({ type: 'text', text: v }))}]})
.then(() => { resultados.ok++; })
.catch(err => {
resultados.falla++;
resultados.errores.push({ phone: r.phone, error: err.message });
})
);
await Promise.all(promesas);
if (i + BATCH < recipients.length) {
await new Promise(r => setTimeout(r, DELAY_MS));
}
}
return resultados;
}
Webhook entrante · clasifica con Claude Haiku
// backend/src/routes/messaging.ts (extracto)
import crypto from 'crypto';
import { callClaude } from '../integrations/claude';
import { db } from '../db';
import { sendWhatsAppText } from '../integrations/whatsapp';
const APP_SECRET = process.env.WA_APP_SECRET!;
// Webhook de verificación (lo invoca Meta al configurar)
messagingRouter.get('/webhook', (req, res) => {
const mode = req.query['hub.mode'];
const token = req.query['hub.verify_token'];
const challenge = req.query['hub.challenge'];
if (mode === 'subscribe' && token === process.env.WA_VERIFY_TOKEN) {
res.status(200).send(challenge);
} else {
res.sendStatus(403);
}
});
// Webhook de eventos
messagingRouter.post('/webhook', async (req, res) => {
// Validar firma
const signature = req.headers['x-hub-signature-256'] as string;
const expected = 'sha256=' + crypto
.createHmac('sha256', APP_SECRET)
.update(JSON.stringify(req.body))
.digest('hex');
if (signature !== expected) {
return res.sendStatus(403);
}
res.sendStatus(200); // Meta exige 200 inmediato
// Procesar async (no bloqueamos el webhook)
procesarMensajeEntrante(req.body).catch(console.error);
});
async function procesarMensajeEntrante(payload: any) {
const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages;
if (!messages) return;
for (const msg of messages) {
const from = msg.from;
const text = msg.text?.body || '';
if (!text) continue;
// Clasificar con Haiku (modelo barato + rápido)
const clasif = await callClaude({
model: 'haiku',
systemPrompt: `Clasificá este mensaje en EXACTAMENTE una categoría:
URGENTE | CONSULTA | VOLUNTARIO | CONFIRMACION | DENUNCIA | SPAM
Respondé solo con la palabra de la categoría.`,
userMessage: text,
maxTokens: 10,
modulo: 'clasificacion_wa'});
const categoria = clasif.text.trim().toUpperCase();
// Guardar en BD
await db.query(`
INSERT INTO conversaciones_wa (telefono, mensaje, categoria, estado, created_at)
VALUES ($1, $2, $3, 'pendiente', NOW())
`, [from, text, categoria]);
// Auto-respuesta si aplica
if (categoria === 'CONSULTA' && esIntencionDondeVoto(text)) {
const cedula = extraerCedula(text);
if (cedula) {
const padron = await db.query(
`SELECT p.nombres, p.apellidos, r.nombre AS recinto, j.numero AS jrv
FROM padron p
JOIN recintos r ON r.id = p.recinto_id
JOIN jrv j ON j.id = p.jrv_id
WHERE p.cedula = $1`,
[cedula]
);
if (padron.rows.length) {
const d = padron.rows[0];
await sendWhatsAppText(from,
`Hola ${d.nombres}, tu recinto es:\n${d.recinto}\nJRV ${d.jrv}\n\nLleva tu cédula el día E. ¡Tu voto cuenta!`);
}
}
}
// URGENTE y DENUNCIA siempre van a humanos · no auto-respondemos
}
}
Bot de Telegram · grupo privado candidatos
// backend/src/integrations/telegram.ts
import TelegramBot from 'node-telegram-bot-api';
const TG_TOKEN = process.env.TG_BOT_TOKEN!;
export const tgBot = new TelegramBot(TG_TOKEN); // sin polling, usamos webhook
export async function sendTelegramToGroup(chatId: string, text: string) {
return tgBot.sendMessage(chatId, text, {
parse_mode: 'Markdown',
disable_web_page_preview: true});
}
export async function sendTelegramBriefing(chatId: string, briefingText: string, pdfPath: string) {
// Texto resumen
await tgBot.sendMessage(chatId, '*Briefing diario ADN*\n\nEl PDF completo está adjunto.\n\n' +
briefingText.split('\n').slice(0, 8).join('\n'),
{ parse_mode: 'Markdown' }
);
// PDF completo
await tgBot.sendDocument(chatId, pdfPath, {
caption: `Briefing ${new Date().toLocaleDateString('es-AR')}`});
}
// Bot 1: recordatorio reporte semanal (lunes 7 AM via cron)
export async function enviarRecordatorioReporte() {
const jefesSinReporte = await db.query(`
SELECT u.id, u.nombre_completo, u.telefono, p.nombre AS parroquia
FROM usuarios u
JOIN parroquias p ON p.id = u.parroquia_id
WHERE u.rol = 'parroquia'
AND u.activo = TRUE
AND NOT EXISTS (
SELECT 1 FROM actividades a
WHERE a.usuario_id = u.id
AND a.created_at > NOW() - INTERVAL '7 days'
)
`);
for (const jefe of jefesSinReporte.rows) {
await sendWhatsAppTemplate({
to: jefe.telefono.replace(/\D/g, ''),
template: 'recordatorio_reporte_semanal',
language: 'es',
components: [{
type: 'body',
parameters: [
{ type: 'text', text: jefe.nombre_completo.split(' ')[0] },
{ type: 'text', text: jefe.parroquia }]}]});
}
}
App Flutter · estructura del proyecto
mobile/
├── pubspec.yaml
├── lib/
│ ├── main.dart
│ ├── app.dart
│ ├── core/
│ │ ├── api/ # cliente HTTP (Dio)
│ │ ├── auth/ # JWT storage + refresh
│ │ ├── db/ # WatermelonDB local
│ │ └── theme/
│ ├── features/
│ │ ├── login/
│ │ ├── home_brigadista/
│ │ ├── captura_cedula/ # OCR de cédula
│ │ ├── registro_contacto/
│ │ ├── mis_electores/
│ │ ├── mensajes/
│ │ └── perfil/
│ └── shared/
│ ├── widgets/
│ └── models/
└── android/, ios/
# Comandos básicos:
flutter create mobile_adn --org com.adn.ec --platforms ios,android
flutter pub add dio flutter_secure_storage geolocator google_ml_kit
flutter pub add watermelon_db sqflite shared_preferences
flutter pub add provider intl flutter_dotenv
Captura OCR de cédula · Google ML Kit
// lib/features/captura_cedula/cedula_ocr_service.dart
import 'package:google_ml_kit/google_ml_kit.dart';
import 'dart:io';
class CedulaOcrResult {
final String? cedula; // 10 dígitos detectados
final String? nombres;
final String? apellidos;
final double confidence;
CedulaOcrResult({this.cedula, this.nombres, this.apellidos, this.confidence = 0});
}
class CedulaOcrService {
final _textRecognizer = TextRecognizer(script: TextRecognitionScript.latin);
Future<CedulaOcrResult> procesarImagen(File imagen) async {
final inputImage = InputImage.fromFile(imagen);
final result = await _textRecognizer.processImage(inputImage);
String? cedula;
String? nombres;
String? apellidos;
// Cédula: regex 10 dígitos consecutivos
final cedulaRegex = RegExp(r'\b\d{10}\b');
for (final block in result.blocks) {
final match = cedulaRegex.firstMatch(block.text);
if (match != null) {
cedula = match.group(0);
break;
}
}
// Nombres/apellidos: heurística por mayúsculas
final lineas = result.text.split('\n')
.where((l) => l.trim().toUpperCase() == l.trim() && l.length > 4)
.toList();
if (lineas.length >= 2) {
apellidos = lineas[0].trim();
nombres = lineas[1].trim();
}
return CedulaOcrResult(
cedula: cedula,
nombres: nombres,
apellidos: apellidos,
confidence: cedula != null ? 0.85 : 0.0,
);
}
void dispose() => _textRecognizer.close();
}
// Validar cédula localmente con el mismo algoritmo del backend
bool validarCedulaEcuador(String cedula) {
if (!RegExp(r'^\d{10}$').hasMatch(cedula)) return false;
final provincia = int.parse(cedula.substring(0, 2));
if (provincia < 1 || provincia > 24) return false;
final tercer = int.parse(cedula[2]);
if (tercer > 5) return false;
int suma = 0;
for (int i = 0; i < 9; i++) {
int dig = int.parse(cedula[i]);
int mult = (i % 2 == 0) ? 2 : 1;
int calc = dig * mult;
if (calc >= 10) calc -= 9;
suma += calc;
}
int dv = int.parse(cedula[9]);
int calc = (10 - (suma % 10)) % 10;
return calc == dv;
}
Modo offline · sincronización al reconectar
// lib/core/sync/sync_service.dart
import 'package:connectivity_plus/connectivity_plus.dart';
import '../api/api_client.dart';
import '../db/local_db.dart';
class SyncService {
final ApiClient api;
final LocalDb db;
bool _sincronizando = false;
SyncService(this.api, this.db) {
// Escuchar cambios de conectividad
Connectivity().onConnectivityChanged.listen((result) {
if (result != ConnectivityResult.none) {
sincronizar();
}
});
}
Future<void> sincronizar() async {
if (_sincronizando) return;
_sincronizando = true;
try {
// 1. Subir actividades pendientes
final pendientes = await db.actividadesPendientes();
for (final act in pendientes) {
try {
await api.post('/api/actividades', data: act.toJson());
await db.marcarSincronizada(act.id);
} catch (e) {
// Mantener pendiente, reintentar próxima vez
print('Error sincronizando ${act.id}: $e');
}
}
// 2. Bajar updates del servidor (mensajes, tareas nuevas)
final lastSync = await db.lastSyncTimestamp();
final updates = await api.get('/api/sync/updates',
queryParameters: { 'since': lastSync.toIso8601String() });
for (final mensaje in updates.data['mensajes'] ?? []) {
await db.upsertMensaje(mensaje);
}
for (final tarea in updates.data['tareas'] ?? []) {
await db.upsertTarea(tarea);
}
await db.setLastSyncTimestamp(DateTime.now());
} finally {
_sincronizando = false;
}
}
}
GitHub Actions · CI/CD del backend
Archivo: .github/workflows/deploy-backend.yml
name: Deploy Backend
on:
push:
branches: [main]
paths: ['backend/**']
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: cd backend && npm ci
- run: cd backend && npm run lint
- run: cd backend && npm test
- run: cd backend && npm run build
deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Build & deploy via SSM
run: |
INSTANCE_ID=$(aws ec2 describe-instances \
--filters "Name=tag:Role,Values=api" "Name=instance-state-name,Values=running" \
--query 'Reservations[0].Instances[0].InstanceId' --output text)
aws ssm send-command \
--instance-ids $INSTANCE_ID \
--document-name "AWS-RunShellScript" \
--parameters 'commands=[
"cd /opt/warroom",
"git pull origin main",
"npm ci --production",
"pm2 reload ecosystem.config.js",
"echo Deploy completo"
]'
- name: Notify Telegram
if: success()
run: |
curl -s "https://api.telegram.org/bot${{ secrets.TG_DEPLOY_BOT }}/sendMessage" \
-d chat_id="${{ secrets.TG_DEPLOY_CHAT }}" \
-d text="Deploy backend OK · $(git log -1 --pretty=%B)"
PM2 ecosystem · monitoreo del proceso
// ecosystem.config.js
module.exports = {
apps: [
{
name: 'warroom-api',
script: './dist/server.js',
instances: 'max', // todos los cores disponibles
exec_mode: 'cluster',
max_memory_restart: '1G',
env: {
NODE_ENV: 'production',
PORT: 3000},
error_file: '/var/log/warroom/error.log',
out_file: '/var/log/warroom/out.log',
time: true,
autorestart: true,
watch: false,
kill_timeout: 10000, // 10s para graceful shutdown
},
{
name: 'warroom-worker',
script: './dist/worker.js', // cron jobs + queue processor
instances: 1,
env: { NODE_ENV: 'production' },
autorestart: true}]};
Load test · K6 · 5,000 usuarios concurrentes
// loadtest/simulacro_dia_e.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate } from 'k6/metrics';
export const errorRate = new Rate('errors');
export const options = {
stages: [
{ duration: '5m', target: 1000 }, // ramp up
{ duration: '10m', target: 5000 }, // sostenido
{ duration: '5m', target: 10000 }, // pico Día E
{ duration: '10m', target: 5000 },
{ duration: '5m', target: 0 }, // ramp down
],
thresholds: {
http_req_duration: ['p(95)<1500'], // p95 < 1.5s
errors: ['rate<0.01'], // < 1% errores
}};
const BASE_URL = __ENV.API_URL || 'https://api.warroom-adn.ec';
const TOKEN = __ENV.AUTH_TOKEN;
export default function () {
// Simular flujo típico de brigadista en campo
const headers = { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' };
// 1. Login check
const me = http.get(`${BASE_URL}/api/users/me`, { headers });
check(me, { 'login válido': r => r.status === 200 }) || errorRate.add(1);
// 2. Ver tareas del día
const tareas = http.get(`${BASE_URL}/api/tareas/hoy`, { headers });
check(tareas, { 'tareas OK': r => r.status === 200 }) || errorRate.add(1);
// 3. Buscar cédula en padrón
const cedula = generarCedulaPrueba();
const padron = http.get(`${BASE_URL}/api/padron/${cedula}`, { headers });
check(padron, { 'padron resp': r => r.status === 200 || r.status === 404 }) || errorRate.add(1);
// 4. Registrar contacto
const registro = http.post(`${BASE_URL}/api/actividades`, JSON.stringify({
tipo: 'contacto_puerta_puerta',
cedula_target: cedula,
nivel_compr: 1 + Math.floor(Math.random() * 4)}), { headers });
check(registro, { 'registro OK': r => r.status === 201 }) || errorRate.add(1);
sleep(2 + Math.random() * 3); // simular tiempo entre acciones
}
function generarCedulaPrueba() {
return '17' + String(Math.floor(10000000 + Math.random() * 90000000));
}
// Ejecutar:
// k6 run --vus 1000 --duration 30m simulacro_dia_e.js
Backup automático cross-region
#!/bin/bash
# scripts/backup-cross-region.sh
# Ejecutar diario 02:00 AM via cron en EC2
set -euo pipefail
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_NAME="warroom-pg-${DATE}.sql.gz"
S3_BUCKET="adn-warroom-backups"
# Dump completo
pg_dump $DATABASE_URL \
--format=custom \
--no-owner \
--no-acl \
--compress=9 \
--verbose \
--file=/tmp/$BACKUP_NAME
# Subir a S3 us-east-1 (primario)
aws s3 cp /tmp/$BACKUP_NAME s3://$S3_BUCKET/daily/$BACKUP_NAME \
--storage-class STANDARD_IA
# Replicar a Azure Blob (cross-cloud)
az storage blob upload \
--account-name warroomadnbackups \
--container-name daily \
--name $BACKUP_NAME \
--file /tmp/$BACKUP_NAME
# Cleanup local
rm /tmp/$BACKUP_NAME
# Retener últimos 30 días en S3, mover el resto a Glacier
aws s3 ls s3://$S3_BUCKET/daily/ \
| awk '{print $4}' \
| head -n -30 \
| xargs -I {} aws s3 mv s3://$S3_BUCKET/daily/{} s3://$S3_BUCKET/glacier/{} \
--storage-class GLACIER
echo "Backup completado: $BACKUP_NAME"
# Notificar a Telegram
curl -s "https://api.telegram.org/bot${TG_OPS_BOT}/sendMessage" \
-d chat_id="${TG_OPS_CHAT}" \
-d text="Backup diario OK · $BACKUP_NAME"