// 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}`); return v; } 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(); } 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', // 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), // 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"]', submit: process.env.SEL_SUBMIT || 'button[type="submit"]', 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', statusDropdown: process.env.SEL_STATUS_DROPDOWN || 'select[name*="estado"], select[id*="estado"], select:has(option)', 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")', }, }; const STATE_MAP = { EN_RUTA: ['De camino', 'En ruta', 'En camino'], EN_CURSO: ['En curso', 'Trabajando', 'En intervención'], FINALIZADO: ['Finalizado', 'Finalizada', 'Terminado'], NO_LOCALIZADO: ['No localizado', 'No localizable', 'Ausente'], CERRADO: ['Cerrado', 'Cierre', 'Cerrada'], ANULADO: ['Anulado', 'Cancelado', 'Cancelada'], }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const nowMs = () => Date.now(); const toServerTimestamp = () => admin.firestore.FieldValue.serverTimestamp(); /** * ========================= * 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 { await browser.close().catch(() => {}); } } 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, 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'); await page.waitForLoadState('networkidle', { timeout: 120000 }); } 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('networkidle', { timeout: 120000 }); 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(1000); } async function setEstado(page, nuevoEstado, nota) { const candidates = STATE_MAP[nuevoEstado] || [nuevoEstado]; await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: 60000 }); // 1) selectOption por label let selected = false; for (const label of candidates) { try { await page.selectOption(CONFIG.SEL.statusDropdown, { label }); selected = true; break; } 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() === 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}"`); } if (nota) { const ta = await page.$(CONFIG.SEL.noteTextarea); if (ta) await page.fill(CONFIG.SEL.noteTextarea, String(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(1200); } /** * ========================= * Queue job claim (transaction) * ========================= */ async function claimJob(jobRef) { const now = nowMs(); const ttlMs = CONFIG.CLAIM_TTL_MINUTES * 60 * 1000; return await db.runTransaction(async (tx) => { const fresh = await tx.get(jobRef); if (!fresh.exists) return null; const d = fresh.data() || {}; const st = d.status ?? 'PENDING'; // si no está pendiente, fuera if (st !== 'PENDING' && st !== null) { // OJO: si está RUNNING y “caducado”, lo re-claim if (st !== 'RUNNING') return null; } 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: fresh.id, ...d }; }); } async function markDone(jobId, result) { const ref = db.collection(CONFIG.QUEUE_COLLECTION).doc(jobId); await ref.set({ status: 'DONE', finishedAt: toServerTimestamp(), result, lastSeenAt: toServerTimestamp(), }, { merge: true }); await db.collection(CONFIG.RESULT_COLLECTION).add({ jobId, ...result, createdAt: toServerTimestamp(), }); } async function markFailed(jobId, err) { const ref = db.collection(CONFIG.QUEUE_COLLECTION).doc(jobId); await ref.set({ status: 'FAILED', finishedAt: toServerTimestamp(), error: { message: String(err?.message || err), stack: String(err?.stack || ''), }, lastSeenAt: toServerTimestamp(), }, { merge: true }); await db.collection(CONFIG.RESULT_COLLECTION).add({ jobId, ok: false, error: { message: String(err?.message || err), stack: String(err?.stack || ''), }, createdAt: toServerTimestamp(), }); } /** * ========================= * Concurrency (semaforo simple) * ========================= */ let running = 0; const waiters = []; function acquire() { return new Promise((resolve) => { if (running < CONFIG.MAX_CONCURRENCY) { running++; resolve(); } else { waiters.push(resolve); } }); } function release() { running = Math.max(0, running - 1); const next = waiters.shift(); if (next) { running++; next(); } } /** * ========================= * Process a job NOW (sin esperar) * ========================= */ 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 || ''; if (!parteId || !nuevoEstado) { await markFailed(jobId, new Error('Job missing parteId or nuevoEstado')); return; } const started = new Date().toISOString(); const creds = await getHomeServeCreds(); await withBrowser(async (page) => { await login(page, creds); await openParte(page, parteId); await setEstado(page, nuevoEstado, nota); }); await markDone(jobId, { ok: true, startedAtISO: started, parteId: String(parteId), nuevoEstado: String(nuevoEstado), nota: String(nota || ''), }); } /** * ========================= * 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('[HS] Listener error:', err); // Si el listener cae, reinicia el proceso (CapRover lo levantará) process.exit(1); }); } /** * ========================= * Main * ========================= */ startQueueListener(); process.on('unhandledRejection', (e) => { console.error('[HS] unhandledRejection', e); }); process.on('uncaughtException', (e) => { console.error('[HS] uncaughtException', e); process.exit(1); });