'use strict'; const express = require('express'); const cors = require('cors'); const { chromium } = require('playwright'); const admin = require('firebase-admin'); // -------------------- Firebase -------------------- function mustEnv(name) { const v = process.env[name]; if (!v) throw new Error(`Missing env: ${name}`); return v; } function initFirebase() { if (admin.apps?.length) return admin.firestore(); if (!process.env.FIREBASE_PRIVATE_KEY) { throw new Error('Missing env: FIREBASE_PRIVATE_KEY'); } admin.initializeApp({ credential: admin.credential.cert({ projectId: mustEnv('FIREBASE_PROJECT_ID'), clientEmail: mustEnv('FIREBASE_CLIENT_EMAIL'), privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'), }), }); return admin.firestore(); } async function getHomeServeCreds(db) { // Tu caso real: providerCredentials/homeserve con { user, pass } const docPath = process.env.HS_CRED_DOC_PATH || 'providerCredentials/homeserve'; let fsUser = ''; let fsPass = ''; try { const snap = await db.doc(docPath).get(); if (snap.exists) { const d = snap.data() || {}; fsUser = String(d.user || '').trim(); fsPass = String(d.pass || '').trim(); } } catch (_) {} const envUser = String(process.env.HOMESERVE_USER || '').trim(); const envPass = String(process.env.HOMESERVE_PASS || '').trim(); const user = fsUser || envUser; const pass = fsPass || envPass; if (!user || !pass) { throw new Error( `HomeServe creds missing. Put {user,pass} in Firestore doc "${docPath}" or set env HOMESERVE_USER/HOMESERVE_PASS` ); } return { user, pass, source: fsUser ? `firestore:${docPath}` : 'env' }; } // -------------------- Config -------------------- const CONFIG = { // Flujo antiguo: HOMESERVE_BASE_URL: process.env.HOMESERVE_BASE_URL || 'https://gestor.homeserve.es/', // Selectores (igual que el robot viejo, pero con extras) SEL: { user: process.env.SEL_USER || 'input[type="text"], input[name="user"], input[name="username"]', pass: process.env.SEL_PASS || 'input[type="password"], input[name="pass"], input[name="password"]', submit: process.env.SEL_SUBMIT || 'button[type="submit"], input[type="submit"], button:has-text("Entrar")', 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" i], select[id*="estado" i], select:has(option)', // Observación/nota noteTextarea: process.env.SEL_NOTE_TEXTAREA || 'textarea[name*="nota" i], textarea[id*="nota" i], textarea', // Fecha (si existe) dateInput: process.env.SEL_DATE_INPUT || 'input[name*="fec" i], input[id*="fec" i], input[placeholder*="dd" i], input[placeholder*="fecha" i]', // Checkbox “ya informado al cliente” informedCheckbox: process.env.SEL_INFORMED_CHECKBOX || 'input[type="checkbox"][name*="inform" i], input[type="checkbox"][id*="inform" i]', // Botón guardar saveBtn: process.env.SEL_SAVE_BTN || 'button:has-text("Guardar"), button:has-text("Save"), button:has-text("Actualizar"), input[type="submit"]', }, HEADLESS: String(process.env.HEADLESS || '1') !== '0', NAV_TIMEOUT_MS: parseInt(process.env.NAV_TIMEOUT_MS || '120000', 10), // Auth opcional del endpoint REQUIRE_AUTH: String(process.env.REQUIRE_AUTH || '0') === '1', API_KEY: process.env.API_KEY || '', }; // Estados por código (los de tu app) const STATUS_CODES = [ { code: '303', title: 'En espera de Cliente por aceptación Presupuesto' }, { code: '307', title: 'En espera de Profesional por fecha de inicio de trabajos' }, { code: '313', title: 'En espera de Profesional por secado de cala, pintura o parquet' }, { code: '318', title: 'En espera de Profesional por confirmación del Siniestro' }, { code: '319', title: 'En espera de Profesional por material' }, { code: '320', title: 'En espera de Profesional por espera de otro gremio' }, { code: '321', title: 'En espera de Profesional por presupuesto/valoración' }, { code: '323', title: 'En espera de Profesional por mejora del tiempo' }, { code: '326', title: 'En espera de Cliente por pago de Factura Contado/Franquicia' }, { code: '336', title: 'En espera de Profesional por avería en observación' }, { code: '342', title: 'En espera de Profesional pendiente cobro franquicia' }, { code: '345', title: 'En espera de Profesional en realización pendiente Terminar' }, { code: '348', title: 'En espera de Cliente por indicaciones' }, { code: '352', title: 'En espera de Perjudicado por indicaciones' }, ]; const CODE_TO_TITLE = new Map(STATUS_CODES.map(x => [x.code, x.title])); // -------------------- Helpers -------------------- const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const nowISO = () => new Date().toISOString(); const trim = (v) => String(v ?? '').trim(); function parseBool(v, def = true) { if (v === undefined || v === null || v === '') return def; const s = String(v).toLowerCase().trim(); if (['1', 'true', 'yes', 'si', 'sí'].includes(s)) return true; if (['0', 'false', 'no'].includes(s)) return false; return def; } async function withBrowser(fn) { const browser = await chromium.launch({ headless: CONFIG.HEADLESS, args: ['--no-sandbox', '--disable-setuid-sandbox'], }); const context = await browser.newContext({ viewport: { width: 1280, height: 800 } }); const page = await context.newPage(); try { return await fn(page); } finally { await browser.close().catch(() => {}); } } // -------------------- Playwright flow (como el robot antiguo) -------------------- async function login(page, creds) { await page.goto(CONFIG.HOMESERVE_BASE_URL, { waitUntil: 'domcontentloaded', timeout: CONFIG.NAV_TIMEOUT_MS }); await page.waitForSelector(CONFIG.SEL.user, { timeout: 60000 }); await page.fill(CONFIG.SEL.user, creds.user); await page.waitForSelector(CONFIG.SEL.pass, { timeout: 60000 }); await page.fill(CONFIG.SEL.pass, creds.pass); const btn = await page.$(CONFIG.SEL.submit); if (btn) await btn.click(); else await page.keyboard.press('Enter'); // networkidle a veces es traicionero; domcontentloaded + pause suele ir mejor await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT_MS }); await sleep(1200); } async function openParte(page, parteId) { const hasSearch = await page.$(CONFIG.SEL.searchBox); if (hasSearch) { await page.fill(CONFIG.SEL.searchBox, String(parteId)); const btn = await page.$(CONFIG.SEL.searchBtn); if (btn) await btn.click(); else await page.keyboard.press('Enter'); await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT_MS }); await sleep(1200); } await page.waitForSelector(CONFIG.SEL.openRow, { timeout: 60000 }); await page.click(CONFIG.SEL.openRow); await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT_MS }); await sleep(900); } async function checkInformedBox(page) { // 1) intento selector directo const cb = await page.$(CONFIG.SEL.informedCheckbox); if (cb) { const isChecked = await cb.isChecked().catch(() => false); if (!isChecked) await cb.check().catch(() => {}); return true; } // 2) intento por texto de label (tu frase exacta) const ok = await page.evaluate(() => { const text = 'Marque esta casilla, si ya ha informado al Cliente'.toLowerCase(); const labels = Array.from(document.querySelectorAll('label')); const hit = labels.find(l => (l.textContent || '').toLowerCase().includes(text)); if (!hit) return false; const forId = hit.getAttribute('for'); if (forId) { const input = document.getElementById(forId); if (input && input.type === 'checkbox') { input.checked = true; input.dispatchEvent(new Event('change', { bubbles: true })); return true; } } // si no hay "for", buscamos checkbox cercano let el = hit; for (let i = 0; i < 4; i++) { const parent = el.parentElement; if (!parent) break; const cb2 = parent.querySelector('input[type="checkbox"]'); if (cb2) { cb2.checked = true; cb2.dispatchEvent(new Event('change', { bubbles: true })); return true; } el = parent; } return false; }); return !!ok; } async function setEstadoByCodeOrLabel(page, newStatusValue) { await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: 60000 }); // Primero intentamos por VALUE (si el select usa value=303 etc) try { await page.selectOption(CONFIG.SEL.statusDropdown, { value: String(newStatusValue) }); return { method: 'value', picked: String(newStatusValue) }; } catch (_) {} // Segundo: intentamos por label con el texto del código (si el option pinta "348 - En espera...") const title = CODE_TO_TITLE.get(String(newStatusValue)); const candidates = []; if (title) { candidates.push(`${newStatusValue} · ${title}`); candidates.push(`${newStatusValue} - ${title}`); candidates.push(`${newStatusValue} ${title}`); candidates.push(title); } else { candidates.push(String(newStatusValue)); } for (const label of candidates) { try { await page.selectOption(CONFIG.SEL.statusDropdown, { label }); return { method: 'label', picked: label }; } catch (_) {} } // Tercero: buscar en DOM option que contenga el código const ok = await page.evaluate(({ sel, code }) => { const s = document.querySelector(sel); if (!s) return null; const opts = Array.from(s.querySelectorAll('option')); const hit = opts.find(o => String(o.value).trim() === String(code).trim()) || opts.find(o => (o.textContent || '').includes(String(code))); if (!hit) return null; s.value = hit.value; s.dispatchEvent(new Event('change', { bubbles: true })); return { value: hit.value, text: (hit.textContent || '').trim() }; }, { sel: CONFIG.SEL.statusDropdown, code: String(newStatusValue) }); if (!ok) throw new Error(`No matching status option for code "${newStatusValue}"`); return { method: 'dom', picked: ok.value, text: ok.text }; } async function fillDateIfExists(page, dateString) { if (!trim(dateString)) return false; const el = await page.$(CONFIG.SEL.dateInput); if (!el) return false; try { await page.fill(CONFIG.SEL.dateInput, String(dateString)); return true; } catch (_) { return false; } } async function fillObservationIfExists(page, observation) { if (!trim(observation)) return false; const ta = await page.$(CONFIG.SEL.noteTextarea); if (!ta) return false; await page.fill(CONFIG.SEL.noteTextarea, String(observation)); return true; } async function clickSave(page) { const save = await page.$(CONFIG.SEL.saveBtn); if (!save) throw new Error('Save button not found'); await save.click(); await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT_MS }); await sleep(1200); } async function changeStatusViaGestor(page, job, creds) { await login(page, creds); await openParte(page, job.serviceNumber); const pick = await setEstadoByCodeOrLabel(page, job.newStatusValue); const informed = job.informoCliente ? await checkInformedBox(page) : false; const dateFilled = await fillDateIfExists(page, job.dateString); const obsFilled = await fillObservationIfExists(page, job.observation); await clickSave(page); const title = await page.title().catch(() => ''); const snippet = await page.evaluate(() => (document.body ? document.body.innerText : '')).catch(() => ''); return { ok: true, flow: 'gestor', picked: pick, informedChecked: informed, dateFilled, observationFilled: obsFilled, pageTitle: title, snippet: trim(snippet).slice(0, 600), }; } // -------------------- API server -------------------- const app = express(); app.use(cors({ origin: true })); app.use(express.json({ limit: '1mb' })); app.get('/', (req, res) => res.status(200).send('ok')); app.get('/health', (req, res) => { const port = Number(process.env.PORT || process.env.CAPROVER_PORT || 3000); res.json({ ok: true, service: 'estados-hs', port, requireAuth: CONFIG.REQUIRE_AUTH, hsCredDocPath: process.env.HS_CRED_DOC_PATH || 'providerCredentials/homeserve', baseUrl: CONFIG.HOMESERVE_BASE_URL, ts: nowISO(), }); }); app.get('/statuses', (req, res) => { res.json({ ok: true, statuses: STATUS_CODES }); }); function authMiddleware(req, res, next) { if (!CONFIG.REQUIRE_AUTH) return next(); if (!CONFIG.API_KEY) return res.status(500).json({ ok: false, error: { message: 'REQUIRE_AUTH=1 but API_KEY not set' } }); const k = trim(req.headers['x-api-key'] || '') || trim(req.query.apiKey || ''); if (k !== CONFIG.API_KEY) return res.status(401).json({ ok: false, error: { message: 'Unauthorized' } }); next(); } // HTML de prueba servido desde el propio server (por si te da pereza subir un .html) app.get('/test', (req, res) => { res.type('html').send(buildTestHtml()); }); app.post('/api/homeserve/change-status', authMiddleware, async (req, res) => { const startedAtISO = nowISO(); const job = { serviceNumber: trim(req.body?.serviceNumber), newStatusValue: trim(req.body?.newStatusValue), dateString: trim(req.body?.dateString), observation: trim(req.body?.observation), informoCliente: parseBool(req.body?.informoCliente, true), // por defecto TRUE }; if (!job.serviceNumber || !job.newStatusValue) { return res.status(400).json({ ok: false, startedAtISO, finishedAtISO: nowISO(), error: { message: 'Missing serviceNumber or newStatusValue' }, }); } let db; try { db = initFirebase(); } catch (e) { return res.status(500).json({ ok: false, startedAtISO, finishedAtISO: nowISO(), error: { message: String(e?.message || e), stack: String(e?.stack || '') }, }); } try { const creds = await getHomeServeCreds(db); const result = await withBrowser(async (page) => { return await changeStatusViaGestor(page, job, creds); }); return res.json({ ok: true, startedAtISO, finishedAtISO: nowISO(), request: job, result, }); } catch (err) { return res.status(500).json({ ok: false, startedAtISO, finishedAtISO: nowISO(), request: job, error: { message: String(err?.message || err), stack: String(err?.stack || '') }, }); } }); function buildTestHtml() { const options = STATUS_CODES.map( (s) => `` ).join('\n'); return `