From 935323440e99c5968a4de7a16c72992d4cb8e2ab Mon Sep 17 00:00:00 2001 From: marsalva Date: Sun, 4 Jan 2026 00:16:17 +0000 Subject: [PATCH] Actualizar index.js --- index.js | 586 ++++++++++++++++++++++++++----------------------------- 1 file changed, 278 insertions(+), 308 deletions(-) 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) => { estados-hs · test

estados-hs · prueba

-
POST /api/homeserve/change-status
+
Servidor:
+ +
+ + +
- +
- +
@@ -418,7 +331,7 @@ app.get('/test', (req, res) => {
- +
Listo.
@@ -427,7 +340,29 @@ app.get('/test', (req, res) => { const $ = (id)=>document.getElementById(id); const log = (x)=>{ $('log').textContent = (typeof x==='string'?x:JSON.stringify(x,null,2)); }; - $('btn').addEventListener('click', async () => { + // Base correcta SIEMPRE al mismo dominio donde estás (evita el lío de marsalva.es) + const BASE = window.location.origin; + $('base').textContent = BASE; + + $('btnHealth').addEventListener('click', async () => { + try{ + log('Consultando /health...'); + const r = await fetch(BASE + '/health'); + const j = await r.json().catch(()=>({})); + log({ http: r.status, base: BASE, response: j }); + }catch(e){ log(String(e)); } + }); + + $('btnRoot').addEventListener('click', async () => { + try{ + log('Consultando / ...'); + const r = await fetch(BASE + '/'); + const t = await r.text(); + log({ http: r.status, base: BASE, responseText: t }); + }catch(e){ log(String(e)); } + }); + + $('btnSend').addEventListener('click', async () => { try{ log('Enviando...'); const body = { @@ -436,61 +371,96 @@ app.get('/test', (req, res) => { dateString: $('dateString').value.trim(), observation: $('observation').value.trim() }; - const r = await fetch('/api/homeserve/change-status', { + + const r = await fetch(BASE + '/api/homeserve/change-status', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) }); const j = await r.json().catch(()=>({})); - log({ http: r.status, ...j }); - }catch(e){ - log(String(e)); - } + log({ http: r.status, base: BASE, request: body, response: j }); + }catch(e){ log(String(e)); } }); `); }); -// ----------------------------- -// CAPROVER PORT FIX (80 + PORT) -// ----------------------------- -const port80 = 80; -const envPort = parsePort(process.env.PORT, null); -const caproverPort = parsePort(process.env.CAPROVER_PORT, null); +// Endpoint principal: cambio directo +app.post('/api/homeserve/change-status', async (req, res) => { + try { + if (CONFIG.REQUIRE_AUTH) { + const auth = req.headers.authorization || ''; + const token = auth.startsWith('Bearer ') ? auth.slice(7) : ''; + if (!CONFIG.AUTH_TOKEN || token !== CONFIG.AUTH_TOKEN) { + return res.status(401).json({ ok: false, error: { message: 'Unauthorized' } }); + } + } -const extraPorts = new Set([envPort, caproverPort].filter(Boolean)); -extraPorts.delete(port80); + const serviceNumber = String(req.body?.serviceNumber || '').trim(); + const newStatusValue = String(req.body?.newStatusValue || '').trim(); // código + const dateString = String(req.body?.dateString || '').trim(); + const observation = String(req.body?.observation || '').trim(); -const servers = []; + if (!serviceNumber) return res.status(400).json({ ok: false, error: { message: 'Missing serviceNumber' } }); + if (!newStatusValue) return res.status(400).json({ ok: false, error: { message: 'Missing newStatusValue' } }); -function startListen(port) { - const server = app.listen(port, '0.0.0.0', () => { - console.log(`[estados-hs] listening on :${port}`); - }); - servers.push(server); -} + const startedAtISO = nowISO(); + const creds = await getHomeServeCreds(); -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(`[${CONFIG.SERVICE_NAME}] change-status service=${serviceNumber} status=${newStatusValue} credsSource=${creds.source}`); -// 1) Puerto “seguro” para CapRover/NGINX -startListen(port80); + await withBrowser(async (page) => { + await login(page, creds); + await openParte(page, serviceNumber); + await setEstado(page, newStatusValue, dateString || null, observation || null); + }); -// 2) Puertos extra (por ejemplo 3000) si existen -for (const p of extraPorts) startListen(p); + const finishedAtISO = nowISO(); -// cierre limpio (evita que npm lo pinte como “error”) -async function shutdown(signal) { - console.log(`[estados-hs] received ${signal} -> shutting down gracefully...`); - for (const s of servers) { - try { - await new Promise((resolve) => s.close(() => resolve())); - } catch (_) {} + res.json({ + ok: true, + serviceNumber, + newStatusValue, + startedAtISO, + finishedAtISO, + }); + + } catch (err) { + const finishedAtISO = nowISO(); + res.status(500).json({ + ok: false, + finishedAtISO, + error: { + message: String(err?.message || err), + stack: String(err?.stack || ''), + }, + }); } - process.exit(0); +}); + +// Helpers +function escapeHtml(s) { + return String(s || '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); } -process.on('SIGTERM', () => shutdown('SIGTERM')); -process.on('SIGINT', () => shutdown('SIGINT')); \ No newline at end of file +// ===== Listen (compatible CapRover) ===== +const portA = parseInt(process.env.CAPROVER_PORT || process.env.PORT || '3000', 10); +const portB = 80; // por si CapRover está esperando 80 y no configuraste el "Container HTTP Port" + +console.log(`[${CONFIG.SERVICE_NAME}] HS_CRED_DOC_PATH=${CONFIG.HS_CRED_DOC_PATH}`); +console.log(`[${CONFIG.SERVICE_NAME}] HS_CRED_DOC_FALLBACK=${CONFIG.HS_CRED_DOC_FALLBACK}`); +console.log(`[${CONFIG.SERVICE_NAME}] REQUIRE_AUTH=${CONFIG.REQUIRE_AUTH ? 1 : 0}`); +console.log(`[${CONFIG.SERVICE_NAME}] ENV PORT=${process.env.PORT || '(unset)'} CAPROVER_PORT=${process.env.CAPROVER_PORT || '(unset)'}`); + +app.listen(portA, () => console.log(`[${CONFIG.SERVICE_NAME}] listening on :${portA}`)); + +// Si el puerto principal no es 80, abrimos 80 también para evitar 502 (si CapRover está esperando 80) +if (portA !== portB) { + app.listen(portB, () => console.log(`[${CONFIG.SERVICE_NAME}] listening on :${portB}`)); +} \ No newline at end of file