Files
dashboard-infra/api/data.js
T
kvilches1410 a2d49b441e tercer commit
2026-04-01 11:34:43 -03:00

393 lines
15 KiB
JavaScript

/**
* 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 = {};
let _cacheTime = {};
export function clearCache() {
_cache = {};
_cacheTime = {};
console.log('🧹 Caché en memoria limpiada');
}
// ─── 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, period) {
const auth = Buffer.from(`${email}:${token}`).toString('base64');
const maxResultsWanted = 1500; // Límite seguro por usuario
let allIssues = [];
let totalInJira = 0;
let startAt = 0;
// Inyectar el filtro de fecha en el JQL base
let dynamicJql = config.jql;
if (period !== 'all') {
const timeFilter = period === '1d' ? 'startOfDay()' : `-${period}`;
// Intentamos insertarlo antes del ORDER BY
if (dynamicJql.includes(' ORDER BY ')) {
dynamicJql = dynamicJql.replace(' ORDER BY ', ` AND updated >= ${timeFilter} ORDER BY `);
} else {
dynamicJql += ` AND updated >= ${timeFilter}`;
}
}
console.log(`📡 Consultando JIRA: ${config.jira.project} (${maxResultsWanted} resultados max, periodo: ${period})`);
console.log(`📋 JQL: ${dynamicJql}`);
let nextPageToken = null;
const fieldsParam = encodeURIComponent(buildFieldsList());
const jqlParam = encodeURIComponent(dynamicJql);
while (allIssues.length < maxResultsWanted) {
let searchUrl = `https://${config.jira.host}/rest/api/3/search/jql?jql=${jqlParam}&fields=${fieldsParam}&maxResults=100`;
if (nextPageToken) {
searchUrl += `&nextPageToken=${encodeURIComponent(nextPageToken)}`;
}
const res = await fetch(searchUrl, {
method: 'GET',
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)}`);
}
const data = await res.json();
const items = data.issues || [];
allIssues = allIssues.concat(items);
nextPageToken = data.nextPageToken;
if (!nextPageToken || items.length === 0) {
break; // No more pages
}
}
// Si frenamos la paginación porque superamos el maxResultsWanted pero aún hay nextPageToken, está truncado
const isTruncated = !!nextPageToken;
console.log(`✅ Se obtuvieron ${allIssues.length} issues (Truncado: ${isTruncated})`);
return { issues: allIssues, isTruncated: isTruncated };
}
// ─── 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)
'resolutiondate',
'created',
'updated',
// ── QA Fields ──────────────────────────────────────
config.fields.qaAssignee, // QA asignado
config.fields.qaEntryRtqa, // Fecha entrada READY TO QA
config.fields.qaTotalTimeRtqa, // Tiempo total en READY TO QA (min)
config.fields.qaLastDurRtqa, // Última duración en READY TO QA (min)
config.fields.qaEntryQa, // Fecha entrada QA
config.fields.qaTotalTimeQa, // Tiempo total en QA (min)
config.fields.qaLastDurQa, // Última duración en QA (min)
config.fields.timesReturned, // Veces que volvió a En Curso
].join(',');
}
// ─── Process Issues ────────────────────────────────────────────────
/**
* Procesa issues de JIRA y agrupa los datos
* @param {Array} issues - Array de issues de JIRA
* @returns {Object} Datos procesados
*/
// Helper: convierte minutos a horas redondeadas a 1 decimal
function minsToHrs(mins) {
if (mins == null || mins === 0) return 0;
return Math.round((mins / 60) * 10) / 10;
}
function processIssues(issues) {
const assigneeData = {};
const monthlyDone = {};
const issuesList = [];
const warnings = [];
const now = new Date();
// ── QA Aggregation ──────────────────────────────────────────────────
const qaData = {
tickets: 0,
ticketsReturned: 0,
totalTimeQa: 0, // minutos
totalTimeRtqa: 0, // minutos
monthlyTotalQa: {}, // { 'YYYY-MM': totalMin }
monthlyCountQa: {}, // { 'YYYY-MM': count }
monthlyTotalRtqa: {},
monthlyCountRtqa: {},
issues: [],
};
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;
// ── 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;
}
}
}
// ── Generar issuesList para Filtros ───────────────────────
issuesList.push({
id: issue.id,
key: issue.key,
summary: f.summary,
status: statusKey,
sp: sp,
devs: devs,
creMonth
});
// ── Acumular datos de QA ────────────────────────────────────
const timeQaMin = f[config.fields.qaTotalTimeQa] ?? 0;
const timeRtqaMin = f[config.fields.qaTotalTimeRtqa] ?? 0;
const lastDurQa = f[config.fields.qaLastDurQa] ?? 0;
const timesRet = f[config.fields.timesReturned] ?? 0;
const qaMonth = resMonth || creMonth;
if (timeQaMin > 0) {
qaData.tickets++;
qaData.totalTimeQa += timeQaMin;
if (timesRet > 1) qaData.ticketsReturned++;
if (qaMonth) {
qaData.monthlyTotalQa[qaMonth] = (qaData.monthlyTotalQa[qaMonth] || 0) + timeQaMin;
qaData.monthlyCountQa[qaMonth] = (qaData.monthlyCountQa[qaMonth] || 0) + 1;
}
qaData.issues.push({
key: issue.key,
summary: f.summary,
timeQa: minsToHrs(timeQaMin),
timeRtqa: minsToHrs(timeRtqaMin),
lastDurQa: minsToHrs(lastDurQa),
timesReturned: timesRet,
status: statusKey,
resMonth: qaMonth,
});
}
if (timeRtqaMin > 0 && qaMonth) {
qaData.totalTimeRtqa += timeRtqaMin;
qaData.monthlyTotalRtqa[qaMonth] = (qaData.monthlyTotalRtqa[qaMonth] || 0) + timeRtqaMin;
qaData.monthlyCountRtqa[qaMonth] = (qaData.monthlyCountRtqa[qaMonth] || 0) + 1;
}
// ── Detectar Warnings ───────────────────────────────────────
// 1. Tickets > 14 días in-progress
if (isInProgress && f.updated) {
const days = (now - new Date(f.updated)) / (1000 * 60 * 60 * 24);
if (days > 14) {
warnings.push({ type: 'stalled', key: issue.key, summary: f.summary, devs, sp, days: Math.round(days) });
}
}
// 2. Done sin SP
if (isDone && sp === 0) {
warnings.push({ type: 'done_no_sp', key: issue.key, summary: f.summary, devs, sp: 0 });
}
// 3. SP sin asignar (todo/in-progress sin dev)
if (!isDone && devs.includes('Sin asignar') && sp > 0) {
warnings.push({ type: 'unassigned_sp', key: issue.key, summary: f.summary, devs, sp });
}
processedCount++;
} catch (error) {
console.warn(`⚠️ Error procesando issue ${issue.key}: ${error.message}`);
skippedCount++;
}
}
console.log(`📊 Procesados: ${processedCount} | Saltados: ${skippedCount} | Warnings: ${warnings.length}`);
// ── Calcular promedios mensuales QA ─────────────────────────────────
const monthlyAvgQa = {};
for (const m of Object.keys(qaData.monthlyTotalQa)) {
monthlyAvgQa[m] = minsToHrs(qaData.monthlyTotalQa[m] / qaData.monthlyCountQa[m]);
}
const monthlyAvgRtqa = {};
for (const m of Object.keys(qaData.monthlyTotalRtqa)) {
monthlyAvgRtqa[m] = minsToHrs(qaData.monthlyTotalRtqa[m] / qaData.monthlyCountRtqa[m]);
}
qaData.monthlyAvgQa = monthlyAvgQa;
qaData.monthlyAvgRtqa = monthlyAvgRtqa;
qaData.avgTimeQa = qaData.tickets > 0 ? minsToHrs(qaData.totalTimeQa / qaData.tickets) : 0;
qaData.avgTimeRtqa = qaData.tickets > 0 ? minsToHrs(qaData.totalTimeRtqa / qaData.tickets) : 0;
// Limpiar campos internos de acumulación
delete qaData.monthlyTotalQa;
delete qaData.monthlyCountQa;
delete qaData.monthlyTotalRtqa;
delete qaData.monthlyCountRtqa;
console.log(`🧪 QA: ${qaData.tickets} tickets | ${qaData.ticketsReturned} devueltos | Avg QA: ${qaData.avgTimeQa}hs`);
return {
lastUpdated: new Date().toISOString(),
totalIssues: processedCount,
skippedIssues: skippedCount,
assigneeData,
monthlyDone,
issuesList,
warnings,
qaData,
};
}
// ─── 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 y Periodo temporal
const forceRefresh = req.query?.refresh === '1';
const period = req.query?.period || '90d';
// Servir desde caché segmentado por período si está fresco
if (!forceRefresh && _cache[period] && (Date.now() - _cacheTime[period]) < config.api.cacheMsec) {
console.log(`📦 Sirviendo desde caché para periodo: ${period}`);
return res.status(200).json({ ..._cache[period], fromCache: true });
}
// ── Consultar JIRA ─────────────────────────────────────────
const jiraData = await fetchJiraIssues(config.jira.email, config.jira.token, period);
const data = processIssues(jiraData.issues);
// Agregar metadata
data.isTruncated = jiraData.isTruncated; // Si hay más paginación pendiente del límite
data.jiraTotal = jiraData.isTruncated ? "1500+" : jiraData.issues.length;
data.period = period;
data.config = {
project: config.jira.project,
host: config.jira.host,
maxResults: 1500,
cacheMinutes: Math.round(config.api.cacheMsec / 1000 / 60),
};
// Guardar en caché segmentado
_cache[period] = data;
_cacheTime[period] = Date.now();
console.log(`✅ Datos frescos almacenados en caché para periodo: ${period}`);
return res.status(200).json({ ...data, fromCache: false });
} catch (err) {
console.error('❌ [dashboard/api/data] Error:', err.message);
// Intentar servir desde caché global si hay error
const period = req.query?.period || '90d';
if (_cache[period]) {
console.log('⚠️ Sirviendo caché anterior debido a error');
return res.status(200).json({
..._cache[period],
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(),
});
}
};