'use strict'; const express = require('express'); const helmet = require('helmet'); const cors = require('cors'); const { chromium } = require('playwright'); const admin = require('firebase-admin'); // -------------------- helpers -------------------- function mustEnv(name) { const v = process.env[name]; if (!v) throw new Error(`Missing env: ${name}`); return v; } function optEnv(name, fallback) { const v = process.env[name]; return (v === undefined || v === null || v === '') ? fallback : v; } function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } // -------------------- Firebase Admin -------------------- function initFirebaseAdmin() { if (admin.apps.length) return; const projectId = mustEnv('FIREBASE_PROJECT_ID'); const clientEmail = mustEnv('FIREBASE_CLIENT_EMAIL'); const privateKeyRaw = mustEnv('FIREBASE_PRIVATE_KEY'); admin.initializeApp({ credential: admin.credential.cert({ projectId, clientEmail, privateKey: privateKeyRaw.replace(/\\n/g, '\n'), }), }); } async function verifyFirebaseIdTokenIfPresent(req) { // Si quieres obligar a auth: REQUIRE_AUTH=1 const requireAuth = optEnv('REQUIRE_AUTH', '1') === '1'; const auth = req.headers.authorization || ''; const m = auth.match(/^Bearer\s+(.+)$/i); const token = m ? m[1] : null; if (!token) { if (requireAuth) throw new Error('Missing Authorization Bearer token'); return null; } const decoded = await admin.auth().verifyIdToken(token); // Opcional: lista blanca de UID const allowed = (process.env.ALLOWED_UIDS || '') .split(',') .map((s) => s.trim()) .filter(Boolean); if (allowed.length && !allowed.includes(decoded.uid)) { throw new Error('User not allowed (uid not in ALLOWED_UIDS)'); } return decoded; } // -------------------- HomeServe Status Map (tu Swift) -------------------- const STATUS_CODE_MAP = { "303": ["En espera de Cliente por aceptación Presupuesto"], "307": ["En espera de Profesional por fecha de inicio de trabajos"], "313": ["En espera de Profesional por secado de cala, pintura o parquet"], "318": ["En espera de Profesional por confirmación del Siniestro"], "319": ["En espera de Profesional por material"], "320": ["En espera de Profesional por espera de otro gremio"], "321": ["En espera de Profesional por presupuesto/valoración"], "323": ["En espera de Profesional por mejora del tiempo"], "326": ["En espera de Cliente por pago de Factura Contado/Franquicia"], "336": ["En espera de Profesional por avería en observación"], "342": ["En espera de Profesional pendiente cobro franquicia"], "345": ["En espera de Profesional en realización pendiente Terminar"], "348": ["En espera de Cliente por indicaciones"], "352": ["En espera de Perjudicado por indicaciones"], }; // -------------------- Config -------------------- const CONFIG = { PORT: parseInt(optEnv('PORT', '3000'), 10), // dónde guardas las credenciales de HomeServe en Firestore // Formato: "collection/doc" o "collection/doc/subcollection/doc" HS_CRED_DOC_PATH: optEnv('HS_CRED_DOC_PATH', 'secrets/homeserve'), // Si quieres fallback por ENV (por si firestore no está listo) HOMESERVE_BASE_URL: optEnv('HOMESERVE_BASE_URL', 'https://gestor.homeserve.es/'), HOMESERVE_USER: process.env.HOMESERVE_USER || null, HOMESERVE_PASS: process.env.HOMESERVE_PASS || null, // Seguridad extra (si no quieres usar Firebase auth): // pon API_KEY y el HTML mandará X-API-Key API_KEY: process.env.API_KEY || null, // Selectores (ajustables por env) SEL: { user: optEnv('SEL_USER', 'input[type="text"]'), pass: optEnv('SEL_PASS', 'input[type="password"]'), submit: optEnv('SEL_SUBMIT', 'button[type="submit"]'), searchBox: optEnv('SEL_SEARCH_BOX', 'input[placeholder*="Buscar"], input[type="search"]'), searchBtn: optEnv('SEL_SEARCH_BTN', 'button:has-text("Buscar"), button:has-text("Search")'), openRow: optEnv('SEL_OPEN_ROW', 'table tbody tr:first-child'), statusDropdown: optEnv('SEL_STATUS_DROPDOWN', 'select[name*="estado"], select[id*="estado"], select:has(option)'), noteTextarea: optEnv('SEL_NOTE_TEXTAREA', 'textarea[name*="nota"], textarea[id*="nota"], textarea'), saveBtn: optEnv('SEL_SAVE_BTN', 'button:has-text("Guardar"), button:has-text("Save"), button:has-text("Actualizar")'), }, // comportamiento HEADLESS: optEnv('HEADLESS', 'true') !== 'false', SLOW_MO_MS: parseInt(optEnv('SLOW_MO_MS', '0'), 10), }; // -------------------- Firestore: leer credenciales HS -------------------- async function getHomeServeCredentials(db) { // Doc esperado: // { // baseUrl: "https://gestor.homeserve.es/", // user: "xxxx", // pass: "yyyy" // } const parts = CONFIG.HS_CRED_DOC_PATH.split('/').filter(Boolean); if (parts.length < 2 || parts.length % 2 !== 0) { throw new Error(`HS_CRED_DOC_PATH inválido: "${CONFIG.HS_CRED_DOC_PATH}". Debe ser collection/doc (o pares).`); } 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]); } const snap = await ref.get(); if (!snap.exists) { throw new Error(`No existe el documento de credenciales: ${CONFIG.HS_CRED_DOC_PATH}`); } const data = snap.data() || {}; const baseUrl = (data.baseUrl || data.HOMESERVE_BASE_URL || CONFIG.HOMESERVE_BASE_URL).toString(); const user = (data.user || data.HOMESERVE_USER || CONFIG.HOMESERVE_USER || '').toString(); const pass = (data.pass || data.HOMESERVE_PASS || CONFIG.HOMESERVE_PASS || '').toString(); if (!user || !pass) { throw new Error(`El doc ${CONFIG.HS_CRED_DOC_PATH} no tiene user/pass (o están vacíos).`); } return { baseUrl, user, pass }; } // -------------------- Playwright actions -------------------- async function withBrowser(fn) { const browser = await chromium.launch({ headless: CONFIG.HEADLESS, slowMo: CONFIG.SLOW_MO_MS, args: ['--no-sandbox', '--disable-setuid-sandbox'], }); const context = await browser.newContext(); const page = await context.newPage(); try { return await fn(page); } finally { await browser.close().catch(() => {}); } } async function loginHomeServe(page, creds) { await page.goto(creds.baseUrl, { waitUntil: 'domcontentloaded', timeout: 120000 }); 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'); await page.waitForLoadState('networkidle', { timeout: 120000 }); // Si HomeServe muestra “Credenciales incorrectas” en el DOM: // (esto es opcional; si no existe, no pasa nada) const possibleError = await page.$('text=/credenciales\\s+incorrectas/i'); if (possibleError) { throw new Error('HomeServe: credenciales incorrectas (detectado en pantalla)'); } } async function openParte(page, parteId) { const hasSearch = await page.$(CONFIG.SEL.searchBox); if (hasSearch) { await page.fill(CONFIG.SEL.searchBox, String(parteId)); const btn = await page.$(CONFIG.SEL.searchBtn); if (btn) await btn.click(); else await page.keyboard.press('Enter'); await page.waitForLoadState('networkidle', { timeout: 120000 }); await sleep(1200); } await page.waitForSelector(CONFIG.SEL.openRow, { timeout: 60000 }); await page.click(CONFIG.SEL.openRow); await page.waitForLoadState('networkidle', { timeout: 120000 }); await sleep(800); } async function setEstadoByStatusCode(page, statusCode, notaFinal) { const code = String(statusCode).trim(); const labels = STATUS_CODE_MAP[code] || []; await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: 60000 }); // 1) Intento por value = code let selected = false; try { await page.selectOption(CONFIG.SEL.statusDropdown, { value: code }); selected = true; } catch (_) {} // 2) Intento por label exacto if (!selected) { for (const label of labels) { try { await page.selectOption(CONFIG.SEL.statusDropdown, { label }); selected = true; break; } catch (_) {} } } // 3) Fallback DOM: contains / code match if (!selected) { const ok = await page.evaluate(({ sel, code, labels }) => { const s = document.querySelector(sel); if (!s) return false; const opts = Array.from(s.querySelectorAll('option')); const norm = (x) => (x || '').trim().toLowerCase(); const needles = labels.map(norm).filter(Boolean); const hit = opts.find((o) => { const t = norm(o.textContent); const v = norm(o.value); if (v === norm(code)) return true; if (t.includes(norm(code))) return true; return needles.some((n) => t.includes(n)); }); if (!hit) return false; s.value = hit.value; s.dispatchEvent(new Event('change', { bubbles: true })); return true; }, { sel: CONFIG.SEL.statusDropdown, code, labels }); if (!ok) throw new Error(`No encuentro el estado en el desplegable para statusCode=${code}`); } if (notaFinal) { const ta = await page.$(CONFIG.SEL.noteTextarea); if (ta) await page.fill(CONFIG.SEL.noteTextarea, String(notaFinal)); } const save = await page.$(CONFIG.SEL.saveBtn); if (!save) throw new Error('Save button not found'); await save.click(); await page.waitForLoadState('networkidle', { timeout: 120000 }); await sleep(1200); } // -------------------- Express app -------------------- initFirebaseAdmin(); const db = admin.firestore(); const app = express(); app.use(helmet()); app.use(cors({ origin: '*'})); app.use(express.json({ limit: '512kb' })); // Concurrencia: servidor pequeño => 1 job a la vez let busy = false; app.get('/health', (_req, res) => { res.json({ ok: true, busy, ts: new Date().toISOString() }); }); app.post('/v1/homeserve/change-status', async (req, res) => { const startedAt = new Date().toISOString(); try { // Seguridad: API_KEY opcional if (CONFIG.API_KEY) { const k = req.headers['x-api-key']; if (!k || String(k) !== String(CONFIG.API_KEY)) { return res.status(401).json({ ok: false, error: 'Invalid X-API-Key' }); } } // Seguridad: Firebase token (por defecto requerido) await verifyFirebaseIdTokenIfPresent(req); if (busy) { return res.status(409).json({ ok: false, error: 'BUSY: ya hay un cambio en curso' }); } const { serviceNumber, statusCode, dateString, observation } = req.body || {}; const parteId = String(serviceNumber || '').trim(); const code = String(statusCode || '').trim(); if (!parteId) return res.status(400).json({ ok: false, error: 'serviceNumber requerido' }); if (!code) return res.status(400).json({ ok: false, error: 'statusCode requerido' }); if (!STATUS_CODE_MAP[code]) { return res.status(400).json({ ok: false, error: `statusCode inválido: ${code}`, allowed: Object.keys(STATUS_CODE_MAP), }); } // Nota final: metemos fecha si viene const ds = (dateString ? String(dateString).trim() : ''); const obs = (observation ? String(observation).trim() : ''); const notaFinal = [obs, ds ? `Fecha: ${ds}` : ''].filter(Boolean).join(' · '); busy = true; const creds = await getHomeServeCredentials(db); await withBrowser(async (page) => { await loginHomeServe(page, creds); await openParte(page, parteId); await setEstadoByStatusCode(page, code, notaFinal); }); busy = false; return res.json({ ok: true, startedAt, finishedAt: new Date().toISOString(), serviceNumber: parteId, statusCode: code, statusText: STATUS_CODE_MAP[code][0], noteSent: notaFinal, }); } catch (err) { busy = false; const msg = String(err?.message || err); return res.status(500).json({ ok: false, error: msg }); } }); app.listen(CONFIG.PORT, () => { console.log(`[estados-hs] listening on :${CONFIG.PORT}`); console.log(`[estados-hs] HS_CRED_DOC_PATH=${CONFIG.HS_CRED_DOC_PATH}`); console.log(`[estados-hs] REQUIRE_AUTH=${optEnv('REQUIRE_AUTH', '1')}`); });