diff --git a/development-system/README.md b/development-system/README.md index 00e20fa..a222077 100644 --- a/development-system/README.md +++ b/development-system/README.md @@ -80,12 +80,13 @@ sowie klar erkennbare Demo-Termine. Die API prüft Kapazitäten innerhalb einer Transaktion und sperrt den Termin dabei mit `SELECT … FOR UPDATE`; parallele Anmeldungen können dadurch die Kapazität nicht überschreiten. -Die Demo speichert eine gültige Anmeldung direkt als `confirmed`. Sie hat -bewusst noch **keinen** Mailversand, kein Double-Opt-in, keine Erinnerungen, -keinen ICS-Feed und keine Verwaltungsoberfläche. Die Anzeige benennt diesen -Umstand ausdrücklich. Ein Produktiveinsatz erfordert außerdem die in den -SOLL-Dokumenten genannten Entscheidungen zu Art. 13 DSGVO, Löschfristen, -Mailbetrieb, Rollen/Berechtigungen und Missbrauchsschutz. +Die Demo speichert eine Anfrage zunächst als `pending_confirmation`. Sie +erstellt einen zeitlich begrenzten Bestätigungslink, bestätigt danach atomar +gegen die verfügbare Kapazität und erzeugt eine zweite Bestätigungsnachricht. +Bestätigungs- und Erinnerungsnachrichten liegen standardmäßig im lokalen +Mailausgang der Testverwaltung. Mit `BOOKING_MAIL_TRANSPORT=mail` versucht der +Job den Versand über die PHP-Funktion `mail()`; SMTP-Konfiguration und echter +Mailbetrieb gehören erst in die Produktiventscheidung. Der Browser erhält ein CSRF-Token per gleicher Sitzung; die API validiert alle Pflichtdaten serverseitig und begrenzt Anmeldungen pro Sitzung. Dies ist eine @@ -134,9 +135,39 @@ X-SUFW-Booking-CSRF: Für ein PS-`Probearbeiten` mit `introductionRequired: true` müssen `introductionAttended` den Wert `true` und `introductionDate` ein gültiges Datum im Format `YYYY-MM-DD` enthalten. Bei Erfolg antwortet die Demo mit -`201 Created`; bei einem inzwischen ausgebuchten oder doppelt gebuchten Termin +`202 Accepted`; bei einem inzwischen ausgebuchten oder doppelt gebuchten Termin mit `409 Conflict`; fehlerhafte Pflichtangaben erhalten `422`. +Der Link ruft `GET /booking/confirm.php?token=` auf. Erst dort wird die +Teilnahme in den Status `confirmed` überführt. Nicht bestätigte Anfragen laufen +nach 30 Minuten ab. + +## Eigenständiges PHP-Backend + +Die Testverwaltung läuft unabhängig von Contao unter +. Sie bietet für PW und PS: + +- Termin anlegen, bearbeiten sowie aktivieren/deaktivieren, +- Übersicht und Entfernen von Teilnahmen, +- lokaler Mailausgang mit kopierbarem Bestätigungslink, +- Anzeige bestätigter Teilnahmen nach frei wählbarem Zeitraum, +- abonnierbaren ICS-Feed je Projekt, +- manuellen Lauf der Ablauf- und Erinnerungsjobs. + +Der Verwaltungsbereich hat absichtlich keine Benutzerverwaltung +und darf nicht unverändert außerhalb der Testumgebung bereitgestellt werden. +Er ist eine eigenständige PHP-Seite, keine Contao-Komponente. + +Die zeitgesteuerten Aufgaben können zusätzlich ohne Browser ausgeführt werden: + +```powershell +docker compose exec app php scripts/Run-BookingJobs.php +``` + +Für einen späteren Betrieb muss dieser Aufruf als geplanter Job erfolgen. Die +ICS-Adresse zeigt die Testverwaltung an; sie enthält nur Terminmetadaten und +keine Daten der Teilnehmenden. + ## Zwei Einbindungswege Die Terminbuchungskomponente hat eine Vorschau auf einem Webseiten-Nachbau der Orginalseite: diff --git a/development-system/database/README.md b/development-system/database/README.md index e38b4d7..f804c80 100644 --- a/development-system/database/README.md +++ b/development-system/database/README.md @@ -54,4 +54,12 @@ MariaDB-/MySQL-Client ausgeführt. Zugangsdaten gehören dabei in die Betriebskonfiguration und niemals in die SQL-Datei oder das Repository. Die erste Migration erstellt `sufw_booking_events` und `sufw_booking_registrations` sowie neun ungefährliche Demo-Termine für beide -Instanzen. +Instanzen. `002_booking_workflow.sql` ergänzt Bestätigungstoken, lokalen +Mailausgang und pro Projekt einen beim Import zufällig erzeugten, +widerrufbaren ICS-Feed-Token. `003_booking_demo_data.sql` legt zusätzlich +bestätigte Demo-Teilnahmen an und hält Beispieltermine jeweils einen Monat in +der Vergangenheit und Zukunft. Diese letzte Datei ist ausschließlich für die +lokale Entwicklungs- und Testdatenbank bestimmt und darf nicht auf dem +Produktivsystem ausgeführt werden. `004_booking_contact_phones.sql` ergänzt +die Telefonnummer der Ansprechperson und ersetzt die Kontakte der stabilen +Demo-Termine durch Carina Hollederer und Sarah van den Heuvel. diff --git a/development-system/database/migrations/002_booking_workflow.sql b/development-system/database/migrations/002_booking_workflow.sql new file mode 100644 index 0000000..2179022 --- /dev/null +++ b/development-system/database/migrations/002_booking_workflow.sql @@ -0,0 +1,40 @@ +-- SUFW Terminbuchung: Bestätigungsfluss, Test-Mailausgang und iCalendar-Feeds +-- Diese Migration ergänzt das Anfangsschema und kann gefahrlos erneut laufen. + +ALTER TABLE `sufw_booking_registrations` + ADD COLUMN IF NOT EXISTS `confirmation_token_hash` char(64) DEFAULT NULL AFTER `privacy_accepted_at`, + ADD COLUMN IF NOT EXISTS `confirmation_expires_at` datetime DEFAULT NULL AFTER `confirmation_token_hash`, + ADD COLUMN IF NOT EXISTS `confirmed_at` datetime DEFAULT NULL AFTER `confirmation_expires_at`, + ADD COLUMN IF NOT EXISTS `removed_at` datetime DEFAULT NULL AFTER `confirmed_at`; + +CREATE TABLE IF NOT EXISTS `sufw_booking_project_settings` ( + `project` varchar(32) NOT NULL, + `feed_token` char(64) NOT NULL, + `updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`project`), + UNIQUE KEY `sufw_booking_project_settings_feed_token` (`feed_token`), + CONSTRAINT `sufw_booking_project_settings_project_check` CHECK (`project` IN ('perspektivwerk', 'produktionsschule')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sufw_booking_outbox` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `registration_id` char(36) NOT NULL, + `kind` varchar(32) NOT NULL, + `recipient_email` varchar(254) NOT NULL, + `subject` varchar(255) NOT NULL, + `body` text NOT NULL, + `created_at` datetime NOT NULL DEFAULT current_timestamp(), + `sent_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `sufw_booking_outbox_registration_kind` (`registration_id`, `kind`), + KEY `sufw_booking_outbox_pending` (`sent_at`, `created_at`), + CONSTRAINT `sufw_booking_outbox_kind_check` CHECK (`kind` IN ('confirmation_request', 'confirmation', 'reminder')), + CONSTRAINT `sufw_booking_outbox_registration_fk` FOREIGN KEY (`registration_id`) REFERENCES `sufw_booking_registrations` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO `sufw_booking_project_settings` (`project`, `feed_token`) +VALUES + ('perspektivwerk', SHA2(CONCAT(UUID(), RAND(), NOW(6)), 256)), + ('produktionsschule', SHA2(CONCAT(UUID(), RAND(), NOW(6)), 256)); + +INSERT IGNORE INTO `sufw_booking_schema_migrations` (`version`) VALUES ('002_booking_workflow'); diff --git a/development-system/database/migrations/003_booking_demo_data.sql b/development-system/database/migrations/003_booking_demo_data.sql new file mode 100644 index 0000000..6c25ded --- /dev/null +++ b/development-system/database/migrations/003_booking_demo_data.sql @@ -0,0 +1,29 @@ +-- SUFW Terminbuchung: Lokale Demo-Termine und bestätigte Teilnahmen +-- +-- Nur für lokale Entwicklungs- und Testdatenbanken. Diese Datei darf nicht +-- auf einem Produktivsystem ausgeführt werden: Sie legt bewusst Testpersonen +-- und Beispieltermine an. Die stabilen IDs verhindern Duplikate; beim erneuten +-- Ausführen werden die Termine relativ zum heutigen Datum aktualisiert. + +INSERT INTO `sufw_booking_events` + (`id`, `project`, `title`, `starts_at`, `ends_at`, `capacity`, `format`, `introduction_required`, `contact_name`, `contact_email`, `is_active`) +VALUES + ('d3c7f216-2704-4c81-b17a-000000000001', 'perspektivwerk', 'Erstgespräch PerspektivWerk', TIMESTAMP(DATE_ADD(CURDATE(), INTERVAL -1 MONTH), '09:00:00'), TIMESTAMP(DATE_ADD(CURDATE(), INTERVAL -1 MONTH), '10:00:00'), 3, 'vor_ort', 0, 'Anna Müller', 'anna.mueller@example.test', 1), + ('d3c7f216-2704-4c81-b17a-000000000002', 'perspektivwerk', 'Psy Modul PerspektivWerk', TIMESTAMP(DATE_ADD(CURDATE(), INTERVAL 1 MONTH), '13:15:00'), TIMESTAMP(DATE_ADD(CURDATE(), INTERVAL 1 MONTH), '17:15:00'), 2, 'telefonisch', 0, 'Anna Müller', 'anna.mueller@example.test', 1), + ('d3c7f216-2704-4c81-b17a-000000000003', 'produktionsschule', 'Voranmeldung Kennenlerntermin Produktionsschule', TIMESTAMP(DATE_ADD(CURDATE(), INTERVAL -1 MONTH), '10:30:00'), TIMESTAMP(DATE_ADD(CURDATE(), INTERVAL -1 MONTH), '11:30:00'), 3, 'vor_ort', 0, 'Bernd Fischer', 'bernd.fischer@example.test', 1), + ('d3c7f216-2704-4c81-b17a-000000000004', 'produktionsschule', 'Probearbeiten', TIMESTAMP(DATE_ADD(CURDATE(), INTERVAL 1 MONTH), '08:00:00'), TIMESTAMP(DATE_ADD(CURDATE(), INTERVAL 1 MONTH), '16:00:00'), 3, 'vor_ort', 1, 'Bernd Fischer', 'bernd.fischer@example.test', 1) +ON DUPLICATE KEY UPDATE + `title` = VALUES(`title`), `starts_at` = VALUES(`starts_at`), `ends_at` = VALUES(`ends_at`), `capacity` = VALUES(`capacity`), `format` = VALUES(`format`), `introduction_required` = VALUES(`introduction_required`), `contact_name` = VALUES(`contact_name`), `contact_email` = VALUES(`contact_email`), `is_active` = VALUES(`is_active`); + +INSERT INTO `sufw_booking_registrations` + (`id`, `event_id`, `status`, `name`, `email`, `phone`, `privacy_accepted_at`, `confirmed_at`, `introduction_attended`, `introduction_date`) +VALUES + ('e3c7f216-2704-4c81-b17a-000000000001', 'd3c7f216-2704-4c81-b17a-000000000001', 'confirmed', 'Demo Alina Beispiel', 'alina.beispiel@example.test', '+49 000 100001', DATE_SUB(NOW(), INTERVAL 35 DAY), DATE_SUB(NOW(), INTERVAL 35 DAY), 0, NULL), + ('e3c7f216-2704-4c81-b17a-000000000002', 'd3c7f216-2704-4c81-b17a-000000000001', 'confirmed', 'Demo Cem Beispiel', 'cem.beispiel@example.test', '+49 000 100002', DATE_SUB(NOW(), INTERVAL 34 DAY), DATE_SUB(NOW(), INTERVAL 34 DAY), 0, NULL), + ('e3c7f216-2704-4c81-b17a-000000000003', 'd3c7f216-2704-4c81-b17a-000000000002', 'confirmed', 'Demo Dana Beispiel', 'dana.beispiel@example.test', '+49 000 100003', NOW(), NOW(), 0, NULL), + ('e3c7f216-2704-4c81-b17a-000000000004', 'd3c7f216-2704-4c81-b17a-000000000003', 'confirmed', 'Demo Emir Beispiel', 'emir.beispiel@example.test', '+49 000 100004', DATE_SUB(NOW(), INTERVAL 35 DAY), DATE_SUB(NOW(), INTERVAL 35 DAY), 0, NULL), + ('e3c7f216-2704-4c81-b17a-000000000005', 'd3c7f216-2704-4c81-b17a-000000000004', 'confirmed', 'Demo Frida Beispiel', 'frida.beispiel@example.test', '+49 000 100005', NOW(), NOW(), 1, CURDATE()) +ON DUPLICATE KEY UPDATE + `event_id` = VALUES(`event_id`), `status` = VALUES(`status`), `name` = VALUES(`name`), `email` = VALUES(`email`), `phone` = VALUES(`phone`), `privacy_accepted_at` = VALUES(`privacy_accepted_at`), `confirmed_at` = VALUES(`confirmed_at`), `introduction_attended` = VALUES(`introduction_attended`), `introduction_date` = VALUES(`introduction_date`), `removed_at` = NULL; + +INSERT IGNORE INTO `sufw_booking_schema_migrations` (`version`) VALUES ('003_booking_demo_data'); diff --git a/development-system/database/migrations/004_booking_contact_phones.sql b/development-system/database/migrations/004_booking_contact_phones.sql new file mode 100644 index 0000000..f94b2b7 --- /dev/null +++ b/development-system/database/migrations/004_booking_contact_phones.sql @@ -0,0 +1,34 @@ +-- SUFW Terminbuchung: Telefonnummern der Ansprechpersonen +-- +-- Eigenständige, idempotente Migration. Sie erweitert das Terminmodell um +-- die Telefonnummer der fachlichen Ansprechperson. + +ALTER TABLE `sufw_booking_events` + ADD COLUMN IF NOT EXISTS `contact_phone` varchar(64) NOT NULL DEFAULT '' AFTER `contact_email`; + +-- Die fest versionierten Demo-Termine verwenden die abgestimmten Kontaktdaten. +-- Eigene, später angelegte Termine bleiben von diesen Updates unberührt. +UPDATE `sufw_booking_events` +SET `contact_name` = 'Carina Hollederer', `contact_email` = 'c.hollederer@sufw.de', `contact_phone` = '(0176) 17922244' +WHERE `id` IN ( + '6c7f216b-2704-4c81-b17a-000000000001', + '6c7f216b-2704-4c81-b17a-000000000002', + '6c7f216b-2704-4c81-b17a-000000000003', + '6c7f216b-2704-4c81-b17a-000000000004', + '6c7f216b-2704-4c81-b17a-000000000005', + 'd3c7f216-2704-4c81-b17a-000000000001', + 'd3c7f216-2704-4c81-b17a-000000000002' +); + +UPDATE `sufw_booking_events` +SET `contact_name` = 'Sarah van den Heuvel', `contact_email` = 's.vandenHeuvel@sufw.de', `contact_phone` = '(0176) 60328929' +WHERE `id` IN ( + '6c7f216b-2704-4c81-b17a-000000000006', + '6c7f216b-2704-4c81-b17a-000000000007', + '6c7f216b-2704-4c81-b17a-000000000008', + '6c7f216b-2704-4c81-b17a-000000000009', + 'd3c7f216-2704-4c81-b17a-000000000003', + 'd3c7f216-2704-4c81-b17a-000000000004' +); + +INSERT IGNORE INTO `sufw_booking_schema_migrations` (`version`) VALUES ('004_booking_contact_phones'); diff --git a/development-system/docker-compose.yml b/development-system/docker-compose.yml index 4a309b1..acf4ae3 100644 --- a/development-system/docker-compose.yml +++ b/development-system/docker-compose.yml @@ -9,6 +9,8 @@ services: APP_ENV: dev CONTAO_ENVIRONMENT: dev DATABASE_URL: mysql://perspektivwerk:perspektivwerk@db:3306/perspektivwerk?serverVersion=mariadb-10.11.0 + BOOKING_BASE_URL: http://localhost:8080 + BOOKING_MAIL_TRANSPORT: database command: - sh - -c diff --git a/development-system/public/.htaccess b/development-system/public/.htaccess index b3f78c1..e62760b 100644 --- a/development-system/public/.htaccess +++ b/development-system/public/.htaccess @@ -44,6 +44,10 @@ # directory request to Contao's front controller. RewriteRule ^perspektivwerk/?$ perspektivwerk/index.html [L] + # The independent booking administration is a plain PHP application, not a + # Contao route. Serve its directory URL directly as well. + RewriteRule ^booking-admin/?$ booking-admin/index.php [L] + # If the requested filename exists, simply serve it. # We only want to let Apache serve files and not directories. RewriteCond %{REQUEST_FILENAME} -f diff --git a/development-system/public/booking-admin/index.php b/development-system/public/booking-admin/index.php new file mode 100644 index 0000000..75054c5 --- /dev/null +++ b/development-system/public/booking-admin/index.php @@ -0,0 +1,177 @@ + strtotime($to)) { + echo json_encode(['error' => 'Bitte wählen Sie einen gültigen Zeitraum.'], JSON_UNESCAPED_UNICODE); + exit; + } + $sql = "SELECT e.title, DATE(e.starts_at) AS event_day, r.name FROM sufw_booking_registrations r JOIN sufw_booking_events e ON e.id = r.event_id WHERE r.status = 'confirmed' AND e.starts_at BETWEEN :fromDate AND :toDate"; + $parameters = ['fromDate' => $from.' 00:00:00', 'toDate' => $to.' 23:59:59']; + if ($exportProject !== 'all') { $sql .= ' AND e.project = :exportProject'; $parameters['exportProject'] = $exportProject; } + $statement = $database->prepare($sql.' ORDER BY e.starts_at, r.name'); + $statement->execute($parameters); + echo json_encode(['registrations' => $statement->fetchAll()], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if (!hash_equals($csrf, (string) ($_POST['csrf'] ?? ''))) { http_response_code(403); exit('Ungültige Sitzung.'); } + $action = (string) ($_POST['action'] ?? ''); + if ($action === 'save_event') { + $id = (string) ($_POST['id'] ?? '') ?: bookingUuid(); + $title = trim((string) ($_POST['title'] ?? '')); + $startsAt = str_replace('T', ' ', (string) ($_POST['starts_at'] ?? '')); + $endsAt = str_replace('T', ' ', (string) ($_POST['ends_at'] ?? '')); + $capacity = (int) ($_POST['capacity'] ?? 0); + $format = (string) ($_POST['format'] ?? 'vor_ort'); + $contactName = trim((string) ($_POST['contact_name'] ?? '')); + $contactEmail = trim((string) ($_POST['contact_email'] ?? '')); + $contactPhone = trim((string) ($_POST['contact_phone'] ?? '')); + if ($title === '' || $capacity < 1 || $capacity > 100 || !in_array($format, ['vor_ort', 'telefonisch'], true) || $contactName === '' || $contactPhone === '' || !filter_var($contactEmail, FILTER_VALIDATE_EMAIL) || !isQuarterHourDateTime($startsAt) || !isQuarterHourDateTime($endsAt) || strtotime($endsAt) <= strtotime($startsAt)) adminBack($project, 'events', 'Bitte prüfen Sie die Termindaten. Uhrzeiten sind nur im 15-Minuten-Raster zulässig.'); + $save = $database->prepare("INSERT INTO sufw_booking_events (id, project, title, starts_at, ends_at, capacity, format, introduction_required, contact_name, contact_email, contact_phone) VALUES (:id, :project, :title, :startsAt, :endsAt, :capacity, :format, :introductionRequired, :contactName, :contactEmail, :contactPhone) ON DUPLICATE KEY UPDATE title = VALUES(title), starts_at = VALUES(starts_at), ends_at = VALUES(ends_at), capacity = VALUES(capacity), format = VALUES(format), introduction_required = VALUES(introduction_required), contact_name = VALUES(contact_name), contact_email = VALUES(contact_email), contact_phone = VALUES(contact_phone)"); + $save->execute(['id' => $id, 'project' => $project, 'title' => $title, 'startsAt' => $startsAt, 'endsAt' => $endsAt, 'capacity' => $capacity, 'format' => $format, 'introductionRequired' => isset($_POST['introduction_required']) ? 1 : 0, 'contactName' => $contactName, 'contactEmail' => $contactEmail, 'contactPhone' => $contactPhone]); + adminBack($project, 'events', 'Termin gespeichert.'); + } + if ($action === 'toggle_event') { + $database->prepare('UPDATE sufw_booking_events SET is_active = 1 - is_active WHERE id = :id AND project = :project')->execute(['id' => $_POST['id'] ?? '', 'project' => $project]); + adminBack($project, 'events', 'Terminstatus geändert.'); + } + if ($action === 'remove_registration') { + $database->prepare("UPDATE sufw_booking_registrations SET status = 'removed', removed_at = UTC_TIMESTAMP() WHERE id = :id AND status IN ('pending_confirmation', 'confirmed')")->execute(['id' => $_POST['id'] ?? '']); + adminBack($project, 'registrations', 'Teilnahme entfernt; die Kapazität ist wieder frei.'); + } + if ($action === 'run_jobs') { + $result = bookingRunJobs($database); + adminBack($project, $view, "Jobs ausgeführt: {$result['expired']} abgelaufen, {$result['remindersConsidered']} Erinnerungen geprüft."); + } +} + +$titles = $database->prepare('SELECT DISTINCT title FROM sufw_booking_events WHERE project = :project ORDER BY title'); +$titles->execute(['project' => $project]); +$titles = $titles->fetchAll(PDO::FETCH_COLUMN); +$contacts = $database->prepare('SELECT DISTINCT contact_name, contact_email, contact_phone FROM sufw_booking_events WHERE project = :project ORDER BY contact_name, contact_email'); +$contacts->execute(['project' => $project]); +$contactByName = []; +$contactByEmail = []; +foreach ($contacts as $contact) { $contactByName[$contact['contact_name']] ??= ['email' => $contact['contact_email'], 'phone' => $contact['contact_phone']]; $contactByEmail[$contact['contact_email']] ??= ['name' => $contact['contact_name'], 'phone' => $contact['contact_phone']]; } +$settings = $database->prepare('SELECT feed_token FROM sufw_booking_project_settings WHERE project = :project'); +$settings->execute(['project' => $project]); +$feedToken = (string) $settings->fetchColumn(); +$notice = (string) ($_GET['notice'] ?? ''); + +$events = []; +$edit = null; +if ($view === 'events') { + $statement = $database->prepare("SELECT e.*, COUNT(CASE WHEN r.status = 'confirmed' THEN 1 END) AS confirmed_count FROM sufw_booking_events e LEFT JOIN sufw_booking_registrations r ON r.event_id = e.id WHERE e.project = :project GROUP BY e.id ORDER BY e.starts_at"); + $statement->execute(['project' => $project]); + $events = $statement->fetchAll(); + foreach ($events as $event) if ($event['id'] === ($_GET['edit'] ?? '')) $edit = $event; +} +$registrations = []; +if ($view === 'registrations') { + $statement = $database->prepare("SELECT r.*, e.title, e.starts_at FROM sufw_booking_registrations r JOIN sufw_booking_events e ON e.id = r.event_id WHERE e.project = :project ORDER BY r.created_at DESC LIMIT 200"); + $statement->execute(['project' => $project]); + $registrations = $statement->fetchAll(); +} +$outbox = []; +if ($view === 'events') { + $statement = $database->prepare("SELECT o.*, r.name FROM sufw_booking_outbox o JOIN sufw_booking_registrations r ON r.id = o.registration_id JOIN sufw_booking_events e ON e.id = r.event_id WHERE e.project = :project ORDER BY o.created_at DESC LIMIT 20"); + $statement->execute(['project' => $project]); + $outbox = $statement->fetchAll(); +} +$defaultFrom = date('Y-m-01'); +$defaultTo = date('Y-m-t'); +?> + +Terminbuchung · Testverwaltung +
+

