feat: Permettre au parent de consulter les devoirs de ses enfants
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

Les parents avaient accès à l'emploi du temps de leurs enfants mais
pas à leurs devoirs. Sans cette visibilité, ils ne pouvaient pas
accompagner efficacement le travail scolaire à la maison, notamment
identifier les devoirs urgents ou contacter l'enseignant en cas
de besoin.

Le parent dispose désormais d'une vue consolidée multi-enfants avec
filtrage par enfant et par matière, badges d'urgence différenciés
(en retard / aujourd'hui / pour demain), lien de contact enseignant
pré-rempli, et cache offline scopé par utilisateur.
This commit is contained in:
2026-03-23 00:34:55 +01:00
parent 2e2328c6ca
commit 8d950b0f3c
18 changed files with 2689 additions and 13 deletions

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetChildrenHomework;
use App\Scolarite\Application\Query\GetStudentHomework\StudentHomeworkDto;
final readonly class ChildHomeworkDto
{
/**
* @param array<StudentHomeworkDto> $homework
*/
public function __construct(
public string $childId,
public string $firstName,
public string $lastName,
public array $homework,
) {
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetChildrenHomework;
use App\Scolarite\Application\Port\ParentChildrenReader;
use App\Scolarite\Application\Port\ScheduleDisplayReader;
use App\Scolarite\Application\Port\StudentClassReader;
use App\Scolarite\Application\Query\GetStudentHomework\StudentHomeworkDetailDto;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository;
use App\Scolarite\Domain\Repository\HomeworkRepository;
use App\Shared\Domain\Tenant\TenantId;
final readonly class GetChildrenHomeworkDetailHandler
{
public function __construct(
private ParentChildrenReader $parentChildrenReader,
private StudentClassReader $studentClassReader,
private HomeworkRepository $homeworkRepository,
private HomeworkAttachmentRepository $attachmentRepository,
private ScheduleDisplayReader $displayReader,
) {
}
public function __invoke(string $parentId, string $tenantId, string $homeworkId): ?StudentHomeworkDetailDto
{
$tid = TenantId::fromString($tenantId);
$homework = $this->homeworkRepository->findById(HomeworkId::fromString($homeworkId), $tid);
if ($homework === null) {
return null;
}
if (!$this->parentHasChildInClass($parentId, $tid, (string) $homework->classId)) {
return null;
}
$attachments = $this->attachmentRepository->findByHomeworkId($homework->id);
$subjects = $this->displayReader->subjectDisplay($tenantId, (string) $homework->subjectId);
$teacherNames = $this->displayReader->teacherNames($tenantId, (string) $homework->teacherId);
return StudentHomeworkDetailDto::fromDomain(
$homework,
$subjects[(string) $homework->subjectId]['name'] ?? '',
$subjects[(string) $homework->subjectId]['color'] ?? null,
$teacherNames[(string) $homework->teacherId] ?? '',
$attachments,
);
}
private function parentHasChildInClass(string $parentId, TenantId $tenantId, string $homeworkClassId): bool
{
$children = $this->parentChildrenReader->childrenOf($parentId, $tenantId);
foreach ($children as $child) {
$classId = $this->studentClassReader->currentClassId($child['studentId'], $tenantId);
if ($classId === $homeworkClassId) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetChildrenHomework;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Scolarite\Application\Port\ParentChildrenReader;
use App\Scolarite\Application\Port\ScheduleDisplayReader;
use App\Scolarite\Application\Port\StudentClassReader;
use App\Scolarite\Application\Query\GetStudentHomework\StudentHomeworkDto;
use App\Scolarite\Domain\Model\Homework\Homework;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository;
use App\Scolarite\Domain\Repository\HomeworkRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_filter;
use function array_map;
use function array_unique;
use function array_values;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use function usort;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetChildrenHomeworkHandler
{
public function __construct(
private ParentChildrenReader $parentChildrenReader,
private StudentClassReader $studentClassReader,
private HomeworkRepository $homeworkRepository,
private HomeworkAttachmentRepository $attachmentRepository,
private ScheduleDisplayReader $displayReader,
) {
}
/** @return array<ChildHomeworkDto> */
public function __invoke(GetChildrenHomeworkQuery $query): array
{
$tenantId = TenantId::fromString($query->tenantId);
$allChildren = $this->parentChildrenReader->childrenOf($query->parentId, $tenantId);
if ($allChildren === []) {
return [];
}
$children = $query->childId !== null
? array_values(array_filter($allChildren, static fn (array $c): bool => $c['studentId'] === $query->childId))
: $allChildren;
if ($children === []) {
return [];
}
$result = [];
foreach ($children as $child) {
$classId = $this->studentClassReader->currentClassId($child['studentId'], $tenantId);
if ($classId === null) {
$result[] = new ChildHomeworkDto(
childId: $child['studentId'],
firstName: $child['firstName'],
lastName: $child['lastName'],
homework: [],
);
continue;
}
$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);
$enriched = $this->enrichHomeworks($homeworks, $query->tenantId);
$result[] = new ChildHomeworkDto(
childId: $child['studentId'],
firstName: $child['firstName'],
lastName: $child['lastName'],
homework: $enriched,
);
}
return $result;
}
/**
* @param array<Homework> $homeworks
*
* @return array<StudentHomeworkDto>
*/
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,
);
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetChildrenHomework;
final readonly class GetChildrenHomeworkQuery
{
public function __construct(
public string $parentId,
public string $tenantId,
public ?string $childId = null,
public ?string $subjectId = null,
) {
}
}

View File

@@ -0,0 +1,232 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Controller;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Query\GetChildrenHomework\ChildHomeworkDto;
use App\Scolarite\Application\Query\GetChildrenHomework\GetChildrenHomeworkDetailHandler;
use App\Scolarite\Application\Query\GetChildrenHomework\GetChildrenHomeworkHandler;
use App\Scolarite\Application\Query\GetChildrenHomework\GetChildrenHomeworkQuery;
use App\Scolarite\Application\Query\GetStudentHomework\StudentHomeworkDetailDto;
use App\Scolarite\Application\Query\GetStudentHomework\StudentHomeworkDto;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository;
use App\Scolarite\Domain\Repository\HomeworkRepository;
use App\Scolarite\Infrastructure\Security\HomeworkParentVoter;
use App\Shared\Domain\Tenant\TenantId;
use function array_map;
use function is_string;
use function realpath;
use function str_starts_with;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Endpoints de consultation des devoirs des enfants pour le parent connecté.
*/
#[IsGranted(HomeworkParentVoter::VIEW)]
final readonly class ParentHomeworkController
{
public function __construct(
private Security $security,
private GetChildrenHomeworkHandler $handler,
private GetChildrenHomeworkDetailHandler $detailHandler,
private HomeworkRepository $homeworkRepository,
private HomeworkAttachmentRepository $attachmentRepository,
#[Autowire('%kernel.project_dir%/var/uploads')]
private string $uploadsDir,
) {
}
/**
* Devoirs d'un enfant spécifique.
*/
#[Route('/api/me/children/{childId}/homework', name: 'api_parent_child_homework', methods: ['GET'])]
public function childHomework(string $childId, Request $request): JsonResponse
{
$user = $this->getSecurityUser();
$subjectId = $request->query->get('subjectId');
$children = ($this->handler)(new GetChildrenHomeworkQuery(
parentId: $user->userId(),
tenantId: $user->tenantId(),
childId: $childId,
subjectId: is_string($subjectId) && $subjectId !== '' ? $subjectId : null,
));
if ($children === []) {
throw new NotFoundHttpException('Enfant non trouvé ou non lié à ce parent.');
}
return new JsonResponse([
'data' => $this->serializeChild($children[0]),
]);
}
/**
* Vue consolidée des devoirs de tous les enfants.
*/
#[Route('/api/me/children/homework', name: 'api_parent_children_homework', methods: ['GET'])]
public function allChildrenHomework(Request $request): JsonResponse
{
$user = $this->getSecurityUser();
$subjectId = $request->query->get('subjectId');
$children = ($this->handler)(new GetChildrenHomeworkQuery(
parentId: $user->userId(),
tenantId: $user->tenantId(),
subjectId: is_string($subjectId) && $subjectId !== '' ? $subjectId : null,
));
return new JsonResponse([
'data' => array_map($this->serializeChild(...), $children),
]);
}
/**
* Détail d'un devoir (accessible si l'enfant du parent est dans la classe du devoir).
*/
#[Route('/api/me/children/homework/{id}', name: 'api_parent_child_homework_detail', methods: ['GET'])]
public function homeworkDetail(string $id): JsonResponse
{
$user = $this->getSecurityUser();
$detail = ($this->detailHandler)($user->userId(), $user->tenantId(), $id);
if ($detail === null) {
throw new NotFoundHttpException('Devoir non trouvé.');
}
return new JsonResponse(['data' => $this->serializeDetail($detail)]);
}
/**
* Téléchargement d'une pièce jointe (parent).
*/
#[Route('/api/me/children/homework/{homeworkId}/attachments/{attachmentId}', name: 'api_parent_child_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é.');
}
// Verify parent has a child in the homework's class
$detail = ($this->detailHandler)($user->userId(), $user->tenantId(), $homeworkId);
if ($detail === null) {
throw new NotFoundHttpException('Devoir non trouvé.');
}
$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 getSecurityUser(): SecurityUser
{
$user = $this->security->getUser();
if (!$user instanceof SecurityUser) {
throw new AccessDeniedHttpException('Authentification requise.');
}
return $user;
}
/**
* @return array<string, mixed>
*/
private function serializeChild(ChildHomeworkDto $child): array
{
return [
'childId' => $child->childId,
'firstName' => $child->firstName,
'lastName' => $child->lastName,
'homework' => array_map($this->serializeHomework(...), $child->homework),
];
}
/**
* @return array<string, mixed>
*/
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,
),
];
}
/**
* @return array<string, mixed>
*/
private function serializeHomework(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,
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Security;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Infrastructure\Security\SecurityUser;
use function in_array;
use Override;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* Voter pour la consultation des devoirs des enfants par le parent.
*
* @extends Voter<string, null>
*/
final class HomeworkParentVoter extends Voter
{
public const string VIEW = 'HOMEWORK_PARENT_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::PARENT->value, $user->getRoles(), true);
}
}