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
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,104 +5266,19 @@ 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;
// 🕒 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));
}
// 🛡️ 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]);
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);
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);
}
debounceTimersIA.set(service.id, timerIA);
} catch (e) {
console.error("❌ [WEBHOOK ERROR]:", e);