diff --git a/app/api/missions/[missionId]/files/route.ts b/app/api/missions/[missionId]/files/route.ts new file mode 100644 index 0000000..c5838af --- /dev/null +++ b/app/api/missions/[missionId]/files/route.ts @@ -0,0 +1,235 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from "@/app/api/auth/options"; +import { prisma } from '@/lib/prisma'; +import { S3Client, ListObjectsV2Command, GetObjectCommand, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'; +import { Readable } from 'stream'; + +// S3 Configuration for missions bucket +const MISSIONS_S3_CONFIG = { + endpoint: (process.env.MINIO_S3_UPLOAD_BUCKET_URL || 'https://dome-api.slm-lab.net').replace(/\/$/, ''), + region: process.env.MINIO_AWS_REGION || 'us-east-1', + bucket: process.env.MINIO_MISSIONS_BUCKET || 'missions', + accessKey: process.env.MINIO_ACCESS_KEY || '', + secretKey: process.env.MINIO_SECRET_KEY || '' +}; + +const missionsS3Client = new S3Client({ + region: MISSIONS_S3_CONFIG.region, + endpoint: MISSIONS_S3_CONFIG.endpoint, + credentials: { + accessKeyId: MISSIONS_S3_CONFIG.accessKey, + secretAccessKey: MISSIONS_S3_CONFIG.secretKey + }, + forcePathStyle: true +}); + +// Helper function to check if user has access to mission +async function checkMissionAccess(userId: string, missionId: string): Promise { + const mission = await prisma.mission.findFirst({ + where: { + id: missionId, + OR: [ + { creatorId: userId }, + { missionUsers: { some: { userId } } } + ] + } + }); + return !!mission; +} + +// Helper function to stream to string +async function streamToString(stream: Readable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString('utf-8'); +} + +// GET endpoint to list files and folders in a mission +export async function GET( + request: Request, + { params }: { params: Promise<{ missionId: string }> } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { missionId } = await params; + const userId = session.user.id; + + // Check if user has access to this mission + const hasAccess = await checkMissionAccess(userId, missionId); + if (!hasAccess) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { searchParams } = new URL(request.url); + const path = searchParams.get('path') || ''; // Subfolder path within mission + + // Construct prefix for listing + const prefix = path ? `missions/${missionId}/${path}/` : `missions/${missionId}/`; + + const command = new ListObjectsV2Command({ + Bucket: MISSIONS_S3_CONFIG.bucket, + Prefix: prefix, + Delimiter: '/' + }); + + const response = await missionsS3Client.send(command); + + // Extract folders (CommonPrefixes) + const folders = (response.CommonPrefixes || []).map(commonPrefix => { + const folderPath = commonPrefix.Prefix || ''; + // Extract folder name from path (e.g., "missions/123/docs/" -> "docs") + const pathParts = folderPath.replace(prefix, '').split('/').filter(Boolean); + const folderName = pathParts[pathParts.length - 1] || folderPath; + return { + type: 'folder', + name: folderName, + path: folderPath, + key: folderPath + }; + }); + + // Extract files (Contents, excluding folder markers) + const files = (response.Contents || []) + .filter(obj => { + if (!obj.Key) return false; + // Exclude folder markers + if (obj.Key.endsWith('/')) return false; + // Exclude placeholder files + if (obj.Key.includes('.placeholder')) return false; + return true; + }) + .map(obj => ({ + type: 'file', + name: obj.Key?.split('/').pop() || obj.Key, + path: obj.Key, + key: obj.Key, + size: obj.Size, + lastModified: obj.LastModified + })); + + return NextResponse.json({ + folders, + files: [...folders, ...files].sort((a, b) => { + // Folders first, then files, both alphabetically + if (a.type !== b.type) { + return a.type === 'folder' ? -1 : 1; + } + return a.name.localeCompare(b.name); + }) + }); + } catch (error) { + console.error('Error listing mission files:', error); + return NextResponse.json( + { error: 'Failed to list files' }, + { status: 500 } + ); + } +} + +// GET endpoint to get file content +export async function POST( + request: Request, + { params }: { params: Promise<{ missionId: string }> } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { missionId } = await params; + const userId = session.user.id; + + // Check if user has access to this mission + const hasAccess = await checkMissionAccess(userId, missionId); + if (!hasAccess) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { key } = body; + + if (!key) { + return NextResponse.json({ error: 'File key is required' }, { status: 400 }); + } + + // Ensure the key is within the mission folder + if (!key.startsWith(`missions/${missionId}/`)) { + return NextResponse.json({ error: 'Invalid file path' }, { status: 400 }); + } + + const command = new GetObjectCommand({ + Bucket: MISSIONS_S3_CONFIG.bucket, + Key: key + }); + + const response = await missionsS3Client.send(command); + const content = await streamToString(response.Body as Readable); + + return NextResponse.json({ content }); + } catch (error) { + console.error('Error fetching file content:', error); + return NextResponse.json( + { error: 'Failed to fetch file content' }, + { status: 500 } + ); + } +} + +// PUT endpoint to save file content +export async function PUT( + request: Request, + { params }: { params: Promise<{ missionId: string }> } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { missionId } = await params; + const userId = session.user.id; + + // Check if user has access to this mission + const hasAccess = await checkMissionAccess(userId, missionId); + if (!hasAccess) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { key, content } = body; + + if (!key || content === undefined) { + return NextResponse.json({ error: 'File key and content are required' }, { status: 400 }); + } + + // Ensure the key is within the mission folder + if (!key.startsWith(`missions/${missionId}/`)) { + return NextResponse.json({ error: 'Invalid file path' }, { status: 400 }); + } + + const command = new PutObjectCommand({ + Bucket: MISSIONS_S3_CONFIG.bucket, + Key: key, + Body: content || Buffer.alloc(0), + ContentType: 'text/plain' + }); + + await missionsS3Client.send(command); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error saving file:', error); + return NextResponse.json( + { error: 'Failed to save file' }, + { status: 500 } + ); + } +} diff --git a/app/api/missions/user/route.ts b/app/api/missions/user/route.ts new file mode 100644 index 0000000..1ddbb4a --- /dev/null +++ b/app/api/missions/user/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from "@/app/api/auth/options"; +import { prisma } from '@/lib/prisma'; + +// GET endpoint to list missions for the current user +export async function GET(request: Request) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const userId = session.user.id; + + // Find all missions where the user is either the creator or a member + const missions = await prisma.mission.findMany({ + where: { + OR: [ + { creatorId: userId }, + { missionUsers: { some: { userId } } } + ] + }, + select: { + id: true, + name: true, + logo: true, + logoUrl: true, + createdAt: true, + updatedAt: true, + isClosed: true, + missionUsers: { + where: { userId }, + select: { + role: true + } + } + }, + orderBy: { updatedAt: 'desc' } + }); + + // Transform missions to include logo URL + const missionsWithUrls = missions.map(mission => ({ + ...mission, + logoUrl: mission.logo ? `/api/missions/image/${mission.logo}` : null + })); + + return NextResponse.json({ missions: missionsWithUrls }); + } catch (error) { + console.error('Error fetching user missions:', error); + return NextResponse.json( + { error: 'Failed to fetch missions' }, + { status: 500 } + ); + } +} diff --git a/app/pages/page.tsx b/app/pages/page.tsx index f44b62c..68cb67f 100644 --- a/app/pages/page.tsx +++ b/app/pages/page.tsx @@ -9,6 +9,8 @@ import { Editor } from "@/components/carnet/editor"; import { PanelResizer } from "@/components/carnet/panel-resizer"; import { useMediaQuery } from "@/hooks/use-media-query"; import { ContactsView } from '@/components/carnet/contacts-view'; +import { MissionsView } from '@/components/carnet/missions-view'; +import { MissionFilesView } from '@/components/carnet/mission-files-view'; import { X, Menu } from "lucide-react"; import { ContactDetails } from '@/components/carnet/contact-details'; import { parse as parseVCard, format as formatVCard } from 'vcard-parser'; @@ -54,6 +56,8 @@ export default function CarnetPage() { const [contacts, setContacts] = useState([]); const [selectedContact, setSelectedContact] = useState(null); const [isLoadingContacts, setIsLoadingContacts] = useState(true); + const [selectedMission, setSelectedMission] = useState<{ id: string; name: string } | null>(null); + const [selectedMissionFile, setSelectedMissionFile] = useState<{ key: string; content: string } | null>(null); // Panel widths state const [navWidth, setNavWidth] = useState(220); @@ -653,9 +657,16 @@ export default function CarnetPage() { setSelectedFolder(folder); setLayoutMode("item-selection"); - // Reset selected note and contact when changing folders + // Reset selected note, contact, and mission when changing folders setSelectedNote(null); setSelectedContact(null); + setSelectedMission(null); + setSelectedMissionFile(null); + + // For Missions, don't create folder structure + if (folder === 'Missions') { + return; + } // Ensure folder exists in storage before fetching try { @@ -999,7 +1010,7 @@ export default function CarnetPage() { )} - {/* Notes/Contacts Panel */} + {/* Notes/Contacts/Missions Panel */} {showNotes && ( <>
@@ -1013,6 +1024,19 @@ export default function CarnetPage() { loading={isLoadingContacts} /> + ) : selectedFolder === 'Missions' ? ( + selectedMission ? ( + + ) : ( + + ) ) : ( + ) : selectedFolder === 'Missions' && selectedMissionFile ? ( + { + await handleMissionFileSave(note.content); + }} + currentFolder="Missions" + /> ) : ( void; + selectedFileKey?: string; +} + +export const MissionFilesView: React.FC = ({ + missionId, + onFileSelect, + selectedFileKey +}) => { + const [files, setFiles] = useState([]); + const [currentPath, setCurrentPath] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!missionId) return; + + const fetchFiles = async () => { + try { + setIsLoading(true); + setError(null); + const url = `/api/missions/${missionId}/files${currentPath ? `?path=${encodeURIComponent(currentPath)}` : ''}`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error('Failed to fetch files'); + } + + const data = await response.json(); + setFiles(data.files || []); + } catch (err) { + console.error('Error fetching mission files:', err); + setError(err instanceof Error ? err.message : 'Failed to load files'); + } finally { + setIsLoading(false); + } + }; + + fetchFiles(); + }, [missionId, currentPath]); + + const handleItemClick = (item: MissionFile) => { + if (item.type === 'folder') { + // Navigate into folder + const newPath = item.path.replace(`missions/${missionId}/`, '').replace(/\/$/, ''); + setCurrentPath(newPath); + } else { + // Select file + onFileSelect(item); + } + }; + + const handleBack = () => { + const pathParts = currentPath.split('/').filter(Boolean); + pathParts.pop(); + setCurrentPath(pathParts.join('/')); + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+
+

Erreur

+

{error}

+
+
+ ); + } + + return ( +
+
+
+ +

+ {currentPath ? currentPath.split('/').pop() : 'Fichiers'} +

+ {currentPath && ( + + )} +
+
+ +
+ {files.length === 0 ? ( +
+

Aucun fichier

+
+ ) : ( +
    + {files.map((file) => { + const Icon = file.type === 'folder' ? Folder : FileText; + return ( +
  • handleItemClick(file)} + className={`p-4 hover:bg-carnet-hover cursor-pointer flex items-center space-x-2 ${ + selectedFileKey === file.key ? 'bg-carnet-hover' : '' + }`} + > + + {file.name} + {file.type === 'folder' && ( + + )} +
  • + ); + })} +
+ )} +
+
+ ); +}; diff --git a/components/carnet/missions-view.tsx b/components/carnet/missions-view.tsx new file mode 100644 index 0000000..7c0ce71 --- /dev/null +++ b/components/carnet/missions-view.tsx @@ -0,0 +1,126 @@ +"use client"; + +import React, { useState, useEffect } from 'react'; +import { BookOpen, Folder, FileText, Loader2 } from 'lucide-react'; + +interface Mission { + id: string; + name: string; + logoUrl?: string | null; + createdAt: string; + updatedAt: string; + isClosed?: boolean; + missionUsers?: Array<{ role: string }>; +} + +interface MissionsViewProps { + onMissionSelect: (mission: Mission) => void; + selectedMissionId?: string; +} + +export const MissionsView: React.FC = ({ onMissionSelect, selectedMissionId }) => { + const [missions, setMissions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchMissions = async () => { + try { + setIsLoading(true); + setError(null); + const response = await fetch('/api/missions/user'); + + if (!response.ok) { + throw new Error('Failed to fetch missions'); + } + + const data = await response.json(); + setMissions(data.missions || []); + } catch (err) { + console.error('Error fetching missions:', err); + setError(err instanceof Error ? err.message : 'Failed to load missions'); + } finally { + setIsLoading(false); + } + }; + + fetchMissions(); + }, []); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+
+

