Files
survey-bundle/src/Controller/FrontendModule/MemberSurveyEditController.php
T

594 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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\SurveyEditorData;
use Mummert\SurveyBundle\Form\Model\SurveyQuestionData;
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\Service\SurveyEditorService;
use Mummert\SurveyBundle\Service\SurveyLoginInfoService;
use Mummert\SurveyBundle\Service\SurveyStructureService;
use Mummert\SurveyBundle\Service\SurveySubmissionService;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
#[AsFrontendModule(type: 'member_survey_edit', category: 'survey', template: '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,
private readonly SurveyStructureService $surveyStructureService,
private readonly SurveyLoginInfoService $surveyLoginInfoService,
private readonly FormFactoryInterface $formFactory,
#[Autowire('%kernel.debug%')]
private readonly bool $kernelDebug = false,
) {
}
protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response
{
$memberId = $this->resolveEditorMemberId($request);
if ($memberId <= 0) {
$template->set('loginRequired', true);
$template->set('loginUrl', $this->resolveLoginUrl());
$template->set('sessionMinutes', $this->surveyLoginInfoService->getSessionLifetimeMinutes());
$template->set('entryNotAllowed', false);
$template->set('backUrl', $this->resolveListUrl($model));
$template->set('createQuestionMode', false);
return $template->getResponse();
}
if (!$this->isEditorEntryAllowed($request)) {
$template->set('loginRequired', false);
$template->set('entryNotAllowed', true);
$template->set('createMode', false);
$template->set('survey', null);
$template->set('backUrl', $this->resolveListUrl($model));
$template->set('createQuestionMode', false);
return $template->getResponse();
}
$survey = $this->resolveSurvey($request, $memberId);
$createQuestionMode = $this->isCreateQuestionRequested($request);
if ($survey instanceof SurveyModel && $request->isMethod('POST') && $request->request->has('_survey_action')) {
return $this->handleAction($survey, $request, $model, $memberId);
}
$surveyForm = $this->formFactory->createNamed('survey_meta', SurveyEditorType::class, $survey instanceof SurveyModel ? SurveyEditorData::fromModel($survey) : new SurveyEditorData());
$surveyForm->handleRequest($request);
if ($surveyForm->isSubmitted() && $surveyForm->isValid() && (!$survey instanceof SurveyModel || '1' !== (string) $survey->published)) {
try {
if ($survey instanceof SurveyModel) {
$this->surveyEditorService->updateSurvey($survey, $memberId, $surveyForm->getData());
$this->addFlash('success', 'Die Umfrage wurde gespeichert.');
return new RedirectResponse($this->buildEditUrl($model, $request, (int) $survey->id));
}
$createdSurvey = $this->surveyEditorService->createSurvey($memberId, $surveyForm->getData());
$this->addFlash('success', 'Die Umfrage wurde angelegt.');
return new RedirectResponse($this->buildEditUrl($model, $request, (int) $createdSurvey->id));
} catch (\Throwable $exception) {
$this->addFlash('error', $exception->getMessage());
}
} elseif ($surveyForm->isSubmitted() && $survey instanceof SurveyModel && '1' === (string) $survey->published) {
$this->addFlash('error', 'Die Metadaten können nach der Veröffentlichung im Frontend nicht mehr geändert werden.');
}
if (!$survey instanceof SurveyModel) {
$template->set('loginRequired', false);
$template->set('entryNotAllowed', false);
$template->set('createMode', true);
$template->set('structureLocked', false);
$template->set('surveyForm', $surveyForm->createView());
$template->set('backUrl', $this->resolveListUrl($model));
$template->set('publicUrl', null);
$template->set('survey', null);
$template->set('outline', []);
$template->set('conditions', []);
$template->set('editors', []);
$template->set('submissions', []);
$template->set('activeQuestionId', 0);
$template->set('createQuestionMode', false);
return $template->getResponse();
}
$structureLocked = $this->surveyEditorService->isStructureLocked($survey);
$surveyId = (int) $survey->id;
$content = $this->surveyQuestionRepository->findAllBySurvey($surveyId);
$outline = $this->surveyStructureService->buildOutline($content);
$questions = array_values(array_filter($content, static fn ($item): bool => !SurveyQuestionRepository::isSection($item)));
$activeQuestion = $this->resolveActiveQuestion($request, $surveyId);
$activeQuestionId = $activeQuestion ? (int) $activeQuestion->id : 0;
$questionFormViews = [];
$newQuestionFormView = null;
$newQuestionContext = $this->resolveNewQuestionContext($request, $outline);
if (!$structureLocked) {
foreach ($content as $item) {
$itemId = (int) $item->id;
$questionForm = $this->formFactory->createNamed('survey_question_'.$itemId, SurveyQuestionEditorType::class, SurveyQuestionData::fromModel($item), [
'question_choices' => $this->buildQuestionChoices($questions, $itemId),
]);
$questionForm->handleRequest($request);
if ($questionForm->isSubmitted()) {
$activeQuestionId = $itemId;
$createQuestionMode = false;
if ($questionForm->isValid()) {
try {
$this->surveyEditorService->saveQuestion($survey, $questionForm->getData(), $itemId);
$this->addFlash('success', SurveyQuestionRepository::isSection($item) ? 'Der Themenbereich wurde aktualisiert.' : 'Die Frage wurde aktualisiert.');
return new RedirectResponse($this->buildEditUrl($model, $request, $surveyId));
} catch (\Throwable $exception) {
$this->addFlash('error', $exception->getMessage());
}
} else {
$this->addFlash('error', 'Frage konnte nicht gespeichert werden: '.$this->describeFormErrors($questionForm));
}
}
$questionFormViews[$itemId] = $questionForm->createView();
}
$newQuestionData = new SurveyQuestionData();
$newQuestionData->type = $newQuestionContext['type'];
$newQuestionData->insertAfter = $newQuestionContext['insertAfter'];
$newQuestionForm = $this->formFactory->createNamed('survey_question_new', SurveyQuestionEditorType::class, $newQuestionData, [
'question_choices' => $this->buildQuestionChoices($questions),
]);
$newQuestionForm->handleRequest($request);
if ($newQuestionForm->isSubmitted()) {
$activeQuestionId = 0;
$createQuestionMode = true;
if ($newQuestionForm->isValid()) {
try {
/** @var SurveyQuestionData $data */
$data = $newQuestionForm->getData();
$insertAfterId = (int) ($data->insertAfter ?? 0);
$this->surveyEditorService->saveQuestion($survey, $data, 0, $insertAfterId > 0 ? $insertAfterId : null);
$this->addFlash('success', SurveyQuestionRepository::TYPE_SECTION === $data->type ? 'Der Themenbereich wurde angelegt.' : 'Die Frage wurde angelegt.');
return new RedirectResponse($this->buildEditUrl($model, $request, $surveyId));
} catch (\Throwable $exception) {
$this->addFlash('error', $exception->getMessage());
}
} else {
$this->addFlash('error', 'Neue Frage konnte nicht gespeichert werden: '.$this->describeFormErrors($newQuestionForm));
}
}
$newQuestionFormView = $newQuestionForm->createView();
}
$template->set('loginRequired', false);
$template->set('entryNotAllowed', false);
$template->set('createMode', false);
$template->set('structureLocked', $structureLocked);
$template->set('createQuestionMode', $createQuestionMode);
$template->set('newQuestionContext', $newQuestionContext);
$template->set('survey', $survey);
$template->set('surveyForm', $surveyForm->createView());
$template->set('questionForms', $questionFormViews);
$template->set('newQuestionForm', $newQuestionFormView);
$template->set('outline', $outline);
$template->set('hasSections', count($outline) !== count($questions));
$template->set('canQuestionReorder', !$structureLocked && count($content) > 1);
$conditionOverview = $this->surveyConditionRepository->findOverviewBySurvey($surveyId);
$template->set('conditions', $this->buildJumpRuleOverview($questions, $conditionOverview));
$template->set('editors', $this->surveyEditorRepository->findEditorsBySurvey($surveyId));
$template->set('submissions', $this->surveySubmissionService->getSubmissionOverview($survey));
$template->set('activeQuestionId', $activeQuestionId);
$template->set('backUrl', $this->resolveListUrl($model));
$template->set('publicUrl', $this->resolveReaderUrl($model, (string) $survey->alias));
$template->set('metadataFormActionUrl', $this->buildEditUrl($model, $request, $surveyId));
$template->set('editBaseUrl', $this->buildEditUrl($model, $request, $surveyId));
return $template->getResponse();
}
private function resolveSurvey(Request $request, int $memberId): ?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.');
}
if (!$this->surveyEditorRepository->isEditor((int) $survey->id, $memberId)) {
throw new \RuntimeException('Sie dürfen diese Umfrage nicht bearbeiten.');
}
return $survey;
}
private function resolveEditorMemberId(Request $request): int
{
$user = $this->getUser();
if ($user instanceof FrontendUser) {
return (int) $user->id;
}
return $this->isDebugAccessAllowed($request) ? $this->resolveDebugMemberId($request) : 0;
}
private function resolveDebugMemberId(Request $request): int
{
$memberId = (int) $request->query->get('debugMember', 0);
if ($memberId > 0) {
return $memberId;
}
$surveyId = (int) $request->query->get('survey', 0);
if ($surveyId <= 0) {
return 0;
}
$memberIds = $this->surveyEditorRepository->findMemberIdsBySurvey($surveyId);
return $memberIds[0] ?? 0;
}
/**
* Debug-Zugang ohne Login: ausschließlich im Debug-Modus des Kernels (ddev/lokal)
* und nur auf lokalen Hosts. In Produktion (kernel.debug = false) ist er tot
* unabhängig vom Host-Header.
*/
private function isDebugAccessAllowed(Request $request): bool
{
if (!$this->kernelDebug || !$request->query->getBoolean('debugAccess')) {
return false;
}
$host = strtolower($request->getHost());
return 'localhost' === $host
|| '127.0.0.1' === $host
|| str_ends_with($host, '.ddev.site');
}
private function handleAction(SurveyModel $survey, Request $request, ModuleModel $model, int $memberId): 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('Ungültiger CSRF-Token.');
}
try {
if ('delete-question' === $action) {
$item = $this->surveyQuestionRepository->findByIdForSurvey((int) $survey->id, $itemId);
$this->surveyEditorService->deleteQuestion($survey, $itemId);
$this->addFlash('success', SurveyQuestionRepository::isSection($item) ? 'Der Themenbereich wurde gelöscht. Die zugehörigen Fragen bleiben erhalten.' : 'Die Frage wurde gelöscht.');
}
if ('reorder-questions' === $action) {
$this->surveyEditorService->reorderQuestions($survey, $this->parseQuestionOrder($request));
$this->addFlash('success', 'Die neue Reihenfolge der Fragen wurde gespeichert.');
}
if ('delete-condition' === $action) {
$this->surveyEditorService->deleteCondition($survey, $itemId);
$this->addFlash('success', 'Die Bedingung wurde gelöscht.');
}
} catch (\Throwable $exception) {
$this->addFlash('error', $exception->getMessage());
}
return new RedirectResponse($this->buildEditUrl($model, $request, (int) $survey->id));
}
/**
* @return list<int>
*/
private function parseQuestionOrder(Request $request): array
{
$rawOrder = trim((string) $request->request->get('question_order', ''));
if ('' === $rawOrder) {
throw new \RuntimeException('Die neue Reihenfolge der Fragen fehlt.');
}
return array_values(array_filter(
array_map('intval', explode(',', $rawOrder)),
static fn (int $questionId): bool => $questionId > 0,
));
}
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 isCreateQuestionRequested(Request $request): bool
{
return 'new' === trim((string) $request->query->get('question', ''));
}
/**
* Kontext für das "Neu anlegen"-Panel: gewünschter Typ (Frage/Themenbereich)
* und die Position ("hinter Inhalt X" = am Ende eines Themenbereichs).
*
* @param list<array<string, mixed>> $outline
*
* @return array{type:string,insertAfter:int|null,sectionTitle:string|null}
*/
private function resolveNewQuestionContext(Request $request, array $outline): array
{
$type = trim((string) $request->query->get('newType', ''));
$insertAfter = (int) $request->query->get('after', 0);
$sectionTitle = null;
if (SurveyQuestionRepository::TYPE_SECTION !== $type) {
$type = 'yes_no_maybe';
}
if ($insertAfter > 0) {
$found = false;
foreach ($outline as $entry) {
if ($entry['id'] === $insertAfter) {
$found = true;
$sectionTitle = $entry['sectionTitle'];
break;
}
}
if (!$found) {
$insertAfter = 0;
}
}
return [
'type' => $type,
'insertAfter' => $insertAfter > 0 ? $insertAfter : null,
'sectionTitle' => $sectionTitle,
];
}
private function isEditorEntryAllowed(Request $request): bool
{
if ((int) $request->query->get('survey', 0) > 0) {
return true;
}
return $request->query->getBoolean('create');
}
/**
* Sprungziel-Auswahl: nur Fragen (keine Themenbereiche), mit Positionsnummern.
*
* @param list<\Mummert\SurveyBundle\Model\SurveyContentModel> $questions
*
* @return array<int, string>
*/
private function buildQuestionChoices(array $questions, int $excludeQuestionId = 0): array
{
$choices = [];
$position = 1;
foreach ($questions as $question) {
if ($excludeQuestionId > 0 && (int) $question->id === $excludeQuestionId) {
++$position;
continue;
}
$choices[(int) $question->id] = sprintf('%d. %s', $position++, (string) $question->question);
}
return $choices;
}
/**
* @param array<int, mixed> $questions
* @param list<array<string, mixed>> $conditionOverview
*
* @return list<array<string, mixed>>
*/
private function buildJumpRuleOverview(array $questions, array $conditionOverview): array
{
$questionLabels = [];
$questionSortings = [];
$questionPositions = [];
$rules = [];
$position = 1;
foreach ($questions as $question) {
$questionId = (int) ($question->id ?? 0);
if ($questionId <= 0) {
continue;
}
$questionLabels[$questionId] = (string) ($question->question ?? '');
$questionSortings[$questionId] = (int) ($question->sorting ?? 0);
$questionPositions[$questionId] = $position++;
}
foreach ($questions as $question) {
$questionId = (int) ($question->id ?? 0);
if ($questionId <= 0) {
continue;
}
if ('yes_no_maybe' !== (string) ($question->type ?? '')) {
continue;
}
foreach (['Ja' => (int) ($question->jumpOnYes ?? 0), 'Nein' => (int) ($question->jumpOnNo ?? 0)] as $answerValue => $targetQuestionId) {
if ($targetQuestionId <= 0 || !isset($questionLabels[$targetQuestionId])) {
continue;
}
$rules[] = [
'id' => 'question-'.$questionId.'-'.$targetQuestionId.'-'.$answerValue,
'answerValue' => $answerValue,
'sourceQuestion' => $questionId,
'targetQuestion' => $targetQuestionId,
'sourcePosition' => $questionPositions[$questionId] ?? 0,
'targetPosition' => $questionPositions[$targetQuestionId] ?? 0,
'sourceQuestionLabel' => (string) ($question->question ?? ''),
'targetQuestionLabel' => $questionLabels[$targetQuestionId] ?? '',
'sourceSorting' => $questionSortings[$questionId] ?? 0,
];
}
}
foreach ($conditionOverview as $condition) {
$sourceQuestionId = (int) ($condition['sourceQuestion'] ?? 0);
$rules[] = [
'id' => $condition['id'] ?? 0,
'answerValue' => (string) ($condition['answerValue'] ?? ''),
'sourceQuestion' => $sourceQuestionId,
'targetQuestion' => (int) ($condition['targetQuestion'] ?? 0),
'sourcePosition' => $questionPositions[$sourceQuestionId] ?? 0,
'targetPosition' => $questionPositions[(int) ($condition['targetQuestion'] ?? 0)] ?? 0,
'sourceQuestionLabel' => (string) ($condition['sourceQuestionLabel'] ?? ''),
'targetQuestionLabel' => (string) ($condition['targetQuestionLabel'] ?? ''),
'sourceSorting' => $questionSortings[$sourceQuestionId] ?? 0,
];
}
usort($rules, static function (array $left, array $right): int {
$sortingComparison = ((int) ($left['sourceSorting'] ?? 0)) <=> ((int) ($right['sourceSorting'] ?? 0));
if (0 !== $sortingComparison) {
return $sortingComparison;
}
return ((int) ($left['targetQuestion'] ?? 0)) <=> ((int) ($right['targetQuestion'] ?? 0));
});
return $rules;
}
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, Request $request, int $surveyId): string
{
$parameters = ['survey' => $surveyId];
if ($request->query->getBoolean('debugAccess') && $this->isDebugAccessAllowed($request)) {
$parameters['debugAccess'] = '1';
}
$debugMemberId = (int) $request->query->get('debugMember', 0);
if ($debugMemberId > 0) {
$parameters['debugMember'] = (string) $debugMemberId;
}
$basePath = $request->getBaseUrl().$request->getPathInfo();
if ('' !== $basePath) {
$queryString = http_build_query($parameters);
$url = $basePath.('' !== $queryString ? '?'.$queryString : '');
} else {
$page = $this->getPageModel();
$url = $page instanceof PageModel ? $this->generateContentUrl($page, $parameters) : $this->resolveListUrl($model);
}
return $url;
}
private function describeFormErrors(FormInterface $form): string
{
$messages = [];
foreach ($form->getErrors(true, true) as $error) {
$origin = $error->getOrigin();
$name = $origin instanceof FormInterface ? $origin->getName() : 'form';
$messages[] = sprintf('%s: %s', $name, $error->getMessage());
}
return [] !== $messages ? implode(' | ', array_unique($messages)) : 'Unbekannter Formularfehler.';
}
private function resolvePage(int $pageId): ?PageModel
{
if ($pageId <= 0) {
return null;
}
$page = $this->getContaoAdapter(PageModel::class)->findById($pageId);
return $page instanceof PageModel ? $page : null;
}
private function resolveLoginUrl(): ?string
{
$page = $this->surveyLoginInfoService->findLoginPage();
return $page instanceof PageModel ? $this->generateContentUrl($page) : null;
}
}