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:
292
src/services/analytics.ts
Normal file
292
src/services/analytics.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
138
src/services/backup-scheduler.ts
Normal file
138
src/services/backup-scheduler.ts
Normal 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
570
src/services/backup.ts
Normal 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
277
src/services/daily.ts
Normal 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
64
src/services/database.ts
Normal 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;
|
||||
320
src/services/jira-advanced-filters.ts
Normal file
320
src/services/jira-advanced-filters.ts
Normal 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]}`;
|
||||
}
|
||||
}
|
||||
155
src/services/jira-analytics-cache.ts
Normal file
155
src/services/jira-analytics-cache.ts
Normal 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();
|
||||
481
src/services/jira-analytics.ts
Normal file
481
src/services/jira-analytics.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
297
src/services/jira-anomaly-detection.ts
Normal file
297
src/services/jira-anomaly-detection.ts
Normal 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();
|
||||
180
src/services/jira-summary.ts
Normal file
180
src/services/jira-summary.ts
Normal 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
753
src/services/jira.ts
Normal 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 });
|
||||
}
|
||||
563
src/services/manager-summary.ts
Normal file
563
src/services/manager-summary.ts
Normal 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
362
src/services/metrics.ts
Normal 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
191
src/services/system-info.ts
Normal 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
245
src/services/tags.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
185
src/services/task-categorization.ts
Normal file
185
src/services/task-categorization.ts
Normal 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
373
src/services/tasks.ts
Normal 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();
|
||||
309
src/services/user-preferences.ts
Normal file
309
src/services/user-preferences.ts
Normal 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();
|
||||
Reference in New Issue
Block a user