Fix frontend CSRF, clean reader URLs, and review findings
CSRF / actions: - List action forms now send both REQUEST_TOKEN (Contao gate) and _token (Symfony, validated via isCsrfTokenValid) — mirrors the editor; fixes the 400/500 "Ungültiger CSRF-Token" on publish/toggle/duplicate/delete Public reader / URLs: - Reader & results read auto_item via Input::get (marks the route param used) so clean path URLs /<page>/<alias> no longer 404 - List controller generates path URLs via ['parameters' => '/'.$alias] - Allow re-participation in the same session after a 60s cooldown Review fixes: - Results: merge answer snapshots by questionId+type (no more split cards on label edits); seed "Vielleicht" when allowMaybe so screen matches Excel - Reader: replace generic answer hint with per-option "weiter zu Frage X" only when a jump is configured; strip HTML tags from survey description - PDF: render free-text frequency summary; drop duplicate "Minimum" label - Drag-sort: bundle SortableJS locally, drop the duplicated inline sort CSS/JS from _survey_assets, throttle the backend MutationObserver - Backend: move hardcoded tl_survey operation labels to TL_LANG (de+en) - Remove dead code: SurveyEditorVoter, SurveyPermissionService, categoryFilter/categorySummary + helpers, orphaned publishSurvey lang key Reader layout: - max-width 1000px (other views keep 1400px); back/next buttons fill the width as equal, card-sized halves; sort handle icon offset tweak Docs: update AI_HANDOVER (dual-token CSRF, clean URLs) and README Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -68,9 +68,9 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
|
||||
'questionCount' => (int) ($survey['questionCount'] ?? 0),
|
||||
'participationCount' => (int) ($survey['participationCount'] ?? 0),
|
||||
'editUrl' => $editPage instanceof PageModel ? $this->generateContentUrl($editPage, ['survey' => $surveyId]) : null,
|
||||
'publicUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['auto_item' => $alias]) : null,
|
||||
'draftUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['auto_item' => $alias, 'preview' => '1']) : null,
|
||||
'resultsUrl' => $resultsPage instanceof PageModel ? $this->generateContentUrl($resultsPage, ['auto_item' => $alias]) : null,
|
||||
'publicUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['parameters' => '/'.$alias]) : null,
|
||||
'draftUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['parameters' => '/'.$alias, 'preview' => '1']) : null,
|
||||
'resultsUrl' => $resultsPage instanceof PageModel ? $this->generateContentUrl($resultsPage, ['parameters' => '/'.$alias]) : null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -90,8 +90,16 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
|
||||
{
|
||||
$surveyId = (int) $request->request->get('item_id', 0);
|
||||
$action = (string) $request->request->get('_survey_action');
|
||||
$token = (string) $request->request->get('_token', '');
|
||||
|
||||
if ($surveyId <= 0 || '' === $action) {
|
||||
throw new \RuntimeException('Ungültige Umfrage-Aktion.');
|
||||
}
|
||||
|
||||
// Session-basierter Symfony-Token (wie im Editor): zuverlässig für
|
||||
// eingeloggte Mitglieder. Der Contao-REQUEST_TOKEN ist cookie-gebunden
|
||||
// und im Frontend-Fragment-Flow nicht stabil verfügbar.
|
||||
if (!$this->isCsrfTokenValid($action.'-'.$surveyId, $token)) {
|
||||
throw new \RuntimeException('Ungültiger CSRF-Token.');
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace Mummert\SurveyBundle\Controller\FrontendModule;
|
||||
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
|
||||
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
|
||||
use Contao\CoreBundle\Twig\FragmentTemplate;
|
||||
use Contao\Input;
|
||||
use Contao\ModuleModel;
|
||||
use Mummert\SurveyBundle\Model\SurveyContentModel;
|
||||
use Mummert\SurveyBundle\Model\SurveyModel;
|
||||
@@ -126,6 +127,7 @@ final class ShowSurveyController extends AbstractFrontendModuleController
|
||||
$template->set('survey', $survey);
|
||||
$template->set('progress', $this->surveyFlowService->getProgress($survey, $question));
|
||||
$template->set('question', $question);
|
||||
$template->set('jumpHints', $this->buildJumpHints($survey, $question));
|
||||
$template->set('surveyForm', $form->createView());
|
||||
$template->set('isComplete', false);
|
||||
$template->set('answers', []);
|
||||
@@ -136,6 +138,30 @@ final class ShowSurveyController extends AbstractFrontendModuleController
|
||||
return $template->getResponse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprung-Hinweise je Ja/Nein-Option für die aktuelle Frage (nur bei konfigurierter Sprunglogik).
|
||||
*
|
||||
* @return array<string, array{position:int,label:string}>
|
||||
*/
|
||||
private function buildJumpHints(SurveyModel $survey, SurveyContentModel $question): array
|
||||
{
|
||||
if ('yes_no_maybe' !== (string) $question->type) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$hints = [];
|
||||
|
||||
if (null !== $yes = $this->surveyFlowService->describeJumpTarget($survey, (int) ($question->jumpOnYes ?? 0))) {
|
||||
$hints['yes'] = $yes;
|
||||
}
|
||||
|
||||
if (null !== $no = $this->surveyFlowService->describeJumpTarget($survey, (int) ($question->jumpOnNo ?? 0))) {
|
||||
$hints['no'] = $no;
|
||||
}
|
||||
|
||||
return $hints;
|
||||
}
|
||||
|
||||
private function buildPreviewResponse(FragmentTemplate $template, SurveyModel $survey, Request $request): Response
|
||||
{
|
||||
$history = $this->decodePreviewHistory((string) $request->request->get('preview_history', ''));
|
||||
@@ -198,6 +224,7 @@ final class ShowSurveyController extends AbstractFrontendModuleController
|
||||
$template->set('survey', $survey);
|
||||
$template->set('progress', $this->surveyFlowService->getProgress($survey, $question));
|
||||
$template->set('question', $question);
|
||||
$template->set('jumpHints', $this->buildJumpHints($survey, $question));
|
||||
$template->set('surveyForm', $form->createView());
|
||||
$template->set('isComplete', false);
|
||||
$template->set('answers', []);
|
||||
@@ -265,7 +292,10 @@ final class ShowSurveyController extends AbstractFrontendModuleController
|
||||
|
||||
private function resolvePublicAlias(Request $request): string
|
||||
{
|
||||
$candidate = $request->attributes->get('auto_item');
|
||||
// Bei sauberen Pfad-URLs (/seite/<alias>) legt Contao den Wert im Input-Adapter
|
||||
// ab und markiert ihn als Route-Parameter. Input::get liest ihn aus UND markiert
|
||||
// ihn als benutzt – sonst wirft Contao 404 (unbenutzter Route-Parameter).
|
||||
$candidate = $this->getContaoAdapter(Input::class)->get('auto_item');
|
||||
|
||||
if (\is_string($candidate) && '' !== trim($candidate)) {
|
||||
return trim($candidate);
|
||||
|
||||
@@ -8,6 +8,7 @@ use Contao\Config;
|
||||
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
|
||||
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
|
||||
use Contao\CoreBundle\Twig\FragmentTemplate;
|
||||
use Contao\Input;
|
||||
use Contao\ModuleModel;
|
||||
use Mummert\SurveyBundle\Repository\SurveyRepository;
|
||||
use Mummert\SurveyBundle\Service\SurveyResultsViewService;
|
||||
@@ -82,7 +83,9 @@ final class ShowSurveyResultsController extends AbstractFrontendModuleController
|
||||
|
||||
private function resolvePublicAlias(Request $request): string
|
||||
{
|
||||
$candidate = $request->attributes->get('auto_item');
|
||||
// Bei sauberen Pfad-URLs (/seite/<alias>) liegt der Wert im Contao-Input-Adapter;
|
||||
// Input::get liest ihn aus und markiert ihn als benutzt (sonst Contao-404).
|
||||
$candidate = $this->getContaoAdapter(Input::class)->get('auto_item');
|
||||
|
||||
if (\is_string($candidate) && '' !== trim($candidate)) {
|
||||
return trim($candidate);
|
||||
|
||||
@@ -14,7 +14,6 @@ use Contao\Image;
|
||||
use Contao\Input;
|
||||
use Contao\Message;
|
||||
use Contao\PageModel;
|
||||
use Contao\StringUtil;
|
||||
use Contao\System;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
|
||||
@@ -213,54 +212,6 @@ final class SurveyDcaListener
|
||||
return $this->surveyEditorRepository->findMemberChoices();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getCategoryOptions(): array
|
||||
{
|
||||
$rows = $this->connection->fetchAllAssociative('SELECT id, title FROM tl_survey_category ORDER BY sorting ASC, title ASC');
|
||||
$options = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$options[(int) $row['id']] = (string) $row['title'];
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getUsedCategoryFilterOptions(): array
|
||||
{
|
||||
$usedCategoryIds = [];
|
||||
|
||||
foreach ($this->connection->fetchFirstColumn('SELECT category FROM tl_survey') as $rawCategoryValue) {
|
||||
foreach (StringUtil::deserialize($rawCategoryValue, true) as $categoryId) {
|
||||
$categoryId = (int) $categoryId;
|
||||
|
||||
if ($categoryId > 0) {
|
||||
$usedCategoryIds[$categoryId] = $categoryId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ([] === $usedCategoryIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$allCategoryOptions = $this->getCategoryOptions();
|
||||
$options = [];
|
||||
|
||||
foreach (array_values($usedCategoryIds) as $categoryId) {
|
||||
if (isset($allCategoryOptions[$categoryId])) {
|
||||
$options[$categoryId] = $allCategoryOptions[$categoryId];
|
||||
}
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
|
||||
@@ -15,7 +15,9 @@ final class SurveyEditorData
|
||||
{
|
||||
$data = new self();
|
||||
$data->title = (string) $survey->title;
|
||||
$data->description = (string) $survey->description;
|
||||
// Beschreibung ist ein Klartextfeld; evtl. gespeicherte HTML-Tags (z. B. <p>)
|
||||
// entfernen, damit das Textarea reinen Text zeigt.
|
||||
$data->description = trim(strip_tags((string) $survey->description));
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
@@ -199,8 +199,6 @@ final class SurveyRepository
|
||||
return;
|
||||
}
|
||||
|
||||
$categoryIds = $this->normalizeCategoryIds($survey->category);
|
||||
$categoryTitles = $this->fetchCategoryTitles($categoryIds);
|
||||
$assignedMembers = $this->fetchAssignedMembers($surveyId);
|
||||
$memberIds = [];
|
||||
$memberNames = [];
|
||||
@@ -211,8 +209,6 @@ final class SurveyRepository
|
||||
}
|
||||
|
||||
$this->connection->update('tl_survey', [
|
||||
'categoryFilter' => implode(',', $categoryIds),
|
||||
'categorySummary' => implode(', ', $categoryTitles),
|
||||
'templateFilter' => '1' === (string) $survey->isTemplate ? '1' : '0',
|
||||
'templateSummary' => '1' === (string) $survey->isTemplate ? 'Ja' : 'Nein',
|
||||
'assignedMembersFilter' => implode(',', $memberIds),
|
||||
@@ -333,39 +329,6 @@ final class SurveyRepository
|
||||
return false !== $qb->executeQuery()->fetchOne();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $categoryIds
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function fetchCategoryTitles(array $categoryIds): array
|
||||
{
|
||||
if ([] === $categoryIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($categoryIds), '?'));
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
sprintf('SELECT id, title FROM tl_survey_category WHERE id IN (%s)', $placeholders),
|
||||
$categoryIds,
|
||||
);
|
||||
$titlesById = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$titlesById[(int) $row['id']] = (string) $row['title'];
|
||||
}
|
||||
|
||||
$titles = [];
|
||||
|
||||
foreach ($categoryIds as $categoryId) {
|
||||
if (isset($titlesById[$categoryId])) {
|
||||
$titles[] = $titlesById[$categoryId];
|
||||
}
|
||||
}
|
||||
|
||||
return $titles;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id:int,name:string}>
|
||||
*/
|
||||
@@ -410,7 +373,7 @@ final class SurveyRepository
|
||||
);
|
||||
|
||||
return $this->hasListMetadataColumns = !array_diff(
|
||||
['categoryfilter', 'categorysummary', 'templatefilter', 'templatesummary', 'assignedmembersfilter', 'assignedmemberssummary'],
|
||||
['templatefilter', 'templatesummary', 'assignedmembersfilter', 'assignedmemberssummary'],
|
||||
$columnNames,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,16 +24,6 @@ final class SurveySubmissionRepository
|
||||
return $model instanceof SurveySubmissionModel ? $model : null;
|
||||
}
|
||||
|
||||
public function findOpenBySurveyAndToken(int $surveyId, string $token): ?SurveySubmissionModel
|
||||
{
|
||||
$id = $this->connection->fetchOne(
|
||||
'SELECT id FROM tl_survey_submission WHERE survey = ? AND token = ? AND isFinished != 1 ORDER BY id DESC LIMIT 1',
|
||||
[$surveyId, $token],
|
||||
);
|
||||
|
||||
return false === $id ? null : $this->findById((int) $id);
|
||||
}
|
||||
|
||||
public function findBySurveyAndToken(int $surveyId, string $token): ?SurveySubmissionModel
|
||||
{
|
||||
$id = $this->connection->fetchOne(
|
||||
@@ -44,14 +34,6 @@ final class SurveySubmissionRepository
|
||||
return false === $id ? null : $this->findById((int) $id);
|
||||
}
|
||||
|
||||
public function hasFinishedBySurveyAndToken(int $surveyId, string $token): bool
|
||||
{
|
||||
return false !== $this->connection->fetchOne(
|
||||
'SELECT id FROM tl_survey_submission WHERE survey = ? AND token = ? AND isFinished = 1 ORDER BY id DESC LIMIT 1',
|
||||
[$surveyId, $token],
|
||||
);
|
||||
}
|
||||
|
||||
public function create(int $surveyId, string $token, ?int $currentQuestionId): SurveySubmissionModel
|
||||
{
|
||||
$now = time();
|
||||
|
||||
@@ -264,7 +264,6 @@
|
||||
{% elseif question.type == 'range' %}
|
||||
<div class="metric-row">
|
||||
<div class="metric-box">
|
||||
<span class="metric-label">Minimum</span>
|
||||
<span class="metric-label">{{ 'survey.pdf.minimum'|trans({}, 'messages') }}</span>
|
||||
<span class="metric-value">{{ question.minimum is not null ? question.minimum : '-' }}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\SurveyBundle\Security;
|
||||
|
||||
use Contao\FrontendUser;
|
||||
use Mummert\SurveyBundle\Model\SurveyModel;
|
||||
use Mummert\SurveyBundle\Service\SurveyPermissionService;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
final class SurveyEditorVoter extends Voter
|
||||
{
|
||||
public const string EDIT = 'SURVEY_EDIT';
|
||||
|
||||
public function __construct(private readonly SurveyPermissionService $surveyPermissionService)
|
||||
{
|
||||
}
|
||||
|
||||
protected function supports(string $attribute, mixed $subject): bool
|
||||
{
|
||||
return self::EDIT === $attribute && $subject instanceof SurveyModel;
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
|
||||
{
|
||||
$user = $token->getUser();
|
||||
|
||||
if (!$user instanceof FrontendUser || !$subject instanceof SurveyModel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->surveyPermissionService->canEditSurvey($subject, $user);
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,29 @@ final class SurveyFlowService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Beschreibt das Sprungziel einer Ja/Nein-Antwort über Positionsnummer und Label.
|
||||
*
|
||||
* @return array{position:int,label:string}|null
|
||||
*/
|
||||
public function describeJumpTarget(SurveyModel $survey, int $targetQuestionId): ?array
|
||||
{
|
||||
if ($targetQuestionId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($this->getOrderedQuestions($survey) as $index => $question) {
|
||||
if ((int) $question->id === $targetQuestionId) {
|
||||
return [
|
||||
'position' => $index + 1,
|
||||
'label' => trim((string) $question->question),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<SurveyContentModel>
|
||||
*/
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\SurveyBundle\Service;
|
||||
|
||||
use Contao\FrontendUser;
|
||||
use Mummert\SurveyBundle\Model\SurveyModel;
|
||||
use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
|
||||
final class SurveyPermissionService
|
||||
{
|
||||
public function __construct(private readonly SurveyEditorRepository $surveyEditorRepository)
|
||||
{
|
||||
}
|
||||
|
||||
public function canEditSurvey(SurveyModel $survey, FrontendUser $member): bool
|
||||
{
|
||||
return $this->surveyEditorRepository->isEditor((int) $survey->id, (int) $member->id);
|
||||
}
|
||||
|
||||
public function assertEditorAccess(SurveyModel $survey, FrontendUser $member): void
|
||||
{
|
||||
if (!$this->canEditSurvey($survey, $member)) {
|
||||
throw new AccessDeniedHttpException('Sie dürfen diese Umfrage nicht bearbeiten.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,23 +136,19 @@ final class SurveyResultsPdfService
|
||||
private function buildChartLegend(array $questionResult): array
|
||||
{
|
||||
$chart = $questionResult['chart'] ?? null;
|
||||
|
||||
if (!is_array($chart)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$chartType = (string) ($chart['type'] ?? '');
|
||||
$chartType = is_array($chart) ? (string) ($chart['type'] ?? '') : '';
|
||||
$sourceRows = match ($chartType) {
|
||||
'pie' => $questionResult['options'] ?? $questionResult['responseSummary'] ?? [],
|
||||
'line' => $questionResult['distribution'] ?? [],
|
||||
default => [],
|
||||
// Freitext hat kein Diagramm: Antwort-Häufigkeiten wie in der Online-Ansicht zeigen.
|
||||
default => $questionResult['responseSummary'] ?? [],
|
||||
};
|
||||
|
||||
if (!is_array($sourceRows) || [] === $sourceRows) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$dataset = $chart['data']['datasets'][0] ?? [];
|
||||
$dataset = is_array($chart) ? ($chart['data']['datasets'][0] ?? []) : [];
|
||||
$fillColors = match ($chartType) {
|
||||
'pie' => is_array($dataset['backgroundColor'] ?? null) ? $dataset['backgroundColor'] : [],
|
||||
'line' => is_array($dataset['pointBackgroundColor'] ?? null) ? $dataset['pointBackgroundColor'] : [],
|
||||
|
||||
@@ -50,12 +50,7 @@ final class SurveyResultsViewService
|
||||
$results = [];
|
||||
|
||||
foreach ($questions as $question) {
|
||||
$key = $this->buildQuestionResultKey(
|
||||
(int) $question->id,
|
||||
(string) $question->type,
|
||||
(string) $question->question,
|
||||
(string) ($question->description ?? ''),
|
||||
);
|
||||
$key = $this->buildQuestionResultKey((int) $question->id, (string) $question->type);
|
||||
|
||||
$results[$key] = $this->buildResultSkeleton(
|
||||
(int) $question->id,
|
||||
@@ -65,6 +60,7 @@ final class SurveyResultsViewService
|
||||
(int) ($question->sorting ?? 0),
|
||||
);
|
||||
$results[$key]['_choiceOptions'] = $this->getChoiceOptions($question);
|
||||
$results[$key]['_allowMaybe'] = '1' === (string) $question->allowMaybe;
|
||||
}
|
||||
|
||||
foreach ($answers as $answer) {
|
||||
@@ -72,7 +68,7 @@ final class SurveyResultsViewService
|
||||
$questionType = (string) ($answer['questionType'] ?? 'text');
|
||||
$questionLabel = trim((string) ($answer['questionLabel'] ?? '')) ?: 'Frage #'.$questionId;
|
||||
$questionDescription = (string) ($answer['questionDescription'] ?? '');
|
||||
$key = $this->buildQuestionResultKey($questionId, $questionType, $questionLabel, $questionDescription);
|
||||
$key = $this->buildQuestionResultKey($questionId, $questionType);
|
||||
|
||||
if (!isset($results[$key])) {
|
||||
$results[$key] = $this->buildResultSkeleton(
|
||||
@@ -104,9 +100,11 @@ final class SurveyResultsViewService
|
||||
return array_values($results);
|
||||
}
|
||||
|
||||
private function buildQuestionResultKey(int $questionId, string $questionType, string $questionLabel, string $questionDescription): string
|
||||
private function buildQuestionResultKey(int $questionId, string $questionType): string
|
||||
{
|
||||
return implode('|', [$questionId, $questionType, md5($questionLabel), md5($questionDescription)]);
|
||||
// Bewusst nur Frage-ID + Typ: So werden Antwort-Snapshots und die aktuelle
|
||||
// Frage auch dann zusammengeführt, wenn das Label nachträglich geändert wurde.
|
||||
return implode('|', [$questionId, $questionType]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,6 +123,7 @@ final class SurveyResultsViewService
|
||||
'_sorting' => $sorting,
|
||||
'_values' => [],
|
||||
'_choiceOptions' => [],
|
||||
'_allowMaybe' => false,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -146,6 +145,12 @@ final class SurveyResultsViewService
|
||||
case 'yes_no_maybe':
|
||||
$optionCounts = ['Ja' => 0, 'Nein' => 0];
|
||||
|
||||
// Wie beim Excel-Export: Bei erlaubtem "Vielleicht" die Zeile auch dann
|
||||
// zeigen, wenn (noch) niemand "Vielleicht" gewählt hat.
|
||||
if (!empty($result['_allowMaybe'])) {
|
||||
$optionCounts['Vielleicht'] = 0;
|
||||
}
|
||||
|
||||
foreach ($values as $value) {
|
||||
$normalizedValue = mb_strtolower(trim($value));
|
||||
|
||||
@@ -205,7 +210,7 @@ final class SurveyResultsViewService
|
||||
break;
|
||||
}
|
||||
|
||||
unset($result['_sorting'], $result['_values'], $result['_choiceOptions']);
|
||||
unset($result['_sorting'], $result['_values'], $result['_choiceOptions'], $result['_allowMaybe']);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@ 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,
|
||||
@@ -35,7 +41,9 @@ final class SurveySubmissionService
|
||||
if ('' !== $token) {
|
||||
$existing = $this->surveySubmissionRepository->findBySurveyAndToken((int) $survey->id, $token);
|
||||
|
||||
if ($existing instanceof SurveySubmissionModel) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -48,6 +56,21 @@ final class SurveySubmissionService
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user