import { NextRequest, NextResponse } from "next/server"; import { getServerSession } from "next-auth/next"; import { authOptions } from "@/app/api/auth/options"; import { getCachedTasksData, cacheTasksData } from "@/lib/redis"; import { logger } from "@/lib/logger"; import { fetchWithTimeout, fetchJsonWithTimeout } from '@/lib/utils/fetch-with-timeout'; interface Task { id: string; headline: string; projectName: string; projectId: number; status: number; dateToFinish: string | null; milestone: string | null; details: string | null; createdOn: string; editedOn: string | null; editorId: string; editorFirstname: string; editorLastname: string; } async function getLeantimeUserId(email: string): Promise { try { if (!process.env.LEANTIME_TOKEN) { logger.error('[LEANTIME_TASKS] LEANTIME_TOKEN is not set in environment variables'); return null; } logger.debug('[LEANTIME_TASKS] Fetching Leantime users', { emailHash: Buffer.from(email.toLowerCase()).toString('base64').slice(0, 12), apiUrlPresent: !!process.env.LEANTIME_API_URL, tokenLength: process.env.LEANTIME_TOKEN.length, }); const headers: Record = { 'Content-Type': 'application/json', 'X-API-Key': process.env.LEANTIME_TOKEN }; let data; try { data = await fetchJsonWithTimeout(`${process.env.LEANTIME_API_URL}/api/jsonrpc`, { method: 'POST', timeout: 10000, // 10 seconds headers, body: JSON.stringify({ jsonrpc: '2.0', method: 'leantime.rpc.users.getAll', id: 1 }), }); } catch (error) { logger.error('[LEANTIME_TASKS] Failed to fetch Leantime users', { error: error instanceof Error ? error.message : String(error), }); return null; } if (!data.result || !Array.isArray(data.result)) { logger.error('[LEANTIME_TASKS] Invalid response format from Leantime users API'); return null; } const users = data.result; const user = users.find((u: any) => u.username === email); if (user) { logger.debug('[LEANTIME_TASKS] Found Leantime user', { emailHash: Buffer.from(email.toLowerCase()).toString('base64').slice(0, 12), }); } else { logger.warn('[LEANTIME_TASKS] No Leantime user found for username', { emailHash: Buffer.from(email.toLowerCase()).toString('base64').slice(0, 12), }); } return user ? user.id : null; } catch (error) { logger.error('[LEANTIME_TASKS] Error fetching Leantime user ID', { error: error instanceof Error ? error.message : String(error), }); return 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> { const doneStatusValues = new Set(); try { const headers: Record = { '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 || typeof data.result !== 'object' || Array.isArray(data.result)) { console.log('[LEANTIME_TASKS] ⚠️ Invalid response format from getAllStatusLabelsByUserId, using fallback'); console.log('[LEANTIME_TASKS] Response type:', typeof data.result, 'isArray:', 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 object where: // - Keys are project IDs (strings like "4", "5", "6", "457") // - Values are objects where keys are status numbers (strings like "0", "1", "2", "3", "4", "5", "-1") // - Each status object has: name, statusType, class, kanbanCol, sortKey console.log('[LEANTIME_TASKS] 📋 getAllStatusLabelsByUserId response structure:', { resultType: typeof data.result, isObject: typeof data.result === 'object', projectIds: Object.keys(data.result), }); // Iterate through all projects Object.keys(data.result).forEach((projectId: string) => { const projectStatuses = data.result[projectId]; if (projectStatuses && typeof projectStatuses === 'object') { // Iterate through all statuses in this project Object.keys(projectStatuses).forEach((statusKey: string) => { const statusInfo = projectStatuses[statusKey]; if (statusInfo && typeof statusInfo === 'object') { const statusType = String(statusInfo.statusType || '').toUpperCase(); const statusName = String(statusInfo.name || '').toLowerCase().trim(); // Check if statusType is "DONE" or name contains "done" if (statusType === 'DONE' || statusName === 'done' || statusName.includes('done')) { // The statusKey IS the status number (e.g., "0", "3", "5", "-1") doneStatusValues.add(statusKey); console.log(`[LEANTIME_TASKS] ✅ Found done status: ${statusKey} in project ${projectId} (name: "${statusInfo.name}", statusType: "${statusType}")`); } } }); } }); console.log('[LEANTIME_TASKS] ✅ Identified done status values:', { doneStatusValues: Array.from(doneStatusValues), projectsCount: Object.keys(data.result).length, }); logger.debug('[LEANTIME_TASKS] Identified done status values', { doneStatusValues: Array.from(doneStatusValues), projectsCount: Object.keys(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) { console.log('[LEANTIME_TASKS] 🔵 API CALLED - Starting request'); try { const session = await getServerSession(authOptions); if (!session?.user?.email) { console.log('[LEANTIME_TASKS] ❌ Unauthorized - no session'); return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } console.log('[LEANTIME_TASKS] ✅ Session found, user:', session.user.email); // Check for force refresh parameter const url = new URL(request.url); 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); console.log('[LEANTIME_TASKS] ✅ Done status values for filtering:', Array.from(doneStatusValues)); logger.debug('[LEANTIME_TASKS] Done status values identified', { doneStatusValues: Array.from(doneStatusValues), }); // Try to get data from cache if not forcing refresh if (!forceRefresh) { const cachedTasks = await getCachedTasksData(session.user.id); if (cachedTasks && Array.isArray(cachedTasks)) { // Filter out done tasks from cache using dynamic status values const filteredCachedTasks = cachedTasks.filter((task: any) => { const taskStatus = task.status; if (taskStatus !== null && taskStatus !== undefined) { const statusStr = String(taskStatus); if (doneStatusValues.has(statusStr)) { logger.debug('[LEANTIME_TASKS] Filtering out done task from cache', { id: task.id, headline: task.headline, status: taskStatus, }); return false; } } return true; }); if (filteredCachedTasks.length !== cachedTasks.length) { logger.debug('[LEANTIME_TASKS] Filtered done tasks from cache', { before: cachedTasks.length, after: filteredCachedTasks.length, removed: cachedTasks.length - filteredCachedTasks.length, }); // Update cache with filtered tasks await cacheTasksData(session.user.id, filteredCachedTasks); } logger.debug('[LEANTIME_TASKS] Using cached tasks data', { emailHash: Buffer.from(session.user.email.toLowerCase()).toString('base64').slice(0, 12), taskCount: filteredCachedTasks.length, }); // Return tasks with done statuses for frontend filtering return NextResponse.json({ tasks: filteredCachedTasks, doneStatuses: Array.from(doneStatusValues), }); } } logger.debug('[LEANTIME_TASKS] Fetching tasks for Leantime user', { emailHash: Buffer.from(session.user.email.toLowerCase()).toString('base64').slice(0, 12), }); const headers: Record = { 'Content-Type': 'application/json', 'X-API-Key': process.env.LEANTIME_TOKEN! }; let data; try { data = await fetchJsonWithTimeout(`${process.env.LEANTIME_API_URL}/api/jsonrpc`, { method: 'POST', timeout: 10000, // 10 seconds headers, body: JSON.stringify({ jsonrpc: '2.0', method: 'leantime.rpc.tickets.getAll', params: { userId: userId, status: "all" }, id: 1 }), }); } catch (error) { logger.error('[LEANTIME_TASKS] Failed to fetch tasks from Leantime', { error: error instanceof Error ? error.message : String(error), }); throw new Error('Failed to fetch tasks from Leantime'); } if (!data.result || !Array.isArray(data.result)) { logger.error('[LEANTIME_TASKS] Invalid response format from Leantime tasks API'); throw new Error('Invalid response format from Leantime'); } console.log('[LEANTIME_TASKS] 🔍 RAW DATA FROM LEANTIME - Total tasks:', data.result.length); // Log RAW data from Leantime to see exact status values (using console.log so it shows in production) data.result.forEach((task: any) => { console.log(`[LEANTIME_TASKS] Task ID: ${task.id}, Headline: ${task.headline}, Status: ${task.status} (type: ${typeof task.status}), EditorId: ${task.editorId}`); }); // Log detailed status information before filtering const statusBreakdownBefore = data.result.reduce((acc: any, task: any) => { const status = task.status; const statusKey = String(status); if (!acc[statusKey]) { acc[statusKey] = { count: 0, type: typeof status, sample: [] }; } acc[statusKey].count++; if (acc[statusKey].sample.length < 3) { acc[statusKey].sample.push({ id: task.id, headline: task.headline }); } return acc; }, {}); logger.debug('[LEANTIME_TASKS] Received tasks summary', { count: data.result.length, statusBreakdown: statusBreakdownBefore, idsSample: data.result.slice(0, 20).map((task: any) => task.id), }); const tasks = data.result .filter((task: any) => { // Filter out any task (main or subtask) that has status Done // Use dynamic status values from Leantime status labels const taskStatus = task.status; if (taskStatus !== null && taskStatus !== undefined) { const statusStr = String(taskStatus); if (doneStatusValues.has(statusStr)) { logger.debug('[LEANTIME_TASKS] Filtering out done task', { id: task.id, headline: task.headline, status: taskStatus, statusType: typeof taskStatus, }); return false; } } // Convert both to strings for comparison to handle any type mismatches const taskEditorId = String(task.editorId).trim(); const currentUserId = String(userId).trim(); // Only show tasks where the user is the editor const isUserEditor = taskEditorId === currentUserId; if (!isUserEditor) { console.log(`[LEANTIME_TASKS] ⚠️ Task filtered - user is not editor: ID=${task.id}, EditorId=${taskEditorId}, UserId=${currentUserId}`); } return isUserEditor; }) .map((task: any) => ({ id: task.id.toString(), headline: task.headline, projectName: task.projectName, projectId: task.projectId, status: task.status, 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, // Added type field to identify subtasks dependingTicketId: task.dependingTicketId || null // Added parent task reference })); // Log detailed status information for debugging const statusBreakdown = tasks.reduce((acc: any, task: any) => { const status = task.status; const statusKey = String(status); if (!acc[statusKey]) { acc[statusKey] = 0; } acc[statusKey]++; return acc; }, {}); logger.debug('[LEANTIME_TASKS] Filtered tasks for user', { userId, count: tasks.length, statusBreakdown, sampleTasks: tasks.slice(0, 5).map((t: any) => ({ id: t.id, headline: t.headline, status: t.status, statusType: typeof t.status, })), }); // Cache the results await cacheTasksData(session.user.id, tasks); // Return tasks with done statuses for frontend filtering return NextResponse.json({ tasks: tasks, doneStatuses: Array.from(doneStatusValues), }); } catch (error) { logger.error('[LEANTIME_TASKS] Error in tasks route', { error: error instanceof Error ? error.message : String(error), }); return NextResponse.json( { error: "Failed to fetch tasks" }, { status: 500 } ); } }