Fix frontend CSRF, clean reader URLs, and review findings

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 /<page>/<alias> 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) <noreply@anthropic.com>
This commit is contained in:
Jürgen Mummert
2026-06-14 13:00:05 +02:00
parent 1b16675ae9
commit db7094fd1e
29 changed files with 230 additions and 456 deletions
+13 -3
View File
@@ -124,10 +124,13 @@ Aktuelle Button- und Sichtbarkeitslogik pro Umfrage:
- Schaltet `isActive` - Schaltet `isActive`
- Ist bewusst kein Auge mehr - 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. - Jedes Frontend-Aktionsformular (Liste **und** Editor) sendet bewusst **zwei** Tokens:
- Die zusätzliche Symfony-Aktions-CSRF-Prüfung wurde bewusst entfernt, weil sie in diesem Frontend-Flow zu ungültigen Tokens führte. - `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('<aktion>-' ~ id) }}`) wird im Controller mit `isCsrfTokenValid('<aktion>-'.$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 ### 2. Frontend-Editor
@@ -191,6 +194,13 @@ Wichtige Regeln:
- Entwurfsansicht arbeitet ohne Speicherung - Entwurfsansicht arbeitet ohne Speicherung
- Öffentliche Ansicht speichert Antworten und erstellt/führt Submission fort - Öffentliche Ansicht speichert Antworten und erstellt/führt Submission fort
- Rückwärtsnavigation ist implementiert - 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: `/<reader-seite>/<alias>` statt `?auto_item=<alias>`.
- 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 ### 4. Ergebnisseite
+16
View File
@@ -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. 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('<aktion>-' ~ id)`, im Controller via `isCsrfTokenValid` geprüft). Beide sind nötig.
- **Saubere Link-URLs:** Reader und Ergebnisseite werden als Pfad adressiert (`/<seite>/<alias>`). 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 ## Rechtlicher Hinweis
Dieses Bundle ist proprietär und nicht zur öffentlichen Weitergabe bestimmt. Dieses Bundle ist proprietär und nicht zur öffentlichen Weitergabe bestimmt.
+5 -18
View File
@@ -48,14 +48,14 @@ $GLOBALS['TL_DCA']['tl_survey'] = [
], ],
'operations' => [ 'operations' => [
'editheader' => [ 'editheader' => [
'label' => ['Umfrage bearbeiten', 'Umfrage bearbeiten'], 'label' => &$GLOBALS['TL_LANG']['tl_survey']['editheader'],
'href' => 'act=edit', 'href' => 'act=edit',
'icon' => 'edit.svg', 'icon' => 'edit.svg',
'primary' => true, 'primary' => true,
'showInHeader' => false, 'showInHeader' => false,
], ],
'edit' => [ 'edit' => [
'label' => ['Fragen bearbeiten', 'Fragen der Umfrage bearbeiten'], 'label' => &$GLOBALS['TL_LANG']['tl_survey']['edit'],
'href' => 'table=tl_survey_content', 'href' => 'table=tl_survey_content',
'icon' => 'children.svg', 'icon' => 'children.svg',
'primary' => true, 'primary' => true,
@@ -81,13 +81,13 @@ $GLOBALS['TL_DCA']['tl_survey'] = [
'button_callback' => [SurveyDcaListener::class, 'generateDuplicateSurveyButton'], 'button_callback' => [SurveyDcaListener::class, 'generateDuplicateSurveyButton'],
], ],
'delete' => [ 'delete' => [
'label' => ['Löschen', 'Eintrag wirklich löschen?'], 'label' => &$GLOBALS['TL_LANG']['tl_survey']['delete'],
'href' => 'act=delete', 'href' => 'act=delete',
'icon' => 'delete.svg', 'icon' => 'delete.svg',
'attributes' => 'onclick="if(!confirm(\'Eintrag wirklich löschen?\'))return false;Backend.getScrollOffset()"', 'attributes' => 'onclick="if(!confirm(\'Eintrag wirklich löschen?\'))return false;Backend.getScrollOffset()"',
], ],
'show' => [ 'show' => [
'label' => ['Details', 'Details des Elements ID %s anzeigen'], 'label' => &$GLOBALS['TL_LANG']['tl_survey']['show'],
'href' => 'act=show', 'href' => 'act=show',
'icon' => 'show.svg', 'icon' => 'show.svg',
], ],
@@ -148,17 +148,6 @@ $GLOBALS['TL_DCA']['tl_survey'] = [
'relation' => ['type' => 'hasMany', 'load' => 'lazy', 'table' => 'tl_survey_category', 'field' => 'id'], 'relation' => ['type' => 'hasMany', 'load' => 'lazy', 'table' => 'tl_survey_category', 'field' => 'id'],
'sql' => 'blob NULL', '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' => [ 'published' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['published'], 'label' => &$GLOBALS['TL_LANG']['tl_survey']['published'],
'inputType' => 'checkbox', 'inputType' => 'checkbox',
@@ -253,7 +242,7 @@ if ('cli' !== PHP_SAPI) {
System::getContainer()->get('database_connection')->fetchFirstColumn('SHOW COLUMNS FROM tl_survey'), System::getContainer()->get('database_connection')->fetchFirstColumn('SHOW COLUMNS FROM tl_survey'),
); );
$hasListMetadataColumns = !array_diff( $hasListMetadataColumns = !array_diff(
['categoryfilter', 'categorysummary', 'templatefilter', 'templatesummary', 'assignedmembersfilter', 'assignedmemberssummary'], ['templatefilter', 'templatesummary', 'assignedmembersfilter', 'assignedmemberssummary'],
$columnNames, $columnNames,
); );
} catch (\Throwable) { } catch (\Throwable) {
@@ -264,8 +253,6 @@ if ('cli' !== PHP_SAPI) {
$GLOBALS['TL_DCA']['tl_survey']['list']['label']['fields'] = ['title', 'alias']; $GLOBALS['TL_DCA']['tl_survey']['list']['label']['fields'] = ['title', 'alias'];
unset( 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']['templateFilter'],
$GLOBALS['TL_DCA']['tl_survey']['fields']['templateSummary'], $GLOBALS['TL_DCA']['tl_survey']['fields']['templateSummary'],
$GLOBALS['TL_DCA']['tl_survey']['fields']['assignedMembersFilter'], $GLOBALS['TL_DCA']['tl_survey']['fields']['assignedMembersFilter'],
+4 -3
View File
@@ -3,8 +3,6 @@
$GLOBALS['TL_LANG']['tl_survey']['title'] = ['Titel', 'Titel der Umfrage.']; $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']['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']['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']['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']['templateFilter'] = ['Vorlage', 'Filtert die Liste nach Vorlagenstatus.'];
$GLOBALS['TL_LANG']['tl_survey']['templateSummary'] = ['Vorlage', 'Zeigt an, ob die Umfrage als Vorlage markiert ist.']; $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']['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']['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']['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']['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']['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.']; $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']['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']['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']['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']['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']['updatedBy'] = ['Aktualisiert von', 'ID des zuletzt bearbeitenden Mitglieds.'];
$GLOBALS['TL_LANG']['tl_survey']['createdAt'] = ['Angelegt am', 'Zeitpunkt der Erstellung.']; $GLOBALS['TL_LANG']['tl_survey']['createdAt'] = ['Angelegt am', 'Zeitpunkt der Erstellung.'];
+4 -3
View File
@@ -3,8 +3,6 @@
$GLOBALS['TL_LANG']['tl_survey']['title'] = ['Title', 'Title of the survey.']; $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']['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']['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']['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']['templateFilter'] = ['Template', 'Filters the list by template status.'];
$GLOBALS['TL_LANG']['tl_survey']['templateSummary'] = ['Template', 'Shows whether the survey is marked as a template.']; $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']['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']['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']['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']['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']['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.']; $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']['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']['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']['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']['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']['updatedBy'] = ['Updated by', 'ID of the member who last edited it.'];
$GLOBALS['TL_LANG']['tl_survey']['createdAt'] = ['Created on', 'Creation time.']; $GLOBALS['TL_LANG']['tl_survey']['createdAt'] = ['Created on', 'Creation time.'];
@@ -85,6 +85,12 @@
margin-bottom: 4em; 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 { .survey-shell--template {
background: background:
radial-gradient(circle at top left, rgba(236, 124, 50, 0.18), transparent 32%), radial-gradient(circle at top left, rgba(236, 124, 50, 0.18), transparent 32%),
@@ -489,6 +495,28 @@
margin-left: auto; 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 { .survey-publish-toggle-form {
margin: 0; margin: 0;
} }
@@ -725,82 +753,6 @@
text-align: left; 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,
.survey-accordion-item .handorgel__header__button:hover, .survey-accordion-item .handorgel__header__button:hover,
.survey-accordion-item .handorgel__header__button:focus { .survey-accordion-item .handorgel__header__button:focus {
@@ -1075,22 +1027,6 @@
padding: 0.95rem 1rem; 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);
}
} }
</style> </style>
<script> <script>
@@ -1249,133 +1185,6 @@
}); });
}); });
var initializeQuestionSorting = function () {
var styleId = 'survey-question-sort-icon-runtime';
if (!document.getElementById(styleId)) {
var runtimeStyle = document.createElement('style');
runtimeStyle.id = styleId;
runtimeStyle.textContent = '' +
'.survey-sort-icon{' +
'width:1.14rem !important;' +
'height:1.52rem !important;' +
'color:#58a6da !important;' +
'background:none !important;' +
'transform:translateY(1px) !important;' +
'}' +
'.survey-sort-icon::before,.survey-sort-icon::after{' +
'content:none !important;' +
'}' +
'.survey-sort-icon svg{' +
'display:block !important;' +
'width:100% !important;' +
'height:100% !important;' +
'transform:translateY(4px);' +
'}' +
'.survey-sort-icon svg path:first-child{' +
'transform:translateY(1.4px);' +
'transform-box:fill-box;' +
'transform-origin:center;' +
'}' +
'.survey-sort-icon svg path:nth-child(2){' +
'transform:translateY(-1.4px);' +
'transform-box:fill-box;' +
'transform-origin:center;' +
'}' +
'.survey-sort-icon svg path[stroke]{' +
'stroke-width:2px;' +
'}';
document.head.appendChild(runtimeStyle);
}
if (typeof Sortable !== 'function') {
return;
}
var sortableList = document.querySelector('[data-survey-question-sortable]');
var orderForm = document.querySelector('[data-survey-question-order-shell]');
var orderInput = document.querySelector('[data-survey-question-order-input]');
if (!sortableList || !orderForm || !orderInput) {
return;
}
if (sortableList._surveyQuestionSortable) {
return;
}
var getOrderedIds = function () {
return Array.from(sortableList.querySelectorAll('[data-survey-question-item]')).map(function (item) {
return item.getAttribute('data-question-id') || '';
}).filter(function (value) {
return value !== '';
});
};
var initialOrder = getOrderedIds().join(',');
var updateOrderField = function () {
orderInput.value = getOrderedIds().join(',');
};
var updateQuestionIndices = function () {
sortableList.querySelectorAll('[data-survey-question-item]').forEach(function (item, index) {
var indexNode = item.querySelector('.survey-accordion-index');
if (indexNode) {
indexNode.textContent = (index + 1) + '.';
}
});
};
var updateSaveVisibility = function () {
var currentOrder = getOrderedIds().join(',');
var isDirty = currentOrder !== initialOrder;
orderForm.hidden = !isDirty;
orderForm.classList.toggle('is-visible', isDirty);
updateOrderField();
};
sortableList.querySelectorAll('[data-survey-question-sort-handle]').forEach(function (handle) {
handle.addEventListener('click', function (event) {
event.preventDefault();
event.stopPropagation();
});
});
updateQuestionIndices();
updateOrderField();
updateSaveVisibility();
var sortableInstance = Sortable.create(sortableList, {
animation: 180,
draggable: '[data-survey-question-item]',
handle: '[data-survey-question-sort-handle]',
ghostClass: 'sortable-ghost',
forceFallback: true,
fallbackTolerance: 3,
onStart: function (event) {
if (event.item) {
event.item.classList.add('is-reordering');
}
},
onEnd: function (event) {
if (event.item) {
event.item.classList.remove('is-reordering');
}
updateQuestionIndices();
updateSaveVisibility();
}
});
sortableList._surveyQuestionSortable = sortableInstance;
};
window.initializeSurveyQuestionSorting = initializeQuestionSorting;
if (typeof handorgel === 'function') { if (typeof handorgel === 'function') {
document.querySelectorAll('[data-survey-handorgel]').forEach(function (element) { document.querySelectorAll('[data-survey-handorgel]').forEach(function (element) {
var accordion = new handorgel(element, { var accordion = new handorgel(element, {
@@ -1395,7 +1204,6 @@
}); });
} }
initializeQuestionSorting();
resetEditorViewport(); resetEditorViewport();
}); });
@@ -70,17 +70,17 @@
.survey-question-order-save-shell { .survey-question-order-save-shell {
position: fixed; position: fixed;
top: 50%; bottom: 1.5rem;
right: 1rem; right: 1.5rem;
z-index: 40; z-index: 40;
transform: translate(120%, -50%); transform: translate(120%, 0);
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
transition: transform 0.26s ease, opacity 0.26s ease; transition: transform 0.26s ease, opacity 0.26s ease;
} }
.survey-question-order-save-shell.is-visible { .survey-question-order-save-shell.is-visible {
transform: translate(0, -50%); transform: translate(0, 0);
opacity: 1; opacity: 1;
pointer-events: auto; pointer-events: auto;
} }
@@ -115,5 +115,5 @@
} }
} }
</style> </style>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js"></script> <script src="{{ asset('bundles/survey/js/sortable.min.js') }}"></script>
<script src="{{ asset('bundles/survey/js/survey-frontend-editor-sort.js') }}"></script> <script src="{{ asset('bundles/survey/js/survey-frontend-editor-sort.js') }}"></script>
@@ -51,6 +51,7 @@
<div class="survey-list-card__head-actions survey-list-card__head-actions--template"> <div class="survey-list-card__head-actions survey-list-card__head-actions--template">
<form method="post" class="survey-question-action-form"> <form method="post" class="survey-question-action-form">
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}"> <input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
<input type="hidden" name="_token" value="{{ csrf_token('duplicate-survey-' ~ survey.id) }}">
<input type="hidden" name="_survey_action" value="duplicate-survey"> <input type="hidden" name="_survey_action" value="duplicate-survey">
<input type="hidden" name="item_id" value="{{ survey.id }}"> <input type="hidden" name="item_id" value="{{ survey.id }}">
<button class="survey-button secondary" onclick="return confirm('{{ 'survey.list.duplicate_confirm'|trans({'%title%': survey.title|striptags|trim})|e('js') }}');">{{ 'survey.list.duplicate'|trans }}</button> <button class="survey-button secondary" onclick="return confirm('{{ 'survey.list.duplicate_confirm'|trans({'%title%': survey.title|striptags|trim})|e('js') }}');">{{ 'survey.list.duplicate'|trans }}</button>
@@ -79,12 +80,14 @@
{% endif %} {% endif %}
<form method="post" class="survey-question-action-form"> <form method="post" class="survey-question-action-form">
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}"> <input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
<input type="hidden" name="_token" value="{{ csrf_token('duplicate-survey-' ~ survey.id) }}">
<input type="hidden" name="_survey_action" value="duplicate-survey"> <input type="hidden" name="_survey_action" value="duplicate-survey">
<input type="hidden" name="item_id" value="{{ survey.id }}"> <input type="hidden" name="item_id" value="{{ survey.id }}">
<button class="survey-button secondary" onclick="return confirm('{{ 'survey.list.duplicate_confirm'|trans({'%title%': survey.title|striptags|trim})|e('js') }}');">{{ 'survey.list.duplicate'|trans }}</button> <button class="survey-button secondary" onclick="return confirm('{{ 'survey.list.duplicate_confirm'|trans({'%title%': survey.title|striptags|trim})|e('js') }}');">{{ 'survey.list.duplicate'|trans }}</button>
</form> </form>
<form method="post" class="survey-question-action-form"> <form method="post" class="survey-question-action-form">
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}"> <input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
<input type="hidden" name="_token" value="{{ csrf_token('delete-survey-' ~ survey.id) }}">
<input type="hidden" name="_survey_action" value="delete-survey"> <input type="hidden" name="_survey_action" value="delete-survey">
<input type="hidden" name="item_id" value="{{ survey.id }}"> <input type="hidden" name="item_id" value="{{ survey.id }}">
<button class="survey-button danger" onclick="return confirm('{{ 'survey.list.delete_confirm'|trans|e('js') }}') && confirm('{{ 'survey.list.delete_confirm_second'|trans|e('js') }}');">{{ 'survey.list.delete'|trans }}</button> <button class="survey-button danger" onclick="return confirm('{{ 'survey.list.delete_confirm'|trans|e('js') }}') && confirm('{{ 'survey.list.delete_confirm_second'|trans|e('js') }}');">{{ 'survey.list.delete'|trans }}</button>
@@ -94,6 +97,7 @@
<div class="survey-list-card__head-actions"> <div class="survey-list-card__head-actions">
<form method="post" class="survey-list-publish-form"> <form method="post" class="survey-list-publish-form">
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}"> <input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
<input type="hidden" name="_token" value="{{ csrf_token('publish-survey-' ~ survey.id) }}">
<input type="hidden" name="_survey_action" value="publish-survey"> <input type="hidden" name="_survey_action" value="publish-survey">
<input type="hidden" name="item_id" value="{{ survey.id }}"> <input type="hidden" name="item_id" value="{{ survey.id }}">
<button <button
@@ -111,6 +115,7 @@
<form method="post" class="survey-list-eye-form"> <form method="post" class="survey-list-eye-form">
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}"> <input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
<input type="hidden" name="_token" value="{{ csrf_token('toggle-active-' ~ survey.id) }}">
<input type="hidden" name="_survey_action" value="toggle-active"> <input type="hidden" name="_survey_action" value="toggle-active">
<input type="hidden" name="item_id" value="{{ survey.id }}"> <input type="hidden" name="item_id" value="{{ survey.id }}">
<button type="submit" class="survey-visibility-dot{{ survey.isActive ? ' is-on' : '' }}" title="{{ survey.isActive ? 'survey.edit.deactivate_survey'|trans : 'survey.edit.activate_survey'|trans }}" aria-label="{{ survey.isActive ? 'survey.edit.deactivate_survey'|trans : 'survey.edit.activate_survey'|trans }}"></button> <button type="submit" class="survey-visibility-dot{{ survey.isActive ? ' is-on' : '' }}" title="{{ survey.isActive ? 'survey.edit.deactivate_survey'|trans : 'survey.edit.activate_survey'|trans }}" aria-label="{{ survey.isActive ? 'survey.edit.deactivate_survey'|trans : 'survey.edit.activate_survey'|trans }}"></button>
@@ -5,7 +5,7 @@
{% set moduleHeadline = (headline is iterable ? headline.text|default('') : headline|default(''))|striptags|trim %} {% set moduleHeadline = (headline is iterable ? headline.text|default('') : headline|default(''))|striptags|trim %}
<section class="survey-shell survey-grid"> <section class="survey-shell survey-shell--reader survey-grid">
{% if moduleHeadline %} {% if moduleHeadline %}
<header><h2>{{ moduleHeadline }}</h2></header> <header><h2>{{ moduleHeadline }}</h2></header>
{% endif %} {% endif %}
@@ -63,7 +63,10 @@
{% for child in surveyForm.answer %} {% for child in surveyForm.answer %}
<label class="survey-card" style="cursor: pointer;"> <label class="survey-card" style="cursor: pointer;">
<div class="survey-meta"><strong>{{ child.vars.label|striptags|trim }}</strong>{{ form_widget(child) }}</div> <div class="survey-meta"><strong>{{ child.vars.label|striptags|trim }}</strong>{{ form_widget(child) }}</div>
<div>{{ 'survey.survey.answer_flow_hint'|trans }}</div> {% set jumpHint = jumpHints|default({})[child.vars.value]|default(null) %}
{% if jumpHint %}
<div class="survey-jump-hint">{{ 'survey.survey.jump_to'|trans({'%position%': jumpHint.position, '%label%': jumpHint.label|striptags|trim}) }}</div>
{% endif %}
</label> </label>
{% endfor %} {% endfor %}
</div> </div>
+1
View File
@@ -170,6 +170,7 @@ nav.mod_customnav.block:has(a[href*="/abmelden"]) {
background: transparent; background: transparent;
box-shadow: none; box-shadow: none;
box-sizing: border-box; box-sizing: border-box;
overflow: visible;
} }
nav.mod_customnav.block:has(a[href*="/abmelden"]) .invisible { nav.mod_customnav.block:has(a[href*="/abmelden"]) .invisible {
+2
View File
File diff suppressed because one or more lines are too long
+22 -2
View File
@@ -108,9 +108,29 @@
document.addEventListener('DOMContentLoaded', normalizeSearchField); document.addEventListener('DOMContentLoaded', normalizeSearchField);
document.addEventListener('turbo:load', normalizeSearchField); document.addEventListener('turbo:load', normalizeSearchField);
var observer = new MutationObserver(function () { // Mutationen werden gebündelt: viele DOM-Änderungen lösen nur einen
// normalizeSearchField-Lauf pro Frame aus statt einen pro Mutation.
var scheduled = false;
var scheduleNormalize = function () {
if (scheduled) {
return;
}
scheduled = true;
var run = function () {
scheduled = false;
normalizeSearchField(); normalizeSearchField();
}); };
if (typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(run);
} else {
window.setTimeout(run, 100);
}
};
var observer = new MutationObserver(scheduleNormalize);
observer.observe(document.documentElement, { childList: true, subtree: true }); observer.observe(document.documentElement, { childList: true, subtree: true });
})(); })();
+1 -1
View File
@@ -9,7 +9,7 @@
style.textContent = [ style.textContent = [
'.survey-sort-icon{width:1.14rem !important;height:1.52rem !important;color:#58a6da !important;background:none !important;transform:translateY(1px) !important;}', '.survey-sort-icon{width:1.14rem !important;height:1.52rem !important;color:#58a6da !important;background:none !important;transform:translateY(1px) !important;}',
'.survey-sort-icon::before,.survey-sort-icon::after{content:none !important;}', '.survey-sort-icon::before,.survey-sort-icon::after{content:none !important;}',
'.survey-sort-icon svg{display:block !important;width:100% !important;height:100% !important;transform:translateY(4px);}', '.survey-sort-icon svg{display:block !important;width:100% !important;height:100% !important;transform:translateY(5px);}',
'.survey-sort-icon svg path:first-child{transform:translateY(1.4px);transform-box:fill-box;transform-origin:center;}', '.survey-sort-icon svg path:first-child{transform:translateY(1.4px);transform-box:fill-box;transform-origin:center;}',
'.survey-sort-icon svg path:nth-child(2){transform:translateY(-1.4px);transform-box:fill-box;transform-origin:center;}', '.survey-sort-icon svg path:nth-child(2){transform:translateY(-1.4px);transform-box:fill-box;transform-origin:center;}',
'.survey-sort-icon svg path[stroke]{stroke-width:2px;}' '.survey-sort-icon svg path[stroke]{stroke-width:2px;}'
@@ -68,9 +68,9 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
'questionCount' => (int) ($survey['questionCount'] ?? 0), 'questionCount' => (int) ($survey['questionCount'] ?? 0),
'participationCount' => (int) ($survey['participationCount'] ?? 0), 'participationCount' => (int) ($survey['participationCount'] ?? 0),
'editUrl' => $editPage instanceof PageModel ? $this->generateContentUrl($editPage, ['survey' => $surveyId]) : null, 'editUrl' => $editPage instanceof PageModel ? $this->generateContentUrl($editPage, ['survey' => $surveyId]) : null,
'publicUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['auto_item' => $alias]) : null, 'publicUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['parameters' => '/'.$alias]) : null,
'draftUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['auto_item' => $alias, 'preview' => '1']) : null, 'draftUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['parameters' => '/'.$alias, 'preview' => '1']) : null,
'resultsUrl' => $resultsPage instanceof PageModel ? $this->generateContentUrl($resultsPage, ['auto_item' => $alias]) : 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); $surveyId = (int) $request->request->get('item_id', 0);
$action = (string) $request->request->get('_survey_action'); $action = (string) $request->request->get('_survey_action');
$token = (string) $request->request->get('_token', '');
if ($surveyId <= 0 || '' === $action) { 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.'); throw new \RuntimeException('Ungültiger CSRF-Token.');
} }
@@ -7,6 +7,7 @@ namespace Mummert\SurveyBundle\Controller\FrontendModule;
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController; use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule; use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
use Contao\CoreBundle\Twig\FragmentTemplate; use Contao\CoreBundle\Twig\FragmentTemplate;
use Contao\Input;
use Contao\ModuleModel; use Contao\ModuleModel;
use Mummert\SurveyBundle\Model\SurveyContentModel; use Mummert\SurveyBundle\Model\SurveyContentModel;
use Mummert\SurveyBundle\Model\SurveyModel; use Mummert\SurveyBundle\Model\SurveyModel;
@@ -126,6 +127,7 @@ final class ShowSurveyController extends AbstractFrontendModuleController
$template->set('survey', $survey); $template->set('survey', $survey);
$template->set('progress', $this->surveyFlowService->getProgress($survey, $question)); $template->set('progress', $this->surveyFlowService->getProgress($survey, $question));
$template->set('question', $question); $template->set('question', $question);
$template->set('jumpHints', $this->buildJumpHints($survey, $question));
$template->set('surveyForm', $form->createView()); $template->set('surveyForm', $form->createView());
$template->set('isComplete', false); $template->set('isComplete', false);
$template->set('answers', []); $template->set('answers', []);
@@ -136,6 +138,30 @@ final class ShowSurveyController extends AbstractFrontendModuleController
return $template->getResponse(); return $template->getResponse();
} }
/**
* Sprung-Hinweise je Ja/Nein-Option für die aktuelle Frage (nur bei konfigurierter Sprunglogik).
*
* @return array<string, array{position:int,label:string}>
*/
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 private function buildPreviewResponse(FragmentTemplate $template, SurveyModel $survey, Request $request): Response
{ {
$history = $this->decodePreviewHistory((string) $request->request->get('preview_history', '')); $history = $this->decodePreviewHistory((string) $request->request->get('preview_history', ''));
@@ -198,6 +224,7 @@ final class ShowSurveyController extends AbstractFrontendModuleController
$template->set('survey', $survey); $template->set('survey', $survey);
$template->set('progress', $this->surveyFlowService->getProgress($survey, $question)); $template->set('progress', $this->surveyFlowService->getProgress($survey, $question));
$template->set('question', $question); $template->set('question', $question);
$template->set('jumpHints', $this->buildJumpHints($survey, $question));
$template->set('surveyForm', $form->createView()); $template->set('surveyForm', $form->createView());
$template->set('isComplete', false); $template->set('isComplete', false);
$template->set('answers', []); $template->set('answers', []);
@@ -265,7 +292,10 @@ final class ShowSurveyController extends AbstractFrontendModuleController
private function resolvePublicAlias(Request $request): string private function resolvePublicAlias(Request $request): string
{ {
$candidate = $request->attributes->get('auto_item'); // Bei sauberen Pfad-URLs (/seite/<alias>) 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)) { if (\is_string($candidate) && '' !== trim($candidate)) {
return trim($candidate); return trim($candidate);
@@ -8,6 +8,7 @@ use Contao\Config;
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController; use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule; use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
use Contao\CoreBundle\Twig\FragmentTemplate; use Contao\CoreBundle\Twig\FragmentTemplate;
use Contao\Input;
use Contao\ModuleModel; use Contao\ModuleModel;
use Mummert\SurveyBundle\Repository\SurveyRepository; use Mummert\SurveyBundle\Repository\SurveyRepository;
use Mummert\SurveyBundle\Service\SurveyResultsViewService; use Mummert\SurveyBundle\Service\SurveyResultsViewService;
@@ -82,7 +83,9 @@ final class ShowSurveyResultsController extends AbstractFrontendModuleController
private function resolvePublicAlias(Request $request): string private function resolvePublicAlias(Request $request): string
{ {
$candidate = $request->attributes->get('auto_item'); // Bei sauberen Pfad-URLs (/seite/<alias>) 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)) { if (\is_string($candidate) && '' !== trim($candidate)) {
return trim($candidate); return trim($candidate);
-49
View File
@@ -14,7 +14,6 @@ use Contao\Image;
use Contao\Input; use Contao\Input;
use Contao\Message; use Contao\Message;
use Contao\PageModel; use Contao\PageModel;
use Contao\StringUtil;
use Contao\System; use Contao\System;
use Doctrine\DBAL\Connection; use Doctrine\DBAL\Connection;
use Mummert\SurveyBundle\Repository\SurveyEditorRepository; use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
@@ -213,54 +212,6 @@ final class SurveyDcaListener
return $this->surveyEditorRepository->findMemberChoices(); 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 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<string, string> * @return array<string, string>
*/ */
+3 -1
View File
@@ -15,7 +15,9 @@ final class SurveyEditorData
{ {
$data = new self(); $data = new self();
$data->title = (string) $survey->title; $data->title = (string) $survey->title;
$data->description = (string) $survey->description; // Beschreibung ist ein Klartextfeld; evtl. gespeicherte HTML-Tags (z. B. <p>)
// entfernen, damit das Textarea reinen Text zeigt.
$data->description = trim(strip_tags((string) $survey->description));
return $data; return $data;
} }
+1 -38
View File
@@ -199,8 +199,6 @@ final class SurveyRepository
return; return;
} }
$categoryIds = $this->normalizeCategoryIds($survey->category);
$categoryTitles = $this->fetchCategoryTitles($categoryIds);
$assignedMembers = $this->fetchAssignedMembers($surveyId); $assignedMembers = $this->fetchAssignedMembers($surveyId);
$memberIds = []; $memberIds = [];
$memberNames = []; $memberNames = [];
@@ -211,8 +209,6 @@ final class SurveyRepository
} }
$this->connection->update('tl_survey', [ $this->connection->update('tl_survey', [
'categoryFilter' => implode(',', $categoryIds),
'categorySummary' => implode(', ', $categoryTitles),
'templateFilter' => '1' === (string) $survey->isTemplate ? '1' : '0', 'templateFilter' => '1' === (string) $survey->isTemplate ? '1' : '0',
'templateSummary' => '1' === (string) $survey->isTemplate ? 'Ja' : 'Nein', 'templateSummary' => '1' === (string) $survey->isTemplate ? 'Ja' : 'Nein',
'assignedMembersFilter' => implode(',', $memberIds), 'assignedMembersFilter' => implode(',', $memberIds),
@@ -333,39 +329,6 @@ final class SurveyRepository
return false !== $qb->executeQuery()->fetchOne(); 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}> * @return list<array{id:int,name:string}>
*/ */
@@ -410,7 +373,7 @@ final class SurveyRepository
); );
return $this->hasListMetadataColumns = !array_diff( return $this->hasListMetadataColumns = !array_diff(
['categoryfilter', 'categorysummary', 'templatefilter', 'templatesummary', 'assignedmembersfilter', 'assignedmemberssummary'], ['templatefilter', 'templatesummary', 'assignedmembersfilter', 'assignedmemberssummary'],
$columnNames, $columnNames,
); );
} }
@@ -24,16 +24,6 @@ final class SurveySubmissionRepository
return $model instanceof SurveySubmissionModel ? $model : null; 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 public function findBySurveyAndToken(int $surveyId, string $token): ?SurveySubmissionModel
{ {
$id = $this->connection->fetchOne( $id = $this->connection->fetchOne(
@@ -44,14 +34,6 @@ final class SurveySubmissionRepository
return false === $id ? null : $this->findById((int) $id); 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 public function create(int $surveyId, string $token, ?int $currentQuestionId): SurveySubmissionModel
{ {
$now = time(); $now = time();
@@ -264,7 +264,6 @@
{% elseif question.type == 'range' %} {% elseif question.type == 'range' %}
<div class="metric-row"> <div class="metric-row">
<div class="metric-box"> <div class="metric-box">
<span class="metric-label">Minimum</span>
<span class="metric-label">{{ 'survey.pdf.minimum'|trans({}, 'messages') }}</span> <span class="metric-label">{{ 'survey.pdf.minimum'|trans({}, 'messages') }}</span>
<span class="metric-value">{{ question.minimum is not null ? question.minimum : '-' }}</span> <span class="metric-value">{{ question.minimum is not null ? question.minimum : '-' }}</span>
</div> </div>
-36
View File
@@ -1,36 +0,0 @@
<?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);
}
}
+23
View File
@@ -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<SurveyContentModel> * @return list<SurveyContentModel>
*/ */
-29
View File
@@ -1,29 +0,0 @@
<?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 dürfen diese Umfrage nicht bearbeiten.');
}
}
}
+4 -8
View File
@@ -136,23 +136,19 @@ final class SurveyResultsPdfService
private function buildChartLegend(array $questionResult): array private function buildChartLegend(array $questionResult): array
{ {
$chart = $questionResult['chart'] ?? null; $chart = $questionResult['chart'] ?? null;
$chartType = is_array($chart) ? (string) ($chart['type'] ?? '') : '';
if (!is_array($chart)) {
return [];
}
$chartType = (string) ($chart['type'] ?? '');
$sourceRows = match ($chartType) { $sourceRows = match ($chartType) {
'pie' => $questionResult['options'] ?? $questionResult['responseSummary'] ?? [], 'pie' => $questionResult['options'] ?? $questionResult['responseSummary'] ?? [],
'line' => $questionResult['distribution'] ?? [], '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) { if (!is_array($sourceRows) || [] === $sourceRows) {
return []; return [];
} }
$dataset = $chart['data']['datasets'][0] ?? []; $dataset = is_array($chart) ? ($chart['data']['datasets'][0] ?? []) : [];
$fillColors = match ($chartType) { $fillColors = match ($chartType) {
'pie' => is_array($dataset['backgroundColor'] ?? null) ? $dataset['backgroundColor'] : [], 'pie' => is_array($dataset['backgroundColor'] ?? null) ? $dataset['backgroundColor'] : [],
'line' => is_array($dataset['pointBackgroundColor'] ?? null) ? $dataset['pointBackgroundColor'] : [], 'line' => is_array($dataset['pointBackgroundColor'] ?? null) ? $dataset['pointBackgroundColor'] : [],
+15 -10
View File
@@ -50,12 +50,7 @@ final class SurveyResultsViewService
$results = []; $results = [];
foreach ($questions as $question) { foreach ($questions as $question) {
$key = $this->buildQuestionResultKey( $key = $this->buildQuestionResultKey((int) $question->id, (string) $question->type);
(int) $question->id,
(string) $question->type,
(string) $question->question,
(string) ($question->description ?? ''),
);
$results[$key] = $this->buildResultSkeleton( $results[$key] = $this->buildResultSkeleton(
(int) $question->id, (int) $question->id,
@@ -65,6 +60,7 @@ final class SurveyResultsViewService
(int) ($question->sorting ?? 0), (int) ($question->sorting ?? 0),
); );
$results[$key]['_choiceOptions'] = $this->getChoiceOptions($question); $results[$key]['_choiceOptions'] = $this->getChoiceOptions($question);
$results[$key]['_allowMaybe'] = '1' === (string) $question->allowMaybe;
} }
foreach ($answers as $answer) { foreach ($answers as $answer) {
@@ -72,7 +68,7 @@ final class SurveyResultsViewService
$questionType = (string) ($answer['questionType'] ?? 'text'); $questionType = (string) ($answer['questionType'] ?? 'text');
$questionLabel = trim((string) ($answer['questionLabel'] ?? '')) ?: 'Frage #'.$questionId; $questionLabel = trim((string) ($answer['questionLabel'] ?? '')) ?: 'Frage #'.$questionId;
$questionDescription = (string) ($answer['questionDescription'] ?? ''); $questionDescription = (string) ($answer['questionDescription'] ?? '');
$key = $this->buildQuestionResultKey($questionId, $questionType, $questionLabel, $questionDescription); $key = $this->buildQuestionResultKey($questionId, $questionType);
if (!isset($results[$key])) { if (!isset($results[$key])) {
$results[$key] = $this->buildResultSkeleton( $results[$key] = $this->buildResultSkeleton(
@@ -104,9 +100,11 @@ final class SurveyResultsViewService
return array_values($results); 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, '_sorting' => $sorting,
'_values' => [], '_values' => [],
'_choiceOptions' => [], '_choiceOptions' => [],
'_allowMaybe' => false,
]; ];
} }
@@ -146,6 +145,12 @@ final class SurveyResultsViewService
case 'yes_no_maybe': case 'yes_no_maybe':
$optionCounts = ['Ja' => 0, 'Nein' => 0]; $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) { foreach ($values as $value) {
$normalizedValue = mb_strtolower(trim($value)); $normalizedValue = mb_strtolower(trim($value));
@@ -205,7 +210,7 @@ final class SurveyResultsViewService
break; break;
} }
unset($result['_sorting'], $result['_values'], $result['_choiceOptions']); unset($result['_sorting'], $result['_values'], $result['_choiceOptions'], $result['_allowMaybe']);
return $result; return $result;
} }
+24 -1
View File
@@ -18,6 +18,12 @@ use Symfony\Component\Uid\Ulid;
final class SurveySubmissionService 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( public function __construct(
private readonly SurveySubmissionRepository $surveySubmissionRepository, private readonly SurveySubmissionRepository $surveySubmissionRepository,
private readonly SurveyAnswerRepository $surveyAnswerRepository, private readonly SurveyAnswerRepository $surveyAnswerRepository,
@@ -35,7 +41,9 @@ final class SurveySubmissionService
if ('' !== $token) { if ('' !== $token) {
$existing = $this->surveySubmissionRepository->findBySurveyAndToken((int) $survey->id, $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; return $existing;
} }
} }
@@ -48,6 +56,21 @@ final class SurveySubmissionService
return $submission; 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 public function storeAnswer(SurveyModel $survey, SurveySubmissionModel $submission, SurveyContentModel $question, mixed $rawAnswer): string
{ {
$normalizedAnswer = $this->normalizeAnswerForQuestion($question, $rawAnswer); $normalizedAnswer = $this->normalizeAnswerForQuestion($question, $rawAnswer);
+1 -1
View File
@@ -108,7 +108,7 @@ survey:
answer_no: "Nein" answer_no: "Nein"
answer_maybe: "Vielleicht" answer_maybe: "Vielleicht"
question_progress: "Frage %current%" 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" current_value: "Aktueller Wert"
back: "Zurück" back: "Zurück"
next: "Weiter" next: "Weiter"
+1 -1
View File
@@ -108,7 +108,7 @@ survey:
answer_no: "No" answer_no: "No"
answer_maybe: "Maybe" answer_maybe: "Maybe"
question_progress: "Question %current%" 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" current_value: "Current value"
back: "Back" back: "Back"
next: "Next" next: "Next"