diff --git a/index.js b/index.js index aa01153..d624164 100644 --- a/index.js +++ b/index.js @@ -1,62 +1,46 @@ '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 --------------------- +const app = express(); +app.use(express.json({ limit: '1mb' })); +app.use(cors()); + +/** ========= Helpers ========= */ + +function boolEnv(name, def = false) { + const v = process.env[name]; + if (v === undefined) return def; + return ['1', 'true', 'yes', 'on'].includes(String(v).toLowerCase()); +} + function mustEnv(name) { const v = process.env[name]; if (!v) throw new Error(`Missing env: ${name}`); return v; } -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'; +function safeTrim(x) { + return String(x ?? '').trim(); } -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - -function safeStr(x) { - return (x == null) ? '' : String(x); -} - -function isoNow() { +function nowISO() { 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; +function sleep(ms) { + return new Promise(r => setTimeout(r, ms)); } -// --------------------- Firebase --------------------- +/** ========= Firebase / Firestore ========= */ + function initFirebase() { + // Admite admin por variables (como tus otros robots) if (!process.env.FIREBASE_PRIVATE_KEY) throw new Error('Missing env: FIREBASE_PRIVATE_KEY'); + if (!admin.apps.length) { admin.initializeApp({ credential: admin.credential.cert({ @@ -69,154 +53,186 @@ function initFirebase() { return admin.firestore(); } -// --------------------- Config --------------------- +function docRefFromPath(db, docPath) { + // docPath tipo "secrets/homeserve" + const parts = String(docPath).split('/').filter(Boolean); + if (parts.length < 2 || parts.length % 2 !== 0) { + throw new Error(`Invalid HS_CRED_DOC_PATH "${docPath}". Expected "collection/doc" or "a/b/c/d".`); + } + let ref = db.collection(parts[0]).doc(parts[1]); + for (let i = 2; i < parts.length; i += 2) { + ref = ref.collection(parts[i]).doc(parts[i + 1]); + } + return ref; +} + +async function getHomeServeCreds(db) { + // Puedes definirlo por env o por Firestore doc. + const envUser = process.env.HOMESERVE_USER || process.env.HS_USER; + const envPass = process.env.HOMESERVE_PASS || process.env.HS_PASS; + const envBaseUrl = process.env.HOMESERVE_BASE_URL || process.env.HS_BASE_URL; + + if (envUser && envPass) { + return { + user: String(envUser), + pass: String(envPass), + baseUrl: String(envBaseUrl || ''), + source: 'env' + }; + } + + const docPath = process.env.HS_CRED_DOC_PATH || 'secrets/homeserve'; + const ref = docRefFromPath(db, docPath); + const snap = await ref.get(); + + if (!snap.exists) { + throw new Error(`HomeServe creds missing. Create Firestore doc "${docPath}" with { user, pass, baseUrl? }`); + } + + const d = snap.data() || {}; + + // Acepta varios nombres de campo (por tu captura) + const user = + d.user ?? d.username ?? d.email ?? d.usuario ?? d.HOMESERVE_USER ?? d.HS_USER ?? d.USER ?? d.USERNAME; + + const pass = + d.pass ?? d.password ?? d.clave ?? d.HOMESERVE_PASS ?? d.HS_PASS ?? d.PASS ?? d.PASSWORD; + + const baseUrl = + d.baseUrl ?? d.HOMESERVE_BASE_URL ?? d.HS_BASE_URL ?? d.url ?? d.URL ?? ''; + + if (!user || !pass) { + throw new Error( + `HomeServe creds missing. Firestore doc "${docPath}" exists but fields not found. ` + + `Use { user, pass } or { HOMESERVE_USER, HOMESERVE_PASS } or { username, password }.` + ); + } + + return { + user: String(user), + pass: String(pass), + baseUrl: String(baseUrl || ''), + source: `firestore:${docPath}` + }; +} + +/** ========= Config ========= */ + const CONFIG = { - REQUIRE_AUTH: toBool(process.env.REQUIRE_AUTH || '0'), - API_TOKEN: process.env.API_TOKEN || '', + REQUIRE_AUTH: boolEnv('REQUIRE_AUTH', false), + API_KEY: process.env.API_KEY || '', - HS_CRED_DOC_PATH: process.env.HS_CRED_DOC_PATH || 'providerCredentials/homeserve', + // Portal clientes (más estable si gestor.homeserve.es no resuelve en tu contenedor) + // Puedes meter en Firestore doc "baseUrl" o por env HOMESERVE_BASE_URL + DEFAULT_CLIENTES_BASE: 'https://www.clientes.homeserve.es/cgi-bin/fccgi.exe', - // Si NO viene baseUrl en Firestore ni env, usamos esta - HOMESERVE_BASE_URL: process.env.HOMESERVE_BASE_URL || 'https://www.clientes.homeserve.es/', - - // Selectores HomeServe (con fallback) + // Selectores "genéricos" SEL: { - // 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: [ + loginUser: [ + 'input[name*="user" i]', + 'input[name*="usuario" i]', + 'input[name*="login" i]', + 'input[type="text"]' + ].join(', '), + loginPass: 'input[type="password"]', + loginSubmit: [ '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]', - ], + 'input[value*="Acceder" i]' + ].join(', '), - // Servicio -> botón cambio estado (repaso) - changeStateBtnCandidates: [ + // Botón "repaso" (cambio de estado) según lo que me pasaste + repasoBtn: [ '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"]', - ], + 'input[type="image"][name="repaso" i]', + 'input[type="image"][title*="Cambiar" i][title*="Estado" i]', + 'input[type="image"][src*="estado1.gif" i]', + 'input[type="image"][src*="Imagenes/estado" i]', + 'a:has(img[src*="estado1.gif" i])' + ].join(', '), - // Form cambio de estado - statusSelectCandidates: [ - 'select[name="D1"]', - 'select[id="D1"]', + // Form cambio estado + estadoSelect: [ 'select[name*="estado" i]', 'select[id*="estado" i]', - 'select', - ], - dateInputCandidates: [ - 'input[name="D2"]', - 'input[id="D2"]', + 'select[name*="Estado" i]', + 'select:has(option)' + ].join(', '), + + fechaInput: [ 'input[name*="fecha" i]', 'input[id*="fecha" i]', - 'input[type="text"]', - ], - obsCandidates: [ - 'textarea[name="D3"]', - 'textarea[id="D3"]', + 'input[placeholder*="dd/mm" i]', + 'input[type="date"]' + ].join(', '), + + obsTextarea: [ 'textarea[name*="obs" i]', - 'textarea[id*="obs" i]', + 'textarea[name*="observ" i]', 'textarea[name*="nota" i]', - 'textarea[id*="nota" i]', - 'textarea', - ], - saveBtnCandidates: [ - 'input[type="submit"]', + 'textarea[id*="obs" i]', + 'textarea' + ].join(', '), + + submitCambio: [ 'button[type="submit"]', - 'input[type="image"][name*="grabar" i]', - 'input[type="image"][title*="Guardar" i]', + 'input[type="submit"]', 'button:has-text("Guardar")', 'button:has-text("Aceptar")', - 'button:has-text("Actualizar")', 'input[value*="Guardar" i]', 'input[value*="Aceptar" i]', - 'input[value*="Actualizar" i]', - ], - }, - - // 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' }, - ], + 'input[value*="Actualizar" i]' + ].join(', '), + } }; -// --------------------- Creds --------------------- -async function getHomeServeCreds(db) { - // 1) env manda - const envUser = process.env.HOMESERVE_USER; - const envPass = process.env.HOMESERVE_PASS; +// Estados EXACTOS como los que usas en app / HTML +const 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' }, +]; - if (envUser && envPass) { - return { - user: String(envUser), - pass: String(envPass), - baseUrl: process.env.HOMESERVE_BASE_URL || CONFIG.HOMESERVE_BASE_URL, - source: 'env', - }; +/** ========= Auth (opcional) ========= */ + +function authMiddleware(req, res, next) { + if (!CONFIG.REQUIRE_AUTH) return next(); + + // modo simple por API_KEY (si lo quieres) + if (CONFIG.API_KEY) { + const got = req.headers['x-api-key'] || (req.headers.authorization || '').replace(/^Bearer\s+/i, ''); + if (got && String(got) === String(CONFIG.API_KEY)) return next(); + return res.status(401).json({ ok: false, error: { message: 'Unauthorized (API key).' } }); } - // 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. Revisa Firestore doc "${CONFIG.HS_CRED_DOC_PATH}" con { user, pass, baseUrl? } o usa env HOMESERVE_USER/HOMESERVE_PASS` - ); - } - - return { user: String(user), pass: String(pass), baseUrl: String(baseUrl), source: CONFIG.HS_CRED_DOC_PATH }; + // Si REQUIRE_AUTH=1 y no pones API_KEY, lo dejamos pasar (para no bloquearte). + return next(); } -// --------------------- Browser helpers --------------------- +/** ========= Playwright ========= */ + async function withBrowser(fn) { const browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'], }); - const context = await browser.newContext({ - viewport: { width: 1365, height: 768 }, - }); - + const context = await browser.newContext(); const page = await context.newPage(); page.setDefaultTimeout(60000); @@ -227,521 +243,305 @@ async function withBrowser(fn) { } } -async function findFirstHandle(page, selectors) { - for (const sel of selectors) { - try { - const h = await page.$(sel); - if (h) return { handle: h, selector: sel }; - } catch (_) {} - } - 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) { - const found = await findFirstHandle(page, selectors); - if (!found) return false; - await found.handle.click(); - return true; -} - 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(); + const b = safeTrim(baseUrl); + if (!b) return `${CONFIG.DEFAULT_CLIENTES_BASE}?w3exec=ver_servicioencurso&Servicio=${encodeURIComponent(serviceNumber)}&Pag=1`; + + // Si te pasan ya una URL completa con Servicio=..., la respetamos + if (b.includes('Servicio=')) return b; + + // Si te pasan el fccgi.exe, montamos la query + if (b.includes('fccgi.exe')) { + const sep = b.includes('?') ? '&' : '?'; + return `${b}${sep}w3exec=ver_servicioencurso&Servicio=${encodeURIComponent(serviceNumber)}&Pag=1`; + } + + // Por defecto, intentamos que sea base y añadimos path típico + const sep = b.endsWith('/') ? '' : '/'; + return `${b}${sep}cgi-bin/fccgi.exe?w3exec=ver_servicioencurso&Servicio=${encodeURIComponent(serviceNumber)}&Pag=1`; } async function maybeLogin(page, creds) { - // Intentamos abrir baseUrl. Si ya está logueado, perfecto. - const base = creds.baseUrl || CONFIG.HOMESERVE_BASE_URL; + // Si aparece password, asumimos login. + const pass = page.locator(CONFIG.SEL.loginPass); + if (!(await pass.count())) return; - await page.goto(base, { waitUntil: 'domcontentloaded', timeout: 120000 }); - await sleep(700); + const user = page.locator(CONFIG.SEL.loginUser).first(); - // 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. + // Rellenar + await user.fill(String(creds.user)); + await pass.first().fill(String(creds.pass)); + + const submit = page.locator(CONFIG.SEL.loginSubmit).first(); + if (await submit.count()) { + await submit.click({ timeout: 60000 }).catch(async () => { + await page.keyboard.press('Enter'); + }); + } else { + await page.keyboard.press('Enter'); } - // 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').catch(() => {}); - } - - await page.waitForLoadState('domcontentloaded', { timeout: 120000 }).catch(() => {}); + await page.waitForLoadState('domcontentloaded', { timeout: 120000 }); await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {}); - await sleep(900); } -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); +async function gotoService(page, serviceUrl, creds) { + await page.goto(serviceUrl, { waitUntil: 'domcontentloaded', timeout: 120000 }); - // 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); + // Si redirige a login, intentamos loguear y volver al servicio + await maybeLogin(page, creds); + + // A veces tras login te manda a home, así que volvemos al servicio + if (!page.url().includes('Servicio=') || page.url().includes('w3exec=login')) { + await page.goto(serviceUrl, { waitUntil: 'domcontentloaded', timeout: 120000 }); } - return url; + await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {}); } 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).'); - } - - 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(sel, { value: String(code) }); - return; - } catch (_) { - // Luego intentamos por label que contenga el código - } - - 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.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, code }); - - 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) 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; + // Botón repaso (input type=image) o link con imagen + const btn = page.locator(CONFIG.SEL.repasoBtn).first(); + if (!(await btn.count())) { + // Debug útil: por si el portal cambia, te dejo un fallback mirando el HTML + const html = await page.content().catch(() => ''); + if (html && html.toLowerCase().includes('repaso')) { + // existe texto pero no casó selector => portal raro, pero seguimos informando } - } catch (_) {} + throw new Error('No encuentro el botón de "Cambiar estado" (repaso).'); + } - // 2) buscar checkbox por texto alrededor + await Promise.allSettled([ + page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 120000 }), + btn.click({ timeout: 60000, force: true }), + ]); + + await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {}); +} + +function ddmmyyyyToYyyyMmDd(ddmmyyyy) { + const s = safeTrim(ddmmyyyy); + const m = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/); + if (!m) return ''; + return `${m[3]}-${m[2]}-${m[1]}`; +} + +async function setCheckboxInformadoCliente(page, enable) { + if (!enable) return; + + // Busca checkbox cuyo texto cercano contenga "informado al Cliente" const ok = await page.evaluate(() => { - 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; + const needles = ['informado al cliente', 'ha informado al cliente', 'informado al Cliente'.toLowerCase()]; + const cbs = Array.from(document.querySelectorAll('input[type="checkbox"]')); + for (const cb of cbs) { + const parent = cb.closest('label') || cb.parentElement || cb.closest('td') || cb.closest('tr') || cb.closest('div'); + const txt = (parent ? parent.textContent : cb.getAttribute('title') || '').toLowerCase(); + if (needles.some(n => txt.includes(n))) { + cb.checked = true; + cb.dispatchEvent(new Event('change', { bubbles: true })); + cb.dispatchEvent(new Event('click', { bubbles: true })); + return true; + } + } + // Fallback: si solo hay 1 checkbox en la página, la marcamos + if (cbs.length === 1) { + cbs[0].checked = true; + cbs[0].dispatchEvent(new Event('change', { bubbles: true })); + cbs[0].dispatchEvent(new Event('click', { bubbles: true })); + return true; + } + return false; }); if (!ok) { - // 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".'); + // No lo hacemos fatal: si no existe esa casilla en ese servicio, que no rompa el cambio + console.warn('[estados-hs] Aviso: no encontré la casilla de "informado al Cliente". Continúo igualmente.'); } } -async function clickSave(page) { - 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'); - }); +async function setEstadoForm(page, payload) { + const { newStatusValue, dateString, observation, informoCliente } = payload; + + // Select estado + const sel = page.locator(CONFIG.SEL.estadoSelect).first(); + if (!(await sel.count())) throw new Error('No encuentro el selector de "estado".'); + + // 1) intentar por value (códigos 303/307/...) + let selected = false; + try { + await sel.selectOption({ value: String(newStatusValue) }); + selected = true; + } catch (_) {} + + // 2) si falla, intentar por label que contenga el código + if (!selected) { + const opt = STATUS_OPTIONS.find(o => o.code === String(newStatusValue)); + const labelTry = opt ? `${opt.code}` : String(newStatusValue); + + try { + await sel.selectOption({ label: labelTry }); + selected = true; + } catch (_) {} + } + + // 3) fallback DOM + if (!selected) { + const ok = await page.evaluate(({ code }) => { + const s = document.querySelector('select[name*="estado" i], select[id*="estado" i], select'); + if (!s) return false; + const opts = Array.from(s.querySelectorAll('option')); + const hit = opts.find(o => (o.value || '').trim() === String(code).trim()) || + opts.find(o => (o.textContent || '').includes(String(code))); if (!hit) return false; - hit.click(); + s.value = hit.value; + s.dispatchEvent(new Event('change', { bubbles: true })); return true; - }); - if (!ok2) throw new Error('No encuentro el botón de Guardar/Aceptar/Actualizar del cambio de estado.'); + }, { code: String(newStatusValue) }); + + if (!ok) throw new Error(`No matching status option for "${newStatusValue}"`); } - await page.waitForLoadState('domcontentloaded', { timeout: 120000 }).catch(() => {}); + // Fecha (opcional) + if (safeTrim(dateString)) { + const dateInput = page.locator(CONFIG.SEL.fechaInput).first(); + if (await dateInput.count()) { + // si es type=date, preferimos yyyy-mm-dd + const type = await dateInput.getAttribute('type').catch(() => ''); + if (String(type).toLowerCase() === 'date') { + const ymd = ddmmyyyyToYyyyMmDd(dateString); + await dateInput.fill(ymd || ''); + } else { + await dateInput.fill(String(dateString)); + } + } + } + + // Observación (opcional) + if (safeTrim(observation)) { + const ta = page.locator(CONFIG.SEL.obsTextarea).first(); + if (await ta.count()) { + await ta.fill(String(observation)); + } + } + + // Checkbox informado al cliente + await setCheckboxInformadoCliente(page, !!informoCliente); + + // Guardar / enviar + const submit = page.locator(CONFIG.SEL.submitCambio).first(); + if (!(await submit.count())) throw new Error('No encuentro el botón para guardar el cambio de estado.'); + + await Promise.allSettled([ + page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 120000 }), + submit.click({ timeout: 60000, force: true }), + ]); + await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {}); - await sleep(1200); } -// --------------------- Main action --------------------- +/** ========= Core: change status ========= */ + 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 baseUrl = safeTrim(creds.baseUrl) || process.env.HOMESERVE_BASE_URL || CONFIG.DEFAULT_CLIENTES_BASE; + const serviceUrl = buildServiceUrl(baseUrl, payload.serviceNumber); - const startedAtISO = isoNow(); + return await withBrowser(async (page) => { + await gotoService(page, serviceUrl, creds); - 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) + // entrar a pantalla de cambio de estado (repaso) await clickChangeState(page); - // Set campos - await selectStatusCode(page, newStatusValue); - await fillDate(page, dateString); - await fillObservation(page, observation); - await setInformoCliente(page, informoCliente); + // rellenar form de cambio de estado + await setEstadoForm(page, payload); - // Guardar - await clickSave(page); - - return { serviceUrl }; - }); - - return { - ok: true, - serviceNumber, - newStatusValue, - dateString, - observation, - informoCliente, - startedAtISO, - finishedAtISO: isoNow(), - ...result, - credsSource: creds.source, - baseUrl: creds.baseUrl, - }; -} - -// --------------------- Express app --------------------- -function requireAuthIfNeeded(req, res) { - if (!CONFIG.REQUIRE_AUTH) return true; - - const auth = (req.headers.authorization || '').trim(); - const x = (req.headers['x-auth'] || '').toString().trim(); - const token = CONFIG.API_TOKEN.trim(); - - const got = - (auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '') || - x; - - 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; - } - - if (got !== token) { - res.status(401).json({ ok: false, error: { message: 'Unauthorized' } }); - return false; - } - - return true; -} - -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