Actualizar server.js

This commit is contained in:
2026-02-21 18:36:05 +00:00
parent e5bcb316ee
commit 6ea1da9017

161
server.js
View File

@@ -504,13 +504,17 @@ app.post("/public/assignment/respond", async (req, res) => {
// 🌐 RUTAS PÚBLICAS: PORTAL DEL CLIENTE (SIN FRICCIÓN) // 🌐 RUTAS PÚBLICAS: PORTAL DEL CLIENTE (SIN FRICCIÓN)
// ========================================== // ==========================================
// ==========================================
// 🌐 RUTAS PÚBLICAS: PORTAL DEL CLIENTE (SIN FRICCIÓN)
// ==========================================
// 1. Cargar datos del cliente, logo y empresa
app.get("/public/portal/:token", async (req, res) => { app.get("/public/portal/:token", async (req, res) => {
try { try {
const { token } = req.params; const { token } = req.params;
// 1. Identificamos al cliente
const clientQ = await pool.query(` const clientQ = await pool.query(`
SELECT c.id, c.full_name, c.phone, c.addresses, c.owner_id, SELECT c.id, c.full_name, c.phone, c.addresses, c.owner_id,
u.company_slug, u.full_name as company_name u.company_slug, u.full_name as company_name, u.company_logo
FROM clients c FROM clients c
JOIN users u ON c.owner_id = u.id JOIN users u ON c.owner_id = u.id
WHERE c.portal_token = $1 WHERE c.portal_token = $1
@@ -519,7 +523,6 @@ app.get("/public/portal/:token", async (req, res) => {
if (clientQ.rowCount === 0) return res.status(404).json({ ok: false, error: "Enlace no válido o caducado" }); if (clientQ.rowCount === 0) return res.status(404).json({ ok: false, error: "Enlace no válido o caducado" });
const clientData = clientQ.rows[0]; const clientData = clientQ.rows[0];
// 2. Buscamos los expedientes en el Panel Operativo (scraped_services)
const phoneRaw = clientData.phone.replace('+34', ''); const phoneRaw = clientData.phone.replace('+34', '');
const scrapedQ = await pool.query(` const scrapedQ = await pool.query(`
SELECT id, service_ref as title, raw_data->>'Descripción' as description, SELECT id, service_ref as title, raw_data->>'Descripción' as description,
@@ -535,7 +538,6 @@ app.get("/public/portal/:token", async (req, res) => {
ORDER BY created_at DESC ORDER BY created_at DESC
`, [clientData.owner_id, `%${phoneRaw}%`]); `, [clientData.owner_id, `%${phoneRaw}%`]);
// Adaptamos el formato visual de los estados
const services = scrapedQ.rows.map(s => { const services = scrapedQ.rows.map(s => {
let statusName = "Pendiente de Asignar"; let color = "gray"; let statusName = "Pendiente de Asignar"; let color = "gray";
if (s.estado_operativo === 'asignado_operario') { statusName = "Asignado a Técnico"; color = "blue"; } if (s.estado_operativo === 'asignado_operario') { statusName = "Asignado a Técnico"; color = "blue"; }
@@ -561,72 +563,13 @@ app.get("/public/portal/:token", async (req, res) => {
res.json({ res.json({
ok: true, ok: true,
client: { name: clientData.full_name, phone: clientData.phone, addresses: clientData.addresses }, client: { name: clientData.full_name, phone: clientData.phone, addresses: clientData.addresses },
company: { name: clientData.company_name, slug: clientData.company_slug }, company: { name: clientData.company_name, slug: clientData.company_slug, logo: clientData.company_logo },
services: services services: services
}); });
} catch (e) { res.status(500).json({ ok: false, error: "Error de servidor" }); } } catch (e) { res.status(500).json({ ok: false, error: "Error de servidor" }); }
}); });
app.post("/public/portal/:token/request", async (req, res) => { // 2. Obtener huecos disponibles inteligentes (CON HORARIOS DINÁMICOS Y 30 MIN)
const client = await pool.connect();
try {
const { token } = req.params;
const { description, address } = req.body;
await client.query('BEGIN');
const clientQ = await client.query("SELECT id, owner_id, full_name, phone FROM clients WHERE portal_token = $1", [token]);
if (clientQ.rowCount === 0) throw new Error("Token inválido");
const cData = clientQ.rows[0];
const statusQ = await client.query("SELECT id FROM service_statuses WHERE owner_id=$1 AND is_default=TRUE LIMIT 1", [cData.owner_id]);
const statusId = statusQ.rows[0]?.id;
const insertSvc = await client.query(`
INSERT INTO services (owner_id, client_id, status_id, contact_name, contact_phone, address, description, title, import_source)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'PORTAL_CLIENTE') RETURNING id
`, [cData.owner_id, cData.id, statusId, cData.full_name, cData.phone, address, description, "Nuevo Aviso desde App Cliente"]);
await client.query("INSERT INTO service_logs (service_id, new_status_id, comment) VALUES ($1, $2, 'Aviso reportado por el cliente desde su portal')", [insertSvc.rows[0].id, statusId]);
await client.query('COMMIT');
res.json({ ok: true, message: "Aviso recibido", service_id: insertSvc.rows[0].id });
} catch (e) {
await client.query('ROLLBACK');
res.status(500).json({ ok: false, error: e.message });
} finally { client.release(); }
});
// ==========================================
// 🔐 RUTAS AUTH Y PRIVADAS ( CRM ORIGINAL )
// ==========================================
app.post("/auth/register", async (req, res) => { const client = await pool.connect(); try { const { fullName, phone, address, dni, email, password } = req.body; const p = normalizePhone(phone); if (!fullName || !p || !email || !password) return res.status(400).json({ ok: false }); const passwordHash = await bcrypt.hash(password, 10); await client.query('BEGIN'); const insert = await client.query("INSERT INTO users (full_name, phone, address, dni, email, password_hash, role, owner_id, plan_tier) VALUES ($1, $2, $3, $4, $5, $6, 'admin', NULL, 'free') RETURNING id", [fullName, p, address, dni, email, passwordHash]); const userId = insert.rows[0].id; const code = genCode6(); const codeHash = await bcrypt.hash(code, 10); const expiresAt = new Date(Date.now() + 10 * 60 * 1000); await client.query("INSERT INTO login_codes (user_id, phone, code_hash, expires_at) VALUES ($1, $2, $3, CURRENT_TIMESTAMP + INTERVAL '10 minutes')", [userId, p, codeHash]);
await sendWhatsAppCode(p, code);
await client.query('COMMIT'); res.json({ ok: true, phone: p }); } catch (e) { await client.query('ROLLBACK'); res.status(500).json({ ok: false }); } finally { client.release(); } });
app.post("/auth/verify", async (req, res) => { try { const { phone, code } = req.body; const p = normalizePhone(phone); const q = await pool.query(`SELECT lc.*, u.id as uid, u.email, u.role, u.owner_id FROM login_codes lc JOIN users u ON lc.user_id = u.id WHERE lc.phone=$1 AND lc.consumed_at IS NULL AND lc.expires_at > CURRENT_TIMESTAMP ORDER BY lc.created_at DESC LIMIT 1`, [p]); if (q.rowCount === 0) return res.status(400).json({ ok: false }); const row = q.rows[0]; if (!(await bcrypt.compare(String(code), row.code_hash))) return res.status(400).json({ ok: false }); await pool.query("UPDATE login_codes SET consumed_at=CURRENT_TIMESTAMP WHERE id=$1", [row.id]); await pool.query("UPDATE users SET is_verified=TRUE WHERE id=$1", [row.uid]); res.json({ ok: true, token: signToken({ id: row.uid, email: row.email, phone: p, role: row.role, owner_id: row.owner_id }) }); } catch (e) { res.status(500).json({ ok: false }); } });
app.post("/auth/login", async (req, res) => { try { const { email, password } = req.body; const q = await pool.query("SELECT * FROM users WHERE email=$1", [email]); if (q.rowCount === 0) return res.status(401).json({ ok: false }); let user = null; for (const u of q.rows) { if (await bcrypt.compare(password, u.password_hash)) { user = u; break; } } if (!user) return res.status(401).json({ ok: false }); res.json({ ok: true, token: signToken(user) }); } catch(e) { res.status(500).json({ ok: false }); } });
app.get("/whatsapp/status", authMiddleware, (req, res, next) => requirePlan(req, res, next, 'whatsapp_enabled'), async (req, res) => {
try {
const instanceName = `cliente_${req.user.accountId}`;
const { baseUrl, headers } = await ensureInstance(instanceName);
const stateRes = await fetch(`${baseUrl}/instance/connectionState/${instanceName}`, { headers });
const stateData = await stateRes.json();
const state = stateData.instance?.state || "close";
let qr = null;
if (state !== "open") {
const qrRes = await fetch(`${baseUrl}/instance/connect/${instanceName}`, { headers });
const qrData = await qrRes.json();
qr = qrData.code || qrData.base64;
}
res.json({ ok: true, state, qr, instanceName });
} catch (e) { res.status(500).json({ ok: false, error: e.message }); }
});
// ==========================================
// 🧠 MOTOR INTELIGENTE DE AGENDAMIENTO (RUTAS NUEVAS)
// ==========================================
// ==========================================
// 🧠 MOTOR INTELIGENTE DE AGENDAMIENTO Y AGENDA ADMIN
// ==========================================
// 1. Obtener huecos disponibles inteligentes (Bloqueando por duración y GREMIO)
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;
@@ -634,6 +577,30 @@ app.get("/public/portal/:token/slots", async (req, res) => {
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" });
const ownerId = clientQ.rows[0].owner_id;
// EXTRAEMOS LA CONFIGURACIÓN DE HORARIOS DEL PORTAL
const userQ = await pool.query("SELECT portal_settings FROM users WHERE id = $1", [ownerId]);
const pSet = userQ.rows[0]?.portal_settings || { m_start:"09:00", m_end:"14:00", a_start:"16:00", a_end:"19:00" };
// Función para generar huecos cada 30 minutos
function genSlots(start, end) {
if(!start || !end) return [];
let s = [];
let [sh, sm] = start.split(':').map(Number);
let [eh, em] = end.split(':').map(Number);
let cur = sh * 60 + sm;
let limit = eh * 60 + em;
while(cur <= limit) {
s.push(`${String(Math.floor(cur/60)).padStart(2,'0')}:${String(cur%60).padStart(2,'0')}`);
cur += 30; // Saltos de 30 minutos
}
return s;
}
// Creamos la plantilla de horas libres del día
const morningBase = genSlots(pSet.m_start, pSet.m_end);
const afternoonBase = genSlots(pSet.a_start, pSet.a_end);
const serviceQ = await pool.query("SELECT * FROM scraped_services WHERE id=$1", [serviceId]); const serviceQ = await pool.query("SELECT * FROM scraped_services WHERE id=$1", [serviceId]);
if (serviceQ.rowCount === 0) return res.status(404).json({ ok: false, error: "Servicio no encontrado" }); if (serviceQ.rowCount === 0) return res.status(404).json({ ok: false, error: "Servicio no encontrado" });
@@ -644,9 +611,8 @@ app.get("/public/portal/:token/slots", async (req, res) => {
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();
const targetGuildId = raw["guild_id"]; // Extraemos el gremio del servicio actual const targetGuildId = raw["guild_id"];
// Extraemos la agenda respetando la duración (duration_minutes) y el gremio bloqueado
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,
@@ -663,23 +629,23 @@ app.get("/public/portal/:token/slots", async (req, res) => {
const agendaMap = {}; const agendaMap = {};
agendaQ.rows.forEach(row => { agendaQ.rows.forEach(row => {
// INTELIGENCIA DE GREMIOS:
// Si es un bloqueo de agenda que tiene un gremio específico, y NO coincide con el gremio del servicio actual...
// ¡Lo ignoramos! (El hueco sigue libre para este servicio)
if (row.provider === 'SYSTEM_BLOCK' && row.blocked_guild_id && String(row.blocked_guild_id) !== String(targetGuildId)) { if (row.provider === 'SYSTEM_BLOCK' && row.blocked_guild_id && String(row.blocked_guild_id) !== String(targetGuildId)) {
return; return;
} }
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() };
// Calculamos cuántas horas bloquea este servicio según su duración // Bloqueamos la agenda en fracciones de 30 minutos reales
const dur = parseInt(row.duration || 60); // Por defecto 1 hora const dur = parseInt(row.duration || 60);
if (row.time) { if (row.time) {
const startHour = parseInt(row.time.split(':')[0]); let [th, tm] = row.time.split(':').map(Number);
const hoursBlocked = Math.ceil(dur / 60); let startMin = th * 60 + tm;
for (let i = 0; i < hoursBlocked; i++) { let endMin = startMin + dur;
const h = startHour + i; // Si la cita es de 10:00 a 11:00, bloqueamos las 10:00 y las 10:30 (la de las 11 queda libre para la siguiente)
agendaMap[row.date].times.push(h.toString().padStart(2, '0') + ":00"); for (let m = startMin; m < endMin; m += 30) {
let hStr = String(Math.floor(m/60)).padStart(2,'0');
let mStr = String(m%60).padStart(2,'0');
agendaMap[row.date].times.push(`${hStr}:${mStr}`);
} }
} }
}); });
@@ -702,11 +668,10 @@ app.get("/public/portal/:token/slots", async (req, res) => {
} }
if (isDayAllowed) { if (isDayAllowed) {
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 takenTimes = dayData ? dayData.times : [];
const availMorning = morningSlots.filter(t => !takenTimes.includes(t)); // Filtramos nuestra plantilla contra los huecos ocupados
const availAfternoon = afternoonSlots.filter(t => !takenTimes.includes(t)); const availMorning = morningBase.filter(t => !takenTimes.includes(t));
const availAfternoon = afternoonBase.filter(t => !takenTimes.includes(t));
if (availMorning.length > 0 || availAfternoon.length > 0) { if (availMorning.length > 0 || availAfternoon.length > 0) {
availableDays.push({ availableDays.push({
@@ -725,40 +690,6 @@ app.get("/public/portal/:token/slots", async (req, res) => {
} catch (e) { console.error("Error Slots:", e); res.status(500).json({ ok: false }); } } catch (e) { console.error("Error Slots:", e); res.status(500).json({ ok: false }); }
}); });
// 2. Guardar la cita como "SOLICITUD PENDIENTE"
app.post("/public/portal/:token/book", async (req, res) => {
const client = await pool.connect();
try {
const { token } = req.params;
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;
const serviceQ = await client.query("SELECT raw_data FROM scraped_services WHERE id=$1 AND owner_id=$2", [serviceId, ownerId]);
if (serviceQ.rowCount === 0) throw new Error("Servicio no encontrado");
const raw = serviceQ.rows[0].raw_data;
// 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]);
await client.query('COMMIT');
res.json({ ok: true });
} catch (e) {
await client.query('ROLLBACK');
res.status(500).json({ ok: false, error: e.message });
} finally { client.release(); }
});
// 3. OBTENER SOLICITUDES PARA EL PANEL DEL ADMIN // 3. OBTENER SOLICITUDES PARA EL PANEL DEL ADMIN
app.get("/agenda/requests", authMiddleware, async (req, res) => { app.get("/agenda/requests", authMiddleware, async (req, res) => {
try { try {