This commit is contained in:
alma 2025-05-05 11:22:14 +02:00
parent d915b70751
commit b4347c5914
11 changed files with 1731 additions and 8 deletions

View File

@ -0,0 +1,80 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { prisma } from '@/lib/prisma';
import { deleteMissionAttachment } from '@/lib/mission-uploads';
// Helper function to check authentication
async function checkAuth(request: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
console.error('Unauthorized access attempt:', {
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers)
});
return { authorized: false, userId: null };
}
return { authorized: true, userId: session.user.id };
}
// DELETE endpoint to remove an attachment
export async function DELETE(
request: Request,
{ params }: { params: { missionId: string, attachmentId: string } }
) {
try {
const { authorized, userId } = await checkAuth(request);
if (!authorized || !userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { missionId, attachmentId } = params;
if (!missionId || !attachmentId) {
return NextResponse.json({ error: 'Mission ID and Attachment ID are required' }, { status: 400 });
}
// Check if mission exists and user has access to it
const mission = await prisma.mission.findFirst({
where: {
id: missionId,
OR: [
{ creatorId: userId },
{ missionUsers: { some: { userId, role: 'gardien-memoire' } } } // Only mission creator or memory guardian can delete
]
},
});
if (!mission) {
return NextResponse.json({ error: 'Mission not found or access denied' }, { status: 404 });
}
// Get the attachment to delete
const attachment = await prisma.attachment.findUnique({
where: {
id: attachmentId,
missionId
}
});
if (!attachment) {
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 });
}
// Delete the file from Minio
await deleteMissionAttachment(attachment.filePath);
// Delete the attachment record from the database
await prisma.attachment.delete({
where: { id: attachmentId }
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting attachment:', error);
return NextResponse.json({
error: 'Internal server error',
details: error instanceof Error ? error.message : String(error)
}, { status: 500 });
}
}

View File

@ -0,0 +1,84 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { prisma } from '@/lib/prisma';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { s3Client, S3_CONFIG } from '@/lib/s3';
// Helper function to check authentication
async function checkAuth(request: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
console.error('Unauthorized access attempt:', {
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers)
});
return { authorized: false, userId: null };
}
return { authorized: true, userId: session.user.id };
}
// GET endpoint to download an attachment
export async function GET(
request: Request,
{ params }: { params: { missionId: string, attachmentId: string } }
) {
try {
const { authorized, userId } = await checkAuth(request);
if (!authorized || !userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { missionId, attachmentId } = params;
if (!missionId || !attachmentId) {
return NextResponse.json({ error: 'Mission ID and Attachment ID are required' }, { status: 400 });
}
// Check if mission exists and user has access to it
const mission = await prisma.mission.findFirst({
where: {
id: missionId,
OR: [
{ creatorId: userId },
{ missionUsers: { some: { userId } } }
]
},
});
if (!mission) {
return NextResponse.json({ error: 'Mission not found or access denied' }, { status: 404 });
}
// Get the attachment
const attachment = await prisma.attachment.findUnique({
where: {
id: attachmentId,
missionId
}
});
if (!attachment) {
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 });
}
// Generate a presigned URL for downloading
const command = new GetObjectCommand({
Bucket: S3_CONFIG.bucket,
Key: attachment.filePath
});
// Set a short expiry for security (5 minutes)
const url = await getSignedUrl(s3Client, command, { expiresIn: 300 });
// Redirect the user to the presigned URL for direct download
return NextResponse.redirect(url);
} catch (error) {
console.error('Error downloading attachment:', error);
return NextResponse.json({
error: 'Internal server error',
details: error instanceof Error ? error.message : String(error)
}, { status: 500 });
}
}

View File

@ -0,0 +1,73 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { prisma } from '@/lib/prisma';
// Helper function to check authentication
async function checkAuth(request: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
console.error('Unauthorized access attempt:', {
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers)
});
return { authorized: false, userId: null };
}
return { authorized: true, userId: session.user.id };
}
// GET endpoint to list all attachments for a mission
export async function GET(
request: Request,
{ params }: { params: { missionId: string } }
) {
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 it
const mission = await prisma.mission.findFirst({
where: {
id: missionId,
OR: [
{ creatorId: userId },
{ missionUsers: { some: { userId } } }
]
},
});
if (!mission) {
return NextResponse.json({ error: 'Mission not found or access denied' }, { status: 404 });
}
// Get all attachments for the mission
const attachments = await prisma.attachment.findMany({
where: { missionId },
orderBy: { createdAt: 'desc' },
select: {
id: true,
filename: true,
filePath: true,
fileType: true,
fileSize: true,
createdAt: true
}
});
return NextResponse.json(attachments);
} catch (error) {
console.error('Error fetching mission attachments:', error);
return NextResponse.json({
error: 'Internal server error',
details: error instanceof Error ? error.message : String(error)
}, { status: 500 });
}
}

