From 91e87aa5482baa5227e56a5cb3b8ba85065e204d Mon Sep 17 00:00:00 2001 From: marsalva Date: Sun, 4 Jan 2026 09:19:28 +0000 Subject: [PATCH] Actualizar index.js --- index.js | 820 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 416 insertions(+), 404 deletions(-) diff --git a/index.js b/index.js index e9e33c6..0f19eac 100644 --- a/index.js +++ b/index.js @@ -5,8 +5,6 @@ const cors = require('cors'); const { chromium } = require('playwright'); const admin = require('firebase-admin'); -// -------------------- Firebase -------------------- - function mustEnv(name) { const v = process.env[name]; if (!v) throw new Error(`Missing env: ${name}`); @@ -14,145 +12,165 @@ function mustEnv(name) { } function initFirebase() { - if (admin.apps?.length) return admin.firestore(); - + // Si NO quieres Firebase, puedes arrancar sin admin. + // Pero aquí lo usamos para leer secrets/homeserve. if (!process.env.FIREBASE_PRIVATE_KEY) { - throw new Error('Missing env: FIREBASE_PRIVATE_KEY'); + // Permitimos arrancar sin Firebase si pones creds por ENV + // (pero si tampoco hay creds, fallará al ejecutar). + return null; } - admin.initializeApp({ - credential: admin.credential.cert({ - projectId: mustEnv('FIREBASE_PROJECT_ID'), - clientEmail: mustEnv('FIREBASE_CLIENT_EMAIL'), - privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'), - }), - }); - + 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) { - // Tu caso real: providerCredentials/homeserve con { user, pass } - const docPath = process.env.HS_CRED_DOC_PATH || 'providerCredentials/homeserve'; + // 1) ENV manda + const envUser = process.env.HOMESERVE_USER; + const envPass = process.env.HOMESERVE_PASS; + const envBase = process.env.HOMESERVE_BASE_URL; - let fsUser = ''; - let fsPass = ''; + if (envUser && envPass) { + return { + user: envUser, + pass: envPass, + baseUrl: envBase || CONFIG.DEFAULT_HS_BASE_URL, + }; + } - try { - const snap = await db.doc(docPath).get(); - if (snap.exists) { - const d = snap.data() || {}; - fsUser = String(d.user || '').trim(); - fsPass = String(d.pass || '').trim(); - } - } catch (_) {} - - const envUser = String(process.env.HOMESERVE_USER || '').trim(); - const envPass = String(process.env.HOMESERVE_PASS || '').trim(); - - const user = fsUser || envUser; - const pass = fsPass || envPass; - - if (!user || !pass) { + // 2) Firestore + if (!db) { throw new Error( - `HomeServe creds missing. Put {user,pass} in Firestore doc "${docPath}" or set env HOMESERVE_USER/HOMESERVE_PASS` + `HomeServe creds missing. Set env HOMESERVE_USER/HOMESERVE_PASS or provide Firebase envs + doc "${CONFIG.HS_CRED_DOC_PATH}"` ); } - return { user, pass, source: fsUser ? `firestore:${docPath}` : 'env' }; -} + // cache 30s + const now = Date.now(); + if (cachedCreds && now - cachedCredsAt < 30000) return cachedCreds; -// -------------------- Config -------------------- + 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 CONFIG = { - // Flujo antiguo: - HOMESERVE_BASE_URL: process.env.HOMESERVE_BASE_URL || 'https://gestor.homeserve.es/', + const d = snap.data() || {}; + const user = d.user || d.email || d.usuario || ''; + const pass = d.pass || d.password || d.clave || ''; - // Selectores (igual que el robot viejo, pero con extras) - SEL: { - user: process.env.SEL_USER || 'input[type="text"], input[name="user"], input[name="username"]', - pass: process.env.SEL_PASS || 'input[type="password"], input[name="pass"], input[name="password"]', - submit: process.env.SEL_SUBMIT || 'button[type="submit"], input[type="submit"], button:has-text("Entrar")', + if (!user || !pass) { + throw new Error(`HomeServe creds missing in Firestore doc "${CONFIG.HS_CRED_DOC_PATH}". Needs fields "user" and "pass".`); + } - searchBox: process.env.SEL_SEARCH_BOX || 'input[placeholder*="Buscar"], input[type="search"]', - searchBtn: process.env.SEL_SEARCH_BTN || 'button:has-text("Buscar"), button:has-text("Search")', - openRow: process.env.SEL_OPEN_ROW || 'table tbody tr:first-child', + const baseUrl = d.baseUrl || d.HOMESERVE_BASE_URL || CONFIG.DEFAULT_HS_BASE_URL; - // Cambio estado - statusDropdown: - process.env.SEL_STATUS_DROPDOWN || - 'select[name*="estado" i], select[id*="estado" i], select:has(option)', - - // Observación/nota - noteTextarea: - process.env.SEL_NOTE_TEXTAREA || - 'textarea[name*="nota" i], textarea[id*="nota" i], textarea', - - // Fecha (si existe) - dateInput: - process.env.SEL_DATE_INPUT || - 'input[name*="fec" i], input[id*="fec" i], input[placeholder*="dd" i], input[placeholder*="fecha" i]', - - // Checkbox “ya informado al cliente” - informedCheckbox: - process.env.SEL_INFORMED_CHECKBOX || - 'input[type="checkbox"][name*="inform" i], input[type="checkbox"][id*="inform" i]', - - // Botón guardar - saveBtn: - process.env.SEL_SAVE_BTN || - 'button:has-text("Guardar"), button:has-text("Save"), button:has-text("Actualizar"), input[type="submit"]', - }, - - HEADLESS: String(process.env.HEADLESS || '1') !== '0', - NAV_TIMEOUT_MS: parseInt(process.env.NAV_TIMEOUT_MS || '120000', 10), - - // Auth opcional del endpoint - REQUIRE_AUTH: String(process.env.REQUIRE_AUTH || '0') === '1', - API_KEY: process.env.API_KEY || '', -}; - -// Estados por código (los de tu app) -const STATUS_CODES = [ - { 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 CODE_TO_TITLE = new Map(STATUS_CODES.map(x => [x.code, x.title])); - -// -------------------- Helpers -------------------- - -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -const nowISO = () => new Date().toISOString(); -const trim = (v) => String(v ?? '').trim(); - -function parseBool(v, def = true) { - if (v === undefined || v === null || v === '') return def; - const s = String(v).toLowerCase().trim(); - if (['1', 'true', 'yes', 'si', 'sí'].includes(s)) return true; - if (['0', 'false', 'no'].includes(s)) return false; - return def; + cachedCreds = { user, pass, baseUrl }; + cachedCredsAt = now; + return cachedCreds; } async function withBrowser(fn) { const browser = await chromium.launch({ - headless: CONFIG.HEADLESS, + headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'], }); - const context = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + 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 { @@ -160,292 +178,300 @@ async function withBrowser(fn) { } } -// -------------------- Playwright flow (como el robot antiguo) -------------------- - -async function login(page, creds) { - await page.goto(CONFIG.HOMESERVE_BASE_URL, { waitUntil: 'domcontentloaded', timeout: CONFIG.NAV_TIMEOUT_MS }); - - await page.waitForSelector(CONFIG.SEL.user, { timeout: 60000 }); - await page.fill(CONFIG.SEL.user, creds.user); - - await page.waitForSelector(CONFIG.SEL.pass, { timeout: 60000 }); - await page.fill(CONFIG.SEL.pass, creds.pass); - - const btn = await page.$(CONFIG.SEL.submit); - if (btn) await btn.click(); - else await page.keyboard.press('Enter'); - - // networkidle a veces es traicionero; domcontentloaded + pause suele ir mejor - await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT_MS }); - await sleep(1200); +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 openParte(page, parteId) { - const hasSearch = await page.$(CONFIG.SEL.searchBox); +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(parteId)); - const btn = await page.$(CONFIG.SEL.searchBtn); + 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('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT_MS }); + await page.waitForLoadState('networkidle', { timeout: CONFIG.GOTO_TIMEOUT }).catch(() => {}); await sleep(1200); } - await page.waitForSelector(CONFIG.SEL.openRow, { timeout: 60000 }); - await page.click(CONFIG.SEL.openRow); - await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT_MS }); - await sleep(900); + // 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 checkInformedBox(page) { - // 1) intento selector directo - const cb = await page.$(CONFIG.SEL.informedCheckbox); - if (cb) { - const isChecked = await cb.isChecked().catch(() => false); - if (!isChecked) await cb.check().catch(() => {}); +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) intento por texto de label (tu frase exacta) - const ok = await page.evaluate(() => { - const text = 'Marque esta casilla, si ya ha informado al Cliente'.toLowerCase(); - const labels = Array.from(document.querySelectorAll('label')); - const hit = labels.find(l => (l.textContent || '').toLowerCase().includes(text)); + // 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; - - const forId = hit.getAttribute('for'); - if (forId) { - const input = document.getElementById(forId); - if (input && input.type === 'checkbox') { - input.checked = true; - input.dispatchEvent(new Event('change', { bubbles: true })); - return true; - } - } - - // si no hay "for", buscamos checkbox cercano - let el = hit; - for (let i = 0; i < 4; i++) { - const parent = el.parentElement; - if (!parent) break; - const cb2 = parent.querySelector('input[type="checkbox"]'); - if (cb2) { - cb2.checked = true; - cb2.dispatchEvent(new Event('change', { bubbles: true })); - return true; - } - el = parent; - } - 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 setEstadoByCodeOrLabel(page, newStatusValue) { - await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: 60000 }); +async function setInformoCliente(page, informoCliente) { + if (!informoCliente) return; - // Primero intentamos por VALUE (si el select usa value=303 etc) - try { - await page.selectOption(CONFIG.SEL.statusDropdown, { value: String(newStatusValue) }); - return { method: 'value', picked: String(newStatusValue) }; - } catch (_) {} - - // Segundo: intentamos por label con el texto del código (si el option pinta "348 - En espera...") - const title = CODE_TO_TITLE.get(String(newStatusValue)); - const candidates = []; - - if (title) { - candidates.push(`${newStatusValue} · ${title}`); - candidates.push(`${newStatusValue} - ${title}`); - candidates.push(`${newStatusValue} ${title}`); - candidates.push(title); - } else { - candidates.push(String(newStatusValue)); + // 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; } - for (const label of candidates) { - try { - await page.selectOption(CONFIG.SEL.statusDropdown, { label }); - return { method: 'label', picked: label }; - } catch (_) {} - } - - // Tercero: buscar en DOM option que contenga el código - const ok = await page.evaluate(({ sel, code }) => { - const s = document.querySelector(sel); - if (!s) return null; - const opts = Array.from(s.querySelectorAll('option')); - const hit = - opts.find(o => String(o.value).trim() === String(code).trim()) || - opts.find(o => (o.textContent || '').includes(String(code))); - if (!hit) return null; - s.value = hit.value; - s.dispatchEvent(new Event('change', { bubbles: true })); - return { value: hit.value, text: (hit.textContent || '').trim() }; - }, { sel: CONFIG.SEL.statusDropdown, code: String(newStatusValue) }); - - if (!ok) throw new Error(`No matching status option for code "${newStatusValue}"`); - return { method: 'dom', picked: ok.value, text: ok.text }; -} - -async function fillDateIfExists(page, dateString) { - if (!trim(dateString)) return false; - const el = await page.$(CONFIG.SEL.dateInput); - if (!el) return false; - try { - await page.fill(CONFIG.SEL.dateInput, String(dateString)); - return true; - } catch (_) { + // 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 fillObservationIfExists(page, observation) { - if (!trim(observation)) return false; - const ta = await page.$(CONFIG.SEL.noteTextarea); - if (!ta) return false; - await page.fill(CONFIG.SEL.noteTextarea, String(observation)); +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); - if (!save) throw new Error('Save button not found'); + 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('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT_MS }); - await sleep(1200); + await page.waitForLoadState('networkidle', { timeout: CONFIG.GOTO_TIMEOUT }).catch(() => {}); + await sleep(900); } -async function changeStatusViaGestor(page, job, creds) { - await login(page, creds); - await openParte(page, job.serviceNumber); +async function changeStatusViaHomeServe({ baseUrl, user, pass }, payload) { + const { + serviceNumber, + newStatusValue, + dateString, + observation, + informoCliente, + } = payload; - const pick = await setEstadoByCodeOrLabel(page, job.newStatusValue); + return await withBrowser(async (page) => { + await login(page, { baseUrl, user, pass }); - const informed = job.informoCliente ? await checkInformedBox(page) : false; - const dateFilled = await fillDateIfExists(page, job.dateString); - const obsFilled = await fillObservationIfExists(page, job.observation); + // abre parte si hace falta + await openParte(page, serviceNumber); - await clickSave(page); + // 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.`); + } - const title = await page.title().catch(() => ''); - const snippet = await page.evaluate(() => (document.body ? document.body.innerText : '')).catch(() => ''); - return { - ok: true, - flow: 'gestor', - picked: pick, - informedChecked: informed, - dateFilled, - observationFilled: obsFilled, - pageTitle: title, - snippet: trim(snippet).slice(0, 600), - }; + // 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 }; + }); } -// -------------------- API server -------------------- +// ------------------------- +// Routes +// ------------------------- -const app = express(); -app.use(cors({ origin: true })); -app.use(express.json({ limit: '1mb' })); +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) => { - const port = Number(process.env.PORT || process.env.CAPROVER_PORT || 3000); +app.get('/health', async (req, res) => { res.json({ ok: true, service: 'estados-hs', - port, + port: pickPort(), requireAuth: CONFIG.REQUIRE_AUTH, - hsCredDocPath: process.env.HS_CRED_DOC_PATH || 'providerCredentials/homeserve', - baseUrl: CONFIG.HOMESERVE_BASE_URL, - ts: nowISO(), + hsCredDocPath: CONFIG.HS_CRED_DOC_PATH, + defaultBaseUrl: CONFIG.DEFAULT_HS_BASE_URL, + ts: new Date().toISOString(), }); }); -app.get('/statuses', (req, res) => { - res.json({ ok: true, statuses: STATUS_CODES }); -}); - -function authMiddleware(req, res, next) { - if (!CONFIG.REQUIRE_AUTH) return next(); - if (!CONFIG.API_KEY) return res.status(500).json({ ok: false, error: { message: 'REQUIRE_AUTH=1 but API_KEY not set' } }); - - const k = trim(req.headers['x-api-key'] || '') || trim(req.query.apiKey || ''); - if (k !== CONFIG.API_KEY) return res.status(401).json({ ok: false, error: { message: 'Unauthorized' } }); - next(); -} - -// HTML de prueba servido desde el propio server (por si te da pereza subir un .html) +// HTML de prueba (corregido + checkbox informoCliente) app.get('/test', (req, res) => { - res.type('html').send(buildTestHtml()); + res.type('html').send(TEST_HTML); }); -app.post('/api/homeserve/change-status', authMiddleware, async (req, res) => { - const startedAtISO = nowISO(); - - const job = { - serviceNumber: trim(req.body?.serviceNumber), - newStatusValue: trim(req.body?.newStatusValue), - dateString: trim(req.body?.dateString), - observation: trim(req.body?.observation), - informoCliente: parseBool(req.body?.informoCliente, true), // por defecto TRUE - }; - - if (!job.serviceNumber || !job.newStatusValue) { - return res.status(400).json({ - ok: false, - startedAtISO, - finishedAtISO: nowISO(), - error: { message: 'Missing serviceNumber or newStatusValue' }, - }); - } - - let db; - try { - db = initFirebase(); - } catch (e) { - return res.status(500).json({ - ok: false, - startedAtISO, - finishedAtISO: nowISO(), - error: { message: String(e?.message || e), stack: String(e?.stack || '') }, - }); - } +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 withBrowser(async (page) => { - return await changeStatusViaGestor(page, job, creds); + const result = await changeStatusViaHomeServe(creds, { + serviceNumber, + newStatusValue, + dateString: dateString || '', + observation: observation || '', + informoCliente, }); return res.json({ ok: true, startedAtISO, - finishedAtISO: nowISO(), - request: job, + finishedAtISO: new Date().toISOString(), + request: { + serviceNumber, + newStatusValue, + dateString: dateString || '', + observation: observation || '', + informoCliente, + }, result, }); } catch (err) { - return res.status(500).json({ + const code = err?.statusCode || 500; + return res.status(code).json({ ok: false, startedAtISO, - finishedAtISO: nowISO(), - request: job, - error: { message: String(err?.message || err), stack: String(err?.stack || '') }, + finishedAtISO: new Date().toISOString(), + error: { + message: String(err?.message || err), + stack: String(err?.stack || ''), + }, }); } }); -function buildTestHtml() { - const options = STATUS_CODES.map( - (s) => `` - ).join('\n'); +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}`); +}); - return ` +// ------------------------- +// Embedded TEST HTML +// ------------------------- + +const TEST_HTML = ` @@ -453,60 +479,66 @@ function buildTestHtml() { estados-hs · test
-

