Actualizar index.js
This commit is contained in:
parent
935323440e
commit
2d5cecb6a5
608
index.js
608
index.js
|
|
@ -1,4 +1,3 @@
|
||||||
// index.js
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
|
@ -6,6 +5,8 @@ const cors = require('cors');
|
||||||
const { chromium } = require('playwright');
|
const { chromium } = require('playwright');
|
||||||
const admin = require('firebase-admin');
|
const admin = require('firebase-admin');
|
||||||
|
|
||||||
|
// -------------------- Firebase --------------------
|
||||||
|
|
||||||
function mustEnv(name) {
|
function mustEnv(name) {
|
||||||
const v = process.env[name];
|
const v = process.env[name];
|
||||||
if (!v) throw new Error(`Missing env: ${name}`);
|
if (!v) throw new Error(`Missing env: ${name}`);
|
||||||
|
|
@ -15,62 +16,104 @@ function mustEnv(name) {
|
||||||
function initFirebase() {
|
function initFirebase() {
|
||||||
if (admin.apps?.length) return admin.firestore();
|
if (admin.apps?.length) return admin.firestore();
|
||||||
|
|
||||||
// Requiere estas 3 env (como ya usas en tus robots)
|
if (!process.env.FIREBASE_PRIVATE_KEY) {
|
||||||
if (!process.env.FIREBASE_PRIVATE_KEY) throw new Error('Missing env: FIREBASE_PRIVATE_KEY');
|
throw new Error('Missing env: FIREBASE_PRIVATE_KEY');
|
||||||
|
}
|
||||||
|
|
||||||
admin.initializeApp({
|
admin.initializeApp({
|
||||||
credential: admin.credential.cert({
|
credential: admin.credential.cert({
|
||||||
projectId: mustEnv('FIREBASE_PROJECT_ID'),
|
projectId: mustEnv('FIREBASE_PROJECT_ID'),
|
||||||
clientEmail: mustEnv('FIREBASE_CLIENT_EMAIL'),
|
clientEmail: mustEnv('FIREBASE_CLIENT_EMAIL'),
|
||||||
privateKey: mustEnv('FIREBASE_PRIVATE_KEY').replace(/\\n/g, '\n'),
|
privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return admin.firestore();
|
return admin.firestore();
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = initFirebase();
|
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 --------------------
|
||||||
|
|
||||||
// ===== Config =====
|
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
SERVICE_NAME: 'estados-hs',
|
// Flujo antiguo:
|
||||||
|
|
||||||
HOMESERVE_BASE_URL: process.env.HOMESERVE_BASE_URL || 'https://gestor.homeserve.es/',
|
HOMESERVE_BASE_URL: process.env.HOMESERVE_BASE_URL || 'https://gestor.homeserve.es/',
|
||||||
|
|
||||||
// 🔥 Preferimos este doc (tu captura)
|
// Selectores (igual que el robot viejo, pero con extras)
|
||||||
HS_CRED_DOC_PATH: process.env.HS_CRED_DOC_PATH || 'providerCredentials/homeserve',
|
|
||||||
HS_CRED_DOC_FALLBACK: process.env.HS_CRED_DOC_FALLBACK || 'secrets/homeserve',
|
|
||||||
|
|
||||||
// Auth opcional al endpoint (si lo quieres)
|
|
||||||
REQUIRE_AUTH: String(process.env.REQUIRE_AUTH || '0') === '1',
|
|
||||||
AUTH_TOKEN: process.env.AUTH_TOKEN || '',
|
|
||||||
|
|
||||||
// Playwright
|
|
||||||
HEADLESS: String(process.env.HEADLESS || 'true') !== 'false',
|
|
||||||
|
|
||||||
// Selectores (ajústalos si difieren)
|
|
||||||
SEL: {
|
SEL: {
|
||||||
user: process.env.SEL_USER || 'input[type="text"]',
|
user: process.env.SEL_USER || 'input[type="text"], input[name="user"], input[name="username"]',
|
||||||
pass: process.env.SEL_PASS || 'input[type="password"]',
|
pass: process.env.SEL_PASS || 'input[type="password"], input[name="pass"], input[name="password"]',
|
||||||
submit: process.env.SEL_SUBMIT || 'button[type="submit"]',
|
submit: process.env.SEL_SUBMIT || 'button[type="submit"], input[type="submit"], button:has-text("Entrar")',
|
||||||
|
|
||||||
// búsqueda de parte/servicio
|
|
||||||
searchBox: process.env.SEL_SEARCH_BOX || 'input[placeholder*="Buscar"], input[type="search"]',
|
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")',
|
searchBtn: process.env.SEL_SEARCH_BTN || 'button:has-text("Buscar"), button:has-text("Search")',
|
||||||
|
|
||||||
// entrar al detalle del parte
|
|
||||||
openRow: process.env.SEL_OPEN_ROW || 'table tbody tr:first-child',
|
openRow: process.env.SEL_OPEN_ROW || 'table tbody tr:first-child',
|
||||||
|
|
||||||
// cambio de estado
|
// Cambio estado
|
||||||
statusDropdown: process.env.SEL_STATUS_DROPDOWN || 'select[name*="estado"], select[id*="estado"], select:has(option)',
|
statusDropdown:
|
||||||
|
process.env.SEL_STATUS_DROPDOWN ||
|
||||||
|
'select[name*="estado" i], select[id*="estado" i], select:has(option)',
|
||||||
|
|
||||||
// (opcionales) campos de fecha / observación si existen en HomeServe
|
// Observación/nota
|
||||||
dateInput: process.env.SEL_DATE_INPUT || 'input[name*="fecha"], input[id*="fecha"], input[placeholder*="dd"], input[placeholder*="DD"]',
|
noteTextarea:
|
||||||
noteTextarea: process.env.SEL_NOTE_TEXTAREA || 'textarea[name*="nota"], textarea[id*="nota"], textarea',
|
process.env.SEL_NOTE_TEXTAREA ||
|
||||||
saveBtn: process.env.SEL_SAVE_BTN || 'button:has-text("Guardar"), button:has-text("Save"), button:has-text("Actualizar")',
|
'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 (los del switch de tu app) =====
|
// Estados por código (los de tu app)
|
||||||
const STATUS = [
|
const STATUS_CODES = [
|
||||||
{ code: '303', title: 'En espera de Cliente por aceptación Presupuesto' },
|
{ 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: '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: '313', title: 'En espera de Profesional por secado de cala, pintura o parquet' },
|
||||||
|
|
@ -87,57 +130,20 @@ const STATUS = [
|
||||||
{ code: '352', title: 'En espera de Perjudicado por indicaciones' },
|
{ code: '352', title: 'En espera de Perjudicado por indicaciones' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CODE_TO_LABEL = Object.fromEntries(STATUS.map(s => [s.code, s.title]));
|
const CODE_TO_TITLE = new Map(STATUS_CODES.map(x => [x.code, x.title]));
|
||||||
|
|
||||||
// ===== Utils =====
|
// -------------------- Helpers --------------------
|
||||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
const nowISO = () => new Date().toISOString();
|
const nowISO = () => new Date().toISOString();
|
||||||
|
const trim = (v) => String(v ?? '').trim();
|
||||||
|
|
||||||
function parseDocPath(path) {
|
function parseBool(v, def = true) {
|
||||||
// "collection/doc/subcollection/doc" -> solo soportamos colección/doc o colección/doc/... (FireStore permite)
|
if (v === undefined || v === null || v === '') return def;
|
||||||
const parts = String(path || '').split('/').filter(Boolean);
|
const s = String(v).toLowerCase().trim();
|
||||||
if (parts.length < 2 || parts.length % 2 !== 0) {
|
if (['1', 'true', 'yes', 'si', 'sí'].includes(s)) return true;
|
||||||
throw new Error(`Invalid Firestore doc path: "${path}" (must be collection/doc[/collection/doc...])`);
|
if (['0', 'false', 'no'].includes(s)) return false;
|
||||||
}
|
return def;
|
||||||
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() {
|
|
||||||
// 1) Firestore doc principal (providerCredentials/homeserve)
|
|
||||||
const tryDoc = async (path) => {
|
|
||||||
try {
|
|
||||||
const ref = parseDocPath(path);
|
|
||||||
const snap = await ref.get();
|
|
||||||
if (!snap.exists) return null;
|
|
||||||
const d = snap.data() || {};
|
|
||||||
const user = (d.user || d.username || '').toString().trim();
|
|
||||||
const pass = (d.pass || d.password || '').toString().trim();
|
|
||||||
if (!user || !pass) return null;
|
|
||||||
return { user, pass, source: `firestore:${path}` };
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const a = await tryDoc(CONFIG.HS_CRED_DOC_PATH);
|
|
||||||
if (a) return a;
|
|
||||||
|
|
||||||
const b = await tryDoc(CONFIG.HS_CRED_DOC_FALLBACK);
|
|
||||||
if (b) return b;
|
|
||||||
|
|
||||||
// 2) Env fallback
|
|
||||||
const envUser = (process.env.HOMESERVE_USER || '').trim();
|
|
||||||
const envPass = (process.env.HOMESERVE_PASS || '').trim();
|
|
||||||
if (envUser && envPass) return { user: envUser, pass: envPass, source: 'env:HOMESERVE_USER/HOMESERVE_PASS' };
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
`HomeServe creds missing. Set them in Firestore doc "${CONFIG.HS_CRED_DOC_PATH}" (user/pass) ` +
|
|
||||||
`or "${CONFIG.HS_CRED_DOC_FALLBACK}" (user/pass) or env HOMESERVE_USER/HOMESERVE_PASS`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withBrowser(fn) {
|
async function withBrowser(fn) {
|
||||||
|
|
@ -145,7 +151,7 @@ async function withBrowser(fn) {
|
||||||
headless: CONFIG.HEADLESS,
|
headless: CONFIG.HEADLESS,
|
||||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||||
});
|
});
|
||||||
const context = await browser.newContext();
|
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
try {
|
try {
|
||||||
return await fn(page);
|
return await fn(page);
|
||||||
|
|
@ -154,8 +160,10 @@ async function withBrowser(fn) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------- Playwright flow (como el robot antiguo) --------------------
|
||||||
|
|
||||||
async function login(page, creds) {
|
async function login(page, creds) {
|
||||||
await page.goto(CONFIG.HOMESERVE_BASE_URL, { waitUntil: 'domcontentloaded', timeout: 120000 });
|
await page.goto(CONFIG.HOMESERVE_BASE_URL, { waitUntil: 'domcontentloaded', timeout: CONFIG.NAV_TIMEOUT_MS });
|
||||||
|
|
||||||
await page.waitForSelector(CONFIG.SEL.user, { timeout: 60000 });
|
await page.waitForSelector(CONFIG.SEL.user, { timeout: 60000 });
|
||||||
await page.fill(CONFIG.SEL.user, creds.user);
|
await page.fill(CONFIG.SEL.user, creds.user);
|
||||||
|
|
@ -167,122 +175,277 @@ async function login(page, creds) {
|
||||||
if (btn) await btn.click();
|
if (btn) await btn.click();
|
||||||
else await page.keyboard.press('Enter');
|
else await page.keyboard.press('Enter');
|
||||||
|
|
||||||
await page.waitForLoadState('networkidle', { timeout: 120000 });
|
// 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, serviceNumber) {
|
async function openParte(page, parteId) {
|
||||||
const hasSearch = await page.$(CONFIG.SEL.searchBox);
|
const hasSearch = await page.$(CONFIG.SEL.searchBox);
|
||||||
if (hasSearch) {
|
if (hasSearch) {
|
||||||
await page.fill(CONFIG.SEL.searchBox, String(serviceNumber));
|
await page.fill(CONFIG.SEL.searchBox, String(parteId));
|
||||||
const btn = await page.$(CONFIG.SEL.searchBtn);
|
const btn = await page.$(CONFIG.SEL.searchBtn);
|
||||||
if (btn) await btn.click();
|
if (btn) await btn.click();
|
||||||
else await page.keyboard.press('Enter');
|
else await page.keyboard.press('Enter');
|
||||||
|
|
||||||
await page.waitForLoadState('networkidle', { timeout: 120000 });
|
await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT_MS });
|
||||||
await sleep(1200);
|
await sleep(1200);
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.waitForSelector(CONFIG.SEL.openRow, { timeout: 60000 });
|
await page.waitForSelector(CONFIG.SEL.openRow, { timeout: 60000 });
|
||||||
await page.click(CONFIG.SEL.openRow);
|
await page.click(CONFIG.SEL.openRow);
|
||||||
await page.waitForLoadState('networkidle', { timeout: 120000 });
|
await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT_MS });
|
||||||
await sleep(900);
|
await sleep(900);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setEstado(page, code, dateString, observation) {
|
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 });
|
await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: 60000 });
|
||||||
|
|
||||||
// 1) intenta por value (= código)
|
// Primero intentamos por VALUE (si el select usa value=303 etc)
|
||||||
let selected = false;
|
|
||||||
try {
|
try {
|
||||||
await page.selectOption(CONFIG.SEL.statusDropdown, { value: String(code) });
|
await page.selectOption(CONFIG.SEL.statusDropdown, { value: String(newStatusValue) });
|
||||||
selected = true;
|
return { method: 'value', picked: String(newStatusValue) };
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
// 2) si no, intenta por label (título del estado)
|
// Segundo: intentamos por label con el texto del código (si el option pinta "348 - En espera...")
|
||||||
if (!selected) {
|
const title = CODE_TO_TITLE.get(String(newStatusValue));
|
||||||
const label = CODE_TO_LABEL[String(code)] || String(code);
|
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 {
|
try {
|
||||||
await page.selectOption(CONFIG.SEL.statusDropdown, { label });
|
await page.selectOption(CONFIG.SEL.statusDropdown, { label });
|
||||||
selected = true;
|
return { method: 'label', picked: label };
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) si no, intenta encontrar option cuyo text contenga el código o el título
|
// Tercero: buscar en DOM option que contenga el código
|
||||||
if (!selected) {
|
const ok = await page.evaluate(({ sel, code }) => {
|
||||||
const label = CODE_TO_LABEL[String(code)] || '';
|
|
||||||
const ok = await page.evaluate(({ sel, code, label }) => {
|
|
||||||
const s = document.querySelector(sel);
|
const s = document.querySelector(sel);
|
||||||
if (!s) return false;
|
if (!s) return null;
|
||||||
const opts = Array.from(s.querySelectorAll('option'));
|
const opts = Array.from(s.querySelectorAll('option'));
|
||||||
const lc = String(code).trim().toLowerCase();
|
const hit =
|
||||||
const ll = String(label).trim().toLowerCase();
|
opts.find(o => String(o.value).trim() === String(code).trim()) ||
|
||||||
const hit = opts.find(o => {
|
opts.find(o => (o.textContent || '').includes(String(code)));
|
||||||
const t = (o.textContent || '').trim().toLowerCase();
|
if (!hit) return null;
|
||||||
return t.includes(lc) || (!!ll && t.includes(ll));
|
|
||||||
});
|
|
||||||
if (!hit) return false;
|
|
||||||
s.value = hit.value;
|
s.value = hit.value;
|
||||||
s.dispatchEvent(new Event('change', { bubbles: true }));
|
s.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
return true;
|
return { value: hit.value, text: (hit.textContent || '').trim() };
|
||||||
}, { sel: CONFIG.SEL.statusDropdown, code: String(code), label });
|
}, { sel: CONFIG.SEL.statusDropdown, code: String(newStatusValue) });
|
||||||
|
|
||||||
if (!ok) throw new Error(`No matching status option for code "${code}"`);
|
if (!ok) throw new Error(`No matching status option for code "${newStatusValue}"`);
|
||||||
}
|
return { method: 'dom', picked: ok.value, text: ok.text };
|
||||||
|
}
|
||||||
|
|
||||||
// Fecha (si existe el campo)
|
async function fillDateIfExists(page, dateString) {
|
||||||
if (dateString) {
|
if (!trim(dateString)) return false;
|
||||||
const el = await page.$(CONFIG.SEL.dateInput);
|
const el = await page.$(CONFIG.SEL.dateInput);
|
||||||
if (el) {
|
if (!el) return false;
|
||||||
|
try {
|
||||||
await page.fill(CONFIG.SEL.dateInput, String(dateString));
|
await page.fill(CONFIG.SEL.dateInput, String(dateString));
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nota / observación (si existe)
|
async function fillObservationIfExists(page, observation) {
|
||||||
if (observation) {
|
if (!trim(observation)) return false;
|
||||||
const ta = await page.$(CONFIG.SEL.noteTextarea);
|
const ta = await page.$(CONFIG.SEL.noteTextarea);
|
||||||
if (ta) {
|
if (!ta) return false;
|
||||||
await page.fill(CONFIG.SEL.noteTextarea, String(observation));
|
await page.fill(CONFIG.SEL.noteTextarea, String(observation));
|
||||||
}
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function clickSave(page) {
|
||||||
const save = await page.$(CONFIG.SEL.saveBtn);
|
const save = await page.$(CONFIG.SEL.saveBtn);
|
||||||
if (!save) throw new Error('Save button not found');
|
if (!save) throw new Error('Save button not found');
|
||||||
await save.click();
|
await save.click();
|
||||||
|
await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT_MS });
|
||||||
await page.waitForLoadState('networkidle', { timeout: 120000 });
|
|
||||||
await sleep(1200);
|
await sleep(1200);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Express =====
|
async function changeStatusViaGestor(page, job, creds) {
|
||||||
const app = express();
|
await login(page, creds);
|
||||||
|
await openParte(page, job.serviceNumber);
|
||||||
|
|
||||||
app.use(cors());
|
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.use(express.json({ limit: '1mb' }));
|
||||||
|
|
||||||
// Root OK (lo que pediste)
|
app.get('/', (req, res) => res.status(200).send('ok'));
|
||||||
app.get('/', (req, res) => {
|
|
||||||
res.status(200).send('ok');
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
|
const port = Number(process.env.PORT || process.env.CAPROVER_PORT || 3000);
|
||||||
res.json({
|
res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
service: CONFIG.SERVICE_NAME,
|
service: 'estados-hs',
|
||||||
|
port,
|
||||||
requireAuth: CONFIG.REQUIRE_AUTH,
|
requireAuth: CONFIG.REQUIRE_AUTH,
|
||||||
hsCredDocPath: CONFIG.HS_CRED_DOC_PATH,
|
hsCredDocPath: process.env.HS_CRED_DOC_PATH || 'providerCredentials/homeserve',
|
||||||
hsCredDocFallback: CONFIG.HS_CRED_DOC_FALLBACK,
|
baseUrl: CONFIG.HOMESERVE_BASE_URL,
|
||||||
ts: nowISO(),
|
ts: nowISO(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// HTML integrado para pruebas (puedes dejarlo así o servir un archivo)
|
app.get('/statuses', (req, res) => {
|
||||||
app.get('/test', (req, res) => {
|
res.json({ ok: true, statuses: STATUS_CODES });
|
||||||
const optionsHtml = STATUS.map(s => {
|
});
|
||||||
const sel = s.code === '348' ? ' selected' : '';
|
|
||||||
return `<option value="${s.code}"${sel}>${s.code} · ${escapeHtml(s.title)}</option>`;
|
|
||||||
}).join('\n');
|
|
||||||
|
|
||||||
res.status(200).send(`<!doctype html>
|
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">
|
<html lang="es">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
|
|
@ -290,27 +453,34 @@ app.get('/test', (req, res) => {
|
||||||
<title>estados-hs · test</title>
|
<title>estados-hs · test</title>
|
||||||
<style>
|
<style>
|
||||||
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial;margin:0;padding:24px;background:#0b1220;color:#e8eefc}
|
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}
|
.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}
|
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}
|
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}
|
textarea{min-height:90px}
|
||||||
button{cursor:pointer;font-weight:800}
|
button{cursor:pointer;font-weight:700}
|
||||||
.row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
.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}
|
.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}
|
.muted{opacity:.7;font-size:12px}
|
||||||
.pill{display:inline-block;padding:6px 10px;border:1px solid rgba(255,255,255,.12);border-radius:999px;background:#0b1220;font-size:12px;opacity:.85}
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 style="margin:0 0 8px">estados-hs · prueba</h2>
|
<h2 style="margin:0 0 8px">estados-hs · pruebas (flujo gestor)</h2>
|
||||||
<div class="muted">Servidor: <span class="pill" id="base"></span></div>
|
<div class="muted">Consejo: si /health responde JSON, el server está vivo. Si POST devuelve ok:true, ya estás cambiando estado 🔧</div>
|
||||||
|
|
||||||
<div class="row" style="margin-top:12px">
|
<label>Base URL (auto)</label>
|
||||||
<button id="btnHealth" type="button">Probar /health</button>
|
<input id="base" />
|
||||||
<button id="btnRoot" type="button">Probar /</button>
|
|
||||||
|
<div class="btnrow">
|
||||||
|
<button id="btnHealth">Probar /health</button>
|
||||||
|
<button id="btnStatuses">Ver /statuses</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<hr style="border:0;border-top:1px solid rgba(255,255,255,.08);margin:14px 0" />
|
||||||
|
|
||||||
<label>Service Number</label>
|
<label>Service Number</label>
|
||||||
<input id="serviceNumber" placeholder="15251178" />
|
<input id="serviceNumber" placeholder="15251178" />
|
||||||
|
|
||||||
|
|
@ -318,7 +488,7 @@ app.get('/test', (req, res) => {
|
||||||
<div>
|
<div>
|
||||||
<label>Código estado</label>
|
<label>Código estado</label>
|
||||||
<select id="statusCode">
|
<select id="statusCode">
|
||||||
${optionsHtml}
|
${options}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -328,10 +498,15 @@ app.get('/test', (req, res) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label>Observación (opcional)</label>
|
<label>Observación (opcional)</label>
|
||||||
<textarea id="observation" placeholder="Texto..."></textarea>
|
<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>
|
<div style="height:10px"></div>
|
||||||
<button id="btnSend" type="button">Enviar cambio de estado</button>
|
<button id="btnSend">Enviar (POST /api/homeserve/change-status)</button>
|
||||||
|
|
||||||
<div class="log" id="log">Listo.</div>
|
<div class="log" id="log">Listo.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -339,27 +514,27 @@ app.get('/test', (req, res) => {
|
||||||
<script>
|
<script>
|
||||||
const $ = (id)=>document.getElementById(id);
|
const $ = (id)=>document.getElementById(id);
|
||||||
const log = (x)=>{ $('log').textContent = (typeof x==='string'?x:JSON.stringify(x,null,2)); };
|
const log = (x)=>{ $('log').textContent = (typeof x==='string'?x:JSON.stringify(x,null,2)); };
|
||||||
|
const baseDefault = window.location.origin;
|
||||||
|
$('base').value = baseDefault;
|
||||||
|
|
||||||
// Base correcta SIEMPRE al mismo dominio donde estás (evita el lío de marsalva.es)
|
async function call(path, opts){
|
||||||
const BASE = window.location.origin;
|
const base = $('base').value.trim() || baseDefault;
|
||||||
$('base').textContent = BASE;
|
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 () => {
|
$('btnHealth').addEventListener('click', async () => {
|
||||||
try{
|
try{ log('probando /health...'); log(await call('/health')); }
|
||||||
log('Consultando /health...');
|
catch(e){ log(String(e)); }
|
||||||
const r = await fetch(BASE + '/health');
|
|
||||||
const j = await r.json().catch(()=>({}));
|
|
||||||
log({ http: r.status, base: BASE, response: j });
|
|
||||||
}catch(e){ log(String(e)); }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$('btnRoot').addEventListener('click', async () => {
|
$('btnStatuses').addEventListener('click', async () => {
|
||||||
try{
|
try{ log('cargando /statuses...'); log(await call('/statuses')); }
|
||||||
log('Consultando / ...');
|
catch(e){ log(String(e)); }
|
||||||
const r = await fetch(BASE + '/');
|
|
||||||
const t = await r.text();
|
|
||||||
log({ http: r.status, base: BASE, responseText: t });
|
|
||||||
}catch(e){ log(String(e)); }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$('btnSend').addEventListener('click', async () => {
|
$('btnSend').addEventListener('click', async () => {
|
||||||
|
|
@ -369,77 +544,23 @@ app.get('/test', (req, res) => {
|
||||||
serviceNumber: $('serviceNumber').value.trim(),
|
serviceNumber: $('serviceNumber').value.trim(),
|
||||||
newStatusValue: $('statusCode').value.trim(),
|
newStatusValue: $('statusCode').value.trim(),
|
||||||
dateString: $('dateString').value.trim(),
|
dateString: $('dateString').value.trim(),
|
||||||
observation: $('observation').value.trim()
|
observation: $('observation').value.trim(),
|
||||||
|
informoCliente: $('informo').checked
|
||||||
};
|
};
|
||||||
|
log(await call('/api/homeserve/change-status', {
|
||||||
const r = await fetch(BASE + '/api/homeserve/change-status', {
|
|
||||||
method:'POST',
|
method:'POST',
|
||||||
headers:{'Content-Type':'application/json'},
|
headers:{'Content-Type':'application/json'},
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body)
|
||||||
});
|
}));
|
||||||
const j = await r.json().catch(()=>({}));
|
}catch(e){
|
||||||
log({ http: r.status, base: BASE, request: body, response: j });
|
log(String(e));
|
||||||
}catch(e){ log(String(e)); }
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`);
|
</html>`;
|
||||||
});
|
}
|
||||||
|
|
||||||
// Endpoint principal: cambio directo
|
|
||||||
app.post('/api/homeserve/change-status', async (req, res) => {
|
|
||||||
try {
|
|
||||||
if (CONFIG.REQUIRE_AUTH) {
|
|
||||||
const auth = req.headers.authorization || '';
|
|
||||||
const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
|
|
||||||
if (!CONFIG.AUTH_TOKEN || token !== CONFIG.AUTH_TOKEN) {
|
|
||||||
return res.status(401).json({ ok: false, error: { message: 'Unauthorized' } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const serviceNumber = String(req.body?.serviceNumber || '').trim();
|
|
||||||
const newStatusValue = String(req.body?.newStatusValue || '').trim(); // código
|
|
||||||
const dateString = String(req.body?.dateString || '').trim();
|
|
||||||
const observation = String(req.body?.observation || '').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' } });
|
|
||||||
|
|
||||||
const startedAtISO = nowISO();
|
|
||||||
const creds = await getHomeServeCreds();
|
|
||||||
|
|
||||||
console.log(`[${CONFIG.SERVICE_NAME}] change-status service=${serviceNumber} status=${newStatusValue} credsSource=${creds.source}`);
|
|
||||||
|
|
||||||
await withBrowser(async (page) => {
|
|
||||||
await login(page, creds);
|
|
||||||
await openParte(page, serviceNumber);
|
|
||||||
await setEstado(page, newStatusValue, dateString || null, observation || null);
|
|
||||||
});
|
|
||||||
|
|
||||||
const finishedAtISO = nowISO();
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
ok: true,
|
|
||||||
serviceNumber,
|
|
||||||
newStatusValue,
|
|
||||||
startedAtISO,
|
|
||||||
finishedAtISO,
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
const finishedAtISO = nowISO();
|
|
||||||
res.status(500).json({
|
|
||||||
ok: false,
|
|
||||||
finishedAtISO,
|
|
||||||
error: {
|
|
||||||
message: String(err?.message || err),
|
|
||||||
stack: String(err?.stack || ''),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helpers
|
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
return String(s || '')
|
return String(s || '')
|
||||||
.replaceAll('&', '&')
|
.replaceAll('&', '&')
|
||||||
|
|
@ -449,18 +570,11 @@ function escapeHtml(s) {
|
||||||
.replaceAll("'", ''');
|
.replaceAll("'", ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Listen (compatible CapRover) =====
|
const PORT = Number(process.env.PORT || process.env.CAPROVER_PORT || 3000);
|
||||||
const portA = parseInt(process.env.CAPROVER_PORT || process.env.PORT || '3000', 10);
|
app.listen(PORT, () => {
|
||||||
const portB = 80; // por si CapRover está esperando 80 y no configuraste el "Container HTTP Port"
|
console.log(`[estados-hs] listening on :${PORT}`);
|
||||||
|
console.log(`[estados-hs] HOMESERVE_BASE_URL=${CONFIG.HOMESERVE_BASE_URL}`);
|
||||||
console.log(`[${CONFIG.SERVICE_NAME}] HS_CRED_DOC_PATH=${CONFIG.HS_CRED_DOC_PATH}`);
|
console.log(`[estados-hs] HS_CRED_DOC_PATH=${process.env.HS_CRED_DOC_PATH || 'providerCredentials/homeserve'}`);
|
||||||
console.log(`[${CONFIG.SERVICE_NAME}] HS_CRED_DOC_FALLBACK=${CONFIG.HS_CRED_DOC_FALLBACK}`);
|
console.log(`[estados-hs] REQUIRE_AUTH=${CONFIG.REQUIRE_AUTH ? 1 : 0}`);
|
||||||
console.log(`[${CONFIG.SERVICE_NAME}] REQUIRE_AUTH=${CONFIG.REQUIRE_AUTH ? 1 : 0}`);
|
console.log(`[estados-hs] HEADLESS=${CONFIG.HEADLESS ? 1 : 0}`);
|
||||||
console.log(`[${CONFIG.SERVICE_NAME}] ENV PORT=${process.env.PORT || '(unset)'} CAPROVER_PORT=${process.env.CAPROVER_PORT || '(unset)'}`);
|
});
|
||||||
|
|
||||||
app.listen(portA, () => console.log(`[${CONFIG.SERVICE_NAME}] listening on :${portA}`));
|
|
||||||
|
|
||||||
// Si el puerto principal no es 80, abrimos 80 también para evitar 502 (si CapRover está esperando 80)
|
|
||||||
if (portA !== portB) {
|
|
||||||
app.listen(portB, () => console.log(`[${CONFIG.SERVICE_NAME}] listening on :${portB}`));
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue