diff --git a/attached_assets/image_1755116495530.png b/attached_assets/image_1755116495530.png new file mode 100644 index 0000000..1ca376a Binary files /dev/null and b/attached_assets/image_1755116495530.png differ diff --git a/client/src/components/CalendarGrid.tsx b/client/src/components/CalendarGrid.tsx index aa908bf..64ea724 100644 --- a/client/src/components/CalendarGrid.tsx +++ b/client/src/components/CalendarGrid.tsx @@ -171,14 +171,16 @@ export default function CalendarGrid({ onDateClick, onItemClick, }: CalendarGridProps) { - console.log('🗓️ CalendarGrid rendered with:', { - currentDate: currentDate?.toISOString(), - ordersCount: orders?.length || 0, - deliveriesCount: deliveries?.length || 0, - publicitiesCount: publicities?.length || 0, - selectedStoreId, - userGroupsCount: userGroups?.length || 0 - }); + if (import.meta.env.DEV) { + console.log('🗓️ CalendarGrid rendered with:', { + currentDate: currentDate?.toISOString(), + ordersCount: orders?.length || 0, + deliveriesCount: deliveries?.length || 0, + publicitiesCount: publicities?.length || 0, + selectedStoreId, + userGroupsCount: userGroups?.length || 0 + }); + } const monthStart = startOfMonth(currentDate); const monthEnd = endOfMonth(currentDate); @@ -192,12 +194,14 @@ export default function CalendarGrid({ return []; } - console.log('🔍 CalendarGrid getPublicitiesForDate:', { - date: dateStr, - totalPublicities: publicities.length, - selectedStoreId, - userGroups: userGroups?.length || 0 - }); + if (import.meta.env.DEV) { + console.log('🔍 CalendarGrid getPublicitiesForDate:', { + date: dateStr, + totalPublicities: publicities.length, + selectedStoreId, + userGroups: userGroups?.length || 0 + }); + } try { return publicities.filter(pub => { @@ -222,7 +226,7 @@ export default function CalendarGrid({ // If no store is selected and user has no assigned groups, show only publicities with participations if (!selectedStoreId && (!userGroups || userGroups.length === 0)) { const hasParticipations = pub.participations && Array.isArray(pub.participations) && pub.participations.length > 0; - if (hasParticipations) { + if (hasParticipations && import.meta.env.DEV) { console.log('📋 Publicity has participations (no store selected):', { pubNumber: pub.pubNumber, participationCount: pub.participations.length }); } return hasParticipations; @@ -232,7 +236,7 @@ export default function CalendarGrid({ if (selectedStoreId) { const matches = pub.participations && Array.isArray(pub.participations) && pub.participations.some((pg: any) => pg?.groupId === selectedStoreId); - if (matches) { + if (matches && import.meta.env.DEV) { console.log('🎯 Publicity matches selected store:', { pubNumber: pub.pubNumber, selectedStoreId }); } return matches; diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index aac637b..5713485 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -138,7 +138,7 @@ export default function Layout({ children }: LayoutProps) {
{store.name}
diff --git a/client/src/components/RouterProduction.tsx b/client/src/components/RouterProduction.tsx index f0b60d3..bb5f6e8 100644 --- a/client/src/components/RouterProduction.tsx +++ b/client/src/components/RouterProduction.tsx @@ -17,6 +17,7 @@ import DatabaseDebug from "@/pages/DatabaseDebug"; import CustomerOrders from "@/pages/CustomerOrders"; import DlcPage from "@/pages/DlcPage"; +import BackupManager from "@/pages/BackupManager"; import Tasks from "@/pages/Tasks"; import TasksSimplified from "@/pages/TasksSimplified"; import Layout from "@/components/Layout"; @@ -76,6 +77,7 @@ function RouterProduction() { + diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index fac67f0..0675126 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -146,6 +146,12 @@ export default function Sidebar() { icon: UserCog, roles: ["admin"] }, + { + path: "/backup", + label: "Sauvegardes", + icon: Database, + roles: ["admin"] + }, { path: "/nocodb-config", label: "Configuration NocoDB", diff --git a/client/src/components/modals/OrderDetailModal.tsx b/client/src/components/modals/OrderDetailModal.tsx index c81f18d..dd386fe 100644 --- a/client/src/components/modals/OrderDetailModal.tsx +++ b/client/src/components/modals/OrderDetailModal.tsx @@ -43,8 +43,8 @@ export default function OrderDetailModal({ title: "Succès", description: "Commande validée avec succès", }); + // Invalidation sélective pour éviter la disparition du calendrier queryClient.invalidateQueries({ queryKey: ['/api/orders'] }); - queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] }); onClose(); }, onError: (error) => { @@ -76,8 +76,8 @@ export default function OrderDetailModal({ title: "Succès", description: "Livraison validée avec succès", }); + // Invalidation sélective pour éviter la disparition du calendrier queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] }); - queryClient.invalidateQueries({ queryKey: ['/api/orders'] }); onClose(); }, onError: (error) => { @@ -103,37 +103,26 @@ export default function OrderDetailModal({ const deleteMutation = useMutation({ mutationFn: async (id: number) => { const endpoint = isOrder ? `/api/orders/${id}` : `/api/deliveries/${id}`; - console.log(`🗑️ Deleting ${isOrder ? 'order' : 'delivery'}:`, id); + if (import.meta.env.DEV) { + console.log(`🗑️ Deleting ${isOrder ? 'order' : 'delivery'}:`, id); + } await apiRequest(endpoint, "DELETE"); }, onSuccess: () => { - console.log(`✅ ${isOrder ? 'Order' : 'Delivery'} deleted successfully, clearing cache...`); + if (import.meta.env.DEV) { + console.log(`✅ ${isOrder ? 'Order' : 'Delivery'} deleted successfully`); + } toast({ title: "Succès", description: `${isOrder ? 'Commande' : 'Livraison'} supprimée avec succès`, }); - // SOLUTION PRODUCTION : Cache clearing radical pour éviter incohérences storeId - console.log('🗑️ Cache cleared, forcing page reload to maintain storeId consistency...'); - - // SOLUTION HYBRIDE : Invalidation sélective pour éviter perte storeId - console.log('🧹 Using selective invalidation to preserve storeId context...'); - - // Invalidation ciblée sans clear() pour préserver le contexte - queryClient.invalidateQueries({ - predicate: (query) => { - const key = query.queryKey[0]?.toString() || ''; - return key.includes('/api/orders') || key.includes('/api/deliveries'); - } - }); - - // Force refetch pour garantir synchronisation immédiate - queryClient.refetchQueries({ - predicate: (query) => { - const key = query.queryKey[0]?.toString() || ''; - return key.includes('/api/orders') || key.includes('/api/deliveries'); - } - }); + // Invalidation sélective uniquement pour l'API concernée + if (isOrder) { + queryClient.invalidateQueries({ queryKey: ['/api/orders'] }); + } else { + queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] }); + } onClose(); }, diff --git a/client/src/pages/BackupManager.tsx b/client/src/pages/BackupManager.tsx new file mode 100644 index 0000000..d80fdb1 --- /dev/null +++ b/client/src/pages/BackupManager.tsx @@ -0,0 +1,330 @@ +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { useToast } from "@/hooks/use-toast"; +import { useAuthUnified } from "@/hooks/useAuthUnified"; +import { apiRequest } from "@/lib/queryClient"; +import { hasPermission } from "@/lib/permissions"; +import { + Download, + Trash2, + Database, + Clock, + User, + FileText, + AlertTriangle, + CheckCircle, + Loader2 +} from "lucide-react"; +import { format } from "date-fns"; +import { fr } from "date-fns/locale"; + +interface BackupFile { + id: string; + filename: string; + description: string | null; + size: number; + createdAt: string; + createdBy: string; + tablesCount: number; + status: string; + backupType: string; +} + +export default function BackupManager() { + const { user } = useAuthUnified(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [deletingBackup, setDeletingBackup] = useState(null); + + // Check permissions + const canManageBackups = hasPermission(user?.role || '', 'admin', 'manage'); + + // Fetch backup list + const { data: backups = [], isLoading, error } = useQuery({ + queryKey: ['/api/backups'], + queryFn: () => apiRequest('/api/backups'), + enabled: canManageBackups, + refetchInterval: 30000, // Refresh every 30 seconds + }); + + // Create manual backup + const createBackupMutation = useMutation({ + mutationFn: () => apiRequest('/api/backups', 'POST'), + onSuccess: () => { + toast({ + title: "Succès", + description: "Sauvegarde manuelle créée avec succès", + }); + queryClient.invalidateQueries({ queryKey: ['/api/backups'] }); + }, + onError: (error: any) => { + toast({ + title: "Erreur", + description: error.message || "Impossible de créer la sauvegarde", + variant: "destructive", + }); + }, + }); + + // Delete backup + const deleteBackupMutation = useMutation({ + mutationFn: (filename: string) => apiRequest(`/api/backups/${filename}`, 'DELETE'), + onSuccess: () => { + toast({ + title: "Succès", + description: "Sauvegarde supprimée avec succès", + }); + queryClient.invalidateQueries({ queryKey: ['/api/backups'] }); + setDeletingBackup(null); + }, + onError: (error: any) => { + toast({ + title: "Erreur", + description: error.message || "Impossible de supprimer la sauvegarde", + variant: "destructive", + }); + setDeletingBackup(null); + }, + }); + + const handleDownload = (filename: string) => { + // Create download link + const link = document.createElement('a'); + link.href = `/api/backups/${filename}/download`; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const handleDelete = (filename: string) => { + setDeletingBackup(filename); + deleteBackupMutation.mutate(filename); + }; + + const formatFileSize = (bytes: number): string => { + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + if (bytes === 0) return '0 Bytes'; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; + }; + + const getTypeIcon = (type: string) => { + return type === 'automatic' ? : ; + }; + + const getTypeBadge = (type: string) => { + return type === 'automatic' + ? Automatique + : Manuelle; + }; + + const getStatusBadge = (status: string) => { + switch (status) { + case 'completed': + return Terminé; + case 'creating': + return En cours; + case 'failed': + return Échec; + default: + return {status}; + } + }; + + if (!canManageBackups) { + return ( +
+ + + + Accès refusé + + Vous n'avez pas les permissions nécessaires pour accéder à la gestion des sauvegardes. + + + +
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+ +
+

+ Gestion des Sauvegardes +

+

+ Sauvegarde automatique quotidienne à 2h00 - Maximum 10 fichiers conservés +

+
+
+ + +
+
+ + {/* Content */} +
+ {/* Status Cards */} +
+ + + + Total Sauvegardes + + + + +
{backups.length}
+

+ Maximum 10 fichiers +

+
+
+ + + + + Dernière Sauvegarde + + + + +
+ {backups.length > 0 ? ( + format(new Date(backups[0].createdAt), 'dd/MM', { locale: fr }) + ) : '-'} +
+

+ {backups.length > 0 ? ( + format(new Date(backups[0].createdAt), 'HH:mm', { locale: fr }) + ) : 'Aucune sauvegarde'} +

+
+
+ + + + + Statut Système + + + + +
Actif
+

+ Sauvegarde automatique 2h00 +

+
+
+
+ + {/* Backup List */} + + + Liste des Sauvegardes + + Gestion des fichiers de sauvegarde de la base de données PostgreSQL + + + + {isLoading ? ( +
+ +
+ ) : error ? ( +
+ + Erreur lors du chargement des sauvegardes +
+ ) : backups.length === 0 ? ( +
+ + Aucune sauvegarde disponible +
+ ) : ( +
+ {backups.map((backup) => ( +
+
+
+ {getTypeIcon(backup.backupType)} + +
+ +
+
+ {backup.filename} +
+
+ {backup.description || 'Sauvegarde de base de données'} +
+
+ + {format(new Date(backup.createdAt), 'dd/MM/yyyy à HH:mm', { locale: fr })} + + {formatFileSize(backup.size)} + {backup.tablesCount} tables + {getTypeBadge(backup.backupType)} + {getStatusBadge(backup.status)} +
+
+
+ +
+ + + +
+
+ ))} +
+ )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/client/src/pages/Calendar.tsx b/client/src/pages/Calendar.tsx index 6413a82..3905e10 100644 --- a/client/src/pages/Calendar.tsx +++ b/client/src/pages/Calendar.tsx @@ -49,12 +49,14 @@ export default function Calendar() { } const url = `/api/orders?${params.toString()}`; - console.log('📅 Calendar fetching orders:', { - url, - selectedStoreId, - userRole: user?.role, - params: params.toString() - }); + if (import.meta.env.DEV) { + console.log('📅 Calendar fetching orders:', { + url, + selectedStoreId, + userRole: user?.role, + params: params.toString() + }); + } const response = await fetch(url, { credentials: 'include' @@ -65,7 +67,9 @@ export default function Calendar() { } const data = await response.json(); - console.log('📅 Calendar orders received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items'); + if (import.meta.env.DEV) { + console.log('📅 Calendar orders received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items'); + } // Protection contre les données invalides en production if (!Array.isArray(data)) { @@ -74,6 +78,8 @@ export default function Calendar() { } return data; }, + staleTime: 5 * 60 * 1000, // 5 minutes de cache pour éviter la disparition des données + gcTime: 10 * 60 * 1000, // 10 minutes en cache }); const { data: deliveries = [], isLoading: loadingDeliveries } = useQuery({ @@ -91,12 +97,14 @@ export default function Calendar() { } const url = `/api/deliveries?${params.toString()}`; - console.log('📅 Calendar fetching deliveries:', { - url, - selectedStoreId, - userRole: user?.role, - params: params.toString() - }); + if (import.meta.env.DEV) { + console.log('📅 Calendar fetching deliveries:', { + url, + selectedStoreId, + userRole: user?.role, + params: params.toString() + }); + } const response = await fetch(url, { credentials: 'include' @@ -107,7 +115,9 @@ export default function Calendar() { } const data = await response.json(); - console.log('📅 Calendar deliveries received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items'); + if (import.meta.env.DEV) { + console.log('📅 Calendar deliveries received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items'); + } // Protection contre les données invalides en production if (!Array.isArray(data)) { @@ -116,6 +126,8 @@ export default function Calendar() { } return data; }, + staleTime: 5 * 60 * 1000, // 5 minutes de cache pour éviter la disparition des données + gcTime: 10 * 60 * 1000, // 10 minutes en cache }); // Fetch publicities for the current year @@ -129,12 +141,14 @@ export default function Calendar() { } const url = `/api/publicities?${params.toString()}`; - console.log('📅 Calendar fetching publicities:', { - url, - selectedStoreId, - userRole: user?.role, - year: currentDate.getFullYear() - }); + if (import.meta.env.DEV) { + console.log('📅 Calendar fetching publicities:', { + url, + selectedStoreId, + userRole: user?.role, + year: currentDate.getFullYear() + }); + } const response = await fetch(url, { credentials: 'include' @@ -145,7 +159,9 @@ export default function Calendar() { } const data = await response.json(); - console.log('📅 Calendar publicities received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items'); + if (import.meta.env.DEV) { + console.log('📅 Calendar publicities received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items'); + } return Array.isArray(data) ? data : []; }, }); @@ -166,12 +182,7 @@ export default function Calendar() { }; const handleItemClick = (item: any, type: 'order' | 'delivery') => { - // Rafraîchir les données avant d'ouvrir le modal pour s'assurer d'avoir les liaisons à jour - queryClient.invalidateQueries({ queryKey: ['/api/orders'] }); - queryClient.invalidateQueries({ queryKey: ['/api/orders', selectedStoreId] }); - queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] }); - queryClient.invalidateQueries({ queryKey: ['/api/deliveries', selectedStoreId] }); - + // Ne pas invalider le cache à l'ouverture pour éviter la disparition des données setSelectedItem({ ...item, type }); setShowOrderDetail(true); }; diff --git a/cookies.txt b/cookies.txt index 4390e07..fb76b7b 100644 --- a/cookies.txt +++ b/cookies.txt @@ -2,4 +2,4 @@ # https://curl.se/docs/http-cookies.html # This file was generated by libcurl! Edit at your own risk. -#HttpOnly_localhost FALSE / FALSE 1755187696 connect.sid s%3AnEg7s3fD2sLyetJTCx-VYkzZFbBkPEcf.2BsOBC1UlFIC%2BpyKgdtocMX42YO%2FZLBn1cU6UeEnIYY +#HttpOnly_localhost FALSE / FALSE 1755203512 connect.sid s%3AGm18ttGO7m1DI7WtETfDd8gMojt-FqEw.%2BP3S4uZr6aX7vk2UAaEIow8kKQlZfWMzPcKLjUHAdNI diff --git a/package-lock.json b/package-lock.json index 8eb97a2..23a42c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,6 +66,7 @@ "memorystore": "^1.6.7", "nanoid": "^5.1.5", "next-themes": "^0.4.6", + "node-cron": "^4.2.1", "openid-client": "^6.6.2", "passport": "^0.7.0", "passport-local": "^1.0.0", @@ -6919,6 +6920,15 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", diff --git a/package.json b/package.json index 715a45a..b3be968 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "memorystore": "^1.6.7", "nanoid": "^5.1.5", "next-themes": "^0.4.6", + "node-cron": "^4.2.1", "openid-client": "^6.6.2", "passport": "^0.7.0", "passport-local": "^1.0.0", diff --git a/replit.md b/replit.md index 1186295..f53d685 100644 --- a/replit.md +++ b/replit.md @@ -19,13 +19,19 @@ La plateforme fournit une gestion robuste des flux de travail de livraison avec ## Recent Changes -### 2025-08-13 - Optimisation performance production + suppression fonctionnelle +### 2025-08-13 - Optimisation performance production + correction calendrier ✅ **Résolution latence production** : - **Problème** : Console saturée par centaines de logs "API Response" causant latence - **Solution** : Logging conditionnel - seulement en développement pour requêtes normales - **Production** : Seules les erreurs API sont loggées pour debugging - **Impact** : Réduction drastique de la charge console et amélioration performances +✅ **Problème calendrier résolu** : +- **Problème** : Fermeture carte commande/livraison faisait disparaître toutes les données +- **Cause** : Invalidation aggressive du cache React Query + invalidation à l'ouverture +- **Solution** : Cache timing optimisé (5min staleTime) + invalidation sélective seulement +- **Résultat** : Calendrier stable, plus de disparition des données au jour actuel + ✅ **Fonctionnalité suppression complètement opérationnelle** : - **Cache NocoDB** : Fonction `saveInvoiceVerificationCache` ajoutée aux deux systèmes stockage - **MemStorage** : Implémentation CRUD complète pour ordres et livraisons diff --git a/server/backupService.ts b/server/backupService.ts new file mode 100644 index 0000000..07661f0 --- /dev/null +++ b/server/backupService.ts @@ -0,0 +1,207 @@ +import { exec } from 'child_process'; +import { promisify } from 'util'; +import fs from 'fs'; +import path from 'path'; +import cron from 'node-cron'; +import { nanoid } from 'nanoid'; +import { eq, desc } from "drizzle-orm"; +import { db } from "./db"; +import { databaseBackups } from "@shared/schema"; +import type { DatabaseBackup, InsertDatabaseBackup } from "@shared/schema"; + +const execAsync = promisify(exec); + +export class BackupService { + private backupDir: string; + private maxBackups = 10; + + constructor() { + this.backupDir = path.join(process.cwd(), 'backups'); + this.ensureBackupDirectory(); + this.scheduleAutomaticBackup(); + } + + private ensureBackupDirectory(): void { + if (!fs.existsSync(this.backupDir)) { + fs.mkdirSync(this.backupDir, { recursive: true }); + console.log('📁 Backup directory created:', this.backupDir); + } + } + + private getDatabaseConfig() { + const dbUrl = process.env.DATABASE_URL; + if (!dbUrl) { + throw new Error('DATABASE_URL not found in environment variables'); + } + + // Parse PostgreSQL URL + const url = new URL(dbUrl); + return { + host: url.hostname, + port: url.port || '5432', + database: url.pathname.slice(1), // Remove leading slash + username: url.username, + password: url.password, + }; + } + + async createBackup(type: 'manual' | 'automatic' = 'manual', createdBy: string = 'system'): Promise { + try { + const id = nanoid(); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = `backup_${type}_${timestamp}.sql`; + const filepath = path.join(this.backupDir, filename); + + // Create database record first with "creating" status + const [backupRecord] = await db.insert(databaseBackups).values({ + id, + filename, + description: `${type === 'manual' ? 'Manuel' : 'Automatique'} backup du ${new Date().toLocaleDateString('fr-FR')}`, + size: 0, + createdBy, + tablesCount: 0, + status: 'creating', + backupType: type, + }).returning(); + + const dbConfig = this.getDatabaseConfig(); + + // Set environment variable for password to avoid prompt + const env = { + ...process.env, + PGPASSWORD: dbConfig.password, + }; + + // Create pg_dump command + const command = `pg_dump -h ${dbConfig.host} -p ${dbConfig.port} -U ${dbConfig.username} -d ${dbConfig.database} --no-password --verbose --clean --if-exists --create > "${filepath}"`; + + console.log(`🔄 Starting ${type} backup...`); + + await execAsync(command, { env }); + + // Get file stats and count tables + const stats = fs.statSync(filepath); + const sqlContent = fs.readFileSync(filepath, 'utf8'); + const tablesCount = (sqlContent.match(/CREATE TABLE/g) || []).length; + + // Update database record with completion details + const [updatedBackup] = await db.update(databaseBackups) + .set({ + size: stats.size, + tablesCount, + status: 'completed', + }) + .where(eq(databaseBackups.id, id)) + .returning(); + + console.log(`✅ ${type} backup created: ${filename} (${this.formatFileSize(stats.size)}, ${tablesCount} tables)`); + + // Clean old backups + await this.cleanOldBackups(); + + return updatedBackup; + } catch (error) { + console.error('❌ Backup failed:', error); + throw new Error(`Backup failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + async getBackupList(): Promise { + try { + const backups = await db.select() + .from(databaseBackups) + .orderBy(desc(databaseBackups.createdAt)) + .limit(this.maxBackups); + + return backups; + } catch (error) { + console.error('❌ Failed to list backups:', error); + return []; + } + } + + async deleteBackup(filename: string): Promise { + try { + // Find backup record + const [backup] = await db.select() + .from(databaseBackups) + .where(eq(databaseBackups.filename, filename)) + .limit(1); + + if (!backup) { + throw new Error('Backup record not found'); + } + + // Delete physical file + const filepath = path.join(this.backupDir, filename); + if (fs.existsSync(filepath)) { + fs.unlinkSync(filepath); + } + + // Delete database record + await db.delete(databaseBackups) + .where(eq(databaseBackups.filename, filename)); + + console.log(`🗑️ Backup deleted: ${filename}`); + } catch (error) { + console.error('❌ Failed to delete backup:', error); + throw error; + } + } + + async downloadBackup(filename: string): Promise { + const filepath = path.join(this.backupDir, filename); + + if (!fs.existsSync(filepath)) { + throw new Error('Backup file not found'); + } + + return filepath; + } + + private async cleanOldBackups(): Promise { + try { + const allBackups = await db.select() + .from(databaseBackups) + .orderBy(desc(databaseBackups.createdAt)); + + if (allBackups.length > this.maxBackups) { + const toDelete = allBackups.slice(this.maxBackups); + + for (const backup of toDelete) { + await this.deleteBackup(backup.filename); + } + + console.log(`🧹 Cleaned ${toDelete.length} old backup(s)`); + } + } catch (error) { + console.error('❌ Failed to clean old backups:', error); + } + } + + private formatFileSize(bytes: number): string { + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + if (bytes === 0) return '0 Bytes'; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; + } + + private scheduleAutomaticBackup(): void { + // Schedule backup every night at 2:00 AM + cron.schedule('0 2 * * *', async () => { + try { + console.log('🌙 Starting scheduled automatic backup...'); + await this.createBackup('automatic'); + } catch (error) { + console.error('❌ Scheduled backup failed:', error); + } + }, { + timezone: 'Europe/Paris' + }); + + console.log('⏰ Automatic backup scheduled for 2:00 AM daily'); + } +} + +// Singleton instance +export const backupService = new BackupService(); \ No newline at end of file diff --git a/server/routes.ts b/server/routes.ts index 4425dd1..7345faf 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -39,6 +39,7 @@ import { import { hasPermission } from "@shared/permissions"; import { z } from "zod"; import { invoiceVerificationService } from "./invoiceVerification"; +import { backupService } from "./backupService"; export async function registerRoutes(app: Express): Promise { // Health check endpoint for Docker @@ -2338,6 +2339,60 @@ RÉSUMÉ DU SCAN } }); + // Backup management routes (Admin only) + app.get('/api/backups', isAuthenticated, requireAdmin, async (req: any, res) => { + try { + const backups = await backupService.getBackupList(); + res.json(backups); + } catch (error) { + console.error("Error fetching backups:", error); + res.status(500).json({ message: "Failed to fetch backups" }); + } + }); + + app.post('/api/backups', isAuthenticated, requireAdmin, async (req: any, res) => { + try { + const user = await storage.getUserWithGroups(req.user.claims ? req.user.claims.sub : req.user.id); + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + + const backup = await backupService.createBackup('manual', user.id); + res.json(backup); + } catch (error) { + console.error("Error creating backup:", error); + res.status(500).json({ message: "Failed to create backup" }); + } + }); + + app.get('/api/backups/:filename/download', isAuthenticated, requireAdmin, async (req: any, res) => { + try { + const { filename } = req.params; + const filepath = await backupService.downloadBackup(filename); + + res.download(filepath, filename, (err) => { + if (err) { + console.error("Error downloading backup:", err); + res.status(404).json({ message: "Backup file not found" }); + } + }); + } catch (error) { + console.error("Error downloading backup:", error); + res.status(500).json({ message: "Failed to download backup" }); + } + }); + + app.delete('/api/backups/:filename', isAuthenticated, requireAdmin, async (req: any, res) => { + try { + const { filename } = req.params; + await backupService.deleteBackup(filename); + res.json({ message: "Backup deleted successfully" }); + } catch (error) { + console.error("Error deleting backup:", error); + res.status(500).json({ message: "Failed to delete backup" }); + } + }); + // Create server instance const httpServer = createServer(app); diff --git a/shared/schema.ts b/shared/schema.ts index 93855d7..89102b0 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -11,6 +11,7 @@ import { date, decimal, primaryKey, + bigint, } from "drizzle-orm/pg-core"; import { relations } from "drizzle-orm"; import { createInsertSchema } from "drizzle-zod"; @@ -512,8 +513,24 @@ export const insertInvoiceVerificationCacheSchema = createInsertSchema(invoiceVe updatedAt: true, }); +export const databaseBackups = pgTable("database_backups", { + id: varchar("id", { length: 255 }).primaryKey(), + filename: varchar("filename", { length: 255 }).notNull(), + description: text("description"), + size: bigint("size", { mode: "number" }).default(0), + createdAt: timestamp("created_at").defaultNow(), + createdBy: varchar("created_by", { length: 255 }).notNull(), + tablesCount: integer("tables_count").default(0), + status: varchar("status", { length: 50 }).default("creating"), + backupType: varchar("backup_type", { length: 10 }).default("manual"), +}); + +export const insertDatabaseBackupSchema = createInsertSchema(databaseBackups); + export type InsertNocodbConfig = z.infer; export type InsertInvoiceVerificationCache = z.infer; +export type DatabaseBackup = typeof databaseBackups.$inferSelect; +export type InsertDatabaseBackup = z.infer; // Types export type User = typeof users.$inferSelect;