NeahNew/lib/services/leantime-service.ts
2025-05-06 20:38:37 +02:00

824 lines
27 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<number> {
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<void> {
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<string | null> {
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<void> {
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<boolean> {
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<boolean> {
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<boolean> {
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<number | null> {
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<number | null> {
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<string | null> {
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<boolean> {
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}`;
}
}