chore: refactor project structure and clean up unused components

- Updated `TODO.md` to reflect new testing tasks and final structure expectations.
- Simplified TypeScript path mappings in `tsconfig.json` for better clarity.
- Revised business logic separation rules in `.cursor/rules` to align with new directory structure.
- Deleted unused client components and services to streamline the codebase.
- Adjusted import paths in scripts to match the new structure.
This commit is contained in:
Julien Froidefond
2025-09-21 10:26:35 +02:00
parent 9dc1fafa76
commit 4152b0bdfc
130 changed files with 360 additions and 413 deletions

292
src/services/analytics.ts Normal file
View File

@@ -0,0 +1,292 @@
import { Task, TaskStatus, TaskPriority, TaskSource } from '@/lib/types';
import { prisma } from './database';
export interface ProductivityMetrics {
completionTrend: Array<{
date: string;
completed: number;
created: number;
total: number;
}>;
velocityData: Array<{
week: string;
completed: number;
average: number;
}>;
priorityDistribution: Array<{
priority: string;
count: number;
percentage: number;
}>;
statusFlow: Array<{
status: string;
count: number;
percentage: number;
}>;
weeklyStats: {
thisWeek: number;
lastWeek: number;
change: number;
changePercent: number;
};
}
export interface TimeRange {
start: Date;
end: Date;
}
export class AnalyticsService {
/**
* Calcule les métriques de productivité pour une période donnée
*/
static async getProductivityMetrics(timeRange?: TimeRange): Promise<ProductivityMetrics> {
try {
const now = new Date();
const defaultStart = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); // 30 jours
const start = timeRange?.start || defaultStart;
const end = timeRange?.end || now;
// Récupérer toutes les tâches depuis la base de données avec leurs tags
const dbTasks = await prisma.task.findMany({
include: {
taskTags: {
include: {
tag: true
}
}
}
});
// Convertir en format Task
const tasks: Task[] = dbTasks.map(task => ({
id: task.id,
title: task.title,
description: task.description || undefined,
status: task.status as TaskStatus,
priority: task.priority as TaskPriority,
source: task.source as TaskSource,
sourceId: task.sourceId || undefined,
tags: task.taskTags.map(taskTag => taskTag.tag.name),
dueDate: task.dueDate || undefined,
completedAt: task.completedAt || undefined,
createdAt: task.createdAt,
updatedAt: task.updatedAt,
jiraProject: task.jiraProject || undefined,
jiraKey: task.jiraKey || undefined,
jiraType: task.jiraType || undefined,
assignee: task.assignee || undefined
}));
return {
completionTrend: this.calculateCompletionTrend(tasks, start, end),
velocityData: this.calculateVelocity(tasks, start, end),
priorityDistribution: this.calculatePriorityDistribution(tasks),
statusFlow: this.calculateStatusFlow(tasks),
weeklyStats: this.calculateWeeklyStats(tasks)
};
} catch (error) {
console.error('Erreur lors du calcul des métriques:', error);
throw new Error('Impossible de calculer les métriques de productivité');
}
}
/**
* Calcule la tendance de completion des tâches par jour
*/
private static calculateCompletionTrend(tasks: Task[], start: Date, end: Date) {
const trend: Array<{ date: string; completed: number; created: number; total: number }> = [];
// Générer les dates pour la période
const currentDate = new Date(start);
while (currentDate <= end) {
const dateStr = currentDate.toISOString().split('T')[0];
// Compter les tâches terminées ce jour
const completedThisDay = tasks.filter(task =>
task.completedAt &&
task.completedAt.toISOString().split('T')[0] === dateStr
).length;
// Compter les tâches créées ce jour
const createdThisDay = tasks.filter(task =>
task.createdAt.toISOString().split('T')[0] === dateStr
).length;
// Total cumulé jusqu'à ce jour
const totalUntilThisDay = tasks.filter(task =>
new Date(task.createdAt) <= currentDate
).length;
trend.push({
date: dateStr,
completed: completedThisDay,
created: createdThisDay,
total: totalUntilThisDay
});
currentDate.setDate(currentDate.getDate() + 1);
}
return trend;
}
/**
* Calcule la vélocité (tâches terminées par semaine)
*/
private static calculateVelocity(tasks: Task[], start: Date, end: Date) {
const weeklyData: Array<{ week: string; completed: number; average: number }> = [];
const completedTasks = tasks.filter(task => task.completedAt);
// Grouper par semaine
const weekGroups = new Map<string, number>();
completedTasks.forEach(task => {
if (task.completedAt && task.completedAt >= start && task.completedAt <= end) {
const weekStart = this.getWeekStart(task.completedAt);
const weekKey = weekStart.toISOString().split('T')[0];
weekGroups.set(weekKey, (weekGroups.get(weekKey) || 0) + 1);
}
});
// Calculer la moyenne mobile
const values = Array.from(weekGroups.values());
const average = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
// Convertir en format pour le graphique
weekGroups.forEach((count, weekKey) => {
const weekDate = new Date(weekKey);
weeklyData.push({
week: `Sem. ${this.getWeekNumber(weekDate)}`,
completed: count,
average: Math.round(average * 10) / 10
});
});
return weeklyData.sort((a, b) => a.week.localeCompare(b.week));
}
/**
* Calcule la distribution des priorités
*/
private static calculatePriorityDistribution(tasks: Task[]) {
const priorityCounts = new Map<string, number>();
const total = tasks.length;
tasks.forEach(task => {
const priority = task.priority || 'non-définie';
priorityCounts.set(priority, (priorityCounts.get(priority) || 0) + 1);
});
return Array.from(priorityCounts.entries()).map(([priority, count]) => ({
priority: this.getPriorityLabel(priority),
count,
percentage: Math.round((count / total) * 100)
}));
}
/**
* Calcule la distribution des statuts
*/
private static calculateStatusFlow(tasks: Task[]) {
const statusCounts = new Map<string, number>();
const total = tasks.length;
tasks.forEach(task => {
const status = task.status;
statusCounts.set(status, (statusCounts.get(status) || 0) + 1);
});
return Array.from(statusCounts.entries()).map(([status, count]) => ({
status: this.getStatusLabel(status),
count,
percentage: Math.round((count / total) * 100)
}));
}
/**
* Calcule les statistiques hebdomadaires
*/
private static calculateWeeklyStats(tasks: Task[]) {
const now = new Date();
const thisWeekStart = this.getWeekStart(now);
const lastWeekStart = new Date(thisWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000);
const lastWeekEnd = new Date(thisWeekStart.getTime() - 1);
const thisWeekCompleted = tasks.filter(task =>
task.completedAt &&
task.completedAt >= thisWeekStart &&
task.completedAt <= now
).length;
const lastWeekCompleted = tasks.filter(task =>
task.completedAt &&
task.completedAt >= lastWeekStart &&
task.completedAt <= lastWeekEnd
).length;
const change = thisWeekCompleted - lastWeekCompleted;
const changePercent = lastWeekCompleted > 0
? Math.round((change / lastWeekCompleted) * 100)
: thisWeekCompleted > 0 ? 100 : 0;
return {
thisWeek: thisWeekCompleted,
lastWeek: lastWeekCompleted,
change,
changePercent
};
}
/**
* Obtient le début de la semaine pour une date
*/
private static getWeekStart(date: Date): Date {
const d = new Date(date);
const day = d.getDay();
const diff = d.getDate() - day + (day === 0 ? -6 : 1); // Lundi = début de semaine
return new Date(d.setDate(diff));
}
/**
* Obtient le numéro de la semaine
*/
private static getWeekNumber(date: Date): number {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
}
/**
* Convertit le code priorité en label français
*/
private static getPriorityLabel(priority: string): string {
const labels: Record<string, string> = {
'low': 'Faible',
'medium': 'Moyenne',
'high': 'Élevée',
'urgent': 'Urgente',
'non-définie': 'Non définie'
};
return labels[priority] || priority;
}
/**
* Convertit le code statut en label français
*/
private static getStatusLabel(status: string): string {
const labels: Record<string, string> = {
'backlog': 'Backlog',
'todo': 'À faire',
'in_progress': 'En cours',
'done': 'Terminé',
'cancelled': 'Annulé',
'freeze': 'Gelé',
'archived': 'Archivé'
};
return labels[status] || status;
}
}

View File

@@ -0,0 +1,138 @@
import { backupService, BackupConfig } from './backup';
export class BackupScheduler {
private timer: NodeJS.Timeout | null = null;
private isRunning = false;
/**
* Démarre le planificateur de sauvegarde automatique
*/
start(): void {
if (this.isRunning) {
console.log('⚠️ Backup scheduler is already running');
return;
}
const config = backupService.getConfig();
if (!config.enabled) {
console.log('📋 Automatic backups are disabled');
return;
}
const intervalMs = this.getIntervalMs(config.interval);
// Première sauvegarde immédiate (optionnelle)
// this.performScheduledBackup();
// Planifier les sauvegardes suivantes
this.timer = setInterval(() => {
this.performScheduledBackup();
}, intervalMs);
this.isRunning = true;
console.log(`✅ Backup scheduler started with ${config.interval} interval`);
}
/**
* Arrête le planificateur
*/
stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
this.isRunning = false;
console.log('🛑 Backup scheduler stopped');
}
/**
* Redémarre le planificateur (utile lors des changements de config)
*/
restart(): void {
this.stop();
this.start();
}
/**
* Vérifie si le planificateur fonctionne
*/
isActive(): boolean {
return this.isRunning && this.timer !== null;
}
/**
* Effectue une sauvegarde planifiée
*/
private async performScheduledBackup(): Promise<void> {
try {
console.log('🔄 Starting scheduled backup...');
const result = await backupService.createBackup('automatic');
if (result === null) {
console.log('⏭️ Scheduled backup skipped: no changes detected');
} else if (result.status === 'success') {
console.log(`✅ Scheduled backup completed: ${result.filename}`);
} else {
console.error(`❌ Scheduled backup failed: ${result.error}`);
}
} catch (error) {
console.error('❌ Scheduled backup error:', error);
}
}
/**
* Convertit l'intervalle en millisecondes
*/
private getIntervalMs(interval: BackupConfig['interval']): number {
const intervals = {
hourly: 60 * 60 * 1000, // 1 heure
daily: 24 * 60 * 60 * 1000, // 24 heures
weekly: 7 * 24 * 60 * 60 * 1000, // 7 jours
};
return intervals[interval];
}
/**
* Obtient le prochain moment de sauvegarde
*/
getNextBackupTime(): Date | null {
if (!this.isRunning || !this.timer) {
return null;
}
const config = backupService.getConfig();
const intervalMs = this.getIntervalMs(config.interval);
return new Date(Date.now() + intervalMs);
}
/**
* Obtient les stats du planificateur
*/
getStatus() {
const config = backupService.getConfig();
return {
isRunning: this.isRunning,
isEnabled: config.enabled,
interval: config.interval,
nextBackup: this.getNextBackupTime(),
maxBackups: config.maxBackups,
backupPath: config.backupPath,
};
}
}
// Instance singleton
export const backupScheduler = new BackupScheduler();
// Auto-start du scheduler
// Démarrer avec un délai pour laisser l'app s'initialiser
setTimeout(() => {
console.log('🚀 Auto-starting backup scheduler...');
backupScheduler.start();
}, 5000); // 5 secondes en dev, pour faciliter les tests

570
src/services/backup.ts Normal file
View File

@@ -0,0 +1,570 @@
import { promises as fs } from 'fs';
import path from 'path';
import { prisma } from './database';
import { userPreferencesService } from './user-preferences';
import { BackupUtils } from '../lib/backup-utils';
export interface BackupConfig {
enabled: boolean;
interval: 'hourly' | 'daily' | 'weekly';
maxBackups: number;
backupPath: string;
includeUploads?: boolean;
compression?: boolean;
}
export interface BackupInfo {
id: string;
filename: string;
size: number;
createdAt: Date;
type: 'manual' | 'automatic';
status: 'success' | 'failed' | 'in_progress';
error?: string;
databaseHash?: string;
}
export class BackupService {
private get defaultConfig(): BackupConfig {
return {
enabled: true,
interval: 'hourly',
maxBackups: 5,
backupPath: this.getDefaultBackupPath(),
includeUploads: true,
compression: true,
};
}
private getDefaultBackupPath(): string {
return BackupUtils.resolveBackupStoragePath();
}
private config: BackupConfig;
constructor(config?: Partial<BackupConfig>) {
this.config = { ...this.defaultConfig, ...config };
// Charger la config depuis la DB de manière asynchrone
this.loadConfigFromDB().catch(() => {
// Ignorer les erreurs de chargement initial
});
}
/**
* Charge la configuration depuis la base de données
*/
private async loadConfigFromDB(): Promise<void> {
try {
const preferences = await userPreferencesService.getAllPreferences();
if (preferences.viewPreferences && typeof preferences.viewPreferences === 'object') {
const backupConfig = (preferences.viewPreferences as Record<string, unknown>).backupConfig;
if (backupConfig) {
this.config = { ...this.defaultConfig, ...backupConfig };
}
}
} catch (error) {
console.warn('Could not load backup config from DB, using defaults:', error);
}
}
/**
* Sauvegarde la configuration dans la base de données
*/
private async saveConfigToDB(): Promise<void> {
try {
// Pour l'instant, on stocke la config backup en tant que JSON dans viewPreferences
// TODO: Ajouter un champ dédié dans le schéma pour la config backup
await prisma.userPreferences.upsert({
where: { id: 'default' },
update: {
viewPreferences: JSON.parse(JSON.stringify({
...(await userPreferencesService.getViewPreferences()),
backupConfig: this.config
}))
},
create: {
id: 'default',
kanbanFilters: {},
viewPreferences: JSON.parse(JSON.stringify({ backupConfig: this.config })),
columnVisibility: {},
jiraConfig: {}
}
});
} catch (error) {
console.error('Failed to save backup config to DB:', error);
}
}
/**
* Calcule un hash de la base de données pour détecter les changements
*/
private async calculateDatabaseHash(): Promise<string> {
try {
const dbPath = BackupUtils.resolveDatabasePath();
return await BackupUtils.calculateFileHash(dbPath);
} catch (error) {
console.error('Error calculating database hash:', error);
throw new Error(`Failed to calculate database hash: ${error}`);
}
}
/**
* Vérifie si la base de données a changé depuis le dernier backup
*/
async hasChangedSinceLastBackup(): Promise<boolean> {
try {
const currentHash = await this.calculateDatabaseHash();
const backups = await this.listBackups();
if (backups.length === 0) {
// Pas de backup précédent, donc il y a forcément des changements
return true;
}
// Récupérer le hash du dernier backup
const lastBackup = backups[0]; // Les backups sont triés par date décroissante
const lastBackupHash = await this.getBackupHash(lastBackup.filename);
if (!lastBackupHash) {
// Pas de hash disponible pour le dernier backup, considérer qu'il y a des changements
console.log('No hash available for last backup, assuming changes');
return true;
}
const hasChanged = currentHash !== lastBackupHash;
console.log(`Database hash comparison: current=${currentHash.substring(0, 8)}..., last=${lastBackupHash.substring(0, 8)}..., changed=${hasChanged}`);
return hasChanged;
} catch (error) {
console.error('Error checking database changes:', error);
// En cas d'erreur, on assume qu'il y a des changements pour être sûr
return true;
}
}
/**
* Récupère le hash d'un backup depuis ses métadonnées
*/
private async getBackupHash(filename: string): Promise<string | null> {
try {
const metadataPath = path.join(this.getCurrentBackupPath(), `${filename}.meta.json`);
try {
const metadataContent = await fs.readFile(metadataPath, 'utf-8');
const metadata = JSON.parse(metadataContent);
return metadata.databaseHash || null;
} catch {
// Fichier de métadonnées n'existe pas, essayer de calculer le hash du backup
return await this.calculateBackupFileHash(filename);
}
} catch (error) {
console.error(`Error getting backup hash for ${filename}:`, error);
return null;
}
}
/**
* Calcule le hash d'un fichier de backup existant
*/
private async calculateBackupFileHash(filename: string): Promise<string | null> {
try {
const backupPath = path.join(this.getCurrentBackupPath(), filename);
// Si le fichier est compressé, il faut le décompresser temporairement
if (filename.endsWith('.gz')) {
const tempFile = path.join(this.getCurrentBackupPath(), `temp_${Date.now()}.db`);
try {
await BackupUtils.decompressFileTemp(backupPath, tempFile);
const hash = await BackupUtils.calculateFileHash(tempFile);
// Nettoyer le fichier temporaire
await fs.unlink(tempFile);
return hash;
} catch (error) {
// Nettoyer le fichier temporaire en cas d'erreur
try {
await fs.unlink(tempFile);
} catch {}
throw error;
}
} else {
// Fichier non compressé
return await BackupUtils.calculateFileHash(backupPath);
}
} catch (error) {
console.error(`Error calculating hash for backup file ${filename}:`, error);
return null;
}
}
/**
* Sauvegarde les métadonnées d'un backup
*/
private async saveBackupMetadata(filename: string, metadata: { databaseHash: string; createdAt: Date; type: string }): Promise<void> {
try {
const metadataPath = path.join(this.getCurrentBackupPath(), `${filename}.meta.json`);
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2));
} catch (error) {
console.error(`Error saving backup metadata for ${filename}:`, error);
// Ne pas faire échouer le backup si on ne peut pas sauvegarder les métadonnées
}
}
/**
* Écrit une entrée dans le fichier de log des backups
*/
private async logBackupAction(type: 'manual' | 'automatic', action: 'created' | 'skipped' | 'failed', details: string, extra?: { hash?: string; size?: number; previousHash?: string }): Promise<void> {
const logPath = path.join(this.getCurrentBackupPath(), 'backup.log');
await BackupUtils.writeLogEntry(logPath, type, action, details, extra);
}
/**
* Crée une sauvegarde complète de la base de données
* Vérifie d'abord s'il y a eu des changements (sauf si forcé)
*/
async createBackup(type: 'manual' | 'automatic' = 'manual', forceCreate: boolean = false): Promise<BackupInfo | null> {
const backupId = `backup_${Date.now()}`;
const filename = BackupUtils.generateBackupFilename(type);
const backupPath = path.join(this.getCurrentBackupPath(), filename);
console.log(`🔄 Starting ${type} backup: ${filename}`);
try {
// Vérifier les changements (sauf si forcé)
if (!forceCreate) {
const hasChanged = await this.hasChangedSinceLastBackup();
if (!hasChanged) {
const currentHash = await this.calculateDatabaseHash();
const backups = await this.listBackups();
const lastBackupHash = backups.length > 0 ? await this.getBackupHash(backups[0].filename) : null;
const message = `No changes detected since last backup`;
console.log(`⏭️ Skipping ${type} backup: ${message}`);
await this.logBackupAction(type, 'skipped', message, {
hash: currentHash,
previousHash: lastBackupHash || undefined
});
return null;
}
console.log(`📝 Changes detected, proceeding with ${type} backup`);
} else {
console.log(`🔧 Forced ${type} backup, skipping change detection`);
}
// Calculer le hash de la base de données avant le backup
const databaseHash = await this.calculateDatabaseHash();
// Créer le dossier de backup si nécessaire
await BackupUtils.ensureDirectory(this.getCurrentBackupPath());
// Créer la sauvegarde SQLite
const dbPath = BackupUtils.resolveDatabasePath();
await BackupUtils.createSQLiteBackup(dbPath, backupPath);
// Compresser si activé
let finalPath = backupPath;
if (this.config.compression) {
finalPath = await BackupUtils.compressFile(backupPath);
await fs.unlink(backupPath); // Supprimer le fichier non compressé
}
// Obtenir les stats du fichier
const stats = await fs.stat(finalPath);
const backupInfo: BackupInfo = {
id: backupId,
filename: path.basename(finalPath),
size: stats.size,
createdAt: new Date(),
type,
status: 'success',
databaseHash,
};
// Sauvegarder les métadonnées du backup
await this.saveBackupMetadata(path.basename(finalPath), {
databaseHash,
createdAt: new Date(),
type,
});
// Nettoyer les anciennes sauvegardes
await this.cleanOldBackups();
const successMessage = `${backupInfo.filename} created successfully`;
console.log(`✅ Backup completed: ${successMessage}`);
await this.logBackupAction(type, 'created', successMessage, {
hash: databaseHash,
size: backupInfo.size
});
return backupInfo;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error(`❌ Backup failed:`, error);
await this.logBackupAction(type, 'failed', `${filename} failed: ${errorMessage}`);
return {
id: backupId,
filename,
size: 0,
createdAt: new Date(),
type,
status: 'failed',
error: errorMessage,
};
}
}
/**
* Restaure une sauvegarde
*/
async restoreBackup(filename: string): Promise<void> {
const backupPath = path.join(this.getCurrentBackupPath(), filename);
// Résoudre le chemin de la base de données
let dbPath: string;
if (process.env.BACKUP_DATABASE_PATH) {
// Utiliser la variable spécifique aux backups
dbPath = path.resolve(process.cwd(), process.env.BACKUP_DATABASE_PATH);
} else if (process.env.DATABASE_URL) {
// Fallback sur DATABASE_URL si BACKUP_DATABASE_PATH n'est pas défini
dbPath = path.resolve(process.env.DATABASE_URL.replace('file:', ''));
} else {
// Chemin par défaut vers prisma/dev.db
dbPath = path.resolve(process.cwd(), 'prisma', 'dev.db');
}
console.log(`🔄 Restore paths - backup: ${backupPath}, target: ${dbPath}`);
console.log(`🔄 Starting restore from: ${filename}`);
try {
// Vérifier que le fichier de sauvegarde existe
await fs.access(backupPath);
// Décompresser si nécessaire
let sourceFile = backupPath;
if (filename.endsWith('.gz')) {
const tempFile = backupPath.replace('.gz', '');
console.log(`🔄 Decompressing ${backupPath} to ${tempFile}`);
try {
await BackupUtils.decompressFileTemp(backupPath, tempFile);
console.log(`✅ Decompression successful`);
// Vérifier que le fichier décompressé existe
await fs.access(tempFile);
console.log(`✅ Decompressed file exists: ${tempFile}`);
sourceFile = tempFile;
} catch (decompError) {
console.error(`❌ Decompression failed:`, decompError);
throw decompError;
}
}
// Créer une sauvegarde de la base actuelle avant restauration
const currentBackup = await this.createBackup('manual', true); // Forcer la création
if (currentBackup) {
console.log(`✅ Current database backed up as: ${currentBackup.filename}`);
}
// Fermer toutes les connexions
await prisma.$disconnect();
// Vérifier que le fichier source existe
await fs.access(sourceFile);
console.log(`✅ Source file verified: ${sourceFile}`);
// Remplacer la base de données
console.log(`🔄 Copying ${sourceFile} to ${dbPath}`);
await fs.copyFile(sourceFile, dbPath);
console.log(`✅ Database file copied successfully`);
// Nettoyer le fichier temporaire si décompressé
if (sourceFile !== backupPath) {
await fs.unlink(sourceFile);
}
// Reconnecter à la base
await prisma.$connect();
// Vérifier l'intégrité après restauration
await this.verifyDatabaseHealth();
console.log(`✅ Database restored from: ${filename}`);
} catch (error) {
console.error(`❌ Restore failed:`, error);
throw new Error(`Failed to restore backup: ${error}`);
}
}
/**
* Obtient le chemin de sauvegarde actuel (toujours à jour)
* Force la relecture des variables d'environnement à chaque appel
*/
private getCurrentBackupPath(): string {
// Toujours recalculer depuis les variables d'environnement
// pour éviter les problèmes de cache lors des refresh
return this.getDefaultBackupPath();
}
/**
* Liste toutes les sauvegardes disponibles
*/
async listBackups(): Promise<BackupInfo[]> {
try {
const currentBackupPath = this.getCurrentBackupPath();
await BackupUtils.ensureDirectory(currentBackupPath);
const files = await fs.readdir(currentBackupPath);
const backups: BackupInfo[] = [];
for (const file of files) {
if (file.startsWith('towercontrol_') && (file.endsWith('.db') || file.endsWith('.db.gz'))) {
const filePath = path.join(currentBackupPath, file);
const stats = await fs.stat(filePath);
// Utiliser l'utilitaire pour parser le nom de fichier
const { type, date } = BackupUtils.parseBackupFilename(file);
const createdAt = date || stats.birthtime;
backups.push({
id: file,
filename: file,
size: stats.size,
createdAt,
type,
status: 'success',
});
}
}
return backups.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
} catch (error) {
console.error('Error listing backups:', error);
return [];
}
}
/**
* Supprime une sauvegarde
*/
async deleteBackup(filename: string): Promise<void> {
const backupPath = path.join(this.getCurrentBackupPath(), filename);
const metadataPath = path.join(this.getCurrentBackupPath(), `${filename}.meta.json`);
try {
// Supprimer le fichier de backup
await fs.unlink(backupPath);
// Supprimer le fichier de métadonnées s'il existe
try {
await fs.unlink(metadataPath);
} catch {
// Ignorer si le fichier de métadonnées n'existe pas
}
console.log(`✅ Backup deleted: ${filename}`);
} catch (error) {
console.error(`❌ Failed to delete backup ${filename}:`, error);
throw error;
}
}
/**
* Vérifie l'intégrité de la base de données
*/
async verifyDatabaseHealth(): Promise<void> {
try {
// Test de connexion simple
await prisma.$queryRaw`SELECT 1`;
// Vérification de l'intégrité SQLite
const result = await prisma.$queryRaw<{integrity_check: string}[]>`PRAGMA integrity_check`;
if (result.length > 0 && result[0].integrity_check !== 'ok') {
throw new Error(`Database integrity check failed: ${result[0].integrity_check}`);
}
console.log('✅ Database health check passed');
} catch (error) {
console.error('❌ Database health check failed:', error);
throw error;
}
}
/**
* Nettoie les anciennes sauvegardes selon la configuration
*/
private async cleanOldBackups(): Promise<void> {
try {
const backups = await this.listBackups();
if (backups.length > this.config.maxBackups) {
const toDelete = backups.slice(this.config.maxBackups);
for (const backup of toDelete) {
await this.deleteBackup(backup.filename);
}
console.log(`🧹 Cleaned ${toDelete.length} old backups`);
}
} catch (error) {
console.error('Error cleaning old backups:', error);
}
}
/**
* Met à jour la configuration
*/
async updateConfig(newConfig: Partial<BackupConfig>): Promise<void> {
this.config = { ...this.config, ...newConfig };
await this.saveConfigToDB();
}
/**
* Obtient la configuration actuelle
*/
getConfig(): BackupConfig {
// Retourner une config avec le chemin à jour
return {
...this.config,
backupPath: this.getCurrentBackupPath()
};
}
/**
* Lit le fichier de log des backups
*/
async getBackupLogs(maxLines: number = 100): Promise<string[]> {
try {
const logPath = path.join(this.getCurrentBackupPath(), 'backup.log');
try {
const logContent = await fs.readFile(logPath, 'utf-8');
const lines = logContent.trim().split('\n').filter(line => line.length > 0);
// Retourner les dernières lignes (les plus récentes)
return lines.slice(-maxLines).reverse();
} catch {
// Fichier de log n'existe pas encore
return [];
}
} catch (error) {
console.error('Error reading backup logs:', error);
return [];
}
}
}
// Instance singleton
export const backupService = new BackupService();

