/** * api/data.js — Vercel Serverless Function (Refactorizado) * * Consulta JIRA en tiempo real y devuelve datos procesados del dashboard. * * Usa: api/config.js para centralizar variables de entorno */ import { config } from './config.js'; // ─── Cache en memoria ────────────────────────────────────────────── let _cache = null; let _cacheTime = 0; // ─── JIRA Fetch ──────────────────────────────────────────────────── /** * Consulta JIRA API con Basic Auth * @param {string} email - Email JIRA * @param {string} token - API Token * @returns {Promise<{issues: Array, total: number}>} */ async function fetchJiraIssues(email, token) { const auth = Buffer.from(`${email}:${token}`).toString('base64'); const url = `https://${config.jira.host}/rest/api/3/search/jql` + `?jql=${encodeURIComponent(config.jql)}` + `&fields=${encodeURIComponent(buildFieldsList())}` + `&maxResults=${config.api.maxResults}`; console.log(`📡 Consultando JIRA: ${config.jira.project} (${config.api.maxResults} resultados max)`); console.log(`📋 JQL: ${config.jql}`); const res = await fetch(url, { headers: { 'Authorization': `Basic ${auth}`, 'Accept': 'application/json', }, }); if (!res.ok) { const text = await res.text(); const errorMsg = `JIRA API ${res.status}: ${text.slice(0, 200)}`; console.error(`❌ ${errorMsg}`); throw new Error(errorMsg); } const data = await res.json(); console.log(`✅ Se obtuvieron ${data.issues.length} issues (total en JIRA: ${data.total})`); return data; } // ─── Build Fields List ───────────────────────────────────────────── /** * Construye lista de campos a traer de JIRA */ function buildFieldsList() { return [ 'summary', 'status', 'assignee', config.fields.spEstimate, // Story points estimate config.fields.spFinal, // Story points finales config.fields.developer, // Desarrollador (multi-user) 'parent', 'resolutiondate', 'created', ].join(','); } // ─── Process Issues ──────────────────────────────────────────────── /** * Procesa issues de JIRA y agrupa los datos * @param {Array} issues - Array de issues de JIRA * @returns {Object} Datos procesados */ function processIssues(issues) { const assigneeData = {}; const monthlyDone = {}; const epicMap = {}; let processedCount = 0; let skippedCount = 0; for (const issue of issues) { try { const f = issue.fields; // ── Extraer desarrolladores ──────────────────────────────── // Prioridad: customfield_10622 (multi) → assignee (single) let devs = []; if (f[config.fields.developer]?.length) { // Multi-usuario devs = f[config.fields.developer].map(d => d.displayName); } else if (f.assignee) { // Single usuario devs = [f.assignee.displayName]; } else { // Sin asignar devs = ['Sin asignar']; } // ── Extraer Story Points ─────────────────────────────────── // Prioridad: SP Finales → SP Estimate const sp = (f[config.fields.spFinal] != null ? f[config.fields.spFinal] : f[config.fields.spEstimate]) ?? 0; // ── Extraer Status ───────────────────────────────────────── const statusKey = f.status?.statusCategory?.key ?? 'new'; const isDone = statusKey === 'done'; const isInProgress = statusKey === 'indeterminate'; // ── Extraer Fechas (YYYY-MM) ────────────────────────────── const resMonth = f.resolutiondate ? f.resolutiondate.slice(0, 7) : null; const creMonth = f.created ? f.created.slice(0, 7) : null; // ── Extraer Épica ───────────────────────────────────────── const epicName = f.parent?.fields?.summary ?? 'Sin épica'; // ── Acumular por Desarrollador ──────────────────────────── for (const dev of devs) { if (!assigneeData[dev]) { assigneeData[dev] = { done: 0, in_progress: 0, todo: 0 }; } if (isDone) { assigneeData[dev].done += sp; } else if (isInProgress) { assigneeData[dev].in_progress += sp; } else { assigneeData[dev].todo += sp; } } // ── Acumular completados por mes ─────────────────────────── if (isDone) { const month = resMonth || creMonth; if (month) { if (!monthlyDone[month]) monthlyDone[month] = {}; for (const dev of devs) { monthlyDone[month][dev] = (monthlyDone[month][dev] ?? 0) + sp; } } } // ── Acumular por Épica ───────────────────────────────────── if (!epicMap[epicName]) { epicMap[epicName] = { name: epicName, done: 0, in_progress: 0, todo: 0 }; } if (isDone) { epicMap[epicName].done += sp; } else if (isInProgress) { epicMap[epicName].in_progress += sp; } else { epicMap[epicName].todo += sp; } processedCount++; } catch (error) { console.warn(`⚠️ Error procesando issue ${issue.key}: ${error.message}`); skippedCount++; } } console.log(`📊 Procesados: ${processedCount} | Saltados: ${skippedCount}`); return { lastUpdated: new Date().toISOString(), totalIssues: processedCount, skippedIssues: skippedCount, assigneeData, monthlyDone, epicData: Object.values(epicMap) .sort((a, b) => (b.done + b.in_progress + b.todo) - (a.done + a.in_progress + a.todo) ), }; } // ─── Main Handler ────────────────────────────────────────────────── /** * Vercel Serverless Function Handler */ export default async (req, res) => { // ── CORS ─────────────────────────────────────────────────────── res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET'); // ── Caching ──────────────────────────────────────────────────── // CDN cache: 24hs fresco, sirve stale mientras recarga res.setHeader('Cache-Control', 's-maxage=86400, stale-while-revalidate=3600'); try { // Force refresh con parámetro ?refresh=1 const forceRefresh = req.query?.refresh === '1'; // Servir desde caché si está fresco if (!forceRefresh && _cache && (Date.now() - _cacheTime) < config.api.cacheMsec) { console.log('📦 Sirviendo desde caché (en memoria)'); return res.status(200).json({ ..._cache, fromCache: true }); } // ── Consultar JIRA ───────────────────────────────────────── const jiraData = await fetchJiraIssues(config.jira.email, config.jira.token); const data = processIssues(jiraData.issues); // Agregar metadata data.jiraTotal = jiraData.total; // Total real en JIRA (puede ser > maxResults) data.config = { project: config.jira.project, host: config.jira.host, maxResults: config.api.maxResults, cacheMinutes: Math.round(config.api.cacheMsec / 1000 / 60), }; // Guardar en caché en memoria _cache = data; _cacheTime = Date.now(); console.log('✅ Datos frescos desde JIRA almacenados en caché'); return res.status(200).json({ ...data, fromCache: false }); } catch (err) { console.error('❌ [dashboard/api/data] Error:', err.message); // Intentar servir desde caché si hay error if (_cache) { console.log('⚠️ Sirviendo caché anterior debido a error'); return res.status(200).json({ ..._cache, fromCache: true, error: `Error al consultar JIRA: ${err.message}. Sirviendo datos en caché.`, }); } // Si no hay caché, retornar error return res.status(500).json({ error: err.message, hint: 'Verifica JIRA_EMAIL y JIRA_TOKEN en variables de entorno', timestamp: new Date().toISOString(), }); } };