diff --git a/index.js b/index.js index 9a49006..1469d39 100644 --- a/index.js +++ b/index.js @@ -1,253 +1,3 @@ -'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'); }); @@ -256,85 +6,9 @@ app.get('/health', (req, res) => { res.status(200).json({ ok: true, service: 'estados-hs', - port: PORT, - requireAuth: REQUIRE_AUTH, - hsCredDocPath: HS_CRED_DOC_PATH, + port: Number(process.env.PORT || 0), + requireAuth: String(process.env.REQUIRE_AUTH || ''), + hsCredDocPath: String(process.env.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')); \ No newline at end of file +}); \ No newline at end of file