NeahStable/app/api/twenty-crm/tasks/route.ts
2026-01-16 22:36:23 +01:00

463 lines
15 KiB
TypeScript

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<string | null> {
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<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 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
if (task.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,
});
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) => ({
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: task.status === 'Done' ? 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 }
);
}
}