Pages corrections pages missions

This commit is contained in:
alma 2026-01-16 14:31:35 +01:00
parent b543c87e42
commit eee7fb06b5
6 changed files with 656 additions and 403 deletions

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

View File

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

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

View File

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

View File

@ -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"
/>
)
) : (

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