Compare commits

...

2 Commits

Author SHA1 Message Date
df25a8cbb0 feat: Permettre à l'élève de rendre un devoir avec réponse texte et pièces jointes
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
L'élève peut désormais répondre à un devoir via un éditeur WYSIWYG,
joindre des fichiers (PDF, JPEG, PNG, DOCX), sauvegarder un brouillon
et soumettre définitivement son rendu. Le système détecte automatiquement
les soumissions en retard par rapport à la date d'échéance.

Côté enseignant, une page dédiée affiche la liste complète des élèves
avec leur statut (soumis, en retard, brouillon, non rendu), le détail
de chaque rendu avec ses pièces jointes téléchargeables, et les
statistiques de rendus par classe.
2026-03-25 19:38:47 +01:00
ab835e5c3d feat: Permettre à l'enseignant de rédiger avec un éditeur riche et joindre des fichiers
Les enseignants avaient besoin de consignes plus claires pour les élèves :
le champ description en texte brut ne permettait ni mise en forme ni
partage de documents. Cette limitation obligeait à décrire verbalement
les ressources au lieu de les joindre directement.

L'éditeur WYSIWYG (TipTap) remplace le textarea avec gras, italique,
listes et liens. Le contenu HTML est sanitisé côté backend via
symfony/html-sanitizer pour prévenir les injections XSS. Les pièces
jointes (PDF, JPEG, PNG, max 10 Mo) sont uploadées via une API dédiée
avec validation MIME côté domaine et protection path-traversal sur le
téléchargement. Les descriptions en texte brut existantes restent
lisibles sans migration de données.
2026-03-24 16:08:48 +01:00
70 changed files with 7167 additions and 38 deletions

View File

@@ -28,6 +28,7 @@
"symfony/dotenv": "^8.0", "symfony/dotenv": "^8.0",
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/framework-bundle": "^8.0", "symfony/framework-bundle": "^8.0",
"symfony/html-sanitizer": "8.0.*",
"symfony/http-client": "8.0.*", "symfony/http-client": "8.0.*",
"symfony/lock": "8.0.*", "symfony/lock": "8.0.*",
"symfony/mailer": "8.0.*", "symfony/mailer": "8.0.*",

256
backend/composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "cf5f7c77977031afccfa7da74ed52205", "content-hash": "92b9472c96a59c314d96372c4094f185",
"packages": [ "packages": [
{ {
"name": "api-platform/core", "name": "api-platform/core",
@@ -1785,6 +1785,188 @@
], ],
"time": "2025-10-17T11:30:53+00:00" "time": "2025-10-17T11:30:53+00:00"
}, },
{
"name": "league/uri",
"version": "7.8.1",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/uri.git",
"reference": "08cf38e3924d4f56238125547b5720496fac8fd4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4",
"reference": "08cf38e3924d4f56238125547b5720496fac8fd4",
"shasum": ""
},
"require": {
"league/uri-interfaces": "^7.8.1",
"php": "^8.1",
"psr/http-factory": "^1"
},
"conflict": {
"league/uri-schemes": "^1.0"
},
"suggest": {
"ext-bcmath": "to improve IPV4 host parsing",
"ext-dom": "to convert the URI into an HTML anchor tag",
"ext-fileinfo": "to create Data URI from file contennts",
"ext-gmp": "to improve IPV4 host parsing",
"ext-intl": "to handle IDN host with the best performance",
"ext-uri": "to use the PHP native URI class",
"jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain",
"league/uri-components": "to provide additional tools to manipulate URI objects components",
"league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP",
"php-64bit": "to improve IPV4 host parsing",
"rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification",
"symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "7.x-dev"
}
},
"autoload": {
"psr-4": {
"League\\Uri\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ignace Nyamagana Butera",
"email": "nyamsprod@gmail.com",
"homepage": "https://nyamsprod.com"
}
],
"description": "URI manipulation library",
"homepage": "https://uri.thephpleague.com",
"keywords": [
"URN",
"data-uri",
"file-uri",
"ftp",
"hostname",
"http",
"https",
"middleware",
"parse_str",
"parse_url",
"psr-7",
"query-string",
"querystring",
"rfc2141",
"rfc3986",
"rfc3987",
"rfc6570",
"rfc8141",
"uri",
"uri-template",
"url",
"ws"
],
"support": {
"docs": "https://uri.thephpleague.com",
"forum": "https://thephpleague.slack.com",
"issues": "https://github.com/thephpleague/uri-src/issues",
"source": "https://github.com/thephpleague/uri/tree/7.8.1"
},
"funding": [
{
"url": "https://github.com/sponsors/nyamsprod",
"type": "github"
}
],
"time": "2026-03-15T20:22:25+00:00"
},
{
"name": "league/uri-interfaces",
"version": "7.8.1",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/uri-interfaces.git",
"reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928",
"reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928",
"shasum": ""
},
"require": {
"ext-filter": "*",
"php": "^8.1",
"psr/http-message": "^1.1 || ^2.0"
},
"suggest": {
"ext-bcmath": "to improve IPV4 host parsing",
"ext-gmp": "to improve IPV4 host parsing",
"ext-intl": "to handle IDN host with the best performance",
"php-64bit": "to improve IPV4 host parsing",
"rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification",
"symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "7.x-dev"
}
},
"autoload": {
"psr-4": {
"League\\Uri\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ignace Nyamagana Butera",
"email": "nyamsprod@gmail.com",
"homepage": "https://nyamsprod.com"
}
],
"description": "Common tools for parsing and resolving RFC3987/RFC3986 URI",
"homepage": "https://uri.thephpleague.com",
"keywords": [
"data-uri",
"file-uri",
"ftp",
"hostname",
"http",
"https",
"parse_str",
"parse_url",
"psr-7",
"query-string",
"querystring",
"rfc3986",
"rfc3987",
"rfc6570",
"uri",
"url",
"ws"
],
"support": {
"docs": "https://uri.thephpleague.com",
"forum": "https://thephpleague.slack.com",
"issues": "https://github.com/thephpleague/uri-src/issues",
"source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1"
},
"funding": [
{
"url": "https://github.com/sponsors/nyamsprod",
"type": "github"
}
],
"time": "2026-03-08T20:05:35+00:00"
},
{ {
"name": "lexik/jwt-authentication-bundle", "name": "lexik/jwt-authentication-bundle",
"version": "v3.2.0", "version": "v3.2.0",
@@ -4841,6 +5023,78 @@
], ],
"time": "2026-01-27T09:06:10+00:00" "time": "2026-01-27T09:06:10+00:00"
}, },
{
"name": "symfony/html-sanitizer",
"version": "v8.0.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/html-sanitizer.git",
"reference": "555b37caeee3d07af33471e02377d5ff561f8ac2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/555b37caeee3d07af33471e02377d5ff561f8ac2",
"reference": "555b37caeee3d07af33471e02377d5ff561f8ac2",
"shasum": ""
},
"require": {
"ext-dom": "*",
"league/uri": "^6.5|^7.0",
"php": ">=8.4"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\HtmlSanitizer\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Titouan Galopin",
"email": "galopintitouan@gmail.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides an object-oriented API to sanitize untrusted HTML input for safe insertion into a document's DOM.",
"homepage": "https://symfony.com",
"keywords": [
"Purifier",
"html",
"sanitizer"
],
"support": {
"source": "https://github.com/symfony/html-sanitizer/tree/v8.0.7"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-06T13:17:40+00:00"
},
{ {
"name": "symfony/http-client", "name": "symfony/http-client",
"version": "v8.0.5", "version": "v8.0.5",

View File

@@ -0,0 +1,13 @@
framework:
html_sanitizer:
sanitizers:
homework_sanitizer:
allow_elements:
p: []
br: []
strong: []
em: []
ul: []
ol: []
li: []
a: ['href', 'target', 'rel']

View File

@@ -216,12 +216,29 @@ services:
App\Scolarite\Domain\Repository\HomeworkAttachmentRepository: App\Scolarite\Domain\Repository\HomeworkAttachmentRepository:
alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineHomeworkAttachmentRepository alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineHomeworkAttachmentRepository
# Homework Submissions (Story 5.10 - Rendu de devoir par l'élève)
App\Scolarite\Domain\Repository\HomeworkSubmissionRepository:
alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineHomeworkSubmissionRepository
App\Scolarite\Domain\Repository\SubmissionAttachmentRepository:
alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineSubmissionAttachmentRepository
App\Scolarite\Application\Port\ClassStudentsReader:
alias: App\Scolarite\Infrastructure\Service\DoctrineClassStudentsReader
App\Scolarite\Domain\Service\DueDateValidator: App\Scolarite\Domain\Service\DueDateValidator:
autowire: true autowire: true
App\Scolarite\Domain\Service\HomeworkDuplicator: App\Scolarite\Domain\Service\HomeworkDuplicator:
autowire: true autowire: true
App\Scolarite\Application\Port\HtmlSanitizer:
alias: App\Scolarite\Infrastructure\Service\HomeworkHtmlSanitizer
App\Scolarite\Infrastructure\Service\HomeworkHtmlSanitizer:
arguments:
$homeworkSanitizer: '@html_sanitizer.sanitizer.homework_sanitizer'
App\Scolarite\Application\Port\FileStorage: App\Scolarite\Application\Port\FileStorage:
alias: App\Scolarite\Infrastructure\Storage\LocalFileStorage alias: App\Scolarite\Infrastructure\Storage\LocalFileStorage

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260324162229 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create homework_submissions and submission_attachments tables';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE homework_submissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
homework_id UUID NOT NULL REFERENCES homework(id),
student_id UUID NOT NULL REFERENCES users(id),
response_html TEXT,
status VARCHAR(20) NOT NULL DEFAULT \'draft\',
submitted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (homework_id, student_id)
)');
$this->addSql('CREATE TABLE submission_attachments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
submission_id UUID NOT NULL REFERENCES homework_submissions(id) ON DELETE CASCADE,
filename VARCHAR(255) NOT NULL,
file_path VARCHAR(500) NOT NULL,
file_size INT NOT NULL,
mime_type VARCHAR(100) NOT NULL,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)');
$this->addSql('CREATE INDEX idx_submission_homework_tenant ON homework_submissions(homework_id, tenant_id)');
$this->addSql('CREATE INDEX idx_submission_lookup ON homework_submissions(homework_id, student_id, tenant_id)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE IF EXISTS submission_attachments');
$this->addSql('DROP TABLE IF EXISTS homework_submissions');
}
}

View File

