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,