diff --git a/TODO.md b/TODO.md index 976b291..9535f7f 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,7 @@ # TowerControl v2.0 - Gestionnaire de tâches moderne ## Autre Todos #2 -- [ ] Synchro Jira auto en background timé comme pour la synchro de sauvegarde +- [x] Synchro Jira auto en background timé comme pour la synchro de sauvegarde - [ ] refacto des allpreferences : ca devrait eter un contexte dans le layout qui balance serverside dans le hook - [x] backups : ne backuper que si il y a eu un changement entre le dernier backup et la base actuelle - [ ] refacto des dates avec le utils qui pour l'instant n'est pas utilisé diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 49593d7..65cc138 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -101,6 +101,10 @@ model UserPreferences { // Configuration Jira (JSON) jiraConfig Json? + // Configuration du scheduler Jira + jiraAutoSync Boolean @default(false) + jiraSyncInterval String @default("daily") // hourly, daily, weekly + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/app/api/jira/sync/route.ts b/src/app/api/jira/sync/route.ts index 41eeaad..e181426 100644 --- a/src/app/api/jira/sync/route.ts +++ b/src/app/api/jira/sync/route.ts @@ -1,14 +1,55 @@ import { NextResponse } from 'next/server'; import { createJiraService, JiraService } from '@/services/jira'; import { userPreferencesService } from '@/services/user-preferences'; +import { jiraScheduler } from '@/services/jira-scheduler'; /** * Route POST /api/jira/sync * Synchronise les tickets Jira avec la base locale + * Supporte aussi les actions du scheduler */ -export async function POST() { +export async function POST(request: Request) { try { - // Essayer d'abord la config depuis la base de données + // Vérifier s'il y a des actions spécifiques (scheduler) + const body = await request.json().catch(() => ({})); + const { action, ...params } = body; + + // Actions du scheduler + if (action) { + switch (action) { + case 'scheduler': + if (params.enabled) { + await jiraScheduler.start(); + } else { + jiraScheduler.stop(); + } + return NextResponse.json({ + success: true, + data: await jiraScheduler.getStatus() + }); + + case 'config': + await userPreferencesService.saveJiraSchedulerConfig( + params.jiraAutoSync, + params.jiraSyncInterval + ); + // Redémarrer le scheduler si la config a changé + await jiraScheduler.restart(); + return NextResponse.json({ + success: true, + message: 'Configuration scheduler mise à jour', + data: await jiraScheduler.getStatus() + }); + + default: + return NextResponse.json( + { success: false, error: 'Action inconnue' }, + { status: 400 } + ); + } + } + + // Synchronisation normale (manuelle) const jiraConfig = await userPreferencesService.getJiraConfig(); let jiraService: JiraService | null = null; @@ -34,7 +75,7 @@ export async function POST() { ); } - console.log('🔄 Début de la synchronisation Jira...'); + console.log('🔄 Début de la synchronisation Jira manuelle...'); // Tester la connexion d'abord const connectionOk = await jiraService.testConnection(); @@ -118,6 +159,9 @@ export async function GET() { projectValidation = await jiraService.validateProject(jiraConfig.projectKey); } + // Récupérer aussi le statut du scheduler + const schedulerStatus = await jiraScheduler.getStatus(); + return NextResponse.json({ connected, message: connected ? 'Connexion Jira OK' : 'Impossible de se connecter à Jira', @@ -126,7 +170,8 @@ export async function GET() { exists: projectValidation.exists, name: projectValidation.name, error: projectValidation.error - } : null + } : null, + scheduler: schedulerStatus }); } catch (error) { diff --git a/src/clients/jira-client.ts b/src/clients/jira-client.ts index 9de5c31..f5fdc4e 100644 --- a/src/clients/jira-client.ts +++ b/src/clients/jira-client.ts @@ -9,6 +9,15 @@ export interface JiraConnectionStatus { connected: boolean; message: string; details?: string; + scheduler?: JiraSchedulerStatus; +} + +export interface JiraSchedulerStatus { + isRunning: boolean; + isEnabled: boolean; + interval: 'hourly' | 'daily' | 'weekly'; + nextSync: string | null; + jiraConfigured: boolean; } export class JiraClient extends HttpClient { @@ -30,6 +39,29 @@ export class JiraClient extends HttpClient { const response = await this.post<{ data: JiraSyncResult }>('/sync'); return response.data; } + + /** + * Active/désactive le scheduler automatique + */ + async toggleScheduler(enabled: boolean): Promise { + const response = await this.post<{ data: JiraSchedulerStatus }>('/sync', { + action: 'scheduler', + enabled + }); + return response.data; + } + + /** + * Met à jour la configuration du scheduler + */ + async updateSchedulerConfig(jiraAutoSync: boolean, jiraSyncInterval: 'hourly' | 'daily' | 'weekly'): Promise { + const response = await this.post<{ data: JiraSchedulerStatus }>('/sync', { + action: 'config', + jiraAutoSync, + jiraSyncInterval + }); + return response.data; + } } // Instance singleton diff --git a/src/components/jira/JiraSchedulerConfig.tsx b/src/components/jira/JiraSchedulerConfig.tsx new file mode 100644 index 0000000..549d555 --- /dev/null +++ b/src/components/jira/JiraSchedulerConfig.tsx @@ -0,0 +1,208 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/Button'; +import { Card, CardHeader, CardContent } from '@/components/ui/Card'; +import { Badge } from '@/components/ui/Badge'; +import { jiraClient, JiraSchedulerStatus } from '@/clients/jira-client'; + +interface JiraSchedulerConfigProps { + className?: string; +} + +export function JiraSchedulerConfig({ className = "" }: JiraSchedulerConfigProps) { + const [schedulerStatus, setSchedulerStatus] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // Charger le statut initial + useEffect(() => { + loadSchedulerStatus(); + }, []); + + const loadSchedulerStatus = async () => { + try { + const status = await jiraClient.testConnection(); + if (status.scheduler) { + setSchedulerStatus(status.scheduler); + } + } catch (err) { + console.error('Erreur lors du chargement du statut scheduler:', err); + } + }; + + const toggleScheduler = async () => { + if (!schedulerStatus) return; + + setIsLoading(true); + setError(null); + + try { + // Utiliser isEnabled au lieu de isRunning pour l'activation + const newStatus = await jiraClient.updateSchedulerConfig(!schedulerStatus.isEnabled, schedulerStatus.interval); + setSchedulerStatus(newStatus); + } catch (err) { + setError(err instanceof Error ? err.message : 'Erreur lors du toggle scheduler'); + } finally { + setIsLoading(false); + } + }; + + const updateInterval = async (interval: 'hourly' | 'daily' | 'weekly') => { + if (!schedulerStatus) return; + + setIsLoading(true); + setError(null); + + try { + const newStatus = await jiraClient.updateSchedulerConfig(true, interval); + setSchedulerStatus(newStatus); + } catch (err) { + setError(err instanceof Error ? err.message : 'Erreur lors de la mise à jour'); + } finally { + setIsLoading(false); + } + }; + + const getStatusBadge = () => { + if (!schedulerStatus) return null; + + if (!schedulerStatus.jiraConfigured) { + return ⚠️ Jira non configuré; + } + + if (!schedulerStatus.isEnabled) { + return ⏸️ Désactivé; + } + + return schedulerStatus.isRunning ? ( + ✅ Actif + ) : ( + ❌ Arrêté + ); + }; + + const getNextSyncText = () => { + if (!schedulerStatus?.nextSync) return 'Aucune synchronisation planifiée'; + + const nextSync = new Date(schedulerStatus.nextSync); + const now = new Date(); + const diffMs = nextSync.getTime() - now.getTime(); + + if (diffMs <= 0) return 'Synchronisation en cours...'; + + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffMinutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); + + if (diffHours > 0) { + return `Dans ${diffHours}h ${diffMinutes}min`; + } else { + return `Dans ${diffMinutes}min`; + } + }; + + const getIntervalText = (interval: string) => { + switch (interval) { + case 'hourly': return 'Toutes les heures'; + case 'daily': return 'Quotidienne'; + case 'weekly': return 'Hebdomadaire'; + default: return interval; + } + }; + + if (!schedulerStatus) { + return ( + + +

