'use strict'; const express = require('express'); const { chromium } = require('playwright'); const admin = require('firebase-admin'); const PORT = parseInt(process.env.PORT || process.env.CAPROVER_PORT || '80', 10); const REQUIRE_AUTH = String(process.env.REQUIRE_AUTH || '0') === '1'; const HS_CRED_DOC_PATH = process.env.HS_CRED_DOC_PATH || 'secrets/homeserve'; 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', 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; try { if (admin.apps && admin.apps.length) { firestore = admin.firestore(); return firestore; } 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; } admin.initializeApp({ credential: admin.credential.applicationDefault() }); firestore = admin.firestore(); return firestore; } catch (e) { 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]; initFirebaseOnce(); const decoded = await admin.auth().verifyIdToken(idToken); return decoded; } // ---------------- HomeServe creds cache ---------------- let cachedCreds = null; let cachedCredsAt = 0; const CREDS_TTL_MS = 60 * 1000; async function loadHomeServeCreds() { const now = Date.now(); if (cachedCreds && (now - cachedCredsAt) < CREDS_TTL_MS) return cachedCreds; const db = initFirebaseOnce(); 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. Put user/pass in Firestore doc "${HS_CRED_DOC_PATH}" or set HOMESERVE_USER/HOMESERVE_PASS envs.` ); } 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); } 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 }); try { await page.selectOption(SEL.statusDropdown, { value: statusCode }); return; } catch (_) {} const r = 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 (!r || !r.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' })); app.get('/', (req, res) => { res.status(200).send('ok'); }); app.get('/health', (req, res) => { res.status(200).json({ ok: true, service: 'estados-hs', port: PORT, requireAuth: REQUIRE_AUTH, hsCredDocPath: HS_CRED_DOC_PATH, ts: new Date().toISOString(), }); }); app.post('/api/homeserve/change-status', async (req, res) => { const startedAt = new Date().toISOString(); try { 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(); 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; const finalNote = [observation, dateString ? `Fecha: ${dateString}` : ''].filter(Boolean).join(' | '); await withBrowser(async (page) => { await loginHomeServe(page, creds); await openParte(page, serviceNumber, SEL); await setEstado(page, SEL, code, finalNote || null); }); return res.json({ ok: true, startedAtISO: startedAt, finishedAtISO: new Date().toISOString(), serviceNumber, code, uid: decoded?.uid || null, }); } 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(), }); } }); app.use((req, res) => { res.status(404).json({ ok: false, error: 'Not found' }); }); 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'}`); log(`ENV PORT=${process.env.PORT || '(unset)'} CAPROVER_PORT=${process.env.CAPROVER_PORT || '(unset)'}`); }); function shutdown(signal) { log(`received ${signal}, shutting down...`); server.close(() => process.exit(0)); setTimeout(() => process.exit(0), 3000).unref(); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT'));