Files
survey-bundle/src/Service/SurveySubmissionService.php
T
Claude Fable 5 833a8964cf Add topic sections, ordered inserts, rich-text descriptions and grouped results (Ausbaustufe 3)
- section content type with structure service, reader context and grouped results/PDF/Excel
- new questions append at the end (backend oncreate) and per-section add buttons
- rich-text descriptions: Quill 2 (frontend), reduced TinyMCE (backend), server-side whitelist sanitizer
- externalize inline assets, bundle Chart.js locally, harden debug access, validate range bounds

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 07:57:40 +02:00

261 lines
9.4 KiB
PHP

<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Service;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Mummert\SurveyBundle\Form\Model\SurveyAnswerData;
use Mummert\SurveyBundle\Model\SurveyModel;
use Mummert\SurveyBundle\Model\SurveySubmissionModel;
use Mummert\SurveyBundle\QuestionType\QuestionTypeRegistry;
use Mummert\SurveyBundle\Repository\SurveyAnswerRepository;
use Mummert\SurveyBundle\Repository\SurveyQuestionRepository;
use Mummert\SurveyBundle\Repository\SurveyRepository;
use Mummert\SurveyBundle\Repository\SurveySubmissionRepository;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use Symfony\Component\Uid\Ulid;
final class SurveySubmissionService
{
/**
* Eine abgeschlossene Teilnahme bleibt für diese Dauer auf der Danke-Seite.
* Danach darf dieselbe Browser-Session erneut an der Umfrage teilnehmen.
*/
private const RESTART_COOLDOWN_SECONDS = 60;
public function __construct(
private readonly SurveySubmissionRepository $surveySubmissionRepository,
private readonly SurveyAnswerRepository $surveyAnswerRepository,
private readonly SurveyRepository $surveyRepository,
private readonly QuestionTypeRegistry $questionTypeRegistry,
private readonly TranslatorInterface $translator,
private readonly SurveyQuestionRepository $surveyQuestionRepository,
private readonly SurveyStructureService $surveyStructureService,
) {
}
public function resolveActiveSubmission(SurveyModel $survey, SessionInterface $session, ?SurveyContentModel $firstQuestion): SurveySubmissionModel
{
$sessionKey = $this->buildSessionKey((int) $survey->id);
$token = (string) $session->get($sessionKey, '');
if ('' !== $token) {
$existing = $this->surveySubmissionRepository->findBySurveyAndToken((int) $survey->id, $token);
// Offene Durchläufe werden fortgesetzt; abgeschlossene Durchläufe halten
// die Danke-Seite, bis der Cooldown abgelaufen ist (dann neuer Durchlauf).
if ($existing instanceof SurveySubmissionModel && !$this->isRestartAllowed($existing)) {
return $existing;
}
}
$token = (string) new Ulid();
$submission = $this->surveySubmissionRepository->create((int) $survey->id, $token, $firstQuestion?->id ? (int) $firstQuestion->id : null);
$session->set($sessionKey, $token);
$session->set($this->buildHistoryKey((int) $survey->id), []);
return $submission;
}
private function isRestartAllowed(SurveySubmissionModel $submission): bool
{
if ('1' !== (string) $submission->isFinished) {
return false;
}
$completedAt = (int) ($submission->completedAt ?? 0);
if ($completedAt <= 0) {
return true;
}
return (time() - $completedAt) >= self::RESTART_COOLDOWN_SECONDS;
}
public function storeAnswer(SurveyModel $survey, SurveySubmissionModel $submission, SurveyContentModel $question, mixed $rawAnswer): string
{
$normalizedAnswer = $this->normalizeAnswerForQuestion($question, $rawAnswer);
$surveyHadAnswers = $this->surveyAnswerRepository->hasAnswersForSurvey((int) $survey->id);
$sectionMap = $this->surveyStructureService->buildSectionMap($this->surveyQuestionRepository->findAllBySurvey((int) $survey->id));
$this->surveyAnswerRepository->saveAnswer(
(int) $submission->id,
(int) $question->id,
(string) $question->type,
trim((string) $question->question),
trim((string) ($question->description ?? '')),
(int) ($question->sorting ?? 0),
$normalizedAnswer,
trim((string) ($sectionMap[(int) $question->id]['title'] ?? '')),
);
if (!$surveyHadAnswers && '1' !== (string) $survey->isLocked) {
$this->surveyRepository->lock((int) $survey->id);
}
return $normalizedAnswer;
}
public function normalizeAnswerForQuestion(SurveyContentModel $question, mixed $rawAnswer): string
{
return $this->questionTypeRegistry->get((string) $question->type)->normalizeAnswer($rawAnswer, $question);
}
public function createAnswerData(SurveyContentModel $question, mixed $storedValue = null): SurveyAnswerData
{
$answerData = new SurveyAnswerData();
if (null === $storedValue || '' === trim((string) $storedValue)) {
return $answerData;
}
$normalizedValue = (string) $storedValue;
$answerData->answer = match ((string) $question->type) {
'choice' => '1' === (string) $question->allowMultiple
? array_values(array_filter(array_map('trim', explode(' | ', $normalizedValue)), static fn (string $entry): bool => '' !== $entry))
: $normalizedValue,
'range' => (int) $normalizedValue,
default => $normalizedValue,
};
return $answerData;
}
public function getStoredAnswerValue(SurveySubmissionModel $submission, SurveyContentModel $question): ?string
{
return $this->surveyAnswerRepository->findStoredAnswerValue((int) $submission->id, (int) $question->id);
}
public function rememberQuestion(SurveyModel $survey, SessionInterface $session, SurveyContentModel $question): void
{
$history = $this->getHistory($survey, $session);
$questionId = (int) $question->id;
if (($history[array_key_last($history)] ?? null) !== $questionId) {
$history[] = $questionId;
}
$session->set($this->buildHistoryKey((int) $survey->id), $history);
}
public function canGoBack(SurveyModel $survey, SessionInterface $session): bool
{
return [] !== $this->getHistory($survey, $session);
}
public function goBack(SurveyModel $survey, SurveySubmissionModel $submission, SessionInterface $session): bool
{
$history = $this->getHistory($survey, $session);
if ([] === $history) {
return false;
}
$previousQuestionId = (int) array_pop($history);
$this->surveySubmissionRepository->reopen((int) $submission->id, $previousQuestionId);
$this->surveyAnswerRepository->deleteAnswersForSubmissionExceptQuestions(
(int) $submission->id,
array_merge($history, [$previousQuestionId]),
);
$session->set($this->buildHistoryKey((int) $survey->id), $history);
return true;
}
public function advance(SurveySubmissionModel $submission, ?SurveyContentModel $nextQuestion): void
{
if ($nextQuestion instanceof SurveyContentModel) {
$this->surveySubmissionRepository->updateCurrentQuestion((int) $submission->id, (int) $nextQuestion->id);
return;
}
$this->surveySubmissionRepository->markFinished((int) $submission->id);
}
/**
* @return list<array<string, mixed>>
*/
public function getAnswersForSubmission(SurveySubmissionModel $submission): array
{
$answers = $this->surveyAnswerRepository->findAnswersBySubmission((int) $submission->id);
return array_map(fn (array $answer): array => $this->formatSubmissionAnswer($answer), $answers);
}
/**
* @return list<array<string, mixed>>
*/
public function getSubmissionOverview(SurveyModel $survey): array
{
return $this->surveySubmissionRepository->findOverviewBySurvey((int) $survey->id);
}
private function buildSessionKey(int $surveyId): string
{
return 'mummert_survey_submission_'.$surveyId;
}
private function buildHistoryKey(int $surveyId): string
{
return 'mummert_survey_history_'.$surveyId;
}
/**
* @return list<int>
*/
private function getHistory(SurveyModel $survey, SessionInterface $session): array
{
$history = $session->get($this->buildHistoryKey((int) $survey->id), []);
if (!\is_array($history)) {
return [];
}
return array_values(array_filter(array_map('intval', $history), static fn (int $questionId): bool => $questionId > 0));
}
/**
* @param array<string, mixed> $answer
*
* @return array<string, mixed>
*/
private function formatSubmissionAnswer(array $answer): array
{
$questionType = (string) ($answer['questionType'] ?? 'text');
$value = trim((string) ($answer['value'] ?? ''));
$answer['questionType'] = $this->translateQuestionType($questionType);
$answer['value'] = $this->translateAnswerValue($questionType, $value);
return $answer;
}
private function translateQuestionType(string $questionType): string
{
return $this->translator->trans('survey.survey.question_type_'.$questionType, [], 'messages');
}
private function translateAnswerValue(string $questionType, string $value): string
{
if ('' === $value) {
return $value;
}
if ('yes_no_maybe' === $questionType) {
return $this->translator->trans('survey.survey.answer_'.$value, [], 'messages');
}
if ('choice' === $questionType) {
$parts = array_values(array_filter(array_map('trim', explode(' | ', $value)), static fn (string $entry): bool => '' !== $entry));
return implode(' | ', $parts);
}
return $value;
}
}