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; bodyV2?: { blocknote?: string; markdown?: string; }; dueAt?: string; status?: string; // e.g., "Done", "Todo", etc. type?: string; assigneeId?: string; assignee?: { id: string; name?: { firstName?: string; lastName?: string; }; }; } /** * Check if user has the mediation role * Uses the same normalization logic as the sidebar component */ function hasMediationRole(userRole: string | string[] | undefined): boolean { if (!userRole) { return false; } // Get user roles and normalize them properly const userRoles = Array.isArray(userRole) ? userRole : [userRole]; // Filter out technical/system roles that shouldn't count for permissions const ignoredRoles = ['offline_access', 'uma_authorization', 'default-roles-cercle']; const cleanUserRoles = userRoles .filter(Boolean) // Remove any null/undefined values .filter(role => !ignoredRoles.includes(String(role))) // Filter out system roles .map(role => { if (typeof role !== 'string') return ''; return role .replace(/^\//, '') // Remove leading slash .replace(/^ROLE_/i, '') // Remove ROLE_ prefix, case insensitive .replace(/^default-roles-[^/]*\//i, '') // Remove realm prefix like default-roles-cercle/ .toLowerCase(); }) .filter(role => role !== ''); // Remove empty strings // Check if user has mediation role return cleanUserRoles.includes('mediation'); } /** * Get Twenty CRM workspace member ID by email */ async function getTwentyCrmUserId(email: string): Promise { try { if (!process.env.TWENTY_CRM_API_URL || !process.env.TWENTY_CRM_API_KEY) { return null; } const apiUrl = process.env.TWENTY_CRM_API_URL.endsWith('/graphql') ? process.env.TWENTY_CRM_API_URL : `${process.env.TWENTY_CRM_API_URL}/graphql`; // Query to find workspace member by email const query = ` query GetWorkspaceMemberByEmail { workspaceMembers { edges { node { id userEmail } } } } `; const response = await fetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.TWENTY_CRM_API_KEY}`, }, body: JSON.stringify({ query }), }); if (!response.ok) { logger.error('[TWENTY_CRM_TASKS] Failed to fetch workspace members', { status: response.status, }); return null; } const data = await response.json(); if (data.errors) { logger.error('[TWENTY_CRM_TASKS] GraphQL errors fetching workspace members', { errors: data.errors, }); return null; } // Find workspace member with matching email const members = data.data?.workspaceMembers?.edges || []; const member = members.find((edge: any) => edge.node?.userEmail?.toLowerCase() === email.toLowerCase() ); if (member) { logger.debug('[TWENTY_CRM_TASKS] Found workspace member', { emailHash: Buffer.from(email.toLowerCase()).toString('base64').slice(0, 12), }); return member.node.id; } logger.warn('[TWENTY_CRM_TASKS] Workspace member not found', { emailHash: Buffer.from(email.toLowerCase()).toString('base64').slice(0, 12), }); return null; } catch (error) { logger.error('[TWENTY_CRM_TASKS] Error fetching workspace member', { error: error instanceof Error ? error.message : String(error), }); return null; } } /** * Fetch tasks from Twenty CRM using GraphQL API */ async function fetchTwentyTasks(userId?: string): 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 todayForISO = new Date(); todayForISO.setHours(0, 0, 0, 0); const todayISO = todayForISO.toISOString(); // GraphQL query to fetch tasks from Twenty CRM // bodyV2 is RichTextV2 type - trying common subfields // name is FullName type - using firstName and lastName const query = ` query GetTasks { tasks { edges { node { id title bodyV2 { blocknote markdown } dueAt status assigneeId assignee { id name { firstName lastName } } } } } } `; logger.debug('[TWENTY_CRM_TASKS] Fetching tasks from Twenty CRM'); 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 []; } // Log raw response metadata (no sensitive data) logger.debug('[TWENTY_CRM_TASKS] Raw GraphQL response received', { hasData: !!data.data, dataKeys: data.data ? Object.keys(data.data) : [], tasksEdgesCount: data.data?.tasks?.edges?.length || 0, }); // Try different possible response structures // Twenty CRM may use different field names depending on version let activitiesData = null; if (data.data?.tasks?.edges) { activitiesData = data.data.tasks.edges; logger.debug('[TWENTY_CRM_TASKS] Found tasks.edges', { count: activitiesData.length }); } else if (data.data?.activities?.edges) { activitiesData = data.data.activities.edges; logger.debug('[TWENTY_CRM_TASKS] Found activities.edges', { count: activitiesData.length }); } else if (data.data?.findManyTasks?.edges) { activitiesData = data.data.findManyTasks.edges; logger.debug('[TWENTY_CRM_TASKS] Found findManyTasks.edges', { count: activitiesData.length }); } else if (data.data?.findManyActivities?.edges) { activitiesData = data.data.findManyActivities.edges; logger.debug('[TWENTY_CRM_TASKS] Found findManyActivities.edges', { count: activitiesData.length }); } else if (Array.isArray(data.data?.tasks)) { // Direct array response activitiesData = data.data.tasks.map((node: any) => ({ node })); logger.debug('[TWENTY_CRM_TASKS] Found tasks as array', { count: activitiesData.length }); } else if (Array.isArray(data.data?.activities)) { // Direct array response activitiesData = data.data.activities.map((node: any) => ({ node })); logger.debug('[TWENTY_CRM_TASKS] Found activities as array', { count: activitiesData.length }); } else if (Array.isArray(data.data)) { // Root level array activitiesData = data.data.map((node: any) => ({ node })); logger.debug('[TWENTY_CRM_TASKS] Found data as array', { count: activitiesData.length }); } if (!activitiesData) { logger.warn('[TWENTY_CRM_TASKS] Unexpected response format from Twenty CRM', { dataKeys: Object.keys(data.data || {}), }); return []; } logger.debug('[TWENTY_CRM_TASKS] Activities data extracted', { count: activitiesData.length, }); // Transform Twenty CRM tasks to match our Task interface const allTasks: TwentyTask[] = activitiesData.map((edge: any) => { const node = edge.node || edge; // Handle both edge.node and direct node // Extract text from bodyV2 (RichTextV2 type) let bodyText = null; if (node.bodyV2) { // bodyV2 has blocknote and markdown subfields bodyText = node.bodyV2.markdown || node.bodyV2.blocknote || null; } return { id: node.id, title: node.title || 'Untitled Task', bodyV2: node.bodyV2 || null, dueAt: node.dueAt || null, status: node.status || null, type: node.type || 'Task', assigneeId: node.assigneeId || null, assignee: node.assignee ? { id: node.assignee.id, name: node.assignee.name ? { firstName: node.assignee.name.firstName || null, lastName: node.assignee.name.lastName || null, } : null, } : null, // Store extracted body text for easier access _bodyText: bodyText, }; }); // Log task count before filtering (no sensitive data) logger.debug('[TWENTY_CRM_TASKS] Tasks before filtering', { count: allTasks.length, }); // Filter by assignee if userId is provided let filteredByAssignee = allTasks; if (userId) { filteredByAssignee = allTasks.filter((task: TwentyTask) => { return task.assigneeId === userId; }); logger.debug('[TWENTY_CRM_TASKS] Tasks after assignee filter', { before: allTasks.length, after: filteredByAssignee.length, }); } // Filter client-side for overdue tasks (dueAt <= today) and not completed (status !== 'Done') // Use local date for comparison to avoid timezone issues const now = new Date(); const todayYear = now.getFullYear(); const todayMonth = now.getMonth(); const todayDay = now.getDate(); const tasks: TwentyTask[] = filteredByAssignee .filter((task: TwentyTask) => { // Filter: only overdue tasks (dueAt < today) and not completed // Check if task is done (case-insensitive to handle "Done", "done", "DONE", etc.) if (task.status) { const status = task.status.trim().toLowerCase(); if (status === 'done') { return false; } } if (!task.dueAt) { return false; // Exclude tasks without due date } // Parse the due date and compare dates (not times) // Use local date comparison to avoid timezone issues const taskDueDate = new Date(task.dueAt); // Get local date components (year, month, day) ignoring time const taskYear = taskDueDate.getFullYear(); const taskMonth = taskDueDate.getMonth(); const taskDay = taskDueDate.getDate(); // Compare dates: task is overdue or due today if its date is before or equal to today's date // (includes tasks due today) const isOverdueOrDueToday = taskYear < todayYear || (taskYear === todayYear && taskMonth < todayMonth) || (taskYear === todayYear && taskMonth === todayMonth && taskDay <= todayDay); return isOverdueOrDueToday; // Include overdue tasks and tasks due today }) .sort((a: TwentyTask, b: TwentyTask) => { // Sort by dueAt (oldest first) if (!a.dueAt || !b.dueAt) return 0; const dateA = new Date(a.dueAt).getTime(); const dateB = new Date(b.dueAt).getTime(); return dateA - dateB; }); logger.debug('[TWENTY_CRM_TASKS] Tasks after filtering', { count: tasks.length, filteredOutDone: filteredByAssignee.length - tasks.length, }); 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 if user has mediation role (same as Médiation page access) if (!hasMediationRole(session.user.role)) { logger.debug('[TWENTY_CRM_TASKS] User does not have mediation role, skipping Twenty CRM API call', { emailHash: Buffer.from(session.user.email.toLowerCase()).toString('base64').slice(0, 12), roles: session.user.role, }); return NextResponse.json([]); } // 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, }); // Get Twenty CRM user ID from email const twentyCrmUserId = await getTwentyCrmUserId(session.user.email); if (!twentyCrmUserId) { logger.warn('[TWENTY_CRM_TASKS] User not found in Twenty CRM, returning empty tasks', { emailHash: Buffer.from(session.user.email.toLowerCase()).toString('base64').slice(0, 12), }); return NextResponse.json([]); } logger.debug('[TWENTY_CRM_TASKS] Found Twenty CRM user ID', { emailHash: Buffer.from(session.user.email.toLowerCase()).toString('base64').slice(0, 12), }); const tasks = await fetchTwentyTasks(twentyCrmUserId); // Transform to match Leantime task format for consistency const transformedTasks = tasks.map((task) => { // Check if task is done (case-insensitive) const isDone = task.status ? task.status.trim().toLowerCase() === 'done' : false; return { id: `twenty-${task.id}`, // Prefix to avoid conflicts with Leantime IDs headline: task.title, description: (task as any)._bodyText || null, // Use extracted body text dateToFinish: task.dueAt || null, // For Twenty CRM, dueAt is used as the notification time projectName: 'Médiation', projectId: 0, status: isDone ? 5 : 1, // 5 = Done, 1 = New (or other status) editorId: task.assigneeId || null, editorFirstname: task.assignee?.name?.firstName || null, editorLastname: task.assignee?.name?.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/task/${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 } ); } }