Mission Refactor Members

This commit is contained in:
alma 2026-01-09 16:50:21 +01:00
parent e523c31fa0
commit 8d4c9d5e23
4 changed files with 509 additions and 36 deletions

4
.env
View File

@ -107,3 +107,7 @@ MICROSOFT_TENANT_ID="cb4281a9-4a3e-4ff5-9a85-8425dd04e2b2"
N8N_WEBHOOK_URL="https://brain.slm-lab.net/webhook/mission-created"
N8N_DELETE_WEBHOOK_URL="https://brain.slm-lab.net/webhook/mission-delete"
N8N_API_KEY=LwgeE1ntADD20OuWC88S3pR0EaO7FtO4
N8N_GENERATE_PLAN_WEBHOOK_URL=https://brain.slm-lab.net/webhook/GeneratePlan
N8N_CLOSE_MISSION_WEBHOOK_URL=https://brain.slm-lab.net/webhook/NeahMissionClose
N8N_CHANGE_GUARDIAN_WEBHOOK_URL=https://brain.slm-lab.net/webhook/NeahMissionChangeGuardian
N8N_REMOVE_USER_WEBHOOK_URL=https://brain.slm-lab.net/webhook/NeahMissionRemoveUser

View File

@ -0,0 +1,164 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { logger } from '@/lib/logger';
export async function POST(
request: Request,
props: { params: Promise<{ missionId: string }> }
) {
const params = await props.params;
const { missionId } = params;
try {
// Check authentication
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { guardianRole, oldUserId, newUserId, oldUserEmail, newUserEmail } = body;
if (!guardianRole || !newUserId) {
return NextResponse.json(
{ error: 'guardianRole and newUserId are required' },
{ status: 400 }
);
}
// Get mission details
const mission = await prisma.mission.findUnique({
where: { id: missionId },
select: {
id: true,
name: true,
creatorId: true,
leantimeProjectId: true,
outlineCollectionId: true,
rocketChatChannelId: true,
giteaRepositoryUrl: true,
},
});
if (!mission) {
return NextResponse.json({ error: 'Mission not found' }, { status: 404 });
}
// Check if user is authorized (creator or admin)
const isCreator = mission.creatorId === session.user.id;
const isAdmin = session.user.role?.includes('admin');
if (!isCreator && !isAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Map guardian role to database role
const roleMap: Record<string, string> = {
'temps': 'temps',
'parole': 'parole',
'memoire': 'memoire',
};
const dbRole = roleMap[guardianRole];
if (!dbRole) {
return NextResponse.json({ error: 'Invalid guardian role' }, { status: 400 });
}
// Update the guardian in database
// First, remove old guardian if exists
if (oldUserId) {
await prisma.missionUser.deleteMany({
where: {
missionId,
userId: oldUserId,
role: dbRole,
},
});
}
// Then add new guardian
await prisma.missionUser.create({
data: {
missionId,
userId: newUserId,
role: dbRole,
},
});
// Extract repo name from Gitea URL
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 {
const match = mission.giteaRepositoryUrl.match(/\/([^\/]+)\/?$/);
repoName = match ? match[1] : '';
}
}
// Prepare data for N8N webhook
const n8nData = {
missionId: mission.id,
missionName: mission.name,
guardianRole,
oldUserId,
newUserId,
oldUserEmail,
newUserEmail,
repoName,
leantimeProjectId: mission.leantimeProjectId || '',
outlineCollectionId: mission.outlineCollectionId || '',
rocketChatChannelId: mission.rocketChatChannelId || '',
giteaRepositoryUrl: mission.giteaRepositoryUrl || '',
};
// Call N8N webhook
const webhookUrl = process.env.N8N_CHANGE_GUARDIAN_WEBHOOK_URL || 'https://brain.slm-lab.net/webhook/NeahMissionChangeGuardian';
const apiKey = process.env.N8N_API_KEY || '';
logger.debug('Calling N8N ChangeGuardian webhook', {
missionId,
guardianRole,
oldUserId,
newUserId,
});
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
},
body: JSON.stringify(n8nData),
});
if (!response.ok) {
const errorText = await response.text();
logger.error('N8N ChangeGuardian webhook error', {
status: response.status,
error: errorText.substring(0, 200),
});
// Continue even if N8N fails, database is already updated
}
return NextResponse.json({
success: true,
message: 'Guardian changed successfully',
});
} catch (error) {
logger.error('Error changing guardian', {
error: error instanceof Error ? error.message : String(error),
missionId,
});
return NextResponse.json(
{ error: 'Failed to change guardian', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,136 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { logger } from '@/lib/logger';
export async function POST(
request: Request,
props: { params: Promise<{ missionId: string }> }
) {
const params = await props.params;
const { missionId } = params;
try {
// Check authentication
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { userId, userEmail, role } = body;
if (!userId) {
return NextResponse.json(
{ error: 'userId is required' },
{ status: 400 }
);
}
// Get mission details
const mission = await prisma.mission.findUnique({
where: { id: missionId },
select: {
id: true,
name: true,
creatorId: true,
leantimeProjectId: true,
outlineCollectionId: true,
rocketChatChannelId: true,
giteaRepositoryUrl: true,
},
});
if (!mission) {
return NextResponse.json({ error: 'Mission not found' }, { status: 404 });
}
// Check if user is authorized (creator or admin)
const isCreator = mission.creatorId === session.user.id;
const isAdmin = session.user.role?.includes('admin');
if (!isCreator && !isAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Remove user from mission in database
await prisma.missionUser.deleteMany({
where: {
missionId,
userId,
},
});
// Extract repo name from Gitea URL
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 {
const match = mission.giteaRepositoryUrl.match(/\/([^\/]+)\/?$/);
repoName = match ? match[1] : '';
}
}
// Prepare data for N8N webhook
const n8nData = {
missionId: mission.id,
missionName: mission.name,
userId,
userEmail,
role: role || 'volontaire',
repoName,
leantimeProjectId: mission.leantimeProjectId || '',
outlineCollectionId: mission.outlineCollectionId || '',
rocketChatChannelId: mission.rocketChatChannelId || '',
giteaRepositoryUrl: mission.giteaRepositoryUrl || '',
};
// Call N8N webhook
const webhookUrl = process.env.N8N_REMOVE_USER_WEBHOOK_URL || 'https://brain.slm-lab.net/webhook/NeahMissionRemoveUser';
const apiKey = process.env.N8N_API_KEY || '';
logger.debug('Calling N8N RemoveUser webhook', {
missionId,
userId,
userEmail,
});
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
},
body: JSON.stringify(n8nData),
});
if (!response.ok) {
const errorText = await response.text();
logger.error('N8N RemoveUser webhook error', {
status: response.status,
error: errorText.substring(0, 200),
});
// Continue even if N8N fails, database is already updated
}
return NextResponse.json({
success: true,
message: 'User removed successfully',
});
} catch (error) {
logger.error('Error removing user from mission', {
error: error instanceof Error ? error.message : String(error),
missionId,
});
return NextResponse.json(
{ error: 'Failed to remove user', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@ -18,7 +18,7 @@ import {
CardContent
} from "../ui/card";
import { Badge } from "../ui/badge";
import { X, Search, UserPlus, Users, PlusCircle, AlertCircle, Check, UploadCloud, File } from "lucide-react";
import { X, Search, UserPlus, Users, PlusCircle, AlertCircle, Check, UploadCloud, File, RefreshCw } from "lucide-react";
import { toast } from "../ui/use-toast";
import {
DropdownMenu,
@ -268,7 +268,7 @@ export function MissionsAdminPanel() {
});
};
// Function to remove a user from all roles
// Function to remove a user from all roles (local state only - for new missions)
const removeUserFromAllRoles = (userId: string) => {
if (gardienDuTemps === userId) setGardienDuTemps(null);
if (gardienDeLaParole === userId) setGardienDeLaParole(null);
@ -278,6 +278,107 @@ export function MissionsAdminPanel() {
}
};
// Function to change a guardian and call N8N webhook (for existing missions)
const handleChangeGuardian = async (
guardianRole: 'temps' | 'parole' | 'memoire',
oldUserId: string | null,
newUserId: string
) => {
const oldUser = oldUserId ? users.find(u => u.id === oldUserId) : null;
const newUser = users.find(u => u.id === newUserId);
// Update local state first
if (guardianRole === 'temps') {
setGardienDuTemps(newUserId);
} else if (guardianRole === 'parole') {
setGardienDeLaParole(newUserId);
} else if (guardianRole === 'memoire') {
setGardienDeLaMemoire(newUserId);
}
// If this is an existing mission, call the API
if (missionId) {
try {
const response = await fetch(`/api/missions/${missionId}/change-guardian`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
guardianRole,
oldUserId,
newUserId,
oldUserEmail: oldUser?.email,
newUserEmail: newUser?.email,
}),
});
if (!response.ok) {
throw new Error('Failed to change guardian');
}
toast({
title: "Gardien modifié",
description: `Le gardien a été changé avec succès`,
});
} catch (error) {
logger.error('Error changing guardian', { error });
toast({
title: "Erreur",
description: "Erreur lors du changement de gardien",
variant: "destructive",
});
}
} else {
toast({
title: "Gardien modifié",
description: `Le gardien a été changé`,
});
}
};
// Function to remove a volontaire and call N8N webhook (for existing missions)
const handleRemoveVolontaire = async (userId: string) => {
const user = users.find(u => u.id === userId);
// Update local state first
setVolontaires(prev => prev.filter(id => id !== userId));
// If this is an existing mission, call the API
if (missionId) {
try {
const response = await fetch(`/api/missions/${missionId}/remove-user`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId,
userEmail: user?.email,
role: 'volontaire',
}),
});
if (!response.ok) {
throw new Error('Failed to remove user');
}
toast({
title: "Utilisateur supprimé",
description: `L'utilisateur a été retiré de la mission`,
});
} catch (error) {
logger.error('Error removing user', { error });
toast({
title: "Erreur",
description: "Erreur lors de la suppression de l'utilisateur",
variant: "destructive",
});
}
} else {
toast({
title: "Utilisateur supprimé",
description: `L'utilisateur a été retiré`,
});
}
};
// Check if all guardian roles are filled
const areAllGuardiensFilled = (): boolean => {
return gardienDuTemps !== null && gardienDeLaParole !== null && gardienDeLaMemoire !== null;
@ -1121,16 +1222,38 @@ export function MissionsAdminPanel() {
<div className="flex justify-between items-center mb-2">
<h4 className="font-medium text-gray-800">Gardien du Temps</h4>
{gardienDuTemps && (
<Button
variant="outline"
size="sm"
onClick={() => removeUserFromAllRoles(gardienDuTemps)}
className="text-red-600 hover:bg-red-50 hover:text-red-700 border-red-200 h-8 bg-white"
disabled={loading}
>
<X size={16} className="mr-1" />
Supprimer
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-blue-600 hover:bg-blue-50 hover:text-blue-700 border-blue-200 h-8 bg-white"
disabled={loading}
>
<RefreshCw size={16} className="mr-1" />
Modifier
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-white border border-gray-200 max-h-[300px] overflow-y-auto w-64">
{users.filter(u => u.id !== gardienDuTemps && u.id !== gardienDeLaParole && u.id !== gardienDeLaMemoire).map(user => (
<DropdownMenuItem
key={user.id}
onClick={() => handleChangeGuardian('temps', gardienDuTemps, user.id)}
className="cursor-pointer hover:bg-gray-100"
>
<div className="flex items-center">
<div className="h-6 w-6 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 text-xs font-medium mr-2">
{user.firstName?.[0] || ""}{user.lastName?.[0] || ""}
</div>
<div>
<div className="text-sm">{user.firstName} {user.lastName}</div>
<div className="text-xs text-gray-500">{user.email}</div>
</div>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{loading ? (
@ -1177,16 +1300,38 @@ export function MissionsAdminPanel() {
<div className="flex justify-between items-center mb-2">
<h4 className="font-medium text-gray-800">Gardien de la Parole</h4>
{gardienDeLaParole && (
<Button
variant="outline"
size="sm"
onClick={() => removeUserFromAllRoles(gardienDeLaParole)}
className="text-red-600 hover:bg-red-50 hover:text-red-700 border-red-200 h-8 bg-white"
disabled={loading}
>
<X size={16} className="mr-1" />
Supprimer
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-blue-600 hover:bg-blue-50 hover:text-blue-700 border-blue-200 h-8 bg-white"
disabled={loading}
>
<RefreshCw size={16} className="mr-1" />
Modifier
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-white border border-gray-200 max-h-[300px] overflow-y-auto w-64">
{users.filter(u => u.id !== gardienDuTemps && u.id !== gardienDeLaParole && u.id !== gardienDeLaMemoire).map(user => (
<DropdownMenuItem
key={user.id}
onClick={() => handleChangeGuardian('parole', gardienDeLaParole, user.id)}
className="cursor-pointer hover:bg-gray-100"
>
<div className="flex items-center">
<div className="h-6 w-6 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 text-xs font-medium mr-2">
{user.firstName?.[0] || ""}{user.lastName?.[0] || ""}
</div>
<div>
<div className="text-sm">{user.firstName} {user.lastName}</div>
<div className="text-xs text-gray-500">{user.email}</div>
</div>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{loading ? (
@ -1233,16 +1378,38 @@ export function MissionsAdminPanel() {
<div className="flex justify-between items-center mb-2">
<h4 className="font-medium text-gray-800">Gardien de la Mémoire</h4>
{gardienDeLaMemoire && (
<Button
variant="outline"
size="sm"
onClick={() => removeUserFromAllRoles(gardienDeLaMemoire)}
className="text-red-600 hover:bg-red-50 hover:text-red-700 border-red-200 h-8 bg-white"
disabled={loading}
>
<X size={16} className="mr-1" />
Supprimer
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-blue-600 hover:bg-blue-50 hover:text-blue-700 border-blue-200 h-8 bg-white"
disabled={loading}
>
<RefreshCw size={16} className="mr-1" />
Modifier
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-white border border-gray-200 max-h-[300px] overflow-y-auto w-64">
{users.filter(u => u.id !== gardienDuTemps && u.id !== gardienDeLaParole && u.id !== gardienDeLaMemoire).map(user => (
<DropdownMenuItem
key={user.id}
onClick={() => handleChangeGuardian('memoire', gardienDeLaMemoire, user.id)}
className="cursor-pointer hover:bg-gray-100"
>
<div className="flex items-center">
<div className="h-6 w-6 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 text-xs font-medium mr-2">
{user.firstName?.[0] || ""}{user.lastName?.[0] || ""}
</div>
<div>
<div className="text-sm">{user.firstName} {user.lastName}</div>
<div className="text-xs text-gray-500">{user.email}</div>
</div>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{loading ? (
@ -1331,8 +1498,9 @@ export function MissionsAdminPanel() {
<Button
variant="ghost"
size="sm"
onClick={() => removeUserFromAllRoles(userId)}
onClick={() => handleRemoveVolontaire(userId)}
className="ml-1 h-5 w-5 p-0 text-gray-500 hover:text-red-600 hover:bg-transparent"
title="Supprimer le volontaire"
>
<X size={12} />
</Button>
@ -1452,11 +1620,12 @@ export function MissionsAdminPanel() {
Volontaire
</Button>
{isUserAssigned(user.id) && (
{/* Only show delete button for volontaires (not guardians) */}
{volontaires.includes(user.id) && (
<Button
variant="outline"
size="sm"
onClick={() => removeUserFromAllRoles(user.id)}
onClick={() => handleRemoveVolontaire(user.id)}
className="ml-2 text-red-600 hover:bg-red-50 hover:text-red-700 border-red-200 h-8 bg-white"
disabled={loading}
>