'use strict'; /** * estados-hs-direct/index.js * - Server HTTP (Express) para ejecutar cambios de estado HomeServe "directo" (sin cola). * - Auth opcional vía Firebase ID Token (REQUIRE_AUTH=1). * - Credenciales HomeServe leídas desde Firestore (HS_CRED_DOC_PATH=secrets/homeserve). * - Healthcheck compatible con CapRover: / y /health devuelven 200. * * Firestore doc recomendado (por defecto: secrets/homeserve): * { * baseUrl: "https://gestor.homeserve.es/", * user: "usuario@...", * pass: "******", * selectors: { * user: "...", * pass: "...", * submit: "...", * searchBox: "...", * searchBtn: "...", * openRow: "...", * statusDropdown: "...", * saveBtn: "...", * noteTextarea: "..." * } * } * * Request POST /api/homeserve/change-status: * { * "serviceNumber": "28197832", * "code": "348", * "dateString": "03/01/2026", * "observation": "texto opcional" * } */ const express = require('express'); const { chromium } = require('playwright'); const admin = require('firebase-admin'); const PORT = parseInt(process.env.PORT || '3000', 10); const REQUIRE_AUTH = String(process.env.REQUIRE_AUTH || '0') === '1'; const HS_CRED_DOC_PATH = process.env.HS_CRED_DOC_PATH || 'secrets/homeserve'; // Selectores por defecto (puedes sobreescribirlos vía env o Firestore doc) const DEFAULT_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', // Ojo: aquí NO seleccionamos por "texto", sino por código (value o texto que contenga el code) 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")', }; function log(...args) { console.log('[estados-hs]', ...args); } function safeStr(v) { return (v == null) ? '' : String(v); } function stripDigits(v) { return safeStr(v).replace(/\D+/g, ''); } // ---------------- Firebase init ---------------- let firestore = null; function initFirebaseOnce() { if (firestore) return firestore; // Intentamos init de varias formas para que no te "explote" según el entorno try { if (admin.apps && admin.apps.length) { firestore = admin.firestore(); return firestore; } // 1) Con service account por envs (como tenías antes) if (process.env.FIREBASE_PRIVATE_KEY) { if (!process.env.FIREBASE_PROJECT_ID || !process.env.FIREBASE_CLIENT_EMAIL) { throw new Error('Missing env: FIREBASE_PROJECT_ID or FIREBASE_CLIENT_EMAIL'); } admin.initializeApp({ credential: admin.credential.cert({ projectId: process.env.FIREBASE_PROJECT_ID, clientEmail: process.env.FIREBASE_CLIENT_EMAIL, privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'), }), }); firestore = admin.firestore(); return firestore; } // 2) Application Default Credentials (por ejemplo con GOOGLE_APPLICATION_CREDENTIALS) admin.initializeApp({ credential: admin.credential.applicationDefault(), }); firestore = admin.firestore(); return firestore; } catch (e) { // Lo dejamos súper claro en logs log('Firebase init error:', e?.message || e); throw e; } } async function verifyFirebaseIdToken(req) { const authHeader = req.headers.authorization || ''; const m = authHeader.match(/^Bearer\s+(.+)$/i); if (!m) throw new Error('Missing Authorization Bearer token'); const idToken = m[1]; const db = initFirebaseOnce(); // asegura admin init void db; // (solo para dejar claro que lo usamos para init) const decoded = await admin.auth().verifyIdToken(idToken); return decoded; } // ---------------- HomeServe creds cache ---------------- let cachedCreds = null; let cachedCredsAt = 0; const CREDS_TTL_MS = 60 * 1000; // 1 min (para que si cambias algo en Firestore lo pille rápido) async function loadHomeServeCreds() { const now = Date.now(); if (cachedCreds && (now - cachedCredsAt) < CREDS_TTL_MS) return cachedCreds; const db = initFirebaseOnce(); // Env override (si quieres, pero por defecto: Firestore) const envUser = process.env.HOMESERVE_USER; const envPass = process.env.HOMESERVE_PASS; const envBaseUrl = process.env.HOMESERVE_BASE_URL; let fromFirestore = null; try { const ref = db.doc(HS_CRED_DOC_PATH); const snap = await ref.get(); if (snap.exists) fromFirestore = snap.data() || {}; } catch (e) { log('Warning: cannot read HS creds from Firestore:', e?.message || e); } const baseUrl = safeStr(envBaseUrl || fromFirestore?.baseUrl || 'https://gestor.homeserve.es/').trim(); const user = safeStr(envUser || fromFirestore?.user || fromFirestore?.username || '').trim(); const pass = safeStr(envPass || fromFirestore?.pass || fromFirestore?.password || '').trim(); const selectors = { ...DEFAULT_SEL, ...(fromFirestore?.selectors || {}), }; if (!user || !pass) { throw new Error( `HomeServe credentials missing. Set them in Firestore doc "${HS_CRED_DOC_PATH}" (fields: user, pass) or env HOMESERVE_USER/HOMESERVE_PASS.` ); } cachedCreds = { baseUrl, user, pass, selectors }; cachedCredsAt = now; return cachedCreds; } // ---------------- Playwright helpers ---------------- const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); 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 loginHomeServe(page, creds) { const { baseUrl, user, pass, selectors: SEL } = creds; await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: 120000 }); await page.waitForSelector(SEL.user, { timeout: 60000 }); await page.fill(SEL.user, user); await page.waitForSelector(SEL.pass, { timeout: 60000 }); await page.fill(SEL.pass, pass); const btn = await page.$(SEL.submit); if (btn) await btn.click(); else await page.keyboard.press('Enter'); await page.waitForLoadState('networkidle', { timeout: 120000 }); } async function openParte(page, parteId, SEL) { const hasSearch = await page.$(SEL.searchBox); if (hasSearch) { await page.fill(SEL.searchBox, String(parteId)); const btn = await page.$(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(SEL.openRow, { timeout: 60000 }); await page.click(SEL.openRow); await page.waitForLoadState('networkidle', { timeout: 120000 }); await sleep(1200); } /** * Selecciona estado por "code". * - 1) intenta selectOption por value==code * - 2) intenta encontrar option cuyo texto contenga el code * - 3) dispara change */ async function selectStatusByCode(page, SEL, code) { const statusCode = safeStr(code).trim(); if (!statusCode) throw new Error('Missing status code'); await page.waitForSelector(SEL.statusDropdown, { timeout: 60000 }); // intento 1: value = code try { await page.selectOption(SEL.statusDropdown, { value: statusCode }); return; } catch (_) { // sigue } // intento 2: buscar option por texto que contenga el code const ok = await page.evaluate(({ sel, code }) => { const s = document.querySelector(sel); if (!s) return { ok: false, why: 'select not found' }; const opts = Array.from(s.querySelectorAll('option')); const hit = opts.find(o => (o.value || '').trim() === code) || opts.find(o => ((o.textContent || '').replace(/\s+/g, ' ')).includes(code)); if (!hit) return { ok: false, why: 'no matching option' }; s.value = hit.value; s.dispatchEvent(new Event('change', { bubbles: true })); return { ok: true, value: hit.value, text: (hit.textContent || '').trim() }; }, { sel: SEL.statusDropdown, code: statusCode }); if (!ok || !ok.ok) { throw new Error(`No matching status option for code "${statusCode}"`); } } async function setEstado(page, SEL, code, noteMaybe) { await selectStatusByCode(page, SEL, code); if (noteMaybe) { const ta = await page.$(SEL.noteTextarea); if (ta) { await page.fill(SEL.noteTextarea, String(noteMaybe)); } } const save = await page.$(SEL.saveBtn); if (!save) throw new Error('Save button not found'); await save.click(); await page.waitForLoadState('networkidle', { timeout: 120000 }); await sleep(1200); } // ---------------- Express app ---------------- const app = express(); app.use(express.json({ limit: '1mb' })); // ✅ Importante para CapRover: / debe devolver 200 (si no, te mata el contenedor) app.get('/', (req, res) => { res.status(200).send('ok'); }); app.get('/health', (req, res) => { res.status(200).json({ ok: true, service: 'estados-hs', requireAuth: REQUIRE_AUTH, hsCredDocPath: HS_CRED_DOC_PATH, ts: new Date().toISOString(), }); }); // Endpoint principal app.post('/api/homeserve/change-status', async (req, res) => { const startedAt = new Date().toISOString(); try { // Auth si está activado let decoded = null; if (REQUIRE_AUTH) { decoded = await verifyFirebaseIdToken(req); } const serviceNumberRaw = req.body?.serviceNumber ?? req.body?.parteId ?? req.body?.parte ?? req.body?.codigo; const codeRaw = req.body?.code ?? req.body?.statusCode ?? req.body?.newStatusValue ?? req.body?.selectedCode; const dateString = safeStr(req.body?.dateString ?? '').trim(); // no siempre se usa en portal, pero lo guardamos en nota si quieres const observation = safeStr(req.body?.observation ?? req.body?.note ?? '').trim(); const serviceNumber = stripDigits(serviceNumberRaw); const code = stripDigits(codeRaw) || safeStr(codeRaw).trim(); if (!serviceNumber || serviceNumber.length < 5) { return res.status(400).json({ ok: false, error: 'Invalid serviceNumber' }); } if (!code) { return res.status(400).json({ ok: false, error: 'Missing status code' }); } const creds = await loadHomeServeCreds(); const SEL = creds.selectors; // Nota final (si quieres incluir fecha) const finalNote = [observation, dateString ? `Fecha: ${dateString}` : ''].filter(Boolean).join(' | '); const result = await withBrowser(async (page) => { await loginHomeServe(page, creds); await openParte(page, serviceNumber, SEL); await setEstado(page, SEL, code, finalNote || null); return { ok: true }; }); return res.json({ ok: true, startedAtISO: startedAt, finishedAtISO: new Date().toISOString(), serviceNumber, code, requireAuth: REQUIRE_AUTH, uid: decoded?.uid || null, result, }); } catch (e) { const msg = safeStr(e?.message || e); log('ERROR change-status:', msg); return res.status(500).json({ ok: false, error: msg, startedAtISO: startedAt, finishedAtISO: new Date().toISOString(), }); } }); // 404 fallback (para que quede claro) app.use((req, res) => { res.status(404).json({ ok: false, error: 'Not found' }); }); // ---------------- start + graceful shutdown ---------------- const server = app.listen(PORT, () => { log(`listening on :${PORT}`); log(`HS_CRED_DOC_PATH=${HS_CRED_DOC_PATH}`); log(`REQUIRE_AUTH=${REQUIRE_AUTH ? '1' : '0'}`); }); function shutdown(signal) { log(`received ${signal}, shutting down...`); server.close(() => { process.exit(0); }); // por si algo se queda colgado setTimeout(() => process.exit(0), 3000).unref(); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT'));