// api/data.js — Vercel Serverless Function // Consulta Jira en tiempo real y devuelve los datos procesados del dashboard. // Variables de entorno requeridas: JIRA_EMAIL, JIRA_TOKEN const JIRA_HOST = 'invera.atlassian.net'; const JQL = 'project = LABS AND cf[10016] is not EMPTY ORDER BY updated DESC'; const FIELDS = [ 'summary', 'status', 'assignee', 'customfield_10016', // Story point estimate 'customfield_11019', // Story Points Finales 'customfield_10622', // Desarrollador (multi-user) 'parent', 'resolutiondate', 'created', ].join(','); const MAX_RESULTS = 100; const CACHE_MS = 24 * 60 * 60 * 1000; // 24 horas // Cache en memoria (se reinicia con cada cold start de la función) let _cache = null; let _cacheTime = 0; // ─── Jira fetch ────────────────────────────────────────────────────────────── async function fetchJiraIssues(email, token) { const auth = Buffer.from(`${email}:${token}`).toString('base64'); const url = `https://${JIRA_HOST}/rest/api/3/search/jql` + `?jql=${encodeURIComponent(JQL)}` + `&fields=${encodeURIComponent(FIELDS)}` + `&maxResults=${MAX_RESULTS}`; const res = await fetch(url, { headers: { 'Authorization': `Basic ${auth}`, 'Accept': 'application/json', }, }); if (!res.ok) { const text = await res.text(); throw new Error(`Jira API ${res.status}: ${text.slice(0, 200)}`); } return res.json(); } // ─── Data processing ───────────────────────────────────────────────────────── function processIssues(issues) { const assigneeData = {}; const monthlyDone = {}; const epicMap = {}; for (const issue of issues) { const f = issue.fields; // Desarrollador (customfield_10622 multi-user), fallback a assignee const devs = f.customfield_10622?.length ? f.customfield_10622.map(d => d.displayName) : f.assignee ? [f.assignee.displayName] : ['Sin asignar']; // SP: SP Finales si está cargado, si no el estimate const sp = (f.customfield_11019 != null ? f.customfield_11019 : f.customfield_10016) ?? 0; // Estado const catKey = f.status?.statusCategory?.key ?? 'new'; // Fechas (YYYY-MM) const resMonth = f.resolutiondate ? f.resolutiondate.slice(0, 7) : null; const creMonth = f.created ? f.created.slice(0, 7) : null; // Épica (parent) const epic = 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 (catKey === 'done') assigneeData[dev].done += sp; else if (catKey === 'indeterminate') assigneeData[dev].in_progress += sp; else assigneeData[dev].todo += sp; } // ── Acumular completados por mes ── if (catKey === 'done') { 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[epic]) epicMap[epic] = { name: epic, done: 0, in_progress: 0, todo: 0 }; if (catKey === 'done') epicMap[epic].done += sp; else if (catKey === 'indeterminate') epicMap[epic].in_progress += sp; else epicMap[epic].todo += sp; } return { lastUpdated: new Date().toISOString(), totalIssues: issues.length, assigneeData, monthlyDone, epicData: Object.values(epicMap) .sort((a, b) => (b.done + b.in_progress + b.todo) - (a.done + a.in_progress + a.todo)), }; } // ─── Handler ───────────────────────────────────────────────────────────────── module.exports = async (req, res) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET'); // CDN cache: 24hs fresco, sirve stale mientras recarga res.setHeader('Cache-Control', 's-maxage=86400, stale-while-revalidate=3600'); // Force refresh si se pasa ?refresh=1 const forceRefresh = req.query?.refresh === '1'; // Servir desde caché si está fresco if (!forceRefresh && _cache && (Date.now() - _cacheTime) < CACHE_MS) { return res.status(200).json({ ..._cache, fromCache: true }); } const email = process.env.JIRA_EMAIL; const token = process.env.JIRA_TOKEN; if (!email || !token) { return res.status(500).json({ error: 'Faltan variables de entorno: JIRA_EMAIL y/o JIRA_TOKEN', hint: 'Configurarlas en Vercel → Settings → Environment Variables', }); } try { const { issues, total } = await fetchJiraIssues(email, token); const data = processIssues(issues); data.jiraTotal = total; // total real en Jira (puede ser > 100) _cache = data; _cacheTime = Date.now(); return res.status(200).json({ ...data, fromCache: false }); } catch (err) { console.error('[dashboard/api/data]', err.message); return res.status(500).json({ error: err.message }); } };