From 6ea1da9017d06262665abd0cfeb90532cf4bc60f Mon Sep 17 00:00:00 2001 From: marsalva Date: Sat, 21 Feb 2026 18:36:05 +0000 Subject: [PATCH] Actualizar server.js --- server.js | 161 ++++++++++++++++-------------------------------------- 1 file changed, 46 insertions(+), 115 deletions(-) diff --git a/server.js b/server.js index 330810d..a9d1402 100644 --- a/server.js +++ b/server.js @@ -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) +// ========================================== + +// 1. Cargar datos del cliente, logo y empresa app.get("/public/portal/:token", async (req, res) => { try { const { token } = req.params; - // 1. Identificamos al cliente const clientQ = await pool.query(` 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 JOIN users u ON c.owner_id = u.id 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" }); const clientData = clientQ.rows[0]; - // 2. Buscamos los expedientes en el Panel Operativo (scraped_services) const phoneRaw = clientData.phone.replace('+34', ''); const scrapedQ = await pool.query(` 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 `, [clientData.owner_id, `%${phoneRaw}%`]); - // Adaptamos el formato visual de los estados const services = scrapedQ.rows.map(s => { let statusName = "Pendiente de Asignar"; let color = "gray"; 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({ ok: true, 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 }); } catch (e) { res.status(500).json({ ok: false, error: "Error de servidor" }); } }); -app.post("/public/portal/:token/request", async (req, res) => { - 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) +// 2. Obtener huecos disponibles inteligentes (CON HORARIOS DIN脕MICOS Y 30 MIN) app.get("/public/portal/:token/slots", async (req, res) => { try { const { token } = req.params; @@ -634,7 +577,31 @@ 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]); 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]); 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 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(` SELECT raw_data->>'scheduled_date' as date, raw_data->>'scheduled_time' as time, @@ -663,23 +629,23 @@ app.get("/public/portal/:token/slots", async (req, res) => { const agendaMap = {}; 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)) { return; } 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 - const dur = parseInt(row.duration || 60); // Por defecto 1 hora + // Bloqueamos la agenda en fracciones de 30 minutos reales + const dur = parseInt(row.duration || 60); 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"); + let [th, tm] = row.time.split(':').map(Number); + let startMin = th * 60 + tm; + let endMin = startMin + dur; + // 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) + 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) { - 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)); + // Filtramos nuestra plantilla contra los huecos ocupados + const availMorning = morningBase.filter(t => !takenTimes.includes(t)); + const availAfternoon = afternoonBase.filter(t => !takenTimes.includes(t)); if (availMorning.length > 0 || availAfternoon.length > 0) { 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 }); } }); -// 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 app.get("/agenda/requests", authMiddleware, async (req, res) => { try {