Pages corrections pages missions
This commit is contained in:
parent
b543c87e42
commit
eee7fb06b5
97
app/api/missions/[missionId]/files/folder/route.ts
Normal file
97
app/api/missions/[missionId]/files/folder/route.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from "@/app/api/auth/options";
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
|
||||
// Use the exact same S3 client configuration as mission-uploads.ts
|
||||
const missionsS3Client = new S3Client({
|
||||
region: 'us-east-1',
|
||||
endpoint: 'https://dome-api.slm-lab.net',
|
||||
credentials: {
|
||||
accessKeyId: process.env.MINIO_ACCESS_KEY || '4aBT4CMb7JIMMyUtp4Pl',
|
||||
secretAccessKey: process.env.MINIO_SECRET_KEY || 'HGn39XhCIlqOjmDVzRK9MED2Fci2rYvDDgbLFElg'
|
||||
},
|
||||
forcePathStyle: true
|
||||
});
|
||||
|
||||
const MISSIONS_BUCKET = 'missions';
|
||||
|
||||
// Helper function to check if user can manage files (creator or gardien)
|
||||
async function checkCanManage(userId: string, missionId: string): Promise<boolean> {
|
||||
const mission = await prisma.mission.findFirst({
|
||||
where: { id: missionId },
|
||||
select: {
|
||||
creatorId: true,
|
||||
missionUsers: {
|
||||
where: { userId },
|
||||
select: { role: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!mission) return false;
|
||||
|
||||
// Creator can always manage
|
||||
if (mission.creatorId === userId) return true;
|
||||
|
||||
// Gardiens can manage
|
||||
const userRole = mission.missionUsers[0]?.role;
|
||||
return userRole === 'gardien-temps' || userRole === 'gardien-parole' || userRole === 'gardien-memoire';
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ missionId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { missionId } = await params;
|
||||
const userId = session.user.id;
|
||||
|
||||
// Check if user can manage files
|
||||
const canManage = await checkCanManage(userId, missionId);
|
||||
if (!canManage) {
|
||||
return NextResponse.json({ error: 'Forbidden: You do not have permission to create folders' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { path } = body;
|
||||
|
||||
if (!path) {
|
||||
return NextResponse.json({ error: 'Path is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Construct the S3 key for the folder marker
|
||||
// Files are stored in MinIO without the "missions/" prefix
|
||||
const s3Key = path.endsWith('/') ? `${path}.placeholder` : `${path}/.placeholder`;
|
||||
|
||||
// Create folder marker in S3
|
||||
await missionsS3Client.send(new PutObjectCommand({
|
||||
Bucket: MISSIONS_BUCKET,
|
||||
Key: s3Key,
|
||||
Body: Buffer.alloc(0),
|
||||
ContentType: 'application/octet-stream'
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
folder: {
|
||||
type: 'folder',
|
||||
name: path.split('/').pop() || path,
|
||||
path: `missions/${path}`,
|
||||
key: `missions/${path}`
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error creating folder:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create folder', details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -33,6 +33,29 @@ async function checkMissionAccess(userId: string, missionId: string): Promise<bo
|
||||
return !!mission;
|
||||
}
|
||||
|
||||
// Helper function to check if user can manage files (creator or gardien)
|
||||
async function checkCanManage(userId: string, missionId: string): Promise<boolean> {
|
||||
const mission = await prisma.mission.findFirst({
|
||||
where: { id: missionId },
|
||||
select: {
|
||||
creatorId: true,
|
||||
missionUsers: {
|
||||
where: { userId },
|
||||
select: { role: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!mission) return false;
|
||||
|
||||
// Creator can always manage
|
||||
if (mission.creatorId === userId) return true;
|
||||
|
||||
// Gardiens can manage
|
||||
const userRole = mission.missionUsers[0]?.role;
|
||||
return userRole === 'gardien-temps' || userRole === 'gardien-parole' || userRole === 'gardien-memoire';
|
||||
}
|
||||
|
||||
// Helper function to stream to string
|
||||
async function streamToString(stream: Readable): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
@ -285,3 +308,67 @@ export async function PUT(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE endpoint to delete a file or folder
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ missionId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { missionId } = await params;
|
||||
const userId = session.user.id;
|
||||
|
||||
// Check if user can manage files
|
||||
const canManage = await checkCanManage(userId, missionId);
|
||||
if (!canManage) {
|
||||
return NextResponse.json({ error: 'Forbidden: You do not have permission to delete files' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { key } = body;
|
||||
|
||||
if (!key) {
|
||||
return NextResponse.json({ error: 'File key is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Ensure the key is within the mission folder
|
||||
if (!key.startsWith(`missions/${missionId}/`)) {
|
||||
return NextResponse.json({ error: 'Invalid file path' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Remove missions/ prefix for MinIO (files are stored without it)
|
||||
const minioKey = key.replace(/^missions\//, '');
|
||||
|
||||
// Delete from S3
|
||||
await missionsS3Client.send(new DeleteObjectCommand({
|
||||
Bucket: MISSIONS_BUCKET,
|
||||
Key: minioKey
|
||||
}));
|
||||
|
||||
// Try to delete from database if it's an attachment
|
||||
try {
|
||||
await prisma.attachment.deleteMany({
|
||||
where: {
|
||||
missionId: missionId,
|
||||
filePath: key
|
||||
}
|
||||
});
|
||||
} catch (dbError) {
|
||||
// Ignore database errors (file might not be in DB)
|
||||
console.warn('Could not delete attachment from database:', dbError);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting file:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete file', details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
118
app/api/missions/[missionId]/files/upload/route.ts
Normal file
118
app/api/missions/[missionId]/files/upload/route.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from "@/app/api/auth/options";
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
|
||||
// Use the exact same S3 client configuration as mission-uploads.ts
|
||||
const missionsS3Client = new S3Client({
|
||||
region: 'us-east-1',
|
||||
endpoint: 'https://dome-api.slm-lab.net',
|
||||
credentials: {
|
||||
accessKeyId: process.env.MINIO_ACCESS_KEY || '4aBT4CMb7JIMMyUtp4Pl',
|
||||
secretAccessKey: process.env.MINIO_SECRET_KEY || 'HGn39XhCIlqOjmDVzRK9MED2Fci2rYvDDgbLFElg'
|
||||
},
|
||||
forcePathStyle: true
|
||||
});
|
||||
|
||||
const MISSIONS_BUCKET = 'missions';
|
||||
|
||||
// Helper function to check if user can manage files (creator or gardien)
|
||||
async function checkCanManage(userId: string, missionId: string): Promise<boolean> {
|
||||
const mission = await prisma.mission.findFirst({
|
||||
where: { id: missionId },
|
||||
select: {
|
||||
creatorId: true,
|
||||
missionUsers: {
|
||||
where: { userId },
|
||||
select: { role: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!mission) return false;
|
||||
|
||||
// Creator can always manage
|
||||
if (mission.creatorId === userId) return true;
|
||||
|
||||
// Gardiens can manage
|
||||
const userRole = mission.missionUsers[0]?.role;
|
||||
return userRole === 'gardien-temps' || userRole === 'gardien-parole' || userRole === 'gardien-memoire';
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ missionId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { missionId } = await params;
|
||||
const userId = session.user.id;
|
||||
|
||||
// Check if user can manage files
|
||||
const canManage = await checkCanManage(userId, missionId);
|
||||
if (!canManage) {
|
||||
return NextResponse.json({ error: 'Forbidden: You do not have permission to upload files' }, { status: 403 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File;
|
||||
const path = formData.get('path') as string || 'attachments';
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'File is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Construct the S3 key
|
||||
// Files are stored in MinIO without the "missions/" prefix
|
||||
const s3Key = path ? `${missionId}/${path}/${file.name}` : `${missionId}/${file.name}`;
|
||||
const filePath = `missions/${s3Key}`; // Full path for database
|
||||
|
||||
// Convert File to Buffer
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
// Upload to S3
|
||||
await missionsS3Client.send(new PutObjectCommand({
|
||||
Bucket: MISSIONS_BUCKET,
|
||||
Key: s3Key,
|
||||
Body: buffer,
|
||||
ContentType: file.type || 'application/octet-stream',
|
||||
ACL: 'public-read'
|
||||
}));
|
||||
|
||||
// Create attachment record in database
|
||||
const attachment = await prisma.attachment.create({
|
||||
data: {
|
||||
filename: file.name,
|
||||
filePath: filePath,
|
||||
fileType: file.type || 'application/octet-stream',
|
||||
fileSize: file.size,
|
||||
missionId: missionId,
|
||||
uploaderId: userId
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
file: {
|
||||
type: 'file',
|
||||
name: file.name,
|
||||
path: filePath,
|
||||
key: filePath,
|
||||
size: file.size,
|
||||
lastModified: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error uploading file:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to upload file', details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -2,40 +2,23 @@ import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from "@/app/api/auth/options";
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { deleteMissionLogo, deleteMissionAttachment, getMissionFileUrl } from '@/lib/mission-uploads';
|
||||
import { getPublicUrl, S3_CONFIG } from '@/lib/s3';
|
||||
import { N8nService } from '@/lib/services/n8n-service';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
// Helper function to check authentication
|
||||
async function checkAuth(request: Request) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
logger.error('Unauthorized access attempt', {
|
||||
url: request.url,
|
||||
method: request.method
|
||||
});
|
||||
return { authorized: false, userId: null };
|
||||
}
|
||||
return { authorized: true, userId: session.user.id };
|
||||
}
|
||||
|
||||
// GET endpoint to retrieve a mission by ID
|
||||
export async function GET(request: Request, props: { params: Promise<{ missionId: string }> }) {
|
||||
const params = await props.params;
|
||||
// GET endpoint to get mission details including creator and missionUsers
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ missionId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { authorized, userId } = await checkAuth(request);
|
||||
if (!authorized || !userId) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { missionId } = params;
|
||||
if (!missionId) {
|
||||
return NextResponse.json({ error: 'Mission ID is required' }, { status: 400 });
|
||||
}
|
||||
const { missionId } = await params;
|
||||
const userId = session.user.id;
|
||||
|
||||
// Get mission with detailed info
|
||||
const mission = await (prisma as any).mission.findFirst({
|
||||
// Find mission and check access
|
||||
const mission = await prisma.mission.findFirst({
|
||||
where: {
|
||||
id: missionId,
|
||||
OR: [
|
||||
@ -43,10 +26,14 @@ export async function GET(request: Request, props: { params: Promise<{ missionId
|
||||
{ missionUsers: { some: { userId } } }
|
||||
]
|
||||
},
|
||||
include: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
creatorId: true,
|
||||
creator: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true
|
||||
}
|
||||
},
|
||||
@ -54,24 +41,15 @@ export async function GET(request: Request, props: { params: Promise<{ missionId
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
userId: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
attachments: {
|
||||
select: {
|
||||
id: true,
|
||||
filename: true,
|
||||
filePath: true,
|
||||
fileType: true,
|
||||
fileSize: true,
|
||||
createdAt: true
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -79,365 +57,13 @@ export async function GET(request: Request, props: { params: Promise<{ missionId
|
||||
if (!mission) {
|
||||
return NextResponse.json({ error: 'Mission not found or access denied' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Add public URLs to mission logo and attachments
|
||||
const missionWithUrls = {
|
||||
...mission,
|
||||
logoUrl: mission.logo ? getMissionFileUrl(mission.logo) : null,
|
||||
logo: mission.logo,
|
||||
attachments: mission.attachments.map((attachment: { id: string; filename: string; filePath: string; fileType: string; fileSize: number; createdAt: Date }) => ({
|
||||
...attachment,
|
||||
publicUrl: getMissionFileUrl(attachment.filePath)
|
||||
}))
|
||||
};
|
||||
|
||||
logger.debug('Mission data with URLs', {
|
||||
missionId: mission.id,
|
||||
hasLogo: !!mission.logo,
|
||||
attachmentCount: mission.attachments.length
|
||||
});
|
||||
|
||||
return NextResponse.json(missionWithUrls);
|
||||
} catch (error) {
|
||||
logger.error('Error retrieving mission', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
missionId: params.missionId
|
||||
});
|
||||
return NextResponse.json({
|
||||
error: 'Internal server error',
|
||||
details: error instanceof Error ? error.message : String(error)
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PUT endpoint to update a mission
|
||||
export async function PUT(request: Request, props: { params: Promise<{ missionId: string }> }) {
|
||||
const params = await props.params;
|
||||
try {
|
||||
const { authorized, userId } = await checkAuth(request);
|
||||
if (!authorized || !userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { missionId } = params;
|
||||
if (!missionId) {
|
||||
return NextResponse.json({ error: 'Mission ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if mission exists and user has access to modify it
|
||||
const existingMission = await (prisma as any).mission.findFirst({
|
||||
where: {
|
||||
id: missionId,
|
||||
OR: [
|
||||
{ creatorId: userId },
|
||||
{ missionUsers: { some: { userId, role: { in: ['gardien-temps', 'gardien-parole'] } } } }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (!existingMission) {
|
||||
return NextResponse.json({ error: 'Mission not found or not authorized to update' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Parse the request body
|
||||
const body = await request.json();
|
||||
const {
|
||||
name,
|
||||
logo,
|
||||
oddScope,
|
||||
niveau,
|
||||
intention,
|
||||
missionType,
|
||||
donneurDOrdre,
|
||||
projection,
|
||||
services,
|
||||
participation,
|
||||
profils,
|
||||
guardians,
|
||||
volunteers
|
||||
} = body;
|
||||
|
||||
// Process logo URL to get relative path
|
||||
let logoPath = logo;
|
||||
if (logo && typeof logo === 'string') {
|
||||
try {
|
||||
// If it's a full URL, extract the path
|
||||
if (logo.startsWith('http')) {
|
||||
const url = new URL(logo);
|
||||
logoPath = url.pathname.startsWith('/') ? url.pathname.substring(1) : url.pathname;
|
||||
}
|
||||
// If it's already a relative path, ensure it starts with 'missions/'
|
||||
else if (!logo.startsWith('missions/')) {
|
||||
logoPath = `missions/${logo}`;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error processing logo URL', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
missionId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update the mission data
|
||||
const updatedMission = await (prisma as any).mission.update({
|
||||
where: { id: missionId },
|
||||
data: {
|
||||
name,
|
||||
logo: logoPath,
|
||||
oddScope: oddScope || undefined,
|
||||
niveau,
|
||||
intention,
|
||||
missionType,
|
||||
donneurDOrdre,
|
||||
projection,
|
||||
services: services || undefined,
|
||||
participation,
|
||||
profils: profils || undefined
|
||||
}
|
||||
});
|
||||
|
||||
// Update guardians if provided
|
||||
if (guardians) {
|
||||
// Get current guardians
|
||||
const currentGuardians = await (prisma as any).missionUser.findMany({
|
||||
where: {
|
||||
missionId,
|
||||
role: { in: ['gardien-temps', 'gardien-parole', 'gardien-memoire'] }
|
||||
}
|
||||
});
|
||||
|
||||
// Delete all guardians
|
||||
if (currentGuardians.length > 0) {
|
||||
await (prisma as any).missionUser.deleteMany({
|
||||
where: {
|
||||
missionId,
|
||||
role: { in: ['gardien-temps', 'gardien-parole', 'gardien-memoire'] }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add new guardians
|
||||
const guardianRoles = ['gardien-temps', 'gardien-parole', 'gardien-memoire'];
|
||||
const guardianEntries = Object.entries(guardians)
|
||||
.filter(([role, userId]) => guardianRoles.includes(role) && userId)
|
||||
.map(([role, userId]) => ({
|
||||
role,
|
||||
userId: userId as string,
|
||||
missionId
|
||||
}));
|
||||
|
||||
if (guardianEntries.length > 0) {
|
||||
await (prisma as any).missionUser.createMany({
|
||||
data: guardianEntries
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update volunteers if provided
|
||||
if (volunteers && Array.isArray(volunteers)) {
|
||||
// Get current volunteers
|
||||
const currentVolunteers = await (prisma as any).missionUser.findMany({
|
||||
where: {
|
||||
missionId,
|
||||
role: 'volontaire'
|
||||
}
|
||||
});
|
||||
|
||||
// Delete all volunteers
|
||||
if (currentVolunteers.length > 0) {
|
||||
await (prisma as any).missionUser.deleteMany({
|
||||
where: {
|
||||
missionId,
|
||||
role: 'volontaire'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add new volunteers
|
||||
if (volunteers.length > 0) {
|
||||
const volunteerEntries = volunteers.map((userId: string) => ({
|
||||
role: 'volontaire',
|
||||
userId,
|
||||
missionId
|
||||
}));
|
||||
|
||||
await (prisma as any).missionUser.createMany({
|
||||
data: volunteerEntries
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
mission: {
|
||||
id: updatedMission.id,
|
||||
name: updatedMission.name,
|
||||
updatedAt: updatedMission.updatedAt
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error updating mission', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
missionId: params.missionId
|
||||
});
|
||||
return NextResponse.json({
|
||||
error: 'Internal server error',
|
||||
details: error instanceof Error ? error.message : String(error)
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE endpoint to remove a mission
|
||||
export async function DELETE(
|
||||
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 mission = await prisma.mission.findUnique({
|
||||
where: { id: params.missionId },
|
||||
include: {
|
||||
missionUsers: {
|
||||
include: {
|
||||
user: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!mission) {
|
||||
return NextResponse.json({ error: 'Mission not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if user is mission 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 });
|
||||
}
|
||||
|
||||
// Get attachments before deletion (needed for Minio cleanup)
|
||||
const attachments = await prisma.attachment.findMany({
|
||||
where: { missionId: params.missionId }
|
||||
});
|
||||
|
||||
// Step 1: Trigger N8N workflow for deletion (rollback external integrations)
|
||||
logger.debug('Starting N8N deletion workflow');
|
||||
const n8nService = new N8nService();
|
||||
|
||||
// Extract repo name from giteaRepositoryUrl if present
|
||||
// Format: https://gite.slm-lab.net/alma/repo-name or https://gite.slm-lab.net/api/v1/repos/alma/repo-name
|
||||
let repoName = '';
|
||||
if (mission.giteaRepositoryUrl) {
|
||||
try {
|
||||
const url = new URL(mission.giteaRepositoryUrl);
|
||||
// Extract repo name from path (last segment)
|
||||
const pathParts = url.pathname.split('/').filter(Boolean);
|
||||
repoName = pathParts[pathParts.length - 1] || '';
|
||||
logger.debug('Extracted repo name from URL', { repoName });
|
||||
} catch (error) {
|
||||
logger.error('Error extracting repo name from URL', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
// If URL parsing fails, try to extract from the string directly
|
||||
const match = mission.giteaRepositoryUrl.match(/\/([^\/]+)\/?$/);
|
||||
repoName = match ? match[1] : '';
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare data according to N8N workflow expectations
|
||||
// The workflow expects: repoName, leantimeProjectId, documentationCollectionId, rocketchatChannelId
|
||||
const n8nDeletionData = {
|
||||
missionId: mission.id,
|
||||
name: mission.name,
|
||||
repoName: repoName, // N8N expects repoName, not giteaRepositoryUrl
|
||||
leantimeProjectId: mission.leantimeProjectId || 0,
|
||||
documentationCollectionId: mission.outlineCollectionId || '', // N8N expects documentationCollectionId
|
||||
rocketchatChannelId: mission.rocketChatChannelId || '', // N8N expects rocketchatChannelId (lowercase 'c')
|
||||
// Keep original fields for reference
|
||||
giteaRepositoryUrl: mission.giteaRepositoryUrl,
|
||||
outlineCollectionId: mission.outlineCollectionId,
|
||||
rocketChatChannelId: mission.rocketChatChannelId,
|
||||
penpotProjectId: mission.penpotProjectId,
|
||||
config: {
|
||||
N8N_API_KEY: process.env.N8N_API_KEY,
|
||||
MISSION_API_URL: process.env.NEXT_PUBLIC_API_URL || 'https://hub.slm-lab.net'
|
||||
}
|
||||
};
|
||||
|
||||
logger.debug('Sending deletion data to N8N', {
|
||||
missionId: n8nDeletionData.missionId,
|
||||
name: n8nDeletionData.name,
|
||||
hasRepoName: !!n8nDeletionData.repoName
|
||||
});
|
||||
|
||||
const n8nResult = await n8nService.triggerMissionDeletion(n8nDeletionData);
|
||||
logger.debug('N8N deletion workflow result', {
|
||||
success: n8nResult.success,
|
||||
hasError: !!n8nResult.error
|
||||
});
|
||||
|
||||
if (!n8nResult.success) {
|
||||
logger.error('N8N deletion workflow failed, but continuing with mission deletion', {
|
||||
error: n8nResult.error
|
||||
});
|
||||
// Continue with deletion even if N8N fails (non-blocking)
|
||||
}
|
||||
|
||||
// Step 2: Delete files from Minio AFTER N8N confirmation
|
||||
// Delete logo if exists
|
||||
if (mission.logo) {
|
||||
try {
|
||||
await deleteMissionLogo(params.missionId, mission.logo);
|
||||
logger.debug('Logo deleted successfully from Minio');
|
||||
} catch (error) {
|
||||
logger.error('Error deleting mission logo from Minio', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
missionId: params.missionId
|
||||
});
|
||||
// Continue deletion even if logo deletion fails
|
||||
}
|
||||
}
|
||||
|
||||
// Delete attachments from Minio
|
||||
if (attachments.length > 0) {
|
||||
logger.debug(`Deleting ${attachments.length} attachment(s) from Minio`);
|
||||
for (const attachment of attachments) {
|
||||
try {
|
||||
await deleteMissionAttachment(attachment.filePath);
|
||||
logger.debug('Attachment deleted successfully', { filename: attachment.filename });
|
||||
} catch (error) {
|
||||
logger.error('Error deleting attachment from Minio', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
filename: attachment.filename
|
||||
});
|
||||
// Continue deletion even if one attachment fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Delete the mission from database (CASCADE will delete MissionUsers and Attachments)
|
||||
await prisma.mission.delete({
|
||||
where: { id: params.missionId }
|
||||
});
|
||||
|
||||
logger.debug('Mission deleted successfully from database', { missionId: params.missionId });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Error deleting mission', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
missionId: params.missionId
|
||||
});
|
||||
return NextResponse.json(mission);
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching mission:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete mission' },
|
||||
{ error: 'Failed to fetch mission', details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { ContactsView } from '@/components/carnet/contacts-view';
|
||||
import { MissionsView } from '@/components/carnet/missions-view';
|
||||
import { MissionFilesView } from '@/components/carnet/mission-files-view';
|
||||
import { MissionFilesManager } from '@/components/carnet/mission-files-manager';
|
||||
import { X, Menu } from "lucide-react";
|
||||
import { ContactDetails } from '@/components/carnet/contact-details';
|
||||
import { parse as parseVCard, format as formatVCard } from 'vcard-parser';
|
||||
@ -697,8 +698,26 @@ export default function CarnetPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMissionSelect = (mission: { id: string; name: string }) => {
|
||||
setSelectedMission(mission);
|
||||
const handleMissionSelect = async (mission: { id: string; name: string }) => {
|
||||
// Fetch full mission details including creator and missionUsers
|
||||
try {
|
||||
const response = await fetch(`/api/missions/${mission.id}`);
|
||||
if (response.ok) {
|
||||
const missionData = await response.json();
|
||||
setSelectedMission({
|
||||
id: missionData.id,
|
||||
name: missionData.name,
|
||||
creatorId: missionData.creatorId || missionData.creator?.id,
|
||||
missionUsers: missionData.missionUsers || []
|
||||
});
|
||||
} else {
|
||||
// Fallback to basic mission data
|
||||
setSelectedMission(mission);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching mission details:', error);
|
||||
setSelectedMission(mission);
|
||||
}
|
||||
setSelectedMissionFile(null);
|
||||
};
|
||||
|
||||
@ -1120,7 +1139,7 @@ export default function CarnetPage() {
|
||||
onDelete={handleContactDelete}
|
||||
/>
|
||||
) : selectedFolder === 'Missions' ? (
|
||||
selectedMission ? (
|
||||
selectedMission && session?.user?.id ? (
|
||||
selectedMissionFile ? (
|
||||
<Editor
|
||||
note={{
|
||||
@ -1138,11 +1157,17 @@ export default function CarnetPage() {
|
||||
currentFolder="Missions"
|
||||
/>
|
||||
) : (
|
||||
<MissionFilesView
|
||||
missionId={selectedMission.id}
|
||||
<MissionFilesManager
|
||||
mission={{
|
||||
id: selectedMission.id,
|
||||
name: selectedMission.name,
|
||||
creatorId: selectedMission.creatorId || '',
|
||||
missionUsers: selectedMission.missionUsers || []
|
||||
}}
|
||||
currentUserId={session.user.id}
|
||||
currentPath="attachments"
|
||||
onFileSelect={handleMissionFileSelect}
|
||||
selectedFileKey={selectedMissionFile?.key}
|
||||
initialPath="attachments"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
|
||||
300
components/carnet/mission-files-manager.tsx
Normal file
300
components/carnet/mission-files-manager.tsx
Normal file
@ -0,0 +1,300 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Folder, FileText, Upload, FolderPlus, Trash2, Loader2, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface MissionFile {
|
||||
type: 'folder' | 'file';
|
||||
name: string;
|
||||
path: string;
|
||||
key: string;
|
||||
size?: number;
|
||||
lastModified?: string;
|
||||
}
|
||||
|
||||
interface MissionUser {
|
||||
role: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
interface Mission {
|
||||
id: string;
|
||||
name: string;
|
||||
creatorId: string;
|
||||
missionUsers?: MissionUser[];
|
||||
}
|
||||
|
||||
interface MissionFilesManagerProps {
|
||||
mission: Mission;
|
||||
currentUserId: string;
|
||||
currentPath?: string;
|
||||
onFileSelect?: (file: MissionFile) => void;
|
||||
selectedFileKey?: string;
|
||||
}
|
||||
|
||||
export const MissionFilesManager: React.FC<MissionFilesManagerProps> = ({
|
||||
mission,
|
||||
currentUserId,
|
||||
currentPath = 'attachments',
|
||||
onFileSelect,
|
||||
selectedFileKey
|
||||
}) => {
|
||||
const [files, setFiles] = useState<MissionFile[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState('');
|
||||
const [showNewFolder, setShowNewFolder] = useState(false);
|
||||
|
||||
// Check user permissions
|
||||
const isCreator = mission.creatorId === currentUserId;
|
||||
const userRole = mission.missionUsers?.find(mu => mu.userId === currentUserId)?.role;
|
||||
const isGardien = userRole === 'gardien-temps' || userRole === 'gardien-parole' || userRole === 'gardien-memoire';
|
||||
const canManage = isCreator || isGardien;
|
||||
|
||||
const fetchFiles = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const url = `/api/missions/${mission.id}/files${currentPath ? `?path=${encodeURIComponent(currentPath)}` : ''}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch files');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// API returns { folders: [], files: [] } or { files: [] }
|
||||
setFiles(data.files || []);
|
||||
} catch (err) {
|
||||
console.error('Error fetching mission files:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load files');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!mission.id) return;
|
||||
fetchFiles();
|
||||
}, [mission.id, currentPath]);
|
||||
|
||||
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file || !canManage) return;
|
||||
|
||||
try {
|
||||
setIsUploading(true);
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('missionId', mission.id);
|
||||
formData.append('path', currentPath);
|
||||
|
||||
const response = await fetch(`/api/missions/${mission.id}/files/upload`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to upload file');
|
||||
}
|
||||
|
||||
// Refresh file list
|
||||
await fetchFiles();
|
||||
} catch (err) {
|
||||
console.error('Error uploading file:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to upload file');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
// Reset input
|
||||
event.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
if (!newFolderName.trim() || !canManage) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const folderPath = currentPath ? `${currentPath}/${newFolderName}` : newFolderName;
|
||||
|
||||
const response = await fetch(`/api/missions/${mission.id}/files/folder`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ path: folderPath })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create folder');
|
||||
}
|
||||
|
||||
// Refresh file list
|
||||
await fetchFiles();
|
||||
|
||||
setNewFolderName('');
|
||||
setShowNewFolder(false);
|
||||
} catch (err) {
|
||||
console.error('Error creating folder:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to create folder');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (file: MissionFile) => {
|
||||
if (!canManage || !confirm(`Êtes-vous sûr de vouloir supprimer ${file.name} ?`)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/missions/${mission.id}/files`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ key: file.key })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete');
|
||||
}
|
||||
|
||||
// Refresh file list
|
||||
await fetchFiles();
|
||||
} catch (err) {
|
||||
console.error('Error deleting file:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-carnet-text-muted" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-2">Erreur</p>
|
||||
<p className="text-carnet-text-muted text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-carnet-bg">
|
||||
<div className="p-4 border-b border-carnet-border">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Folder className="h-5 w-5 text-carnet-text-primary" />
|
||||
<h2 className="text-lg font-semibold text-carnet-text-primary">
|
||||
{currentPath || 'Fichiers'}
|
||||
</h2>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setShowNewFolder(!showNewFolder)}
|
||||
className="p-2 text-carnet-text-primary hover:bg-carnet-hover rounded-md"
|
||||
title="Créer un dossier"
|
||||
>
|
||||
<FolderPlus className="h-5 w-5" />
|
||||
</button>
|
||||
<label className="p-2 text-carnet-text-primary hover:bg-carnet-hover rounded-md cursor-pointer">
|
||||
<Upload className="h-5 w-5" />
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleFileUpload}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showNewFolder && canManage && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleCreateFolder()}
|
||||
placeholder="Nom du dossier"
|
||||
className="flex-1 px-3 py-2 border border-carnet-border rounded-md text-sm text-carnet-text-primary bg-white focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreateFolder}
|
||||
className="px-4 py-2 bg-primary text-white rounded-md text-sm hover:bg-primary/90"
|
||||
>
|
||||
Créer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowNewFolder(false);
|
||||
setNewFolderName('');
|
||||
}}
|
||||
className="px-4 py-2 border border-carnet-border rounded-md text-sm text-carnet-text-primary hover:bg-carnet-hover"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{files.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-carnet-text-muted">Aucun fichier</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-carnet-border">
|
||||
{files.map((file) => {
|
||||
const Icon = file.type === 'folder' ? Folder : FileText;
|
||||
return (
|
||||
<li
|
||||
key={file.key}
|
||||
className={`p-4 hover:bg-carnet-hover flex items-center justify-between group ${
|
||||
selectedFileKey === file.key ? 'bg-carnet-hover' : ''
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="flex items-center space-x-2 flex-1 cursor-pointer"
|
||||
onClick={() => onFileSelect?.(file)}
|
||||
>
|
||||
<Icon className="h-4 w-4 text-carnet-text-muted" />
|
||||
<span className="text-sm text-carnet-text-primary">{file.name}</span>
|
||||
{file.type === 'folder' && (
|
||||
<ChevronRight className="h-4 w-4 text-carnet-text-muted" />
|
||||
)}
|
||||
</div>
|
||||
{canManage && (
|
||||
<button
|
||||
onClick={() => handleDelete(file)}
|
||||
className="p-2 text-red-500 hover:bg-red-50 rounded-md opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{isUploading && (
|
||||
<div className="p-4 border-t border-carnet-border">
|
||||
<div className="flex items-center space-x-2 text-carnet-text-muted">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">Upload en cours...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user