import axios from 'axios'; export class LeantimeService { private apiUrl: string; private apiToken: string; private apiUserId: string | null; constructor() { this.apiUrl = process.env.LEANTIME_API_URL || ''; this.apiToken = process.env.LEANTIME_TOKEN || ''; this.apiUserId = null; // Will be fetched when needed console.log('LeantimeService initialized with URL:', this.apiUrl); } /** * Get the properly formatted API endpoint * @returns The formatted API endpoint */ private getApiEndpoint(): string { return this.apiUrl.endsWith('/api/jsonrpc') ? this.apiUrl : this.apiUrl.endsWith('/') ? `${this.apiUrl}api/jsonrpc` : `${this.apiUrl}/api/jsonrpc`; } /** * Create a new project in Leantime * @param mission The mission data * @returns Project ID or throws error */ async createProject(mission: any): Promise { try { // Determine client ID based on mission type const clientId = mission.niveau.toLowerCase() === 'a' ? await this.getClientIdByName('Enkun') : await this.getClientIdByName('ONG'); if (!clientId) { throw new Error(`Leantime client not found for mission type ${mission.niveau}`); } // Generate dates for the project (today and one year from now) const today = new Date(); const endDate = new Date(); endDate.setFullYear(today.getFullYear() + 1); const formattedStartDate = today.toISOString().split('T')[0]; const formattedEndDate = endDate.toISOString().split('T')[0]; console.log(`Creating project with dates: start=${formattedStartDate}, end=${formattedEndDate}`); // Create project values object const projectData = { name: mission.name, clientId: clientId, details: mission.intention || '', type: 'project', start: formattedStartDate, end: formattedEndDate, status: 'open', psettings: 'restricted' }; console.log('Creating project with data:', JSON.stringify(projectData, null, 2)); // Use only the method that we know works const payload = { method: 'leantime.rpc.Projects.Projects.addProject', jsonrpc: '2.0', id: 1, params: { values: projectData } }; console.log('Project creation payload:', JSON.stringify(payload, null, 2)); const response = await axios.post( this.getApiEndpoint(), payload, { headers: { 'Content-Type': 'application/json', 'X-API-Key': this.apiToken } } ); console.log('Project creation response status:', response.status); console.log('Project creation response data:', JSON.stringify(response.data, null, 2)); if (!response.data || !response.data.result) { throw new Error(`Failed to create Leantime project: ${JSON.stringify(response.data)}`); } const projectId = response.data.result[0]; // We need the first element in the array console.log(`Created Leantime project with ID: ${projectId}`); // If the mission has a logo, set it as project avatar if (mission.logo) { await this.setProjectAvatar(projectId, mission.logo); } // Check if mission has users to assign if (mission.missionUsers && mission.missionUsers.length > 0) { await this.assignUsersToProject(projectId, mission.missionUsers); } else { // No users to assign const projectUrl = this.getProjectUrl(projectId); console.log(`ℹ️ No users to assign. Project created at: ${projectUrl}`); } return projectId; } catch (error) { if (axios.isAxiosError(error) && error.response) { console.error('Axios Error Details:', { status: error.response.status, data: error.response.data, headers: error.response.headers }); } console.error('Error creating Leantime project:', error); throw new Error(`Leantime integration failed: ${error instanceof Error ? error.message : String(error)}`); } } /** * Set project avatar using mission logo * @param projectId The Leantime project ID * @param logoPath The mission logo URL */ async setProjectAvatar(projectId: number, logoPath: string): Promise { try { // Get the logo file from the storage const logoResponse = await fetch(`/api/missions/image/${logoPath}`); if (!logoResponse.ok) { throw new Error(`Failed to fetch logo file: ${logoResponse.statusText}`); } const formData = new FormData(); const logoBlob = await logoResponse.blob(); // Add the file to form data formData.append('file', logoBlob, 'logo.png'); formData.append('project', JSON.stringify({ id: projectId })); // Get base URL by removing any API path const baseUrl = this.apiUrl .replace('/api/jsonrpc', '') .replace('/api/jsonrpc.php', ''); // Construct proper avatar endpoint const avatarEndpoint = baseUrl.endsWith('/') ? `${baseUrl}api/v1/projects.setProjectAvatar` : `${baseUrl}/api/v1/projects.setProjectAvatar`; console.log('Using avatar endpoint:', avatarEndpoint); // Upload the avatar const response = await axios.post( avatarEndpoint, formData, { headers: { 'Content-Type': 'multipart/form-data', 'X-API-Key': this.apiToken } } ); if (!response.data || !response.data.success) { throw new Error(`Failed to set project avatar: ${JSON.stringify(response.data)}`); } console.log(`Set avatar for Leantime project ${projectId}`); } catch (error) { console.error('Error setting project avatar:', error); // Don't fail the entire process if avatar upload fails } } /** * Get the API user's ID for use in project assignments * This is useful when all other methods of user assignment fail */ private async getApiUserId(): Promise { if (this.apiUserId) { return this.apiUserId; } try { const response = await axios.post( this.getApiEndpoint(), { method: 'leantime.rpc.Auth.getCurrentUser', jsonrpc: '2.0', id: 1 }, { headers: { 'Content-Type': 'application/json', 'X-API-Key': this.apiToken } } ); if (response.data && response.data.result && response.data.result.id) { this.apiUserId = response.data.result.id; console.log(`Found API user ID: ${this.apiUserId}`); return this.apiUserId; } return null; } catch (error) { console.error('Error getting API user ID:', error); return null; } } /** * Assign mission users to the Leantime project * @param projectId The Leantime project ID * @param missionUsers The mission users with roles */ async assignUsersToProject(projectId: number, missionUsers: any[]): Promise { const projectUrl = this.getProjectUrl(projectId); console.log(`⚠️ For best results, please assign users manually at: ${projectUrl}`); console.log('ℹ️ Automatic user assignment is currently disabled due to API limitations.'); // No automatic user assignment - this was causing too many issues return; } /** * Verify if a user is assigned to a project * @param userId The Leantime user ID * @param projectId The Leantime project ID * @returns True if the user is assigned to the project, false otherwise */ private async verifyUserAssignedToProject(userId: string, projectId: number): Promise { try { console.log(`Verifying if user ${userId} is assigned to project ${projectId}...`); const response = await axios.post( this.getApiEndpoint(), { method: 'leantime.rpc.Projects.Projects.getProjectIdAssignedToUser', jsonrpc: '2.0', id: 1, params: { userId: userId } }, { headers: { 'Content-Type': 'application/json', 'X-API-Key': this.apiToken } } ); // Check if the response contains project IDs if (response.data && response.data.result && Array.isArray(response.data.result)) { // Convert project IDs to strings for comparison (API might return strings or numbers) const assignedProjects = response.data.result.map((id: number | string) => String(id)); const projectIdStr = String(projectId); // Check if the user is assigned to our project const isAssigned = assignedProjects.includes(projectIdStr); console.log(`User ${userId} ${isAssigned ? 'is' : 'is not'} assigned to project ${projectId}`); return isAssigned; } console.log(`User ${userId} is not assigned to any projects`); return false; } catch (error) { console.error(`Error verifying user assignment for user ${userId}:`, error); return false; } } /** * Assign a user to a project with the specified role * @param projectId The Leantime project ID * @param userId The Leantime user ID * @param role The role to assign * @returns true if assignment succeeded, false otherwise */ async assignUserToProject(projectId: number, userId: string, role: string): Promise { try { console.log(`Assigning user ${userId} to project ${projectId} with role ${role}`); // Wait before making the API call to avoid rate limiting await new Promise(resolve => setTimeout(resolve, 2000)); // Use only the method that we know works best const response = await axios.post( this.getApiEndpoint(), { method: 'leantime.rpc.Projects.addProjectUser', jsonrpc: '2.0', id: 1, params: { projectId: projectId, userId: userId, role: role } }, { headers: { 'Content-Type': 'application/json', 'X-API-Key': this.apiToken } } ); if (response.data && response.data.result) { console.log(`✅ Assigned user ${userId} to project ${projectId} with role ${role}`); return true; } console.warn(`⚠️ Could not assign user ${userId} to project ${projectId}.`); return false; } catch (error) { console.error(`Error assigning user ${userId} to project ${projectId}:`, error); console.warn(`⚠️ User assignment failed. The project ${projectId} was created successfully, but users will need to be added manually.`); return false; } } /** * Special method to assign the API user to a project * This uses different approaches to try to ensure success * @returns true if assignment succeeded, false otherwise */ private async assignApiUserToProject(projectId: number, apiUserId: string): Promise { console.log(`Assigning API user ${apiUserId} to project ${projectId} as admin`); // First, check if the API user is already assigned const alreadyAssigned = await this.verifyUserAssignedToProject(apiUserId, projectId); if (alreadyAssigned) { console.log(`✅ API user ${apiUserId} is already assigned to project ${projectId}`); return true; } // Wait before making the API call to avoid rate limiting await new Promise(resolve => setTimeout(resolve, 2000)); // Use only the method that we know works best try { const response = await axios.post( this.getApiEndpoint(), { method: 'leantime.rpc.Projects.addProjectUser', jsonrpc: '2.0', id: 1, params: { projectId: projectId, userId: apiUserId, role: 'admin' // API user gets admin to ensure full control } }, { headers: { 'Content-Type': 'application/json', 'X-API-Key': this.apiToken } } ); if (response.data && response.data.result) { console.log(`✅ API user successfully assigned to project ${projectId}`); return true; } } catch (error) { console.error('Error assigning API user to project:', error); } console.warn(`⚠️ Could not assign API user to project ${projectId}. The project may appear with "New API Access" as the only team member.`); return false; } /** * Get a client ID by name * @param clientName The client name to search for * @returns The client ID or null if not found */ async getClientIdByName(clientName: string): Promise { try { // Log the API URL to debug console.log('Leantime API URL:', this.apiUrl); const apiEndpoint = this.getApiEndpoint(); console.log('Using endpoint:', apiEndpoint); const response = await axios.post( apiEndpoint, { method: 'leantime.rpc.Clients.Clients.getAll', jsonrpc: '2.0', id: 1, params: {} }, { headers: { 'Content-Type': 'application/json', 'X-API-Key': this.apiToken } } ); if (!response.data || !response.data.result) { throw new Error(`Failed to get clients: ${JSON.stringify(response.data)}`); } const clients = response.data.result; const client = clients.find((c: any) => c.name.toLowerCase() === clientName.toLowerCase() ); if (client) { return parseInt(client.id); } else { console.log(`Client "${clientName}" not found. Creating it...`); // Add delay before creating client to avoid rate limiting await new Promise(resolve => setTimeout(resolve, 1000)); return await this.createClient(clientName); } } catch (error) { console.error('Error getting client by name:', error); // Check if this is a rate limiting error if (axios.isAxiosError(error) && error.response?.status === 429) { console.log('Rate limiting detected (429). Waiting before retry...'); // Wait 2 seconds before next API call to respect rate limits await new Promise(resolve => setTimeout(resolve, 2000)); } // Try to create the client if we couldn't find it try { console.log(`Attempting to create client "${clientName}" after error...`); return await this.createClient(clientName); } catch (createError) { console.error('Error creating client:', createError); return null; } } } /** * Create a new client in Leantime * @param clientName The name of the client to create * @returns The ID of the created client or null if failed */ async createClient(clientName: string): Promise { try { // Wait before making the API call to avoid rate limiting await new Promise(resolve => setTimeout(resolve, 1000)); // Use only the method that works consistently const response = await axios.post( this.getApiEndpoint(), { method: 'leantime.rpc.Clients.Clients.addClient', jsonrpc: '2.0', id: 1, params: { values: { name: clientName, street: '', zip: '', city: '', state: '', country: '', phone: '', internet: '', email: '' } } }, { headers: { 'Content-Type': 'application/json', 'X-API-Key': this.apiToken } } ); if (!response.data || !response.data.result) { throw new Error(`Failed to create client: ${JSON.stringify(response.data)}`); } const clientId = parseInt(response.data.result); console.log(`Created client "${clientName}" with ID: ${clientId}`); return clientId; } catch (error) { // Check if this is a rate limiting error if (axios.isAxiosError(error) && error.response?.status === 429) { console.log('Rate limiting detected (429). Waiting and retrying...'); // Wait 3 seconds and then retry await new Promise(resolve => setTimeout(resolve, 3000)); try { // Retry with the same method after waiting const retryResponse = await axios.post( this.getApiEndpoint(), { method: 'leantime.rpc.Clients.Clients.addClient', jsonrpc: '2.0', id: 1, params: { values: { name: clientName, street: '', zip: '', city: '', state: '', country: '', phone: '', internet: '', email: '' } } }, { headers: { 'Content-Type': 'application/json', 'X-API-Key': this.apiToken } } ); if (retryResponse.data && retryResponse.data.result) { const retryClientId = parseInt(retryResponse.data.result); console.log(`Created client "${clientName}" with ID: ${retryClientId} (retry)`); return retryClientId; } } catch (retryError) { console.error('Error on retry:', retryError); } } console.error(`Error creating client "${clientName}":`, error); return null; } } /** * Get a user ID by email * @param email The user email to search for * @returns The user ID or null if not found */ async getUserByEmail(email: string): Promise { try { console.log(`Looking up user with email: ${email}`); // Try getUserByEmail first (direct lookup method) try { const directResponse = await axios.post( this.getApiEndpoint(), { method: 'leantime.rpc.Users.Users.getUserByEmail', jsonrpc: '2.0', id: 1, params: { email: email } }, { headers: { 'Content-Type': 'application/json', 'X-API-Key': this.apiToken } } ); if (directResponse.data && directResponse.data.result && directResponse.data.result !== false && directResponse.data.result.id) { console.log(`Found user by direct email lookup: ${directResponse.data.result.id}`); return directResponse.data.result.id; } } catch (directError) { console.log('Direct email lookup failed, trying alternate method...'); // Check if the error is rate limiting (429) if (axios.isAxiosError(directError) && directError.response?.status === 429) { console.log('Rate limiting detected (429). Waiting before retry...'); // Wait 1 second before next API call to respect rate limits await new Promise(resolve => setTimeout(resolve, 1000)); } } // Fall back to get all users and filter try { const response = await axios.post( this.getApiEndpoint(), { method: 'leantime.rpc.Users.Users.getAll', jsonrpc: '2.0', id: 1 }, { headers: { 'Content-Type': 'application/json', 'X-API-Key': this.apiToken } } ); if (!response.data || !response.data.result) { throw new Error(`Failed to get users: ${JSON.stringify(response.data)}`); } const users = response.data.result; // First try exact email match let user = users.find((u: any) => u.email === email || u.username === email); // If that fails, try case-insensitive match if (!user) { const lowerEmail = email.toLowerCase(); user = users.find((u: any) => (u.email && u.email.toLowerCase() === lowerEmail) || (u.username && u.username.toLowerCase() === lowerEmail) ); } if (user) { console.log(`Found user with email ${email}: ID ${user.id}`); return user.id; } } catch (fetchError) { // Check if this is a rate limiting error if (axios.isAxiosError(fetchError) && fetchError.response?.status === 429) { console.log('Rate limiting detected (429). Consider user assignment through manual UI.'); } else { console.error('Error fetching users:', fetchError); } } // If user is still not found or error occurred console.log(`No user found with email ${email}`); return null; } catch (error) { console.error('Error getting user by email:', error); return null; } } /** * Delete a project from Leantime * @param projectId The Leantime project ID to delete * @returns True if successful, false otherwise */ async deleteProject(projectId: number): Promise { try { console.log(`Attempting to delete Leantime project ${projectId} using REST API approach`); // Get base URL by removing any API path const baseUrl = this.apiUrl .replace('/api/jsonrpc', '') .replace('/api/jsonrpc.php', ''); // Construct REST API URL for project deletion // Attempt different patterns that might work: // 1. First, try the standard REST API approach const restApiUrl = baseUrl.endsWith('/') ? `${baseUrl}api/v1/projects/${projectId}` : `${baseUrl}/api/v1/projects/${projectId}`; console.log(`Trying REST API deletion via DELETE to: ${restApiUrl}`); try { const restResponse = await axios.delete( restApiUrl, { headers: { 'Content-Type': 'application/json', 'X-API-Key': this.apiToken } } ); console.log(`REST API Delete response:`, JSON.stringify(restResponse.data, null, 2)); if (restResponse.status >= 200 && restResponse.status < 300) { console.log(`Successfully deleted project ${projectId} via REST API`); return true; } } catch (restError) { console.error('REST API error:', restError); if (axios.isAxiosError(restError)) { console.error('REST API error details:', { status: restError.response?.status, data: restError.response?.data, message: restError.message }); } } // 2. Try the web interface approach (simulate a form submission) console.log(`Trying web interface simulation for project deletion...`); // Assuming a standard web interface form submission const formUrl = baseUrl.endsWith('/') ? `${baseUrl}projects/deleteProject/${projectId}` : `${baseUrl}/projects/deleteProject/${projectId}`; console.log(`Simulating form submission to: ${formUrl}`); try { // Some systems require a POST to the delete endpoint const formResponse = await axios.post( formUrl, {}, // Empty payload or { confirm: true } depending on the system { headers: { 'Content-Type': 'application/json', 'X-API-Key': this.apiToken } } ); console.log(`Form submission response:`, JSON.stringify(formResponse.data, null, 2)); if (formResponse.status >= 200 && formResponse.status < 300) { console.log(`Successfully deleted project ${projectId} via web interface simulation`); return true; } } catch (formError) { console.error('Form submission error:', formError); if (axios.isAxiosError(formError)) { console.error('Form submission error details:', { status: formError.response?.status, data: formError.response?.data, message: formError.message }); } } // 3. Last resort: Try to mark the project as archived/inactive if deletion isn't supported console.log(`Attempting to mark project as archived/inactive as a fallback...`); try { // Use the JSON-RPC API to update the project status const updateResponse = await axios.post( this.getApiEndpoint(), { method: 'leantime.rpc.Projects.Projects.updateProject', jsonrpc: '2.0', id: 1, params: { id: projectId, values: { status: 'archived' // Try 'archived', 'deleted', or 'inactive' } } }, { headers: { 'Content-Type': 'application/json', 'X-API-Key': this.apiToken } } ); console.log(`Project status update response:`, JSON.stringify(updateResponse.data, null, 2)); if (updateResponse.data && updateResponse.data.result) { console.log(`Successfully marked project ${projectId} as archived`); return true; } } catch (updateError) { console.error('Project status update error:', updateError); if (axios.isAxiosError(updateError)) { console.error('Update error details:', { status: updateError.response?.status, data: updateError.response?.data, message: updateError.message }); } } // If we get here, all methods failed console.error(`Failed to delete or archive Leantime project ${projectId} with all methods.`); console.log(`Please manually delete/archive project ${projectId} from Leantime.`); // Get the project URL for manual deletion const projectUrl = this.getProjectUrl(projectId); console.log(`Project URL for manual cleanup: ${projectUrl}`); return false; } catch (error) { // Log detailed error information console.error(`Error in deleteProject for Leantime project ${projectId}:`, error); if (axios.isAxiosError(error)) { console.error('Error details:', { status: error.response?.status, data: error.response?.data, message: error.message }); } return false; } } /** * Helper method to get the project URL */ private getProjectUrl(projectId: number): string { const baseUrl = this.apiUrl .replace('/api/jsonrpc', '') .replace('/api/jsonrpc.php', ''); return baseUrl.endsWith('/') ? `${baseUrl}projects/showProject/${projectId}` : `${baseUrl}/projects/showProject/${projectId}`; } }