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,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from "../ui/dropdown-menu";
|
} from "../ui/dropdown-menu";
|
||||||
|
import { FileUpload } from "./file-upload";
|
||||||
|
import { AttachmentsList } from "./attachments-list";
|
||||||
|
|
||||||
// Define interfaces for user and group data
|
// Define interfaces for user and group data
|
||||||
interface User {
|
interface User {
|
||||||
@ -57,6 +59,20 @@ export function MissionsAdminPanel() {
|
|||||||
const [gardienDeLaParole, setGardienDeLaParole] = useState<string | null>(null);
|
const [gardienDeLaParole, setGardienDeLaParole] = useState<string | null>(null);
|
||||||
const [gardienDeLaMemoire, setGardienDeLaMemoire] = useState<string | null>(null);
|
const [gardienDeLaMemoire, setGardienDeLaMemoire] = useState<string | null>(null);
|
||||||
const [volontaires, setVolontaires] = useState<string[]>([]);
|
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
|
// State for storing fetched data
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
@ -291,9 +307,19 @@ export function MissionsAdminPanel() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700">Logo</label>
|
<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">
|
<FileUpload
|
||||||
<Button variant="outline" className="mb-2 bg-white text-gray-700 border-gray-300 hover:bg-gray-50">Browse</Button>
|
type="logo"
|
||||||
</div>
|
missionId={missionId || ""}
|
||||||
|
onUploadComplete={(data) => {
|
||||||
|
// Handle logo upload complete
|
||||||
|
if (data?.filePath) {
|
||||||
|
setMissionData(prev => ({
|
||||||
|
...prev,
|
||||||
|
logo: data.filePath
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<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">
|
<TabsContent value="attachments" className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700">Attachments</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700">Attachements</label>
|
||||||
<div className="border border-dashed rounded-md p-6 text-center bg-gray-50">
|
<AttachmentsList
|
||||||
<Button variant="outline" className="mb-2 bg-white text-gray-700 border-gray-300 hover:bg-gray-50">Browse</Button>
|
missionId={missionId || ""}
|
||||||
<p className="text-sm text-gray-500">Upload file .pdf, .doc, .docx</p>
|
allowUpload={true}
|
||||||
</div>
|
allowDelete={true}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</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[]
|
mailCredentials MailCredentials[]
|
||||||
webdavCredentials WebDAVCredentials?
|
webdavCredentials WebDAVCredentials?
|
||||||
announcements Announcement[]
|
announcements Announcement[]
|
||||||
|
missions Mission[]
|
||||||
|
missionUsers MissionUser[]
|
||||||
|
uploadedAttachments Attachment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Calendar {
|
model Calendar {
|
||||||
@ -113,3 +116,59 @@ model Announcement {
|
|||||||
|
|
||||||
@@index([authorId])
|
@@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