Añadir automatizaciones de recordatorios (sin citar / espera cliente / dia antes) + fix linkPreview
This commit is contained in:
290
server.js
290
server.js
@@ -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,6 +6162,120 @@ setInterval(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 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) {
|
} catch (e) {
|
||||||
console.error("Reloj:", e.message);
|
console.error("Reloj:", e.message);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user