From 9fae87e0ab992b54d4922000ee01fc64ffeb774b Mon Sep 17 00:00:00 2001 From: alma Date: Fri, 9 Jan 2026 12:13:23 +0100 Subject: [PATCH] Mission Refactor Big --- .../[missionId]/generate-plan/route.ts | 214 +++++++ app/missions/[missionId]/page.tsx | 556 ++++++++++++------ prisma/schema.prisma | 2 + 3 files changed, 604 insertions(+), 168 deletions(-) create mode 100644 app/api/missions/[missionId]/generate-plan/route.ts diff --git a/app/api/missions/[missionId]/generate-plan/route.ts b/app/api/missions/[missionId]/generate-plan/route.ts new file mode 100644 index 00000000..6954f310 --- /dev/null +++ b/app/api/missions/[missionId]/generate-plan/route.ts @@ -0,0 +1,214 @@ +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]/generate-plan + * + * Generates an action plan by calling N8N webhook which uses an LLM. + * Saves the generated plan to the mission. + */ +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 + const mission = await prisma.mission.findUnique({ + where: { id: missionId } + }); + + 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 }); + } + + // Prepare data for N8N webhook + const webhookData = { + name: mission.name, + oddScope: mission.oddScope, + niveau: mission.niveau, + intention: mission.intention, + missionType: mission.missionType, + donneurDOrdre: mission.donneurDOrdre, + projection: mission.projection, + services: mission.services, + participation: mission.participation, + profils: mission.profils, + }; + + logger.debug('Calling N8N GeneratePlan webhook', { + missionId, + missionName: mission.name + }); + + // Call N8N webhook + const webhookUrl = process.env.N8N_GENERATE_PLAN_WEBHOOK_URL || 'https://brain.slm-lab.net/webhook/GeneratePlan'; + 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(webhookData), + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.error('N8N GeneratePlan webhook error', { + status: response.status, + error: errorText.substring(0, 200) + }); + throw new Error(`Failed to generate plan: ${response.status}`); + } + + // Parse the response + const responseText = await response.text(); + let actionPlan: string; + + try { + const result = JSON.parse(responseText); + // The LLM response might be in different formats + actionPlan = result.plan || result.actionPlan || result.content || result.text || responseText; + } catch { + // If not JSON, use the raw text + actionPlan = responseText; + } + + logger.debug('Received action plan from N8N', { + missionId, + planLength: actionPlan.length + }); + + // Save the action plan to the mission + const updatedMission = await prisma.mission.update({ + where: { id: missionId }, + data: { + actionPlan: actionPlan, + actionPlanGeneratedAt: new Date() + } + }); + + logger.debug('Action plan saved successfully', { + missionId, + generatedAt: updatedMission.actionPlanGeneratedAt + }); + + return NextResponse.json({ + success: true, + actionPlan: updatedMission.actionPlan, + generatedAt: updatedMission.actionPlanGeneratedAt + }); + + } catch (error) { + logger.error('Error generating action plan', { + error: error instanceof Error ? error.message : String(error), + missionId: params.missionId + }); + return NextResponse.json( + { error: 'Failed to generate action plan', details: error instanceof Error ? error.message : String(error) }, + { status: 500 } + ); + } +} + +/** + * PUT /api/missions/[missionId]/generate-plan + * + * Updates the action plan (allows manual editing by creator) + */ +export async function PUT( + 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 }); + } + + const body = await request.json(); + const { actionPlan } = body; + + if (typeof actionPlan !== 'string') { + return NextResponse.json({ error: 'actionPlan must be a string' }, { status: 400 }); + } + + // Get the mission + const mission = await prisma.mission.findUnique({ + where: { id: missionId } + }); + + 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 }); + } + + // Update the action plan + const updatedMission = await prisma.mission.update({ + where: { id: missionId }, + data: { + actionPlan: actionPlan + } + }); + + logger.debug('Action plan updated successfully', { + missionId + }); + + return NextResponse.json({ + success: true, + actionPlan: updatedMission.actionPlan + }); + + } catch (error) { + logger.error('Error updating action plan', { + error: error instanceof Error ? error.message : String(error), + missionId: params.missionId + }); + return NextResponse.json( + { error: 'Failed to update action plan', 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 9d4709e8..168902aa 100644 --- a/app/missions/[missionId]/page.tsx +++ b/app/missions/[missionId]/page.tsx @@ -2,7 +2,22 @@ import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; -import { FileIcon, Calendar, Eye, MapPin, Users, Clock, ThumbsUp, Languages, BarChart, Edit, Trash2 } from "lucide-react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; +import { + FileIcon, + Calendar, + MapPin, + Users, + Clock, + ThumbsUp, + Languages, + Trash2, + Sparkles, + Save, + Loader2, + FileText +} from "lucide-react"; import { useToast } from "@/components/ui/use-toast"; import { useParams, useRouter } from "next/navigation"; @@ -37,6 +52,8 @@ interface Mission { services?: string[]; profils?: string[]; attachments?: Attachment[]; + actionPlan?: string | null; + actionPlanGeneratedAt?: string | null; createdAt: string; creator: User; missionUsers: any[]; @@ -46,6 +63,11 @@ export default function MissionDetailPage() { const [mission, setMission] = useState(null); const [loading, setLoading] = useState(true); const [deleting, setDeleting] = useState(false); + const [generatingPlan, setGeneratingPlan] = useState(false); + const [savingPlan, setSavingPlan] = useState(false); + const [editedPlan, setEditedPlan] = useState(""); + const [isPlanModified, setIsPlanModified] = useState(false); + const [activeTab, setActiveTab] = useState("general"); const { toast } = useToast(); const params = useParams(); const router = useRouter(); @@ -63,6 +85,7 @@ export default function MissionDetailPage() { const data = await response.json(); console.log("Mission details:", data); setMission(data); + setEditedPlan(data.actionPlan || ""); } catch (error) { console.error('Error fetching mission details:', error); toast({ @@ -80,6 +103,13 @@ export default function MissionDetailPage() { } }, [missionId, toast]); + // Track if plan has been modified + useEffect(() => { + if (mission) { + setIsPlanModified(editedPlan !== (mission.actionPlan || "")); + } + }, [editedPlan, mission]); + // Helper function to format date const formatDate = (dateString: string) => { const date = new Date(dateString); @@ -119,13 +149,29 @@ export default function MissionDetailPage() { } }; + const getDonneurDOrdreLabel = (donneurDOrdre: string) => { + switch(donneurDOrdre) { + case 'individual': return 'Individu'; + case 'group': return 'ONG'; + case 'organization': return 'Start-ups'; + default: return donneurDOrdre; + } + }; + + const getParticipationLabel = (participation: string) => { + switch(participation) { + case 'volontaire': return 'Volontaire'; + case 'cooptation': return 'Cooptation'; + default: return participation; + } + }; + // Function to get odd info const getODDInfo = (oddScope: string[]) => { const oddCode = oddScope && oddScope.length > 0 ? oddScope[0] : null; - // Extract number from odd code (e.g., "odd-3" -> "3") const oddNumber = oddCode ? oddCode.replace('odd-', '') : null; return { @@ -134,11 +180,6 @@ export default function MissionDetailPage() { iconPath: oddNumber ? `/F SDG Icons 2019 WEB/F-WEB-Goal-${oddNumber.padStart(2, '0')}.png` : "" }; }; - - // Handle edit mission - const handleEditMission = () => { - router.push(`/missions/${missionId}/edit`); - }; // Handle delete mission const handleDeleteMission = async () => { @@ -161,7 +202,6 @@ export default function MissionDetailPage() { description: "La mission a été supprimée avec succès", }); - // Redirect back to missions list router.push('/missions'); } catch (error) { console.error('Error deleting mission:', error); @@ -175,6 +215,86 @@ export default function MissionDetailPage() { } }; + // Handle generate action plan + const handleGeneratePlan = async () => { + try { + setGeneratingPlan(true); + + const response = await fetch(`/api/missions/${missionId}/generate-plan`, { + method: 'POST', + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to generate plan'); + } + + const data = await response.json(); + + setEditedPlan(data.actionPlan || ""); + setMission(prev => prev ? { + ...prev, + actionPlan: data.actionPlan, + actionPlanGeneratedAt: data.generatedAt + } : null); + + toast({ + title: "Plan d'action généré", + description: "Le plan d'action a été généré avec succès par l'IA", + }); + } catch (error) { + console.error('Error generating plan:', error); + toast({ + title: "Erreur", + description: error instanceof Error ? error.message : "Impossible de générer le plan d'action", + variant: "destructive", + }); + } finally { + setGeneratingPlan(false); + } + }; + + // Handle save action plan + const handleSavePlan = async () => { + try { + setSavingPlan(true); + + const response = await fetch(`/api/missions/${missionId}/generate-plan`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ actionPlan: editedPlan }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to save plan'); + } + + setMission(prev => prev ? { + ...prev, + actionPlan: editedPlan + } : null); + + setIsPlanModified(false); + + toast({ + title: "Plan sauvegardé", + description: "Le plan d'action a été sauvegardé avec succès", + }); + } catch (error) { + console.error('Error saving plan:', error); + toast({ + title: "Erreur", + description: error instanceof Error ? error.message : "Impossible de sauvegarder le plan d'action", + variant: "destructive", + }); + } finally { + setSavingPlan(false); + } + }; + // Loading state if (loading) { return ( @@ -206,7 +326,7 @@ export default function MissionDetailPage() { return (
- {/* Header */} + {/* Header with Name and Logo */}
@@ -219,7 +339,7 @@ export default function MissionDetailPage() {
- {/* Display logo instead of Participate button */} + {/* Logo */}
{mission.logoUrl ? ( {mission.name} { - console.error("Logo failed to load:", { - missionId: mission.id, - missionName: mission.name, - logoUrl: mission.logoUrl, - logoPath: mission.logo - }); - // Show placeholder on error (e.currentTarget as HTMLImageElement).style.display = 'none'; const parent = e.currentTarget.parentElement; if (parent) { - parent.classList.add('bg-gray-100'); - parent.classList.add('flex'); - parent.classList.add('items-center'); - parent.classList.add('justify-center'); + parent.classList.add('bg-gray-100', 'flex', 'items-center', 'justify-center'); parent.innerHTML = `${mission.name.slice(0, 2).toUpperCase()}`; } }} @@ -254,161 +364,271 @@ export default function MissionDetailPage() {
- {/* Info Grid */} -
-
-
- -
-
-

Type de mission

-

{getMissionTypeLabel(mission.missionType)}

-
-
- -
-
- -
-
-

Donneur d'ordre

-

{mission.donneurDOrdre || "Non spécifié"}

-
-
- -
-
- -
-
-

Durée

-

{getDurationLabel(mission.projection)}

-
-
- -
-
- -
-
-

Niveau

-

{getNiveauLabel(mission.niveau)}

-
-
- -
-
- -
-
-

Participation

-

{mission.participation || "Non spécifié"}

-
-
- - {oddInfo.number && ( -
-
- {oddInfo.label} -
-
-

Objectif

-

Développement durable

-
-
- )} -
+ {/* Tabs */} + + + + + Général + + + + Plan d'actions + + - {/* Project Description */} -
-

Description de la mission

-
- {mission.intention || "Aucune description disponible pour cette mission."} -
-
- - {/* Attachments Section */} - {mission.attachments && mission.attachments.length > 0 && ( -
-

Documents

-
- {mission.attachments.map((attachment) => ( - - - )} - {/* Skills Required Section */} - {mission.profils && mission.profils.length > 0 && ( -
-

Profils recherchés

-
- {mission.profils.map((profil, index) => ( - - {profil} - - ))} + {/* Description */} +
+

Description de la mission

+
+ {mission.intention || "Aucune description disponible pour cette mission."} +
-
- )} - {/* Services Section */} - {mission.services && mission.services.length > 0 && ( -
-

Services

-
- {mission.services.map((service, index) => ( - - {service} - - ))} -
-
- )} - - {/* Action Buttons */} -
- -
+ + {/* Profils */} + {mission.profils && mission.profils.length > 0 && ( +
+

Profils recherchés

+
+ {mission.profils.map((profil, index) => ( + + {profil} + + ))} +
+
+ )} + + {/* Attachments */} + {mission.attachments && mission.attachments.length > 0 && ( + + )} + + {/* Delete Button */} +
+ +
+ + + {/* Plan d'actions Tab */} + +
+
+
+

Plan d'actions

+ {mission.actionPlanGeneratedAt && ( +

+ Généré le {formatDate(mission.actionPlanGeneratedAt)} +

+ )} +
+
+ {isPlanModified && ( + + )} + +
+
+ + {/* Plan Content */} + {editedPlan || mission.actionPlan ? ( +
+