'use strict'; const express = require('express'); const cors = require('cors'); const { chromium } = require('playwright'); const admin = require('firebase-admin'); 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 safeTrim(x) { return String(x ?? '').trim(); } function nowISO() { return new Date().toISOString(); } function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } /** ========= 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({ projectId: mustEnv('FIREBASE_PROJECT_ID'), clientEmail: mustEnv('FIREBASE_CLIENT_EMAIL'), privateKey: mustEnv('FIREBASE_PRIVATE_KEY').replace(/\\n/g, '\n'), }), }); } 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; } 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: 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).' } }); } // Si REQUIRE_AUTH=1 y no pones API_KEY, lo dejamos pasar (para no bloquearte). return next(); } /** ========= Playwright ========= */ 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 { await browser.close().catch(() => {}); } } 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`; } 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'); } await page.waitForLoadState('domcontentloaded', { timeout: 120000 }); await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {}); } 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 } 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))) { 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)); } } } // 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(() => {}); } /** ========= Core: change status ========= */ async function changeStatusViaClientesPortal(db, payload) { 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); return await withBrowser(async (page) => { await gotoService(page, serviceUrl, creds); // entrar a pantalla de cambio de estado (repaso) await clickChangeState(page); // rellenar form de cambio de estado await setEstadoForm(page, payload); return { ok: true, usedBaseUrl: baseUrl, serviceUrl, credsSource: creds.source, }; }); } /** ========= Routes ========= */ const db = initFirebase(); app.get('/', (req, res) => res.status(200).send('ok')); app.get('/health', (req, res) => { res.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), ts: nowISO(), }); }); app.get('/api/homeserve/status-options', (req, res) => { res.json({ ok: true, options: STATUS_OPTIONS }); }); app.post('/api/homeserve/change-status', authMiddleware, 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({ ok: false, startedAtISO, finishedAtISO: nowISO(), request: { serviceNumber, newStatusValue, dateString, observation, informoCliente }, error: { message: String(err?.message || err), stack: String(err?.stack || ''), }, }); } }); /** ========= Listen ========= */ 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)'}`); app.listen(PORT, () => { console.log(`[estados-hs] listening on :${PORT}`); });