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} + +