@@ -10,6 +10,7 @@ use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Port\CurrentCalendarProvider; use App\Scolarite\Application\Port\CurrentCalendarProvider;
use App\Scolarite\Application\Port\EnseignantAffectationChecker; use App\Scolarite\Application\Port\EnseignantAffectationChecker;
use App\Scolarite\Application\Port\HomeworkRulesChecker; use App\Scolarite\Application\Port\HomeworkRulesChecker;
use App\Scolarite\Application\Port\HtmlSanitizer;
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException; use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
use App\Scolarite\Domain\Exception\ReglesDevoirsNonRespecteesException; use App\Scolarite\Domain\Exception\ReglesDevoirsNonRespecteesException;
use App\Scolarite\Domain\Model\Homework\Homework; use App\Scolarite\Domain\Model\Homework\Homework;
@@ -29,6 +30,7 @@ final readonly class CreateHomeworkHandler
private CurrentCalendarProvider $calendarProvider, private CurrentCalendarProvider $calendarProvider,
private DueDateValidator $dueDateValidator, private DueDateValidator $dueDateValidator,
private HomeworkRulesChecker $rulesChecker, private HomeworkRulesChecker $rulesChecker,
private HtmlSanitizer $htmlSanitizer,
private Clock $clock, private Clock $clock,
) { ) {
} }
@@ -63,13 +65,17 @@ final readonly class CreateHomeworkHandler
throw new ReglesDevoirsNonRespecteesException($rulesResult->toArray()); throw new ReglesDevoirsNonRespecteesException($rulesResult->toArray());
} }
$description = $command->description !== null
? $this->htmlSanitizer->sanitize($command->description)
: null;
$homework = Homework::creer( $homework = Homework::creer(
tenantId: $tenantId, tenantId: $tenantId,
classId: $classId, classId: $classId,
subjectId: $subjectId, subjectId: $subjectId,
teacherId: $teacherId, teacherId: $teacherId,
title: $command->title, title: $command->title,
description: $command->description, description: $description,
dueDate: $dueDate, dueDate: $dueDate,
now: $now, now: $now,
); );

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\SaveDraftSubmission;
final readonly class SaveDraftSubmissionCommand
{
public function __construct(
public string $tenantId,
public string $homeworkId,
public string $studentId,
public ?string $responseHtml,
) {
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\SaveDraftSubmission;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Port\HtmlSanitizer;
use App\Scolarite\Application\Port\StudentClassReader;
use App\Scolarite\Domain\Exception\EleveNonAffecteAuDevoirException;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmission;
use App\Scolarite\Domain\Repository\HomeworkRepository;
use App\Scolarite\Domain\Repository\HomeworkSubmissionRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class SaveDraftSubmissionHandler
{
public function __construct(
private HomeworkRepository $homeworkRepository,
private HomeworkSubmissionRepository $submissionRepository,
private StudentClassReader $studentClassReader,
private HtmlSanitizer $htmlSanitizer,
private Clock $clock,
) {
}
public function __invoke(SaveDraftSubmissionCommand $command): HomeworkSubmission
{
$tenantId = TenantId::fromString($command->tenantId);
$homeworkId = HomeworkId::fromString($command->homeworkId);
$studentId = UserId::fromString($command->studentId);
$homework = $this->homeworkRepository->get($homeworkId, $tenantId);
$classId = $this->studentClassReader->currentClassId($command->studentId, $tenantId);
if ($classId === null || $classId !== (string) $homework->classId) {
throw EleveNonAffecteAuDevoirException::pourEleve($studentId, $homeworkId);
}
$sanitizedHtml = $command->responseHtml !== null
? $this->htmlSanitizer->sanitize($command->responseHtml)
: null;
$now = $this->clock->now();
$existing = $this->submissionRepository->findByHomeworkAndStudent($homeworkId, $studentId, $tenantId);
if ($existing !== null) {
$existing->modifierBrouillon($sanitizedHtml, $now);
$this->submissionRepository->save($existing);
return $existing;
}
$submission = HomeworkSubmission::creerBrouillon(
tenantId: $tenantId,
homeworkId: $homeworkId,
studentId: $studentId,
responseHtml: $sanitizedHtml,
now: $now,
);
$this->submissionRepository->save($submission);
return $submission;
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\SubmitHomework;
final readonly class SubmitHomeworkCommand
{
public function __construct(
public string $tenantId,
public string $homeworkId,
public string $studentId,
) {
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\SubmitHomework;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Port\StudentClassReader;
use App\Scolarite\Domain\Exception\EleveNonAffecteAuDevoirException;
use App\Scolarite\Domain\Exception\RenduNonTrouveException;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmission;
use App\Scolarite\Domain\Repository\HomeworkRepository;
use App\Scolarite\Domain\Repository\HomeworkSubmissionRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class SubmitHomeworkHandler
{
public function __construct(
private HomeworkRepository $homeworkRepository,
private HomeworkSubmissionRepository $submissionRepository,
private StudentClassReader $studentClassReader,
private Clock $clock,
) {
}
public function __invoke(SubmitHomeworkCommand $command): HomeworkSubmission
{
$tenantId = TenantId::fromString($command->tenantId);
$homeworkId = HomeworkId::fromString($command->homeworkId);
$studentId = UserId::fromString($command->studentId);
$homework = $this->homeworkRepository->get($homeworkId, $tenantId);
$classId = $this->studentClassReader->currentClassId($command->studentId, $tenantId);
if ($classId === null || $classId !== (string) $homework->classId) {
throw EleveNonAffecteAuDevoirException::pourEleve($studentId, $homeworkId);
}
$submission = $this->submissionRepository->findByHomeworkAndStudent($homeworkId, $studentId, $tenantId);
if ($submission === null) {
throw RenduNonTrouveException::pourDevoirEtEleve($homeworkId, $studentId);
}
$now = $this->clock->now();
$submission->soumettre(dueDate: $homework->dueDate, now: $now);
$this->submissionRepository->save($submission);
return $submission;
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Scolarite\Application\Command\UpdateHomework;
use App\Administration\Domain\Model\User\UserId; use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Port\CurrentCalendarProvider; use App\Scolarite\Application\Port\CurrentCalendarProvider;
use App\Scolarite\Application\Port\HtmlSanitizer;
use App\Scolarite\Domain\Exception\NonProprietaireDuDevoirException; use App\Scolarite\Domain\Exception\NonProprietaireDuDevoirException;
use App\Scolarite\Domain\Model\Homework\Homework; use App\Scolarite\Domain\Model\Homework\Homework;
use App\Scolarite\Domain\Model\Homework\HomeworkId; use App\Scolarite\Domain\Model\Homework\HomeworkId;
@@ -23,6 +24,7 @@ final readonly class UpdateHomeworkHandler
private HomeworkRepository $homeworkRepository, private HomeworkRepository $homeworkRepository,
private CurrentCalendarProvider $calendarProvider, private CurrentCalendarProvider $calendarProvider,
private DueDateValidator $dueDateValidator, private DueDateValidator $dueDateValidator,
private HtmlSanitizer $htmlSanitizer,
private Clock $clock, private Clock $clock,
) { ) {
} }
@@ -44,9 +46,13 @@ final readonly class UpdateHomeworkHandler
$dueDate = new DateTimeImmutable($command->dueDate); $dueDate = new DateTimeImmutable($command->dueDate);
$this->dueDateValidator->valider($dueDate, $now, $calendar); $this->dueDateValidator->valider($dueDate, $now, $calendar);
$description = $command->description !== null
? $this->htmlSanitizer->sanitize($command->description)
: null;
$homework->modifier( $homework->modifier(
title: $command->title, title: $command->title,
description: $command->description, description: $description,
dueDate: $dueDate, dueDate: $dueDate,
now: $now, now: $now,
); );

View File

@@ -37,7 +37,7 @@ final readonly class UploadHomeworkAttachmentHandler
$this->homeworkRepository->get($homeworkId, $tenantId); $this->homeworkRepository->get($homeworkId, $tenantId);
$attachmentId = HomeworkAttachmentId::generate(); $attachmentId = HomeworkAttachmentId::generate();
$storagePath = sprintf('homework/%s/%s/%s', $command->tenantId, $command->homeworkId, $command->filename); $storagePath = sprintf('homework/%s/%s/%s/%s', $command->tenantId, $command->homeworkId, (string) $attachmentId, $command->filename);
$content = file_get_contents($command->tempFilePath); $content = file_get_contents($command->tempFilePath);

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\UploadSubmissionAttachment;
final readonly class UploadSubmissionAttachmentCommand
{
public function __construct(
public string $tenantId,
public string $submissionId,
public string $filename,
public string $mimeType,
public int $fileSize,
public string $tempFilePath,
) {
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\UploadSubmissionAttachment;
use App\Scolarite\Application\Port\FileStorage;
use App\Scolarite\Domain\Exception\PieceJointeInvalideException;
use App\Scolarite\Domain\Exception\RenduDejaSoumisException;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId;
use App\Scolarite\Domain\Model\HomeworkSubmission\SubmissionAttachment;
use App\Scolarite\Domain\Model\HomeworkSubmission\SubmissionAttachmentId;
use App\Scolarite\Domain\Repository\HomeworkSubmissionRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use function file_get_contents;
use function sprintf;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class UploadSubmissionAttachmentHandler
{
public function __construct(
private HomeworkSubmissionRepository $submissionRepository,
private FileStorage $fileStorage,
private Clock $clock,
) {
}
public function __invoke(UploadSubmissionAttachmentCommand $command): SubmissionAttachment
{
$tenantId = TenantId::fromString($command->tenantId);
$submissionId = HomeworkSubmissionId::fromString($command->submissionId);
$submission = $this->submissionRepository->get($submissionId, $tenantId);
if (!$submission->status->estModifiable()) {
throw RenduDejaSoumisException::pourRendu($submissionId);
}
$attachmentId = SubmissionAttachmentId::generate();
$storagePath = sprintf(
'submissions/%s/%s/%s/%s',
$command->tenantId,
$command->submissionId,
(string) $attachmentId,
$command->filename,
);
$content = file_get_contents($command->tempFilePath);
if ($content === false) {
throw PieceJointeInvalideException::lectureFichierImpossible($command->filename);
}
$this->fileStorage->upload($storagePath, $content, $command->mimeType);
return new SubmissionAttachment(
id: $attachmentId,
filename: $command->filename,
filePath: $storagePath,
fileSize: $command->fileSize,
mimeType: $command->mimeType,
uploadedAt: $this->clock->now(),
);
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Port;
use App\Shared\Domain\Tenant\TenantId;
/**
* Port pour lire les élèves affectés à une classe.
*/
interface ClassStudentsReader
{
/**
* @return array<array{id: string, name: string}> Liste des élèves avec ID et nom complet
*/
public function studentsInClass(string $classId, TenantId $tenantId): array;
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Port;
interface HtmlSanitizer
{
public function sanitize(string $html): string;
}

View File

@@ -5,12 +5,14 @@ declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetStudentHomework; namespace App\Scolarite\Application\Query\GetStudentHomework;
use App\Administration\Domain\Model\SchoolClass\ClassId; use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Port\ScheduleDisplayReader; use App\Scolarite\Application\Port\ScheduleDisplayReader;
use App\Scolarite\Application\Port\StudentClassReader; use App\Scolarite\Application\Port\StudentClassReader;
use App\Scolarite\Domain\Model\Homework\Homework; use App\Scolarite\Domain\Model\Homework\Homework;
use App\Scolarite\Domain\Model\Homework\HomeworkId; use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository; use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository;
use App\Scolarite\Domain\Repository\HomeworkRepository; use App\Scolarite\Domain\Repository\HomeworkRepository;
use App\Scolarite\Domain\Repository\HomeworkSubmissionRepository;
use App\Shared\Domain\Tenant\TenantId; use App\Shared\Domain\Tenant\TenantId;
use function array_filter; use function array_filter;
@@ -29,6 +31,7 @@ final readonly class GetStudentHomeworkHandler
private StudentClassReader $studentClassReader, private StudentClassReader $studentClassReader,
private HomeworkRepository $homeworkRepository, private HomeworkRepository $homeworkRepository,
private HomeworkAttachmentRepository $attachmentRepository, private HomeworkAttachmentRepository $attachmentRepository,
private HomeworkSubmissionRepository $submissionRepository,
private ScheduleDisplayReader $displayReader, private ScheduleDisplayReader $displayReader,
) { ) {
} }
@@ -56,7 +59,7 @@ final readonly class GetStudentHomeworkHandler
usort($homeworks, static fn (Homework $a, Homework $b): int => $a->dueDate <=> $b->dueDate); usort($homeworks, static fn (Homework $a, Homework $b): int => $a->dueDate <=> $b->dueDate);
return $this->enrichHomeworks($homeworks, $query->tenantId); return $this->enrichHomeworks($homeworks, $query->studentId, $query->tenantId);
} }
/** /**
@@ -64,7 +67,7 @@ final readonly class GetStudentHomeworkHandler
* *
* @return array<StudentHomeworkDto> * @return array<StudentHomeworkDto>
*/ */
private function enrichHomeworks(array $homeworks, string $tenantId): array private function enrichHomeworks(array $homeworks, string $studentId, string $tenantId): array
{ {
if ($homeworks === []) { if ($homeworks === []) {
return []; return [];
@@ -83,6 +86,14 @@ final readonly class GetStudentHomeworkHandler
$homeworkIds = array_map(static fn (Homework $h): HomeworkId => $h->id, $homeworks); $homeworkIds = array_map(static fn (Homework $h): HomeworkId => $h->id, $homeworks);
$attachmentMap = $this->attachmentRepository->hasAttachments(...$homeworkIds); $attachmentMap = $this->attachmentRepository->hasAttachments(...$homeworkIds);
$studentUserId = UserId::fromString($studentId);
$tenantIdObj = TenantId::fromString($tenantId);
$submissionStatusMap = $this->submissionRepository->findStatusesByStudent(
$studentUserId,
$tenantIdObj,
...$homeworkIds,
);
return array_map( return array_map(
static fn (Homework $h): StudentHomeworkDto => StudentHomeworkDto::fromDomain( static fn (Homework $h): StudentHomeworkDto => StudentHomeworkDto::fromDomain(
$h, $h,
@@ -90,6 +101,7 @@ final readonly class GetStudentHomeworkHandler
$subjects[(string) $h->subjectId]['color'] ?? null, $subjects[(string) $h->subjectId]['color'] ?? null,
$teacherNames[(string) $h->teacherId] ?? '', $teacherNames[(string) $h->teacherId] ?? '',
$attachmentMap[(string) $h->id] ?? false, $attachmentMap[(string) $h->id] ?? false,
$submissionStatusMap[(string) $h->id] ?? null,
), ),
$homeworks, $homeworks,
); );

View File

@@ -20,6 +20,7 @@ final readonly class StudentHomeworkDto
public string $dueDate, public string $dueDate,
public string $createdAt, public string $createdAt,
public bool $hasAttachments, public bool $hasAttachments,
public ?string $submissionStatus = null,
) { ) {
} }
@@ -29,6 +30,7 @@ final readonly class StudentHomeworkDto
?string $subjectColor, ?string $subjectColor,
string $teacherName, string $teacherName,
bool $hasAttachments, bool $hasAttachments,
?string $submissionStatus = null,
): self { ): self {
return new self( return new self(
id: (string) $homework->id, id: (string) $homework->id,
@@ -42,6 +44,7 @@ final readonly class StudentHomeworkDto
dueDate: $homework->dueDate->format('Y-m-d'), dueDate: $homework->dueDate->format('Y-m-d'),
createdAt: $homework->createdAt->format('Y-m-d\TH:i:sP'), createdAt: $homework->createdAt->format('Y-m-d\TH:i:sP'),
hasAttachments: $hasAttachments, hasAttachments: $hasAttachments,
submissionStatus: $submissionStatus,
); );
} }
} }

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Event;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class DevoirRendu implements DomainEvent
{
public function __construct(
public HomeworkSubmissionId $submissionId,
public HomeworkId $homeworkId,
public UserId $studentId,
public TenantId $tenantId,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->submissionId->value;
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Event;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class DevoirRenduEnRetard implements DomainEvent
{
public function __construct(
public HomeworkSubmissionId $submissionId,
public HomeworkId $homeworkId,
public UserId $studentId,
public TenantId $tenantId,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->submissionId->value;
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use DomainException;
use function sprintf;
final class EleveNonAffecteAuDevoirException extends DomainException
{
public static function pourEleve(UserId $studentId, HomeworkId $homeworkId): self
{
return new self(sprintf(
'L\'élève "%s" n\'appartient pas à la classe du devoir "%s".',
$studentId,
$homeworkId,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId;
use DomainException;
use function sprintf;
final class RenduDejaSoumisException extends DomainException
{
public static function pourRendu(HomeworkSubmissionId $id): self
{
return new self(sprintf(
'Le rendu "%s" a déjà été soumis et ne peut plus être modifié.',
$id,
));
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId;
use DomainException;
use function sprintf;
final class RenduNonTrouveException extends DomainException
{
public static function withId(HomeworkSubmissionId $id): self
{
return new self(sprintf(
'Le rendu avec l\'ID "%s" n\'a pas été trouvé.',
$id,
));
}
public static function pourDevoirEtEleve(HomeworkId $homeworkId, UserId $studentId): self
{
return new self(sprintf(
'Aucun rendu trouvé pour le devoir "%s" et l\'élève "%s".',
$homeworkId,
$studentId,
));
}
}

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\HomeworkSubmission;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Event\DevoirRendu;
use App\Scolarite\Domain\Event\DevoirRenduEnRetard;
use App\Scolarite\Domain\Exception\RenduDejaSoumisException;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Shared\Domain\AggregateRoot;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
final class HomeworkSubmission extends AggregateRoot
{
public private(set) DateTimeImmutable $updatedAt;
private function __construct(
public private(set) HomeworkSubmissionId $id,
public private(set) TenantId $tenantId,
public private(set) HomeworkId $homeworkId,
public private(set) UserId $studentId,
public private(set) ?string $responseHtml,
public private(set) SubmissionStatus $status,
public private(set) ?DateTimeImmutable $submittedAt,
public private(set) DateTimeImmutable $createdAt,
) {
$this->updatedAt = $createdAt;
}
public static function creerBrouillon(
TenantId $tenantId,
HomeworkId $homeworkId,
UserId $studentId,
?string $responseHtml,
DateTimeImmutable $now,
): self {
return new self(
id: HomeworkSubmissionId::generate(),
tenantId: $tenantId,
homeworkId: $homeworkId,
studentId: $studentId,
responseHtml: $responseHtml,
status: SubmissionStatus::DRAFT,
submittedAt: null,
createdAt: $now,
);
}
public function modifierBrouillon(?string $responseHtml, DateTimeImmutable $now): void
{
if (!$this->status->estModifiable()) {
throw RenduDejaSoumisException::pourRendu($this->id);
}
$this->responseHtml = $responseHtml;
$this->updatedAt = $now;
}
public function soumettre(DateTimeImmutable $dueDate, DateTimeImmutable $now): void
{
if (!$this->status->estModifiable()) {
throw RenduDejaSoumisException::pourRendu($this->id);
}
$this->submittedAt = $now;
$this->updatedAt = $now;
$estEnRetard = $now > $dueDate;
$this->status = $estEnRetard
? SubmissionStatus::LATE
: SubmissionStatus::SUBMITTED;
if ($estEnRetard) {
$this->recordEvent(new DevoirRenduEnRetard(
submissionId: $this->id,
homeworkId: $this->homeworkId,
studentId: $this->studentId,
tenantId: $this->tenantId,
occurredOn: $now,
));
} else {
$this->recordEvent(new DevoirRendu(
submissionId: $this->id,
homeworkId: $this->homeworkId,
studentId: $this->studentId,
tenantId: $this->tenantId,
occurredOn: $now,
));
}
}
/**
* @internal Pour usage Infrastructure uniquement
*/
public static function reconstitute(
HomeworkSubmissionId $id,
TenantId $tenantId,
HomeworkId $homeworkId,
UserId $studentId,
?string $responseHtml,
SubmissionStatus $status,
?DateTimeImmutable $submittedAt,
DateTimeImmutable $createdAt,
DateTimeImmutable $updatedAt,
): self {
$submission = new self(
id: $id,
tenantId: $tenantId,
homeworkId: $homeworkId,
studentId: $studentId,
responseHtml: $responseHtml,
status: $status,
submittedAt: $submittedAt,
createdAt: $createdAt,
);
$submission->updatedAt = $updatedAt;
return $submission;
}
}

View File

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

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\HomeworkSubmission;
use App\Scolarite\Domain\Exception\PieceJointeInvalideException;
use DateTimeImmutable;
use function in_array;
final class SubmissionAttachment
{
private const int MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 Mo
private const array ALLOWED_MIME_TYPES = [
'application/pdf',
'image/jpeg',
'image/png',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
];
public function __construct(
public private(set) SubmissionAttachmentId $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\HomeworkSubmission;
use App\Shared\Domain\EntityId;
final readonly class SubmissionAttachmentId extends EntityId
{
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\HomeworkSubmission;
enum SubmissionStatus: string
{
case DRAFT = 'draft';
case SUBMITTED = 'submitted';
case LATE = 'late';
public function estModifiable(): bool
{
return $this === self::DRAFT;
}
}

View File

@@ -18,4 +18,6 @@ interface HomeworkAttachmentRepository
public function hasAttachments(HomeworkId ...$homeworkIds): array; public function hasAttachments(HomeworkId ...$homeworkIds): array;
public function save(HomeworkId $homeworkId, HomeworkAttachment $attachment): void; public function save(HomeworkId $homeworkId, HomeworkAttachment $attachment): void;
public function delete(HomeworkId $homeworkId, HomeworkAttachment $attachment): void;
} }

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Repository;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Exception\RenduNonTrouveException;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmission;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId;
use App\Shared\Domain\Tenant\TenantId;
interface HomeworkSubmissionRepository
{
public function save(HomeworkSubmission $submission): void;
/** @throws RenduNonTrouveException */
public function get(HomeworkSubmissionId $id, TenantId $tenantId): HomeworkSubmission;
public function findById(HomeworkSubmissionId $id, TenantId $tenantId): ?HomeworkSubmission;
public function findByHomeworkAndStudent(
HomeworkId $homeworkId,
UserId $studentId,
TenantId $tenantId,
): ?HomeworkSubmission;
/** @return array<HomeworkSubmission> */
public function findByHomework(HomeworkId $homeworkId, TenantId $tenantId): array;
/**
* @return array<string, string|null> Map homeworkId => submission status value (or null)
*/
public function findStatusesByStudent(UserId $studentId, TenantId $tenantId, HomeworkId ...$homeworkIds): array;
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Repository;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId;
use App\Scolarite\Domain\Model\HomeworkSubmission\SubmissionAttachment;
interface SubmissionAttachmentRepository
{
/** @return array<SubmissionAttachment> */
public function findBySubmissionId(HomeworkSubmissionId $submissionId): array;
public function save(HomeworkSubmissionId $submissionId, SubmissionAttachment $attachment): void;
public function delete(HomeworkSubmissionId $submissionId, SubmissionAttachment $attachment): void;
}

View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Controller;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Command\UploadHomeworkAttachment\UploadHomeworkAttachmentCommand;
use App\Scolarite\Application\Command\UploadHomeworkAttachment\UploadHomeworkAttachmentHandler;
use App\Scolarite\Application\Port\FileStorage;
use App\Scolarite\Domain\Exception\PieceJointeInvalideException;
use App\Scolarite\Domain\Model\Homework\HomeworkAttachment;
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_map;
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\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
final readonly class HomeworkAttachmentController
{
public function __construct(
private Security $security,
private HomeworkRepository $homeworkRepository,
private HomeworkAttachmentRepository $attachmentRepository,
private UploadHomeworkAttachmentHandler $uploadHandler,
private FileStorage $fileStorage,
#[Autowire('%kernel.project_dir%/var/storage')]
private string $storageDir,
) {
}
#[Route('/api/homework/{id}/attachments', name: 'api_homework_attachment_list', methods: ['GET'])]
public function list(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é.');
}
if ((string) $homework->teacherId !== $user->userId()) {
throw new AccessDeniedHttpException('Accès non autorisé.');
}
$attachments = $this->attachmentRepository->findByHomeworkId($homework->id);
return new JsonResponse(array_map(
static fn (HomeworkAttachment $a): array => [
'id' => (string) $a->id,
'filename' => $a->filename,
'fileSize' => $a->fileSize,
'mimeType' => $a->mimeType,
],
$attachments,
));
}
#[Route('/api/homework/{id}/attachments', name: 'api_homework_attachment_upload', methods: ['POST'])]
public function upload(string $id, Request $request): 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é.');
}
if ((string) $homework->teacherId !== $user->userId()) {
throw new AccessDeniedHttpException('Seul le propriétaire peut ajouter des pièces jointes.');
}
$file = $request->files->get('file');
if ($file === null) {
throw new BadRequestHttpException('Aucun fichier envoyé.');
}
/** @var \Symfony\Component\HttpFoundation\File\UploadedFile $file */
$originalName = $file->getClientOriginalName();
$mimeType = $file->getMimeType() ?? $file->getClientMimeType() ?? '';
$fileSize = $file->getSize();
try {
$attachment = ($this->uploadHandler)(new UploadHomeworkAttachmentCommand(
tenantId: $user->tenantId(),
homeworkId: $id,
filename: $originalName,
mimeType: $mimeType,
fileSize: (int) $fileSize,
tempFilePath: $file->getPathname(),
));
$this->attachmentRepository->save($homework->id, $attachment);
} catch (PieceJointeInvalideException $e) {
throw new BadRequestHttpException($e->getMessage());
}
return new JsonResponse([
'id' => (string) $attachment->id,
'filename' => $attachment->filename,
'fileSize' => $attachment->fileSize,
'mimeType' => $attachment->mimeType,
], Response::HTTP_CREATED);
}
#[Route('/api/homework/{id}/attachments/{attachmentId}', name: 'api_homework_attachment_download', methods: ['GET'])]
public function download(string $id, string $attachmentId): BinaryFileResponse
{
$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é.');
}
if ((string) $homework->teacherId !== $user->userId()) {
throw new AccessDeniedHttpException('Accès non autorisé.');
}
$attachments = $this->attachmentRepository->findByHomeworkId($homework->id);
foreach ($attachments as $attachment) {
if ((string) $attachment->id === $attachmentId) {
$fullPath = $this->storageDir . '/' . $attachment->filePath;
$realPath = realpath($fullPath);
$realStorageDir = realpath($this->storageDir);
if ($realPath === false || $realStorageDir === false || !str_starts_with($realPath, $realStorageDir)) {
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.');
}
#[Route('/api/homework/{id}/attachments/{attachmentId}', name: 'api_homework_attachment_delete', methods: ['DELETE'])]
public function delete(string $id, string $attachmentId): Response
{
$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é.');
}
if ((string) $homework->teacherId !== $user->userId()) {
throw new AccessDeniedHttpException('Seul le propriétaire peut supprimer des pièces jointes.');
}
$attachments = $this->attachmentRepository->findByHomeworkId($homework->id);
foreach ($attachments as $attachment) {
if ((string) $attachment->id === $attachmentId) {
$this->fileStorage->delete($attachment->filePath);
$this->attachmentRepository->delete($homework->id, $attachment);
return new Response(status: Response::HTTP_NO_CONTENT);
}
}
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;
}
}

View File

@@ -45,7 +45,7 @@ final readonly class ParentHomeworkController
private GetChildrenHomeworkDetailHandler $detailHandler, private GetChildrenHomeworkDetailHandler $detailHandler,
private HomeworkRepository $homeworkRepository, private HomeworkRepository $homeworkRepository,
private HomeworkAttachmentRepository $attachmentRepository, private HomeworkAttachmentRepository $attachmentRepository,
#[Autowire('%kernel.project_dir%/var/uploads')] #[Autowire('%kernel.project_dir%/var/storage')]
private string $uploadsDir, private string $uploadsDir,
) { ) {
} }
@@ -138,7 +138,8 @@ final readonly class ParentHomeworkController
foreach ($attachments as $attachment) { foreach ($attachments as $attachment) {
if ((string) $attachment->id === $attachmentId) { if ((string) $attachment->id === $attachmentId) {
$realPath = realpath($attachment->filePath); $fullPath = $this->uploadsDir . '/' . $attachment->filePath;
$realPath = realpath($fullPath);
$realUploadsDir = realpath($this->uploadsDir); $realUploadsDir = realpath($this->uploadsDir);
if ($realPath === false || $realUploadsDir === false || !str_starts_with($realPath, $realUploadsDir)) { if ($realPath === false || $realUploadsDir === false || !str_starts_with($realPath, $realUploadsDir)) {

View File

@@ -44,7 +44,7 @@ final readonly class StudentHomeworkController
private HomeworkAttachmentRepository $attachmentRepository, private HomeworkAttachmentRepository $attachmentRepository,
private ScheduleDisplayReader $displayReader, private ScheduleDisplayReader $displayReader,
private StudentClassReader $studentClassReader, private StudentClassReader $studentClassReader,
#[Autowire('%kernel.project_dir%/var/uploads')] #[Autowire('%kernel.project_dir%/var/storage')]
private string $uploadsDir, private string $uploadsDir,
) { ) {
} }
@@ -115,7 +115,8 @@ final readonly class StudentHomeworkController
foreach ($attachments as $attachment) { foreach ($attachments as $attachment) {
if ((string) $attachment->id === $attachmentId) { if ((string) $attachment->id === $attachmentId) {
$realPath = realpath($attachment->filePath); $fullPath = $this->uploadsDir . '/' . $attachment->filePath;
$realPath = realpath($fullPath);
$realUploadsDir = realpath($this->uploadsDir); $realUploadsDir = realpath($this->uploadsDir);
if ($realPath === false || $realUploadsDir === false || !str_starts_with($realPath, $realUploadsDir)) { if ($realPath === false || $realUploadsDir === false || !str_starts_with($realPath, $realUploadsDir)) {
@@ -175,6 +176,7 @@ final readonly class StudentHomeworkController
'dueDate' => $dto->dueDate, 'dueDate' => $dto->dueDate,
'createdAt' => $dto->createdAt, 'createdAt' => $dto->createdAt,
'hasAttachments' => $dto->hasAttachments, 'hasAttachments' => $dto->hasAttachments,
'submissionStatus' => $dto->submissionStatus,
]; ];
} }

View File

@@ -0,0 +1,259 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Controller;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Command\SaveDraftSubmission\SaveDraftSubmissionCommand;
use App\Scolarite\Application\Command\SaveDraftSubmission\SaveDraftSubmissionHandler;
use App\Scolarite\Application\Command\SubmitHomework\SubmitHomeworkCommand;
use App\Scolarite\Application\Command\SubmitHomework\SubmitHomeworkHandler;
use App\Scolarite\Application\Command\UploadSubmissionAttachment\UploadSubmissionAttachmentCommand;
use App\Scolarite\Application\Command\UploadSubmissionAttachment\UploadSubmissionAttachmentHandler;
use App\Scolarite\Application\Port\StudentClassReader;
use App\Scolarite\Domain\Exception\EleveNonAffecteAuDevoirException;
use App\Scolarite\Domain\Exception\RenduDejaSoumisException;
use App\Scolarite\Domain\Exception\RenduNonTrouveException;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmission;
use App\Scolarite\Domain\Model\HomeworkSubmission\SubmissionAttachment;
use App\Scolarite\Domain\Repository\HomeworkRepository;
use App\Scolarite\Domain\Repository\HomeworkSubmissionRepository;
use App\Scolarite\Domain\Repository\SubmissionAttachmentRepository;
use App\Scolarite\Infrastructure\Security\HomeworkStudentVoter;
use App\Shared\Domain\Tenant\TenantId;
use function array_map;
use DateTimeImmutable;
use function is_string;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted(HomeworkStudentVoter::VIEW)]
final readonly class StudentSubmissionController
{
public function __construct(
private Security $security,
private SaveDraftSubmissionHandler $saveDraftHandler,
private SubmitHomeworkHandler $submitHandler,
private UploadSubmissionAttachmentHandler $uploadHandler,
private HomeworkRepository $homeworkRepository,
private HomeworkSubmissionRepository $submissionRepository,
private SubmissionAttachmentRepository $attachmentRepository,
private StudentClassReader $studentClassReader,
private MessageBusInterface $eventBus,
) {
}
#[Route('/api/me/homework/{id}/submission', name: 'api_student_submission_save_draft', methods: ['POST'])]
public function saveDraft(string $id, Request $request): JsonResponse
{
$user = $this->getSecurityUser();
$this->assertHomeworkBelongsToStudentClass($user, $id);
/** @var array{responseHtml?: string|null} $data */
$data = $request->toArray();
try {
$submission = ($this->saveDraftHandler)(new SaveDraftSubmissionCommand(
tenantId: $user->tenantId(),
homeworkId: $id,
studentId: $user->userId(),
responseHtml: isset($data['responseHtml']) && is_string($data['responseHtml']) ? $data['responseHtml'] : null,
));
} catch (EleveNonAffecteAuDevoirException $e) {
throw new NotFoundHttpException('Devoir non trouvé.', $e);
} catch (RenduDejaSoumisException $e) {
throw new ConflictHttpException($e->getMessage(), $e);
}
return new JsonResponse(
['data' => $this->serializeSubmission($submission)],
Response::HTTP_CREATED,
);
}
#[Route('/api/me/homework/{id}/submission/submit', name: 'api_student_submission_submit', methods: ['POST'])]
public function submit(string $id): JsonResponse
{
$user = $this->getSecurityUser();
$this->assertHomeworkBelongsToStudentClass($user, $id);
try {
$submission = ($this->submitHandler)(new SubmitHomeworkCommand(
tenantId: $user->tenantId(),
homeworkId: $id,
studentId: $user->userId(),
));
} catch (EleveNonAffecteAuDevoirException $e) {
throw new NotFoundHttpException('Devoir non trouvé.', $e);
} catch (RenduNonTrouveException $e) {
throw new NotFoundHttpException('Aucun brouillon à soumettre.', $e);
} catch (RenduDejaSoumisException $e) {
throw new ConflictHttpException($e->getMessage(), $e);
}
foreach ($submission->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
return new JsonResponse(['data' => $this->serializeSubmission($submission)]);
}
#[Route('/api/me/homework/{id}/submission/attachments', name: 'api_student_submission_upload_attachment', methods: ['POST'])]
public function uploadAttachment(string $id, Request $request): JsonResponse
{
$user = $this->getSecurityUser();
$this->assertHomeworkBelongsToStudentClass($user, $id);
$tenantId = TenantId::fromString($user->tenantId());
$homeworkId = HomeworkId::fromString($id);
$submission = $this->submissionRepository->findByHomeworkAndStudent(
$homeworkId,
UserId::fromString($user->userId()),
$tenantId,
);
if ($submission === null) {
throw new NotFoundHttpException('Aucun brouillon trouvé. Veuillez d\'abord créer un brouillon.');
}
if (!$submission->status->estModifiable()) {
throw new ConflictHttpException('Le rendu a déjà été soumis.');
}
$file = $request->files->get('file');
if ($file === null) {
throw new BadRequestHttpException('Aucun fichier fourni.');
}
/** @var \Symfony\Component\HttpFoundation\File\UploadedFile $file */
$attachment = ($this->uploadHandler)(new UploadSubmissionAttachmentCommand(
tenantId: $user->tenantId(),
submissionId: (string) $submission->id,
filename: $file->getClientOriginalName(),
mimeType: $file->getMimeType() ?? 'application/octet-stream',
fileSize: $file->getSize(),
tempFilePath: $file->getPathname(),
));
$this->attachmentRepository->save($submission->id, $attachment);
return new JsonResponse(
['data' => $this->serializeAttachment($attachment)],
Response::HTTP_CREATED,
);
}
#[Route('/api/me/homework/{id}/submission', name: 'api_student_submission_get', methods: ['GET'])]
public function getSubmission(string $id): JsonResponse
{
$user = $this->getSecurityUser();
$this->assertHomeworkBelongsToStudentClass($user, $id);
$tenantId = TenantId::fromString($user->tenantId());
$homeworkId = HomeworkId::fromString($id);
$submission = $this->submissionRepository->findByHomeworkAndStudent(
$homeworkId,
UserId::fromString($user->userId()),
$tenantId,
);
if ($submission === null) {
return new JsonResponse(['data' => null]);
}
$attachments = $this->attachmentRepository->findBySubmissionId($submission->id);
return new JsonResponse([
'data' => $this->serializeSubmissionWithAttachments($submission, $attachments),
]);
}
private function assertHomeworkBelongsToStudentClass(SecurityUser $user, string $homeworkId): void
{
$tenantId = TenantId::fromString($user->tenantId());
$homework = $this->homeworkRepository->findById(HomeworkId::fromString($homeworkId), $tenantId);
if ($homework === null) {
throw new NotFoundHttpException('Devoir non trouvé.');
}
$classId = $this->studentClassReader->currentClassId($user->userId(), $tenantId);
if ($classId === null || $classId !== (string) $homework->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<string, mixed>
*/
private function serializeSubmission(HomeworkSubmission $submission): array
{
return [
'id' => (string) $submission->id,
'homeworkId' => (string) $submission->homeworkId,
'studentId' => (string) $submission->studentId,
'responseHtml' => $submission->responseHtml,
'status' => $submission->status->value,
'submittedAt' => $submission->submittedAt?->format(DateTimeImmutable::ATOM),
'createdAt' => $submission->createdAt->format(DateTimeImmutable::ATOM),
'updatedAt' => $submission->updatedAt->format(DateTimeImmutable::ATOM),
];
}
/**
* @param array<SubmissionAttachment> $attachments
*
* @return array<string, mixed>
*/
private function serializeSubmissionWithAttachments(HomeworkSubmission $submission, array $attachments): array
{
$data = $this->serializeSubmission($submission);
$data['attachments'] = array_map($this->serializeAttachment(...), $attachments);
return $data;
}
/**
* @return array<string, mixed>
*/
private function serializeAttachment(SubmissionAttachment $attachment): array
{
return [
'id' => (string) $attachment->id,
'filename' => $attachment->filename,
'fileSize' => $attachment->fileSize,
'mimeType' => $attachment->mimeType,
];
}
}

View File

@@ -0,0 +1,302 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Controller;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Port\ClassStudentsReader;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmission;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId;
use App\Scolarite\Domain\Model\HomeworkSubmission\SubmissionAttachment;
use App\Scolarite\Domain\Repository\HomeworkRepository;
use App\Scolarite\Domain\Repository\HomeworkSubmissionRepository;
use App\Scolarite\Domain\Repository\SubmissionAttachmentRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_column;
use function array_diff;
use function array_filter;
use function array_map;
use function array_values;
use function count;
use DateTimeImmutable;
use function in_array;
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\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
final readonly class TeacherSubmissionController
{
public function __construct(
private Security $security,
private HomeworkRepository $homeworkRepository,
private HomeworkSubmissionRepository $submissionRepository,
private SubmissionAttachmentRepository $attachmentRepository,
private ClassStudentsReader $classStudentsReader,
#[Autowire('%kernel.project_dir%/var/storage')]
private string $storageDir,
) {
}
#[Route('/api/homework/{id}/submissions', name: 'api_teacher_submission_list', methods: ['GET'])]
public function list(string $id): JsonResponse
{
$user = $this->getSecurityUser();
$tenantId = TenantId::fromString($user->tenantId());
$homeworkId = HomeworkId::fromString($id);
$homework = $this->homeworkRepository->findById($homeworkId, $tenantId);
if ($homework === null) {
throw new NotFoundHttpException('Devoir non trouvé.');
}
if ((string) $homework->teacherId !== $user->userId()) {
throw new AccessDeniedHttpException('Accès non autorisé.');
}
$submissions = $this->submissionRepository->findByHomework($homeworkId, $tenantId);
$students = $this->classStudentsReader->studentsInClass((string) $homework->classId, $tenantId);
$studentNameMap = [];
foreach ($students as $student) {
/** @var string $studentId */
$studentId = $student['id'];
/** @var string $studentName */
$studentName = $student['name'];
$studentNameMap[$studentId] = $studentName;
}
$submittedStudentIds = array_map(
static fn (HomeworkSubmission $s): string => (string) $s->studentId,
$submissions,
);
$rows = array_map(
static fn (HomeworkSubmission $s): array => [
'id' => (string) $s->id,
'studentId' => (string) $s->studentId,
'studentName' => $studentNameMap[(string) $s->studentId] ?? '',
'status' => $s->status->value,
'submittedAt' => $s->submittedAt?->format(DateTimeImmutable::ATOM),
'createdAt' => $s->createdAt->format(DateTimeImmutable::ATOM),
],
$submissions,
);
foreach ($students as $student) {
/** @var string $sId */
$sId = $student['id'];
if (!in_array($sId, $submittedStudentIds, true)) {
/** @var string $sName */
$sName = $student['name'];
$rows[] = [
'id' => null,
'studentId' => $sId,
'studentName' => $sName,
'status' => 'not_submitted',
'submittedAt' => null,
'createdAt' => null,
];
}
}
return new JsonResponse(['data' => $rows]);
}
#[Route('/api/homework/{id}/submissions/stats', name: 'api_teacher_submission_stats', methods: ['GET'])]
public function stats(string $id): JsonResponse
{
$user = $this->getSecurityUser();
$tenantId = TenantId::fromString($user->tenantId());
$homeworkId = HomeworkId::fromString($id);
$homework = $this->homeworkRepository->findById($homeworkId, $tenantId);
if ($homework === null) {
throw new NotFoundHttpException('Devoir non trouvé.');
}
if ((string) $homework->teacherId !== $user->userId()) {
throw new AccessDeniedHttpException('Accès non autorisé.');
}
$submissions = $this->submissionRepository->findByHomework($homeworkId, $tenantId);
$students = $this->classStudentsReader->studentsInClass((string) $homework->classId, $tenantId);
$submittedStudentIds = array_map(
static fn (HomeworkSubmission $s): string => (string) $s->studentId,
array_filter(
$submissions,
static fn (HomeworkSubmission $s): bool => !$s->status->estModifiable(),
),
);
$allStudentIds = array_column($students, 'id');
$missingStudentIds = array_diff($allStudentIds, $submittedStudentIds);
$studentNameMap = [];
foreach ($students as $student) {
/** @var string $sId */
$sId = $student['id'];
/** @var string $sName */
$sName = $student['name'];
$studentNameMap[$sId] = $sName;
}
$missingStudents = array_values(array_map(
static fn (string $studentId): array => [
'id' => $studentId,
'name' => $studentNameMap[$studentId] ?? '',
],
array_filter(
$missingStudentIds,
static fn (string $studentId): bool => !in_array($studentId, $submittedStudentIds, true),
),
));
return new JsonResponse([
'data' => [
'totalStudents' => count($students),
'submittedCount' => count($submittedStudentIds),
'missingStudents' => $missingStudents,
],
]);
}
#[Route('/api/homework/{id}/submissions/{submissionId}', name: 'api_teacher_submission_detail', methods: ['GET'])]
public function detail(string $id, string $submissionId): JsonResponse
{
$user = $this->getSecurityUser();
$tenantId = TenantId::fromString($user->tenantId());
$homeworkId = HomeworkId::fromString($id);
$homework = $this->homeworkRepository->findById($homeworkId, $tenantId);
if ($homework === null) {
throw new NotFoundHttpException('Devoir non trouvé.');
}
if ((string) $homework->teacherId !== $user->userId()) {
throw new AccessDeniedHttpException('Accès non autorisé.');
}
$submission = $this->submissionRepository->findById(
HomeworkSubmissionId::fromString($submissionId),
$tenantId,
);
if ($submission === null || !$submission->homeworkId->equals($homeworkId)) {
throw new NotFoundHttpException('Rendu non trouvé.');
}
$attachments = $this->attachmentRepository->findBySubmissionId($submission->id);
$students = $this->classStudentsReader->studentsInClass((string) $homework->classId, $tenantId);
$studentName = '';
foreach ($students as $student) {
if ($student['id'] === (string) $submission->studentId) {
/** @var string $name */
$name = $student['name'];
$studentName = $name;
break;
}
}
return new JsonResponse([
'data' => [
'id' => (string) $submission->id,
'studentId' => (string) $submission->studentId,
'studentName' => $studentName,
'responseHtml' => $submission->responseHtml,
'status' => $submission->status->value,
'submittedAt' => $submission->submittedAt?->format(DateTimeImmutable::ATOM),
'createdAt' => $submission->createdAt->format(DateTimeImmutable::ATOM),
'attachments' => array_map(
static fn (SubmissionAttachment $a): array => [
'id' => (string) $a->id,
'filename' => $a->filename,
'fileSize' => $a->fileSize,
'mimeType' => $a->mimeType,
],
$attachments,
),
],
]);
}
#[Route('/api/homework/{homeworkId}/submissions/{submissionId}/attachments/{attachmentId}', name: 'api_teacher_submission_attachment_download', methods: ['GET'])]
public function downloadAttachment(string $homeworkId, string $submissionId, 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é.');
}
if ((string) $homework->teacherId !== $user->userId()) {
throw new AccessDeniedHttpException('Accès non autorisé.');
}
$submission = $this->submissionRepository->findById(
HomeworkSubmissionId::fromString($submissionId),
$tenantId,
);
if ($submission === null || !$submission->homeworkId->equals(HomeworkId::fromString($homeworkId))) {
throw new NotFoundHttpException('Rendu non trouvé.');
}
$attachments = $this->attachmentRepository->findBySubmissionId($submission->id);
foreach ($attachments as $attachment) {
if ((string) $attachment->id === $attachmentId) {
$fullPath = $this->storageDir . '/' . $attachment->filePath;
$realPath = realpath($fullPath);
$realStorageDir = realpath($this->storageDir);
if ($realPath === false || $realStorageDir === false || !str_starts_with($realPath, $realStorageDir)) {
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;
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\EventListener;
use App\Scolarite\Domain\Event\DevoirRendu;
use App\Scolarite\Domain\Event\DevoirRenduEnRetard;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Placeholder : notifiera l'enseignant lorsqu'un élève rend un devoir.
*
* L'implémentation réelle dépend de l'Epic 9 (Communication & Notifications).
* En attendant, on log l'événement pour la traçabilité.
*/
#[AsMessageHandler(bus: 'event.bus')]
final readonly class NotifierEnseignantDevoirRenduListener
{
public function __construct(
private LoggerInterface $logger,
) {
}
public function __invoke(DevoirRendu|DevoirRenduEnRetard $event): void
{
$this->logger->info('Devoir rendu par un élève (notification enseignant à implémenter — Epic 9)', [
'submissionId' => (string) $event->submissionId,
'homeworkId' => (string) $event->homeworkId,
'studentId' => (string) $event->studentId,
'tenantId' => (string) $event->tenantId,
'late' => $event instanceof DevoirRenduEnRetard,
]);
}
}

View File

@@ -80,6 +80,18 @@ final readonly class DoctrineHomeworkAttachmentRepository implements HomeworkAtt
); );
} }
#[Override]
public function delete(HomeworkId $homeworkId, HomeworkAttachment $attachment): void
{
$this->connection->executeStatement(
'DELETE FROM homework_attachments WHERE id = :id AND homework_id = :homework_id',
[
'id' => (string) $attachment->id,
'homework_id' => (string) $homeworkId,
],
);
}
/** @param array<string, mixed> $row */ /** @param array<string, mixed> $row */
private function hydrate(array $row): HomeworkAttachment private function hydrate(array $row): HomeworkAttachment
{ {

View File

@@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Persistence\Doctrine;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Exception\RenduNonTrouveException;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmission;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId;
use App\Scolarite\Domain\Model\HomeworkSubmission\SubmissionStatus;
use App\Scolarite\Domain\Repository\HomeworkSubmissionRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_fill_keys;
use function array_map;
use DateTimeImmutable;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Override;
final readonly class DoctrineHomeworkSubmissionRepository implements HomeworkSubmissionRepository
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function save(HomeworkSubmission $submission): void
{
$this->connection->executeStatement(
'INSERT INTO homework_submissions (id, tenant_id, homework_id, student_id, response_html, status, submitted_at, created_at, updated_at)
VALUES (:id, :tenant_id, :homework_id, :student_id, :response_html, :status, :submitted_at, :created_at, :updated_at)
ON CONFLICT (id) DO UPDATE SET
response_html = EXCLUDED.response_html,
status = EXCLUDED.status,
submitted_at = EXCLUDED.submitted_at,
updated_at = EXCLUDED.updated_at',
[
'id' => (string) $submission->id,
'tenant_id' => (string) $submission->tenantId,
'homework_id' => (string) $submission->homeworkId,
'student_id' => (string) $submission->studentId,
'response_html' => $submission->responseHtml,
'status' => $submission->status->value,
'submitted_at' => $submission->submittedAt?->format(DateTimeImmutable::ATOM),
'created_at' => $submission->createdAt->format(DateTimeImmutable::ATOM),
'updated_at' => $submission->updatedAt->format(DateTimeImmutable::ATOM),
],
);
}
#[Override]
public function get(HomeworkSubmissionId $id, TenantId $tenantId): HomeworkSubmission
{
$submission = $this->findById($id, $tenantId);
if ($submission === null) {
throw RenduNonTrouveException::withId($id);
}
return $submission;
}
#[Override]
public function findById(HomeworkSubmissionId $id, TenantId $tenantId): ?HomeworkSubmission
{
$row = $this->connection->fetchAssociative(
'SELECT * FROM homework_submissions WHERE id = :id AND tenant_id = :tenant_id',
['id' => (string) $id, 'tenant_id' => (string) $tenantId],
);
if ($row === false) {
return null;
}
return $this->hydrate($row);
}
#[Override]
public function findByHomeworkAndStudent(
HomeworkId $homeworkId,
UserId $studentId,
TenantId $tenantId,
): ?HomeworkSubmission {
$row = $this->connection->fetchAssociative(
'SELECT * FROM homework_submissions
WHERE homework_id = :homework_id
AND student_id = :student_id
AND tenant_id = :tenant_id',
[
'homework_id' => (string) $homeworkId,
'student_id' => (string) $studentId,
'tenant_id' => (string) $tenantId,
],
);
if ($row === false) {
return null;
}
return $this->hydrate($row);
}
#[Override]
public function findByHomework(HomeworkId $homeworkId, TenantId $tenantId): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM homework_submissions
WHERE homework_id = :homework_id
AND tenant_id = :tenant_id
ORDER BY submitted_at ASC NULLS LAST, created_at ASC',
[
'homework_id' => (string) $homeworkId,
'tenant_id' => (string) $tenantId,
],
);
return array_map($this->hydrate(...), $rows);
}
#[Override]
public function findStatusesByStudent(UserId $studentId, TenantId $tenantId, HomeworkId ...$homeworkIds): array
{
if ($homeworkIds === []) {
return [];
}
$ids = array_map(static fn (HomeworkId $id): string => (string) $id, $homeworkIds);
/** @var array<array{homework_id: string, status: string}> $rows */
$rows = $this->connection->fetchAllAssociative(
'SELECT homework_id, status FROM homework_submissions
WHERE student_id = :student_id
AND tenant_id = :tenant_id
AND homework_id IN (:homework_ids)',
[
'student_id' => (string) $studentId,
'tenant_id' => (string) $tenantId,
'homework_ids' => $ids,
],
['homework_ids' => ArrayParameterType::STRING],
);
$result = array_fill_keys($ids, null);
foreach ($rows as $row) {
/** @var string $hwId */
$hwId = $row['homework_id'];
/** @var string $status */
$status = $row['status'];
$result[$hwId] = $status;
}
return $result;
}
/** @param array<string, mixed> $row */
private function hydrate(array $row): HomeworkSubmission
{
/** @var string $id */
$id = $row['id'];
/** @var string $tenantId */
$tenantId = $row['tenant_id'];
/** @var string $homeworkId */
$homeworkId = $row['homework_id'];
/** @var string $studentId */
$studentId = $row['student_id'];
/** @var string|null $responseHtml */
$responseHtml = $row['response_html'];
/** @var string $status */
$status = $row['status'];
/** @var string|null $submittedAt */
$submittedAt = $row['submitted_at'];
/** @var string $createdAt */
$createdAt = $row['created_at'];
/** @var string $updatedAt */
$updatedAt = $row['updated_at'];
return HomeworkSubmission::reconstitute(
id: HomeworkSubmissionId::fromString($id),
tenantId: TenantId::fromString($tenantId),
homeworkId: HomeworkId::fromString($homeworkId),
studentId: UserId::fromString($studentId),
responseHtml: $responseHtml,
status: SubmissionStatus::from($status),
submittedAt: $submittedAt !== null ? new DateTimeImmutable($submittedAt) : null,
createdAt: new DateTimeImmutable($createdAt),
updatedAt: new DateTimeImmutable($updatedAt),
);
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Persistence\Doctrine;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId;
use App\Scolarite\Domain\Model\HomeworkSubmission\SubmissionAttachment;
use App\Scolarite\Domain\Model\HomeworkSubmission\SubmissionAttachmentId;
use App\Scolarite\Domain\Repository\SubmissionAttachmentRepository;
use function array_map;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Override;
final readonly class DoctrineSubmissionAttachmentRepository implements SubmissionAttachmentRepository
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function findBySubmissionId(HomeworkSubmissionId $submissionId): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM submission_attachments WHERE submission_id = :submission_id',
['submission_id' => (string) $submissionId],
);
return array_map($this->hydrate(...), $rows);
}
#[Override]
public function save(HomeworkSubmissionId $submissionId, SubmissionAttachment $attachment): void
{
$this->connection->executeStatement(
'INSERT INTO submission_attachments (id, submission_id, filename, file_path, file_size, mime_type, uploaded_at)
VALUES (:id, :submission_id, :filename, :file_path, :file_size, :mime_type, :uploaded_at)',
[
'id' => (string) $attachment->id,
'submission_id' => (string) $submissionId,
'filename' => $attachment->filename,
'file_path' => $attachment->filePath,
'file_size' => $attachment->fileSize,
'mime_type' => $attachment->mimeType,
'uploaded_at' => $attachment->uploadedAt->format(DateTimeImmutable::ATOM),
],
);
}
#[Override]
public function delete(HomeworkSubmissionId $submissionId, SubmissionAttachment $attachment): void
{
$this->connection->executeStatement(
'DELETE FROM submission_attachments WHERE id = :id AND submission_id = :submission_id',
[
'id' => (string) $attachment->id,
'submission_id' => (string) $submissionId,
],
);
}
/** @param array<string, mixed> $row */
private function hydrate(array $row): SubmissionAttachment
{
/** @var string $id */
$id = $row['id'];
/** @var string $filename */
$filename = $row['filename'];
/** @var string $filePath */
$filePath = $row['file_path'];
/** @var string|int $rawFileSize */
$rawFileSize = $row['file_size'];
$fileSize = (int) $rawFileSize;
/** @var string $mimeType */
$mimeType = $row['mime_type'];
/** @var string $uploadedAt */
$uploadedAt = $row['uploaded_at'];
return new SubmissionAttachment(
id: SubmissionAttachmentId::fromString($id),
filename: $filename,
filePath: $filePath,
fileSize: $fileSize,
mimeType: $mimeType,
uploadedAt: new DateTimeImmutable($uploadedAt),
);
}
}

View File

@@ -9,7 +9,9 @@ use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository; use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository;
use function array_fill_keys; use function array_fill_keys;
use function array_filter;
use function array_map; use function array_map;
use function array_values;
use Override; use Override;
@@ -42,4 +44,19 @@ final class InMemoryHomeworkAttachmentRepository implements HomeworkAttachmentRe
{ {
$this->byHomeworkId[(string) $homeworkId][] = $attachment; $this->byHomeworkId[(string) $homeworkId][] = $attachment;
} }
#[Override]
public function delete(HomeworkId $homeworkId, HomeworkAttachment $attachment): void
{
$key = (string) $homeworkId;
if (!isset($this->byHomeworkId[$key])) {
return;
}
$this->byHomeworkId[$key] = array_values(array_filter(
$this->byHomeworkId[$key],
static fn (HomeworkAttachment $a): bool => (string) $a->id !== (string) $attachment->id,
));
}
} }

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Exception\RenduNonTrouveException;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmission;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId;
use App\Scolarite\Domain\Repository\HomeworkSubmissionRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_fill_keys;
use function array_filter;
use function array_key_exists;
use function array_map;
use function array_values;
use Override;
final class InMemoryHomeworkSubmissionRepository implements HomeworkSubmissionRepository
{
/** @var array<string, HomeworkSubmission> */
private array $byId = [];
#[Override]
public function save(HomeworkSubmission $submission): void
{
$this->byId[(string) $submission->id] = $submission;
}
#[Override]
public function get(HomeworkSubmissionId $id, TenantId $tenantId): HomeworkSubmission
{
$submission = $this->findById($id, $tenantId);
if ($submission === null) {
throw RenduNonTrouveException::withId($id);
}
return $submission;
}
#[Override]
public function findById(HomeworkSubmissionId $id, TenantId $tenantId): ?HomeworkSubmission
{
$submission = $this->byId[(string) $id] ?? null;
if ($submission === null || !$submission->tenantId->equals($tenantId)) {
return null;
}
return $submission;
}
#[Override]
public function findByHomeworkAndStudent(
HomeworkId $homeworkId,
UserId $studentId,
TenantId $tenantId,
): ?HomeworkSubmission {
foreach ($this->byId as $submission) {
if (
$submission->homeworkId->equals($homeworkId)
&& $submission->studentId->equals($studentId)
&& $submission->tenantId->equals($tenantId)
) {
return $submission;
}
}
return null;
}
#[Override]
public function findByHomework(HomeworkId $homeworkId, TenantId $tenantId): array
{
return array_values(array_filter(
$this->byId,
static fn (HomeworkSubmission $s): bool => $s->homeworkId->equals($homeworkId)
&& $s->tenantId->equals($tenantId),
));
}
#[Override]
public function findStatusesByStudent(UserId $studentId, TenantId $tenantId, HomeworkId ...$homeworkIds): array
{
$ids = array_map(static fn (HomeworkId $id): string => (string) $id, $homeworkIds);
$result = array_fill_keys($ids, null);
foreach ($this->byId as $submission) {
if (
$submission->studentId->equals($studentId)
&& $submission->tenantId->equals($tenantId)
) {
$hwId = (string) $submission->homeworkId;
if (array_key_exists($hwId, $result)) {
$result[$hwId] = $submission->status->value;
}
}
}
return $result;
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Persistence\InMemory;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId;
use App\Scolarite\Domain\Model\HomeworkSubmission\SubmissionAttachment;
use App\Scolarite\Domain\Repository\SubmissionAttachmentRepository;
use function array_filter;
use function array_values;
use Override;
final class InMemorySubmissionAttachmentRepository implements SubmissionAttachmentRepository
{
/** @var array<string, array<SubmissionAttachment>> */
private array $bySubmissionId = [];
#[Override]
public function findBySubmissionId(HomeworkSubmissionId $submissionId): array
{
return $this->bySubmissionId[(string) $submissionId] ?? [];
}
#[Override]
public function save(HomeworkSubmissionId $submissionId, SubmissionAttachment $attachment): void
{
$this->bySubmissionId[(string) $submissionId][] = $attachment;
}
#[Override]
public function delete(HomeworkSubmissionId $submissionId, SubmissionAttachment $attachment): void
{
$key = (string) $submissionId;
if (!isset($this->bySubmissionId[$key])) {
return;
}
$this->bySubmissionId[$key] = array_values(array_filter(
$this->bySubmissionId[$key],
static fn (SubmissionAttachment $a): bool => (string) $a->id !== (string) $attachment->id,
));
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Service;
use App\Scolarite\Application\Port\ClassStudentsReader;
use App\Shared\Domain\Tenant\TenantId;
use function array_map;
use Doctrine\DBAL\Connection;
use Override;
final readonly class DoctrineClassStudentsReader implements ClassStudentsReader
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function studentsInClass(string $classId, TenantId $tenantId): array
{
/** @var array<array{id: string, first_name: string, last_name: string}> $rows */
$rows = $this->connection->fetchAllAssociative(
'SELECT u.id, u.first_name, u.last_name
FROM class_assignments ca
JOIN users u ON u.id = ca.user_id
WHERE ca.school_class_id = :class_id
AND ca.tenant_id = :tenant_id
ORDER BY u.last_name ASC, u.first_name ASC',
[
'class_id' => $classId,
'tenant_id' => (string) $tenantId,
],
);
return array_map(
static fn (array $row): array => [
'id' => $row['id'],
'name' => $row['first_name'] . ' ' . $row['last_name'],
],
$rows,
);
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Service;
use App\Scolarite\Application\Port\HtmlSanitizer;
use Override;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface;
final readonly class HomeworkHtmlSanitizer implements HtmlSanitizer
{
public function __construct(
private HtmlSanitizerInterface $homeworkSanitizer,
) {
}
#[Override]
public function sanitize(string $html): string
{
return $this->homeworkSanitizer->sanitize($html);
}
}

View File

@@ -15,6 +15,7 @@ use App\Scolarite\Application\Port\CurrentCalendarProvider;
use App\Scolarite\Application\Port\EnseignantAffectationChecker; use App\Scolarite\Application\Port\EnseignantAffectationChecker;
use App\Scolarite\Application\Port\HomeworkRulesChecker; use App\Scolarite\Application\Port\HomeworkRulesChecker;
use App\Scolarite\Application\Port\HomeworkRulesCheckResult; use App\Scolarite\Application\Port\HomeworkRulesCheckResult;
use App\Scolarite\Application\Port\HtmlSanitizer;
use App\Scolarite\Application\Port\RuleWarning; use App\Scolarite\Application\Port\RuleWarning;
use App\Scolarite\Domain\Exception\DateEcheanceInvalideException; use App\Scolarite\Domain\Exception\DateEcheanceInvalideException;
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException; use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
@@ -110,6 +111,46 @@ final class CreateHomeworkHandlerTest extends TestCase
self::assertNull($homework->description); self::assertNull($homework->description);
} }
#[Test]
public function itSanitizesHtmlDescription(): void
{
$sanitizer = new class implements HtmlSanitizer {
public function sanitize(string $html): string
{
return strip_tags($html, '<p><strong><em><ul><ol><li><a>');
}
};
$handler = $this->createHandlerWithSanitizer(affecte: true, htmlSanitizer: $sanitizer);
$command = $this->createCommand(description: '<p>Texte <strong>gras</strong></p><script>alert("xss")</script>');
$homework = $handler($command);
self::assertSame('<p>Texte <strong>gras</strong></p>alert("xss")', $homework->description);
}
#[Test]
public function itDoesNotSanitizeNullDescription(): void
{
$sanitizer = new class implements HtmlSanitizer {
public bool $called = false;
public function sanitize(string $html): string
{
$this->called = true;
return $html;
}
};
$handler = $this->createHandlerWithSanitizer(affecte: true, htmlSanitizer: $sanitizer);
$command = $this->createCommand(description: null);
$handler($command);
self::assertFalse($sanitizer->called);
}
#[Test] #[Test]
public function itThrowsWhenSoftRulesViolatedAndNotAcknowledged(): void public function itThrowsWhenSoftRulesViolatedAndNotAcknowledged(): void
{ {
@@ -269,12 +310,67 @@ final class CreateHomeworkHandlerTest extends TestCase
} }
}; };
$htmlSanitizer = new class implements HtmlSanitizer {
public function sanitize(string $html): string
{
return $html;
}
};
return new CreateHomeworkHandler( return new CreateHomeworkHandler(
$this->homeworkRepository, $this->homeworkRepository,
$affectationChecker, $affectationChecker,
$calendarProvider, $calendarProvider,
new DueDateValidator(), new DueDateValidator(),
$rulesChecker, $rulesChecker,
$htmlSanitizer,
$this->clock,
);
}
private function createHandlerWithSanitizer(bool $affecte, HtmlSanitizer $htmlSanitizer, ?HomeworkRulesCheckResult $rulesResult = null): CreateHomeworkHandler
{
$affectationChecker = new class($affecte) implements EnseignantAffectationChecker {
public function __construct(private readonly bool $affecte)
{
}
public function estAffecte(UserId $teacherId, ClassId $classId, SubjectId $subjectId, TenantId $tenantId): bool
{
return $this->affecte;
}
};
$calendarProvider = new class implements CurrentCalendarProvider {
public function forCurrentYear(TenantId $tenantId): SchoolCalendar
{
return SchoolCalendar::reconstitute(
tenantId: $tenantId,
academicYearId: AcademicYearId::fromString('550e8400-e29b-41d4-a716-446655440002'),
zone: null,
entries: [],
);
}
};
$rulesChecker = new class($rulesResult ?? HomeworkRulesCheckResult::ok()) implements HomeworkRulesChecker {
public function __construct(private readonly HomeworkRulesCheckResult $result)
{
}
public function verifier(TenantId $tenantId, DateTimeImmutable $dueDate, DateTimeImmutable $creationDate): HomeworkRulesCheckResult
{
return $this->result;
}
};
return new CreateHomeworkHandler(
$this->homeworkRepository,
$affectationChecker,
$calendarProvider,
new DueDateValidator(),
$rulesChecker,
$htmlSanitizer,
$this->clock, $this->clock,
); );
} }

View File

@@ -0,0 +1,220 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Command\SaveDraftSubmission;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Command\SaveDraftSubmission\SaveDraftSubmissionCommand;
use App\Scolarite\Application\Command\SaveDraftSubmission\SaveDraftSubmissionHandler;
use App\Scolarite\Application\Port\HtmlSanitizer;
use App\Scolarite\Application\Port\StudentClassReader;
use App\Scolarite\Domain\Exception\EleveNonAffecteAuDevoirException;
use App\Scolarite\Domain\Exception\RenduDejaSoumisException;
use App\Scolarite\Domain\Model\Homework\Homework;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Model\HomeworkSubmission\SubmissionStatus;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkRepository;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkSubmissionRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class SaveDraftSubmissionHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440060';
private InMemoryHomeworkRepository $homeworkRepository;
private InMemoryHomeworkSubmissionRepository $submissionRepository;
private Clock $clock;
private Homework $homework;
protected function setUp(): void
{
$this->homeworkRepository = new InMemoryHomeworkRepository();
$this->submissionRepository = new InMemoryHomeworkSubmissionRepository();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-03-24 10:00:00');
}
};
$this->homework = Homework::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
classId: ClassId::fromString(self::CLASS_ID),
subjectId: \App\Administration\Domain\Model\Subject\SubjectId::fromString(self::SUBJECT_ID),
teacherId: UserId::fromString(self::TEACHER_ID),
title: 'Exercices chapitre 5',
description: null,
dueDate: new DateTimeImmutable('2026-04-15'),
now: new DateTimeImmutable('2026-03-12 10:00:00'),
);
$this->homeworkRepository->save($this->homework);
}
#[Test]
public function itCreatesNewDraftSubmission(): void
{
$handler = $this->createHandler();
$command = $this->createCommand();
$submission = $handler($command);
self::assertSame(SubmissionStatus::DRAFT, $submission->status);
self::assertSame('<p>Ma réponse</p>', $submission->responseHtml);
self::assertNull($submission->submittedAt);
}
#[Test]
public function itPersistsDraftInRepository(): void
{
$handler = $this->createHandler();
$command = $this->createCommand();
$created = $handler($command);
$found = $this->submissionRepository->findByHomeworkAndStudent(
HomeworkId::fromString((string) $this->homework->id),
UserId::fromString(self::STUDENT_ID),
TenantId::fromString(self::TENANT_ID),
);
self::assertNotNull($found);
self::assertTrue($found->id->equals($created->id));
}
#[Test]
public function itUpdatesExistingDraft(): void
{
$handler = $this->createHandler();
$handler($this->createCommand(responseHtml: '<p>Première version</p>'));
$updated = $handler($this->createCommand(responseHtml: '<p>Version modifiée</p>'));
self::assertSame('<p>Version modifiée</p>', $updated->responseHtml);
self::assertSame(SubmissionStatus::DRAFT, $updated->status);
}
#[Test]
public function itThrowsWhenStudentNotInClass(): void
{
$handler = $this->createHandler(studentClassId: null);
$this->expectException(EleveNonAffecteAuDevoirException::class);
$handler($this->createCommand());
}
#[Test]
public function itThrowsWhenStudentInDifferentClass(): void
{
$handler = $this->createHandler(studentClassId: '550e8400-e29b-41d4-a716-446655440099');
$this->expectException(EleveNonAffecteAuDevoirException::class);
$handler($this->createCommand());
}
#[Test]
public function itThrowsWhenUpdatingSubmittedSubmission(): void
{
$handler = $this->createHandler();
$handler($this->createCommand());
// Soumettre le rendu
$submission = $this->submissionRepository->findByHomeworkAndStudent(
HomeworkId::fromString((string) $this->homework->id),
UserId::fromString(self::STUDENT_ID),
TenantId::fromString(self::TENANT_ID),
);
self::assertNotNull($submission);
$submission->soumettre(
dueDate: new DateTimeImmutable('2026-04-15'),
now: new DateTimeImmutable('2026-03-24 11:00:00'),
);
$this->submissionRepository->save($submission);
$this->expectException(RenduDejaSoumisException::class);
$handler($this->createCommand(responseHtml: '<p>Tentative de modification</p>'));
}
#[Test]
public function itSanitizesHtmlResponse(): void
{
$sanitizer = new class implements HtmlSanitizer {
public function sanitize(string $html): string
{
return strip_tags($html, '<p><strong><em>');
}
};
$handler = $this->createHandler(htmlSanitizer: $sanitizer);
$command = $this->createCommand(responseHtml: '<p>Texte</p><script>alert("xss")</script>');
$submission = $handler($command);
self::assertSame('<p>Texte</p>alert("xss")', $submission->responseHtml);
}
#[Test]
public function itAllowsNullResponse(): void
{
$handler = $this->createHandler();
$command = $this->createCommand(responseHtml: null);
$submission = $handler($command);
self::assertNull($submission->responseHtml);
}
private function createHandler(
?string $studentClassId = self::CLASS_ID,
?HtmlSanitizer $htmlSanitizer = null,
): SaveDraftSubmissionHandler {
$studentClassReader = new class($studentClassId) implements StudentClassReader {
public function __construct(private readonly ?string $classId)
{
}
public function currentClassId(string $studentId, TenantId $tenantId): ?string
{
return $this->classId;
}
};
$sanitizer = $htmlSanitizer ?? new class implements HtmlSanitizer {
public function sanitize(string $html): string
{
return $html;
}
};
return new SaveDraftSubmissionHandler(
$this->homeworkRepository,
$this->submissionRepository,
$studentClassReader,
$sanitizer,
$this->clock,
);
}
private function createCommand(?string $responseHtml = '<p>Ma réponse</p>'): SaveDraftSubmissionCommand
{
return new SaveDraftSubmissionCommand(
tenantId: self::TENANT_ID,
homeworkId: (string) $this->homework->id,
studentId: self::STUDENT_ID,
responseHtml: $responseHtml,
);
}
}

View File

@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Command\SubmitHomework;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Command\SubmitHomework\SubmitHomeworkCommand;
use App\Scolarite\Application\Command\SubmitHomework\SubmitHomeworkHandler;
use App\Scolarite\Application\Port\StudentClassReader;
use App\Scolarite\Domain\Exception\EleveNonAffecteAuDevoirException;
use App\Scolarite\Domain\Exception\RenduDejaSoumisException;
use App\Scolarite\Domain\Exception\RenduNonTrouveException;
use App\Scolarite\Domain\Model\Homework\Homework;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmission;
use App\Scolarite\Domain\Model\HomeworkSubmission\SubmissionStatus;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkRepository;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkSubmissionRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class SubmitHomeworkHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440060';
private InMemoryHomeworkRepository $homeworkRepository;
private InMemoryHomeworkSubmissionRepository $submissionRepository;
private Homework $homework;
protected function setUp(): void
{
$this->homeworkRepository = new InMemoryHomeworkRepository();
$this->submissionRepository = new InMemoryHomeworkSubmissionRepository();
$this->homework = Homework::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
classId: ClassId::fromString(self::CLASS_ID),
subjectId: SubjectId::fromString(self::SUBJECT_ID),
teacherId: UserId::fromString(self::TEACHER_ID),
title: 'Exercices chapitre 5',
description: null,
dueDate: new DateTimeImmutable('2026-04-15'),
now: new DateTimeImmutable('2026-03-12 10:00:00'),
);
$this->homeworkRepository->save($this->homework);
}
#[Test]
public function itSubmitsOnTimeSuccessfully(): void
{
$this->createDraft();
$handler = $this->createHandler(now: '2026-04-10 14:00:00');
$command = $this->createCommand();
$submission = $handler($command);
self::assertSame(SubmissionStatus::SUBMITTED, $submission->status);
self::assertNotNull($submission->submittedAt);
}
#[Test]
public function itSubmitsLateWhenAfterDueDate(): void
{
$this->createDraft();
$handler = $this->createHandler(now: '2026-04-20 14:00:00');
$command = $this->createCommand();
$submission = $handler($command);
self::assertSame(SubmissionStatus::LATE, $submission->status);
}
#[Test]
public function itThrowsWhenNoDraftExists(): void
{
$handler = $this->createHandler(now: '2026-04-10 14:00:00');
$this->expectException(RenduNonTrouveException::class);
$handler($this->createCommand());
}
#[Test]
public function itThrowsWhenAlreadySubmitted(): void
{
$this->createDraft();
$handler = $this->createHandler(now: '2026-04-10 14:00:00');
$handler($this->createCommand());
$this->expectException(RenduDejaSoumisException::class);
$handler($this->createCommand());
}
#[Test]
public function itThrowsWhenStudentNotInClass(): void
{
$this->createDraft();
$handler = $this->createHandler(now: '2026-04-10 14:00:00', studentClassId: null);
$this->expectException(EleveNonAffecteAuDevoirException::class);
$handler($this->createCommand());
}
private function createDraft(): void
{
$submission = HomeworkSubmission::creerBrouillon(
tenantId: TenantId::fromString(self::TENANT_ID),
homeworkId: $this->homework->id,
studentId: UserId::fromString(self::STUDENT_ID),
responseHtml: '<p>Ma réponse</p>',
now: new DateTimeImmutable('2026-03-24 10:00:00'),
);
$this->submissionRepository->save($submission);
}
private function createHandler(string $now, ?string $studentClassId = self::CLASS_ID): SubmitHomeworkHandler
{
$clock = new class($now) implements Clock {
public function __construct(private readonly string $time)
{
}
public function now(): DateTimeImmutable
{
return new DateTimeImmutable($this->time);
}
};
$studentClassReader = new class($studentClassId) implements StudentClassReader {
public function __construct(private readonly ?string $classId)
{
}
public function currentClassId(string $studentId, TenantId $tenantId): ?string
{
return $this->classId;
}
};
return new SubmitHomeworkHandler(
$this->homeworkRepository,
$this->submissionRepository,
$studentClassReader,
$clock,
);
}
private function createCommand(): SubmitHomeworkCommand
{
return new SubmitHomeworkCommand(
tenantId: self::TENANT_ID,
homeworkId: (string) $this->homework->id,
studentId: self::STUDENT_ID,
);
}
}

View File

@@ -12,6 +12,7 @@ use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Command\UpdateHomework\UpdateHomeworkCommand; use App\Scolarite\Application\Command\UpdateHomework\UpdateHomeworkCommand;
use App\Scolarite\Application\Command\UpdateHomework\UpdateHomeworkHandler; use App\Scolarite\Application\Command\UpdateHomework\UpdateHomeworkHandler;
use App\Scolarite\Application\Port\CurrentCalendarProvider; use App\Scolarite\Application\Port\CurrentCalendarProvider;
use App\Scolarite\Application\Port\HtmlSanitizer;
use App\Scolarite\Domain\Exception\DateEcheanceInvalideException; use App\Scolarite\Domain\Exception\DateEcheanceInvalideException;
use App\Scolarite\Domain\Exception\DevoirDejaSupprimeException; use App\Scolarite\Domain\Exception\DevoirDejaSupprimeException;
use App\Scolarite\Domain\Exception\HomeworkNotFoundException; use App\Scolarite\Domain\Exception\HomeworkNotFoundException;
@@ -172,6 +173,60 @@ final class UpdateHomeworkHandlerTest extends TestCase
self::assertSame('Exercices sans description', $homework->title); self::assertSame('Exercices sans description', $homework->title);
} }
#[Test]
public function itSanitizesHtmlDescription(): void
{
$sanitizer = new class implements HtmlSanitizer {
public function sanitize(string $html): string
{
return strip_tags($html, '<p><strong><em><ul><ol><li><a>');
}
};
$handler = $this->createHandlerWithSanitizer($sanitizer);
$command = new UpdateHomeworkCommand(
tenantId: self::TENANT_ID,
homeworkId: (string) $this->existingHomeworkId,
teacherId: '550e8400-e29b-41d4-a716-446655440010',
title: 'Test sanitize',
description: '<p>Texte</p><script>alert("xss")</script>',
dueDate: '2026-04-20',
);
$homework = $handler($command);
self::assertSame('<p>Texte</p>alert("xss")', $homework->description);
}
#[Test]
public function itDoesNotSanitizeNullDescription(): void
{
$sanitizer = new class implements HtmlSanitizer {
public bool $called = false;
public function sanitize(string $html): string
{
$this->called = true;
return $html;
}
};
$handler = $this->createHandlerWithSanitizer($sanitizer);
$command = new UpdateHomeworkCommand(
tenantId: self::TENANT_ID,
homeworkId: (string) $this->existingHomeworkId,
teacherId: '550e8400-e29b-41d4-a716-446655440010',
title: 'Test null desc',
description: null,
dueDate: '2026-04-20',
);
$handler($command);
self::assertFalse($sanitizer->called);
}
#[Test] #[Test]
public function itThrowsWhenNotOwner(): void public function itThrowsWhenNotOwner(): void
{ {
@@ -206,7 +261,7 @@ final class UpdateHomeworkHandlerTest extends TestCase
$this->homeworkRepository->save($homework); $this->homeworkRepository->save($homework);
} }
private function createHandler(): UpdateHomeworkHandler private function createHandlerWithSanitizer(HtmlSanitizer $htmlSanitizer): UpdateHomeworkHandler
{ {
$calendarProvider = new class implements CurrentCalendarProvider { $calendarProvider = new class implements CurrentCalendarProvider {
public function forCurrentYear(TenantId $tenantId): SchoolCalendar public function forCurrentYear(TenantId $tenantId): SchoolCalendar
@@ -224,6 +279,37 @@ final class UpdateHomeworkHandlerTest extends TestCase
$this->homeworkRepository, $this->homeworkRepository,
$calendarProvider, $calendarProvider,
new DueDateValidator(), new DueDateValidator(),
$htmlSanitizer,
$this->clock,
);
}
private function createHandler(): UpdateHomeworkHandler
{
$calendarProvider = new class implements CurrentCalendarProvider {
public function forCurrentYear(TenantId $tenantId): SchoolCalendar
{
return SchoolCalendar::reconstitute(
tenantId: $tenantId,
academicYearId: AcademicYearId::fromString('550e8400-e29b-41d4-a716-446655440002'),
zone: null,
entries: [],
);
}
};
$htmlSanitizer = new class implements HtmlSanitizer {
public function sanitize(string $html): string
{
return $html;
}
};
return new UpdateHomeworkHandler(
$this->homeworkRepository,
$calendarProvider,
new DueDateValidator(),
$htmlSanitizer,
$this->clock, $this->clock,
); );
} }

View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Command\UploadSubmissionAttachment;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Command\UploadSubmissionAttachment\UploadSubmissionAttachmentCommand;
use App\Scolarite\Application\Command\UploadSubmissionAttachment\UploadSubmissionAttachmentHandler;
use App\Scolarite\Application\Port\FileStorage;
use App\Scolarite\Domain\Exception\RenduDejaSoumisException;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmission;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkSubmissionRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class UploadSubmissionAttachmentHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string HOMEWORK_ID = '550e8400-e29b-41d4-a716-446655440050';
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440060';
private InMemoryHomeworkSubmissionRepository $submissionRepository;
private string $tempFile;
protected function setUp(): void
{
$this->submissionRepository = new InMemoryHomeworkSubmissionRepository();
$this->tempFile = tempnam(sys_get_temp_dir(), 'e2e_upload_');
file_put_contents($this->tempFile, 'fake PDF content');
}
protected function tearDown(): void
{
if (file_exists($this->tempFile)) {
unlink($this->tempFile);
}
}
#[Test]
public function itUploadsAttachmentForDraftSubmission(): void
{
$submission = $this->createDraftSubmission();
$this->submissionRepository->save($submission);
$handler = $this->createHandler();
$command = new UploadSubmissionAttachmentCommand(
tenantId: self::TENANT_ID,
submissionId: (string) $submission->id,
filename: 'devoir.pdf',
mimeType: 'application/pdf',
fileSize: 1024,
tempFilePath: $this->tempFile,
);
$attachment = $handler($command);
self::assertSame('devoir.pdf', $attachment->filename);
self::assertSame('application/pdf', $attachment->mimeType);
self::assertSame(1024, $attachment->fileSize);
self::assertStringContainsString('submissions/', $attachment->filePath);
}
#[Test]
public function itRejectsUploadOnSubmittedSubmission(): void
{
$submission = $this->createDraftSubmission();
$submission->soumettre(
dueDate: new DateTimeImmutable('2026-04-15'),
now: new DateTimeImmutable('2026-03-24 11:00:00'),
);
$this->submissionRepository->save($submission);
$handler = $this->createHandler();
$command = new UploadSubmissionAttachmentCommand(
tenantId: self::TENANT_ID,
submissionId: (string) $submission->id,
filename: 'devoir.pdf',
mimeType: 'application/pdf',
fileSize: 1024,
tempFilePath: $this->tempFile,
);
$this->expectException(RenduDejaSoumisException::class);
$handler($command);
}
private function createDraftSubmission(): HomeworkSubmission
{
return HomeworkSubmission::creerBrouillon(
tenantId: TenantId::fromString(self::TENANT_ID),
homeworkId: HomeworkId::fromString(self::HOMEWORK_ID),
studentId: UserId::fromString(self::STUDENT_ID),
responseHtml: '<p>Ma réponse</p>',
now: new DateTimeImmutable('2026-03-24 10:00:00'),
);
}
private function createHandler(): UploadSubmissionAttachmentHandler
{
$fileStorage = new class implements FileStorage {
public function upload(string $path, mixed $content, string $mimeType): string
{
return $path;
}
public function delete(string $path): void
{
}
};
$clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-03-24 10:00:00');
}
};
return new UploadSubmissionAttachmentHandler(
$this->submissionRepository,
$fileStorage,
$clock,
);
}
}

View File

@@ -14,8 +14,10 @@ use App\Scolarite\Application\Query\GetStudentHomework\GetStudentHomeworkQuery;
use App\Scolarite\Domain\Model\Homework\Homework; use App\Scolarite\Domain\Model\Homework\Homework;
use App\Scolarite\Domain\Model\Homework\HomeworkAttachment; use App\Scolarite\Domain\Model\Homework\HomeworkAttachment;
use App\Scolarite\Domain\Model\Homework\HomeworkAttachmentId; use App\Scolarite\Domain\Model\Homework\HomeworkAttachmentId;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmission;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkAttachmentRepository; use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkAttachmentRepository;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkRepository; use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkRepository;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkSubmissionRepository;
use App\Shared\Domain\Tenant\TenantId; use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable; use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
@@ -32,11 +34,13 @@ final class GetStudentHomeworkHandlerTest extends TestCase
private InMemoryHomeworkRepository $homeworkRepository; private InMemoryHomeworkRepository $homeworkRepository;
private InMemoryHomeworkAttachmentRepository $attachmentRepository; private InMemoryHomeworkAttachmentRepository $attachmentRepository;
private InMemoryHomeworkSubmissionRepository $submissionRepository;
protected function setUp(): void protected function setUp(): void
{ {
$this->homeworkRepository = new InMemoryHomeworkRepository(); $this->homeworkRepository = new InMemoryHomeworkRepository();
$this->attachmentRepository = new InMemoryHomeworkAttachmentRepository(); $this->attachmentRepository = new InMemoryHomeworkAttachmentRepository();
$this->submissionRepository = new InMemoryHomeworkSubmissionRepository();
} }
#[Test] #[Test]
@@ -190,6 +194,73 @@ final class GetStudentHomeworkHandlerTest extends TestCase
self::assertFalse($result[0]->hasAttachments); self::assertFalse($result[0]->hasAttachments);
} }
#[Test]
public function itReturnsNullSubmissionStatusWhenNoSubmission(): void
{
$handler = $this->createHandler();
$this->givenHomework('Devoir sans rendu', '2026-04-15');
$result = $handler(new GetStudentHomeworkQuery(
studentId: self::STUDENT_ID,
tenantId: self::TENANT_ID,
));
self::assertCount(1, $result);
self::assertNull($result[0]->submissionStatus);
}
#[Test]
public function itReturnsSubmissionStatusWhenDraftExists(): void
{
$handler = $this->createHandler();
$homework = $this->givenHomework('Devoir avec brouillon', '2026-04-15');
$submission = HomeworkSubmission::creerBrouillon(
tenantId: TenantId::fromString(self::TENANT_ID),
homeworkId: $homework->id,
studentId: UserId::fromString(self::STUDENT_ID),
responseHtml: '<p>Brouillon</p>',
now: new DateTimeImmutable('2026-03-24 10:00:00'),
);
$this->submissionRepository->save($submission);
$result = $handler(new GetStudentHomeworkQuery(
studentId: self::STUDENT_ID,
tenantId: self::TENANT_ID,
));
self::assertCount(1, $result);
self::assertSame('draft', $result[0]->submissionStatus);
}
#[Test]
public function itReturnsSubmittedStatusAfterSubmission(): void
{
$handler = $this->createHandler();
$homework = $this->givenHomework('Devoir soumis', '2026-04-15');
$submission = HomeworkSubmission::creerBrouillon(
tenantId: TenantId::fromString(self::TENANT_ID),
homeworkId: $homework->id,
studentId: UserId::fromString(self::STUDENT_ID),
responseHtml: '<p>Réponse</p>',
now: new DateTimeImmutable('2026-03-24 10:00:00'),
);
$submission->soumettre(
dueDate: new DateTimeImmutable('2026-04-15'),
now: new DateTimeImmutable('2026-03-24 11:00:00'),
);
$this->submissionRepository->save($submission);
$result = $handler(new GetStudentHomeworkQuery(
studentId: self::STUDENT_ID,
tenantId: self::TENANT_ID,
));
self::assertCount(1, $result);
self::assertSame('submitted', $result[0]->submissionStatus);
}
private function createHandler(?string $classId = self::CLASS_ID): GetStudentHomeworkHandler private function createHandler(?string $classId = self::CLASS_ID): GetStudentHomeworkHandler
{ {
$studentClassReader = new class($classId) implements StudentClassReader { $studentClassReader = new class($classId) implements StudentClassReader {
@@ -231,6 +302,7 @@ final class GetStudentHomeworkHandlerTest extends TestCase
$studentClassReader, $studentClassReader,
$this->homeworkRepository, $this->homeworkRepository,
$this->attachmentRepository, $this->attachmentRepository,
$this->submissionRepository,
$displayReader, $displayReader,
); );
} }

View File

@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Domain\Model\HomeworkSubmission;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Event\DevoirRendu;
use App\Scolarite\Domain\Event\DevoirRenduEnRetard;
use App\Scolarite\Domain\Exception\RenduDejaSoumisException;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmission;
use App\Scolarite\Domain\Model\HomeworkSubmission\SubmissionStatus;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class HomeworkSubmissionTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string HOMEWORK_ID = '550e8400-e29b-41d4-a716-446655440050';
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440060';
#[Test]
public function creerBrouillonCreatesDraftSubmission(): void
{
$submission = $this->createDraft();
self::assertSame(SubmissionStatus::DRAFT, $submission->status);
self::assertNull($submission->submittedAt);
self::assertEmpty($submission->pullDomainEvents());
}
#[Test]
public function creerBrouillonSetsAllProperties(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$homeworkId = HomeworkId::fromString(self::HOMEWORK_ID);
$studentId = UserId::fromString(self::STUDENT_ID);
$now = new DateTimeImmutable('2026-03-24 10:00:00');
$submission = HomeworkSubmission::creerBrouillon(
tenantId: $tenantId,
homeworkId: $homeworkId,
studentId: $studentId,
responseHtml: '<p>Ma réponse</p>',
now: $now,
);
self::assertTrue($submission->tenantId->equals($tenantId));
self::assertTrue($submission->homeworkId->equals($homeworkId));
self::assertTrue($submission->studentId->equals($studentId));
self::assertSame('<p>Ma réponse</p>', $submission->responseHtml);
self::assertSame(SubmissionStatus::DRAFT, $submission->status);
self::assertEquals($now, $submission->createdAt);
self::assertEquals($now, $submission->updatedAt);
}
#[Test]
public function creerBrouillonAllowsNullResponse(): void
{
$submission = HomeworkSubmission::creerBrouillon(
tenantId: TenantId::fromString(self::TENANT_ID),
homeworkId: HomeworkId::fromString(self::HOMEWORK_ID),
studentId: UserId::fromString(self::STUDENT_ID),
responseHtml: null,
now: new DateTimeImmutable('2026-03-24 10:00:00'),
);
self::assertNull($submission->responseHtml);
}
#[Test]
public function modifierBrouillonUpdatesResponse(): void
{
$submission = $this->createDraft();
$updatedAt = new DateTimeImmutable('2026-03-24 11:00:00');
$submission->modifierBrouillon('<p>Réponse modifiée</p>', $updatedAt);
self::assertSame('<p>Réponse modifiée</p>', $submission->responseHtml);
self::assertEquals($updatedAt, $submission->updatedAt);
}
#[Test]
public function modifierBrouillonThrowsWhenAlreadySubmitted(): void
{
$submission = $this->createDraft();
$submission->soumettre(
dueDate: new DateTimeImmutable('2026-04-15'),
now: new DateTimeImmutable('2026-03-24 12:00:00'),
);
$this->expectException(RenduDejaSoumisException::class);
$submission->modifierBrouillon('<p>Modification</p>', new DateTimeImmutable('2026-03-24 13:00:00'));
}
#[Test]
public function soumettreOnTimeRecordsDevoirRenduEvent(): void
{
$submission = $this->createDraft();
$dueDate = new DateTimeImmutable('2026-04-15 23:59:59');
$now = new DateTimeImmutable('2026-04-10 14:00:00');
$submission->soumettre(dueDate: $dueDate, now: $now);
self::assertSame(SubmissionStatus::SUBMITTED, $submission->status);
self::assertEquals($now, $submission->submittedAt);
$events = $submission->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(DevoirRendu::class, $events[0]);
self::assertSame($submission->id, $events[0]->submissionId);
}
#[Test]
public function soumettreLateSetsLateStatusAndRecordsEvent(): void
{
$submission = $this->createDraft();
$dueDate = new DateTimeImmutable('2026-03-20 23:59:59');
$now = new DateTimeImmutable('2026-03-24 14:00:00');
$submission->soumettre(dueDate: $dueDate, now: $now);
self::assertSame(SubmissionStatus::LATE, $submission->status);
self::assertEquals($now, $submission->submittedAt);
$events = $submission->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(DevoirRenduEnRetard::class, $events[0]);
self::assertSame($submission->id, $events[0]->submissionId);
}
#[Test]
public function soumettreThrowsWhenAlreadySubmitted(): void
{
$submission = $this->createDraft();
$submission->soumettre(
dueDate: new DateTimeImmutable('2026-04-15'),
now: new DateTimeImmutable('2026-03-24 12:00:00'),
);
$this->expectException(RenduDejaSoumisException::class);
$submission->soumettre(
dueDate: new DateTimeImmutable('2026-04-15'),
now: new DateTimeImmutable('2026-03-24 13:00:00'),
);
}
#[Test]
public function reconstituteRestoresAllPropertiesWithoutEvents(): void
{
$id = \App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId::generate();
$tenantId = TenantId::fromString(self::TENANT_ID);
$homeworkId = HomeworkId::fromString(self::HOMEWORK_ID);
$studentId = UserId::fromString(self::STUDENT_ID);
$createdAt = new DateTimeImmutable('2026-03-24 10:00:00');
$updatedAt = new DateTimeImmutable('2026-03-24 12:00:00');
$submittedAt = new DateTimeImmutable('2026-03-24 11:30:00');
$submission = HomeworkSubmission::reconstitute(
id: $id,
tenantId: $tenantId,
homeworkId: $homeworkId,
studentId: $studentId,
responseHtml: '<p>Réponse</p>',
status: SubmissionStatus::SUBMITTED,
submittedAt: $submittedAt,
createdAt: $createdAt,
updatedAt: $updatedAt,
);
self::assertTrue($submission->id->equals($id));
self::assertTrue($submission->tenantId->equals($tenantId));
self::assertTrue($submission->homeworkId->equals($homeworkId));
self::assertTrue($submission->studentId->equals($studentId));
self::assertSame('<p>Réponse</p>', $submission->responseHtml);
self::assertSame(SubmissionStatus::SUBMITTED, $submission->status);
self::assertEquals($submittedAt, $submission->submittedAt);
self::assertEquals($createdAt, $submission->createdAt);
self::assertEquals($updatedAt, $submission->updatedAt);
self::assertEmpty($submission->pullDomainEvents());
}
private function createDraft(): HomeworkSubmission
{
return HomeworkSubmission::creerBrouillon(
tenantId: TenantId::fromString(self::TENANT_ID),
homeworkId: HomeworkId::fromString(self::HOMEWORK_ID),
studentId: UserId::fromString(self::STUDENT_ID),
responseHtml: '<p>Ma réponse</p>',
now: new DateTimeImmutable('2026-03-24 10:00:00'),
);
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Domain\Model\HomeworkSubmission;
use App\Scolarite\Domain\Exception\PieceJointeInvalideException;
use App\Scolarite\Domain\Model\HomeworkSubmission\SubmissionAttachment;
use App\Scolarite\Domain\Model\HomeworkSubmission\SubmissionAttachmentId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class SubmissionAttachmentTest extends TestCase
{
#[Test]
#[DataProvider('validMimeTypes')]
public function acceptsAllowedMimeTypes(string $mimeType): void
{
$attachment = new SubmissionAttachment(
id: SubmissionAttachmentId::generate(),
filename: 'test.pdf',
filePath: 'submissions/tenant/hw/sub/test.pdf',
fileSize: 1024,
mimeType: $mimeType,
uploadedAt: new DateTimeImmutable(),
);
self::assertSame($mimeType, $attachment->mimeType);
}
/** @return iterable<string, array{string}> */
public static function validMimeTypes(): iterable
{
yield 'pdf' => ['application/pdf'];
yield 'jpeg' => ['image/jpeg'];
yield 'png' => ['image/png'];
yield 'docx' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'];
}
#[Test]
public function rejectsInvalidMimeType(): void
{
$this->expectException(PieceJointeInvalideException::class);
new SubmissionAttachment(
id: SubmissionAttachmentId::generate(),
filename: 'test.exe',
filePath: 'submissions/tenant/hw/sub/test.exe',
fileSize: 1024,
mimeType: 'application/x-msdownload',
uploadedAt: new DateTimeImmutable(),
);
}
#[Test]
public function rejectsFileTooLarge(): void
{
$this->expectException(PieceJointeInvalideException::class);
new SubmissionAttachment(
id: SubmissionAttachmentId::generate(),
filename: 'large.pdf',
filePath: 'submissions/tenant/hw/sub/large.pdf',
fileSize: 11 * 1024 * 1024, // 11 Mo
mimeType: 'application/pdf',
uploadedAt: new DateTimeImmutable(),
);
}
#[Test]
public function acceptsFileAtMaxSize(): void
{
$attachment = new SubmissionAttachment(
id: SubmissionAttachmentId::generate(),
filename: 'max.pdf',
filePath: 'submissions/tenant/hw/sub/max.pdf',
fileSize: 10 * 1024 * 1024, // exactly 10 Mo
mimeType: 'application/pdf',
uploadedAt: new DateTimeImmutable(),
);
self::assertSame(10 * 1024 * 1024, $attachment->fileSize);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Domain\Model\HomeworkSubmission;
use App\Scolarite\Domain\Model\HomeworkSubmission\SubmissionStatus;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class SubmissionStatusTest extends TestCase
{
#[Test]
public function draftEstModifiable(): void
{
self::assertTrue(SubmissionStatus::DRAFT->estModifiable());
}
#[Test]
public function submittedNestPasModifiable(): void
{
self::assertFalse(SubmissionStatus::SUBMITTED->estModifiable());
}
#[Test]
public function lateNestPasModifiable(): void
{
self::assertFalse(SubmissionStatus::LATE->estModifiable());
}
#[Test]
public function backingValues(): void
{
self::assertSame('draft', SubmissionStatus::DRAFT->value);
self::assertSame('submitted', SubmissionStatus::SUBMITTED->value);
self::assertSame('late', SubmissionStatus::LATE->value);
}
}

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Infrastructure\Service;
use App\Scolarite\Infrastructure\Service\HomeworkHtmlSanitizer;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
final class HomeworkHtmlSanitizerTest extends TestCase
{
private HomeworkHtmlSanitizer $sanitizer;
protected function setUp(): void
{
$config = (new HtmlSanitizerConfig())
->allowElement('p')
->allowElement('br')
->allowElement('strong')
->allowElement('em')
->allowElement('ul')
->allowElement('ol')
->allowElement('li')
->allowElement('a', ['href', 'target', 'rel']);
$this->sanitizer = new HomeworkHtmlSanitizer(new HtmlSanitizer($config));
}
#[Test]
public function itAllowsBoldText(): void
{
$html = '<p>Texte <strong>en gras</strong></p>';
self::assertSame($html, $this->sanitizer->sanitize($html));
}
#[Test]
public function itAllowsItalicText(): void
{
$html = '<p>Texte <em>en italique</em></p>';
self::assertSame($html, $this->sanitizer->sanitize($html));
}
#[Test]
public function itAllowsUnorderedLists(): void
{
$html = '<ul><li>Élément 1</li><li>Élément 2</li></ul>';
self::assertSame($html, $this->sanitizer->sanitize($html));
}
#[Test]
public function itAllowsOrderedLists(): void
{
$html = '<ol><li>Premier</li><li>Deuxième</li></ol>';
self::assertSame($html, $this->sanitizer->sanitize($html));
}
#[Test]
public function itAllowsLinksWithSafeAttributes(): void
{
$html = '<p><a href="https://example.com" target="_blank" rel="noopener noreferrer">Lien</a></p>';
self::assertSame($html, $this->sanitizer->sanitize($html));
}
#[Test]
public function itStripsScriptTags(): void
{
$result = $this->sanitizer->sanitize('<p>Texte</p><script>alert("xss")</script>');
self::assertStringNotContainsString('<script>', $result);
self::assertStringNotContainsString('alert', $result);
}
#[Test]
public function itStripsEventHandlers(): void
{
$result = $this->sanitizer->sanitize('<p onclick="alert(1)">Texte</p>');
self::assertStringNotContainsString('onclick', $result);
self::assertStringContainsString('Texte', $result);
}
#[Test]
public function itStripsStyleTags(): void
{
$result = $this->sanitizer->sanitize('<style>body{display:none}</style><p>Texte</p>');
self::assertStringNotContainsString('<style>', $result);
self::assertStringContainsString('Texte', $result);
}
#[Test]
public function itStripsIframeTags(): void
{
$result = $this->sanitizer->sanitize('<p>Texte</p><iframe src="evil.com"></iframe>');
self::assertStringNotContainsString('<iframe>', $result);
}
#[Test]
public function itStripsImgTags(): void
{
$result = $this->sanitizer->sanitize('<p>Texte</p><img src="x" onerror="alert(1)">');
self::assertStringNotContainsString('<img', $result);
self::assertStringNotContainsString('onerror', $result);
}
#[Test]
public function itHandlesEmptyString(): void
{
self::assertSame('', $this->sanitizer->sanitize(''));
}
#[Test]
public function itHandlesPlainText(): void
{
$result = $this->sanitizer->sanitize('Texte simple sans HTML');
self::assertSame('Texte simple sans HTML', $result);
}
#[Test]
public function itStripsJavascriptLinks(): void
{
$result = $this->sanitizer->sanitize('<a href="javascript:alert(1)">Clic</a>');
self::assertStringNotContainsString('javascript:', $result);
}
#[Test]
#[DataProvider('richContentProvider')]
public function itPreservesRichContent(string $input, string $expectedSubstring): void
{
$result = $this->sanitizer->sanitize($input);
self::assertStringContainsString($expectedSubstring, $result);
}
/** @return iterable<string, array{string, string}> */
public static function richContentProvider(): iterable
{
yield 'gras' => ['<strong>gras</strong>', '<strong>gras</strong>'];
yield 'italique' => ['<em>italique</em>', '<em>italique</em>'];
yield 'liste à puces' => ['<ul><li>item</li></ul>', '<li>item</li>'];
yield 'liste numérotée' => ['<ol><li>item</li></ol>', '<ol>'];
yield 'paragraphe' => ['<p>texte</p>', '<p>texte</p>'];
yield 'lien' => ['<a href="https://x.com">lien</a>', 'href="https://x.com"'];
}
}

View File

@@ -0,0 +1,467 @@
import { test, expect } from '@playwright/test';
import { execSync } from 'child_process';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { writeFileSync, mkdirSync, unlinkSync } from 'fs';
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}`;
// Réutilise le même enseignant que homework.spec.ts pour partager le setup
const TEACHER_EMAIL = 'e2e-homework-teacher@example.com';
const TEACHER_PASSWORD = 'HomeworkTest123';
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}`;
}
function seedTeacherAssignments() {
const { academicYearId } = resolveDeterministicIds();
try {
runSql(
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
`FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` +
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.tenant_id = '${TENANT_ID}' ` +
`AND s.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
}
async function loginAsTeacher(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(TEACHER_EMAIL);
await page.locator('#password').fill(TEACHER_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
async function navigateToHomework(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/dashboard/teacher/homework`);
await expect(page.getByRole('heading', { name: /mes devoirs/i })).toBeVisible({ timeout: 15000 });
}
async function createHomework(page: import('@playwright/test').Page, title: string) {
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await page.locator('#hw-class').selectOption({ index: 1 });
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill(title);
// Type in WYSIWYG editor
const editorContent = page.locator('.modal .rich-text-content');
await editorContent.click();
await page.keyboard.type('Consignes du devoir');
await page.locator('#hw-due-date').fill(getNextWeekday(5));
await page.getByRole('button', { name: /créer le devoir/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText(title)).toBeVisible({ timeout: 10000 });
}
function createTempPdf(): string {
const tmpDir = join(__dirname, '..', 'tmp-test-files');
mkdirSync(tmpDir, { recursive: true });
const filePath = join(tmpDir, 'test-attachment.pdf');
const pdfContent = `%PDF-1.4
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj
xref
0 4
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
trailer<</Size 4/Root 1 0 R>>
startxref
190
%%EOF`;
writeFileSync(filePath, pdfContent);
return filePath;
}
function createTempTxt(): string {
const tmpDir = join(__dirname, '..', 'tmp-test-files');
mkdirSync(tmpDir, { recursive: true });
const filePath = join(tmpDir, 'test-invalid.txt');
writeFileSync(filePath, 'This is a plain text file that should be rejected.');
return filePath;
}
function cleanupTempFiles() {
const tmpDir = join(__dirname, '..', 'tmp-test-files');
for (const name of ['test-attachment.pdf', 'test-invalid.txt']) {
try {
unlinkSync(join(tmpDir, name));
} catch {
// May not exist
}
}
}
test.describe('Rich Text & Attachments (Story 5.9)', () => {
test.beforeAll(async () => {
// Ensure teacher user exists (same as homework.spec.ts)
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();
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-HW-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-HW-Maths', 'E2EMAT', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
seedTeacherAssignments();
clearCache();
});
test.afterAll(() => {
cleanupTempFiles();
});
test.beforeEach(async () => {
try {
runSql(`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`);
} catch {
// Table may not exist
}
clearCache();
});
// ============================================================================
// T4.1 : WYSIWYG Editor
// ============================================================================
test.describe('WYSIWYG Editor', () => {
test('create form shows rich text editor with toolbar', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
// Rich text editor with toolbar should be visible
const editor = page.locator('.rich-text-editor');
await expect(editor).toBeVisible({ timeout: 5000 });
await expect(page.locator('.toolbar')).toBeVisible();
// Toolbar buttons
await expect(page.getByRole('button', { name: 'Gras' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Italique' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Liste à puces' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Liste numérotée' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Lien' })).toBeVisible();
});
test('can create homework with rich text description', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await page.locator('#hw-class').selectOption({ index: 1 });
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill('Devoir texte riche');
// Type in rich text editor
const editorContent = page.locator('.modal .rich-text-content');
await editorContent.click();
await page.keyboard.type('Consignes importantes');
await page.locator('#hw-due-date').fill(getNextWeekday(5));
await page.getByRole('button', { name: /créer le devoir/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText('Devoir texte riche')).toBeVisible({ timeout: 10000 });
});
test('bold formatting works in editor', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await page.locator('#hw-class').selectOption({ index: 1 });
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill('Devoir gras test');
const editorContent = page.locator('.modal .rich-text-content');
await editorContent.click();
await page.keyboard.type('Normal ');
// Apply bold via keyboard shortcut (more reliable than toolbar click)
await page.keyboard.press('Control+b');
await page.keyboard.type('en gras');
await page.locator('#hw-due-date').fill(getNextWeekday(5));
await page.getByRole('button', { name: /créer le devoir/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText('Devoir gras test')).toBeVisible({ timeout: 10000 });
// Verify bold is rendered in the description
const description = page.locator('.homework-description');
await expect(description.locator('strong')).toContainText('en gras');
});
});
// ============================================================================
// T4.2 : Upload attachment
// ============================================================================
test.describe('Attachments', () => {
test('can upload a PDF attachment to homework via edit modal', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
// Create homework
await createHomework(page, 'Devoir avec PJ');
// Open edit modal
const hwCard = page.locator('.homework-card', { hasText: 'Devoir avec PJ' });
await hwCard.getByRole('button', { name: /modifier/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
// Upload file
const pdfPath = createTempPdf();
const fileInput = page.locator('.file-input-hidden');
await fileInput.setInputFiles(pdfPath);
// File appears in list
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
});
// T4.3 : Delete attachment
test('can delete an uploaded attachment', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir suppr PJ');
// Open edit modal and upload
const hwCard = page.locator('.homework-card', { hasText: 'Devoir suppr PJ' });
await hwCard.getByRole('button', { name: /modifier/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
const pdfPath = createTempPdf();
await page.locator('.file-input-hidden').setInputFiles(pdfPath);
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
// Delete the attachment
await page.getByRole('button', { name: /supprimer test-attachment.pdf/i }).click();
await expect(page.getByText('test-attachment.pdf')).not.toBeVisible({ timeout: 5000 });
});
});
// ============================================================================
// T5.9.1 : Invalid file type rejection (P1)
// ============================================================================
test.describe('Invalid File Type Rejection', () => {
test('rejects a .txt file with an error message', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir rejet fichier');
// Open edit modal
const hwCard = page.locator('.homework-card', { hasText: 'Devoir rejet fichier' });
await hwCard.getByRole('button', { name: /modifier/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
// Try to upload a .txt file
const txtPath = createTempTxt();
const fileInput = page.locator('.file-input-hidden');
await fileInput.setInputFiles(txtPath);
// Error message should appear
const errorAlert = page.locator('[role="alert"]');
await expect(errorAlert).toBeVisible({ timeout: 5000 });
await expect(errorAlert).toContainText('Type de fichier non accepté');
// The .txt file should NOT appear in the file list
await expect(page.getByText('test-invalid.txt')).not.toBeVisible();
});
});
// ============================================================================
// T5.9.2 : Attachment persistence after save (P1)
// ============================================================================
test.describe('Attachment Persistence', () => {
test('uploaded attachment persists after saving and reopening edit modal', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir persistance PJ');
// Open edit modal
const hwCard = page.locator('.homework-card', { hasText: 'Devoir persistance PJ' });
await hwCard.getByRole('button', { name: /modifier/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
// Upload a PDF
const pdfPath = createTempPdf();
const fileInput = page.locator('.file-input-hidden');
await fileInput.setInputFiles(pdfPath);
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
// Save the changes
await page.getByRole('button', { name: /enregistrer/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
// Reopen the edit modal
const hwCardAfterSave = page.locator('.homework-card', { hasText: 'Devoir persistance PJ' });
await hwCardAfterSave.getByRole('button', { name: /modifier/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
// The attachment should still be there
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
});
});
// ============================================================================
// T5.9.3 : File size display after upload (P2)
// ============================================================================
test.describe('File Size Display', () => {
test('shows formatted file size after uploading a PDF', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir taille fichier');
// Open edit modal
const hwCard = page.locator('.homework-card', { hasText: 'Devoir taille fichier' });
await hwCard.getByRole('button', { name: /modifier/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
// Upload a PDF
const pdfPath = createTempPdf();
const fileInput = page.locator('.file-input-hidden');
await fileInput.setInputFiles(pdfPath);
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
// The file size element should be visible and show a formatted size (e.g., "xxx o" or "xxx Ko")
const fileSize = page.locator('.file-size');
await expect(fileSize).toBeVisible({ timeout: 5000 });
await expect(fileSize).toHaveText(/\d+(\.\d+)?\s*(o|Ko|Mo)/);
});
});
// ============================================================================
// T4.4 : Backward compatibility
// ============================================================================
test.describe('Backward Compatibility', () => {
test('existing plain text homework displays correctly', async ({ page }) => {
// Create homework with plain text description via SQL
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, u.id, 'Devoir texte brut E2E', 'Description simple sans balise HTML', '${getNextWeekday(10)}', 'published', NOW(), NOW() ` +
`FROM users u, school_classes c, subjects s ` +
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.tenant_id = '${TENANT_ID}' AND c.name = 'E2E-HW-6A' ` +
`AND s.tenant_id = '${TENANT_ID}' AND s.name = 'E2E-HW-Maths' ` +
`LIMIT 1`
);
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
// Plain text description displays correctly
await expect(page.getByText('Devoir texte brut E2E')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('Description simple sans balise HTML')).toBeVisible();
});
test('edit modal loads plain text in WYSIWYG editor', async ({ page }) => {
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, u.id, 'Devoir edit brut E2E', 'Ancienne description', '${getNextWeekday(10)}', 'published', NOW(), NOW() ` +
`FROM users u, school_classes c, subjects s ` +
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.tenant_id = '${TENANT_ID}' AND c.name = 'E2E-HW-6A' ` +
`AND s.tenant_id = '${TENANT_ID}' AND s.name = 'E2E-HW-Maths' ` +
`LIMIT 1`
);
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
// Open edit modal
const hwCard = page.locator('.homework-card', { hasText: 'Devoir edit brut E2E' });
await hwCard.getByRole('button', { name: /modifier/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
// WYSIWYG editor contains the old text
const editorContent = page.locator('.modal .rich-text-content');
await expect(editorContent).toContainText('Ancienne description', { timeout: 5000 });
});
});
});

View File

@@ -0,0 +1,394 @@
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-sub-student@example.com';
const STUDENT_PASSWORD = 'SubStudent123';
const TEACHER_EMAIL = 'e2e-sub-teacher@example.com';
const TEACHER_PASSWORD = 'SubTeacher123';
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}`;
}
function getPastDate(daysAgo: number): string {
const date = new Date();
date.setDate(date.getDate() - daysAgo);
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()
]);
}
async function loginAsTeacher(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(TEACHER_EMAIL);
await page.locator('#password').fill(TEACHER_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
test.describe('Homework Submission (Story 5.10)', () => {
test.describe.configure({ mode: 'serial' });
const dueDate = getNextWeekday(7);
const pastDueDate = getPastDate(3);
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-Sub-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
// Ensure subject exists
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-Sub-Maths', 'E2ESUBMAT', '#3b82f6', '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-Sub-6A' AND c.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
// Clean up submissions and homework
try {
runSql(
`DELETE FROM submission_attachments WHERE submission_id IN ` +
`(SELECT hs.id FROM homework_submissions hs JOIN homework h ON h.id = hs.homework_id ` +
`WHERE h.tenant_id = '${TENANT_ID}' AND h.class_id IN ` +
`(SELECT id FROM school_classes WHERE name = 'E2E-Sub-6A' AND tenant_id = '${TENANT_ID}'))`
);
runSql(
`DELETE FROM homework_submissions WHERE homework_id IN ` +
`(SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}' AND class_id IN ` +
`(SELECT id FROM school_classes WHERE name = 'E2E-Sub-6A' AND tenant_id = '${TENANT_ID}'))`
);
runSql(
`DELETE FROM homework_attachments WHERE homework_id IN ` +
`(SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}' AND class_id IN ` +
`(SELECT id FROM school_classes WHERE name = 'E2E-Sub-6A' AND tenant_id = '${TENANT_ID}'))`
);
runSql(
`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND class_id IN ` +
`(SELECT id FROM school_classes WHERE name = 'E2E-Sub-6A' AND tenant_id = '${TENANT_ID}')`
);
} catch {
// Tables may not exist
}
// Seed homework (future due date)
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, 'E2E Devoir à rendre', 'Rédigez un texte libre.', '${dueDate}', 'published', NOW(), NOW() ` +
`FROM school_classes c, ` +
`(SELECT id FROM subjects WHERE code = 'E2ESUBMAT' 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-Sub-6A' AND c.tenant_id = '${TENANT_ID}'`
);
// Seed homework (past due date for late submission test)
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, 'E2E Devoir en retard', 'Devoir déjà dû.', '${pastDueDate}', 'published', NOW(), NOW() ` +
`FROM school_classes c, ` +
`(SELECT id FROM subjects WHERE code = 'E2ESUBMAT' 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-Sub-6A' AND c.tenant_id = '${TENANT_ID}'`
);
clearCache();
});
// ======================================================================
// AC1 + AC3: Draft and Submission
// ======================================================================
test.describe('AC1+AC3: Write and submit homework', () => {
test('student sees "Rendre mon devoir" button in homework detail', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
await expect(page.locator('.homework-card')).toHaveCount(2, { timeout: 15000 });
// Click on the homework
await page.locator('.homework-card', { hasText: 'E2E Devoir à rendre' }).click();
await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 });
// Verify "Rendre mon devoir" button is visible
await expect(page.getByRole('button', { name: /rendre mon devoir/i })).toBeVisible();
});
test('student can save a draft', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
await page.locator('.homework-card', { hasText: 'E2E Devoir à rendre' }).click();
await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 });
// Click "Rendre mon devoir"
await page.getByRole('button', { name: /rendre mon devoir/i }).click();
await expect(page.locator('.submission-form')).toBeVisible({ timeout: 10000 });
// Wait for editor and type
const editor = page.locator('.ProseMirror');
await expect(editor).toBeVisible({ timeout: 10000 });
await editor.click();
await page.keyboard.type('Mon brouillon de reponse');
// Save draft
await page.getByRole('button', { name: /sauvegarder le brouillon/i }).click();
await expect(page.locator('.success-banner')).toContainText('Brouillon sauvegardé', {
timeout: 10000
});
});
test('student can upload a file attachment', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
await page.locator('.homework-card', { hasText: 'E2E Devoir à rendre' }).click();
await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 });
await page.getByRole('button', { name: /rendre mon devoir/i }).click();
await expect(page.locator('.submission-form')).toBeVisible({ timeout: 10000 });
// Upload a PDF file
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles({
name: 'devoir-eleve.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('Fake PDF content for E2E test')
});
// Wait for file to appear in the list
await expect(page.locator('.file-item')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('devoir-eleve.pdf')).toBeVisible();
});
test('student can submit homework with confirmation', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
await page.locator('.homework-card', { hasText: 'E2E Devoir à rendre' }).click();
await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 });
await page.getByRole('button', { name: /rendre mon devoir/i }).click();
await expect(page.locator('.submission-form')).toBeVisible({ timeout: 10000 });
// Click submit
await page.getByRole('button', { name: /soumettre mon devoir/i }).click();
// Confirmation dialog should appear
await expect(page.locator('[role="alertdialog"]')).toBeVisible({ timeout: 5000 });
await expect(page.locator('[role="alertdialog"]')).toContainText(
'Confirmer la soumission'
);
// Confirm submission
await page.getByRole('button', { name: /confirmer/i }).click();
// Should show success
await expect(page.locator('.success-banner')).toContainText('rendu avec succès', {
timeout: 10000
});
});
});
// ======================================================================
// AC4: Status in homework list
// ======================================================================
test.describe('AC4: Submission status in list', () => {
test('student sees submitted status in homework list', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
await expect(page.locator('.homework-card')).toHaveCount(2, { timeout: 15000 });
const card = page.locator('.homework-card', { hasText: 'E2E Devoir à rendre' });
await expect(card.locator('.submission-submitted')).toContainText('Rendu');
});
});
// ======================================================================
// AC4: Late submission
// ======================================================================
test.describe('AC4: Late submission', () => {
test('late submission shows en retard status', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
await expect(page.locator('.homework-card')).toHaveCount(2, { timeout: 15000 });
await page.locator('.homework-card', { hasText: 'E2E Devoir en retard' }).click();
await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 });
await page.getByRole('button', { name: /rendre mon devoir/i }).click();
await expect(page.locator('.submission-form')).toBeVisible({ timeout: 10000 });
// Wait for editor to be ready then type
const editor = page.locator('.ProseMirror');
await expect(editor).toBeVisible({ timeout: 10000 });
await editor.click();
await page.keyboard.type('Rendu en retard');
// Save draft first
await page.getByRole('button', { name: /sauvegarder le brouillon/i }).click();
await expect(page.locator('.success-banner')).toBeVisible({ timeout: 10000 });
// Wait for success to appear then submit
await page.getByRole('button', { name: /soumettre mon devoir/i }).click();
await expect(page.locator('[role="alertdialog"]')).toBeVisible({ timeout: 5000 });
await page.getByRole('button', { name: /confirmer/i }).click();
await expect(page.locator('.success-banner')).toContainText('rendu', {
timeout: 15000
});
// Go back to list and check status
await page.goto(`${ALPHA_URL}/dashboard/homework`);
await page.waitForLoadState('networkidle');
await expect(page.locator('.homework-card')).toHaveCount(2, { timeout: 15000 });
const card = page.locator('.homework-card', { hasText: 'E2E Devoir en retard' });
await expect(card.locator('.submission-late')).toContainText('retard', {
timeout: 15000
});
});
});
// ======================================================================
// AC5 + AC6: Teacher views submissions and stats
// ======================================================================
test.describe('AC5+AC6: Teacher submission views', () => {
test('teacher can view submissions list and stats', async ({ page }) => {
await loginAsTeacher(page);
// Get the homework ID from the database
const homeworkIdOutput = execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM homework WHERE title = 'E2E Devoir à rendre' AND tenant_id = '${TENANT_ID}' LIMIT 1" 2>&1`,
{ encoding: 'utf-8' }
);
const idMatch = homeworkIdOutput.match(
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/
);
if (!idMatch) {
throw new Error('Could not find homework ID');
}
const homeworkId = idMatch[0];
// Navigate to submissions page
await page.goto(
`${ALPHA_URL}/dashboard/teacher/homework/${homeworkId}/submissions`
);
// Should see stats
await expect(page.locator('.stat-value')).toContainText('1', { timeout: 15000 });
// Should see the submission in the table
await expect(page.locator('tbody tr')).toHaveCount(1, { timeout: 10000 });
// Should see "Voir" button
await expect(page.getByRole('button', { name: 'Voir', exact: true })).toBeVisible();
// Click to view detail
await page.getByRole('button', { name: 'Voir', exact: true }).click();
await expect(page.locator('.detail-header')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.response-content')).toBeVisible();
});
});
});

