diff --git a/index.js b/index.js index 1469d39..9cf1baf 100644 --- a/index.js +++ b/index.js @@ -1,3 +1,290 @@ +// 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'); }); @@ -6,9 +293,204 @@ app.get('/health', (req, res) => { res.status(200).json({ ok: true, service: 'estados-hs', - port: Number(process.env.PORT || 0), - requireAuth: String(process.env.REQUIRE_AUTH || ''), - hsCredDocPath: String(process.env.HS_CRED_DOC_PATH || ''), - ts: new Date().toISOString(), + port: parsePort(process.env.PORT, null), + requireAuth: CONFIG.REQUIRE_AUTH, + hsCredDocPath: CONFIG.HS_CRED_DOC_PATH, + ts: nowISO(), }); -}); \ No newline at end of file +}); + +// 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(` + +
+ + +