Actualizar server.js

This commit is contained in:
2026-02-21 16:03:37 +00:00
parent b1314e6308
commit 49c34f01f6

141
server.js
View File

@@ -614,13 +614,16 @@ app.get("/whatsapp/status", authMiddleware, (req, res, next) => requirePlan(req,
// 🧠 MOTOR INTELIGENTE DE AGENDAMIENTO (RUTAS NUEVAS) // 🧠 MOTOR INTELIGENTE DE AGENDAMIENTO (RUTAS NUEVAS)
// ========================================== // ==========================================
// 1. Obtener huecos disponibles inteligentes // ==========================================
// 🧠 MOTOR INTELIGENTE DE AGENDAMIENTO Y AGENDA ADMIN
// ==========================================
// 1. Obtener huecos disponibles inteligentes (Bloqueando por duración)
app.get("/public/portal/:token/slots", async (req, res) => { app.get("/public/portal/:token/slots", async (req, res) => {
try { try {
const { token } = req.params; const { token } = req.params;
const { serviceId } = req.query; const { serviceId } = req.query;
// 1. Validar cliente y servicio
const clientQ = await pool.query("SELECT id, owner_id FROM clients WHERE portal_token = $1", [token]); const clientQ = await pool.query("SELECT id, owner_id FROM clients WHERE portal_token = $1", [token]);
if (clientQ.rowCount === 0) return res.status(404).json({ ok: false, error: "Token inválido" }); if (clientQ.rowCount === 0) return res.status(404).json({ ok: false, error: "Token inválido" });
@@ -631,14 +634,14 @@ app.get("/public/portal/:token/slots", async (req, res) => {
const assignedTo = service.assigned_to; const assignedTo = service.assigned_to;
if (!assignedTo) return res.status(400).json({ ok: false, error: "No hay operario asignado" }); if (!assignedTo) return res.status(400).json({ ok: false, error: "No hay operario asignado" });
// 2. Extraer "Zona" del servicio actual (Población o CP) para agrupar
const raw = service.raw_data || {}; const raw = service.raw_data || {};
const targetZone = (raw["Población"] || raw["POBLACION-PROVINCIA"] || raw["Código Postal"] || "").toLowerCase().trim(); const targetZone = (raw["Población"] || raw["POBLACION-PROVINCIA"] || raw["Código Postal"] || "").toLowerCase().trim();
// 3. Buscar la agenda del operario para los próximos 14 días // Extraemos la agenda respetando la duración (duration_minutes)
const agendaQ = await pool.query(` const agendaQ = await pool.query(`
SELECT raw_data->>'scheduled_date' as date, SELECT raw_data->>'scheduled_date' as date,
raw_data->>'scheduled_time' as time, raw_data->>'scheduled_time' as time,
raw_data->>'duration_minutes' as duration,
raw_data->>'Población' as poblacion, raw_data->>'Población' as poblacion,
raw_data->>'Código Postal' as cp raw_data->>'Código Postal' as cp
FROM scraped_services FROM scraped_services
@@ -647,43 +650,43 @@ app.get("/public/portal/:token/slots", async (req, res) => {
AND raw_data->>'scheduled_date' >= CURRENT_DATE::text AND raw_data->>'scheduled_date' >= CURRENT_DATE::text
`, [assignedTo]); `, [assignedTo]);
// Mapa de días ocupados y sus zonas "Ancla"
const agendaMap = {}; const agendaMap = {};
agendaQ.rows.forEach(row => { agendaQ.rows.forEach(row => {
if (!agendaMap[row.date]) agendaMap[row.date] = { times: [], zone: (row.poblacion || row.cp || "").toLowerCase().trim() }; if (!agendaMap[row.date]) agendaMap[row.date] = { times: [], zone: (row.poblacion || row.cp || "").toLowerCase().trim() };
agendaMap[row.date].times.push(row.time);
// Calculamos cuántas horas bloquea este servicio según su duración
const dur = parseInt(row.duration || 60); // Por defecto 1 hora
if (row.time) {
const startHour = parseInt(row.time.split(':')[0]);
const hoursBlocked = Math.ceil(dur / 60);
for (let i = 0; i < hoursBlocked; i++) {
const h = startHour + i;
agendaMap[row.date].times.push(h.toString().padStart(2, '0') + ":00");
}
}
}); });
// 4. Generar próximos 10 días laborables
const availableDays = []; const availableDays = [];
let d = new Date(); let d = new Date();
d.setDate(d.getDate() + 1); // Empezamos desde mañana d.setDate(d.getDate() + 1);
let daysAdded = 0; let daysAdded = 0;
while(daysAdded < 10) { while(daysAdded < 10) {
// Saltamos domingos if (d.getDay() !== 0) { // Omitir domingos
if (d.getDay() !== 0) { const dateStr = d.toISOString().split('T')[0];
const dateStr = d.toISOString().split('T')[0]; // YYYY-MM-DD
const dayData = agendaMap[dateStr]; const dayData = agendaMap[dateStr];
let isDayAllowed = true; let isDayAllowed = true;
// REGLA DE AGRUPACIÓN (EL CEREBRO):
// Si el día ya tiene servicios, comprobamos si la zona coincide.
if (dayData && dayData.zone && targetZone) { if (dayData && dayData.zone && targetZone) {
// Si el día está anclado a una zona diferente, bloqueamos el día para este cliente
if (!dayData.zone.includes(targetZone) && !targetZone.includes(dayData.zone)) { if (!dayData.zone.includes(targetZone) && !targetZone.includes(dayData.zone)) {
isDayAllowed = false; isDayAllowed = false;
} }
} }
if (isDayAllowed) { if (isDayAllowed) {
// Generar Huecos. Mañana: 9 a 13 | Tarde: 16 a 19
const morningSlots = ["09:00", "10:00", "11:00", "12:00", "13:00"]; const morningSlots = ["09:00", "10:00", "11:00", "12:00", "13:00"];
const afternoonSlots = ["16:00", "17:00", "18:00", "19:00"]; const afternoonSlots = ["16:00", "17:00", "18:00", "19:00"];
const takenTimes = dayData ? dayData.times : []; const takenTimes = dayData ? dayData.times : [];
const availMorning = morningSlots.filter(t => !takenTimes.includes(t)); const availMorning = morningSlots.filter(t => !takenTimes.includes(t));
const availAfternoon = afternoonSlots.filter(t => !takenTimes.includes(t)); const availAfternoon = afternoonSlots.filter(t => !takenTimes.includes(t));
@@ -700,12 +703,11 @@ app.get("/public/portal/:token/slots", async (req, res) => {
} }
d.setDate(d.getDate() + 1); d.setDate(d.getDate() + 1);
} }
res.json({ ok: true, days: availableDays }); res.json({ ok: true, days: availableDays });
} catch (e) { console.error("Error Slots:", e); res.status(500).json({ ok: false }); } } catch (e) { res.status(500).json({ ok: false }); }
}); });
// 2. Guardar la cita elegida por el cliente // 2. Guardar la cita como "SOLICITUD PENDIENTE"
app.post("/public/portal/:token/book", async (req, res) => { app.post("/public/portal/:token/book", async (req, res) => {
const client = await pool.connect(); const client = await pool.connect();
try { try {
@@ -713,7 +715,6 @@ app.post("/public/portal/:token/book", async (req, res) => {
const { serviceId, date, time } = req.body; const { serviceId, date, time } = req.body;
await client.query('BEGIN'); await client.query('BEGIN');
const clientQ = await client.query("SELECT owner_id FROM clients WHERE portal_token = $1", [token]); const clientQ = await client.query("SELECT owner_id FROM clients WHERE portal_token = $1", [token]);
if (clientQ.rowCount === 0) throw new Error("Token inválido"); if (clientQ.rowCount === 0) throw new Error("Token inválido");
const ownerId = clientQ.rows[0].owner_id; const ownerId = clientQ.rows[0].owner_id;
@@ -723,26 +724,102 @@ app.post("/public/portal/:token/book", async (req, res) => {
const raw = serviceQ.rows[0].raw_data; const raw = serviceQ.rows[0].raw_data;
// Buscamos el estado "Citado" // Guardamos las fechas requeridas pero NO asignamos aún la cita real
const statusQ = await client.query("SELECT id FROM service_statuses WHERE owner_id=$1 AND name ILIKE '%citado%' LIMIT 1", [ownerId]); const updatedRaw = {
const idCitado = statusQ.rows[0]?.id || raw.status_operativo; ...raw,
requested_date: date,
const updatedRaw = { ...raw, scheduled_date: date, scheduled_time: time, status_operativo: idCitado }; requested_time: time,
appointment_status: 'pending'
};
await client.query("UPDATE scraped_services SET raw_data = $1 WHERE id = $2", [JSON.stringify(updatedRaw), serviceId]); await client.query("UPDATE scraped_services SET raw_data = $1 WHERE id = $2", [JSON.stringify(updatedRaw), serviceId]);
// Disparamos el WhatsApp informando de la cita
await triggerWhatsAppEvent(ownerId, serviceId, 'wa_evt_date');
await client.query('COMMIT'); await client.query('COMMIT');
res.json({ ok: true }); res.json({ ok: true });
} catch (e) { } catch (e) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
console.error("Error Booking:", e);
res.status(500).json({ ok: false, error: e.message }); res.status(500).json({ ok: false, error: e.message });
} finally { client.release(); } } finally { client.release(); }
}); });
// 3. OBTENER SOLICITUDES PARA EL PANEL DEL ADMIN
app.get("/agenda/requests", authMiddleware, async (req, res) => {
try {
const q = await pool.query(`
SELECT s.id, s.service_ref, s.raw_data, u.full_name as assigned_name
FROM scraped_services s
LEFT JOIN users u ON s.assigned_to = u.id
WHERE s.owner_id = $1
AND s.raw_data->>'appointment_status' = 'pending'
ORDER BY s.created_at ASC
`, [req.user.accountId]);
res.json({ ok: true, requests: q.rows });
} catch (e) { res.status(500).json({ ok: false }); }
});
// 4. APROBAR CITA (Aquí se establece la duración y se envía WA)
app.post("/agenda/requests/:id/approve", authMiddleware, async (req, res) => {
try {
const { id } = req.params;
const { duration } = req.body; // En minutos
const current = await pool.query('SELECT raw_data 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 reqDate = raw.requested_date;
const reqTime = raw.requested_time;
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;
const updatedRaw = {
...raw,
scheduled_date: reqDate,
scheduled_time: reqTime,
duration_minutes: duration,
appointment_status: 'approved',
status_operativo: idCitado
};
delete updatedRaw.requested_date;
delete updatedRaw.requested_time;
await pool.query("UPDATE scraped_services SET raw_data=$1 WHERE id=$2", [JSON.stringify(updatedRaw), id]);
// Disparamos WhatsApp oficial de cita confirmada
await triggerWhatsAppEvent(req.user.accountId, id, 'wa_evt_date');
res.json({ok: true});
} catch (e) { res.status(500).json({ok: false}); }
});
// 5. RECHAZAR CITA
app.post("/agenda/requests/:id/reject", authMiddleware, async (req, res) => {
try {
const { id } = req.params;
const current = await pool.query('SELECT raw_data 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 updatedRaw = { ...raw, appointment_status: 'rejected' };
delete updatedRaw.requested_date;
delete updatedRaw.requested_time;
await pool.query("UPDATE scraped_services SET raw_data=$1 WHERE id=$2", [JSON.stringify(updatedRaw), id]);
// Enviar WA de rechazo con el enlace para que elija otra
const phone = raw["Teléfono"] || raw["TELEFONO"] || "";
if (phone) {
const clientQ = await pool.query("SELECT portal_token FROM clients WHERE phone LIKE $1 AND owner_id=$2 LIMIT 1", [`%${phone.replace('+34', '').trim()}%`, req.user.accountId]);
const token = clientQ.rows[0]?.portal_token;
const link = `https://portal.integrarepara.es/?token=${token}&service=${id}`;
const finalMsg = `⚠️ *CITA NO CONFIRMADA*\n\nHola ${raw["Nombre Cliente"] || "Cliente"}. Lamentamos informarte que el técnico no podrá acudir en el horario que solicitaste por un problema de ruta.\n\nPor favor, entra de nuevo en tu portal y elige otro hueco disponible:\n🔗 ${link}`;
await sendWhatsAppAuto(phone, finalMsg, `cliente_${req.user.accountId}`, false);
}
res.json({ok: true});
} catch (e) { res.status(500).json({ok: false}); }
});
// ========================================== // ==========================================
// ⚙️ MOTOR AUTOMÁTICO DE WHATSAPP // ⚙️ MOTOR AUTOMÁTICO DE WHATSAPP
// ========================================== // ==========================================