Files
survey-bundle/src/Service/SurveyResultsViewService.php
T
Jürgen Mummert db7094fd1e 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>
2026-06-14 13:00:05 +02:00

473 lines
17 KiB
PHP

<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Service;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Mummert\SurveyBundle\Model\SurveyModel;
use Mummert\SurveyBundle\Repository\SurveyAnswerRepository;
use Mummert\SurveyBundle\Repository\SurveyQuestionRepository;
final class SurveyResultsViewService
{
public function __construct(
private readonly SurveyQuestionRepository $surveyQuestionRepository,
private readonly SurveyAnswerRepository $surveyAnswerRepository,
private readonly SurveySubmissionService $surveySubmissionService,
) {
}
/**
* @return array{completedSubmissionCount:int,questionResults:list<array<string, mixed>>}
*/
public function buildForSurvey(SurveyModel $survey): array
{
$overview = $this->surveySubmissionService->getSubmissionOverview($survey);
$finishedSubmissions = array_values(array_filter(
$overview,
static fn (array $submission): bool => '1' === (string) ($submission['isFinished'] ?? '')
));
$completedSubmissionCount = count($finishedSubmissions);
$questions = $this->surveyQuestionRepository->findAllBySurvey((int) $survey->id);
$answers = $this->surveyAnswerRepository->findFinishedAnswersBySurvey((int) $survey->id);
return [
'completedSubmissionCount' => $completedSubmissionCount,
'questionResults' => $this->buildQuestionResults($questions, $answers, $completedSubmissionCount),
];
}
/**
* @param list<SurveyContentModel> $questions
* @param list<array{questionId:int,questionType:string,questionLabel:string,questionDescription:string,questionSorting:int,value:string}> $answers
*
* @return list<array<string, mixed>>
*/
private function buildQuestionResults(array $questions, array $answers, int $completedSubmissionCount): array
{
$results = [];
foreach ($questions as $question) {
$key = $this->buildQuestionResultKey((int) $question->id, (string) $question->type);
$results[$key] = $this->buildResultSkeleton(
(int) $question->id,
(string) $question->type,
(string) $question->question,
(string) ($question->description ?? ''),
(int) ($question->sorting ?? 0),
);
$results[$key]['_choiceOptions'] = $this->getChoiceOptions($question);
$results[$key]['_allowMaybe'] = '1' === (string) $question->allowMaybe;
}
foreach ($answers as $answer) {
$questionId = (int) ($answer['questionId'] ?? 0);
$questionType = (string) ($answer['questionType'] ?? 'text');
$questionLabel = trim((string) ($answer['questionLabel'] ?? '')) ?: 'Frage #'.$questionId;
$questionDescription = (string) ($answer['questionDescription'] ?? '');
$key = $this->buildQuestionResultKey($questionId, $questionType);
if (!isset($results[$key])) {
$results[$key] = $this->buildResultSkeleton(
$questionId,
$questionType,
$questionLabel,
$questionDescription,
(int) ($answer['questionSorting'] ?? 0),
);
}
$results[$key]['_values'][] = (string) ($answer['value'] ?? '');
}
foreach ($results as $key => $result) {
$results[$key] = $this->finalizeQuestionResult($result, $completedSubmissionCount);
}
uasort($results, static function (array $left, array $right): int {
$sortingComparison = ((int) ($left['_sorting'] ?? 0)) <=> ((int) ($right['_sorting'] ?? 0));
if (0 !== $sortingComparison) {
return $sortingComparison;
}
return strcasecmp((string) ($left['question'] ?? ''), (string) ($right['question'] ?? ''));
});
return array_values($results);
}
private function buildQuestionResultKey(int $questionId, string $questionType): string
{
// 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]);
}
/**
* @return array<string, mixed>
*/
private function buildResultSkeleton(int $questionId, string $questionType, string $questionLabel, string $questionDescription, int $sorting): array
{
return [
'id' => $questionId,
'question' => $questionLabel,
'description' => $questionDescription,
'type' => $questionType,
'totalAnswers' => 0,
'responseRate' => 0,
'chart' => null,
'_sorting' => $sorting,
'_values' => [],
'_choiceOptions' => [],
'_allowMaybe' => false,
];
}
/**
* @param array<string, mixed> $result
*
* @return array<string, mixed>
*/
private function finalizeQuestionResult(array $result, int $completedSubmissionCount): array
{
$values = $result['_values'] ?? [];
$values = \is_array($values) ? array_values(array_map('strval', $values)) : [];
$totalAnswers = count($values);
$result['totalAnswers'] = $totalAnswers;
$result['responseRate'] = $completedSubmissionCount > 0 ? (int) round(($totalAnswers / $completedSubmissionCount) * 100) : 0;
switch ((string) ($result['type'] ?? 'text')) {
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));
if ('yes' === $normalizedValue) {
++$optionCounts['Ja'];
} elseif ('no' === $normalizedValue) {
++$optionCounts['Nein'];
} elseif ('maybe' === $normalizedValue) {
$optionCounts['Vielleicht'] = ($optionCounts['Vielleicht'] ?? 0) + 1;
}
}
$result['options'] = $this->buildOptionStats($optionCounts);
$result['chart'] = $this->buildPieChartConfig($result['options']);
break;
case 'choice':
$optionCounts = [];
foreach (($result['_choiceOptions'] ?? []) as $choiceOption) {
$optionCounts[(string) $choiceOption] = 0;
}
foreach ($values as $value) {
foreach (array_filter(array_map('trim', explode(' | ', $value)), static fn (string $entry): bool => '' !== $entry) as $selectedOption) {
$optionCounts[$selectedOption] = ($optionCounts[$selectedOption] ?? 0) + 1;
}
}
$result['options'] = $this->buildOptionStats($optionCounts);
$result['chart'] = $this->buildPieChartConfig($result['options']);
break;
case 'range':
$numericValues = array_values(array_map(static fn (string $value): int => (int) $value, $values));
$distribution = [];
foreach ($numericValues as $numericValue) {
$label = (string) $numericValue;
$distribution[$label] = ($distribution[$label] ?? 0) + 1;
}
ksort($distribution, SORT_NATURAL);
$result['minimum'] = [] !== $numericValues ? min($numericValues) : null;
$result['maximum'] = [] !== $numericValues ? max($numericValues) : null;
$result['average'] = [] !== $numericValues ? round(array_sum($numericValues) / count($numericValues), 2) : null;
$result['distribution'] = $this->buildOptionStats($distribution);
$result['chart'] = $this->buildRangeChartConfig($result['distribution']);
break;
case 'text':
default:
$result['responses'] = array_values(array_filter($values, static fn (string $value): bool => '' !== trim($value)));
$result['responseSummary'] = $this->buildTextResponseSummary($result['responses']);
$result['chart'] = null;
break;
}
unset($result['_sorting'], $result['_values'], $result['_choiceOptions'], $result['_allowMaybe']);
return $result;
}
/**
* @return list<string>
*/
private function getChoiceOptions(SurveyContentModel $question): array
{
$options = [];
for ($index = 1; $index <= 10; ++$index) {
$option = trim((string) $question->{'answerOption'.$index});
if ('' !== $option) {
$options[] = $option;
}
}
return $options;
}
/**
* @param array<string, int> $optionCounts
*
* @return list<array{label:string,count:int,percentage:int}>
*/
private function buildOptionStats(array $optionCounts): array
{
$total = array_sum($optionCounts);
$options = [];
foreach ($optionCounts as $label => $count) {
$options[] = [
'label' => $label,
'count' => $count,
'percentage' => $total > 0 ? (int) round(($count / $total) * 100) : 0,
];
}
return $options;
}
/**
* @param list<array{label:string,count:int,percentage:int}> $options
*
* @return array<string, mixed>|null
*/
private function buildPieChartConfig(array $options): ?array
{
if ([] === $options) {
return null;
}
$palette = $this->buildChartPalette(count($options));
return [
'type' => 'pie',
'data' => [
'labels' => array_column($options, 'label'),
'datasets' => [[
'data' => array_column($options, 'count'),
'backgroundColor' => array_column($palette, 'background'),
'borderColor' => array_column($palette, 'border'),
'borderWidth' => 2,
'hoverOffset' => 18,
]],
],
'options' => [
'responsive' => true,
'maintainAspectRatio' => false,
'plugins' => [
'legend' => [
'position' => 'bottom',
'labels' => [
'usePointStyle' => true,
'boxWidth' => 12,
'padding' => 18,
'color' => '#555E68',
'font' => [
'family' => 'PT Sans Narrow',
'size' => 12,
'weight' => '600',
],
],
],
'tooltip' => [
'backgroundColor' => 'rgba(34, 34, 34, 0.92)',
'titleFont' => [
'family' => 'Blogger Sans',
'size' => 14,
],
'bodyFont' => [
'family' => 'PT Sans Narrow',
'size' => 12,
],
],
],
],
];
}
/**
* @param list<array{label:string,count:int,percentage:int}> $distribution
*
* @return array<string, mixed>|null
*/
private function buildRangeChartConfig(array $distribution): ?array
{
if ([] === $distribution) {
return null;
}
$pointStyles = ['circle', 'rectRounded', 'triangle', 'star', 'rectRot', 'crossRot'];
$palette = $this->buildChartPalette(count($distribution));
return [
'type' => 'line',
'data' => [
'labels' => array_column($distribution, 'label'),
'datasets' => [[
'label' => 'Antworten',
'data' => array_column($distribution, 'count'),
'borderColor' => '#0064AD',
'backgroundColor' => 'rgba(236, 124, 50, 0.12)',
'pointBackgroundColor' => array_column($palette, 'border'),
'pointBorderColor' => '#ffffff',
'pointBorderWidth' => 3,
'pointHoverBorderWidth' => 3,
'pointRadius' => 7,
'pointHoverRadius' => 9,
'pointStyle' => array_map(
static fn (int $index): string => $pointStyles[$index % count($pointStyles)],
array_keys($distribution)
),
'fill' => true,
'tension' => 0.32,
]],
],
'options' => [
'responsive' => true,
'maintainAspectRatio' => false,
'plugins' => [
'legend' => [
'display' => false,
],
'tooltip' => [
'backgroundColor' => 'rgba(34, 34, 34, 0.92)',
'titleFont' => [
'family' => 'Blogger Sans',
'size' => 14,
],
'bodyFont' => [
'family' => 'PT Sans Narrow',
'size' => 12,
],
],
],
'scales' => [
'x' => [
'grid' => [
'display' => false,
],
'ticks' => [
'color' => '#555E68',
'font' => [
'family' => 'PT Sans Narrow',
'weight' => '600',
],
],
],
'y' => [
'beginAtZero' => true,
'ticks' => [
'precision' => 0,
'color' => '#555E68',
'font' => [
'family' => 'PT Sans Narrow',
'weight' => '600',
],
],
'grid' => [
'color' => 'rgba(162, 168, 180, 0.22)',
],
],
],
],
];
}
/**
* @param list<string> $responses
*
* @return list<array{label:string,count:int,percentage:int}>
*/
private function buildTextResponseSummary(array $responses): array
{
if ([] === $responses) {
return [];
}
$counts = [];
foreach ($responses as $response) {
$label = trim($response);
if ('' === $label) {
continue;
}
$counts[$label] = ($counts[$label] ?? 0) + 1;
}
arsort($counts);
$summary = [];
$remainingCount = 0;
$index = 0;
foreach ($counts as $label => $count) {
if ($index < 5) {
$summary[$label] = $count;
} else {
$remainingCount += $count;
}
++$index;
}
if ($remainingCount > 0) {
$summary['Weitere Antworten'] = $remainingCount;
}
return $this->buildOptionStats($summary);
}
/**
* @return list<array{background:string,border:string}>
*/
private function buildChartPalette(int $count): array
{
$palette = [
['background' => 'rgba(0, 100, 173, 0.16)', 'border' => '#0064AD'],
['background' => 'rgba(236, 124, 50, 0.24)', 'border' => '#EC7C32'],
['background' => 'rgba(157, 82, 118, 0.18)', 'border' => '#9D5276'],
['background' => 'rgba(0, 174, 151, 0.22)', 'border' => '#00AE97'],
['background' => 'rgba(252, 228, 214, 0.9)', 'border' => '#EC7C32'],
['background' => 'rgba(162, 168, 180, 0.28)', 'border' => '#555E68'],
['background' => 'rgba(233, 233, 235, 0.9)', 'border' => '#A2A8B4'],
['background' => 'rgba(34, 34, 34, 0.12)', 'border' => '#222222'],
];
$colors = [];
for ($index = 0; $index < max(1, $count); ++$index) {
$colors[] = $palette[$index % count($palette)];
}
return $colors;
}
}