Actualiza server.js
This commit is contained in:
290
server.js
290
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);
|
||||
|
||||
Reference in New Issue
Block a user