Añade Ficha del Cliente: historial de servicios y notas del equipo

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).
This commit is contained in:
2026-09-07 06:59:04 +00:00
parent 6a18d229be
commit 219d049045

View File

@@ -4152,10 +4152,12 @@ app.post('/clients/ensure', authMiddleware, async (req, res) => {
} else { } else {
const newToken = crypto.randomBytes(6).toString('hex'); const newToken = crypto.randomBytes(6).toString('hex');
const insert = await pool.query( 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] [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) { } catch (e) {
res.status(500).json({ ok: false, error: "Error interno del servidor" }); res.status(500).json({ ok: false, error: "Error interno del servidor" });
@@ -5032,6 +5034,84 @@ app.get("/services/:id/logs", authMiddleware, async (req, res) => {
} }
}); });
// ==========================================
// 🧑‍🔧 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 });
}
});
// ========================================== // ==========================================
// 📄 MOTOR DE PRESUPUESTOS (continuación) — POST/PUT/PATCH de /budgets // 📄 MOTOR DE PRESUPUESTOS (continuación) — POST/PUT/PATCH de /budgets
// 🩹 FIX: este bloque duplicaba por completo la creación de tablas y las rutas // 🩹 FIX: este bloque duplicaba por completo la creación de tablas y las rutas