Actualizar index.js
This commit is contained in:
parent
848c2e5b69
commit
879d1a711f
103
index.js
103
index.js
|
|
@ -1,48 +1,13 @@
|
||||||
'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 { chromium } = require('playwright');
|
const { chromium } = require('playwright');
|
||||||
const admin = require('firebase-admin');
|
const admin = require('firebase-admin');
|
||||||
|
|
||||||
const PORT = parseInt(process.env.PORT || '3000', 10);
|
const PORT = parseInt(process.env.PORT || process.env.CAPROVER_PORT || '80', 10);
|
||||||
const REQUIRE_AUTH = String(process.env.REQUIRE_AUTH || '0') === '1';
|
const REQUIRE_AUTH = String(process.env.REQUIRE_AUTH || '0') === '1';
|
||||||
const HS_CRED_DOC_PATH = process.env.HS_CRED_DOC_PATH || 'secrets/homeserve';
|
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 = {
|
const DEFAULT_SEL = {
|
||||||
user: process.env.SEL_USER || 'input[type="text"]',
|
user: process.env.SEL_USER || 'input[type="text"]',
|
||||||
pass: process.env.SEL_PASS || 'input[type="password"]',
|
pass: process.env.SEL_PASS || 'input[type="password"]',
|
||||||
|
|
@ -52,7 +17,6 @@ const DEFAULT_SEL = {
|
||||||
searchBtn: process.env.SEL_SEARCH_BTN || 'button:has-text("Buscar"), button:has-text("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',
|
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:
|
statusDropdown:
|
||||||
process.env.SEL_STATUS_DROPDOWN ||
|
process.env.SEL_STATUS_DROPDOWN ||
|
||||||
'select[name*="estado"], select[id*="estado"], select:has(option)',
|
'select[name*="estado"], select[id*="estado"], select:has(option)',
|
||||||
|
|
@ -85,14 +49,12 @@ let firestore = null;
|
||||||
function initFirebaseOnce() {
|
function initFirebaseOnce() {
|
||||||
if (firestore) return firestore;
|
if (firestore) return firestore;
|
||||||
|
|
||||||
// Intentamos init de varias formas para que no te "explote" según el entorno
|
|
||||||
try {
|
try {
|
||||||
if (admin.apps && admin.apps.length) {
|
if (admin.apps && admin.apps.length) {
|
||||||
firestore = admin.firestore();
|
firestore = admin.firestore();
|
||||||
return firestore;
|
return firestore;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) Con service account por envs (como tenías antes)
|
|
||||||
if (process.env.FIREBASE_PRIVATE_KEY) {
|
if (process.env.FIREBASE_PRIVATE_KEY) {
|
||||||
if (!process.env.FIREBASE_PROJECT_ID || !process.env.FIREBASE_CLIENT_EMAIL) {
|
if (!process.env.FIREBASE_PROJECT_ID || !process.env.FIREBASE_CLIENT_EMAIL) {
|
||||||
throw new Error('Missing env: FIREBASE_PROJECT_ID or FIREBASE_CLIENT_EMAIL');
|
throw new Error('Missing env: FIREBASE_PROJECT_ID or FIREBASE_CLIENT_EMAIL');
|
||||||
|
|
@ -110,16 +72,11 @@ function initFirebaseOnce() {
|
||||||
return firestore;
|
return firestore;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Application Default Credentials (por ejemplo con GOOGLE_APPLICATION_CREDENTIALS)
|
admin.initializeApp({ credential: admin.credential.applicationDefault() });
|
||||||
admin.initializeApp({
|
|
||||||
credential: admin.credential.applicationDefault(),
|
|
||||||
});
|
|
||||||
|
|
||||||
firestore = admin.firestore();
|
firestore = admin.firestore();
|
||||||
return firestore;
|
return firestore;
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Lo dejamos súper claro en logs
|
|
||||||
log('Firebase init error:', e?.message || e);
|
log('Firebase init error:', e?.message || e);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
@ -131,8 +88,7 @@ async function verifyFirebaseIdToken(req) {
|
||||||
if (!m) throw new Error('Missing Authorization Bearer token');
|
if (!m) throw new Error('Missing Authorization Bearer token');
|
||||||
|
|
||||||
const idToken = m[1];
|
const idToken = m[1];
|
||||||
const db = initFirebaseOnce(); // asegura admin init
|
initFirebaseOnce();
|
||||||
void db; // (solo para dejar claro que lo usamos para init)
|
|
||||||
const decoded = await admin.auth().verifyIdToken(idToken);
|
const decoded = await admin.auth().verifyIdToken(idToken);
|
||||||
return decoded;
|
return decoded;
|
||||||
}
|
}
|
||||||
|
|
@ -141,7 +97,7 @@ async function verifyFirebaseIdToken(req) {
|
||||||
|
|
||||||
let cachedCreds = null;
|
let cachedCreds = null;
|
||||||
let cachedCredsAt = 0;
|
let cachedCredsAt = 0;
|
||||||
const CREDS_TTL_MS = 60 * 1000; // 1 min (para que si cambias algo en Firestore lo pille rápido)
|
const CREDS_TTL_MS = 60 * 1000;
|
||||||
|
|
||||||
async function loadHomeServeCreds() {
|
async function loadHomeServeCreds() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
@ -149,7 +105,6 @@ async function loadHomeServeCreds() {
|
||||||
|
|
||||||
const db = initFirebaseOnce();
|
const db = initFirebaseOnce();
|
||||||
|
|
||||||
// Env override (si quieres, pero por defecto: Firestore)
|
|
||||||
const envUser = process.env.HOMESERVE_USER;
|
const envUser = process.env.HOMESERVE_USER;
|
||||||
const envPass = process.env.HOMESERVE_PASS;
|
const envPass = process.env.HOMESERVE_PASS;
|
||||||
const envBaseUrl = process.env.HOMESERVE_BASE_URL;
|
const envBaseUrl = process.env.HOMESERVE_BASE_URL;
|
||||||
|
|
@ -172,14 +127,11 @@ async function loadHomeServeCreds() {
|
||||||
const pass =
|
const pass =
|
||||||
safeStr(envPass || fromFirestore?.pass || fromFirestore?.password || '').trim();
|
safeStr(envPass || fromFirestore?.pass || fromFirestore?.password || '').trim();
|
||||||
|
|
||||||
const selectors = {
|
const selectors = { ...DEFAULT_SEL, ...(fromFirestore?.selectors || {}) };
|
||||||
...DEFAULT_SEL,
|
|
||||||
...(fromFirestore?.selectors || {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!user || !pass) {
|
if (!user || !pass) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`HomeServe credentials missing. Set them in Firestore doc "${HS_CRED_DOC_PATH}" (fields: user, pass) or env HOMESERVE_USER/HOMESERVE_PASS.`
|
`HomeServe credentials missing. Put user/pass in Firestore doc "${HS_CRED_DOC_PATH}" or set HOMESERVE_USER/HOMESERVE_PASS envs.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -245,28 +197,18 @@ async function openParte(page, parteId, SEL) {
|
||||||
await sleep(1200);
|
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) {
|
async function selectStatusByCode(page, SEL, code) {
|
||||||
const statusCode = safeStr(code).trim();
|
const statusCode = safeStr(code).trim();
|
||||||
if (!statusCode) throw new Error('Missing status code');
|
if (!statusCode) throw new Error('Missing status code');
|
||||||
|
|
||||||
await page.waitForSelector(SEL.statusDropdown, { timeout: 60000 });
|
await page.waitForSelector(SEL.statusDropdown, { timeout: 60000 });
|
||||||
|
|
||||||
// intento 1: value = code
|
|
||||||
try {
|
try {
|
||||||
await page.selectOption(SEL.statusDropdown, { value: statusCode });
|
await page.selectOption(SEL.statusDropdown, { value: statusCode });
|
||||||
return;
|
return;
|
||||||
} catch (_) {
|
} catch (_) {}
|
||||||
// sigue
|
|
||||||
}
|
|
||||||
|
|
||||||
// intento 2: buscar option por texto que contenga el code
|
const r = await page.evaluate(({ sel, code }) => {
|
||||||
const ok = await page.evaluate(({ sel, code }) => {
|
|
||||||
const s = document.querySelector(sel);
|
const s = document.querySelector(sel);
|
||||||
if (!s) return { ok: false, why: 'select not found' };
|
if (!s) return { ok: false, why: 'select not found' };
|
||||||
|
|
||||||
|
|
@ -282,9 +224,7 @@ async function selectStatusByCode(page, SEL, code) {
|
||||||
return { ok: true, value: hit.value, text: (hit.textContent || '').trim() };
|
return { ok: true, value: hit.value, text: (hit.textContent || '').trim() };
|
||||||
}, { sel: SEL.statusDropdown, code: statusCode });
|
}, { sel: SEL.statusDropdown, code: statusCode });
|
||||||
|
|
||||||
if (!ok || !ok.ok) {
|
if (!r || !r.ok) throw new Error(`No matching status option for code "${statusCode}"`);
|
||||||
throw new Error(`No matching status option for code "${statusCode}"`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setEstado(page, SEL, code, noteMaybe) {
|
async function setEstado(page, SEL, code, noteMaybe) {
|
||||||
|
|
@ -292,9 +232,7 @@ async function setEstado(page, SEL, code, noteMaybe) {
|
||||||
|
|
||||||
if (noteMaybe) {
|
if (noteMaybe) {
|
||||||
const ta = await page.$(SEL.noteTextarea);
|
const ta = await page.$(SEL.noteTextarea);
|
||||||
if (ta) {
|
if (ta) await page.fill(SEL.noteTextarea, String(noteMaybe));
|
||||||
await page.fill(SEL.noteTextarea, String(noteMaybe));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const save = await page.$(SEL.saveBtn);
|
const save = await page.$(SEL.saveBtn);
|
||||||
|
|
@ -310,7 +248,6 @@ async function setEstado(page, SEL, code, noteMaybe) {
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json({ limit: '1mb' }));
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
|
||||||
// ✅ Importante para CapRover: / debe devolver 200 (si no, te mata el contenedor)
|
|
||||||
app.get('/', (req, res) => {
|
app.get('/', (req, res) => {
|
||||||
res.status(200).send('ok');
|
res.status(200).send('ok');
|
||||||
});
|
});
|
||||||
|
|
@ -319,18 +256,17 @@ app.get('/health', (req, res) => {
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
ok: true,
|
ok: true,
|
||||||
service: 'estados-hs',
|
service: 'estados-hs',
|
||||||
|
port: PORT,
|
||||||
requireAuth: REQUIRE_AUTH,
|
requireAuth: REQUIRE_AUTH,
|
||||||
hsCredDocPath: HS_CRED_DOC_PATH,
|
hsCredDocPath: HS_CRED_DOC_PATH,
|
||||||
ts: new Date().toISOString(),
|
ts: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Endpoint principal
|
|
||||||
app.post('/api/homeserve/change-status', async (req, res) => {
|
app.post('/api/homeserve/change-status', async (req, res) => {
|
||||||
const startedAt = new Date().toISOString();
|
const startedAt = new Date().toISOString();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Auth si está activado
|
|
||||||
let decoded = null;
|
let decoded = null;
|
||||||
if (REQUIRE_AUTH) {
|
if (REQUIRE_AUTH) {
|
||||||
decoded = await verifyFirebaseIdToken(req);
|
decoded = await verifyFirebaseIdToken(req);
|
||||||
|
|
@ -338,7 +274,7 @@ app.post('/api/homeserve/change-status', async (req, res) => {
|
||||||
|
|
||||||
const serviceNumberRaw = req.body?.serviceNumber ?? req.body?.parteId ?? req.body?.parte ?? req.body?.codigo;
|
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 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 dateString = safeStr(req.body?.dateString ?? '').trim();
|
||||||
const observation = safeStr(req.body?.observation ?? req.body?.note ?? '').trim();
|
const observation = safeStr(req.body?.observation ?? req.body?.note ?? '').trim();
|
||||||
|
|
||||||
const serviceNumber = stripDigits(serviceNumberRaw);
|
const serviceNumber = stripDigits(serviceNumberRaw);
|
||||||
|
|
@ -354,14 +290,12 @@ app.post('/api/homeserve/change-status', async (req, res) => {
|
||||||
const creds = await loadHomeServeCreds();
|
const creds = await loadHomeServeCreds();
|
||||||
const SEL = creds.selectors;
|
const SEL = creds.selectors;
|
||||||
|
|
||||||
// Nota final (si quieres incluir fecha)
|
|
||||||
const finalNote = [observation, dateString ? `Fecha: ${dateString}` : ''].filter(Boolean).join(' | ');
|
const finalNote = [observation, dateString ? `Fecha: ${dateString}` : ''].filter(Boolean).join(' | ');
|
||||||
|
|
||||||
const result = await withBrowser(async (page) => {
|
await withBrowser(async (page) => {
|
||||||
await loginHomeServe(page, creds);
|
await loginHomeServe(page, creds);
|
||||||
await openParte(page, serviceNumber, SEL);
|
await openParte(page, serviceNumber, SEL);
|
||||||
await setEstado(page, SEL, code, finalNote || null);
|
await setEstado(page, SEL, code, finalNote || null);
|
||||||
return { ok: true };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
|
|
@ -370,9 +304,7 @@ app.post('/api/homeserve/change-status', async (req, res) => {
|
||||||
finishedAtISO: new Date().toISOString(),
|
finishedAtISO: new Date().toISOString(),
|
||||||
serviceNumber,
|
serviceNumber,
|
||||||
code,
|
code,
|
||||||
requireAuth: REQUIRE_AUTH,
|
|
||||||
uid: decoded?.uid || null,
|
uid: decoded?.uid || null,
|
||||||
result,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -387,25 +319,20 @@ app.post('/api/homeserve/change-status', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 404 fallback (para que quede claro)
|
|
||||||
app.use((req, res) => {
|
app.use((req, res) => {
|
||||||
res.status(404).json({ ok: false, error: 'Not found' });
|
res.status(404).json({ ok: false, error: 'Not found' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------- start + graceful shutdown ----------------
|
|
||||||
|
|
||||||
const server = app.listen(PORT, () => {
|
const server = app.listen(PORT, () => {
|
||||||
log(`listening on :${PORT}`);
|
log(`listening on :${PORT}`);
|
||||||
log(`HS_CRED_DOC_PATH=${HS_CRED_DOC_PATH}`);
|
log(`HS_CRED_DOC_PATH=${HS_CRED_DOC_PATH}`);
|
||||||
log(`REQUIRE_AUTH=${REQUIRE_AUTH ? '1' : '0'}`);
|
log(`REQUIRE_AUTH=${REQUIRE_AUTH ? '1' : '0'}`);
|
||||||
|
log(`ENV PORT=${process.env.PORT || '(unset)'} CAPROVER_PORT=${process.env.CAPROVER_PORT || '(unset)'}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
function shutdown(signal) {
|
function shutdown(signal) {
|
||||||
log(`received ${signal}, shutting down...`);
|
log(`received ${signal}, shutting down...`);
|
||||||
server.close(() => {
|
server.close(() => process.exit(0));
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
// por si algo se queda colgado
|
|
||||||
setTimeout(() => process.exit(0), 3000).unref();
|
setTimeout(() => process.exit(0), 3000).unref();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue