Actualizar index.js
This commit is contained in:
parent
5b77956430
commit
935323440e
580
index.js
580
index.js
|
|
@ -3,88 +3,20 @@
|
|||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const admin = require('firebase-admin');
|
||||
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 parsePort(v, fallback) {
|
||||
const n = Number.parseInt(String(v || ''), 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : fallback;
|
||||
}
|
||||
|
||||
function nowISO() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function safeStr(v) {
|
||||
return String(v ?? '').trim();
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Config
|
||||
// -----------------------------
|
||||
const CONFIG = {
|
||||
// HomeServe
|
||||
HOMESERVE_BASE_URL: process.env.HOMESERVE_BASE_URL || 'https://gestor.homeserve.es/',
|
||||
|
||||
// Credenciales desde Firestore (doc path tipo: secrets/homeserve)
|
||||
HS_CRED_DOC_PATH: process.env.HS_CRED_DOC_PATH || 'secrets/homeserve',
|
||||
|
||||
// Auth API (opcional)
|
||||
REQUIRE_AUTH: String(process.env.REQUIRE_AUTH || '0') === '1',
|
||||
API_TOKEN: process.env.API_TOKEN || '',
|
||||
|
||||
// Playwright
|
||||
HEADLESS: String(process.env.HEADLESS || 'true') !== 'false',
|
||||
|
||||
// Selectores (ajustables por env)
|
||||
SEL: {
|
||||
user: process.env.SEL_USER || 'input[type="text"], input[name="username"], input[id*="user"]',
|
||||
pass: process.env.SEL_PASS || 'input[type="password"], input[name="password"], input[id*="pass"]',
|
||||
submit: process.env.SEL_SUBMIT || 'button[type="submit"], button:has-text("Acceder"), button:has-text("Entrar")',
|
||||
|
||||
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")',
|
||||
openRow: process.env.SEL_OPEN_ROW || 'table tbody tr:first-child',
|
||||
|
||||
// OJO: aquí depende mucho del portal
|
||||
statusDropdown: process.env.SEL_STATUS_DROPDOWN || 'select[name*="estado"], select[id*="estado"], select',
|
||||
noteTextarea: process.env.SEL_NOTE_TEXTAREA || 'textarea[name*="nota"], textarea[id*="nota"], textarea',
|
||||
saveBtn: process.env.SEL_SAVE_BTN || 'button:has-text("Guardar"), button:has-text("Save"), button:has-text("Actualizar")',
|
||||
},
|
||||
|
||||
// Timing
|
||||
NAV_TIMEOUT: parseInt(process.env.NAV_TIMEOUT || '120000', 10),
|
||||
WAIT_TIMEOUT: parseInt(process.env.WAIT_TIMEOUT || '60000', 10),
|
||||
};
|
||||
|
||||
// -----------------------------
|
||||
// Firebase Admin init
|
||||
// -----------------------------
|
||||
function initFirebase() {
|
||||
// Soporta 2 modos:
|
||||
// 1) FIREBASE_PROJECT_ID + FIREBASE_CLIENT_EMAIL + FIREBASE_PRIVATE_KEY
|
||||
// 2) Application Default Credentials (si existiese)
|
||||
if (admin.apps.length) return admin.firestore();
|
||||
if (admin.apps?.length) return admin.firestore();
|
||||
|
||||
const hasEnvCreds =
|
||||
process.env.FIREBASE_PROJECT_ID &&
|
||||
process.env.FIREBASE_CLIENT_EMAIL &&
|
||||
process.env.FIREBASE_PRIVATE_KEY;
|
||||
|
||||
if (hasEnvCreds) {
|
||||
// Requiere estas 3 env (como ya usas en tus robots)
|
||||
if (!process.env.FIREBASE_PRIVATE_KEY) throw new Error('Missing env: FIREBASE_PRIVATE_KEY');
|
||||
admin.initializeApp({
|
||||
credential: admin.credential.cert({
|
||||
projectId: mustEnv('FIREBASE_PROJECT_ID'),
|
||||
|
|
@ -92,63 +24,129 @@ function initFirebase() {
|
|||
privateKey: mustEnv('FIREBASE_PRIVATE_KEY').replace(/\\n/g, '\n'),
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
// fallback: si tu entorno tuviera ADC (no siempre)
|
||||
admin.initializeApp();
|
||||
}
|
||||
|
||||
return admin.firestore();
|
||||
}
|
||||
|
||||
const db = initFirebase();
|
||||
|
||||
// -----------------------------
|
||||
// HomeServe credentials loader (Firestore)
|
||||
// -----------------------------
|
||||
let credCache = { ts: 0, data: null };
|
||||
const CRED_TTL_MS = parseInt(process.env.CRED_TTL_MS || '60000', 10);
|
||||
// ===== Config =====
|
||||
const CONFIG = {
|
||||
SERVICE_NAME: 'estados-hs',
|
||||
|
||||
HOMESERVE_BASE_URL: process.env.HOMESERVE_BASE_URL || 'https://gestor.homeserve.es/',
|
||||
|
||||
// 🔥 Preferimos este doc (tu captura)
|
||||
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: {
|
||||
user: process.env.SEL_USER || 'input[type="text"]',
|
||||
pass: process.env.SEL_PASS || 'input[type="password"]',
|
||||
submit: process.env.SEL_SUBMIT || 'button[type="submit"]',
|
||||
|
||||
// búsqueda de parte/servicio
|
||||
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")',
|
||||
|
||||
// entrar al detalle del parte
|
||||
openRow: process.env.SEL_OPEN_ROW || 'table tbody tr:first-child',
|
||||
|
||||
// cambio de estado
|
||||
statusDropdown: process.env.SEL_STATUS_DROPDOWN || 'select[name*="estado"], select[id*="estado"], select:has(option)',
|
||||
|
||||
// (opcionales) campos de fecha / observación si existen en HomeServe
|
||||
dateInput: process.env.SEL_DATE_INPUT || 'input[name*="fecha"], input[id*="fecha"], input[placeholder*="dd"], input[placeholder*="DD"]',
|
||||
noteTextarea: process.env.SEL_NOTE_TEXTAREA || 'textarea[name*="nota"], textarea[id*="nota"], textarea',
|
||||
saveBtn: process.env.SEL_SAVE_BTN || 'button:has-text("Guardar"), button:has-text("Save"), button:has-text("Actualizar")',
|
||||
},
|
||||
};
|
||||
|
||||
// ===== Estados (los del switch de tu app) =====
|
||||
const STATUS = [
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
const CODE_TO_LABEL = Object.fromEntries(STATUS.map(s => [s.code, s.title]));
|
||||
|
||||
// ===== Utils =====
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
const nowISO = () => new Date().toISOString();
|
||||
|
||||
function parseDocPath(path) {
|
||||
// "collection/doc/subcollection/doc" -> solo soportamos colección/doc o colección/doc/... (FireStore permite)
|
||||
const parts = String(path || '').split('/').filter(Boolean);
|
||||
if (parts.length < 2 || parts.length % 2 !== 0) {
|
||||
throw new Error(`Invalid Firestore doc path: "${path}" (must be collection/doc[/collection/doc...])`);
|
||||
}
|
||||
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() {
|
||||
const now = Date.now();
|
||||
if (credCache.data && (now - credCache.ts) < CRED_TTL_MS) return credCache.data;
|
||||
// 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;
|
||||
}
|
||||
};
|
||||
|
||||
// Firestore doc: CONFIG.HS_CRED_DOC_PATH
|
||||
// Esperado:
|
||||
// {
|
||||
// user: "usuario",
|
||||
// pass: "password",
|
||||
// baseUrl: "https://gestor.homeserve.es/" (opcional)
|
||||
// }
|
||||
const snap = await db.doc(CONFIG.HS_CRED_DOC_PATH).get();
|
||||
const d = snap.exists ? (snap.data() || {}) : {};
|
||||
const a = await tryDoc(CONFIG.HS_CRED_DOC_PATH);
|
||||
if (a) return a;
|
||||
|
||||
const user = safeStr(d.user || d.username || process.env.HOMESERVE_USER);
|
||||
const pass = safeStr(d.pass || d.password || process.env.HOMESERVE_PASS);
|
||||
const baseUrl = safeStr(d.baseUrl || d.url || CONFIG.HOMESERVE_BASE_URL) || CONFIG.HOMESERVE_BASE_URL;
|
||||
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' };
|
||||
|
||||
if (!user || !pass) {
|
||||
throw new Error(
|
||||
`HomeServe creds missing. Set them in Firestore doc "${CONFIG.HS_CRED_DOC_PATH}" (user/pass) or env HOMESERVE_USER/HOMESERVE_PASS`
|
||||
`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`
|
||||
);
|
||||
}
|
||||
|
||||
const out = { user, pass, baseUrl };
|
||||
credCache = { ts: now, data: out };
|
||||
return out;
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Playwright helpers
|
||||
// -----------------------------
|
||||
async function withBrowser(fn) {
|
||||
const browser = await chromium.launch({
|
||||
headless: CONFIG.HEADLESS,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||
});
|
||||
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
return await fn(page);
|
||||
} finally {
|
||||
|
|
@ -157,212 +155,134 @@ async function withBrowser(fn) {
|
|||
}
|
||||
|
||||
async function login(page, creds) {
|
||||
await page.goto(creds.baseUrl, { waitUntil: 'domcontentloaded', timeout: CONFIG.NAV_TIMEOUT });
|
||||
await page.goto(CONFIG.HOMESERVE_BASE_URL, { waitUntil: 'domcontentloaded', timeout: 120000 });
|
||||
|
||||
await page.waitForSelector(CONFIG.SEL.user, { timeout: CONFIG.WAIT_TIMEOUT });
|
||||
await page.waitForSelector(CONFIG.SEL.user, { timeout: 60000 });
|
||||
await page.fill(CONFIG.SEL.user, creds.user);
|
||||
|
||||
await page.waitForSelector(CONFIG.SEL.pass, { timeout: CONFIG.WAIT_TIMEOUT });
|
||||
await page.waitForSelector(CONFIG.SEL.pass, { timeout: 60000 });
|
||||
await page.fill(CONFIG.SEL.pass, creds.pass);
|
||||
|
||||
const btn = await page.$(CONFIG.SEL.submit);
|
||||
if (btn) await btn.click();
|
||||
else await page.keyboard.press('Enter');
|
||||
|
||||
// “networkidle” a veces se atasca; lo hacemos más tolerante
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT });
|
||||
await sleep(1200);
|
||||
await page.waitForLoadState('networkidle', { timeout: 120000 });
|
||||
}
|
||||
|
||||
async function openParte(page, parteId) {
|
||||
// Buscar
|
||||
async function openParte(page, serviceNumber) {
|
||||
const hasSearch = await page.$(CONFIG.SEL.searchBox);
|
||||
if (hasSearch) {
|
||||
await page.fill(CONFIG.SEL.searchBox, String(parteId));
|
||||
await page.fill(CONFIG.SEL.searchBox, String(serviceNumber));
|
||||
const btn = await page.$(CONFIG.SEL.searchBtn);
|
||||
if (btn) await btn.click();
|
||||
else await page.keyboard.press('Enter');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT });
|
||||
|
||||
await page.waitForLoadState('networkidle', { timeout: 120000 });
|
||||
await sleep(1200);
|
||||
}
|
||||
|
||||
// Abrir primera fila
|
||||
await page.waitForSelector(CONFIG.SEL.openRow, { timeout: CONFIG.WAIT_TIMEOUT });
|
||||
await page.waitForSelector(CONFIG.SEL.openRow, { timeout: 60000 });
|
||||
await page.click(CONFIG.SEL.openRow);
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT });
|
||||
await sleep(1000);
|
||||
await page.waitForLoadState('networkidle', { timeout: 120000 });
|
||||
await sleep(900);
|
||||
}
|
||||
|
||||
async function setEstadoByCode(page, statusCode, note) {
|
||||
const code = safeStr(statusCode);
|
||||
if (!code) throw new Error('Missing statusCode');
|
||||
async function setEstado(page, code, dateString, observation) {
|
||||
await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: 60000 });
|
||||
|
||||
await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: CONFIG.WAIT_TIMEOUT });
|
||||
|
||||
// 1) Intentar selectOption por value exacto
|
||||
// 1) intenta por value (= código)
|
||||
let selected = false;
|
||||
try {
|
||||
await page.selectOption(CONFIG.SEL.statusDropdown, { value: code });
|
||||
await page.selectOption(CONFIG.SEL.statusDropdown, { value: String(code) });
|
||||
selected = true;
|
||||
} catch (_) {}
|
||||
|
||||
// 2) Intentar selectOption por label que contenga el código
|
||||
// 2) si no, intenta por label (título del estado)
|
||||
if (!selected) {
|
||||
const label = CODE_TO_LABEL[String(code)] || String(code);
|
||||
try {
|
||||
await page.selectOption(CONFIG.SEL.statusDropdown, { label: code });
|
||||
await page.selectOption(CONFIG.SEL.statusDropdown, { label });
|
||||
selected = true;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// 3) Fallback DOM: busca option cuyo value == code o cuyo texto contenga "(code)" o termine con code
|
||||
// 3) si no, intenta encontrar option cuyo text contenga el código o el título
|
||||
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);
|
||||
if (!s) return false;
|
||||
|
||||
const opts = Array.from(s.querySelectorAll('option'));
|
||||
const norm = (x) => (x || '').toString().trim().toLowerCase();
|
||||
|
||||
const hit =
|
||||
opts.find(o => norm(o.value) === norm(code)) ||
|
||||
opts.find(o => norm(o.textContent).includes(`(${norm(code)})`)) ||
|
||||
opts.find(o => norm(o.textContent).endsWith(norm(code))) ||
|
||||
opts.find(o => norm(o.textContent).includes(norm(code)));
|
||||
|
||||
const lc = String(code).trim().toLowerCase();
|
||||
const ll = String(label).trim().toLowerCase();
|
||||
const hit = opts.find(o => {
|
||||
const t = (o.textContent || '').trim().toLowerCase();
|
||||
return t.includes(lc) || (!!ll && t.includes(ll));
|
||||
});
|
||||
if (!hit) return false;
|
||||
|
||||
s.value = hit.value;
|
||||
s.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
}, { sel: CONFIG.SEL.statusDropdown, code });
|
||||
}, { sel: CONFIG.SEL.statusDropdown, code: String(code), label });
|
||||
|
||||
if (!ok) {
|
||||
throw new Error(`No matching status option for code "${code}". Revisa SEL_STATUS_DROPDOWN o el HTML del portal.`);
|
||||
if (!ok) throw new Error(`No matching status option for code "${code}"`);
|
||||
}
|
||||
|
||||
// Fecha (si existe el campo)
|
||||
if (dateString) {
|
||||
const el = await page.$(CONFIG.SEL.dateInput);
|
||||
if (el) {
|
||||
await page.fill(CONFIG.SEL.dateInput, String(dateString));
|
||||
}
|
||||
}
|
||||
|
||||
// Nota / observación
|
||||
if (note) {
|
||||
// Nota / observación (si existe)
|
||||
if (observation) {
|
||||
const ta = await page.$(CONFIG.SEL.noteTextarea);
|
||||
if (ta) {
|
||||
await page.fill(CONFIG.SEL.noteTextarea, String(note));
|
||||
await page.fill(CONFIG.SEL.noteTextarea, String(observation));
|
||||
}
|
||||
}
|
||||
|
||||
// Guardar
|
||||
const save = await page.$(CONFIG.SEL.saveBtn);
|
||||
if (!save) throw new Error('Save button not found (SEL_SAVE_BTN)');
|
||||
if (!save) throw new Error('Save button not found');
|
||||
await save.click();
|
||||
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT });
|
||||
await sleep(1400);
|
||||
await page.waitForLoadState('networkidle', { timeout: 120000 });
|
||||
await sleep(1200);
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Express app
|
||||
// -----------------------------
|
||||
// ===== Express =====
|
||||
const app = express();
|
||||
|
||||
app.use(cors({ origin: true }));
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
// Auth middleware (opcional)
|
||||
app.use((req, res, next) => {
|
||||
if (!CONFIG.REQUIRE_AUTH) return next();
|
||||
|
||||
const h = req.headers.authorization || '';
|
||||
const token = h.startsWith('Bearer ') ? h.slice(7).trim() : '';
|
||||
|
||||
if (!CONFIG.API_TOKEN) {
|
||||
return res.status(500).json({ ok: false, error: { message: 'REQUIRE_AUTH=1 but API_TOKEN is not set' } });
|
||||
}
|
||||
|
||||
if (!token || token !== CONFIG.API_TOKEN) {
|
||||
return res.status(401).json({ ok: false, error: { message: 'Unauthorized' } });
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// Health + root
|
||||
// Root OK (lo que pediste)
|
||||
app.get('/', (req, res) => {
|
||||
res.status(200).send('ok');
|
||||
});
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.status(200).json({
|
||||
res.json({
|
||||
ok: true,
|
||||
service: 'estados-hs',
|
||||
port: parsePort(process.env.PORT, null),
|
||||
service: CONFIG.SERVICE_NAME,
|
||||
requireAuth: CONFIG.REQUIRE_AUTH,
|
||||
hsCredDocPath: CONFIG.HS_CRED_DOC_PATH,
|
||||
hsCredDocFallback: CONFIG.HS_CRED_DOC_FALLBACK,
|
||||
ts: nowISO(),
|
||||
});
|
||||
});
|
||||
|
||||
// Endpoint principal: cambio directo
|
||||
// Body esperado:
|
||||
// {
|
||||
// serviceNumber: "28219874",
|
||||
// newStatusValue: "348", // código
|
||||
// dateString: "03/01/2026", // opcional (por si lo quieres guardar en nota)
|
||||
// observation: "texto..." // opcional
|
||||
// }
|
||||
app.post('/api/homeserve/change-status', async (req, res) => {
|
||||
const serviceNumber = safeStr(req.body?.serviceNumber);
|
||||
const newStatusValue = safeStr(req.body?.newStatusValue || req.body?.statusCode);
|
||||
const dateString = safeStr(req.body?.dateString);
|
||||
const observation = safeStr(req.body?.observation);
|
||||
|
||||
if (!serviceNumber || !newStatusValue) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: { message: 'Missing serviceNumber or newStatusValue' },
|
||||
});
|
||||
}
|
||||
|
||||
// Construimos nota final (si quieres que la fecha viaje dentro)
|
||||
const noteParts = [];
|
||||
if (dateString) noteParts.push(`Fecha: ${dateString}`);
|
||||
if (observation) noteParts.push(observation);
|
||||
const note = noteParts.join(' · ').trim();
|
||||
|
||||
const startedAtISO = nowISO();
|
||||
|
||||
try {
|
||||
const creds = await getHomeServeCreds();
|
||||
|
||||
await withBrowser(async (page) => {
|
||||
await login(page, creds);
|
||||
await openParte(page, serviceNumber);
|
||||
await setEstadoByCode(page, newStatusValue, note);
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
ok: true,
|
||||
serviceNumber,
|
||||
newStatusValue,
|
||||
startedAtISO,
|
||||
finishedAtISO: nowISO(),
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(500).json({
|
||||
ok: false,
|
||||
serviceNumber,
|
||||
newStatusValue,
|
||||
startedAtISO,
|
||||
finishedAtISO: nowISO(),
|
||||
error: {
|
||||
message: String(err?.message || err),
|
||||
stack: String(err?.stack || ''),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// HTML test page (por si quieres probar rápido desde el navegador)
|
||||
// HTML integrado para pruebas (puedes dejarlo así o servir un archivo)
|
||||
app.get('/test', (req, res) => {
|
||||
res.type('html').send(`<!doctype html>
|
||||
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>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
|
|
@ -370,47 +290,40 @@ app.get('/test', (req, res) => {
|
|||
<title>estados-hs · test</title>
|
||||
<style>
|
||||
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial;margin:0;padding:24px;background:#0b1220;color:#e8eefc}
|
||||
.card{max-width:720px;margin:0 auto;background:#111a2e;border:1px solid rgba(255,255,255,.08);border-radius:16px;padding:18px}
|
||||
.card{max-width:760px;margin:0 auto;background:#111a2e;border:1px solid rgba(255,255,255,.08);border-radius:16px;padding:18px}
|
||||
label{display:block;margin:12px 0 6px;font-size:13px;opacity:.85}
|
||||
input,select,textarea,button{width:100%;padding:12px;border-radius:12px;border:1px solid rgba(255,255,255,.12);background:#0b1220;color:#e8eefc}
|
||||
textarea{min-height:90px}
|
||||
button{cursor:pointer;font-weight:700}
|
||||
button{cursor:pointer;font-weight:800}
|
||||
.row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||
.log{white-space:pre-wrap;background:#0b1220;border-radius:12px;border:1px solid rgba(255,255,255,.12);padding:12px;margin-top:12px;font-size:12px}
|
||||
.muted{opacity:.7;font-size:12px}
|
||||
.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}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2 style="margin:0 0 8px">estados-hs · prueba</h2>
|
||||
<div class="muted">POST /api/homeserve/change-status</div>
|
||||
<div class="muted">Servidor: <span class="pill" id="base"></span></div>
|
||||
|
||||
<div class="row" style="margin-top:12px">
|
||||
<button id="btnHealth" type="button">Probar /health</button>
|
||||
<button id="btnRoot" type="button">Probar /</button>
|
||||
</div>
|
||||
|
||||
<label>Service Number</label>
|
||||
<input id="serviceNumber" placeholder="28219874" />
|
||||
<input id="serviceNumber" placeholder="15251178" />
|
||||
|
||||
<div class="row">
|
||||
<div>
|
||||
<label>Código estado</label>
|
||||
<select id="statusCode">
|
||||
<option value="303">303 · En espera de Cliente por aceptación Presupuesto</option>
|
||||
<option value="307">307 · En espera de Profesional por fecha de inicio de trabajos</option>
|
||||
<option value="313">313 · En espera de Profesional por secado de cala, pintura o parquet</option>
|
||||
<option value="318">318 · En espera de Profesional por confirmación del Siniestro</option>
|
||||
<option value="319">319 · En espera de Profesional por material</option>
|
||||
<option value="320">320 · En espera de Profesional por espera de otro gremio</option>
|
||||
<option value="321">321 · En espera de Profesional por presupuesto/valoración</option>
|
||||
<option value="323">323 · En espera de Profesional por mejora del tiempo</option>
|
||||
<option value="326">326 · En espera de Cliente por pago de Factura Contado/Franquicia</option>
|
||||
<option value="336">336 · En espera de Profesional por avería en observación</option>
|
||||
<option value="342">342 · En espera de Profesional pendiente cobro franquicia</option>
|
||||
<option value="345">345 · En espera de Profesional en realización pendiente Terminar</option>
|
||||
<option value="348" selected>348 · En espera de Cliente por indicaciones</option>
|
||||
<option value="352">352 · En espera de Perjudicado por indicaciones</option>
|
||||
${optionsHtml}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Fecha (DD/MM/AAAA) (opcional)</label>
|
||||
<input id="dateString" placeholder="03/01/2026" />
|
||||
<input id="dateString" placeholder="05/01/2026" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -418,7 +331,7 @@ app.get('/test', (req, res) => {
|
|||
<textarea id="observation" placeholder="Texto..."></textarea>
|
||||
|
||||
<div style="height:10px"></div>
|
||||
<button id="btn">Enviar</button>
|
||||
<button id="btnSend" type="button">Enviar cambio de estado</button>
|
||||
|
||||
<div class="log" id="log">Listo.</div>
|
||||
</div>
|
||||
|
|
@ -427,7 +340,29 @@ app.get('/test', (req, res) => {
|
|||
const $ = (id)=>document.getElementById(id);
|
||||
const log = (x)=>{ $('log').textContent = (typeof x==='string'?x:JSON.stringify(x,null,2)); };
|
||||
|
||||
$('btn').addEventListener('click', async () => {
|
||||
// Base correcta SIEMPRE al mismo dominio donde estás (evita el lío de marsalva.es)
|
||||
const BASE = window.location.origin;
|
||||
$('base').textContent = BASE;
|
||||
|
||||
$('btnHealth').addEventListener('click', async () => {
|
||||
try{
|
||||
log('Consultando /health...');
|
||||
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 () => {
|
||||
try{
|
||||
log('Consultando / ...');
|
||||
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 () => {
|
||||
try{
|
||||
log('Enviando...');
|
||||
const body = {
|
||||
|
|
@ -436,61 +371,96 @@ app.get('/test', (req, res) => {
|
|||
dateString: $('dateString').value.trim(),
|
||||
observation: $('observation').value.trim()
|
||||
};
|
||||
const r = await fetch('/api/homeserve/change-status', {
|
||||
|
||||
const r = await fetch(BASE + '/api/homeserve/change-status', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const j = await r.json().catch(()=>({}));
|
||||
log({ http: r.status, ...j });
|
||||
}catch(e){
|
||||
log(String(e));
|
||||
}
|
||||
log({ http: r.status, base: BASE, request: body, response: j });
|
||||
}catch(e){ log(String(e)); }
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`);
|
||||
});
|
||||
|
||||
// -----------------------------
|
||||
// CAPROVER PORT FIX (80 + PORT)
|
||||
// -----------------------------
|
||||
const port80 = 80;
|
||||
const envPort = parsePort(process.env.PORT, null);
|
||||
const caproverPort = parsePort(process.env.CAPROVER_PORT, null);
|
||||
|
||||
const extraPorts = new Set([envPort, caproverPort].filter(Boolean));
|
||||
extraPorts.delete(port80);
|
||||
|
||||
const servers = [];
|
||||
|
||||
function startListen(port) {
|
||||
const server = app.listen(port, '0.0.0.0', () => {
|
||||
console.log(`[estados-hs] listening on :${port}`);
|
||||
});
|
||||
servers.push(server);
|
||||
}
|
||||
|
||||
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)'}`);
|
||||
|
||||
// 1) Puerto “seguro” para CapRover/NGINX
|
||||
startListen(port80);
|
||||
|
||||
// 2) Puertos extra (por ejemplo 3000) si existen
|
||||
for (const p of extraPorts) startListen(p);
|
||||
|
||||
// cierre limpio (evita que npm lo pinte como “error”)
|
||||
async function shutdown(signal) {
|
||||
console.log(`[estados-hs] received ${signal} -> shutting down gracefully...`);
|
||||
for (const s of servers) {
|
||||
// Endpoint principal: cambio directo
|
||||
app.post('/api/homeserve/change-status', async (req, res) => {
|
||||
try {
|
||||
await new Promise((resolve) => s.close(() => resolve()));
|
||||
} catch (_) {}
|
||||
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' } });
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
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) {
|
||||
return String(s || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
// ===== Listen (compatible CapRover) =====
|
||||
const portA = parseInt(process.env.CAPROVER_PORT || process.env.PORT || '3000', 10);
|
||||
const portB = 80; // por si CapRover está esperando 80 y no configuraste el "Container HTTP Port"
|
||||
|
||||
console.log(`[${CONFIG.SERVICE_NAME}] HS_CRED_DOC_PATH=${CONFIG.HS_CRED_DOC_PATH}`);
|
||||
console.log(`[${CONFIG.SERVICE_NAME}] HS_CRED_DOC_FALLBACK=${CONFIG.HS_CRED_DOC_FALLBACK}`);
|
||||
console.log(`[${CONFIG.SERVICE_NAME}] REQUIRE_AUTH=${CONFIG.REQUIRE_AUTH ? 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