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>
556 lines
26 KiB
PHP
556 lines
26 KiB
PHP
<?php
|
||
|
||
namespace App\Content;
|
||
|
||
use App\Domain\InvalidForm;
|
||
use App\Domain\StaleWrite;
|
||
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
|
||
{
|
||
public const TYPES = [
|
||
'text' => 'Einzeiliger Text',
|
||
'email' => 'E-Mail-Adresse',
|
||
'phone' => 'Telefonnummer',
|
||
'number' => 'Zahl',
|
||
'textarea' => 'Mehrzeiliger Text',
|
||
'select' => 'Auswahl (Liste)',
|
||
'radio' => 'Auswahl (Optionsfelder)',
|
||
'checkboxes' => 'Mehrfachauswahl',
|
||
'consent' => 'Einwilligung',
|
||
'step' => 'Neuer Schritt',
|
||
];
|
||
/** 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',
|
||
'fieldBackgroundColor' => 'den Hintergrund der Felder',
|
||
'buttonColor' => 'den Button',
|
||
'buttonInk' => 'die Buttonschrift',
|
||
];
|
||
/** Submissions per sender per hour. Deliberately low: a contact form is not a chat. */
|
||
public const RATE_LIMIT = 5;
|
||
/** A human needs longer than this to fill in a form; anything faster is a script. */
|
||
public const MIN_SECONDS = 3;
|
||
public const MAX_SECONDS = 86400;
|
||
|
||
public function __construct(private Connection $db)
|
||
{
|
||
}
|
||
|
||
/** @return list<array{id: string, slug: string, name: string, fields: array, settings: array, version: int}> */
|
||
public function all(string $locale = 'de'): array
|
||
{
|
||
return array_map($this->present(...), $this->db->fetchAllAssociative(
|
||
'SELECT * FROM form WHERE website_id = ? AND locale = ? ORDER BY name',
|
||
['default', $locale],
|
||
));
|
||
}
|
||
|
||
public function findBySlug(string $slug, string $locale = 'de'): ?array
|
||
{
|
||
$row = $this->db->fetchAssociative('SELECT * FROM form WHERE website_id = ? AND locale = ? AND slug = ?', ['default', $locale, $slug]);
|
||
|
||
return false === $row ? null : $this->present($row);
|
||
}
|
||
|
||
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' => $fields,
|
||
'settings' => json_decode($row['settings'], true, 512, JSON_THROW_ON_ERROR) + ['summary' => false],
|
||
'version' => (int) $row['version'],
|
||
];
|
||
}
|
||
|
||
public function save(string $slug, string $name, array $fields, array $settings, int $expectedVersion, string $locale = 'de'): string
|
||
{
|
||
$slug = strtolower(trim($slug));
|
||
if (1 !== preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $slug) || strlen($slug) > 80) {
|
||
throw new InvalidForm('Die Adresse des Formulars darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten.');
|
||
}
|
||
$name = trim($name);
|
||
if ('' === $name || mb_strlen($name) > 120) {
|
||
throw new InvalidForm('Bitte gib einen Namen mit höchstens 120 Zeichen an.');
|
||
}
|
||
$fields = $this->validateFields($fields);
|
||
$settings = $this->validateSettings($settings, $fields);
|
||
|
||
return $this->db->transactional(function () use ($slug, $name, $fields, $settings, $expectedVersion, $locale): string {
|
||
$existing = $this->db->fetchAssociative('SELECT id, version FROM form WHERE website_id = ? AND locale = ? AND slug = ?', ['default', $locale, $slug]);
|
||
$payload = [
|
||
'name' => $name,
|
||
'fields' => json_encode($fields, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||
'settings' => json_encode($settings, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||
];
|
||
if (false === $existing) {
|
||
if (0 !== $expectedVersion) {
|
||
throw new StaleWrite('Das Formular wurde inzwischen angelegt. Lade die Seite neu.');
|
||
}
|
||
$id = bin2hex(random_bytes(16));
|
||
$this->db->insert('form', [...$payload, 'id' => $id, 'website_id' => 'default', 'locale' => $locale, 'slug' => $slug, 'version' => 1]);
|
||
|
||
return $id;
|
||
}
|
||
$changed = $this->db->executeStatement(
|
||
'UPDATE form SET name = ?, fields = ?, settings = ?, version = version + 1 WHERE id = ? AND version = ?',
|
||
[$payload['name'], $payload['fields'], $payload['settings'], $existing['id'], $expectedVersion],
|
||
);
|
||
if (1 !== $changed) {
|
||
throw new StaleWrite('Das Formular wurde inzwischen geändert. Lade die Seite neu, bevor du speicherst.');
|
||
}
|
||
|
||
return $existing['id'];
|
||
});
|
||
}
|
||
|
||
private function validateFields(array $fields): array
|
||
{
|
||
if (!array_is_list($fields) || [] === $fields) {
|
||
throw new InvalidForm('Ein Formular braucht mindestens ein Feld.');
|
||
}
|
||
if (count($fields) > self::MAX_FIELDS) {
|
||
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'] ?? '')));
|
||
if (1 !== preg_match('/^[a-z][a-z0-9_]{0,31}$/D', $name) || isset($names[$name])) {
|
||
throw new InvalidForm('Jedes Feld braucht einen eindeutigen technischen Namen aus Kleinbuchstaben, Ziffern und Unterstrich.');
|
||
}
|
||
$names[$name] = true;
|
||
$type = (string) ($field['type'] ?? '');
|
||
if (!isset(self::TYPES[$type])) {
|
||
throw new InvalidForm('Unbekannter Feldtyp.');
|
||
}
|
||
$label = trim((string) ($field['label'] ?? ''));
|
||
if ('' === $label || mb_strlen($label) > 120) {
|
||
throw new InvalidForm('Jedes Feld braucht eine Beschriftung mit höchstens 120 Zeichen.');
|
||
}
|
||
$options = [];
|
||
if (in_array($type, self::CHOICE_TYPES, true)) {
|
||
foreach ((array) ($field['options'] ?? []) as $option) {
|
||
$option = trim((string) $option);
|
||
// 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 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; 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'] ?? ''));
|
||
if (false === filter_var($recipient, FILTER_VALIDATE_EMAIL)) {
|
||
throw new InvalidForm('Bitte gib eine gültige Empfängeradresse an.');
|
||
}
|
||
// Der Absender ist freiwillig: leer heisst, die Post kommt von der Empfaengeradresse.
|
||
$sender = trim((string) ($settings['sender'] ?? ''));
|
||
if ('' !== $sender && false === filter_var($sender, FILTER_VALIDATE_EMAIL)) {
|
||
throw new InvalidForm('Bitte gib eine gültige Absenderadresse an oder lass das Feld leer.');
|
||
}
|
||
$replyTo = trim((string) ($settings['replyToField'] ?? ''));
|
||
if ('' !== $replyTo) {
|
||
$match = array_filter($fields, static fn (array $f): bool => $f['name'] === $replyTo && 'email' === $f['type']);
|
||
if ([] === $match) {
|
||
throw new InvalidForm('Die Antwortadresse muss auf ein E-Mail-Feld dieses Formulars zeigen.');
|
||
}
|
||
}
|
||
|
||
$clean = [
|
||
'recipient' => $recipient,
|
||
'sender' => $sender,
|
||
'subject' => mb_substr(trim((string) ($settings['subject'] ?? '')), 0, 160) ?: 'Neue Formularnachricht',
|
||
'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.
|
||
foreach (self::COLORS as $field => $label) {
|
||
$value = trim((string) ($settings[$field] ?? ''));
|
||
if ('' !== $value && 1 !== preg_match(StyleSchema::COLOR, $value)) {
|
||
throw new InvalidForm(sprintf('„%s“ ist kein Farbwert für %s.', $value, $label));
|
||
}
|
||
$clean[$field] = $value;
|
||
}
|
||
|
||
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.
|
||
*
|
||
* Das Formular bekommt seine Farben als Variablen: Rahmen und Grund der Felder direkt, die
|
||
* Buttonfarben ueber dieselben Token, die auch der Standardbutton der Website benutzt - so
|
||
* behaelt der Absendeknopf Form und Darstellung aus dem Design und wechselt nur die Farbe.
|
||
* Nichts gesetzt, nichts geschrieben: dann gilt weiter die Website.
|
||
*/
|
||
public function css(array $form, array $theme): string
|
||
{
|
||
$values = [];
|
||
$button = false;
|
||
foreach (['fieldBorderColor' => '--form-border', 'fieldBackgroundColor' => '--form-bg', 'buttonColor' => '--kb-fill', 'buttonInk' => '--kb-ink'] as $field => $variable) {
|
||
$value = Theme::cssColor((string) ($form['settings'][$field] ?? ''), $theme);
|
||
if (null === $value) {
|
||
continue;
|
||
}
|
||
$values[] = $variable.':'.$value;
|
||
if ('buttonColor' === $field) {
|
||
$values[] = '--kb-hover-fill:color-mix(in srgb,'.$value.' 82%,var(--ink))';
|
||
$button = true;
|
||
}
|
||
if ('buttonInk' === $field) {
|
||
$values[] = '--kb-hover-ink:'.$value;
|
||
$button = true;
|
||
}
|
||
}
|
||
if ($button) {
|
||
// Die Darstellung (gefuellt, Rahmen, dezent) leitet Flaeche und Schrift aus Fuellung
|
||
// und Tinte ab - am Wurzelelement, mit den Werten von dort. Hier noch einmal, damit
|
||
// die Ableitung die Farben dieses Formulars nimmt.
|
||
foreach (ButtonDesign::appearance($theme['buttons']['appearance'] ?? 'solid') as $key => $value) {
|
||
$values[] = '--kb-'.$key.':'.$value;
|
||
}
|
||
}
|
||
if ([] === $values) {
|
||
return '';
|
||
}
|
||
|
||
return sprintf('.block-form[data-form="%s"]{%s}', $form['slug'], implode(';', $values));
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
{
|
||
$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) {
|
||
$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[$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[$name] = 'Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.';
|
||
}
|
||
$values[$name] = $consent ? 'ja' : '';
|
||
continue;
|
||
}
|
||
if ('' === $value) {
|
||
if ($field['required']) {
|
||
$errors[$name] = in_array($field['type'], self::CHOICE_TYPES, true) ? 'Bitte wähle eine der angebotenen Optionen.' : 'Dieses Feld wird benötigt.';
|
||
}
|
||
$values[$name] = '';
|
||
continue;
|
||
}
|
||
if ('email' === $field['type'] && false === filter_var($value, FILTER_VALIDATE_EMAIL)) {
|
||
$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[$name] = 'Bitte gib eine gültige Telefonnummer an.';
|
||
}
|
||
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);
|
||
}
|
||
}
|
||
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
|
||
{
|
||
// gmdate, not DateTimeImmutable: created_at is written in UTC, and a local clock ahead of
|
||
// UTC would push the window past every stored row and disable the limit entirely.
|
||
$since = gmdate('Y-m-d H:i:s', time() - 3600);
|
||
|
||
return self::RATE_LIMIT <= (int) $this->db->fetchOne(
|
||
'SELECT COUNT(*) FROM form_submission WHERE sender_hash = ? AND created_at >= ?',
|
||
[$senderHash, $since],
|
||
);
|
||
}
|
||
|
||
public function store(string $formId, array $values, bool $consent, string $senderHash): string
|
||
{
|
||
$id = bin2hex(random_bytes(16));
|
||
$now = gmdate('Y-m-d H:i:s');
|
||
$this->db->insert('form_submission', [
|
||
'id' => $id, 'form_id' => $formId,
|
||
'data' => json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||
'sender_hash' => $senderHash, 'created_at' => $now,
|
||
'consent_at' => $consent ? $now : null, 'delivered' => false,
|
||
]);
|
||
|
||
return $id;
|
||
}
|
||
|
||
public function markDelivered(string $submissionId): void
|
||
{
|
||
$this->db->executeStatement('UPDATE form_submission SET delivered = ? WHERE id = ?', [true, $submissionId]);
|
||
}
|
||
|
||
/**
|
||
* One page of the submissions, newest first.
|
||
*
|
||
* @return array{items: list<array>, page: int, pages: int, total: int}
|
||
*/
|
||
public function submissions(int $number = 1, int $perPage = 25): array
|
||
{
|
||
$slice = Pagination::of($number, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'), $perPage);
|
||
$items = array_map(static fn (array $row): array => [
|
||
'id' => $row['id'],
|
||
'form' => $row['name'] ?? $row['form_id'],
|
||
'data' => json_decode($row['data'], true, 512, JSON_THROW_ON_ERROR),
|
||
'createdAt' => $row['created_at'],
|
||
'consentAt' => $row['consent_at'],
|
||
'delivered' => (bool) $row['delivered'],
|
||
], $this->db->fetchAllAssociative(
|
||
'SELECT s.*, f.name FROM form_submission s LEFT JOIN form f ON f.id = s.form_id ORDER BY s.created_at DESC, s.id DESC LIMIT '.$slice['perPage'].' OFFSET '.$slice['offset'],
|
||
));
|
||
|
||
return ['items' => $items, 'page' => $slice['page'], 'pages' => $slice['pages'], 'total' => $slice['total']];
|
||
}
|
||
}
|