Terminbuchung · Testverwaltung

+

+ + + +

+ + + + + +

Abbrechen

+
+

Termine

TerminZeitKapazitätStatus


bis
/ bestätigtBearbeiten
+

Lokaler Mailausgang

·
+ +

Teilnahmen ·

PersonTerminStatus



+ +

Bestätigte Teilnahmen anzeigen

Der Zeitraum umfasst jeweils vollständige Kalendertage (00:00 bis 23:59 Uhr) und aktualisiert die Liste unmittelbar.

+ + + +
Lade Teilnahmen …
+ +
+
+ diff --git a/development-system/public/booking/api.php b/development-system/public/booking/api.php index 0800415..9847b25 100644 --- a/development-system/public/booking/api.php +++ b/development-system/public/booking/api.php @@ -2,13 +2,7 @@ declare(strict_types=1); -/** - * Öffentliche Demo-API der Terminbuchung. - * - * Die Datei ist absichtlich nicht an Contao-Tabellen gekoppelt: Sie wird unter - * /booking/api.php direkt von Apache ausgeliefert und kann dadurch gleich aus - * der statischen Vorschau und aus einem Contao-HTML-Element verwendet werden. - */ +require __DIR__.'/backend.php'; header('Content-Type: application/json; charset=utf-8'); header('Cache-Control: no-store'); @@ -21,246 +15,58 @@ function respond(int $status, array $payload): never exit; } -function bookingDatabase(): PDO -{ - $databaseUrl = getenv('DATABASE_URL'); - if (!is_string($databaseUrl) || $databaseUrl === '') { - throw new RuntimeException('DATABASE_URL is not configured.'); - } - - $parts = parse_url($databaseUrl); - if (!is_array($parts) || ($parts['scheme'] ?? '') !== 'mysql' || !isset($parts['host'], $parts['path'], $parts['user'])) { - throw new RuntimeException('DATABASE_URL is invalid.'); - } - - $database = ltrim($parts['path'], '/'); - if ($database === '') { - throw new RuntimeException('DATABASE_URL has no database name.'); - } - - $port = isset($parts['port']) ? (int) $parts['port'] : 3306; - $dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $parts['host'], $port, $database); - - return new PDO($dsn, rawurldecode($parts['user']), rawurldecode($parts['pass'] ?? ''), [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - PDO::ATTR_EMULATE_PREPARES => false, - ]); -} - function bookingSession(): void { - if (session_status() === PHP_SESSION_ACTIVE) { - return; - } - + if (session_status() === PHP_SESSION_ACTIVE) return; session_name('sufw_booking'); - session_set_cookie_params([ - 'httponly' => true, - 'samesite' => 'Lax', - 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'), - ]); + session_set_cookie_params(['httponly' => true, 'samesite' => 'Lax', 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')]); session_start(); } function csrfToken(): string { bookingSession(); - if (!isset($_SESSION['csrf_token'])) { - $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); - } - - return $_SESSION['csrf_token']; + return $_SESSION['csrf_token'] ??= bin2hex(random_bytes(32)); } -function ensureBookingRateLimit(): void +function readPayload(): array { - bookingSession(); - $now = time(); - $recent = array_values(array_filter($_SESSION['booking_requests'] ?? [], static fn ($timestamp): bool => is_int($timestamp) && $timestamp > $now - 900)); - if (count($recent) >= 10) { - respond(429, ['error' => 'Zu viele Anfragen. Bitte versuchen Sie es später erneut.']); - } - - $recent[] = $now; - $_SESSION['booking_requests'] = $recent; -} - -function readJsonBody(): array -{ - $contentLength = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0); - if ($contentLength > 32768) { - respond(413, ['error' => 'Die Anfrage ist zu groß.']); - } - - try { - $payload = json_decode((string) file_get_contents('php://input'), true, 32, JSON_THROW_ON_ERROR); - } catch (JsonException) { - respond(400, ['error' => 'Die Anfrage konnte nicht verarbeitet werden.']); - } - - if (!is_array($payload)) { - respond(400, ['error' => 'Die Anfrage konnte nicht verarbeitet werden.']); - } - + if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 32768) respond(413, ['error' => 'Die Anfrage ist zu groß.']); + try { $payload = json_decode((string) file_get_contents('php://input'), true, 32, JSON_THROW_ON_ERROR); } + catch (JsonException) { respond(400, ['error' => 'Die Anfrage konnte nicht verarbeitet werden.']); } + if (!is_array($payload)) respond(400, ['error' => 'Die Anfrage konnte nicht verarbeitet werden.']); return $payload; } -function stringField(array $payload, string $name, int $maxLength): string +function ensureRateLimit(): void { - $value = trim((string) ($payload[$name] ?? '')); - if ($value === '' || mb_strlen($value) > $maxLength) { - throw new InvalidArgumentException($name); - } - - return $value; -} - -function dateField(array $payload, string $name): ?string -{ - $value = trim((string) ($payload[$name] ?? '')); - if ($value === '') { - return null; - } - - $date = DateTimeImmutable::createFromFormat('!Y-m-d', $value); - return $date && $date->format('Y-m-d') === $value ? $value : null; -} - -function registrationId(): string -{ - $hex = bin2hex(random_bytes(16)); - return sprintf('%s-%s-%s-%s-%s', substr($hex, 0, 8), substr($hex, 8, 4), substr($hex, 12, 4), substr($hex, 16, 4), substr($hex, 20)); -} - -function normaliseEvent(array $event): array -{ - $event['id'] = (string) $event['id']; - $event['capacity'] = (int) $event['capacity']; - $event['confirmedCount'] = (int) $event['confirmedCount']; - $event['availableCapacity'] = max(0, $event['capacity'] - $event['confirmedCount']); - $event['status'] = $event['availableCapacity'] > 0 ? 'available' : 'full'; - $event['introductionRequired'] = (bool) $event['introductionRequired']; - unset($event['confirmedCount']); - return $event; -} - -function listEvents(PDO $database): never -{ - $project = (string) ($_GET['project'] ?? ''); - $from = (string) ($_GET['from'] ?? ''); - $to = (string) ($_GET['to'] ?? ''); - if (!in_array($project, ['perspektivwerk', 'produktionsschule'], true) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $from) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) { - respond(400, ['error' => 'Ungültige Kalenderanfrage.']); - } - - $fromDate = DateTimeImmutable::createFromFormat('!Y-m-d', $from); - $toDate = DateTimeImmutable::createFromFormat('!Y-m-d', $to); - if (!$fromDate || !$toDate || $fromDate >= $toDate || $fromDate->diff($toDate)->days > 93) { - respond(400, ['error' => 'Ungültiger Kalenderzeitraum.']); - } - - $statement = $database->prepare(<<<'SQL' - SELECT e.id, e.project, e.title, e.starts_at AS startsAt, e.ends_at AS endsAt, - e.capacity, e.format, e.introduction_required AS introductionRequired, - e.contact_name AS contactName, - COUNT(r.id) AS confirmedCount - FROM sufw_booking_events e - LEFT JOIN sufw_booking_registrations r ON r.event_id = e.id AND r.status = 'confirmed' - WHERE e.project = :project AND e.is_active = 1 - AND e.starts_at < :toDate AND e.ends_at >= :fromDate - GROUP BY e.id - ORDER BY e.starts_at, e.title - SQL); - $statement->execute(['project' => $project, 'fromDate' => "$from 00:00:00", 'toDate' => "$to 00:00:00"]); - - respond(200, ['csrfToken' => csrfToken(), 'events' => array_map('normaliseEvent', $statement->fetchAll())]); -} - -function createRegistration(PDO $database): never -{ - ensureBookingRateLimit(); - $providedToken = (string) ($_SERVER['HTTP_X_SUFW_BOOKING_CSRF'] ?? ''); - if (!hash_equals(csrfToken(), $providedToken)) { - respond(403, ['error' => 'Die Sitzung ist abgelaufen. Bitte laden Sie den Kalender neu.']); - } - - $payload = readJsonBody(); - $project = (string) ($payload['project'] ?? ''); - $eventId = (string) ($payload['eventId'] ?? ''); - $privacyAccepted = ($payload['privacyAccepted'] ?? false) === true; - try { - $name = stringField($payload, 'name', 160); - $email = mb_strtolower(stringField($payload, 'email', 254)); - $phone = stringField($payload, 'phone', 64); - } catch (InvalidArgumentException) { - respond(422, ['error' => 'Bitte prüfen Sie Ihre Pflichtangaben.']); - } - - if (!in_array($project, ['perspektivwerk', 'produktionsschule'], true) || !preg_match('/^[a-f0-9-]{36}$/', $eventId) || !filter_var($email, FILTER_VALIDATE_EMAIL) || !preg_match('/^[0-9+()\-\/\s]{6,64}$/', $phone) || !$privacyAccepted) { - respond(422, ['error' => 'Bitte prüfen Sie Ihre Pflichtangaben.']); - } - - $introductionAttended = ($payload['introductionAttended'] ?? false) === true; - $introductionDate = dateField($payload, 'introductionDate'); - try { - $database->beginTransaction(); - $eventStatement = $database->prepare('SELECT id, introduction_required, capacity FROM sufw_booking_events WHERE id = :id AND project = :project AND is_active = 1 FOR UPDATE'); - $eventStatement->execute(['id' => $eventId, 'project' => $project]); - $event = $eventStatement->fetch(); - if (!$event) { - $database->rollBack(); - respond(404, ['error' => 'Dieser Termin ist nicht mehr verfügbar.']); - } - if ((bool) $event['introduction_required'] && (!$introductionAttended || $introductionDate === null)) { - $database->rollBack(); - respond(422, ['error' => 'Bitte bestätigen Sie die Einführungsveranstaltung und geben Sie das Datum an.']); - } - - $countStatement = $database->prepare("SELECT COUNT(*) FROM sufw_booking_registrations WHERE event_id = :eventId AND status = 'confirmed'"); - $countStatement->execute(['eventId' => $eventId]); - if ((int) $countStatement->fetchColumn() >= (int) $event['capacity']) { - $database->rollBack(); - respond(409, ['error' => 'Dieser Termin ist inzwischen ausgebucht. Bitte wählen Sie einen anderen Termin.']); - } - - $duplicateStatement = $database->prepare("SELECT id FROM sufw_booking_registrations WHERE event_id = :eventId AND status = 'confirmed' AND email = :email AND name = :name LIMIT 1"); - $duplicateStatement->execute(['eventId' => $eventId, 'email' => $email, 'name' => $name]); - if ($duplicateStatement->fetch()) { - $database->rollBack(); - respond(409, ['error' => 'Für diese Person liegt bereits eine bestätigte Anmeldung vor.']); - } - - $insert = $database->prepare(<<<'SQL' - INSERT INTO sufw_booking_registrations - (id, event_id, status, name, email, phone, privacy_accepted_at, introduction_attended, introduction_date) - VALUES - (:id, :eventId, 'confirmed', :name, :email, :phone, UTC_TIMESTAMP(), :introductionAttended, :introductionDate) - SQL); - $insert->execute([ - 'id' => registrationId(), 'eventId' => $eventId, 'name' => $name, 'email' => $email, 'phone' => $phone, - 'introductionAttended' => $introductionAttended ? 1 : 0, - 'introductionDate' => $introductionAttended ? $introductionDate : null, - ]); - $database->commit(); - } catch (Throwable $exception) { - if ($database->inTransaction()) { - $database->rollBack(); - } - error_log('SUFW booking API: '.$exception->getMessage()); - respond(500, ['error' => 'Die Anmeldung konnte nicht gespeichert werden. Bitte versuchen Sie es später erneut.']); - } - - respond(201, ['message' => 'Ihre Anmeldung wurde verbindlich gespeichert.']); + bookingSession(); + $now = time(); + $recent = array_values(array_filter($_SESSION['booking_requests'] ?? [], static fn ($value): bool => is_int($value) && $value > $now - 900)); + if (count($recent) >= 10) respond(429, ['error' => 'Zu viele Anfragen. Bitte versuchen Sie es später erneut.']); + $recent[] = $now; + $_SESSION['booking_requests'] = $recent; } try { $database = bookingDatabase(); if ($_SERVER['REQUEST_METHOD'] === 'GET') { - listEvents($database); + $project = (string) ($_GET['project'] ?? ''); + $from = (string) ($_GET['from'] ?? ''); + $to = (string) ($_GET['to'] ?? ''); + $fromDate = DateTimeImmutable::createFromFormat('!Y-m-d', $from); + $toDate = DateTimeImmutable::createFromFormat('!Y-m-d', $to); + if (!bookingProjectIsValid($project) || !$fromDate || !$toDate || $fromDate >= $toDate || $fromDate->diff($toDate)->days > 93) respond(400, ['error' => 'Ungültige Kalenderanfrage.']); + respond(200, ['csrfToken' => csrfToken(), 'events' => bookingListEvents($database, $project, $from, $to)]); } if ($_SERVER['REQUEST_METHOD'] === 'POST') { - createRegistration($database); + ensureRateLimit(); + if (!hash_equals(csrfToken(), (string) ($_SERVER['HTTP_X_SUFW_BOOKING_CSRF'] ?? ''))) respond(403, ['error' => 'Die Sitzung ist abgelaufen. Bitte laden Sie den Kalender neu.']); + try { $registration = bookingCreateRegistration($database, readPayload()); } + catch (InvalidArgumentException $exception) { respond(422, ['error' => $exception->getMessage()]); } + catch (OutOfBoundsException $exception) { respond(404, ['error' => $exception->getMessage()]); } + catch (LogicException|RuntimeException $exception) { respond(409, ['error' => $exception->getMessage()]); } + respond(202, ['message' => 'Ihre Anfrage wurde gespeichert. Bitte bestätigen Sie den Link aus der E-Mail innerhalb von 30 Minuten.', 'expiresAt' => $registration['expiresAt']]); } header('Allow: GET, POST'); respond(405, ['error' => 'Diese Methode wird nicht unterstützt.']); diff --git a/development-system/public/booking/backend.php b/development-system/public/booking/backend.php new file mode 100644 index 0000000..9d2e13b --- /dev/null +++ b/development-system/public/booking/backend.php @@ -0,0 +1,184 @@ + PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false]); +} + +function bookingBaseUrl(): string +{ + $configured = getenv('BOOKING_BASE_URL'); + if (is_string($configured) && $configured !== '') { + return rtrim($configured, '/'); + } + $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + return $scheme.'://'.($_SERVER['HTTP_HOST'] ?? 'localhost'); +} + +function bookingUuid(): string +{ + $hex = bin2hex(random_bytes(16)); + return sprintf('%s-%s-%s-%s-%s', substr($hex, 0, 8), substr($hex, 8, 4), substr($hex, 12, 4), substr($hex, 16, 4), substr($hex, 20)); +} + +function bookingProjectIsValid(string $project): bool +{ + return in_array($project, ['perspektivwerk', 'produktionsschule'], true); +} + +function bookingProjectTitle(string $project): string +{ + return $project === 'produktionsschule' ? 'Produktionsschule' : 'PerspektivWerk'; +} + +function bookingQueueMail(PDO $database, string $registrationId, string $kind, string $email, string $subject, string $body): void +{ + $statement = $database->prepare('INSERT IGNORE INTO sufw_booking_outbox (registration_id, kind, recipient_email, subject, body) VALUES (:registrationId, :kind, :email, :subject, :body)'); + $statement->execute(compact('registrationId', 'kind', 'email', 'subject', 'body')); +} + +function bookingNormaliseEvent(array $event): array +{ + $event['capacity'] = (int) $event['capacity']; + $event['confirmedCount'] = (int) $event['confirmedCount']; + $event['availableCapacity'] = max(0, $event['capacity'] - $event['confirmedCount']); + $event['status'] = $event['availableCapacity'] > 0 ? 'available' : 'full'; + $event['introductionRequired'] = (bool) $event['introductionRequired']; + unset($event['confirmedCount']); + return $event; +} + +function bookingListEvents(PDO $database, string $project, string $from, string $to): array +{ + $statement = $database->prepare(<<<'SQL' + SELECT e.id, e.project, e.title, e.starts_at AS startsAt, e.ends_at AS endsAt, e.capacity, + e.format, e.introduction_required AS introductionRequired, e.contact_name AS contactName, + COUNT(r.id) AS confirmedCount + FROM sufw_booking_events e + LEFT JOIN sufw_booking_registrations r ON r.event_id = e.id AND r.status = 'confirmed' + WHERE e.project = :project AND e.is_active = 1 AND e.starts_at < :toDate AND e.ends_at >= :fromDate + GROUP BY e.id ORDER BY e.starts_at, e.title + SQL); + $statement->execute(['project' => $project, 'fromDate' => "$from 00:00:00", 'toDate' => "$to 00:00:00"]); + return array_map('bookingNormaliseEvent', $statement->fetchAll()); +} + +function bookingCreateRegistration(PDO $database, array $payload): array +{ + $project = (string) ($payload['project'] ?? ''); + $eventId = (string) ($payload['eventId'] ?? ''); + $name = trim((string) ($payload['name'] ?? '')); + $email = mb_strtolower(trim((string) ($payload['email'] ?? ''))); + $phone = trim((string) ($payload['phone'] ?? '')); + $privacyAccepted = ($payload['privacyAccepted'] ?? false) === true; + $introductionAttended = ($payload['introductionAttended'] ?? false) === true; + $introductionDate = trim((string) ($payload['introductionDate'] ?? '')); + if (!bookingProjectIsValid($project) || !preg_match('/^[a-f0-9-]{36}$/', $eventId) || $name === '' || mb_strlen($name) > 160 || !filter_var($email, FILTER_VALIDATE_EMAIL) || mb_strlen($email) > 254 || !preg_match('/^[0-9+()\-\/\s]{6,64}$/', $phone) || !$privacyAccepted) { + throw new InvalidArgumentException('Bitte prüfen Sie Ihre Pflichtangaben.'); + } + if ($introductionDate !== '' && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $introductionDate) || !DateTimeImmutable::createFromFormat('!Y-m-d', $introductionDate))) { + throw new InvalidArgumentException('Bitte prüfen Sie das Datum der Einführungsveranstaltung.'); + } + + $database->beginTransaction(); + try { + $eventStatement = $database->prepare('SELECT * FROM sufw_booking_events WHERE id = :id AND project = :project AND is_active = 1 FOR UPDATE'); + $eventStatement->execute(['id' => $eventId, 'project' => $project]); + $event = $eventStatement->fetch(); + if (!$event) throw new OutOfBoundsException('Dieser Termin ist nicht mehr verfügbar.'); + if ((bool) $event['introduction_required'] && (!$introductionAttended || $introductionDate === '')) { + throw new InvalidArgumentException('Bitte bestätigen Sie die Einführungsveranstaltung und geben Sie das Datum an.'); + } + $confirmed = $database->prepare("SELECT COUNT(*) FROM sufw_booking_registrations WHERE event_id = :eventId AND status = 'confirmed'"); + $confirmed->execute(['eventId' => $eventId]); + if ((int) $confirmed->fetchColumn() >= (int) $event['capacity']) throw new RuntimeException('Dieser Termin ist inzwischen ausgebucht.'); + $duplicate = $database->prepare("SELECT id FROM sufw_booking_registrations WHERE event_id = :eventId AND email = :email AND name = :name AND status IN ('pending_confirmation', 'confirmed') LIMIT 1"); + $duplicate->execute(['eventId' => $eventId, 'email' => $email, 'name' => $name]); + if ($duplicate->fetch()) throw new LogicException('Für diese Person liegt bereits eine Anmeldung für diesen Termin vor.'); + + $registrationId = bookingUuid(); + $token = bin2hex(random_bytes(32)); + $insert = $database->prepare(<<<'SQL' + INSERT INTO sufw_booking_registrations + (id, event_id, status, name, email, phone, privacy_accepted_at, confirmation_token_hash, confirmation_expires_at, introduction_attended, introduction_date) + VALUES + (:id, :eventId, 'pending_confirmation', :name, :email, :phone, UTC_TIMESTAMP(), :tokenHash, DATE_ADD(UTC_TIMESTAMP(), INTERVAL 30 MINUTE), :introductionAttended, :introductionDate) + SQL); + $insert->execute(['id' => $registrationId, 'eventId' => $eventId, 'name' => $name, 'email' => $email, 'phone' => $phone, 'tokenHash' => hash('sha256', $token), 'introductionAttended' => $introductionAttended ? 1 : 0, 'introductionDate' => $introductionAttended ? $introductionDate : null]); + $link = bookingBaseUrl().'/booking/confirm.php?token='.rawurlencode($token); + $subject = 'Bitte bestätigen Sie Ihre Terminanfrage'; + $body = "Guten Tag $name,\n\nbitte bestätigen Sie Ihre Anfrage für \"{$event['title']}\" am {$event['starts_at']} innerhalb von 30 Minuten:\n$link\n\nKontakt für Rückfragen: {$event['contact_name']} ({$event['contact_email']})."; + bookingQueueMail($database, $registrationId, 'confirmation_request', $email, $subject, $body); + $database->commit(); + return ['registrationId' => $registrationId, 'expiresAt' => gmdate('c', time() + 1800)]; + } catch (Throwable $exception) { + if ($database->inTransaction()) $database->rollBack(); + throw $exception; + } +} + +function bookingConfirmRegistration(PDO $database, string $token): array +{ + if (!preg_match('/^[a-f0-9]{64}$/', $token)) throw new InvalidArgumentException('Der Bestätigungslink ist ungültig.'); + $database->beginTransaction(); + try { + $statement = $database->prepare(<<<'SQL' + SELECT r.*, e.title, e.project, e.starts_at, e.ends_at, e.capacity, e.contact_name, e.contact_email + FROM sufw_booking_registrations r JOIN sufw_booking_events e ON e.id = r.event_id + WHERE r.confirmation_token_hash = :tokenHash FOR UPDATE + SQL); + $statement->execute(['tokenHash' => hash('sha256', $token)]); + $registration = $statement->fetch(); + if (!$registration || $registration['status'] !== 'pending_confirmation') throw new OutOfBoundsException('Dieser Bestätigungslink ist nicht mehr gültig.'); + if (strtotime($registration['confirmation_expires_at'].' UTC') < time()) { + $database->prepare("UPDATE sufw_booking_registrations SET status = 'expired' WHERE id = :id")->execute(['id' => $registration['id']]); + $database->commit(); + throw new OutOfBoundsException('Die Bestätigungsfrist ist abgelaufen. Bitte fragen Sie den Termin erneut an.'); + } + $count = $database->prepare("SELECT COUNT(*) FROM sufw_booking_registrations WHERE event_id = :eventId AND status = 'confirmed'"); + $count->execute(['eventId' => $registration['event_id']]); + if ((int) $count->fetchColumn() >= (int) $registration['capacity']) throw new RuntimeException('Der Termin wurde inzwischen vergeben. Bitte wählen Sie einen anderen Termin.'); + $database->prepare("UPDATE sufw_booking_registrations SET status = 'confirmed', confirmed_at = UTC_TIMESTAMP(), confirmation_token_hash = NULL WHERE id = :id")->execute(['id' => $registration['id']]); + $history = $database->prepare(<<<'SQL' + SELECT e.title, e.starts_at FROM sufw_booking_registrations r JOIN sufw_booking_events e ON e.id = r.event_id + WHERE r.email = :email AND r.name = :name AND r.status = 'confirmed' ORDER BY e.starts_at + SQL); + $history->execute(['email' => $registration['email'], 'name' => $registration['name']]); + $appointments = implode("\n", array_map(static fn (array $row): string => "- {$row['title']} am {$row['starts_at']}", $history->fetchAll())); + $body = "Guten Tag {$registration['name']},\n\nIhr Termin ist bestätigt:\n{$registration['title']} am {$registration['starts_at']} bis {$registration['ends_at']}.\n\nIhre weiteren bestätigten Termine:\n$appointments\n\nBei Stornierungswunsch wenden Sie sich an {$registration['contact_name']} ({$registration['contact_email']})."; + bookingQueueMail($database, $registration['id'], 'confirmation', $registration['email'], 'Ihr Termin ist bestätigt', $body); + $database->commit(); + return $registration; + } catch (Throwable $exception) { + if ($database->inTransaction()) $database->rollBack(); + throw $exception; + } +} + +function bookingRunJobs(PDO $database): array +{ + $expired = $database->exec("UPDATE sufw_booking_registrations SET status = 'expired' WHERE status = 'pending_confirmation' AND confirmation_expires_at < UTC_TIMESTAMP()"); + $reminders = $database->query(<<<'SQL' + SELECT r.id, r.email, r.name, e.title, e.starts_at, e.contact_name, e.contact_email + FROM sufw_booking_registrations r JOIN sufw_booking_events e ON e.id = r.event_id + WHERE r.status = 'confirmed' AND DATE(e.starts_at) = DATE_ADD(UTC_DATE(), INTERVAL 2 DAY) + SQL)->fetchAll(); + foreach ($reminders as $reminder) { + $body = "Guten Tag {$reminder['name']},\n\ndies ist die Erinnerung an Ihren Termin \"{$reminder['title']}\" am {$reminder['starts_at']}.\n\nKontakt: {$reminder['contact_name']} ({$reminder['contact_email']})."; + bookingQueueMail($database, $reminder['id'], 'reminder', $reminder['email'], 'Erinnerung an Ihren Termin', $body); + } + return ['expired' => $expired, 'remindersConsidered' => count($reminders)]; +} + +function bookingIcsEscape(string $value): string +{ + return str_replace(["\\", ";", ",", "\n"], ["\\\\", "\\;", "\\,", "\\n"], $value); +} diff --git a/development-system/public/booking/booking.js b/development-system/public/booking/booking.js index 45a51c2..01a8ce7 100644 --- a/development-system/public/booking/booking.js +++ b/development-system/public/booking/booking.js @@ -50,7 +50,7 @@ export function validateBookingForm({ name = '', email = '', phone = '', privacy } export function canSubmitBookingForm(privacyAccepted) { return privacyAccepted; } -export function bookingConfirmationMessage(appointmentTitle) { return `Ihre Anmeldung für ${appointmentTitle} wurde verbindlich gespeichert.`; } +export function bookingConfirmationMessage(appointmentTitle) { return `Ihre Anfrage für ${appointmentTitle} wurde gespeichert. Bitte bestätigen Sie den Link aus der E-Mail innerhalb von 30 Minuten.`; } function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (character) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[character]); } function createMonthDates(month) { @@ -89,7 +89,7 @@ function openConfirmationDialog(container, event) { const dialog = document.createElement('dialog'); dialog.className = 'sufw-booking__dialog'; dialog.setAttribute('aria-labelledby', 'sufw-booking-confirmation-title'); - dialog.innerHTML = `

Termin bestätigt

Vielen Dank für Ihre Anmeldung

${escapeHtml(bookingConfirmationMessage(event.title))}

${escapeHtml(event.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule')}
${escapeHtml(dateFormatter.format(parseDate(event.startsAt.slice(0, 10))))} · ${formatEventTime(event)} · ${escapeHtml(event.title)}

Dies ist die lokale Demoversion. Es wird noch keine Bestätigungs-, Erinnerungs- oder Kalender-E-Mail versendet.

`; + dialog.innerHTML = `

E-Mail bestätigen

Fast geschafft

${escapeHtml(bookingConfirmationMessage(event.title))}

${escapeHtml(event.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule')}
${escapeHtml(dateFormatter.format(parseDate(event.startsAt.slice(0, 10))))} · ${formatEventTime(event)} · ${escapeHtml(event.title)}

In der lokalen Demo liegt die Nachricht im Mailausgang des Verwaltungsbereichs bereit.

`; dialog.querySelector('[data-close-confirmation]').addEventListener('click', () => dialog.close()); dialog.addEventListener('close', () => dialog.remove()); container.append(dialog); diff --git a/development-system/public/booking/confirm.php b/development-system/public/booking/confirm.php new file mode 100644 index 0000000..12b95ab --- /dev/null +++ b/development-system/public/booking/confirm.php @@ -0,0 +1,25 @@ +$safeTitle

$safeTitle

$safeMessage

Zurück zur Terminbuchung

"; + exit; +} + +try { + $registration = bookingConfirmRegistration(bookingDatabase(), (string) ($_GET['token'] ?? '')); + page('Termin bestätigt', "Vielen Dank. Ihr Termin \"{$registration['title']}\" ist verbindlich bestätigt.", true); +} catch (Throwable $exception) { + page('Bestätigung nicht möglich', $exception->getMessage()); +} diff --git a/development-system/public/booking/ical.php b/development-system/public/booking/ical.php new file mode 100644 index 0000000..a26b043 --- /dev/null +++ b/development-system/public/booking/ical.php @@ -0,0 +1,48 @@ +prepare('SELECT feed_token FROM sufw_booking_project_settings WHERE project = :project'); +$settings->execute(['project' => $project]); +if (!hash_equals((string) $settings->fetchColumn(), $token)) { + http_response_code(404); + exit; +} + +$events = $database->prepare(<<<'SQL' + SELECT e.id, e.title, e.starts_at, e.ends_at, e.format + FROM sufw_booking_events e + WHERE e.project = :project AND EXISTS (SELECT 1 FROM sufw_booking_registrations r WHERE r.event_id = e.id AND r.status = 'confirmed') + ORDER BY e.starts_at + SQL); +$events->execute(['project' => $project]); +$timezone = new DateTimeZone('Europe/Berlin'); +$utc = new DateTimeZone('UTC'); +$lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//SUFW//Terminbuchung//DE', 'CALSCALE:GREGORIAN', 'X-WR-CALNAME:'.bookingIcsEscape(bookingProjectTitle($project))]; +foreach ($events as $event) { + $start = new DateTimeImmutable($event['starts_at'], $timezone); + $end = new DateTimeImmutable($event['ends_at'], $timezone); + $lines[] = 'BEGIN:VEVENT'; + $lines[] = 'UID:'.bookingIcsEscape($event['id']).'@sufw-booking'; + $lines[] = 'DTSTAMP:'.gmdate('Ymd\\THis\\Z'); + $lines[] = 'DTSTART:'.$start->setTimezone($utc)->format('Ymd\\THis\\Z'); + $lines[] = 'DTEND:'.$end->setTimezone($utc)->format('Ymd\\THis\\Z'); + $lines[] = 'SUMMARY:'.bookingIcsEscape($event['title']); + $lines[] = 'DESCRIPTION:'.bookingIcsEscape($event['format'] === 'telefonisch' ? 'Telefonischer Termin' : 'Termin vor Ort'); + $lines[] = 'END:VEVENT'; +} +$lines[] = 'END:VCALENDAR'; +header('Content-Type: text/calendar; charset=utf-8'); +header('Content-Disposition: inline; filename="'.($project === 'produktionsschule' ? 'produktionsschule' : 'perspektivwerk').'-termine.ics"'); +header('Cache-Control: no-store'); +echo implode("\r\n", $lines)."\r\n"; diff --git a/development-system/public/perspektivwerk/js/booking.js b/development-system/public/perspektivwerk/js/booking.js index 45a51c2..01a8ce7 100644 --- a/development-system/public/perspektivwerk/js/booking.js +++ b/development-system/public/perspektivwerk/js/booking.js @@ -50,7 +50,7 @@ export function validateBookingForm({ name = '', email = '', phone = '', privacy } export function canSubmitBookingForm(privacyAccepted) { return privacyAccepted; } -export function bookingConfirmationMessage(appointmentTitle) { return `Ihre Anmeldung für ${appointmentTitle} wurde verbindlich gespeichert.`; } +export function bookingConfirmationMessage(appointmentTitle) { return `Ihre Anfrage für ${appointmentTitle} wurde gespeichert. Bitte bestätigen Sie den Link aus der E-Mail innerhalb von 30 Minuten.`; } function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (character) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[character]); } function createMonthDates(month) { @@ -89,7 +89,7 @@ function openConfirmationDialog(container, event) { const dialog = document.createElement('dialog'); dialog.className = 'sufw-booking__dialog'; dialog.setAttribute('aria-labelledby', 'sufw-booking-confirmation-title'); - dialog.innerHTML = `

Termin bestätigt

Vielen Dank für Ihre Anmeldung

${escapeHtml(bookingConfirmationMessage(event.title))}

${escapeHtml(event.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule')}
${escapeHtml(dateFormatter.format(parseDate(event.startsAt.slice(0, 10))))} · ${formatEventTime(event)} · ${escapeHtml(event.title)}

Dies ist die lokale Demoversion. Es wird noch keine Bestätigungs-, Erinnerungs- oder Kalender-E-Mail versendet.

`; + dialog.innerHTML = `

E-Mail bestätigen

Fast geschafft

${escapeHtml(bookingConfirmationMessage(event.title))}

${escapeHtml(event.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule')}
${escapeHtml(dateFormatter.format(parseDate(event.startsAt.slice(0, 10))))} · ${formatEventTime(event)} · ${escapeHtml(event.title)}

In der lokalen Demo liegt die Nachricht im Mailausgang des Verwaltungsbereichs bereit.

`; dialog.querySelector('[data-close-confirmation]').addEventListener('click', () => dialog.close()); dialog.addEventListener('close', () => dialog.remove()); container.append(dialog); diff --git a/development-system/scripts/Run-BookingJobs.php b/development-system/scripts/Run-BookingJobs.php new file mode 100644 index 0000000..a622b66 --- /dev/null +++ b/development-system/scripts/Run-BookingJobs.php @@ -0,0 +1,18 @@ +query('SELECT id, recipient_email, subject, body FROM sufw_booking_outbox WHERE sent_at IS NULL ORDER BY id'); + foreach ($outbox as $mail) { + if (mail($mail['recipient_email'], $mail['subject'], $mail['body'])) { + $database->prepare('UPDATE sufw_booking_outbox SET sent_at = UTC_TIMESTAMP() WHERE id = :id')->execute(['id' => $mail['id']]); + } + } +} +echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL; diff --git a/development-system/tests/booking-migrations.test.mjs b/development-system/tests/booking-migrations.test.mjs index 4b314c2..f99fd84 100644 --- a/development-system/tests/booking-migrations.test.mjs +++ b/development-system/tests/booking-migrations.test.mjs @@ -22,3 +22,63 @@ test('hält die statische Vorschaukopie der Buchungsassets identisch', async () assert.equal(previewScript, componentScript); assert.equal(previewStyle, componentStyle); }); + +test('modelliert Bestätigung, Mailausgang und ICS-Feeds als zweite Migration', async () => { + const migration = await readFile(new URL('../database/migrations/002_booking_workflow.sql', import.meta.url), 'utf8'); + + assert.match(migration, /confirmation_token_hash/); + assert.match(migration, /confirmation_expires_at/); + assert.match(migration, /CREATE TABLE IF NOT EXISTS `sufw_booking_outbox`/); + assert.match(migration, /CREATE TABLE IF NOT EXISTS `sufw_booking_project_settings`/); + assert.match(migration, /UNIQUE KEY `sufw_booking_outbox_registration_kind`/); +}); + +test('liefert lokale Demo-Termine und bestätigte Teilnahmen vor und nach dem heutigen Datum', async () => { + const migration = await readFile(new URL('../database/migrations/003_booking_demo_data.sql', import.meta.url), 'utf8'); + + assert.match(migration, /Nur für lokale Entwicklungs- und Testdatenbanken/); + assert.match(migration, /DATE_ADD\(CURDATE\(\), INTERVAL -1 MONTH\)/); + assert.match(migration, /DATE_ADD\(CURDATE\(\), INTERVAL 1 MONTH\)/); + assert.match(migration, /'confirmed'/); + assert.match(migration, /sufw_booking_registrations/); +}); + +test('erweitert Demo-Kontakte um die vorgesehenen Ansprechpartnerdaten', async () => { + const migration = await readFile(new URL('../database/migrations/004_booking_contact_phones.sql', import.meta.url), 'utf8'); + + assert.match(migration, /ADD COLUMN IF NOT EXISTS `contact_phone`/); + assert.match(migration, /Carina Hollederer/); + assert.match(migration, /Sarah van den Heuvel/); +}); + +test('liefert die unabhängige Verwaltung unter booking-admin mit getrennten Bereichen', async () => { + const [routes, admin] = await Promise.all([ + readFile(new URL('../public/.htaccess', import.meta.url), 'utf8'), + readFile(new URL('../public/booking-admin/index.php', import.meta.url), 'utf8'), + ]); + + assert.match(routes, /\^booking-admin\/\?\$ booking-admin\/index\.php/); + assert.match(admin, /view === 'registrations'/); + assert.match(admin, /view === 'export'/); + assert.doesNotMatch(admin, /getenv\(/); + assert.match(admin, />TeilnahmenExport { assert.equal(canSubmitBookingForm(true), true); }); -test('zeigt nach dem Absenden eine verbindliche Bestätigung ohne Mockup-Hinweis', () => { +test('zeigt nach dem Absenden den nächsten Bestätigungsschritt ohne Mockup-Hinweis', () => { const message = bookingConfirmationMessage('Erstgespräch'); - assert.match(message, /verbindlich gespeichert/); + assert.match(message, /E-Mail/); assert.doesNotMatch(message, /Vorschau|Mockup/i); });