diff --git a/LEANTIME_TASKS_FLOW.md b/LEANTIME_TASKS_FLOW.md new file mode 100644 index 0000000..ce871d0 --- /dev/null +++ b/LEANTIME_TASKS_FLOW.md @@ -0,0 +1,192 @@ +# Flow complet des tâches Leantime dans le widget Devoirs + +## 1. BACKEND - API Route `/api/leantime/tasks` (app/api/leantime/tasks/route.ts) + +### Étape 1.1 : Vérification de session +- **Ligne 91-95** : Vérifie la session utilisateur +- Si pas de session → retourne 401 Unauthorized + +### Étape 1.2 : Vérification du cache Redis +- **Ligne 103-141** : Si `forceRefresh=false`, vérifie le cache Redis +- **Ligne 107-123** : **FILTRE 1** - Filtre les tâches "done" du cache : + - Statuts filtrés : `0`, `3`, `5` (ou strings `'0'`, `'3'`, `'5'`, `'Done'`, `'done'`, `'DONE'`) + - Si des tâches "done" sont trouvées dans le cache, elles sont supprimées + - Le cache est mis à jour avec les tâches filtrées +- Si cache valide → retourne les tâches filtrées du cache (ligne 139) + +### Étape 1.3 : Récupération de l'ID utilisateur Leantime +- **Ligne 146** : `getLeantimeUserId(session.user.email)` +- Appelle l'API Leantime `leantime.rpc.users.getAll` +- Trouve l'utilisateur par `username === email` +- Retourne `user.id` ou `null` + +### Étape 1.4 : Appel API Leantime +- **Ligne 165-178** : Appelle `leantime.rpc.tickets.getAll` avec : + - `userId: userId` + - `status: "all"` (récupère TOUTES les tâches, tous statuts confondus) +- **Ligne 193-195** : Log toutes les tâches brutes reçues de Leantime + +### Étape 1.5 : Filtrage des tâches +- **Ligne 217-249** : **FILTRE 2** - Filtre les tâches : + + **a) Filtre par statut "done" (ligne 223-240)** : + - Statuts filtrés : `0`, `3`, `5` (ou strings `'0'`, `'3'`, `'5'`, `'done'`) + - Si `isDone === true` → la tâche est exclue (`return false`) + + **b) Filtre par éditeur (ligne 242-248)** : + - Seules les tâches où `task.editorId === userId` sont gardées + - Si `taskEditorId !== currentUserId` → la tâche est exclue + +### Étape 1.6 : Transformation des données +- **Ligne 250-266** : Transforme les tâches Leantime en format standard : + ```typescript + { + id: task.id.toString(), + headline: task.headline, + projectName: task.projectName, + projectId: task.projectId, + status: task.status, // ⚠️ LE STATUT EST INCLUS ICI + dateToFinish: task.dateToFinish || null, + milestone: task.type || null, + details: task.description || null, + createdOn: task.dateCreated, + editedOn: task.editedOn || null, + editorId: task.editorId, + editorFirstname: task.editorFirstname, + editorLastname: task.editorLastname, + type: task.type || null, + dependingTicketId: task.dependingTicketId || null + } + ``` + +### Étape 1.7 : Mise en cache +- **Ligne 292** : Met en cache les tâches filtrées dans Redis +- **Ligne 294** : Retourne les tâches filtrées en JSON + +--- + +## 2. FRONTEND - Widget Devoirs (components/flow.tsx) + +### Étape 2.1 : Appel API +- **Ligne 107-113** : Appelle `/api/leantime/tasks` (ou `/api/leantime/tasks?refresh=true`) +- **Ligne 117-127** : Récupère les données JSON et les assigne à `leantimeTasks` +- **Ligne 121-126** : Log toutes les tâches reçues avec leur statut + +### Étape 2.2 : Détection des tâches "done" (pour log uniquement) +- **Ligne 129-143** : Détecte les tâches avec statut `5` ou `'done'` (⚠️ NE FILTRE PAS LE STATUT 0) +- Affiche un warning si des tâches "done" sont trouvées + +### Étape 2.3 : Analyse des statuts (pour log uniquement) +- **Ligne 164-178** : Crée `leantimeStatusDetails` avec calcul de `isDone` +- **Ligne 168** : `isDone = statusNum === 0 || statusNum === 3 || statusNum === 5 || ...` +- **Ligne 193** : Filtre les tâches "done" pour le log `doneTasksCount` +- **Ligne 201-206** : Log le breakdown des statuts + +### Étape 2.4 : Combinaison avec Twenty CRM +- **Ligne 161** : Combine `leantimeTasks` et `twentyCrmTasks` dans `allTasks` + +### Étape 2.5 : Filtrage frontend +- **Ligne 242-281** : **FILTRE 3** - Filtre les tâches : + + **a) Filtre par statut "done" (ligne 245-260)** : + - Statuts filtrés : `0`, `3`, `5` (ou strings `'0'`, `'3'`, `'5'`, `'done'`) + - Si le statut correspond → la tâche est exclue (`return false`) + + **b) Filtre par date (ligne 262-280)** : + - Exclut les tâches sans `dateToFinish` + - Garde uniquement les tâches avec `dateToFinish <= today` (overdue ou due today) + +### Étape 2.6 : Tri +- **Ligne 296-317** : Trie les tâches : + 1. Par `dateToFinish` (oldest first) + 2. Si dates égales, par statut (statut `4` en premier) + +### Étape 2.7 : Notification badge +- **Ligne 362-366** : Met à jour le badge de notification avec le nombre de tâches + +### Étape 2.8 : Affichage +- **Ligne 368** : `setTasks(sortedTasks)` - Met à jour l'état React + +### Étape 2.9 : Filtrage avant notification Outlook +- **Ligne 372-390** : **FILTRE 4** - Filtre les tâches avant d'envoyer l'événement `tasks-updated` : + - **Ligne 380** : ⚠️ **PROBLÈME ICI** - Ne filtre que `3` et `5`, PAS le statut `0` ! + - Si `isDone === true` → la tâche est exclue +- **Ligne 391-398** : Transforme les tâches pour l'événement (sans inclure le `status`) + +--- + +## 3. PROBLÈMES IDENTIFIÉS + +### Problème 1 : Filtre ligne 380 dans flow.tsx +- **Ligne 380** : `isDone = taskStatus === 3 || taskStatus === 5 || ...` +- **Manque** : `taskStatus === 0` +- **Impact** : Les tâches avec statut `0` passent le filtre et sont envoyées dans l'événement `tasks-updated` + +### Problème 2 : Détection ligne 129-135 dans flow.tsx +- **Ligne 134** : Ne détecte que le statut `5`, pas `0` ni `3` +- **Impact** : Le log `⚠️ Received done tasks` ne détecte pas toutes les tâches "done" + +### Problème 3 : Le statut n'est pas inclus dans l'événement tasks-updated +- **Ligne 391-398** : L'objet envoyé dans l'événement ne contient pas le champ `status` +- **Impact** : Les hooks qui écoutent `tasks-updated` ne peuvent pas filtrer par statut + +--- + +## 4. FLOW RÉSUMÉ + +``` +1. API Leantime (toutes les tâches, tous statuts) + ↓ +2. FILTRE 1 (cache) : Exclut statuts 0, 3, 5 + ↓ +3. FILTRE 2 (API backend) : Exclut statuts 0, 3, 5 + filtre par editorId + ↓ +4. Transformation + Cache Redis + ↓ +5. Frontend reçoit les tâches (avec statut inclus) + ↓ +6. FILTRE 3 (frontend) : Exclut statuts 0, 3, 5 + filtre par date + ↓ +7. Tri par date + ↓ +8. FILTRE 4 (avant notification) : ⚠️ Exclut seulement statuts 3, 5 (MANQUE 0) + ↓ +9. Événement tasks-updated (sans statut dans l'objet) + ↓ +10. Affichage dans le widget +``` + +--- + +## 5. CORRECTIONS NÉCESSAIRES + +1. **Ligne 380 dans flow.tsx** : Ajouter `taskStatus === 0` au filtre ✅ CORRIGÉ +2. **Ligne 134 dans flow.tsx** : Ajouter détection des statuts `0` et `3` ✅ CORRIGÉ +3. **Ligne 391-398 dans flow.tsx** : Inclure le champ `status` dans l'objet envoyé dans l'événement + +--- + +## 6. SIGNIFICATION DES STATUTS LEANTIME + +D'après le code : + +### Mapping standard (app/api/leantime/status-labels/route.ts) : +- **Status 1** = 'NEW' +- **Status 2** = 'INPROGRESS' +- **Status 3** = 'DONE' ⚠️ +- **Status 0** = Non défini (tombe dans `default: 'UNKNOWN'`) + +### Mapping dans le widget (components/flow.tsx) : +- **Status 1** = 'New' +- **Status 2** = 'Blocked' +- **Status 3** = 'In Progress' ⚠️ **INCOHÉRENCE avec status-labels** +- **Status 4** = 'Waiting for Approval' +- **Status 5** = 'Done' +- **Status 0** = 'Unknown' (par défaut) + +### Statuts filtrés comme "done" : +- **Status 0** = "Done" (dans votre instance Leantime, configuration personnalisée) +- **Status 3** = "Done" (selon `status-labels/route.ts`) +- **Status 5** = "Done" (selon `flow.tsx`) + +**Note** : Il y a une incohérence entre les deux mappings. Le statut 3 est mappé à "DONE" dans `status-labels` mais à "In Progress" dans `flow.tsx`. Le statut 0 n'est pas standard dans Leantime mais semble être utilisé comme "done" dans votre instance. diff --git a/app/api/leantime/tasks/route.ts b/app/api/leantime/tasks/route.ts index 783eea2..71b565e 100644 --- a/app/api/leantime/tasks/route.ts +++ b/app/api/leantime/tasks/route.ts @@ -85,6 +85,83 @@ async function getLeantimeUserId(email: string): Promise { } } +/** + * Get all status labels for a user and identify which status values correspond to "done" + * Returns a Set of status values (as strings) that are marked as "done" + */ +async function getDoneStatusValues(userId: number): Promise> { + const doneStatusValues = new Set(); + + try { + const headers: Record = { + 'Content-Type': 'application/json', + 'X-API-Key': process.env.LEANTIME_TOKEN! + }; + + const data = await fetchJsonWithTimeout(`${process.env.LEANTIME_API_URL}/api/jsonrpc`, { + method: 'POST', + timeout: 10000, + headers, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'leantime.rpc.Tickets.Tickets.getAllStatusLabelsByUserId', + params: { + userId: userId + }, + id: 1 + }), + }); + + if (!data.result || !Array.isArray(data.result)) { + logger.warn('[LEANTIME_TASKS] Invalid response format from getAllStatusLabelsByUserId, using fallback'); + // Fallback to default values if API fails + return new Set(['0', '3', '5']); + } + + // data.result is an array of projects, each with labels + // Each label has: id, name, statusType, class, etc. + data.result.forEach((project: any) => { + if (project.labels && Array.isArray(project.labels)) { + project.labels.forEach((label: any) => { + // Check if the label name (case-insensitive) contains "done" + const labelName = String(label.name || '').toLowerCase().trim(); + if (labelName === 'done' || labelName.includes('done')) { + // The status value is typically in label.id or label.name + // We need to extract the numeric status value + const statusValue = String(label.id || label.name || ''); + doneStatusValues.add(statusValue); + + // Also try to extract numeric value if it's in a format like "projectId-status" + const parts = statusValue.split('-'); + if (parts.length > 1) { + doneStatusValues.add(parts[parts.length - 1]); // Last part is usually the status + } + } + }); + } + }); + + logger.debug('[LEANTIME_TASKS] Identified done status values', { + doneStatusValues: Array.from(doneStatusValues), + projectsCount: data.result.length, + }); + + // If no done statuses found, use fallback + if (doneStatusValues.size === 0) { + logger.warn('[LEANTIME_TASKS] No done status labels found, using fallback values'); + return new Set(['0', '3', '5']); + } + + return doneStatusValues; + } catch (error) { + logger.error('[LEANTIME_TASKS] Error fetching status labels, using fallback', { + error: error instanceof Error ? error.message : String(error), + }); + // Fallback to default values if API fails + return new Set(['0', '3', '5']); + } +} + export async function GET(request: NextRequest) { console.log('[LEANTIME_TASKS] 🔵 API CALLED - Starting request'); try { @@ -99,22 +176,36 @@ export async function GET(request: NextRequest) { const url = new URL(request.url); const forceRefresh = url.searchParams.get('refresh') === 'true'; + // Get Leantime user ID first (needed for status labels) + const userId = await getLeantimeUserId(session.user.email); + + if (!userId) { + logger.error('[LEANTIME_TASKS] User not found in Leantime', { + emailHash: Buffer.from(session.user.email.toLowerCase()).toString('base64').slice(0, 12), + }); + return NextResponse.json({ error: "User not found in Leantime" }, { status: 404 }); + } + + // Get done status values dynamically from Leantime status labels + const doneStatusValues = await getDoneStatusValues(userId); + logger.debug('[LEANTIME_TASKS] Done status values identified', { + doneStatusValues: Array.from(doneStatusValues), + }); + // Try to get data from cache if not forcing refresh if (!forceRefresh) { const cachedTasks = await getCachedTasksData(session.user.id); if (cachedTasks && Array.isArray(cachedTasks)) { - // Filter out done tasks from cache as well (in case cache contains old data) + // Filter out done tasks from cache using dynamic status values const filteredCachedTasks = cachedTasks.filter((task: any) => { const taskStatus = task.status; if (taskStatus !== null && taskStatus !== undefined) { - const statusNum = typeof taskStatus === 'string' ? parseInt(taskStatus, 10) : taskStatus; - // In Leantime: status 0, 3, 5 = DONE - if (statusNum === 0 || statusNum === 3 || statusNum === 5 || taskStatus === '0' || taskStatus === '3' || taskStatus === '5' || taskStatus === 'Done' || taskStatus === 'done' || taskStatus === 'DONE') { + const statusStr = String(taskStatus); + if (doneStatusValues.has(statusStr)) { logger.debug('[LEANTIME_TASKS] Filtering out done task from cache', { id: task.id, headline: task.headline, status: taskStatus, - statusNum, }); return false; } @@ -140,21 +231,10 @@ export async function GET(request: NextRequest) { } } - logger.debug('[LEANTIME_TASKS] Fetching tasks for user', { - emailHash: Buffer.from(session.user.email.toLowerCase()).toString('base64').slice(0, 12), - }); - const userId = await getLeantimeUserId(session.user.email); - - if (!userId) { - logger.error('[LEANTIME_TASKS] User not found in Leantime', { - emailHash: Buffer.from(session.user.email.toLowerCase()).toString('base64').slice(0, 12), - }); - return NextResponse.json({ error: "User not found in Leantime" }, { status: 404 }); - } - logger.debug('[LEANTIME_TASKS] Fetching tasks for Leantime user', { emailHash: Buffer.from(session.user.email.toLowerCase()).toString('base64').slice(0, 12), }); + const headers: Record = { 'Content-Type': 'application/json', 'X-API-Key': process.env.LEANTIME_TOKEN! @@ -217,23 +297,16 @@ export async function GET(request: NextRequest) { const tasks = data.result .filter((task: any) => { // Filter out any task (main or subtask) that has status Done - // In Leantime: status 3 = DONE (see status-labels/route.ts) - // Also check for status 5 as fallback - // Handle both number and string formats, and check for null/undefined + // Use dynamic status values from Leantime status labels const taskStatus = task.status; if (taskStatus !== null && taskStatus !== undefined) { - const statusNum = typeof taskStatus === 'string' ? parseInt(taskStatus, 10) : taskStatus; - const statusStr = typeof taskStatus === 'string' ? taskStatus.trim().toLowerCase() : String(taskStatus).trim().toLowerCase(); - const isDone = statusNum === 0 || statusNum === 3 || statusNum === 5 || statusStr === '0' || statusStr === '3' || statusStr === '5' || statusStr === 'done'; - - if (isDone) { + const statusStr = String(taskStatus); + if (doneStatusValues.has(statusStr)) { logger.debug('[LEANTIME_TASKS] Filtering out done task', { id: task.id, headline: task.headline, status: taskStatus, statusType: typeof taskStatus, - statusNum, - statusStr, }); return false; } diff --git a/components/flow.tsx b/components/flow.tsx index f341ec3..5941454 100644 --- a/components/flow.tsx +++ b/components/flow.tsx @@ -125,13 +125,13 @@ export function Duties() { statusType: typeof t.status, }))); leantimeTasks = leantimeData; - // Log tasks with status 5 to debug + // Log tasks with status 0, 3, or 5 (done) to debug const doneTasks = leantimeData.filter((t: Task) => { const taskStatus = (t as any).status; // Use any to handle potential string/number mismatch if (taskStatus === null || taskStatus === undefined) return false; const statusNum = typeof taskStatus === 'string' ? parseInt(taskStatus, 10) : taskStatus; const statusStr = typeof taskStatus === 'string' ? taskStatus.toLowerCase() : String(taskStatus).toLowerCase(); - return statusNum === 5 || statusStr === '5' || statusStr === 'done'; + return statusNum === 0 || statusNum === 3 || statusNum === 5 || statusStr === '0' || statusStr === '3' || statusStr === '5' || statusStr === 'done'; }); if (doneTasks.length > 0) { console.warn('[Devoirs Widget] ⚠️ Received done tasks from Leantime API:', doneTasks.map((t: Task) => ({ @@ -377,7 +377,7 @@ export function Duties() { } const taskStatus = typeof rawStatus === 'string' ? parseInt(rawStatus, 10) : rawStatus; const statusStr = typeof rawStatus === 'string' ? rawStatus.toLowerCase().trim() : String(rawStatus).toLowerCase().trim(); - const isDone = taskStatus === 3 || taskStatus === 5 || statusStr === '3' || statusStr === '5' || statusStr === 'done'; + const isDone = taskStatus === 0 || taskStatus === 3 || taskStatus === 5 || statusStr === '0' || statusStr === '3' || statusStr === '5' || statusStr === 'done'; if (isDone) { console.warn('[Devoirs Widget] ⚠️ Filtering out done task before notification:', { id: task.id, diff --git a/hooks/use-rocketchat-calls.ts b/hooks/use-rocketchat-calls.ts index ba07a5f..f8c5152 100644 --- a/hooks/use-rocketchat-calls.ts +++ b/hooks/use-rocketchat-calls.ts @@ -159,28 +159,28 @@ export function useRocketChatCalls() { // Handle incoming call events if (callEvent.type === 'call-incoming') { - console.log('[useRocketChatCalls] 🎉 INCOMING CALL DETECTED!', { - from: callEvent.from.username, - roomId: callEvent.roomId, - roomName: callEvent.roomName, - }); + console.log('[useRocketChatCalls] 🎉 INCOMING CALL DETECTED!', { + from: callEvent.from.username, + roomId: callEvent.roomId, + roomName: callEvent.roomName, + }); - logger.info('[useRocketChatCalls] Incoming call detected', { - from: callEvent.from.username, - roomId: callEvent.roomId, - }); + logger.info('[useRocketChatCalls] Incoming call detected', { + from: callEvent.from.username, + roomId: callEvent.roomId, + }); - // Show incoming call notification UI (Outlook-style rectangle) - setIncomingCall({ - from: { - userId: callEvent.from.userId, - username: callEvent.from.username, - name: callEvent.from.name || callEvent.from.username, - }, - roomId: callEvent.roomId, - roomName: callEvent.roomName || callEvent.roomId, - timestamp: callEvent.timestamp, - }); + // Show incoming call notification UI (Outlook-style rectangle) + setIncomingCall({ + from: { + userId: callEvent.from.userId, + username: callEvent.from.username, + name: callEvent.from.name || callEvent.from.username, + }, + roomId: callEvent.roomId, + roomName: callEvent.roomName || callEvent.roomId, + timestamp: callEvent.timestamp, + }); // Track the current call's roomId so we can match it when the call ends currentCallRoomIdRef.current = callEvent.roomId; @@ -205,36 +205,36 @@ export function useRocketChatCalls() { callTimeoutRef.current = null; }, 20000); // 20 seconds safety timeout - typical max ringing duration - console.log('[useRocketChatCalls] 📞 Incoming call notification UI set', { - from: callEvent.from.username, - roomId: callEvent.roomId, - }); + console.log('[useRocketChatCalls] 📞 Incoming call notification UI set', { + from: callEvent.from.username, + roomId: callEvent.roomId, + }); - // Trigger notification badge - // For calls, we want to increment the existing count, not replace it - // So we fetch current count first, then increment - triggerNotification({ - source: 'rocketchat', - count: 1, // This will be added to existing count in the registry - items: [ - { - id: `call-${callEvent.roomId}-${Date.now()}`, - title: `📞 Appel entrant de ${callEvent.from.name || callEvent.from.username}`, - message: `Appel vidéo/audio dans ${callEvent.roomName || 'chat'}`, - link: `/parole?room=${callEvent.roomId}`, - timestamp: callEvent.timestamp, - metadata: { - type: 'call', - from: callEvent.from, - roomId: callEvent.roomId, - }, + // Trigger notification badge + // For calls, we want to increment the existing count, not replace it + // So we fetch current count first, then increment + triggerNotification({ + source: 'rocketchat', + count: 1, // This will be added to existing count in the registry + items: [ + { + id: `call-${callEvent.roomId}-${Date.now()}`, + title: `📞 Appel entrant de ${callEvent.from.name || callEvent.from.username}`, + message: `Appel vidéo/audio dans ${callEvent.roomName || 'chat'}`, + link: `/parole?room=${callEvent.roomId}`, + timestamp: callEvent.timestamp, + metadata: { + type: 'call', + from: callEvent.from, + roomId: callEvent.roomId, }, - ], - }).then(() => { - console.log('[useRocketChatCalls] ✅ Notification triggered successfully'); - }).catch((error) => { - console.error('[useRocketChatCalls] ❌ Error triggering notification', error); - }); + }, + ], + }).then(() => { + console.log('[useRocketChatCalls] ✅ Notification triggered successfully'); + }).catch((error) => { + console.error('[useRocketChatCalls] ❌ Error triggering notification', error); + }); } }); diff --git a/lib/services/n8n-service.ts b/lib/services/n8n-service.ts index bff3bbf..62eacf0 100644 --- a/lib/services/n8n-service.ts +++ b/lib/services/n8n-service.ts @@ -47,15 +47,15 @@ export class N8nService { let response: Response; try { response = await fetchWithTimeout(deleteWebhookUrl, { - method: 'POST', - timeout: 30000, // 30 seconds - headers: { - 'Content-Type': 'application/json', - 'x-api-key': this.apiKey - }, - body: JSON.stringify(data), - }); - + method: 'POST', + timeout: 30000, // 30 seconds + headers: { + 'Content-Type': 'application/json', + 'x-api-key': this.apiKey + }, + body: JSON.stringify(data), + }); + logger.debug('Deletion webhook response received', { status: response.status, statusText: response.statusText, @@ -197,15 +197,15 @@ export class N8nService { let response: Response; try { response = await fetchWithTimeout(this.webhookUrl, { - method: 'POST', - timeout: 30000, // 30 seconds - headers: { - 'Content-Type': 'application/json', - 'x-api-key': this.apiKey - }, - body: JSON.stringify(cleanData), - }); - + method: 'POST', + timeout: 30000, // 30 seconds + headers: { + 'Content-Type': 'application/json', + 'x-api-key': this.apiKey + }, + body: JSON.stringify(cleanData), + }); + logger.debug('Webhook response received', { status: response.status, statusText: response.statusText, diff --git a/lib/services/rocketchat-call-listener.ts b/lib/services/rocketchat-call-listener.ts index 0ae4c9f..bf28e66 100644 --- a/lib/services/rocketchat-call-listener.ts +++ b/lib/services/rocketchat-call-listener.ts @@ -529,10 +529,10 @@ export class RocketChatCallListener { isIncomingCall, isCallEnded }); - if (args.length > 0) { - this.handleCallEvent(args[0]); - } else { - this.handleCallEvent(message.fields); + if (args.length > 0) { + this.handleCallEvent(args[0]); + } else { + this.handleCallEvent(message.fields); } } } @@ -635,13 +635,13 @@ export class RocketChatCallListener { }); } } else { - logger.debug('[ROCKETCHAT_CALL_LISTENER] 📢 Stream notify user message', { - msg: message.msg, - collection: message.collection, + logger.debug('[ROCKETCHAT_CALL_LISTENER] 📢 Stream notify user message', { + msg: message.msg, + collection: message.collection, eventName, hasArgs: args.length > 0, argsCount: args.length, - }); + }); } }