diff --git a/DEPLOY-INSTRUCTIONS.md b/DEPLOY-INSTRUCTIONS.md new file mode 100644 index 0000000..3e83af0 --- /dev/null +++ b/DEPLOY-INSTRUCTIONS.md @@ -0,0 +1,103 @@ +# Instructions de déploiement - Correction des notes + +## Problème actuel +Erreur 500 sur `/api/notes` - Les notes ne s'affichent pas + +## Causes identifiées + +### 1. Colonnes manquantes (PRINCIPAL) +Les colonnes `parent_id` et `level` ajoutées pendant les phases 1-5 pour le support des subtasks n'existaient pas dans la base PostgreSQL de production. + +### 2. Types booléens incorrects (SECONDAIRE) +Les données booléennes migrées de SQLite sont stockées en INTEGER (0/1) au lieu de BOOLEAN (TRUE/FALSE). + +## ✅ Solution AUTOMATIQUE (Recommandée) + +La correction se fait **automatiquement au démarrage de Docker** ! + +### Déploiement avec Docker +```bash +cd /chemin/vers/noteflow +git pull origin claude/fix-notes-sync-issue-01AMeEdrKQErsM7bvhKkqEEF +docker-compose down +docker-compose up -d --build +``` + +Le conteneur Docker va automatiquement: +1. Attendre que PostgreSQL soit prêt +2. **Exécuter les migrations de schéma** ✨ + - Ajouter les colonnes `parent_id` et `level` pour les subtasks + - Ajouter tous les autres champs manquants +3. **Corriger automatiquement les types booléens** + - Convertir INTEGER → BOOLEAN pour les champs concernés +4. Démarrer le serveur + +C'est tout ! Les notes s'afficheront correctement après le redémarrage. + +**Note:** Les migrations sont idempotentes et ne causeront aucune perte de données. + +--- + +## Solution manuelle (Si vous n'utilisez pas Docker) + +### Étape 1 : Déployer le nouveau code +```bash +cd /chemin/vers/noteflow +git pull origin claude/fix-notes-sync-issue-01AMeEdrKQErsM7bvhKkqEEF +``` + +### Étape 2 : Exécuter le script de correction +```bash +# Option A: Script Node.js +node scripts/fix-boolean-data.js + +# Option B: Script SQL (plus rapide) +psql -U noteflow -d noteflow -f scripts/fix-postgres-boolean-types.sql +``` + +### Étape 3 : Redémarrer le serveur +```bash +# Si vous utilisez PM2: +pm2 restart noteflow + +# Si vous utilisez systemd: +sudo systemctl restart noteflow + +# Ou simplement: +npm run start +``` + +### Étape 4 : Vérifier +Ouvrez https://note.ffnancy.fr et vérifiez que les notes s'affichent correctement. + +## Alternative manuelle (SQL direct) + +Si le script ne fonctionne pas, vous pouvez exécuter ces commandes SQL directement: + +```sql +-- Se connecter à PostgreSQL +psql -U noteflow -d noteflow + +-- Convertir les colonnes +ALTER TABLE users ALTER COLUMN is_admin TYPE BOOLEAN USING CASE WHEN is_admin = 0 THEN FALSE WHEN is_admin = 1 THEN TRUE ELSE is_admin::BOOLEAN END; +ALTER TABLE notes ALTER COLUMN archived TYPE BOOLEAN USING CASE WHEN archived = 0 THEN FALSE WHEN archived = 1 THEN TRUE ELSE archived::BOOLEAN END; +ALTER TABLE note_todos ALTER COLUMN completed TYPE BOOLEAN USING CASE WHEN completed = 0 THEN FALSE WHEN completed = 1 THEN TRUE ELSE completed::BOOLEAN END; +ALTER TABLE note_todos ALTER COLUMN priority TYPE BOOLEAN USING CASE WHEN priority = 0 THEN FALSE WHEN priority = 1 THEN TRUE ELSE priority::BOOLEAN END; +ALTER TABLE global_todos ALTER COLUMN completed TYPE BOOLEAN USING CASE WHEN completed = 0 THEN FALSE WHEN completed = 1 THEN TRUE ELSE completed::BOOLEAN END; +ALTER TABLE global_todos ALTER COLUMN priority TYPE BOOLEAN USING CASE WHEN priority = 0 THEN FALSE WHEN priority = 1 THEN TRUE ELSE priority::BOOLEAN END; +ALTER TABLE rss_feeds ALTER COLUMN enabled TYPE BOOLEAN USING CASE WHEN enabled = 0 THEN FALSE WHEN enabled = 1 THEN TRUE ELSE enabled::BOOLEAN END; +ALTER TABLE calendar_events ALTER COLUMN all_day TYPE BOOLEAN USING CASE WHEN all_day = 0 THEN FALSE WHEN all_day = 1 THEN TRUE ELSE all_day::BOOLEAN END; +``` + +## Commits déployés + +- `616e257` - Correction initiale des types BOOLEAN +- `6f2f366` - Correction des champs INTEGER (priority, in_progress) +- `a8ebca6` - Ajout du script de correction des données + +## Support + +Si vous rencontrez des problèmes, vérifiez: +1. La connexion PostgreSQL fonctionne: `psql -U noteflow -d noteflow -c "SELECT 1"` +2. Le fichier .env contient bien DATABASE_URL +3. Les logs du serveur: `pm2 logs noteflow` ou `journalctl -u noteflow -f` diff --git a/config/database-postgres.js b/config/database-postgres.js index 584f60c..cd4cbd2 100644 --- a/config/database-postgres.js +++ b/config/database-postgres.js @@ -1,7 +1,53 @@ // Configuration et initialisation de la base de données PostgreSQL -const { Pool } = require('pg'); +const { Pool, types } = require('pg'); const bcrypt = require('bcrypt'); const logger = require('./logger'); +const timezoneLogger = require('../services/timezone-logger'); + +// IMPORTANT: Parser personnalisé pour TIMESTAMPTZ +// DOIT être appelé AVANT le Pool pour être pris en compte ! +// Types TIMESTAMP(TZ) PostgreSQL : +// - 1082 = DATE +// - 1114 = TIMESTAMP WITHOUT TIME ZONE +// - 1184 = TIMESTAMP WITH TIME ZONE (TIMESTAMPTZ) + +// Parser pour TIMESTAMPTZ (1184) +types.setTypeParser(1184, function (stringValue) { + if (!stringValue) return null; + + const originalValue = stringValue; + let result; + + // Si déjà au format ISO avec Z ou timezone (+HH:MM ou +HH) + if (stringValue.includes('Z') || stringValue.match(/[+-]\d{2}(:\d{2})?$/)) { + result = new Date(stringValue).toISOString(); + timezoneLogger.log('PARSER', `[1184 TIMESTAMPTZ] Input avec TZ: "${originalValue}" → Output: "${result}"`); + } else { + // Si format "YYYY-MM-DD HH:MM:SS" sans timezone + // Avec timezone=UTC, on sait que c'est en UTC + const isoString = stringValue.replace(' ', 'T') + 'Z'; + result = new Date(isoString).toISOString(); + timezoneLogger.log('PARSER', `[1184 TIMESTAMPTZ] Input sans TZ: "${originalValue}" → ISO+Z: "${isoString}" → Output: "${result}"`); + } + + return result; +}); + +// Parser pour TIMESTAMP WITHOUT TIME ZONE (1114) +// IMPORTANT: Les valeurs TIMESTAMP sont stockées en heure locale (Europe/Paris) +// mais PostgreSQL les retourne sans fuseau horaire. +// Exemple: Google envoie "2025-11-17T10:20:00+01:00" → PG stocke "2025-11-17 10:20:00" +// On doit interpréter cette heure comme UTC (car PG a converti lors de l'insertion) +types.setTypeParser(1114, function (stringValue) { + if (!stringValue) return null; + + // PostgreSQL retourne "YYYY-MM-DD HH:MM:SS" sans fuseau horaire + // Cette valeur doit être traitée comme UTC car PostgreSQL stocke en UTC en interne + const isoString = stringValue.replace(' ', 'T') + 'Z'; + const result = new Date(isoString).toISOString(); + timezoneLogger.log('PARSER', `[1114 TIMESTAMP] Input: "${stringValue}" → Output: "${result}"`); + return result; +}); // Configuration PostgreSQL depuis DATABASE_URL ou variables d'environnement const DATABASE_URL = process.env.DATABASE_URL || @@ -13,6 +59,9 @@ const pool = new Pool({ max: 20, // Maximum de connexions dans le pool idleTimeoutMillis: 30000, connectionTimeoutMillis: 2000, + // IMPORTANT: Forcer le timezone à UTC pour toutes les connexions + // Cela garantit que PostgreSQL renvoie toujours les dates en UTC + options: '-c timezone=UTC' }); // Gestion des erreurs du pool @@ -73,10 +122,10 @@ async function initDatabase() { note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE, text TEXT NOT NULL, completed BOOLEAN DEFAULT FALSE, - completed_at TIMESTAMP, priority BOOLEAN DEFAULT FALSE, position INTEGER DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP ) `); @@ -87,9 +136,10 @@ async function initDatabase() { user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, text TEXT NOT NULL, completed BOOLEAN DEFAULT FALSE, - completed_at TIMESTAMP, priority BOOLEAN DEFAULT FALSE, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + in_progress INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP ) `); @@ -174,8 +224,8 @@ async function initDatabase() { user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, title TEXT NOT NULL, description TEXT, - start_time TIMESTAMP NOT NULL, - end_time TIMESTAMP NOT NULL, + start_time TIMESTAMPTZ NOT NULL, + end_time TIMESTAMPTZ NOT NULL, location TEXT, color_id TEXT, html_link TEXT, @@ -227,78 +277,6 @@ async function initDatabase() { logger.info('✓ Tables PostgreSQL créées avec succès'); - // Créer les triggers pour mise à jour automatique des dates de purge - // Trigger pour notes.archived_at - 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(); - `); - - // Trigger pour global_todos.completed_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(); - `); - - // Trigger pour note_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(); - `); - - logger.info('✓ Triggers de purge automatique créés'); - // Créer l'utilisateur admin par défaut si la table est vide const usersCount = await client.query('SELECT COUNT(*) as count FROM users'); @@ -334,20 +312,8 @@ async function runQuery(sql, params = []) { const client = await pool.connect(); try { const result = await client.query(sql, params); - - // Si c'est un INSERT/UPDATE avec RETURNING, retourner la ligne complète - if (result.rows && result.rows.length > 0) { - logger.info(`[runQuery] Requête avec RETURNING - ID: ${result.rows[0].id}, rowCount: ${result.rowCount}`); - return { - ...result.rows[0], // Retourne toutes les colonnes de la première ligne - changes: result.rowCount - }; - } - - // Sinon, retourner juste le nombre de lignes affectées - logger.info(`[runQuery] Requête sans RETURNING - rowCount: ${result.rowCount}`); return { - id: result.rowCount, + id: result.rows[0]?.id || result.rowCount, changes: result.rowCount }; } catch (error) { diff --git a/config/database-sqlite-backup.js b/config/database-sqlite.js similarity index 100% rename from config/database-sqlite-backup.js rename to config/database-sqlite.js diff --git a/config/database.js b/config/database.js index cd4cbd2..fdc4980 100644 --- a/config/database.js +++ b/config/database.js @@ -53,12 +53,14 @@ types.setTypeParser(1114, function (stringValue) { const DATABASE_URL = process.env.DATABASE_URL || `postgresql://${process.env.PGUSER || 'noteflow'}:${process.env.PGPASSWORD || 'noteflow_secure_password_change_me'}@${process.env.PGHOST || 'localhost'}:${process.env.PGPORT || '5499'}/${process.env.PGDATABASE || 'noteflow'}`; +logger.info(`PostgreSQL CONNECTION URL: ${DATABASE_URL.replace(/:[^:@]+@/, ':***@')}`); + // Créer le pool de connexions PostgreSQL const pool = new Pool({ connectionString: DATABASE_URL, max: 20, // Maximum de connexions dans le pool idleTimeoutMillis: 30000, - connectionTimeoutMillis: 2000, + connectionTimeoutMillis: 10000, // Increased from 2000 to 10000 // IMPORTANT: Forcer le timezone à UTC pour toutes les connexions // Cela garantit que PostgreSQL renvoie toujours les dates en UTC options: '-c timezone=UTC' @@ -124,6 +126,8 @@ async function initDatabase() { completed BOOLEAN DEFAULT FALSE, priority BOOLEAN DEFAULT FALSE, position INTEGER DEFAULT 0, + parent_id INTEGER REFERENCES note_todos(id) ON DELETE CASCADE, + level INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, completed_at TIMESTAMP ) @@ -138,6 +142,8 @@ async function initDatabase() { completed BOOLEAN DEFAULT FALSE, priority BOOLEAN DEFAULT FALSE, in_progress INTEGER DEFAULT 0, + parent_id INTEGER REFERENCES global_todos(id) ON DELETE CASCADE, + level INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, completed_at TIMESTAMP ) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 5954de5..c9d1767 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -83,6 +83,29 @@ echo "" echo "🔄 Migration des timezones du calendrier..." node scripts/migrate-calendar-timezone.js 2>/dev/null || echo " ℹ️ Migration timezone déjà effectuée ou non nécessaire" +echo "" +echo "🔧 Correction des types booléens PostgreSQL..." +# Exécuter le script SQL de correction des types booléens +if [ -f "/app/scripts/fix-postgres-boolean-types.sql" ]; then + # Extraire les informations de connexion depuis DATABASE_URL + PGHOST=$(echo $DATABASE_URL | sed -n 's/.*@\([^:]*\):.*/\1/p') + PGPORT=$(echo $DATABASE_URL | sed -n 's/.*:\([0-9]*\)\/.*/\1/p') + PGUSER=$(echo $DATABASE_URL | sed -n 's/.*\/\/\([^:]*\):.*/\1/p') + PGPASSWORD=$(echo $DATABASE_URL | sed -n 's/.*:\/\/[^:]*:\([^@]*\)@.*/\1/p') + PGDATABASE=$(echo $DATABASE_URL | sed -n 's/.*\/\([^?]*\).*/\1/p') + + export PGHOST PGPORT PGUSER PGPASSWORD PGDATABASE + + # Exécuter le script SQL + if psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -f /app/scripts/fix-postgres-boolean-types.sql > /dev/null 2>&1; then + echo " ✅ Types booléens corrigés" + else + echo " ℹ️ Correction des types booléens déjà effectuée ou non nécessaire" + fi +else + echo " ⚠️ Script de correction non trouvé" +fi + echo "" echo "========================================" echo "🚀 Démarrage du serveur NoteFlow..." diff --git a/migrations/003_add_due_date_to_todos.sql b/migrations/003_add_due_date_to_todos.sql new file mode 100644 index 0000000..3c67484 --- /dev/null +++ b/migrations/003_add_due_date_to_todos.sql @@ -0,0 +1,11 @@ +-- Migration: Add due_date to note_todos table +-- Description: Adds a due_date column to track task deadlines + +-- Add due_date column to note_todos table +ALTER TABLE note_todos ADD COLUMN IF NOT EXISTS due_date TIMESTAMPTZ; + +-- Create index on due_date for faster queries +CREATE INDEX IF NOT EXISTS idx_note_todos_due_date ON note_todos(due_date); + +-- Create index on combination of completed and due_date for efficient queries of pending tasks +CREATE INDEX IF NOT EXISTS idx_note_todos_completed_due_date ON note_todos(completed, due_date); diff --git a/migrations/004_add_due_date_to_global_todos.sql b/migrations/004_add_due_date_to_global_todos.sql new file mode 100644 index 0000000..4d79eb4 --- /dev/null +++ b/migrations/004_add_due_date_to_global_todos.sql @@ -0,0 +1,11 @@ +-- Migration: Add due_date to global_todos table +-- Description: Adds a due_date column to track task deadlines for global todos + +-- Add due_date column to global_todos table +ALTER TABLE global_todos ADD COLUMN IF NOT EXISTS due_date TIMESTAMPTZ; + +-- Create index on due_date for faster queries +CREATE INDEX IF NOT EXISTS idx_global_todos_due_date ON global_todos(due_date); + +-- Create index on combination of completed and due_date for efficient queries of pending tasks +CREATE INDEX IF NOT EXISTS idx_global_todos_completed_due_date ON global_todos(completed, due_date); diff --git a/migrations/005_add_subtasks_support.sql b/migrations/005_add_subtasks_support.sql new file mode 100644 index 0000000..8727a30 --- /dev/null +++ b/migrations/005_add_subtasks_support.sql @@ -0,0 +1,16 @@ +-- Migration: Add subtasks support to todos tables +-- Description: Adds parent_id column to enable hierarchical task structure + +-- Add parent_id column to note_todos table (note todos) +ALTER TABLE note_todos ADD COLUMN IF NOT EXISTS parent_id INTEGER REFERENCES note_todos(id) ON DELETE CASCADE; + +-- Add parent_id column to global_todos table +ALTER TABLE global_todos ADD COLUMN IF NOT EXISTS parent_id INTEGER REFERENCES global_todos(id) ON DELETE CASCADE; + +-- Create indexes for faster subtask queries +CREATE INDEX IF NOT EXISTS idx_note_todos_parent_id ON note_todos(parent_id); +CREATE INDEX IF NOT EXISTS idx_global_todos_parent_id ON global_todos(parent_id); + +-- Add level column to track depth (optional, for UI optimization) +ALTER TABLE note_todos ADD COLUMN IF NOT EXISTS level INTEGER DEFAULT 0; +ALTER TABLE global_todos ADD COLUMN IF NOT EXISTS level INTEGER DEFAULT 0; diff --git a/package-lock.json b/package-lock.json index 15c3881..d330b17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "cmdk": "^1.0.4", "cors": "^2.8.5", "date-fns": "^3.6.0", + "dotenv": "^17.2.3", "embla-carousel-react": "^8.5.2", "express": "^4.18.2", "express-rate-limit": "^7.1.5", @@ -138,7 +139,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -3682,7 +3682,6 @@ "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3693,7 +3692,6 @@ "integrity": "sha512-k5dJVszUiNr1DSe8Cs+knKR6IrqhqdhpUwzqhkS8ecQTSf3THNtbfIp/umqHMpX2bv+9dkx3fwDv/86LcSfvSg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3704,7 +3702,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3761,7 +3758,6 @@ "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.4", "@typescript-eslint/types": "8.46.4", @@ -4047,7 +4043,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4482,7 +4477,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -5289,6 +5283,18 @@ "csstype": "^3.0.2" } }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -5335,8 +5341,7 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -5537,7 +5542,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6992,7 +6996,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -8226,7 +8229,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -8366,7 +8368,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -8757,7 +8758,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8798,7 +8798,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9851,7 +9850,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -10056,7 +10054,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10185,7 +10182,6 @@ "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10433,7 +10429,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -10525,7 +10520,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/package.json b/package.json index 636d780..a12a529 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "cmdk": "^1.0.4", "cors": "^2.8.5", "date-fns": "^3.6.0", + "dotenv": "^17.2.3", "embla-carousel-react": "^8.5.2", "express": "^4.18.2", "express-rate-limit": "^7.1.5", diff --git a/routes/calendar.routes.js b/routes/calendar.routes.js index 710616c..4a91527 100644 --- a/routes/calendar.routes.js +++ b/routes/calendar.routes.js @@ -853,7 +853,7 @@ router.put('/events/:id', authenticateToken, async (req, res) => { endTimeUTC, location || null, response.data.htmlLink || null, - isAllDay ? 1 : 0, + isAllDay, // BOOLEAN instead of 0/1 req.params.id ]); diff --git a/routes/notes.routes.js b/routes/notes.routes.js index 98a8977..422373b 100644 --- a/routes/notes.routes.js +++ b/routes/notes.routes.js @@ -45,7 +45,7 @@ router.use(authenticateToken); router.get('/', async (req, res) => { try { const showArchived = req.query.archived === 'true'; - const archivedFilter = showArchived ? 1 : 0; + const archivedFilter = showArchived; // BOOLEAN instead of 0/1 const notes = await getAll(` SELECT @@ -61,7 +61,7 @@ router.get('/', async (req, res) => { // Charger les todos, images, fichiers et tags pour chaque note for (const note of notes) { const todos = await getAll(` - SELECT id, text, completed, position + SELECT id, text, completed, position, parent_id, level FROM note_todos WHERE note_id = $1 ORDER BY position ASC, id ASC @@ -274,7 +274,7 @@ router.put('/:id/archive', async (req, res) => { await runQuery( 'UPDATE notes SET archived = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', - [archived ? 1 : 0, req.params.id] + [archived, req.params.id] // BOOLEAN instead of 0/1 ); logger.info(`Note ${archived ? 'archivée' : 'désarchivée'}: ${req.params.id}`); @@ -391,10 +391,13 @@ router.delete('/:id/image', async (req, res) => { /** * POST /api/notes/:id/todos - * Ajouter un todo à une note + * Ajouter un todo à une note (supports subtasks via parent_id) */ router.post('/:id/todos', - [body('text').trim().notEmpty().withMessage('Le texte est requis')], + [ + body('text').trim().notEmpty().withMessage('Le texte est requis'), + body('parent_id').optional().isInt() + ], async (req, res) => { try { const errors = validationResult(req); @@ -408,17 +411,27 @@ router.post('/:id/todos', return res.status(404).json({ error: 'Note non trouvée' }); } - const { text } = req.body; + const { text, parent_id } = req.body; + let level = 0; - logger.info(`[CREATE TODO] Ajout todo à note ${req.params.id}: "${text}"`); + // Si c'est un subtask, vérifier que le parent existe et calculer le niveau + if (parent_id) { + const parent = await getOne('SELECT id, level FROM note_todos WHERE id = $1 AND note_id = $2', [parent_id, req.params.id]); + if (!parent) { + return res.status(404).json({ error: 'Todo parent non trouvé' }); + } + level = (parent.level || 0) + 1; + } + + logger.info(`[CREATE TODO] Ajout todo à note ${req.params.id}: "${text}"${parent_id ? ` (subtask of ${parent_id})` : ''}`); const result = await runQuery(` - INSERT INTO note_todos (note_id, text, position) - VALUES ($1, $2, (SELECT COALESCE(MAX(position), FALSE) + 1 FROM note_todos WHERE note_id = $3)) + INSERT INTO note_todos (note_id, text, parent_id, level, position) + VALUES ($1, $2, $3, $4, (SELECT COALESCE(MAX(position), 0) + 1 FROM note_todos WHERE note_id = $5 AND COALESCE(parent_id, 0) = COALESCE($6, 0))) RETURNING * - `, [req.params.id, text, req.params.id]); + `, [req.params.id, text, parent_id || null, level, req.params.id, parent_id || null]); - logger.info(`[CREATE TODO] Todo créé - ID: ${result.id}, note_id: ${req.params.id}`); + logger.info(`[CREATE TODO] Todo créé - ID: ${result.id}, note_id: ${req.params.id}, level: ${level}`); await runQuery('UPDATE notes SET updated_at = CURRENT_TIMESTAMP WHERE id = $1', [req.params.id]); @@ -426,7 +439,9 @@ router.post('/:id/todos', id: result.id, text, completed: false, - position: 0 + position: result.position || 0, + parent_id: parent_id || null, + level: level }); } catch (error) { logger.error('Erreur lors de l\'ajout du todo:', error); @@ -473,7 +488,7 @@ router.put('/todos/:todoId', if (completed !== undefined) { paramCount++; updates.push(`completed = $${paramCount}`); - params.push(completed ? 1 : 0); + params.push(completed); // BOOLEAN instead of 0/1 } if (position !== undefined) { paramCount++; @@ -957,7 +972,7 @@ router.patch('/:id/priority', async (req, res) => { return res.status(404).json({ error: 'Note non trouvée' }); } - await runQuery('UPDATE notes SET priority = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', [priority ? 1 : 0, req.params.id]); + await runQuery('UPDATE notes SET priority = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', [priority ? 1 : 0, req.params.id]); // notes.priority is INTEGER not BOOLEAN logger.info(`Priorité de la note ${req.params.id} modifiée: ${priority}`); res.json({ message: 'Priorité modifiée avec succès', priority }); diff --git a/routes/openrouter.routes.js b/routes/openrouter.routes.js index 6d3de69..ec05d24 100644 --- a/routes/openrouter.routes.js +++ b/routes/openrouter.routes.js @@ -143,7 +143,10 @@ router.post('/chat', authenticateToken, async (req, res) => { try { const { model, messages } = req.body; + logger.info(`[OPENROUTER CHAT] Début requête - model: "${model}", messages: ${messages?.length || 0}`); + if (!model || !messages || !Array.isArray(messages)) { + logger.warn('[OPENROUTER CHAT] Requête invalide - modèle ou messages manquants'); return res.status(400).json({ error: 'Modèle et messages requis' }); } @@ -151,11 +154,21 @@ router.post('/chat', authenticateToken, async (req, res) => { const setting = await getOne('SELECT value FROM settings WHERE key = $1', ['openrouter_api_key']); if (!setting || !setting.value) { - return res.status(400).json({ error: 'Clé API OpenRouter non configurée' }); + logger.warn('[OPENROUTER CHAT] Clé API non configurée'); + return res.status(400).json({ error: 'Clé API OpenRouter non configurée. Allez dans Paramètres pour la configurer.' }); } + logger.info(`[OPENROUTER CHAT] Clé API trouvée, appel à OpenRouter...`); + // Appeler l'API OpenRouter const fetch = (await import('node-fetch')).default; + const requestBody = { + model: model, + messages: messages + }; + + logger.info(`[OPENROUTER CHAT] Requête OpenRouter: ${JSON.stringify({ model, messageCount: messages.length })}`); + const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { method: 'POST', headers: { @@ -164,25 +177,40 @@ router.post('/chat', authenticateToken, async (req, res) => { 'X-Title': 'NoteFlow', 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: model, - messages: messages - }) + body: JSON.stringify(requestBody) }); + logger.info(`[OPENROUTER CHAT] Réponse reçue - status: ${response.status} ${response.statusText}`); + if (!response.ok) { const errorData = await response.json().catch(() => ({})); - logger.error(`Erreur API OpenRouter chat: ${response.status}`, errorData); + const errorMessage = errorData.error?.message || errorData.error || `Erreur ${response.status}: ${response.statusText}`; + + logger.error(`[OPENROUTER CHAT] Erreur API OpenRouter:`, { + status: response.status, + statusText: response.statusText, + error: errorData + }); + return res.status(response.status).json({ - error: errorData.error?.message || 'Erreur lors de la communication avec l\'IA' + error: errorMessage }); } const data = await response.json(); + logger.info(`[OPENROUTER CHAT] Succès - réponse générée (${data.choices?.[0]?.message?.content?.length || 0} caractères)`); + res.json(data); } catch (error) { - logger.error('Erreur lors du chat OpenRouter:', error); - res.status(500).json({ error: 'Erreur serveur' }); + logger.error('[OPENROUTER CHAT] Erreur exception:', { + message: error.message, + stack: error.stack, + name: error.name + }); + + res.status(500).json({ + error: `Erreur serveur: ${error.message || 'Erreur inconnue'}` + }); } }); diff --git a/routes/rss.routes-v2.js b/routes/rss.routes-v2.js index e24b413..464b7fe 100644 --- a/routes/rss.routes-v2.js +++ b/routes/rss.routes-v2.js @@ -88,7 +88,7 @@ router.put('/feeds/:id', requireAdmin, async (req, res) => { await runQuery( 'UPDATE rss_feeds SET enabled = $1 WHERE id = $2', - [enabled ? 1 : 0, req.params.id] + [enabled, req.params.id] // BOOLEAN instead of 0/1 ); logger.info(`Flux ${enabled ? 'activé' : 'désactivé'}: ${req.params.id}`); diff --git a/routes/rss.routes.js b/routes/rss.routes.js index e19daa9..e296fe5 100644 --- a/routes/rss.routes.js +++ b/routes/rss.routes.js @@ -90,7 +90,7 @@ router.put('/feeds/:id', requireAdmin, async (req, res) => { await runQuery( 'UPDATE rss_feeds SET enabled = $1 WHERE id = $2', - [enabled ? 1 : 0, req.params.id] + [enabled, req.params.id] // BOOLEAN instead of 0/1 ); logger.info(`Flux ${enabled ? 'activé' : 'désactivé'}: ${req.params.id}`); diff --git a/routes/todos.routes.js b/routes/todos.routes.js index 7b86d8e..726c63d 100644 --- a/routes/todos.routes.js +++ b/routes/todos.routes.js @@ -19,7 +19,7 @@ router.get('/', async (req, res) => { // Essayer d'abord avec le champ priority (nouvelle version) try { const todos = await getAll(` - SELECT id, text, completed, priority, in_progress, created_at + SELECT id, text, completed, priority, in_progress, parent_id, level, created_at FROM global_todos WHERE user_id = $1 ORDER BY priority DESC, created_at DESC @@ -38,7 +38,7 @@ router.get('/', async (req, res) => { `, [req.user.id]); // Ajouter priority=false et in_progress=false par défaut pour compatibilité frontend - const todosWithPriority = todos.map(t => ({ ...t, priority: false, in_progress: false })); + const todosWithPriority = todos.map(t => ({ ...t, priority: false, in_progress: false, parent_id: null, level: 0 })); res.json(todosWithPriority); } } catch (error) { @@ -49,10 +49,13 @@ router.get('/', async (req, res) => { /** * POST /api/todos - * Créer un nouveau todo global + * Créer un nouveau todo global (supports subtasks via parent_id) */ router.post('/', - [body('text').trim().notEmpty().withMessage('Le texte est requis')], + [ + body('text').trim().notEmpty().withMessage('Le texte est requis'), + body('parent_id').optional().isInt() + ], async (req, res) => { try { const errors = validationResult(req); @@ -60,17 +63,27 @@ router.post('/', return res.status(400).json({ error: 'Données invalides', details: errors.array() }); } - const { text } = req.body; + const { text, parent_id } = req.body; + let level = 0; - logger.info(`[CREATE GLOBAL TODO] Début création - user_id: ${req.user.id}, text: "${text}"`); + // Si c'est un subtask, vérifier que le parent existe et calculer le niveau + if (parent_id) { + const parent = await getOne('SELECT id, level FROM global_todos WHERE id = $1 AND user_id = $2', [parent_id, req.user.id]); + if (!parent) { + return res.status(404).json({ error: 'Todo parent non trouvé' }); + } + level = (parent.level || 0) + 1; + } + + logger.info(`[CREATE GLOBAL TODO] Début création - user_id: ${req.user.id}, text: "${text}"${parent_id ? ` (subtask of ${parent_id})` : ''}`); const result = await runQuery(` - INSERT INTO global_todos (user_id, text) - VALUES ($1, $2) + INSERT INTO global_todos (user_id, text, parent_id, level) + VALUES ($1, $2, $3, $4) RETURNING * - `, [req.user.id, text]); + `, [req.user.id, text, parent_id || null, level]); - logger.info(`[CREATE GLOBAL TODO] Todo global créé avec succès - ID: ${result.id}, user: ${req.user.username}`); + logger.info(`[CREATE GLOBAL TODO] Todo global créé avec succès - ID: ${result.id}, user: ${req.user.username}, level: ${level}`); res.status(201).json({ id: result.id, @@ -78,6 +91,8 @@ router.post('/', completed: false, priority: false, in_progress: false, + parent_id: parent_id || null, + level: level, created_at: new Date().toISOString() }); } catch (error) { @@ -125,17 +140,17 @@ router.put('/:id', if (completed !== undefined) { paramCount++; updates.push(`completed = $${paramCount}`); - params.push(completed ? 1 : 0); + params.push(completed); // BOOLEAN instead of 0/1 } if (priority !== undefined) { paramCount++; updates.push(`priority = $${paramCount}`); - params.push(priority ? 1 : 0); + params.push(priority); // BOOLEAN instead of 0/1 } if (in_progress !== undefined) { paramCount++; updates.push(`in_progress = $${paramCount}`); - params.push(in_progress ? 1 : 0); + params.push(in_progress ? 1 : 0); // global_todos.in_progress is INTEGER not BOOLEAN } if (updates.length > 0) { @@ -164,12 +179,12 @@ router.patch('/:id/toggle', async (req, res) => { return res.status(404).json({ error: 'Todo non trouvé' }); } - const newCompleted = todo.completed ? 0 : 1; + const newCompleted = !todo.completed; // BOOLEAN instead of 0/1 await runQuery('UPDATE global_todos SET completed = $1 WHERE id = $2', [newCompleted, req.params.id]); logger.info(`Todo global ${newCompleted ? 'complété' : 'réouvert'} (ID: ${req.params.id}) par ${req.user.username}`); - res.json({ message: 'Todo modifié avec succès', completed: newCompleted === 1 }); + res.json({ message: 'Todo modifié avec succès', completed: newCompleted }); } catch (error) { logger.error('Erreur lors du toggle du todo:', error); res.status(500).json({ error: 'Erreur serveur' }); @@ -189,12 +204,12 @@ router.patch('/:id/priority', async (req, res) => { return res.status(404).json({ error: 'Todo non trouvé' }); } - const newPriority = todo.priority ? 0 : 1; + const newPriority = !todo.priority; // BOOLEAN instead of 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 }); + res.json({ message: 'Priorité modifiée avec succès', priority: newPriority }); } catch (priorityError) { // Si le champ priority n'existe pas encore, retourner un message d'erreur explicite if (priorityError.message && priorityError.message.includes('priority')) { @@ -225,7 +240,7 @@ router.patch('/:id/in-progress', async (req, res) => { return res.status(404).json({ error: 'Todo non trouvé' }); } - const newInProgress = todo.in_progress ? 0 : 1; + const newInProgress = todo.in_progress ? 0 : 1; // global_todos.in_progress is INTEGER not BOOLEAN await runQuery('UPDATE global_todos SET in_progress = $1 WHERE id = $2', [newInProgress, req.params.id]); logger.info(`Todo global ${newInProgress ? 'marqué en cours' : 'démarqué en cours'} (ID: ${req.params.id}) par ${req.user.username}`); diff --git a/routes/users.routes.js b/routes/users.routes.js index 836f459..4d6cc7a 100644 --- a/routes/users.routes.js +++ b/routes/users.routes.js @@ -56,12 +56,12 @@ router.post('/', // Hasher le mot de passe const passwordHash = await bcrypt.hash(password, 12); - logger.info(`[CREATE USER] Début création - username: "${username}", is_admin: ${is_admin ? 1 : 0}`); + logger.info(`[CREATE USER] Début création - username: "${username}", is_admin: ${is_admin || false}`); // Créer l'utilisateur const result = await runQuery( 'INSERT INTO users (username, password_hash, is_admin) VALUES ($1, $2, $3) RETURNING *', - [username, passwordHash, is_admin ? 1 : 0] + [username, passwordHash, is_admin || false] // BOOLEAN instead of 0/1 ); logger.info(`[CREATE USER] Utilisateur créé avec succès - ID: ${result.id}, username: "${username}"`); @@ -111,7 +111,7 @@ router.put('/:id', // Mettre à jour le statut admin si fourni if (typeof is_admin !== 'undefined') { - await runQuery('UPDATE users SET is_admin = $1 WHERE id = $2', [is_admin ? 1 : 0, userId]); + await runQuery('UPDATE users SET is_admin = $1 WHERE id = $2', [is_admin, userId]); // BOOLEAN instead of 0/1 } logger.info(`Utilisateur modifié: ${user.username} (ID: ${userId})`); diff --git a/scripts/README-FIXES.md b/scripts/README-FIXES.md new file mode 100644 index 0000000..31ca5d6 --- /dev/null +++ b/scripts/README-FIXES.md @@ -0,0 +1,60 @@ +# Scripts de correction PostgreSQL + +## Problème: Notes ne s'affichent pas (Erreur 500) + +Si vous voyez une erreur 500 sur `/api/notes` après la migration vers PostgreSQL, c'est probablement dû à un problème de conversion des données booléennes. + +### Cause + +Lors de la migration de SQLite vers PostgreSQL: +- SQLite stocke les booléens comme INTEGER (0/1) +- PostgreSQL utilise le type BOOLEAN (TRUE/FALSE) +- Si les données ont été migrées sans conversion, les valeurs restent en INTEGER +- Les requêtes SQL qui utilisent `completed = TRUE` échouent + +### Solution + +Exécutez le script de correction sur votre base de données de production: + +```bash +# Sur le serveur de production +cd /path/to/noteflow +node scripts/fix-boolean-data.js +``` + +Ce script va: +1. Se connecter à votre base PostgreSQL +2. Vérifier les types des colonnes booléennes +3. Convertir les colonnes INTEGER en BOOLEAN si nécessaire +4. Convertir automatiquement les valeurs 0/1 en FALSE/TRUE + +### Colonnes concernées + +- `users.is_admin` +- `notes.archived` +- `note_todos.completed`, `note_todos.priority` +- `global_todos.completed`, `global_todos.priority` +- `rss_feeds.enabled` +- `calendar_events.all_day` + +### Variables d'environnement + +Le script utilise `DATABASE_URL` de votre fichier `.env`: + +```env +DATABASE_URL=postgresql://noteflow:password@localhost:5499/noteflow +``` + +### Migration future + +Le script `migrate-sqlite-to-postgres.js` a été amélioré pour convertir automatiquement les booléens lors des futures migrations. Vous n'aurez plus besoin d'exécuter `fix-boolean-data.js` après de nouvelles migrations. + +### Vérification + +Après l'exécution du script, redémarrez votre serveur et vérifiez que les notes s'affichent correctement. + +```bash +pm2 restart noteflow +# ou +systemctl restart noteflow +``` diff --git a/scripts/auto-migrate.js b/scripts/auto-migrate.js index ab25009..df91505 100644 --- a/scripts/auto-migrate.js +++ b/scripts/auto-migrate.js @@ -185,6 +185,55 @@ async function autoMigrate() { 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)`); + // Migration 4: Support des subtasks (parent_id, level) + logger.info(' Vérification: support des subtasks (parent_id, level)...'); + + const noteTodosParentIdExists = await client.query(` + SELECT column_name + FROM information_schema.columns + WHERE table_name='note_todos' AND column_name='parent_id' + `); + + if (noteTodosParentIdExists.rows.length === 0) { + logger.info(' → Ajout du champ parent_id à note_todos'); + await client.query(`ALTER TABLE note_todos ADD COLUMN parent_id INTEGER REFERENCES note_todos(id) ON DELETE CASCADE`); + await client.query(`CREATE INDEX IF NOT EXISTS idx_note_todos_parent_id ON note_todos(parent_id)`); + } + + const noteTodosLevelExists = await client.query(` + SELECT column_name + FROM information_schema.columns + WHERE table_name='note_todos' AND column_name='level' + `); + + if (noteTodosLevelExists.rows.length === 0) { + logger.info(' → Ajout du champ level à note_todos'); + await client.query(`ALTER TABLE note_todos ADD COLUMN level INTEGER DEFAULT 0`); + } + + const globalTodosParentIdExists = await client.query(` + SELECT column_name + FROM information_schema.columns + WHERE table_name='global_todos' AND column_name='parent_id' + `); + + if (globalTodosParentIdExists.rows.length === 0) { + logger.info(' → Ajout du champ parent_id à global_todos'); + await client.query(`ALTER TABLE global_todos ADD COLUMN parent_id INTEGER REFERENCES global_todos(id) ON DELETE CASCADE`); + await client.query(`CREATE INDEX IF NOT EXISTS idx_global_todos_parent_id ON global_todos(parent_id)`); + } + + const globalTodosLevelExists = await client.query(` + SELECT column_name + FROM information_schema.columns + WHERE table_name='global_todos' AND column_name='level' + `); + + if (globalTodosLevelExists.rows.length === 0) { + logger.info(' → Ajout du champ level à global_todos'); + await client.query(`ALTER TABLE global_todos ADD COLUMN level INTEGER DEFAULT 0`); + } + await client.query('COMMIT'); logger.info('✅ Migrations automatiques terminées avec succès'); diff --git a/scripts/fix-boolean-data.js b/scripts/fix-boolean-data.js new file mode 100755 index 0000000..c08c30b --- /dev/null +++ b/scripts/fix-boolean-data.js @@ -0,0 +1,92 @@ +#!/usr/bin/env node +// Script de correction des données booléennes dans PostgreSQL +// Convertit les valeurs 0/1 (INTEGER) en FALSE/TRUE (BOOLEAN) + +require('dotenv').config(); +const { Pool } = require('pg'); + +const DATABASE_URL = process.env.DATABASE_URL || + `postgresql://noteflow:noteflow_secure_password_change_me@localhost:5499/noteflow`; + +console.log('\n========== CORRECTION DES DONNÉES BOOLÉENNES ==========\n'); + +const pool = new Pool({ + connectionString: DATABASE_URL, +}); + +async function fixBooleanColumns() { + const client = await pool.connect(); + + try { + console.log('✓ Connexion PostgreSQL établie\n'); + + // Liste des tables et colonnes avec type BOOLEAN + const booleanColumns = [ + { table: 'users', columns: ['is_admin'] }, + { table: 'notes', columns: ['archived'] }, + { table: 'note_todos', columns: ['completed', 'priority'] }, + { table: 'global_todos', columns: ['completed', 'priority'] }, + { table: 'rss_feeds', columns: ['enabled'] }, + { table: 'calendar_events', columns: ['all_day'] } + ]; + + for (const { table, columns } of booleanColumns) { + console.log(`📋 Table: ${table}`); + + for (const column of columns) { + try { + // Vérifier le type actuel de la colonne + const typeCheck = await client.query(` + SELECT data_type + FROM information_schema.columns + WHERE table_name = $1 AND column_name = $2 + `, [table, column]); + + if (typeCheck.rows.length === 0) { + console.log(` ⏭️ Colonne ${column} n'existe pas, skip`); + continue; + } + + const dataType = typeCheck.rows[0].data_type; + console.log(` 📊 ${column}: type actuel = ${dataType}`); + + if (dataType === 'boolean') { + // Si la colonne est déjà BOOLEAN, vérifier s'il y a des valeurs INTEGER incorrectes + // En réalité, PostgreSQL convertit automatiquement lors de l'insertion + // Mais on peut forcer la conversion pour être sûr + console.log(` ✓ ${column} est déjà BOOLEAN`); + } else if (dataType === 'integer') { + // Si la colonne est INTEGER, la convertir en BOOLEAN + console.log(` 🔧 Conversion ${column}: INTEGER → BOOLEAN`); + + await client.query(` + ALTER TABLE ${table} + ALTER COLUMN ${column} TYPE BOOLEAN + USING CASE + WHEN ${column} = 0 THEN FALSE + WHEN ${column} = 1 THEN TRUE + ELSE ${column}::BOOLEAN + END + `); + + console.log(` ✅ ${column} converti en BOOLEAN`); + } + } catch (err) { + console.error(` ❌ Erreur sur ${column}:`, err.message); + } + } + console.log(''); + } + + console.log('✅ Correction terminée!\n'); + + } catch (error) { + console.error('❌ Erreur:', error); + process.exit(1); + } finally { + client.release(); + await pool.end(); + } +} + +fixBooleanColumns(); diff --git a/scripts/fix-postgres-boolean-types.sql b/scripts/fix-postgres-boolean-types.sql new file mode 100644 index 0000000..012e7d2 --- /dev/null +++ b/scripts/fix-postgres-boolean-types.sql @@ -0,0 +1,91 @@ +-- Script de correction des types booléens PostgreSQL +-- Convertit les colonnes INTEGER en BOOLEAN et les données 0/1 en FALSE/TRUE +-- Ce script est idempotent: il peut être exécuté plusieurs fois sans problème + +-- Fonction pour vérifier et corriger le type d'une colonne +DO $$ +DECLARE + column_info RECORD; +BEGIN + -- Corriger users.is_admin + SELECT data_type INTO column_info FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'is_admin'; + + IF FOUND AND column_info.data_type = 'integer' THEN + RAISE NOTICE 'Converting users.is_admin from INTEGER to BOOLEAN'; + ALTER TABLE users ALTER COLUMN is_admin TYPE BOOLEAN + USING CASE WHEN is_admin = 0 THEN FALSE WHEN is_admin = 1 THEN TRUE ELSE is_admin::BOOLEAN END; + END IF; + + -- Corriger notes.archived + SELECT data_type INTO column_info FROM information_schema.columns + WHERE table_name = 'notes' AND column_name = 'archived'; + + IF FOUND AND column_info.data_type = 'integer' THEN + RAISE NOTICE 'Converting notes.archived from INTEGER to BOOLEAN'; + ALTER TABLE notes ALTER COLUMN archived TYPE BOOLEAN + USING CASE WHEN archived = 0 THEN FALSE WHEN archived = 1 THEN TRUE ELSE archived::BOOLEAN END; + END IF; + + -- Corriger note_todos.completed + SELECT data_type INTO column_info FROM information_schema.columns + WHERE table_name = 'note_todos' AND column_name = 'completed'; + + IF FOUND AND column_info.data_type = 'integer' THEN + RAISE NOTICE 'Converting note_todos.completed from INTEGER to BOOLEAN'; + ALTER TABLE note_todos ALTER COLUMN completed TYPE BOOLEAN + USING CASE WHEN completed = 0 THEN FALSE WHEN completed = 1 THEN TRUE ELSE completed::BOOLEAN END; + END IF; + + -- Corriger note_todos.priority + SELECT data_type INTO column_info FROM information_schema.columns + WHERE table_name = 'note_todos' AND column_name = 'priority'; + + IF FOUND AND column_info.data_type = 'integer' THEN + RAISE NOTICE 'Converting note_todos.priority from INTEGER to BOOLEAN'; + ALTER TABLE note_todos ALTER COLUMN priority TYPE BOOLEAN + USING CASE WHEN priority = 0 THEN FALSE WHEN priority = 1 THEN TRUE ELSE priority::BOOLEAN END; + END IF; + + -- Corriger global_todos.completed + SELECT data_type INTO column_info FROM information_schema.columns + WHERE table_name = 'global_todos' AND column_name = 'completed'; + + IF FOUND AND column_info.data_type = 'integer' THEN + RAISE NOTICE 'Converting global_todos.completed from INTEGER to BOOLEAN'; + ALTER TABLE global_todos ALTER COLUMN completed TYPE BOOLEAN + USING CASE WHEN completed = 0 THEN FALSE WHEN completed = 1 THEN TRUE ELSE completed::BOOLEAN END; + END IF; + + -- Corriger global_todos.priority + SELECT data_type INTO column_info FROM information_schema.columns + WHERE table_name = 'global_todos' AND column_name = 'priority'; + + IF FOUND AND column_info.data_type = 'integer' THEN + RAISE NOTICE 'Converting global_todos.priority from INTEGER to BOOLEAN'; + ALTER TABLE global_todos ALTER COLUMN priority TYPE BOOLEAN + USING CASE WHEN priority = 0 THEN FALSE WHEN priority = 1 THEN TRUE ELSE priority::BOOLEAN END; + END IF; + + -- Corriger rss_feeds.enabled + SELECT data_type INTO column_info FROM information_schema.columns + WHERE table_name = 'rss_feeds' AND column_name = 'enabled'; + + IF FOUND AND column_info.data_type = 'integer' THEN + RAISE NOTICE 'Converting rss_feeds.enabled from INTEGER to BOOLEAN'; + ALTER TABLE rss_feeds ALTER COLUMN enabled TYPE BOOLEAN + USING CASE WHEN enabled = 0 THEN FALSE WHEN enabled = 1 THEN TRUE ELSE enabled::BOOLEAN END; + END IF; + + -- Corriger calendar_events.all_day + SELECT data_type INTO column_info FROM information_schema.columns + WHERE table_name = 'calendar_events' AND column_name = 'all_day'; + + IF FOUND AND column_info.data_type = 'integer' THEN + RAISE NOTICE 'Converting calendar_events.all_day from INTEGER to BOOLEAN'; + ALTER TABLE calendar_events ALTER COLUMN all_day TYPE BOOLEAN + USING CASE WHEN all_day = 0 THEN FALSE WHEN all_day = 1 THEN TRUE ELSE all_day::BOOLEAN END; + END IF; + + RAISE NOTICE 'Boolean type correction completed successfully'; +END $$; diff --git a/scripts/migrate-sqlite-to-postgres.js b/scripts/migrate-sqlite-to-postgres.js index d520244..edea403 100644 --- a/scripts/migrate-sqlite-to-postgres.js +++ b/scripts/migrate-sqlite-to-postgres.js @@ -69,6 +69,16 @@ function getSqliteData(tableName) { }); } +// Colonnes booléennes connues (pour conversion 0/1 → FALSE/TRUE) +const BOOLEAN_COLUMNS = { + users: ['is_admin'], + notes: ['archived'], + note_todos: ['completed', 'priority'], + global_todos: ['completed', 'priority'], + rss_feeds: ['enabled'], + calendar_events: ['all_day'] +}; + /** * Insérer les données dans PostgreSQL */ @@ -80,6 +90,7 @@ async function insertPostgresData(tableName, rows) { const client = await pgPool.connect(); let inserted = 0; + const booleanCols = BOOLEAN_COLUMNS[tableName] || []; try { await client.query('BEGIN'); @@ -87,10 +98,18 @@ async function insertPostgresData(tableName, rows) { for (const row of rows) { // Convertir les noms de colonnes et valeurs const columns = Object.keys(row); - const values = Object.values(row).map(val => { + const values = columns.map((col, idx) => { + const val = Object.values(row)[idx]; + // Convertir les valeurs SQLite vers PostgreSQL if (val === null) return null; if (typeof val === 'boolean') return val; + + // Convertir les colonnes booléennes: 0/1 → FALSE/TRUE + if (booleanCols.includes(col) && (val === 0 || val === 1)) { + return val === 1; + } + if (typeof val === 'number') return val; // Dates if (typeof val === 'string' && /^\d{4}-\d{2}-\d{2}/.test(val)) { diff --git a/server.js b/server.js index 0deb779..78f4f78 100644 --- a/server.js +++ b/server.js @@ -1,4 +1,7 @@ // Serveur Express principal pour l'application NoteFlow +// Load environment variables from .env file +require('dotenv').config(); + const express = require('express'); const cors = require('cors'); const helmet = require('helmet'); diff --git a/src/App.tsx b/src/App.tsx index 42ed1da..06ed6d3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -75,7 +75,7 @@ const App = () => { return ( - + diff --git a/src/components/AdvancedSearch.tsx b/src/components/AdvancedSearch.tsx new file mode 100644 index 0000000..34b365a --- /dev/null +++ b/src/components/AdvancedSearch.tsx @@ -0,0 +1,283 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { Calendar } from "@/components/ui/calendar"; +import { SlidersHorizontal, X, Calendar as CalendarIcon, Tag as TagIcon } from "lucide-react"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; + +export interface SearchFilters { + tags: string[]; // Tag names + dateFrom?: Date; + dateTo?: Date; + hasTodos?: boolean; + hasImages?: boolean; + hasFiles?: boolean; + priority?: boolean; +} + +export interface TagOption { + id: number; + name: string; +} + +interface AdvancedSearchProps { + filters: SearchFilters; + onFiltersChange: (filters: SearchFilters) => void; + availableTags: TagOption[]; +} + +export function AdvancedSearch({ filters, onFiltersChange, availableTags }: AdvancedSearchProps) { + const [open, setOpen] = useState(false); + const [datePickerOpen, setDatePickerOpen] = useState<'from' | 'to' | null>(null); + const [tagSelectorOpen, setTagSelectorOpen] = useState(false); + + const hasActiveFilters = filters.tags.length > 0 || + filters.dateFrom !== undefined || + filters.dateTo !== undefined || + filters.hasTodos !== undefined || + filters.hasImages !== undefined || + filters.hasFiles !== undefined || + filters.priority !== undefined; + + const clearFilters = () => { + onFiltersChange({ + tags: [], + dateFrom: undefined, + dateTo: undefined, + hasTodos: undefined, + hasImages: undefined, + hasFiles: undefined, + priority: undefined + }); + }; + + const toggleTag = (tagName: string) => { + const newTags = filters.tags.includes(tagName) + ? filters.tags.filter(name => name !== tagName) + : [...filters.tags, tagName]; + onFiltersChange({ ...filters, tags: newTags }); + }; + + const selectedTags = availableTags.filter(tag => filters.tags.includes(tag.name)); + + return ( +
+ + + + + +
+
+

