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); }