From a42f082042928aec80bda259b58098ba38a9e322 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Dec 2025 10:08:21 +0000 Subject: [PATCH 1/3] feat: add full notes API with batch todos support Add three new API endpoints for comprehensive note management: - POST /api/notes/full: Create note with title, content, todos, and tags in one request - PUT /api/notes/:id/full: Update note with optional todos/tags replacement - POST /api/notes/:id/todos/batch: Add multiple todos at once with subtask support Also adds corresponding frontend service methods in NotesService.ts --- routes/notes.routes.js | 379 +++++++++++++++++++++++++++++++++++ src/services/NotesService.ts | 124 ++++++++++++ 2 files changed, 503 insertions(+) diff --git a/routes/notes.routes.js b/routes/notes.routes.js index e7a40d0..f24a084 100644 --- a/routes/notes.routes.js +++ b/routes/notes.routes.js @@ -220,6 +220,385 @@ router.post('/', } ); +/** + * 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 diff --git a/src/services/NotesService.ts b/src/services/NotesService.ts index dab034a..df8830e 100644 --- a/src/services/NotesService.ts +++ b/src/services/NotesService.ts @@ -323,6 +323,130 @@ class NotesService { return false; } } + + /** + * Créer une note complète avec titre, contenu, tâches et tags en une seule requête + */ + async createNoteFull(data: { + title: string; + content?: string; + todos?: Array<{ + text: string; + completed?: boolean; + priority?: boolean; + subtasks?: Array<{ text: string; completed?: boolean; priority?: boolean }>; + }>; + tags?: string[]; + }): Promise { + try { + const response = await fetch("/api/notes/full", { + method: "POST", + headers: AuthService.getHeaders(), + body: JSON.stringify(data) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Erreur lors de la création de la note"); + } + + showSuccess("Note créée avec succès"); + const note = await response.json(); + return { + ...note, + todos: note.todos || [], + images: note.images || [], + tags: note.tags || [] + }; + } catch (error) { + showError(error instanceof Error ? error.message : "Erreur serveur"); + return null; + } + } + + /** + * Mettre à jour une note complète avec titre, contenu, tâches et tags + * @param replace_todos - Si true, remplace toutes les tâches existantes + * @param replace_tags - Si true, remplace tous les tags existants + */ + async updateNoteFull( + noteId: number, + data: { + title?: string; + content?: string; + todos?: Array<{ + text: string; + completed?: boolean; + priority?: boolean; + subtasks?: Array<{ text: string; completed?: boolean; priority?: boolean }>; + }>; + tags?: string[]; + replace_todos?: boolean; + replace_tags?: boolean; + } + ): Promise { + try { + const response = await fetch(`/api/notes/${noteId}/full`, { + method: "PUT", + headers: AuthService.getHeaders(), + body: JSON.stringify(data) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Erreur lors de la mise à jour de la note"); + } + + const result = await response.json(); + showSuccess("Note mise à jour avec succès"); + + if (result.note) { + return { + ...result.note, + todos: result.note.todos || [], + images: result.note.images || [], + tags: result.note.tags || [] + }; + } + return null; + } catch (error) { + showError(error instanceof Error ? error.message : "Erreur serveur"); + return null; + } + } + + /** + * Ajouter plusieurs tâches à une note en une seule requête + */ + async addTodosBatch( + noteId: number, + todos: Array<{ + text: string; + completed?: boolean; + priority?: boolean; + subtasks?: Array<{ text: string; completed?: boolean; priority?: boolean }>; + }> + ): Promise { + try { + const response = await fetch(`/api/notes/${noteId}/todos/batch`, { + method: "POST", + headers: AuthService.getHeaders(), + body: JSON.stringify({ todos }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Erreur lors de l'ajout des tâches"); + } + + const result = await response.json(); + showSuccess(`${result.todos?.length || 0} tâche(s) ajoutée(s)`); + return result.todos || []; + } catch (error) { + showError(error instanceof Error ? error.message : "Erreur serveur"); + return null; + } + } } export default new NotesService(); \ No newline at end of file From 64ba17407430ce4e76bf83d129bd0bbd4b0ac1cf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Dec 2025 10:21:08 +0000 Subject: [PATCH 2/3] feat: add API documentation modal in admin panel Add an "API" button for each user in the admin panel that displays: - Authentication instructions with curl examples - Documentation for new endpoints (notes/full, todos/batch) - Complete usage examples with proper headers - Important notes about token expiration and data isolation --- src/pages/Index.tsx | 150 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index a416f7f..601a139 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -194,6 +194,7 @@ const Index = () => { const [showKeyboardHelp, setShowKeyboardHelp] = useState(false); const [showStatsDashboard, setShowStatsDashboard] = useState(false); const [showPomodoroModal, setShowPomodoroModal] = useState(false); + const [apiInfoModal, setApiInfoModal] = useState<{ open: boolean; user?: UserType }>({ open: false }); // Pomodoro timer states const [pomodoroRunning, setPomodoroRunning] = useState(false); @@ -2258,6 +2259,14 @@ const Index = () => {
+ + + +
+ + ) : ( +
+

+ Aucune clé API. Cliquez pour en générer une. +

+ +
+ )} +
+

Utilisation avec curl :

+
{`curl ${window.location.origin}/api/notes \\
+  -H "X-API-Key: ${apiInfoModal.apiKey || 'VOTRE_CLE_API'}"`}
+
+ + + {/* Alternative JWT */} +
+

Alternative : Token JWT (expire après 24h)

+
{`curl -X POST ${window.location.origin}/api/auth/login \\
   -H "Content-Type: application/json" \\
-  -d '{"username": "${apiInfoModal.user?.username}", "password": "VOTRE_MOT_DE_PASSE"}'`}
+ -d '{"username": "${apiInfoModal.user?.username}", "password": "MOT_DE_PASSE"}'`}
-

- Réponse : {`{"token": "eyJhbG...", "user": {...}}`} -

{/* Endpoints */} @@ -2709,10 +2808,10 @@ const Index = () => {

3. Exemple complet (curl)

-
{`# Créer une note avec tâches
+                
{`# Créer une note avec tâches (clé API permanente)
 curl -X POST ${window.location.origin}/api/notes/full \\
   -H "Content-Type: application/json" \\
-  -H "Authorization: Bearer VOTRE_TOKEN" \\
+  -H "X-API-Key: ${apiInfoModal.apiKey || 'VOTRE_CLE_API'}" \\
   -d '{
     "title": "Liste de courses",
     "content": "Pour le weekend",
@@ -2727,13 +2826,14 @@ curl -X POST ${window.location.origin}/api/notes/full \\
             
{/* Info importante */} -
-

- ⚠️ Important +

+

+ ✓ Clé API permanente

-
    -
  • • Le token expire après 24 heures
  • -
  • • Utilisez le header Authorization: Bearer TOKEN
  • +
      +
    • • La clé API n'expire jamais (sauf si révoquée)
    • +
    • • Utilisez le header X-API-Key: VOTRE_CLE
    • +
    • • Gardez votre clé secrète - ne la partagez jamais
    • • Les données sont isolées par utilisateur
diff --git a/src/services/AdminService.ts b/src/services/AdminService.ts index 9e18e66..0d3e1a6 100644 --- a/src/services/AdminService.ts +++ b/src/services/AdminService.ts @@ -5,6 +5,7 @@ interface User { id: number; username: string; is_admin: boolean; + has_api_key?: boolean; created_at?: string; } @@ -134,6 +135,70 @@ class AdminService { }; } } + + /** + * Récupérer la clé API d'un utilisateur (génère si n'existe pas) + */ + async getApiKey(userId: number): Promise<{ api_key: string; generated: boolean } | null> { + try { + const response = await fetch(`/api/users/${userId}/api-key`, { + headers: AuthService.getHeaders() + }); + + if (!response.ok) { + throw new Error("Erreur lors de la récupération de la clé API"); + } + + return await response.json(); + } catch (error) { + showError(error instanceof Error ? error.message : "Erreur serveur"); + return null; + } + } + + /** + * Régénérer la clé API d'un utilisateur + */ + async regenerateApiKey(userId: number): Promise<{ api_key: string } | null> { + try { + const response = await fetch(`/api/users/${userId}/api-key/regenerate`, { + method: "POST", + headers: AuthService.getHeaders() + }); + + if (!response.ok) { + throw new Error("Erreur lors de la régénération de la clé API"); + } + + showSuccess("Clé API régénérée avec succès"); + return await response.json(); + } catch (error) { + showError(error instanceof Error ? error.message : "Erreur serveur"); + return null; + } + } + + /** + * Révoquer la clé API d'un utilisateur + */ + async revokeApiKey(userId: number): Promise { + try { + const response = await fetch(`/api/users/${userId}/api-key`, { + method: "DELETE", + headers: AuthService.getHeaders() + }); + + if (!response.ok) { + throw new Error("Erreur lors de la révocation de la clé API"); + } + + showSuccess("Clé API révoquée avec succès"); + return true; + } catch (error) { + showError(error instanceof Error ? error.message : "Erreur serveur"); + return false; + } + } } // Créer une instance unique