From d103b34023e5ad27b50c438d61a61d7e487a6cee Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Tue, 3 Mar 2026 13:54:53 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Permettre=20la=20cr=C3=A9ation=20et=20m?= =?UTF-8?q?odification=20de=20l'emploi=20du=20temps=20des=20classes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'administration a besoin de construire et maintenir les emplois du temps hebdomadaires pour chaque classe, en s'assurant que les enseignants ne sont pas en conflit (même créneau, classes différentes) et que les affectations enseignant-matière-classe sont respectées. Cette implémentation couvre le CRUD complet des créneaux (ScheduleSlot), la détection de conflits (classe, enseignant, salle) avec possibilité de forcer, la validation des affectations côté serveur (AC2), l'intégration calendrier pour les jours bloqués, une vue mobile-first avec onglets jour par jour, et le drag-and-drop pour réorganiser les créneaux sur desktop. --- backend/config/services.yaml | 10 + backend/migrations/Version20260302091704.php | 52 + .../CreateScheduleSlotCommand.php | 22 + .../CreateScheduleSlotHandler.php | 68 + .../DeleteScheduleSlotCommand.php | 14 + .../DeleteScheduleSlotHandler.php | 38 + .../UpdateScheduleSlotCommand.php | 22 + .../UpdateScheduleSlotHandler.php | 84 + .../Port/EnseignantAffectationChecker.php | 25 + .../Query/GetBlockedDates/BlockedDateDto.php | 15 + .../GetBlockedDatesHandler.php | 70 + .../GetBlockedDates/GetBlockedDatesQuery.php | 16 + .../GetScheduleSlotsHandler.php | 55 + .../GetScheduleSlotsQuery.php | 15 + .../GetScheduleSlots/ScheduleSlotDto.php | 43 + .../src/Scolarite/Domain/Event/CoursCree.php | 43 + .../Scolarite/Domain/Event/CoursModifie.php | 43 + .../Scolarite/Domain/Event/CoursSupprime.php | 36 + .../CreneauHoraireInvalideException.php | 25 + .../EnseignantNonAffecteException.php | 23 + .../ScheduleSlotNotFoundException.php | 16 + .../Domain/Model/Schedule/DayOfWeek.php | 29 + .../Domain/Model/Schedule/ScheduleSlot.php | 180 ++ .../Domain/Model/Schedule/ScheduleSlotId.php | 11 + .../Domain/Model/Schedule/TimeSlot.php | 74 + .../Repository/ScheduleSlotRepository.php | 61 + .../Domain/Service/ScheduleConflict.php | 23 + .../Service/ScheduleConflictDetector.php | 90 + .../Processor/CreateScheduleSlotProcessor.php | 105 ++ .../Processor/DeleteScheduleSlotProcessor.php | 77 + .../Processor/UpdateScheduleSlotProcessor.php | 116 ++ .../BlockedDateCollectionProvider.php | 76 + .../ScheduleSlotCollectionProvider.php | 62 + .../Api/Provider/ScheduleSlotItemProvider.php | 63 + .../Api/Resource/BlockedDateResource.php | 41 + .../Api/Resource/ScheduleSlotResource.php | 149 ++ .../DoctrineScheduleSlotRepository.php | 278 +++ .../InMemoryScheduleSlotRepository.php | 149 ++ .../Security/ScheduleSlotVoter.php | 97 ++ ...urrentYearEnseignantAffectationChecker.php | 49 + .../CreateScheduleSlotHandlerTest.php | 196 +++ .../DeleteScheduleSlotHandlerTest.php | 105 ++ .../UpdateScheduleSlotHandlerTest.php | 221 +++ .../GetBlockedDatesHandlerTest.php | 149 ++ .../GetScheduleSlotsHandlerTest.php | 176 ++ .../Model/Schedule/ScheduleSlotTest.php | 193 +++ .../Domain/Model/Schedule/TimeSlotTest.php | 120 ++ .../Service/ScheduleConflictDetectorTest.php | 281 +++ frontend/e2e/schedule-advanced.spec.ts | 524 ++++++ frontend/e2e/schedule.spec.ts | 417 +++++ .../organisms/Dashboard/DashboardAdmin.svelte | 5 + frontend/src/routes/admin/+layout.svelte | 3 +- .../src/routes/admin/schedule/+page.svelte | 1528 +++++++++++++++++ 53 files changed, 6382 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/Version20260302091704.php create mode 100644 backend/src/Scolarite/Application/Command/CreateScheduleSlot/CreateScheduleSlotCommand.php create mode 100644 backend/src/Scolarite/Application/Command/CreateScheduleSlot/CreateScheduleSlotHandler.php create mode 100644 backend/src/Scolarite/Application/Command/DeleteScheduleSlot/DeleteScheduleSlotCommand.php create mode 100644 backend/src/Scolarite/Application/Command/DeleteScheduleSlot/DeleteScheduleSlotHandler.php create mode 100644 backend/src/Scolarite/Application/Command/UpdateScheduleSlot/UpdateScheduleSlotCommand.php create mode 100644 backend/src/Scolarite/Application/Command/UpdateScheduleSlot/UpdateScheduleSlotHandler.php create mode 100644 backend/src/Scolarite/Application/Port/EnseignantAffectationChecker.php create mode 100644 backend/src/Scolarite/Application/Query/GetBlockedDates/BlockedDateDto.php create mode 100644 backend/src/Scolarite/Application/Query/GetBlockedDates/GetBlockedDatesHandler.php create mode 100644 backend/src/Scolarite/Application/Query/GetBlockedDates/GetBlockedDatesQuery.php create mode 100644 backend/src/Scolarite/Application/Query/GetScheduleSlots/GetScheduleSlotsHandler.php create mode 100644 backend/src/Scolarite/Application/Query/GetScheduleSlots/GetScheduleSlotsQuery.php create mode 100644 backend/src/Scolarite/Application/Query/GetScheduleSlots/ScheduleSlotDto.php create mode 100644 backend/src/Scolarite/Domain/Event/CoursCree.php create mode 100644 backend/src/Scolarite/Domain/Event/CoursModifie.php create mode 100644 backend/src/Scolarite/Domain/Event/CoursSupprime.php create mode 100644 backend/src/Scolarite/Domain/Exception/CreneauHoraireInvalideException.php create mode 100644 backend/src/Scolarite/Domain/Exception/EnseignantNonAffecteException.php create mode 100644 backend/src/Scolarite/Domain/Exception/ScheduleSlotNotFoundException.php create mode 100644 backend/src/Scolarite/Domain/Model/Schedule/DayOfWeek.php create mode 100644 backend/src/Scolarite/Domain/Model/Schedule/ScheduleSlot.php create mode 100644 backend/src/Scolarite/Domain/Model/Schedule/ScheduleSlotId.php create mode 100644 backend/src/Scolarite/Domain/Model/Schedule/TimeSlot.php create mode 100644 backend/src/Scolarite/Domain/Repository/ScheduleSlotRepository.php create mode 100644 backend/src/Scolarite/Domain/Service/ScheduleConflict.php create mode 100644 backend/src/Scolarite/Domain/Service/ScheduleConflictDetector.php create mode 100644 backend/src/Scolarite/Infrastructure/Api/Processor/CreateScheduleSlotProcessor.php create mode 100644 backend/src/Scolarite/Infrastructure/Api/Processor/DeleteScheduleSlotProcessor.php create mode 100644 backend/src/Scolarite/Infrastructure/Api/Processor/UpdateScheduleSlotProcessor.php create mode 100644 backend/src/Scolarite/Infrastructure/Api/Provider/BlockedDateCollectionProvider.php create mode 100644 backend/src/Scolarite/Infrastructure/Api/Provider/ScheduleSlotCollectionProvider.php create mode 100644 backend/src/Scolarite/Infrastructure/Api/Provider/ScheduleSlotItemProvider.php create mode 100644 backend/src/Scolarite/Infrastructure/Api/Resource/BlockedDateResource.php create mode 100644 backend/src/Scolarite/Infrastructure/Api/Resource/ScheduleSlotResource.php create mode 100644 backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineScheduleSlotRepository.php create mode 100644 backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryScheduleSlotRepository.php create mode 100644 backend/src/Scolarite/Infrastructure/Security/ScheduleSlotVoter.php create mode 100644 backend/src/Scolarite/Infrastructure/Service/CurrentYearEnseignantAffectationChecker.php create mode 100644 backend/tests/Unit/Scolarite/Application/Command/CreateScheduleSlot/CreateScheduleSlotHandlerTest.php create mode 100644 backend/tests/Unit/Scolarite/Application/Command/DeleteScheduleSlot/DeleteScheduleSlotHandlerTest.php create mode 100644 backend/tests/Unit/Scolarite/Application/Command/UpdateScheduleSlot/UpdateScheduleSlotHandlerTest.php create mode 100644 backend/tests/Unit/Scolarite/Application/Query/GetBlockedDates/GetBlockedDatesHandlerTest.php create mode 100644 backend/tests/Unit/Scolarite/Application/Query/GetScheduleSlots/GetScheduleSlotsHandlerTest.php create mode 100644 backend/tests/Unit/Scolarite/Domain/Model/Schedule/ScheduleSlotTest.php create mode 100644 backend/tests/Unit/Scolarite/Domain/Model/Schedule/TimeSlotTest.php create mode 100644 backend/tests/Unit/Scolarite/Domain/Service/ScheduleConflictDetectorTest.php create mode 100644 frontend/e2e/schedule-advanced.spec.ts create mode 100644 frontend/e2e/schedule.spec.ts create mode 100644 frontend/src/routes/admin/schedule/+page.svelte diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 5c440b6..8dd1307 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -175,6 +175,16 @@ services: App\Scolarite\Domain\Repository\TeacherReplacementRepository: alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineTeacherReplacementRepository + # Schedule (Story 4.1 - Emploi du temps) + App\Scolarite\Domain\Repository\ScheduleSlotRepository: + alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineScheduleSlotRepository + + App\Scolarite\Domain\Service\ScheduleConflictDetector: + autowire: true + + App\Scolarite\Application\Port\EnseignantAffectationChecker: + alias: App\Scolarite\Infrastructure\Service\CurrentYearEnseignantAffectationChecker + # Super Admin Repositories (Story 2.10 - Multi-établissements) App\SuperAdmin\Domain\Repository\SuperAdminRepository: alias: App\SuperAdmin\Infrastructure\Persistence\Doctrine\DoctrineSuperAdminRepository diff --git a/backend/migrations/Version20260302091704.php b/backend/migrations/Version20260302091704.php new file mode 100644 index 0000000..3348eea --- /dev/null +++ b/backend/migrations/Version20260302091704.php @@ -0,0 +1,52 @@ +addSql(<<<'SQL' + CREATE TABLE schedule_slots ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + class_id UUID NOT NULL, + subject_id UUID NOT NULL, + teacher_id UUID NOT NULL, + day_of_week SMALLINT NOT NULL CHECK (day_of_week BETWEEN 1 AND 7), + start_time VARCHAR(5) NOT NULL, + end_time VARCHAR(5) NOT NULL, + room VARCHAR(50), + is_recurring BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT valid_times CHECK (end_time > start_time), + CONSTRAINT fk_schedule_class FOREIGN KEY (class_id) REFERENCES school_classes(id) ON DELETE CASCADE, + CONSTRAINT fk_schedule_subject FOREIGN KEY (subject_id) REFERENCES subjects(id) ON DELETE CASCADE, + CONSTRAINT fk_schedule_teacher FOREIGN KEY (teacher_id) REFERENCES users(id) ON DELETE CASCADE + ) + SQL); + + $this->addSql('CREATE INDEX idx_schedule_tenant ON schedule_slots(tenant_id)'); + $this->addSql('CREATE INDEX idx_schedule_class ON schedule_slots(class_id)'); + $this->addSql('CREATE INDEX idx_schedule_teacher ON schedule_slots(teacher_id)'); + $this->addSql('CREATE INDEX idx_schedule_day ON schedule_slots(day_of_week)'); + $this->addSql('CREATE INDEX idx_schedule_teacher_day ON schedule_slots(tenant_id, teacher_id, day_of_week)'); + $this->addSql('CREATE INDEX idx_schedule_room_day ON schedule_slots(tenant_id, room, day_of_week) WHERE room IS NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE schedule_slots'); + } +} diff --git a/backend/src/Scolarite/Application/Command/CreateScheduleSlot/CreateScheduleSlotCommand.php b/backend/src/Scolarite/Application/Command/CreateScheduleSlot/CreateScheduleSlotCommand.php new file mode 100644 index 0000000..acb0e26 --- /dev/null +++ b/backend/src/Scolarite/Application/Command/CreateScheduleSlot/CreateScheduleSlotCommand.php @@ -0,0 +1,22 @@ +} + */ + public function __invoke(CreateScheduleSlotCommand $command): array + { + $tenantId = TenantId::fromString($command->tenantId); + $classId = ClassId::fromString($command->classId); + $subjectId = SubjectId::fromString($command->subjectId); + $teacherId = UserId::fromString($command->teacherId); + $timeSlot = new TimeSlot($command->startTime, $command->endTime); + + if (!$this->affectationChecker->estAffecte($teacherId, $classId, $subjectId, $tenantId)) { + throw EnseignantNonAffecteException::pourClasseEtMatiere($teacherId, $classId, $subjectId); + } + + $slot = ScheduleSlot::creer( + tenantId: $tenantId, + classId: $classId, + subjectId: $subjectId, + teacherId: $teacherId, + dayOfWeek: DayOfWeek::from($command->dayOfWeek), + timeSlot: $timeSlot, + room: $command->room, + isRecurring: $command->isRecurring, + now: $this->clock->now(), + ); + + $conflicts = $this->conflictDetector->detectConflicts($slot, $tenantId); + + if ($conflicts === [] || $command->forceConflicts) { + $this->repository->save($slot); + } + + return ['slot' => $slot, 'conflicts' => $conflicts]; + } +} diff --git a/backend/src/Scolarite/Application/Command/DeleteScheduleSlot/DeleteScheduleSlotCommand.php b/backend/src/Scolarite/Application/Command/DeleteScheduleSlot/DeleteScheduleSlotCommand.php new file mode 100644 index 0000000..d9776e1 --- /dev/null +++ b/backend/src/Scolarite/Application/Command/DeleteScheduleSlot/DeleteScheduleSlotCommand.php @@ -0,0 +1,14 @@ +tenantId); + $slotId = ScheduleSlotId::fromString($command->slotId); + + $slot = $this->repository->get($slotId, $tenantId); + + $slot->supprimer($this->clock->now()); + $this->repository->delete($slotId, $tenantId); + + return $slot; + } +} diff --git a/backend/src/Scolarite/Application/Command/UpdateScheduleSlot/UpdateScheduleSlotCommand.php b/backend/src/Scolarite/Application/Command/UpdateScheduleSlot/UpdateScheduleSlotCommand.php new file mode 100644 index 0000000..bb3db63 --- /dev/null +++ b/backend/src/Scolarite/Application/Command/UpdateScheduleSlot/UpdateScheduleSlotCommand.php @@ -0,0 +1,22 @@ +} + */ + public function __invoke(UpdateScheduleSlotCommand $command): array + { + $tenantId = TenantId::fromString($command->tenantId); + $slotId = ScheduleSlotId::fromString($command->slotId); + + $slot = $this->repository->get($slotId, $tenantId); + + $classId = ClassId::fromString($command->classId); + $subjectId = SubjectId::fromString($command->subjectId); + $teacherId = UserId::fromString($command->teacherId); + $dayOfWeek = DayOfWeek::from($command->dayOfWeek); + $newTimeSlot = new TimeSlot($command->startTime, $command->endTime); + + if (!$this->affectationChecker->estAffecte($teacherId, $classId, $subjectId, $tenantId)) { + throw EnseignantNonAffecteException::pourClasseEtMatiere($teacherId, $classId, $subjectId); + } + + // Détecte les conflits avant de modifier le slot. + // On crée un slot temporaire avec les nouvelles valeurs pour la détection. + $preview = ScheduleSlot::creer( + tenantId: $tenantId, + classId: $classId, + subjectId: $subjectId, + teacherId: $teacherId, + dayOfWeek: $dayOfWeek, + timeSlot: $newTimeSlot, + room: $command->room, + isRecurring: $slot->isRecurring, + now: $this->clock->now(), + ); + $conflicts = $this->conflictDetector->detectConflicts($preview, $tenantId, $slotId); + + if ($conflicts === [] || $command->forceConflicts) { + $slot->modifier( + classId: $classId, + subjectId: $subjectId, + teacherId: $teacherId, + dayOfWeek: $dayOfWeek, + timeSlot: $newTimeSlot, + room: $command->room, + at: $this->clock->now(), + ); + $this->repository->save($slot); + } + + return ['slot' => $slot, 'conflicts' => $conflicts]; + } +} diff --git a/backend/src/Scolarite/Application/Port/EnseignantAffectationChecker.php b/backend/src/Scolarite/Application/Port/EnseignantAffectationChecker.php new file mode 100644 index 0000000..55a11ef --- /dev/null +++ b/backend/src/Scolarite/Application/Port/EnseignantAffectationChecker.php @@ -0,0 +1,25 @@ + */ + public function __invoke(GetBlockedDatesQuery $query): array + { + $tenantId = TenantId::fromString($query->tenantId); + $academicYearId = AcademicYearId::fromString($query->academicYearId); + + $calendar = $this->calendarRepository->findByTenantAndYear($tenantId, $academicYearId); + + $startDate = new DateTimeImmutable($query->startDate); + $endDate = new DateTimeImmutable($query->endDate); + $oneDay = new DateInterval('P1D'); + + $blockedDates = []; + $current = $startDate; + + while ($current <= $endDate) { + $dayOfWeek = (int) $current->format('N'); + $dateStr = $current->format('Y-m-d'); + + if ($dayOfWeek >= 6) { + $blockedDates[] = new BlockedDateDto( + date: $dateStr, + reason: $dayOfWeek === 6 ? 'Samedi' : 'Dimanche', + type: 'weekend', + ); + } elseif ($calendar !== null) { + $entry = $calendar->trouverEntreePourDate($current); + + if ($entry !== null) { + $blockedDates[] = new BlockedDateDto( + date: $dateStr, + reason: $entry->label, + type: $entry->type->value, + ); + } + } + + $current = $current->add($oneDay); + } + + return $blockedDates; + } +} diff --git a/backend/src/Scolarite/Application/Query/GetBlockedDates/GetBlockedDatesQuery.php b/backend/src/Scolarite/Application/Query/GetBlockedDates/GetBlockedDatesQuery.php new file mode 100644 index 0000000..b54a2c0 --- /dev/null +++ b/backend/src/Scolarite/Application/Query/GetBlockedDates/GetBlockedDatesQuery.php @@ -0,0 +1,16 @@ + */ + public function __invoke(GetScheduleSlotsQuery $query): array + { + try { + $tenantId = TenantId::fromString($query->tenantId); + + if ($query->classId !== null) { + $slots = $this->repository->findByClass(ClassId::fromString($query->classId), $tenantId); + + if ($query->teacherId !== null) { + $teacherId = UserId::fromString($query->teacherId); + $slots = array_values(array_filter( + $slots, + static fn (ScheduleSlot $slot) => $slot->teacherId->equals($teacherId), + )); + } + } elseif ($query->teacherId !== null) { + $slots = $this->repository->findByTeacher(UserId::fromString($query->teacherId), $tenantId); + } else { + $slots = []; + } + } catch (InvalidUuidStringException) { + return []; + } + + return array_map(ScheduleSlotDto::fromDomain(...), $slots); + } +} diff --git a/backend/src/Scolarite/Application/Query/GetScheduleSlots/GetScheduleSlotsQuery.php b/backend/src/Scolarite/Application/Query/GetScheduleSlots/GetScheduleSlotsQuery.php new file mode 100644 index 0000000..fec5269 --- /dev/null +++ b/backend/src/Scolarite/Application/Query/GetScheduleSlots/GetScheduleSlotsQuery.php @@ -0,0 +1,15 @@ +id, + classId: (string) $slot->classId, + subjectId: (string) $slot->subjectId, + teacherId: (string) $slot->teacherId, + dayOfWeek: $slot->dayOfWeek->value, + startTime: $slot->timeSlot->startTime, + endTime: $slot->timeSlot->endTime, + room: $slot->room, + isRecurring: $slot->isRecurring, + createdAt: $slot->createdAt, + updatedAt: $slot->updatedAt, + ); + } +} diff --git a/backend/src/Scolarite/Domain/Event/CoursCree.php b/backend/src/Scolarite/Domain/Event/CoursCree.php new file mode 100644 index 0000000..7052493 --- /dev/null +++ b/backend/src/Scolarite/Domain/Event/CoursCree.php @@ -0,0 +1,43 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->slotId->value; + } +} diff --git a/backend/src/Scolarite/Domain/Event/CoursModifie.php b/backend/src/Scolarite/Domain/Event/CoursModifie.php new file mode 100644 index 0000000..8fe4b2f --- /dev/null +++ b/backend/src/Scolarite/Domain/Event/CoursModifie.php @@ -0,0 +1,43 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->slotId->value; + } +} diff --git a/backend/src/Scolarite/Domain/Event/CoursSupprime.php b/backend/src/Scolarite/Domain/Event/CoursSupprime.php new file mode 100644 index 0000000..e116cb5 --- /dev/null +++ b/backend/src/Scolarite/Domain/Event/CoursSupprime.php @@ -0,0 +1,36 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->slotId->value; + } +} diff --git a/backend/src/Scolarite/Domain/Exception/CreneauHoraireInvalideException.php b/backend/src/Scolarite/Domain/Exception/CreneauHoraireInvalideException.php new file mode 100644 index 0000000..772d70a --- /dev/null +++ b/backend/src/Scolarite/Domain/Exception/CreneauHoraireInvalideException.php @@ -0,0 +1,25 @@ + 'Lundi', + self::TUESDAY => 'Mardi', + self::WEDNESDAY => 'Mercredi', + self::THURSDAY => 'Jeudi', + self::FRIDAY => 'Vendredi', + self::SATURDAY => 'Samedi', + self::SUNDAY => 'Dimanche', + }; + } +} diff --git a/backend/src/Scolarite/Domain/Model/Schedule/ScheduleSlot.php b/backend/src/Scolarite/Domain/Model/Schedule/ScheduleSlot.php new file mode 100644 index 0000000..602a13b --- /dev/null +++ b/backend/src/Scolarite/Domain/Model/Schedule/ScheduleSlot.php @@ -0,0 +1,180 @@ +updatedAt = $createdAt; + } + + /** + * Crée un nouveau créneau dans l'emploi du temps. + */ + public static function creer( + TenantId $tenantId, + ClassId $classId, + SubjectId $subjectId, + UserId $teacherId, + DayOfWeek $dayOfWeek, + TimeSlot $timeSlot, + ?string $room, + bool $isRecurring, + DateTimeImmutable $now, + ): self { + $room = $room !== '' ? $room : null; + + $slot = new self( + id: ScheduleSlotId::generate(), + tenantId: $tenantId, + classId: $classId, + subjectId: $subjectId, + teacherId: $teacherId, + dayOfWeek: $dayOfWeek, + timeSlot: $timeSlot, + room: $room, + isRecurring: $isRecurring, + createdAt: $now, + ); + + $slot->recordEvent(new CoursCree( + slotId: $slot->id, + classId: $classId, + subjectId: $subjectId, + teacherId: $teacherId, + dayOfWeek: $dayOfWeek, + startTime: $timeSlot->startTime, + endTime: $timeSlot->endTime, + room: $room, + occurredOn: $now, + )); + + return $slot; + } + + /** + * Modifie les propriétés du créneau. + */ + public function modifier( + ClassId $classId, + SubjectId $subjectId, + UserId $teacherId, + DayOfWeek $dayOfWeek, + TimeSlot $timeSlot, + ?string $room, + DateTimeImmutable $at, + ): void { + $room = $room !== '' ? $room : null; + + $this->classId = $classId; + $this->subjectId = $subjectId; + $this->teacherId = $teacherId; + $this->dayOfWeek = $dayOfWeek; + $this->timeSlot = $timeSlot; + $this->room = $room; + $this->updatedAt = $at; + + $this->recordEvent(new CoursModifie( + slotId: $this->id, + classId: $classId, + subjectId: $subjectId, + teacherId: $teacherId, + dayOfWeek: $dayOfWeek, + startTime: $timeSlot->startTime, + endTime: $timeSlot->endTime, + room: $room, + occurredOn: $at, + )); + } + + /** + * Enregistre l'événement de suppression avant le hard-delete par le repository. + */ + public function supprimer(DateTimeImmutable $at): void + { + $this->recordEvent(new CoursSupprime( + slotId: $this->id, + classId: $this->classId, + subjectId: $this->subjectId, + occurredOn: $at, + )); + } + + /** + * Vérifie si ce créneau entre en conflit temporel avec un autre sur le même jour. + */ + public function conflictsAvec(self $other): bool + { + return $this->dayOfWeek === $other->dayOfWeek + && $this->timeSlot->overlaps($other->timeSlot); + } + + /** + * Reconstitue un ScheduleSlot depuis le stockage. + * + * @internal Pour usage Infrastructure uniquement + */ + public static function reconstitute( + ScheduleSlotId $id, + TenantId $tenantId, + ClassId $classId, + SubjectId $subjectId, + UserId $teacherId, + DayOfWeek $dayOfWeek, + TimeSlot $timeSlot, + ?string $room, + bool $isRecurring, + DateTimeImmutable $createdAt, + DateTimeImmutable $updatedAt, + ): self { + $slot = new self( + id: $id, + tenantId: $tenantId, + classId: $classId, + subjectId: $subjectId, + teacherId: $teacherId, + dayOfWeek: $dayOfWeek, + timeSlot: $timeSlot, + room: $room, + isRecurring: $isRecurring, + createdAt: $createdAt, + ); + + $slot->updatedAt = $updatedAt; + + return $slot; + } +} diff --git a/backend/src/Scolarite/Domain/Model/Schedule/ScheduleSlotId.php b/backend/src/Scolarite/Domain/Model/Schedule/ScheduleSlotId.php new file mode 100644 index 0000000..64cfbd1 --- /dev/null +++ b/backend/src/Scolarite/Domain/Model/Schedule/ScheduleSlotId.php @@ -0,0 +1,11 @@ + début, durée minimum 5 minutes. + */ +final readonly class TimeSlot +{ + private const string TIME_PATTERN = '/^([01]\d|2[0-3]):[0-5]\d$/'; + private const int MINIMUM_DURATION_MINUTES = 5; + + public function __construct( + public string $startTime, + public string $endTime, + ) { + if (preg_match(self::TIME_PATTERN, $startTime) !== 1) { + throw CreneauHoraireInvalideException::formatInvalide($startTime); + } + + if (preg_match(self::TIME_PATTERN, $endTime) !== 1) { + throw CreneauHoraireInvalideException::formatInvalide($endTime); + } + + if ($endTime <= $startTime) { + throw CreneauHoraireInvalideException::finAvantDebut($startTime, $endTime); + } + + $duration = $this->computeDurationInMinutes($startTime, $endTime); + + if ($duration < self::MINIMUM_DURATION_MINUTES) { + throw CreneauHoraireInvalideException::dureeTropCourte($duration); + } + } + + /** + * Vérifie si ce créneau chevauche un autre créneau. + * + * Deux créneaux adjacents (fin de l'un = début de l'autre) ne se chevauchent pas. + */ + public function overlaps(self $other): bool + { + return $this->startTime < $other->endTime && $other->startTime < $this->endTime; + } + + public function equals(self $other): bool + { + return $this->startTime === $other->startTime + && $this->endTime === $other->endTime; + } + + public function durationInMinutes(): int + { + return $this->computeDurationInMinutes($this->startTime, $this->endTime); + } + + private function computeDurationInMinutes(string $start, string $end): int + { + [$startHour, $startMin] = explode(':', $start); + [$endHour, $endMin] = explode(':', $end); + + return ((int) $endHour * 60 + (int) $endMin) - ((int) $startHour * 60 + (int) $startMin); + } +} diff --git a/backend/src/Scolarite/Domain/Repository/ScheduleSlotRepository.php b/backend/src/Scolarite/Domain/Repository/ScheduleSlotRepository.php new file mode 100644 index 0000000..20c2683 --- /dev/null +++ b/backend/src/Scolarite/Domain/Repository/ScheduleSlotRepository.php @@ -0,0 +1,61 @@ + */ + public function findByClass(ClassId $classId, TenantId $tenantId): array; + + /** @return array */ + public function findByTeacher(UserId $teacherId, TenantId $tenantId): array; + + /** @return array */ + public function findOverlappingForClass( + ClassId $classId, + DayOfWeek $dayOfWeek, + string $startTime, + string $endTime, + TenantId $tenantId, + ?ScheduleSlotId $excludeId = null, + ): array; + + /** @return array */ + public function findOverlappingForTeacher( + UserId $teacherId, + DayOfWeek $dayOfWeek, + string $startTime, + string $endTime, + TenantId $tenantId, + ?ScheduleSlotId $excludeId = null, + ): array; + + /** @return array */ + public function findOverlappingForRoom( + string $room, + DayOfWeek $dayOfWeek, + string $startTime, + string $endTime, + TenantId $tenantId, + ?ScheduleSlotId $excludeId = null, + ): array; +} diff --git a/backend/src/Scolarite/Domain/Service/ScheduleConflict.php b/backend/src/Scolarite/Domain/Service/ScheduleConflict.php new file mode 100644 index 0000000..9a6d172 --- /dev/null +++ b/backend/src/Scolarite/Domain/Service/ScheduleConflict.php @@ -0,0 +1,23 @@ + + */ + public function detectConflicts( + ScheduleSlot $slot, + TenantId $tenantId, + ?ScheduleSlotId $excludeId = null, + ): array { + $conflicts = []; + + $classConflicts = $this->repository->findOverlappingForClass( + $slot->classId, + $slot->dayOfWeek, + $slot->timeSlot->startTime, + $slot->timeSlot->endTime, + $tenantId, + $excludeId, + ); + + foreach ($classConflicts as $conflicting) { + $conflicts[] = new ScheduleConflict( + type: 'class', + conflictingSlot: $conflicting, + description: "La classe a déjà un cours le {$conflicting->dayOfWeek->label()} de {$conflicting->timeSlot->startTime} à {$conflicting->timeSlot->endTime}.", + ); + } + + $teacherConflicts = $this->repository->findOverlappingForTeacher( + $slot->teacherId, + $slot->dayOfWeek, + $slot->timeSlot->startTime, + $slot->timeSlot->endTime, + $tenantId, + $excludeId, + ); + + foreach ($teacherConflicts as $conflicting) { + $conflicts[] = new ScheduleConflict( + type: 'teacher', + conflictingSlot: $conflicting, + description: "L'enseignant est déjà occupé le {$conflicting->dayOfWeek->label()} de {$conflicting->timeSlot->startTime} à {$conflicting->timeSlot->endTime}.", + ); + } + + if ($slot->room !== null) { + $roomConflicts = $this->repository->findOverlappingForRoom( + $slot->room, + $slot->dayOfWeek, + $slot->timeSlot->startTime, + $slot->timeSlot->endTime, + $tenantId, + $excludeId, + ); + + foreach ($roomConflicts as $conflicting) { + $conflicts[] = new ScheduleConflict( + type: 'room', + conflictingSlot: $conflicting, + description: "La salle {$slot->room} est déjà occupée le {$conflicting->dayOfWeek->label()} de {$conflicting->timeSlot->startTime} à {$conflicting->timeSlot->endTime}.", + ); + } + } + + return $conflicts; + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Processor/CreateScheduleSlotProcessor.php b/backend/src/Scolarite/Infrastructure/Api/Processor/CreateScheduleSlotProcessor.php new file mode 100644 index 0000000..d1921a9 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Processor/CreateScheduleSlotProcessor.php @@ -0,0 +1,105 @@ + + */ +final readonly class CreateScheduleSlotProcessor implements ProcessorInterface +{ + public function __construct( + private CreateScheduleSlotHandler $handler, + private TenantContext $tenantContext, + private MessageBusInterface $eventBus, + private AuthorizationCheckerInterface $authorizationChecker, + ) { + } + + /** + * @param ScheduleSlotResource $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ScheduleSlotResource + { + if (!$this->authorizationChecker->isGranted(ScheduleSlotVoter::CREATE)) { + throw new AccessDeniedHttpException("Vous n'êtes pas autorisé à modifier l'emploi du temps."); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $tenantId = (string) $this->tenantContext->getCurrentTenantId(); + + try { + $command = new CreateScheduleSlotCommand( + tenantId: $tenantId, + classId: $data->classId ?? '', + subjectId: $data->subjectId ?? '', + teacherId: $data->teacherId ?? '', + dayOfWeek: $data->dayOfWeek ?? 1, + startTime: $data->startTime ?? '', + endTime: $data->endTime ?? '', + room: $data->room, + isRecurring: $data->isRecurring ?? true, + forceConflicts: $data->forceConflicts ?? false, + ); + + $result = ($this->handler)($command); + $slot = $result['slot']; + /** @var array $conflicts */ + $conflicts = $result['conflicts']; + + if ($conflicts === [] || $command->forceConflicts) { + foreach ($slot->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + } + + $resource = ScheduleSlotResource::fromDomain($slot); + + if ($conflicts !== []) { + $resource->conflicts = array_map( + static fn (ScheduleConflict $c) => [ + 'type' => $c->type, + 'description' => $c->description, + 'slotId' => (string) $c->conflictingSlot->id, + ], + $conflicts, + ); + } + + return $resource; + } catch (EnseignantNonAffecteException $e) { + throw new UnprocessableEntityHttpException($e->getMessage()); + } catch (CreneauHoraireInvalideException|ValueError $e) { + throw new BadRequestHttpException($e->getMessage()); + } + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Processor/DeleteScheduleSlotProcessor.php b/backend/src/Scolarite/Infrastructure/Api/Processor/DeleteScheduleSlotProcessor.php new file mode 100644 index 0000000..98e47a6 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Processor/DeleteScheduleSlotProcessor.php @@ -0,0 +1,77 @@ + + */ +final readonly class DeleteScheduleSlotProcessor implements ProcessorInterface +{ + public function __construct( + private DeleteScheduleSlotHandler $handler, + private TenantContext $tenantContext, + private MessageBusInterface $eventBus, + private AuthorizationCheckerInterface $authorizationChecker, + ) { + } + + /** + * @param ScheduleSlotResource $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null + { + if (!$this->authorizationChecker->isGranted(ScheduleSlotVoter::DELETE)) { + throw new AccessDeniedHttpException("Vous n'êtes pas autorisé à supprimer des créneaux."); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + /** @var string|null $slotId */ + $slotId = $uriVariables['id'] ?? null; + if ($slotId === null) { + throw new NotFoundHttpException('Créneau non trouvé.'); + } + + $tenantId = (string) $this->tenantContext->getCurrentTenantId(); + + try { + $command = new DeleteScheduleSlotCommand( + tenantId: $tenantId, + slotId: $slotId, + ); + + $slot = ($this->handler)($command); + + foreach ($slot->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + + return null; + } catch (ScheduleSlotNotFoundException|InvalidUuidStringException) { + throw new NotFoundHttpException('Créneau non trouvé.'); + } + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Processor/UpdateScheduleSlotProcessor.php b/backend/src/Scolarite/Infrastructure/Api/Processor/UpdateScheduleSlotProcessor.php new file mode 100644 index 0000000..dbadaa9 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Processor/UpdateScheduleSlotProcessor.php @@ -0,0 +1,116 @@ + + */ +final readonly class UpdateScheduleSlotProcessor implements ProcessorInterface +{ + public function __construct( + private UpdateScheduleSlotHandler $handler, + private TenantContext $tenantContext, + private MessageBusInterface $eventBus, + private AuthorizationCheckerInterface $authorizationChecker, + ) { + } + + /** + * @param ScheduleSlotResource $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ScheduleSlotResource + { + if (!$this->authorizationChecker->isGranted(ScheduleSlotVoter::EDIT)) { + throw new AccessDeniedHttpException("Vous n'êtes pas autorisé à modifier l'emploi du temps."); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + /** @var string|null $slotId */ + $slotId = $uriVariables['id'] ?? null; + if ($slotId === null) { + throw new NotFoundHttpException('Créneau non trouvé.'); + } + + $tenantId = (string) $this->tenantContext->getCurrentTenantId(); + + try { + $command = new UpdateScheduleSlotCommand( + tenantId: $tenantId, + slotId: $slotId, + classId: $data->classId ?? '', + subjectId: $data->subjectId ?? '', + teacherId: $data->teacherId ?? '', + dayOfWeek: $data->dayOfWeek ?? 1, + startTime: $data->startTime ?? '', + endTime: $data->endTime ?? '', + room: $data->room, + forceConflicts: $data->forceConflicts ?? false, + ); + + $result = ($this->handler)($command); + $slot = $result['slot']; + /** @var array $conflicts */ + $conflicts = $result['conflicts']; + + if ($conflicts === [] || $command->forceConflicts) { + foreach ($slot->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + } + + $resource = ScheduleSlotResource::fromDomain($slot); + + if ($conflicts !== []) { + $resource->conflicts = array_map( + static fn (ScheduleConflict $c) => [ + 'type' => $c->type, + 'description' => $c->description, + 'slotId' => (string) $c->conflictingSlot->id, + ], + $conflicts, + ); + } + + return $resource; + } catch (EnseignantNonAffecteException $e) { + throw new UnprocessableEntityHttpException($e->getMessage()); + } catch (ScheduleSlotNotFoundException|InvalidUuidStringException) { + throw new NotFoundHttpException('Créneau non trouvé.'); + } catch (CreneauHoraireInvalideException|ValueError $e) { + throw new BadRequestHttpException($e->getMessage()); + } + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Provider/BlockedDateCollectionProvider.php b/backend/src/Scolarite/Infrastructure/Api/Provider/BlockedDateCollectionProvider.php new file mode 100644 index 0000000..bbf74ad --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Provider/BlockedDateCollectionProvider.php @@ -0,0 +1,76 @@ + + */ +final readonly class BlockedDateCollectionProvider implements ProviderInterface +{ + public function __construct( + private GetBlockedDatesHandler $handler, + private TenantContext $tenantContext, + private AuthorizationCheckerInterface $authorizationChecker, + private CurrentAcademicYearResolver $academicYearResolver, + ) { + } + + /** @return array */ + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array + { + if (!$this->authorizationChecker->isGranted(ScheduleSlotVoter::VIEW)) { + throw new AccessDeniedHttpException("Vous n'êtes pas autorisé à consulter les dates bloquées."); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + /** @var array $filters */ + $filters = $context['filters'] ?? []; + + if (!isset($filters['startDate'], $filters['endDate'])) { + throw new BadRequestHttpException('Les paramètres startDate et endDate sont requis.'); + } + + $tenantId = (string) $this->tenantContext->getCurrentTenantId(); + $academicYearId = $this->academicYearResolver->resolve('current'); + + if ($academicYearId === null) { + return []; + } + + $query = new GetBlockedDatesQuery( + tenantId: $tenantId, + academicYearId: $academicYearId, + startDate: (string) $filters['startDate'], + endDate: (string) $filters['endDate'], + ); + + $dtos = ($this->handler)($query); + + return array_map(BlockedDateResource::fromDto(...), $dtos); + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Provider/ScheduleSlotCollectionProvider.php b/backend/src/Scolarite/Infrastructure/Api/Provider/ScheduleSlotCollectionProvider.php new file mode 100644 index 0000000..73f2157 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Provider/ScheduleSlotCollectionProvider.php @@ -0,0 +1,62 @@ + + */ +final readonly class ScheduleSlotCollectionProvider implements ProviderInterface +{ + public function __construct( + private GetScheduleSlotsHandler $handler, + private TenantContext $tenantContext, + private AuthorizationCheckerInterface $authorizationChecker, + ) { + } + + /** @return array */ + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array + { + if (!$this->authorizationChecker->isGranted(ScheduleSlotVoter::VIEW)) { + throw new AccessDeniedHttpException("Vous n'êtes pas autorisé à consulter l'emploi du temps."); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $tenantId = (string) $this->tenantContext->getCurrentTenantId(); + /** @var array $filters */ + $filters = $context['filters'] ?? []; + + $query = new GetScheduleSlotsQuery( + tenantId: $tenantId, + classId: isset($filters['classId']) ? (string) $filters['classId'] : null, + teacherId: isset($filters['teacherId']) ? (string) $filters['teacherId'] : null, + ); + + $dtos = ($this->handler)($query); + + return array_map(ScheduleSlotResource::fromDto(...), $dtos); + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Provider/ScheduleSlotItemProvider.php b/backend/src/Scolarite/Infrastructure/Api/Provider/ScheduleSlotItemProvider.php new file mode 100644 index 0000000..06605b2 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Provider/ScheduleSlotItemProvider.php @@ -0,0 +1,63 @@ + + */ +final readonly class ScheduleSlotItemProvider implements ProviderInterface +{ + public function __construct( + private ScheduleSlotRepository $repository, + private TenantContext $tenantContext, + private AuthorizationCheckerInterface $authorizationChecker, + ) { + } + + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): ScheduleSlotResource + { + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + if (!$this->authorizationChecker->isGranted(ScheduleSlotVoter::VIEW)) { + throw new AccessDeniedHttpException("Vous n'êtes pas autorisé à consulter l'emploi du temps."); + } + + /** @var string|null $slotId */ + $slotId = $uriVariables['id'] ?? null; + if ($slotId === null) { + throw new NotFoundHttpException('Créneau non trouvé.'); + } + + $tenantId = $this->tenantContext->getCurrentTenantId(); + + try { + $slot = $this->repository->get(ScheduleSlotId::fromString($slotId), $tenantId); + } catch (ScheduleSlotNotFoundException|InvalidUuidStringException) { + throw new NotFoundHttpException('Créneau non trouvé.'); + } + + return ScheduleSlotResource::fromDomain($slot); + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Resource/BlockedDateResource.php b/backend/src/Scolarite/Infrastructure/Api/Resource/BlockedDateResource.php new file mode 100644 index 0000000..eaab3eb --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Resource/BlockedDateResource.php @@ -0,0 +1,41 @@ +date = $dto->date; + $resource->reason = $dto->reason; + $resource->type = $dto->type; + + return $resource; + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Resource/ScheduleSlotResource.php b/backend/src/Scolarite/Infrastructure/Api/Resource/ScheduleSlotResource.php new file mode 100644 index 0000000..4fbd209 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Resource/ScheduleSlotResource.php @@ -0,0 +1,149 @@ + ['Default', 'create']], + name: 'create_schedule_slot', + ), + new Patch( + uriTemplate: '/schedule/slots/{id}', + provider: ScheduleSlotItemProvider::class, + processor: UpdateScheduleSlotProcessor::class, + validationContext: ['groups' => ['Default', 'update']], + name: 'update_schedule_slot', + ), + new Delete( + uriTemplate: '/schedule/slots/{id}', + provider: ScheduleSlotItemProvider::class, + processor: DeleteScheduleSlotProcessor::class, + name: 'delete_schedule_slot', + ), + ], +)] +final class ScheduleSlotResource +{ + #[ApiProperty(identifier: true)] + public ?string $id = null; + + #[Assert\NotBlank(message: 'La classe est requise.', groups: ['create'])] + public ?string $classId = null; + + #[Assert\NotBlank(message: 'La matière est requise.', groups: ['create'])] + public ?string $subjectId = null; + + #[Assert\NotBlank(message: "L'enseignant est requis.", groups: ['create'])] + public ?string $teacherId = null; + + #[Assert\NotNull(message: 'Le jour de la semaine est requis.', groups: ['create'])] + #[Assert\Range(min: 1, max: 7, notInRangeMessage: 'Le jour doit être compris entre 1 (lundi) et 7 (dimanche).')] + public ?int $dayOfWeek = null; + + #[Assert\NotBlank(message: "L'heure de début est requise.", groups: ['create'])] + #[Assert\Regex(pattern: '/^([01]\d|2[0-3]):[0-5]\d$/', message: "L'heure doit être au format HH:MM.")] + public ?string $startTime = null; + + #[Assert\NotBlank(message: "L'heure de fin est requise.", groups: ['create'])] + #[Assert\Regex(pattern: '/^([01]\d|2[0-3]):[0-5]\d$/', message: "L'heure doit être au format HH:MM.")] + public ?string $endTime = null; + + #[Assert\Length(max: 50, maxMessage: 'Le nom de la salle ne peut pas dépasser {{ limit }} caractères.')] + public ?string $room = null; + + public ?bool $isRecurring = null; + + /** + * Si true, forcer la création/modification malgré les conflits. + */ + #[ApiProperty(readable: false)] + public ?bool $forceConflicts = null; + + /** + * Conflits détectés lors de la création/modification. + * Renvoyé en réponse si des conflits existent. + * + * @var array|null + */ + #[ApiProperty(readable: true, writable: false)] + public ?array $conflicts = null; + + public ?DateTimeImmutable $createdAt = null; + + public ?DateTimeImmutable $updatedAt = null; + + public static function fromDomain(ScheduleSlot $slot): self + { + $resource = new self(); + $resource->id = (string) $slot->id; + $resource->classId = (string) $slot->classId; + $resource->subjectId = (string) $slot->subjectId; + $resource->teacherId = (string) $slot->teacherId; + $resource->dayOfWeek = $slot->dayOfWeek->value; + $resource->startTime = $slot->timeSlot->startTime; + $resource->endTime = $slot->timeSlot->endTime; + $resource->room = $slot->room; + $resource->isRecurring = $slot->isRecurring; + $resource->createdAt = $slot->createdAt; + $resource->updatedAt = $slot->updatedAt; + + return $resource; + } + + public static function fromDto(ScheduleSlotDto $dto): self + { + $resource = new self(); + $resource->id = $dto->id; + $resource->classId = $dto->classId; + $resource->subjectId = $dto->subjectId; + $resource->teacherId = $dto->teacherId; + $resource->dayOfWeek = $dto->dayOfWeek; + $resource->startTime = $dto->startTime; + $resource->endTime = $dto->endTime; + $resource->room = $dto->room; + $resource->isRecurring = $dto->isRecurring; + $resource->createdAt = $dto->createdAt; + $resource->updatedAt = $dto->updatedAt; + + return $resource; + } +} diff --git a/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineScheduleSlotRepository.php b/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineScheduleSlotRepository.php new file mode 100644 index 0000000..7aa2e3b --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineScheduleSlotRepository.php @@ -0,0 +1,278 @@ +connection->executeStatement( + 'INSERT INTO schedule_slots (id, tenant_id, class_id, subject_id, teacher_id, day_of_week, start_time, end_time, room, is_recurring, created_at, updated_at) + VALUES (:id, :tenant_id, :class_id, :subject_id, :teacher_id, :day_of_week, :start_time, :end_time, :room, :is_recurring, :created_at, :updated_at) + ON CONFLICT (id) DO UPDATE SET + class_id = EXCLUDED.class_id, + subject_id = EXCLUDED.subject_id, + teacher_id = EXCLUDED.teacher_id, + day_of_week = EXCLUDED.day_of_week, + start_time = EXCLUDED.start_time, + end_time = EXCLUDED.end_time, + room = EXCLUDED.room, + updated_at = EXCLUDED.updated_at', + [ + 'id' => (string) $slot->id, + 'tenant_id' => (string) $slot->tenantId, + 'class_id' => (string) $slot->classId, + 'subject_id' => (string) $slot->subjectId, + 'teacher_id' => (string) $slot->teacherId, + 'day_of_week' => $slot->dayOfWeek->value, + 'start_time' => $slot->timeSlot->startTime, + 'end_time' => $slot->timeSlot->endTime, + 'room' => $slot->room, + 'is_recurring' => $slot->isRecurring ? 'true' : 'false', + 'created_at' => $slot->createdAt->format(DateTimeImmutable::ATOM), + 'updated_at' => $slot->updatedAt->format(DateTimeImmutable::ATOM), + ], + ); + } + + #[Override] + public function get(ScheduleSlotId $id, TenantId $tenantId): ScheduleSlot + { + $slot = $this->findById($id, $tenantId); + + if ($slot === null) { + throw ScheduleSlotNotFoundException::avecId($id); + } + + return $slot; + } + + #[Override] + public function findById(ScheduleSlotId $id, TenantId $tenantId): ?ScheduleSlot + { + $row = $this->connection->fetchAssociative( + 'SELECT * FROM schedule_slots WHERE id = :id AND tenant_id = :tenant_id', + ['id' => (string) $id, 'tenant_id' => (string) $tenantId], + ); + + if ($row === false) { + return null; + } + + return $this->hydrate($row); + } + + #[Override] + public function delete(ScheduleSlotId $id, TenantId $tenantId): void + { + $this->connection->executeStatement( + 'DELETE FROM schedule_slots WHERE id = :id AND tenant_id = :tenant_id', + ['id' => (string) $id, 'tenant_id' => (string) $tenantId], + ); + } + + #[Override] + public function findByClass(ClassId $classId, TenantId $tenantId): array + { + $rows = $this->connection->fetchAllAssociative( + 'SELECT * FROM schedule_slots + WHERE class_id = :class_id AND tenant_id = :tenant_id + ORDER BY day_of_week, start_time', + ['class_id' => (string) $classId, 'tenant_id' => (string) $tenantId], + ); + + return $this->hydrateMany($rows); + } + + #[Override] + public function findByTeacher(UserId $teacherId, TenantId $tenantId): array + { + $rows = $this->connection->fetchAllAssociative( + 'SELECT * FROM schedule_slots + WHERE teacher_id = :teacher_id AND tenant_id = :tenant_id + ORDER BY day_of_week, start_time', + ['teacher_id' => (string) $teacherId, 'tenant_id' => (string) $tenantId], + ); + + return $this->hydrateMany($rows); + } + + #[Override] + public function findOverlappingForClass( + ClassId $classId, + DayOfWeek $dayOfWeek, + string $startTime, + string $endTime, + TenantId $tenantId, + ?ScheduleSlotId $excludeId = null, + ): array { + $sql = 'SELECT * FROM schedule_slots + WHERE tenant_id = :tenant_id + AND class_id = :class_id + AND day_of_week = :day_of_week + AND start_time < :end_time + AND end_time > :start_time'; + $params = [ + 'tenant_id' => (string) $tenantId, + 'class_id' => (string) $classId, + 'day_of_week' => $dayOfWeek->value, + 'start_time' => $startTime, + 'end_time' => $endTime, + ]; + + if ($excludeId !== null) { + $sql .= ' AND id != :exclude_id'; + $params['exclude_id'] = (string) $excludeId; + } + + $rows = $this->connection->fetchAllAssociative($sql, $params); + + return $this->hydrateMany($rows); + } + + #[Override] + public function findOverlappingForTeacher( + UserId $teacherId, + DayOfWeek $dayOfWeek, + string $startTime, + string $endTime, + TenantId $tenantId, + ?ScheduleSlotId $excludeId = null, + ): array { + $sql = 'SELECT * FROM schedule_slots + WHERE tenant_id = :tenant_id + AND teacher_id = :teacher_id + AND day_of_week = :day_of_week + AND start_time < :end_time + AND end_time > :start_time'; + $params = [ + 'tenant_id' => (string) $tenantId, + 'teacher_id' => (string) $teacherId, + 'day_of_week' => $dayOfWeek->value, + 'start_time' => $startTime, + 'end_time' => $endTime, + ]; + + if ($excludeId !== null) { + $sql .= ' AND id != :exclude_id'; + $params['exclude_id'] = (string) $excludeId; + } + + $rows = $this->connection->fetchAllAssociative($sql, $params); + + return $this->hydrateMany($rows); + } + + #[Override] + public function findOverlappingForRoom( + string $room, + DayOfWeek $dayOfWeek, + string $startTime, + string $endTime, + TenantId $tenantId, + ?ScheduleSlotId $excludeId = null, + ): array { + $sql = 'SELECT * FROM schedule_slots + WHERE tenant_id = :tenant_id + AND room = :room + AND day_of_week = :day_of_week + AND start_time < :end_time + AND end_time > :start_time'; + $params = [ + 'tenant_id' => (string) $tenantId, + 'room' => $room, + 'day_of_week' => $dayOfWeek->value, + 'start_time' => $startTime, + 'end_time' => $endTime, + ]; + + if ($excludeId !== null) { + $sql .= ' AND id != :exclude_id'; + $params['exclude_id'] = (string) $excludeId; + } + + $rows = $this->connection->fetchAllAssociative($sql, $params); + + return $this->hydrateMany($rows); + } + + /** + * @param list> $rows + * + * @return list + */ + private function hydrateMany(array $rows): array + { + return array_map($this->hydrate(...), $rows); + } + + /** + * @param array $row + */ + private function hydrate(array $row): ScheduleSlot + { + /** @var string $id */ + $id = $row['id']; + /** @var string $tenantId */ + $tenantId = $row['tenant_id']; + /** @var string $classId */ + $classId = $row['class_id']; + /** @var string $subjectId */ + $subjectId = $row['subject_id']; + /** @var string $teacherId */ + $teacherId = $row['teacher_id']; + /** @var int $dayOfWeek */ + $dayOfWeek = $row['day_of_week']; + /** @var string $startTime */ + $startTime = $row['start_time']; + /** @var string $endTime */ + $endTime = $row['end_time']; + /** @var string|null $room */ + $room = $row['room']; + /** @var bool $isRecurring */ + $isRecurring = $row['is_recurring']; + /** @var string $createdAt */ + $createdAt = $row['created_at']; + /** @var string $updatedAt */ + $updatedAt = $row['updated_at']; + + return ScheduleSlot::reconstitute( + id: ScheduleSlotId::fromString($id), + tenantId: TenantId::fromString($tenantId), + classId: ClassId::fromString($classId), + subjectId: SubjectId::fromString($subjectId), + teacherId: UserId::fromString($teacherId), + dayOfWeek: DayOfWeek::from((int) $dayOfWeek), + timeSlot: new TimeSlot($startTime, $endTime), + room: $room, + isRecurring: (bool) $isRecurring, + createdAt: new DateTimeImmutable($createdAt), + updatedAt: new DateTimeImmutable($updatedAt), + ); + } +} diff --git a/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryScheduleSlotRepository.php b/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryScheduleSlotRepository.php new file mode 100644 index 0000000..b5bc5b8 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryScheduleSlotRepository.php @@ -0,0 +1,149 @@ + */ + private array $byId = []; + + #[Override] + public function save(ScheduleSlot $slot): void + { + $this->byId[(string) $slot->id] = $slot; + } + + #[Override] + public function get(ScheduleSlotId $id, TenantId $tenantId): ScheduleSlot + { + $slot = $this->findById($id, $tenantId); + + if ($slot === null) { + throw ScheduleSlotNotFoundException::avecId($id); + } + + return $slot; + } + + #[Override] + public function findById(ScheduleSlotId $id, TenantId $tenantId): ?ScheduleSlot + { + $slot = $this->byId[(string) $id] ?? null; + + if ($slot !== null && !$slot->tenantId->equals($tenantId)) { + return null; + } + + return $slot; + } + + #[Override] + public function delete(ScheduleSlotId $id, TenantId $tenantId): void + { + $slot = $this->findById($id, $tenantId); + + if ($slot !== null) { + unset($this->byId[(string) $id]); + } + } + + #[Override] + public function findByClass(ClassId $classId, TenantId $tenantId): array + { + return array_values(array_filter( + $this->byId, + static fn (ScheduleSlot $s) => $s->tenantId->equals($tenantId) + && $s->classId->equals($classId), + )); + } + + #[Override] + public function findByTeacher(UserId $teacherId, TenantId $tenantId): array + { + return array_values(array_filter( + $this->byId, + static fn (ScheduleSlot $s) => $s->tenantId->equals($tenantId) + && $s->teacherId->equals($teacherId), + )); + } + + #[Override] + public function findOverlappingForClass( + ClassId $classId, + DayOfWeek $dayOfWeek, + string $startTime, + string $endTime, + TenantId $tenantId, + ?ScheduleSlotId $excludeId = null, + ): array { + $timeSlot = new TimeSlot($startTime, $endTime); + + return array_values(array_filter( + $this->byId, + static fn (ScheduleSlot $s) => $s->tenantId->equals($tenantId) + && $s->classId->equals($classId) + && $s->dayOfWeek === $dayOfWeek + && $s->timeSlot->overlaps($timeSlot) + && ($excludeId === null || !$s->id->equals($excludeId)), + )); + } + + #[Override] + public function findOverlappingForTeacher( + UserId $teacherId, + DayOfWeek $dayOfWeek, + string $startTime, + string $endTime, + TenantId $tenantId, + ?ScheduleSlotId $excludeId = null, + ): array { + $timeSlot = new TimeSlot($startTime, $endTime); + + return array_values(array_filter( + $this->byId, + static fn (ScheduleSlot $s) => $s->tenantId->equals($tenantId) + && $s->teacherId->equals($teacherId) + && $s->dayOfWeek === $dayOfWeek + && $s->timeSlot->overlaps($timeSlot) + && ($excludeId === null || !$s->id->equals($excludeId)), + )); + } + + #[Override] + public function findOverlappingForRoom( + string $room, + DayOfWeek $dayOfWeek, + string $startTime, + string $endTime, + TenantId $tenantId, + ?ScheduleSlotId $excludeId = null, + ): array { + $timeSlot = new TimeSlot($startTime, $endTime); + + return array_values(array_filter( + $this->byId, + static fn (ScheduleSlot $s) => $s->tenantId->equals($tenantId) + && $s->room === $room + && $s->dayOfWeek === $dayOfWeek + && $s->timeSlot->overlaps($timeSlot) + && ($excludeId === null || !$s->id->equals($excludeId)), + )); + } +} diff --git a/backend/src/Scolarite/Infrastructure/Security/ScheduleSlotVoter.php b/backend/src/Scolarite/Infrastructure/Security/ScheduleSlotVoter.php new file mode 100644 index 0000000..2b6f39e --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Security/ScheduleSlotVoter.php @@ -0,0 +1,97 @@ + + */ +final class ScheduleSlotVoter extends Voter +{ + public const string VIEW = 'SCHEDULE_VIEW'; + public const string CREATE = 'SCHEDULE_CREATE'; + public const string EDIT = 'SCHEDULE_EDIT'; + public const string DELETE = 'SCHEDULE_DELETE'; + + private const array SUPPORTED_ATTRIBUTES = [ + self::VIEW, + self::CREATE, + self::EDIT, + self::DELETE, + ]; + + #[Override] + protected function supports(string $attribute, mixed $subject): bool + { + return in_array($attribute, self::SUPPORTED_ATTRIBUTES, true); + } + + #[Override] + protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool + { + $user = $token->getUser(); + + if (!$user instanceof SecurityUser) { + return false; + } + + $roles = $user->getRoles(); + + return match ($attribute) { + self::VIEW => $this->canView($roles), + self::CREATE, self::EDIT, self::DELETE => $this->canManage($roles), + default => false, + }; + } + + /** @param string[] $roles */ + private function canView(array $roles): bool + { + return $this->hasAnyRole($roles, [ + Role::SUPER_ADMIN->value, + Role::ADMIN->value, + Role::PROF->value, + Role::VIE_SCOLAIRE->value, + ]); + } + + /** @param string[] $roles */ + private function canManage(array $roles): bool + { + return $this->hasAnyRole($roles, [ + Role::SUPER_ADMIN->value, + Role::ADMIN->value, + ]); + } + + /** + * @param string[] $userRoles + * @param string[] $allowedRoles + */ + private function hasAnyRole(array $userRoles, array $allowedRoles): bool + { + foreach ($userRoles as $role) { + if (in_array($role, $allowedRoles, true)) { + return true; + } + } + + return false; + } +} diff --git a/backend/src/Scolarite/Infrastructure/Service/CurrentYearEnseignantAffectationChecker.php b/backend/src/Scolarite/Infrastructure/Service/CurrentYearEnseignantAffectationChecker.php new file mode 100644 index 0000000..3c907d8 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Service/CurrentYearEnseignantAffectationChecker.php @@ -0,0 +1,49 @@ +academicYearResolver->resolve('current'); + + if ($academicYearId === null) { + return true; + } + + return $this->assignmentChecker->estAffecte( + $teacherId, + $classId, + $subjectId, + AcademicYearId::fromString($academicYearId), + $tenantId, + ); + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Command/CreateScheduleSlot/CreateScheduleSlotHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Command/CreateScheduleSlot/CreateScheduleSlotHandlerTest.php new file mode 100644 index 0000000..4543d77 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Command/CreateScheduleSlot/CreateScheduleSlotHandlerTest.php @@ -0,0 +1,196 @@ +repository = new InMemoryScheduleSlotRepository(); + $clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-03-02 10:00:00'); + } + }; + + $this->handler = new CreateScheduleSlotHandler( + $this->repository, + new ScheduleConflictDetector($this->repository), + $this->createAlwaysAssignedChecker(), + $clock, + ); + } + + #[Test] + public function createsSlotSuccessfully(): void + { + $result = ($this->handler)($this->createCommand()); + + /** @var ScheduleSlot $slot */ + $slot = $result['slot']; + + self::assertTrue($slot->classId->equals(ClassId::fromString(self::CLASS_ID))); + self::assertSame(DayOfWeek::MONDAY, $slot->dayOfWeek); + self::assertSame('08:00', $slot->timeSlot->startTime); + self::assertSame('09:00', $slot->timeSlot->endTime); + self::assertEmpty($result['conflicts']); + } + + #[Test] + public function savesSlotToRepository(): void + { + $result = ($this->handler)($this->createCommand()); + + /** @var ScheduleSlot $slot */ + $slot = $result['slot']; + + $found = $this->repository->findById($slot->id, TenantId::fromString(self::TENANT_ID)); + + self::assertNotNull($found); + self::assertTrue($found->id->equals($slot->id)); + } + + #[Test] + public function detectsConflictsWithoutSaving(): void + { + // Créer un slot existant + ($this->handler)($this->createCommand()); + + // Même enseignant, même créneau, classe différente + $result = ($this->handler)(new CreateScheduleSlotCommand( + tenantId: self::TENANT_ID, + classId: '550e8400-e29b-41d4-a716-446655440021', + subjectId: self::SUBJECT_ID, + teacherId: self::TEACHER_ID, + dayOfWeek: DayOfWeek::MONDAY->value, + startTime: '08:30', + endTime: '09:30', + room: null, + )); + + self::assertNotEmpty($result['conflicts']); + // Le slot conflictuel ne devrait PAS être sauvegardé + $allSlots = $this->repository->findByTeacher( + UserId::fromString(self::TEACHER_ID), + TenantId::fromString(self::TENANT_ID), + ); + self::assertCount(1, $allSlots); + } + + #[Test] + public function forceSavesSlotWithConflicts(): void + { + ($this->handler)($this->createCommand()); + + $result = ($this->handler)(new CreateScheduleSlotCommand( + tenantId: self::TENANT_ID, + classId: '550e8400-e29b-41d4-a716-446655440021', + subjectId: self::SUBJECT_ID, + teacherId: self::TEACHER_ID, + dayOfWeek: DayOfWeek::MONDAY->value, + startTime: '08:30', + endTime: '09:30', + room: null, + forceConflicts: true, + )); + + self::assertNotEmpty($result['conflicts']); + // Malgré les conflits, le slot est sauvegardé + $allSlots = $this->repository->findByTeacher( + UserId::fromString(self::TEACHER_ID), + TenantId::fromString(self::TENANT_ID), + ); + self::assertCount(2, $allSlots); + } + + #[Test] + public function rejectsUnassignedTeacher(): void + { + $clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-03-02 10:00:00'); + } + }; + + $handler = new CreateScheduleSlotHandler( + $this->repository, + new ScheduleConflictDetector($this->repository), + $this->createNeverAssignedChecker(), + $clock, + ); + + $this->expectException(EnseignantNonAffecteException::class); + ($handler)($this->createCommand()); + } + + private function createCommand(): CreateScheduleSlotCommand + { + return new CreateScheduleSlotCommand( + tenantId: self::TENANT_ID, + classId: self::CLASS_ID, + subjectId: self::SUBJECT_ID, + teacherId: self::TEACHER_ID, + dayOfWeek: DayOfWeek::MONDAY->value, + startTime: '08:00', + endTime: '09:00', + room: null, + ); + } + + private function createAlwaysAssignedChecker(): EnseignantAffectationChecker + { + return new class implements EnseignantAffectationChecker { + public function estAffecte( + UserId $teacherId, + ClassId $classId, + SubjectId $subjectId, + TenantId $tenantId, + ): bool { + return true; + } + }; + } + + private function createNeverAssignedChecker(): EnseignantAffectationChecker + { + return new class implements EnseignantAffectationChecker { + public function estAffecte( + UserId $teacherId, + ClassId $classId, + SubjectId $subjectId, + TenantId $tenantId, + ): bool { + return false; + } + }; + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Command/DeleteScheduleSlot/DeleteScheduleSlotHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Command/DeleteScheduleSlot/DeleteScheduleSlotHandlerTest.php new file mode 100644 index 0000000..4e6ac20 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Command/DeleteScheduleSlot/DeleteScheduleSlotHandlerTest.php @@ -0,0 +1,105 @@ +repository = new InMemoryScheduleSlotRepository(); + $clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-03-02 15:00:00'); + } + }; + + $this->handler = new DeleteScheduleSlotHandler($this->repository, $clock); + } + + #[Test] + public function deletesSlotFromRepository(): void + { + $slot = $this->createAndSaveSlot(); + $tenantId = TenantId::fromString(self::TENANT_ID); + + ($this->handler)(new DeleteScheduleSlotCommand( + tenantId: self::TENANT_ID, + slotId: (string) $slot->id, + )); + + self::assertNull($this->repository->findById($slot->id, $tenantId)); + } + + #[Test] + public function returnsSlotWithCoursSupprime(): void + { + $slot = $this->createAndSaveSlot(); + $slot->pullDomainEvents(); // Clear creation event + + $deletedSlot = ($this->handler)(new DeleteScheduleSlotCommand( + tenantId: self::TENANT_ID, + slotId: (string) $slot->id, + )); + + $events = $deletedSlot->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(CoursSupprime::class, $events[0]); + } + + #[Test] + public function throwsWhenSlotNotFound(): void + { + $this->expectException(ScheduleSlotNotFoundException::class); + + ($this->handler)(new DeleteScheduleSlotCommand( + tenantId: self::TENANT_ID, + slotId: '550e8400-e29b-41d4-a716-446655440099', + )); + } + + private function createAndSaveSlot(): ScheduleSlot + { + $slot = ScheduleSlot::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + classId: ClassId::fromString(self::CLASS_ID), + subjectId: SubjectId::fromString(self::SUBJECT_ID), + teacherId: UserId::fromString(self::TEACHER_ID), + dayOfWeek: DayOfWeek::MONDAY, + timeSlot: new TimeSlot('08:00', '09:00'), + room: null, + isRecurring: true, + now: new DateTimeImmutable('2026-03-01 10:00:00'), + ); + $this->repository->save($slot); + + return $slot; + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Command/UpdateScheduleSlot/UpdateScheduleSlotHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Command/UpdateScheduleSlot/UpdateScheduleSlotHandlerTest.php new file mode 100644 index 0000000..c512053 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Command/UpdateScheduleSlot/UpdateScheduleSlotHandlerTest.php @@ -0,0 +1,221 @@ +repository = new InMemoryScheduleSlotRepository(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-03-02 15:00:00'); + } + }; + + $this->handler = new UpdateScheduleSlotHandler( + $this->repository, + new ScheduleConflictDetector($this->repository), + $this->createAlwaysAssignedChecker(), + $this->clock, + ); + } + + #[Test] + public function updatesSlotProperties(): void + { + $slot = $this->createAndSaveSlot(); + $newSubjectId = '550e8400-e29b-41d4-a716-446655440031'; + + $result = ($this->handler)(new UpdateScheduleSlotCommand( + tenantId: self::TENANT_ID, + slotId: (string) $slot->id, + classId: self::CLASS_ID, + subjectId: $newSubjectId, + teacherId: self::TEACHER_ID, + dayOfWeek: DayOfWeek::WEDNESDAY->value, + startTime: '10:00', + endTime: '11:00', + room: 'Salle 301', + )); + + /** @var ScheduleSlot $updated */ + $updated = $result['slot']; + + self::assertTrue($updated->subjectId->equals(SubjectId::fromString($newSubjectId))); + self::assertSame(DayOfWeek::WEDNESDAY, $updated->dayOfWeek); + self::assertSame('10:00', $updated->timeSlot->startTime); + self::assertSame('Salle 301', $updated->room); + self::assertEmpty($result['conflicts']); + } + + #[Test] + public function updatesClassId(): void + { + $slot = $this->createAndSaveSlot(); + + $result = ($this->handler)(new UpdateScheduleSlotCommand( + tenantId: self::TENANT_ID, + slotId: (string) $slot->id, + classId: self::OTHER_CLASS_ID, + subjectId: self::SUBJECT_ID, + teacherId: self::TEACHER_ID, + dayOfWeek: DayOfWeek::MONDAY->value, + startTime: '08:00', + endTime: '09:00', + room: null, + )); + + /** @var ScheduleSlot $updated */ + $updated = $result['slot']; + + self::assertTrue($updated->classId->equals(ClassId::fromString(self::OTHER_CLASS_ID))); + self::assertFalse($updated->classId->equals(ClassId::fromString(self::CLASS_ID))); + } + + #[Test] + public function persistsClassIdChange(): void + { + $slot = $this->createAndSaveSlot(); + + ($this->handler)(new UpdateScheduleSlotCommand( + tenantId: self::TENANT_ID, + slotId: (string) $slot->id, + classId: self::OTHER_CLASS_ID, + subjectId: self::SUBJECT_ID, + teacherId: self::TEACHER_ID, + dayOfWeek: DayOfWeek::MONDAY->value, + startTime: '08:00', + endTime: '09:00', + room: null, + )); + + $persisted = $this->repository->get($slot->id, TenantId::fromString(self::TENANT_ID)); + + self::assertTrue($persisted->classId->equals(ClassId::fromString(self::OTHER_CLASS_ID))); + } + + #[Test] + public function excludesSelfFromConflictDetection(): void + { + $slot = $this->createAndSaveSlot(); + + // Modifier le slot sans changer le créneau → pas de conflit avec lui-même + $result = ($this->handler)(new UpdateScheduleSlotCommand( + tenantId: self::TENANT_ID, + slotId: (string) $slot->id, + classId: self::CLASS_ID, + subjectId: self::SUBJECT_ID, + teacherId: self::TEACHER_ID, + dayOfWeek: DayOfWeek::MONDAY->value, + startTime: '08:00', + endTime: '09:00', + room: 'Salle 101', + )); + + self::assertEmpty($result['conflicts']); + } + + #[Test] + public function rejectsUnassignedTeacher(): void + { + $slot = $this->createAndSaveSlot(); + + $handler = new UpdateScheduleSlotHandler( + $this->repository, + new ScheduleConflictDetector($this->repository), + $this->createNeverAssignedChecker(), + $this->clock, + ); + + $this->expectException(EnseignantNonAffecteException::class); + ($handler)(new UpdateScheduleSlotCommand( + tenantId: self::TENANT_ID, + slotId: (string) $slot->id, + classId: self::CLASS_ID, + subjectId: self::SUBJECT_ID, + teacherId: self::TEACHER_ID, + dayOfWeek: DayOfWeek::MONDAY->value, + startTime: '08:00', + endTime: '09:00', + room: null, + )); + } + + private function createAndSaveSlot(): ScheduleSlot + { + $slot = ScheduleSlot::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + classId: ClassId::fromString(self::CLASS_ID), + subjectId: SubjectId::fromString(self::SUBJECT_ID), + teacherId: UserId::fromString(self::TEACHER_ID), + dayOfWeek: DayOfWeek::MONDAY, + timeSlot: new TimeSlot('08:00', '09:00'), + room: null, + isRecurring: true, + now: new DateTimeImmutable('2026-03-01 10:00:00'), + ); + $this->repository->save($slot); + + return $slot; + } + + private function createAlwaysAssignedChecker(): EnseignantAffectationChecker + { + return new class implements EnseignantAffectationChecker { + public function estAffecte( + UserId $teacherId, + ClassId $classId, + SubjectId $subjectId, + TenantId $tenantId, + ): bool { + return true; + } + }; + } + + private function createNeverAssignedChecker(): EnseignantAffectationChecker + { + return new class implements EnseignantAffectationChecker { + public function estAffecte( + UserId $teacherId, + ClassId $classId, + SubjectId $subjectId, + TenantId $tenantId, + ): bool { + return false; + } + }; + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Query/GetBlockedDates/GetBlockedDatesHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Query/GetBlockedDates/GetBlockedDatesHandlerTest.php new file mode 100644 index 0000000..13beaf2 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Query/GetBlockedDates/GetBlockedDatesHandlerTest.php @@ -0,0 +1,149 @@ +calendarRepository = new InMemorySchoolCalendarRepository(); + $this->handler = new GetBlockedDatesHandler($this->calendarRepository); + } + + #[Test] + public function returnsWeekendsAsBlocked(): void + { + // 2026-03-02 est un lundi, 2026-03-08 est un dimanche + $result = ($this->handler)(new GetBlockedDatesQuery( + tenantId: self::TENANT_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + startDate: '2026-03-02', + endDate: '2026-03-08', + )); + + $weekendDates = array_filter($result, static fn ($d) => $d->type === 'weekend'); + + self::assertCount(2, $weekendDates); + $dates = array_map(static fn ($d) => $d->date, array_values($weekendDates)); + self::assertContains('2026-03-07', $dates); + self::assertContains('2026-03-08', $dates); + } + + #[Test] + public function returnsHolidaysAsBlocked(): void + { + $calendar = $this->createCalendarWithHoliday( + new DateTimeImmutable('2026-03-04'), + 'Jour de test', + ); + $this->calendarRepository->save($calendar); + + $result = ($this->handler)(new GetBlockedDatesQuery( + tenantId: self::TENANT_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + startDate: '2026-03-02', + endDate: '2026-03-06', + )); + + $holidays = array_filter($result, static fn ($d) => $d->type === CalendarEntryType::HOLIDAY->value); + + self::assertCount(1, $holidays); + $holiday = array_values($holidays)[0]; + self::assertSame('2026-03-04', $holiday->date); + self::assertSame('Jour de test', $holiday->reason); + } + + #[Test] + public function returnsEmptyForWeekWithNoBlockedDates(): void + { + // Lundi à vendredi sans calendrier configuré + $result = ($this->handler)(new GetBlockedDatesQuery( + tenantId: self::TENANT_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + startDate: '2026-03-02', + endDate: '2026-03-06', + )); + + self::assertEmpty($result); + } + + #[Test] + public function returnsVacationsAsBlocked(): void + { + $calendar = $this->createCalendarWithVacation( + new DateTimeImmutable('2026-03-02'), + new DateTimeImmutable('2026-03-06'), + 'Vacances de printemps', + ); + $this->calendarRepository->save($calendar); + + $result = ($this->handler)(new GetBlockedDatesQuery( + tenantId: self::TENANT_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + startDate: '2026-03-02', + endDate: '2026-03-06', + )); + + $vacations = array_filter($result, static fn ($d) => $d->type === CalendarEntryType::VACATION->value); + + self::assertCount(5, $vacations); + } + + private function createCalendarWithHoliday(DateTimeImmutable $date, string $label): SchoolCalendar + { + $tenantId = TenantId::fromString(self::TENANT_ID); + $academicYearId = AcademicYearId::fromString(self::ACADEMIC_YEAR_ID); + + $calendar = SchoolCalendar::initialiser($tenantId, $academicYearId); + $calendar->ajouterEntree(new CalendarEntry( + id: CalendarEntryId::generate(), + type: CalendarEntryType::HOLIDAY, + startDate: $date, + endDate: $date, + label: $label, + )); + + return $calendar; + } + + private function createCalendarWithVacation( + DateTimeImmutable $startDate, + DateTimeImmutable $endDate, + string $label, + ): SchoolCalendar { + $tenantId = TenantId::fromString(self::TENANT_ID); + $academicYearId = AcademicYearId::fromString(self::ACADEMIC_YEAR_ID); + + $calendar = SchoolCalendar::initialiser($tenantId, $academicYearId); + $calendar->ajouterEntree(new CalendarEntry( + id: CalendarEntryId::generate(), + type: CalendarEntryType::VACATION, + startDate: $startDate, + endDate: $endDate, + label: $label, + )); + + return $calendar; + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Query/GetScheduleSlots/GetScheduleSlotsHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Query/GetScheduleSlots/GetScheduleSlotsHandlerTest.php new file mode 100644 index 0000000..d9e0259 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Query/GetScheduleSlots/GetScheduleSlotsHandlerTest.php @@ -0,0 +1,176 @@ +repository = new InMemoryScheduleSlotRepository(); + $this->handler = new GetScheduleSlotsHandler($this->repository); + } + + #[Test] + public function returnsSlotsFilteredByClass(): void + { + $this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A); + $this->createSlot(classId: self::CLASS_B, teacherId: self::TEACHER_A); + + $result = ($this->handler)(new GetScheduleSlotsQuery( + tenantId: self::TENANT_ID, + classId: self::CLASS_A, + )); + + self::assertCount(1, $result); + self::assertSame(self::CLASS_A, $result[0]->classId); + } + + #[Test] + public function returnsSlotsFilteredByTeacher(): void + { + $this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A); + $this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_B, day: DayOfWeek::TUESDAY); + + $result = ($this->handler)(new GetScheduleSlotsQuery( + tenantId: self::TENANT_ID, + teacherId: self::TEACHER_A, + )); + + self::assertCount(1, $result); + self::assertSame(self::TEACHER_A, $result[0]->teacherId); + } + + #[Test] + public function returnsSlotsFilteredByClassAndTeacher(): void + { + // Classe A, enseignant A + $this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A); + // Classe A, enseignant B + $this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_B, day: DayOfWeek::TUESDAY); + // Classe B, enseignant A + $this->createSlot(classId: self::CLASS_B, teacherId: self::TEACHER_A, day: DayOfWeek::WEDNESDAY); + + $result = ($this->handler)(new GetScheduleSlotsQuery( + tenantId: self::TENANT_ID, + classId: self::CLASS_A, + teacherId: self::TEACHER_A, + )); + + self::assertCount(1, $result); + self::assertSame(self::CLASS_A, $result[0]->classId); + self::assertSame(self::TEACHER_A, $result[0]->teacherId); + } + + #[Test] + public function returnsEmptyWhenClassAndTeacherDontMatch(): void + { + $this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A); + + $result = ($this->handler)(new GetScheduleSlotsQuery( + tenantId: self::TENANT_ID, + classId: self::CLASS_A, + teacherId: self::TEACHER_B, + )); + + self::assertCount(0, $result); + } + + #[Test] + public function returnsEmptyWhenNoFilters(): void + { + $this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A); + + $result = ($this->handler)(new GetScheduleSlotsQuery( + tenantId: self::TENANT_ID, + )); + + self::assertCount(0, $result); + } + + #[Test] + public function returnsEmptyForInvalidClassIdUuid(): void + { + $this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A); + + $result = ($this->handler)(new GetScheduleSlotsQuery( + tenantId: self::TENANT_ID, + classId: 'not-a-valid-uuid', + )); + + self::assertCount(0, $result); + } + + #[Test] + public function returnsEmptyForInvalidTeacherIdUuid(): void + { + $this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A); + + $result = ($this->handler)(new GetScheduleSlotsQuery( + tenantId: self::TENANT_ID, + teacherId: 'invalid', + )); + + self::assertCount(0, $result); + } + + #[Test] + public function teacherFilterReturnsAllClassesForTeacher(): void + { + $this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A); + $this->createSlot(classId: self::CLASS_B, teacherId: self::TEACHER_A, day: DayOfWeek::TUESDAY); + + $result = ($this->handler)(new GetScheduleSlotsQuery( + tenantId: self::TENANT_ID, + teacherId: self::TEACHER_A, + )); + + self::assertCount(2, $result); + } + + private function createSlot( + string $classId, + string $teacherId, + DayOfWeek $day = DayOfWeek::MONDAY, + ): ScheduleSlot { + $slot = ScheduleSlot::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + classId: ClassId::fromString($classId), + subjectId: SubjectId::fromString(self::SUBJECT_ID), + teacherId: UserId::fromString($teacherId), + dayOfWeek: $day, + timeSlot: new TimeSlot('08:00', '09:00'), + room: null, + isRecurring: true, + now: new DateTimeImmutable('2026-03-01 10:00:00'), + ); + $this->repository->save($slot); + + return $slot; + } +} diff --git a/backend/tests/Unit/Scolarite/Domain/Model/Schedule/ScheduleSlotTest.php b/backend/tests/Unit/Scolarite/Domain/Model/Schedule/ScheduleSlotTest.php new file mode 100644 index 0000000..bf70954 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Domain/Model/Schedule/ScheduleSlotTest.php @@ -0,0 +1,193 @@ +createSlot(); + + self::assertTrue($slot->tenantId->equals(TenantId::fromString(self::TENANT_ID))); + self::assertTrue($slot->classId->equals(ClassId::fromString(self::CLASS_ID))); + self::assertTrue($slot->subjectId->equals(SubjectId::fromString(self::SUBJECT_ID))); + self::assertTrue($slot->teacherId->equals(UserId::fromString(self::TEACHER_ID))); + self::assertSame(DayOfWeek::MONDAY, $slot->dayOfWeek); + self::assertSame('08:00', $slot->timeSlot->startTime); + self::assertSame('09:00', $slot->timeSlot->endTime); + self::assertNull($slot->room); + self::assertTrue($slot->isRecurring); + } + + #[Test] + public function creerRecordsCoursCreeEvent(): void + { + $slot = $this->createSlot(); + + $events = $slot->pullDomainEvents(); + + self::assertCount(1, $events); + self::assertInstanceOf(CoursCree::class, $events[0]); + self::assertTrue($slot->id->equals($events[0]->slotId)); + } + + #[Test] + public function creerWithRoomSetsRoom(): void + { + $slot = $this->createSlot(room: 'Salle 101'); + + self::assertSame('Salle 101', $slot->room); + } + + #[Test] + public function modifierUpdatesProperties(): void + { + $slot = $this->createSlot(); + $slot->pullDomainEvents(); + + $newSubjectId = SubjectId::fromString('550e8400-e29b-41d4-a716-446655440031'); + $newTeacherId = UserId::fromString('550e8400-e29b-41d4-a716-446655440011'); + $newTimeSlot = new TimeSlot('10:00', '11:00'); + $now = new DateTimeImmutable('2026-03-02 15:00:00'); + + $newClassId = ClassId::fromString('550e8400-e29b-41d4-a716-446655440021'); + + $slot->modifier( + classId: $newClassId, + subjectId: $newSubjectId, + teacherId: $newTeacherId, + dayOfWeek: DayOfWeek::TUESDAY, + timeSlot: $newTimeSlot, + room: 'Salle 202', + at: $now, + ); + + self::assertTrue($slot->classId->equals($newClassId)); + self::assertTrue($slot->subjectId->equals($newSubjectId)); + self::assertTrue($slot->teacherId->equals($newTeacherId)); + self::assertSame(DayOfWeek::TUESDAY, $slot->dayOfWeek); + self::assertSame('10:00', $slot->timeSlot->startTime); + self::assertSame('11:00', $slot->timeSlot->endTime); + self::assertSame('Salle 202', $slot->room); + self::assertEquals($now, $slot->updatedAt); + } + + #[Test] + public function modifierRecordsCoursModifieEvent(): void + { + $slot = $this->createSlot(); + $slot->pullDomainEvents(); + + $now = new DateTimeImmutable('2026-03-02 15:00:00'); + $slot->modifier( + classId: $slot->classId, + subjectId: $slot->subjectId, + teacherId: $slot->teacherId, + dayOfWeek: DayOfWeek::TUESDAY, + timeSlot: new TimeSlot('10:00', '11:00'), + room: null, + at: $now, + ); + + $events = $slot->pullDomainEvents(); + + self::assertCount(1, $events); + self::assertInstanceOf(CoursModifie::class, $events[0]); + self::assertTrue($slot->id->equals($events[0]->slotId)); + } + + #[Test] + public function supprimerRecordsCoursSupprime(): void + { + $slot = $this->createSlot(); + $slot->pullDomainEvents(); + + $now = new DateTimeImmutable('2026-03-02 15:00:00'); + $slot->supprimer($now); + + $events = $slot->pullDomainEvents(); + + self::assertCount(1, $events); + self::assertInstanceOf(CoursSupprime::class, $events[0]); + self::assertTrue($slot->id->equals($events[0]->slotId)); + } + + #[Test] + public function reconstituteRestoresAllPropertiesWithoutEvents(): void + { + $id = ScheduleSlotId::generate(); + $tenantId = TenantId::fromString(self::TENANT_ID); + $classId = ClassId::fromString(self::CLASS_ID); + $subjectId = SubjectId::fromString(self::SUBJECT_ID); + $teacherId = UserId::fromString(self::TEACHER_ID); + $timeSlot = new TimeSlot('14:00', '15:30'); + $createdAt = new DateTimeImmutable('2026-03-01 10:00:00'); + $updatedAt = new DateTimeImmutable('2026-03-02 14:00:00'); + + $slot = ScheduleSlot::reconstitute( + id: $id, + tenantId: $tenantId, + classId: $classId, + subjectId: $subjectId, + teacherId: $teacherId, + dayOfWeek: DayOfWeek::FRIDAY, + timeSlot: $timeSlot, + room: 'Salle 305', + isRecurring: false, + createdAt: $createdAt, + updatedAt: $updatedAt, + ); + + self::assertTrue($slot->id->equals($id)); + self::assertTrue($slot->tenantId->equals($tenantId)); + self::assertTrue($slot->classId->equals($classId)); + self::assertTrue($slot->subjectId->equals($subjectId)); + self::assertTrue($slot->teacherId->equals($teacherId)); + self::assertSame(DayOfWeek::FRIDAY, $slot->dayOfWeek); + self::assertSame('14:00', $slot->timeSlot->startTime); + self::assertSame('15:30', $slot->timeSlot->endTime); + self::assertSame('Salle 305', $slot->room); + self::assertFalse($slot->isRecurring); + self::assertEquals($createdAt, $slot->createdAt); + self::assertEquals($updatedAt, $slot->updatedAt); + self::assertEmpty($slot->pullDomainEvents()); + } + + private function createSlot(?string $room = null): ScheduleSlot + { + return ScheduleSlot::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + classId: ClassId::fromString(self::CLASS_ID), + subjectId: SubjectId::fromString(self::SUBJECT_ID), + teacherId: UserId::fromString(self::TEACHER_ID), + dayOfWeek: DayOfWeek::MONDAY, + timeSlot: new TimeSlot('08:00', '09:00'), + room: $room, + isRecurring: true, + now: new DateTimeImmutable('2026-03-01 10:00:00'), + ); + } +} diff --git a/backend/tests/Unit/Scolarite/Domain/Model/Schedule/TimeSlotTest.php b/backend/tests/Unit/Scolarite/Domain/Model/Schedule/TimeSlotTest.php new file mode 100644 index 0000000..0c19acf --- /dev/null +++ b/backend/tests/Unit/Scolarite/Domain/Model/Schedule/TimeSlotTest.php @@ -0,0 +1,120 @@ +startTime); + self::assertSame('09:00', $timeSlot->endTime); + } + + #[Test] + public function throwsWhenEndTimeBeforeStartTime(): void + { + $this->expectException(CreneauHoraireInvalideException::class); + + new TimeSlot('10:00', '09:00'); + } + + #[Test] + public function throwsWhenEndTimeEqualsStartTime(): void + { + $this->expectException(CreneauHoraireInvalideException::class); + + new TimeSlot('08:00', '08:00'); + } + + #[Test] + public function throwsWhenDurationLessThanFiveMinutes(): void + { + $this->expectException(CreneauHoraireInvalideException::class); + + new TimeSlot('08:00', '08:04'); + } + + #[Test] + public function acceptsFiveMinuteDuration(): void + { + $timeSlot = new TimeSlot('08:00', '08:05'); + + self::assertSame('08:00', $timeSlot->startTime); + self::assertSame('08:05', $timeSlot->endTime); + } + + #[Test] + public function overlapsReturnsTrueWhenTimesOverlap(): void + { + $slot1 = new TimeSlot('08:00', '09:00'); + $slot2 = new TimeSlot('08:30', '09:30'); + + self::assertTrue($slot1->overlaps($slot2)); + self::assertTrue($slot2->overlaps($slot1)); + } + + #[Test] + public function overlapsReturnsFalseWhenTimesDoNotOverlap(): void + { + $slot1 = new TimeSlot('08:00', '09:00'); + $slot2 = new TimeSlot('09:00', '10:00'); + + self::assertFalse($slot1->overlaps($slot2)); + self::assertFalse($slot2->overlaps($slot1)); + } + + #[Test] + public function overlapsReturnsTrueWhenOneContainsOther(): void + { + $slot1 = new TimeSlot('08:00', '12:00'); + $slot2 = new TimeSlot('09:00', '10:00'); + + self::assertTrue($slot1->overlaps($slot2)); + self::assertTrue($slot2->overlaps($slot1)); + } + + #[Test] + public function overlapsReturnsFalseWhenAdjacent(): void + { + $slot1 = new TimeSlot('08:00', '09:00'); + $slot2 = new TimeSlot('09:00', '10:00'); + + self::assertFalse($slot1->overlaps($slot2)); + } + + #[Test] + public function equalsReturnsTrueForSameTimes(): void + { + $slot1 = new TimeSlot('08:00', '09:00'); + $slot2 = new TimeSlot('08:00', '09:00'); + + self::assertTrue($slot1->equals($slot2)); + } + + #[Test] + public function equalsReturnsFalseForDifferentTimes(): void + { + $slot1 = new TimeSlot('08:00', '09:00'); + $slot2 = new TimeSlot('08:00', '10:00'); + + self::assertFalse($slot1->equals($slot2)); + } + + #[Test] + public function durationInMinutesReturnsCorrectValue(): void + { + $timeSlot = new TimeSlot('08:00', '09:30'); + + self::assertSame(90, $timeSlot->durationInMinutes()); + } +} diff --git a/backend/tests/Unit/Scolarite/Domain/Service/ScheduleConflictDetectorTest.php b/backend/tests/Unit/Scolarite/Domain/Service/ScheduleConflictDetectorTest.php new file mode 100644 index 0000000..26f52cd --- /dev/null +++ b/backend/tests/Unit/Scolarite/Domain/Service/ScheduleConflictDetectorTest.php @@ -0,0 +1,281 @@ +repository = new InMemoryScheduleSlotRepository(); + $this->detector = new ScheduleConflictDetector($this->repository); + } + + #[Test] + public function detectsClassConflict(): void + { + $tenantId = TenantId::fromString(self::TENANT_ID); + $existingSlot = $this->createSlot( + dayOfWeek: DayOfWeek::MONDAY, + startTime: '08:00', + endTime: '09:00', + ); + $this->repository->save($existingSlot); + + // Même classe, même créneau, enseignant différent + $newSlot = $this->createSlot( + teacherId: '550e8400-e29b-41d4-a716-446655440011', + dayOfWeek: DayOfWeek::MONDAY, + startTime: '08:30', + endTime: '09:30', + ); + + $conflicts = $this->detector->detectConflicts($newSlot, $tenantId); + + self::assertNotEmpty($conflicts); + $types = array_map(static fn ($c) => $c->type, $conflicts); + self::assertContains('class', $types); + } + + #[Test] + public function noClassConflictWhenDifferentClass(): void + { + $tenantId = TenantId::fromString(self::TENANT_ID); + $existingSlot = $this->createSlot( + dayOfWeek: DayOfWeek::MONDAY, + startTime: '08:00', + endTime: '09:00', + ); + $this->repository->save($existingSlot); + + $newSlot = $this->createSlot( + teacherId: '550e8400-e29b-41d4-a716-446655440011', + classId: '550e8400-e29b-41d4-a716-446655440021', + dayOfWeek: DayOfWeek::MONDAY, + startTime: '08:00', + endTime: '09:00', + ); + + $conflicts = $this->detector->detectConflicts($newSlot, $tenantId); + + self::assertEmpty($conflicts); + } + + #[Test] + public function detectsTeacherConflict(): void + { + $tenantId = TenantId::fromString(self::TENANT_ID); + $existingSlot = $this->createSlot( + teacherId: self::TEACHER_ID, + dayOfWeek: DayOfWeek::MONDAY, + startTime: '08:00', + endTime: '09:00', + ); + $this->repository->save($existingSlot); + + $newSlot = $this->createSlot( + teacherId: self::TEACHER_ID, + classId: '550e8400-e29b-41d4-a716-446655440021', + dayOfWeek: DayOfWeek::MONDAY, + startTime: '08:30', + endTime: '09:30', + ); + + $conflicts = $this->detector->detectConflicts($newSlot, $tenantId); + + self::assertNotEmpty($conflicts); + self::assertSame('teacher', $conflicts[0]->type); + } + + #[Test] + public function noConflictWhenDifferentDay(): void + { + $tenantId = TenantId::fromString(self::TENANT_ID); + $existingSlot = $this->createSlot( + teacherId: self::TEACHER_ID, + dayOfWeek: DayOfWeek::MONDAY, + startTime: '08:00', + endTime: '09:00', + ); + $this->repository->save($existingSlot); + + $newSlot = $this->createSlot( + teacherId: self::TEACHER_ID, + classId: '550e8400-e29b-41d4-a716-446655440021', + dayOfWeek: DayOfWeek::TUESDAY, + startTime: '08:00', + endTime: '09:00', + ); + + $conflicts = $this->detector->detectConflicts($newSlot, $tenantId); + + self::assertEmpty($conflicts); + } + + #[Test] + public function noConflictWhenAdjacentTimes(): void + { + $tenantId = TenantId::fromString(self::TENANT_ID); + $existingSlot = $this->createSlot( + teacherId: self::TEACHER_ID, + dayOfWeek: DayOfWeek::MONDAY, + startTime: '08:00', + endTime: '09:00', + ); + $this->repository->save($existingSlot); + + $newSlot = $this->createSlot( + teacherId: self::TEACHER_ID, + classId: '550e8400-e29b-41d4-a716-446655440021', + dayOfWeek: DayOfWeek::MONDAY, + startTime: '09:00', + endTime: '10:00', + ); + + $conflicts = $this->detector->detectConflicts($newSlot, $tenantId); + + self::assertEmpty($conflicts); + } + + #[Test] + public function detectsRoomConflict(): void + { + $tenantId = TenantId::fromString(self::TENANT_ID); + $existingSlot = $this->createSlot( + teacherId: self::TEACHER_ID, + dayOfWeek: DayOfWeek::MONDAY, + startTime: '08:00', + endTime: '09:00', + room: 'Salle 101', + ); + $this->repository->save($existingSlot); + + $newSlot = $this->createSlot( + teacherId: '550e8400-e29b-41d4-a716-446655440011', + classId: '550e8400-e29b-41d4-a716-446655440021', + dayOfWeek: DayOfWeek::MONDAY, + startTime: '08:30', + endTime: '09:30', + room: 'Salle 101', + ); + + $conflicts = $this->detector->detectConflicts($newSlot, $tenantId); + + self::assertNotEmpty($conflicts); + self::assertSame('room', $conflicts[0]->type); + } + + #[Test] + public function noRoomConflictWhenNoRoom(): void + { + $tenantId = TenantId::fromString(self::TENANT_ID); + $existingSlot = $this->createSlot( + teacherId: self::TEACHER_ID, + dayOfWeek: DayOfWeek::MONDAY, + startTime: '08:00', + endTime: '09:00', + ); + $this->repository->save($existingSlot); + + $newSlot = $this->createSlot( + teacherId: '550e8400-e29b-41d4-a716-446655440011', + classId: '550e8400-e29b-41d4-a716-446655440021', + dayOfWeek: DayOfWeek::MONDAY, + startTime: '08:30', + endTime: '09:30', + ); + + $conflicts = $this->detector->detectConflicts($newSlot, $tenantId); + + self::assertEmpty($conflicts); + } + + #[Test] + public function excludesCurrentSlotWhenUpdating(): void + { + $tenantId = TenantId::fromString(self::TENANT_ID); + $existingSlot = $this->createSlot( + teacherId: self::TEACHER_ID, + dayOfWeek: DayOfWeek::MONDAY, + startTime: '08:00', + endTime: '09:00', + ); + $this->repository->save($existingSlot); + + $conflicts = $this->detector->detectConflicts($existingSlot, $tenantId, $existingSlot->id); + + self::assertEmpty($conflicts); + } + + #[Test] + public function detectsBothTeacherAndRoomConflicts(): void + { + $tenantId = TenantId::fromString(self::TENANT_ID); + $existingSlot = $this->createSlot( + teacherId: self::TEACHER_ID, + dayOfWeek: DayOfWeek::MONDAY, + startTime: '08:00', + endTime: '09:00', + room: 'Salle 101', + ); + $this->repository->save($existingSlot); + + $newSlot = $this->createSlot( + teacherId: self::TEACHER_ID, + classId: '550e8400-e29b-41d4-a716-446655440021', + dayOfWeek: DayOfWeek::MONDAY, + startTime: '08:30', + endTime: '09:30', + room: 'Salle 101', + ); + + $conflicts = $this->detector->detectConflicts($newSlot, $tenantId); + + $types = array_map(static fn ($c) => $c->type, $conflicts); + self::assertContains('teacher', $types); + self::assertContains('room', $types); + } + + private function createSlot( + string $teacherId = self::TEACHER_ID, + string $classId = self::CLASS_ID, + DayOfWeek $dayOfWeek = DayOfWeek::MONDAY, + string $startTime = '08:00', + string $endTime = '09:00', + ?string $room = null, + ): ScheduleSlot { + return ScheduleSlot::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + classId: ClassId::fromString($classId), + subjectId: SubjectId::fromString(self::SUBJECT_ID), + teacherId: UserId::fromString($teacherId), + dayOfWeek: $dayOfWeek, + timeSlot: new TimeSlot($startTime, $endTime), + room: $room, + isRecurring: true, + now: new DateTimeImmutable('2026-03-01 10:00:00'), + ); + } +} diff --git a/frontend/e2e/schedule-advanced.spec.ts b/frontend/e2e/schedule-advanced.spec.ts new file mode 100644 index 0000000..35c66ca --- /dev/null +++ b/frontend/e2e/schedule-advanced.spec.ts @@ -0,0 +1,524 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; + +const ADMIN_EMAIL = 'e2e-schedule-admin@example.com'; +const ADMIN_PASSWORD = 'ScheduleTest123'; +const TEACHER_EMAIL = 'e2e-schedule-teacher@example.com'; +const TEACHER_PASSWORD = 'ScheduleTeacher123'; +const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + +const projectRoot = join(__dirname, '../..'); +const composeFile = join(projectRoot, 'compose.yaml'); + +function runSql(sql: string) { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`, + { encoding: 'utf-8' } + ); +} + +function clearCache() { + try { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`, + { encoding: 'utf-8' } + ); + } catch { + // Cache pool may not exist + } +} + +function resolveDeterministicIds(): { schoolId: string; academicYearId: string } { + const output = execSync( + `docker compose -f "${composeFile}" exec -T php php -r '` + + `require "/app/vendor/autoload.php"; ` + + `$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` + + `echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` + + `$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` + + `echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` + + `' 2>&1`, + { encoding: 'utf-8' } + ).trim(); + const [schoolId, academicYearId] = output.split('\n'); + return { schoolId: schoolId!, academicYearId: academicYearId! }; +} + +function cleanupScheduleData() { + try { + runSql(`DELETE FROM schedule_slots WHERE tenant_id = '${TENANT_ID}'`); + } catch { + // Table may not exist yet + } +} + +function seedTeacherAssignments() { + const { academicYearId } = resolveDeterministicIds(); + try { + // Assign test teacher to ALL classes × ALL subjects so any dropdown combo is valid + runSql( + `INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` + + `FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` + + `WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + + `AND c.tenant_id = '${TENANT_ID}' ` + + `AND s.tenant_id = '${TENANT_ID}' ` + + `ON CONFLICT DO NOTHING` + ); + } catch { + // Table may not exist + } +} + +function cleanupCalendarEntries() { + try { + runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`); + } catch { + // Table may not exist + } +} + +function seedBlockedDate(date: string, label: string, type: string) { + const { academicYearId } = resolveDeterministicIds(); + runSql( + `INSERT INTO school_calendar_entries (id, tenant_id, academic_year_id, entry_type, start_date, end_date, label, created_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${academicYearId}', '${type}', '${date}', '${date}', '${label}', NOW()) ` + + `ON CONFLICT DO NOTHING` + ); +} + +function getWeekdayInCurrentWeek(isoDay: number): string { + const now = new Date(); + const monday = new Date(now); + monday.setDate(now.getDate() - ((now.getDay() + 6) % 7)); + const target = new Date(monday); + target.setDate(monday.getDate() + (isoDay - 1)); + return target.toISOString().split('T')[0]!; +} + +async function loginAsAdmin(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(ADMIN_EMAIL); + await page.locator('#password').fill(ADMIN_PASSWORD); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 30000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); +} + +async function waitForScheduleReady(page: import('@playwright/test').Page) { + await expect(page.getByRole('heading', { name: /emploi du temps/i })).toBeVisible({ + timeout: 15000 + }); + // Wait for either the grid or the empty state to appear + await expect(page.locator('.schedule-grid, .empty-state, .alert-error')).toBeVisible({ + timeout: 15000 + }); +} + +async function fillSlotForm( + dialog: import('@playwright/test').Locator, + options: { + className?: string; + dayValue?: string; + startTime?: string; + endTime?: string; + room?: string; + } = {} +) { + const { className, dayValue = '1', startTime = '09:00', endTime = '10:00', room } = options; + + if (className) { + await dialog.locator('#slot-class').selectOption({ label: className }); + } + // Wait for assignments to load — only the test teacher is assigned, + // so the teacher dropdown filters down to 1 option + const teacherOptions = dialog.locator('#slot-teacher option:not([value=""])'); + await expect(teacherOptions).toHaveCount(1, { timeout: 10000 }); + await dialog.locator('#slot-subject').selectOption({ index: 1 }); + await dialog.locator('#slot-teacher').selectOption({ index: 1 }); + await dialog.locator('#slot-day').selectOption(dayValue); + await dialog.locator('#slot-start').fill(startTime); + await dialog.locator('#slot-end').fill(endTime); + if (room) { + await dialog.locator('#slot-room').fill(room); + } +} + +test.describe('Schedule Management - Modification & Conflicts & Calendar (Story 4.1)', () => { + // Tests share database state (same tenant, users, slots) so they must run sequentially + test.describe.configure({ mode: 'serial' }); + + test.beforeAll(async () => { + // Create admin user + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, + { encoding: 'utf-8' } + ); + + // Create teacher user + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`, + { encoding: 'utf-8' } + ); + + const { schoolId, academicYearId } = resolveDeterministicIds(); + + // Ensure test classes exist + try { + runSql( + `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-Schedule-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + } catch { + // May already exist + } + + try { + runSql( + `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-Schedule-5A', '5ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + } catch { + // May already exist + } + + // Ensure test subjects exist + try { + runSql( + `INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Schedule-Maths', 'E2ESCHEDMATH', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + } catch { + // May already exist + } + + try { + runSql( + `INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Schedule-Français', 'E2ESCHEDFRA', '#ef4444', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + } catch { + // May already exist + } + + cleanupScheduleData(); + cleanupCalendarEntries(); + clearCache(); + }); + + test.beforeEach(async () => { + cleanupScheduleData(); + cleanupCalendarEntries(); + try { + runSql(`DELETE FROM teacher_assignments WHERE tenant_id = '${TENANT_ID}'`); + } catch { + // Table may not exist + } + seedTeacherAssignments(); + clearCache(); + }); + + // ========================================================================== + // AC3: Slot Modification & Deletion + // ========================================================================== + test.describe('AC3: Slot Modification & Deletion', () => { + test('clicking a slot opens edit modal', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/schedule`); + + await waitForScheduleReady(page); + + // First create a slot + const timeCell = page.locator('.time-cell').first(); + await timeCell.click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + await fillSlotForm(dialog); + + await dialog.getByRole('button', { name: /créer/i }).click(); + await expect(dialog).not.toBeVisible({ timeout: 10000 }); + + // Click on the created slot + const slotCard = page.locator('.slot-card').first(); + await expect(slotCard).toBeVisible({ timeout: 10000 }); + await slotCard.click(); + + // Edit modal should appear + const editDialog = page.getByRole('dialog'); + await expect(editDialog).toBeVisible({ timeout: 10000 }); + await expect( + editDialog.getByRole('heading', { name: /modifier le créneau/i }) + ).toBeVisible(); + }); + + test('can delete a slot via edit modal', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/schedule`); + + await waitForScheduleReady(page); + + // Create a slot + const timeCell = page.locator('.time-cell').first(); + await timeCell.click(); + + let dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + await fillSlotForm(dialog, { dayValue: '2', startTime: '14:00', endTime: '15:00' }); + + await dialog.getByRole('button', { name: /créer/i }).click(); + await expect(dialog).not.toBeVisible({ timeout: 10000 }); + + // Click on the slot to edit + const slotCard = page.locator('.slot-card').first(); + await expect(slotCard).toBeVisible({ timeout: 10000 }); + await slotCard.click(); + + dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + // Click delete button + await dialog.getByRole('button', { name: /supprimer/i }).click(); + + // Confirmation modal should appear + const deleteModal = page.getByRole('alertdialog'); + await expect(deleteModal).toBeVisible({ timeout: 10000 }); + + // Confirm deletion + await deleteModal.getByRole('button', { name: /supprimer/i }).click(); + + // Modal should close and slot should disappear + await expect(deleteModal).not.toBeVisible({ timeout: 10000 }); + await expect(page.locator('.slot-card')).not.toBeVisible({ timeout: 10000 }); + }); + + test('can modify a slot and see updated data in grid', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/schedule`); + + await waitForScheduleReady(page); + + // Create initial slot with room + const timeCell = page.locator('.time-cell').first(); + await timeCell.click(); + + let dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + await fillSlotForm(dialog, { room: 'A101' }); + + await dialog.getByRole('button', { name: /créer/i }).click(); + await expect(dialog).not.toBeVisible({ timeout: 10000 }); + + // Verify initial slot with room A101 + const slotCard = page.locator('.slot-card').first(); + await expect(slotCard).toBeVisible({ timeout: 10000 }); + await expect(slotCard.getByText('A101')).toBeVisible(); + + // Click to open edit modal + await slotCard.click(); + + dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + await expect( + dialog.getByRole('heading', { name: /modifier le créneau/i }) + ).toBeVisible(); + + // Change room to B202 + await dialog.locator('#slot-room').clear(); + await dialog.locator('#slot-room').fill('B202'); + + // Submit modification + await dialog.getByRole('button', { name: /modifier/i }).click(); + await expect(dialog).not.toBeVisible({ timeout: 10000 }); + + // Verify success and updated data + await expect(page.getByText('Créneau modifié.')).toBeVisible({ timeout: 5000 }); + const updatedSlot = page.locator('.slot-card').first(); + await expect(updatedSlot.getByText('B202')).toBeVisible(); + }); + }); + + // ========================================================================== + // AC4: Conflict Detection + // ========================================================================== + test.describe('AC4: Conflict Detection', () => { + test('displays conflict warning when creating slot with same teacher at overlapping time', async ({ + page + }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/schedule`); + + await waitForScheduleReady(page); + + // Step 1: Create first slot (class 6A, Wednesday 10:00-11:00) + const timeCell = page.locator('.time-cell').first(); + await timeCell.click(); + + let dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + await fillSlotForm(dialog, { dayValue: '3', startTime: '10:00', endTime: '11:00' }); + + await dialog.getByRole('button', { name: /créer/i }).click(); + await expect(dialog).not.toBeVisible({ timeout: 10000 }); + await expect(page.locator('.slot-card')).toBeVisible({ timeout: 10000 }); + + // Step 2: Create conflicting slot with DIFFERENT class but SAME teacher at same time + const timeCell2 = page.locator('.time-cell').first(); + await timeCell2.click(); + + dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + await fillSlotForm(dialog, { + className: 'E2E-Schedule-5A', + dayValue: '3', + startTime: '10:00', + endTime: '11:00' + }); + + // Submit - should trigger conflict detection + await dialog.getByRole('button', { name: /créer/i }).click(); + + // Conflict warning should appear inside the dialog + await expect(dialog.locator('.alert-warning')).toBeVisible({ timeout: 10000 }); + await expect(dialog.getByText(/conflits détectés/i)).toBeVisible(); + + // Force checkbox should be available + await expect(dialog.getByText(/forcer la création/i)).toBeVisible(); + + // Dialog should still be open (not closed) + await expect(dialog).toBeVisible(); + }); + + test('can force creation despite detected conflict', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/schedule`); + + await waitForScheduleReady(page); + + // Step 1: Create first slot (class 6A, Thursday 14:00-15:00) + const timeCell = page.locator('.time-cell').first(); + await timeCell.click(); + + let dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + await fillSlotForm(dialog, { dayValue: '4', startTime: '14:00', endTime: '15:00' }); + + await dialog.getByRole('button', { name: /créer/i }).click(); + await expect(dialog).not.toBeVisible({ timeout: 10000 }); + await expect(page.locator('.slot-card')).toBeVisible({ timeout: 10000 }); + + // Step 2: Create conflicting slot with different class, same teacher, same time + const timeCell2 = page.locator('.time-cell').first(); + await timeCell2.click(); + + dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + await fillSlotForm(dialog, { + className: 'E2E-Schedule-5A', + dayValue: '4', + startTime: '14:00', + endTime: '15:00' + }); + + // First submit - triggers conflict warning + await dialog.getByRole('button', { name: /créer/i }).click(); + await expect(dialog.locator('.alert-warning')).toBeVisible({ timeout: 10000 }); + + // Check force checkbox + await dialog.locator('.force-checkbox input[type="checkbox"]').check(); + + // Submit again with force enabled + await dialog.getByRole('button', { name: /créer/i }).click(); + + // Modal should close - slot created despite conflict + await expect(dialog).not.toBeVisible({ timeout: 10000 }); + + // Success message should appear + await expect(page.getByText('Créneau créé.')).toBeVisible({ timeout: 5000 }); + }); + }); + + // ========================================================================== + // AC5: Calendar Respect (Blocked Days) + // ========================================================================== + test.describe('AC5: Calendar Respect', () => { + test('time validation prevents end before start', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/schedule`); + + await waitForScheduleReady(page); + + // Open creation modal + const timeCell = page.locator('.time-cell').first(); + await timeCell.click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + // Set end time before start time + await dialog.locator('#slot-start').fill('10:00'); + await dialog.locator('#slot-end').fill('09:00'); + + // Error message should appear + await expect( + dialog.getByText(/l'heure de fin doit être après/i) + ).toBeVisible(); + + // Submit should be disabled + await expect(dialog.getByRole('button', { name: /créer/i })).toBeDisabled(); + }); + + test('blocked day is visually marked in the grid', async ({ page }) => { + // Seed a holiday on Wednesday of current week + const wednesdayDate = getWeekdayInCurrentWeek(3); + seedBlockedDate(wednesdayDate, 'Jour férié test', 'holiday'); + clearCache(); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/schedule`); + await waitForScheduleReady(page); + + // The third day-column (Wednesday) should have the blocked class + const dayColumns = page.locator('.day-column'); + await expect(dayColumns.nth(2)).toHaveClass(/day-blocked/, { timeout: 10000 }); + + // Should display the reason badge in the header + await expect(page.getByText('Jour férié test')).toBeVisible(); + }); + + test('cannot create a slot on a blocked day', async ({ page }) => { + // Seed a vacation on Tuesday of current week + const tuesdayDate = getWeekdayInCurrentWeek(2); + seedBlockedDate(tuesdayDate, 'Vacances test', 'vacation'); + clearCache(); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/schedule`); + await waitForScheduleReady(page); + + // Tuesday column should be blocked + const dayColumns = page.locator('.day-column'); + await expect(dayColumns.nth(1)).toHaveClass(/day-blocked/, { timeout: 10000 }); + + // Attempt to click a time cell in the blocked day — dialog should NOT open + // Use dispatchEvent to bypass pointer-events: none + await dayColumns.nth(1).locator('.time-cell').first().dispatchEvent('click'); + + const dialog = page.getByRole('dialog'); + await expect(dialog).not.toBeVisible({ timeout: 3000 }); + }); + }); +}); diff --git a/frontend/e2e/schedule.spec.ts b/frontend/e2e/schedule.spec.ts new file mode 100644 index 0000000..f7ea8dd --- /dev/null +++ b/frontend/e2e/schedule.spec.ts @@ -0,0 +1,417 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; + +const ADMIN_EMAIL = 'e2e-schedule-admin@example.com'; +const ADMIN_PASSWORD = 'ScheduleTest123'; +const TEACHER_EMAIL = 'e2e-schedule-teacher@example.com'; +const TEACHER_PASSWORD = 'ScheduleTeacher123'; +const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + +const projectRoot = join(__dirname, '../..'); +const composeFile = join(projectRoot, 'compose.yaml'); + +function runSql(sql: string) { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`, + { encoding: 'utf-8' } + ); +} + +function clearCache() { + try { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`, + { encoding: 'utf-8' } + ); + } catch { + // Cache pool may not exist + } +} + +function resolveDeterministicIds(): { schoolId: string; academicYearId: string } { + const output = execSync( + `docker compose -f "${composeFile}" exec -T php php -r '` + + `require "/app/vendor/autoload.php"; ` + + `$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` + + `echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` + + `$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` + + `echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` + + `' 2>&1`, + { encoding: 'utf-8' } + ).trim(); + const [schoolId, academicYearId] = output.split('\n'); + return { schoolId: schoolId!, academicYearId: academicYearId! }; +} + +function cleanupScheduleData() { + try { + runSql(`DELETE FROM schedule_slots WHERE tenant_id = '${TENANT_ID}'`); + } catch { + // Table may not exist yet + } +} + +function seedTeacherAssignments() { + const { academicYearId } = resolveDeterministicIds(); + try { + // Assign test teacher to ALL classes × ALL subjects so any dropdown combo is valid + runSql( + `INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` + + `FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` + + `WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + + `AND c.tenant_id = '${TENANT_ID}' ` + + `AND s.tenant_id = '${TENANT_ID}' ` + + `ON CONFLICT DO NOTHING` + ); + } catch { + // Table may not exist + } +} + +async function loginAsAdmin(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(ADMIN_EMAIL); + await page.locator('#password').fill(ADMIN_PASSWORD); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 30000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); +} + +async function waitForScheduleReady(page: import('@playwright/test').Page) { + await expect(page.getByRole('heading', { name: /emploi du temps/i })).toBeVisible({ + timeout: 15000 + }); + // Wait for either the grid or the empty state to appear + await expect(page.locator('.schedule-grid, .empty-state, .alert-error')).toBeVisible({ + timeout: 15000 + }); +} + +async function fillSlotForm( + dialog: import('@playwright/test').Locator, + options: { + className?: string; + dayValue?: string; + startTime?: string; + endTime?: string; + room?: string; + } = {} +) { + const { className, dayValue = '1', startTime = '09:00', endTime = '10:00', room } = options; + + if (className) { + await dialog.locator('#slot-class').selectOption({ label: className }); + } + // Wait for assignments to load — only the test teacher is assigned, + // so the teacher dropdown filters down to 1 option + const teacherOptions = dialog.locator('#slot-teacher option:not([value=""])'); + await expect(teacherOptions).toHaveCount(1, { timeout: 10000 }); + await dialog.locator('#slot-subject').selectOption({ index: 1 }); + await dialog.locator('#slot-teacher').selectOption({ index: 1 }); + await dialog.locator('#slot-day').selectOption(dayValue); + await dialog.locator('#slot-start').fill(startTime); + await dialog.locator('#slot-end').fill(endTime); + if (room) { + await dialog.locator('#slot-room').fill(room); + } +} + +test.describe('Schedule Management - Navigation & Grid & Creation (Story 4.1)', () => { + // Tests share database state (same tenant, users, assignments) so they must run sequentially + test.describe.configure({ mode: 'serial' }); + + test.beforeAll(async () => { + // Create admin user + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, + { encoding: 'utf-8' } + ); + + // Create teacher user + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`, + { encoding: 'utf-8' } + ); + + const { schoolId, academicYearId } = resolveDeterministicIds(); + + // Ensure test class exists + try { + runSql( + `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-Schedule-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + } catch { + // May already exist + } + + // Ensure second test class exists (for conflict tests across classes) + try { + runSql( + `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-Schedule-5A', '5ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + } catch { + // May already exist + } + + // Ensure test subjects exist + try { + runSql( + `INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Schedule-Maths', 'E2ESCHEDMATH', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + } catch { + // May already exist + } + + try { + runSql( + `INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Schedule-Français', 'E2ESCHEDFRA', '#ef4444', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + } catch { + // May already exist + } + + cleanupScheduleData(); + clearCache(); + }); + + test.beforeEach(async () => { + cleanupScheduleData(); + try { + runSql(`DELETE FROM teacher_assignments WHERE tenant_id = '${TENANT_ID}'`); + } catch { + // Table may not exist + } + seedTeacherAssignments(); + clearCache(); + }); + + // ========================================================================== + // Navigation + // ========================================================================== + test.describe('Navigation', () => { + test('schedule link appears in admin navigation under Organisation', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin`); + + const nav = page.locator('.desktop-nav'); + await nav.getByRole('button', { name: /organisation/i }).hover(); + const navLink = nav.getByRole('menuitem', { name: /emploi du temps/i }); + await expect(navLink).toBeVisible({ timeout: 15000 }); + }); + + test('can navigate to schedule page', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/schedule`); + + await expect( + page.getByRole('heading', { name: /emploi du temps/i }) + ).toBeVisible({ timeout: 15000 }); + }); + }); + + // ========================================================================== + // AC1: Schedule Grid + // ========================================================================== + test.describe('AC1: Schedule Grid', () => { + test('displays weekly grid with day columns', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/schedule`); + + await waitForScheduleReady(page); + + // Check day headers are present + await expect(page.getByText('Lundi')).toBeVisible(); + await expect(page.getByText('Mardi')).toBeVisible(); + await expect(page.getByText('Mercredi')).toBeVisible(); + await expect(page.getByText('Jeudi')).toBeVisible(); + await expect(page.getByText('Vendredi')).toBeVisible(); + }); + + test('has class filter dropdown', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/schedule`); + + await waitForScheduleReady(page); + + const classFilter = page.locator('#filter-class'); + await expect(classFilter).toBeVisible(); + // Should have at least the placeholder option + one class + const options = classFilter.locator('option'); + await expect(options).not.toHaveCount(1, { timeout: 10000 }); + }); + + test('has teacher filter dropdown', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/schedule`); + + await waitForScheduleReady(page); + + const teacherFilter = page.locator('#filter-teacher'); + await expect(teacherFilter).toBeVisible(); + }); + }); + + // ========================================================================== + // AC2: Slot Creation + // ========================================================================== + test.describe('AC2: Slot Creation', () => { + test('clicking on a time cell opens creation modal', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/schedule`); + + await waitForScheduleReady(page); + + // Click on a time cell in the grid + const timeCell = page.locator('.time-cell').first(); + await timeCell.click(); + + // Modal should appear + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + await expect( + dialog.getByRole('heading', { name: /nouveau créneau/i }) + ).toBeVisible(); + }); + + test('creation form has required fields', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/schedule`); + + await waitForScheduleReady(page); + + // Open creation modal + const timeCell = page.locator('.time-cell').first(); + await timeCell.click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + // Check required form fields + await expect(dialog.locator('#slot-subject')).toBeVisible(); + await expect(dialog.locator('#slot-teacher')).toBeVisible(); + await expect(dialog.locator('#slot-day')).toBeVisible(); + await expect(dialog.locator('#slot-start')).toBeVisible(); + await expect(dialog.locator('#slot-end')).toBeVisible(); + await expect(dialog.locator('#slot-room')).toBeVisible(); + + // Submit button should be disabled when fields are empty + const submitButton = dialog.getByRole('button', { name: /créer/i }); + await expect(submitButton).toBeDisabled(); + }); + + test('can close creation modal with cancel button', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/schedule`); + + await waitForScheduleReady(page); + + const timeCell = page.locator('.time-cell').first(); + await timeCell.click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + // Click cancel + await dialog.getByRole('button', { name: /annuler/i }).click(); + await expect(dialog).not.toBeVisible({ timeout: 5000 }); + }); + + test('can close creation modal with Escape key', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/schedule`); + + await waitForScheduleReady(page); + + const timeCell = page.locator('.time-cell').first(); + await timeCell.click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + // Press Escape + await page.keyboard.press('Escape'); + await expect(dialog).not.toBeVisible({ timeout: 5000 }); + }); + + test('can create a slot and see it in the grid', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/schedule`); + + await waitForScheduleReady(page); + + // Open creation modal + const timeCell = page.locator('.time-cell').first(); + await timeCell.click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + await fillSlotForm(dialog, { room: 'A101' }); + + // Submit + const submitButton = dialog.getByRole('button', { name: /créer/i }); + await expect(submitButton).toBeEnabled(); + await submitButton.click(); + + // Modal should close + await expect(dialog).not.toBeVisible({ timeout: 10000 }); + + // Slot card should appear in the grid + await expect(page.locator('.slot-card')).toBeVisible({ timeout: 10000 }); + + // Should show room on the slot card + await expect(page.locator('.slot-card').getByText('A101')).toBeVisible(); + }); + + test('filters subjects and teachers by class assignment', async ({ page }) => { + const { academicYearId } = resolveDeterministicIds(); + + // Clear all assignments, seed exactly one: teacher → class 6A → first subject + runSql(`DELETE FROM teacher_assignments WHERE tenant_id = '${TENANT_ID}'`); + runSql( + `INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` + + `FROM users u, school_classes c, (SELECT id FROM subjects WHERE tenant_id = '${TENANT_ID}' ORDER BY name LIMIT 1) s ` + + `WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + + `AND c.name = 'E2E-Schedule-6A' AND c.tenant_id = '${TENANT_ID}' ` + + `ON CONFLICT DO NOTHING` + ); + clearCache(); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/schedule`); + await waitForScheduleReady(page); + + // Open creation modal + const timeCell = page.locator('.time-cell').first(); + await timeCell.click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + // Select class E2E-Schedule-6A (triggers loadAssignments for this class) + await dialog.locator('#slot-class').selectOption({ label: 'E2E-Schedule-6A' }); + + // Subject dropdown should be filtered to only the assigned subject + // (auto-retry handles the async assignment loading) + const subjectOptions = dialog.locator('#slot-subject option:not([value=""])'); + await expect(subjectOptions).toHaveCount(1, { timeout: 15000 }); + + // Teacher dropdown should only show the assigned teacher + const teacherOptions = dialog.locator('#slot-teacher option:not([value=""])'); + await expect(teacherOptions).toHaveCount(1, { timeout: 10000 }); + }); + }); +}); diff --git a/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte b/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte index fe732f6..2f9de16 100644 --- a/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte +++ b/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte @@ -61,6 +61,11 @@ Remplacements Enseignants absents + + 🕐 + Emploi du temps + Cours et créneaux + 📅 Périodes scolaires diff --git a/frontend/src/routes/admin/+layout.svelte b/frontend/src/routes/admin/+layout.svelte index 39fcc94..da6cf6c 100644 --- a/frontend/src/routes/admin/+layout.svelte +++ b/frontend/src/routes/admin/+layout.svelte @@ -49,7 +49,8 @@ { href: '/admin/classes', label: 'Classes' }, { href: '/admin/subjects', label: 'Matières' }, { href: '/admin/assignments', label: 'Affectations' }, - { href: '/admin/replacements', label: 'Remplacements' } + { href: '/admin/replacements', label: 'Remplacements' }, + { href: '/admin/schedule', label: 'Emploi du temps' } ] }, { diff --git a/frontend/src/routes/admin/schedule/+page.svelte b/frontend/src/routes/admin/schedule/+page.svelte new file mode 100644 index 0000000..f24ea98 --- /dev/null +++ b/frontend/src/routes/admin/schedule/+page.svelte @@ -0,0 +1,1528 @@ + + + { + if (e.key === 'Escape') { + if (showDeleteModal) closeDeleteModal(); + else if (showSlotModal) closeSlotModal(); + } +}} /> + + + Emploi du temps - Classeo + + +
+ + + {#if error} +
+ ! + {error} + +
+ {/if} + + {#if successMessage} +
+ {successMessage} +
+ {/if} + + +
+
+ + +
+
+ + +
+
+ + {#if isLoading} +
+
+

Chargement...

+
+ {:else if !selectedClassId && !selectedTeacherFilter} +
+ 📅 +

Sélectionnez une classe

+

Choisissez une classe dans le filtre pour afficher son emploi du temps.

+
+ {:else} + +
+ {#each DAYS as day} + + {/each} +
+ + +
+ +
+
+ {#each DAYS as day} +
+ {day.label} + {#if blockedDays.has(day.value)} + {blockedDays.get(day.value)?.reason} + {/if} +
+ {/each} +
+ + +
+ +
+ {#each TIME_SLOTS as time} +
+ {#if time.endsWith(':00')} + {time} + {/if} +
+ {/each} +
+ + + {#each DAYS as day} +
+ + {#each TIME_SLOTS as time} + +
handleDrop(e, day.value, time)} + onclick={() => { if (!blockedDays.has(day.value)) openCreateModal(day.value, time); }} + title={blockedDays.has(day.value) ? `Jour bloqué : ${blockedDays.get(day.value)?.reason}` : `Cliquer pour ajouter un cours à ${time}`} + >
+ {/each} + + + {#each getSlotsForDay(day.value) as slot (slot.id)} + {@const bgColor = getSubjectColor(slot.subjectId)} + {@const textColor = getContrastColor(bgColor)} + +
handleDragStart(e, slot)} + ondragend={handleDragEnd} + onclick={(e) => { e.stopPropagation(); openEditModal(slot); }} + role="button" + tabindex="0" + onkeydown={(e) => { if (e.key === 'Enter') openEditModal(slot); }} + title="{getSubjectName(slot.subjectId)} - {getTeacherName(slot.teacherId)}" + > + {getSubjectName(slot.subjectId)} + {getTeacherName(slot.teacherId)} + {#if slot.room} + {slot.room} + {/if} +
+ {/each} +
+ {/each} +
+
+ {/if} +
+ + +{#if showSlotModal} + + +{/if} + + +{#if showDeleteModal && slotToDelete} + + +{/if} + +