feat: accept form submissions from the websites over an API endpoint

This commit is contained in:
fromme committed 2026-09-24 16:35:06 +02:00
1 parent f48e53fdd0
commit 30ad5e8488
17 files changed
+1350 -4

No files matched your search

@@ -0,0 +1,83 @@
<?php
namespace App\Controller\Api;
use App\Entity\ApplicationFormSubmission;
use App\Model\ApplicationFormSubmissionDto;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Receives a form submitted on one of the public websites, posted by the TYPO3
* ApplicationApiFinisher. Authenticated by the API key of the stateless "api" firewall.
*
* The visitor waits for this response inside their own form submission and TYPO3 gives up after
* a few seconds without retrying, so this stores the row and answers - notification mail and any
* matching against known people belong in a worker, not here
* (.ddev/plans/MYEP-APPLICATION-ENDPOINT.md §6).
*/
class ApplicationFormController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
/**
* A failed mapping or validation answers 400 rather than the resolver's default 422: the
* contract names 400 for an unusable payload, and the TYPO3 side logs the response body.
* Errors are English - the only caller is a machine.
*/
#[Route('/api/application-forms', name: 'app_api_application_form_submit', methods: ['POST'])]
#[IsGranted('ROLE_API')]
public function submit(
Request $request,
#[MapRequestPayload(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
ApplicationFormSubmissionDto $dto,
): JsonResponse {
$submission = $this->store($dto, $request);
$this->logger->info('Application form submission received', [
'uuid' => $submission->getUuid(),
'form' => $submission->getForm(),
'formLabel' => $submission->getFormLabel(),
'pageUid' => $submission->getPageUid(),
'submittedAt' => $submission->getSubmittedAt()->format(\DateTimeInterface::ATOM),
// the labels and the answers are personal data (§7), their number is not
'detailCount' => count($submission->getDetails()),
]);
return new JsonResponse(
['status' => 'accepted', 'uuid' => $submission->getUuid()],
Response::HTTP_ACCEPTED
);
}
private function store(ApplicationFormSubmissionDto $dto, Request $request): ApplicationFormSubmission
{
$submission = ApplicationFormSubmission::fromDto($dto, $this->decodePayload($request));
$this->entityManager->persist($submission);
$this->entityManager->flush();
return $submission;
}
/**
* @return array<string, mixed>
*/
private function decodePayload(Request $request): array
{
$payload = json_decode($request->getContent(), true);
return is_array($payload) ? $payload : [];
}
}