Actualizar server.js
This commit is contained in:
141
server.js
141
server.js
@@ -614,13 +614,16 @@ app.get("/whatsapp/status", authMiddleware, (req, res, next) => requirePlan(req,
|
||||
// 🧠 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) => {
|
||||
try {
|
||||
const { token } = req.params;
|
||||
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]);
|
||||
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;
|
||||
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 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(`
|
||||
SELECT raw_data->>'scheduled_date' as date,
|
||||
raw_data->>'scheduled_time' as time,
|
||||
raw_data->>'duration_minutes' as duration,
|
||||
raw_data->>'Población' as poblacion,
|
||||
raw_data->>'Código Postal' as cp
|
||||
FROM scraped_services
|
||||
@@ -647,43 +650,43 @@ app.get("/public/portal/:token/slots", async (req, res) => {
|
||||
AND raw_data->>'scheduled_date' >= CURRENT_DATE::text
|
||||
`, [assignedTo]);
|
||||
|
||||
// Mapa de días ocupados y sus zonas "Ancla"
|
||||
const agendaMap = {};
|
||||
agendaQ.rows.forEach(row => {
|
||||
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 = [];
|
||||
let d = new Date();
|
||||
d.setDate(d.getDate() + 1); // Empezamos desde mañana
|
||||
d.setDate(d.getDate() + 1);
|
||||
|
||||
let daysAdded = 0;
|
||||
while(daysAdded < 10) {
|
||||
// Saltamos domingos
|
||||
if (d.getDay() !== 0) {
|
||||
const dateStr = d.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
if (d.getDay() !== 0) { // Omitir domingos
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
const dayData = agendaMap[dateStr];
|
||||
|
||||
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) {
|
||||
// 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)) {
|
||||
isDayAllowed = false;
|
||||
}
|
||||
}
|
||||
|
||||
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 afternoonSlots = ["16:00", "17:00", "18:00", "19:00"];
|
||||
|
||||
const takenTimes = dayData ? dayData.times : [];
|
||||
|
||||
const availMorning = morningSlots.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);
|
||||
}
|
||||
|
||||
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) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
@@ -713,7 +715,6 @@ app.post("/public/portal/:token/book", async (req, res) => {
|
||||
const { serviceId, date, time } = req.body;
|
||||
|
||||
await client.query('BEGIN');
|
||||
|
||||
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");
|
||||
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;
|
||||
|
||||
// Buscamos el estado "Citado"
|
||||
const statusQ = await client.query("SELECT id FROM service_statuses WHERE owner_id=$1 AND name ILIKE '%citado%' LIMIT 1", [ownerId]);
|
||||
const idCitado = statusQ.rows[0]?.id || raw.status_operativo;
|
||||
|
||||
const updatedRaw = { ...raw, scheduled_date: date, scheduled_time: time, status_operativo: idCitado };
|
||||
// Guardamos las fechas requeridas pero NO asignamos aún la cita real
|
||||
const updatedRaw = {
|
||||
...raw,
|
||||
requested_date: date,
|
||||
requested_time: time,
|
||||
appointment_status: 'pending'
|
||||
};
|
||||
|
||||
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');
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error("Error Booking:", e);
|
||||
res.status(500).json({ ok: false, error: e.message });
|
||||
} 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
|
||||
// ==========================================
|
||||
|
||||
Reference in New Issue
Block a user