/** * Workflow Service * Handles workflow fetching with lazy loading and caching * Workflows are only loaded when first requested (not at startup) * * Un cache par application (D26) : le paramètre `application` des outils * sélectionne l'application AD interrogée (défaut : celle du profil actif). */ const apiService = require('./api-service').getInstance(); const profileManager = require('../config/profile-manager'); const { createSingleFlight } = require('./single-flight'); // Cache state — un cache de workflows par application (D26) let workflowCaches = {}; // application -> workflows[] let cacheTimestamps = {}; // application -> timestamp let applicationsCache = null; // liste allégée de POST /Application/GetAll let applicationsTimestamp = null; const CACHE_TTL = parseInt(process.env.WORKFLOW_CACHE_TTL) || 3600000; // 1 hour in milliseconds // Déduplication des chargements concurrents, par clé de cache (D27). Deux // clés distinctes ici : une par application, plus la liste d'applications. const singleFlight = createSingleFlight('Workflow'); // Clear cache when profile changes — workflows are per-tenant, so the previous // profile's cache is meaningless after a switch. profileManager.onSwitch(() => clearCache()); /** * Application effective : celle demandée, sinon celle du profil actif. */ function resolveApplication(application) { return (application && application.trim()) || profileManager.getCurrent().application; } /** * Check if cache is still valid for an application */ function isCacheValid(application) { if (!workflowCaches[application] || !cacheTimestamps[application]) { return false; } const age = Date.now() - cacheTimestamps[application]; return age < CACHE_TTL; } /** * Fetch all workflows of an application from API with pagination. * Uses high page size (5000) to minimize API calls. * Lazy : seule l'application effectivement demandée est chargée (D26) — ne * jamais précharger les 9 applications. * @param {string} [application] - Application AD (défaut : profil actif) */ async function fetchAllWorkflows(application) { const app = resolveApplication(application); // Check cache validity if (isCacheValid(app)) { console.error(`[Workflow] Using cached data for "${app}"`); return workflowCaches[app]; } // Un seul chargement par application, même sous rafale concurrente (D27). return singleFlight.run(`workflows::${app}`, () => loadWorkflows(app)); } /** * Chargement réel des workflows d'une application (pagination complète). * Appelé au plus une fois par application tant qu'il est en vol (D27). */ async function loadWorkflows(app) { console.error(`[Workflow] Cache expired or empty for "${app}", fetching from API...`); try { let allWorkflows = []; let offset = 0; const pageSize = parseInt(process.env.WORKFLOW_PAGE_SIZE) || 5000; const tenant = profileManager.getCurrent().tenant; while (true) { const body = [app, tenant, pageSize, offset]; console.error(`[Workflow] Fetching page: application=${app}, offset=${offset}, pageSize=${pageSize}`); // Use AD API (useAdApi=true) const response = await apiService.post('/Workflow/GetByApplication', body, true); // Extract entities array from response const workflows = response?.entities || []; // Check if response is valid if (!workflows || workflows.length === 0) { console.error('[Workflow] No more workflows to fetch'); break; } allWorkflows = allWorkflows.concat(workflows); console.error(`[Workflow] Fetched ${workflows.length} workflows (total: ${allWorkflows.length})`); // If we got less than page size, we've reached the last page if (workflows.length < pageSize) { console.error('[Workflow] Last page reached'); break; } offset += pageSize; } // Update cache workflowCaches[app] = allWorkflows; cacheTimestamps[app] = Date.now(); console.error(`[Workflow] Successfully cached ${allWorkflows.length} workflows for "${app}"`); return allWorkflows; } catch (error) { console.error(`[Workflow] Error fetching workflows for "${app}":`, error.message); throw new Error(`Failed to fetch workflows for application "${app}": ${error.message}`); } } /** * Liste les applications déclarées (POST /Application/GetAll, payload null). * La réponse est une enveloppe { entities: [...] } (D4) dont chaque élément * porte un blob `data` volumineux — on ne conserve que les champs légers. * Cache TTL commun, vidé au switch de profil. * @returns {Promise>} */ async function fetchApplications() { const cached = getCachedApplications(); if (cached) return cached; // Même déduplication que les workflows, sur sa propre clé (D27). return singleFlight.run('applications', loadApplications); } /** * Chargement réel de la liste d'applications (D27). */ async function loadApplications() { console.error('[Workflow] Fetching application list (Application/GetAll)...'); const response = await apiService.post('/Application/GetAll', null, true); const entities = response?.entities || []; applicationsCache = entities.map(a => ({ name: a.name || a.Name, id: a.id || a.Id, version: a.version ?? a.Version, })).filter(a => a.name); applicationsTimestamp = Date.now(); console.error(`[Workflow] Cached ${applicationsCache.length} application(s)`); return applicationsCache; } /** * Liste des applications déjà en cache, ou null si le cache est vide/expiré. * Ne déclenche AUCUN appel réseau — c'est ce qui permet d'enrichir une réponse * de recherche sans jamais précharger une application non demandée (D26). * @returns {Array<{name: string, id: string, version: number}>|null} */ function getCachedApplications() { if (applicationsCache && applicationsTimestamp && Date.now() - applicationsTimestamp < CACHE_TTL) { return applicationsCache; } return null; } /** * Hint de découvrabilité (L5.4). Une recherche n'interroge qu'UNE application * sur les neuf déclarées, et rien dans la réponse ne le disait : une session * cherchant des workflows `CST_*` sans `application: "CustomApp"` a conclu à * tort qu'il n'y en avait aucun (25/08/2026). * * Les autres applications sont nommées depuis la liste allégée **déjà en * cache** ; sans elle, le hint reste générique et renvoie vers * `list_workflow_categories` — jamais de fetch pour construire un hint. * * @param {string} application - application effectivement interrogée * @param {string} sujet - ce qui a été cherché ('workflow', 'élément Command'…) */ function buildOtherApplicationsHint(application, sujet) { const cached = getCachedApplications(); const others = (cached || []).map(a => a.name).filter(n => n !== application); const liste = others.length ? `Autres applications déclarées sur ce tenant : ${others.join(', ')}.` : `Appelez list_workflow_categories pour lister les autres applications déclarées.`; const custom = application.toLowerCase() === 'customapp' ? '' : ` Le spécifique client (préfixe CST_) vit dans "CustomApp" : relancez avec application: "CustomApp".`; return `Aucun ${sujet} trouvé dans l'application "${application}" — c'est la SEULE interrogée, ` + `les autres ne le sont jamais implicitement.${custom} ${liste}`; } /** * Search workflows by query string. * Real AD keys (lowercase, cf. D5): id, name, version, applicationName, * commonInfo — no description/code/category field exists. * @param {string} query - Search query (matches workflow name) * @param {string|null} category - Optional applicationName filter (the only * grouping the AD API provides) * @param {number} limit - Maximum results to return * @param {string} [application] - Application AD interrogée (défaut : profil) */ async function searchWorkflows(query, category = null, limit = 50, application) { const workflows = await fetchAllWorkflows(application); let results = workflows; // Filter by query if provided if (query) { const lowerQuery = query.toLowerCase(); results = results.filter(w => { const name = (w.name || w.Name || '').toLowerCase(); return name.includes(lowerQuery); }); } // Filter by applicationName if provided if (category) { const lowerCategory = category.toLowerCase(); results = results.filter(w => { const applicationName = (w.applicationName || w.ApplicationName || '').toLowerCase(); return applicationName.includes(lowerCategory); }); } // Limit results return results.slice(0, limit); } /** * Get workflow details by ID * @param {string|number} workflowId - Workflow ID * @param {string} [application] - Application AD interrogée (défaut : profil) */ async function getWorkflowDetails(workflowId, application) { // Garde d'entrée : sans elle, un workflow_id absent matchait le premier // workflow du cache (undefined === undefined sur les clés mortes ci-dessous). if (workflowId == null || workflowId === '') { throw new Error('workflow_id est requis (id ou nom exact du workflow). Utilisez search_workflows pour le trouver.'); } const app = resolveApplication(application); const workflows = await fetchAllWorkflows(app); // Clés réelles de l'API AD (minuscules, D5) : id, name. Les variantes // Id/Code/Name n'existent pas sur ces objets — les comparer faisait matcher // undefined === undefined dès que workflow_id manquait. const workflow = workflows.find(w => w.id === workflowId || w.name === workflowId ); if (!workflow) { throw new Error( `Workflow not found: ${workflowId} (application "${app}"). ` + `Utilisez search_workflows pour trouver l'id ou le nom exact — ` + `pensez au paramètre application (ex: "CustomApp" pour le spécifique client).` ); } return workflow; } /** * Get workflow statistics for one application * @param {string} [application] - Application AD interrogée (défaut : profil) */ async function getWorkflowStats(application) { const app = resolveApplication(application); const workflows = await fetchAllWorkflows(app); return { application: app, total: workflows.length, cacheAge: cacheTimestamps[app] ? Math.floor((Date.now() - cacheTimestamps[app]) / 1000) : null }; } /** * Clear workflow caches (force refresh on next request) — toutes applications. */ function clearCache() { workflowCaches = {}; cacheTimestamps = {}; applicationsCache = null; applicationsTimestamp = null; console.error('[Workflow] Cache cleared'); } /** * Get cache status, per application (D26) * @returns {Object} application -> { cached, count, timestamp, age, valid } */ function getCacheStatus() { const status = {}; Object.keys(workflowCaches).forEach(app => { status[app] = { cached: true, count: workflowCaches[app].length, timestamp: cacheTimestamps[app], age: cacheTimestamps[app] ? Math.floor((Date.now() - cacheTimestamps[app]) / 1000) : null, valid: isCacheValid(app) }; }); return status; } module.exports = { fetchAllWorkflows, fetchApplications, getCachedApplications, buildOtherApplicationsHint, resolveApplication, searchWorkflows, getWorkflowDetails, getWorkflowStats, clearCache, getCacheStatus };