diff --git a/app/api/missions/[missionId]/files/folder/route.ts b/app/api/missions/[missionId]/files/folder/route.ts new file mode 100644 index 0000000..070d38f --- /dev/null +++ b/app/api/missions/[missionId]/files/folder/route.ts @@ -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 { + 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 } + ); + } +} diff --git a/app/api/missions/[missionId]/files/route.ts b/app/api/missions/[missionId]/files/route.ts index 773e63a..3cc395f 100644 --- a/app/api/missions/[missionId]/files/route.ts +++ b/app/api/missions/[missionId]/files/route.ts @@ -33,6 +33,29 @@ async function checkMissionAccess(userId: string, missionId: string): Promise { + 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 { 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 } + ); + } +} diff --git a/app/api/missions/[missionId]/files/upload/route.ts b/app/api/missions/[missionId]/files/upload/route.ts new file mode 100644 index 0000000..74edfd2 --- /dev/null +++ b/app/api/missions/[missionId]/files/upload/route.ts @@ -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 { + 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 } + ); + } +} diff --git a/app/api/missions/[missionId]/route.ts b/app/api/missions/[missionId]/route.ts index ae1caea..04adceb 100644 --- a/app/api/missions/[missionId]/route.ts +++ b/app/api/missions/[missionId]/route.ts @@ -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 } ); } -} \ No newline at end of file +} diff --git a/app/pages/page.tsx b/app/pages/page.tsx index 71bae43..c043523 100644 --- a/app/pages/page.tsx +++ b/app/pages/page.tsx @@ -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 ? ( ) : ( - ) ) : ( diff --git a/components/carnet/mission-files-manager.tsx b/components/carnet/mission-files-manager.tsx new file mode 100644 index 0000000..4745285 --- /dev/null +++ b/components/carnet/mission-files-manager.tsx @@ -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 = ({ + mission, + currentUserId, + currentPath = 'attachments', + onFileSelect, + selectedFileKey +}) => { + const [files, setFiles] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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) => { + 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 ( +
+ +
+ ); + } + + if (error) { + return ( +
+
+

Erreur

+

{error}

+
+
+ ); + } + + return ( +
+
+
+
+ +

+ {currentPath || 'Fichiers'} +

+
+ {canManage && ( +
+ + +
+ )} +
+ {showNewFolder && canManage && ( +
+ 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" + /> + + +
+ )} +
+ +
+ {files.length === 0 ? ( +
+

Aucun fichier

+
+ ) : ( +
    + {files.map((file) => { + const Icon = file.type === 'folder' ? Folder : FileText; + return ( +
  • +
    onFileSelect?.(file)} + > + + {file.name} + {file.type === 'folder' && ( + + )} +
    + {canManage && ( + + )} +
  • + ); + })} +
+ )} +
+ {isUploading && ( +
+
+ + Upload en cours... +
+
+ )} +
+ ); +};