Actualizar index.js

This commit is contained in:
marsalva 2026-01-03 23:52:19 +00:00
parent 2a68ddf1bb
commit 5b77956430
1 changed files with 487 additions and 5 deletions

492
index.js
View File

@ -1,3 +1,290 @@
// index.js
'use strict';
const express = require('express');
const cors = require('cors');
const admin = require('firebase-admin');
const { chromium } = require('playwright');
// -----------------------------
// 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();
}
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);
async function getHomeServeCreds() {
const now = Date.now();
if (credCache.data && (now - credCache.ts) < CRED_TTL_MS) return credCache.data;
// 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() || {}) : {};
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;
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`
);
}
const out = { user, pass, baseUrl };
credCache = { ts: now, data: out };
return out;
}
// -----------------------------
// 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 {
await browser.close().catch(() => {});
}
}
async function login(page, creds) {
await page.goto(creds.baseUrl, { waitUntil: 'domcontentloaded', timeout: CONFIG.NAV_TIMEOUT });
await page.waitForSelector(CONFIG.SEL.user, { timeout: CONFIG.WAIT_TIMEOUT });
await page.fill(CONFIG.SEL.user, creds.user);
await page.waitForSelector(CONFIG.SEL.pass, { timeout: CONFIG.WAIT_TIMEOUT });
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);
}
async function openParte(page, parteId) {
// Buscar
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('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT });
await sleep(1200);
}
// Abrir primera fila
await page.waitForSelector(CONFIG.SEL.openRow, { timeout: CONFIG.WAIT_TIMEOUT });
await page.click(CONFIG.SEL.openRow);
await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT });
await sleep(1000);
}
async function setEstadoByCode(page, statusCode, note) {
const code = safeStr(statusCode);
if (!code) throw new Error('Missing statusCode');
await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: CONFIG.WAIT_TIMEOUT });
// 1) Intentar selectOption por value exacto
let selected = false;
try {
await page.selectOption(CONFIG.SEL.statusDropdown, { value: code });
selected = true;
} catch (_) {}
// 2) Intentar selectOption por label que contenga el código
if (!selected) {
try {
await page.selectOption(CONFIG.SEL.statusDropdown, { label: code });
selected = true;
} catch (_) {}
}
// 3) Fallback DOM: busca option cuyo value == code o cuyo texto contenga "(code)" o termine con code
if (!selected) {
const ok = await page.evaluate(({ sel, code }) => {
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)));
if (!hit) return false;
s.value = hit.value;
s.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}, { sel: CONFIG.SEL.statusDropdown, code });
if (!ok) {
throw new Error(`No matching status option for code "${code}". Revisa SEL_STATUS_DROPDOWN o el HTML del portal.`);
}
}
// Nota / observación
if (note) {
const ta = await page.$(CONFIG.SEL.noteTextarea);
if (ta) {
await page.fill(CONFIG.SEL.noteTextarea, String(note));
}
}
// Guardar
const save = await page.$(CONFIG.SEL.saveBtn);
if (!save) throw new Error('Save button not found (SEL_SAVE_BTN)');
await save.click();
await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT });
await sleep(1400);
}
// -----------------------------
// Express app
// -----------------------------
const app = express();
app.use(cors({ origin: true }));
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
app.get('/', (req, res) => {
res.status(200).send('ok');
});
@ -6,9 +293,204 @@ app.get('/health', (req, res) => {
res.status(200).json({
ok: true,
service: 'estados-hs',
port: Number(process.env.PORT || 0),
requireAuth: String(process.env.REQUIRE_AUTH || ''),
hsCredDocPath: String(process.env.HS_CRED_DOC_PATH || ''),
ts: new Date().toISOString(),
port: parsePort(process.env.PORT, null),
requireAuth: CONFIG.REQUIRE_AUTH,
hsCredDocPath: CONFIG.HS_CRED_DOC_PATH,
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)
app.get('/test', (req, res) => {
res.type('html').send(`<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>estados-hs · test</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial;margin:0;padding:24px;background:#0b1220;color:#e8eefc}
.card{max-width:720px;margin:0 auto;background:#111a2e;border:1px solid rgba(255,255,255,.08);border-radius:16px;padding:18px}
label{display:block;margin:12px 0 6px;font-size:13px;opacity:.85}
input,select,textarea,button{width:100%;padding:12px;border-radius:12px;border:1px solid rgba(255,255,255,.12);background:#0b1220;color:#e8eefc}
textarea{min-height:90px}
button{cursor:pointer;font-weight:700}
.row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.log{white-space:pre-wrap;background:#0b1220;border-radius:12px;border:1px solid rgba(255,255,255,.12);padding:12px;margin-top:12px;font-size:12px}
.muted{opacity:.7;font-size:12px}
</style>
</head>
<body>
<div class="card">
<h2 style="margin:0 0 8px">estados-hs · prueba</h2>
<div class="muted">POST /api/homeserve/change-status</div>
<label>Service Number</label>
<input id="serviceNumber" placeholder="28219874" />
<div class="row">
<div>
<label>Código estado</label>
<select id="statusCode">
<option value="303">303 · En espera de Cliente por aceptación Presupuesto</option>
<option value="307">307 · En espera de Profesional por fecha de inicio de trabajos</option>
<option value="313">313 · En espera de Profesional por secado de cala, pintura o parquet</option>
<option value="318">318 · En espera de Profesional por confirmación del Siniestro</option>
<option value="319">319 · En espera de Profesional por material</option>
<option value="320">320 · En espera de Profesional por espera de otro gremio</option>
<option value="321">321 · En espera de Profesional por presupuesto/valoración</option>
<option value="323">323 · En espera de Profesional por mejora del tiempo</option>
<option value="326">326 · En espera de Cliente por pago de Factura Contado/Franquicia</option>
<option value="336">336 · En espera de Profesional por avería en observación</option>
<option value="342">342 · En espera de Profesional pendiente cobro franquicia</option>
<option value="345">345 · En espera de Profesional en realización pendiente Terminar</option>
<option value="348" selected>348 · En espera de Cliente por indicaciones</option>
<option value="352">352 · En espera de Perjudicado por indicaciones</option>
</select>
</div>
<div>
<label>Fecha (DD/MM/AAAA) (opcional)</label>
<input id="dateString" placeholder="03/01/2026" />
</div>
</div>
<label>Observación (opcional)</label>
<textarea id="observation" placeholder="Texto..."></textarea>
<div style="height:10px"></div>
<button id="btn">Enviar</button>
<div class="log" id="log">Listo.</div>
</div>
<script>
const $ = (id)=>document.getElementById(id);
const log = (x)=>{ $('log').textContent = (typeof x==='string'?x:JSON.stringify(x,null,2)); };
$('btn').addEventListener('click', async () => {
try{
log('Enviando...');
const body = {
serviceNumber: $('serviceNumber').value.trim(),
newStatusValue: $('statusCode').value.trim(),
dateString: $('dateString').value.trim(),
observation: $('observation').value.trim()
};
const r = await fetch('/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));
}
});
</script>
</body>
</html>`);
});
// -----------------------------
// CAPROVER PORT FIX (80 + PORT)
// -----------------------------
const port80 = 80;
const envPort = parsePort(process.env.PORT, null);
const caproverPort = parsePort(process.env.CAPROVER_PORT, null);
const extraPorts = new Set([envPort, caproverPort].filter(Boolean));
extraPorts.delete(port80);
const servers = [];
function startListen(port) {
const server = app.listen(port, '0.0.0.0', () => {
console.log(`[estados-hs] listening on :${port}`);
});
servers.push(server);
}
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)'}`);
// 1) Puerto “seguro” para CapRover/NGINX
startListen(port80);
// 2) Puertos extra (por ejemplo 3000) si existen
for (const p of extraPorts) startListen(p);
// 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 (_) {}
}
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));