View File

@@ -217,7 +217,7 @@ test.describe('Homework Management (Story 5.1)', () => {
await page.locator('#hw-title').fill('Exercices chapitre 5'); await page.locator('#hw-title').fill('Exercices chapitre 5');
// Fill description // Fill description
await page.locator('#hw-description').fill('Pages 42-45, exercices 1 à 10'); await page.locator('.modal .rich-text-content').click(); await page.locator('.modal .rich-text-content').pressSequentially('Pages 42-45, exercices 1 à 10');
// Set due date (next weekday, at least 2 days from now) // Set due date (next weekday, at least 2 days from now)
await page.locator('#hw-due-date').fill(getNextWeekday(3)); await page.locator('#hw-due-date').fill(getNextWeekday(3));
@@ -389,7 +389,7 @@ test.describe('Homework Management (Story 5.1)', () => {
await page.locator('#hw-class').selectOption({ index: 1 }); await page.locator('#hw-class').selectOption({ index: 1 });
await page.locator('#hw-subject').selectOption({ index: 1 }); await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill('Devoir date passée'); await page.locator('#hw-title').fill('Devoir date passée');
await page.locator('#hw-description').fill('Test validation'); await page.locator('.modal .rich-text-content').click(); await page.locator('.modal .rich-text-content').pressSequentially('Test validation');
// Set a past date — fill() works with Svelte 5 bind:value // Set a past date — fill() works with Svelte 5 bind:value
const yesterday = new Date(); const yesterday = new Date();
@@ -686,7 +686,7 @@ test.describe('Homework Management (Story 5.1)', () => {
await page.locator('#hw-class').selectOption({ index: 1 }); await page.locator('#hw-class').selectOption({ index: 1 });
await page.locator('#hw-subject').selectOption({ index: 1 }); await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill('Titre original'); await page.locator('#hw-title').fill('Titre original');
await page.locator('#hw-description').fill('Description inchangée'); await page.locator('.modal .rich-text-content').click(); await page.locator('.modal .rich-text-content').pressSequentially('Description inchangée');
await page.locator('#hw-due-date').fill(dueDate); await page.locator('#hw-due-date').fill(dueDate);
await page.getByRole('button', { name: /créer le devoir/i }).click(); await page.getByRole('button', { name: /créer le devoir/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
@@ -698,7 +698,7 @@ test.describe('Homework Management (Story 5.1)', () => {
await expect(editDialog).toBeVisible({ timeout: 10000 }); await expect(editDialog).toBeVisible({ timeout: 10000 });
// Verify pre-filled values // Verify pre-filled values
await expect(page.locator('#edit-description')).toHaveValue('Description inchangée'); await expect(page.locator('.modal .rich-text-content')).toContainText('Description inchangée');
await expect(page.locator('#edit-due-date')).toHaveValue(dueDate); await expect(page.locator('#edit-due-date')).toHaveValue(dueDate);
// Change only the title // Change only the title

View File

@@ -34,7 +34,6 @@
"eslint": "^9.0.0", "eslint": "^9.0.0",
"eslint-config-prettier": "^10.0.0", "eslint-config-prettier": "^10.0.0",
"eslint-plugin-svelte": "^3.0.0", "eslint-plugin-svelte": "^3.0.0",
"svelte-eslint-parser": "^1.0.0",
"jsdom": "^27.4.0", "jsdom": "^27.4.0",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"prettier": "^3.4.0", "prettier": "^3.4.0",
@@ -42,6 +41,7 @@
"prettier-plugin-tailwindcss": "^0.6.0", "prettier-plugin-tailwindcss": "^0.6.0",
"svelte": "^5.15.0", "svelte": "^5.15.0",
"svelte-check": "^4.1.0", "svelte-check": "^4.1.0",
"svelte-eslint-parser": "^1.0.0",
"tailwindcss": "^3.4.16", "tailwindcss": "^3.4.16",
"typescript": "^5.7.0", "typescript": "^5.7.0",
"typescript-eslint": "^8.54.0", "typescript-eslint": "^8.54.0",
@@ -51,6 +51,10 @@
"dependencies": { "dependencies": {
"@sentry/sveltekit": "^8.50.0", "@sentry/sveltekit": "^8.50.0",
"@tanstack/svelte-query": "^5.66.0", "@tanstack/svelte-query": "^5.66.0",
"@tiptap/core": "^3.20.4",
"@tiptap/extension-link": "^3.20.4",
"@tiptap/pm": "^3.20.4",
"@tiptap/starter-kit": "^3.20.4",
"@vite-pwa/sveltekit": "^0.6.8", "@vite-pwa/sveltekit": "^0.6.8",
"web-vitals": "^4.2.0", "web-vitals": "^4.2.0",
"workbox-window": "^7.3.0" "workbox-window": "^7.3.0"

535
frontend/pnpm-lock.yaml generated
View File

@@ -14,6 +14,18 @@ importers:
'@tanstack/svelte-query': '@tanstack/svelte-query':
specifier: ^5.66.0 specifier: ^5.66.0
version: 5.90.2(svelte@5.49.1) version: 5.90.2(svelte@5.49.1)
'@tiptap/core':
specifier: ^3.20.4
version: 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-link':
specifier: ^3.20.4
version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/pm':
specifier: ^3.20.4
version: 3.20.4
'@tiptap/starter-kit':
specifier: ^3.20.4
version: 3.20.4
'@vite-pwa/sveltekit': '@vite-pwa/sveltekit':
specifier: ^0.6.8 specifier: ^0.6.8
version: 0.6.8(@sveltejs/kit@2.50.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(vite-plugin-pwa@0.21.2(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0))(workbox-build@7.4.0)(workbox-window@7.4.0)) version: 0.6.8(@sveltejs/kit@2.50.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(vite-plugin-pwa@0.21.2(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0))(workbox-build@7.4.0)(workbox-window@7.4.0))
@@ -1315,6 +1327,9 @@ packages:
'@prisma/instrumentation@5.22.0': '@prisma/instrumentation@5.22.0':
resolution: {integrity: sha512-LxccF392NN37ISGxIurUljZSh1YWnphO34V5a0+T7FVQG2u9bhAXRTJpgmQ3483woVhkraQZFF7cbRrpbw/F4Q==} resolution: {integrity: sha512-LxccF392NN37ISGxIurUljZSh1YWnphO34V5a0+T7FVQG2u9bhAXRTJpgmQ3483woVhkraQZFF7cbRrpbw/F4Q==}
'@remirror/core-constants@3.0.0':
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
'@rollup/plugin-babel@5.3.1': '@rollup/plugin-babel@5.3.1':
resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==} resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
@@ -1741,6 +1756,132 @@ packages:
vitest: vitest:
optional: true optional: true
'@tiptap/core@3.20.4':
resolution: {integrity: sha512-3i/DG89TFY/b34T5P+j35UcjYuB5d3+9K8u6qID+iUqNPiza015HPIZLuPfE5elNwVdV3EXIoPo0LLeBLgXXAg==}
peerDependencies:
'@tiptap/pm': ^3.20.4
'@tiptap/extension-blockquote@3.20.4':
resolution: {integrity: sha512-9sskyyhYj2oKat//lyZVXCp9YrPt4oJAZnGHYWXS0xlskjsLElrfKKlM4vpbhGss3VrhQRoEGqWLnIaJYPF1zw==}
peerDependencies:
'@tiptap/core': ^3.20.4
'@tiptap/extension-bold@3.20.4':
resolution: {integrity: sha512-Md7/mNAeJCY+VLJc8JRGI+8XkVPKiOGB1NgqQPdh3aYtxXQDChQOZoJEQl6TuudDxZ85bLZB67NjZlx3jo8/0g==}
peerDependencies:
'@tiptap/core': ^3.20.4
'@tiptap/extension-bullet-list@3.20.4':
resolution: {integrity: sha512-1RTGrur1EKoxfnLZ3M6xeNj8GITAz74jH2DHGcjLsd2Xr7Q7BozGaIq6GkkvKguMwbI1zCOxTHFCpUETXAIQQA==}
peerDependencies:
'@tiptap/extension-list': ^3.20.4
'@tiptap/extension-code-block@3.20.4':
resolution: {integrity: sha512-Zlw3FrXTy01+o1yISeX/LC+iJeHA+ym602bMXGmtA6lyl7QSOSO7WExweJ6xeJGhbCjldwT5al6fkRAs8iGJZg==}
peerDependencies:
'@tiptap/core': ^3.20.4
'@tiptap/pm': ^3.20.4
'@tiptap/extension-code@3.20.4':
resolution: {integrity: sha512-7j8Hi964bH1SZ9oLdZC1fkqWz27mliSDV7M8lmL/M14+Qw42D/VOAKS4Aw9OCFtHMlTsjLR6qsoVxL8Lpkt6NA==}
peerDependencies:
'@tiptap/core': ^3.20.4
'@tiptap/extension-document@3.20.4':
resolution: {integrity: sha512-zF1CIFVLt8MfSpWWnPwtGyxPOsT0xYM2qJKcXf2yZcTG37wDKmUi6heG53vGigIavbQlLaAFvs+1mNdOu2x/0A==}
peerDependencies:
'@tiptap/core': ^3.20.4
'@tiptap/extension-dropcursor@3.20.4':
resolution: {integrity: sha512-TgMwvZ8myXYdmd6bUV7qkpZXv7ZUiSmX/8eo+iPEzYo2CnDLAGvDKgC50nfq/g87SDvfBgPuAiBfFvsMQQWaTw==}
peerDependencies:
'@tiptap/extensions': ^3.20.4
'@tiptap/extension-gapcursor@3.20.4':
resolution: {integrity: sha512-JJ6f1iQ1e0s4kISgq55U3UYGwWV/N9f0PYMtB6e3L+SBQjXnywaLK0g6vfN6IvTCC2vdIuqeSOX8VlSO97sJLw==}
peerDependencies:
'@tiptap/extensions': ^3.20.4
'@tiptap/extension-hard-break@3.20.4':
resolution: {integrity: sha512-gJbq58d8zB1gzyqVEopowej5CpW4/Fpg6oGJvlZxaCukqd0gJRWGC89K+jE62YA1Td4sfcKrekKvN7jm2y/ZUg==}
peerDependencies:
'@tiptap/core': ^3.20.4
'@tiptap/extension-heading@3.20.4':
resolution: {integrity: sha512-xsnkmTGggJc5P2iCwS1lv8KFG31xC/GNPJKoi/3UH67j/lKDhA3AdtshsLeyv2FKtTtYDb8oV0IqzHB1MM6a7w==}
peerDependencies:
'@tiptap/core': ^3.20.4
'@tiptap/extension-horizontal-rule@3.20.4':
resolution: {integrity: sha512-y6joCi49haAA0bo3EGUY+dWUMHH1GPUc84hxrBY/0pMs+Bn+kQ1+DQJErZDTWGJrlHPWU/yekBZT72SNdp0DNA==}
peerDependencies:
'@tiptap/core': ^3.20.4
'@tiptap/pm': ^3.20.4
'@tiptap/extension-italic@3.20.4':
resolution: {integrity: sha512-4ZqiWr7cmqPFux8tj1ZLiYytyWf343IvQemNX6AvVWvscrJcrfj3YX4Le2BA0RW3A3M6RpLQXXozuF8vxYFDeQ==}
peerDependencies:
'@tiptap/core': ^3.20.4
'@tiptap/extension-link@3.20.4':
resolution: {integrity: sha512-JNDSkWrVdb8NSvbQXwHWvK5tCMbTWwOHFOweknQZ1JPK4dei9FJVofYQaHyW4bJBdcCjds3NZSnXE8DM9iAWmg==}
peerDependencies:
'@tiptap/core': ^3.20.4
'@tiptap/pm': ^3.20.4
'@tiptap/extension-list-item@3.20.4':
resolution: {integrity: sha512-QoTc5RACXaZF+vIIBBxjGO7D0oWFUDgBKJCpvUZ0CoGGKosnfe4a9I5THFyLj4201cf0oUqgf1oZhTqETGxlVw==}
peerDependencies:
'@tiptap/extension-list': ^3.20.4
'@tiptap/extension-list-keymap@3.20.4':
resolution: {integrity: sha512-RIqXM649+8IP7p/KVfaGlJiwjCylm1m6OPlaoM3K8O7oEOGRQzNeexexECCD2jsXRxew4E+vBNMD2orXqJmu8A==}
peerDependencies:
'@tiptap/extension-list': ^3.20.4
'@tiptap/extension-list@3.20.4':
resolution: {integrity: sha512-X+5plTKhOioNcQ4KsAFJJSb/3+zR8Xhdpow4HzXtoV1KcbdDey1fhZdpsfkbrzCL0s6/wAgwZuAchCK7HujurQ==}
peerDependencies:
'@tiptap/core': ^3.20.4
'@tiptap/pm': ^3.20.4
'@tiptap/extension-ordered-list@3.20.4':
resolution: {integrity: sha512-3budNL8BgBon3TcXZ4hjT0YpFvx1Ka3uSIECKDxHgES+OQcR+6cagxSb60gFEccf3Dr0PIwcVTY6g14lC1qKRQ==}
peerDependencies:
'@tiptap/extension-list': ^3.20.4
'@tiptap/extension-paragraph@3.20.4':
resolution: {integrity: sha512-lm6fOScWuZAF/Sfp97igUwFd3L1QHIVLAWP5NVdh0DTLrEIt4rMBmsww+yOpMQRhvz2uTgMbMXynrimhzi/QVw==}
peerDependencies:
'@tiptap/core': ^3.20.4
'@tiptap/extension-strike@3.20.4':
resolution: {integrity: sha512-It1Px9uDGTsVqyyg6cy7DigLoenljpQwqdI0jssM7QclZrHnsrye9fZxBBiiuCzzV1305MxKgHvratkHwqmVNA==}
peerDependencies:
'@tiptap/core': ^3.20.4
'@tiptap/extension-text@3.20.4':
resolution: {integrity: sha512-jchJcBZixDEO2J66Zx5dchsI2mA6IYsROqF8P1poxL4ienH7RVQRCTsBNnSfIeOtREKKWeOU/tEs5fcpvvGwIQ==}
peerDependencies:
'@tiptap/core': ^3.20.4
'@tiptap/extension-underline@3.20.4':
resolution: {integrity: sha512-0OjMc3FDujX16G+jhvqcY/mLot8SrNtDu8ggUwNLAfiI/QIvMVgk7giFD71DATC/4Nb8i/iwAEegTD8MxBIXCg==}
peerDependencies:
'@tiptap/core': ^3.20.4
'@tiptap/extensions@3.20.4':
resolution: {integrity: sha512-8p6hVT65DjuQjtEdlH6ewX9SOJHlVQAOee3sWIJQmeJNRnZNvqPIBLleebUqDiljNTpxBv6s6QWkSTKgf3btwg==}
peerDependencies:
'@tiptap/core': ^3.20.4
'@tiptap/pm': ^3.20.4
'@tiptap/pm@3.20.4':
resolution: {integrity: sha512-rCHYSBToilBEuI6PtjziHDdRkABH/XqwJ7dG4Amn/SD3yGiZKYCiEApQlTUS2zZeo8DsLeuqqqB4vEOeD4OEPg==}
'@tiptap/starter-kit@3.20.4':
resolution: {integrity: sha512-WcyK6hsTl8eBsQhQ+d9Sq8fYZKOYdL+D45MyH3hz583elXqJlW3h3JPFYb0o87gddGxn8Mm57OA/gA1zEdeDMw==}
'@types/aria-query@5.0.4': '@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
@@ -1759,6 +1900,15 @@ packages:
'@types/json-schema@7.0.15': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
'@types/markdown-it@14.1.2':
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
'@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
'@types/mysql@2.15.26': '@types/mysql@2.15.26':
resolution: {integrity: sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ==} resolution: {integrity: sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ==}
@@ -2137,6 +2287,9 @@ packages:
core-js-compat@3.48.0: core-js-compat@3.48.0:
resolution: {integrity: sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==} resolution: {integrity: sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==}
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -2246,6 +2399,10 @@ packages:
emoji-regex@9.2.2: emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
entities@6.0.1: entities@6.0.1:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'} engines: {node: '>=0.12'}
@@ -2864,6 +3021,12 @@ packages:
lines-and-columns@1.2.4: lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
linkify-it@5.0.0:
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
linkifyjs@4.3.2:
resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==}
locate-character@3.0.0: locate-character@3.0.0:
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
@@ -2924,6 +3087,10 @@ packages:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'} engines: {node: '>=10'}
markdown-it@14.1.1:
resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==}
hasBin: true
math-intrinsics@1.1.0: math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2931,6 +3098,9 @@ packages:
mdn-data@2.12.2: mdn-data@2.12.2:
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
merge2@1.4.1: merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -3038,6 +3208,9 @@ packages:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
orderedmap@2.1.1:
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
own-keys@1.0.1: own-keys@1.0.1:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3317,9 +3490,71 @@ packages:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
prosemirror-changeset@2.4.0:
resolution: {integrity: sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==}
prosemirror-collab@1.3.1:
resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==}
prosemirror-commands@1.7.1:
resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==}
prosemirror-dropcursor@1.8.2:
resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==}
prosemirror-gapcursor@1.4.1:
resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==}
prosemirror-history@1.5.0:
resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==}
prosemirror-inputrules@1.5.1:
resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==}
prosemirror-keymap@1.2.3:
resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==}
prosemirror-markdown@1.13.4:
resolution: {integrity: sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==}
prosemirror-menu@1.3.0:
resolution: {integrity: sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==}
prosemirror-model@1.25.4:
resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==}
prosemirror-schema-basic@1.2.4:
resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==}
prosemirror-schema-list@1.5.1:
resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==}
prosemirror-state@1.4.4:
resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==}
prosemirror-tables@1.8.5:
resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==}
prosemirror-trailing-node@3.0.0:
resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==}
peerDependencies:
prosemirror-model: ^1.22.1
prosemirror-state: ^1.4.2
prosemirror-view: ^1.33.8
prosemirror-transform@1.11.0:
resolution: {integrity: sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==}
prosemirror-view@1.41.7:
resolution: {integrity: sha512-jUwKNCEIGiqdvhlS91/2QAg21e4dfU5bH2iwmSDQeosXJgKF7smG0YSplOWK0cjSNgIqXe7VXqo7EIfUFJdt3w==}
proxy-from-env@1.1.0: proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
punycode.js@2.3.1:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
punycode@2.3.1: punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -3405,6 +3640,9 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'} engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true hasBin: true
rope-sequence@1.3.4:
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
run-parallel@1.2.0: run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -3748,6 +3986,9 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
unbox-primitive@1.1.0: unbox-primitive@1.1.0:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3919,6 +4160,9 @@ packages:
jsdom: jsdom:
optional: true optional: true
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
w3c-xmlserializer@5.0.0: w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -5368,6 +5612,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@remirror/core-constants@3.0.0': {}
'@rollup/plugin-babel@5.3.1(@babel/core@7.28.6)(rollup@2.79.2)': '@rollup/plugin-babel@5.3.1(@babel/core@7.28.6)(rollup@2.79.2)':
dependencies: dependencies:
'@babel/core': 7.28.6 '@babel/core': 7.28.6
@@ -5815,6 +6061,152 @@ snapshots:
vite: 6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0) vite: 6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)
vitest: 2.1.9(@types/node@22.19.7)(jsdom@27.4.0)(terser@5.46.0) vitest: 2.1.9(@types/node@22.19.7)(jsdom@27.4.0)(terser@5.46.0)
'@tiptap/core@3.20.4(@tiptap/pm@3.20.4)':
dependencies:
'@tiptap/pm': 3.20.4
'@tiptap/extension-blockquote@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-bold@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-bullet-list@3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))':
dependencies:
'@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-code-block@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/pm': 3.20.4
'@tiptap/extension-code@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-document@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-dropcursor@3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))':
dependencies:
'@tiptap/extensions': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-gapcursor@3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))':
dependencies:
'@tiptap/extensions': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-hard-break@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-heading@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-horizontal-rule@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/pm': 3.20.4
'@tiptap/extension-italic@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-link@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/pm': 3.20.4
linkifyjs: 4.3.2
'@tiptap/extension-list-item@3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))':
dependencies:
'@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-list-keymap@3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))':
dependencies:
'@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/pm': 3.20.4
'@tiptap/extension-ordered-list@3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))':
dependencies:
'@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-paragraph@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-strike@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-text@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-underline@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/pm': 3.20.4
'@tiptap/pm@3.20.4':
dependencies:
prosemirror-changeset: 2.4.0
prosemirror-collab: 1.3.1
prosemirror-commands: 1.7.1
prosemirror-dropcursor: 1.8.2
prosemirror-gapcursor: 1.4.1
prosemirror-history: 1.5.0
prosemirror-inputrules: 1.5.1
prosemirror-keymap: 1.2.3
prosemirror-markdown: 1.13.4
prosemirror-menu: 1.3.0
prosemirror-model: 1.25.4
prosemirror-schema-basic: 1.2.4
prosemirror-schema-list: 1.5.1
prosemirror-state: 1.4.4
prosemirror-tables: 1.8.5
prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7)
prosemirror-transform: 1.11.0
prosemirror-view: 1.41.7
'@tiptap/starter-kit@3.20.4':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-blockquote': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-bold': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-bullet-list': 3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
'@tiptap/extension-code': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-code-block': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-document': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-dropcursor': 3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
'@tiptap/extension-gapcursor': 3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
'@tiptap/extension-hard-break': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-heading': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-horizontal-rule': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-italic': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-link': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-list-item': 3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
'@tiptap/extension-list-keymap': 3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
'@tiptap/extension-ordered-list': 3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
'@tiptap/extension-paragraph': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-strike': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-text': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-underline': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extensions': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/pm': 3.20.4
'@types/aria-query@5.0.4': {} '@types/aria-query@5.0.4': {}
'@types/connect@3.4.36': '@types/connect@3.4.36':
@@ -5829,6 +6221,15 @@ snapshots:
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/linkify-it@5.0.0': {}
'@types/markdown-it@14.1.2':
dependencies:
'@types/linkify-it': 5.0.0
'@types/mdurl': 2.0.0
'@types/mdurl@2.0.0': {}
'@types/mysql@2.15.26': '@types/mysql@2.15.26':
dependencies: dependencies:
'@types/node': 22.19.7 '@types/node': 22.19.7
@@ -6257,6 +6658,8 @@ snapshots:
dependencies: dependencies:
browserslist: 4.28.1 browserslist: 4.28.1
crelt@1.0.6: {}
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -6356,6 +6759,8 @@ snapshots:
emoji-regex@9.2.2: {} emoji-regex@9.2.2: {}
entities@4.5.0: {}
entities@6.0.1: {} entities@6.0.1: {}
es-abstract@1.24.1: es-abstract@1.24.1:
@@ -7107,6 +7512,12 @@ snapshots:
lines-and-columns@1.2.4: {} lines-and-columns@1.2.4: {}
linkify-it@5.0.0:
dependencies:
uc.micro: 2.1.0
linkifyjs@4.3.2: {}
locate-character@3.0.0: {} locate-character@3.0.0: {}
locate-path@6.0.0: locate-path@6.0.0:
@@ -7165,10 +7576,21 @@ snapshots:
dependencies: dependencies:
semver: 7.7.3 semver: 7.7.3
markdown-it@14.1.1:
dependencies:
argparse: 2.0.1
entities: 4.5.0
linkify-it: 5.0.0
mdurl: 2.0.0
punycode.js: 2.3.1
uc.micro: 2.1.0
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
mdn-data@2.12.2: {} mdn-data@2.12.2: {}
mdurl@2.0.0: {}
merge2@1.4.1: {} merge2@1.4.1: {}
micromatch@4.0.8: micromatch@4.0.8:
@@ -7256,6 +7678,8 @@ snapshots:
type-check: 0.4.0 type-check: 0.4.0
word-wrap: 1.2.5 word-wrap: 1.2.5
orderedmap@2.1.1: {}
own-keys@1.0.1: own-keys@1.0.1:
dependencies: dependencies:
get-intrinsic: 1.3.0 get-intrinsic: 1.3.0
@@ -7431,8 +7855,113 @@ snapshots:
progress@2.0.3: {} progress@2.0.3: {}
prosemirror-changeset@2.4.0:
dependencies:
prosemirror-transform: 1.11.0
prosemirror-collab@1.3.1:
dependencies:
prosemirror-state: 1.4.4
prosemirror-commands@1.7.1:
dependencies:
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-transform: 1.11.0
prosemirror-dropcursor@1.8.2:
dependencies:
prosemirror-state: 1.4.4
prosemirror-transform: 1.11.0
prosemirror-view: 1.41.7
prosemirror-gapcursor@1.4.1:
dependencies:
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-view: 1.41.7
prosemirror-history@1.5.0:
dependencies:
prosemirror-state: 1.4.4
prosemirror-transform: 1.11.0
prosemirror-view: 1.41.7
rope-sequence: 1.3.4
prosemirror-inputrules@1.5.1:
dependencies:
prosemirror-state: 1.4.4
prosemirror-transform: 1.11.0
prosemirror-keymap@1.2.3:
dependencies:
prosemirror-state: 1.4.4
w3c-keyname: 2.2.8
prosemirror-markdown@1.13.4:
dependencies:
'@types/markdown-it': 14.1.2
markdown-it: 14.1.1
prosemirror-model: 1.25.4
prosemirror-menu@1.3.0:
dependencies:
crelt: 1.0.6
prosemirror-commands: 1.7.1
prosemirror-history: 1.5.0
prosemirror-state: 1.4.4
prosemirror-model@1.25.4:
dependencies:
orderedmap: 2.1.1
prosemirror-schema-basic@1.2.4:
dependencies:
prosemirror-model: 1.25.4
prosemirror-schema-list@1.5.1:
dependencies:
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-transform: 1.11.0
prosemirror-state@1.4.4:
dependencies:
prosemirror-model: 1.25.4
prosemirror-transform: 1.11.0
prosemirror-view: 1.41.7
prosemirror-tables@1.8.5:
dependencies:
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-transform: 1.11.0
prosemirror-view: 1.41.7
prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7):
dependencies:
'@remirror/core-constants': 3.0.0
escape-string-regexp: 4.0.0
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-view: 1.41.7
prosemirror-transform@1.11.0:
dependencies:
prosemirror-model: 1.25.4
prosemirror-view@1.41.7:
dependencies:
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-transform: 1.11.0
proxy-from-env@1.1.0: {} proxy-from-env@1.1.0: {}
punycode.js@2.3.1: {}
punycode@2.3.1: {} punycode@2.3.1: {}
queue-microtask@1.2.3: {} queue-microtask@1.2.3: {}
@@ -7557,6 +8086,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.57.0 '@rollup/rollup-win32-x64-msvc': 4.57.0
fsevents: 2.3.3 fsevents: 2.3.3
rope-sequence@1.3.4: {}
run-parallel@1.2.0: run-parallel@1.2.0:
dependencies: dependencies:
queue-microtask: 1.2.3 queue-microtask: 1.2.3
@@ -7994,6 +8525,8 @@ snapshots:
typescript@5.9.3: {} typescript@5.9.3: {}
uc.micro@2.1.0: {}
unbox-primitive@1.1.0: unbox-primitive@1.1.0:
dependencies: dependencies:
call-bound: 1.0.4 call-bound: 1.0.4
@@ -8134,6 +8667,8 @@ snapshots:
- supports-color - supports-color
- terser - terser
w3c-keyname@2.2.8: {}
w3c-xmlserializer@5.0.0: w3c-xmlserializer@5.0.0:
dependencies: dependencies:
xml-name-validator: 5.0.0 xml-name-validator: 5.0.0

