Initial survey bundle import
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\SurveyBundle\Form\Model;
|
||||
|
||||
final class SurveyAnswerData
|
||||
{
|
||||
public mixed $answer = null;
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' => [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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' => [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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, '_-'));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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__);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user