From cbd109013a52a8d419d4a7436eae8ca5afe6c063 Mon Sep 17 00:00:00 2001 From: marsalva Date: Wed, 19 Aug 2026 22:10:43 +0000 Subject: [PATCH] Anade backend de partes de trabajo --- server.js | 188 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) diff --git a/server.js b/server.js index d298e3c..1c65e90 100644 --- a/server.js +++ b/server.js @@ -813,6 +813,23 @@ async function autoUpdateDB() { is_internal BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT NOW() ); + + -- đŸ§Ÿ PARTES DE TRABAJO (texto redactado con IA + firma del cliente + PDF) + CREATE TABLE IF NOT EXISTS work_reports ( + id SERIAL PRIMARY KEY, + scraped_id INT REFERENCES scraped_services(id) ON DELETE CASCADE, + owner_id INT REFERENCES users(id) ON DELETE CASCADE, + operator_id INT REFERENCES users(id) ON DELETE SET NULL, + operator_name TEXT, + client_name TEXT, + client_phone TEXT, + raw_text TEXT, + final_text TEXT NOT NULL, + signature_data TEXT, + pdf_base64 TEXT, + sent_to JSONB DEFAULT '[]', + created_at TIMESTAMP DEFAULT NOW() + ); `); // PARCHE DE ACTUALIZACIÓN @@ -2951,6 +2968,48 @@ async function sendWhatsAppAuto(phone, message, instanceName, useDelay = false) } } +// 📎 EnvĂ­a un documento (PDF) por WhatsApp vĂ­a Evolution API. Devuelve {ok, detail} +// para poder ver el motivo exacto de un fallo (Ăștil para depurar en caliente). +async function sendWhatsAppMedia(phone, base64Pdf, fileName, caption, instanceName) { + if (!EVOLUTION_BASE_URL || !EVOLUTION_API_KEY) { + console.error("⚠ WhatsApp omitido: Servidor Evolution no configurado."); + return { ok: false, detail: "Evolution no configurado" }; + } + if (!base64Pdf) return { ok: false, detail: "Sin PDF" }; + + try { + const baseUrl = EVOLUTION_BASE_URL.replace(/\/$/, ""); + const url = `${baseUrl}/message/sendMedia/${instanceName}`; + const cleanBase64 = base64Pdf.includes(',') ? base64Pdf.split(',').pop() : base64Pdf; + + const response = await fetch(url, { + method: 'POST', + headers: { + "Content-Type": "application/json", + "apikey": EVOLUTION_API_KEY.trim() + }, + body: JSON.stringify({ + number: phone, + mediatype: "document", + mimetype: "application/pdf", + fileName: fileName, + caption: caption, + media: cleanBase64 + }) + }); + + if (!response.ok) { + const errText = await response.text().catch(() => ""); + console.error("❌ Fallo enviando documento WhatsApp. Estado Evolution:", response.status, errText); + return { ok: false, detail: `HTTP ${response.status}: ${errText.substring(0, 300)}` }; + } + return { ok: true }; + } catch (error) { + console.error("❌ Error de red enviando documento WhatsApp:", error.message); + return { ok: false, detail: error.message }; + } +} + // ========================================== // 🔔 DISPARADOR DE EVENTOS DE WHATSAPP (CON MODO SEGURO) // ========================================== @@ -4990,6 +5049,135 @@ app.post("/services/:id/chat", authMiddleware, async (req, res) => { } }); +// ========================================== +// đŸ§Ÿ PARTES DE TRABAJO (texto con IA + firma + PDF + envĂ­o WhatsApp) +// ========================================== + +// 1. Reescribe con IA el texto que escribe el operario, en un tono profesional +app.post("/work-reports/improve", authMiddleware, async (req, res) => { + try { + const { text } = req.body; + if (!text || !text.toString().trim()) return res.status(400).json({ ok: false, error: "Falta el texto" }); + + const completion = await openai.chat.completions.create({ + model: OPENAI_MODEL || "gpt-4o-mini", + messages: [ + { role: "system", content: "Eres un tĂ©cnico administrativo que redacta partes de trabajo profesionales para una empresa de reparaciones y mantenimiento del hogar. Reescribe las notas que te da el operario en un texto claro, profesional, ordenado y objetivo, en español, describiendo el diagnĂłstico y el trabajo realizado. No inventes datos que no te den. No uses markdown, ni asteriscos, ni listas con guiones. MĂĄximo 120 palabras." }, + { role: "user", content: text.toString().trim() } + ], + temperature: 0.4 + }); + + const improved = completion.choices?.[0]?.message?.content?.trim(); + if (!improved) return res.status(500).json({ ok: false, error: "La IA no ha devuelto ningĂșn texto" }); + res.json({ ok: true, text: improved }); + } catch (e) { + console.error("❌ Error mejorando parte de trabajo con IA:", e.message); + res.status(500).json({ ok: false, error: "Error al contactar con la IA" }); + } +}); + +// 2. Guarda un parte de trabajo ya generado (texto final + firma + PDF en base64) +app.post("/services/:id/work-report", authMiddleware, async (req, res) => { + try { + const { id } = req.params; + const { raw_text, final_text, signature_data, pdf_base64 } = req.body; + + if (!final_text || !final_text.toString().trim()) return res.status(400).json({ ok: false, error: "Falta el texto del parte" }); + if (!pdf_base64) return res.status(400).json({ ok: false, error: "Falta el PDF generado" }); + + const svcQ = await pool.query("SELECT * FROM scraped_services WHERE id=$1 AND owner_id=$2", [id, req.user.accountId]); + if (svcQ.rowCount === 0) return res.status(404).json({ ok: false, error: "Servicio no encontrado" }); + const raw = svcQ.rows[0].raw_data || {}; + + const userQ = await pool.query("SELECT full_name FROM users WHERE id=$1", [req.user.sub]); + const operatorName = userQ.rows[0]?.full_name || "Operario"; + + const clientName = raw["Nombre Cliente"] || raw["CLIENTE"] || ""; + const clientPhoneRaw = raw["TelĂ©fono"] || raw["TELEFONO"] || raw["TELEFONOS"] || ""; + const clientPhone = extractValidPhone(clientPhoneRaw); + + const insert = await pool.query( + `INSERT INTO work_reports (scraped_id, owner_id, operator_id, operator_name, client_name, client_phone, raw_text, final_text, signature_data, pdf_base64) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING id, created_at`, + [id, req.user.accountId, req.user.sub, operatorName, clientName, clientPhone, (raw_text || "").toString(), final_text.toString().trim(), signature_data || null, pdf_base64] + ); + + res.json({ ok: true, report: { id: insert.rows[0].id, created_at: insert.rows[0].created_at, client_phone: clientPhone, client_name: clientName } }); + } catch (e) { + console.error("❌ Error guardando parte de trabajo:", e); + res.status(500).json({ ok: false }); + } +}); + +// 3. Lista el historial de partes de trabajo de un servicio (sin el PDF pesado) +app.get("/services/:id/work-reports", authMiddleware, async (req, res) => { + try { + const { id } = req.params; + const q = await pool.query( + `SELECT id, operator_name, final_text, client_phone, sent_to, created_at + FROM work_reports WHERE scraped_id=$1 AND owner_id=$2 ORDER BY created_at DESC`, + [id, req.user.accountId] + ); + res.json({ ok: true, reports: q.rows }); + } catch (e) { + console.error("❌ Error listando partes de trabajo:", e); + res.status(500).json({ ok: false }); + } +}); + +// 4. Devuelve el PDF en base64 de un parte concreto (para verlo o reenviarlo) +app.get("/work-reports/:id/pdf", authMiddleware, async (req, res) => { + try { + const q = await pool.query("SELECT pdf_base64, client_name FROM work_reports WHERE id=$1 AND owner_id=$2", [req.params.id, req.user.accountId]); + if (q.rowCount === 0) return res.status(404).json({ ok: false }); + res.json({ ok: true, pdf_base64: q.rows[0].pdf_base64, client_name: q.rows[0].client_name }); + } catch (e) { + res.status(500).json({ ok: false }); + } +}); + +// 5. EnvĂ­a por WhatsApp el PDF de un parte ya guardado. Si no se pasan telĂ©fonos, +// se envĂ­a automĂĄticamente al telĂ©fono del cliente guardado en el parte. +app.post("/work-reports/:id/send", authMiddleware, async (req, res) => { + try { + const { id } = req.params; + let { phones } = req.body; + + const q = await pool.query("SELECT * FROM work_reports WHERE id=$1 AND owner_id=$2", [id, req.user.accountId]); + if (q.rowCount === 0) return res.status(404).json({ ok: false, error: "Parte no encontrado" }); + const report = q.rows[0]; + + if (!phones || !Array.isArray(phones) || phones.length === 0) { + phones = report.client_phone ? [report.client_phone] : []; + } + phones = phones.map(p => extractValidPhone(p)).filter(p => p && p.length >= 9); + if (phones.length === 0) return res.status(400).json({ ok: false, error: "No hay ningĂșn telĂ©fono vĂĄlido al que enviar" }); + + const fileName = `Parte_Trabajo_${report.scraped_id}.pdf`; + const caption = `📋 Parte de trabajo${report.client_name ? ` - ${report.client_name}` : ''}`; + + const results = []; + for (const p of phones) { + const finalPhone = "34" + p; + const sendRes = await sendWhatsAppMedia(finalPhone, report.pdf_base64, fileName, caption, `cliente_${req.user.accountId}`); + results.push({ phone: finalPhone, ok: sendRes.ok, detail: sendRes.ok ? undefined : sendRes.detail }); + } + + const sentOk = results.filter(r => r.ok).map(r => r.phone); + if (sentOk.length > 0) { + const prevSent = Array.isArray(report.sent_to) ? report.sent_to : []; + const newSentTo = Array.from(new Set([...prevSent, ...sentOk])); + await pool.query("UPDATE work_reports SET sent_to=$1 WHERE id=$2", [JSON.stringify(newSentTo), id]); + } + + res.json({ ok: sentOk.length > 0, results }); + } catch (e) { + console.error("❌ Error enviando parte de trabajo por WhatsApp:", e); + res.status(500).json({ ok: false }); + } +}); + // đŸ€– WEBHOOK CON ESCUDO HUMANO, MEMORIA Y GESTIÓN DE URGENCIAS // 🧠 Ejecuta realmente el turno de la IA para un expediente (llamado tras el debounce) async function procesarTurnoIA({ ownerId, service, mensajeTexto, telefonoCliente, instanceName }) {