From 251397cdeadcf8e734049d7f3c918440250fc5db Mon Sep 17 00:00:00 2001 From: marsalva Date: Sat, 3 Jan 2026 23:10:10 +0000 Subject: [PATCH] Actualizar index.js --- index.js | 478 +++++++++++++++++++++++++------------------------------ 1 file changed, 221 insertions(+), 257 deletions(-) diff --git a/index.js b/index.js index 1452cf3..e6a0ad2 100644 --- a/index.js +++ b/index.js @@ -1,166 +1,124 @@ 'use strict'; const express = require('express'); -const helmet = require('helmet'); const cors = require('cors'); -const { chromium } = require('playwright'); const admin = require('firebase-admin'); +const { chromium } = require('playwright'); + +const PORT = parseInt(process.env.PORT || '3000', 10); + +// ====== CONFIG ====== +const CONFIG = { + HOMESERVE_BASE_URL: process.env.HOMESERVE_BASE_URL || 'https://gestor.homeserve.es/', + + // Firestore doc path que guarda credenciales HS + // Ejemplo: secrets/homeserve (collection/doc) + HS_CRED_DOC_PATH: process.env.HS_CRED_DOC_PATH || 'secrets/homeserve', + + // Seguridad: si REQUIRE_AUTH=1, obliga a token Firebase (Authorization: Bearer ) + REQUIRE_AUTH: String(process.env.REQUIRE_AUTH || '1') === '1', + + // Playwright + HEADLESS: String(process.env.HEADLESS || 'true') !== 'false', + + // Selectores (ajustables por env si el portal cambia) + SEL: { + user: process.env.SEL_USER || 'input[type="text"], input[name="username"], input[id*="user"], input[autocomplete="username"]', + pass: process.env.SEL_PASS || 'input[type="password"], input[name="password"], input[id*="pass"], input[autocomplete="current-password"]', + submit: process.env.SEL_SUBMIT || 'button[type="submit"], button:has-text("Entrar"), button:has-text("Acceder"), button:has-text("Login")', + + // búsqueda parte + searchBox: process.env.SEL_SEARCH_BOX || 'input[placeholder*="Buscar"], input[type="search"]', + searchBtn: process.env.SEL_SEARCH_BTN || 'button:has-text("Buscar"), button:has-text("Search")', + + openRow: process.env.SEL_OPEN_ROW || 'table tbody tr:first-child', + + // cambio estado + statusDropdown: process.env.SEL_STATUS_DROPDOWN || 'select[name*="estado"], select[id*="estado"], select', + // opcional: input/selector para fecha seguimiento + followUpDate: process.env.SEL_FOLLOWUP_DATE || 'input[name*="fecha"], input[id*="fecha"], input[type="date"]', + // opcional: textarea nota + noteTextarea: process.env.SEL_NOTE_TEXTAREA || 'textarea[name*="nota"], textarea[id*="nota"], textarea', + saveBtn: process.env.SEL_SAVE_BTN || 'button:has-text("Guardar"), button:has-text("Save"), button:has-text("Actualizar")', + }, + + // Timeout + TIMEOUT_MS: parseInt(process.env.TIMEOUT_MS || '120000', 10), +}; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +let busy = false; + +// ====== FIREBASE ADMIN INIT ====== +function initFirebaseAdmin() { + // Opción A: variables (como tus robots antiguos) + const hasEnvKey = !!process.env.FIREBASE_PRIVATE_KEY; + if (hasEnvKey) { + if (admin.apps.length === 0) { + admin.initializeApp({ + credential: admin.credential.cert({ + projectId: mustEnv('FIREBASE_PROJECT_ID'), + clientEmail: mustEnv('FIREBASE_CLIENT_EMAIL'), + privateKey: mustEnv('FIREBASE_PRIVATE_KEY').replace(/\\n/g, '\n'), + }), + }); + } + return; + } + + // Opción B: Application Default Credentials (GOOGLE_APPLICATION_CREDENTIALS) + // En CapRover puedes montar un archivo JSON y exportar GOOGLE_APPLICATION_CREDENTIALS + if (admin.apps.length === 0) { + admin.initializeApp({ + credential: admin.credential.applicationDefault(), + }); + } +} -// -------------------- helpers -------------------- function mustEnv(name) { const v = process.env[name]; if (!v) throw new Error(`Missing env: ${name}`); return v; } -function optEnv(name, fallback) { - const v = process.env[name]; - return (v === undefined || v === null || v === '') ? fallback : v; -} - -function sleep(ms) { - return new Promise((r) => setTimeout(r, ms)); -} - -// -------------------- Firebase Admin -------------------- -function initFirebaseAdmin() { - if (admin.apps.length) return; - - const projectId = mustEnv('FIREBASE_PROJECT_ID'); - const clientEmail = mustEnv('FIREBASE_CLIENT_EMAIL'); - const privateKeyRaw = mustEnv('FIREBASE_PRIVATE_KEY'); - - admin.initializeApp({ - credential: admin.credential.cert({ - projectId, - clientEmail, - privateKey: privateKeyRaw.replace(/\\n/g, '\n'), - }), - }); -} - -async function verifyFirebaseIdTokenIfPresent(req) { - // Si quieres obligar a auth: REQUIRE_AUTH=1 - const requireAuth = optEnv('REQUIRE_AUTH', '1') === '1'; - - const auth = req.headers.authorization || ''; - const m = auth.match(/^Bearer\s+(.+)$/i); - const token = m ? m[1] : null; - - if (!token) { - if (requireAuth) throw new Error('Missing Authorization Bearer token'); - return null; - } - - const decoded = await admin.auth().verifyIdToken(token); - // Opcional: lista blanca de UID - const allowed = (process.env.ALLOWED_UIDS || '') - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - - if (allowed.length && !allowed.includes(decoded.uid)) { - throw new Error('User not allowed (uid not in ALLOWED_UIDS)'); - } - - return decoded; -} - -// -------------------- HomeServe Status Map (tu Swift) -------------------- -const STATUS_CODE_MAP = { - "303": ["En espera de Cliente por aceptación Presupuesto"], - "307": ["En espera de Profesional por fecha de inicio de trabajos"], - "313": ["En espera de Profesional por secado de cala, pintura o parquet"], - "318": ["En espera de Profesional por confirmación del Siniestro"], - "319": ["En espera de Profesional por material"], - "320": ["En espera de Profesional por espera de otro gremio"], - "321": ["En espera de Profesional por presupuesto/valoración"], - "323": ["En espera de Profesional por mejora del tiempo"], - "326": ["En espera de Cliente por pago de Factura Contado/Franquicia"], - "336": ["En espera de Profesional por avería en observación"], - "342": ["En espera de Profesional pendiente cobro franquicia"], - "345": ["En espera de Profesional en realización pendiente Terminar"], - "348": ["En espera de Cliente por indicaciones"], - "352": ["En espera de Perjudicado por indicaciones"], -}; - -// -------------------- Config -------------------- -const CONFIG = { - PORT: parseInt(optEnv('PORT', '3000'), 10), - - // dónde guardas las credenciales de HomeServe en Firestore - // Formato: "collection/doc" o "collection/doc/subcollection/doc" - HS_CRED_DOC_PATH: optEnv('HS_CRED_DOC_PATH', 'secrets/homeserve'), - - // Si quieres fallback por ENV (por si firestore no está listo) - HOMESERVE_BASE_URL: optEnv('HOMESERVE_BASE_URL', 'https://gestor.homeserve.es/'), - HOMESERVE_USER: process.env.HOMESERVE_USER || null, - HOMESERVE_PASS: process.env.HOMESERVE_PASS || null, - - // Seguridad extra (si no quieres usar Firebase auth): - // pon API_KEY y el HTML mandará X-API-Key - API_KEY: process.env.API_KEY || null, - - // Selectores (ajustables por env) - SEL: { - user: optEnv('SEL_USER', 'input[type="text"]'), - pass: optEnv('SEL_PASS', 'input[type="password"]'), - submit: optEnv('SEL_SUBMIT', 'button[type="submit"]'), - - searchBox: optEnv('SEL_SEARCH_BOX', 'input[placeholder*="Buscar"], input[type="search"]'), - searchBtn: optEnv('SEL_SEARCH_BTN', 'button:has-text("Buscar"), button:has-text("Search")'), - openRow: optEnv('SEL_OPEN_ROW', 'table tbody tr:first-child'), - - statusDropdown: optEnv('SEL_STATUS_DROPDOWN', 'select[name*="estado"], select[id*="estado"], select:has(option)'), - noteTextarea: optEnv('SEL_NOTE_TEXTAREA', 'textarea[name*="nota"], textarea[id*="nota"], textarea'), - saveBtn: optEnv('SEL_SAVE_BTN', 'button:has-text("Guardar"), button:has-text("Save"), button:has-text("Actualizar")'), - }, - - // comportamiento - HEADLESS: optEnv('HEADLESS', 'true') !== 'false', - SLOW_MO_MS: parseInt(optEnv('SLOW_MO_MS', '0'), 10), -}; - -// -------------------- Firestore: leer credenciales HS -------------------- -async function getHomeServeCredentials(db) { - // Doc esperado: - // { - // baseUrl: "https://gestor.homeserve.es/", - // user: "xxxx", - // pass: "yyyy" - // } - const parts = CONFIG.HS_CRED_DOC_PATH.split('/').filter(Boolean); - if (parts.length < 2 || parts.length % 2 !== 0) { - throw new Error(`HS_CRED_DOC_PATH inválido: "${CONFIG.HS_CRED_DOC_PATH}". Debe ser collection/doc (o pares).`); - } - - let ref = db.collection(parts[0]).doc(parts[1]); - for (let i = 2; i < parts.length; i += 2) { - ref = ref.collection(parts[i]).doc(parts[i + 1]); - } - - const snap = await ref.get(); +async function getHomeServeCreds(db) { + // Espera que el doc tenga campos tipo: + // { user: "...", pass: "..." } o { username: "...", password: "..." } + const snap = await db.doc(CONFIG.HS_CRED_DOC_PATH).get(); if (!snap.exists) { - throw new Error(`No existe el documento de credenciales: ${CONFIG.HS_CRED_DOC_PATH}`); + throw new Error(`HS credentials doc not found: ${CONFIG.HS_CRED_DOC_PATH}`); } - - const data = snap.data() || {}; - const baseUrl = (data.baseUrl || data.HOMESERVE_BASE_URL || CONFIG.HOMESERVE_BASE_URL).toString(); - const user = (data.user || data.HOMESERVE_USER || CONFIG.HOMESERVE_USER || '').toString(); - const pass = (data.pass || data.HOMESERVE_PASS || CONFIG.HOMESERVE_PASS || '').toString(); - + const d = snap.data() || {}; + const user = (d.user || d.username || d.email || d.HOMESERVE_USER || '').toString().trim(); + const pass = (d.pass || d.password || d.HOMESERVE_PASS || '').toString().trim(); if (!user || !pass) { - throw new Error(`El doc ${CONFIG.HS_CRED_DOC_PATH} no tiene user/pass (o están vacíos).`); + throw new Error(`HS credentials doc missing fields (need user/pass): ${CONFIG.HS_CRED_DOC_PATH}`); } - - return { baseUrl, user, pass }; + return { user, pass }; } -// -------------------- Playwright actions -------------------- +async function verifyAuth(req) { + if (!CONFIG.REQUIRE_AUTH) return { ok: true, uid: null }; + + const h = req.headers.authorization || ''; + const m = h.match(/^Bearer\s+(.+)$/i); + if (!m) { + return { ok: false, status: 401, error: 'Missing Authorization Bearer token' }; + } + + try { + const decoded = await admin.auth().verifyIdToken(m[1]); + return { ok: true, uid: decoded.uid }; + } catch (e) { + return { ok: false, status: 401, error: 'Invalid token' }; + } +} + +// ====== PLAYWRIGHT FLOW ====== async function withBrowser(fn) { const browser = await chromium.launch({ headless: CONFIG.HEADLESS, - slowMo: CONFIG.SLOW_MO_MS, args: ['--no-sandbox', '--disable-setuid-sandbox'], }); @@ -174,27 +132,22 @@ async function withBrowser(fn) { } } -async function loginHomeServe(page, creds) { - await page.goto(creds.baseUrl, { waitUntil: 'domcontentloaded', timeout: 120000 }); +async function login(page, hsUser, hsPass) { + await page.goto(CONFIG.HOMESERVE_BASE_URL, { waitUntil: 'domcontentloaded', timeout: CONFIG.TIMEOUT_MS }); await page.waitForSelector(CONFIG.SEL.user, { timeout: 60000 }); - await page.fill(CONFIG.SEL.user, creds.user); + await page.fill(CONFIG.SEL.user, hsUser); await page.waitForSelector(CONFIG.SEL.pass, { timeout: 60000 }); - await page.fill(CONFIG.SEL.pass, creds.pass); + await page.fill(CONFIG.SEL.pass, hsPass); const btn = await page.$(CONFIG.SEL.submit); if (btn) await btn.click(); else await page.keyboard.press('Enter'); - await page.waitForLoadState('networkidle', { timeout: 120000 }); - - // Si HomeServe muestra “Credenciales incorrectas” en el DOM: - // (esto es opcional; si no existe, no pasa nada) - const possibleError = await page.$('text=/credenciales\\s+incorrectas/i'); - if (possibleError) { - throw new Error('HomeServe: credenciales incorrectas (detectado en pantalla)'); - } + // Importante: a veces HS no llega a networkidle (polling interno). Mejor esperar a DOM + un respiro. + await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.TIMEOUT_MS }); + await sleep(1200); } async function openParte(page, parteId) { @@ -205,165 +158,176 @@ async function openParte(page, parteId) { if (btn) await btn.click(); else await page.keyboard.press('Enter'); - await page.waitForLoadState('networkidle', { timeout: 120000 }); + await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.TIMEOUT_MS }); await sleep(1200); } await page.waitForSelector(CONFIG.SEL.openRow, { timeout: 60000 }); await page.click(CONFIG.SEL.openRow); - await page.waitForLoadState('networkidle', { timeout: 120000 }); - await sleep(800); + + await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.TIMEOUT_MS }); + await sleep(1200); } -async function setEstadoByStatusCode(page, statusCode, notaFinal) { - const code = String(statusCode).trim(); - const labels = STATUS_CODE_MAP[code] || []; +function normalizeDateDDMMYYYY(s) { + const v = String(s || '').trim(); + if (!v) return ''; + // acepta dd/MM/yyyy o yyyy-MM-dd + if (/^\d{2}\/\d{2}\/\d{4}$/.test(v)) return v; + if (/^\d{4}-\d{2}-\d{2}$/.test(v)) { + const [y, m, d] = v.split('-'); + return `${d}/${m}/${y}`; + } + return v; // lo dejamos tal cual si el usuario mete algo raro +} + +async function setEstadoByCode(page, statusCode, followUpDate, note) { + const code = String(statusCode || '').trim(); + if (!code) throw new Error('Missing statusCode'); await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: 60000 }); - // 1) Intento por value = code + // 1) intenta por value exacto (lo normal si el portal usa códigos) let selected = false; try { await page.selectOption(CONFIG.SEL.statusDropdown, { value: code }); selected = true; } catch (_) {} - // 2) Intento por label exacto + // 2) si no, intenta por label que contenga el código if (!selected) { - for (const label of labels) { + const ok = await page.evaluate(({ sel, code }) => { + const s = document.querySelector(sel); + if (!s) return false; + const opts = Array.from(s.querySelectorAll('option')); + const hit = opts.find(o => { + const t = (o.textContent || '').trim(); + // acepta "Texto (348)" o "348 - Texto" o similares + return t.includes(code) || t.startsWith(code); + }); + if (!hit) return false; + s.value = hit.value; + s.dispatchEvent(new Event('change', { bubbles: true })); + return true; + }, { sel: CONFIG.SEL.statusDropdown, code }); + + if (!ok) throw new Error(`No matching status option for code "${code}"`); + } + + // Fecha seguimiento (opcional) + const dateStr = normalizeDateDDMMYYYY(followUpDate); + if (dateStr) { + const hasDate = await page.$(CONFIG.SEL.followUpDate); + if (hasDate) { + // si es input type="date" espera yyyy-MM-dd, pero muchos portales usan texto. + // Intentamos poner tal cual; si falla, no rompemos. try { - await page.selectOption(CONFIG.SEL.statusDropdown, { label }); - selected = true; - break; + await page.fill(CONFIG.SEL.followUpDate, dateStr); } catch (_) {} } } - // 3) Fallback DOM: contains / code match - if (!selected) { - const ok = await page.evaluate(({ sel, code, labels }) => { - const s = document.querySelector(sel); - if (!s) return false; - - const opts = Array.from(s.querySelectorAll('option')); - const norm = (x) => (x || '').trim().toLowerCase(); - const needles = labels.map(norm).filter(Boolean); - - const hit = opts.find((o) => { - const t = norm(o.textContent); - const v = norm(o.value); - if (v === norm(code)) return true; - if (t.includes(norm(code))) return true; - return needles.some((n) => t.includes(n)); - }); - - if (!hit) return false; - - s.value = hit.value; - s.dispatchEvent(new Event('change', { bubbles: true })); - return true; - }, { sel: CONFIG.SEL.statusDropdown, code, labels }); - - if (!ok) throw new Error(`No encuentro el estado en el desplegable para statusCode=${code}`); - } - - if (notaFinal) { + // Nota (opcional) + if (note) { const ta = await page.$(CONFIG.SEL.noteTextarea); - if (ta) await page.fill(CONFIG.SEL.noteTextarea, String(notaFinal)); + if (ta) { + await page.fill(CONFIG.SEL.noteTextarea, String(note)); + } } const save = await page.$(CONFIG.SEL.saveBtn); if (!save) throw new Error('Save button not found'); await save.click(); - await page.waitForLoadState('networkidle', { timeout: 120000 }); + await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.TIMEOUT_MS }); await sleep(1200); } -// -------------------- Express app -------------------- +// ====== EXPRESS SERVER ====== initFirebaseAdmin(); const db = admin.firestore(); const app = express(); -app.use(helmet()); -app.use(cors({ origin: '*'})); -app.use(express.json({ limit: '512kb' })); +app.use(cors()); +app.use(express.json({ limit: '1mb' })); -// Concurrencia: servidor pequeño => 1 job a la vez -let busy = false; - -app.get('/health', (_req, res) => { - res.json({ ok: true, busy, ts: new Date().toISOString() }); +// Para CapRover: NO 404 en / +app.get('/', (req, res) => { + res.status(200).json({ ok: true, service: 'estados-hs', ts: new Date().toISOString() }); }); -app.post('/v1/homeserve/change-status', async (req, res) => { +app.get('/health', (req, res) => { + res.status(200).json({ ok: true, busy, ts: new Date().toISOString() }); +}); + +/** + * POST /v1/homeserve/status + * Body: + * { + * "parteId": "12345678", + * "statusCode": "348", + * "followUpDate": "03/01/2026" // opcional (dd/MM/yyyy o yyyy-MM-dd) + * "observation": "texto..." // opcional + * } + */ +app.post('/v1/homeserve/status', async (req, res) => { + const auth = await verifyAuth(req); + if (!auth.ok) return res.status(auth.status).json({ ok: false, error: auth.error }); + + const parteId = (req.body?.parteId || req.body?.serviceNumber || req.body?.parte || '').toString().trim(); + const statusCode = (req.body?.statusCode || req.body?.newStatusValue || req.body?.code || '').toString().trim(); + const followUpDate = (req.body?.followUpDate || req.body?.dateString || '').toString().trim(); + const observation = (req.body?.observation || req.body?.nota || req.body?.note || '').toString().trim(); + + if (!parteId || !statusCode) { + return res.status(400).json({ + ok: false, + error: 'Missing parteId or statusCode', + example: { parteId: '28197832', statusCode: '348', followUpDate: '03/01/2026', observation: '...' }, + }); + } + + if (busy) { + return res.status(409).json({ ok: false, error: 'BUSY', message: 'Server is processing another request' }); + } + + busy = true; const startedAt = new Date().toISOString(); try { - // Seguridad: API_KEY opcional - if (CONFIG.API_KEY) { - const k = req.headers['x-api-key']; - if (!k || String(k) !== String(CONFIG.API_KEY)) { - return res.status(401).json({ ok: false, error: 'Invalid X-API-Key' }); - } - } - - // Seguridad: Firebase token (por defecto requerido) - await verifyFirebaseIdTokenIfPresent(req); - - if (busy) { - return res.status(409).json({ ok: false, error: 'BUSY: ya hay un cambio en curso' }); - } - - const { serviceNumber, statusCode, dateString, observation } = req.body || {}; - const parteId = String(serviceNumber || '').trim(); - const code = String(statusCode || '').trim(); - - if (!parteId) return res.status(400).json({ ok: false, error: 'serviceNumber requerido' }); - if (!code) return res.status(400).json({ ok: false, error: 'statusCode requerido' }); - if (!STATUS_CODE_MAP[code]) { - return res.status(400).json({ - ok: false, - error: `statusCode inválido: ${code}`, - allowed: Object.keys(STATUS_CODE_MAP), - }); - } - - // Nota final: metemos fecha si viene - const ds = (dateString ? String(dateString).trim() : ''); - const obs = (observation ? String(observation).trim() : ''); - const notaFinal = [obs, ds ? `Fecha: ${ds}` : ''].filter(Boolean).join(' · '); - - busy = true; - const creds = await getHomeServeCredentials(db); + const { user, pass } = await getHomeServeCreds(db); await withBrowser(async (page) => { - await loginHomeServe(page, creds); + await login(page, user, pass); await openParte(page, parteId); - await setEstadoByStatusCode(page, code, notaFinal); + await setEstadoByCode(page, statusCode, followUpDate, observation); }); - busy = false; - return res.json({ ok: true, + parteId, + statusCode, + followUpDate: followUpDate || null, + observation: observation || null, startedAt, finishedAt: new Date().toISOString(), - serviceNumber: parteId, - statusCode: code, - statusText: STATUS_CODE_MAP[code][0], - noteSent: notaFinal, + uid: auth.uid, }); - } catch (err) { + } catch (e) { + return res.status(500).json({ + ok: false, + error: String(e?.message || e), + startedAt, + finishedAt: new Date().toISOString(), + }); + } finally { busy = false; - const msg = String(err?.message || err); - return res.status(500).json({ ok: false, error: msg }); } }); -app.listen(CONFIG.PORT, () => { - console.log(`[estados-hs] listening on :${CONFIG.PORT}`); +app.listen(PORT, () => { + console.log(`[estados-hs] listening on :${PORT}`); console.log(`[estados-hs] HS_CRED_DOC_PATH=${CONFIG.HS_CRED_DOC_PATH}`); - console.log(`[estados-hs] REQUIRE_AUTH=${optEnv('REQUIRE_AUTH', '1')}`); + console.log(`[estados-hs] REQUIRE_AUTH=${CONFIG.REQUIRE_AUTH ? '1' : '0'}`); }); \ No newline at end of file