// Routes de gestion des notes const express = require('express'); const router = express.Router(); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const { body, validationResult } = require('express-validator'); const { getAll, getOne, runQuery } = require('../config/database'); const { authenticateToken } = require('../middleware/auth'); const logger = require('../config/logger'); // Configuration de multer pour l'upload d'images const storage = multer.diskStorage({ destination: (req, file, cb) => { const uploadsDir = path.join(__dirname, '../public/uploads'); cb(null, uploadsDir); }, filename: (req, file, cb) => { const uniqueName = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}${path.extname(file.originalname)}`; cb(null, uniqueName); } }); const upload = multer({ storage, limits: { fileSize: parseInt(process.env.MAX_FILE_SIZE) || 5 * 1024 * 1024 }, // 5MB fileFilter: (req, file, cb) => { const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; if (allowedTypes.includes(file.mimetype)) { cb(null, true); } else { cb(new Error('Type de fichier non autorisé. Utilisez JPEG, PNG, WebP ou GIF.')); } } }); // Toutes les routes nécessitent authentification router.use(authenticateToken); /** * GET /api/notes/counts * Récupérer le nombre de notes actives et archivées */ router.get('/counts', async (req, res) => { try { const activeCount = await getAll(` SELECT COUNT(*) as count FROM notes WHERE user_id = $1 AND archived = false `, [req.user.id]); const archivedCount = await getAll(` SELECT COUNT(*) as count FROM notes WHERE user_id = $1 AND archived = true `, [req.user.id]); res.json({ active: parseInt(activeCount[0]?.count || 0), archived: parseInt(archivedCount[0]?.count || 0) }); } catch (error) { logger.error('Erreur lors de la récupération des compteurs:', error); res.status(500).json({ error: 'Erreur serveur' }); } }); /** * GET /api/notes?archived=true/false * Liste toutes les notes de l'utilisateur */ router.get('/', async (req, res) => { try { const showArchived = req.query.archived === 'true'; const archivedFilter = showArchived; // BOOLEAN instead of 0/1 const notes = await getAll(` SELECT n.id, n.title, n.content, n.image_filename, n.archived, n.priority, n.created_at, n.updated_at, (SELECT COUNT(*) FROM note_todos WHERE note_id = n.id) as todos_count, (SELECT COUNT(*) FROM note_todos WHERE note_id = n.id AND completed = TRUE) as todos_completed FROM notes n WHERE n.user_id = $1 AND n.archived = $2 ORDER BY n.priority DESC, n.updated_at DESC `, [req.user.id, archivedFilter]); // Charger les todos, images, fichiers et tags pour chaque note for (const note of notes) { const todos = await getAll(` SELECT id, text, completed, position, parent_id, level FROM note_todos WHERE note_id = $1 ORDER BY position ASC, id ASC `, [note.id]); note.todos = todos || []; const images = await getAll(` SELECT id, filename, original_name, created_at FROM note_images WHERE note_id = $1 ORDER BY created_at DESC `, [note.id]); note.images = images || []; const files = await getAll(` SELECT id, filename, original_name, file_size, mime_type, created_at FROM note_files WHERE note_id = $1 ORDER BY created_at DESC `, [note.id]); note.files = files || []; note.files_count = files?.length || 0; const tags = await getAll(` SELECT id, tag as name FROM note_tags WHERE note_id = $1 ORDER BY tag ASC `, [note.id]); note.tags = tags || []; } res.json(notes || []); } catch (error) { logger.error('Erreur lors de la récupération des notes:', error); res.status(500).json({ error: 'Erreur serveur' }); } }); /** * GET /api/notes/:id * Récupérer une note avec ses todos */ router.get('/:id', async (req, res) => { try { const note = await getOne(` SELECT id, title, content, image_filename, created_at, updated_at FROM notes WHERE id = $1 AND user_id = $2 `, [req.params.id, req.user.id]); if (!note) { return res.status(404).json({ error: 'Note non trouvée' }); } // Récupérer les todos de la note const todos = await getAll(` SELECT id, text, completed, position FROM note_todos WHERE note_id = $1 ORDER BY position, id `, [req.params.id]); note.todos = todos; // Récupérer les images de la note const images = await getAll(` SELECT id, filename, original_name, created_at FROM note_images WHERE note_id = $1 ORDER BY created_at DESC `, [req.params.id]); note.images = images || []; // Récupérer les fichiers de la note const files = await getAll(` SELECT id, filename, original_name, file_size, mime_type, created_at FROM note_files WHERE note_id = $1 ORDER BY created_at DESC `, [req.params.id]); note.files = files || []; res.json(note); } catch (error) { logger.error('Erreur lors de la récupération de la note:', error); res.status(500).json({ error: 'Erreur serveur' }); } }); /** * POST /api/notes * Créer une nouvelle note */ router.post('/', [ body('title').trim().notEmpty().withMessage('Le titre est requis'), body('content').optional() ], async (req, res) => { try { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ error: 'Données invalides', details: errors.array() }); } const { title, content } = req.body; logger.info(`[CREATE NOTE] Début création - user_id: ${req.user.id}, title: "${title}"`); const result = await runQuery(` INSERT INTO notes (user_id, title, content) VALUES ($1, $2, $3) RETURNING * `, [req.user.id, title, content || '']); logger.info(`[CREATE NOTE] Note créée avec succès - ID: ${result.id}, title: "${title}", user: ${req.user.username}`); res.status(201).json({ id: result.id, title, content: content || '', image_filename: null, todos: [] }); } catch (error) { logger.error('Erreur lors de la création de la note:', error); res.status(500).json({ error: 'Erreur serveur' }); } } ); /** * POST /api/notes/full * Créer une note complète avec tâches et tags en une seule requête */ router.post('/full', [ body('title').trim().notEmpty().withMessage('Le titre est requis'), body('content').optional(), body('todos').optional().isArray(), body('tags').optional().isArray() ], async (req, res) => { try { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ error: 'Données invalides', details: errors.array() }); } const { title, content, todos = [], tags = [] } = req.body; logger.info(`[CREATE NOTE FULL] Début création - user_id: ${req.user.id}, title: "${title}", todos: ${todos.length}, tags: ${tags.length}`); // 1. Créer la note const noteResult = await runQuery(` INSERT INTO notes (user_id, title, content) VALUES ($1, $2, $3) RETURNING * `, [req.user.id, title, content || '']); const noteId = noteResult.id; const createdTodos = []; const createdTags = []; // 2. Créer les todos (avec support des subtasks) const createTodosRecursive = async (todoList, parentId = null, level = 0) => { let position = 0; for (const todo of todoList) { position++; const todoResult = await runQuery(` INSERT INTO note_todos (note_id, text, completed, priority, position, parent_id, level) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING * `, [ noteId, todo.text, todo.completed || false, todo.priority || false, position, parentId, level ]); const createdTodo = { id: todoResult.id, text: todoResult.text, completed: todoResult.completed, priority: todoResult.priority, position: todoResult.position, parent_id: todoResult.parent_id, level: todoResult.level }; // Traiter les subtasks si présents if (todo.subtasks && Array.isArray(todo.subtasks) && todo.subtasks.length > 0) { createdTodo.subtasks = []; const subtasks = await createTodosRecursive(todo.subtasks, todoResult.id, level + 1); createdTodo.subtasks = subtasks; } createdTodos.push(createdTodo); } return createdTodos.filter(t => t.parent_id === parentId); }; await createTodosRecursive(todos); // 3. Créer les tags for (const tag of tags) { const tagName = typeof tag === 'string' ? tag.trim().toLowerCase() : tag.name?.trim().toLowerCase(); if (tagName) { try { const tagResult = await runQuery(` INSERT INTO note_tags (note_id, tag) VALUES ($1, $2) RETURNING * `, [noteId, tagName]); createdTags.push({ id: tagResult.id, name: tagResult.tag }); } catch (err) { // Ignorer les doublons if (!err.message?.includes('UNIQUE')) { throw err; } } } } logger.info(`[CREATE NOTE FULL] Note créée avec succès - ID: ${noteId}, todos: ${createdTodos.length}, tags: ${createdTags.length}`); res.status(201).json({ id: noteId, title, content: content || '', image_filename: null, archived: false, priority: 0, todos: createdTodos, tags: createdTags, images: [], files: [], created_at: noteResult.created_at, updated_at: noteResult.updated_at }); } catch (error) { logger.error('Erreur lors de la création de la note complète:', error); res.status(500).json({ error: 'Erreur serveur' }); } } ); /** * PUT /api/notes/:id/full * Mise à jour complète d'une note avec ses tâches et tags */ router.put('/:id/full', [ body('title').optional().trim().notEmpty(), body('content').optional(), body('todos').optional().isArray(), body('tags').optional().isArray(), body('replace_todos').optional().isBoolean(), body('replace_tags').optional().isBoolean() ], async (req, res) => { try { const { title, content, todos, tags, replace_todos = false, replace_tags = false } = req.body; const noteId = req.params.id; // Vérifier que la note appartient à l'utilisateur const note = await getOne('SELECT id FROM notes WHERE id = $1 AND user_id = $2', [noteId, req.user.id]); if (!note) { return res.status(404).json({ error: 'Note non trouvée' }); } logger.info(`[UPDATE NOTE FULL] Mise à jour - note_id: ${noteId}, replace_todos: ${replace_todos}, replace_tags: ${replace_tags}`); // 1. Mettre à jour la note const updates = []; const params = []; let paramCount = 0; if (title !== undefined) { paramCount++; updates.push(`title = $${paramCount}`); params.push(title); } if (content !== undefined) { paramCount++; updates.push(`content = $${paramCount}`); params.push(content); } if (updates.length > 0) { updates.push('updated_at = CURRENT_TIMESTAMP'); paramCount++; params.push(noteId); await runQuery(`UPDATE notes SET ${updates.join(', ')} WHERE id = $${paramCount}`, params); } const createdTodos = []; const createdTags = []; // 2. Gérer les todos if (todos !== undefined) { if (replace_todos) { // Supprimer tous les todos existants await runQuery('DELETE FROM note_todos WHERE note_id = $1', [noteId]); } // Créer les nouveaux todos const createTodosRecursive = async (todoList, parentId = null, level = 0) => { let position = 0; for (const todo of todoList) { position++; const todoResult = await runQuery(` INSERT INTO note_todos (note_id, text, completed, priority, position, parent_id, level) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING * `, [ noteId, todo.text, todo.completed || false, todo.priority || false, position, parentId, level ]); const createdTodo = { id: todoResult.id, text: todoResult.text, completed: todoResult.completed, priority: todoResult.priority, position: todoResult.position, parent_id: todoResult.parent_id, level: todoResult.level }; if (todo.subtasks && Array.isArray(todo.subtasks) && todo.subtasks.length > 0) { createdTodo.subtasks = []; await createTodosRecursive(todo.subtasks, todoResult.id, level + 1); } createdTodos.push(createdTodo); } }; await createTodosRecursive(todos); } // 3. Gérer les tags if (tags !== undefined) { if (replace_tags) { // Supprimer tous les tags existants await runQuery('DELETE FROM note_tags WHERE note_id = $1', [noteId]); } // Créer les nouveaux tags for (const tag of tags) { const tagName = typeof tag === 'string' ? tag.trim().toLowerCase() : tag.name?.trim().toLowerCase(); if (tagName) { try { const tagResult = await runQuery(` INSERT INTO note_tags (note_id, tag) VALUES ($1, $2) RETURNING * `, [noteId, tagName]); createdTags.push({ id: tagResult.id, name: tagResult.tag }); } catch (err) { if (!err.message?.includes('UNIQUE')) { throw err; } } } } } // Mettre à jour updated_at await runQuery('UPDATE notes SET updated_at = CURRENT_TIMESTAMP WHERE id = $1', [noteId]); // Récupérer la note mise à jour const updatedNote = await getOne(` SELECT id, title, content, image_filename, archived, priority, created_at, updated_at FROM notes WHERE id = $1 `, [noteId]); // Récupérer tous les todos de la note const allTodos = await getAll(` SELECT id, text, completed, priority, position, parent_id, level FROM note_todos WHERE note_id = $1 ORDER BY position ASC, id ASC `, [noteId]); // Récupérer tous les tags de la note const allTags = await getAll(` SELECT id, tag as name FROM note_tags WHERE note_id = $1 ORDER BY tag ASC `, [noteId]); logger.info(`[UPDATE NOTE FULL] Mise à jour réussie - note_id: ${noteId}`); res.json({ message: 'Note mise à jour avec succès', note: { ...updatedNote, todos: allTodos || [], tags: allTags || [] } }); } catch (error) { logger.error('Erreur lors de la mise à jour complète de la note:', error); res.status(500).json({ error: 'Erreur serveur' }); } } ); /** * POST /api/notes/:id/todos/batch * Ajouter plusieurs tâches à une note en une seule requête */ router.post('/:id/todos/batch', [ body('todos').isArray({ min: 1 }).withMessage('Au moins une tâche est requise'), body('todos.*.text').trim().notEmpty().withMessage('Le texte de chaque tâche est requis') ], async (req, res) => { try { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ error: 'Données invalides', details: errors.array() }); } const noteId = req.params.id; const { todos } = req.body; // Vérifier que la note appartient à l'utilisateur const note = await getOne('SELECT id FROM notes WHERE id = $1 AND user_id = $2', [noteId, req.user.id]); if (!note) { return res.status(404).json({ error: 'Note non trouvée' }); } logger.info(`[CREATE TODOS BATCH] Ajout de ${todos.length} tâches à la note ${noteId}`); const createdTodos = []; // Créer les todos récursivement const createTodosRecursive = async (todoList, parentId = null, level = 0) => { // Récupérer la position max actuelle const maxPosResult = await getOne(` SELECT COALESCE(MAX(position), 0) as max_pos FROM note_todos WHERE note_id = $1 AND COALESCE(parent_id, 0) = COALESCE($2, 0) `, [noteId, parentId]); let position = maxPosResult?.max_pos || 0; for (const todo of todoList) { position++; const todoResult = await runQuery(` INSERT INTO note_todos (note_id, text, completed, priority, position, parent_id, level) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING * `, [ noteId, todo.text, todo.completed || false, todo.priority || false, position, parentId, level ]); const createdTodo = { id: todoResult.id, text: todoResult.text, completed: todoResult.completed, priority: todoResult.priority, position: todoResult.position, parent_id: todoResult.parent_id, level: todoResult.level }; // Traiter les subtasks si présents if (todo.subtasks && Array.isArray(todo.subtasks) && todo.subtasks.length > 0) { createdTodo.subtasks = await createTodosRecursive(todo.subtasks, todoResult.id, level + 1); } createdTodos.push(createdTodo); } return createdTodos.filter(t => t.parent_id === parentId); }; await createTodosRecursive(todos); // Mettre à jour updated_at de la note await runQuery('UPDATE notes SET updated_at = CURRENT_TIMESTAMP WHERE id = $1', [noteId]); logger.info(`[CREATE TODOS BATCH] ${createdTodos.length} tâches créées pour la note ${noteId}`); res.status(201).json({ message: `${createdTodos.length} tâche(s) ajoutée(s) avec succès`, todos: createdTodos }); } catch (error) { logger.error('Erreur lors de l\'ajout des tâches en lot:', error); res.status(500).json({ error: 'Erreur serveur' }); } } ); /** * PUT /api/notes/:id * Modifier une note */ router.put('/:id', [ body('title').optional().trim().notEmpty(), body('content').optional() ], async (req, res) => { try { const { title, content } = req.body; // Vérifier que la note appartient à l'utilisateur const note = await getOne('SELECT id FROM notes WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]); if (!note) { return res.status(404).json({ error: 'Note non trouvée' }); } const updates = []; const params = []; let paramCount = 0; if (title !== undefined) { paramCount++; updates.push(`title = $${paramCount}`); params.push(title); } if (content !== undefined) { paramCount++; updates.push(`content = $${paramCount}`); params.push(content); } if (updates.length > 0) { updates.push('updated_at = CURRENT_TIMESTAMP'); paramCount++; params.push(req.params.id); await runQuery(`UPDATE notes SET ${updates.join(', ')} WHERE id = $${paramCount}`, params); } // Récupérer la note mise à jour avec sa nouvelle updated_at const updatedNote = await getOne(` SELECT id, title, content, image_filename, archived, priority, created_at, updated_at FROM notes WHERE id = $1 `, [req.params.id]); logger.info(`[UPDATE NOTE] Note mise à jour - ID: ${req.params.id}, updated_at: ${updatedNote.updated_at}`); res.json({ message: 'Note modifiée avec succès', note: updatedNote }); } catch (error) { logger.error('Erreur lors de la modification de la note:', error); res.status(500).json({ error: 'Erreur serveur' }); } } ); /** * PUT /api/notes/:id/archive * Archiver ou désarchiver une note */ router.put('/:id/archive', async (req, res) => { try { const { archived } = req.body; // Vérifier que la note appartient à l'utilisateur const note = await getOne('SELECT id FROM notes WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]); if (!note) { return res.status(404).json({ error: 'Note non trouvée' }); } await runQuery( 'UPDATE notes SET archived = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', [archived, req.params.id] // BOOLEAN instead of 0/1 ); logger.info(`Note ${archived ? 'archivée' : 'désarchivée'}: ${req.params.id}`); res.json({ message: `Note ${archived ? 'archivée' : 'désarchivée'} avec succès`, archived }); } catch (error) { logger.error('Erreur lors de l\'archivage de la note:', error); res.status(500).json({ error: 'Erreur serveur' }); } }); /** * DELETE /api/notes/:id * Supprimer une note */ router.delete('/:id', async (req, res) => { try { // Vérifier que la note appartient à l'utilisateur const note = await getOne('SELECT id, image_filename FROM notes WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]); if (!note) { return res.status(404).json({ error: 'Note non trouvée' }); } // Supprimer l'image si elle existe if (note.image_filename) { const imagePath = path.join(__dirname, '../public/uploads', note.image_filename); if (fs.existsSync(imagePath)) { fs.unlinkSync(imagePath); } } await runQuery('DELETE FROM notes WHERE id = $1', [req.params.id]); logger.info(`Note supprimée (ID: ${req.params.id}) par ${req.user.username}`); res.json({ message: 'Note supprimée avec succès' }); } catch (error) { logger.error('Erreur lors de la suppression de la note:', error); res.status(500).json({ error: 'Erreur serveur' }); } }); /** * POST /api/notes/:id/image * Ajouter une image à une note */ router.post('/:id/image', upload.single('image'), async (req, res) => { try { if (!req.file) { return res.status(400).json({ error: 'Aucune image fournie' }); } // Vérifier que la note appartient à l'utilisateur const note = await getOne('SELECT id, image_filename FROM notes WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]); if (!note) { // Supprimer le fichier uploadé fs.unlinkSync(req.file.path); return res.status(404).json({ error: 'Note non trouvée' }); } // Supprimer l'ancienne image si elle existe if (note.image_filename) { const oldImagePath = path.join(__dirname, '../public/uploads', note.image_filename); if (fs.existsSync(oldImagePath)) { fs.unlinkSync(oldImagePath); } } // Mettre à jour la note avec le nouveau fichier await runQuery('UPDATE notes SET image_filename = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', [req.file.filename, req.params.id]); res.json({ message: 'Image ajoutée avec succès', filename: req.file.filename, url: `/uploads/${req.file.filename}` }); } catch (error) { logger.error('Erreur lors de l\'ajout de l\'image:', error); if (req.file) { fs.unlinkSync(req.file.path); } res.status(500).json({ error: 'Erreur serveur' }); } }); /** * DELETE /api/notes/:id/image * Supprimer l'image d'une note */ router.delete('/:id/image', async (req, res) => { try { const note = await getOne('SELECT id, image_filename FROM notes WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]); if (!note) { return res.status(404).json({ error: 'Note non trouvée' }); } if (!note.image_filename) { return res.status(400).json({ error: 'Aucune image à supprimer' }); } // Supprimer le fichier const imagePath = path.join(__dirname, '../public/uploads', note.image_filename); if (fs.existsSync(imagePath)) { fs.unlinkSync(imagePath); } await runQuery('UPDATE notes SET image_filename = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = $1', [req.params.id]); res.json({ message: 'Image supprimée avec succès' }); } catch (error) { logger.error('Erreur lors de la suppression de l\'image:', error); res.status(500).json({ error: 'Erreur serveur' }); } }); /** * POST /api/notes/:id/todos * Ajouter un todo à une note (supports subtasks via parent_id) */ router.post('/:id/todos', [ body('text').trim().notEmpty().withMessage('Le texte est requis'), body('parent_id').optional().isInt() ], async (req, res) => { try { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ error: 'Données invalides', details: errors.array() }); } // Vérifier que la note appartient à l'utilisateur const note = await getOne('SELECT id FROM notes WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]); if (!note) { return res.status(404).json({ error: 'Note non trouvée' }); } const { text, parent_id } = req.body; let level = 0; // 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, 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, parent_id || null, level, req.params.id, parent_id || null]); 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]); res.status(201).json({ id: result.id, text, completed: false, position: result.position || 0, parent_id: parent_id || null, level: level }); } catch (error) { logger.error('Erreur lors de l\'ajout du todo:', error); res.status(500).json({ error: 'Erreur serveur' }); } } ); /** * PUT /api/notes/todos/:todoId * Modifier un todo dans une note */ router.put('/todos/:todoId', [ body('text').optional().trim().notEmpty(), body('completed').optional().isBoolean(), body('position').optional().isInt() ], async (req, res) => { try { const { text, completed, position } = req.body; // Vérifier que le todo appartient à une note de l'utilisateur const todo = await getOne(` SELECT nt.id, nt.note_id FROM note_todos nt JOIN notes n ON nt.note_id = n.id WHERE nt.id = $1 AND n.user_id = $2 `, [req.params.todoId, req.user.id]); if (!todo) { return res.status(404).json({ error: 'Todo non trouvé' }); } const updates = []; const params = []; let paramCount = 0; if (text !== undefined) { paramCount++; updates.push(`text = $${paramCount}`); params.push(text); } if (completed !== undefined) { paramCount++; updates.push(`completed = $${paramCount}`); params.push(completed); // BOOLEAN instead of 0/1 } if (position !== undefined) { paramCount++; updates.push(`position = $${paramCount}`); params.push(position); } if (updates.length > 0) { paramCount++; params.push(req.params.todoId); await runQuery(`UPDATE note_todos SET ${updates.join(', ')} WHERE id = $${paramCount}`, params); await runQuery('UPDATE notes SET updated_at = CURRENT_TIMESTAMP WHERE id = $1', [todo.note_id]); } res.json({ message: 'Todo modifié avec succès' }); } catch (error) { logger.error('Erreur lors de la modification du todo:', error); res.status(500).json({ error: 'Erreur serveur' }); } } ); /** * DELETE /api/notes/todos/:todoId * Supprimer un todo d'une note */ router.delete('/todos/:todoId', async (req, res) => { try { // Vérifier que le todo appartient à une note de l'utilisateur const todo = await getOne(` SELECT nt.id, nt.note_id FROM note_todos nt JOIN notes n ON nt.note_id = n.id WHERE nt.id = $1 AND n.user_id = $2 `, [req.params.todoId, req.user.id]); if (!todo) { return res.status(404).json({ error: 'Todo non trouvé' }); } await runQuery('DELETE FROM note_todos WHERE id = $1', [req.params.todoId]); await runQuery('UPDATE notes SET updated_at = CURRENT_TIMESTAMP WHERE id = $1', [todo.note_id]); res.json({ message: 'Todo supprimé avec succès' }); } catch (error) { logger.error('Erreur lors de la suppression du todo:', error); res.status(500).json({ error: 'Erreur serveur' }); } }); /** * GET /api/search * Rechercher des notes */ const searchNotes = async (req, res) => { try { const query = req.query.q; if (!query || query.trim().length === 0) { return res.json([]); } const searchTerm = `%${query}%`; const notes = await getAll(` SELECT id, title, content, image_filename, created_at, updated_at FROM notes WHERE user_id = $1 AND (title LIKE $2 OR content LIKE $3) ORDER BY updated_at DESC LIMIT 50 `, [req.user.id, searchTerm, searchTerm]); res.json(notes); } catch (error) { logger.error('Erreur lors de la recherche:', error); res.status(500).json({ error: 'Erreur serveur' }); } }; // ==================== IMAGE ATTACHMENTS ==================== // Configuration multer pour les images multiples const imageStorage = multer.diskStorage({ destination: (req, file, cb) => { const imagesDir = path.join(__dirname, '../public/uploads/images'); // Créer le dossier s'il n'existe pas if (!fs.existsSync(imagesDir)) { fs.mkdirSync(imagesDir, { recursive: true }); } cb(null, imagesDir); }, filename: (req, file, cb) => { const uniqueName = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}${path.extname(file.originalname)}`; cb(null, uniqueName); } }); const imageUpload = multer({ storage: imageStorage, limits: { fileSize: parseInt(process.env.MAX_FILE_SIZE) || 5 * 1024 * 1024 }, // 5MB fileFilter: (req, file, cb) => { const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; if (allowedTypes.includes(file.mimetype)) { cb(null, true); } else { cb(new Error('Type de fichier non autorisé. Utilisez JPEG, PNG, WebP ou GIF.')); } } }); /** * POST /api/notes/:id/images * Ajouter une image à une note */ router.post('/:id/images', imageUpload.single('image'), async (req, res) => { try { if (!req.file) { return res.status(400).json({ error: 'Aucune image fournie' }); } // Vérifier que la note appartient à l'utilisateur const note = await getOne('SELECT id FROM notes WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]); if (!note) { // Supprimer le fichier uploadé fs.unlinkSync(req.file.path); return res.status(404).json({ error: 'Note non trouvée' }); } // Enregistrer l'image dans la base de données logger.info(`[CREATE IMAGE] Ajout image à note ${req.params.id}: ${req.file.originalname}`); const result = await runQuery(` INSERT INTO note_images (note_id, filename, original_name) VALUES ($1, $2, $3) RETURNING * `, [req.params.id, req.file.filename, req.file.originalname]); // Mettre à jour la date de modification de la note await runQuery('UPDATE notes SET updated_at = CURRENT_TIMESTAMP WHERE id = $1', [req.params.id]); logger.info(`[CREATE IMAGE] Image créée - ID: ${result.id}, filename: ${req.file.filename}, note_id: ${req.params.id}`); res.json({ message: 'Image ajoutée avec succès', image: { id: result.id, filename: req.file.filename, original_name: req.file.originalname } }); } catch (error) { logger.error('Erreur lors de l\'ajout de l\'image:', error); if (req.file && fs.existsSync(req.file.path)) { fs.unlinkSync(req.file.path); } res.status(500).json({ error: 'Erreur serveur' }); } }); /** * GET /api/notes/:id/images * Liste les images d'une note */ router.get('/:id/images', async (req, res) => { try { // Vérifier que la note appartient à l'utilisateur const note = await getOne('SELECT id FROM notes WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]); if (!note) { return res.status(404).json({ error: 'Note non trouvée' }); } const images = await getAll(` SELECT id, filename, original_name, created_at FROM note_images WHERE note_id = $1 ORDER BY created_at DESC `, [req.params.id]); res.json(images); } catch (error) { logger.error('Erreur lors de la récupération des images:', error); res.status(500).json({ error: 'Erreur serveur' }); } }); /** * DELETE /api/notes/:noteId/images/:imageId * Supprimer une image d'une note */ router.delete('/:noteId/images/:imageId', async (req, res) => { try { // Vérifier que l'image appartient à une note de l'utilisateur const image = await getOne(` SELECT ni.id, ni.filename, ni.note_id FROM note_images ni JOIN notes n ON ni.note_id = n.id WHERE ni.id = $1 AND ni.note_id = $2 AND n.user_id = $3 `, [req.params.imageId, req.params.noteId, req.user.id]); if (!image) { return res.status(404).json({ error: 'Image non trouvée' }); } // Supprimer le fichier physique const imagePath = path.join(__dirname, '../public/uploads/images', image.filename); if (fs.existsSync(imagePath)) { fs.unlinkSync(imagePath); } // Supprimer l'entrée de la base de données await runQuery('DELETE FROM note_images WHERE id = $1', [req.params.imageId]); // Mettre à jour la date de modification de la note await runQuery('UPDATE notes SET updated_at = CURRENT_TIMESTAMP WHERE id = $1', [req.params.noteId]); logger.info(`Image ${image.filename} supprimée de la note ${req.params.noteId}`); res.json({ message: 'Image supprimée avec succès' }); } catch (error) { logger.error('Erreur lors de la suppression de l\'image:', error); res.status(500).json({ error: 'Erreur serveur' }); } }); // ==================== FILE ATTACHMENTS ==================== // Configuration multer pour tous types de fichiers const fileStorage = multer.diskStorage({ destination: (req, file, cb) => { const filesDir = path.join(__dirname, '../public/uploads/files'); // Créer le dossier s'il n'existe pas if (!fs.existsSync(filesDir)) { fs.mkdirSync(filesDir, { recursive: true }); } cb(null, filesDir); }, filename: (req, file, cb) => { const uniqueName = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}-${file.originalname}`; cb(null, uniqueName); } }); const fileUpload = multer({ storage: fileStorage, limits: { fileSize: parseInt(process.env.MAX_FILE_SIZE) || 10 * 1024 * 1024 } // 10MB }); /** * POST /api/notes/:id/files * Ajouter un fichier à une note */ router.post('/:id/files', fileUpload.single('file'), async (req, res) => { try { if (!req.file) { return res.status(400).json({ error: 'Aucun fichier fourni' }); } // Vérifier que la note appartient à l'utilisateur const note = await getOne('SELECT id FROM notes WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]); if (!note) { // Supprimer le fichier uploadé fs.unlinkSync(req.file.path); return res.status(404).json({ error: 'Note non trouvée' }); } // Enregistrer le fichier dans la base de données logger.info(`[CREATE FILE] Ajout fichier à note ${req.params.id}: ${req.file.originalname}`); const result = await runQuery(` INSERT INTO note_files (note_id, filename, original_name, file_size, mime_type) VALUES ($1, $2, $3, $4, $5) RETURNING * `, [req.params.id, req.file.filename, req.file.originalname, req.file.size, req.file.mimetype]); // Mettre à jour la date de modification de la note await runQuery('UPDATE notes SET updated_at = CURRENT_TIMESTAMP WHERE id = $1', [req.params.id]); logger.info(`[CREATE FILE] Fichier créé - ID: ${result.id}, filename: ${req.file.filename}, note_id: ${req.params.id}`); res.json({ message: 'Fichier ajouté avec succès', file: { id: result.id, filename: req.file.filename, original_name: req.file.originalname, file_size: req.file.size, mime_type: req.file.mimetype } }); } catch (error) { logger.error('Erreur lors de l\'ajout du fichier:', error); if (req.file && fs.existsSync(req.file.path)) { fs.unlinkSync(req.file.path); } res.status(500).json({ error: 'Erreur serveur' }); } }); /** * GET /api/notes/:id/files * Liste les fichiers d'une note */ router.get('/:id/files', async (req, res) => { try { // Vérifier que la note appartient à l'utilisateur const note = await getOne('SELECT id FROM notes WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]); if (!note) { return res.status(404).json({ error: 'Note non trouvée' }); } const files = await getAll(` SELECT id, filename, original_name, file_size, mime_type, created_at FROM note_files WHERE note_id = $1 ORDER BY created_at DESC `, [req.params.id]); res.json(files); } catch (error) { logger.error('Erreur lors de la récupération des fichiers:', error); res.status(500).json({ error: 'Erreur serveur' }); } }); /** * GET /api/notes/files/:fileId/download * Télécharger un fichier */ router.get('/files/:fileId/download', async (req, res) => { try { // Vérifier que le fichier appartient à une note de l'utilisateur const file = await getOne(` SELECT nf.id, nf.filename, nf.original_name, nf.mime_type FROM note_files nf JOIN notes n ON nf.note_id = n.id WHERE nf.id = $1 AND n.user_id = $2 `, [req.params.fileId, req.user.id]); if (!file) { return res.status(404).json({ error: 'Fichier non trouvé' }); } const filePath = path.join(__dirname, '../public/uploads/files', file.filename); if (!fs.existsSync(filePath)) { return res.status(404).json({ error: 'Fichier physique non trouvé' }); } res.download(filePath, file.original_name); } catch (error) { logger.error('Erreur lors du téléchargement du fichier:', error); res.status(500).json({ error: 'Erreur serveur' }); } }); /** * DELETE /api/notes/files/:fileId * Supprimer un fichier */ router.delete('/files/:fileId', async (req, res) => { try { // Vérifier que le fichier appartient à une note de l'utilisateur const file = await getOne(` SELECT nf.id, nf.note_id, nf.filename FROM note_files nf JOIN notes n ON nf.note_id = n.id WHERE nf.id = $1 AND n.user_id = $2 `, [req.params.fileId, req.user.id]); if (!file) { return res.status(404).json({ error: 'Fichier non trouvé' }); } // Supprimer le fichier physique const filePath = path.join(__dirname, '../public/uploads/files', file.filename); if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } // Supprimer de la base de données await runQuery('DELETE FROM note_files WHERE id = $1', [req.params.fileId]); // Mettre à jour la date de modification de la note await runQuery('UPDATE notes SET updated_at = CURRENT_TIMESTAMP WHERE id = $1', [file.note_id]); logger.info(`Fichier supprimé: ${file.filename}`); res.json({ message: 'Fichier supprimé avec succès' }); } catch (error) { logger.error('Erreur lors de la suppression du fichier:', error); res.status(500).json({ error: 'Erreur serveur' }); } }); // ==================== TAGS ==================== /** * GET /api/notes/:id/tags * Récupérer les tags d'une note */ router.get('/:id/tags', async (req, res) => { try { const note = await getOne('SELECT id FROM notes WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]); if (!note) { return res.status(404).json({ error: 'Note non trouvée' }); } const tags = await getAll('SELECT id, tag FROM note_tags WHERE note_id = $1 ORDER BY tag ASC', [req.params.id]); res.json(tags || []); } catch (error) { logger.error('Erreur lors de la récupération des tags:', error); res.status(500).json({ error: 'Erreur serveur' }); } }); /** * POST /api/notes/:id/tags * Ajouter un tag à une note */ router.post('/:id/tags', async (req, res) => { try { const { tag } = req.body; if (!tag || !tag.trim()) { return res.status(400).json({ error: 'Tag invalide' }); } const note = await getOne('SELECT id FROM notes WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]); if (!note) { return res.status(404).json({ error: 'Note non trouvée' }); } logger.info(`[CREATE TAG] Ajout tag à note ${req.params.id}: "${tag.trim().toLowerCase()}"`); const result = await runQuery('INSERT INTO note_tags (note_id, tag) VALUES ($1, $2) RETURNING *', [req.params.id, tag.trim().toLowerCase()]); logger.info(`[CREATE TAG] Tag créé - ID: ${result.id}, tag: "${result.tag}", note_id: ${req.params.id}`); res.json({ id: result.id, tag: tag.trim().toLowerCase() }); } catch (error) { if (error.message && error.message.includes('UNIQUE constraint')) { return res.status(409).json({ error: 'Ce tag existe déjà' }); } logger.error('Erreur lors de l\'ajout du tag:', error); res.status(500).json({ error: 'Erreur serveur' }); } }); /** * DELETE /api/notes/:noteId/tags/:tagId * Supprimer un tag d'une note */ router.delete('/:noteId/tags/:tagId', async (req, res) => { try { const tag = await getOne(` SELECT nt.id FROM note_tags nt JOIN notes n ON nt.note_id = n.id WHERE nt.id = $1 AND nt.note_id = $2 AND n.user_id = $3 `, [req.params.tagId, req.params.noteId, req.user.id]); if (!tag) { return res.status(404).json({ error: 'Tag non trouvé' }); } await runQuery('DELETE FROM note_tags WHERE id = $1', [req.params.tagId]); res.json({ message: 'Tag supprimé' }); } catch (error) { logger.error('Erreur lors de la suppression du tag:', error); res.status(500).json({ error: 'Erreur serveur' }); } }); /** * PATCH /api/notes/:id/priority * Modifier la priorité d'une note */ router.patch('/:id/priority', async (req, res) => { try { const { priority } = req.body; // Vérifier que la note appartient à l'utilisateur const note = await getOne('SELECT id FROM notes WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]); if (!note) { 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]); // 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 }); } catch (error) { logger.error('Erreur lors de la modification de la priorité:', error); res.status(500).json({ error: 'Erreur serveur' }); } }); module.exports = router; module.exports.searchNotes = searchNotes;