- 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>
261 lines
9.4 KiB
PHP
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;
|
|
}
|
|
} |