From 9f0b49a0ccc1418b09e575cb1b6846905edbf984 Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 28 Feb 2025 17:25:12 +0100 Subject: [PATCH] =?UTF-8?q?Ajout=20de=20la=20prise=20en=20charge=20de=20Po?= =?UTF-8?q?stgreSQL,=20configuration=20de=20Prisma,=20et=20cr=C3=A9ation?= =?UTF-8?q?=20d'une=20API=20pour=20g=C3=A9rer=20les=20calendriers=20par=20?= =?UTF-8?q?d=C3=A9faut=20et=20le=20partage.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 +- ansible/playbooks/1_docker.yml | 9 + ansible/playbooks/6_postgresql.yml | 33 + ansible/run_playbooks.sh | 1 + front/.env.production | 4 +- front/Dockerfile | 4 + .../calendars/[id]/events/[eventId]/route.ts | 270 +++++++ front/app/api/calendars/[id]/events/route.ts | 171 +++++ front/app/api/calendars/[id]/route.ts | 181 +++++ front/app/api/calendars/[id]/share/route.ts | 53 ++ front/app/api/calendars/default/route.ts | 57 ++ front/app/api/calendars/route.ts | 93 +++ front/app/calendar/page.tsx | 54 ++ front/components/calendar/calendar-client.tsx | 268 +++++++ front/components/calendar/event-dialog.tsx | 220 ++++++ front/components/sidebar.tsx | 6 +- front/hooks/use-calendar-events.ts | 106 +++ front/lib/prisma.ts | 10 + front/package-lock.json | 710 +++++++++++++++++- front/package.json | 6 +- front/prisma/schema.prisma | 39 + front/types/calendar.d.ts | 23 + front/yarn.lock | 175 ++++- 23 files changed, 2486 insertions(+), 13 deletions(-) create mode 100644 ansible/playbooks/6_postgresql.yml create mode 100644 front/app/api/calendars/[id]/events/[eventId]/route.ts create mode 100644 front/app/api/calendars/[id]/events/route.ts create mode 100644 front/app/api/calendars/[id]/route.ts create mode 100644 front/app/api/calendars/[id]/share/route.ts create mode 100644 front/app/api/calendars/default/route.ts create mode 100644 front/app/api/calendars/route.ts create mode 100644 front/app/calendar/page.tsx create mode 100644 front/components/calendar/calendar-client.tsx create mode 100644 front/components/calendar/event-dialog.tsx create mode 100644 front/hooks/use-calendar-events.ts create mode 100644 front/lib/prisma.ts create mode 100644 front/prisma/schema.prisma create mode 100644 front/types/calendar.d.ts diff --git a/.gitignore b/.gitignore index 7861025..5922872 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,8 @@ copy.sh start.sh # Playbook développement -ansible/playbooks/dev \ No newline at end of file +ansible/playbooks/dev + +# Migrations Prisma +#TODO: Supprimer en prod +front/prisma/migrations \ No newline at end of file diff --git a/ansible/playbooks/1_docker.yml b/ansible/playbooks/1_docker.yml index 537b303..cde75ea 100644 --- a/ansible/playbooks/1_docker.yml +++ b/ansible/playbooks/1_docker.yml @@ -103,6 +103,15 @@ retries: 3 delay: 5 + #TODO: Supprimer en prod + - name: Supprimer tous les conteneurs + command: "docker compose down -v" + register: rm_status + until: rm_status is success + retries: 3 + delay: 5 + ignore_errors: yes + - name: Lancer le service Traefik command: "docker compose up -d --build --remove-orphans {{ traefik_service_name }}" args: diff --git a/ansible/playbooks/6_postgresql.yml b/ansible/playbooks/6_postgresql.yml new file mode 100644 index 0000000..043dc48 --- /dev/null +++ b/ansible/playbooks/6_postgresql.yml @@ -0,0 +1,33 @@ +--- +- name: Installer et configurer PostgreSQL + hosts: servers + become: true + gather_facts: true + + vars: + git_repo: "https://gite.slm-lab.net/Chabdeltsang/Neah-Enkun.git" + git_dest: "/opt/Neah-Enkun" + git_branch: "master" + + pre_tasks: + - name: Cloner le dépôt Git + git: + repo: "{{ git_repo }}" + dest: "{{ git_dest }}" + version: "{{ git_branch }}" + update: true + force: true + register: git_status + until: git_status is success + retries: 3 + delay: 5 + + tasks: + - name: Lancer le service PostgreSQL + command: "docker compose up -d --build --remove-orphans postgresql" + args: + chdir: "{{ git_dest }}" + register: postgresql_launch + until: postgresql_launch is success + retries: 3 + delay: 5 diff --git a/ansible/run_playbooks.sh b/ansible/run_playbooks.sh index 4f73850..4006021 100755 --- a/ansible/run_playbooks.sh +++ b/ansible/run_playbooks.sh @@ -17,6 +17,7 @@ PLAYBOOKS=( "playbooks/dev/3_1_keycloak_dev.yml" "playbooks/4_mysql.yml" "playbooks/5_nextcloud.yml" + "playbooks/6_postgresql.yml" "playbooks/0_front.yml" ) diff --git a/front/.env.production b/front/.env.production index 1b48b9b..5e4f1bf 100644 --- a/front/.env.production +++ b/front/.env.production @@ -7,4 +7,6 @@ KEYCLOAK_REALM=master KEYCLOAK_ISSUER=http://172.16.32.141:8090/realms/master KEYCLOAK_BASE_URL=http://172.16.32.141:8090 -NEXTCLOUD_URL=http://cloud.neah.local \ No newline at end of file +NEXTCLOUD_URL=http://cloud.neah.local + +DATABASE_URL="postgresql://enkun:183d9ad665c9257703c2e0703f111d240266a56b33e10df04fb8c565e55e0b94@172.16.32.141:5432/enkun?schema=public" \ No newline at end of file diff --git a/front/Dockerfile b/front/Dockerfile index ab133e5..a90168b 100644 --- a/front/Dockerfile +++ b/front/Dockerfile @@ -13,6 +13,10 @@ RUN npm ci # Copy the rest of the application COPY . . +# Initialize Prisma +RUN npx prisma generate +RUN npx prisma migrate dev --name init + # Build the Next.js application RUN npm run build diff --git a/front/app/api/calendars/[id]/events/[eventId]/route.ts b/front/app/api/calendars/[id]/events/[eventId]/route.ts new file mode 100644 index 0000000..784f3d3 --- /dev/null +++ b/front/app/api/calendars/[id]/events/[eventId]/route.ts @@ -0,0 +1,270 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { prisma } from "@/lib/prisma"; + +/** + * Handles the GET request to retrieve a specific event from a calendar. + * + * @param req - The incoming Next.js request object. + * @param params - An object containing the route parameters. + * @param params.id - The ID of the calendar. + * @param params.eventId - The ID of the event. + * @returns A JSON response containing the event data or an error message. + * + * The function performs the following steps: + * 1. Checks if the user is authenticated. + * 2. Verifies that the calendar exists and belongs to the authenticated user. + * 3. Verifies that the event exists and belongs to the specified calendar. + * 4. Returns the event data if all checks pass. + * + * Possible error responses: + * - 401: User is not authenticated. + * - 403: User is not authorized to access the calendar. + * - 404: Calendar or event not found. + * - 500: Server error occurred while retrieving the event. + */ +export async function GET( + req: NextRequest, + { params }: { params: { id: string; eventId: string } } +) { + const session = await getServerSession(authOptions); + + if (!session?.user?.username) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } + + try { + // Vérifier que le calendrier appartient à l'utilisateur + const calendar = await prisma.calendar.findUnique({ + where: { + id: params.id, + }, + }); + + if (!calendar) { + return NextResponse.json( + { error: "Calendrier non trouvé" }, + { status: 404 } + ); + } + + if (calendar.userId !== session.user.username) { + return NextResponse.json({ error: "Non autorisé" }, { status: 403 }); + } + + const event = await prisma.event.findUnique({ + where: { + id: params.eventId, + }, + }); + + if (!event) { + return NextResponse.json( + { error: "Événement non trouvé" }, + { status: 404 } + ); + } + + // Vérifier que l'événement appartient bien au calendrier + if (event.calendarId !== params.id) { + return NextResponse.json( + { error: "Événement non trouvé dans ce calendrier" }, + { status: 404 } + ); + } + + return NextResponse.json(event); + } catch (error) { + console.error("Erreur lors de la récupération de l'événement:", error); + return NextResponse.json({ error: "Erreur serveur" }, { status: 500 }); + } +} + +/** + * Handles the PUT request to update an event in a calendar. + * + * @param req - The incoming request object. + * @param params - The route parameters containing the calendar ID and event ID. + * @returns A JSON response indicating the result of the update operation. + * + * The function performs the following steps: + * 1. Retrieves the server session to check if the user is authenticated. + * 2. Verifies that the calendar belongs to the authenticated user. + * 3. Checks if the event exists and belongs to the specified calendar. + * 4. Validates the request payload to ensure required fields are present. + * 5. Updates the event with the provided data. + * 6. Returns the updated event or an appropriate error response. + * + * Possible error responses: + * - 401: User is not authenticated. + * - 403: User is not authorized to update the calendar. + * - 404: Calendar or event not found. + * - 400: Validation error for missing required fields. + * - 500: Server error during the update process. + */ +export async function PUT( + req: NextRequest, + { params }: { params: { id: string; eventId: string } } +) { + const session = await getServerSession(authOptions); + + if (!session?.user?.username) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } + + try { + // Vérifier que le calendrier appartient à l'utilisateur + const calendar = await prisma.calendar.findUnique({ + where: { + id: params.id, + }, + }); + + if (!calendar) { + return NextResponse.json( + { error: "Calendrier non trouvé" }, + { status: 404 } + ); + } + + if (calendar.userId !== session.user.username) { + return NextResponse.json({ error: "Non autorisé" }, { status: 403 }); + } + + // Vérifier que l'événement existe et appartient au calendrier + const existingEvent = await prisma.event.findUnique({ + where: { + id: params.eventId, + }, + }); + + if (!existingEvent) { + return NextResponse.json( + { error: "Événement non trouvé" }, + { status: 404 } + ); + } + + if (existingEvent.calendarId !== params.id) { + return NextResponse.json( + { error: "Événement non trouvé dans ce calendrier" }, + { status: 404 } + ); + } + + const { title, description, start, end, location, isAllDay } = + await req.json(); + + // Validation + if (!title) { + return NextResponse.json( + { error: "Le titre est requis" }, + { status: 400 } + ); + } + + if (!start || !end) { + return NextResponse.json( + { error: "Les dates de début et de fin sont requises" }, + { status: 400 } + ); + } + + const updatedEvent = await prisma.event.update({ + where: { + id: params.eventId, + }, + data: { + title, + description, + start: new Date(start), + end: new Date(end), + location, + isAllDay: isAllDay || false, + }, + }); + + return NextResponse.json(updatedEvent); + } catch (error) { + console.error("Erreur lors de la mise à jour de l'événement:", error); + return NextResponse.json({ error: "Erreur serveur" }, { status: 500 }); + } +} + +/** + * Handles the DELETE request to remove an event from a calendar. + * + * @param req - The incoming Next.js request object. + * @param params - An object containing the parameters from the request URL. + * @param params.id - The ID of the calendar. + * @param params.eventId - The ID of the event to be deleted. + * @returns A JSON response indicating the result of the deletion operation. + * + * @throws Will return a 401 status if the user is not authenticated. + * @throws Will return a 404 status if the calendar or event is not found. + * @throws Will return a 403 status if the user is not authorized to delete the event. + * @throws Will return a 500 status if there is a server error during the deletion process. + */ +export async function DELETE( + req: NextRequest, + { params }: { params: { id: string; eventId: string } } +) { + const session = await getServerSession(authOptions); + + if (!session?.user?.username) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } + + try { + // Vérifier que le calendrier appartient à l'utilisateur + const calendar = await prisma.calendar.findUnique({ + where: { + id: params.id, + }, + }); + + if (!calendar) { + return NextResponse.json( + { error: "Calendrier non trouvé" }, + { status: 404 } + ); + } + + if (calendar.userId !== session.user.username) { + return NextResponse.json({ error: "Non autorisé" }, { status: 403 }); + } + + // Vérifier que l'événement existe et appartient au calendrier + const existingEvent = await prisma.event.findUnique({ + where: { + id: params.eventId, + }, + }); + + if (!existingEvent) { + return NextResponse.json( + { error: "Événement non trouvé" }, + { status: 404 } + ); + } + + if (existingEvent.calendarId !== params.id) { + return NextResponse.json( + { error: "Événement non trouvé dans ce calendrier" }, + { status: 404 } + ); + } + + await prisma.event.delete({ + where: { + id: params.eventId, + }, + }); + + return new NextResponse(null, { status: 204 }); + } catch (error) { + console.error("Erreur lors de la suppression de l'événement:", error); + return NextResponse.json({ error: "Erreur serveur" }, { status: 500 }); + } +} diff --git a/front/app/api/calendars/[id]/events/route.ts b/front/app/api/calendars/[id]/events/route.ts new file mode 100644 index 0000000..268ce1b --- /dev/null +++ b/front/app/api/calendars/[id]/events/route.ts @@ -0,0 +1,171 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { prisma } from "@/lib/prisma"; + +/** + * Handles the GET request to retrieve events for a specific calendar. + * + * @param req - The incoming request object. + * @param params - An object containing the route parameters. + * @param params.id - The ID of the calendar. + * @returns A JSON response containing the events or an error message. + * + * The function performs the following steps: + * 1. Retrieves the server session to check if the user is authenticated. + * 2. Verifies that the calendar exists and belongs to the authenticated user. + * 3. Retrieves and filters events based on optional date parameters (`start` and `end`). + * 4. Returns the filtered events in ascending order of their start date. + * + * Possible response statuses: + * - 200: Successfully retrieved events. + * - 401: User is not authenticated. + * - 403: User is not authorized to access the calendar. + * - 404: Calendar not found. + * - 500: Server error occurred while retrieving events. + */ +export async function GET( + req: NextRequest, + { params }: { params: { id: string } } +) { + const session = await getServerSession(authOptions); + + if (!session?.user?.username) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } + + try { + // Vérifier que le calendrier appartient à l'utilisateur + const calendar = await prisma.calendar.findUnique({ + where: { + id: params.id, + }, + }); + + if (!calendar) { + return NextResponse.json( + { error: "Calendrier non trouvé" }, + { status: 404 } + ); + } + + if (calendar.userId !== session.user.username) { + return NextResponse.json({ error: "Non autorisé" }, { status: 403 }); + } + + // Récupérer les paramètres de filtrage de date s'ils existent + const { searchParams } = new URL(req.url); + const startParam = searchParams.get("start"); + const endParam = searchParams.get("end"); + + let whereClause: any = { + calendarId: params.id, + }; + + if (startParam && endParam) { + whereClause.AND = [ + { + start: { + lte: new Date(endParam), + }, + }, + { + end: { + gte: new Date(startParam), + }, + }, + ]; + } + + const events = await prisma.event.findMany({ + where: whereClause, + orderBy: { + start: "asc", + }, + }); + + return NextResponse.json(events); + } catch (error) { + console.error("Erreur lors de la récupération des événements:", error); + return NextResponse.json({ error: "Erreur serveur" }, { status: 500 }); + } +} + +/** + * Handles the creation of a new event for a specific calendar. + * + * @param req - The incoming request object. + * @param params - An object containing the route parameters. + * @param params.id - The ID of the calendar to which the event will be added. + * @returns A JSON response with the created event data or an error message. + * + * @throws {401} If the user is not authenticated. + * @throws {404} If the specified calendar is not found. + * @throws {403} If the user is not authorized to add events to the specified calendar. + * @throws {400} If the required fields (title, start, end) are missing. + * @throws {500} If there is a server error during event creation. + */ +export async function POST( + req: NextRequest, + { params }: { params: { id: string } } +) { + const session = await getServerSession(authOptions); + + if (!session?.user?.username) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } + + try { + const calendar = await prisma.calendar.findUnique({ + where: { + id: params.id, + }, + }); + + if (!calendar) { + return NextResponse.json( + { error: "Calendrier non trouvé" }, + { status: 404 } + ); + } + + if (calendar.userId !== session.user.username) { + return NextResponse.json({ error: "Non autorisé" }, { status: 403 }); + } + + const { title, description, start, end, location, isAllDay } = + await req.json(); + + // Validation + if (!title) { + return NextResponse.json( + { error: "Le titre est requis" }, + { status: 400 } + ); + } + + if (!start || !end) { + return NextResponse.json( + { error: "Les dates de début et de fin sont requises" }, + { status: 400 } + ); + } + + const event = await prisma.event.create({ + data: { + title, + description, + start: new Date(start), + end: new Date(end), + location, + isAllDay: isAllDay || false, + calendarId: params.id, + }, + }); + + return NextResponse.json(event, { status: 201 }); + } catch (error) { + console.error("Erreur lors de la création de l'événement:", error); + return NextResponse.json({ error: "Erreur serveur" }, { status: 500 }); + } +} diff --git a/front/app/api/calendars/[id]/route.ts b/front/app/api/calendars/[id]/route.ts new file mode 100644 index 0000000..fefca9f --- /dev/null +++ b/front/app/api/calendars/[id]/route.ts @@ -0,0 +1,181 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { prisma } from "@/lib/prisma"; + +/** + * Handles GET requests to retrieve a calendar by its ID. + * + * @param req - The incoming request object. + * @param params - An object containing the route parameters. + * @param params.id - The ID of the calendar to retrieve. + * @returns A JSON response containing the calendar data if found and authorized, + * or an error message with the appropriate HTTP status code. + * + * - 401: If the user is not authenticated. + * - 403: If the user is not authorized to access the calendar. + * - 404: If the calendar is not found. + * - 500: If there is a server error during the retrieval process. + */ +export async function GET( + req: NextRequest, + { params }: { params: { id: string } } +) { + const session = await getServerSession(authOptions); + + if (!session?.user?.username) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } + + try { + const calendar = await prisma.calendar.findUnique({ + where: { + id: params.id, + }, + }); + + if (!calendar) { + return NextResponse.json( + { error: "Calendrier non trouvé" }, + { status: 404 } + ); + } + + // Vérification que l'utilisateur est bien le propriétaire + if (calendar.userId !== session.user.username) { + return NextResponse.json({ error: "Non autorisé" }, { status: 403 }); + } + + return NextResponse.json(calendar); + } catch (error) { + console.error("Erreur lors de la récupération du calendrier:", error); + return NextResponse.json({ error: "Erreur serveur" }, { status: 500 }); + } +} + +/** + * Handles the PUT request to update a calendar. + * + * @param req - The incoming request object. + * @param params - An object containing the route parameters. + * @param params.id - The ID of the calendar to update. + * @returns A JSON response with the updated calendar data or an error message. + * + * @throws {401} If the user is not authenticated. + * @throws {404} If the calendar is not found. + * @throws {403} If the user is not authorized to update the calendar. + * @throws {400} If the calendar name is not provided. + * @throws {500} If there is a server error during the update process. + */ +export async function PUT( + req: NextRequest, + { params }: { params: { id: string } } +) { + const session = await getServerSession(authOptions); + + if (!session?.user?.username) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } + + try { + // Vérifier que le calendrier existe et appartient à l'utilisateur + const existingCalendar = await prisma.calendar.findUnique({ + where: { + id: params.id, + }, + }); + + if (!existingCalendar) { + return NextResponse.json( + { error: "Calendrier non trouvé" }, + { status: 404 } + ); + } + + if (existingCalendar.userId !== session.user.username) { + return NextResponse.json({ error: "Non autorisé" }, { status: 403 }); + } + + const { name, color, description } = await req.json(); + + // Validation + if (!name) { + return NextResponse.json( + { error: "Le nom du calendrier est requis" }, + { status: 400 } + ); + } + + const updatedCalendar = await prisma.calendar.update({ + where: { + id: params.id, + }, + data: { + name, + color, + description, + }, + }); + + return NextResponse.json(updatedCalendar); + } catch (error) { + console.error("Erreur lors de la mise à jour du calendrier:", error); + return NextResponse.json({ error: "Erreur serveur" }, { status: 500 }); + } +} + +/** + * Handles the DELETE request to remove a calendar by its ID. + * + * @param req - The incoming Next.js request object. + * @param params - An object containing the route parameters. + * @param params.id - The ID of the calendar to be deleted. + * @returns A JSON response indicating the result of the deletion operation. + * + * - If the user is not authenticated, returns a 401 status with an error message. + * - If the calendar does not exist, returns a 404 status with an error message. + * - If the calendar does not belong to the authenticated user, returns a 403 status with an error message. + * - If the calendar is successfully deleted, returns a 204 status with no content. + * - If an error occurs during the deletion process, returns a 500 status with an error message. + */ +export async function DELETE( + req: NextRequest, + { params }: { params: { id: string } } +) { + const session = await getServerSession(authOptions); + + if (!session?.user?.username) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } + + try { + // Vérifier que le calendrier existe et appartient à l'utilisateur + const existingCalendar = await prisma.calendar.findUnique({ + where: { + id: params.id, + }, + }); + + if (!existingCalendar) { + return NextResponse.json( + { error: "Calendrier non trouvé" }, + { status: 404 } + ); + } + + if (existingCalendar.userId !== session.user.username) { + return NextResponse.json({ error: "Non autorisé" }, { status: 403 }); + } + + await prisma.calendar.delete({ + where: { + id: params.id, + }, + }); + + return new NextResponse(null, { status: 204 }); + } catch (error) { + console.error("Erreur lors de la suppression du calendrier:", error); + return NextResponse.json({ error: "Erreur serveur" }, { status: 500 }); + } +} diff --git a/front/app/api/calendars/[id]/share/route.ts b/front/app/api/calendars/[id]/share/route.ts new file mode 100644 index 0000000..cc76aa4 --- /dev/null +++ b/front/app/api/calendars/[id]/share/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { prisma } from "@/lib/prisma"; +import crypto from "crypto"; + +// Non testé, généré automatiquement par IA +export async function POST( + req: NextRequest, + { params }: { params: { id: string } } +) { + const session = await getServerSession(authOptions); + + if (!session?.user?.username) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } + + try { + // Vérifier que le calendrier appartient à l'utilisateur + const calendar = await prisma.calendar.findUnique({ + where: { + id: params.id, + }, + }); + + if (!calendar) { + return NextResponse.json( + { error: "Calendrier non trouvé" }, + { status: 404 } + ); + } + + if (calendar.userId !== session.user.username) { + return NextResponse.json({ error: "Non autorisé" }, { status: 403 }); + } + + // Générer un token de partage + const shareToken = crypto.randomBytes(32).toString("hex"); + + // Dans une implémentation réelle, on stockerait ce token dans la base de données + // avec une date d'expiration et des permissions + + const shareUrl = `${process.env.NEXT_PUBLIC_BASE_URL}/calendars/shared/${shareToken}`; + + return NextResponse.json({ + shareUrl, + shareToken, + }); + } catch (error) { + console.error("Erreur lors de la création du lien de partage:", error); + return NextResponse.json({ error: "Erreur serveur" }, { status: 500 }); + } +} diff --git a/front/app/api/calendars/default/route.ts b/front/app/api/calendars/default/route.ts new file mode 100644 index 0000000..d4adc3b --- /dev/null +++ b/front/app/api/calendars/default/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { prisma } from "@/lib/prisma"; + +/** + * Handles the creation of a default calendar for an authenticated user. + * + * This function checks if the user already has a default calendar named "Calendrier principal". + * If such a calendar exists, it returns the existing calendar. + * Otherwise, it creates a new default calendar for the user. + * + * @param req - The incoming request object. + * @returns A JSON response containing the existing or newly created calendar, or an error message. + * + * @throws Will return a 401 status if the user is not authenticated. + * @throws Will return a 500 status if there is a server error during the calendar creation process. + */ +export async function POST(req: NextRequest) { + const session = await getServerSession(authOptions); + + if (!session?.user?.username) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } + + try { + // Vérifier si l'utilisateur a déjà un calendrier par défaut + const existingCalendar = await prisma.calendar.findFirst({ + where: { + userId: session.user.username, + name: "Calendrier principal", + }, + }); + + if (existingCalendar) { + return NextResponse.json(existingCalendar); + } + + // Créer un calendrier par défaut + const calendar = await prisma.calendar.create({ + data: { + name: "Calendrier principal", + color: "#0082c9", + description: "Calendrier principal", + userId: session.user.username, + }, + }); + + return NextResponse.json(calendar, { status: 201 }); + } catch (error) { + console.error( + "Erreur lors de la création du calendrier par défaut:", + error + ); + return NextResponse.json({ error: "Erreur serveur" }, { status: 500 }); + } +} diff --git a/front/app/api/calendars/route.ts b/front/app/api/calendars/route.ts new file mode 100644 index 0000000..70557ba --- /dev/null +++ b/front/app/api/calendars/route.ts @@ -0,0 +1,93 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { prisma } from "@/lib/prisma"; + +/** + * Handles the GET request to retrieve calendars for the authenticated user. + * + * @param {NextRequest} req - The incoming request object. + * @returns {Promise} - A promise that resolves to a JSON response containing the calendars or an error message. + * + * The function performs the following steps: + * 1. Retrieves the server session using `getServerSession`. + * 2. Checks if the user is authenticated by verifying the presence of `session.user.username`. + * - If not authenticated, returns a 401 response with an error message. + * 3. Attempts to fetch the calendars associated with the authenticated user from the database. + * - If successful, returns the calendars in a JSON response. + * - If an error occurs during the database query, logs the error and returns a 500 response with an error message. + */ +export async function GET(req: NextRequest) { + const session = await getServerSession(authOptions); + + if (!session?.user?.username) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } + + try { + const calendars = await prisma.calendar.findMany({ + where: { + userId: session.user.username, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return NextResponse.json(calendars); + } catch (error) { + console.error("Erreur lors de la récupération des calendriers:", error); + return NextResponse.json({ error: "Erreur serveur" }, { status: 500 }); + } +} + +/** + * Handles the POST request to create a new calendar. + * + * @param {NextRequest} req - The incoming request object. + * @returns {Promise} The response object containing the created calendar or an error message. + * + * @throws {Error} If there is an issue with the request or server. + * + * The function performs the following steps: + * 1. Retrieves the server session using `getServerSession`. + * 2. Checks if the user is authenticated by verifying the presence of `session.user.username`. + * 3. Parses the request body to extract `name`, `color`, and `description`. + * 4. Validates that the `name` field is provided. + * 5. Creates a new calendar entry in the database using Prisma. + * 6. Returns the created calendar with a 201 status code. + * 7. Catches and logs any errors, returning a 500 status code with an error message. + */ +export async function POST(req: NextRequest) { + const session = await getServerSession(authOptions); + + if (!session?.user?.username) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } + + try { + const { name, color, description } = await req.json(); + + // Validation + if (!name) { + return NextResponse.json( + { error: "Le nom du calendrier est requis" }, + { status: 400 } + ); + } + + const calendar = await prisma.calendar.create({ + data: { + name, + color: color || "#0082c9", + description, + userId: session.user.username, + }, + }); + + return NextResponse.json(calendar, { status: 201 }); + } catch (error) { + console.error("Erreur lors de la création du calendrier:", error); + return NextResponse.json({ error: "Erreur serveur" }, { status: 500 }); + } +} diff --git a/front/app/calendar/page.tsx b/front/app/calendar/page.tsx new file mode 100644 index 0000000..335b958 --- /dev/null +++ b/front/app/calendar/page.tsx @@ -0,0 +1,54 @@ +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { redirect } from "next/navigation"; +import { prisma } from "@/lib/prisma"; +import { CalendarClient } from "@/components/calendar/calendar-client"; + +export const metadata = { + title: "Enkun - Calendrier", + description: "Gérez vos rendez-vous et événements", +}; + +export default async function CalendarPage() { + const session = await getServerSession(authOptions); + + if (!session?.user) { + redirect("/api/auth/signin"); + } + + // Récupérer tous les calendriers de l'utilisateur + const userCalendars = await prisma.calendar.findMany({ + where: { + userId: session.user.username || session.user.email, + }, + orderBy: { + createdAt: "desc", + }, + }); + + // Si aucun calendrier n'existe, en créer un par défaut + let calendars = userCalendars; + if (calendars.length === 0) { + const defaultCalendar = await prisma.calendar.create({ + data: { + name: "Calendrier principal", + color: "#0082c9", + description: "Calendrier par défaut", + userId: session.user.username || session.user.email, + }, + }); + calendars = [defaultCalendar]; + } + + return ( +
+
+

