'use strict'; const express = require('express'); const cors = require('cors'); const admin = require('firebase-admin'); const { chromium } = require('playwright'); const PORT = parseInt(process.env.PORT || '3000', 10); // ====== CONFIG ====== const CONFIG = { HOMESERVE_BASE_URL: process.env.HOMESERVE_BASE_URL || 'https://gestor.homeserve.es/', // Firestore doc path que guarda credenciales HS // Ejemplo: secrets/homeserve (collection/doc) HS_CRED_DOC_PATH: process.env.HS_CRED_DOC_PATH || 'secrets/homeserve', // Seguridad: si REQUIRE_AUTH=1, obliga a token Firebase (Authorization: Bearer ) REQUIRE_AUTH: String(process.env.REQUIRE_AUTH || '1') === '1', // Playwright HEADLESS: String(process.env.HEADLESS || 'true') !== 'false', // Selectores (ajustables por env si el portal cambia) SEL: { user: process.env.SEL_USER || 'input[type="text"], input[name="username"], input[id*="user"], input[autocomplete="username"]', pass: process.env.SEL_PASS || 'input[type="password"], input[name="password"], input[id*="pass"], input[autocomplete="current-password"]', submit: process.env.SEL_SUBMIT || 'button[type="submit"], button:has-text("Entrar"), button:has-text("Acceder"), button:has-text("Login")', // búsqueda parte 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', // cambio estado statusDropdown: process.env.SEL_STATUS_DROPDOWN || 'select[name*="estado"], select[id*="estado"], select', // opcional: input/selector para fecha seguimiento followUpDate: process.env.SEL_FOLLOWUP_DATE || 'input[name*="fecha"], input[id*="fecha"], input[type="date"]', // opcional: textarea nota 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")', }, // Timeout TIMEOUT_MS: parseInt(process.env.TIMEOUT_MS || '120000', 10), }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let busy = false; // ====== FIREBASE ADMIN INIT ====== function initFirebaseAdmin() { // Opción A: variables (como tus robots antiguos) const hasEnvKey = !!process.env.FIREBASE_PRIVATE_KEY; if (hasEnvKey) { if (admin.apps.length === 0) { 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; } // Opción B: Application Default Credentials (GOOGLE_APPLICATION_CREDENTIALS) // En CapRover puedes montar un archivo JSON y exportar GOOGLE_APPLICATION_CREDENTIALS if (admin.apps.length === 0) { admin.initializeApp({ credential: admin.credential.applicationDefault(), }); } } function mustEnv(name) { const v = process.env[name]; if (!v) throw new Error(`Missing env: ${name}`); return v; } async function getHomeServeCreds(db) { // Espera que el doc tenga campos tipo: // { user: "...", pass: "..." } o { username: "...", password: "..." } const snap = await db.doc(CONFIG.HS_CRED_DOC_PATH).get(); if (!snap.exists) { throw new Error(`HS credentials doc not found: ${CONFIG.HS_CRED_DOC_PATH}`); } const d = snap.data() || {}; const user = (d.user || d.username || d.email || d.HOMESERVE_USER || '').toString().trim(); const pass = (d.pass || d.password || d.HOMESERVE_PASS || '').toString().trim(); if (!user || !pass) { throw new Error(`HS credentials doc missing fields (need user/pass): ${CONFIG.HS_CRED_DOC_PATH}`); } return { user, pass }; } async function verifyAuth(req) { if (!CONFIG.REQUIRE_AUTH) return { ok: true, uid: null }; const h = req.headers.authorization || ''; const m = h.match(/^Bearer\s+(.+)$/i); if (!m) { return { ok: false, status: 401, error: 'Missing Authorization Bearer token' }; } try { const decoded = await admin.auth().verifyIdToken(m[1]); return { ok: true, uid: decoded.uid }; } catch (e) { return { ok: false, status: 401, error: 'Invalid token' }; } } // ====== PLAYWRIGHT FLOW ====== async function withBrowser(fn) { const browser = await chromium.launch({ headless: CONFIG.HEADLESS, 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, hsUser, hsPass) { await page.goto(CONFIG.HOMESERVE_BASE_URL, { waitUntil: 'domcontentloaded', timeout: CONFIG.TIMEOUT_MS }); await page.waitForSelector(CONFIG.SEL.user, { timeout: 60000 }); await page.fill(CONFIG.SEL.user, hsUser); await page.waitForSelector(CONFIG.SEL.pass, { timeout: 60000 }); await page.fill(CONFIG.SEL.pass, hsPass); const btn = await page.$(CONFIG.SEL.submit); if (btn) await btn.click(); else await page.keyboard.press('Enter'); // Importante: a veces HS no llega a networkidle (polling interno). Mejor esperar a DOM + un respiro. await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.TIMEOUT_MS }); await sleep(1200); } 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('domcontentloaded', { timeout: CONFIG.TIMEOUT_MS }); await sleep(1200); } await page.waitForSelector(CONFIG.SEL.openRow, { timeout: 60000 }); await page.click(CONFIG.SEL.openRow); await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.TIMEOUT_MS }); await sleep(1200); } function normalizeDateDDMMYYYY(s) { const v = String(s || '').trim(); if (!v) return ''; // acepta dd/MM/yyyy o yyyy-MM-dd if (/^\d{2}\/\d{2}\/\d{4}$/.test(v)) return v; if (/^\d{4}-\d{2}-\d{2}$/.test(v)) { const [y, m, d] = v.split('-'); return `${d}/${m}/${y}`; } return v; // lo dejamos tal cual si el usuario mete algo raro } async function setEstadoByCode(page, statusCode, followUpDate, note) { const code = String(statusCode || '').trim(); if (!code) throw new Error('Missing statusCode'); await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: 60000 }); // 1) intenta por value exacto (lo normal si el portal usa códigos) let selected = false; try { await page.selectOption(CONFIG.SEL.statusDropdown, { value: code }); selected = true; } catch (_) {} // 2) si no, intenta por label que contenga el código if (!selected) { const ok = await page.evaluate(({ sel, code }) => { const s = document.querySelector(sel); if (!s) return false; const opts = Array.from(s.querySelectorAll('option')); const hit = opts.find(o => { const t = (o.textContent || '').trim(); // acepta "Texto (348)" o "348 - Texto" o similares return t.includes(code) || t.startsWith(code); }); if (!hit) return false; s.value = hit.value; s.dispatchEvent(new Event('change', { bubbles: true })); return true; }, { sel: CONFIG.SEL.statusDropdown, code }); if (!ok) throw new Error(`No matching status option for code "${code}"`); } // Fecha seguimiento (opcional) const dateStr = normalizeDateDDMMYYYY(followUpDate); if (dateStr) { const hasDate = await page.$(CONFIG.SEL.followUpDate); if (hasDate) { // si es input type="date" espera yyyy-MM-dd, pero muchos portales usan texto. // Intentamos poner tal cual; si falla, no rompemos. try { await page.fill(CONFIG.SEL.followUpDate, dateStr); } catch (_) {} } } // Nota (opcional) if (note) { const ta = await page.$(CONFIG.SEL.noteTextarea); if (ta) { await page.fill(CONFIG.SEL.noteTextarea, String(note)); } } const save = await page.$(CONFIG.SEL.saveBtn); if (!save) throw new Error('Save button not found'); await save.click(); await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.TIMEOUT_MS }); await sleep(1200); } // ====== EXPRESS SERVER ====== initFirebaseAdmin(); const db = admin.firestore(); const app = express(); app.use(cors()); app.use(express.json({ limit: '1mb' })); // Para CapRover: NO 404 en / app.get('/', (req, res) => { res.status(200).json({ ok: true, service: 'estados-hs', ts: new Date().toISOString() }); }); app.get('/health', (req, res) => { res.status(200).json({ ok: true, busy, ts: new Date().toISOString() }); }); /** * POST /v1/homeserve/status * Body: * { * "parteId": "12345678", * "statusCode": "348", * "followUpDate": "03/01/2026" // opcional (dd/MM/yyyy o yyyy-MM-dd) * "observation": "texto..." // opcional * } */ app.post('/v1/homeserve/status', async (req, res) => { const auth = await verifyAuth(req); if (!auth.ok) return res.status(auth.status).json({ ok: false, error: auth.error }); const parteId = (req.body?.parteId || req.body?.serviceNumber || req.body?.parte || '').toString().trim(); const statusCode = (req.body?.statusCode || req.body?.newStatusValue || req.body?.code || '').toString().trim(); const followUpDate = (req.body?.followUpDate || req.body?.dateString || '').toString().trim(); const observation = (req.body?.observation || req.body?.nota || req.body?.note || '').toString().trim(); if (!parteId || !statusCode) { return res.status(400).json({ ok: false, error: 'Missing parteId or statusCode', example: { parteId: '28197832', statusCode: '348', followUpDate: '03/01/2026', observation: '...' }, }); } if (busy) { return res.status(409).json({ ok: false, error: 'BUSY', message: 'Server is processing another request' }); } busy = true; const startedAt = new Date().toISOString(); try { const { user, pass } = await getHomeServeCreds(db); await withBrowser(async (page) => { await login(page, user, pass); await openParte(page, parteId); await setEstadoByCode(page, statusCode, followUpDate, observation); }); return res.json({ ok: true, parteId, statusCode, followUpDate: followUpDate || null, observation: observation || null, startedAt, finishedAt: new Date().toISOString(), uid: auth.uid, }); } catch (e) { return res.status(500).json({ ok: false, error: String(e?.message || e), startedAt, finishedAt: new Date().toISOString(), }); } finally { busy = false; } }); app.listen(PORT, () => { console.log(`[estados-hs] listening on :${PORT}`); console.log(`[estados-hs] HS_CRED_DOC_PATH=${CONFIG.HS_CRED_DOC_PATH}`); console.log(`[estados-hs] REQUIRE_AUTH=${CONFIG.REQUIRE_AUTH ? '1' : '0'}`); });