From 81669eb2ebc90bcd9e2b7401422df43911c7db5f Mon Sep 17 00:00:00 2001 From: marsalva Date: Tue, 18 Aug 2026 07:36:39 +0000 Subject: [PATCH] Actualiza server.js --- server.js | 294 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 186 insertions(+), 108 deletions(-) diff --git a/server.js b/server.js index 23f87ea..057bd96 100644 --- a/server.js +++ b/server.js @@ -14,6 +14,11 @@ const app = express(); // 🛑 SEMÁFORO IA: Guarda los IDs de los servicios que están siendo procesados 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 const corsOptions = { origin: [ @@ -1625,6 +1630,20 @@ async function procesarConIA(ownerId, mensajeCliente, datosExpediente) { const calcularHuecosDisponiblesExactos = async () => { 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(` SELECT COALESCE( @@ -1641,7 +1660,11 @@ async function procesarConIA(ownerId, mensajeCliente, datosExpediente) { ELSE NULL END ) 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 WHERE owner_id = $1 AND assigned_to = $2 @@ -1656,14 +1679,33 @@ async function procesarConIA(ownerId, mensajeCliente, datosExpediente) { `, [ownerId, datosExpediente.worker_id, datosExpediente.dbId]); const ocupacionesPorDia = {}; + const ciudadPorDia = {}; + const afternoonStartMin = parseTimeToMinutes(horarios.a_start); for (const row of agendaQ.rows) { const isoDate = row.effective_date; const startMin = parseTimeToMinutes(row.effective_time); 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] = []; ocupacionesPorDia[isoDate].push([startMin, startMin + duration]); } @@ -1673,24 +1715,29 @@ async function procesarConIA(ownerId, mensajeCliente, datosExpediente) { [parseTimeToMinutes(horarios.a_start), parseTimeToMinutes(horarios.a_end)] ].filter(([ini, fin]) => ini !== null && fin !== null && fin > ini); - const { todayISO, currentMinutes } = getMadridNowInfo(); + const { todayISO } = getMadridNowInfo(); 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); if (isWeekendISO(isoDate)) continue; const ocupadas = mergeIntervals(ocupacionesPorDia[isoDate] || []); + const dayCity = ciudadPorDia[isoDate]; const ventanas = []; 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; const sesionEnd = sesionEndBase; - if (isoDate === todayISO) { - sesionStart = Math.max(sesionStart, roundUpTo30(currentMinutes + 30)); - } - if ((sesionEnd - sesionStart) < 60) continue; 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 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}); - + 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. const reqDate = date || raw.requested_date; 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 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 +// 🧠 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) => { try { 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_time', '') as hora_cita, 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->>'requested_date', '') as cita_pendiente_fecha, COALESCE(s.raw_data->>'requested_time', '') as cita_pendiente_hora, @@ -5103,105 +5266,20 @@ app.post("/webhook/evolution", async (req, res) => { console.error("Error guardando mensaje del cliente:", err); } - 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) { - 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); + // 🕒 Debounce: si el cliente manda varios mensajes seguidos, esperamos a que haga + // una pausa antes de contestar, para no fragmentar ni duplicar respuestas. + if (debounceTimersIA.has(service.id)) { + clearTimeout(debounceTimersIA.get(service.id)); } + const timerIA = setTimeout(() => { + debounceTimersIA.delete(service.id); + procesarTurnoIA({ ownerId, service, mensajeTexto, telefonoCliente, instanceName }) + .catch(err => console.error("❌ [WEBHOOK IA DIFERIDA] Error:", err)); + }, IA_DEBOUNCE_MS); + + debounceTimersIA.set(service.id, timerIA); + } catch (e) { console.error("❌ [WEBHOOK ERROR]:", e); if (!res.headersSent) return res.sendStatus(200);