feat: Permettre aux enseignants de créer et gérer les devoirs
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 enseignants avaient besoin d'un outil pour créer des devoirs assignés
à leurs classes, avec filtrage automatique par matière selon la classe
sélectionnée. Le système valide que la date d'échéance tombe un jour
ouvrable (lundi-vendredi) et empêche les dates dans le passé.

Le domaine modélise le devoir comme un agrégat avec pièces jointes,
statut brouillon/publié, et événements métier (création, modification,
suppression). Les handlers de notification écoutent ces événements pour
les futurs envois aux parents et élèves.
This commit is contained in:
2026-03-12 10:11:06 +01:00
parent 56bc808d85
commit e9efb90f59
51 changed files with 4776 additions and 7 deletions

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Event;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Shared\Domain\DomainEvent;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class DevoirCree implements DomainEvent
{
public function __construct(
public HomeworkId $homeworkId,
public string $classId,
public string $subjectId,
public string $teacherId,
public string $title,
public DateTimeImmutable $dueDate,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->homeworkId->value;
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Event;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Shared\Domain\DomainEvent;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class DevoirModifie implements DomainEvent
{
public function __construct(
public HomeworkId $homeworkId,
public string $title,
public DateTimeImmutable $dueDate,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->homeworkId->value;
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Event;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Shared\Domain\DomainEvent;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class DevoirSupprime implements DomainEvent
{
public function __construct(
public HomeworkId $homeworkId,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->homeworkId->value;
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use DateTimeImmutable;
use DomainException;
use function sprintf;
final class DateEcheanceInvalideException extends DomainException
{
public static function dansLePasse(DateTimeImmutable $dueDate): self
{
return new self(sprintf(
'La date d\'échéance "%s" doit être dans le futur.',
$dueDate->format('Y-m-d'),
));
}
public static function jourBloque(DateTimeImmutable $dueDate, string $raison): self
{
return new self(sprintf(
'La date d\'échéance "%s" est bloquée : %s.',
$dueDate->format('Y-m-d'),
$raison,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use DomainException;
use function sprintf;
final class DevoirDejaSupprimeException extends DomainException
{
public static function withId(HomeworkId $id): self
{
return new self(sprintf(
'Le devoir avec l\'ID "%s" a déjà été supprimé.',
$id,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use DomainException;
use function sprintf;
final class HomeworkNotFoundException extends DomainException
{
public static function withId(HomeworkId $id): self
{
return new self(sprintf(
'Le devoir avec l\'ID "%s" n\'a pas été trouvé.',
$id,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use DomainException;
use function sprintf;
final class NonProprietaireDuDevoirException extends DomainException
{
public static function withId(HomeworkId $id): self
{
return new self(sprintf(
'Vous n\'êtes pas le propriétaire du devoir "%s".',
$id,
));
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use DomainException;
use function sprintf;
final class PieceJointeInvalideException extends DomainException
{
public static function fichierTropGros(int $fileSize, int $maxSize): self
{
return new self(sprintf(
'Le fichier fait %d octets, la taille maximum est de %d octets (10 Mo).',
$fileSize,
$maxSize,
));
}
public static function typeFichierNonAutorise(string $mimeType): self
{
return new self(sprintf(
'Le type de fichier "%s" n\'est pas autorisé. Types acceptés : PDF, JPEG, PNG.',
$mimeType,
));
}
public static function lectureFichierImpossible(string $filename): self
{
return new self(sprintf(
'Impossible de lire le fichier "%s".',
$filename,
));
}
}

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\Homework;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Event\DevoirCree;
use App\Scolarite\Domain\Event\DevoirModifie;
use App\Scolarite\Domain\Event\DevoirSupprime;
use App\Scolarite\Domain\Exception\DevoirDejaSupprimeException;
use App\Shared\Domain\AggregateRoot;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
final class Homework extends AggregateRoot
{
public private(set) DateTimeImmutable $updatedAt;
private function __construct(
public private(set) HomeworkId $id,
public private(set) TenantId $tenantId,
public private(set) ClassId $classId,
public private(set) SubjectId $subjectId,
public private(set) UserId $teacherId,
public private(set) string $title,
public private(set) ?string $description,
public private(set) DateTimeImmutable $dueDate,
public private(set) HomeworkStatus $status,
public private(set) DateTimeImmutable $createdAt,
) {
$this->updatedAt = $createdAt;
}
public static function creer(
TenantId $tenantId,
ClassId $classId,
SubjectId $subjectId,
UserId $teacherId,
string $title,
?string $description,
DateTimeImmutable $dueDate,
DateTimeImmutable $now,
): self {
$homework = new self(
id: HomeworkId::generate(),
tenantId: $tenantId,
classId: $classId,
subjectId: $subjectId,
teacherId: $teacherId,
title: $title,
description: $description,
dueDate: $dueDate,
status: HomeworkStatus::PUBLISHED,
createdAt: $now,
);
$homework->recordEvent(new DevoirCree(
homeworkId: $homework->id,
classId: (string) $classId,
subjectId: (string) $subjectId,
teacherId: (string) $teacherId,
title: $title,
dueDate: $dueDate,
occurredOn: $now,
));
return $homework;
}
public function modifier(
string $title,
?string $description,
DateTimeImmutable $dueDate,
DateTimeImmutable $now,
): void {
if ($this->status === HomeworkStatus::DELETED) {
throw DevoirDejaSupprimeException::withId($this->id);
}
$this->title = $title;
$this->description = $description;
$this->dueDate = $dueDate;
$this->updatedAt = $now;
$this->recordEvent(new DevoirModifie(
homeworkId: $this->id,
title: $title,
dueDate: $dueDate,
occurredOn: $now,
));
}
public function supprimer(DateTimeImmutable $now): void
{
if ($this->status === HomeworkStatus::DELETED) {
throw DevoirDejaSupprimeException::withId($this->id);
}
$this->status = HomeworkStatus::DELETED;
$this->updatedAt = $now;
$this->recordEvent(new DevoirSupprime(
homeworkId: $this->id,
occurredOn: $now,
));
}
/**
* @internal Pour usage Infrastructure uniquement
*/
public static function reconstitute(
HomeworkId $id,
TenantId $tenantId,
ClassId $classId,
SubjectId $subjectId,
UserId $teacherId,
string $title,
?string $description,
DateTimeImmutable $dueDate,
HomeworkStatus $status,
DateTimeImmutable $createdAt,
DateTimeImmutable $updatedAt,
): self {
$homework = new self(
id: $id,
tenantId: $tenantId,
classId: $classId,
subjectId: $subjectId,
teacherId: $teacherId,
title: $title,
description: $description,
dueDate: $dueDate,
status: $status,
createdAt: $createdAt,
);
$homework->updatedAt = $updatedAt;
return $homework;
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\Homework;
use App\Scolarite\Domain\Exception\PieceJointeInvalideException;
use DateTimeImmutable;
use function in_array;
final class HomeworkAttachment
{
private const int MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 Mo
private const array ALLOWED_MIME_TYPES = [
'application/pdf',
'image/jpeg',
'image/png',
];
public function __construct(
public private(set) HomeworkAttachmentId $id,
public private(set) string $filename,
public private(set) string $filePath,
public private(set) int $fileSize {
set(int $fileSize) {
if ($fileSize > self::MAX_FILE_SIZE) {
throw PieceJointeInvalideException::fichierTropGros($fileSize, self::MAX_FILE_SIZE);
}
$this->fileSize = $fileSize;
}
},
public private(set) string $mimeType {
set(string $mimeType) {
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) {
throw PieceJointeInvalideException::typeFichierNonAutorise($mimeType);
}
$this->mimeType = $mimeType;
}
},
public private(set) DateTimeImmutable $uploadedAt,
) {
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\Homework;
use App\Shared\Domain\EntityId;
final readonly class HomeworkAttachmentId extends EntityId
{
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\Homework;
use App\Shared\Domain\EntityId;
final readonly class HomeworkId extends EntityId
{
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\Homework;
enum HomeworkStatus: string
{
case PUBLISHED = 'published';
case DELETED = 'deleted';
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Repository;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Exception\HomeworkNotFoundException;
use App\Scolarite\Domain\Model\Homework\Homework;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Shared\Domain\Tenant\TenantId;
interface HomeworkRepository
{
public function save(Homework $homework): void;
/** @throws HomeworkNotFoundException */
public function get(HomeworkId $id, TenantId $tenantId): Homework;
public function findById(HomeworkId $id, TenantId $tenantId): ?Homework;
/** @return array<Homework> */
public function findByTeacher(UserId $teacherId, TenantId $tenantId): array;
/** @return array<Homework> */
public function findByClass(ClassId $classId, TenantId $tenantId): array;
public function delete(HomeworkId $id, TenantId $tenantId): void;
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Service;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Scolarite\Domain\Exception\DateEcheanceInvalideException;
use DateTimeImmutable;
final readonly class DueDateValidator
{
public function valider(DateTimeImmutable $dueDate, DateTimeImmutable $now, SchoolCalendar $calendar): void
{
$tomorrow = $now->modify('+1 day')->setTime(0, 0);
if ($dueDate < $tomorrow) {
throw DateEcheanceInvalideException::dansLePasse($dueDate);
}
if (!$calendar->estJourOuvre($dueDate)) {
$entry = $calendar->trouverEntreePourDate($dueDate);
$raison = $entry !== null ? $entry->label : 'weekend';
throw DateEcheanceInvalideException::jourBloque($dueDate, $raison);
}
}
}