Files
api/server.js
2026-02-25 22:29:07 +00:00

1821 lines
92 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import express from "express";
import cors from "cors";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import pg from "pg";
import crypto from "crypto";
const { Pool } = pg;
const app = express();
app.use(cors());
// Ampliamos el límite a 10MB para permitir la subida de logotipos en Base64
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ limit: '10mb', extended: true }));
// VARIABLES DE ENTORNO
const {
DATABASE_URL,
JWT_SECRET,
EVOLUTION_BASE_URL,
EVOLUTION_API_KEY,
EVOLUTION_INSTANCE,
} = process.env;
// --- DIAGNÓSTICO DE INICIO ---
console.log("------------------------------------------------");
console.log("🚀 VERSIÓN COMPLETA - CON AUTOMATISMOS REALES");
console.log("------------------------------------------------");
if (!DATABASE_URL) console.error("❌ FALTA: DATABASE_URL");
if (!JWT_SECRET) console.error("❌ FALTA: JWT_SECRET");
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()
);
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()
);
`);
// PARCHE DE ACTUALIZACIÓN
await client.query(`
DO $$ BEGIN
-- 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)); }
// 🛡️ 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.post("/auth/register", async (req, res) => {
const client = await pool.connect();
try {
const { fullName, email, phone, password } = req.body;
if (!email || !password) return res.status(400).json({ ok: false, error: "Faltan datos requeridos" });
const hash = await bcrypt.hash(password, 10);
await client.query('BEGIN');
const insert = await client.query(
"INSERT INTO users (full_name, email, phone, password_hash, role, status) VALUES ($1, $2, $3, $4, 'admin', 'active') RETURNING id",
[fullName || 'Admin', email, normalizePhone(phone), hash]
);
const newId = insert.rows[0].id;
// Al ser registro, él es su propio dueño (owner_id = id)
await client.query("UPDATE users SET owner_id = id WHERE id = $1", [newId]);
await client.query('COMMIT');
res.json({ ok: true, message: "Usuario creado. Ya puedes iniciar sesión." });
} catch (e) {
await client.query('ROLLBACK');
console.error("Register error:", e);
res.status(400).json({ ok: false, error: "El correo o teléfono ya están en uso." });
} finally {
client.release();
}
});
app.get("/auth/me", authMiddleware, async (req, res) => {
try {
const q = await pool.query("SELECT id, full_name, email, role, 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 });
}
});
// --- WHATSAPP UTILS ---
async function sendWhatsAppCode(phone, code) {
if (!EVOLUTION_BASE_URL || !EVOLUTION_API_KEY || !EVOLUTION_INSTANCE) { console.error("❌ Faltan datos WhatsApp"); return; }
try {
await fetch(`${EVOLUTION_BASE_URL.replace(/\/$/, "")}/message/sendText/${EVOLUTION_INSTANCE}`, {
method: "POST", headers: { "Content-Type": "application/json", "apikey": EVOLUTION_API_KEY },
body: JSON.stringify({ number: phone.replace("+", ""), text: `🔐 Código: *${code}*` })
});
} catch (e) { console.error("Error envío WA:", e.message); }
}
async function sendWhatsAppAuto(originalPhone, text, instanceName, useDelay = true) {
if (!EVOLUTION_BASE_URL || !EVOLUTION_API_KEY || !instanceName) return false;
const TEST_PHONE = "34667248132"; // TU NÚMERO PROTEGIDO
const phone = TEST_PHONE;
try {
console.log(`\n📲 [MODO PRUEBA] WA a ${originalPhone} redirigido a -> ${phone}`);
let payloadConEscribiendo;
const typingTimeMs = Math.min(Math.max(text.length * 30, 1500), 8000);
const textWithNotice = `*(PRUEBA - Iba para: ${originalPhone})*\n\n` + text;
if(useDelay) {
payloadConEscribiendo = {
number: phone.replace("+", ""),
text: textWithNotice,
options: { delay: typingTimeMs, presence: "composing" }
};
} else {
payloadConEscribiendo = { number: phone.replace("+", ""), text: textWithNotice };
}
const res = await fetch(`${EVOLUTION_BASE_URL.replace(/\/$/, "")}/message/sendText/${instanceName}`, {
method: "POST",
headers: { "Content-Type": "application/json", "apikey": EVOLUTION_API_KEY },
body: JSON.stringify(payloadConEscribiendo)
});
if (!res.ok && useDelay) {
const payloadSeguro = { number: phone.replace("+", ""), text: textWithNotice };
const res2 = await fetch(`${EVOLUTION_BASE_URL.replace(/\/$/, "")}/message/sendText/${instanceName}`, {
method: "POST", headers: { "Content-Type": "application/json", "apikey": EVOLUTION_API_KEY },
body: JSON.stringify(payloadSeguro)
});
if (!res2.ok) return false; // Falló el plan B
return true; // Éxito en plan B
} else if (res.ok) {
return true; // Éxito a la primera
} else {
return false; // Falló y no había delay
}
} catch (e) {
console.error("❌ Error crítico en WA:", e.message);
return false;
}
}
async function ensureInstance(instanceName) {
if (!EVOLUTION_BASE_URL || !EVOLUTION_API_KEY) throw new Error("Faltan variables EVOLUTION");
const baseUrl = EVOLUTION_BASE_URL.replace(/\/$/, "");
const headers = { "Content-Type": "application/json", "apikey": EVOLUTION_API_KEY.trim() };
const checkRes = await fetch(`${baseUrl}/instance/connectionState/${instanceName}`, { headers });
if (checkRes.status === 404) {
await fetch(`${baseUrl}/instance/create`, {
method: 'POST', headers,
body: JSON.stringify({ instanceName: instanceName, qrcode: true, integration: "WHATSAPP-BAILEYS" })
});
}
return { baseUrl, headers };
}
// ==========================================
// 🚀 RUTAS PÚBLICAS (MÓVIL OPERARIO)
// ==========================================
app.get("/public/portal/:token
// 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;
// Recuperamos los datos crudos del servicio
const serviceQ = await pool.query("SELECT raw_data 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 raw = serviceQ.rows[0].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]);
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 }); }
});
// ==========================================
// ⚙️ 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 }); }
});
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 || {};
const phone = raw["Teléfono"] || raw["TELEFONO"] || "";
if (!phone) return false; // Sin teléfono = No enviado
// 4. Buscamos el token del portal cliente (o lo creamos si no existe)
const phoneClean = phone.replace('+34', '').trim();
let token = "ERROR";
const clientQ = await pool.query("SELECT portal_token FROM clients WHERE phone LIKE $1 AND owner_id=$2 LIMIT 1", [`%${phoneClean}%`, ownerId]);
if (clientQ.rowCount > 0) {
token = clientQ.rows[0].portal_token;
} else {
// El cliente no existe en la agenda aún. Lo creamos al vuelo.
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, phone, 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 || "");
text = text.replace(/{{ENLACE}}/g, linkMagico);
const useDelay = settings.wa_delay_enabled !== false;
// RETORNAMOS EL ÉXITO O FRACASO DEL ENVÍO
return await sendWhatsAppAuto(phone, text, `cliente_${ownerId}`, useDelay);
} catch (e) { console.error("Error Motor WA:", e.message); return false; }
}
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 }); }
});
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 (CORREGIDA)
// ==========================================
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 || !cp) {
console.error(" [AUTOMATE] Faltan datos: guild_id o CP");
return res.status(400).json({ ok: false, error: "Faltan datos (Gremio o CP)" });
}
// 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" });
}
const serviceData = serviceQ.rows[0];
const raw = serviceData.raw_data;
// 2. Buscar operarios que cumplan Gremio + Zona (CP) + Activos
const 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.toString() }])]);
if (workersQ.rowCount === 0) {
console.warn(`⚠️ [AUTOMATE] No hay operarios activos para Gremio:${guild_id} y CP:${cp}`);
return res.status(404).json({ ok: false, error: "No hay operarios disponibles para esta zona/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 mensaje = `🛠️ *NUEVO SERVICIO DISPONIBLE*\n\n👤 *Operario:* ${worker.full_name}\n📍 *Zona:* ${cp}\n⏱ *Expira:* ${horaCaducidad}\n\nRevisa y acepta aquí:\n🔗 ${link}`;
// 6. Envío WA (Sandbox 667248132 activo en sendWhatsAppAuto)
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}`);
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 });
}
});
// AÑADIDO Y BLINDADO: CAPTURA COMPLETA, REGLA WA MANUAL, DESASIGNACIÓN Y BORRADO DE FECHA
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 {
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 });
}
if (status === 'archived') {
await pool.query(`UPDATE scraped_services SET status = 'archived', automation_status = 'manual' WHERE id = $2 AND owner_id = $3`, [id, req.user.accountId]);
return res.json({ ok: true });
}
const current = await pool.query('SELECT raw_data, assigned_to, status 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 oldStatus = current.rows[0].raw_data.status_operativo || null;
let newStatus = extra.status_operativo || oldStatus;
if (newStatus === "") newStatus = null;
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();
// --- NUEVA REGLA: BORRADO DE FECHA SI RETROCEDE O SE ANULA ---
if (stName.includes('pendiente') || stName.includes('desasignado') || stName.includes('asignado') || stName.includes('anulado') || stName.includes('esperando')) {
extra.scheduled_date = "";
extra.scheduled_time = "";
}
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) {
newStatus = estadoEsperando.rows[0].id;
extra.status_operativo = newStatus;
}
}
} else if (stName.includes('pendiente de asignar') || stName.includes('desasignado')) {
const oldWorkerId = current.rows[0].assigned_to || current.rows[0].raw_data.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 = current.rows[0].raw_data.service_ref || current.rows[0].raw_data["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);
}
}
assigned_to = null;
assigned_to_name = null;
extra.assigned_to = null;
extra.assigned_to_name = null;
}
}
const updatedRawData = {
...current.rows[0].raw_data,
...extra,
"Nombre Cliente": name || current.rows[0].raw_data["Nombre Cliente"],
"Teléfono": phone || current.rows[0].raw_data["Teléfono"],
"Dirección": address || current.rows[0].raw_data["Dirección"],
"Código Postal": cp || current.rows[0].raw_data["Código Postal"],
"Descripción": description || current.rows[0].raw_data["Descripción"],
"guild_id": guild_id,
"assigned_to": assigned_to,
"assigned_to_name": assigned_to_name,
"internal_notes": internal_notes,
"client_notes": client_notes,
"Urgente": is_urgent ? "" : "No",
"status_operativo": newStatus
};
let finalAssignedTo = assigned_to;
if (finalAssignedTo === "" || finalAssignedTo === null) finalAssignedTo = null;
else if (finalAssignedTo === undefined) finalAssignedTo = current.rows[0].assigned_to;
let currentDbStatus = current.rows[0].status;
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, is_urgent || false, finalAssignedTo, id, req.user.accountId]
);
res.json({ ok: true });
} catch (error) {
console.error("Error actualización manual:", error);
res.status(500).json({ error: 'Error' });
}
});
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)
app.put("/services/set-appointment/:id", authMiddleware, async (req, res) => {
try {
const { id } = req.params;
let { date, time, status_operativo, ...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 ---
// Si 'date' es undefined (no viene en el JSON), usamos la que ya tiene el servicio.
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')) {
// Solo en estos casos reseteamos la fecha a vacío
newDate = "";
newTime = "";
}
// --- MOTOR DE EVENTOS ---
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) {
status_operativo = estadoEsperando.rows[0].id;
}
}
}
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);
}
}
extra.assigned_to = null;
extra.assigned_to_name = null;
finalAssignedTo = null;
}
else if (stName.includes('citado') && newDate !== "" && date !== undefined) {
// Solo disparamos el WA de cita si realmente estamos enviando una fecha nueva (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')) {
await triggerWhatsAppEvent(req.user.accountId, id, 'wa_evt_survey');
}
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]
);
res.json({ ok: true });
} catch (e) {
console.error("Error agendando cita:", e);
res.status(500).json({ ok: false });
}
});
// ==========================================
// 📞 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
const phone = raw["Teléfono"] || raw["TELEFONOS"] || raw["TELEFONO"] || "";
if (phone) {
// Buscamos si existe la plantilla (tendrás que crearla en el panel de admin luego)
const tplQ = await pool.query("SELECT content FROM message_templates WHERE owner_id=$1 AND type='not_found'", [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 phoneClean = phone.replace('+34', '').trim();
let token = "ERROR";
const clientQ = await pool.query("SELECT portal_token FROM clients WHERE phone LIKE $1 AND owner_id=$2 LIMIT 1", [`%${phoneClean}%`, 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", phone, newToken]
);
token = insertC.rows[0].portal_token;
}
const linkMagico = `https://portal.integrarepara.es/?token=${token}&service=${id}`;
text = text.replace(/{{NOMBRE}}/g, raw["Nombre Cliente"] || raw["CLIENTE"] || "Cliente");
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;
sendWhatsAppAuto(phone, text, `cliente_${req.user.accountId}`, useDelay).catch(console.error);
}
res.json({ ok: true, called_times: currentCalls });
} catch (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" });
// Normalizar teléfono (quitar espacios, +34, etc) para buscar bien
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) {
// Cliente existe, devolvemos su token
res.json({ ok: true, client: q.rows[0] });
} else {
// Cliente nuevo, generamos token y lo creamos
const newToken = crypto.randomBytes(6).toString('hex'); // Token seguro y corto
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) {
console.error("Error ensure client:", 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();
}
});
// ==========================================
// 🛠️ 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 });
}
});
// ==========================================
// 🕒 EL RELOJ DEL SISTEMA (Ejecutar cada minuto)
// ==========================================
setInterval(async () => {
try {
const expiredPings = await pool.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 pool.query("UPDATE assignment_pings SET status = 'expired' WHERE id = $1", [ping.id]);
const nextWorkerQ = await pool.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 pool.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 pool.query("UPDATE scraped_services SET automation_status = 'failed' WHERE id = $1", [ping.scraped_id]);
}
}
} catch (e) { console.error("Reloj:", e); }
}, 60000);
const port = process.env.PORT || 3000;
autoUpdateDB().then(() => { app.listen(port, "0.0.0.0", () => console.log(`🚀 Server OK en puerto ${port}`)); });