estados-homeserve/index.js

413 lines
12 KiB
JavaScript

'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 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"]',
submit: process.env.SEL_SUBMIT || 'button[type="submit"]',
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í 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)',
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")',
};
function log(...args) {
console.log('[estados-hs]', ...args);
}
function safeStr(v) {
return (v == null) ? '' : String(v);
}
function stripDigits(v) {
return safeStr(v).replace(/\D+/g, '');
}
// ---------------- Firebase init ----------------
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');
}
admin.initializeApp({
credential: admin.credential.cert({
projectId: process.env.FIREBASE_PROJECT_ID,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'),
}),
});
firestore = admin.firestore();
return firestore;
}
// 2) Application Default Credentials (por ejemplo con GOOGLE_APPLICATION_CREDENTIALS)
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;
}
}
async function verifyFirebaseIdToken(req) {
const authHeader = req.headers.authorization || '';
const m = authHeader.match(/^Bearer\s+(.+)$/i);
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)
const decoded = await admin.auth().verifyIdToken(idToken);
return decoded;
}
// ---------------- HomeServe creds cache ----------------
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)
async function loadHomeServeCreds() {
const now = Date.now();
if (cachedCreds && (now - cachedCredsAt) < CREDS_TTL_MS) return cachedCreds;
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;
let fromFirestore = null;
try {
const ref = db.doc(HS_CRED_DOC_PATH);
const snap = await ref.get();
if (snap.exists) fromFirestore = snap.data() || {};
} catch (e) {
log('Warning: cannot read HS creds from Firestore:', e?.message || e);
}
const baseUrl =
safeStr(envBaseUrl || fromFirestore?.baseUrl || 'https://gestor.homeserve.es/').trim();
const user =
safeStr(envUser || fromFirestore?.user || fromFirestore?.username || '').trim();
const pass =
safeStr(envPass || fromFirestore?.pass || fromFirestore?.password || '').trim();
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.`
);
}
cachedCreds = { baseUrl, user, pass, selectors };
cachedCredsAt = now;
return cachedCreds;
}
// ---------------- Playwright helpers ----------------
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function withBrowser(fn) {
const browser = await chromium.launch({
headless: true,
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) {
const { baseUrl, user, pass, selectors: SEL } = creds;
await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: 120000 });
await page.waitForSelector(SEL.user, { timeout: 60000 });
await page.fill(SEL.user, user);
await page.waitForSelector(SEL.pass, { timeout: 60000 });
await page.fill(SEL.pass, pass);
const btn = await page.$(SEL.submit);
if (btn) await btn.click();
else await page.keyboard.press('Enter');
await page.waitForLoadState('networkidle', { timeout: 120000 });
}
async function openParte(page, parteId, SEL) {
const hasSearch = await page.$(SEL.searchBox);
if (hasSearch) {
await page.fill(SEL.searchBox, String(parteId));
const btn = await page.$(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(SEL.openRow, { timeout: 60000 });
await page.click(SEL.openRow);
await page.waitForLoadState('networkidle', { timeout: 120000 });
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
}
// intento 2: buscar option por texto que contenga el code
const ok = await page.evaluate(({ sel, code }) => {
const s = document.querySelector(sel);
if (!s) return { ok: false, why: 'select not found' };
const opts = Array.from(s.querySelectorAll('option'));
const hit =
opts.find(o => (o.value || '').trim() === code) ||
opts.find(o => ((o.textContent || '').replace(/\s+/g, ' ')).includes(code));
if (!hit) return { ok: false, why: 'no matching option' };
s.value = hit.value;
s.dispatchEvent(new Event('change', { bubbles: true }));
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}"`);
}
}
async function setEstado(page, SEL, code, noteMaybe) {
await selectStatusByCode(page, SEL, code);
if (noteMaybe) {
const ta = await page.$(SEL.noteTextarea);
if (ta) {
await page.fill(SEL.noteTextarea, String(noteMaybe));
}
}
const save = await page.$(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 ----------------
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');
});
app.get('/health', (req, res) => {
res.status(200).json({
ok: true,
service: 'estados-hs',
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);
}
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 observation = safeStr(req.body?.observation ?? req.body?.note ?? '').trim();
const serviceNumber = stripDigits(serviceNumberRaw);
const code = stripDigits(codeRaw) || safeStr(codeRaw).trim();
if (!serviceNumber || serviceNumber.length < 5) {
return res.status(400).json({ ok: false, error: 'Invalid serviceNumber' });
}
if (!code) {
return res.status(400).json({ ok: false, error: 'Missing status code' });
}
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 loginHomeServe(page, creds);
await openParte(page, serviceNumber, SEL);
await setEstado(page, SEL, code, finalNote || null);
return { ok: true };
});
return res.json({
ok: true,
startedAtISO: startedAt,
finishedAtISO: new Date().toISOString(),
serviceNumber,
code,
requireAuth: REQUIRE_AUTH,
uid: decoded?.uid || null,
result,
});
} catch (e) {
const msg = safeStr(e?.message || e);
log('ERROR change-status:', msg);
return res.status(500).json({
ok: false,
error: msg,
startedAtISO: startedAt,
finishedAtISO: new Date().toISOString(),
});
}
});
// 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'}`);
});
function shutdown(signal) {
log(`received ${signal}, shutting down...`);
server.close(() => {
process.exit(0);
});
// por si algo se queda colgado
setTimeout(() => process.exit(0), 3000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));