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:
Jürgen Mummert
2026-06-14 13:00:05 +02:00
parent 1b16675ae9
commit db7094fd1e
29 changed files with 230 additions and 456 deletions
+15 -10
View File
@@ -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;
}