Files
api/server.js
2026-03-07 21:39:49 +00:00

3133 lines
152 KiB
JavaScript

import express from "express";
import cors from "cors";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import pg from "pg";
import crypto from "crypto";
import OpenAI from "openai";
const { Pool } = pg;
const app = express();
// Configuración de CORS Profesional
const corsOptions = {
origin: [
'https://web.integrarepara.es',
'https://portal.integrarepara.es',
'https://app.integrarepara.es',
'http://localhost:3000',
'http://127.0.0.1:3000'
],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
optionsSuccessStatus: 200
};
app.use(cors(corsOptions));
// Habilitar pre-flight para todas las rutas
app.options('*', cors(corsOptions));
// Límites de subida para logotipos en Base64
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ limit: '10mb', extended: true }));
// VARIABLES DE ENTORNO
// --- 1. VARIABLES DE ENTORNO ---
const {
DATABASE_URL,
JWT_SECRET,
EVOLUTION_BASE_URL,
EVOLUTION_API_KEY,
EVOLUTION_INSTANCE,
OPENAI_API_KEY, // 🔔 LEER LLAVE
OPENAI_MODEL // 🔔 LEER MODELO
} = process.env;
// --- 2. INICIALIZACIÓN GLOBAL DEL MOTOR IA (ESTO ES LO QUE TE FALTABA) ---
const openai = new OpenAI({
apiKey: OPENAI_API_KEY,
});
// --- 3. DIAGNÓSTICO DE INICIO ---
console.log("------------------------------------------------");
console.log("🚀 VERSIÓN COMPLETA - INTEGRA REPARA SAAS");
console.log("------------------------------------------------");
if (!OPENAI_API_KEY) {
console.error("⚠️ AVISO: Falta OPENAI_API_KEY en variables de entorno.");
} else {
console.log("✅ OpenAI API Key detectada. IA lista para operar.");
}
if (!EVOLUTION_BASE_URL) console.error("⚠️ AVISO: Falta EVOLUTION_BASE_URL");
else console.log("✅ Evolution URL:", EVOLUTION_BASE_URL);
if (!EVOLUTION_INSTANCE) console.error("⚠️ AVISO: Falta EVOLUTION_INSTANCE");
else console.log("✅ Instancia Notificaciones:", EVOLUTION_INSTANCE);
console.log("------------------------------------------------");
if (!DATABASE_URL || !JWT_SECRET) process.exit(1);
const pool = new Pool({ connectionString: DATABASE_URL, ssl: false });
// ==========================================
// 💰 CONFIGURACIÓN DE PLANES (SAAS)
// ==========================================
const PLAN_LIMITS = {
'free': { name: 'Básico Gratuito', whatsapp_enabled: false, templates_enabled: false, automation_enabled: false },
'standard': { name: 'Estándar', whatsapp_enabled: true, templates_enabled: true, automation_enabled: false },
'pro': { name: 'Profesional', whatsapp_enabled: true, templates_enabled: true, automation_enabled: true }
};
// ==========================================
// 🧠 AUTO-ACTUALIZACIÓN DB
// ==========================================
async function autoUpdateDB() {
const client = await pool.connect();
try {
console.log("🔄 Verificando estructura DB...");
await client.query(`
-- USUARIOS
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
full_name TEXT NOT NULL,
phone TEXT NOT NULL,
email TEXT NOT NULL,
dni TEXT,
address TEXT,
password_hash TEXT NOT NULL,
is_verified BOOLEAN DEFAULT FALSE,
owner_id INT,
role TEXT DEFAULT 'operario',
company_slug TEXT UNIQUE,
plan_tier TEXT DEFAULT 'free',
subscription_status TEXT DEFAULT 'active',
paid_providers_count INT DEFAULT 0,
zones JSONB DEFAULT '[]',
status TEXT DEFAULT 'active',
created_at TIMESTAMP DEFAULT NOW()
);
-- COLA DEL ROBOT
CREATE TABLE IF NOT EXISTS robot_queue (
id SERIAL PRIMARY KEY,
owner_id INT REFERENCES users(id) ON DELETE CASCADE,
provider TEXT DEFAULT 'homeserve',
service_number TEXT NOT NULL,
new_status TEXT NOT NULL,
appointment_date TEXT,
observation TEXT,
inform_client BOOLEAN DEFAULT FALSE,
status TEXT DEFAULT 'PENDING', -- PENDING, RUNNING, DONE, FAILED
error_msg TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS login_codes (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE CASCADE,
phone TEXT NOT NULL,
code_hash TEXT NOT NULL,
purpose TEXT DEFAULT 'register_verify',
consumed_at TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
-- CONFIGURACIÓN NEGOCIO
CREATE TABLE IF NOT EXISTS guilds (
id SERIAL PRIMARY KEY,
owner_id INT REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS user_guilds (
user_id INT REFERENCES users(id) ON DELETE CASCADE,
guild_id INT REFERENCES guilds(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, guild_id)
);
CREATE TABLE IF NOT EXISTS companies (
id SERIAL PRIMARY KEY,
owner_id INT REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
cif TEXT,
email TEXT,
phone TEXT,
address TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
-- CLIENTES (CRM)
CREATE TABLE IF NOT EXISTS clients (
id SERIAL PRIMARY KEY,
owner_id INT REFERENCES users(id) ON DELETE CASCADE,
full_name TEXT NOT NULL,
phone TEXT NOT NULL,
email TEXT,
addresses JSONB DEFAULT '[]',
notes TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
-- ESTADOS Y PLANTILLAS
CREATE TABLE IF NOT EXISTS service_statuses (
id SERIAL PRIMARY KEY,
owner_id INT REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT DEFAULT 'gray',
is_default BOOLEAN DEFAULT FALSE,
is_final BOOLEAN DEFAULT FALSE,
is_system BOOLEAN DEFAULT FALSE, -- AÑADIDO: Identificador de estados imborrables
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS message_templates (
id SERIAL PRIMARY KEY,
owner_id INT REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
content TEXT,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(owner_id, type)
);
-- ZONAS
CREATE TABLE IF NOT EXISTS zones (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
owner_id INT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS user_zones (
user_id INT REFERENCES users(id) ON DELETE CASCADE,
zone_id INT REFERENCES zones(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, zone_id)
);
-- 🤖 ROBOTS / PROVEEDORES
CREATE TABLE IF NOT EXISTS provider_credentials (
id SERIAL PRIMARY KEY,
owner_id INT REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
last_sync TIMESTAMP,
status TEXT DEFAULT 'active',
UNIQUE(owner_id, provider)
);
CREATE TABLE IF NOT EXISTS scraped_services (
id SERIAL PRIMARY KEY,
owner_id INT REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
service_ref TEXT NOT NULL,
raw_data JSONB,
status TEXT DEFAULT 'pending',
automation_status TEXT DEFAULT 'manual',
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(owner_id, provider, service_ref)
);
-- 🗺️ TABLA DE MAPEO DE VARIABLES
CREATE TABLE IF NOT EXISTS variable_mappings (
id SERIAL PRIMARY KEY,
owner_id INT REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
original_key TEXT NOT NULL,
target_key TEXT,
is_ignored BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(owner_id, provider, original_key)
);
-- SERVICIOS (PRINCIPAL)
CREATE TABLE IF NOT EXISTS services (
id SERIAL PRIMARY KEY,
owner_id INT REFERENCES users(id) ON DELETE CASCADE,
client_id INT REFERENCES clients(id) ON DELETE SET NULL,
status_id INT REFERENCES service_statuses(id) ON DELETE SET NULL,
guild_id INT REFERENCES guilds(id) ON DELETE SET NULL,
assigned_to INT REFERENCES users(id) ON DELETE SET NULL,
title TEXT,
description TEXT,
contact_phone TEXT,
contact_name TEXT,
address TEXT,
email TEXT,
scheduled_date DATE DEFAULT CURRENT_DATE,
scheduled_time TIME DEFAULT CURRENT_TIME,
duration_minutes INT DEFAULT 30,
is_urgent BOOLEAN DEFAULT FALSE,
is_company BOOLEAN DEFAULT FALSE,
company_id INT REFERENCES companies(id) ON DELETE SET NULL,
company_ref TEXT,
internal_notes TEXT,
client_notes TEXT,
import_source TEXT,
provider_data JSONB DEFAULT '{}',
closed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS service_logs (
id SERIAL PRIMARY KEY,
service_id INT REFERENCES services(id) ON DELETE CASCADE,
user_id INT REFERENCES users(id) ON DELETE SET NULL,
old_status_id INT REFERENCES service_statuses(id),
new_status_id INT REFERENCES service_statuses(id),
comment TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
-- TABLA PARA ASIGNACIÓN AUTOMÁTICA
CREATE TABLE IF NOT EXISTS assignment_pings (
id SERIAL PRIMARY KEY,
scraped_id INT NOT NULL,
user_id INT REFERENCES users(id) ON DELETE CASCADE,
token TEXT UNIQUE NOT NULL,
status TEXT DEFAULT 'pending',
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
-- 💬 CHAT Y NOTAS INTERNAS
CREATE TABLE IF NOT EXISTS service_communications (
id SERIAL PRIMARY KEY,
scraped_id INT REFERENCES scraped_services(id) ON DELETE CASCADE,
owner_id INT REFERENCES users(id) ON DELETE CASCADE,
sender_id INT REFERENCES users(id) ON DELETE SET NULL,
sender_name TEXT NOT NULL,
sender_role TEXT,
message TEXT NOT NULL,
is_internal BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW()
);
`);
// PARCHE DE ACTUALIZACIÓN
await client.query(`
DO $$ BEGIN
-- 🟢 AÑADIDO: Fecha de última lectura del chat por el operario
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='scraped_services' AND column_name='last_chat_read_worker') THEN
ALTER TABLE scraped_services ADD COLUMN last_chat_read_worker TIMESTAMP DEFAULT '2000-01-01';
END IF;
-- AÑADIDO: Token mágico para el Portal del Cliente
-- AÑADIDO: Token mágico para el Portal del Cliente
-- AÑADIDO: Token mágico para el Portal del Cliente
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='clients' AND column_name='portal_token') THEN
ALTER TABLE clients ADD COLUMN portal_token TEXT UNIQUE;
UPDATE clients SET portal_token = substr(md5(random()::text || id::text), 1, 12) WHERE portal_token IS NULL;
ALTER TABLE clients ALTER COLUMN portal_token SET DEFAULT substr(md5(random()::text || clock_timestamp()::text), 1, 12);
END IF;
-- AÑADIDO: Motor de Ranking
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='ranking_score') THEN
ALTER TABLE users ADD COLUMN ranking_score NUMERIC DEFAULT 50.0;
ALTER TABLE users ADD COLUMN ranking_data JSONB DEFAULT '{}'::jsonb;
END IF;
-- NUEVO: Columna para colores personalizados de la App
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='app_settings') THEN
ALTER TABLE users ADD COLUMN app_settings JSONB DEFAULT '{"primary": "#1e3a8a", "secondary": "#2563eb", "bg": "#f8fafc"}';
END IF;
-- AÑADIDO: Permiso para coger servicios libres de la bolsa
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='can_claim_services') THEN
ALTER TABLE users ADD COLUMN can_claim_services BOOLEAN DEFAULT TRUE;
END IF;
-- AÑADIDO: Columna para guardar la configuración de WhatsApp
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='wa_settings') THEN
ALTER TABLE users ADD COLUMN wa_settings JSONB DEFAULT '{}';
END IF;
-- AÑADIDO: Configuración del Portal del Cliente
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='company_logo') THEN
ALTER TABLE users ADD COLUMN company_logo TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='portal_settings') THEN
ALTER TABLE users ADD COLUMN portal_settings JSONB DEFAULT '{"m_start":"09:00", "m_end":"14:00", "a_start":"16:00", "a_end":"19:00"}';
END IF;
-- AÑADIDO: Columna física de operario para el panel operativo
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='scraped_services' AND column_name='assigned_to') THEN
ALTER TABLE scraped_services ADD COLUMN assigned_to INT REFERENCES users(id);
END IF;
-- ASEGURAR COLUMNA URGENTE EN SCRAPED
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='scraped_services' AND column_name='is_urgent') THEN
ALTER TABLE scraped_services ADD COLUMN is_urgent BOOLEAN DEFAULT FALSE;
END IF;
-- AÑADIDO: Columna de palabras clave IA para los gremios
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='guilds' AND column_name='ia_keywords') THEN
ALTER TABLE guilds ADD COLUMN ia_keywords JSONB DEFAULT '[]';
END IF;
-- AÑADIDO: Columna para marcar estados imborrables del sistema
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='service_statuses' AND column_name='is_system') THEN
ALTER TABLE service_statuses ADD COLUMN is_system BOOLEAN DEFAULT FALSE;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='services' AND column_name='client_id') THEN ALTER TABLE services ADD COLUMN client_id INT REFERENCES clients(id) ON DELETE SET NULL; END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='services' AND column_name='status_id') THEN ALTER TABLE services ADD COLUMN status_id INT REFERENCES service_statuses(id) ON DELETE SET NULL; END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='services' AND column_name='contact_phone') THEN ALTER TABLE services ADD COLUMN contact_phone TEXT; END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='services' AND column_name='contact_name') THEN ALTER TABLE services ADD COLUMN contact_name TEXT; END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='plan_tier') THEN ALTER TABLE users ADD COLUMN plan_tier TEXT DEFAULT 'free'; END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='company_slug') THEN ALTER TABLE users ADD COLUMN company_slug TEXT UNIQUE; END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='zones') THEN ALTER TABLE users ADD COLUMN zones JSONB DEFAULT '[]'; END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='status') THEN ALTER TABLE users ADD COLUMN status TEXT DEFAULT 'active'; END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='services' AND column_name='provider_data') THEN ALTER TABLE services ADD COLUMN provider_data JSONB DEFAULT '{}'; END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='services' AND column_name='import_source') THEN ALTER TABLE services ADD COLUMN import_source TEXT; END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='scraped_services' AND column_name='automation_status') THEN ALTER TABLE scraped_services ADD COLUMN automation_status TEXT DEFAULT 'manual'; END IF;
-- AÑADIDO: Token mágico para el Portal del Cliente
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='clients' AND column_name='portal_token') THEN
ALTER TABLE clients ADD COLUMN portal_token TEXT UNIQUE;
UPDATE clients SET portal_token = substr(md5(random()::text || id::text), 1, 12) WHERE portal_token IS NULL;
ALTER TABLE clients ALTER COLUMN portal_token SET DEFAULT substr(md5(random()::text || clock_timestamp()::text), 1, 12);
END IF;
BEGIN ALTER TABLE users DROP CONSTRAINT IF EXISTS users_phone_key; EXCEPTION WHEN OTHERS THEN NULL; END;
BEGIN ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key; EXCEPTION WHEN OTHERS THEN NULL; END;
END $$;
`);
console.log("✅ DB Sincronizada.");
} catch (e) { console.error("❌ Error DB:", e); } finally { client.release(); }
}
// HELPERS
function normalizePhone(phone) { let p = String(phone || "").trim().replace(/\s+/g, "").replace(/-/g, ""); if (!p) return ""; if (!p.startsWith("+") && /^[6789]\d{8}/.test(p)) return "+34" + p; return p; }
function signToken(user) { const accountId = user.owner_id || user.id; return jwt.sign({ sub: user.id, email: user.email, phone: user.phone, role: user.role || 'operario', accountId }, JWT_SECRET, { expiresIn: "30d" }); }
function authMiddleware(req, res, next) { const h = req.headers.authorization || ""; const token = h.startsWith("Bearer ") ? h.slice(7) : ""; if (!token) return res.status(401).json({ ok: false, error: "No token" }); try { req.user = jwt.verify(token, JWT_SECRET); next(); } catch { return res.status(401).json({ ok: false, error: "Token inválido" }); } }
function genCode6() { return String(Math.floor(100000 + Math.random() * 900000)); }
// ==========================================
// 🕵️ ROBOT NOTARIO (TRAZABILIDAD TOTAL)
// ==========================================
async function registrarMovimiento(serviceId, userId, action, details) {
try {
let userName = "Sistema Robot";
if (userId) {
const u = await pool.query("SELECT full_name FROM users WHERE id=$1", [userId]);
if (u.rowCount > 0) userName = u.rows[0].full_name;
}
await pool.query(
"INSERT INTO scraped_service_logs (scraped_id, user_name, action, details) VALUES ($1, $2, $3, $4)",
[serviceId, userName, action, details || ""]
);
} catch (e) { console.error("Error Robot Notario:", e); }
}
async function procesarConIA(ownerId, mensajeCliente, datosExpediente) {
try {
const userQ = await pool.query("SELECT wa_settings, full_name FROM users WHERE id=$1", [ownerId]);
const settings = userQ.rows[0]?.wa_settings || {};
const empresaNombre = userQ.rows[0]?.full_name || "nuestra empresa";
if (!settings.wa_ai_enabled) return null;
const promptSistema = `
Eres el asistente de "${empresaNombre}".
DATOS EXPEDIENTE #${datosExpediente.ref}:
- Estado: ${datosExpediente.estado}
- Cita actual: ${datosExpediente.cita || 'Ninguna'}
Eres el asistente de "${empresaNombre}".
REGLA DE CITAS (CRÍTICA):
- Tú NO confirmas citas finales, solo recoges la preferencia del cliente.
- Si el cliente propone un día/hora, responde: "He recibido su preferencia para el [FECHA]. Voy a consultarlo con el técnico asignado y le confirmaremos por aquí en cuanto valide su agenda."
- Al final de esa respuesta, añade el código: [PROPUESTA:DD-MM-YYYY HH:mm]
- Si el cliente no propone nada, dile que nos diga qué mañanas o tardes le vienen mejor para que el técnico se organice.
REGLAS DE ORO:
1. Si el cliente pregunta "¿Cuándo venís?", responde con la "Fecha Cita" si existe. Si no existe, di que la oficina le llamará en breve para agendar.
2. NUNCA inventes precios ni presupuestos. Si preguntan coste, di que el técnico debe valorar el daño in situ.
3. Si el cliente está enfadado o usa lenguaje ofensivo, di: "Siento las molestias. He dado aviso urgente a mis compañeros de la oficina para que le llamen ahora mismo".
4. Responde SIEMPRE en máximo 2 frases cortas. Usa un tono cercano pero profesional.
5. Si preguntan por el nombre del técnico y está asignado, dáselo. Si no, di que se le notificará por SMS/WhatsApp en cuanto se le asigne uno.
6. No uses negritas (**) en el texto, usa asteriscos (*) solo si es muy necesario.
`;
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: promptSistema },
{ role: "user", content: mensajeCliente }
],
temperature: 0.5, // Menos locura, más precisión
});
return completion.choices[0].message.content;
} catch (e) {
console.error("❌ Error OpenAI:", e.message);
return null;
}
}
// 🛡️ MIDDLEWARE DE PLANES
async function requirePlan(req, res, next, feature) {
try {
const q = await pool.query("SELECT plan_tier FROM users WHERE id=$1", [req.user.accountId]);
const userPlan = 'pro'; // Forzamos a 'pro' para tus pruebas actuales sin límites
const limits = PLAN_LIMITS[userPlan];
if (!limits || !limits[feature]) {
return res.status(403).json({ ok: false, error: `Función exclusiva del plan Profesional.` });
}
next();
} catch (e) {
console.error("Error comprobando plan:", e);
res.status(500).json({ ok: false, error: "Error interno verificando plan" });
}
}
// ==========================================
// 🔐 RUTAS DE AUTENTICACIÓN (LOGIN) - RESTAURADAS
// ==========================================
app.post("/auth/login", async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) return res.status(400).json({ ok: false, error: "Faltan credenciales" });
// Buscamos al usuario por email o por teléfono
const q = await pool.query("SELECT * FROM users WHERE email = $1 OR phone = $1", [email]);
if (q.rowCount === 0) return res.status(401).json({ ok: false, error: "Usuario no encontrado" });
const user = q.rows[0];
if (user.status !== 'active') return res.status(401).json({ ok: false, error: "Cuenta desactivada. Contacta con el administrador." });
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) return res.status(401).json({ ok: false, error: "Contraseña incorrecta" });
const token = signToken(user);
res.json({ ok: true, token, role: user.role, name: user.full_name });
} catch (e) {
console.error("Login error:", e);
res.status(500).json({ ok: false, error: "Error de servidor al iniciar sesión" });
}
});
app.get("/auth/me", authMiddleware, async (req, res) => {
try {
const q = await pool.query("SELECT id, full_name, email, phone, role, owner_id, status, company_slug, plan_tier FROM users WHERE id = $1", [req.user.sub]);
if (q.rowCount === 0) return res.status(404).json({ ok: false });
res.json({ ok: true, user: q.rows[0] });
} catch (e) {
res.status(500).json({ ok: false });
}
});
// ==========================================
// 🔗 PORTAL PÚBLICO DEL CLIENTE
// ==========================================
app.get("/public/portal/:token", async (req, res) => {
try {
const { token } = req.params;
const serviceId = req.query.service;
// 1. Buscamos al cliente por su token
const qClient = await pool.query("SELECT * FROM clients WHERE portal_token = $1 LIMIT 1", [token]);
if (qClient.rowCount === 0) return res.status(404).json({ ok: false, error: "Enlace no válido" });
const client = qClient.rows[0];
const ownerId = client.owner_id;
// 2. Buscamos los datos de la empresa
const qConfig = await pool.query("SELECT full_name, company_logo FROM users WHERE id = $1", [ownerId]);
const company = {
name: qConfig.rows[0]?.full_name || "IntegraRepara",
logo: qConfig.rows[0]?.company_logo || null
};
// 3. CONSULTA SEGURA (Con o sin ID)
let qServices;
if (serviceId && !isNaN(parseInt(serviceId))) {
qServices = await pool.query(`
SELECT s.id, s.service_ref, s.is_urgent, s.raw_data, s.created_at,
st.name as real_status_name, st.is_final as is_status_final,
u.full_name as worker_name, u.phone as worker_phone
FROM scraped_services s
LEFT JOIN users u ON u.id = s.assigned_to
LEFT JOIN service_statuses st ON st.id::text = (s.raw_data->>'status_operativo')::text
WHERE s.id = $1 AND s.owner_id = $2 AND s.provider != 'SYSTEM_BLOCK'
`, [parseInt(serviceId), ownerId]);
} else {
let phoneMatch = String(client.phone || "").replace(/[^0-9]/g, "");
if (phoneMatch.length > 9) phoneMatch = phoneMatch.slice(-9);
if (phoneMatch.length < 6) phoneMatch = "TELEFONO_FALSO_123";
qServices = await pool.query(`
SELECT s.id, s.service_ref, s.is_urgent, s.raw_data, s.created_at,
st.name as real_status_name, st.is_final as is_status_final,
u.full_name as worker_name, u.phone as worker_phone
FROM scraped_services s
LEFT JOIN users u ON u.id = s.assigned_to
LEFT JOIN service_statuses st ON st.id::text = (s.raw_data->>'status_operativo')::text
WHERE s.owner_id = $1 AND s.provider != 'SYSTEM_BLOCK'
AND s.raw_data::text ILIKE $2
ORDER BY s.created_at DESC
`, [ownerId, `%${phoneMatch}%`]);
}
const formattedServices = qServices.rows.map(s => {
return {
id: s.id,
title: s.is_urgent ? `🚨 URGENTE: #${s.service_ref}` : `Expediente #${s.service_ref}`,
description: s.raw_data?.["Descripción"] || s.raw_data?.["DESCRIPCION"] || "Aviso de reparación",
status_name: s.real_status_name || "En gestión",
is_final: s.is_status_final || false,
scheduled_date: s.raw_data?.scheduled_date || "",
scheduled_time: s.raw_data?.scheduled_time || "",
assigned_worker: s.worker_name || null,
worker_phone: s.worker_phone || null,
raw_data: s.raw_data
};
});
res.json({ ok: true, client: { name: client.full_name }, company, services: formattedServices });
} catch (e) {
console.error("🔥 ERROR EN PORTAL:", e.message);
res.status(500).json({ ok: false, error: e.message });
}
});
// 2. Obtener huecos disponibles inteligentes (CON HORARIOS DINÁMICOS Y TRAMOS DE 1 HORA)
app.get("/public/portal/:token/slots", async (req, res) => {
try {
const { token } = req.params;
const { serviceId } = req.query;
const clientQ = await pool.query("SELECT id, owner_id FROM clients WHERE portal_token = $1", [token]);
if (clientQ.rowCount === 0) return res.status(404).json({ ok: false, error: "Token inválido" });
const ownerId = clientQ.rows[0].owner_id;
// EXTRAEMOS LA CONFIGURACIÓN DE HORARIOS DEL PORTAL
const userQ = await pool.query("SELECT portal_settings FROM users WHERE id = $1", [ownerId]);
const pSet = userQ.rows[0]?.portal_settings || { m_start:"09:00", m_end:"14:00", a_start:"16:00", a_end:"19:00" };
// Función para generar huecos cada 60 minutos (Ventanas de 1 hora)
function genSlots(start, end) {
if(!start || !end) return [];
let s = [];
let [sh, sm] = start.split(':').map(Number);
let [eh, em] = end.split(':').map(Number);
let cur = sh * 60 + sm;
let limit = eh * 60 + em;
// Queremos ventanas de 1 hora completas.
// Si el límite es 15:00, el último hueco que puede empezar es a las 14:00.
while(cur + 60 <= limit) {
s.push(`${String(Math.floor(cur/60)).padStart(2,'0')}:${String(cur%60).padStart(2,'0')}`);
cur += 60; // Avanzamos de hora en hora
}
return s;
}
// Creamos la plantilla de horas libres del día
const morningBase = genSlots(pSet.m_start, pSet.m_end);
const afternoonBase = genSlots(pSet.a_start, pSet.a_end);
const serviceQ = await pool.query("SELECT * FROM scraped_services WHERE id=$1", [serviceId]);
if (serviceQ.rowCount === 0) return res.status(404).json({ ok: false, error: "Servicio no encontrado" });
const service = serviceQ.rows[0];
const assignedTo = service.assigned_to;
if (!assignedTo) return res.status(400).json({ ok: false, error: "No hay operario asignado" });
const raw = service.raw_data || {};
const targetZone = (raw["Población"] || raw["POBLACION-PROVINCIA"] || raw["Código Postal"] || "").toLowerCase().trim();
const targetGuildId = raw["guild_id"];
const agendaQ = await pool.query(`
SELECT raw_data->>'scheduled_date' as date,
raw_data->>'scheduled_time' as time,
raw_data->>'duration_minutes' as duration,
raw_data->>'Población' as poblacion,
raw_data->>'Código Postal' as cp,
provider,
raw_data->>'blocked_guild_id' as blocked_guild_id
FROM scraped_services
WHERE assigned_to = $1
AND raw_data->>'scheduled_date' IS NOT NULL
AND raw_data->>'scheduled_date' >= CURRENT_DATE::text
`, [assignedTo]);
const agendaMap = {};
agendaQ.rows.forEach(row => {
if (row.provider === 'SYSTEM_BLOCK' && row.blocked_guild_id && String(row.blocked_guild_id) !== String(targetGuildId)) {
return;
}
if (!agendaMap[row.date]) agendaMap[row.date] = { times: [], zone: (row.poblacion || row.cp || "").toLowerCase().trim() };
// Bloqueamos la agenda evaluando la duración estimada real del aviso/bloqueo
const dur = parseInt(row.duration || 60);
if (row.time) {
let [th, tm] = row.time.split(':').map(Number);
let startMin = th * 60 + tm;
let endMin = startMin + dur;
// Bloquea cualquier franja de 1 HORA que se solape con este aviso
// Como ahora generamos horas en punto (o y media, según config), chequeamos si el tramo de 60 mins pisa al servicio
morningBase.forEach(slot => {
let [sh, sm] = slot.split(':').map(Number);
let slotStart = sh * 60 + sm;
let slotEnd = slotStart + 60;
if(slotStart < endMin && slotEnd > startMin) {
agendaMap[row.date].times.push(slot);
}
});
afternoonBase.forEach(slot => {
let [sh, sm] = slot.split(':').map(Number);
let slotStart = sh * 60 + sm;
let slotEnd = slotStart + 60;
if(slotStart < endMin && slotEnd > startMin) {
agendaMap[row.date].times.push(slot);
}
});
}
});
const availableDays = [];
let d = new Date();
d.setDate(d.getDate() + 1);
let daysAdded = 0;
while(daysAdded < 10) {
if (d.getDay() !== 0) { // Omitir domingos
const dateStr = d.toISOString().split('T')[0];
const dayData = agendaMap[dateStr];
let isDayAllowed = true;
if (dayData && dayData.zone && targetZone) {
if (!dayData.zone.includes(targetZone) && !targetZone.includes(dayData.zone)) {
isDayAllowed = false;
}
}
if (isDayAllowed) {
const takenTimes = dayData ? dayData.times : [];
// Filtramos nuestra plantilla contra los huecos ocupados
const availMorning = morningBase.filter(t => !takenTimes.includes(t));
const availAfternoon = afternoonBase.filter(t => !takenTimes.includes(t));
if (availMorning.length > 0 || availAfternoon.length > 0) {
availableDays.push({
date: dateStr,
displayDate: d.toLocaleDateString('es-ES', { weekday: 'long', day: 'numeric', month: 'long' }),
morning: availMorning,
afternoon: availAfternoon
});
daysAdded++;
}
}
}
d.setDate(d.getDate() + 1);
}
res.json({ ok: true, days: availableDays });
} catch (e) { console.error("Error Slots:", e); res.status(500).json({ ok: false }); }
});
// --- RUTA PARA GUARDAR LA CITA SOLICITADA POR EL CLIENTE ---
app.post("/public/portal/:token/book", async (req, res) => {
try {
const { token } = req.params;
const { serviceId, date, time } = req.body;
if (!serviceId || !date || !time) return res.status(400).json({ ok: false, error: "Faltan datos" });
// Verificamos quién es el dueño del portal usando el token
const clientQ = await pool.query("SELECT id, owner_id FROM clients WHERE portal_token = $1", [token]);
if (clientQ.rowCount === 0) return res.status(404).json({ ok: false, error: "Token inválido" });
const ownerId = clientQ.rows[0].owner_id;
// 🚨 CAMBIO AQUÍ: Recuperamos los datos crudos Y TAMBIÉN a quién está asignado
const serviceQ = await pool.query("SELECT raw_data, assigned_to, service_ref FROM scraped_services WHERE id=$1 AND owner_id=$2", [serviceId, ownerId]);
if (serviceQ.rowCount === 0) return res.status(404).json({ ok: false, error: "Servicio no encontrado" });
const srv = serviceQ.rows[0];
const raw = srv.raw_data || {};
// Grabamos la solicitud en el jsonb para que el admin la vea en agenda.html
raw.requested_date = date;
raw.requested_time = time;
raw.appointment_status = 'pending';
await pool.query("UPDATE scraped_services SET raw_data = $1 WHERE id = $2", [JSON.stringify(raw), serviceId]);
// =========================================================
// 🚨 MAGIA: AVISAR AL OPERARIO POR WHATSAPP AL INSTANTE
// =========================================================
if (srv.assigned_to) {
const workerQ = await pool.query("SELECT full_name, phone FROM users WHERE id=$1", [srv.assigned_to]);
if (workerQ.rowCount > 0) {
const w = workerQ.rows[0];
const ref = srv.service_ref || raw["Referencia"] || serviceId;
const clientName = raw["Nombre Cliente"] || raw["CLIENTE"] || "El cliente";
// Formateamos la fecha para que se lea bonita en WhatsApp
const [y, m, d] = date.split('-');
const dateFormatted = `${d}/${m}/${y}`;
const msg = `🔔 *¡NUEVA CITA SOLICITADA!*\n\nHola ${w.full_name}, ${clientName} acaba de elegir un hueco para el expediente *#${ref}*.\n\n📅 *Fecha:* ${dateFormatted}\n⏰ *Hora aprox:* ${time}\n\nEntra en tu App (Agenda) para confirmarla o rechazarla.`;
// Enviamos el WA de aviso al trabajador
sendWhatsAppAuto(w.phone, msg, `cliente_${ownerId}`, false).catch(console.error);
}
}
res.json({ ok: true });
} catch (e) {
console.error("Error agendando cita (book):", e);
res.status(500).json({ ok: false, error: "Error interno" });
}
});
// 3. OBTENER SOLICITUDES PARA EL PANEL DEL ADMIN Y APP OPERARIO
app.get("/agenda/requests", authMiddleware, async (req, res) => {
try {
let query = `
SELECT s.id, s.service_ref, s.raw_data, u.full_name as assigned_name
FROM scraped_services s
LEFT JOIN users u ON s.assigned_to = u.id
WHERE s.owner_id = $1
AND s.raw_data->>'appointment_status' = 'pending'
`;
const params = [req.user.accountId];
// Si es operario, solo ve sus propias solicitudes
if (req.user.role === 'operario' || req.user.role === 'operario_cerrado') {
query += ` AND s.assigned_to = $2`;
params.push(req.user.sub);
}
query += ` ORDER BY s.created_at ASC`;
const q = await pool.query(query, params);
res.json({ ok: true, requests: q.rows });
} catch (e) { res.status(500).json({ ok: false }); }
});
// 4. APROBAR CITA (Aquí se establece la duración y se envía WA)
app.post("/agenda/requests/:id/approve", authMiddleware, async (req, res) => {
try {
const { id } = req.params;
const { duration } = req.body; // En minutos
const current = await pool.query('SELECT raw_data FROM scraped_services WHERE id=$1 AND owner_id=$2', [id, req.user.accountId]);
if (current.rowCount === 0) return res.status(404).json({ok: false});
const raw = current.rows[0].raw_data;
const reqDate = raw.requested_date;
const reqTime = raw.requested_time;
const statusQ = await pool.query("SELECT id FROM service_statuses WHERE owner_id=$1 AND name ILIKE '%citado%' LIMIT 1", [req.user.accountId]);
const idCitado = statusQ.rows[0]?.id || raw.status_operativo;
const updatedRaw = {
...raw,
scheduled_date: reqDate,
scheduled_time: reqTime,
duration_minutes: duration,
appointment_status: 'approved',
status_operativo: idCitado
};
delete updatedRaw.requested_date;
delete updatedRaw.requested_time;
await pool.query("UPDATE scraped_services SET raw_data=$1 WHERE id=$2", [JSON.stringify(updatedRaw), id]);
// Disparamos WhatsApp oficial de cita confirmada
await triggerWhatsAppEvent(req.user.accountId, id, 'wa_evt_date');
res.json({ok: true});
} catch (e) { res.status(500).json({ok: false}); }
});
// 5. RECHAZAR CITA
app.post("/agenda/requests/:id/reject", authMiddleware, async (req, res) => {
try {
const { id } = req.params;
const current = await pool.query('SELECT raw_data FROM scraped_services WHERE id=$1 AND owner_id=$2', [id, req.user.accountId]);
if (current.rowCount === 0) return res.status(404).json({ok: false});
const raw = current.rows[0].raw_data;
const updatedRaw = { ...raw, appointment_status: 'rejected' };
delete updatedRaw.requested_date;
delete updatedRaw.requested_time;
await pool.query("UPDATE scraped_services SET raw_data=$1 WHERE id=$2", [JSON.stringify(updatedRaw), id]);
// Enviar WA de rechazo con el enlace para que elija otra
const phone = raw["Teléfono"] || raw["TELEFONO"] || "";
if (phone) {
const clientQ = await pool.query("SELECT portal_token FROM clients WHERE phone LIKE $1 AND owner_id=$2 LIMIT 1", [`%${phone.replace('+34', '').trim()}%`, req.user.accountId]);
const token = clientQ.rows[0]?.portal_token;
const link = `https://portal.integrarepara.es/?token=${token}&service=${id}`;
const finalMsg = `⚠️ *CITA NO CONFIRMADA*\n\nHola ${raw["Nombre Cliente"] || "Cliente"}. Lamentamos informarte que el técnico no podrá acudir en el horario que solicitaste por un problema de ruta.\n\nPor favor, entra de nuevo en tu portal y elige otro hueco disponible:\n🔗 ${link}`;
await sendWhatsAppAuto(phone, finalMsg, `cliente_${req.user.accountId}`, false);
}
res.json({ok: true});
} catch (e) { res.status(500).json({ok: false}); }
});
// 6. RUTAS DE BLOQUEOS (AGENDA) CON SOPORTE PARA GREMIOS
app.post("/agenda/blocks", authMiddleware, async (req, res) => {
try {
const { worker_id, date, time, duration, reason, guild_id, guild_name } = req.body;
const raw = {
"Nombre Cliente": "BLOQUEO DE AGENDA",
"Descripción": reason,
scheduled_date: date,
scheduled_time: time,
duration_minutes: duration,
blocked_guild_id: guild_id || null, // Guardamos el gremio
blocked_guild_name: guild_name || null
};
await pool.query(`
INSERT INTO scraped_services (owner_id, provider, service_ref, status, assigned_to, raw_data)
VALUES ($1, 'SYSTEM_BLOCK', 'BLOCK-' || extract(epoch from now())::int, 'pending', $2, $3)
`, [req.user.accountId, worker_id, JSON.stringify(raw)]);
res.json({ ok: true });
} catch(e) { res.status(500).json({ ok: false }); }
});
app.get("/agenda/blocks", authMiddleware, async (req, res) => {
try {
const q = await pool.query(`
SELECT s.id, u.full_name as worker_name,
s.raw_data->>'scheduled_date' as date,
s.raw_data->>'scheduled_time' as time,
s.raw_data->>'duration_minutes' as duration,
s.raw_data->>'Descripción' as reason,
s.raw_data->>'blocked_guild_name' as guild_name
FROM scraped_services s
JOIN users u ON s.assigned_to = u.id
WHERE s.owner_id = $1 AND s.provider = 'SYSTEM_BLOCK'
ORDER BY s.raw_data->>'scheduled_date' DESC
`, [req.user.accountId]);
res.json({ ok: true, blocks: q.rows });
} catch(e) { res.status(500).json({ ok: false }); }
});
app.delete("/agenda/blocks/:id", authMiddleware, async (req, res) => {
try {
await pool.query("DELETE FROM scraped_services WHERE id=$1 AND owner_id=$2 AND provider='SYSTEM_BLOCK'", [req.params.id, req.user.accountId]);
res.json({ ok: true });
} catch(e) { res.status(500).json({ ok: false }); }
});
// ==========================================
// 📥 TRASPASO MANUAL (BUZÓN -> PANEL)
// ==========================================
app.post('/providers/import/:id', authMiddleware, async (req, res) => {
try {
const { id } = req.params;
// 1. Cambiamos el estado principal a 'imported' para que el buzón lo marque en azul
// y detenemos cualquier automatismo pendiente ('completed')
await pool.query(
"UPDATE scraped_services SET status = 'imported', automation_status = 'completed' WHERE id = $1 AND owner_id = $2",
[id, req.user.accountId]
);
// 2. Dejamos constancia en la trazabilidad (Historial)
await pool.query(
"INSERT INTO scraped_service_logs (scraped_id, user_name, action, details) VALUES ($1, $2, $3, $4)",
[id, "Admin", "Traspaso Manual", "El expediente ha sido validado y asignado desde el buzón."]
);
res.json({ ok: true });
} catch (e) {
console.error("Error en traspaso manual:", e);
res.status(500).json({ ok: false, error: "Error en el servidor" });
}
});
// ==========================================
// ⚙️ MOTOR AUTOMÁTICO DE WHATSAPP Y APP SETTINGS (AÑADIDO PARA SOLUCIONAR ERROR 404)
// ==========================================
app.get("/whatsapp/status", authMiddleware, async (req, res) => {
try {
const instanceName = `cliente_${req.user.accountId}`;
if (!EVOLUTION_BASE_URL || !EVOLUTION_API_KEY) return res.json({ ok: false, error: "Servidor Evolution no configurado" });
const baseUrl = EVOLUTION_BASE_URL.replace(/\/$/, "");
const headers = { "Content-Type": "application/json", "apikey": EVOLUTION_API_KEY.trim() };
// 1. Verificamos si la instancia existe en Evolution
let statusRes = await fetch(`${baseUrl}/instance/connectionState/${instanceName}`, { headers });
// 2. Si da 404, significa que nunca se ha creado. La creamos y pedimos el QR
if (statusRes.status === 404) {
await fetch(`${baseUrl}/instance/create`, {
method: 'POST', headers,
body: JSON.stringify({ instanceName: instanceName, qrcode: true, integration: "WHATSAPP-BAILEYS" })
});
// Esperamos un poco y volvemos a consultar
await new Promise(r => setTimeout(r, 2000));
statusRes = await fetch(`${baseUrl}/instance/connectionState/${instanceName}`, { headers });
}
const data = await statusRes.json();
// 3. Devolvemos el estado
if (data.instance?.state === 'open') {
return res.json({ ok: true, state: 'open', instanceName });
} else {
// Buscamos el QR
const qrRes = await fetch(`${baseUrl}/instance/connect/${instanceName}`, { headers });
const qrData = await qrRes.json();
return res.json({ ok: true, state: 'connecting', qr: qrData.base64 || qrData.code });
}
} catch (e) {
console.error("Error consultando estado WA:", e);
res.status(500).json({ ok: false, error: "Error de conexión con el motor WA" });
}
});
app.get("/whatsapp/settings", authMiddleware, async (req, res) => {
try {
const q = await pool.query("SELECT wa_settings FROM users WHERE id=$1", [req.user.accountId]);
res.json({ ok: true, settings: q.rows[0]?.wa_settings || {} });
} catch (e) { res.status(500).json({ ok: false }); }
});
app.post("/whatsapp/settings", authMiddleware, async (req, res) => {
try {
await pool.query("UPDATE users SET wa_settings = $1 WHERE id=$2", [JSON.stringify(req.body), req.user.accountId]);
res.json({ ok: true });
} catch (e) { res.status(500).json({ ok: false }); }
});
// ==========================================
// 💬 NÚCLEO DE ENVÍO WHATSAPP (EVOLUTION API - MODO HUMANO DINÁMICO)
// ==========================================
async function sendWhatsAppAuto(phone, message, instanceName, useDelay = false) {
if (!EVOLUTION_BASE_URL || !EVOLUTION_API_KEY) {
console.error("⚠️ WhatsApp omitido: Servidor Evolution no configurado.");
return false;
}
// 🧠 Lógica de escritura "Modo Humano"
let timeToType = 0;
if (useDelay) {
// Calcula 50ms por cada carácter del mensaje
let baseTime = message.length * 50;
// Nos aseguramos de que tarde entre 1.5 segundos (mínimo) y 10 segundos (máximo)
baseTime = Math.min(Math.max(baseTime, 1500), 10000);
// Sumamos un margen aleatorio (entre 0 y 1.5 segundos) para que nunca tarde exactamente lo mismo
const factorAleatorio = Math.floor(Math.random() * 1500);
timeToType = baseTime + factorAleatorio;
console.log(`[WA] Modo Humano: Simulando escritura durante ${Math.round(timeToType/1000)} segundos para un texto de ${message.length} letras.`);
}
try {
const baseUrl = EVOLUTION_BASE_URL.replace(/\/$/, "");
const url = `${baseUrl}/message/sendText/${instanceName}`;
const response = await fetch(url, {
method: 'POST',
headers: {
"Content-Type": "application/json",
"apikey": EVOLUTION_API_KEY.trim()
},
body: JSON.stringify({
number: phone,
text: message,
delay: timeToType // Usamos el tiempo dinámico que acabamos de calcular
})
});
if (!response.ok) {
console.error("❌ Fallo enviando WhatsApp. Estado Evolution:", response.status);
return false;
}
return true;
} catch (error) {
console.error("❌ Error de red conectando con Evolution API:", error.message);
return false;
}
}
// ==========================================
// 🔔 DISPARADOR DE EVENTOS DE WHATSAPP (CON MODO SEGURO)
// ==========================================
async function triggerWhatsAppEvent(ownerId, serviceId, eventType) {
try {
const userQ = await pool.query("SELECT wa_settings FROM users WHERE id=$1", [ownerId]);
const settings = userQ.rows[0]?.wa_settings || {};
const checkSwitch = eventType === 'wa_evt_update' ? 'wa_evt_date' : eventType;
if (!settings[checkSwitch]) return false; // Botón apagado = No enviado
const tplTypeMap = {
'wa_evt_welcome': 'welcome',
'wa_evt_assigned': 'assigned',
'wa_evt_date': 'appointment',
'wa_evt_update': 'update',
'wa_evt_onway': 'on_way',
'wa_evt_survey': 'survey'
};
const tplQ = await pool.query("SELECT content FROM message_templates WHERE owner_id=$1 AND type=$2", [ownerId, tplTypeMap[eventType]]);
if (tplQ.rowCount === 0 || !tplQ.rows[0].content) return false;
let text = tplQ.rows[0].content;
const svcQ = await pool.query("SELECT * FROM scraped_services WHERE id=$1", [serviceId]);
if (svcQ.rowCount === 0) return false;
const s = svcQ.rows[0];
const raw = s.raw_data || {};
// 1. EXTRAER TELÉFONO DEL CLIENTE LIMPIO
let rawPhone = raw["Teléfono"] || raw["TELEFONO"] || raw["TELEFONOS"] || "";
let cleanPhoneToMatch = String(rawPhone).replace(/\D/g, "");
if (cleanPhoneToMatch.length > 9) cleanPhoneToMatch = cleanPhoneToMatch.slice(-9);
if (cleanPhoneToMatch.length < 9) return false; // Si no hay teléfono válido, cancelamos
const finalPhoneToSend = "34" + cleanPhoneToMatch;
// 2. Buscamos el token del portal cliente (o lo creamos si no existe)
let token = "ERROR";
const clientQ = await pool.query("SELECT portal_token FROM clients WHERE phone LIKE $1 AND owner_id=$2 LIMIT 1", [`%${cleanPhoneToMatch}%`, ownerId]);
if (clientQ.rowCount > 0) {
token = clientQ.rows[0].portal_token;
} else {
const cName = raw["Nombre Cliente"] || raw["CLIENTE"] || "Asegurado";
const cAddr = raw["Dirección"] || raw["DOMICILIO"] || "";
const insertC = await pool.query(
"INSERT INTO clients (owner_id, full_name, phone, addresses) VALUES ($1, $2, $3, $4) RETURNING portal_token",
[ownerId, cName, finalPhoneToSend, JSON.stringify([cAddr])]
);
token = insertC.rows[0].portal_token;
}
const linkMagico = `https://portal.integrarepara.es/?token=${token}&service=${serviceId}`;
let fechaLimpia = raw["scheduled_date"] || "la fecha acordada";
if (fechaLimpia.includes("-")) {
const partes = fechaLimpia.split("-");
if (partes.length === 3) {
const fechaObj = new Date(partes[0], partes[1] - 1, partes[2], 12, 0, 0);
const diaSemana = fechaObj.toLocaleDateString('es-ES', { weekday: 'long' });
fechaLimpia = `(${diaSemana}) ${partes[2]}/${partes[1]}/${partes[0]}`;
}
}
text = text.replace(/{{NOMBRE}}/g, raw["Nombre Cliente"] || raw["CLIENTE"] || "Cliente");
text = text.replace(/{{DIRECCION}}/g, raw["Dirección"] || raw["DOMICILIO"] || "su domicilio");
text = text.replace(/{{FECHA}}/g, fechaLimpia);
text = text.replace(/{{HORA}}/g, raw["scheduled_time"] || "la hora acordada");
text = text.replace(/{{COMPANIA}}/g, raw["Compañía"] || raw["COMPAÑIA"] || "su Aseguradora");
text = text.replace(/{{REFERENCIA}}/g, s.service_ref || raw["Referencia"] || raw["Nº Siniestro"] || serviceId);
text = text.replace(/{{ENLACE}}/g, linkMagico);
const useDelay = settings.wa_delay_enabled !== false;
const instanceName = `cliente_${ownerId}`;
// ====================================================
// 🛑 MODO PRUEBAS (Desvía los mensajes a tu móvil)
// ====================================================
const MODO_PRUEBAS = true; // Cambia esto a 'false' para enviar a clientes reales
const MI_TELEFONO = "34667248132"; // <--- TU NÚMERO DE MÓVIL YA CONFIGURADO
if (MODO_PRUEBAS) {
console.log(`🛡️ [MODO PRUEBAS] Enviando WA al admin (${MI_TELEFONO}) en lugar de al cliente (${finalPhoneToSend})`);
const textoPrueba = `*(SIMULACIÓN PARA CLIENTE: ${finalPhoneToSend})*\n\n` + text;
return await sendWhatsAppAuto(MI_TELEFONO, textoPrueba, instanceName, useDelay);
} else {
console.log(`[WA] Enviando mensaje real al cliente: ${finalPhoneToSend}`);
return await sendWhatsAppAuto(finalPhoneToSend, text, instanceName, useDelay);
}
} catch (e) {
console.error("Error Motor WA:", e.message);
return false;
}
}
app.post("/providers/credentials", authMiddleware, async (req, res) => {
try {
const { provider, username, password } = req.body;
const passwordSafe = Buffer.from(password).toString('base64');
await pool.query(`
INSERT INTO provider_credentials (owner_id, provider, username, password_hash)
VALUES ($1, $2, $3, $4)
ON CONFLICT (owner_id, provider) DO UPDATE SET username = EXCLUDED.username, password_hash = EXCLUDED.password_hash, status = 'active'
`, [req.user.accountId, provider, username, passwordSafe]);
res.json({ ok: true });
} catch (e) { res.status(500).json({ ok: false }); }
});
app.get("/providers/scraped", authMiddleware, async (req, res) => {
try {
// Pedimos a Postgres que calcule los SEGUNDOS que faltan y enviamos la cuenta exacta
const q = await pool.query(`
SELECT
s.*,
ap.token as active_token,
EXTRACT(EPOCH FROM (ap.expires_at - CURRENT_TIMESTAMP)) as seconds_left,
u.full_name as current_worker_name,
(SELECT json_agg(json_build_object('name', u2.full_name, 'phone', u2.phone))
FROM assignment_pings ap2
JOIN users u2 ON ap2.user_id = u2.id
WHERE ap2.scraped_id = s.id AND ap2.status IN ('expired', 'rejected')) as attempted_workers_data
FROM scraped_services s
LEFT JOIN assignment_pings ap ON s.id = ap.scraped_id AND ap.status = 'pending'
LEFT JOIN users u ON ap.user_id = u.id
WHERE s.owner_id = $1
ORDER BY s.created_at DESC
`, [req.user.accountId]);
const services = q.rows.map(row => {
if (row.seconds_left && row.seconds_left > 0) {
row.token_expires_at = new Date(Date.now() + (row.seconds_left * 1000));
} else if (row.seconds_left <= 0) {
row.token_expires_at = new Date(Date.now() - 1000);
}
delete row.seconds_left;
return row;
});
res.json({ ok: true, services });
} catch (e) {
res.status(500).json({ ok: false });
}
});
// ==========================================
// 🤖 RUTA DE AUTOMATIZACIÓN (MEJORADA)
// ==========================================
app.post("/providers/automate/:id", authMiddleware, async (req, res) => {
const { id } = req.params;
console.log(`\n🤖 [AUTOMATE] Iniciando proceso para ID: ${id}`);
try {
const { guild_id, cp, useDelay } = req.body;
if (!guild_id) {
console.error("❌ [AUTOMATE] Faltan datos: guild_id");
return res.status(400).json({ ok: false, error: "Falta Gremio" });
}
// 1. Verificar si el expediente existe
const serviceQ = await pool.query("SELECT raw_data, provider, owner_id FROM scraped_services WHERE id = $1", [id]);
if (serviceQ.rowCount === 0) {
console.error(`❌ [AUTOMATE] Expediente ${id} no encontrado en la DB`);
return res.status(404).json({ ok: false, error: "Expediente no encontrado" });
}
// 2. Buscar operarios que cumplan Gremio + Zona (CP) + Activos
let workersQ = await pool.query(`
SELECT u.id, u.full_name, u.phone
FROM users u
JOIN user_guilds ug ON u.id = ug.user_id
WHERE u.owner_id = $1
AND u.role = 'operario'
AND u.status = 'active'
AND ug.guild_id = $2
AND u.zones::jsonb @> $3::jsonb
`, [req.user.accountId, guild_id, JSON.stringify([{ cps: (cp || "00000").toString() }])]);
// MEJORA: Si no hay nadie para ese CP exacto (o si no se puso CP en el presupuesto),
// buscamos a CUALQUIER operario de ese gremio
if (workersQ.rowCount === 0) {
console.log(`⚠️ [AUTOMATE] No hay operario para el CP exacto, buscando a cualquiera del gremio ${guild_id}...`);
workersQ = await pool.query(`
SELECT u.id, u.full_name, u.phone
FROM users u
JOIN user_guilds ug ON u.id = ug.user_id
WHERE u.owner_id = $1
AND u.role = 'operario'
AND u.status = 'active'
AND ug.guild_id = $2
`, [req.user.accountId, guild_id]);
}
if (workersQ.rowCount === 0) {
console.warn(`❌ [AUTOMATE] No hay operarios activos para el Gremio:${guild_id}`);
return res.status(404).json({ ok: false, error: "No hay operarios disponibles para este gremio" });
}
// 3. Marcar como "En progreso"
await pool.query("UPDATE scraped_services SET automation_status = 'in_progress' WHERE id = $1", [id]);
const worker = workersQ.rows[Math.floor(Math.random() * workersQ.rows.length)];
const token = crypto.randomBytes(16).toString('hex');
// 4. Crear el Ping de asignación (5 minutos)
await pool.query(`
INSERT INTO assignment_pings (scraped_id, user_id, token, expires_at)
VALUES ($1, $2, $3, CURRENT_TIMESTAMP + INTERVAL '5 minutes')
`, [id, worker.id, token]);
// 5. Preparar mensaje
const horaCaducidad = new Date(Date.now() + 5 * 60 * 1000).toLocaleTimeString('es-ES', {
hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Madrid'
});
const link = `https://web.integrarepara.es/aceptar.html?t=${token}`;
const cpMostrar = (cp && cp !== "00000") ? cp : "Zona asignada";
const mensaje = `🛠️ *NUEVO SERVICIO DISPONIBLE*\n\n👤 *Operario:* ${worker.full_name}\n📍 *Zona:* ${cpMostrar}\n⏱️ *Expira:* ${horaCaducidad}\n\nRevisa y acepta aquí:\n🔗 ${link}`;
// 6. Envío WA
const instanceName = `cliente_${req.user.accountId}`;
sendWhatsAppAuto(worker.phone, mensaje, instanceName, useDelay).catch(e => console.error("Error WA Automate:", e.message));
console.log(`✅ [AUTOMATE] Asignación enviada a ${worker.full_name}`);
// --- INICIO TRAZABILIDAD ---
await registrarMovimiento(id, req.user.sub, "Bolsa Automática", `Aviso enviado a la bolsa. Notificación disparada a: ${worker.full_name}`);
// --- FIN TRAZABILIDAD ---
res.json({ ok: true, message: "Automatismo iniciado con " + worker.full_name });
} catch (e) {
console.error("❌ [AUTOMATE] Error Crítico:", e.message);
res.status(500).json({ ok: false, error: e.message });
}
});
// ==========================================
// 📝 ACTUALIZACIÓN DE EXPEDIENTES (Y ESCUDO ANTI-ARCHIVO)
// ==========================================
app.put('/providers/scraped/:id', authMiddleware, async (req, res) => {
const { id } = req.params;
let { automation_status, status, name, phone, address, cp, description, guild_id, assigned_to, assigned_to_name, internal_notes, client_notes, is_urgent, ...extra } = req.body;
try {
// 1. Cambio de estado de automatización (ej. detener bolsa)
if (automation_status) {
await pool.query(`UPDATE scraped_services SET automation_status = $1 WHERE id = $2 AND owner_id = $3`, [automation_status, id, req.user.accountId]);
return res.json({ ok: true });
}
// 2. ESCUDO ANTI-ARCHIVO Y LOG AUTOMÁTICO (CON WA AL OPERARIO)
if (status === 'archived') {
const checkQ = await pool.query(`
SELECT raw_data, assigned_to, service_ref,
(SELECT is_final FROM service_statuses WHERE id::text = raw_data->>'status_operativo') as is_final
FROM scraped_services WHERE id = $1 AND owner_id = $2
`, [id, req.user.accountId]);
if (checkQ.rowCount > 0) {
const row = checkQ.rows[0];
const isFinal = row.is_final === true;
const hasWorker = row.assigned_to !== null;
let raw = row.raw_data || {};
const serviceRef = row.service_ref || raw["Referencia"] || id;
// SI tiene trabajador asignado Y NO está en un estado final operativo
if (hasWorker && !isFinal) {
if (!raw.cerrado_proveedor) {
raw.cerrado_proveedor = true;
await pool.query(`UPDATE scraped_services SET raw_data = $1 WHERE id = $2 AND owner_id = $3`, [JSON.stringify(raw), id, req.user.accountId]);
// LOG: INTENTO DE CIERRE
await pool.query(
"INSERT INTO scraped_service_logs (scraped_id, user_name, action, details) VALUES ($1, $2, $3, $4)",
[id, 'Sistema (Robot)', 'Intento de Cierre', 'La compañía ha cerrado el aviso, pero se mantiene vivo en IntegraRepara.']
);
// ALERTA URGENTE AL OPERARIO POR WHATSAPP
const workerQ = await pool.query("SELECT full_name, phone FROM users WHERE id=$1", [row.assigned_to]);
if (workerQ.rowCount > 0) {
const w = workerQ.rows[0];
const msg = `🚨 *¡ALERTA URGENTE!* 🚨\n\nHola ${w.full_name}, la compañía aseguradora acaba de *CERRAR/ANULAR* el expediente *#${serviceRef}* en su sistema.\n\n⚠️ *NO ACUDAS NI REALICES EL TRABAJO* si no lo has hecho ya, porque no se va a cobrar.\n\nPor favor, contacta con la oficina.`;
sendWhatsAppAuto(w.phone, msg, `cliente_${req.user.accountId}`, false).catch(console.error);
await pool.query(
"INSERT INTO scraped_service_logs (scraped_id, user_name, action, details) VALUES ($1, $2, $3, $4)",
[id, 'Sistema (Robot)', 'Alerta de Cancelación', `WhatsApp de emergencia enviado a ${w.full_name}.`]
);
}
}
return res.json({ ok: true, note: "Protegido de archivo automático y operario alertado" });
}
}
// Si no tiene trabajador, o ya está finalizado
await pool.query(`UPDATE scraped_services SET status = 'archived', automation_status = 'manual' WHERE id = $1 AND owner_id = $2`, [id, req.user.accountId]);
await pool.query(
"INSERT INTO scraped_service_logs (scraped_id, user_name, action, details) VALUES ($1, $2, $3, $4)",
[id, 'Sistema', 'Archivado', 'El expediente se ha cerrado definitivamente y movido al histórico.']
);
return res.json({ ok: true });
}
// 3. ACTUALIZACIÓN MANUAL NORMAL DE LA FICHA
const current = await pool.query('SELECT raw_data, assigned_to, status, is_urgent FROM scraped_services WHERE id = $1 AND owner_id = $2', [id, req.user.accountId]);
if (current.rows.length === 0) return res.status(404).json({ error: 'No encontrado' });
let rawActual = current.rows[0].raw_data || {};
let oldStatus = rawActual.status_operativo || null;
let newStatus = extra.status_operativo || oldStatus;
if (newStatus === "") newStatus = null;
const oldWorkerId = current.rows[0].assigned_to || rawActual.assigned_to;
let finalAssignedTo = assigned_to !== undefined ? (assigned_to === "" ? null : assigned_to) : oldWorkerId;
// --- AVISO AL OPERARIO (ASIGNACIÓN NUEVA / DESASIGNACIÓN) ---
if (finalAssignedTo !== oldWorkerId) {
if (finalAssignedTo) {
const workerQ = await pool.query("SELECT full_name, phone FROM users WHERE id=$1", [finalAssignedTo]);
if (workerQ.rowCount > 0) {
const w = workerQ.rows[0];
const ref = rawActual.service_ref || rawActual["Referencia"] || id;
const dir = address || rawActual["Dirección"] || "Ver ficha";
const msg = `🛠️ *NUEVO SERVICIO ASIGNADO*\n\nHola ${w.full_name}, se te ha asignado el expediente *#${ref}*.\n📍 Población/Zona: ${cp || rawActual["Población"] || dir}\n\nRevisa tu panel de operario para agendar la cita.`;
sendWhatsAppAuto(w.phone, msg, `cliente_${req.user.accountId}`, false).catch(console.error);
}
} else if (oldWorkerId && !finalAssignedTo) {
const workerQ = await pool.query("SELECT full_name, phone FROM users WHERE id=$1", [oldWorkerId]);
if (workerQ.rowCount > 0) {
const w = workerQ.rows[0];
const ref = rawActual.service_ref || rawActual["Referencia"] || id;
const msg = `⚠️ *AVISO DE DESASIGNACIÓN*\n\nHola ${w.full_name}, se te ha retirado el expediente *#${ref}*.\n\nYa no tienes que atender este servicio.`;
sendWhatsAppAuto(w.phone, msg, `cliente_${req.user.accountId}`, false).catch(console.error);
}
}
}
// --- LÓGICA DE ESTADOS Y WHATSAPP AL CLIENTE ---
if (newStatus && newStatus !== oldStatus) {
const statusQ = await pool.query("SELECT name FROM service_statuses WHERE id=$1", [newStatus]);
const stName = (statusQ.rows[0]?.name || "").toLowerCase();
if ((stName.includes('pendiente') && !stName.includes('cita')) || stName.includes('desasignado') || stName.includes('asignado') || stName.includes('anulado') || stName.includes('esperando')) {
if (!extra.scheduled_date) {
extra.scheduled_date = "";
extra.scheduled_time = "";
}
}
if (stName.includes('asignado') && finalAssignedTo) {
const waEnviadoExito = await triggerWhatsAppEvent(req.user.accountId, id, 'wa_evt_assigned');
if (waEnviadoExito) {
const estadoEsperando = await pool.query("SELECT id FROM service_statuses WHERE owner_id=$1 AND name='Esperando al Cliente' LIMIT 1", [req.user.accountId]);
if(estadoEsperando.rowCount > 0) {
newStatus = estadoEsperando.rows[0].id;
extra.status_operativo = newStatus;
await registrarMovimiento(id, req.user.sub, "Robot WA", "WhatsApp de asignación entregado. Estado cambiado a Esperando al Cliente.");
}
} else {
await registrarMovimiento(id, req.user.sub, "Robot WA", "WhatsApp de asignación falló o está desactivado.");
}
}
}
// 4. UNIFICAR DATOS FINALES
const updatedRawData = {
...rawActual, ...extra,
"Nombre Cliente": name || rawActual["Nombre Cliente"],
"Teléfono": phone || rawActual["Teléfono"],
"Dirección": address || rawActual["Dirección"],
"Código Postal": cp || rawActual["Código Postal"],
"Descripción": description || rawActual["Descripción"],
"guild_id": guild_id,
"assigned_to": finalAssignedTo,
"assigned_to_name": assigned_to_name,
"internal_notes": internal_notes,
"client_notes": client_notes,
"Urgente": is_urgent ? "Sí" : "No",
"status_operativo": newStatus
};
let currentDbStatus = current.rows[0].status;
const finalIsUrgent = is_urgent !== undefined ? is_urgent : current.rows[0].is_urgent;
await pool.query(
`UPDATE scraped_services SET raw_data = $1, status = $2, is_urgent = $3, assigned_to = $4 WHERE id = $5 AND owner_id = $6`,
[JSON.stringify(updatedRawData), currentDbStatus, finalIsUrgent, finalAssignedTo, id, req.user.accountId]
);
await registrarMovimiento(id, req.user.sub, "Edición / Asignación", "Expediente actualizado o asignado.");
res.json({ ok: true });
} catch (error) {
console.error("Error actualización manual:", error);
res.status(500).json({ error: 'Error' });
}
});
// Validar si una referencia ya existe para este dueño
app.get("/services/check-ref", authMiddleware, async (req, res) => {
try {
const { ref } = req.query;
const q = await pool.query(
"SELECT id FROM scraped_services WHERE service_ref = $1 AND owner_id = $2",
[ref, req.user.accountId]
);
res.json({ exists: q.rowCount > 0 });
} catch (e) { res.status(500).json({ ok: false }); }
});
app.get("/discovery/keys/:provider", authMiddleware, async (req, res) => {
try {
const { provider } = req.params;
const rawServices = await pool.query("SELECT raw_data FROM scraped_services WHERE owner_id=$1 AND provider=$2 ORDER BY id DESC LIMIT 1", [req.user.accountId, provider]);
const mappings = await pool.query("SELECT original_key, target_key, is_ignored FROM variable_mappings WHERE owner_id=$1 AND provider=$2", [req.user.accountId, provider]);
const mapDict = {}; mappings.rows.forEach(m => { mapDict[m.original_key] = m; });
const discoverySet = new Set(); const samples = {};
rawServices.rows.forEach(row => { const data = row.raw_data; if (data && typeof data === 'object') { Object.keys(data).forEach(k => { discoverySet.add(k); if (!samples[k]) samples[k] = data[k]; }); } });
const result = Array.from(discoverySet).map(key => ({ original: key, sample: samples[key] || "(Vacío)", mappedTo: mapDict[key]?.target_key || "", ignored: mapDict[key]?.is_ignored || false })).sort((a, b) => a.original.localeCompare(b.original));
res.json({ ok: true, keys: result });
} catch (e) { res.status(500).json({ ok: false }); }
});
// AÑADIDO Y MEJORADO: Ruta para el Panel Operativo (Muestra TODOS los activos o filtra por operario)
// RUTA PARA EL PANEL OPERATIVO (ADMIN VE TODO, OPERARIO VE LO SUYO)
// AÑADIDO Y MEJORADO: Ruta para el Panel Operativo (Muestra TODOS los activos o filtra por operario)
// RUTA PARA EL PANEL OPERATIVO (MUESTRA SOLO ACTIVOS)
app.get("/services/active", authMiddleware, async (req, res) => {
try {
let query = `
SELECT
s.*,
u.full_name as assigned_name
FROM scraped_services s
LEFT JOIN users u ON s.assigned_to = u.id
WHERE s.owner_id = $1
AND s.status != 'archived'
`;
// ^^^ ¡Ahí le hemos devuelto el filtro para que oculte los archivados a todo el mundo!
const params = [req.user.accountId];
// SI ES OPERARIO: Ve solo lo suyo
if (req.user.role === 'operario') {
query += ` AND s.assigned_to = $2`;
params.push(req.user.sub);
}
query += ` ORDER BY s.created_at DESC`;
const q = await pool.query(query, params);
res.json({ ok: true, services: q.rows });
} catch (e) {
console.error("Error al cargar /services/active:", e);
res.status(500).json({ ok: false });
}
});
// AÑADIDO: Ruta para fijar la cita o el estado operativo (CORREGIDA PARA NO PERDER LA FECHA)
// AÑADIDO: Ruta para fijar la cita o el estado operativo (CORREGIDA PARA NO PERDER LA FECHA Y ENVIAR BIEN EL WHATSAPP)
// AÑADIDO: Ruta para fijar la cita o el estado operativo (CON ENVÍO WA EN SEGUNDO PLANO)
app.put("/services/set-appointment/:id", authMiddleware, async (req, res) => {
try {
const { id } = req.params;
let { date, time, status_operativo, skip_survey, ...extra } = req.body;
const current = await pool.query('SELECT raw_data, assigned_to FROM scraped_services WHERE id = $1 AND owner_id = $2', [id, req.user.accountId]);
if (current.rowCount === 0) return res.status(404).json({ ok: false, error: 'No encontrado' });
const rawActual = current.rows[0].raw_data || {};
// --- MEJORA: MANTENER FECHA SI NO SE ENVÍA ---
let newDate = (date !== undefined) ? date : (rawActual.scheduled_date || "");
let newTime = (time !== undefined) ? time : (rawActual.scheduled_time || "");
let finalAssignedTo = current.rows[0].assigned_to;
if (status_operativo === "") status_operativo = null;
let stName = "";
if (status_operativo) {
const statusQ = await pool.query("SELECT name FROM service_statuses WHERE id=$1", [status_operativo]);
stName = (statusQ.rows[0]?.name || "").toLowerCase();
}
// --- REGLA ESTRICTA: BORRAR FECHAS SOLO SI SE ANULA O RETROCEDE A PENDIENTE ---
if (stName.includes('pendiente') || stName.includes('desasignado') || stName.includes('asignado') || stName.includes('anulado') || stName.includes('esperando')) {
newDate = "";
newTime = "";
}
// 1. GUARDAMOS EN BBDD RÁPIDAMENTE
const updatedRawData = {
...rawActual,
...extra,
"scheduled_date": newDate,
"scheduled_time": newTime,
"status_operativo": status_operativo
};
await pool.query('UPDATE scraped_services SET raw_data = $1, assigned_to = $2 WHERE id = $3 AND owner_id = $4',
[JSON.stringify(updatedRawData), finalAssignedTo, id, req.user.accountId]
);
// --- INICIO TRAZABILIDAD ---
let logDetalle = `Estado modificado a: ${stName.toUpperCase() || 'MODIFICADO'}.`;
if (newDate) logDetalle += ` Cita para el ${newDate} a las ${newTime}.`;
await registrarMovimiento(id, req.user.sub, "Actualización desde App", logDetalle);
// 🚀 RESPONDEMOS AL NAVEGADOR INMEDIATAMENTE (La ventana se cierra al instante)
res.json({ ok: true });
// 2. MAGIA: TAREAS EN SEGUNDO PLANO (El WhatsApp tarda lo que tenga que tardar, sin bloquear)
(async () => {
try {
if (stName.includes('asignado')) {
const waEnviadoExito = await triggerWhatsAppEvent(req.user.accountId, id, 'wa_evt_assigned');
if (waEnviadoExito) {
const estadoEsperando = await pool.query("SELECT id FROM service_statuses WHERE owner_id=$1 AND name='Esperando al Cliente' LIMIT 1", [req.user.accountId]);
if (estadoEsperando.rowCount > 0) {
updatedRawData.status_operativo = estadoEsperando.rows[0].id;
await pool.query('UPDATE scraped_services SET raw_data = $1 WHERE id = $2 AND owner_id = $3', [JSON.stringify(updatedRawData), id, req.user.accountId]);
}
}
}
else if (stName.includes('esperando') || stName.includes('pendiente de cita')) {
await triggerWhatsAppEvent(req.user.accountId, id, 'wa_evt_assigned');
}
else if (stName.includes('pendiente de asignar') || stName.includes('desasignado')) {
const oldWorkerId = finalAssignedTo || rawActual.assigned_to;
if (oldWorkerId) {
const workerQ = await pool.query("SELECT full_name, phone FROM users WHERE id=$1", [oldWorkerId]);
if (workerQ.rowCount > 0) {
const w = workerQ.rows[0];
const ref = rawActual.service_ref || rawActual["Referencia"] || id;
const msg = `⚠️ *AVISO DE DESASIGNACIÓN*\n\nHola ${w.full_name}, se te ha retirado el expediente *#${ref}*.\n\nYa no tienes que atender este servicio.`;
sendWhatsAppAuto(w.phone, msg, `cliente_${req.user.accountId}`, false).catch(console.error);
}
}
updatedRawData.assigned_to = null;
updatedRawData.assigned_to_name = null;
await pool.query('UPDATE scraped_services SET raw_data = $1, assigned_to = null WHERE id = $2 AND owner_id = $3', [JSON.stringify(updatedRawData), id, req.user.accountId]);
}
else if (stName.includes('citado') && newDate !== "" && date !== undefined) {
const oldDate = rawActual.scheduled_date || "";
if (oldDate === "") await triggerWhatsAppEvent(req.user.accountId, id, 'wa_evt_date');
else if (oldDate !== newDate) await triggerWhatsAppEvent(req.user.accountId, id, 'wa_evt_update');
} else if (stName.includes('camino')) {
await triggerWhatsAppEvent(req.user.accountId, id, 'wa_evt_onway');
} else if (stName.includes('finalizado') || stName.includes('terminado')) {
if (!skip_survey) {
await triggerWhatsAppEvent(req.user.accountId, id, 'wa_evt_survey');
}
}
} catch (errBckg) {
console.error("Error en tareas de fondo:", errBckg);
}
})(); // El paréntesis final ejecuta esto en las sombras
} catch (e) {
console.error("Error agendando cita:", e);
// Solo enviamos error si no hemos respondido ya
if (!res.headersSent) res.status(500).json({ ok: false });
}
});
// ==========================================
// 📞 RUTA PARA CLIENTE NO LOCALIZADO
// ==========================================
// ==========================================
// 📞 RUTA PARA CLIENTE NO LOCALIZADO
// ==========================================
app.post("/services/not-found/:id", authMiddleware, async (req, res) => {
try {
const { id } = req.params;
const current = await pool.query('SELECT raw_data, provider, owner_id FROM scraped_services WHERE id=$1 AND owner_id=$2', [id, req.user.accountId]);
if (current.rowCount === 0) return res.status(404).json({ok: false});
const raw = current.rows[0].raw_data || {};
// Sumamos 1 al contador de llamadas
const currentCalls = parseInt(raw.called_times || 0) + 1;
raw.called_times = currentCalls;
// Guardamos en la base de datos
await pool.query("UPDATE scraped_services SET raw_data=$1 WHERE id=$2", [JSON.stringify(raw), id]);
// Intentamos enviar el WhatsApp usando la plantilla
let rawPhone = raw["Teléfono"] || raw["TELEFONOS"] || raw["TELEFONO"] || "";
let cleanPhoneToMatch = String(rawPhone).replace(/\D/g, "");
if (cleanPhoneToMatch.length > 9) cleanPhoneToMatch = cleanPhoneToMatch.slice(-9);
if (cleanPhoneToMatch.length >= 9) { // Solo intentamos enviar si hay un número válido
// 🚨 Búsqueda flexible por si la plantilla se guardó con otro nombre interno
const tplQ = await pool.query("SELECT content FROM message_templates WHERE owner_id=$1 AND type IN ('not_found', 'unreachable', 'no_reply') LIMIT 1", [req.user.accountId]);
let text = tplQ.rowCount > 0 && tplQ.rows[0].content
? tplQ.rows[0].content
: `Hola {{NOMBRE}}, soy el técnico de {{COMPANIA}}. He intentado contactar contigo para agendar tu reparación (Exp. {{REFERENCIA}}), pero no ha sido posible. Por favor, pulsa aquí para elegir tu cita: {{ENLACE}}`;
const finalPhoneToSend = "34" + cleanPhoneToMatch;
let token = "ERROR";
const clientQ = await pool.query("SELECT portal_token FROM clients WHERE phone LIKE $1 AND owner_id=$2 LIMIT 1", [`%${cleanPhoneToMatch}%`, req.user.accountId]);
if (clientQ.rowCount > 0) {
token = clientQ.rows[0].portal_token;
} else {
const newToken = crypto.randomBytes(6).toString('hex');
const insertC = await pool.query(
"INSERT INTO clients (owner_id, full_name, phone, addresses, portal_token) VALUES ($1, $2, $3, '[]', $4) RETURNING portal_token",
[req.user.accountId, raw["Nombre Cliente"] || "Cliente", finalPhoneToSend, newToken]
);
token = insertC.rows[0].portal_token;
}
const linkMagico = `https://portal.integrarepara.es/?token=${token}&service=${id}`;
let fechaLimpia = raw["scheduled_date"] || "la fecha acordada";
if (fechaLimpia.includes("-")) {
const partes = fechaLimpia.split("-");
if (partes.length === 3) {
const fechaObj = new Date(partes[0], partes[1] - 1, partes[2], 12, 0, 0);
const diaSemana = fechaObj.toLocaleDateString('es-ES', { weekday: 'long' });
fechaLimpia = `(${diaSemana}) ${partes[2]}/${partes[1]}/${partes[0]}`;
}
}
// REEMPLAZO DE TODAS LAS VARIABLES (Como en la foto)
text = text.replace(/{{NOMBRE}}/g, raw["Nombre Cliente"] || raw["CLIENTE"] || "Cliente");
text = text.replace(/{{DIRECCION}}/g, raw["Dirección"] || raw["DOMICILIO"] || "su domicilio");
text = text.replace(/{{FECHA}}/g, fechaLimpia);
text = text.replace(/{{HORA}}/g, raw["scheduled_time"] || "la hora acordada");
text = text.replace(/{{COMPANIA}}/g, raw["Compañía"] || raw["COMPAÑIA"] || "su Aseguradora");
text = text.replace(/{{REFERENCIA}}/g, current.rows[0].service_ref || id);
text = text.replace(/{{ENLACE}}/g, linkMagico);
const userQ = await pool.query("SELECT wa_settings FROM users WHERE id=$1", [req.user.accountId]);
const settings = userQ.rows[0]?.wa_settings || {};
const useDelay = settings.wa_delay_enabled !== false;
// MODO PRUEBAS: Redirigir el mensaje a tu móvil
const MODO_PRUEBAS = true;
const MI_TELEFONO = "34667248132";
if (MODO_PRUEBAS) {
console.log(`🛡️ [MODO PRUEBAS NO LOCALIZADO] Desvío a tu móvil (${MI_TELEFONO})`);
const textoPrueba = `*(SIMULACIÓN NO LOCALIZADO PARA: ${finalPhoneToSend})*\n\n` + text;
await sendWhatsAppAuto(MI_TELEFONO, textoPrueba, `cliente_${req.user.accountId}`, useDelay);
} else {
await sendWhatsAppAuto(finalPhoneToSend, text, `cliente_${req.user.accountId}`, useDelay);
}
}
// --- INICIO TRAZABILIDAD ---
await registrarMovimiento(id, req.user.sub, "Intento de Contacto", `El operario reporta que el cliente no contesta. Total intentos: ${currentCalls}`);
// --- FIN TRAZABILIDAD ---
res.json({ ok: true, called_times: currentCalls });
} catch (e) {
console.error("Error No Localizado:", e);
res.status(500).json({ ok: false });
}
});
// ==========================================
// 📝 RUTAS DE PLANTILLAS DE MENSAJES
// ==========================================
app.get("/templates", authMiddleware, async (req, res) => {
try {
const q = await pool.query("SELECT type, content FROM message_templates WHERE owner_id=$1", [req.user.accountId]);
res.json({ ok: true, templates: q.rows });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
app.post("/templates", authMiddleware, async (req, res) => {
try {
const { type, content } = req.body;
if (!type) return res.status(400).json({ ok: false, error: "Falta el tipo de plantilla" });
await pool.query(`
INSERT INTO message_templates (owner_id, type, content)
VALUES ($1, $2, $3)
ON CONFLICT (owner_id, type) DO UPDATE SET content = EXCLUDED.content
`, [req.user.accountId, type, content || ""]);
res.json({ ok: true });
} catch (e) {
console.error("Error guardando plantilla:", e);
res.status(500).json({ ok: false, error: e.message });
}
});
// ==========================================
// 🎨 RUTAS DE ESTADOS DEL SISTEMA (SAAS COMPLETO)
// ==========================================
app.get("/statuses", authMiddleware, async (req, res) => {
try {
// 1. FORZAMOS LA INYECCIÓN/ACTUALIZACIÓN SIEMPRE
const defaults = [
{name:'Pendiente de Asignar', c:'gray', d:true, f:false, sys:true},
{name:'Asignado', c:'blue', d:false, f:false, sys:true},
{name:'Esperando al Cliente', c:'amber', d:false, f:false, sys:true},
{name:'Citado', c:'emerald', d:false, f:false, sys:true},
{name:'De Camino', c:'indigo', d:false, f:false, sys:true},
{name:'Trabajando', c:'orange', d:false, f:false, sys:true},
{name:'Incidencia', c:'red', d:false, f:false, sys:true},
{name:'Desasignado', c:'rose', d:false, f:false, sys:true},
{name:'Finalizado', c:'purple', d:false, f:true, sys:true},
{name:'Anulado', c:'gray', d:false, f:true, sys:true}
];
for (const s of defaults) {
const check = await pool.query("SELECT id FROM service_statuses WHERE owner_id=$1 AND name=$2", [req.user.accountId, s.name]);
if(check.rowCount === 0){
await pool.query("INSERT INTO service_statuses (owner_id,name,color,is_default,is_final,is_system) VALUES ($1,$2,$3,$4,$5,$6)", [req.user.accountId,s.name,s.c,s.d,s.f,s.sys]);
} else {
await pool.query("UPDATE service_statuses SET is_system=true, color=$2, is_final=$3 WHERE id=$1", [check.rows[0].id, s.c, s.f]);
}
}
// 🧹 Limpiamos el candado a los viejos
const nombresOficiales = defaults.map(d => d.name);
await pool.query("UPDATE service_statuses SET is_system=false WHERE owner_id=$1 AND name != ALL($2::text[])", [req.user.accountId, nombresOficiales]);
// 🚀 FUSIÓN AUTOMÁTICA: Movemos todo lo viejo al nuevo y borramos el fantasma
let currentDb = await pool.query("SELECT * FROM service_statuses WHERE owner_id=$1", [req.user.accountId]);
const idEsperando = currentDb.rows.find(s => s.name === 'Esperando al Cliente')?.id;
const idPendiente = currentDb.rows.find(s => s.name === 'Pendiente de Cita')?.id;
if (idEsperando && idPendiente) {
// Pasamos los servicios normales
await pool.query("UPDATE services SET status_id = $1 WHERE status_id = $2 AND owner_id = $3", [idEsperando, idPendiente, req.user.accountId]);
// Pasamos los servicios del panel operativo (JSON)
await pool.query(`UPDATE scraped_services SET raw_data = jsonb_set(COALESCE(raw_data, '{}'::jsonb), '{status_operativo}', to_jsonb($1::text)) WHERE raw_data->>'status_operativo' = $2 AND owner_id = $3`, [String(idEsperando), String(idPendiente), req.user.accountId]);
// Exterminamos "Pendiente de Cita"
await pool.query("DELETE FROM service_statuses WHERE id = $1 AND owner_id = $2", [idPendiente, req.user.accountId]);
}
// 2. RECUPERAMOS LOS ESTADOS LIMPIOS
let q = await pool.query("SELECT * FROM service_statuses WHERE owner_id=$1", [req.user.accountId]);
// ORDENAMOS
let sortedStatuses = q.rows.sort((a, b) => {
let idxA = nombresOficiales.indexOf(a.name);
let idxB = nombresOficiales.indexOf(b.name);
if(idxA === -1) idxA = 99;
if(idxB === -1) idxB = 99;
return idxA - idxB;
});
res.json({ ok: true, statuses: sortedStatuses });
} catch (e) { res.status(500).json({ ok: false }); }
});
app.get("/clients/search", authMiddleware, async (req, res) => { try { const { phone } = req.query; const p = normalizePhone(phone); if(!p) return res.json({ok:true,client:null}); const q = await pool.query("SELECT * FROM clients WHERE phone=$1 AND owner_id=$2 LIMIT 1", [p, req.user.accountId]); res.json({ ok: true, client: q.rows[0] || null }); } catch (e) { res.status(500).json({ ok: false }); } });
// --- ENDPOINT PARA GENERAR ENLACE AL PORTAL DEL CLIENTE DESDE LA APP ---
app.post('/clients/ensure', authMiddleware, async (req, res) => {
try {
const { phone, name, address } = req.body;
if (!phone) return res.status(400).json({ ok: false, error: "Teléfono obligatorio" });
const cleanPhone = phone.replace('+34', '').replace(/\s+/g, '').trim();
const ownerId = req.user.accountId;
const q = await pool.query("SELECT * FROM clients WHERE phone LIKE $1 AND owner_id = $2 LIMIT 1", [`%${cleanPhone}%`, ownerId]);
if (q.rowCount > 0) {
let client = q.rows[0];
// PARCHE: Si el cliente existe pero no tiene token (porque es antiguo), se lo creamos
if (!client.portal_token) {
client.portal_token = crypto.randomBytes(6).toString('hex');
await pool.query("UPDATE clients SET portal_token = $1 WHERE id = $2", [client.portal_token, client.id]);
}
res.json({ ok: true, client });
} else {
const newToken = crypto.randomBytes(6).toString('hex');
const insert = await pool.query(
"INSERT INTO clients (owner_id, full_name, phone, addresses, portal_token) VALUES ($1, $2, $3, $4, $5) RETURNING portal_token",
[ownerId, name || "Cliente", phone, JSON.stringify([address || ""]), newToken]
);
res.json({ ok: true, client: { portal_token: insert.rows[0].portal_token } });
}
} catch (e) {
res.status(500).json({ ok: false, error: "Error interno del servidor" });
}
});
app.get("/companies", authMiddleware, async (req, res) => { try { const q = await pool.query("SELECT * FROM companies WHERE owner_id=$1 ORDER BY name ASC", [req.user.accountId]); res.json({ ok: true, companies: q.rows }); } catch (e) { res.status(500).json({ ok: false }); } });
app.post("/companies", authMiddleware, async (req, res) => { try { const { name } = req.body; await pool.query("INSERT INTO companies (name, owner_id) VALUES ($1, $2)", [name, req.user.accountId]); res.json({ ok: true }); } catch (e) { res.status(500).json({ ok: false }); } });
app.delete("/companies/:id", authMiddleware, async (req, res) => { try { await pool.query("DELETE FROM companies WHERE id=$1 AND owner_id=$2", [req.params.id, req.user.accountId]); res.json({ ok: true }); } catch (e) { res.status(500).json({ ok: false }); } });
// AÑADIDO: Filtro estricto para que solo devuelva operarios que estén en estado 'active'
app.get("/operators", authMiddleware, async (req, res) => {
try {
const guildId = req.query.guild_id;
let query = `SELECT u.id, u.full_name, u.zones FROM users u WHERE u.owner_id=$1 AND u.role='operario' AND u.status='active'`;
const params = [req.user.accountId];
if (guildId) { query = `SELECT u.id, u.full_name, u.zones FROM users u JOIN user_guilds ug ON u.id = ug.user_id WHERE u.owner_id=$1 AND u.role='operario' AND u.status='active' AND ug.guild_id=$2`; params.push(guildId); }
query += ` ORDER BY u.full_name ASC`;
const q = await pool.query(query, params);
res.json({ ok: true, operators: q.rows });
} catch (e) { res.status(500).json({ ok: false }); }
});
app.get("/zones", authMiddleware, async (req, res) => { try { const q = await pool.query("SELECT * FROM zones WHERE owner_id=$1 ORDER BY name ASC", [req.user.accountId]); res.json({ ok: true, zones: q.rows }); } catch (e) { res.status(500).json({ ok: false }); } });
app.post("/zones", authMiddleware, async (req, res) => { try { const { name } = req.body; await pool.query("INSERT INTO zones (name, owner_id) VALUES ($1, $2)", [name, req.user.accountId]); res.json({ ok: true }); } catch (e) { res.status(500).json({ ok: false }); } });
app.delete("/zones/:id", authMiddleware, async (req, res) => { try { await pool.query("DELETE FROM zones WHERE id=$1 AND owner_id=$2", [req.params.id, req.user.accountId]); res.json({ ok: true }); } catch (e) { res.status(500).json({ ok: false }); } });
app.get("/zones/:id/operators", authMiddleware, async (req, res) => { try { const q = await pool.query("SELECT user_id FROM user_zones WHERE zone_id=$1", [req.params.id]); res.json({ ok: true, assignedIds: q.rows.map(r=>r.user_id) }); } catch (e) { res.status(500).json({ ok: false }); } });
app.post("/zones/:id/assign", authMiddleware, async (req, res) => { const client = await pool.connect(); try { const { operator_ids } = req.body; await client.query('BEGIN'); await client.query("DELETE FROM user_zones WHERE zone_id=$1", [req.params.id]); if(operator_ids) for(const uid of operator_ids) await client.query("INSERT INTO user_zones (user_id, zone_id) VALUES ($1, $2)", [uid, req.params.id]); await client.query('COMMIT'); res.json({ok:true}); } catch(e){ await client.query('ROLLBACK'); res.status(500).json({ok:false}); } finally { client.release(); } });
app.get("/api/geo/municipios/:provincia", authMiddleware, async (req, res) => { try { let { provincia } = req.params; const provClean = provincia.toUpperCase().normalize("NFD").replace(/[\u0300-\u036f]/g, ""); const q = await pool.query("SELECT municipio, codigo_postal FROM master_geo_es WHERE provincia = $1 ORDER BY municipio ASC", [provClean]); res.json({ ok: true, municipios: q.rows }); } catch (e) { res.status(500).json({ ok: false }); } });
app.patch("/admin/users/:id/status", authMiddleware, async (req, res) => { try { const { status } = req.body; await pool.query("UPDATE users SET status = $1 WHERE id = $2 AND owner_id = $3", [status, req.params.id, req.user.accountId]); res.json({ ok: true }); } catch (e) { res.status(500).json({ ok: false }); } });
app.get("/admin/users", authMiddleware, async (req, res) => { try { const q = await pool.query(`SELECT u.id, u.full_name, u.email, u.phone, u.role, u.zones, u.status, COALESCE(json_agg(g.id) FILTER (WHERE g.id IS NOT NULL), '[]') as guilds FROM users u LEFT JOIN user_guilds ug ON u.id=ug.user_id LEFT JOIN guilds g ON ug.guild_id=g.id WHERE u.owner_id=$1 GROUP BY u.id ORDER BY u.id DESC`, [req.user.accountId]); res.json({ ok: true, users: q.rows }); } catch (e) { res.status(500).json({ ok: false }); } });
app.post("/admin/users", authMiddleware, async (req, res) => { const client = await pool.connect(); try { const { fullName, email, password, role, guilds, phone, zones } = req.body; if (!email || !password || !fullName || !phone) return res.status(400).json({ ok: false }); const p = normalizePhone(phone); const hash = await bcrypt.hash(password, 10); const check = await client.query("SELECT id FROM users WHERE (phone=$1 OR email=$2) AND owner_id=$3", [p, email, req.user.accountId]); if (check.rowCount > 0) return res.status(400).json({ ok: false, error: "Duplicado" }); await client.query('BEGIN'); const insert = await client.query("INSERT INTO users (full_name, email, password_hash, role, phone, is_verified, owner_id, zones, status) VALUES ($1, $2, $3, $4, $5, TRUE, $6, $7, 'active') RETURNING id", [fullName, email, hash, role || 'operario', p, req.user.accountId, JSON.stringify(zones || [])]); const uid = insert.rows[0].id; if (guilds) for (const gid of guilds) await client.query("INSERT INTO user_guilds (user_id, guild_id) VALUES ($1, $2)", [uid, gid]); await client.query('COMMIT'); res.json({ ok: true }); } catch (e) { await client.query('ROLLBACK'); res.status(500).json({ ok: false }); } finally { client.release(); } });
app.put("/admin/users/:id", authMiddleware, async (req, res) => { const client = await pool.connect(); try { const userId = req.params.id; const { fullName, email, phone, role, guilds, password, zones } = req.body; const p = normalizePhone(phone); await client.query('BEGIN'); if(password) { const hash = await bcrypt.hash(password, 10); await client.query("UPDATE users SET full_name=$1, email=$2, phone=$3, role=$4, password_hash=$5, zones=$6 WHERE id=$7", [fullName, email, p, role, hash, JSON.stringify(zones || []), userId]); } else { await client.query("UPDATE users SET full_name=$1, email=$2, phone=$3, role=$4, zones=$5 WHERE id=$6", [fullName, email, p, role, JSON.stringify(zones || []), userId]); } if (guilds && Array.isArray(guilds)) { await client.query("DELETE FROM user_guilds WHERE user_id=$1", [userId]); for (const gid of guilds) await client.query("INSERT INTO user_guilds (user_id, guild_id) VALUES ($1, $2)", [userId, gid]); } await client.query('COMMIT'); res.json({ ok: true }); } catch (e) { await client.query('ROLLBACK'); res.status(500).json({ ok: false }); } finally { client.release(); } });
app.delete("/admin/users/:id", authMiddleware, async (req, res) => { try { await pool.query("DELETE FROM users WHERE id=$1 AND owner_id=$2", [req.params.id, req.user.accountId]); res.json({ ok: true }); } catch (e) { res.status(500).json({ ok: false }); } });
// ==========================================
// 🏢 CONFIGURACIÓN EMPRESA (COLORES, LOGO, PORTAL)
// ==========================================
app.get("/config/company", authMiddleware, async (req, res) => {
try {
const q = await pool.query("SELECT company_slug, full_name, plan_tier, company_logo, portal_settings, app_settings FROM users WHERE id=$1", [req.user.accountId]);
res.json({ ok: true, config: q.rows[0] || {} });
} catch (e) { res.status(500).json({ ok: false }); }
});
app.post("/config/company", authMiddleware, async (req, res) => {
const client = await pool.connect();
try {
const { slug, company_name, company_logo, portal_settings, app_settings } = req.body;
let cleanSlug = null;
if (slug) {
cleanSlug = slug.toLowerCase().replace(/[^a-z0-9-]/g, "");
if (cleanSlug !== slug) return res.status(400).json({ ok: false, error: "El enlace solo puede contener letras minúsculas, números y guiones" });
const check = await client.query("SELECT id FROM users WHERE company_slug=$1 AND id != $2", [cleanSlug, req.user.accountId]);
if (check.rowCount > 0) return res.status(400).json({ ok: false, error: "Ese enlace ya está en uso por otra empresa" });
}
await client.query(`
UPDATE users
SET company_slug = COALESCE($1, company_slug),
full_name = COALESCE($2, full_name),
company_logo = COALESCE($3, company_logo),
portal_settings = COALESCE($4, portal_settings),
app_settings = COALESCE($5, app_settings)
WHERE id = $6
`, [cleanSlug, company_name, company_logo, portal_settings, app_settings, req.user.accountId]);
res.json({ ok: true });
} catch (e) {
console.error("Error en config/company:", e);
res.status(500).json({ ok: false, error: "Error interno" });
} finally {
client.release();
}
});
// RUTA: Alta de expediente manual con validación de cliente
app.post("/services/manual-high", authMiddleware, async (req, res) => {
try {
const { phone, name, address, description, guild_id, assigned_to, duration_minutes, mode, is_company, company_name, company_ref } = req.body;
const ownerId = req.user.accountId;
// 1. Manejo del Cliente (Buscamos si existe por teléfono)
const cleanPhone = phone.replace(/\D/g, "");
let clientQ = await pool.query("SELECT id, addresses FROM clients WHERE phone LIKE $1 AND owner_id = $2", [`%${cleanPhone}%`, ownerId]);
let clientId;
if (clientQ.rowCount > 0) {
clientId = clientQ.rows[0].id;
let currentAddrs = clientQ.rows[0].addresses || [];
// Si la dirección es nueva, la añadimos a su ficha
if (!currentAddrs.includes(address)) {
currentAddrs.push(address);
await pool.query("UPDATE clients SET addresses = $1 WHERE id = $2", [JSON.stringify(currentAddrs), clientId]);
}
} else {
// Si no existe, creamos cliente nuevo
const token = crypto.randomBytes(6).toString('hex');
const newClient = await pool.query(
"INSERT INTO clients (owner_id, full_name, phone, addresses, portal_token) VALUES ($1, $2, $3, $4, $5) RETURNING id",
[ownerId, name, phone, JSON.stringify([address]), token]
);
clientId = newClient.rows[0].id;
}
// 2. Crear el Expediente
const rawData = {
"Nombre Cliente": name,
"Teléfono": phone,
"Dirección": address,
"Descripción": description,
"guild_id": guild_id,
"scheduled_date": "",
"scheduled_time": "",
"duration_minutes": duration_minutes || 60,
"Compañía": is_company ? company_name : "Particular"
};
const serviceReference = is_company ? company_ref : `M-${Date.now().toString().slice(-6)}`;
const insertSvc = await pool.query(
`INSERT INTO scraped_services (owner_id, provider, service_ref, status, automation_status, assigned_to, raw_data)
VALUES ($1, 'MANUAL', $2, 'pending', $3, $4, $5) RETURNING id`,
[
ownerId,
serviceReference,
mode === 'auto' ? 'in_progress' : 'manual',
assigned_to || null,
JSON.stringify(rawData)
]
);
const newId = insertSvc.rows[0].id;
// 3. Si se eligió "Mandar a la bolsa", llamamos internamente al robot
if (mode === 'auto' && guild_id) {
const port = process.env.PORT || 3000;
fetch(`http://127.0.0.1:${port}/providers/automate/${newId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': req.headers.authorization },
body: JSON.stringify({ guild_id, cp: "00000" })
}).catch(e => console.error("Error lanzando bolsa:", e));
}
// --- TRAZABILIDAD ---
await registrarMovimiento(newId, req.user.sub, "Alta Manual", `Servicio creado manualmente (${rawData["Compañía"]}).`);
res.json({ ok: true, id: newId });
} catch (e) {
console.error("Error Alta Manual:", e);
res.status(500).json({ ok: false });
}
});
// ==========================================
// 🛠️ RUTAS DE GREMIOS E INTELIGENCIA ARTIFICIAL
// ==========================================
app.get("/guilds", authMiddleware, async (req, res) => {
try {
let q = await pool.query("SELECT id, name, ia_keywords FROM guilds WHERE owner_id=$1 ORDER BY name ASC", [req.user.accountId]);
if (q.rowCount === 0) {
const defaults = [
{ n: "ELECTRICISTA", kw: '["electric", "cortocircuito", "cuadro electrico", "salto de plomos", "apagon", "diferencial", "icp", "magnetotermico", "chispazo", "sin luz", "cableado", "derivacion", "no hay luz", "salta el termico"]' },
{ n: "FONTANERIA", kw: '["fontaner", "fuga de agua", "tuberia", "atasco", "desatasco", "bote sifonico", "llave de paso", "calentador", "termo", "radiador", "caldera", "gotera", "inundacion", "filtracion", "bajante", "humedad"]' },
{ n: "CRISTALERIA", kw: '["cristal", "vidrio", "ventana rota", "escaparate", "luna", "espejo", "climalit", "doble acristalamiento", "velux", "rotura"]' },
{ n: "PERSIANAS", kw: '["motor persiana", "eje persiana", "persianista", "persiana atascada", "rotura de persiana", "domotica persiana"]' },
{ n: "CARPINTERIA", kw: '["carpinter", "puerta de madera", "bisagra", "marco", "rodapie", "tarima", "armario", "cepillar puerta", "cajon", "encimera", "madera hinchada"]' },
{ n: "ALBAÑILERIA", kw: '["albañil", "cemento", "yeso", "ladrillo", "azulejo", "desconchado", "grieta", "muro", "alicatado"]' },
{ n: "MANITAS ELECTRICISTA", kw: '["manitas electric", "cambiar bombilla", "colgar lampara", "instalar foco", "fluorescente", "casquillo", "lampara del dormitorio", "cambiar enchufe", "embellecedor"]' },
{ n: "MANITAS FONTANERIA", kw: '["manitas fontaner", "cambiar grifo", "sellar bañera", "silicona", "latiguillo", "alcachofa", "tapon", "cambiar cisterna", "descargador"]' },
{ n: "MANITAS PERSIANAS", kw: '["manitas persian", "cambiar cinta", "cuerda persiana", "recogedor", "atasco persiana", "lamas rotas", "persiana descolgada"]' },
{ n: "MANITAS GENERAL", kw: '["bombin", "colgar cuadro", "soporte tv", "estanteria", "montar mueble", "ikea", "cortina", "riel", "estor", "agujero", "taladro", "picaporte", "colgar espejo"]' }
];
for (const g of defaults) { await pool.query("INSERT INTO guilds (owner_id, name, ia_keywords) VALUES ($1, $2, $3::jsonb)", [req.user.accountId, g.n, g.kw]); }
q = await pool.query("SELECT id, name, ia_keywords FROM guilds WHERE owner_id=$1 ORDER BY name ASC", [req.user.accountId]);
}
res.json({ ok: true, guilds: q.rows });
} catch (e) { res.status(500).json({ ok: false }); }
});
app.post("/guilds", authMiddleware, async (req, res) => { try { const { name } = req.body; await pool.query("INSERT INTO guilds (name, owner_id) VALUES ($1, $2)", [name, req.user.accountId]); res.json({ ok: true }); } catch (e) { res.status(500).json({ ok: false }); } });
app.delete("/guilds/:id", authMiddleware, async (req, res) => { try { await pool.query("DELETE FROM guilds WHERE id=$1 AND owner_id=$2", [req.params.id, req.user.accountId]); res.json({ ok: true }); } catch (e) { res.status(500).json({ ok: false }); } });
app.put("/guilds/:id/ia-rules", authMiddleware, async (req, res) => {
try {
const { keywords } = req.body;
const guildId = req.params.id;
const safeKeywords = Array.isArray(keywords) ? keywords : [];
await pool.query("UPDATE guilds SET ia_keywords = $1 WHERE id = $2 AND owner_id = $3", [JSON.stringify(safeKeywords), guildId, req.user.accountId]);
res.json({ ok: true });
} catch (e) { res.status(500).json({ ok: false, error: e.message }); }
});
// ==========================================
// 🏆 MOTOR DE RANKING Y ESTADÍSTICAS
// ==========================================
function calculateScore(services) {
let score = 0;
const now = new Date();
const thirtyDaysAgo = new Date(now.getTime() - (30 * 24 * 60 * 60 * 1000));
// Separar servicios (usamos created_at para medir el último mes)
const openServices = services.filter(s => !s.is_final);
const closedLast30Days = services.filter(s => s.is_final && new Date(s.created_at) >= thirtyDaysAgo);
// --- 1. VELOCIDAD DE CIERRE (Max 30 Puntos) ---
let scoreCierre = 0;
if (closedLast30Days.length > 0) {
let totalDaysToClose = 0;
closedLast30Days.forEach(s => {
const created = new Date(s.created_at);
const closed = new Date(); // Estimamos el cierre en el ciclo actual
totalDaysToClose += Math.max(1, (closed - created) / (1000 * 60 * 60 * 24));
});
const avgCloseDays = totalDaysToClose / closedLast30Days.length;
if (avgCloseDays <= 2) scoreCierre = 30;
else if (avgCloseDays >= 14) scoreCierre = 0;
else scoreCierre = 30 - ((avgCloseDays - 2) * (30 / 12));
}
// --- 2. CITA RÁPIDA < 24h (Max 30 Puntos) ---
let scoreCita = 0;
let validSchedules = 0;
let fastSchedules = 0;
const recentServices = services.filter(s => new Date(s.created_at) >= thirtyDaysAgo);
recentServices.forEach(s => {
const raw = s.raw_data || {};
if (raw.scheduled_date) {
validSchedules++;
const created = new Date(s.created_at);
const [y, m, d] = raw.scheduled_date.split('-');
const schedDate = new Date(y, m - 1, d);
const diffDays = (schedDate - created) / (1000 * 60 * 60 * 24);
if (diffDays <= 1.5) fastSchedules++;
}
});
if (validSchedules > 0) {
const fastRatio = fastSchedules / validSchedules;
scoreCita = fastRatio * 30;
}
// --- 3. VOLUMEN DE TRABAJO AL DÍA (Max 20 Puntos) ---
let scoreVolumen = 0;
const closedPerDay = closedLast30Days.length / 22;
if (closedPerDay >= 3) scoreVolumen = 20;
else scoreVolumen = (closedPerDay / 3) * 20;
// --- 4. PENALIZACIÓN POR RE-CITAS (Max 20 Puntos) ---
let scoreRecitas = 20;
let totalCalled = 0;
recentServices.forEach(s => {
const raw = s.raw_data || {};
const calls = parseInt(raw.called_times || 0);
if (calls > 1) totalCalled += (calls - 1);
});
scoreRecitas = Math.max(0, 20 - (totalCalled * 2));
// --- SUMA BASE ---
let totalScore = scoreCierre + scoreCita + scoreVolumen + scoreRecitas;
// --- 5. PENALIZACIÓN POR ACUMULACIÓN DE ABIERTOS ---
let penalizacionAbiertos = 0;
if (openServices.length > 15) {
penalizacionAbiertos = (openServices.length - 15) * 1.5;
}
totalScore -= penalizacionAbiertos;
totalScore = Math.min(100, Math.max(0, totalScore));
return {
score: Math.round(totalScore),
details: {
cierre: Math.round(scoreCierre),
cita: Math.round(scoreCita),
volumen: Math.round(scoreVolumen),
recitas: Math.round(scoreRecitas),
penalizacion: Math.round(penalizacionAbiertos),
abiertos: openServices.length,
cerrados_mes: closedLast30Days.length
}
};
}
// RUTA GET PARA EL RANKING
app.get("/ranking", authMiddleware, async (req, res) => {
try {
// CORRECCIÓN: Hemos quitado "updated_at" de aquí para evitar que la base de datos se queje
const q = await pool.query(`
SELECT id, created_at, raw_data,
(SELECT is_final FROM service_statuses WHERE id::text = raw_data->>'status_operativo') as is_final
FROM scraped_services
WHERE assigned_to = $1
`, [req.user.sub]);
const rankingData = calculateScore(q.rows);
await pool.query(
"UPDATE users SET ranking_score = $1, ranking_data = $2 WHERE id = $3",
[rankingData.score, rankingData.details, req.user.sub]
);
res.json({ ok: true, ranking: rankingData });
} catch (error) {
console.error("Error en ranking:", error);
res.status(500).json({ ok: false });
}
});
// ==========================================
// 📍 MOTOR GPS: RASTREO EN TIEMPO REAL
// ==========================================
// El operario envía su ubicación
app.post("/services/:id/location", authMiddleware, async (req, res) => {
try {
const { lat, lng } = req.body;
if (!lat || !lng) return res.status(400).json({ ok: false });
const locData = { lat, lng, updated_at: new Date().toISOString() };
// CORRECCIÓN: Ahora usa owner_id para que funcione aunque el admin esté haciendo la prueba
await pool.query(`
UPDATE scraped_services
SET raw_data = jsonb_set(COALESCE(raw_data, '{}'::jsonb), '{worker_location}', $1::jsonb)
WHERE id = $2 AND owner_id = $3
`, [JSON.stringify(locData), req.params.id, req.user.accountId]);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false });
}
});
// El cliente consulta la ubicación
app.get("/public/portal/:token/location/:serviceId", async (req, res) => {
try {
const { token, serviceId } = req.params;
const clientQ = await pool.query("SELECT owner_id FROM clients WHERE portal_token = $1", [token]);
if (clientQ.rowCount === 0) return res.status(404).json({ ok: false });
const serviceQ = await pool.query("SELECT raw_data FROM scraped_services WHERE id = $1 AND owner_id = $2", [serviceId, clientQ.rows[0].owner_id]);
if (serviceQ.rowCount === 0) return res.status(404).json({ ok: false });
const loc = serviceQ.rows[0].raw_data.worker_location || null;
res.json({ ok: true, location: loc });
} catch (e) {
res.status(500).json({ ok: false });
}
});
// ==========================================
// 🔗 RUTAS PARA ACEPTAR/RECHAZAR AVISOS (PANTALLA OPERARIOS)
// ==========================================
app.get("/public/assignment/:token", async (req, res) => {
try {
const { token } = req.params;
const q = await pool.query(`
SELECT ap.*, s.raw_data, s.service_ref, s.is_urgent, u.full_name
FROM assignment_pings ap
JOIN scraped_services s ON ap.scraped_id = s.id
JOIN users u ON ap.user_id = u.id
WHERE ap.token = $1
`, [token]);
if (q.rowCount === 0) return res.status(404).json({ ok: false, error: "Asignación no encontrada" });
const assign = q.rows[0];
if (assign.status !== 'pending') {
return res.json({ ok: false, error: "Este aviso ya fue " + (assign.status === 'accepted' ? 'aceptado' : 'rechazado o ha caducado.') });
}
if (new Date() > new Date(assign.expires_at)) {
await pool.query("UPDATE assignment_pings SET status = 'expired' WHERE id = $1", [assign.id]);
return res.json({ ok: false, error: "El tiempo para aceptar este aviso ha caducado." });
}
// Parsear datos para enviarlos bonitos a la pantalla de aceptar.html
const raw = assign.raw_data || {};
const serviceData = {
"Gremio": raw["Gremio"] || raw.guild_name || "Servicio General",
"Expediente": assign.service_ref || raw["Referencia"] || "Sin Ref",
"Población": raw["Población"] || raw["POBLACION-PROVINCIA"] || "",
"Código Postal": raw["Código Postal"] || "",
"Descripción": raw["Descripción"] || raw["DESCRIPCION"] || "Revisar en el lugar."
};
res.json({ ok: true, service: serviceData, debug: { hora_limite_bd: assign.expires_at } });
} catch (e) {
console.error("Error al obtener asignación:", e);
res.status(500).json({ ok: false });
}
});
app.post("/public/assignment/respond", async (req, res) => {
const client = await pool.connect();
try {
const { token, action } = req.body;
if (!token || !action) return res.status(400).json({ ok: false, error: "Faltan datos" });
await client.query('BEGIN');
// 1. Validar ping bloqueando la fila
const qPing = await client.query("SELECT * FROM assignment_pings WHERE token = $1 AND status = 'pending' FOR UPDATE", [token]);
if (qPing.rowCount === 0) {
await client.query('ROLLBACK');
return res.status(400).json({ ok: false, error: "Este aviso ya no está disponible." });
}
const ping = qPing.rows[0];
if (action === 'reject') {
await client.query("UPDATE assignment_pings SET status = 'rejected' WHERE id = $1", [ping.id]);
await client.query('COMMIT');
// --- INICIO TRAZABILIDAD ---
await registrarMovimiento(ping.scraped_id, ping.user_id, "Servicio Aceptado", "El operario ha aceptado el aviso desde su teléfono móvil.");
// --- FIN TRAZABILIDAD ---
return res.json({ ok: true });
}
if (action === 'accept') {
// 2. Marcar ping como aceptado
await client.query("UPDATE assignment_pings SET status = 'accepted' WHERE id = $1", [ping.id]);
// 3. Sacar datos del servicio
const sQ = await client.query("SELECT owner_id, raw_data FROM scraped_services WHERE id = $1", [ping.scraped_id]);
const ownerId = sQ.rows[0].owner_id;
let rawData = sQ.rows[0].raw_data || {};
// 4. Poner el estado en "Asignado" si existe
const statusQ = await client.query("SELECT id, name FROM service_statuses WHERE owner_id=$1 AND name ILIKE '%asignado%' LIMIT 1", [ownerId]);
const statusAsignadoId = statusQ.rows[0]?.id || null;
if (statusAsignadoId) {
rawData.status_operativo = statusAsignadoId;
}
// 5. Actualizar el expediente oficial con el técnico
await client.query(`
UPDATE scraped_services
SET assigned_to = $1, automation_status = 'completed', raw_data = $2
WHERE id = $3
`, [ping.user_id, JSON.stringify(rawData), ping.scraped_id]);
await client.query('COMMIT');
// 6. ¡Magia! Le enviamos un WhatsApp automático al cliente diciendo que ya tiene técnico
triggerWhatsAppEvent(ownerId, ping.scraped_id, 'wa_evt_assigned').catch(e => console.error(e));
return res.json({ ok: true });
}
await client.query('ROLLBACK');
res.status(400).json({ ok: false, error: "Acción no válida" });
} catch (e) {
await client.query('ROLLBACK');
console.error("Error respondiendo asignación:", e);
res.status(500).json({ ok: false, error: "Error interno del servidor" });
} finally {
client.release();
}
});
app.post("/public/assignment/:token/reject", async (req, res) => {
try {
const { token } = req.params;
// Si lo rechaza, el reloj (setInterval) se encargará de buscar al siguiente técnico libre al instante
await pool.query("UPDATE assignment_pings SET status = 'rejected' WHERE token = $1 AND status = 'pending'", [token]);
res.json({ ok: true });
} catch (e) {
console.error("Error rechazando asignación:", e);
res.status(500).json({ ok: false });
}
});
// ==========================================
// 📄 MOTOR DE PRESUPUESTOS Y CATÁLOGO DE ARTÍCULOS
// ==========================================
pool.query(`
CREATE TABLE IF NOT EXISTS articles (
id SERIAL PRIMARY KEY,
owner_id INT REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
price DECIMAL(10,2) DEFAULT 0.00,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS budgets (
id SERIAL PRIMARY KEY,
owner_id INT REFERENCES users(id) ON DELETE CASCADE,
client_phone TEXT,
client_name TEXT,
client_address TEXT,
items JSONB DEFAULT '[]',
subtotal DECIMAL(10,2) DEFAULT 0.00,
tax DECIMAL(10,2) DEFAULT 0.00,
total DECIMAL(10,2) DEFAULT 0.00,
status TEXT DEFAULT 'pending', -- pending, accepted, rejected, converted
created_at TIMESTAMP DEFAULT NOW()
);
`).catch(console.error);
// --- CATÁLOGO DE ARTÍCULOS ---
app.get("/articles", authMiddleware, async (req, res) => {
try {
const q = await pool.query("SELECT * FROM articles WHERE owner_id=$1 ORDER BY name ASC", [req.user.accountId]);
res.json({ ok: true, articles: q.rows });
} catch(e) { res.status(500).json({ok: false}); }
});
app.post("/articles", authMiddleware, async (req, res) => {
try {
const { name, price } = req.body;
await pool.query("INSERT INTO articles (owner_id, name, price) VALUES ($1, $2, $3)", [req.user.accountId, name, price]);
res.json({ ok: true });
} catch(e) { res.status(500).json({ok: false}); }
});
app.put("/articles/:id", authMiddleware, async (req, res) => {
try {
const { name, price } = req.body;
await pool.query("UPDATE articles SET name=$1, price=$2 WHERE id=$3 AND owner_id=$4", [name, price, req.params.id, req.user.accountId]);
res.json({ ok: true });
} catch(e) { res.status(500).json({ok: false}); }
});
// --- PRESUPUESTOS ---
app.get("/budgets", authMiddleware, async (req, res) => {
try {
// MAGIA: Cruzamos los datos con scraped_services y service_statuses para saber
// exactamente en qué estado se encuentra el servicio que nació de este presupuesto.
const q = await pool.query(`
SELECT b.*,
s.status as linked_service_status,
st.name as linked_service_status_name
FROM budgets b
LEFT JOIN scraped_services s ON s.service_ref = 'PRE-' || b.id AND s.owner_id = b.owner_id
LEFT JOIN service_statuses st ON st.id::text = (s.raw_data->>'status_operativo')::text
WHERE b.owner_id=$1
ORDER BY b.created_at DESC
`, [req.user.accountId]);
res.json({ ok: true, budgets: q.rows });
} catch(e) { res.status(500).json({ok: false}); }
});
// Borrar Presupuesto
app.delete("/budgets/:id", authMiddleware, async (req, res) => {
try {
const q = await pool.query("SELECT status FROM budgets WHERE id=$1 AND owner_id=$2", [req.params.id, req.user.accountId]);
if (q.rowCount === 0) return res.status(404).json({ok: false, error: "No encontrado"});
const status = q.rows[0].status;
// Comprobamos si el servicio vinculado está anulado
const sq = await pool.query(`
SELECT st.name as status_name
FROM scraped_services s
LEFT JOIN service_statuses st ON st.id::text = (s.raw_data->>'status_operativo')::text
WHERE s.service_ref = $1 AND s.owner_id = $2
`, [`PRE-${req.params.id}`, req.user.accountId]);
let isAnulado = false;
if (sq.rowCount > 0 && sq.rows[0].status_name && sq.rows[0].status_name.toLowerCase().includes('anulado')) {
isAnulado = true;
}
// REGLA DE NEGOCIO: No se puede borrar si está aceptado o convertido (Y NO ESTÁ ANULADO)
if ((status === 'accepted' || status === 'converted') && !isAnulado) {
return res.status(400).json({ok: false, error: "Para poder borrar un presupuesto, el servicio primero debe estar anulado."});
}
await pool.query("DELETE FROM budgets WHERE id=$1 AND owner_id=$2", [req.params.id, req.user.accountId]);
res.json({ ok: true });
} catch(e) {
res.status(500).json({ok: false, error: "Error interno"});
}
});
// Convertir Presupuesto en Servicio Activo (CON SOPORTE RED INTERNA Y TRAZABILIDAD)
app.post("/budgets/:id/convert", authMiddleware, async (req, res) => {
try {
const { date, time, guild_id, assigned_to, use_automation } = req.body;
const bq = await pool.query("SELECT * FROM budgets WHERE id=$1 AND owner_id=$2", [req.params.id, req.user.accountId]);
if (bq.rowCount === 0) return res.status(404).json({ok: false});
const budget = bq.rows[0];
// 1. Montamos el Raw Data para el servicio
const rawData = {
"Nombre Cliente": budget.client_name,
"Teléfono": budget.client_phone,
"Dirección": budget.client_address,
"Compañía": "Particular",
"Descripción": "PRESUPUESTO ACEPTADO.\n" + budget.items.map(i => `${i.qty}x ${i.concept}`).join("\n"),
"guild_id": guild_id || null,
"assigned_to": assigned_to || null,
"scheduled_date": date || "",
"scheduled_time": time || ""
};
// 2. Insertamos en el Panel Operativo (Buzón) empezando en manual
const insertSvc = await pool.query(
"INSERT INTO scraped_services (owner_id, provider, service_ref, status, automation_status, assigned_to, raw_data) VALUES ($1, 'particular', $2, 'pending', 'manual', $3, $4) RETURNING id",
[
req.user.accountId,
`PRE-${budget.id}`,
assigned_to || null,
JSON.stringify(rawData)
]
);
const newServiceId = insertSvc.rows[0].id;
// 3. Marcamos presupuesto como convertido y le enlazamos la ficha financiera por el total
await pool.query("UPDATE budgets SET status='converted' WHERE id=$1", [budget.id]);
await pool.query(
"INSERT INTO service_financials (scraped_id, amount, payment_method) VALUES ($1, $2, 'Pendiente')",
[newServiceId, budget.total]
);
// 4. Si pide automatización, la disparamos internamente llamando a nuestra propia IP (127.0.0.1)
if (use_automation && guild_id) {
const cpMatch = budget.client_address ? budget.client_address.match(/\b\d{5}\b/) : null;
const cp = cpMatch ? cpMatch[0] : "00000";
const port = process.env.PORT || 3000;
const autoUrl = `http://127.0.0.1:${port}/providers/automate/${newServiceId}`;
fetch(autoUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': req.headers.authorization
},
body: JSON.stringify({ guild_id, cp, useDelay: false })
}).catch(e => console.error("Error lanzando automatización interna:", e));
if (budget.client_phone) {
const msg = `✅ *PRESUPUESTO ACEPTADO*\n\nHola ${budget.client_name}, confirmamos la aceptación del presupuesto por un total de *${budget.total}€*.\n\nEn breve un técnico se pondrá en contacto contigo para agendar la cita. ¡Gracias por confiar en nosotros!`;
sendWhatsAppAuto(budget.client_phone, msg, `cliente_${req.user.accountId}`, false).catch(console.error);
}
}
else if (budget.client_phone && date && time) {
// Asignación directa a un técnico con fecha y hora
const [y, m, d] = date.split('-');
const msg = `✅ *PRESUPUESTO ACEPTADO*\n\nHola ${budget.client_name}, confirmamos la aceptación del presupuesto por un total de *${budget.total}€*.\n\nEl servicio ha sido agendado para el *${d}/${m}/${y} a las ${time}*. ¡Gracias por confiar en nosotros!`;
sendWhatsAppAuto(budget.client_phone, msg, `cliente_${req.user.accountId}`, false).catch(console.error);
if (assigned_to) {
const statusQ = await pool.query("SELECT id FROM service_statuses WHERE owner_id=$1 AND name ILIKE '%asignado%' LIMIT 1", [req.user.accountId]);
if (statusQ.rowCount > 0) {
rawData.status_operativo = statusQ.rows[0].id;
await pool.query("UPDATE scraped_services SET raw_data = $1 WHERE id = $2", [JSON.stringify(rawData), newServiceId]);
}
}
}
// --- INICIO TRAZABILIDAD ---
await registrarMovimiento(newServiceId, req.user.sub, "Aviso Creado", `Servicio generado a raíz del presupuesto aceptado #PRE-${budget.id}.`);
// --- FIN TRAZABILIDAD ---
res.json({ ok: true });
} catch(e) {
console.error("Error convirtiendo presupuesto:", e);
res.status(500).json({ok: false});
}
});
// ==========================================
// 💰 MOTOR FINANCIERO Y CONTABILIDAD (PREPARADO PARA ROBOT PDF)
// ==========================================
// Creamos la tabla financiera preparada para facturas y robots
pool.query(`
CREATE TABLE IF NOT EXISTS service_financials (
id SERIAL PRIMARY KEY,
scraped_id INT REFERENCES scraped_services(id) ON DELETE CASCADE UNIQUE,
amount DECIMAL(10,2) DEFAULT 0.00,
payment_method TEXT,
is_paid BOOLEAN DEFAULT false,
invoice_ref TEXT,
pdf_raw_data JSONB,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
`).catch(console.error);
// Obtener toda la contabilidad
app.get("/financials", authMiddleware, async (req, res) => {
try {
// 1. Truco de magia: Si hay servicios que no tienen ficha financiera, se la creamos automáticamente.
// Si tiene compañía asignada, le ponemos "Cobro Banco" por defecto. Si no, "Pendiente".
await pool.query(`
INSERT INTO service_financials (scraped_id, payment_method)
SELECT id,
CASE WHEN raw_data->>'Compañía' IS NOT NULL AND raw_data->>'Compañía' != '' AND raw_data->>'Compañía' != 'Particular'
THEN 'Cobro Banco'
ELSE 'Pendiente' END
FROM scraped_services
WHERE owner_id = $1 AND id NOT IN (SELECT scraped_id FROM service_financials)
`, [req.user.accountId]);
// 2. Devolvemos la lista cruzando las finanzas con los datos del servicio
const q = await pool.query(`
SELECT f.*, s.service_ref, s.raw_data, s.status
FROM service_financials f
JOIN scraped_services s ON f.scraped_id = s.id
WHERE s.owner_id = $1
ORDER BY f.updated_at DESC
`, [req.user.accountId]);
res.json({ ok: true, financials: q.rows });
} catch(e) {
console.error("Error financiero:", e);
res.status(500).json({ ok: false });
}
});
// Guardar un cobro/pago
app.put("/financials/:id", authMiddleware, async (req, res) => {
try {
const { amount, payment_method } = req.body;
const parsedAmount = parseFloat(amount) || 0;
// NUEVA REGLA: Si el método de pago es "Pendiente", NO está pagado,
// independientemente del importe que tenga apuntado (Ej: Presupuestos).
const isPaid = payment_method !== 'Pendiente';
await pool.query(`
UPDATE service_financials
SET amount = $1, payment_method = $2, is_paid = $3, updated_at = NOW()
WHERE scraped_id = $4
`, [parsedAmount, payment_method, isPaid, req.params.id]);
// LOG AUTOMÁTICO DE TRAZABILIDAD
const userQ = await pool.query("SELECT full_name FROM users WHERE id=$1", [req.user.sub]);
const userName = userQ.rows[0]?.full_name || "Sistema";
await pool.query(
"INSERT INTO scraped_service_logs (scraped_id, user_name, action, details) VALUES ($1, $2, $3, $4)",
[req.params.id, userName, "Cobro Actualizado", `Importe: ${parsedAmount}€ | Método: ${payment_method}`]
);
res.json({ ok: true, is_paid: isPaid });
} catch(e) {
console.error(e);
res.status(500).json({ ok: false });
}
});
// ==========================================
// 📖 MOTOR DE TRAZABILIDAD (LOGS)
// ==========================================
// Creamos la tabla automáticamente si no existe
pool.query(`
CREATE TABLE IF NOT EXISTS scraped_service_logs (
id SERIAL PRIMARY KEY,
scraped_id INT REFERENCES scraped_services(id) ON DELETE CASCADE,
user_name TEXT,
action TEXT NOT NULL,
details TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
`).catch(console.error);
// Ruta para GUARDAR un evento en el log
app.post("/services/:id/log", authMiddleware, async (req, res) => {
try {
const { action, details } = req.body;
const serviceId = req.params.id;
// Verificamos propiedad antes de insertar
const check = await pool.query("SELECT id FROM scraped_services WHERE id=$1 AND owner_id=$2", [serviceId, req.user.accountId]);
if (check.rowCount === 0) return res.status(403).json({ ok: false, error: "No autorizado" });
const userQ = await pool.query("SELECT full_name FROM users WHERE id=$1", [req.user.sub]);
const userName = userQ.rows[0]?.full_name || "Sistema";
await pool.query(
"INSERT INTO scraped_service_logs (scraped_id, user_name, action, details) VALUES ($1, $2, $3, $4)",
[serviceId, userName, action, details || ""]
);
res.json({ ok: true });
} catch(e) {
res.status(500).json({ ok: false });
}
});
// Ruta para LEER el historial de un servicio
app.get("/services/:id/logs", authMiddleware, async (req, res) => {
try {
// Cruce con la tabla principal para verificar el dueño (owner_id)
const q = await pool.query(`
SELECT l.* FROM scraped_service_logs l
JOIN scraped_services s ON l.scraped_id = s.id
WHERE l.scraped_id = $1
AND s.owner_id = $2
ORDER BY l.created_at DESC
`, [req.params.id, req.user.accountId]);
res.json({ ok: true, logs: q.rows });
} catch(e) {
res.status(500).json({ ok: false });
}
});
// ==========================================
// 📄 MOTOR DE PRESUPUESTOS Y CATÁLOGO DE ARTÍCULOS
// ==========================================
pool.query(`
CREATE TABLE IF NOT EXISTS articles (
id SERIAL PRIMARY KEY,
owner_id INT REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
price DECIMAL(10,2) DEFAULT 0.00,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS budgets (
id SERIAL PRIMARY KEY,
owner_id INT REFERENCES users(id) ON DELETE CASCADE,
client_phone TEXT,
client_name TEXT,
client_address TEXT,
items JSONB DEFAULT '[]',
subtotal DECIMAL(10,2) DEFAULT 0.00,
tax DECIMAL(10,2) DEFAULT 0.00,
total DECIMAL(10,2) DEFAULT 0.00,
status TEXT DEFAULT 'pending',
created_at TIMESTAMP DEFAULT NOW()
);
`).catch(console.error);
// --- CATÁLOGO DE ARTÍCULOS ---
app.get("/articles", authMiddleware, async (req, res) => {
try {
const q = await pool.query("SELECT * FROM articles WHERE owner_id=$1 ORDER BY name ASC", [req.user.accountId]);
res.json({ ok: true, articles: q.rows });
} catch(e) { res.status(500).json({ok: false}); }
});
app.post("/articles", authMiddleware, async (req, res) => {
try {
const { name, price } = req.body;
await pool.query("INSERT INTO articles (owner_id, name, price) VALUES ($1, $2, $3)", [req.user.accountId, name, price]);
res.json({ ok: true });
} catch(e) { res.status(500).json({ok: false}); }
});
app.put("/articles/:id", authMiddleware, async (req, res) => {
try {
const { name, price } = req.body;
await pool.query("UPDATE articles SET name=$1, price=$2 WHERE id=$3 AND owner_id=$4", [name, price, req.params.id, req.user.accountId]);
res.json({ ok: true });
} catch(e) { res.status(500).json({ok: false}); }
});
// --- PRESUPUESTOS ---
app.get("/budgets", authMiddleware, async (req, res) => {
try {
const q = await pool.query(`
SELECT b.*,
s.status as linked_service_status,
st.name as linked_service_status_name
FROM budgets b
LEFT JOIN scraped_services s ON s.service_ref = 'PRE-' || b.id AND s.owner_id = b.owner_id
LEFT JOIN service_statuses st ON st.id::text = (s.raw_data->>'status_operativo')::text
WHERE b.owner_id=$1
ORDER BY b.created_at DESC
`, [req.user.accountId]);
res.json({ ok: true, budgets: q.rows });
} catch(e) { res.status(500).json({ok: false}); }
});
app.post("/budgets", authMiddleware, async (req, res) => {
try {
const { client_phone, client_name, client_address, items, subtotal, tax, total } = req.body;
await pool.query(
"INSERT INTO budgets (owner_id, client_phone, client_name, client_address, items, subtotal, tax, total) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
[req.user.accountId, client_phone, client_name, client_address, JSON.stringify(items), subtotal, tax, total]
);
res.json({ ok: true });
} catch(e) { res.status(500).json({ok: false}); }
});
app.patch("/budgets/:id/status", authMiddleware, async (req, res) => {
try {
await pool.query("UPDATE budgets SET status=$1 WHERE id=$2 AND owner_id=$3", [req.body.status, req.params.id, req.user.accountId]);
res.json({ ok: true });
} catch(e) { res.status(500).json({ok: false}); }
});
app.delete("/budgets/:id", authMiddleware, async (req, res) => {
try {
const q = await pool.query("SELECT status FROM budgets WHERE id=$1 AND owner_id=$2", [req.params.id, req.user.accountId]);
if (q.rowCount === 0) return res.status(404).json({ok: false, error: "No encontrado"});
const status = q.rows[0].status;
const sq = await pool.query(`
SELECT st.name as status_name
FROM scraped_services s
LEFT JOIN service_statuses st ON st.id::text = (s.raw_data->>'status_operativo')::text
WHERE s.service_ref = $1 AND s.owner_id = $2
`, [`PRE-${req.params.id}`, req.user.accountId]);
let isAnulado = false;
if (sq.rowCount > 0 && sq.rows[0].status_name && sq.rows[0].status_name.toLowerCase().includes('anulado')) {
isAnulado = true;
}
if ((status === 'accepted' || status === 'converted') && !isAnulado) {
return res.status(400).json({ok: false, error: "Para poder borrar un presupuesto, el servicio primero debe estar anulado."});
}
await pool.query("DELETE FROM budgets WHERE id=$1 AND owner_id=$2", [req.params.id, req.user.accountId]);
res.json({ ok: true });
} catch(e) {
res.status(500).json({ok: false, error: "Error interno"});
}
});
// ==========================================
// 🤖 API COLA DEL ROBOT (REEMPLAZO FIREBASE)
// ==========================================
// 1. Enviar una orden al Robot
app.post("/robot/queue", authMiddleware, async (req, res) => {
try {
const { service_number, new_status, appointment_date, observation, inform_client } = req.body;
const q = await pool.query(`
INSERT INTO robot_queue (owner_id, service_number, new_status, appointment_date, observation, inform_client)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id
`, [req.user.accountId, service_number, new_status, appointment_date || "", observation || "", inform_client || false]);
res.json({ ok: true, jobId: q.rows[0].id });
} catch (e) {
console.error("Error guardando en cola:", e);
res.status(500).json({ ok: false, error: e.message });
}
});
// 2. Leer el estado de la cola (Monitor)
app.get("/robot/queue", authMiddleware, async (req, res) => {
try {
const q = await pool.query(`
SELECT id, service_number, status, error_msg, created_at
FROM robot_queue
WHERE owner_id = $1
ORDER BY created_at DESC
LIMIT 20
`, [req.user.accountId]);
res.json({ ok: true, jobs: q.rows });
} catch (e) {
res.status(500).json({ ok: false });
}
});
// ==========================================
// 🔐 RECUPERAR CREDENCIALES
// ==========================================
app.get("/providers/credentials", authMiddleware, async (req, res) => {
try {
const q = await pool.query("SELECT provider, username, last_sync, status FROM provider_credentials WHERE owner_id=$1", [req.user.accountId]);
res.json({ ok: true, credentials: q.rows });
} catch (e) {
res.status(500).json({ ok: false });
}
});
// 3. Obtener resumen de mensajes nuevos para el operario (Globo de Notificación)
app.get("/worker/notifications", authMiddleware, async (req, res) => {
try {
// Buscamos expedientes donde la fecha del último mensaje de ADMIN sea más reciente que la última lectura del operario
const q = await pool.query(`
SELECT DISTINCT s.id
FROM scraped_services s
JOIN service_communications c ON s.id = c.scraped_id
WHERE s.assigned_to = $1
AND c.sender_role IN ('admin', 'superadmin')
AND c.is_internal = FALSE
AND c.created_at > COALESCE(s.last_chat_read_worker, '2000-01-01')
`, [req.user.sub]);
res.json({ ok: true, unreadCount: q.rowCount, serviceIds: q.rows.map(r => r.id) });
} catch (e) {
console.error("Error notificaciones:", e);
res.status(500).json({ ok: false });
}
});
// 4. Marcar chat como leído por el operario
app.post("/services/:id/chat/read", authMiddleware, async (req, res) => {
try {
await pool.query(
"UPDATE scraped_services SET last_chat_read_worker = NOW() WHERE id = $1 AND assigned_to = $2",
[req.params.id, req.user.sub]
);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false });
}
});
// ==========================================
// 💬 CHAT Y COMUNICACIÓN INTERNA (TIPO iTRAMIT)
// ==========================================
app.get("/services/:id/chat", authMiddleware, async (req, res) => {
try {
const { id } = req.params;
const isOperario = req.user.role === 'operario';
let query = `
SELECT id, sender_id, sender_name, sender_role, message, is_internal, created_at
FROM service_communications
WHERE scraped_id = $1 AND owner_id = $2
`;
if (isOperario) query += ` AND is_internal = FALSE`;
query += ` ORDER BY created_at ASC`;
const q = await pool.query(query, [id, req.user.accountId]);
res.json({ ok: true, messages: q.rows });
} catch (e) {
console.error("Error cargando chat:", e);
res.status(500).json({ ok: false });
}
});
// 2. Enviar un nuevo mensaje (Oficina u Operario) CON AVISO WHATSAPP Y TRAZABILIDAD
app.post("/services/:id/chat", authMiddleware, async (req, res) => {
try {
const { id } = req.params;
const { message, is_internal } = req.body;
if (!message || message.trim() === "") return res.status(400).json({ ok: false, error: "Vacío" });
const isOperario = req.user.role === 'operario';
const finalIsInternal = isOperario ? false : (is_internal || false);
const userQ = await pool.query("SELECT full_name, role FROM users WHERE id=$1", [req.user.sub]);
const senderName = userQ.rows[0]?.full_name || "Usuario";
const senderRole = userQ.rows[0]?.role || "operario";
// 1. Guardar el mensaje en la base de datos (Chat)
await pool.query(`
INSERT INTO service_communications
(scraped_id, owner_id, sender_id, sender_name, sender_role, message, is_internal)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`, [id, req.user.accountId, req.user.sub, senderName, senderRole, message.trim(), finalIsInternal]);
res.json({ ok: true });
// 2. Lógica de Notificación y Trazabilidad
if (!isOperario && !finalIsInternal) {
const svcQ = await pool.query("SELECT assigned_to, service_ref FROM scraped_services WHERE id=$1", [id]);
if (svcQ.rowCount > 0 && svcQ.rows[0].assigned_to) {
const workerId = svcQ.rows[0].assigned_to;
const ref = svcQ.rows[0].service_ref || id;
const wQ = await pool.query("SELECT phone, full_name FROM users WHERE id=$1", [workerId]);
if (wQ.rowCount > 0 && wQ.rows[0].phone) {
const workerPhone = wQ.rows[0].phone;
const workerName = wQ.rows[0].full_name;
const msgWa = `💬 *NUEVO MENSAJE DE LA OFICINA*\nExpediente: *#${ref}*\n\n"${message.trim()}"\n\n_Entra en tu App para contestar._`;
// A) Disparar el WhatsApp
const waExito = await sendWhatsAppAuto(workerPhone, msgWa, `cliente_${req.user.accountId}`, false);
// B) 🟢 DEJAR HUELLA EN EL LOG (Trazabilidad)
const logDetalle = waExito
? `Aviso enviado por WhatsApp a ${workerName} (${workerPhone}).`
: `Intento de aviso por WhatsApp a ${workerName} fallido (revisar conexión Evolution).`;
await pool.query(
"INSERT INTO scraped_service_logs (scraped_id, user_name, action, details) VALUES ($1, $2, $3, $4)",
[id, "Sistema (Chat)", "Notificación Enviada", logDetalle]
);
}
}
}
} catch (e) {
console.error("Error enviando mensaje y log:", e);
if (!res.headersSent) res.status(500).json({ ok: false });
}
});
// 🤖 WEBHOOK CON ESCUDO DE INTERVENCIÓN HUMANA
app.post("/webhook/evolution", async (req, res) => {
try {
const data = req.body;
if (data.event !== "messages.upsert" || data.data.key.fromMe) return res.sendStatus(200);
const remoteJid = data.data.key.remoteJid;
const telefonoCliente = remoteJid.split("@")[0];
const mensajeTexto = data.data.message?.conversation || data.data.message?.extendedTextMessage?.text;
const instanceName = data.instance;
if (!mensajeTexto) return res.sendStatus(200);
const ownerId = instanceName.split("_")[1];
const cleanPhone = telefonoCliente.slice(-9);
// 1. BUSCAMOS EL SINIESTRO
const svcQ = await pool.query(`
SELECT s.id, s.service_ref, u.full_name as worker_name,
st.name as status_name, s.raw_data->>'scheduled_date' as cita
FROM scraped_services s
LEFT JOIN users u ON s.assigned_to = u.id
LEFT JOIN service_statuses st ON (s.raw_data->>'status_operativo')::text = st.id::text
WHERE s.owner_id = $1 AND s.status != 'archived' AND s.raw_data::text ILIKE $2
ORDER BY s.created_at DESC LIMIT 1
`, [ownerId, `%${cleanPhone}%`]);
if (svcQ.rowCount > 0) {
const service = svcQ.rows[0];
// 🛡️ 2. VERIFICAR INTERVENCIÓN HUMANA (NUEVO)
// Miramos si el último mensaje fue de un humano en las últimas 2 horas
const checkHumanQ = await pool.query(`
SELECT sender_role, created_at
FROM service_communications
WHERE scraped_id = $1
ORDER BY created_at DESC LIMIT 1
`, [service.id]);
if (checkHumanQ.rowCount > 0) {
const lastMsg = checkHumanQ.rows[0];
const diffMinutos = (new Date() - new Date(lastMsg.created_at)) / (1000 * 60);
// Si el último que habló fue admin/operario y hace menos de 120 min, la IA no responde
if (['admin', 'superadmin', 'operario'].includes(lastMsg.sender_role) && diffMinutos < 120) {
console.log(`🤫 [IA Silenciada] Un humano ha intervenido hace ${Math.round(diffMinutos)} min en el exp #${service.service_ref}`);
return res.sendStatus(200);
}
}
// 3. SI NO HAY HUMANO RECIENTE, LLAMAMOS A LA IA
const respuestaIA = await procesarConIA(ownerId, mensajeTexto, {
ref: service.service_ref,
estado: service.status_name || "En proceso",
operario: service.worker_name,
cita: service.cita
});
if (respuestaIA) {
// 1. --- MAGIA: DETECTAR SI LA IA RECOGIÓ UNA PROPUESTA DE CITA ---
const matchPropuesta = respuestaIA.match(/\[PROPUESTA:(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2})\]/);
if (matchPropuesta) {
const fechaSugerida = matchPropuesta[1];
const horaSugerida = matchPropuesta[2];
console.log(`📅 PROPUESTA RECIBIDA: ${fechaSugerida} a las ${horaSugerida} para exp #${service.service_ref}`);
// Actualizamos el siniestro indicando que hay una propuesta pendiente (sin confirmar cita final)
await pool.query(`
UPDATE scraped_services
SET raw_data = raw_data || jsonb_build_object(
'propuesta_cliente_fecha', $1,
'propuesta_cliente_hora', $2,
'status_ia', 'esperando_confirmacion_humana'
)
WHERE id = $3
`, [fechaSugerida, horaSugerida, service.id]);
// Registramos el movimiento para que salga en el historial del servicio
await registrarMovimiento(
service.id,
null,
"Propuesta de Cita",
`El cliente solicita cita para el ${fechaSugerida} a las ${horaSugerida}. Pendiente de confirmación por el operario.`
);
}
// 2. --- LIMPIEZA Y ENVÍO ---
// Quitamos el código [PROPUESTA:...] del texto para que el cliente no lo vea
const textoLimpio = respuestaIA.replace(/\[PROPUESTA:.*?\]/, "").trim();
await sendWhatsAppAuto(telefonoCliente, textoLimpio, instanceName, true);
// 3. --- REGISTRO DEL CHAT ---
await pool.query(`INSERT INTO service_communications (scraped_id, owner_id, sender_name, sender_role, message) VALUES ($1, $2, $3, $4, $5)`,
[service.id, ownerId, "Asistente IA", "ia", textoLimpio]);
}
}
res.sendStatus(200);
} catch (e) {
console.error("❌ [WEBHOOK ERROR]:", e.message);
if (!res.headersSent) res.sendStatus(500);
}
});
// ==========================================
// 🕒 EL RELOJ DEL SISTEMA (Ejecutar cada minuto)
// ==========================================
setInterval(async () => {
const client = await pool.connect(); // <-- Conectamos de forma segura
try {
const expiredPings = await client.query(`
SELECT ap.id, ap.scraped_id, ap.user_id, s.owner_id, s.raw_data
FROM assignment_pings ap
JOIN scraped_services s ON ap.scraped_id = s.id
WHERE ap.status = 'pending'
AND EXTRACT(EPOCH FROM (ap.expires_at - CURRENT_TIMESTAMP)) <= 0
AND s.automation_status = 'in_progress'
`);
for (const ping of expiredPings.rows) {
await client.query("UPDATE assignment_pings SET status = 'expired' WHERE id = $1", [ping.id]);
const nextWorkerQ = await client.query(`
SELECT u.id, u.phone, u.full_name
FROM users u
JOIN user_guilds ug ON u.id = ug.user_id
WHERE u.owner_id = $1 AND u.status = 'active'
AND u.id NOT IN (SELECT user_id FROM assignment_pings WHERE scraped_id = $2)
LIMIT 1
`, [ping.owner_id, ping.scraped_id]);
if (nextWorkerQ.rowCount > 0) {
const nextW = nextWorkerQ.rows[0];
const newToken = crypto.randomBytes(16).toString('hex');
await client.query(`INSERT INTO assignment_pings (scraped_id, user_id, token, expires_at) VALUES ($1, $2, $3, CURRENT_TIMESTAMP + INTERVAL '5 minutes')`, [ping.scraped_id, nextW.id, newToken]);
const mensaje = `🛠️ *SERVICIO DISPONIBLE*\nEl anterior compañero no respondió. Es tu turno:\n🔗 https://integrarepara.es/aceptar.html?t=${newToken}`;
const instanceName = `cliente_${ping.owner_id}`;
sendWhatsAppAuto(nextW.phone, mensaje, instanceName).catch(console.error);
} else {
await client.query("UPDATE scraped_services SET automation_status = 'failed' WHERE id = $1", [ping.scraped_id]);
}
}
} catch (e) {
console.error("Reloj:", e);
} finally {
client.release(); // <-- Liberamos la conexión SIEMPRE
}
}, 60000);
const port = process.env.PORT || 3000;
autoUpdateDB().then(() => { app.listen(port, "0.0.0.0", () => console.log(`🚀 Server OK en puerto ${port}`)); });