Actualizar server.js

This commit is contained in:
2026-04-03 11:51:15 +00:00
parent f42124cb45
commit a4ab87ef42

465
server.js
View File

@@ -497,37 +497,88 @@ function genCode6() { return String(Math.floor(100000 + Math.random() * 900000))
// 🛡️ ESCUDO DE TITANIO: ANTI-SOLAPAMIENTOS BBDD // 🛡️ ESCUDO DE TITANIO: ANTI-SOLAPAMIENTOS BBDD
// ========================================== // ==========================================
async function comprobarDisponibilidad(ownerId, workerId, date, time, durationMin, excludeId = null) { async function comprobarDisponibilidad(ownerId, workerId, date, time, durationMin, excludeId = null) {
if (!workerId || !date || !time) return { choca: false }; try {
if (!ownerId || !workerId || !date || !time) {
let [newH, newM] = time.split(':').map(Number); return { choca: false };
let newStart = newH * 60 + newM;
let newEnd = newStart + (parseInt(durationMin) || 60);
let query = `
SELECT id, service_ref, raw_data->>'scheduled_time' as time, raw_data->>'duration_minutes' as dur,
raw_data->>'requested_time' as req_time, raw_data->>'appointment_status' as app_status
FROM scraped_services
WHERE owner_id = $1 AND assigned_to = $2 AND status != 'archived'
AND (
raw_data->>'scheduled_date' = $3 OR
(raw_data->>'requested_date' = $3 AND raw_data->>'appointment_status' = 'pending')
)
`;
let params = [ownerId, workerId, date];
if (excludeId) { query += ` AND id != $4`; params.push(excludeId); }
const q = await pool.query(query, params);
for (const s of q.rows) {
let sTime = s.time || (s.app_status === 'pending' ? s.req_time : null);
if (sTime && sTime.includes(':')) {
let [sH, sM] = sTime.split(':').map(Number);
let sStart = sH * 60 + sM;
let sEnd = sStart + parseInt(s.dur || 60);
if (newStart < sEnd && newEnd > sStart) return { choca: true, ref: s.service_ref, time: sTime };
} }
const parseTimeToMinutes = (value) => {
if (!value) return null;
const parts = String(value).trim().split(":");
if (parts.length < 2) return null;
const hh = parseInt(parts[0], 10);
const mm = parseInt(parts[1], 10);
if (Number.isNaN(hh) || Number.isNaN(mm)) return null;
return (hh * 60) + mm;
};
const requestedStart = parseTimeToMinutes(time);
const requestedDuration = Math.max(parseInt(durationMin, 10) || 60, 1);
if (requestedStart === null) {
return { choca: false };
}
const requestedEnd = requestedStart + requestedDuration;
let query = `
SELECT
id,
service_ref,
COALESCE(
NULLIF(raw_data->>'scheduled_time', ''),
CASE
WHEN raw_data->>'appointment_status' = 'pending' THEN NULLIF(raw_data->>'requested_time', '')
ELSE NULL
END
) as effective_time,
COALESCE(
NULLIF(raw_data->>'duration_minutes', ''),
'60'
) as effective_duration
FROM scraped_services
WHERE owner_id = $1
AND assigned_to = $2
AND status != 'archived'
AND (
raw_data->>'scheduled_date' = $3
OR (
raw_data->>'appointment_status' = 'pending'
AND raw_data->>'requested_date' = $3
)
)
`;
const params = [ownerId, workerId, date];
if (excludeId) {
query += ` AND id != $4`;
params.push(excludeId);
}
const q = await pool.query(query, params);
for (const row of q.rows) {
const start = parseTimeToMinutes(row.effective_time);
if (start === null) continue;
const duration = Math.max(parseInt(row.effective_duration, 10) || 60, 1);
const end = start + duration;
if (requestedStart < end && requestedEnd > start) {
return {
choca: true,
ref: row.service_ref || `ID-${row.id}`,
time: row.effective_time
};
}
}
return { choca: false };
} catch (e) {
console.error("❌ Error en comprobarDisponibilidad:", e.message);
return { choca: false };
} }
return { choca: false };
} }
// ========================================== // ==========================================
@@ -938,8 +989,215 @@ async function procesarConIA(ownerId, mensajeCliente, datosExpediente) {
day: "numeric" day: "numeric"
}).format(new Date()); }).format(new Date());
// Historial REAL del chat (ya incluye el último mensaje del cliente // =========================================================
// si el webhook lo ha guardado antes de llamar a esta función) // HELPERS INTERNOS (AUTOCONTENIDOS, PARA NO ROMPER TU SERVER)
// =========================================================
const parseTimeToMinutes = (value) => {
if (!value) return null;
const parts = String(value).trim().split(":");
if (parts.length < 2) return null;
const hh = parseInt(parts[0], 10);
const mm = parseInt(parts[1], 10);
if (Number.isNaN(hh) || Number.isNaN(mm)) return null;
return (hh * 60) + mm;
};
const minutesToHHMM = (mins) => {
const safe = Math.max(0, mins);
const hh = String(Math.floor(safe / 60)).padStart(2, "0");
const mm = String(safe % 60).padStart(2, "0");
return `${hh}:${mm}`;
};
const roundUpTo30 = (mins) => Math.ceil(mins / 30) * 30;
const roundDownTo30 = (mins) => Math.floor(mins / 30) * 30;
const mergeIntervals = (intervals) => {
if (!Array.isArray(intervals) || intervals.length === 0) return [];
const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
const merged = [sorted[0]];
for (let i = 1; i < sorted.length; i++) {
const [curStart, curEnd] = sorted[i];
const last = merged[merged.length - 1];
if (curStart <= last[1]) {
last[1] = Math.max(last[1], curEnd);
} else {
merged.push([curStart, curEnd]);
}
}
return merged;
};
const getMadridNowInfo = () => {
const fmt = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Madrid",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false
});
const parts = fmt.formatToParts(new Date());
const map = {};
for (const p of parts) {
if (p.type !== "literal") map[p.type] = p.value;
}
return {
todayISO: `${map.year}-${map.month}-${map.day}`,
currentMinutes: (parseInt(map.hour, 10) * 60) + parseInt(map.minute, 10)
};
};
const addDaysISO = (isoDate, daysToAdd) => {
const [y, m, d] = isoDate.split("-").map(Number);
const dt = new Date(Date.UTC(y, m - 1, d));
dt.setUTCDate(dt.getUTCDate() + daysToAdd);
return dt.toISOString().slice(0, 10);
};
const isWeekendISO = (isoDate) => {
const [y, m, d] = isoDate.split("-").map(Number);
const dt = new Date(Date.UTC(y, m - 1, d));
const day = dt.getUTCDay();
return day === 0 || day === 6;
};
const formatDisplayDateES = (isoDate) => {
const [y, m, d] = isoDate.split("-").map(Number);
return new Date(y, m - 1, d, 12, 0, 0).toLocaleDateString("es-ES", {
weekday: "long",
day: "numeric",
month: "long"
});
};
const calcularHuecosDisponiblesExactos = async () => {
if (!datosExpediente.worker_id) return [];
const agendaQ = await pool.query(`
SELECT
COALESCE(
NULLIF(raw_data->>'scheduled_date', ''),
CASE
WHEN raw_data->>'appointment_status' = 'pending' THEN NULLIF(raw_data->>'requested_date', '')
ELSE NULL
END
) as effective_date,
COALESCE(
NULLIF(raw_data->>'scheduled_time', ''),
CASE
WHEN raw_data->>'appointment_status' = 'pending' THEN NULLIF(raw_data->>'requested_time', '')
ELSE NULL
END
) as effective_time,
COALESCE(NULLIF(raw_data->>'duration_minutes', ''), '60') as effective_duration
FROM scraped_services
WHERE owner_id = $1
AND assigned_to = $2
AND status != 'archived'
AND id != $3
AND (
(raw_data->>'scheduled_date' IS NOT NULL AND raw_data->>'scheduled_date' >= CURRENT_DATE::text)
OR
(raw_data->>'appointment_status' = 'pending' AND raw_data->>'requested_date' >= CURRENT_DATE::text)
)
ORDER BY effective_date ASC, effective_time ASC
`, [ownerId, datosExpediente.worker_id, datosExpediente.dbId]);
const ocupacionesPorDia = {};
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 (!ocupacionesPorDia[isoDate]) ocupacionesPorDia[isoDate] = [];
ocupacionesPorDia[isoDate].push([startMin, startMin + duration]);
}
const sesionesTrabajo = [
[parseTimeToMinutes(horarios.m_start), parseTimeToMinutes(horarios.m_end)],
[parseTimeToMinutes(horarios.a_start), parseTimeToMinutes(horarios.a_end)]
].filter(([ini, fin]) => ini !== null && fin !== null && fin > ini);
const { todayISO, currentMinutes } = getMadridNowInfo();
const diasDisponibles = [];
for (let offset = 0; offset < 21 && diasDisponibles.length < 10; offset++) {
const isoDate = addDaysISO(todayISO, offset);
if (isWeekendISO(isoDate)) continue;
const ocupadas = mergeIntervals(ocupacionesPorDia[isoDate] || []);
const ventanas = [];
for (const [sesionStartBase, sesionEndBase] of sesionesTrabajo) {
let sesionStart = sesionStartBase;
const sesionEnd = sesionEndBase;
if (isoDate === todayISO) {
sesionStart = Math.max(sesionStart, roundUpTo30(currentMinutes + 30));
}
if ((sesionEnd - sesionStart) < 60) continue;
let cursor = sesionStart;
for (const [occStart, occEnd] of ocupadas) {
if (occEnd <= sesionStart) continue;
if (occStart >= sesionEnd) break;
const gapStart = roundUpTo30(cursor);
const gapEnd = roundDownTo30(Math.min(occStart, sesionEnd));
if ((gapEnd - gapStart) >= 60) {
ventanas.push({
start: minutesToHHMM(gapStart),
end: minutesToHHMM(gapEnd),
startMin: gapStart,
endMin: gapEnd
});
}
cursor = Math.max(cursor, occEnd);
if (cursor >= sesionEnd) break;
}
const tailStart = roundUpTo30(cursor);
const tailEnd = roundDownTo30(sesionEnd);
if ((tailEnd - tailStart) >= 60) {
ventanas.push({
start: minutesToHHMM(tailStart),
end: minutesToHHMM(tailEnd),
startMin: tailStart,
endMin: tailEnd
});
}
}
if (ventanas.length > 0) {
diasDisponibles.push({
date: isoDate,
displayDate: formatDisplayDateES(isoDate),
windows: ventanas
});
}
}
return diasDisponibles;
};
// =========================================================
// HISTORIAL REAL DEL CHAT
// =========================================================
const historyQ = await pool.query(` const historyQ = await pool.query(`
SELECT sender_role, message SELECT sender_role, message
FROM service_communications FROM service_communications
@@ -956,70 +1214,27 @@ async function procesarConIA(ownerId, mensajeCliente, datosExpediente) {
content: row.message content: row.message
})); }));
// Si el historial solo tiene el mensaje actual del cliente, es primer contacto
const esPrimerMensaje = historialRows.length <= 1; const esPrimerMensaje = historialRows.length <= 1;
let agendaOcupadaTexto = "✅ El técnico tiene la agenda libre en horario laboral."; // =========================================================
// HUECOS EXACTOS CALCULADOS POR BACKEND
// =========================================================
let huecosExactos = [];
let agendaDisponibleTexto = "❌ Ahora mismo no hay huecos exactos calculados por el sistema.";
if (datosExpediente.worker_id) { if (datosExpediente.worker_id) {
const agendaQ = await pool.query(` huecosExactos = await calcularHuecosDisponiblesExactos();
SELECT
COALESCE(NULLIF(raw_data->>'scheduled_date', ''), raw_data->>'requested_date') as date,
COALESCE(NULLIF(raw_data->>'scheduled_time', ''), raw_data->>'requested_time') as time,
raw_data->>'duration_minutes' as duration,
COALESCE(raw_data->>'Población', raw_data->>'POBLACION-PROVINCIA') as pob,
provider
FROM scraped_services
WHERE owner_id = $1
AND assigned_to = $2
AND status != 'archived'
AND id != $3
AND (
(raw_data->>'scheduled_date' IS NOT NULL AND raw_data->>'scheduled_date' >= CURRENT_DATE::text)
OR
(raw_data->>'appointment_status' = 'pending' AND raw_data->>'requested_date' >= CURRENT_DATE::text)
)
ORDER BY date ASC, time ASC
`, [ownerId, datosExpediente.worker_id, datosExpediente.dbId]);
if (agendaQ.rowCount > 0) { if (huecosExactos.length > 0) {
const ocupaciones = {}; agendaDisponibleTexto = huecosExactos.map(dia => {
const tramos = dia.windows
.map(w => `entre las ${w.start} y las ${w.end} aprox`)
.join(" | ");
agendaQ.rows.forEach(r => { return `- ${dia.displayDate} (${dia.date}): ${tramos}`;
if (r.date && r.time && r.time.includes(':')) { }).join("\n");
if (!ocupaciones[r.date]) ocupaciones[r.date] = []; } else {
agendaDisponibleTexto = "❌ No hay huecos exactos libres en los próximos días laborables calculados por el sistema.";
const [h, m] = r.time.split(':').map(Number);
const dur = parseInt(r.duration || 60, 10);
const endMin = (h * 60 + m) + dur;
const endH = String(Math.floor(endMin / 60) % 24).padStart(2, '0');
const endM = String(endMin % 60).padStart(2, '0');
const tipo = r.provider === 'SYSTEM_BLOCK' ? 'BLOQUEO/AUSENCIA' : 'CITA';
const lugar = r.pob || 'Otra zona';
ocupaciones[r.date].push(`❌ OCUPADO de ${r.time} a ${endH}:${endM} (${tipo} en ${lugar})`);
}
});
const lineas = Object.keys(ocupaciones).sort().map(d => {
const [y, m, day] = d.split('-').map(Number);
const fechaHumana = new Date(y, m - 1, day, 12, 0, 0).toLocaleDateString('es-ES', {
weekday: 'long',
day: 'numeric',
month: 'long'
});
return `- Día ${fechaHumana} (${d}):\n * ${ocupaciones[d].join("\n * ")}`;
});
if (lineas.length > 0) {
agendaOcupadaTexto =
"⚠️ ¡ATENCIÓN! LA SIGUIENTE LISTA SON LOS HORARIOS QUE YA ESTÁN OCUPADOS Y NO PUEDES OFRECER:\n" +
lineas.join("\n") +
"\n\n👉 INSTRUCCIÓN VITAL: Revisa bien la lista de arriba. Tienes que proponerle al cliente CUALQUIER OTRA HORA que esté totalmente libre y no pise esos tramos." +
"\n🚨 REGLA LOGÍSTICA: Si la zona de la avería actual no es la misma que la de la cita anterior o posterior, DEBES dejar al menos 45-60 minutos libres para el viaje.";
}
} }
} }
@@ -1083,47 +1298,63 @@ TU ÚNICO OBJETIVO: Informar al cliente que hemos recibido el aviso de su averí
directivaEstricta = `🛑 ESTADO ACTUAL: CITA PENDIENTE DE APROBACIÓN. directivaEstricta = `🛑 ESTADO ACTUAL: CITA PENDIENTE DE APROBACIÓN.
📅 Propuesta actual: El día ${datosExpediente.cita_pendiente_fecha} ${tramoPendiente}. 📅 Propuesta actual: El día ${datosExpediente.cita_pendiente_fecha} ${tramoPendiente}.
TU OBJETIVO: Informar que esperamos confirmación del técnico para reparar su avería (${datosExpediente.averia}). TU OBJETIVO: Informar que esperamos confirmación del técnico para reparar su avería (${datosExpediente.averia}).
⚠️ EXCEPCIÓN: Si el cliente pide CAMBIAR o CANCELAR, ofrécele un hueco nuevo.`; ⚠️ EXCEPCIÓN: Si el cliente pide CAMBIAR o CANCELAR, ofrécele un hueco nuevo usando SOLO los huecos exactos calculados por el sistema.`;
} else if (tieneCitaConfirmada) { } else if (tieneCitaConfirmada) {
directivaEstricta = `🛑 ESTADO ACTUAL: CITA CONFIRMADA para el ${datosExpediente.cita} ${tramoConfirmado}. Recuerda la cita para su avería (${datosExpediente.averia}). directivaEstricta = `🛑 ESTADO ACTUAL: CITA CONFIRMADA para el ${datosExpediente.cita} ${tramoConfirmado}. Recuerda la cita para su avería (${datosExpediente.averia}).
⚠️ EXCEPCIÓN: Si el cliente pide CAMBIARLA o CANCELARLA, ofrécele un hueco nuevo.`; ⚠️ EXCEPCIÓN: Si el cliente pide CAMBIARLA o CANCELARLA, ofrécele un hueco nuevo usando SOLO los huecos exactos calculados por el sistema.`;
} else { } else {
directivaEstricta = `🟢 ESTADO ACTUAL: PENDIENTE DE AGENDAR CITA. directivaEstricta = `🟢 ESTADO ACTUAL: PENDIENTE DE AGENDAR CITA.
TU OBJETIVO: Acordar fecha y hora para reparar su avería (${datosExpediente.averia}). NUNCA ofrezcas horas ocupadas. Fines de semana solo URGENCIAS. TU OBJETIVO: Acordar fecha y hora para reparar su avería (${datosExpediente.averia}) usando SOLO los huecos exactos calculados por el sistema.
⚠️ MUY IMPORTANTE: Cuando el cliente elija un hueco, NO le digas que la cita está confirmada. Dile que le pasas la nota al técnico para que él lo valide.`; ⚠️ MUY IMPORTANTE: Cuando el cliente elija un hueco, NO le digas que la cita está confirmada. Dile que le pasas la nota al técnico para que él lo valide.`;
} }
const promptSistema = ` const promptSistema = `
Eres el coordinador humano de "${empresaNombre}". Hablas de tú, de forma muy natural, empática y con buen tono por WhatsApp. Eres el coordinador humano de "${empresaNombre}". Hablas de tú, de forma natural, clara, cercana y profesional por WhatsApp.
--- 📋 CONTEXTO BÁSICO --- --- 📋 CONTEXTO BÁSICO ---
- Hoy es: ${fechaHoyTexto}. - Hoy es: ${fechaHoyTexto}.
- Horario de la empresa: L-V de ${horarios.m_start} a ${horarios.m_end} y de ${horarios.a_start} a ${horarios.a_end}. - Horario de la empresa: L-V de ${horarios.m_start} a ${horarios.m_end} y de ${horarios.a_start} a ${horarios.a_end}.
- ⛔ REGLA DE ORO DEL HORARIO: NUNCA propongas horas fuera de ese horario ni pises el tramo de comer. Fines de semana prohibidos salvo urgencias. - ⛔ REGLA DE ORO DEL HORARIO: NUNCA propongas horas fuera de ese horario ni cruces el tramo de comer.
- Fines de semana solo si es una urgencia.
- Localidad del cliente actual: ${datosExpediente.poblacion || 'Localidad no especificada'}. - Localidad del cliente actual: ${datosExpediente.poblacion || 'Localidad no especificada'}.
--- 📅 REVISIÓN DE AGENDA (EVITAR SOLAPES) --- --- ✅ HUECOS EXACTOS CALCULADOS POR EL SISTEMA ---
${agendaOcupadaTexto} ${agendaDisponibleTexto}
--- 🚨 NORMA ABSOLUTA DE AGENDA ---
Los huecos anteriores son la ÚNICA verdad.
NO puedes inventar horas.
NO puedes deducir huecos por tu cuenta.
NO puedes reinterpretar horarios ocupados.
SOLO puedes ofrecer horas que estén dentro de esos huecos exactos calculados por el sistema.
Si el cliente pide una hora concreta y esa hora cae dentro de uno de esos huecos, puedes aceptarla tentativamente.
Si cae fuera, debes decir que no la ves libre y ofrecer una alternativa de la lista exacta.
--- 🎯 DIRECTIVA ESTRICTA PARA ESTE MENSAJE --- --- 🎯 DIRECTIVA ESTRICTA PARA ESTE MENSAJE ---
${directivaEstricta} ${directivaEstricta}
--- ⚡ REGLA CRÍTICA DE AGENDA (COMANDO SECRETO) --- --- ⚡ REGLA CRÍTICA DE AGENDA (COMANDO SECRETO) ---
Si (y solo si) has propuesto un hueco y el cliente ACEPTA FIRMEMENTE, DEBES añadir AL FINAL ABSOLUTO de tu respuesta este texto literal: Si (y solo si) el cliente ACEPTA claramente un hueco concreto, DEBES añadir AL FINAL ABSOLUTO de tu respuesta este texto literal:
[PROPUESTA:YYYY-MM-DD HH:mm] [PROPUESTA:YYYY-MM-DD HH:mm]
Ejemplo: Ejemplo:
"Perfecto, le paso la nota al técnico para que te confirme el miércoles entre las 10:00 y las 11:00 aprox. ¡Te decimos algo pronto! [PROPUESTA:2026-03-25 10:00]" "Perfecto, le paso la nota al técnico para que te confirme el miércoles entre las 12:00 y las 13:00 aprox. ¡Te decimos algo pronto! [PROPUESTA:2026-03-25 12:00]"
⛔ PROHIBICIÓN: NUNCA le digas "te agendo" ni "cita confirmada". El cliente debe saber que dependemos del técnico. NUNCA menciones las palabras "código" o "etiqueta". ⛔ PROHIBICIONES:
- NUNCA digas "te agendo".
- NUNCA digas "cita confirmada".
- NUNCA menciones las palabras "código" o "etiqueta".
- NUNCA ofrezcas huecos distintos a los calculados.
- Si el cliente pregunta por "la semana que viene", responde usando solo los huecos de esa semana.
--- ⚙️ REGLAS DE COMUNICACIÓN --- --- ⚙️ REGLAS DE COMUNICACIÓN ---
1. MÁXIMO 2 FRASES. Mensajes cortos y directos. 1. MÁXIMO 2 FRASES.
2. NUNCA uses fechas frías si puedes decir "el martes". NUNCA des una hora exacta si puedes decir "entre las 10:00 y las 11:00 aprox". 2. Mensajes cortos, claros y directos.
3. NO TE PRESENTES si ya habéis intercambiado mensajes. 3. NO TE PRESENTES si ya habéis hablado antes.
4. ⛔ MULETILLAS PROHIBIDAS: NUNCA digas "¿En qué más te puedo ayudar?". 4. NO uses la muletilla "¿En qué más te puedo ayudar?".
${esPrimerMensaje ? `5. Primer mensaje: preséntate y menciona el aviso (#${datosExpediente.ref}).` : ''} 5. Si el cliente pide disponibilidad general, resume las opciones reales sin inventar nada.
${instruccionesExtra ? `6. Instrucción extra de la empresa: ${instruccionesExtra}` : ''} ${esPrimerMensaje ? `6. Primer mensaje: preséntate y menciona el aviso (#${datosExpediente.ref}).` : ''}
${instruccionesExtra ? `7. Instrucción extra de la empresa: ${instruccionesExtra}` : ''}
`; `;
const completion = await openai.chat.completions.create({ const completion = await openai.chat.completions.create({
@@ -1142,7 +1373,6 @@ ${instruccionesExtra ? `6. Instrucción extra de la empresa: ${instruccionesExtr
} }
} }
// ========================================== // ==========================================
// 📱 OTP PARA PORTAL DEL CLIENTE (ACCESO WEB) // 📱 OTP PARA PORTAL DEL CLIENTE (ACCESO WEB)
// ========================================== // ==========================================
@@ -4122,10 +4352,14 @@ app.post("/webhook/evolution", async (req, res) => {
return res.sendStatus(200); return res.sendStatus(200);
} }
// Ignorar grupos, estados y cosas raras
if (!/@s\.whatsapp\.net$/i.test(remoteJid)) {
return res.sendStatus(200);
}
// Respondemos rápido a Evolution // Respondemos rápido a Evolution
res.sendStatus(200); res.sendStatus(200);
// Tu arquitectura actual depende de cliente_ID
if (!/^cliente_\d+$/.test(instanceName)) { if (!/^cliente_\d+$/.test(instanceName)) {
console.log(`⚠️ [WEBHOOK IA] Instancia ignorada: ${instanceName}`); console.log(`⚠️ [WEBHOOK IA] Instancia ignorada: ${instanceName}`);
return; return;
@@ -4155,7 +4389,10 @@ app.post("/webhook/evolution", async (req, res) => {
COALESCE(s.raw_data->>'requested_time', '') as cita_pendiente_hora, COALESCE(s.raw_data->>'requested_time', '') as cita_pendiente_hora,
COALESCE(s.raw_data->>'Compañía', s.raw_data->>'COMPAÑIA', '') as compania, COALESCE(s.raw_data->>'Compañía', s.raw_data->>'COMPAÑIA', '') as compania,
COALESCE(s.raw_data->>'Descripción', s.raw_data->>'DESCRIPCION', '') as averia, COALESCE(s.raw_data->>'Descripción', s.raw_data->>'DESCRIPCION', '') as averia,
COALESCE((s.raw_data->>'ia_paused')::boolean, false) as ia_paused CASE
WHEN LOWER(COALESCE(s.raw_data->>'ia_paused', 'false')) IN ('true', 't', '1') THEN true
ELSE false
END as ia_paused
FROM scraped_services s FROM scraped_services s
LEFT JOIN users u ON s.assigned_to = u.id LEFT JOIN users u ON s.assigned_to = u.id
LEFT JOIN service_statuses st ON (s.raw_data->>'status_operativo')::text = st.id::text LEFT JOIN service_statuses st ON (s.raw_data->>'status_operativo')::text = st.id::text
@@ -4272,7 +4509,6 @@ app.post("/webhook/evolution", async (req, res) => {
console.error("Error guardando mensaje del cliente:", err); console.error("Error guardando mensaje del cliente:", err);
} }
// Candado para que no responda dos veces a la vez
if (candadosIA.has(service.id)) return; if (candadosIA.has(service.id)) return;
candadosIA.add(service.id); candadosIA.add(service.id);
@@ -4283,7 +4519,7 @@ app.post("/webhook/evolution", async (req, res) => {
return; return;
} }
// 🛡️ Escudo humano: si alguien de oficina/operario habló hace menos de 120 min, la IA calla // 🛡️ Escudo humano: si alguien habló hace menos de 120 min, la IA se calla
const checkHumanQ = await pool.query(` const checkHumanQ = await pool.query(`
SELECT sender_role, created_at SELECT sender_role, created_at
FROM service_communications FROM service_communications
@@ -4303,7 +4539,7 @@ app.post("/webhook/evolution", async (req, res) => {
} }
} }
// 🧠 Llamada a la IA // 🧠 LLAMADA A LA IA
const respuestaIA = await procesarConIA(ownerId, mensajeTexto, { const respuestaIA = await procesarConIA(ownerId, mensajeTexto, {
dbId: service.id, dbId: service.id,
ref: service.service_ref, ref: service.service_ref,
@@ -4324,7 +4560,10 @@ app.post("/webhook/evolution", async (req, res) => {
if (!respuestaIA) return; if (!respuestaIA) return;
const matchPropuesta = respuestaIA.match(/\[PROPUESTA:\s*(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})\]/i); const matchPropuesta = respuestaIA.match(/\[PROPUESTA:\s*(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})\]/i);
let textoLimpio = respuestaIA.replace(/\[PROPUESTA:.*?\]/gi, "").trim(); let textoLimpio = respuestaIA
.replace(/\[PROPUESTA:.*?\]/gi, "")
.replace(/\n{3,}/g, "\n\n")
.trim();
if (matchPropuesta && service.assigned_to) { if (matchPropuesta && service.assigned_to) {
const fechaSugerida = matchPropuesta[1]; const fechaSugerida = matchPropuesta[1];
@@ -4341,7 +4580,7 @@ app.post("/webhook/evolution", async (req, res) => {
if (disponibilidad.choca) { if (disponibilidad.choca) {
console.log(`⛔ [DOBLE-BOOKING EVITADO] ${service.service_ref} chocaba con ${disponibilidad.ref}`); console.log(`⛔ [DOBLE-BOOKING EVITADO] ${service.service_ref} chocaba con ${disponibilidad.ref}`);
textoLimpio = "Uy, justo ese hueco acaba de quedar ocupado en el sistema. Dime otra hora y lo reviso."; textoLimpio = "Uy, justo ese hueco ya no lo veo libre en el sistema. Dime otra hora y lo reviso.";
} else { } else {
await pool.query(` await pool.query(`
UPDATE scraped_services UPDATE scraped_services