'use strict'; const express = require('express'); const cors = require('cors'); const { chromium } = require('playwright'); const admin = require('firebase-admin'); function mustEnv(name) { const v = process.env[name]; if (!v) throw new Error(`Missing env: ${name}`); return v; } function initFirebase() { // Si NO quieres Firebase, puedes arrancar sin admin. // Pero aquí lo usamos para leer secrets/homeserve. if (!process.env.FIREBASE_PRIVATE_KEY) { // Permitimos arrancar sin Firebase si pones creds por ENV // (pero si tampoco hay creds, fallará al ejecutar). return null; } if (!admin.apps.length) { 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'), }), }); } return admin.firestore(); } const CONFIG = { // 🔥 IMPORTANTE: por defecto entramos por “clientes CGI” (como tu HTML), // y si quieres lo cambias por ENV o por Firestore (secrets/homeserve.baseUrl). DEFAULT_HS_BASE_URL: process.env.HOMESERVE_BASE_URL || 'https://www.clientes.homeserve.es/cgi-bin/fccgi.exe?w3exec=prof_pass&urgente', HS_CRED_DOC_PATH: process.env.HS_CRED_DOC_PATH || 'secrets/homeserve', // auth para el endpoint (opcional) REQUIRE_AUTH: String(process.env.REQUIRE_AUTH || '0') === '1', AUTH_TOKEN: process.env.AUTH_TOKEN || '', // Selectores: mantenemos compatibles con tu robot antiguo SEL: { user: process.env.SEL_USER || 'input[type="text"], input[name*="user" i], input[name*="usuario" i]', pass: process.env.SEL_PASS || 'input[type="password"], input[name*="pass" i], input[name*="clave" i]', submit: process.env.SEL_SUBMIT || 'button[type="submit"], input[type="submit"], button:has-text("Entrar"), button:has-text("Acceder")', searchBox: process.env.SEL_SEARCH_BOX || 'input[placeholder*="Buscar"], input[type="search"], input[name*="buscar" i], input[id*="buscar" i]', searchBtn: process.env.SEL_SEARCH_BTN || 'button:has-text("Buscar"), button:has-text("Search"), input[type="submit"][value*="Buscar" i]', openRow: process.env.SEL_OPEN_ROW || 'table tbody tr:first-child', // Estado / observaciones / fecha / checkbox informado statusDropdown: process.env.SEL_STATUS_DROPDOWN || 'select[name*="estado" i], select[id*="estado" i], select[name="ESTADO"], select:has(option)', noteTextarea: process.env.SEL_NOTE_TEXTAREA || 'textarea[name*="nota" i], textarea[name*="observa" i], textarea[id*="nota" i], textarea[id*="observa" i], textarea', dateInput: process.env.SEL_DATE_INPUT || 'input[name*="fecha" i], input[id*="fecha" i], input[placeholder*="dd" i], input[type="date"]', informedCheckbox: process.env.SEL_INFORMED_CHECKBOX || 'input[type="checkbox"][name="INFORMO"], input[type="checkbox"][id*="inform" i], input[type="checkbox"][name*="inform" i]', saveBtn: process.env.SEL_SAVE_BTN || 'button:has-text("Guardar"), button:has-text("Actualizar"), button:has-text("Aceptar"), input[type="submit"][value*="Guardar" i], input[type="submit"][value*="Actualizar" i]', }, // timeouts GOTO_TIMEOUT: parseInt(process.env.GOTO_TIMEOUT || '120000', 10), WAIT_TIMEOUT: parseInt(process.env.WAIT_TIMEOUT || '60000', 10), }; const app = express(); app.use(cors({ origin: true })); app.use(express.json({ limit: '1mb' })); // ------------------------- // Helpers // ------------------------- const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function pickPort() { // CapRover suele inyectar CAPROVER_PORT // Si no existe, usamos PORT o 3000. return parseInt(process.env.CAPROVER_PORT || process.env.PORT || '3000', 10); } function ensureAuth(req) { if (!CONFIG.REQUIRE_AUTH) return; const token = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim(); if (!token || !CONFIG.AUTH_TOKEN || token !== CONFIG.AUTH_TOKEN) { const err = new Error('Unauthorized'); err.statusCode = 401; throw err; } } let cachedCreds = null; let cachedCredsAt = 0; async function getHomeServeCreds(db) { // 1) ENV manda const envUser = process.env.HOMESERVE_USER; const envPass = process.env.HOMESERVE_PASS; const envBase = process.env.HOMESERVE_BASE_URL; if (envUser && envPass) { return { user: envUser, pass: envPass, baseUrl: envBase || CONFIG.DEFAULT_HS_BASE_URL, }; } // 2) Firestore if (!db) { throw new Error( `HomeServe creds missing. Set env HOMESERVE_USER/HOMESERVE_PASS or provide Firebase envs + doc "${CONFIG.HS_CRED_DOC_PATH}"` ); } // cache 30s const now = Date.now(); if (cachedCreds && now - cachedCredsAt < 30000) return cachedCreds; const snap = await db.doc(CONFIG.HS_CRED_DOC_PATH).get(); if (!snap.exists) { throw new Error(`HomeServe creds missing. Create Firestore doc "${CONFIG.HS_CRED_DOC_PATH}" with { user, pass, baseUrl? }`); } const d = snap.data() || {}; const user = d.user || d.email || d.usuario || ''; const pass = d.pass || d.password || d.clave || ''; if (!user || !pass) { throw new Error(`HomeServe creds missing in Firestore doc "${CONFIG.HS_CRED_DOC_PATH}". Needs fields "user" and "pass".`); } const baseUrl = d.baseUrl || d.HOMESERVE_BASE_URL || CONFIG.DEFAULT_HS_BASE_URL; cachedCreds = { user, pass, baseUrl }; cachedCredsAt = now; return cachedCreds; } 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(); // Un poco de margen page.setDefaultTimeout(CONFIG.WAIT_TIMEOUT); try { return await fn(page); } finally { await browser.close().catch(() => {}); } } async function fillFirst(page, selectors, value) { for (const sel of selectors) { const el = await page.$(sel).catch(() => null); if (el) { await page.fill(sel, value); return true; } } return false; } async function clickFirst(page, selectors) { for (const sel of selectors) { const el = await page.$(sel).catch(() => null); if (el) { await el.click(); return true; } } return false; } async function login(page, { baseUrl, user, pass }) { await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: CONFIG.GOTO_TIMEOUT }); // user/pass const okUser = await fillFirst(page, [CONFIG.SEL.user], user); const okPass = await fillFirst(page, [CONFIG.SEL.pass], pass); if (!okUser || !okPass) { throw new Error('Login form not found (user/pass selectors). Adjust SEL_USER / SEL_PASS.'); } // submit const clicked = await clickFirst(page, [CONFIG.SEL.submit]); if (!clicked) { // fallback Enter await page.keyboard.press('Enter'); } // esperamos navegación await page.waitForLoadState('networkidle', { timeout: CONFIG.GOTO_TIMEOUT }).catch(() => {}); await sleep(800); } async function openParte(page, serviceNumber) { // búsqueda const hasSearch = await page.$(CONFIG.SEL.searchBox).catch(() => null); if (hasSearch) { await page.fill(CONFIG.SEL.searchBox, String(serviceNumber)); const btn = await page.$(CONFIG.SEL.searchBtn).catch(() => null); if (btn) await btn.click(); else await page.keyboard.press('Enter'); await page.waitForLoadState('networkidle', { timeout: CONFIG.GOTO_TIMEOUT }).catch(() => {}); await sleep(1200); } // abre primera fila const row = await page.$(CONFIG.SEL.openRow).catch(() => null); if (row) { await row.click(); await page.waitForLoadState('networkidle', { timeout: CONFIG.GOTO_TIMEOUT }).catch(() => {}); await sleep(900); } // Si tu portal entra directo al parte sin tabla, esto simplemente no hace nada. } async function trySelectStatus(page, statusValue) { await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: CONFIG.WAIT_TIMEOUT }); // 1) por value (lo que tú usas: 303/307/...) try { await page.selectOption(CONFIG.SEL.statusDropdown, { value: String(statusValue) }); return true; } catch (_) {} // 2) por label exacta (por si el portal usa texto) try { await page.selectOption(CONFIG.SEL.statusDropdown, { label: String(statusValue) }); return true; } catch (_) {} // 3) fallback DOM (busca option que contenga el código) const ok = await page.evaluate(({ sel, value }) => { const s = document.querySelector(sel); if (!s) return false; const opts = Array.from(s.querySelectorAll('option')); const hit = opts.find(o => (o.value || '').trim() === String(value).trim() || (o.textContent || '').includes(String(value).trim())); if (!hit) return false; s.value = hit.value; s.dispatchEvent(new Event('change', { bubbles: true })); return true; }, { sel: CONFIG.SEL.statusDropdown, value: statusValue }); return !!ok; } async function setInformoCliente(page, informoCliente) { if (!informoCliente) return; // 1) selector directo (INFORMO) const cb = await page.$(CONFIG.SEL.informedCheckbox).catch(() => null); if (cb) { const checked = await cb.isChecked().catch(() => false); if (!checked) await cb.check().catch(() => {}); return; } // 2) fallback por label con el texto const ok = await page.evaluate(() => { const labels = Array.from(document.querySelectorAll('label')); const target = labels.find(l => (l.textContent || '').toLowerCase().includes('marque esta casilla') && (l.textContent || '').toLowerCase().includes('informado')); if (!target) return false; const inputId = target.getAttribute('for'); if (inputId) { const el = document.getElementById(inputId); if (el && el.type === 'checkbox') { el.checked = true; el.dispatchEvent(new Event('change', { bubbles: true })); return true; } } const cb = target.querySelector('input[type="checkbox"]'); if (cb) { cb.checked = true; cb.dispatchEvent(new Event('change', { bubbles: true })); return true; } return false; }); if (!ok) { // no lo hacemos fatal: solo avisamos si quieres forzarlo // throw new Error('Could not find "Informado al cliente" checkbox. Set SEL_INFORMED_CHECKBOX.'); } } async function fillOptional(page, selector, value) { if (!value) return false; const el = await page.$(selector).catch(() => null); if (!el) return false; await page.fill(selector, String(value)); return true; } async function clickSave(page) { const save = await page.$(CONFIG.SEL.saveBtn).catch(() => null); if (!save) throw new Error('Save button not found. Adjust SEL_SAVE_BTN.'); await save.click(); await page.waitForLoadState('networkidle', { timeout: CONFIG.GOTO_TIMEOUT }).catch(() => {}); await sleep(900); } async function changeStatusViaHomeServe({ baseUrl, user, pass }, payload) { const { serviceNumber, newStatusValue, dateString, observation, informoCliente, } = payload; return await withBrowser(async (page) => { await login(page, { baseUrl, user, pass }); // abre parte si hace falta await openParte(page, serviceNumber); // cambia estado const selected = await trySelectStatus(page, newStatusValue); if (!selected) { throw new Error(`No matching status option for "${newStatusValue}". Adjust status selector or confirm option values.`); } // fecha + observación (si el portal lo tiene) if (dateString) { await fillOptional(page, CONFIG.SEL.dateInput, dateString); } if (observation) { await fillOptional(page, CONFIG.SEL.noteTextarea, observation); } // ✅ casilla "ya informado al Cliente" await setInformoCliente(page, !!informoCliente); // guardar await clickSave(page); return { ok: true }; }); } // ------------------------- // Routes // ------------------------- const db = initFirebase(); app.get('/', (req, res) => { res.status(200).send('ok'); }); app.get('/health', async (req, res) => { res.json({ ok: true, service: 'estados-hs', port: pickPort(), requireAuth: CONFIG.REQUIRE_AUTH, hsCredDocPath: CONFIG.HS_CRED_DOC_PATH, defaultBaseUrl: CONFIG.DEFAULT_HS_BASE_URL, ts: new Date().toISOString(), }); }); // HTML de prueba (corregido + checkbox informoCliente) app.get('/test', (req, res) => { res.type('html').send(TEST_HTML); }); app.post('/api/homeserve/change-status', async (req, res) => { const startedAtISO = new Date().toISOString(); try { ensureAuth(req); const body = req.body || {}; const serviceNumber = String(body.serviceNumber || '').trim(); const newStatusValue = String(body.newStatusValue || '').trim(); // dateString opcional (DD/MM/AAAA) const dateString = String(body.dateString || '').trim(); const observation = String(body.observation || '').trim(); const informoCliente = !!body.informoCliente; if (!serviceNumber || !newStatusValue) { return res.status(400).json({ ok: false, error: { message: 'Missing serviceNumber or newStatusValue' }, }); } const creds = await getHomeServeCreds(db); const result = await changeStatusViaHomeServe(creds, { serviceNumber, newStatusValue, dateString: dateString || '', observation: observation || '', informoCliente, }); return res.json({ ok: true, startedAtISO, finishedAtISO: new Date().toISOString(), request: { serviceNumber, newStatusValue, dateString: dateString || '', observation: observation || '', informoCliente, }, result, }); } catch (err) { const code = err?.statusCode || 500; return res.status(code).json({ ok: false, startedAtISO, finishedAtISO: new Date().toISOString(), error: { message: String(err?.message || err), stack: String(err?.stack || ''), }, }); } }); const port = pickPort(); app.listen(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'}`); console.log(`[estados-hs] ENV PORT=${process.env.PORT || '(unset)'} CAPROVER_PORT=${process.env.CAPROVER_PORT || '(unset)'}`); console.log(`[estados-hs] listening on :${port}`); }); // ------------------------- // Embedded TEST HTML // ------------------------- const TEST_HTML = `
/api/homeserve/change-status