View File

@@ -0,0 +1,286 @@
<script lang="ts">
const DEFAULT_ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png'];
const DEFAULT_ACCEPT_ATTR = '.pdf,.jpg,.jpeg,.png';
const DEFAULT_HINT = 'PDF, JPEG ou PNG — 10 Mo max par fichier';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 Mo
interface UploadedFile {
id: string;
filename: string;
fileSize: number;
mimeType: string;
}
let {
existingFiles = [],
onUpload,
onDelete,
disabled = false,
acceptedTypes = DEFAULT_ACCEPTED_TYPES,
acceptAttr = DEFAULT_ACCEPT_ATTR,
hint = DEFAULT_HINT,
showDelete = true
}: {
existingFiles?: UploadedFile[];
onUpload: (file: File) => Promise<UploadedFile>;
onDelete: (fileId: string) => Promise<void>;
disabled?: boolean;
acceptedTypes?: string[];
acceptAttr?: string;
hint?: string;
showDelete?: boolean;
} = $props();
let files = $state<UploadedFile[]>(existingFiles);
let pendingFiles = $state<{ name: string; size: number }[]>([]);
let error = $state<string | null>(null);
let fileInput: HTMLInputElement;
$effect(() => {
files = existingFiles;
});
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} o`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
}
function getFileIcon(mimeType: string): string {
if (mimeType === 'application/pdf') return '📄';
if (mimeType.startsWith('image/')) return '🖼️';
return '📎';
}
function validateFile(file: File): string | null {
if (!acceptedTypes.includes(file.type)) {
return `Type de fichier non accepté : ${file.type}.`;
}
if (file.size > MAX_FILE_SIZE) {
return `Le fichier dépasse la taille maximale de 10 Mo (${formatFileSize(file.size)}).`;
}
return null;
}
async function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
const selectedFiles = input.files;
if (!selectedFiles || selectedFiles.length === 0) return;
error = null;
for (const file of selectedFiles) {
const validationError = validateFile(file);
if (validationError) {
error = validationError;
continue;
}
pendingFiles = [...pendingFiles, { name: file.name, size: file.size }];
try {
const uploaded = await onUpload(file);
files = [...files, uploaded];
} catch {
error = `Erreur lors de l'envoi de "${file.name}".`;
} finally {
pendingFiles = pendingFiles.filter((p) => p.name !== file.name);
}
}
input.value = '';
}
async function handleDelete(fileId: string) {
error = null;
try {
await onDelete(fileId);
files = files.filter((f) => f.id !== fileId);
} catch {
error = 'Erreur lors de la suppression du fichier.';
}
}
</script>
<div class="file-upload" class:disabled>
{#if error}
<p class="upload-error" role="alert">{error}</p>
{/if}
{#if files.length > 0 || pendingFiles.length > 0}
<ul class="file-list">
{#each files as file}
<li class="file-item">
<span class="file-icon">{getFileIcon(file.mimeType)}</span>
<span class="file-name">{file.filename}</span>
<span class="file-size">{formatFileSize(file.fileSize)}</span>
{#if !disabled && showDelete}
<button
type="button"
class="file-remove"
onclick={() => handleDelete(file.id)}
title="Supprimer {file.filename}"
aria-label="Supprimer {file.filename}"
>
</button>
{/if}
</li>
{/each}
{#each pendingFiles as pending}
<li class="file-item file-pending">
<span class="file-icon"></span>
<span class="file-name">{pending.name}</span>
<span class="file-size">{formatFileSize(pending.size)}</span>
<span class="file-uploading">Envoi...</span>
</li>
{/each}
</ul>
{/if}
{#if !disabled}
<button type="button" class="upload-btn" onclick={() => fileInput.click()}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48" />
</svg>
Ajouter un fichier
</button>
<input
bind:this={fileInput}
type="file"
accept={acceptAttr}
onchange={handleFileSelect}
class="file-input-hidden"
aria-hidden="true"
tabindex="-1"
/>
<p class="upload-hint">{hint}</p>
{/if}
</div>
<style>
.file-upload {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.file-upload.disabled {
opacity: 0.6;
}
.upload-error {
margin: 0;
padding: 0.5rem 0.75rem;
background: #fee2e2;
border-radius: 0.375rem;
color: #991b1b;
font-size: 0.8125rem;
}
.file-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.file-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
font-size: 0.875rem;
}
.file-pending {
opacity: 0.6;
}
.file-icon {
flex-shrink: 0;
}
.file-name {
flex: 1;
color: #374151;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
color: #9ca3af;
font-size: 0.75rem;
flex-shrink: 0;
}
.file-uploading {
color: #3b82f6;
font-size: 0.75rem;
flex-shrink: 0;
}
.file-remove {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border: none;
border-radius: 50%;
background: #e5e7eb;
color: #6b7280;
font-size: 0.625rem;
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s;
}
.file-remove:hover {
background: #fecaca;
color: #dc2626;
}
.upload-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
border: 1px dashed #d1d5db;
border-radius: 0.375rem;
background: white;
color: #374151;
font-size: 0.875rem;
cursor: pointer;
transition: border-color 0.15s, background-color 0.15s;
align-self: flex-start;
}
.upload-btn:hover {
border-color: #3b82f6;
background: #eff6ff;
color: #2563eb;
}
.file-input-hidden {
position: absolute;
width: 0;
height: 0;
overflow: hidden;
opacity: 0;
}
.upload-hint {
margin: 0;
font-size: 0.75rem;
color: #9ca3af;
}
</style>

View File

@@ -0,0 +1,300 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Editor as EditorType } from '@tiptap/core';
let {
content = '',
onUpdate,
placeholder = 'Saisissez votre texte...',
disabled = false
}: {
content?: string;
onUpdate: (html: string) => void;
placeholder?: string;
disabled?: boolean;
} = $props();
let editorElement: HTMLDivElement;
let editor: EditorType | null = $state(null);
onMount(() => {
initEditor();
return () => {
editor?.destroy();
};
});
async function initEditor() {
const [{ Editor }, { default: StarterKit }, { default: Link }] = await Promise.all([
import('@tiptap/core'),
import('@tiptap/starter-kit'),
import('@tiptap/extension-link')
]);
editor = new Editor({
element: editorElement,
extensions: [
StarterKit.configure({
heading: false,
codeBlock: false,
blockquote: false,
horizontalRule: false,
code: false
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
rel: 'noopener noreferrer nofollow',
target: '_blank'
}
})
],
content,
editable: !disabled,
onUpdate: ({ editor: e }) => {
const html = e.isEmpty ? '' : e.getHTML();
onUpdate(html);
},
editorProps: {
attributes: {
class: 'rich-text-content',
'aria-label': placeholder,
role: 'textbox',
'aria-multiline': 'true'
}
}
});
}
$effect(() => {
if (editor && disabled !== undefined) {
editor.setEditable(!disabled);
}
});
function toggleBold() {
editor?.chain().focus().toggleBold().run();
}
function toggleItalic() {
editor?.chain().focus().toggleItalic().run();
}
function toggleBulletList() {
editor?.chain().focus().toggleBulletList().run();
}
function toggleOrderedList() {
editor?.chain().focus().toggleOrderedList().run();
}
function toggleLink() {
if (!editor) return;
if (editor.isActive('link')) {
editor.chain().focus().unsetLink().run();
return;
}
const url = window.prompt('URL du lien :');
if (url) {
editor.chain().focus().setLink({ href: url }).run();
}
}
</script>
<div class="rich-text-editor" class:disabled>
{#if editor && !disabled}
<div class="toolbar" role="toolbar" aria-label="Formatage du texte">
<button
type="button"
class="toolbar-btn"
class:active={editor.isActive('bold')}
onclick={toggleBold}
title="Gras"
aria-label="Gras"
aria-pressed={editor.isActive('bold')}
>
<strong>G</strong>
</button>
<button
type="button"
class="toolbar-btn"
class:active={editor.isActive('italic')}
onclick={toggleItalic}
title="Italique"
aria-label="Italique"
aria-pressed={editor.isActive('italic')}
>
<em>I</em>
</button>
<span class="toolbar-separator" aria-hidden="true"></span>
<button
type="button"
class="toolbar-btn"
class:active={editor.isActive('bulletList')}
onclick={toggleBulletList}
title="Liste à puces"
aria-label="Liste à puces"
aria-pressed={editor.isActive('bulletList')}
>
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16" aria-hidden="true">
<path
fill-rule="evenodd"
d="M3 4a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 000 2h10a1 1 0 100-2H7zm0 5a1 1 0 000 2h10a1 1 0 100-2H7zm0 5a1 1 0 000 2h10a1 1 0 100-2H7zM3 9a1 1 0 100 2 1 1 0 000-2zm0 5a1 1 0 100 2 1 1 0 000-2z"
clip-rule="evenodd"
/>
</svg>
</button>
<button
type="button"
class="toolbar-btn"
class:active={editor.isActive('orderedList')}
onclick={toggleOrderedList}
title="Liste numérotée"
aria-label="Liste numérotée"
aria-pressed={editor.isActive('orderedList')}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" aria-hidden="true">
<line x1="10" y1="6" x2="21" y2="6" />
<line x1="10" y1="12" x2="21" y2="12" />
<line x1="10" y1="18" x2="21" y2="18" />
<text x="4" y="7.5" font-size="7" font-weight="700" fill="currentColor" stroke="none" text-anchor="middle">1</text>
<text x="4" y="13.5" font-size="7" font-weight="700" fill="currentColor" stroke="none" text-anchor="middle">2</text>
<text x="4" y="19.5" font-size="7" font-weight="700" fill="currentColor" stroke="none" text-anchor="middle">3</text>
</svg>
</button>
<span class="toolbar-separator" aria-hidden="true"></span>
<button
type="button"
class="toolbar-btn"
class:active={editor.isActive('link')}
onclick={toggleLink}
title="Lien"
aria-label="Lien"
aria-pressed={editor.isActive('link')}
>
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16" aria-hidden="true">
<path
d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z"
/>
</svg>
</button>
</div>
{/if}
<div bind:this={editorElement} class="editor-container"></div>
</div>
<style>
.rich-text-editor {
border: 1px solid #d1d5db;
border-radius: 0.375rem;
overflow: hidden;
transition: border-color 0.15s, box-shadow 0.15s;
}
.rich-text-editor:focus-within {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.rich-text-editor.disabled {
opacity: 0.6;
background: #f9fafb;
}
.toolbar {
display: flex;
align-items: center;
gap: 0.125rem;
padding: 0.375rem;
border-bottom: 1px solid #e5e7eb;
background: #f9fafb;
}
.toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: none;
border-radius: 0.25rem;
background: transparent;
color: #374151;
font-size: 0.875rem;
cursor: pointer;
transition: background-color 0.15s, color 0.15s;
}
.toolbar-btn:hover {
background: #e5e7eb;
}
.toolbar-btn.active {
background: #dbeafe;
color: #2563eb;
}
.toolbar-separator {
width: 1px;
height: 1.25rem;
background: #d1d5db;
margin: 0 0.25rem;
}
.editor-container {
min-height: 8rem;
}
.editor-container :global(.rich-text-content) {
padding: 0.75rem;
min-height: 8rem;
outline: none;
font-size: 0.875rem;
line-height: 1.5;
color: #374151;
}
.editor-container :global(.rich-text-content p) {
margin: 0 0 0.5rem;
}
.editor-container :global(.rich-text-content p:last-child) {
margin-bottom: 0;
}
.editor-container :global(.rich-text-content ul) {
list-style-type: disc;
padding-left: 1.5rem;
margin: 0 0 0.5rem;
}
.editor-container :global(.rich-text-content ol) {
list-style-type: decimal;
padding-left: 1.5rem;
margin: 0 0 0.5rem;
}
.editor-container :global(.rich-text-content li) {
margin-bottom: 0.25rem;
}
.editor-container :global(.rich-text-content a) {
color: #2563eb;
text-decoration: underline;
}
.editor-container :global(.rich-text-content strong) {
font-weight: 600;
}
.editor-container :global(.rich-text-content .is-empty::before) {
content: attr(data-placeholder);
color: #9ca3af;
pointer-events: none;
float: left;
height: 0;
}
</style>

View File

@@ -0,0 +1,569 @@
<script lang="ts">
import type {
HomeworkSubmission,
HomeworkAttachment,
StudentHomeworkDetail
} from '$lib/features/homework/api/studentHomework';
import {
fetchSubmission,
saveDraftSubmission,
submitHomework,
uploadSubmissionAttachment
} from '$lib/features/homework/api/studentHomework';
import RichTextEditor from '$lib/components/molecules/RichTextEditor/RichTextEditor.svelte';
import FileUpload from '$lib/components/molecules/FileUpload/FileUpload.svelte';
let {
detail,
onBack,
onSubmitted
}: {
detail: StudentHomeworkDetail;
onBack: () => void;
onSubmitted?: () => void;
} = $props();
let submission = $state<HomeworkSubmission | null>(null);
let responseHtml = $state<string>('');
let loading = $state(true);
let saving = $state(false);
let submitting = $state(false);
let error = $state<string | null>(null);
let successMessage = $state<string | null>(null);
let showConfirmDialog = $state(false);
let attachments = $state<HomeworkAttachment[]>([]);
let isSubmitted = $derived(
submission?.status === 'submitted' || submission?.status === 'late'
);
let statusLabel = $derived(() => {
if (!submission) return null;
switch (submission.status) {
case 'draft':
return { text: 'Brouillon', className: 'status-draft' };
case 'submitted':
return { text: 'Soumis', className: 'status-submitted' };
case 'late':
return { text: 'En retard', className: 'status-late' };
}
});
async function loadSubmission() {
loading = true;
error = null;
try {
submission = await fetchSubmission(detail.id);
if (submission) {
responseHtml = submission.responseHtml ?? '';
attachments = submission.attachments ?? [];
}
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur de chargement';
} finally {
loading = false;
}
}
async function handleSaveDraft() {
saving = true;
error = null;
successMessage = null;
try {
submission = await saveDraftSubmission(detail.id, responseHtml || null);
successMessage = 'Brouillon sauvegardé';
window.setTimeout(() => {
successMessage = null;
}, 3000);
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors de la sauvegarde';
} finally {
saving = false;
}
}
function handleSubmitClick() {
showConfirmDialog = true;
}
async function handleConfirmSubmit() {
showConfirmDialog = false;
submitting = true;
error = null;
try {
// Save draft first if needed
if (!submission) {
submission = await saveDraftSubmission(detail.id, responseHtml || null);
} else if (responseHtml !== (submission.responseHtml ?? '')) {
submission = await saveDraftSubmission(detail.id, responseHtml || null);
}
submission = await submitHomework(detail.id);
successMessage = 'Devoir rendu avec succès !';
onSubmitted?.();
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors de la soumission';
} finally {
submitting = false;
}
}
async function handleUploadAttachment(file: File): Promise<HomeworkAttachment> {
// Ensure a draft exists first
if (!submission) {
submission = await saveDraftSubmission(detail.id, responseHtml || null);
}
return uploadSubmissionAttachment(detail.id, file);
}
async function handleDeleteAttachment(_fileId: string): Promise<void> {
// Suppression non supportée — le bouton est masqué via showDelete={false}
}
function formatDueDate(dateStr: string): string {
const date = new Date(dateStr + 'T00:00:00');
return date.toLocaleDateString('fr-FR', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
});
}
$effect(() => {
void loadSubmission();
});
</script>
<div class="submission-form">
<button class="back-button" onclick={onBack}>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
Retour au devoir
</button>
<header class="form-header" style:border-left-color={detail.subjectColor ?? '#3b82f6'}>
<span class="subject-name" style:color={detail.subjectColor ?? '#3b82f6'}>
{detail.subjectName}
</span>
<h2 class="form-title">{detail.title}</h2>
<div class="form-meta">
<span class="due-date">Pour le {formatDueDate(detail.dueDate)}</span>
{#if statusLabel()}
<span class="status-badge {statusLabel()?.className}">{statusLabel()?.text}</span>
{/if}
</div>
</header>
{#if error}
<div class="error-banner" role="alert">{error}</div>
{/if}
{#if successMessage}
<div class="success-banner" role="status">{successMessage}</div>
{/if}
{#if loading}
<div class="loading-state">Chargement...</div>
{:else if isSubmitted}
<section class="submitted-view">
<div class="submitted-icon"></div>
<p class="submitted-message">
{#if submission?.status === 'late'}
Votre devoir a été rendu en retard le {submission?.submittedAt
? new Date(submission.submittedAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
: ''}.
{:else}
Votre devoir a été rendu le {submission?.submittedAt
? new Date(submission.submittedAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
: ''}.
{/if}
</p>
{#if submission?.responseHtml}
<section class="response-view">
<h3>Votre réponse</h3>
<div class="response-content">{@html submission.responseHtml}</div>
</section>
{/if}
{#if attachments.length > 0}
<section class="attachments-view">
<h3>Pièces jointes</h3>
<ul class="attachments-list">
{#each attachments as attachment}
<li class="attachment-item">
<span class="attachment-name">{attachment.filename}</span>
</li>
{/each}
</ul>
</section>
{/if}
</section>
{:else}
<section class="editor-section">
<h3>Votre réponse</h3>
<RichTextEditor
content={responseHtml}
onUpdate={(html) => {
responseHtml = html;
}}
placeholder="Rédigez votre réponse ici..."
/>
</section>
<section class="attachments-section">
<h3>Pièces jointes</h3>
<FileUpload
existingFiles={attachments}
onUpload={handleUploadAttachment}
onDelete={handleDeleteAttachment}
disabled={saving || submitting}
acceptedTypes={['application/pdf', 'image/jpeg', 'image/png', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']}
acceptAttr=".pdf,.jpg,.jpeg,.png,.docx"
hint="PDF, JPEG, PNG ou DOCX — 10 Mo max par fichier"
showDelete={false}
/>
</section>
<div class="form-actions">
<button
class="btn-draft"
onclick={handleSaveDraft}
disabled={saving || submitting}
>
{saving ? 'Sauvegarde...' : 'Sauvegarder le brouillon'}
</button>
<button
class="btn-submit"
onclick={handleSubmitClick}
disabled={saving || submitting}
>
{submitting ? 'Soumission...' : 'Soumettre mon devoir'}
</button>
</div>
{/if}
</div>
{#if showConfirmDialog}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="dialog-overlay" role="presentation" onclick={() => (showConfirmDialog = false)}>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div class="dialog" role="alertdialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
<h3>Confirmer la soumission</h3>
<p>
Êtes-vous sûr de vouloir soumettre votre devoir ? Vous ne pourrez plus le modifier
après soumission.
</p>
<div class="dialog-actions">
<button class="btn-cancel" onclick={() => (showConfirmDialog = false)}>Annuler</button>
<button class="btn-confirm" onclick={handleConfirmSubmit}>Confirmer</button>
</div>
</div>
</div>
{/if}
<style>
.submission-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.back-button {
display: inline-flex;
align-items: center;
gap: 0.375rem;
background: none;
border: none;
color: #3b82f6;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
padding: 0.25rem 0;
align-self: flex-start;
}
.back-button:hover {
color: #2563eb;
}
.form-header {
border-left: 4px solid #3b82f6;
padding-left: 1rem;
}
.subject-name {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.form-title {
margin: 0.25rem 0 0.5rem;
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
.form-meta {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.875rem;
color: #6b7280;
}
.status-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 1rem;
font-size: 0.75rem;
font-weight: 600;
}
.status-draft {
background: #e0e7ff;
color: #3730a3;
}
.status-submitted {
background: #dcfce7;
color: #166534;
}
.status-late {
background: #fee2e2;
color: #991b1b;
}
.error-banner {
padding: 0.5rem 0.75rem;
background: #fee2e2;
border-radius: 0.375rem;
color: #991b1b;
font-size: 0.8125rem;
}
.success-banner {
padding: 0.5rem 0.75rem;
background: #dcfce7;
border-radius: 0.375rem;
color: #166534;
font-size: 0.8125rem;
}
.loading-state {
text-align: center;
padding: 2rem;
color: #6b7280;
}
.editor-section h3,
.attachments-section h3 {
margin: 0 0 0.5rem;
font-size: 0.9375rem;
font-weight: 600;
color: #374151;
}
.form-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.btn-draft {
padding: 0.5rem 1rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
background: white;
color: #374151;
font-size: 0.875rem;
cursor: pointer;
}
.btn-draft:hover:not(:disabled) {
background: #f3f4f6;
}
.btn-submit {
padding: 0.5rem 1rem;
border: none;
border-radius: 0.375rem;
background: #3b82f6;
color: white;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.btn-submit:hover:not(:disabled) {
background: #2563eb;
}
.btn-draft:disabled,
.btn-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Submitted view */
.submitted-view {
text-align: center;
padding: 1.5rem;
}
.submitted-icon {
width: 3rem;
height: 3rem;
margin: 0 auto 0.75rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #dcfce7;
color: #166534;
font-size: 1.5rem;
font-weight: 700;
}
.submitted-message {
color: #374151;
font-size: 0.9375rem;
}
.response-view,
.attachments-view {
text-align: left;
margin-top: 1rem;
}
.response-view h3,
.attachments-view h3 {
margin: 0 0 0.5rem;
font-size: 0.9375rem;
font-weight: 600;
color: #374151;
}
.response-content {
padding: 0.75rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
color: #4b5563;
line-height: 1.6;
}
.attachments-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.attachment-item {
padding: 0.5rem 0.75rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
font-size: 0.875rem;
}
.attachment-name {
color: #374151;
font-weight: 500;
}
/* Confirmation Dialog */
.dialog-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
z-index: 100;
}
.dialog {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
max-width: 24rem;
width: 90%;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
}
.dialog h3 {
margin: 0 0 0.75rem;
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
}
.dialog p {
margin: 0 0 1.25rem;
color: #4b5563;
font-size: 0.875rem;
line-height: 1.5;
}
.dialog-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.btn-cancel {
padding: 0.5rem 1rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
background: white;
color: #374151;
font-size: 0.875rem;
cursor: pointer;
}
.btn-confirm {
padding: 0.5rem 1rem;
border: none;
border-radius: 0.375rem;
background: #3b82f6;
color: white;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.btn-confirm:hover {
background: #2563eb;
}
</style>

View File

@@ -78,6 +78,13 @@
<span class="status-badge" class:done={isDone}> <span class="status-badge" class:done={isDone}>
{isDone ? 'Fait' : 'À faire'} {isDone ? 'Fait' : 'À faire'}
</span> </span>
{#if homework.submissionStatus === 'submitted'}
<span class="submission-badge submission-submitted">Rendu</span>
{:else if homework.submissionStatus === 'late'}
<span class="submission-badge submission-late">Rendu en retard</span>
{:else if homework.submissionStatus === 'draft'}
<span class="submission-badge submission-draft">Brouillon</span>
{/if}
{#if homework.hasAttachments} {#if homework.hasAttachments}
<span class="attachment-indicator" title="Pièce(s) jointe(s)"> <span class="attachment-indicator" title="Pièce(s) jointe(s)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -178,4 +185,26 @@
display: flex; display: flex;
align-items: center; align-items: center;
} }
.submission-badge {
font-size: 0.75rem;
font-weight: 600;
border-radius: 999px;
padding: 0.125rem 0.5rem;
}
.submission-submitted {
color: #166534;
background: #dcfce7;
}
.submission-late {
color: #991b1b;
background: #fee2e2;
}
.submission-draft {
color: #3730a3;
background: #e0e7ff;
}
</style> </style>

View File

@@ -6,10 +6,12 @@
let { let {
detail, detail,
onBack, onBack,
onSubmit,
getAttachmentUrl = defaultGetAttachmentUrl getAttachmentUrl = defaultGetAttachmentUrl
}: { }: {
detail: StudentHomeworkDetail; detail: StudentHomeworkDetail;
onBack: () => void; onBack: () => void;
onSubmit?: () => void;
getAttachmentUrl?: (homeworkId: string, attachmentId: string) => string; getAttachmentUrl?: (homeworkId: string, attachmentId: string) => string;
} = $props(); } = $props();
@@ -45,7 +47,7 @@
} }
link.click(); link.click();
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000); window.setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
} }
let downloadError = $state<string | null>(null); let downloadError = $state<string | null>(null);
@@ -93,10 +95,18 @@
{#if detail.description} {#if detail.description}
<section class="detail-description"> <section class="detail-description">
<h3>Description</h3> <h3>Description</h3>
<p>{detail.description}</p> <div class="description-content">{@html detail.description}</div>
</section> </section>
{/if} {/if}
{#if onSubmit}
<div class="submit-section">
<button class="btn-submit-homework" onclick={onSubmit}>
Rendre mon devoir
</button>
</div>
{/if}
{#if detail.attachments.length > 0} {#if detail.attachments.length > 0}
<section class="detail-attachments"> <section class="detail-attachments">
<h3>Pièces jointes</h3> <h3>Pièces jointes</h3>
@@ -183,11 +193,34 @@
color: #374151; color: #374151;
} }
.detail-description p { .detail-description :global(.description-content) {
margin: 0;
color: #4b5563; color: #4b5563;
line-height: 1.6; line-height: 1.6;
white-space: pre-wrap; }
.detail-description :global(.description-content p) {
margin: 0 0 0.5rem;
}
.detail-description :global(.description-content p:last-child) {
margin-bottom: 0;
}
.detail-description :global(.description-content ul) {
list-style-type: disc;
padding-left: 1.5rem;
margin: 0 0 0.5rem;
}
.detail-description :global(.description-content ol) {
list-style-type: decimal;
padding-left: 1.5rem;
margin: 0 0 0.5rem;
}
.detail-description :global(.description-content a) {
color: #2563eb;
text-decoration: underline;
} }
.download-error { .download-error {
@@ -237,4 +270,27 @@
color: #9ca3af; color: #9ca3af;
font-size: 0.75rem; font-size: 0.75rem;
} }
.submit-section {
padding-top: 0.5rem;
}
.btn-submit-homework {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.625rem 1.25rem;
border: none;
border-radius: 0.375rem;
background: #3b82f6;
color: white;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s;
}
.btn-submit-homework:hover {
background: #2563eb;
}
</style> </style>

View File

@@ -5,6 +5,7 @@
import { isOffline } from '$lib/features/schedule/stores/scheduleCache.svelte'; import { isOffline } from '$lib/features/schedule/stores/scheduleCache.svelte';
import HomeworkCard from './HomeworkCard.svelte'; import HomeworkCard from './HomeworkCard.svelte';
import HomeworkDetail from './HomeworkDetail.svelte'; import HomeworkDetail from './HomeworkDetail.svelte';
import HomeworkSubmissionForm from '$lib/components/organisms/HomeworkSubmission/HomeworkSubmissionForm.svelte';
import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte'; import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte';
let homeworks = $state<StudentHomework[]>([]); let homeworks = $state<StudentHomework[]>([]);
@@ -13,6 +14,7 @@
let error = $state<string | null>(null); let error = $state<string | null>(null);
let selectedSubjectId = $state<string | null>(null); let selectedSubjectId = $state<string | null>(null);
let selectedDetail = $state<HomeworkDetailType | null>(null); let selectedDetail = $state<HomeworkDetailType | null>(null);
let showSubmissionForm = $state(false);
let detailLoading = $state(false); let detailLoading = $state(false);
let statuses = $derived(getHomeworkStatuses()); let statuses = $derived(getHomeworkStatuses());
@@ -69,6 +71,24 @@
function handleBack() { function handleBack() {
selectedDetail = null; selectedDetail = null;
showSubmissionForm = false;
}
function handleOpenSubmissionForm() {
showSubmissionForm = true;
}
function handleSubmissionBack() {
showSubmissionForm = false;
}
function handleSubmitted() {
// Laisser le message de succès visible brièvement avant de revenir à la liste
window.setTimeout(() => {
showSubmissionForm = false;
selectedDetail = null;
void loadHomeworks();
}, 1500);
} }
function handleToggleDone(homeworkId: string) { function handleToggleDone(homeworkId: string) {
@@ -81,8 +101,10 @@
}); });
</script> </script>
{#if selectedDetail} {#if selectedDetail && showSubmissionForm}
<HomeworkDetail detail={selectedDetail} onBack={handleBack} /> <HomeworkSubmissionForm detail={selectedDetail} onBack={handleSubmissionBack} onSubmitted={handleSubmitted} />
{:else if selectedDetail}
<HomeworkDetail detail={selectedDetail} onBack={handleBack} onSubmit={handleOpenSubmissionForm} />
{:else} {:else}
<div class="student-homework"> <div class="student-homework">
{#if isOffline()} {#if isOffline()}

View File

@@ -13,6 +13,7 @@ export interface StudentHomework {
dueDate: string; dueDate: string;
createdAt: string; createdAt: string;
hasAttachments: boolean; hasAttachments: boolean;
submissionStatus: 'draft' | 'submitted' | 'late' | null;
} }
export interface HomeworkAttachment { export interface HomeworkAttachment {
@@ -36,6 +37,18 @@ export interface StudentHomeworkDetail {
attachments: HomeworkAttachment[]; attachments: HomeworkAttachment[];
} }
export interface HomeworkSubmission {
id: string;
homeworkId: string;
studentId: string;
responseHtml: string | null;
status: 'draft' | 'submitted' | 'late';
submittedAt: string | null;
createdAt: string;
updatedAt: string;
attachments?: HomeworkAttachment[];
}
/** /**
* Récupère la liste des devoirs pour l'élève connecté. * Récupère la liste des devoirs pour l'élève connecté.
*/ */
@@ -74,3 +87,87 @@ export function getAttachmentUrl(homeworkId: string, attachmentId: string): stri
const apiUrl = getApiBaseUrl(); const apiUrl = getApiBaseUrl();
return `${apiUrl}/me/homework/${homeworkId}/attachments/${attachmentId}`; return `${apiUrl}/me/homework/${homeworkId}/attachments/${attachmentId}`;
} }
/**
* Récupère le rendu de l'élève pour un devoir (ou null si aucun brouillon).
*/
export async function fetchSubmission(homeworkId: string): Promise<HomeworkSubmission | null> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/me/homework/${homeworkId}/submission`);
if (!response.ok) {
throw new Error(`Erreur lors du chargement du rendu (${response.status})`);
}
const json = await response.json();
return json.data ?? null;
}
/**
* Sauvegarde un brouillon de rendu.
*/
export async function saveDraftSubmission(
homeworkId: string,
responseHtml: string | null
): Promise<HomeworkSubmission> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/me/homework/${homeworkId}/submission`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ responseHtml })
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail ?? `Erreur lors de la sauvegarde (${response.status})`);
}
const json = await response.json();
return json.data;
}
/**
* Soumet définitivement le rendu.
*/
export async function submitHomework(homeworkId: string): Promise<HomeworkSubmission> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/me/homework/${homeworkId}/submission/submit`, {
method: 'POST'
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail ?? `Erreur lors de la soumission (${response.status})`);
}
const json = await response.json();
return json.data;
}
/**
* Upload une pièce jointe au rendu de l'élève.
*/
export async function uploadSubmissionAttachment(
homeworkId: string,
file: File
): Promise<HomeworkAttachment> {
const apiUrl = getApiBaseUrl();
const formData = new FormData();
formData.append('file', file);
const response = await authenticatedFetch(
`${apiUrl}/me/homework/${homeworkId}/submission/attachments`,
{
method: 'POST',
body: formData
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail ?? "Erreur lors de l'envoi du fichier.");
}
const json = await response.json();
return json.data;
}

View File

@@ -0,0 +1,97 @@
import { getApiBaseUrl } from '$lib/api';
import { authenticatedFetch } from '$lib/auth';
export interface TeacherSubmission {
id: string | null;
studentId: string;
studentName: string;
status: 'draft' | 'submitted' | 'late' | 'not_submitted';
submittedAt: string | null;
createdAt: string | null;
}
export interface TeacherSubmissionDetail {
id: string;
studentId: string;
studentName: string;
responseHtml: string | null;
status: 'draft' | 'submitted' | 'late';
submittedAt: string | null;
createdAt: string;
attachments: {
id: string;
filename: string;
fileSize: number;
mimeType: string;
}[];
}
export interface SubmissionStats {
totalStudents: number;
submittedCount: number;
missingStudents: { id: string; name: string }[];
}
/**
* Récupère la liste des rendus pour un devoir.
*/
export async function fetchSubmissions(homeworkId: string): Promise<TeacherSubmission[]> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/homework/${homeworkId}/submissions`);
if (!response.ok) {
throw new Error(`Erreur lors du chargement des rendus (${response.status})`);
}
const json = await response.json();
return json.data ?? [];
}
/**
* Récupère le détail d'un rendu.
*/
export async function fetchSubmissionDetail(
homeworkId: string,
submissionId: string
): Promise<TeacherSubmissionDetail> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(
`${apiUrl}/homework/${homeworkId}/submissions/${submissionId}`
);
if (!response.ok) {
throw new Error(`Erreur lors du chargement du rendu (${response.status})`);
}
const json = await response.json();
return json.data;
}
/**
* Récupère les statistiques de rendus pour un devoir.
*/
export async function fetchSubmissionStats(homeworkId: string): Promise<SubmissionStats> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(
`${apiUrl}/homework/${homeworkId}/submissions/stats`
);
if (!response.ok) {
throw new Error(`Erreur lors du chargement des statistiques (${response.status})`);
}
const json = await response.json();
return json.data;
}
/**
* Retourne l'URL de téléchargement d'une pièce jointe de rendu.
*/
export function getSubmissionAttachmentUrl(
homeworkId: string,
submissionId: string,
attachmentId: string
): string {
const apiUrl = getApiBaseUrl();
return `${apiUrl}/homework/${homeworkId}/submissions/${submissionId}/attachments/${attachmentId}`;
}

View File

@@ -6,6 +6,8 @@
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte'; import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
import ExceptionRequestModal from '$lib/components/molecules/ExceptionRequestModal/ExceptionRequestModal.svelte'; import ExceptionRequestModal from '$lib/components/molecules/ExceptionRequestModal/ExceptionRequestModal.svelte';
import RuleBlockedModal from '$lib/components/molecules/RuleBlockedModal/RuleBlockedModal.svelte'; import RuleBlockedModal from '$lib/components/molecules/RuleBlockedModal/RuleBlockedModal.svelte';
import FileUpload from '$lib/components/molecules/FileUpload/FileUpload.svelte';
import RichTextEditor from '$lib/components/molecules/RichTextEditor/RichTextEditor.svelte';
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte'; import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
@@ -28,6 +30,13 @@
updatedAt: string; updatedAt: string;
} }
interface HomeworkAttachmentFile {
id: string;
filename: string;
fileSize: number;
mimeType: string;
}
interface RuleWarning { interface RuleWarning {
ruleType: string; ruleType: string;
message: string; message: string;
@@ -75,6 +84,10 @@
let newDescription = $state(''); let newDescription = $state('');
let newDueDate = $state(''); let newDueDate = $state('');
let isSubmitting = $state(false); let isSubmitting = $state(false);
let newPendingFiles = $state<File[]>([]);
// Attachments
let editAttachments = $state<HomeworkAttachmentFile[]>([]);
// Edit modal // Edit modal
let showEditModal = $state(false); let showEditModal = $state(false);
@@ -321,6 +334,7 @@
newTitle = ''; newTitle = '';
newDescription = ''; newDescription = '';
newDueDate = ''; newDueDate = '';
newPendingFiles = [];
ruleConformMinDate = ''; ruleConformMinDate = '';
dueDateError = null; dueDateError = null;
} }
@@ -382,6 +396,22 @@
throw new Error(msg); throw new Error(msg);
} }
// Upload pending files if any
if (newPendingFiles.length > 0) {
const created = await response.json().catch(() => null);
const homeworkId = created?.id;
if (homeworkId) {
for (const file of newPendingFiles) {
try {
await uploadAttachment(homeworkId, file);
} catch {
// Best effort — file upload failure doesn't block creation
}
}
}
newPendingFiles = [];
}
closeCreateModal(); closeCreateModal();
showRuleWarningModal = false; showRuleWarningModal = false;
ruleWarnings = []; ruleWarnings = [];
@@ -525,18 +555,63 @@
showCreateModal = true; showCreateModal = true;
} }
// --- Attachments ---
async function uploadAttachment(homeworkId: string, file: File): Promise<HomeworkAttachmentFile> {
const apiUrl = getApiBaseUrl();
const formData = new window.FormData();
formData.append('file', file);
const response = await authenticatedFetch(`${apiUrl}/homework/${homeworkId}/attachments`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail ?? 'Erreur lors de l\'envoi du fichier.');
}
return response.json() as Promise<HomeworkAttachmentFile>;
}
async function deleteAttachment(homeworkId: string, attachmentId: string): Promise<void> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/homework/${homeworkId}/attachments/${attachmentId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Erreur lors de la suppression du fichier.');
}
}
async function fetchAttachments(homeworkId: string): Promise<HomeworkAttachmentFile[]> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/homework/${homeworkId}/attachments`);
if (!response.ok) return [];
const data = await response.json();
return (data as HomeworkAttachmentFile[]) ?? [];
}
// --- Edit --- // --- Edit ---
function openEditModal(hw: Homework) { async function openEditModal(hw: Homework) {
editHomework = hw; editHomework = hw;
editTitle = hw.title; editTitle = hw.title;
editDescription = hw.description ?? ''; editDescription = hw.description ?? '';
editDueDate = hw.dueDate; editDueDate = hw.dueDate;
editAttachments = [];
showEditModal = true; showEditModal = true;
// Charger les pièces jointes existantes en arrière-plan
editAttachments = await fetchAttachments(hw.id);
} }
function closeEditModal() { function closeEditModal() {
showEditModal = false; showEditModal = false;
editHomework = null; editHomework = null;
editAttachments = [];
} }
async function handleUpdate() { async function handleUpdate() {
@@ -811,7 +886,7 @@
</div> </div>
{#if hw.description} {#if hw.description}
<p class="homework-description">{hw.description}</p> <div class="homework-description">{@html hw.description}</div>
{/if} {/if}
{#if hw.status === 'published'} {#if hw.status === 'published'}
@@ -901,13 +976,13 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="hw-description">Description</label> <label>Description</label>
<textarea <RichTextEditor
id="hw-description" content={newDescription}
bind:value={newDescription} onUpdate={(html) => (newDescription = html)}
placeholder="Consignes, pages à lire, liens utiles..." placeholder="Consignes, pages à lire, liens utiles..."
rows="4" disabled={isSubmitting}
></textarea> />
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -931,6 +1006,27 @@
{/if} {/if}
</div> </div>
<div class="form-group">
<label>Pièces jointes</label>
<FileUpload
existingFiles={[]}
onUpload={async (file) => {
newPendingFiles = [...newPendingFiles, file];
const pendingId = `pending-${file.name}-${file.size}`;
return { id: pendingId, filename: file.name, fileSize: file.size, mimeType: file.type };
}}
onDelete={async (fileId) => {
newPendingFiles = newPendingFiles.filter(
(f) => `pending-${f.name}-${f.size}` !== fileId
);
}}
disabled={isSubmitting}
/>
{#if newPendingFiles.length > 0}
<small class="form-hint">Les fichiers seront envoyés après la création du devoir.</small>
{/if}
</div>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn-secondary" onclick={closeCreateModal} disabled={isSubmitting}> <button type="button" class="btn-secondary" onclick={closeCreateModal} disabled={isSubmitting}>
Annuler Annuler
@@ -997,13 +1093,13 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="edit-description">Description</label> <label>Description</label>
<textarea <RichTextEditor
id="edit-description" content={editDescription}
bind:value={editDescription} onUpdate={(html) => (editDescription = html)}
placeholder="Consignes, pages à lire, liens utiles..." placeholder="Consignes, pages à lire, liens utiles..."
rows="4" disabled={isUpdating}
></textarea> />
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -1011,6 +1107,18 @@
<input type="date" id="edit-due-date" bind:value={editDueDate} required min={minDueDate} /> <input type="date" id="edit-due-date" bind:value={editDueDate} required min={minDueDate} />
</div> </div>
{#if editHomework}
<div class="form-group">
<label>Pièces jointes</label>
<FileUpload
existingFiles={editAttachments}
onUpload={(file) => uploadAttachment(editHomework!.id, file)}
onDelete={(attachmentId) => deleteAttachment(editHomework!.id, attachmentId)}
disabled={isUpdating}
/>
</div>
{/if}
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn-secondary" onclick={closeEditModal} disabled={isUpdating}> <button type="button" class="btn-secondary" onclick={closeEditModal} disabled={isUpdating}>
Annuler Annuler
@@ -1553,7 +1661,16 @@
font-size: 0.875rem; font-size: 0.875rem;
color: #4b5563; color: #4b5563;
line-height: 1.5; line-height: 1.5;
white-space: pre-line; }
.homework-description :global(ul) {
list-style-type: disc;
padding-left: 1.5rem;
}
.homework-description :global(ol) {
list-style-type: decimal;
padding-left: 1.5rem;
} }
.homework-actions { .homework-actions {

View File

@@ -0,0 +1,497 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { authenticatedFetch } from '$lib/auth';
import type {
TeacherSubmission,
TeacherSubmissionDetail,
SubmissionStats
} from '$lib/features/homework/api/teacherSubmissions';
import {
fetchSubmissions,
fetchSubmissionDetail,
fetchSubmissionStats,
getSubmissionAttachmentUrl
} from '$lib/features/homework/api/teacherSubmissions';
let homeworkId = $derived(page.params.id ?? '');
let submissions = $state<TeacherSubmission[]>([]);
let stats = $state<SubmissionStats | null>(null);
let selectedDetail = $state<TeacherSubmissionDetail | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let downloadError = $state<string | null>(null);
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} o`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
}
function statusLabel(status: string): { text: string; className: string } {
switch (status) {
case 'submitted':
return { text: 'Soumis', className: 'badge-submitted' };
case 'late':
return { text: 'En retard', className: 'badge-late' };
case 'draft':
return { text: 'Brouillon', className: 'badge-draft' };
case 'not_submitted':
return { text: 'Non rendu', className: 'badge-not-submitted' };
default:
return { text: status, className: '' };
}
}
async function loadData() {
loading = true;
error = null;
try {
const [subs, st] = await Promise.all([
fetchSubmissions(homeworkId),
fetchSubmissionStats(homeworkId)
]);
submissions = subs;
stats = st;
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur de chargement';
} finally {
loading = false;
}
}
async function handleViewDetail(submissionId: string) {
try {
selectedDetail = await fetchSubmissionDetail(homeworkId, submissionId);
} catch {
error = 'Erreur lors du chargement du détail.';
}
}
function handleBack() {
selectedDetail = null;
}
function shouldOpenInline(mimeType: string): boolean {
return mimeType === 'application/pdf' || mimeType.startsWith('image/') || mimeType.startsWith('text/');
}
async function downloadAttachment(submissionId: string, attachmentId: string, filename: string, mimeType: string) {
downloadError = null;
const url = getSubmissionAttachmentUrl(homeworkId, submissionId, attachmentId);
try {
const response = await authenticatedFetch(url);
if (!response.ok) {
downloadError = `Impossible de télécharger "${filename}".`;
return;
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
if (shouldOpenInline(mimeType)) {
link.target = '_blank';
link.rel = 'noopener noreferrer';
} else {
link.download = filename;
}
link.click();
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
} catch {
downloadError = `Impossible de télécharger "${filename}".`;
}
}
$effect(() => {
void homeworkId;
void loadData();
});
</script>
<div class="submissions-page">
<button class="back-link" onclick={() => goto('/dashboard/teacher/homework')}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
Retour aux devoirs
</button>
{#if loading}
<div class="loading">Chargement des rendus...</div>
{:else if error}
<div class="error-message" role="alert">
<p>{error}</p>
<button onclick={() => void loadData()}>Réessayer</button>
</div>
{:else if selectedDetail}
<div class="detail-view">
<button class="back-link" onclick={handleBack}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
Retour à la liste
</button>
<header class="detail-header">
<h2>{selectedDetail.studentName}</h2>
<div class="detail-meta">
<span class="badge {statusLabel(selectedDetail.status).className}">
{statusLabel(selectedDetail.status).text}
</span>
{#if selectedDetail.submittedAt}
<span class="submitted-date">Soumis le {formatDate(selectedDetail.submittedAt)}</span>
{/if}
</div>
</header>
{#if selectedDetail.responseHtml}
<section class="detail-response">
<h3>Réponse</h3>
<div class="response-content">{@html selectedDetail.responseHtml}</div>
</section>
{:else}
<p class="no-response">Aucune réponse textuelle.</p>
{/if}
{#if selectedDetail.attachments.length > 0}
<section class="detail-attachments">
<h3>Pièces jointes</h3>
{#if downloadError}
<p class="download-error" role="alert">{downloadError}</p>
{/if}
<ul class="attachments-list">
{#each selectedDetail.attachments as attachment}
<li>
<button
class="attachment-item"
onclick={() => downloadAttachment(selectedDetail!.id, attachment.id, attachment.filename, attachment.mimeType)}
>
<span class="attachment-name">{attachment.filename}</span>
<span class="attachment-size">{formatFileSize(attachment.fileSize)}</span>
</button>
</li>
{/each}
</ul>
</section>
{/if}
</div>
{:else}
{#if stats}
<div class="stats-bar">
<div class="stat-item">
<span class="stat-value">{stats.submittedCount}</span>
<span class="stat-label">/ {stats.totalStudents} rendus</span>
</div>
</div>
{/if}
{#if submissions.length === 0}
<div class="empty-state">
<p>Aucun rendu pour ce devoir.</p>
</div>
{:else}
<div class="submissions-list">
<h3>Rendus ({submissions.length})</h3>
<table>
<thead>
<tr>
<th>Élève</th>
<th>Statut</th>
<th>Date de soumission</th>
<th></th>
</tr>
</thead>
<tbody>
{#each submissions as sub}
<tr>
<td>{sub.studentName}</td>
<td>
<span class="badge {statusLabel(sub.status).className}">
{statusLabel(sub.status).text}
</span>
</td>
<td>{sub.submittedAt ? formatDate(sub.submittedAt) : '—'}</td>
<td>
{#if sub.id}
<button class="btn-view" onclick={() => handleViewDetail(sub.id!)}>
Voir
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
</div>
<style>
.submissions-page {
max-width: 48rem;
margin: 0 auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.375rem;
background: none;
border: none;
color: #3b82f6;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
padding: 0.25rem 0;
align-self: flex-start;
}
.back-link:hover {
color: #2563eb;
}
.loading {
text-align: center;
padding: 2rem;
color: #6b7280;
}
.error-message {
text-align: center;
padding: 1rem;
color: #ef4444;
}
.error-message button {
margin-top: 0.5rem;
padding: 0.375rem 0.75rem;
border: 1px solid #ef4444;
border-radius: 0.375rem;
background: white;
color: #ef4444;
cursor: pointer;
}
.stats-bar {
display: flex;
gap: 1rem;
padding: 0.75rem 1rem;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 0.5rem;
}
.stat-item {
display: flex;
align-items: baseline;
gap: 0.375rem;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: #0369a1;
}
.stat-label {
font-size: 0.875rem;
color: #6b7280;
}
.submissions-list h3,
.missing-students h3 {
margin: 0 0 0.5rem;
font-size: 0.9375rem;
font-weight: 600;
color: #374151;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
thead {
background: #f9fafb;
}
th {
text-align: left;
padding: 0.625rem 0.75rem;
font-weight: 600;
color: #6b7280;
border-bottom: 1px solid #e5e7eb;
}
td {
padding: 0.625rem 0.75rem;
border-bottom: 1px solid #f3f4f6;
color: #374151;
}
tr:hover {
background: #f9fafb;
}
.badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 1rem;
font-size: 0.75rem;
font-weight: 600;
}
.badge-submitted {
background: #dcfce7;
color: #166534;
}
.badge-late {
background: #fee2e2;
color: #991b1b;
}
.badge-draft {
background: #e0e7ff;
color: #3730a3;
}
.badge-not-submitted {
background: #f3f4f6;
color: #6b7280;
}
.btn-view {
padding: 0.25rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
background: white;
color: #3b82f6;
font-size: 0.8125rem;
cursor: pointer;
}
.btn-view:hover {
background: #eff6ff;
border-color: #3b82f6;
}
.empty-state {
text-align: center;
padding: 2rem;
color: #6b7280;
}
/* Detail view */
.detail-header h2 {
margin: 0 0 0.25rem;
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
.detail-meta {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.875rem;
color: #6b7280;
}
.submitted-date {
color: #6b7280;
}
.detail-response h3,
.detail-attachments h3 {
margin: 0 0 0.5rem;
font-size: 0.9375rem;
font-weight: 600;
color: #374151;
}
.response-content {
padding: 0.75rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
color: #4b5563;
line-height: 1.6;
}
.no-response {
color: #9ca3af;
font-style: italic;
}
.download-error {
margin: 0 0 0.5rem;
padding: 0.5rem 0.75rem;
background: #fee2e2;
border-radius: 0.375rem;
color: #991b1b;
font-size: 0.8125rem;
}
.attachments-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.attachment-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.625rem 0.75rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
cursor: pointer;
text-align: left;
font-size: 0.875rem;
}
.attachment-item:hover {
background: #f3f4f6;
border-color: #d1d5db;
}
.attachment-name {
flex: 1;
color: #3b82f6;
font-weight: 500;
}
.attachment-size {
color: #9ca3af;
font-size: 0.75rem;
}
</style>