Erreur

+

{error}

+
+
+ ); + } + + if (missions.length === 0) { + return ( +
+
+ +

Aucune mission

+
+
+ ); + } + + return ( +
+
+
+ +

Missions

+
+
+ +
+
    + {missions.map((mission) => ( +
  • onMissionSelect(mission)} + className={`p-4 hover:bg-carnet-hover cursor-pointer ${ + selectedMissionId === mission.id ? 'bg-carnet-hover' : '' + }`} + > +
    + {mission.logoUrl ? ( + {mission.name} + ) : ( +
    + +
    + )} +
    +

    + {mission.name} +

    + {mission.isClosed && ( +

    Fermée

    + )} +
    +
    +
  • + ))} +
+
+
+ ); +}; diff --git a/components/carnet/navigation.tsx b/components/carnet/navigation.tsx index 86aea9c..f6c3315 100644 --- a/components/carnet/navigation.tsx +++ b/components/carnet/navigation.tsx @@ -9,7 +9,7 @@ interface NavigationProps { onFolderSelect: (folder: string) => void; } -type FolderType = 'Notes' | 'Diary' | 'Health' | 'Contacts'; +type FolderType = 'Notes' | 'Diary' | 'Health' | 'Contacts' | 'Missions'; interface FolderConfig { icon: LucideIcon; @@ -22,7 +22,8 @@ const FOLDER_CONFIG: Record = { 'Notes': { icon: FileText, order: 1, displayName: 'Bloc-notes' }, 'Diary': { icon: Calendar, order: 2, displayName: 'Journal' }, 'Health': { icon: Heart, order: 3, displayName: 'Carnet de santé' }, - 'Contacts': { icon: Users, order: 4, displayName: 'Carnet d\'adresses' } + 'Contacts': { icon: Users, order: 4, displayName: 'Carnet d\'adresses' }, + 'Missions': { icon: BookOpen, order: 5, displayName: 'Missions' } }; interface ContactFile { @@ -67,13 +68,15 @@ export default function Navigation({ nextcloudFolders, onFolderSelect }: Navigat return Heart; case 'Contacts': return Users; + case 'Missions': + return BookOpen; default: return FileText; } }; // Make sure we always have the standard folders available - const defaultFolders = ['Notes', 'Diary', 'Health', 'Contacts']; + const defaultFolders = ['Notes', 'Diary', 'Health', 'Contacts', 'Missions']; // Combine API-provided folders with defaults to ensure we always have folders to show const allFolders = nextcloudFolders && nextcloudFolders.length > 0