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
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Contao\Manager;
use Contao\CoreBundle\ContaoCoreBundle;
use Contao\ManagerPlugin\Bundle\BundlePluginInterface;
use Contao\ManagerPlugin\Bundle\Config\BundleConfig;
use Contao\ManagerPlugin\Bundle\Parser\ParserInterface;
use Contao\ManagerPlugin\Routing\RoutingPluginInterface;
use Mummert\SurveyBundle\SurveyBundle;
use Symfony\Component\Config\Loader\LoaderResolverInterface;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Routing\RouteCollection;
class Plugin implements BundlePluginInterface, RoutingPluginInterface
{
public function getBundles(ParserInterface $parser): iterable
{
return [
BundleConfig::create(SurveyBundle::class)
->setLoadAfter([ContaoCoreBundle::class]),
];
}
public function getRouteCollection(LoaderResolverInterface $resolver, KernelInterface $kernel): ?RouteCollection
{
return $resolver
->resolve(__DIR__.'/../../Resources/config/routes.yaml')
?->load(__DIR__.'/../../Resources/config/routes.yaml')
;
}
}
@@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Controller\FrontendModule;
use Contao\Config;
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
use Contao\CoreBundle\Twig\FragmentTemplate;
use Contao\FrontendUser;
use Contao\ModuleModel;
use Contao\PageModel;
use Mummert\SurveyBundle\Form\Model\SurveyConditionData;
use Mummert\SurveyBundle\Form\Model\SurveyEditorData;
use Mummert\SurveyBundle\Form\Model\SurveyQuestionData;
use Mummert\SurveyBundle\Form\SurveyConditionEditorType;
use Mummert\SurveyBundle\Form\SurveyEditorType;
use Mummert\SurveyBundle\Form\SurveyQuestionEditorType;
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;
use Mummert\SurveyBundle\Security\SurveyEditorVoter;
use Mummert\SurveyBundle\Service\SurveyEditorService;
use Mummert\SurveyBundle\Service\SurveySubmissionService;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
#[AsFrontendModule(type: 'member_survey_edit', category: 'survey', template: 'frontend/member_survey_edit')]
final class MemberSurveyEditController extends AbstractFrontendModuleController
{
public function __construct(
private readonly SurveyRepository $surveyRepository,
private readonly SurveyQuestionRepository $surveyQuestionRepository,
private readonly SurveyConditionRepository $surveyConditionRepository,
private readonly SurveyEditorRepository $surveyEditorRepository,
private readonly SurveyEditorService $surveyEditorService,
private readonly SurveySubmissionService $surveySubmissionService,
) {
}
protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response
{
$user = $this->getUser();
if (!$user instanceof FrontendUser) {
$template->set('loginRequired', true);
$template->set('backUrl', $this->resolveListUrl($model));
return $template->getResponse();
}
$survey = $this->resolveSurvey($request, $user);
if ($survey instanceof SurveyModel && $request->isMethod('POST') && $request->request->has('_survey_action')) {
return $this->handleAction($survey, $request, $model);
}
$questionChoices = $survey instanceof SurveyModel ? $this->buildQuestionChoices((int) $survey->id) : [];
$surveyForm = $this->createNamed('survey_meta', SurveyEditorType::class, $survey instanceof SurveyModel ? SurveyEditorData::fromModel($survey) : new SurveyEditorData());
$surveyForm->handleRequest($request);
if ($surveyForm->isSubmitted() && $surveyForm->isValid()) {
try {
if ($survey instanceof SurveyModel) {
$this->surveyEditorService->updateSurvey($survey, (int) $user->id, $surveyForm->getData());
$this->addFlash('success', 'Die Umfrage wurde gespeichert.');
return new RedirectResponse($this->buildEditUrl($model, (int) $survey->id));
}
$createdSurvey = $this->surveyEditorService->createSurvey((int) $user->id, $surveyForm->getData());
$this->addFlash('success', 'Die Umfrage wurde angelegt.');
return new RedirectResponse($this->buildEditUrl($model, (int) $createdSurvey->id));
} catch (\Throwable $exception) {
$this->addFlash('error', $exception->getMessage());
}
}
if (!$survey instanceof SurveyModel) {
$template->set('loginRequired', false);
$template->set('createMode', true);
$template->set('surveyForm', $surveyForm->createView());
$template->set('backUrl', $this->resolveListUrl($model));
$template->set('publicUrl', null);
$template->set('survey', null);
$template->set('questionForm', null);
$template->set('conditionForm', null);
$template->set('questions', []);
$template->set('conditions', []);
$template->set('editors', []);
$template->set('submissions', []);
$template->set('activeQuestionId', 0);
$template->set('activeConditionId', 0);
return $template->getResponse();
}
$activeQuestion = $this->resolveActiveQuestion($request, (int) $survey->id);
$activeCondition = $this->resolveActiveCondition($request, (int) $survey->id);
$questionForm = $this->createNamed('survey_question', SurveyQuestionEditorType::class, $activeQuestion ? SurveyQuestionData::fromModel($activeQuestion) : new SurveyQuestionData(), [
'question_choices' => $this->buildQuestionChoices((int) $survey->id),
]);
$conditionForm = $this->createNamed('survey_condition', SurveyConditionEditorType::class, $activeCondition ? SurveyConditionData::fromModel($activeCondition) : new SurveyConditionData(), [
'question_choices' => $questionChoices,
]);
$questionForm->handleRequest($request);
$conditionForm->handleRequest($request);
if ($questionForm->isSubmitted() && $questionForm->isValid()) {
try {
$this->surveyEditorService->saveQuestion($survey, $questionForm->getData(), $activeQuestion ? (int) $activeQuestion->id : 0);
$this->addFlash('success', $activeQuestion ? 'Die Frage wurde aktualisiert.' : 'Die Frage wurde angelegt.');
return new RedirectResponse($this->buildEditUrl($model, (int) $survey->id));
} catch (\Throwable $exception) {
$this->addFlash('error', $exception->getMessage());
}
}
if ($conditionForm->isSubmitted() && $conditionForm->isValid()) {
try {
$this->surveyEditorService->saveCondition($survey, $conditionForm->getData(), $activeCondition ? (int) $activeCondition->id : 0);
$this->addFlash('success', $activeCondition ? 'Die Bedingung wurde aktualisiert.' : 'Die Bedingung wurde angelegt.');
return new RedirectResponse($this->buildEditUrl($model, (int) $survey->id));
} catch (\Throwable $exception) {
$this->addFlash('error', $exception->getMessage());
}
}
$template->set('loginRequired', false);
$template->set('createMode', false);
$template->set('survey', $survey);
$template->set('surveyForm', $surveyForm->createView());
$template->set('questionForm', $questionForm->createView());
$template->set('conditionForm', $conditionForm->createView());
$template->set('questions', $this->surveyQuestionRepository->findAllBySurvey((int) $survey->id));
$template->set('conditions', $this->surveyConditionRepository->findOverviewBySurvey((int) $survey->id));
$template->set('editors', $this->surveyEditorRepository->findEditorsBySurvey((int) $survey->id));
$template->set('submissions', $this->surveySubmissionService->getSubmissionOverview($survey));
$template->set('activeQuestionId', $activeQuestion ? (int) $activeQuestion->id : 0);
$template->set('activeConditionId', $activeCondition ? (int) $activeCondition->id : 0);
$template->set('backUrl', $this->resolveListUrl($model));
$template->set('publicUrl', $this->resolveReaderUrl($model, (string) $survey->alias));
return $template->getResponse();
}
private function resolveSurvey(Request $request, FrontendUser $user): ?SurveyModel
{
$surveyId = (int) $request->query->get('survey', 0);
if ($surveyId <= 0) {
return null;
}
$survey = $this->surveyRepository->findById($surveyId);
if (!$survey instanceof SurveyModel) {
throw new \RuntimeException('Die Umfrage wurde nicht gefunden.');
}
$this->denyAccessUnlessGranted(SurveyEditorVoter::EDIT, $survey);
return $survey;
}
private function handleAction(SurveyModel $survey, Request $request, ModuleModel $model): Response
{
$action = (string) $request->request->get('_survey_action');
$itemId = (int) $request->request->get('item_id', 0);
$token = (string) $request->request->get('_token');
if (!$this->isCsrfTokenValid($action.'-'.$itemId, $token)) {
throw new \RuntimeException('Ungueltiger CSRF-Token.');
}
try {
if ('delete-question' === $action) {
$this->surveyEditorService->deleteQuestion($survey, $itemId);
$this->addFlash('success', 'Die Frage wurde geloescht.');
}
if ('delete-condition' === $action) {
$this->surveyEditorService->deleteCondition($survey, $itemId);
$this->addFlash('success', 'Die Bedingung wurde geloescht.');
}
} catch (\Throwable $exception) {
$this->addFlash('error', $exception->getMessage());
}
return new RedirectResponse($this->buildEditUrl($model, (int) $survey->id));
}
private function resolveActiveQuestion(Request $request, int $surveyId): mixed
{
$questionId = (int) $request->query->get('question', 0);
return $questionId > 0 ? $this->surveyQuestionRepository->findByIdForSurvey($surveyId, $questionId) : null;
}
private function resolveActiveCondition(Request $request, int $surveyId): mixed
{
$conditionId = (int) $request->query->get('condition', 0);
$condition = $conditionId > 0 ? $this->surveyConditionRepository->findById($conditionId) : null;
if (null === $condition || (int) $condition->pid !== $surveyId) {
return null;
}
return $condition;
}
/**
* @return array<int, string>
*/
private function buildQuestionChoices(int $surveyId, int $excludeQuestionId = 0): array
{
$choices = [];
foreach ($this->surveyQuestionRepository->findAllBySurvey($surveyId) as $question) {
if ($excludeQuestionId > 0 && (int) $question->id === $excludeQuestionId) {
continue;
}
$choices[(int) $question->id] = sprintf('#%d %s', (int) $question->id, (string) $question->question);
}
return $choices;
}
private function resolveListUrl(ModuleModel $model): string
{
$page = $this->resolvePage((int) ($model->surveyListPage ?? 0)) ?? $this->getPageModel();
return $page instanceof PageModel ? $this->generateContentUrl($page) : '/';
}
private function resolveReaderUrl(ModuleModel $model, string $publicAlias): ?string
{
$page = $this->resolvePage((int) $this->getContaoAdapter(Config::class)->get('surveyReaderPage'));
if (!$page instanceof PageModel) {
return null;
}
return $this->generateContentUrl($page, ['auto_item' => $publicAlias]);
}
private function buildEditUrl(ModuleModel $model, int $surveyId): string
{
$page = $this->getPageModel();
return $page instanceof PageModel ? $this->generateContentUrl($page, ['survey' => $surveyId]) : $this->resolveListUrl($model);
}
private function resolvePage(int $pageId): ?PageModel
{
if ($pageId <= 0) {
return null;
}
$page = $this->getContaoAdapter(PageModel::class)->findById($pageId);
return $page instanceof PageModel ? $page : null;
}
}
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Controller\FrontendModule;
use Contao\Config;
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
use Contao\CoreBundle\Twig\FragmentTemplate;
use Contao\FrontendUser;
use Contao\ModuleModel;
use Contao\PageModel;
use Mummert\SurveyBundle\Repository\SurveyRepository;
use Mummert\SurveyBundle\Service\SurveySubmissionService;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
#[AsFrontendModule(type: 'member_survey_list', category: 'survey', template: 'frontend/member_survey_list')]
final class MemberSurveyListController extends AbstractFrontendModuleController
{
public function __construct(
private readonly SurveyRepository $surveyRepository,
private readonly SurveySubmissionService $surveySubmissionService,
) {
}
protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response
{
$user = $this->getUser();
if (!$user instanceof FrontendUser) {
$template->set('loginRequired', true);
$template->set('surveys', []);
$template->set('createUrl', null);
return $template->getResponse();
}
$editPage = $this->resolvePage((int) ($model->surveyEditPage ?? 0));
$readerPage = $this->resolvePage((int) $this->getContaoAdapter(Config::class)->get('surveyReaderPage'));
$resultsPage = $this->resolvePage((int) $this->getContaoAdapter(Config::class)->get('surveyResultsPage'));
$items = [];
foreach ($this->surveyRepository->findEditableByMember((int) $user->id) as $survey) {
$surveyId = (int) $survey['id'];
$isCompletedInCurrentSession = $this->surveySubmissionService->hasFinishedSubmissionForSurvey($surveyId, $request->getSession());
$items[] = [
'id' => $surveyId,
'title' => (string) $survey['title'],
'description' => (string) ($survey['description'] ?? ''),
'isLocked' => !empty($survey['isLocked']),
'published' => !empty($survey['published']),
'questionCount' => (int) ($survey['questionCount'] ?? 0),
'answerCount' => (int) ($survey['answerCount'] ?? 0),
'editUrl' => $editPage instanceof PageModel ? $this->generateContentUrl($editPage, ['survey' => $surveyId]) : null,
'publicUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['auto_item' => (string) $survey['alias']]) : null,
'resultsUrl' => !$isCompletedInCurrentSession && $resultsPage instanceof PageModel ? $this->generateContentUrl($resultsPage, ['auto_item' => (string) $survey['alias']]) : null,
];
}
$template->set('loginRequired', false);
$template->set('surveys', $items);
$template->set('createUrl', $editPage instanceof PageModel ? $this->generateContentUrl($editPage) : null);
return $template->getResponse();
}
private function resolvePage(int $pageId): ?PageModel
{
if ($pageId <= 0) {
return null;
}
$page = $this->getContaoAdapter(PageModel::class)->findById($pageId);
return $page instanceof PageModel ? $page : null;
}
}
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Controller\FrontendModule;
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
use Contao\CoreBundle\Twig\FragmentTemplate;
use Contao\ModuleModel;
use Mummert\SurveyBundle\Form\Model\SurveyAnswerData;
use Mummert\SurveyBundle\Form\SurveyQuestionAnswerType;
use Mummert\SurveyBundle\Repository\SurveyRepository;
use Mummert\SurveyBundle\Service\SurveyFlowService;
use Mummert\SurveyBundle\Service\SurveySubmissionService;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
#[AsFrontendModule(type: 'show_survey', category: 'survey', template: 'frontend/show_survey')]
final class ShowSurveyController extends AbstractFrontendModuleController
{
public function __construct(
private readonly SurveyRepository $surveyRepository,
private readonly SurveyFlowService $surveyFlowService,
private readonly SurveySubmissionService $surveySubmissionService,
) {
}
protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response
{
$publicAlias = $this->resolvePublicAlias($request);
if ('' === $publicAlias) {
$template->set('errorMessage', 'Es wurde kein oeffentlicher Link auf der Anzeigeseite gefunden.');
$template->set('question', null);
$template->set('surveyForm', null);
$template->set('isComplete', false);
$template->set('answers', []);
$template->set('survey', null);
$template->set('progress', ['current' => 0, 'total' => 0, 'percentage' => 0]);
return $template->getResponse();
}
$survey = $this->surveyRepository->findByPublicAlias($publicAlias, true);
if (null === $survey) {
$template->set('errorMessage', 'Die Umfrage wurde nicht gefunden oder ist nicht veroeffentlicht.');
$template->set('question', null);
$template->set('surveyForm', null);
$template->set('isComplete', false);
$template->set('answers', []);
$template->set('survey', null);
$template->set('progress', ['current' => 0, 'total' => 0, 'percentage' => 0]);
return $template->getResponse();
}
if ($htmlHeadBag = $this->getHtmlHeadBag()) {
$htmlHeadBag->setTitle((string) $survey->title);
}
$submission = $this->surveySubmissionService->resolveActiveSubmission($survey, $request->getSession(), null);
$question = $this->surveyFlowService->resolveCurrentQuestion($survey, $submission);
if (null === $question && '1' !== (string) $submission->isFinished) {
$template->set('errorMessage', 'Diese Umfrage ist noch nicht vollstaendig eingerichtet. Es fehlt eine gueltige erste Frage oder eine verknuepfte Folgefrage ist nicht mehr verfuegbar.');
$template->set('question', null);
$template->set('surveyForm', null);
$template->set('isComplete', false);
$template->set('answers', []);
$template->set('survey', $survey);
$template->set('progress', ['current' => 0, 'total' => 0, 'percentage' => 0]);
return $template->getResponse();
}
if (null === $question) {
$template->set('errorMessage', null);
$template->set('survey', $survey);
$template->set('progress', $this->surveyFlowService->getProgress($survey, null));
$template->set('question', null);
$template->set('surveyForm', null);
$template->set('isComplete', true);
$template->set('answers', $this->surveySubmissionService->getAnswersForSubmission($submission));
return $template->getResponse();
}
$answerData = new SurveyAnswerData();
$form = $this->createForm(SurveyQuestionAnswerType::class, $answerData, ['question' => $question]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$normalizedAnswer = $this->surveySubmissionService->storeAnswer($survey, $submission, $question, $answerData->answer);
$nextQuestion = $this->surveyFlowService->determineNextQuestion($survey, $question, $normalizedAnswer);
$this->surveySubmissionService->advance($submission, $nextQuestion);
return new RedirectResponse($request->getUri());
}
$template->set('errorMessage', null);
$template->set('survey', $survey);
$template->set('progress', $this->surveyFlowService->getProgress($survey, $question));
$template->set('question', $question);
$template->set('surveyForm', $form->createView());
$template->set('isComplete', false);
$template->set('answers', []);
return $template->getResponse();
}
private function resolvePublicAlias(Request $request): string
{
$candidate = $request->attributes->get('auto_item');
if (\is_string($candidate) && '' !== trim($candidate)) {
return trim($candidate);
}
$queryValue = (string) $request->query->get('auto_item', '');
return trim($queryValue);
}
}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Controller\FrontendModule;
use Contao\Config;
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
use Contao\CoreBundle\Twig\FragmentTemplate;
use Contao\ModuleModel;
use Mummert\SurveyBundle\Repository\SurveyRepository;
use Mummert\SurveyBundle\Service\SurveyResultsViewService;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
#[AsFrontendModule(type: 'survey_results', category: 'survey', template: 'frontend/show_survey_results')]
final class ShowSurveyResultsController extends AbstractFrontendModuleController
{
public function __construct(
private readonly SurveyRepository $surveyRepository,
private readonly SurveyResultsViewService $surveyResultsViewService,
private readonly UrlGeneratorInterface $urlGenerator,
) {
}
protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response
{
$publicAlias = $this->resolvePublicAlias($request);
if ('' === $publicAlias) {
$template->set('errorMessage', 'Es wurde kein Umfrage-Link auf der Ergebnisseite gefunden.');
$template->set('survey', null);
$template->set('questionResults', []);
$template->set('completedSubmissionCount', 0);
return $template->getResponse();
}
$survey = $this->surveyRepository->findByPublicAlias($publicAlias, false);
if (null === $survey) {
$template->set('errorMessage', 'Die Umfrage wurde nicht gefunden.');
$template->set('survey', null);
$template->set('questionResults', []);
$template->set('completedSubmissionCount', 0);
return $template->getResponse();
}
if ($htmlHeadBag = $this->getHtmlHeadBag()) {
$htmlHeadBag->setTitle((string) $survey->title.' - Ergebnisse');
}
$resultsData = $this->surveyResultsViewService->buildForSurvey($survey);
$template->set('errorMessage', null);
$template->set('survey', $survey);
$template->set('completedSubmissionCount', $resultsData['completedSubmissionCount']);
$template->set('downloadUrl', $this->buildDownloadUrl((string) $survey->alias));
$template->set('pdfDownloadUrl', $this->hasGotenbergConfiguration() ? $this->buildPdfDownloadUrl((string) $survey->alias) : null);
$template->set('questionResults', $resultsData['questionResults']);
return $template->getResponse();
}
private function buildDownloadUrl(string $alias): string
{
return $this->urlGenerator->generate('mummert_survey_results_export', ['alias' => $alias]);
}
private function buildPdfDownloadUrl(string $alias): string
{
return $this->urlGenerator->generate('mummert_survey_results_pdf_export', ['alias' => $alias]);
}
private function hasGotenbergConfiguration(): bool
{
return '' !== trim((string) Config::get('surveyGotenbergUrl'));
}
private function resolvePublicAlias(Request $request): string
{
$candidate = $request->attributes->get('auto_item');
if (\is_string($candidate) && '' !== trim($candidate)) {
return trim($candidate);
}
return trim((string) $request->query->get('auto_item', ''));
}
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Controller;
use Mummert\SurveyBundle\Repository\SurveyRepository;
use Mummert\SurveyBundle\Service\SurveyResultsExportService;
use Mummert\SurveyBundle\Service\SurveyResultsPdfService;
use Mummert\SurveyBundle\Service\SurveyResultsViewService;
use Mummert\SurveyBundle\Service\SurveySubmissionService;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
final class SurveyResultsExportController
{
public function __construct(
private readonly SurveyRepository $surveyRepository,
private readonly SurveyResultsViewService $surveyResultsViewService,
private readonly SurveySubmissionService $surveySubmissionService,
private readonly SurveyResultsExportService $surveyResultsExportService,
private readonly SurveyResultsPdfService $surveyResultsPdfService,
) {
}
#[Route('/umfrage-ergebnisse-export/{alias}', name: 'mummert_survey_results_export', defaults: ['_scope' => 'frontend'], requirements: ['alias' => '[^/]+'], methods: ['GET'])]
public function __invoke(string $alias): Response
{
$survey = $this->surveyRepository->findByPublicAlias($alias, false);
if (null === $survey) {
throw new NotFoundHttpException('Die Umfrage wurde nicht gefunden.');
}
$finishedSubmissions = array_values(array_filter(
$this->surveySubmissionService->getSubmissionOverview($survey),
static fn (array $submission): bool => '1' === (string) ($submission['isFinished'] ?? '')
));
return $this->surveyResultsExportService->createExcelExportResponse($survey, $finishedSubmissions);
}
#[Route('/umfrage-ergebnisse-pdf/{alias}', name: 'mummert_survey_results_pdf_export', defaults: ['_scope' => 'frontend'], requirements: ['alias' => '[^/]+'], methods: ['GET'])]
public function pdf(string $alias): Response
{
$survey = $this->surveyRepository->findByPublicAlias($alias, false);
if (null === $survey) {
throw new NotFoundHttpException('Die Umfrage wurde nicht gefunden.');
}
$resultsData = $this->surveyResultsViewService->buildForSurvey($survey);
return $this->surveyResultsPdfService->createPdfExportResponse(
$survey,
$resultsData['completedSubmissionCount'],
$resultsData['questionResults'],
);
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
class SurveyExtension extends Extension implements PrependExtensionInterface
{
public function prepend(ContainerBuilder $container): void
{
if (!$container->hasExtension('twig')) {
return;
}
$container->prependExtensionConfig('twig', [
'paths' => [
dirname(__DIR__, 2).'/contao/templates' => 'Survey',
],
]);
}
public function load(array $configs, ContainerBuilder $container): void
{
$loader = new YamlFileLoader($container, new FileLocator(dirname(__DIR__, 2).'/config'));
$loader->load('services.yaml');
}
}
+477
View File
@@ -0,0 +1,477 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\EventListener;
use Contao\Config;
use Contao\CoreBundle\Framework\ContaoFramework;
use Contao\DataContainer;
use Contao\Environment;
use Contao\Image;
use Contao\Input;
use Contao\PageModel;
use Doctrine\DBAL\Connection;
use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
use Mummert\SurveyBundle\Repository\SurveyQuestionRepository;
use Mummert\SurveyBundle\Repository\SurveyRepository;
use Mummert\SurveyBundle\Service\SurveyCategoryAssignmentService;
final class SurveyDcaListener
{
public function __construct(
private readonly SurveyRepository $surveyRepository,
private readonly SurveyQuestionRepository $surveyQuestionRepository,
private readonly SurveyEditorRepository $surveyEditorRepository,
private readonly SurveyCategoryAssignmentService $surveyCategoryAssignmentService,
private readonly ContaoFramework $framework,
private readonly Connection $connection,
) {
}
public function generateSurveyAlias(mixed $value, DataContainer $dataContainer): string
{
return $this->surveyRepository->ensurePublicAlias((string) $value, (int) ($dataContainer->id ?? 0));
}
public function generateCopyPublicLinkButton(array $row, ?string $href, string $label, string $title, ?string $icon, string $attributes): string
{
$publicUrl = $this->buildSurveyPageUrl((string) ($row['alias'] ?? ''), 'surveyReaderPage');
$label = 'Link kopieren';
$title = 'Den oeffentlichen Link in die Zwischenablage kopieren';
if (null === $publicUrl) {
return sprintf(
'<span title="%s">%s</span>',
htmlspecialchars('In den Contao-Einstellungen ist noch keine Reader-Seite hinterlegt oder der Link-Schluessel ist ungueltig.', ENT_QUOTES),
Image::getHtml($icon ?? 'copy.svg', $label)
);
}
$copyScript = sprintf(
'navigator.clipboard.writeText(%s).then(function(){Contao.flashMessage(%s, %s, "confirmation");});return false;',
json_encode($publicUrl, JSON_THROW_ON_ERROR),
json_encode('Oeffentlicher Link wurde in die Zwischenablage kopiert.', JSON_THROW_ON_ERROR),
json_encode('Bestaetigung', JSON_THROW_ON_ERROR),
);
return sprintf(
'<a href="#" title="%s" onclick="%s"%s>%s</a>',
htmlspecialchars($title, ENT_QUOTES),
htmlspecialchars($copyScript, ENT_QUOTES),
$attributes,
Image::getHtml($icon ?? 'copy.svg', $label).' '.$label
);
}
public function generateShowResultsButton(array $row, ?string $href, string $label, string $title, ?string $icon, string $attributes): string
{
$resultsUrl = $this->buildSurveyPageUrl((string) ($row['alias'] ?? ''), 'surveyResultsPage');
$label = 'Ergebnisse anzeigen';
$title = 'Die Ergebnisse dieser Umfrage im Frontend anzeigen';
if (null === $resultsUrl) {
return sprintf(
'<span title="%s">%s</span>',
htmlspecialchars('In den Contao-Einstellungen ist noch keine Ergebnisseite hinterlegt oder der Link-Schluessel ist ungueltig.', ENT_QUOTES),
Image::getHtml($icon ?? 'show.svg', $label)
);
}
return sprintf(
'<a href="%s" title="%s" target="_blank" rel="noreferrer"%s>%s</a>',
htmlspecialchars($resultsUrl, ENT_QUOTES),
htmlspecialchars($title, ENT_QUOTES),
$attributes,
Image::getHtml($icon ?? 'show.svg', $label).' '.$label
);
}
/**
* @return array<int, string>
*/
public function getQuestionOptions(?DataContainer $dataContainer = null): array
{
$surveyId = $this->resolveSurveyId('tl_survey_condition', $dataContainer);
return $this->buildQuestionOptions($surveyId);
}
/**
* @return array<int, string>
*/
public function getQuestionOptionsForContent(?DataContainer $dataContainer = null): array
{
$surveyId = $this->resolveSurveyId('tl_survey_content', $dataContainer);
return $this->buildQuestionOptions($surveyId);
}
/**
* @return array<int, string>
*/
private function buildQuestionOptions(int $surveyId): array
{
$choices = [];
foreach ($this->surveyQuestionRepository->findAllBySurvey($surveyId) as $question) {
$choices[(int) $question->id] = sprintf('#%d %s', (int) $question->id, (string) $question->question);
}
return $choices;
}
/**
* @return array<int, string>
*/
public function getMemberOptions(): array
{
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 getSurveyOptions(): array
{
return $this->surveyEditorRepository->findSurveyChoices();
}
/**
* @return list<int>
*/
public function loadMemberAssignedSurveys(mixed $value, DataContainer $dataContainer): array
{
$memberId = (int) ($dataContainer->id ?? 0);
return $memberId > 0 ? $this->surveyEditorRepository->findSurveyIdsByMember($memberId) : [];
}
/**
* @return list<int>
*/
public function loadSurveyAssignedMembers(mixed $value, DataContainer $dataContainer): array
{
$surveyId = (int) ($dataContainer->id ?? 0);
return $surveyId > 0 ? $this->surveyEditorRepository->findMemberIdsBySurvey($surveyId) : [];
}
public function guardContentSave(mixed $value, DataContainer $dataContainer): mixed
{
$this->assertSurveyUnlocked($this->resolveSurveyId('tl_survey_content', $dataContainer));
return $value;
}
public function guardConditionSave(mixed $value, DataContainer $dataContainer): mixed
{
$this->assertSurveyUnlocked($this->resolveSurveyId('tl_survey_condition', $dataContainer));
return $value;
}
public function guardEditorSave(mixed $value, DataContainer $dataContainer): mixed
{
$surveyId = $this->resolveSurveyId('tl_survey_editor', $dataContainer);
$this->assertSurveyUnlocked($surveyId);
$this->surveyCategoryAssignmentService->applyMemberCategoriesToSurvey((int) $value, $surveyId);
return $value;
}
/**
* @return list<int>
*/
public function saveMemberSurveyCategories(mixed $value, DataContainer $dataContainer): array
{
$memberId = (int) ($dataContainer->id ?? 0);
$categoryIds = $this->surveyCategoryAssignmentService->normalizeCategoryIds($value);
if ($memberId > 0) {
$this->surveyCategoryAssignmentService->syncAssignedSurveys($memberId, $categoryIds);
}
return $categoryIds;
}
public function refreshMemberSurveyListMetadata(DataContainer $dataContainer): void
{
$memberId = (int) ($dataContainer->id ?? 0);
if ($memberId <= 0) {
return;
}
foreach ($this->surveyEditorRepository->findSurveyIdsByMember($memberId) as $surveyId) {
$this->surveyRepository->refreshListMetadata($surveyId);
}
}
/**
* @return list<int>
*/
public function saveMemberAssignedSurveys(mixed $value, DataContainer $dataContainer): array
{
$memberId = (int) ($dataContainer->id ?? 0);
$surveyIds = $this->surveyCategoryAssignmentService->normalizeCategoryIds($value);
if ($memberId <= 0) {
return $surveyIds;
}
$previousSurveyIds = $this->surveyEditorRepository->findSurveyIdsByMember($memberId);
if ($previousSurveyIds !== $surveyIds) {
foreach (array_unique(array_merge($previousSurveyIds, $surveyIds)) as $surveyId) {
$this->assertSurveyUnlocked((int) $surveyId);
}
$this->surveyEditorRepository->syncSurveysForMember($memberId, $surveyIds);
}
$postedCategories = Input::post('surveyCategories');
$memberCategoryIds = null !== $postedCategories
? $this->surveyCategoryAssignmentService->normalizeCategoryIds($postedCategories)
: $this->surveyCategoryAssignmentService->getMemberCategoryIds($memberId);
foreach (array_unique(array_merge($previousSurveyIds, $surveyIds)) as $surveyId) {
$this->surveyCategoryAssignmentService->syncSurvey((int) $surveyId, [$memberId => $memberCategoryIds]);
}
return $surveyIds;
}
/**
* @return list<int>
*/
public function saveSurveyAssignedMembers(mixed $value, DataContainer $dataContainer): array
{
$surveyId = (int) ($dataContainer->id ?? 0);
$memberIds = $this->surveyCategoryAssignmentService->normalizeCategoryIds($value);
if ($surveyId <= 0) {
return $memberIds;
}
$previousMemberIds = $this->surveyEditorRepository->findMemberIdsBySurvey($surveyId);
if ($previousMemberIds !== $memberIds) {
$this->assertSurveyUnlocked($surveyId);
$this->surveyEditorRepository->syncMembersForSurvey($surveyId, $memberIds);
$this->surveyCategoryAssignmentService->syncSurvey($surveyId);
}
$this->surveyRepository->refreshListMetadata($surveyId);
return $memberIds;
}
public function refreshSurveyBackendStateIndex(): void
{
foreach ($this->connection->fetchFirstColumn('SELECT id FROM tl_survey') as $surveyId) {
$this->normalizeSurveyBackendState((int) $surveyId);
}
}
public function persistSurveyBackendState(DataContainer $dataContainer): void
{
$surveyId = (int) ($dataContainer->id ?? 0);
if ($surveyId <= 0) {
return;
}
$this->normalizeSurveyBackendState($surveyId);
}
private function normalizeSurveyBackendState(int $surveyId): void
{
if ($surveyId <= 0) {
return;
}
$survey = $this->surveyRepository->findById($surveyId);
if (null === $survey) {
return;
}
$now = time();
$createdAt = (int) $survey->createdAt;
$updatedAt = (int) $survey->updatedAt;
$tstamp = (int) $survey->tstamp;
$this->connection->update('tl_survey', [
'createdAt' => $createdAt > 0 ? $createdAt : ($updatedAt > 0 ? $updatedAt : ($tstamp > 0 ? $tstamp : $now)),
'updatedAt' => $updatedAt > 0 ? $updatedAt : $now,
], [
'id' => $surveyId,
]);
$this->surveyRepository->refreshListMetadata($surveyId);
}
public function guardContentDelete(DataContainer $dataContainer): void
{
$this->assertSurveyUnlocked($this->resolveSurveyId('tl_survey_content', $dataContainer));
}
public function touchSurveyContentParent(DataContainer $dataContainer): void
{
$surveyId = $this->resolveSurveyId('tl_survey_content', $dataContainer);
if ($surveyId > 0) {
$this->surveyRepository->touch($surveyId);
}
}
public function guardConditionDelete(DataContainer $dataContainer): void
{
$this->assertSurveyUnlocked($this->resolveSurveyId('tl_survey_condition', $dataContainer));
}
public function touchSurveyConditionParent(DataContainer $dataContainer): void
{
$surveyId = $this->resolveSurveyId('tl_survey_condition', $dataContainer);
if ($surveyId > 0) {
$this->surveyRepository->touch($surveyId);
}
}
public function guardEditorDelete(DataContainer $dataContainer): void
{
$surveyId = $this->resolveSurveyId('tl_survey_editor', $dataContainer);
$this->assertSurveyUnlocked($surveyId);
if ($dataContainer->activeRecord?->member) {
$this->surveyCategoryAssignmentService->syncSurveyWithoutMember($surveyId, (int) $dataContainer->activeRecord->member);
}
}
public function guardSurveyStructureSave(mixed $value, DataContainer $dataContainer): mixed
{
$surveyId = (int) ($dataContainer->id ?? 0);
$this->assertSurveyUnlocked($surveyId);
return $value;
}
public function resolveSurveyContentPalette(string $palette, DataContainer $dataContainer): string
{
$type = (string) ($dataContainer->activeRecord->type ?? '');
if ('' === $type) {
$type = (string) Input::post('type');
}
return match ($type) {
'range' => (string) ($GLOBALS['TL_DCA']['tl_survey_content']['palettes']['range'] ?? $palette),
'text' => (string) ($GLOBALS['TL_DCA']['tl_survey_content']['palettes']['text'] ?? $palette),
'choice' => (string) ($GLOBALS['TL_DCA']['tl_survey_content']['palettes']['choice'] ?? $palette),
'yes_no_maybe' => (string) ($GLOBALS['TL_DCA']['tl_survey_content']['palettes']['yes_no_maybe'] ?? $palette),
default => $palette,
};
}
private function assertSurveyUnlocked(int $surveyId): void
{
$survey = $this->surveyRepository->findById($surveyId);
if (null === $survey) {
return;
}
if ('1' === (string) $survey->isLocked) {
throw new \RuntimeException('Die Umfrage ist gesperrt, weil bereits Antworten existieren.');
}
}
private function resolveSurveyId(string $table, ?DataContainer $dataContainer = null): int
{
if ($dataContainer?->activeRecord) {
if (isset($dataContainer->activeRecord->pid)) {
return (int) $dataContainer->activeRecord->pid;
}
if (isset($dataContainer->activeRecord->survey)) {
return (int) $dataContainer->activeRecord->survey;
}
}
$pid = (int) Input::get('pid');
if ($pid > 0) {
return $pid;
}
$recordId = (int) ($dataContainer?->id ?? 0);
if ($recordId <= 0) {
return 0;
}
if ('survey' === $table) {
$value = $this->connection->fetchOne(sprintf('SELECT %s FROM %s WHERE id = ?', 'id', $table), [$recordId]);
return false === $value ? 0 : (int) $value;
}
if ('tl_survey_editor' === $table) {
$value = $this->connection->fetchOne(
'SELECT COALESCE(NULLIF(survey, 0), pid) FROM tl_survey_editor WHERE id = ?',
[$recordId],
);
return false === $value ? 0 : (int) $value;
}
$value = $this->connection->fetchOne(sprintf('SELECT %s FROM %s WHERE id = ?', 'pid', $table), [$recordId]);
return false === $value ? 0 : (int) $value;
}
private function buildSurveyPageUrl(string $alias, string $settingKey): ?string
{
$pageId = $this->resolvePageId($settingKey);
if ($pageId <= 0 || '' === $alias) {
return null;
}
$page = $this->framework->getAdapter(PageModel::class)->findById($pageId);
if (!$page instanceof PageModel) {
return null;
}
$baseUrl = rtrim((string) $this->framework->getAdapter(Environment::class)->get('url'), '/');
$pagePath = trim((string) $page->getFrontendUrl(), '/');
if ('' === $pagePath) {
return sprintf('%s/%s', $baseUrl, $alias);
}
return sprintf('%s/%s/%s', $baseUrl, $pagePath, $alias);
}
private function resolvePageId(string $settingKey): int
{
return (int) $this->framework->getAdapter(Config::class)->get($settingKey);
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Form\Model;
final class SurveyAnswerData
{
public mixed $answer = null;
}
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Form\Model;
use Mummert\SurveyBundle\Model\SurveyConditionModel;
final class SurveyConditionData
{
public int $sourceQuestion = 0;
public string $answerValue = '';
public int $targetQuestion = 0;
public static function fromModel(SurveyConditionModel $condition): self
{
$data = new self();
$data->sourceQuestion = (int) $condition->sourceQuestion;
$data->answerValue = (string) $condition->answerValue;
$data->targetQuestion = (int) $condition->targetQuestion;
return $data;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'sourceQuestion' => $this->sourceQuestion,
'answerValue' => $this->answerValue,
'targetQuestion' => $this->targetQuestion,
];
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Form\Model;
use Mummert\SurveyBundle\Model\SurveyModel;
final class SurveyEditorData
{
public string $title = '';
public string $description = '';
public bool $published = false;
public static function fromModel(SurveyModel $survey): self
{
$data = new self();
$data->title = (string) $survey->title;
$data->description = (string) $survey->description;
$data->published = '1' === (string) $survey->published;
return $data;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'title' => $this->title,
'description' => $this->description,
'published' => $this->published,
];
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Form\Model;
use Mummert\SurveyBundle\Model\SurveyContentModel;
final class SurveyQuestionData
{
public string $type = 'yes_no_maybe';
public string $question = '';
public string $description = '';
public bool $mandatory = true;
public bool $published = true;
public bool $allowMaybe = false;
public bool $allowMultiple = false;
public int $jumpOnYes = 0;
public int $jumpOnNo = 0;
public string $answerOption1 = '';
public string $answerOption2 = '';
public string $answerOption3 = '';
public string $answerOption4 = '';
public string $answerOption5 = '';
public string $answerOption6 = '';
public string $answerOption7 = '';
public string $answerOption8 = '';
public string $answerOption9 = '';
public string $answerOption10 = '';
public int $rangeMin = 0;
public int $rangeMax = 10;
public int $rangeStep = 1;
public static function fromModel(SurveyContentModel $question): self
{
$data = new self();
$data->type = (string) $question->type;
$data->question = (string) $question->question;
$data->description = (string) $question->description;
$data->mandatory = '1' === (string) $question->mandatory;
$data->published = '1' === (string) $question->published;
$data->allowMaybe = '1' === (string) $question->allowMaybe;
$data->allowMultiple = '1' === (string) $question->allowMultiple;
$data->jumpOnYes = (int) $question->jumpOnYes;
$data->jumpOnNo = (int) $question->jumpOnNo;
for ($index = 1; $index <= 10; ++$index) {
$property = 'answerOption'.$index;
$data->{$property} = (string) $question->{$property};
}
$data->rangeMin = (int) $question->rangeMin;
$data->rangeMax = (int) $question->rangeMax;
$data->rangeStep = max(1, (int) $question->rangeStep);
return $data;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
$payload = [
'type' => $this->type,
'question' => $this->question,
'description' => $this->description,
'mandatory' => $this->mandatory,
'published' => $this->published,
'allowMaybe' => $this->allowMaybe,
'allowMultiple' => $this->allowMultiple,
'jumpOnYes' => $this->jumpOnYes,
'jumpOnNo' => $this->jumpOnNo,
'rangeMin' => $this->rangeMin,
'rangeMax' => $this->rangeMax,
'rangeStep' => $this->rangeStep,
];
for ($index = 1; $index <= 10; ++$index) {
$property = 'answerOption'.$index;
$payload[$property] = $this->{$property};
}
return $payload;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Form;
use Mummert\SurveyBundle\Form\Model\SurveyConditionData;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
final class SurveyConditionEditorType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$questionChoices = $options['question_choices'];
$builder
->add('sourceQuestion', ChoiceType::class, [
'label' => 'Ausgangsfrage',
'choices' => array_flip($questionChoices),
'constraints' => [new NotBlank()],
])
->add('answerValue', TextType::class, [
'label' => 'Antwort',
'help' => 'Bei Ja-Nein-Fragen bitte genau ja oder nein eintragen, bei aktivierter Vielleicht-Option auch vielleicht. Bei Choice-Fragen den exakten Antworttext verwenden, bei Multiple-Choice mehrere Werte mit | trennen.',
'constraints' => [new NotBlank(), new Length(['max' => 255])],
])
->add('targetQuestion', ChoiceType::class, [
'label' => 'Naechste Frage',
'choices' => array_flip($questionChoices),
'constraints' => [new NotBlank()],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => SurveyConditionData::class,
'question_choices' => [],
]);
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Form;
use Mummert\SurveyBundle\Form\Model\SurveyEditorData;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
final class SurveyEditorType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title', TextType::class, [
'label' => 'Titel',
'constraints' => [new NotBlank(), new Length(['max' => 255])],
])
->add('description', TextareaType::class, [
'label' => 'Beschreibung',
'required' => false,
'attr' => ['rows' => 5],
])
->add('published', CheckboxType::class, [
'label' => 'Veroeffentlicht',
'required' => false,
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => SurveyEditorData::class,
]);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Form;
use Mummert\SurveyBundle\Form\Model\SurveyAnswerData;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Mummert\SurveyBundle\QuestionType\QuestionTypeRegistry;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class SurveyQuestionAnswerType extends AbstractType
{
public function __construct(private readonly QuestionTypeRegistry $questionTypeRegistry)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$question = $options['question'];
if (!$question instanceof SurveyContentModel) {
throw new \InvalidArgumentException('Die Frage fehlt fuer das Antwortformular.');
}
$this->questionTypeRegistry->get((string) $question->type)->buildField($builder, $question);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => SurveyAnswerData::class,
'question' => null,
]);
$resolver->setAllowedTypes('question', [SurveyContentModel::class]);
}
}
+155
View File
@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Form;
use Mummert\SurveyBundle\Form\Model\SurveyQuestionData;
use Mummert\SurveyBundle\QuestionType\QuestionTypeRegistry;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\GreaterThanOrEqual;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
final class SurveyQuestionEditorType extends AbstractType
{
public function __construct(private readonly QuestionTypeRegistry $questionTypeRegistry)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('type', ChoiceType::class, [
'label' => 'Fragetyp',
'choices' => $this->questionTypeRegistry->getChoices(),
'attr' => [
'data-controller' => 'question-editor',
'data-action' => 'change->question-editor#toggle',
'data-question-editor-target' => 'type',
],
])
->add('question', TextType::class, [
'label' => 'Frage',
'constraints' => [new NotBlank(), new Length(['max' => 255])],
])
->add('description', TextareaType::class, [
'label' => 'Beschreibung',
'required' => false,
'attr' => ['rows' => 4],
])
->add('mandatory', CheckboxType::class, [
'label' => 'Pflichtfrage',
'required' => false,
])
->add('published', CheckboxType::class, [
'label' => 'Aktiv',
'required' => false,
])
->add('allowMaybe', CheckboxType::class, [
'label' => 'Vielleicht anbieten',
'required' => false,
'attr' => ['data-question-editor-target' => 'yesNoMaybeField'],
])
->add('jumpOnYes', ChoiceType::class, [
'label' => 'Zur Frage springen bei Auswahl ja',
'required' => false,
'choices' => array_flip($options['question_choices']),
'placeholder' => 'keine',
'attr' => ['data-question-editor-target' => 'yesNoMaybeField'],
])
->add('jumpOnNo', ChoiceType::class, [
'label' => 'Zur Frage springen bei Auswahl nein',
'required' => false,
'choices' => array_flip($options['question_choices']),
'placeholder' => 'keine',
'attr' => ['data-question-editor-target' => 'yesNoMaybeField'],
])
->add('allowMultiple', CheckboxType::class, [
'label' => 'Multiple-Choice aktivieren',
'required' => false,
'attr' => ['data-question-editor-target' => 'choiceField'],
])
->add('answerOption1', TextType::class, [
'label' => 'Antwort 1',
'required' => false,
'attr' => ['data-question-editor-target' => 'choiceField'],
])
->add('answerOption2', TextType::class, [
'label' => 'Antwort 2',
'required' => false,
'attr' => ['data-question-editor-target' => 'choiceField'],
])
->add('answerOption3', TextType::class, [
'label' => 'Antwort 3',
'required' => false,
'attr' => ['data-question-editor-target' => 'choiceField'],
])
->add('answerOption4', TextType::class, [
'label' => 'Antwort 4',
'required' => false,
'attr' => ['data-question-editor-target' => 'choiceField'],
])
->add('answerOption5', TextType::class, [
'label' => 'Antwort 5',
'required' => false,
'attr' => ['data-question-editor-target' => 'choiceField'],
])
->add('answerOption6', TextType::class, [
'label' => 'Antwort 6',
'required' => false,
'attr' => ['data-question-editor-target' => 'choiceField'],
])
->add('answerOption7', TextType::class, [
'label' => 'Antwort 7',
'required' => false,
'attr' => ['data-question-editor-target' => 'choiceField'],
])
->add('answerOption8', TextType::class, [
'label' => 'Antwort 8',
'required' => false,
'attr' => ['data-question-editor-target' => 'choiceField'],
])
->add('answerOption9', TextType::class, [
'label' => 'Antwort 9',
'required' => false,
'attr' => ['data-question-editor-target' => 'choiceField'],
])
->add('answerOption10', TextType::class, [
'label' => 'Antwort 10',
'required' => false,
'attr' => ['data-question-editor-target' => 'choiceField'],
])
->add('rangeMin', IntegerType::class, [
'label' => 'Kleinster Wert',
'constraints' => [new GreaterThanOrEqual(['value' => -100000])],
'attr' => ['data-question-editor-target' => 'rangeField'],
])
->add('rangeMax', IntegerType::class, [
'label' => 'Groesster Wert',
'constraints' => [new GreaterThanOrEqual(['value' => -100000])],
'attr' => ['data-question-editor-target' => 'rangeField'],
])
->add('rangeStep', IntegerType::class, [
'label' => 'Schrittweite',
'constraints' => [new GreaterThanOrEqual(['value' => 1])],
'attr' => ['data-question-editor-target' => 'rangeField'],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => SurveyQuestionData::class,
'question_choices' => [],
]);
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Model;
use Contao\Model;
final class SurveyAnswerModel extends Model
{
protected static $strTable = 'tl_survey_answer';
}
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Model;
use Contao\Model;
final class SurveyCategoryModel extends Model
{
protected static $strTable = 'tl_survey_category';
}
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Model;
use Contao\Model;
final class SurveyConditionModel extends Model
{
protected static $strTable = 'tl_survey_condition';
}
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Model;
use Contao\Model;
final class SurveyContentModel extends Model
{
protected static $strTable = 'tl_survey_content';
}
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Model;
use Contao\Model;
final class SurveyEditorModel extends Model
{
protected static $strTable = 'tl_survey_editor';
}
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Model;
use Contao\Model;
final class SurveyModel extends Model
{
protected static $strTable = 'tl_survey';
}
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Model;
use Contao\Model;
final class SurveySubmissionModel extends Model
{
protected static $strTable = 'tl_survey_submission';
}
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\QuestionType;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\Choice;
use Symfony\Component\Validator\Constraints\Count;
use Symfony\Component\Validator\Constraints\NotBlank;
final class ChoiceQuestionType implements QuestionTypeInterface
{
public function getName(): string
{
return 'choice';
}
public function getConstraints(SurveyContentModel $question): array
{
$choices = $this->getAnswerOptions($question);
$multiple = '1' === (string) $question->allowMultiple;
$constraints = [new Choice(['choices' => $choices, 'multiple' => $multiple])];
if ('1' === (string) $question->mandatory) {
$constraints[] = $multiple ? new Count(['min' => 1]) : new NotBlank();
}
return $constraints;
}
public function buildField(FormBuilderInterface $builder, SurveyContentModel $question, string $fieldName = 'answer'): void
{
$choices = [];
foreach ($this->getAnswerOptions($question) as $answerOption) {
$choices[$answerOption] = $answerOption;
}
$builder->add($fieldName, ChoiceType::class, [
'label' => false,
'expanded' => true,
'multiple' => '1' === (string) $question->allowMultiple,
'choices' => $choices,
'constraints' => $this->getConstraints($question),
]);
}
public function normalizeAnswer(mixed $value, SurveyContentModel $question): string
{
if (!\is_array($value)) {
return trim((string) $value);
}
$answers = array_values(array_filter(array_map(static fn (mixed $entry): string => trim((string) $entry), $value), static fn (string $entry): bool => '' !== $entry));
return implode(' | ', $answers);
}
/**
* @return list<string>
*/
private function getAnswerOptions(SurveyContentModel $question): array
{
$options = [];
for ($index = 1; $index <= 10; ++$index) {
$value = trim((string) $question->{'answerOption'.$index});
if ('' !== $value) {
$options[] = $value;
}
}
return $options;
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\QuestionType;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Symfony\Component\Form\FormBuilderInterface;
interface QuestionTypeInterface
{
public function getName(): string;
/**
* @return list<object>
*/
public function getConstraints(SurveyContentModel $question): array;
public function buildField(FormBuilderInterface $builder, SurveyContentModel $question, string $fieldName = 'answer'): void;
public function normalizeAnswer(mixed $value, SurveyContentModel $question): string;
}
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\QuestionType;
final class QuestionTypeRegistry
{
/**
* @var array<string, QuestionTypeInterface>
*/
private array $questionTypes = [];
public function __construct(iterable $questionTypes)
{
foreach ($questionTypes as $questionType) {
if (!$questionType instanceof QuestionTypeInterface) {
continue;
}
$this->questionTypes[$questionType->getName()] = $questionType;
}
}
public function get(string $type): QuestionTypeInterface
{
if (!isset($this->questionTypes[$type])) {
throw new \InvalidArgumentException(sprintf('Unbekannter Fragetyp "%s".', $type));
}
return $this->questionTypes[$type];
}
/**
* @return array<string, string>
*/
public function getChoices(): array
{
return [
'Ja-Nein-Frage' => 'yes_no_maybe',
'Offene Frage' => 'text',
'Bewertungsfrage' => 'range',
'Single/Multiple-Choice' => 'choice',
];
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\QuestionType;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Symfony\Component\Form\Extension\Core\Type\RangeType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Range;
final class RangeQuestionType implements QuestionTypeInterface
{
public function getName(): string
{
return 'range';
}
public function getConstraints(SurveyContentModel $question): array
{
$constraints = [
new Range([
'min' => (int) $question->rangeMin,
'max' => (int) $question->rangeMax,
]),
];
if ('1' === (string) $question->mandatory) {
$constraints[] = new NotBlank();
}
return $constraints;
}
public function buildField(FormBuilderInterface $builder, SurveyContentModel $question, string $fieldName = 'answer'): void
{
$builder->add($fieldName, RangeType::class, [
'label' => false,
'constraints' => $this->getConstraints($question),
'attr' => [
'min' => (int) $question->rangeMin,
'max' => (int) $question->rangeMax,
'step' => max(1, (int) $question->rangeStep),
'class' => 'range range-primary w-full',
'data-controller' => 'range-preview',
'data-action' => 'input->range-preview#update',
],
]);
}
public function normalizeAnswer(mixed $value, SurveyContentModel $question): string
{
return (string) (int) $value;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\QuestionType;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
final class TextQuestionType implements QuestionTypeInterface
{
public function getName(): string
{
return 'text';
}
public function getConstraints(SurveyContentModel $question): array
{
$constraints = [new Length(['max' => 5000])];
if ('1' === (string) $question->mandatory) {
$constraints[] = new NotBlank();
}
return $constraints;
}
public function buildField(FormBuilderInterface $builder, SurveyContentModel $question, string $fieldName = 'answer'): void
{
$builder->add($fieldName, TextareaType::class, [
'label' => false,
'constraints' => $this->getConstraints($question),
'attr' => [
'rows' => 6,
'placeholder' => 'Ihre Antwort',
'class' => 'textarea textarea-bordered w-full min-h-40',
],
]);
}
public function normalizeAnswer(mixed $value, SurveyContentModel $question): string
{
return trim((string) $value);
}
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\QuestionType;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\Choice;
use Symfony\Component\Validator\Constraints\NotBlank;
final class YesNoMaybeQuestionType implements QuestionTypeInterface
{
public function getName(): string
{
return 'yes_no_maybe';
}
public function getConstraints(SurveyContentModel $question): array
{
$choices = ['yes', 'no'];
if ('1' === (string) $question->allowMaybe) {
$choices[] = 'maybe';
}
$constraints = [new Choice(['choices' => $choices])];
if ('1' === (string) $question->mandatory) {
$constraints[] = new NotBlank();
}
return $constraints;
}
public function buildField(FormBuilderInterface $builder, SurveyContentModel $question, string $fieldName = 'answer'): void
{
$choices = [
'Ja' => 'yes',
'Nein' => 'no',
];
if ('1' === (string) $question->allowMaybe) {
$choices['Vielleicht'] = 'maybe';
}
$builder->add($fieldName, ChoiceType::class, [
'label' => false,
'expanded' => true,
'multiple' => false,
'choices' => $choices,
'constraints' => $this->getConstraints($question),
]);
}
public function normalizeAnswer(mixed $value, SurveyContentModel $question): string
{
return mb_strtolower(trim((string) $value));
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Repository;
use Doctrine\DBAL\Connection;
final class SurveyAnswerRepository
{
public function __construct(private readonly Connection $connection)
{
}
public function saveAnswer(int $submissionId, int $questionId, string $questionType, string $value): void
{
$existingId = $this->connection->fetchOne(
'SELECT id FROM tl_survey_answer WHERE submission = ? AND question = ? LIMIT 1',
[$submissionId, $questionId],
);
if (false !== $existingId) {
$this->connection->update('tl_survey_answer', [
'questionType' => $questionType,
'value' => $value,
], [
'id' => (int) $existingId,
]);
return;
}
$this->connection->insert('tl_survey_answer', [
'pid' => $submissionId,
'submission' => $submissionId,
'question' => $questionId,
'questionType' => $questionType,
'value' => $value,
]);
}
public function hasAnswersForSurvey(int $surveyId): bool
{
return false !== $this->connection->fetchOne(
<<<'SQL'
SELECT ans.id
FROM tl_survey_answer ans
INNER JOIN tl_survey_submission sub ON sub.id = ans.submission
WHERE sub.survey = ?
LIMIT 1
SQL,
[$surveyId],
);
}
/**
* @return list<array{questionId:int,value:string}>
*/
public function findFinishedAnswersBySurvey(int $surveyId): array
{
$rows = $this->connection->fetchAllAssociative(
<<<'SQL'
SELECT
a.question AS questionId,
a.value
FROM tl_survey_answer a
INNER JOIN tl_survey_submission sub ON sub.id = a.submission
WHERE sub.survey = :survey AND sub.isFinished = 1
ORDER BY a.question ASC, a.id ASC
SQL,
['survey' => $surveyId],
);
return array_map(
static fn (array $row): array => [
'questionId' => (int) $row['questionId'],
'value' => (string) $row['value'],
],
$rows,
);
}
/**
* @return list<array<string, mixed>>
*/
public function findAnswersBySubmission(int $submissionId): array
{
return $this->connection->fetchAllAssociative(
<<<'SQL'
SELECT
a.id,
a.question AS questionId,
a.value,
a.questionType,
q.question
FROM tl_survey_answer a
INNER JOIN tl_survey_content q ON q.id = a.question
WHERE a.submission = :submission
ORDER BY q.sorting ASC
SQL,
['submission' => $submissionId],
);
}
}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Repository;
use Contao\CoreBundle\Framework\ContaoFramework;
use Doctrine\DBAL\Connection;
use Mummert\SurveyBundle\Model\SurveyConditionModel;
final class SurveyConditionRepository
{
public function __construct(
private readonly Connection $connection,
private readonly ContaoFramework $framework,
) {
}
public function findById(int $id): ?SurveyConditionModel
{
$adapter = $this->framework->getAdapter(SurveyConditionModel::class);
$model = $adapter->findByPk($id);
return $model instanceof SurveyConditionModel ? $model : null;
}
public function findMatchingCondition(int $surveyId, int $sourceQuestionId, string $answerValue): ?SurveyConditionModel
{
$id = $this->connection->fetchOne(
'SELECT id FROM tl_survey_condition WHERE pid = ? AND sourceQuestion = ? AND LOWER(TRIM(answerValue)) = LOWER(TRIM(?)) ORDER BY id ASC LIMIT 1',
[$surveyId, $sourceQuestionId, $answerValue],
);
return false === $id ? null : $this->findById((int) $id);
}
/**
* @return list<array<string, mixed>>
*/
public function findOverviewBySurvey(int $surveyId): array
{
return $this->connection->fetchAllAssociative(
<<<'SQL'
SELECT
c.id,
c.answerValue,
c.sourceQuestion,
c.targetQuestion,
source.question AS sourceQuestionLabel,
target.question AS targetQuestionLabel
FROM tl_survey_condition c
LEFT JOIN tl_survey_content source ON source.id = c.sourceQuestion
LEFT JOIN tl_survey_content target ON target.id = c.targetQuestion
WHERE c.pid = :survey
ORDER BY source.sorting ASC, c.id ASC
SQL,
['survey' => $surveyId],
);
}
public function create(int $surveyId, array $data): SurveyConditionModel
{
$this->connection->insert('tl_survey_condition', [
'pid' => $surveyId,
'sourceQuestion' => (int) ($data['sourceQuestion'] ?? 0),
'answerValue' => mb_strtolower(trim((string) ($data['answerValue'] ?? ''))),
'targetQuestion' => (int) ($data['targetQuestion'] ?? 0),
]);
$id = (int) $this->connection->lastInsertId();
return $this->findById($id) ?? throw new \RuntimeException('Bedingung konnte nicht angelegt werden.');
}
public function update(SurveyConditionModel $condition, array $data): void
{
$this->connection->update('tl_survey_condition', [
'sourceQuestion' => (int) ($data['sourceQuestion'] ?? $condition->sourceQuestion),
'answerValue' => mb_strtolower(trim((string) ($data['answerValue'] ?? $condition->answerValue))),
'targetQuestion' => (int) ($data['targetQuestion'] ?? $condition->targetQuestion),
], [
'id' => (int) $condition->id,
]);
}
public function delete(int $surveyId, int $conditionId): void
{
$this->connection->delete('tl_survey_condition', [
'id' => $conditionId,
'pid' => $surveyId,
]);
}
}
+177
View File
@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Repository;
use Doctrine\DBAL\Connection;
final class SurveyEditorRepository
{
public function __construct(private readonly Connection $connection)
{
}
public function isEditor(int $surveyId, int $memberId): bool
{
return false !== $this->connection->fetchOne(
'SELECT id FROM tl_survey_editor WHERE COALESCE(NULLIF(survey, 0), pid) = ? AND member = ? LIMIT 1',
[$surveyId, $memberId],
);
}
public function assignEditor(int $surveyId, int $memberId): void
{
if ($this->isEditor($surveyId, $memberId)) {
return;
}
$this->connection->insert('tl_survey_editor', [
'pid' => $surveyId,
'survey' => $surveyId,
'member' => $memberId,
]);
}
public function removeEditor(int $surveyId, int $memberId): void
{
$this->connection->delete('tl_survey_editor', [
'survey' => $surveyId,
'member' => $memberId,
]);
$this->connection->delete('tl_survey_editor', [
'pid' => $surveyId,
'member' => $memberId,
'survey' => 0,
]);
}
/**
* @return list<int>
*/
public function findSurveyIdsByMember(int $memberId): array
{
$surveyIds = $this->connection->fetchFirstColumn(
'SELECT DISTINCT COALESCE(NULLIF(survey, 0), pid) FROM tl_survey_editor WHERE member = ? ORDER BY COALESCE(NULLIF(survey, 0), pid)',
[$memberId],
);
return array_values(array_map('intval', $surveyIds));
}
/**
* @return list<int>
*/
public function findMemberIdsBySurvey(int $surveyId): array
{
$memberIds = $this->connection->fetchFirstColumn(
'SELECT DISTINCT member FROM tl_survey_editor WHERE COALESCE(NULLIF(survey, 0), pid) = ? AND member > 0 ORDER BY member',
[$surveyId],
);
return array_values(array_map('intval', $memberIds));
}
/**
* @return array<int, string>
*/
public function findSurveyChoices(): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT id, title FROM tl_survey ORDER BY title ASC, id ASC'
);
$choices = [];
foreach ($rows as $row) {
$title = trim((string) ($row['title'] ?? ''));
$choices[(int) $row['id']] = '' !== $title ? $title : 'Ohne Titel';
}
return $choices;
}
/**
* @param list<int> $memberIds
*/
public function syncMembersForSurvey(int $surveyId, array $memberIds): void
{
$memberIds = $this->normalizeIds($memberIds);
$existingMemberIds = $this->findMemberIdsBySurvey($surveyId);
foreach (array_diff($existingMemberIds, $memberIds) as $memberId) {
$this->removeEditor($surveyId, (int) $memberId);
}
foreach (array_diff($memberIds, $existingMemberIds) as $memberId) {
$this->assignEditor($surveyId, (int) $memberId);
}
}
/**
* @param list<int> $surveyIds
*/
public function syncSurveysForMember(int $memberId, array $surveyIds): void
{
$surveyIds = $this->normalizeIds($surveyIds);
$existingSurveyIds = $this->findSurveyIdsByMember($memberId);
foreach (array_diff($existingSurveyIds, $surveyIds) as $surveyId) {
$this->removeEditor((int) $surveyId, $memberId);
}
foreach (array_diff($surveyIds, $existingSurveyIds) as $surveyId) {
$this->assignEditor((int) $surveyId, $memberId);
}
}
/**
* @return list<array<string, mixed>>
*/
public function findEditorsBySurvey(int $surveyId): array
{
return $this->connection->fetchAllAssociative(
<<<'SQL'
SELECT
m.id,
m.firstname,
m.lastname,
m.email
FROM tl_survey_editor e
INNER JOIN tl_member m ON m.id = e.member
WHERE COALESCE(NULLIF(e.survey, 0), e.pid) = :survey
ORDER BY m.lastname ASC, m.firstname ASC
SQL,
['survey' => $surveyId],
);
}
/**
* @return array<int, string>
*/
public function findMemberChoices(): array
{
$rows = $this->connection->fetchAllAssociative('SELECT id, firstname, lastname, email FROM tl_member WHERE disable != 1 ORDER BY lastname ASC, firstname ASC');
$choices = [];
foreach ($rows as $row) {
$name = trim(sprintf('%s %s', (string) $row['firstname'], (string) $row['lastname']));
$choices[(int) $row['id']] = '' !== $name ? sprintf('%s <%s>', $name, (string) $row['email']) : (string) $row['email'];
}
return $choices;
}
/**
* @param list<int> $ids
*
* @return list<int>
*/
private function normalizeIds(array $ids): array
{
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $id): bool => $id > 0)));
sort($ids);
return $ids;
}
}
+163
View File
@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Repository;
use Contao\CoreBundle\Framework\ContaoFramework;
use Doctrine\DBAL\Connection;
use Mummert\SurveyBundle\Model\SurveyContentModel;
final class SurveyQuestionRepository
{
public function __construct(
private readonly Connection $connection,
private readonly ContaoFramework $framework,
) {
}
public function findById(int $id): ?SurveyContentModel
{
$adapter = $this->framework->getAdapter(SurveyContentModel::class);
$model = $adapter->findByPk($id);
return $model instanceof SurveyContentModel ? $model : null;
}
public function findByIdForSurvey(int $surveyId, int $questionId, bool $publishedOnly = false): ?SurveyContentModel
{
$row = $this->connection->fetchAssociative(
'SELECT id FROM tl_survey_content WHERE id = ? AND pid = ?'.($publishedOnly ? ' AND published = 1' : '').' LIMIT 1',
[$questionId, $surveyId],
);
if (!$row) {
return null;
}
return $this->findById((int) $row['id']);
}
/**
* @return list<SurveyContentModel>
*/
public function findAllBySurvey(int $surveyId, bool $publishedOnly = false): array
{
$sql = 'SELECT id FROM tl_survey_content WHERE pid = ?';
if ($publishedOnly) {
$sql .= ' AND published = 1';
}
$sql .= ' ORDER BY sorting ASC';
$ids = $this->connection->fetchFirstColumn($sql, [$surveyId]);
return $this->hydrate($ids);
}
public function create(int $surveyId, array $data): SurveyContentModel
{
$sorting = (int) $this->connection->fetchOne('SELECT COALESCE(MAX(sorting), 0) FROM tl_survey_content WHERE pid = ?', [$surveyId]) + 128;
$this->connection->insert('tl_survey_content', [
'pid' => $surveyId,
'sorting' => $sorting,
'type' => (string) ($data['type'] ?? 'yes_no_maybe'),
'question' => (string) ($data['question'] ?? ''),
'description' => (string) ($data['description'] ?? ''),
'mandatory' => !empty($data['mandatory']) ? '1' : '',
'published' => !empty($data['published']) ? '1' : '',
'allowMaybe' => 'yes_no_maybe' === (string) ($data['type'] ?? 'yes_no_maybe') && !empty($data['allowMaybe']) ? '1' : '',
'jumpOnYes' => 'yes_no_maybe' === (string) ($data['type'] ?? 'yes_no_maybe') ? max(0, (int) ($data['jumpOnYes'] ?? 0)) : 0,
'jumpOnNo' => 'yes_no_maybe' === (string) ($data['type'] ?? 'yes_no_maybe') ? max(0, (int) ($data['jumpOnNo'] ?? 0)) : 0,
'allowMultiple' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') && !empty($data['allowMultiple']) ? '1' : '',
'answerOption1' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') ? trim((string) ($data['answerOption1'] ?? '')) : '',
'answerOption2' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') ? trim((string) ($data['answerOption2'] ?? '')) : '',
'answerOption3' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') ? trim((string) ($data['answerOption3'] ?? '')) : '',
'answerOption4' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') ? trim((string) ($data['answerOption4'] ?? '')) : '',
'answerOption5' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') ? trim((string) ($data['answerOption5'] ?? '')) : '',
'answerOption6' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') ? trim((string) ($data['answerOption6'] ?? '')) : '',
'answerOption7' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') ? trim((string) ($data['answerOption7'] ?? '')) : '',
'answerOption8' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') ? trim((string) ($data['answerOption8'] ?? '')) : '',
'answerOption9' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') ? trim((string) ($data['answerOption9'] ?? '')) : '',
'answerOption10' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') ? trim((string) ($data['answerOption10'] ?? '')) : '',
'rangeMin' => (int) ($data['rangeMin'] ?? 0),
'rangeMax' => (int) ($data['rangeMax'] ?? 10),
'rangeStep' => max(1, (int) ($data['rangeStep'] ?? 1)),
]);
$id = (int) $this->connection->lastInsertId();
return $this->findById($id) ?? throw new \RuntimeException('Frage konnte nicht angelegt werden.');
}
public function update(SurveyContentModel $question, array $data): void
{
$this->connection->update('tl_survey_content', [
'type' => (string) ($data['type'] ?? $question->type),
'question' => (string) ($data['question'] ?? $question->question),
'description' => (string) ($data['description'] ?? $question->description),
'mandatory' => !empty($data['mandatory']) ? '1' : '',
'published' => !empty($data['published']) ? '1' : '',
'allowMaybe' => 'yes_no_maybe' === (string) ($data['type'] ?? $question->type) && !empty($data['allowMaybe']) ? '1' : '',
'jumpOnYes' => 'yes_no_maybe' === (string) ($data['type'] ?? $question->type) ? max(0, (int) ($data['jumpOnYes'] ?? $question->jumpOnYes)) : 0,
'jumpOnNo' => 'yes_no_maybe' === (string) ($data['type'] ?? $question->type) ? max(0, (int) ($data['jumpOnNo'] ?? $question->jumpOnNo)) : 0,
'allowMultiple' => 'choice' === (string) ($data['type'] ?? $question->type) && !empty($data['allowMultiple']) ? '1' : '',
'answerOption1' => 'choice' === (string) ($data['type'] ?? $question->type) ? trim((string) ($data['answerOption1'] ?? $question->answerOption1)) : '',
'answerOption2' => 'choice' === (string) ($data['type'] ?? $question->type) ? trim((string) ($data['answerOption2'] ?? $question->answerOption2)) : '',
'answerOption3' => 'choice' === (string) ($data['type'] ?? $question->type) ? trim((string) ($data['answerOption3'] ?? $question->answerOption3)) : '',
'answerOption4' => 'choice' === (string) ($data['type'] ?? $question->type) ? trim((string) ($data['answerOption4'] ?? $question->answerOption4)) : '',
'answerOption5' => 'choice' === (string) ($data['type'] ?? $question->type) ? trim((string) ($data['answerOption5'] ?? $question->answerOption5)) : '',
'answerOption6' => 'choice' === (string) ($data['type'] ?? $question->type) ? trim((string) ($data['answerOption6'] ?? $question->answerOption6)) : '',
'answerOption7' => 'choice' === (string) ($data['type'] ?? $question->type) ? trim((string) ($data['answerOption7'] ?? $question->answerOption7)) : '',
'answerOption8' => 'choice' === (string) ($data['type'] ?? $question->type) ? trim((string) ($data['answerOption8'] ?? $question->answerOption8)) : '',
'answerOption9' => 'choice' === (string) ($data['type'] ?? $question->type) ? trim((string) ($data['answerOption9'] ?? $question->answerOption9)) : '',
'answerOption10' => 'choice' === (string) ($data['type'] ?? $question->type) ? trim((string) ($data['answerOption10'] ?? $question->answerOption10)) : '',
'rangeMin' => (int) ($data['rangeMin'] ?? $question->rangeMin),
'rangeMax' => (int) ($data['rangeMax'] ?? $question->rangeMax),
'rangeStep' => max(1, (int) ($data['rangeStep'] ?? $question->rangeStep)),
], [
'id' => (int) $question->id,
]);
}
public function delete(int $surveyId, int $questionId): void
{
$this->connection->delete('tl_survey_content', [
'id' => $questionId,
'pid' => $surveyId,
]);
$this->connection->delete('tl_survey_condition', [
'sourceQuestion' => $questionId,
]);
$this->connection->delete('tl_survey_condition', [
'targetQuestion' => $questionId,
]);
}
public function countPublishedBySurvey(int $surveyId): int
{
return (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM tl_survey_content WHERE pid = ? AND published = 1',
[$surveyId],
);
}
private function hydrate(array $ids): array
{
$models = [];
foreach ($ids as $id) {
$model = $this->findById((int) $id);
if ($model instanceof SurveyContentModel) {
$models[] = $model;
}
}
return $models;
}
}
+346
View File
@@ -0,0 +1,346 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Repository;
use Contao\CoreBundle\Framework\ContaoFramework;
use Contao\StringUtil;
use Doctrine\DBAL\Connection;
use Mummert\SurveyBundle\Model\SurveyModel;
final class SurveyRepository
{
private ?bool $hasListMetadataColumns = null;
public function __construct(
private readonly Connection $connection,
private readonly ContaoFramework $framework,
) {
}
public function findById(int $id): ?SurveyModel
{
$adapter = $this->framework->getAdapter(SurveyModel::class);
$model = $adapter->findByPk($id);
return $model instanceof SurveyModel ? $model : null;
}
public function findByPublicAlias(string $alias, bool $publishedOnly = true): ?SurveyModel
{
$qb = $this->connection->createQueryBuilder();
$qb
->select('id')
->from('tl_survey')
->where('alias = :alias')
->setParameter('alias', $alias)
->setMaxResults(1)
;
if ($publishedOnly) {
$qb->andWhere('published = 1');
}
$id = $qb->executeQuery()->fetchOne();
if (false === $id) {
return null;
}
return $this->findById((int) $id);
}
/**
* @return list<array<string, mixed>>
*/
public function findEditableByMember(int $memberId): array
{
return $this->connection->fetchAllAssociative(
<<<'SQL'
SELECT
s.id,
s.title,
s.alias,
s.description,
s.published,
s.isLocked,
s.updatedAt,
COUNT(DISTINCT q.id) AS questionCount,
COUNT(DISTINCT a.id) AS answerCount
FROM tl_survey s
INNER JOIN tl_survey_editor e ON COALESCE(NULLIF(e.survey, 0), e.pid) = s.id AND e.member = :member
LEFT JOIN tl_survey_content q ON q.pid = s.id
LEFT JOIN tl_survey_submission sub ON sub.survey = s.id
LEFT JOIN tl_survey_answer a ON a.submission = sub.id
GROUP BY s.id
ORDER BY s.updatedAt DESC, s.title ASC
SQL,
['member' => $memberId],
);
}
public function create(int $memberId, array $data): SurveyModel
{
$now = time();
$title = trim((string) ($data['title'] ?? 'Neue Umfrage'));
$this->connection->insert('tl_survey', [
'tstamp' => $now,
'title' => $title,
'alias' => $this->ensurePublicAlias(null),
'category' => serialize($this->normalizeCategoryIds($data['category'] ?? [])),
'description' => (string) ($data['description'] ?? ''),
'published' => !empty($data['published']) ? '1' : '',
'isLocked' => '',
'createdBy' => $memberId,
'updatedBy' => $memberId,
'createdAt' => $now,
'updatedAt' => $now,
]);
$id = (int) $this->connection->lastInsertId();
$this->refreshListMetadata($id);
return $this->findById($id) ?? throw new \RuntimeException('Umfrage konnte nicht angelegt werden.');
}
public function update(SurveyModel $survey, int $memberId, array $data): void
{
$title = trim((string) ($data['title'] ?? $survey->title));
$this->connection->update('tl_survey', [
'tstamp' => time(),
'title' => $title,
'alias' => $this->ensurePublicAlias((string) $survey->alias, (int) $survey->id),
'category' => serialize($this->normalizeCategoryIds($data['category'] ?? StringUtil::deserialize($survey->category, true))),
'description' => (string) ($data['description'] ?? $survey->description),
'published' => !empty($data['published']) ? '1' : '',
'updatedBy' => $memberId,
'updatedAt' => time(),
], [
'id' => (int) $survey->id,
]);
$this->refreshListMetadata((int) $survey->id);
}
public function ensurePublicAlias(?string $alias, int $excludeId = 0): string
{
$candidate = strtolower(trim((string) $alias));
if ($this->isValidPublicAlias($candidate) && !$this->aliasExists($candidate, $excludeId)) {
return $candidate;
}
do {
$candidate = $this->generateRandomPublicAlias();
} while ($this->aliasExists($candidate, $excludeId));
return $candidate;
}
/**
* @param list<int> $categoryIds
*/
public function updateCategories(int $surveyId, array $categoryIds): void
{
$this->connection->update('tl_survey', [
'category' => serialize($this->normalizeCategoryIds($categoryIds)),
'tstamp' => time(),
'updatedAt' => time(),
], [
'id' => $surveyId,
]);
$this->refreshListMetadata($surveyId);
}
public function refreshListMetadata(int $surveyId): void
{
if (!$this->hasListMetadataColumns()) {
return;
}
$survey = $this->findById($surveyId);
if (!$survey instanceof SurveyModel) {
return;
}
$categoryIds = $this->normalizeCategoryIds($survey->category);
$categoryTitles = $this->fetchCategoryTitles($categoryIds);
$assignedMembers = $this->fetchAssignedMembers($surveyId);
$memberIds = [];
$memberNames = [];
foreach ($assignedMembers as $assignedMember) {
$memberIds[] = (int) $assignedMember['id'];
$memberNames[] = (string) $assignedMember['name'];
}
$this->connection->update('tl_survey', [
'categoryFilter' => implode(',', $categoryIds),
'categorySummary' => implode(', ', $categoryTitles),
'assignedMembersFilter' => implode(',', $memberIds),
'assignedMembersSummary' => implode(', ', $memberNames),
], [
'id' => $surveyId,
]);
}
public function touch(int $surveyId): void
{
$now = time();
$this->connection->update('tl_survey', [
'tstamp' => $now,
'updatedAt' => $now,
], [
'id' => $surveyId,
]);
}
public function lock(int $surveyId): void
{
$this->connection->update('tl_survey', [
'isLocked' => '1',
'tstamp' => time(),
'updatedAt' => time(),
], [
'id' => $surveyId,
]);
}
private function generateRandomPublicAlias(): string
{
$characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
$alias = '';
for ($index = 0; $index < 20; ++$index) {
$alias .= $characters[random_int(0, strlen($characters) - 1)];
}
return $alias;
}
private function aliasExists(string $alias, int $excludeId = 0): bool
{
$qb = $this->connection->createQueryBuilder();
$qb
->select('id')
->from('tl_survey')
->where('alias = :alias')
->setParameter('alias', $alias)
->setMaxResults(1)
;
if ($excludeId > 0) {
$qb
->andWhere('id != :excludeId')
->setParameter('excludeId', $excludeId)
;
}
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}>
*/
private function fetchAssignedMembers(int $surveyId): array
{
$rows = $this->connection->fetchAllAssociative(
<<<'SQL'
SELECT DISTINCT
m.id,
m.firstname,
m.lastname,
m.email
FROM tl_survey_editor e
INNER JOIN tl_member m ON m.id = e.member
WHERE COALESCE(NULLIF(e.survey, 0), e.pid) = ?
ORDER BY m.lastname ASC, m.firstname ASC, m.email ASC
SQL,
[$surveyId],
);
$members = [];
foreach ($rows as $row) {
$name = trim(sprintf('%s %s', (string) $row['firstname'], (string) $row['lastname']));
$members[] = [
'id' => (int) $row['id'],
'name' => '' !== $name ? $name : (string) $row['email'],
];
}
return $members;
}
private function hasListMetadataColumns(): bool
{
if (null !== $this->hasListMetadataColumns) {
return $this->hasListMetadataColumns;
}
$columnNames = array_map(
static fn (string $columnName): string => strtolower($columnName),
array_keys($this->connection->createSchemaManager()->listTableColumns('tl_survey')),
);
return $this->hasListMetadataColumns = !array_diff(
['categoryfilter', 'categorysummary', 'assignedmembersfilter', 'assignedmemberssummary'],
$columnNames,
);
}
private function isValidPublicAlias(string $alias): bool
{
return 1 === preg_match('/^[a-z0-9]{20}$/', $alias);
}
/**
* @return list<int>
*/
private 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;
}
}
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Repository;
use Contao\CoreBundle\Framework\ContaoFramework;
use Doctrine\DBAL\Connection;
use Mummert\SurveyBundle\Model\SurveySubmissionModel;
final class SurveySubmissionRepository
{
public function __construct(
private readonly Connection $connection,
private readonly ContaoFramework $framework,
) {
}
public function findById(int $id): ?SurveySubmissionModel
{
$adapter = $this->framework->getAdapter(SurveySubmissionModel::class);
$model = $adapter->findByPk($id);
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(
'SELECT id FROM tl_survey_submission WHERE survey = ? AND token = ? ORDER BY id DESC LIMIT 1',
[$surveyId, $token],
);
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();
$this->connection->insert('tl_survey_submission', [
'pid' => $surveyId,
'survey' => $surveyId,
'token' => $token,
'startedAt' => $now,
'completedAt' => 0,
'currentQuestion' => $currentQuestionId ?? 0,
'isFinished' => '',
]);
$id = (int) $this->connection->lastInsertId();
return $this->findById($id) ?? throw new \RuntimeException('Der Umfrage-Durchlauf konnte nicht angelegt werden.');
}
public function updateCurrentQuestion(int $submissionId, ?int $questionId): void
{
$this->connection->update('tl_survey_submission', [
'currentQuestion' => $questionId ?? 0,
], [
'id' => $submissionId,
]);
}
public function markFinished(int $submissionId): void
{
$this->connection->update('tl_survey_submission', [
'isFinished' => '1',
'completedAt' => time(),
'currentQuestion' => 0,
], [
'id' => $submissionId,
]);
}
/**
* @return list<array<string, mixed>>
*/
public function findOverviewBySurvey(int $surveyId): array
{
return $this->connection->fetchAllAssociative(
<<<'SQL'
SELECT
sub.id,
sub.token,
sub.startedAt,
sub.completedAt,
sub.isFinished,
COUNT(ans.id) AS answerCount
FROM tl_survey_submission sub
LEFT JOIN tl_survey_answer ans ON ans.submission = sub.id
WHERE sub.survey = :survey
GROUP BY sub.id
ORDER BY sub.startedAt DESC
SQL,
['survey' => $surveyId],
);
}
}
+3
View File
@@ -0,0 +1,3 @@
mummert_survey_bundle_controllers:
resource: ../../Controller/
type: attribute
@@ -0,0 +1,338 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<style>
@page {
margin: 20mm 14mm 18mm;
}
body {
margin: 0;
color: #222222;
font-family: DejaVu Sans, sans-serif;
font-size: 11px;
line-height: 1.45;
background: #ffffff;
}
h1,
h2,
h3,
p {
margin: 0;
}
.page-header {
margin-bottom: 18px;
padding: 16px 18px;
border: 1px solid #d9e1ea;
border-radius: 14px;
background: #f7fafc;
}
.page-header h1 {
font-size: 22px;
color: #0f4f88;
margin-bottom: 8px;
}
.meta-row {
margin-top: 12px;
}
.meta-pill {
display: inline-block;
margin-right: 8px;
margin-bottom: 6px;
padding: 4px 9px;
border-radius: 999px;
background: #eaf2fa;
color: #0f4f88;
font-size: 10px;
font-weight: bold;
}
.question-card {
margin-bottom: 14px;
padding: 14px 16px;
border: 1px solid #dde4ec;
border-radius: 14px;
page-break-inside: avoid;
break-inside: avoid;
}
.question-card h2 {
font-size: 16px;
color: #0f4f88;
margin-bottom: 6px;
}
.question-description {
margin-bottom: 8px;
color: #555e68;
}
.chart-box {
margin: 12px 0 14px;
padding: 10px;
border: 1px solid #e2e7ee;
border-radius: 12px;
background: #fbfdff;
text-align: center;
}
.chart-box img {
display: inline-block;
width: auto;
max-width: 100%;
height: auto;
}
.chart-box.chart-pie img {
width: 170px;
}
.chart-box.chart-line img {
width: 360px;
}
.chart-hint {
margin-top: 8px;
font-size: 9px;
color: #7b8794;
}
.stats-table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
.stats-table th,
.stats-table td {
padding: 7px 8px;
border-bottom: 1px solid #ebeff4;
text-align: left;
vertical-align: top;
}
.stats-table th {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #667280;
}
.swatch-col {
width: 26px;
}
.swatch {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 999px;
border: 1px solid #d3d9e1;
vertical-align: middle;
}
.numeric {
text-align: right;
white-space: nowrap;
}
.metric-row {
margin: 12px 0 2px;
}
.metric-box {
display: inline-block;
width: 29%;
margin-right: 3%;
padding: 9px 10px;
border: 1px solid #eadfcf;
border-radius: 10px;
background: #fff7f1;
vertical-align: top;
box-sizing: border-box;
}
.metric-box.last {
margin-right: 0;
}
.metric-label {
display: block;
margin-bottom: 4px;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #7b8794;
}
.metric-value {
font-size: 18px;
font-weight: bold;
color: #0f4f88;
}
.response-list {
margin-top: 12px;
padding-left: 18px;
}
.response-list li {
margin-bottom: 6px;
}
.footer-note {
margin-top: 20px;
font-size: 9px;
color: #7b8794;
text-align: right;
}
</style>
</head>
<body>
<header class="page-header">
<h1>{{ survey.title }}</h1>
<p>{{ survey.description ?: 'Keine Beschreibung hinterlegt.' }}</p>
<div class="meta-row">
<span class="meta-pill">{{ completedSubmissionCount }} abgeschlossene Teilnahmen</span>
<span class="meta-pill">{{ questionResults|length }} Fragen</span>
<span class="meta-pill">Export {{ exportedAt }}</span>
</div>
</header>
{% for question in questionResults %}
<section class="question-card">
<h2>{{ question.question }}</h2>
{% if question.description %}
<p class="question-description">{{ question.description }}</p>
{% endif %}
<div class="meta-row">
<span class="meta-pill">{{ question.totalAnswers }} Antworten</span>
<span class="meta-pill">{{ question.responseRate }}% Ruecklauf</span>
</div>
{% if question.pdfChartDataUri %}
<div class="chart-box chart-{{ question.pdfChartType ?: 'default' }}">
<img src="{{ question.pdfChartDataUri }}" alt="Diagramm zu {{ question.question }}">
{% if question.type == 'range' and question.pdfChartLegend %}
<div class="chart-hint">Die Punkte sind farblich den Werten in der Tabelle zugeordnet.</div>
{% endif %}
</div>
{% endif %}
{% if question.type in ['yes_no_maybe', 'choice'] %}
<table class="stats-table">
<thead>
<tr>
<th class="swatch-col">Farbe</th>
<th>Option</th>
<th class="numeric">Anzahl</th>
<th class="numeric">Anteil</th>
</tr>
</thead>
<tbody>
{% for option in question.pdfChartLegend %}
<tr>
<td><span class="swatch" style="background: {{ option.fillColor }}; border-color: {{ option.borderColor }};"></span></td>
<td>{{ option.label }}</td>
<td class="numeric">{{ option.count }}</td>
<td class="numeric">{{ option.percentage }}%</td>
</tr>
{% else %}
<tr>
<td colspan="4">Noch keine Daten vorhanden.</td>
</tr>
{% endfor %}
</tbody>
</table>
{% elseif question.type == 'range' %}
<div class="metric-row">
<div class="metric-box">
<span class="metric-label">Minimum</span>
<span class="metric-value">{{ question.minimum is not null ? question.minimum : '-' }}</span>
</div>
<div class="metric-box">
<span class="metric-label">Maximum</span>
<span class="metric-value">{{ question.maximum is not null ? question.maximum : '-' }}</span>
</div>
<div class="metric-box last">
<span class="metric-label">Durchschnitt</span>
<span class="metric-value">{{ question.average is not null ? question.average : '-' }}</span>
</div>
</div>
<table class="stats-table">
<thead>
<tr>
<th class="swatch-col">Farbe</th>
<th>Wert</th>
<th class="numeric">Anzahl</th>
<th class="numeric">Anteil</th>
</tr>
</thead>
<tbody>
{% for option in question.pdfChartLegend %}
<tr>
<td><span class="swatch" style="background: {{ option.fillColor }}; border-color: {{ option.borderColor }};"></span></td>
<td>{{ option.label }}</td>
<td class="numeric">{{ option.count }}</td>
<td class="numeric">{{ option.percentage }}%</td>
</tr>
{% else %}
<tr>
<td colspan="4">Noch keine Bewertungen vorhanden.</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
{% if question.pdfChartLegend %}
<table class="stats-table">
<thead>
<tr>
<th class="swatch-col">Farbe</th>
<th>Antwortgruppe</th>
<th class="numeric">Anzahl</th>
<th class="numeric">Anteil</th>
</tr>
</thead>
<tbody>
{% for option in question.pdfChartLegend %}
<tr>
<td><span class="swatch" style="background: {{ option.fillColor }}; border-color: {{ option.borderColor }};"></span></td>
<td>{{ option.label }}</td>
<td class="numeric">{{ option.count }}</td>
<td class="numeric">{{ option.percentage }}%</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<ol class="response-list">
{% for response in question.responses %}
<li>{{ response }}</li>
{% else %}
<li>Noch keine Freitextantworten vorhanden.</li>
{% endfor %}
</ol>
{% endif %}
</section>
{% else %}
<section class="question-card">
<h2>Keine Fragen vorhanden</h2>
<p>Fuer diese Umfrage sind noch keine Fragen angelegt.</p>
</section>
{% endfor %}
<p class="footer-note">PDF-Export des Survey-Bundles</p>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
<?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);
}
}
@@ -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;
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class SurveyBundle extends Bundle
{
public function getPath(): string
{
return dirname(__DIR__);
}
}