277
src/services/daily.ts Normal file
View File

@@ -0,0 +1,277 @@
import { prisma } from './database';
import { Prisma } from '@prisma/client';
import { DailyCheckbox, DailyView, CreateDailyCheckboxData, UpdateDailyCheckboxData, BusinessError, DailyCheckboxType, TaskStatus, TaskPriority, TaskSource } from '@/lib/types';
import { getPreviousWorkday } from '@/lib/workday-utils';
/**
* Service pour la gestion des checkboxes daily
*/
export class DailyService {
/**
* Récupère la vue daily pour une date donnée (checkboxes d'hier et d'aujourd'hui)
*/
async getDailyView(date: Date): Promise<DailyView> {
// Normaliser la date (début de journée)
const today = new Date(date);
today.setHours(0, 0, 0, 0);
// Utiliser la logique de jour de travail précédent au lieu de jour-1
const yesterday = getPreviousWorkday(today);
// Récupérer les checkboxes des deux jours
const [yesterdayCheckboxes, todayCheckboxes] = await Promise.all([
this.getCheckboxesByDate(yesterday),
this.getCheckboxesByDate(today)
]);
return {
date: today,
yesterday: yesterdayCheckboxes,
today: todayCheckboxes
};
}
/**
* Récupère toutes les checkboxes d'une date donnée
*/
async getCheckboxesByDate(date: Date): Promise<DailyCheckbox[]> {
// Normaliser la date (début de journée)
const normalizedDate = new Date(date);
normalizedDate.setHours(0, 0, 0, 0);
const checkboxes = await prisma.dailyCheckbox.findMany({
where: { date: normalizedDate },
include: { task: true },
orderBy: { order: 'asc' }
});
return checkboxes.map(this.mapPrismaCheckbox);
}
/**
* Ajoute une checkbox à une date donnée
*/
async addCheckbox(data: CreateDailyCheckboxData): Promise<DailyCheckbox> {
// Normaliser la date
const normalizedDate = new Date(data.date);
normalizedDate.setHours(0, 0, 0, 0);
// Calculer l'ordre suivant pour cette date
const maxOrder = await prisma.dailyCheckbox.aggregate({
where: { date: normalizedDate },
_max: { order: true }
});
const order = data.order ?? ((maxOrder._max.order ?? -1) + 1);
const checkbox = await prisma.dailyCheckbox.create({
data: {
date: normalizedDate,
text: data.text.trim(),
type: data.type ?? 'task',
taskId: data.taskId,
order,
isChecked: data.isChecked ?? false
},
include: { task: true }
});
return this.mapPrismaCheckbox(checkbox);
}
/**
* Met à jour une checkbox
*/
async updateCheckbox(checkboxId: string, data: UpdateDailyCheckboxData): Promise<DailyCheckbox> {
const updateData: Prisma.DailyCheckboxUpdateInput = {};
if (data.text !== undefined) updateData.text = data.text.trim();
if (data.isChecked !== undefined) updateData.isChecked = data.isChecked;
if (data.type !== undefined) updateData.type = data.type;
if (data.taskId !== undefined) {
if (data.taskId === null) {
updateData.task = { disconnect: true };
} else {
updateData.task = { connect: { id: data.taskId } };
}
}
if (data.order !== undefined) updateData.order = data.order;
const checkbox = await prisma.dailyCheckbox.update({
where: { id: checkboxId },
data: updateData,
include: { task: true }
});
return this.mapPrismaCheckbox(checkbox);
}
/**
* Supprime une checkbox
*/
async deleteCheckbox(checkboxId: string): Promise<void> {
const checkbox = await prisma.dailyCheckbox.findUnique({
where: { id: checkboxId }
});
if (!checkbox) {
throw new BusinessError('Checkbox non trouvée');
}
await prisma.dailyCheckbox.delete({
where: { id: checkboxId }
});
}
/**
* Réordonne les checkboxes d'une date donnée
*/
async reorderCheckboxes(date: Date, checkboxIds: string[]): Promise<void> {
// Normaliser la date
const normalizedDate = new Date(date);
normalizedDate.setHours(0, 0, 0, 0);
await prisma.$transaction(async (prisma) => {
for (let i = 0; i < checkboxIds.length; i++) {
await prisma.dailyCheckbox.update({
where: { id: checkboxIds[i] },
data: { order: i }
});
}
});
}
/**
* Recherche dans les checkboxes
*/
async searchCheckboxes(query: string, limit: number = 20): Promise<DailyCheckbox[]> {
const checkboxes = await prisma.dailyCheckbox.findMany({
where: {
text: {
contains: query
}
},
include: { task: true },
orderBy: { date: 'desc' },
take: limit
});
return checkboxes.map(this.mapPrismaCheckbox);
}
/**
* Récupère l'historique des checkboxes (groupées par date)
*/
async getCheckboxHistory(limit: number = 30): Promise<{ date: Date; checkboxes: DailyCheckbox[] }[]> {
// Récupérer les dates distinctes des dernières checkboxes
const distinctDates = await prisma.dailyCheckbox.findMany({
select: { date: true },
distinct: ['date'],
orderBy: { date: 'desc' },
take: limit
});
const history = [];
for (const { date } of distinctDates) {
const checkboxes = await this.getCheckboxesByDate(date);
if (checkboxes.length > 0) {
history.push({ date, checkboxes });
}
}
return history;
}
/**
* Récupère la vue daily d'aujourd'hui
*/
async getTodaysDailyView(): Promise<DailyView> {
return this.getDailyView(new Date());
}
/**
* Ajoute une checkbox pour aujourd'hui
*/
async addTodayCheckbox(text: string, taskId?: string): Promise<DailyCheckbox> {
return this.addCheckbox({
date: new Date(),
text,
taskId
});
}
/**
* Ajoute une checkbox pour hier
*/
async addYesterdayCheckbox(text: string, taskId?: string): Promise<DailyCheckbox> {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return this.addCheckbox({
date: yesterday,
text,
taskId
});
}
/**
* Mappe une checkbox Prisma vers notre interface
*/
private mapPrismaCheckbox(checkbox: Prisma.DailyCheckboxGetPayload<{ include: { task: true } }>): DailyCheckbox {
return {
id: checkbox.id,
date: checkbox.date,
text: checkbox.text,
isChecked: checkbox.isChecked,
type: checkbox.type as DailyCheckboxType,
order: checkbox.order,
taskId: checkbox.taskId || undefined,
task: checkbox.task ? {
id: checkbox.task.id,
title: checkbox.task.title,
description: checkbox.task.description || undefined,
status: checkbox.task.status as TaskStatus,
priority: checkbox.task.priority as TaskPriority,
source: checkbox.task.source as TaskSource,
sourceId: checkbox.task.sourceId || undefined,
tags: [], // Les tags seront chargés séparément si nécessaire
dueDate: checkbox.task.dueDate || undefined,
completedAt: checkbox.task.completedAt || undefined,
createdAt: checkbox.task.createdAt,
updatedAt: checkbox.task.updatedAt,
jiraProject: checkbox.task.jiraProject || undefined,
jiraKey: checkbox.task.jiraKey || undefined,
assignee: checkbox.task.assignee || undefined
} : undefined,
createdAt: checkbox.createdAt,
updatedAt: checkbox.updatedAt
};
}
/**
* Récupère toutes les dates qui ont des checkboxes (pour le calendrier)
*/
async getDailyDates(): Promise<string[]> {
const checkboxes = await prisma.dailyCheckbox.findMany({
select: {
date: true
},
distinct: ['date'],
orderBy: {
date: 'desc'
}
});
return checkboxes.map(checkbox => {
const date = checkbox.date;
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
});
}
}
// Instance singleton du service
export const dailyService = new DailyService();

64
src/services/database.ts Normal file
View File

@@ -0,0 +1,64 @@
import { PrismaClient } from '@prisma/client';
// Singleton pattern pour Prisma Client
declare global {
var __prisma: PrismaClient | undefined;
}
// Créer une instance unique de Prisma Client
export const prisma = globalThis.__prisma || new PrismaClient({
log: ['error'], // Désactiver les logs query/warn pour éviter le bruit
});
// En développement, stocker l'instance globalement pour éviter les reconnexions
if (process.env.NODE_ENV !== 'production') {
globalThis.__prisma = prisma;
}
// Fonction pour tester la connexion
export async function testDatabaseConnection(): Promise<boolean> {
try {
await prisma.$connect();
console.log('✅ Database connection successful');
return true;
} catch (error) {
console.error('❌ Database connection failed:', error);
return false;
}
}
// Fonction pour fermer la connexion proprement
export async function closeDatabaseConnection(): Promise<void> {
try {
await prisma.$disconnect();
console.log('✅ Database connection closed');
} catch (error) {
console.error('❌ Error closing database connection:', error);
}
}
// Fonction utilitaire pour les transactions
export async function withTransaction<T>(
callback: (tx: Omit<PrismaClient, '$connect' | '$disconnect' | '$on' | '$transaction' | '$extends'>) => Promise<T>
): Promise<T> {
return await prisma.$transaction(async (tx) => {
return await callback(tx);
});
}
// Fonction pour nettoyer la base (utile pour les tests)
export async function clearDatabase(): Promise<void> {
if (process.env.NODE_ENV === 'production') {
throw new Error('Cannot clear database in production');
}
await prisma.taskTag.deleteMany();
await prisma.task.deleteMany();
await prisma.tag.deleteMany();
await prisma.syncLog.deleteMany();
console.log('🧹 Database cleared');
}
// Export par défaut
export default prisma;

View File

