diff --git a/app/api/missions/[missionId]/route.ts b/app/api/missions/[missionId]/route.ts index 9e2620c..9b274ec 100644 --- a/app/api/missions/[missionId]/route.ts +++ b/app/api/missions/[missionId]/route.ts @@ -2,6 +2,29 @@ import { NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from "@/app/api/auth/options"; import { prisma } from '@/lib/prisma'; +import { N8nService } from '@/lib/services/n8n-service'; +import { deleteMissionLogo, deleteMissionAttachment } from '@/lib/mission-uploads'; +import { S3Client, ListObjectsV2Command, DeleteObjectCommand } from '@aws-sdk/client-s3'; +import { logger } from '@/lib/logger'; + +// S3 Configuration for missions - uses environment variables +const MISSIONS_S3_CONFIG = { + endpoint: (process.env.MINIO_S3_UPLOAD_BUCKET_URL || process.env.S3_ENDPOINT || 'https://dome-api.slm-lab.net').replace(/\/$/, ''), + region: process.env.MINIO_AWS_REGION || process.env.S3_REGION || 'us-east-1', + accessKey: process.env.MINIO_ACCESS_KEY || process.env.S3_ACCESS_KEY || '', + secretKey: process.env.MINIO_SECRET_KEY || process.env.S3_SECRET_KEY || '', + bucket: 'missions' +}; + +const missionsS3Client = new S3Client({ + region: MISSIONS_S3_CONFIG.region, + endpoint: MISSIONS_S3_CONFIG.endpoint, + credentials: { + accessKeyId: MISSIONS_S3_CONFIG.accessKey, + secretAccessKey: MISSIONS_S3_CONFIG.secretKey + }, + forcePathStyle: true +}); // GET endpoint to get mission details including creator and missionUsers export async function GET( @@ -66,3 +89,161 @@ export async function GET( ); } } + +// DELETE endpoint to delete a mission +export async function DELETE( + request: Request, + { params }: { params: Promise<{ missionId: string }> } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { missionId } = await params; + const userId = session.user.id; + + // Get mission with all related data + const mission = await prisma.mission.findUnique({ + where: { id: missionId }, + include: { + attachments: true, + missionUsers: true, + calendars: true + } + }); + + if (!mission) { + return NextResponse.json({ error: 'Mission not found' }, { status: 404 }); + } + + // Check if user has permission (only creator can delete) + if (mission.creatorId !== userId) { + return NextResponse.json({ error: 'Forbidden: Only the mission creator can delete the mission' }, { status: 403 }); + } + + logger.debug('Starting mission deletion', { + missionId, + missionName: mission.name, + hasLogo: !!mission.logo, + attachmentsCount: mission.attachments.length + }); + + // Step 1: Delete all files from S3/MinIO + try { + // Delete logo if exists + if (mission.logo) { + try { + await deleteMissionLogo(missionId, mission.logo); + logger.debug('Mission logo deleted from S3'); + } catch (logoError) { + logger.warn('Error deleting logo (continuing)', { + error: logoError instanceof Error ? logoError.message : String(logoError) + }); + } + } + + // Delete all attachments + for (const attachment of mission.attachments) { + try { + await deleteMissionAttachment(attachment.filePath); + logger.debug('Attachment deleted from S3', { filePath: attachment.filePath }); + } catch (attachmentError) { + logger.warn('Error deleting attachment (continuing)', { + filePath: attachment.filePath, + error: attachmentError instanceof Error ? attachmentError.message : String(attachmentError) + }); + } + } + + // Delete all files in the mission folder (cleanup any orphaned files) + try { + const listCommand = new ListObjectsV2Command({ + Bucket: MISSIONS_S3_CONFIG.bucket, + Prefix: `${missionId}/` + }); + const listResponse = await missionsS3Client.send(listCommand); + + if (listResponse.Contents && listResponse.Contents.length > 0) { + const deletePromises = listResponse.Contents.map(obj => { + if (obj.Key) { + return missionsS3Client.send(new DeleteObjectCommand({ + Bucket: MISSIONS_S3_CONFIG.bucket, + Key: obj.Key + })); + } + }); + await Promise.all(deletePromises); + logger.debug('Cleaned up all files in mission folder', { count: listResponse.Contents.length }); + } + } catch (cleanupError) { + logger.warn('Error cleaning up mission folder (continuing)', { + error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError) + }); + } + } catch (s3Error) { + logger.error('Error deleting files from S3 (continuing with database deletion)', { + error: s3Error instanceof Error ? s3Error.message : String(s3Error) + }); + } + + // Step 2: Extract repo name for N8N + let repoName = ''; + if (mission.giteaRepositoryUrl) { + try { + const url = new URL(mission.giteaRepositoryUrl); + const pathParts = url.pathname.split('/').filter(Boolean); + repoName = pathParts[pathParts.length - 1] || ''; + } catch (error) { + const match = mission.giteaRepositoryUrl.match(/\/([^\/]+)\/?$/); + repoName = match ? match[1] : ''; + } + } + + // Step 3: Call N8N deletion webhook (non-blocking) + const n8nService = new N8nService(); + const n8nData = { + missionId: mission.id, + name: mission.name, + repoName: repoName, + leantimeProjectId: mission.leantimeProjectId || 0, + documentationCollectionId: mission.outlineCollectionId || '', + rocketchatChannelId: mission.rocketChatChannelId || '', + giteaRepositoryUrl: mission.giteaRepositoryUrl, + outlineCollectionId: mission.outlineCollectionId, + rocketChatChannelId: mission.rocketChatChannelId, + penpotProjectId: mission.penpotProjectId + }; + + // Call N8N but don't fail if it errors + n8nService.triggerMissionDeletion(n8nData).catch(n8nError => { + logger.error('N8N deletion webhook failed (mission still deleted from database)', { + error: n8nError instanceof Error ? n8nError.message : String(n8nError), + missionId + }); + }); + + // Step 4: Delete from database (cascade will handle related records) + await prisma.mission.delete({ + where: { id: missionId } + }); + + logger.debug('Mission deleted successfully', { missionId }); + + return NextResponse.json({ + success: true, + message: 'Mission deleted successfully' + }); + } catch (error: any) { + const { missionId: errorMissionId } = await params; + logger.error('Error deleting mission', { + error: error instanceof Error ? error.message : String(error), + missionId: errorMissionId + }); + return NextResponse.json( + { error: 'Failed to delete mission', details: error.message }, + { status: 500 } + ); + } +} diff --git a/app/api/missions/image/[...path]/route.ts b/app/api/missions/image/[...path]/route.ts index 05ea9da..a14e024 100644 --- a/app/api/missions/image/[...path]/route.ts +++ b/app/api/missions/image/[...path]/route.ts @@ -91,15 +91,29 @@ export async function GET( return new NextResponse(response.Body as any, { headers }); } catch (error) { + // Check if it's a NoSuchKey error (file doesn't exist) + const isNoSuchKey = error instanceof NoSuchKey || + (error instanceof Error && error.name === 'NoSuchKey') || + (error instanceof Error && error.message.includes('does not exist')); + logger.error('Error fetching file from Minio', { error: error instanceof Error ? error.message : String(error), path: filePath, minioPath, - bucket: 'missions', - errorType: error instanceof NoSuchKey ? 'NoSuchKey' : 'Unknown' + bucket: MISSIONS_S3_CONFIG.bucket, + errorType: isNoSuchKey ? 'NoSuchKey' : 'Unknown', + errorName: error instanceof Error ? error.name : typeof error }); - if (error instanceof NoSuchKey) { - return new NextResponse('File not found', { status: 404 }); + + if (isNoSuchKey) { + // Return 404 with proper headers for image requests + return new NextResponse('File not found', { + status: 404, + headers: { + 'Content-Type': 'text/plain', + 'Cache-Control': 'no-cache' + } + }); } return new NextResponse('Internal Server Error', { status: 500 }); }