From db7094fd1e4f81087ea3a4e88615ab9a54f7b517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Mummert?= Date: Sun, 14 Jun 2026 13:00:05 +0200 Subject: [PATCH] Fix frontend CSRF, clean reader URLs, and review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSRF / actions: - List action forms now send both REQUEST_TOKEN (Contao gate) and _token (Symfony, validated via isCsrfTokenValid) — mirrors the editor; fixes the 400/500 "Ungültiger CSRF-Token" on publish/toggle/duplicate/delete Public reader / URLs: - Reader & results read auto_item via Input::get (marks the route param used) so clean path URLs // no longer 404 - List controller generates path URLs via ['parameters' => '/'.$alias] - Allow re-participation in the same session after a 60s cooldown Review fixes: - Results: merge answer snapshots by questionId+type (no more split cards on label edits); seed "Vielleicht" when allowMaybe so screen matches Excel - Reader: replace generic answer hint with per-option "weiter zu Frage X" only when a jump is configured; strip HTML tags from survey description - PDF: render free-text frequency summary; drop duplicate "Minimum" label - Drag-sort: bundle SortableJS locally, drop the duplicated inline sort CSS/JS from _survey_assets, throttle the backend MutationObserver - Backend: move hardcoded tl_survey operation labels to TL_LANG (de+en) - Remove dead code: SurveyEditorVoter, SurveyPermissionService, categoryFilter/categorySummary + helpers, orphaned publishSurvey lang key Reader layout: - max-width 1000px (other views keep 1400px); back/next buttons fill the width as equal, card-sized halves; sort handle icon offset tweak Docs: update AI_HANDOVER (dual-token CSRF, clean URLs) and README Co-Authored-By: Claude Opus 4.8 (1M context) --- AI_HANDOVER.md | 16 +- README.md | 16 ++ contao/dca/tl_survey.php | 23 +- contao/languages/de/tl_survey.php | 7 +- contao/languages/en/tl_survey.php | 7 +- .../frontend/_survey_assets.html.twig | 248 ++---------------- .../_survey_question_sort_assets.html.twig | 10 +- .../frontend/member_survey_list.html.twig | 5 + .../templates/frontend/show_survey.html.twig | 7 +- public/css/auth-module.css | 1 + public/js/sortable.min.js | 2 + public/js/survey-backend.js | 26 +- public/js/survey-frontend-editor-sort.js | 2 +- .../MemberSurveyListController.php | 14 +- .../FrontendModule/ShowSurveyController.php | 32 ++- .../ShowSurveyResultsController.php | 5 +- src/EventListener/SurveyDcaListener.php | 49 ---- src/Form/Model/SurveyEditorData.php | 4 +- src/Repository/SurveyRepository.php | 39 +-- src/Repository/SurveySubmissionRepository.php | 18 -- .../views/pdf/survey_results.html.twig | 1 - src/Security/SurveyEditorVoter.php | 36 --- src/Service/SurveyFlowService.php | 23 ++ src/Service/SurveyPermissionService.php | 29 -- src/Service/SurveyResultsPdfService.php | 12 +- src/Service/SurveyResultsViewService.php | 25 +- src/Service/SurveySubmissionService.php | 25 +- translations/messages.de.yaml | 2 +- translations/messages.en.yaml | 2 +- 29 files changed, 230 insertions(+), 456 deletions(-) create mode 100644 public/js/sortable.min.js delete mode 100644 src/Security/SurveyEditorVoter.php delete mode 100644 src/Service/SurveyPermissionService.php diff --git a/AI_HANDOVER.md b/AI_HANDOVER.md index 6448250..7cb66aa 100644 --- a/AI_HANDOVER.md +++ b/AI_HANDOVER.md @@ -124,10 +124,13 @@ Aktuelle Button- und Sichtbarkeitslogik pro Umfrage: - Schaltet `isActive` - Ist bewusst kein Auge mehr -Wichtiger technischer Punkt: +Wichtiger technischer Punkt (CSRF): -- Die Frontend-Listenaktionen arbeiten nur noch mit dem Contao-`REQUEST_TOKEN` plus Berechtigungsprüfung. - - Die zusätzliche Symfony-Aktions-CSRF-Prüfung wurde bewusst entfernt, weil sie in diesem Frontend-Flow zu ungültigen Tokens führte. +- Jedes Frontend-Aktionsformular (Liste **und** Editor) sendet bewusst **zwei** Tokens: + - `REQUEST_TOKEN` (`{{ contao.request_token }}`) – nötig für Contaos globalen Frontend-POST-Gate; fehlt er, lehnt Contao den POST schon vor dem Controller mit HTTP 400 ab. + - `_token` (`{{ csrf_token('-' ~ id) }}`) – wird im Controller mit `isCsrfTokenValid('-'.$id, $token)` geprüft (session-basierter Symfony-Token). +- Beide sind erforderlich. Frühere Stände hatten nur einen davon – das führte zu „Ungültiger CSRF-Token" (500) bzw. Bad Request (400). Der Listen-Controller spiegelt jetzt exakt die Token-Mechanik des Editors. +- Der Contao-`REQUEST_TOKEN` allein reicht **nicht** als Controller-Prüfung: Er ist cookie-gebunden und bei anonymen/fragmentierten Frontend-Requests nicht stabil (Contao setzt das CSRF-Cookie dort teils gar nicht). Deshalb die zusätzliche session-basierte `_token`-Prüfung. ### 2. Frontend-Editor @@ -191,6 +194,13 @@ Wichtige Regeln: - Entwurfsansicht arbeitet ohne Speicherung - Öffentliche Ansicht speichert Antworten und erstellt/führt Submission fort - Rückwärtsnavigation ist implementiert +- Nach Abschluss zeigt der gleiche Browser-Token für eine kurze Cooldown-Zeit (`RESTART_COOLDOWN_SECONDS = 60`) die Danke-Seite; danach darf dieselbe Session erneut teilnehmen (`SurveySubmissionService::isRestartAllowed`). + +Saubere Link-URLs (Reader und Ergebnisseite): + +- Die Umfrage wird über den Alias als **Pfad-Segment** adressiert: `//` statt `?auto_item=`. +- URL-Erzeugung: per Route-Platzhalter `['parameters' => '/'.$alias]` (Frontend-Liste `MemberSurveyListController`, Backend `SurveyDcaListener::buildSurveyPageUrl`), nicht `['auto_item' => $alias]`. +- Auslesen im Controller: `$this->getContaoAdapter(Input::class)->get('auto_item')`. Wichtig: `Input::get` markiert den Route-Parameter als **benutzt** – sonst wirft Contaos `LegacyRouteParametersListener` eine 404 wegen „unbenutzter Parameter". Ein reines `$request->query->get('auto_item')` reicht für Pfad-URLs nicht. ### 4. Ergebnisseite diff --git a/README.md b/README.md index 49f14c2..6c63460 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,22 @@ Das Bundle stellt Frontend- und Backend-Funktionen für die Erstellung, Verwaltu Ziel des Bundles ist eine möglichst native Integration in Contao, sodass Umfragen sich wie ein regulärer Bestandteil der bestehenden System- und Redaktionsumgebung verhalten. +## Dokumentation + +Architektur, Zustandslogik, bewusste Produktentscheidungen und Einstiegspunkte sind ausführlich in [`AI_HANDOVER.md`](AI_HANDOVER.md) beschrieben. + +Zwei wiederkehrend relevante technische Konventionen: + +- **CSRF in Frontend-Aktionsformularen:** Jedes POST-Formular sendet **zwei** Tokens – `REQUEST_TOKEN` (Contaos globaler Frontend-Gate) und `_token` (`csrf_token('-' ~ id)`, im Controller via `isCsrfTokenValid` geprüft). Beide sind nötig. +- **Saubere Link-URLs:** Reader und Ergebnisseite werden als Pfad adressiert (`//`). Erzeugung über `['parameters' => '/'.$alias]`, Auslesen über `Input::get('auto_item')` (markiert den Route-Parameter als benutzt – sonst Contao-404). + +Nach Änderungen an DCA, Backend-CSS oder JS: + +```bash +php vendor/bin/contao-console assets:install public --symlink --relative +php vendor/bin/contao-console cache:clear --env=prod +``` + ## Rechtlicher Hinweis Dieses Bundle ist proprietär und nicht zur öffentlichen Weitergabe bestimmt. diff --git a/contao/dca/tl_survey.php b/contao/dca/tl_survey.php index 2df7a05..0b7b463 100644 --- a/contao/dca/tl_survey.php +++ b/contao/dca/tl_survey.php @@ -48,14 +48,14 @@ $GLOBALS['TL_DCA']['tl_survey'] = [ ], 'operations' => [ 'editheader' => [ - 'label' => ['Umfrage bearbeiten', 'Umfrage bearbeiten'], + 'label' => &$GLOBALS['TL_LANG']['tl_survey']['editheader'], 'href' => 'act=edit', 'icon' => 'edit.svg', 'primary' => true, 'showInHeader' => false, ], 'edit' => [ - 'label' => ['Fragen bearbeiten', 'Fragen der Umfrage bearbeiten'], + 'label' => &$GLOBALS['TL_LANG']['tl_survey']['edit'], 'href' => 'table=tl_survey_content', 'icon' => 'children.svg', 'primary' => true, @@ -81,13 +81,13 @@ $GLOBALS['TL_DCA']['tl_survey'] = [ 'button_callback' => [SurveyDcaListener::class, 'generateDuplicateSurveyButton'], ], 'delete' => [ - 'label' => ['Löschen', 'Eintrag wirklich löschen?'], + 'label' => &$GLOBALS['TL_LANG']['tl_survey']['delete'], 'href' => 'act=delete', 'icon' => 'delete.svg', 'attributes' => 'onclick="if(!confirm(\'Eintrag wirklich löschen?\'))return false;Backend.getScrollOffset()"', ], 'show' => [ - 'label' => ['Details', 'Details des Elements ID %s anzeigen'], + 'label' => &$GLOBALS['TL_LANG']['tl_survey']['show'], 'href' => 'act=show', 'icon' => 'show.svg', ], @@ -148,17 +148,6 @@ $GLOBALS['TL_DCA']['tl_survey'] = [ 'relation' => ['type' => 'hasMany', 'load' => 'lazy', 'table' => 'tl_survey_category', 'field' => 'id'], 'sql' => 'blob NULL', ], - 'categoryFilter' => [ - 'label' => &$GLOBALS['TL_LANG']['tl_survey']['categoryFilter'], - 'inputType' => 'select', - 'options_callback' => [SurveyDcaListener::class, 'getUsedCategoryFilterOptions'], - 'eval' => ['includeBlankOption' => true, 'chosen' => true, 'findInSet' => true], - 'sql' => "varchar(255) NOT NULL default ''", - ], - 'categorySummary' => [ - 'label' => &$GLOBALS['TL_LANG']['tl_survey']['categorySummary'], - 'sql' => 'text NULL', - ], 'published' => [ 'label' => &$GLOBALS['TL_LANG']['tl_survey']['published'], 'inputType' => 'checkbox', @@ -253,7 +242,7 @@ if ('cli' !== PHP_SAPI) { System::getContainer()->get('database_connection')->fetchFirstColumn('SHOW COLUMNS FROM tl_survey'), ); $hasListMetadataColumns = !array_diff( - ['categoryfilter', 'categorysummary', 'templatefilter', 'templatesummary', 'assignedmembersfilter', 'assignedmemberssummary'], + ['templatefilter', 'templatesummary', 'assignedmembersfilter', 'assignedmemberssummary'], $columnNames, ); } catch (\Throwable) { @@ -264,8 +253,6 @@ if ('cli' !== PHP_SAPI) { $GLOBALS['TL_DCA']['tl_survey']['list']['label']['fields'] = ['title', 'alias']; unset( - $GLOBALS['TL_DCA']['tl_survey']['fields']['categoryFilter'], - $GLOBALS['TL_DCA']['tl_survey']['fields']['categorySummary'], $GLOBALS['TL_DCA']['tl_survey']['fields']['templateFilter'], $GLOBALS['TL_DCA']['tl_survey']['fields']['templateSummary'], $GLOBALS['TL_DCA']['tl_survey']['fields']['assignedMembersFilter'], diff --git a/contao/languages/de/tl_survey.php b/contao/languages/de/tl_survey.php index 414f4e7..d15a050 100644 --- a/contao/languages/de/tl_survey.php +++ b/contao/languages/de/tl_survey.php @@ -3,8 +3,6 @@ $GLOBALS['TL_LANG']['tl_survey']['title'] = ['Titel', 'Titel der Umfrage.']; $GLOBALS['TL_LANG']['tl_survey']['alias'] = ['Öffentlicher Link-Schlüssel', 'Wird automatisch als 20-stelliger Link-Schlüssel erzeugt.']; $GLOBALS['TL_LANG']['tl_survey']['category'] = ['Kategorien', 'Ordnet die Umfrage einer oder mehreren Kategorien zu.']; -$GLOBALS['TL_LANG']['tl_survey']['categoryFilter'] = ['Kategorien', 'Filtert die Liste nach einer Umfrage-Kategorie.']; -$GLOBALS['TL_LANG']['tl_survey']['categorySummary'] = ['Kategorien', 'Zugeordnete Kategorien der Umfrage.']; $GLOBALS['TL_LANG']['tl_survey']['isTemplate'] = ['Umfrage ist eine Vorlage', 'Wird den zugewiesenen Nutzern als Vorlage angezeigt.']; $GLOBALS['TL_LANG']['tl_survey']['templateFilter'] = ['Vorlage', 'Filtert die Liste nach Vorlagenstatus.']; $GLOBALS['TL_LANG']['tl_survey']['templateSummary'] = ['Vorlage', 'Zeigt an, ob die Umfrage als Vorlage markiert ist.']; @@ -19,7 +17,6 @@ $GLOBALS['TL_LANG']['tl_survey']['meta_legend'] = 'Verwaltungsdaten'; $GLOBALS['TL_LANG']['tl_survey']['published'] = ['Veröffentlicht', 'Die Umfrage öffentlich freischalten.']; $GLOBALS['TL_LANG']['tl_survey']['isActive'] = ['Vorübergehend aktiv', 'Schaltet eine bereits veröffentlichte Umfrage öffentlich an oder aus.']; $GLOBALS['TL_LANG']['tl_survey']['isLocked'] = ['Gesperrt', 'Struktur ist wegen vorhandener Antworten gesperrt.']; -$GLOBALS['TL_LANG']['tl_survey']['publishSurvey'] = ['Umfrage veröffentlichen', 'Veröffentlicht die Umfrage endgültig.']; $GLOBALS['TL_LANG']['tl_survey']['publishedWarning'] = ['Veröffentlichungshinweis', 'Zeigt einen Warnhinweis bei veröffentlichten Umfragen.']; $GLOBALS['TL_LANG']['tl_survey']['toggleActive'] = ['Sichtbarkeit umschalten', 'Schaltet die veröffentlichte Umfrage vorübergehend an oder aus.']; $GLOBALS['TL_LANG']['tl_survey']['assignedMembers'] = ['Zugewiesene Mitglieder', 'Wählen Sie die Mitglieder aus, die diese Umfrage bearbeiten dürfen.']; @@ -30,6 +27,10 @@ $GLOBALS['TL_LANG']['tl_survey']['duplicateSurvey'] = ['Duplizieren', 'Diese Umf $GLOBALS['TL_LANG']['tl_survey']['duplicateSurveyConfirm'] = 'Sind Sie sicher, dass Sie die Umfrage "%s" mit allen Fragen duplizieren wollen?'; $GLOBALS['TL_LANG']['tl_survey']['duplicateSurveyDone'] = 'Die Umfrage "%s" wurde dupliziert.'; $GLOBALS['TL_LANG']['tl_survey']['showResults'] = ['Ergebnisse anzeigen', 'Die Ergebnisse dieser Umfrage im Frontend anzeigen']; +$GLOBALS['TL_LANG']['tl_survey']['editheader'] = ['Umfrage bearbeiten', 'Umfrage bearbeiten']; +$GLOBALS['TL_LANG']['tl_survey']['edit'] = ['Fragen bearbeiten', 'Fragen der Umfrage bearbeiten']; +$GLOBALS['TL_LANG']['tl_survey']['delete'] = ['Löschen', 'Eintrag wirklich löschen?']; +$GLOBALS['TL_LANG']['tl_survey']['show'] = ['Details', 'Details des Elements ID %s anzeigen']; $GLOBALS['TL_LANG']['tl_survey']['createdBy'] = ['Angelegt von', 'ID des erstellenden Mitglieds.']; $GLOBALS['TL_LANG']['tl_survey']['updatedBy'] = ['Aktualisiert von', 'ID des zuletzt bearbeitenden Mitglieds.']; $GLOBALS['TL_LANG']['tl_survey']['createdAt'] = ['Angelegt am', 'Zeitpunkt der Erstellung.']; diff --git a/contao/languages/en/tl_survey.php b/contao/languages/en/tl_survey.php index 024b1e7..b53059d 100644 --- a/contao/languages/en/tl_survey.php +++ b/contao/languages/en/tl_survey.php @@ -3,8 +3,6 @@ $GLOBALS['TL_LANG']['tl_survey']['title'] = ['Title', 'Title of the survey.']; $GLOBALS['TL_LANG']['tl_survey']['alias'] = ['Public link key', 'Automatically generated as a 20-character link key.']; $GLOBALS['TL_LANG']['tl_survey']['category'] = ['Categories', 'Assigns the survey to one or more categories.']; -$GLOBALS['TL_LANG']['tl_survey']['categoryFilter'] = ['Categories', 'Filters the list by a survey category.']; -$GLOBALS['TL_LANG']['tl_survey']['categorySummary'] = ['Categories', 'Assigned categories of the survey.']; $GLOBALS['TL_LANG']['tl_survey']['isTemplate'] = ['Survey is a template', 'Shown to assigned users as a template.']; $GLOBALS['TL_LANG']['tl_survey']['templateFilter'] = ['Template', 'Filters the list by template status.']; $GLOBALS['TL_LANG']['tl_survey']['templateSummary'] = ['Template', 'Shows whether the survey is marked as a template.']; @@ -19,7 +17,6 @@ $GLOBALS['TL_LANG']['tl_survey']['meta_legend'] = 'Administrative data'; $GLOBALS['TL_LANG']['tl_survey']['published'] = ['Published', 'Make the survey publicly accessible.']; $GLOBALS['TL_LANG']['tl_survey']['isActive'] = ['Temporarily active', 'Turns an already published survey on or off publicly.']; $GLOBALS['TL_LANG']['tl_survey']['isLocked'] = ['Locked', 'Structure is locked because answers already exist.']; -$GLOBALS['TL_LANG']['tl_survey']['publishSurvey'] = ['Publish survey', 'Publishes the survey permanently.']; $GLOBALS['TL_LANG']['tl_survey']['publishedWarning'] = ['Publication warning', 'Shows a warning hint for already published surveys.']; $GLOBALS['TL_LANG']['tl_survey']['toggleActive'] = ['Toggle visibility', 'Temporarily switches the published survey on or off.']; $GLOBALS['TL_LANG']['tl_survey']['assignedMembers'] = ['Assigned members', 'Choose the members who are allowed to edit this survey.']; @@ -30,6 +27,10 @@ $GLOBALS['TL_LANG']['tl_survey']['duplicateSurvey'] = ['Duplicate', 'Duplicate t $GLOBALS['TL_LANG']['tl_survey']['duplicateSurveyConfirm'] = 'Are you sure you want to duplicate the survey "%s" with all questions?'; $GLOBALS['TL_LANG']['tl_survey']['duplicateSurveyDone'] = 'The survey "%s" has been duplicated.'; $GLOBALS['TL_LANG']['tl_survey']['showResults'] = ['Show results', 'Display the results of this survey in the frontend']; +$GLOBALS['TL_LANG']['tl_survey']['editheader'] = ['Edit survey', 'Edit survey']; +$GLOBALS['TL_LANG']['tl_survey']['edit'] = ['Edit questions', 'Edit the survey questions']; +$GLOBALS['TL_LANG']['tl_survey']['delete'] = ['Delete', 'Really delete the entry?']; +$GLOBALS['TL_LANG']['tl_survey']['show'] = ['Details', 'Show the details of entry ID %s']; $GLOBALS['TL_LANG']['tl_survey']['createdBy'] = ['Created by', 'ID of the creating member.']; $GLOBALS['TL_LANG']['tl_survey']['updatedBy'] = ['Updated by', 'ID of the member who last edited it.']; $GLOBALS['TL_LANG']['tl_survey']['createdAt'] = ['Created on', 'Creation time.']; diff --git a/contao/templates/frontend/_survey_assets.html.twig b/contao/templates/frontend/_survey_assets.html.twig index 0ca8f1c..ce62454 100644 --- a/contao/templates/frontend/_survey_assets.html.twig +++ b/contao/templates/frontend/_survey_assets.html.twig @@ -85,6 +85,12 @@ margin-bottom: 4em; } + .survey-shell--reader { + max-width: 1000px; + margin-left: max(var(--survey-page-gutter), calc((100% - 1000px) / 2)); + margin-right: max(var(--survey-page-gutter), calc((100% - 1000px) / 2)); + } + .survey-shell--template { background: radial-gradient(circle at top left, rgba(236, 124, 50, 0.18), transparent 32%), @@ -489,6 +495,28 @@ margin-left: auto; } + /* Reader: Zurück/Weiter füllen die volle Breite und sind so groß wie die Ja/Nein-Karten */ + .survey-shell--reader .survey-button-row--navigation { + display: grid; + grid-template-columns: 1fr; + gap: 1.5rem; + } + + .survey-shell--reader .survey-button-row--navigation.has-back { + grid-template-columns: 1fr 1fr; + } + + .survey-shell--reader .survey-navigation-form--back { + width: 100%; + } + + .survey-shell--reader .survey-button-row--navigation .survey-button { + width: 100%; + margin: 0; + padding: 1.25rem; + border-radius: 1.5rem; + } + .survey-publish-toggle-form { margin: 0; } @@ -725,82 +753,6 @@ text-align: left; } - .survey-accordion-sort-handle { - position: relative; - display: inline-flex; - flex: 0 0 auto; - min-width: 3.5rem; - padding: 0 1rem; - border: 0; - border-left: 1px solid rgba(162, 168, 180, 0.18); - background: transparent; - cursor: grab; - align-items: center; - justify-content: center; - align-self: stretch; - transition: background 0.2s ease, color 0.2s ease; - } - - .survey-accordion-sort-handle:active { - cursor: grabbing; - } - - .survey-accordion-item .handorgel__header__button:hover + .survey-accordion-sort-handle, - .survey-accordion-item .handorgel__header__button:focus + .survey-accordion-sort-handle, - .survey-accordion-item .handorgel__header--opened .survey-accordion-sort-handle, - .survey-accordion-sort-handle:hover, - .survey-accordion-sort-handle:focus { - background: var(--survey-blue); - color: #fff; - } - - .survey-sort-icon { - display: inline-flex; - width: 0.95rem; - height: 1.28rem; - align-items: center; - justify-content: center; - color: #58a6da; - line-height: 0; - transform: translateY(1px); - } - - .survey-sort-icon svg { - display: block; - width: 100%; - height: 100%; - transform: translateY(4px); - } - - .survey-question-order-save-shell { - position: fixed; - top: 50%; - right: 1rem; - z-index: 40; - transform: translate(120%, -50%); - opacity: 0; - pointer-events: none; - transition: transform 0.26s ease, opacity 0.26s ease; - } - - .survey-question-order-save-shell.is-visible { - transform: translate(0, -50%); - opacity: 1; - pointer-events: auto; - } - - .survey-question-order-save { - box-shadow: 0 18px 40px rgba(0, 59, 102, 0.24); - } - - .survey-accordion-item.is-reordering { - box-shadow: 0 20px 44px rgba(0, 59, 102, 0.18); - } - - .survey-accordion-item.sortable-ghost { - opacity: 0.55; - } - .survey-accordion-item .handorgel__header__button, .survey-accordion-item .handorgel__header__button:hover, .survey-accordion-item .handorgel__header__button:focus { @@ -1075,22 +1027,6 @@ padding: 0.95rem 1rem; } - .survey-accordion-sort-handle { - min-width: 3.1rem; - padding: 0 0.8rem; - } - - .survey-question-order-save-shell { - top: auto; - right: 0.85rem; - bottom: 0.85rem; - transform: translate(0, 130%); - } - - .survey-question-order-save-shell.is-visible { - transform: translate(0, 0); - } - } + \ No newline at end of file diff --git a/contao/templates/frontend/member_survey_list.html.twig b/contao/templates/frontend/member_survey_list.html.twig index 80f3482..2f8a692 100644 --- a/contao/templates/frontend/member_survey_list.html.twig +++ b/contao/templates/frontend/member_survey_list.html.twig @@ -51,6 +51,7 @@
+ @@ -79,12 +80,14 @@ {% endif %} +
+ @@ -94,6 +97,7 @@
+ diff --git a/contao/templates/frontend/show_survey.html.twig b/contao/templates/frontend/show_survey.html.twig index 12219e8..1700ca8 100644 --- a/contao/templates/frontend/show_survey.html.twig +++ b/contao/templates/frontend/show_survey.html.twig @@ -5,7 +5,7 @@ {% set moduleHeadline = (headline is iterable ? headline.text|default('') : headline|default(''))|striptags|trim %} -
+
{% if moduleHeadline %}

