Actualizar index.js
This commit is contained in:
parent
251397cdea
commit
848c2e5b69
536
index.js
536
index.js
|
|
@ -1,124 +1,200 @@
|
||||||
'use strict';
|
'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 express = require('express');
|
||||||
const cors = require('cors');
|
|
||||||
const admin = require('firebase-admin');
|
|
||||||
const { chromium } = require('playwright');
|
const { chromium } = require('playwright');
|
||||||
|
const admin = require('firebase-admin');
|
||||||
|
|
||||||
const PORT = parseInt(process.env.PORT || '3000', 10);
|
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';
|
||||||
|
|
||||||
// ====== CONFIG ======
|
// Selectores por defecto (puedes sobreescribirlos vía env o Firestore doc)
|
||||||
const CONFIG = {
|
const DEFAULT_SEL = {
|
||||||
HOMESERVE_BASE_URL: process.env.HOMESERVE_BASE_URL || 'https://gestor.homeserve.es/',
|
user: process.env.SEL_USER || 'input[type="text"]',
|
||||||
|
pass: process.env.SEL_PASS || 'input[type="password"]',
|
||||||
|
submit: process.env.SEL_SUBMIT || 'button[type="submit"]',
|
||||||
|
|
||||||
// Firestore doc path que guarda credenciales HS
|
searchBox: process.env.SEL_SEARCH_BOX || 'input[placeholder*="Buscar"], input[type="search"]',
|
||||||
// Ejemplo: secrets/homeserve (collection/doc)
|
searchBtn: process.env.SEL_SEARCH_BTN || 'button:has-text("Buscar"), button:has-text("Search")',
|
||||||
HS_CRED_DOC_PATH: process.env.HS_CRED_DOC_PATH || 'secrets/homeserve',
|
openRow: process.env.SEL_OPEN_ROW || 'table tbody tr:first-child',
|
||||||
|
|
||||||
// Seguridad: si REQUIRE_AUTH=1, obliga a token Firebase (Authorization: Bearer <idToken>)
|
// Ojo: aquí NO seleccionamos por "texto", sino por código (value o texto que contenga el code)
|
||||||
REQUIRE_AUTH: String(process.env.REQUIRE_AUTH || '1') === '1',
|
statusDropdown:
|
||||||
|
process.env.SEL_STATUS_DROPDOWN ||
|
||||||
|
'select[name*="estado"], select[id*="estado"], select:has(option)',
|
||||||
|
|
||||||
// Playwright
|
noteTextarea:
|
||||||
HEADLESS: String(process.env.HEADLESS || 'true') !== 'false',
|
process.env.SEL_NOTE_TEXTAREA ||
|
||||||
|
'textarea[name*="nota"], textarea[id*="nota"], textarea',
|
||||||
|
|
||||||
// Selectores (ajustables por env si el portal cambia)
|
saveBtn:
|
||||||
SEL: {
|
process.env.SEL_SAVE_BTN ||
|
||||||
user: process.env.SEL_USER || 'input[type="text"], input[name="username"], input[id*="user"], input[autocomplete="username"]',
|
'button:has-text("Guardar"), button:has-text("Save"), button:has-text("Actualizar")',
|
||||||
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));
|
function log(...args) {
|
||||||
|
console.log('[estados-hs]', ...args);
|
||||||
|
}
|
||||||
|
|
||||||
let busy = false;
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
// ====== 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({
|
admin.initializeApp({
|
||||||
credential: admin.credential.cert({
|
credential: admin.credential.cert({
|
||||||
projectId: mustEnv('FIREBASE_PROJECT_ID'),
|
projectId: process.env.FIREBASE_PROJECT_ID,
|
||||||
clientEmail: mustEnv('FIREBASE_CLIENT_EMAIL'),
|
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
|
||||||
privateKey: mustEnv('FIREBASE_PRIVATE_KEY').replace(/\\n/g, '\n'),
|
privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Opción B: Application Default Credentials (GOOGLE_APPLICATION_CREDENTIALS)
|
firestore = admin.firestore();
|
||||||
// En CapRover puedes montar un archivo JSON y exportar GOOGLE_APPLICATION_CREDENTIALS
|
return firestore;
|
||||||
if (admin.apps.length === 0) {
|
}
|
||||||
|
|
||||||
|
// 2) Application Default Credentials (por ejemplo con GOOGLE_APPLICATION_CREDENTIALS)
|
||||||
admin.initializeApp({
|
admin.initializeApp({
|
||||||
credential: admin.credential.applicationDefault(),
|
credential: admin.credential.applicationDefault(),
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function mustEnv(name) {
|
firestore = admin.firestore();
|
||||||
const v = process.env[name];
|
return firestore;
|
||||||
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) {
|
} catch (e) {
|
||||||
return { ok: false, status: 401, error: 'Invalid token' };
|
// Lo dejamos súper claro en logs
|
||||||
|
log('Firebase init error:', e?.message || e);
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ====== PLAYWRIGHT FLOW ======
|
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) {
|
async function withBrowser(fn) {
|
||||||
const browser = await chromium.launch({
|
const browser = await chromium.launch({
|
||||||
headless: CONFIG.HEADLESS,
|
headless: true,
|
||||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -132,202 +208,206 @@ async function withBrowser(fn) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login(page, hsUser, hsPass) {
|
async function loginHomeServe(page, creds) {
|
||||||
await page.goto(CONFIG.HOMESERVE_BASE_URL, { waitUntil: 'domcontentloaded', timeout: CONFIG.TIMEOUT_MS });
|
const { baseUrl, user, pass, selectors: SEL } = creds;
|
||||||
|
|
||||||
await page.waitForSelector(CONFIG.SEL.user, { timeout: 60000 });
|
await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: 120000 });
|
||||||
await page.fill(CONFIG.SEL.user, hsUser);
|
|
||||||
|
|
||||||
await page.waitForSelector(CONFIG.SEL.pass, { timeout: 60000 });
|
await page.waitForSelector(SEL.user, { timeout: 60000 });
|
||||||
await page.fill(CONFIG.SEL.pass, hsPass);
|
await page.fill(SEL.user, user);
|
||||||
|
|
||||||
const btn = await page.$(CONFIG.SEL.submit);
|
await page.waitForSelector(SEL.pass, { timeout: 60000 });
|
||||||
|
await page.fill(SEL.pass, pass);
|
||||||
|
|
||||||
|
const btn = await page.$(SEL.submit);
|
||||||
if (btn) await btn.click();
|
if (btn) await btn.click();
|
||||||
else await page.keyboard.press('Enter');
|
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('networkidle', { timeout: 120000 });
|
||||||
await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.TIMEOUT_MS });
|
|
||||||
await sleep(1200);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openParte(page, parteId) {
|
async function openParte(page, parteId, SEL) {
|
||||||
const hasSearch = await page.$(CONFIG.SEL.searchBox);
|
const hasSearch = await page.$(SEL.searchBox);
|
||||||
if (hasSearch) {
|
if (hasSearch) {
|
||||||
await page.fill(CONFIG.SEL.searchBox, String(parteId));
|
await page.fill(SEL.searchBox, String(parteId));
|
||||||
const btn = await page.$(CONFIG.SEL.searchBtn);
|
const btn = await page.$(SEL.searchBtn);
|
||||||
if (btn) await btn.click();
|
if (btn) await btn.click();
|
||||||
else await page.keyboard.press('Enter');
|
else await page.keyboard.press('Enter');
|
||||||
|
|
||||||
await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.TIMEOUT_MS });
|
await page.waitForLoadState('networkidle', { timeout: 120000 });
|
||||||
await sleep(1200);
|
await sleep(1200);
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.waitForSelector(CONFIG.SEL.openRow, { timeout: 60000 });
|
await page.waitForSelector(SEL.openRow, { timeout: 60000 });
|
||||||
await page.click(CONFIG.SEL.openRow);
|
await page.click(SEL.openRow);
|
||||||
|
|
||||||
await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.TIMEOUT_MS });
|
await page.waitForLoadState('networkidle', { timeout: 120000 });
|
||||||
await sleep(1200);
|
await sleep(1200);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDateDDMMYYYY(s) {
|
/**
|
||||||
const v = String(s || '').trim();
|
* Selecciona estado por "code".
|
||||||
if (!v) return '';
|
* - 1) intenta selectOption por value==code
|
||||||
// acepta dd/MM/yyyy o yyyy-MM-dd
|
* - 2) intenta encontrar option cuyo texto contenga el code
|
||||||
if (/^\d{2}\/\d{2}\/\d{4}$/.test(v)) return v;
|
* - 3) dispara change
|
||||||
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) {
|
*/
|
||||||
const [y, m, d] = v.split('-');
|
async function selectStatusByCode(page, SEL, code) {
|
||||||
return `${d}/${m}/${y}`;
|
const statusCode = safeStr(code).trim();
|
||||||
}
|
if (!statusCode) throw new Error('Missing status code');
|
||||||
return v; // lo dejamos tal cual si el usuario mete algo raro
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setEstadoByCode(page, statusCode, followUpDate, note) {
|
await page.waitForSelector(SEL.statusDropdown, { timeout: 60000 });
|
||||||
const code = String(statusCode || '').trim();
|
|
||||||
if (!code) throw new Error('Missing statusCode');
|
|
||||||
|
|
||||||
await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: 60000 });
|
// intento 1: value = code
|
||||||
|
|
||||||
// 1) intenta por value exacto (lo normal si el portal usa códigos)
|
|
||||||
let selected = false;
|
|
||||||
try {
|
try {
|
||||||
await page.selectOption(CONFIG.SEL.statusDropdown, { value: code });
|
await page.selectOption(SEL.statusDropdown, { value: statusCode });
|
||||||
selected = true;
|
return;
|
||||||
} catch (_) {}
|
} catch (_) {
|
||||||
|
// sigue
|
||||||
// 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)
|
// intento 2: buscar option por texto que contenga el code
|
||||||
const dateStr = normalizeDateDDMMYYYY(followUpDate);
|
const ok = await page.evaluate(({ sel, code }) => {
|
||||||
if (dateStr) {
|
const s = document.querySelector(sel);
|
||||||
const hasDate = await page.$(CONFIG.SEL.followUpDate);
|
if (!s) return { ok: false, why: 'select not found' };
|
||||||
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)
|
const opts = Array.from(s.querySelectorAll('option'));
|
||||||
if (note) {
|
const hit =
|
||||||
const ta = await page.$(CONFIG.SEL.noteTextarea);
|
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) {
|
if (ta) {
|
||||||
await page.fill(CONFIG.SEL.noteTextarea, String(note));
|
await page.fill(SEL.noteTextarea, String(noteMaybe));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const save = await page.$(CONFIG.SEL.saveBtn);
|
const save = await page.$(SEL.saveBtn);
|
||||||
if (!save) throw new Error('Save button not found');
|
if (!save) throw new Error('Save button not found');
|
||||||
await save.click();
|
|
||||||
|
|
||||||
await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.TIMEOUT_MS });
|
await save.click();
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 120000 });
|
||||||
await sleep(1200);
|
await sleep(1200);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ====== EXPRESS SERVER ======
|
// ---------------- Express app ----------------
|
||||||
initFirebaseAdmin();
|
|
||||||
const db = admin.firestore();
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
|
||||||
app.use(express.json({ limit: '1mb' }));
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
|
||||||
// Para CapRover: NO 404 en /
|
// ✅ Importante para CapRover: / debe devolver 200 (si no, te mata el contenedor)
|
||||||
app.get('/', (req, res) => {
|
app.get('/', (req, res) => {
|
||||||
res.status(200).json({ ok: true, service: 'estados-hs', ts: new Date().toISOString() });
|
res.status(200).send('ok');
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.status(200).json({ ok: true, busy, ts: new Date().toISOString() });
|
res.status(200).json({
|
||||||
|
ok: true,
|
||||||
|
service: 'estados-hs',
|
||||||
|
requireAuth: REQUIRE_AUTH,
|
||||||
|
hsCredDocPath: HS_CRED_DOC_PATH,
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
// Endpoint principal
|
||||||
* POST /v1/homeserve/status
|
app.post('/api/homeserve/change-status', async (req, res) => {
|
||||||
* 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();
|
const startedAt = new Date().toISOString();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { user, pass } = await getHomeServeCreds(db);
|
// Auth si está activado
|
||||||
|
let decoded = null;
|
||||||
|
if (REQUIRE_AUTH) {
|
||||||
|
decoded = await verifyFirebaseIdToken(req);
|
||||||
|
}
|
||||||
|
|
||||||
await withBrowser(async (page) => {
|
const serviceNumberRaw = req.body?.serviceNumber ?? req.body?.parteId ?? req.body?.parte ?? req.body?.codigo;
|
||||||
await login(page, user, pass);
|
const codeRaw = req.body?.code ?? req.body?.statusCode ?? req.body?.newStatusValue ?? req.body?.selectedCode;
|
||||||
await openParte(page, parteId);
|
const dateString = safeStr(req.body?.dateString ?? '').trim(); // no siempre se usa en portal, pero lo guardamos en nota si quieres
|
||||||
await setEstadoByCode(page, statusCode, followUpDate, observation);
|
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({
|
return res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
parteId,
|
startedAtISO: startedAt,
|
||||||
statusCode,
|
finishedAtISO: new Date().toISOString(),
|
||||||
followUpDate: followUpDate || null,
|
serviceNumber,
|
||||||
observation: observation || null,
|
code,
|
||||||
startedAt,
|
requireAuth: REQUIRE_AUTH,
|
||||||
finishedAt: new Date().toISOString(),
|
uid: decoded?.uid || null,
|
||||||
uid: auth.uid,
|
result,
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
const msg = safeStr(e?.message || e);
|
||||||
|
log('ERROR change-status:', msg);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: String(e?.message || e),
|
error: msg,
|
||||||
startedAt,
|
startedAtISO: startedAt,
|
||||||
finishedAt: new Date().toISOString(),
|
finishedAtISO: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
// 404 fallback (para que quede claro)
|
||||||
console.log(`[estados-hs] listening on :${PORT}`);
|
app.use((req, res) => {
|
||||||
console.log(`[estados-hs] HS_CRED_DOC_PATH=${CONFIG.HS_CRED_DOC_PATH}`);
|
res.status(404).json({ ok: false, error: 'Not found' });
|
||||||
console.log(`[estados-hs] REQUIRE_AUTH=${CONFIG.REQUIRE_AUTH ? '1' : '0'}`);
|
});
|
||||||
});
|
|
||||||
|
// ---------------- 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'));
|
||||||
Loading…
Reference in New Issue