estados-hs · pruebas (flujo gestor)

-
Consejo: si /health responde JSON, el server está vivo. Si POST devuelve ok:true, ya estás cambiando estado 🔧
+

estados-hs · prueba

+
POST /api/homeserve/change-status
- - - -
- - -
- -
- - - + +
- - + + +
- + -
- - +
+ +
+
Marcar como informado al cliente
+
Equivale a: “Marque esta casilla, si ya ha informado al Cliente”
+
-
- +
+
Listo.
@@ -514,67 +546,47 @@ function buildTestHtml() { -`; -} - -function escapeHtml(s) { - return String(s || '') - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); -} - -const PORT = Number(process.env.PORT || process.env.CAPROVER_PORT || 3000); -app.listen(PORT, () => { - console.log(`[estados-hs] listening on :${PORT}`); - console.log(`[estados-hs] HOMESERVE_BASE_URL=${CONFIG.HOMESERVE_BASE_URL}`); - console.log(`[estados-hs] HS_CRED_DOC_PATH=${process.env.HS_CRED_DOC_PATH || 'providerCredentials/homeserve'}`); - console.log(`[estados-hs] REQUIRE_AUTH=${CONFIG.REQUIRE_AUTH ? 1 : 0}`); - console.log(`[estados-hs] HEADLESS=${CONFIG.HEADLESS ? 1 : 0}`); -}); \ No newline at end of file +`; \ No newline at end of file