From 2e2328c6ca29d5bd4f5ec9bfc691a850ad1c5784 Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Sun, 22 Mar 2026 17:01:32 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Permettre=20=C3=A0=20l'=C3=A9l=C3=A8ve?= =?UTF-8?q?=20de=20consulter=20ses=20devoirs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'élève n'avait aucun moyen de voir les devoirs assignés à sa classe. Cette fonctionnalité ajoute la consultation complète : liste triée par échéance, détail avec pièces jointes, filtrage par matière, et marquage personnel « fait » en localStorage. Le dashboard élève affiche désormais les devoirs à venir avec ouverture du détail en modale, et un lien vers la page complète. L'accès API est sécurisé par vérification de la classe de l'élève (pas d'IDOR) et validation du chemin des pièces jointes (pas de path traversal). --- .../GetStudentHomework/AttachmentDto.php | 16 + .../GetStudentHomeworkHandler.php | 97 +++ .../GetStudentHomeworkQuery.php | 15 + .../StudentHomeworkDetailDto.php | 64 ++ .../GetStudentHomework/StudentHomeworkDto.php | 47 ++ .../HomeworkAttachmentRepository.php | 5 + .../Controller/StudentHomeworkController.php | 208 +++++++ .../DoctrineHomeworkAttachmentRepository.php | 29 + .../InMemoryHomeworkAttachmentRepository.php | 17 + .../Security/HomeworkStudentVoter.php | 43 ++ .../GetStudentHomeworkHandlerTest.php | 259 ++++++++ frontend/e2e/student-homework.spec.ts | 570 ++++++++++++++++++ .../Dashboard/DashboardStudent.svelte | 174 +++++- .../StudentHomework/HomeworkCard.svelte | 181 ++++++ .../StudentHomework/HomeworkDetail.svelte | 238 ++++++++ .../StudentHomeworkList.svelte | 277 +++++++++ .../features/homework/api/studentHomework.ts | 76 +++ .../homework/stores/homeworkStatus.svelte.ts | 60 ++ .../routes/dashboard/homework/+page.svelte | 34 ++ frontend/vite.config.ts | 44 +- 20 files changed, 2442 insertions(+), 12 deletions(-) create mode 100644 backend/src/Scolarite/Application/Query/GetStudentHomework/AttachmentDto.php create mode 100644 backend/src/Scolarite/Application/Query/GetStudentHomework/GetStudentHomeworkHandler.php create mode 100644 backend/src/Scolarite/Application/Query/GetStudentHomework/GetStudentHomeworkQuery.php create mode 100644 backend/src/Scolarite/Application/Query/GetStudentHomework/StudentHomeworkDetailDto.php create mode 100644 backend/src/Scolarite/Application/Query/GetStudentHomework/StudentHomeworkDto.php create mode 100644 backend/src/Scolarite/Infrastructure/Api/Controller/StudentHomeworkController.php create mode 100644 backend/src/Scolarite/Infrastructure/Security/HomeworkStudentVoter.php create mode 100644 backend/tests/Unit/Scolarite/Application/Query/GetStudentHomework/GetStudentHomeworkHandlerTest.php create mode 100644 frontend/e2e/student-homework.spec.ts create mode 100644 frontend/src/lib/components/organisms/StudentHomework/HomeworkCard.svelte create mode 100644 frontend/src/lib/components/organisms/StudentHomework/HomeworkDetail.svelte create mode 100644 frontend/src/lib/components/organisms/StudentHomework/StudentHomeworkList.svelte create mode 100644 frontend/src/lib/features/homework/api/studentHomework.ts create mode 100644 frontend/src/lib/features/homework/stores/homeworkStatus.svelte.ts create mode 100644 frontend/src/routes/dashboard/homework/+page.svelte diff --git a/backend/src/Scolarite/Application/Query/GetStudentHomework/AttachmentDto.php b/backend/src/Scolarite/Application/Query/GetStudentHomework/AttachmentDto.php new file mode 100644 index 0000000..b468be3 --- /dev/null +++ b/backend/src/Scolarite/Application/Query/GetStudentHomework/AttachmentDto.php @@ -0,0 +1,16 @@ + */ + public function __invoke(GetStudentHomeworkQuery $query): array + { + $tenantId = TenantId::fromString($query->tenantId); + + $classId = $this->studentClassReader->currentClassId($query->studentId, $tenantId); + + if ($classId === null) { + return []; + } + + $homeworks = $this->homeworkRepository->findByClass(ClassId::fromString($classId), $tenantId); + + if ($query->subjectId !== null) { + $filterSubjectId = $query->subjectId; + $homeworks = array_values(array_filter( + $homeworks, + static fn (Homework $h): bool => (string) $h->subjectId === $filterSubjectId, + )); + } + + usort($homeworks, static fn (Homework $a, Homework $b): int => $a->dueDate <=> $b->dueDate); + + return $this->enrichHomeworks($homeworks, $query->tenantId); + } + + /** + * @param array $homeworks + * + * @return array + */ + private function enrichHomeworks(array $homeworks, string $tenantId): array + { + if ($homeworks === []) { + return []; + } + + $subjectIds = array_values(array_unique( + array_map(static fn (Homework $h): string => (string) $h->subjectId, $homeworks), + )); + $teacherIds = array_values(array_unique( + array_map(static fn (Homework $h): string => (string) $h->teacherId, $homeworks), + )); + + $subjects = $this->displayReader->subjectDisplay($tenantId, ...$subjectIds); + $teacherNames = $this->displayReader->teacherNames($tenantId, ...$teacherIds); + + $homeworkIds = array_map(static fn (Homework $h): HomeworkId => $h->id, $homeworks); + $attachmentMap = $this->attachmentRepository->hasAttachments(...$homeworkIds); + + return array_map( + static fn (Homework $h): StudentHomeworkDto => StudentHomeworkDto::fromDomain( + $h, + $subjects[(string) $h->subjectId]['name'] ?? '', + $subjects[(string) $h->subjectId]['color'] ?? null, + $teacherNames[(string) $h->teacherId] ?? '', + $attachmentMap[(string) $h->id] ?? false, + ), + $homeworks, + ); + } +} diff --git a/backend/src/Scolarite/Application/Query/GetStudentHomework/GetStudentHomeworkQuery.php b/backend/src/Scolarite/Application/Query/GetStudentHomework/GetStudentHomeworkQuery.php new file mode 100644 index 0000000..8950d8a --- /dev/null +++ b/backend/src/Scolarite/Application/Query/GetStudentHomework/GetStudentHomeworkQuery.php @@ -0,0 +1,15 @@ + $attachments + */ + public function __construct( + public string $id, + public string $subjectId, + public string $subjectName, + public ?string $subjectColor, + public string $teacherId, + public string $teacherName, + public string $title, + public ?string $description, + public string $dueDate, + public string $createdAt, + public array $attachments, + ) { + } + + /** + * @param array $attachments + */ + public static function fromDomain( + Homework $homework, + string $subjectName, + ?string $subjectColor, + string $teacherName, + array $attachments, + ): self { + return new self( + id: (string) $homework->id, + subjectId: (string) $homework->subjectId, + subjectName: $subjectName, + subjectColor: $subjectColor, + teacherId: (string) $homework->teacherId, + teacherName: $teacherName, + title: $homework->title, + description: $homework->description, + dueDate: $homework->dueDate->format('Y-m-d'), + createdAt: $homework->createdAt->format('Y-m-d\TH:i:sP'), + attachments: array_map( + static fn (HomeworkAttachment $a): AttachmentDto => new AttachmentDto( + id: (string) $a->id, + filename: $a->filename, + fileSize: $a->fileSize, + mimeType: $a->mimeType, + ), + $attachments, + ), + ); + } +} diff --git a/backend/src/Scolarite/Application/Query/GetStudentHomework/StudentHomeworkDto.php b/backend/src/Scolarite/Application/Query/GetStudentHomework/StudentHomeworkDto.php new file mode 100644 index 0000000..9101334 --- /dev/null +++ b/backend/src/Scolarite/Application/Query/GetStudentHomework/StudentHomeworkDto.php @@ -0,0 +1,47 @@ +id, + subjectId: (string) $homework->subjectId, + subjectName: $subjectName, + subjectColor: $subjectColor, + teacherId: (string) $homework->teacherId, + teacherName: $teacherName, + title: $homework->title, + description: $homework->description, + dueDate: $homework->dueDate->format('Y-m-d'), + createdAt: $homework->createdAt->format('Y-m-d\TH:i:sP'), + hasAttachments: $hasAttachments, + ); + } +} diff --git a/backend/src/Scolarite/Domain/Repository/HomeworkAttachmentRepository.php b/backend/src/Scolarite/Domain/Repository/HomeworkAttachmentRepository.php index 5646c81..db91e91 100644 --- a/backend/src/Scolarite/Domain/Repository/HomeworkAttachmentRepository.php +++ b/backend/src/Scolarite/Domain/Repository/HomeworkAttachmentRepository.php @@ -12,5 +12,10 @@ interface HomeworkAttachmentRepository /** @return array */ public function findByHomeworkId(HomeworkId $homeworkId): array; + /** + * @return array Map homeworkId => hasAttachments + */ + public function hasAttachments(HomeworkId ...$homeworkIds): array; + public function save(HomeworkId $homeworkId, HomeworkAttachment $attachment): void; } diff --git a/backend/src/Scolarite/Infrastructure/Api/Controller/StudentHomeworkController.php b/backend/src/Scolarite/Infrastructure/Api/Controller/StudentHomeworkController.php new file mode 100644 index 0000000..2bbd470 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Controller/StudentHomeworkController.php @@ -0,0 +1,208 @@ +getSecurityUser(); + + $subjectId = $request->query->get('subjectId'); + + $result = ($this->handler)(new GetStudentHomeworkQuery( + studentId: $user->userId(), + tenantId: $user->tenantId(), + subjectId: is_string($subjectId) && $subjectId !== '' ? $subjectId : null, + )); + + return new JsonResponse([ + 'data' => array_map($this->serializeListItem(...), $result), + ]); + } + + #[Route('/api/me/homework/{id}', name: 'api_student_homework_detail', methods: ['GET'])] + public function detail(string $id): JsonResponse + { + $user = $this->getSecurityUser(); + $tenantId = TenantId::fromString($user->tenantId()); + + $homework = $this->homeworkRepository->findById(HomeworkId::fromString($id), $tenantId); + + if ($homework === null) { + throw new NotFoundHttpException('Devoir non trouvé.'); + } + + $this->assertHomeworkBelongsToStudentClass($user, $homework); + + $attachments = $this->attachmentRepository->findByHomeworkId($homework->id); + + $subjects = $this->displayReader->subjectDisplay($user->tenantId(), (string) $homework->subjectId); + $teacherNames = $this->displayReader->teacherNames($user->tenantId(), (string) $homework->teacherId); + + $detail = StudentHomeworkDetailDto::fromDomain( + $homework, + $subjects[(string) $homework->subjectId]['name'] ?? '', + $subjects[(string) $homework->subjectId]['color'] ?? null, + $teacherNames[(string) $homework->teacherId] ?? '', + $attachments, + ); + + return new JsonResponse(['data' => $this->serializeDetail($detail)]); + } + + #[Route('/api/me/homework/{homeworkId}/attachments/{attachmentId}', name: 'api_student_homework_attachment', methods: ['GET'])] + public function downloadAttachment(string $homeworkId, string $attachmentId): BinaryFileResponse + { + $user = $this->getSecurityUser(); + $tenantId = TenantId::fromString($user->tenantId()); + + $homework = $this->homeworkRepository->findById(HomeworkId::fromString($homeworkId), $tenantId); + + if ($homework === null) { + throw new NotFoundHttpException('Devoir non trouvé.'); + } + + $this->assertHomeworkBelongsToStudentClass($user, $homework); + + $attachments = $this->attachmentRepository->findByHomeworkId($homework->id); + + foreach ($attachments as $attachment) { + if ((string) $attachment->id === $attachmentId) { + $realPath = realpath($attachment->filePath); + $realUploadsDir = realpath($this->uploadsDir); + + if ($realPath === false || $realUploadsDir === false || !str_starts_with($realPath, $realUploadsDir)) { + throw new NotFoundHttpException('Pièce jointe non trouvée.'); + } + + $response = new BinaryFileResponse($realPath); + $response->setContentDisposition( + ResponseHeaderBag::DISPOSITION_INLINE, + $attachment->filename, + ); + + return $response; + } + } + + throw new NotFoundHttpException('Pièce jointe non trouvée.'); + } + + private function assertHomeworkBelongsToStudentClass(SecurityUser $user, Homework $homework): void + { + $classId = $this->studentClassReader->currentClassId( + $user->userId(), + TenantId::fromString($user->tenantId()), + ); + + if ($classId === null || (string) $homework->classId !== $classId) { + throw new NotFoundHttpException('Devoir non trouvé.'); + } + } + + private function getSecurityUser(): SecurityUser + { + $user = $this->security->getUser(); + + if (!$user instanceof SecurityUser) { + throw new AccessDeniedHttpException('Authentification requise.'); + } + + return $user; + } + + /** + * @return array + */ + private function serializeListItem(StudentHomeworkDto $dto): array + { + return [ + 'id' => $dto->id, + 'subjectId' => $dto->subjectId, + 'subjectName' => $dto->subjectName, + 'subjectColor' => $dto->subjectColor, + 'teacherId' => $dto->teacherId, + 'teacherName' => $dto->teacherName, + 'title' => $dto->title, + 'description' => $dto->description, + 'dueDate' => $dto->dueDate, + 'createdAt' => $dto->createdAt, + 'hasAttachments' => $dto->hasAttachments, + ]; + } + + /** + * @return array + */ + private function serializeDetail(StudentHomeworkDetailDto $dto): array + { + return [ + 'id' => $dto->id, + 'subjectId' => $dto->subjectId, + 'subjectName' => $dto->subjectName, + 'subjectColor' => $dto->subjectColor, + 'teacherId' => $dto->teacherId, + 'teacherName' => $dto->teacherName, + 'title' => $dto->title, + 'description' => $dto->description, + 'dueDate' => $dto->dueDate, + 'createdAt' => $dto->createdAt, + 'attachments' => array_map( + static fn ($a): array => [ + 'id' => $a->id, + 'filename' => $a->filename, + 'fileSize' => $a->fileSize, + 'mimeType' => $a->mimeType, + ], + $dto->attachments, + ), + ]; + } +} diff --git a/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineHomeworkAttachmentRepository.php b/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineHomeworkAttachmentRepository.php index 5ef635d..a552c71 100644 --- a/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineHomeworkAttachmentRepository.php +++ b/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineHomeworkAttachmentRepository.php @@ -9,9 +9,11 @@ use App\Scolarite\Domain\Model\Homework\HomeworkAttachmentId; use App\Scolarite\Domain\Model\Homework\HomeworkId; use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository; +use function array_fill_keys; use function array_map; use DateTimeImmutable; +use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Connection; use Override; @@ -33,6 +35,33 @@ final readonly class DoctrineHomeworkAttachmentRepository implements HomeworkAtt return array_map($this->hydrate(...), $rows); } + #[Override] + public function hasAttachments(HomeworkId ...$homeworkIds): array + { + if ($homeworkIds === []) { + return []; + } + + $ids = array_map(static fn (HomeworkId $id): string => (string) $id, $homeworkIds); + + /** @var array $rows */ + $rows = $this->connection->fetchAllAssociative( + 'SELECT DISTINCT homework_id FROM homework_attachments WHERE homework_id IN (:ids)', + ['ids' => $ids], + ['ids' => ArrayParameterType::STRING], + ); + + $result = array_fill_keys($ids, false); + + foreach ($rows as $row) { + /** @var string $hwId */ + $hwId = $row['homework_id']; + $result[$hwId] = true; + } + + return $result; + } + #[Override] public function save(HomeworkId $homeworkId, HomeworkAttachment $attachment): void { diff --git a/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryHomeworkAttachmentRepository.php b/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryHomeworkAttachmentRepository.php index a0e2bb3..00f7aea 100644 --- a/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryHomeworkAttachmentRepository.php +++ b/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryHomeworkAttachmentRepository.php @@ -7,6 +7,10 @@ namespace App\Scolarite\Infrastructure\Persistence\InMemory; use App\Scolarite\Domain\Model\Homework\HomeworkAttachment; use App\Scolarite\Domain\Model\Homework\HomeworkId; use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository; + +use function array_fill_keys; +use function array_map; + use Override; final class InMemoryHomeworkAttachmentRepository implements HomeworkAttachmentRepository @@ -20,6 +24,19 @@ final class InMemoryHomeworkAttachmentRepository implements HomeworkAttachmentRe return $this->byHomeworkId[(string) $homeworkId] ?? []; } + #[Override] + public function hasAttachments(HomeworkId ...$homeworkIds): array + { + $ids = array_map(static fn (HomeworkId $id): string => (string) $id, $homeworkIds); + $result = array_fill_keys($ids, false); + + foreach ($ids as $id) { + $result[$id] = isset($this->byHomeworkId[$id]) && $this->byHomeworkId[$id] !== []; + } + + return $result; + } + #[Override] public function save(HomeworkId $homeworkId, HomeworkAttachment $attachment): void { diff --git a/backend/src/Scolarite/Infrastructure/Security/HomeworkStudentVoter.php b/backend/src/Scolarite/Infrastructure/Security/HomeworkStudentVoter.php new file mode 100644 index 0000000..6ec6c6f --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Security/HomeworkStudentVoter.php @@ -0,0 +1,43 @@ + + */ +final class HomeworkStudentVoter extends Voter +{ + public const string VIEW = 'HOMEWORK_STUDENT_VIEW'; + + #[Override] + protected function supports(string $attribute, mixed $subject): bool + { + return $attribute === self::VIEW; + } + + #[Override] + protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool + { + $user = $token->getUser(); + + if (!$user instanceof SecurityUser) { + return false; + } + + return in_array(Role::ELEVE->value, $user->getRoles(), true); + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Query/GetStudentHomework/GetStudentHomeworkHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Query/GetStudentHomework/GetStudentHomeworkHandlerTest.php new file mode 100644 index 0000000..ebb7555 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Query/GetStudentHomework/GetStudentHomeworkHandlerTest.php @@ -0,0 +1,259 @@ +homeworkRepository = new InMemoryHomeworkRepository(); + $this->attachmentRepository = new InMemoryHomeworkAttachmentRepository(); + } + + #[Test] + public function itReturnsEmptyWhenStudentHasNoClass(): void + { + $handler = $this->createHandler(classId: null); + + $result = $handler(new GetStudentHomeworkQuery( + studentId: self::STUDENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertSame([], $result); + } + + #[Test] + public function itReturnsHomeworkForStudentClass(): void + { + $this->givenHomework(title: 'Exercices chapitre 5', dueDate: '2026-04-15'); + $handler = $this->createHandler(); + + $result = $handler(new GetStudentHomeworkQuery( + studentId: self::STUDENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(1, $result); + self::assertSame('Exercices chapitre 5', $result[0]->title); + self::assertSame('2026-04-15', $result[0]->dueDate); + self::assertSame('Mathématiques', $result[0]->subjectName); + self::assertSame('#3b82f6', $result[0]->subjectColor); + self::assertSame('Jean Dupont', $result[0]->teacherName); + } + + #[Test] + public function itSortsByDueDateAscending(): void + { + $this->givenHomework(title: 'Devoir lointain', dueDate: '2026-05-20'); + $this->givenHomework(title: 'Devoir proche', dueDate: '2026-04-10'); + $this->givenHomework(title: 'Devoir milieu', dueDate: '2026-04-25'); + + $handler = $this->createHandler(); + + $result = $handler(new GetStudentHomeworkQuery( + studentId: self::STUDENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(3, $result); + self::assertSame('Devoir proche', $result[0]->title); + self::assertSame('Devoir milieu', $result[1]->title); + self::assertSame('Devoir lointain', $result[2]->title); + } + + #[Test] + public function itFiltersDeletedHomework(): void + { + $homework = $this->givenHomework(title: 'Devoir supprimé', dueDate: '2026-04-15'); + $homework->supprimer(new DateTimeImmutable('2026-03-20')); + $this->homeworkRepository->save($homework); + + $this->givenHomework(title: 'Devoir actif', dueDate: '2026-04-20'); + + $handler = $this->createHandler(); + + $result = $handler(new GetStudentHomeworkQuery( + studentId: self::STUDENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(1, $result); + self::assertSame('Devoir actif', $result[0]->title); + } + + #[Test] + public function itFiltersBySubjectWhenProvided(): void + { + $this->givenHomework(title: 'Maths', dueDate: '2026-04-15', subjectId: self::SUBJECT_MATH_ID); + $this->givenHomework(title: 'Français', dueDate: '2026-04-16', subjectId: self::SUBJECT_FRENCH_ID); + + $handler = $this->createHandler(); + + $result = $handler(new GetStudentHomeworkQuery( + studentId: self::STUDENT_ID, + tenantId: self::TENANT_ID, + subjectId: self::SUBJECT_MATH_ID, + )); + + self::assertCount(1, $result); + self::assertSame('Maths', $result[0]->title); + } + + #[Test] + public function itExcludesHomeworkFromOtherClasses(): void + { + $this->givenHomework(title: 'Mon devoir', dueDate: '2026-04-15', classId: self::CLASS_ID); + + $otherClassId = '550e8400-e29b-41d4-a716-446655440099'; + $this->givenHomework(title: 'Autre classe', dueDate: '2026-04-16', classId: $otherClassId); + + $handler = $this->createHandler(); + + $result = $handler(new GetStudentHomeworkQuery( + studentId: self::STUDENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(1, $result); + self::assertSame('Mon devoir', $result[0]->title); + } + + #[Test] + public function itIndicatesWhenHomeworkHasAttachments(): void + { + $homework = $this->givenHomework(title: 'Avec pièce jointe', dueDate: '2026-04-15'); + + $attachment = new HomeworkAttachment( + id: HomeworkAttachmentId::generate(), + filename: 'exercice.pdf', + filePath: '/uploads/exercice.pdf', + fileSize: 1024, + mimeType: 'application/pdf', + uploadedAt: new DateTimeImmutable('2026-03-12'), + ); + $this->attachmentRepository->save($homework->id, $attachment); + + $handler = $this->createHandler(); + + $result = $handler(new GetStudentHomeworkQuery( + studentId: self::STUDENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(1, $result); + self::assertTrue($result[0]->hasAttachments); + } + + #[Test] + public function itIndicatesWhenHomeworkHasNoAttachments(): void + { + $this->givenHomework(title: 'Sans pièce jointe', dueDate: '2026-04-15'); + + $handler = $this->createHandler(); + + $result = $handler(new GetStudentHomeworkQuery( + studentId: self::STUDENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(1, $result); + self::assertFalse($result[0]->hasAttachments); + } + + private function createHandler(?string $classId = self::CLASS_ID): GetStudentHomeworkHandler + { + $studentClassReader = new class($classId) implements StudentClassReader { + public function __construct(private readonly ?string $classId) + { + } + + public function currentClassId(string $studentId, TenantId $tenantId): ?string + { + return $this->classId; + } + }; + + $displayReader = new class implements ScheduleDisplayReader { + public function subjectDisplay(string $tenantId, string ...$subjectIds): array + { + $map = []; + + foreach ($subjectIds as $id) { + $map[$id] = ['name' => 'Mathématiques', 'color' => '#3b82f6']; + } + + return $map; + } + + public function teacherNames(string $tenantId, string ...$teacherIds): array + { + $map = []; + + foreach ($teacherIds as $id) { + $map[$id] = 'Jean Dupont'; + } + + return $map; + } + }; + + return new GetStudentHomeworkHandler( + $studentClassReader, + $this->homeworkRepository, + $this->attachmentRepository, + $displayReader, + ); + } + + private function givenHomework( + string $title, + string $dueDate, + string $classId = self::CLASS_ID, + string $subjectId = self::SUBJECT_MATH_ID, + ): Homework { + $homework = Homework::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + classId: ClassId::fromString($classId), + subjectId: SubjectId::fromString($subjectId), + teacherId: UserId::fromString(self::TEACHER_ID), + title: $title, + description: null, + dueDate: new DateTimeImmutable($dueDate), + now: new DateTimeImmutable('2026-03-12 10:00:00'), + ); + + $this->homeworkRepository->save($homework); + + return $homework; + } +} diff --git a/frontend/e2e/student-homework.spec.ts b/frontend/e2e/student-homework.spec.ts new file mode 100644 index 0000000..4260c26 --- /dev/null +++ b/frontend/e2e/student-homework.spec.ts @@ -0,0 +1,570 @@ +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 STUDENT_EMAIL = 'e2e-student-homework@example.com'; +const STUDENT_PASSWORD = 'StudentHomework123'; +const TEACHER_EMAIL = 'e2e-student-hw-teacher@example.com'; +const TEACHER_PASSWORD = 'TeacherHomework123'; +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 getNextWeekday(daysFromNow: number): string { + const date = new Date(); + date.setDate(date.getDate() + daysFromNow); + const day = date.getDay(); + if (day === 0) date.setDate(date.getDate() + 1); + if (day === 6) date.setDate(date.getDate() + 2); + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +async function loginAsStudent(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(STUDENT_EMAIL); + await page.locator('#password').fill(STUDENT_PASSWORD); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 30000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); +} + +test.describe('Student Homework Consultation (Story 5.7)', () => { + test.describe.configure({ mode: 'serial' }); + + const dueDate1 = getNextWeekday(5); + const dueDate2 = getNextWeekday(10); + + test.beforeAll(async () => { + try { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter users.cache --env=dev 2>&1`, + { encoding: 'utf-8' } + ); + } catch { + // Cache pools may not exist + } + + // Create student user + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT_EMAIL} --password=${STUDENT_PASSWORD} --role=ROLE_ELEVE 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 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-StudentHW-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + } catch { + // May already exist + } + + // Ensure 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-StudentHW-Maths', 'E2ESHWMAT', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + 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-StudentHW-Français', 'E2ESHWFRA', '#ef4444', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + } catch { + // May already exist + } + + // Assign student to class + runSql( + `INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, '${academicYearId}', NOW(), NOW(), NOW() ` + + `FROM users u, school_classes c ` + + `WHERE u.email = '${STUDENT_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + + `AND c.name = 'E2E-StudentHW-6A' AND c.tenant_id = '${TENANT_ID}' ` + + `ON CONFLICT DO NOTHING` + ); + + // Clean up homework data + try { + runSql( + `DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND class_id IN ` + + `(SELECT id FROM school_classes WHERE name = 'E2E-StudentHW-6A' AND tenant_id = '${TENANT_ID}')` + ); + } catch { + // Table may not exist + } + + // Seed homework for the student's class + runSql( + `INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, 'Exercices chapitre 3', 'Faire les exercices 1 à 10 page 42', '${dueDate1}', 'published', NOW(), NOW() ` + + `FROM school_classes c, ` + + `(SELECT id FROM subjects WHERE code = 'E2ESHWMAT' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` + + `(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` + + `WHERE c.name = 'E2E-StudentHW-6A' AND c.tenant_id = '${TENANT_ID}'` + ); + + runSql( + `INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, 'Rédaction sur les vacances', 'Écrire une rédaction de 200 mots', '${dueDate2}', 'published', NOW(), NOW() ` + + `FROM school_classes c, ` + + `(SELECT id FROM subjects WHERE code = 'E2ESHWFRA' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` + + `(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` + + `WHERE c.name = 'E2E-StudentHW-6A' AND c.tenant_id = '${TENANT_ID}'` + ); + + // Create a dummy attachment file in the container + execSync( + `docker compose -f "${composeFile}" exec -T php sh -c "mkdir -p /app/var/uploads && echo 'Test PDF content for E2E' > /app/var/uploads/e2e-exercice.pdf"`, + { encoding: 'utf-8' } + ); + + // Seed attachment for "Exercices chapitre 3" homework + runSql( + `INSERT INTO homework_attachments (id, homework_id, filename, file_path, file_size, mime_type, uploaded_at) ` + + `SELECT gen_random_uuid(), h.id, 'exercice.pdf', '/app/var/uploads/e2e-exercice.pdf', 1024, 'application/pdf', NOW() ` + + `FROM homework h ` + + `WHERE h.title = 'Exercices chapitre 3' AND h.tenant_id = '${TENANT_ID}' ` + + `ON CONFLICT DO NOTHING` + ); + + clearCache(); + }); + + // ====================================================================== + // AC1: Liste devoirs + // ====================================================================== + test.describe('AC1: Homework List', () => { + test('student can navigate to homework page', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/homework`); + await expect( + page.getByRole('heading', { name: /mes devoirs/i }) + ).toBeVisible({ timeout: 15000 }); + }); + + test('homework list shows pending items sorted by due date', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/homework`); + await expect( + page.getByRole('heading', { name: /mes devoirs/i }) + ).toBeVisible({ timeout: 15000 }); + + // Wait for homework cards to appear + const cards = page.locator('.homework-card'); + await expect(cards.first()).toBeVisible({ timeout: 10000 }); + await expect(cards).toHaveCount(2); + + // Verify sorted by due date (closest first) + const firstTitle = await cards.nth(0).locator('.card-title').textContent(); + const secondTitle = await cards.nth(1).locator('.card-title').textContent(); + expect(firstTitle).toBe('Exercices chapitre 3'); + expect(secondTitle).toBe('Rédaction sur les vacances'); + }); + }); + + // ====================================================================== + // AC2: Affichage devoir + // ====================================================================== + test.describe('AC2: Homework Display', () => { + test('each homework card shows subject, title and due date', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/homework`); + + const card = page.locator('.homework-card').first(); + await expect(card).toBeVisible({ timeout: 10000 }); + + // Subject name visible + await expect(card.locator('.subject-name')).toContainText(/maths/i); + // Title visible + await expect(card.locator('.card-title')).toContainText('Exercices chapitre 3'); + // Due date visible + await expect(card.locator('.due-date')).toBeVisible(); + // Status visible + await expect(card.locator('.status-badge')).toContainText(/à faire/i); + }); + }); + + // ====================================================================== + // AC3: Détail devoir + // ====================================================================== + test.describe('AC3: Homework Detail', () => { + test('clicking a card shows the detail view', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/homework`); + + const card = page.locator('.homework-card').first(); + await expect(card).toBeVisible({ timeout: 10000 }); + await card.click(); + + // Detail view should appear + await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.detail-title')).toContainText('Exercices chapitre 3'); + await expect(page.locator('.detail-description')).toContainText('Faire les exercices 1 à 10 page 42'); + + // Teacher name visible + await expect(page.locator('.teacher-name')).toBeVisible(); + }); + + test('back button returns to list', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/homework`); + + const card = page.locator('.homework-card').first(); + await expect(card).toBeVisible({ timeout: 10000 }); + await card.click(); + await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 }); + + // Click back + await page.locator('.back-button').click(); + await expect(page.locator('.homework-card').first()).toBeVisible({ timeout: 5000 }); + }); + }); + + // ====================================================================== + // AC3 (extended): Fichiers joints dans le détail + // ====================================================================== + test.describe('AC3: Homework Detail - Attachments', () => { + test('detail view shows attachment list when homework has attachments', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/homework`); + + // Click on "Exercices chapitre 3" which has an attachment + const card = page.locator('.homework-card').first(); + await expect(card).toBeVisible({ timeout: 10000 }); + await card.click(); + + await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 }); + + // Attachments section should be visible + await expect(page.locator('.detail-attachments')).toBeVisible(); + await expect(page.locator('.attachment-item')).toBeVisible(); + await expect(page.locator('.attachment-name')).toContainText('exercice.pdf'); + }); + }); + + // ====================================================================== + // AC4: Téléchargement fichiers joints + // ====================================================================== + test.describe('AC4: Attachment Download', () => { + test('clicking an attachment triggers file download', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/homework`); + + const card = page.locator('.homework-card').first(); + await expect(card).toBeVisible({ timeout: 10000 }); + await card.click(); + + await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.attachment-item')).toBeVisible(); + + // Intercept the attachment download request + const responsePromise = page.waitForResponse( + (resp) => resp.url().includes('/attachments/') && resp.status() === 200 + ); + + await page.locator('.attachment-item').first().click(); + + const response = await responsePromise; + expect(response.status()).toBe(200); + }); + }); + + // ====================================================================== + // AC5: Filtrage par matière + // ====================================================================== + test.describe('AC5: Subject Filter', () => { + test('filter buttons appear when multiple subjects exist', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/homework`); + + await expect(page.locator('.homework-card').first()).toBeVisible({ timeout: 10000 }); + + // Filter bar should be visible + const filterBar = page.locator('.filter-bar'); + await expect(filterBar).toBeVisible(); + + // "Toutes" chip + 2 subject chips + const chips = filterBar.locator('.filter-chip'); + await expect(chips).toHaveCount(3); + }); + + test('clicking a subject filter shows only that subject homework', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/homework`); + + await expect(page.locator('.homework-card').first()).toBeVisible({ timeout: 10000 }); + + // Click on Maths filter + await page.locator('.filter-chip', { hasText: /maths/i }).click(); + + const cards = page.locator('.homework-card'); + await expect(cards).toHaveCount(1, { timeout: 5000 }); + await expect(cards.first().locator('.card-title')).toContainText('Exercices chapitre 3'); + }); + + test('clicking "Toutes" shows all homework again', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/homework`); + + await expect(page.locator('.homework-card').first()).toBeVisible({ timeout: 10000 }); + + // Filter then unfilter + await page.locator('.filter-chip', { hasText: /maths/i }).click(); + await expect(page.locator('.homework-card')).toHaveCount(1, { timeout: 5000 }); + + await page.locator('.filter-chip', { hasText: /tous/i }).click(); + await expect(page.locator('.homework-card')).toHaveCount(2, { timeout: 5000 }); + }); + }); + + // ====================================================================== + // AC6: Marquage "Fait" + // ====================================================================== + test.describe('AC6: Toggle Done', () => { + test('toggling done moves homework to "Terminés" section', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/homework`); + + const firstCard = page.locator('.homework-card').first(); + await expect(firstCard).toBeVisible({ timeout: 10000 }); + + // Click toggle button on first card + const toggleBtn = firstCard.locator('.toggle-done'); + await toggleBtn.click(); + + // A "Terminés" section should appear + await expect(page.getByText(/terminés/i)).toBeVisible({ timeout: 5000 }); + + // The card should now be in done state + const doneCard = page.locator('.homework-card.done'); + await expect(doneCard).toBeVisible(); + await expect(doneCard.locator('.status-badge')).toContainText(/fait/i); + }); + + test('done state persists after page reload', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/homework`); + + const firstCard = page.locator('.homework-card').first(); + await expect(firstCard).toBeVisible({ timeout: 10000 }); + + // Mark as done + await firstCard.locator('.toggle-done').click(); + await expect(page.locator('.homework-card.done')).toBeVisible({ timeout: 5000 }); + + // Reload the page + await page.reload(); + await expect(page.locator('.homework-card').first()).toBeVisible({ timeout: 10000 }); + + // Done state should persist (localStorage) + await expect(page.locator('.homework-card.done')).toBeVisible({ timeout: 5000 }); + }); + + test('toggling done again restores homework to pending section', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/homework`); + + const firstCard = page.locator('.homework-card').first(); + await expect(firstCard).toBeVisible({ timeout: 10000 }); + + // Mark as done + await firstCard.locator('.toggle-done').click(); + await expect(page.locator('.homework-card.done')).toBeVisible({ timeout: 5000 }); + + // Toggle back to undone + const doneCard = page.locator('.homework-card.done'); + await doneCard.locator('.toggle-done').click(); + + // Card should no longer have done class + await expect(page.locator('.homework-card.done')).toHaveCount(0, { timeout: 5000 }); + }); + }); + + // ====================================================================== + // AC7: Mode offline + // Skipped: Service Worker cannot cache cross-origin API requests in E2E + // (API runs on a different port). Works in production (same origin). + // ====================================================================== + test.describe('AC7: Offline Mode', () => { + test.skip('cached homework is displayed when offline', async ({ page, context }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/homework`); + + // Wait for homework to load (populates Service Worker cache) + await expect(page.locator('.homework-card').first()).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.homework-card')).toHaveCount(2); + + // Go offline + await context.setOffline(true); + + // Reload the page — Service Worker should serve cached data + await page.reload(); + + // Offline banner should appear + await expect(page.locator('.offline-banner')).toBeVisible({ timeout: 10000 }); + + // Cached homework should still be visible + await expect(page.locator('.homework-card').first()).toBeVisible({ timeout: 10000 }); + + // Restore connectivity + await context.setOffline(false); + }); + + test.skip('marking homework as done works offline and persists', async ({ page, context }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/homework`); + + // Wait for homework to load + await expect(page.locator('.homework-card').first()).toBeVisible({ timeout: 10000 }); + + // Go offline + await context.setOffline(true); + await page.reload(); + await expect(page.locator('.offline-banner')).toBeVisible({ timeout: 10000 }); + + // Mark homework as done while offline (should work via localStorage) + const firstCard = page.locator('.homework-card').first(); + await expect(firstCard).toBeVisible({ timeout: 10000 }); + await firstCard.locator('.toggle-done').click(); + + // Done state should be applied + await expect(page.locator('.homework-card.done')).toBeVisible({ timeout: 5000 }); + + // Come back online and reload + await context.setOffline(false); + await page.reload(); + await expect(page.locator('.homework-card').first()).toBeVisible({ timeout: 10000 }); + + // Done state should persist (synced from localStorage) + await expect(page.locator('.homework-card.done')).toBeVisible({ timeout: 5000 }); + }); + }); + + // ====================================================================== + // Dashboard integration + // ====================================================================== + test.describe('Dashboard Widget', () => { + test('dashboard shows homework widget with real data', async ({ page }) => { + await loginAsStudent(page); + + // Dashboard should load + await expect( + page.getByRole('heading', { name: /mon espace/i }) + ).toBeVisible({ timeout: 15000 }); + + // Homework section should show homework items + const homeworkSection = page.locator('.dashboard-grid').locator('section', { hasText: /mes devoirs/i }); + await expect(homeworkSection).toBeVisible({ timeout: 10000 }); + + // Should have a "Voir tous les devoirs" link + await expect(page.getByText(/voir tous les devoirs/i)).toBeVisible({ timeout: 10000 }); + }); + + test('dashboard "Voir tous les devoirs" link navigates to homework page', async ({ page }) => { + await loginAsStudent(page); + + await expect( + page.getByRole('heading', { name: /mon espace/i }) + ).toBeVisible({ timeout: 15000 }); + + await page.getByText(/voir tous les devoirs/i).click(); + await expect( + page.getByRole('heading', { name: /mes devoirs/i }) + ).toBeVisible({ timeout: 15000 }); + }); + + test('clicking a homework item opens detail modal on dashboard', async ({ page }) => { + await loginAsStudent(page); + + await expect( + page.getByRole('heading', { name: /mon espace/i }) + ).toBeVisible({ timeout: 15000 }); + + // Click on a homework item in the widget + const homeworkBtn = page.locator('button.homework-item').first(); + await expect(homeworkBtn).toBeVisible({ timeout: 10000 }); + await homeworkBtn.click(); + + // Modal with detail should appear + const modal = page.locator('[role="dialog"]'); + await expect(modal).toBeVisible({ timeout: 10000 }); + await expect(modal.locator('.detail-title')).toBeVisible(); + await expect(modal.locator('.teacher-name')).toBeVisible(); + }); + + test('homework detail modal closes with X button', async ({ page }) => { + await loginAsStudent(page); + + await expect( + page.getByRole('heading', { name: /mon espace/i }) + ).toBeVisible({ timeout: 15000 }); + + const homeworkBtn = page.locator('button.homework-item').first(); + await expect(homeworkBtn).toBeVisible({ timeout: 10000 }); + await homeworkBtn.click(); + + const modal = page.locator('[role="dialog"]'); + await expect(modal).toBeVisible({ timeout: 10000 }); + + // Close modal + await page.locator('.homework-modal-close').click(); + await expect(modal).not.toBeVisible({ timeout: 5000 }); + }); + }); +}); diff --git a/frontend/src/lib/components/organisms/Dashboard/DashboardStudent.svelte b/frontend/src/lib/components/organisms/Dashboard/DashboardStudent.svelte index 1e87ac9..450558c 100644 --- a/frontend/src/lib/components/organisms/Dashboard/DashboardStudent.svelte +++ b/frontend/src/lib/components/organisms/Dashboard/DashboardStudent.svelte @@ -1,8 +1,12 @@
@@ -148,11 +205,34 @@ - {#if hasRealData} + {#if isEleve} + {#if homeworkLoading} + + {:else if pendingHomeworks.length === 0} +

Aucun devoir à faire

+ {:else} +
    + {#each pendingHomeworks as homework} +
  • + +
  • + {/each} +
+ + Voir tous les devoirs → + + {/if} + {:else if hasRealData} {#if isLoading} {:else} @@ -178,6 +258,16 @@
+{#if selectedHomeworkDetail} + + +{/if} + diff --git a/frontend/src/lib/components/organisms/StudentHomework/HomeworkCard.svelte b/frontend/src/lib/components/organisms/StudentHomework/HomeworkCard.svelte new file mode 100644 index 0000000..a46ad92 --- /dev/null +++ b/frontend/src/lib/components/organisms/StudentHomework/HomeworkCard.svelte @@ -0,0 +1,181 @@ + + +
+
+ + {homework.subjectName} + + +
+ +

{homework.title}

+ + +
+ + diff --git a/frontend/src/lib/components/organisms/StudentHomework/HomeworkDetail.svelte b/frontend/src/lib/components/organisms/StudentHomework/HomeworkDetail.svelte new file mode 100644 index 0000000..52cd0d1 --- /dev/null +++ b/frontend/src/lib/components/organisms/StudentHomework/HomeworkDetail.svelte @@ -0,0 +1,238 @@ + + +
+ + +
+ + {detail.subjectName} + +

{detail.title}

+
+ Pour le {formatDueDate(detail.dueDate)} + Par {detail.teacherName} +
+
+ + {#if detail.description} +
+

Description

+

{detail.description}

+
+ {/if} + + {#if detail.attachments.length > 0} +
+

Pièces jointes

+ {#if downloadError} + + {/if} +
    + {#each detail.attachments as attachment} +
  • + +
  • + {/each} +
+
+ {/if} +
+ + diff --git a/frontend/src/lib/components/organisms/StudentHomework/StudentHomeworkList.svelte b/frontend/src/lib/components/organisms/StudentHomework/StudentHomeworkList.svelte new file mode 100644 index 0000000..1f934a5 --- /dev/null +++ b/frontend/src/lib/components/organisms/StudentHomework/StudentHomeworkList.svelte @@ -0,0 +1,277 @@ + + +{#if selectedDetail} + +{:else} +
+ {#if isOffline()} +
+ + + + + + + + + + Mode hors ligne +
+ {/if} + + {#if allSubjects.length > 1} + + {/if} + + {#if loading} + + {:else if error} + + {:else if homeworks.length === 0} +
+

Aucun devoir pour le moment

+
+ {:else} + {#if pendingHomeworks.length > 0} +
+

À faire ({pendingHomeworks.length})

+
    + {#each pendingHomeworks as hw (hw.id)} +
  • + +
  • + {/each} +
+
+ {/if} + + {#if doneHomeworks.length > 0} +
+

Terminés ({doneHomeworks.length})

+
    + {#each doneHomeworks as hw (hw.id)} +
  • + +
  • + {/each} +
+
+ {/if} + {/if} + + {#if detailLoading} +
+

Chargement...

+
+ {/if} +
+{/if} + + diff --git a/frontend/src/lib/features/homework/api/studentHomework.ts b/frontend/src/lib/features/homework/api/studentHomework.ts new file mode 100644 index 0000000..b92c4ab --- /dev/null +++ b/frontend/src/lib/features/homework/api/studentHomework.ts @@ -0,0 +1,76 @@ +import { getApiBaseUrl } from '$lib/api'; +import { authenticatedFetch } from '$lib/auth'; + +export interface StudentHomework { + id: string; + subjectId: string; + subjectName: string; + subjectColor: string | null; + teacherId: string; + teacherName: string; + title: string; + description: string | null; + dueDate: string; + createdAt: string; + hasAttachments: boolean; +} + +export interface HomeworkAttachment { + id: string; + filename: string; + fileSize: number; + mimeType: string; +} + +export interface StudentHomeworkDetail { + id: string; + subjectId: string; + subjectName: string; + subjectColor: string | null; + teacherId: string; + teacherName: string; + title: string; + description: string | null; + dueDate: string; + createdAt: string; + attachments: HomeworkAttachment[]; +} + +/** + * Récupère la liste des devoirs pour l'élève connecté. + */ +export async function fetchStudentHomework(subjectId?: string): Promise { + const apiUrl = getApiBaseUrl(); + const params = subjectId ? `?subjectId=${encodeURIComponent(subjectId)}` : ''; + const response = await authenticatedFetch(`${apiUrl}/me/homework${params}`); + + if (!response.ok) { + throw new Error(`Erreur lors du chargement des devoirs (${response.status})`); + } + + const json = await response.json(); + return json.data ?? []; +} + +/** + * Récupère le détail d'un devoir. + */ +export async function fetchHomeworkDetail(homeworkId: string): Promise { + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch(`${apiUrl}/me/homework/${homeworkId}`); + + if (!response.ok) { + throw new Error(`Erreur lors du chargement du devoir (${response.status})`); + } + + const json = await response.json(); + return json.data; +} + +/** + * Retourne l'URL de téléchargement d'une pièce jointe. + */ +export function getAttachmentUrl(homeworkId: string, attachmentId: string): string { + const apiUrl = getApiBaseUrl(); + return `${apiUrl}/me/homework/${homeworkId}/attachments/${attachmentId}`; +} diff --git a/frontend/src/lib/features/homework/stores/homeworkStatus.svelte.ts b/frontend/src/lib/features/homework/stores/homeworkStatus.svelte.ts new file mode 100644 index 0000000..39a1e27 --- /dev/null +++ b/frontend/src/lib/features/homework/stores/homeworkStatus.svelte.ts @@ -0,0 +1,60 @@ +import { browser } from '$app/environment'; + +const STORAGE_KEY = 'classeo:homework:status'; + +interface HomeworkStatusEntry { + done: boolean; + doneAt: number | null; +} + +type StatusMap = Record; + +function loadStatuses(): StatusMap { + if (!browser) return {}; + + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? (JSON.parse(raw) as StatusMap) : {}; + } catch { + return {}; + } +} + +function saveStatuses(statuses: StatusMap): void { + if (!browser) return; + localStorage.setItem(STORAGE_KEY, JSON.stringify(statuses)); +} + +let statuses = $state(loadStatuses()); + +/** + * Marque un devoir comme "fait" ou "à faire". + */ +export function toggleHomeworkDone(homeworkId: string): void { + const current = statuses[homeworkId]; + const isDone = current?.done ?? false; + + statuses = { + ...statuses, + [homeworkId]: { + done: !isDone, + doneAt: !isDone ? Date.now() : null, + }, + }; + + saveStatuses(statuses); +} + +/** + * Vérifie si un devoir est marqué comme "fait". + */ +export function isHomeworkDone(homeworkId: string): boolean { + return statuses[homeworkId]?.done ?? false; +} + +/** + * Retourne le map complet des statuts (réactif via $state). + */ +export function getHomeworkStatuses(): StatusMap { + return statuses; +} diff --git a/frontend/src/routes/dashboard/homework/+page.svelte b/frontend/src/routes/dashboard/homework/+page.svelte new file mode 100644 index 0000000..7006eaf --- /dev/null +++ b/frontend/src/routes/dashboard/homework/+page.svelte @@ -0,0 +1,34 @@ + + + + Mes devoirs - Classeo + + +
+ + + +
+ + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index c597550..0c2b07e 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -47,13 +47,43 @@ export default defineConfig({ } ] }, - workbox: { - globPatterns: ['**/*.{js,css,html,ico,png,svg,webp,woff,woff2}'], - runtimeCaching: [ - { - urlPattern: /\/api\/me\/schedule\//, - handler: 'NetworkFirst', - options: { + workbox: { + globPatterns: ['**/*.{js,css,html,ico,png,svg,webp,woff,woff2}'], + runtimeCaching: [ + { + urlPattern: /\/api\/me\/homework(?:\?.*)?$/, + handler: 'NetworkFirst', + options: { + cacheName: 'student-homework-list-v1', + expiration: { + maxEntries: 31, + maxAgeSeconds: 30 * 24 * 60 * 60 + }, + networkTimeoutSeconds: 5, + cacheableResponse: { + statuses: [0, 200] + } + } + }, + { + urlPattern: /\/api\/me\/homework\/[^/]+(?:\/attachments\/[^/]+)?$/, + handler: 'NetworkFirst', + options: { + cacheName: 'student-homework-detail-v1', + expiration: { + maxEntries: 120, + maxAgeSeconds: 30 * 24 * 60 * 60 + }, + networkTimeoutSeconds: 5, + cacheableResponse: { + statuses: [0, 200] + } + } + }, + { + urlPattern: /\/api\/me\/schedule\//, + handler: 'NetworkFirst', + options: { cacheName: 'schedule-v1', expiration: { maxEntries: 90,