diff --git a/.env.dist b/.env.dist index 161ea29..74fd130 100644 --- a/.env.dist +++ b/.env.dist @@ -15,3 +15,6 @@ MAILHOG_WEB_PORT=8025 # phpMyAdmin Configuration PHPMYADMIN_PORT=8080 + +# .env.local +DATABASE_URL="postgresql://goofyuser:goofypassword@localhost:5432/goofytrack" diff --git a/app/components/header.tsx b/app/components/header.tsx index 7c774ba..52246a4 100644 --- a/app/components/header.tsx +++ b/app/components/header.tsx @@ -1,5 +1,44 @@ import { Button } from '@/components/ui/button'; import { signOut, useSession } from 'next-auth/react'; +import { Moon, Sun } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +function DarkModeButton() { + const [isDark, setIsDark] = useState(false); + + useEffect(() => { + // On mount, check localStorage or system preference + const stored = localStorage.getItem('theme'); + if ( + stored === 'dark' || + (!stored && window.matchMedia('(prefers-color-scheme: dark)').matches) + ) { + document.documentElement.classList.add('dark'); + setIsDark(true); + } else { + document.documentElement.classList.remove('dark'); + setIsDark(false); + } + }, []); + + const toggleDark = () => { + const next = !isDark; + setIsDark(next); + if (next) { + document.documentElement.classList.add('dark'); + localStorage.setItem('theme', 'dark'); + } else { + document.documentElement.classList.remove('dark'); + localStorage.setItem('theme', 'light'); + } + }; + + return ( + + ); +} export default function Header() { const { data: session, status } = useSession(); @@ -11,7 +50,8 @@ export default function Header() { return (

Goofy Talk

