diff --git a/docs/MIGRATIONS.md b/docs/MIGRATIONS.md new file mode 100644 index 0000000..56e721f --- /dev/null +++ b/docs/MIGRATIONS.md @@ -0,0 +1,295 @@ +# Système de Migrations Automatiques + +Ce document décrit le système de migrations automatiques de NoteFlow. + +## Vue d'ensemble + +Le système de migrations automatiques s'exécute **à chaque démarrage de l'application** pour s'assurer que le schéma de la base de données est à jour. Cela permet de : + +- ✅ Déployer automatiquement les changements de schéma +- ✅ Éviter les erreurs de "colonne inexistante" +- ✅ Simplifier le processus de mise à jour +- ✅ Garantir la cohérence entre le code et la base de données + +## Comment ça fonctionne + +### 1. Démarrage de l'application + +Lors du démarrage, l'application exécute dans l'ordre : + +```javascript +1. initDatabase() // Crée les tables de base +2. autoMigrate() // Applique les migrations manquantes +3. startSchedulers() // Démarre les services +4. listen() // Lance le serveur +``` + +### 2. Vérification intelligente + +Le script `scripts/auto-migrate.js` vérifie pour chaque migration : + +```sql +-- Est-ce que la colonne existe déjà ? +SELECT column_name +FROM information_schema.columns +WHERE table_name='ma_table' AND column_name='ma_colonne' +``` + +Si la colonne n'existe pas, elle est créée automatiquement. + +### 3. Sécurité + +- **Idempotent** : Les migrations peuvent être exécutées plusieurs fois sans problème +- **Transactionnel** : Utilise `BEGIN`/`COMMIT`/`ROLLBACK` +- **Non-bloquant** : En cas d'erreur, l'application démarre quand même (mais log l'erreur) +- **Double protection** : Le code API a aussi une rétrocompatibilité en cas d'échec + +## Migrations actuelles + +### Migration 1 : Champs de tracking pour purge + +**Ajouté** : v1.1.0 + +**Objectif** : Permettre la purge automatique des données obsolètes + +**Changements** : +- `notes.archived_at` - Date d'archivage d'une note +- `global_todos.completed_at` - Date de complétion d'une tâche globale +- `note_todos.completed_at` - Date de complétion d'une tâche de note +- `note_todos.created_at` - Date de création d'une tâche de note +- Triggers PostgreSQL pour mise à jour automatique + +**Script manuel** (si besoin) : +```bash +npm run db:migrate +``` + +### Migration 2 : Champ priority pour tâches + +**Ajouté** : v1.2.0 + +**Objectif** : Permettre de marquer les tâches importantes avec une étoile + +**Changements** : +- `global_todos.priority` - Indicateur de priorité (BOOLEAN) +- `note_todos.priority` - Indicateur de priorité (BOOLEAN) +- Index pour optimiser le tri par priorité + +**Script manuel** (si besoin) : +```bash +npm run db:migrate:priority +``` + +## Ajouter une nouvelle migration + +Pour ajouter une nouvelle migration au système automatique : + +### 1. Créer le script manuel (optionnel) + +Créez un fichier dans `scripts/` pour permettre l'exécution manuelle : + +```javascript +// scripts/add-mon-champ.js +#!/usr/bin/env node + +const { Pool } = require('pg'); +// ... votre migration +``` + +### 2. Ajouter au script auto-migrate.js + +Éditez `scripts/auto-migrate.js` et ajoutez votre migration : + +```javascript +// Migration 3: Votre nouvelle fonctionnalité +logger.info(' Vérification: mon nouveau champ...'); + +const monChampExists = await client.query(` + SELECT column_name + FROM information_schema.columns + WHERE table_name='ma_table' AND column_name='mon_champ' +`); + +if (monChampExists.rows.length === 0) { + logger.info(' → Ajout du champ mon_champ à ma_table'); + await client.query(`ALTER TABLE ma_table ADD COLUMN mon_champ TYPE DEFAULT valeur`); + + // Mise à jour des données existantes si nécessaire + await client.query(`UPDATE ma_table SET mon_champ = ... WHERE ...`); +} +``` + +### 3. Tester localement + +```bash +# Démarrer l'application +npm run start + +# Vérifier les logs +# Vous devriez voir : "✓ Migrations automatiques appliquées" + +# Vérifier que le champ existe +psql $DATABASE_URL -c "\d ma_table" +``` + +### 4. Ajouter un script npm (optionnel) + +Dans `package.json` : + +```json +{ + "scripts": { + "db:migrate:mon-feature": "node scripts/add-mon-champ.js" + } +} +``` + +## Dépannage + +### La migration ne s'exécute pas + +**Vérifiez les logs au démarrage** : + +```bash +docker logs noteflow-notes-app-1 | grep -i migration +``` + +Vous devriez voir : +``` +✓ Base de données initialisée avec succès +🔄 Vérification des migrations... + Vérification: champs de tracking pour purge... + Vérification: champ priority pour tâches... +✅ Migrations automatiques terminées avec succès +✓ Migrations automatiques appliquées +``` + +### Erreur lors de la migration + +Les erreurs de migration sont **loggées mais ne bloquent pas le démarrage** : + +``` +❌ Erreur lors des migrations automatiques: ... +✓ Scheduler RSS démarré +``` + +Pour corriger : + +1. Identifiez l'erreur dans les logs +2. Corrigez le problème (droits, syntaxe SQL, etc.) +3. Redémarrez l'application + +### Forcer une migration manuelle + +Si vous préférez exécuter manuellement : + +```bash +# Dans le conteneur Docker +docker exec -it noteflow-notes-app-1 node scripts/auto-migrate.js + +# Ou avec npm +docker exec -it noteflow-notes-app-1 npm run db:migrate +docker exec -it noteflow-notes-app-1 npm run db:migrate:priority +``` + +### Vérifier l'état des migrations + +Utilisez les commandes SQL directement : + +```bash +docker exec -it noteflow-postgres-1 psql -U noteflow noteflow + +-- Vérifier les colonnes d'une table +\d notes +\d global_todos +\d note_todos + +-- Vérifier une colonne spécifique +SELECT column_name, data_type, is_nullable +FROM information_schema.columns +WHERE table_name='global_todos'; + +-- Vérifier les triggers +SELECT trigger_name, event_manipulation, event_object_table +FROM information_schema.triggers; +``` + +## Bonnes pratiques + +### ✅ À faire + +- **Toujours** utiliser `IF NOT EXISTS` / `IF EXISTS` +- **Toujours** tester en local avant de déployer +- **Toujours** ajouter des logs explicites +- **Toujours** gérer la rétrocompatibilité dans le code +- **Toujours** utiliser des transactions (`BEGIN`/`COMMIT`) +- **Toujours** mettre à jour cette documentation + +### ❌ À éviter + +- Supprimer des colonnes directement (préférer un soft-delete) +- Modifier le type d'une colonne avec données +- Faire des migrations lourdes au démarrage (>5 secondes) +- Oublier les valeurs par défaut pour les colonnes existantes +- Crasher l'application en cas d'erreur de migration + +## Architecture + +``` +📁 noteflow/ +├── 📁 scripts/ +│ ├── auto-migrate.js ← Script principal (s'exécute au démarrage) +│ ├── add-cleanup-tracking-fields.js ← Migration manuelle 1 +│ ├── add-priority-field.js ← Migration manuelle 2 +│ └── ... ← Futures migrations +├── 📁 config/ +│ └── database-postgres.js ← Schéma initial (CREATE TABLE) +├── server.js ← Appelle autoMigrate() au démarrage +└── package.json ← Scripts npm pour migrations manuelles +``` + +## Flux de démarrage + +``` +┌─────────────────────────────────────────────────┐ +│ docker-compose up / npm start │ +└──────────────────┬──────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────┐ +│ server.js: startServer() │ +└──────────────────┬──────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────┐ +│ initDatabase() │ +│ → Crée les tables de base (si n'existent pas) │ +└──────────────────┬──────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────┐ +│ autoMigrate() │ +│ → Vérifie et ajoute les colonnes manquantes │ +│ → Crée les triggers │ +│ → Crée les index │ +└──────────────────┬──────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────┐ +│ Démarrage des services │ +│ → RSS Scheduler │ +│ → Cleanup Scheduler │ +└──────────────────┬──────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────┐ +│ app.listen() - Serveur prêt ! 🚀 │ +└─────────────────────────────────────────────────┘ +``` + +## Support + +Pour plus d'informations : +- Consultez les logs : `docker logs noteflow-notes-app-1` +- Exécutez manuellement : `node scripts/auto-migrate.js` +- Vérifiez la base : `psql $DATABASE_URL -c "\d"` diff --git a/routes/todos.routes.js b/routes/todos.routes.js index c89d892..501ecb5 100644 --- a/routes/todos.routes.js +++ b/routes/todos.routes.js @@ -16,14 +16,31 @@ router.use(authenticateToken); */ router.get('/', async (req, res) => { try { - const todos = await getAll(` - SELECT id, text, completed, priority, created_at - FROM global_todos - WHERE user_id = $1 - ORDER BY priority DESC, created_at DESC - `, [req.user.id]); + // Essayer d'abord avec le champ priority (nouvelle version) + try { + const todos = await getAll(` + SELECT id, text, completed, priority, created_at + FROM global_todos + WHERE user_id = $1 + ORDER BY priority DESC, created_at DESC + `, [req.user.id]); - res.json(todos); + res.json(todos); + return; + } catch (priorityError) { + // Si le champ priority n'existe pas, utiliser l'ancienne requête + logger.info('Champ priority non trouvé, utilisation de la requête sans priority'); + const todos = await getAll(` + SELECT id, text, completed, created_at + FROM global_todos + WHERE user_id = $1 + ORDER BY created_at DESC + `, [req.user.id]); + + // Ajouter priority=false par défaut pour compatibilité frontend + const todosWithPriority = todos.map(t => ({ ...t, priority: false })); + res.json(todosWithPriority); + } } catch (error) { logger.error('Erreur lors de la récupération des todos:', error); res.status(500).json({ error: 'Erreur serveur' }); @@ -159,17 +176,29 @@ router.patch('/:id/toggle', async (req, res) => { router.patch('/:id/priority', async (req, res) => { try { // Vérifier que le todo appartient à l'utilisateur - const todo = await getOne('SELECT id, priority FROM global_todos WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]); - if (!todo) { - return res.status(404).json({ error: 'Todo non trouvé' }); + try { + const todo = await getOne('SELECT id, priority FROM global_todos WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]); + if (!todo) { + return res.status(404).json({ error: 'Todo non trouvé' }); + } + + const newPriority = todo.priority ? 0 : 1; + await runQuery('UPDATE global_todos SET priority = $1 WHERE id = $2', [newPriority, req.params.id]); + + logger.info(`Todo global ${newPriority ? 'marqué prioritaire' : 'démarqué prioritaire'} (ID: ${req.params.id}) par ${req.user.username}`); + + res.json({ message: 'Priorité modifiée avec succès', priority: newPriority === 1 }); + } catch (priorityError) { + // Si le champ priority n'existe pas encore, retourner un message d'erreur explicite + if (priorityError.message && priorityError.message.includes('priority')) { + logger.warn('Tentative d\'utilisation de la fonctionnalité priority sans migration'); + return res.status(400).json({ + error: 'Fonctionnalité priority non disponible', + message: 'Veuillez exécuter la migration: npm run db:migrate:priority' + }); + } + throw priorityError; } - - const newPriority = todo.priority ? 0 : 1; - await runQuery('UPDATE global_todos SET priority = $1 WHERE id = $2', [newPriority, req.params.id]); - - logger.info(`Todo global ${newPriority ? 'marqué prioritaire' : 'démarqué prioritaire'} (ID: ${req.params.id}) par ${req.user.username}`); - - res.json({ message: 'Priorité modifiée avec succès', priority: newPriority === 1 }); } catch (error) { logger.error('Erreur lors du toggle de la priorité:', error); res.status(500).json({ error: 'Erreur serveur' }); diff --git a/scripts/auto-migrate.js b/scripts/auto-migrate.js new file mode 100644 index 0000000..fc85faa --- /dev/null +++ b/scripts/auto-migrate.js @@ -0,0 +1,204 @@ +#!/usr/bin/env node + +/** + * Script de migrations automatiques + * S'exécute au démarrage de l'application pour mettre à jour le schéma + */ + +const { Pool } = require('pg'); +const logger = require('../config/logger'); + +const DATABASE_URL = process.env.DATABASE_URL || + `postgresql://${process.env.PGUSER || 'noteflow'}:${process.env.PGPASSWORD || 'noteflow_secure_password_change_me'}@${process.env.PGHOST || 'postgres'}:${process.env.PGPORT || '5499'}/${process.env.PGDATABASE || 'noteflow'}`; + +async function autoMigrate() { + const pool = new Pool({ connectionString: DATABASE_URL }); + + try { + logger.info('🔄 Vérification des migrations...'); + + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + + // Migration 1: Champs de tracking pour la purge (archived_at, completed_at) + logger.info(' Vérification: champs de tracking pour purge...'); + + // Vérifier si archived_at existe + const archivedAtExists = await client.query(` + SELECT column_name + FROM information_schema.columns + WHERE table_name='notes' AND column_name='archived_at' + `); + + if (archivedAtExists.rows.length === 0) { + logger.info(' → Ajout du champ archived_at à notes'); + await client.query(`ALTER TABLE notes ADD COLUMN archived_at TIMESTAMP`); + await client.query(`UPDATE notes SET archived_at = updated_at WHERE archived = TRUE AND archived_at IS NULL`); + } + + // Vérifier si completed_at existe pour global_todos + const globalTodosCompletedAtExists = await client.query(` + SELECT column_name + FROM information_schema.columns + WHERE table_name='global_todos' AND column_name='completed_at' + `); + + if (globalTodosCompletedAtExists.rows.length === 0) { + logger.info(' → Ajout du champ completed_at à global_todos'); + await client.query(`ALTER TABLE global_todos ADD COLUMN completed_at TIMESTAMP`); + await client.query(`UPDATE global_todos SET completed_at = created_at WHERE completed = TRUE AND completed_at IS NULL`); + } + + // Vérifier si note_todos a created_at et completed_at + const noteTodosCreatedAtExists = await client.query(` + SELECT column_name + FROM information_schema.columns + WHERE table_name='note_todos' AND column_name='created_at' + `); + + if (noteTodosCreatedAtExists.rows.length === 0) { + logger.info(' → Ajout du champ created_at à note_todos'); + await client.query(`ALTER TABLE note_todos ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP`); + } + + const noteTodosCompletedAtExists = await client.query(` + SELECT column_name + FROM information_schema.columns + WHERE table_name='note_todos' AND column_name='completed_at' + `); + + if (noteTodosCompletedAtExists.rows.length === 0) { + logger.info(' → Ajout du champ completed_at à note_todos'); + await client.query(`ALTER TABLE note_todos ADD COLUMN completed_at TIMESTAMP`); + await client.query(`UPDATE note_todos SET completed_at = CURRENT_TIMESTAMP WHERE completed = TRUE AND completed_at IS NULL`); + } + + // Créer les triggers pour mise à jour automatique + await client.query(` + CREATE OR REPLACE FUNCTION update_notes_archived_at() + RETURNS TRIGGER AS $$ + BEGIN + IF NEW.archived = TRUE AND OLD.archived = FALSE THEN + NEW.archived_at = CURRENT_TIMESTAMP; + ELSIF NEW.archived = FALSE THEN + NEW.archived_at = NULL; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `); + + await client.query(` + DROP TRIGGER IF EXISTS trigger_notes_archived_at ON notes; + CREATE TRIGGER trigger_notes_archived_at + BEFORE UPDATE ON notes + FOR EACH ROW + EXECUTE FUNCTION update_notes_archived_at(); + `); + + await client.query(` + CREATE OR REPLACE FUNCTION update_global_todos_completed_at() + RETURNS TRIGGER AS $$ + BEGIN + IF NEW.completed = TRUE AND OLD.completed = FALSE THEN + NEW.completed_at = CURRENT_TIMESTAMP; + ELSIF NEW.completed = FALSE THEN + NEW.completed_at = NULL; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `); + + await client.query(` + DROP TRIGGER IF EXISTS trigger_global_todos_completed_at ON global_todos; + CREATE TRIGGER trigger_global_todos_completed_at + BEFORE UPDATE ON global_todos + FOR EACH ROW + EXECUTE FUNCTION update_global_todos_completed_at(); + `); + + await client.query(` + CREATE OR REPLACE FUNCTION update_note_todos_completed_at() + RETURNS TRIGGER AS $$ + BEGIN + IF NEW.completed = TRUE AND (OLD.completed IS NULL OR OLD.completed = FALSE) THEN + NEW.completed_at = CURRENT_TIMESTAMP; + ELSIF NEW.completed = FALSE THEN + NEW.completed_at = NULL; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `); + + await client.query(` + DROP TRIGGER IF EXISTS trigger_note_todos_completed_at ON note_todos; + CREATE TRIGGER trigger_note_todos_completed_at + BEFORE UPDATE ON note_todos + FOR EACH ROW + EXECUTE FUNCTION update_note_todos_completed_at(); + `); + + // Migration 2: Champ priority pour les tâches + logger.info(' Vérification: champ priority pour tâches...'); + + const globalTodosPriorityExists = await client.query(` + SELECT column_name + FROM information_schema.columns + WHERE table_name='global_todos' AND column_name='priority' + `); + + if (globalTodosPriorityExists.rows.length === 0) { + logger.info(' → Ajout du champ priority à global_todos'); + await client.query(`ALTER TABLE global_todos ADD COLUMN priority BOOLEAN DEFAULT FALSE`); + } + + const noteTodosPriorityExists = await client.query(` + SELECT column_name + FROM information_schema.columns + WHERE table_name='note_todos' AND column_name='priority' + `); + + if (noteTodosPriorityExists.rows.length === 0) { + logger.info(' → Ajout du champ priority à note_todos'); + await client.query(`ALTER TABLE note_todos ADD COLUMN priority BOOLEAN DEFAULT FALSE`); + } + + // Créer les index pour performance + await client.query(`CREATE INDEX IF NOT EXISTS idx_global_todos_priority ON global_todos(priority DESC, created_at DESC)`); + await client.query(`CREATE INDEX IF NOT EXISTS idx_note_todos_priority ON note_todos(priority DESC, position)`); + + await client.query('COMMIT'); + + logger.info('✅ Migrations automatiques terminées avec succès'); + + } catch (error) { + await client.query('ROLLBACK'); + logger.error('❌ Erreur lors des migrations automatiques:', error); + throw error; + } finally { + client.release(); + } + + await pool.end(); + + } catch (error) { + logger.error('❌ Erreur fatale lors des migrations:', error); + // Ne pas crasher l'application, juste logger l'erreur + } +} + +// Si appelé directement +if (require.main === module) { + autoMigrate().then(() => { + process.exit(0); + }).catch((error) => { + console.error(error); + process.exit(1); + }); +} + +module.exports = { autoMigrate }; diff --git a/server.js b/server.js index 39c8688..0deb779 100644 --- a/server.js +++ b/server.js @@ -134,6 +134,11 @@ async function startServer() { await initDatabase(); logger.info('✓ Base de données initialisée avec succès'); + // Exécuter les migrations automatiques + const { autoMigrate } = require('./scripts/auto-migrate'); + await autoMigrate(); + logger.info('✓ Migrations automatiques appliquées'); + // Démarrer le scheduler RSS const rssScheduler = require('./services/rss-scheduler'); rssScheduler.startScheduler();