{{ moduleHeadline }}

{% endif %} @@ -63,7 +63,10 @@ {% for child in surveyForm.answer %} {% endfor %}
diff --git a/public/css/auth-module.css b/public/css/auth-module.css index 507b8a6..0acbb4e 100644 --- a/public/css/auth-module.css +++ b/public/css/auth-module.css @@ -170,6 +170,7 @@ nav.mod_customnav.block:has(a[href*="/abmelden"]) { background: transparent; box-shadow: none; box-sizing: border-box; + overflow: visible; } nav.mod_customnav.block:has(a[href*="/abmelden"]) .invisible { diff --git a/public/js/sortable.min.js b/public/js/sortable.min.js new file mode 100644 index 0000000..95423a6 --- /dev/null +++ b/public/js/sortable.min.js @@ -0,0 +1,2 @@ +/*! Sortable 1.15.6 - MIT | git://github.com/SortableJS/Sortable.git */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function e(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function I(o){for(var t=1;tt.length)&&(e=t.length);for(var n=0,o=new Array(e);n"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function g(t){return t.host&&t!==document&&t.host.nodeType?t.host:t.parentNode}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&f(t,e)||o&&t===n)return t}while(t!==n&&(t=g(t)))}return null}var m,v=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(v," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(v," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function b(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function D(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i=n.left-e&&i<=n.right+e,e=r>=n.top-e&&r<=n.bottom+e;return o&&e?a=t:void 0}}),a);if(e){var n,o={};for(n in t)t.hasOwnProperty(n)&&(o[n]=t[n]);o.target=o.rootEl=e,o.preventDefault=void 0,o.stopPropagation=void 0,e[K]._onDragOver(o)}}var i,r,a}function Ft(t){Z&&Z.parentNode[K]._isOutsideThisEl(t.target)}function jt(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[K]=this;var n,o,i={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return kt(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==jt.supportPointer&&"PointerEvent"in window&&(!u||c),emptyInsertThreshold:5};for(n in z.initializePlugins(this,t,i),i)n in e||(e[n]=i[n]);for(o in Rt(e),this)"_"===o.charAt(0)&&"function"==typeof this[o]&&(this[o]=this[o].bind(this));this.nativeDraggable=!e.forceFallback&&It,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?h(t,"pointerdown",this._onTapStart):(h(t,"mousedown",this._onTapStart),h(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(h(t,"dragover",this),h(t,"dragenter",this)),St.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,A())}function Ht(t,e,n,o,i,r,a,l){var s,c,u=t[K],d=u.options.onMove;return!window.CustomEvent||y||w?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||X(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),c=d?d.call(u,s,a):c}function Lt(t){t.draggable=!1}function Kt(){xt=!1}function Wt(t){return setTimeout(t,0)}function zt(t){return clearTimeout(t)}jt.prototype={constructor:jt,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(vt=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,Z):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(!function(t){Ot.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&Ot.push(o)}}(o),!Z&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!u||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=P(l,t.draggable,o,!1))&&l.animated||et===l)){if(it=j(l),at=j(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return V({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),U("filter",n,{evt:e}),void(i&&e.preventDefault())}else if(c=c&&c.split(",").some(function(t){if(t=P(s,t.trim(),o,!1))return V({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),U("filter",n,{evt:e}),!0}))return void(i&&e.preventDefault());t.handle&&!P(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;n&&!Z&&n.parentNode===r&&(o=X(n),J=r,$=(Z=n).parentNode,tt=Z.nextSibling,et=n,st=a.group,ut={target:jt.dragged=Z,clientX:(e||t).clientX,clientY:(e||t).clientY},ft=ut.clientX-o.left,gt=ut.clientY-o.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,Z.style["will-change"]="all",o=function(){U("delayEnded",i,{evt:t}),jt.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!s&&i.nativeDraggable&&(Z.draggable=!0),i._triggerDragStart(t,e),V({sortable:i,name:"choose",originalEvent:t}),k(Z,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){D(Z,t.trim(),Lt)}),h(l,"dragover",Bt),h(l,"mousemove",Bt),h(l,"touchmove",Bt),a.supportPointer?(h(l,"pointerup",i._onDrop),this.nativeDraggable||h(l,"pointercancel",i._onDrop)):(h(l,"mouseup",i._onDrop),h(l,"touchend",i._onDrop),h(l,"touchcancel",i._onDrop)),s&&this.nativeDraggable&&(this.options.touchStartThreshold=4,Z.draggable=!0),U("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(w||y)?o():jt.eventCanceled?this._onDrop():(a.supportPointer?(h(l,"pointerup",i._disableDelayedDrag),h(l,"pointercancel",i._disableDelayedDrag)):(h(l,"mouseup",i._disableDelayedDrag),h(l,"touchend",i._disableDelayedDrag),h(l,"touchcancel",i._disableDelayedDrag)),h(l,"mousemove",i._delayedDragTouchMoveHandler),h(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&h(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)))},_delayedDragTouchMoveHandler:function(t){t=t.touches?t.touches[0]:t;Math.max(Math.abs(t.clientX-this._lastX),Math.abs(t.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){Z&&Lt(Z),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;p(t,"mouseup",this._disableDelayedDrag),p(t,"touchend",this._disableDelayedDrag),p(t,"touchcancel",this._disableDelayedDrag),p(t,"pointerup",this._disableDelayedDrag),p(t,"pointercancel",this._disableDelayedDrag),p(t,"mousemove",this._delayedDragTouchMoveHandler),p(t,"touchmove",this._delayedDragTouchMoveHandler),p(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?h(document,"pointermove",this._onTouchMove):h(document,e?"touchmove":"mousemove",this._onTouchMove):(h(Z,"dragend",this),h(J,"dragstart",this._onDragStart));try{document.selection?Wt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){var n;Dt=!1,J&&Z?(U("dragStarted",this,{evt:e}),this.nativeDraggable&&h(document,"dragover",Ft),n=this.options,t||k(Z,n.dragClass,!1),k(Z,n.ghostClass,!0),jt.active=this,t&&this._appendGhost(),V({sortable:this,name:"start",originalEvent:e})):this._nulling()},_emulateDragOver:function(){if(dt){this._lastX=dt.clientX,this._lastY=dt.clientY,Xt();for(var t=document.elementFromPoint(dt.clientX,dt.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(dt.clientX,dt.clientY))!==e;)e=t;if(Z.parentNode[K]._isOutsideThisEl(t),e)do{if(e[K])if(e[K]._onDragOver({clientX:dt.clientX,clientY:dt.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}while(e=g(t=e));Yt()}},_onTouchMove:function(t){if(ut){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=Q&&b(Q,!0),a=Q&&r&&r.a,l=Q&&r&&r.d,e=At&&wt&&E(wt),a=(i.clientX-ut.clientX+o.x)/(a||1)+(e?e[0]-Tt[0]:0)/(a||1),l=(i.clientY-ut.clientY+o.y)/(l||1)+(e?e[1]-Tt[1]:0)/(l||1);if(!jt.active&&!Dt){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))E.right+10||S.clientY>x.bottom&&S.clientX>x.left:S.clientY>E.bottom+10||S.clientX>x.right&&S.clientY>x.top)||m.animated)){if(m&&(t=n,e=r,C=X(B((_=this).el,0,_.options,!0)),_=L(_.el,_.options,Q),e?t.clientX<_.left-10||t.clientY (int) ($survey['questionCount'] ?? 0), 'participationCount' => (int) ($survey['participationCount'] ?? 0), 'editUrl' => $editPage instanceof PageModel ? $this->generateContentUrl($editPage, ['survey' => $surveyId]) : null, - 'publicUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['auto_item' => $alias]) : null, - 'draftUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['auto_item' => $alias, 'preview' => '1']) : null, - 'resultsUrl' => $resultsPage instanceof PageModel ? $this->generateContentUrl($resultsPage, ['auto_item' => $alias]) : null, + 'publicUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['parameters' => '/'.$alias]) : null, + 'draftUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['parameters' => '/'.$alias, 'preview' => '1']) : null, + 'resultsUrl' => $resultsPage instanceof PageModel ? $this->generateContentUrl($resultsPage, ['parameters' => '/'.$alias]) : null, ]; } @@ -90,8 +90,16 @@ final class MemberSurveyListController extends AbstractFrontendModuleController { $surveyId = (int) $request->request->get('item_id', 0); $action = (string) $request->request->get('_survey_action'); + $token = (string) $request->request->get('_token', ''); if ($surveyId <= 0 || '' === $action) { + throw new \RuntimeException('Ungültige Umfrage-Aktion.'); + } + + // Session-basierter Symfony-Token (wie im Editor): zuverlässig für + // eingeloggte Mitglieder. Der Contao-REQUEST_TOKEN ist cookie-gebunden + // und im Frontend-Fragment-Flow nicht stabil verfügbar. + if (!$this->isCsrfTokenValid($action.'-'.$surveyId, $token)) { throw new \RuntimeException('Ungültiger CSRF-Token.'); } diff --git a/src/Controller/FrontendModule/ShowSurveyController.php b/src/Controller/FrontendModule/ShowSurveyController.php index 6b4e1e4..411cbca 100644 --- a/src/Controller/FrontendModule/ShowSurveyController.php +++ b/src/Controller/FrontendModule/ShowSurveyController.php @@ -7,6 +7,7 @@ namespace Mummert\SurveyBundle\Controller\FrontendModule; use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController; use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule; use Contao\CoreBundle\Twig\FragmentTemplate; +use Contao\Input; use Contao\ModuleModel; use Mummert\SurveyBundle\Model\SurveyContentModel; use Mummert\SurveyBundle\Model\SurveyModel; @@ -126,6 +127,7 @@ final class ShowSurveyController extends AbstractFrontendModuleController $template->set('survey', $survey); $template->set('progress', $this->surveyFlowService->getProgress($survey, $question)); $template->set('question', $question); + $template->set('jumpHints', $this->buildJumpHints($survey, $question)); $template->set('surveyForm', $form->createView()); $template->set('isComplete', false); $template->set('answers', []); @@ -136,6 +138,30 @@ final class ShowSurveyController extends AbstractFrontendModuleController return $template->getResponse(); } + /** + * Sprung-Hinweise je Ja/Nein-Option für die aktuelle Frage (nur bei konfigurierter Sprunglogik). + * + * @return array + */ + private function buildJumpHints(SurveyModel $survey, SurveyContentModel $question): array + { + if ('yes_no_maybe' !== (string) $question->type) { + return []; + } + + $hints = []; + + if (null !== $yes = $this->surveyFlowService->describeJumpTarget($survey, (int) ($question->jumpOnYes ?? 0))) { + $hints['yes'] = $yes; + } + + if (null !== $no = $this->surveyFlowService->describeJumpTarget($survey, (int) ($question->jumpOnNo ?? 0))) { + $hints['no'] = $no; + } + + return $hints; + } + private function buildPreviewResponse(FragmentTemplate $template, SurveyModel $survey, Request $request): Response { $history = $this->decodePreviewHistory((string) $request->request->get('preview_history', '')); @@ -198,6 +224,7 @@ final class ShowSurveyController extends AbstractFrontendModuleController $template->set('survey', $survey); $template->set('progress', $this->surveyFlowService->getProgress($survey, $question)); $template->set('question', $question); + $template->set('jumpHints', $this->buildJumpHints($survey, $question)); $template->set('surveyForm', $form->createView()); $template->set('isComplete', false); $template->set('answers', []); @@ -265,7 +292,10 @@ final class ShowSurveyController extends AbstractFrontendModuleController private function resolvePublicAlias(Request $request): string { - $candidate = $request->attributes->get('auto_item'); + // Bei sauberen Pfad-URLs (/seite/) legt Contao den Wert im Input-Adapter + // ab und markiert ihn als Route-Parameter. Input::get liest ihn aus UND markiert + // ihn als benutzt – sonst wirft Contao 404 (unbenutzter Route-Parameter). + $candidate = $this->getContaoAdapter(Input::class)->get('auto_item'); if (\is_string($candidate) && '' !== trim($candidate)) { return trim($candidate); diff --git a/src/Controller/FrontendModule/ShowSurveyResultsController.php b/src/Controller/FrontendModule/ShowSurveyResultsController.php index 18d8182..285bd60 100644 --- a/src/Controller/FrontendModule/ShowSurveyResultsController.php +++ b/src/Controller/FrontendModule/ShowSurveyResultsController.php @@ -8,6 +8,7 @@ use Contao\Config; use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController; use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule; use Contao\CoreBundle\Twig\FragmentTemplate; +use Contao\Input; use Contao\ModuleModel; use Mummert\SurveyBundle\Repository\SurveyRepository; use Mummert\SurveyBundle\Service\SurveyResultsViewService; @@ -82,7 +83,9 @@ final class ShowSurveyResultsController extends AbstractFrontendModuleController private function resolvePublicAlias(Request $request): string { - $candidate = $request->attributes->get('auto_item'); + // Bei sauberen Pfad-URLs (/seite/) liegt der Wert im Contao-Input-Adapter; + // Input::get liest ihn aus und markiert ihn als benutzt (sonst Contao-404). + $candidate = $this->getContaoAdapter(Input::class)->get('auto_item'); if (\is_string($candidate) && '' !== trim($candidate)) { return trim($candidate); diff --git a/src/EventListener/SurveyDcaListener.php b/src/EventListener/SurveyDcaListener.php index 32deef9..b698e08 100644 --- a/src/EventListener/SurveyDcaListener.php +++ b/src/EventListener/SurveyDcaListener.php @@ -14,7 +14,6 @@ use Contao\Image; use Contao\Input; use Contao\Message; use Contao\PageModel; -use Contao\StringUtil; use Contao\System; use Doctrine\DBAL\Connection; use Mummert\SurveyBundle\Repository\SurveyEditorRepository; @@ -213,54 +212,6 @@ final class SurveyDcaListener return $this->surveyEditorRepository->findMemberChoices(); } - /** - * @return array - */ - 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 - */ - public function getUsedCategoryFilterOptions(): array - { - $usedCategoryIds = []; - - foreach ($this->connection->fetchFirstColumn('SELECT category FROM tl_survey') as $rawCategoryValue) { - foreach (StringUtil::deserialize($rawCategoryValue, true) as $categoryId) { - $categoryId = (int) $categoryId; - - if ($categoryId > 0) { - $usedCategoryIds[$categoryId] = $categoryId; - } - } - } - - if ([] === $usedCategoryIds) { - return []; - } - - $allCategoryOptions = $this->getCategoryOptions(); - $options = []; - - foreach (array_values($usedCategoryIds) as $categoryId) { - if (isset($allCategoryOptions[$categoryId])) { - $options[$categoryId] = $allCategoryOptions[$categoryId]; - } - } - - return $options; - } - /** * @return array */ diff --git a/src/Form/Model/SurveyEditorData.php b/src/Form/Model/SurveyEditorData.php index c8950c9..2a7448b 100644 --- a/src/Form/Model/SurveyEditorData.php +++ b/src/Form/Model/SurveyEditorData.php @@ -15,7 +15,9 @@ final class SurveyEditorData { $data = new self(); $data->title = (string) $survey->title; - $data->description = (string) $survey->description; + // Beschreibung ist ein Klartextfeld; evtl. gespeicherte HTML-Tags (z. B.

) + // entfernen, damit das Textarea reinen Text zeigt. + $data->description = trim(strip_tags((string) $survey->description)); return $data; } diff --git a/src/Repository/SurveyRepository.php b/src/Repository/SurveyRepository.php index a2f19c1..c549ce5 100644 --- a/src/Repository/SurveyRepository.php +++ b/src/Repository/SurveyRepository.php @@ -199,8 +199,6 @@ final class SurveyRepository return; } - $categoryIds = $this->normalizeCategoryIds($survey->category); - $categoryTitles = $this->fetchCategoryTitles($categoryIds); $assignedMembers = $this->fetchAssignedMembers($surveyId); $memberIds = []; $memberNames = []; @@ -211,8 +209,6 @@ final class SurveyRepository } $this->connection->update('tl_survey', [ - 'categoryFilter' => implode(',', $categoryIds), - 'categorySummary' => implode(', ', $categoryTitles), 'templateFilter' => '1' === (string) $survey->isTemplate ? '1' : '0', 'templateSummary' => '1' === (string) $survey->isTemplate ? 'Ja' : 'Nein', 'assignedMembersFilter' => implode(',', $memberIds), @@ -333,39 +329,6 @@ final class SurveyRepository return false !== $qb->executeQuery()->fetchOne(); } - /** - * @param list $categoryIds - * - * @return list - */ - 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 */ @@ -410,7 +373,7 @@ final class SurveyRepository ); return $this->hasListMetadataColumns = !array_diff( - ['categoryfilter', 'categorysummary', 'templatefilter', 'templatesummary', 'assignedmembersfilter', 'assignedmemberssummary'], + ['templatefilter', 'templatesummary', 'assignedmembersfilter', 'assignedmemberssummary'], $columnNames, ); } diff --git a/src/Repository/SurveySubmissionRepository.php b/src/Repository/SurveySubmissionRepository.php index 7c4a847..df2d87d 100644 --- a/src/Repository/SurveySubmissionRepository.php +++ b/src/Repository/SurveySubmissionRepository.php @@ -24,16 +24,6 @@ final class SurveySubmissionRepository 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( @@ -44,14 +34,6 @@ final class SurveySubmissionRepository 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(); diff --git a/src/Resources/views/pdf/survey_results.html.twig b/src/Resources/views/pdf/survey_results.html.twig index ddc7ac2..df2fc8d 100644 --- a/src/Resources/views/pdf/survey_results.html.twig +++ b/src/Resources/views/pdf/survey_results.html.twig @@ -264,7 +264,6 @@ {% elseif question.type == 'range' %}

- Minimum {{ 'survey.pdf.minimum'|trans({}, 'messages') }} {{ question.minimum is not null ? question.minimum : '-' }}
diff --git a/src/Security/SurveyEditorVoter.php b/src/Security/SurveyEditorVoter.php deleted file mode 100644 index 1b5d91e..0000000 --- a/src/Security/SurveyEditorVoter.php +++ /dev/null @@ -1,36 +0,0 @@ -getUser(); - - if (!$user instanceof FrontendUser || !$subject instanceof SurveyModel) { - return false; - } - - return $this->surveyPermissionService->canEditSurvey($subject, $user); - } -} \ No newline at end of file diff --git a/src/Service/SurveyFlowService.php b/src/Service/SurveyFlowService.php index 315115f..ff752a0 100644 --- a/src/Service/SurveyFlowService.php +++ b/src/Service/SurveyFlowService.php @@ -112,6 +112,29 @@ final class SurveyFlowService ]; } + /** + * Beschreibt das Sprungziel einer Ja/Nein-Antwort über Positionsnummer und Label. + * + * @return array{position:int,label:string}|null + */ + public function describeJumpTarget(SurveyModel $survey, int $targetQuestionId): ?array + { + if ($targetQuestionId <= 0) { + return null; + } + + foreach ($this->getOrderedQuestions($survey) as $index => $question) { + if ((int) $question->id === $targetQuestionId) { + return [ + 'position' => $index + 1, + 'label' => trim((string) $question->question), + ]; + } + } + + return null; + } + /** * @return list */ diff --git a/src/Service/SurveyPermissionService.php b/src/Service/SurveyPermissionService.php deleted file mode 100644 index 85ddc8f..0000000 --- a/src/Service/SurveyPermissionService.php +++ /dev/null @@ -1,29 +0,0 @@ -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 dürfen diese Umfrage nicht bearbeiten.'); - } - } -} \ No newline at end of file diff --git a/src/Service/SurveyResultsPdfService.php b/src/Service/SurveyResultsPdfService.php index 5e033fc..e457f0a 100644 --- a/src/Service/SurveyResultsPdfService.php +++ b/src/Service/SurveyResultsPdfService.php @@ -136,23 +136,19 @@ final class SurveyResultsPdfService private function buildChartLegend(array $questionResult): array { $chart = $questionResult['chart'] ?? null; - - if (!is_array($chart)) { - return []; - } - - $chartType = (string) ($chart['type'] ?? ''); + $chartType = is_array($chart) ? (string) ($chart['type'] ?? '') : ''; $sourceRows = match ($chartType) { 'pie' => $questionResult['options'] ?? $questionResult['responseSummary'] ?? [], 'line' => $questionResult['distribution'] ?? [], - default => [], + // Freitext hat kein Diagramm: Antwort-Häufigkeiten wie in der Online-Ansicht zeigen. + default => $questionResult['responseSummary'] ?? [], }; if (!is_array($sourceRows) || [] === $sourceRows) { return []; } - $dataset = $chart['data']['datasets'][0] ?? []; + $dataset = is_array($chart) ? ($chart['data']['datasets'][0] ?? []) : []; $fillColors = match ($chartType) { 'pie' => is_array($dataset['backgroundColor'] ?? null) ? $dataset['backgroundColor'] : [], 'line' => is_array($dataset['pointBackgroundColor'] ?? null) ? $dataset['pointBackgroundColor'] : [], diff --git a/src/Service/SurveyResultsViewService.php b/src/Service/SurveyResultsViewService.php index f083a77..2f4f4af 100644 --- a/src/Service/SurveyResultsViewService.php +++ b/src/Service/SurveyResultsViewService.php @@ -50,12 +50,7 @@ final class SurveyResultsViewService $results = []; foreach ($questions as $question) { - $key = $this->buildQuestionResultKey( - (int) $question->id, - (string) $question->type, - (string) $question->question, - (string) ($question->description ?? ''), - ); + $key = $this->buildQuestionResultKey((int) $question->id, (string) $question->type); $results[$key] = $this->buildResultSkeleton( (int) $question->id, @@ -65,6 +60,7 @@ final class SurveyResultsViewService (int) ($question->sorting ?? 0), ); $results[$key]['_choiceOptions'] = $this->getChoiceOptions($question); + $results[$key]['_allowMaybe'] = '1' === (string) $question->allowMaybe; } foreach ($answers as $answer) { @@ -72,7 +68,7 @@ final class SurveyResultsViewService $questionType = (string) ($answer['questionType'] ?? 'text'); $questionLabel = trim((string) ($answer['questionLabel'] ?? '')) ?: 'Frage #'.$questionId; $questionDescription = (string) ($answer['questionDescription'] ?? ''); - $key = $this->buildQuestionResultKey($questionId, $questionType, $questionLabel, $questionDescription); + $key = $this->buildQuestionResultKey($questionId, $questionType); if (!isset($results[$key])) { $results[$key] = $this->buildResultSkeleton( @@ -104,9 +100,11 @@ final class SurveyResultsViewService return array_values($results); } - private function buildQuestionResultKey(int $questionId, string $questionType, string $questionLabel, string $questionDescription): string + private function buildQuestionResultKey(int $questionId, string $questionType): string { - return implode('|', [$questionId, $questionType, md5($questionLabel), md5($questionDescription)]); + // Bewusst nur Frage-ID + Typ: So werden Antwort-Snapshots und die aktuelle + // Frage auch dann zusammengeführt, wenn das Label nachträglich geändert wurde. + return implode('|', [$questionId, $questionType]); } /** @@ -125,6 +123,7 @@ final class SurveyResultsViewService '_sorting' => $sorting, '_values' => [], '_choiceOptions' => [], + '_allowMaybe' => false, ]; } @@ -146,6 +145,12 @@ final class SurveyResultsViewService case 'yes_no_maybe': $optionCounts = ['Ja' => 0, 'Nein' => 0]; + // Wie beim Excel-Export: Bei erlaubtem "Vielleicht" die Zeile auch dann + // zeigen, wenn (noch) niemand "Vielleicht" gewählt hat. + if (!empty($result['_allowMaybe'])) { + $optionCounts['Vielleicht'] = 0; + } + foreach ($values as $value) { $normalizedValue = mb_strtolower(trim($value)); @@ -205,7 +210,7 @@ final class SurveyResultsViewService break; } - unset($result['_sorting'], $result['_values'], $result['_choiceOptions']); + unset($result['_sorting'], $result['_values'], $result['_choiceOptions'], $result['_allowMaybe']); return $result; } diff --git a/src/Service/SurveySubmissionService.php b/src/Service/SurveySubmissionService.php index af44edb..90fe884 100644 --- a/src/Service/SurveySubmissionService.php +++ b/src/Service/SurveySubmissionService.php @@ -18,6 +18,12 @@ use Symfony\Component\Uid\Ulid; final class SurveySubmissionService { + /** + * Eine abgeschlossene Teilnahme bleibt für diese Dauer auf der Danke-Seite. + * Danach darf dieselbe Browser-Session erneut an der Umfrage teilnehmen. + */ + private const RESTART_COOLDOWN_SECONDS = 60; + public function __construct( private readonly SurveySubmissionRepository $surveySubmissionRepository, private readonly SurveyAnswerRepository $surveyAnswerRepository, @@ -35,7 +41,9 @@ final class SurveySubmissionService if ('' !== $token) { $existing = $this->surveySubmissionRepository->findBySurveyAndToken((int) $survey->id, $token); - if ($existing instanceof SurveySubmissionModel) { + // Offene Durchläufe werden fortgesetzt; abgeschlossene Durchläufe halten + // die Danke-Seite, bis der Cooldown abgelaufen ist (dann neuer Durchlauf). + if ($existing instanceof SurveySubmissionModel && !$this->isRestartAllowed($existing)) { return $existing; } } @@ -48,6 +56,21 @@ final class SurveySubmissionService return $submission; } + private function isRestartAllowed(SurveySubmissionModel $submission): bool + { + if ('1' !== (string) $submission->isFinished) { + return false; + } + + $completedAt = (int) ($submission->completedAt ?? 0); + + if ($completedAt <= 0) { + return true; + } + + return (time() - $completedAt) >= self::RESTART_COOLDOWN_SECONDS; + } + public function storeAnswer(SurveyModel $survey, SurveySubmissionModel $submission, SurveyContentModel $question, mixed $rawAnswer): string { $normalizedAnswer = $this->normalizeAnswerForQuestion($question, $rawAnswer); diff --git a/translations/messages.de.yaml b/translations/messages.de.yaml index 99323c4..586b523 100644 --- a/translations/messages.de.yaml +++ b/translations/messages.de.yaml @@ -108,7 +108,7 @@ survey: answer_no: "Nein" answer_maybe: "Vielleicht" question_progress: "Frage %current%" - answer_flow_hint: "Diese Antwort wird direkt für den weiteren Ablauf verwendet." + jump_to: "weiter zu Frage %position%: %label%" current_value: "Aktueller Wert" back: "Zurück" next: "Weiter" diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 7ede603..33def9b 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -108,7 +108,7 @@ survey: answer_no: "No" answer_maybe: "Maybe" question_progress: "Question %current%" - answer_flow_hint: "This answer is used directly for the further flow." + jump_to: "continue to question %position%: %label%" current_value: "Current value" back: "Back" next: "Next"