first commit
This commit is contained in:
@@ -1 +1,3 @@
|
||||
.vercel
|
||||
.env
|
||||
ex.env
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* config.js — Configuración centralizada del Dashboard JIRA
|
||||
*
|
||||
* Carga y valida todas las variables de entorno.
|
||||
* Uso: import { config } from './config.js'
|
||||
*/
|
||||
|
||||
// Función helper para obtener variable o lanzar error
|
||||
function getEnv(key, defaultValue = null) {
|
||||
const value = process.env[key] || defaultValue;
|
||||
if (!value) {
|
||||
throw new Error(`❌ Variable de entorno requerida no configurada: ${key}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Función para convertir string a número
|
||||
function getEnvNumber(key, defaultValue) {
|
||||
const value = process.env[key] || defaultValue;
|
||||
const num = parseInt(value, 10);
|
||||
if (isNaN(num)) {
|
||||
throw new Error(`❌ ${key} debe ser un número válido, recibió: ${value}`);
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
// Validar que las credenciales estén presentes
|
||||
function validateCredentials() {
|
||||
const email = process.env.JIRA_EMAIL;
|
||||
const token = process.env.JIRA_TOKEN;
|
||||
|
||||
if (!email) {
|
||||
throw new Error('❌ Falta JIRA_EMAIL en variables de entorno');
|
||||
}
|
||||
if (!token) {
|
||||
throw new Error('❌ Falta JIRA_TOKEN en variables de entorno');
|
||||
}
|
||||
|
||||
// Validar formato de email
|
||||
if (!email.includes('@')) {
|
||||
throw new Error(`❌ JIRA_EMAIL parece inválido: ${email}`);
|
||||
}
|
||||
|
||||
// Validar que el token no esté vacío
|
||||
if (token.length < 10) {
|
||||
throw new Error('❌ JIRA_TOKEN parece muy corto, verifica que sea válido');
|
||||
}
|
||||
}
|
||||
|
||||
// Construir JQL dinámicamente reemplazando variables
|
||||
function buildJQL() {
|
||||
const baseJQL = getEnv('JIRA_JQL',
|
||||
'project = ${PROJECT} AND ${FIELD_SP_ESTIMATE} is not EMPTY ORDER BY updated DESC'
|
||||
);
|
||||
|
||||
return baseJQL
|
||||
.replace('${PROJECT}', getEnv('JIRA_PROJECT', 'LABS'))
|
||||
.replace('${FIELD_SP_ESTIMATE}', getEnv('JIRA_FIELD_SP_ESTIMATE', 'customfield_10016'));
|
||||
}
|
||||
|
||||
// Exportar configuración
|
||||
export const config = {
|
||||
// ── JIRA Host ──────────────────────────────────────────────────────
|
||||
jira: {
|
||||
host: getEnv('JIRA_HOST', 'invera.atlassian.net'),
|
||||
email: getEnv('JIRA_EMAIL'),
|
||||
token: getEnv('JIRA_TOKEN'),
|
||||
project: getEnv('JIRA_PROJECT', 'LABS'),
|
||||
},
|
||||
|
||||
// ── Custom Fields (IDs) ────────────────────────────────────────────
|
||||
fields: {
|
||||
spEstimate: getEnv('JIRA_FIELD_SP_ESTIMATE', 'customfield_10016'),
|
||||
spFinal: getEnv('JIRA_FIELD_SP_FINAL', 'customfield_11019'),
|
||||
developer: getEnv('JIRA_FIELD_DEVELOPER', 'customfield_10622'),
|
||||
},
|
||||
|
||||
// ── JQL Query ──────────────────────────────────────────────────────
|
||||
jql: buildJQL(),
|
||||
|
||||
// ── API Settings ───────────────────────────────────────────────────
|
||||
api: {
|
||||
maxResults: getEnvNumber('MAX_RESULTS', 500),
|
||||
cacheMsec: getEnvNumber('CACHE_MS', 86400000), // 24 horas por defecto
|
||||
},
|
||||
|
||||
// ── Environment ────────────────────────────────────────────────────
|
||||
env: {
|
||||
nodeEnv: getEnv('NODE_ENV', 'development'),
|
||||
isProduction: getEnv('NODE_ENV', 'development') === 'production',
|
||||
isDevelopment: getEnv('NODE_ENV', 'development') === 'development',
|
||||
},
|
||||
};
|
||||
|
||||
// ── Validación al cargar ───────────────────────────────────────────
|
||||
try {
|
||||
validateCredentials();
|
||||
console.log('✅ Configuración cargada correctamente');
|
||||
console.log(` Host JIRA: ${config.jira.host}`);
|
||||
console.log(` Proyecto: ${config.jira.project}`);
|
||||
console.log(` Max Results: ${config.api.maxResults}`);
|
||||
console.log(` Cache: ${config.api.cacheMsec / 1000 / 60}min`);
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export default config;
|
||||
+198
-71
@@ -1,70 +1,144 @@
|
||||
// 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)
|
||||
/**
|
||||
* 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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
// ─── 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://${JIRA_HOST}/rest/api/3/search/jql`
|
||||
+ `?jql=${encodeURIComponent(JQL)}`
|
||||
+ `&fields=${encodeURIComponent(FIELDS)}`
|
||||
+ `&maxResults=${MAX_RESULTS}`;
|
||||
|
||||
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();
|
||||
throw new Error(`Jira API ${res.status}: ${text.slice(0, 200)}`);
|
||||
const errorMsg = `JIRA API ${res.status}: ${text.slice(0, 200)}`;
|
||||
console.error(`❌ ${errorMsg}`);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
return res.json();
|
||||
|
||||
const data = await res.json();
|
||||
console.log(`✅ Se obtuvieron ${data.issues.length} issues (total en JIRA: ${data.total})`);
|
||||
|
||||
return data;
|
||||
}
|
||||
// ─── Data processing ─────────────────────────────────────────────────────────
|
||||
|
||||
// ─── 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;
|
||||
// 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;
|
||||
|
||||
// ── 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'];
|
||||
}
|
||||
// ── Acumular completados por mes ──
|
||||
if (catKey === 'done') {
|
||||
|
||||
// ── 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] = {};
|
||||
@@ -73,50 +147,103 @@ function processIssues(issues) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── 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;
|
||||
|
||||
// ── 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: issues.length,
|
||||
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)),
|
||||
.sort((a, b) =>
|
||||
(b.done + b.in_progress + b.todo) - (a.done + a.in_progress + a.todo)
|
||||
),
|
||||
};
|
||||
}
|
||||
// ─── Handler ─────────────────────────────────────────────────────────────────
|
||||
module.exports = async (req, res) => {
|
||||
|
||||
// ─── 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');
|
||||
// Force refresh si se pasa ?refresh=1
|
||||
|
||||
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) < CACHE_MS) {
|
||||
if (!forceRefresh && _cache && (Date.now() - _cacheTime) < config.api.cacheMsec) {
|
||||
console.log('📦 Sirviendo desde caché (en memoria)');
|
||||
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)
|
||||
|
||||
// ── 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]', err.message);
|
||||
return res.status(500).json({ error: err.message });
|
||||
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(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
// 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_11217', // Story Points Finales
|
||||
'customfield_10628', // Desarrollador (multi-user)
|
||||
'parent', 'resolutiondate', 'created',
|
||||
].join(',');
|
||||
const MAX_RESULTS = 500;
|
||||
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_10628 multi-user), fallback a assignee
|
||||
const devs = f.customfield_10628?.length
|
||||
? f.customfield_10628.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_11217 != null ? f.customfield_11217 : 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 });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user