@@ -0,0 +1,320 @@
/**
* Service pour les filtres avancés Jira
* Gère le filtrage par composant, version, type de ticket, etc.
*/
import { JiraTask, JiraAnalytics, JiraAnalyticsFilters, AvailableFilters, FilterOption } from '@/lib/types';
export class JiraAdvancedFiltersService {
/**
* Extrait toutes les options de filtrage disponibles depuis les données
*/
static extractAvailableFilters(issues: JiraTask[]): AvailableFilters {
const componentCounts = new Map<string, number>();
const fixVersionCounts = new Map<string, number>();
const issueTypeCounts = new Map<string, number>();
const statusCounts = new Map<string, number>();
const assigneeCounts = new Map<string, number>();
const labelCounts = new Map<string, number>();
const priorityCounts = new Map<string, number>();
issues.forEach(issue => {
// Components
if (issue.components) {
issue.components.forEach(component => {
componentCounts.set(component.name, (componentCounts.get(component.name) || 0) + 1);
});
}
// Fix Versions
if (issue.fixVersions) {
issue.fixVersions.forEach(version => {
fixVersionCounts.set(version.name, (fixVersionCounts.get(version.name) || 0) + 1);
});
}
// Issue Types
issueTypeCounts.set(issue.issuetype.name, (issueTypeCounts.get(issue.issuetype.name) || 0) + 1);
// Statuses
statusCounts.set(issue.status.name, (statusCounts.get(issue.status.name) || 0) + 1);
// Assignees
const assigneeName = issue.assignee?.displayName || 'Non assigné';
assigneeCounts.set(assigneeName, (assigneeCounts.get(assigneeName) || 0) + 1);
// Labels
issue.labels.forEach(label => {
labelCounts.set(label, (labelCounts.get(label) || 0) + 1);
});
// Priorities
if (issue.priority) {
priorityCounts.set(issue.priority.name, (priorityCounts.get(issue.priority.name) || 0) + 1);
}
});
return {
components: this.mapToFilterOptions(componentCounts),
fixVersions: this.mapToFilterOptions(fixVersionCounts),
issueTypes: this.mapToFilterOptions(issueTypeCounts),
statuses: this.mapToFilterOptions(statusCounts),
assignees: this.mapToFilterOptions(assigneeCounts),
labels: this.mapToFilterOptions(labelCounts),
priorities: this.mapToFilterOptions(priorityCounts)
};
}
/**
* Applique les filtres aux données analytics
*/
static applyFiltersToAnalytics(analytics: JiraAnalytics, filters: Partial<JiraAnalyticsFilters>, allIssues: JiraTask[]): JiraAnalytics {
// Filtrer les issues d'abord
const filteredIssues = this.filterIssues(allIssues, filters);
// Recalculer les métriques avec les issues filtrées
return this.recalculateAnalytics(analytics, filteredIssues);
}
/**
* Filtre la liste des issues selon les critères
*/
static filterIssues(issues: JiraTask[], filters: Partial<JiraAnalyticsFilters>): JiraTask[] {
return issues.filter(issue => {
// Filtrage par composants
if (filters.components && filters.components.length > 0) {
const issueComponents = issue.components?.map(c => c.name) || [];
if (!filters.components.some(comp => issueComponents.includes(comp))) {
return false;
}
}
// Filtrage par versions
if (filters.fixVersions && filters.fixVersions.length > 0) {
const issueVersions = issue.fixVersions?.map(v => v.name) || [];
if (!filters.fixVersions.some(version => issueVersions.includes(version))) {
return false;
}
}
// Filtrage par types
if (filters.issueTypes && filters.issueTypes.length > 0) {
if (!filters.issueTypes.includes(issue.issuetype.name)) {
return false;
}
}
// Filtrage par statuts
if (filters.statuses && filters.statuses.length > 0) {
if (!filters.statuses.includes(issue.status.name)) {
return false;
}
}
// Filtrage par assignees
if (filters.assignees && filters.assignees.length > 0) {
const assigneeName = issue.assignee?.displayName || 'Non assigné';
if (!filters.assignees.includes(assigneeName)) {
return false;
}
}
// Filtrage par labels
if (filters.labels && filters.labels.length > 0) {
if (!filters.labels.some(label => issue.labels.includes(label))) {
return false;
}
}
// Filtrage par priorités
if (filters.priorities && filters.priorities.length > 0) {
const priorityName = issue.priority?.name;
if (!priorityName || !filters.priorities.includes(priorityName)) {
return false;
}
}
// Filtrage par date
if (filters.dateRange) {
const issueDate = new Date(issue.created);
if (issueDate < filters.dateRange.from || issueDate > filters.dateRange.to) {
return false;
}
}
return true;
});
}
/**
* Recalcule les analytics avec un subset d'issues filtrées
*/
private static recalculateAnalytics(originalAnalytics: JiraAnalytics, filteredIssues: JiraTask[]): JiraAnalytics {
// Pour une implémentation complète, il faudrait recalculer toutes les métriques
// Ici on fait une version simplifiée qui garde la structure mais met à jour les counts
const totalFilteredIssues = filteredIssues.length;
// Calculer la nouvelle distribution par assignee
const assigneeMap = new Map<string, { completed: number; inProgress: number; total: number }>();
filteredIssues.forEach(issue => {
const assigneeName = issue.assignee?.displayName || 'Non assigné';
const current = assigneeMap.get(assigneeName) || { completed: 0, inProgress: 0, total: 0 };
current.total++;
if (issue.status.category === 'Done') {
current.completed++;
} else if (issue.status.category === 'In Progress') {
current.inProgress++;
}
assigneeMap.set(assigneeName, current);
});
const newIssuesDistribution = Array.from(assigneeMap.entries()).map(([assignee, stats]) => ({
assignee: assignee === 'Non assigné' ? '' : assignee,
displayName: assignee,
totalIssues: stats.total,
completedIssues: stats.completed,
inProgressIssues: stats.inProgress,
percentage: totalFilteredIssues > 0 ? (stats.total / totalFilteredIssues) * 100 : 0
}));
// Calculer la nouvelle distribution par statut
const statusMap = new Map<string, number>();
filteredIssues.forEach(issue => {
statusMap.set(issue.status.name, (statusMap.get(issue.status.name) || 0) + 1);
});
const newStatusDistribution = Array.from(statusMap.entries()).map(([status, count]) => ({
status,
count,
percentage: totalFilteredIssues > 0 ? (count / totalFilteredIssues) * 100 : 0
}));
// Calculer la nouvelle charge par assignee
const newAssigneeWorkload = Array.from(assigneeMap.entries()).map(([assignee, stats]) => ({
assignee: assignee === 'Non assigné' ? '' : assignee,
displayName: assignee,
todoCount: stats.total - stats.completed - stats.inProgress,
inProgressCount: stats.inProgress,
reviewCount: 0, // Simplified
totalActive: stats.total - stats.completed
}));
return {
...originalAnalytics,
project: {
...originalAnalytics.project,
totalIssues: totalFilteredIssues
},
teamMetrics: {
...originalAnalytics.teamMetrics,
issuesDistribution: newIssuesDistribution
},
workInProgress: {
byStatus: newStatusDistribution,
byAssignee: newAssigneeWorkload
}
};
}
/**
* Convertit une Map de counts en options de filtre triées
*/
private static mapToFilterOptions(countMap: Map<string, number>): FilterOption[] {
return Array.from(countMap.entries())
.map(([value, count]) => ({
value,
label: value,
count
}))
.sort((a, b) => b.count - a.count); // Trier par count décroissant
}
/**
* Crée un filtre vide
*/
static createEmptyFilters(): JiraAnalyticsFilters {
return {
components: [],
fixVersions: [],
issueTypes: [],
statuses: [],
assignees: [],
labels: [],
priorities: []
};
}
/**
* Vérifie si des filtres sont actifs
*/
static hasActiveFilters(filters: Partial<JiraAnalyticsFilters>): boolean {
return !!(
filters.components?.length ||
filters.fixVersions?.length ||
filters.issueTypes?.length ||
filters.statuses?.length ||
filters.assignees?.length ||
filters.labels?.length ||
filters.priorities?.length ||
filters.dateRange
);
}
/**
* Compte le nombre total de filtres actifs
*/
static countActiveFilters(filters: Partial<JiraAnalyticsFilters>): number {
let count = 0;
if (filters.components?.length) count += filters.components.length;
if (filters.fixVersions?.length) count += filters.fixVersions.length;
if (filters.issueTypes?.length) count += filters.issueTypes.length;
if (filters.statuses?.length) count += filters.statuses.length;
if (filters.assignees?.length) count += filters.assignees.length;
if (filters.labels?.length) count += filters.labels.length;
if (filters.priorities?.length) count += filters.priorities.length;
if (filters.dateRange) count += 1;
return count;
}
/**
* Génère un résumé textuel des filtres actifs
*/
static getFiltersSummary(filters: Partial<JiraAnalyticsFilters>): string {
const parts: string[] = [];
if (filters.components?.length) {
parts.push(`${filters.components.length} composant${filters.components.length > 1 ? 's' : ''}`);
}
if (filters.fixVersions?.length) {
parts.push(`${filters.fixVersions.length} version${filters.fixVersions.length > 1 ? 's' : ''}`);
}
if (filters.issueTypes?.length) {
parts.push(`${filters.issueTypes.length} type${filters.issueTypes.length > 1 ? 's' : ''}`);
}
if (filters.statuses?.length) {
parts.push(`${filters.statuses.length} statut${filters.statuses.length > 1 ? 's' : ''}`);
}
if (filters.assignees?.length) {
parts.push(`${filters.assignees.length} assigné${filters.assignees.length > 1 ? 's' : ''}`);
}
if (filters.labels?.length) {
parts.push(`${filters.labels.length} label${filters.labels.length > 1 ? 's' : ''}`);
}
if (filters.priorities?.length) {
parts.push(`${filters.priorities.length} priorité${filters.priorities.length > 1 ? 's' : ''}`);
}
if (filters.dateRange) {
parts.push('période personnalisée');
}
if (parts.length === 0) return 'Aucun filtre actif';
if (parts.length === 1) return `Filtré par ${parts[0]}`;
if (parts.length === 2) return `Filtré par ${parts[0]} et ${parts[1]}`;
return `Filtré par ${parts.slice(0, -1).join(', ')} et ${parts[parts.length - 1]}`;
}
}

View File

