235 lines
13 KiB
PHP
235 lines
13 KiB
PHP
<?php
|
|
|
|
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 = 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.');
|
|
}
|
|
$dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $parts['host'], (int) ($parts['port'] ?? 3306), ltrim($parts['path'], '/'));
|
|
return new PDO($dsn, rawurldecode($parts['user']), rawurldecode($parts['pass'] ?? ''), [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false]);
|
|
}
|
|
|
|
function bookingBaseUrl(): string
|
|
{
|
|
$configured = getenv('BOOKING_BASE_URL');
|
|
if (is_string($configured) && $configured !== '') {
|
|
return rtrim($configured, '/');
|
|
}
|
|
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
|
return $scheme.'://'.($_SERVER['HTTP_HOST'] ?? 'localhost');
|
|
}
|
|
|
|
function bookingUuid(): string
|
|
{
|
|
$hex = bin2hex(random_bytes(16));
|
|
return sprintf('%s-%s-%s-%s-%s', substr($hex, 0, 8), substr($hex, 8, 4), substr($hex, 12, 4), substr($hex, 16, 4), substr($hex, 20));
|
|
}
|
|
|
|
function bookingProjectIsValid(string $project): bool
|
|
{
|
|
return in_array($project, ['perspektivwerk', 'produktionsschule'], true);
|
|
}
|
|
|
|
function bookingProjectTitle(string $project): string
|
|
{
|
|
return $project === 'produktionsschule' ? 'Produktionsschule' : 'PerspektivWerk';
|
|
}
|
|
|
|
function bookingMailTransport(?string $configuredTransport = null): string
|
|
{
|
|
$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
|
|
{
|
|
$event['capacity'] = (int) $event['capacity'];
|
|
$event['confirmedCount'] = (int) $event['confirmedCount'];
|
|
$event['availableCapacity'] = max(0, $event['capacity'] - $event['confirmedCount']);
|
|
$event['status'] = $event['availableCapacity'] > 0 ? 'available' : 'full';
|
|
$event['introductionRequired'] = (bool) $event['introductionRequired'];
|
|
unset($event['confirmedCount']);
|
|
return $event;
|
|
}
|
|
|
|
function bookingListEvents(PDO $database, string $project, string $from, string $to): array
|
|
{
|
|
$statement = $database->prepare(<<<'SQL'
|
|
SELECT e.id, e.project, e.title, e.starts_at AS startsAt, e.ends_at AS endsAt, e.capacity,
|
|
e.format, e.introduction_required AS introductionRequired, e.contact_name AS contactName,
|
|
COUNT(r.id) AS confirmedCount
|
|
FROM sufw_booking_events e
|
|
LEFT JOIN sufw_booking_registrations r ON r.event_id = e.id AND r.status = 'confirmed'
|
|
WHERE e.project = :project AND e.is_active = 1 AND e.starts_at < :toDate AND e.ends_at >= :fromDate
|
|
GROUP BY e.id ORDER BY e.starts_at, e.title
|
|
SQL);
|
|
$statement->execute(['project' => $project, 'fromDate' => "$from 00:00:00", 'toDate' => "$to 00:00:00"]);
|
|
return array_map('bookingNormaliseEvent', $statement->fetchAll());
|
|
}
|
|
|
|
function bookingCreateRegistration(PDO $database, array $payload): array
|
|
{
|
|
$project = (string) ($payload['project'] ?? '');
|
|
$eventId = (string) ($payload['eventId'] ?? '');
|
|
$name = trim((string) ($payload['name'] ?? ''));
|
|
$email = mb_strtolower(trim((string) ($payload['email'] ?? '')));
|
|
$phone = trim((string) ($payload['phone'] ?? ''));
|
|
$privacyAccepted = ($payload['privacyAccepted'] ?? false) === true;
|
|
$introductionAttended = ($payload['introductionAttended'] ?? false) === true;
|
|
$introductionDate = trim((string) ($payload['introductionDate'] ?? ''));
|
|
if (!bookingProjectIsValid($project) || !preg_match('/^[a-f0-9-]{36}$/', $eventId) || $name === '' || mb_strlen($name) > 160 || !filter_var($email, FILTER_VALIDATE_EMAIL) || mb_strlen($email) > 254 || !preg_match('/^[0-9+()\-\/\s]{6,64}$/', $phone) || !$privacyAccepted) {
|
|
throw new InvalidArgumentException('Bitte prüfen Sie Ihre Pflichtangaben.');
|
|
}
|
|
if ($introductionDate !== '' && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $introductionDate) || !DateTimeImmutable::createFromFormat('!Y-m-d', $introductionDate))) {
|
|
throw new InvalidArgumentException('Bitte prüfen Sie das Datum der Einführungsveranstaltung.');
|
|
}
|
|
|
|
$database->beginTransaction();
|
|
try {
|
|
$eventStatement = $database->prepare('SELECT * FROM sufw_booking_events WHERE id = :id AND project = :project AND is_active = 1 FOR UPDATE');
|
|
$eventStatement->execute(['id' => $eventId, 'project' => $project]);
|
|
$event = $eventStatement->fetch();
|
|
if (!$event) throw new OutOfBoundsException('Dieser Termin ist nicht mehr verfügbar.');
|
|
if ((bool) $event['introduction_required'] && (!$introductionAttended || $introductionDate === '')) {
|
|
throw new InvalidArgumentException('Bitte bestätigen Sie die Einführungsveranstaltung und geben Sie das Datum an.');
|
|
}
|
|
$confirmed = $database->prepare("SELECT COUNT(*) FROM sufw_booking_registrations WHERE event_id = :eventId AND status = 'confirmed'");
|
|
$confirmed->execute(['eventId' => $eventId]);
|
|
if ((int) $confirmed->fetchColumn() >= (int) $event['capacity']) throw new RuntimeException('Dieser Termin ist inzwischen ausgebucht.');
|
|
$duplicate = $database->prepare("SELECT id FROM sufw_booking_registrations WHERE event_id = :eventId AND email = :email AND name = :name AND status IN ('pending_confirmation', 'confirmed') LIMIT 1");
|
|
$duplicate->execute(['eventId' => $eventId, 'email' => $email, 'name' => $name]);
|
|
if ($duplicate->fetch()) throw new LogicException('Für diese Person liegt bereits eine Anmeldung für diesen Termin vor.');
|
|
|
|
$registrationId = bookingUuid();
|
|
$token = bin2hex(random_bytes(32));
|
|
$insert = $database->prepare(<<<'SQL'
|
|
INSERT INTO sufw_booking_registrations
|
|
(id, event_id, status, name, email, phone, privacy_accepted_at, confirmation_token_hash, confirmation_expires_at, introduction_attended, introduction_date)
|
|
VALUES
|
|
(:id, :eventId, 'pending_confirmation', :name, :email, :phone, UTC_TIMESTAMP(), :tokenHash, DATE_ADD(UTC_TIMESTAMP(), INTERVAL 30 MINUTE), :introductionAttended, :introductionDate)
|
|
SQL);
|
|
$insert->execute(['id' => $registrationId, 'eventId' => $eventId, 'name' => $name, 'email' => $email, 'phone' => $phone, 'tokenHash' => hash('sha256', $token), 'introductionAttended' => $introductionAttended ? 1 : 0, 'introductionDate' => $introductionAttended ? $introductionDate : null]);
|
|
$link = bookingBaseUrl().'/booking/confirm.php?token='.rawurlencode($token);
|
|
$subject = 'Bitte bestätigen Sie Ihre Terminanfrage';
|
|
$body = "Guten Tag $name,\n\nbitte bestätigen Sie Ihre Anfrage für \"{$event['title']}\" am {$event['starts_at']} innerhalb von 30 Minuten:\n$link\n\nKontakt für Rückfragen: {$event['contact_name']} ({$event['contact_email']}).";
|
|
$database->commit();
|
|
bookingSendMail($email, $subject, $body);
|
|
return ['registrationId' => $registrationId, 'expiresAt' => gmdate('c', time() + 1800)];
|
|
} catch (Throwable $exception) {
|
|
if ($database->inTransaction()) $database->rollBack();
|
|
throw $exception;
|
|
}
|
|
}
|
|
|
|
function bookingConfirmRegistration(PDO $database, string $token): array
|
|
{
|
|
if (!preg_match('/^[a-f0-9]{64}$/', $token)) throw new InvalidArgumentException('Der Bestätigungslink ist ungültig.');
|
|
$database->beginTransaction();
|
|
try {
|
|
$statement = $database->prepare(<<<'SQL'
|
|
SELECT r.*, e.title, e.project, e.starts_at, e.ends_at, e.capacity, e.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);
|
|
$statement->execute(['tokenHash' => hash('sha256', $token)]);
|
|
$registration = $statement->fetch();
|
|
if (!$registration || $registration['status'] !== 'pending_confirmation') throw new OutOfBoundsException('Dieser Bestätigungslink ist nicht mehr gültig.');
|
|
if (strtotime($registration['confirmation_expires_at'].' UTC') < time()) {
|
|
$database->prepare("UPDATE sufw_booking_registrations SET status = 'expired' WHERE id = :id")->execute(['id' => $registration['id']]);
|
|
$database->commit();
|
|
throw new OutOfBoundsException('Die Bestätigungsfrist ist abgelaufen. Bitte fragen Sie den Termin erneut an.');
|
|
}
|
|
$count = $database->prepare("SELECT COUNT(*) FROM sufw_booking_registrations WHERE event_id = :eventId AND status = 'confirmed'");
|
|
$count->execute(['eventId' => $registration['event_id']]);
|
|
if ((int) $count->fetchColumn() >= (int) $registration['capacity']) throw new RuntimeException('Der Termin wurde inzwischen vergeben. Bitte wählen Sie einen anderen Termin.');
|
|
$database->prepare("UPDATE sufw_booking_registrations SET status = 'confirmed', confirmed_at = UTC_TIMESTAMP(), confirmation_token_hash = NULL WHERE id = :id")->execute(['id' => $registration['id']]);
|
|
$history = $database->prepare(<<<'SQL'
|
|
SELECT e.title, e.starts_at FROM sufw_booking_registrations r JOIN sufw_booking_events e ON e.id = r.event_id
|
|
WHERE r.email = :email AND r.name = :name AND r.status = 'confirmed' ORDER BY e.starts_at
|
|
SQL);
|
|
$history->execute(['email' => $registration['email'], 'name' => $registration['name']]);
|
|
$appointments = implode("\n", array_map(static fn (array $row): string => "- {$row['title']} am {$row['starts_at']}", $history->fetchAll()));
|
|
$body = "Guten Tag {$registration['name']},\n\nIhr Termin ist bestätigt:\n{$registration['title']} am {$registration['starts_at']} bis {$registration['ends_at']}.\n\nIhre weiteren bestätigten Termine:\n$appointments\n\nBei Stornierungswunsch wenden Sie sich an {$registration['contact_name']} ({$registration['contact_email']}).";
|
|
$database->commit();
|
|
bookingSendMail($registration['email'], 'Ihr Termin ist bestätigt', $body, bookingIcsCalendar($registration));
|
|
return $registration;
|
|
} catch (Throwable $exception) {
|
|
if ($database->inTransaction()) $database->rollBack();
|
|
throw $exception;
|
|
}
|
|
}
|
|
|
|
function bookingIcsEscape(string $value): string
|
|
{
|
|
return str_replace(["\\", ";", ",", "\n"], ["\\\\", "\\;", "\\,", "\\n"], $value);
|
|
}
|