Filtres avancés

+ {hasActiveFilters && ( + + )} +
+ + {/* Tags Filter */} +
+ + + + + + + + + + Aucun tag trouvé + + {availableTags.map((tag) => ( + toggleTag(tag.name)} + className="cursor-pointer" + > + + + {tag.name} + + ))} + + + + + + {selectedTags.length > 0 && ( +
+ {selectedTags.map((tag) => ( + + {tag.name} + toggleTag(tag.name)} + /> + + ))} +
+ )} +
+ + {/* Date Range Filter */} +
+ +
+ setDatePickerOpen(open ? 'from' : null)}> + + + + + { + onFiltersChange({ ...filters, dateFrom: date }); + setDatePickerOpen(null); + }} + initialFocus + /> + + + setDatePickerOpen(open ? 'to' : null)}> + + + + + { + onFiltersChange({ ...filters, dateTo: date }); + setDatePickerOpen(null); + }} + initialFocus + /> + + +
+ {(filters.dateFrom || filters.dateTo) && ( + + )} +
+ + {/* Content Type Filters */} +
+ +
+
+ + onFiltersChange({ ...filters, hasTodos: checked ? true : undefined }) + } + /> + +
+
+ + onFiltersChange({ ...filters, hasImages: checked ? true : undefined }) + } + /> + +
+
+ + onFiltersChange({ ...filters, hasFiles: checked ? true : undefined }) + } + /> + +
+
+
+ + {/* Priority Filter */} +
+
+ + onFiltersChange({ ...filters, priority: checked ? true : undefined }) + } + /> + +
+
+
+
+
+ + {hasActiveFilters && ( + + )} +
+ ); +} diff --git a/src/components/ExportImportModal.tsx b/src/components/ExportImportModal.tsx new file mode 100644 index 0000000..120d6d3 --- /dev/null +++ b/src/components/ExportImportModal.tsx @@ -0,0 +1,255 @@ +import { useState } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Download, Upload, FileJson, FileSpreadsheet, AlertCircle, CheckCircle } from "lucide-react"; +import { Note } from "@/services/NotesService"; +import { Todo } from "@/services/TodosService"; +import { + exportToJSON, + downloadBackup, + downloadCSV, + exportNotesToCSV, + parseBackupJSON, + readFileAsText, + getBackupStats, + type BackupData +} from "@/utils/exportImport"; +import { showSuccess, showError } from "@/utils/toast"; + +interface ExportImportModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + notes: Note[]; + todos: Todo[]; + settings?: Record; + onImport?: (backup: BackupData) => Promise; +} + +export function ExportImportModal({ + open, + onOpenChange, + notes, + todos, + settings, + onImport +}: ExportImportModalProps) { + const [importing, setImporting] = useState(false); + const [importFile, setImportFile] = useState(null); + const [previewData, setPreviewData] = useState(null); + + const handleExportJSON = async () => { + try { + const jsonData = await exportToJSON(notes, todos, settings); + downloadBackup(jsonData); + showSuccess('Backup exporté avec succès'); + } catch (error) { + showError('Erreur lors de l\'export'); + } + }; + + const handleExportCSV = () => { + try { + const csvData = exportNotesToCSV(notes); + downloadCSV(csvData); + showSuccess('CSV exporté avec succès'); + } catch (error) { + showError('Erreur lors de l\'export CSV'); + } + }; + + const handleFileSelect = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setImportFile(file); + + try { + const content = await readFileAsText(file); + const backup = parseBackupJSON(content); + + if (backup) { + setPreviewData(backup); + } else { + showError('Fichier de backup invalide'); + setImportFile(null); + } + } catch (error) { + showError('Erreur lors de la lecture du fichier'); + setImportFile(null); + } + }; + + const handleImport = async () => { + if (!previewData || !onImport) return; + + setImporting(true); + try { + await onImport(previewData); + showSuccess('Données importées avec succès'); + setImportFile(null); + setPreviewData(null); + onOpenChange(false); + } catch (error) { + showError('Erreur lors de l\'import'); + } finally { + setImporting(false); + } + }; + + const stats = notes.length > 0 ? { + notesCount: notes.length, + archivedCount: notes.filter(n => n.archived).length, + priorityCount: notes.filter(n => n.priority).length, + todosCount: todos.length, + tagsCount: new Set(notes.flatMap(n => n.tags?.map(t => t.name) || [])).size + } : null; + + const previewStats = previewData ? getBackupStats(previewData) : null; + + return ( + + + + Export / Import + + Exportez ou importez toutes vos données + + + + + + Export + Import + + + + {/* Current Data Stats */} + {stats && ( +
+
+
{stats.notesCount}
+
Notes
+
+
+
{stats.todosCount}
+
Tâches
+
+
+
{stats.tagsCount}
+
Tags
+
+
+ )} + + {/* Export Options */} +
+
+ + +

+ Inclut toutes les notes, tâches, tags, et paramètres +

+
+ +
+ + +

+ Tableau des notes pour Excel ou Google Sheets +

+
+
+ + + + + Les fichiers seront téléchargés dans votre dossier de téléchargements + + +
+ + + {/* File Upload */} +
+ + +
+ + {/* Preview */} + {previewData && previewStats && ( +
+
+ + Backup valide + {previewData.version} +
+ +
+
+
{previewStats.notesCount}
+
Notes
+
+
+
{previewStats.todosCount}
+
Tâches
+
+
+
{previewStats.tagsCount}
+
Tags
+
+
+ + + + + Créé le {new Date(previewData.exportDate).toLocaleDateString('fr-FR')} + + + + + + {!onImport && ( +

+ La fonction d'import n'est pas disponible +

+ )} +
+ )} + + {!previewData && !importFile && ( +
+ +

Sélectionnez un fichier de backup pour commencer

+
+ )} +
+
+
+
+ ); +} diff --git a/src/components/KanbanBoard.tsx b/src/components/KanbanBoard.tsx new file mode 100644 index 0000000..4e5ac7b --- /dev/null +++ b/src/components/KanbanBoard.tsx @@ -0,0 +1,196 @@ +import { useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { CheckSquare, Image as ImageIcon, Paperclip, Tag as TagIcon, Plus, GripVertical } from "lucide-react"; +import { Note } from "@/services/NotesService"; + +interface KanbanColumn { + id: string; + title: string; + color: string; +} + +const DEFAULT_COLUMNS: KanbanColumn[] = [ + { id: 'backlog', title: 'Backlog', color: 'bg-gray-100 dark:bg-gray-800' }, + { id: 'todo', title: 'À faire', color: 'bg-blue-100 dark:bg-blue-900' }, + { id: 'in_progress', title: 'En cours', color: 'bg-yellow-100 dark:bg-yellow-900' }, + { id: 'review', title: 'En révision', color: 'bg-purple-100 dark:bg-purple-900' }, + { id: 'done', title: 'Terminé', color: 'bg-green-100 dark:bg-green-900' }, +]; + +interface KanbanBoardProps { + notes: Note[]; + onNoteClick: (note: Note) => void; + onCreateNote: (columnId: string) => void; + onMoveNote?: (noteId: number, newStatus: string) => void; +} + +export function KanbanBoard({ notes, onNoteClick, onCreateNote, onMoveNote }: KanbanBoardProps) { + const [draggedNote, setDraggedNote] = useState(null); + const [dragOverColumn, setDragOverColumn] = useState(null); + + // Group notes by their kanban_status tag or default to 'backlog' + const getNoteStatus = (note: Note): string => { + const statusTag = note.tags?.find(tag => + DEFAULT_COLUMNS.some(col => col.id === tag.name.toLowerCase()) + ); + return statusTag?.name.toLowerCase() || 'backlog'; + }; + + const notesByColumn = DEFAULT_COLUMNS.reduce((acc, column) => { + acc[column.id] = notes.filter(note => + !note.archived && getNoteStatus(note) === column.id + ); + return acc; + }, {} as Record); + + const handleDragStart = (note: Note) => { + setDraggedNote(note); + }; + + const handleDragOver = (e: React.DragEvent, columnId: string) => { + e.preventDefault(); + setDragOverColumn(columnId); + }; + + const handleDragLeave = () => { + setDragOverColumn(null); + }; + + const handleDrop = (e: React.DragEvent, columnId: string) => { + e.preventDefault(); + setDragOverColumn(null); + + if (draggedNote && onMoveNote && getNoteStatus(draggedNote) !== columnId) { + onMoveNote(draggedNote.id!, columnId); + } + setDraggedNote(null); + }; + + const handleDragEnd = () => { + setDraggedNote(null); + setDragOverColumn(null); + }; + + return ( +
+ {DEFAULT_COLUMNS.map((column) => ( +
handleDragOver(e, column.id)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, column.id)} + > + + +
+ +
+ {column.title} + + {notesByColumn[column.id]?.length || 0} + + + +
+ + + +
+ {notesByColumn[column.id]?.map((note) => ( +
handleDragStart(note)} + onDragEnd={handleDragEnd} + onClick={() => onNoteClick(note)} + className={`p-3 border rounded-lg cursor-move hover:shadow-md transition-all group ${ + draggedNote?.id === note.id ? 'opacity-50' : '' + } ${note.priority ? 'border-l-4 border-l-red-500' : ''}`} + > +
+ +
+

+ {note.title || "Sans titre"} +

+ {note.content && ( +

+ {note.content.replace(/<[^>]*>/g, '')} +

+ )} +
+
+ + {/* Metadata badges */} +
+ {note.todos && note.todos.length > 0 && ( + + + {note.todos.filter(t => t.completed).length}/{note.todos.length} + + )} + {note.images && note.images.length > 0 && ( + + + {note.images.length} + + )} + {note.files && note.files.length > 0 && ( + + + {note.files.length} + + )} + {note.tags && note.tags.length > 0 && ( +
+ {note.tags.slice(0, 2).map((tag) => ( + + + {tag.name} + + ))} + {note.tags.length > 2 && ( + + +{note.tags.length - 2} + + )} +
+ )} +
+ + {/* Date */} + {note.updated_at && ( +

+ {new Date(note.updated_at).toLocaleDateString('fr-FR', { + day: '2-digit', + month: 'short' + })} +

+ )} +
+ ))} + {notesByColumn[column.id]?.length === 0 && ( +
+ Aucune note +
+ )} +
+
+
+ +
+ ))} +
+ ); +} diff --git a/src/components/KeyboardShortcutsHelp.tsx b/src/components/KeyboardShortcutsHelp.tsx new file mode 100644 index 0000000..deb161a --- /dev/null +++ b/src/components/KeyboardShortcutsHelp.tsx @@ -0,0 +1,82 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Keyboard } from "lucide-react"; +import { type KeyboardShortcut, formatShortcut } from "@/hooks/useKeyboardShortcuts"; + +interface KeyboardShortcutsHelpProps { + open: boolean; + onOpenChange: (open: boolean) => void; + shortcuts: KeyboardShortcut[]; +} + +export function KeyboardShortcutsHelp({ open, onOpenChange, shortcuts }: KeyboardShortcutsHelpProps) { + // Group shortcuts by category + const groupedShortcuts = shortcuts.reduce((acc, shortcut) => { + // Determine category based on description + let category = 'Général'; + + if (shortcut.description.toLowerCase().includes('note')) { + category = 'Notes'; + } else if (shortcut.description.toLowerCase().includes('recherch')) { + category = 'Recherche'; + } else if (shortcut.description.toLowerCase().includes('todo') || shortcut.description.toLowerCase().includes('tâche')) { + category = 'Tâches'; + } else if (shortcut.description.toLowerCase().includes('navigation')) { + category = 'Navigation'; + } else if (shortcut.description.toLowerCase().includes('édition') || shortcut.description.toLowerCase().includes('gras') || shortcut.description.toLowerCase().includes('italique')) { + category = 'Édition'; + } + + if (!acc[category]) { + acc[category] = []; + } + acc[category].push(shortcut); + return acc; + }, {} as Record); + + return ( + + + + + + Raccourcis clavier + + + Utilisez ces raccourcis pour naviguer plus rapidement + + + + +
+ {Object.entries(groupedShortcuts).map(([category, categoryShortcuts]) => ( +
+

+ {category} +

+
+ {categoryShortcuts.map((shortcut, index) => ( +
+ {shortcut.description} + + {formatShortcut(shortcut)} + +
+ ))} +
+
+ ))} +
+
+ +
+ Appuyez sur ? pour afficher cette aide +
+
+
+ ); +} diff --git a/src/components/PomodoroModal.tsx b/src/components/PomodoroModal.tsx new file mode 100644 index 0000000..538e148 --- /dev/null +++ b/src/components/PomodoroModal.tsx @@ -0,0 +1,28 @@ +import { ReactNode } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface PomodoroModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + children: ReactNode; +} + +export function PomodoroModal({ open, onOpenChange, children }: PomodoroModalProps) { + return ( + + + + Pomodoro Timer + +
+ {children} +
+
+
+ ); +} diff --git a/src/components/PomodoroTimer.tsx b/src/components/PomodoroTimer.tsx new file mode 100644 index 0000000..34b2ae4 --- /dev/null +++ b/src/components/PomodoroTimer.tsx @@ -0,0 +1,363 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Play, Pause, RotateCcw, Clock, Coffee, Trophy } from "lucide-react"; +import { showSuccess } from "@/utils/toast"; + +type TimerMode = 'work' | 'shortBreak' | 'longBreak'; + +const TIMER_DURATIONS = { + work: 25 * 60, // 25 minutes + shortBreak: 5 * 60, // 5 minutes + longBreak: 15 * 60, // 15 minutes +}; + +const SESSIONS_BEFORE_LONG_BREAK = 4; + +interface PomodoroTimerProps { + onStateChange?: (isRunning: boolean, timeLeft: number, mode: TimerMode) => void; +} + +export function PomodoroTimer({ onStateChange }: PomodoroTimerProps = {}) { + // Load initial state from localStorage + const [mode, setMode] = useState(() => { + const saved = localStorage.getItem('pomodoroMode'); + return (saved as TimerMode) || 'work'; + }); + const [timeLeft, setTimeLeft] = useState(() => { + const saved = localStorage.getItem('pomodoroTimeLeft'); + return saved ? parseInt(saved) : TIMER_DURATIONS.work; + }); + const [isRunning, setIsRunning] = useState(() => { + const saved = localStorage.getItem('pomodoroIsRunning'); + return saved === 'true'; + }); + const [sessionsCompleted, setSessionsCompleted] = useState(() => { + const saved = localStorage.getItem('pomodoroSessionsCompleted'); + return saved ? parseInt(saved) : 0; + }); + const [endTime, setEndTime] = useState(() => { + const saved = localStorage.getItem('pomodoroEndTime'); + return saved ? parseInt(saved) : null; + }); + const intervalRef = useRef(null); + + // Save state to localStorage whenever it changes and dispatch custom event + useEffect(() => { + localStorage.setItem('pomodoroMode', mode); + window.dispatchEvent(new CustomEvent('pomodoroStateChange')); + }, [mode]); + + useEffect(() => { + localStorage.setItem('pomodoroTimeLeft', timeLeft.toString()); + window.dispatchEvent(new CustomEvent('pomodoroStateChange')); + }, [timeLeft]); + + useEffect(() => { + localStorage.setItem('pomodoroIsRunning', isRunning.toString()); + window.dispatchEvent(new CustomEvent('pomodoroStateChange')); + }, [isRunning]); + + useEffect(() => { + localStorage.setItem('pomodoroSessionsCompleted', sessionsCompleted.toString()); + window.dispatchEvent(new CustomEvent('pomodoroStateChange')); + }, [sessionsCompleted]); + + useEffect(() => { + if (endTime !== null) { + localStorage.setItem('pomodoroEndTime', endTime.toString()); + } else { + localStorage.removeItem('pomodoroEndTime'); + } + window.dispatchEvent(new CustomEvent('pomodoroStateChange')); + }, [endTime]); + + // Restore timer state on mount if it was running + useEffect(() => { + // Clean up invalid states on mount + if (isRunning && endTime === null) { + // If running but no endTime, stop the timer + setIsRunning(false); + setTimeLeft(TIMER_DURATIONS[mode]); + } else if (endTime !== null && isRunning) { + // Recalculate remaining time based on endTime + const now = Date.now(); + const remaining = Math.max(0, Math.ceil((endTime - now) / 1000)); + + if (remaining > 0) { + setTimeLeft(remaining); + } else { + // Timer expired while page was closed/reloaded + setIsRunning(false); + setEndTime(null); + setTimeLeft(0); + } + } else if (endTime !== null && !isRunning) { + // If paused with an endTime, clean up the endTime + setEndTime(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Run only on mount + + // Listen for localStorage changes from other instances + useEffect(() => { + const handleStorageChange = () => { + const savedMode = localStorage.getItem('pomodoroMode') as TimerMode; + const savedTimeLeft = localStorage.getItem('pomodoroTimeLeft'); + const savedIsRunning = localStorage.getItem('pomodoroIsRunning'); + const savedSessions = localStorage.getItem('pomodoroSessionsCompleted'); + const savedEndTime = localStorage.getItem('pomodoroEndTime'); + + if (savedMode && savedMode !== mode) { + setMode(savedMode); + } + if (savedTimeLeft && parseInt(savedTimeLeft) !== timeLeft) { + setTimeLeft(parseInt(savedTimeLeft)); + } + if (savedIsRunning && (savedIsRunning === 'true') !== isRunning) { + setIsRunning(savedIsRunning === 'true'); + } + if (savedSessions && parseInt(savedSessions) !== sessionsCompleted) { + setSessionsCompleted(parseInt(savedSessions)); + } + const newEndTime = savedEndTime ? parseInt(savedEndTime) : null; + if (newEndTime !== endTime) { + setEndTime(newEndTime); + } + }; + + window.addEventListener('pomodoroStateChange', handleStorageChange); + return () => window.removeEventListener('pomodoroStateChange', handleStorageChange); + }, [mode, timeLeft, isRunning, sessionsCompleted, endTime]); + + // Function to play notification sound using Web Audio API + const playNotificationSound = () => { + try { + const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + + // Create a pleasant notification sound (two beeps) + oscillator.frequency.value = 800; + oscillator.type = 'sine'; + + gainNode.gain.setValueAtTime(0.3, audioContext.currentTime); + gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2); + + oscillator.start(audioContext.currentTime); + oscillator.stop(audioContext.currentTime + 0.2); + + // Second beep + setTimeout(() => { + const oscillator2 = audioContext.createOscillator(); + const gainNode2 = audioContext.createGain(); + + oscillator2.connect(gainNode2); + gainNode2.connect(audioContext.destination); + + oscillator2.frequency.value = 1000; + oscillator2.type = 'sine'; + + gainNode2.gain.setValueAtTime(0.3, audioContext.currentTime); + gainNode2.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2); + + oscillator2.start(audioContext.currentTime); + oscillator2.stop(audioContext.currentTime + 0.2); + }, 250); + } catch (e) { + console.error('Audio play failed:', e); + } + }; + + // Notify parent component of state changes + useEffect(() => { + if (onStateChange) { + onStateChange(isRunning, timeLeft, mode); + } + }, [isRunning, timeLeft, mode, onStateChange]); + + const handleTimerComplete = useCallback(() => { + setIsRunning(false); + setEndTime(null); + + // Play notification sound + playNotificationSound(); + + if (mode === 'work') { + const newSessionCount = sessionsCompleted + 1; + setSessionsCompleted(newSessionCount); + + // Determine next break type + if (newSessionCount % SESSIONS_BEFORE_LONG_BREAK === 0) { + setMode('longBreak'); + setTimeLeft(TIMER_DURATIONS.longBreak); + showSuccess(`Session terminée ! Temps pour une longue pause (${TIMER_DURATIONS.longBreak / 60} min)`); + } else { + setMode('shortBreak'); + setTimeLeft(TIMER_DURATIONS.shortBreak); + showSuccess(`Session terminée ! Temps pour une courte pause (${TIMER_DURATIONS.shortBreak / 60} min)`); + } + } else { + // Break completed, back to work + setMode('work'); + setTimeLeft(TIMER_DURATIONS.work); + showSuccess('Pause terminée ! Prêt pour une nouvelle session ?'); + } + }, [mode, sessionsCompleted]); + + // Manage timer interval - using timestamps for accuracy across tab switches + useEffect(() => { + if (isRunning && endTime) { + intervalRef.current = setInterval(() => { + const now = Date.now(); + const remaining = Math.max(0, Math.ceil((endTime - now) / 1000)); + + setTimeLeft(remaining); + + if (remaining <= 0) { + handleTimerComplete(); + } + }, 100); // Check every 100ms for better accuracy + } else { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + } + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [isRunning, endTime, handleTimerComplete]); + + const handleStart = () => { + const now = Date.now(); + const newEndTime = now + (timeLeft * 1000); + setEndTime(newEndTime); + setIsRunning(true); + }; + + const handlePause = () => { + setIsRunning(false); + setEndTime(null); + // timeLeft is already updated by the interval + }; + + const handleReset = () => { + setIsRunning(false); + setEndTime(null); + setTimeLeft(TIMER_DURATIONS[mode]); + }; + + const handleModeChange = (newMode: TimerMode) => { + setMode(newMode); + setTimeLeft(TIMER_DURATIONS[newMode]); + setIsRunning(false); + setEndTime(null); + }; + + const formatTime = (seconds: number): string => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + }; + + const progress = ((TIMER_DURATIONS[mode] - timeLeft) / TIMER_DURATIONS[mode]) * 100; + + return ( + + + + + Pomodoro Timer + + + {sessionsCompleted} session{sessionsCompleted !== 1 ? 's' : ''} + + + + + {/* Mode Selector */} +
+ + + +
+ + {/* Timer Display */} +
+
+ {formatTime(timeLeft)} +
+ {/* Progress bar */} +
+
+
+
+ + {/* Controls */} +
+ {!isRunning ? ( + + ) : ( + + )} + +
+ + {/* Session Info */} +
+ {mode === 'work' && ( +

Session {(sessionsCompleted % SESSIONS_BEFORE_LONG_BREAK) + 1} sur {SESSIONS_BEFORE_LONG_BREAK}

+ )} + {mode === 'shortBreak' &&

Pause courte - Profitez-en pour vous étirer

} + {mode === 'longBreak' &&

Pause longue - Temps de vraiment déconnecter

} +
+ + + ); +} diff --git a/src/components/QuickCaptureWidget.tsx b/src/components/QuickCaptureWidget.tsx new file mode 100644 index 0000000..dae59d8 --- /dev/null +++ b/src/components/QuickCaptureWidget.tsx @@ -0,0 +1,144 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Textarea } from "@/components/ui/textarea"; +import { Input } from "@/components/ui/input"; +import { Zap, Send } from "lucide-react"; +import NotesService from "@/services/NotesService"; +import { showSuccess, showError } from "@/utils/toast"; + +interface QuickCaptureWidgetProps { + onNoteCaptured?: () => void; +} + +export function QuickCaptureWidget({ onNoteCaptured }: QuickCaptureWidgetProps) { + const [open, setOpen] = useState(false); + const [title, setTitle] = useState(""); + const [content, setContent] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleCapture = async () => { + if (!content.trim()) { + showError("Le contenu ne peut pas être vide"); + return; + } + + setIsSubmitting(true); + try { + const noteTitle = title.trim() || `Note rapide - ${new Date().toLocaleDateString('fr-FR', { + day: '2-digit', + month: 'short', + hour: '2-digit', + minute: '2-digit' + })}`; + + await NotesService.createNote(noteTitle, content); + showSuccess("Note capturée avec succès"); + + // Reset form + setTitle(""); + setContent(""); + setOpen(false); + + // Notify parent component + if (onNoteCaptured) { + onNoteCaptured(); + } + } catch (error) { + showError("Erreur lors de la capture de la note"); + } finally { + setIsSubmitting(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Cmd/Ctrl + Enter to submit + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault(); + handleCapture(); + } + + // Escape to close + if (e.key === 'Escape') { + setOpen(false); + } + }; + + return ( + <> + {/* Floating Action Button */} + + + {/* Quick Capture Modal */} + + + + + + Capture rapide + + + Capturez rapidement une idée ou une note. Appuyez sur Ctrl+Entrée pour enregistrer. + + + +
+ {/* Optional Title */} +
+ setTitle(e.target.value)} + className="text-lg font-medium" + autoFocus={false} + /> +

+ Si vide, un titre avec la date sera généré automatiquement +

+
+ + {/* Content */} +