Actualizar index.js

This commit is contained in:
marsalva 2026-01-03 22:36:36 +00:00
parent 054b930c7a
commit e37b5cc12e
1 changed files with 255 additions and 304 deletions

559
index.js
View File

@ -1,127 +1,166 @@
// estados-homeserve/index.js
'use strict'; 'use strict';
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const { chromium } = require('playwright'); const { chromium } = require('playwright');
const admin = require('firebase-admin'); const admin = require('firebase-admin');
/** // -------------------- helpers --------------------
* =========================
* Firebase Admin Init
* =========================
*/
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 initFirebase() { function optEnv(name, fallback) {
if (!process.env.FIREBASE_PRIVATE_KEY) throw new Error('Missing env: FIREBASE_PRIVATE_KEY'); const v = process.env[name];
return (v === undefined || v === null || v === '') ? fallback : v;
}
if (!admin.apps.length) { function sleep(ms) {
admin.initializeApp({ return new Promise((r) => setTimeout(r, ms));
credential: admin.credential.cert({ }
projectId: mustEnv('FIREBASE_PROJECT_ID'),
clientEmail: mustEnv('FIREBASE_CLIENT_EMAIL'), // -------------------- Firebase Admin --------------------
privateKey: mustEnv('FIREBASE_PRIVATE_KEY').replace(/\\n/g, '\n'), 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;
} }
return admin.firestore();
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;
} }
const db = initFirebase(); // -------------------- 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 --------------------
* =========================
* Config
* =========================
*/
const CONFIG = { const CONFIG = {
// HomeServe base URL (fallback si no hay en Firestore) PORT: parseInt(optEnv('PORT', '3000'), 10),
HOMESERVE_BASE_URL: process.env.HOMESERVE_BASE_URL || 'https://gestor.homeserve.es/',
// Colecciones // dónde guardas las credenciales de HomeServe en Firestore
QUEUE_COLLECTION: process.env.QUEUE_COLLECTION || 'homeserve_cambios_estado', // Formato: "collection/doc" o "collection/doc/subcollection/doc"
RESULT_COLLECTION: process.env.RESULT_COLLECTION || 'homeserve_cambios_estado_log', HS_CRED_DOC_PATH: optEnv('HS_CRED_DOC_PATH', 'secrets/homeserve'),
// Credenciales en Firestore // Si quieres fallback por ENV (por si firestore no está listo)
PROVIDER_CREDENTIALS_COLLECTION: process.env.PROVIDER_CREDENTIALS_COLLECTION || 'providerCredentials', HOMESERVE_BASE_URL: optEnv('HOMESERVE_BASE_URL', 'https://gestor.homeserve.es/'),
PROVIDER_DOC_ID: process.env.PROVIDER_DOC_ID || 'homeserve', HOMESERVE_USER: process.env.HOMESERVE_USER || null,
HOMESERVE_PASS: process.env.HOMESERVE_PASS || null,
// Control de “claim” // Seguridad extra (si no quieres usar Firebase auth):
CLAIM_TTL_MINUTES: parseInt(process.env.CLAIM_TTL_MINUTES || '10', 10), // pon API_KEY y el HTML mandará X-API-Key
API_KEY: process.env.API_KEY || null,
// Concurrencia (para que no te arranque 20 Chromiums) // Selectores (ajustables por env)
MAX_CONCURRENCY: parseInt(process.env.MAX_CONCURRENCY || '1', 10),
// Selectores HomeServe (como ya tenías)
SEL: { SEL: {
user: process.env.SEL_USER || 'input[type="text"]', user: optEnv('SEL_USER', 'input[type="text"]'),
pass: process.env.SEL_PASS || 'input[type="password"]', pass: optEnv('SEL_PASS', 'input[type="password"]'),
submit: process.env.SEL_SUBMIT || 'button[type="submit"]', submit: optEnv('SEL_SUBMIT', 'button[type="submit"]'),
searchBox: process.env.SEL_SEARCH_BOX || 'input[placeholder*="Buscar"], input[type="search"]', searchBox: optEnv('SEL_SEARCH_BOX', 'input[placeholder*="Buscar"], input[type="search"]'),
searchBtn: process.env.SEL_SEARCH_BTN || 'button:has-text("Buscar"), button:has-text("Search")', searchBtn: optEnv('SEL_SEARCH_BTN', 'button:has-text("Buscar"), button:has-text("Search")'),
openRow: optEnv('SEL_OPEN_ROW', 'table tbody tr:first-child'),
openRow: process.env.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'),
statusDropdown: process.env.SEL_STATUS_DROPDOWN || 'select[name*="estado"], select[id*="estado"], select:has(option)', saveBtn: optEnv('SEL_SAVE_BTN', 'button:has-text("Guardar"), button:has-text("Save"), button:has-text("Actualizar")'),
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")',
}, },
// comportamiento
HEADLESS: optEnv('HEADLESS', 'true') !== 'false',
SLOW_MO_MS: parseInt(optEnv('SLOW_MO_MS', '0'), 10),
}; };
const STATE_MAP = { // -------------------- Firestore: leer credenciales HS --------------------
EN_RUTA: ['De camino', 'En ruta', 'En camino'], async function getHomeServeCredentials(db) {
EN_CURSO: ['En curso', 'Trabajando', 'En intervención'], // Doc esperado:
FINALIZADO: ['Finalizado', 'Finalizada', 'Terminado'], // {
NO_LOCALIZADO: ['No localizado', 'No localizable', 'Ausente'], // baseUrl: "https://gestor.homeserve.es/",
CERRADO: ['Cerrado', 'Cierre', 'Cerrada'], // user: "xxxx",
ANULADO: ['Anulado', 'Cancelado', 'Cancelada'], // 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).`);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let ref = db.collection(parts[0]).doc(parts[1]);
const nowMs = () => Date.now(); for (let i = 2; i < parts.length; i += 2) {
const toServerTimestamp = () => admin.firestore.FieldValue.serverTimestamp(); ref = ref.collection(parts[i]).doc(parts[i + 1]);
}
/**
* =========================
* Credenciales desde Firestore (con cache)
* =========================
*/
let credsCache = null;
let credsCacheAt = 0;
const CREDS_TTL_MS = 60_000; // 1 min
async function getHomeServeCreds() {
const now = nowMs();
if (credsCache && (now - credsCacheAt) < CREDS_TTL_MS) return credsCache;
const ref = db.collection(CONFIG.PROVIDER_CREDENTIALS_COLLECTION).doc(CONFIG.PROVIDER_DOC_ID);
const snap = await ref.get(); const snap = await ref.get();
if (!snap.exists) throw new Error(`Missing provider credentials doc: ${CONFIG.PROVIDER_CREDENTIALS_COLLECTION}/${CONFIG.PROVIDER_DOC_ID}`); if (!snap.exists) {
throw new Error(`No existe el documento de credenciales: ${CONFIG.HS_CRED_DOC_PATH}`);
}
const d = snap.data() || {}; const data = snap.data() || {};
const user = d.user || d.username || d.email; const baseUrl = (data.baseUrl || data.HOMESERVE_BASE_URL || CONFIG.HOMESERVE_BASE_URL).toString();
const pass = d.pass || d.password; const user = (data.user || data.HOMESERVE_USER || CONFIG.HOMESERVE_USER || '').toString();
const baseUrl = d.baseUrl || d.baseURL || d.homeserveBaseUrl || CONFIG.HOMESERVE_BASE_URL; const pass = (data.pass || data.HOMESERVE_PASS || CONFIG.HOMESERVE_PASS || '').toString();
if (!user || !pass) throw new Error('HomeServe credentials missing in Firestore doc (need fields user & pass)'); if (!user || !pass) {
throw new Error(`El doc ${CONFIG.HS_CRED_DOC_PATH} no tiene user/pass (o están vacíos).`);
}
credsCache = { user: String(user), pass: String(pass), baseUrl: String(baseUrl) }; return { baseUrl, user, pass };
credsCacheAt = now;
return credsCache;
} }
/** // -------------------- Playwright actions --------------------
* =========================
* Browser helpers
* =========================
*/
async function withBrowser(fn) { async function withBrowser(fn) {
const browser = await chromium.launch({ const browser = await chromium.launch({
headless: true, headless: CONFIG.HEADLESS,
slowMo: CONFIG.SLOW_MO_MS,
args: ['--no-sandbox', '--disable-setuid-sandbox'], args: ['--no-sandbox', '--disable-setuid-sandbox'],
}); });
@ -135,7 +174,7 @@ async function withBrowser(fn) {
} }
} }
async function login(page, creds) { async function loginHomeServe(page, creds) {
await page.goto(creds.baseUrl, { waitUntil: 'domcontentloaded', timeout: 120000 }); await page.goto(creds.baseUrl, { waitUntil: 'domcontentloaded', timeout: 120000 });
await page.waitForSelector(CONFIG.SEL.user, { timeout: 60000 }); await page.waitForSelector(CONFIG.SEL.user, { timeout: 60000 });
@ -149,6 +188,13 @@ async function login(page, creds) {
else await page.keyboard.press('Enter'); else await page.keyboard.press('Enter');
await page.waitForLoadState('networkidle', { timeout: 120000 }); await page.waitForLoadState('networkidle', { timeout: 120000 });
// Si HomeServe muestra “Credenciales incorrectas” en el DOM:
// (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) {
@ -165,254 +211,159 @@ async function openParte(page, parteId) {
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 page.waitForLoadState('networkidle', { timeout: 120000 });
await sleep(1000); await sleep(800);
} }
async function setEstado(page, nuevoEstado, nota) { async function setEstadoByStatusCode(page, statusCode, notaFinal) {
const candidates = STATE_MAP[nuevoEstado] || [nuevoEstado]; const code = String(statusCode).trim();
const labels = STATUS_CODE_MAP[code] || [];
await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: 60000 }); await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: 60000 });
// 1) selectOption por label // 1) Intento por value = code
let selected = false; let selected = false;
for (const label of candidates) { try {
try { await page.selectOption(CONFIG.SEL.statusDropdown, { value: code });
await page.selectOption(CONFIG.SEL.statusDropdown, { label }); selected = true;
selected = true; } catch (_) {}
break;
} catch (_) {} // 2) Intento por label exacto
if (!selected) {
for (const label of labels) {
try {
await page.selectOption(CONFIG.SEL.statusDropdown, { label });
selected = true;
break;
} catch (_) {}
}
} }
// 2) fallback DOM match (case-insensitive) // 3) Fallback DOM: contains / code match
if (!selected) { if (!selected) {
const ok = await page.evaluate(({ sel, candidates }) => { const ok = await page.evaluate(({ sel, code, labels }) => {
const s = document.querySelector(sel); const s = document.querySelector(sel);
if (!s) return false; if (!s) return false;
const opts = Array.from(s.querySelectorAll('option')); const opts = Array.from(s.querySelectorAll('option'));
const hit = opts.find(o => const norm = (x) => (x || '').trim().toLowerCase();
candidates.some(c => (o.textContent || '').trim().toLowerCase() === String(c).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; if (!hit) return false;
s.value = hit.value; s.value = hit.value;
s.dispatchEvent(new Event('change', { bubbles: true })); s.dispatchEvent(new Event('change', { bubbles: true }));
return true; return true;
}, { sel: CONFIG.SEL.statusDropdown, candidates }); }, { sel: CONFIG.SEL.statusDropdown, code, labels });
if (!ok) throw new Error(`No matching status option for "${nuevoEstado}"`); if (!ok) throw new Error(`No encuentro el estado en el desplegable para statusCode=${code}`);
} }
if (nota) { 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(nota)); if (ta) await page.fill(CONFIG.SEL.noteTextarea, String(notaFinal));
} }
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('networkidle', { timeout: 120000 });
await sleep(1200); await sleep(1200);
} }
/** // -------------------- Express app --------------------
* ========================= initFirebaseAdmin();
* Queue job claim (transaction) const db = admin.firestore();
* =========================
*/
async function claimJob(jobRef) {
const now = nowMs();
const ttlMs = CONFIG.CLAIM_TTL_MINUTES * 60 * 1000;
return await db.runTransaction(async (tx) => { const app = express();
const fresh = await tx.get(jobRef); app.use(helmet());
if (!fresh.exists) return null; app.use(cors({ origin: '*'}));
app.use(express.json({ limit: '512kb' }));
const d = fresh.data() || {}; // Concurrencia: servidor pequeño => 1 job a la vez
const st = d.status ?? 'PENDING'; let busy = false;
// si no está pendiente, fuera app.get('/health', (_req, res) => {
if (st !== 'PENDING' && st !== null) { res.json({ ok: true, busy, ts: new Date().toISOString() });
// OJO: si está RUNNING y “caducado”, lo re-claim
if (st !== 'RUNNING') return null;
}
const claimedAtMs = d.claimedAt?.toMillis ? d.claimedAt.toMillis() : null;
const stale = claimedAtMs && (now - claimedAtMs > ttlMs);
if (st === 'RUNNING' && !stale) return null;
tx.set(jobRef, {
status: 'RUNNING',
claimedAt: toServerTimestamp(),
claimedBy: process.env.HOSTNAME || 'estados-homeserve',
lastSeenAt: toServerTimestamp(),
}, { merge: true });
return { id: fresh.id, ...d };
});
}
async function markDone(jobId, result) {
const ref = db.collection(CONFIG.QUEUE_COLLECTION).doc(jobId);
await ref.set({
status: 'DONE',
finishedAt: toServerTimestamp(),
result,
lastSeenAt: toServerTimestamp(),
}, { merge: true });
await db.collection(CONFIG.RESULT_COLLECTION).add({
jobId,
...result,
createdAt: toServerTimestamp(),
});
}
async function markFailed(jobId, err) {
const ref = db.collection(CONFIG.QUEUE_COLLECTION).doc(jobId);
await ref.set({
status: 'FAILED',
finishedAt: toServerTimestamp(),
error: {
message: String(err?.message || err),
stack: String(err?.stack || ''),
},
lastSeenAt: toServerTimestamp(),
}, { merge: true });
await db.collection(CONFIG.RESULT_COLLECTION).add({
jobId,
ok: false,
error: {
message: String(err?.message || err),
stack: String(err?.stack || ''),
},
createdAt: toServerTimestamp(),
});
}
/**
* =========================
* Concurrency (semaforo simple)
* =========================
*/
let running = 0;
const waiters = [];
function acquire() {
return new Promise((resolve) => {
if (running < CONFIG.MAX_CONCURRENCY) {
running++;
resolve();
} else {
waiters.push(resolve);
}
});
}
function release() {
running = Math.max(0, running - 1);
const next = waiters.shift();
if (next) {
running++;
next();
}
}
/**
* =========================
* Process a job NOW (sin esperar)
* =========================
*/
async function processJob(jobId, jobData) {
const parteId = jobData.parteId || jobData.parte || jobData.codigo || jobData.serviceId;
const nuevoEstado = jobData.nuevoEstado || jobData.estado || jobData.statusTo;
const nota = jobData.nota || jobData.note || '';
if (!parteId || !nuevoEstado) {
await markFailed(jobId, new Error('Job missing parteId or nuevoEstado'));
return;
}
const started = new Date().toISOString();
const creds = await getHomeServeCreds();
await withBrowser(async (page) => {
await login(page, creds);
await openParte(page, parteId);
await setEstado(page, nuevoEstado, nota);
});
await markDone(jobId, {
ok: true,
startedAtISO: started,
parteId: String(parteId),
nuevoEstado: String(nuevoEstado),
nota: String(nota || ''),
});
}
/**
* =========================
* Firestore listener (event-driven)
* =========================
*/
function startQueueListener() {
console.log(`[HS] Listening queue: ${CONFIG.QUEUE_COLLECTION} ...`);
const q = db.collection(CONFIG.QUEUE_COLLECTION)
.where('status', 'in', ['PENDING', null]);
q.onSnapshot(async (snap) => {
// Procesa solo cambios relevantes
const changes = snap.docChanges()
.filter(ch => ch.type === 'added' || ch.type === 'modified')
.map(ch => ch.doc);
for (const doc of changes) {
const ref = doc.ref;
const data = doc.data() || {};
// Seguridad: si ya no está pending, ignora
const st = data.status ?? 'PENDING';
if (st !== 'PENDING' && st !== null) continue;
// Concurrency guard
await acquire();
(async () => {
try {
const claimed = await claimJob(ref);
if (!claimed) return;
await processJob(doc.id, claimed);
} catch (err) {
await markFailed(doc.id, err);
} finally {
release();
}
})();
}
}, (err) => {
console.error('[HS] Listener error:', err);
// Si el listener cae, reinicia el proceso (CapRover lo levantará)
process.exit(1);
});
}
/**
* =========================
* Main
* =========================
*/
startQueueListener();
process.on('unhandledRejection', (e) => {
console.error('[HS] unhandledRejection', e);
}); });
process.on('uncaughtException', (e) => {
console.error('[HS] uncaughtException', e); app.post('/v1/homeserve/change-status', async (req, res) => {
process.exit(1); const startedAt = new Date().toISOString();
try {
// Seguridad: API_KEY opcional
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 loginHomeServe(page, creds);
await openParte(page, parteId);
await setEstadoByStatusCode(page, code, notaFinal);
});
busy = false;
return res.json({
ok: true,
startedAt,
finishedAt: new Date().toISOString(),
serviceNumber: parteId,
statusCode: code,
statusText: STATUS_CODE_MAP[code][0],
noteSent: notaFinal,
});
} catch (err) {
busy = false;
const msg = String(err?.message || err);
return res.status(500).json({ ok: false, error: msg });
}
});
app.listen(CONFIG.PORT, () => {
console.log(`[estados-hs] listening on :${CONFIG.PORT}`);
console.log(`[estados-hs] HS_CRED_DOC_PATH=${CONFIG.HS_CRED_DOC_PATH}`);
console.log(`[estados-hs] REQUIRE_AUTH=${optEnv('REQUIRE_AUTH', '1')}`);
}); });