@@ -0,0 +1,155 @@
/**
* Service de cache pour les analytics Jira
* Cache en mémoire avec invalidation manuelle
*/
import { JiraAnalytics } from '@/lib/types';
interface CacheEntry {
data: JiraAnalytics;
timestamp: number;
projectKey: string;
configHash: string; // Hash de la config Jira pour détecter les changements
}
class JiraAnalyticsCacheService {
private cache = new Map<string, CacheEntry>();
private readonly CACHE_KEY_PREFIX = 'jira-analytics:';
/**
* 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 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}`;
// Simple hash (pour production, utiliser crypto.createHash)
let hash = 0;
for (let i = 0; i < configString.length; i++) {
const char = configString.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return hash.toString();
}
/**
* Récupère les analytics depuis le cache si disponible
*/
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;
}
console.log(`✅ Cache HIT pour projet ${config.projectKey} (${this.getAgeDescription(entry.timestamp)})`);
return entry.data;
}
/**
* Stocke les analytics dans le cache
*/
set(config: { baseUrl: string; email: string; apiToken: string; projectKey: string }, data: JiraAnalytics): void {
const configHash = this.generateConfigHash(config);
const cacheKey = this.getCacheKey(config.projectKey, configHash);
const entry: CacheEntry = {
data,
timestamp: Date.now(),
projectKey: config.projectKey,
configHash
};
this.cache.set(cacheKey, entry);
console.log(`💾 Analytics mises en cache pour projet ${config.projectKey}`);
}
/**
* 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
*/
getStats(): {
totalEntries: number;
projects: Array<{ projectKey: string; age: string; size: number }>;
} {
const projects = Array.from(this.cache.entries()).map(([, entry]) => ({
projectKey: entry.projectKey,
age: this.getAgeDescription(entry.timestamp),
size: JSON.stringify(entry.data).length
}));
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';
}
}
/**
* 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);
}
}
// Instance singleton
export const jiraAnalyticsCache = new JiraAnalyticsCacheService();

View File

@@ -0,0 +1,481 @@
/**
* Service d'analytics Jira pour la surveillance d'équipe
* Calcule des métriques avancées sur un projet spécifique
*/
import { JiraService } from './jira';
import { jiraAnalyticsCache } from './jira-analytics-cache';
import {
JiraAnalytics,
JiraTask,
AssigneeDistribution,
SprintVelocity,
CycleTimeByType,
StatusDistribution,
AssigneeWorkload
} from '@/lib/types';
export interface JiraAnalyticsConfig {
baseUrl: string;
email: string;
apiToken: string;
projectKey: string;
}
export class JiraAnalyticsService {
private jiraService: JiraService;
private projectKey: string;
private config: JiraAnalyticsConfig;
constructor(config: JiraAnalyticsConfig) {
this.jiraService = new JiraService(config);
this.projectKey = config.projectKey;
this.config = config;
}
/**
* Récupère toutes les issues du projet pour filtrage
*/
async getAllProjectIssues(): Promise<JiraTask[]> {
try {
const jql = `project = "${this.projectKey}" ORDER BY created DESC`;
const issues = await this.jiraService.searchIssues(jql);
console.log(`📋 Récupéré ${issues.length} issues pour filtrage`);
return issues;
} catch (error) {
console.error('❌ Erreur lors de la récupération des issues:', error);
throw new Error(`Impossible de récupérer les issues: ${error instanceof Error ? error.message : 'Erreur inconnue'}`);
}
}
/**
* Récupère toutes les analytics du projet avec cache
*/
async getProjectAnalytics(forceRefresh = false): Promise<JiraAnalytics> {
try {
// Vérifier le cache d'abord (sauf si forceRefresh)
if (!forceRefresh) {
const cachedAnalytics = jiraAnalyticsCache.get(this.config);
if (cachedAnalytics) {
return cachedAnalytics;
}
}
console.log(`🔄 Calcul des analytics Jira pour projet ${this.projectKey} ${forceRefresh ? '(actualisation forcée)' : '(cache manquant)'}`);
// Récupérer les informations du projet
const projectInfo = await this.getProjectInfo();
// Récupérer tous les tickets du projet (pas seulement assignés)
const allIssues = await this.getAllProjectIssues();
console.log(`📋 ${allIssues.length} tickets récupérés pour l'analyse`);
// Calculer les différentes métriques
const [
teamMetrics,
velocityMetrics,
cycleTimeMetrics,
workInProgress
] = await Promise.all([
this.calculateTeamMetrics(allIssues),
this.calculateVelocityMetrics(allIssues),
this.calculateCycleTimeMetrics(allIssues),
this.calculateWorkInProgress(allIssues)
]);
const analytics: JiraAnalytics = {
project: {
key: this.projectKey,
name: projectInfo.name,
totalIssues: allIssues.length
},
teamMetrics,
velocityMetrics,
cycleTimeMetrics,
workInProgress
};
// Mettre en cache le résultat
jiraAnalyticsCache.set(this.config, analytics);
return analytics;
} catch (error) {
console.error('Erreur lors du calcul des analytics:', error);
throw error;
}
}
/**
* Invalide le cache pour ce projet
*/
invalidateCache(): void {
jiraAnalyticsCache.invalidate(this.config);
}
/**
* Récupère les informations de base du projet
*/
private async getProjectInfo(): Promise<{ name: string }> {
const validation = await this.jiraService.validateProject(this.projectKey);
if (!validation.exists) {
throw new Error(`Projet ${this.projectKey} introuvable`);
}
return { name: validation.name || this.projectKey };
}
/**
* Calcule les métriques d'équipe (répartition par assignee)
*/
private async calculateTeamMetrics(issues: JiraTask[]): Promise<{
totalAssignees: number;
activeAssignees: number;
issuesDistribution: AssigneeDistribution[];
}> {
const assigneeStats = new Map<string, {
displayName: string;
total: number;
completed: number;
inProgress: number;
}>();
// Analyser chaque ticket
issues.forEach(issue => {
const assignee = issue.assignee;
const status = issue.status?.name || 'Unknown';
// Utiliser "Unassigned" si pas d'assignee
const assigneeKey = assignee?.emailAddress || 'unassigned';
const displayName = assignee?.displayName || 'Non assigné';
if (!assigneeStats.has(assigneeKey)) {
assigneeStats.set(assigneeKey, {
displayName,
total: 0,
completed: 0,
inProgress: 0
});
}
const stats = assigneeStats.get(assigneeKey)!;
stats.total++;
// Catégoriser par statut (logique simplifiée)
const statusLower = status.toLowerCase();
if (statusLower.includes('done') || statusLower.includes('closed') || statusLower.includes('resolved')) {
stats.completed++;
} else if (statusLower.includes('progress') || statusLower.includes('review') || statusLower.includes('testing')) {
stats.inProgress++;
}
});
// Convertir en tableau et calculer les pourcentages
const distribution: AssigneeDistribution[] = Array.from(assigneeStats.entries()).map(([assignee, stats]) => ({
assignee,
displayName: stats.displayName,
totalIssues: stats.total,
completedIssues: stats.completed,
inProgressIssues: stats.inProgress,
percentage: Math.round((stats.total / issues.length) * 100)
})).sort((a, b) => b.totalIssues - a.totalIssues);
const activeAssignees = distribution.filter(d => d.inProgressIssues > 0).length;
return {
totalAssignees: assigneeStats.size,
activeAssignees,
issuesDistribution: distribution
};
}
/**
* Calcule les métriques de vélocité (basées sur les story points)
*/
private async calculateVelocityMetrics(issues: JiraTask[]): Promise<{
currentSprintPoints: number;
averageVelocity: number;
sprintHistory: SprintVelocity[];
}> {
// Pour l'instant, implémentation basique
// TODO: Intégrer avec l'API Jira Agile pour les vrais sprints
const completedIssues = issues.filter(issue => {
const statusCategory = issue.status?.category?.toLowerCase();
const statusName = issue.status?.name?.toLowerCase() || '';
// Support Jira français ET anglais
const isCompleted = statusCategory === 'done' ||
statusCategory === 'terminé' ||
statusName.includes('done') ||
statusName.includes('closed') ||
statusName.includes('resolved') ||
statusName.includes('complete') ||
statusName.includes('fait') ||
statusName.includes('clôturé') ||
statusName.includes('cloturé') ||
statusName.includes('en production') ||
statusName.includes('finished') ||
statusName.includes('delivered');
return isCompleted;
});
// Calculer les points (1 point par ticket pour simplifier)
const getStoryPoints = () => {
return 1; // Simplifié pour l'instant, pas de story points dans JiraTask
};
const currentSprintPoints = completedIssues
.reduce((sum) => sum + getStoryPoints(), 0);
// Créer un historique basé sur les données réelles des 4 dernières périodes
const sprintHistory = this.generateSprintHistoryFromIssues(issues, completedIssues);
const averageVelocity = sprintHistory.length > 0
? Math.round(sprintHistory.reduce((sum, sprint) => sum + sprint.completedPoints, 0) / sprintHistory.length)
: 0;
return {
currentSprintPoints,
averageVelocity,
sprintHistory
};
}
/**
* Génère un historique de sprints basé sur les dates de création/résolution des tickets
*/
private generateSprintHistoryFromIssues(allIssues: JiraTask[], completedIssues: JiraTask[]): SprintVelocity[] {
const now = new Date();
const sprintHistory: SprintVelocity[] = [];
// Créer 4 périodes de 2 semaines (8 semaines au total)
for (let i = 3; i >= 0; i--) {
const endDate = new Date(now.getTime() - (i * 14 * 24 * 60 * 60 * 1000));
const startDate = new Date(endDate.getTime() - (14 * 24 * 60 * 60 * 1000));
// Compter les tickets complétés dans cette période
const completedInPeriod = completedIssues.filter(issue => {
const updatedDate = new Date(issue.updated);
return updatedDate >= startDate && updatedDate <= endDate;
});
// Compter les tickets créés dans cette période (approximation du planifié)
const createdInPeriod = allIssues.filter(issue => {
const createdDate = new Date(issue.created);
return createdDate >= startDate && createdDate <= endDate;
});
const completedPoints = completedInPeriod.length;
const plannedPoints = Math.max(completedPoints, createdInPeriod.length);
const completionRate = plannedPoints > 0 ? Math.round((completedPoints / plannedPoints) * 100) : 0;
sprintHistory.push({
sprintName: i === 0 ? 'Sprint actuel' : `Sprint -${i}`,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
completedPoints,
plannedPoints,
completionRate
});
}
return sprintHistory;
}
/**
* Calcule les métriques de cycle time
*/
private async calculateCycleTimeMetrics(issues: JiraTask[]): Promise<{
averageCycleTime: number;
cycleTimeByType: CycleTimeByType[];
}> {
const completedIssues = issues.filter(issue => {
const statusCategory = issue.status?.category?.toLowerCase();
const statusName = issue.status?.name?.toLowerCase() || '';
// Support Jira français ET anglais
return statusCategory === 'done' ||
statusCategory === 'terminé' ||
statusName.includes('done') ||
statusName.includes('closed') ||
statusName.includes('resolved') ||
statusName.includes('complete') ||
statusName.includes('fait') ||
statusName.includes('clôturé') ||
statusName.includes('cloturé') ||
statusName.includes('en production') ||
statusName.includes('finished') ||
statusName.includes('delivered');
});
// Calculer le cycle time (de création à résolution)
const cycleTimes = completedIssues
.filter(issue => issue.created && issue.updated) // S'assurer qu'on a les dates
.map(issue => {
const created = new Date(issue.created);
const resolved = new Date(issue.updated);
const days = Math.max(0.1, (resolved.getTime() - created.getTime()) / (1000 * 60 * 60 * 24)); // Minimum 0.1 jour
return Math.round(days * 10) / 10; // Arrondir à 1 décimale
})
.filter(time => time > 0 && time < 365); // Filtrer les valeurs aberrantes (plus d'un an)
const averageCycleTime = cycleTimes.length > 0
? Math.round(cycleTimes.reduce((sum, time) => sum + time, 0) / cycleTimes.length * 10) / 10
: 0;
// Grouper par type d'issue (recalculer avec les données filtrées)
const validCompletedIssues = completedIssues.filter(issue => issue.created && issue.updated);
const typeStats = new Map<string, number[]>();
validCompletedIssues.forEach((issue, index) => {
if (index < cycleTimes.length) { // Sécurité pour éviter l'index out of bounds
const issueType = issue.issuetype?.name || 'Unknown';
if (!typeStats.has(issueType)) {
typeStats.set(issueType, []);
}
const cycleTime = cycleTimes[index];
if (cycleTime > 0 && cycleTime < 365) { // Même filtre que plus haut
typeStats.get(issueType)!.push(cycleTime);
}
}
});
const cycleTimeByType: CycleTimeByType[] = Array.from(typeStats.entries()).map(([type, times]) => {
const average = times.reduce((sum, time) => sum + time, 0) / times.length;
const sorted = [...times].sort((a, b) => a - b);
const median = sorted.length % 2 === 0
? (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2
: sorted[Math.floor(sorted.length / 2)];
return {
issueType: type,
averageDays: Math.round(average * 10) / 10,
medianDays: Math.round(median * 10) / 10,
samples: times.length
};
}).sort((a, b) => b.samples - a.samples);
return {
averageCycleTime,
cycleTimeByType
};
}
/**
* Calcule le work in progress (WIP)
*/
private async calculateWorkInProgress(issues: JiraTask[]): Promise<{
byStatus: StatusDistribution[];
byAssignee: AssigneeWorkload[];
}> {
// Grouper par statut
const statusCounts = new Map<string, number>();
issues.forEach(issue => {
const status = issue.status?.name || 'Unknown';
statusCounts.set(status, (statusCounts.get(status) || 0) + 1);
});
const byStatus: StatusDistribution[] = Array.from(statusCounts.entries()).map(([status, count]) => ({
status,
count,
percentage: Math.round((count / issues.length) * 100)
})).sort((a, b) => b.count - a.count);
// Grouper par assignee (WIP seulement)
const wipIssues = issues.filter(issue => {
const statusCategory = issue.status?.category?.toLowerCase();
const statusName = issue.status?.name?.toLowerCase() || '';
// Exclure les tickets terminés (support français ET anglais)
return statusCategory !== 'done' &&
statusCategory !== 'terminé' &&
!statusName.includes('done') &&
!statusName.includes('closed') &&
!statusName.includes('resolved') &&
!statusName.includes('complete') &&
!statusName.includes('fait') &&
!statusName.includes('clôturé') &&
!statusName.includes('cloturé') &&
!statusName.includes('en production') &&
!statusName.includes('finished') &&
!statusName.includes('delivered');
});
const assigneeWorkload = new Map<string, {
displayName: string;
todo: number;
inProgress: number;
review: number;
}>();
wipIssues.forEach(issue => {
const assignee = issue.assignee;
const status = issue.status?.name?.toLowerCase() || '';
const assigneeKey = assignee?.emailAddress || 'unassigned';
const displayName = assignee?.displayName || 'Non assigné';
if (!assigneeWorkload.has(assigneeKey)) {
assigneeWorkload.set(assigneeKey, {
displayName,
todo: 0,
inProgress: 0,
review: 0
});
}
const workload = assigneeWorkload.get(assigneeKey)!;
const statusCategory = issue.status?.category?.toLowerCase();
// Classification robuste français/anglais basée sur les catégories et noms Jira
if (statusCategory === 'indeterminate' ||
statusCategory === 'en cours' ||
status.includes('progress') ||
status.includes('en cours') ||
status.includes('developing') ||
status.includes('implementation')) {
workload.inProgress++;
} else if (status.includes('review') ||
status.includes('testing') ||
status.includes('validation') ||
status.includes('validating') ||
status.includes('ready for')) {
workload.review++;
} else if (statusCategory === 'new' ||
statusCategory === 'a faire' ||
status.includes('todo') ||
status.includes('to do') ||
status.includes('a faire') ||
status.includes('backlog') ||
status.includes('product backlog') ||
status.includes('ready to sprint') ||
status.includes('estimating') ||
status.includes('refinement') ||
status.includes('open') ||
status.includes('created')) {
workload.todo++;
} else {
// Fallback: si on ne peut pas classifier, mettre en "À faire"
workload.todo++;
}
});
const byAssignee: AssigneeWorkload[] = Array.from(assigneeWorkload.entries()).map(([assignee, workload]) => ({
assignee,
displayName: workload.displayName,
todoCount: workload.todo,
inProgressCount: workload.inProgress,
reviewCount: workload.review,
totalActive: workload.todo + workload.inProgress + workload.review
})).sort((a, b) => b.totalActive - a.totalActive);
return {
byStatus,
byAssignee
};
}
}

View File

@@ -0,0 +1,297 @@
/**
* Service de détection d'anomalies dans les métriques Jira
* Analyse les patterns et tendances pour identifier des problèmes potentiels
*/
import { JiraAnalytics, SprintVelocity, CycleTimeByType, AssigneeWorkload } from '@/lib/types';
export interface JiraAnomaly {
id: string;
type: 'velocity' | 'cycle_time' | 'workload' | 'completion' | 'blockers';
severity: 'low' | 'medium' | 'high' | 'critical';
title: string;
description: string;
value: number;
threshold: number;
recommendation: string;
affectedItems: string[];
timestamp: string;
}
export interface AnomalyDetectionConfig {
velocityVarianceThreshold: number; // % de variance acceptable
cycleTimeThreshold: number; // multiplicateur du cycle time moyen
workloadImbalanceThreshold: number; // ratio max entre assignees
completionRateThreshold: number; // % minimum de completion
stalledItemsThreshold: number; // jours sans changement
}
export class JiraAnomalyDetectionService {
private readonly defaultConfig: AnomalyDetectionConfig = {
velocityVarianceThreshold: 30, // 30% de variance
cycleTimeThreshold: 2.0, // 2x le cycle time moyen
workloadImbalanceThreshold: 3.0, // 3:1 ratio max
completionRateThreshold: 70, // 70% completion minimum
stalledItemsThreshold: 7 // 7 jours
};
constructor(private config: Partial<AnomalyDetectionConfig> = {}) {
this.config = { ...this.defaultConfig, ...config };
}
/**
* Analyse toutes les métriques et détecte les anomalies
*/
async detectAnomalies(analytics: JiraAnalytics): Promise<JiraAnomaly[]> {
const anomalies: JiraAnomaly[] = [];
const timestamp = new Date().toISOString();
// 1. Détection d'anomalies de vélocité
const velocityAnomalies = this.detectVelocityAnomalies(analytics.velocityMetrics, timestamp);
anomalies.push(...velocityAnomalies);
// 2. Détection d'anomalies de cycle time
const cycleTimeAnomalies = this.detectCycleTimeAnomalies(analytics.cycleTimeMetrics, timestamp);
anomalies.push(...cycleTimeAnomalies);
// 3. Détection de déséquilibres de charge
const workloadAnomalies = this.detectWorkloadAnomalies(analytics.workInProgress.byAssignee, timestamp);
anomalies.push(...workloadAnomalies);
// 4. Détection de problèmes de completion
const completionAnomalies = this.detectCompletionAnomalies(analytics.velocityMetrics, timestamp);
anomalies.push(...completionAnomalies);
// Trier par sévérité
return anomalies.sort((a, b) => this.getSeverityWeight(b.severity) - this.getSeverityWeight(a.severity));
}
/**
* Détecte les anomalies de vélocité (variance excessive, tendance négative)
*/
private detectVelocityAnomalies(velocityMetrics: { sprintHistory: SprintVelocity[]; averageVelocity: number }, timestamp: string): JiraAnomaly[] {
const anomalies: JiraAnomaly[] = [];
const { sprintHistory, averageVelocity } = velocityMetrics;
if (sprintHistory.length < 3) return anomalies;
// Calcul de la variance de vélocité
const velocities = sprintHistory.map((s: SprintVelocity) => s.completedPoints);
const variance = this.calculateVariance(velocities);
const variancePercent = (Math.sqrt(variance) / averageVelocity) * 100;
if (variancePercent > (this.config.velocityVarianceThreshold ?? this.defaultConfig.velocityVarianceThreshold)) {
anomalies.push({
id: `velocity-variance-${Date.now()}`,
type: 'velocity',
severity: variancePercent > 50 ? 'high' : 'medium',
title: 'Vélocité très variable',
description: `La vélocité de l'équipe varie de ${variancePercent.toFixed(1)}% autour de la moyenne`,
value: variancePercent,
threshold: this.config.velocityVarianceThreshold ?? this.defaultConfig.velocityVarianceThreshold,
recommendation: 'Analysez les facteurs causant cette instabilité : estimation, complexité, blockers',
affectedItems: sprintHistory.slice(-3).map((s: SprintVelocity) => s.sprintName),
timestamp
});
}
// Détection de tendance décroissante
const recentSprints = sprintHistory.slice(-3);
const isDecreasing = recentSprints.every((sprint: SprintVelocity, i: number) =>
i === 0 || sprint.completedPoints < recentSprints[i - 1].completedPoints
);
if (isDecreasing && recentSprints.length >= 3) {
const decline = ((recentSprints[0].completedPoints - recentSprints[recentSprints.length - 1].completedPoints) / recentSprints[0].completedPoints) * 100;
anomalies.push({
id: `velocity-decline-${Date.now()}`,
type: 'velocity',
severity: decline > 30 ? 'critical' : 'high',
title: 'Vélocité en déclin',
description: `La vélocité a diminué de ${decline.toFixed(1)}% sur les 3 derniers sprints`,
value: decline,
threshold: 0,
recommendation: 'Identifiez les causes : technical debt, complexité croissante, ou problèmes d\'équipe',
affectedItems: recentSprints.map((s: SprintVelocity) => s.sprintName),
timestamp
});
}
return anomalies;
}
/**
* Détecte les anomalies de cycle time (temps excessifs, types problématiques)
*/
private detectCycleTimeAnomalies(cycleTimeMetrics: { averageCycleTime: number; cycleTimeByType: CycleTimeByType[] }, timestamp: string): JiraAnomaly[] {
const anomalies: JiraAnomaly[] = [];
const { averageCycleTime, cycleTimeByType } = cycleTimeMetrics;
// Détection des types avec cycle time excessif
cycleTimeByType.forEach((typeMetrics: CycleTimeByType) => {
const ratio = typeMetrics.averageDays / averageCycleTime;
if (ratio > (this.config.cycleTimeThreshold ?? this.defaultConfig.cycleTimeThreshold)) {
anomalies.push({
id: `cycle-time-${typeMetrics.issueType}-${Date.now()}`,
type: 'cycle_time',
severity: ratio > 3 ? 'high' : 'medium',
title: `Cycle time excessif - ${typeMetrics.issueType}`,
description: `Le type "${typeMetrics.issueType}" prend ${ratio.toFixed(1)}x plus de temps que la moyenne`,
value: typeMetrics.averageDays,
threshold: averageCycleTime * (this.config.cycleTimeThreshold ?? this.defaultConfig.cycleTimeThreshold),
recommendation: 'Analysez les blockers spécifiques à ce type de ticket',
affectedItems: [typeMetrics.issueType],
timestamp
});
}
});
// Détection cycle time global excessif (> 14 jours)
if (averageCycleTime > 14) {
anomalies.push({
id: `global-cycle-time-${Date.now()}`,
type: 'cycle_time',
severity: averageCycleTime > 21 ? 'critical' : 'high',
title: 'Cycle time global élevé',
description: `Le cycle time moyen de ${averageCycleTime.toFixed(1)} jours est préoccupant`,
value: averageCycleTime,
threshold: 14,
recommendation: 'Réduisez la taille des tâches et identifiez les goulots d\'étranglement',
affectedItems: ['Projet global'],
timestamp
});
}
return anomalies;
}
/**
* Détecte les déséquilibres de charge de travail
*/
private detectWorkloadAnomalies(assigneeWorkloads: AssigneeWorkload[], timestamp: string): JiraAnomaly[] {
const anomalies: JiraAnomaly[] = [];
if (assigneeWorkloads.length < 2) return anomalies;
const workloads = assigneeWorkloads.map(a => a.totalActive);
const maxWorkload = Math.max(...workloads);
const minWorkload = Math.min(...workloads.filter(w => w > 0));
if (minWorkload === 0) return anomalies; // Éviter division par zéro
const imbalanceRatio = maxWorkload / minWorkload;
if (imbalanceRatio > (this.config.workloadImbalanceThreshold ?? this.defaultConfig.workloadImbalanceThreshold)) {
const overloadedMember = assigneeWorkloads.find(a => a.totalActive === maxWorkload);
const underloadedMember = assigneeWorkloads.find(a => a.totalActive === minWorkload);
anomalies.push({
id: `workload-imbalance-${Date.now()}`,
type: 'workload',
severity: imbalanceRatio > 5 ? 'high' : 'medium',
title: 'Déséquilibre de charge',
description: `Ratio de ${imbalanceRatio.toFixed(1)}:1 entre membres les plus/moins chargés`,
value: imbalanceRatio,
threshold: this.config.workloadImbalanceThreshold ?? this.defaultConfig.workloadImbalanceThreshold,
recommendation: 'Redistribuez les tâches pour équilibrer la charge de travail',
affectedItems: [
`Surchargé: ${overloadedMember?.displayName} (${maxWorkload} tâches)`,
`Sous-chargé: ${underloadedMember?.displayName} (${minWorkload} tâches)`
],
timestamp
});
}
// Détection de membres avec trop de tâches en cours
assigneeWorkloads.forEach(assignee => {
if (assignee.inProgressCount > 5) {
anomalies.push({
id: `wip-limit-${assignee.assignee}-${Date.now()}`,
type: 'workload',
severity: assignee.inProgressCount > 8 ? 'high' : 'medium',
title: 'WIP limite dépassée',
description: `${assignee.displayName} a ${assignee.inProgressCount} tâches en cours`,
value: assignee.inProgressCount,
threshold: 5,
recommendation: 'Limitez le WIP à 3-5 tâches par personne pour améliorer le focus',
affectedItems: [assignee.displayName],
timestamp
});
}
});
return anomalies;
}
/**
* Détecte les problèmes de completion rate
*/
private detectCompletionAnomalies(velocityMetrics: { sprintHistory: SprintVelocity[] }, timestamp: string): JiraAnomaly[] {
const anomalies: JiraAnomaly[] = [];
const { sprintHistory } = velocityMetrics;
if (sprintHistory.length === 0) return anomalies;
// Analyse des 3 derniers sprints
const recentSprints = sprintHistory.slice(-3);
const avgCompletionRate = recentSprints.reduce((sum: number, sprint: SprintVelocity) =>
sum + sprint.completionRate, 0) / recentSprints.length;
if (avgCompletionRate < (this.config.completionRateThreshold ?? this.defaultConfig.completionRateThreshold)) {
anomalies.push({
id: `low-completion-rate-${Date.now()}`,
type: 'completion',
severity: avgCompletionRate < 50 ? 'critical' : 'high',
title: 'Taux de completion faible',
description: `Taux de completion moyen de ${avgCompletionRate.toFixed(1)}% sur les derniers sprints`,
value: avgCompletionRate,
threshold: this.config.completionRateThreshold ?? this.defaultConfig.completionRateThreshold,
recommendation: 'Revoyez la planification et l\'estimation des sprints',
affectedItems: recentSprints.map((s: SprintVelocity) => `${s.sprintName}: ${s.completionRate.toFixed(1)}%`),
timestamp
});
}
return anomalies;
}
/**
* Calcule la variance d'un tableau de nombres
*/
private calculateVariance(numbers: number[]): number {
const mean = numbers.reduce((sum, num) => sum + num, 0) / numbers.length;
const squaredDiffs = numbers.map(num => Math.pow(num - mean, 2));
return squaredDiffs.reduce((sum, diff) => sum + diff, 0) / numbers.length;
}
/**
* Retourne le poids numérique d'une sévérité pour le tri
*/
private getSeverityWeight(severity: string): number {
switch (severity) {
case 'critical': return 4;
case 'high': return 3;
case 'medium': return 2;
case 'low': return 1;
default: return 0;
}
}
/**
* Met à jour la configuration de détection
*/
updateConfig(newConfig: Partial<AnomalyDetectionConfig>): void {
this.config = { ...this.config, ...newConfig };
}
/**
* Retourne la configuration actuelle
*/
getConfig(): AnomalyDetectionConfig {
return { ...this.defaultConfig, ...this.config };
}
}
export const jiraAnomalyDetection = new JiraAnomalyDetectionService();

View File

@@ -0,0 +1,180 @@
import type { JiraConfig } from './jira';
import { Task } from '@/lib/types';
export interface JiraWeeklyMetrics {
totalJiraTasks: number;
completedJiraTasks: number;
totalStoryPoints: number; // Estimation basée sur le type de ticket
projectsContributed: string[];
ticketTypes: { [type: string]: number };
jiraLinks: Array<{
key: string;
title: string;
status: string;
type: string;
url: string;
estimatedPoints: number;
}>;
}
export class JiraSummaryService {
/**
* Enrichit les tâches hebdomadaires avec des métriques Jira
*/
static async getJiraWeeklyMetrics(
weeklyTasks: Task[],
jiraConfig?: JiraConfig
): Promise<JiraWeeklyMetrics | null> {
if (!jiraConfig?.baseUrl || !jiraConfig?.email || !jiraConfig?.apiToken) {
return null;
}
const jiraTasks = weeklyTasks.filter(task =>
task.source === 'jira' && task.jiraKey && task.jiraProject
);
if (jiraTasks.length === 0) {
return {
totalJiraTasks: 0,
completedJiraTasks: 0,
totalStoryPoints: 0,
projectsContributed: [],
ticketTypes: {},
jiraLinks: []
};
}
// Calculer les métriques basiques
const completedJiraTasks = jiraTasks.filter(task => task.status === 'done');
const projectsContributed = [...new Set(jiraTasks.map(task => task.jiraProject).filter((project): project is string => Boolean(project)))];
// Analyser les types de tickets
const ticketTypes: { [type: string]: number } = {};
jiraTasks.forEach(task => {
const type = task.jiraType || 'Unknown';
ticketTypes[type] = (ticketTypes[type] || 0) + 1;
});
// Estimer les story points basés sur le type de ticket
const estimateStoryPoints = (type: string): number => {
const typeMapping: { [key: string]: number } = {
'Story': 3,
'Task': 2,
'Bug': 1,
'Epic': 8,
'Sub-task': 1,
'Improvement': 2,
'New Feature': 5,
'défaut': 1, // French
'amélioration': 2, // French
'nouvelle fonctionnalité': 5, // French
};
return typeMapping[type] || typeMapping[type?.toLowerCase()] || 2; // Défaut: 2 points
};
const totalStoryPoints = jiraTasks.reduce((sum, task) => {
return sum + estimateStoryPoints(task.jiraType || '');
}, 0);
// Créer les liens Jira
const jiraLinks = jiraTasks.map(task => ({
key: task.jiraKey || '',
title: task.title,
status: task.status,
type: task.jiraType || 'Unknown',
url: `${jiraConfig.baseUrl.replace('/rest/api/3', '')}/browse/${task.jiraKey}`,
estimatedPoints: estimateStoryPoints(task.jiraType || '')
}));
return {
totalJiraTasks: jiraTasks.length,
completedJiraTasks: completedJiraTasks.length,
totalStoryPoints,
projectsContributed,
ticketTypes,
jiraLinks
};
}
/**
* Récupère la configuration Jira depuis les préférences utilisateur
*/
static async getJiraConfig(): Promise<JiraConfig | null> {
try {
// Import dynamique pour éviter les cycles de dépendance
const { userPreferencesService } = await import('./user-preferences');
const preferences = await userPreferencesService.getAllPreferences();
if (!preferences.jiraConfig?.baseUrl ||
!preferences.jiraConfig?.email ||
!preferences.jiraConfig?.apiToken) {
return null;
}
return {
baseUrl: preferences.jiraConfig.baseUrl,
email: preferences.jiraConfig.email,
apiToken: preferences.jiraConfig.apiToken,
projectKey: preferences.jiraConfig.projectKey,
ignoredProjects: preferences.jiraConfig.ignoredProjects
};
} catch (error) {
console.error('Erreur lors de la récupération de la config Jira:', error);
return null;
}
}
/**
* Génère des insights business basés sur les métriques Jira
*/
static generateBusinessInsights(jiraMetrics: JiraWeeklyMetrics): string[] {
const insights: string[] = [];
if (jiraMetrics.totalJiraTasks === 0) {
insights.push("Aucune tâche Jira cette semaine. Concentré sur des tâches internes ?");
return insights;
}
// Insights sur la completion
const completionRate = (jiraMetrics.completedJiraTasks / jiraMetrics.totalJiraTasks) * 100;
if (completionRate >= 80) {
insights.push(`🎯 Excellent taux de completion Jira: ${completionRate.toFixed(0)}%`);
} else if (completionRate < 50) {
insights.push(`⚠️ Taux de completion Jira faible: ${completionRate.toFixed(0)}%. Revoir les estimations ?`);
}
// Insights sur les story points
if (jiraMetrics.totalStoryPoints > 0) {
insights.push(`📊 Estimation: ${jiraMetrics.totalStoryPoints} story points traités cette semaine`);
const avgPointsPerTask = jiraMetrics.totalStoryPoints / jiraMetrics.totalJiraTasks;
if (avgPointsPerTask > 4) {
insights.push(`🏋️ Travail sur des tâches complexes (${avgPointsPerTask.toFixed(1)} pts/tâche en moyenne)`);
}
}
// Insights sur les projets
if (jiraMetrics.projectsContributed.length > 1) {
insights.push(`🤝 Contribution multi-projets: ${jiraMetrics.projectsContributed.join(', ')}`);
} else if (jiraMetrics.projectsContributed.length === 1) {
insights.push(`🎯 Focus sur le projet ${jiraMetrics.projectsContributed[0]}`);
}
// Insights sur les types de tickets
const bugCount = jiraMetrics.ticketTypes['Bug'] || jiraMetrics.ticketTypes['défaut'] || 0;
const totalTickets = Object.values(jiraMetrics.ticketTypes).reduce((sum, count) => sum + count, 0);
if (bugCount > 0) {
const bugRatio = (bugCount / totalTickets) * 100;
if (bugRatio > 50) {
insights.push(`🐛 Semaine focalisée sur la correction de bugs (${bugRatio.toFixed(0)}%)`);
} else if (bugRatio < 20) {
insights.push(`✨ Semaine productive avec peu de bugs (${bugRatio.toFixed(0)}%)`);
}
}
return insights;
}
}

753
src/services/jira.ts Normal file
View File

@@ -0,0 +1,753 @@
/**
* Service de gestion Jira Cloud
* Intégration unidirectionnelle Jira → TowerControl
*/
import { JiraTask } from '@/lib/types';
import { prisma } from './database';
export interface JiraConfig {
baseUrl: string;
email: string;
apiToken: string;
projectKey?: string; // Clé du projet à surveiller pour les analytics d'équipe (ex: "MYTEAM")
ignoredProjects?: string[]; // Liste des clés de projets à ignorer (ex: ["DEMO", "TEST"])
}
export interface JiraSyncAction {
type: 'created' | 'updated' | 'skipped' | 'deleted';
taskKey: string;
taskTitle: string;
reason?: string; // Raison du skip ou de la suppression
changes?: string[]; // Liste des champs modifiés pour les updates
}
export interface JiraSyncResult {
success: boolean;
tasksFound: number;
tasksCreated: number;
tasksUpdated: number;
tasksSkipped: number;
tasksDeleted: number;
errors: string[];
actions: JiraSyncAction[]; // Détail des actions effectuées
}
export class JiraService {
private config: JiraConfig;
constructor(config: JiraConfig) {
this.config = config;
}
/**
* Valide l'existence d'un projet Jira
*/
async validateProject(projectKey: string): Promise<{ exists: boolean; name?: string; error?: string }> {
try {
const response = await this.makeJiraRequestPrivate(`/rest/api/3/project/${projectKey}`);
if (response.status === 404) {
return { exists: false, error: `Projet "${projectKey}" introuvable` };
}
if (!response.ok) {
const errorText = await response.text();
return { exists: false, error: `Erreur API: ${response.status} - ${errorText}` };
}
const project = await response.json();
return {
exists: true,
name: project.name
};
} catch (error) {
console.error('Erreur lors de la validation du projet:', error);
return {
exists: false,
error: error instanceof Error ? error.message : 'Erreur de connexion'
};
}
}
/**
* Teste la connexion à Jira
*/
async testConnection(): Promise<boolean> {
try {
const response = await this.makeJiraRequestPrivate('/rest/api/3/myself');
if (!response.ok) {
console.error(`Test connexion Jira échoué: ${response.status} ${response.statusText}`);
const errorText = await response.text();
console.error('Détails erreur:', errorText);
}
return response.ok;
} catch (error) {
console.error('Erreur de connexion Jira:', error);
return false;
}
}
/**
* Filtre les tâches Jira selon les projets ignorés
*/
private filterIgnoredProjects(jiraTasks: JiraTask[]): JiraTask[] {
if (!this.config.ignoredProjects || this.config.ignoredProjects.length === 0) {
return jiraTasks;
}
const ignoredSet = new Set(this.config.ignoredProjects.map(p => p.toUpperCase()));
return jiraTasks.filter(task => {
const projectKey = task.project.key.toUpperCase();
const shouldIgnore = ignoredSet.has(projectKey);
if (shouldIgnore) {
console.log(`🚫 Ticket ${task.key} ignoré (projet ${task.project.key} dans la liste d'exclusion)`);
}
return !shouldIgnore;
});
}
/**
* Récupère les tickets avec une requête JQL personnalisée avec pagination
*/
async searchIssues(jql: string): Promise<JiraTask[]> {
try {
const fields = ['id', 'key', 'summary', 'description', 'status', 'priority', 'assignee', 'project', 'issuetype', 'components', 'fixVersions', 'duedate', 'created', 'updated', 'labels'];
const allIssues: unknown[] = [];
let nextPageToken: string | undefined = undefined;
let pageNumber = 1;
console.log('🔄 Récupération paginée des tickets Jira (POST /search/jql avec tokens)...');
while (true) {
console.log(`📄 Page ${pageNumber} ${nextPageToken ? `(token présent)` : '(première page)'}`);
// Utiliser POST /rest/api/3/search/jql avec nextPageToken selon la doc officielle
const requestBody: {
jql: string;
fields: string[];
maxResults: number;
nextPageToken?: string;
} = {
jql,
fields,
maxResults: 50
};
if (nextPageToken) {
requestBody.nextPageToken = nextPageToken;
}
console.log(`🌐 POST /rest/api/3/search/jql avec ${nextPageToken ? 'nextPageToken' : 'première page'}`);
const response = await this.makeJiraRequestPrivate('/rest/api/3/search/jql', 'POST', requestBody);
console.log(`📡 Status réponse: ${response.status}`);
if (!response.ok) {
const errorText = await response.text();
console.error(`Erreur API Jira détaillée:`, {
status: response.status,
statusText: response.statusText,
url: response.url,
errorBody: errorText
});
throw new Error(`Erreur API Jira: ${response.status} ${response.statusText}. Détails: ${errorText.substring(0, 200)}`);
}
const data = await response.json() as {
issues: unknown[],
nextPageToken?: string,
isLast?: boolean
};
if (!data.issues || !Array.isArray(data.issues)) {
console.error('❌ Format de données inattendu:', data);
throw new Error('Format de données Jira inattendu: pas d\'array issues');
}
allIssues.push(...data.issues);
console.log(`${data.issues.length} tickets récupérés (total accumulé: ${allIssues.length})`);
console.log(`🔍 Pagination info:`, {
issuesLength: data.issues.length,
hasNextPageToken: !!data.nextPageToken,
isLast: data.isLast,
pageNumber
});
// Vérifier s'il y a plus de pages selon la doc officielle
if (data.isLast === true || !data.nextPageToken) {
console.log('🏁 Dernière page atteinte (isLast=true ou pas de nextPageToken)');
break;
}
nextPageToken = data.nextPageToken;
pageNumber++;
// Sécurité: éviter les boucles infinies
if (allIssues.length >= 10000) {
console.warn(`⚠️ Limite de sécurité atteinte (${allIssues.length} tickets). Arrêt de la pagination.`);
break;
}
}
console.log(`🎯 Total final: ${allIssues.length} tickets Jira récupérés`);
return allIssues.map((issue: unknown) => this.mapJiraIssueToTask(issue));
} catch (error) {
console.error('Erreur lors de la récupération des tickets Jira:', error);
throw error;
}
}
/**
* Récupère les tickets assignés à l'utilisateur connecté
*/
async getAssignedIssues(): Promise<JiraTask[]> {
const jql = 'assignee = currentUser() AND resolution = Unresolved AND issuetype != Epic ORDER BY updated DESC';
return this.searchIssues(jql);
}
/**
* S'assure que le tag "🔗 From Jira" existe dans la base
*/
private async ensureJiraTagExists(): Promise<void> {
try {
const tagName = '🔗 From Jira';
// Vérifier si le tag existe déjà
const existingTag = await prisma.tag.findUnique({
where: { name: tagName }
});
if (!existingTag) {
// Créer le tag s'il n'existe pas
await prisma.tag.create({
data: {
name: tagName,
color: '#0082C9', // Bleu Jira
isPinned: false
}
});
console.log(`✅ Tag "${tagName}" créé automatiquement`);
}
} catch (error) {
console.error('Erreur lors de la création du tag Jira:', error);
// Ne pas faire échouer la sync pour un problème de tag
}
}
/**
* Synchronise les tickets Jira avec la base locale
*/
async syncTasks(): Promise<JiraSyncResult> {
const result: JiraSyncResult = {
success: false,
tasksFound: 0,
tasksCreated: 0,
tasksUpdated: 0,
tasksSkipped: 0,
tasksDeleted: 0,
errors: [],
actions: []
};
try {
console.log('🔄 Début de la synchronisation Jira...');
// S'assurer que le tag "From Jira" existe
await this.ensureJiraTagExists();
// Récupérer les tickets Jira actuellement assignés
const jiraTasks = await this.getAssignedIssues();
result.tasksFound = jiraTasks.length;
console.log(`📋 ${jiraTasks.length} tickets trouvés dans Jira`);
// Filtrer les tâches selon les projets ignorés
const filteredTasks = this.filterIgnoredProjects(jiraTasks);
console.log(`🔽 ${filteredTasks.length} tickets après filtrage des projets ignorés (${jiraTasks.length - filteredTasks.length} ignorés)`);
// Récupérer la liste des IDs Jira actuels pour le nettoyage (après filtrage)
const currentJiraIds = new Set(filteredTasks.map(task => task.id));
// Synchroniser chaque ticket
for (const jiraTask of filteredTasks) {
try {
const syncAction = await this.syncSingleTask(jiraTask);
// Ajouter l'action au résultat
result.actions.push(syncAction);
// Compter les actions
if (syncAction.type === 'created') {
result.tasksCreated++;
} else if (syncAction.type === 'updated') {
result.tasksUpdated++;
} else {
result.tasksSkipped++;
}
} catch (error) {
console.error(`Erreur sync ticket ${jiraTask.key}:`, error);
result.errors.push(`${jiraTask.key}: ${error instanceof Error ? error.message : 'Erreur inconnue'}`);
}
}
// Nettoyer les tâches Jira qui ne sont plus assignées à l'utilisateur
const deletedActions = await this.cleanupUnassignedTasks(currentJiraIds);
result.tasksDeleted = deletedActions.length;
result.actions.push(...deletedActions);
// Déterminer le succès et enregistrer le log
result.success = result.errors.length === 0;
await this.logSync(result);
console.log('✅ Synchronisation Jira terminée:', result);
return result;
} catch (error) {
console.error('❌ Erreur générale de synchronisation:', error);
result.errors.push(error instanceof Error ? error.message : 'Erreur inconnue');
result.success = false;
await this.logSync(result);
return result;
}
}
/**
* Synchronise un ticket Jira unique
*/
private async syncSingleTask(jiraTask: JiraTask): Promise<JiraSyncAction> {
// Chercher la tâche existante
const existingTask = await prisma.task.findUnique({
where: {
source_sourceId: {
source: 'jira',
sourceId: jiraTask.id
}
}
});
const taskData = {
title: jiraTask.summary,
description: jiraTask.description || null,
status: this.mapJiraStatusToInternal(jiraTask.status.name),
priority: this.mapJiraPriorityToInternal(jiraTask.priority?.name),
source: 'jira' as const,
sourceId: jiraTask.id,
dueDate: jiraTask.duedate ? new Date(jiraTask.duedate) : null,
jiraProject: jiraTask.project.key,
jiraKey: jiraTask.key,
jiraType: this.mapJiraTypeToDisplay(jiraTask.issuetype.name),
assignee: jiraTask.assignee?.displayName || null,
updatedAt: new Date(jiraTask.updated)
};
if (!existingTask) {
// Créer nouvelle tâche avec le tag Jira
const newTask = await prisma.task.create({
data: {
...taskData,
createdAt: new Date(jiraTask.created)
}
});
// Assigner le tag Jira
await this.assignJiraTag(newTask.id);
console.log(` Nouvelle tâche créée: ${jiraTask.key}`);
return {
type: 'created',
taskKey: jiraTask.key,
taskTitle: jiraTask.summary
};
} else {
// Toujours mettre à jour les données Jira (écrasement forcé)
// Détecter les changements et créer la liste des modifications
const changes: string[] = [];
// Préserver le titre et la priorité si modifiés localement
const finalTitle = existingTask.title !== taskData.title ? existingTask.title : taskData.title;
const finalPriority = existingTask.priority !== taskData.priority ? existingTask.priority : taskData.priority;
if (existingTask.title !== taskData.title) {
changes.push(`Titre: préservé localement ("${existingTask.title}")`);
}
if (existingTask.description !== taskData.description) {
changes.push(`Description modifiée`);
}
if (existingTask.status !== taskData.status) {
changes.push(`Statut: ${existingTask.status}${taskData.status}`);
}
if (existingTask.priority !== taskData.priority) {
changes.push(`Priorité: préservée localement (${existingTask.priority})`);
}
if ((existingTask.dueDate?.getTime() || null) !== (taskData.dueDate?.getTime() || null)) {
const oldDate = existingTask.dueDate ? existingTask.dueDate.toLocaleDateString() : 'Aucune';
const newDate = taskData.dueDate ? taskData.dueDate.toLocaleDateString() : 'Aucune';
changes.push(`Échéance: ${oldDate}${newDate}`);
}
if (existingTask.jiraProject !== taskData.jiraProject) {
changes.push(`Projet: ${existingTask.jiraProject}${taskData.jiraProject}`);
}
if (existingTask.jiraType !== taskData.jiraType) {
changes.push(`Type: ${existingTask.jiraType}${taskData.jiraType}`);
}
if (existingTask.assignee !== taskData.assignee) {
changes.push(`Assigné: ${existingTask.assignee}${taskData.assignee}`);
}
if (changes.length === 0) {
console.log(`⏭️ Aucun changement pour ${jiraTask.key}, skip mise à jour`);
// S'assurer que le tag Jira est assigné (pour les anciennes tâches) même en skip
await this.assignJiraTag(existingTask.id);
return {
type: 'skipped',
taskKey: jiraTask.key,
taskTitle: jiraTask.summary,
reason: 'Aucun changement détecté'
};
}
// Mettre à jour les champs Jira (titre et priorité préservés si modifiés)
await prisma.task.update({
where: { id: existingTask.id },
data: {
title: finalTitle,
description: taskData.description,
status: taskData.status,
priority: finalPriority,
dueDate: taskData.dueDate,
jiraProject: taskData.jiraProject,
jiraKey: taskData.jiraKey,
jiraType: taskData.jiraType,
assignee: taskData.assignee,
updatedAt: taskData.updatedAt
}
});
// S'assurer que le tag Jira est assigné (pour les anciennes tâches)
await this.assignJiraTag(existingTask.id);
console.log(`🔄 Tâche mise à jour (titre/priorité préservés): ${jiraTask.key} (${changes.length} changement${changes.length > 1 ? 's' : ''})`);
return {
type: 'updated',
taskKey: jiraTask.key,
taskTitle: jiraTask.summary,
changes
};
}
}
/**
* Nettoie les tâches Jira qui ne sont plus assignées à l'utilisateur
*/
private async cleanupUnassignedTasks(currentJiraIds: Set<string>): Promise<JiraSyncAction[]> {
try {
console.log('🧹 Début du nettoyage des tâches non assignées...');
// Trouver toutes les tâches Jira existantes dans la base
const existingJiraTasks = await prisma.task.findMany({
where: {
source: 'jira'
},
select: {
id: true,
sourceId: true,
jiraKey: true,
title: true
}
});
console.log(`📊 ${existingJiraTasks.length} tâches Jira trouvées en base`);
// Identifier les tâches à supprimer (celles qui ne sont plus dans Jira)
const tasksToDelete = existingJiraTasks.filter(task =>
task.sourceId && !currentJiraIds.has(task.sourceId)
);
if (tasksToDelete.length === 0) {
console.log('✅ Aucune tâche à supprimer');
return [];
}
console.log(`🗑️ ${tasksToDelete.length} tâche(s) à supprimer (plus assignées à l'utilisateur)`);
const deletedActions: JiraSyncAction[] = [];
// Supprimer les tâches une par une avec logging
for (const task of tasksToDelete) {
try {
await prisma.task.delete({
where: { id: task.id }
});
console.log(`🗑️ Tâche supprimée: ${task.jiraKey} (non assignée)`);
deletedActions.push({
type: 'deleted',
taskKey: task.jiraKey || 'UNKNOWN',
taskTitle: task.title,
reason: 'Plus assignée à l\'utilisateur actuel'
});
} catch (error) {
console.error(`❌ Erreur suppression tâche ${task.jiraKey}:`, error);
}
}
console.log(`✅ Nettoyage terminé: ${deletedActions.length} tâche(s) supprimée(s)`);
return deletedActions;
} catch (error) {
console.error('❌ Erreur lors du nettoyage des tâches non assignées:', error);
// Ne pas faire échouer la sync pour un problème de nettoyage
return [];
}
}
/**
* Assigne le tag "🔗 From Jira" à une tâche si pas déjà assigné
*/
private async assignJiraTag(taskId: string): Promise<void> {
try {
const tagName = '🔗 From Jira';
// Récupérer le tag
const jiraTag = await prisma.tag.findUnique({
where: { name: tagName }
});
if (!jiraTag) {
console.warn(`⚠️ Tag "${tagName}" introuvable lors de l'assignation`);
return;
}
// Vérifier si le tag est déjà assigné
const existingAssignment = await prisma.taskTag.findUnique({
where: {
taskId_tagId: {
taskId: taskId,
tagId: jiraTag.id
}
}
});
if (!existingAssignment) {
// Créer l'assignation du tag
await prisma.taskTag.create({
data: {
taskId: taskId,
tagId: jiraTag.id
}
});
console.log(`🏷️ Tag "${tagName}" assigné à la tâche`);
}
} catch (error) {
console.error('Erreur lors de l\'assignation du tag Jira:', error);
// Ne pas faire échouer la sync pour un problème de tag
}
}
/**
* Mappe un issue Jira vers le format JiraTask
*/
private mapJiraIssueToTask(issue: unknown): JiraTask {
const issueData = issue as {
id: string;
key: string;
fields: {
summary: string;
description?: { content?: { content?: { text: string }[] }[] };
status: { name: string; statusCategory: { name: string } };
priority?: { name: string };
assignee?: { displayName: string; emailAddress: string };
project: { key: string; name: string };
issuetype: { name: string };
duedate?: string;
created: string;
updated: string;
labels?: string[];
};
};
return {
id: issueData.id,
key: issueData.key,
summary: issueData.fields.summary,
description: issueData.fields.description?.content?.[0]?.content?.[0]?.text || undefined,
status: {
name: issueData.fields.status.name,
category: issueData.fields.status.statusCategory.name
},
priority: issueData.fields.priority ? {
name: issueData.fields.priority.name
} : undefined,
assignee: issueData.fields.assignee ? {
displayName: issueData.fields.assignee.displayName,
emailAddress: issueData.fields.assignee.emailAddress
} : undefined,
project: {
key: issueData.fields.project.key,
name: issueData.fields.project.name
},
issuetype: {
name: issueData.fields.issuetype.name
},
duedate: issueData.fields.duedate,
created: issueData.fields.created,
updated: issueData.fields.updated,
labels: issueData.fields.labels || []
};
}
/**
* Mappe les statuts Jira vers les statuts internes
*/
private mapJiraStatusToInternal(jiraStatus: string): string {
const statusMapping: Record<string, string> = {
// Statuts "Backlog" (pas encore priorisés)
'Backlog': 'backlog',
'Product backlog': 'backlog',
'Product Discovery': 'backlog', // Phase de découverte
// Statuts "To Do" (priorisés, prêts à développer)
'To Do': 'todo',
'Open': 'todo',
'Ouvert': 'todo', // Français
'Selected for Development': 'todo',
'A faire': 'todo', // Français
// Statuts "In Progress"
'In Progress': 'in_progress',
'En cours': 'in_progress', // Français
'In Review': 'in_progress',
'Code Review': 'in_progress',
'Code review': 'in_progress', // Variante casse
'Testing': 'in_progress',
'Product Delivery': 'in_progress', // Livré en prod
// Statuts "Done"
'Done': 'done',
'Closed': 'done',
'Resolved': 'done',
'Complete': 'done',
// Statuts bloqués
'Validating': 'freeze', // Phase de validation
'Blocked': 'freeze',
'On Hold': 'freeze',
'En attente du support': 'freeze' // Français - bloqué en attente
};
return statusMapping[jiraStatus] || 'todo';
}
/**
* Mappe les types Jira vers des termes plus courts
*/
private mapJiraTypeToDisplay(jiraType: string): string {
const typeMap: Record<string, string> = {
'Nouvelle fonctionnalité': 'Feature',
'Nouvelle Fonctionnalité': 'Feature',
'Feature': 'Feature',
'Story': 'Story',
'User Story': 'Story',
'Tâche': 'Task',
'Task': 'Task',
'Bug': 'Bug',
'Défaut': 'Bug',
'Support': 'Support',
'Enabler': 'Enabler',
'Epic': 'Epic',
'Épique': 'Epic'
};
return typeMap[jiraType] || jiraType;
}
/**
* Mappe les priorités Jira vers les priorités internes
*/
private mapJiraPriorityToInternal(jiraPriority?: string): string {
if (!jiraPriority) return 'medium';
const priorityMapping: Record<string, string> = {
'Highest': 'urgent',
'High': 'high',
'Medium': 'medium',
'Low': 'low',
'Lowest': 'low'
};
return priorityMapping[jiraPriority] || 'medium';
}
/**
* Effectue une requête à l'API Jira avec authentification (méthode publique pour analytics)
*/
async makeJiraRequest(endpoint: string, method: string = 'GET', body?: unknown): Promise<Response> {
return this.makeJiraRequestPrivate(endpoint, method, body);
}
/**
* Effectue une requête à l'API Jira avec authentification
*/
private async makeJiraRequestPrivate(endpoint: string, method: string = 'GET', body?: unknown): Promise<Response> {
const url = `${this.config.baseUrl}${endpoint}`;
const auth = Buffer.from(`${this.config.email}:${this.config.apiToken}`).toString('base64');
const options: RequestInit = {
method,
headers: {
'Authorization': `Basic ${auth}`,
'Accept': 'application/json',
'Content-Type': 'application/json'
}
};
if (body && method !== 'GET') {
options.body = JSON.stringify(body);
}
return fetch(url, options);
}
/**
* Enregistre un log de synchronisation
*/
private async logSync(result: JiraSyncResult): Promise<void> {
try {
await prisma.syncLog.create({
data: {
source: 'jira',
status: result.success ? 'success' : 'error',
message: result.errors.length > 0 ? result.errors.join('; ') : null,
tasksSync: result.tasksCreated + result.tasksUpdated
}
});
} catch (error) {
console.error('Erreur lors de l\'enregistrement du log:', error);
}
}
}
/**
* Factory pour créer une instance JiraService avec la config env
*/
export function createJiraService(): JiraService | null {
const baseUrl = process.env.JIRA_BASE_URL;
const email = process.env.JIRA_EMAIL;
const apiToken = process.env.JIRA_API_TOKEN;
if (!baseUrl || !email || !apiToken) {
console.warn('Configuration Jira incomplète - service désactivé');
return null;
}
return new JiraService({ baseUrl, email, apiToken });
}

View File

@@ -0,0 +1,563 @@
import { prisma } from './database';
import { startOfWeek, endOfWeek } from 'date-fns';
type TaskType = {
id: string;
title: string;
description?: string | null;
priority: string; // high, medium, low
completedAt?: Date | null;
createdAt: Date;
taskTags?: {
tag: {
name: string;
}
}[];
};
type CheckboxType = {
id: string;
text: string;
isChecked: boolean;
type: string; // task, meeting
date: Date;
createdAt: Date;
task?: {
id: string;
title: string;
priority: string;
taskTags?: {
tag: {
name: string;
}
}[];
} | null;
};
export interface KeyAccomplishment {
id: string;
title: string;
description?: string;
tags: string[];
impact: 'high' | 'medium' | 'low';
completedAt: Date;
relatedItems: string[]; // IDs des tâches/checkboxes liées
todosCount: number; // Nombre de todos associés
}
export interface UpcomingChallenge {
id: string;
title: string;
description?: string;
tags: string[];
priority: 'high' | 'medium' | 'low';
estimatedEffort: 'days' | 'weeks' | 'hours';
blockers: string[];
deadline?: Date;
relatedItems: string[]; // IDs des tâches/checkboxes liées
todosCount: number; // Nombre de todos associés
}
export interface ManagerSummary {
period: {
start: Date;
end: Date;
};
keyAccomplishments: KeyAccomplishment[];
upcomingChallenges: UpcomingChallenge[];
metrics: {
totalTasksCompleted: number;
totalCheckboxesCompleted: number;
highPriorityTasksCompleted: number;
meetingCheckboxesCompleted: number;
completionRate: number;
focusAreas: { [category: string]: number };
};
narrative: {
weekHighlight: string;
mainChallenges: string;
nextWeekFocus: string;
};
}
export class ManagerSummaryService {
/**
* Génère un résumé orienté manager pour la semaine
*/
static async getManagerSummary(date: Date = new Date()): Promise<ManagerSummary> {
const weekStart = startOfWeek(date, { weekStartsOn: 1 }); // Lundi
const weekEnd = endOfWeek(date, { weekStartsOn: 1 }); // Dimanche
// Récupérer les données de base
const [tasks, checkboxes] = await Promise.all([
this.getCompletedTasks(weekStart, weekEnd),
this.getCompletedCheckboxes(weekStart, weekEnd)
]);
// Analyser et extraire les accomplissements clés
const keyAccomplishments = this.extractKeyAccomplishments(tasks, checkboxes);
// Identifier les défis à venir
const upcomingChallenges = await this.identifyUpcomingChallenges();
// Calculer les métriques
const metrics = this.calculateMetrics(tasks, checkboxes);
// Générer le narratif
const narrative = this.generateNarrative(keyAccomplishments, upcomingChallenges);
return {
period: { start: weekStart, end: weekEnd },
keyAccomplishments,
upcomingChallenges,
metrics,
narrative
};
}
/**
* Récupère les tâches complétées de la semaine
*/
private static async getCompletedTasks(startDate: Date, endDate: Date) {
const tasks = await prisma.task.findMany({
where: {
OR: [
// Tâches avec completedAt dans la période (priorité)
{
completedAt: {
gte: startDate,
lte: endDate
}
},
// Tâches avec status 'done' et updatedAt dans la période
{
status: 'done',
updatedAt: {
gte: startDate,
lte: endDate
}
},
// Tâches avec status 'archived' récemment (aussi des accomplissements)
{
status: 'archived',
updatedAt: {
gte: startDate,
lte: endDate
}
}
]
},
orderBy: {
completedAt: 'desc'
},
select: {
id: true,
title: true,
description: true,
priority: true,
completedAt: true,
createdAt: true,
taskTags: {
select: {
tag: {
select: {
name: true
}
}
}
}
}
});
return tasks;
}
/**
* Récupère les checkboxes complétées de la semaine
*/
private static async getCompletedCheckboxes(startDate: Date, endDate: Date) {
const checkboxes = await prisma.dailyCheckbox.findMany({
where: {
isChecked: true,
date: {
gte: startDate,
lte: endDate
}
},
select: {
id: true,
text: true,
isChecked: true,
type: true,
date: true,
createdAt: true,
task: {
select: {
id: true,
title: true,
priority: true,
taskTags: {
select: {
tag: {
select: {
name: true
}
}
}
}
}
}
},
orderBy: {
date: 'desc'
}
});
return checkboxes;
}
/**
* Extrait les accomplissements clés basés sur la priorité
*/
private static extractKeyAccomplishments(tasks: TaskType[], checkboxes: CheckboxType[]): KeyAccomplishment[] {
const accomplishments: KeyAccomplishment[] = [];
// Tâches: prendre toutes les high/medium priority, et quelques low si significatives
tasks.forEach(task => {
const priority = task.priority.toLowerCase();
// Convertir priorité task en impact accomplissement
let impact: 'high' | 'medium' | 'low';
if (priority === 'high') {
impact = 'high';
} else if (priority === 'medium') {
impact = 'medium';
} else {
// Pour les low priority, ne garder que si c'est vraiment significatif
if (!this.isSignificantTask(task.title)) {
return;
}
impact = 'low';
}
// Compter les todos (checkboxes) associés à cette tâche
const relatedTodos = checkboxes.filter(cb => cb.task?.id === task.id);
accomplishments.push({
id: `task-${task.id}`,
title: task.title,
description: task.description || undefined,
tags: task.taskTags?.map(tt => tt.tag.name) || [],
impact,
completedAt: task.completedAt || new Date(),
relatedItems: [task.id, ...relatedTodos.map(t => t.id)],
todosCount: relatedTodos.length // Nombre réel de todos associés
});
});
// AJOUTER SEULEMENT les meetings importants standalone (non liés à une tâche)
const standaloneMeetings = checkboxes.filter(checkbox =>
checkbox.type === 'meeting' && !checkbox.task // Meetings non liés à une tâche
);
standaloneMeetings.forEach(meeting => {
accomplishments.push({
id: `meeting-${meeting.id}`,
title: `📅 ${meeting.text}`,
tags: [], // Meetings n'ont pas de tags par défaut
impact: 'medium', // Meetings sont importants
completedAt: meeting.date,
relatedItems: [meeting.id],
todosCount: 1 // Un meeting = 1 todo
});
});
// Trier par impact puis par date
return accomplishments
.sort((a, b) => {
const impactOrder = { high: 3, medium: 2, low: 1 };
if (impactOrder[a.impact] !== impactOrder[b.impact]) {
return impactOrder[b.impact] - impactOrder[a.impact];
}
return b.completedAt.getTime() - a.completedAt.getTime();
})
.slice(0, 12); // Plus d'items maintenant qu'on filtre mieux
}
/**
* Identifie les défis et enjeux à venir
*/
private static async identifyUpcomingChallenges(): Promise<UpcomingChallenge[]> {
// Récupérer les tâches à venir (priorité high/medium en premier)
const upcomingTasks = await prisma.task.findMany({
where: {
completedAt: null
},
orderBy: [
{ priority: 'asc' }, // high < medium < low
{ createdAt: 'desc' }
],
select: {
id: true,
title: true,
description: true,
priority: true,
createdAt: true,
taskTags: {
select: {
tag: {
select: {
name: true
}
}
}
}
},
take: 30
});
// Récupérer les checkboxes récurrentes non complétées (meetings + tâches prioritaires)
const upcomingCheckboxes = await prisma.dailyCheckbox.findMany({
where: {
isChecked: false,
date: {
gte: new Date()
},
OR: [
{ type: 'meeting' },
{
task: {
priority: {
in: ['high', 'medium']
}
}
}
]
},
select: {
id: true,
text: true,
isChecked: true,
type: true,
date: true,
createdAt: true,
task: {
select: {
id: true,
title: true,
priority: true,
taskTags: {
select: {
tag: {
select: {
name: true
}
}
}
}
}
}
},
orderBy: [
{ date: 'asc' },
{ createdAt: 'asc' }
],
take: 20
});
const challenges: UpcomingChallenge[] = [];
// Analyser les tâches - se baser sur la priorité réelle
upcomingTasks.forEach((task) => {
const taskPriority = task.priority.toLowerCase();
// Convertir priorité task en priorité challenge
let priority: 'high' | 'medium' | 'low';
if (taskPriority === 'high') {
priority = 'high';
} else if (taskPriority === 'medium') {
priority = 'medium';
} else {
// Pour les low priority, ne garder que si c'est vraiment challengeant
if (!this.isChallengingTask(task.title)) {
return;
}
priority = 'low';
}
const estimatedEffort = this.estimateEffort(task.title, task.description || undefined);
challenges.push({
id: `task-${task.id}`,
title: task.title,
description: task.description || undefined,
tags: task.taskTags?.map(tt => tt.tag.name) || [],
priority,
estimatedEffort,
blockers: this.identifyBlockers(task.title, task.description || undefined),
relatedItems: [task.id],
todosCount: 0 // TODO: compter les todos associés à cette tâche
});
});
// Ajouter les meetings importants comme challenges
upcomingCheckboxes.forEach(checkbox => {
if (checkbox.type === 'meeting') {
challenges.push({
id: `checkbox-${checkbox.id}`,
title: checkbox.text,
tags: checkbox.task?.taskTags?.map(tt => tt.tag.name) || [],
priority: 'medium', // Meetings sont medium par défaut
estimatedEffort: 'hours',
blockers: [],
relatedItems: [checkbox.id],
todosCount: 1 // Une checkbox = 1 todo
});
}
});
return challenges
.sort((a, b) => {
const priorityOrder = { high: 3, medium: 2, low: 1 };
return priorityOrder[b.priority] - priorityOrder[a.priority];
})
.slice(0, 10); // Plus d'items maintenant qu'on filtre mieux
}
/**
* Estime l'effort requis
*/
private static estimateEffort(title: string, description?: string): 'days' | 'weeks' | 'hours' {
const content = `${title} ${description || ''}`.toLowerCase();
if (content.includes('architecture') || content.includes('migration') || content.includes('refactor')) {
return 'weeks';
}
if (content.includes('feature') || content.includes('implement') || content.includes('integration')) {
return 'days';
}
return 'hours';
}
/**
* Identifie les blockers potentiels
*/
private static identifyBlockers(title: string, description?: string): string[] {
const content = `${title} ${description || ''}`.toLowerCase();
const blockers: string[] = [];
if (content.includes('depends') || content.includes('waiting')) {
blockers.push('Dépendances externes');
}
if (content.includes('approval') || content.includes('review')) {
blockers.push('Validation requise');
}
if (content.includes('design') && !content.includes('implement')) {
blockers.push('Spécifications incomplètes');
}
return blockers;
}
/**
* Détermine si une tâche est significative
*/
private static isSignificantTask(title: string): boolean {
const significantKeywords = [
'release', 'deploy', 'launch', 'milestone',
'architecture', 'design', 'strategy',
'integration', 'migration', 'optimization'
];
return significantKeywords.some(keyword => title.toLowerCase().includes(keyword));
}
/**
* Détermine si une checkbox est significative
*/
private static isSignificantCheckbox(text: string): boolean {
const content = text.toLowerCase();
return content.length > 30 || // Checkboxes détaillées
content.includes('meeting') ||
content.includes('review') ||
content.includes('call') ||
content.includes('presentation');
}
/**
* Détermine si une tâche représente un défi
*/
private static isChallengingTask(title: string): boolean {
const challengingKeywords = [
'complex', 'difficult', 'challenge',
'architecture', 'performance', 'security',
'integration', 'migration', 'optimization'
];
return challengingKeywords.some(keyword => title.toLowerCase().includes(keyword));
}
/**
* Analyse les patterns dans les checkboxes pour identifier des enjeux
*/
private static analyzeCheckboxPatterns(): UpcomingChallenge[] {
// Pour l'instant, retourner un array vide
// À implémenter selon les besoins spécifiques
return [];
}
/**
* Calcule les métriques résumées
*/
private static calculateMetrics(tasks: TaskType[], checkboxes: CheckboxType[]) {
const totalTasksCompleted = tasks.length;
const totalCheckboxesCompleted = checkboxes.length;
// Calculer les métriques détaillées
const highPriorityTasksCompleted = tasks.filter(t => t.priority.toLowerCase() === 'high').length;
const meetingCheckboxesCompleted = checkboxes.filter(c => c.type === 'meeting').length;
// Analyser la répartition par catégorie
const focusAreas: { [category: string]: number } = {};
return {
totalTasksCompleted,
totalCheckboxesCompleted,
highPriorityTasksCompleted,
meetingCheckboxesCompleted,
completionRate: 0, // À calculer par rapport aux objectifs
focusAreas
};
}
/**
* Génère le narratif pour le manager
*/
private static generateNarrative(
accomplishments: KeyAccomplishment[],
challenges: UpcomingChallenge[]
) {
// Points forts de la semaine
const topAccomplishments = accomplishments.slice(0, 3);
const weekHighlight = topAccomplishments.length > 0
? `Cette semaine, j'ai principalement progressé sur ${topAccomplishments.map(a => a.title).join(', ')}.`
: 'Semaine focalisée sur l\'exécution des tâches quotidiennes.';
// Défis rencontrés
const highImpactItems = accomplishments.filter(a => a.impact === 'high');
const mainChallenges = highImpactItems.length > 0
? `Les principaux enjeux traités ont été liés aux ${[...new Set(highImpactItems.flatMap(a => a.tags))].join(', ')}.`
: 'Pas de blockers majeurs rencontrés cette semaine.';
// Focus semaine prochaine
const topChallenges = challenges.slice(0, 3);
const nextWeekFocus = topChallenges.length > 0
? `La semaine prochaine sera concentrée sur ${topChallenges.map(c => c.title).join(', ')}.`
: 'Continuation du travail en cours selon les priorités établies.';
return {
weekHighlight,
mainChallenges,
nextWeekFocus
};
}
}

362
src/services/metrics.ts Normal file
View File

@@ -0,0 +1,362 @@
import { prisma } from './database';
import { startOfWeek, endOfWeek, eachDayOfInterval, format, startOfDay, endOfDay } from 'date-fns';
import { fr } from 'date-fns/locale';
export interface DailyMetrics {
date: string; // Format ISO
dayName: string; // Lundi, Mardi, etc.
completed: number;
inProgress: number;
blocked: number;
pending: number;
newTasks: number;
totalTasks: number;
completionRate: number;
}
export interface VelocityTrend {
date: string;
completed: number;
created: number;
velocity: number;
}
export interface WeeklyMetricsOverview {
period: {
start: Date;
end: Date;
};
dailyBreakdown: DailyMetrics[];
summary: {
totalTasksCompleted: number;
totalTasksCreated: number;
averageCompletionRate: number;
peakProductivityDay: string;
lowProductivityDay: string;
trendsAnalysis: {
completionTrend: 'improving' | 'declining' | 'stable';
productivityPattern: 'consistent' | 'variable' | 'weekend-heavy';
};
};
statusDistribution: {
status: string;
count: number;
percentage: number;
color: string;
}[];
priorityBreakdown: {
priority: string;
completed: number;
pending: number;
total: number;
completionRate: number;
color: string;
}[];
}
export class MetricsService {
/**
* Récupère les métriques journalières de la semaine
*/
static async getWeeklyMetrics(date: Date = new Date()): Promise<WeeklyMetricsOverview> {
const weekStart = startOfWeek(date, { weekStartsOn: 1 }); // Lundi
const weekEnd = endOfWeek(date, { weekStartsOn: 1 }); // Dimanche
// Générer tous les jours de la semaine
const daysOfWeek = eachDayOfInterval({ start: weekStart, end: weekEnd });
// Récupérer les données pour chaque jour
const dailyBreakdown = await Promise.all(
daysOfWeek.map(day => this.getDailyMetrics(day))
);
// Calculer les métriques de résumé
const summary = this.calculateWeeklySummary(dailyBreakdown);
// Récupérer la distribution des statuts pour la semaine
const statusDistribution = await this.getStatusDistribution(weekStart, weekEnd);
// Récupérer la répartition par priorité
const priorityBreakdown = await this.getPriorityBreakdown(weekStart, weekEnd);
return {
period: { start: weekStart, end: weekEnd },
dailyBreakdown,
summary,
statusDistribution,
priorityBreakdown
};
}
/**
* Récupère les métriques pour un jour donné
*/
private static async getDailyMetrics(date: Date): Promise<DailyMetrics> {
const dayStart = startOfDay(date);
const dayEnd = endOfDay(date);
// Compter les tâches par statut à la fin de la journée
const [completed, inProgress, blocked, pending, newTasks, totalTasks] = await Promise.all([
// Tâches complétées ce jour
prisma.task.count({
where: {
OR: [
{
completedAt: {
gte: dayStart,
lte: dayEnd
}
},
{
status: 'done',
updatedAt: {
gte: dayStart,
lte: dayEnd
}
}
]
}
}),
// Tâches en cours (status = in_progress à ce moment)
prisma.task.count({
where: {
status: 'in_progress',
createdAt: { lte: dayEnd }
}
}),
// Tâches bloquées
prisma.task.count({
where: {
status: 'blocked',
createdAt: { lte: dayEnd }
}
}),
// Tâches en attente
prisma.task.count({
where: {
status: 'pending',
createdAt: { lte: dayEnd }
}
}),
// Nouvelles tâches créées ce jour
prisma.task.count({
where: {
createdAt: {
gte: dayStart,
lte: dayEnd
}
}
}),
// Total des tâches existantes ce jour
prisma.task.count({
where: {
createdAt: { lte: dayEnd }
}
})
]);
const completionRate = totalTasks > 0 ? (completed / totalTasks) * 100 : 0;
return {
date: date.toISOString(),
dayName: format(date, 'EEEE', { locale: fr }),
completed,
inProgress,
blocked,
pending,
newTasks,
totalTasks,
completionRate: Math.round(completionRate * 100) / 100
};
}
/**
* Calcule le résumé hebdomadaire
*/
private static calculateWeeklySummary(dailyBreakdown: DailyMetrics[]) {
const totalTasksCompleted = dailyBreakdown.reduce((sum, day) => sum + day.completed, 0);
const totalTasksCreated = dailyBreakdown.reduce((sum, day) => sum + day.newTasks, 0);
const averageCompletionRate = dailyBreakdown.reduce((sum, day) => sum + day.completionRate, 0) / dailyBreakdown.length;
// Identifier les jours de pic et de creux
const peakDay = dailyBreakdown.reduce((peak, day) =>
day.completed > peak.completed ? day : peak
);
const lowDay = dailyBreakdown.reduce((low, day) =>
day.completed < low.completed ? day : low
);
// Analyser les tendances
const firstHalf = dailyBreakdown.slice(0, 3);
const secondHalf = dailyBreakdown.slice(4);
const firstHalfAvg = firstHalf.reduce((sum, day) => sum + day.completed, 0) / firstHalf.length;
const secondHalfAvg = secondHalf.reduce((sum, day) => sum + day.completed, 0) / secondHalf.length;
let completionTrend: 'improving' | 'declining' | 'stable';
if (secondHalfAvg > firstHalfAvg * 1.1) {
completionTrend = 'improving';
} else if (secondHalfAvg < firstHalfAvg * 0.9) {
completionTrend = 'declining';
} else {
completionTrend = 'stable';
}
// Analyser le pattern de productivité
const weekendDays = dailyBreakdown.slice(5); // Samedi et dimanche
const weekdayDays = dailyBreakdown.slice(0, 5);
const weekendAvg = weekendDays.reduce((sum, day) => sum + day.completed, 0) / weekendDays.length;
const weekdayAvg = weekdayDays.reduce((sum, day) => sum + day.completed, 0) / weekdayDays.length;
let productivityPattern: 'consistent' | 'variable' | 'weekend-heavy';
if (weekendAvg > weekdayAvg * 1.2) {
productivityPattern = 'weekend-heavy';
} else {
const variance = dailyBreakdown.reduce((sum, day) => {
const diff = day.completed - (totalTasksCompleted / dailyBreakdown.length);
return sum + diff * diff;
}, 0) / dailyBreakdown.length;
productivityPattern = variance > 4 ? 'variable' : 'consistent';
}
return {
totalTasksCompleted,
totalTasksCreated,
averageCompletionRate: Math.round(averageCompletionRate * 100) / 100,
peakProductivityDay: peakDay.dayName,
lowProductivityDay: lowDay.dayName,
trendsAnalysis: {
completionTrend,
productivityPattern
}
};
}
/**
* Récupère la distribution des statuts pour la période
*/
private static async getStatusDistribution(start: Date, end: Date) {
const statusCounts = await prisma.task.groupBy({
by: ['status'],
_count: {
status: true
},
where: {
createdAt: {
gte: start,
lte: end
}
}
});
const total = statusCounts.reduce((sum, item) => sum + item._count.status, 0);
const statusColors: { [key: string]: string } = {
pending: '#94a3b8', // gray
in_progress: '#3b82f6', // blue
blocked: '#ef4444', // red
done: '#10b981', // green
archived: '#6b7280' // gray-500
};
return statusCounts.map(item => ({
status: item.status,
count: item._count.status,
percentage: Math.round((item._count.status / total) * 100 * 100) / 100,
color: statusColors[item.status] || '#6b7280'
}));
}
/**
* Récupère la répartition par priorité avec taux de completion
*/
private static async getPriorityBreakdown(start: Date, end: Date) {
const priorities = ['high', 'medium', 'low'];
const priorityData = await Promise.all(
priorities.map(async (priority) => {
const [completed, total] = await Promise.all([
prisma.task.count({
where: {
priority,
completedAt: {
gte: start,
lte: end
}
}
}),
prisma.task.count({
where: {
priority,
createdAt: {
gte: start,
lte: end
}
}
})
]);
const pending = total - completed;
const completionRate = total > 0 ? (completed / total) * 100 : 0;
return {
priority,
completed,
pending,
total,
completionRate: Math.round(completionRate * 100) / 100,
color: priority === 'high' ? '#ef4444' :
priority === 'medium' ? '#f59e0b' : '#10b981'
};
})
);
return priorityData;
}
/**
* Récupère les métriques de vélocité d'équipe (pour graphiques de tendance)
*/
static async getVelocityTrends(weeksBack: number = 4): Promise<VelocityTrend[]> {
const trends = [];
for (let i = weeksBack - 1; i >= 0; i--) {
const weekStart = startOfWeek(new Date(Date.now() - i * 7 * 24 * 60 * 60 * 1000), { weekStartsOn: 1 });
const weekEnd = endOfWeek(weekStart, { weekStartsOn: 1 });
const [completed, created] = await Promise.all([
prisma.task.count({
where: {
completedAt: {
gte: weekStart,
lte: weekEnd
}
}
}),
prisma.task.count({
where: {
createdAt: {
gte: weekStart,
lte: weekEnd
}
}
})
]);
const velocity = created > 0 ? (completed / created) * 100 : 0;
trends.push({
date: format(weekStart, 'dd/MM', { locale: fr }),
completed,
created,
velocity: Math.round(velocity * 100) / 100
});
}
return trends;
}
}

191
src/services/system-info.ts Normal file
View File

@@ -0,0 +1,191 @@
import { prisma } from './database';
import { readFile } from 'fs/promises';
import { join } from 'path';
export interface SystemInfo {
version: string;
environment: string;
database: {
totalTasks: number;
totalUsers: number;
totalBackups: number;
databaseSize: string;
};
uptime: string;
lastUpdate: string;
}
export class SystemInfoService {
/**
* Récupère les informations système complètes
*/
static async getSystemInfo(): Promise<SystemInfo> {
try {
const [packageInfo, dbStats] = await Promise.all([
this.getPackageInfo(),
this.getDatabaseStats()
]);
return {
version: packageInfo.version,
environment: process.env.NODE_ENV || 'development',
database: dbStats,
uptime: this.getUptime(),
lastUpdate: this.getLastUpdate()
};
} catch (error) {
console.error('Error getting system info:', error);
throw new Error('Failed to get system information');
}
}
/**
* Lit les informations du package.json
*/
private static async getPackageInfo(): Promise<{ version: string; name: string }> {
try {
const packagePath = join(process.cwd(), 'package.json');
const packageContent = await readFile(packagePath, 'utf-8');
const packageJson = JSON.parse(packageContent);
return {
name: packageJson.name || 'TowerControl',
version: packageJson.version || '1.0.0'
};
} catch (error) {
console.error('Error reading package.json:', error);
return {
name: 'TowerControl',
version: '1.0.0'
};
}
}
/**
* Récupère les statistiques de la base de données
*/
private static async getDatabaseStats() {
try {
const [totalTasks, totalUsers, totalBackups] = await Promise.all([
prisma.task.count(),
prisma.userPreferences.count(),
// Pour les backups, on compte les fichiers via le service backup
this.getBackupCount()
]);
return {
totalTasks,
totalUsers,
totalBackups,
databaseSize: await this.getDatabaseSize()
};
} catch (error) {
console.error('Error getting database stats:', error);
return {
totalTasks: 0,
totalUsers: 0,
totalBackups: 0,
databaseSize: 'N/A'
};
}
}
/**
* Compte le nombre de sauvegardes
*/
private static async getBackupCount(): Promise<number> {
try {
// Import dynamique pour éviter les dépendances circulaires
const { backupService } = await import('./backup');
const backups = await backupService.listBackups();
return backups.length;
} catch (error) {
console.error('Error counting backups:', error);
return 0;
}
}
/**
* Estime la taille de la base de données
*/
private static async getDatabaseSize(): Promise<string> {
try {
const { stat } = await import('fs/promises');
const { resolve } = await import('path');
// Utiliser la même logique que le service de backup pour trouver la DB
let dbPath: string;
if (process.env.BACKUP_DATABASE_PATH) {
dbPath = resolve(process.cwd(), process.env.BACKUP_DATABASE_PATH);
} else if (process.env.DATABASE_URL) {
dbPath = resolve(process.env.DATABASE_URL.replace('file:', ''));
} else {
dbPath = resolve(process.cwd(), 'prisma', 'dev.db');
}
const stats = await stat(dbPath);
// Convertir en format lisible
const bytes = stats.size;
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
} catch (error) {
console.error('Error getting database size:', error);
return 'N/A';
}
}
/**
* Calcule l'uptime du processus
*/
private static getUptime(): string {
const uptime = process.uptime();
const hours = Math.floor(uptime / 3600);
const minutes = Math.floor((uptime % 3600) / 60);
const seconds = Math.floor(uptime % 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
} else if (minutes > 0) {
return `${minutes}m ${seconds}s`;
} else {
return `${seconds}s`;
}
}
/**
* Retourne une date de dernière mise à jour fictive
* (dans un vrai projet, cela viendrait d'un système de déploiement)
*/
private static getLastUpdate(): string {
// Pour l'instant, on utilise la date de modification du package.json
try {
const fs = require('fs'); // eslint-disable-line @typescript-eslint/no-require-imports
const packagePath = join(process.cwd(), 'package.json');
const stats = fs.statSync(packagePath);
const now = new Date();
const lastModified = new Date(stats.mtime);
const diffTime = Math.abs(now.getTime() - lastModified.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 1) {
return 'Il y a 1 jour';
} else if (diffDays < 7) {
return `Il y a ${diffDays} jours`;
} else if (diffDays < 30) {
const weeks = Math.floor(diffDays / 7);
return `Il y a ${weeks} semaine${weeks > 1 ? 's' : ''}`;
} else {
const months = Math.floor(diffDays / 30);
return `Il y a ${months} mois`;
}
} catch {
return 'Il y a 2 jours';
}
}
}

245
src/services/tags.ts Normal file
View File

@@ -0,0 +1,245 @@
import { prisma } from './database';
import { Prisma } from '@prisma/client';
import { Tag } from '@/lib/types';
/**
* Service pour la gestion des tags
*/
export const tagsService = {
/**
* Récupère tous les tags avec leur nombre d'utilisations
*/
async getTags(): Promise<(Tag & { usage: number })[]> {
const tags = await prisma.tag.findMany({
include: {
_count: {
select: {
taskTags: true
}
}
},
orderBy: { name: 'asc' }
});
return tags.map(tag => ({
id: tag.id,
name: tag.name,
color: tag.color,
isPinned: tag.isPinned,
usage: tag._count.taskTags
}));
},
/**
* Récupère un tag par son ID
*/
async getTagById(id: string): Promise<Tag | null> {
const tag = await prisma.tag.findUnique({
where: { id }
});
if (!tag) return null;
return {
id: tag.id,
name: tag.name,
color: tag.color,
isPinned: tag.isPinned
};
},
/**
* Récupère un tag par son nom
*/
async getTagByName(name: string): Promise<Tag | null> {
const tag = await prisma.tag.findFirst({
where: {
name: {
equals: name
}
}
});
if (!tag) return null;
return {
id: tag.id,
name: tag.name,
color: tag.color,
isPinned: tag.isPinned
};
},
/**
* Crée un nouveau tag
*/
async createTag(data: { name: string; color: string; isPinned?: boolean }): Promise<Tag> {
// Vérifier si le tag existe déjà
const existing = await this.getTagByName(data.name);
if (existing) {
throw new Error(`Un tag avec le nom "${data.name}" existe déjà`);
}
const tag = await prisma.tag.create({
data: {
name: data.name.trim(),
color: data.color,
isPinned: data.isPinned || false
}
});
return {
id: tag.id,
name: tag.name,
color: tag.color,
isPinned: tag.isPinned
};
},
/**
* Met à jour un tag
*/
async updateTag(id: string, data: { name?: string; color?: string; isPinned?: boolean }): Promise<Tag | null> {
// Vérifier que le tag existe
const existing = await this.getTagById(id);
if (!existing) {
throw new Error(`Tag avec l'ID "${id}" non trouvé`);
}
// Si on change le nom, vérifier qu'il n'existe pas déjà
if (data.name && data.name !== existing.name) {
const nameExists = await this.getTagByName(data.name);
if (nameExists && nameExists.id !== id) {
throw new Error(`Un tag avec le nom "${data.name}" existe déjà`);
}
}
const updateData: Prisma.TagUpdateInput = {};
if (data.name !== undefined) {
updateData.name = data.name.trim();
}
if (data.color !== undefined) {
updateData.color = data.color;
}
if (data.isPinned !== undefined) {
updateData.isPinned = data.isPinned;
}
if (Object.keys(updateData).length === 0) {
return existing;
}
const tag = await prisma.tag.update({
where: { id },
data: updateData
});
return {
id: tag.id,
name: tag.name,
color: tag.color,
isPinned: tag.isPinned
};
},
/**
* Supprime un tag et toutes ses relations
*/
async deleteTag(id: string): Promise<void> {
// Vérifier que le tag existe
const existing = await this.getTagById(id);
if (!existing) {
throw new Error(`Tag avec l'ID "${id}" non trouvé`);
}
// Supprimer d'abord toutes les relations TaskTag
await prisma.taskTag.deleteMany({
where: { tagId: id }
});
// Puis supprimer le tag lui-même
await prisma.tag.delete({
where: { id }
});
},
/**
* Récupère les tags les plus utilisés
*/
async getPopularTags(limit: number = 10): Promise<Array<Tag & { usage: number }>> {
// Utiliser une requête SQL brute pour compter les usages
const tagsWithUsage = await prisma.$queryRaw<Array<{
id: string;
name: string;
color: string;
usage: number;
}>>`
SELECT t.id, t.name, t.color, COUNT(tt.tagId) as usage
FROM tags t
LEFT JOIN task_tags tt ON t.id = tt.tagId
GROUP BY t.id, t.name, t.color
ORDER BY usage DESC, t.name ASC
LIMIT ${limit}
`;
return tagsWithUsage.map(tag => ({
id: tag.id,
name: tag.name,
color: tag.color,
usage: Number(tag.usage)
}));
},
/**
* Recherche des tags par nom (pour autocomplete)
*/
async searchTags(query: string, limit: number = 10): Promise<Tag[]> {
const tags = await prisma.tag.findMany({
where: {
name: {
contains: query
}
},
orderBy: { name: 'asc' },
take: limit
});
return tags.map(tag => ({
id: tag.id,
name: tag.name,
color: tag.color,
isPinned: tag.isPinned
}));
},
/**
* Crée automatiquement des tags manquants à partir d'une liste de noms
*/
async ensureTagsExist(tagNames: string[]): Promise<Tag[]> {
const results: Tag[] = [];
for (const name of tagNames) {
if (!name.trim()) continue;
let tag = await this.getTagByName(name.trim());
if (!tag) {
// Générer une couleur aléatoirement
const colors = [
'#3B82F6', '#EF4444', '#10B981', '#F59E0B',
'#8B5CF6', '#EC4899', '#06B6D4', '#84CC16'
];
const randomColor = colors[Math.floor(Math.random() * colors.length)];
tag = await this.createTag({
name: name.trim(),
color: randomColor
});
}
results.push(tag);
}
return results;
}
};

View File

@@ -0,0 +1,185 @@
export interface PredefinedCategory {
name: string;
color: string;
keywords: string[];
icon: string;
}
export const PREDEFINED_CATEGORIES: PredefinedCategory[] = [
{
name: 'Dev',
color: '#3b82f6', // Blue
icon: '💻',
keywords: [
'code', 'coding', 'development', 'develop', 'dev', 'programming', 'program',
'bug', 'fix', 'debug', 'feature', 'implement', 'refactor', 'review',
'api', 'database', 'db', 'frontend', 'backend', 'ui', 'ux',
'component', 'service', 'function', 'method', 'class',
'git', 'commit', 'merge', 'pull request', 'pr', 'deploy', 'deployment',
'test', 'testing', 'unit test', 'integration'
]
},
{
name: 'Meeting',
color: '#8b5cf6', // Purple
icon: '🤝',
keywords: [
'meeting', 'réunion', 'call', 'standup', 'daily', 'retrospective', 'retro',
'planning', 'demo', 'presentation', 'sync', 'catch up', 'catchup',
'interview', 'discussion', 'brainstorm', 'workshop', 'session',
'one on one', '1on1', 'review meeting', 'sprint planning'
]
},
{
name: 'Admin',
color: '#6b7280', // Gray
icon: '📋',
keywords: [
'admin', 'administration', 'paperwork', 'documentation', 'doc', 'docs',
'report', 'reporting', 'timesheet', 'expense', 'invoice',
'email', 'mail', 'communication', 'update', 'status',
'config', 'configuration', 'setup', 'installation', 'maintenance',
'backup', 'security', 'permission', 'user management'
]
},
{
name: 'Learning',
color: '#10b981', // Green
icon: '📚',
keywords: [
'learning', 'learn', 'study', 'training', 'course', 'tutorial',
'research', 'reading', 'documentation', 'knowledge', 'skill',
'certification', 'workshop', 'seminar', 'conference',
'practice', 'exercise', 'experiment', 'exploration', 'investigate'
]
}
];
export class TaskCategorizationService {
/**
* Suggère une catégorie basée sur le titre et la description d'une tâche
*/
static suggestCategory(title: string, description?: string): PredefinedCategory | null {
const text = `${title} ${description || ''}`.toLowerCase();
// Compte les matches pour chaque catégorie
const categoryScores = PREDEFINED_CATEGORIES.map(category => {
const matches = category.keywords.filter(keyword =>
text.includes(keyword.toLowerCase())
).length;
return {
category,
score: matches
};
});
// Trouve la meilleure catégorie
const bestMatch = categoryScores.reduce((best, current) =>
current.score > best.score ? current : best
);
// Retourne la catégorie seulement s'il y a au moins un match
return bestMatch.score > 0 ? bestMatch.category : null;
}
/**
* Suggère plusieurs catégories avec leur score de confiance
*/
static suggestCategoriesWithScore(title: string, description?: string): Array<{
category: PredefinedCategory;
score: number;
confidence: number;
}> {
const text = `${title} ${description || ''}`.toLowerCase();
const categoryScores = PREDEFINED_CATEGORIES.map(category => {
const matches = category.keywords.filter(keyword =>
text.includes(keyword.toLowerCase())
);
const score = matches.length;
const confidence = Math.min((score / 3) * 100, 100); // Max 100% de confiance avec 3+ mots-clés
return {
category,
score,
confidence
};
});
return categoryScores
.filter(item => item.score > 0)
.sort((a, b) => b.score - a.score);
}
/**
* Analyse les activités et retourne la répartition par catégorie
*/
static analyzeActivitiesByCategory(activities: Array<{ title: string; description?: string }>): {
[categoryName: string]: {
count: number;
percentage: number;
color: string;
icon: string;
}
} {
const categoryCounts: { [key: string]: number } = {};
const uncategorized = { count: 0 };
// Initialiser les compteurs
PREDEFINED_CATEGORIES.forEach(cat => {
categoryCounts[cat.name] = 0;
});
// Analyser chaque activité
activities.forEach(activity => {
const suggestedCategory = this.suggestCategory(activity.title, activity.description);
if (suggestedCategory) {
categoryCounts[suggestedCategory.name]++;
} else {
uncategorized.count++;
}
});
const total = activities.length;
const result: { [categoryName: string]: { count: number; percentage: number; color: string; icon: string } } = {};
// Ajouter les catégories prédéfinies
PREDEFINED_CATEGORIES.forEach(category => {
const count = categoryCounts[category.name];
result[category.name] = {
count,
percentage: total > 0 ? (count / total) * 100 : 0,
color: category.color,
icon: category.icon
};
});
// Ajouter "Autre" si nécessaire
if (uncategorized.count > 0) {
result['Autre'] = {
count: uncategorized.count,
percentage: total > 0 ? (uncategorized.count / total) * 100 : 0,
color: '#d1d5db',
icon: '❓'
};
}
return result;
}
/**
* Retourne les tags suggérés pour une tâche
*/
static getSuggestedTags(title: string, description?: string): string[] {
const suggestions = this.suggestCategoriesWithScore(title, description);
return suggestions
.filter(s => s.confidence >= 30) // Seulement les suggestions avec 30%+ de confiance
.slice(0, 2) // Maximum 2 suggestions
.map(s => s.category.name);
}
}

373
src/services/tasks.ts Normal file
View File

@@ -0,0 +1,373 @@
import { prisma } from './database';
import { Task, TaskStatus, TaskPriority, TaskSource, BusinessError, DailyCheckbox, DailyCheckboxType } from '@/lib/types';
import { Prisma } from '@prisma/client';
/**
* Service pour la gestion des tâches (version standalone)
*/
export class TasksService {
/**
* Récupère toutes les tâches avec filtres optionnels
*/
async getTasks(filters?: {
status?: TaskStatus[];
search?: string;
limit?: number;
offset?: number;
}): Promise<Task[]> {
const where: Prisma.TaskWhereInput = {};
if (filters?.status) {
where.status = { in: filters.status };
}
if (filters?.search) {
where.OR = [
{ title: { contains: filters.search } },
{ description: { contains: filters.search } }
];
}
const tasks = await prisma.task.findMany({
where,
include: {
taskTags: {
include: {
tag: true
}
}
},
take: filters?.limit, // Pas de limite par défaut - récupère toutes les tâches
skip: filters?.offset || 0,
orderBy: [
{ completedAt: 'desc' },
{ dueDate: 'asc' },
{ createdAt: 'desc' }
]
});
return tasks.map(this.mapPrismaTaskToTask);
}
/**
* Crée une nouvelle tâche
*/
async createTask(taskData: {
title: string;
description?: string;
status?: TaskStatus;
priority?: TaskPriority;
tags?: string[];
dueDate?: Date;
}): Promise<Task> {
const task = await prisma.task.create({
data: {
title: taskData.title,
description: taskData.description,
status: taskData.status || 'todo',
priority: taskData.priority || 'medium',
dueDate: taskData.dueDate,
source: 'manual', // Source manuelle
sourceId: `manual-${Date.now()}` // ID unique
},
include: {
taskTags: {
include: {
tag: true
}
}
}
});
// Créer les relations avec les tags
if (taskData.tags && taskData.tags.length > 0) {
await this.createTaskTagRelations(task.id, taskData.tags);
}
// Récupérer la tâche avec les tags pour le retour
const taskWithTags = await prisma.task.findUnique({
where: { id: task.id },
include: {
taskTags: {
include: {
tag: true
}
}
}
});
return this.mapPrismaTaskToTask(taskWithTags!);
}
/**
* Met à jour une tâche
*/
async updateTask(taskId: string, updates: {
title?: string;
description?: string;
status?: TaskStatus;
priority?: TaskPriority;
tags?: string[];
dueDate?: Date;
}): Promise<Task> {
const task = await prisma.task.findUnique({
where: { id: taskId }
});
if (!task) {
throw new BusinessError(`Tâche ${taskId} introuvable`);
}
// Logique métier : si on marque comme terminé, on ajoute la date
const updateData: Prisma.TaskUpdateInput = {
title: updates.title,
description: updates.description,
status: updates.status,
priority: updates.priority,
dueDate: updates.dueDate,
updatedAt: new Date()
};
if (updates.status === 'done' && !task.completedAt) {
updateData.completedAt = new Date();
} else if (updates.status && updates.status !== 'done' && task.completedAt) {
updateData.completedAt = null;
}
await prisma.task.update({
where: { id: taskId },
data: updateData
});
// Mettre à jour les relations avec les tags
if (updates.tags !== undefined) {
await this.updateTaskTagRelations(taskId, updates.tags);
}
// Récupérer la tâche avec les tags pour le retour
const taskWithTags = await prisma.task.findUnique({
where: { id: taskId },
include: {
taskTags: {
include: {
tag: true
}
}
}
});
return this.mapPrismaTaskToTask(taskWithTags!);
}
/**
* Supprime une tâche
*/
async deleteTask(taskId: string): Promise<void> {
const task = await prisma.task.findUnique({
where: { id: taskId }
});
if (!task) {
throw new BusinessError(`Tâche ${taskId} introuvable`);
}
await prisma.task.delete({
where: { id: taskId }
});
}
/**
* Met à jour le statut d'une tâche
*/
async updateTaskStatus(taskId: string, newStatus: TaskStatus): Promise<Task> {
return this.updateTask(taskId, { status: newStatus });
}
/**
* Récupère les daily checkboxes liées à une tâche
*/
async getTaskRelatedCheckboxes(taskId: string): Promise<DailyCheckbox[]> {
const checkboxes = await prisma.dailyCheckbox.findMany({
where: { taskId: taskId },
include: { task: true },
orderBy: [
{ date: 'desc' },
{ order: 'asc' }
]
});
return checkboxes.map(checkbox => ({
id: checkbox.id,
date: checkbox.date,
text: checkbox.text,
isChecked: checkbox.isChecked,
type: checkbox.type as DailyCheckboxType,
order: checkbox.order,
taskId: checkbox.taskId ?? undefined,
task: checkbox.task ? {
id: checkbox.task.id,
title: checkbox.task.title,
description: checkbox.task.description ?? undefined,
status: checkbox.task.status as TaskStatus,
priority: checkbox.task.priority as TaskPriority,
source: checkbox.task.source as TaskSource,
sourceId: checkbox.task.sourceId ?? undefined,
tags: [], // Les tags ne sont pas nécessaires dans ce contexte
dueDate: checkbox.task.dueDate ?? undefined,
completedAt: checkbox.task.completedAt ?? undefined,
createdAt: checkbox.task.createdAt,
updatedAt: checkbox.task.updatedAt,
jiraProject: checkbox.task.jiraProject ?? undefined,
jiraKey: checkbox.task.jiraKey ?? undefined,
jiraType: checkbox.task.jiraType ?? undefined,
assignee: checkbox.task.assignee ?? undefined
} : undefined,
createdAt: checkbox.createdAt,
updatedAt: checkbox.updatedAt
}));
}
/**
* Récupère les statistiques des tâches
*/
async getTaskStats() {
const [total, completed, inProgress, todo, backlog, cancelled, freeze, archived] = await Promise.all([
prisma.task.count(),
prisma.task.count({ where: { status: 'done' } }),
prisma.task.count({ where: { status: 'in_progress' } }),
prisma.task.count({ where: { status: 'todo' } }),
prisma.task.count({ where: { status: 'backlog' } }),
prisma.task.count({ where: { status: 'cancelled' } }),
prisma.task.count({ where: { status: 'freeze' } }),
prisma.task.count({ where: { status: 'archived' } })
]);
return {
total,
completed,
inProgress,
todo,
backlog,
cancelled,
freeze,
archived,
completionRate: total > 0 ? Math.round((completed / total) * 100) : 0
};
}
/**
* Crée les relations TaskTag pour une tâche
*/
private async createTaskTagRelations(taskId: string, tagNames: string[]): Promise<void> {
for (const tagName of tagNames) {
try {
// Créer ou récupérer le tag
const tag = await prisma.tag.upsert({
where: { name: tagName },
update: {}, // Pas de mise à jour nécessaire
create: {
name: tagName,
color: this.generateTagColor(tagName)
}
});
// Créer la relation TaskTag si elle n'existe pas
await prisma.taskTag.upsert({
where: {
taskId_tagId: {
taskId: taskId,
tagId: tag.id
}
},
update: {}, // Pas de mise à jour nécessaire
create: {
taskId: taskId,
tagId: tag.id
}
});
} catch (error) {
console.error(`Erreur lors de la création de la relation tag ${tagName}:`, error);
}
}
}
/**
* Met à jour les relations TaskTag pour une tâche
*/
private async updateTaskTagRelations(taskId: string, tagNames: string[]): Promise<void> {
// Supprimer toutes les relations existantes
await prisma.taskTag.deleteMany({
where: { taskId: taskId }
});
// Créer les nouvelles relations
if (tagNames.length > 0) {
await this.createTaskTagRelations(taskId, tagNames);
}
}
/**
* Génère une couleur pour un tag basée sur son nom
*/
private generateTagColor(tagName: string): string {
const colors = [
'#ef4444', '#f97316', '#f59e0b', '#eab308',
'#84cc16', '#22c55e', '#10b981', '#14b8a6',
'#06b6d4', '#0ea5e9', '#3b82f6', '#6366f1',
'#8b5cf6', '#a855f7', '#d946ef', '#ec4899'
];
// Hash simple du nom pour choisir une couleur
let hash = 0;
for (let i = 0; i < tagName.length; i++) {
hash = tagName.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
}
/**
* Convertit une tâche Prisma en objet Task
*/
private mapPrismaTaskToTask(prismaTask: Prisma.TaskGetPayload<{
include: {
taskTags: {
include: {
tag: true
}
}
}
}> | Prisma.TaskGetPayload<object>): Task {
// Extraire les tags depuis les relations TaskTag ou fallback sur tagsJson
let tags: string[] = [];
if ('taskTags' in prismaTask && prismaTask.taskTags && Array.isArray(prismaTask.taskTags)) {
// Utiliser les relations Prisma
tags = prismaTask.taskTags.map((tt) => tt.tag.name);
}
return {
id: prismaTask.id,
title: prismaTask.title,
description: prismaTask.description ?? undefined,
status: prismaTask.status as TaskStatus,
priority: prismaTask.priority as TaskPriority,
source: prismaTask.source as TaskSource,
sourceId: prismaTask.sourceId ?? undefined,
tags: tags,
dueDate: prismaTask.dueDate ?? undefined,
completedAt: prismaTask.completedAt ?? undefined,
createdAt: prismaTask.createdAt,
updatedAt: prismaTask.updatedAt,
jiraProject: prismaTask.jiraProject ?? undefined,
jiraKey: prismaTask.jiraKey ?? undefined,
jiraType: prismaTask.jiraType ?? undefined,
assignee: prismaTask.assignee ?? undefined
};
}
}
// Instance singleton
export const tasksService = new TasksService();

View File

@@ -0,0 +1,309 @@
import { TaskStatus, KanbanFilters, ViewPreferences, ColumnVisibility, UserPreferences, JiraConfig } from '@/lib/types';
import { prisma } from './database';
import { getConfig } from '@/lib/config';
// Valeurs par défaut
const DEFAULT_PREFERENCES: UserPreferences = {
kanbanFilters: {
search: '',
tags: [],
priorities: [],
showCompleted: true,
sortBy: ''
},
viewPreferences: {
compactView: false,
swimlanesByTags: false,
swimlanesMode: 'tags',
showObjectives: true,
showFilters: true,
objectivesCollapsed: false,
theme: 'dark',
fontSize: 'medium'
},
columnVisibility: {
hiddenStatuses: []
},
jiraConfig: {
enabled: false,
baseUrl: '',
email: '',
apiToken: '',
ignoredProjects: []
}
};
/**
* Service pour gérer les préférences utilisateur en base de données
*/
class UserPreferencesService {
private readonly USER_ID = 'default'; // Pour l'instant, un seul utilisateur
/**
* Récupère ou crée l'entrée user preferences (avec upsert pour éviter les doublons)
*/
private async getOrCreateUserPreferences() {
// Utiliser upsert pour éviter les conditions de course
const userPrefs = await prisma.userPreferences.upsert({
where: { id: 'default' }, // ID fixe pour l'utilisateur unique
update: {}, // Ne rien mettre à jour si existe
create: {
id: 'default',
kanbanFilters: DEFAULT_PREFERENCES.kanbanFilters,
viewPreferences: DEFAULT_PREFERENCES.viewPreferences,
columnVisibility: DEFAULT_PREFERENCES.columnVisibility,
jiraConfig: DEFAULT_PREFERENCES.jiraConfig as any, // eslint-disable-line @typescript-eslint/no-explicit-any
}
});
return userPrefs;
}
// === FILTRES KANBAN ===
/**
* Sauvegarde les filtres Kanban
*/
async saveKanbanFilters(filters: KanbanFilters): Promise<void> {
try {
const userPrefs = await this.getOrCreateUserPreferences();
await prisma.userPreferences.update({
where: { id: userPrefs.id },
data: { kanbanFilters: filters }
});
} catch (error) {
console.warn('Erreur lors de la sauvegarde des filtres Kanban:', error);
throw error;
}
}
/**
* Récupère les filtres Kanban
*/
async getKanbanFilters(): Promise<KanbanFilters> {
try {
const userPrefs = await this.getOrCreateUserPreferences();
const filters = userPrefs.kanbanFilters as KanbanFilters | null;
return { ...DEFAULT_PREFERENCES.kanbanFilters, ...(filters || {}) };
} catch (error) {
console.warn('Erreur lors de la récupération des filtres Kanban:', error);
return DEFAULT_PREFERENCES.kanbanFilters;
}
}
// === PRÉFÉRENCES DE VUE ===
/**
* Sauvegarde les préférences de vue
*/
async saveViewPreferences(preferences: ViewPreferences): Promise<void> {
try {
const userPrefs = await this.getOrCreateUserPreferences();
await prisma.userPreferences.update({
where: { id: userPrefs.id },
data: { viewPreferences: preferences }
});
} catch (error) {
console.warn('Erreur lors de la sauvegarde des préférences de vue:', error);
throw error;
}
}
/**
* Récupère les préférences de vue
*/
async getViewPreferences(): Promise<ViewPreferences> {
try {
const userPrefs = await this.getOrCreateUserPreferences();
const preferences = userPrefs.viewPreferences as ViewPreferences | null;
return { ...DEFAULT_PREFERENCES.viewPreferences, ...(preferences || {}) };
} catch (error) {
console.warn('Erreur lors de la récupération des préférences de vue:', error);
return DEFAULT_PREFERENCES.viewPreferences;
}
}
// === VISIBILITÉ DES COLONNES ===
/**
* Sauvegarde la visibilité des colonnes
*/
async saveColumnVisibility(visibility: ColumnVisibility): Promise<void> {
try {
const userPrefs = await this.getOrCreateUserPreferences();
await prisma.userPreferences.update({
where: { id: userPrefs.id },
data: { columnVisibility: visibility }
});
} catch (error) {
console.warn('Erreur lors de la sauvegarde de la visibilité des colonnes:', error);
throw error;
}
}
/**
* Récupère la visibilité des colonnes
*/
async getColumnVisibility(): Promise<ColumnVisibility> {
try {
const userPrefs = await this.getOrCreateUserPreferences();
const visibility = userPrefs.columnVisibility as ColumnVisibility | null;
return { ...DEFAULT_PREFERENCES.columnVisibility, ...(visibility || {}) };
} catch (error) {
console.warn('Erreur lors de la récupération de la visibilité des colonnes:', error);
return DEFAULT_PREFERENCES.columnVisibility;
}
}
// === MÉTHODES GLOBALES ===
/**
* Récupère uniquement le thème pour le SSR (optimisé)
*/
async getTheme(): Promise<'light' | 'dark'> {
try {
const userPrefs = await this.getOrCreateUserPreferences();
const viewPrefs = userPrefs.viewPreferences as ViewPreferences;
return viewPrefs.theme;
} catch (error) {
console.error('Erreur lors de la récupération du thème:', error);
return DEFAULT_PREFERENCES.viewPreferences.theme; // Fallback
}
}
// === CONFIGURATION JIRA ===
/**
* Sauvegarde la configuration Jira
*/
async saveJiraConfig(config: JiraConfig): Promise<void> {
try {
const userPrefs = await this.getOrCreateUserPreferences();
await prisma.userPreferences.update({
where: { id: userPrefs.id },
data: { jiraConfig: config as any } // eslint-disable-line @typescript-eslint/no-explicit-any
});
} catch (error) {
console.warn('Erreur lors de la sauvegarde de la config Jira:', error);
throw error;
}
}
/**
* Récupère la configuration Jira depuis la base de données avec fallback sur les variables d'environnement
*/
async getJiraConfig(): Promise<JiraConfig> {
try {
const userPrefs = await this.getOrCreateUserPreferences();
const dbConfig = userPrefs.jiraConfig as JiraConfig | null;
// Si config en DB, l'utiliser
if (dbConfig && (dbConfig.baseUrl || dbConfig.email || dbConfig.apiToken)) {
return { ...DEFAULT_PREFERENCES.jiraConfig, ...dbConfig };
}
// Sinon fallback sur les variables d'environnement (existant)
const config = getConfig();
return {
baseUrl: config.integrations.jira.baseUrl,
email: config.integrations.jira.email,
apiToken: '', // On ne retourne pas le token des env vars pour la sécurité
enabled: config.integrations.jira.enabled
};
} catch (error) {
console.warn('Erreur lors de la récupération de la config Jira:', error);
return DEFAULT_PREFERENCES.jiraConfig;
}
}
/**
* Récupère toutes les préférences utilisateur
*/
async getAllPreferences(): Promise<UserPreferences> {
const [kanbanFilters, viewPreferences, columnVisibility, jiraConfig] = await Promise.all([
this.getKanbanFilters(),
this.getViewPreferences(),
this.getColumnVisibility(),
this.getJiraConfig()
]);
return {
kanbanFilters,
viewPreferences,
columnVisibility,
jiraConfig
};
}
/**
* Sauvegarde toutes les préférences utilisateur
*/
async saveAllPreferences(preferences: UserPreferences): Promise<void> {
await Promise.all([
this.saveKanbanFilters(preferences.kanbanFilters),
this.saveViewPreferences(preferences.viewPreferences),
this.saveColumnVisibility(preferences.columnVisibility),
this.saveJiraConfig(preferences.jiraConfig)
]);
}
/**
* Remet à zéro toutes les préférences
*/
async resetAllPreferences(): Promise<void> {
try {
const userPrefs = await this.getOrCreateUserPreferences();
await prisma.userPreferences.update({
where: { id: userPrefs.id },
data: {
kanbanFilters: DEFAULT_PREFERENCES.kanbanFilters,
viewPreferences: DEFAULT_PREFERENCES.viewPreferences,
columnVisibility: DEFAULT_PREFERENCES.columnVisibility,
jiraConfig: DEFAULT_PREFERENCES.jiraConfig as any, // eslint-disable-line @typescript-eslint/no-explicit-any
}
});
} catch (error) {
console.warn('Erreur lors de la remise à zéro des préférences:', error);
throw error;
}
}
// === MÉTHODES UTILITAIRES ===
/**
* Met à jour partiellement les filtres Kanban
*/
async updateKanbanFilters(updates: Partial<KanbanFilters>): Promise<void> {
const current = await this.getKanbanFilters();
await this.saveKanbanFilters({ ...current, ...updates });
}
/**
* Met à jour partiellement les préférences de vue
*/
async updateViewPreferences(updates: Partial<ViewPreferences>): Promise<void> {
const current = await this.getViewPreferences();
await this.saveViewPreferences({ ...current, ...updates });
}
/**
* Met à jour la visibilité d'une colonne spécifique
*/
async toggleColumnVisibility(status: TaskStatus): Promise<void> {
const current = await this.getColumnVisibility();
const hiddenStatuses = new Set(current.hiddenStatuses);
if (hiddenStatuses.has(status)) {
hiddenStatuses.delete(status);
} else {
hiddenStatuses.add(status);
}
await this.saveColumnVisibility({
hiddenStatuses: Array.from(hiddenStatuses)
});
}
}
// Export de l'instance singleton
export const userPreferencesService = new UserPreferencesService();