diff --git a/index.js b/index.js index e6a0ad2..5defc5d 100644 --- a/index.js +++ b/index.js @@ -1,124 +1,200 @@ '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 cors = require('cors'); -const admin = require('firebase-admin'); 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'; -// ====== CONFIG ====== -const CONFIG = { - HOMESERVE_BASE_URL: process.env.HOMESERVE_BASE_URL || 'https://gestor.homeserve.es/', +// 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"]', - // Firestore doc path que guarda credenciales HS - // Ejemplo: secrets/homeserve (collection/doc) - HS_CRED_DOC_PATH: process.env.HS_CRED_DOC_PATH || 'secrets/homeserve', + 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', - // Seguridad: si REQUIRE_AUTH=1, obliga a token Firebase (Authorization: Bearer ) - REQUIRE_AUTH: String(process.env.REQUIRE_AUTH || '1') === '1', + // 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)', - // Playwright - HEADLESS: String(process.env.HEADLESS || 'true') !== 'false', + noteTextarea: + process.env.SEL_NOTE_TEXTAREA || + 'textarea[name*="nota"], textarea[id*="nota"], textarea', - // Selectores (ajustables por env si el portal cambia) - SEL: { - user: process.env.SEL_USER || 'input[type="text"], input[name="username"], input[id*="user"], input[autocomplete="username"]', - pass: process.env.SEL_PASS || 'input[type="password"], input[name="password"], input[id*="pass"], input[autocomplete="current-password"]', - submit: process.env.SEL_SUBMIT || 'button[type="submit"], button:has-text("Entrar"), button:has-text("Acceder"), button:has-text("Login")', - - // búsqueda parte - 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', - - // cambio estado - statusDropdown: process.env.SEL_STATUS_DROPDOWN || 'select[name*="estado"], select[id*="estado"], select', - // opcional: input/selector para fecha seguimiento - followUpDate: process.env.SEL_FOLLOWUP_DATE || 'input[name*="fecha"], input[id*="fecha"], input[type="date"]', - // opcional: textarea nota - 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")', - }, - - // Timeout - TIMEOUT_MS: parseInt(process.env.TIMEOUT_MS || '120000', 10), + saveBtn: + process.env.SEL_SAVE_BTN || + 'button:has-text("Guardar"), button:has-text("Save"), button:has-text("Actualizar")', }; -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +function log(...args) { + console.log('[estados-hs]', ...args); +} -let busy = false; +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'); + } -// ====== FIREBASE ADMIN INIT ====== -function initFirebaseAdmin() { - // Opción A: variables (como tus robots antiguos) - const hasEnvKey = !!process.env.FIREBASE_PRIVATE_KEY; - if (hasEnvKey) { - if (admin.apps.length === 0) { 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'), + projectId: process.env.FIREBASE_PROJECT_ID, + clientEmail: process.env.FIREBASE_CLIENT_EMAIL, + privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'), }), }); - } - return; - } - // Opción B: Application Default Credentials (GOOGLE_APPLICATION_CREDENTIALS) - // En CapRover puedes montar un archivo JSON y exportar GOOGLE_APPLICATION_CREDENTIALS - if (admin.apps.length === 0) { + firestore = admin.firestore(); + return firestore; + } + + // 2) Application Default Credentials (por ejemplo con GOOGLE_APPLICATION_CREDENTIALS) admin.initializeApp({ credential: admin.credential.applicationDefault(), }); - } -} -function mustEnv(name) { - const v = process.env[name]; - if (!v) throw new Error(`Missing env: ${name}`); - return v; -} + firestore = admin.firestore(); + return firestore; -async function getHomeServeCreds(db) { - // Espera que el doc tenga campos tipo: - // { user: "...", pass: "..." } o { username: "...", password: "..." } - const snap = await db.doc(CONFIG.HS_CRED_DOC_PATH).get(); - if (!snap.exists) { - throw new Error(`HS credentials doc not found: ${CONFIG.HS_CRED_DOC_PATH}`); - } - const d = snap.data() || {}; - const user = (d.user || d.username || d.email || d.HOMESERVE_USER || '').toString().trim(); - const pass = (d.pass || d.password || d.HOMESERVE_PASS || '').toString().trim(); - if (!user || !pass) { - throw new Error(`HS credentials doc missing fields (need user/pass): ${CONFIG.HS_CRED_DOC_PATH}`); - } - return { user, pass }; -} - -async function verifyAuth(req) { - if (!CONFIG.REQUIRE_AUTH) return { ok: true, uid: null }; - - const h = req.headers.authorization || ''; - const m = h.match(/^Bearer\s+(.+)$/i); - if (!m) { - return { ok: false, status: 401, error: 'Missing Authorization Bearer token' }; - } - - try { - const decoded = await admin.auth().verifyIdToken(m[1]); - return { ok: true, uid: decoded.uid }; } catch (e) { - return { ok: false, status: 401, error: 'Invalid token' }; + // Lo dejamos súper claro en logs + log('Firebase init error:', e?.message || e); + throw e; } } -// ====== PLAYWRIGHT FLOW ====== +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: CONFIG.HEADLESS, + headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'], }); @@ -132,202 +208,206 @@ async function withBrowser(fn) { } } -async function login(page, hsUser, hsPass) { - await page.goto(CONFIG.HOMESERVE_BASE_URL, { waitUntil: 'domcontentloaded', timeout: CONFIG.TIMEOUT_MS }); +async function loginHomeServe(page, creds) { + const { baseUrl, user, pass, selectors: SEL } = creds; - await page.waitForSelector(CONFIG.SEL.user, { timeout: 60000 }); - await page.fill(CONFIG.SEL.user, hsUser); + await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: 120000 }); - await page.waitForSelector(CONFIG.SEL.pass, { timeout: 60000 }); - await page.fill(CONFIG.SEL.pass, hsPass); + await page.waitForSelector(SEL.user, { timeout: 60000 }); + await page.fill(SEL.user, user); - const btn = await page.$(CONFIG.SEL.submit); + 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'); - // Importante: a veces HS no llega a networkidle (polling interno). Mejor esperar a DOM + un respiro. - await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.TIMEOUT_MS }); - await sleep(1200); + await page.waitForLoadState('networkidle', { timeout: 120000 }); } -async function openParte(page, parteId) { - const hasSearch = await page.$(CONFIG.SEL.searchBox); +async function openParte(page, parteId, SEL) { + const hasSearch = await page.$(SEL.searchBox); if (hasSearch) { - await page.fill(CONFIG.SEL.searchBox, String(parteId)); - const btn = await page.$(CONFIG.SEL.searchBtn); + 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('domcontentloaded', { timeout: CONFIG.TIMEOUT_MS }); + await page.waitForLoadState('networkidle', { timeout: 120000 }); await sleep(1200); } - await page.waitForSelector(CONFIG.SEL.openRow, { timeout: 60000 }); - await page.click(CONFIG.SEL.openRow); + await page.waitForSelector(SEL.openRow, { timeout: 60000 }); + await page.click(SEL.openRow); - await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.TIMEOUT_MS }); + await page.waitForLoadState('networkidle', { timeout: 120000 }); await sleep(1200); } -function normalizeDateDDMMYYYY(s) { - const v = String(s || '').trim(); - if (!v) return ''; - // acepta dd/MM/yyyy o yyyy-MM-dd - if (/^\d{2}\/\d{2}\/\d{4}$/.test(v)) return v; - if (/^\d{4}-\d{2}-\d{2}$/.test(v)) { - const [y, m, d] = v.split('-'); - return `${d}/${m}/${y}`; - } - return v; // lo dejamos tal cual si el usuario mete algo raro -} +/** + * 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'); -async function setEstadoByCode(page, statusCode, followUpDate, note) { - const code = String(statusCode || '').trim(); - if (!code) throw new Error('Missing statusCode'); + await page.waitForSelector(SEL.statusDropdown, { timeout: 60000 }); - await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: 60000 }); - - // 1) intenta por value exacto (lo normal si el portal usa códigos) - let selected = false; + // intento 1: value = code try { - await page.selectOption(CONFIG.SEL.statusDropdown, { value: code }); - selected = true; - } catch (_) {} - - // 2) si no, intenta por label que contenga el código - 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 hit = opts.find(o => { - const t = (o.textContent || '').trim(); - // acepta "Texto (348)" o "348 - Texto" o similares - return t.includes(code) || t.startsWith(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}"`); + await page.selectOption(SEL.statusDropdown, { value: statusCode }); + return; + } catch (_) { + // sigue } - // Fecha seguimiento (opcional) - const dateStr = normalizeDateDDMMYYYY(followUpDate); - if (dateStr) { - const hasDate = await page.$(CONFIG.SEL.followUpDate); - if (hasDate) { - // si es input type="date" espera yyyy-MM-dd, pero muchos portales usan texto. - // Intentamos poner tal cual; si falla, no rompemos. - try { - await page.fill(CONFIG.SEL.followUpDate, dateStr); - } catch (_) {} - } - } + // 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' }; - // Nota (opcional) - if (note) { - const ta = await page.$(CONFIG.SEL.noteTextarea); + 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(CONFIG.SEL.noteTextarea, String(note)); + await page.fill(SEL.noteTextarea, String(noteMaybe)); } } - const save = await page.$(CONFIG.SEL.saveBtn); + const save = await page.$(SEL.saveBtn); if (!save) throw new Error('Save button not found'); - await save.click(); - await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.TIMEOUT_MS }); + await save.click(); + await page.waitForLoadState('networkidle', { timeout: 120000 }); await sleep(1200); } -// ====== EXPRESS SERVER ====== -initFirebaseAdmin(); -const db = admin.firestore(); +// ---------------- Express app ---------------- const app = express(); -app.use(cors()); app.use(express.json({ limit: '1mb' })); -// Para CapRover: NO 404 en / +// ✅ Importante para CapRover: / debe devolver 200 (si no, te mata el contenedor) app.get('/', (req, res) => { - res.status(200).json({ ok: true, service: 'estados-hs', ts: new Date().toISOString() }); + res.status(200).send('ok'); }); app.get('/health', (req, res) => { - res.status(200).json({ ok: true, busy, ts: new Date().toISOString() }); + res.status(200).json({ + ok: true, + service: 'estados-hs', + requireAuth: REQUIRE_AUTH, + hsCredDocPath: HS_CRED_DOC_PATH, + ts: new Date().toISOString(), + }); }); -/** - * POST /v1/homeserve/status - * Body: - * { - * "parteId": "12345678", - * "statusCode": "348", - * "followUpDate": "03/01/2026" // opcional (dd/MM/yyyy o yyyy-MM-dd) - * "observation": "texto..." // opcional - * } - */ -app.post('/v1/homeserve/status', async (req, res) => { - const auth = await verifyAuth(req); - if (!auth.ok) return res.status(auth.status).json({ ok: false, error: auth.error }); - - const parteId = (req.body?.parteId || req.body?.serviceNumber || req.body?.parte || '').toString().trim(); - const statusCode = (req.body?.statusCode || req.body?.newStatusValue || req.body?.code || '').toString().trim(); - const followUpDate = (req.body?.followUpDate || req.body?.dateString || '').toString().trim(); - const observation = (req.body?.observation || req.body?.nota || req.body?.note || '').toString().trim(); - - if (!parteId || !statusCode) { - return res.status(400).json({ - ok: false, - error: 'Missing parteId or statusCode', - example: { parteId: '28197832', statusCode: '348', followUpDate: '03/01/2026', observation: '...' }, - }); - } - - if (busy) { - return res.status(409).json({ ok: false, error: 'BUSY', message: 'Server is processing another request' }); - } - - busy = true; +// Endpoint principal +app.post('/api/homeserve/change-status', async (req, res) => { const startedAt = new Date().toISOString(); try { - const { user, pass } = await getHomeServeCreds(db); + // Auth si está activado + let decoded = null; + if (REQUIRE_AUTH) { + decoded = await verifyFirebaseIdToken(req); + } - await withBrowser(async (page) => { - await login(page, user, pass); - await openParte(page, parteId); - await setEstadoByCode(page, statusCode, followUpDate, observation); + 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, - parteId, - statusCode, - followUpDate: followUpDate || null, - observation: observation || null, - startedAt, - finishedAt: new Date().toISOString(), - uid: auth.uid, + 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: String(e?.message || e), - startedAt, - finishedAt: new Date().toISOString(), + error: msg, + startedAtISO: startedAt, + finishedAtISO: new Date().toISOString(), }); - } finally { - busy = false; } }); -app.listen(PORT, () => { - console.log(`[estados-hs] listening on :${PORT}`); - console.log(`[estados-hs] HS_CRED_DOC_PATH=${CONFIG.HS_CRED_DOC_PATH}`); - console.log(`[estados-hs] REQUIRE_AUTH=${CONFIG.REQUIRE_AUTH ? '1' : '0'}`); -}); \ No newline at end of file +// 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')); \ No newline at end of file