Calendrier

+

+ Gérez vos rendez-vous et événements +

+
+ +
+ ); +} diff --git a/front/components/calendar/calendar-client.tsx b/front/components/calendar/calendar-client.tsx new file mode 100644 index 0000000..9de0282 --- /dev/null +++ b/front/components/calendar/calendar-client.tsx @@ -0,0 +1,268 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import FullCalendar from "@fullcalendar/react"; +import dayGridPlugin from "@fullcalendar/daygrid"; +import timeGridPlugin from "@fullcalendar/timegrid"; +import interactionPlugin from "@fullcalendar/interaction"; +import frLocale from "@fullcalendar/core/locales/fr"; +import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Loader2, Plus } from "lucide-react"; +import { useCalendarEvents } from "@/hooks/use-calendar-events"; +import { EventDialog } from "@/components/calendar/event-dialog"; +import { Calendar as CalendarType } from "@prisma/client"; +import { useToast } from "@/components/ui/use-toast"; + +interface CalendarClientProps { + initialCalendars: CalendarType[]; +} + +export function CalendarClient({ initialCalendars }: CalendarClientProps) { + const [calendars, setCalendars] = useState(initialCalendars); + const [selectedCalendarId, setSelectedCalendarId] = useState( + initialCalendars[0]?.id || "" + ); + const [view, setView] = useState< + "dayGridMonth" | "timeGridWeek" | "timeGridDay" + >("dayGridMonth"); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [selectedEvent, setSelectedEvent] = useState(null); + const [dateRange, setDateRange] = useState({ + start: new Date(), + end: new Date(new Date().setMonth(new Date().getMonth() + 1)), + }); + + const calendarRef = useRef(null); + const { toast } = useToast(); + + const { + events, + loading, + error, + refresh, + createEvent, + updateEvent, + deleteEvent, + } = useCalendarEvents(selectedCalendarId, dateRange.start, dateRange.end); + + // Mettre à jour la plage de dates lorsque la vue change + const handleDatesSet = (arg: any) => { + setDateRange({ + start: arg.start, + end: arg.end, + }); + }; + + // Gérer la sélection d'une plage de dates pour créer un événement + const handleDateSelect = (selectInfo: any) => { + setSelectedEvent({ + start: selectInfo.startStr, + end: selectInfo.endStr, + allDay: selectInfo.allDay, + }); + setIsDialogOpen(true); + }; + + // Gérer le clic sur un événement existant + const handleEventClick = (clickInfo: any) => { + setSelectedEvent({ + id: clickInfo.event.id, + title: clickInfo.event.title, + description: clickInfo.event.extendedProps.description, + start: clickInfo.event.startStr, + end: clickInfo.event.endStr, + location: clickInfo.event.extendedProps.location, + allDay: clickInfo.event.allDay, + }); + setIsDialogOpen(true); + }; + + // Gérer la création ou mise à jour d'un événement + const handleEventSave = async (eventData: any) => { + try { + if (eventData.id) { + await updateEvent(eventData); + toast({ + title: "Événement mis à jour", + description: "L'événement a été modifié avec succès.", + }); + } else { + await createEvent({ + ...eventData, + calendarId: selectedCalendarId, + }); + toast({ + title: "Événement créé", + description: "L'événement a été ajouté au calendrier.", + }); + } + setIsDialogOpen(false); + refresh(); + } catch (error) { + console.error("Erreur lors de la sauvegarde de l'événement:", error); + toast({ + title: "Erreur", + description: "Impossible d'enregistrer l'événement.", + variant: "destructive", + }); + } + }; + + // Gérer la suppression d'un événement + const handleEventDelete = async (eventId: string) => { + try { + await deleteEvent(eventId); + toast({ + title: "Événement supprimé", + description: "L'événement a été supprimé du calendrier.", + }); + setIsDialogOpen(false); + refresh(); + } catch (error) { + console.error("Erreur lors de la suppression de l'événement:", error); + toast({ + title: "Erreur", + description: "Impossible de supprimer l'événement.", + variant: "destructive", + }); + } + }; + + // Changer la vue du calendrier + const handleViewChange = ( + newView: "dayGridMonth" | "timeGridWeek" | "timeGridDay" + ) => { + setView(newView); + if (calendarRef.current) { + const calendarApi = calendarRef.current.getApi(); + calendarApi.changeView(newView); + } + }; + + // Changer le calendrier sélectionné + const handleCalendarChange = (calendarId: string) => { + setSelectedCalendarId(calendarId); + }; + + return ( +
+ {/* Options et filtres du calendrier */} +
+
+ {calendars.map((calendar) => ( + + ))} +
+ +
+ + {/* Sélecteur de vue */} + + + handleViewChange("dayGridMonth")} + > + Mois + + handleViewChange("timeGridWeek")} + > + Semaine + + handleViewChange("timeGridDay")} + > + Jour + + + + {/* Affichage du calendrier */} + + {error && ( +
+ Erreur: {error.message} +
+ )} + + {loading && !events.length ? ( +
+ + Chargement des événements... +
+ ) : ( + ({ + id: event.id, + title: event.title, + start: event.start, + end: event.end, + allDay: event.isAllDay, + extendedProps: { + description: event.description, + location: event.location, + }, + }))} + selectable={true} + selectMirror={true} + dayMaxEvents={true} + weekends={true} + locale={frLocale} + select={handleDateSelect} + eventClick={handleEventClick} + datesSet={handleDatesSet} + height='auto' + aspectRatio={1.8} + /> + )} +
+
+ + {/* Dialogue pour créer/modifier un événement */} + {isDialogOpen && ( + setIsDialogOpen(false)} + onSave={handleEventSave} + onDelete={selectedEvent?.id ? handleEventDelete : undefined} + /> + )} +
+ ); +} diff --git a/front/components/calendar/event-dialog.tsx b/front/components/calendar/event-dialog.tsx new file mode 100644 index 0000000..a75745a --- /dev/null +++ b/front/components/calendar/event-dialog.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Checkbox } from "@/components/ui/checkbox"; +import { parseISO, format } from "date-fns"; +import { fr } from "date-fns/locale"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "../ui/alert-dialog"; + +interface EventDialogProps { + open: boolean; + event?: any; + onClose: () => void; + onSave: (event: any) => void; + onDelete?: (eventId: string) => void; +} + +export function EventDialog({ + open, + event, + onClose, + onSave, + onDelete, +}: EventDialogProps) { + const [title, setTitle] = useState(event?.title || ""); + const [description, setDescription] = useState(event?.description || ""); + const [location, setLocation] = useState(event?.location || ""); + const [start, setStart] = useState(event?.start || ""); + const [end, setEnd] = useState(event?.end || ""); + const [allDay, setAllDay] = useState(event?.allDay || false); + const [confirmDelete, setConfirmDelete] = useState(false); + + // Formater les dates pour l'affichage + const formatDate = (dateStr: string) => { + if (!dateStr) return ""; + try { + const date = parseISO(dateStr); + return format(date, allDay ? "yyyy-MM-dd" : "yyyy-MM-dd'T'HH:mm", { + locale: fr, + }); + } catch (e) { + return dateStr; + } + }; + + // Gérer le changement de l'option "Toute la journée" + const handleAllDayChange = (checked: boolean) => { + setAllDay(checked); + + // Ajuster les dates si nécessaire + if (checked && start) { + const startDate = parseISO(start); + setStart(format(startDate, "yyyy-MM-dd")); + + if (end) { + const endDate = parseISO(end); + setEnd(format(endDate, "yyyy-MM-dd")); + } + } + }; + + // Enregistrer l'événement + const handleSave = () => { + onSave({ + id: event?.id, + title, + description, + location, + start, + end, + isAllDay: allDay, + }); + }; + + // Supprimer l'événement + const handleDelete = () => { + if (onDelete && event?.id) { + onDelete(event.id); + } + }; + + return ( + <> + + + + + {event?.id ? "Modifier l'événement" : "Nouvel événement"} + + + +
+
+ + setTitle(e.target.value)} + placeholder='Ajouter un titre' + required + /> +
+ +
+ +