View File

@ -0,0 +1,302 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { prisma } from '@/lib/prisma';
import { deleteMissionLogo } from '@/lib/mission-uploads';
// Helper function to check authentication
async function checkAuth(request: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
console.error('Unauthorized access attempt:', {
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers)
});
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,
{ params }: { params: { missionId: string } }
) {
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 });
}
// Get mission with detailed info
const mission = await prisma.mission.findFirst({
where: {
id: missionId,
OR: [
{ creatorId: userId },
{ missionUsers: { some: { userId } } }
]
},
include: {
creator: {
select: {
id: true,
email: true
}
},
missionUsers: {
select: {
id: true,
role: true,
user: {
select: {
id: true,
email: true
}
}
}
},
attachments: {
select: {
id: true,
filename: true,
filePath: true,
fileType: true,
fileSize: true,
createdAt: true
},
orderBy: { createdAt: 'desc' }
}
}
});
if (!mission) {
return NextResponse.json({ error: 'Mission not found or access denied' }, { status: 404 });
}
return NextResponse.json(mission);
} catch (error) {
console.error('Error retrieving mission:', error);
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,
{ params }: { params: { missionId: string } }
) {
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.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;
// Update the mission data
const updatedMission = await prisma.mission.update({
where: { id: missionId },
data: {
name,
logo,
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.missionUser.findMany({
where: {
missionId,
role: { in: ['gardien-temps', 'gardien-parole', 'gardien-memoire'] }
}
});
// Delete all guardians
if (currentGuardians.length > 0) {
await prisma.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.missionUser.createMany({
data: guardianEntries
});
}
}
// Update volunteers if provided
if (volunteers) {
// Get current volunteers
const currentVolunteers = await prisma.missionUser.findMany({
where: {
missionId,
role: 'volontaire'
}
});
// Delete all volunteers
if (currentVolunteers.length > 0) {
await prisma.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.missionUser.createMany({
data: volunteerEntries
});
}
}
return NextResponse.json({
success: true,
mission: {
id: updatedMission.id,
name: updatedMission.name,
updatedAt: updatedMission.updatedAt
}
});
} catch (error) {
console.error('Error updating mission:', error);
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,
{ params }: { params: { missionId: string } }
) {
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 is the creator
const mission = await prisma.mission.findFirst({
where: {
id: missionId,
creatorId: userId // Only creator can delete a mission
},
include: {
attachments: true
}
});
if (!mission) {
return NextResponse.json({ error: 'Mission not found or not authorized to delete' }, { status: 404 });
}
// Delete logo if exists
if (mission.logo) {
try {
await deleteMissionLogo(mission.logo);
} catch (error) {
console.error('Error deleting mission logo:', error);
// Continue deletion even if logo deletion fails
}
}
// Delete mission (cascade will handle missionUsers and attachments)
await prisma.mission.delete({
where: { id: missionId }
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting mission:', error);
return NextResponse.json({
error: 'Internal server error',
details: error instanceof Error ? error.message : String(error)
}, { status: 500 });
}
}

213
app/api/missions/route.ts Normal file
View File

@ -0,0 +1,213 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { prisma } from '@/lib/prisma';
// Helper function to check authentication
async function checkAuth(request: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
console.error('Unauthorized access attempt:', {
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers)
});
return { authorized: false, userId: null };
}
return { authorized: true, userId: session.user.id };
}
// GET endpoint to list missions with filters
export async function GET(request: Request) {
try {
const { authorized, userId } = await checkAuth(request);
if (!authorized || !userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const limit = Number(searchParams.get('limit') || '10');
const offset = Number(searchParams.get('offset') || '0');
const search = searchParams.get('search');
// Build query conditions
const where: any = {};
// Add search filter if provided
if (search) {
where.OR = [
{ name: { contains: search, mode: 'insensitive' } },
{ intention: { contains: search, mode: 'insensitive' } }
];
}
// Get missions with basic info
const missions = await prisma.mission.findMany({
where,
skip: offset,
take: limit,
orderBy: { createdAt: 'desc' },
select: {
id: true,
name: true,
logo: true,
oddScope: true,
niveau: true,
missionType: true,
projection: true,
createdAt: true,
creator: {
select: {
id: true,
email: true
}
},
missionUsers: {
select: {
id: true,
role: true,
user: {
select: {
id: true,
email: true
}
}
}
}
}
});
// Get total count
const totalCount = await prisma.mission.count({ where });
return NextResponse.json({
missions,
pagination: {
total: totalCount,
offset,
limit
}
});
} catch (error) {
console.error('Error listing missions:', error);
return NextResponse.json({
error: 'Internal server error',
details: error instanceof Error ? error.message : String(error)
}, { status: 500 });
}
}
// POST endpoint to create a new mission
export async function POST(request: Request) {
try {
const { authorized, userId } = await checkAuth(request);
if (!authorized || !userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Parse the request body
const body = await request.json();
const {
name,
logo,
oddScope,
niveau,
intention,
missionType,
donneurDOrdre,
projection,
services,
participation,
profils,
guardians,
volunteers
} = body;
// Validate required fields
if (!name || !niveau || !intention || !missionType || !donneurDOrdre || !projection) {
return NextResponse.json({
error: 'Missing required fields',
required: {
name: true,
niveau: true,
intention: true,
missionType: true,
donneurDOrdre: true,
projection: true
},
received: {
name: !!name,
niveau: !!niveau,
intention: !!intention,
missionType: !!missionType,
donneurDOrdre: !!donneurDOrdre,
projection: !!projection
}
}, { status: 400 });
}
// Create the mission
const mission = await prisma.mission.create({
data: {
name,
logo,
oddScope: oddScope || [],
niveau,
intention,
missionType,
donneurDOrdre,
projection,
services: services || [],
participation,
profils: profils || [],
creatorId: userId
}
});
// Add guardians if provided
if (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: mission.id
}));
if (guardianEntries.length > 0) {
await prisma.missionUser.createMany({
data: guardianEntries
});
}
}
// Add volunteers if provided
if (volunteers && volunteers.length > 0) {
const volunteerEntries = volunteers.map((userId: string) => ({
role: 'volontaire',
userId,
missionId: mission.id
}));
await prisma.missionUser.createMany({
data: volunteerEntries
});
}
return NextResponse.json({
success: true,
mission: {
id: mission.id,
name: mission.name,
createdAt: mission.createdAt
}
});
} catch (error) {
console.error('Error creating mission:', error);
return NextResponse.json({
error: 'Internal server error',
details: error instanceof Error ? error.message : String(error)
}, { status: 500 });
}
}

