Añadir automatizaciones de recordatorios (sin citar / espera cliente / dia antes) + fix linkPreview

This commit is contained in:
2026-08-23 15:23:56 +00:00
parent e1e42ca879
commit f3e53d4898

294
server.js
View File

@@ -831,6 +831,39 @@ async function autoUpdateDB() {
sent_to JSONB DEFAULT '[]', sent_to JSONB DEFAULT '[]',
created_at TIMESTAMP DEFAULT NOW() 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 // PARCHE DE ACTUALIZACIÓN
@@ -2954,7 +2987,8 @@ async function sendWhatsAppAuto(phone, message, instanceName, useDelay = false)
body: JSON.stringify({ body: JSON.stringify({
number: phone, number: phone,
text: message, 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) // 🎨 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) // 🕒 EL RELOJ DEL SISTEMA (Ejecutar cada minuto)
// ========================================== // ==========================================
@@ -5988,8 +6162,122 @@ setInterval(async () => {
} }
} }
} catch (e) { // --- 3. 🔔 AUTOMATIZACIONES DE RECORDATORIOS (SIN CITAR / ESPERA CLIENTE / DÍA ANTES) ---
console.error("Reloj:", e.message); 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 { } finally {
client.release(); // <-- Liberamos la conexión SIEMPRE client.release(); // <-- Liberamos la conexión SIEMPRE
} }