Mission Refactor Big
This commit is contained in:
parent
55585db2f1
commit
0f275fc45d
157
app/api/missions/[missionId]/close/route.ts
Normal file
157
app/api/missions/[missionId]/close/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -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,21 +567,56 @@ export default function MissionDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete Button */}
|
{/* Mission Status & Action Buttons */}
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-between items-center">
|
||||||
<Button
|
{/* Closed Status */}
|
||||||
variant="outline"
|
{mission.isClosed && (
|
||||||
className="flex items-center gap-2 border-red-600 text-red-600 hover:bg-red-50 bg-white"
|
<div className="flex items-center gap-2 text-amber-600 bg-amber-50 px-4 py-2 rounded-lg border border-amber-200">
|
||||||
onClick={handleDeleteMission}
|
<Lock className="h-4 w-4" />
|
||||||
disabled={deleting}
|
<span className="font-medium">Mission clôturée</span>
|
||||||
>
|
{mission.closedAt && (
|
||||||
{deleting ? (
|
<span className="text-sm text-amber-500">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
le {formatDate(mission.closedAt)}
|
||||||
) : (
|
</span>
|
||||||
<Trash2 className="h-4 w-4" />
|
)}
|
||||||
|
</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>
|
||||||
)}
|
)}
|
||||||
Supprimer la mission
|
|
||||||
</Button>
|
{/* Delete Button */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex items-center gap-2 border-red-600 text-red-600 hover:bg-red-50 bg-white"
|
||||||
|
onClick={handleDeleteMission}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
{deleting ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Supprimer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user