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/talks/MyTalksList.tsx b/app/components/talks/MyTalksList.tsx new file mode 100644 index 0000000..df8549f --- /dev/null +++ b/app/components/talks/MyTalksList.tsx @@ -0,0 +1,264 @@ +// components/talks/MyTalksList.tsx +'use client'; + +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { levels } from '@/lib/mock-data'; +import { Talk, Room } from '@/lib/types'; +import { isOrganizer, isSpeaker } from '@/utils/auth.utils'; +import { Pencil, Plus, Trash2, CalendarPlus } from 'lucide-react'; +import { useSession } from 'next-auth/react'; +import { useState, useMemo } from 'react'; +import DeleteDialog from './DeleteDialog'; +import StatusBadge from './StatusBadge'; +import TalkDialog from './TalkDialog'; + +interface MyTalksListProps { + talks: Talk[]; + onAddTalk: (talk: Omit) => void; + onUpdateTalk: (talk: Talk) => void; + onDeleteTalk: (talkId: string) => void; + // scheduledTalks: ScheduledTalk[]; + rooms?: Room[]; + // topics: string[]; +} + +// Helper to generate Google Calendar event link +function getGoogleCalendarUrl(talk: Talk) { + // For demo, use current date/time as start, and add duration + const start = new Date(); + const end = new Date(start.getTime() + (talk.durationMinutes || 30) * 60000); + + function formatDate(d: Date) { + // YYYYMMDDTHHmmssZ + return d.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; + } + + const params = new URLSearchParams({ + action: 'TEMPLATE', + text: talk.title, + details: talk.description || '', + location: 'Goofy Talk', + dates: `${formatDate(start)}/${formatDate(end)}`, + }); + + return `https://calendar.google.com/calendar/render?${params.toString()}`; +} + +export default function MyTalksList({ + talks, + onAddTalk, + onUpdateTalk, + onDeleteTalk, + // scheduledTalks, + rooms = [], + // topics, +}: MyTalksListProps) { + const session = useSession(); + + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [currentTalk, setCurrentTalk] = useState(null); + const [talkToDelete, setTalkToDelete] = useState(null); + const [isNewTalk, setIsNewTalk] = useState(true); + + const [filterTopic, setFilterTopic] = useState(''); + const [filterDuration, setFilterDuration] = useState(''); + const [filterLevel, setFilterLevel] = useState(''); + const [filterRoom, setFilterRoom] = useState(''); + const [filterDate, setFilterDate] = useState(''); + + const filteredScheduledTalks = useMemo(() => { + return talks.filter((talk) => { + let ok = true; + if (filterTopic && talk.topic !== filterTopic) ok = false; + if (filterDuration && String(talk.durationMinutes) !== filterDuration) ok = false; + if (filterLevel && talk.level !== filterLevel) ok = false; + return ok; + }); + // }, [scheduledTalks, filterTopic, filterDuration, filterLevel, filterRoom, filterDate]); + }, [talks, filterTopic, filterDuration, filterLevel, filterRoom, filterDate]); + + const topics = useMemo(() => { + const allTopics = talks.map((talk) => talk.topic); + const uniqueTopics = Array.from(new Set(allTopics)); + + return uniqueTopics; + }, [talks]); + + const handleCreateTalk = () => { + setIsNewTalk(true); + setCurrentTalk(null); + setIsDialogOpen(true); + }; + + const handleEditTalk = (talk: Talk) => { + setCurrentTalk(talk); + setIsNewTalk(false); + setIsDialogOpen(true); + }; + + const handleDeleteTalk = (talk: Talk) => { + setTalkToDelete(talk); + setIsDeleteDialogOpen(true); + }; + + const confirmDeleteTalk = () => { + if (talkToDelete) { + onDeleteTalk(talkToDelete.id); + setIsDeleteDialogOpen(false); + setTalkToDelete(null); + } + }; + + const saveTalk = (talk: Omit & { id?: string }) => { + if (isNewTalk) { + onAddTalk(talk); + } else { + onUpdateTalk(talk as Talk); + } + setIsDialogOpen(false); + }; + + return ( +
+
+

Tous mes talks

