estados-homeserve/index.js

547 lines
18 KiB
JavaScript

'use strict';
const express = require('express');
const cors = require('cors');
const { chromium } = require('playwright');
const admin = require('firebase-admin');
const app = express();
app.use(express.json({ limit: '1mb' }));
app.use(cors());
/** ========= Helpers ========= */
function boolEnv(name, def = false) {
const v = process.env[name];
if (v === undefined) return def;
return ['1', 'true', 'yes', 'on'].includes(String(v).toLowerCase());
}
function mustEnv(name) {
const v = process.env[name];
if (!v) throw new Error(`Missing env: ${name}`);
return v;
}
function safeTrim(x) {
return String(x ?? '').trim();
}
function nowISO() {
return new Date().toISOString();
}
function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
/** ========= Firebase / Firestore ========= */
function initFirebase() {
// Admite admin por variables (como tus otros robots)
if (!process.env.FIREBASE_PRIVATE_KEY) throw new Error('Missing env: FIREBASE_PRIVATE_KEY');
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();
}
function docRefFromPath(db, docPath) {
// docPath tipo "secrets/homeserve"
const parts = String(docPath).split('/').filter(Boolean);
if (parts.length < 2 || parts.length % 2 !== 0) {
throw new Error(`Invalid HS_CRED_DOC_PATH "${docPath}". Expected "collection/doc" or "a/b/c/d".`);
}
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]);
}
return ref;
}
async function getHomeServeCreds(db) {
// Puedes definirlo por env o por Firestore doc.
const envUser = process.env.HOMESERVE_USER || process.env.HS_USER;
const envPass = process.env.HOMESERVE_PASS || process.env.HS_PASS;
const envBaseUrl = process.env.HOMESERVE_BASE_URL || process.env.HS_BASE_URL;
if (envUser && envPass) {
return {
user: String(envUser),
pass: String(envPass),
baseUrl: String(envBaseUrl || ''),
source: 'env'
};
}
const docPath = process.env.HS_CRED_DOC_PATH || 'secrets/homeserve';
const ref = docRefFromPath(db, docPath);
const snap = await ref.get();
if (!snap.exists) {
throw new Error(`HomeServe creds missing. Create Firestore doc "${docPath}" with { user, pass, baseUrl? }`);
}
const d = snap.data() || {};
// Acepta varios nombres de campo (por tu captura)
const user =
d.user ?? d.username ?? d.email ?? d.usuario ?? d.HOMESERVE_USER ?? d.HS_USER ?? d.USER ?? d.USERNAME;
const pass =
d.pass ?? d.password ?? d.clave ?? d.HOMESERVE_PASS ?? d.HS_PASS ?? d.PASS ?? d.PASSWORD;
const baseUrl =
d.baseUrl ?? d.HOMESERVE_BASE_URL ?? d.HS_BASE_URL ?? d.url ?? d.URL ?? '';
if (!user || !pass) {
throw new Error(
`HomeServe creds missing. Firestore doc "${docPath}" exists but fields not found. ` +
`Use { user, pass } or { HOMESERVE_USER, HOMESERVE_PASS } or { username, password }.`
);
}
return {
user: String(user),
pass: String(pass),
baseUrl: String(baseUrl || ''),
source: `firestore:${docPath}`
};
}
/** ========= Config ========= */
const CONFIG = {
REQUIRE_AUTH: boolEnv('REQUIRE_AUTH', false),
API_KEY: process.env.API_KEY || '',
// Portal clientes (más estable si gestor.homeserve.es no resuelve en tu contenedor)
// Puedes meter en Firestore doc "baseUrl" o por env HOMESERVE_BASE_URL
DEFAULT_CLIENTES_BASE: 'https://www.clientes.homeserve.es/cgi-bin/fccgi.exe',
// Selectores "genéricos"
SEL: {
loginUser: [
'input[name*="user" i]',
'input[name*="usuario" i]',
'input[name*="login" i]',
'input[type="text"]'
].join(', '),
loginPass: 'input[type="password"]',
loginSubmit: [
'button[type="submit"]',
'input[type="submit"]',
'input[type="image"]',
'button:has-text("Entrar")',
'button:has-text("Acceder")',
'input[value*="Entrar" i]',
'input[value*="Acceder" i]'
].join(', '),
// Botón "repaso" (cambio de estado) según lo que me pasaste
repasoBtn: [
'input[type="image"][name="repaso"]',
'input[type="image"][name="repaso" i]',
'input[type="image"][title*="Cambiar" i][title*="Estado" i]',
'input[type="image"][src*="estado1.gif" i]',
'input[type="image"][src*="Imagenes/estado" i]',
'a:has(img[src*="estado1.gif" i])'
].join(', '),
// Form cambio estado
estadoSelect: [
'select[name*="estado" i]',
'select[id*="estado" i]',
'select[name*="Estado" i]',
'select:has(option)'
].join(', '),
fechaInput: [
'input[name*="fecha" i]',
'input[id*="fecha" i]',
'input[placeholder*="dd/mm" i]',
'input[type="date"]'
].join(', '),
obsTextarea: [
'textarea[name*="obs" i]',
'textarea[name*="observ" i]',
'textarea[name*="nota" i]',
'textarea[id*="obs" i]',
'textarea'
].join(', '),
submitCambio: [
'button[type="submit"]',
'input[type="submit"]',
'button:has-text("Guardar")',
'button:has-text("Aceptar")',
'input[value*="Guardar" i]',
'input[value*="Aceptar" i]',
'input[value*="Actualizar" i]'
].join(', '),
}
};
// Estados EXACTOS como los que usas en app / HTML
const STATUS_OPTIONS = [
{ 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' },
];
/** ========= Auth (opcional) ========= */
function authMiddleware(req, res, next) {
if (!CONFIG.REQUIRE_AUTH) return next();
// modo simple por API_KEY (si lo quieres)
if (CONFIG.API_KEY) {
const got = req.headers['x-api-key'] || (req.headers.authorization || '').replace(/^Bearer\s+/i, '');
if (got && String(got) === String(CONFIG.API_KEY)) return next();
return res.status(401).json({ ok: false, error: { message: 'Unauthorized (API key).' } });
}
// Si REQUIRE_AUTH=1 y no pones API_KEY, lo dejamos pasar (para no bloquearte).
return next();
}
/** ========= Playwright ========= */
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();
page.setDefaultTimeout(60000);
try {
return await fn(page);
} finally {
await browser.close().catch(() => {});
}
}
function buildServiceUrl(baseUrl, serviceNumber) {
const b = safeTrim(baseUrl);
if (!b) return `${CONFIG.DEFAULT_CLIENTES_BASE}?w3exec=ver_servicioencurso&Servicio=${encodeURIComponent(serviceNumber)}&Pag=1`;
// Si te pasan ya una URL completa con Servicio=..., la respetamos
if (b.includes('Servicio=')) return b;
// Si te pasan el fccgi.exe, montamos la query
if (b.includes('fccgi.exe')) {
const sep = b.includes('?') ? '&' : '?';
return `${b}${sep}w3exec=ver_servicioencurso&Servicio=${encodeURIComponent(serviceNumber)}&Pag=1`;
}
// Por defecto, intentamos que sea base y añadimos path típico
const sep = b.endsWith('/') ? '' : '/';
return `${b}${sep}cgi-bin/fccgi.exe?w3exec=ver_servicioencurso&Servicio=${encodeURIComponent(serviceNumber)}&Pag=1`;
}
async function maybeLogin(page, creds) {
// Si aparece password, asumimos login.
const pass = page.locator(CONFIG.SEL.loginPass);
if (!(await pass.count())) return;
const user = page.locator(CONFIG.SEL.loginUser).first();
// Rellenar
await user.fill(String(creds.user));
await pass.first().fill(String(creds.pass));
const submit = page.locator(CONFIG.SEL.loginSubmit).first();
if (await submit.count()) {
await submit.click({ timeout: 60000 }).catch(async () => {
await page.keyboard.press('Enter');
});
} else {
await page.keyboard.press('Enter');
}
await page.waitForLoadState('domcontentloaded', { timeout: 120000 });
await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {});
}
async function gotoService(page, serviceUrl, creds) {
await page.goto(serviceUrl, { waitUntil: 'domcontentloaded', timeout: 120000 });
// Si redirige a login, intentamos loguear y volver al servicio
await maybeLogin(page, creds);
// A veces tras login te manda a home, así que volvemos al servicio
if (!page.url().includes('Servicio=') || page.url().includes('w3exec=login')) {
await page.goto(serviceUrl, { waitUntil: 'domcontentloaded', timeout: 120000 });
}
await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {});
}
async function clickChangeState(page) {
// Botón repaso (input type=image) o link con imagen
const btn = page.locator(CONFIG.SEL.repasoBtn).first();
if (!(await btn.count())) {
// Debug útil: por si el portal cambia, te dejo un fallback mirando el HTML
const html = await page.content().catch(() => '');
if (html && html.toLowerCase().includes('repaso')) {
// existe texto pero no casó selector => portal raro, pero seguimos informando
}
throw new Error('No encuentro el botón de "Cambiar estado" (repaso).');
}
await Promise.allSettled([
page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 120000 }),
btn.click({ timeout: 60000, force: true }),
]);
await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {});
}
function ddmmyyyyToYyyyMmDd(ddmmyyyy) {
const s = safeTrim(ddmmyyyy);
const m = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
if (!m) return '';
return `${m[3]}-${m[2]}-${m[1]}`;
}
async function setCheckboxInformadoCliente(page, enable) {
if (!enable) return;
// Busca checkbox cuyo texto cercano contenga "informado al Cliente"
const ok = await page.evaluate(() => {
const needles = ['informado al cliente', 'ha informado al cliente', 'informado al Cliente'.toLowerCase()];
const cbs = Array.from(document.querySelectorAll('input[type="checkbox"]'));
for (const cb of cbs) {
const parent = cb.closest('label') || cb.parentElement || cb.closest('td') || cb.closest('tr') || cb.closest('div');
const txt = (parent ? parent.textContent : cb.getAttribute('title') || '').toLowerCase();
if (needles.some(n => txt.includes(n))) {
cb.checked = true;
cb.dispatchEvent(new Event('change', { bubbles: true }));
cb.dispatchEvent(new Event('click', { bubbles: true }));
return true;
}
}
// Fallback: si solo hay 1 checkbox en la página, la marcamos
if (cbs.length === 1) {
cbs[0].checked = true;
cbs[0].dispatchEvent(new Event('change', { bubbles: true }));
cbs[0].dispatchEvent(new Event('click', { bubbles: true }));
return true;
}
return false;
});
if (!ok) {
// No lo hacemos fatal: si no existe esa casilla en ese servicio, que no rompa el cambio
console.warn('[estados-hs] Aviso: no encontré la casilla de "informado al Cliente". Continúo igualmente.');
}
}
async function setEstadoForm(page, payload) {
const { newStatusValue, dateString, observation, informoCliente } = payload;
// Select estado
const sel = page.locator(CONFIG.SEL.estadoSelect).first();
if (!(await sel.count())) throw new Error('No encuentro el selector de "estado".');
// 1) intentar por value (códigos 303/307/...)
let selected = false;
try {
await sel.selectOption({ value: String(newStatusValue) });
selected = true;
} catch (_) {}
// 2) si falla, intentar por label que contenga el código
if (!selected) {
const opt = STATUS_OPTIONS.find(o => o.code === String(newStatusValue));
const labelTry = opt ? `${opt.code}` : String(newStatusValue);
try {
await sel.selectOption({ label: labelTry });
selected = true;
} catch (_) {}
}
// 3) fallback DOM
if (!selected) {
const ok = await page.evaluate(({ code }) => {
const s = document.querySelector('select[name*="estado" i], select[id*="estado" i], select');
if (!s) return false;
const opts = Array.from(s.querySelectorAll('option'));
const hit = opts.find(o => (o.value || '').trim() === String(code).trim()) ||
opts.find(o => (o.textContent || '').includes(String(code)));
if (!hit) return false;
s.value = hit.value;
s.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}, { code: String(newStatusValue) });
if (!ok) throw new Error(`No matching status option for "${newStatusValue}"`);
}
// Fecha (opcional)
if (safeTrim(dateString)) {
const dateInput = page.locator(CONFIG.SEL.fechaInput).first();
if (await dateInput.count()) {
// si es type=date, preferimos yyyy-mm-dd
const type = await dateInput.getAttribute('type').catch(() => '');
if (String(type).toLowerCase() === 'date') {
const ymd = ddmmyyyyToYyyyMmDd(dateString);
await dateInput.fill(ymd || '');
} else {
await dateInput.fill(String(dateString));
}
}
}
// Observación (opcional)
if (safeTrim(observation)) {
const ta = page.locator(CONFIG.SEL.obsTextarea).first();
if (await ta.count()) {
await ta.fill(String(observation));
}
}
// Checkbox informado al cliente
await setCheckboxInformadoCliente(page, !!informoCliente);
// Guardar / enviar
const submit = page.locator(CONFIG.SEL.submitCambio).first();
if (!(await submit.count())) throw new Error('No encuentro el botón para guardar el cambio de estado.');
await Promise.allSettled([
page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 120000 }),
submit.click({ timeout: 60000, force: true }),
]);
await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {});
}
/** ========= Core: change status ========= */
async function changeStatusViaClientesPortal(db, payload) {
const creds = await getHomeServeCreds(db);
const baseUrl = safeTrim(creds.baseUrl) || process.env.HOMESERVE_BASE_URL || CONFIG.DEFAULT_CLIENTES_BASE;
const serviceUrl = buildServiceUrl(baseUrl, payload.serviceNumber);
return await withBrowser(async (page) => {
await gotoService(page, serviceUrl, creds);
// entrar a pantalla de cambio de estado (repaso)
await clickChangeState(page);
// rellenar form de cambio de estado
await setEstadoForm(page, payload);
return {
ok: true,
usedBaseUrl: baseUrl,
serviceUrl,
credsSource: creds.source,
};
});
}
/** ========= Routes ========= */
const db = initFirebase();
app.get('/', (req, res) => res.status(200).send('ok'));
app.get('/health', (req, res) => {
res.json({
ok: true,
service: 'estados-hs',
requireAuth: CONFIG.REQUIRE_AUTH,
hsCredDocPath: process.env.HS_CRED_DOC_PATH || 'secrets/homeserve',
port: Number(process.env.CAPROVER_PORT || process.env.PORT || 80),
ts: nowISO(),
});
});
app.get('/api/homeserve/status-options', (req, res) => {
res.json({ ok: true, options: STATUS_OPTIONS });
});
app.post('/api/homeserve/change-status', authMiddleware, async (req, res) => {
const startedAtISO = nowISO();
const serviceNumber = safeTrim(req.body?.serviceNumber);
const newStatusValue = safeTrim(req.body?.newStatusValue);
const dateString = safeTrim(req.body?.dateString); // DD/MM/YYYY
const observation = safeTrim(req.body?.observation);
const informoCliente = !!req.body?.informoCliente;
if (!serviceNumber || !newStatusValue) {
return res.status(400).json({
ok: false,
error: { message: 'Missing serviceNumber or newStatusValue' },
startedAtISO,
finishedAtISO: nowISO(),
});
}
try {
const out = await changeStatusViaClientesPortal(db, {
serviceNumber,
newStatusValue,
dateString,
observation,
informoCliente,
});
return res.json({
ok: true,
startedAtISO,
finishedAtISO: nowISO(),
request: { serviceNumber, newStatusValue, dateString, observation, informoCliente },
result: out,
});
} catch (err) {
return res.status(500).json({
ok: false,
startedAtISO,
finishedAtISO: nowISO(),
request: { serviceNumber, newStatusValue, dateString, observation, informoCliente },
error: {
message: String(err?.message || err),
stack: String(err?.stack || ''),
},
});
}
});
/** ========= Listen ========= */
const PORT = parseInt(process.env.CAPROVER_PORT || process.env.PORT || '80', 10);
console.log(`[estados-hs] HS_CRED_DOC_PATH=${process.env.HS_CRED_DOC_PATH || 'secrets/homeserve'}`);
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)'}`);
app.listen(PORT, () => {
console.log(`[estados-hs] listening on :${PORT}`);
});