From a18907c0ee4d168bd769dc5f1139213df904f974 Mon Sep 17 00:00:00 2001 From: Victor Prouff Date: Sun, 8 Mar 2026 13:12:09 +0100 Subject: [PATCH] feat: add data export/import feature - Add GET /api/data/export: dumps all user data (budget, tasks, recipes, meal plans, shopping, appointments, schedule) as a versioned JSON file - Add POST /api/data/import: restores data from an export file inside a transaction; inserts are idempotent (ON CONFLICT DO NOTHING) - Import order respects FK constraints (family_members and recipes first) - Add Settings page with Export and Import UI cards - Register /settings route and add entry in sidebar navigation Co-Authored-By: Claude Sonnet 4.6 --- client/src/App.tsx | 2 + client/src/components/layout/Layout.tsx | 2 + client/src/pages/Settings.tsx | 212 ++++++++++++++++++++++++ server/src/app.ts | 2 + server/src/routes/dataTransfer.ts | 113 +++++++++++++ 5 files changed, 331 insertions(+) create mode 100644 client/src/pages/Settings.tsx create mode 100644 server/src/routes/dataTransfer.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index e612246..66f20b2 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -11,6 +11,7 @@ import Recipes from './pages/Recipes'; import MealPlanning from './pages/MealPlanning'; import Budget from './pages/Budget'; import Family from './pages/Family'; +import Settings from './pages/Settings'; function App() { const { isAuthenticated, loading } = useAuth(); @@ -42,6 +43,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/client/src/components/layout/Layout.tsx b/client/src/components/layout/Layout.tsx index bd59de0..5d80987 100644 --- a/client/src/components/layout/Layout.tsx +++ b/client/src/components/layout/Layout.tsx @@ -12,6 +12,7 @@ import { UtensilsCrossed, Wallet, Users, + Settings, Moon, Sun, LogOut, @@ -36,6 +37,7 @@ const navigation = [ { name: 'Repas', href: '/meal-planning', icon: UtensilsCrossed }, { name: 'Budget', href: '/budget', icon: Wallet }, { name: 'Famille', href: '/family', icon: Users }, + { name: 'Paramètres', href: '/settings', icon: Settings }, ]; const mobileTabs = [ diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx new file mode 100644 index 0000000..d637cb7 --- /dev/null +++ b/client/src/pages/Settings.tsx @@ -0,0 +1,212 @@ +import React, { useRef, useState } from 'react'; +import { api } from '../lib/api'; +import { Download, Upload, CheckCircle, AlertCircle, Loader2 } from 'lucide-react'; +import { Card, CardContent, Button } from '../components/ui'; + +interface ImportCounts { + family_members?: number; + tasks?: number; + recipes?: number; + meal_plans?: number; + budget_entries?: number; + budget_limits?: number; + shopping_items?: number; + appointments?: number; + schedule_entries?: number; +} + +const ENTITY_LABELS: Record = { + family_members: 'Membres de la famille', + tasks: 'Tâches', + recipes: 'Recettes', + meal_plans: 'Repas planifiés', + budget_entries: 'Entrées budget', + budget_limits: 'Limites budget', + shopping_items: 'Articles de courses', + appointments: 'Rendez-vous', + schedule_entries: 'Plannings', +}; + +const Settings: React.FC = () => { + const fileInputRef = useRef(null); + const [exportLoading, setExportLoading] = useState(false); + const [exportError, setExportError] = useState(''); + const [importLoading, setImportLoading] = useState(false); + const [importError, setImportError] = useState(''); + const [importSuccess, setImportSuccess] = useState(null); + const [selectedFile, setSelectedFile] = useState(null); + + const handleExport = async () => { + setExportLoading(true); + setExportError(''); + try { + const response = await api.get<{ success: boolean; data: unknown }>('/api/data/export'); + const blob = new Blob([JSON.stringify(response.data, null, 2)], { + type: 'application/json', + }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `openfamily-export-${new Date().toISOString().split('T')[0]}.json`; + a.click(); + URL.revokeObjectURL(url); + } catch (error) { + setExportError(error instanceof Error ? error.message : 'Erreur lors de l\'export.'); + } finally { + setExportLoading(false); + } + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] ?? null; + setSelectedFile(file); + setImportError(''); + setImportSuccess(null); + }; + + const handleImport = async () => { + if (!selectedFile) return; + setImportLoading(true); + setImportError(''); + setImportSuccess(null); + try { + const text = await selectedFile.text(); + const parsed = JSON.parse(text); + + // Accept both the raw export format and the full API response + const data = parsed.success && parsed.data ? parsed.data : parsed; + + const response = await api.post<{ success: boolean; data: { imported: ImportCounts } }>( + '/api/data/import', + data + ); + if (response.success) { + setImportSuccess(response.data.imported); + setSelectedFile(null); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + } catch (error) { + if (error instanceof SyntaxError) { + setImportError('Fichier JSON invalide.'); + } else { + setImportError(error instanceof Error ? error.message : 'Erreur lors de l\'import.'); + } + } finally { + setImportLoading(false); + } + }; + + return ( +
+
+

Paramètres

+

Gérez vos données et préférences.

+
+ + {/* Export */} + + +
+
+ +
+
+

Exporter les données

+

+ Télécharge toutes vos données (budget, tâches, recettes, membres, courses, + rendez-vous, plannings, repas) dans un fichier JSON. +

+ {exportError && ( +

+ + {exportError} +

+ )} + +
+
+
+
+ + {/* Import */} + + +
+
+ +
+
+

Importer des données

+

+ Restaure des données depuis un fichier d'export OpenFamily. Les données + existantes ne sont pas écrasées (doublons ignorés). +

+ + {importSuccess && ( +
+

+ + Import réussi +

+
    + {Object.entries(importSuccess).map(([key, count]) => ( +
  • + {ENTITY_LABELS[key] ?? key} : {count} élément(s) importé(s) +
  • + ))} +
+
+ )} + + {importError && ( +

+ + {importError} +

+ )} + +
+ + {selectedFile && ( + + )} +
+
+
+
+
+
+ ); +}; + +export default Settings; diff --git a/server/src/app.ts b/server/src/app.ts index bf95085..8ae890b 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -10,6 +10,7 @@ import budgetRoutes from './routes/budget'; import familyRoutes from './routes/family'; import dashboardRoutes from './routes/dashboard'; import planningRoutes from './routes/planning'; +import dataTransferRoutes from './routes/dataTransfer'; import { loadEnv } from './config/loadEnv'; loadEnv(); @@ -47,6 +48,7 @@ app.use('/api/budget', budgetRoutes); app.use('/api/family', familyRoutes); app.use('/api/dashboard', dashboardRoutes); app.use('/api/planning', planningRoutes); +app.use('/api/data', dataTransferRoutes); // 404 handler app.use((req, res) => { diff --git a/server/src/routes/dataTransfer.ts b/server/src/routes/dataTransfer.ts new file mode 100644 index 0000000..23eb6da --- /dev/null +++ b/server/src/routes/dataTransfer.ts @@ -0,0 +1,113 @@ +import { Router } from 'express'; +import { getClient, query } from '../db'; +import { authMiddleware, AuthRequest } from '../middleware/auth'; + +const router = Router(); +router.use(authMiddleware); + +// Export all user data +router.get('/export', async (req: AuthRequest, res) => { + try { + const userId = req.userId!; + + const [ + familyMembers, + tasks, + recipes, + mealPlans, + budgetEntries, + budgetLimits, + shoppingItems, + appointments, + scheduleEntries, + ] = await Promise.all([ + query('SELECT * FROM family_members WHERE user_id = $1', [userId]), + query('SELECT * FROM tasks WHERE user_id = $1', [userId]), + query('SELECT * FROM recipes WHERE user_id = $1', [userId]), + query('SELECT * FROM meal_plans WHERE user_id = $1', [userId]), + query('SELECT * FROM budget_entries WHERE user_id = $1', [userId]), + query('SELECT * FROM budget_limits WHERE user_id = $1', [userId]), + query('SELECT * FROM shopping_items WHERE user_id = $1', [userId]), + query('SELECT * FROM appointments WHERE user_id = $1', [userId]), + query('SELECT * FROM schedule_entries WHERE user_id = $1', [userId]), + ]); + + const exportData = { + version: '1.0', + exportedAt: new Date().toISOString(), + family_members: familyMembers.rows, + tasks: tasks.rows, + recipes: recipes.rows, + meal_plans: mealPlans.rows, + budget_entries: budgetEntries.rows, + budget_limits: budgetLimits.rows, + shopping_items: shoppingItems.rows, + appointments: appointments.rows, + schedule_entries: scheduleEntries.rows, + }; + + res.json({ success: true, data: exportData }); + } catch (error) { + console.error('Export error:', error); + res.status(500).json({ success: false, error: 'Internal server error' }); + } +}); + +// Import user data +router.post('/import', async (req: AuthRequest, res) => { + const userId = req.userId!; + const importData = req.body; + + if (!importData || typeof importData !== 'object') { + return res.status(400).json({ success: false, error: 'Invalid import data format' }); + } + + const client = await getClient(); + const counts: Record = {}; + + const importRows = async (table: string, rows: unknown) => { + if (!Array.isArray(rows) || rows.length === 0) return; + let count = 0; + for (const row of rows) { + const entry: Record = { ...(row as Record), user_id: userId }; + const keys = Object.keys(entry); + const values = keys.map((k) => entry[k]); + const placeholders = keys.map((_, i) => `$${i + 1}`); + const result = await client.query( + `INSERT INTO ${table} (${keys.map((k) => `"${k}"`).join(', ')}) + VALUES (${placeholders.join(', ')}) + ON CONFLICT DO NOTHING`, + values + ); + count += result.rowCount ?? 0; + } + counts[table] = count; + }; + + try { + await client.query('BEGIN'); + + // Import in order respecting foreign key constraints: + // family_members and recipes must come before tables that reference them + await importRows('family_members', importData.family_members); + await importRows('recipes', importData.recipes); + await importRows('tasks', importData.tasks); + await importRows('budget_entries', importData.budget_entries); + await importRows('budget_limits', importData.budget_limits); + await importRows('shopping_items', importData.shopping_items); + await importRows('appointments', importData.appointments); + await importRows('schedule_entries', importData.schedule_entries); + await importRows('meal_plans', importData.meal_plans); + + await client.query('COMMIT'); + res.json({ success: true, data: { imported: counts } }); + } catch (error) { + await client.query('ROLLBACK'); + console.error('Import error:', error); + res.status(500).json({ success: false, error: 'Import failed. No data was modified.' }); + } finally { + client.release(); + } +}); + +export default router;