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