// index.js 'use strict'; const express = require('express'); const cors = require('cors'); const admin = require('firebase-admin'); const { chromium } = require('playwright'); // ----------------------------- // Utils // ----------------------------- function mustEnv(name) { const v = process.env[name]; if (!v) throw new Error(`Missing env: ${name}`); return v; } function parsePort(v, fallback) { const n = Number.parseInt(String(v || ''), 10); return Number.isFinite(n) && n > 0 ? n : fallback; } function nowISO() { return new Date().toISOString(); } function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } function safeStr(v) { return String(v ?? '').trim(); } // ----------------------------- // Config // ----------------------------- const CONFIG = { // HomeServe HOMESERVE_BASE_URL: process.env.HOMESERVE_BASE_URL || 'https://gestor.homeserve.es/', // Credenciales desde Firestore (doc path tipo: secrets/homeserve) HS_CRED_DOC_PATH: process.env.HS_CRED_DOC_PATH || 'secrets/homeserve', // Auth API (opcional) REQUIRE_AUTH: String(process.env.REQUIRE_AUTH || '0') === '1', API_TOKEN: process.env.API_TOKEN || '', // Playwright HEADLESS: String(process.env.HEADLESS || 'true') !== 'false', // Selectores (ajustables por env) SEL: { user: process.env.SEL_USER || 'input[type="text"], input[name="username"], input[id*="user"]', pass: process.env.SEL_PASS || 'input[type="password"], input[name="password"], input[id*="pass"]', submit: process.env.SEL_SUBMIT || 'button[type="submit"], button:has-text("Acceder"), button:has-text("Entrar")', 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', // OJO: aquí depende mucho del portal statusDropdown: process.env.SEL_STATUS_DROPDOWN || 'select[name*="estado"], select[id*="estado"], select', 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")', }, // Timing NAV_TIMEOUT: parseInt(process.env.NAV_TIMEOUT || '120000', 10), WAIT_TIMEOUT: parseInt(process.env.WAIT_TIMEOUT || '60000', 10), }; // ----------------------------- // Firebase Admin init // ----------------------------- function initFirebase() { // Soporta 2 modos: // 1) FIREBASE_PROJECT_ID + FIREBASE_CLIENT_EMAIL + FIREBASE_PRIVATE_KEY // 2) Application Default Credentials (si existiese) if (admin.apps.length) return admin.firestore(); const hasEnvCreds = process.env.FIREBASE_PROJECT_ID && process.env.FIREBASE_CLIENT_EMAIL && process.env.FIREBASE_PRIVATE_KEY; if (hasEnvCreds) { 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'), }), }); } else { // fallback: si tu entorno tuviera ADC (no siempre) admin.initializeApp(); } return admin.firestore(); } const db = initFirebase(); // ----------------------------- // HomeServe credentials loader (Firestore) // ----------------------------- let credCache = { ts: 0, data: null }; const CRED_TTL_MS = parseInt(process.env.CRED_TTL_MS || '60000', 10); async function getHomeServeCreds() { const now = Date.now(); if (credCache.data && (now - credCache.ts) < CRED_TTL_MS) return credCache.data; // Firestore doc: CONFIG.HS_CRED_DOC_PATH // Esperado: // { // user: "usuario", // pass: "password", // baseUrl: "https://gestor.homeserve.es/" (opcional) // } const snap = await db.doc(CONFIG.HS_CRED_DOC_PATH).get(); const d = snap.exists ? (snap.data() || {}) : {}; const user = safeStr(d.user || d.username || process.env.HOMESERVE_USER); const pass = safeStr(d.pass || d.password || process.env.HOMESERVE_PASS); const baseUrl = safeStr(d.baseUrl || d.url || CONFIG.HOMESERVE_BASE_URL) || CONFIG.HOMESERVE_BASE_URL; if (!user || !pass) { throw new Error( `HomeServe creds missing. Set them in Firestore doc "${CONFIG.HS_CRED_DOC_PATH}" (user/pass) or env HOMESERVE_USER/HOMESERVE_PASS` ); } const out = { user, pass, baseUrl }; credCache = { ts: now, data: out }; return out; } // ----------------------------- // Playwright helpers // ----------------------------- 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, creds) { await page.goto(creds.baseUrl, { waitUntil: 'domcontentloaded', timeout: CONFIG.NAV_TIMEOUT }); await page.waitForSelector(CONFIG.SEL.user, { timeout: CONFIG.WAIT_TIMEOUT }); await page.fill(CONFIG.SEL.user, creds.user); await page.waitForSelector(CONFIG.SEL.pass, { timeout: CONFIG.WAIT_TIMEOUT }); 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'); // “networkidle” a veces se atasca; lo hacemos más tolerante await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT }); await sleep(1200); } async function openParte(page, parteId) { // Buscar 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.NAV_TIMEOUT }); await sleep(1200); } // Abrir primera fila await page.waitForSelector(CONFIG.SEL.openRow, { timeout: CONFIG.WAIT_TIMEOUT }); await page.click(CONFIG.SEL.openRow); await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT }); await sleep(1000); } async function setEstadoByCode(page, statusCode, note) { const code = safeStr(statusCode); if (!code) throw new Error('Missing statusCode'); await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: CONFIG.WAIT_TIMEOUT }); // 1) Intentar selectOption por value exacto let selected = false; try { await page.selectOption(CONFIG.SEL.statusDropdown, { value: code }); selected = true; } catch (_) {} // 2) Intentar selectOption por label que contenga el código if (!selected) { try { await page.selectOption(CONFIG.SEL.statusDropdown, { label: code }); selected = true; } catch (_) {} } // 3) Fallback DOM: busca option cuyo value == code o cuyo texto contenga "(code)" o termine con code 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 norm = (x) => (x || '').toString().trim().toLowerCase(); const hit = opts.find(o => norm(o.value) === norm(code)) || opts.find(o => norm(o.textContent).includes(`(${norm(code)})`)) || opts.find(o => norm(o.textContent).endsWith(norm(code))) || opts.find(o => norm(o.textContent).includes(norm(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}". Revisa SEL_STATUS_DROPDOWN o el HTML del portal.`); } } // Nota / observación if (note) { const ta = await page.$(CONFIG.SEL.noteTextarea); if (ta) { await page.fill(CONFIG.SEL.noteTextarea, String(note)); } } // Guardar const save = await page.$(CONFIG.SEL.saveBtn); if (!save) throw new Error('Save button not found (SEL_SAVE_BTN)'); await save.click(); await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT }); await sleep(1400); } // ----------------------------- // Express app // ----------------------------- const app = express(); app.use(cors({ origin: true })); app.use(express.json({ limit: '1mb' })); // Auth middleware (opcional) app.use((req, res, next) => { if (!CONFIG.REQUIRE_AUTH) return next(); const h = req.headers.authorization || ''; const token = h.startsWith('Bearer ') ? h.slice(7).trim() : ''; if (!CONFIG.API_TOKEN) { return res.status(500).json({ ok: false, error: { message: 'REQUIRE_AUTH=1 but API_TOKEN is not set' } }); } if (!token || token !== CONFIG.API_TOKEN) { return res.status(401).json({ ok: false, error: { message: 'Unauthorized' } }); } next(); }); // Health + root app.get('/', (req, res) => { res.status(200).send('ok'); }); app.get('/health', (req, res) => { res.status(200).json({ ok: true, service: 'estados-hs', port: parsePort(process.env.PORT, null), requireAuth: CONFIG.REQUIRE_AUTH, hsCredDocPath: CONFIG.HS_CRED_DOC_PATH, ts: nowISO(), }); }); // Endpoint principal: cambio directo // Body esperado: // { // serviceNumber: "28219874", // newStatusValue: "348", // código // dateString: "03/01/2026", // opcional (por si lo quieres guardar en nota) // observation: "texto..." // opcional // } app.post('/api/homeserve/change-status', async (req, res) => { const serviceNumber = safeStr(req.body?.serviceNumber); const newStatusValue = safeStr(req.body?.newStatusValue || req.body?.statusCode); const dateString = safeStr(req.body?.dateString); const observation = safeStr(req.body?.observation); if (!serviceNumber || !newStatusValue) { return res.status(400).json({ ok: false, error: { message: 'Missing serviceNumber or newStatusValue' }, }); } // Construimos nota final (si quieres que la fecha viaje dentro) const noteParts = []; if (dateString) noteParts.push(`Fecha: ${dateString}`); if (observation) noteParts.push(observation); const note = noteParts.join(' · ').trim(); const startedAtISO = nowISO(); try { const creds = await getHomeServeCreds(); await withBrowser(async (page) => { await login(page, creds); await openParte(page, serviceNumber); await setEstadoByCode(page, newStatusValue, note); }); return res.status(200).json({ ok: true, serviceNumber, newStatusValue, startedAtISO, finishedAtISO: nowISO(), }); } catch (err) { return res.status(500).json({ ok: false, serviceNumber, newStatusValue, startedAtISO, finishedAtISO: nowISO(), error: { message: String(err?.message || err), stack: String(err?.stack || ''), }, }); } }); // HTML test page (por si quieres probar rápido desde el navegador) app.get('/test', (req, res) => { res.type('html').send(`