missions
This commit is contained in:
parent
d915b70751
commit
b4347c5914
@ -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 });
|
||||
}
|
||||
}
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
73
app/api/missions/[missionId]/attachments/route.ts
Normal file
73
app/api/missions/[missionId]/attachments/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
302
app/api/missions/[missionId]/route.ts
Normal file
302
app/api/missions/[missionId]/route.ts
Normal 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
213
app/api/missions/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
176
app/api/missions/upload/route.ts
Normal file
176
app/api/missions/upload/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
292
components/missions/attachments-list.tsx
Normal file
292
components/missions/attachments-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
262
components/missions/file-upload.tsx
Normal file
262
components/missions/file-upload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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
155
lib/mission-uploads.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@ -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])
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user