estados-homeserve/index.js

592 lines
19 KiB
JavaScript

'use strict';
const express = require('express');
const cors = require('cors');
const { chromium } = require('playwright');
const admin = require('firebase-admin');
function mustEnv(name) {
const v = process.env[name];
if (!v) throw new Error(`Missing env: ${name}`);
return v;
}
function initFirebase() {
// Si NO quieres Firebase, puedes arrancar sin admin.
// Pero aquí lo usamos para leer secrets/homeserve.
if (!process.env.FIREBASE_PRIVATE_KEY) {
// Permitimos arrancar sin Firebase si pones creds por ENV
// (pero si tampoco hay creds, fallará al ejecutar).
return null;
}
if (!admin.apps.length) {
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 admin.firestore();
}
const CONFIG = {
// 🔥 IMPORTANTE: por defecto entramos por “clientes CGI” (como tu HTML),
// y si quieres lo cambias por ENV o por Firestore (secrets/homeserve.baseUrl).
DEFAULT_HS_BASE_URL:
process.env.HOMESERVE_BASE_URL ||
'https://www.clientes.homeserve.es/cgi-bin/fccgi.exe?w3exec=prof_pass&urgente',
HS_CRED_DOC_PATH: process.env.HS_CRED_DOC_PATH || 'secrets/homeserve',
// auth para el endpoint (opcional)
REQUIRE_AUTH: String(process.env.REQUIRE_AUTH || '0') === '1',
AUTH_TOKEN: process.env.AUTH_TOKEN || '',
// Selectores: mantenemos compatibles con tu robot antiguo
SEL: {
user: process.env.SEL_USER || 'input[type="text"], input[name*="user" i], input[name*="usuario" i]',
pass: process.env.SEL_PASS || 'input[type="password"], input[name*="pass" i], input[name*="clave" i]',
submit:
process.env.SEL_SUBMIT ||
'button[type="submit"], input[type="submit"], button:has-text("Entrar"), button:has-text("Acceder")',
searchBox:
process.env.SEL_SEARCH_BOX ||
'input[placeholder*="Buscar"], input[type="search"], input[name*="buscar" i], input[id*="buscar" i]',
searchBtn:
process.env.SEL_SEARCH_BTN ||
'button:has-text("Buscar"), button:has-text("Search"), input[type="submit"][value*="Buscar" i]',
openRow: process.env.SEL_OPEN_ROW || 'table tbody tr:first-child',
// Estado / observaciones / fecha / checkbox informado
statusDropdown:
process.env.SEL_STATUS_DROPDOWN ||
'select[name*="estado" i], select[id*="estado" i], select[name="ESTADO"], select:has(option)',
noteTextarea:
process.env.SEL_NOTE_TEXTAREA ||
'textarea[name*="nota" i], textarea[name*="observa" i], textarea[id*="nota" i], textarea[id*="observa" i], textarea',
dateInput:
process.env.SEL_DATE_INPUT ||
'input[name*="fecha" i], input[id*="fecha" i], input[placeholder*="dd" i], input[type="date"]',
informedCheckbox:
process.env.SEL_INFORMED_CHECKBOX ||
'input[type="checkbox"][name="INFORMO"], input[type="checkbox"][id*="inform" i], input[type="checkbox"][name*="inform" i]',
saveBtn:
process.env.SEL_SAVE_BTN ||
'button:has-text("Guardar"), button:has-text("Actualizar"), button:has-text("Aceptar"), input[type="submit"][value*="Guardar" i], input[type="submit"][value*="Actualizar" i]',
},
// timeouts
GOTO_TIMEOUT: parseInt(process.env.GOTO_TIMEOUT || '120000', 10),
WAIT_TIMEOUT: parseInt(process.env.WAIT_TIMEOUT || '60000', 10),
};
const app = express();
app.use(cors({ origin: true }));
app.use(express.json({ limit: '1mb' }));
// -------------------------
// Helpers
// -------------------------
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function pickPort() {
// CapRover suele inyectar CAPROVER_PORT
// Si no existe, usamos PORT o 3000.
return parseInt(process.env.CAPROVER_PORT || process.env.PORT || '3000', 10);
}
function ensureAuth(req) {
if (!CONFIG.REQUIRE_AUTH) return;
const token = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
if (!token || !CONFIG.AUTH_TOKEN || token !== CONFIG.AUTH_TOKEN) {
const err = new Error('Unauthorized');
err.statusCode = 401;
throw err;
}
}
let cachedCreds = null;
let cachedCredsAt = 0;
async function getHomeServeCreds(db) {
// 1) ENV manda
const envUser = process.env.HOMESERVE_USER;
const envPass = process.env.HOMESERVE_PASS;
const envBase = process.env.HOMESERVE_BASE_URL;
if (envUser && envPass) {
return {
user: envUser,
pass: envPass,
baseUrl: envBase || CONFIG.DEFAULT_HS_BASE_URL,
};
}
// 2) Firestore
if (!db) {
throw new Error(
`HomeServe creds missing. Set env HOMESERVE_USER/HOMESERVE_PASS or provide Firebase envs + doc "${CONFIG.HS_CRED_DOC_PATH}"`
);
}
// cache 30s
const now = Date.now();
if (cachedCreds && now - cachedCredsAt < 30000) return cachedCreds;
const snap = await db.doc(CONFIG.HS_CRED_DOC_PATH).get();
if (!snap.exists) {
throw new Error(`HomeServe creds missing. Create Firestore doc "${CONFIG.HS_CRED_DOC_PATH}" with { user, pass, baseUrl? }`);
}
const d = snap.data() || {};
const user = d.user || d.email || d.usuario || '';
const pass = d.pass || d.password || d.clave || '';
if (!user || !pass) {
throw new Error(`HomeServe creds missing in Firestore doc "${CONFIG.HS_CRED_DOC_PATH}". Needs fields "user" and "pass".`);
}
const baseUrl = d.baseUrl || d.HOMESERVE_BASE_URL || CONFIG.DEFAULT_HS_BASE_URL;
cachedCreds = { user, pass, baseUrl };
cachedCredsAt = now;
return cachedCreds;
}
async function withBrowser(fn) {
const browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
const context = await browser.newContext();
const page = await context.newPage();
// Un poco de margen
page.setDefaultTimeout(CONFIG.WAIT_TIMEOUT);
try {
return await fn(page);
} finally {
await browser.close().catch(() => {});
}
}
async function fillFirst(page, selectors, value) {
for (const sel of selectors) {
const el = await page.$(sel).catch(() => null);
if (el) {
await page.fill(sel, value);
return true;
}
}
return false;
}
async function clickFirst(page, selectors) {
for (const sel of selectors) {
const el = await page.$(sel).catch(() => null);
if (el) {
await el.click();
return true;
}
}
return false;
}
async function login(page, { baseUrl, user, pass }) {
await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: CONFIG.GOTO_TIMEOUT });
// user/pass
const okUser = await fillFirst(page, [CONFIG.SEL.user], user);
const okPass = await fillFirst(page, [CONFIG.SEL.pass], pass);
if (!okUser || !okPass) {
throw new Error('Login form not found (user/pass selectors). Adjust SEL_USER / SEL_PASS.');
}
// submit
const clicked = await clickFirst(page, [CONFIG.SEL.submit]);
if (!clicked) {
// fallback Enter
await page.keyboard.press('Enter');
}
// esperamos navegación
await page.waitForLoadState('networkidle', { timeout: CONFIG.GOTO_TIMEOUT }).catch(() => {});
await sleep(800);
}
async function openParte(page, serviceNumber) {
// búsqueda
const hasSearch = await page.$(CONFIG.SEL.searchBox).catch(() => null);
if (hasSearch) {
await page.fill(CONFIG.SEL.searchBox, String(serviceNumber));
const btn = await page.$(CONFIG.SEL.searchBtn).catch(() => null);
if (btn) await btn.click();
else await page.keyboard.press('Enter');
await page.waitForLoadState('networkidle', { timeout: CONFIG.GOTO_TIMEOUT }).catch(() => {});
await sleep(1200);
}
// abre primera fila
const row = await page.$(CONFIG.SEL.openRow).catch(() => null);
if (row) {
await row.click();
await page.waitForLoadState('networkidle', { timeout: CONFIG.GOTO_TIMEOUT }).catch(() => {});
await sleep(900);
}
// Si tu portal entra directo al parte sin tabla, esto simplemente no hace nada.
}
async function trySelectStatus(page, statusValue) {
await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: CONFIG.WAIT_TIMEOUT });
// 1) por value (lo que tú usas: 303/307/...)
try {
await page.selectOption(CONFIG.SEL.statusDropdown, { value: String(statusValue) });
return true;
} catch (_) {}
// 2) por label exacta (por si el portal usa texto)
try {
await page.selectOption(CONFIG.SEL.statusDropdown, { label: String(statusValue) });
return true;
} catch (_) {}
// 3) fallback DOM (busca option que contenga el código)
const ok = await page.evaluate(({ sel, value }) => {
const s = document.querySelector(sel);
if (!s) return false;
const opts = Array.from(s.querySelectorAll('option'));
const hit = opts.find(o => (o.value || '').trim() === String(value).trim()
|| (o.textContent || '').includes(String(value).trim()));
if (!hit) return false;
s.value = hit.value;
s.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}, { sel: CONFIG.SEL.statusDropdown, value: statusValue });
return !!ok;
}
async function setInformoCliente(page, informoCliente) {
if (!informoCliente) return;
// 1) selector directo (INFORMO)
const cb = await page.$(CONFIG.SEL.informedCheckbox).catch(() => null);
if (cb) {
const checked = await cb.isChecked().catch(() => false);
if (!checked) await cb.check().catch(() => {});
return;
}
// 2) fallback por label con el texto
const ok = await page.evaluate(() => {
const labels = Array.from(document.querySelectorAll('label'));
const target = labels.find(l => (l.textContent || '').toLowerCase().includes('marque esta casilla')
&& (l.textContent || '').toLowerCase().includes('informado'));
if (!target) return false;
const inputId = target.getAttribute('for');
if (inputId) {
const el = document.getElementById(inputId);
if (el && el.type === 'checkbox') {
el.checked = true;
el.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
}
const cb = target.querySelector('input[type="checkbox"]');
if (cb) {
cb.checked = true;
cb.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
return false;
});
if (!ok) {
// no lo hacemos fatal: solo avisamos si quieres forzarlo
// throw new Error('Could not find "Informado al cliente" checkbox. Set SEL_INFORMED_CHECKBOX.');
}
}
async function fillOptional(page, selector, value) {
if (!value) return false;
const el = await page.$(selector).catch(() => null);
if (!el) return false;
await page.fill(selector, String(value));
return true;
}
async function clickSave(page) {
const save = await page.$(CONFIG.SEL.saveBtn).catch(() => null);
if (!save) throw new Error('Save button not found. Adjust SEL_SAVE_BTN.');
await save.click();
await page.waitForLoadState('networkidle', { timeout: CONFIG.GOTO_TIMEOUT }).catch(() => {});
await sleep(900);
}
async function changeStatusViaHomeServe({ baseUrl, user, pass }, payload) {
const {
serviceNumber,
newStatusValue,
dateString,
observation,
informoCliente,
} = payload;
return await withBrowser(async (page) => {
await login(page, { baseUrl, user, pass });
// abre parte si hace falta
await openParte(page, serviceNumber);
// cambia estado
const selected = await trySelectStatus(page, newStatusValue);
if (!selected) {
throw new Error(`No matching status option for "${newStatusValue}". Adjust status selector or confirm option values.`);
}
// fecha + observación (si el portal lo tiene)
if (dateString) {
await fillOptional(page, CONFIG.SEL.dateInput, dateString);
}
if (observation) {
await fillOptional(page, CONFIG.SEL.noteTextarea, observation);
}
// ✅ casilla "ya informado al Cliente"
await setInformoCliente(page, !!informoCliente);
// guardar
await clickSave(page);
return { ok: true };
});
}
// -------------------------
// Routes
// -------------------------
const db = initFirebase();
app.get('/', (req, res) => {
res.status(200).send('ok');
});
app.get('/health', async (req, res) => {
res.json({
ok: true,
service: 'estados-hs',
port: pickPort(),
requireAuth: CONFIG.REQUIRE_AUTH,
hsCredDocPath: CONFIG.HS_CRED_DOC_PATH,
defaultBaseUrl: CONFIG.DEFAULT_HS_BASE_URL,
ts: new Date().toISOString(),
});
});
// HTML de prueba (corregido + checkbox informoCliente)
app.get('/test', (req, res) => {
res.type('html').send(TEST_HTML);
});
app.post('/api/homeserve/change-status', async (req, res) => {
const startedAtISO = new Date().toISOString();
try {
ensureAuth(req);
const body = req.body || {};
const serviceNumber = String(body.serviceNumber || '').trim();
const newStatusValue = String(body.newStatusValue || '').trim();
// dateString opcional (DD/MM/AAAA)
const dateString = String(body.dateString || '').trim();
const observation = String(body.observation || '').trim();
const informoCliente = !!body.informoCliente;
if (!serviceNumber || !newStatusValue) {
return res.status(400).json({
ok: false,
error: { message: 'Missing serviceNumber or newStatusValue' },
});
}
const creds = await getHomeServeCreds(db);
const result = await changeStatusViaHomeServe(creds, {
serviceNumber,
newStatusValue,
dateString: dateString || '',
observation: observation || '',
informoCliente,
});
return res.json({
ok: true,
startedAtISO,
finishedAtISO: new Date().toISOString(),
request: {
serviceNumber,
newStatusValue,
dateString: dateString || '',
observation: observation || '',
informoCliente,
},
result,
});
} catch (err) {
const code = err?.statusCode || 500;
return res.status(code).json({
ok: false,
startedAtISO,
finishedAtISO: new Date().toISOString(),
error: {
message: String(err?.message || err),
stack: String(err?.stack || ''),
},
});
}
});
const port = pickPort();
app.listen(port, () => {
console.log(`[estados-hs] HS_CRED_DOC_PATH=${CONFIG.HS_CRED_DOC_PATH}`);
console.log(`[estados-hs] REQUIRE_AUTH=${CONFIG.REQUIRE_AUTH ? '1' : '0'}`);
console.log(`[estados-hs] ENV PORT=${process.env.PORT || '(unset)'} CAPROVER_PORT=${process.env.CAPROVER_PORT || '(unset)'}`);
console.log(`[estados-hs] listening on :${port}`);
});
// -------------------------
// Embedded TEST HTML
// -------------------------
const TEST_HTML = `<!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:760px;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:800}
.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}
.checkline{display:flex;gap:10px;align-items:flex-start;margin-top:12px;padding:10px;border-radius:12px;background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.08)}
.checkline input{width:auto;margin-top:2px}
</style>
</head>
<body>
<div class="card">
<h2 style="margin:0 0 8px">estados-hs · prueba</h2>
<div class="muted">POST <code>/api/homeserve/change-status</code></div>
<label>Nº de Servicio</label>
<input id="serviceNumber" placeholder="Ej: 15251178" />
<div class="row">
<div>
<label>Código estado</label>
<select id="statusCode">
<option value="303">303 · En espera de Cliente por aceptación Presupuesto</option>
<option value="307">307 · En espera de Profesional por fecha de inicio de trabajos</option>
<option value="313">313 · En espera de Profesional por secado de cala, pintura o parquet</option>
<option value="318">318 · En espera de Profesional por confirmación del Siniestro</option>
<option value="319">319 · En espera de Profesional por material</option>
<option value="320">320 · En espera de Profesional por espera de otro gremio</option>
<option value="321">321 · En espera de Profesional por presupuesto/valoración</option>
<option value="323">323 · En espera de Profesional por mejora del tiempo</option>
<option value="326">326 · En espera de Cliente por pago de Factura Contado/Franquicia</option>
<option value="336">336 · En espera de Profesional por avería en observación</option>
<option value="342">342 · En espera de Profesional pendiente cobro franquicia</option>
<option value="345">345 · En espera de Profesional en realización pendiente Terminar</option>
<option value="348" selected>348 · En espera de Cliente por indicaciones</option>
<option value="352">352 · En espera de Perjudicado por indicaciones</option>
</select>
</div>
<div>
<label>Fecha siguiente acción</label>
<input id="fecha" type="date" />
<div class="muted" id="fechaOut" style="margin-top:6px"></div>
</div>
</div>
<label>Observación (opcional)</label>
<textarea id="observation" placeholder="Ej: se le envía WhatsApp al asegurado"></textarea>
<div class="checkline">
<input id="informoCliente" type="checkbox" />
<div>
<div style="font-weight:800">Marcar como informado al cliente</div>
<div class="muted">Equivale a: “Marque esta casilla, si ya ha informado al Cliente”</div>
</div>
</div>
<div style="height:12px"></div>
<button id="btn">Enviar</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)); };
function toDDMMYYYY(iso){
if(!iso) return "";
const [y,m,d] = iso.split("-");
if(!y||!m||!d) return "";
return \`\${d}/\${m}/\${y}\`;
}
function refreshFechaOut(){
const iso = $('fecha').value;
const dd = toDDMMYYYY(iso);
$('fechaOut').textContent = dd ? ('Se enviará como: ' + dd) : '';
}
$('fecha').addEventListener('change', refreshFechaOut);
refreshFechaOut();
$('btn').addEventListener('click', async () => {
try{
log('Enviando...');
const dateString = toDDMMYYYY($('fecha').value);
const body = {
serviceNumber: $('serviceNumber').value.trim(),
newStatusValue: $('statusCode').value.trim(),
dateString,
observation: $('observation').value.trim(),
informoCliente: $('informoCliente').checked
};
const r = await fetch('/api/homeserve/change-status', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(body)
});
const j = await r.json().catch(()=>({}));
log({ http: r.status, url: location.origin + '/api/homeserve/change-status', ...j });
}catch(e){
log(String(e));
}
});
</script>
</body>
</html>`;