547 lines
18 KiB
JavaScript
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}`);
|
|
}); |