diff --git a/app/api/missions/[missionId]/close/route.ts b/app/api/missions/[missionId]/close/route.ts new file mode 100644 index 00000000..4df5c57d --- /dev/null +++ b/app/api/missions/[missionId]/close/route.ts @@ -0,0 +1,157 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from "@/app/api/auth/options"; +import { prisma } from '@/lib/prisma'; +import { logger } from '@/lib/logger'; + +/** + * POST /api/missions/[missionId]/close + * + * Closes a mission by calling N8N webhook to close it in external services + * and marking it as closed in the database. + */ +export async function POST( + request: Request, + props: { params: Promise<{ missionId: string }> } +) { + const params = await props.params; + + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { missionId } = params; + if (!missionId) { + return NextResponse.json({ error: 'Mission ID is required' }, { status: 400 }); + } + + // Get the mission with all details needed for N8N + const mission = await prisma.mission.findUnique({ + where: { id: missionId }, + include: { + missionUsers: { + include: { + user: true + } + } + } + }); + + if (!mission) { + return NextResponse.json({ error: 'Mission not found' }, { status: 404 }); + } + + // Check if user has permission (creator or admin) + const isCreator = mission.creatorId === session.user.id; + const userRoles = Array.isArray(session.user.role) ? session.user.role : []; + const isAdmin = userRoles.includes('admin') || userRoles.includes('ADMIN'); + + if (!isCreator && !isAdmin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + // Check if already closed + if ((mission as any).isClosed) { + return NextResponse.json({ error: 'Mission is already closed' }, { status: 400 }); + } + + // Extract repo name from giteaRepositoryUrl if present + let repoName = ''; + if (mission.giteaRepositoryUrl) { + try { + const url = new URL(mission.giteaRepositoryUrl); + const pathParts = url.pathname.split('/').filter(Boolean); + repoName = pathParts[pathParts.length - 1] || ''; + logger.debug('Extracted repo name from URL', { repoName }); + } catch (error) { + const match = mission.giteaRepositoryUrl.match(/\/([^\/]+)\/?$/); + repoName = match ? match[1] : ''; + } + } + + // Prepare data for N8N webhook (same format as deletion) + const n8nCloseData = { + 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, + action: 'close' // Indicate this is a close action, not delete + }; + + logger.debug('Calling N8N NeahMissionClose webhook', { + missionId: mission.id, + missionName: mission.name, + hasRepoName: !!repoName + }); + + // Call N8N webhook + const webhookUrl = process.env.N8N_CLOSE_MISSION_WEBHOOK_URL || 'https://brain.slm-lab.net/webhook/NeahMissionClose'; + const apiKey = process.env.N8N_API_KEY || ''; + + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey + }, + body: JSON.stringify(n8nCloseData), + }); + + logger.debug('N8N close webhook response', { status: response.status }); + + if (!response.ok) { + const errorText = await response.text(); + logger.error('N8N close webhook error', { + status: response.status, + error: errorText.substring(0, 200) + }); + // Continue with closing even if N8N fails (non-blocking) + logger.warn('Continuing with mission close despite N8N error'); + } + + // Mark mission as closed in database + // Using 'as any' until prisma generate is run + const updatedMission = await (prisma.mission as any).update({ + where: { id: missionId }, + data: { + isClosed: true, + closedAt: new Date() + } + }); + + logger.debug('Mission closed successfully', { + missionId: updatedMission.id, + closedAt: updatedMission.closedAt + }); + + return NextResponse.json({ + success: true, + message: 'Mission closed successfully', + mission: { + id: updatedMission.id, + name: updatedMission.name, + isClosed: updatedMission.isClosed, + closedAt: updatedMission.closedAt + } + }); + + } catch (error) { + logger.error('Error closing mission', { + error: error instanceof Error ? error.message : String(error), + missionId: params.missionId + }); + return NextResponse.json( + { error: 'Failed to close mission', details: error instanceof Error ? error.message : String(error) }, + { status: 500 } + ); + } +} + diff --git a/app/missions/[missionId]/page.tsx b/app/missions/[missionId]/page.tsx index 92c1a637..902489ec 100644 --- a/app/missions/[missionId]/page.tsx +++ b/app/missions/[missionId]/page.tsx @@ -16,7 +16,9 @@ import { Sparkles, Save, Loader2, - FileText + FileText, + Lock, + CheckCircle } from "lucide-react"; import { useToast } from "@/components/ui/use-toast"; import { useParams, useRouter } from "next/navigation"; @@ -54,6 +56,8 @@ interface Mission { attachments?: Attachment[]; actionPlan?: string | null; actionPlanGeneratedAt?: string | null; + isClosed?: boolean; + closedAt?: string | null; createdAt: string; creator: User; missionUsers: any[]; @@ -63,6 +67,7 @@ export default function MissionDetailPage() { const [mission, setMission] = useState(null); const [loading, setLoading] = useState(true); const [deleting, setDeleting] = useState(false); + const [closing, setClosing] = useState(false); const [generatingPlan, setGeneratingPlan] = useState(false); const [savingPlan, setSavingPlan] = useState(false); const [editedPlan, setEditedPlan] = useState(""); @@ -215,6 +220,48 @@ export default function MissionDetailPage() { } }; + // Handle close mission + const handleCloseMission = async () => { + if (!confirm("Êtes-vous sûr de vouloir clôturer cette mission ? Cette action fermera la mission dans tous les services externes.")) { + return; + } + + try { + setClosing(true); + const response = await fetch(`/api/missions/${missionId}/close`, { + method: 'POST', + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to close mission'); + } + + const data = await response.json(); + + // Update local state + setMission(prev => prev ? { + ...prev, + isClosed: true, + closedAt: data.mission.closedAt + } : null); + + toast({ + title: "Mission clôturée", + description: "La mission a été clôturée avec succès dans tous les services", + }); + } catch (error) { + console.error('Error closing mission:', error); + toast({ + title: "Erreur", + description: error instanceof Error ? error.message : "Impossible de clôturer la mission", + variant: "destructive", + }); + } finally { + setClosing(false); + } + }; + // Handle generate action plan const handleGeneratePlan = async () => { try { @@ -520,21 +567,56 @@ export default function MissionDetailPage() { )} - {/* Delete Button */} -
- )} - Supprimer la mission - + + {/* Delete Button */} + +
diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 06c0d77d..98294da5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -134,6 +134,8 @@ model Mission { profils String[] // Level / Profils actionPlan String? // Generated action plan from LLM (stored as text/markdown) actionPlanGeneratedAt DateTime? // When the action plan was generated + isClosed Boolean @default(false) // Whether the mission is closed + closedAt DateTime? // When the mission was closed createdAt DateTime @default(now()) updatedAt DateTime @updatedAt creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade)