Compare commits

..
6 Commits
24 changed files with 346 additions and 176 deletions

No files matched your search

+1
View File
@@ -5,6 +5,7 @@
/public/share /public/share
/public/system /public/system
/public/vendor /public/vendor
/public/booking-config.php
/system /system
/bin/console /bin/console
/node_modules/ /node_modules/
+60 -29
View File
@@ -58,15 +58,40 @@ http://localhost:8080/perspektivwerk/
Die genaue Contao-, PHP- und Template-Version der öffentlichen Seite ist nicht verlässlich aus dem ausgelieferten HTML ableitbar. Das Setup bildet daher die wahrscheinlichste Komponentenfamilie nach; es ist kein Dump der Produktivinstallation. IONOS/Hosting, Matomo-Konfiguration, Inhalte, Templates und Erweiterungen müssen bei Bedarf separat ergänzt werden. Die genaue Contao-, PHP- und Template-Version der öffentlichen Seite ist nicht verlässlich aus dem ausgelieferten HTML ableitbar. Das Setup bildet daher die wahrscheinlichste Komponentenfamilie nach; es ist kein Dump der Produktivinstallation. IONOS/Hosting, Matomo-Konfiguration, Inhalte, Templates und Erweiterungen müssen bei Bedarf separat ergänzt werden.
Die Terminlogik liegt in `public/booking/booking.js` als browsernatives ES-Modul. Für die statische Perspektivwerk-Vorschau liegen identische Kopien unter `public/perspektivwerk/js/`. Der vollständige, statisch auslieferbare Perspektivwerk-DocumentRoot liegt in
`public/perspektivwerk/`. Er enthält die Buchungs-API unter `booking/` und die
Verwaltung unter `booking-admin/`. Die Vorschau verwendet relative API-URLs und
funktioniert dadurch lokal unter `/perspektivwerk/` sowie auf dem Server direkt
im DocumentRoot.
Für einen einfachen Upload der vollständigen statischen Mockup-Seite auf den
Testserver steht unter Windows dieses Skript bereit:
```bat
scripts\Deploy-Perspektivwerk.bat
```
Es überträgt den vollständigen Ordner `public\perspektivwerk` und dessen
benachbarte Konfigurationsdatei `public\booking-config.php` nach
`boogie:html/perspektivwerk/`. Damit entsteht der Server-DocumentRoot unter
`html/perspektivwerk/perspektivwerk/`; die Konfigurationsdatei liegt eine Ebene
darüber und damit außerhalb des DocumentRoots. Anschließend setzt es per SSH
lesbare Dateien und durchsuchbare Verzeichnisse.
Vor dem ersten Upload `public\booking-config.php.example` nach
`public\booking-config.php` kopieren und dort die Produktiv-`database_url`
eintragen. Die echte Konfigurationsdatei ist von Git ausgeschlossen; sie wird
bei fehlender oder leerer PHP-Umgebungsvariable `DATABASE_URL` verwendet. Die
mit übertragene `.htaccess` verweigert den HTTP-Zugriff auf diese Datei.
Voraussetzung ist ein konfigurierter OpenSSH-Zugang mit dem Host-Alias
`boogie`.
## Datenbankgestützte Buchungsdemo ## Datenbankgestützte Buchungsdemo
Die Buchungsdemo lädt ihre Termine aus MariaDB und speichert eine Anmeldung Die Buchungsdemo lädt ihre Termine aus MariaDB und speichert eine Anmeldung
über `public/booking/api.php`. Die API wird unabhängig von Contao direkt durch über `public/perspektivwerk/booking/api.php`. Die API wird unabhängig von
Apache ausgeliefert, verwendet aber dieselbe `DATABASE_URL` wie Contao. Dadurch Contao direkt durch Apache ausgeliefert. In der statischen Vorschau liegt sie
funktioniert sie aus der Vorschau `/perspektivwerk/` und aus dem Contao- relativ unter `/perspektivwerk/booking/api.php`.
HTML-Element gleichermaßen.
Vor dem ersten Aufruf müssen die externen Migrationen eingespielt werden: Vor dem ersten Aufruf müssen die externen Migrationen eingespielt werden:
@@ -80,13 +105,12 @@ sowie klar erkennbare Demo-Termine. Die API prüft Kapazitäten innerhalb einer
Transaktion und sperrt den Termin dabei mit `SELECT … FOR UPDATE`; parallele Transaktion und sperrt den Termin dabei mit `SELECT … FOR UPDATE`; parallele
Anmeldungen können dadurch die Kapazität nicht überschreiten. Anmeldungen können dadurch die Kapazität nicht überschreiten.
Die Demo speichert eine Anfrage zunächst als `pending_confirmation`. Sie Die Demo speichert eine Anfrage zunächst als `pending_confirmation` und
erstellt einen zeitlich begrenzten Bestätigungslink, bestätigt danach atomar übergibt die Bestätigungsnachricht direkt an das Server-Sendmail. Der Link ist
gegen die verfügbare Kapazität und erzeugt eine zweite Bestätigungsnachricht. 30 Minuten gültig; sein Aufruf bestätigt die Teilnahme atomar und löst die
Bestätigungs- und Erinnerungsnachrichten liegen standardmäßig im lokalen zweite Nachricht mit ICS-Anhang aus. Ohne Cronjob werden keine Erinnerungen
Mailausgang der Testverwaltung. Mit `BOOKING_MAIL_TRANSPORT=mail` versucht der verschickt. Lokal ist der Mailtransport bewusst mit
Job den Versand über die PHP-Funktion `mail()`; SMTP-Konfiguration und echter `BOOKING_MAIL_TRANSPORT=disabled` deaktiviert.
Mailbetrieb gehören erst in die Produktiventscheidung.
Der Browser erhält ein CSRF-Token per gleicher Sitzung; die API validiert alle Der Browser erhält ein CSRF-Token per gleicher Sitzung; die API validiert alle
Pflichtdaten serverseitig und begrenzt Anmeldungen pro Sitzung. Dies ist eine Pflichtdaten serverseitig und begrenzt Anmeldungen pro Sitzung. Dies ist eine
@@ -145,14 +169,12 @@ nach 30 Minuten ab.
## Eigenständiges PHP-Backend ## Eigenständiges PHP-Backend
Die Testverwaltung läuft unabhängig von Contao unter Die Testverwaltung läuft unabhängig von Contao unter
<http://localhost:8080/booking-admin/>. Sie bietet für PW und PS: <http://localhost:8080/perspektivwerk/booking-admin/>. Sie bietet für PW und PS:
- Termin anlegen, bearbeiten sowie aktivieren/deaktivieren, - Termin anlegen, bearbeiten sowie aktivieren/deaktivieren,
- Übersicht und Entfernen von Teilnahmen, - Übersicht und Entfernen von Teilnahmen,
- lokaler Mailausgang mit kopierbarem Bestätigungslink,
- Anzeige bestätigter Teilnahmen nach frei wählbarem Zeitraum, - Anzeige bestätigter Teilnahmen nach frei wählbarem Zeitraum,
- abonnierbaren ICS-Feed je Projekt, - abonnierbaren ICS-Feed je Projekt.
- manuellen Lauf der Ablauf- und Erinnerungsjobs.
Der Verwaltungsbereich hat absichtlich keine Benutzerverwaltung Der Verwaltungsbereich hat absichtlich keine Benutzerverwaltung
und darf nicht unverändert außerhalb der Testumgebung bereitgestellt werden. und darf nicht unverändert außerhalb der Testumgebung bereitgestellt werden.
@@ -204,34 +226,43 @@ Contao maskiert nicht freigegebene HTML-Tags auch im Inhaltselement **HTML**. Vo
Danach den Contao-Cache über **Systemwartung** leeren und das Inhaltselement erneut speichern. Keine globale Freigabe mit `*` verwenden. Wer ein HTML-Inhaltselement bearbeiten darf, kann mit dem freigegebenen `script`-Tag JavaScript auf der Website ausführen; diese Bearbeitungsrechte müssen daher auf vertrauenswürdige Backend-Nutzer beschränkt bleiben. Danach den Contao-Cache über **Systemwartung** leeren und das Inhaltselement erneut speichern. Keine globale Freigabe mit `*` verwenden. Wer ein HTML-Inhaltselement bearbeiten darf, kann mit dem freigegebenen `script`-Tag JavaScript auf der Website ausführen; diese Bearbeitungsrechte müssen daher auf vertrauenswürdige Backend-Nutzer beschränkt bleiben.
Die Termin- und Kapazitätsdaten kommen aus `/booking/api.php`; Datenbankzugänge Die Termin- und Kapazitätsdaten kommen in der statischen Vorschau aus
oder SQL gehören niemals in das HTML oder das Browser-JavaScript. `/perspektivwerk/booking/api.php`; Datenbankzugänge oder SQL gehören niemals in
das HTML oder das Browser-JavaScript.
`public/perspektivwerk/js/booking.js` und die zugehörige CSS-Datei sind die für `public/perspektivwerk/js/booking.js` und die zugehörige CSS-Datei sind die für
den statischen Upload bestimmte Kopie. Ein Test stellt sicher, dass beide den statischen Upload bestimmte Kopie. Ein Test stellt sicher, dass beide
Asset-Paare gleich bleiben. Nach Änderungen können sie lokal synchronisiert Asset-Paare gleich bleiben. Nach Änderungen können sie lokal synchronisiert
werden mit: werden mit:
```powershell ```powershell
Copy-Item public\booking\booking.js public\perspektivwerk\js\booking.js -Force Copy-Item public\perspektivwerk\booking\booking.js public\perspektivwerk\js\booking.js -Force
Copy-Item public\booking\booking.css public\perspektivwerk\js\booking.css -Force Copy-Item public\perspektivwerk\booking\booking.css public\perspektivwerk\js\booking.css -Force
``` ```
Der vorhandene SCP-Upload überträgt weiterhin nur die statische Der vorhandene SCP-Upload überträgt den vollständigen statischen
Perspektivwerk-Seite. Für einen externen Einsatz der Buchungsdemo müssen Perspektivwerk-DocumentRoot einschließlich Buchungsdemo. Vor dem externen
zusätzlich `/booking/api.php`, die Migrationen und die sichere Einsatz müssen die Migrationen eingespielt und `booking-config.php` mit einer
`DATABASE_URL`-Konfiguration auf dem Zielserver bereitgestellt werden. sicheren Datenbank-URL befüllt sein.
## Statische Perspektivwerk-Seite übertragen ## Statische Perspektivwerk-Seite übertragen
Das Skript [scripts/Deploy-Perspektivwerk.ps1](scripts/Deploy-Perspektivwerk.ps1) überträgt ausschließlich den Inhalt von `public/perspektivwerk/` per SCP und korrigiert anschließend die Lesbarkeit auf dem Server per SSH. Dieser Ordner enthält auch `js/booking.js` und `js/booking.css`. Es lädt weder Contao noch die Datenbank oder andere Projektdateien hoch. Das Skript [scripts/Deploy-Perspektivwerk.bat](scripts/Deploy-Perspektivwerk.bat) überträgt den Ordner `public/perspektivwerk/` als vollständigen DocumentRoot per SCP und korrigiert anschließend die Lesbarkeit auf dem Server per SSH. Es lädt weder Contao noch Datenbank-Migrationen hoch.
```powershell ```bat
.\scripts\Deploy-Perspektivwerk.ps1 scripts\Deploy-Perspektivwerk.bat
``` ```
Der Hostname `boogie` wird aus der lokalen SSH-Konfiguration verwendet. Der vollständige Inhalt wird per SCP-Aufruf nach `/home/boogie/html/perspektivwerk/` übertragen. Anschließend führt ein separater SSH-Aufruf `chmod -R a+rX /home/boogie/html/perspektivwerk` aus. Die Passphrase des SSH-Schlüssels wird daher in der Regel zweimal abgefragt. Der Hostname `boogie` wird aus der lokalen SSH-Konfiguration verwendet. Der
Ordner landet unter `/home/boogie/html/perspektivwerk/perspektivwerk/`; die
Konfiguration liegt daneben unter `/home/boogie/html/perspektivwerk/booking-config.php`.
Anschließend führt ein separater SSH-Aufruf `chmod -R a+rX
/home/boogie/html/perspektivwerk` aus. Die Passphrase des SSH-Schlüssels wird
daher in der Regel zweimal abgefragt.
Vor dem Upload gibt das Skript den vollständigen Zielpfad aus. Damit lässt sich direkt prüfen, ob `booking.js` unter `/home/boogie/html/perspektivwerk/js/booking.js` landet. Das große `X` in `a+rX` setzt das Ausführungsrecht nur auf Verzeichnissen und auf bereits ausführbaren Dateien; normale HTML-, CSS- und JavaScript-Dateien erhalten kein unnötiges Ausführungsbit. Vor dem Upload muss die ignorierte `booking-config.php` befüllt sein. Das große
`X` in `a+rX` setzt das Ausführungsrecht nur auf Verzeichnissen und auf bereits
ausführbaren Dateien; normale HTML-, CSS- und JavaScript-Dateien erhalten kein
unnötiges Ausführungsbit.
## Versionierter Contao-Seitenzustand ## Versionierter Contao-Seitenzustand
+8 -3
View File
@@ -54,12 +54,17 @@ MariaDB-/MySQL-Client ausgeführt. Zugangsdaten gehören dabei in die
Betriebskonfiguration und niemals in die SQL-Datei oder das Repository. Die Betriebskonfiguration und niemals in die SQL-Datei oder das Repository. Die
erste Migration erstellt `sufw_booking_events` und erste Migration erstellt `sufw_booking_events` und
`sufw_booking_registrations` sowie neun ungefährliche Demo-Termine für beide `sufw_booking_registrations` sowie neun ungefährliche Demo-Termine für beide
Instanzen. `002_booking_workflow.sql` ergänzt Bestätigungstoken, lokalen Instanzen. `002_booking_workflow.sql` ergänzt Bestätigungstoken, den später
Mailausgang und pro Projekt einen beim Import zufällig erzeugten, entfernten Test-Mailausgang und pro Projekt einen beim Import zufällig
widerrufbaren ICS-Feed-Token. `003_booking_demo_data.sql` legt zusätzlich 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 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 der Vergangenheit und Zukunft. Diese letzte Datei ist ausschließlich für die
lokale Entwicklungs- und Testdatenbank bestimmt und darf nicht auf dem lokale Entwicklungs- und Testdatenbank bestimmt und darf nicht auf dem
Produktivsystem ausgeführt werden. `004_booking_contact_phones.sql` ergänzt Produktivsystem ausgeführt werden. `004_booking_contact_phones.sql` ergänzt
die Telefonnummer der Ansprechperson und ersetzt die Kontakte der stabilen die Telefonnummer der Ansprechperson und ersetzt die Kontakte der stabilen
Demo-Termine durch Carina Hollederer und Sarah van den Heuvel. Demo-Termine durch Carina Hollederer und Sarah van den Heuvel.
`005_booking_demo_july_august.sql` ergänzt ausschließlich lokal weitere
bestätigte Demo-Termine und -Teilnahmen für Juli und August.
`006_remove_booking_outbox.sql` entfernt den nicht mehr verwendeten
Test-Mailausgang; Buchungsnachrichten werden direkt an das Server-Sendmail
übergeben.
@@ -0,0 +1,26 @@
-- SUFW Terminbuchung: Zusätzliche lokale Demo-Termine für Juli und August
--
-- Nur für lokale Entwicklungs- und Testdatenbanken. Stabile IDs sorgen dafür,
-- dass die Beispieldaten bei erneutem Ausführen nicht dupliziert werden.
INSERT INTO `sufw_booking_events`
(`id`, `project`, `title`, `starts_at`, `ends_at`, `capacity`, `format`, `introduction_required`, `contact_name`, `contact_email`, `contact_phone`, `is_active`)
VALUES
('f5c7f216-2704-4c81-b17a-000000000001', 'perspektivwerk', 'Soz. Päd. Modul PerspektivWerk', CONCAT(YEAR(CURDATE()), '-07-10 09:00:00'), CONCAT(YEAR(CURDATE()), '-07-10 13:00:00'), 2, 'vor_ort', 0, 'Carina Hollederer', 'c.hollederer@sufw.de', '(0176) 17922244', 1),
('f5c7f216-2704-4c81-b17a-000000000002', 'produktionsschule', 'Voranmeldung Kennenlerntermin Produktionsschule', CONCAT(YEAR(CURDATE()), '-07-10 10:00:00'), CONCAT(YEAR(CURDATE()), '-07-10 11:00:00'), 3, 'vor_ort', 0, 'Sarah van den Heuvel', 's.vandenHeuvel@sufw.de', '(0176) 60328929', 1),
('f5c7f216-2704-4c81-b17a-000000000003', 'perspektivwerk', 'Erstgespräch PerspektivWerk', CONCAT(YEAR(CURDATE()), '-08-12 09:00:00'), CONCAT(YEAR(CURDATE()), '-08-12 10:00:00'), 2, 'vor_ort', 0, 'Carina Hollederer', 'c.hollederer@sufw.de', '(0176) 17922244', 1),
('f5c7f216-2704-4c81-b17a-000000000004', 'produktionsschule', 'Probearbeiten', CONCAT(YEAR(CURDATE()), '-08-12 08:00:00'), CONCAT(YEAR(CURDATE()), '-08-12 16:00:00'), 2, 'vor_ort', 1, 'Sarah van den Heuvel', 's.vandenHeuvel@sufw.de', '(0176) 60328929', 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`), `contact_phone` = VALUES(`contact_phone`), `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
('g5c7f216-2704-4c81-b17a-000000000001', 'f5c7f216-2704-4c81-b17a-000000000001', 'confirmed', 'Demo Greta Juli', 'greta.juli@example.test', '+49 000 100006', DATE_SUB(NOW(), INTERVAL 50 DAY), DATE_SUB(NOW(), INTERVAL 50 DAY), 0, NULL),
('g5c7f216-2704-4c81-b17a-000000000002', 'f5c7f216-2704-4c81-b17a-000000000002', 'confirmed', 'Demo Hasan Juli', 'hasan.juli@example.test', '+49 000 100007', DATE_SUB(NOW(), INTERVAL 50 DAY), DATE_SUB(NOW(), INTERVAL 50 DAY), 0, NULL),
('g5c7f216-2704-4c81-b17a-000000000003', 'f5c7f216-2704-4c81-b17a-000000000003', 'confirmed', 'Demo Ines August', 'ines.august@example.test', '+49 000 100008', DATE_SUB(NOW(), INTERVAL 20 DAY), DATE_SUB(NOW(), INTERVAL 20 DAY), 0, NULL),
('g5c7f216-2704-4c81-b17a-000000000004', 'f5c7f216-2704-4c81-b17a-000000000004', 'confirmed', 'Demo Jakob August', 'jakob.august@example.test', '+49 000 100009', DATE_SUB(NOW(), INTERVAL 20 DAY), DATE_SUB(NOW(), INTERVAL 20 DAY), 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 ('005_booking_demo_july_august');
@@ -0,0 +1,6 @@
-- SUFW Terminbuchung: Der Test-Mailausgang wird durch direkten Sendmail-Versand ersetzt.
-- Diese Migration entfernt ausschließlich die nicht mehr verwendete Outbox-Tabelle.
DROP TABLE IF EXISTS `sufw_booking_outbox`;
INSERT IGNORE INTO `sufw_booking_schema_migrations` (`version`) VALUES ('006_remove_booking_outbox');
+2 -2
View File
@@ -9,8 +9,8 @@ services:
APP_ENV: dev APP_ENV: dev
CONTAO_ENVIRONMENT: dev CONTAO_ENVIRONMENT: dev
DATABASE_URL: mysql://perspektivwerk:perspektivwerk@db:3306/perspektivwerk?serverVersion=mariadb-10.11.0 DATABASE_URL: mysql://perspektivwerk:perspektivwerk@db:3306/perspektivwerk?serverVersion=mariadb-10.11.0
BOOKING_BASE_URL: http://localhost:8080 BOOKING_BASE_URL: http://localhost:8080/perspektivwerk
BOOKING_MAIL_TRANSPORT: database BOOKING_MAIL_TRANSPORT: disabled
command: command:
- sh - sh
- -c - -c
+13 -3
View File
@@ -1,3 +1,13 @@
<Files "booking-config.php">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
</Files>
<IfModule mod_rewrite.c> <IfModule mod_rewrite.c>
RewriteEngine On RewriteEngine On
@@ -44,9 +54,9 @@
# directory request to Contao's front controller. # directory request to Contao's front controller.
RewriteRule ^perspektivwerk/?$ perspektivwerk/index.html [L] RewriteRule ^perspektivwerk/?$ perspektivwerk/index.html [L]
# The independent booking administration is a plain PHP application, not a # The standalone booking administration lives below the static mockup and
# Contao route. Serve its directory URL directly as well. # must not be handled by Contao's front controller.
RewriteRule ^booking-admin/?$ booking-admin/index.php [L] RewriteRule ^perspektivwerk/booking-admin/?$ perspektivwerk/booking-admin/index.php [L]
# If the requested filename exists, simply serve it. # If the requested filename exists, simply serve it.
# We only want to let Apache serve files and not directories. # We only want to let Apache serve files and not directories.
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
// Copy this file to booking-config.php, then insert the production database URL.
// booking-config.php is intentionally excluded from Git and denied by public/.htaccess.
return [
'database_url' => 'mysql://USER:PASSWORD@database-host:3306/booking_database',
];
@@ -68,10 +68,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$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'] ?? '']); $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.'); 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 = $database->prepare('SELECT DISTINCT title FROM sufw_booking_events WHERE project = :project ORDER BY title');
@@ -101,21 +97,16 @@ if ($view === 'registrations') {
$statement->execute(['project' => $project]); $statement->execute(['project' => $project]);
$registrations = $statement->fetchAll(); $registrations = $statement->fetchAll();
} }
$outbox = []; $nextMonth = new DateTimeImmutable('first day of next month');
if ($view === 'events') { $defaultFrom = $nextMonth->format('Y-m-01');
$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"); $defaultTo = $nextMonth->format('Y-m-t');
$statement->execute(['project' => $project]);
$outbox = $statement->fetchAll();
}
$defaultFrom = date('Y-m-01');
$defaultTo = date('Y-m-t');
?> ?>
<!doctype html> <!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Terminbuchung · Testverwaltung</title> <html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Terminbuchung · Testverwaltung</title>
<style> <style>
:root{--green:#08752a;--bg:#f3f6f3;--line:#cad2cb;--ink:#202020}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.45 Arial,sans-serif}.shell{max-width:1180px;margin:auto;padding:1.5rem}header{display:flex;gap:1rem;justify-content:space-between;align-items:start;flex-wrap:wrap}h1,h2{color:var(--green)}h1{margin:0}.warning,.notice{padding:.75rem 1rem;border-left:4px solid #a36a00;background:#fff8e7}.notice{border-color:var(--green);background:#eaf6ed}.tabs{display:flex;flex-wrap:wrap;gap:.5rem;margin:1rem 0}.tabs a,.button,button{display:inline-block;padding:.65rem .85rem;border:1px solid var(--green);background:#fff;color:var(--green);font:inherit;text-decoration:none;cursor:pointer}.tabs a.active,button.primary{background:var(--green);color:#fff}.grid{display:grid;gap:1.25rem;grid-template-columns:1.25fr .75fr;margin-top:1.25rem}.card{background:#fff;border:1px solid var(--line);padding:1rem;overflow:auto}table{border-collapse:collapse;width:100%;min-width:680px}th,td{padding:.6rem;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}small{color:#555}.status{font-weight:bold}.confirmed{color:#08752a}.pending_confirmation{color:#a36a00}.removed,.expired{color:#8a1515}form.inline{display:inline}label{display:grid;gap:.25rem;font-weight:bold}input,select{font:inherit;padding:.55rem;border:1px solid #68736a;width:100%}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.8rem}.wide{grid-column:1/-1}pre{white-space:pre-wrap;max-width:42rem;font:13px/1.35 ui-monospace,monospace}.date-time-trigger{width:100%;min-height:2.75rem;text-align:left}.picker-dialog{width:min(25rem,calc(100% - 2rem));border:0;padding:0;box-shadow:0 .75rem 2rem #0006}.picker-dialog::backdrop{background:#0008}.picker-content{padding:1rem}.picker-head,.picker-actions{display:flex;justify-content:space-between;gap:.5rem;align-items:center}.picker-month{font-weight:bold;text-transform:capitalize}.picker-week,.picker-days{display:grid;grid-template-columns:repeat(7,1fr);gap:.25rem;text-align:center}.picker-week{margin:1rem 0 .25rem;font-size:.8rem;font-weight:bold}.picker-days button{min-height:2.4rem;padding:.25rem;border:1px solid transparent}.picker-days button.selected{background:var(--green);color:#fff}.picker-days button:disabled{visibility:hidden}.picker-time{display:grid;grid-template-columns:1fr 1fr;gap:.75rem;margin:1rem 0}.export-results{margin-top:1rem}.export-results li{margin:.5rem 0;white-space:nowrap;overflow-x:auto}@media(max-width:760px){.grid,.form-grid{grid-template-columns:1fr}.shell{padding:1rem}} :root{--green:#08752a;--bg:#f3f6f3;--line:#cad2cb;--ink:#202020}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.45 Arial,sans-serif}.shell{max-width:1180px;margin:auto;padding:1.5rem}header{display:flex;gap:1rem;justify-content:space-between;align-items:start;flex-wrap:wrap}h1,h2{color:var(--green)}h1{margin:0}.warning,.notice{padding:.75rem 1rem;border-left:4px solid #a36a00;background:#fff8e7}.notice{border-color:var(--green);background:#eaf6ed}.tabs{display:flex;flex-wrap:wrap;gap:.5rem;margin:1rem 0}.tabs a,.button,button{display:inline-block;padding:.65rem .85rem;border:1px solid var(--green);background:#fff;color:var(--green);font:inherit;text-decoration:none;cursor:pointer}.tabs a.active,button.primary{background:var(--green);color:#fff}.grid{display:grid;gap:1.25rem;grid-template-columns:1.25fr .75fr;margin-top:1.25rem}.card{background:#fff;border:1px solid var(--line);padding:1rem;overflow:auto}table{border-collapse:collapse;width:100%;min-width:680px}th,td{padding:.6rem;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}small{color:#555}.status{font-weight:bold}.confirmed{color:#08752a}.pending_confirmation{color:#a36a00}.removed,.expired{color:#8a1515}form.inline{display:inline}label{display:grid;gap:.25rem;font-weight:bold}input,select{font:inherit;padding:.55rem;border:1px solid #68736a;width:100%}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.8rem}.wide{grid-column:1/-1}pre{white-space:pre-wrap;max-width:42rem;font:13px/1.35 ui-monospace,monospace}.date-time-trigger{width:100%;min-height:2.75rem;text-align:left}.picker-dialog{width:min(25rem,calc(100% - 2rem));border:0;padding:0;box-shadow:0 .75rem 2rem #0006}.picker-dialog::backdrop{background:#0008}.picker-content{padding:1rem}.picker-head,.picker-actions{display:flex;justify-content:space-between;gap:.5rem;align-items:center}.picker-month{font-weight:bold;text-transform:capitalize}.picker-week,.picker-days{display:grid;grid-template-columns:repeat(7,1fr);gap:.25rem;text-align:center}.picker-week{margin:1rem 0 .25rem;font-size:.8rem;font-weight:bold}.picker-days button{min-height:2.4rem;padding:.25rem;border:1px solid transparent}.picker-days button.selected{background:var(--green);color:#fff}.picker-days button:disabled{visibility:hidden}.picker-time{display:grid;grid-template-columns:1fr 1fr;gap:.75rem;margin:1rem 0}.export-results{margin-top:1rem}.export-results li{margin:.5rem 0;white-space:nowrap;overflow-x:auto}@media(max-width:760px){.grid,.form-grid{grid-template-columns:1fr}.shell{padding:1rem}}
select[data-dropdown-indicator]{appearance:none;padding-right:2.5rem;background:#fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='%2308752a' d='m3 6 5 5 5-5z'/%3E%3C/svg%3E") no-repeat right .75rem center/1rem}</style></head><body><main class="shell"> select[data-dropdown-indicator]{appearance:none;padding-right:2.5rem;background:#fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='%2308752a' d='m3 6 5 5 5-5z'/%3E%3C/svg%3E") no-repeat right .75rem center/1rem}</style></head><body><main class="shell">
<header><div><h1>Terminbuchung · Testverwaltung</h1></div><form method="post"><input type="hidden" name="csrf" value="<?=h($csrf)?>"><input type="hidden" name="project" value="<?=h($project)?>"><input type="hidden" name="action" value="run_jobs"><button>Jobs ausführen</button></form></header> <header><div><h1>Terminbuchung · Testverwaltung</h1></div></header>
<?php if ($notice !== ''): ?><p class="notice"><?=h($notice)?></p><?php endif; ?> <?php if ($notice !== ''): ?><p class="notice"><?=h($notice)?></p><?php endif; ?>
<nav class="tabs"><a class="<?= $view === 'events' && $project === 'perspektivwerk' ? 'active' : '' ?>" href="?project=perspektivwerk&view=events">PerspektivWerk</a><a class="<?= $view === 'events' && $project === 'produktionsschule' ? 'active' : '' ?>" href="?project=produktionsschule&view=events">Produktionsschule</a><a class="<?= $view === 'registrations' ? 'active' : '' ?>" href="?project=<?=h($project)?>&view=registrations">Teilnahmen</a><a class="<?= $view === 'export' ? 'active' : '' ?>" href="?project=<?=h($project)?>&view=export">Export</a></nav> <nav class="tabs"><a class="<?= $view === 'events' && $project === 'perspektivwerk' ? 'active' : '' ?>" href="?project=perspektivwerk&view=events">PerspektivWerk</a><a class="<?= $view === 'events' && $project === 'produktionsschule' ? 'active' : '' ?>" href="?project=produktionsschule&view=events">Produktionsschule</a><a class="<?= $view === 'registrations' ? 'active' : '' ?>" href="?project=<?=h($project)?>&view=registrations">Teilnahmen</a><a class="<?= $view === 'export' ? 'active' : '' ?>" href="?project=<?=h($project)?>&view=export">Export</a></nav>
@@ -129,7 +120,6 @@ select[data-dropdown-indicator]{appearance:none;padding-right:2.5rem;background:
<label class="wide"><span><input type="checkbox" name="introduction_required" <?=!empty($edit['introduction_required']) ? 'checked' : ''?>> Einführungsveranstaltung erforderlich (PS-Probearbeiten)</span></label></div><p><button class="primary">Termin speichern</button><?php if ($edit): ?> <a class="button" href="?project=<?=h($project)?>&view=events">Abbrechen</a><?php endif; ?></p></form></article> <label class="wide"><span><input type="checkbox" name="introduction_required" <?=!empty($edit['introduction_required']) ? 'checked' : ''?>> Einführungsveranstaltung erforderlich (PS-Probearbeiten)</span></label></div><p><button class="primary">Termin speichern</button><?php if ($edit): ?> <a class="button" href="?project=<?=h($project)?>&view=events">Abbrechen</a><?php endif; ?></p></form></article>
<aside class="card"><h2>Kalenderfeed</h2><p>Nur bestätigte Termine, ohne Teilnehmendendaten:</p><p><a href="<?=h(bookingBaseUrl().'/booking/ical.php?project='.$project.'&token='.$feedToken)?>">ICS-Feed öffnen</a></p></aside></section> <aside class="card"><h2>Kalenderfeed</h2><p>Nur bestätigte Termine, ohne Teilnehmendendaten:</p><p><a href="<?=h(bookingBaseUrl().'/booking/ical.php?project='.$project.'&token='.$feedToken)?>">ICS-Feed öffnen</a></p></aside></section>
<section class="card"><h2>Termine</h2><table><thead><tr><th>Termin</th><th>Zeit</th><th>Kapazität</th><th>Status</th><th></th></tr></thead><tbody><?php foreach ($events as $event): ?><tr><td><strong><?=h($event['title'])?></strong><br><small><?=h($event['format'])?><?=!empty($event['introduction_required']) ? ' · Einführung erforderlich' : ''?></small></td><td><?=h($event['starts_at'])?><br>bis <?=h($event['ends_at'])?></td><td><?=h($event['confirmed_count'])?> / <?=h($event['capacity'])?> bestätigt</td><td><?= $event['is_active'] ? 'Aktiv' : 'Inaktiv' ?></td><td><a class="button" href="?project=<?=h($project)?>&view=events&edit=<?=h($event['id'])?>">Bearbeiten</a> <form class="inline" method="post"><input type="hidden" name="csrf" value="<?=h($csrf)?>"><input type="hidden" name="project" value="<?=h($project)?>"><input type="hidden" name="action" value="toggle_event"><input type="hidden" name="id" value="<?=h($event['id'])?>"><button><?= $event['is_active'] ? 'Deaktivieren' : 'Aktivieren' ?></button></form></td></tr><?php endforeach; ?></tbody></table></section> <section class="card"><h2>Termine</h2><table><thead><tr><th>Termin</th><th>Zeit</th><th>Kapazität</th><th>Status</th><th></th></tr></thead><tbody><?php foreach ($events as $event): ?><tr><td><strong><?=h($event['title'])?></strong><br><small><?=h($event['format'])?><?=!empty($event['introduction_required']) ? ' · Einführung erforderlich' : ''?></small></td><td><?=h($event['starts_at'])?><br>bis <?=h($event['ends_at'])?></td><td><?=h($event['confirmed_count'])?> / <?=h($event['capacity'])?> bestätigt</td><td><?= $event['is_active'] ? 'Aktiv' : 'Inaktiv' ?></td><td><a class="button" href="?project=<?=h($project)?>&view=events&edit=<?=h($event['id'])?>">Bearbeiten</a> <form class="inline" method="post"><input type="hidden" name="csrf" value="<?=h($csrf)?>"><input type="hidden" name="project" value="<?=h($project)?>"><input type="hidden" name="action" value="toggle_event"><input type="hidden" name="id" value="<?=h($event['id'])?>"><button><?= $event['is_active'] ? 'Deaktivieren' : 'Aktivieren' ?></button></form></td></tr><?php endforeach; ?></tbody></table></section>
<section class="card"><h2>Lokaler Mailausgang</h2><?php foreach ($outbox as $mail): ?><article><strong><?=h($mail['kind'])?></strong> · <?=h($mail['recipient_email'])?><br><small><?=h($mail['subject'])?></small><pre><?=h($mail['body'])?></pre></article><?php endforeach; ?></section>
<?php elseif ($view === 'registrations'): ?> <?php elseif ($view === 'registrations'): ?>
<section class="card"><h2>Teilnahmen · <?=h(bookingProjectTitle($project))?></h2><table><thead><tr><th>Person</th><th>Termin</th><th>Status</th><th></th></tr></thead><tbody><?php foreach ($registrations as $registration): ?><tr><td><?=h($registration['name'])?><br><small><?=h($registration['email'])?><br><?=h($registration['phone'])?></small></td><td><?=h($registration['title'])?><br><small><?=h($registration['starts_at'])?></small></td><td class="status <?=h($registration['status'])?>"><?=h($registration['status'])?></td><td><?php if (in_array($registration['status'], ['pending_confirmation','confirmed'], true)): ?><form method="post"><input type="hidden" name="csrf" value="<?=h($csrf)?>"><input type="hidden" name="project" value="<?=h($project)?>"><input type="hidden" name="action" value="remove_registration"><input type="hidden" name="id" value="<?=h($registration['id'])?>"><button>Entfernen</button></form><?php endif; ?></td></tr><?php endforeach; ?></tbody></table></section> <section class="card"><h2>Teilnahmen · <?=h(bookingProjectTitle($project))?></h2><table><thead><tr><th>Person</th><th>Termin</th><th>Status</th><th></th></tr></thead><tbody><?php foreach ($registrations as $registration): ?><tr><td><?=h($registration['name'])?><br><small><?=h($registration['email'])?><br><?=h($registration['phone'])?></small></td><td><?=h($registration['title'])?><br><small><?=h($registration['starts_at'])?></small></td><td class="status <?=h($registration['status'])?>"><?=h($registration['status'])?></td><td><?php if (in_array($registration['status'], ['pending_confirmation','confirmed'], true)): ?><form method="post"><input type="hidden" name="csrf" value="<?=h($csrf)?>"><input type="hidden" name="project" value="<?=h($project)?>"><input type="hidden" name="action" value="remove_registration"><input type="hidden" name="id" value="<?=h($registration['id'])?>"><button>Entfernen</button></form><?php endif; ?></td></tr><?php endforeach; ?></tbody></table></section>
<?php elseif ($view === 'export'): ?> <?php elseif ($view === 'export'): ?>
@@ -140,7 +130,7 @@ select[data-dropdown-indicator]{appearance:none;padding-right:2.5rem;background:
</div><div id="export-results" class="export-results" role="status" aria-live="polite">Lade Teilnahmen …</div></section> </div><div id="export-results" class="export-results" role="status" aria-live="polite">Lade Teilnahmen …</div></section>
<?php endif; ?> <?php endif; ?>
</main> </main>
<dialog class="picker-dialog" id="date-time-dialog" aria-labelledby="date-time-title"><div class="picker-content"><div class="picker-head"><button type="button" data-picker-prev aria-label="Vorheriger Monat">‹</button><strong class="picker-month" id="date-time-title"></strong><button type="button" data-picker-next aria-label="Nächster Monat">›</button></div><div class="picker-week" aria-hidden="true"><span>Mo</span><span>Di</span><span>Mi</span><span>Do</span><span>Fr</span><span>Sa</span><span>So</span></div><div class="picker-days" data-picker-days></div><div class="picker-time" data-picker-time-controls><label>Stunde<select data-picker-hour></select></label><label>Viertelstunde<select data-picker-quarter></select></label></div><div class="picker-actions"><button type="button" data-picker-close>Schließen</button><button type="button" class="primary" data-picker-apply>Übernehmen</button></div></div></dialog> <dialog class="picker-dialog" id="date-time-dialog" aria-labelledby="date-time-title"><div class="picker-content"><div class="picker-head"><button type="button" data-picker-prev aria-label="Vorheriger Monat">‹</button><strong class="picker-month" id="date-time-title"></strong><button type="button" data-picker-next aria-label="Nächster Monat">›</button></div><div class="picker-week" aria-hidden="true"><span>Mo</span><span>Di</span><span>Mi</span><span>Do</span><span>Fr</span><span>Sa</span><span>So</span></div><div class="picker-days" data-picker-days></div><?php if ($view === 'events'): ?><div class="picker-time" data-picker-time-controls><label><select data-picker-hour></select></label><label><select data-picker-quarter></select></label></div><?php endif; ?><div class="picker-actions"><button type="button" data-picker-close>Schließen</button><button type="button" class="primary" data-picker-apply>Übernehmen</button></div></div></dialog>
<script> <script>
const contactByName = <?=json_encode($contactByName, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)?>; const contactByName = <?=json_encode($contactByName, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)?>;
const contactByEmail = <?=json_encode($contactByEmail, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)?>; const contactByEmail = <?=json_encode($contactByEmail, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)?>;
@@ -148,11 +138,9 @@ const contactName = document.querySelector('#contact_name'), contactEmail = docu
contactName?.addEventListener('change', () => { const contact=contactByName[contactName.value]; if (contact) { contactEmail.value=contact.email; contactPhone.value=contact.phone; } }); contactName?.addEventListener('change', () => { const contact=contactByName[contactName.value]; if (contact) { contactEmail.value=contact.email; contactPhone.value=contact.phone; } });
contactEmail?.addEventListener('change', () => { const contact=contactByEmail[contactEmail.value]; if (contact) { contactName.value=contact.name; contactPhone.value=contact.phone; } }); contactEmail?.addEventListener('change', () => { const contact=contactByEmail[contactEmail.value]; if (contact) { contactName.value=contact.name; contactPhone.value=contact.phone; } });
const dialog = document.querySelector('#date-time-dialog'), days = dialog.querySelector('[data-picker-days]'), monthLabel = dialog.querySelector('.picker-month'), timeControls = dialog.querySelector('[data-picker-time-controls]'), hourSelect = dialog.querySelector('[data-picker-hour]'), quarterSelect = dialog.querySelector('[data-picker-quarter]'); const dialog = document.querySelector('#date-time-dialog'), days = dialog.querySelector('[data-picker-days]'), monthLabel = dialog.querySelector('.picker-month'), timeControls = dialog.querySelector('[data-picker-time-controls]'), hourSelect = dialog.querySelector('[data-picker-hour]'), quarterSelect = dialog.querySelector('[data-picker-quarter]');
const hours = Array.from({length:24}, (_,hour) => String(hour).padStart(2,'0')); if (timeControls) { const hours = Array.from({length:24}, (_,hour) => String(hour).padStart(2,'0')); hourSelect.innerHTML = hours.map(hour => '<option value="' + hour + '">' + hour + ' Uhr</option>').join(''); quarterSelect.innerHTML = ['00','15','30','45'].map(minute => '<option value="' + minute + '">' + minute + ' Min.</option>').join(''); }
hourSelect.innerHTML = hours.map(hour => '<option value="' + hour + '">' + hour + ' Uhr</option>').join('');
quarterSelect.innerHTML = ['00','15','30','45'].map(minute => '<option value="' + minute + '">' + minute + ' Min.</option>').join('');
let pickerTarget, pickerDate, pickerMonth, pickerMode; let pickerTarget, pickerDate, pickerMonth, pickerMode;
const asDate = value => { const parts = String(value || '').match(/^(\\d{4})-(\\d{2})-(\\d{2})(?:[ T](\\d{2}):(\\d{2}))?/); return parts ? new Date(Number(parts[1]), Number(parts[2])-1, Number(parts[3]), Number(parts[4] || 0), Number(parts[5] || 0)) : new Date(); }; const asDate = value => { const parts = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}))?/); if (parts) return new Date(Number(parts[1]), Number(parts[2])-1, Number(parts[3]), Number(parts[4] || 0), Number(parts[5] || 0)); const defaultDate = new Date(); defaultDate.setHours(9,0,0,0); return defaultDate; };
const formatDateValue = date => String(date.getFullYear())+'-'+String(date.getMonth()+1).padStart(2,'0')+'-'+String(date.getDate()).padStart(2,'0'); const formatDateValue = date => String(date.getFullYear())+'-'+String(date.getMonth()+1).padStart(2,'0')+'-'+String(date.getDate()).padStart(2,'0');
const formatValue = date => formatDateValue(date)+' '+String(date.getHours()).padStart(2,'0')+':'+String(date.getMinutes()).padStart(2,'0')+':00'; const formatValue = date => formatDateValue(date)+' '+String(date.getHours()).padStart(2,'0')+':'+String(date.getMinutes()).padStart(2,'0')+':00';
const formatLabel = (value, includeTime) => value ? new Intl.DateTimeFormat('de-DE', includeTime ? {dateStyle:'medium',timeStyle:'short'} : {dateStyle:'medium'}).format(asDate(value)) : (includeTime ? 'Datum & Uhrzeit wählen' : 'Datum wählen'); const formatLabel = (value, includeTime) => value ? new Intl.DateTimeFormat('de-DE', includeTime ? {dateStyle:'medium',timeStyle:'short'} : {dateStyle:'medium'}).format(asDate(value)) : (includeTime ? 'Datum & Uhrzeit wählen' : 'Datum wählen');
@@ -164,11 +152,11 @@ function renderPicker() {
days.innerHTML = Array.from({length:offset+last}, (_,index) => { if(index<offset) return '<button type="button" disabled></button>'; const day=index-offset+1, selected=pickerDate.getFullYear()===pickerMonth.getFullYear()&&pickerDate.getMonth()===pickerMonth.getMonth()&&pickerDate.getDate()===day; return '<button type="button" data-picker-day="'+day+'" class="'+(selected?'selected':'')+'" aria-pressed="'+selected+'">'+day+'</button>'; }).join(''); days.innerHTML = Array.from({length:offset+last}, (_,index) => { if(index<offset) return '<button type="button" disabled></button>'; const day=index-offset+1, selected=pickerDate.getFullYear()===pickerMonth.getFullYear()&&pickerDate.getMonth()===pickerMonth.getMonth()&&pickerDate.getDate()===day; return '<button type="button" data-picker-day="'+day+'" class="'+(selected?'selected':'')+'" aria-pressed="'+selected+'">'+day+'</button>'; }).join('');
days.querySelectorAll('[data-picker-day]').forEach(button => button.addEventListener('click', () => { pickerDate = new Date(pickerMonth.getFullYear(),pickerMonth.getMonth(),Number(button.dataset.pickerDay),pickerDate.getHours(),pickerDate.getMinutes()); renderPicker(); })); days.querySelectorAll('[data-picker-day]').forEach(button => button.addEventListener('click', () => { pickerDate = new Date(pickerMonth.getFullYear(),pickerMonth.getMonth(),Number(button.dataset.pickerDay),pickerDate.getHours(),pickerDate.getMinutes()); renderPicker(); }));
} }
document.querySelectorAll('[data-datetime-picker],[data-date-picker]').forEach(button => button.addEventListener('click', () => { pickerTarget=getTarget(button.dataset.target); pickerMode=button.hasAttribute('data-date-picker') ? 'date' : 'datetime'; pickerDate=asDate(pickerTarget.value); pickerMonth=new Date(pickerDate.getFullYear(),pickerDate.getMonth(),1); timeControls.hidden=pickerMode === 'date'; hourSelect.value=String(pickerDate.getHours()).padStart(2,'0'); quarterSelect.value=String(Math.floor(pickerDate.getMinutes()/15)*15).padStart(2,'0'); renderPicker(); dialog.showModal(); })); document.querySelectorAll('[data-datetime-picker],[data-date-picker]').forEach(button => button.addEventListener('click', () => { pickerTarget=getTarget(button.dataset.target); pickerMode=button.hasAttribute('data-date-picker') ? 'date' : 'datetime'; pickerDate=asDate(pickerTarget.value); pickerMonth=new Date(pickerDate.getFullYear(),pickerDate.getMonth(),1); if (timeControls) { hourSelect.value=String(pickerDate.getHours()).padStart(2,'0'); quarterSelect.value=String(Math.floor(pickerDate.getMinutes()/15)*15).padStart(2,'0'); } renderPicker(); dialog.showModal(); }));
dialog.querySelector('[data-picker-prev]').addEventListener('click', () => { pickerMonth=new Date(pickerMonth.getFullYear(),pickerMonth.getMonth()-1,1); renderPicker(); }); dialog.querySelector('[data-picker-prev]').addEventListener('click', () => { pickerMonth=new Date(pickerMonth.getFullYear(),pickerMonth.getMonth()-1,1); renderPicker(); });
dialog.querySelector('[data-picker-next]').addEventListener('click', () => { pickerMonth=new Date(pickerMonth.getFullYear(),pickerMonth.getMonth()+1,1); renderPicker(); }); dialog.querySelector('[data-picker-next]').addEventListener('click', () => { pickerMonth=new Date(pickerMonth.getFullYear(),pickerMonth.getMonth()+1,1); renderPicker(); });
dialog.querySelector('[data-picker-close]').addEventListener('click', () => dialog.close()); dialog.querySelector('[data-picker-close]').addEventListener('click', () => dialog.close());
dialog.querySelector('[data-picker-apply]').addEventListener('click', () => { if (pickerMode === 'datetime') pickerDate.setHours(Number(hourSelect.value),Number(quarterSelect.value),0,0); pickerTarget.value=pickerMode === 'date' ? formatDateValue(pickerDate) : formatValue(pickerDate); syncPickerLabels(); pickerTarget.dispatchEvent(new Event('booking-datetime-change')); dialog.close(); }); dialog.querySelector('[data-picker-apply]').addEventListener('click', () => { if (pickerMode === 'datetime' && timeControls) pickerDate.setHours(Number(hourSelect.value),Number(quarterSelect.value),0,0); pickerTarget.value=pickerMode === 'date' ? formatDateValue(pickerDate) : formatValue(pickerDate); syncPickerLabels(); pickerTarget.dispatchEvent(new Event('booking-datetime-change')); dialog.close(); });
syncPickerLabels(); syncPickerLabels();
const exportResults=document.querySelector('#export-results'); const exportResults=document.querySelector('#export-results');
const escapeHtml = value => String(value).replace(/[&<>"']/g, character => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[character])); const escapeHtml = value => String(value).replace(/[&<>"']/g, character => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[character]));
@@ -2,9 +2,25 @@
declare(strict_types=1); declare(strict_types=1);
function bookingDatabaseUrl(?string $environmentUrl = null, ?string $configurationFile = null): string
{
$url = $environmentUrl ?? getenv('DATABASE_URL');
if (!is_string($url) || trim($url) === '') {
$configurationFile ??= dirname(__DIR__, 2).'/booking-config.php';
if (is_file($configurationFile)) {
$configuration = require $configurationFile;
$url = is_array($configuration) ? ($configuration['database_url'] ?? '') : '';
}
}
if (!is_string($url) || trim($url) === '') {
throw new RuntimeException('DATABASE_URL is not configured and the booking configuration file does not contain a database URL.');
}
return trim($url);
}
function bookingDatabase(): PDO function bookingDatabase(): PDO
{ {
$url = getenv('DATABASE_URL'); $url = bookingDatabaseUrl();
$parts = is_string($url) ? parse_url($url) : false; $parts = is_string($url) ? parse_url($url) : false;
if (!is_array($parts) || ($parts['scheme'] ?? '') !== 'mysql' || !isset($parts['host'], $parts['path'], $parts['user'])) { if (!is_array($parts) || ($parts['scheme'] ?? '') !== 'mysql' || !isset($parts['host'], $parts['path'], $parts['user'])) {
throw new RuntimeException('DATABASE_URL is not configured correctly.'); throw new RuntimeException('DATABASE_URL is not configured correctly.');
@@ -39,10 +55,59 @@ function bookingProjectTitle(string $project): string
return $project === 'produktionsschule' ? 'Produktionsschule' : 'PerspektivWerk'; return $project === 'produktionsschule' ? 'Produktionsschule' : 'PerspektivWerk';
} }
function bookingQueueMail(PDO $database, string $registrationId, string $kind, string $email, string $subject, string $body): void function bookingMailTransport(?string $configuredTransport = null): string
{ {
$statement = $database->prepare('INSERT IGNORE INTO sufw_booking_outbox (registration_id, kind, recipient_email, subject, body) VALUES (:registrationId, :kind, :email, :subject, :body)'); $transport = $configuredTransport ?? getenv('BOOKING_MAIL_TRANSPORT');
$statement->execute(compact('registrationId', 'kind', 'email', 'subject', 'body')); if (!is_string($transport) || $transport === '') return 'sendmail';
return $transport;
}
function bookingIcsCalendar(array $event): string
{
$timezone = new DateTimeZone('Europe/Berlin');
$utc = new DateTimeZone('UTC');
$start = new DateTimeImmutable((string) $event['starts_at'], $timezone);
$end = new DateTimeImmutable((string) $event['ends_at'], $timezone);
$lines = [
'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//SUFW//Terminbuchung//DE', 'CALSCALE:GREGORIAN',
'BEGIN:VEVENT',
'UID:'.bookingIcsEscape((string) $event['id']).'@sufw-booking',
'DTSTAMP:'.gmdate('Ymd\\THis\\Z'),
'DTSTART:'.$start->setTimezone($utc)->format('Ymd\\THis\\Z'),
'DTEND:'.$end->setTimezone($utc)->format('Ymd\\THis\\Z'),
'SUMMARY:'.bookingIcsEscape((string) $event['title']),
'DESCRIPTION:'.bookingIcsEscape(($event['format'] ?? '') === 'telefonisch' ? 'Telefonischer Termin' : 'Termin vor Ort'),
'END:VEVENT', 'END:VCALENDAR',
];
return implode("\r\n", $lines)."\r\n";
}
function bookingSendMail(string $email, string $subject, string $textBody, ?string $calendar = null): void
{
$transport = bookingMailTransport();
if ($transport === 'disabled') return;
if ($transport !== 'sendmail') throw new RuntimeException('Unsupported booking mail transport.');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) throw new InvalidArgumentException('Invalid mail recipient.');
$sender = 'sufw-buchung@boogiedev.net';
$headers = ['From' => $sender, 'Reply-To' => $sender, 'MIME-Version' => '1.0'];
if ($calendar === null) {
$headers['Content-Type'] = 'text/plain; charset=UTF-8';
$message = $textBody;
} else {
$boundary = '=_sufw_'.bin2hex(random_bytes(16));
$headers['Content-Type'] = 'multipart/mixed; boundary="'.$boundary.'"';
$message = '--'.$boundary."\r\n"
."Content-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n"
.$textBody."\r\n--".$boundary."\r\n"
."Content-Type: text/calendar; charset=UTF-8; method=PUBLISH; name=\"termin.ics\"\r\n"
."Content-Transfer-Encoding: base64\r\nContent-Disposition: attachment; filename=\"termin.ics\"\r\n\r\n"
.chunk_split(base64_encode($calendar), 76, "\r\n")
.'--'.$boundary."--\r\n";
}
if (!mail($email, $subject, $message, $headers, '-f'.$sender)) {
throw new RuntimeException('The confirmation email could not be handed to sendmail.');
}
} }
function bookingNormaliseEvent(array $event): array function bookingNormaliseEvent(array $event): array
@@ -116,8 +181,8 @@ function bookingCreateRegistration(PDO $database, array $payload): array
$link = bookingBaseUrl().'/booking/confirm.php?token='.rawurlencode($token); $link = bookingBaseUrl().'/booking/confirm.php?token='.rawurlencode($token);
$subject = 'Bitte bestätigen Sie Ihre Terminanfrage'; $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']})."; $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(); $database->commit();
bookingSendMail($email, $subject, $body);
return ['registrationId' => $registrationId, 'expiresAt' => gmdate('c', time() + 1800)]; return ['registrationId' => $registrationId, 'expiresAt' => gmdate('c', time() + 1800)];
} catch (Throwable $exception) { } catch (Throwable $exception) {
if ($database->inTransaction()) $database->rollBack(); if ($database->inTransaction()) $database->rollBack();
@@ -131,7 +196,7 @@ function bookingConfirmRegistration(PDO $database, string $token): array
$database->beginTransaction(); $database->beginTransaction();
try { try {
$statement = $database->prepare(<<<'SQL' $statement = $database->prepare(<<<'SQL'
SELECT r.*, e.title, e.project, e.starts_at, e.ends_at, e.capacity, e.contact_name, e.contact_email SELECT r.*, e.title, e.project, e.starts_at, e.ends_at, e.capacity, e.format, e.contact_name, e.contact_email
FROM sufw_booking_registrations r JOIN sufw_booking_events e ON e.id = r.event_id FROM sufw_booking_registrations r JOIN sufw_booking_events e ON e.id = r.event_id
WHERE r.confirmation_token_hash = :tokenHash FOR UPDATE WHERE r.confirmation_token_hash = :tokenHash FOR UPDATE
SQL); SQL);
@@ -154,8 +219,8 @@ function bookingConfirmRegistration(PDO $database, string $token): array
$history->execute(['email' => $registration['email'], 'name' => $registration['name']]); $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())); $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']})."; $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(); $database->commit();
bookingSendMail($registration['email'], 'Ihr Termin ist bestätigt', $body, bookingIcsCalendar($registration));
return $registration; return $registration;
} catch (Throwable $exception) { } catch (Throwable $exception) {
if ($database->inTransaction()) $database->rollBack(); if ($database->inTransaction()) $database->rollBack();
@@ -163,21 +228,6 @@ function bookingConfirmRegistration(PDO $database, string $token): array
} }
} }
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 function bookingIcsEscape(string $value): string
{ {
return str_replace(["\\", ";", ",", "\n"], ["\\\\", "\\;", "\\,", "\\n"], $value); return str_replace(["\\", ";", ",", "\n"], ["\\\\", "\\;", "\\,", "\\n"], $value);
@@ -89,7 +89,7 @@ function openConfirmationDialog(container, event) {
const dialog = document.createElement('dialog'); const dialog = document.createElement('dialog');
dialog.className = 'sufw-booking__dialog'; dialog.className = 'sufw-booking__dialog';
dialog.setAttribute('aria-labelledby', 'sufw-booking-confirmation-title'); dialog.setAttribute('aria-labelledby', 'sufw-booking-confirmation-title');
dialog.innerHTML = `<section class="sufw-booking__form"><header class="sufw-booking__form-header"><p class="sufw-booking__eyebrow">E-Mail bestätigen</p><h2 id="sufw-booking-confirmation-title">Fast geschafft</h2><p>${escapeHtml(bookingConfirmationMessage(event.title))}</p></header><p class="sufw-booking__appointment"><strong>${escapeHtml(event.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule')}</strong><br>${escapeHtml(dateFormatter.format(parseDate(event.startsAt.slice(0, 10))))} · ${formatEventTime(event)} · ${escapeHtml(event.title)}</p><p>In der lokalen Demo liegt die Nachricht im Mailausgang des Verwaltungsbereichs bereit.</p><div class="sufw-booking__form-actions"><button type="button" data-close-confirmation>Schließen</button></div></section>`; dialog.innerHTML = `<section class="sufw-booking__form"><header class="sufw-booking__form-header"><p class="sufw-booking__eyebrow">E-Mail bestätigen</p><h2 id="sufw-booking-confirmation-title">Fast geschafft</h2><p>${escapeHtml(bookingConfirmationMessage(event.title))}</p></header><p class="sufw-booking__appointment"><strong>${escapeHtml(event.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule')}</strong><br>${escapeHtml(dateFormatter.format(parseDate(event.startsAt.slice(0, 10))))} · ${formatEventTime(event)} · ${escapeHtml(event.title)}</p><p>Bitte prüfen Sie Ihr E-Mail-Postfach und bestätigen Sie dort Ihre Anfrage.</p><div class="sufw-booking__form-actions"><button type="button" data-close-confirmation>Schließen</button></div></section>`;
dialog.querySelector('[data-close-confirmation]').addEventListener('click', () => dialog.close()); dialog.querySelector('[data-close-confirmation]').addEventListener('click', () => dialog.close());
dialog.addEventListener('close', () => dialog.remove()); dialog.addEventListener('close', () => dialog.remove());
container.append(dialog); container.append(dialog);
@@ -153,7 +153,7 @@ function openBookingDialog(container, event, state) {
function renderComponent(container) { function renderComponent(container) {
const project = container.dataset.project === 'produktionsschule' ? 'produktionsschule' : 'perspektivwerk'; const project = container.dataset.project === 'produktionsschule' ? 'produktionsschule' : 'perspektivwerk';
const now = new Date(); const now = new Date();
const state = { apiUrl: container.dataset.apiUrl || '/booking/api.php', csrfToken: '', events: [], currentMonth: new Date(now.getFullYear(), now.getMonth(), 1), selectedDate: '' }; const state = { apiUrl: container.dataset.apiUrl || 'booking/api.php', csrfToken: '', events: [], currentMonth: new Date(now.getFullYear(), now.getMonth(), 1), selectedDate: '' };
const fetchEvents = async () => { const fetchEvents = async () => {
const from = formatDateKey(state.currentMonth); const from = formatDateKey(state.currentMonth);
const to = formatDateKey(new Date(state.currentMonth.getFullYear(), state.currentMonth.getMonth() + 1, 1)); const to = formatDateKey(new Date(state.currentMonth.getFullYear(), state.currentMonth.getMonth() + 1, 1));
@@ -13,7 +13,7 @@ function page(string $title, string $message, bool $success = false): never
$safeTitle = htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $safeTitle = htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$safeMessage = htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $safeMessage = htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$class = $success ? 'success' : 'error'; $class = $success ? 'success' : 'error';
echo "<!doctype html><html lang=\"de\"><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>$safeTitle</title><style>body{font:18px/1.5 Arial,sans-serif;margin:0;background:#f3f6f3;color:#202020}.box{max-width:42rem;margin:12vh auto;padding:2rem;background:#fff;border-top:6px solid #08752a}.success{color:#08752a}.error{color:#8a1515}a{color:#08752a}</style><main class=\"box\"><h1 class=\"$class\">$safeTitle</h1><p>$safeMessage</p><p><a href=\"/perspektivwerk/\">Zurück zur Terminbuchung</a></p></main></html>"; echo "<!doctype html><html lang=\"de\"><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>$safeTitle</title><style>body{font:18px/1.5 Arial,sans-serif;margin:0;background:#f3f6f3;color:#202020}.box{max-width:42rem;margin:12vh auto;padding:2rem;background:#fff;border-top:6px solid #08752a}.success{color:#08752a}.error{color:#8a1515}a{color:#08752a}</style><main class=\"box\"><h1 class=\"$class\">$safeTitle</h1><p>$safeMessage</p><p><a href=\"../\">Zurück zur Terminbuchung</a></p></main></html>";
exit; exit;
} }
@@ -21,7 +21,10 @@
<section class="team-section"><div class="team-copy"><img src="media/wir-sind-fuer-sie-da.svg" alt="Wir sind für Sie da"><p>Unser Team, bestehend aus sozialpädagogischen, pädagogischen und psychologischen Mitarbeiterinnen und Mitarbeitern, <strong>unterstützt Ihren Prozess der beruflichen Orientierung.</strong> Wir arbeiten eng mit den Bearbeiterinnen und Beratern der Agentur für Arbeit, dem Jobcenter und weiteren Beratungsstellen zusammen.</p><p>Die Teilnahme am <strong>PerspektivWerk ist kostenlos.</strong> Sie bestimmen, an welchen Angeboten Sie teilnehmen.</p></div><div class="team-image"><img src="media/perspektivwerk-psych.jpg" alt="Beratungsgespräch im PerspektivWerk"></div></section> <section class="team-section"><div class="team-copy"><img src="media/wir-sind-fuer-sie-da.svg" alt="Wir sind für Sie da"><p>Unser Team, bestehend aus sozialpädagogischen, pädagogischen und psychologischen Mitarbeiterinnen und Mitarbeitern, <strong>unterstützt Ihren Prozess der beruflichen Orientierung.</strong> Wir arbeiten eng mit den Bearbeiterinnen und Beratern der Agentur für Arbeit, dem Jobcenter und weiteren Beratungsstellen zusammen.</p><p>Die Teilnahme am <strong>PerspektivWerk ist kostenlos.</strong> Sie bestimmen, an welchen Angeboten Sie teilnehmen.</p></div><div class="team-image"><img src="media/perspektivwerk-psych.jpg" alt="Beratungsgespräch im PerspektivWerk"></div></section>
<section class="content page-width" id="projekte"><div class="content-grid"><article><h2>Wer kann zu uns kommen?</h2><p>Menschen zwischen 15 und 65 Jahren, die sich beruflich neu orientieren möchten, beim Einstieg Unterstützung suchen oder ihre Möglichkeiten gemeinsam klären wollen.</p></article><article><h2>1. Orientierung &amp; Ersteinschätzung</h2><p>Sozialpädagogische und psychologische Module helfen dabei, persönliche Situation, Motivation, Belastbarkeit und Stärken einzuschätzen.</p></article><article><h2>2. Praktische Erprobung &amp; Belastungstest</h2><p>Arbeitsproben und praktische Erprobungen machen fachliche, soziale und arbeitsbezogene Kompetenzen sichtbar.</p></article><article><h2>3. Auswertung &amp; Perspektiventwicklung</h2><p>Wir führen die Ergebnisse zusammen und entwickeln konkrete, realistische nächste Schritte.</p></article></div></section> <section class="content page-width" id="projekte"><div class="content-grid"><article><h2>Wer kann zu uns kommen?</h2><p>Menschen zwischen 15 und 65 Jahren, die sich beruflich neu orientieren möchten, beim Einstieg Unterstützung suchen oder ihre Möglichkeiten gemeinsam klären wollen.</p></article><article><h2>1. Orientierung &amp; Ersteinschätzung</h2><p>Sozialpädagogische und psychologische Module helfen dabei, persönliche Situation, Motivation, Belastbarkeit und Stärken einzuschätzen.</p></article><article><h2>2. Praktische Erprobung &amp; Belastungstest</h2><p>Arbeitsproben und praktische Erprobungen machen fachliche, soziale und arbeitsbezogene Kompetenzen sichtbar.</p></article><article><h2>3. Auswertung &amp; Perspektiventwicklung</h2><p>Wir führen die Ergebnisse zusammen und entwickeln konkrete, realistische nächste Schritte.</p></article></div></section>
<section class="page-width" aria-label="Terminbuchung PerspektivWerk"> <section class="page-width" aria-label="Terminbuchung PerspektivWerk">
<div data-sufw-booking data-project="perspektivwerk" data-api-url="/booking/api.php"></div> <div data-sufw-booking data-project="perspektivwerk" data-api-url="booking/api.php"></div>
</section>
<section class="page-width" aria-label="Terminbuchung Produktionsschule">
<div data-sufw-booking data-project="produktionsschule" data-api-url="booking/api.php"></div>
</section> </section>
<section class="contact page-width" id="kontakt"><h2>Sie haben Fragen zu dieser Maßnahme?</h2><p>Haben Sie Interesse an unserem Angebot, melden Sie sich bei uns. Wir beraten Sie gern vorab zu den weiteren Schritten.</p><p><strong>Kontakt:</strong> <a href="tel:+4917660328929">(0176) 60328929</a> · <a href="mailto:perspektivwerk@sufw.de">perspektivwerk@sufw.de</a> · Dresdner Straße 16, 01326 Dresden</p></section> <section class="contact page-width" id="kontakt"><h2>Sie haben Fragen zu dieser Maßnahme?</h2><p>Haben Sie Interesse an unserem Angebot, melden Sie sich bei uns. Wir beraten Sie gern vorab zu den weiteren Schritten.</p><p><strong>Kontakt:</strong> <a href="tel:+4917660328929">(0176) 60328929</a> · <a href="mailto:perspektivwerk@sufw.de">perspektivwerk@sufw.de</a> · Dresdner Straße 16, 01326 Dresden</p></section>
</main> </main>
@@ -89,7 +89,7 @@ function openConfirmationDialog(container, event) {
const dialog = document.createElement('dialog'); const dialog = document.createElement('dialog');
dialog.className = 'sufw-booking__dialog'; dialog.className = 'sufw-booking__dialog';
dialog.setAttribute('aria-labelledby', 'sufw-booking-confirmation-title'); dialog.setAttribute('aria-labelledby', 'sufw-booking-confirmation-title');
dialog.innerHTML = `<section class="sufw-booking__form"><header class="sufw-booking__form-header"><p class="sufw-booking__eyebrow">E-Mail bestätigen</p><h2 id="sufw-booking-confirmation-title">Fast geschafft</h2><p>${escapeHtml(bookingConfirmationMessage(event.title))}</p></header><p class="sufw-booking__appointment"><strong>${escapeHtml(event.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule')}</strong><br>${escapeHtml(dateFormatter.format(parseDate(event.startsAt.slice(0, 10))))} · ${formatEventTime(event)} · ${escapeHtml(event.title)}</p><p>In der lokalen Demo liegt die Nachricht im Mailausgang des Verwaltungsbereichs bereit.</p><div class="sufw-booking__form-actions"><button type="button" data-close-confirmation>Schließen</button></div></section>`; dialog.innerHTML = `<section class="sufw-booking__form"><header class="sufw-booking__form-header"><p class="sufw-booking__eyebrow">E-Mail bestätigen</p><h2 id="sufw-booking-confirmation-title">Fast geschafft</h2><p>${escapeHtml(bookingConfirmationMessage(event.title))}</p></header><p class="sufw-booking__appointment"><strong>${escapeHtml(event.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule')}</strong><br>${escapeHtml(dateFormatter.format(parseDate(event.startsAt.slice(0, 10))))} · ${formatEventTime(event)} · ${escapeHtml(event.title)}</p><p>Bitte prüfen Sie Ihr E-Mail-Postfach und bestätigen Sie dort Ihre Anfrage.</p><div class="sufw-booking__form-actions"><button type="button" data-close-confirmation>Schließen</button></div></section>`;
dialog.querySelector('[data-close-confirmation]').addEventListener('click', () => dialog.close()); dialog.querySelector('[data-close-confirmation]').addEventListener('click', () => dialog.close());
dialog.addEventListener('close', () => dialog.remove()); dialog.addEventListener('close', () => dialog.remove());
container.append(dialog); container.append(dialog);
@@ -153,7 +153,7 @@ function openBookingDialog(container, event, state) {
function renderComponent(container) { function renderComponent(container) {
const project = container.dataset.project === 'produktionsschule' ? 'produktionsschule' : 'perspektivwerk'; const project = container.dataset.project === 'produktionsschule' ? 'produktionsschule' : 'perspektivwerk';
const now = new Date(); const now = new Date();
const state = { apiUrl: container.dataset.apiUrl || '/booking/api.php', csrfToken: '', events: [], currentMonth: new Date(now.getFullYear(), now.getMonth(), 1), selectedDate: '' }; const state = { apiUrl: container.dataset.apiUrl || 'booking/api.php', csrfToken: '', events: [], currentMonth: new Date(now.getFullYear(), now.getMonth(), 1), selectedDate: '' };
const fetchEvents = async () => { const fetchEvents = async () => {
const from = formatDateKey(state.currentMonth); const from = formatDateKey(state.currentMonth);
const to = formatDateKey(new Date(state.currentMonth.getFullYear(), state.currentMonth.getMonth() + 1, 1)); const to = formatDateKey(new Date(state.currentMonth.getFullYear(), state.currentMonth.getMonth() + 1, 1));
@@ -0,0 +1,29 @@
@echo off
setlocal
set "SOURCE=%~dp0..\public\perspektivwerk"
set "TARGET=boogie:html/perspektivwerk/"
set "CONFIG=%~dp0..\public\booking-config.php"
if not exist "%CONFIG%" (
echo Konfiguration fehlt: %CONFIG%
exit /b 1
)
echo Lade den Perspektivwerk-DocumentRoot und die Konfiguration nach %TARGET% hoch ...
scp -r "%SOURCE%" "%CONFIG%" "%TARGET%"
if errorlevel 1 (
echo Upload fehlgeschlagen.
exit /b 1
)
echo Setze Leseberechtigungen auf %TARGET% ...
ssh boogie "chmod -R a+rX html/perspektivwerk/"
if errorlevel 1 (
echo Berechtigungen konnten nicht gesetzt werden.
exit /b 1
)
echo Upload abgeschlossen.
@@ -1,45 +0,0 @@
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent $PSScriptRoot
$perspektivwerkSource = Join-Path $projectRoot 'public/perspektivwerk'
$remoteTarget = 'boogie:/home/boogie/html/perspektivwerk/'
if (-not (Test-Path -LiteralPath $perspektivwerkSource -PathType Container)) {
throw "Der zu übertragende Ordner '$perspektivwerkSource' wurde nicht gefunden."
}
$sourceItems = @(Get-ChildItem -LiteralPath $perspektivwerkSource -Force)
if ($sourceItems.Count -eq 0) {
throw "Der zu übertragende Ordner '$perspektivwerkSource' enthält keine Dateien."
}
if (-not (Get-Command scp -ErrorAction SilentlyContinue)) {
throw 'OpenSSH scp wurde nicht gefunden. Installiere den OpenSSH-Client oder füge ihn zum PATH hinzu.'
}
if (-not (Get-Command ssh -ErrorAction SilentlyContinue)) {
throw 'OpenSSH ssh wurde nicht gefunden. Installiere den OpenSSH-Client oder füge ihn zum PATH hinzu.'
}
Write-Host 'Zielverzeichnisse auf dem Server:'
Write-Host " $perspektivwerkSource\* -> $remoteTarget"
Write-Host "Übertrage ausschließlich die statische Perspektivwerk-Seite nach $remoteTarget"
Write-Host 'SCP-Upload und anschließende Rechtekorrektur benötigen jeweils eine SSH-Verbindung.'
& scp -r @sourceItems $remoteTarget
if ($LASTEXITCODE -ne 0) {
throw "Der Upload nach '$remoteTarget' ist fehlgeschlagen (scp Exit-Code $LASTEXITCODE)."
}
Write-Host 'Setze Lese- und Durchsuchrechte auf dem hochgeladenen Ordner.'
& ssh boogie 'chmod -R a+rX /home/boogie/html/perspektivwerk'
if ($LASTEXITCODE -ne 0) {
throw "Der Upload war erfolgreich, aber die Rechtekorrektur ist fehlgeschlagen (ssh Exit-Code $LASTEXITCODE)."
}
Write-Host 'Upload und Rechtekorrektur abgeschlossen: /home/boogie/html/perspektivwerk/ (inklusive js/booking.js).'
@@ -1,32 +1,16 @@
$projectRoot = Split-Path -Parent $PSScriptRoot $projectRoot = Split-Path -Parent $PSScriptRoot
$deployScriptPath = Join-Path $projectRoot 'scripts\Deploy-Perspektivwerk.ps1' $deployBatchPath = Join-Path $projectRoot 'scripts\Deploy-Perspektivwerk.bat'
$htaccessPath = Join-Path $projectRoot 'public\.htaccess'
Describe 'Perspektivwerk deployment' { Describe 'Perspektivwerk deployment' {
It 'provides a deployment script for the static page' { It 'deploys the complete Perspektivwerk document root and its sibling configuration file' {
(Test-Path -LiteralPath $deployScriptPath) | Should Be $true $script = Get-Content -LiteralPath $deployBatchPath -Raw
} $htaccess = Get-Content -LiteralPath $htaccessPath -Raw
It 'uploads the static page and its nested booking assets with one scp invocation' { $script | Should Match 'public\\booking-config\.php'
$script = Get-Content -LiteralPath $deployScriptPath -Raw $script | Should Match 'public\\perspektivwerk"'
$script | Should Match 'boogie:html/perspektivwerk/'
$script | Should Match 'public[\\/]perspektivwerk' $htaccess | Should Match '<Files "booking-config\.php">'
$script | Should Not Match 'public[\\/]booking' $htaccess | Should Match 'Require all denied'
$script | Should Match 'boogie:/home/boogie/html/perspektivwerk/'
([regex]::Matches($script, '& scp')).Count | Should Be 1
$script | Should Not Match '& rsync'
}
It 'repairs read and traversal permissions with a separate SSH invocation' {
$script = Get-Content -LiteralPath $deployScriptPath -Raw
([regex]::Matches($script, '& ssh')).Count | Should Be 1
$script | Should Match 'chmod -R a\+rX /home/boogie/html/perspektivwerk'
}
It 'prints the complete static-page target directory' {
$script = Get-Content -LiteralPath $deployScriptPath -Raw
$script | Should Match 'Zielverzeichnisse auf dem Server'
$script | Should Match 'boogie:/home/boogie/html/perspektivwerk/'
} }
} }
@@ -2,8 +2,8 @@ $projectRoot = Split-Path -Parent $PSScriptRoot
$mockupPath = Join-Path $projectRoot 'public\perspektivwerk\index.html' $mockupPath = Join-Path $projectRoot 'public\perspektivwerk\index.html'
$bookingScriptPath = Join-Path $projectRoot 'public\perspektivwerk\js\booking.js' $bookingScriptPath = Join-Path $projectRoot 'public\perspektivwerk\js\booking.js'
$bookingStylesPath = Join-Path $projectRoot 'public\perspektivwerk\js\booking.css' $bookingStylesPath = Join-Path $projectRoot 'public\perspektivwerk\js\booking.css'
$contaoBookingScriptPath = Join-Path $projectRoot 'public\booking\booking.js' $deploymentBookingScriptPath = Join-Path $projectRoot 'public\perspektivwerk\booking\booking.js'
$contaoBookingStylesPath = Join-Path $projectRoot 'public\booking\booking.css' $deploymentBookingStylesPath = Join-Path $projectRoot 'public\perspektivwerk\booking\booking.css'
$htaccessPath = Join-Path $projectRoot 'public\.htaccess' $htaccessPath = Join-Path $projectRoot 'public\.htaccess'
Describe 'Perspektivwerk static route' { Describe 'Perspektivwerk static route' {
@@ -17,26 +17,30 @@ Describe 'Perspektivwerk static route' {
(Test-Path -LiteralPath $bookingScriptPath) | Should Be $true (Test-Path -LiteralPath $bookingScriptPath) | Should Be $true
(Test-Path -LiteralPath $bookingStylesPath) | Should Be $true (Test-Path -LiteralPath $bookingStylesPath) | Should Be $true
$mockup | Should Match 'href="js/booking\.css"' $mockup | Should Match 'href="js/booking\.css"'
$mockup | Should Match 'data-sufw-booking' $mockup | Should Match 'data-sufw-booking data-project="perspektivwerk"'
$mockup | Should Match 'data-sufw-booking data-project="produktionsschule"'
$mockup | Should Match 'data-api-url="booking/api\.php"'
$mockup | Should Not Match 'data-api-url="/booking/api\.php"'
$mockup.IndexOf('data-project="perspektivwerk"') | Should BeLessThan $mockup.IndexOf('data-project="produktionsschule"')
$mockup | Should Match 'src="js/booking\.js"' $mockup | Should Match 'src="js/booking\.js"'
} }
It 'keeps the Contao and static-preview asset copies identical' { It 'keeps the deployed API assets and static-preview copies identical' {
(Test-Path -LiteralPath $contaoBookingScriptPath) | Should Be $true (Test-Path -LiteralPath $deploymentBookingScriptPath) | Should Be $true
(Test-Path -LiteralPath $contaoBookingStylesPath) | Should Be $true (Test-Path -LiteralPath $deploymentBookingStylesPath) | Should Be $true
(Get-FileHash -LiteralPath $bookingScriptPath).Hash | Should Be (Get-FileHash -LiteralPath $contaoBookingScriptPath).Hash (Get-FileHash -LiteralPath $bookingScriptPath).Hash | Should Be (Get-FileHash -LiteralPath $deploymentBookingScriptPath).Hash
(Get-FileHash -LiteralPath $bookingStylesPath).Hash | Should Be (Get-FileHash -LiteralPath $contaoBookingStylesPath).Hash (Get-FileHash -LiteralPath $bookingStylesPath).Hash | Should Be (Get-FileHash -LiteralPath $deploymentBookingStylesPath).Hash
} }
It 'keeps the enabled submit button visibly rendered' { It 'keeps the enabled submit button visibly rendered' {
$script = Get-Content -LiteralPath $contaoBookingScriptPath -Raw $script = Get-Content -LiteralPath $deploymentBookingScriptPath -Raw
$script | Should Match "submitButton\.style\.visibility = 'visible'" $script | Should Match "submitButton\.style\.visibility = 'visible'"
$script | Should Match "actions\.style\.position = 'sticky'" $script | Should Match "actions\.style\.position = 'sticky'"
} }
It 'defines colours for the booking form action buttons' { It 'defines colours for the booking form action buttons' {
$styles = Get-Content -LiteralPath $contaoBookingStylesPath -Raw $styles = Get-Content -LiteralPath $deploymentBookingStylesPath -Raw
$styles | Should Match '\.sufw-booking__form-actions button\s*\{[^}]*background-color:\s*#08752a' $styles | Should Match '\.sufw-booking__form-actions button\s*\{[^}]*background-color:\s*#08752a'
$styles | Should Match '\.sufw-booking__form-actions button\s*\{[^}]*color:\s*#fff' $styles | Should Match '\.sufw-booking__form-actions button\s*\{[^}]*color:\s*#fff'
@@ -45,9 +49,12 @@ Describe 'Perspektivwerk static route' {
It 'routes /perspektivwerk to the static entry point before the Contao fallback' { It 'routes /perspektivwerk to the static entry point before the Contao fallback' {
$htaccess = Get-Content -LiteralPath $htaccessPath -Raw $htaccess = Get-Content -LiteralPath $htaccessPath -Raw
$staticRouteIndex = $htaccess.IndexOf('RewriteRule ^perspektivwerk/?$ perspektivwerk/index.html [L]') $staticRouteIndex = $htaccess.IndexOf('RewriteRule ^perspektivwerk/?$ perspektivwerk/index.html [L]')
$adminRouteIndex = $htaccess.IndexOf('RewriteRule ^perspektivwerk/booking-admin/?$ perspektivwerk/booking-admin/index.php [L]')
$fallbackIndex = $htaccess.IndexOf('RewriteRule ^ %{ENV:BASE}/index.php [L]') $fallbackIndex = $htaccess.IndexOf('RewriteRule ^ %{ENV:BASE}/index.php [L]')
$staticRouteIndex | Should BeGreaterThan -1 $staticRouteIndex | Should BeGreaterThan -1
$staticRouteIndex | Should BeLessThan $fallbackIndex $staticRouteIndex | Should BeLessThan $fallbackIndex
$adminRouteIndex | Should BeGreaterThan -1
$adminRouteIndex | Should BeLessThan $fallbackIndex
} }
} }
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
require __DIR__.'/../public/perspektivwerk/booking/backend.php';
function expectSame(string $expected, string $actual, string $message): void
{
if ($expected !== $actual) {
throw new RuntimeException($message."\nExpected: $expected\nActual: $actual");
}
}
function expectContains(string $needle, string $haystack, string $message): void
{
if (!str_contains($haystack, $needle)) throw new RuntimeException($message);
}
$configFile = tempnam(sys_get_temp_dir(), 'sufw-booking-config-');
if ($configFile === false) throw new RuntimeException('Temporary configuration file could not be created.');
try {
file_put_contents($configFile, "<?php\nreturn ['database_url' => 'mysql://fallback:secret@database:3306/booking'];\n");
expectSame(
'mysql://fallback:secret@database:3306/booking',
bookingDatabaseUrl('', $configFile),
'The configuration file must be used when DATABASE_URL is empty.'
);
expectSame(
'mysql://environment:secret@database:3306/booking',
bookingDatabaseUrl('mysql://environment:secret@database:3306/booking', $configFile),
'DATABASE_URL must take precedence over the configuration file.'
);
expectSame('disabled', bookingMailTransport('disabled'), 'Local mail delivery must be explicitly disabled.');
$calendar = bookingIcsCalendar([
'id' => '44c5d0f9-6c98-4b04-8d25-c32834d21a61',
'project' => 'perspektivwerk',
'title' => 'Erstgespräch PerspektivWerk',
'starts_at' => '2026-09-14 09:00:00',
'ends_at' => '2026-09-14 10:00:00',
'format' => 'vor_ort',
]);
expectContains('BEGIN:VCALENDAR', $calendar, 'The confirmation attachment must be an iCalendar document.');
expectContains('UID:44c5d0f9-6c98-4b04-8d25-c32834d21a61@sufw-booking', $calendar, 'The iCalendar attachment must contain the event UID.');
} finally {
unlink($configFile);
}
echo "booking backend configuration tests passed\n";
@@ -13,9 +13,9 @@ test('liefert eine externe, idempotente Anfangsmigration für Termine und Anmeld
test('hält die statische Vorschaukopie der Buchungsassets identisch', async () => { test('hält die statische Vorschaukopie der Buchungsassets identisch', async () => {
const [componentScript, previewScript, componentStyle, previewStyle] = await Promise.all([ const [componentScript, previewScript, componentStyle, previewStyle] = await Promise.all([
readFile(new URL('../public/booking/booking.js', import.meta.url), 'utf8'), readFile(new URL('../public/perspektivwerk/booking/booking.js', import.meta.url), 'utf8'),
readFile(new URL('../public/perspektivwerk/js/booking.js', import.meta.url), 'utf8'), readFile(new URL('../public/perspektivwerk/js/booking.js', import.meta.url), 'utf8'),
readFile(new URL('../public/booking/booking.css', import.meta.url), 'utf8'), readFile(new URL('../public/perspektivwerk/booking/booking.css', import.meta.url), 'utf8'),
readFile(new URL('../public/perspektivwerk/js/booking.css', import.meta.url), 'utf8'), readFile(new URL('../public/perspektivwerk/js/booking.css', import.meta.url), 'utf8'),
]); ]);
@@ -33,6 +33,13 @@ test('modelliert Bestätigung, Mailausgang und ICS-Feeds als zweite Migration',
assert.match(migration, /UNIQUE KEY `sufw_booking_outbox_registration_kind`/); assert.match(migration, /UNIQUE KEY `sufw_booking_outbox_registration_kind`/);
}); });
test('entfernt den Test-Mailausgang mit einer Folgemigration', async () => {
const migration = await readFile(new URL('../database/migrations/006_remove_booking_outbox.sql', import.meta.url), 'utf8');
assert.match(migration, /DROP TABLE IF EXISTS `sufw_booking_outbox`/);
assert.match(migration, /INSERT IGNORE INTO `sufw_booking_schema_migrations` \(`version`\) VALUES \('006_remove_booking_outbox'\)/);
});
test('liefert lokale Demo-Termine und bestätigte Teilnahmen vor und nach dem heutigen Datum', async () => { 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'); const migration = await readFile(new URL('../database/migrations/003_booking_demo_data.sql', import.meta.url), 'utf8');
@@ -51,13 +58,18 @@ test('erweitert Demo-Kontakte um die vorgesehenen Ansprechpartnerdaten', async (
assert.match(migration, /Sarah van den Heuvel/); assert.match(migration, /Sarah van den Heuvel/);
}); });
test('liefert die unabhängige Verwaltung unter booking-admin mit getrennten Bereichen', async () => { test('liefert zusätzliche lokale Demo-Termine für Juli und August', async () => {
const [routes, admin] = await Promise.all([ const migration = await readFile(new URL('../database/migrations/005_booking_demo_july_august.sql', import.meta.url), 'utf8');
readFile(new URL('../public/.htaccess', import.meta.url), 'utf8'),
readFile(new URL('../public/booking-admin/index.php', import.meta.url), 'utf8'), assert.match(migration, /Nur für lokale Entwicklungs- und Testdatenbanken/);
]); assert.match(migration, /-07-10 09:00:00/);
assert.match(migration, /-08-12 09:00:00/);
assert.match(migration, /sufw_booking_registrations/);
});
test('liefert die unabhängige Verwaltung unter booking-admin mit getrennten Bereichen', async () => {
const admin = await readFile(new URL('../public/perspektivwerk/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 === 'registrations'/);
assert.match(admin, /view === 'export'/); assert.match(admin, /view === 'export'/);
assert.doesNotMatch(admin, /getenv\(/); assert.doesNotMatch(admin, /getenv\(/);
@@ -68,8 +80,10 @@ test('liefert die unabhängige Verwaltung unter booking-admin mit getrennten Ber
assert.doesNotMatch(admin, /datetime-local/); assert.doesNotMatch(admin, /datetime-local/);
assert.match(admin, /data-picker-hour/); assert.match(admin, /data-picker-hour/);
assert.match(admin, /data-picker-quarter/); assert.match(admin, /data-picker-quarter/);
assert.match(admin, /<\?php if \(\$view === 'events'\): \?><div class="picker-time" data-picker-time-controls>/);
assert.match(admin, /Array\.from\(\{length:24\}/); assert.match(admin, /Array\.from\(\{length:24\}/);
assert.match(admin, /\['00','15','30','45'\]/); assert.match(admin, /\['00','15','30','45'\]/);
assert.match(admin, /defaultDate\.setHours\(9,0,0,0\)/);
assert.match(admin, /data-picker-close/); assert.match(admin, /data-picker-close/);
assert.match(admin, /<select name="title"/); assert.match(admin, /<select name="title"/);
assert.match(admin, /data-dropdown-indicator/); assert.match(admin, /data-dropdown-indicator/);
@@ -80,5 +94,6 @@ test('liefert die unabhängige Verwaltung unter booking-admin mit getrennten Ber
assert.match(admin, /id="export-project"/); assert.match(admin, /id="export-project"/);
assert.match(admin, /DATE\(e\.starts_at\) AS event_day/); assert.match(admin, /DATE\(e\.starts_at\) AS event_day/);
assert.match(admin, /export-project/); assert.match(admin, /export-project/);
assert.match(admin, /new DateTimeImmutable\('first day of next month'\)/);
assert.doesNotMatch(admin, /text\/csv|fputcsv/i); assert.doesNotMatch(admin, /text\/csv|fputcsv/i);
}); });
+1 -1
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { bookingConfirmationMessage, canSubmitBookingForm, formatDateKey, formatEventDuration, getSlotsForDate, requiresIntroductionDetails, validateBookingForm } from '../public/booking/booking.js'; import { bookingConfirmationMessage, canSubmitBookingForm, formatDateKey, formatEventDuration, getSlotsForDate, requiresIntroductionDetails, validateBookingForm } from '../public/perspektivwerk/booking/booking.js';
const events = [ const events = [
{ id: 'one', project: 'perspektivwerk', startsAt: '2026-09-14 08:00:00', endsAt: '2026-09-18 16:00:00', status: 'available' }, { id: 'one', project: 'perspektivwerk', startsAt: '2026-09-14 08:00:00', endsAt: '2026-09-18 16:00:00', status: 'available' },