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:
@@ -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