diff --git a/index.js b/index.js index 0f19eac..aa01153 100644 --- a/index.js +++ b/index.js @@ -1,25 +1,62 @@ 'use strict'; +/** + * estados-hs-direct (HomeServe) + * - API: POST /api/homeserve/change-status + * - Health: GET /health + * - Test UI: GET /test + * + * Lee credenciales: + * 1) env HOMESERVE_USER/HOMESERVE_PASS (si existen) + * 2) Firestore doc HS_CRED_DOC_PATH (por defecto: providerCredentials/homeserve) con { user, pass, baseUrl? } + * + * Navegación: + * - Abre servicio: https://www.clientes.homeserve.es/cgi-bin/fccgi.exe?w3exec=ver_servicioencurso&Servicio=XXXX&Pag=1 + * - Click botón cambio estado: input[type="image"][name="repaso"] (o title contiene "Cambiar el Estado del Servicio") + * - En formulario: select estado (normalmente name="D1" o similar) -> selecciona por value (código) + * - Checkbox "ya informado al Cliente" (si informoCliente=true) + * - Guardar/Aceptar/Actualizar + */ + const express = require('express'); const cors = require('cors'); const { chromium } = require('playwright'); const admin = require('firebase-admin'); +// --------------------- Utils --------------------- 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; - } +function toBool(v) { + if (v === true || v === false) return v; + if (v == null) return false; + const s = String(v).trim().toLowerCase(); + return s === '1' || s === 'true' || s === 'yes' || s === 'y' || s === 'on'; +} +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +function safeStr(x) { + return (x == null) ? '' : String(x); +} + +function isoNow() { + return new Date().toISOString(); +} + +function pickPort() { + // CapRover normalmente usa process.env.PORT + const p = process.env.PORT || process.env.CAPROVER_PORT || '3000'; + const n = parseInt(p, 10); + return Number.isFinite(n) ? n : 3000; +} + +// --------------------- Firebase --------------------- +function initFirebase() { + if (!process.env.FIREBASE_PRIVATE_KEY) throw new Error('Missing env: FIREBASE_PRIVATE_KEY'); if (!admin.apps.length) { admin.initializeApp({ credential: admin.credential.cert({ @@ -32,144 +69,156 @@ function initFirebase() { return admin.firestore(); } +// --------------------- Config --------------------- 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', + REQUIRE_AUTH: toBool(process.env.REQUIRE_AUTH || '0'), + API_TOKEN: process.env.API_TOKEN || '', - HS_CRED_DOC_PATH: process.env.HS_CRED_DOC_PATH || 'secrets/homeserve', + HS_CRED_DOC_PATH: process.env.HS_CRED_DOC_PATH || 'providerCredentials/homeserve', - // auth para el endpoint (opcional) - REQUIRE_AUTH: String(process.env.REQUIRE_AUTH || '0') === '1', - AUTH_TOKEN: process.env.AUTH_TOKEN || '', + // Si NO viene baseUrl en Firestore ni env, usamos esta + HOMESERVE_BASE_URL: process.env.HOMESERVE_BASE_URL || 'https://www.clientes.homeserve.es/', - // Selectores: mantenemos compatibles con tu robot antiguo + // Selectores HomeServe (con fallback) 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")', + // Login (muy genérico, porque HomeServe cambia formularios) + loginUserCandidates: [ + 'input[name="user"]', + 'input[name="usuario"]', + 'input[name="username"]', + 'input[id*="user" i]', + 'input[id*="usu" i]', + 'input[type="text"]', + ], + loginPassCandidates: [ + 'input[name="pass"]', + 'input[name="password"]', + 'input[name="clave"]', + 'input[id*="pass" i]', + 'input[id*="clave" i]', + 'input[type="password"]', + ], + loginSubmitCandidates: [ + 'button[type="submit"]', + 'input[type="submit"]', + 'input[type="image"]', + 'button:has-text("Entrar")', + 'button:has-text("Acceder")', + 'input[value*="Entrar" i]', + 'input[value*="Acceder" i]', + ], - 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]', + // Servicio -> botón cambio estado (repaso) + changeStateBtnCandidates: [ + 'input[type="image"][name="repaso"]', + 'input[type="image"][title*="Cambiar el Estado del Servicio" i]', + 'input[type="image"][title*="Cambiar el Estado" i]', + 'input[name="repaso"]', + ], - 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]', + // Form cambio de estado + statusSelectCandidates: [ + 'select[name="D1"]', + 'select[id="D1"]', + 'select[name*="estado" i]', + 'select[id*="estado" i]', + 'select', + ], + dateInputCandidates: [ + 'input[name="D2"]', + 'input[id="D2"]', + 'input[name*="fecha" i]', + 'input[id*="fecha" i]', + 'input[type="text"]', + ], + obsCandidates: [ + 'textarea[name="D3"]', + 'textarea[id="D3"]', + 'textarea[name*="obs" i]', + 'textarea[id*="obs" i]', + 'textarea[name*="nota" i]', + 'textarea[id*="nota" i]', + 'textarea', + ], + saveBtnCandidates: [ + 'input[type="submit"]', + 'button[type="submit"]', + 'input[type="image"][name*="grabar" i]', + 'input[type="image"][title*="Guardar" i]', + 'button:has-text("Guardar")', + 'button:has-text("Aceptar")', + 'button:has-text("Actualizar")', + 'input[value*="Guardar" i]', + 'input[value*="Aceptar" i]', + 'input[value*="Actualizar" i]', + ], }, - // timeouts - GOTO_TIMEOUT: parseInt(process.env.GOTO_TIMEOUT || '120000', 10), - WAIT_TIMEOUT: parseInt(process.env.WAIT_TIMEOUT || '60000', 10), + // Estados permitidos (los de tu Swift) + STATUS_OPTIONS: [ + { code: '303', title: 'En espera de Cliente por aceptación Presupuesto' }, + { code: '307', title: 'En espera de Profesional por fecha de inicio de trabajos' }, + { code: '313', title: 'En espera de Profesional por secado de cala, pintura o parquet' }, + { code: '318', title: 'En espera de Profesional por confirmación del Siniestro' }, + { code: '319', title: 'En espera de Profesional por material' }, + { code: '320', title: 'En espera de Profesional por espera de otro gremio' }, + { code: '321', title: 'En espera de Profesional por presupuesto/valoración' }, + { code: '323', title: 'En espera de Profesional por mejora del tiempo' }, + { code: '326', title: 'En espera de Cliente por pago de Factura Contado/Franquicia' }, + { code: '336', title: 'En espera de Profesional por avería en observación' }, + { code: '342', title: 'En espera de Profesional pendiente cobro franquicia' }, + { code: '345', title: 'En espera de Profesional en realización pendiente Terminar' }, + { code: '348', title: 'En espera de Cliente por indicaciones' }, + { code: '352', title: 'En espera de Perjudicado por indicaciones' }, + ], }; -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; - +// --------------------- Creds --------------------- async function getHomeServeCreds(db) { - // 1) ENV manda + // 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, + user: String(envUser), + pass: String(envPass), + baseUrl: process.env.HOMESERVE_BASE_URL || CONFIG.HOMESERVE_BASE_URL, + source: 'env', }; } - // 2) Firestore - if (!db) { + // 2) Firestore doc + const ref = db.doc(CONFIG.HS_CRED_DOC_PATH); + const snap = await ref.get(); + const d = snap.exists ? (snap.data() || {}) : null; + + const user = d?.user || d?.username || d?.usuario; + const pass = d?.pass || d?.password || d?.clave; + const baseUrl = d?.baseUrl || d?.baseURL || d?.url || CONFIG.HOMESERVE_BASE_URL; + + if (!user || !pass) { throw new Error( - `HomeServe creds missing. Set env HOMESERVE_USER/HOMESERVE_PASS or provide Firebase envs + doc "${CONFIG.HS_CRED_DOC_PATH}"` + `HomeServe creds missing. Revisa Firestore doc "${CONFIG.HS_CRED_DOC_PATH}" con { user, pass, baseUrl? } o usa env HOMESERVE_USER/HOMESERVE_PASS` ); } - // 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; + return { user: String(user), pass: String(pass), baseUrl: String(baseUrl), source: CONFIG.HS_CRED_DOC_PATH }; } +// --------------------- Browser helpers --------------------- 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); + const context = await browser.newContext({ + viewport: { width: 1365, height: 768 }, + }); + + const page = await context.newPage(); + page.setDefaultTimeout(60000); try { return await fn(page); @@ -178,368 +227,378 @@ async function withBrowser(fn) { } } -async function fillFirst(page, selectors, value) { +async function findFirstHandle(page, selectors) { for (const sel of selectors) { - const el = await page.$(sel).catch(() => null); - if (el) { - await page.fill(sel, value); - return true; - } + try { + const h = await page.$(sel); + if (h) return { handle: h, selector: sel }; + } catch (_) {} } - return false; + return null; +} + +async function fillFirst(page, selectors, value) { + const found = await findFirstHandle(page, selectors); + if (!found) return false; + await found.handle.fill(String(value)); + return true; } 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; + const found = await findFirstHandle(page, selectors); + if (!found) return false; + await found.handle.click(); + return true; } -async function login(page, { baseUrl, user, pass }) { - await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: CONFIG.GOTO_TIMEOUT }); +function buildServiceUrl(baseUrl, serviceNumber) { + // baseUrl puede venir como dominio o con path; garantizamos el CGI correcto + const origin = new URL(baseUrl).origin; + const u = new URL(origin + '/cgi-bin/fccgi.exe'); + u.searchParams.set('w3exec', 'ver_servicioencurso'); + u.searchParams.set('Servicio', String(serviceNumber)); + u.searchParams.set('Pag', '1'); + return u.toString(); +} - // user/pass - const okUser = await fillFirst(page, [CONFIG.SEL.user], user); - const okPass = await fillFirst(page, [CONFIG.SEL.pass], pass); +async function maybeLogin(page, creds) { + // Intentamos abrir baseUrl. Si ya está logueado, perfecto. + const base = creds.baseUrl || CONFIG.HOMESERVE_BASE_URL; - if (!okUser || !okPass) { - throw new Error('Login form not found (user/pass selectors). Adjust SEL_USER / SEL_PASS.'); + await page.goto(base, { waitUntil: 'domcontentloaded', timeout: 120000 }); + await sleep(700); + + // Si vemos algo típico del portal ya logueado, salimos (heurístico) + const alreadyOk = await page.$('input[type="image"][name="repaso"], a[href*="ver_servicioencurso" i], form'); + if (!alreadyOk) { + // Igual es una landing rara, seguimos. } - // submit - const clicked = await clickFirst(page, [CONFIG.SEL.submit]); + // Si NO existe password input, probablemente no es login o ya está logueado + const passHandle = await findFirstHandle(page, CONFIG.SEL.loginPassCandidates); + if (!passHandle) return; + + // Usuario + const userOk = await fillFirst(page, CONFIG.SEL.loginUserCandidates, creds.user); + // Pass + const passOk = await fillFirst(page, CONFIG.SEL.loginPassCandidates, creds.pass); + + if (!userOk || !passOk) { + // Si no pudimos, que no reviente aquí: lo intentaremos al entrar al servicio + return; + } + + // Submit + const clicked = await clickFirst(page, CONFIG.SEL.loginSubmitCandidates); if (!clicked) { - // fallback Enter - await page.keyboard.press('Enter'); + // fallback: enter + await page.keyboard.press('Enter').catch(() => {}); } - // esperamos navegación - await page.waitForLoadState('networkidle', { timeout: CONFIG.GOTO_TIMEOUT }).catch(() => {}); - await sleep(800); + await page.waitForLoadState('domcontentloaded', { timeout: 120000 }).catch(() => {}); + await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {}); + await sleep(900); } -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'); +async function goToService(page, serviceNumber, creds) { + const url = buildServiceUrl(creds.baseUrl || CONFIG.HOMESERVE_BASE_URL, serviceNumber); + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 120000 }); + await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {}); + await sleep(700); - await page.waitForLoadState('networkidle', { timeout: CONFIG.GOTO_TIMEOUT }).catch(() => {}); - await sleep(1200); + // Si te manda a login, intentamos login y volvemos a ir + const hasPassword = await page.$('input[type="password"]'); + if (hasPassword) { + await maybeLogin(page, creds); + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 120000 }); + await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {}); + await sleep(700); } - // 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. + return url; } -async function trySelectStatus(page, statusValue) { - await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: CONFIG.WAIT_TIMEOUT }); +async function clickChangeState(page) { + const ok = await clickFirst(page, CONFIG.SEL.changeStateBtnCandidates); + if (!ok) { + // fallback por texto/atributos + const ok2 = await page.evaluate(() => { + const inputs = Array.from(document.querySelectorAll('input[type="image"], input[type="submit"], button')); + const hit = inputs.find((el) => { + const t = (el.getAttribute('title') || el.getAttribute('name') || el.getAttribute('value') || '').toLowerCase(); + return t.includes('cambiar') && t.includes('estado'); + }); + if (!hit) return false; + hit.click(); + return true; + }); + if (!ok2) throw new Error('No encuentro el botón de "Cambiar estado" (repaso).'); + } - // 1) por value (lo que tú usas: 303/307/...) + await page.waitForLoadState('domcontentloaded', { timeout: 120000 }).catch(() => {}); + await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {}); + await sleep(900); +} + +async function selectStatusCode(page, code) { + // Preferimos seleccionar por value (porque tus códigos son los valores) + const found = await findFirstHandle(page, CONFIG.SEL.statusSelectCandidates); + if (!found) throw new Error('No encuentro el desplegable de estado (select).'); + + const sel = found.selector; + + // Primero por value exacto try { - await page.selectOption(CONFIG.SEL.statusDropdown, { value: String(statusValue) }); - return true; - } catch (_) {} + await page.selectOption(sel, { value: String(code) }); + return; + } catch (_) { + // Luego intentamos por label que contenga el código + } - // 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 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 => (o.value || '').trim() === String(value).trim() - || (o.textContent || '').includes(String(value).trim())); + const hit = + opts.find(o => (o.getAttribute('value') || '').trim() === String(code).trim()) || + opts.find(o => (o.textContent || '').includes(String(code))); if (!hit) return false; s.value = hit.value; s.dispatchEvent(new Event('change', { bubbles: true })); return true; - }, { sel: CONFIG.SEL.statusDropdown, value: statusValue }); + }, { sel, code }); - return !!ok; + if (!ok) throw new Error(`No encuentro opción para código de estado "${code}".`); +} + +async function fillDate(page, dateString) { + if (!dateString) return; + + // En HomeServe suele ser input text DD/MM/AAAA + // Buscamos uno "razonable" y lo rellenamos. + const found = await findFirstHandle(page, CONFIG.SEL.dateInputCandidates); + if (!found) return; + + // Intento simple: fill + try { + await found.handle.fill(String(dateString)); + } catch (_) {} +} + +async function fillObservation(page, observation) { + if (!observation) return; + const found = await findFirstHandle(page, CONFIG.SEL.obsCandidates); + if (!found) return; + try { + await found.handle.fill(String(observation)); + } catch (_) {} } 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; - } + // 1) getByLabel (si existe) + try { + const locator = page.getByLabel(/Marque esta casilla.*informado al Cliente/i); + if (await locator.count()) { + await locator.first().check({ force: true }); + return; + } + } catch (_) {} - // 2) fallback por label con el texto + // 2) buscar checkbox por texto alrededor 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; + const checkboxes = Array.from(document.querySelectorAll('input[type="checkbox"]')); + const hit = checkboxes.find((cb) => { + const rowText = + (cb.closest('tr')?.innerText || cb.parentElement?.innerText || '').toLowerCase(); + return rowText.includes('informado') && rowText.includes('cliente'); + }); + if (!hit) return false; + hit.checked = true; + hit.dispatchEvent(new Event('change', { bubbles: true })); + hit.dispatchEvent(new Event('click', { bubbles: true })); + return true; }); 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.'); + // No lo hacemos fatal: mejor seguir que romper el cambio por una casilla + console.warn('[estados-hs] ⚠️ No encontré la casilla "ya informado al Cliente".'); } } -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); + const ok = await clickFirst(page, CONFIG.SEL.saveBtnCandidates); + if (!ok) { + const ok2 = await page.evaluate(() => { + const els = Array.from(document.querySelectorAll('button,input')); + const hit = els.find((el) => { + const t = (el.getAttribute('value') || el.textContent || el.getAttribute('title') || '').toLowerCase(); + return t.includes('guardar') || t.includes('aceptar') || t.includes('actualizar') || t.includes('grabar'); + }); + if (!hit) return false; + hit.click(); + return true; + }); + if (!ok2) throw new Error('No encuentro el botón de Guardar/Aceptar/Actualizar del cambio de estado.'); + } + + await page.waitForLoadState('domcontentloaded', { timeout: 120000 }).catch(() => {}); + await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {}); + await sleep(1200); } -async function changeStatusViaHomeServe({ baseUrl, user, pass }, payload) { - const { +// --------------------- Main action --------------------- +async function changeStatusViaClientesPortal(db, payload) { + const creds = await getHomeServeCreds(db); + + const serviceNumber = String(payload.serviceNumber || '').trim(); + const newStatusValue = String(payload.newStatusValue || '').trim(); + const dateString = String(payload.dateString || '').trim(); + const observation = String(payload.observation || '').trim(); + const informoCliente = toBool(payload.informoCliente); + + const startedAtISO = isoNow(); + + const result = await withBrowser(async (page) => { + // Login “suave” + await maybeLogin(page, creds); + + // Ir al servicio (si no hay sesión, reintenta login) + const serviceUrl = await goToService(page, serviceNumber, creds); + + // Click botón repaso (cambio estado) + await clickChangeState(page); + + // Set campos + await selectStatusCode(page, newStatusValue); + await fillDate(page, dateString); + await fillObservation(page, observation); + await setInformoCliente(page, informoCliente); + + // Guardar + await clickSave(page); + + return { serviceUrl }; + }); + + return { + ok: true, 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 }; - }); + startedAtISO, + finishedAtISO: isoNow(), + ...result, + credsSource: creds.source, + baseUrl: creds.baseUrl, + }; } -// ------------------------- -// Routes -// ------------------------- +// --------------------- Express app --------------------- +function requireAuthIfNeeded(req, res) { + if (!CONFIG.REQUIRE_AUTH) return true; -const db = initFirebase(); + const auth = (req.headers.authorization || '').trim(); + const x = (req.headers['x-auth'] || '').toString().trim(); + const token = CONFIG.API_TOKEN.trim(); -app.get('/', (req, res) => { - res.status(200).send('ok'); -}); + const got = + (auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '') || + x; -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 || ''), - }, - }); + if (!token) { + // Si REQUIRE_AUTH=1 pero no hay token configurado, mejor bloquear. + res.status(500).json({ ok: false, error: { message: 'REQUIRE_AUTH=1 pero falta API_TOKEN en env.' } }); + return false; } -}); -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}`); -}); + if (got !== token) { + res.status(401).json({ ok: false, error: { message: 'Unauthorized' } }); + return false; + } -// ------------------------- -// Embedded TEST HTML -// ------------------------- + return true; +} -const TEST_HTML = ` +function makeTestHtml(defaultBase) { + const options = CONFIG.STATUS_OPTIONS.map((o) => + `` + ).join('\n'); + + return `
-/ y JSON en /health, el servicio está vivo. Luego ya nos peleamos con HomeServe 😅/api/homeserve/change-status