⏰ Synchronisation automatique

+
+ +

Chargement...

+
+
+ ); + } + + return ( + + +
+

⏰ Synchronisation automatique

+
+ {getStatusBadge()} +
+
+
+ + {error && ( +
+

{error}

+
+ )} + + {/* Statut actuel */} +
+
+ Statut: +

+ {schedulerStatus.isEnabled && schedulerStatus.isRunning ? '🟢 Actif' : '🔴 Arrêté'} +

+
+
+ Fréquence: +

{getIntervalText(schedulerStatus.interval)}

+
+
+ Prochaine synchronisation: +

{getNextSyncText()}

+
+
+ + {/* Contrôles */} +
+ {/* Toggle scheduler */} +
+ Synchronisation automatique + +
+ + {/* Sélecteur d'intervalle */} + {schedulerStatus.isEnabled && ( +
+ Fréquence de synchronisation +
+ {(['hourly', 'daily', 'weekly'] as const).map((interval) => ( + + ))} +
+
+ )} +
+ + {/* Avertissement si Jira non configuré */} + {!schedulerStatus.jiraConfigured && ( +
+

+ ⚠️ Configurez d'abord votre connexion Jira pour activer la synchronisation automatique. +

+
+ )} +
+
+ ); +} diff --git a/src/components/settings/IntegrationsSettingsPageClient.tsx b/src/components/settings/IntegrationsSettingsPageClient.tsx index b4430fa..504fe61 100644 --- a/src/components/settings/IntegrationsSettingsPageClient.tsx +++ b/src/components/settings/IntegrationsSettingsPageClient.tsx @@ -6,6 +6,7 @@ import { Card, CardHeader, CardContent } from '@/components/ui/Card'; import { JiraConfigForm } from '@/components/settings/JiraConfigForm'; import { JiraSync } from '@/components/jira/JiraSync'; import { JiraLogs } from '@/components/jira/JiraLogs'; +import { JiraSchedulerConfig } from '@/components/jira/JiraSchedulerConfig'; import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext'; import Link from 'next/link'; @@ -145,6 +146,7 @@ export function IntegrationsSettingsPageClient({ )} + diff --git a/src/lib/types.ts b/src/lib/types.ts index 62fa0ad..3e06f4f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -93,6 +93,8 @@ export interface UserPreferences { viewPreferences: ViewPreferences; columnVisibility: ColumnVisibility; jiraConfig: JiraConfig; + jiraAutoSync: boolean; + jiraSyncInterval: 'hourly' | 'daily' | 'weekly'; } // Interface pour les logs de synchronisation diff --git a/src/services/jira-scheduler.ts b/src/services/jira-scheduler.ts new file mode 100644 index 0000000..45ab347 --- /dev/null +++ b/src/services/jira-scheduler.ts @@ -0,0 +1,201 @@ +import { userPreferencesService } from './user-preferences'; +import { JiraService } from './jira'; + +export interface JiraSchedulerConfig { + enabled: boolean; + interval: 'hourly' | 'daily' | 'weekly'; +} + +export class JiraScheduler { + private timer: NodeJS.Timeout | null = null; + private isRunning = false; + + /** + * Démarre le planificateur de synchronisation Jira automatique + */ + async start(): Promise { + if (this.isRunning) { + console.log('⚠️ Jira scheduler is already running'); + return; + } + + const config = await this.getConfig(); + + if (!config.enabled) { + console.log('📋 Automatic Jira sync is disabled'); + return; + } + + // Vérifier que Jira est configuré + const jiraConfig = await userPreferencesService.getJiraConfig(); + if (!jiraConfig.enabled || !jiraConfig.baseUrl || !jiraConfig.email || !jiraConfig.apiToken) { + console.log('⚠️ Jira not configured, scheduler cannot start'); + return; + } + + const intervalMs = this.getIntervalMs(config.interval); + + // Première synchronisation immédiate (optionnelle) + // this.performScheduledSync(); + + // Planifier les synchronisations suivantes + this.timer = setInterval(() => { + this.performScheduledSync(); + }, intervalMs); + + this.isRunning = true; + console.log(`✅ Jira 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('🛑 Jira scheduler stopped'); + } + + /** + * Redémarre le planificateur (utile lors des changements de config) + */ + async restart(): Promise { + this.stop(); + await this.start(); + } + + /** + * Vérifie si le planificateur fonctionne + */ + isActive(): boolean { + return this.isRunning && this.timer !== null; + } + + /** + * Effectue une synchronisation planifiée + */ + private async performScheduledSync(): Promise { + try { + console.log('🔄 Starting scheduled Jira sync...'); + + // Récupérer la config Jira + const jiraConfig = await userPreferencesService.getJiraConfig(); + + if (!jiraConfig.enabled || !jiraConfig.baseUrl || !jiraConfig.email || !jiraConfig.apiToken) { + console.log('⚠️ Jira config incomplete, skipping scheduled sync'); + return; + } + + // Créer le service Jira + const jiraService = new JiraService({ + baseUrl: jiraConfig.baseUrl, + email: jiraConfig.email, + apiToken: jiraConfig.apiToken, + projectKey: jiraConfig.projectKey, + ignoredProjects: jiraConfig.ignoredProjects || [] + }); + + // Tester la connexion d'abord + const connectionOk = await jiraService.testConnection(); + if (!connectionOk) { + console.error('❌ Scheduled Jira sync failed: connection error'); + return; + } + + // Effectuer la synchronisation + const result = await jiraService.syncTasks(); + + if (result.success) { + console.log(`✅ Scheduled Jira sync completed: ${result.tasksCreated} created, ${result.tasksUpdated} updated, ${result.tasksSkipped} skipped`); + } else { + console.error(`❌ Scheduled Jira sync failed: ${result.errors.join(', ')}`); + } + } catch (error) { + console.error('❌ Scheduled Jira sync error:', error); + } + } + + /** + * Convertit l'intervalle en millisecondes + */ + private getIntervalMs(interval: JiraSchedulerConfig['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 synchronisation + */ + async getNextSyncTime(): Promise { + if (!this.isRunning || !this.timer) { + return null; + } + + const config = await this.getConfig(); + const intervalMs = this.getIntervalMs(config.interval); + + return new Date(Date.now() + intervalMs); + } + + /** + * Récupère la configuration du scheduler depuis les user preferences + */ + private async getConfig(): Promise { + try { + const [jiraConfig, schedulerConfig] = await Promise.all([ + userPreferencesService.getJiraConfig(), + userPreferencesService.getJiraSchedulerConfig() + ]); + + return { + enabled: schedulerConfig.jiraAutoSync && + jiraConfig.enabled && + !!jiraConfig.baseUrl && + !!jiraConfig.email && + !!jiraConfig.apiToken, + interval: schedulerConfig.jiraSyncInterval + }; + } catch (error) { + console.error('Error getting Jira scheduler config:', error); + return { + enabled: false, + interval: 'daily' + }; + } + } + + /** + * Obtient les stats du planificateur + */ + async getStatus() { + const config = await this.getConfig(); + const jiraConfig = await userPreferencesService.getJiraConfig(); + + return { + isRunning: this.isRunning, + isEnabled: config.enabled, + interval: config.interval, + nextSync: await this.getNextSyncTime(), + jiraConfigured: !!(jiraConfig.baseUrl && jiraConfig.email && jiraConfig.apiToken), + }; + } +} + +// Instance singleton +export const jiraScheduler = new JiraScheduler(); + +// Auto-start du scheduler +// Démarrer avec un délai pour laisser l'app s'initialiser +setTimeout(() => { + console.log('🚀 Auto-starting Jira scheduler...'); + jiraScheduler.start(); +}, 6000); // 6 secondes, après le backup scheduler diff --git a/src/services/user-preferences.ts b/src/services/user-preferences.ts index b9a0d79..e6b7481 100644 --- a/src/services/user-preferences.ts +++ b/src/services/user-preferences.ts @@ -30,7 +30,9 @@ const DEFAULT_PREFERENCES: UserPreferences = { email: '', apiToken: '', ignoredProjects: [] - } + }, + jiraAutoSync: false, + jiraSyncInterval: 'daily' }; /** @@ -56,9 +58,29 @@ class UserPreferencesService { } }); + // S'assurer que les nouveaux champs existent (migration douce) + await this.ensureJiraSchedulerFields(); + return userPrefs; } + /** + * S'assure que les champs jiraAutoSync et jiraSyncInterval existent + */ + private async ensureJiraSchedulerFields(): Promise { + try { + await prisma.$executeRaw` + UPDATE user_preferences + SET jiraAutoSync = COALESCE(jiraAutoSync, ${DEFAULT_PREFERENCES.jiraAutoSync}), + jiraSyncInterval = COALESCE(jiraSyncInterval, ${DEFAULT_PREFERENCES.jiraSyncInterval}) + WHERE id = 'default' + `; + } catch (error) { + // Ignorer les erreurs si les colonnes n'existent pas encore + console.debug('Migration douce des champs scheduler Jira:', error); + } + } + // === FILTRES KANBAN === /** @@ -216,22 +238,76 @@ class UserPreferencesService { } } + // === CONFIGURATION SCHEDULER JIRA === + + /** + * Sauvegarde les préférences du scheduler Jira + */ + async saveJiraSchedulerConfig(jiraAutoSync: boolean, jiraSyncInterval: 'hourly' | 'daily' | 'weekly'): Promise { + try { + const userPrefs = await this.getOrCreateUserPreferences(); + // Utiliser une requête SQL brute temporairement pour éviter les problèmes de types + await prisma.$executeRaw` + UPDATE user_preferences + SET jiraAutoSync = ${jiraAutoSync}, jiraSyncInterval = ${jiraSyncInterval} + WHERE id = ${userPrefs.id} + `; + } catch (error) { + console.warn('Erreur lors de la sauvegarde de la config scheduler Jira:', error); + throw error; + } + } + + /** + * Récupère les préférences du scheduler Jira + */ + async getJiraSchedulerConfig(): Promise<{ jiraAutoSync: boolean; jiraSyncInterval: 'hourly' | 'daily' | 'weekly' }> { + try { + const userPrefs = await this.getOrCreateUserPreferences(); + // Utiliser une requête SQL brute pour récupérer les nouveaux champs + const result = await prisma.$queryRaw>` + SELECT jiraAutoSync, jiraSyncInterval FROM user_preferences WHERE id = ${userPrefs.id} + `; + + if (result.length > 0) { + return { + jiraAutoSync: Boolean(result[0].jiraAutoSync), + jiraSyncInterval: (result[0].jiraSyncInterval as 'hourly' | 'daily' | 'weekly') || DEFAULT_PREFERENCES.jiraSyncInterval + }; + } + + return { + jiraAutoSync: DEFAULT_PREFERENCES.jiraAutoSync, + jiraSyncInterval: DEFAULT_PREFERENCES.jiraSyncInterval + }; + } catch (error) { + console.warn('Erreur lors de la récupération de la config scheduler Jira:', error); + return { + jiraAutoSync: DEFAULT_PREFERENCES.jiraAutoSync, + jiraSyncInterval: DEFAULT_PREFERENCES.jiraSyncInterval + }; + } + } + /** * Récupère toutes les préférences utilisateur */ async getAllPreferences(): Promise { - const [kanbanFilters, viewPreferences, columnVisibility, jiraConfig] = await Promise.all([ + const [kanbanFilters, viewPreferences, columnVisibility, jiraConfig, jiraSchedulerConfig] = await Promise.all([ this.getKanbanFilters(), this.getViewPreferences(), this.getColumnVisibility(), - this.getJiraConfig() + this.getJiraConfig(), + this.getJiraSchedulerConfig() ]); return { kanbanFilters, viewPreferences, columnVisibility, - jiraConfig + jiraConfig, + jiraAutoSync: jiraSchedulerConfig.jiraAutoSync, + jiraSyncInterval: jiraSchedulerConfig.jiraSyncInterval }; } @@ -243,7 +319,8 @@ class UserPreferencesService { this.saveKanbanFilters(preferences.kanbanFilters), this.saveViewPreferences(preferences.viewPreferences), this.saveColumnVisibility(preferences.columnVisibility), - this.saveJiraConfig(preferences.jiraConfig) + this.saveJiraConfig(preferences.jiraConfig), + this.saveJiraSchedulerConfig(preferences.jiraAutoSync, preferences.jiraSyncInterval) ]); }