vision refactor

This commit is contained in:
alma 2026-01-15 22:27:51 +01:00
parent 0f12b2a5ab
commit 4cb4cccf5d
3 changed files with 515 additions and 14 deletions

252
TWENTY_CRM_INTEGRATION.md Normal file
View File

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

View File

@ -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<TwentyTask[]> {
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 }
);
}
}

View File

@ -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() {
</div>
<div className="flex-1 min-w-0 space-y-1">
<a
href={`https://agilite.slm-lab.net/tickets/showTicket/${task.id}`}
href={(task as any).url || `https://agilite.slm-lab.net/tickets/showTicket/${task.id.replace('twenty-', '')}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-700 font-medium block text-sm line-clamp-2"
@ -298,6 +328,9 @@ export function Duties() {
<div className="flex items-center text-gray-500 text-[10px] bg-gray-50 px-1.5 py-0.5 rounded-md">
<Folder className="h-2.5 w-2.5 mr-1 opacity-70" />
<span className="truncate">{task.projectName}</span>
{(task as any).source === 'twenty-crm' && (
<span className="ml-1 text-[9px] text-purple-600 font-medium">(Twenty CRM)</span>
)}
</div>
</div>
</div>