Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b58d96cd5e | ||
|
|
6c16e16354 | ||
|
|
de5895f558 | ||
|
|
6ef38aa7d2 | ||
|
|
a6e6e42a43 | ||
|
|
72c032a5ca |
No files matched your search
@@ -5,6 +5,7 @@
|
||||
/public/share
|
||||
/public/system
|
||||
/public/vendor
|
||||
/public/booking-config.php
|
||||
/system
|
||||
/bin/console
|
||||
/node_modules/
|
||||
|
||||
@@ -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 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
|
||||
|
||||
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
|
||||
Apache ausgeliefert, verwendet aber dieselbe `DATABASE_URL` wie Contao. Dadurch
|
||||
funktioniert sie aus der Vorschau `/perspektivwerk/` und aus dem Contao-
|
||||
HTML-Element gleichermaßen.
|
||||
über `public/perspektivwerk/booking/api.php`. Die API wird unabhängig von
|
||||
Contao direkt durch Apache ausgeliefert. In der statischen Vorschau liegt sie
|
||||
relativ unter `/perspektivwerk/booking/api.php`.
|
||||
|
||||
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
|
||||
Anmeldungen können dadurch die Kapazität nicht überschreiten.
|
||||
|
||||
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.
|
||||
Die Demo speichert eine Anfrage zunächst als `pending_confirmation` und
|
||||
übergibt die Bestätigungsnachricht direkt an das Server-Sendmail. Der Link ist
|
||||
30 Minuten gültig; sein Aufruf bestätigt die Teilnahme atomar und löst die
|
||||
zweite Nachricht mit ICS-Anhang aus. Ohne Cronjob werden keine Erinnerungen
|
||||
verschickt. Lokal ist der Mailtransport bewusst mit
|
||||
`BOOKING_MAIL_TRANSPORT=disabled` deaktiviert.
|
||||
|
||||
Der Browser erhält ein CSRF-Token per gleicher Sitzung; die API validiert alle
|
||||
Pflichtdaten serverseitig und begrenzt Anmeldungen pro Sitzung. Dies ist eine
|
||||
@@ -145,14 +169,12 @@ nach 30 Minuten ab.
|
||||
## Eigenständiges PHP-Backend
|
||||
|
||||
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,
|
||||
- Ü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.
|
||||
- abonnierbaren ICS-Feed je Projekt.
|
||||
|
||||
Der Verwaltungsbereich hat absichtlich keine Benutzerverwaltung
|
||||
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.
|
||||
|
||||
Die Termin- und Kapazitätsdaten kommen aus `/booking/api.php`; Datenbankzugänge
|
||||
oder SQL gehören niemals in das HTML oder das Browser-JavaScript.
|
||||
Die Termin- und Kapazitätsdaten kommen in der statischen Vorschau aus
|
||||
`/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
|
||||
den statischen Upload bestimmte Kopie. Ein Test stellt sicher, dass beide
|
||||
Asset-Paare gleich bleiben. Nach Änderungen können sie lokal synchronisiert
|
||||
werden mit:
|
||||
|
||||
```powershell
|
||||
Copy-Item public\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.js public\perspektivwerk\js\booking.js -Force
|
||||
Copy-Item public\perspektivwerk\booking\booking.css public\perspektivwerk\js\booking.css -Force
|
||||
```
|
||||
|
||||
Der vorhandene SCP-Upload überträgt weiterhin nur die statische
|
||||
Perspektivwerk-Seite. Für einen externen Einsatz der Buchungsdemo müssen
|
||||
zusätzlich `/booking/api.php`, die Migrationen und die sichere
|
||||
`DATABASE_URL`-Konfiguration auf dem Zielserver bereitgestellt werden.
|
||||
Der vorhandene SCP-Upload überträgt den vollständigen statischen
|
||||
Perspektivwerk-DocumentRoot einschließlich Buchungsdemo. Vor dem externen
|
||||
Einsatz müssen die Migrationen eingespielt und `booking-config.php` mit einer
|
||||
sicheren Datenbank-URL befüllt sein.
|
||||
|
||||
## 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
|
||||
.\scripts\Deploy-Perspektivwerk.ps1
|
||||
```bat
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
erste Migration erstellt `sufw_booking_events` und
|
||||
`sufw_booking_registrations` sowie neun ungefährliche Demo-Termine für beide
|
||||
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
|
||||
Instanzen. `002_booking_workflow.sql` ergänzt Bestätigungstoken, den später
|
||||
entfernten Test-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.
|
||||
`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');
|
||||
@@ -9,8 +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
|
||||
BOOKING_BASE_URL: http://localhost:8080/perspektivwerk
|
||||
BOOKING_MAIL_TRANSPORT: disabled
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
|
||||
@@ -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>
|
||||
RewriteEngine On
|
||||
|
||||
@@ -44,9 +54,9 @@
|
||||
# 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]
|
||||
# The standalone booking administration lives below the static mockup and
|
||||
# must not be handled by Contao's front controller.
|
||||
RewriteRule ^perspektivwerk/booking-admin/?$ perspektivwerk/booking-admin/index.php [L]
|
||||
|
||||
# If the requested filename exists, simply serve it.
|
||||
# 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',
|
||||
];
|
||||
+9
-21
@@ -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'] ?? '']);
|
||||
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');
|
||||
@@ -101,21 +97,16 @@ if ($view === 'registrations') {
|
||||
$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');
|
||||
$nextMonth = new DateTimeImmutable('first day of next month');
|
||||
$defaultFrom = $nextMonth->format('Y-m-01');
|
||||
$defaultTo = $nextMonth->format('Y-m-t');
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Terminbuchung · Testverwaltung</title>
|
||||
<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}}
|
||||
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; ?>
|
||||
<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>
|
||||
<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>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'): ?>
|
||||
<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'): ?>
|
||||
@@ -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>
|
||||
<?php endif; ?>
|
||||
</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>
|
||||
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)?>;
|
||||
@@ -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; } });
|
||||
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 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('');
|
||||
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(''); }
|
||||
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 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');
|
||||
@@ -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.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-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-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();
|
||||
const exportResults=document.querySelector('#export-results');
|
||||
const escapeHtml = value => String(value).replace(/[&<>"']/g, character => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character]));
|
||||
File renamed without changes.
+72
-22
@@ -2,9 +2,25 @@
|
||||
|
||||
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
|
||||
{
|
||||
$url = getenv('DATABASE_URL');
|
||||
$url = bookingDatabaseUrl();
|
||||
$parts = is_string($url) ? parse_url($url) : false;
|
||||
if (!is_array($parts) || ($parts['scheme'] ?? '') !== 'mysql' || !isset($parts['host'], $parts['path'], $parts['user'])) {
|
||||
throw new RuntimeException('DATABASE_URL is not configured correctly.');
|
||||
@@ -39,10 +55,59 @@ 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
|
||||
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)');
|
||||
$statement->execute(compact('registrationId', 'kind', 'email', 'subject', 'body'));
|
||||
$transport = $configuredTransport ?? getenv('BOOKING_MAIL_TRANSPORT');
|
||||
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
|
||||
@@ -116,8 +181,8 @@ function bookingCreateRegistration(PDO $database, array $payload): array
|
||||
$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();
|
||||
bookingSendMail($email, $subject, $body);
|
||||
return ['registrationId' => $registrationId, 'expiresAt' => gmdate('c', time() + 1800)];
|
||||
} catch (Throwable $exception) {
|
||||
if ($database->inTransaction()) $database->rollBack();
|
||||
@@ -131,7 +196,7 @@ function bookingConfirmRegistration(PDO $database, string $token): array
|
||||
$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
|
||||
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
|
||||
WHERE r.confirmation_token_hash = :tokenHash FOR UPDATE
|
||||
SQL);
|
||||
@@ -154,8 +219,8 @@ function bookingConfirmRegistration(PDO $database, string $token): array
|
||||
$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();
|
||||
bookingSendMail($registration['email'], 'Ihr Termin ist bestätigt', $body, bookingIcsCalendar($registration));
|
||||
return $registration;
|
||||
} catch (Throwable $exception) {
|
||||
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
|
||||
{
|
||||
return str_replace(["\\", ";", ",", "\n"], ["\\\\", "\\;", "\\,", "\\n"], $value);
|
||||
File renamed without changes.
+2
-2
@@ -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 = `<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.addEventListener('close', () => dialog.remove());
|
||||
container.append(dialog);
|
||||
@@ -153,7 +153,7 @@ function openBookingDialog(container, event, state) {
|
||||
function renderComponent(container) {
|
||||
const project = container.dataset.project === 'produktionsschule' ? 'produktionsschule' : 'perspektivwerk';
|
||||
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 from = formatDateKey(state.currentMonth);
|
||||
const to = formatDateKey(new Date(state.currentMonth.getFullYear(), state.currentMonth.getMonth() + 1, 1));
|
||||
+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');
|
||||
$safeMessage = htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
$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;
|
||||
}
|
||||
|
||||
File renamed without changes.
@@ -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="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 & 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 & Belastungstest</h2><p>Arbeitsproben und praktische Erprobungen machen fachliche, soziale und arbeitsbezogene Kompetenzen sichtbar.</p></article><article><h2>3. Auswertung & 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">
|
||||
<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 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>
|
||||
|
||||
@@ -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 = `<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.addEventListener('close', () => dialog.remove());
|
||||
container.append(dialog);
|
||||
@@ -153,7 +153,7 @@ function openBookingDialog(container, event, state) {
|
||||
function renderComponent(container) {
|
||||
const project = container.dataset.project === 'produktionsschule' ? 'produktionsschule' : 'perspektivwerk';
|
||||
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 from = formatDateKey(state.currentMonth);
|
||||
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
|
||||
$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' {
|
||||
It 'provides a deployment script for the static page' {
|
||||
(Test-Path -LiteralPath $deployScriptPath) | Should Be $true
|
||||
}
|
||||
It 'deploys the complete Perspektivwerk document root and its sibling configuration file' {
|
||||
$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 = Get-Content -LiteralPath $deployScriptPath -Raw
|
||||
|
||||
$script | Should Match 'public[\\/]perspektivwerk'
|
||||
$script | Should Not Match 'public[\\/]booking'
|
||||
$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/'
|
||||
$script | Should Match 'public\\booking-config\.php'
|
||||
$script | Should Match 'public\\perspektivwerk"'
|
||||
$script | Should Match 'boogie:html/perspektivwerk/'
|
||||
$htaccess | Should Match '<Files "booking-config\.php">'
|
||||
$htaccess | Should Match 'Require all denied'
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@ $projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$mockupPath = Join-Path $projectRoot 'public\perspektivwerk\index.html'
|
||||
$bookingScriptPath = Join-Path $projectRoot 'public\perspektivwerk\js\booking.js'
|
||||
$bookingStylesPath = Join-Path $projectRoot 'public\perspektivwerk\js\booking.css'
|
||||
$contaoBookingScriptPath = Join-Path $projectRoot 'public\booking\booking.js'
|
||||
$contaoBookingStylesPath = Join-Path $projectRoot 'public\booking\booking.css'
|
||||
$deploymentBookingScriptPath = Join-Path $projectRoot 'public\perspektivwerk\booking\booking.js'
|
||||
$deploymentBookingStylesPath = Join-Path $projectRoot 'public\perspektivwerk\booking\booking.css'
|
||||
$htaccessPath = Join-Path $projectRoot 'public\.htaccess'
|
||||
|
||||
Describe 'Perspektivwerk static route' {
|
||||
@@ -17,26 +17,30 @@ Describe 'Perspektivwerk static route' {
|
||||
(Test-Path -LiteralPath $bookingScriptPath) | Should Be $true
|
||||
(Test-Path -LiteralPath $bookingStylesPath) | Should Be $true
|
||||
$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"'
|
||||
}
|
||||
|
||||
It 'keeps the Contao and static-preview asset copies identical' {
|
||||
(Test-Path -LiteralPath $contaoBookingScriptPath) | Should Be $true
|
||||
(Test-Path -LiteralPath $contaoBookingStylesPath) | Should Be $true
|
||||
(Get-FileHash -LiteralPath $bookingScriptPath).Hash | Should Be (Get-FileHash -LiteralPath $contaoBookingScriptPath).Hash
|
||||
(Get-FileHash -LiteralPath $bookingStylesPath).Hash | Should Be (Get-FileHash -LiteralPath $contaoBookingStylesPath).Hash
|
||||
It 'keeps the deployed API assets and static-preview copies identical' {
|
||||
(Test-Path -LiteralPath $deploymentBookingScriptPath) | Should Be $true
|
||||
(Test-Path -LiteralPath $deploymentBookingStylesPath) | Should Be $true
|
||||
(Get-FileHash -LiteralPath $bookingScriptPath).Hash | Should Be (Get-FileHash -LiteralPath $deploymentBookingScriptPath).Hash
|
||||
(Get-FileHash -LiteralPath $bookingStylesPath).Hash | Should Be (Get-FileHash -LiteralPath $deploymentBookingStylesPath).Hash
|
||||
}
|
||||
|
||||
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 "actions\.style\.position = 'sticky'"
|
||||
}
|
||||
|
||||
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*\{[^}]*color:\s*#fff'
|
||||
@@ -45,9 +49,12 @@ Describe 'Perspektivwerk static route' {
|
||||
It 'routes /perspektivwerk to the static entry point before the Contao fallback' {
|
||||
$htaccess = Get-Content -LiteralPath $htaccessPath -Raw
|
||||
$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]')
|
||||
|
||||
$staticRouteIndex | Should BeGreaterThan -1
|
||||
$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 () => {
|
||||
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/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'),
|
||||
]);
|
||||
|
||||
@@ -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`/);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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/);
|
||||
});
|
||||
|
||||
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'),
|
||||
]);
|
||||
test('liefert zusätzliche lokale Demo-Termine für Juli und August', async () => {
|
||||
const migration = await readFile(new URL('../database/migrations/005_booking_demo_july_august.sql', 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 === 'export'/);
|
||||
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.match(admin, /data-picker-hour/);
|
||||
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, /\['00','15','30','45'\]/);
|
||||
assert.match(admin, /defaultDate\.setHours\(9,0,0,0\)/);
|
||||
assert.match(admin, /data-picker-close/);
|
||||
assert.match(admin, /<select name="title"/);
|
||||
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, /DATE\(e\.starts_at\) AS event_day/);
|
||||
assert.match(admin, /export-project/);
|
||||
assert.match(admin, /new DateTimeImmutable\('first day of next month'\)/);
|
||||
assert.doesNotMatch(admin, /text\/csv|fputcsv/i);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
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 = [
|
||||
{ id: 'one', project: 'perspektivwerk', startsAt: '2026-09-14 08:00:00', endsAt: '2026-09-18 16:00:00', status: 'available' },
|
||||
|
||||
Reference in new issue
Block a user