diff --git a/index.js b/index.js index 2115230..c89a1da 100644 --- a/index.js +++ b/index.js @@ -12,13 +12,15 @@ function mustEnv(name) { function initFirebase() { 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: mustEnv('FIREBASE_PRIVATE_KEY').replace(/\\n/g, '\n'), - }), - }); + 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(); } @@ -27,38 +29,26 @@ const CONFIG = { HOMESERVE_USER: mustEnv('HOMESERVE_USER'), HOMESERVE_PASS: mustEnv('HOMESERVE_PASS'), - // Colección donde tu app mete solicitudes de cambio de estado - // Docs recomendados: - // { - // parteId: "12345678" | codigoParte, - // nuevoEstado: "EN_RUTA" | "EN_CURSO" | "FINALIZADO" | "NO_LOCALIZADO" | "CERRADO" | "ANULADO", - // nota: "texto opcional", - // requestedBy: "marsalva-app", - // createdAt: serverTimestamp - // } QUEUE_COLLECTION: process.env.QUEUE_COLLECTION || 'homeserve_cambios_estado', RESULT_COLLECTION: process.env.RESULT_COLLECTION || 'homeserve_cambios_estado_log', - // Control de loop - POLL_SECONDS: parseInt(process.env.POLL_SECONDS || '20', 10), + // TTL de claim (si un worker muere, otro puede reintentar) CLAIM_TTL_MINUTES: parseInt(process.env.CLAIM_TTL_MINUTES || '10', 10), - // Selectores (ajústalos a tu portal real si difieren) + // rescaneo por si un listener se pierde un evento (seguridad) + RESCAN_SECONDS: parseInt(process.env.RESCAN_SECONDS || '60', 10), + 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"]', - // búsqueda de parte/servicio 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")', - // entrar al detalle del parte openRow: process.env.SEL_OPEN_ROW || 'table tbody tr:first-child', - // cambio de estado statusDropdown: process.env.SEL_STATUS_DROPDOWN || 'select[name*="estado"], select[id*="estado"], select:has(option)', - statusOptionByText: process.env.SEL_STATUS_OPTION_BY_TEXT || null, // si quieres forzar otro método 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")', }, @@ -74,11 +64,11 @@ const STATE_MAP = { }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - function nowMs() { return Date.now(); } +function toServerTimestamp() { return admin.firestore.FieldValue.serverTimestamp(); } -function toServerTimestamp() { - return admin.firestore.FieldValue.serverTimestamp(); +function isPendingStatus(st) { + return st === undefined || st === null || st === 'PENDING'; } async function withBrowser(fn) { @@ -111,7 +101,6 @@ async function login(page) { } async function openParte(page, parteId) { - // intenta buscar por el buscador const hasSearch = await page.$(CONFIG.SEL.searchBox); if (hasSearch) { await page.fill(CONFIG.SEL.searchBox, String(parteId)); @@ -122,7 +111,6 @@ async function openParte(page, parteId) { await sleep(1500); } - // abre primera fila (ajusta si tu portal tiene un link directo) await page.waitForSelector(CONFIG.SEL.openRow, { timeout: 60000 }); await page.click(CONFIG.SEL.openRow); await page.waitForLoadState('networkidle', { timeout: 120000 }); @@ -134,7 +122,6 @@ async function setEstado(page, nuevoEstado, nota) { await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: 60000 }); - // selecciona por label visible (texto) let selected = false; for (const label of candidates) { try { @@ -145,12 +132,13 @@ async function setEstado(page, nuevoEstado, nota) { } if (!selected) { - // fallback: intenta elegir por contenido del DOM 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())); + const hit = opts.find(o => + candidates.some(c => (o.textContent || '').trim().toLowerCase() === c.trim().toLowerCase()) + ); if (!hit) return false; s.value = hit.value; s.dispatchEvent(new Event('change', { bubbles: true })); @@ -161,9 +149,7 @@ async function setEstado(page, nuevoEstado, nota) { if (nota) { const ta = await page.$(CONFIG.SEL.noteTextarea); - if (ta) { - await page.fill(CONFIG.SEL.noteTextarea, String(nota)); - } + if (ta) await page.fill(CONFIG.SEL.noteTextarea, String(nota)); } const save = await page.$(CONFIG.SEL.saveBtn); @@ -173,54 +159,41 @@ async function setEstado(page, nuevoEstado, nota) { await sleep(1500); } -async function claimNextJob(db) { +// Claim por ID (mantiene la “seguridad” si hay 2 robots) +async function claimJobById(db, jobId) { const now = nowMs(); const ttlMs = CONFIG.CLAIM_TTL_MINUTES * 60 * 1000; + const ref = db.collection(CONFIG.QUEUE_COLLECTION).doc(jobId); - const snap = await db.collection(CONFIG.QUEUE_COLLECTION) - .where('status', 'in', ['PENDING', null]) - .orderBy('createdAt', 'asc') - .limit(10) - .get(); + const res = await db.runTransaction(async (tx) => { + const snap = await tx.get(ref); + if (!snap.exists) return null; - if (snap.empty) return null; + const d = snap.data() || {}; + const st = d.status ?? 'PENDING'; - for (const doc of snap.docs) { - const ref = doc.ref; - const data = doc.data() || {}; - const claimedAt = data.claimedAt?.toMillis ? data.claimedAt.toMillis() : null; + const claimedAt = d.claimedAt?.toMillis ? d.claimedAt.toMillis() : null; const isStale = claimedAt && (now - claimedAt > ttlMs); - try { - const res = await db.runTransaction(async (tx) => { - const fresh = await tx.get(ref); - const d = fresh.data() || {}; - const st = d.status ?? 'PENDING'; + if (st === 'DONE') return null; + if (st === 'RUNNING' && !isStale) return null; - const cAt = d.claimedAt?.toMillis ? d.claimedAt.toMillis() : null; - const stale = cAt && (now - cAt > ttlMs); - - if (st === 'DONE' || st === 'RUNNING') { - if (!stale) return null; - } - - tx.update(ref, { - status: 'RUNNING', - claimedAt: toServerTimestamp(), - claimedBy: process.env.HOSTNAME || 'estados-homeserve', - lastSeenAt: toServerTimestamp(), - }); - - return { id: ref.id, ...d }; - }); - - if (res) return { id: doc.id, ...data }; - } catch (_) { - // otro worker lo pilló + if (!isPendingStatus(st) && st !== 'FAILED' && !(st === 'RUNNING' && isStale)) { + // Si alguien mete estados raros, lo ignoramos + return null; } - } - return null; + tx.set(ref, { + status: 'RUNNING', + claimedAt: toServerTimestamp(), + claimedBy: process.env.HOSTNAME || 'estados-homeserve', + lastSeenAt: toServerTimestamp(), + }, { merge: true }); + + return { id: jobId, ...d }; + }); + + return res; } async function markDone(db, jobId, result) { @@ -262,10 +235,7 @@ async function markFailed(db, jobId, err) { }); } -async function processOne(db) { - const job = await claimNextJob(db); - if (!job) return false; - +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; @@ -273,7 +243,7 @@ async function processOne(db) { if (!parteId || !nuevoEstado) { await markFailed(db, jobId, new Error('Job missing parteId or nuevoEstado')); - return true; + return; } try { @@ -292,20 +262,103 @@ async function processOne(db) { nuevoEstado: String(nuevoEstado), nota: String(nota || ''), }); - } catch (err) { await markFailed(db, jobId, err); } +} - return true; +/** + * Modo reactivo: + * - Listener Firestore detecta jobs PENDING y los mete en cola interna + * - Procesamos de uno en uno (Playwright mejor así) + */ +function createWorker(db) { + const queue = []; + const queued = new Set(); + let running = false; + + 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; + } + } + + function enqueue(id) { + if (queued.has(id)) return; + queued.add(id); + queue.push(id); + // Arranca al momento, sin esperar + drain().catch(console.error); + } + + async function rescanPending() { + try { + const snap = await db.collection(CONFIG.QUEUE_COLLECTION) + .orderBy('createdAt', 'asc') + .limit(50) + .get(); + + snap.forEach((doc) => { + const d = doc.data() || {}; + if (isPendingStatus(d.status)) enqueue(doc.id); + }); + } catch (e) { + console.error('Rescan error:', e); + } + } + + 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); + } + }, (err) => { + console.error('onSnapshot error:', err); + }); + } + + return { startListener, rescanPending }; } async function main() { const db = initFirebase(); - while (true) { - const did = await processOne(db); - if (!did) await sleep(CONFIG.POLL_SECONDS * 1000); - } + + 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) => {