Mission Refactor Big

This commit is contained in:
alma 2026-01-09 14:13:59 +01:00
parent 55585db2f1
commit 0f275fc45d
3 changed files with 256 additions and 15 deletions

View File

@ -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 }
);
}
}

View File

@ -16,7 +16,9 @@ import {
Sparkles, Sparkles,
Save, Save,
Loader2, Loader2,
FileText FileText,
Lock,
CheckCircle
} from "lucide-react"; } from "lucide-react";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
@ -54,6 +56,8 @@ interface Mission {
attachments?: Attachment[]; attachments?: Attachment[];
actionPlan?: string | null; actionPlan?: string | null;
actionPlanGeneratedAt?: string | null; actionPlanGeneratedAt?: string | null;
isClosed?: boolean;
closedAt?: string | null;
createdAt: string; createdAt: string;
creator: User; creator: User;
missionUsers: any[]; missionUsers: any[];
@ -63,6 +67,7 @@ export default function MissionDetailPage() {
const [mission, setMission] = useState<Mission | null>(null); const [mission, setMission] = useState<Mission | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [closing, setClosing] = useState(false);
const [generatingPlan, setGeneratingPlan] = useState(false); const [generatingPlan, setGeneratingPlan] = useState(false);
const [savingPlan, setSavingPlan] = useState(false); const [savingPlan, setSavingPlan] = useState(false);
const [editedPlan, setEditedPlan] = useState<string>(""); const [editedPlan, setEditedPlan] = useState<string>("");
@ -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 // Handle generate action plan
const handleGeneratePlan = async () => { const handleGeneratePlan = async () => {
try { try {
@ -520,8 +567,42 @@ export default function MissionDetailPage() {
</div> </div>
)} )}
{/* Mission Status & Action Buttons */}
<div className="flex justify-between items-center">
{/* Closed Status */}
{mission.isClosed && (
<div className="flex items-center gap-2 text-amber-600 bg-amber-50 px-4 py-2 rounded-lg border border-amber-200">
<Lock className="h-4 w-4" />
<span className="font-medium">Mission clôturée</span>
{mission.closedAt && (
<span className="text-sm text-amber-500">
le {formatDate(mission.closedAt)}
</span>
)}
</div>
)}
{!mission.isClosed && <div></div>}
{/* Action Buttons */}
<div className="flex gap-3">
{/* Close Button - only show if not already closed */}
{!mission.isClosed && (
<Button
variant="outline"
className="flex items-center gap-2 border-amber-600 text-amber-600 hover:bg-amber-50 bg-white"
onClick={handleCloseMission}
disabled={closing}
>
{closing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Lock className="h-4 w-4" />
)}
Clôturer
</Button>
)}
{/* Delete Button */} {/* Delete Button */}
<div className="flex justify-end">
<Button <Button
variant="outline" variant="outline"
className="flex items-center gap-2 border-red-600 text-red-600 hover:bg-red-50 bg-white" className="flex items-center gap-2 border-red-600 text-red-600 hover:bg-red-50 bg-white"
@ -533,9 +614,10 @@ export default function MissionDetailPage() {
) : ( ) : (
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
)} )}
Supprimer la mission Supprimer
</Button> </Button>
</div> </div>
</div>
</TabsContent> </TabsContent>
{/* Plan d'actions Tab */} {/* Plan d'actions Tab */}

View File

@ -134,6 +134,8 @@ model Mission {
profils String[] // Level / Profils profils String[] // Level / Profils
actionPlan String? // Generated action plan from LLM (stored as text/markdown) actionPlan String? // Generated action plan from LLM (stored as text/markdown)
actionPlanGeneratedAt DateTime? // When the action plan was generated 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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade) creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade)