/** * Service de cache pour les analytics Jira * Cache en mémoire avec invalidation manuelle et TTL */ import { JiraAnalytics } from '@/lib/types'; import { createHash } from 'crypto'; interface CacheEntry { data: JiraAnalytics; timestamp: number; projectKey: string; configHash: string; // Hash de la config Jira pour détecter les changements ttl: number; // Time To Live en millisecondes } class JiraAnalyticsCacheService { private cache = new Map(); private readonly CACHE_KEY_PREFIX = 'jira-analytics:'; private readonly DEFAULT_TTL = 30 * 60 * 1000; // 30 minutes par défaut private cleanupInterval: NodeJS.Timeout | null = null; /** * Génère une clé de cache basée sur la config Jira */ private getCacheKey(projectKey: string, configHash: string): string { return `${this.CACHE_KEY_PREFIX}${projectKey}:${configHash}`; } /** * Génère un hash sécurisé de la configuration Jira pour détecter les changements */ private generateConfigHash(config: { baseUrl: string; email: string; apiToken: string; projectKey: string }): string { const configString = `${config.baseUrl}|${config.email}|${config.apiToken}|${config.projectKey}`; return createHash('sha256').update(configString).digest('hex').substring(0, 16); } /** * Récupère les analytics depuis le cache si disponible et non expirées */ get(config: { baseUrl: string; email: string; apiToken: string; projectKey: string }): JiraAnalytics | null { const configHash = this.generateConfigHash(config); const cacheKey = this.getCacheKey(config.projectKey, configHash); const entry = this.cache.get(cacheKey); if (!entry) { console.log(`📋 Cache MISS pour projet ${config.projectKey}`); return null; } // Vérifier que la config n'a pas changé if (entry.configHash !== configHash) { console.log(`🔄 Config changée pour projet ${config.projectKey}, invalidation du cache`); this.cache.delete(cacheKey); return null; } // Vérifier si l'entrée a expiré const now = Date.now(); const isExpired = (now - entry.timestamp) > entry.ttl; if (isExpired) { console.log(`⏰ Cache EXPIRÉ pour projet ${config.projectKey} (${this.getAgeDescription(entry.timestamp)})`); this.cache.delete(cacheKey); return null; } console.log(`✅ Cache HIT pour projet ${config.projectKey} (${this.getAgeDescription(entry.timestamp)}, expire dans ${this.getTimeUntilExpiry(entry.timestamp, entry.ttl)})`); return entry.data; } /** * Stocke les analytics dans le cache avec TTL personnalisé */ set(config: { baseUrl: string; email: string; apiToken: string; projectKey: string }, data: JiraAnalytics, ttl?: number): void { const configHash = this.generateConfigHash(config); const cacheKey = this.getCacheKey(config.projectKey, configHash); const entry: CacheEntry = { data, timestamp: Date.now(), projectKey: config.projectKey, configHash, ttl: ttl || this.DEFAULT_TTL }; this.cache.set(cacheKey, entry); console.log(`💾 Analytics mises en cache pour projet ${config.projectKey} (TTL: ${this.formatTTL(entry.ttl)})`); // Démarrer le nettoyage automatique si pas déjà fait this.startCleanupInterval(); } /** * Invalide le cache pour un projet spécifique */ invalidate(config: { baseUrl: string; email: string; apiToken: string; projectKey: string }): void { const configHash = this.generateConfigHash(config); const cacheKey = this.getCacheKey(config.projectKey, configHash); const deleted = this.cache.delete(cacheKey); if (deleted) { console.log(`🗑️ Cache invalidé pour projet ${config.projectKey}`); } else { console.log(`ℹ️ Aucun cache à invalider pour projet ${config.projectKey}`); } } /** * Invalide tout le cache */ invalidateAll(): void { const size = this.cache.size; this.cache.clear(); console.log(`🗑️ Tout le cache analytics invalidé (${size} entrées supprimées)`); } /** * Retourne les statistiques du cache avec informations TTL */ getStats(): { totalEntries: number; projects: Array<{ projectKey: string; age: string; size: number; ttl: string; expiresIn: string; isExpired: boolean; }>; } { const now = Date.now(); const projects = Array.from(this.cache.entries()).map(([, entry]) => ({ projectKey: entry.projectKey, age: this.getAgeDescription(entry.timestamp), size: JSON.stringify(entry.data).length, ttl: this.formatTTL(entry.ttl), expiresIn: this.getTimeUntilExpiry(entry.timestamp, entry.ttl), isExpired: (now - entry.timestamp) > entry.ttl })); return { totalEntries: this.cache.size, projects }; } /** * Formate l'âge d'une entrée de cache */ private getAgeDescription(timestamp: number): string { const ageMs = Date.now() - timestamp; const ageMinutes = Math.floor(ageMs / (1000 * 60)); const ageHours = Math.floor(ageMinutes / 60); if (ageHours > 0) { return `il y a ${ageHours}h${ageMinutes % 60}m`; } else if (ageMinutes > 0) { return `il y a ${ageMinutes}m`; } else { return 'maintenant'; } } /** * Calcule le temps restant avant expiration */ private getTimeUntilExpiry(timestamp: number, ttl: number): string { const now = Date.now(); const remainingMs = ttl - (now - timestamp); if (remainingMs <= 0) return 'expiré'; const remainingMinutes = Math.floor(remainingMs / (1000 * 60)); const remainingHours = Math.floor(remainingMinutes / 60); if (remainingHours > 0) { return `${remainingHours}h${remainingMinutes % 60}m`; } else { return `${remainingMinutes}m`; } } /** * Formate un TTL en texte lisible */ private formatTTL(ttl: number): string { const minutes = Math.floor(ttl / (1000 * 60)); const hours = Math.floor(minutes / 60); if (hours > 0) { return `${hours}h${minutes % 60}m`; } else { return `${minutes}m`; } } /** * Démarre l'intervalle de nettoyage automatique */ private startCleanupInterval(): void { if (this.cleanupInterval) return; // Déjà démarré // Nettoyer toutes les 5 minutes this.cleanupInterval = setInterval(() => { this.cleanupExpiredEntries(); }, 5 * 60 * 1000); console.log('🧹 Nettoyage automatique du cache démarré (toutes les 5 minutes)'); } /** * Nettoie les entrées expirées du cache */ private cleanupExpiredEntries(): void { const now = Date.now(); let cleanedCount = 0; for (const [key, entry] of this.cache.entries()) { if ((now - entry.timestamp) > entry.ttl) { this.cache.delete(key); cleanedCount++; } } if (cleanedCount > 0) { console.log(`🧹 Nettoyage automatique: ${cleanedCount} entrées expirées supprimées`); } } /** * Vérifie si une entrée existe pour un projet */ has(config: { baseUrl: string; email: string; apiToken: string; projectKey: string }): boolean { const configHash = this.generateConfigHash(config); const cacheKey = this.getCacheKey(config.projectKey, configHash); return this.cache.has(cacheKey); } /** * Arrête le nettoyage automatique */ stopCleanupInterval(): void { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; console.log('🛑 Nettoyage automatique du cache arrêté'); } } /** * Force un nettoyage immédiat des entrées expirées */ forceCleanup(): number { const beforeCount = this.cache.size; this.cleanupExpiredEntries(); const afterCount = this.cache.size; const cleanedCount = beforeCount - afterCount; console.log(`🧹 Nettoyage forcé: ${cleanedCount} entrées supprimées`); return cleanedCount; } } // Instance singleton export const jiraAnalyticsCache = new JiraAnalyticsCacheService();