-
+
+ {status === 'unauthenticated' ? ( + )} +
+ +
+ + + + + setFilterDate(e.target.value)} + /> +
+ + {filteredScheduledTalks.length === 0 ? ( +
+ Aucun talk ne correspond aux filtres. +
+ ) : ( +
+ {filteredScheduledTalks.map((talk) => ( + + +
+ {talk.title} + +
+ + {talk.topic} + + {levels.find((l) => l.value === talk.level)?.label} + + {talk.durationMinutes} min + +
+ +

{talk.description}

+
+ +
+ <> + + + +
+ {talk.status === 'accepted' && ( + + + + )} +
+
+ ))} +
+ )} + + + + +
+ ); +} diff --git a/app/components/talks/PendingTalksList.tsx b/app/components/talks/PendingTalksList.tsx index 2ad2355..604e021 100644 --- a/app/components/talks/PendingTalksList.tsx +++ b/app/components/talks/PendingTalksList.tsx @@ -1,4 +1,4 @@ -// components/talks/TalksList.tsx +// components/talks/PendingTalksList.tsx 'use client'; import { Button } from '@/components/ui/button'; @@ -10,28 +10,60 @@ import { CardHeader, CardTitle, } from '@/components/ui/card'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; import { levels } from '@/lib/mock-data'; import { Talk, TalkStatus } from '@/lib/types'; import { isOrganizer, isSpeaker } from '@/utils/auth.utils'; -import { Check, Pencil, Plus, Trash2, X } from 'lucide-react'; +import { Pencil, Plus, Trash2 } from 'lucide-react'; import { useSession } from 'next-auth/react'; import { useState } from 'react'; import DeleteDialog from './DeleteDialog'; import StatusBadge from './StatusBadge'; +import StatusDialog from './StatusDialog'; import TalkDialog from './TalkDialog'; +// Mock data for rooms (you should replace this with your actual data) +// const rooms = [ +// { +// name: 'Salle Amphithéâtre', +// capacity: 300, +// description: 'Grande salle principale pour les keynotes et sessions populaires', +// }, +// { +// name: 'Salle Ateliers', +// capacity: 100, +// description: 'Salle équipée pour les ateliers pratiques et hands-on labs', +// }, +// { +// name: 'Salle Conférences A', +// capacity: 150, +// description: 'Salle de conférence standard pour les présentations techniques', +// }, +// { +// name: 'Salle Conférences B', +// capacity: 150, +// description: 'Salle de conférence standard pour les présentations techniques', +// }, +// { +// name: 'Salle Innovation', +// capacity: 80, +// description: 'Espace dédié aux démonstrations et nouvelles technologies', +// }, +// ]; + interface PendingTalksListProps { talks: Talk[]; onAddTalk: (talk: Omit) => void; onUpdateTalk: (talk: Talk) => void; onDeleteTalk: (talkId: string) => void; - onChangeTalkStatus: (talkId: string, newStatus: TalkStatus) => void; + onChangeTalkStatus: ( + talkId: string, + newStatus: TalkStatus, + details?: { + startDate?: Date; + endDate?: Date; + roomId?: string; + }, + ) => void; } export default function PendingTalksList({ @@ -45,8 +77,10 @@ export default function PendingTalksList({ const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isStatusDialogOpen, setIsStatusDialogOpen] = useState(false); const [currentTalk, setCurrentTalk] = useState(null); const [talkToDelete, setTalkToDelete] = useState(null); + const [talkToChangeStatus, setTalkToChangeStatus] = useState(null); const [isNewTalk, setIsNewTalk] = useState(true); const handleCreateTalk = () => { @@ -66,6 +100,11 @@ export default function PendingTalksList({ setIsDeleteDialogOpen(true); }; + const handleChangeStatus = (talk: Talk) => { + setTalkToChangeStatus(talk); + setIsStatusDialogOpen(true); + }; + const confirmDeleteTalk = () => { if (talkToDelete) { onDeleteTalk(talkToDelete.id); @@ -140,21 +179,15 @@ export default function PendingTalksList({ )}
- - - - - - onChangeTalkStatus(talk.id, 'accepted')}> - Accepter - - onChangeTalkStatus(talk.id, 'refused')}> - Refuser - - - + {/* Bouton pour ouvrir la modale de changement de statut - sans condition */} + ))} @@ -177,6 +210,14 @@ export default function PendingTalksList({ talk={talkToDelete} onConfirm={confirmDeleteTalk} /> + + {/* Dialog for status change */} + ); } diff --git a/app/components/talks/PlanningOverview.tsx b/app/components/talks/PlanningOverview.tsx index 7c4d124..2d5f02d 100644 --- a/app/components/talks/PlanningOverview.tsx +++ b/app/components/talks/PlanningOverview.tsx @@ -1,91 +1,83 @@ -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Room, ScheduledTalk } from '@/lib/types'; +'use client'; + +import { useEffect, useState } from 'react'; +import type { Talk } from '@/lib/types'; + +interface Room { + roomId: number; + name: string; +} + +interface ScheduledSlot { + id: number; + roomId: number; + startTime: string; + endTime: string; + talk: Talk; +} interface PlanningOverviewProps { rooms: Room[]; - scheduledTalks: ScheduledTalk[]; + date: Date; } -export default function PlanningOverview({ rooms, scheduledTalks }: PlanningOverviewProps) { - const timeSlots = ['9:00', '10:15', '11:30', '13:30', '14:45', '16:00']; +export default function PlanningOverview({ rooms, date }: PlanningOverviewProps) { + const [scheduledSlots, setScheduledSlots] = useState([]); + const hours = Array.from({ length: 9 }, (_, i) => 9 + i); // 09–10 … 17–18 - return ( - - - Vue d'ensemble du planning - - Visualisez tous les talks programmés par jour et par salle - - - -
- - - - - {timeSlots.map((time, index) => { - let endTime; - switch (index) { - case 0: - endTime = '10:00'; - break; - case 1: - endTime = '11:15'; - break; - case 2: - endTime = '12:30'; - break; - case 3: - endTime = '14:30'; - break; - case 4: - endTime = '15:45'; - break; - default: - endTime = '17:00'; - } - return ( - - ); - })} - - - - {rooms.map((room) => ( - - - {timeSlots.map((time, index) => { - // Trouver un talk programmé pour cette salle à ce créneau - const startTime = index === 0 ? '09:00' : time; - const scheduledTalk = scheduledTalks.find( - (st) => st.room.id === room.id && st.slot.startTime === startTime, - ); + useEffect(() => { + const dateParam = date.toISOString().slice(0, 10); // "YYYY-MM-DD" + fetch(`/api/schedules?date=${dateParam}`) + .then((res) => { + if (!res.ok) throw new Error('Impossible de charger le planning'); + return res.json(); + }) + .then((data: { schedules: ScheduledSlot[] }) => { + // console.log('schedules from API:', data.schedules); + setScheduledSlots(data.schedules); + }) + .catch((err) => { + console.error(err); + alert(err.message); + }); + }, [date]); - return ( - - ); - })} - - ))} - -
Salle / Heure - {time} - {endTime} -
{room.name} - {scheduledTalk ? ( -
-
{scheduledTalk.talk.title}
-
- {scheduledTalk.talk.durationMinutes} min -
-
- ) : ( -
- Disponible -
- )} -
-
-
-
+ return ( +
+ + + + + {hours.map((h) => ( + + ))} + + + + {rooms.map((room) => ( + + + {hours.map((h) => { + const slot = scheduledSlots.find( + (s) => s.roomId === room.roomId && new Date(s.startTime).getHours() === h, + ); + return ( + + ); + })} + + ))} + +
Salle / Heure + {`${h}:00–${h + 1}:00`} +
{room.name} + {slot ? ( +
+ {slot.talk.title} + Speaker #{slot.talk.speakerId} +
+ ) : null} +
+
); } diff --git a/app/components/talks/StatusBadge.tsx b/app/components/talks/StatusBadge.tsx index 6c8423a..5ff8e20 100644 --- a/app/components/talks/StatusBadge.tsx +++ b/app/components/talks/StatusBadge.tsx @@ -19,25 +19,25 @@ export default function StatusBadge({ status }: StatusBadgeProps) { const statusConfig: Record = { pending: { variant: 'outline', - className: 'bg-yellow-100', + className: 'bg-yellow-100 dark:bg-yellow-900 dark:text-yellow-100', icon: , label: 'En attente', }, accepted: { variant: 'outline', - className: 'bg-blue-100', + className: 'bg-blue-100 dark:bg-blue-900 dark:text-blue-100', icon: , label: 'Accepté', }, - refused: { + rejected: { variant: 'outline', - className: 'bg-red-100', + className: 'bg-red-100 dark:bg-red-900 dark:text-red-100', icon: , label: 'Refusé', }, scheduled: { variant: 'outline', - className: 'bg-green-100', + className: 'bg-green-100 dark:bg-green-900 dark:text-green-100', icon: , label: 'Programmé', }, diff --git a/app/components/talks/StatusDialog.tsx b/app/components/talks/StatusDialog.tsx new file mode 100644 index 0000000..45959fd --- /dev/null +++ b/app/components/talks/StatusDialog.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Talk, TalkStatus } from '@/lib/types'; +import { cn } from '@/lib/utils'; +import { Check, X } from 'lucide-react'; +import { useState } from 'react'; + +interface StatusDialogProps { + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; + talk: Talk | null; + onChangeTalkStatus: (talkId: string, newStatus: TalkStatus) => void; +} + +export default function StatusDialog({ + isOpen, + setIsOpen, + talk, + onChangeTalkStatus, +}: StatusDialogProps) { + // const [selectedStartDate, setSelectedStartDate] = useState(undefined); + // const [selectedEndDate, setSelectedEndDate] = useState(undefined); + // const [selectedRoom, setSelectedRoom] = useState(''); + const [selectedStatus, setSelectedStatus] = useState('pending'); + + const handleStatusChange = (status: TalkStatus) => { + setSelectedStatus(status); + }; + + const handleSave = () => { + if (talk) { + onChangeTalkStatus(talk.id, selectedStatus); + setIsOpen(false); + // Reset state + // setSelectedStartDate(undefined); + // setSelectedEndDate(undefined); + // setSelectedRoom(''); + setSelectedStatus('pending'); + } + }; + + return ( + + + + Changer le statut du talk + {talk?.title} + + +
+
+ + +
+
+ + + + + +
+
+ ); +} diff --git a/app/components/talks/TalkDialog.tsx b/app/components/talks/TalkDialog.tsx index 2a49ab2..bed465f 100644 --- a/app/components/talks/TalkDialog.tsx +++ b/app/components/talks/TalkDialog.tsx @@ -1,7 +1,6 @@ -// components/talks/TalkDialog.tsx 'use client'; -import { useState, useEffect, FormEvent } from 'react'; +import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, @@ -10,9 +9,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; import { Select, @@ -21,40 +18,84 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { topics, levels, durations, emptyTalk } from '@/lib/mock-data'; +import { Textarea } from '@/components/ui/textarea'; +import { durations, emptyTalk, levels } from '@/lib/mock-data'; import { Talk, TalkLevel } from '@/lib/types'; import { useSession } from 'next-auth/react'; +import { FormEvent, useEffect, useState } from 'react'; + +// form subjects (must match your DB subjects.name) +export const subjects = [ + 'JavaScript', + 'TypeScript', + 'React', + 'Next.js', + 'Node.js', + 'Prisma', + 'GraphQL', + 'DevOps', + 'Architecture', + 'UX/UI', + 'Mobile', + 'Security', + 'Testing', + 'Performance', + 'Accessibility', +]; interface TalkDialogProps { isOpen: boolean; - setIsOpen: (isOpen: boolean) => void; + setIsOpen: (open: boolean) => void; talk: Talk | null; isNew: boolean; - onSave: (talk: Omit & { id?: string }) => void; + onSave: (talk: Talk) => void; } export default function TalkDialog({ isOpen, setIsOpen, talk, isNew, onSave }: TalkDialogProps) { - const session = useSession(); - const [currentTalk, setCurrentTalk] = useState & { id?: string }>(emptyTalk); + const { data: session } = useSession(); + const [currentTalk, setCurrentTalk] = useState>(emptyTalk); useEffect(() => { - if (isOpen) { - if (!session.data?.user.id) throw new Error('User ID is required'); + if (!isOpen) return; + if (!session?.user?.id) throw new Error('User ID is required'); + setCurrentTalk(isNew ? { ...emptyTalk, speakerId: session.user.id } : { ...(talk as Talk) }); + }, [isOpen, isNew, talk, session]); - setCurrentTalk(isNew ? { ...emptyTalk, speakerId: session.data.user.id } : { ...talk! }); - } - }, [isOpen, isNew, talk]); - - const handleInputChange = (field: keyof Talk, value: string | number | TalkLevel) => { - setCurrentTalk({ - ...currentTalk, - [field]: value, - }); + const handleInputChange = (field: keyof Omit, value: string | number | TalkLevel) => { + setCurrentTalk({ ...currentTalk, [field]: value }); }; - const handleSubmit = (e: FormEvent) => { + const handleSubmit = async (e: FormEvent) => { e.preventDefault(); - onSave(currentTalk); + + const payload = { + title: currentTalk.title, + description: currentTalk.description, + topic: currentTalk.topic, + durationMinutes: currentTalk.durationMinutes, + level: currentTalk.level, + }; + + // Choose POST for new, PUT for existing + const url = isNew ? '/api/talks' : `/api/talks/${talk?.id}`; + const method = isNew ? 'POST' : 'PUT'; + + try { + const response = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + const err = await response.json(); + throw new Error(err.error || 'Unknown error'); + } + const saved = await response.json(); + onSave(saved); + setIsOpen(false); + } catch (err) { + console.error('Failed to save talk:', err); + } }; return ( @@ -83,19 +124,19 @@ export default function TalkDialog({ isOpen, setIsOpen, talk, isNew, onSave }: T
- + handleInputChange('level', value as TalkLevel)} + onValueChange={(v) => handleInputChange('level', v as TalkLevel)} > - {levels.map((level) => ( - - {level.label} + {levels.map((lvl) => ( + + {lvl.label} ))} @@ -125,17 +166,19 @@ export default function TalkDialog({ isOpen, setIsOpen, talk, isNew, onSave }: T
@@ -115,8 +197,8 @@ export default function TalksSchedule({ {talks.map((talk) => ( - - {talk.title} ({talk.durationMinutes} min) - {talk.level} + + {talk.title} ({talk.durationMinutes} min) – {talk.level} ))} @@ -125,7 +207,7 @@ export default function TalksSchedule({ {isOrganizer(session.data?.user.roleId) && ( <> - {/* Sélection de la date */} + {/* Date picker */}
@@ -138,25 +220,20 @@ export default function TalksSchedule({ )} > - {selectedDate ? ( - format(selectedDate, 'PPP', { locale: fr }) - ) : ( - Choisir une date - )} + {format(selectedDate, 'PPP', { locale: fr })} date && setSelectedDate(date)} + onSelect={(d) => d && setSelectedDate(d)} />
- {/* Sélection de la salle */} + {/* Room select */}
@@ -190,8 +269,9 @@ export default function TalksSchedule({ {availableSlots.map((slot) => ( - - {slot.startTime} - {slot.endTime} + + {format(new Date(slot.startTime), 'HH:mm')} –{' '} + {format(new Date(slot.endTime), 'HH:mm')} ))} @@ -200,19 +280,18 @@ export default function TalksSchedule({ - -
- - {/* Vue d'ensemble du planning */} - + ({ roomId, name }))} + date={selectedDate} + />
); } diff --git a/app/lib/mock-data.ts b/app/lib/mock-data.ts index b1b9c0b..05096bd 100644 --- a/app/lib/mock-data.ts +++ b/app/lib/mock-data.ts @@ -128,20 +128,6 @@ export const mockData: { ], }; -// Constantes pour les options de formulaires -export const topics: string[] = [ - 'Frontend', - 'Backend', - 'DevOps', - 'Mobile', - 'AI/ML', - 'Security', - 'Design', - 'Programming', - 'Architecture', - 'Operations', -]; - export const levels: LevelOption[] = [ { value: 'beginner', label: 'Débutant' }, { value: 'intermediate', label: 'Intermédiaire' }, diff --git a/app/lib/types.ts b/app/lib/types.ts index 00df001..df4db51 100644 --- a/app/lib/types.ts +++ b/app/lib/types.ts @@ -1,7 +1,9 @@ // lib/types.ts +import { RoomWithSlots } from '@/components/talks/TalksSchedule'; + // Définition des types pour l'application -export type TalkStatus = 'pending' | 'accepted' | 'refused' | 'scheduled'; +export type TalkStatus = 'pending' | 'accepted' | 'rejected' | 'scheduled'; export type TalkLevel = 'beginner' | 'intermediate' | 'advanced'; export interface Talk { @@ -33,7 +35,7 @@ export interface Slot { export interface ScheduledTalk { talk: Talk; slot: Slot; - room: Room; + room: RoomWithSlots; } // Types pour les options de formulaires diff --git a/app/package.json b/app/package.json index f250dd3..194cb86 100644 --- a/app/package.json +++ b/app/package.json @@ -24,6 +24,7 @@ "@radix-ui/react-select": "^2.2.4", "@radix-ui/react-slot": "^1.2.2", "@radix-ui/react-tabs": "^1.1.11", + "@tanstack/react-query": "^5.76.1", "@types/bcrypt": "^5.0.2", "bcrypt": "^6.0.0", "class-variance-authority": "^0.7.1", @@ -34,11 +35,13 @@ "mariadb": "^3.4.2", "next": "^15.3.2", "next-auth": "^4.24.11", + "pg": "^8.16.0", "prisma": "^6.7.0", "react": "^19.0.0", "react-day-picker": "8.10.1", "react-dom": "^19.0.0", - "tailwind-merge": "^3.3.0" + "tailwind-merge": "^3.3.0", + "zod": "^3.24.4" }, "prisma": { "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts" diff --git a/app/pages/_app.tsx b/app/pages/_app.tsx index 7dac1a7..a9d9aab 100644 --- a/app/pages/_app.tsx +++ b/app/pages/_app.tsx @@ -1,11 +1,16 @@ import '@/styles/globals.css'; import { SessionProvider } from 'next-auth/react'; import type { AppProps } from 'next/app'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const queryClient = new QueryClient(); export default function App({ Component, pageProps }: AppProps) { return ( - + + + ); } diff --git a/app/pages/api/auth/[...nextauth].ts b/app/pages/api/auth/[...nextauth].ts index ad6e96c..56e7823 100644 --- a/app/pages/api/auth/[...nextauth].ts +++ b/app/pages/api/auth/[...nextauth].ts @@ -2,6 +2,7 @@ import { prisma } from '@/lib/prisma'; import { roleToRoleId } from '@/utils/auth.utils'; import bcrypt from 'bcrypt'; import NextAuth, { User } from 'next-auth'; +import { NextAuthOptions } from 'next-auth'; import CredentialsProvider from 'next-auth/providers/credentials'; declare module 'next-auth' { @@ -20,7 +21,7 @@ interface ExtendedUser extends User { roleId: number; } -export default NextAuth({ +export const authOptions: NextAuthOptions = { secret: process.env.NEXTAUTH_SECRET, providers: [ CredentialsProvider({ @@ -113,4 +114,6 @@ export default NextAuth({ return token; }, }, -}); +}; + +export default NextAuth(authOptions); diff --git a/app/pages/api/db-health.ts b/app/pages/api/db-health.ts index 11ae007..f42a67c 100644 --- a/app/pages/api/db-health.ts +++ b/app/pages/api/db-health.ts @@ -1,38 +1,41 @@ // pages/api/db-health.ts -import type { NextApiRequest, NextApiResponse } from 'next'; -import mariadb from 'mariadb'; -const pool = mariadb.createPool({ - host: process.env.MYSQL_HOST, - port: process.env.MYSQL_PORT ? parseInt(process.env.MYSQL_PORT, 10) : 3306, - user: process.env.MYSQL_USER, - password: process.env.MYSQL_PASSWORD, - database: process.env.MYSQL_DATABASE, - connectionLimit: 5, -}); +// -- OUTDATED CODE, WE SWITCHED TO POSTGRES! -- // -type ResponseData = { - status: 'ok' | 'error'; - message?: string; -}; +// import type { NextApiRequest, NextApiResponse } from 'next'; +// import mariadb from 'mariadb'; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (process.env.NODE_ENV !== 'development') { - return res.status(403).json({ - status: 'error', - message: 'This endpoint is only available in development mode.', - }); - } +// const pool = mariadb.createPool({ +// host: process.env.MYSQL_HOST, +// port: process.env.MYSQL_PORT ? parseInt(process.env.MYSQL_PORT, 10) : 3306, +// user: process.env.MYSQL_USER, +// password: process.env.MYSQL_PASSWORD, +// database: process.env.MYSQL_DATABASE, +// connectionLimit: 5, +// }); - try { - const conn = await pool.getConnection(); - await conn.query('SELECT 1'); - conn.release(); +// type ResponseData = { +// status: 'ok' | 'error'; +// message?: string; +// }; - return res.status(200).json({ status: 'ok' }); - } catch (error) { - console.error('DB connection test failed:', error); - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - return res.status(500).json({ status: 'error', message: errorMessage }); - } -} +// export default async function handler(req: NextApiRequest, res: NextApiResponse) { +// if (process.env.NODE_ENV !== 'development') { +// return res.status(403).json({ +// status: 'error', +// message: 'This endpoint is only available in development mode.', +// }); +// } + +// try { +// const conn = await pool.getConnection(); +// await conn.query('SELECT 1'); +// conn.release(); + +// return res.status(200).json({ status: 'ok' }); +// } catch (error) { +// console.error('DB connection test failed:', error); +// const errorMessage = error instanceof Error ? error.message : 'Unknown error'; +// return res.status(500).json({ status: 'error', message: errorMessage }); +// } +// } diff --git a/app/pages/api/references/rooms.ts b/app/pages/api/references/rooms.ts new file mode 100644 index 0000000..ccb29d4 --- /dev/null +++ b/app/pages/api/references/rooms.ts @@ -0,0 +1,20 @@ +import { getRooms } from '@/services/referenceDataService'; +import { NextApiRequest, NextApiResponse } from 'next'; + +/** + * API pour récupérer toutes les salles disponibles + * GET: Retourne la liste des salles + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Méthode non autorisée' }); + } + + try { + const rooms = await getRooms(); + return res.status(200).json(rooms); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Une erreur est survenue'; + return res.status(500).json({ error: errorMessage }); + } +} diff --git a/app/pages/api/references/subjects.ts b/app/pages/api/references/subjects.ts new file mode 100644 index 0000000..80784e0 --- /dev/null +++ b/app/pages/api/references/subjects.ts @@ -0,0 +1,20 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { getSubjects } from '@/services/referenceDataService'; + +/** + * API pour récupérer tous les sujets disponibles + * GET: Retourne la liste des sujets + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Méthode non autorisée' }); + } + + try { + const subjects = await getSubjects(); + return res.status(200).json(subjects); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Une erreur est survenue'; + return res.status(500).json({ error: errorMessage }); + } +} diff --git a/app/pages/api/references/talkLevels.ts b/app/pages/api/references/talkLevels.ts new file mode 100644 index 0000000..c85dbf3 --- /dev/null +++ b/app/pages/api/references/talkLevels.ts @@ -0,0 +1,20 @@ +import { getTalkLevels } from '@/services/referenceDataService'; +import { NextApiRequest, NextApiResponse } from 'next'; + +/** + * API pour récupérer tous les niveaux de talks disponibles + * GET: Retourne la liste des niveaux + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Méthode non autorisée' }); + } + + try { + const levels = await getTalkLevels(); + return res.status(200).json(levels); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Une erreur est survenue'; + return res.status(500).json({ error: errorMessage }); + } +} diff --git a/app/pages/api/references/talkStatuses.ts b/app/pages/api/references/talkStatuses.ts new file mode 100644 index 0000000..24a015f --- /dev/null +++ b/app/pages/api/references/talkStatuses.ts @@ -0,0 +1,20 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { getTalkStatuses } from '@/services/referenceDataService'; + +/** + * API pour récupérer tous les statuts de talks disponibles + * GET: Retourne la liste des statuts + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Méthode non autorisée' }); + } + + try { + const statuses = await getTalkStatuses(); + return res.status(200).json(statuses); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Une erreur est survenue'; + return res.status(500).json({ error: errorMessage }); + } +} diff --git a/app/pages/api/rooms/availability.ts b/app/pages/api/rooms/availability.ts new file mode 100644 index 0000000..fa4f0ee --- /dev/null +++ b/app/pages/api/rooms/availability.ts @@ -0,0 +1,119 @@ +// pages/api/rooms/availability.ts +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getServerSession } from 'next-auth/next'; +import { prisma } from '@/lib/prisma'; +import { authOptions } from '../auth/[...nextauth]'; + +// type Slot = { +// id: number; +// start_time: Date; +// end_time: Date; +// }; + +// type RoomWithBookings = { +// id: number; +// name: string; +// capacity: number; +// schedules: Slot[]; +// }; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse< + | { + rooms: { + roomId: number; + name: string; + capacity: number; + availableSlots: { + slotId: number; + startTime: Date; + endTime: Date; + }[]; + }[]; + } + | { error: string } + >, +) { + if (req.method !== 'GET') { + res.setHeader('Allow', ['GET']); + return res.status(405).end(`Method ${req.method} Not Allowed`); + } + + // ─── AUTHORIZATION ──────────────────────────────────────────────────────────── + const session = await getServerSession(req, res, authOptions); + if (!session) return res.status(401).json({ error: 'Unauthorized' }); + const user = await prisma.user.findUnique({ + where: { id: parseInt(session.user.id, 10) }, + include: { roles: true }, + }); + if (!user || user.roles.name !== 'organizer') { + return res.status(403).json({ error: 'Forbidden' }); + } + + // ─── PARSE DATE PARAM ───────────────────────────────────────────────────────── + // if ?date=YYYY-MM-DD is provided, use it; otherwise default to today + const dateParam = typeof req.query.date === 'string' ? req.query.date : null; + const day = dateParam ? new Date(dateParam) : new Date(); + // reset to midnight + const dayStart = new Date(day); + dayStart.setHours(0, 0, 0, 0); + const dayEnd = new Date(day); + dayEnd.setHours(23, 59, 59, 999); + + // define your conference hours on *that* day + const SLOT_START = new Date(day); + SLOT_START.setHours(9, 0, 0, 0); + const SLOT_END = new Date(day); + SLOT_END.setHours(18, 0, 0, 0); + + // ─── FETCH BOOKINGS FOR THAT DAY ────────────────────────────────────────────── + const roomsWithBookings = await prisma.rooms.findMany({ + select: { + id: true, + name: true, + capacity: true, + schedules: { + where: { + start_time: { gte: dayStart, lt: dayEnd }, + }, + select: { id: true, start_time: true, end_time: true }, + orderBy: { start_time: 'asc' }, + }, + }, + }); + + // ─── BUILD EXACTLY 9 × 1-HOUR SLOTS (09–10, 10–11 … 17–18) ──────────────────── + const result = roomsWithBookings.map((room) => { + const freeSlots: { slotId: number; startTime: Date; endTime: Date }[] = []; + + for (let i = 0; i < 9; i++) { + const startTime = new Date(SLOT_START); + startTime.setHours(9 + i); + const endTime = new Date(SLOT_START); + endTime.setHours(10 + i); + + // check overlap with any existing booking + const occupied = room.schedules.some( + (booking) => booking.start_time < endTime && booking.end_time > startTime, + ); + + if (!occupied) { + freeSlots.push({ + slotId: i + 1, // 1..9 within each room + startTime, + endTime, + }); + } + } + + return { + roomId: room.id, + name: room.name, + capacity: room.capacity, + availableSlots: freeSlots, + }; + }); + + return res.status(200).json({ rooms: result }); +} diff --git a/app/pages/api/rooms/index.ts b/app/pages/api/rooms/index.ts new file mode 100644 index 0000000..fea2268 --- /dev/null +++ b/app/pages/api/rooms/index.ts @@ -0,0 +1,20 @@ +// pages/api/rooms/index.ts +import type { NextApiRequest, NextApiResponse } from 'next'; +import { prisma } from '@/lib/prisma'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'GET') { + res.setHeader('Allow', ['GET']); + return res.status(405).end(`Method ${req.method} Not Allowed`); + } + + try { + const rooms = await prisma.rooms.findMany({ + orderBy: { name: 'asc' }, + }); + return res.status(200).json({ rooms }); + } catch (err) { + console.error(err); + return res.status(500).json({ error: 'Unable to load rooms' }); + } +} diff --git a/app/pages/api/schedules.ts b/app/pages/api/schedules.ts new file mode 100644 index 0000000..ac72222 --- /dev/null +++ b/app/pages/api/schedules.ts @@ -0,0 +1,55 @@ +// pages/api/schedules.ts +import { prisma } from '@/lib/prisma'; +import type { NextApiRequest, NextApiResponse } from 'next'; + +type RawSchedule = { + id: number; + talk_id: number; + room_id: number; + start_time: Date; + end_time: Date; + // **Here** we use `speaker_id`, not `speakerId` + talk: { + id: number; + title: string; + speaker_id: number; + // …other fields if you need them + }; +}; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { date } = req.query as { date?: string }; + if (req.method !== 'GET' || !date) { + return res.status(400).json({ error: 'Use GET & provide ?date=YYYY-MM-DD' }); + } + + const day = new Date(date); + const start = new Date(day); + start.setHours(9, 0, 0, 0); + const end = new Date(day); + end.setHours(18, 0, 0, 0); + + // no more type‐assertion here: let TS infer the raw type + const raw: RawSchedule[] = await prisma.schedules.findMany({ + where: { + start_time: { gte: start, lt: end }, + }, + include: { + talk: true, + }, + }); + + const schedules = raw.map((s) => ({ + id: s.id, + roomId: s.room_id, // ← note room_id → roomId + startTime: s.start_time.toISOString(), + endTime: s.end_time.toISOString(), + talk: { + id: s.talk.id, + title: s.talk.title, + speakerId: s.talk.speaker_id, // ← speaker_id → speakerId + }, + })); + + return res.status(200).json({ schedules }); +} diff --git a/app/pages/api/schedules/available-rooms.ts b/app/pages/api/schedules/available-rooms.ts new file mode 100644 index 0000000..8eb9932 --- /dev/null +++ b/app/pages/api/schedules/available-rooms.ts @@ -0,0 +1,40 @@ +import { getAvailableRoomsForTimeSlot } from '@/services/scheduleService'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { getSession } from 'next-auth/react'; + +/** + * API pour récupérer les salles disponibles pour un créneau horaire spécifique + * GET: Retourne les salles disponibles + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + // Vérifier l'authentification + const session = await getSession({ req }); + if (!session?.user) { + return res.status(401).json({ error: 'Authentification requise' }); + } + + // Accepter uniquement les requêtes GET + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Méthode non autorisée' }); + } + + try { + const { startTime, endTime } = req.query; + + if (!startTime || !endTime) { + return res.status(400).json({ error: 'startTime et endTime sont requis' }); + } + + const availableRooms = await getAvailableRoomsForTimeSlot( + new Date(startTime as string), + new Date(endTime as string), + ); + + return res.status(200).json(availableRooms); + } catch (error) { + if (error instanceof Error) { + return res.status(500).json({ error: error.message }); + } + return res.status(500).json({ error: 'Une erreur inconnue est survenue' }); + } +} diff --git a/app/pages/api/schedules/available-times.ts b/app/pages/api/schedules/available-times.ts new file mode 100644 index 0000000..3f36f51 --- /dev/null +++ b/app/pages/api/schedules/available-times.ts @@ -0,0 +1,40 @@ +import { getAvailableTimesForRoom } from '@/services/scheduleService'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { getSession } from 'next-auth/react'; + +/** + * API pour récupérer les créneaux disponibles pour une salle à une date spécifique + * GET: Retourne les créneaux disponibles + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + // Vérifier l'authentification + const session = await getSession({ req }); + if (!session?.user) { + return res.status(401).json({ error: 'Authentification requise' }); + } + + // Accepter uniquement les requêtes GET + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Méthode non autorisée' }); + } + + try { + const { roomId, date } = req.query; + + if (!roomId || !date) { + return res.status(400).json({ error: 'roomId et date sont requis' }); + } + + const availableTimes = await getAvailableTimesForRoom( + parseInt(roomId as string), + date as string, + ); + + return res.status(200).json(availableTimes); + } catch (error) { + if (error instanceof Error) { + return res.status(500).json({ error: error.message }); + } + return res.status(500).json({ error: 'Une erreur inconnue est survenue' }); + } +} diff --git a/app/pages/api/schedules/index.ts b/app/pages/api/schedules/index.ts new file mode 100644 index 0000000..2151cf5 --- /dev/null +++ b/app/pages/api/schedules/index.ts @@ -0,0 +1,28 @@ +// pages/api/schedules/index.ts +import type { NextApiRequest, NextApiResponse } from 'next'; +import { prisma } from '@/lib/prisma'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + // Only GET is allowed + if (req.method === 'GET') { + try { + const schedules = await prisma.schedules.findMany({ + include: { + rooms: true, // include room details for each schedule + }, + orderBy: { + start_time: 'asc', // optional: sort by start time + }, + }); + + return res.status(200).json({ schedules }); + } catch (error) { + console.error('Error fetching schedules:', error); + return res.status(500).json({ error: 'Unable to load schedules' }); + } + } + + // Method not allowed + res.setHeader('Allow', ['GET']); + return res.status(405).end(`Method ${req.method} Not Allowed`); +} diff --git a/app/pages/api/schedules/room-availability.ts b/app/pages/api/schedules/room-availability.ts new file mode 100644 index 0000000..386eece --- /dev/null +++ b/app/pages/api/schedules/room-availability.ts @@ -0,0 +1,53 @@ +import { roomAvailabilitySchema } from '@/schemas/talkSchemas'; +import { checkRoomAvailability } from '@/services/scheduleService'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { getSession } from 'next-auth/react'; + +/** + * API pour vérifier la disponibilité d'une salle à un créneau horaire + * GET: Vérifier si une salle est disponible + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const session = await getSession({ req }); + if (!session?.user) { + return res.status(401).json({ error: 'Authentification requise' }); + } + + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Méthode non autorisée' }); + } + + try { + const validationResult = roomAvailabilitySchema.safeParse({ + roomId: parseInt(req.query.roomId as string), + startTime: req.query.startTime as string, + endTime: req.query.endTime as string, + excludeTalkId: req.query.excludeTalkId + ? parseInt(req.query.excludeTalkId as string) + : undefined, + }); + + if (!validationResult.success) { + return res.status(400).json({ + error: 'Validation failed', + details: validationResult.error.errors, + }); + } + + const { roomId, startTime, endTime, excludeTalkId } = validationResult.data; + + const result = await checkRoomAvailability( + roomId, + new Date(startTime), + new Date(endTime), + excludeTalkId, + ); + + return res.status(200).json(result); + } catch (error) { + if (error instanceof Error) { + return res.status(500).json({ error: error.message }); + } + return res.status(500).json({ error: 'Une erreur inconnue est survenue' }); + } +} diff --git a/app/pages/api/slots.ts b/app/pages/api/slots.ts new file mode 100644 index 0000000..dc5c6c4 --- /dev/null +++ b/app/pages/api/slots.ts @@ -0,0 +1,34 @@ +// pages/api/slots.ts +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from './auth/[...nextauth]'; +import { prisma } from '@/lib/prisma'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'GET') { + res.setHeader('Allow', ['GET']); + return res.status(405).end(`Method ${req.method} Not Allowed`); + } + + // require an authenticated user, but no role check: + const session = await getServerSession(req, res, authOptions); + if (!session) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const schedules = await prisma.schedules.findMany({ + include: { rooms: true }, + orderBy: { start_time: 'asc' }, + }); + + const slots = schedules.map((s) => ({ + id: s.id.toString(), + roomId: s.room_id.toString(), + date: s.start_time, + startTime: s.start_time.toISOString(), + endTime: s.end_time.toISOString(), + talkId: s.talk_id?.toString() ?? '', + })); + + return res.status(200).json({ slots }); +} diff --git a/app/pages/api/talks/[id].ts b/app/pages/api/talks/[id].ts new file mode 100644 index 0000000..a283d80 --- /dev/null +++ b/app/pages/api/talks/[id].ts @@ -0,0 +1,91 @@ +// pages/api/talks/[id].ts +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '../auth/[...nextauth]'; +import { prisma } from '@/lib/prisma'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const talkId = parseInt(req.query.id as string, 10); + if (Number.isNaN(talkId)) { + return res.status(400).json({ error: 'Invalid talk ID' }); + } + + // Only support PUT and DELETE + if (req.method !== 'PUT' && req.method !== 'DELETE') { + res.setHeader('Allow', ['PUT', 'DELETE']); + return res.status(405).end(`Method ${req.method} Not Allowed`); + } + + // Authenticate user + const session = await getServerSession(req, res, authOptions); + if (!session) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const userId = parseInt(session.user.id, 10); + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { roles: true }, + }); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + const role = user.roles.name; + if (role !== 'speaker') { + return res.status(403).json({ error: 'Forbidden' }); + } + + if (req.method === 'PUT') { + // Validate payload + const { title, description, topic, durationMinutes, level } = req.body; + if (!title || !description || !topic || !durationMinutes || !level) { + return res.status(400).json({ error: 'Missing fields' }); + } + + // Resolve subject + const subject = await prisma.subjects.findUnique({ + where: { name: topic }, + }); + if (!subject) { + return res.status(400).json({ error: `Subject ${topic} not found` }); + } + + // Ensure ownership + const existing = await prisma.talks.findUnique({ where: { id: talkId } }); + if (!existing || existing.speaker_id !== user.id) { + return res.status(404).json({ error: `Talk #${talkId} not found or you’re not the owner` }); + } + + // Perform update + const updated = await prisma.talks.update({ + where: { id: talkId }, + data: { + title, + description, + subject_id: subject.id, + duration: durationMinutes, + level, + updated_at: new Date(), + }, + }); + + return res.status(200).json(updated); + } + + if (req.method === 'DELETE') { + // Delete the talk owned by the speaker + const result = await prisma.talks.deleteMany({ + where: { + id: talkId, + speaker_id: user.id, + }, + }); + + if (result.count === 0) { + return res.status(404).json({ error: `Talk #${talkId} not found or you’re not the owner` }); + } + + return res.status(204).end(); + } +} diff --git a/app/pages/api/talks/[id]/accept.ts b/app/pages/api/talks/[id]/accept.ts new file mode 100644 index 0000000..8590780 --- /dev/null +++ b/app/pages/api/talks/[id]/accept.ts @@ -0,0 +1,43 @@ +import { acceptTalk } from '@/services/scheduleService'; +import { isOrganizer } from '@/utils/auth.utils'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { getSession } from 'next-auth/react'; + +/** + * API pour accepter un talk (sans lui attribuer une salle/créneau) + * POST: Accepte un talk en attente + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const session = await getSession({ req }); + if (!session?.user) { + return res.status(401).json({ error: 'Authentification requise' }); + } + + const userRoleId = session.user.roleId; + if (!isOrganizer(userRoleId)) { + return res.status(403).json({ error: 'Autorisation refusée: Réservé aux organisateurs' }); + } + + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Méthode non autorisée' }); + } + + try { + const { id } = req.query; + + if (!id || Array.isArray(id)) { + return res.status(400).json({ error: 'ID de talk invalide' }); + } + + const talkId = parseInt(id); + + const acceptResult = await acceptTalk(talkId, req); + + return res.status(200).json(acceptResult); + } catch (error) { + if (error instanceof Error) { + return res.status(500).json({ error: error.message }); + } + return res.status(500).json({ error: 'Une erreur inconnue est survenue' }); + } +} diff --git a/app/pages/api/talks/[id]/favorite.ts b/app/pages/api/talks/[id]/favorite.ts new file mode 100644 index 0000000..b25c50f --- /dev/null +++ b/app/pages/api/talks/[id]/favorite.ts @@ -0,0 +1,46 @@ +// pages/api/talks/[id]/favorite.ts +import { NextApiRequest, NextApiResponse } from 'next'; +import { getServerSession } from 'next-auth/next'; +import { prisma } from '@/lib/prisma'; +import { authOptions } from '../../auth/[...nextauth]'; +import { Prisma } from '@prisma/client'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const talkId = parseInt(req.query.id as string, 10); + if (!talkId || (req.method !== 'POST' && req.method !== 'DELETE')) { + res.setHeader('Allow', ['POST', 'DELETE']); + return res.status(405).end(`Method ${req.method} Not Allowed`); + } + + const session = await getServerSession(req, res, authOptions); + if (!session) return res.status(401).json({ error: 'Unauthorized' }); + + const userId = parseInt(session.user.id, 10); + // optional: check role is attendee + // if (!isAttendee(session.user.roleId)) return res.status(403).json({ error: 'Forbidden' }) + + try { + if (req.method === 'POST') { + // Create favorite (unique constraint guards duplicates) + const fav = await prisma.favorites.create({ + data: { user_id: userId, talk_id: talkId }, + }); + return res.status(201).json(fav); + } else { + // DELETE: remove the favorite + await prisma.favorites.deleteMany({ + where: { user_id: userId, talk_id: talkId }, + }); + return res.status(204).end(); + } + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError) { + // Unique constraint violation on POST + if (err.code === 'P2002') { + return res.status(409).json({ error: 'Already favorited' }); + } + } + console.error(err); + return res.status(500).json({ error: 'Server error' }); + } +} diff --git a/app/pages/api/talks/[id]/reject.ts b/app/pages/api/talks/[id]/reject.ts new file mode 100644 index 0000000..4119a79 --- /dev/null +++ b/app/pages/api/talks/[id]/reject.ts @@ -0,0 +1,46 @@ +import { rejectTalk } from '@/services/scheduleService'; +import { isOrganizer } from '@/utils/auth.utils'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { getSession } from 'next-auth/react'; + +/** + * API pour rejeter un talk + * POST: Rejette un talk en attente + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + // Vérifier l'authentification + const session = await getSession({ req }); + if (!session?.user) { + return res.status(401).json({ error: 'Authentification requise' }); + } + + // Vérifier que l'utilisateur est un organisateur + const userRoleId = session.user.roleId; + if (!isOrganizer(userRoleId)) { + return res.status(403).json({ error: 'Autorisation refusée: Réservé aux organisateurs' }); + } + + // Accepter uniquement les requêtes POST + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Méthode non autorisée' }); + } + + try { + // Récupérer l'ID du talk depuis l'URL + const { id } = req.query; + + if (!id || Array.isArray(id)) { + return res.status(400).json({ error: 'ID de talk invalide' }); + } + + const talkId = parseInt(id); + + // Appeler le service pour rejeter le talk + const rejectResult = await rejectTalk(talkId, req); + + return res.status(200).json(rejectResult); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Une erreur est survenue'; + return res.status(500).json({ error: errorMessage }); + } +} diff --git a/app/pages/api/talks/[id]/schedule.ts b/app/pages/api/talks/[id]/schedule.ts new file mode 100644 index 0000000..ab5b249 --- /dev/null +++ b/app/pages/api/talks/[id]/schedule.ts @@ -0,0 +1,84 @@ +// pages/api/talks/[id]/schedule.ts +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getServerSession } from 'next-auth/next'; +import { prisma } from '@/lib/prisma'; +import { authOptions } from '../../auth/[...nextauth]'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') { + res.setHeader('Allow', ['POST']); + return res.status(405).end(`Method ${req.method} Not Allowed`); + } + + const talkId = parseInt(req.query.id as string, 10); + if (Number.isNaN(talkId)) { + return res.status(400).json({ error: 'Invalid talk ID' }); + } + + // — AUTH — + const session = await getServerSession(req, res, authOptions); + if (!session) return res.status(401).json({ error: 'Unauthorized' }); + const user = await prisma.user.findUnique({ + where: { id: parseInt(session.user.id, 10) }, + include: { roles: true }, + }); + if (!user || user.roles.name !== 'organizer') { + return res.status(403).json({ error: 'Forbidden' }); + } + + // — PARSE BODY — + const { roomId, startTime, endTime } = req.body as { + roomId?: number; + startTime?: string; + endTime?: string; + }; + if ( + typeof roomId !== 'number' || + !startTime || + !endTime || + isNaN(new Date(startTime).getTime()) || + isNaN(new Date(endTime).getTime()) + ) { + return res.status(400).json({ error: 'roomId, startTime & endTime are required' }); + } + const start = new Date(startTime); + const end = new Date(endTime); + + // — CHECK OVERLAP — + const conflict = await prisma.schedules.findFirst({ + where: { + room_id: roomId, + AND: [{ start_time: { lt: end } }, { end_time: { gt: start } }], + }, + }); + if (conflict) { + return res.status(409).json({ error: 'Slot already taken' }); + } + + // — CREATE SCHEDULE & UPDATE TALK STATUS — + const [newSchedule, updatedTalk] = await prisma.$transaction([ + prisma.schedules.create({ + data: { + talk_id: talkId, + room_id: roomId, + start_time: start, + end_time: end, + }, + }), + prisma.talks.update({ + where: { id: talkId }, + data: { status: 'scheduled', updated_at: new Date() }, + }), + ]); + + return res.status(200).json({ + slot: { + id: newSchedule.id, + roomId: newSchedule.room_id, + startTime: newSchedule.start_time, + endTime: newSchedule.end_time, + talkId: newSchedule.talk_id, + }, + talk: updatedTalk, + }); +} diff --git a/app/pages/api/talks/[id]/status.ts b/app/pages/api/talks/[id]/status.ts new file mode 100644 index 0000000..2935ac1 --- /dev/null +++ b/app/pages/api/talks/[id]/status.ts @@ -0,0 +1,59 @@ +// pages/api/talks/[id]/status.ts + +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '../../auth/[...nextauth]'; +import { prisma } from '@/lib/prisma'; +import { TalkStatus } from '@/lib/types'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + // Parse and validate talk ID + const talkId = parseInt(req.query.id as string, 10); + if (Number.isNaN(talkId)) { + return res.status(400).json({ error: 'Invalid talk ID' }); + } + + // Only allow PUT for status updates + if (req.method !== 'PUT') { + res.setHeader('Allow', ['PUT']); + return res.status(405).end(`Method ${req.method} Not Allowed`); + } + + // Authenticate + const session = await getServerSession(req, res, authOptions); + if (!session) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const userId = parseInt(session.user.id, 10); + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { roles: true }, + }); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + // Validate incoming status + const { status } = req.body as { status?: TalkStatus }; + if (status !== 'accepted' && status !== 'rejected') { + return res.status(400).json({ error: 'Invalid status; must be "accepted" or "rejected"' }); + } + + // Ensure the talk exists + const existing = await prisma.talks.findUnique({ where: { id: talkId } }); + if (!existing) { + return res.status(404).json({ error: 'Talk not found' }); + } + + // Update status + const updated = await prisma.talks.update({ + where: { id: talkId }, + data: { + status, + updated_at: new Date(), + }, + }); + + return res.status(200).json(updated); +} diff --git a/app/pages/api/talks/[id]/validate.ts b/app/pages/api/talks/[id]/validate.ts new file mode 100644 index 0000000..b40e587 --- /dev/null +++ b/app/pages/api/talks/[id]/validate.ts @@ -0,0 +1,66 @@ +import { validateTalk } from '@/services/scheduleService'; +import { isOrganizer } from '@/utils/auth.utils'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { getSession } from 'next-auth/react'; +import { z } from 'zod'; + +// Schéma de validation pour les données de planification +const validateScheduleSchema = z.object({ + roomId: z.number().int().positive(), + startTime: z.string().datetime(), + endTime: z.string().datetime(), +}); + +/** + * API pour valider un talk et l'attribuer à une salle + * POST: Valide un talk et lui attribue une salle et un créneau + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + // Vérifier l'authentification + const session = await getSession({ req }); + if (!session?.user) { + return res.status(401).json({ error: 'Authentification requise' }); + } + + // Vérifier que l'utilisateur est un organisateur + const userRoleId = session.user.roleId; + if (!isOrganizer(userRoleId)) { + return res.status(403).json({ error: 'Autorisation refusée: Réservé aux organisateurs' }); + } + + // Accepter uniquement les requêtes POST + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Méthode non autorisée' }); + } + + try { + // Récupérer l'ID du talk depuis l'URL + const { id } = req.query; + + if (!id || Array.isArray(id)) { + return res.status(400).json({ error: 'ID de talk invalide' }); + } + + const talkId = parseInt(id); + + // Valider les données de planification + const validationResult = validateScheduleSchema.safeParse(req.body); + + if (!validationResult.success) { + return res.status(400).json({ + error: 'Validation failed', + details: validationResult.error.errors, + }); + } + + const { roomId, startTime, endTime } = validationResult.data; + + // Appeler le service pour valider et planifier le talk + const result = await validateTalk(talkId, roomId, new Date(startTime), new Date(endTime), req); + + return res.status(200).json(result); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Une erreur est survenue'; + return res.status(500).json({ error: errorMessage }); + } +} diff --git a/app/pages/api/talks/index.ts b/app/pages/api/talks/index.ts new file mode 100644 index 0000000..7a2b753 --- /dev/null +++ b/app/pages/api/talks/index.ts @@ -0,0 +1,87 @@ +// pages/api/talks/index.ts +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '../auth/[...nextauth]'; +import { prisma } from '@/lib/prisma'; +import type { Prisma } from '@prisma/client'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + // 1) For GET, decide which talks to show based on role + if (req.method === 'GET') { + // try to get a session (if any) + const session = await getServerSession(req, res, authOptions); + + // now strongly typed + let filter: Prisma.talksWhereInput = { status: 'accepted' }; + + if (session) { + const userId = parseInt(session.user.id, 10); + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { roles: true }, + }); + + // if the user is an organizer, show everything + if (user?.roles.name === 'organizer') { + filter = {}; // {} is a valid TalksWhereInput + } + } + + const talks = await prisma.talks.findMany({ + where: filter, + include: { + subjects: true, + schedules: true, + feedback: true, + favorites: true, + users: { select: { id: true, username: true, email: true } }, + }, + orderBy: { created_at: 'desc' }, + }); + + return res.status(200).json({ talks }); + } + + // 2) For other methods (POST, etc), we still require auth + const session = await getServerSession(req, res, authOptions); + if (!session) return res.status(401).json({ error: 'Unauthorized' }); + + // 3) Load user + const userId = parseInt(session.user.id, 10); + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { roles: true }, + }); + if (!user) return res.status(404).json({ error: 'User not found' }); + + // 4) Branch POST (only speakers/orgs) + if (req.method === 'POST') { + const role = user.roles.name; + if (role !== 'speaker' && role !== 'organizer') + return res.status(403).json({ error: 'Forbidden' }); + + const { title, description, topic, durationMinutes, level } = req.body; + if (!title || !description || !topic || !durationMinutes || !level) + return res.status(400).json({ error: 'Missing fields' }); + + const subject = await prisma.subjects.findUnique({ where: { name: topic } }); + if (!subject) return res.status(400).json({ error: `Subject "${topic}" not found` }); + + const newTalk = await prisma.talks.create({ + data: { + title, + description, + speaker_id: user.id, + subject_id: subject.id, + duration: durationMinutes, + level, + status: role === 'organizer' ? 'accepted' : 'pending', + }, + }); + return res.status(201).json(newTalk); + } + + // 5) All other methods — 405 + res.setHeader('Allow', ['GET', 'POST']); + return res.status(405).end(`Method ${req.method} Not Allowed`); +} diff --git a/app/pages/api/talks/me.ts b/app/pages/api/talks/me.ts new file mode 100644 index 0000000..bc7a4bb --- /dev/null +++ b/app/pages/api/talks/me.ts @@ -0,0 +1,39 @@ +// pages/api/talks/me.ts +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '../auth/[...nextauth]'; +import { prisma } from '@/lib/prisma'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + // 1. Authenticate + const session = await getServerSession(req, res, authOptions); + if (!session) { + return res.status(401).json({ error: 'Unauthorized' }); + } + const userId = parseInt(session.user.id, 10); + + // 2. Only allow GET + if (req.method !== 'GET') { + res.setHeader('Allow', ['GET']); + return res.status(405).end(`Method ${req.method} Not Allowed`); + } + + try { + // 3. Fetch all talks where the current user is the speaker + const talks = await prisma.talks.findMany({ + where: { speaker_id: userId }, + include: { + subjects: true, // if you want the topic name + schedules: true, // if you want any scheduled slots + feedback: true, // feedback for each talk + favorites: true, // who favorited it + }, + orderBy: { created_at: 'desc' }, + }); + + return res.status(200).json({ talks }); + } catch (error) { + console.error('Error fetching user talks:', error); + return res.status(500).json({ error: 'Internal server error' }); + } +} diff --git a/app/pages/index.tsx b/app/pages/index.tsx index 222fa23..df7a3ac 100644 --- a/app/pages/index.tsx +++ b/app/pages/index.tsx @@ -1,42 +1,80 @@ +import { useEffect, useState } from 'react'; import Header from '@/components/header'; import PendingTalksList from '@/components/talks/PendingTalksList'; import TalksList from '@/components/talks/TalksList'; import TalksSchedule from '@/components/talks/TalksSchedule'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { mockData } from '@/lib/mock-data'; -import { ScheduledTalk, Talk } from '@/lib/types'; -import { isOrganizer } from '@/utils/auth.utils'; +import { Talk, TalkStatus } from '@/lib/types'; +import { isOrganizer, isSpeaker } from '@/utils/auth.utils'; import { useSession } from 'next-auth/react'; -import { useState } from 'react'; +import { mockData } from '@/lib/mock-data'; +import MyTalksList from '@/components/talks/MyTalksList'; export default function TalksPage() { - const session = useSession(); - - const [talks, setTalks] = useState(mockData.talks); - const [scheduledTalks, setScheduledTalks] = useState([]); + const { data: session, status } = useSession(); + const [talks, setTalks] = useState([]); + // const [scheduledTalks, setScheduledTalks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + // wait until Next-Auth has resolved + if (status === 'loading') return; + + const fetchTalks = async () => { + setLoading(true); + setError(null); + + // choose endpoint: authenticated speakers -> /api/talks/me; everything else -> public /api/talks + let endpoint = '/api/talks'; + if (status === 'authenticated') { + if (isSpeaker(session!.user.roleId)) { + endpoint = '/api/talks/me'; + } + } + + try { + const res = await fetch(endpoint); + if (!res.ok) throw new Error(`Error: ${res.status}`); + const { talks: fetched } = await res.json(); + setTalks(fetched); + // console.log('session:', session); + // console.log('fetched talks:', fetched); + } catch (err) { + if (err instanceof Error) { + console.error(err); + setError(err.message); + } else { + console.error('Unexpected error:', err); + setError('An unexpected error occurred'); + } + } finally { + setLoading(false); + } + }; + + fetchTalks(); + }, [status, session?.user.roleId]); // Fonction pour programmer un talk const scheduleTalk = (talkId: string, slotId: string) => { - // Trouver le talk et le slot sélectionnés const talk = talks.find((t) => t.id === talkId); const slot = mockData.slots.find((s) => s.id === slotId); if (!talk || !slot) return; - // Pour le MVP, on met à jour localement const updatedTalk = { ...talk, status: 'scheduled' as const }; const updatedSlot = { ...slot, talkId: talk.id }; - // Mettre à jour les états locaux - setTalks(talks.map((t) => (t.id === talk.id ? updatedTalk : t))); - setScheduledTalks([ - ...scheduledTalks, - { - talk: updatedTalk, - slot: updatedSlot, - room: mockData.rooms.find((r) => r.id === slot.roomId)!, - }, - ]); + setTalks((prev) => prev.map((t) => (t.id === talk.id ? updatedTalk : t))); + // setScheduledTalks((prev) => [ + // ...prev, + // { + // talk: updatedTalk, + // slot: updatedSlot, + // room: mockData.rooms.find((r) => r.id === slot.roomId)!, + // }, + // ]); return { updatedTalk, updatedSlot }; }; @@ -47,28 +85,52 @@ export default function TalksPage() { ...newTalk, id: Date.now().toString(), } as Talk; - - setTalks([...talks, talkWithId]); + setTalks((prev) => [...prev, talkWithId]); }; const updateTalk = (updatedTalk: Talk) => { - setTalks(talks.map((t) => (t.id === updatedTalk.id ? updatedTalk : t))); - }; - - const deleteTalk = (talkId: string) => { - setTalks(talks.filter((t) => t.id !== talkId)); + setTalks((prev) => prev.map((t) => (t.id === updatedTalk.id ? updatedTalk : t))); }; - const changeTalkStatus = (talkId: string, newStatus: Talk['status']) => { - setTalks(talks.map((t) => (t.id === talkId ? { ...t, status: newStatus } : t))); + const deleteTalk = async (talkId: string) => { + const res = await fetch(`/api/talks/${talkId}`, { method: 'DELETE' }); + if (res.ok) { + setTalks((prev) => prev.filter((t) => t.id !== talkId)); + } else { + const err = await res.json(); + console.error('Failed to delete talk:', err.error); + // optionally show a toast/snackbar here + } }; + // const changeTalkStatus = (talkId: string, newStatus: Talk['status']) => { + // setTalks((prev) => prev.map((t) => (t.id === talkId ? { ...t, status: newStatus } : t))); + // }; + + async function changeTalkStatus(talkId: string, newStatus: TalkStatus) { + const res = await fetch(`/api/talks/${talkId}/status`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: newStatus }), + }); + if (!res.ok) { + const error = await res.json(); + console.error('Failed to change talk status:', error.error); + } + } + + if (loading) { + return
Loading talks...
; + } + if (error) { + return
Error loading talks: {error}
; + } return (
- {isOrganizer(session.data?.user?.roleId) && ( + {isOrganizer(session?.user?.roleId) && ( Tous les talks Tous les talks en attente @@ -78,15 +140,24 @@ export default function TalksPage() { {/* Tab: Liste des talks */} - + {isSpeaker(session?.user?.roleId) ? ( + + ) : ( + + )} - {/* Tab: Liste des talks */} + {/* Tab: Liste des talks en attente */} talk.status === 'accepted')} onScheduleTalk={scheduleTalk} /> diff --git a/app/prisma/migrations/20250515133410_allow_multiple_schedules_per_talk/migration.sql b/app/prisma/migrations/20250515133410_allow_multiple_schedules_per_talk/migration.sql new file mode 100644 index 0000000..5f74822 --- /dev/null +++ b/app/prisma/migrations/20250515133410_allow_multiple_schedules_per_talk/migration.sql @@ -0,0 +1,5 @@ +-- DropIndex +DROP INDEX "schedules_talk_id_unique"; + +-- CreateIndex +CREATE INDEX "talk_id" ON "schedules"("talk_id"); diff --git a/app/prisma/migrations/20250516041455_make_talk_id_nullable/migration.sql b/app/prisma/migrations/20250516041455_make_talk_id_nullable/migration.sql new file mode 100644 index 0000000..e714a42 --- /dev/null +++ b/app/prisma/migrations/20250516041455_make_talk_id_nullable/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "schedules" ALTER COLUMN "talk_id" DROP NOT NULL; diff --git a/app/prisma/migrations/20250516041649_reset_make_talk_id_nullable/migration.sql b/app/prisma/migrations/20250516041649_reset_make_talk_id_nullable/migration.sql new file mode 100644 index 0000000..38c3cb2 --- /dev/null +++ b/app/prisma/migrations/20250516041649_reset_make_talk_id_nullable/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Made the column `talk_id` on table `schedules` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "schedules" ALTER COLUMN "talk_id" SET NOT NULL; diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma index db7fe1f..a1b9fae 100644 --- a/app/prisma/schema.prisma +++ b/app/prisma/schema.prisma @@ -70,17 +70,18 @@ model rooms { model schedules { id Int @id @default(autoincrement()) - talk_id Int @unique(map: "schedules_talk_id_unique") + talk_id Int room_id Int start_time DateTime @db.Timestamp end_time DateTime @db.Timestamp created_at DateTime @default(now()) @db.Timestamp updated_at DateTime @default(now()) @db.Timestamp rooms rooms @relation(fields: [room_id], references: [id], onUpdate: Restrict, map: "schedules_room_id_fk") - talks talks @relation(fields: [talk_id], references: [id], onDelete: Cascade, onUpdate: Restrict, map: "schedules_talk_id_fk") + talk talks @relation(fields: [talk_id], references: [id], onDelete: Cascade, onUpdate: Restrict, map: "schedules_talk_id_fk") @@index([room_id], map: "room_id") @@index([start_time], map: "start_time") + @@index([talk_id], map: "talk_id") } model subjects { @@ -104,7 +105,7 @@ model talks { updated_at DateTime @default(now()) @db.Timestamp favorites favorites[] feedback feedback[] - schedules schedules? + schedules schedules[] users User @relation(fields: [speaker_id], references: [id], onDelete: Cascade, onUpdate: Restrict, map: "talks_speaker_id_fk") subjects subjects @relation(fields: [subject_id], references: [id], onUpdate: Restrict, map: "talks_subject_id_fk") @@ -125,4 +126,4 @@ enum talks_status { accepted rejected scheduled -} \ No newline at end of file +} diff --git a/app/prisma/seed.ts b/app/prisma/seed.ts index e20ed64..06d3292 100644 --- a/app/prisma/seed.ts +++ b/app/prisma/seed.ts @@ -21,9 +21,9 @@ async function seedRoles() { await prisma.roles.createMany({ data: [{ name: 'admin' }, { name: 'organizer' }, { name: 'speaker' }, { name: 'attendee' }], }); - console.log('Rôles de base créés avec succès'); + // console.log('Rôles de base créés avec succès'); } else { - console.log('Les rôles existent déjà, aucune action nécessaire'); + // console.log('Les rôles existent déjà, aucune action nécessaire'); } } @@ -52,9 +52,9 @@ async function seedSubjects() { { name: 'Accessibility' }, ], }); - console.log('Sujets de base créés avec succès'); + // console.log('Sujets de base créés avec succès'); } else { - console.log('Les sujets existent déjà, aucune action nécessaire'); + // console.log('Les sujets existent déjà, aucune action nécessaire'); } } @@ -93,9 +93,9 @@ async function seedRooms() { }, ], }); - console.log('Les 5 salles ont été créées avec succès'); + // console.log('Les 5 salles ont été créées avec succès'); } else { - console.log('Les salles existent déjà, aucune action nécessaire'); + // console.log('Les salles existent déjà, aucune action nécessaire'); } } diff --git a/app/schemas/talkSchemas.ts b/app/schemas/talkSchemas.ts new file mode 100644 index 0000000..675c019 --- /dev/null +++ b/app/schemas/talkSchemas.ts @@ -0,0 +1,58 @@ +import { talks_level } from '@prisma/client'; +import { z } from 'zod'; + +// Schéma pour la création d'un talk par un conférencier +export const createTalkSchema = z.object({ + title: z.string().min(5, 'Le titre doit contenir au moins 5 caractères').max(200), + description: z.string().min(20, 'La description doit contenir au moins 20 caractères'), + subject: z.string().min(1, 'Le sujet est requis'), + duration: z.number().int().positive().max(240, 'La durée maximale est de 240 minutes'), + level: z.nativeEnum(talks_level).default('intermediate'), +}); + +// Schéma pour la validation d'un talk par un organisateur +export const validateTalkSchema = z.object({ + talkId: z.number().int().positive(), + roomId: z.number().int().positive(), + startTime: z.string().datetime(), + endTime: z.string().datetime(), +}); + +// Schéma pour l'acceptation/rejet d'un talk par un organisateur +export const talkStatusUpdateSchema = z.object({ + talkId: z.number().int().positive(), +}); + +// Schéma pour la mise à jour d'un talk par un conférencier +export const updateTalkSchema = z.object({ + title: z.string().min(5, 'Le titre doit contenir au moins 5 caractères').max(200).optional(), + description: z.string().min(20, 'La description doit contenir au moins 20 caractères').optional(), + subject_id: z.number().int().positive().optional(), + duration: z.number().int().positive().max(240, 'La durée maximale est de 240 minutes').optional(), + level: z.nativeEnum(talks_level).optional(), +}); + +// Schéma pour le toggle des favoris +export const toggleFavoriteSchema = z.object({ + talkId: z.number().int().positive(), +}); + +// Schéma pour la vérification de disponibilité d'une salle +export const roomAvailabilitySchema = z.object({ + roomId: z.number().int().positive(), + startTime: z.string().datetime(), + endTime: z.string().datetime(), + excludeTalkId: z.number().int().positive().optional(), +}); + +// Schéma pour la récupération des créneaux disponibles +export const availableTimesSchema = z.object({ + roomId: z.number().int().positive(), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Format de date invalide (YYYY-MM-DD)'), +}); + +// Schéma pour la récupération des salles disponibles +export const availableRoomsSchema = z.object({ + startTime: z.string().datetime(), + endTime: z.string().datetime(), +}); diff --git a/app/services/favoriteService.ts b/app/services/favoriteService.ts new file mode 100644 index 0000000..4a885ee --- /dev/null +++ b/app/services/favoriteService.ts @@ -0,0 +1,99 @@ +import { prisma } from '@/lib/prisma'; +import { NextApiRequest } from 'next'; +import { getSession } from 'next-auth/react'; + +/** + * Toggle un talk en favoris pour un utilisateur connecté + * @param talkId ID du talk à ajouter/supprimer des favoris + * @param req Requête API contenant les informations de session + * @returns Un objet indiquant si le talk a été ajouté ou supprimé des favoris + */ +export async function toggleFavorite(talkId: number, req: NextApiRequest) { + // Vérifier l'authentification de l'utilisateur + const session = await getSession({ req }); + + if (!session?.user) { + throw new Error('Unauthorized: Authentication required'); + } + + const userId = parseInt(session.user.id); + + // Vérifier que le talk existe + const talk = await prisma.talks.findUnique({ + where: { id: talkId }, + }); + + if (!talk) { + throw new Error('Talk not found'); + } + + // Vérifier si le talk est déjà en favoris + const existingFavorite = await prisma.favorites.findUnique({ + where: { + user_id_talk_id: { + user_id: userId, + talk_id: talkId, + }, + }, + }); + + // Si le favoris existe, le supprimer + if (existingFavorite) { + await prisma.favorites.delete({ + where: { + id: existingFavorite.id, + }, + }); + return { added: false, removed: true, talkId }; + } + + // Sinon, créer un nouveau favoris + await prisma.favorites.create({ + data: { + user_id: userId, + talk_id: talkId, + }, + }); + + return { added: true, removed: false, talkId }; +} + +/** + * Récupère les talks favoris d'un utilisateur + * @param req Requête API contenant les informations de session + * @returns Liste des talks favoris avec leurs détails + */ +export async function getUserFavorites(req: NextApiRequest) { + const session = await getSession({ req }); + + if (!session?.user) { + throw new Error('Unauthorized: Authentication required'); + } + + const userId = parseInt(session.user.id); + + return prisma.favorites.findMany({ + where: { + user_id: userId, + }, + include: { + talks: { + include: { + users: { + select: { + id: true, + username: true, + avatarUrl: true, + }, + }, + subjects: true, + schedules: { + include: { + rooms: true, + }, + }, + }, + }, + }, + }); +} diff --git a/app/services/referenceDataService.ts b/app/services/referenceDataService.ts new file mode 100644 index 0000000..036cf6f --- /dev/null +++ b/app/services/referenceDataService.ts @@ -0,0 +1,52 @@ +import { prisma } from '@/lib/prisma'; +import { talks_level, talks_status } from '@prisma/client'; + +/** + * Récupère tous les niveaux de talks disponibles + * @returns Array de tous les niveaux de talks + */ +export async function getTalkLevels() { + return Object.values(talks_level); +} + +/** + * Récupère tous les statuts de talks disponibles + * @returns Array de tous les statuts de talks + */ +export async function getTalkStatuses() { + return Object.values(talks_status); +} + +/** + * Récupère tous les sujets disponibles + * @returns Liste des sujets avec leur id et nom + */ +export async function getSubjects() { + return prisma.subjects.findMany({ + select: { + id: true, + name: true, + }, + orderBy: { + name: 'asc', + }, + }); +} + +/** + * Récupère toutes les salles + * @returns Liste des salles avec leurs détails + */ +export async function getRooms() { + return prisma.rooms.findMany({ + select: { + id: true, + name: true, + capacity: true, + description: true, + }, + orderBy: { + name: 'asc', + }, + }); +} diff --git a/app/services/scheduleService.ts b/app/services/scheduleService.ts new file mode 100644 index 0000000..e889d59 --- /dev/null +++ b/app/services/scheduleService.ts @@ -0,0 +1,400 @@ +import { prisma } from '@/lib/prisma'; +import { isOrganizer } from '@/utils/auth.utils'; +import { Prisma } from '@prisma/client'; +import { NextApiRequest } from 'next'; +import { getSession } from 'next-auth/react'; + +// Constantes pour les horaires +const MIN_HOUR = 9; // 9h +const MAX_HOUR = 19; // 19h + +/** + * Vérifie si une salle est disponible pour une plage horaire donnée + * @param roomId ID de la salle + * @param startTime Heure de début + * @param endTime Heure de fin + * @param excludeTalkId ID du talk à exclure de la vérification (pour les updates) + * @returns true si la salle est disponible, false sinon + */ +export async function checkRoomAvailability( + roomId: number, + startTime: Date, + endTime: Date, + excludeTalkId?: number, +) { + const startHour = startTime.getHours(); + const endHour = endTime.getHours(); + const endMinutes = endTime.getMinutes(); + + if (startHour < MIN_HOUR) { + throw new Error('Les talks doivent commencer après 9h'); + } + + if (endHour > MAX_HOUR || (endHour === MAX_HOUR && endMinutes > 0)) { + throw new Error('Les talks doivent se terminer avant 19h'); + } + + if (startTime >= endTime) { + throw new Error("L'heure de début doit être antérieure à l'heure de fin"); + } + + const room = await prisma.rooms.findUnique({ + where: { id: roomId }, + }); + + if (!room) { + throw new Error('Salle non trouvée'); + } + const conflictCondition: Prisma.schedulesWhereInput = { + room_id: roomId, + OR: [ + { start_time: { gte: startTime, lt: endTime } }, + { end_time: { gt: startTime, lte: endTime } }, + { AND: [{ start_time: { lte: startTime } }, { end_time: { gte: endTime } }] }, + ], + }; + + // Si on exclut un talk (pour les mises à jour), ajouter cette condition + if (excludeTalkId) { + conflictCondition['talk_id'] = { not: excludeTalkId }; + } + + const existingSchedules = await prisma.schedules.findMany({ + where: conflictCondition, + include: { + talk: { + select: { + title: true, + }, + }, + }, + }); + + return { + available: existingSchedules.length === 0, + conflicts: existingSchedules.map((schedule) => ({ + id: schedule.id, + talkId: schedule.talk_id, + talkTitle: schedule.talk.title, + startTime: schedule.start_time, + endTime: schedule.end_time, + })), + }; +} + +/** + * Récupère les horaires disponibles pour une salle donnée à une date spécifique + * @param roomId ID de la salle + * @param date Date à vérifier + * @returns Les plages horaires disponibles pour la salle + */ +export async function getAvailableTimesForRoom(roomId: number, date: string | Date) { + const targetDate = new Date(date); + + targetDate.setHours(0, 0, 0, 0); + + const dayStart = new Date(targetDate); + dayStart.setHours(MIN_HOUR, 0, 0, 0); + + const dayEnd = new Date(targetDate); + dayEnd.setHours(MAX_HOUR, 0, 0, 0); + + const schedules = await prisma.schedules.findMany({ + where: { + room_id: roomId, + start_time: { + gte: dayStart, + lt: new Date(targetDate.getTime() + 24 * 60 * 60 * 1000), // Jour suivant + }, + }, + orderBy: { + start_time: 'asc', + }, + }); + + if (schedules.length === 0) { + return [ + { + startTime: dayStart, + endTime: dayEnd, + }, + ]; + } + + const availableSlots = []; + let currentTime = dayStart; + + for (const schedule of schedules) { + if (currentTime < schedule.start_time) { + availableSlots.push({ + startTime: currentTime, + endTime: schedule.start_time, + }); + } + currentTime = schedule.end_time; + } + + if (currentTime < dayEnd) { + availableSlots.push({ + startTime: currentTime, + endTime: dayEnd, + }); + } + + return availableSlots; +} + +/** + * Récupère les salles disponibles pour une plage horaire donnée + * @param startTime Heure de début + * @param endTime Heure de fin + * @returns Liste des salles disponibles + */ +export async function getAvailableRoomsForTimeSlot(startTime: Date, endTime: Date) { + // Vérifier que les horaires sont valides (entre 9h et 19h) + const startHour = startTime.getHours(); + const endHour = endTime.getHours(); + const endMinutes = endTime.getMinutes(); + + if (startHour < MIN_HOUR) { + throw new Error('Les talks doivent commencer après 9h'); + } + + if (endHour > MAX_HOUR || (endHour === MAX_HOUR && endMinutes > 0)) { + throw new Error('Les talks doivent se terminer avant 19h'); + } + + if (startTime >= endTime) { + throw new Error("L'heure de début doit être antérieure à l'heure de fin"); + } + + // Récupérer toutes les salles + const allRooms = await prisma.rooms.findMany(); + + // Récupérer les salles qui ont déjà un schedule dans cette plage horaire + const scheduledRooms = await prisma.schedules.findMany({ + where: { + OR: [ + { + start_time: { + gte: startTime, + lt: endTime, + }, + }, + { + end_time: { + gt: startTime, + lte: endTime, + }, + }, + { + AND: [{ start_time: { lte: startTime } }, { end_time: { gte: endTime } }], + }, + ], + }, + select: { + room_id: true, + }, + }); + + // Construire un ensemble des IDs de salles occupées + const occupiedRoomIds = new Set(scheduledRooms.map((schedule) => schedule.room_id)); + + // Filtrer les salles disponibles + const availableRooms = allRooms.filter((room) => !occupiedRoomIds.has(room.id)); + + return availableRooms; +} + +/** + * Valide un talk et l'attribue à une salle pour une plage horaire donnée + * @param talkId ID du talk à valider + * @param roomId ID de la salle + * @param startTime Heure de début + * @param endTime Heure de fin + * @param req Requête API + * @returns Le talk mis à jour + */ +export async function validateTalk( + talkId: number, + roomId: number, + startTime: Date, + endTime: Date, + req: NextApiRequest, +) { + const session = await getSession({ req }); + + if (!session?.user) { + throw new Error('Unauthorized: Authentication required'); + } + + const userRoleId = session.user.roleId; + + if (!isOrganizer(userRoleId)) { + throw new Error('Unauthorized: Only organizers can validate talks'); + } + + // Vérifier que le talk existe et qu'il est en attente + const talk = await prisma.talks.findUnique({ + where: { id: talkId }, + }); + + if (!talk) { + throw new Error('Talk not found'); + } + + if (talk.status !== 'pending') { + throw new Error('Only pending talks can be validated'); + } + + const availability = await checkRoomAvailability(roomId, startTime, endTime); + + if (!availability.available) { + throw new Error( + `Room is not available at the requested time slot. Conflicts with: ${availability.conflicts + .map((c) => c.talkTitle) + .join(', ')}`, + ); + } + + // Mettre à jour le talk et créer le schedule + return prisma.$transaction(async (tx) => { + // Mettre à jour le statut du talk + const updatedTalk = await tx.talks.update({ + where: { id: talkId }, + data: { + status: 'scheduled', + }, + include: { + users: { + select: { + id: true, + username: true, + email: true, + avatarUrl: true, + }, + }, + subjects: true, + }, + }); + + // Créer le schedule + const schedule = await tx.schedules.create({ + data: { + talk_id: talkId, + room_id: roomId, + start_time: startTime, + end_time: endTime, + }, + }); + + return { + ...updatedTalk, + schedule, + }; + }); +} + +/** + * Rejette un talk + * @param talkId ID du talk à rejeter + * @param req Requête API + * @returns Le talk mis à jour + */ +export async function rejectTalk(talkId: number, req: NextApiRequest) { + // Vérifier que l'utilisateur est un organisateur + const session = await getSession({ req }); + + if (!session?.user) { + throw new Error('Unauthorized: Authentication required'); + } + + const userRoleId = session.user.roleId; + + if (!isOrganizer(userRoleId)) { + throw new Error('Unauthorized: Only organizers can reject talks'); + } + + // Vérifier que le talk existe et qu'il est en attente + const talk = await prisma.talks.findUnique({ + where: { id: talkId }, + }); + + if (!talk) { + throw new Error('Talk not found'); + } + + if (talk.status !== 'pending') { + throw new Error('Only pending talks can be rejected'); + } + + // Mettre à jour le statut du talk + return prisma.talks.update({ + where: { id: talkId }, + data: { + status: 'rejected', + }, + include: { + users: { + select: { + id: true, + username: true, + email: true, + avatarUrl: true, + }, + }, + subjects: true, + }, + }); +} + +/** + * Accepte un talk + * @param talkId ID du talk à accepter + * @param req Requête API + * @returns Le talk mis à jour + */ +export async function acceptTalk(talkId: number, req: NextApiRequest) { + // Vérifier que l'utilisateur est un organisateur + const session = await getSession({ req }); + + if (!session?.user) { + throw new Error('Unauthorized: Authentication required'); + } + + const userRoleId = session.user.roleId; + + if (!isOrganizer(userRoleId)) { + throw new Error('Unauthorized: Only organizers can accept talks'); + } + + const talk = await prisma.talks.findUnique({ + where: { id: talkId }, + }); + + if (!talk) { + throw new Error('Talk not found'); + } + + if (talk.status !== 'pending') { + throw new Error('Only pending talks can be accepted'); + } + + return prisma.talks.update({ + where: { id: talkId }, + data: { + status: 'accepted', + }, + include: { + users: { + select: { + id: true, + username: true, + email: true, + avatarUrl: true, + }, + }, + subjects: true, + }, + }); +} diff --git a/app/services/talkService.ts b/app/services/talkService.ts new file mode 100644 index 0000000..39d50c0 --- /dev/null +++ b/app/services/talkService.ts @@ -0,0 +1,290 @@ +import { prisma } from '@/lib/prisma'; +import { isOrganizer } from '@/utils/auth.utils'; +import { Prisma } from '@prisma/client'; +import { NextApiRequest } from 'next'; +import { getSession } from 'next-auth/react'; + +// Définir les interfaces pour les données de schedule +interface ScheduleInput { + room_id: number; + start_time: string | Date; + end_time: string | Date; +} + +interface ScheduleData { + room_id: number; + start_time: Date; + end_time: Date; +} + +// Constantes pour les horaires +const MIN_HOUR = 9; // 9h +const MAX_HOUR = 19; // 19h + +/** + * Vérifie si l'utilisateur est authentifié et a les droits pour modifier/supprimer un talk + * @param req Requête API + * @param talkId ID du talk + * @returns Un objet contenant userId, isAuthorized et isOrganizer + */ +export async function checkTalkPermissions(req: NextApiRequest, talkId: number) { + // Récupérer la session de l'utilisateur + const session = await getSession({ req }); + + if (!session?.user) { + throw new Error('Unauthorized: Authentication required'); + } + + const userId = parseInt(session.user.id); + const userRoleId = session.user.roleId; + + // Vérifier si l'utilisateur est un organisateur + const userIsOrganizer = isOrganizer(userRoleId); + + // Si l'utilisateur n'est pas un organisateur, vérifier s'il est le propriétaire du talk + let isOwner = false; + + if (!userIsOrganizer) { + const talk = await prisma.talks.findUnique({ + where: { id: talkId }, + select: { speaker_id: true }, + }); + + if (!talk) { + throw new Error('Talk not found'); + } + + isOwner = talk.speaker_id === userId; + } + + const isAuthorized = userIsOrganizer || isOwner; + + if (!isAuthorized) { + throw new Error('Unauthorized: You do not have permission to modify this talk'); + } + + return { + userId, + isAuthorized, + isOrganizer: userIsOrganizer, + isOwner, + }; +} + +/** + * Vérifie si les horaires du talk sont valides (entre 9h et 19h) + * @param startTime Heure de début + * @param endTime Heure de fin + * @returns true si les horaires sont valides, false sinon + */ +export function validateTalkSchedule(startTime: Date, endTime: Date): boolean { + const startHour = startTime.getHours(); + const endHour = endTime.getHours(); + const endMinutes = endTime.getMinutes(); + + // Vérifier que l'heure de début est après 9h + if (startHour < MIN_HOUR) { + return false; + } + + // Vérifier que l'heure de fin est avant 19h + // Si l'heure est 19h, les minutes doivent être 0 + if (endHour > MAX_HOUR || (endHour === MAX_HOUR && endMinutes > 0)) { + return false; + } + + // Vérifier que l'heure de début est avant l'heure de fin + if (startTime >= endTime) { + return false; + } + + return true; +} + +/** + * Supprime un talk + * @param talkId ID du talk à supprimer + * @param req Requête API + * @returns Le talk supprimé + */ +export async function deleteTalk(talkId: number, req: NextApiRequest) { + // Vérifier les permissions + await checkTalkPermissions(req, talkId); + + // Supprimer le talk + return prisma.talks.delete({ + where: { id: talkId }, + }); +} + +/** + * Met à jour un talk + * @param talkId ID du talk à mettre à jour + * @param data Données à mettre à jour + * @param req Requête API + * @returns Le talk mis à jour + */ +// Utiliser un type d'intersection avec Prisma.talksUpdateInput pour garantir la compatibilité +type UpdateTalkData = { + schedules?: ScheduleInput | ScheduleInput[]; + subject_id?: number; // Ajout de subject_id pour la connexion avec la table subjects +} & Omit; + +export async function updateTalk(talkId: number, data: UpdateTalkData, req: NextApiRequest) { + // Vérifier les permissions + const { isOrganizer: userIsOrganizer } = await checkTalkPermissions(req, talkId); + + // Créer un objet de données de mise à jour compatible avec Prisma + const updateData: Prisma.talksUpdateInput = {}; + + // Copier les propriétés valides de data vers updateData + if (data.title) updateData.title = data.title; + if (data.description) updateData.description = data.description; + if (data.duration) updateData.duration = data.duration; + if (data.level) updateData.level = data.level; + if (data.subject_id) { + updateData.subjects = { + connect: { id: data.subject_id }, + }; + } + + // Si l'utilisateur est un organisateur, il peut modifier le statut + if (userIsOrganizer && data.status) { + updateData.status = data.status; + } + + // Vérifier si le talk a des plannings + const schedulesToCreate: ScheduleData[] = []; + + if (data.schedules) { + // Gérer le cas où schedules est un tableau (plusieurs jours/salles) + const schedulesArray: ScheduleInput[] = Array.isArray(data.schedules) + ? data.schedules + : [data.schedules]; + + // Vérifier chaque schedule + for (const schedule of schedulesArray) { + if (schedule && schedule.start_time && schedule.end_time) { + const startTime = new Date(schedule.start_time); + const endTime = new Date(schedule.end_time); + const roomId = schedule.room_id; + + if (!roomId) { + throw new Error('Room ID is required for scheduling a talk'); + } + + // Vérifier que les horaires sont valides + if (!validateTalkSchedule(startTime, endTime)) { + throw new Error('Invalid schedule: Talks must be scheduled between 9:00 and 19:00'); + } + + // Vérifier qu'il n'y a pas de conflit avec d'autres talks dans la même salle + const existingSchedules = await prisma.schedules.findMany({ + where: { + room_id: roomId, + talk_id: { not: talkId }, + OR: [ + { + // Commence pendant un autre talk + start_time: { + gte: startTime, + lt: endTime, + }, + }, + { + // Finit pendant un autre talk + end_time: { + gt: startTime, + lte: endTime, + }, + }, + { + // Englobe un autre talk + AND: [{ start_time: { lte: startTime } }, { end_time: { gte: endTime } }], + }, + ], + }, + }); + + if (existingSchedules.length > 0) { + throw new Error( + `Schedule conflict: Another talk is already scheduled in room ${roomId} from ${startTime.toLocaleString()} to ${endTime.toLocaleString()}`, + ); + } + + // Ajouter ce schedule à la liste à créer + schedulesToCreate.push({ + room_id: roomId, + start_time: startTime, + end_time: endTime, + }); + } + } + } + + // Utiliser une transaction pour mettre à jour le talk et gérer les schedules + return prisma.$transaction(async (tx) => { + // 1. Mettre à jour le talk + // const updatedTalk = await tx.talks.update({ + // where: { id: talkId }, + // data: updateData, + // include: { + // users: { + // select: { + // id: true, + // username: true, + // email: true, + // avatarUrl: true, + // }, + // }, + // subjects: true, + // }, + // }); + + // 2. Gérer les schedules + if (schedulesToCreate.length > 0) { + // Récupérer les schedules existants pour ce talk + const existingSchedules = await tx.schedules.findMany({ + where: { talk_id: talkId }, + }); + + // Si l'utilisateur a envoyé des schedules, on supprime tous les anciens et on crée les nouveaux + // Cette approche est plus simple que d'essayer de mettre à jour les existants + if (existingSchedules.length > 0) { + // Supprimer tous les schedules existants + await tx.schedules.deleteMany({ + where: { talk_id: talkId }, + }); + } + + // Créer tous les nouveaux schedules + for (const schedule of schedulesToCreate) { + await tx.schedules.create({ + data: { + talk_id: talkId, + room_id: schedule.room_id, + start_time: schedule.start_time, + end_time: schedule.end_time, + }, + }); + } + } + + // Récupérer le talk mis à jour avec son schedule + return tx.talks.findUnique({ + where: { id: talkId }, + include: { + schedules: true, + users: { + select: { + id: true, + username: true, + email: true, + avatarUrl: true, + }, + }, + subjects: true, + }, + }); + }); +} diff --git a/app/styles/globals.css b/app/styles/globals.css index 3a3e9a8..685b81a 100644 --- a/app/styles/globals.css +++ b/app/styles/globals.css @@ -1,5 +1,5 @@ -@import "tailwindcss"; -@import "tw-animate-css"; +@import 'tailwindcss'; +@import 'tw-animate-css'; @custom-variant dark (&:is(.dark *)); @@ -78,11 +78,13 @@ --radius-xl: calc(var(--radius) + 4px); } - body { background: var(--background); color: var(--foreground); font-family: Arial, Helvetica, sans-serif; + transition: + background 0.2s, + color 0.2s; } .dark { @@ -101,7 +103,7 @@ body { --accent: oklch(0.269 0 0); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); + --border: oklch(1 0 0 / 20%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.556 0 0); --chart-1: oklch(0.488 0.243 264.376); @@ -115,13 +117,17 @@ body { --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.269 0 0); --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-border: oklch(1 0 0 / 20%); --sidebar-ring: oklch(0.556 0 0); } @layer base { * { @apply border-border outline-ring/50; + transition: + background 0.2s, + color 0.2s, + border-color 0.2s; } body { @apply bg-background text-foreground; diff --git a/app/tsconfig.json b/app/tsconfig.json index 957e71f..25ea2f1 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -17,6 +17,6 @@ "@/*": ["./*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "prisma/seed.js"], "exclude": ["node_modules"] } diff --git a/app/utils/auth.utils.ts b/app/utils/auth.utils.ts index 9d3e881..99ee3c9 100644 --- a/app/utils/auth.utils.ts +++ b/app/utils/auth.utils.ts @@ -12,6 +12,11 @@ export function isAttendee(roleId: number | undefined): boolean { return roleId === ROLE_IDS.ATTENDEE; } +export function isConferencier(roleId: number | undefined): boolean { + // Un conférencier peut être un SPEAKER ou un ORGANIZER + return roleId === ROLE_IDS.SPEAKER || roleId === ROLE_IDS.ORGANIZER; +} + // Fonctions de conversion export function roleIdToRole(roleId: number): string { const role = roleIdMap.get(roleId); diff --git a/app/yarn.lock b/app/yarn.lock index 4a10bb9..5ff6cec 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -1609,6 +1609,18 @@ postcss "^8.4.41" tailwindcss "4.1.6" +"@tanstack/query-core@5.76.0": + version "5.76.0" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.76.0.tgz#3b4d5d34ce307ba0cf7d1a3e90d7adcdc6c46be0" + integrity sha512-FN375hb8ctzfNAlex5gHI6+WDXTNpe0nbxp/d2YJtnP+IBM6OUm7zcaoCW6T63BawGOYZBbKC0iPvr41TteNVg== + +"@tanstack/react-query@^5.76.1": + version "5.76.1" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.76.1.tgz#ac8a19f99dfec1452a44fe22d46680c396c21152" + integrity sha512-YxdLZVGN4QkT5YT1HKZQWiIlcgauIXEIsMOTSjvyD5wLYK8YVvKZUPAysMqossFJJfDpJW3pFn7WNZuPOqq+fw== + dependencies: + "@tanstack/query-core" "5.76.0" + "@testing-library/dom@^10.4.0": version "10.4.0" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-10.4.0.tgz#82a9d9462f11d240ecadbf406607c6ceeeff43a8" @@ -5631,6 +5643,62 @@ path-type@^4.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== +pg-cloudflare@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.2.5.tgz#2e3649c38a7a9c74a7e5327c8098a2fd9af595bd" + integrity sha512-OOX22Vt0vOSRrdoUPKJ8Wi2OpE/o/h9T8X1s4qSkCedbNah9ei2W2765be8iMVxQUsvgT7zIAT2eIa9fs5+vtg== + +pg-connection-string@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.9.0.tgz#f75e06591fdd42ec7636fe2c6a03febeedbec9bf" + integrity sha512-P2DEBKuvh5RClafLngkAuGe9OUlFV7ebu8w1kmaaOgPcpJd1RIFh7otETfI6hAR8YupOLFTY7nuvvIn7PLciUQ== + +pg-int8@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" + integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== + +pg-pool@^3.10.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.10.0.tgz#134b0213755c5e7135152976488aa7cd7ee1268d" + integrity sha512-DzZ26On4sQ0KmqnO34muPcmKbhrjmyiO4lCCR0VwEd7MjmiKf5NTg/6+apUEu0NF7ESa37CGzFxH513CoUmWnA== + +pg-protocol@^1.10.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.10.0.tgz#a473afcbb1c6e5dc3ac24869ba3dd563f8a1ae1b" + integrity sha512-IpdytjudNuLv8nhlHs/UrVBhU0e78J0oIS/0AVdTbWxSOkFUVdsHC/NrorO6nXsQNDTT1kzDSOMJubBQviX18Q== + +pg-types@2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/pg-types/-/pg-types-2.2.0.tgz#2d0250d636454f7cfa3b6ae0382fdfa8063254a3" + integrity sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA== + dependencies: + pg-int8 "1.0.1" + postgres-array "~2.0.0" + postgres-bytea "~1.0.0" + postgres-date "~1.0.4" + postgres-interval "^1.1.0" + +pg@^8.16.0: + version "8.16.0" + resolved "https://registry.yarnpkg.com/pg/-/pg-8.16.0.tgz#40b08eedb5eb1834252cf3e3629503e32e6c6c04" + integrity sha512-7SKfdvP8CTNXjMUzfcVTaI+TDzBEeaUnVwiVGZQD1Hh33Kpev7liQba9uLd4CfN8r9mCVsD0JIpq03+Unpz+kg== + dependencies: + pg-connection-string "^2.9.0" + pg-pool "^3.10.0" + pg-protocol "^1.10.0" + pg-types "2.2.0" + pgpass "1.0.5" + optionalDependencies: + pg-cloudflare "^1.2.5" + +pgpass@1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/pgpass/-/pgpass-1.0.5.tgz#9b873e4a564bb10fa7a7dbd55312728d422a223d" + integrity sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug== + dependencies: + split2 "^4.1.0" + picocolors@^1.0.0, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" @@ -5696,6 +5764,28 @@ postcss@^8.4.41: picocolors "^1.1.1" source-map-js "^1.2.1" +postgres-array@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-2.0.0.tgz#48f8fce054fbc69671999329b8834b772652d82e" + integrity sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA== + +postgres-bytea@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/postgres-bytea/-/postgres-bytea-1.0.0.tgz#027b533c0aa890e26d172d47cf9ccecc521acd35" + integrity sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w== + +postgres-date@~1.0.4: + version "1.0.7" + resolved "https://registry.yarnpkg.com/postgres-date/-/postgres-date-1.0.7.tgz#51bc086006005e5061c591cee727f2531bf641a8" + integrity sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q== + +postgres-interval@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/postgres-interval/-/postgres-interval-1.2.0.tgz#b460c82cb1587507788819a06aa0fffdb3544695" + integrity sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ== + dependencies: + xtend "^4.0.0" + preact-render-to-string@^5.1.19: version "5.2.6" resolved "https://registry.yarnpkg.com/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz#0ff0c86cd118d30affb825193f18e92bd59d0604" @@ -6292,6 +6382,11 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== +split2@^4.1.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4" + integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg== + sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" @@ -6994,6 +7089,11 @@ xmlchars@^2.2.0: resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== +xtend@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" + integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== + y18n@^5.0.5: version "5.0.8" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" @@ -7047,7 +7147,7 @@ zod-to-json-schema@^3.24.1: resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz#d1095440b147fb7c2093812a53c54df8d5df50a3" integrity sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g== -zod@^3.23.8, zod@^3.24.2: +zod@^3.23.8, zod@^3.24.2, zod@^3.24.4: version "3.24.4" resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.4.tgz#e2e2cca5faaa012d76e527d0d36622e0a90c315f" integrity sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg== diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..8ff69ea --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,526 @@ +# API Reference + +Cette documentation détaille les endpoints de l'API GoofyTrack, leurs paramètres, et les réponses attendues. + +## Base URL + +``` +https://goofy-track.vercel.app/api +``` + +Pour le développement local : +``` +http://localhost:3000/api +``` + +## Authentification + +Tous les endpoints protégés nécessitent un token JWT valide, fourni via le header `Authorization`. + +``` +Authorization: Bearer +``` + +Le token est obtenu via le endpoint d'authentification NextAuth.js. + +## Endpoints + +### Authentification + +#### `POST /auth/[...nextauth]` + +Endpoint NextAuth.js pour la gestion de l'authentification. + + + + + + + + + +### Talks + +#### `GET /talks` + +Récupère la liste des talks. + +**Paramètres de requête** + +| Nom | Type | Description | +|-----|------|-------------| +| status | string | Filtre par statut (pending, accepted, rejected, scheduled) | +| subject_id | number | Filtre par sujet | +| speaker_id | number | Filtre par conférencier | +| level | string | Filtre par niveau (beginner, intermediate, advanced, expert) | + +**Réponse** + +```json +[ + { + "id": 1, + "title": "Introduction à Next.js", + "description": "Une présentation des bases de Next.js", + "speaker_id": 1, + "subject_id": 3, + "duration": 45, + "level": "beginner", + "status": "accepted", + "created_at": "2025-05-01T10:00:00Z", + "updated_at": "2025-05-01T10:00:00Z" + } +] +``` + +#### `GET /talks/:id` + +Récupère les détails d'un talk spécifique. + +**Paramètres** + +| Nom | Type | Description | +|-----|------|-------------| +| id | number | ID du talk | + +**Réponse** + +```json +{ + "id": 1, + "title": "Introduction à Next.js", + "description": "Une présentation des bases de Next.js", + "speaker_id": 1, + "subject_id": 3, + "duration": 45, + "level": "beginner", + "status": "accepted", + "created_at": "2025-05-01T10:00:00Z", + "updated_at": "2025-05-01T10:00:00Z", + "speaker": { + "id": 1, + "username": "johndoe", + "email": "john@example.com", + "avatarUrl": "https://example.com/avatar.jpg" + }, + "subject": { + "id": 3, + "name": "Next.js" + } +} +``` + +#### `POST /talks` + +Crée un nouveau talk (réservé aux conférenciers). + +**Corps de la requête** + +```json +{ + "title": "Introduction à Next.js", + "description": "Une présentation des bases de Next.js", + "subject_id": 3, + "duration": 45, + "level": "beginner" +} +``` + +**Réponse** + +```json +{ + "id": 1, + "title": "Introduction à Next.js", + "description": "Une présentation des bases de Next.js", + "speaker_id": 1, + "subject_id": 3, + "duration": 45, + "level": "beginner", + "status": "pending", + "created_at": "2025-05-01T10:00:00Z", + "updated_at": "2025-05-01T10:00:00Z" +} +``` + +#### `PUT /talks/:id` + +Met à jour un talk existant (réservé au conférencier propriétaire ou aux organisateurs). + +**Paramètres** + +| Nom | Type | Description | +|-----|------|-------------| +| id | number | ID du talk | + +**Corps de la requête** + +```json +{ + "title": "Introduction à Next.js et React", + "description": "Une présentation des bases de Next.js et React", + "subject_id": 3, + "duration": 60, + "level": "intermediate" +} +``` + +**Réponse** + +```json +{ + "id": 1, + "title": "Introduction à Next.js et React", + "description": "Une présentation des bases de Next.js et React", + "speaker_id": 1, + "subject_id": 3, + "duration": 60, + "level": "intermediate", + "status": "pending", + "created_at": "2025-05-01T10:00:00Z", + "updated_at": "2025-05-01T10:30:00Z" +} +``` + +#### `DELETE /talks/:id` + +Supprime un talk (réservé au conférencier propriétaire ou aux organisateurs). + +**Paramètres** + +| Nom | Type | Description | +|-----|------|-------------| +| id | number | ID du talk | + +**Réponse** + +``` +Status: 204 No Content +``` + +### Planning + +#### `GET /schedules` + +Récupère le planning des talks. + +**Paramètres de requête** + +| Nom | Type | Description | +|-----|------|-------------| +| room_id | number | Filtre par salle | +| date | string | Filtre par date (YYYY-MM-DD) | + +**Réponse** + +```json +[ + { + "id": 1, + "talk_id": 1, + "room_id": 2, + "start_time": "2025-06-01T10:00:00Z", + "end_time": "2025-06-01T10:45:00Z", + "created_at": "2025-05-01T10:00:00Z", + "updated_at": "2025-05-01T10:00:00Z", + "talk": { + "id": 1, + "title": "Introduction à Next.js", + "speaker_id": 1, + "level": "beginner" + }, + "room": { + "id": 2, + "name": "Salle Conférences A", + "capacity": 150 + } + } +] +``` + +#### `POST /schedules` + +Planifie un talk (réservé aux organisateurs). + +**Corps de la requête** + +```json +{ + "talk_id": 1, + "room_id": 2, + "start_time": "2025-06-01T10:00:00Z", + "end_time": "2025-06-01T10:45:00Z" +} +``` + +**Réponse** + +```json +{ + "id": 1, + "talk_id": 1, + "room_id": 2, + "start_time": "2025-06-01T10:00:00Z", + "end_time": "2025-06-01T10:45:00Z", + "created_at": "2025-05-01T10:00:00Z", + "updated_at": "2025-05-01T10:00:00Z" +} +``` + +### Favoris + +#### `GET /favorites` + +Récupère les favoris de l'utilisateur connecté. + +**Réponse** + +```json +[ + { + "id": 1, + "user_id": 1, + "talk_id": 1, + "created_at": "2025-05-01T10:00:00Z", + "talk": { + "id": 1, + "title": "Introduction à Next.js", + "speaker_id": 1, + "level": "beginner" + } + } +] +``` + +#### `POST /favorites` + +Ajoute un talk aux favoris. + +**Corps de la requête** + +```json +{ + "talk_id": 1 +} +``` + +**Réponse** + +```json +{ + "id": 1, + "user_id": 1, + "talk_id": 1, + "created_at": "2025-05-01T10:00:00Z" +} +``` + +#### `DELETE /favorites/:id` + +Supprime un talk des favoris. + +**Paramètres** + +| Nom | Type | Description | +|-----|------|-------------| +| id | number | ID du favori | + +**Réponse** + +``` +Status: 204 No Content +``` + +### Feedback + +#### `POST /feedback` + +Ajoute un feedback pour un talk. + +**Corps de la requête** + +```json +{ + "talk_id": 1, + "rating": true, + "comment": "Excellent talk, très instructif !" +} +``` + +**Réponse** + +```json +{ + "id": 1, + "user_id": 1, + "talk_id": 1, + "rating": true, + "comment": "Excellent talk, très instructif !", + "created_at": "2025-05-01T10:00:00Z" +} +``` + +### Statistiques + +#### `GET /stats/talks` + +Récupère des statistiques sur les talks (réservé aux organisateurs). + +**Réponse** + +```json +{ + "total": 50, + "by_status": { + "pending": 10, + "accepted": 30, + "rejected": 5, + "scheduled": 25 + }, + "by_subject": { + "JavaScript": 10, + "TypeScript": 8, + "React": 12, + "Next.js": 15, + "Node.js": 5 + }, + "by_level": { + "beginner": 15, + "intermediate": 20, + "advanced": 10, + "expert": 5 + } +} +``` + +#### `GET /stats/rooms` + +Récupère des statistiques sur l'occupation des salles (réservé aux organisateurs). + +**Réponse** + +```json +[ + { + "room_id": 1, + "room_name": "Salle Amphithéâtre", + "capacity": 300, + "talks_count": 10, + "occupation_rate": 0.8, + "total_duration": 450 + } +] +``` + +## Codes d'erreur + +| Code | Description | +|------|-------------| +| 400 | Bad Request - La requête est mal formée | +| 401 | Unauthorized - Authentification requise | +| 403 | Forbidden - Permissions insuffisantes | +| 404 | Not Found - Ressource non trouvée | +| 409 | Conflict - Conflit avec l'état actuel | +| 422 | Unprocessable Entity - Validation échouée | +| 500 | Internal Server Error - Erreur serveur | + +## Pagination + +Pour les endpoints qui retournent de nombreux résultats, la pagination est supportée via les paramètres de requête suivants : + +| Nom | Type | Description | +|-----|------|-------------| +| page | number | Numéro de page (commence à 1) | +| limit | number | Nombre d'éléments par page (défaut: 10, max: 50) | + +**Exemple** + +``` +GET /api/talks?page=2&limit=20 +``` + +**Réponse** + +```json +{ + "data": [...], + "pagination": { + "total": 100, + "page": 2, + "limit": 20, + "pages": 5 + } +} +``` diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..18e9454 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,341 @@ +# Guide de Contribution + +Merci de votre intérêt pour contribuer au projet GoofyTrack ! Ce document vous guidera à travers le processus de contribution. + +## Prérequis + +- Node.js v18 ou supérieur +- Docker et Docker Compose +- Git +- Un éditeur de code (VS Code recommandé) + +## Installation de l'environnement de développement + +1. **Cloner le dépôt** + +```bash +git clone https://github.com/GoofyTeam/GoofyTrack.git +cd GoofyTrack +``` + +2. **Installer les dépendances** + +```bash +npm install +# ou +yarn install +``` + +3. **Configurer les variables d'environnement** + +```bash +cp .env.dist .env +# Modifier les variables dans .env selon vos besoins +``` + +4. **Démarrer les services Docker** + +```bash +docker-compose up -d +``` + +5. **Exécuter les migrations** + +```bash +npx prisma migrate dev +``` + +6. **Seed la base de données** + +```bash +npx prisma db seed +``` + +7. **Démarrer l'application en mode développement** + +```bash +npm run dev +# ou +yarn dev +``` + +L'application sera disponible à l'adresse http://localhost:3000. + +## Workflow Git + +Nous utilisons un workflow Git inspiré de GitFlow : + +### Branches principales + +- `main` : Code en production +- `develop` : Code de développement stable + +### Branches de fonctionnalités + +Pour développer une nouvelle fonctionnalité : + +1. Créer une branche à partir de `develop` : + +```bash +git checkout develop +git pull +git checkout -b feature/nom-de-la-fonctionnalite +``` + +2. Développer la fonctionnalité dans cette branche +3. Pousser la branche vers le dépôt distant : + +```bash +git push -u origin feature/nom-de-la-fonctionnalite +``` + +4. Créer une Pull Request vers la branche `develop` + +### Branches de correction + +Pour corriger un bug en production : + +1. Créer une branche à partir de `main` : + +```bash +git checkout main +git pull +git checkout -b hotfix/nom-du-correctif +``` + +2. Développer le correctif +3. Pousser la branche vers le dépôt distant : + +```bash +git push -u origin hotfix/nom-du-correctif +``` + +4. Créer une Pull Request vers la branche `main` et une autre vers `develop` + +## Convention de Commits + +Nous utilisons la convention de commits suivante : + +``` +(): + +[corps] + +[pied de page] +``` + +### Types de commits + +- `feat` : Nouvelle fonctionnalité +- `fix` : Correction de bug +- `docs` : Modification de la documentation +- `style` : Changements de formatage (espaces, indentation, etc.) +- `refactor` : Refactorisation du code +- `test` : Ajout ou modification de tests +- `chore` : Tâches de maintenance + +### Exemples + +``` +feat(auth): ajouter l'authentification Google + +Ajouter la possibilité de se connecter avec un compte Google. + +Closes #123 +``` + +``` +fix(planning): corriger le chevauchement des talks + +Résoudre le problème de chevauchement des talks dans le planning. + +Fixes #456 +``` + +## Pull Requests + +### Création d'une Pull Request + +1. Assurez-vous que votre code est bien formaté et que les tests passent +2. Créez une Pull Request sur GitHub +3. Remplissez le template de PR avec : + - Une description claire de ce que fait la PR + - Les issues liées + - Les captures d'écran (si applicable) + - Les étapes de test + +### Revue de Code + +Chaque PR doit être revue par au moins un autre membre de l'équipe avant d'être fusionnée. + +### Critères d'acceptation + +- Les tests automatisés passent +- Le code respecte les standards de qualité +- La PR a été revue et approuvée +- La fonctionnalité répond aux exigences + +## Standards de Code + +### Linting et Formatage + +Nous utilisons ESLint et Prettier pour maintenir la qualité du code. Configurez votre éditeur pour formater le code automatiquement à la sauvegarde. + +Pour vérifier le formatage : + +```bash +npm run lint +# ou +yarn lint +``` + +Pour corriger automatiquement les problèmes de formatage : + +```bash +npm run lint:fix +# ou +yarn lint:fix +``` + +### Tests + +Nous utilisons Jest et React Testing Library pour les tests. Chaque nouvelle fonctionnalité doit être accompagnée de tests. + +Pour exécuter les tests : + +```bash +npm run test +# ou +yarn test +``` + +Pour exécuter les tests avec couverture : + +```bash +npm run test:coverage +# ou +yarn test:coverage +``` + +### TypeScript + +Tout le code doit être écrit en TypeScript avec un typage approprié. Évitez d'utiliser `any` autant que possible. + +## Structure du Projet + +``` +/app + /api # API Routes + /components # Composants React partagés + /features # Fonctionnalités organisées par domaine + /auth + /talks + /schedule + /favorites + /hooks # Hooks React personnalisés + /lib # Utilitaires et services + /prisma # Schéma et migrations Prisma + /public # Fichiers statiques + /styles # Styles globaux +``` + +### Directives pour les composants + +- Utilisez des composants fonctionnels avec des hooks +- Utilisez TypeScript pour typer les props +- Suivez le principe de responsabilité unique +- Utilisez des tests pour les composants + +### Directives pour l'API + +- Utilisez les API Routes de Next.js +- Validez les entrées avec Zod +- Gérez correctement les erreurs +- Documentez les endpoints + +## Base de Données + +### Modifications du Schéma + +Pour modifier le schéma de la base de données : + +1. Modifiez le fichier `prisma/schema.prisma` +2. Générez une migration : + +```bash +npx prisma migrate dev --name nom-de-la-migration +``` + +3. Vérifiez que la migration fonctionne correctement +4. Mettez à jour les seeds si nécessaire + +### Requêtes à la Base de Données + +Utilisez le client Prisma pour interagir avec la base de données. Évitez les requêtes SQL brutes sauf si nécessaire. + +## Documentation + +### Documentation du Code + +Documentez votre code avec des commentaires JSDoc : + +```typescript +/** + * Récupère un talk par son ID + * @param id - L'ID du talk à récupérer + * @returns Le talk correspondant ou null s'il n'existe pas + */ +async function getTalkById(id: number): Promise { + // ... +} +``` + +### Documentation de l'API + +Documentez les endpoints API dans le fichier `docs/api-reference.md`. + +### Mise à Jour de la Documentation + +Lorsque vous ajoutez ou modifiez une fonctionnalité, assurez-vous de mettre à jour la documentation correspondante. + +## Déploiement + +Le déploiement est géré automatiquement par notre pipeline CI/CD. Consultez le [Guide de Déploiement](./deployment-guide.md) pour plus d'informations. + +## Signalement de Bugs + +Si vous trouvez un bug : + +1. Vérifiez si le bug a déjà été signalé dans les issues GitHub +2. Si ce n'est pas le cas, créez une nouvelle issue avec : + - Une description claire du bug + - Les étapes pour reproduire + - Le comportement attendu + - Le comportement observé + - Des captures d'écran si applicable + - Des informations sur l'environnement + +## Proposer des Améliorations + +Pour proposer une amélioration : + +1. Créez une issue GitHub décrivant votre proposition +2. Discutez de la proposition avec l'équipe +3. Si la proposition est acceptée, suivez le workflow standard pour implémenter la fonctionnalité + +## Communication + +- **GitHub Issues** : Pour le suivi des bugs et des fonctionnalités +- **Pull Requests** : Pour les revues de code +- **Discord** : Pour la communication en temps réel (demandez le lien d'invitation) +- **Daily Meetings** : Pour la synchronisation quotidienne de l'équipe + +## Ressources Utiles + +- [Documentation Next.js](https://nextjs.org/docs) +- [Documentation Prisma](https://www.prisma.io/docs) +- [Documentation NextAuth.js](https://next-auth.js.org/getting-started/introduction) +- [Documentation Tailwind CSS](https://tailwindcss.com/docs) +- [Documentation TypeScript](https://www.typescriptlang.org/docs) + +Merci de contribuer à GoofyTrack ! 🚀 diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md new file mode 100644 index 0000000..b9367a1 --- /dev/null +++ b/docs/deployment-guide.md @@ -0,0 +1,308 @@ +# Guide de Déploiement + +Ce document détaille les étapes nécessaires pour déployer l'application GoofyTrack dans différents environnements. + +## Prérequis + +- Compte GitHub +- Compte Vercel +- Compte Neon Tech (pour la base de données PostgreSQL) +- Node.js v18 ou supérieur +- Docker et Docker Compose (pour le développement local) + +## Environnements de Déploiement + +GoofyTrack utilise trois environnements de déploiement : + +1. **Développement** : Environnement local pour le développement +2. **Staging** : Environnement de préproduction pour les tests +3. **Production** : Environnement de production pour les utilisateurs finaux + +## Configuration des Variables d'Environnement + +### Variables Requises + +``` +# Base de données +DATABASE_URL="postgresql://user:password@host:port/database" + +# NextAuth.js +NEXTAUTH_URL="https://your-domain.com" +NEXTAUTH_SECRET="your-secret-key" + +# Providers d'authentification (optionnel) +GITHUB_ID="your-github-client-id" +GITHUB_SECRET="your-github-client-secret" +GOOGLE_ID="your-google-client-id" +GOOGLE_SECRET="your-google-client-secret" + +# Email (optionnel) +EMAIL_SERVER_HOST="smtp.example.com" +EMAIL_SERVER_PORT=587 +EMAIL_SERVER_USER="user@example.com" +EMAIL_SERVER_PASSWORD="password" +EMAIL_FROM="noreply@example.com" +``` + +## Déploiement Local (Développement) + +### 1. Cloner le Dépôt + +```bash +git clone https://github.com/GoofyTeam/GoofyTrack.git +cd GoofyTrack +``` + +### 2. Configurer les Variables d'Environnement + +```bash +cp .env.dist .env +# Modifier les variables dans .env selon vos besoins +``` + +### 3. Démarrer les Services Docker + +```bash +docker-compose up -d +``` + +### 4. Installer les Dépendances + +```bash +npm install +# ou +yarn install +``` + +### 5. Exécuter les Migrations + +```bash +npx prisma migrate dev +``` + +### 6. Seed la Base de Données + +```bash +npx prisma db seed +``` + +### 7. Démarrer l'Application + +```bash +npm run dev +# ou +yarn dev +``` + +L'application sera disponible à l'adresse http://localhost:3000. + +## Déploiement sur Vercel (Staging et Production) + +### 1. Connecter le Dépôt GitHub à Vercel + +1. Créez un compte sur [Vercel](https://vercel.com) si vous n'en avez pas déjà un. +2. Cliquez sur "New Project" dans le dashboard Vercel. +3. Importez le dépôt GitHub de GoofyTrack. +4. Configurez le projet : + - Framework Preset : Next.js + - Root Directory : ./ + - Build Command : `yarn build` (ou laissez la valeur par défaut) + - Output Directory : .next (ou laissez la valeur par défaut) + +### 2. Configurer les Variables d'Environnement sur Vercel + +1. Dans les paramètres du projet, allez dans l'onglet "Environment Variables". +2. Ajoutez toutes les variables d'environnement nécessaires (voir la liste ci-dessus). +3. Assurez-vous de configurer différentes valeurs pour les environnements de staging et de production. + +### 3. Configurer la Base de Données PostgreSQL sur Neon Tech + +1. Créez un compte sur [Neon Tech](https://neon.tech) si vous n'en avez pas déjà un. +2. Créez un nouveau projet. +3. Créez une branche principale pour la production et une branche de développement pour le staging. +4. Récupérez les chaînes de connexion pour chaque branche. +5. Ajoutez ces chaînes de connexion comme variables d'environnement `DATABASE_URL` dans Vercel pour les environnements correspondants. + +### 4. Configurer les Domaines Personnalisés (Optionnel) + +1. Dans les paramètres du projet Vercel, allez dans l'onglet "Domains". +2. Ajoutez vos domaines personnalisés pour les environnements de staging et de production. +3. Suivez les instructions pour configurer les enregistrements DNS. + +### 5. Exécuter les Migrations en Production + +```bash +# Assurez-vous d'avoir configuré la variable DATABASE_URL pour pointer vers votre base de données de production +npx prisma migrate deploy +``` + +### 6. Seed la Base de Données en Production (Si Nécessaire) + +```bash +# Assurez-vous d'avoir configuré la variable DATABASE_URL pour pointer vers votre base de données de production +npx prisma db seed +``` + +## Configuration CI/CD avec GitHub Actions + +GoofyTrack utilise GitHub Actions pour l'intégration continue et le déploiement continu. + +### Workflow de CI/CD + +Le fichier de workflow `.github/workflows/ci.yml` configure les actions suivantes : + +1. **Lint et Tests** : Exécutés à chaque push et pull request. +2. **Preview Deployment** : Déploiement de prévisualisation pour chaque pull request. +3. **Staging Deployment** : Déploiement automatique sur l'environnement de staging pour chaque merge dans la branche `develop`. +4. **Production Deployment** : Déploiement automatique sur l'environnement de production pour chaque merge dans la branche `main`. + +### Configuration du Workflow + +```yaml +name: CI/CD Pipeline + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + lint-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Use Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + cache: 'yarn' + - name: Install dependencies + run: yarn install --frozen-lockfile + - name: Lint + run: yarn lint + - name: Test + run: yarn test + + preview-deploy: + needs: lint-and-test + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Deploy to Vercel + uses: amondnet/vercel-action@v20 + with: + vercel-token: ${{ secrets.VERCEL_TOKEN }} + github-token: ${{ secrets.GITHUB_TOKEN }} + vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} + vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} + + staging-deploy: + needs: lint-and-test + if: github.event_name == 'push' && github.ref == 'refs/heads/develop' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Deploy to Vercel (Staging) + uses: amondnet/vercel-action@v20 + with: + vercel-token: ${{ secrets.VERCEL_TOKEN }} + github-token: ${{ secrets.GITHUB_TOKEN }} + vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} + vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} + vercel-args: '--prod' + alias-domains: | + staging.goofy-track.vercel.app + + production-deploy: + needs: lint-and-test + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Deploy to Vercel (Production) + uses: amondnet/vercel-action@v20 + with: + vercel-token: ${{ secrets.VERCEL_TOKEN }} + github-token: ${{ secrets.GITHUB_TOKEN }} + vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} + vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} + vercel-args: '--prod' + alias-domains: | + goofy-track.vercel.app +``` + +### Configuration des Secrets GitHub + +Pour que le workflow CI/CD fonctionne, vous devez configurer les secrets suivants dans votre dépôt GitHub : + +1. `VERCEL_TOKEN` : Token d'API Vercel +2. `VERCEL_ORG_ID` : ID de l'organisation Vercel +3. `VERCEL_PROJECT_ID` : ID du projet Vercel + +## Mise à Jour de la Base de Données + +### Créer une Nouvelle Migration + +Lorsque vous modifiez le schéma Prisma, vous devez créer une nouvelle migration : + +```bash +npx prisma migrate dev --name nom-de-la-migration +``` + +### Appliquer les Migrations en Production + +```bash +npx prisma migrate deploy +``` + +## Rollback + +### Rollback d'un Déploiement sur Vercel + +1. Accédez au dashboard du projet sur Vercel. +2. Allez dans l'onglet "Deployments". +3. Trouvez le déploiement précédent que vous souhaitez restaurer. +4. Cliquez sur les trois points à côté du déploiement et sélectionnez "Promote to Production". + +### Rollback d'une Migration de Base de Données + +Prisma ne prend pas en charge nativement le rollback des migrations. En cas de besoin : + +1. Créez une nouvelle migration qui annule les changements de la migration problématique. +2. Appliquez cette nouvelle migration. + +## Surveillance et Logging + +### Surveillance des Performances + +GoofyTrack utilise Vercel Analytics pour surveiller les performances de l'application. + +### Logging + +Les logs de l'application sont disponibles dans le dashboard Vercel. + +## Sauvegarde et Restauration + +### Sauvegarde de la Base de Données + +Neon Tech effectue des sauvegardes automatiques de la base de données. Pour une sauvegarde manuelle : + +```bash +pg_dump -h -U -d > backup.sql +``` + +### Restauration de la Base de Données + +```bash +psql -h -U -d < backup.sql +``` + +## Bonnes Pratiques + +1. **Toujours tester les migrations** dans un environnement de développement avant de les appliquer en production. +2. **Utiliser des branches** pour chaque nouvelle fonctionnalité ou correction. +3. **Vérifier les variables d'environnement** avant chaque déploiement. +4. **Surveiller les logs** après un déploiement pour détecter d'éventuels problèmes. +5. **Effectuer des sauvegardes régulières** de la base de données de production. diff --git a/docs/index.md b/docs/index.md index 114a07e..69cb484 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,597 +1,39 @@ -## **1. Introduction et Contexte** +# Documentation GoofyTrack -GoofyTrack est une application web développée pour gérer un événement technique réunissant des centaines de participants et de conférenciers (type Devoxx France). L'objectif est de proposer un outil simple, fluide et ergonomique permettant aux différents acteurs de l'événement d'interagir efficacement. +Bienvenue dans la documentation technique du projet GoofyTrack, une application de gestion d'événements techniques. -**1.1 Objectifs du Projet** +## Table des matières -L'application vise à répondre aux besoins de trois types d'utilisateurs : +1. [Architecture](./architecture-diagram.md) - Vue d'ensemble de l'architecture technique +2. [Modèle de données](./er-diagram.md) - Diagramme entité-relation et structure de la base de données +3. [Flux utilisateur](./user-flow-diagram.md) - Parcours utilisateur selon les rôles +4. [Diagramme de séquence](./sequence-diagram.md) - Interactions entre composants +5. [API Reference](./api-reference.md) - Documentation de l'API +6. [Décisions techniques](./technical-decisions.md) - Justifications des choix techniques +7. [Guide de déploiement](./deployment-guide.md) - Instructions pour le déploiement +8. [Guide de contribution](./contributing.md) - Comment contribuer au projet -- **Conférenciers** : Proposer, modifier ou supprimer des talks -- **Organisateurs** : Planifier les interventions et organiser le programme -- **Public** : Consulter un planning clair et interactif, filtrer les contenus, et gérer ses favoris +## À propos de GoofyTrack -**1.2 Fonctionnalités Principales** +GoofyTrack est une application conçue pour gérer un événement technique réunissant des centaines de participants et de conférenciers (type Devoxx France). L'objectif est de proposer un outil simple, fluide et ergonomique pour : -Le MVP (Minimum Viable Product) de l'application comprend : +- **Conférenciers** : proposer, modifier ou supprimer des talks +- **Organisateurs** : planifier les interventions et organiser le programme +- **Public** : consulter un planning clair et interactif, filtrer les contenus, et gérer ses favoris -- Gestion des talks (création, modification, suppression) -- Gestion des statuts de talks (en attente, accepté, refusé, planifié) -- Planning avec créneaux entre 9h et 19h, répartis sur 5 salles -- Système d'authentification avec différents rôles -- Interface responsive (web et mobile) -- Vue privée pour la gestion et la soumission -- Vue publique pour la consultation du planning +## Technologies utilisées -## **2. Modèle de Données** +- **Frontend** : Next.js 15.3.2 avec TypeScript +- **Backend** : API Routes Next.js +- **ORM** : Prisma 6.7.0 +- **Base de données** : PostgreSQL +- **Authentification** : NextAuth.js +- **UI** : Tailwind CSS avec composants Radix UI +- **Déploiement** : Vercel +- **CI/CD** : GitHub Actions -```mermaid -graph TD - Mermaid --> Diagram -``` +## Liens utiles -**2.1 Schéma Relationnel** - -Notre modèle de données est structuré autour des entités principales suivantes : - -**Users** - -- Stocke les informations des utilisateurs (conférenciers, organisateurs, public) -- Attributs : id, username, email, password, role_id, avatarUrl, bio -- Relations : Lié aux rôles, talks, favoris et feedback - -**Roles** - -- Définit les différents rôles des utilisateurs -- Attributs : id, name -- Valeurs : admin, organizer, speaker, attendee - -**Talks** - -- Contient les informations sur les présentations -- Attributs : id, title, description, speaker_id, subject_id, duration, level, status -- Relations : Lié aux utilisateurs (speakers), sujets, favoris, feedback et planning - -**Subjects** - -- Catégorisation des talks par thématique -- Attributs : id, name -- Exemples : JavaScript, TypeScript, React, Next.js, etc. - -**Rooms** - -- Informations sur les salles disponibles -- Attributs : id, name, capacity, description - -**Schedules** - -- Planification des talks dans les salles -- Attributs : id, talk_id, room_id, start_time, end_time -- Contraintes : Pas de chevauchement de talks dans une même salle - -**Favorites** - -- Gestion des talks favoris des utilisateurs -- Attributs : id, user_id, talk_id - -**Feedback** - -- Retours des utilisateurs sur les talks -- Attributs : id, user_id, talk_id, rating, comment - -**2.2 Diagramme Entité-Relation** - -`CopyInsertUser (1) --- (*) Talks (Speaker) -User (1) --- (*) Favorites -User (1) --- (*) Feedback -Talks (1) --- (0..1) Schedules -Rooms (1) --- (*) Schedules -Subjects (1) --- (*) Talks -Roles (1) --- (*) Users` - -## **3. Architecture Technique** - -**3.1 Stack Technologique** - -GoofyTrack est développé avec les technologies suivantes : - -**Frontend** - -- **Framework** : Next.js 15.3.2 -- **Langage** : TypeScript -- **Gestion d'état** : Context API / Hooks -- **UI Framework** : Tailwind CSS avec composants Radix UI -- **Authentification** : NextAuth.js - -**Backend** - -- **API** : API Routes de Next.js -- **ORM** : Prisma 6.7.0 -- **Base de données** : PostgreSQL -- **Authentification** : JWT via NextAuth.js - -**Infrastructure** - -- **Déploiement** : Vercel -- **Base de données** : Neon Tech (PostgreSQL) -- **Environnement de développement** : Docker (PostgreSQL, pgAdmin, Mailhog) -- **CI/CD** : GitHub Actions - -**3.2 Architecture Applicative** - -L'application suit une architecture en couches : - -1. **Couche Présentation** : Composants React/Next.js -2. **Couche Logique** : API Routes Next.js et services -3. **Couche Accès aux Données** : Prisma ORM -4. **Couche Persistance** : PostgreSQL - -5. 1. **Couche Présentation** : Composants React/Next.js -6. 2. **Couche Logique** : API Routes Next.js et services -7. 3. **Couche Accès aux Données** : Prisma ORM -8. 4. **Couche Persistance** : PostgreSQL - -**3.3 Sécurité** - -- Authentification basée sur NextAuth.js avec JWT -- Hachage des mots de passe avec bcrypt -- Contrôle d'accès basé sur les rôles -- Protection CSRF intégrée à Next.js -- Variables d'environnement sécurisées - -## **4. Cas d'Usage** - -**4.1 Parcours Conférencier** - -1. **Inscription/Connexion** - ◦ Le conférencier s'inscrit ou se connecte à l'application - ◦ Le système lui attribue le rôle "speaker" -2. **Proposition de Talk** - ◦ Le conférencier remplit un formulaire avec les détails du talk - ◦ Il sélectionne un sujet, définit la durée et le niveau - ◦ Le talk est enregistré avec le statut "en attente" -3. **Gestion des Propositions** - ◦ Le conférencier peut consulter l'état de ses propositions - ◦ Il peut modifier ou supprimer ses talks non planifiés - -**4.2 Parcours Organisateur** - -1. **Validation des Talks** - ◦ L'organisateur consulte la liste des talks en attente - ◦ Il peut accepter ou refuser chaque proposition -2. **Planification** - ◦ Pour les talks acceptés, l'organisateur attribue une salle et un créneau - ◦ Le système vérifie qu'il n'y a pas de conflit (même salle, même horaire) - ◦ Le statut du talk passe à "planifié" -3. **Gestion du Programme** - ◦ L'organisateur peut visualiser et modifier le planning global - ◦ Il peut réorganiser les talks si nécessaire - -**4.3 Parcours Public** - -1. **Consultation du Planning** - ◦ L'utilisateur consulte le programme des talks planifiés - ◦ Il peut filtrer par jour, salle, sujet ou niveau -2. **Gestion des Favoris** - ◦ L'utilisateur peut ajouter des talks à ses favoris - ◦ Il peut consulter sa liste personnalisée de favoris -3. **Feedback** - ◦ Après un talk, l'utilisateur peut laisser une évaluation et un commentaire - -## **5. Justifications Techniques et Évolutions** - -**5.1 Choix Techniques** - -**Next.js + TypeScript** - -- **Justification** : Framework React moderne avec rendu côté serveur et génération statique -- **Avantages** : Performance, SEO, typage fort, routing intégré -- **Alternative considérée** : React + Express (rejeté car Next.js offre une solution plus intégrée) - -**Prisma ORM** - -- **Justification** : ORM moderne avec typage fort et migrations intégrées -- **Avantages** : Type-safety, auto-complétion, modèle déclaratif -- **Alternative considérée** : Sequelize (rejeté car moins bien intégré avec TypeScript) - -**PostgreSQL** - -- **Justification** : Base de données relationnelle robuste et performante bdd gratuite avec NEon Tech sinon on partirait sur du mysql car postgresql est plus axé écriture et mysql lecture -- **Avantages** : Fiabilité, support des transactions, contraintes d'intégrité -- **Alternative considérée** : MongoDB (rejeté car le modèle de données est fortement relationnel) - -**NextAuth.js** - -- **Justification** : Solution d'authentification intégrée à Next.js -- **Avantages** : Support de multiples providers, gestion des sessions, JWT -- **Alternative considérée** : Auth0, Clerk (rejeté pour garder le contrôle sur les données utilisateurs) - -**5.2 Évolutions Futures** - -**Court terme** - -- Implémentation du système de favoris -- Ajout de filtres avancés (niveau, conférencier) -- Thème clair/sombre - -**Moyen terme** - -- Génération automatique de planning optimisé -- Système de notifications par email -- Statistiques sur l'occupation des salles - -**Long terme** - -- Application mobile native -- Intégration avec des calendriers externes (Google Calendar, iCal) -- Système de recommandation basé sur les préférences utilisateur - -**5.3 Défis et Solutions** - -**Gestion des conflits de planning** - -- **Défi** : Éviter les chevauchements de talks dans une même salle -- **Solution** : Contraintes de validation dans l'API et alertes visuelles dans l'interface - -**Performance avec grand nombre d'utilisateurs** - -- **Défi** : Maintenir la réactivité avec des centaines d'utilisateurs simultanés -- **Solution** : Mise en cache, pagination, et optimisation des requêtes Prisma - -**Expérience utilisateur cohérente** - -- **Défi** : Assurer une UX fluide sur tous les appareils -- **Solution** : Design responsive, tests sur différents appareils, et composants UI optimisés - ---- - -# Diagramme de Séquence - Planification d'un Talk - -Ce diagramme représente les interactions entre les différents composants lors de la planification d'un talk. - -```mermaid -sequenceDiagram - actor Organisateur - participant UI as Interface Utilisateur - participant API as API Next.js - participant Service as Service de Planification - participant Prisma as Prisma ORM - participant DB as Base de données - - Organisateur->>UI: Sélectionne un talk à planifier - UI->>API: GET /api/talks/:id - API->>Prisma: findUnique(talk) - Prisma->>DB: SELECT * FROM talks WHERE id = ? - DB-->>Prisma: Données du talk - Prisma-->>API: Talk - API-->>UI: Détails du talk - - Organisateur->>UI: Choisit salle et créneau - UI->>API: GET /api/rooms - API->>Prisma: findMany(rooms) - Prisma->>DB: SELECT * FROM rooms - DB-->>Prisma: Liste des salles - Prisma-->>API: Salles - API-->>UI: Options de salles - - Organisateur->>UI: Confirme la planification - UI->>API: POST /api/schedules - API->>Service: checkConflicts(room_id, start_time, end_time) - Service->>Prisma: findMany(schedules) - Prisma->>DB: SELECT * FROM schedules WHERE room_id = ? AND ... - DB-->>Prisma: Créneaux existants - Prisma-->>Service: Résultat de la recherche - - alt Pas de conflit - Service-->>API: Aucun conflit détecté - API->>Prisma: create(schedule) - Prisma->>DB: INSERT INTO schedules ... - DB-->>Prisma: Confirmation - API->>Prisma: update(talk) - Prisma->>DB: UPDATE talks SET status = 'scheduled' ... - DB-->>Prisma: Confirmation - Prisma-->>API: Schedule créé - API-->>UI: Succès - UI-->>Organisateur: Notification de succès - else Conflit détecté - Service-->>API: Conflit détecté - API-->>UI: Erreur de conflit - UI-->>Organisateur: Notification d'erreur - end - -``` - -# Diagramme de Flux Utilisateur - -Ce diagramme représente les différents parcours utilisateurs dans l'application GoofyTrack. - -```mermaid -flowchart TD - Start((Entrée)) --> Auth{Authentifié?} - Auth -->|Non| Login[Connexion/Inscription] - Auth -->|Oui| Role{Rôle?} - - Login --> Role - - Role -->|Conférencier| Speaker[Dashboard Conférencier] - Role -->|Organisateur| Organizer[Dashboard Organisateur] - Role -->|Participant| Attendee[Dashboard Participant] - - Speaker --> S1[Proposer un talk] - Speaker --> S2[Voir mes talks] - Speaker --> S3[Modifier un talk] - - S1 --> TalkForm[Formulaire de talk] - TalkForm --> TalkSubmit[Soumission] - TalkSubmit --> S2 - - Organizer --> O1[Voir les talks en attente] - Organizer --> O2[Planifier les talks] - Organizer --> O3[Gérer le programme] - - O1 --> ReviewTalk{Décision} - ReviewTalk -->|Accepter| AcceptTalk[Talk accepté] - ReviewTalk -->|Refuser| RejectTalk[Talk refusé] - AcceptTalk --> O2 - - O2 --> ScheduleForm[Attribuer salle et horaire] - ScheduleForm --> ScheduleCheck{Conflit?} - ScheduleCheck -->|Oui| ScheduleForm - ScheduleCheck -->|Non| ScheduleConfirm[Planning confirmé] - - Attendee --> A1[Consulter le programme] - Attendee --> A2[Gérer mes favoris] - Attendee --> A3[Donner un feedback] - - A1 --> Filter[Filtrer par jour/salle/sujet] - Filter --> TalkList[Liste des talks] - TalkList --> TalkDetails[Détails du talk] - TalkDetails --> A2 - TalkDetails --> A3 -``` - -```mermaid -erDiagram - User { - int id PK - string username - string email UK - string password - int role_id FK - string avatarUrl - string bio - datetime created_at - datetime updated_at - } - - roles { - int id PK - string name UK - datetime created_at - } - - talks { - int id PK - string title - string description - int speaker_id FK - int subject_id FK - int duration - enum level - enum status - datetime created_at - datetime updated_at - } - - subjects { - int id PK - string name UK - datetime created_at - } - - rooms { - int id PK - string name UK - int capacity - string description - datetime created_at - } - - schedules { - int id PK - int talk_id FK,UK - int room_id FK - datetime start_time - datetime end_time - datetime created_at - datetime updated_at - } - - favorites { - int id PK - int user_id FK - int talk_id FK - datetime created_at - composite user_id_talk_id UK - } - - feedback { - int id PK - int user_id FK - int talk_id FK - boolean rating - string comment - datetime created_at - composite user_id_talk_id UK - } - - talks_level { - beginner - intermediate - advanced - expert - } - - talks_status { - pending - accepted - rejected - scheduled - } - - User ||--o{ talks : "propose" - User ||--o{ favorites : "saves" - User ||--o{ feedback : "gives" - roles ||--o{ User : "has" - subjects ||--o{ talks : "categorizes" - talks ||--o{ favorites : "saved in" - talks ||--o{ feedback : "receives" - talks ||--o| schedules : "scheduled as" - rooms ||--o{ schedules : "hosts" - talks }|--|| talks_level : "has" - talks }|--|| talks_status : "has" - -``` - -# Diagramme de composant - -Ce diagramme représente la structure des composants de l'application GoofyTrack. - -```mermaid -graph TB - subgraph "Pages" - Home["/"] - Dashboard["/dashboard"] - Talks["/talks"] - Schedule["/schedule"] - Profile["/profile"] - Admin["/admin"] - end - - subgraph "Composants UI" - Layout["Layout"] - Navbar["Navbar"] - Footer["Footer"] - TalkCard["TalkCard"] - TalkForm["TalkForm"] - ScheduleGrid["ScheduleGrid"] - FilterBar["FilterBar"] - UserProfile["UserProfile"] - end - - subgraph "Hooks & Context" - AuthContext["AuthContext"] - TalksContext["TalksContext"] - ScheduleContext["ScheduleContext"] - useUser["useUser"] - useTalks["useTalks"] - useSchedule["useSchedule"] - useFavorites["useFavorites"] - end - - subgraph "API Routes" - AuthAPI["/api/auth"] - UsersAPI["/api/users"] - TalksAPI["/api/talks"] - SchedulesAPI["/api/schedules"] - RoomsAPI["/api/rooms"] - SubjectsAPI["/api/subjects"] - FavoritesAPI["/api/favorites"] - FeedbackAPI["/api/feedback"] - end - - subgraph "Services" - AuthService["AuthService"] - TalkService["TalkService"] - ScheduleService["ScheduleService"] - UserService["UserService"] - NotificationService["NotificationService"] - end - - Home --> Layout - Dashboard --> Layout - Talks --> Layout - Schedule --> Layout - Profile --> Layout - Admin --> Layout - - Layout --> Navbar - Layout --> Footer - - Talks --> TalkCard - Talks --> TalkForm - Talks --> FilterBar - - Schedule --> ScheduleGrid - Schedule --> FilterBar - - Profile --> UserProfile - - Dashboard --> TalkCard - Dashboard --> ScheduleGrid - - AuthContext --> useUser - TalksContext --> useTalks - ScheduleContext --> useSchedule - - useUser --> AuthAPI - useTalks --> TalksAPI - useSchedule --> SchedulesAPI - useFavorites --> FavoritesAPI - - AuthAPI --> AuthService - UsersAPI --> UserService - TalksAPI --> TalkService - SchedulesAPI --> ScheduleService - - AuthService --> Prisma - TalkService --> Prisma - ScheduleService --> Prisma - UserService --> Prisma - - Prisma[("Prisma Client")] -``` - -# Diagramme d'Architecture - -Ce diagramme représente l'architecture technique de l'application GoofyTrack. - -```mermaid -flowchart TB - subgraph "Frontend" - UI["UI Components\\n(React/Next.js)"] - State["État de l'application\\n(Context API/Hooks)"] - Router["Routeur Next.js"] - end - - subgraph "Backend" - API["API Routes Next.js"] - Auth["NextAuth.js"] - Services["Services"] - end - - subgraph "Données" - Prisma["Prisma ORM"] - DB[(PostgreSQL)] - end - - subgraph "Infrastructure" - Vercel["Vercel (Déploiement)"] - NeonDB["Neon Tech (PostgreSQL)"] - GitHub["GitHub Actions (CI/CD)"] - end - - UI <--> State - UI <--> Router - Router <--> API - API <--> Auth - API <--> Services - Services <--> Prisma - Auth <--> Prisma - Prisma <--> DB - - Vercel --> UI - Vercel --> API - NeonDB --> DB - GitHub --> Vercel - -``` +- [Repository GitHub](https://github.com/GoofyTeam/GoofyTrack) +- [Application déployée](https://goofy-track.vercel.app) +- [Tableau de bord du projet](https://github.com/GoofyTeam/GoofyTrack/projects) diff --git a/docs/presentation.md b/docs/presentation.md new file mode 100644 index 0000000..745a7c5 --- /dev/null +++ b/docs/presentation.md @@ -0,0 +1,137 @@ +# Présentation GoofyTrack + +## 1. 👥 Organisation de l'équipe + +### Présentation des rôles +- **Lead Developer** : Responsable de l'architecture technique et des décisions de conception +- **Scrum Master** : Facilite les processus agiles et supprime les obstacles +- **UI/UX Designer** : Conception de l'interface utilisateur et de l'expérience utilisateur +- **Frontend Developer** : Développement de l'interface utilisateur avec Next.js +- **Backend Developer** : Développement des API et de la logique métier + +### Méthodologie choisie : Scrum +- **Sprints** de 1 semaine avec planning, daily et rétrospective +- **Backlog** priorisé et mis à jour régulièrement +- **User Stories** clairement définies avec critères d'acceptation + +### Outils utilisés +- **GitHub** : Gestion du code source et des pull requests +- **Discord** : Communication d'équipe et partage d'informations +- **Trello/GitHub Projects** : Suivi des tâches et visualisation du sprint +- **Figma** : Conception des maquettes et prototypes + +### Workflow Git +1. Création d'une branche feature à partir de develop +2. Développement et tests locaux +3. Pull request avec code review +4. Merge dans develop après validation +5. Déploiement en production via la branche main + +🎯 **Objectif** : Notre organisation a permis une communication fluide et une répartition claire des tâches, facilitant le développement rapide et efficace de notre MVP. + +## 2. 🧱 Présentation de la stack technique + +### Frontend +- **Next.js** : Framework React pour le développement d'applications web +- **Tailwind CSS** : Framework CSS utilitaire pour un design responsive +- **Zustand** : Gestion d'état global simple et efficace +- **React Query** : Gestion des requêtes API et du cache + +### Backend +- **Next.js API Routes** : API REST intégrée à notre application Next.js +- **Prisma** : ORM moderne pour interagir avec notre base de données +- **NextAuth.js** : Solution d'authentification complète avec gestion des rôles + +### Base de données +- **PostgreSQL** : Base de données relationnelle robuste +- **Prisma Schema** : Modélisation des données avec migrations automatiques + +### Déploiement +- **Vercel** : Plateforme de déploiement optimisée pour Next.js +- **Docker** : Conteneurisation pour le développement local +- **GitHub Actions** : CI/CD pour les tests automatisés et le déploiement + +### Architecture logicielle +- **Architecture en couches** : Séparation claire entre UI, logique métier et accès aux données +- **API RESTful** : Communication standardisée entre frontend et backend +- **Responsive Design** : Adaptation à tous les appareils (desktop, tablette, mobile) + +🎯 **Objectif** : Notre stack technique moderne nous permet de développer rapidement, de maintenir facilement et de faire évoluer notre application selon les besoins. + +## 3. 🧭 Expression du besoin + +### Problème à résoudre +L'organisation d'événements techniques (type Devoxx France) nécessite une gestion complexe des conférences, des salles et des horaires. Les solutions existantes sont souvent trop rigides ou trop complexes pour les organisateurs. + +### Utilisateurs cibles +- **Conférenciers** : Professionnels souhaitant partager leur expertise +- **Organisateurs** : Équipe responsable de la planification et de la logistique +- **Public** : Participants cherchant à optimiser leur parcours durant l'événement + +### Contexte d'utilisation +- **Conférenciers** : Avant l'événement pour proposer des talks, pendant pour suivre leur statut +- **Organisateurs** : Phase de préparation et pendant l'événement pour ajustements +- **Public** : Avant et pendant l'événement pour consulter et planifier leur agenda + +🎯 **Objectif** : GoofyTrack simplifie la gestion des conférences techniques en offrant une plateforme intuitive pour tous les acteurs impliqués. + +## 4. 💡 Fonctionnalité principale + +### Gestion complète du cycle de vie des talks +Notre fonctionnalité essentielle est la gestion des talks depuis leur proposition jusqu'à leur planification dans le programme de l'événement. + +#### Parcours utilisateur +1. Le conférencier soumet un talk via un formulaire détaillé +2. L'organisateur évalue la proposition et change son statut (accepté/refusé) +3. L'organisateur attribue un créneau et une salle aux talks acceptés +4. Le public consulte le planning et filtre selon ses intérêts + +#### Priorité de cette fonctionnalité +Cette fonctionnalité est le cœur de notre application car elle répond au besoin principal de tous nos utilisateurs : la gestion efficace du contenu de l'événement. + +#### Réponse au besoin utilisateur +- **Conférenciers** : Interface simple pour proposer et suivre leurs talks +- **Organisateurs** : Outils de validation et planification avec vérification des conflits +- **Public** : Vue claire et filtrable du programme + +#### Démonstration +[Insérer ici des captures d'écran ou lien vers une démo] + +🎯 **Objectif** : Notre solution offre une expérience fluide et intuitive pour la gestion des talks, éliminant les frictions habituelles dans l'organisation d'événements. + +## 5. 🔭 Axes d'amélioration et évolutions futures + +### Système de recommandation personnalisé +Nous envisageons d'implémenter un système de recommandation basé sur les préférences des utilisateurs et leur historique de participation. + +### Génération automatique de planning +Développement d'un algorithme d'optimisation pour générer automatiquement un planning optimal en tenant compte des contraintes (salles, disponibilités des conférenciers, thématiques). + +### Application mobile native +Création d'une application mobile dédiée avec fonctionnalités hors-ligne et notifications push. + +### Raisons de priorisation ultérieure +Ces fonctionnalités n'ont pas été intégrées au MVP pour plusieurs raisons : +- Contraintes de temps pour le développement initial +- Nécessité de valider d'abord le concept de base +- Complexité technique nécessitant une phase de recherche approfondie + +🎯 **Objectif** : Ces évolutions permettront d'enrichir l'expérience utilisateur et d'augmenter la valeur ajoutée de notre plateforme. + +## 6. 🤝 Soft skills et bilan de groupe + +### Apprentissages +- **Gestion d'équipe** : Importance de la clarté des rôles et de la responsabilisation +- **Communication** : Bénéfices des daily meetings courts et des canaux de communication dédiés +- **Résolution de problèmes** : Approche collaborative face aux défis techniques + +### Gestion des difficultés +Face au défi de l'intégration de NextAuth avec Prisma, nous avons organisé une session de pair programming qui a permis de résoudre rapidement le problème tout en partageant les connaissances. + +### Améliorations futures +Si nous recommencions le projet, nous : +- Consacrerions plus de temps à la phase de conception initiale +- Mettrions en place des tests automatisés dès le début +- Établirions des conventions de code plus strictes + +🎯 **Objectif** : Cette expérience nous a permis de grandir en tant qu'équipe et d'acquérir des compétences précieuses pour nos futurs projets professionnels. \ No newline at end of file diff --git a/docs/technical-decisions.md b/docs/technical-decisions.md new file mode 100644 index 0000000..af2e097 --- /dev/null +++ b/docs/technical-decisions.md @@ -0,0 +1,249 @@ +# Décisions Techniques + +Ce document détaille les principales décisions techniques prises dans le cadre du projet GoofyTrack, ainsi que leurs justifications. + +## Stack Technologique + +### Frontend + +#### Next.js avec TypeScript + +**Décision :** Utiliser Next.js 15.3.2 avec TypeScript comme framework frontend. + +**Justification :** +- **Rendu hybride :** Next.js permet à la fois le rendu côté serveur (SSR), la génération statique (SSG) et le rendu côté client, offrant une flexibilité optimale pour notre application. +- **Performance :** Le rendu côté serveur améliore les performances perçues et le SEO. +- **Routing intégré :** Système de routing basé sur le système de fichiers, simplifiant la structure du projet. +- **TypeScript :** Apporte un typage statique qui réduit les erreurs et améliore la maintenabilité du code. +- **API Routes :** Permet de créer des endpoints API dans le même projet, simplifiant l'architecture. + +**Alternatives considérées :** +- Create React App : Rejeté car il manque de fonctionnalités SSR et SSG natives. +- Remix : Prometteur mais moins mature que Next.js au moment de la décision. + +#### Tailwind CSS avec Radix UI + +**Décision :** Utiliser Tailwind CSS pour les styles avec des composants Radix UI. + +**Justification :** +- **Productivité :** Tailwind permet un développement rapide avec des classes utilitaires. +- **Cohérence :** Système de design cohérent avec des variables prédéfinies. +- **Performance :** Génère uniquement le CSS utilisé, réduisant la taille du bundle. +- **Accessibilité :** Les composants Radix UI sont accessibles par défaut et s'intègrent bien avec Tailwind. + +**Alternatives considérées :** +- Material UI : Trop opinionated et plus lourd. +- Bootstrap : Style visuel moins moderne et plus difficile à personnaliser. + +### Backend + +#### API Routes Next.js + +**Décision :** Utiliser les API Routes de Next.js pour le backend. + +**Justification :** +- **Simplicité :** Permet de maintenir le frontend et le backend dans un seul projet. +- **Déploiement unifié :** Un seul déploiement pour toute l'application. +- **Partage de code :** Facilite le partage de types et de logique entre le frontend et le backend. + +**Alternatives considérées :** +- Express.js : Aurait nécessité un projet séparé et un déploiement distinct. +- NestJS : Plus structuré mais trop complexe pour les besoins du projet. + +#### Prisma ORM + +**Décision :** Utiliser Prisma comme ORM. + +**Justification :** +- **Type-safety :** Génère des types TypeScript à partir du schéma de base de données. +- **Migrations :** Système de migrations intégré et facile à utiliser. +- **Requêtes optimisées :** Génère des requêtes SQL efficaces. +- **Studio :** Interface graphique pour explorer et modifier les données. + +**Alternatives considérées :** +- Sequelize : Moins bien intégré avec TypeScript. +- TypeORM : Plus complexe et moins mature que Prisma. + +### Base de données + +#### PostgreSQL + +**Décision :** Utiliser PostgreSQL comme système de gestion de base de données. + +**Justification :** +- **Fiabilité :** PostgreSQL est reconnu pour sa robustesse et sa conformité SQL. +- **Fonctionnalités avancées :** Support des JSON, des index avancés et des contraintes complexes. +- **Scalabilité :** Peut évoluer avec les besoins du projet. +- **Écosystème :** Bien supporté par Prisma et les services d'hébergement comme Neon Tech. + +**Alternatives considérées :** +- MySQL/MariaDB : Moins de fonctionnalités avancées. +- MongoDB : Le modèle de données du projet est fortement relationnel, rendant PostgreSQL plus adapté. + +### Authentification + +#### NextAuth.js + +**Décision :** Utiliser NextAuth.js pour l'authentification. + +**Justification :** +- **Intégration native :** S'intègre parfaitement avec Next.js. +- **Flexibilité :** Support de multiples providers d'authentification. +- **Sécurité :** Gestion des sessions et des JWT intégrée. +- **Simplicité :** API simple pour protéger les routes et obtenir les informations utilisateur. + +**Alternatives considérées :** +- Auth0 : Solution payante et externe, moins de contrôle sur les données utilisateur. +- Firebase Auth : Aurait introduit une dépendance supplémentaire. + +### Déploiement + +#### Vercel + +**Décision :** Déployer l'application sur Vercel. + +**Justification :** +- **Intégration optimale :** Plateforme créée par l'équipe de Next.js, offrant une intégration parfaite. +- **Déploiements automatiques :** CI/CD intégré avec GitHub. +- **Prévisualisations :** Génération automatique d'environnements de prévisualisation pour les PR. +- **Edge Network :** Distribution globale pour des performances optimales. + +**Alternatives considérées :** +- Netlify : Moins optimisé pour Next.js. +- AWS : Plus complexe à configurer et à maintenir. + +#### Neon Tech pour PostgreSQL + +**Décision :** Utiliser Neon Tech pour héberger la base de données PostgreSQL. + +**Justification :** +- **Serverless :** Base de données PostgreSQL serverless, s'adapte automatiquement à la charge. +- **Branchement :** Possibilité de créer des branches de la base de données pour les environnements de développement. +- **Performance :** Optimisé pour les applications web modernes. +- **Coût :** Tier gratuit généreux pour les projets en phase de démarrage. + +**Alternatives considérées :** +- Heroku : Plus cher et moins flexible. +- AWS RDS : Plus complexe à configurer et à maintenir. + +## Architecture de l'Application + +### Structure du Projet + +**Décision :** Organiser le projet selon une architecture basée sur les fonctionnalités plutôt que sur les types de fichiers. + +**Justification :** +- **Cohésion :** Les fichiers liés à une même fonctionnalité sont regroupés. +- **Scalabilité :** Facilite l'ajout de nouvelles fonctionnalités sans impacter les existantes. +- **Maintenabilité :** Réduit la complexité lors de la modification d'une fonctionnalité. + +**Structure adoptée :** +``` +/app + /api # API Routes + /components # Composants React partagés + /features # Fonctionnalités organisées par domaine + /auth + /talks + /schedule + /favorites + /hooks # Hooks React personnalisés + /lib # Utilitaires et services + /prisma # Schéma et migrations Prisma + /public # Fichiers statiques + /styles # Styles globaux +``` + +### Gestion d'État + +**Décision :** Utiliser React Context API pour la gestion d'état globale. + +**Justification :** +- **Simplicité :** Solution native à React, sans dépendance supplémentaire. +- **Suffisance :** Adapté à la complexité modérée de l'application. +- **TypeScript :** Bonne intégration avec TypeScript. + +**Alternatives considérées :** +- Redux : Trop complexe pour les besoins actuels du projet. +- Zustand : Envisagé pour une future migration si la complexité augmente. + +### Containerisation + +**Décision :** Utiliser Docker pour l'environnement de développement. + +**Justification :** +- **Cohérence :** Garantit un environnement de développement identique pour tous les membres de l'équipe. +- **Isolation :** Isole les services (PostgreSQL, pgAdmin, Mailhog) du système hôte. +- **Simplicité :** Configuration unique et partagée via docker-compose. + +## Choix de Conception + +### Modèle de Données + +**Décision :** Concevoir un modèle de données centré sur les talks et les utilisateurs. + +**Justification :** +- **Simplicité :** Modèle facile à comprendre et à maintenir. +- **Flexibilité :** Permet d'évoluer avec les besoins futurs. +- **Performance :** Optimisé pour les requêtes les plus fréquentes. + +### API REST + +**Décision :** Concevoir une API REST plutôt que GraphQL. + +**Justification :** +- **Simplicité :** Plus simple à implémenter et à comprendre. +- **Cache HTTP :** Peut tirer parti du cache HTTP standard. +- **Suffisance :** Répond aux besoins actuels de l'application. + +**Alternatives considérées :** +- GraphQL : Aurait été plus complexe à mettre en place pour les bénéfices apportés. + +### Tests + +**Décision :** Utiliser Jest et React Testing Library pour les tests. + +**Justification :** +- **Intégration :** Bien intégré à l'écosystème React et Next.js. +- **Approche :** Testing Library encourage les tests qui reflètent l'utilisation réelle de l'application. +- **Couverture :** Permet de tester à la fois les composants UI et la logique métier. + +## Décisions Spécifiques + +### Gestion des Favoris + +**Décision :** Implémenter les favoris comme une relation many-to-many entre utilisateurs et talks. + +**Justification :** +- **Simplicité :** Modèle simple et efficace. +- **Performance :** Requêtes optimisées pour récupérer les favoris d'un utilisateur. +- **Fonctionnalité :** Répond exactement au besoin exprimé dans le cahier des charges. + +### Planification des Talks + +**Décision :** Implémenter une validation côté serveur pour éviter les conflits de planning. + +**Justification :** +- **Intégrité des données :** Garantit qu'aucun chevauchement ne peut se produire. +- **Expérience utilisateur :** Fournit un feedback immédiat aux organisateurs. +- **Sécurité :** Empêche les manipulations côté client de contourner les règles. + +## Évolutions Futures + +### Génération Automatique de Planning + +**Plan :** Implémenter un algorithme d'optimisation pour générer automatiquement le planning. + +**Approche envisagée :** +- Utiliser un algorithme de satisfaction de contraintes. +- Prendre en compte les préférences des conférenciers et la popularité estimée des talks. +- Permettre des ajustements manuels après la génération automatique. + +### Notifications + +**Plan :** Ajouter un système de notifications par email et in-app. + +**Approche envisagée :** +- Utiliser un service comme SendGrid pour les emails. +- Implémenter un système de notifications en temps réel avec WebSockets. +- Permettre aux utilisateurs de configurer leurs préférences de notification. diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..6813f81 --- /dev/null +++ b/shell.nix @@ -0,0 +1,21 @@ +{ pkgs ? import { } }: + +pkgs.mkShell { + buildInputs = with pkgs; [ + nodejs_20 + nodePackages.yarn + nodePackages.node-gyp + vips + python3 + prisma-engines + openssl + ]; + + shellHook = '' + export PRISMA_SCHEMA_ENGINE_BINARY="${pkgs.prisma-engines}/bin/schema-engine" + export PRISMA_QUERY_ENGINE_BINARY="${pkgs.prisma-engines}/bin/query-engine" + export PRISMA_QUERY_ENGINE_LIBRARY="${pkgs.prisma-engines}/lib/libquery_engine.node" + export PRISMA_FMT_BINARY="${pkgs.prisma-engines}/bin/prisma-fmt" + export PATH="$PWD/node_modules/.bin/:$PATH" + ''; +} diff --git a/tsconfig.json b/tsconfig.json index 957e71f..cf66fed 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,6 @@ "@/*": ["./*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "app/prisma/seed.js"], "exclude": ["node_modules"] }