diff --git a/src/Controller/Api/SignalementController.php b/src/Controller/Api/SignalementController.php
index dcd75943a..ec360509c 100644
--- a/src/Controller/Api/SignalementController.php
+++ b/src/Controller/Api/SignalementController.php
@@ -7,7 +7,7 @@
use App\Entity\User;
use App\Factory\Api\SignalementResponseFactory;
use App\Repository\SignalementRepository;
-use Nelmio\ApiDocBundle\Annotation\Model;
+use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\When;
diff --git a/src/Controller/Api/SignalementFileUploadController.php b/src/Controller/Api/SignalementFileUploadController.php
index b978a492f..e0540b3fb 100644
--- a/src/Controller/Api/SignalementFileUploadController.php
+++ b/src/Controller/Api/SignalementFileUploadController.php
@@ -11,7 +11,7 @@
use App\Factory\Api\FileFactory;
use App\Service\Signalement\SignalementFileProcessor;
use Doctrine\ORM\EntityManagerInterface;
-use Nelmio\ApiDocBundle\Annotation\Model;
+use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\When;
diff --git a/src/Controller/Api/SuiviCreateController.php b/src/Controller/Api/SuiviCreateController.php
new file mode 100644
index 000000000..71e9ecc38
--- /dev/null
+++ b/src/Controller/Api/SuiviCreateController.php
@@ -0,0 +1,180 @@
+ []]],
+ tags: ['Suivis']
+ )]
+ #[OA\Response(
+ response: Response::HTTP_CREATED,
+ description: 'Suivi crée avec succès',
+ content: new OA\JsonContent(ref: new Model(type: SuiviResponse::class))
+ )]
+ #[OA\Response(
+ response: Response::HTTP_NOT_FOUND,
+ description: 'Signalement introuvable',
+ content: new OA\JsonContent(
+ properties: [
+ new OA\Property(
+ property: 'message',
+ type: 'string',
+ example: 'Signalement introuvable'
+ ),
+ new OA\Property(
+ property: 'statut',
+ type: 'int',
+ example: Response::HTTP_NOT_FOUND
+ ),
+ ],
+ type: 'object'
+ )
+ )]
+ #[OA\Response(
+ response: Response::HTTP_BAD_REQUEST,
+ description: 'Mauvaise payload (données invalides).',
+ content: new OA\JsonContent(
+ properties: [
+ new OA\Property(
+ property: 'message',
+ type: 'string',
+ example: 'Valeurs invalides pour les champs suivants :'
+ ),
+ new OA\Property(
+ property: 'status',
+ type: 'integer',
+ example: 400
+ ),
+ new OA\Property(
+ property: 'errors',
+ type: 'array',
+ items: new OA\Items(
+ properties: [
+ new OA\Property(
+ property: 'property',
+ type: 'string',
+ example: 'description'
+ ),
+ new OA\Property(
+ property: 'message',
+ type: 'string',
+ example: 'Le contenu du suivi doit faire au moins 10 caractères !'
+ ),
+ ],
+ type: 'object'
+ )
+ ),
+ ],
+ type: 'object'
+ )
+ )]
+ #[OA\Response(
+ response: Response::HTTP_FORBIDDEN,
+ description: 'Accès à la ressource non autorisé.',
+ content: new OA\JsonContent(
+ properties: [
+ new OA\Property(
+ property: 'message',
+ type: 'string',
+ example: 'Vous n\'avez pas l\'autorisation d\'accéder à cette ressource.'
+ ),
+ new OA\Property(
+ property: 'statut',
+ type: 'int',
+ example: Response::HTTP_FORBIDDEN
+ ),
+ ],
+ type: 'object'
+ )
+ )]
+ public function __invoke(
+ #[MapRequestPayload]
+ SuiviRequest $suiviRequest,
+ ?Signalement $signalement = null,
+ ): JsonResponse {
+ if (null === $signalement) {
+ return $this->json(
+ ['message' => 'Signalement introuvable', 'status' => Response::HTTP_NOT_FOUND],
+ Response::HTTP_NOT_FOUND
+ );
+ }
+ $this->denyAccessUnlessGranted('COMMENT_CREATE',
+ $signalement,
+ SecurityApiExceptionListener::ACCESS_DENIED
+ );
+
+ /** @var User $user */
+ $user = $this->getUser();
+ $suivi = $this->suiviManager->createSuivi(
+ signalement: $signalement,
+ description: $this->buildDescription($signalement, $suiviRequest),
+ type: Suivi::TYPE_PARTNER,
+ isPublic: $suiviRequest->notifyUsager,
+ user: $user,
+ );
+
+ return $this->json(new SuiviResponse($suivi), Response::HTTP_CREATED);
+ }
+
+ private function buildDescription(Signalement $signalement, SuiviRequest $suiviRequest): string
+ {
+ $fileListAsHtml = '';
+ $description = Sanitizer::sanitize($suiviRequest->description);
+ $filesFiltered = $signalement->getFiles()->filter(function (File $file) use ($suiviRequest) {
+ return in_array($file->getUuid(), $suiviRequest->files, true);
+ });
+
+ if ($filesFiltered->count() > 0) {
+ $fileListAsHtml = '
';
+ /** @var File $file */
+ foreach ($filesFiltered as $file) {
+ $fileUrl = $this->urlGenerator->generate(
+ 'show_file',
+ ['uuid' => $file->getUuid()],
+ UrlGeneratorInterface::ABSOLUTE_URL
+ );
+ $fileListAsHtml .= sprintf("- %s",
+ $fileUrl,
+ $file->getTitle()
+ );
+ }
+ $fileListAsHtml .= '
';
+ }
+
+ return $description.$fileListAsHtml;
+ }
+}
diff --git a/src/Controller/Back/SignalementActionController.php b/src/Controller/Back/SignalementActionController.php
index 0623a2eae..02d0a18a3 100755
--- a/src/Controller/Back/SignalementActionController.php
+++ b/src/Controller/Back/SignalementActionController.php
@@ -16,6 +16,7 @@
use App\Service\Mailer\NotificationMail;
use App\Service\Mailer\NotificationMailerRegistry;
use App\Service\Mailer\NotificationMailerType;
+use App\Service\Sanitizer;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
@@ -109,9 +110,7 @@ public function addSuiviSignalement(
$this->denyAccessUnlessGranted('COMMENT_CREATE', $signalement);
if ($this->isCsrfTokenValid('signalement_add_suivi_'.$signalement->getId(), $request->get('_token'))
&& $form = $request->get('signalement-add-suivi')) {
- $content = $form['content'];
- $content = preg_replace('/]*>/', '', $content); // Remove the start
or
- $content = str_replace('
', '
', $content); // Replace the end
+ $content = Sanitizer::sanitize($form['content']);
if (mb_strlen($content) < 10) {
$this->addFlash('error', 'Le contenu du suivi doit faire au moins 10 caractères !');
@@ -121,11 +120,11 @@ public function addSuiviSignalement(
/** @var User $user */
$user = $this->getUser();
$suiviManager->createSuivi(
- user: $user,
signalement: $signalement,
description: $content,
type: Suivi::TYPE_PARTNER,
isPublic: !empty($form['notifyUsager']),
+ user: $user,
);
} catch (\Throwable $exception) {
$this->addFlash('error', 'Une erreur est survenue lors de la publication.');
diff --git a/src/Dto/Api/Model/Suivi.php b/src/Dto/Api/Model/Suivi.php
index 8f3caf969..57ded5bce 100644
--- a/src/Dto/Api/Model/Suivi.php
+++ b/src/Dto/Api/Model/Suivi.php
@@ -11,13 +11,6 @@
)]
class Suivi
{
- #[OA\Property(
- description: 'Identifiant unique du suivi.',
- type: 'integer',
- example: 1
- )]
- public int $id;
-
#[OA\Property(
description: 'Date de création du suivi.
Exemple : `2024-11-01T10:00:00+00:00`',
type: 'string',
@@ -26,9 +19,9 @@ class Suivi
)]
public string $dateCreation;
#[OA\Property(
- description: 'Description détaillée du suivi.',
+ description: 'Description détaillée du suivi, peut contenir des balises HTML.',
type: 'string',
- example: 'Premier suivi associé.'
+ example: 'Premier suivi associé.'
)]
public string $description;
@@ -49,9 +42,8 @@ class Suivi
public function __construct(
SuiviEntity $suivi,
) {
- $this->id = $suivi->getId();
$this->dateCreation = $suivi->getCreatedAt()->format(\DATE_ATOM);
- $this->description = $suivi->getDescription(); // traitement de suppression du html ? comment gérer les bouton/doc qui sont présent en dur dans le contenu ?
+ $this->description = $suivi->getDescription();
$this->public = $suivi->getIsPublic();
$this->createdBy = $suivi->getCreatedByLabel();
}
diff --git a/src/Dto/Api/Request/SuiviRequest.php b/src/Dto/Api/Request/SuiviRequest.php
new file mode 100644
index 000000000..54123f896
--- /dev/null
+++ b/src/Dto/Api/Request/SuiviRequest.php
@@ -0,0 +1,37 @@
+ 1,
'dateCreation' => '2024-11-01T10:00:00+00:00',
'description' => 'Premier suivi associé.',
'public' => true,
'createdBy' => 'John Doe',
],
[
- 'id' => 2,
'dateCreation' => '2024-11-02T12:30:00+00:00',
'description' => 'Deuxième suivi, accès limité.',
'public' => false,
diff --git a/src/Dto/Api/Response/SuiviResponse.php b/src/Dto/Api/Response/SuiviResponse.php
new file mode 100644
index 000000000..6afc45d81
--- /dev/null
+++ b/src/Dto/Api/Response/SuiviResponse.php
@@ -0,0 +1,38 @@
+Exemple : `2024-11-01T10:00:00+00:00`',
+ type: 'string',
+ format: 'date-time',
+ example: '2024-11-01T10:00:00+00:00'
+ )]
+ public string $dateCreation;
+
+ #[OA\Property(
+ description: 'Description détaillée du suivi, peut contenir des balises HTML.',
+ type: 'string',
+ example: ''
+ )]
+ public string $description;
+
+ #[OA\Property(
+ description: 'Indique si le suivi est visible pour les usagers.',
+ type: 'boolean',
+ example: true
+ )]
+ public bool $public;
+
+ public function __construct(Suivi $suivi)
+ {
+ $this->dateCreation = $suivi->getCreatedAt()->format(\DATE_ATOM);
+ $this->description = $suivi->getDescription();
+ $this->public = $suivi->getIsPublic();
+ }
+}
diff --git a/src/EventListener/SecurityApiExceptionListener.php b/src/EventListener/SecurityApiExceptionListener.php
index f88d8459e..2c765c79f 100644
--- a/src/EventListener/SecurityApiExceptionListener.php
+++ b/src/EventListener/SecurityApiExceptionListener.php
@@ -17,7 +17,6 @@ class SecurityApiExceptionListener
public function onKernelException(ExceptionEvent $event): void
{
$exception = $event->getThrowable();
-
if ($this->supports($exception)) {
$previous = $exception->getPrevious();
$affectation = null;
diff --git a/src/Validator/SanitizedLength.php b/src/Validator/SanitizedLength.php
new file mode 100644
index 000000000..76ac342d9
--- /dev/null
+++ b/src/Validator/SanitizedLength.php
@@ -0,0 +1,21 @@
+min = $min;
+ if ($message) {
+ $this->message = $message;
+ }
+ }
+}
diff --git a/src/Validator/SanitizedLengthValidator.php b/src/Validator/SanitizedLengthValidator.php
new file mode 100644
index 000000000..641952091
--- /dev/null
+++ b/src/Validator/SanitizedLengthValidator.php
@@ -0,0 +1,36 @@
+min) {
+ $this->context
+ ->buildViolation($constraint->message)
+ ->setParameter('{{ limit }}', (string) $constraint->min)
+ ->addViolation();
+ }
+ }
+}
diff --git a/src/Validator/ValidFiles.php b/src/Validator/ValidFiles.php
new file mode 100644
index 000000000..32b82a1ae
--- /dev/null
+++ b/src/Validator/ValidFiles.php
@@ -0,0 +1,19 @@
+message = $message;
+ }
+ }
+}
diff --git a/src/Validator/ValidFilesValidator.php b/src/Validator/ValidFilesValidator.php
new file mode 100644
index 000000000..ef5fcd756
--- /dev/null
+++ b/src/Validator/ValidFilesValidator.php
@@ -0,0 +1,56 @@
+signalementRepository->findOneBy([
+ 'uuid' => $this->requestStack->getCurrentRequest()->get('signalement')]
+ );
+
+ if (null === $signalement) {
+ return;
+ }
+
+ $uuidFiles = array_map(fn (File $file) => $file->getUuid(), $signalement->getFiles()->toArray());
+
+ foreach ($value as $uuid) {
+ if (!is_string($uuid) || !UuidV4::isValid($uuid)) {
+ $this->context->buildViolation('Le fichier avec l\'UUID "{{ uuid }}" n\'est pas un UUID valide.')
+ ->setParameter('{{ uuid }}', $uuid)
+ ->addViolation();
+ continue;
+ }
+ if (!in_array($uuid, $uuidFiles)) {
+ $this->context->buildViolation('Le fichier avec l\'UUID "{{ uuid }}" n\'appartient pas au signalement.')
+ ->setParameter('{{ uuid }}', $uuid)
+ ->addViolation();
+ }
+ }
+ }
+}
diff --git a/tests/Functional/Controller/Api/SuiviCreateControllerTest.php b/tests/Functional/Controller/Api/SuiviCreateControllerTest.php
new file mode 100644
index 000000000..37ccd390f
--- /dev/null
+++ b/tests/Functional/Controller/Api/SuiviCreateControllerTest.php
@@ -0,0 +1,73 @@
+client = static::createClient();
+ $user = self::getContainer()->get('doctrine')->getRepository(User::class)->findOneBy([
+ 'email' => 'api-01@histologe.fr',
+ ]);
+
+ $this->router = self::getContainer()->get('router');
+
+ $this->client->loginUser($user, 'api');
+ }
+
+ /**
+ * @dataProvider provideData
+ */
+ public function testCreateSuivi(string $signalementUuid, bool $notifyUsager, int $nbMailSent): void
+ {
+ $signalement = self::getContainer()->get(SignalementRepository::class)->findOneBy(['uuid' => $signalementUuid]);
+ $firstFile = $signalement?->getFiles()?->first() ?? null;
+ $lastFile = $signalement?->getFiles()?->last() ?? null;
+ $payload = [
+ 'description' => 'lorem ipsum dolor sit amet',
+ 'notifyUsager' => $notifyUsager,
+ ];
+
+ if (null !== $firstFile && null !== $lastFile) {
+ $payload['files'] = [$firstFile->getUuid(), $lastFile->getUuid()];
+ }
+ $this->client->request(
+ method: 'POST',
+ uri: $this->router->generate('api_signalements_suivis_post', ['uuid' => $signalementUuid]),
+ server: ['CONTENT_TYPE' => 'application/json'],
+ content: json_encode($payload)
+ );
+ if ('0000' !== $signalementUuid) {
+ /** @var Suivi $suiviCreated */
+ $suiviCreated = $signalement->getSuivis()->last();
+
+ $this->assertEquals(201, $this->client->getResponse()->getStatusCode());
+ $this->assertEmailCount($nbMailSent);
+
+ $crawler = new Crawler($suiviCreated->getDescription());
+ $links = $crawler->filter('a.fr-link');
+ $this->assertCount(2, $links, 'Il doit y avoir exactement 2 liens dans le contenu HTML.');
+ } else {
+ $this->assertEquals(404, $this->client->getResponse()->getStatusCode());
+ }
+ }
+
+ public function provideData(): \Generator
+ {
+ yield 'test create suivi with usager notification' => ['00000000-0000-0000-2022-000000000006', true, 2];
+ yield 'test create suivi with no usager notification' => ['00000000-0000-0000-2022-000000000006', false, 1, '00000000-0000-0000-2022-000000000006'];
+ yield 'test create suivi with unknown signalement' => ['0000', false, 1];
+ }
+}
diff --git a/tests/Unit/Validator/SanitizedLengthValidatorTest.php b/tests/Unit/Validator/SanitizedLengthValidatorTest.php
new file mode 100644
index 000000000..7d41f8edd
--- /dev/null
+++ b/tests/Unit/Validator/SanitizedLengthValidatorTest.php
@@ -0,0 +1,58 @@
+validator->validate($value, $constraint);
+ $this->assertNoViolation();
+ }
+
+ /**
+ * @dataProvider provideInvalidValues
+ */
+ public function testSanitizedTextTooShort(string $value): void
+ {
+ $constraint = new SanitizedLength(10, 'Text too short.');
+ $this->validator->validate($value, $constraint);
+ $this->buildViolation('Text too short.')
+ ->setParameter('{{ limit }}', '10')
+ ->assertRaised();
+ }
+
+ public function provideValidValues(): \Generator
+ {
+ yield 'null value' => [null];
+ yield 'empty value' => [''];
+ yield 'valid value' => ['Lorem ipsum dolor sit amet
'];
+ }
+
+ public function provideInvalidValues(): \Generator
+ {
+ yield 'too short value with no html' => ['Hi buddy!'];
+ yield 'too short value with html' => ['Hi buddy!'];
+ }
+
+ public function testNonStringThrowsException(): void
+ {
+ $this->expectException(UnexpectedValueException::class);
+ $constraint = new SanitizedLength(10, 'Text too short.');
+ $this->validator->validate(12345, $constraint);
+ }
+}