Initial survey bundle import

This commit is contained in:
Jürgen Mummert
2026-05-17 17:31:28 +02:00
commit b8475e3a57
84 changed files with 7157 additions and 0 deletions
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Service;
use Contao\StringUtil;
use Doctrine\DBAL\Connection;
use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
use Mummert\SurveyBundle\Repository\SurveyRepository;
final class SurveyCategoryAssignmentService
{
public function __construct(
private readonly Connection $connection,
private readonly SurveyRepository $surveyRepository,
private readonly SurveyEditorRepository $surveyEditorRepository,
) {
}
/**
* @return list<int>
*/
public function getMemberCategoryIds(int $memberId): array
{
$value = $this->connection->fetchOne('SELECT surveyCategories FROM tl_member WHERE id = ?', [$memberId]);
return $this->normalizeCategoryIds(false === $value ? [] : $value);
}
public function applyMemberCategoriesToSurvey(int $memberId, int $surveyId): void
{
if ($memberId <= 0 || $surveyId <= 0) {
return;
}
$this->syncSurvey($surveyId, [
$memberId => $this->getMemberCategoryIds($memberId),
]);
}
/**
* @param list<int>|null $categoryIds
*/
public function syncAssignedSurveys(int $memberId, ?array $categoryIds = null): void
{
if ($memberId <= 0) {
return;
}
$normalizedCategoryIds = $this->normalizeCategoryIds($categoryIds ?? $this->getMemberCategoryIds($memberId));
foreach ($this->surveyEditorRepository->findSurveyIdsByMember($memberId) as $surveyId) {
$this->syncSurvey($surveyId, [
$memberId => $normalizedCategoryIds,
]);
}
}
public function syncSurvey(int $surveyId, array $memberCategoryOverrides = []): void
{
if ($surveyId <= 0) {
return;
}
$categoryIds = [];
foreach ($this->surveyEditorRepository->findMemberIdsBySurvey($surveyId) as $memberId) {
$memberCategoryIds = $memberCategoryOverrides[$memberId] ?? $this->getMemberCategoryIds($memberId);
foreach ($this->normalizeCategoryIds($memberCategoryIds) as $categoryId) {
$categoryIds[] = $categoryId;
}
}
$this->surveyRepository->updateCategories($surveyId, $this->normalizeCategoryIds($categoryIds));
}
public function syncSurveyWithoutMember(int $surveyId, int $memberId): void
{
if ($surveyId <= 0) {
return;
}
$categoryIds = [];
foreach ($this->surveyEditorRepository->findMemberIdsBySurvey($surveyId) as $assignedMemberId) {
if ($assignedMemberId === $memberId) {
continue;
}
foreach ($this->getMemberCategoryIds($assignedMemberId) as $categoryId) {
$categoryIds[] = $categoryId;
}
}
$this->surveyRepository->updateCategories($surveyId, $this->normalizeCategoryIds($categoryIds));
}
/**
* @return list<int>
*/
public function normalizeCategoryIds(mixed $value): array
{
$categoryIds = is_array($value) ? $value : StringUtil::deserialize($value, true);
$categoryIds = array_values(array_unique(array_filter(array_map('intval', $categoryIds), static fn (int $id): bool => $id > 0)));
sort($categoryIds);
return $categoryIds;
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Service;
use Mummert\SurveyBundle\Form\Model\SurveyConditionData;
use Mummert\SurveyBundle\Form\Model\SurveyEditorData;
use Mummert\SurveyBundle\Form\Model\SurveyQuestionData;
use Mummert\SurveyBundle\Model\SurveyModel;
use Mummert\SurveyBundle\Repository\SurveyConditionRepository;
use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
use Mummert\SurveyBundle\Repository\SurveyQuestionRepository;
use Mummert\SurveyBundle\Repository\SurveyRepository;
final class SurveyEditorService
{
public function __construct(
private readonly SurveyRepository $surveyRepository,
private readonly SurveyQuestionRepository $surveyQuestionRepository,
private readonly SurveyConditionRepository $surveyConditionRepository,
private readonly SurveyEditorRepository $surveyEditorRepository,
private readonly SurveyCategoryAssignmentService $surveyCategoryAssignmentService,
) {
}
public function createSurvey(int $memberId, SurveyEditorData $data): SurveyModel
{
$payload = $data->toArray();
$payload['category'] = $this->surveyCategoryAssignmentService->getMemberCategoryIds($memberId);
$survey = $this->surveyRepository->create($memberId, $payload);
$this->surveyEditorRepository->assignEditor((int) $survey->id, $memberId);
$this->surveyRepository->refreshListMetadata((int) $survey->id);
return $survey;
}
public function updateSurvey(SurveyModel $survey, int $memberId, SurveyEditorData $data): void
{
$this->surveyRepository->update($survey, $memberId, $data->toArray());
}
public function saveQuestion(SurveyModel $survey, SurveyQuestionData $data, int $questionId = 0): void
{
$this->assertUnlocked($survey);
if ($questionId > 0) {
$question = $this->surveyQuestionRepository->findByIdForSurvey((int) $survey->id, $questionId);
if (null === $question) {
throw new \RuntimeException('Die Frage wurde nicht gefunden.');
}
$this->surveyQuestionRepository->update($question, $data->toArray());
$this->surveyRepository->touch((int) $survey->id);
return;
}
$this->surveyQuestionRepository->create((int) $survey->id, $data->toArray());
$this->surveyRepository->touch((int) $survey->id);
}
public function deleteQuestion(SurveyModel $survey, int $questionId): void
{
$this->assertUnlocked($survey);
$this->surveyQuestionRepository->delete((int) $survey->id, $questionId);
$this->surveyRepository->touch((int) $survey->id);
}
public function saveCondition(SurveyModel $survey, SurveyConditionData $data, int $conditionId = 0): void
{
$this->assertUnlocked($survey);
if ($conditionId > 0) {
$condition = $this->surveyConditionRepository->findById($conditionId);
if (null === $condition || (int) $condition->pid !== (int) $survey->id) {
throw new \RuntimeException('Die Bedingung wurde nicht gefunden.');
}
$this->surveyConditionRepository->update($condition, $data->toArray());
$this->surveyRepository->touch((int) $survey->id);
return;
}
$this->surveyConditionRepository->create((int) $survey->id, $data->toArray());
$this->surveyRepository->touch((int) $survey->id);
}
public function deleteCondition(SurveyModel $survey, int $conditionId): void
{
$this->assertUnlocked($survey);
$this->surveyConditionRepository->delete((int) $survey->id, $conditionId);
$this->surveyRepository->touch((int) $survey->id);
}
private function assertUnlocked(SurveyModel $survey): void
{
if ('1' === (string) $survey->isLocked) {
throw new \RuntimeException('Die Umfrage ist gesperrt, weil bereits Antworten existieren.');
}
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Service;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Mummert\SurveyBundle\Model\SurveyModel;
use Mummert\SurveyBundle\Repository\SurveyConditionRepository;
use Mummert\SurveyBundle\Repository\SurveyQuestionRepository;
final class SurveyEngine
{
public function __construct(
private readonly SurveyConditionRepository $surveyConditionRepository,
private readonly SurveyQuestionRepository $surveyQuestionRepository,
) {
}
public function resolveConditionalTarget(SurveyModel $survey, SurveyContentModel $sourceQuestion, string $normalizedAnswer): ?SurveyContentModel
{
$condition = $this->surveyConditionRepository->findMatchingCondition(
(int) $survey->id,
(int) $sourceQuestion->id,
mb_strtolower(trim($normalizedAnswer)),
);
if (null === $condition) {
return null;
}
return $this->surveyQuestionRepository->findByIdForSurvey(
(int) $survey->id,
(int) $condition->targetQuestion,
false,
);
}
}
+134
View File
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Service;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Mummert\SurveyBundle\Model\SurveyModel;
use Mummert\SurveyBundle\Model\SurveySubmissionModel;
use Mummert\SurveyBundle\Repository\SurveyRepository;
use Mummert\SurveyBundle\Repository\SurveyQuestionRepository;
final class SurveyFlowService
{
public function __construct(
private readonly SurveyEngine $surveyEngine,
private readonly SurveyRepository $surveyRepository,
private readonly SurveyQuestionRepository $surveyQuestionRepository,
) {
}
public function resolveCurrentQuestion(SurveyModel $survey, SurveySubmissionModel $submission): ?SurveyContentModel
{
if ('1' === (string) $submission->isFinished) {
return null;
}
$questions = $this->getOrderedQuestions($survey);
$currentQuestionId = (int) $submission->currentQuestion;
if ($currentQuestionId > 0) {
foreach ($questions as $question) {
if ((int) $question->id === $currentQuestionId) {
return $question;
}
}
}
return $questions[0] ?? null;
}
public function determineNextQuestion(SurveyModel $survey, SurveyContentModel $currentQuestion, string $normalizedAnswer): ?SurveyContentModel
{
$questions = $this->getOrderedQuestions($survey);
$configuredJumpTarget = $this->resolveYesNoJumpTarget($currentQuestion, $questions, $normalizedAnswer);
if ($configuredJumpTarget instanceof SurveyContentModel) {
return $configuredJumpTarget;
}
$conditionalTarget = $this->surveyEngine->resolveConditionalTarget($survey, $currentQuestion, $normalizedAnswer);
if ($conditionalTarget instanceof SurveyContentModel) {
return $conditionalTarget;
}
foreach ($questions as $index => $question) {
if ((int) $question->id === (int) $currentQuestion->id) {
return $questions[$index + 1] ?? null;
}
}
return null;
}
/**
* @return array{current:int,total:int,percentage:int}
*/
public function getProgress(SurveyModel $survey, ?SurveyContentModel $currentQuestion): array
{
$questions = $this->getOrderedQuestions($survey);
$total = max(1, count($questions));
$current = 1;
if ($currentQuestion instanceof SurveyContentModel) {
foreach ($questions as $index => $question) {
if ((int) $question->id === (int) $currentQuestion->id) {
$current = $index + 1;
break;
}
}
} else {
$current = $total;
}
return [
'current' => $current,
'total' => $total,
'percentage' => (int) round(($current / $total) * 100),
];
}
/**
* @return list<SurveyContentModel>
*/
private function getOrderedQuestions(SurveyModel $survey): array
{
$surveyId = (int) $survey->id;
if ($this->surveyQuestionRepository->countPublishedBySurvey($surveyId) > 0) {
return $this->surveyQuestionRepository->findAllBySurvey($surveyId, true);
}
return $this->surveyQuestionRepository->findAllBySurvey($surveyId);
}
/**
* @param list<SurveyContentModel> $questions
*/
private function resolveYesNoJumpTarget(SurveyContentModel $currentQuestion, array $questions, string $normalizedAnswer): ?SurveyContentModel
{
if ('yes_no_maybe' !== (string) $currentQuestion->type) {
return null;
}
$targetId = match ($normalizedAnswer) {
'yes' => (int) ($currentQuestion->jumpOnYes ?? 0),
'no' => (int) ($currentQuestion->jumpOnNo ?? 0),
default => 0,
};
if ($targetId <= 0) {
return null;
}
foreach ($questions as $question) {
if ((int) $question->id === $targetId) {
return $question;
}
}
return null;
}
}
+29
View File
@@ -0,0 +1,29 @@
<?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 duerfen diese Umfrage nicht bearbeiten.');
}
}
}
+242
View File
@@ -0,0 +1,242 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Service;
use Contao\StringUtil;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Mummert\SurveyBundle\Model\SurveyModel;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use Mummert\SurveyBundle\Repository\SurveyAnswerRepository;
use Mummert\SurveyBundle\Repository\SurveyQuestionRepository;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
final class SurveyResultsExportService
{
public function __construct(
private readonly SurveyQuestionRepository $surveyQuestionRepository,
private readonly SurveyAnswerRepository $surveyAnswerRepository,
) {
}
/**
* @param list<array<string, mixed>> $finishedSubmissions
*/
public function createExcelExportResponse(SurveyModel $survey, array $finishedSubmissions): Response
{
[$headers, $columns] = $this->buildExportColumns($this->surveyQuestionRepository->findAllBySurvey((int) $survey->id));
$rows = [
[(string) $survey->title],
$headers,
];
foreach ($finishedSubmissions as $submission) {
$answers = $this->surveyAnswerRepository->findAnswersBySubmission((int) $submission['id']);
$answersByQuestion = [];
foreach ($answers as $answer) {
$answersByQuestion[(int) ($answer['questionId'] ?? 0)] = (string) ($answer['value'] ?? '');
}
$row = [];
foreach ($columns as $column) {
$questionId = (int) $column['questionId'];
$value = $answersByQuestion[$questionId] ?? '';
switch ((string) $column['kind']) {
case 'option':
$selectedValues = array_filter(array_map('trim', explode(' | ', $value)), static fn (string $entry): bool => '' !== $entry);
$row[] = in_array((string) $column['value'], $selectedValues, true) ? 1 : '';
break;
case 'range':
case 'text':
default:
$row[] = $value;
break;
}
}
$rows[] = $row;
}
$response = new Response($this->buildSpreadsheetContent($rows));
$disposition = $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
$this->buildExportFilename((string) $survey->title)
);
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', $disposition);
$response->headers->set('Cache-Control', 'private, no-store, no-cache, must-revalidate');
$response->headers->set('Pragma', 'no-cache');
$response->headers->set('X-Content-Type-Options', 'nosniff');
return $response;
}
/**
* @param list<SurveyContentModel> $questions
*
* @return array{0:list<string>,1:list<array{questionId:int,kind:string,value:string}>}
*/
private function buildExportColumns(array $questions): array
{
$headers = [];
$columns = [];
foreach (array_values($questions) as $index => $question) {
$questionNumber = $index + 1;
$prefix = 'F'.$questionNumber;
switch ((string) $question->type) {
case 'yes_no_maybe':
foreach ($this->getYesNoMaybeLabels($question) as $label => $value) {
$headers[] = $prefix.' '.$label;
$columns[] = [
'questionId' => (int) $question->id,
'kind' => 'option',
'value' => $value,
];
}
break;
case 'choice':
foreach ($this->getChoiceLabels($question) as $label) {
$headers[] = $prefix.' '.$label;
$columns[] = [
'questionId' => (int) $question->id,
'kind' => 'option',
'value' => $label,
];
}
break;
case 'range':
$headers[] = $prefix.' Wert';
$columns[] = [
'questionId' => (int) $question->id,
'kind' => 'range',
'value' => '',
];
break;
case 'text':
default:
$headers[] = $prefix.' Text';
$columns[] = [
'questionId' => (int) $question->id,
'kind' => 'text',
'value' => '',
];
break;
}
}
return [$headers, $columns];
}
/**
* @return array<string, string>
*/
private function getYesNoMaybeLabels(SurveyContentModel $question): array
{
$labels = [
'ja' => 'yes',
'nein' => 'no',
];
if ('1' === (string) $question->allowMaybe) {
$labels['vielleicht'] = 'maybe';
}
return $labels;
}
/**
* @return list<string>
*/
private function getChoiceLabels(SurveyContentModel $question): array
{
$labels = [];
for ($index = 1; $index <= 10; ++$index) {
$option = trim((string) $question->{'answerOption'.$index});
if ('' !== $option) {
$labels[] = $option;
}
}
return $labels;
}
/**
* @param list<list<int|string|float>> $rows
*/
private function buildSpreadsheetContent(array $rows): string
{
$spreadsheet = new Spreadsheet();
$worksheet = $spreadsheet->getActiveSheet();
$worksheet->setTitle('Ergebnisse');
foreach ($rows as $rowIndex => $row) {
foreach ($row as $columnIndex => $cellValue) {
$coordinate = Coordinate::stringFromColumnIndex($columnIndex + 1).($rowIndex + 1);
if (is_int($cellValue) || is_float($cellValue)) {
$worksheet->setCellValue($coordinate, $cellValue);
continue;
}
$worksheet->setCellValueExplicit($coordinate, (string) $cellValue, DataType::TYPE_STRING);
}
}
$lastColumn = Coordinate::stringFromColumnIndex(max(1, count($rows[1] ?? $rows[0] ?? [])));
$worksheet->getStyle('A1')->getFont()->setBold(true)->setSize(14);
$worksheet->getStyle('A2:'.$lastColumn.'2')->getFont()->setBold(true);
$worksheet->freezePane('A3');
for ($column = 1; $column <= max(1, count($rows[1] ?? $rows[0] ?? [])); ++$column) {
$worksheet->getColumnDimension(Coordinate::stringFromColumnIndex($column))->setAutoSize(true);
}
$writer = new Xlsx($spreadsheet);
ob_start();
try {
$writer->save('php://output');
$content = ob_get_clean();
} catch (\Throwable $throwable) {
ob_end_clean();
$spreadsheet->disconnectWorksheets();
throw $throwable;
}
$spreadsheet->disconnectWorksheets();
return false === $content ? '' : $content;
}
private function buildExportFilename(string $title): string
{
$baseName = StringUtil::stripInsertTags($title);
$baseName = preg_replace('/[^A-Za-z0-9]+/', '-', $baseName ?? '') ?? 'umfrage-ergebnisse';
$baseName = trim($baseName, '-');
if ('' === $baseName) {
$baseName = 'umfrage-ergebnisse';
}
return $baseName.'.xlsx';
}
}
+409
View File
@@ -0,0 +1,409 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Service;
use Contao\Config;
use Mummert\SurveyBundle\Model\SurveyModel;
use Symfony\Component\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadGatewayHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Mime\Part\DataPart;
use Symfony\Component\Mime\Part\Multipart\FormDataPart;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Twig\Environment;
final class SurveyResultsPdfService
{
public function __construct(
private readonly Environment $twig,
private readonly HttpClientInterface $httpClient,
) {
}
public function isConfigured(): bool
{
return null !== $this->getGotenbergBaseUrl();
}
/**
* @param list<array<string, mixed>> $questionResults
*/
public function createPdfExportResponse(SurveyModel $survey, int $completedSubmissionCount, array $questionResults): Response
{
$gotenbergBaseUrl = $this->getGotenbergBaseUrl();
if (null === $gotenbergBaseUrl) {
throw new BadRequestHttpException('Es wurde keine Gotenberg-URL in den Einstellungen hinterlegt.');
}
$html = $this->renderTemplate(
$survey,
$completedSubmissionCount,
$this->prepareQuestionResults($questionResults),
);
$filename = $this->buildFilename((string) $survey->title);
$pdfContent = $this->requestPdfFromGotenberg($gotenbergBaseUrl, $html, $filename);
return new Response(
$pdfContent,
Response::HTTP_OK,
[
'Content-Type' => 'application/pdf',
'Content-Disposition' => sprintf('attachment; filename="%s"', $filename),
],
);
}
private function getGotenbergBaseUrl(): ?string
{
$url = trim((string) Config::get('surveyGotenbergUrl'));
return '' !== $url ? rtrim($url, '/') : null;
}
private function requestPdfFromGotenberg(string $gotenbergBaseUrl, string $html, string $filename): string
{
$formData = new FormDataPart([
'files' => new DataPart($html, 'index.html', 'text/html'),
'paperWidth' => '8.27',
'paperHeight' => '11.7',
'marginTop' => '0.5',
'marginBottom' => '0.5',
'marginLeft' => '0.5',
'marginRight' => '0.5',
'emulatedMediaType' => 'screen',
'printBackground' => 'true',
'failOnConsoleExceptions' => 'true',
'failOnResourceLoadingFailed' => 'true',
]);
$headers = $formData->getPreparedHeaders()->toArray();
$headers['Gotenberg-Output-Filename'] = pathinfo($filename, PATHINFO_FILENAME);
try {
$response = $this->httpClient->request('POST', $gotenbergBaseUrl.'/forms/chromium/convert/html', [
'headers' => $headers,
'body' => $formData->bodyToIterable(),
'timeout' => 60,
]);
} catch (TransportExceptionInterface $exception) {
throw new BadGatewayHttpException('Die Gotenberg-Instanz konnte nicht erreicht werden.', $exception);
}
$statusCode = $response->getStatusCode();
$content = $response->getContent(false);
if (Response::HTTP_OK !== $statusCode) {
$message = trim(strip_tags(substr($content, 0, 500)));
throw new BadGatewayHttpException(sprintf(
'Die Gotenberg-Instanz hat den PDF-Export mit HTTP %d abgelehnt%s',
$statusCode,
'' !== $message ? ': '.$message : '.'
));
}
return $content;
}
/**
* @param list<array<string, mixed>> $questionResults
*
* @return list<array<string, mixed>>
*/
private function prepareQuestionResults(array $questionResults): array
{
$preparedResults = [];
foreach ($questionResults as $questionResult) {
$questionResult['pdfChartType'] = (string) (($questionResult['chart']['type'] ?? '') ?: '');
$questionResult['pdfChartDataUri'] = $this->buildChartDataUri($questionResult);
$questionResult['pdfChartLegend'] = $this->buildChartLegend($questionResult);
$preparedResults[] = $questionResult;
}
return $preparedResults;
}
/**
* @param array<string, mixed> $questionResult
*
* @return list<array{label:string,fillColor:string,borderColor:string,count:int,percentage:int}>
*/
private function buildChartLegend(array $questionResult): array
{
$chart = $questionResult['chart'] ?? null;
if (!is_array($chart)) {
return [];
}
$chartType = (string) ($chart['type'] ?? '');
$sourceRows = match ($chartType) {
'pie' => $questionResult['options'] ?? $questionResult['responseSummary'] ?? [],
'line' => $questionResult['distribution'] ?? [],
default => [],
};
if (!is_array($sourceRows) || [] === $sourceRows) {
return [];
}
$dataset = $chart['data']['datasets'][0] ?? [];
$fillColors = match ($chartType) {
'pie' => is_array($dataset['backgroundColor'] ?? null) ? $dataset['backgroundColor'] : [],
'line' => is_array($dataset['pointBackgroundColor'] ?? null) ? $dataset['pointBackgroundColor'] : [],
default => [],
};
$borderColors = match ($chartType) {
'pie' => is_array($dataset['borderColor'] ?? null) ? $dataset['borderColor'] : [],
'line' => is_array($dataset['pointBackgroundColor'] ?? null) ? $dataset['pointBackgroundColor'] : [],
default => [],
};
$legend = [];
foreach (array_values($sourceRows) as $index => $row) {
if (!is_array($row)) {
continue;
}
$legend[] = [
'label' => (string) ($row['label'] ?? ''),
'fillColor' => (string) ($fillColors[$index] ?? '#D3E4F1'),
'borderColor' => (string) ($borderColors[$index] ?? '#0064AD'),
'count' => (int) ($row['count'] ?? 0),
'percentage' => (int) ($row['percentage'] ?? 0),
];
}
return $legend;
}
private function renderTemplate(SurveyModel $survey, int $completedSubmissionCount, array $questionResults): string
{
$templatePath = __DIR__.'/../Resources/views/pdf/survey_results.html.twig';
$templateContent = file_get_contents($templatePath);
if (false === $templateContent) {
throw new \RuntimeException('Die PDF-Template-Datei konnte nicht gelesen werden: '.$templatePath);
}
return $this->twig->createTemplate($templateContent)->render([
'survey' => $survey,
'completedSubmissionCount' => $completedSubmissionCount,
'questionResults' => $questionResults,
'exportedAt' => (new \DateTimeImmutable())->format('d.m.Y H:i'),
]);
}
/**
* @param array<string, mixed> $questionResult
*/
private function buildChartDataUri(array $questionResult): ?string
{
$chart = $questionResult['chart'] ?? null;
if (!is_array($chart)) {
return null;
}
$svg = match ((string) ($chart['type'] ?? '')) {
'pie' => $this->buildPieChartSvg($questionResult, $chart),
'line' => $this->buildLineChartSvg($questionResult, $chart),
default => null,
};
if (null === $svg) {
return null;
}
return 'data:image/svg+xml;base64,'.base64_encode($svg);
}
/**
* @param array<string, mixed> $questionResult
* @param array<string, mixed> $chart
*/
private function buildPieChartSvg(array $questionResult, array $chart): ?string
{
$options = $questionResult['options'] ?? $questionResult['responseSummary'] ?? [];
if (!is_array($options) || [] === $options) {
return $this->buildEmptyChartSvg('Noch keine Daten');
}
$dataset = $chart['data']['datasets'][0] ?? null;
$fillColors = is_array($dataset['backgroundColor'] ?? null) ? $dataset['backgroundColor'] : [];
$borderColors = is_array($dataset['borderColor'] ?? null) ? $dataset['borderColor'] : [];
$counts = array_map(static fn (array $option): int => (int) ($option['count'] ?? 0), $options);
$total = array_sum($counts);
if ($total <= 0) {
return $this->buildEmptyChartSvg('Noch keine Daten');
}
$centerX = 140.0;
$centerY = 140.0;
$radius = 88.0;
$startAngle = -90.0;
$segments = [];
foreach ($options as $index => $option) {
$count = (int) ($option['count'] ?? 0);
if ($count <= 0) {
continue;
}
$sweepAngle = ($count / $total) * 360.0;
$endAngle = $startAngle + $sweepAngle;
$largeArcFlag = $sweepAngle > 180.0 ? 1 : 0;
$startPoint = $this->polarToCartesian($centerX, $centerY, $radius, $startAngle);
$endPoint = $this->polarToCartesian($centerX, $centerY, $radius, $endAngle);
$fill = (string) ($fillColors[$index] ?? '#D3E4F1');
$stroke = (string) ($borderColors[$index] ?? '#0064AD');
$segments[] = sprintf(
'<path d="M %.2f %.2f L %.2f %.2f A %.2f %.2f 0 %d 1 %.2f %.2f Z" fill="%s" stroke="%s" stroke-width="2" />',
$centerX,
$centerY,
$startPoint['x'],
$startPoint['y'],
$radius,
$radius,
$largeArcFlag,
$endPoint['x'],
$endPoint['y'],
$this->escapeXml($fill),
$this->escapeXml($stroke),
);
$startAngle = $endAngle;
}
return sprintf(
'<svg xmlns="http://www.w3.org/2000/svg" width="280" height="280" viewBox="0 0 280 280" role="img" aria-label="Kreisdiagramm"><rect width="280" height="280" rx="24" fill="#ffffff"/><circle cx="140" cy="140" r="108" fill="#f6f8fb"/><g>%s</g><circle cx="140" cy="140" r="26" fill="#ffffff"/><text x="140" y="146" text-anchor="middle" font-size="18" font-family="DejaVu Sans" fill="#222222">%d</text></svg>',
implode('', $segments),
$total,
);
}
/**
* @param array<string, mixed> $questionResult
* @param array<string, mixed> $chart
*/
private function buildLineChartSvg(array $questionResult, array $chart): ?string
{
$distribution = $questionResult['distribution'] ?? [];
if (!is_array($distribution) || [] === $distribution) {
return $this->buildEmptyChartSvg('Noch keine Bewertungen');
}
$labels = array_map(static fn (array $row): string => (string) ($row['label'] ?? ''), $distribution);
$values = array_map(static fn (array $row): int => (int) ($row['count'] ?? 0), $distribution);
$maxValue = max(1, max($values));
$width = 560.0;
$height = 280.0;
$paddingLeft = 58.0;
$paddingRight = 22.0;
$paddingTop = 24.0;
$paddingBottom = 44.0;
$plotWidth = $width - $paddingLeft - $paddingRight;
$plotHeight = $height - $paddingTop - $paddingBottom;
$steps = max(1, count($values) - 1);
$dataset = is_array($chart['data']['datasets'][0] ?? null) ? $chart['data']['datasets'][0] : [];
$lineColor = (string) ($dataset['borderColor'] ?? '#0064AD');
$areaFill = (string) ($dataset['backgroundColor'] ?? 'rgba(236, 124, 50, 0.12)');
$pointColors = is_array($chart['data']['datasets'][0]['pointBackgroundColor'] ?? null) ? $chart['data']['datasets'][0]['pointBackgroundColor'] : [];
$polylinePoints = [];
$areaPolylinePoints = [];
$pointCircles = [];
$xLabels = [];
$yGrid = [];
for ($tick = 0; $tick <= $maxValue; ++$tick) {
$ratio = $maxValue > 0 ? $tick / $maxValue : 0;
$y = $paddingTop + $plotHeight - ($ratio * $plotHeight);
$yGrid[] = sprintf('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#d7dee8" stroke-width="1" />', $paddingLeft, $y, $width - $paddingRight, $y);
$yGrid[] = sprintf('<text x="%.2f" y="%.2f" text-anchor="end" font-size="11" font-family="DejaVu Sans" fill="#555e68">%d</text>', $paddingLeft - 8, $y + 4, $tick);
}
foreach ($values as $index => $value) {
$x = $paddingLeft + ($plotWidth * ($index / $steps));
$y = $paddingTop + $plotHeight - (($value / $maxValue) * $plotHeight);
$polylinePoints[] = sprintf('%.2f,%.2f', $x, $y);
$areaPolylinePoints[] = sprintf('%.2f,%.2f', $x, $y);
$pointCircles[] = sprintf('<circle cx="%.2f" cy="%.2f" r="5.5" fill="%s" stroke="#ffffff" stroke-width="2" />', $x, $y, $this->escapeXml((string) ($pointColors[$index] ?? '#EC7C32')));
$xLabels[] = sprintf('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="11" font-family="DejaVu Sans" fill="#555e68">%s</text>', $x, $height - 16, $this->escapeXml($labels[$index]));
}
$areaPoints = sprintf(
'%.2f,%.2f %s %.2f,%.2f',
$paddingLeft,
$paddingTop + $plotHeight,
implode(' ', $areaPolylinePoints),
$width - $paddingRight,
$paddingTop + $plotHeight,
);
return sprintf(
'<svg xmlns="http://www.w3.org/2000/svg" width="560" height="280" viewBox="0 0 560 280" role="img" aria-label="Liniendiagramm"><rect width="560" height="280" rx="22" fill="#ffffff"/><rect x="1" y="1" width="558" height="278" rx="22" fill="none" stroke="#e3e7ee"/><g>%s</g><line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#8a95a3" stroke-width="1.2"/><line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#8a95a3" stroke-width="1.2"/><polygon fill="%s" stroke="none" points="%s"/><polyline fill="none" stroke="%s" stroke-width="3.5" stroke-linejoin="round" stroke-linecap="round" points="%s"/><g>%s</g><g>%s</g></svg>',
implode('', $yGrid),
$paddingLeft,
$paddingTop + $plotHeight,
$width - $paddingRight,
$paddingTop + $plotHeight,
$paddingLeft,
$paddingTop,
$paddingLeft,
$paddingTop + $plotHeight,
$this->escapeXml($areaFill),
$areaPoints,
$this->escapeXml($lineColor),
implode(' ', $polylinePoints),
implode('', $pointCircles),
implode('', $xLabels),
);
}
private function buildEmptyChartSvg(string $label): string
{
return sprintf(
'<svg xmlns="http://www.w3.org/2000/svg" width="560" height="220" viewBox="0 0 560 220" role="img" aria-label="Leeres Diagramm"><rect width="560" height="220" rx="22" fill="#f6f8fb"/><rect x="1" y="1" width="558" height="218" rx="22" fill="none" stroke="#e3e7ee"/><text x="280" y="114" text-anchor="middle" font-size="18" font-family="DejaVu Sans" fill="#555e68">%s</text></svg>',
$this->escapeXml($label),
);
}
/**
* @return array{x:float,y:float}
*/
private function polarToCartesian(float $centerX, float $centerY, float $radius, float $angleInDegrees): array
{
$angleInRadians = deg2rad($angleInDegrees);
return [
'x' => $centerX + ($radius * cos($angleInRadians)),
'y' => $centerY + ($radius * sin($angleInRadians)),
];
}
private function escapeXml(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_XML1, 'UTF-8');
}
private function buildFilename(string $title): string
{
$normalizedTitle = preg_replace('/\s+/', '_', trim($title)) ?: 'Umfrage';
$sanitizedTitle = preg_replace('/[^\pL\pN_-]+/u', '', $normalizedTitle) ?: 'Umfrage';
return sprintf('%s_Ergebnisse.pdf', trim($sanitizedTitle, '_-'));
}
}
+395
View File
@@ -0,0 +1,395 @@
<?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,value:string}> $answers
*
* @return list<array<string, mixed>>
*/
private function buildQuestionResults(array $questions, array $answers, int $completedSubmissionCount): array
{
$answersByQuestion = [];
foreach ($answers as $answer) {
$answersByQuestion[$answer['questionId']][] = $answer['value'];
}
$results = [];
foreach ($questions as $question) {
$questionId = (int) $question->id;
$values = $answersByQuestion[$questionId] ?? [];
$totalAnswers = count($values);
$responseRate = $completedSubmissionCount > 0 ? (int) round(($totalAnswers / $completedSubmissionCount) * 100) : 0;
$result = [
'id' => $questionId,
'question' => (string) $question->question,
'description' => (string) ($question->description ?? ''),
'type' => (string) $question->type,
'totalAnswers' => $totalAnswers,
'responseRate' => $responseRate,
'chart' => null,
];
switch ((string) $question->type) {
case 'yes_no_maybe':
$optionCounts = [
'Ja' => 0,
'Nein' => 0,
];
if ('1' === (string) $question->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 && isset($optionCounts['Vielleicht'])) {
++$optionCounts['Vielleicht'];
}
}
$result['options'] = $this->buildOptionStats($optionCounts);
$result['chart'] = $this->buildPieChartConfig($result['options']);
break;
case 'choice':
$optionCounts = [];
for ($index = 1; $index <= 10; ++$index) {
$option = trim((string) $question->{'answerOption'.$index});
if ('' !== $option) {
$optionCounts[$option] = 0;
}
}
foreach ($values as $value) {
foreach (array_filter(array_map('trim', explode(' | ', $value)), static fn (string $entry): bool => '' !== $entry) as $selectedOption) {
if (!array_key_exists($selectedOption, $optionCounts)) {
$optionCounts[$selectedOption] = 0;
}
++$optionCounts[$selectedOption];
}
}
$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'] = $this->buildPieChartConfig($result['responseSummary']);
break;
}
$results[] = $result;
}
return $results;
}
/**
* @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;
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Service;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Mummert\SurveyBundle\Model\SurveyModel;
use Mummert\SurveyBundle\Model\SurveySubmissionModel;
use Mummert\SurveyBundle\QuestionType\QuestionTypeRegistry;
use Mummert\SurveyBundle\Repository\SurveyAnswerRepository;
use Mummert\SurveyBundle\Repository\SurveyRepository;
use Mummert\SurveyBundle\Repository\SurveySubmissionRepository;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Uid\Ulid;
final class SurveySubmissionService
{
public function __construct(
private readonly SurveySubmissionRepository $surveySubmissionRepository,
private readonly SurveyAnswerRepository $surveyAnswerRepository,
private readonly SurveyRepository $surveyRepository,
private readonly QuestionTypeRegistry $questionTypeRegistry,
) {
}
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);
if ($existing instanceof SurveySubmissionModel) {
return $existing;
}
}
$token = (string) new Ulid();
$submission = $this->surveySubmissionRepository->create((int) $survey->id, $token, $firstQuestion?->id ? (int) $firstQuestion->id : null);
$session->set($sessionKey, $token);
return $submission;
}
public function storeAnswer(SurveyModel $survey, SurveySubmissionModel $submission, SurveyContentModel $question, mixed $rawAnswer): string
{
$questionType = $this->questionTypeRegistry->get((string) $question->type);
$normalizedAnswer = $questionType->normalizeAnswer($rawAnswer, $question);
$surveyHadAnswers = $this->surveyAnswerRepository->hasAnswersForSurvey((int) $survey->id);
$this->surveyAnswerRepository->saveAnswer(
(int) $submission->id,
(int) $question->id,
(string) $question->type,
$normalizedAnswer,
);
if (!$surveyHadAnswers && '1' !== (string) $survey->isLocked) {
$this->surveyRepository->lock((int) $survey->id);
}
return $normalizedAnswer;
}
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
{
return $this->surveyAnswerRepository->findAnswersBySubmission((int) $submission->id);
}
/**
* @return list<array<string, mixed>>
*/
public function getSubmissionOverview(SurveyModel $survey): array
{
return $this->surveySubmissionRepository->findOverviewBySurvey((int) $survey->id);
}
public function hasFinishedSubmissionForSurvey(int $surveyId, SessionInterface $session): bool
{
if ($surveyId <= 0) {
return false;
}
$token = (string) $session->get($this->buildSessionKey($surveyId), '');
if ('' === $token) {
return false;
}
return $this->surveySubmissionRepository->hasFinishedBySurveyAndToken($surveyId, $token);
}
private function buildSessionKey(int $surveyId): string
{
return 'mummert_survey_submission_'.$surveyId;
}
}