estados-homeserve/index.js

333 lines
11 KiB
JavaScript

'use strict';
const express = require('express');
const cors = require('cors');
const admin = require('firebase-admin');
const { chromium } = require('playwright');
const PORT = parseInt(process.env.PORT || '3000', 10);
// ====== CONFIG ======
const CONFIG = {
HOMESERVE_BASE_URL: process.env.HOMESERVE_BASE_URL || 'https://gestor.homeserve.es/',
// Firestore doc path que guarda credenciales HS
// Ejemplo: secrets/homeserve (collection/doc)
HS_CRED_DOC_PATH: process.env.HS_CRED_DOC_PATH || 'secrets/homeserve',
// Seguridad: si REQUIRE_AUTH=1, obliga a token Firebase (Authorization: Bearer <idToken>)
REQUIRE_AUTH: String(process.env.REQUIRE_AUTH || '1') === '1',
// Playwright
HEADLESS: String(process.env.HEADLESS || 'true') !== 'false',
// Selectores (ajustables por env si el portal cambia)
SEL: {
user: process.env.SEL_USER || 'input[type="text"], input[name="username"], input[id*="user"], input[autocomplete="username"]',
pass: process.env.SEL_PASS || 'input[type="password"], input[name="password"], input[id*="pass"], input[autocomplete="current-password"]',
submit: process.env.SEL_SUBMIT || 'button[type="submit"], button:has-text("Entrar"), button:has-text("Acceder"), button:has-text("Login")',
// búsqueda parte
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',
// cambio estado
statusDropdown: process.env.SEL_STATUS_DROPDOWN || 'select[name*="estado"], select[id*="estado"], select',
// opcional: input/selector para fecha seguimiento
followUpDate: process.env.SEL_FOLLOWUP_DATE || 'input[name*="fecha"], input[id*="fecha"], input[type="date"]',
// opcional: textarea nota
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")',
},
// Timeout
TIMEOUT_MS: parseInt(process.env.TIMEOUT_MS || '120000', 10),
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let busy = false;
// ====== FIREBASE ADMIN INIT ======
function initFirebaseAdmin() {
// Opción A: variables (como tus robots antiguos)
const hasEnvKey = !!process.env.FIREBASE_PRIVATE_KEY;
if (hasEnvKey) {
if (admin.apps.length === 0) {
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;
}
// Opción B: Application Default Credentials (GOOGLE_APPLICATION_CREDENTIALS)
// En CapRover puedes montar un archivo JSON y exportar GOOGLE_APPLICATION_CREDENTIALS
if (admin.apps.length === 0) {
admin.initializeApp({
credential: admin.credential.applicationDefault(),
});
}
}
function mustEnv(name) {
const v = process.env[name];
if (!v) throw new Error(`Missing env: ${name}`);
return v;
}
async function getHomeServeCreds(db) {
// Espera que el doc tenga campos tipo:
// { user: "...", pass: "..." } o { username: "...", password: "..." }
const snap = await db.doc(CONFIG.HS_CRED_DOC_PATH).get();
if (!snap.exists) {
throw new Error(`HS credentials doc not found: ${CONFIG.HS_CRED_DOC_PATH}`);
}
const d = snap.data() || {};
const user = (d.user || d.username || d.email || d.HOMESERVE_USER || '').toString().trim();
const pass = (d.pass || d.password || d.HOMESERVE_PASS || '').toString().trim();
if (!user || !pass) {
throw new Error(`HS credentials doc missing fields (need user/pass): ${CONFIG.HS_CRED_DOC_PATH}`);
}
return { user, pass };
}
async function verifyAuth(req) {
if (!CONFIG.REQUIRE_AUTH) return { ok: true, uid: null };
const h = req.headers.authorization || '';
const m = h.match(/^Bearer\s+(.+)$/i);
if (!m) {
return { ok: false, status: 401, error: 'Missing Authorization Bearer token' };
}
try {
const decoded = await admin.auth().verifyIdToken(m[1]);
return { ok: true, uid: decoded.uid };
} catch (e) {
return { ok: false, status: 401, error: 'Invalid token' };
}
}
// ====== PLAYWRIGHT FLOW ======
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, hsUser, hsPass) {
await page.goto(CONFIG.HOMESERVE_BASE_URL, { waitUntil: 'domcontentloaded', timeout: CONFIG.TIMEOUT_MS });
await page.waitForSelector(CONFIG.SEL.user, { timeout: 60000 });
await page.fill(CONFIG.SEL.user, hsUser);
await page.waitForSelector(CONFIG.SEL.pass, { timeout: 60000 });
await page.fill(CONFIG.SEL.pass, hsPass);
const btn = await page.$(CONFIG.SEL.submit);
if (btn) await btn.click();
else await page.keyboard.press('Enter');
// Importante: a veces HS no llega a networkidle (polling interno). Mejor esperar a DOM + un respiro.
await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.TIMEOUT_MS });
await sleep(1200);
}
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('domcontentloaded', { timeout: CONFIG.TIMEOUT_MS });
await sleep(1200);
}
await page.waitForSelector(CONFIG.SEL.openRow, { timeout: 60000 });
await page.click(CONFIG.SEL.openRow);
await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.TIMEOUT_MS });
await sleep(1200);
}
function normalizeDateDDMMYYYY(s) {
const v = String(s || '').trim();
if (!v) return '';
// acepta dd/MM/yyyy o yyyy-MM-dd
if (/^\d{2}\/\d{2}\/\d{4}$/.test(v)) return v;
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) {
const [y, m, d] = v.split('-');
return `${d}/${m}/${y}`;
}
return v; // lo dejamos tal cual si el usuario mete algo raro
}
async function setEstadoByCode(page, statusCode, followUpDate, note) {
const code = String(statusCode || '').trim();
if (!code) throw new Error('Missing statusCode');
await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: 60000 });
// 1) intenta por value exacto (lo normal si el portal usa códigos)
let selected = false;
try {
await page.selectOption(CONFIG.SEL.statusDropdown, { value: code });
selected = true;
} catch (_) {}
// 2) si no, intenta por label que contenga el código
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 hit = opts.find(o => {
const t = (o.textContent || '').trim();
// acepta "Texto (348)" o "348 - Texto" o similares
return t.includes(code) || t.startsWith(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}"`);
}
// Fecha seguimiento (opcional)
const dateStr = normalizeDateDDMMYYYY(followUpDate);
if (dateStr) {
const hasDate = await page.$(CONFIG.SEL.followUpDate);
if (hasDate) {
// si es input type="date" espera yyyy-MM-dd, pero muchos portales usan texto.
// Intentamos poner tal cual; si falla, no rompemos.
try {
await page.fill(CONFIG.SEL.followUpDate, dateStr);
} catch (_) {}
}
}
// Nota (opcional)
if (note) {
const ta = await page.$(CONFIG.SEL.noteTextarea);
if (ta) {
await page.fill(CONFIG.SEL.noteTextarea, String(note));
}
}
const save = await page.$(CONFIG.SEL.saveBtn);
if (!save) throw new Error('Save button not found');
await save.click();
await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.TIMEOUT_MS });
await sleep(1200);
}
// ====== EXPRESS SERVER ======
initFirebaseAdmin();
const db = admin.firestore();
const app = express();
app.use(cors());
app.use(express.json({ limit: '1mb' }));
// Para CapRover: NO 404 en /
app.get('/', (req, res) => {
res.status(200).json({ ok: true, service: 'estados-hs', ts: new Date().toISOString() });
});
app.get('/health', (req, res) => {
res.status(200).json({ ok: true, busy, ts: new Date().toISOString() });
});
/**
* POST /v1/homeserve/status
* Body:
* {
* "parteId": "12345678",
* "statusCode": "348",
* "followUpDate": "03/01/2026" // opcional (dd/MM/yyyy o yyyy-MM-dd)
* "observation": "texto..." // opcional
* }
*/
app.post('/v1/homeserve/status', async (req, res) => {
const auth = await verifyAuth(req);
if (!auth.ok) return res.status(auth.status).json({ ok: false, error: auth.error });
const parteId = (req.body?.parteId || req.body?.serviceNumber || req.body?.parte || '').toString().trim();
const statusCode = (req.body?.statusCode || req.body?.newStatusValue || req.body?.code || '').toString().trim();
const followUpDate = (req.body?.followUpDate || req.body?.dateString || '').toString().trim();
const observation = (req.body?.observation || req.body?.nota || req.body?.note || '').toString().trim();
if (!parteId || !statusCode) {
return res.status(400).json({
ok: false,
error: 'Missing parteId or statusCode',
example: { parteId: '28197832', statusCode: '348', followUpDate: '03/01/2026', observation: '...' },
});
}
if (busy) {
return res.status(409).json({ ok: false, error: 'BUSY', message: 'Server is processing another request' });
}
busy = true;
const startedAt = new Date().toISOString();
try {
const { user, pass } = await getHomeServeCreds(db);
await withBrowser(async (page) => {
await login(page, user, pass);
await openParte(page, parteId);
await setEstadoByCode(page, statusCode, followUpDate, observation);
});
return res.json({
ok: true,
parteId,
statusCode,
followUpDate: followUpDate || null,
observation: observation || null,
startedAt,
finishedAt: new Date().toISOString(),
uid: auth.uid,
});
} catch (e) {
return res.status(500).json({
ok: false,
error: String(e?.message || e),
startedAt,
finishedAt: new Date().toISOString(),
});
} finally {
busy = false;
}
});
app.listen(PORT, () => {
console.log(`[estados-hs] listening on :${PORT}`);
console.log(`[estados-hs] HS_CRED_DOC_PATH=${CONFIG.HS_CRED_DOC_PATH}`);
console.log(`[estados-hs] REQUIRE_AUTH=${CONFIG.REQUIRE_AUTH ? '1' : '0'}`);
});