diff --git a/index.js b/index.js index 9cf1baf..90ac928 100644 --- a/index.js +++ b/index.js @@ -3,152 +3,150 @@ const express = require('express'); const cors = require('cors'); -const admin = require('firebase-admin'); const { chromium } = require('playwright'); +const admin = require('firebase-admin'); -// ----------------------------- -// Utils -// ----------------------------- function mustEnv(name) { const v = process.env[name]; if (!v) throw new Error(`Missing env: ${name}`); return v; } -function parsePort(v, fallback) { - const n = Number.parseInt(String(v || ''), 10); - return Number.isFinite(n) && n > 0 ? n : fallback; -} - -function nowISO() { - return new Date().toISOString(); -} - -function sleep(ms) { - return new Promise((r) => setTimeout(r, ms)); -} - -function safeStr(v) { - return String(v ?? '').trim(); -} - -// ----------------------------- -// Config -// ----------------------------- -const CONFIG = { - // HomeServe - HOMESERVE_BASE_URL: process.env.HOMESERVE_BASE_URL || 'https://gestor.homeserve.es/', - - // Credenciales desde Firestore (doc path tipo: secrets/homeserve) - HS_CRED_DOC_PATH: process.env.HS_CRED_DOC_PATH || 'secrets/homeserve', - - // Auth API (opcional) - REQUIRE_AUTH: String(process.env.REQUIRE_AUTH || '0') === '1', - API_TOKEN: process.env.API_TOKEN || '', - - // Playwright - HEADLESS: String(process.env.HEADLESS || 'true') !== 'false', - - // Selectores (ajustables por env) - SEL: { - user: process.env.SEL_USER || 'input[type="text"], input[name="username"], input[id*="user"]', - pass: process.env.SEL_PASS || 'input[type="password"], input[name="password"], input[id*="pass"]', - submit: process.env.SEL_SUBMIT || 'button[type="submit"], button:has-text("Acceder"), button:has-text("Entrar")', - - 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', - - // OJO: aquí depende mucho del portal - statusDropdown: process.env.SEL_STATUS_DROPDOWN || 'select[name*="estado"], select[id*="estado"], select', - noteTextarea: process.env.SEL_NOTE_TEXTAREA || 'textarea[name*="nota"], textarea[id*="nota"], textarea', - saveBtn: process.env.SEL_SAVE_BTN || 'button:has-text("Guardar"), button:has-text("Save"), button:has-text("Actualizar")', - }, - - // Timing - NAV_TIMEOUT: parseInt(process.env.NAV_TIMEOUT || '120000', 10), - WAIT_TIMEOUT: parseInt(process.env.WAIT_TIMEOUT || '60000', 10), -}; - -// ----------------------------- -// Firebase Admin init -// ----------------------------- function initFirebase() { - // Soporta 2 modos: - // 1) FIREBASE_PROJECT_ID + FIREBASE_CLIENT_EMAIL + FIREBASE_PRIVATE_KEY - // 2) Application Default Credentials (si existiese) - if (admin.apps.length) return admin.firestore(); - - const hasEnvCreds = - process.env.FIREBASE_PROJECT_ID && - process.env.FIREBASE_CLIENT_EMAIL && - process.env.FIREBASE_PRIVATE_KEY; - - if (hasEnvCreds) { - 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'), - }), - }); - } else { - // fallback: si tu entorno tuviera ADC (no siempre) - admin.initializeApp(); - } + if (admin.apps?.length) return admin.firestore(); + // Requiere estas 3 env (como ya usas en tus robots) + if (!process.env.FIREBASE_PRIVATE_KEY) throw new Error('Missing env: FIREBASE_PRIVATE_KEY'); + 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 db = initFirebase(); -// ----------------------------- -// HomeServe credentials loader (Firestore) -// ----------------------------- -let credCache = { ts: 0, data: null }; -const CRED_TTL_MS = parseInt(process.env.CRED_TTL_MS || '60000', 10); +// ===== Config ===== +const CONFIG = { + SERVICE_NAME: 'estados-hs', -async function getHomeServeCreds() { - const now = Date.now(); - if (credCache.data && (now - credCache.ts) < CRED_TTL_MS) return credCache.data; + HOMESERVE_BASE_URL: process.env.HOMESERVE_BASE_URL || 'https://gestor.homeserve.es/', - // Firestore doc: CONFIG.HS_CRED_DOC_PATH - // Esperado: - // { - // user: "usuario", - // pass: "password", - // baseUrl: "https://gestor.homeserve.es/" (opcional) - // } - const snap = await db.doc(CONFIG.HS_CRED_DOC_PATH).get(); - const d = snap.exists ? (snap.data() || {}) : {}; + // 🔥 Preferimos este doc (tu captura) + HS_CRED_DOC_PATH: process.env.HS_CRED_DOC_PATH || 'providerCredentials/homeserve', + HS_CRED_DOC_FALLBACK: process.env.HS_CRED_DOC_FALLBACK || 'secrets/homeserve', - const user = safeStr(d.user || d.username || process.env.HOMESERVE_USER); - const pass = safeStr(d.pass || d.password || process.env.HOMESERVE_PASS); - const baseUrl = safeStr(d.baseUrl || d.url || CONFIG.HOMESERVE_BASE_URL) || CONFIG.HOMESERVE_BASE_URL; + // Auth opcional al endpoint (si lo quieres) + REQUIRE_AUTH: String(process.env.REQUIRE_AUTH || '0') === '1', + AUTH_TOKEN: process.env.AUTH_TOKEN || '', - if (!user || !pass) { - throw new Error( - `HomeServe creds missing. Set them in Firestore doc "${CONFIG.HS_CRED_DOC_PATH}" (user/pass) or env HOMESERVE_USER/HOMESERVE_PASS` - ); + // Playwright + HEADLESS: String(process.env.HEADLESS || 'true') !== 'false', + + // Selectores (ajústalos si difieren) + SEL: { + user: process.env.SEL_USER || 'input[type="text"]', + pass: process.env.SEL_PASS || 'input[type="password"]', + submit: process.env.SEL_SUBMIT || 'button[type="submit"]', + + // búsqueda de parte/servicio + 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")', + + // entrar al detalle del parte + openRow: process.env.SEL_OPEN_ROW || 'table tbody tr:first-child', + + // cambio de estado + statusDropdown: process.env.SEL_STATUS_DROPDOWN || 'select[name*="estado"], select[id*="estado"], select:has(option)', + + // (opcionales) campos de fecha / observación si existen en HomeServe + dateInput: process.env.SEL_DATE_INPUT || 'input[name*="fecha"], input[id*="fecha"], input[placeholder*="dd"], input[placeholder*="DD"]', + noteTextarea: process.env.SEL_NOTE_TEXTAREA || 'textarea[name*="nota"], textarea[id*="nota"], textarea', + saveBtn: process.env.SEL_SAVE_BTN || 'button:has-text("Guardar"), button:has-text("Save"), button:has-text("Actualizar")', + }, +}; + +// ===== Estados (los del switch de tu app) ===== +const STATUS = [ + { 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_LABEL = Object.fromEntries(STATUS.map(s => [s.code, s.title])); + +// ===== Utils ===== +const sleep = (ms) => new Promise(r => setTimeout(r, ms)); +const nowISO = () => new Date().toISOString(); + +function parseDocPath(path) { + // "collection/doc/subcollection/doc" -> solo soportamos colección/doc o colección/doc/... (FireStore permite) + const parts = String(path || '').split('/').filter(Boolean); + if (parts.length < 2 || parts.length % 2 !== 0) { + throw new Error(`Invalid Firestore doc path: "${path}" (must be collection/doc[/collection/doc...])`); } - - const out = { user, pass, baseUrl }; - credCache = { ts: now, data: out }; - return out; + 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() { + // 1) Firestore doc principal (providerCredentials/homeserve) + const tryDoc = async (path) => { + try { + const ref = parseDocPath(path); + const snap = await ref.get(); + if (!snap.exists) return null; + const d = snap.data() || {}; + const user = (d.user || d.username || '').toString().trim(); + const pass = (d.pass || d.password || '').toString().trim(); + if (!user || !pass) return null; + return { user, pass, source: `firestore:${path}` }; + } catch (_) { + return null; + } + }; + + const a = await tryDoc(CONFIG.HS_CRED_DOC_PATH); + if (a) return a; + + const b = await tryDoc(CONFIG.HS_CRED_DOC_FALLBACK); + if (b) return b; + + // 2) Env fallback + const envUser = (process.env.HOMESERVE_USER || '').trim(); + const envPass = (process.env.HOMESERVE_PASS || '').trim(); + if (envUser && envPass) return { user: envUser, pass: envPass, source: 'env:HOMESERVE_USER/HOMESERVE_PASS' }; + + throw new Error( + `HomeServe creds missing. Set them in Firestore doc "${CONFIG.HS_CRED_DOC_PATH}" (user/pass) ` + + `or "${CONFIG.HS_CRED_DOC_FALLBACK}" (user/pass) or env HOMESERVE_USER/HOMESERVE_PASS` + ); } -// ----------------------------- -// Playwright helpers -// ----------------------------- async function withBrowser(fn) { const browser = await chromium.launch({ headless: CONFIG.HEADLESS, args: ['--no-sandbox', '--disable-setuid-sandbox'], }); - const context = await browser.newContext(); const page = await context.newPage(); - try { return await fn(page); } finally { @@ -157,212 +155,134 @@ async function withBrowser(fn) { } async function login(page, creds) { - await page.goto(creds.baseUrl, { waitUntil: 'domcontentloaded', timeout: CONFIG.NAV_TIMEOUT }); + await page.goto(CONFIG.HOMESERVE_BASE_URL, { waitUntil: 'domcontentloaded', timeout: 120000 }); - await page.waitForSelector(CONFIG.SEL.user, { timeout: CONFIG.WAIT_TIMEOUT }); + await page.waitForSelector(CONFIG.SEL.user, { timeout: 60000 }); await page.fill(CONFIG.SEL.user, creds.user); - await page.waitForSelector(CONFIG.SEL.pass, { timeout: CONFIG.WAIT_TIMEOUT }); + 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 se atasca; lo hacemos más tolerante - await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT }); - await sleep(1200); + await page.waitForLoadState('networkidle', { timeout: 120000 }); } -async function openParte(page, parteId) { - // Buscar +async function openParte(page, serviceNumber) { const hasSearch = await page.$(CONFIG.SEL.searchBox); if (hasSearch) { - await page.fill(CONFIG.SEL.searchBox, String(parteId)); + await page.fill(CONFIG.SEL.searchBox, String(serviceNumber)); const btn = await page.$(CONFIG.SEL.searchBtn); if (btn) await btn.click(); else await page.keyboard.press('Enter'); - await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT }); + + await page.waitForLoadState('networkidle', { timeout: 120000 }); await sleep(1200); } - // Abrir primera fila - await page.waitForSelector(CONFIG.SEL.openRow, { timeout: CONFIG.WAIT_TIMEOUT }); + await page.waitForSelector(CONFIG.SEL.openRow, { timeout: 60000 }); await page.click(CONFIG.SEL.openRow); - await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT }); - await sleep(1000); + await page.waitForLoadState('networkidle', { timeout: 120000 }); + await sleep(900); } -async function setEstadoByCode(page, statusCode, note) { - const code = safeStr(statusCode); - if (!code) throw new Error('Missing statusCode'); +async function setEstado(page, code, dateString, observation) { + await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: 60000 }); - await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: CONFIG.WAIT_TIMEOUT }); - - // 1) Intentar selectOption por value exacto + // 1) intenta por value (= código) let selected = false; try { - await page.selectOption(CONFIG.SEL.statusDropdown, { value: code }); + await page.selectOption(CONFIG.SEL.statusDropdown, { value: String(code) }); selected = true; } catch (_) {} - // 2) Intentar selectOption por label que contenga el código + // 2) si no, intenta por label (título del estado) if (!selected) { + const label = CODE_TO_LABEL[String(code)] || String(code); try { - await page.selectOption(CONFIG.SEL.statusDropdown, { label: code }); + await page.selectOption(CONFIG.SEL.statusDropdown, { label }); selected = true; } catch (_) {} } - // 3) Fallback DOM: busca option cuyo value == code o cuyo texto contenga "(code)" o termine con code + // 3) si no, intenta encontrar option cuyo text contenga el código o el título if (!selected) { - const ok = await page.evaluate(({ sel, code }) => { + const label = CODE_TO_LABEL[String(code)] || ''; + const ok = await page.evaluate(({ sel, code, label }) => { const s = document.querySelector(sel); if (!s) return false; - const opts = Array.from(s.querySelectorAll('option')); - const norm = (x) => (x || '').toString().trim().toLowerCase(); - - const hit = - opts.find(o => norm(o.value) === norm(code)) || - opts.find(o => norm(o.textContent).includes(`(${norm(code)})`)) || - opts.find(o => norm(o.textContent).endsWith(norm(code))) || - opts.find(o => norm(o.textContent).includes(norm(code))); - + const lc = String(code).trim().toLowerCase(); + const ll = String(label).trim().toLowerCase(); + const hit = opts.find(o => { + const t = (o.textContent || '').trim().toLowerCase(); + return t.includes(lc) || (!!ll && t.includes(ll)); + }); if (!hit) return false; - s.value = hit.value; s.dispatchEvent(new Event('change', { bubbles: true })); return true; - }, { sel: CONFIG.SEL.statusDropdown, code }); + }, { sel: CONFIG.SEL.statusDropdown, code: String(code), label }); - if (!ok) { - throw new Error(`No matching status option for code "${code}". Revisa SEL_STATUS_DROPDOWN o el HTML del portal.`); + if (!ok) throw new Error(`No matching status option for code "${code}"`); + } + + // Fecha (si existe el campo) + if (dateString) { + const el = await page.$(CONFIG.SEL.dateInput); + if (el) { + await page.fill(CONFIG.SEL.dateInput, String(dateString)); } } - // Nota / observación - if (note) { + // Nota / observación (si existe) + if (observation) { const ta = await page.$(CONFIG.SEL.noteTextarea); if (ta) { - await page.fill(CONFIG.SEL.noteTextarea, String(note)); + await page.fill(CONFIG.SEL.noteTextarea, String(observation)); } } - // Guardar const save = await page.$(CONFIG.SEL.saveBtn); - if (!save) throw new Error('Save button not found (SEL_SAVE_BTN)'); + if (!save) throw new Error('Save button not found'); await save.click(); - await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT }); - await sleep(1400); + await page.waitForLoadState('networkidle', { timeout: 120000 }); + await sleep(1200); } -// ----------------------------- -// Express app -// ----------------------------- +// ===== Express ===== const app = express(); -app.use(cors({ origin: true })); +app.use(cors()); app.use(express.json({ limit: '1mb' })); -// Auth middleware (opcional) -app.use((req, res, next) => { - if (!CONFIG.REQUIRE_AUTH) return next(); - - const h = req.headers.authorization || ''; - const token = h.startsWith('Bearer ') ? h.slice(7).trim() : ''; - - if (!CONFIG.API_TOKEN) { - return res.status(500).json({ ok: false, error: { message: 'REQUIRE_AUTH=1 but API_TOKEN is not set' } }); - } - - if (!token || token !== CONFIG.API_TOKEN) { - return res.status(401).json({ ok: false, error: { message: 'Unauthorized' } }); - } - - next(); -}); - -// Health + root +// Root OK (lo que pediste) app.get('/', (req, res) => { res.status(200).send('ok'); }); app.get('/health', (req, res) => { - res.status(200).json({ + res.json({ ok: true, - service: 'estados-hs', - port: parsePort(process.env.PORT, null), + service: CONFIG.SERVICE_NAME, requireAuth: CONFIG.REQUIRE_AUTH, hsCredDocPath: CONFIG.HS_CRED_DOC_PATH, + hsCredDocFallback: CONFIG.HS_CRED_DOC_FALLBACK, ts: nowISO(), }); }); -// Endpoint principal: cambio directo -// Body esperado: -// { -// serviceNumber: "28219874", -// newStatusValue: "348", // código -// dateString: "03/01/2026", // opcional (por si lo quieres guardar en nota) -// observation: "texto..." // opcional -// } -app.post('/api/homeserve/change-status', async (req, res) => { - const serviceNumber = safeStr(req.body?.serviceNumber); - const newStatusValue = safeStr(req.body?.newStatusValue || req.body?.statusCode); - const dateString = safeStr(req.body?.dateString); - const observation = safeStr(req.body?.observation); - - if (!serviceNumber || !newStatusValue) { - return res.status(400).json({ - ok: false, - error: { message: 'Missing serviceNumber or newStatusValue' }, - }); - } - - // Construimos nota final (si quieres que la fecha viaje dentro) - const noteParts = []; - if (dateString) noteParts.push(`Fecha: ${dateString}`); - if (observation) noteParts.push(observation); - const note = noteParts.join(' · ').trim(); - - const startedAtISO = nowISO(); - - try { - const creds = await getHomeServeCreds(); - - await withBrowser(async (page) => { - await login(page, creds); - await openParte(page, serviceNumber); - await setEstadoByCode(page, newStatusValue, note); - }); - - return res.status(200).json({ - ok: true, - serviceNumber, - newStatusValue, - startedAtISO, - finishedAtISO: nowISO(), - }); - } catch (err) { - return res.status(500).json({ - ok: false, - serviceNumber, - newStatusValue, - startedAtISO, - finishedAtISO: nowISO(), - error: { - message: String(err?.message || err), - stack: String(err?.stack || ''), - }, - }); - } -}); - -// HTML test page (por si quieres probar rápido desde el navegador) +// HTML integrado para pruebas (puedes dejarlo así o servir un archivo) app.get('/test', (req, res) => { - res.type('html').send(` + const optionsHtml = STATUS.map(s => { + const sel = s.code === '348' ? ' selected' : ''; + return ``; + }).join('\n'); + + res.status(200).send(`
@@ -370,47 +290,40 @@ app.get('/test', (req, res) => {