import axios from 'axios'; export class LeantimeService { private apiUrl: string; private apiToken: string; constructor() { this.apiUrl = process.env.LEANTIME_API_URL || ''; this.apiToken = process.env.LEANTIME_TOKEN || ''; 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)); // Create with direct call to createProject const createResponse = await axios.post( this.getApiEndpoint(), { method: 'leantime.rpc.Projects.createProject', jsonrpc: '2.0', id: 1, params: projectData }, { headers: { 'Content-Type': 'application/json', 'X-API-Key': this.apiToken } } ); // Log the response console.log('Create response status:', createResponse.status); console.log('Create response data:', JSON.stringify(createResponse.data, null, 2)); if (createResponse.data && createResponse.data.result) { const projectId = createResponse.data.result; 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); } // Assign users to the project if (mission.missionUsers && mission.missionUsers.length > 0) { await this.assignUsersToProject(projectId, mission.missionUsers); } return projectId; } // Fall back to addProject if createProject fails console.log('createProject failed, trying addProject...'); // Based on the error message, Leantime expects a nested 'values' parameter const payload = { method: 'leantime.rpc.Projects.Projects.addProject', jsonrpc: '2.0', id: 1, params: { values: { name: mission.name, clientId: clientId, details: mission.intention || '', type: 'project', start: formattedStartDate, end: formattedEndDate, status: 'open', psettings: 'restricted' } } }; console.log('addProject 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('addProject response status:', response.status); console.log('addProject 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; 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); } // Assign users to the project if (mission.missionUsers && mission.missionUsers.length > 0) { await this.assignUsersToProject(projectId, mission.missionUsers); } 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 } } /** * 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 { console.log(`Attempting to add ${missionUsers.length} users to project ${projectId}`); // Create a direct URL to the project that can be shared with users const baseUrl = this.apiUrl .replace('/api/jsonrpc', '') .replace('/api/jsonrpc.php', ''); const projectUrl = baseUrl.endsWith('/') ? `${baseUrl}projects/showProject/${projectId}` : `${baseUrl}/projects/showProject/${projectId}`; console.log(`🔗 Project created successfully. Direct project URL: ${projectUrl}`); if (!missionUsers || missionUsers.length === 0) { console.log('No users to assign to the project.'); return; } // Users will need to be added manually if the automatic assignment fails console.log('Attempting to assign users to the project - if this fails, users can be added manually through the Leantime interface.'); try { let successCount = 0; let errorCount = 0; for (const missionUser of missionUsers) { try { // Get or create the user in Leantime const leantimeUserId = await this.getUserByEmail(missionUser.user.email); if (!leantimeUserId) { console.warn(`⚠️ User not found in Leantime: ${missionUser.user.email}`); errorCount++; continue; } // Determine role (Gardien du Temps gets editor, others get commenter) const role = missionUser.role === 'gardien-temps' ? 'editor' : 'commenter'; // Assign the user to the project await this.assignUserToProject(projectId, leantimeUserId, role); successCount++; } catch (userError) { console.error(`Error assigning user ${missionUser.user.email}:`, userError); errorCount++; // Continue with next user } } console.log(`User assignment complete: ${successCount} successful, ${errorCount} failed.`); if (errorCount > 0) { console.log(`⚠️ Some users could not be assigned automatically. They will need to be added manually at: ${projectUrl}`); } } catch (error) { console.error('Error assigning users to project:', error); console.log(`⚠️ Users will need to be added manually at: ${projectUrl}`); // Continue even if some user assignments fail } } /** * 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 */ async assignUserToProject(projectId: number, userId: string, role: string): Promise { try { console.log(`Assigning user ${userId} to project ${projectId} with role ${role}`); // Try method 1: Projects.Projects.addUser try { const response = await axios.post( this.getApiEndpoint(), { method: 'leantime.rpc.Projects.Projects.addUser', 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} (method 1)`); return; } } catch (error) { console.log('Method 1 failed, trying next method...'); } // Try method 2: Projects.addProjectUser try { const response2 = 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 (response2.data && response2.data.result) { console.log(`Assigned user ${userId} to project ${projectId} with role ${role} (method 2)`); return; } } catch (error) { console.log('Method 2 failed, trying next method...'); } // Try method 3: Projects.Projects.addProjectUser try { const response3 = await axios.post( this.getApiEndpoint(), { method: 'leantime.rpc.Projects.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 (response3.data && response3.data.result) { console.log(`Assigned user ${userId} to project ${projectId} with role ${role} (method 3)`); return; } } catch (error) { console.log('Method 3 failed, trying next method...'); } // Try method 4: Projects.editUserProjectRelations try { // This method might take a different param structure const response4 = await axios.post( this.getApiEndpoint(), { method: 'leantime.rpc.Projects.editUserProjectRelations', jsonrpc: '2.0', id: 1, params: { action: 'add', userId: userId, projects: [{ id: projectId, role: role }] } }, { headers: { 'Content-Type': 'application/json', 'X-API-Key': this.apiToken } } ); if (response4.data && response4.data.result) { console.log(`Assigned user ${userId} to project ${projectId} with role ${role} (method 4)`); return; } } catch (error) { console.log('Method 4 failed.'); } // If we reach here, all methods failed console.warn(`⚠️ Could not assign user ${userId} to project ${projectId} with any method. This is not a critical error, the project was created.`); } catch (error) { console.error(`Error assigning user ${userId} to project ${projectId}:`, error); // We'll allow this to fail since the project was created successfully console.warn(`⚠️ User assignment failed, but this is not critical. The project ${projectId} was created successfully.`); } } /** * 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 { const response = await axios.post( this.getApiEndpoint(), { method: 'leantime.rpc.Clients.Clients.create', 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 { // Try again with a different API method name 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 { const response = await axios.post( this.getApiEndpoint(), { method: 'leantime.rpc.Projects.Projects.deleteProject', jsonrpc: '2.0', id: 1, params: { id: projectId } }, { headers: { 'Content-Type': 'application/json', 'X-API-Key': this.apiToken } } ); return response.data && response.data.result === true; } catch (error) { // Check if this is a rate limiting error if (axios.isAxiosError(error) && error.response?.status === 429) { console.log('Rate limiting detected (429) on delete. Waiting and retrying...'); // Wait 3 seconds and then retry await new Promise(resolve => setTimeout(resolve, 3000)); try { // Retry the delete const retryResponse = await axios.post( this.getApiEndpoint(), { method: 'leantime.rpc.Projects.Projects.deleteProject', jsonrpc: '2.0', id: 1, params: { id: projectId } }, { headers: { 'Content-Type': 'application/json', 'X-API-Key': this.apiToken } } ); return retryResponse.data && retryResponse.data.result === true; } catch (retryError) { console.error(`Error on retry delete for project ${projectId}:`, retryError); return false; } } console.error(`Error deleting Leantime project ${projectId}:`, error); return false; } } }