View File

@ -0,0 +1,176 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { PrismaClient } from '@prisma/client';
import { prisma } from '@/lib/prisma';
import {
uploadMissionLogo,
uploadMissionAttachment,
generateMissionLogoUploadUrl,
generateMissionAttachmentUploadUrl
} from '@/lib/mission-uploads';
// Helper function to check authentication
async function checkAuth(request: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
console.error('Unauthorized access attempt:', {
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers)
});
return { authorized: false, userId: null };
}
return { authorized: true, userId: session.user.id };
}
// Generate presigned URL for direct upload to S3/Minio
export async function GET(request: Request) {
try {
const { authorized, userId } = await checkAuth(request);
if (!authorized || !userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const missionId = searchParams.get('missionId');
const type = searchParams.get('type'); // 'logo' or 'attachment'
const filename = searchParams.get('filename');
if (!missionId || !type || !filename) {
return NextResponse.json({
error: 'Missing required parameters',
required: { missionId: true, type: true, filename: true },
received: { missionId: !!missionId, type: !!type, filename: !!filename }
}, { status: 400 });
}
// Verify that the mission exists and user has access to it
const mission = await prisma.mission.findUnique({
where: { id: missionId },
select: { id: true, creatorId: true }
});
if (!mission) {
return NextResponse.json({ error: 'Mission not found' }, { status: 404 });
}
// Currently only allow creator to upload files
// You can modify this to include other roles if needed
if (mission.creatorId !== userId) {
return NextResponse.json({ error: 'Not authorized to upload to this mission' }, { status: 403 });
}
let result;
if (type === 'logo') {
// For logo, we expect filename to contain the file extension (e.g., '.jpg')
const fileExtension = filename.substring(filename.lastIndexOf('.'));
result = await generateMissionLogoUploadUrl(userId, missionId, fileExtension);
} else if (type === 'attachment') {
result = await generateMissionAttachmentUploadUrl(userId, missionId, filename);
} else {
return NextResponse.json({ error: 'Invalid upload type' }, { status: 400 });
}
return NextResponse.json(result);
} catch (error) {
console.error('Error generating upload URL:', error);
return NextResponse.json({
error: 'Internal server error',
details: error instanceof Error ? error.message : String(error)
}, { status: 500 });
}
}
// Handle file upload (server-side)
export async function POST(request: Request) {
try {
const { authorized, userId } = await checkAuth(request);
if (!authorized || !userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Parse the form data
const formData = await request.formData();
const missionId = formData.get('missionId') as string;
const type = formData.get('type') as string; // 'logo' or 'attachment'
const file = formData.get('file') as File;
if (!missionId || !type || !file) {
return NextResponse.json({
error: 'Missing required fields',
required: { missionId: true, type: true, file: true },
received: { missionId: !!missionId, type: !!type, file: !!file }
}, { status: 400 });
}
// Verify that the mission exists and user has access to it
const mission = await prisma.mission.findUnique({
where: { id: missionId },
select: { id: true, creatorId: true }
});
if (!mission) {
return NextResponse.json({ error: 'Mission not found' }, { status: 404 });
}
// Currently only allow creator to upload files
if (mission.creatorId !== userId) {
return NextResponse.json({ error: 'Not authorized to upload to this mission' }, { status: 403 });
}
if (type === 'logo') {
// Upload logo file to Minio
const { filePath } = await uploadMissionLogo(userId, missionId, file);
// Update mission record with logo path
await prisma.mission.update({
where: { id: missionId },
data: { logo: filePath }
});
return NextResponse.json({ success: true, filePath });
}
else if (type === 'attachment') {
// Upload attachment file to Minio
const { filename, filePath, fileType, fileSize } = await uploadMissionAttachment(
userId,
missionId,
file
);
// Create attachment record in database
const attachment = await prisma.attachment.create({
data: {
filename,
filePath,
fileType,
fileSize,
missionId,
uploaderId: userId
}
});
return NextResponse.json({
success: true,
attachment: {
id: attachment.id,
filename: attachment.filename,
filePath: attachment.filePath,
fileType: attachment.fileType,
fileSize: attachment.fileSize,
createdAt: attachment.createdAt
}
});
}
else {
return NextResponse.json({ error: 'Invalid upload type' }, { status: 400 });
}
} catch (error) {
console.error('Error uploading file:', error);
return NextResponse.json({
error: 'Internal server error',
details: error instanceof Error ? error.message : String(error)
}, { status: 500 });
}
}

View File

@ -0,0 +1,292 @@
"use client";
import React, { useState, useEffect } from 'react';
import {
FileText,
Image,
File,
Trash2,
Download,
Loader2,
FileSpreadsheet,
FileArchive,
FileVideo,
FileAudio,
FilePlus
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useSession } from 'next-auth/react';
import { toast } from '@/components/ui/use-toast';
import { FileUpload } from './file-upload';
interface Attachment {
id: string;
filename: string;
filePath: string;
fileType: string;
fileSize: number;
createdAt: string;
}
interface AttachmentsListProps {
missionId: string;
initialAttachments?: Attachment[];
allowUpload?: boolean;
allowDelete?: boolean;
onAttachmentAdded?: (attachment: Attachment) => void;
onAttachmentDeleted?: (attachmentId: string) => void;
}
export function AttachmentsList({
missionId,
initialAttachments = [],
allowUpload = true,
allowDelete = true,
onAttachmentAdded,
onAttachmentDeleted
}: AttachmentsListProps) {
const { data: session } = useSession();
const [attachments, setAttachments] = useState<Attachment[]>(initialAttachments);
const [isLoading, setIsLoading] = useState(false);
const [deleteAttachment, setDeleteAttachment] = useState<Attachment | null>(null);
const [showUpload, setShowUpload] = useState(false);
// Fetch attachments for the mission if not provided initially
useEffect(() => {
if (initialAttachments.length === 0) {
fetchAttachments();
}
}, [missionId]);
const fetchAttachments = async () => {
if (!missionId || !session?.user?.id) return;
setIsLoading(true);
try {
const response = await fetch(`/api/missions/${missionId}/attachments`);
if (!response.ok) {
throw new Error('Failed to fetch attachments');
}
const data = await response.json();
setAttachments(data);
} catch (error) {
console.error('Error fetching attachments:', error);
toast({
title: 'Error',
description: 'Failed to load attachments',
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
const handleAttachmentUploaded = (data: any) => {
if (data?.attachment) {
setAttachments(prev => [...prev, data.attachment]);
setShowUpload(false);
if (onAttachmentAdded) {
onAttachmentAdded(data.attachment);
}
}
};
const handleAttachmentDelete = async () => {
if (!deleteAttachment) return;
try {
const response = await fetch(`/api/missions/${missionId}/attachments/${deleteAttachment.id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete attachment');
}
setAttachments(prev => prev.filter(a => a.id !== deleteAttachment.id));
if (onAttachmentDeleted) {
onAttachmentDeleted(deleteAttachment.id);
}
toast({
title: 'Success',
description: 'Attachment deleted successfully',
variant: 'default',
});
} catch (error) {
console.error('Error deleting attachment:', error);
toast({
title: 'Error',
description: 'Failed to delete attachment',
variant: 'destructive',
});
} finally {
setDeleteAttachment(null);
}
};
const getFileIcon = (fileType: string) => {
if (fileType.startsWith('image/')) {
return <Image className="h-5 w-5 text-blue-500" />;
} else if (fileType.includes('pdf')) {
return <FileText className="h-5 w-5 text-red-500" />;
} else if (fileType.includes('spreadsheet') || fileType.includes('excel') || fileType.includes('csv')) {
return <FileSpreadsheet className="h-5 w-5 text-green-500" />;
} else if (fileType.includes('zip') || fileType.includes('compressed')) {
return <FileArchive className="h-5 w-5 text-purple-500" />;
} else if (fileType.includes('video')) {
return <FileVideo className="h-5 w-5 text-pink-500" />;
} else if (fileType.includes('audio')) {
return <FileAudio className="h-5 w-5 text-orange-500" />;
} else {
return <File className="h-5 w-5 text-gray-500" />;
}
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) {
return `${bytes} B`;
} else if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
} else {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
};
return (
<div className="space-y-4">
{(allowUpload && !showUpload) && (
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => setShowUpload(true)}
className="bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
>
<FilePlus className="h-4 w-4 mr-1" />
Add Attachment
</Button>
</div>
)}
{showUpload && (
<div className="mb-6">
<h4 className="text-sm font-medium mb-2 text-gray-700">Upload New Attachment</h4>
<FileUpload
type="attachment"
missionId={missionId}
onUploadComplete={handleAttachmentUploaded}
/>
<div className="mt-2 flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => setShowUpload(false)}
className="text-gray-500"
>
Cancel
</Button>
</div>
</div>
)}
{isLoading ? (
<div className="flex justify-center items-center p-8">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
) : attachments.length === 0 ? (
<div className="text-center p-8 bg-gray-50 border border-gray-200 rounded-md">
<File className="h-10 w-10 text-gray-300 mx-auto mb-2" />
<p className="text-sm text-gray-500">No attachments yet</p>
{allowUpload && (
<Button
variant="outline"
size="sm"
onClick={() => setShowUpload(true)}
className="mt-4 bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
>
<FilePlus className="h-4 w-4 mr-1" />
Add your first attachment
</Button>
)}
</div>
) : (
<div className="border rounded-md overflow-hidden">
<ul className="divide-y">
{attachments.map((attachment) => (
<li key={attachment.id} className="flex items-center justify-between p-3 hover:bg-gray-50">
<div className="flex items-center space-x-3">
<div className="flex-shrink-0">
{getFileIcon(attachment.fileType)}
</div>
<div>
<p className="font-medium text-sm text-gray-900">{attachment.filename}</p>
<p className="text-xs text-gray-500">
{formatFileSize(attachment.fileSize)} {new Date(attachment.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex space-x-1">
<Button
variant="ghost"
size="sm"
asChild
className="text-gray-500 hover:text-gray-700 hover:bg-gray-100"
>
<a
href={`/api/missions/${missionId}/attachments/download/${attachment.id}`}
download={attachment.filename}
>
<Download className="h-4 w-4" />
</a>
</Button>
{allowDelete && (
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteAttachment(attachment)}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</li>
))}
</ul>
</div>
)}
<AlertDialog open={!!deleteAttachment} onOpenChange={(open) => !open && setDeleteAttachment(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Attachment</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{deleteAttachment?.filename}"? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleAttachmentDelete} className="bg-red-600 hover:bg-red-700 text-white">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@ -0,0 +1,262 @@
"use client";
import React, { useState, useRef } from 'react';
import { UploadCloud, X, Check, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useSession } from 'next-auth/react';
import { toast } from '@/components/ui/use-toast';
interface FileUploadProps {
type: 'logo' | 'attachment';
missionId: string;
onUploadComplete?: (data: any) => void;
maxSize?: number; // in bytes, default 5MB
acceptedFileTypes?: string;
}
export function FileUpload({
type,
missionId,
onUploadComplete,
maxSize = 5 * 1024 * 1024, // 5MB
acceptedFileTypes = type === 'logo' ? 'image/*' : '.pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png'
}: FileUploadProps) {
const { data: session } = useSession();
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [file, setFile] = useState<File | null>(null);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Handle drag events
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const validateFile = (file: File): boolean => {
// Check file size
if (file.size > maxSize) {
setError(`File size exceeds the limit of ${maxSize / (1024 * 1024)}MB`);
return false;
}
// Check file type for logo
if (type === 'logo' && !file.type.startsWith('image/')) {
setError('Only image files are allowed for logo');
return false;
}
// For attachments, check file extension
if (type === 'attachment') {
const ext = file.name.split('.').pop()?.toLowerCase();
const allowedExt = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'jpg', 'jpeg', 'png'];
if (ext && !allowedExt.includes(ext)) {
setError(`File type .${ext} is not allowed. Allowed types: ${allowedExt.join(', ')}`);
return false;
}
}
setError(null);
return true;
};
const handleFileDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const droppedFile = e.dataTransfer.files[0];
if (validateFile(droppedFile)) {
setFile(droppedFile);
}
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const selectedFile = e.target.files[0];
if (validateFile(selectedFile)) {
setFile(selectedFile);
}
}
};
const handleUpload = async () => {
if (!file || !session?.user?.id || !missionId) return;
setIsUploading(true);
setProgress(0);
try {
// Create form data
const formData = new FormData();
formData.append('file', file);
formData.append('missionId', missionId);
formData.append('type', type);
// Upload the file
const response = await fetch('/api/missions/upload', {
method: 'POST',
body: formData
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Upload failed');
}
const result = await response.json();
setProgress(100);
// Reset file after successful upload
setTimeout(() => {
setFile(null);
setIsUploading(false);
setProgress(0);
// Call the callback if provided
if (onUploadComplete) {
onUploadComplete(result);
}
toast({
title: 'File uploaded successfully',
description: type === 'logo' ? 'Logo has been updated' : `${file.name} has been added to attachments`,
variant: 'default',
});
}, 1000);
} catch (error) {
console.error('Upload error:', error);
setIsUploading(false);
toast({
title: 'Upload failed',
description: error instanceof Error ? error.message : 'An error occurred during upload',
variant: 'destructive',
});
}
};
const handleCancel = () => {
setFile(null);
setError(null);
};
return (
<div className="w-full">
{!file ? (
<div
className={`border-2 border-dashed rounded-md p-6 text-center transition-colors ${
isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300 bg-gray-50'
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleFileDrop}
>
<div className="flex flex-col items-center justify-center">
<UploadCloud className="h-10 w-10 text-gray-400 mb-2" />
<p className="text-sm mb-2 font-medium text-gray-700">
{type === 'logo' ? 'Upload logo image' : 'Upload attachment'}
</p>
<p className="text-xs text-gray-500 mb-4">
Drag and drop or click to browse
</p>
<Button
variant="outline"
size="sm"
className="bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
onClick={() => fileInputRef.current?.click()}
>
Browse Files
</Button>
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept={acceptedFileTypes}
/>
{error && (
<p className="text-xs text-red-500 mt-2">{error}</p>
)}
</div>
</div>
) : (
<div className="border rounded-md p-4 bg-white">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="flex-shrink-0 h-10 w-10 bg-gray-100 rounded-md flex items-center justify-center">
{type === 'logo' ? (
<img
src={URL.createObjectURL(file)}
alt="Preview"
className="h-full w-full object-cover rounded-md"
/>
) : (
<div className="text-xs font-bold bg-blue-100 text-blue-600 h-full w-full rounded-md flex items-center justify-center">
{file.name.split('.').pop()?.toUpperCase()}
</div>
)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900 truncate">
{file.name}
</p>
<p className="text-xs text-gray-500">
{(file.size / 1024).toFixed(2)} KB
</p>
</div>
</div>
<div className="flex items-center space-x-2">
{isUploading ? (
<div className="flex items-center">
<Loader2 className="animate-spin h-4 w-4 mr-1 text-blue-500" />
<span className="text-xs text-gray-500">{progress}%</span>
</div>
) : (
<>
<Button
variant="ghost"
size="sm"
className="text-red-500 hover:text-red-700 hover:bg-red-50"
onClick={handleCancel}
>
<X className="h-4 w-4" />
</Button>
<Button
variant="default"
size="sm"
className="bg-blue-600 hover:bg-blue-700 text-white"
onClick={handleUpload}
>
<Check className="h-4 w-4 mr-1" />
Upload
</Button>
</>
)}
</div>
</div>
{isUploading && (
<div className="w-full bg-gray-200 rounded-full h-1.5 mt-3">
<div
className="bg-blue-600 h-1.5 rounded-full"
style={{ width: `${progress}%` }}
></div>
</div>
)}
</div>
)}
</div>
);
}

View File

@ -25,6 +25,8 @@ import {
DropdownMenuItem,
DropdownMenuTrigger
} from "../ui/dropdown-menu";
import { FileUpload } from "./file-upload";
import { AttachmentsList } from "./attachments-list";
// Define interfaces for user and group data
interface User {
@ -57,6 +59,20 @@ export function MissionsAdminPanel() {
const [gardienDeLaParole, setGardienDeLaParole] = useState<string | null>(null);
const [gardienDeLaMemoire, setGardienDeLaMemoire] = useState<string | null>(null);
const [volontaires, setVolontaires] = useState<string[]>([]);
const [missionId, setMissionId] = useState<string>("");
const [missionData, setMissionData] = useState<{
name?: string;
logo?: string;
oddScope?: string[];
niveau?: string;
intention?: string;
missionType?: string;
donneurDOrdre?: string;
projection?: string;
services?: string[];
participation?: string;
profils?: string[];
}>({});
// State for storing fetched data
const [users, setUsers] = useState<User[]>([]);
@ -291,9 +307,19 @@ export function MissionsAdminPanel() {
<div>
<label className="block text-sm font-medium mb-1 text-gray-700">Logo</label>
<div className="border border-dashed rounded-md p-6 text-center bg-gray-50">
<Button variant="outline" className="mb-2 bg-white text-gray-700 border-gray-300 hover:bg-gray-50">Browse</Button>
</div>
<FileUpload
type="logo"
missionId={missionId || ""}
onUploadComplete={(data) => {
// Handle logo upload complete
if (data?.filePath) {
setMissionData(prev => ({
...prev,
logo: data.filePath
}));
}
}}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@ -614,11 +640,12 @@ export function MissionsAdminPanel() {
<TabsContent value="attachments" className="space-y-6">
<div>
<label className="block text-sm font-medium mb-1 text-gray-700">Attachments</label>
<div className="border border-dashed rounded-md p-6 text-center bg-gray-50">
<Button variant="outline" className="mb-2 bg-white text-gray-700 border-gray-300 hover:bg-gray-50">Browse</Button>
<p className="text-sm text-gray-500">Upload file .pdf, .doc, .docx</p>
</div>
<label className="block text-sm font-medium mb-1 text-gray-700">Attachements</label>
<AttachmentsList
missionId={missionId || ""}
allowUpload={true}
allowDelete={true}
/>
</div>
</TabsContent>

155
lib/mission-uploads.ts Normal file
View File

@ -0,0 +1,155 @@
import { s3Client, putObject, generatePresignedUrl, S3_CONFIG, deleteObject } from '@/lib/s3';
import { PutObjectCommand } from '@aws-sdk/client-s3';
/**
* Utilities for mission-related file uploads using Minio
*/
// Generate the mission logo path in Minio
export function getMissionLogoPath(userId: string, missionId: string, fileExtension: string): string {
return `user-${userId}/missions/${missionId}/logo${fileExtension}`;
}
// Generate the mission attachment path in Minio
export function getMissionAttachmentPath(userId: string, missionId: string, filename: string): string {
return `user-${userId}/missions/${missionId}/attachments/${filename}`;
}
// Upload mission logo to Minio
export async function uploadMissionLogo(
userId: string,
missionId: string,
file: File
): Promise<{ filePath: string }> {
try {
// Get file extension
const fileExtension = file.name.substring(file.name.lastIndexOf('.'));
// Create file path
const filePath = getMissionLogoPath(userId, missionId, fileExtension);
// Convert file to ArrayBuffer
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Upload to Minio
const command = new PutObjectCommand({
Bucket: S3_CONFIG.bucket,
Key: filePath,
Body: buffer,
ContentType: file.type,
});
await s3Client.send(command);
return { filePath };
} catch (error) {
console.error('Error uploading mission logo:', error);
throw new Error('Failed to upload mission logo');
}
}
// Upload mission attachment to Minio
export async function uploadMissionAttachment(
userId: string,
missionId: string,
file: File
): Promise<{
filename: string,
filePath: string,
fileType: string,
fileSize: number
}> {
try {
// Create file path
const filePath = getMissionAttachmentPath(userId, missionId, file.name);
// Convert file to ArrayBuffer
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Upload to Minio
const command = new PutObjectCommand({
Bucket: S3_CONFIG.bucket,
Key: filePath,
Body: buffer,
ContentType: file.type,
});
await s3Client.send(command);
return {
filename: file.name,
filePath,
fileType: file.type,
fileSize: file.size,
};
} catch (error) {
console.error('Error uploading mission attachment:', error);
throw new Error('Failed to upload mission attachment');
}
}
// Generate presigned URL for direct browser upload of mission logo
export async function generateMissionLogoUploadUrl(
userId: string,
missionId: string,
fileExtension: string,
expiresIn = 3600
): Promise<{
uploadUrl: string,
filePath: string
}> {
try {
const filePath = getMissionLogoPath(userId, missionId, fileExtension);
const uploadUrl = await generatePresignedUrl(filePath, expiresIn);
return { uploadUrl, filePath };
} catch (error) {
console.error('Error generating mission logo upload URL:', error);
throw new Error('Failed to generate upload URL for mission logo');
}
}
// Generate presigned URL for direct browser upload of mission attachment
export async function generateMissionAttachmentUploadUrl(
userId: string,
missionId: string,
filename: string,
expiresIn = 3600
): Promise<{
uploadUrl: string,
filePath: string
}> {
try {
const filePath = getMissionAttachmentPath(userId, missionId, filename);
const uploadUrl = await generatePresignedUrl(filePath, expiresIn);
return { uploadUrl, filePath };
} catch (error) {
console.error('Error generating mission attachment upload URL:', error);
throw new Error('Failed to generate upload URL for mission attachment');
}
}
// Delete mission attachment from Minio
export async function deleteMissionAttachment(filePath: string): Promise<boolean> {
try {
await deleteObject(filePath);
return true;
} catch (error) {
console.error('Error deleting mission attachment:', error);
throw new Error('Failed to delete mission attachment');
}
}
// Delete mission logo from Minio
export async function deleteMissionLogo(filePath: string): Promise<boolean> {
try {
await deleteObject(filePath);
return true;
} catch (error) {
console.error('Error deleting mission logo:', error);
throw new Error('Failed to delete mission logo');
}
}

View File

@ -22,6 +22,9 @@ model User {
mailCredentials MailCredentials[]
webdavCredentials WebDAVCredentials?
announcements Announcement[]
missions Mission[]
missionUsers MissionUser[]
uploadedAttachments Attachment[]
}
model Calendar {
@ -112,4 +115,60 @@ model Announcement {
targetRoles String[]
@@index([authorId])
}
// Mission models
model Mission {
id String @id @default(uuid())
name String
logo String? // Stores the path to the logo in Minio
oddScope String[] // Categories / ODD scope
niveau String // Project Type / Niveau
intention String // Description / Intention
missionType String // Project location type / Type de mission
donneurDOrdre String // Volunteer Type / Donneur d'ordre
projection String // Duration / Projection
services String[] // Experience / Services
participation String? // Friendly Address / Participation
profils String[] // Level / Profils
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade)
creatorId String
attachments Attachment[]
missionUsers MissionUser[]
@@index([creatorId])
}
model Attachment {
id String @id @default(uuid())
filename String // Original filename
filePath String // Path in Minio: user-${userId}/missions/${missionId}/attachments/${filename}
fileType String // MIME type
fileSize Int // Size in bytes
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
mission Mission @relation(fields: [missionId], references: [id], onDelete: Cascade)
missionId String
uploader User @relation(fields: [uploaderId], references: [id], onDelete: Cascade)
uploaderId String
@@index([missionId])
@@index([uploaderId])
}
model MissionUser {
id String @id @default(uuid())
role String // 'gardien-temps', 'gardien-parole', 'gardien-memoire', 'volontaire'
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
mission Mission @relation(fields: [missionId], references: [id], onDelete: Cascade)
missionId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
@@unique([missionId, userId, role])
@@index([missionId])
@@index([userId])
}