From 219d049045c060adae00d44e4ae6ab9a8f4f450e Mon Sep 17 00:00:00 2001 From: marsalva Date: Mon, 7 Sep 2026 06:59:04 +0000 Subject: [PATCH] =?UTF-8?q?A=C3=B1ade=20Ficha=20del=20Cliente:=20historial?= =?UTF-8?q?=20de=20servicios=20y=20notas=20del=20equipo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nueva tabla client_notes + endpoints GET/POST /clients/:id/history y /clients/:id/notes. También corrige POST /clients/ensure para devolver la fila completa del cliente (antes solo devolvía portal_token, faltaba el id). --- server.js | 90 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 85 insertions(+), 5 deletions(-) diff --git a/server.js b/server.js index d54c546..86c22d6 100644 --- a/server.js +++ b/server.js @@ -4152,10 +4152,12 @@ app.post('/clients/ensure', authMiddleware, async (req, res) => { } else { const newToken = crypto.randomBytes(6).toString('hex'); const insert = await pool.query( - "INSERT INTO clients (owner_id, full_name, phone, addresses, portal_token) VALUES ($1, $2, $3, $4, $5) RETURNING portal_token", + "INSERT INTO clients (owner_id, full_name, phone, addresses, portal_token) VALUES ($1, $2, $3, $4, $5) RETURNING *", [ownerId, name || "Cliente", phone, JSON.stringify([address || ""]), newToken] ); - res.json({ ok: true, client: { portal_token: insert.rows[0].portal_token } }); + // 🆕 Devolvemos la fila completa (antes solo el portal_token), porque ahora la + // Ficha del Cliente de la App necesita el id del cliente recién creado. + res.json({ ok: true, client: insert.rows[0] }); } } catch (e) { res.status(500).json({ ok: false, error: "Error interno del servidor" }); @@ -5021,14 +5023,92 @@ app.get("/services/:id/logs", authMiddleware, async (req, res) => { const q = await pool.query(` SELECT l.* FROM scraped_service_logs l JOIN scraped_services s ON l.scraped_id = s.id - WHERE l.scraped_id = $1 + WHERE l.scraped_id = $1 AND s.owner_id = $2 ORDER BY l.created_at DESC `, [req.params.id, req.user.accountId]); res.json({ ok: true, logs: q.rows }); - } catch(e) { - res.status(500).json({ ok: false }); + } catch(e) { + res.status(500).json({ ok: false }); + } +}); + +// ========================================== +// 🧑‍🔧 FICHA DEL CLIENTE (App): historial de servicios + notas propias del equipo +// ========================================== +pool.query(` + CREATE TABLE IF NOT EXISTS client_notes ( + id SERIAL PRIMARY KEY, + client_id INT REFERENCES clients(id) ON DELETE CASCADE, + owner_id INT REFERENCES users(id) ON DELETE CASCADE, + user_name TEXT, + note TEXT NOT NULL, + created_at TIMESTAMP DEFAULT NOW() + ); +`).catch(console.error); + +// Ficha completa: datos del cliente + todos sus servicios anteriores (de cualquier compañía), +// buscados por teléfono igual que hace /clients/search. +app.get("/clients/:id/history", authMiddleware, async (req, res) => { + try { + const ownerId = req.user.accountId; + const clientQ = await pool.query("SELECT * FROM clients WHERE id=$1 AND owner_id=$2", [req.params.id, ownerId]); + if (clientQ.rowCount === 0) return res.status(404).json({ ok: false, error: "Cliente no encontrado" }); + const client = clientQ.rows[0]; + + const bare = extractValidPhone(client.phone); + const servicesQ = await pool.query(` + SELECT s.id, s.provider, s.service_ref, s.status, s.raw_data, s.created_at, + st.name as status_name, st.color as status_color, st.is_final as status_is_final + FROM scraped_services s + LEFT JOIN service_statuses st ON st.id::text = (s.raw_data->>'status_operativo')::text + WHERE s.owner_id=$1 + AND regexp_replace(COALESCE(s.raw_data->>'Teléfono', s.raw_data->>'TELEFONO', s.raw_data->>'TELEFONOS', ''), '\\D', '', 'g') LIKE '%' || $2 || '%' + ORDER BY s.created_at DESC + LIMIT 100 + `, [ownerId, bare]); + + res.json({ ok: true, client, services: servicesQ.rows }); + } catch (e) { + console.error("Error cargando ficha de cliente:", e); + res.status(500).json({ ok: false }); + } +}); + +// Notas del equipo sobre este cliente (visibles para toda la cuenta, no solo quien las escribe) +app.get("/clients/:id/notes", authMiddleware, async (req, res) => { + try { + const q = await pool.query(` + SELECT n.* FROM client_notes n + JOIN clients c ON n.client_id = c.id + WHERE n.client_id = $1 AND c.owner_id = $2 + ORDER BY n.created_at DESC + `, [req.params.id, req.user.accountId]); + res.json({ ok: true, notes: q.rows }); + } catch (e) { + res.status(500).json({ ok: false }); + } +}); + +app.post("/clients/:id/notes", authMiddleware, async (req, res) => { + try { + const { note } = req.body; + if (!note || !note.trim()) return res.status(400).json({ ok: false, error: "La nota está vacía" }); + + const check = await pool.query("SELECT id FROM clients WHERE id=$1 AND owner_id=$2", [req.params.id, req.user.accountId]); + if (check.rowCount === 0) return res.status(403).json({ ok: false, error: "No autorizado" }); + + const userQ = await pool.query("SELECT full_name FROM users WHERE id=$1", [req.user.sub]); + const userName = userQ.rows[0]?.full_name || "Usuario"; + + const insert = await pool.query( + "INSERT INTO client_notes (client_id, owner_id, user_name, note) VALUES ($1, $2, $3, $4) RETURNING *", + [req.params.id, req.user.accountId, userName, note.trim()] + ); + res.json({ ok: true, note: insert.rows[0] }); + } catch (e) { + res.status(500).json({ ok: false }); } });