Actualiza server.js

This commit is contained in:
2026-08-18 07:36:39 +00:00
parent 232e9ec436
commit 81669eb2eb

286
server.js
View File

@@ -14,6 +14,11 @@ const app = express();
// 🛑 SEMÁFORO IA: Guarda los IDs de los servicios que están siendo procesados // 🛑 SEMÁFORO IA: Guarda los IDs de los servicios que están siendo procesados
const candadosIA = new Set(); const candadosIA = new Set();
// 🕒 DEBOUNCE IA: si el cliente manda varios WhatsApps seguidos, esperamos a que pare
// de escribir para contestar una sola vez (evita respuestas fragmentadas o duplicadas)
const debounceTimersIA = new Map();
const IA_DEBOUNCE_MS = 8000;
// Configuración de CORS Profesional // Configuración de CORS Profesional
const corsOptions = { const corsOptions = {
origin: [ origin: [
@@ -1625,6 +1630,20 @@ async function procesarConIA(ownerId, mensajeCliente, datosExpediente) {
const calcularHuecosDisponiblesExactos = async () => { const calcularHuecosDisponiblesExactos = async () => {
if (!datosExpediente.worker_id) return []; if (!datosExpediente.worker_id) return [];
// 🧭 Ciudad del cliente actual (misma lógica de zonas que usa el portal público)
const workerZonesQ = await pool.query("SELECT zones FROM users WHERE id = $1", [datosExpediente.worker_id]);
const workerZones = workerZonesQ.rows[0]?.zones || [];
const targetGuildId = datosExpediente.guild_id || null;
function getCityForCP(cp, fallbackPop) {
const cleanCP = String(cp || "").trim();
const zone = Array.isArray(workerZones) ? workerZones.find(z => z.cps === cleanCP) : null;
if (zone && zone.city) return zone.city.toUpperCase().trim();
return String(fallbackPop || "").toUpperCase().trim();
}
const targetCity = getCityForCP(datosExpediente.cp, datosExpediente.poblacion);
const agendaQ = await pool.query(` const agendaQ = await pool.query(`
SELECT SELECT
COALESCE( COALESCE(
@@ -1641,7 +1660,11 @@ async function procesarConIA(ownerId, mensajeCliente, datosExpediente) {
ELSE NULL ELSE NULL
END END
) as effective_time, ) as effective_time,
COALESCE(NULLIF(raw_data->>'duration_minutes', ''), '60') as effective_duration COALESCE(NULLIF(raw_data->>'duration_minutes', ''), '60') as effective_duration,
raw_data->>'Población' as poblacion,
COALESCE(raw_data->>'Código Postal', raw_data->>'C.P.') as cp,
provider,
raw_data->>'blocked_guild_id' as blocked_guild_id
FROM scraped_services FROM scraped_services
WHERE owner_id = $1 WHERE owner_id = $1
AND assigned_to = $2 AND assigned_to = $2
@@ -1656,14 +1679,33 @@ async function procesarConIA(ownerId, mensajeCliente, datosExpediente) {
`, [ownerId, datosExpediente.worker_id, datosExpediente.dbId]); `, [ownerId, datosExpediente.worker_id, datosExpediente.dbId]);
const ocupacionesPorDia = {}; const ocupacionesPorDia = {};
const ciudadPorDia = {};
const afternoonStartMin = parseTimeToMinutes(horarios.a_start);
for (const row of agendaQ.rows) { for (const row of agendaQ.rows) {
const isoDate = row.effective_date; const isoDate = row.effective_date;
const startMin = parseTimeToMinutes(row.effective_time); const startMin = parseTimeToMinutes(row.effective_time);
const duration = Math.max(parseInt(row.effective_duration, 10) || 60, 1); const duration = Math.max(parseInt(row.effective_duration, 10) || 60, 1);
if (!isoDate || startMin === null) continue; if (!isoDate) continue;
// 🔒 Un bloqueo manual reservado para OTRO gremio no cuenta para nada (igual que en el portal)
if (row.provider === 'SYSTEM_BLOCK' && row.blocked_guild_id && String(row.blocked_guild_id) !== String(targetGuildId)) {
continue;
}
// 📍 REGLA CRÍTICA DE TURNOS: anotamos la ciudad de la mañana/tarde de ese día
if (row.provider !== 'SYSTEM_BLOCK' && startMin !== null && afternoonStartMin !== null) {
const city = getCityForCP(row.cp, row.poblacion);
if (!ciudadPorDia[isoDate]) ciudadPorDia[isoDate] = { morningCity: null, afternoonCity: null };
if (startMin < afternoonStartMin) {
if (!ciudadPorDia[isoDate].morningCity) ciudadPorDia[isoDate].morningCity = city;
} else {
if (!ciudadPorDia[isoDate].afternoonCity) ciudadPorDia[isoDate].afternoonCity = city;
}
}
if (startMin === null) continue;
if (!ocupacionesPorDia[isoDate]) ocupacionesPorDia[isoDate] = []; if (!ocupacionesPorDia[isoDate]) ocupacionesPorDia[isoDate] = [];
ocupacionesPorDia[isoDate].push([startMin, startMin + duration]); ocupacionesPorDia[isoDate].push([startMin, startMin + duration]);
} }
@@ -1673,24 +1715,29 @@ async function procesarConIA(ownerId, mensajeCliente, datosExpediente) {
[parseTimeToMinutes(horarios.a_start), parseTimeToMinutes(horarios.a_end)] [parseTimeToMinutes(horarios.a_start), parseTimeToMinutes(horarios.a_end)]
].filter(([ini, fin]) => ini !== null && fin !== null && fin > ini); ].filter(([ini, fin]) => ini !== null && fin !== null && fin > ini);
const { todayISO, currentMinutes } = getMadridNowInfo(); const { todayISO } = getMadridNowInfo();
const diasDisponibles = []; const diasDisponibles = [];
for (let offset = 0; offset < 21 && diasDisponibles.length < 10; offset++) { // ⚠️ Igual que el portal público: no se ofrece el mismo día, se empieza a partir de mañana
for (let offset = 1; offset < 22 && diasDisponibles.length < 10; offset++) {
const isoDate = addDaysISO(todayISO, offset); const isoDate = addDaysISO(todayISO, offset);
if (isWeekendISO(isoDate)) continue; if (isWeekendISO(isoDate)) continue;
const ocupadas = mergeIntervals(ocupacionesPorDia[isoDate] || []); const ocupadas = mergeIntervals(ocupacionesPorDia[isoDate] || []);
const dayCity = ciudadPorDia[isoDate];
const ventanas = []; const ventanas = [];
for (const [sesionStartBase, sesionEndBase] of sesionesTrabajo) { for (const [sesionStartBase, sesionEndBase] of sesionesTrabajo) {
// 📍 Si esa mañana/tarde ya está anclada a otra ciudad, no la ofrecemos (evita saltos de zona)
if (dayCity && afternoonStartMin !== null) {
const esSesionManana = sesionStartBase < afternoonStartMin;
if (esSesionManana && dayCity.morningCity && dayCity.morningCity !== targetCity) continue;
if (!esSesionManana && dayCity.afternoonCity && dayCity.afternoonCity !== targetCity) continue;
}
let sesionStart = sesionStartBase; let sesionStart = sesionStartBase;
const sesionEnd = sesionEndBase; const sesionEnd = sesionEndBase;
if (isoDate === todayISO) {
sesionStart = Math.max(sesionStart, roundUpTo30(currentMinutes + 30));
}
if ((sesionEnd - sesionStart) < 60) continue; if ((sesionEnd - sesionStart) < 60) continue;
let cursor = sesionStart; let cursor = sesionStart;
@@ -2610,14 +2657,23 @@ app.post("/agenda/requests/:id/approve", authMiddleware, async (req, res) => {
// 🛑 NUEVO: Recibimos date y time desde la App // 🛑 NUEVO: Recibimos date y time desde la App
const { duration, date, time } = req.body; const { duration, date, time } = req.body;
const current = await pool.query('SELECT raw_data FROM scraped_services WHERE id=$1 AND owner_id=$2', [id, req.user.accountId]); 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}); if (current.rowCount === 0) return res.status(404).json({ok: false});
const raw = current.rows[0].raw_data; const raw = current.rows[0].raw_data;
const assignedTo = current.rows[0].assigned_to;
// 🛑 NUEVO: Si el técnico ha cambiado la fecha/hora en la App, usamos esa. Si no, la que pidió el cliente. // 🛑 NUEVO: Si el técnico ha cambiado la fecha/hora en la App, usamos esa. Si no, la que pidió el cliente.
const reqDate = date || raw.requested_date; const reqDate = date || raw.requested_date;
const reqTime = time || raw.requested_time; const reqTime = time || raw.requested_time;
// 🛡️ ESCUDO: Verificamos que el hueco siga libre antes de confirmar (evita solapamientos)
if (assignedTo && reqDate && reqTime) {
const solapamiento = await comprobarDisponibilidad(req.user.accountId, assignedTo, reqDate, reqTime, duration || 60, id);
if (solapamiento.choca) {
return res.status(400).json({ ok: false, error: `Ese hueco ya no está libre (choca con ${solapamiento.ref} a las ${solapamiento.time}). Elige otra hora.` });
}
}
const statusQ = await pool.query("SELECT id FROM service_statuses WHERE owner_id=$1 AND name ILIKE '%citado%' LIMIT 1", [req.user.accountId]); 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 idCitado = statusQ.rows[0]?.id || raw.status_operativo;
@@ -4925,6 +4981,111 @@ app.get("/services/:id/chat", authMiddleware, async (req, res) => {
}); });
// 🤖 WEBHOOK CON ESCUDO HUMANO, MEMORIA Y GESTIÓN DE URGENCIAS // 🤖 WEBHOOK CON ESCUDO HUMANO, MEMORIA Y GESTIÓN DE URGENCIAS
// 🧠 Ejecuta realmente el turno de la IA para un expediente (llamado tras el debounce)
async function procesarTurnoIA({ ownerId, service, mensajeTexto, telefonoCliente, instanceName }) {
if (candadosIA.has(service.id)) return;
candadosIA.add(service.id);
try {
// 🛑 Si está pausada, no contestamos
if (service.ia_paused === true || service.ia_paused === 'true') {
console.log(`🤫 [IA MUTEADA] ${service.service_ref}`);
return;
}
// 🛡️ Escudo humano: si alguien habló hace menos de 120 min, la IA se calla
const checkHumanQ = await pool.query(`
SELECT sender_role, created_at
FROM service_communications
WHERE scraped_id = $1
AND sender_role IN ('admin', 'superadmin', 'operario')
ORDER BY created_at DESC
LIMIT 1
`, [service.id]);
if (checkHumanQ.rowCount > 0) {
const lastMsg = checkHumanQ.rows[0];
const diffMinutos = (Date.now() - new Date(lastMsg.created_at).getTime()) / (1000 * 60);
if (diffMinutos >= 0 && diffMinutos < 120) {
console.log(`🛡️ [ESCUDO IA] Silenciada porque un humano habló hace ${Math.round(diffMinutos)} minutos.`);
return;
}
}
// 🧠 LLAMADA A LA IA (el historial ya incluye, en orden, todos los mensajes que el cliente
// haya mandado seguidos durante la espera del debounce)
const respuestaIA = await procesarConIA(ownerId, mensajeTexto, {
dbId: service.id,
ref: service.service_ref,
estado: service.status_name || "En proceso",
operario: service.worker_name,
worker_id: service.assigned_to,
cita: service.cita,
hora_cita: service.hora_cita,
poblacion: service.poblacion || "",
cp: service.cp || "",
guild_id: service.guild_id || "",
is_urgent: service.is_urgent,
appointment_status: service.appointment_status,
cita_pendiente_fecha: service.cita_pendiente_fecha,
cita_pendiente_hora: service.cita_pendiente_hora,
compania: service.compania,
averia: service.averia
});
if (!respuestaIA) return;
const matchPropuesta = respuestaIA.match(/\[PROPUESTA:\s*(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})\]/i);
let textoLimpio = respuestaIA
.replace(/\[PROPUESTA:.*?\]/gi, "")
.replace(/\n{3,}/g, "\n\n")
.trim();
if (matchPropuesta && service.assigned_to) {
const fechaSugerida = matchPropuesta[1];
const horaSugerida = matchPropuesta[2];
const disponibilidad = await comprobarDisponibilidad(
ownerId,
service.assigned_to,
fechaSugerida,
horaSugerida,
60,
service.id
);
if (disponibilidad.choca) {
console.log(`⛔ [DOBLE-BOOKING EVITADO] ${service.service_ref} chocaba con ${disponibilidad.ref}`);
textoLimpio = "Uy, justo ese hueco ya no lo veo libre en el sistema. Dime otra hora y lo reviso.";
} else {
await pool.query(`
UPDATE scraped_services
SET raw_data = raw_data || jsonb_build_object(
'requested_date', $1::text,
'requested_time', $2::text,
'appointment_status', 'pending'
)
WHERE id = $3
`, [fechaSugerida, horaSugerida, service.id]);
}
}
if (!textoLimpio) return;
await sendWhatsAppAuto(telefonoCliente, textoLimpio, instanceName, true);
await pool.query(`
INSERT INTO service_communications
(scraped_id, owner_id, sender_name, sender_role, message, is_internal)
VALUES ($1, $2, 'Asistente IA', 'ia', $3, false)
`, [service.id, ownerId, textoLimpio]);
} finally {
candadosIA.delete(service.id);
}
}
app.post("/webhook/evolution", async (req, res) => { app.post("/webhook/evolution", async (req, res) => {
try { try {
const data = req.body; const data = req.body;
@@ -4978,6 +5139,8 @@ app.post("/webhook/evolution", async (req, res) => {
COALESCE(s.raw_data->>'scheduled_date', '') as cita, COALESCE(s.raw_data->>'scheduled_date', '') as cita,
COALESCE(s.raw_data->>'scheduled_time', '') as hora_cita, COALESCE(s.raw_data->>'scheduled_time', '') as hora_cita,
COALESCE(s.raw_data->>'Población', s.raw_data->>'POBLACION-PROVINCIA', '') as poblacion, COALESCE(s.raw_data->>'Población', s.raw_data->>'POBLACION-PROVINCIA', '') as poblacion,
COALESCE(s.raw_data->>'Código Postal', s.raw_data->>'C.P.', '') as cp,
COALESCE(s.raw_data->>'guild_id', '') as guild_id,
COALESCE(s.raw_data->>'appointment_status', '') as appointment_status, COALESCE(s.raw_data->>'appointment_status', '') as appointment_status,
COALESCE(s.raw_data->>'requested_date', '') as cita_pendiente_fecha, COALESCE(s.raw_data->>'requested_date', '') as cita_pendiente_fecha,
COALESCE(s.raw_data->>'requested_time', '') as cita_pendiente_hora, COALESCE(s.raw_data->>'requested_time', '') as cita_pendiente_hora,
@@ -5103,104 +5266,19 @@ app.post("/webhook/evolution", async (req, res) => {
console.error("Error guardando mensaje del cliente:", err); console.error("Error guardando mensaje del cliente:", err);
} }
if (candadosIA.has(service.id)) return; // 🕒 Debounce: si el cliente manda varios mensajes seguidos, esperamos a que haga
candadosIA.add(service.id); // una pausa antes de contestar, para no fragmentar ni duplicar respuestas.
if (debounceTimersIA.has(service.id)) {
try { clearTimeout(debounceTimersIA.get(service.id));
// 🛑 Si está pausada, no contestamos
if (service.ia_paused === true || service.ia_paused === 'true') {
console.log(`🤫 [IA MUTEADA] ${service.service_ref}`);
return;
} }
// 🛡️ Escudo humano: si alguien habló hace menos de 120 min, la IA se calla const timerIA = setTimeout(() => {
const checkHumanQ = await pool.query(` debounceTimersIA.delete(service.id);
SELECT sender_role, created_at procesarTurnoIA({ ownerId, service, mensajeTexto, telefonoCliente, instanceName })
FROM service_communications .catch(err => console.error("❌ [WEBHOOK IA DIFERIDA] Error:", err));
WHERE scraped_id = $1 }, IA_DEBOUNCE_MS);
AND sender_role IN ('admin', 'superadmin', 'operario')
ORDER BY created_at DESC
LIMIT 1
`, [service.id]);
if (checkHumanQ.rowCount > 0) { debounceTimersIA.set(service.id, timerIA);
const lastMsg = checkHumanQ.rows[0];
const diffMinutos = (Date.now() - new Date(lastMsg.created_at).getTime()) / (1000 * 60);
if (diffMinutos < 0) {
console.log(`🛡️ [ESCUDO IA] Silenciada porque un humano habló hace ${Math.round(diffMinutos)} minutos.`);
return;
}
}
// 🧠 LLAMADA A LA IA
const respuestaIA = await procesarConIA(ownerId, mensajeTexto, {
dbId: service.id,
ref: service.service_ref,
estado: service.status_name || "En proceso",
operario: service.worker_name,
worker_id: service.assigned_to,
cita: service.cita,
hora_cita: service.hora_cita,
poblacion: service.poblacion || "",
is_urgent: service.is_urgent,
appointment_status: service.appointment_status,
cita_pendiente_fecha: service.cita_pendiente_fecha,
cita_pendiente_hora: service.cita_pendiente_hora,
compania: service.compania,
averia: service.averia
});
if (!respuestaIA) return;
const matchPropuesta = respuestaIA.match(/\[PROPUESTA:\s*(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})\]/i);
let textoLimpio = respuestaIA
.replace(/\[PROPUESTA:.*?\]/gi, "")
.replace(/\n{3,}/g, "\n\n")
.trim();
if (matchPropuesta && service.assigned_to) {
const fechaSugerida = matchPropuesta[1];
const horaSugerida = matchPropuesta[2];
const disponibilidad = await comprobarDisponibilidad(
ownerId,
service.assigned_to,
fechaSugerida,
horaSugerida,
60,
service.id
);
if (disponibilidad.choca) {
console.log(`⛔ [DOBLE-BOOKING EVITADO] ${service.service_ref} chocaba con ${disponibilidad.ref}`);
textoLimpio = "Uy, justo ese hueco ya no lo veo libre en el sistema. Dime otra hora y lo reviso.";
} else {
await pool.query(`
UPDATE scraped_services
SET raw_data = raw_data || jsonb_build_object(
'requested_date', $1::text,
'requested_time', $2::text,
'appointment_status', 'pending'
)
WHERE id = $3
`, [fechaSugerida, horaSugerida, service.id]);
}
}
if (!textoLimpio) return;
await sendWhatsAppAuto(telefonoCliente, textoLimpio, instanceName, true);
await pool.query(`
INSERT INTO service_communications
(scraped_id, owner_id, sender_name, sender_role, message, is_internal)
VALUES ($1, $2, 'Asistente IA', 'ia', $3, false)
`, [service.id, ownerId, textoLimpio]);
} finally {
candadosIA.delete(service.id);
}
} catch (e) { } catch (e) {
console.error("❌ [WEBHOOK ERROR]:", e); console.error("❌ [WEBHOOK ERROR]:", e);