estados-homeserve/index.js

580 lines
19 KiB
JavaScript

'use strict';
const express = require('express');
const cors = require('cors');
const { chromium } = require('playwright');
const admin = require('firebase-admin');
// -------------------- Firebase --------------------
function mustEnv(name) {
const v = process.env[name];
if (!v) throw new Error(`Missing env: ${name}`);
return v;
}
function initFirebase() {
if (admin.apps?.length) return admin.firestore();
if (!process.env.FIREBASE_PRIVATE_KEY) {
throw new Error('Missing env: FIREBASE_PRIVATE_KEY');
}
admin.initializeApp({
credential: admin.credential.cert({
projectId: mustEnv('FIREBASE_PROJECT_ID'),
clientEmail: mustEnv('FIREBASE_CLIENT_EMAIL'),
privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'),
}),
});
return admin.firestore();
}
async function getHomeServeCreds(db) {
// Tu caso real: providerCredentials/homeserve con { user, pass }
const docPath = process.env.HS_CRED_DOC_PATH || 'providerCredentials/homeserve';
let fsUser = '';
let fsPass = '';
try {
const snap = await db.doc(docPath).get();
if (snap.exists) {
const d = snap.data() || {};
fsUser = String(d.user || '').trim();
fsPass = String(d.pass || '').trim();
}
} catch (_) {}
const envUser = String(process.env.HOMESERVE_USER || '').trim();
const envPass = String(process.env.HOMESERVE_PASS || '').trim();
const user = fsUser || envUser;
const pass = fsPass || envPass;
if (!user || !pass) {
throw new Error(
`HomeServe creds missing. Put {user,pass} in Firestore doc "${docPath}" or set env HOMESERVE_USER/HOMESERVE_PASS`
);
}
return { user, pass, source: fsUser ? `firestore:${docPath}` : 'env' };
}
// -------------------- Config --------------------
const CONFIG = {
// Flujo antiguo:
HOMESERVE_BASE_URL: process.env.HOMESERVE_BASE_URL || 'https://gestor.homeserve.es/',
// Selectores (igual que el robot viejo, pero con extras)
SEL: {
user: process.env.SEL_USER || 'input[type="text"], input[name="user"], input[name="username"]',
pass: process.env.SEL_PASS || 'input[type="password"], input[name="pass"], input[name="password"]',
submit: process.env.SEL_SUBMIT || 'button[type="submit"], input[type="submit"], button:has-text("Entrar")',
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" i], select[id*="estado" i], select:has(option)',
// Observación/nota
noteTextarea:
process.env.SEL_NOTE_TEXTAREA ||
'textarea[name*="nota" i], textarea[id*="nota" i], textarea',
// Fecha (si existe)
dateInput:
process.env.SEL_DATE_INPUT ||
'input[name*="fec" i], input[id*="fec" i], input[placeholder*="dd" i], input[placeholder*="fecha" i]',
// Checkbox “ya informado al cliente”
informedCheckbox:
process.env.SEL_INFORMED_CHECKBOX ||
'input[type="checkbox"][name*="inform" i], input[type="checkbox"][id*="inform" i]',
// Botón guardar
saveBtn:
process.env.SEL_SAVE_BTN ||
'button:has-text("Guardar"), button:has-text("Save"), button:has-text("Actualizar"), input[type="submit"]',
},
HEADLESS: String(process.env.HEADLESS || '1') !== '0',
NAV_TIMEOUT_MS: parseInt(process.env.NAV_TIMEOUT_MS || '120000', 10),
// Auth opcional del endpoint
REQUIRE_AUTH: String(process.env.REQUIRE_AUTH || '0') === '1',
API_KEY: process.env.API_KEY || '',
};
// Estados por código (los de tu app)
const STATUS_CODES = [
{ code: '303', title: 'En espera de Cliente por aceptación Presupuesto' },
{ code: '307', title: 'En espera de Profesional por fecha de inicio de trabajos' },
{ code: '313', title: 'En espera de Profesional por secado de cala, pintura o parquet' },
{ code: '318', title: 'En espera de Profesional por confirmación del Siniestro' },
{ code: '319', title: 'En espera de Profesional por material' },
{ code: '320', title: 'En espera de Profesional por espera de otro gremio' },
{ code: '321', title: 'En espera de Profesional por presupuesto/valoración' },
{ code: '323', title: 'En espera de Profesional por mejora del tiempo' },
{ code: '326', title: 'En espera de Cliente por pago de Factura Contado/Franquicia' },
{ code: '336', title: 'En espera de Profesional por avería en observación' },
{ code: '342', title: 'En espera de Profesional pendiente cobro franquicia' },
{ code: '345', title: 'En espera de Profesional en realización pendiente Terminar' },
{ code: '348', title: 'En espera de Cliente por indicaciones' },
{ code: '352', title: 'En espera de Perjudicado por indicaciones' },
];
const CODE_TO_TITLE = new Map(STATUS_CODES.map(x => [x.code, x.title]));
// -------------------- Helpers --------------------
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const nowISO = () => new Date().toISOString();
const trim = (v) => String(v ?? '').trim();
function parseBool(v, def = true) {
if (v === undefined || v === null || v === '') return def;
const s = String(v).toLowerCase().trim();
if (['1', 'true', 'yes', 'si', 'sí'].includes(s)) return true;
if (['0', 'false', 'no'].includes(s)) return false;
return def;
}
async function withBrowser(fn) {
const browser = await chromium.launch({
headless: CONFIG.HEADLESS,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const page = await context.newPage();
try {
return await fn(page);
} finally {
await browser.close().catch(() => {});
}
}
// -------------------- Playwright flow (como el robot antiguo) --------------------
async function login(page, creds) {
await page.goto(CONFIG.HOMESERVE_BASE_URL, { waitUntil: 'domcontentloaded', timeout: CONFIG.NAV_TIMEOUT_MS });
await page.waitForSelector(CONFIG.SEL.user, { timeout: 60000 });
await page.fill(CONFIG.SEL.user, creds.user);
await page.waitForSelector(CONFIG.SEL.pass, { timeout: 60000 });
await page.fill(CONFIG.SEL.pass, creds.pass);
const btn = await page.$(CONFIG.SEL.submit);
if (btn) await btn.click();
else await page.keyboard.press('Enter');
// networkidle a veces es traicionero; domcontentloaded + pause suele ir mejor
await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT_MS });
await sleep(1200);
}
async function openParte(page, parteId) {
const hasSearch = await page.$(CONFIG.SEL.searchBox);
if (hasSearch) {
await page.fill(CONFIG.SEL.searchBox, String(parteId));
const btn = await page.$(CONFIG.SEL.searchBtn);
if (btn) await btn.click();
else await page.keyboard.press('Enter');
await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT_MS });
await sleep(1200);
}
await page.waitForSelector(CONFIG.SEL.openRow, { timeout: 60000 });
await page.click(CONFIG.SEL.openRow);
await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT_MS });
await sleep(900);
}
async function checkInformedBox(page) {
// 1) intento selector directo
const cb = await page.$(CONFIG.SEL.informedCheckbox);
if (cb) {
const isChecked = await cb.isChecked().catch(() => false);
if (!isChecked) await cb.check().catch(() => {});
return true;
}
// 2) intento por texto de label (tu frase exacta)
const ok = await page.evaluate(() => {
const text = 'Marque esta casilla, si ya ha informado al Cliente'.toLowerCase();
const labels = Array.from(document.querySelectorAll('label'));
const hit = labels.find(l => (l.textContent || '').toLowerCase().includes(text));
if (!hit) return false;
const forId = hit.getAttribute('for');
if (forId) {
const input = document.getElementById(forId);
if (input && input.type === 'checkbox') {
input.checked = true;
input.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
}
// si no hay "for", buscamos checkbox cercano
let el = hit;
for (let i = 0; i < 4; i++) {
const parent = el.parentElement;
if (!parent) break;
const cb2 = parent.querySelector('input[type="checkbox"]');
if (cb2) {
cb2.checked = true;
cb2.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
el = parent;
}
return false;
});
return !!ok;
}
async function setEstadoByCodeOrLabel(page, newStatusValue) {
await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: 60000 });
// Primero intentamos por VALUE (si el select usa value=303 etc)
try {
await page.selectOption(CONFIG.SEL.statusDropdown, { value: String(newStatusValue) });
return { method: 'value', picked: String(newStatusValue) };
} catch (_) {}
// Segundo: intentamos por label con el texto del código (si el option pinta "348 - En espera...")
const title = CODE_TO_TITLE.get(String(newStatusValue));
const candidates = [];
if (title) {
candidates.push(`${newStatusValue} · ${title}`);
candidates.push(`${newStatusValue} - ${title}`);
candidates.push(`${newStatusValue} ${title}`);
candidates.push(title);
} else {
candidates.push(String(newStatusValue));
}
for (const label of candidates) {
try {
await page.selectOption(CONFIG.SEL.statusDropdown, { label });
return { method: 'label', picked: label };
} catch (_) {}
}
// Tercero: buscar en DOM option que contenga el código
const ok = await page.evaluate(({ sel, code }) => {
const s = document.querySelector(sel);
if (!s) return null;
const opts = Array.from(s.querySelectorAll('option'));
const hit =
opts.find(o => String(o.value).trim() === String(code).trim()) ||
opts.find(o => (o.textContent || '').includes(String(code)));
if (!hit) return null;
s.value = hit.value;
s.dispatchEvent(new Event('change', { bubbles: true }));
return { value: hit.value, text: (hit.textContent || '').trim() };
}, { sel: CONFIG.SEL.statusDropdown, code: String(newStatusValue) });
if (!ok) throw new Error(`No matching status option for code "${newStatusValue}"`);
return { method: 'dom', picked: ok.value, text: ok.text };
}
async function fillDateIfExists(page, dateString) {
if (!trim(dateString)) return false;
const el = await page.$(CONFIG.SEL.dateInput);
if (!el) return false;
try {
await page.fill(CONFIG.SEL.dateInput, String(dateString));
return true;
} catch (_) {
return false;
}
}
async function fillObservationIfExists(page, observation) {
if (!trim(observation)) return false;
const ta = await page.$(CONFIG.SEL.noteTextarea);
if (!ta) return false;
await page.fill(CONFIG.SEL.noteTextarea, String(observation));
return true;
}
async function clickSave(page) {
const save = await page.$(CONFIG.SEL.saveBtn);
if (!save) throw new Error('Save button not found');
await save.click();
await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT_MS });
await sleep(1200);
}
async function changeStatusViaGestor(page, job, creds) {
await login(page, creds);
await openParte(page, job.serviceNumber);
const pick = await setEstadoByCodeOrLabel(page, job.newStatusValue);
const informed = job.informoCliente ? await checkInformedBox(page) : false;
const dateFilled = await fillDateIfExists(page, job.dateString);
const obsFilled = await fillObservationIfExists(page, job.observation);
await clickSave(page);
const title = await page.title().catch(() => '');
const snippet = await page.evaluate(() => (document.body ? document.body.innerText : '')).catch(() => '');
return {
ok: true,
flow: 'gestor',
picked: pick,
informedChecked: informed,
dateFilled,
observationFilled: obsFilled,
pageTitle: title,
snippet: trim(snippet).slice(0, 600),
};
}
// -------------------- API server --------------------
const app = express();
app.use(cors({ origin: true }));
app.use(express.json({ limit: '1mb' }));
app.get('/', (req, res) => res.status(200).send('ok'));
app.get('/health', (req, res) => {
const port = Number(process.env.PORT || process.env.CAPROVER_PORT || 3000);
res.json({
ok: true,
service: 'estados-hs',
port,
requireAuth: CONFIG.REQUIRE_AUTH,
hsCredDocPath: process.env.HS_CRED_DOC_PATH || 'providerCredentials/homeserve',
baseUrl: CONFIG.HOMESERVE_BASE_URL,
ts: nowISO(),
});
});
app.get('/statuses', (req, res) => {
res.json({ ok: true, statuses: STATUS_CODES });
});
function authMiddleware(req, res, next) {
if (!CONFIG.REQUIRE_AUTH) return next();
if (!CONFIG.API_KEY) return res.status(500).json({ ok: false, error: { message: 'REQUIRE_AUTH=1 but API_KEY not set' } });
const k = trim(req.headers['x-api-key'] || '') || trim(req.query.apiKey || '');
if (k !== CONFIG.API_KEY) return res.status(401).json({ ok: false, error: { message: 'Unauthorized' } });
next();
}
// HTML de prueba servido desde el propio server (por si te da pereza subir un .html)
app.get('/test', (req, res) => {
res.type('html').send(buildTestHtml());
});
app.post('/api/homeserve/change-status', authMiddleware, async (req, res) => {
const startedAtISO = nowISO();
const job = {
serviceNumber: trim(req.body?.serviceNumber),
newStatusValue: trim(req.body?.newStatusValue),
dateString: trim(req.body?.dateString),
observation: trim(req.body?.observation),
informoCliente: parseBool(req.body?.informoCliente, true), // por defecto TRUE
};
if (!job.serviceNumber || !job.newStatusValue) {
return res.status(400).json({
ok: false,
startedAtISO,
finishedAtISO: nowISO(),
error: { message: 'Missing serviceNumber or newStatusValue' },
});
}
let db;
try {
db = initFirebase();
} catch (e) {
return res.status(500).json({
ok: false,
startedAtISO,
finishedAtISO: nowISO(),
error: { message: String(e?.message || e), stack: String(e?.stack || '') },
});
}
try {
const creds = await getHomeServeCreds(db);
const result = await withBrowser(async (page) => {
return await changeStatusViaGestor(page, job, creds);
});
return res.json({
ok: true,
startedAtISO,
finishedAtISO: nowISO(),
request: job,
result,
});
} catch (err) {
return res.status(500).json({
ok: false,
startedAtISO,
finishedAtISO: nowISO(),
request: job,
error: { message: String(err?.message || err), stack: String(err?.stack || '') },
});
}
});
function buildTestHtml() {
const options = STATUS_CODES.map(
(s) => `<option value="${s.code}">${s.code} · ${escapeHtml(s.title)}</option>`
).join('\n');
return `<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>estados-hs · test</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial;margin:0;padding:24px;background:#0b1220;color:#e8eefc}
.card{max-width:820px;margin:0 auto;background:#111a2e;border:1px solid rgba(255,255,255,.08);border-radius:16px;padding:18px}
label{display:block;margin:12px 0 6px;font-size:13px;opacity:.85}
input,select,textarea,button{width:100%;padding:12px;border-radius:12px;border:1px solid rgba(255,255,255,.12);background:#0b1220;color:#e8eefc}
textarea{min-height:90px}
button{cursor:pointer;font-weight:700}
.row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.log{white-space:pre-wrap;background:#0b1220;border-radius:12px;border:1px solid rgba(255,255,255,.12);padding:12px;margin-top:12px;font-size:12px}
.muted{opacity:.7;font-size:12px}
.btnrow{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:10px}
.check{display:flex;gap:10px;align-items:center;margin-top:10px}
.check input{width:auto}
</style>
</head>
<body>
<div class="card">
<h2 style="margin:0 0 8px">estados-hs · pruebas (flujo gestor)</h2>
<div class="muted">Consejo: si /health responde JSON, el server está vivo. Si POST devuelve ok:true, ya estás cambiando estado 🔧</div>
<label>Base URL (auto)</label>
<input id="base" />
<div class="btnrow">
<button id="btnHealth">Probar /health</button>
<button id="btnStatuses">Ver /statuses</button>
</div>
<hr style="border:0;border-top:1px solid rgba(255,255,255,.08);margin:14px 0" />
<label>Service Number</label>
<input id="serviceNumber" placeholder="15251178" />
<div class="row">
<div>
<label>Código estado</label>
<select id="statusCode">
${options}
</select>
</div>
<div>
<label>Fecha (DD/MM/AAAA) (opcional)</label>
<input id="dateString" placeholder="05/01/2026" />
</div>
</div>
<label>Observación (opcional)</label>
<textarea id="observation" placeholder="se le envia whatsapp al asegurado"></textarea>
<div class="check">
<input type="checkbox" id="informo" checked />
<label for="informo" style="margin:0">Marcar “ya he informado al cliente”</label>
</div>
<div style="height:10px"></div>
<button id="btnSend">Enviar (POST /api/homeserve/change-status)</button>
<div class="log" id="log">Listo.</div>
</div>
<script>
const $ = (id)=>document.getElementById(id);
const log = (x)=>{ $('log').textContent = (typeof x==='string'?x:JSON.stringify(x,null,2)); };
const baseDefault = window.location.origin;
$('base').value = baseDefault;
async function call(path, opts){
const base = $('base').value.trim() || baseDefault;
const url = base.replace(/\\/$/, '') + path;
const r = await fetch(url, opts);
const txt = await r.text();
let j = {};
try{ j = JSON.parse(txt); } catch(_){ j = { raw: txt }; }
return { http: r.status, url, ...j };
}
$('btnHealth').addEventListener('click', async () => {
try{ log('probando /health...'); log(await call('/health')); }
catch(e){ log(String(e)); }
});
$('btnStatuses').addEventListener('click', async () => {
try{ log('cargando /statuses...'); log(await call('/statuses')); }
catch(e){ log(String(e)); }
});
$('btnSend').addEventListener('click', async () => {
try{
log('Enviando...');
const body = {
serviceNumber: $('serviceNumber').value.trim(),
newStatusValue: $('statusCode').value.trim(),
dateString: $('dateString').value.trim(),
observation: $('observation').value.trim(),
informoCliente: $('informo').checked
};
log(await call('/api/homeserve/change-status', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(body)
}));
}catch(e){
log(String(e));
}
});
</script>
</body>
</html>`;
}
function escapeHtml(s) {
return String(s || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
const PORT = Number(process.env.PORT || process.env.CAPROVER_PORT || 3000);
app.listen(PORT, () => {
console.log(`[estados-hs] listening on :${PORT}`);
console.log(`[estados-hs] HOMESERVE_BASE_URL=${CONFIG.HOMESERVE_BASE_URL}`);
console.log(`[estados-hs] HS_CRED_DOC_PATH=${process.env.HS_CRED_DOC_PATH || 'providerCredentials/homeserve'}`);
console.log(`[estados-hs] REQUIRE_AUTH=${CONFIG.REQUIRE_AUTH ? 1 : 0}`);
console.log(`[estados-hs] HEADLESS=${CONFIG.HEADLESS ? 1 : 0}`);
});