diff --git a/server.js b/server.js index 98780bc..0314ca2 100644 --- a/server.js +++ b/server.js @@ -831,6 +831,39 @@ async function autoUpdateDB() { sent_to JSONB DEFAULT '[]', created_at TIMESTAMP DEFAULT NOW() ); + + -- 🔔 AUTOMATIZACIONES DE RECORDATORIOS (SIN CITAR / ESPERA CLIENTE / DÍA ANTES) + CREATE TABLE IF NOT EXISTS automation_settings ( + id SERIAL PRIMARY KEY, + owner_id INT REFERENCES users(id) ON DELETE CASCADE, + automation_type TEXT NOT NULL, + enabled BOOLEAN DEFAULT FALSE, + test_mode BOOLEAN DEFAULT TRUE, + dias_espera_inicial INT DEFAULT 1, + dias_repeticion INT DEFAULT 3, + hora_envio TEXT DEFAULT '09:00', + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(owner_id, automation_type) + ); + + -- 🕒 Seguimiento de cuándo entró cada servicio en el estado vigilado (para contar los días de espera) + CREATE TABLE IF NOT EXISTS automation_tracking ( + id SERIAL PRIMARY KEY, + owner_id INT REFERENCES users(id) ON DELETE CASCADE, + service_id INT REFERENCES scraped_services(id) ON DELETE CASCADE, + automation_type TEXT NOT NULL, + first_seen_at TIMESTAMP DEFAULT NOW(), + UNIQUE(service_id, automation_type) + ); + + -- 📜 Historial de cada recordatorio automático enviado + CREATE TABLE IF NOT EXISTS automation_log ( + id SERIAL PRIMARY KEY, + owner_id INT REFERENCES users(id) ON DELETE CASCADE, + service_id INT REFERENCES scraped_services(id) ON DELETE CASCADE, + automation_type TEXT NOT NULL, + sent_at TIMESTAMP DEFAULT NOW() + ); `); // PARCHE DE ACTUALIZACIÓN @@ -2954,7 +2987,8 @@ async function sendWhatsAppAuto(phone, message, instanceName, useDelay = false) body: JSON.stringify({ number: phone, text: message, - delay: timeToType // Usamos el tiempo dinámico que acabamos de calcular + delay: timeToType, // Usamos el tiempo dinámico que acabamos de calcular + linkPreview: true // Muestra la vista previa del enlace (imagen/título) en WhatsApp }) }); @@ -3805,6 +3839,75 @@ app.post("/templates", authMiddleware, async (req, res) => { } }); +// ========================================== +// 🔔 RUTAS DE AUTOMATIZACIONES (RECORDATORIOS) +// ========================================== +const AUTOMATION_TYPES = ['sin_citar', 'espera_cliente', 'dia_antes']; + +app.get("/automations/settings", authMiddleware, async (req, res) => { + try { + for (const tipo of AUTOMATION_TYPES) { + await pool.query( + "INSERT INTO automation_settings (owner_id, automation_type) VALUES ($1,$2) ON CONFLICT (owner_id, automation_type) DO NOTHING", + [req.user.accountId, tipo] + ); + } + const q = await pool.query("SELECT * FROM automation_settings WHERE owner_id=$1", [req.user.accountId]); + res.json({ ok: true, settings: q.rows }); + } catch (e) { + console.error("Error obteniendo settings de automatizaciones:", e); + res.status(500).json({ ok: false, error: e.message }); + } +}); + +app.post("/automations/settings", authMiddleware, async (req, res) => { + try { + const { automation_type, enabled, test_mode, dias_espera_inicial, dias_repeticion, hora_envio } = req.body; + if (!AUTOMATION_TYPES.includes(automation_type)) { + return res.status(400).json({ ok: false, error: "Tipo de automatización no válido" }); + } + await pool.query(` + INSERT INTO automation_settings (owner_id, automation_type, enabled, test_mode, dias_espera_inicial, dias_repeticion, hora_envio) + VALUES ($1,$2,$3,$4,$5,$6,$7) + ON CONFLICT (owner_id, automation_type) DO UPDATE SET + enabled = EXCLUDED.enabled, + test_mode = EXCLUDED.test_mode, + dias_espera_inicial = EXCLUDED.dias_espera_inicial, + dias_repeticion = EXCLUDED.dias_repeticion, + hora_envio = EXCLUDED.hora_envio + `, [ + req.user.accountId, + automation_type, + !!enabled, + test_mode !== false, + parseInt(dias_espera_inicial) || 1, + parseInt(dias_repeticion) || 3, + hora_envio || '09:00' + ]); + res.json({ ok: true }); + } catch (e) { + console.error("Error guardando settings de automatizaciones:", e); + res.status(500).json({ ok: false, error: e.message }); + } +}); + +app.get("/automations/history", authMiddleware, async (req, res) => { + try { + const q = await pool.query(` + SELECT at.service_id, s.service_ref, s.raw_data, at.automation_type, at.first_seen_at, + (SELECT COUNT(*) FROM automation_log al WHERE al.service_id = at.service_id AND al.automation_type = at.automation_type) as veces_enviado + FROM automation_tracking at + JOIN scraped_services s ON s.id = at.service_id + WHERE at.owner_id = $1 + ORDER BY at.first_seen_at ASC + `, [req.user.accountId]); + res.json({ ok: true, history: q.rows }); + } catch (e) { + console.error("Error obteniendo historial de automatizaciones:", e); + res.status(500).json({ ok: false, error: e.message }); + } +}); + // ========================================== // 🎨 RUTAS DE ESTADOS DEL SISTEMA (SAAS COMPLETO) // ========================================== @@ -5891,6 +5994,77 @@ app.post("/public/portal/:token/protection/subscribe", async (req, res) => { } }); +// ========================================== +// 🔔 HELPERS PARA LAS AUTOMATIZACIONES DE RECORDATORIOS +// ========================================== +function getMadridNowInfoAuto() { + 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) + }; +} + +function addDaysISOAuto(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); +} + +// Envía el WhatsApp de un recordatorio automático y deja constancia en automation_log. +// Si test_mode está activo, el mensaje se desvía SIEMPRE al móvil de pruebas (667248132), +// nunca al cliente real, para poder validar el automatismo sin molestar a nadie. +async function enviarRecordatorioAutomatizacion(client, svc, tipo, testMode) { + try { + const raw = svc.raw_data || {}; + const tplTypeMap = { + sin_citar: 'reminder_sin_citar', + espera_cliente: 'reminder_espera_cliente', + dia_antes: 'reminder_dia_antes' + }; + const tplQ = await client.query("SELECT content FROM message_templates WHERE owner_id=$1 AND type=$2", [svc.owner_id, tplTypeMap[tipo]]); + if (tplQ.rowCount === 0 || !tplQ.rows[0].content) return; + let text = tplQ.rows[0].content; + + const nombre = raw["Nombre Cliente"] || raw["CLIENTE"] || raw["Nombre"] || "Cliente"; + const direccion = raw["Dirección"] || raw["DOMICILIO"] || ""; + text = text.replace(/{{NOMBRE}}/g, nombre); + text = text.replace(/{{DIRECCION}}/g, direccion); + text = text.replace(/{{FECHA}}/g, raw["scheduled_date"] || ""); + text = text.replace(/{{HORA}}/g, raw["scheduled_time"] || ""); + text = text.replace(/{{REFERENCIA}}/g, svc.service_ref || raw["Referencia"] || raw["Nº Siniestro"] || String(svc.id)); + + const rawPhone = raw["Teléfono"] || raw["TELEFONO"] || raw["TELEFONOS"] || ""; + const cleanPhone = extractValidPhone(rawPhone); + + let finalPhone; + if (testMode) { + finalPhone = "34667248132"; // 🛡️ Modo prueba: siempre al móvil de pruebas, nunca al cliente + } else { + if (cleanPhone.length < 9) return; // Sin teléfono válido del cliente, no hay a quién enviar + finalPhone = "34" + cleanPhone; + } + + const instanceName = `cliente_${svc.owner_id}`; + await sendWhatsAppAuto(finalPhone, text, instanceName, false); + + await client.query( + "INSERT INTO automation_log (owner_id, service_id, automation_type, sent_at) VALUES ($1,$2,$3,NOW())", + [svc.owner_id, svc.id, tipo] + ); + } catch (e) { + console.error(`Error enviando recordatorio automatizado (${tipo}) srv#${svc.id}:`, e.message); + } +} + // ========================================== // 🕒 EL RELOJ DEL SISTEMA (Ejecutar cada minuto) // ========================================== @@ -5988,8 +6162,122 @@ setInterval(async () => { } } - } catch (e) { - console.error("Reloj:", e.message); + // --- 3. 🔔 AUTOMATIZACIONES DE RECORDATORIOS (SIN CITAR / ESPERA CLIENTE / DÍA ANTES) --- + const madridInfo = getMadridNowInfoAuto(); + const dentroDeHorario = madridInfo.currentMinutes >= 540 && madridInfo.currentMinutes < 1260; // 9:00 - 21:00 (si no toca ahora, se difiere, nunca se salta) + + // 3.1 — "Sin citar" (Asignado sin fecha) y "Espera de cliente" (Esperando al Cliente sin fecha) + const tiposEstado = [ + { tipo: 'sin_citar', statusName: 'Asignado' }, + { tipo: 'espera_cliente', statusName: 'Esperando al Cliente' } + ]; + + for (const { tipo, statusName } of tiposEstado) { + const settingsQ = await client.query( + "SELECT * FROM automation_settings WHERE automation_type = $1 AND enabled = true", + [tipo] + ); + + for (const cfg of settingsQ.rows) { + // Candidatos actuales: servicios de este owner, en el estado vigilado, sin fecha de cita + const candidatesQ = await client.query(` + SELECT s.id, s.owner_id, s.raw_data, s.service_ref + FROM scraped_services s + JOIN service_statuses st ON st.id::text = (s.raw_data->>'status_operativo')::text + WHERE s.owner_id = $1 + AND st.name = $2 + AND (s.raw_data->>'scheduled_date' IS NULL OR s.raw_data->>'scheduled_date' = '') + AND s.status != 'archived' + `, [cfg.owner_id, statusName]); + + const candidateIds = candidatesQ.rows.map(r => r.id); + + // Damos de alta en el seguimiento a los que son nuevos en este estado + for (const svc of candidatesQ.rows) { + await client.query( + "INSERT INTO automation_tracking (owner_id, service_id, automation_type, first_seen_at) VALUES ($1,$2,$3,NOW()) ON CONFLICT (service_id, automation_type) DO NOTHING", + [svc.owner_id, svc.id, tipo] + ); + } + + // Limpiamos el seguimiento de los que ya no están en ese estado (se citaron, cambiaron, etc.) + if (candidateIds.length > 0) { + await client.query( + "DELETE FROM automation_tracking WHERE owner_id = $1 AND automation_type = $2 AND service_id != ALL($3::int[])", + [cfg.owner_id, tipo, candidateIds] + ); + } else { + await client.query( + "DELETE FROM automation_tracking WHERE owner_id = $1 AND automation_type = $2", + [cfg.owner_id, tipo] + ); + } + + if (!dentroDeHorario) continue; // Fuera de horario (21h-9h): no enviamos todavía, pero el seguimiento ya está al día + + for (const svc of candidatesQ.rows) { + const trackQ = await client.query( + "SELECT first_seen_at FROM automation_tracking WHERE service_id = $1 AND automation_type = $2", + [svc.id, tipo] + ); + if (trackQ.rowCount === 0) continue; + const firstSeenAt = trackQ.rows[0].first_seen_at; + + const lastSentQ = await client.query( + "SELECT MAX(sent_at) as last_sent FROM automation_log WHERE service_id = $1 AND automation_type = $2", + [svc.id, tipo] + ); + const lastSent = lastSentQ.rows[0].last_sent; + + let debeEnviar = false; + if (!lastSent) { + const diasEspera = (Date.now() - new Date(firstSeenAt).getTime()) / 86400000; + debeEnviar = diasEspera >= cfg.dias_espera_inicial; + } else { + const diasDesdeUltimo = (Date.now() - new Date(lastSent).getTime()) / 86400000; + debeEnviar = diasDesdeUltimo >= cfg.dias_repeticion; + } + + if (debeEnviar) { + await enviarRecordatorioAutomatizacion(client, svc, tipo, cfg.test_mode); + } + } + } + } + + // 3.2 — "Día antes" de la cita: recordatorio al asegurado con dirección, hora y día de la visita + if (dentroDeHorario) { + const diaAntesSettingsQ = await client.query( + "SELECT * FROM automation_settings WHERE automation_type = 'dia_antes' AND enabled = true" + ); + const mananaISO = addDaysISOAuto(madridInfo.todayISO, 1); + + for (const cfg of diaAntesSettingsQ.rows) { + const [hh, mm] = (cfg.hora_envio || '09:00').split(':').map(Number); + const minutosEnvio = (hh * 60) + (mm || 0); + if (madridInfo.currentMinutes < minutosEnvio) continue; // Aún no toca su hora configurada de envío + + const candidatesQ = await client.query(` + SELECT s.id, s.owner_id, s.raw_data, s.service_ref + FROM scraped_services s + WHERE s.owner_id = $1 + AND s.raw_data->>'scheduled_date' = $2 + AND s.status != 'archived' + AND NOT EXISTS ( + SELECT 1 FROM automation_log al + WHERE al.service_id = s.id AND al.automation_type = 'dia_antes' + AND al.sent_at::date = CURRENT_DATE + ) + `, [cfg.owner_id, mananaISO]); + + for (const svc of candidatesQ.rows) { + await enviarRecordatorioAutomatizacion(client, svc, 'dia_antes', cfg.test_mode); + } + } + } + + } catch (e) { + console.error("Reloj:", e.message); } finally { client.release(); // <-- Liberamos la conexión SIEMPRE }