From e37b5cc12e7f1c2ca563fc60ae10aeaad725f3e0 Mon Sep 17 00:00:00 2001 From: marsalva Date: Sat, 3 Jan 2026 22:36:36 +0000 Subject: [PATCH] Actualizar index.js --- index.js | 559 +++++++++++++++++++++++++------------------------------ 1 file changed, 255 insertions(+), 304 deletions(-) diff --git a/index.js b/index.js index ad81464..1452cf3 100644 --- a/index.js +++ b/index.js @@ -1,127 +1,166 @@ -// estados-homeserve/index.js 'use strict'; +const express = require('express'); +const helmet = require('helmet'); +const cors = require('cors'); const { chromium } = require('playwright'); const admin = require('firebase-admin'); -/** - * ========================= - * Firebase Admin Init - * ========================= - */ +// -------------------- helpers -------------------- 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'); +function optEnv(name, fallback) { + const v = process.env[name]; + return (v === undefined || v === null || v === '') ? fallback : v; +} - 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'), - }), - }); +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +// -------------------- Firebase Admin -------------------- +function initFirebaseAdmin() { + if (admin.apps.length) return; + + const projectId = mustEnv('FIREBASE_PROJECT_ID'); + const clientEmail = mustEnv('FIREBASE_CLIENT_EMAIL'); + const privateKeyRaw = mustEnv('FIREBASE_PRIVATE_KEY'); + + admin.initializeApp({ + credential: admin.credential.cert({ + projectId, + clientEmail, + privateKey: privateKeyRaw.replace(/\\n/g, '\n'), + }), + }); +} + +async function verifyFirebaseIdTokenIfPresent(req) { + // Si quieres obligar a auth: REQUIRE_AUTH=1 + const requireAuth = optEnv('REQUIRE_AUTH', '1') === '1'; + + const auth = req.headers.authorization || ''; + const m = auth.match(/^Bearer\s+(.+)$/i); + const token = m ? m[1] : null; + + if (!token) { + if (requireAuth) throw new Error('Missing Authorization Bearer token'); + return null; } - return admin.firestore(); + + const decoded = await admin.auth().verifyIdToken(token); + // Opcional: lista blanca de UID + const allowed = (process.env.ALLOWED_UIDS || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + + if (allowed.length && !allowed.includes(decoded.uid)) { + throw new Error('User not allowed (uid not in ALLOWED_UIDS)'); + } + + return decoded; } -const db = initFirebase(); +// -------------------- HomeServe Status Map (tu Swift) -------------------- +const STATUS_CODE_MAP = { + "303": ["En espera de Cliente por aceptación Presupuesto"], + "307": ["En espera de Profesional por fecha de inicio de trabajos"], + "313": ["En espera de Profesional por secado de cala, pintura o parquet"], + "318": ["En espera de Profesional por confirmación del Siniestro"], + "319": ["En espera de Profesional por material"], + "320": ["En espera de Profesional por espera de otro gremio"], + "321": ["En espera de Profesional por presupuesto/valoración"], + "323": ["En espera de Profesional por mejora del tiempo"], + "326": ["En espera de Cliente por pago de Factura Contado/Franquicia"], + "336": ["En espera de Profesional por avería en observación"], + "342": ["En espera de Profesional pendiente cobro franquicia"], + "345": ["En espera de Profesional en realización pendiente Terminar"], + "348": ["En espera de Cliente por indicaciones"], + "352": ["En espera de Perjudicado por indicaciones"], +}; -/** - * ========================= - * Config - * ========================= - */ +// -------------------- Config -------------------- const CONFIG = { - // HomeServe base URL (fallback si no hay en Firestore) - HOMESERVE_BASE_URL: process.env.HOMESERVE_BASE_URL || 'https://gestor.homeserve.es/', + PORT: parseInt(optEnv('PORT', '3000'), 10), - // Colecciones - QUEUE_COLLECTION: process.env.QUEUE_COLLECTION || 'homeserve_cambios_estado', - RESULT_COLLECTION: process.env.RESULT_COLLECTION || 'homeserve_cambios_estado_log', + // dónde guardas las credenciales de HomeServe en Firestore + // Formato: "collection/doc" o "collection/doc/subcollection/doc" + HS_CRED_DOC_PATH: optEnv('HS_CRED_DOC_PATH', 'secrets/homeserve'), - // Credenciales en Firestore - PROVIDER_CREDENTIALS_COLLECTION: process.env.PROVIDER_CREDENTIALS_COLLECTION || 'providerCredentials', - PROVIDER_DOC_ID: process.env.PROVIDER_DOC_ID || 'homeserve', + // Si quieres fallback por ENV (por si firestore no está listo) + HOMESERVE_BASE_URL: optEnv('HOMESERVE_BASE_URL', 'https://gestor.homeserve.es/'), + HOMESERVE_USER: process.env.HOMESERVE_USER || null, + HOMESERVE_PASS: process.env.HOMESERVE_PASS || null, - // Control de “claim” - CLAIM_TTL_MINUTES: parseInt(process.env.CLAIM_TTL_MINUTES || '10', 10), + // Seguridad extra (si no quieres usar Firebase auth): + // pon API_KEY y el HTML mandará X-API-Key + API_KEY: process.env.API_KEY || null, - // Concurrencia (para que no te arranque 20 Chromiums) - MAX_CONCURRENCY: parseInt(process.env.MAX_CONCURRENCY || '1', 10), - - // Selectores HomeServe (como ya tenías) + // Selectores (ajustables por env) 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"]', + user: optEnv('SEL_USER', 'input[type="text"]'), + pass: optEnv('SEL_PASS', 'input[type="password"]'), + submit: optEnv('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")', + searchBox: optEnv('SEL_SEARCH_BOX', 'input[placeholder*="Buscar"], input[type="search"]'), + searchBtn: optEnv('SEL_SEARCH_BTN', 'button:has-text("Buscar"), button:has-text("Search")'), + openRow: optEnv('SEL_OPEN_ROW', 'table tbody tr:first-child'), - 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")', + statusDropdown: optEnv('SEL_STATUS_DROPDOWN', 'select[name*="estado"], select[id*="estado"], select:has(option)'), + noteTextarea: optEnv('SEL_NOTE_TEXTAREA', 'textarea[name*="nota"], textarea[id*="nota"], textarea'), + saveBtn: optEnv('SEL_SAVE_BTN', 'button:has-text("Guardar"), button:has-text("Save"), button:has-text("Actualizar")'), }, + + // comportamiento + HEADLESS: optEnv('HEADLESS', 'true') !== 'false', + SLOW_MO_MS: parseInt(optEnv('SLOW_MO_MS', '0'), 10), }; -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'], -}; +// -------------------- Firestore: leer credenciales HS -------------------- +async function getHomeServeCredentials(db) { + // Doc esperado: + // { + // baseUrl: "https://gestor.homeserve.es/", + // user: "xxxx", + // pass: "yyyy" + // } + const parts = CONFIG.HS_CRED_DOC_PATH.split('/').filter(Boolean); + if (parts.length < 2 || parts.length % 2 !== 0) { + throw new Error(`HS_CRED_DOC_PATH inválido: "${CONFIG.HS_CRED_DOC_PATH}". Debe ser collection/doc (o pares).`); + } -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -const nowMs = () => Date.now(); -const toServerTimestamp = () => admin.firestore.FieldValue.serverTimestamp(); + let ref = db.collection(parts[0]).doc(parts[1]); + for (let i = 2; i < parts.length; i += 2) { + ref = ref.collection(parts[i]).doc(parts[i + 1]); + } -/** - * ========================= - * 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}`); + if (!snap.exists) { + throw new Error(`No existe el documento de credenciales: ${CONFIG.HS_CRED_DOC_PATH}`); + } - 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; + const data = snap.data() || {}; + const baseUrl = (data.baseUrl || data.HOMESERVE_BASE_URL || CONFIG.HOMESERVE_BASE_URL).toString(); + const user = (data.user || data.HOMESERVE_USER || CONFIG.HOMESERVE_USER || '').toString(); + const pass = (data.pass || data.HOMESERVE_PASS || CONFIG.HOMESERVE_PASS || '').toString(); - if (!user || !pass) throw new Error('HomeServe credentials missing in Firestore doc (need fields user & pass)'); + if (!user || !pass) { + throw new Error(`El doc ${CONFIG.HS_CRED_DOC_PATH} no tiene user/pass (o están vacíos).`); + } - credsCache = { user: String(user), pass: String(pass), baseUrl: String(baseUrl) }; - credsCacheAt = now; - return credsCache; + return { baseUrl, user, pass }; } -/** - * ========================= - * Browser helpers - * ========================= - */ +// -------------------- Playwright actions -------------------- async function withBrowser(fn) { const browser = await chromium.launch({ - headless: true, + headless: CONFIG.HEADLESS, + slowMo: CONFIG.SLOW_MO_MS, args: ['--no-sandbox', '--disable-setuid-sandbox'], }); @@ -135,7 +174,7 @@ async function withBrowser(fn) { } } -async function login(page, creds) { +async function loginHomeServe(page, creds) { await page.goto(creds.baseUrl, { waitUntil: 'domcontentloaded', timeout: 120000 }); await page.waitForSelector(CONFIG.SEL.user, { timeout: 60000 }); @@ -149,6 +188,13 @@ async function login(page, creds) { else await page.keyboard.press('Enter'); await page.waitForLoadState('networkidle', { timeout: 120000 }); + + // Si HomeServe muestra “Credenciales incorrectas” en el DOM: + // (esto es opcional; si no existe, no pasa nada) + const possibleError = await page.$('text=/credenciales\\s+incorrectas/i'); + if (possibleError) { + throw new Error('HomeServe: credenciales incorrectas (detectado en pantalla)'); + } } async function openParte(page, parteId) { @@ -165,254 +211,159 @@ async function openParte(page, parteId) { await page.waitForSelector(CONFIG.SEL.openRow, { timeout: 60000 }); await page.click(CONFIG.SEL.openRow); - await page.waitForLoadState('networkidle', { timeout: 120000 }); - await sleep(1000); + await sleep(800); } -async function setEstado(page, nuevoEstado, nota) { - const candidates = STATE_MAP[nuevoEstado] || [nuevoEstado]; +async function setEstadoByStatusCode(page, statusCode, notaFinal) { + const code = String(statusCode).trim(); + const labels = STATUS_CODE_MAP[code] || []; await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: 60000 }); - // 1) selectOption por label + // 1) Intento por value = code let selected = false; - for (const label of candidates) { - try { - await page.selectOption(CONFIG.SEL.statusDropdown, { label }); - selected = true; - break; - } catch (_) {} + try { + await page.selectOption(CONFIG.SEL.statusDropdown, { value: code }); + selected = true; + } catch (_) {} + + // 2) Intento por label exacto + if (!selected) { + for (const label of labels) { + try { + await page.selectOption(CONFIG.SEL.statusDropdown, { label }); + selected = true; + break; + } catch (_) {} + } } - // 2) fallback DOM match (case-insensitive) + // 3) Fallback DOM: contains / code match if (!selected) { - const ok = await page.evaluate(({ sel, candidates }) => { + const ok = await page.evaluate(({ sel, code, labels }) => { 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()) - ); + const norm = (x) => (x || '').trim().toLowerCase(); + const needles = labels.map(norm).filter(Boolean); + + const hit = opts.find((o) => { + const t = norm(o.textContent); + const v = norm(o.value); + if (v === norm(code)) return true; + if (t.includes(norm(code))) return true; + return needles.some((n) => t.includes(n)); + }); + if (!hit) return false; + s.value = hit.value; s.dispatchEvent(new Event('change', { bubbles: true })); return true; - }, { sel: CONFIG.SEL.statusDropdown, candidates }); + }, { sel: CONFIG.SEL.statusDropdown, code, labels }); - if (!ok) throw new Error(`No matching status option for "${nuevoEstado}"`); + if (!ok) throw new Error(`No encuentro el estado en el desplegable para statusCode=${code}`); } - if (nota) { + if (notaFinal) { 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(notaFinal)); } 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; +// -------------------- Express app -------------------- +initFirebaseAdmin(); +const db = admin.firestore(); - return await db.runTransaction(async (tx) => { - const fresh = await tx.get(jobRef); - if (!fresh.exists) return null; +const app = express(); +app.use(helmet()); +app.use(cors({ origin: '*'})); +app.use(express.json({ limit: '512kb' })); - const d = fresh.data() || {}; - const st = d.status ?? 'PENDING'; +// Concurrencia: servidor pequeño => 1 job a la vez +let busy = false; - // 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); +app.get('/health', (_req, res) => { + res.json({ ok: true, busy, ts: new Date().toISOString() }); }); -process.on('uncaughtException', (e) => { - console.error('[HS] uncaughtException', e); - process.exit(1); + +app.post('/v1/homeserve/change-status', async (req, res) => { + const startedAt = new Date().toISOString(); + + try { + // Seguridad: API_KEY opcional + if (CONFIG.API_KEY) { + const k = req.headers['x-api-key']; + if (!k || String(k) !== String(CONFIG.API_KEY)) { + return res.status(401).json({ ok: false, error: 'Invalid X-API-Key' }); + } + } + + // Seguridad: Firebase token (por defecto requerido) + await verifyFirebaseIdTokenIfPresent(req); + + if (busy) { + return res.status(409).json({ ok: false, error: 'BUSY: ya hay un cambio en curso' }); + } + + const { serviceNumber, statusCode, dateString, observation } = req.body || {}; + const parteId = String(serviceNumber || '').trim(); + const code = String(statusCode || '').trim(); + + if (!parteId) return res.status(400).json({ ok: false, error: 'serviceNumber requerido' }); + if (!code) return res.status(400).json({ ok: false, error: 'statusCode requerido' }); + if (!STATUS_CODE_MAP[code]) { + return res.status(400).json({ + ok: false, + error: `statusCode inválido: ${code}`, + allowed: Object.keys(STATUS_CODE_MAP), + }); + } + + // Nota final: metemos fecha si viene + const ds = (dateString ? String(dateString).trim() : ''); + const obs = (observation ? String(observation).trim() : ''); + const notaFinal = [obs, ds ? `Fecha: ${ds}` : ''].filter(Boolean).join(' · '); + + busy = true; + const creds = await getHomeServeCredentials(db); + + await withBrowser(async (page) => { + await loginHomeServe(page, creds); + await openParte(page, parteId); + await setEstadoByStatusCode(page, code, notaFinal); + }); + + busy = false; + + return res.json({ + ok: true, + startedAt, + finishedAt: new Date().toISOString(), + serviceNumber: parteId, + statusCode: code, + statusText: STATUS_CODE_MAP[code][0], + noteSent: notaFinal, + }); + + } catch (err) { + busy = false; + const msg = String(err?.message || err); + return res.status(500).json({ ok: false, error: msg }); + } +}); + +app.listen(CONFIG.PORT, () => { + console.log(`[estados-hs] listening on :${CONFIG.PORT}`); + console.log(`[estados-hs] HS_CRED_DOC_PATH=${CONFIG.HS_CRED_DOC_PATH}`); + console.log(`[estados-hs] REQUIRE_AUTH=${optEnv('REQUIRE_AUTH', '1')}`); }); \ No newline at end of file