diff --git a/index.js b/index.js index 002c0f0..ad81464 100644 --- a/index.js +++ b/index.js @@ -1,10 +1,14 @@ // estados-homeserve/index.js 'use strict'; - const { chromium } = require('playwright'); const admin = require('firebase-admin'); +/** + * ========================= + * Firebase Admin Init + * ========================= + */ function mustEnv(name) { const v = process.env[name]; if (!v) throw new Error(`Missing env: ${name}`); @@ -13,6 +17,7 @@ function mustEnv(name) { 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({ @@ -25,20 +30,32 @@ function initFirebase() { return admin.firestore(); } -const CONFIG = { - HOMESERVE_BASE_URL: process.env.HOMESERVE_BASE_URL || 'https://gestor.homeserve.es/', - HOMESERVE_USER: mustEnv('HOMESERVE_USER'), - HOMESERVE_PASS: mustEnv('HOMESERVE_PASS'), +const db = initFirebase(); +/** + * ========================= + * Config + * ========================= + */ +const CONFIG = { + // HomeServe base URL (fallback si no hay en Firestore) + HOMESERVE_BASE_URL: process.env.HOMESERVE_BASE_URL || 'https://gestor.homeserve.es/', + + // Colecciones QUEUE_COLLECTION: process.env.QUEUE_COLLECTION || 'homeserve_cambios_estado', RESULT_COLLECTION: process.env.RESULT_COLLECTION || 'homeserve_cambios_estado_log', - // TTL de claim (si un worker muere, otro puede reintentar) + // Credenciales en Firestore + PROVIDER_CREDENTIALS_COLLECTION: process.env.PROVIDER_CREDENTIALS_COLLECTION || 'providerCredentials', + PROVIDER_DOC_ID: process.env.PROVIDER_DOC_ID || 'homeserve', + + // Control de “claim” CLAIM_TTL_MINUTES: parseInt(process.env.CLAIM_TTL_MINUTES || '10', 10), - // rescaneo por si un listener se pierde un evento (seguridad) - RESCAN_SECONDS: parseInt(process.env.RESCAN_SECONDS || '60', 10), + // Concurrencia (para que no te arranque 20 Chromiums) + MAX_CONCURRENCY: parseInt(process.env.MAX_CONCURRENCY || '1', 10), + // Selectores HomeServe (como ya tenías) SEL: { user: process.env.SEL_USER || 'input[type="text"]', pass: process.env.SEL_PASS || 'input[type="password"]', @@ -65,20 +82,52 @@ const STATE_MAP = { }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -function nowMs() { return Date.now(); } -function toServerTimestamp() { return admin.firestore.FieldValue.serverTimestamp(); } +const nowMs = () => Date.now(); +const toServerTimestamp = () => admin.firestore.FieldValue.serverTimestamp(); -function isPendingStatus(st) { - return st === undefined || st === null || st === 'PENDING'; +/** + * ========================= + * Credenciales desde Firestore (con cache) + * ========================= + */ +let credsCache = null; +let credsCacheAt = 0; +const CREDS_TTL_MS = 60_000; // 1 min + +async function getHomeServeCreds() { + const now = nowMs(); + if (credsCache && (now - credsCacheAt) < CREDS_TTL_MS) return credsCache; + + const ref = db.collection(CONFIG.PROVIDER_CREDENTIALS_COLLECTION).doc(CONFIG.PROVIDER_DOC_ID); + const snap = await ref.get(); + if (!snap.exists) throw new Error(`Missing provider credentials doc: ${CONFIG.PROVIDER_CREDENTIALS_COLLECTION}/${CONFIG.PROVIDER_DOC_ID}`); + + const d = snap.data() || {}; + const user = d.user || d.username || d.email; + const pass = d.pass || d.password; + const baseUrl = d.baseUrl || d.baseURL || d.homeserveBaseUrl || CONFIG.HOMESERVE_BASE_URL; + + if (!user || !pass) throw new Error('HomeServe credentials missing in Firestore doc (need fields user & pass)'); + + credsCache = { user: String(user), pass: String(pass), baseUrl: String(baseUrl) }; + credsCacheAt = now; + return credsCache; } +/** + * ========================= + * Browser 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 { @@ -86,13 +135,14 @@ async function withBrowser(fn) { } } -async function login(page) { - await page.goto(CONFIG.HOMESERVE_BASE_URL, { waitUntil: 'domcontentloaded', timeout: 120000 }); +async function login(page, creds) { + await page.goto(creds.baseUrl, { waitUntil: 'domcontentloaded', timeout: 120000 }); + await page.waitForSelector(CONFIG.SEL.user, { timeout: 60000 }); - await page.fill(CONFIG.SEL.user, CONFIG.HOMESERVE_USER); + await page.fill(CONFIG.SEL.user, creds.user); await page.waitForSelector(CONFIG.SEL.pass, { timeout: 60000 }); - await page.fill(CONFIG.SEL.pass, CONFIG.HOMESERVE_PASS); + await page.fill(CONFIG.SEL.pass, creds.pass); const btn = await page.$(CONFIG.SEL.submit); if (btn) await btn.click(); @@ -108,14 +158,16 @@ async function openParte(page, parteId) { const btn = await page.$(CONFIG.SEL.searchBtn); if (btn) await btn.click(); else await page.keyboard.press('Enter'); + await page.waitForLoadState('networkidle', { timeout: 120000 }); - await sleep(1500); + 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(1200); + await sleep(1000); } async function setEstado(page, nuevoEstado, nota) { @@ -123,6 +175,7 @@ async function setEstado(page, nuevoEstado, nota) { await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: 60000 }); + // 1) selectOption por label let selected = false; for (const label of candidates) { try { @@ -132,19 +185,21 @@ async function setEstado(page, nuevoEstado, nota) { } catch (_) {} } + // 2) fallback DOM match (case-insensitive) if (!selected) { const ok = await page.evaluate(({ sel, candidates }) => { const s = document.querySelector(sel); if (!s) return false; const opts = Array.from(s.querySelectorAll('option')); const hit = opts.find(o => - candidates.some(c => (o.textContent || '').trim().toLowerCase() === c.trim().toLowerCase()) + candidates.some(c => (o.textContent || '').trim().toLowerCase() === String(c).trim().toLowerCase()) ); if (!hit) return false; s.value = hit.value; s.dispatchEvent(new Event('change', { bubbles: true })); return true; }, { sel: CONFIG.SEL.statusDropdown, candidates }); + if (!ok) throw new Error(`No matching status option for "${nuevoEstado}"`); } @@ -155,49 +210,51 @@ async function setEstado(page, nuevoEstado, nota) { 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 sleep(1500); + await sleep(1200); } -// Claim por ID (mantiene la “seguridad” si hay 2 robots) -async function claimJobById(db, jobId) { +/** + * ========================= + * Queue job claim (transaction) + * ========================= + */ +async function claimJob(jobRef) { const now = nowMs(); const ttlMs = CONFIG.CLAIM_TTL_MINUTES * 60 * 1000; - const ref = db.collection(CONFIG.QUEUE_COLLECTION).doc(jobId); - const res = await db.runTransaction(async (tx) => { - const snap = await tx.get(ref); - if (!snap.exists) return null; + return await db.runTransaction(async (tx) => { + const fresh = await tx.get(jobRef); + if (!fresh.exists) return null; - const d = snap.data() || {}; + const d = fresh.data() || {}; const st = d.status ?? 'PENDING'; - const claimedAt = d.claimedAt?.toMillis ? d.claimedAt.toMillis() : null; - const isStale = claimedAt && (now - claimedAt > ttlMs); - - if (st === 'DONE') return null; - if (st === 'RUNNING' && !isStale) return null; - - if (!isPendingStatus(st) && st !== 'FAILED' && !(st === 'RUNNING' && isStale)) { - // Si alguien mete estados raros, lo ignoramos - return null; + // si no está pendiente, fuera + if (st !== 'PENDING' && st !== null) { + // OJO: si está RUNNING y “caducado”, lo re-claim + if (st !== 'RUNNING') return null; } - tx.set(ref, { + const claimedAtMs = d.claimedAt?.toMillis ? d.claimedAt.toMillis() : null; + const stale = claimedAtMs && (now - claimedAtMs > ttlMs); + + if (st === 'RUNNING' && !stale) return null; + + tx.set(jobRef, { status: 'RUNNING', claimedAt: toServerTimestamp(), claimedBy: process.env.HOSTNAME || 'estados-homeserve', lastSeenAt: toServerTimestamp(), }, { merge: true }); - return { id: jobId, ...d }; + return { id: fresh.id, ...d }; }); - - return res; } -async function markDone(db, jobId, result) { +async function markDone(jobId, result) { const ref = db.collection(CONFIG.QUEUE_COLLECTION).doc(jobId); await ref.set({ status: 'DONE', @@ -213,7 +270,7 @@ async function markDone(db, jobId, result) { }); } -async function markFailed(db, jobId, err) { +async function markFailed(jobId, err) { const ref = db.collection(CONFIG.QUEUE_COLLECTION).doc(jobId); await ref.set({ status: 'FAILED', @@ -236,133 +293,126 @@ async function markFailed(db, jobId, err) { }); } -async function processJob(db, job) { - const jobId = job.id; - const parteId = job.parteId || job.parte || job.codigo || job.serviceId; - const nuevoEstado = job.nuevoEstado || job.estado || job.statusTo; - const nota = job.nota || job.note || ''; +/** + * ========================= + * Concurrency (semaforo simple) + * ========================= + */ +let running = 0; +const waiters = []; - if (!parteId || !nuevoEstado) { - await markFailed(db, jobId, new Error('Job missing parteId or nuevoEstado')); - return; - } +function acquire() { + return new Promise((resolve) => { + if (running < CONFIG.MAX_CONCURRENCY) { + running++; + resolve(); + } else { + waiters.push(resolve); + } + }); +} - try { - const started = new Date().toISOString(); - - await withBrowser(async (page) => { - await login(page); - await openParte(page, parteId); - await setEstado(page, nuevoEstado, nota); - }); - - await markDone(db, jobId, { - ok: true, - startedAtISO: started, - parteId: String(parteId), - nuevoEstado: String(nuevoEstado), - nota: String(nota || ''), - }); - } catch (err) { - await markFailed(db, jobId, err); +function release() { + running = Math.max(0, running - 1); + const next = waiters.shift(); + if (next) { + running++; + next(); } } /** - * Modo reactivo: - * - Listener Firestore detecta jobs PENDING y los mete en cola interna - * - Procesamos de uno en uno (Playwright mejor así) + * ========================= + * Process a job NOW (sin esperar) + * ========================= */ -function createWorker(db) { - const queue = []; - const queued = new Set(); - let running = false; +async function processJob(jobId, jobData) { + const parteId = jobData.parteId || jobData.parte || jobData.codigo || jobData.serviceId; + const nuevoEstado = jobData.nuevoEstado || jobData.estado || jobData.statusTo; + const nota = jobData.nota || jobData.note || ''; - async function drain() { - if (running) return; - running = true; - try { - while (queue.length) { - const id = queue.shift(); - queued.delete(id); - - const claimed = await claimJobById(db, id); - if (!claimed) continue; // otro worker lo pilló o ya no aplica - - await processJob(db, claimed); - } - } finally { - running = false; - } + if (!parteId || !nuevoEstado) { + await markFailed(jobId, new Error('Job missing parteId or nuevoEstado')); + return; } - function enqueue(id) { - if (queued.has(id)) return; - queued.add(id); - queue.push(id); - // Arranca al momento, sin esperar - drain().catch(console.error); - } + const started = new Date().toISOString(); + const creds = await getHomeServeCreds(); - async function rescanPending() { - try { - const snap = await db.collection(CONFIG.QUEUE_COLLECTION) - .orderBy('createdAt', 'asc') - .limit(50) - .get(); + await withBrowser(async (page) => { + await login(page, creds); + await openParte(page, parteId); + await setEstado(page, nuevoEstado, nota); + }); - snap.forEach((doc) => { - const d = doc.data() || {}; - if (isPendingStatus(d.status)) enqueue(doc.id); - }); - } catch (e) { - console.error('Rescan error:', e); - } - } + await markDone(jobId, { + ok: true, + startedAtISO: started, + parteId: String(parteId), + nuevoEstado: String(nuevoEstado), + nota: String(nota || ''), + }); +} - function startListener() { - // Listener general (evita problemas de query con null en "in") - return db.collection(CONFIG.QUEUE_COLLECTION) - .orderBy('createdAt', 'asc') - .limit(50) - .onSnapshot((snap) => { - for (const ch of snap.docChanges()) { - if (ch.type !== 'added' && ch.type !== 'modified') continue; - const d = ch.doc.data() || {}; - if (isPendingStatus(d.status)) enqueue(ch.doc.id); +/** + * ========================= + * Firestore listener (event-driven) + * ========================= + */ +function startQueueListener() { + console.log(`[HS] Listening queue: ${CONFIG.QUEUE_COLLECTION} ...`); + + const q = db.collection(CONFIG.QUEUE_COLLECTION) + .where('status', 'in', ['PENDING', null]); + + q.onSnapshot(async (snap) => { + // Procesa solo cambios relevantes + const changes = snap.docChanges() + .filter(ch => ch.type === 'added' || ch.type === 'modified') + .map(ch => ch.doc); + + for (const doc of changes) { + const ref = doc.ref; + const data = doc.data() || {}; + + // Seguridad: si ya no está pending, ignora + const st = data.status ?? 'PENDING'; + if (st !== 'PENDING' && st !== null) continue; + + // Concurrency guard + await acquire(); + + (async () => { + try { + const claimed = await claimJob(ref); + if (!claimed) return; + + await processJob(doc.id, claimed); + } catch (err) { + await markFailed(doc.id, err); + } finally { + release(); } - }, (err) => { - console.error('onSnapshot error:', err); - }); - } - - return { startListener, rescanPending }; + })(); + } + }, (err) => { + console.error('[HS] Listener error:', err); + // Si el listener cae, reinicia el proceso (CapRover lo levantará) + process.exit(1); + }); } -async function main() { - const db = initFirebase(); +/** + * ========================= + * Main + * ========================= + */ +startQueueListener(); - const worker = createWorker(db); - const unsubscribe = worker.startListener(); - - // rescaneo “por si acaso” - await worker.rescanPending(); - setInterval(() => worker.rescanPending(), CONFIG.RESCAN_SECONDS * 1000); - - // no salimos nunca (servicio) - process.on('SIGINT', () => { - try { unsubscribe && unsubscribe(); } catch (_) {} - process.exit(0); - }); - process.on('SIGTERM', () => { - try { unsubscribe && unsubscribe(); } catch (_) {} - process.exit(0); - }); - - console.log('✅ estados-homeserve listo (modo reactivo, sin polling).'); -} - -main().catch((e) => { - console.error(e); +process.on('unhandledRejection', (e) => { + console.error('[HS] unhandledRejection', e); +}); +process.on('uncaughtException', (e) => { + console.error('[HS] uncaughtException', e); process.exit(1); }); \ No newline at end of file