Actualizar index.js
This commit is contained in:
parent
4b57416af0
commit
251397cdea
478
index.js
478
index.js
|
|
@ -1,166 +1,124 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const helmet = require('helmet');
|
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const { chromium } = require('playwright');
|
|
||||||
const admin = require('firebase-admin');
|
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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------- helpers --------------------
|
|
||||||
function mustEnv(name) {
|
function mustEnv(name) {
|
||||||
const v = process.env[name];
|
const v = process.env[name];
|
||||||
if (!v) throw new Error(`Missing env: ${name}`);
|
if (!v) throw new Error(`Missing env: ${name}`);
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
function optEnv(name, fallback) {
|
async function getHomeServeCreds(db) {
|
||||||
const v = process.env[name];
|
// Espera que el doc tenga campos tipo:
|
||||||
return (v === undefined || v === null || v === '') ? fallback : v;
|
// { user: "...", pass: "..." } o { username: "...", password: "..." }
|
||||||
}
|
const snap = await db.doc(CONFIG.HS_CRED_DOC_PATH).get();
|
||||||
|
|
||||||
function sleep(ms) {
|
|
||||||
return new Promise((r) => setTimeout(r, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------- Firebase Admin --------------------
|
|
||||||
function initFirebaseAdmin() {
|
|
||||||
if (admin.apps.length) return;
|
|
||||||
|
|
||||||
const projectId = mustEnv('FIREBASE_PROJECT_ID');
|
|
||||||
const clientEmail = mustEnv('FIREBASE_CLIENT_EMAIL');
|
|
||||||
const privateKeyRaw = mustEnv('FIREBASE_PRIVATE_KEY');
|
|
||||||
|
|
||||||
admin.initializeApp({
|
|
||||||
credential: admin.credential.cert({
|
|
||||||
projectId,
|
|
||||||
clientEmail,
|
|
||||||
privateKey: privateKeyRaw.replace(/\\n/g, '\n'),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function verifyFirebaseIdTokenIfPresent(req) {
|
|
||||||
// Si quieres obligar a auth: REQUIRE_AUTH=1
|
|
||||||
const requireAuth = optEnv('REQUIRE_AUTH', '1') === '1';
|
|
||||||
|
|
||||||
const auth = req.headers.authorization || '';
|
|
||||||
const m = auth.match(/^Bearer\s+(.+)$/i);
|
|
||||||
const token = m ? m[1] : null;
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
if (requireAuth) throw new Error('Missing Authorization Bearer token');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const decoded = await admin.auth().verifyIdToken(token);
|
|
||||||
// Opcional: lista blanca de UID
|
|
||||||
const allowed = (process.env.ALLOWED_UIDS || '')
|
|
||||||
.split(',')
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
if (allowed.length && !allowed.includes(decoded.uid)) {
|
|
||||||
throw new Error('User not allowed (uid not in ALLOWED_UIDS)');
|
|
||||||
}
|
|
||||||
|
|
||||||
return decoded;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------- HomeServe Status Map (tu Swift) --------------------
|
|
||||||
const STATUS_CODE_MAP = {
|
|
||||||
"303": ["En espera de Cliente por aceptación Presupuesto"],
|
|
||||||
"307": ["En espera de Profesional por fecha de inicio de trabajos"],
|
|
||||||
"313": ["En espera de Profesional por secado de cala, pintura o parquet"],
|
|
||||||
"318": ["En espera de Profesional por confirmación del Siniestro"],
|
|
||||||
"319": ["En espera de Profesional por material"],
|
|
||||||
"320": ["En espera de Profesional por espera de otro gremio"],
|
|
||||||
"321": ["En espera de Profesional por presupuesto/valoración"],
|
|
||||||
"323": ["En espera de Profesional por mejora del tiempo"],
|
|
||||||
"326": ["En espera de Cliente por pago de Factura Contado/Franquicia"],
|
|
||||||
"336": ["En espera de Profesional por avería en observación"],
|
|
||||||
"342": ["En espera de Profesional pendiente cobro franquicia"],
|
|
||||||
"345": ["En espera de Profesional en realización pendiente Terminar"],
|
|
||||||
"348": ["En espera de Cliente por indicaciones"],
|
|
||||||
"352": ["En espera de Perjudicado por indicaciones"],
|
|
||||||
};
|
|
||||||
|
|
||||||
// -------------------- Config --------------------
|
|
||||||
const CONFIG = {
|
|
||||||
PORT: parseInt(optEnv('PORT', '3000'), 10),
|
|
||||||
|
|
||||||
// dónde guardas las credenciales de HomeServe en Firestore
|
|
||||||
// Formato: "collection/doc" o "collection/doc/subcollection/doc"
|
|
||||||
HS_CRED_DOC_PATH: optEnv('HS_CRED_DOC_PATH', 'secrets/homeserve'),
|
|
||||||
|
|
||||||
// Si quieres fallback por ENV (por si firestore no está listo)
|
|
||||||
HOMESERVE_BASE_URL: optEnv('HOMESERVE_BASE_URL', 'https://gestor.homeserve.es/'),
|
|
||||||
HOMESERVE_USER: process.env.HOMESERVE_USER || null,
|
|
||||||
HOMESERVE_PASS: process.env.HOMESERVE_PASS || null,
|
|
||||||
|
|
||||||
// Seguridad extra (si no quieres usar Firebase auth):
|
|
||||||
// pon API_KEY y el HTML mandará X-API-Key
|
|
||||||
API_KEY: process.env.API_KEY || null,
|
|
||||||
|
|
||||||
// Selectores (ajustables por env)
|
|
||||||
SEL: {
|
|
||||||
user: optEnv('SEL_USER', 'input[type="text"]'),
|
|
||||||
pass: optEnv('SEL_PASS', 'input[type="password"]'),
|
|
||||||
submit: optEnv('SEL_SUBMIT', 'button[type="submit"]'),
|
|
||||||
|
|
||||||
searchBox: optEnv('SEL_SEARCH_BOX', 'input[placeholder*="Buscar"], input[type="search"]'),
|
|
||||||
searchBtn: optEnv('SEL_SEARCH_BTN', 'button:has-text("Buscar"), button:has-text("Search")'),
|
|
||||||
openRow: optEnv('SEL_OPEN_ROW', 'table tbody tr:first-child'),
|
|
||||||
|
|
||||||
statusDropdown: optEnv('SEL_STATUS_DROPDOWN', 'select[name*="estado"], select[id*="estado"], select:has(option)'),
|
|
||||||
noteTextarea: optEnv('SEL_NOTE_TEXTAREA', 'textarea[name*="nota"], textarea[id*="nota"], textarea'),
|
|
||||||
saveBtn: optEnv('SEL_SAVE_BTN', 'button:has-text("Guardar"), button:has-text("Save"), button:has-text("Actualizar")'),
|
|
||||||
},
|
|
||||||
|
|
||||||
// comportamiento
|
|
||||||
HEADLESS: optEnv('HEADLESS', 'true') !== 'false',
|
|
||||||
SLOW_MO_MS: parseInt(optEnv('SLOW_MO_MS', '0'), 10),
|
|
||||||
};
|
|
||||||
|
|
||||||
// -------------------- Firestore: leer credenciales HS --------------------
|
|
||||||
async function getHomeServeCredentials(db) {
|
|
||||||
// Doc esperado:
|
|
||||||
// {
|
|
||||||
// baseUrl: "https://gestor.homeserve.es/",
|
|
||||||
// user: "xxxx",
|
|
||||||
// pass: "yyyy"
|
|
||||||
// }
|
|
||||||
const parts = CONFIG.HS_CRED_DOC_PATH.split('/').filter(Boolean);
|
|
||||||
if (parts.length < 2 || parts.length % 2 !== 0) {
|
|
||||||
throw new Error(`HS_CRED_DOC_PATH inválido: "${CONFIG.HS_CRED_DOC_PATH}". Debe ser collection/doc (o pares).`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let ref = db.collection(parts[0]).doc(parts[1]);
|
|
||||||
for (let i = 2; i < parts.length; i += 2) {
|
|
||||||
ref = ref.collection(parts[i]).doc(parts[i + 1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const snap = await ref.get();
|
|
||||||
if (!snap.exists) {
|
if (!snap.exists) {
|
||||||
throw new Error(`No existe el documento de credenciales: ${CONFIG.HS_CRED_DOC_PATH}`);
|
throw new Error(`HS credentials doc not found: ${CONFIG.HS_CRED_DOC_PATH}`);
|
||||||
}
|
}
|
||||||
|
const d = snap.data() || {};
|
||||||
const data = snap.data() || {};
|
const user = (d.user || d.username || d.email || d.HOMESERVE_USER || '').toString().trim();
|
||||||
const baseUrl = (data.baseUrl || data.HOMESERVE_BASE_URL || CONFIG.HOMESERVE_BASE_URL).toString();
|
const pass = (d.pass || d.password || d.HOMESERVE_PASS || '').toString().trim();
|
||||||
const user = (data.user || data.HOMESERVE_USER || CONFIG.HOMESERVE_USER || '').toString();
|
|
||||||
const pass = (data.pass || data.HOMESERVE_PASS || CONFIG.HOMESERVE_PASS || '').toString();
|
|
||||||
|
|
||||||
if (!user || !pass) {
|
if (!user || !pass) {
|
||||||
throw new Error(`El doc ${CONFIG.HS_CRED_DOC_PATH} no tiene user/pass (o están vacíos).`);
|
throw new Error(`HS credentials doc missing fields (need user/pass): ${CONFIG.HS_CRED_DOC_PATH}`);
|
||||||
}
|
}
|
||||||
|
return { user, pass };
|
||||||
return { baseUrl, user, pass };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- Playwright actions --------------------
|
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) {
|
async function withBrowser(fn) {
|
||||||
const browser = await chromium.launch({
|
const browser = await chromium.launch({
|
||||||
headless: CONFIG.HEADLESS,
|
headless: CONFIG.HEADLESS,
|
||||||
slowMo: CONFIG.SLOW_MO_MS,
|
|
||||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -174,27 +132,22 @@ async function withBrowser(fn) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loginHomeServe(page, creds) {
|
async function login(page, hsUser, hsPass) {
|
||||||
await page.goto(creds.baseUrl, { waitUntil: 'domcontentloaded', timeout: 120000 });
|
await page.goto(CONFIG.HOMESERVE_BASE_URL, { waitUntil: 'domcontentloaded', timeout: CONFIG.TIMEOUT_MS });
|
||||||
|
|
||||||
await page.waitForSelector(CONFIG.SEL.user, { timeout: 60000 });
|
await page.waitForSelector(CONFIG.SEL.user, { timeout: 60000 });
|
||||||
await page.fill(CONFIG.SEL.user, creds.user);
|
await page.fill(CONFIG.SEL.user, hsUser);
|
||||||
|
|
||||||
await page.waitForSelector(CONFIG.SEL.pass, { timeout: 60000 });
|
await page.waitForSelector(CONFIG.SEL.pass, { timeout: 60000 });
|
||||||
await page.fill(CONFIG.SEL.pass, creds.pass);
|
await page.fill(CONFIG.SEL.pass, hsPass);
|
||||||
|
|
||||||
const btn = await page.$(CONFIG.SEL.submit);
|
const btn = await page.$(CONFIG.SEL.submit);
|
||||||
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('networkidle', { timeout: 120000 });
|
// Importante: a veces HS no llega a networkidle (polling interno). Mejor esperar a DOM + un respiro.
|
||||||
|
await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.TIMEOUT_MS });
|
||||||
// Si HomeServe muestra “Credenciales incorrectas” en el DOM:
|
await sleep(1200);
|
||||||
// (esto es opcional; si no existe, no pasa nada)
|
|
||||||
const possibleError = await page.$('text=/credenciales\\s+incorrectas/i');
|
|
||||||
if (possibleError) {
|
|
||||||
throw new Error('HomeServe: credenciales incorrectas (detectado en pantalla)');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openParte(page, parteId) {
|
async function openParte(page, parteId) {
|
||||||
|
|
@ -205,165 +158,176 @@ async function openParte(page, parteId) {
|
||||||
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('networkidle', { timeout: 120000 });
|
await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.TIMEOUT_MS });
|
||||||
await sleep(1200);
|
await sleep(1200);
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.waitForSelector(CONFIG.SEL.openRow, { timeout: 60000 });
|
await page.waitForSelector(CONFIG.SEL.openRow, { timeout: 60000 });
|
||||||
await page.click(CONFIG.SEL.openRow);
|
await page.click(CONFIG.SEL.openRow);
|
||||||
await page.waitForLoadState('networkidle', { timeout: 120000 });
|
|
||||||
await sleep(800);
|
await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.TIMEOUT_MS });
|
||||||
|
await sleep(1200);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setEstadoByStatusCode(page, statusCode, notaFinal) {
|
function normalizeDateDDMMYYYY(s) {
|
||||||
const code = String(statusCode).trim();
|
const v = String(s || '').trim();
|
||||||
const labels = STATUS_CODE_MAP[code] || [];
|
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 });
|
await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: 60000 });
|
||||||
|
|
||||||
// 1) Intento por value = code
|
// 1) intenta por value exacto (lo normal si el portal usa códigos)
|
||||||
let selected = false;
|
let selected = false;
|
||||||
try {
|
try {
|
||||||
await page.selectOption(CONFIG.SEL.statusDropdown, { value: code });
|
await page.selectOption(CONFIG.SEL.statusDropdown, { value: code });
|
||||||
selected = true;
|
selected = true;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
// 2) Intento por label exacto
|
// 2) si no, intenta por label que contenga el código
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
for (const label of labels) {
|
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 {
|
try {
|
||||||
await page.selectOption(CONFIG.SEL.statusDropdown, { label });
|
await page.fill(CONFIG.SEL.followUpDate, dateStr);
|
||||||
selected = true;
|
|
||||||
break;
|
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Fallback DOM: contains / code match
|
// Nota (opcional)
|
||||||
if (!selected) {
|
if (note) {
|
||||||
const ok = await page.evaluate(({ sel, code, labels }) => {
|
|
||||||
const s = document.querySelector(sel);
|
|
||||||
if (!s) return false;
|
|
||||||
|
|
||||||
const opts = Array.from(s.querySelectorAll('option'));
|
|
||||||
const norm = (x) => (x || '').trim().toLowerCase();
|
|
||||||
const needles = labels.map(norm).filter(Boolean);
|
|
||||||
|
|
||||||
const hit = opts.find((o) => {
|
|
||||||
const t = norm(o.textContent);
|
|
||||||
const v = norm(o.value);
|
|
||||||
if (v === norm(code)) return true;
|
|
||||||
if (t.includes(norm(code))) return true;
|
|
||||||
return needles.some((n) => t.includes(n));
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!hit) return false;
|
|
||||||
|
|
||||||
s.value = hit.value;
|
|
||||||
s.dispatchEvent(new Event('change', { bubbles: true }));
|
|
||||||
return true;
|
|
||||||
}, { sel: CONFIG.SEL.statusDropdown, code, labels });
|
|
||||||
|
|
||||||
if (!ok) throw new Error(`No encuentro el estado en el desplegable para statusCode=${code}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notaFinal) {
|
|
||||||
const ta = await page.$(CONFIG.SEL.noteTextarea);
|
const ta = await page.$(CONFIG.SEL.noteTextarea);
|
||||||
if (ta) await page.fill(CONFIG.SEL.noteTextarea, String(notaFinal));
|
if (ta) {
|
||||||
|
await page.fill(CONFIG.SEL.noteTextarea, String(note));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const save = await page.$(CONFIG.SEL.saveBtn);
|
const save = await page.$(CONFIG.SEL.saveBtn);
|
||||||
if (!save) throw new Error('Save button not found');
|
if (!save) throw new Error('Save button not found');
|
||||||
await save.click();
|
await save.click();
|
||||||
|
|
||||||
await page.waitForLoadState('networkidle', { timeout: 120000 });
|
await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.TIMEOUT_MS });
|
||||||
await sleep(1200);
|
await sleep(1200);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- Express app --------------------
|
// ====== EXPRESS SERVER ======
|
||||||
initFirebaseAdmin();
|
initFirebaseAdmin();
|
||||||
const db = admin.firestore();
|
const db = admin.firestore();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(helmet());
|
app.use(cors());
|
||||||
app.use(cors({ origin: '*'}));
|
app.use(express.json({ limit: '1mb' }));
|
||||||
app.use(express.json({ limit: '512kb' }));
|
|
||||||
|
|
||||||
// Concurrencia: servidor pequeño => 1 job a la vez
|
// Para CapRover: NO 404 en /
|
||||||
let busy = false;
|
app.get('/', (req, res) => {
|
||||||
|
res.status(200).json({ ok: true, service: 'estados-hs', ts: new Date().toISOString() });
|
||||||
app.get('/health', (_req, res) => {
|
|
||||||
res.json({ ok: true, busy, ts: new Date().toISOString() });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/v1/homeserve/change-status', async (req, res) => {
|
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();
|
const startedAt = new Date().toISOString();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Seguridad: API_KEY opcional
|
const { user, pass } = await getHomeServeCreds(db);
|
||||||
if (CONFIG.API_KEY) {
|
|
||||||
const k = req.headers['x-api-key'];
|
|
||||||
if (!k || String(k) !== String(CONFIG.API_KEY)) {
|
|
||||||
return res.status(401).json({ ok: false, error: 'Invalid X-API-Key' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seguridad: Firebase token (por defecto requerido)
|
|
||||||
await verifyFirebaseIdTokenIfPresent(req);
|
|
||||||
|
|
||||||
if (busy) {
|
|
||||||
return res.status(409).json({ ok: false, error: 'BUSY: ya hay un cambio en curso' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { serviceNumber, statusCode, dateString, observation } = req.body || {};
|
|
||||||
const parteId = String(serviceNumber || '').trim();
|
|
||||||
const code = String(statusCode || '').trim();
|
|
||||||
|
|
||||||
if (!parteId) return res.status(400).json({ ok: false, error: 'serviceNumber requerido' });
|
|
||||||
if (!code) return res.status(400).json({ ok: false, error: 'statusCode requerido' });
|
|
||||||
if (!STATUS_CODE_MAP[code]) {
|
|
||||||
return res.status(400).json({
|
|
||||||
ok: false,
|
|
||||||
error: `statusCode inválido: ${code}`,
|
|
||||||
allowed: Object.keys(STATUS_CODE_MAP),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nota final: metemos fecha si viene
|
|
||||||
const ds = (dateString ? String(dateString).trim() : '');
|
|
||||||
const obs = (observation ? String(observation).trim() : '');
|
|
||||||
const notaFinal = [obs, ds ? `Fecha: ${ds}` : ''].filter(Boolean).join(' · ');
|
|
||||||
|
|
||||||
busy = true;
|
|
||||||
const creds = await getHomeServeCredentials(db);
|
|
||||||
|
|
||||||
await withBrowser(async (page) => {
|
await withBrowser(async (page) => {
|
||||||
await loginHomeServe(page, creds);
|
await login(page, user, pass);
|
||||||
await openParte(page, parteId);
|
await openParte(page, parteId);
|
||||||
await setEstadoByStatusCode(page, code, notaFinal);
|
await setEstadoByCode(page, statusCode, followUpDate, observation);
|
||||||
});
|
});
|
||||||
|
|
||||||
busy = false;
|
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
parteId,
|
||||||
|
statusCode,
|
||||||
|
followUpDate: followUpDate || null,
|
||||||
|
observation: observation || null,
|
||||||
startedAt,
|
startedAt,
|
||||||
finishedAt: new Date().toISOString(),
|
finishedAt: new Date().toISOString(),
|
||||||
serviceNumber: parteId,
|
uid: auth.uid,
|
||||||
statusCode: code,
|
|
||||||
statusText: STATUS_CODE_MAP[code][0],
|
|
||||||
noteSent: notaFinal,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (e) {
|
||||||
|
return res.status(500).json({
|
||||||
|
ok: false,
|
||||||
|
error: String(e?.message || e),
|
||||||
|
startedAt,
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
const msg = String(err?.message || err);
|
|
||||||
return res.status(500).json({ ok: false, error: msg });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(CONFIG.PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`[estados-hs] listening on :${CONFIG.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] HS_CRED_DOC_PATH=${CONFIG.HS_CRED_DOC_PATH}`);
|
||||||
console.log(`[estados-hs] REQUIRE_AUTH=${optEnv('REQUIRE_AUTH', '1')}`);
|
console.log(`[estados-hs] REQUIRE_AUTH=${CONFIG.REQUIRE_AUTH ? '1' : '0'}`);
|
||||||
});
|
});
|
||||||
Loading…
Reference in New Issue