diff --git a/TWENTY_CRM_INTEGRATION.md b/TWENTY_CRM_INTEGRATION.md new file mode 100644 index 0000000..8885df4 --- /dev/null +++ b/TWENTY_CRM_INTEGRATION.md @@ -0,0 +1,252 @@ +# IntĂ©gration Twenty CRM dans le Widget Devoirs + +## 📋 Vue d'ensemble + +Le widget "Devoirs" affiche maintenant les tĂąches en retard provenant de **deux sources** : +1. **Leantime** (agilite.slm-lab.net) +2. **Twenty CRM** (mediation.slm-lab.net) + +Les tĂąches sont combinĂ©es, filtrĂ©es (uniquement celles en retard), triĂ©es par date d'Ă©chĂ©ance, et limitĂ©es Ă  7 tĂąches. + +--- + +## 🔧 Configuration Requise + +### Variables d'Environnement + +Ajoutez les variables suivantes Ă  votre fichier `.env.local` (dĂ©veloppement) ou Ă  vos variables d'environnement de production : + +```env +# Twenty CRM API Configuration +TWENTY_CRM_API_URL=https://mediation.slm-lab.net/graphql +TWENTY_CRM_API_KEY=your_api_key_here +TWENTY_CRM_URL=https://mediation.slm-lab.net +``` + +**OĂč obtenir la clĂ© API :** +1. Connectez-vous Ă  Twenty CRM (mediation.slm-lab.net) +2. Allez dans **Settings → APIs & Webhooks** +3. Cliquez sur **"+ Create key"** +4. Donnez un nom Ă  la clĂ© (ex: "NeahStable Widget") +5. Copiez la clĂ© (elle ne sera affichĂ©e qu'une seule fois) + +--- + +## 📁 Fichiers Créés/ModifiĂ©s + +### Nouveau Fichier +- **`app/api/twenty-crm/tasks/route.ts`** - Endpoint API pour rĂ©cupĂ©rer les tĂąches Twenty CRM + +### Fichiers ModifiĂ©s +- **`components/flow.tsx`** - Widget Devoirs modifiĂ© pour combiner les deux sources + +--- + +## 🔄 Flow de Fonctionnement + +### 1. RĂ©cupĂ©ration des TĂąches + +Le widget fait **deux appels API en parallĂšle** : + +```typescript +const [leantimeResponse, twentyCrmResponse] = await Promise.allSettled([ + fetch('/api/leantime/tasks'), + fetch('/api/twenty-crm/tasks'), +]); +``` + +**Avantages :** +- ✅ Appels parallĂšles = plus rapide +- ✅ `Promise.allSettled` = si une source Ă©choue, l'autre continue de fonctionner +- ✅ Pas de dĂ©pendance entre les deux sources + +### 2. Transformation des TĂąches Twenty CRM + +Les tĂąches Twenty CRM sont transformĂ©es pour correspondre au format Leantime : + +```typescript +{ + id: `twenty-${task.id}`, // PrĂ©fixe pour Ă©viter les conflits + headline: task.title, + dateToFinish: task.dueAt, + projectName: 'Twenty CRM', + source: 'twenty-crm', // Identifiant de source + url: `${TWENTY_CRM_URL}/object/activity/${task.id}`, // Lien direct + // ... autres champs +} +``` + +### 3. Filtrage et Tri + +1. **Filtrage :** Uniquement les tĂąches avec date d'Ă©chĂ©ance **avant aujourd'hui** (en retard) +2. **Tri :** Par date d'Ă©chĂ©ance (plus anciennes en premier) +3. **Limite :** Maximum 7 tĂąches affichĂ©es + +### 4. Affichage + +- Les tĂąches Twenty CRM sont identifiĂ©es par un badge "(Twenty CRM)" +- Le lien pointe vers la page de la tĂąche dans Twenty CRM +- Le format d'affichage est identique pour les deux sources + +--- + +## 🔍 Structure de l'API Twenty CRM + +### Endpoint GraphQL + +**URL :** `https://mediation.slm-lab.net/graphql` + +**MĂ©thode :** POST + +**Headers :** +``` +Authorization: Bearer YOUR_API_KEY +Content-Type: application/json +``` + +### RequĂȘte GraphQL + +```graphql +query GetOverdueTasks { + findManyActivities( + filter: { + type: { eq: Task } + completedAt: { is: NULL } + dueAt: { lt: "2026-01-15T00:00:00Z" } + } + orderBy: { dueAt: AscNullsLast } + ) { + edges { + node { + id + title + body + dueAt + completedAt + type + assigneeId + assignee { + id + firstName + lastName + email + } + } + } + } +} +``` + +**Filtres appliquĂ©s :** +- `type: { eq: Task }` - Uniquement les tĂąches (pas les autres activitĂ©s) +- `completedAt: { is: NULL }` - Uniquement les tĂąches non complĂ©tĂ©es +- `dueAt: { lt: "..." }` - Uniquement les tĂąches avec date d'Ă©chĂ©ance avant aujourd'hui + +--- + +## 🐛 DĂ©pannage + +### Erreur : "TWENTY_CRM_API_URL is not set" + +**Solution :** Ajoutez `TWENTY_CRM_API_URL` Ă  vos variables d'environnement. + +### Erreur : "TWENTY_CRM_API_KEY is not set" + +**Solution :** Ajoutez `TWENTY_CRM_API_KEY` Ă  vos variables d'environnement. + +### Erreur : "401 Unauthorized" + +**Causes possibles :** +- ClĂ© API invalide ou expirĂ©e +- ClĂ© API mal copiĂ©e (espaces, caractĂšres invisibles) +- Permissions insuffisantes sur la clĂ© API + +**Solution :** +1. VĂ©rifiez que la clĂ© API est correctement copiĂ©e +2. RĂ©gĂ©nĂ©rez la clĂ© API dans Twenty CRM +3. VĂ©rifiez les permissions de la clĂ© API + +### Erreur : "GraphQL errors from Twenty CRM" + +**Causes possibles :** +- Structure de la requĂȘte GraphQL incorrecte +- Version de Twenty CRM incompatible +- SchĂ©ma GraphQL diffĂ©rent selon le workspace + +**Solution :** +1. VĂ©rifiez la documentation de votre version de Twenty CRM +2. Testez la requĂȘte GraphQL directement dans l'interface GraphQL de Twenty CRM +3. Ajustez la requĂȘte selon votre schĂ©ma + +### Aucune tĂąche Twenty CRM n'apparaĂźt + +**VĂ©rifications :** +1. ✅ VĂ©rifiez qu'il existe des tĂąches en retard dans Twenty CRM +2. ✅ VĂ©rifiez que les tĂąches ont une date d'Ă©chĂ©ance (`dueAt`) +3. ✅ VĂ©rifiez que les tĂąches ne sont pas complĂ©tĂ©es (`completedAt` est NULL) +4. ✅ VĂ©rifiez les logs du serveur pour voir les erreurs Ă©ventuelles + +--- + +## 📊 Logs et Debug + +### Logs Backend + +Tous les logs sont prĂ©fixĂ©s avec `[TWENTY_CRM_TASKS]` : + +```typescript +logger.debug('[TWENTY_CRM_TASKS] Fetching tasks from Twenty CRM', {...}); +logger.error('[TWENTY_CRM_TASKS] Failed to fetch tasks', {...}); +``` + +### Logs Frontend + +Le widget affiche dans la console : +- Nombre de tĂąches Leantime rĂ©cupĂ©rĂ©es +- Nombre de tĂąches Twenty CRM rĂ©cupĂ©rĂ©es +- Total combinĂ© +- TĂąches triĂ©es avec leur source + +--- + +## 🔄 Alternatives si GraphQL ne fonctionne pas + +Si la requĂȘte GraphQL ne fonctionne pas avec votre version de Twenty CRM, vous pouvez utiliser l'API REST : + +### Option 1 : API REST (si disponible) + +```typescript +const response = await fetch(`${process.env.TWENTY_CRM_API_URL}/api/activities`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${process.env.TWENTY_CRM_API_KEY}`, + }, +}); +``` + +### Option 2 : Ajuster la requĂȘte GraphQL + +La structure exacte peut varier. Consultez la documentation de votre instance Twenty CRM ou utilisez l'explorateur GraphQL intĂ©grĂ©. + +--- + +## ✅ Checklist de DĂ©ploiement + +- [ ] Variables d'environnement configurĂ©es : + - [ ] `TWENTY_CRM_API_URL` + - [ ] `TWENTY_CRM_API_KEY` + - [ ] `TWENTY_CRM_URL` (optionnel, pour les liens) +- [ ] ClĂ© API créée dans Twenty CRM +- [ ] Permissions de la clĂ© API vĂ©rifiĂ©es +- [ ] Test de l'endpoint `/api/twenty-crm/tasks` effectuĂ© +- [ ] VĂ©rification que les tĂąches apparaissent dans le widget +- [ ] Logs vĂ©rifiĂ©s pour dĂ©tecter d'Ă©ventuelles erreurs + +--- + +## 📝 Notes + +- Les tĂąches Twenty CRM sont prĂ©fixĂ©es avec `twenty-` dans leur ID pour Ă©viter les conflits +- Le widget continue de fonctionner mĂȘme si une seule source Ă©choue (grace Ă  `Promise.allSettled`) +- Le cache Redis n'est pas encore implĂ©mentĂ© pour Twenty CRM (peut ĂȘtre ajoutĂ© plus tard) +- La requĂȘte GraphQL peut nĂ©cessiter des ajustements selon votre version de Twenty CRM diff --git a/app/api/twenty-crm/tasks/route.ts b/app/api/twenty-crm/tasks/route.ts new file mode 100644 index 0000000..1a8fc44 --- /dev/null +++ b/app/api/twenty-crm/tasks/route.ts @@ -0,0 +1,216 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/options"; +import { logger } from "@/lib/logger"; + +interface TwentyTask { + id: string; + title: string; + body?: string; + dueAt?: string; + completedAt?: string; + type?: string; + assigneeId?: string; + assignee?: { + id: string; + firstName?: string; + lastName?: string; + email?: string; + }; +} + +/** + * Fetch tasks from Twenty CRM using GraphQL API + */ +async function fetchTwentyTasks(): Promise { + try { + if (!process.env.TWENTY_CRM_API_URL) { + logger.error('[TWENTY_CRM_TASKS] TWENTY_CRM_API_URL is not set in environment variables'); + return []; + } + + if (!process.env.TWENTY_CRM_API_KEY) { + logger.error('[TWENTY_CRM_TASKS] TWENTY_CRM_API_KEY is not set in environment variables'); + return []; + } + + const apiUrl = process.env.TWENTY_CRM_API_URL.endsWith('/graphql') + ? process.env.TWENTY_CRM_API_URL + : `${process.env.TWENTY_CRM_API_URL}/graphql`; + + // Calculate today's date at midnight for filtering overdue tasks + const today = new Date(); + today.setHours(0, 0, 0, 0); + const todayISO = today.toISOString(); + + // GraphQL query to fetch activities (tasks) that are not completed and due before today + // Using findManyActivities which is the standard query for Twenty CRM + // Note: The exact filter syntax may vary based on your Twenty CRM version + // If this doesn't work, try using REST API or adjust the filter syntax + const query = ` + query GetOverdueTasks { + findManyActivities( + filter: { + type: { eq: Task } + completedAt: { is: NULL } + dueAt: { lt: "${todayISO}" } + } + orderBy: { dueAt: AscNullsLast } + ) { + edges { + node { + id + title + body + dueAt + completedAt + type + assigneeId + assignee { + id + firstName + lastName + email + } + } + } + } + } + `; + + logger.debug('[TWENTY_CRM_TASKS] Fetching tasks from Twenty CRM', { + apiUrl: apiUrl.replace(/\/graphql$/, ''), // Log without /graphql for security + }); + + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${process.env.TWENTY_CRM_API_KEY}`, + }, + body: JSON.stringify({ query }), + }); + + const responseText = await response.text(); + + if (!response.ok) { + logger.error('[TWENTY_CRM_TASKS] Failed to fetch tasks from Twenty CRM', { + status: response.status, + statusText: response.statusText, + response: responseText.substring(0, 500), // Log first 500 chars + }); + return []; + } + + let data; + try { + data = JSON.parse(responseText); + } catch (e) { + logger.error('[TWENTY_CRM_TASKS] Failed to parse Twenty CRM response', { + error: e instanceof Error ? e.message : String(e), + response: responseText.substring(0, 500), + }); + return []; + } + + // Check for GraphQL errors + if (data.errors) { + logger.error('[TWENTY_CRM_TASKS] GraphQL errors from Twenty CRM', { + errors: data.errors, + }); + return []; + } + + if (!data.data?.findManyActivities?.edges) { + logger.warn('[TWENTY_CRM_TASKS] Unexpected response format from Twenty CRM', { + dataKeys: Object.keys(data.data || {}), + }); + return []; + } + + // Transform Twenty CRM tasks to match our Task interface + const tasks: TwentyTask[] = data.data.findManyActivities.edges.map((edge: any) => ({ + id: edge.node.id, + title: edge.node.title || 'Untitled Task', + body: edge.node.body || null, + dueAt: edge.node.dueAt || null, + completedAt: edge.node.completedAt || null, + type: edge.node.type || 'Task', + assigneeId: edge.node.assigneeId || null, + assignee: edge.node.assignee ? { + id: edge.node.assignee.id, + firstName: edge.node.assignee.firstName || null, + lastName: edge.node.assignee.lastName || null, + email: edge.node.assignee.email || null, + } : null, + })); + + logger.debug('[TWENTY_CRM_TASKS] Successfully fetched tasks from Twenty CRM', { + count: tasks.length, + }); + + return tasks; + } catch (error) { + logger.error('[TWENTY_CRM_TASKS] Error fetching tasks from Twenty CRM', { + error: error instanceof Error ? error.message : String(error), + }); + return []; + } +} + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Check for force refresh parameter + const url = new URL(request.url); + const forceRefresh = url.searchParams.get('refresh') === 'true'; + + logger.debug('[TWENTY_CRM_TASKS] Fetching tasks for user', { + emailHash: Buffer.from(session.user.email.toLowerCase()).toString('base64').slice(0, 12), + forceRefresh, + }); + + const tasks = await fetchTwentyTasks(); + + // Transform to match Leantime task format for consistency + const transformedTasks = tasks.map((task) => ({ + id: `twenty-${task.id}`, // Prefix to avoid conflicts with Leantime IDs + headline: task.title, + description: task.body || null, + dateToFinish: task.dueAt || null, + projectName: 'Twenty CRM', + projectId: 0, + status: task.completedAt ? 5 : 1, // 5 = Done, 1 = New + editorId: task.assigneeId || null, + editorFirstname: task.assignee?.firstName || null, + editorLastname: task.assignee?.lastName || null, + authorFirstname: null, + authorLastname: null, + milestoneHeadline: null, + editTo: null, + editFrom: null, + type: 'twenty-crm', + dependingTicketId: null, + source: 'twenty-crm', // Add source identifier + url: process.env.TWENTY_CRM_URL ? `${process.env.TWENTY_CRM_URL}/object/activity/${task.id}` : null, + })); + + logger.debug('[TWENTY_CRM_TASKS] Transformed tasks', { + count: transformedTasks.length, + }); + + return NextResponse.json(transformedTasks); + } catch (error) { + logger.error('[TWENTY_CRM_TASKS] Error in tasks route', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: "Failed to fetch tasks" }, + { status: 500 } + ); + } +} diff --git a/components/flow.tsx b/components/flow.tsx index 3a71013..0ca7303 100644 --- a/components/flow.tsx +++ b/components/flow.tsx @@ -100,28 +100,57 @@ export function Duties() { setRefreshing(true); setError(null); try { - // Use cache by default, only bypass with ?refresh=true for manual refresh - const url = forceRefresh ? '/api/leantime/tasks?refresh=true' : '/api/leantime/tasks'; - const response = await fetch(url); - if (!response.ok) { - throw new Error('Failed to fetch tasks'); + // Fetch tasks from both Leantime and Twenty CRM in parallel + const leantimeUrl = forceRefresh ? '/api/leantime/tasks?refresh=true' : '/api/leantime/tasks'; + const twentyCrmUrl = forceRefresh ? '/api/twenty-crm/tasks?refresh=true' : '/api/twenty-crm/tasks'; + + const [leantimeResponse, twentyCrmResponse] = await Promise.allSettled([ + fetch(leantimeUrl), + fetch(twentyCrmUrl), + ]); + + // Process Leantime tasks + let leantimeTasks: Task[] = []; + if (leantimeResponse.status === 'fulfilled' && leantimeResponse.value.ok) { + const leantimeData = await leantimeResponse.value.json(); + if (Array.isArray(leantimeData)) { + leantimeTasks = leantimeData; + } + } else { + console.warn('Failed to fetch Leantime tasks:', leantimeResponse); } - const data = await response.json(); + + // Process Twenty CRM tasks + let twentyCrmTasks: Task[] = []; + if (twentyCrmResponse.status === 'fulfilled' && twentyCrmResponse.value.ok) { + const twentyCrmData = await twentyCrmResponse.value.json(); + if (Array.isArray(twentyCrmData)) { + twentyCrmTasks = twentyCrmData; + } + } else { + console.warn('Failed to fetch Twenty CRM tasks:', twentyCrmResponse); + } + + // Combine tasks from both sources + const allTasks = [...leantimeTasks, ...twentyCrmTasks]; - console.log('Raw API response:', data); - - if (!Array.isArray(data)) { - console.warn('No tasks found in response', data as unknown); + console.log('Combined tasks:', { + leantime: leantimeTasks.length, + twentyCrm: twentyCrmTasks.length, + total: allTasks.length, + }); + + if (allTasks.length === 0) { setTasks([]); return; } - // Backend already filters out status=5 (Done) and filters by editorId + // Backend already filters out status=5 (Done) and filters by editorId for Leantime // Filter to keep only tasks with due date before today (past due) const today = new Date(); today.setHours(0, 0, 0, 0); // Set to start of today for accurate comparison - const filteredTasks = data.filter((task: Task) => { + const filteredTasks = allTasks.filter((task: Task) => { const dueDate = getValidDate(task); if (!dueDate) { return false; // Exclude tasks without a due date @@ -162,7 +191,8 @@ export function Duties() { id: t.id, date: t.dateToFinish, status: t.status, - type: t.type || 'main' + type: t.type || 'main', + source: (t as any).source || 'leantime' }))); setTasks(sortedTasks.slice(0, 7)); } catch (error) { @@ -288,7 +318,7 @@ export function Duties() {
{task.projectName} + {(task as any).source === 'twenty-crm' && ( + (Twenty CRM) + )}