From ec0adbf24a4de24e531c149092f9168cb1ad1f28 Mon Sep 17 00:00:00 2001 From: alma Date: Tue, 6 May 2025 16:34:46 +0200 Subject: [PATCH] missions api2 --- app/api/missions/route.ts | 2 +- lib/services/integration-service-fixed.ts | 277 ++++++++++++++++++++++ lib/services/outline-service-fixed.ts | 183 ++++++++++++++ lib/services/rocketchat-service-fixed.ts | 221 +++++++++++++++++ 4 files changed, 682 insertions(+), 1 deletion(-) create mode 100644 lib/services/integration-service-fixed.ts create mode 100644 lib/services/outline-service-fixed.ts create mode 100644 lib/services/rocketchat-service-fixed.ts diff --git a/app/api/missions/route.ts b/app/api/missions/route.ts index 66c100e2..31094c36 100644 --- a/app/api/missions/route.ts +++ b/app/api/missions/route.ts @@ -4,7 +4,7 @@ import { authOptions } from "@/app/api/auth/options"; import { prisma } from '@/lib/prisma'; import { getPublicUrl } from '@/lib/s3'; import { S3_CONFIG } from '@/lib/s3'; -import { IntegrationService } from '@/lib/services/integration-service'; +import { IntegrationService } from '@/lib/services/integration-service-fixed'; // Helper function to check authentication async function checkAuth(request: Request) { diff --git a/lib/services/integration-service-fixed.ts b/lib/services/integration-service-fixed.ts new file mode 100644 index 00000000..c3ddd309 --- /dev/null +++ b/lib/services/integration-service-fixed.ts @@ -0,0 +1,277 @@ +import { prisma } from '@/lib/prisma'; +import { LeantimeService } from './leantime-service'; +import { OutlineService } from './outline-service-fixed'; +import { RocketChatService } from './rocketchat-service-fixed'; +import axios from 'axios'; + +interface IntegrationResult { + success: boolean; + error?: string; + data?: { + leantimeProjectId?: number; + outlineCollectionId?: string; + rocketChatChannelId?: string; + giteaRepositoryUrl?: string; + penpotProjectId?: string; + }; +} + +export class IntegrationService { + private leantimeService: LeantimeService; + private outlineService: OutlineService; + private rocketChatService: RocketChatService; + + constructor() { + this.leantimeService = new LeantimeService(); + this.outlineService = new OutlineService(); + this.rocketChatService = new RocketChatService(); + } + + /** + * Set up all integrations for a mission + * @param missionId The mission ID + * @returns Integration result + */ + async setupIntegrationsForMission(missionId: string): Promise { + try { + // Get complete mission data with users + const mission = await prisma.mission.findUnique({ + where: { id: missionId }, + include: { + missionUsers: { + include: { + user: true + } + }, + attachments: true + } + }); + + if (!mission) { + throw new Error(`Mission not found: ${missionId}`); + } + + // These fields will store the IDs of created resources + let leantimeProjectId: number | undefined; + let outlineCollectionId: string | undefined; + let rocketChatChannelId: string | undefined; + + // Track which integrations succeeded and which failed + const integrationStatus = { + leantime: { success: false, id: undefined as number | undefined, error: undefined as string | undefined }, + outline: { success: false, id: undefined as string | undefined, error: undefined as string | undefined }, + rocketchat: { success: false, id: undefined as string | undefined, error: undefined as string | undefined } + }; + + // A flag to determine if we should consider this a success or failure overall + let criticalFailure = false; + + try { + // Step 1: Create Leantime project (Consider this a critical integration) + try { + console.log('Starting Leantime project creation...'); + leantimeProjectId = await this.leantimeService.createProject(mission); + console.log(`Leantime project created with ID: ${leantimeProjectId}`); + integrationStatus.leantime.success = true; + integrationStatus.leantime.id = leantimeProjectId; + } catch (leantimeError) { + console.error('Error creating Leantime project:', leantimeError); + integrationStatus.leantime.success = false; + integrationStatus.leantime.error = leantimeError instanceof Error ? leantimeError.message : String(leantimeError); + criticalFailure = true; + throw leantimeError; // Leantime is critical, so we rethrow + } + + // Add a delay to avoid rate limits (extended to 3 seconds) + console.log('Waiting 3 seconds before proceeding to Outline integration...'); + await new Promise(resolve => setTimeout(resolve, 3000)); + + // Step 2: Create Outline collection (Consider this non-critical) + try { + console.log('Starting Outline collection creation...'); + outlineCollectionId = await this.outlineService.createCollection(mission); + console.log(`Outline collection created with ID: ${outlineCollectionId}`); + integrationStatus.outline.success = true; + integrationStatus.outline.id = outlineCollectionId; + } catch (outlineError) { + console.error('Error creating Outline collection:', outlineError); + integrationStatus.outline.success = false; + integrationStatus.outline.error = outlineError instanceof Error ? outlineError.message : String(outlineError); + + // Check if it's an authentication error (401) + if (axios.isAxiosError(outlineError) && outlineError.response?.status === 401) { + console.log('⚠️ Outline authentication error. Please check your API credentials.'); + } else if (axios.isAxiosError(outlineError) && outlineError.response?.status === 429) { + console.log('⚠️ Outline rate limiting error (429). The integration will be skipped for now.'); + } + + // Don't set criticalFailure - Outline is non-critical + } + + // Add a delay to avoid rate limits (extended to 3 seconds) + console.log('Waiting 3 seconds before proceeding to Rocket.Chat integration...'); + await new Promise(resolve => setTimeout(resolve, 3000)); + + // Step 3: Create Rocket.Chat channel (Consider this non-critical) + try { + console.log('Starting Rocket.Chat channel creation...'); + rocketChatChannelId = await this.rocketChatService.createChannel(mission); + console.log(`Rocket.Chat channel created with ID: ${rocketChatChannelId}`); + integrationStatus.rocketchat.success = true; + integrationStatus.rocketchat.id = rocketChatChannelId; + } catch (rocketChatError) { + console.error('Error creating Rocket.Chat channel:', rocketChatError); + integrationStatus.rocketchat.success = false; + integrationStatus.rocketchat.error = rocketChatError instanceof Error ? rocketChatError.message : String(rocketChatError); + + // Check for rate limiting + if (axios.isAxiosError(rocketChatError) && rocketChatError.response?.status === 429) { + console.log('⚠️ Rocket.Chat rate limiting error (429). The integration will be skipped for now.'); + } + // Don't set criticalFailure - Rocket.Chat is non-critical + } + + // Update the mission with the integration IDs (only for successful integrations) + try { + // Use raw SQL or updateMany if the Prisma client doesn't recognize the fields + await prisma.$executeRaw` + UPDATE "Mission" + SET "leantimeProjectId" = ${integrationStatus.leantime.success ? leantimeProjectId?.toString() : null}, + "outlineCollectionId" = ${integrationStatus.outline.success ? outlineCollectionId : null}, + "rocketChatChannelId" = ${integrationStatus.rocketchat.success ? rocketChatChannelId : null} + WHERE "id" = ${missionId} + `; + + console.log('Successfully updated mission with integration IDs'); + } catch (updateError) { + console.error('Error updating mission with integration IDs:', updateError); + // This isn't critical, so don't fail the overall process + } + + // Output a summary of integration results + console.log('Integration results:', JSON.stringify(integrationStatus, null, 2)); + + // If we get here without a critical failure, we consider it a success + // even if some non-critical integrations failed + return { + success: !criticalFailure, + data: { + leantimeProjectId, + outlineCollectionId, + rocketChatChannelId + } + }; + } catch (error) { + console.error('Error setting up integrations:', error); + + // Rollback any created resources + await this.rollbackIntegrations(leantimeProjectId, outlineCollectionId, rocketChatChannelId); + + // Delete the mission itself since a critical integration failed + try { + await prisma.mission.delete({ + where: { id: missionId } + }); + console.log(`Mission ${missionId} deleted due to critical integration failure.`); + } catch (deleteError) { + console.error('Error deleting mission after integration failure:', deleteError); + } + + return { + success: false, + error: `Integration failed: ${error instanceof Error ? error.message : String(error)}` + }; + } + } catch (error) { + console.error('Error in integration setup:', error); + return { + success: false, + error: `Integration setup failed: ${error instanceof Error ? error.message : String(error)}` + }; + } + } + + /** + * Rollback integrations if any step fails + * @param leantimeProjectId The Leantime project ID to delete + * @param outlineCollectionId The Outline collection ID to delete + * @param rocketChatChannelId The Rocket.Chat channel ID to delete + */ + private async rollbackIntegrations( + leantimeProjectId?: number, + outlineCollectionId?: string, + rocketChatChannelId?: string + ): Promise { + console.log('⚠️ Rolling back integrations due to an error...'); + + // Track what we've successfully rolled back + const rollbackStatuses = { + leantime: false, + outline: false, + rocketchat: false + }; + + try { + // Attempt to delete Leantime project + if (leantimeProjectId) { + try { + console.log(`Attempting to delete Leantime project: ${leantimeProjectId}`); + const leantimeSuccess = await this.leantimeService.deleteProject(leantimeProjectId); + rollbackStatuses.leantime = leantimeSuccess; + console.log(`Leantime project deletion ${leantimeSuccess ? 'successful' : 'failed'}: ${leantimeProjectId}`); + + // Add a longer delay to avoid rate limiting (3 seconds) + console.log('Waiting 3 seconds before next rollback operation...'); + await new Promise(resolve => setTimeout(resolve, 3000)); + } catch (leantimeError) { + console.error('Error during Leantime rollback:', leantimeError); + console.log(`⚠️ Note: Leantime project ${leantimeProjectId} may need to be deleted manually`); + } + } + + // Attempt to delete Outline collection + if (outlineCollectionId) { + try { + console.log(`Attempting to delete Outline collection: ${outlineCollectionId}`); + const outlineSuccess = await this.outlineService.deleteCollection(outlineCollectionId); + rollbackStatuses.outline = outlineSuccess; + console.log(`Outline collection deletion ${outlineSuccess ? 'successful' : 'failed'}: ${outlineCollectionId}`); + + // Add a longer delay to avoid rate limiting (3 seconds) + console.log('Waiting 3 seconds before next rollback operation...'); + await new Promise(resolve => setTimeout(resolve, 3000)); + } catch (outlineError) { + console.error('Error during Outline rollback:', outlineError); + console.log(`⚠️ Note: Outline collection ${outlineCollectionId} may need to be deleted manually`); + } + } + + // Attempt to delete Rocket.Chat channel + if (rocketChatChannelId) { + try { + console.log(`Attempting to delete Rocket.Chat channel: ${rocketChatChannelId}`); + const rocketChatSuccess = await this.rocketChatService.deleteChannel(rocketChatChannelId); + rollbackStatuses.rocketchat = rocketChatSuccess; + console.log(`Rocket.Chat channel deletion ${rocketChatSuccess ? 'successful' : 'failed'}: ${rocketChatChannelId}`); + } catch (rocketChatError) { + console.error('Error during Rocket.Chat rollback:', rocketChatError); + console.log(`⚠️ Note: Rocket.Chat channel ${rocketChatChannelId} may need to be deleted manually`); + } + } + + // Provide a summary of rollback operations + console.log('Rollback summary:', JSON.stringify(rollbackStatuses)); + + // If any rollbacks failed, provide a note + if (!rollbackStatuses.leantime || !rollbackStatuses.outline || !rollbackStatuses.rocketchat) { + console.log('⚠️ Some resources may need to be deleted manually.'); + } + } catch (error) { + console.error('Error during rollback:', error); + console.log('⚠️ Resources may need to be deleted manually:'); + if (leantimeProjectId) console.log(`- Leantime project: ${leantimeProjectId}`); + if (outlineCollectionId) console.log(`- Outline collection: ${outlineCollectionId}`); + if (rocketChatChannelId) console.log(`- Rocket.Chat channel: ${rocketChatChannelId}`); + } + } +} \ No newline at end of file diff --git a/lib/services/outline-service-fixed.ts b/lib/services/outline-service-fixed.ts new file mode 100644 index 00000000..6c8b1abc --- /dev/null +++ b/lib/services/outline-service-fixed.ts @@ -0,0 +1,183 @@ +import axios from 'axios'; + +export class OutlineService { + private apiUrl: string; + private apiToken: string; + + constructor() { + this.apiUrl = process.env.OUTLINE_API_URL || 'https://app.getoutline.com/api'; + this.apiToken = process.env.OUTLINE_API_KEY || ''; + } + + /** + * Create a new collection in Outline + * @param mission The mission data + * @returns Collection ID or throws error + */ + async createCollection(mission: any): Promise { + try { + // Log the API details for debugging + console.log('Creating Outline collection with token length:', this.apiToken ? this.apiToken.length : 0); + console.log('Outline API URL:', this.apiUrl); + + const response = await axios.post( + `${this.apiUrl}/collections.create`, + { + name: mission.name, + description: mission.intention || '', + permission: 'read', + private: true + }, + { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiToken}` + } + } + ); + + if (!response.data || !response.data.data || !response.data.data.id) { + throw new Error(`Failed to create Outline collection: ${JSON.stringify(response.data)}`); + } + + const collectionId = response.data.data.id; + console.log(`Created Outline collection with ID: ${collectionId}`); + + // Assign users to the collection + if (mission.missionUsers && mission.missionUsers.length > 0) { + await this.assignUsersToCollection(collectionId, mission.missionUsers); + } + + return collectionId; + } catch (error) { + if (axios.isAxiosError(error) && error.response) { + console.error('Outline API Error Details:', { + status: error.response.status, + statusText: error.response.statusText, + data: error.response.data + }); + } + console.error('Error creating Outline collection:', error); + throw new Error(`Outline integration failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Assign mission users to the Outline collection + * @param collectionId The Outline collection ID + * @param missionUsers The mission users with roles + */ + async assignUsersToCollection(collectionId: string, missionUsers: any[]): Promise { + try { + for (const missionUser of missionUsers) { + // Get the user in Outline + const outlineUserId = await this.getUserByEmail(missionUser.user.email); + if (!outlineUserId) { + console.warn(`User not found in Outline: ${missionUser.user.email}`); + continue; + } + + // Determine permission (Gardien de la Mémoire gets admin, others get read) + const permission = missionUser.role === 'gardien-memoire' ? 'admin' : 'read'; + + // Add the user to the collection + await this.addUserToCollection(collectionId, outlineUserId, permission); + } + } catch (error) { + console.error('Error assigning users to collection:', error); + // Continue even if some user assignments fail + } + } + + /** + * Add a user to a collection with the specified permission + * @param collectionId The Outline collection ID + * @param userId The Outline user ID + * @param permission The permission to assign + */ + async addUserToCollection(collectionId: string, userId: string, permission: 'read' | 'admin'): Promise { + try { + const response = await axios.post( + `${this.apiUrl}/collections.add_user`, + { + id: collectionId, + userId: userId, + permission: permission + }, + { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiToken}` + } + } + ); + + if (!response.data || !response.data.success) { + throw new Error(`Failed to add user to collection: ${JSON.stringify(response.data)}`); + } + + console.log(`Added user ${userId} to collection ${collectionId} with permission ${permission}`); + } catch (error) { + console.error(`Error adding user ${userId} to collection ${collectionId}:`, error); + // Don't fail if individual user assignment fails + } + } + + /** + * 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 { + const response = await axios.post( + `${this.apiUrl}/users.info`, + { + email: email + }, + { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiToken}` + } + } + ); + + if (!response.data || !response.data.data || !response.data.data.id) { + return null; + } + + return response.data.data.id; + } catch (error) { + console.error('Error getting user by email:', error); + return null; + } + } + + /** + * Delete a collection from Outline + * @param collectionId The Outline collection ID to delete + * @returns True if successful, false otherwise + */ + async deleteCollection(collectionId: string): Promise { + try { + const response = await axios.post( + `${this.apiUrl}/collections.delete`, + { + id: collectionId + }, + { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiToken}` + } + } + ); + + return response.data && response.data.success === true; + } catch (error) { + console.error(`Error deleting Outline collection ${collectionId}:`, error); + return false; + } + } +} \ No newline at end of file diff --git a/lib/services/rocketchat-service-fixed.ts b/lib/services/rocketchat-service-fixed.ts new file mode 100644 index 00000000..a54e27d7 --- /dev/null +++ b/lib/services/rocketchat-service-fixed.ts @@ -0,0 +1,221 @@ +import axios from 'axios'; + +export class RocketChatService { + private apiUrl: string; + private authToken: string; + private userId: string; + + constructor() { + // Extract the base URL from the iframe URL + const baseUrl = process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL?.split('/channel')[0] || ''; + this.apiUrl = baseUrl; + this.authToken = process.env.ROCKET_CHAT_TOKEN || ''; + this.userId = process.env.ROCKET_CHAT_USER_ID || ''; + } + + /** + * Create a new channel in Rocket.Chat + * @param mission The mission data + * @returns Channel ID or throws error + */ + async createChannel(mission: any): Promise { + try { + // First, get all mission users that need to be added to the channel + const missionUserEmails = mission.missionUsers.map((mu: any) => mu.user.email); + const rocketChatUsernames: string[] = []; + + for (const email of missionUserEmails) { + const user = await this.getUserByEmail(email); + if (user) { + rocketChatUsernames.push(user.username); + } else { + console.warn(`User not found in Rocket.Chat: ${email}`); + } + } + + // Sanitize the channel name to comply with Rocket.Chat restrictions + const channelName = this.sanitizeChannelName(mission.name); + + // Create the channel + const response = await axios.post( + `${this.apiUrl}/api/v1/channels.create`, + { + name: channelName, + members: rocketChatUsernames, + readOnly: false + }, + { + headers: { + 'Content-Type': 'application/json', + 'X-Auth-Token': this.authToken, + 'X-User-Id': this.userId + } + } + ); + + if (!response.data || !response.data.success) { + throw new Error(`Failed to create Rocket.Chat channel: ${JSON.stringify(response.data)}`); + } + + const channelId = response.data.channel._id; + console.log(`Created Rocket.Chat channel with ID: ${channelId}`); + + // Make "Gardien de la Parole" users channel admins + await this.setChannelAdmins(channelId, mission.missionUsers); + + return channelId; + } catch (error) { + console.error('Error creating Rocket.Chat channel:', error); + throw new Error(`Rocket.Chat integration failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Make "Gardien de la Parole" users channel admins + * @param channelId The Rocket.Chat channel ID + * @param missionUsers The mission users with roles + */ + async setChannelAdmins(channelId: string, missionUsers: any[]): Promise { + try { + // Find "Gardien de la Parole" users + const gardienParoleUsers = missionUsers.filter((mu: any) => mu.role === 'gardien-parole'); + + for (const gardienUser of gardienParoleUsers) { + const user = await this.getUserByEmail(gardienUser.user.email); + if (user) { + await this.makeUserChannelOwner(channelId, user._id); + } + } + } catch (error) { + console.error('Error setting channel admins:', error); + // Don't fail if setting admins fails + } + } + + /** + * Make a user a channel owner + * @param channelId The Rocket.Chat channel ID + * @param userId The Rocket.Chat user ID + */ + async makeUserChannelOwner(channelId: string, userId: string): Promise { + try { + const response = await axios.post( + `${this.apiUrl}/api/v1/channels.addOwner`, + { + roomId: channelId, + userId: userId + }, + { + headers: { + 'Content-Type': 'application/json', + 'X-Auth-Token': this.authToken, + 'X-User-Id': this.userId + } + } + ); + + if (!response.data || !response.data.success) { + throw new Error(`Failed to make user channel owner: ${JSON.stringify(response.data)}`); + } + + console.log(`Made user ${userId} an owner of channel ${channelId}`); + } catch (error) { + console.error(`Error making user ${userId} channel owner:`, error); + // Don't fail if individual user assignment fails + } + } + + /** + * Get a user by email + * @param email The user email to search for + * @returns The user object or null if not found + */ + async getUserByEmail(email: string): Promise { + try { + // First try exact email match + const response = await axios.get( + `${this.apiUrl}/api/v1/users.list`, + { + headers: { + 'X-Auth-Token': this.authToken, + 'X-User-Id': this.userId + } + } + ); + + if (!response.data || !response.data.success) { + throw new Error(`Failed to get users: ${JSON.stringify(response.data)}`); + } + + // First check if user exists by email + const userByEmail = response.data.users.find((user: any) => + user.emails?.some((e: any) => e.address === email) + ); + + if (userByEmail) { + return userByEmail; + } + + // If not found by email, try by username (username might be the part before @) + const username = email.split('@')[0]; + const userByUsername = response.data.users.find((user: any) => user.username === username); + + return userByUsername || null; + } catch (error) { + console.error('Error getting user by email:', error); + return null; + } + } + + /** + * Sanitize a channel name to comply with Rocket.Chat restrictions + * @param name The original name + * @returns The sanitized name + */ + private sanitizeChannelName(name: string): string { + // Replace spaces and invalid characters with hyphens + // Channel names must match regex [0-9a-zA-Z-_.]+ + let sanitized = name.replace(/[^0-9a-zA-Z-_.]/g, '-'); + + // Ensure no sequential hyphens + sanitized = sanitized.replace(/-+/g, '-'); + + // Trim hyphens from beginning and end + sanitized = sanitized.replace(/^-+|-+$/g, ''); + + // Ensure name is not empty after sanitization + if (!sanitized) { + sanitized = 'mission-channel'; + } + + return sanitized.toLowerCase(); + } + + /** + * Delete a channel from Rocket.Chat + * @param channelId The Rocket.Chat channel ID to delete + * @returns True if successful, false otherwise + */ + async deleteChannel(channelId: string): Promise { + try { + const response = await axios.post( + `${this.apiUrl}/api/v1/channels.delete`, + { + roomId: channelId + }, + { + headers: { + 'Content-Type': 'application/json', + 'X-Auth-Token': this.authToken, + 'X-User-Id': this.userId + } + } + ); + + return response.data && response.data.success === true; + } catch (error) { + console.error(`Error deleting Rocket.Chat channel ${channelId}:`, error); + return false; + } + } +} \ No newline at end of file