Actualizar server.js
This commit is contained in:
161
server.js
161
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)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 🌐 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,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]);
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user