+ {session.data?.user && + (isOrganizer(session.data.user.roleId) || isSpeaker(session.data.user.roleId)) && ( + + )} +
+ +
+ + + + + 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 2be90c5..604e021 100644 --- a/app/components/talks/PendingTalksList.tsx +++ b/app/components/talks/PendingTalksList.tsx @@ -1,4 +1,4 @@ -// components/talks/PendingTalksList.tsx - Modified version +// components/talks/PendingTalksList.tsx 'use client'; import { Button } from '@/components/ui/button'; @@ -22,11 +22,33 @@ import StatusDialog from './StatusDialog'; import TalkDialog from './TalkDialog'; // Mock data for rooms (you should replace this with your actual data) -const rooms = [ - { id: 'room1', name: 'Salle A', capacity: 100 }, - { id: 'room2', name: 'Salle B', capacity: 50 }, - { id: 'room3', name: 'Salle C', capacity: 200 }, -]; +// 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[]; @@ -192,7 +214,6 @@ export default function PendingTalksList({ {/* Dialog for status change */} ([]); + 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..fd8b2e5 100644 --- a/app/components/talks/StatusBadge.tsx +++ b/app/components/talks/StatusBadge.tsx @@ -29,7 +29,7 @@ export default function StatusBadge({ status }: StatusBadgeProps) { icon: , label: 'Accepté', }, - refused: { + rejected: { variant: 'outline', className: 'bg-red-100', icon: , diff --git a/app/components/talks/StatusDialog.tsx b/app/components/talks/StatusDialog.tsx index 295bd63..45959fd 100644 --- a/app/components/talks/StatusDialog.tsx +++ b/app/components/talks/StatusDialog.tsx @@ -1,5 +1,6 @@ +'use client'; + import { Button } from '@/components/ui/button'; -import { Calendar } from '@/components/ui/calendar'; import { Dialog, DialogContent, @@ -8,36 +9,16 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { Label } from '@/components/ui/label'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; import { Talk, TalkStatus } from '@/lib/types'; import { cn } from '@/lib/utils'; -import { format } from 'date-fns'; -import { fr } from 'date-fns/locale'; -import { CalendarIcon, Check, X } from 'lucide-react'; +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, - details?: { - startDate?: Date; - endDate?: Date; - roomId?: string; - }, - ) => void; - rooms: { id: string; name: string; capacity: number }[]; + onChangeTalkStatus: (talkId: string, newStatus: TalkStatus) => void; } export default function StatusDialog({ @@ -45,11 +26,10 @@ export default function StatusDialog({ setIsOpen, talk, onChangeTalkStatus, - rooms, }: StatusDialogProps) { - const [selectedStartDate, setSelectedStartDate] = useState(undefined); - const [selectedEndDate, setSelectedEndDate] = useState(undefined); - const [selectedRoom, setSelectedRoom] = useState(''); + // const [selectedStartDate, setSelectedStartDate] = useState(undefined); + // const [selectedEndDate, setSelectedEndDate] = useState(undefined); + // const [selectedRoom, setSelectedRoom] = useState(''); const [selectedStatus, setSelectedStatus] = useState('pending'); const handleStatusChange = (status: TalkStatus) => { @@ -58,16 +38,12 @@ export default function StatusDialog({ const handleSave = () => { if (talk) { - onChangeTalkStatus(talk.id, selectedStatus, { - startDate: selectedStartDate, - endDate: selectedEndDate, - roomId: selectedRoom, - }); + onChangeTalkStatus(talk.id, selectedStatus); setIsOpen(false); // Reset state - setSelectedStartDate(undefined); - setSelectedEndDate(undefined); - setSelectedRoom(''); + // setSelectedStartDate(undefined); + // setSelectedEndDate(undefined); + // setSelectedRoom(''); setSelectedStatus('pending'); } }; @@ -90,97 +66,13 @@ export default function StatusDialog({ Accepter - - {selectedStatus === 'accepted' && ( - <> - {/* Sélection de la date de début */} -
- - - - - - - date && setSelectedStartDate(date)} - /> - - -
- - {/* Sélection de la date de fin */} -
- - - - - - - (selectedStartDate ? date < selectedStartDate : false)} - mode="single" - selected={selectedEndDate} - initialFocus - onSelect={(date) => date && setSelectedEndDate(date)} - /> - - -
- - {/* Sélection de la salle */} -
- - -
- - )} 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..45f70a4 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") 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/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/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"] }