Widget Devoir Finition

This commit is contained in:
alma 2026-01-24 14:55:13 +01:00
parent 929f1e0c03
commit 690de0ab5b
6 changed files with 369 additions and 104 deletions

192
LEANTIME_TASKS_FLOW.md Normal file
View File

@ -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.

View File

@ -85,6 +85,83 @@ async function getLeantimeUserId(email: string): Promise<number | null> {
} }
} }
/**
* 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<Set<string>> {
const doneStatusValues = new Set<string>();
try {
const headers: Record<string, string> = {
'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) { export async function GET(request: NextRequest) {
console.log('[LEANTIME_TASKS] 🔵 API CALLED - Starting request'); console.log('[LEANTIME_TASKS] 🔵 API CALLED - Starting request');
try { try {
@ -99,22 +176,36 @@ export async function GET(request: NextRequest) {
const url = new URL(request.url); const url = new URL(request.url);
const forceRefresh = url.searchParams.get('refresh') === 'true'; 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 // Try to get data from cache if not forcing refresh
if (!forceRefresh) { if (!forceRefresh) {
const cachedTasks = await getCachedTasksData(session.user.id); const cachedTasks = await getCachedTasksData(session.user.id);
if (cachedTasks && Array.isArray(cachedTasks)) { 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 filteredCachedTasks = cachedTasks.filter((task: any) => {
const taskStatus = task.status; const taskStatus = task.status;
if (taskStatus !== null && taskStatus !== undefined) { if (taskStatus !== null && taskStatus !== undefined) {
const statusNum = typeof taskStatus === 'string' ? parseInt(taskStatus, 10) : taskStatus; const statusStr = String(taskStatus);
// In Leantime: status 0, 3, 5 = DONE if (doneStatusValues.has(statusStr)) {
if (statusNum === 0 || statusNum === 3 || statusNum === 5 || taskStatus === '0' || taskStatus === '3' || taskStatus === '5' || taskStatus === 'Done' || taskStatus === 'done' || taskStatus === 'DONE') {
logger.debug('[LEANTIME_TASKS] Filtering out done task from cache', { logger.debug('[LEANTIME_TASKS] Filtering out done task from cache', {
id: task.id, id: task.id,
headline: task.headline, headline: task.headline,
status: taskStatus, status: taskStatus,
statusNum,
}); });
return false; 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', { logger.debug('[LEANTIME_TASKS] Fetching tasks for Leantime user', {
emailHash: Buffer.from(session.user.email.toLowerCase()).toString('base64').slice(0, 12), emailHash: Buffer.from(session.user.email.toLowerCase()).toString('base64').slice(0, 12),
}); });
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-API-Key': process.env.LEANTIME_TOKEN! 'X-API-Key': process.env.LEANTIME_TOKEN!
@ -217,23 +297,16 @@ export async function GET(request: NextRequest) {
const tasks = data.result const tasks = data.result
.filter((task: any) => { .filter((task: any) => {
// Filter out any task (main or subtask) that has status Done // Filter out any task (main or subtask) that has status Done
// In Leantime: status 3 = DONE (see status-labels/route.ts) // Use dynamic status values from Leantime status labels
// Also check for status 5 as fallback
// Handle both number and string formats, and check for null/undefined
const taskStatus = task.status; const taskStatus = task.status;
if (taskStatus !== null && taskStatus !== undefined) { if (taskStatus !== null && taskStatus !== undefined) {
const statusNum = typeof taskStatus === 'string' ? parseInt(taskStatus, 10) : taskStatus; const statusStr = String(taskStatus);
const statusStr = typeof taskStatus === 'string' ? taskStatus.trim().toLowerCase() : String(taskStatus).trim().toLowerCase(); if (doneStatusValues.has(statusStr)) {
const isDone = statusNum === 0 || statusNum === 3 || statusNum === 5 || statusStr === '0' || statusStr === '3' || statusStr === '5' || statusStr === 'done';
if (isDone) {
logger.debug('[LEANTIME_TASKS] Filtering out done task', { logger.debug('[LEANTIME_TASKS] Filtering out done task', {
id: task.id, id: task.id,
headline: task.headline, headline: task.headline,
status: taskStatus, status: taskStatus,
statusType: typeof taskStatus, statusType: typeof taskStatus,
statusNum,
statusStr,
}); });
return false; return false;
} }

View File

@ -125,13 +125,13 @@ export function Duties() {
statusType: typeof t.status, statusType: typeof t.status,
}))); })));
leantimeTasks = leantimeData; 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 doneTasks = leantimeData.filter((t: Task) => {
const taskStatus = (t as any).status; // Use any to handle potential string/number mismatch const taskStatus = (t as any).status; // Use any to handle potential string/number mismatch
if (taskStatus === null || taskStatus === undefined) return false; if (taskStatus === null || taskStatus === undefined) return false;
const statusNum = typeof taskStatus === 'string' ? parseInt(taskStatus, 10) : taskStatus; const statusNum = typeof taskStatus === 'string' ? parseInt(taskStatus, 10) : taskStatus;
const statusStr = typeof taskStatus === 'string' ? taskStatus.toLowerCase() : String(taskStatus).toLowerCase(); 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) { if (doneTasks.length > 0) {
console.warn('[Devoirs Widget] ⚠️ Received done tasks from Leantime API:', doneTasks.map((t: Task) => ({ 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 taskStatus = typeof rawStatus === 'string' ? parseInt(rawStatus, 10) : rawStatus;
const statusStr = typeof rawStatus === 'string' ? rawStatus.toLowerCase().trim() : String(rawStatus).toLowerCase().trim(); 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) { if (isDone) {
console.warn('[Devoirs Widget] ⚠️ Filtering out done task before notification:', { console.warn('[Devoirs Widget] ⚠️ Filtering out done task before notification:', {
id: task.id, id: task.id,