diff --git a/index.js b/index.js index 5defc5d..9a49006 100644 --- a/index.js +++ b/index.js @@ -1,48 +1,13 @@ 'use strict'; -/** - * estados-hs-direct/index.js - * - Server HTTP (Express) para ejecutar cambios de estado HomeServe "directo" (sin cola). - * - Auth opcional vía Firebase ID Token (REQUIRE_AUTH=1). - * - Credenciales HomeServe leídas desde Firestore (HS_CRED_DOC_PATH=secrets/homeserve). - * - Healthcheck compatible con CapRover: / y /health devuelven 200. - * - * Firestore doc recomendado (por defecto: secrets/homeserve): - * { - * baseUrl: "https://gestor.homeserve.es/", - * user: "usuario@...", - * pass: "******", - * selectors: { - * user: "...", - * pass: "...", - * submit: "...", - * searchBox: "...", - * searchBtn: "...", - * openRow: "...", - * statusDropdown: "...", - * saveBtn: "...", - * noteTextarea: "..." - * } - * } - * - * Request POST /api/homeserve/change-status: - * { - * "serviceNumber": "28197832", - * "code": "348", - * "dateString": "03/01/2026", - * "observation": "texto opcional" - * } - */ - const express = require('express'); const { chromium } = require('playwright'); const admin = require('firebase-admin'); -const PORT = parseInt(process.env.PORT || '3000', 10); +const PORT = parseInt(process.env.PORT || process.env.CAPROVER_PORT || '80', 10); const REQUIRE_AUTH = String(process.env.REQUIRE_AUTH || '0') === '1'; const HS_CRED_DOC_PATH = process.env.HS_CRED_DOC_PATH || 'secrets/homeserve'; -// Selectores por defecto (puedes sobreescribirlos vía env o Firestore doc) const DEFAULT_SEL = { user: process.env.SEL_USER || 'input[type="text"]', pass: process.env.SEL_PASS || 'input[type="password"]', @@ -52,7 +17,6 @@ const DEFAULT_SEL = { 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í NO seleccionamos por "texto", sino por código (value o texto que contenga el code) statusDropdown: process.env.SEL_STATUS_DROPDOWN || 'select[name*="estado"], select[id*="estado"], select:has(option)', @@ -85,14 +49,12 @@ let firestore = null; function initFirebaseOnce() { if (firestore) return firestore; - // Intentamos init de varias formas para que no te "explote" según el entorno try { if (admin.apps && admin.apps.length) { firestore = admin.firestore(); return firestore; } - // 1) Con service account por envs (como tenías antes) if (process.env.FIREBASE_PRIVATE_KEY) { if (!process.env.FIREBASE_PROJECT_ID || !process.env.FIREBASE_CLIENT_EMAIL) { throw new Error('Missing env: FIREBASE_PROJECT_ID or FIREBASE_CLIENT_EMAIL'); @@ -110,16 +72,11 @@ function initFirebaseOnce() { return firestore; } - // 2) Application Default Credentials (por ejemplo con GOOGLE_APPLICATION_CREDENTIALS) - admin.initializeApp({ - credential: admin.credential.applicationDefault(), - }); - + admin.initializeApp({ credential: admin.credential.applicationDefault() }); firestore = admin.firestore(); return firestore; } catch (e) { - // Lo dejamos súper claro en logs log('Firebase init error:', e?.message || e); throw e; } @@ -131,8 +88,7 @@ async function verifyFirebaseIdToken(req) { if (!m) throw new Error('Missing Authorization Bearer token'); const idToken = m[1]; - const db = initFirebaseOnce(); // asegura admin init - void db; // (solo para dejar claro que lo usamos para init) + initFirebaseOnce(); const decoded = await admin.auth().verifyIdToken(idToken); return decoded; } @@ -141,7 +97,7 @@ async function verifyFirebaseIdToken(req) { let cachedCreds = null; let cachedCredsAt = 0; -const CREDS_TTL_MS = 60 * 1000; // 1 min (para que si cambias algo en Firestore lo pille rápido) +const CREDS_TTL_MS = 60 * 1000; async function loadHomeServeCreds() { const now = Date.now(); @@ -149,7 +105,6 @@ async function loadHomeServeCreds() { const db = initFirebaseOnce(); - // Env override (si quieres, pero por defecto: Firestore) const envUser = process.env.HOMESERVE_USER; const envPass = process.env.HOMESERVE_PASS; const envBaseUrl = process.env.HOMESERVE_BASE_URL; @@ -172,14 +127,11 @@ async function loadHomeServeCreds() { const pass = safeStr(envPass || fromFirestore?.pass || fromFirestore?.password || '').trim(); - const selectors = { - ...DEFAULT_SEL, - ...(fromFirestore?.selectors || {}), - }; + const selectors = { ...DEFAULT_SEL, ...(fromFirestore?.selectors || {}) }; if (!user || !pass) { throw new Error( - `HomeServe credentials missing. Set them in Firestore doc "${HS_CRED_DOC_PATH}" (fields: user, pass) or env HOMESERVE_USER/HOMESERVE_PASS.` + `HomeServe credentials missing. Put user/pass in Firestore doc "${HS_CRED_DOC_PATH}" or set HOMESERVE_USER/HOMESERVE_PASS envs.` ); } @@ -245,28 +197,18 @@ async function openParte(page, parteId, SEL) { await sleep(1200); } -/** - * Selecciona estado por "code". - * - 1) intenta selectOption por value==code - * - 2) intenta encontrar option cuyo texto contenga el code - * - 3) dispara change - */ async function selectStatusByCode(page, SEL, code) { const statusCode = safeStr(code).trim(); if (!statusCode) throw new Error('Missing status code'); await page.waitForSelector(SEL.statusDropdown, { timeout: 60000 }); - // intento 1: value = code try { await page.selectOption(SEL.statusDropdown, { value: statusCode }); return; - } catch (_) { - // sigue - } + } catch (_) {} - // intento 2: buscar option por texto que contenga el code - const ok = await page.evaluate(({ sel, code }) => { + const r = await page.evaluate(({ sel, code }) => { const s = document.querySelector(sel); if (!s) return { ok: false, why: 'select not found' }; @@ -282,9 +224,7 @@ async function selectStatusByCode(page, SEL, code) { return { ok: true, value: hit.value, text: (hit.textContent || '').trim() }; }, { sel: SEL.statusDropdown, code: statusCode }); - if (!ok || !ok.ok) { - throw new Error(`No matching status option for code "${statusCode}"`); - } + if (!r || !r.ok) throw new Error(`No matching status option for code "${statusCode}"`); } async function setEstado(page, SEL, code, noteMaybe) { @@ -292,9 +232,7 @@ async function setEstado(page, SEL, code, noteMaybe) { if (noteMaybe) { const ta = await page.$(SEL.noteTextarea); - if (ta) { - await page.fill(SEL.noteTextarea, String(noteMaybe)); - } + if (ta) await page.fill(SEL.noteTextarea, String(noteMaybe)); } const save = await page.$(SEL.saveBtn); @@ -310,7 +248,6 @@ async function setEstado(page, SEL, code, noteMaybe) { const app = express(); app.use(express.json({ limit: '1mb' })); -// ✅ Importante para CapRover: / debe devolver 200 (si no, te mata el contenedor) app.get('/', (req, res) => { res.status(200).send('ok'); }); @@ -319,18 +256,17 @@ app.get('/health', (req, res) => { res.status(200).json({ ok: true, service: 'estados-hs', + port: PORT, requireAuth: REQUIRE_AUTH, hsCredDocPath: HS_CRED_DOC_PATH, ts: new Date().toISOString(), }); }); -// Endpoint principal app.post('/api/homeserve/change-status', async (req, res) => { const startedAt = new Date().toISOString(); try { - // Auth si está activado let decoded = null; if (REQUIRE_AUTH) { decoded = await verifyFirebaseIdToken(req); @@ -338,7 +274,7 @@ app.post('/api/homeserve/change-status', async (req, res) => { const serviceNumberRaw = req.body?.serviceNumber ?? req.body?.parteId ?? req.body?.parte ?? req.body?.codigo; const codeRaw = req.body?.code ?? req.body?.statusCode ?? req.body?.newStatusValue ?? req.body?.selectedCode; - const dateString = safeStr(req.body?.dateString ?? '').trim(); // no siempre se usa en portal, pero lo guardamos en nota si quieres + const dateString = safeStr(req.body?.dateString ?? '').trim(); const observation = safeStr(req.body?.observation ?? req.body?.note ?? '').trim(); const serviceNumber = stripDigits(serviceNumberRaw); @@ -354,14 +290,12 @@ app.post('/api/homeserve/change-status', async (req, res) => { const creds = await loadHomeServeCreds(); const SEL = creds.selectors; - // Nota final (si quieres incluir fecha) const finalNote = [observation, dateString ? `Fecha: ${dateString}` : ''].filter(Boolean).join(' | '); - const result = await withBrowser(async (page) => { + await withBrowser(async (page) => { await loginHomeServe(page, creds); await openParte(page, serviceNumber, SEL); await setEstado(page, SEL, code, finalNote || null); - return { ok: true }; }); return res.json({ @@ -370,9 +304,7 @@ app.post('/api/homeserve/change-status', async (req, res) => { finishedAtISO: new Date().toISOString(), serviceNumber, code, - requireAuth: REQUIRE_AUTH, uid: decoded?.uid || null, - result, }); } catch (e) { @@ -387,25 +319,20 @@ app.post('/api/homeserve/change-status', async (req, res) => { } }); -// 404 fallback (para que quede claro) app.use((req, res) => { res.status(404).json({ ok: false, error: 'Not found' }); }); -// ---------------- start + graceful shutdown ---------------- - const server = app.listen(PORT, () => { log(`listening on :${PORT}`); log(`HS_CRED_DOC_PATH=${HS_CRED_DOC_PATH}`); log(`REQUIRE_AUTH=${REQUIRE_AUTH ? '1' : '0'}`); + log(`ENV PORT=${process.env.PORT || '(unset)'} CAPROVER_PORT=${process.env.CAPROVER_PORT || '(unset)'}`); }); function shutdown(signal) { log(`received ${signal}, shutting down...`); - server.close(() => { - process.exit(0); - }); - // por si algo se queda colgado + server.close(() => process.exit(0)); setTimeout(() => process.exit(0), 3000).unref(); }