Rebuild DEEG as a fifth installation, add stepped forms and a plugin system, and clear the review backlog

Forms gained steps, conditions, image choices, number fields with units and a
summary step; the DEEG price finder uses all of it. Plugins are manifest plus
Twig: blocks, settings, containers, routes, presets and theme packs, switched
per installation. The review backlog is done: archive pages render through the
normal page pipeline, forms and post lists work inside areas and sections,
repeated sections get their own ids, breakpoints and colours come from the
palette, backup and update work on MySQL and lock each other out.

The task worker no longer shortens a process that has no time limit:
set_time_limit() sets the limit instead of extending it, which turned an
unlimited CLI run into eighty seconds and killed the full test suite far away
from the scheduler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-16 07:32:35 +02:00
1 parent 66ff68abf8
commit c8a8fa7460
74 files changed
+5352 -243

No files matched your search

+232 -21
View File
@@ -9,6 +9,12 @@ use Doctrine\DBAL\Connection;
/**
* Form definitions and their submissions. A definition is a validated list of fields; a submission
* stores the answers, the moment consent was given and a keyed hash of the sender address.
*
* Ein Formular kann in Schritten fragen: ein Feld vom Typ „Schritt“ eröffnet einen Abschnitt, alle
* Felder bis zum nächsten Schritt gehören dazu. Und ein Feld oder ein Schritt kann von einer
* früheren Auswahl abhängen („nur zeigen, wenn Immobilienart = Wohnung“). Beides steht in der
* Definition, nicht im Skript: der Server entscheidet beim Absenden selbst, welche Felder galten,
* und verlangt nichts, was der Besucher gar nicht sehen sollte.
*/
final class FormRepository
{
@@ -16,11 +22,18 @@ final class FormRepository
'text' => 'Einzeiliger Text',
'email' => 'E-Mail-Adresse',
'phone' => 'Telefonnummer',
'number' => 'Zahl',
'textarea' => 'Mehrzeiliger Text',
'select' => 'Auswahl',
'select' => 'Auswahl (Liste)',
'radio' => 'Auswahl (Optionsfelder)',
'checkboxes' => 'Mehrfachauswahl',
'consent' => 'Einwilligung',
'step' => 'Neuer Schritt',
];
public const MAX_FIELDS = 25;
/** Die Feldtypen mit Optionen; nur von ihnen kann ein anderes Feld abhängen. */
public const CHOICE_TYPES = ['select', 'radio', 'checkboxes'];
/** Ein Preisfinder mit vier Objektarten braucht mehr als ein Kontaktformular. */
public const MAX_FIELDS = 40;
/** Die gestaltbaren Farben eines Formulars, mit ihrer Bezeichnung in der Verwaltung. */
public const COLORS = [
'fieldBorderColor' => 'den Rahmen der Felder',
@@ -56,10 +69,14 @@ final class FormRepository
private function present(array $row): array
{
// Ältere Definitionen kennen weder Grenzen noch Bedingungen; die Vorlage und die Prüfung
// verlassen sich darauf, dass jeder Schlüssel da ist.
$fields = array_map(static fn (array $field): array => $field + ['help' => '', 'options' => [], 'images' => [], 'min' => null, 'max' => null, 'unit' => '', 'showIf' => null], json_decode($row['fields'], true, 512, JSON_THROW_ON_ERROR));
return [
'id' => $row['id'], 'slug' => $row['slug'], 'name' => $row['name'],
'fields' => json_decode($row['fields'], true, 512, JSON_THROW_ON_ERROR),
'settings' => json_decode($row['settings'], true, 512, JSON_THROW_ON_ERROR),
'fields' => $fields,
'settings' => json_decode($row['settings'], true, 512, JSON_THROW_ON_ERROR) + ['summary' => false],
'version' => (int) $row['version'],
];
}
@@ -114,6 +131,8 @@ final class FormRepository
throw new InvalidForm(sprintf('Ein Formular fasst höchstens %d Felder.', self::MAX_FIELDS));
}
$names = [];
// Die Optionen der bisherigen Auswahlfelder: nur davon darf ein späteres Feld abhängen.
$choices = [];
$clean = [];
foreach ($fields as $field) {
$name = strtolower(trim((string) ($field['name'] ?? '')));
@@ -130,34 +149,149 @@ final class FormRepository
throw new InvalidForm('Jedes Feld braucht eine Beschriftung mit höchstens 120 Zeichen.');
}
$options = [];
if ('select' === $type) {
if (in_array($type, self::CHOICE_TYPES, true)) {
foreach ((array) ($field['options'] ?? []) as $option) {
$option = trim((string) $option);
if ('' !== $option && mb_strlen($option) <= 120) {
// Kein Komma in einer Option: die Verwaltung trennt Optionen daran, und eine
// Mehrfachauswahl wird mit Komma gespeichert.
if ('' !== $option && mb_strlen($option) <= 120 && !str_contains($option, ',') && !in_array($option, $options, true)) {
$options[] = $option;
}
}
if (count($options) < 2) {
throw new InvalidForm(sprintf('Die Auswahl „%s“ braucht mindestens zwei Optionen.', $label));
throw new InvalidForm(sprintf('Die Auswahl „%s“ braucht mindestens zwei Optionen ohne Komma.', $label));
}
}
$images = in_array($type, self::CHOICE_TYPES, true) ? $this->validateImages($field['images'] ?? [], $options, $label) : [];
$min = $max = null;
$unit = '';
if ('number' === $type) {
foreach (['min', 'max'] as $grenze) {
$wert = str_replace(',', '.', trim((string) ($field[$grenze] ?? '')));
if ('' === $wert) {
continue;
}
if (!is_numeric($wert)) {
throw new InvalidForm(sprintf('Die Grenze „%s“ von „%s“ ist keine Zahl.', $wert, $label));
}
$$grenze = (float) $wert;
}
if (null !== $min && null !== $max && $min >= $max) {
throw new InvalidForm(sprintf('Bei „%s“ muss die Untergrenze unter der Obergrenze liegen.', $label));
}
$unit = mb_substr(trim((string) ($field['unit'] ?? '')), 0, 12);
}
$showIf = $this->validateCondition($field, $label, $choices);
$clean[] = [
'name' => $name,
'type' => $type,
'label' => $label,
// A consent field is only meaningful when it has to be ticked.
'required' => 'consent' === $type ? true : (bool) ($field['required'] ?? false),
// A consent field is only meaningful when it has to be ticked; a step asks nothing.
'required' => 'consent' === $type ? true : ('step' !== $type && (bool) ($field['required'] ?? false)),
'help' => mb_substr(trim((string) ($field['help'] ?? '')), 0, 200),
'options' => $options,
'images' => $images,
'min' => $min,
'max' => $max,
'unit' => $unit,
'showIf' => $showIf,
];
if (in_array($type, self::CHOICE_TYPES, true)) {
$choices[$name] = $options;
}
}
if (count(array_filter($clean, static fn (array $f): bool => 'consent' === $f['type'])) > 1) {
throw new InvalidForm('Ein Formular hat höchstens ein Einwilligungsfeld.');
}
if ([] === array_filter($clean, static fn (array $f): bool => 'step' !== $f['type'])) {
throw new InvalidForm('Ein Formular braucht mindestens ein Feld, das etwas fragt – Schritte allein reichen nicht.');
}
return $clean;
}
/**
* Die Bedingung eines Feldes: es gilt nur, wenn ein früheres Auswahlfeld einen der genannten
* Werte hat. Angenommen wird die gespeicherte Form (showIf: {field, values}) und die aus der
* Verwaltung (showIfField, showIfValues als Kommaliste).
*
* @param array<string, list<string>> $choices Optionen der Auswahlfelder vor diesem Feld
*
* @return array{field: string, values: list<string>}|null
*/
private function validateCondition(array $field, string $label, array $choices): ?array
{
$ref = strtolower(trim((string) ($field['showIfField'] ?? ($field['showIf']['field'] ?? ''))));
if ('' === $ref) {
return null;
}
if (!isset($choices[$ref])) {
throw new InvalidForm(sprintf('„%s“ soll von „%s“ abhängen, aber davor steht kein Auswahlfeld mit diesem Namen.', $label, $ref));
}
$wanted = $field['showIfValues'] ?? ($field['showIf']['values'] ?? []);
if (is_string($wanted)) {
$wanted = explode(',', $wanted);
}
$values = [];
foreach ((array) $wanted as $wert) {
$wert = trim((string) $wert);
if ('' === $wert || in_array($wert, $values, true)) {
continue;
}
if (!in_array($wert, $choices[$ref], true)) {
throw new InvalidForm(sprintf('„%s“ ist keine Option von „%s“ – die Bedingung von „%s“ könnte nie zutreffen.', $wert, $ref, $label));
}
$values[] = $wert;
}
if ([] === $values) {
throw new InvalidForm(sprintf('Für die Bedingung von „%s“ fehlt der Wert, bei dem das Feld gilt.', $label));
}
return ['field' => $ref, 'values' => $values];
}
/**
* Die Bilder einer Auswahl: je Option höchstens ein Bild aus der Mediathek. Sobald eine Option
* eines hat, zeigt die Website die Auswahl als anklickbare Bilder statt als Kästchen.
* Angenommen wird die gespeicherte Form (Option => Kennung) und die aus der Verwaltung
* (eine Zeile je Option: „Option = Kennung“).
*
* @param list<string> $options
*
* @return array<string, string>
*/
private function validateImages(mixed $input, array $options, string $label): array
{
if (is_string($input)) {
$paare = [];
foreach (preg_split('/\\r?\\n/', $input) ?: [] as $zeile) {
if (!str_contains($zeile, '=')) {
continue;
}
[$option, $kennung] = array_map(trim(...), explode('=', $zeile, 2));
$paare[$option] = $kennung;
}
$input = $paare;
}
$images = [];
foreach ((array) $input as $option => $kennung) {
$option = trim((string) $option);
$kennung = strtolower(trim((string) $kennung));
if ('' === $kennung) {
continue;
}
if (!in_array($option, $options, true)) {
throw new InvalidForm(sprintf('„%s“ ist keine Option von „%s“ und kann kein Bild bekommen.', $option, $label));
}
if (1 !== preg_match('/^[a-f0-9]{32}$/D', $kennung)) {
throw new InvalidForm(sprintf('Das Bild für „%s“ ist keine Kennung aus der Mediathek.', $option));
}
$images[$option] = $kennung;
}
return $images;
}
private function validateSettings(array $settings, array $fields): array
{
$recipient = trim((string) ($settings['recipient'] ?? ''));
@@ -184,6 +318,9 @@ final class FormRepository
'success' => mb_substr(trim((string) ($settings['success'] ?? '')), 0, 300) ?: 'Vielen Dank. Deine Nachricht ist angekommen.',
'replyToField' => $replyTo,
'buttonLabel' => mb_substr(trim((string) ($settings['buttonLabel'] ?? '')), 0, 40) ?: 'Absenden',
// Die Zusammenfassung vor dem Absenden - ein Schritt, den nur das Skript zeigt; ohne
// Skript steht das ganze Formular ohnehin auf einer Seite.
'summary' => (bool) ($settings['summary'] ?? false),
];
// Das Aussehen: leer heisst "wie die Website", sonst ein Palettenschluessel oder ein
// Hexwert - dieselbe Regel wie fuer jede Farbe im Editor.
@@ -198,6 +335,21 @@ final class FormRepository
return $clean;
}
/** Ob dieses Formular auf der Seite das Skript braucht: Schritte, Bedingungen oder die Zusammenfassung. */
public function needsScript(array $form): bool
{
if ($form['settings']['summary'] ?? false) {
return true;
}
foreach ($form['fields'] as $field) {
if ('step' === $field['type'] || null !== ($field['showIf'] ?? null)) {
return true;
}
}
return false;
}
/**
* Die Gestaltung eines Formulars als Regel fuer die Seite, die es zeigt.
*
@@ -217,7 +369,7 @@ final class FormRepository
}
$values[] = $variable.':'.$value;
if ('buttonColor' === $field) {
$values[] = '--kb-hover-fill:color-mix(in srgb,'.$value.' 82%,#000)';
$values[] = '--kb-hover-fill:color-mix(in srgb,'.$value.' 82%,var(--ink))';
$button = true;
}
if ('buttonInk' === $field) {
@@ -243,6 +395,10 @@ final class FormRepository
/**
* Checks one submission against the stored definition.
*
* Ein Feld hinter einer Bedingung zählt nur, wenn sie zutrifft; ein Schritt hinter einer
* Bedingung nimmt seine Felder mit. Was nicht galt, wird weder verlangt noch gespeichert -
* auch wenn ein Skript im Browser es mitgeschickt hat.
*
* @return array{values: array<string, string>, errors: array<string, string>, consent: bool}
*/
public function check(array $form, array $input): array
@@ -250,44 +406,99 @@ final class FormRepository
$values = [];
$errors = [];
$consent = false;
// Die gewählten Optionen der bisherigen Auswahlfelder, für die Bedingungen danach.
$chosen = [];
$stepActive = true;
// Kein Pfeil: der bekäme die leere Liste von oben und sähe keine spätere Wahl.
$holds = static function (?array $showIf) use (&$chosen): bool {
return null === $showIf || [] !== array_intersect($showIf['values'], $chosen[$showIf['field']] ?? []);
};
foreach ($form['fields'] as $field) {
$raw = $input[$field['name']] ?? '';
$name = $field['name'];
if ('step' === $field['type']) {
$stepActive = $holds($field['showIf']);
continue;
}
if (!$stepActive || !$holds($field['showIf'])) {
$values[$name] = '';
continue;
}
$raw = $input[$name] ?? '';
if ('checkboxes' === $field['type']) {
$picked = array_values(array_filter(array_map(static fn ($v): string => is_string($v) ? trim($v) : '', is_array($raw) ? $raw : [$raw]), static fn (string $v): bool => '' !== $v));
if ([] !== array_diff($picked, $field['options'])) {
$errors[$name] = 'Bitte wähle nur aus den angebotenen Optionen.';
$picked = array_values(array_intersect($picked, $field['options']));
} elseif ([] === $picked && $field['required']) {
$errors[$name] = 'Bitte wähle mindestens eine Option.';
}
$chosen[$name] = $picked;
$values[$name] = implode(', ', $picked);
continue;
}
$value = is_string($raw) ? trim($raw) : '';
$limit = 'textarea' === $field['type'] ? 5000 : 200;
if (mb_strlen($value) > $limit) {
$errors[$field['name']] = sprintf('Bitte kürze die Eingabe auf höchstens %d Zeichen.', $limit);
$errors[$name] = sprintf('Bitte kürze die Eingabe auf höchstens %d Zeichen.', $limit);
$value = mb_substr($value, 0, $limit);
}
if ('consent' === $field['type']) {
$consent = '' !== $value;
if (!$consent) {
$errors[$field['name']] = 'Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.';
$errors[$name] = 'Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.';
}
$values[$field['name']] = $consent ? 'ja' : '';
$values[$name] = $consent ? 'ja' : '';
continue;
}
if ('' === $value) {
if ($field['required']) {
$errors[$field['name']] = 'Dieses Feld wird benötigt.';
$errors[$name] = in_array($field['type'], self::CHOICE_TYPES, true) ? 'Bitte wähle eine der angebotenen Optionen.' : 'Dieses Feld wird benötigt.';
}
$values[$field['name']] = '';
$values[$name] = '';
continue;
}
if ('email' === $field['type'] && false === filter_var($value, FILTER_VALIDATE_EMAIL)) {
$errors[$field['name']] = 'Bitte gib eine gültige E-Mail-Adresse an.';
$errors[$name] = 'Bitte gib eine gültige E-Mail-Adresse an.';
}
if ('phone' === $field['type'] && 1 !== preg_match('/^\+?[0-9 ()\/-]{3,32}$/D', $value)) {
$errors[$field['name']] = 'Bitte gib eine gültige Telefonnummer an.';
$errors[$name] = 'Bitte gib eine gültige Telefonnummer an.';
}
if ('select' === $field['type'] && !in_array($value, $field['options'], true)) {
$errors[$field['name']] = 'Bitte wähle eine der angebotenen Optionen.';
if ('number' === $field['type']) {
$zahl = str_replace(',', '.', $value);
if (!is_numeric($zahl)) {
$errors[$name] = 'Bitte gib eine Zahl an.';
} elseif ((null !== $field['min'] && (float) $zahl < $field['min']) || (null !== $field['max'] && (float) $zahl > $field['max'])) {
$errors[$name] = match (true) {
null === $field['min'] => sprintf('Bitte gib höchstens %s an.', self::zahl($field['max'])),
null === $field['max'] => sprintf('Bitte gib mindestens %s an.', self::zahl($field['min'])),
default => sprintf('Bitte gib einen Wert zwischen %s und %s an.', self::zahl($field['min']), self::zahl($field['max'])),
};
} else {
// Gespeichert wird die Zahl, wie sie ist - ohne führende Nullen, mit Punkt.
$value = self::zahl((float) $zahl);
}
}
$values[$field['name']] = $value;
if (in_array($field['type'], ['select', 'radio'], true)) {
if (!in_array($value, $field['options'], true)) {
$errors[$name] = 'Bitte wähle eine der angebotenen Optionen.';
} else {
$chosen[$name] = [$value];
}
}
$values[$name] = $value;
}
return ['values' => $values, 'errors' => $errors, 'consent' => $consent];
}
/** Eine Zahl ohne unnötige Nachkommastellen, für Meldungen und die gespeicherte Antwort. */
public static function zahl(float $wert): string
{
$text = number_format($wert, 3, '.', '');
return str_contains($text, '.') ? rtrim(rtrim($text, '0'), '.') : $text;
}
/** True when this sender has already sent the allowed number of messages within the hour. */
public function rateLimited(string $senderHash): bool
{