From be412bf0182f2ab8fa3ad7225abfddf63f5ce41f Mon Sep 17 00:00:00 2001 From: marsalva Date: Sun, 4 Jan 2026 10:53:15 +0000 Subject: [PATCH] Actualizar index.js --- index.js | 942 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 521 insertions(+), 421 deletions(-) diff --git a/index.js b/index.js index d624164..eec44f9 100644 --- a/index.js +++ b/index.js @@ -1,46 +1,69 @@ 'use strict'; +/** + * estados-hs-direct/index.js + * - Endpoint directo: POST /api/homeserve/change-status + * - Healthchecks: GET / , GET /health + * - Test UI: GET /test + * + * Credenciales HomeServe: + * 1) ENV HOMESERVE_USER/HOMESERVE_PASS (+ HOMESERVE_BASE_URL opcional) + * 2) Firestore doc en HS_CRED_DOC_PATH (ej: providerCredentials/homeserve) + * 3) Fallbacks: providerCredentials/homeserve -> secrets/homeserve + */ + const express = require('express'); const cors = require('cors'); const { chromium } = require('playwright'); const admin = require('firebase-admin'); const app = express(); +app.use(cors({ origin: true })); 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()); -} +// --------------------- Utils --------------------- function mustEnv(name) { const v = process.env[name]; if (!v) throw new Error(`Missing env: ${name}`); return v; } -function safeTrim(x) { - return String(x ?? '').trim(); +function envBool(name, def = false) { + const v = (process.env[name] ?? '').toString().trim().toLowerCase(); + if (!v) return def; + return ['1', 'true', 'yes', 'y', 'on'].includes(v); } +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + function nowISO() { return new Date().toISOString(); } -function sleep(ms) { - return new Promise(r => setTimeout(r, ms)); +function safeStr(x) { + return (x === undefined || x === null) ? '' : String(x); } -/** ========= Firebase / Firestore ========= */ +function normalizeDocPath(p) { + // esperamos "collection/doc" + if (!p) return null; + const t = String(p).trim().replace(/^\/+|\/+$/g, ''); + if (!t) return null; + const parts = t.split('/').filter(Boolean); + if (parts.length < 2) return null; + return { col: parts[0], doc: parts[1] }; +} +function pickFirstNonEmpty(...vals) { + for (const v of vals) { + if (v !== undefined && v !== null && String(v).trim() !== '') return String(v).trim(); + } + return ''; +} + +// --------------------- Firebase --------------------- 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({ @@ -53,189 +76,98 @@ function initFirebase() { return admin.firestore(); } -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; -} +// --------------------- Config --------------------- +const CFG = { + REQUIRE_AUTH: envBool('REQUIRE_AUTH', false), -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; + // Si quieres, puedes dejarlo vacío y se usará providerCredentials/homeserve por defecto + HS_CRED_DOC_PATH: process.env.HS_CRED_DOC_PATH || process.env.HS_CRED_DOC || process.env.HS_CRED_PATH || '', - if (envUser && envPass) { - return { - user: String(envUser), - pass: String(envPass), - baseUrl: String(envBaseUrl || ''), - source: 'env' - }; - } + // Puerto + PORT: Number(process.env.PORT || process.env.CAPROVER_PORT || 3000), - const docPath = process.env.HS_CRED_DOC_PATH || 'secrets/homeserve'; - const ref = docRefFromPath(db, docPath); - const snap = await ref.get(); + // Base “clientes” por defecto (tu enlace es CGI) + CLIENTES_CGI_BASE: process.env.CLIENTES_CGI_BASE || 'https://www.clientes.homeserve.es/cgi-bin/fccgi.exe', - 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 }.` - ); - } + // Timeouts + NAV_TIMEOUT: Number(process.env.NAV_TIMEOUT || 120000), + SEL_TIMEOUT: Number(process.env.SEL_TIMEOUT || 60000), +}; +function maskCreds(obj) { return { - user: String(user), - pass: String(pass), - baseUrl: String(baseUrl || ''), - source: `firestore:${docPath}` + ...obj, + pass: obj.pass ? '***' : '', }; } -/** ========= Config ========= */ +// --------------------- Credenciales --------------------- +async function getHomeServeCreds(db) { + // 1) ENV + const envUser = pickFirstNonEmpty(process.env.HOMESERVE_USER, process.env.HS_USER); + const envPass = pickFirstNonEmpty(process.env.HOMESERVE_PASS, process.env.HS_PASS); + const envBaseUrl = pickFirstNonEmpty(process.env.HOMESERVE_BASE_URL, process.env.HS_BASE_URL); -const CONFIG = { - REQUIRE_AUTH: boolEnv('REQUIRE_AUTH', false), - API_KEY: process.env.API_KEY || '', - - // 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', - - // Selectores "genéricos" - SEL: { - 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]' - ].join(', '), - - // Botón "repaso" (cambio de estado) según lo que me pasaste - repasoBtn: [ - 'input[type="image"][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 estado - estadoSelect: [ - 'select[name*="estado" i]', - 'select[id*="estado" i]', - 'select[name*="Estado" i]', - 'select:has(option)' - ].join(', '), - - fechaInput: [ - 'input[name*="fecha" i]', - 'input[id*="fecha" i]', - 'input[placeholder*="dd/mm" i]', - 'input[type="date"]' - ].join(', '), - - obsTextarea: [ - 'textarea[name*="obs" i]', - 'textarea[name*="observ" i]', - 'textarea[name*="nota" i]', - 'textarea[id*="obs" i]', - 'textarea' - ].join(', '), - - submitCambio: [ - 'button[type="submit"]', - 'input[type="submit"]', - 'button:has-text("Guardar")', - 'button:has-text("Aceptar")', - 'input[value*="Guardar" i]', - 'input[value*="Aceptar" i]', - 'input[value*="Actualizar" i]' - ].join(', '), - } -}; - -// 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' }, -]; - -/** ========= 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).' } }); + if (envUser && envPass) { + return { + user: envUser, + pass: envPass, + baseUrl: envBaseUrl || CFG.CLIENTES_CGI_BASE, + cgiBase: CFG.CLIENTES_CGI_BASE, + source: 'env', + }; } - // Si REQUIRE_AUTH=1 y no pones API_KEY, lo dejamos pasar (para no bloquearte). - return next(); + // 2) Firestore (intenta varios paths) + const candidates = []; + const p = normalizeDocPath(CFG.HS_CRED_DOC_PATH); + if (p) candidates.push(`${p.col}/${p.doc}`); + + // Fallbacks IMPORTANTES (tu caso) + candidates.push('providerCredentials/homeserve'); + candidates.push('secrets/homeserve'); + + const tried = new Set(); + for (const path of candidates) { + if (!path || tried.has(path)) continue; + tried.add(path); + + const dp = normalizeDocPath(path); + if (!dp) continue; + + const snap = await db.collection(dp.col).doc(dp.doc).get(); + if (!snap.exists) continue; + + const d = snap.data() || {}; + + const user = pickFirstNonEmpty(d.user, d.username, d.usuario); + const pass = pickFirstNonEmpty(d.pass, d.password, d.clave); + const baseUrl = pickFirstNonEmpty(d.baseUrl, d.url, d.loginUrl, envBaseUrl); + + if (user && pass) { + return { + user, + pass, + baseUrl: baseUrl || CFG.CLIENTES_CGI_BASE, + cgiBase: CFG.CLIENTES_CGI_BASE, + source: `firestore:${path}`, + }; + } + } + + throw new Error( + 'HomeServe creds missing. Busca en ENV (HOMESERVE_USER/HOMESERVE_PASS) o crea/usa el doc Firestore providerCredentials/homeserve con { user, pass, baseUrl? }.' + ); } -/** ========= Playwright ========= */ - +// --------------------- Playwright 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(); - page.setDefaultTimeout(60000); - try { return await fn(page); } finally { @@ -243,305 +175,473 @@ async function withBrowser(fn) { } } -function buildServiceUrl(baseUrl, serviceNumber) { - 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`; +function allFrames(page) { + return page.frames(); } -async function maybeLogin(page, creds) { - // Si aparece password, asumimos login. - const pass = page.locator(CONFIG.SEL.loginPass); - if (!(await pass.count())) return; - - const user = page.locator(CONFIG.SEL.loginUser).first(); - - // 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'); +async function findLocatorInFrames(page, selector) { + for (const fr of allFrames(page)) { + const loc = fr.locator(selector); + try { + if (await loc.count()) return { frame: fr, locator: loc }; + } catch (_) {} } - - await page.waitForLoadState('domcontentloaded', { timeout: 120000 }); - await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {}); + return null; } -async function gotoService(page, serviceUrl, creds) { - await page.goto(serviceUrl, { waitUntil: 'domcontentloaded', timeout: 120000 }); - - // 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 }); - } - - await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {}); -} - -async function clickChangeState(page) { - // 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 +async function clickFirstThatExists(page, selectors, opts = {}) { + for (const sel of selectors) { + const hit = await findLocatorInFrames(page, sel); + if (hit) { + await hit.locator.first().click(opts); + return sel; + } + } + return null; +} + +async function fillFirstThatExists(page, selectors, value) { + for (const sel of selectors) { + const hit = await findLocatorInFrames(page, sel); + if (hit) { + await hit.locator.first().fill(value); + return sel; + } + } + return null; +} + +async function checkInformoClienteIfNeeded(page, enabled) { + if (!enabled) return false; + + // Intento 1: label explícito + const labelTextVariants = [ + 'ya ha informado al Cliente', + 'ya ha informado al cliente', + 'informado al Cliente', + 'informado al cliente', + 'Marque esta casilla', + 'marque esta casilla', + ]; + + for (const txt of labelTextVariants) { + const sel = `label:has-text("${txt}") >> input[type="checkbox"]`; + const hit = await findLocatorInFrames(page, sel); + if (hit) { + const cb = hit.locator.first(); + if (!(await cb.isChecked())) await cb.check(); + return true; } - throw new Error('No encuentro el botón de "Cambiar estado" (repaso).'); } - 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 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))) { + // Intento 2: búsqueda en DOM (checkbox cerca de texto) + const done = await page.evaluate((variants) => { + const norm = (s) => (s || '').toLowerCase(); + const labels = Array.from(document.querySelectorAll('label')); + for (const l of labels) { + const t = norm(l.textContent); + if (!variants.some(v => t.includes(norm(v)))) continue; + const cb = l.querySelector('input[type="checkbox"]') || document.getElementById(l.getAttribute('for') || ''); + if (cb && cb.type === 'checkbox') { 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: 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 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; - s.value = hit.value; - s.dispatchEvent(new Event('change', { bubbles: true })); - return true; - }, { code: String(newStatusValue) }); - - if (!ok) throw new Error(`No matching status option for "${newStatusValue}"`); - } - - // 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)); + // fallback: checkbox con texto al lado (layout antiguo) + const cbs = Array.from(document.querySelectorAll('input[type="checkbox"]')); + for (const cb of cbs) { + const parentText = norm(cb.parentElement ? cb.parentElement.textContent : ''); + if (variants.some(v => parentText.includes(norm(v)))) { + cb.checked = true; + cb.dispatchEvent(new Event('change', { bubbles: true })); + return true; } } - } + return false; + }, labelTextVariants); - // 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(() => {}); + return !!done; } -/** ========= Core: change status ========= */ +async function selectStatusByCode(page, code) { + // preferimos un + const selectors = [ + 'input[name="repaso"]', + 'input[title*="Cambiar el Estado" i]', + 'input[src*="estado1.gif" i]', + 'input[type="image"][name*="repaso" i]', + ]; + + const clicked = await clickFirstThatExists(page, selectors, { timeout: CFG.SEL_TIMEOUT }).catch(() => null); + if (!clicked) throw new Error('No encuentro el botón de "Cambiar estado" (repaso).'); +} + +async function submitChange(page) { + const selectors = [ + 'input[type="submit"][value*="Enviar" i]', + 'input[type="submit"][value*="Guardar" i]', + 'button:has-text("Enviar")', + 'button:has-text("Guardar")', + 'button:has-text("Aceptar")', + 'input[type="image"][title*="Enviar" i]', + ]; + const clicked = await clickFirstThatExists(page, selectors, { timeout: CFG.SEL_TIMEOUT }).catch(() => null); + if (!clicked) throw new Error('No encuentro el botón para guardar/enviar el cambio de estado.'); +} + +async function changeStatusViaClientesPortal(db, reqBody) { const creds = await getHomeServeCreds(db); - const baseUrl = safeTrim(creds.baseUrl) || process.env.HOMESERVE_BASE_URL || CONFIG.DEFAULT_CLIENTES_BASE; - const serviceUrl = buildServiceUrl(baseUrl, payload.serviceNumber); + const serviceNumber = safeStr(reqBody.serviceNumber).trim(); + const newStatusValue = safeStr(reqBody.newStatusValue).trim(); + const dateString = safeStr(reqBody.dateString).trim(); // DD/MM/AAAA + const observation = safeStr(reqBody.observation).trim(); + const informoCliente = !!reqBody.informoCliente; - return await withBrowser(async (page) => { - await gotoService(page, serviceUrl, creds); + if (!serviceNumber) throw new Error('Missing serviceNumber'); + if (!newStatusValue) throw new Error('Missing newStatusValue'); - // entrar a pantalla de cambio de estado (repaso) + const startedAtISO = nowISO(); + + const result = await withBrowser(async (page) => { + // Login + await loginClientesPortal(page, creds); + + // Abrir servicio + const serviceUrl = buildServiceUrl(serviceNumber); + await page.goto(serviceUrl, { waitUntil: 'domcontentloaded', timeout: CFG.NAV_TIMEOUT }); + await page.waitForLoadState('networkidle', { timeout: CFG.NAV_TIMEOUT }).catch(() => {}); + await page.waitForTimeout(800); + + // Click “cambio de estado” await clickChangeState(page); - // rellenar form de cambio de estado - await setEstadoForm(page, payload); + // Esperar que cargue formulario + await page.waitForLoadState('networkidle', { timeout: CFG.NAV_TIMEOUT }).catch(() => {}); + await page.waitForTimeout(800); + + // Seleccionar estado por CÓDIGO (307, 348, etc.) + await selectStatusByCode(page, newStatusValue); + + // Fecha (si existe un input razonable) + if (dateString) { + const dateSelectors = [ + 'input[name*="fecha" i]', + 'input[id*="fecha" i]', + 'input[placeholder*="dd" i]', + 'input[type="text"][size="10"]', + 'input[type="text"]', + ]; + // llenamos el primero que exista, pero intentando no pisar user/pass: ya estamos dentro + await fillFirstThatExists(page, dateSelectors, dateString).catch(() => {}); + } + + // Observación + if (observation) { + const obsSelectors = [ + 'textarea[name*="obs" i]', + 'textarea[name*="nota" i]', + 'textarea[id*="obs" i]', + 'textarea', + ]; + await fillFirstThatExists(page, obsSelectors, observation).catch(() => {}); + } + + // Checkbox “ya he informado al Cliente” + await checkInformoClienteIfNeeded(page, informoCliente).catch(() => false); + + // Guardar + await submitChange(page); + + await page.waitForLoadState('networkidle', { timeout: CFG.NAV_TIMEOUT }).catch(() => {}); + await page.waitForTimeout(1200); + + // Verificación “blanda”: volvemos al servicio y comprobamos que la página carga + await page.goto(serviceUrl, { waitUntil: 'domcontentloaded', timeout: CFG.NAV_TIMEOUT }); + await page.waitForLoadState('networkidle', { timeout: CFG.NAV_TIMEOUT }).catch(() => {}); + const html = await page.content().catch(() => ''); return { ok: true, - usedBaseUrl: baseUrl, + startedAtISO, + finishedAtISO: nowISO(), + usedCreds: maskCreds({ ...creds }), serviceUrl, - credsSource: creds.source, + // dejamos un “hint” por si quieres inspeccionar rápido + pageContainsStatusCode: html.includes(String(newStatusValue)), }; }); + + return result; } -/** ========= Routes ========= */ - +// --------------------- Routes --------------------- const db = initFirebase(); -app.get('/', (req, res) => res.status(200).send('ok')); +app.get('/', (req, res) => { + res.status(200).send('ok'); +}); app.get('/health', (req, res) => { - res.json({ + res.status(200).json({ ok: true, service: 'estados-hs', - requireAuth: CONFIG.REQUIRE_AUTH, - hsCredDocPath: process.env.HS_CRED_DOC_PATH || 'secrets/homeserve', - port: Number(process.env.CAPROVER_PORT || process.env.PORT || 80), + port: CFG.PORT, + requireAuth: CFG.REQUIRE_AUTH, + hsCredDocPath: CFG.HS_CRED_DOC_PATH || '(auto)', ts: nowISO(), }); }); -app.get('/api/homeserve/status-options', (req, res) => { - res.json({ ok: true, options: STATUS_OPTIONS }); +// UI simple de pruebas dentro del propio servicio +app.get('/test', (req, res) => { + res.type('html').send(TEST_HTML); }); -app.post('/api/homeserve/change-status', authMiddleware, async (req, res) => { +app.post('/api/homeserve/change-status', async (req, res) => { const startedAtISO = nowISO(); - - const serviceNumber = safeTrim(req.body?.serviceNumber); - const newStatusValue = safeTrim(req.body?.newStatusValue); - const dateString = safeTrim(req.body?.dateString); // DD/MM/YYYY - const observation = safeTrim(req.body?.observation); - const informoCliente = !!req.body?.informoCliente; - - if (!serviceNumber || !newStatusValue) { - return res.status(400).json({ - ok: false, - error: { message: 'Missing serviceNumber or newStatusValue' }, - startedAtISO, - finishedAtISO: nowISO(), - }); - } - try { - const out = await changeStatusViaClientesPortal(db, { - serviceNumber, - newStatusValue, - dateString, - observation, - informoCliente, - }); - - return res.json({ - ok: true, - startedAtISO, - finishedAtISO: nowISO(), - request: { serviceNumber, newStatusValue, dateString, observation, informoCliente }, - result: out, - }); - - } catch (err) { - return res.status(500).json({ + const out = await changeStatusViaClientesPortal(db, req.body || {}); + res.status(200).json({ ...out, startedAtISO }); + } catch (e) { + res.status(500).json({ ok: false, startedAtISO, finishedAtISO: nowISO(), - request: { serviceNumber, newStatusValue, dateString, observation, informoCliente }, error: { - message: String(err?.message || err), - stack: String(err?.stack || ''), + message: String(e?.message || e), + stack: String(e?.stack || ''), }, }); } }); -/** ========= Listen ========= */ +// --------------------- Start --------------------- +app.listen(CFG.PORT, '0.0.0.0', () => { + console.log(`[estados-hs] listening on :${CFG.PORT}`); + console.log(`[estados-hs] HS_CRED_DOC_PATH=${CFG.HS_CRED_DOC_PATH || '(auto providerCredentials/homeserve)'}`); + console.log(`[estados-hs] REQUIRE_AUTH=${CFG.REQUIRE_AUTH ? 1 : 0}`); + console.log(`[estados-hs] CLIENTES_CGI_BASE=${CFG.CLIENTES_CGI_BASE}`); +}); -const PORT = parseInt(process.env.CAPROVER_PORT || process.env.PORT || '80', 10); -console.log(`[estados-hs] HS_CRED_DOC_PATH=${process.env.HS_CRED_DOC_PATH || 'secrets/homeserve'}`); -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)'}`); +// --------------------- Embedded test HTML --------------------- +const TEST_HTML = ` + + + + + estados-hs · prueba + + + +
+

estados-hs · prueba

+
POST /api/homeserve/change-status
-app.listen(PORT, () => { - console.log(`[estados-hs] listening on :${PORT}`); -}); \ No newline at end of file + + + +
+ + +
+ + + + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + + + +
+ + +
Listo.
+
+ Consejo rápido: si ves "ok" en / y JSON en /health, el servicio está vivo. Si el cambio falla, el JSON de error te dirá en qué paso se atranca. +
+
+ + + +`; \ No newline at end of file