747 lines
24 KiB
JavaScript
747 lines
24 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* estados-hs-direct (HomeServe)
|
|
* - API: POST /api/homeserve/change-status
|
|
* - Health: GET /health
|
|
* - Test UI: GET /test
|
|
*
|
|
* Lee credenciales:
|
|
* 1) env HOMESERVE_USER/HOMESERVE_PASS (si existen)
|
|
* 2) Firestore doc HS_CRED_DOC_PATH (por defecto: providerCredentials/homeserve) con { user, pass, baseUrl? }
|
|
*
|
|
* Navegación:
|
|
* - Abre servicio: https://www.clientes.homeserve.es/cgi-bin/fccgi.exe?w3exec=ver_servicioencurso&Servicio=XXXX&Pag=1
|
|
* - Click botón cambio estado: input[type="image"][name="repaso"] (o title contiene "Cambiar el Estado del Servicio")
|
|
* - En formulario: select estado (normalmente name="D1" o similar) -> selecciona por value (código)
|
|
* - Checkbox "ya informado al Cliente" (si informoCliente=true)
|
|
* - Guardar/Aceptar/Actualizar
|
|
*/
|
|
|
|
const express = require('express');
|
|
const cors = require('cors');
|
|
const { chromium } = require('playwright');
|
|
const admin = require('firebase-admin');
|
|
|
|
// --------------------- Utils ---------------------
|
|
function mustEnv(name) {
|
|
const v = process.env[name];
|
|
if (!v) throw new Error(`Missing env: ${name}`);
|
|
return v;
|
|
}
|
|
|
|
function toBool(v) {
|
|
if (v === true || v === false) return v;
|
|
if (v == null) return false;
|
|
const s = String(v).trim().toLowerCase();
|
|
return s === '1' || s === 'true' || s === 'yes' || s === 'y' || s === 'on';
|
|
}
|
|
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
function safeStr(x) {
|
|
return (x == null) ? '' : String(x);
|
|
}
|
|
|
|
function isoNow() {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
function pickPort() {
|
|
// CapRover normalmente usa process.env.PORT
|
|
const p = process.env.PORT || process.env.CAPROVER_PORT || '3000';
|
|
const n = parseInt(p, 10);
|
|
return Number.isFinite(n) ? n : 3000;
|
|
}
|
|
|
|
// --------------------- Firebase ---------------------
|
|
function initFirebase() {
|
|
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();
|
|
}
|
|
|
|
// --------------------- Config ---------------------
|
|
const CONFIG = {
|
|
REQUIRE_AUTH: toBool(process.env.REQUIRE_AUTH || '0'),
|
|
API_TOKEN: process.env.API_TOKEN || '',
|
|
|
|
HS_CRED_DOC_PATH: process.env.HS_CRED_DOC_PATH || 'providerCredentials/homeserve',
|
|
|
|
// Si NO viene baseUrl en Firestore ni env, usamos esta
|
|
HOMESERVE_BASE_URL: process.env.HOMESERVE_BASE_URL || 'https://www.clientes.homeserve.es/',
|
|
|
|
// Selectores HomeServe (con fallback)
|
|
SEL: {
|
|
// Login (muy genérico, porque HomeServe cambia formularios)
|
|
loginUserCandidates: [
|
|
'input[name="user"]',
|
|
'input[name="usuario"]',
|
|
'input[name="username"]',
|
|
'input[id*="user" i]',
|
|
'input[id*="usu" i]',
|
|
'input[type="text"]',
|
|
],
|
|
loginPassCandidates: [
|
|
'input[name="pass"]',
|
|
'input[name="password"]',
|
|
'input[name="clave"]',
|
|
'input[id*="pass" i]',
|
|
'input[id*="clave" i]',
|
|
'input[type="password"]',
|
|
],
|
|
loginSubmitCandidates: [
|
|
'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]',
|
|
],
|
|
|
|
// Servicio -> botón cambio estado (repaso)
|
|
changeStateBtnCandidates: [
|
|
'input[type="image"][name="repaso"]',
|
|
'input[type="image"][title*="Cambiar el Estado del Servicio" i]',
|
|
'input[type="image"][title*="Cambiar el Estado" i]',
|
|
'input[name="repaso"]',
|
|
],
|
|
|
|
// Form cambio de estado
|
|
statusSelectCandidates: [
|
|
'select[name="D1"]',
|
|
'select[id="D1"]',
|
|
'select[name*="estado" i]',
|
|
'select[id*="estado" i]',
|
|
'select',
|
|
],
|
|
dateInputCandidates: [
|
|
'input[name="D2"]',
|
|
'input[id="D2"]',
|
|
'input[name*="fecha" i]',
|
|
'input[id*="fecha" i]',
|
|
'input[type="text"]',
|
|
],
|
|
obsCandidates: [
|
|
'textarea[name="D3"]',
|
|
'textarea[id="D3"]',
|
|
'textarea[name*="obs" i]',
|
|
'textarea[id*="obs" i]',
|
|
'textarea[name*="nota" i]',
|
|
'textarea[id*="nota" i]',
|
|
'textarea',
|
|
],
|
|
saveBtnCandidates: [
|
|
'input[type="submit"]',
|
|
'button[type="submit"]',
|
|
'input[type="image"][name*="grabar" i]',
|
|
'input[type="image"][title*="Guardar" i]',
|
|
'button:has-text("Guardar")',
|
|
'button:has-text("Aceptar")',
|
|
'button:has-text("Actualizar")',
|
|
'input[value*="Guardar" i]',
|
|
'input[value*="Aceptar" i]',
|
|
'input[value*="Actualizar" i]',
|
|
],
|
|
},
|
|
|
|
// Estados permitidos (los de tu Swift)
|
|
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' },
|
|
],
|
|
};
|
|
|
|
// --------------------- Creds ---------------------
|
|
async function getHomeServeCreds(db) {
|
|
// 1) env manda
|
|
const envUser = process.env.HOMESERVE_USER;
|
|
const envPass = process.env.HOMESERVE_PASS;
|
|
|
|
if (envUser && envPass) {
|
|
return {
|
|
user: String(envUser),
|
|
pass: String(envPass),
|
|
baseUrl: process.env.HOMESERVE_BASE_URL || CONFIG.HOMESERVE_BASE_URL,
|
|
source: 'env',
|
|
};
|
|
}
|
|
|
|
// 2) Firestore doc
|
|
const ref = db.doc(CONFIG.HS_CRED_DOC_PATH);
|
|
const snap = await ref.get();
|
|
const d = snap.exists ? (snap.data() || {}) : null;
|
|
|
|
const user = d?.user || d?.username || d?.usuario;
|
|
const pass = d?.pass || d?.password || d?.clave;
|
|
const baseUrl = d?.baseUrl || d?.baseURL || d?.url || CONFIG.HOMESERVE_BASE_URL;
|
|
|
|
if (!user || !pass) {
|
|
throw new Error(
|
|
`HomeServe creds missing. Revisa Firestore doc "${CONFIG.HS_CRED_DOC_PATH}" con { user, pass, baseUrl? } o usa env HOMESERVE_USER/HOMESERVE_PASS`
|
|
);
|
|
}
|
|
|
|
return { user: String(user), pass: String(pass), baseUrl: String(baseUrl), source: CONFIG.HS_CRED_DOC_PATH };
|
|
}
|
|
|
|
// --------------------- Browser helpers ---------------------
|
|
async function withBrowser(fn) {
|
|
const browser = await chromium.launch({
|
|
headless: true,
|
|
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
});
|
|
|
|
const context = await browser.newContext({
|
|
viewport: { width: 1365, height: 768 },
|
|
});
|
|
|
|
const page = await context.newPage();
|
|
page.setDefaultTimeout(60000);
|
|
|
|
try {
|
|
return await fn(page);
|
|
} finally {
|
|
await browser.close().catch(() => {});
|
|
}
|
|
}
|
|
|
|
async function findFirstHandle(page, selectors) {
|
|
for (const sel of selectors) {
|
|
try {
|
|
const h = await page.$(sel);
|
|
if (h) return { handle: h, selector: sel };
|
|
} catch (_) {}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function fillFirst(page, selectors, value) {
|
|
const found = await findFirstHandle(page, selectors);
|
|
if (!found) return false;
|
|
await found.handle.fill(String(value));
|
|
return true;
|
|
}
|
|
|
|
async function clickFirst(page, selectors) {
|
|
const found = await findFirstHandle(page, selectors);
|
|
if (!found) return false;
|
|
await found.handle.click();
|
|
return true;
|
|
}
|
|
|
|
function buildServiceUrl(baseUrl, serviceNumber) {
|
|
// baseUrl puede venir como dominio o con path; garantizamos el CGI correcto
|
|
const origin = new URL(baseUrl).origin;
|
|
const u = new URL(origin + '/cgi-bin/fccgi.exe');
|
|
u.searchParams.set('w3exec', 'ver_servicioencurso');
|
|
u.searchParams.set('Servicio', String(serviceNumber));
|
|
u.searchParams.set('Pag', '1');
|
|
return u.toString();
|
|
}
|
|
|
|
async function maybeLogin(page, creds) {
|
|
// Intentamos abrir baseUrl. Si ya está logueado, perfecto.
|
|
const base = creds.baseUrl || CONFIG.HOMESERVE_BASE_URL;
|
|
|
|
await page.goto(base, { waitUntil: 'domcontentloaded', timeout: 120000 });
|
|
await sleep(700);
|
|
|
|
// Si vemos algo típico del portal ya logueado, salimos (heurístico)
|
|
const alreadyOk = await page.$('input[type="image"][name="repaso"], a[href*="ver_servicioencurso" i], form');
|
|
if (!alreadyOk) {
|
|
// Igual es una landing rara, seguimos.
|
|
}
|
|
|
|
// Si NO existe password input, probablemente no es login o ya está logueado
|
|
const passHandle = await findFirstHandle(page, CONFIG.SEL.loginPassCandidates);
|
|
if (!passHandle) return;
|
|
|
|
// Usuario
|
|
const userOk = await fillFirst(page, CONFIG.SEL.loginUserCandidates, creds.user);
|
|
// Pass
|
|
const passOk = await fillFirst(page, CONFIG.SEL.loginPassCandidates, creds.pass);
|
|
|
|
if (!userOk || !passOk) {
|
|
// Si no pudimos, que no reviente aquí: lo intentaremos al entrar al servicio
|
|
return;
|
|
}
|
|
|
|
// Submit
|
|
const clicked = await clickFirst(page, CONFIG.SEL.loginSubmitCandidates);
|
|
if (!clicked) {
|
|
// fallback: enter
|
|
await page.keyboard.press('Enter').catch(() => {});
|
|
}
|
|
|
|
await page.waitForLoadState('domcontentloaded', { timeout: 120000 }).catch(() => {});
|
|
await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {});
|
|
await sleep(900);
|
|
}
|
|
|
|
async function goToService(page, serviceNumber, creds) {
|
|
const url = buildServiceUrl(creds.baseUrl || CONFIG.HOMESERVE_BASE_URL, serviceNumber);
|
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 120000 });
|
|
await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {});
|
|
await sleep(700);
|
|
|
|
// Si te manda a login, intentamos login y volvemos a ir
|
|
const hasPassword = await page.$('input[type="password"]');
|
|
if (hasPassword) {
|
|
await maybeLogin(page, creds);
|
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 120000 });
|
|
await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {});
|
|
await sleep(700);
|
|
}
|
|
|
|
return url;
|
|
}
|
|
|
|
async function clickChangeState(page) {
|
|
const ok = await clickFirst(page, CONFIG.SEL.changeStateBtnCandidates);
|
|
if (!ok) {
|
|
// fallback por texto/atributos
|
|
const ok2 = await page.evaluate(() => {
|
|
const inputs = Array.from(document.querySelectorAll('input[type="image"], input[type="submit"], button'));
|
|
const hit = inputs.find((el) => {
|
|
const t = (el.getAttribute('title') || el.getAttribute('name') || el.getAttribute('value') || '').toLowerCase();
|
|
return t.includes('cambiar') && t.includes('estado');
|
|
});
|
|
if (!hit) return false;
|
|
hit.click();
|
|
return true;
|
|
});
|
|
if (!ok2) throw new Error('No encuentro el botón de "Cambiar estado" (repaso).');
|
|
}
|
|
|
|
await page.waitForLoadState('domcontentloaded', { timeout: 120000 }).catch(() => {});
|
|
await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {});
|
|
await sleep(900);
|
|
}
|
|
|
|
async function selectStatusCode(page, code) {
|
|
// Preferimos seleccionar por value (porque tus códigos son los valores)
|
|
const found = await findFirstHandle(page, CONFIG.SEL.statusSelectCandidates);
|
|
if (!found) throw new Error('No encuentro el desplegable de estado (select).');
|
|
|
|
const sel = found.selector;
|
|
|
|
// Primero por value exacto
|
|
try {
|
|
await page.selectOption(sel, { value: String(code) });
|
|
return;
|
|
} catch (_) {
|
|
// Luego intentamos por label que contenga el código
|
|
}
|
|
|
|
const ok = await page.evaluate(({ sel, code }) => {
|
|
const s = document.querySelector(sel);
|
|
if (!s) return false;
|
|
const opts = Array.from(s.querySelectorAll('option'));
|
|
const hit =
|
|
opts.find(o => (o.getAttribute('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;
|
|
}, { sel, code });
|
|
|
|
if (!ok) throw new Error(`No encuentro opción para código de estado "${code}".`);
|
|
}
|
|
|
|
async function fillDate(page, dateString) {
|
|
if (!dateString) return;
|
|
|
|
// En HomeServe suele ser input text DD/MM/AAAA
|
|
// Buscamos uno "razonable" y lo rellenamos.
|
|
const found = await findFirstHandle(page, CONFIG.SEL.dateInputCandidates);
|
|
if (!found) return;
|
|
|
|
// Intento simple: fill
|
|
try {
|
|
await found.handle.fill(String(dateString));
|
|
} catch (_) {}
|
|
}
|
|
|
|
async function fillObservation(page, observation) {
|
|
if (!observation) return;
|
|
const found = await findFirstHandle(page, CONFIG.SEL.obsCandidates);
|
|
if (!found) return;
|
|
try {
|
|
await found.handle.fill(String(observation));
|
|
} catch (_) {}
|
|
}
|
|
|
|
async function setInformoCliente(page, informoCliente) {
|
|
if (!informoCliente) return;
|
|
|
|
// 1) getByLabel (si existe)
|
|
try {
|
|
const locator = page.getByLabel(/Marque esta casilla.*informado al Cliente/i);
|
|
if (await locator.count()) {
|
|
await locator.first().check({ force: true });
|
|
return;
|
|
}
|
|
} catch (_) {}
|
|
|
|
// 2) buscar checkbox por texto alrededor
|
|
const ok = await page.evaluate(() => {
|
|
const checkboxes = Array.from(document.querySelectorAll('input[type="checkbox"]'));
|
|
const hit = checkboxes.find((cb) => {
|
|
const rowText =
|
|
(cb.closest('tr')?.innerText || cb.parentElement?.innerText || '').toLowerCase();
|
|
return rowText.includes('informado') && rowText.includes('cliente');
|
|
});
|
|
if (!hit) return false;
|
|
hit.checked = true;
|
|
hit.dispatchEvent(new Event('change', { bubbles: true }));
|
|
hit.dispatchEvent(new Event('click', { bubbles: true }));
|
|
return true;
|
|
});
|
|
|
|
if (!ok) {
|
|
// No lo hacemos fatal: mejor seguir que romper el cambio por una casilla
|
|
console.warn('[estados-hs] ⚠️ No encontré la casilla "ya informado al Cliente".');
|
|
}
|
|
}
|
|
|
|
async function clickSave(page) {
|
|
const ok = await clickFirst(page, CONFIG.SEL.saveBtnCandidates);
|
|
if (!ok) {
|
|
const ok2 = await page.evaluate(() => {
|
|
const els = Array.from(document.querySelectorAll('button,input'));
|
|
const hit = els.find((el) => {
|
|
const t = (el.getAttribute('value') || el.textContent || el.getAttribute('title') || '').toLowerCase();
|
|
return t.includes('guardar') || t.includes('aceptar') || t.includes('actualizar') || t.includes('grabar');
|
|
});
|
|
if (!hit) return false;
|
|
hit.click();
|
|
return true;
|
|
});
|
|
if (!ok2) throw new Error('No encuentro el botón de Guardar/Aceptar/Actualizar del cambio de estado.');
|
|
}
|
|
|
|
await page.waitForLoadState('domcontentloaded', { timeout: 120000 }).catch(() => {});
|
|
await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {});
|
|
await sleep(1200);
|
|
}
|
|
|
|
// --------------------- Main action ---------------------
|
|
async function changeStatusViaClientesPortal(db, payload) {
|
|
const creds = await getHomeServeCreds(db);
|
|
|
|
const serviceNumber = String(payload.serviceNumber || '').trim();
|
|
const newStatusValue = String(payload.newStatusValue || '').trim();
|
|
const dateString = String(payload.dateString || '').trim();
|
|
const observation = String(payload.observation || '').trim();
|
|
const informoCliente = toBool(payload.informoCliente);
|
|
|
|
const startedAtISO = isoNow();
|
|
|
|
const result = await withBrowser(async (page) => {
|
|
// Login “suave”
|
|
await maybeLogin(page, creds);
|
|
|
|
// Ir al servicio (si no hay sesión, reintenta login)
|
|
const serviceUrl = await goToService(page, serviceNumber, creds);
|
|
|
|
// Click botón repaso (cambio estado)
|
|
await clickChangeState(page);
|
|
|
|
// Set campos
|
|
await selectStatusCode(page, newStatusValue);
|
|
await fillDate(page, dateString);
|
|
await fillObservation(page, observation);
|
|
await setInformoCliente(page, informoCliente);
|
|
|
|
// Guardar
|
|
await clickSave(page);
|
|
|
|
return { serviceUrl };
|
|
});
|
|
|
|
return {
|
|
ok: true,
|
|
serviceNumber,
|
|
newStatusValue,
|
|
dateString,
|
|
observation,
|
|
informoCliente,
|
|
startedAtISO,
|
|
finishedAtISO: isoNow(),
|
|
...result,
|
|
credsSource: creds.source,
|
|
baseUrl: creds.baseUrl,
|
|
};
|
|
}
|
|
|
|
// --------------------- Express app ---------------------
|
|
function requireAuthIfNeeded(req, res) {
|
|
if (!CONFIG.REQUIRE_AUTH) return true;
|
|
|
|
const auth = (req.headers.authorization || '').trim();
|
|
const x = (req.headers['x-auth'] || '').toString().trim();
|
|
const token = CONFIG.API_TOKEN.trim();
|
|
|
|
const got =
|
|
(auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '') ||
|
|
x;
|
|
|
|
if (!token) {
|
|
// Si REQUIRE_AUTH=1 pero no hay token configurado, mejor bloquear.
|
|
res.status(500).json({ ok: false, error: { message: 'REQUIRE_AUTH=1 pero falta API_TOKEN en env.' } });
|
|
return false;
|
|
}
|
|
|
|
if (got !== token) {
|
|
res.status(401).json({ ok: false, error: { message: 'Unauthorized' } });
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function makeTestHtml(defaultBase) {
|
|
const options = CONFIG.STATUS_OPTIONS.map((o) =>
|
|
`<option value="${o.code}">${o.code} · ${escapeHtml(o.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 · tester</title>
|
|
<style>
|
|
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial;margin:0;padding:24px;background:#0b1220;color:#e8eefc}
|
|
.card{max-width:860px;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;box-sizing:border-box}
|
|
textarea{min-height:90px}
|
|
button{cursor:pointer;font-weight:800}
|
|
.row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
|
.row3{display:grid;grid-template-columns:1fr 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 1fr;gap:10px;margin-top:10px}
|
|
.pill{display:inline-flex;gap:8px;align-items:center}
|
|
.pill input{width:auto}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<h2 style="margin:0 0 8px">estados-hs · tester</h2>
|
|
<div class="muted">Consejo rápido: si ves <b>ok</b> en <code>/</code> y JSON en <code>/health</code>, el servicio está vivo. Luego ya nos peleamos con HomeServe 😅</div>
|
|
|
|
<label>Base URL (solo para probar endpoints desde aquí)</label>
|
|
<input id="base" value="${escapeHtml(defaultBase)}" />
|
|
|
|
<div class="btnrow">
|
|
<button onclick="probe('/')">Probar /</button>
|
|
<button onclick="probe('/health')">Probar /health</button>
|
|
<button onclick="probe('/test')">Probar /test</button>
|
|
</div>
|
|
|
|
<div style="height:14px"></div>
|
|
<div class="muted">POST <code>/api/homeserve/change-status</code></div>
|
|
|
|
<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="Texto..."></textarea>
|
|
|
|
<div class="row3">
|
|
<div class="pill">
|
|
<input id="informoCliente" type="checkbox" />
|
|
<label for="informoCliente" style="margin:0;opacity:.9">Ya he informado al cliente</label>
|
|
</div>
|
|
<div>
|
|
<label>Auth token (si REQUIRE_AUTH=1)</label>
|
|
<input id="token" placeholder="Bearer o X-Auth..." />
|
|
</div>
|
|
<div>
|
|
<label> </label>
|
|
<button id="btn">Enviar</button>
|
|
</div>
|
|
</div>
|
|
|
|
<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)); };
|
|
|
|
async function probe(path){
|
|
const base = $('base').value.trim().replace(/\\/+$/,'');
|
|
try{
|
|
const r = await fetch(base + path, { method:'GET' });
|
|
const ct = r.headers.get('content-type') || '';
|
|
let body = null;
|
|
if(ct.includes('application/json')) body = await r.json().catch(()=>({}));
|
|
else body = await r.text().catch(()=> '');
|
|
log({ http: r.status, base, path, response: body });
|
|
}catch(e){
|
|
log({ http: 0, base, path, error: String(e) });
|
|
}
|
|
}
|
|
|
|
$('btn').addEventListener('click', async () => {
|
|
try{
|
|
const base = $('base').value.trim().replace(/\\/+$/,'');
|
|
const url = base + '/api/homeserve/change-status';
|
|
|
|
log('Enviando...');
|
|
const body = {
|
|
serviceNumber: $('serviceNumber').value.trim(),
|
|
newStatusValue: $('statusCode').value.trim(),
|
|
dateString: $('dateString').value.trim(),
|
|
observation: $('observation').value.trim(),
|
|
informoCliente: $('informoCliente').checked
|
|
};
|
|
|
|
const token = $('token').value.trim();
|
|
const headers = { 'Content-Type':'application/json' };
|
|
if(token){
|
|
if(token.toLowerCase().startsWith('bearer ')) headers['Authorization'] = token;
|
|
else headers['X-Auth'] = token;
|
|
}
|
|
|
|
const r = await fetch(url, { method:'POST', headers, body: JSON.stringify(body) });
|
|
const j = await r.json().catch(()=>({}));
|
|
log({ http: r.status, base, url, request: body, response: j });
|
|
}catch(e){
|
|
log(String(e));
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s)
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
}
|
|
|
|
// --------------------- Boot ---------------------
|
|
async function main() {
|
|
const db = initFirebase();
|
|
const app = express();
|
|
|
|
app.use(cors());
|
|
app.use(express.json({ limit: '1mb' }));
|
|
|
|
app.get('/', (req, res) => res.status(200).send('ok'));
|
|
|
|
app.get('/health', (req, res) => {
|
|
res.json({
|
|
ok: true,
|
|
service: 'estados-hs',
|
|
port: pickPort(),
|
|
requireAuth: CONFIG.REQUIRE_AUTH,
|
|
hsCredDocPath: CONFIG.HS_CRED_DOC_PATH,
|
|
ts: isoNow(),
|
|
});
|
|
});
|
|
|
|
app.get('/test', (req, res) => {
|
|
// Sirve el tester embebido
|
|
const base = `${req.protocol}://${req.get('host')}`;
|
|
res.status(200).type('text/html').send(makeTestHtml(base));
|
|
});
|
|
|
|
app.post('/api/homeserve/change-status', async (req, res) => {
|
|
if (!requireAuthIfNeeded(req, res)) return;
|
|
|
|
const startedAtISO = isoNow();
|
|
|
|
try {
|
|
const body = req.body || {};
|
|
const serviceNumber = String(body.serviceNumber || '').trim();
|
|
const newStatusValue = String(body.newStatusValue || '').trim();
|
|
|
|
if (!serviceNumber) {
|
|
return res.status(400).json({ ok: false, error: { message: 'Missing serviceNumber' } });
|
|
}
|
|
if (!newStatusValue) {
|
|
return res.status(400).json({ ok: false, error: { message: 'Missing newStatusValue' } });
|
|
}
|
|
|
|
// Validación ligera: que sea uno de tus códigos
|
|
const allowed = new Set(CONFIG.STATUS_OPTIONS.map(o => o.code));
|
|
if (!allowed.has(newStatusValue)) {
|
|
return res.status(400).json({
|
|
ok: false,
|
|
error: { message: `Estado no permitido: ${newStatusValue}` },
|
|
allowed: Array.from(allowed),
|
|
});
|
|
}
|
|
|
|
const out = await changeStatusViaClientesPortal(db, body);
|
|
|
|
return res.status(200).json(out);
|
|
} catch (e) {
|
|
return res.status(500).json({
|
|
ok: false,
|
|
startedAtISO,
|
|
finishedAtISO: isoNow(),
|
|
error: {
|
|
message: String(e?.message || e),
|
|
stack: String(e?.stack || ''),
|
|
},
|
|
});
|
|
}
|
|
});
|
|
|
|
const port = pickPort();
|
|
app.listen(port, '0.0.0.0', () => {
|
|
console.log(`[estados-hs] listening on :${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)'}`);
|
|
});
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error(e);
|
|
process.exit(1);
|
|
}); |