'use strict'; /** * estados-hs-direct/index.js * - Endpoint directo: POST /api/homeserve/change-status * - Healthchecks: GET / , GET /health * - Test UI: GET /test * * Credenciales HomeServe: * 1) ENV HOMESERVE_USER/HOMESERVE_PASS (+ HOMESERVE_BASE_URL opcional) * 2) Firestore doc en HS_CRED_DOC_PATH (ej: providerCredentials/homeserve) * 3) Fallbacks: providerCredentials/homeserve -> secrets/homeserve */ const express = require('express'); const cors = require('cors'); const { chromium } = require('playwright'); const admin = require('firebase-admin'); const app = express(); app.use(cors({ origin: true })); app.use(express.json({ limit: '1mb' })); // --------------------- Utils --------------------- function mustEnv(name) { const v = process.env[name]; if (!v) throw new Error(`Missing env: ${name}`); return v; } function envBool(name, def = false) { const v = (process.env[name] ?? '').toString().trim().toLowerCase(); if (!v) return def; return ['1', 'true', 'yes', 'y', 'on'].includes(v); } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function nowISO() { return new Date().toISOString(); } function safeStr(x) { return (x === undefined || x === null) ? '' : String(x); } function normalizeDocPath(p) { // esperamos "collection/doc" if (!p) return null; const t = String(p).trim().replace(/^\/+|\/+$/g, ''); if (!t) return null; const parts = t.split('/').filter(Boolean); if (parts.length < 2) return null; return { col: parts[0], doc: parts[1] }; } function pickFirstNonEmpty(...vals) { for (const v of vals) { if (v !== undefined && v !== null && String(v).trim() !== '') return String(v).trim(); } return ''; } // --------------------- Firebase --------------------- function initFirebase() { if (!process.env.FIREBASE_PRIVATE_KEY) throw new Error('Missing env: FIREBASE_PRIVATE_KEY'); if (!admin.apps.length) { 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 admin.firestore(); } // --------------------- Config --------------------- const CFG = { REQUIRE_AUTH: envBool('REQUIRE_AUTH', false), // Si quieres, puedes dejarlo vacío y se usará providerCredentials/homeserve por defecto HS_CRED_DOC_PATH: process.env.HS_CRED_DOC_PATH || process.env.HS_CRED_DOC || process.env.HS_CRED_PATH || '', // Puerto PORT: Number(process.env.PORT || process.env.CAPROVER_PORT || 3000), // Base “clientes” por defecto (tu enlace es CGI) CLIENTES_CGI_BASE: process.env.CLIENTES_CGI_BASE || 'https://www.clientes.homeserve.es/cgi-bin/fccgi.exe', // Timeouts NAV_TIMEOUT: Number(process.env.NAV_TIMEOUT || 120000), SEL_TIMEOUT: Number(process.env.SEL_TIMEOUT || 60000), }; function maskCreds(obj) { return { ...obj, pass: obj.pass ? '***' : '', }; } // --------------------- Credenciales --------------------- async function getHomeServeCreds(db) { // 1) ENV const envUser = pickFirstNonEmpty(process.env.HOMESERVE_USER, process.env.HS_USER); const envPass = pickFirstNonEmpty(process.env.HOMESERVE_PASS, process.env.HS_PASS); const envBaseUrl = pickFirstNonEmpty(process.env.HOMESERVE_BASE_URL, process.env.HS_BASE_URL); if (envUser && envPass) { return { user: envUser, pass: envPass, baseUrl: envBaseUrl || CFG.CLIENTES_CGI_BASE, cgiBase: CFG.CLIENTES_CGI_BASE, source: 'env', }; } // 2) Firestore (intenta varios paths) const candidates = []; const p = normalizeDocPath(CFG.HS_CRED_DOC_PATH); if (p) candidates.push(`${p.col}/${p.doc}`); // Fallbacks IMPORTANTES (tu caso) candidates.push('providerCredentials/homeserve'); candidates.push('secrets/homeserve'); const tried = new Set(); for (const path of candidates) { if (!path || tried.has(path)) continue; tried.add(path); const dp = normalizeDocPath(path); if (!dp) continue; const snap = await db.collection(dp.col).doc(dp.doc).get(); if (!snap.exists) continue; const d = snap.data() || {}; const user = pickFirstNonEmpty(d.user, d.username, d.usuario); const pass = pickFirstNonEmpty(d.pass, d.password, d.clave); const baseUrl = pickFirstNonEmpty(d.baseUrl, d.url, d.loginUrl, envBaseUrl); if (user && pass) { return { user, pass, baseUrl: baseUrl || CFG.CLIENTES_CGI_BASE, cgiBase: CFG.CLIENTES_CGI_BASE, source: `firestore:${path}`, }; } } throw new Error( 'HomeServe creds missing. Busca en ENV (HOMESERVE_USER/HOMESERVE_PASS) o crea/usa el doc Firestore providerCredentials/homeserve con { user, pass, baseUrl? }.' ); } // --------------------- Playwright helpers --------------------- async function withBrowser(fn) { const browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'], }); const context = await browser.newContext(); const page = await context.newPage(); try { return await fn(page); } finally { await browser.close().catch(() => {}); } } function allFrames(page) { return page.frames(); } async function findLocatorInFrames(page, selector) { for (const fr of allFrames(page)) { const loc = fr.locator(selector); try { if (await loc.count()) return { frame: fr, locator: loc }; } catch (_) {} } return null; } async function clickFirstThatExists(page, selectors, opts = {}) { for (const sel of selectors) { const hit = await findLocatorInFrames(page, sel); if (hit) { await hit.locator.first().click(opts); return sel; } } return null; } async function fillFirstThatExists(page, selectors, value) { for (const sel of selectors) { const hit = await findLocatorInFrames(page, sel); if (hit) { await hit.locator.first().fill(value); return sel; } } return null; } async function checkInformoClienteIfNeeded(page, enabled) { if (!enabled) return false; // Intento 1: label explícito const labelTextVariants = [ 'ya ha informado al Cliente', 'ya ha informado al cliente', 'informado al Cliente', 'informado al cliente', 'Marque esta casilla', 'marque esta casilla', ]; for (const txt of labelTextVariants) { const sel = `label:has-text("${txt}") >> input[type="checkbox"]`; const hit = await findLocatorInFrames(page, sel); if (hit) { const cb = hit.locator.first(); if (!(await cb.isChecked())) await cb.check(); return true; } } // Intento 2: búsqueda en DOM (checkbox cerca de texto) const done = await page.evaluate((variants) => { const norm = (s) => (s || '').toLowerCase(); const labels = Array.from(document.querySelectorAll('label')); for (const l of labels) { const t = norm(l.textContent); if (!variants.some(v => t.includes(norm(v)))) continue; const cb = l.querySelector('input[type="checkbox"]') || document.getElementById(l.getAttribute('for') || ''); if (cb && cb.type === 'checkbox') { cb.checked = true; cb.dispatchEvent(new Event('change', { bubbles: true })); return true; } } // fallback: checkbox con texto al lado (layout antiguo) const cbs = Array.from(document.querySelectorAll('input[type="checkbox"]')); for (const cb of cbs) { const parentText = norm(cb.parentElement ? cb.parentElement.textContent : ''); if (variants.some(v => parentText.includes(norm(v)))) { cb.checked = true; cb.dispatchEvent(new Event('change', { bubbles: true })); return true; } } return false; }, labelTextVariants); return !!done; } async function selectStatusByCode(page, code) { // preferimos un const selectors = [ 'input[name="repaso"]', 'input[title*="Cambiar el Estado" i]', 'input[src*="estado1.gif" i]', 'input[type="image"][name*="repaso" i]', ]; const clicked = await clickFirstThatExists(page, selectors, { timeout: CFG.SEL_TIMEOUT }).catch(() => null); if (!clicked) throw new Error('No encuentro el botón de "Cambiar estado" (repaso).'); } async function submitChange(page) { const selectors = [ 'input[type="submit"][value*="Enviar" i]', 'input[type="submit"][value*="Guardar" i]', 'button:has-text("Enviar")', 'button:has-text("Guardar")', 'button:has-text("Aceptar")', 'input[type="image"][title*="Enviar" i]', ]; const clicked = await clickFirstThatExists(page, selectors, { timeout: CFG.SEL_TIMEOUT }).catch(() => null); if (!clicked) throw new Error('No encuentro el botón para guardar/enviar el cambio de estado.'); } async function changeStatusViaClientesPortal(db, reqBody) { const creds = await getHomeServeCreds(db); const serviceNumber = safeStr(reqBody.serviceNumber).trim(); const newStatusValue = safeStr(reqBody.newStatusValue).trim(); const dateString = safeStr(reqBody.dateString).trim(); // DD/MM/AAAA const observation = safeStr(reqBody.observation).trim(); const informoCliente = !!reqBody.informoCliente; if (!serviceNumber) throw new Error('Missing serviceNumber'); if (!newStatusValue) throw new Error('Missing newStatusValue'); const startedAtISO = nowISO(); const result = await withBrowser(async (page) => { // Login await loginClientesPortal(page, creds); // Abrir servicio const serviceUrl = buildServiceUrl(serviceNumber); await page.goto(serviceUrl, { waitUntil: 'domcontentloaded', timeout: CFG.NAV_TIMEOUT }); await page.waitForLoadState('networkidle', { timeout: CFG.NAV_TIMEOUT }).catch(() => {}); await page.waitForTimeout(800); // Click “cambio de estado” await clickChangeState(page); // Esperar que cargue formulario await page.waitForLoadState('networkidle', { timeout: CFG.NAV_TIMEOUT }).catch(() => {}); await page.waitForTimeout(800); // Seleccionar estado por CÓDIGO (307, 348, etc.) await selectStatusByCode(page, newStatusValue); // Fecha (si existe un input razonable) if (dateString) { const dateSelectors = [ 'input[name*="fecha" i]', 'input[id*="fecha" i]', 'input[placeholder*="dd" i]', 'input[type="text"][size="10"]', 'input[type="text"]', ]; // llenamos el primero que exista, pero intentando no pisar user/pass: ya estamos dentro await fillFirstThatExists(page, dateSelectors, dateString).catch(() => {}); } // Observación if (observation) { const obsSelectors = [ 'textarea[name*="obs" i]', 'textarea[name*="nota" i]', 'textarea[id*="obs" i]', 'textarea', ]; await fillFirstThatExists(page, obsSelectors, observation).catch(() => {}); } // Checkbox “ya he informado al Cliente” await checkInformoClienteIfNeeded(page, informoCliente).catch(() => false); // Guardar await submitChange(page); await page.waitForLoadState('networkidle', { timeout: CFG.NAV_TIMEOUT }).catch(() => {}); await page.waitForTimeout(1200); // Verificación “blanda”: volvemos al servicio y comprobamos que la página carga await page.goto(serviceUrl, { waitUntil: 'domcontentloaded', timeout: CFG.NAV_TIMEOUT }); await page.waitForLoadState('networkidle', { timeout: CFG.NAV_TIMEOUT }).catch(() => {}); const html = await page.content().catch(() => ''); return { ok: true, startedAtISO, finishedAtISO: nowISO(), usedCreds: maskCreds({ ...creds }), serviceUrl, // dejamos un “hint” por si quieres inspeccionar rápido pageContainsStatusCode: html.includes(String(newStatusValue)), }; }); return result; } // --------------------- Routes --------------------- const db = initFirebase(); app.get('/', (req, res) => { res.status(200).send('ok'); }); app.get('/health', (req, res) => { res.status(200).json({ ok: true, service: 'estados-hs', port: CFG.PORT, requireAuth: CFG.REQUIRE_AUTH, hsCredDocPath: CFG.HS_CRED_DOC_PATH || '(auto)', ts: nowISO(), }); }); // UI simple de pruebas dentro del propio servicio app.get('/test', (req, res) => { res.type('html').send(TEST_HTML); }); app.post('/api/homeserve/change-status', async (req, res) => { const startedAtISO = nowISO(); try { const out = await changeStatusViaClientesPortal(db, req.body || {}); res.status(200).json({ ...out, startedAtISO }); } catch (e) { res.status(500).json({ ok: false, startedAtISO, finishedAtISO: nowISO(), error: { message: String(e?.message || e), stack: String(e?.stack || ''), }, }); } }); // --------------------- Start --------------------- app.listen(CFG.PORT, '0.0.0.0', () => { console.log(`[estados-hs] listening on :${CFG.PORT}`); console.log(`[estados-hs] HS_CRED_DOC_PATH=${CFG.HS_CRED_DOC_PATH || '(auto providerCredentials/homeserve)'}`); console.log(`[estados-hs] REQUIRE_AUTH=${CFG.REQUIRE_AUTH ? 1 : 0}`); console.log(`[estados-hs] CLIENTES_CGI_BASE=${CFG.CLIENTES_CGI_BASE}`); }); // --------------------- Embedded test HTML --------------------- const TEST_HTML = ` estados-hs · prueba

estados-hs · prueba

POST /api/homeserve/change-status
Listo.
Consejo rápido: si ves "ok" en / y JSON en /health, el servicio está vivo. Si el cambio falla, el JSON de error te dirá en qué paso se atranca.
`;