diff --git a/app/api/missions/[missionId]/attachments/[attachmentId]/route.ts b/app/api/missions/[missionId]/attachments/[attachmentId]/route.ts new file mode 100644 index 00000000..3ce0f0b2 --- /dev/null +++ b/app/api/missions/[missionId]/attachments/[attachmentId]/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/app/api/missions/[missionId]/attachments/download/[attachmentId]/route.ts b/app/api/missions/[missionId]/attachments/download/[attachmentId]/route.ts new file mode 100644 index 00000000..83bd6f4f --- /dev/null +++ b/app/api/missions/[missionId]/attachments/download/[attachmentId]/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/app/api/missions/[missionId]/attachments/route.ts b/app/api/missions/[missionId]/attachments/route.ts new file mode 100644 index 00000000..7a2ad6f5 --- /dev/null +++ b/app/api/missions/[missionId]/attachments/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/app/api/missions/[missionId]/route.ts b/app/api/missions/[missionId]/route.ts new file mode 100644 index 00000000..a39dec8c --- /dev/null +++ b/app/api/missions/[missionId]/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/app/api/missions/route.ts b/app/api/missions/route.ts new file mode 100644 index 00000000..544e2e46 --- /dev/null +++ b/app/api/missions/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/app/api/missions/upload/route.ts b/app/api/missions/upload/route.ts new file mode 100644 index 00000000..f7823b55 --- /dev/null +++ b/app/api/missions/upload/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/components/missions/attachments-list.tsx b/components/missions/attachments-list.tsx new file mode 100644 index 00000000..f3638caf --- /dev/null +++ b/components/missions/attachments-list.tsx @@ -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(initialAttachments); + const [isLoading, setIsLoading] = useState(false); + const [deleteAttachment, setDeleteAttachment] = useState(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 ; + } else if (fileType.includes('pdf')) { + return ; + } else if (fileType.includes('spreadsheet') || fileType.includes('excel') || fileType.includes('csv')) { + return ; + } else if (fileType.includes('zip') || fileType.includes('compressed')) { + return ; + } else if (fileType.includes('video')) { + return ; + } else if (fileType.includes('audio')) { + return ; + } else { + return ; + } + }; + + 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 ( +
+ {(allowUpload && !showUpload) && ( +
+ +
+ )} + + {showUpload && ( +
+

Upload New Attachment

+ +
+ +
+
+ )} + + {isLoading ? ( +
+ +
+ ) : attachments.length === 0 ? ( +
+ +

No attachments yet

+ {allowUpload && ( + + )} +
+ ) : ( +
+
    + {attachments.map((attachment) => ( +
  • +
    +
    + {getFileIcon(attachment.fileType)} +
    +
    +

    {attachment.filename}

    +

    + {formatFileSize(attachment.fileSize)} • {new Date(attachment.createdAt).toLocaleDateString()} +

    +
    +
    +
    + + + {allowDelete && ( + + )} +
    +
  • + ))} +
+
+ )} + + !open && setDeleteAttachment(null)}> + + + Delete Attachment + + Are you sure you want to delete "{deleteAttachment?.filename}"? This action cannot be undone. + + + + Cancel + + Delete + + + + +
+ ); +} \ No newline at end of file diff --git a/components/missions/file-upload.tsx b/components/missions/file-upload.tsx new file mode 100644 index 00000000..11a66071 --- /dev/null +++ b/components/missions/file-upload.tsx @@ -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(null); + const [error, setError] = useState(null); + const fileInputRef = useRef(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) => { + 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 ( +
+ {!file ? ( +
+
+ +

+ {type === 'logo' ? 'Upload logo image' : 'Upload attachment'} +

+

+ Drag and drop or click to browse +

+ + + {error && ( +

{error}

+ )} +
+
+ ) : ( +
+
+
+
+ {type === 'logo' ? ( + Preview + ) : ( +
+ {file.name.split('.').pop()?.toUpperCase()} +
+ )} +
+
+

+ {file.name} +

+

+ {(file.size / 1024).toFixed(2)} KB +

+
+
+ +
+ {isUploading ? ( +
+ + {progress}% +
+ ) : ( + <> + + + + )} +
+
+ + {isUploading && ( +
+
+
+ )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/components/missions/missions-admin-panel.tsx b/components/missions/missions-admin-panel.tsx index 932a559c..b4faac91 100644 --- a/components/missions/missions-admin-panel.tsx +++ b/components/missions/missions-admin-panel.tsx @@ -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(null); const [gardienDeLaMemoire, setGardienDeLaMemoire] = useState(null); const [volontaires, setVolontaires] = useState([]); + const [missionId, setMissionId] = useState(""); + 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([]); @@ -291,9 +307,19 @@ export function MissionsAdminPanel() {
-
- -
+ { + // Handle logo upload complete + if (data?.filePath) { + setMissionData(prev => ({ + ...prev, + logo: data.filePath + })); + } + }} + />
@@ -614,11 +640,12 @@ export function MissionsAdminPanel() {
- -
- -

Upload file .pdf, .doc, .docx

-
+ +
diff --git a/lib/mission-uploads.ts b/lib/mission-uploads.ts new file mode 100644 index 00000000..6054d78c --- /dev/null +++ b/lib/mission-uploads.ts @@ -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 { + 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 { + try { + await deleteObject(filePath); + return true; + } catch (error) { + console.error('Error deleting mission logo:', error); + throw new Error('Failed to delete mission logo'); + } +} \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e74f0f40..b2f7c45e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) } \ No newline at end of file