diff --git a/index.js b/index.js index eec44f9..b873006 100644 --- a/index.js +++ b/index.js @@ -1,59 +1,39 @@ +// worker-homeserve.js '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' })); +// --- CONFIGURACIÓN --- +const CONFIG = { + // Firestore Collections + QUEUE_COLLECTION: process.env.QUEUE_COLLECTION || 'homeserve_cambios_estado', + RESULT_COLLECTION: process.env.RESULT_COLLECTION || 'homeserve_cambios_estado_log', + + // Credenciales: Busca en ENV primero, luego en Firestore + HS_CRED_DOC_PATH: process.env.HS_CRED_DOC_PATH || 'providerCredentials/homeserve', + + // URL Base + CLIENTES_CGI_BASE: process.env.CLIENTES_CGI_BASE || 'https://www.clientes.homeserve.es/cgi-bin/fccgi.exe', -// --------------------- Utils --------------------- + // Timeouts (Ms) + NAV_TIMEOUT: 120000, + SEL_TIMEOUT: 60000, + + // Worker Settings + CLAIM_TTL_MINUTES: 10, // Tiempo antes de robarle la tarea a un worker muerto + RESCAN_SECONDS: 60 // Cada cuánto mirar si se nos escapó algo +}; + +// --- UTILS BÁSICOS --- +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +function nowISO() { return new Date().toISOString(); } +function toServerTimestamp() { return admin.firestore.FieldValue.serverTimestamp(); } function mustEnv(name) { const v = process.env[name]; - if (!v) throw new Error(`Missing env: ${name}`); + if (!v) throw new Error(`Falta variable de entorno: ${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(); @@ -61,7 +41,7 @@ function pickFirstNonEmpty(...vals) { return ''; } -// --------------------- Firebase --------------------- +// --- FIREBASE INIT --- function initFirebase() { if (!process.env.FIREBASE_PRIVATE_KEY) throw new Error('Missing env: FIREBASE_PRIVATE_KEY'); if (!admin.apps.length) { @@ -76,94 +56,33 @@ function initFirebase() { 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 --------------------- +// --- CREDENCIALES HOMESERVE --- 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); + // 1. Intentar ENV + const envUser = pickFirstNonEmpty(process.env.HOMESERVE_USER); + const envPass = pickFirstNonEmpty(process.env.HOMESERVE_PASS); + if (envUser && envPass) return { user: envUser, pass: envPass }; - 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}`, - }; + // 2. Intentar Firestore + const path = CONFIG.HS_CRED_DOC_PATH; + const parts = path.split('/'); + if (parts.length === 2) { + const snap = await db.collection(parts[0]).doc(parts[1]).get(); + if (snap.exists) { + const d = snap.data(); + const user = pickFirstNonEmpty(d.user, d.username, d.usuario); + const pass = pickFirstNonEmpty(d.pass, d.password, d.clave); + if (user && pass) return { user, pass }; } } - - 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? }.' - ); + + throw new Error('No se encontraron credenciales de HomeServe (ni en ENV ni en Firestore).'); } -// --------------------- Playwright helpers --------------------- +// --- PLAYWRIGHT HELPERS (LA MAGIA DEL CÓDIGO B) --- async function withBrowser(fn) { const browser = await chromium.launch({ - headless: true, + headless: true, // Pon false si quieres ver lo que hace en local args: ['--no-sandbox', '--disable-setuid-sandbox'], }); const context = await browser.newContext(); @@ -175,12 +94,9 @@ async function withBrowser(fn) { } } -function allFrames(page) { - return page.frames(); -} - +// Busca en todos los iframes (vital para HomeServe) async function findLocatorInFrames(page, selector) { - for (const fr of allFrames(page)) { + for (const fr of page.frames()) { const loc = fr.locator(selector); try { if (await loc.count()) return { frame: fr, locator: loc }; @@ -189,6 +105,7 @@ async function findLocatorInFrames(page, selector) { return null; } +// Intenta clickar el primero que encuentre de una lista async function clickFirstThatExists(page, selectors, opts = {}) { for (const sel of selectors) { const hit = await findLocatorInFrames(page, sel); @@ -200,448 +117,269 @@ async function clickFirstThatExists(page, selectors, opts = {}) { return null; } +// Intenta llenar texto en el primero que encuentre 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); + await hit.locator.first().fill(String(value)); return sel; } } return null; } +// Lógica inteligente para checkboxes 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; + if (!enabled) return; + const labels = ['informado al cliente', 'informado al Cliente', 'Marque esta casilla']; + + // 1. Buscar por label directa + for (const txt of labels) { + const hit = await findLocatorInFrames(page, `label:has-text("${txt}") >> input[type="checkbox"]`); + if (hit && !(await hit.locator.first().isChecked())) { + await hit.locator.first().check(); + return; } } - - // 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; } +// Lógica inteligente para Dropdowns (Selects) 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 = [ + // 5. GUARDAR + const saveBtn = await clickFirstThatExists(page, [ '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.'); + 'button:has-text("Guardar")' + ]); + + if (!saveBtn) throw new Error('No se encontró el botón de Guardar.'); + + await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {}); + + return { success: true, serviceUrl: serviceUrl.toString() }; } -async function changeStatusViaClientesPortal(db, reqBody) { - const creds = await getHomeServeCreds(db); +// --- GESTIÓN DE COLAS (EL CEREBRO DEL CÓDIGO A) --- - 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; +async function claimJobById(db, jobId) { + const ref = db.collection(CONFIG.QUEUE_COLLECTION).doc(jobId); + return await db.runTransaction(async (tx) => { + const snap = await tx.get(ref); + if (!snap.exists) return null; - if (!serviceNumber) throw new Error('Missing serviceNumber'); - if (!newStatusValue) throw new Error('Missing newStatusValue'); + const d = snap.data(); + const st = d.status || 'PENDING'; + const claimedAt = d.claimedAt ? d.claimedAt.toMillis() : 0; + const now = Date.now(); + const isStale = (st === 'RUNNING') && ((now - claimedAt) > (CONFIG.CLAIM_TTL_MINUTES * 60 * 1000)); - const startedAtISO = nowISO(); + // Solo cogemos PENDING o RUNNING caducados + if (st !== 'PENDING' && !isStale) return null; - const result = await withBrowser(async (page) => { - // Login - await loginClientesPortal(page, creds); + tx.set(ref, { + status: 'RUNNING', + claimedAt: toServerTimestamp(), + lastSeenAt: toServerTimestamp(), + workerId: process.env.HOSTNAME || 'worker-local' + }, { merge: true }); - // 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 { id: jobId, ...d }; }); - - return result; } -// --------------------- Routes --------------------- -const db = initFirebase(); +async function markJobDone(db, jobId, result) { + await db.collection(CONFIG.QUEUE_COLLECTION).doc(jobId).set({ + status: 'DONE', + finishedAt: toServerTimestamp(), + result: result + }, { merge: true }); -app.get('/', (req, res) => { - res.status(200).send('ok'); -}); - -app.get('/health', (req, res) => { - res.status(200).json({ + await db.collection(CONFIG.RESULT_COLLECTION).add({ + jobId, ok: true, - service: 'estados-hs', - port: CFG.PORT, - requireAuth: CFG.REQUIRE_AUTH, - hsCredDocPath: CFG.HS_CRED_DOC_PATH || '(auto)', - ts: nowISO(), + ...result, + createdAt: toServerTimestamp() }); -}); +} -// UI simple de pruebas dentro del propio servicio -app.get('/test', (req, res) => { - res.type('html').send(TEST_HTML); -}); +async function markJobFailed(db, jobId, error) { + const errData = { + message: String(error?.message || error), + stack: String(error?.stack || '') + }; + + await db.collection(CONFIG.QUEUE_COLLECTION).doc(jobId).set({ + status: 'FAILED', + finishedAt: toServerTimestamp(), + error: errData + }, { merge: true }); -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 || ''), - }, - }); - } -}); + await db.collection(CONFIG.RESULT_COLLECTION).add({ + jobId, + ok: false, + error: errData, + createdAt: toServerTimestamp() + }); +} -// --------------------- 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}`); -}); +// --- BUCLE PRINCIPAL --- -// --------------------- 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. -
-
- - - -`; \ No newline at end of file + isProcessing = false; + }; + + const enqueue = (id) => { + if (!queue.includes(id)) { + queue.push(id); + processQueue(); + } + }; + + // 1. Escuchar nuevos + db.collection(CONFIG.QUEUE_COLLECTION) + .where('status', '==', 'PENDING') + .onSnapshot(snap => { + snap.docChanges().forEach(change => { + if (change.type === 'added') enqueue(change.doc.id); + }); + }); + + // 2. Rescaneo de seguridad (polling) + setInterval(async () => { + const snap = await db.collection(CONFIG.QUEUE_COLLECTION) + .where('status', '==', 'PENDING') + .limit(10).get(); + snap.forEach(doc => enqueue(doc.id)); + }, CONFIG.RESCAN_SECONDS * 1000); + + console.log('🚀 Worker HomeServe iniciado. Esperando trabajos en Firestore...'); +} + +// Start +const db = initFirebase(); +startWorker(db); \ No newline at end of file